mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-09 17:20:38 -10:00
Compare commits
170
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6087b8b8e8 | ||
|
|
2c9a6470ff | ||
|
|
cf51b57924 | ||
|
|
e7740e4032 | ||
|
|
bdadecdf91 | ||
|
|
178fc9eaba | ||
|
|
17e3cd2b39 | ||
|
|
4796265b5c | ||
|
|
1e9cf1c447 | ||
|
|
7610fc3f80 | ||
|
|
a2405c083a | ||
|
|
ee2585e7e9 | ||
|
|
999eb86408 | ||
|
|
8f85f35ebe | ||
|
|
1e8551ad0b | ||
|
|
27ed0515e8 | ||
|
|
ac360b6143 | ||
|
|
4418f49c35 | ||
|
|
c5cb217c7c | ||
|
|
a553dafa7a | ||
|
|
cbdc177bbc | ||
|
|
c0f8557f05 | ||
|
|
d4b354732c | ||
|
|
53bebe4836 | ||
|
|
290abd303e | ||
|
|
bde19959b9 | ||
|
|
92cbdd0cb8 | ||
|
|
8fe09eba7f | ||
|
|
9d670e6262 | ||
|
|
504d8592d2 | ||
|
|
4f1feebbd0 | ||
|
|
4a620122e8 | ||
|
|
f9a7c68897 | ||
|
|
28baa4a57a | ||
|
|
a5fb30e04f | ||
|
|
f2b8db548f | ||
|
|
89475f4fed | ||
|
|
2bc5e8d0c8 | ||
|
|
79e4233c56 | ||
|
|
df6ed5ccba | ||
|
|
f592ff5b05 | ||
|
|
c0c9553550 | ||
|
|
059fd8a69b | ||
|
|
696377acc2 | ||
|
|
52834088fe | ||
|
|
c3d29dc842 | ||
|
|
8dc1ee062f | ||
|
|
fcc36d67e7 | ||
|
|
00108a669e | ||
|
|
c08b0f543e | ||
|
|
7f9dda50b6 | ||
|
|
98c8c55b9e | ||
|
|
8fabf054ef | ||
|
|
a3cd99db1f | ||
|
|
0660f3c020 | ||
|
|
2754cf327f | ||
|
|
986b0ade58 | ||
|
|
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 |
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
|
||||
|
||||
|
||||
@@ -70,6 +70,12 @@ jobs:
|
||||
- name: Linux x64
|
||||
os: ubuntu-22.04
|
||||
target: x86_64-unknown-linux-gnu
|
||||
- name: Linux arm64
|
||||
os: ubuntu-22.04-arm
|
||||
target: aarch64-unknown-linux-gnu
|
||||
- name: Windows arm64
|
||||
os: windows-11-arm
|
||||
target: aarch64-pc-windows-msvc
|
||||
- name: Windows x64
|
||||
os: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
@@ -196,11 +202,16 @@ jobs:
|
||||
}
|
||||
// Bundle suffix → updater platform keys, as tauri-action names them.
|
||||
const kinds = [
|
||||
[/_aarch64\.AppImage$/, ["linux-aarch64", "linux-aarch64-appimage"]],
|
||||
[/_arm64\.deb$/, ["linux-aarch64-deb"]],
|
||||
[/\.aarch64\.rpm$/, ["linux-aarch64-rpm"]],
|
||||
[/_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"]],
|
||||
[/_arm64_en-US\.msi$/, ["windows-aarch64", "windows-aarch64-msi"]],
|
||||
[/_arm64-setup\.exe$/, ["windows-aarch64-nsis"]],
|
||||
[/_x64_en-US\.msi$/, ["windows-x86_64", "windows-x86_64-msi"]],
|
||||
[/_x64-setup\.exe$/, ["windows-x86_64-nsis"]],
|
||||
];
|
||||
|
||||
@@ -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
+419
-144
File diff suppressed because it is too large.
Load diff
+8
-1
@@ -3,7 +3,7 @@ resolver = "3"
|
||||
members = ["crates/*", "src-tauri"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.24"
|
||||
version = "0.1.29"
|
||||
edition = "2024"
|
||||
rust-version = "1.96"
|
||||
license = "Apache-2.0"
|
||||
@@ -40,6 +40,7 @@ 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"] }
|
||||
@@ -75,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"
|
||||
@@ -85,6 +87,9 @@ 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"
|
||||
@@ -92,6 +97,8 @@ reqwest = { version = "0.13", default-features = false, features = ["rustls-no-p
|
||||
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).
|
||||
|
||||
+15
-2
@@ -12,12 +12,13 @@ icon set, copied unmodified (some renamed) from JetBrains' open-source repositor
|
||||
`python/python-requirements` (`fileTypes/requirements`); `python/pipenv`
|
||||
(`fileTypes/pipenv`); `python/python-pyproject` (`expui/toml.svg` →
|
||||
`fileTypes/pyprojectToml`); `plugins/kotlin/base` (`fileTypes/kotlin`,
|
||||
`kotlinScript`, `kotlinGradleScript`); `java/openapi` (`fileTypes/maven`);
|
||||
`kotlinScript`, `kotlinGradleScript`, `icons/expui/objectKotlin` → `kotlin/objectKotlin`); `java/openapi` (`fileTypes/maven`);
|
||||
`plugins/markdown` (`fileTypes/markdown`); `plugins/toml` (`fileTypes/toml`);
|
||||
`plugins/terminal` (`toolwindows/terminal`); `plugins/maven`
|
||||
(`images/expui/toolwindow/maven` → `toolwindows/maven`, `images/expui/build/maven*`
|
||||
→ `maven/`); `plugins/gradle` (`icons/expui/gradle` → `toolwindows/gradle`,
|
||||
`icons/expui/gradleLoadChanges` → `build/gradleLoadChanges`);
|
||||
`icons/expui/gradleLoadChanges` → `build/gradleLoadChanges`,
|
||||
`icons/expui/gradle`, `icons/gradleSubproject` → `gradle/`);
|
||||
`platform/external-system-impl` (`icons/newui/task` → `externalSystem/task`).
|
||||
Copyright 2000-2026 JetBrains s.r.o. and contributors. Apache License 2.0 (below).
|
||||
- [intellij-plugins](https://github.com/JetBrains/intellij-plugins) (each plugin
|
||||
@@ -111,6 +112,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() }],
|
||||
|
||||
@@ -37,6 +37,16 @@ pub(crate) fn bypass_approvals(provider: &str, config: &[AgentConfigOption]) ->
|
||||
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> {
|
||||
|
||||
@@ -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};
|
||||
@@ -116,9 +117,13 @@ impl Shared {
|
||||
fn known_config(&self, provider: &str) -> Vec<AgentConfigOption> {
|
||||
let mut config = self.known.lock().get(provider).map(|k| k.config.clone()).unwrap_or_default();
|
||||
config::add_client_options(provider, &mut config);
|
||||
// Approval bypass belongs to the conversation, never to another new task.
|
||||
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("ask".into());
|
||||
o.current = Some("bypass".into());
|
||||
}
|
||||
config
|
||||
}
|
||||
@@ -314,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) => {
|
||||
@@ -325,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);
|
||||
}
|
||||
@@ -353,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?;
|
||||
@@ -379,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(),
|
||||
@@ -539,6 +560,10 @@ 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();
|
||||
@@ -748,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);
|
||||
@@ -1017,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,
|
||||
@@ -1037,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,
|
||||
@@ -1183,7 +1233,6 @@ impl Task {
|
||||
(if known { reported } else { s.task.config.clone() }, s.wanted.clone())
|
||||
};
|
||||
config::add_client_options(&provider_id, &mut options);
|
||||
self.shared.remember(&provider_id, Some(&options), None);
|
||||
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 };
|
||||
@@ -1212,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());
|
||||
@@ -1629,6 +1679,16 @@ 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`].
|
||||
@@ -240,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>,
|
||||
@@ -361,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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,27 @@ 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();
|
||||
// Windows: read afresh, so a tool the user installed since the IDE started is found too.
|
||||
newest_install_in(command, &ide_core::process::with_user_path(&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 +51,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 +65,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 +75,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 +93,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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -222,6 +222,13 @@ fn choose<'a>(entry: &'a Entry, platform: Platform, on_path: &dyn Fn(&str) -> bo
|
||||
{
|
||||
return Some(Choice::Uvx(p));
|
||||
}
|
||||
// Last resort: npx and uvx run natively, this one under OS emulation.
|
||||
if platform.os == Os::Windows
|
||||
&& platform.arch == Arch::Arm64
|
||||
&& let Some(b) = d.binary.get("windows-x86_64")
|
||||
{
|
||||
return Some(Choice::Binary(b));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
@@ -335,7 +342,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() })
|
||||
}
|
||||
|
||||
@@ -689,6 +696,20 @@ mod tests {
|
||||
const LINUX: Platform = Platform { os: Os::Linux, arch: Arch::X64 };
|
||||
const WINDOWS: Platform = Platform { os: Os::Windows, arch: Arch::X64 };
|
||||
|
||||
#[test]
|
||||
fn windows_arm_binary_prefers_native_then_x64() {
|
||||
let mut entry = parse(INDEX).unwrap().remove(0);
|
||||
let arm = Platform { os: Os::Windows, arch: Arch::Arm64 };
|
||||
assert!(matches!(choose(&entry, arm, &|_| false), Some(Choice::Binary(_))));
|
||||
assert!(choose(&entry, Platform { os: Os::Linux, arch: Arch::Arm64 }, &|_| false).is_none());
|
||||
entry.distribution.npx = Some(serde_json::from_str(r#"{"package":"amp-acp"}"#).unwrap());
|
||||
assert!(matches!(choose(&entry, arm, &|_| true), Some(Choice::Npx(_))));
|
||||
let mut native = entry.distribution.binary["windows-x86_64"].clone();
|
||||
native.cmd = "native.exe".into();
|
||||
entry.distribution.binary.insert("windows-aarch64".into(), native);
|
||||
assert!(matches!(choose(&entry, arm, &|_| false), Some(Choice::Binary(b)) if b.cmd == "native.exe"));
|
||||
}
|
||||
|
||||
const INDEX: &str = r#"{"version":"1.0.0","agents":[
|
||||
{"id":"amp-acp","name":"Amp","version":"0.10.0","description":"Amp over ACP",
|
||||
"repository":"https://github.com/x/amp-acp","authors":["x"],"license":"Apache-2.0",
|
||||
|
||||
@@ -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.
|
||||
@@ -727,6 +778,7 @@ async fn cursor_approval_switch_resolves_pending_and_survives_restart() {
|
||||
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(),
|
||||
@@ -752,15 +804,17 @@ async fn cursor_approval_switch_resolves_pending_and_survives_restart() {
|
||||
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")));
|
||||
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;
|
||||
// An unrelated new conversation still asks, even after the cache was updated.
|
||||
// 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();
|
||||
}
|
||||
|
||||
@@ -770,6 +824,7 @@ async fn cursor_approval_choice_applies_before_first_prompt() {
|
||||
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() }],
|
||||
@@ -781,3 +836,109 @@ async fn cursor_approval_choice_applies_before_first_prompt() {
|
||||
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(
|
||||
@@ -42,14 +58,27 @@ pub(crate) fn run(
|
||||
cwd: &Path,
|
||||
env: &BTreeMap<String, String>,
|
||||
cancel: &CancellationToken,
|
||||
on_line: impl FnMut(Stream, &str),
|
||||
) -> IdeResult<Output> {
|
||||
run_with_env(program, args, cwd, env, false, cancel, on_line)
|
||||
}
|
||||
|
||||
pub(crate) fn run_with_env(
|
||||
program: &str,
|
||||
args: &[String],
|
||||
cwd: &Path,
|
||||
env: &BTreeMap<String, String>,
|
||||
clear_env: bool,
|
||||
cancel: &CancellationToken,
|
||||
mut on_line: impl FnMut(Stream, &str),
|
||||
) -> IdeResult<Output> {
|
||||
tracing::info!("build: {program} {} (in {})", args.join(" "), cwd.display());
|
||||
// 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 mut command = Command::new(program);
|
||||
command.args(args).current_dir(cwd).envs(env).envs(encoding.env).stdin(Stdio::null());
|
||||
let program = program_path(program, env);
|
||||
let mut command = Command::new(&program);
|
||||
command.args(args).current_dir(cwd).envs(env).envs(encoding.env.iter().map(|(k, v)| (k, v))).stdin(Stdio::null());
|
||||
command.stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
#[cfg(unix)]
|
||||
{
|
||||
@@ -57,7 +86,11 @@ pub(crate) fn run(
|
||||
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}")))?;
|
||||
if clear_env {
|
||||
command.env_clear().envs(env).envs(encoding.env);
|
||||
}
|
||||
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 || {
|
||||
@@ -68,7 +101,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;
|
||||
|
||||
@@ -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,268 @@
|
||||
//! WAR modules and build-tool-owned archive resolution (including custom output names).
|
||||
use crate::BuildModel;
|
||||
use ide_core::{CancellationToken, 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>,
|
||||
maven_project: Option<(Vec<String>, std::path::PathBuf)>,
|
||||
}
|
||||
|
||||
impl WebBuild {
|
||||
/// Resolve the selected module's effective identity using the build's options and environment.
|
||||
/// Runs off the UI thread, before launching the reactor build.
|
||||
pub fn resolve_project(
|
||||
&mut self,
|
||||
root: &Path,
|
||||
env: &BTreeMap<String, String>,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<()> {
|
||||
let Some((args, output)) = &self.maven_project else {
|
||||
return Ok(());
|
||||
};
|
||||
if output.exists() {
|
||||
fs::remove_file(output)?;
|
||||
}
|
||||
let cwd = root.join(&self.cwd).canonicalize()?;
|
||||
let mut env = env.clone();
|
||||
if !env.iter().any(|(k, v)| k.eq_ignore_ascii_case("MAVEN_BASEDIR") && !v.is_empty()) {
|
||||
// -f changes Maven's .mvn search origin; keep the reactor's launcher settings.
|
||||
let base = cwd.ancestors().find(|p| p.join(".mvn").is_dir()).unwrap_or(&cwd);
|
||||
env.retain(|k, _| !k.eq_ignore_ascii_case("MAVEN_BASEDIR"));
|
||||
env.insert("MAVEN_BASEDIR".into(), base.to_string_lossy().into_owned());
|
||||
}
|
||||
let result = crate::process::run_with_env(&self.command, args, &cwd, &env, true, cancel, |_, _| {})?;
|
||||
if !result.success {
|
||||
return Err(IdeError::invalid(format!("Cannot resolve Maven WAR artifact: {}", result.text.trim())));
|
||||
}
|
||||
let id = fs::read_to_string(output)?;
|
||||
let parts: Vec<_> = id.trim().split(':').collect();
|
||||
if parts.len() != 4 || parts[2] != "war" || parts.iter().any(|s| s.is_empty()) {
|
||||
return Err(IdeError::invalid(format!("Cannot resolve Maven WAR artifact: {}", id.trim())));
|
||||
}
|
||||
let project = format!("{}:{}:{}", parts[0], parts[1], parts[3]);
|
||||
self.args.push(format!("-Dexpression=session.projectMap({project}).artifact.file"));
|
||||
self.maven_project = None;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn groovy_literal(s: &str) -> String {
|
||||
format!("'{}'", s.replace('\\', "\\\\").replace('\'', "\\'"))
|
||||
}
|
||||
|
||||
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()]);
|
||||
}
|
||||
// help:evaluate runs once, in the reactor's first project. Query the selected
|
||||
// POM's effective identity first; profiles and -D options can change its GAV.
|
||||
let output = cache.join("artifact-project.txt");
|
||||
let mut probe = top.options.clone();
|
||||
probe.extend([
|
||||
"-f".into(),
|
||||
root.join(&p.pom).to_string_lossy().into_owned(),
|
||||
"help:evaluate".into(),
|
||||
"-Dexpression=project.id".into(),
|
||||
format!("-Doutput={}", output.display()),
|
||||
]);
|
||||
args.extend([
|
||||
"-DskipTests".into(),
|
||||
"package".into(),
|
||||
"help:evaluate".into(),
|
||||
format!("-Doutput={}", manifest.display()),
|
||||
]);
|
||||
return Ok(WebBuild {
|
||||
command: top.command.clone(),
|
||||
args,
|
||||
cwd: top.dir.clone(),
|
||||
env: p.env.clone(),
|
||||
maven_project: Some((probe, output)),
|
||||
});
|
||||
}
|
||||
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 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({}).setText(warTask.get().archiveFile.get().asFile.absolutePath, 'UTF-8') }}
|
||||
}}
|
||||
}}
|
||||
"#,
|
||||
groovy_literal(path),
|
||||
groovy_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(),
|
||||
maven_project: None,
|
||||
});
|
||||
}
|
||||
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();
|
||||
let (probe, _) = plan.maven_project.as_ref().unwrap();
|
||||
assert!(probe.contains(&"-Dexpression=project.id".into()));
|
||||
assert!(probe.contains(&tmp.path().join("pom.xml").to_string_lossy().into_owned()));
|
||||
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"]);
|
||||
assert!(
|
||||
plan.maven_project
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.0
|
||||
.contains(&tmp.path().join("web/pom.xml").to_string_lossy().into_owned())
|
||||
);
|
||||
}
|
||||
#[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 }"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradle_manifest_paths_preserve_windows_characters() {
|
||||
for path in [r"C:\工作 区\Tomcat's cache\artifact-path.txt", r"\\server\share\artifact-path.txt"] {
|
||||
let literal = groovy_literal(path);
|
||||
assert_eq!(literal[1..literal.len() - 1].replace("\\'", "'").replace("\\\\", "\\"), path);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradle_root_and_nested_modules_write_utf8_archive_paths() {
|
||||
for module in [":", ":apps:timesheet"] {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = if module == ":" { "" } else { "apps/timesheet" };
|
||||
fs::create_dir_all(tmp.path().join(dir)).unwrap();
|
||||
fs::write(tmp.path().join("settings.gradle"), "rootProject.name = 'timesheet'\ninclude 'apps:timesheet'")
|
||||
.unwrap();
|
||||
fs::write(tmp.path().join(dir).join("build.gradle"), "plugins { id 'war' }").unwrap();
|
||||
let model = crate::load(tmp.path(), &tmp.path().join("cache"), None);
|
||||
let cache = tmp.path().join("工作 cache");
|
||||
let plan = build(tmp.path(), &model, &format!("gradle:{module}"), &cache).unwrap();
|
||||
assert!(plan.args.contains(&format!("{}:openideaTomcatArtifact", module.trim_end_matches(':'))));
|
||||
let script = fs::read_to_string(cache.join("war.init.gradle")).unwrap();
|
||||
assert!(script.contains(&format!("rootProject.project('{}')", module)));
|
||||
assert!(script.contains("archiveFile.get().asFile.absolutePath, 'UTF-8'"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
dunce.workspace = true
|
||||
encoding_rs.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
specta.workspace = true
|
||||
@@ -16,3 +17,6 @@ tokio-util.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.61", features = ["Win32_Globalization", "Win32_System_Registry", "Win32_Foundation"] }
|
||||
@@ -12,6 +12,8 @@ use std::collections::BTreeMap;
|
||||
pub struct EditorGeneralSettings {
|
||||
/// Change font size with ⌘ + mouse wheel.
|
||||
pub wheel_zoom: bool,
|
||||
pub markdown_image_folder: String,
|
||||
pub markdown_image_name_pattern: String,
|
||||
/// Drag-and-drop of selected text inside the editor.
|
||||
pub drag_and_drop: bool,
|
||||
/// When the caret moves out of view: `minimize` scrolling | `center` the caret line.
|
||||
@@ -35,6 +37,8 @@ impl Default for EditorGeneralSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
wheel_zoom: false,
|
||||
markdown_image_folder: "assets".into(),
|
||||
markdown_image_name_pattern: "image-{timestamp}".into(),
|
||||
drag_and_drop: true,
|
||||
caret_scroll: "minimize".into(),
|
||||
soft_wrap_files: "*.md; *.txt; *.rst; *.adoc".into(),
|
||||
@@ -434,6 +438,8 @@ pub struct EncodingSettings {
|
||||
pub project: String,
|
||||
/// Fallback for `.properties` files.
|
||||
pub properties: String,
|
||||
/// Display properties Unicode escapes as characters and save non-ASCII as escapes.
|
||||
pub native_to_ascii: bool,
|
||||
/// Create UTF-8 files: `never` (no BOM) | `always` (with BOM) | `windows` (with BOM on Windows).
|
||||
pub utf8_bom: String,
|
||||
}
|
||||
@@ -444,6 +450,7 @@ impl Default for EncodingSettings {
|
||||
global: "utf-8".into(),
|
||||
project: "utf-8".into(),
|
||||
properties: "iso-8859-1".into(),
|
||||
native_to_ascii: false,
|
||||
utf8_bom: "never".into(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,12 +13,22 @@ 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,
|
||||
ProjectTrustChanged {
|
||||
root: crate::IpcPath,
|
||||
trusted: bool,
|
||||
},
|
||||
/// Files or directories changed on disk (after debouncing, ignore rules applied).
|
||||
FsChanged {
|
||||
created: Vec<crate::IpcPath>,
|
||||
modified: Vec<crate::IpcPath>,
|
||||
removed: Vec<crate::IpcPath>,
|
||||
},
|
||||
/// Excluded or ignored folders whose contents changed; they stay out of `FsChanged`.
|
||||
ExcludedDirsChanged {
|
||||
dirs: Vec<crate::IpcPath>,
|
||||
},
|
||||
/// The backend changed a document (reload from disk, formatting, refactoring).
|
||||
/// The UI applies `changes` on top of `base_version`, yielding `version`.
|
||||
DocPatched {
|
||||
@@ -32,10 +42,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 +125,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.
|
||||
|
||||
@@ -20,6 +20,7 @@ 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;
|
||||
|
||||
|
||||
@@ -18,6 +18,60 @@
|
||||
|
||||
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";
|
||||
@@ -237,6 +291,29 @@ impl NoticeFilter {
|
||||
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",
|
||||
|
||||
@@ -218,10 +218,129 @@ 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()))
|
||||
}
|
||||
|
||||
/// The user's own `PATH` entries on Windows (`HKCU\Environment`), the part of the
|
||||
/// merged `PATH` a process may not inherit — an IDE started before the variable was
|
||||
/// edited, or as a different user, sees only the machine entries. `%VAR%` references
|
||||
/// (`REG_EXPAND_SZ`) are expanded against the current environment. `None` elsewhere:
|
||||
/// there a login shell already supplies the user's whole `PATH`.
|
||||
#[cfg(windows)]
|
||||
pub fn user_path() -> Option<OsString> {
|
||||
use windows_sys::Win32::Foundation::ERROR_SUCCESS;
|
||||
use windows_sys::Win32::System::Registry::{
|
||||
HKEY, HKEY_CURRENT_USER, KEY_READ, RRF_RT_REG_EXPAND_SZ, RRF_RT_REG_SZ, RegCloseKey, RegGetValueW,
|
||||
RegOpenKeyExW,
|
||||
};
|
||||
|
||||
fn wide(s: &str) -> Vec<u16> {
|
||||
s.encode_utf16().chain(std::iter::once(0)).collect()
|
||||
}
|
||||
|
||||
unsafe {
|
||||
let mut key: HKEY = std::ptr::null_mut();
|
||||
if RegOpenKeyExW(HKEY_CURRENT_USER, wide("Environment").as_ptr(), 0, KEY_READ, &mut key) != ERROR_SUCCESS {
|
||||
return None;
|
||||
}
|
||||
let read = || {
|
||||
// Only `Path`; `REG_EXPAND_SZ` is accepted so `%USERPROFILE%\…` entries expand.
|
||||
let mut kind = 0u32;
|
||||
let mut len = 0u32;
|
||||
let name = wide("Path");
|
||||
let status = RegGetValueW(
|
||||
key,
|
||||
std::ptr::null(),
|
||||
name.as_ptr(),
|
||||
RRF_RT_REG_SZ | RRF_RT_REG_EXPAND_SZ,
|
||||
&mut kind,
|
||||
std::ptr::null_mut(),
|
||||
&mut len,
|
||||
);
|
||||
if status != ERROR_SUCCESS || len == 0 {
|
||||
return None;
|
||||
}
|
||||
let mut buf = vec![0u16; (len as usize).div_ceil(2)];
|
||||
let status = RegGetValueW(
|
||||
key,
|
||||
std::ptr::null(),
|
||||
name.as_ptr(),
|
||||
RRF_RT_REG_SZ | RRF_RT_REG_EXPAND_SZ,
|
||||
&mut kind,
|
||||
buf.as_mut_ptr() as *mut _,
|
||||
&mut len,
|
||||
);
|
||||
if status != ERROR_SUCCESS {
|
||||
return None;
|
||||
}
|
||||
let text = String::from_utf16_lossy(&buf);
|
||||
Some(text.trim_end_matches('\0').to_owned())
|
||||
};
|
||||
let value = read();
|
||||
RegCloseKey(key);
|
||||
value.filter(|v| !v.trim().is_empty()).map(OsString::from)
|
||||
}
|
||||
}
|
||||
|
||||
/// The user's own `PATH` entries; `None` outside Windows (see the Windows impl).
|
||||
#[cfg(not(windows))]
|
||||
pub fn user_path() -> Option<OsString> {
|
||||
None
|
||||
}
|
||||
|
||||
/// `path` followed by the [`user_path`] entries it lacks.
|
||||
pub fn with_user_path(path: &OsStr) -> OsString {
|
||||
user_path().map_or_else(|| path.to_owned(), |user| append_path_entries(path, &user))
|
||||
}
|
||||
|
||||
fn append_path_entries(path: &OsStr, extra: &OsStr) -> OsString {
|
||||
let mut dirs: Vec<std::path::PathBuf> = std::env::split_paths(path).filter(|d| !d.as_os_str().is_empty()).collect();
|
||||
for dir in std::env::split_paths(extra).filter(|d| !d.as_os_str().is_empty()) {
|
||||
if !dirs.contains(&dir) {
|
||||
dirs.push(dir);
|
||||
}
|
||||
}
|
||||
std::env::join_paths(dirs).unwrap_or_else(|_| path.to_owned())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn user_path_reads_the_current_users_path() {
|
||||
// This host has a user `Path` only when the test account set one; either way the call
|
||||
// must not fail, and a present value is a non-empty `;`-joined list of directories.
|
||||
if let Some(path) = user_path() {
|
||||
let dirs: Vec<_> = std::env::split_paths(&path).collect();
|
||||
assert!(!dirs.is_empty(), "user PATH should hold at least one directory");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_path_entries_keeps_the_order_and_adds_what_is_missing() {
|
||||
let join = |dirs: &[&str]| std::env::join_paths(dirs).unwrap();
|
||||
assert_eq!(append_path_entries(&join(&["a", "b"]), &join(&["b", "c"])), join(&["a", "b", "c"]));
|
||||
assert_eq!(append_path_entries(OsStr::new(""), &join(&["c"])), join(&["c"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_env_adds_only_what_the_process_lacks_and_the_overlay_wins() {
|
||||
set_shell_env(vec![("OPENIDEA_SHELL_ONLY_VAR".into(), "1".into()), ("PATH".into(), "/from/shell".into())]);
|
||||
@@ -363,4 +482,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);
|
||||
}
|
||||
}
|
||||
@@ -103,10 +103,19 @@ impl Default for DeploymentSettings {
|
||||
}
|
||||
}
|
||||
|
||||
/// An SFTP server and how project folders map onto it.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum DeploymentType {
|
||||
#[default]
|
||||
Sftp,
|
||||
Local,
|
||||
}
|
||||
|
||||
/// A deployment filesystem and how project folders map onto it.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct DeploymentServer {
|
||||
pub kind: DeploymentType,
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
/// [`SshConfiguration::id`].
|
||||
@@ -148,3 +157,23 @@ pub struct RunTarget {
|
||||
pub node: String,
|
||||
pub java: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod deployment_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn deployment_type_defaults_and_round_trips() {
|
||||
let legacy: DeploymentServer =
|
||||
serde_json::from_str(r#"{"id":"existing","sshConfig":"ssh","rootPath":"/srv"}"#).unwrap();
|
||||
assert_eq!(legacy.kind, DeploymentType::Sftp);
|
||||
assert_eq!(legacy.ssh_config, "ssh");
|
||||
for kind in [DeploymentType::Sftp, DeploymentType::Local] {
|
||||
let server = DeploymentServer { kind, ..legacy.clone() };
|
||||
assert_eq!(
|
||||
serde_json::from_str::<DeploymentServer>(&serde_json::to_string(&server).unwrap()).unwrap(),
|
||||
server
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -178,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,
|
||||
@@ -285,6 +285,8 @@ pub struct AppearanceSettings {
|
||||
pub project_colors_in_toolbar: bool,
|
||||
pub dnd_with_alt_only: bool,
|
||||
pub menu_icons: bool,
|
||||
/// `hamburger` | `merged`: main menu presentation in the frameless title bar.
|
||||
pub main_menu_style: String,
|
||||
/// `subpixel` | `greyscale` | `none`
|
||||
pub ide_antialiasing: String,
|
||||
pub editor_antialiasing: String,
|
||||
@@ -331,6 +333,7 @@ impl Default for AppearanceSettings {
|
||||
project_colors_in_toolbar: true,
|
||||
dnd_with_alt_only: false,
|
||||
menu_icons: true,
|
||||
main_menu_style: "hamburger".into(),
|
||||
ide_antialiasing: "greyscale".into(),
|
||||
editor_antialiasing: "greyscale".into(),
|
||||
show_tool_window_bars: true,
|
||||
@@ -342,6 +345,15 @@ impl Default for AppearanceSettings {
|
||||
}
|
||||
}
|
||||
|
||||
/// Imported editor colour scheme on top of a built-in base.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CustomColorScheme {
|
||||
pub name: String,
|
||||
pub parent: String,
|
||||
pub attributes: BTreeMap<String, TextAttributesOverride>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct EditorSettings {
|
||||
@@ -357,6 +369,8 @@ pub struct EditorSettings {
|
||||
pub format_on_save: bool,
|
||||
/// Editor colour scheme id; `theme-default` follows the IDE theme.
|
||||
pub color_scheme: String,
|
||||
/// Imported editor schemes, stored in user settings.
|
||||
pub custom_schemes: BTreeMap<String, CustomColorScheme>,
|
||||
/// Color Scheme edits: scheme id → attribute id → override.
|
||||
pub color_overrides: BTreeMap<String, BTreeMap<String, TextAttributesOverride>>,
|
||||
/// Color Scheme › Language Defaults › Semantic highlighting: local variables and
|
||||
@@ -404,6 +418,7 @@ impl Default for EditorSettings {
|
||||
format_on_save: false,
|
||||
color_scheme: "theme-default".into(),
|
||||
color_overrides: BTreeMap::new(),
|
||||
custom_schemes: BTreeMap::new(),
|
||||
semantic_highlighting: false,
|
||||
rainbow_brackets: false,
|
||||
ligatures: false,
|
||||
@@ -434,6 +449,8 @@ impl Default for EditorSettings {
|
||||
pub struct FileSettings {
|
||||
/// Save on focus loss / tab switch, like IDEA.
|
||||
pub auto_save: bool,
|
||||
/// Re-check open files against disk when the IDE window gains focus.
|
||||
pub sync_on_focus: bool,
|
||||
pub exclude: Vec<String>,
|
||||
/// Ignored Files and Folders: name patterns hidden everywhere; see [`crate::ignored`].
|
||||
pub ignored_names: Vec<String>,
|
||||
@@ -465,6 +482,7 @@ impl Default for FileSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
auto_save: true,
|
||||
sync_on_focus: true,
|
||||
exclude: vec!["**/.git".into(), "**/node_modules".into(), "**/target".into()],
|
||||
ignored_names: crate::ignored::DEFAULT_IGNORED_NAMES.iter().map(|s| (*s).into()).collect(),
|
||||
trim_trailing_whitespace: true,
|
||||
@@ -546,6 +564,35 @@ pub struct GoSettings {
|
||||
pub goroot: String,
|
||||
/// Empty = `go env GOPATH`.
|
||||
pub gopath: String,
|
||||
pub build_tags: GoBuildTags,
|
||||
pub modules: GoModules,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct GoModules {
|
||||
pub env: BTreeMap<String, String>,
|
||||
pub auto_vendor: bool,
|
||||
}
|
||||
|
||||
impl Default for GoModules {
|
||||
fn default() -> Self {
|
||||
Self { env: BTreeMap::new(), auto_vendor: true }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct GoBuildTags {
|
||||
/// Empty values inherit the selected toolchain's defaults.
|
||||
pub os: String,
|
||||
pub arch: String,
|
||||
pub version: String,
|
||||
pub compiler: String,
|
||||
/// Empty = automatic, "1" = enabled, "0" = disabled.
|
||||
pub cgo: String,
|
||||
pub custom: String,
|
||||
pub experiments: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
|
||||
@@ -563,12 +610,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`.
|
||||
@@ -647,6 +719,15 @@ impl Settings {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn main_menu_style_defaults_and_round_trips() {
|
||||
let old: Settings = serde_json::from_str(r#"{"appearance":{"compactMode":true}}"#).unwrap();
|
||||
assert_eq!(old.appearance.main_menu_style, "hamburger");
|
||||
let merged: Settings = serde_json::from_str(r#"{"appearance":{"mainMenuStyle":"merged"}}"#).unwrap();
|
||||
assert_eq!(merged.appearance.main_menu_style, "merged");
|
||||
assert_eq!(serde_json::to_value(merged).unwrap()["appearance"]["mainMenuStyle"], "merged");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_font_weights_default_to_regular_and_bold() {
|
||||
let json = serde_json::to_value(Settings::default()).unwrap();
|
||||
@@ -691,3 +772,57 @@ 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod go_module_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn old_go_settings_inherit_module_defaults() {
|
||||
let old: GoSettings = serde_json::from_str(r#"{"goroot":"/go"}"#).unwrap();
|
||||
assert!(old.modules.auto_vendor);
|
||||
assert!(old.modules.env.is_empty());
|
||||
let configured: GoSettings =
|
||||
serde_json::from_str(r#"{"modules":{"autoVendor":false,"env":{"GOPRIVATE":"example.com/private"}}}"#)
|
||||
.unwrap();
|
||||
assert!(!configured.modules.auto_vendor);
|
||||
assert_eq!(configured.modules.env["GOPRIVATE"], "example.com/private");
|
||||
}
|
||||
}
|
||||
@@ -99,6 +99,44 @@ pub struct WorkspaceEdit {
|
||||
pub files: Vec<FileEdit>,
|
||||
}
|
||||
|
||||
impl WorkspaceEdit {
|
||||
/// Coalesces aliases of the same file before applying edits against its original text.
|
||||
/// Resource operations separate batches because later edits may use the new contents.
|
||||
pub fn deduplicate(&mut self) {
|
||||
let mut files = Vec::new();
|
||||
let mut batch = std::collections::HashMap::<std::path::PathBuf, usize>::new();
|
||||
let mut resolve_aliases = true;
|
||||
for file in std::mem::take(&mut self.files) {
|
||||
match file {
|
||||
FileEdit::Edit { path, edits } => {
|
||||
let path = if resolve_aliases {
|
||||
dunce::canonicalize(&path).unwrap_or_else(|_| path.to_path_buf())
|
||||
} else {
|
||||
path.to_path_buf()
|
||||
};
|
||||
let index = *batch.entry(path.clone()).or_insert_with(|| {
|
||||
files.push(FileEdit::Edit { path: path.into(), edits: Vec::new() });
|
||||
files.len() - 1
|
||||
});
|
||||
let FileEdit::Edit { edits: own, .. } = &mut files[index] else { unreachable!() };
|
||||
for edit in edits {
|
||||
if !own.contains(&edit) {
|
||||
own.push(edit);
|
||||
}
|
||||
}
|
||||
}
|
||||
op => {
|
||||
batch.clear();
|
||||
// Later paths refer to a filesystem state the resource operation has yet to create.
|
||||
resolve_aliases = false;
|
||||
files.push(op);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.files = files;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum FileEdit {
|
||||
@@ -203,3 +241,34 @@ pub struct OutlineItem {
|
||||
|
||||
/// Fuzzy-match highlight: indices (in chars of the displayed string) that matched.
|
||||
pub type MatchIndices = Vec<u32>;
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod workspace_edit_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn aliases_are_one_edit_but_resource_operations_preserve_order() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let real = dir.path().join("main.cpp");
|
||||
let alias = dir.path().join("alias.cpp");
|
||||
std::fs::write(&real, "add()").unwrap();
|
||||
std::os::unix::fs::symlink(&real, &alias).unwrap();
|
||||
let change = TextEdit { range: Range::new(Pos::new(0, 0), Pos::new(0, 3)), new_text: "sum_values".into() };
|
||||
let edit = |path: PathBuf| FileEdit::Edit { path: path.into(), edits: vec![change.clone()] };
|
||||
let mut workspace = WorkspaceEdit {
|
||||
files: vec![
|
||||
edit(real.clone()),
|
||||
edit(alias),
|
||||
FileEdit::Create { path: dir.path().join("other").into(), overwrite: false },
|
||||
edit(real),
|
||||
],
|
||||
};
|
||||
workspace.deduplicate();
|
||||
assert_eq!(workspace.files.len(), 3);
|
||||
let FileEdit::Edit { edits, .. } = &workspace.files[0] else { panic!() };
|
||||
assert_eq!(edits, &[change]);
|
||||
let once = workspace.clone();
|
||||
workspace.deduplicate();
|
||||
assert_eq!(workspace, once);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
//! 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();
|
||||
// The Windows user `PATH` may not reach a process started before it was set; append its
|
||||
// entries after the inherited ones so missing user tools still resolve.
|
||||
let user_path = crate::process::user_path();
|
||||
let dirs = search_dirs(
|
||||
host_var_os("PATH").as_deref(),
|
||||
shell_path.as_deref(),
|
||||
user_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>,
|
||||
user_path: Option<&OsStr>,
|
||||
home: Option<&Path>,
|
||||
shims: &[PathBuf],
|
||||
) -> Vec<PathBuf> {
|
||||
let mut dirs: Vec<PathBuf> =
|
||||
process_path.into_iter().chain(shell_path).chain(user_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",
|
||||
// `dotnet tool install --global` puts fsautocomplete, csharp-ls and friends here.
|
||||
".dotnet/tools",
|
||||
".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"];
|
||||
|
||||
/// Whether `dir` is one of the system's own bin directories, which [`search_path`] adds
|
||||
/// whatever `PATH` holds.
|
||||
pub fn is_system_dir(dir: &Path) -> bool {
|
||||
SYSTEM_DIRS.iter().any(|d| Path::new(d) == dir)
|
||||
}
|
||||
|
||||
/// 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")),
|
||||
None,
|
||||
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"));
|
||||
// Global .NET tools (fsautocomplete, csharp-ls) install here, off PATH.
|
||||
assert!(at("/home/me/.dotnet/tools") < at("/usr/local/bin"));
|
||||
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, None, Some(Path::new("/home/a:b")), &[]);
|
||||
assert!(dirs.contains(&PathBuf::from("/usr/bin")) && !dirs.iter().any(|d| d.starts_with("/home")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_dirs_append_user_path_entries_once_after_the_inherited_path() {
|
||||
let (system, dup, usertool) =
|
||||
if cfg!(windows) { ("C:/system", "C:/dup", "C:/usertool") } else { ("/system", "/dup", "/usertool") };
|
||||
let home = Path::new(if cfg!(windows) { "C:/home/me" } else { "/home/me" });
|
||||
let join = |dirs: &[&str]| std::env::join_paths(dirs.iter().map(PathBuf::from)).unwrap();
|
||||
let inherited = join(&[system, dup]);
|
||||
let user = join(&[dup, usertool]);
|
||||
let dirs = search_dirs(Some(inherited.as_os_str()), None, Some(user.as_os_str()), Some(home), &[]);
|
||||
let at = |d: &str| dirs.iter().position(|p| p == Path::new(d)).unwrap();
|
||||
// User entries follow the inherited PATH, de-duplicated, ahead of the fallbacks.
|
||||
assert_eq!(dirs.iter().filter(|d| *d == Path::new(dup)).count(), 1);
|
||||
assert!(at(dup) < at(usertool));
|
||||
// And before this platform's home/system fallbacks.
|
||||
let fallback = dirs.iter().position(|d| d == &home.join(USER_DIRS[0])).unwrap();
|
||||
assert!(at(usertool) < fallback);
|
||||
}
|
||||
|
||||
#[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"]);
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
@@ -342,8 +348,17 @@ pub struct AdapterIo {
|
||||
pub port: Option<u16>,
|
||||
/// Recent stderr lines of the adapter process, for error messages.
|
||||
pub stderr: Arc<parking_lot::Mutex<Vec<String>>>,
|
||||
/// (category, line) printed by the debuggee when it shares the adapter's stdio.
|
||||
pub program_output: Option<ProgramOutput>,
|
||||
}
|
||||
|
||||
pub type ProgramOutput = tokio::sync::mpsc::UnboundedReceiver<(&'static str, String)>;
|
||||
type ProgramSink = tokio::sync::mpsc::UnboundedSender<(&'static str, String)>;
|
||||
|
||||
/// Delve's debuggee inherits its stdio; no DAP output event carries what the program prints.
|
||||
const SHARES_STDIO: &str = "delve";
|
||||
const DELVE_BANNER: &str = "DAP server listening at:";
|
||||
|
||||
pub type LaunchFuture = Pin<Box<dyn Future<Output = IdeResult<AdapterIo>> + Send>>;
|
||||
|
||||
/// Starts adapters. Tests substitute in-memory adapters.
|
||||
@@ -379,6 +394,7 @@ impl AdapterLauncher for ProcessLauncher {
|
||||
child: None,
|
||||
port: Some(port),
|
||||
stderr,
|
||||
program_output: None,
|
||||
});
|
||||
}
|
||||
let embedded = match spec.connect.as_deref().filter(|_| spec.transport == Transport::Tcp) {
|
||||
@@ -401,6 +417,7 @@ impl AdapterLauncher for ProcessLauncher {
|
||||
child: None,
|
||||
port: Some(*port),
|
||||
stderr,
|
||||
program_output: None,
|
||||
});
|
||||
}
|
||||
if spec.adapter == DEBUGPY_TOOL {
|
||||
@@ -439,8 +456,14 @@ impl AdapterLauncher for ProcessLauncher {
|
||||
.kill_on_drop(true);
|
||||
let mut child =
|
||||
cmd.spawn().map_err(|e| IdeError::unavailable(format!("failed to start {}: {e}", binary.display())))?;
|
||||
let (program_sink, program_output) = if spec.adapter == SHARES_STDIO {
|
||||
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
(Some(tx), Some(rx))
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
if let Some(err) = child.stderr.take() {
|
||||
pump_lines(err, spec.adapter.clone(), stderr.clone());
|
||||
pump_lines(err, spec.adapter.clone(), stderr.clone(), program_sink.clone().map(|tx| (tx, "stderr")));
|
||||
}
|
||||
match port {
|
||||
None => {
|
||||
@@ -452,11 +475,12 @@ impl AdapterLauncher for ProcessLauncher {
|
||||
child: Some(child),
|
||||
port: None,
|
||||
stderr,
|
||||
program_output,
|
||||
})
|
||||
}
|
||||
Some(port) => {
|
||||
if let Some(out) = child.stdout.take() {
|
||||
pump_lines(out, spec.adapter.clone(), stderr.clone());
|
||||
pump_lines(out, spec.adapter.clone(), stderr.clone(), program_sink.map(|tx| (tx, "stdout")));
|
||||
}
|
||||
let timeout = if embedded.is_some() { EMBEDDED_START_TIMEOUT } else { CONNECT_TIMEOUT };
|
||||
let stream = match connect_with_retries(LOOPBACK, port, Some(&mut child), timeout).await {
|
||||
@@ -478,6 +502,7 @@ impl AdapterLauncher for ProcessLauncher {
|
||||
child: Some(child),
|
||||
port: Some(port),
|
||||
stderr,
|
||||
program_output,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -489,9 +514,15 @@ fn pump_lines<R: AsyncRead + Unpin + Send + 'static>(
|
||||
stream: R,
|
||||
name: String,
|
||||
sink: Arc<parking_lot::Mutex<Vec<String>>>,
|
||||
program: Option<(ProgramSink, &'static str)>,
|
||||
) {
|
||||
tokio::spawn(ide_jsonrpc::output::read_lines(stream, move |line| {
|
||||
tracing::debug!(target: "ide_dap::adapter", "[{name}] {line}");
|
||||
if let Some((tx, category)) = &program
|
||||
&& !line.starts_with(DELVE_BANNER)
|
||||
{
|
||||
let _ = tx.send((category, format!("{line}\n")));
|
||||
}
|
||||
push_line(&sink, line);
|
||||
}));
|
||||
}
|
||||
@@ -543,6 +574,18 @@ async fn connect_with_retries(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn program_output_is_forwarded_without_the_adapter_banner() {
|
||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let sink = Arc::default();
|
||||
let text: &[u8] = b"DAP server listening at: 127.0.0.1:1\n[hello two words]\nno newline";
|
||||
pump_lines(text, "delve".into(), Arc::clone(&sink), Some((tx, "stdout")));
|
||||
assert_eq!(rx.recv().await, Some(("stdout", "[hello two words]\n".into())));
|
||||
assert_eq!(rx.recv().await, Some(("stdout", "no newline\n".into())));
|
||||
assert_eq!(rx.recv().await, None);
|
||||
assert_eq!(sink.lock().len(), 3);
|
||||
}
|
||||
|
||||
/// Managed tools win over `PATH`, and debugpy falls back to the private copy.
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -687,6 +697,14 @@ impl DebugManager {
|
||||
};
|
||||
let session =
|
||||
Arc::new(Session::new(id, owner, name, adapter_name, parent, request, conn, spec, io.port, io.child));
|
||||
if let Some(mut output) = io.program_output {
|
||||
let session = session.clone();
|
||||
tokio::spawn(async move {
|
||||
while let Some((category, output)) = output.recv().await {
|
||||
session.emit(ide_core::event::DebugEvent::Output { category: category.into(), output });
|
||||
}
|
||||
});
|
||||
}
|
||||
self.sessions.lock().insert(id, session.clone());
|
||||
self.order.lock().push(id);
|
||||
// Announced before the event loop runs, so it precedes every adapter event.
|
||||
@@ -722,7 +740,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 +779,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 +834,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 +857,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 +872,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 +931,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 +949,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(())
|
||||
}
|
||||
+135
-4
@@ -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 {
|
||||
@@ -30,7 +32,14 @@ impl AdapterLauncher for FakeLauncher {
|
||||
tokio::spawn(fake_adapter(server, log, script));
|
||||
let (r, w) = tokio::io::split(client);
|
||||
Box::pin(async move {
|
||||
Ok(AdapterIo { reader: Box::new(r), writer: Box::new(w), child: None, port: None, stderr: Arc::default() })
|
||||
Ok(AdapterIo {
|
||||
reader: Box::new(r),
|
||||
writer: Box::new(w),
|
||||
child: None,
|
||||
port: None,
|
||||
stderr: Arc::default(),
|
||||
program_output: None,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -130,7 +139,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 +150,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 +904,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 +1027,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
|
||||
|
||||
|
||||
+409
-21
@@ -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");
|
||||
@@ -197,6 +379,37 @@ 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) => {{
|
||||
@@ -313,6 +526,7 @@ impl Pool {
|
||||
Pool::Sqlite(_) => DbDriver::Sqlite,
|
||||
Pool::Postgres(_) => DbDriver::Postgres,
|
||||
Pool::Mysql(_) => DbDriver::Mysql,
|
||||
Pool::SqlServer(_) => DbDriver::SqlServer,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -453,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: 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, Cells::Text));
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -485,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)
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -509,6 +789,7 @@ pub enum Held {
|
||||
Sqlite(PoolConnection<Sqlite>),
|
||||
Postgres(PoolConnection<Postgres>),
|
||||
Mysql(PoolConnection<MySql>),
|
||||
SqlServer(Box<bb8::PooledConnection<'static, MssqlConnectionManager>>),
|
||||
}
|
||||
|
||||
impl Held {
|
||||
@@ -517,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
|
||||
@@ -542,6 +830,9 @@ impl Held {
|
||||
Held::Mysql(c) => {
|
||||
let _ = c.detach().close().await;
|
||||
}
|
||||
Held::SqlServer(mut c) => {
|
||||
c.0.take();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -589,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;
|
||||
@@ -608,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",
|
||||
}
|
||||
}
|
||||
@@ -624,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() }
|
||||
}
|
||||
|
||||
@@ -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,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));
|
||||
}
|
||||
|
||||
+57
-15
@@ -4,13 +4,14 @@
|
||||
//! 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 once per run and cached; the first read can take a few
|
||||
//! hundred milliseconds, so callers run it off the UI and command threads.
|
||||
//! 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::OnceLock;
|
||||
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).
|
||||
@@ -21,10 +22,23 @@ pub struct FontFamily {
|
||||
pub monospaced: bool,
|
||||
}
|
||||
|
||||
/// The installed families, sorted case-insensitively, read on first use.
|
||||
pub fn families() -> &'static [FontFamily] {
|
||||
static CACHE: OnceLock<Vec<FontFamily>> = OnceLock::new();
|
||||
CACHE.get_or_init(|| normalize(imp::list()))
|
||||
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
|
||||
@@ -79,7 +93,7 @@ mod imp {
|
||||
};
|
||||
|
||||
/// Every face CoreText makes available to this process, as (family, fixed pitch).
|
||||
pub(crate) fn list() -> Vec<(String, bool)> {
|
||||
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 {
|
||||
@@ -116,15 +130,15 @@ mod imp {
|
||||
|
||||
/// 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() -> Vec<(String, bool)> {
|
||||
pub(crate) fn list(refresh: bool) -> Vec<(String, bool)> {
|
||||
// SAFETY: plain DirectWrite COM calls on interfaces we own.
|
||||
unsafe { read() }.unwrap_or_default()
|
||||
unsafe { read(refresh) }.unwrap_or_default()
|
||||
}
|
||||
|
||||
unsafe fn read() -> windows::core::Result<Vec<(String, bool)>> {
|
||||
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, false) }?;
|
||||
unsafe { factory.GetSystemFontCollection(&mut collection, refresh) }?;
|
||||
let Some(collection) = collection else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
@@ -171,7 +185,7 @@ mod imp {
|
||||
|
||||
/// fontconfig's view, which WebKitGTK resolves names with. Empty when
|
||||
/// `fc-list` isn't installed.
|
||||
pub(crate) fn list() -> Vec<(String, bool)> {
|
||||
pub(crate) fn list(_refresh: bool) -> Vec<(String, bool)> {
|
||||
Command::new("fc-list")
|
||||
.args(["-f", "%{family[0]}\t%{spacing}\n"])
|
||||
.stdin(Stdio::null())
|
||||
@@ -186,7 +200,7 @@ mod imp {
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
mod imp {
|
||||
pub(crate) fn list() -> Vec<(String, bool)> {
|
||||
pub(crate) fn list(_refresh: bool) -> Vec<(String, bool)> {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
@@ -194,6 +208,7 @@ mod imp {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
fn fam(name: &str, monospaced: bool) -> FontFamily {
|
||||
FontFamily { name: name.into(), monospaced }
|
||||
@@ -243,12 +258,38 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[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();
|
||||
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));
|
||||
@@ -260,5 +301,6 @@ mod tests {
|
||||
assert!(menlo.monospaced);
|
||||
assert!(list.iter().any(|f| f.name == "Helvetica Neue" && !f.monospaced));
|
||||
}
|
||||
assert_eq!(&*families(true), &*list);
|
||||
}
|
||||
}
|
||||
@@ -107,7 +107,7 @@ pub fn availability(root: &Path) -> GhAvailability {
|
||||
}
|
||||
let (repo, login) = std::thread::scope(|s| {
|
||||
let repo = s.spawn(|| {
|
||||
let cmd = command(root, &["repo", "view", "--json", "nameWithOwner,url,defaultBranchRef"])?;
|
||||
let cmd = command(root, &["repo", "view", "--json", REPO_FIELDS])?;
|
||||
process::run(cmd, QUICK, None)
|
||||
});
|
||||
let login = s.spawn(|| run(root, &["api", "user", "--jq", ".login"]));
|
||||
@@ -278,6 +278,53 @@ pub fn add_comment(root: &Path, number: u32, body: &str) -> IdeResult<()> {
|
||||
run(root, &["pr", "comment", &n, "--body", body]).map(drop)
|
||||
}
|
||||
|
||||
/// A GitHub API call through `gh api`: GraphQL (`endpoint` `graphql`, `body`
|
||||
/// `{"query", "variables"}`) or REST (`repos/{owner}/{repo}/…`, gh fills in the
|
||||
/// placeholders from the project's remote).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GhApiRequest {
|
||||
/// `GET`, `POST`, `PATCH`, `PUT`, `DELETE`.
|
||||
pub method: String,
|
||||
pub endpoint: String,
|
||||
/// JSON request body, sent on stdin.
|
||||
pub body: Option<String>,
|
||||
/// Follow REST `Link` pages; the result is the array of all pages (`--slurp`).
|
||||
pub paginate: bool,
|
||||
}
|
||||
|
||||
const METHODS: &[&str] = &["GET", "POST", "PATCH", "PUT", "DELETE"];
|
||||
|
||||
/// `gh api` arguments for `req`.
|
||||
pub fn api_args(req: &GhApiRequest) -> IdeResult<Vec<String>> {
|
||||
let method = req.method.to_ascii_uppercase();
|
||||
if !METHODS.contains(&method.as_str()) {
|
||||
return Err(IdeError::invalid(format!("unsupported HTTP method {}", req.method)));
|
||||
}
|
||||
let endpoint = req.endpoint.trim();
|
||||
if endpoint.is_empty() || endpoint.starts_with('-') || endpoint.contains("://") {
|
||||
return Err(IdeError::invalid(format!("not a GitHub API endpoint: {endpoint}")));
|
||||
}
|
||||
let mut args: Vec<String> = vec!["api".into(), "--method".into(), method, endpoint.into()];
|
||||
if req.body.is_some() {
|
||||
args.push("--input".into());
|
||||
args.push("-".into());
|
||||
}
|
||||
if req.paginate {
|
||||
args.push("--paginate".into());
|
||||
args.push("--slurp".into());
|
||||
}
|
||||
Ok(args)
|
||||
}
|
||||
|
||||
/// Runs `req`; returns the response body (JSON text, empty for `204 No Content`).
|
||||
pub fn api(root: &Path, req: &GhApiRequest) -> IdeResult<String> {
|
||||
let args = api_args(req)?;
|
||||
let refs: Vec<&str> = args.iter().map(String::as_str).collect();
|
||||
let out = process::run_with_input(command(root, &refs)?, req.body.as_deref(), QUICK, None)?;
|
||||
if out.success { Ok(out.stdout) } else { Err(failure(&out.stderr, &out.stdout)) }
|
||||
}
|
||||
|
||||
/// Revisions to diff a pull request's files with, present in the local repository.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
||||
@@ -34,6 +34,12 @@ pub struct GhRepo {
|
||||
pub name_with_owner: String,
|
||||
pub url: String,
|
||||
pub default_branch: Option<String>,
|
||||
/// Merge methods the repository allows (all of them when gh does not say).
|
||||
pub merge_commit_allowed: bool,
|
||||
pub squash_merge_allowed: bool,
|
||||
pub rebase_merge_allowed: bool,
|
||||
/// The signed-in user's permission: `ADMIN`, `MAINTAIN`, `WRITE`, `TRIAGE`, `READ`; empty when unknown.
|
||||
pub viewer_permission: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
@@ -152,6 +158,8 @@ pub struct PullRequestDetails {
|
||||
pub base_oid: String,
|
||||
/// `MERGEABLE`, `CONFLICTING`, `UNKNOWN`.
|
||||
pub mergeable: String,
|
||||
/// GitHub's merge box state: `CLEAN`, `UNSTABLE`, `HAS_HOOKS`, `BLOCKED`, `BEHIND`, `DIRTY`, `DRAFT`, `UNKNOWN`.
|
||||
pub merge_state_status: String,
|
||||
pub is_cross_repository: bool,
|
||||
pub merged_at: Option<String>,
|
||||
pub closed_at: Option<String>,
|
||||
@@ -242,7 +250,7 @@ impl RawSummary {
|
||||
pub const LIST_FIELDS: &str = "number,title,author,state,isDraft,headRefName,baseRefName,createdAt,updatedAt,labels,reviewDecision,url,additions,deletions";
|
||||
|
||||
/// Fields requested from `gh pr view`.
|
||||
pub const VIEW_FIELDS: &str = "number,title,author,state,isDraft,headRefName,baseRefName,createdAt,updatedAt,labels,reviewDecision,url,additions,deletions,body,headRefOid,baseRefOid,mergeable,isCrossRepository,mergedAt,closedAt,statusCheckRollup,reviewRequests,reviews,assignees,commits,files,comments";
|
||||
pub const VIEW_FIELDS: &str = "number,title,author,state,isDraft,headRefName,baseRefName,createdAt,updatedAt,labels,reviewDecision,url,additions,deletions,body,headRefOid,baseRefOid,mergeable,mergeStateStatus,isCrossRepository,mergedAt,closedAt,statusCheckRollup,reviewRequests,reviews,assignees,commits,files,comments";
|
||||
|
||||
pub fn parse_list(json: &str) -> serde_json::Result<Vec<PullRequestSummary>> {
|
||||
let raw: Vec<RawSummary> = serde_json::from_str(json)?;
|
||||
@@ -348,6 +356,7 @@ struct RawDetails {
|
||||
head_ref_oid: String,
|
||||
base_ref_oid: String,
|
||||
mergeable: String,
|
||||
merge_state_status: String,
|
||||
is_cross_repository: bool,
|
||||
merged_at: Option<String>,
|
||||
closed_at: Option<String>,
|
||||
@@ -442,6 +451,7 @@ pub fn parse_view(json: &str) -> serde_json::Result<PullRequestDetails> {
|
||||
head_oid: raw.head_ref_oid,
|
||||
base_oid: raw.base_ref_oid,
|
||||
mergeable: raw.mergeable,
|
||||
merge_state_status: raw.merge_state_status,
|
||||
is_cross_repository: raw.is_cross_repository,
|
||||
merged_at: raw.merged_at.filter(|s| !s.is_empty()),
|
||||
closed_at: raw.closed_at.filter(|s| !s.is_empty()),
|
||||
@@ -534,6 +544,10 @@ struct RawRepo {
|
||||
name_with_owner: String,
|
||||
url: String,
|
||||
default_branch_ref: Option<RawBranchRef>,
|
||||
merge_commit_allowed: Option<bool>,
|
||||
squash_merge_allowed: Option<bool>,
|
||||
rebase_merge_allowed: Option<bool>,
|
||||
viewer_permission: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
@@ -542,13 +556,21 @@ struct RawBranchRef {
|
||||
name: String,
|
||||
}
|
||||
|
||||
/// `gh repo view --json nameWithOwner,url,defaultBranchRef`.
|
||||
/// Fields requested from `gh repo view`.
|
||||
pub const REPO_FIELDS: &str =
|
||||
"nameWithOwner,url,defaultBranchRef,mergeCommitAllowed,squashMergeAllowed,rebaseMergeAllowed,viewerPermission";
|
||||
|
||||
/// `gh repo view --json` with [`REPO_FIELDS`].
|
||||
pub fn parse_repo(json: &str) -> serde_json::Result<GhRepo> {
|
||||
let raw: RawRepo = serde_json::from_str(json)?;
|
||||
Ok(GhRepo {
|
||||
name_with_owner: raw.name_with_owner,
|
||||
url: raw.url,
|
||||
default_branch: raw.default_branch_ref.map(|b| b.name).filter(|n| !n.is_empty()),
|
||||
merge_commit_allowed: raw.merge_commit_allowed.unwrap_or(true),
|
||||
squash_merge_allowed: raw.squash_merge_allowed.unwrap_or(true),
|
||||
rebase_merge_allowed: raw.rebase_merge_allowed.unwrap_or(true),
|
||||
viewer_permission: raw.viewer_permission.unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -60,9 +60,30 @@ fn read_all(mut pipe: impl Read + Send + 'static) -> std::thread::JoinHandle<Str
|
||||
|
||||
/// Runs `cmd` to completion. Fails when it outlives `timeout` or `cancel` fires;
|
||||
/// a non-zero exit is reported in [`Output::success`], not as an error.
|
||||
pub(crate) fn run(mut cmd: Command, timeout: Duration, cancel: Option<&CancellationToken>) -> IdeResult<Output> {
|
||||
pub(crate) fn run(cmd: Command, timeout: Duration, cancel: Option<&CancellationToken>) -> IdeResult<Output> {
|
||||
run_with_input(cmd, None, timeout, cancel)
|
||||
}
|
||||
|
||||
/// [`run`] with `input` written to the child's stdin (then closed).
|
||||
pub(crate) fn run_with_input(
|
||||
mut cmd: Command,
|
||||
input: Option<&str>,
|
||||
timeout: Duration,
|
||||
cancel: Option<&CancellationToken>,
|
||||
) -> IdeResult<Output> {
|
||||
prepare(&mut cmd);
|
||||
if input.is_some() {
|
||||
cmd.stdin(Stdio::piped());
|
||||
}
|
||||
let mut child = spawn(&mut cmd)?;
|
||||
if let (Some(input), Some(mut stdin)) = (input, child.stdin.take()) {
|
||||
let input = input.to_owned();
|
||||
// A writer thread: a large body must not block on a full pipe while gh is not reading yet.
|
||||
std::thread::spawn(move || {
|
||||
use std::io::Write;
|
||||
let _ = stdin.write_all(input.as_bytes());
|
||||
});
|
||||
}
|
||||
let out = child.stdout.take().map(read_all);
|
||||
let err = child.stderr.take().map(read_all);
|
||||
let deadline = Instant::now() + timeout;
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"additions":42,"assignees":[],"author":{"id":"MDQ6VXNlcjM2NzI4OTMx","is_bot":false,"login":"babakks","name":"Babak K. Shandiz"},"baseRefName":"trunk","baseRefOid":"ae7b9715976c5508398b11dc8546e1279e10f6fc","body":"### Description\n\nThis PR moves gh to the tagged version of `cli/oauth`.","closedAt":"2026-10-21T12:29:58Z","comments":[{"id":"IC_1","author":{"login":"babakks"},"authorAssociation":"MEMBER","body":"Rebased on trunk.","createdAt":"2026-10-21T08:00:00Z","includesCreatedEdit":false,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/cli/cli/pull/14768#issuecomment-1","viewerDidAuthor":false}],"commits":[{"authoredDate":"2026-10-20T16:37:24Z","authors":[{"email":"[email protected]","id":"MDQ6VXNlcjM2NzI4OTMx","login":"babakks","name":"Babak K. Shandiz"}],"committedDate":"2026-10-21T12:06:51Z","messageBody":"cli/oauth v1.3.0 exposes the token expiry fields as time.Duration values.","messageHeadline":"chore(deps): bump github.com/cli/oauth to v1.3.0","oid":"fd1b1feb222dde885d00262b2c21b107c6c6e416"}],"createdAt":"2026-10-20T16:40:41Z","deletions":48,"files":[{"path":"go.mod","additions":1,"deletions":1,"changeType":"MODIFIED"},{"path":"pkg/cmd/status/status.go","additions":3,"deletions":2,"changeType":"MODIFIED"}],"headRefName":"babakks/refresh-token-bump-deps","headRefOid":"6ec1ba54542baca6cc5932582634104c83f36a28","isCrossRepository":false,"isDraft":false,"labels":[],"mergeable":"UNKNOWN","mergedAt":"2026-10-21T12:29:58Z","number":14768,"reviewDecision":"APPROVED","reviewRequests":[{"__typename":"User","login":"niik"}],"reviews":[{"id":"PRR_1","author":{"login":"copilot-pull-request-reviewer"},"authorAssociation":"CONTRIBUTOR","body":"","submittedAt":"2026-10-21T09:46:01Z","includesCreatedEdit":false,"reactionGroups":[],"state":"COMMENTED","commit":{"oid":"831b9343fa77c2f11f521a49fd01da2d2d007b8e"}},{"id":"PRR_2","author":{"login":"williammartin"},"authorAssociation":"MEMBER","body":"LGTM","submittedAt":"2026-10-21T12:20:00Z","includesCreatedEdit":false,"reactionGroups":[],"state":"APPROVED","commit":{"oid":"6ec1ba54542baca6cc5932582634104c83f36a28"}}],"state":"MERGED","statusCheckRollup":[{"__typename":"CheckRun","completedAt":"2026-10-21T12:09:03Z","conclusion":"SUCCESS","detailsUrl":"https://github.com/cli/cli/actions/runs/1/job/1","name":"build (ubuntu)","startedAt":"2026-10-21T12:07:01Z","status":"COMPLETED","workflowName":"Tests"},{"__typename":"CheckRun","completedAt":"0001-01-01T00:00:00Z","conclusion":"","detailsUrl":"https://github.com/cli/cli/actions/runs/1/job/2","name":"build (windows)","startedAt":"2026-10-21T12:07:01Z","status":"IN_PROGRESS","workflowName":"Tests"},{"__typename":"CheckRun","completedAt":"2026-10-21T12:08:00Z","conclusion":"FAILURE","detailsUrl":"https://github.com/cli/cli/actions/runs/1/job/3","name":"lint","startedAt":"2026-10-21T12:07:01Z","status":"COMPLETED","workflowName":"Lint"},{"__typename":"CheckRun","completedAt":"2026-10-21T12:08:00Z","conclusion":"SKIPPED","detailsUrl":"","name":"docs","startedAt":"2026-10-21T12:07:01Z","status":"COMPLETED","workflowName":""},{"__typename":"StatusContext","context":"ci/legacy","state":"PENDING","targetUrl":"https://ci.example.com/1","startedAt":"2026-10-21T12:07:01Z"}],"title":"Refreshable tokens: bump cli/oauth to v1.3.0 and fix `gh status` column width","updatedAt":"2026-10-21T12:30:00Z","url":"https://github.com/cli/cli/pull/14768"}
|
||||
{"additions":42,"assignees":[],"author":{"id":"MDQ6VXNlcjM2NzI4OTMx","is_bot":false,"login":"babakks","name":"Babak K. Shandiz"},"baseRefName":"trunk","baseRefOid":"ae7b9715976c5508398b11dc8546e1279e10f6fc","body":"### Description\n\nThis PR moves gh to the tagged version of `cli/oauth`.","closedAt":"2026-10-21T12:29:58Z","comments":[{"id":"IC_1","author":{"login":"babakks"},"authorAssociation":"MEMBER","body":"Rebased on trunk.","createdAt":"2026-10-21T08:00:00Z","includesCreatedEdit":false,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/cli/cli/pull/14768#issuecomment-1","viewerDidAuthor":false}],"commits":[{"authoredDate":"2026-10-20T16:37:24Z","authors":[{"email":"[email protected]","id":"MDQ6VXNlcjM2NzI4OTMx","login":"babakks","name":"Babak K. Shandiz"}],"committedDate":"2026-10-21T12:06:51Z","messageBody":"cli/oauth v1.3.0 exposes the token expiry fields as time.Duration values.","messageHeadline":"chore(deps): bump github.com/cli/oauth to v1.3.0","oid":"fd1b1feb222dde885d00262b2c21b107c6c6e416"}],"createdAt":"2026-10-20T16:40:41Z","deletions":48,"files":[{"path":"go.mod","additions":1,"deletions":1,"changeType":"MODIFIED"},{"path":"pkg/cmd/status/status.go","additions":3,"deletions":2,"changeType":"MODIFIED"}],"headRefName":"babakks/refresh-token-bump-deps","headRefOid":"6ec1ba54542baca6cc5932582634104c83f36a28","isCrossRepository":false,"isDraft":false,"labels":[],"mergeable":"UNKNOWN","mergeStateStatus":"UNKNOWN","mergedAt":"2026-10-21T12:29:58Z","number":14768,"reviewDecision":"APPROVED","reviewRequests":[{"__typename":"User","login":"niik"}],"reviews":[{"id":"PRR_1","author":{"login":"copilot-pull-request-reviewer"},"authorAssociation":"CONTRIBUTOR","body":"","submittedAt":"2026-10-21T09:46:01Z","includesCreatedEdit":false,"reactionGroups":[],"state":"COMMENTED","commit":{"oid":"831b9343fa77c2f11f521a49fd01da2d2d007b8e"}},{"id":"PRR_2","author":{"login":"williammartin"},"authorAssociation":"MEMBER","body":"LGTM","submittedAt":"2026-10-21T12:20:00Z","includesCreatedEdit":false,"reactionGroups":[],"state":"APPROVED","commit":{"oid":"6ec1ba54542baca6cc5932582634104c83f36a28"}}],"state":"MERGED","statusCheckRollup":[{"__typename":"CheckRun","completedAt":"2026-10-21T12:09:03Z","conclusion":"SUCCESS","detailsUrl":"https://github.com/cli/cli/actions/runs/1/job/1","name":"build (ubuntu)","startedAt":"2026-10-21T12:07:01Z","status":"COMPLETED","workflowName":"Tests"},{"__typename":"CheckRun","completedAt":"0001-01-01T00:00:00Z","conclusion":"","detailsUrl":"https://github.com/cli/cli/actions/runs/1/job/2","name":"build (windows)","startedAt":"2026-10-21T12:07:01Z","status":"IN_PROGRESS","workflowName":"Tests"},{"__typename":"CheckRun","completedAt":"2026-10-21T12:08:00Z","conclusion":"FAILURE","detailsUrl":"https://github.com/cli/cli/actions/runs/1/job/3","name":"lint","startedAt":"2026-10-21T12:07:01Z","status":"COMPLETED","workflowName":"Lint"},{"__typename":"CheckRun","completedAt":"2026-10-21T12:08:00Z","conclusion":"SKIPPED","detailsUrl":"","name":"docs","startedAt":"2026-10-21T12:07:01Z","status":"COMPLETED","workflowName":""},{"__typename":"StatusContext","context":"ci/legacy","state":"PENDING","targetUrl":"https://ci.example.com/1","startedAt":"2026-10-21T12:07:01Z"}],"title":"Refreshable tokens: bump cli/oauth to v1.3.0 and fix `gh status` column width","updatedAt":"2026-10-21T12:30:00Z","url":"https://github.com/cli/cli/pull/14768"}
|
||||
@@ -22,6 +22,7 @@ fn pr_view() {
|
||||
assert_eq!(pr.summary.number, 14768);
|
||||
assert_eq!(pr.summary.head_ref, "babakks/refresh-token-bump-deps");
|
||||
assert_eq!(pr.head_oid, "6ec1ba54542baca6cc5932582634104c83f36a28");
|
||||
assert_eq!(pr.merge_state_status, "UNKNOWN");
|
||||
assert_eq!(pr.files.len(), 2);
|
||||
assert_eq!(pr.files[0].change_type, "MODIFIED");
|
||||
assert_eq!(pr.commits[0].headline, "chore(deps): bump github.com/cli/oauth to v1.3.0");
|
||||
@@ -69,6 +70,16 @@ fn repo_view() {
|
||||
.unwrap();
|
||||
assert_eq!(r.name_with_owner, "l0ng-ai/openidea");
|
||||
assert_eq!(r.default_branch.as_deref(), Some("main"));
|
||||
assert!(r.merge_commit_allowed && r.squash_merge_allowed && r.rebase_merge_allowed, "unknown: allowed");
|
||||
assert_eq!(r.viewer_permission, "");
|
||||
|
||||
let r = parse_repo(
|
||||
r#"{"mergeCommitAllowed":true,"nameWithOwner":"o/r","url":"","rebaseMergeAllowed":false,"squashMergeAllowed":false,"viewerPermission":"WRITE"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(r.merge_commit_allowed);
|
||||
assert!(!r.squash_merge_allowed && !r.rebase_merge_allowed);
|
||||
assert_eq!(r.viewer_permission, "WRITE");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -87,3 +98,24 @@ fn list_arguments() {
|
||||
assert!(joined.contains("--limit 500 --state merged --author @me"));
|
||||
assert_eq!(args.last().map(String::as_str), Some("review-requested:@me fix login"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_arguments() {
|
||||
let req =
|
||||
GhApiRequest { method: "post".into(), endpoint: "graphql".into(), body: Some("{}".into()), paginate: false };
|
||||
assert_eq!(api_args(&req).unwrap(), ["api", "--method", "POST", "graphql", "--input", "-"]);
|
||||
let req = GhApiRequest {
|
||||
method: "GET".into(),
|
||||
endpoint: "repos/{owner}/{repo}/labels".into(),
|
||||
body: None,
|
||||
paginate: true,
|
||||
};
|
||||
assert_eq!(
|
||||
api_args(&req).unwrap(),
|
||||
["api", "--method", "GET", "repos/{owner}/{repo}/labels", "--paginate", "--slurp"]
|
||||
);
|
||||
for bad in ["--hostname", "https://evil.example/x", ""] {
|
||||
assert!(api_args(&GhApiRequest { endpoint: bad.into(), ..req.clone() }).is_err(), "{bad}");
|
||||
}
|
||||
assert!(api_args(&GhApiRequest { method: "TRACE".into(), ..req }).is_err());
|
||||
}
|
||||
@@ -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
|
||||
@@ -44,6 +44,7 @@ struct State {
|
||||
project: Changes,
|
||||
/// Project roots whose `postStartCommand` ran in this process.
|
||||
post_started: HashSet<PathBuf>,
|
||||
post_commands: std::collections::HashMap<PathBuf, CancellationToken>,
|
||||
}
|
||||
|
||||
pub struct Environment {
|
||||
@@ -78,7 +79,12 @@ impl Environment {
|
||||
|
||||
pub(crate) fn project_closed(&self) {
|
||||
if self.project_env {
|
||||
self.state.lock().project.clear();
|
||||
let mut state = self.state.lock();
|
||||
for (_, token) in state.post_commands.drain() {
|
||||
token.cancel();
|
||||
}
|
||||
state.project.clear();
|
||||
drop(state);
|
||||
self.publish();
|
||||
}
|
||||
}
|
||||
@@ -144,6 +150,7 @@ impl Environment {
|
||||
/// a failure is a notification.
|
||||
pub(crate) fn spawn_post_start(&self, cmd: LifecycleCommand, root: PathBuf, sink: SharedSink) {
|
||||
let cancel = self.cancel.child_token();
|
||||
self.state.lock().post_commands.insert(root.clone(), cancel.clone());
|
||||
let spawned = std::thread::Builder::new().name("openidea-post-start".into()).spawn(move || {
|
||||
run_post_start(&cmd, &root, &sink, &cancel);
|
||||
});
|
||||
@@ -263,6 +270,9 @@ fn run_post_start(cmd: &LifecycleCommand, root: &Path, sink: &SharedSink, cancel
|
||||
/// Runs `argv` in `root` with the children's environment, its stdout and stderr line by line
|
||||
/// to `on_line`; `Err` says how it failed.
|
||||
fn run_one(argv: &[String], root: &Path, cancel: &CancellationToken, on_line: &dyn Fn(String)) -> Result<(), String> {
|
||||
if cancel.is_cancelled() {
|
||||
return Err("cancelled".into());
|
||||
}
|
||||
let (program, args) = argv.split_first().ok_or("empty command")?;
|
||||
let mut child = ide_core::process::prepare(&mut Command::new(program))
|
||||
.args(args)
|
||||
@@ -281,7 +291,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;
|
||||
}
|
||||
|
||||
@@ -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,12 +4,15 @@
|
||||
//! 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;
|
||||
pub mod trust;
|
||||
|
||||
pub use project::{Project, apply_git_executable, apply_workspace_edit, history_retention};
|
||||
|
||||
@@ -42,6 +45,7 @@ pub struct HostConfig {
|
||||
}
|
||||
|
||||
pub struct Host {
|
||||
pub trust: trust::TrustStore,
|
||||
pub config_dir: PathBuf,
|
||||
pub data_dir: PathBuf,
|
||||
pub languages: Arc<LanguageRegistry>,
|
||||
@@ -65,6 +69,7 @@ pub struct Host {
|
||||
/// One project per window, keyed by window label.
|
||||
projects: RwLock<HashMap<String, Arc<Project>>>,
|
||||
requests: requests::Requests,
|
||||
project_requests: RwLock<HashMap<String, Vec<RequestId>>>,
|
||||
}
|
||||
|
||||
impl Host {
|
||||
@@ -75,6 +80,7 @@ impl Host {
|
||||
let ssh = Arc::new(ide_ssh::SshManager::new(data_dir.join("ssh-passwords.json")));
|
||||
ssh.set_configs(global_settings.effective().ssh.configs);
|
||||
let host = Self {
|
||||
trust: trust::TrustStore::load(&config_dir, &data_dir),
|
||||
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,
|
||||
@@ -93,6 +99,7 @@ impl Host {
|
||||
runtime,
|
||||
projects: RwLock::default(),
|
||||
requests: Default::default(),
|
||||
project_requests: RwLock::default(),
|
||||
};
|
||||
host.apply_proxy();
|
||||
host
|
||||
@@ -117,6 +124,10 @@ impl Host {
|
||||
.ok_or_else(|| IdeError::not_found("no project is open in this window"))
|
||||
}
|
||||
|
||||
pub fn require_trust(&self, window: &str) -> IdeResult<()> {
|
||||
self.project(window)?.require_trust()
|
||||
}
|
||||
|
||||
/// The window that owns what `window` starts.
|
||||
pub fn owner_window<'a>(&self, window: &'a str) -> &'a str {
|
||||
(self.owner)(window)
|
||||
@@ -132,26 +143,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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,7 +221,19 @@ impl Host {
|
||||
self.requests.begin(id)
|
||||
}
|
||||
|
||||
pub fn begin_project_request(&self, window: &str, id: RequestId) -> CancellationToken {
|
||||
let token = self.begin_request(id);
|
||||
self.project_requests.write().entry(self.owner_window(window).to_owned()).or_default().push(id);
|
||||
if self.project(window).is_ok_and(|p| !p.is_trusted()) {
|
||||
token.cancel();
|
||||
}
|
||||
token
|
||||
}
|
||||
|
||||
pub fn end_request(&self, id: RequestId) {
|
||||
for ids in self.project_requests.write().values_mut() {
|
||||
ids.retain(|entry| *entry != id);
|
||||
}
|
||||
self.requests.end(id);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,814 @@
|
||||
//! 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 restrict(&self) {
|
||||
let documents: Vec<_> = self.registry.lock().documents.values().cloned().collect();
|
||||
for document in &documents {
|
||||
let mut state = document.state.lock();
|
||||
state.generation += 1;
|
||||
if let Some(batch) = &state.active {
|
||||
batch.cancel.cancel();
|
||||
}
|
||||
}
|
||||
for document in documents {
|
||||
let _ = self.kernels.shutdown_kernel(&document.path).await;
|
||||
let batch = document.state.lock().active.clone();
|
||||
if let Err(error) = self.drain(&document, batch).await {
|
||||
tracing::warn!("notebook restriction: {error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ pub fn subscribe(host: &Host, window: &str, on_event: Emit<AgentEvent>) -> IdeRe
|
||||
}
|
||||
|
||||
pub fn providers(host: &Host, window: &str) -> IdeResult<Vec<AgentProvider>> {
|
||||
host.require_trust(window)?;
|
||||
let mut providers = host.project(window)?.agents.providers();
|
||||
ide_agent::registry::mark_updates(®istry_paths(host), &mut providers);
|
||||
Ok(providers)
|
||||
@@ -87,12 +88,20 @@ 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> {
|
||||
host.require_trust(window)?;
|
||||
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.
|
||||
pub async fn prompt(host: &Host, window: &str, task: String, prompt: AgentPrompt, now: bool) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
host.project(window)?.agents.prompt(&task, prompt, now).await
|
||||
}
|
||||
|
||||
@@ -103,26 +112,31 @@ pub fn unqueue(host: &Host, window: &str, task: String, queued: String) -> IdeRe
|
||||
|
||||
/// Sends a queued message now, interrupting the current turn.
|
||||
pub fn send_queued_now(host: &Host, window: &str, task: String, queued: String) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
host.project(window)?.agents.send_queued_now(&task, &queued)
|
||||
}
|
||||
|
||||
/// Changes a session setting (model, mode, thinking effort…).
|
||||
pub async fn set_config(host: &Host, window: &str, task: String, option: String, value: String) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
host.project(window)?.agents.set_config(&task, &option, value).await
|
||||
}
|
||||
|
||||
/// Copies the task's conversation into a new task, up to its first `turns` turns (all when `None`).
|
||||
pub async fn fork(host: &Host, window: &str, task: String, turns: Option<u32>) -> IdeResult<AgentTask> {
|
||||
host.require_trust(window)?;
|
||||
host.project(window)?.agents.fork(&task, turns).await
|
||||
}
|
||||
|
||||
/// Drops the task's turns after its first `turns`; returns the message of the first dropped one.
|
||||
pub async fn rollback(host: &Host, window: &str, task: String, turns: u32) -> IdeResult<Option<AgentPrompt>> {
|
||||
host.require_trust(window)?;
|
||||
host.project(window)?.agents.rollback(&task, turns).await
|
||||
}
|
||||
|
||||
/// What the agent reports about itself when it starts, without opening a session.
|
||||
pub async fn probe(host: &Host, window: &str, provider: String, refresh: bool) -> IdeResult<AgentProbe> {
|
||||
host.require_trust(window)?;
|
||||
host.project(window)?.agents.probe(&provider, refresh).await
|
||||
}
|
||||
|
||||
@@ -133,6 +147,7 @@ pub fn cancel(host: &Host, window: &str, task: String, subagent: Option<String>)
|
||||
|
||||
/// Answers a permission request; `option: None` cancels it.
|
||||
pub fn respond(host: &Host, window: &str, task: String, permission: u32, option: Option<String>) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
host.project(window)?.agents.respond(&task, permission, option)
|
||||
}
|
||||
|
||||
@@ -140,16 +155,23 @@ 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.require_trust(window)?;
|
||||
host.project(window)?.agents.delete(&task, remove_worktree).await
|
||||
}
|
||||
|
||||
pub async fn changes(host: &Host, window: &str, task: String) -> IdeResult<Vec<AgentChange>> {
|
||||
host.require_trust(window)?;
|
||||
host.project(window)?.agents.changes(&task).await
|
||||
}
|
||||
|
||||
/// Content of `path` when the task started (left side of its diff).
|
||||
pub async fn base_text(host: &Host, window: &str, task: String, path: IpcPath) -> IdeResult<Option<String>> {
|
||||
host.require_trust(window)?;
|
||||
host.project(window)?.agents.base_text(&task, path.into_path_buf()).await
|
||||
}
|
||||
|
||||
@@ -183,6 +205,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 +214,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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -39,12 +39,13 @@ fn load(project: &Project, languages: &LanguageRegistry, path: &Path) -> Option<
|
||||
let snap = project.documents.text_snapshot(doc).ok()?;
|
||||
return (!snap.large).then_some((snap.language, snap.text));
|
||||
}
|
||||
let loaded = ide_vfs::load_text(path).ok()?;
|
||||
let loaded = project.documents.load_file(path).ok()?;
|
||||
if loaded.binary || loaded.large || loaded.too_large {
|
||||
return None;
|
||||
}
|
||||
let language = languages.detect(path, loaded.text.lines().next());
|
||||
Some((language, loaded.text))
|
||||
let text = ide_text::normalize_line_endings(&loaded.text).into_owned();
|
||||
let language = languages.detect(path, text.lines().next());
|
||||
Some((language, text))
|
||||
}
|
||||
|
||||
// ---- Inspect Code ----
|
||||
|
||||
@@ -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
|
||||
@@ -70,6 +76,7 @@ pub fn load(root: &Path, cache: &Path, settings: &Settings) -> BuildModel {
|
||||
|
||||
/// Maven and Gradle projects of the window's project, from the build files plus the last sync.
|
||||
pub async fn build_model(host: &Host, window: &str) -> IdeResult<BuildModel> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective());
|
||||
tokio::task::spawn_blocking(move || load(&root, &cache, &settings)).await.map_err(join_err)
|
||||
@@ -87,9 +94,10 @@ pub async fn build_sync(
|
||||
profiles: Vec<String>,
|
||||
on_progress: Channel<SyncProgress>,
|
||||
) -> IdeResult<BuildModel> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective());
|
||||
let token = host.begin_request(request);
|
||||
let token = host.begin_project_request(window, request);
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let current = load(&root, &cache, &settings);
|
||||
let env = match tool {
|
||||
@@ -107,6 +115,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 +125,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
|
||||
@@ -200,10 +225,12 @@ pub async fn build_run(
|
||||
clear: Vec<std::path::PathBuf>,
|
||||
on_event: Channel<BuildEvent>,
|
||||
) -> IdeResult<BuildResult> {
|
||||
host.require_trust(window)?;
|
||||
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 token = host.begin_request(request);
|
||||
let config_dir = host.config_dir.clone();
|
||||
let token = host.begin_project_request(window, request);
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
for path in clear {
|
||||
sink.emit(IdeEvent::Diagnostics {
|
||||
@@ -212,7 +239,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 +304,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());
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ pub async fn blocking<T: Send + 'static>(f: impl FnOnce() -> IdeResult<T> + Send
|
||||
/// How to run `config` with coverage: the command to launch and where its report will be,
|
||||
/// or which tool is missing.
|
||||
pub async fn coverage_prepare(host: &Host, window: &str, config: RunConfig) -> IdeResult<CoveragePrepared> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let (root, cache) = (project.root.clone(), project.cache_dir.join("coverage"));
|
||||
blocking(move || {
|
||||
@@ -26,7 +27,8 @@ pub async fn coverage_prepare(host: &Host, window: &str, config: RunConfig) -> I
|
||||
}
|
||||
|
||||
/// Reads the report a coverage run left behind.
|
||||
pub async fn coverage_collect(_host: &Host, _window: &str, report: CoverageReportSpec) -> IdeResult<CoverageSuite> {
|
||||
pub async fn coverage_collect(host: &Host, window: &str, report: CoverageReportSpec) -> IdeResult<CoverageSuite> {
|
||||
host.require_trust(window)?;
|
||||
blocking(move || ide_coverage::collect(&report)).await
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
mod output;
|
||||
|
||||
use crate::Host;
|
||||
use crate::rpc::Dispatcher;
|
||||
use ide_core::{DebugSessionId, IdeResult};
|
||||
@@ -6,6 +8,7 @@ use std::path::PathBuf;
|
||||
|
||||
/// Starts a session owned by this window: its events (and those of child sessions) go here only.
|
||||
pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> IdeResult<DebugSessionId> {
|
||||
host.require_trust(window)?;
|
||||
let mut config = match_java_debug_encoding(config, cfg!(windows));
|
||||
if config.adapter == "debugpy"
|
||||
&& config.request == "launch"
|
||||
@@ -14,17 +17,70 @@ pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> Ide
|
||||
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 == "delve" && config.request == "launch" {
|
||||
let root = host.project(window).map(|p| p.root.clone()).unwrap_or_default();
|
||||
let settings = settings.clone();
|
||||
config = host
|
||||
.blocking(move || {
|
||||
ide_sdk::go::debug_environment(&root, &settings, &mut config.env);
|
||||
ide_sdk::go::apply_to_debug(
|
||||
&mut config.env,
|
||||
&mut config.extra_json,
|
||||
&settings.languages.go.build_tags,
|
||||
)?;
|
||||
Ok(config)
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
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
|
||||
host.require_trust(window)?;
|
||||
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
|
||||
@@ -253,11 +309,12 @@ pub async fn debug_attachable_processes(_host: &Host, _window: &str) -> IdeResul
|
||||
/// JVM to connect to `host:port`; returns the loopback port java-debug attaches to.
|
||||
pub async fn debug_listen_for_jvm(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
window: &str,
|
||||
request: ide_core::RequestId,
|
||||
jvm_host: String,
|
||||
port: u16,
|
||||
) -> IdeResult<u16> {
|
||||
host.require_trust(window)?;
|
||||
let token = host.begin_request(request);
|
||||
let result = jdwp::listen_for_jvm(&jvm_host, port, token).await;
|
||||
host.end_request(request);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
use crate::Host;
|
||||
use ide_core::{CancellationToken, IdeEvent, IdeResult, IpcPath, RequestId, SharedSink, TaskId};
|
||||
use ide_remote::{DeployedPath, DeploymentCoverage, SyncEntry, SyncStep, TransferReport};
|
||||
use ide_ssh::{FsConnection, RemoteEntry};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Reports a transfer as a background task (status bar progress) whose Cancel fires `token`.
|
||||
fn progress(
|
||||
sink: SharedSink,
|
||||
title: &str,
|
||||
token: CancellationToken,
|
||||
) -> (impl FnMut(&str, usize, usize) + Send, impl FnOnce()) {
|
||||
let id = TaskId::next();
|
||||
let cancel = token;
|
||||
let handle = ide_core::task::on_cancel(id, move || cancel.cancel());
|
||||
let title = title.to_owned();
|
||||
let (s, t) = (sink.clone(), title.clone());
|
||||
let report = move |path: &str, i: usize, n: usize| {
|
||||
let fraction = if n == 0 { None } else { Some(i as f32 / n as f32) };
|
||||
let message = Some(path.to_owned());
|
||||
s.emit(IdeEvent::Task { id, title: t.clone(), message, fraction, done: false, cancellable: true });
|
||||
};
|
||||
let done = move || {
|
||||
drop(handle);
|
||||
sink.emit(IdeEvent::Task { id, title, message: None, fraction: Some(1.0), done: true, cancellable: false })
|
||||
};
|
||||
(report, done)
|
||||
}
|
||||
|
||||
fn local(paths: Vec<IpcPath>) -> Vec<PathBuf> {
|
||||
paths.into_iter().map(|p| p.to_path_buf()).collect()
|
||||
}
|
||||
|
||||
/// Eligible paths per server, without contacting any deployment host.
|
||||
pub async fn deploy_coverage(window: &str, state: &Host, paths: Vec<IpcPath>) -> IdeResult<Vec<DeploymentCoverage>> {
|
||||
let project = state.project(window)?;
|
||||
Ok(ide_remote::coverage(&project.settings.effective(), &project.root, &local(paths)))
|
||||
}
|
||||
|
||||
/// Tools › Deployment › Upload to…: files and folders to `server` (default server when `None`).
|
||||
pub async fn deploy_upload(
|
||||
window: &str,
|
||||
state: &Host,
|
||||
paths: Vec<IpcPath>,
|
||||
server: Option<String>,
|
||||
request: RequestId,
|
||||
) -> IdeResult<TransferReport> {
|
||||
let project = state.project(window)?;
|
||||
let token = state.begin_request(request);
|
||||
let (mut report, done) = progress(project.sink.clone(), "Uploading", token.clone());
|
||||
let r = state
|
||||
.remote
|
||||
.upload(&project.settings.effective(), &project.root, server.as_deref(), &local(paths), token, &mut report)
|
||||
.await;
|
||||
done();
|
||||
state.end_request(request);
|
||||
r
|
||||
}
|
||||
|
||||
/// Download from…: the remote counterparts of files and folders.
|
||||
pub async fn deploy_download(
|
||||
window: &str,
|
||||
state: &Host,
|
||||
paths: Vec<IpcPath>,
|
||||
server: Option<String>,
|
||||
request: RequestId,
|
||||
) -> IdeResult<TransferReport> {
|
||||
let project = state.project(window)?;
|
||||
let token = state.begin_request(request);
|
||||
let (mut report, done) = progress(project.sink.clone(), "Downloading", token.clone());
|
||||
let r = state
|
||||
.remote
|
||||
.download(&project.settings.effective(), &project.root, server.as_deref(), &local(paths), token, &mut report)
|
||||
.await;
|
||||
done();
|
||||
state.end_request(request);
|
||||
r
|
||||
}
|
||||
|
||||
/// Deletes the remote counterparts of files and folders.
|
||||
pub async fn deploy_delete_remote(
|
||||
window: &str,
|
||||
state: &Host,
|
||||
paths: Vec<IpcPath>,
|
||||
server: Option<String>,
|
||||
) -> IdeResult<TransferReport> {
|
||||
let project = state.project(window)?;
|
||||
state.remote.delete_remote(&project.settings.effective(), &project.root, server.as_deref(), &local(paths)).await
|
||||
}
|
||||
|
||||
/// Sync with Deployed: local and remote files under `path`, side by side.
|
||||
pub async fn deploy_compare(
|
||||
window: &str,
|
||||
state: &Host,
|
||||
path: IpcPath,
|
||||
server: Option<String>,
|
||||
request: RequestId,
|
||||
) -> IdeResult<Vec<SyncEntry>> {
|
||||
let project = state.project(window)?;
|
||||
let token = state.begin_request(request);
|
||||
let r = state.remote.compare(&project.settings.effective(), &project.root, server.as_deref(), &path, token).await;
|
||||
state.end_request(request);
|
||||
r
|
||||
}
|
||||
|
||||
/// Applies the Sync with Deployed choices.
|
||||
pub async fn deploy_sync(
|
||||
window: &str,
|
||||
state: &Host,
|
||||
steps: Vec<SyncStep>,
|
||||
server: Option<String>,
|
||||
request: RequestId,
|
||||
) -> IdeResult<TransferReport> {
|
||||
let project = state.project(window)?;
|
||||
let token = state.begin_request(request);
|
||||
let (mut report, done) = progress(project.sink.clone(), "Synchronizing", token.clone());
|
||||
let r = state
|
||||
.remote
|
||||
.sync(&project.settings.effective(), &project.root, server.as_deref(), &steps, token, &mut report)
|
||||
.await;
|
||||
done();
|
||||
state.end_request(request);
|
||||
r
|
||||
}
|
||||
|
||||
/// Where `path` is deployed (remote path, web URL); `None` when not mapped.
|
||||
pub async fn deploy_info(
|
||||
window: &str,
|
||||
state: &Host,
|
||||
path: IpcPath,
|
||||
server: Option<String>,
|
||||
) -> IdeResult<Option<DeployedPath>> {
|
||||
let project = state.project(window)?;
|
||||
state.remote.deployed(&project.settings.effective(), &project.root, server.as_deref(), &path).await
|
||||
}
|
||||
|
||||
/// Remote Host tool window: the server root of `server`, resolved on the host.
|
||||
pub async fn deploy_server_root(window: &str, state: &Host, server: String) -> IdeResult<String> {
|
||||
let settings = match state.project(window) {
|
||||
Ok(p) => p.settings.effective(),
|
||||
Err(_) => state.global_settings.effective(),
|
||||
};
|
||||
state.remote.server_root(&settings, &server).await
|
||||
}
|
||||
|
||||
/// Entries of a folder on the selected filesystem (`~`: its home directory).
|
||||
pub async fn remote_list(state: &Host, connection: FsConnection, path: String) -> IdeResult<Vec<RemoteEntry>> {
|
||||
state.remote.list(&connection, &path).await
|
||||
}
|
||||
|
||||
/// Opens a remote file for editing: a local copy, uploaded back on save.
|
||||
pub async fn remote_open(state: &Host, connection: FsConnection, path: String) -> IdeResult<IpcPath> {
|
||||
Ok(state.remote.open_remote(&connection, &path).await?.into())
|
||||
}
|
||||
|
||||
/// The filesystem connection and path a cached editing copy stands for.
|
||||
pub fn remote_of(state: &Host, path: IpcPath) -> Option<(FsConnection, String)> {
|
||||
state.remote.remote_of(&path)
|
||||
}
|
||||
|
||||
pub async fn remote_delete(state: &Host, connection: FsConnection, path: String) -> IdeResult<()> {
|
||||
state.remote.remote_delete(&connection, &path).await
|
||||
}
|
||||
|
||||
pub async fn remote_mkdir(state: &Host, connection: FsConnection, path: String) -> IdeResult<()> {
|
||||
state.remote.remote_mkdir(&connection, &path).await
|
||||
}
|
||||
|
||||
pub async fn remote_rename(state: &Host, connection: FsConnection, from: String, to: String) -> IdeResult<()> {
|
||||
state.remote.remote_rename(&connection, &from, &to).await
|
||||
}
|
||||
|
||||
pub async fn deploy_test_connection(state: &Host, connection: FsConnection, path: String) -> IdeResult<()> {
|
||||
state.remote.test_connection(&connection, &path).await
|
||||
}
|
||||
|
||||
pub(crate) fn register(d: &mut crate::rpc::Dispatcher) {
|
||||
d.add("deploy_coverage", |c| async move { deploy_coverage(&c.window, &c.host, c.arg("paths")?).await });
|
||||
d.add("deploy_upload", |c| async move {
|
||||
deploy_upload(&c.window, &c.host, c.arg("paths")?, c.arg("server")?, c.arg("request")?).await
|
||||
});
|
||||
d.add("deploy_download", |c| async move {
|
||||
deploy_download(&c.window, &c.host, c.arg("paths")?, c.arg("server")?, c.arg("request")?).await
|
||||
});
|
||||
d.add("deploy_delete_remote", |c| async move {
|
||||
deploy_delete_remote(&c.window, &c.host, c.arg("paths")?, c.arg("server")?).await
|
||||
});
|
||||
d.add("deploy_compare", |c| async move {
|
||||
deploy_compare(&c.window, &c.host, c.arg("path")?, c.arg("server")?, c.arg("request")?).await
|
||||
});
|
||||
d.add("deploy_sync", |c| async move {
|
||||
deploy_sync(&c.window, &c.host, c.arg("steps")?, c.arg("server")?, c.arg("request")?).await
|
||||
});
|
||||
d.add("deploy_info", |c| async move { deploy_info(&c.window, &c.host, c.arg("path")?, c.arg("server")?).await });
|
||||
d.add("deploy_server_root", |c| async move { deploy_server_root(&c.window, &c.host, c.arg("server")?).await });
|
||||
d.add("remote_list", |c| async move { remote_list(&c.host, c.arg("connection")?, c.arg("path")?).await });
|
||||
d.add("remote_open", |c| async move { remote_open(&c.host, c.arg("connection")?, c.arg("path")?).await });
|
||||
d.add("remote_of", |c| async move { Ok(remote_of(&c.host, c.arg("path")?)) });
|
||||
d.add("remote_delete", |c| async move { remote_delete(&c.host, c.arg("connection")?, c.arg("path")?).await });
|
||||
d.add("remote_mkdir", |c| async move { remote_mkdir(&c.host, c.arg("connection")?, c.arg("path")?).await });
|
||||
d.add("remote_rename", |c| async move {
|
||||
remote_rename(&c.host, c.arg("connection")?, c.arg("from")?, c.arg("to")?).await
|
||||
});
|
||||
d.add("deploy_test_connection", |c| async move {
|
||||
deploy_test_connection(&c.host, c.arg("connection")?, c.arg("path")?).await
|
||||
});
|
||||
}
|
||||
@@ -28,12 +28,26 @@ 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
|
||||
}
|
||||
|
||||
/// Recovers external changes missed by the project's file watchers.
|
||||
pub async fn sync_from_disk(host: &Host, window: &str) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
host.blocking(move || {
|
||||
if project.settings.effective().files.sync_on_focus {
|
||||
project.documents.sync_from_disk();
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn snapshot(host: &Host, window: &str, doc: DocId) -> IdeResult<DocumentInfo> {
|
||||
@@ -43,3 +57,6 @@ pub async fn snapshot(host: &Host, window: &str, doc: DocId) -> IdeResult<Docume
|
||||
|
||||
mod commands;
|
||||
pub use commands::*;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -22,9 +22,23 @@ 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)
|
||||
pub async fn doc_set_encoding(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
doc: DocId,
|
||||
encoding: String,
|
||||
reload: bool,
|
||||
) -> IdeResult<String> {
|
||||
let project = host.project(window)?;
|
||||
host.blocking(move || project.documents.set_encoding(doc, &encoding, reload)).await
|
||||
}
|
||||
|
||||
pub fn doc_set_line_ending(host: &Host, window: &str, doc: DocId, line_ending: LineEnding) -> IdeResult<()> {
|
||||
@@ -108,8 +122,9 @@ 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")?)
|
||||
doc_set_encoding(&c.host, &c.window, c.arg("doc")?, c.arg("encoding")?, c.arg("reload")?).await
|
||||
});
|
||||
d.add("doc_set_line_ending", |c| async move {
|
||||
doc_set_line_ending(&c.host, &c.window, c.arg("doc")?, c.arg("lineEnding")?)
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
use super::*;
|
||||
use crate::{HostConfig, Project};
|
||||
use ide_core::{IdeEvent, TextChange, event::NotifyLevel};
|
||||
use parking_lot::Mutex;
|
||||
use std::{path::Path, sync::Arc};
|
||||
|
||||
struct Fixture {
|
||||
host: Host,
|
||||
project: Arc<Project>,
|
||||
events: Arc<Mutex<Vec<IdeEvent>>>,
|
||||
path: PathBuf,
|
||||
outside: PathBuf,
|
||||
_temp: tempfile::TempDir,
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let base = dunce::canonicalize(temp.path()).unwrap();
|
||||
let root = base.join("project");
|
||||
// Keep watcher delivery out of these tests: the op must recover it itself.
|
||||
let excluded = root.join("node_modules");
|
||||
std::fs::create_dir_all(&excluded).unwrap();
|
||||
let path = excluded.join("file.txt");
|
||||
let outside = base.join("outside.txt");
|
||||
std::fs::write(&path, "original\n").unwrap();
|
||||
std::fs::write(&outside, "outside\n").unwrap();
|
||||
let events = Arc::new(Mutex::new(Vec::new()));
|
||||
let captured = events.clone();
|
||||
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(move |_| {
|
||||
let events = captured.clone();
|
||||
Arc::new(move |event| events.lock().push(event))
|
||||
}),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
});
|
||||
let project = host.open_project("main", &root).unwrap();
|
||||
Self { _temp: temp, host, project, events, path, outside }
|
||||
}
|
||||
|
||||
async fn open(&self, path: &Path) -> DocumentInfo {
|
||||
open(&self.host, "main", path.to_path_buf()).await.unwrap()
|
||||
}
|
||||
|
||||
async fn sync(&self) {
|
||||
sync_from_disk(&self.host, "main").await.unwrap();
|
||||
}
|
||||
|
||||
fn document_events(&self) -> Vec<IdeEvent> {
|
||||
self.events
|
||||
.lock()
|
||||
.iter()
|
||||
.filter(|e| matches!(e, IdeEvent::DocPatched { .. } | IdeEvent::DocDirty { .. } | IdeEvent::Notify { .. }))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn changed_clean_document_reloads_once() {
|
||||
let f = Fixture::new();
|
||||
let original = f.open(&f.path).await;
|
||||
std::fs::write(&f.path, "externally changed\n").unwrap();
|
||||
f.sync().await;
|
||||
let loaded = f.project.documents.snapshot(original.id).unwrap();
|
||||
assert_eq!(loaded.text, "externally changed\n");
|
||||
assert!(!loaded.dirty);
|
||||
assert!(loaded.version > original.version);
|
||||
f.events.lock().clear();
|
||||
f.sync().await;
|
||||
assert_eq!(f.project.documents.snapshot(original.id).unwrap().version, loaded.version);
|
||||
assert!(f.document_events().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn unchanged_document_has_no_patch_or_event() {
|
||||
let f = Fixture::new();
|
||||
let original = f.open(&f.path).await;
|
||||
f.events.lock().clear();
|
||||
f.sync().await;
|
||||
let loaded = f.project.documents.snapshot(original.id).unwrap();
|
||||
assert_eq!(loaded.text, original.text);
|
||||
assert_eq!(loaded.version, original.version);
|
||||
assert!(f.document_events().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn dirty_document_keeps_edits_and_warns_once() {
|
||||
let f = Fixture::new();
|
||||
let original = f.open(&f.path).await;
|
||||
edit(
|
||||
&f.host,
|
||||
"main",
|
||||
original.id,
|
||||
original.version,
|
||||
ChangeSet { changes: vec![TextChange { from: 0, to: 0, insert: "local ".into() }] },
|
||||
)
|
||||
.unwrap();
|
||||
f.events.lock().clear();
|
||||
std::fs::write(&f.path, "external replacement\n").unwrap();
|
||||
f.sync().await;
|
||||
let loaded = f.project.documents.snapshot(original.id).unwrap();
|
||||
assert_eq!(loaded.text, "local original\n");
|
||||
assert!(loaded.dirty);
|
||||
assert!(matches!(f.document_events().as_slice(), [IdeEvent::Notify { level: NotifyLevel::Warning, .. }]));
|
||||
f.events.lock().clear();
|
||||
f.sync().await;
|
||||
assert!(f.document_events().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn deleted_document_keeps_its_buffer_and_other_documents_still_reload() {
|
||||
let f = Fixture::new();
|
||||
let original = f.open(&f.path).await;
|
||||
let outside = f.open(&f.outside).await;
|
||||
std::fs::remove_file(&f.path).unwrap();
|
||||
std::fs::write(&f.outside, "outside changed after deletion\n").unwrap();
|
||||
f.sync().await;
|
||||
let loaded = f.project.documents.snapshot(original.id).unwrap();
|
||||
assert_eq!(loaded.text, original.text);
|
||||
assert_eq!(loaded.version, original.version);
|
||||
assert_eq!(f.project.documents.snapshot(outside.id).unwrap().text, "outside changed after deletion\n");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn outside_project_document_reloads() {
|
||||
let f = Fixture::new();
|
||||
assert!(!f.outside.starts_with(&f.project.root));
|
||||
let original = f.open(&f.outside).await;
|
||||
std::fs::write(&f.outside, "outside changed externally\n").unwrap();
|
||||
f.sync().await;
|
||||
assert_eq!(f.project.documents.snapshot(original.id).unwrap().text, "outside changed externally\n");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn setting_defaults_on_and_disabled_sync_keeps_buffer() {
|
||||
let f = Fixture::new();
|
||||
assert!(f.project.settings.effective().files.sync_on_focus);
|
||||
assert!(serde_json::from_value::<ide_core::settings::FileSettings>(serde_json::json!({})).unwrap().sync_on_focus);
|
||||
let original = f.open(&f.outside).await;
|
||||
f.project.settings.set(ide_workspace::settings::SettingsScope::Project, "files.syncOnFocus", false.into()).unwrap();
|
||||
std::fs::write(&f.outside, "external change while disabled\n").unwrap();
|
||||
f.sync().await;
|
||||
assert_eq!(f.project.documents.snapshot(original.id).unwrap().version, original.version);
|
||||
f.project.settings.set(ide_workspace::settings::SettingsScope::Project, "files.syncOnFocus", true.into()).unwrap();
|
||||
f.sync().await;
|
||||
assert_eq!(f.project.documents.snapshot(original.id).unwrap().text, "external change while disabled\n");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn properties_reload_preserves_native_to_ascii_decoding() {
|
||||
let f = Fixture::new();
|
||||
let path = f.outside.with_extension("properties");
|
||||
std::fs::write(&path, "key=\\u4F60\n").unwrap();
|
||||
let mut defaults = f.project.documents.file_defaults();
|
||||
defaults.native_to_ascii = true;
|
||||
f.project.documents.set_file_defaults(defaults);
|
||||
let original = f.open(&path).await;
|
||||
assert_eq!(original.text, "key=你\n");
|
||||
std::fs::write(&path, "key=\\u4E16\\u754C\n").unwrap();
|
||||
f.sync().await;
|
||||
assert_eq!(f.project.documents.snapshot(original.id).unwrap().text, "key=世界\n");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn binary_and_virtual_documents_are_skipped() {
|
||||
let f = Fixture::new();
|
||||
let path = f.outside.with_extension("bin");
|
||||
std::fs::write(&path, b"\0\0binary payload").unwrap();
|
||||
let binary = f.open(&path).await;
|
||||
assert!(binary.read_only);
|
||||
let virtual_doc =
|
||||
f.project.documents.open_virtual("test://virtual", ide_core::LanguageId::plain_text(), "virtual").unwrap();
|
||||
std::fs::write(&path, "now ordinary text").unwrap();
|
||||
f.events.lock().clear();
|
||||
f.sync().await;
|
||||
assert_eq!(f.project.documents.snapshot(binary.id).unwrap().version, binary.version);
|
||||
assert_eq!(f.project.documents.snapshot(virtual_doc.id).unwrap().text, "virtual");
|
||||
assert!(f.document_events().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn synchronization_only_changes_the_calling_windows_documents() {
|
||||
let f = Fixture::new();
|
||||
let original = f.open(&f.outside).await;
|
||||
let other_root = f.outside.parent().unwrap().join("other-project");
|
||||
std::fs::create_dir(&other_root).unwrap();
|
||||
let other = f.host.open_project("other", &other_root).unwrap();
|
||||
let other_doc = open(&f.host, "other", f.outside.clone()).await.unwrap();
|
||||
std::fs::write(&f.outside, "external change in shared file\n").unwrap();
|
||||
f.sync().await;
|
||||
assert_eq!(f.project.documents.snapshot(original.id).unwrap().text, "external change in shared file\n");
|
||||
assert_eq!(other.documents.snapshot(other_doc.id).unwrap().text, other_doc.text);
|
||||
}
|
||||
@@ -9,7 +9,7 @@ use std::sync::Arc;
|
||||
/// Re-reads the login shell (and the window's project `remoteEnv` where it applies);
|
||||
/// running terminals and processes keep their environment.
|
||||
pub async fn reload(host: &Arc<Host>, window: &str) -> IdeResult<EnvReloaded> {
|
||||
let root = host.project(window).ok().filter(|p| !p.light).map(|p| p.root.clone());
|
||||
let root = host.project(window).ok().filter(|p| !p.light && p.is_trusted()).map(|p| p.root.clone());
|
||||
let sink = host.window_sink(window);
|
||||
let h = host.clone();
|
||||
host.blocking(move || h.environment.reload(root.as_deref(), &sink)).await
|
||||
|
||||
@@ -49,6 +49,10 @@ pub fn create_file(host: &Host, window: &str, path: PathBuf) -> IdeResult<()> {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_file_bytes(host: &Host, path: PathBuf, data: Vec<u8>) -> IdeResult<()> {
|
||||
host.blocking(move || ide_vfs::create_file_bytes(&path, &data)).await
|
||||
}
|
||||
|
||||
/// Toggle Read-only Attribute: sets the writable bit (and the open document's
|
||||
/// state). Returns whether the file is read-only now.
|
||||
pub fn set_writable(host: &Host, window: &str, path: PathBuf, writable: bool) -> IdeResult<bool> {
|
||||
|
||||
@@ -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,58 +18,94 @@ pub async fn java_resolve_launch(
|
||||
module: PathBuf,
|
||||
test: bool,
|
||||
build: bool,
|
||||
request: RequestId,
|
||||
) -> IdeResult<JavaLaunchInfo> {
|
||||
host.require_trust(window)?;
|
||||
let cancel = host.begin_project_request(window, 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 started = std::time::Instant::now();
|
||||
let project = host.project(window)?;
|
||||
let module = project.root.join(module);
|
||||
let settings = project.settings.effective();
|
||||
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());
|
||||
tokio::task::spawn_blocking(move || {
|
||||
compile::build_for_launch(&root, &load(&root, &cache, &settings), &module, test, &CancellationToken::new())
|
||||
})
|
||||
.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) {
|
||||
class_paths.push(p);
|
||||
// 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 lsp_ms = started.elapsed().as_millis();
|
||||
let data_dir = host.data_dir.clone();
|
||||
let cancel = cancel.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let built = if build && compile::has_non_java_sources(&module) {
|
||||
compile::build_for_launch(
|
||||
&project.root,
|
||||
&load(&project.root, &project.build_cache_dir(), &settings),
|
||||
&module,
|
||||
test,
|
||||
&cancel,
|
||||
)?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
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) {
|
||||
class_paths.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
info.class_paths = class_paths;
|
||||
// The project JDK, else the JDK jdtls compiles the module against, else one matching its Java version.
|
||||
let project_jdk = ide_sdk::apply::project_jdk(&project.root, &settings);
|
||||
let from_jdtls = info.java_home.as_deref().map(ide_sdk::java::java_executable).filter(|p| p.is_file());
|
||||
let java = project_jdk.map(|j| ide_sdk::java::java_executable(&j.home)).or(from_jdtls).or_else(|| {
|
||||
let release = ide_sdk::java::project_java_version(&module);
|
||||
let jdks = ide_sdk::java::installed_jdks();
|
||||
info.hints.extend(ide_sdk::java::missing_jdk_message(&jdks, release));
|
||||
ide_sdk::java::pick_jdk(&jdks, release).map(|j| ide_sdk::java::java_executable(&j.home))
|
||||
});
|
||||
info.java_exec = Some(java.map(|p| p.to_string_lossy().into_owned()).unwrap_or_else(|| "java".to_owned()));
|
||||
let exec = PathBuf::from(info.java_exec.as_deref().unwrap_or("java"));
|
||||
let shorten = ide_run::java_cmdline::shortening_for(&exec, &info.class_paths, &info.module_paths, cfg!(windows));
|
||||
info.shorten_command_line = Some(shorten.as_str().to_owned());
|
||||
if info.error_count > 0 {
|
||||
let own = ide_sdk::lombok::agent_in_args(settings.languages.java.jdtls_vm_args.split_whitespace());
|
||||
let managed = ide_tools::managed_bundle(&host.data_dir, "lombok");
|
||||
let repo = ide_sdk::apply::maven_local_repository(&project.root, &settings);
|
||||
let root = project.root.clone();
|
||||
// Detection may read the project's sources.
|
||||
let lombok = tokio::task::spawn_blocking(move || ide_sdk::lombok::lombok_agent(&root, own, managed, repo))
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
info.hints.extend(lombok.problem());
|
||||
}
|
||||
Ok(info)
|
||||
info.class_paths = class_paths;
|
||||
let compile_ms = started.elapsed().as_millis() - lsp_ms;
|
||||
// The project JDK, else the JDK jdtls compiles the module against, else one matching its Java version.
|
||||
let project_jdk = ide_sdk::apply::project_jdk(&project.root, &settings);
|
||||
let from_jdtls = info.java_home.as_deref().map(ide_sdk::java::java_executable).filter(|p| p.is_file());
|
||||
let java = project_jdk.map(|j| ide_sdk::java::java_executable(&j.home)).or(from_jdtls).or_else(|| {
|
||||
let release = ide_sdk::java::project_java_version(&module);
|
||||
let jdks = ide_sdk::java::installed_jdks();
|
||||
info.hints.extend(ide_sdk::java::missing_jdk_message(&jdks, release));
|
||||
ide_sdk::java::pick_jdk(&jdks, release).map(|j| ide_sdk::java::java_executable(&j.home))
|
||||
});
|
||||
info.java_exec = Some(java.map(|p| p.to_string_lossy().into_owned()).unwrap_or_else(|| "java".to_owned()));
|
||||
let exec = PathBuf::from(info.java_exec.as_deref().unwrap_or("java"));
|
||||
let shorten =
|
||||
ide_run::java_cmdline::shortening_for(&exec, &info.class_paths, &info.module_paths, cfg!(windows));
|
||||
info.shorten_command_line = Some(shorten.as_str().to_owned());
|
||||
if info.error_count > 0 {
|
||||
let own = ide_sdk::lombok::agent_in_args(settings.languages.java.jdtls_vm_args.split_whitespace());
|
||||
let managed = ide_tools::managed_bundle(&data_dir, "lombok");
|
||||
let repo = ide_sdk::apply::maven_local_repository(&project.root, &settings);
|
||||
let root = project.root.clone();
|
||||
// Detection may read the project's sources.
|
||||
let lombok = ide_sdk::lombok::lombok_agent(&root, own, managed, repo);
|
||||
info.hints.extend(lombok.problem());
|
||||
}
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
tracing::info!(
|
||||
lsp_ms,
|
||||
compile_ms,
|
||||
jdk_ms = started.elapsed().as_millis() - lsp_ms - compile_ms,
|
||||
total_ms = started.elapsed().as_millis(),
|
||||
"Java launch resolved"
|
||||
);
|
||||
Ok(info)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
}
|
||||
|
||||
/// Port of java-debug's DAP server inside the jdtls serving `module` (started on demand).
|
||||
pub async fn java_debug_server(host: &Host, window: &str, module: PathBuf) -> IdeResult<u16> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let module = project.root.join(module);
|
||||
project.lsp.java_debug_server(&module).await
|
||||
@@ -80,15 +117,17 @@ pub async fn java_build_workspace(
|
||||
window: &str,
|
||||
module: PathBuf,
|
||||
) -> IdeResult<ide_lsp::jdtls::JavaBuildStatus> {
|
||||
host.require_trust(window)?;
|
||||
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 });
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
//! Bounded, read-only JVM presentation for visible files, also served remotely.
|
||||
use crate::Host;
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult, LanguageId};
|
||||
use ide_syntax::{JvmPresentation, Snapshot};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fs::File,
|
||||
io::Read,
|
||||
path::PathBuf,
|
||||
sync::{Mutex, OnceLock},
|
||||
time::SystemTime,
|
||||
};
|
||||
|
||||
pub const PREFIX_BYTES: u64 = 64 * 1024;
|
||||
const MAX_BATCH: usize = 64;
|
||||
const MAX_CACHE: usize = 4096;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct JvmFilePresentation {
|
||||
pub path: PathBuf,
|
||||
pub presentation: JvmPresentation,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Cached {
|
||||
modified: SystemTime,
|
||||
size: u64,
|
||||
presentation: JvmPresentation,
|
||||
}
|
||||
|
||||
fn prefix(path: &PathBuf) -> std::io::Result<String> {
|
||||
let mut bytes = Vec::new();
|
||||
File::open(path)?.take(PREFIX_BYTES).read_to_end(&mut bytes)?;
|
||||
// A truncated final UTF-8 character must not invalidate the entire prefix.
|
||||
Ok(String::from_utf8_lossy(&bytes).into_owned())
|
||||
}
|
||||
|
||||
pub async fn jvm_file_presentations(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
paths: Vec<PathBuf>,
|
||||
) -> IdeResult<Vec<JvmFilePresentation>> {
|
||||
if paths.len() > MAX_BATCH {
|
||||
return Err(IdeError::invalid("at most 64 JVM files per batch"));
|
||||
}
|
||||
let project = host.project(window)?;
|
||||
host.blocking(move || {
|
||||
static CACHE: OnceLock<Mutex<HashMap<PathBuf, Cached>>> = OnceLock::new();
|
||||
let cache = CACHE.get_or_init(Default::default);
|
||||
let mut results = Vec::new();
|
||||
for path in paths {
|
||||
let language = match path.extension().and_then(|e| e.to_str()) {
|
||||
Some("java") => LanguageId::new("java"),
|
||||
Some("kt") => LanguageId::new("kotlin"),
|
||||
_ => continue,
|
||||
};
|
||||
let Ok(meta) = path.metadata() else { continue };
|
||||
if !meta.is_file() {
|
||||
continue;
|
||||
}
|
||||
let Ok(modified) = meta.modified() else { continue };
|
||||
let hit =
|
||||
cache.lock().unwrap().get(&path).filter(|c| c.modified == modified && c.size == meta.len()).cloned();
|
||||
let presentation = if let Some(hit) = hit {
|
||||
hit.presentation
|
||||
} else {
|
||||
let Ok(source) = prefix(&path) else { continue };
|
||||
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or_default();
|
||||
let presentation = project.structure.jvm_presentation(
|
||||
&Snapshot::text(&language, &source),
|
||||
stem,
|
||||
&CancellationToken::new(),
|
||||
)?;
|
||||
let mut cache = cache.lock().unwrap();
|
||||
if cache.len() >= MAX_CACHE {
|
||||
cache.clear();
|
||||
}
|
||||
cache.insert(path.clone(), Cached { modified, size: meta.len(), presentation: presentation.clone() });
|
||||
presentation
|
||||
};
|
||||
results.push(JvmFilePresentation { path, presentation });
|
||||
}
|
||||
Ok(results)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn huge_files_read_only_a_prefix() {
|
||||
let path = std::env::temp_dir().join(format!("openidea-icon-prefix-{}.java", std::process::id()));
|
||||
let file = File::create(&path).unwrap();
|
||||
file.set_len(100 * 1024 * 1024).unwrap();
|
||||
assert_eq!(prefix(&path).unwrap().len(), PREFIX_BYTES as usize);
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -8,13 +8,16 @@ pub mod build;
|
||||
pub mod bytes;
|
||||
pub mod coverage;
|
||||
pub mod debug;
|
||||
pub mod deploy;
|
||||
pub mod doc;
|
||||
pub mod env;
|
||||
pub mod fs;
|
||||
pub mod history;
|
||||
pub mod java;
|
||||
pub mod jvm_icons;
|
||||
pub mod lsp;
|
||||
pub mod nav;
|
||||
pub mod notebook;
|
||||
pub mod project;
|
||||
mod python;
|
||||
pub mod refactor;
|
||||
@@ -60,6 +63,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);
|
||||
@@ -78,6 +82,7 @@ pub fn register_all(d: &mut crate::rpc::Dispatcher) {
|
||||
search::register(d);
|
||||
run::register(d);
|
||||
env::register(d);
|
||||
deploy::register(d);
|
||||
}
|
||||
|
||||
impl Host {
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
//! 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> {
|
||||
host.require_trust(window)?;
|
||||
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> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let cancel = host.begin_project_request(window, 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
|
||||
});
|
||||
}
|
||||
@@ -10,16 +10,20 @@ use std::sync::Arc;
|
||||
#[derive(Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProjectInfo {
|
||||
pub trusted: bool,
|
||||
pub root: ide_core::IpcPath,
|
||||
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 {
|
||||
let settings = project.settings.effective();
|
||||
let custom = settings.general.project_name.trim();
|
||||
ProjectInfo {
|
||||
trusted: project.is_trusted(),
|
||||
name: if custom.is_empty() {
|
||||
project.root.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default()
|
||||
} else {
|
||||
@@ -28,6 +32,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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +58,42 @@ 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,
|
||||
@@ -107,6 +148,14 @@ pub fn set_settings(
|
||||
}
|
||||
}
|
||||
let affected = || if project_scope { current.iter().cloned().collect() } else { host.projects() };
|
||||
if key == "editor" || key == "editor.encodings" || key == "editor.encodings.nativeToAscii" {
|
||||
let affected = affected();
|
||||
host.runtime.spawn_blocking(move || {
|
||||
for project in affected {
|
||||
project.documents.reload_properties();
|
||||
}
|
||||
});
|
||||
}
|
||||
// File type associations re-detect open documents (off the command thread).
|
||||
if key == "files" || key.starts_with("files.fileType") {
|
||||
let affected = affected();
|
||||
@@ -129,7 +178,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.") {
|
||||
|
||||
+113
-11
@@ -6,27 +6,80 @@ use std::time::Duration;
|
||||
|
||||
/// Detected configs followed by user-defined ones (user ids win on conflict).
|
||||
pub fn configs(host: &Host, window: &str) -> IdeResult<Vec<RunConfig>> {
|
||||
host.require_trust(window)?;
|
||||
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 {
|
||||
for task in &mut config.extras.before_launch {
|
||||
if task.kind == "run" {
|
||||
task.target = format!("workspace:{}:{}", key, task.target);
|
||||
}
|
||||
}
|
||||
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> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
if config.command.is_empty() {
|
||||
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,19 +88,68 @@ 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.kind == "go" {
|
||||
ide_sdk::apply::apply_to_run_configs(std::slice::from_mut(&mut config), root, &settings);
|
||||
ide_sdk::go::apply_to_run(&mut config, &settings.languages.go.build_tags);
|
||||
}
|
||||
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()))??;
|
||||
}
|
||||
if config.kind == "go" && config.extras.params.get("scope").is_some_and(|s| s == "file") {
|
||||
let root = project.root.clone();
|
||||
config = host
|
||||
.blocking(move || {
|
||||
ide_run::go::prepare_test_file(&root, &mut config)?;
|
||||
Ok(config)
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
let encoding = settings.console.encoding;
|
||||
let options = StartOptions {
|
||||
encoding: Some(encoding).filter(|e| !e.trim().is_empty()),
|
||||
python_paths: super::python::paths(&project),
|
||||
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)))
|
||||
project.require_trust()?;
|
||||
let mut sink = project.sink.clone();
|
||||
if config.kind == "go" && matches!(config.args.first().map(String::as_str), Some("mod" | "work")) {
|
||||
let previous = sink;
|
||||
let root = project.root.clone();
|
||||
sink = std::sync::Arc::new(move |event: ide_core::IdeEvent| {
|
||||
if matches!(&event, ide_core::IdeEvent::RunExited { .. }) {
|
||||
ide_sdk::libraries::invalidate_go(&root);
|
||||
}
|
||||
previous.emit(event);
|
||||
});
|
||||
}
|
||||
host.processes.start(&project.root, &config, &options, sink, Box::new(move |c| on_output(c)))
|
||||
}
|
||||
|
||||
/// Stops a run (interrupt, then kill after Settings › Run's termination timeout).
|
||||
|
||||
@@ -49,8 +49,97 @@ 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> {
|
||||
host.require_trust(window)?;
|
||||
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>> {
|
||||
host.require_trust(window)?;
|
||||
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>> {
|
||||
host.require_trust(window)?;
|
||||
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 mut plan = ide_build::web::build(
|
||||
&project.root,
|
||||
&model,
|
||||
id,
|
||||
&ide_run::tomcat::base_dir(&project.cache_dir, &config.id),
|
||||
)?;
|
||||
plan.env.extend(config.env.clone());
|
||||
let mut build = 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.clone(),
|
||||
args: vec![],
|
||||
cwd: Some(std::path::PathBuf::from(&plan.cwd).into()),
|
||||
env: plan.env.clone(),
|
||||
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()
|
||||
},
|
||||
};
|
||||
plan.resolve_project(
|
||||
&project.root,
|
||||
&ide_run::resolved_env(&project.root, &build)?,
|
||||
&ide_core::CancellationToken::new(),
|
||||
)?;
|
||||
build.args = plan.args;
|
||||
Ok(Some(build))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
}
|
||||
|
||||
pub async fn run_tomcat_update(host: &Host, window: &str, config: RunConfig, mode: String) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
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)) });
|
||||
|
||||
@@ -22,12 +22,14 @@ pub struct LocatedTools {
|
||||
|
||||
/// Installed interpreters / toolchains of `kind`; project-local ones first.
|
||||
pub async fn sdk_detect(host: &Host, window: &str, kind: SdkKind) -> IdeResult<Vec<Sdk>> {
|
||||
host.require_trust(window)?;
|
||||
let root = host.project(window).ok().map(|p| p.root.clone());
|
||||
tokio::task::spawn_blocking(move || ide_sdk::detect(kind, root.as_deref())).await.map_err(join_err)
|
||||
}
|
||||
|
||||
/// The interpreter the project uses: the configured one, else its virtualenv.
|
||||
pub fn sdk_python_effective(host: &Host, window: &str) -> IdeResult<Option<ide_core::IpcPath>> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
Ok(ide_sdk::apply::python_interpreter(&project.root, &project.settings.effective()).map(Into::into))
|
||||
}
|
||||
@@ -35,6 +37,7 @@ pub fn sdk_python_effective(host: &Host, window: &str) -> IdeResult<Option<ide_c
|
||||
/// SDK / library source folders shown under "External Libraries" in the Project view,
|
||||
/// with the Maven / Gradle dependency jars of the build model.
|
||||
pub async fn sdk_project_libraries(host: &Host, window: &str) -> IdeResult<Vec<ide_sdk::libraries::LibraryGroup>> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective());
|
||||
tokio::task::spawn_blocking(move || {
|
||||
@@ -56,11 +59,12 @@ pub async fn sdk_project_libraries(host: &Host, window: &str) -> IdeResult<Vec<i
|
||||
|
||||
/// Looks up `names` (bare command names): `node_modules/.bin` above `near`, then the search path.
|
||||
pub async fn sdk_locate_tools(
|
||||
_host: &Host,
|
||||
_window: &str,
|
||||
host: &Host,
|
||||
window: &str,
|
||||
names: Vec<String>,
|
||||
near: Option<PathBuf>,
|
||||
) -> IdeResult<LocatedTools> {
|
||||
host.require_trust(window)?;
|
||||
tokio::task::spawn_blocking(move || LocatedTools {
|
||||
tools: ide_sdk::locate::locate_tools(&names, near.as_deref()),
|
||||
compile_dir: ide_sdk::locate::compile_dir().into(),
|
||||
@@ -72,13 +76,14 @@ pub async fn sdk_locate_tools(
|
||||
/// `base -m venv location`; output lines stream through `on_output`.
|
||||
pub async fn sdk_python_create_venv(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
window: &str,
|
||||
base: PathBuf,
|
||||
location: PathBuf,
|
||||
request: RequestId,
|
||||
on_output: Channel<String>,
|
||||
) -> IdeResult<Sdk> {
|
||||
let cancel = host.begin_request(request);
|
||||
host.require_trust(window)?;
|
||||
let cancel = host.begin_project_request(window, request);
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
ide_sdk::python::create_venv(&base, &location, &cancel, &|line| {
|
||||
let _ = on_output.send(line);
|
||||
@@ -92,11 +97,12 @@ pub async fn sdk_python_create_venv(
|
||||
|
||||
pub async fn sdk_python_packages(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
window: &str,
|
||||
python: PathBuf,
|
||||
request: RequestId,
|
||||
) -> IdeResult<Vec<PythonPackage>> {
|
||||
let cancel = host.begin_request(request);
|
||||
host.require_trust(window)?;
|
||||
let cancel = host.begin_project_request(window, request);
|
||||
let result =
|
||||
tokio::task::spawn_blocking(move || ide_sdk::python::list_packages(&python, &cancel)).await.map_err(join_err);
|
||||
host.end_request(request);
|
||||
@@ -107,7 +113,7 @@ pub async fn sdk_python_packages(
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn sdk_python_pip(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
window: &str,
|
||||
python: PathBuf,
|
||||
requirement: String,
|
||||
uninstall: bool,
|
||||
@@ -115,7 +121,8 @@ pub async fn sdk_python_pip(
|
||||
request: RequestId,
|
||||
on_output: Channel<String>,
|
||||
) -> IdeResult<()> {
|
||||
let cancel = host.begin_request(request);
|
||||
host.require_trust(window)?;
|
||||
let cancel = host.begin_project_request(window, request);
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let send = |line: String| {
|
||||
let _ = on_output.send(line);
|
||||
|
||||
@@ -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,15 +69,21 @@ 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,
|
||||
Some(&filter),
|
||||
Some(&literals),
|
||||
&|p| documents.text_of(p),
|
||||
&|p| documents.search_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;
|
||||
|
||||
@@ -124,7 +124,7 @@ pub async fn syntax_identifiers_in_files(
|
||||
let snap = project.documents.text_snapshot(doc).ok()?;
|
||||
return (!snap.large).then_some((snap.language, Some((doc, snap.version)), snap.text));
|
||||
}
|
||||
let text = ide_vfs::load_text(path).ok()?.text;
|
||||
let text = project.text_of(path).ok()?;
|
||||
let first_line = text.lines().next();
|
||||
Some((languages.detect(path, first_line), None, text))
|
||||
};
|
||||
|
||||
@@ -3,12 +3,24 @@ use crate::Host;
|
||||
use ide_core::{IdeResult, TerminalId};
|
||||
use ide_terminal::{TerminalInfo, TerminalOptions};
|
||||
|
||||
pub fn spawn(host: &Host, window: &str, options: TerminalOptions, on_output: Emit<String>) -> IdeResult<TerminalInfo> {
|
||||
pub fn spawn(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
mut options: TerminalOptions,
|
||||
on_output: Emit<String>,
|
||||
) -> IdeResult<TerminalInfo> {
|
||||
if !host.project(window).is_ok_and(|p| p.is_trusted()) {
|
||||
// Project terminal settings can otherwise start a command as soon as a tab restores.
|
||||
let global = host.global_settings.effective().terminal;
|
||||
options.shell = None;
|
||||
options.args.clear();
|
||||
options.env = global.env;
|
||||
}
|
||||
// Python › "Activate virtualenv": typed into the new shell, like PyCharm.
|
||||
let activation = host
|
||||
.project(window)
|
||||
.ok()
|
||||
.and_then(|p| ide_sdk::apply::terminal_activation(&p.root, &p.settings.effective(), options.shell.as_deref()));
|
||||
let activation =
|
||||
host.project(window).ok().filter(|p| p.is_trusted()).and_then(|p| {
|
||||
ide_sdk::apply::terminal_activation(&p.root, &p.settings.effective(), options.shell.as_deref())
|
||||
});
|
||||
let info = host.terminals.spawn(options, host.window_sink(window), Box::new(move |chunk| on_output(chunk)))?;
|
||||
if let Some(line) = activation {
|
||||
let _ = host.terminals.write(info.id, &format!(" {line}\r"));
|
||||
|
||||
@@ -11,6 +11,7 @@ use std::path::PathBuf;
|
||||
|
||||
/// The installer, reporting the executables chosen in the window's settings.
|
||||
fn tools(host: &Host, window: &str) -> IdeResult<Tools> {
|
||||
host.require_trust(window)?;
|
||||
let data = host.data_dir.clone();
|
||||
let (settings, root) = match host.project(window) {
|
||||
Ok(project) => (project.settings.effective(), project.root.clone()),
|
||||
|
||||
@@ -7,7 +7,9 @@ pub async fn status(host: &Host, window: &str) -> IdeResult<Vec<FileStatus>> {
|
||||
let project = host.project(window)?;
|
||||
host.blocking(move || {
|
||||
let settings = project.settings.effective();
|
||||
apply_git_executable(&settings);
|
||||
if project.is_trusted() {
|
||||
apply_git_executable(&settings);
|
||||
}
|
||||
let repos = project.repos();
|
||||
if repos.is_empty() {
|
||||
return Err(ide_core::IdeError::unavailable("project is not a git repository"));
|
||||
|
||||
@@ -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 {
|
||||
@@ -184,21 +187,39 @@ pub async fn vcs_branch_info(host: &Host, window: &str, repo: Option<PathBuf>) -
|
||||
}
|
||||
|
||||
/// Gutter change markers for a file, computed against its current (unsaved) text.
|
||||
pub fn vcs_line_hunks(host: &Host, window: &str, path: PathBuf) -> IdeResult<Vec<LineHunk>> {
|
||||
pub async 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))
|
||||
blocking(move || {
|
||||
let text = project.text_of(&path)?;
|
||||
project.with_repo_of(&path, |r| r.line_hunks(&path, &text, false, project.vcs_encoding(&path, None)?))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
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).
|
||||
blocking(move || {
|
||||
let text = text.or_else(|| project.documents.text_of(&path));
|
||||
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>> {
|
||||
@@ -238,17 +259,20 @@ pub async fn vcs_stashes(host: &Host, window: &str, repo: Option<PathBuf>) -> Id
|
||||
}
|
||||
|
||||
pub async fn vcs_stage(host: &Host, window: &str, paths: Vec<PathBuf>) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
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<()> {
|
||||
) -> IdeResult<Vec<String>> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
for path in &paths {
|
||||
@@ -264,22 +288,25 @@ pub async fn vcs_add_to_ignore(
|
||||
watcher.reload_ignore_rules();
|
||||
}
|
||||
project.sink.emit(ide_core::IdeEvent::VcsChanged { root: project.root.clone().into() });
|
||||
result.map(|_| ())
|
||||
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<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || per_repo(&project, &paths, None, |r, paths| r.unstage(paths))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_stage_patch(host: &Host, window: &str, patch: String) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
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<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || per_repo(&project, &paths, Some("Rollback"), |r, paths| r.discard(paths))).await
|
||||
}
|
||||
@@ -291,9 +318,15 @@ pub async fn vcs_commit(
|
||||
message: String,
|
||||
options: CommitOptions,
|
||||
) -> IdeResult<String> {
|
||||
host.require_trust(window)?;
|
||||
// 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(
|
||||
@@ -304,6 +337,7 @@ pub async fn vcs_checkout(
|
||||
create: bool,
|
||||
options: Option<ide_vcs::CheckoutOptions>,
|
||||
) -> IdeResult<ide_vcs::CheckoutOutcome> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let options = options.unwrap_or_default();
|
||||
blocking(move || {
|
||||
@@ -322,6 +356,7 @@ pub async fn vcs_create_branch(
|
||||
start: Option<String>,
|
||||
checkout: bool,
|
||||
) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let recorded = checkout.then_some("Git checkout");
|
||||
blocking(move || {
|
||||
@@ -333,6 +368,7 @@ pub async fn vcs_create_branch(
|
||||
/// Undo Commit: moves HEAD back to the commit's parent, keeping its changes staged.
|
||||
/// Returns the undone commit's message.
|
||||
pub async fn vcs_undo_commit(host: &Host, window: &str, repo: Option<PathBuf>, commit: String) -> IdeResult<String> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.undo_commit(&commit))).await
|
||||
}
|
||||
@@ -343,6 +379,7 @@ pub async fn vcs_delete_branch(
|
||||
repo: Option<PathBuf>,
|
||||
branch: String,
|
||||
) -> IdeResult<ide_vcs::DeletedBranch> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.delete_branch_restorable(&branch))).await
|
||||
}
|
||||
@@ -354,6 +391,7 @@ pub async fn vcs_restore_branch(
|
||||
repo: Option<PathBuf>,
|
||||
deleted: ide_vcs::DeletedBranch,
|
||||
) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.restore_branch(&deleted))).await
|
||||
}
|
||||
@@ -365,16 +403,19 @@ pub async fn vcs_rename_branch(
|
||||
from: String,
|
||||
to: String,
|
||||
) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.rename_branch(&from, &to))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_merge(host: &Host, window: &str, repo: Option<PathBuf>, branch: String) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_recorded(repo.as_deref(), Some("Git merge"), |r| r.merge(&branch))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_rebase(host: &Host, window: &str, repo: Option<PathBuf>, onto: String) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_recorded(repo.as_deref(), Some("Git rebase"), |r| r.rebase(&onto))).await
|
||||
}
|
||||
@@ -387,6 +428,7 @@ pub async fn vcs_stash_push(
|
||||
include_untracked: bool,
|
||||
keep_index: bool,
|
||||
) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
project.with_repo_recorded(repo.as_deref(), Some("Git stash"), |r| {
|
||||
@@ -405,6 +447,7 @@ pub async fn vcs_stash_apply(
|
||||
reinstate_index: bool,
|
||||
branch: Option<String>,
|
||||
) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
project.with_repo_recorded(repo.as_deref(), Some("Git unstash"), |r| match branch.as_deref() {
|
||||
@@ -416,6 +459,7 @@ pub async fn vcs_stash_apply(
|
||||
}
|
||||
|
||||
pub async fn vcs_stash_drop(host: &Host, window: &str, repo: Option<PathBuf>, index: u32) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.stash_drop(index))).await
|
||||
}
|
||||
@@ -429,6 +473,7 @@ pub async fn vcs_remote(
|
||||
op: RemoteOp,
|
||||
on_output: Channel<String>,
|
||||
) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let cancel = host.begin_request(request);
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
@@ -443,12 +488,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)
|
||||
@@ -464,12 +514,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).
|
||||
@@ -479,16 +531,18 @@ pub fn vcs_diff_hunks(_host: &Host, _window: &str, old_text: String, new_text: S
|
||||
|
||||
/// Stages only the selected hunks of a file's working-tree changes.
|
||||
/// 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<()> {
|
||||
pub async fn vcs_stage_hunks(host: &Host, window: &str, path: PathBuf, hunks: Vec<u32>) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let current = ide_vfs::load_text(&path)?.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) }
|
||||
blocking(move || {
|
||||
let encoding = project.vcs_encoding(&path, Some(":0"))?;
|
||||
let current = project.documents.load_file_as(&path, encoding.charset.name())?.text;
|
||||
project.with_repo_of(&path, |r| {
|
||||
let selected: Vec<usize> = hunks.iter().map(|&h| h as usize).collect();
|
||||
r.stage_hunks(&path, ¤t, &selected, encoding)
|
||||
})
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Clones a repository (Get from VCS). Works without an open project.
|
||||
@@ -521,7 +575,8 @@ pub async fn vcs_init(host: &Host, _window: &str, dir: PathBuf) -> IdeResult<()>
|
||||
|
||||
/// Settings › Git › Test: runs `git --version` on `path`, or on the auto-detected
|
||||
/// executable when `path` is empty.
|
||||
pub async fn vcs_git_version(_host: &Host, _window: &str, path: String) -> IdeResult<GitExecutableInfo> {
|
||||
pub async fn vcs_git_version(host: &Host, window: &str, path: String) -> IdeResult<GitExecutableInfo> {
|
||||
host.require_trust(window)?;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let explicit = path.trim();
|
||||
let exe = if explicit.is_empty() {
|
||||
@@ -563,6 +618,7 @@ pub async fn vcs_operation(
|
||||
op: GitOperation,
|
||||
on_output: Channel<String>,
|
||||
) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let cancel = host.begin_request(request);
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
@@ -603,6 +659,7 @@ pub async fn vcs_create_tag(
|
||||
message: Option<String>,
|
||||
force: bool,
|
||||
) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.create_tag(&name, &commit, message.as_deref(), force)))
|
||||
.await
|
||||
@@ -621,6 +678,7 @@ pub async fn vcs_merge_with(
|
||||
branches: Vec<String>,
|
||||
options: MergeOptions,
|
||||
) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
project.with_repo_recorded(repo.as_deref(), Some("Git merge"), |r| r.merge_with(&branches, &options))
|
||||
@@ -636,6 +694,7 @@ pub async fn vcs_rebase_with(
|
||||
upstream: String,
|
||||
options: RebaseOptions,
|
||||
) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
project.with_repo_recorded(repo.as_deref(), Some("Git rebase"), |r| r.rebase_with(&upstream, &options))
|
||||
@@ -649,6 +708,7 @@ pub async fn vcs_remotes(host: &Host, window: &str, repo: Option<PathBuf>) -> Id
|
||||
}
|
||||
|
||||
pub async fn vcs_edit_remote(host: &Host, window: &str, repo: Option<PathBuf>, edit: RemoteEdit) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.edit_remote(&edit))).await
|
||||
}
|
||||
@@ -666,6 +726,7 @@ pub async fn vcs_add_worktree(
|
||||
commitish: String,
|
||||
new_branch: Option<String>,
|
||||
) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
project.with_repo_at(repo.as_deref(), |r| r.add_worktree(&path, &commitish, new_branch.as_deref()))
|
||||
@@ -680,6 +741,7 @@ pub async fn vcs_remove_worktree(
|
||||
path: PathBuf,
|
||||
force: bool,
|
||||
) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.remove_worktree(&path, force))).await
|
||||
}
|
||||
@@ -707,6 +769,19 @@ pub async fn vcs_diff_revisions(
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.diff_revisions(&from, &to))).await
|
||||
}
|
||||
|
||||
/// The best common ancestor of `a` and `b` (`git merge-base`), if any: the New Pull
|
||||
/// Request tab's three-dot diff.
|
||||
pub async fn vcs_merge_base(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
repo: Option<PathBuf>,
|
||||
a: String,
|
||||
b: String,
|
||||
) -> IdeResult<Option<String>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.merge_base(&a, &b))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_create_patch(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
@@ -742,12 +817,14 @@ pub async fn vcs_apply_patch(
|
||||
repo: Option<PathBuf>,
|
||||
patch: String,
|
||||
) -> IdeResult<ApplyOutcome> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
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.
|
||||
pub async fn vcs_shelve(host: &Host, window: &str, name: String, paths: Vec<PathBuf>) -> IdeResult<ShelvedChangeList> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
in_primary_repo(&project, &paths)?;
|
||||
@@ -764,17 +841,26 @@ pub fn vcs_shelves(host: &Host, window: &str) -> IdeResult<Vec<ShelvedChangeList
|
||||
|
||||
/// Unshelve: applies the shelved patch (3-way merging; conflicts are left for the
|
||||
/// merge UI); with `remove`, deletes it from the shelf.
|
||||
pub async fn vcs_unshelve(host: &Host, window: &str, id: String, remove: bool) -> IdeResult<ApplyOutcome> {
|
||||
pub async fn vcs_unshelve(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
id: String,
|
||||
remove: bool,
|
||||
path: Option<String>,
|
||||
) -> IdeResult<ApplyOutcome> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
project.with_repo_recorded(None, Some("Unshelve"), |r| r.unshelve(&shelf_dir(&project.root), &id, remove))
|
||||
project.with_repo_recorded(None, Some("Unshelve"), |r| {
|
||||
ide_vcs::shelf::unshelve_path(r, &shelf_dir(&project.root), &id, remove, path.as_deref())
|
||||
})
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn vcs_shelf_delete(host: &Host, window: &str, id: String) -> IdeResult<()> {
|
||||
pub fn vcs_shelf_delete(host: &Host, window: &str, id: String, path: Option<String>) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
ide_vcs::shelf::delete(&shelf_dir(&project.root), &id)
|
||||
ide_vcs::shelf::delete_path(&shelf_dir(&project.root), &id, path.as_deref())
|
||||
}
|
||||
|
||||
pub fn vcs_shelf_rename(host: &Host, window: &str, id: String, name: String) -> IdeResult<()> {
|
||||
@@ -782,9 +868,19 @@ pub fn vcs_shelf_rename(host: &Host, window: &str, id: String, name: String) ->
|
||||
ide_vcs::shelf::rename(&shelf_dir(&project.root), &id, &name)
|
||||
}
|
||||
|
||||
pub fn vcs_shelf_patch(host: &Host, window: &str, id: String) -> IdeResult<String> {
|
||||
pub fn vcs_shelf_patch(host: &Host, window: &str, id: String, path: Option<String>) -> IdeResult<String> {
|
||||
let project = host.project(window)?;
|
||||
ide_vcs::shelf::patch_of(&shelf_dir(&project.root), &id)
|
||||
ide_vcs::shelf::selected_patch(&shelf_dir(&project.root), &id, path.as_deref())
|
||||
}
|
||||
|
||||
pub async fn vcs_shelf_diff(host: &Host, window: &str, id: String) -> IdeResult<Vec<ide_vcs::shelf::ShelfDiffFile>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo(|r| ide_vcs::shelf::diff_files(r, &shelf_dir(&project.root), &id))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_shelf_import(host: &Host, window: &str, name: String, patch: String) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || ide_vcs::shelf::import_patch(&shelf_dir(&project.root), &name, &patch)).await
|
||||
}
|
||||
|
||||
pub fn vcs_changelists_load(host: &Host, window: &str) -> IdeResult<ChangelistState> {
|
||||
@@ -814,9 +910,11 @@ pub async fn vcs_write_patch(_host: &Host, _window: &str, path: PathBuf, patch:
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("vcs_roots", |c| async move { vcs_roots(&c.host, &c.window) });
|
||||
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_line_hunks", |c| async move { vcs_line_hunks(&c.host, &c.window, c.arg("path")?).await });
|
||||
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
|
||||
@@ -896,7 +994,10 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
"vcs_diff_hunks",
|
||||
|c| async move { Ok(vcs_diff_hunks(&c.host, &c.window, c.arg("oldText")?, c.arg("newText")?)) },
|
||||
);
|
||||
d.add("vcs_stage_hunks", |c| async move { vcs_stage_hunks(&c.host, &c.window, c.arg("path")?, c.arg("hunks")?) });
|
||||
d.add(
|
||||
"vcs_stage_hunks",
|
||||
|c| async move { vcs_stage_hunks(&c.host, &c.window, c.arg("path")?, c.arg("hunks")?).await },
|
||||
);
|
||||
d.add("vcs_clone", |c| async move {
|
||||
vcs_clone(&c.host, &c.window, c.arg("request")?, c.arg("url")?, c.arg("dest")?, c.sender("onOutput")?).await
|
||||
});
|
||||
@@ -945,6 +1046,9 @@ 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_merge_base", |c| async move {
|
||||
vcs_merge_base(&c.host, &c.window, c.arg("repo")?, c.arg("a")?, c.arg("b")?).await
|
||||
});
|
||||
d.add("vcs_create_patch", |c| async move {
|
||||
vcs_create_patch(&c.host, &c.window, c.arg("repo")?, c.arg("source")?).await
|
||||
});
|
||||
@@ -957,10 +1061,16 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
);
|
||||
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 });
|
||||
d.add("vcs_shelf_delete", |c| async move { vcs_shelf_delete(&c.host, &c.window, c.arg("id")?) });
|
||||
d.add("vcs_unshelve", |c| async move {
|
||||
vcs_unshelve(&c.host, &c.window, c.arg("id")?, c.arg("remove")?, c.arg("path")?).await
|
||||
});
|
||||
d.add("vcs_shelf_delete", |c| async move { vcs_shelf_delete(&c.host, &c.window, c.arg("id")?, c.arg("path")?) });
|
||||
d.add("vcs_shelf_rename", |c| async move { vcs_shelf_rename(&c.host, &c.window, c.arg("id")?, c.arg("name")?) });
|
||||
d.add("vcs_shelf_patch", |c| async move { vcs_shelf_patch(&c.host, &c.window, c.arg("id")?) });
|
||||
d.add("vcs_shelf_diff", |c| async move { vcs_shelf_diff(&c.host, &c.window, c.arg("id")?).await });
|
||||
d.add("vcs_shelf_import", |c| async move {
|
||||
vcs_shelf_import(&c.host, &c.window, c.arg("name")?, c.arg("patch")?).await
|
||||
});
|
||||
d.add("vcs_shelf_patch", |c| async move { vcs_shelf_patch(&c.host, &c.window, c.arg("id")?, c.arg("path")?) });
|
||||
d.add("vcs_changelists_load", |c| async move { vcs_changelists_load(&c.host, &c.window) });
|
||||
d.add("vcs_changelists_save", |c| async move { vcs_changelists_save(&c.host, &c.window, c.arg("changelists")?) });
|
||||
d.add(
|
||||
|
||||
+327
-43
@@ -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;
|
||||
@@ -15,9 +15,11 @@ use ide_workspace::{DocumentInfo, Documents, FileMoves, LocalHistory, ProjectRoo
|
||||
use parking_lot::{Mutex, RwLock};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
/// Everything bound to one open project folder.
|
||||
pub struct Project {
|
||||
pub(crate) trusted: Arc<AtomicBool>,
|
||||
pub root: PathBuf,
|
||||
/// Window-scoped event sink, for commands that emit project events.
|
||||
pub sink: SharedSink,
|
||||
@@ -46,6 +48,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 +69,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)
|
||||
@@ -76,23 +82,19 @@ impl Host {
|
||||
self.close_project(window);
|
||||
let sink: SharedSink = (self.sink_for)(window);
|
||||
// Before anything the project starts: its children get the project's environment.
|
||||
let post_start = self.environment.project_opened(&root, &sink);
|
||||
let trusted = Arc::new(AtomicBool::new(self.trust.trusted(&root)?));
|
||||
let post_start =
|
||||
trusted.load(Ordering::Acquire).then(|| self.environment.project_opened(&root, &sink)).flatten();
|
||||
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 +110,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 +118,41 @@ impl Host {
|
||||
)?;
|
||||
mark("index");
|
||||
let lsp = LspManager::new(root.clone(), languages.clone(), sink.clone());
|
||||
lsp.set_execution_allowed(trusted.load(Ordering::Acquire));
|
||||
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 +161,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 +170,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,
|
||||
);
|
||||
}
|
||||
}));
|
||||
}
|
||||
{
|
||||
@@ -180,20 +213,27 @@ impl Host {
|
||||
root: root.clone(),
|
||||
sink: sink.clone(),
|
||||
runtime: self.runtime.clone(),
|
||||
trusted: trusted.clone(),
|
||||
}));
|
||||
|
||||
let repos = Arc::new(RwLock::new(Repos::enclosing(&root)?));
|
||||
for repo in repos.read().all() {
|
||||
repo.set_execution_allowed(trusted.load(Ordering::Acquire));
|
||||
}
|
||||
let watcher = {
|
||||
let repos = repos.clone();
|
||||
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();
|
||||
let history = history.clone();
|
||||
let remote = self.remote.clone();
|
||||
let runtime = self.runtime.clone();
|
||||
let lsp = lsp.clone();
|
||||
let trusted = trusted.clone();
|
||||
Watcher::start(
|
||||
&root.clone(),
|
||||
excludes.clone(),
|
||||
@@ -201,10 +241,17 @@ impl Host {
|
||||
Box::new(move |batch| {
|
||||
let changed: Vec<PathBuf> = batch.created.iter().chain(&batch.modified).cloned().collect();
|
||||
index.update(&changed, &batch.removed);
|
||||
lsp.project_files_changed(batch.created.iter().chain(&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() {
|
||||
if trusted.load(Ordering::Acquire) && !batch.removed.is_empty() {
|
||||
delete_deployed(
|
||||
&runtime,
|
||||
remote.clone(),
|
||||
@@ -221,7 +268,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 +279,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 +289,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,8 +335,13 @@ 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 {
|
||||
trusted,
|
||||
root,
|
||||
sink,
|
||||
languages,
|
||||
@@ -301,6 +359,7 @@ impl Host {
|
||||
db,
|
||||
http_history,
|
||||
agents,
|
||||
notebooks,
|
||||
watcher,
|
||||
content_watchers: Mutex::new(content_watchers),
|
||||
_scratch_watcher: scratch_watcher,
|
||||
@@ -312,6 +371,12 @@ impl Host {
|
||||
weak.upgrade().map(|p| p.reference_candidates(q, cancel)).unwrap_or_default()
|
||||
}));
|
||||
}
|
||||
{
|
||||
let weak = Arc::downgrade(&project);
|
||||
project.lsp.set_project_excludes_hook(Box::new(move || {
|
||||
weak.upgrade().map(|p| p.exclude_globs()).unwrap_or_default()
|
||||
}));
|
||||
}
|
||||
self.insert_project(window, project.clone());
|
||||
if let Some(cmd) = post_start {
|
||||
self.environment.spawn_post_start(cmd, project.root.clone(), project.sink.clone());
|
||||
@@ -375,13 +440,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,7 +452,12 @@ 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 {
|
||||
trusted: Arc::new(AtomicBool::new(true)),
|
||||
sink,
|
||||
languages,
|
||||
settings,
|
||||
@@ -410,6 +474,7 @@ impl Host {
|
||||
db,
|
||||
http_history,
|
||||
agents,
|
||||
notebooks,
|
||||
watcher,
|
||||
content_watchers: Mutex::new(Vec::new()),
|
||||
_scratch_watcher: None,
|
||||
@@ -423,6 +488,18 @@ impl Host {
|
||||
}
|
||||
|
||||
impl Project {
|
||||
pub fn is_trusted(&self) -> bool {
|
||||
self.trusted.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
pub fn require_trust(&self) -> IdeResult<()> {
|
||||
if self.is_trusted() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(IdeError::unavailable("Project is in Restricted Mode. Trust the project to enable execution."))
|
||||
}
|
||||
}
|
||||
|
||||
/// LightEdit: follow external changes to the files of `dir` (its own entries only).
|
||||
pub fn watch_light_dir(&self, dir: &Path) {
|
||||
if !self.light {
|
||||
@@ -440,6 +517,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 +554,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,9 +568,17 @@ 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()
|
||||
let repos = self.repos.read().clone();
|
||||
for repo in repos.all() {
|
||||
repo.set_execution_allowed(self.is_trusted());
|
||||
}
|
||||
repos
|
||||
}
|
||||
|
||||
/// Runs `f` with the repository whose root is `at`, the project's primary repository
|
||||
@@ -511,7 +610,9 @@ impl Project {
|
||||
|
||||
fn run_in<T>(&self, repo: &Repo, f: impl FnOnce(&Repo) -> IdeResult<T>) -> IdeResult<T> {
|
||||
let settings = self.settings.effective();
|
||||
apply_git_executable(&settings);
|
||||
if self.is_trusted() {
|
||||
apply_git_executable(&settings);
|
||||
}
|
||||
repo.set_ignored_names(settings.ignored_names());
|
||||
f(repo)
|
||||
}
|
||||
@@ -567,7 +668,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 +703,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.search_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 +730,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 +756,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 +776,39 @@ 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<ide_vcs::TextEncoding> {
|
||||
let charset = self.file_encoding(path, rev)?;
|
||||
let encoding = ide_vcs::TextEncoding::from(charset);
|
||||
Ok(if self.documents.native_to_ascii_for(path) {
|
||||
encoding.with_ascii_conversion(ide_vfs::decode_properties_escapes, ide_vfs::encode_properties_ascii)
|
||||
} else {
|
||||
encoding
|
||||
})
|
||||
}
|
||||
|
||||
fn file_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_text::normalize_line_endings(&self.documents.load_file(path)?.text).into_owned()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -680,6 +816,8 @@ impl Project {
|
||||
/// Applies a multi-file edit through the document layer so open editors update live.
|
||||
pub fn apply_workspace_edit(documents: &Documents, edit: &ide_core::WorkspaceEdit) -> IdeResult<()> {
|
||||
use ide_core::FileEdit;
|
||||
let mut edit = edit.clone();
|
||||
edit.deduplicate();
|
||||
for file in &edit.files {
|
||||
match file {
|
||||
FileEdit::Edit { path, edits } => {
|
||||
@@ -735,8 +873,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 +919,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;
|
||||
@@ -851,6 +1011,7 @@ impl DocumentObserver for HistoryObserver {
|
||||
|
||||
/// Uploads saved files: remote files' local copies, and automatic upload to the default server.
|
||||
struct DeployObserver {
|
||||
trusted: Arc<AtomicBool>,
|
||||
remote: Arc<ide_remote::RemoteService>,
|
||||
settings: Arc<SettingsStore>,
|
||||
root: PathBuf,
|
||||
@@ -860,6 +1021,9 @@ struct DeployObserver {
|
||||
|
||||
impl DocumentObserver for DeployObserver {
|
||||
fn saved(&self, _doc: ide_core::DocId, path: &Path, _text: &str) {
|
||||
if !self.trusted.load(Ordering::Acquire) {
|
||||
return;
|
||||
}
|
||||
let (remote, settings, root, sink, path) =
|
||||
(self.remote.clone(), self.settings.clone(), self.root.clone(), self.sink.clone(), path.to_path_buf());
|
||||
self.runtime.spawn(async move {
|
||||
@@ -906,3 +1070,123 @@ 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));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod workspace_edit_tests {
|
||||
use super::*;
|
||||
use ide_core::{FileEdit, LanguageId, Pos, Range, TextEdit, WorkspaceEdit};
|
||||
|
||||
#[test]
|
||||
fn rename_via_symlink_updates_the_open_document_once() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let real = dir.path().join("main.cpp");
|
||||
let alias = dir.path().join("alias.cpp");
|
||||
std::fs::write(&real, "add();\n").unwrap();
|
||||
std::os::unix::fs::symlink(&real, &alias).unwrap();
|
||||
let documents = Documents::new(Arc::new(|_: IdeEvent| {}), Box::new(|_, _| LanguageId::new("cpp")));
|
||||
let doc = documents.open(&alias).unwrap();
|
||||
let changes = |path: PathBuf| FileEdit::Edit {
|
||||
path: path.into(),
|
||||
edits: vec![TextEdit { range: Range::new(Pos::new(0, 0), Pos::new(0, 3)), new_text: "sum_values".into() }],
|
||||
};
|
||||
apply_workspace_edit(&documents, &WorkspaceEdit { files: vec![changes(real.clone()), changes(alias)] })
|
||||
.unwrap();
|
||||
assert_eq!(std::fs::read_to_string(real).unwrap(), "sum_values();\n");
|
||||
assert_eq!(documents.snapshot(doc.id).unwrap().text, "sum_values();\n");
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@ use std::path::PathBuf;
|
||||
use std::pin::Pin;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
pub const PROTOCOL_VERSION: u32 = 1;
|
||||
pub const PROTOCOL_VERSION: u32 = 2;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -138,6 +138,15 @@ impl Dispatcher {
|
||||
/// terminals, runs and VCS status.
|
||||
pub fn standard() -> Self {
|
||||
let mut d = Self::default();
|
||||
d.add("project_trust_status", |c| async move { c.host.trust.status(&c.arg::<PathBuf>("path")?) })
|
||||
.add("project_trust_list", |c| async move { c.host.trust.decisions() })
|
||||
.add("project_trust_set", |c| async move {
|
||||
c.host.set_project_trust(&c.arg::<PathBuf>("path")?, c.arg("trusted")?, c.arg("folder")?).await
|
||||
})
|
||||
.add("project_trust_remove_location", |c| async move {
|
||||
c.host.trust.remove_location(&c.arg::<PathBuf>("path")?)?;
|
||||
c.host.refresh_project_trust().await
|
||||
});
|
||||
d.add("open_project", |c| async move {
|
||||
let path: PathBuf = c.arg("path")?;
|
||||
let project = ops::project::open(&c.host, &c.window, &path).await?;
|
||||
@@ -148,6 +157,9 @@ impl Dispatcher {
|
||||
c.host.close_project(&c.window);
|
||||
Ok(())
|
||||
})
|
||||
.add("jvm_file_presentations", |c| async move {
|
||||
ops::jvm_icons::jvm_file_presentations(&c.host, &c.window, c.arg("paths")?).await
|
||||
})
|
||||
.add("project_roots", |c| async move { ops::project::roots(&c.host, &c.window) })
|
||||
.add("settings_get", |c| async move { Ok(ops::project::settings(&c.host, &c.window)) })
|
||||
.add("settings_set", |c| async move {
|
||||
@@ -168,6 +180,9 @@ impl Dispatcher {
|
||||
.add("cancel_task", |c| async move { Ok(ide_core::task::cancel(c.arg::<TaskId>("id")?)) })
|
||||
.add("fs_read_dir", |c| async move { ops::fs::read_dir(&c.host, &c.window, c.arg("path")?) })
|
||||
.add("fs_create_file", |c| async move { ops::fs::create_file(&c.host, &c.window, c.arg("path")?) })
|
||||
.add("fs_create_file_bytes", |c| async move {
|
||||
ops::fs::create_file_bytes(&c.host, c.arg("path")?, c.arg("data")?).await
|
||||
})
|
||||
.add("fs_create_dir", |c| async move { ide_vfs::create_dir(&c.arg::<PathBuf>("path")?) })
|
||||
.add("fs_rename", |c| async move { ide_vfs::rename(&c.arg::<PathBuf>("from")?, &c.arg::<PathBuf>("to")?) })
|
||||
.add("fs_set_writable", |c| async move {
|
||||
@@ -183,6 +198,7 @@ impl Dispatcher {
|
||||
})
|
||||
.add("doc_save", |c| async move { ops::doc::save(&c.host, &c.window, c.arg("doc")?).await })
|
||||
.add("doc_save_all", |c| async move { ops::doc::save_all(&c.host, &c.window).await })
|
||||
.add("doc_sync_from_disk", |c| async move { ops::doc::sync_from_disk(&c.host, &c.window).await })
|
||||
.add("doc_snapshot", |c| async move { ops::doc::snapshot(&c.host, &c.window, c.arg("doc")?).await })
|
||||
.add("search_start", |c| async move {
|
||||
let on_event = c.channel("onEvent")?;
|
||||
@@ -200,7 +216,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 {
|
||||
@@ -279,6 +300,7 @@ impl Handler for ServerHandler {
|
||||
let Some(handler) = self.dispatcher.handlers.get(call.cmd.as_str()).cloned() else {
|
||||
return Err((codes::METHOD_NOT_FOUND, format!("command {} is not available remotely", call.cmd)));
|
||||
};
|
||||
self.host.authorize_command(&call.window, &call.cmd).map_err(error_reply)?;
|
||||
let c = Call { host: self.host.clone(), window: call.window, args: call.args, peer: self.peer.clone() };
|
||||
// Requests run on blocking-capable threads: wait for the command here.
|
||||
self.host.runtime.block_on(handler(c)).map_err(error_reply)
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
//! User-owned project execution decisions. Project settings never grant trust.
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use parking_lot::Mutex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Clone, Default, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TrustDecisions {
|
||||
pub projects: Vec<PathBuf>,
|
||||
pub locations: Vec<PathBuf>,
|
||||
pub restricted: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
pub struct TrustStore {
|
||||
file: PathBuf,
|
||||
state: Mutex<IdeResult<TrustDecisions>>,
|
||||
}
|
||||
|
||||
impl TrustStore {
|
||||
pub fn load(config: &Path, data: &Path) -> Self {
|
||||
let file = data.join("project-trust.json");
|
||||
let state = match std::fs::read(&file) {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes).map_err(|e| IdeError::invalid(e.to_string())),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
let projects = ide_workspace::RecentProjects::load(config)
|
||||
.paths()
|
||||
.into_iter()
|
||||
.filter_map(|p| dunce::canonicalize(p).ok())
|
||||
.collect();
|
||||
let migrated = TrustDecisions { projects, ..Default::default() };
|
||||
persist(&file, &migrated).map(|()| migrated)
|
||||
}
|
||||
Err(e) => Err(e.into()),
|
||||
};
|
||||
Self { file, state: Mutex::new(state) }
|
||||
}
|
||||
|
||||
pub fn decisions(&self) -> IdeResult<TrustDecisions> {
|
||||
self.state.lock().clone()
|
||||
}
|
||||
|
||||
pub fn trusted(&self, path: &Path) -> IdeResult<bool> {
|
||||
let root = dunce::canonicalize(path)?;
|
||||
let decisions = self.decisions()?;
|
||||
Ok(!decisions.restricted.contains(&root)
|
||||
&& (decisions.projects.contains(&root) || decisions.locations.iter().any(|p| root.starts_with(p))))
|
||||
}
|
||||
|
||||
pub fn decide(&self, path: &Path, trusted: bool, folder: bool) -> IdeResult<()> {
|
||||
let root = match dunce::canonicalize(path) {
|
||||
Ok(root) => root,
|
||||
Err(_) if !trusted && path.is_absolute() => path.to_path_buf(),
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
let mut state = self.state.lock();
|
||||
let mut next = state.clone()?;
|
||||
next.projects.retain(|p| p != &root);
|
||||
next.restricted.retain(|p| p != &root);
|
||||
if trusted {
|
||||
next.projects.push(root.clone());
|
||||
if folder && let Some(parent) = root.parent() {
|
||||
let parent = parent.to_path_buf();
|
||||
if !next.locations.contains(&parent) {
|
||||
next.locations.push(parent);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
next.restricted.push(root);
|
||||
}
|
||||
persist(&self.file, &next)?;
|
||||
*state = Ok(next);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn remove_location(&self, path: &Path) -> IdeResult<()> {
|
||||
let mut state = self.state.lock();
|
||||
let mut next = state.clone()?;
|
||||
next.locations.retain(|p| p != path);
|
||||
persist(&self.file, &next)?;
|
||||
*state = Ok(next);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn persist(file: &Path, state: &TrustDecisions) -> IdeResult<()> {
|
||||
if let Some(parent) = file.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let bytes = serde_json::to_vec_pretty(state).map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
ide_vfs::write_atomic(file, &bytes)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl crate::Host {
|
||||
pub async fn set_project_trust(&self, path: &Path, trusted: bool, folder: bool) -> IdeResult<()> {
|
||||
self.trust.decide(path, trusted, folder)?;
|
||||
self.refresh_project_trust().await
|
||||
}
|
||||
|
||||
pub async fn refresh_project_trust(&self) -> IdeResult<()> {
|
||||
for project in self.projects().into_iter().filter(|p| !p.light) {
|
||||
let trusted = self.trust.trusted(&project.root).unwrap_or(false);
|
||||
let previous = project.trusted.swap(trusted, std::sync::atomic::Ordering::AcqRel);
|
||||
if previous == trusted {
|
||||
continue;
|
||||
}
|
||||
if !trusted {
|
||||
project.lsp.set_execution_allowed(false);
|
||||
}
|
||||
project.repos();
|
||||
if trusted {
|
||||
if let Some(cmd) = self.environment.project_opened(&project.root, &project.sink) {
|
||||
self.environment.spawn_post_start(cmd, project.root.clone(), project.sink.clone());
|
||||
}
|
||||
project.lsp.set_execution_allowed(true);
|
||||
} else {
|
||||
self.environment.project_closed();
|
||||
project.agents.shutdown();
|
||||
self.processes.kill_project(&project.root);
|
||||
project.notebooks.restrict().await;
|
||||
let windows: Vec<_> = self
|
||||
.projects
|
||||
.read()
|
||||
.iter()
|
||||
.filter(|(_, current)| std::sync::Arc::ptr_eq(current, &project))
|
||||
.map(|(window, _)| window.clone())
|
||||
.collect();
|
||||
for window in windows {
|
||||
let requests = self.project_requests.write().remove(&window).unwrap_or_default();
|
||||
for request in requests {
|
||||
self.cancel_request(request);
|
||||
}
|
||||
for session in self.debugger.sessions(Some(&window)) {
|
||||
let _ = self.debugger.stop(session.id).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
project.lsp.reconfigure().await;
|
||||
project.sink.emit(ide_core::IdeEvent::ProjectTrustChanged { root: project.root.clone().into(), trusted });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Commands which only inspect/edit source or operate user-owned application state.
|
||||
/// New execution commands are denied until deliberately classified here.
|
||||
pub fn restricted_command_allowed(cmd: &str) -> bool {
|
||||
if [
|
||||
"fs_",
|
||||
"doc_",
|
||||
"syntax_",
|
||||
"search_",
|
||||
"nav_",
|
||||
"history_",
|
||||
"spell_",
|
||||
"analysis_",
|
||||
"structural_",
|
||||
"refactor_",
|
||||
"bytes_",
|
||||
"window_",
|
||||
"window_group_",
|
||||
"term_",
|
||||
"settings_",
|
||||
"project_trust_",
|
||||
"ssh_",
|
||||
"git_credential",
|
||||
]
|
||||
.iter()
|
||||
.any(|p| cmd.starts_with(p))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
matches!(
|
||||
cmd,
|
||||
"app_info"
|
||||
| "app_metrics"
|
||||
| "app_quit"
|
||||
| "app_cancel_quit"
|
||||
| "app_restart"
|
||||
| "languages"
|
||||
| "menu_refresh"
|
||||
| "menu_model"
|
||||
| "menu_configure"
|
||||
| "menu_set_disabled"
|
||||
| "lsp_servers"
|
||||
| "lsp_resend_diagnostics"
|
||||
| "recent_projects"
|
||||
| "remove_recent_project"
|
||||
| "open_project"
|
||||
| "close_project"
|
||||
| "project_current"
|
||||
| "project_roots"
|
||||
| "index_status"
|
||||
| "jvm_file_presentations"
|
||||
| "deploy_coverage"
|
||||
| "find_files"
|
||||
| "find_symbols"
|
||||
| "mark_recent"
|
||||
| "remove_recent"
|
||||
| "recent_files"
|
||||
| "web_goto"
|
||||
| "path_completions"
|
||||
| "web_unresolved_references"
|
||||
| "project_rename"
|
||||
| "light_edit_open"
|
||||
| "workspace_create"
|
||||
| "workspace_update"
|
||||
| "workspace_attach"
|
||||
| "cancel_request"
|
||||
| "cancel_task"
|
||||
| "log_frontend"
|
||||
| "env_reload"
|
||||
| "remote_invoke"
|
||||
| "remote_current"
|
||||
| "remote_disconnect"
|
||||
| "remote_connect"
|
||||
| "remote_list"
|
||||
| "remote_open"
|
||||
| "remote_of"
|
||||
| "web_browsers"
|
||||
| "web_url"
|
||||
| "web_open"
|
||||
| "debug_attachable_processes"
|
||||
| "agent_subscribe"
|
||||
| "agent_entries"
|
||||
| "agent_commands"
|
||||
| "agent_unqueue"
|
||||
| "agent_cancel"
|
||||
| "agent_archive"
|
||||
| "agent_unarchive"
|
||||
| "agent_registry_list"
|
||||
| "agent_registry_install"
|
||||
| "agent_registry_remove"
|
||||
| "coverage_import"
|
||||
| "system_font_families"
|
||||
| "plugin_subscribe"
|
||||
| "plugin_list"
|
||||
| "plugin_set_active_file"
|
||||
| "plugin_set_theme"
|
||||
| "plugin_set_theme_frame"
|
||||
| "plugin_catalog"
|
||||
| "notebook_open"
|
||||
| "notebook_get"
|
||||
| "notebook_edit"
|
||||
| "notebook_save"
|
||||
| "notebook_undo"
|
||||
| "notebook_redo"
|
||||
| "notebook_reload"
|
||||
| "notebook_close"
|
||||
| "notebook_interrupt"
|
||||
| "run_stop"
|
||||
| "run_list"
|
||||
| "debug_stop"
|
||||
| "debug_sessions"
|
||||
| "vcs_clone"
|
||||
| "vcs_init"
|
||||
| "vcs_status"
|
||||
| "vcs_roots"
|
||||
| "vcs_branch_info"
|
||||
| "vcs_line_hunks"
|
||||
| "vcs_file_at"
|
||||
| "vcs_blame"
|
||||
| "vcs_log"
|
||||
| "vcs_commit_files"
|
||||
| "vcs_commits_changes"
|
||||
| "vcs_branches"
|
||||
| "vcs_stashes"
|
||||
| "vcs_conflict_versions"
|
||||
| "vcs_diff_text"
|
||||
| "vcs_diff_hunks"
|
||||
| "vcs_detected_git"
|
||||
| "vcs_config_get"
|
||||
| "vcs_tags"
|
||||
| "vcs_remotes"
|
||||
| "vcs_worktrees"
|
||||
| "vcs_diff_with_worktree"
|
||||
| "vcs_diff_revisions"
|
||||
| "vcs_merge_base"
|
||||
| "vcs_shelves"
|
||||
| "vcs_shelf_patch"
|
||||
| "vcs_changelists_load"
|
||||
) || cmd.starts_with("plugin:")
|
||||
}
|
||||
|
||||
impl crate::Host {
|
||||
pub fn authorize_command(&self, window: &str, command: &str) -> IdeResult<()> {
|
||||
if restricted_command_allowed(command) {
|
||||
return Ok(());
|
||||
}
|
||||
self.project(window)?.require_trust()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TrustStatus {
|
||||
pub root: ide_core::IpcPath,
|
||||
pub trusted: bool,
|
||||
pub decided: bool,
|
||||
}
|
||||
impl TrustStore {
|
||||
pub fn status(&self, path: &Path) -> IdeResult<TrustStatus> {
|
||||
let root = dunce::canonicalize(path)?;
|
||||
let trusted = self.trusted(&root)?;
|
||||
let state = self.decisions()?;
|
||||
Ok(TrustStatus { decided: trusted || state.restricted.contains(&root), root: root.into(), trusted })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
use ide_core::{ChangeSet, NullSink, RequestId, TextChange};
|
||||
use ide_host::{
|
||||
Host, HostConfig,
|
||||
ops::{doc, search, vcs},
|
||||
};
|
||||
use ide_search::{SearchQuery, SearchScope};
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
process::Command,
|
||||
sync::{Arc, Mutex},
|
||||
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));
|
||||
}
|
||||
|
||||
fn host(base: &Path) -> 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,
|
||||
})
|
||||
}
|
||||
|
||||
async fn find(host: &Host, paths: Vec<PathBuf>, pattern: &str) -> Vec<ide_search::FileMatches> {
|
||||
let files = Arc::new(Mutex::new(Vec::new()));
|
||||
let found = files.clone();
|
||||
search::start(
|
||||
host,
|
||||
"main",
|
||||
RequestId(519),
|
||||
SearchQuery {
|
||||
pattern: pattern.into(),
|
||||
is_regex: false,
|
||||
case_sensitive: true,
|
||||
whole_word: false,
|
||||
include: Vec::new(),
|
||||
exclude: Vec::new(),
|
||||
scope: SearchScope::Files { paths: paths.into_iter().map(Into::into).collect() },
|
||||
max_matches: 0,
|
||||
context: Default::default(),
|
||||
},
|
||||
Arc::new(move |event| {
|
||||
if let search::SearchEvent::File { file } = event {
|
||||
found.lock().unwrap().push(file);
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
Arc::try_unwrap(files).unwrap().into_inner().unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn properties_search_git_and_document_coordinates_agree() {
|
||||
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("messages.properties");
|
||||
let original = "hello=\\u4f60\\u597d\\uD83D\\uDE00target\nsecond=unchanged\nescaped=line\\u000Alogical\n";
|
||||
std::fs::write(&path, original).unwrap();
|
||||
let host = host(&base);
|
||||
host.trust.decide(&root, true, false).unwrap();
|
||||
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;
|
||||
}
|
||||
git(&root, &["add", "."]);
|
||||
git(&root, &["commit", "-m", "Properties"]);
|
||||
let mut defaults = project.documents.file_defaults();
|
||||
defaults.native_to_ascii = true;
|
||||
project.documents.set_file_defaults(defaults);
|
||||
let displayed = "hello=你好😀target\nsecond=unchanged\nescaped=line\\u000Alogical\n";
|
||||
assert_eq!(project.text_of(&path).unwrap(), displayed);
|
||||
let closed = find(&host, vec![path.clone()], "target").await;
|
||||
let closed_json = serde_json::to_value(&closed).unwrap();
|
||||
let opened = doc::open(&host, "main", path.clone()).await.unwrap();
|
||||
assert_eq!(opened.text, displayed);
|
||||
assert_eq!(serde_json::to_value(find(&host, vec![path.clone()], "target").await).unwrap(), closed_json);
|
||||
assert_eq!(closed[0].matches[0].ranges, vec![(10, 16)]);
|
||||
assert_eq!(find(&host, vec![path.clone()], "你好").await[0].matches.len(), 1);
|
||||
assert!(find(&host, vec![path.clone()], r"\u4f60").await.is_empty());
|
||||
assert!(vcs::vcs_line_hunks(&host, "main", path.clone()).await.unwrap().is_empty());
|
||||
assert_eq!(vcs::vcs_file_at(&host, "main", path.clone(), None).await.unwrap().as_deref(), Some(displayed));
|
||||
assert!(vcs::vcs_blame(&host, "main", path.clone(), None, None).await.unwrap().iter().all(|l| l.author == "Test"));
|
||||
doc::edit(
|
||||
&host,
|
||||
"main",
|
||||
opened.id,
|
||||
opened.version,
|
||||
ChangeSet { changes: vec![TextChange { from: 10, to: 16, insert: "世界".into() }] },
|
||||
)
|
||||
.unwrap();
|
||||
let hunks = vcs::vcs_line_hunks(&host, "main", path.clone()).await.unwrap();
|
||||
assert_eq!(hunks.len(), 1);
|
||||
assert_eq!(hunks[0].old_text, "hello=你好😀target\n");
|
||||
doc::save(&host, "main", opened.id).await.unwrap();
|
||||
let saved = std::fs::read(&path).unwrap();
|
||||
assert!(saved.is_ascii());
|
||||
assert!(String::from_utf8(saved.clone()).unwrap().contains(r"\u4F60\u597D\uD83D\uDE00\u4E16\u754C"));
|
||||
vcs::vcs_stage_hunks(&host, "main", path.clone(), vec![0]).await.unwrap();
|
||||
let repo = ide_vcs::Repo::discover(&root).unwrap().unwrap();
|
||||
assert_eq!(repo.file_bytes_at(&path, Some(":0")).unwrap().unwrap(), saved);
|
||||
repo.unstage(std::slice::from_ref(&path)).unwrap();
|
||||
// Line staging must match the decoded base even when Git stores lowercase escapes.
|
||||
let patch = ide_vcs::build_partial_patch("messages.properties", displayed, &project.text_of(&path).unwrap(), &[0]);
|
||||
repo.stage_patch(&patch, &|p| project.vcs_encoding(p, Some(":0"))).unwrap();
|
||||
assert_eq!(repo.file_bytes_at(&path, Some(":0")).unwrap().unwrap(), saved);
|
||||
repo.unstage(std::slice::from_ref(&path)).unwrap();
|
||||
let snap = project.documents.snapshot(opened.id).unwrap();
|
||||
project.documents.replace_all(opened.id, displayed).unwrap();
|
||||
doc::save(&host, "main", opened.id).await.unwrap();
|
||||
assert!(project.documents.snapshot(opened.id).unwrap().version > snap.version);
|
||||
assert!(vcs::vcs_line_hunks(&host, "main", path.clone()).await.unwrap().is_empty());
|
||||
// Partial commits identify hunks in displayed line coordinates as well.
|
||||
let partial_text = displayed.replace("target", "世界").replace("logical", "changed");
|
||||
project.documents.replace_all(opened.id, &partial_text).unwrap();
|
||||
doc::save(&host, "main", opened.id).await.unwrap();
|
||||
let hunks = ide_vcs::diff_hunks(displayed, &partial_text);
|
||||
assert_eq!(hunks.len(), 2);
|
||||
let selected = &hunks[0];
|
||||
let options = ide_vcs::CommitOptions {
|
||||
partial: vec![ide_vcs::PartialCommitFile {
|
||||
path: path.clone().into(),
|
||||
hunks: vec![ide_vcs::HunkRange {
|
||||
start: selected.start,
|
||||
lines: selected.lines,
|
||||
old_start: selected.old_start,
|
||||
old_lines: selected.old_lines,
|
||||
}],
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
repo.commit_with("Selected properties change", &options, &|p| project.vcs_encoding(p, None)).unwrap();
|
||||
let head = vcs::vcs_file_at(&host, "main", path.clone(), None).await.unwrap().unwrap();
|
||||
assert_eq!(head, displayed.replace("target", "世界"));
|
||||
assert!(repo.file_bytes_at(&path, None).unwrap().unwrap().is_ascii());
|
||||
let closed_path = root.join("closed.properties");
|
||||
std::fs::write(&closed_path, r"value=\u4F60\u597D\uD83D\uDE00target").unwrap();
|
||||
let query = SearchQuery {
|
||||
pattern: "target".into(),
|
||||
is_regex: false,
|
||||
case_sensitive: true,
|
||||
whole_word: false,
|
||||
include: Vec::new(),
|
||||
exclude: Vec::new(),
|
||||
scope: SearchScope::Files { paths: vec![closed_path.clone().into()] },
|
||||
max_matches: 0,
|
||||
context: Default::default(),
|
||||
};
|
||||
search::replace_in_files(
|
||||
&host,
|
||||
"main",
|
||||
RequestId(520),
|
||||
query,
|
||||
"世界".into(),
|
||||
false,
|
||||
vec![ide_search::ReplaceTarget {
|
||||
path: closed_path.clone().into(),
|
||||
only: Some(vec![ide_core::Pos { line: 0, col: 10 }]),
|
||||
}],
|
||||
ide_host::ops::Channel::new(|_| {}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&closed_path).unwrap(), r"value=\u4F60\u597D\uD83D\uDE00\u4E16\u754C");
|
||||
assert!(project.documents.by_path(&closed_path).is_none());
|
||||
host.close_project("main");
|
||||
}
|
||||
@@ -100,6 +100,7 @@ fn server_environment_from_login_shell_and_devcontainer() {
|
||||
"postStartCommand": ["/bin/sh", "-c", "echo \"$FROM_PROJECT\" >> started.txt; echo post-start-output"],
|
||||
}"#,
|
||||
);
|
||||
host.trust.decide(&project, true, false).unwrap();
|
||||
runtime.block_on(ide_host::ops::project::open(&host, "main", &project)).unwrap();
|
||||
assert_eq!(child_var("FROM_PROJECT"), "two-demo");
|
||||
assert!(child_var("PATH").starts_with("/project/bin:"), "{}", child_var("PATH"));
|
||||
@@ -158,6 +159,7 @@ fn server_environment_from_login_shell_and_devcontainer() {
|
||||
// Reopening the project doesn't run postStartCommand again (once per server start).
|
||||
host.close_project("main");
|
||||
assert_eq!(child_var("FROM_PROJECT"), "", "remoteEnv leaves with the project");
|
||||
host.trust.decide(&project, true, false).unwrap();
|
||||
runtime.block_on(ide_host::ops::project::open(&host, "main", &project)).unwrap();
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
assert_eq!(std::fs::read_to_string(&started).unwrap(), "two-demo\n");
|
||||
@@ -165,6 +167,7 @@ fn server_environment_from_login_shell_and_devcontainer() {
|
||||
// A failing postStartCommand is a notification; the project opens regardless.
|
||||
let failing = tmp.join("failing");
|
||||
write(&failing.join(".devcontainer.json"), r#"{ "postStartCommand": "echo oops >&2; exit 7" }"#);
|
||||
host.trust.decide(&failing, true, false).unwrap();
|
||||
runtime.block_on(ide_host::ops::project::open(&host, "main", &failing)).unwrap();
|
||||
wait_for("the failure notification", || {
|
||||
events.0.lock().iter().any(|e| {
|
||||
@@ -176,6 +179,7 @@ fn server_environment_from_login_shell_and_devcontainer() {
|
||||
// A broken file is a warning, not an error.
|
||||
let broken = tmp.join("broken");
|
||||
write(&broken.join(".devcontainer.json"), "{ \"remoteEnv\": ");
|
||||
host.trust.decide(&broken, true, false).unwrap();
|
||||
runtime.block_on(ide_host::ops::project::open(&host, "main", &broken)).unwrap();
|
||||
assert!(events.0.lock().iter().any(|e| matches!(e,
|
||||
IdeEvent::Notify { level: NotifyLevel::Warning, title, .. } if title == "devcontainer.json was not applied")));
|
||||
|
||||
Loaded 100 of 1037 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user