diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index 6ad430750..4fb67fbfe 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -9,7 +9,8 @@
## Review focus
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 2a6b1f2a0..25606eee2 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -7,6 +7,9 @@ on:
types: [opened, synchronize, reopened]
workflow_dispatch:
+permissions:
+ contents: read
+
concurrency:
group: ci-${{ github.ref }}
# A newer push supersedes the run in progress, on main too.
@@ -19,8 +22,50 @@ env:
CARGO_PROFILE_DEV_DEBUG: line-tables-only
jobs:
+ # Which parts a pull request touches, so jobs it can't affect skip and leave the
+ # shared runners to others. Pushes and manual runs always run everything.
+ changes:
+ name: Changes
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ permissions:
+ pull-requests: read
+ outputs:
+ rust: ${{ steps.classify.outputs.rust }}
+ frontend: ${{ steps.classify.outputs.frontend }}
+ steps:
+ - id: classify
+ env:
+ GH_TOKEN: ${{ github.token }}
+ PR: ${{ github.event.pull_request.number }}
+ run: |
+ set -euo pipefail
+ if [ "$GITHUB_EVENT_NAME" != pull_request ]; then
+ echo "rust=true" >> "$GITHUB_OUTPUT"
+ echo "frontend=true" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+ gh api --paginate "repos/$GITHUB_REPOSITORY/pulls/$PR/files" \
+ --jq '.[] | .filename, (.previous_filename // empty)' > files.txt
+ cat files.txt
+ docs='^(docs/|\.agents/|\.claude/|\.idea/|\.github/ISSUE_TEMPLATE/|\.github/pull_request_template\.md$|[^/]+\.md$|LICENSE$|NOTICE$)'
+ # Frontend-only paths. The Rust code embeds a few files under src/; those stay out.
+ web='^(src/|e2e/|perf/|public/|scripts/|smoke/|index\.html$|vite\.config\.ts$|tsconfig[^/]*\.json$|knip\.ts$|playwright\.config\.ts$|\.oxlintrc\.json$|\.oxfmtrc\.json$)'
+ embedded='^src/(bindings\.ts|styles/tokens\.css|features/plugins/views/frame-bridge\.js|platform/keymaps/)'
+ # A job runs when any changed file falls outside the paths that can't affect it.
+ rust=false
+ if grep -Ev "$docs|$web" files.txt | grep -q . || grep -Eq "$embedded" files.txt; then rust=true; fi
+ frontend=false
+ if grep -Ev "$docs" files.txt | grep -q .; then frontend=true; fi
+ echo "rust=$rust frontend=$frontend"
+ echo "rust=$rust" >> "$GITHUB_OUTPUT"
+ echo "frontend=$frontend" >> "$GITHUB_OUTPUT"
+
rust:
name: Rust (${{ matrix.os }})
+ needs: changes
+ if: needs.changes.outputs.rust == 'true'
+ timeout-minutes: 60
strategy:
fail-fast: false
matrix:
@@ -62,6 +107,9 @@ jobs:
# keep that build working.
server:
name: Remote server (linux musl)
+ needs: changes
+ if: needs.changes.outputs.rust == 'true'
+ timeout-minutes: 40
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v5
@@ -77,6 +125,9 @@ jobs:
# own workspaces (the root excludes them), so the jobs above don't see them.
plugins:
name: Plugins (SDK, official, fixtures)
+ needs: changes
+ if: needs.changes.outputs.rust == 'true'
+ timeout-minutes: 30
runs-on: ubuntu-22.04
env:
CARGO_TARGET_DIR: ${{ github.workspace }}/target-plugins
@@ -126,6 +177,9 @@ jobs:
deny:
name: Cargo deny
+ needs: changes
+ if: needs.changes.outputs.rust == 'true'
+ timeout-minutes: 15
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
@@ -135,6 +189,9 @@ jobs:
frontend:
name: Frontend
+ needs: changes
+ if: needs.changes.outputs.frontend == 'true'
+ timeout-minutes: 30
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v5
@@ -152,6 +209,9 @@ jobs:
e2e:
name: E2E
+ needs: changes
+ if: needs.changes.outputs.frontend == 'true'
+ timeout-minutes: 30
runs-on: ubuntu-24.04
# Browsers and their system libraries are preinstalled; keep the tag in step with @playwright/test.
container: mcr.microsoft.com/playwright:v1.64.1-noble
diff --git a/.github/workflows/language-smoke.yml b/.github/workflows/language-smoke.yml
index 03f80a688..b349d2d53 100644
--- a/.github/workflows/language-smoke.yml
+++ b/.github/workflows/language-smoke.yml
@@ -11,10 +11,7 @@ name: Language smoke
on:
push:
branches: [main]
- pull_request:
- branches: [main]
- types: [opened, synchronize, reopened]
- paths:
+ paths: &paths
- "crates/**"
- "languages/**"
- "fixtures/**"
@@ -27,6 +24,10 @@ on:
- "package.json"
- "pnpm-lock.yaml"
- ".github/workflows/language-smoke.yml"
+ pull_request:
+ branches: [main]
+ types: [opened, synchronize, reopened]
+ paths: *paths
workflow_dispatch:
concurrency:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 1758e67a4..ed6644138 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -37,6 +37,16 @@ jobs:
echo "::error::tag $TAG does not match tauri.conf.json version $app"
exit 1
fi
+ # A shipped plugin API is frozen: its WIT must be the snapshot api_frozen.rs checks later
+ # versions against (plugins/README.md, "Evolving the plugin API").
+ - name: Plugin API snapshot matches wit/
+ run: |
+ api() { sed -n 's/^package openidea:plugin@\(.*\);$/\1/p' "$1/plugin.wit"; }
+ snapshot=sdk/rust/openidea-plugin/tests/api/released
+ if [ "$(api wit)" != "$(api "$snapshot")" ]; then
+ echo "::error::plugin API $(api wit) ships without a snapshot ($snapshot is $(api "$snapshot")): copy wit/*.wit to $snapshot/"
+ exit 1
+ fi
# The official plugins' public key (from PLUGIN_SIGNING_KEY, the seed
# `openidea-plugin keygen` writes), built into the app: their packages
# must carry its signature whatever the marketplace index lists. Without
diff --git a/AGENTS.md b/AGENTS.md
index 54cc95ae4..50ad7debe 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -51,7 +51,8 @@ Visual style: dark, low-contrast greys, one cool accent
Follow `CONTRIBUTING.md` and `.github/pull_request_template.md`: one problem per PR
(`Closes #` when it resolves an issue), reproduce first, keep the diff focused, short plain description, and a
-**How it was verified** section with commands run, results and (for UI) before/after screenshots.
+**How it was verified** section with commands run, results and, when the change alters what users
+see, before/after screenshots (not for UI removed along with a feature, copy-only or non-visual changes).
Unclear product decisions → `needs-decision` and stop.
## Skills
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 6e02bd6bf..2b4544255 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -25,8 +25,9 @@ Project layout, coding rules, commands and checks are in [AGENTS.md](AGENTS.md).
- Fill in the template. Keep the description short and in plain words. If it
doesn't fit in a few sentences, the PR is probably too big.
- **How it was verified** is required: the commands you ran and their results,
- and what else the change could affect. UI changes need before/after screenshots;
- multi-step interactions need a recording.
+ and what else the change could affect. Changes to what users see need before/after
+ screenshots; multi-step interactions need a recording. UI that goes away with a removed
+ feature, copy-only and non-visual changes don't; say what you checked instead.
- If a change needs a product decision you can't make, ask in the issue and stop
rather than guessing.
diff --git a/Cargo.lock b/Cargo.lock
index 317a7da60..5a3e48cb0 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1496,7 +1496,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a"
dependencies = [
"quote",
- "syn 3.0.9",
+ "syn 2.0.119",
]
[[package]]
@@ -3001,7 +3001,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c48eccd894bd15555b0346470e63491ded94aefe76b3fcb1052084b5a393238"
dependencies = [
"bstr",
- "hashbrown 0.17.1",
+ "hashbrown 0.16.1",
]
[[package]]
@@ -4071,7 +4071,7 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "ide-agent"
-version = "0.1.36"
+version = "0.1.37"
dependencies = [
"dunce",
"encoding_rs",
@@ -4094,7 +4094,7 @@ dependencies = [
[[package]]
name = "ide-analysis"
-version = "0.1.36"
+version = "0.1.37"
dependencies = [
"ast-grep-core",
"ide-core",
@@ -4106,9 +4106,20 @@ dependencies = [
"tree-sitter",
]
+[[package]]
+name = "ide-android"
+version = "0.1.37"
+dependencies = [
+ "ide-core",
+ "serde",
+ "specta",
+ "tempfile",
+ "which",
+]
+
[[package]]
name = "ide-build"
-version = "0.1.36"
+version = "0.1.37"
dependencies = [
"dirs 6.0.0",
"dunce",
@@ -4123,7 +4134,7 @@ dependencies = [
[[package]]
name = "ide-clipboard"
-version = "0.1.36"
+version = "0.1.37"
dependencies = [
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
@@ -4132,7 +4143,7 @@ dependencies = [
[[package]]
name = "ide-core"
-version = "0.1.36"
+version = "0.1.37"
dependencies = [
"dunce",
"encoding_rs",
@@ -4149,7 +4160,7 @@ dependencies = [
[[package]]
name = "ide-coverage"
-version = "0.1.36"
+version = "0.1.37"
dependencies = [
"ide-core",
"ide-run",
@@ -4163,7 +4174,7 @@ dependencies = [
[[package]]
name = "ide-dap"
-version = "0.1.36"
+version = "0.1.37"
dependencies = [
"ide-core",
"ide-jsonrpc",
@@ -4181,7 +4192,7 @@ dependencies = [
[[package]]
name = "ide-db"
-version = "0.1.36"
+version = "0.1.37"
dependencies = [
"bb8",
"dirs 6.0.0",
@@ -4207,7 +4218,7 @@ dependencies = [
[[package]]
name = "ide-docker"
-version = "0.1.36"
+version = "0.1.37"
dependencies = [
"ide-core",
"serde",
@@ -4220,7 +4231,7 @@ dependencies = [
[[package]]
name = "ide-extension"
-version = "0.1.36"
+version = "0.1.37"
dependencies = [
"base64 0.22.1",
"blake3",
@@ -4240,7 +4251,7 @@ dependencies = [
[[package]]
name = "ide-fonts"
-version = "0.1.36"
+version = "0.1.37"
dependencies = [
"objc2-core-foundation",
"objc2-core-text",
@@ -4252,7 +4263,7 @@ dependencies = [
[[package]]
name = "ide-github"
-version = "0.1.36"
+version = "0.1.37"
dependencies = [
"ide-core",
"ide-vcs",
@@ -4265,7 +4276,7 @@ dependencies = [
[[package]]
name = "ide-host"
-version = "0.1.36"
+version = "0.1.37"
dependencies = [
"base64 0.22.1",
"criterion",
@@ -4326,7 +4337,7 @@ dependencies = [
[[package]]
name = "ide-http"
-version = "0.1.36"
+version = "0.1.37"
dependencies = [
"ide-core",
"parking_lot",
@@ -4344,7 +4355,7 @@ dependencies = [
[[package]]
name = "ide-index"
-version = "0.1.36"
+version = "0.1.37"
dependencies = [
"blake3",
"criterion",
@@ -4366,7 +4377,7 @@ dependencies = [
[[package]]
name = "ide-jsonrpc"
-version = "0.1.36"
+version = "0.1.37"
dependencies = [
"encoding_rs",
"futures",
@@ -4383,7 +4394,7 @@ dependencies = [
[[package]]
name = "ide-language"
-version = "0.1.36"
+version = "0.1.37"
dependencies = [
"blake3",
"criterion",
@@ -4439,7 +4450,7 @@ dependencies = [
[[package]]
name = "ide-lsp"
-version = "0.1.36"
+version = "0.1.37"
dependencies = [
"dunce",
"futures",
@@ -4465,7 +4476,7 @@ dependencies = [
[[package]]
name = "ide-notebook"
-version = "0.1.36"
+version = "0.1.37"
dependencies = [
"futures",
"ide-core",
@@ -4488,7 +4499,7 @@ dependencies = [
[[package]]
name = "ide-plugin"
-version = "0.1.36"
+version = "0.1.37"
dependencies = [
"blake3",
"ide-core",
@@ -4504,11 +4515,12 @@ dependencies = [
"tracing",
"wasmtime",
"wasmtime-wasi",
+ "wat",
]
[[package]]
name = "ide-profiler"
-version = "0.1.36"
+version = "0.1.37"
dependencies = [
"ide-core",
"ide-run",
@@ -4521,7 +4533,7 @@ dependencies = [
[[package]]
name = "ide-remote"
-version = "0.1.36"
+version = "0.1.37"
dependencies = [
"globset",
"ide-core",
@@ -4538,7 +4550,7 @@ dependencies = [
[[package]]
name = "ide-run"
-version = "0.1.36"
+version = "0.1.37"
dependencies = [
"dunce",
"encoding_rs",
@@ -4561,7 +4573,7 @@ dependencies = [
[[package]]
name = "ide-sdk"
-version = "0.1.36"
+version = "0.1.37"
dependencies = [
"dunce",
"ide-core",
@@ -4580,7 +4592,7 @@ dependencies = [
[[package]]
name = "ide-search"
-version = "0.1.36"
+version = "0.1.37"
dependencies = [
"criterion",
"globset",
@@ -4600,7 +4612,7 @@ dependencies = [
[[package]]
name = "ide-spell"
-version = "0.1.36"
+version = "0.1.37"
dependencies = [
"ide-core",
"parking_lot",
@@ -4613,7 +4625,7 @@ dependencies = [
[[package]]
name = "ide-ssh"
-version = "0.1.36"
+version = "0.1.37"
dependencies = [
"dirs 6.0.0",
"flate2",
@@ -4635,7 +4647,7 @@ dependencies = [
[[package]]
name = "ide-syntax"
-version = "0.1.36"
+version = "0.1.37"
dependencies = [
"ide-core",
"ide-language",
@@ -4650,7 +4662,7 @@ dependencies = [
[[package]]
name = "ide-terminal"
-version = "0.1.36"
+version = "0.1.37"
dependencies = [
"ide-core",
"libc",
@@ -4663,7 +4675,7 @@ dependencies = [
[[package]]
name = "ide-text"
-version = "0.1.36"
+version = "0.1.37"
dependencies = [
"criterion",
"ide-core",
@@ -4676,7 +4688,7 @@ dependencies = [
[[package]]
name = "ide-tools"
-version = "0.1.36"
+version = "0.1.37"
dependencies = [
"flate2",
"hex",
@@ -4697,7 +4709,7 @@ dependencies = [
[[package]]
name = "ide-vcs"
-version = "0.1.36"
+version = "0.1.37"
dependencies = [
"blake3",
"dunce",
@@ -4718,7 +4730,7 @@ dependencies = [
[[package]]
name = "ide-vfs"
-version = "0.1.36"
+version = "0.1.37"
dependencies = [
"chardetng",
"dunce",
@@ -4740,7 +4752,7 @@ dependencies = [
[[package]]
name = "ide-web"
-version = "0.1.36"
+version = "0.1.37"
dependencies = [
"ide-core",
"open",
@@ -4756,7 +4768,7 @@ dependencies = [
[[package]]
name = "ide-workspace"
-version = "0.1.36"
+version = "0.1.37"
dependencies = [
"blake3",
"dunce",
@@ -6297,7 +6309,7 @@ dependencies = [
[[package]]
name = "openidea"
-version = "0.1.36"
+version = "0.1.37"
dependencies = [
"base64 0.22.1",
"blake3",
@@ -6307,6 +6319,7 @@ dependencies = [
"gtk",
"ide-agent",
"ide-analysis",
+ "ide-android",
"ide-build",
"ide-clipboard",
"ide-core",
@@ -6361,10 +6374,12 @@ dependencies = [
"tauri-plugin-updater",
"tauri-plugin-window-state",
"tauri-specta",
+ "tempfile",
"tokio",
"tracing",
"tracing-appender",
"tracing-subscriber",
+ "windows",
"windows-sys 0.61.2",
]
@@ -8362,7 +8377,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d204abcbdf8e88729306a8d0ca01a79d8a49969fe1f84696909d6dbc4321c1a"
dependencies = [
"foldhash",
- "hashbrown 0.17.1",
+ "hashbrown 0.16.1",
]
[[package]]
@@ -10305,6 +10320,12 @@ version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
+[[package]]
+name = "unicode-width"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca718471876992059f251e33aafc334346ada08042b7661dd1fe313a9bced557"
+
[[package]]
name = "universal-hash"
version = "0.6.1"
@@ -10825,6 +10846,28 @@ dependencies = [
"wasmtime",
]
+[[package]]
+name = "wast"
+version = "258.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b912cfe5696dfaae736e344fc82082dbd63609abbaeca3926466c0b10d9fecc"
+dependencies = [
+ "bumpalo",
+ "leb128fmt",
+ "memchr",
+ "unicode-width",
+ "wasm-encoder",
+]
+
+[[package]]
+name = "wat"
+version = "1.258.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3069154948f5737a8bcd5c55dcdf2c2b59ca776057732e49821167bde268ec6e"
+dependencies = [
+ "wast",
+]
+
[[package]]
name = "wayland-backend"
version = "0.3.17"
diff --git a/Cargo.toml b/Cargo.toml
index ea4e1506d..507145e37 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -5,7 +5,7 @@ members = ["crates/*", "src-tauri"]
exclude = ["sdk", "plugins"]
[workspace.package]
-version = "0.1.36"
+version = "0.1.37"
edition = "2024"
rust-version = "1.96"
license = "Apache-2.0"
diff --git a/README.md b/README.md
index 0e0ca9dc7..50ea82fd9 100644
--- a/README.md
+++ b/README.md
@@ -16,7 +16,7 @@
·
Plugins
·
- Discord
+ Community
https://github.com/user-attachments/assets/234dd54c-1255-4b3a-b35c-533d78d737c0
@@ -57,6 +57,13 @@ Install them from Settings › Plugins, or start your own with
`openidea-plugin new` — see [Writing OpenIDEA plugins](plugins/README.md) and
the [official plugins](plugins/official). The plugin API is still a preview.
+## Community
+
+- [Discord](https://discord.gg/2Zy4yn7gP) — questions, feedback and release news.
+- WeChat group — scan to join:
+
+
+
## License
[Apache 2.0](LICENSE). Bundled third-party assets are listed in [`THIRD_PARTY_LICENSES.md`](THIRD_PARTY_LICENSES.md).
diff --git a/crates/ide-agent/src/registry.rs b/crates/ide-agent/src/registry.rs
index 602a4a1cc..0fcc5eb73 100644
--- a/crates/ide-agent/src/registry.rs
+++ b/crates/ide-agent/src/registry.rs
@@ -76,6 +76,8 @@ pub struct RegistryAgent {
pub installed: Option,
/// Whether [`Self::version`] is newer than the installed one.
pub update: bool,
+ /// An install of it is running (it goes on after the Settings page that started it closes).
+ pub installing: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Type)]
@@ -332,6 +334,7 @@ fn visible(
distribution,
no_checksum,
installed,
+ installing: false,
})
})
.collect();
diff --git a/crates/ide-android/Cargo.toml b/crates/ide-android/Cargo.toml
new file mode 100644
index 000000000..21c049e4d
--- /dev/null
+++ b/crates/ide-android/Cargo.toml
@@ -0,0 +1,15 @@
+[package]
+name = "ide-android"
+description = "Android devices and logcat for the Logcat tool window, driven through the adb CLI"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+
+[dependencies]
+ide-core.workspace = true
+serde.workspace = true
+specta.workspace = true
+which = "8"
+
+[dev-dependencies]
+tempfile.workspace = true
diff --git a/crates/ide-android/src/lib.rs b/crates/ide-android/src/lib.rs
new file mode 100644
index 000000000..ade335cdb
--- /dev/null
+++ b/crates/ide-android/src/lib.rs
@@ -0,0 +1,222 @@
+//! Android devices and their logs, driven through the `adb` CLI (like git is
+//! driven through its CLI).
+//!
+//! The Logcat tool window needs two things from a device: that it is there, and
+//! what it prints. Both come from `adb`, which the Android SDK installs — the
+//! IDE does not bundle it.
+//!
+//! Every function blocks; callers run them off the UI / command threads.
+
+mod model;
+mod process;
+
+pub use model::*;
+
+use ide_core::{CancellationToken, IdeError, IdeResult};
+use std::path::{Path, PathBuf};
+use std::process::Command;
+use std::sync::OnceLock;
+use std::time::Duration;
+
+/// `adb devices` is quick unless it starts the daemon, which can take a moment.
+const DEVICES: Duration = Duration::from_secs(20);
+/// `adb logcat -c` only truncates the buffers.
+const CLEAR: Duration = Duration::from_secs(15);
+/// How much of the buffer to show before following, so opening the window is not empty.
+const TAIL_LINES: u32 = 200;
+/// Logcat can print thousands of lines a second: batches keep the IPC channel sane,
+/// while `IDLE` makes sure a quiet stream still shows its last line promptly.
+const BATCH_IDLE: Duration = Duration::from_millis(60);
+const BATCH_MAX: usize = 300;
+
+fn exe(name: &str) -> String {
+ if cfg!(windows) { format!("{name}.exe") } else { name.to_owned() }
+}
+
+/// Android SDK locations, most specific first: the environment, then the usual
+/// per-platform installs (a GUI app on macOS does not see a shell's `ANDROID_HOME`).
+fn sdk_roots() -> Vec {
+ let mut roots: Vec = ["ANDROID_HOME", "ANDROID_SDK_ROOT"]
+ .iter()
+ .filter_map(|key| std::env::var_os(key).map(PathBuf::from))
+ .collect();
+ let home = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).map(PathBuf::from);
+ if let Some(home) = home.filter(|_| !cfg!(windows)) {
+ roots.push(home.join("Library/Android/sdk"));
+ roots.push(home.join("Android/Sdk"));
+ }
+ if cfg!(windows)
+ && let Some(local) = std::env::var_os("LOCALAPPDATA").map(PathBuf::from)
+ {
+ roots.push(local.join("Android/Sdk"));
+ }
+ roots
+}
+
+/// Locates the adb executable once per process; `OPENIDEA_ADB` overrides.
+pub fn adb_executable() -> Option {
+ static ADB: OnceLock> = OnceLock::new();
+ ADB.get_or_init(|| {
+ if let Some(explicit) = std::env::var_os("OPENIDEA_ADB").map(PathBuf::from).filter(|p| p.is_file()) {
+ return Some(explicit);
+ }
+ let name = exe("adb");
+ if let Some(found) =
+ sdk_roots().into_iter().map(|root| root.join("platform-tools").join(&name)).find(|p| p.is_file())
+ {
+ return Some(found);
+ }
+ if let Ok(found) = which::which("adb") {
+ return Some(found);
+ }
+ let mut candidates: Vec = if cfg!(windows) {
+ vec![PathBuf::from(r"C:\platform-tools\adb.exe")]
+ } else {
+ ["/usr/local/bin/adb", "/opt/homebrew/bin/adb", "/usr/bin/adb"].iter().map(PathBuf::from).collect()
+ };
+ candidates.extend([
+ PathBuf::from("/opt/android-sdk/platform-tools/adb"),
+ PathBuf::from("/usr/lib/android-sdk/platform-tools/adb"),
+ ]);
+ candidates.into_iter().find(|p: &PathBuf| p.is_file())
+ })
+ .clone()
+}
+
+fn executable() -> IdeResult {
+ adb_executable().ok_or_else(|| {
+ IdeError::unavailable(
+ "adb was not found: install the Android SDK platform-tools, or set ANDROID_HOME (or OPENIDEA_ADB)",
+ )
+ })
+}
+
+/// The directory holding adb, for command lines that expect `adb` on `PATH`.
+pub fn platform_tools_dir() -> Option<&'static Path> {
+ static DIR: OnceLock> = OnceLock::new();
+ DIR.get_or_init(|| adb_executable().as_deref().and_then(Path::parent).map(Path::to_path_buf)).as_deref()
+}
+
+/// Connected devices, online ones first; starting the adb daemon when needed.
+pub fn devices() -> IdeResult> {
+ let adb = executable()?;
+ let mut cmd = Command::new(adb);
+ cmd.args(["devices", "-l"]);
+ let out = process::run(cmd, DEVICES, None)?;
+ if !out.success {
+ let message = out.stderr.trim();
+ return Err(IdeError::unavailable(if message.is_empty() {
+ "adb devices failed".to_owned()
+ } else {
+ format!("adb devices failed: {message}")
+ }));
+ }
+ Ok(parse_devices(&out.stdout))
+}
+
+/// Empties the device's log buffers (`adb logcat -c`).
+pub fn clear_log(serial: &str, cancel: &CancellationToken) -> IdeResult<()> {
+ let adb = executable()?;
+ let mut cmd = Command::new(adb);
+ cmd.args(["-s", serial, "logcat", "-c"]);
+ let out = process::run(cmd, CLEAR, Some(cancel))?;
+ if out.success {
+ Ok(())
+ } else {
+ Err(IdeError::unavailable(format!("adb logcat -c failed: {}", out.stderr.trim())))
+ }
+}
+
+fn command(adb: &Path, serial: &str, tail: Option) -> Command {
+ let mut cmd = Command::new(adb);
+ cmd.args(["-s", serial, "logcat", "-v", "threadtime"]);
+ if let Some(lines) = tail {
+ // Recent history first, then follow: `-T ` is lines, not an exit.
+ cmd.arg("-T").arg(lines.to_string());
+ }
+ cmd
+}
+
+/// Follows `serial`'s log until `cancel` fires, handing lines over in batches.
+/// With `clear`, the buffers are emptied first so the window starts clean.
+pub fn logcat(
+ serial: &str,
+ clear: bool,
+ cancel: &CancellationToken,
+ on_batch: &mut dyn FnMut(Vec),
+) -> IdeResult<()> {
+ let adb = executable()?;
+ if clear {
+ clear_log(serial, cancel)?;
+ }
+ let mut parse = |line: &str| Some(parse_line(line));
+ let (ok, stderr) = match process::stream_batched(
+ command(&adb, serial, Some(TAIL_LINES)),
+ cancel,
+ BATCH_IDLE,
+ BATCH_MAX,
+ &mut parse,
+ on_batch,
+ ) {
+ Ok(result) => result,
+ // The window closed or switched devices: stopping is the expected end.
+ Err(e) if e.kind == ide_core::error::ErrorKind::Cancelled => return Ok(()),
+ Err(e) => return Err(e),
+ };
+ if ok || cancel.is_cancelled() {
+ return Ok(());
+ }
+ // `-T` is old (API 21) but adb forwards logcat options it does not know on
+ // very old devices; retry without the history rather than showing nothing.
+ let unsupported = stderr.iter().any(|line| {
+ line.contains("Unrecognized option") || line.contains("unknown option") || line.contains("Invalid option")
+ });
+ if unsupported {
+ let (ok, stderr) = match process::stream_batched(
+ command(&adb, serial, None),
+ cancel,
+ BATCH_IDLE,
+ BATCH_MAX,
+ &mut parse,
+ on_batch,
+ ) {
+ Ok(result) => result,
+ Err(e) if e.kind == ide_core::error::ErrorKind::Cancelled => return Ok(()),
+ Err(e) => return Err(e),
+ };
+ if ok || cancel.is_cancelled() {
+ return Ok(());
+ }
+ return Err(failure(serial, &stderr));
+ }
+ Err(failure(serial, &stderr))
+}
+
+fn failure(serial: &str, stderr: &[String]) -> IdeError {
+ let message = stderr.join("\n");
+ IdeError::unavailable(if message.trim().is_empty() {
+ format!("adb logcat on {serial} stopped")
+ } else {
+ format!("adb logcat on {serial} stopped: {message}")
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn adb_candidates_are_absolute() {
+ for root in sdk_roots() {
+ assert!(root.is_absolute(), "{root:?}");
+ }
+ }
+
+ #[test]
+ fn executable_is_absolute_when_found() {
+ if let Some(adb) = adb_executable() {
+ assert!(adb.is_absolute());
+ assert!(adb.is_file());
+ }
+ }
+}
diff --git a/crates/ide-android/src/model.rs b/crates/ide-android/src/model.rs
new file mode 100644
index 000000000..1bbada32d
--- /dev/null
+++ b/crates/ide-android/src/model.rs
@@ -0,0 +1,276 @@
+//! What the Logcat tool window shows: the devices `adb` reports and the lines
+//! `adb logcat` prints, parsed from its `threadtime` format.
+
+use serde::{Deserialize, Serialize};
+use specta::Type;
+
+/// One entry of `adb devices -l`.
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
+#[serde(rename_all = "camelCase")]
+pub struct AndroidDevice {
+ /// `adb -s` value: a USB serial number, `emulator-5554`, or `host:port`.
+ pub serial: String,
+ /// `device` (usable), `offline`, `unauthorized` or `no permissions`.
+ pub state: String,
+ pub model: Option,
+ pub product: Option,
+ /// The device's own name from the build (`ro.product.device`).
+ pub device: Option,
+ pub transport_id: Option,
+}
+
+impl AndroidDevice {
+ /// Ready for `logcat` / `install`.
+ pub fn is_online(&self) -> bool {
+ self.state == "device"
+ }
+
+ /// What the picker calls it: the model when known, the serial otherwise.
+ pub fn label(&self) -> &str {
+ self.model.as_deref().filter(|m| !m.trim().is_empty()).unwrap_or(&self.serial)
+ }
+}
+
+/// The single-letter logcat priority, in increasing severity.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Type)]
+#[serde(rename_all = "camelCase")]
+pub enum LogcatLevel {
+ Verbose,
+ Debug,
+ Info,
+ Warn,
+ Error,
+ Fatal,
+ /// A line logcat prints outside the message format (`--------- beginning of main`).
+ Unknown,
+}
+
+impl LogcatLevel {
+ pub fn letter(self) -> char {
+ match self {
+ Self::Verbose => 'V',
+ Self::Debug => 'D',
+ Self::Info => 'I',
+ Self::Warn => 'W',
+ Self::Error => 'E',
+ Self::Fatal => 'F',
+ Self::Unknown => '?',
+ }
+ }
+
+ fn parse(c: char) -> Self {
+ match c.to_ascii_uppercase() {
+ 'V' => Self::Verbose,
+ 'D' => Self::Debug,
+ 'I' => Self::Info,
+ 'W' => Self::Warn,
+ 'E' => Self::Error,
+ 'F' | 'A' => Self::Fatal,
+ _ => Self::Unknown,
+ }
+ }
+}
+
+/// One [`LogcatLevel`]-tagged line of `adb logcat -v threadtime`.
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
+#[serde(rename_all = "camelCase")]
+pub struct LogcatLine {
+ /// `MM-DD HH:MM:SS.mmm`, as logcat prints it (no year, device time zone).
+ pub time: String,
+ pub pid: i32,
+ pub tid: i32,
+ pub level: LogcatLevel,
+ pub tag: String,
+ pub message: String,
+}
+
+impl LogcatLine {
+ /// A line logcat printed outside the message format, kept so nothing is lost.
+ fn unparsed(text: &str) -> Self {
+ Self {
+ time: String::new(),
+ pid: 0,
+ tid: 0,
+ level: LogcatLevel::Unknown,
+ tag: String::new(),
+ message: text.to_owned(),
+ }
+ }
+}
+
+/// Parses `adb devices -l` output; the daemon's chatter on stderr is not included.
+pub fn parse_devices(output: &str) -> Vec {
+ let mut devices: Vec = Vec::new();
+ for line in output.lines() {
+ let line = line.trim_end();
+ if line.trim().is_empty() || line.starts_with("List of devices") || line.starts_with('*') {
+ continue;
+ }
+ let mut fields = line.split_whitespace();
+ let (Some(serial), Some(state)) = (fields.next(), fields.next()) else {
+ continue;
+ };
+ // "no permissions (user in plugdev group; are your udev rules wrong?)"
+ // is the one state that carries spaces; the rest is a `key:value` list.
+ let mut device = AndroidDevice {
+ serial: serial.to_owned(),
+ state: state.to_owned(),
+ model: None,
+ product: None,
+ device: None,
+ transport_id: None,
+ };
+ if device.state == "no" {
+ device.state = "no permissions".to_owned();
+ devices.push(device);
+ continue;
+ }
+ for field in fields {
+ let Some((key, value)) = field.split_once(':') else {
+ continue;
+ };
+ match key {
+ "model" => device.model = Some(value.to_owned()),
+ "product" => device.product = Some(value.to_owned()),
+ "device" => device.device = Some(value.to_owned()),
+ "transport_id" => device.transport_id = Some(value.to_owned()),
+ _ => {}
+ }
+ }
+ devices.push(device);
+ }
+ // Online first, then by name, so the picker's default is the device in hand.
+ devices.sort_by(|a, b| b.is_online().cmp(&a.is_online()).then_with(|| a.serial.cmp(&b.serial)));
+ devices
+}
+
+/// Parses one `logcat -v threadtime` line:
+///
+/// ```text
+/// 11-04 12:52:39.123 3048 3059 I WindowManager: displayed
+/// ```
+///
+/// Text that does not follow the format still becomes a line, so a device that
+/// prints something unexpected does not look like a dead stream.
+pub fn parse_line(text: &str) -> LogcatLine {
+ let Some(line) = parse_formatted(text) else {
+ return LogcatLine::unparsed(text);
+ };
+ line
+}
+
+fn parse_formatted(text: &str) -> Option {
+ // "MM-DD HH:MM:SS.mmm" is 18 bytes; logcat pads the rest with spaces.
+ let stamp = text.get(..18)?;
+ if !looks_like_stamp(stamp) {
+ return None;
+ }
+ let mut rest = text.get(18..)?;
+ let pid: i32 = take_field(&mut rest)?.parse().ok()?;
+ let tid: i32 = take_field(&mut rest)?.parse().ok()?;
+ let level = LogcatLevel::parse(take_field(&mut rest)?.chars().next()?);
+ let tail = rest.trim_start();
+ // "Tag : message": logcat pads the tag to 8 columns, and the tag
+ // itself never contains a colon followed by a space.
+ let (tag, message) = match tail.split_once(": ") {
+ Some((tag, message)) => (tag.trim_end(), message),
+ None => (tail.trim_end(), ""),
+ };
+ Some(LogcatLine { time: stamp.to_owned(), pid, tid, level, tag: tag.to_owned(), message: message.to_owned() })
+}
+
+/// Consumes the next space-separated field of `rest`, leaving the remainder.
+fn take_field<'a>(rest: &mut &'a str) -> Option<&'a str> {
+ *rest = rest.trim_start();
+ if rest.is_empty() {
+ return None;
+ }
+ let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
+ let field = &rest[..end];
+ *rest = &rest[end..];
+ Some(field)
+}
+
+fn looks_like_stamp(s: &str) -> bool {
+ let bytes = s.as_bytes();
+ bytes.len() == 18
+ && bytes[2] == b'-'
+ && bytes[5] == b' '
+ && bytes[8] == b':'
+ && bytes[11] == b':'
+ && bytes[14] == b'.'
+ && bytes.iter().enumerate().all(|(i, b)| matches!(i, 2 | 5 | 8 | 11 | 14) || b.is_ascii_digit())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn reads_adb_devices_with_attributes() {
+ let out = "\
+List of devices attached
+HT7A1B2C3D4 device product:generic model:Generic_Phone device:generic transport_id:1
+emulator-5554 offline transport_id:2
+0123456789ABCDEF unauthorized usb:1-1 transport_id:3
+
+";
+ let devices = parse_devices(out);
+ assert_eq!(devices.len(), 3);
+ // Online first, then by serial.
+ assert_eq!(devices[0].serial, "HT7A1B2C3D4");
+ assert_eq!(devices[0].state, "device");
+ assert_eq!(devices[0].model.as_deref(), Some("Generic_Phone"));
+ assert_eq!(devices[0].transport_id.as_deref(), Some("1"));
+ assert!(devices[0].is_online());
+ assert_eq!(devices[1].serial, "0123456789ABCDEF");
+ assert_eq!(devices[1].state, "unauthorized");
+ assert_eq!(devices[1].label(), "0123456789ABCDEF");
+ assert_eq!(devices[2].serial, "emulator-5554");
+ assert!(!devices[2].is_online());
+ }
+
+ #[test]
+ fn reads_adb_devices_when_the_list_is_empty() {
+ assert!(parse_devices("List of devices attached\n\n").is_empty());
+ }
+
+ #[test]
+ fn reads_no_permissions() {
+ let devices = parse_devices("ABC\tno permissions (user in plugdev group; are your udev rules wrong?)\n");
+ assert_eq!(devices.len(), 1);
+ assert_eq!(devices[0].state, "no permissions");
+ }
+
+ #[test]
+ fn parses_a_threadtime_line() {
+ let line = parse_line("11-04 12:52:39.123 3048 3059 I WindowManager: Displayed activity");
+ assert_eq!(line.time, "11-04 12:52:39.123");
+ assert_eq!(line.pid, 3048);
+ assert_eq!(line.tid, 3059);
+ assert_eq!(line.level, LogcatLevel::Info);
+ assert_eq!(line.tag, "WindowManager");
+ assert_eq!(line.message, "Displayed activity");
+ }
+
+ #[test]
+ fn keeps_colons_in_the_message() {
+ let line = parse_line("11-04 12:52:39.123 3048 3059 E flutter : java.lang.IllegalStateException: boom");
+ assert_eq!(line.tag, "flutter");
+ assert_eq!(line.level, LogcatLevel::Error);
+ assert_eq!(line.message, "java.lang.IllegalStateException: boom");
+ }
+
+ #[test]
+ fn keeps_lines_outside_the_format() {
+ let line = parse_line("--------- beginning of main");
+ assert_eq!(line.level, LogcatLevel::Unknown);
+ assert_eq!(line.message, "--------- beginning of main");
+ }
+
+ #[test]
+ fn levels_order_by_severity() {
+ assert!(LogcatLevel::Warn > LogcatLevel::Info);
+ assert!(LogcatLevel::Fatal > LogcatLevel::Error);
+ }
+}
diff --git a/crates/ide-android/src/process.rs b/crates/ide-android/src/process.rs
new file mode 100644
index 000000000..b5b858389
--- /dev/null
+++ b/crates/ide-android/src/process.rs
@@ -0,0 +1,206 @@
+//! Child processes of the `adb` CLI: bounded runs, and cancellable line streams
+//! that are handed over in batches (logcat is far too chatty for one message a line).
+
+use ide_core::process::terminate_tree;
+use ide_core::{CancellationToken, IdeError, IdeResult};
+use std::io::Read;
+use std::process::{Child, Command, Stdio};
+use std::sync::mpsc;
+use std::time::{Duration, Instant};
+
+/// How long a cancelled child gets to exit on `SIGTERM` (and flush) before `SIGKILL`.
+const KILL_GRACE: Duration = Duration::from_millis(20);
+
+pub(crate) struct Output {
+ pub stdout: String,
+ pub stderr: String,
+ pub success: bool,
+}
+
+fn prepare(cmd: &mut Command) {
+ cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
+ ide_core::process::prepare(ide_core::process::own_group(cmd));
+}
+
+fn spawn(cmd: &mut Command) -> IdeResult {
+ cmd.spawn().map_err(|e| IdeError::unavailable(format!("could not run adb: {e}")))
+}
+
+fn read_all(mut pipe: impl Read + Send + 'static) -> std::thread::JoinHandle {
+ std::thread::spawn(move || {
+ let mut buf = Vec::new();
+ let _ = pipe.read_to_end(&mut buf);
+ ide_core::output_encoding::decode_tool_output(&buf)
+ })
+}
+
+/// 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 {
+ prepare(&mut cmd);
+ let mut child = spawn(&mut cmd)?;
+ let out = child.stdout.take().map(read_all);
+ let err = child.stderr.take().map(read_all);
+ let deadline = Instant::now() + timeout;
+ let status = loop {
+ if let Some(status) = child.try_wait()? {
+ break status;
+ }
+ if cancel.is_some_and(CancellationToken::is_cancelled) {
+ terminate_tree(&mut child, KILL_GRACE);
+ return Err(IdeError::cancelled());
+ }
+ if Instant::now() >= deadline {
+ terminate_tree(&mut child, KILL_GRACE);
+ return Err(IdeError::unavailable("adb did not respond in time"));
+ }
+ std::thread::sleep(Duration::from_millis(10));
+ };
+ Ok(Output {
+ stdout: out.map(|t| t.join().unwrap_or_default()).unwrap_or_default(),
+ stderr: err.map(|t| t.join().unwrap_or_default()).unwrap_or_default(),
+ success: status.success(),
+ })
+}
+
+enum Event {
+ Line(bool, String),
+ Closed,
+}
+
+fn spawn_reader(mut pipe: impl Read + Send + 'static, tx: mpsc::Sender, stdout: bool) {
+ std::thread::spawn(move || {
+ let mut buf = [0u8; 8192];
+ let mut pending: Vec = Vec::new();
+ let flush = |pending: &mut Vec| {
+ if !pending.is_empty() {
+ let line = ide_core::output_encoding::decode_tool_output(pending);
+ pending.clear();
+ let _ = tx.send(Event::Line(stdout, line.trim_end().to_string()));
+ }
+ };
+ loop {
+ match pipe.read(&mut buf) {
+ Ok(0) | Err(_) => break,
+ Ok(n) => {
+ for &b in &buf[..n] {
+ if b == b'\n' {
+ flush(&mut pending);
+ } else if b != b'\r' {
+ pending.push(b);
+ }
+ }
+ }
+ }
+ }
+ flush(&mut pending);
+ let _ = tx.send(Event::Closed);
+ });
+}
+
+fn flush(batch: &mut Vec, on_batch: &mut dyn FnMut(Vec)) {
+ if !batch.is_empty() {
+ on_batch(std::mem::take(batch));
+ }
+}
+
+/// Runs `cmd`, parsing every stdout line with `parse` and handing the results to
+/// `on_batch` — as soon as `max` are ready, or once the oldest waiting line is `idle`
+/// old, so neither a quiet nor a steady stream holds lines back. Stderr is kept as a
+/// tail for the error message. Returns whether the child exited successfully.
+pub(crate) fn stream_batched(
+ mut cmd: Command,
+ cancel: &CancellationToken,
+ idle: Duration,
+ max: usize,
+ mut parse: impl FnMut(&str) -> Option,
+ on_batch: &mut dyn FnMut(Vec),
+) -> IdeResult<(bool, Vec)> {
+ if cancel.is_cancelled() {
+ return Err(IdeError::cancelled());
+ }
+ prepare(&mut cmd);
+ let mut child = spawn(&mut cmd)?;
+ let (tx, rx) = mpsc::channel::();
+ let mut open = 0;
+ if let Some(out) = child.stdout.take() {
+ spawn_reader(out, tx.clone(), true);
+ open += 1;
+ }
+ if let Some(err) = child.stderr.take() {
+ spawn_reader(err, tx.clone(), false);
+ open += 1;
+ }
+ drop(tx);
+ let mut stderr_tail: Vec = Vec::new();
+ let mut batch: Vec = Vec::with_capacity(max);
+ // When the first line of the waiting batch arrived.
+ let mut since = Instant::now();
+ while open > 0 {
+ if cancel.is_cancelled() {
+ terminate_tree(&mut child, KILL_GRACE);
+ return Err(IdeError::cancelled());
+ }
+ let wait = if batch.is_empty() { idle } else { idle.saturating_sub(since.elapsed()) };
+ match rx.recv_timeout(wait) {
+ Ok(Event::Line(true, line)) => {
+ if let Some(item) = parse(&line) {
+ if batch.is_empty() {
+ since = Instant::now();
+ }
+ batch.push(item);
+ if batch.len() >= max || since.elapsed() >= idle {
+ flush(&mut batch, on_batch);
+ }
+ }
+ }
+ Ok(Event::Line(false, line)) => {
+ if !line.trim().is_empty() {
+ stderr_tail.push(line);
+ if stderr_tail.len() > 20 {
+ stderr_tail.remove(0);
+ }
+ }
+ }
+ Ok(Event::Closed) => open -= 1,
+ Err(mpsc::RecvTimeoutError::Timeout) => flush(&mut batch, on_batch),
+ Err(mpsc::RecvTimeoutError::Disconnected) => break,
+ }
+ }
+ flush(&mut batch, on_batch);
+ let status = loop {
+ if let Some(status) = child.try_wait()? {
+ break status;
+ }
+ if cancel.is_cancelled() {
+ terminate_tree(&mut child, KILL_GRACE);
+ return Err(IdeError::cancelled());
+ }
+ std::thread::sleep(Duration::from_millis(10));
+ };
+ Ok((status.success(), stderr_tail))
+}
+
+#[cfg(all(test, unix))]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn a_steady_stream_is_handed_over_while_it_runs() {
+ let mut cmd = Command::new("sh");
+ cmd.args(["-c", "for i in $(seq 1 20); do echo $i; sleep 0.02; done"]);
+ let mut sizes = Vec::new();
+ let (ok, _) = stream_batched(
+ cmd,
+ &CancellationToken::new(),
+ Duration::from_millis(60),
+ 300,
+ |line| Some(line.to_owned()),
+ &mut |batch| sizes.push(batch.len()),
+ )
+ .unwrap();
+ assert!(ok);
+ assert_eq!(sizes.iter().sum::(), 20);
+ assert!(sizes.len() >= 3, "lines arriving every 20 ms waited for the end: {sizes:?}");
+ }
+}
diff --git a/crates/ide-android/tests/device.rs b/crates/ide-android/tests/device.rs
new file mode 100644
index 000000000..d8afd6c52
--- /dev/null
+++ b/crates/ide-android/tests/device.rs
@@ -0,0 +1,65 @@
+//! Drives a real device through `adb`, checking what the Logcat tool window does:
+//! that the device list parses, that the stream delivers batches, and that
+//! cancelling stops both the stream and its `adb` process.
+//!
+//! Ignored by default (needs a connected device). Run with
+//! `OPENIDEA_ADB_DEVICE= cargo test -p ide-android --test device -- --ignored --nocapture`
+//! — without the variable, the connected device is used when there is exactly one.
+
+use ide_android::*;
+use ide_core::CancellationToken;
+use std::sync::{Arc, Mutex};
+use std::time::{Duration, Instant};
+
+fn serial() -> Option {
+ if let Ok(serial) = std::env::var("OPENIDEA_ADB_DEVICE") {
+ return Some(serial);
+ }
+ let online = devices().ok()?.into_iter().filter(AndroidDevice::is_online).collect::>();
+ (online.len() == 1).then(|| online[0].serial.clone())
+}
+
+#[test]
+#[ignore]
+fn finds_the_adb_executable() {
+ let adb = adb_executable().expect("adb (set ANDROID_HOME or OPENIDEA_ADB)");
+ println!("adb: {}", adb.display());
+ assert!(adb.is_file());
+}
+
+#[test]
+#[ignore]
+fn lists_connected_devices() {
+ let devices = devices().expect("adb devices");
+ for device in &devices {
+ println!("{:<16} {:<14} {} ({:?})", device.serial, device.state, device.label(), device.transport_id);
+ }
+ assert!(!devices.is_empty(), "no devices connected");
+}
+
+#[test]
+#[ignore]
+fn streams_batches_and_stops_on_cancel() {
+ let serial = serial().expect("a single connected device, or OPENIDEA_ADB_DEVICE");
+ let cancel = CancellationToken::new();
+ let stop = cancel.clone();
+ let received = Arc::new(Mutex::new(Vec::::new()));
+ let sink = received.clone();
+ let started = Instant::now();
+ let stream = std::thread::spawn(move || {
+ ide_android::logcat(&serial, false, &stop, &mut |batch| {
+ sink.lock().unwrap().extend(batch);
+ })
+ });
+ // Enough to see the tail seed and a few live lines from a device that idles.
+ std::thread::sleep(Duration::from_secs(4));
+ cancel.cancel();
+ stream.join().unwrap().expect("logcat stops cleanly when cancelled");
+ let lines = received.lock().unwrap();
+ println!("{} lines in {:?}", lines.len(), started.elapsed());
+ for line in lines.iter().rev().take(5) {
+ println!("{:<14} {:>6} {}", line.tag, line.pid, line.message);
+ }
+ assert!(!lines.is_empty(), "no lines within 4s");
+ assert!(lines.iter().any(|l| l.level != LogcatLevel::Unknown), "every line failed to parse");
+}
diff --git a/crates/ide-build/src/application_update.rs b/crates/ide-build/src/application_update.rs
index cabd8a736..96a7ad0d8 100644
--- a/crates/ide-build/src/application_update.rs
+++ b/crates/ide-build/src/application_update.rs
@@ -40,7 +40,7 @@ pub fn plan(root: &Path, model: &BuildModel, module: &Path, cache: &Path) -> Ide
kind: "maven",
});
}
- if let Some(g) = &model.gradle
+ if let Some(g) = model.gradle_for_module(root, &module)
&& let Some(p) = g.projects.iter().find(|p| root.join(&p.dir) == module)
{
let script = cache.join("resources.init.gradle");
@@ -163,11 +163,11 @@ mod tests {
assert!(maven.args.contains(&"-Dexpression=project.build.outputDirectory".into()));
assert_eq!(maven.env["JAVA_HOME"], "/jdk");
model.maven = None;
- model.gradle = Some(crate::GradleModel {
+ model.gradle = vec![crate::GradleModel {
command: "/tools/gradlew".into(),
projects: vec![crate::GradleProject { path: ":app".into(), dir: "app".into(), ..Default::default() }],
..Default::default()
- });
+ }];
let gradle = plan(&root, &model, Path::new("app"), &cache).unwrap();
assert_eq!(gradle.command, "/tools/gradlew");
assert_eq!(gradle.args.last().unwrap(), ":app:openideaUpdateResources");
diff --git a/crates/ide-build/src/compile/mod.rs b/crates/ide-build/src/compile/mod.rs
index 603d5b3ff..91bbdbf35 100644
--- a/crates/ide-build/src/compile/mod.rs
+++ b/crates/ide-build/src/compile/mod.rs
@@ -147,7 +147,9 @@ fn detect_dir(dir: &Path, model: &BuildModel, root: &Path) -> Option Option PathBuf {
- rel.split('/').filter(|part| !part.is_empty()).fold(root.to_path_buf(), |path, part| path.join(part))
+ root.join(rel).components().collect()
}
fn rel_str(root: &Path, dir: &Path) -> String {
- dir.strip_prefix(root).map(|r| r.to_string_lossy().replace('\\', "/")).unwrap_or_default()
+ dir.strip_prefix(root).unwrap_or(dir).to_string_lossy().replace('\\', "/")
}
/// Directories from `file`'s folder up to `root` (inclusive).
@@ -192,7 +191,14 @@ pub fn plan(
let project_name = root.file_name().map_or_else(|| "project".to_owned(), |n| n.to_string_lossy().into_owned());
let module_dir = match file {
Some(f) => {
- let dir = ancestors_within(root, f)
+ let scope = model
+ .gradle
+ .iter()
+ .flat_map(|g| std::iter::once(root.join(&g.dir)).chain(g.projects.iter().map(|p| root.join(&p.dir))))
+ .filter(|dir| f.starts_with(dir))
+ .max_by_key(|dir| dir.components().count())
+ .unwrap_or_else(|| root.to_path_buf());
+ let dir = ancestors_within(&scope, f)
.find(|d| detect_dir(d, model, root).is_some())
.ok_or_else(|| IdeError::not_found(format!("{} is not in a module", f.display())))?;
Some(dir.to_path_buf())
@@ -202,7 +208,7 @@ pub fn plan(
let dir = module_dir.clone().unwrap_or_else(|| root.to_path_buf());
// A folder of sibling projects has no build file itself; its Maven projects are built one by one.
let system = detect_dir(&dir, model, root)
- .or_else(|| if model.gradle.is_some() { Some(BuildSystem::Gradle) } else { None })
+ .or((!model.gradle.is_empty()).then_some(BuildSystem::Gradle))
.or_else(|| model.maven.as_ref().filter(|m| !m.projects.is_empty()).map(|_| BuildSystem::Maven))
.ok_or_else(|| {
IdeError::not_found("no supported build system (Maven, Gradle, Cargo, Go, TypeScript, CMake) found")
@@ -270,14 +276,16 @@ fn maven_steps(root: &Path, model: &BuildModel, kind: BuildKind, module: Option<
}
fn gradle_steps(root: &Path, model: &BuildModel, kind: BuildKind, module: Option<&Path>) -> IdeResult> {
- let fallback;
- let g = match &model.gradle {
- Some(g) => g,
- None => {
- fallback = crate::gradle::load(root).ok_or_else(|| IdeError::not_found("no Gradle build found"))?;
- &fallback
- }
- };
+ if module.is_none() && !model.gradle.is_empty() {
+ return Ok(model.gradle.iter().flat_map(|g| gradle_step(root, g, kind, None)).collect());
+ }
+ let g = module
+ .and_then(|m| model.gradle_for_module(root, m))
+ .ok_or_else(|| IdeError::not_found("module does not belong to a linked Gradle build"))?;
+ Ok(gradle_step(root, g, kind, module))
+}
+
+fn gradle_step(root: &Path, g: &crate::GradleModel, kind: BuildKind, module: Option<&Path>) -> Vec {
let prefix = module
.and_then(|m| {
let rel = rel_str(root, m);
@@ -296,7 +304,7 @@ fn gradle_steps(root: &Path, model: &BuildModel, kind: BuildKind, module: Option
let mut s = BuildStep::new(&g.command, &["--console=plain"], &subdir(root, &g.dir), Format::Gradle);
s.args.extend(tasks);
s.env = g.env.clone();
- Ok(vec![s])
+ vec![s]
}
fn cargo_steps(dir: &Path, kind: BuildKind) -> Vec {
@@ -811,6 +819,79 @@ fn launch_class_dirs(system: BuildSystem, module: &Path, test: bool) -> Vec(root: &Path, dirs: impl IntoIterator- )
pub fn load(root: &Path) -> Option
{
let dir = build_dir(root)?;
+ load_build(root, dir.strip_prefix(root).unwrap_or(&dir))
+}
+
+/// Loads exactly this build, without searching its child folders.
+pub fn load_build(root: &Path, dir: &Path) -> Option {
+ // Linked builds are saved with forward slashes; rebuild the path so the launcher
+ // and module folders use the platform's separators.
+ let dir: PathBuf = root.join(dir).components().collect();
+ if !SETTINGS_FILES.iter().chain(BUILD_FILES).any(|name| dir.join(name).is_file()) {
+ return None;
+ }
let settings = read_first(&dir, SETTINGS_FILES).map(|(_, t)| parse_settings(&t)).unwrap_or_default();
let root_name =
settings.name.unwrap_or_else(|| dir.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default());
@@ -455,6 +466,65 @@ pub fn load(root: &Path) -> Option {
Some(GradleModel { dir: rel(root, &dir), projects, java_version, command: wrapper(&dir), ..GradleModel::default() })
}
+/// Validates user-selected build folders and stores a stable, deduplicated identity.
+pub fn normalize_linked_projects(root: &Path, dirs: &[String]) -> ide_core::IdeResult> {
+ let root = dunce::canonicalize(root)?;
+ let mut out = Vec::new();
+ for dir in dirs {
+ let path = dunce::canonicalize(root.join(dir))?;
+ if !path.is_dir() || !SETTINGS_FILES.iter().chain(BUILD_FILES).any(|name| path.join(name).is_file()) {
+ return Err(ide_core::IdeError::invalid(format!("No Gradle build in {}", path.display())));
+ }
+ let dir = rel(&root, &path);
+ if !out.contains(&dir) {
+ out.push(dir);
+ }
+ }
+ Ok(out)
+}
+
+/// The form `normalize_linked_projects` saves `dir` in, also for a folder that no longer
+/// exists, so a path the UI sends (`C:\…` on Windows) matches the saved entry.
+pub fn linked_project_key(root: &Path, dir: &str) -> String {
+ match (dunce::canonicalize(root), dunce::canonicalize(root.join(dir))) {
+ (Ok(root), Ok(path)) => rel(&root, &path),
+ _ => dir.replace('\\', "/"),
+ }
+}
+
+/// Linked build identities without reading their scripts (also used by index hot paths).
+pub(crate) fn linked_dirs(root: &Path, settings: &ide_core::settings::GradleSettings) -> Vec {
+ match &settings.linked_projects {
+ None => build_dir(root).map(|dir| rel(root, &dir)).into_iter().collect(),
+ Some(dirs) => {
+ let mut out = Vec::new();
+ for dir in dirs {
+ if let Ok(dirs) = normalize_linked_projects(root, std::slice::from_ref(dir)) {
+ for dir in dirs {
+ if !out.contains(&dir) {
+ out.push(dir);
+ }
+ }
+ }
+ }
+ out
+ }
+ }
+}
+
+pub fn load_linked(root: &Path, settings: &ide_core::settings::GradleSettings) -> Vec {
+ linked_dirs(root, settings).iter().filter_map(|dir| load_build(root, Path::new(dir))).collect()
+}
+
+/// A build's task paths are local to it, so sync caches must be local to its identity too.
+pub fn cache_dir(cache: &Path, build_dir: &str) -> PathBuf {
+ if build_dir.is_empty() {
+ return cache.to_path_buf();
+ }
+ let hash = build_dir.bytes().fold(0xcbf29ce484222325u64, |h, b| (h ^ u64::from(b)).wrapping_mul(0x100000001b3));
+ cache.join("gradle").join(format!("{hash:016x}"))
+}
+
/// `JavaLanguageVersion.of(21)` / `sourceCompatibility = JavaVersion.VERSION_17` in a build script.
pub fn script_java_version(text: &str) -> Option {
let number = |s: &str| -> Option {
@@ -616,6 +686,65 @@ pub(crate) fn has_war_plugin(script: &str) -> bool {
#[cfg(test)]
mod tests {
+ #[test]
+ fn a_path_from_the_ui_finds_the_saved_link() {
+ let temp = tempfile::tempdir().unwrap();
+ let root = temp.path().join("workspace");
+ let external = temp.path().join("external");
+ for dir in [root.join("a"), external.clone()] {
+ std::fs::create_dir_all(&dir).unwrap();
+ std::fs::write(dir.join("settings.gradle"), "").unwrap();
+ }
+ let native = |p: &std::path::Path| p.to_string_lossy().into_owned();
+ // Saved by linking; unlinking may send a native, absolute or non-canonical spelling.
+ for (dir, sent) in [
+ ("a", native(&root.join("a"))),
+ ("a", "a".to_owned()),
+ ("a", native(&root.join("a").join("..").join("a"))),
+ ("../external", native(&external)),
+ ] {
+ let saved = super::normalize_linked_projects(&root, &[dir.into()]).unwrap();
+ assert_eq!(super::linked_project_key(&root, &sent), saved[0], "{sent}");
+ }
+ // A folder deleted since it was linked still matches its saved entry.
+ assert_eq!(super::linked_project_key(&root, r"gone\build"), "gone/build");
+ }
+
+ #[test]
+ fn explicit_linkage_preserves_default_discovery_and_can_unlink_every_build() {
+ let temp = tempfile::tempdir().unwrap();
+ let root = temp.path();
+ for dir in ["a", "b"] {
+ std::fs::create_dir(root.join(dir)).unwrap();
+ std::fs::write(root.join(dir).join("settings.gradle"), format!("rootProject.name = '{dir}'")).unwrap();
+ }
+ assert_eq!(super::load_linked(root, &Default::default())[0].dir, "a");
+ let mut settings = ide_core::settings::GradleSettings {
+ linked_projects: Some(vec!["b".into(), "./b".into(), "a".into()]),
+ ..Default::default()
+ };
+ let models = super::load_linked(root, &settings);
+ assert_eq!(models.iter().map(|g| g.dir.as_str()).collect::>(), ["b", "a"]);
+ settings.linked_projects = Some(vec![]);
+ assert!(super::load_linked(root, &settings).is_empty());
+ assert!(super::normalize_linked_projects(root, &["missing".into()]).is_err());
+ assert!(super::normalize_linked_projects(root, &["".into()]).is_err());
+ assert!(super::load_build(root, root).is_none(), "exact loading never picks a child");
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn linked_directories_deduplicate_symlinks_and_keep_external_builds() {
+ let temp = tempfile::tempdir().unwrap();
+ let root = temp.path().join("workspace");
+ let external = temp.path().join("external");
+ std::fs::create_dir(&root).unwrap();
+ std::fs::create_dir(&external).unwrap();
+ std::fs::write(external.join("build.gradle"), "").unwrap();
+ std::os::unix::fs::symlink(&external, root.join("alias")).unwrap();
+ let dirs = super::normalize_linked_projects(&root, &["alias".into(), "../external".into()]).unwrap();
+ assert_eq!(dirs, [external.canonicalize().unwrap().to_string_lossy().to_string()]);
+ }
#[test]
fn script_dependencies_read_string_and_map_notations() {
let script = r#"
diff --git a/crates/ide-build/src/gradle_deps.rs b/crates/ide-build/src/gradle_deps.rs
index 40c902029..109fea96f 100644
--- a/crates/ide-build/src/gradle_deps.rs
+++ b/crates/ide-build/src/gradle_deps.rs
@@ -126,6 +126,7 @@ fn project_path(text: &str) -> String {
/// `configuration` and returns its tree.
pub fn gradle_dependencies(
root: &Path,
+ model: &gradle::GradleModel,
options: &SyncOptions,
project: &str,
configuration: &str,
@@ -137,7 +138,6 @@ pub fn gradle_dependencies(
if !project.starts_with(':') || project.chars().any(char::is_whitespace) {
return Err(IdeError::invalid(format!("not a Gradle project path: {project}")));
}
- let model = gradle::load(root).ok_or_else(|| IdeError::not_found("no Gradle build in this project"))?;
let task = if project == ":" { ":dependencies".to_owned() } else { format!("{project}:dependencies") };
let mut args: Vec =
vec!["--console=plain".into(), "-q".into(), task, "--configuration".into(), configuration.into()];
@@ -226,8 +226,12 @@ runtimeClasspath - Runtime classpath of source set 'main'.
let tmp = tempfile::tempdir().unwrap();
let options = SyncOptions::default();
let cancel = CancellationToken::new();
- assert!(gradle_dependencies(tmp.path(), &options, ":", "x --init-script y", &cancel).is_err());
- assert!(gradle_dependencies(tmp.path(), &options, "app", "runtimeClasspath", &cancel).is_err());
+ assert!(
+ gradle_dependencies(tmp.path(), &Default::default(), &options, ":", "x --init-script y", &cancel).is_err()
+ );
+ assert!(
+ gradle_dependencies(tmp.path(), &Default::default(), &options, "app", "runtimeClasspath", &cancel).is_err()
+ );
}
#[cfg(unix)]
@@ -246,8 +250,15 @@ runtimeClasspath - Runtime classpath of source set 'main'.
offline: true,
..Default::default()
};
- let tree =
- gradle_dependencies(root, &options, ":app", "testRuntimeClasspath", &CancellationToken::new()).unwrap();
+ let tree = gradle_dependencies(
+ root,
+ &gradle::load(root).unwrap(),
+ &options,
+ ":app",
+ "testRuntimeClasspath",
+ &CancellationToken::new(),
+ )
+ .unwrap();
assert_eq!(tree, vec![dep("org.a", "b", Some("1.0"))]);
assert_eq!(
std::fs::read_to_string(root.join("args")).unwrap().trim(),
diff --git a/crates/ide-build/src/lib.rs b/crates/ide-build/src/lib.rs
index ab0588c45..eb613c5cf 100644
--- a/crates/ide-build/src/lib.rs
+++ b/crates/ide-build/src/lib.rs
@@ -20,7 +20,9 @@ mod xml;
pub use gradle::{GradleModel, GradleProject, GradleTask};
pub use gradle_deps::{GradleDependency, gradle_dependencies};
pub use maven::{MavenDependency, MavenModel, MavenPlugin, MavenProfile, MavenProject, Omission};
-pub use sync::{SyncOptions, SyncProgress, maven_profiles, save_maven_profiles, sync_gradle, sync_maven};
+pub use sync::{
+ SyncOptions, SyncProgress, maven_profiles, save_maven_profiles, sync_gradle, sync_gradle_model, sync_maven,
+};
use serde::{Deserialize, Serialize};
use specta::Type;
@@ -38,13 +40,31 @@ pub enum BuildTool {
#[serde(rename_all = "camelCase")]
pub struct BuildModel {
pub maven: Option,
- pub gradle: Option,
+ #[serde(default)]
+ pub gradle: Vec,
+}
+
+impl BuildModel {
+ /// Prefer the deepest linked build owning the module over a composite including it.
+ pub fn gradle_for_module(&self, root: &Path, module: &Path) -> Option<&GradleModel> {
+ let module = root.join(module);
+ self.gradle.iter().filter(|g| g.projects.iter().any(|p| root.join(&p.dir) == module)).min_by_key(|g| {
+ let dir = root.join(&g.dir);
+ std::cmp::Reverse(if module.starts_with(&dir) { dir.components().count() } else { 0 })
+ })
+ }
}
/// 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) -> BuildModel {
- load_linked(root, cache_dir, maven_repo, &ide_core::settings::MavenSettings::default())
+ load_linked(
+ root,
+ cache_dir,
+ maven_repo,
+ &ide_core::settings::MavenSettings::default(),
+ &ide_core::settings::GradleSettings::default(),
+ )
}
/// Loads only linked Maven reactors, retaining an empty model so projects can be re-added.
@@ -53,31 +73,66 @@ pub fn load_linked(
cache_dir: &Path,
maven_repo: Option,
settings: &ide_core::settings::MavenSettings,
+ gradle_settings: &ide_core::settings::GradleSettings,
) -> BuildModel {
- let maven = maven::is_maven_project(root).then(|| {
+ let maven = maven::has_projects(root, settings).then(|| {
let profiles = sync::maven_profiles(cache_dir);
- let mut m = maven::load_with(root, maven_repo.or_else(maven::default_local_repo), &profiles);
+ let repo = maven_repo.or_else(maven::default_local_repo);
+ let mut m = maven::load_with_linked(root, repo, &profiles, &settings.linked_projects);
maven::UnlinkedProjects::from_model(&m, settings).filter(&mut m);
+ // The user settings' profiles can be chosen too (`-P` applies to them as well).
+ for p in maven::user_settings_file(root, settings).map(|f| maven::settings_profiles(&f)).unwrap_or_default() {
+ if !m.profiles.iter().any(|x| x.id == p.id) {
+ m.profiles.push(p);
+ }
+ }
sync::apply_maven_cache(root, cache_dir, &mut m);
m
});
- let gradle = gradle::load(root).map(|mut g| {
- sync::apply_gradle_cache(root, cache_dir, &mut g);
- g
- });
+ let gradle = gradle::load_linked(root, gradle_settings)
+ .into_iter()
+ .map(|mut g| {
+ sync::apply_gradle_cache(root, &gradle::cache_dir(cache_dir, &g.dir), &mut g);
+ g
+ })
+ .collect();
BuildModel { maven, gradle }
}
/// Gradle project folders outside `root` as of the last sync (`includeFlat` siblings):
/// extra content roots, as IDEA lists module roots beside the project folder.
/// Reads only the sync's cached projects (no build script), so it is cheap enough for hot paths.
-pub fn gradle_external_roots(root: &Path, cache_dir: &Path) -> Vec {
- gradle::external_roots(root, sync::reported_gradle_projects(cache_dir).iter().map(|p| p.dir.as_str()))
+pub fn gradle_external_roots(
+ root: &Path,
+ cache_dir: &Path,
+ settings: &ide_core::settings::GradleSettings,
+) -> Vec {
+ let builds = gradle::linked_dirs(root, settings);
+ let dirs: Vec = builds
+ .iter()
+ .flat_map(|dir| {
+ std::iter::once(root.join(dir).to_string_lossy().into_owned()).chain(
+ sync::reported_gradle_projects(&gradle::cache_dir(cache_dir, dir))
+ .iter()
+ .map(|p| p.dir.clone())
+ .collect::>(),
+ )
+ })
+ .collect();
+ gradle::external_roots(root, dirs.iter().map(String::as_str))
}
/// Source, resource and build folders of the last Gradle sync, absolute.
-pub fn gradle_source_roots(cache_dir: &Path) -> Vec<(PathBuf, gradle::ReportedRoot)> {
- gradle::reported_source_roots(&sync::reported_gradle_projects(cache_dir))
+pub fn gradle_source_roots(
+ root: &Path,
+ cache_dir: &Path,
+ settings: &ide_core::settings::GradleSettings,
+) -> Vec<(PathBuf, gradle::ReportedRoot)> {
+ let reported = gradle::linked_dirs(root, settings)
+ .iter()
+ .flat_map(|dir| sync::reported_gradle_projects(&gradle::cache_dir(cache_dir, dir)).as_ref().clone())
+ .collect::>();
+ gradle::reported_source_roots(&reported)
}
/// A library the project depends on, as `groupId:artifactId:version`.
@@ -121,7 +176,7 @@ pub fn dependency_coordinates(root: &Path, model: &BuildModel) -> Vec bool {
mod tests {
use super::*;
+ #[test]
+ fn external_linked_build_is_indexed_before_sync_and_disappears_when_unlinked() {
+ let temp = tempfile::tempdir().unwrap();
+ let root = temp.path().join("workspace");
+ let external = temp.path().join("external");
+ std::fs::create_dir(&root).unwrap();
+ std::fs::create_dir(&external).unwrap();
+ std::fs::write(external.join("settings.gradle"), "").unwrap();
+ let mut settings = ide_core::settings::GradleSettings {
+ linked_projects: Some(vec![external.to_string_lossy().into_owned()]),
+ ..Default::default()
+ };
+ assert_eq!(
+ gradle_external_roots(&root, &temp.path().join("cache"), &settings),
+ [dunce::canonicalize(&external).unwrap()]
+ );
+ settings.linked_projects = Some(vec![]);
+ assert!(gradle_external_roots(&root, &temp.path().join("cache"), &settings).is_empty());
+ }
+
#[test]
fn detects_build_systems_and_build_files() {
let tmp = tempfile::tempdir().unwrap();
@@ -158,7 +233,7 @@ mod tests {
std::fs::write(root.join("build.gradle"), "").unwrap();
let m = load(&root, &cache, None);
assert_eq!(m.maven.unwrap().projects[0].pom, "svc/pom.xml");
- assert_eq!(m.gradle.unwrap().projects[0].path, ":");
+ assert_eq!(m.gradle[0].projects[0].path, ":");
assert!(affects_build(Path::new("a/pom.xml")) && affects_build(Path::new("settings.gradle.kts")));
assert!(!affects_build(Path::new("src/Main.java")));
}
@@ -205,7 +280,7 @@ mod tests {
],
..Default::default()
};
- let gradle = gradle::load(root);
+ let gradle = gradle::load(root).into_iter().collect();
let model = BuildModel { maven: Some(maven), gradle };
let names: Vec = dependency_coordinates(root, &model)
.into_iter()
diff --git a/crates/ide-build/src/maven.rs b/crates/ide-build/src/maven.rs
index 12c8b14a8..5fd70ba67 100644
--- a/crates/ide-build/src/maven.rs
+++ b/crates/ide-build/src/maven.rs
@@ -472,6 +472,41 @@ pub(crate) fn default_local_repo() -> Option {
local_repository_of(&m2.join("settings.xml")).or(Some(m2.join("repository")))
}
+/// The user `settings.xml` a build reads: the configured one (`~/` and project-relative
+/// paths allowed), else `~/.m2/settings.xml`.
+pub fn user_settings_file(root: &Path, settings: &ide_core::settings::MavenSettings) -> Option {
+ let configured = settings.user_settings.trim();
+ if configured.is_empty() {
+ return Some(dirs::home_dir()?.join(".m2").join("settings.xml"));
+ }
+ let path = match configured.strip_prefix("~/") {
+ Some(rest) => dirs::home_dir()?.join(rest),
+ None => PathBuf::from(configured),
+ };
+ Some(if path.is_absolute() { path } else { root.join(path) })
+}
+
+/// The profiles a `settings.xml` defines, for the Profiles list beside the POMs' own (as
+/// IDEA lists them): checked by default when `` names them or they are
+/// `activeByDefault`.
+pub fn settings_profiles(settings: &Path) -> Vec {
+ let Some(root) = std::fs::read_to_string(settings).ok().and_then(|text| xml::parse(&text)) else {
+ return Vec::new();
+ };
+ let active: Vec =
+ root.list("activeProfiles", "activeProfile").iter().map(|e| e.text.trim().to_owned()).collect();
+ let mut profiles: Vec = Vec::new();
+ for profile in root.list("profiles", "profile") {
+ let Some(id) = profile.text_of("id") else { continue };
+ if profiles.iter().any(|p| p.id == id) {
+ continue;
+ }
+ let by_default = profile.at("activation/activeByDefault").is_some_and(|e| e.text.trim() == "true");
+ profiles.push(MavenProfile { active_by_default: by_default || active.contains(&id), id });
+ }
+ profiles
+}
+
/// `` of a settings file, when it names one: the repository a build
/// with `-s ` resolves into, which is not necessarily the user's default.
pub fn local_repository_of(settings: &Path) -> Option {
@@ -1065,10 +1100,9 @@ pub fn load(root: &Path) -> MavenModel {
load_with_repo(root, default_local_repo())
}
-/// [`load`] with the chosen `profiles` (`-P` entries; `!id` deactivates): their modules,
-/// dependencies and properties are part of the model, those turned off are not.
-pub fn load_with_profiles(root: &Path, profiles: &[String]) -> MavenModel {
- load_with(root, default_local_repo(), profiles)
+/// [`load`] with the POMs `settings` link (Add Maven Projects).
+pub fn load_linked(root: &Path, settings: &ide_core::settings::MavenSettings) -> MavenModel {
+ load_with_linked(root, default_local_repo(), &[], &settings.linked_projects)
}
pub(crate) fn load_with_repo(root: &Path, local_repo: Option) -> MavenModel {
@@ -1090,11 +1124,53 @@ fn profile_choice(chosen: &[String], id: &str) -> Option {
}
pub(crate) fn load_with(root: &Path, local_repo: Option, profiles: &[String]) -> MavenModel {
+ load_with_linked(root, local_repo, profiles, &[])
+}
+
+/// The POMs of `linked` (project-relative, from Settings) that exist inside `root`.
+fn linked_poms(root: &Path, linked: &[String]) -> Vec {
+ let base = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
+ let mut out: Vec = Vec::new();
+ for pom in linked {
+ let pom = normalized_pom(pom);
+ if pom.is_empty() || pom.starts_with("../") || pom == ".." {
+ continue;
+ }
+ let path = root.join(&pom);
+ let inside = dunce::canonicalize(&path).is_ok_and(|p| p.starts_with(&base));
+ if path.is_file() && inside && !out.contains(&path) {
+ out.push(path);
+ }
+ }
+ out
+}
+
+/// Whether `root` holds a Maven build or the settings link one inside it.
+pub fn has_projects(root: &Path, settings: &ide_core::settings::MavenSettings) -> bool {
+ is_maven_project(root) || !linked_poms(root, &settings.linked_projects).is_empty()
+}
+
+/// The model with the chosen `profiles` (`-P` entries; `!id` deactivates: their modules,
+/// dependencies and properties are part of the model, those turned off are not) and the
+/// `linked` POMs (Add Maven Projects): builds that no reactor lists, shown as independent
+/// top-level projects. A linked POM that turns out to be a module of the root reactor stays that module.
+pub fn load_with_linked(
+ root: &Path,
+ local_repo: Option,
+ profiles: &[String],
+ linked: &[String],
+) -> MavenModel {
let mut resolver = Resolver::new(local_repo, profiles);
let mut model = MavenModel::default();
let mut seen: HashSet = HashSet::new();
- // (pom, aggregated)
- let mut stack: Vec<(PathBuf, bool)> = top_level_poms(root).into_iter().rev().map(|p| (p, false)).collect();
+ let mut tops = top_level_poms(root);
+ for pom in linked_poms(root, linked) {
+ if !tops.contains(&pom) {
+ tops.push(pom);
+ }
+ }
+ // (pom, aggregated); the root reactor and its modules come before linked POMs.
+ let mut stack: Vec<(PathBuf, bool)> = tops.into_iter().rev().map(|p| (p, false)).collect();
while let Some((path, aggregated)) = stack.pop() {
if model.projects.len() >= MAX_PROJECTS {
break;
@@ -1216,7 +1292,7 @@ impl UnlinkedProjects {
if settings.unlinked_projects.is_empty() {
return Self::default();
}
- Self::from_model(&load(root), settings)
+ Self::from_model(&load_linked(root, settings), settings)
}
pub fn from_model(model: &MavenModel, settings: &ide_core::settings::MavenSettings) -> Self {
@@ -1495,6 +1571,37 @@ mod tests {
assert_eq!(p.prefix, "checkstyle");
}
+ #[test]
+ fn settings_profiles_are_listed_with_the_active_ones_checked() {
+ let tmp = tempfile::tempdir().unwrap();
+ let file = tmp.path().join("settings.xml");
+ std::fs::write(
+ &file,
+ r#"
+
+ nexus
+ jdk21 true
+ release
+ nexus
+
+
+ nexus
+ "#,
+ )
+ .unwrap();
+ let p = |id: &str, active| MavenProfile { id: id.into(), active_by_default: active };
+ assert_eq!(settings_profiles(&file), [p("nexus", true), p("jdk21", true), p("release", false)]);
+ assert!(settings_profiles(&tmp.path().join("missing.xml")).is_empty());
+
+ let mut s = ide_core::settings::MavenSettings::default();
+ let home = dirs::home_dir().unwrap();
+ assert_eq!(user_settings_file(tmp.path(), &s), Some(home.join(".m2/settings.xml")));
+ s.user_settings = "conf/settings.xml".into();
+ assert_eq!(user_settings_file(tmp.path(), &s), Some(tmp.path().join("conf/settings.xml")));
+ s.user_settings = "~/work/settings.xml".into();
+ assert_eq!(user_settings_file(tmp.path(), &s), Some(home.join("work/settings.xml")));
+ }
+
#[test]
fn a_settings_file_names_the_repository_to_read_goals_from() {
let tmp = tempfile::tempdir().unwrap();
@@ -1733,6 +1840,49 @@ mod tests {
assert!(model.projects.iter().all(|p| p.command == if cfg!(windows) { "mvn.cmd" } else { "mvn" }));
}
+ /// Add Maven Projects: a POM inside the project that no reactor lists becomes an
+ /// independent top-level project; one the root reactor lists stays its module.
+ #[test]
+ fn linked_poms_outside_the_reactor_are_top_level_projects() {
+ let tmp = tempfile::tempdir().unwrap();
+ let root = tmp.path();
+ write(
+ root,
+ "pom.xml",
+ "demo main 1.0 pom core ",
+ );
+ write(
+ root,
+ "core/pom.xml",
+ "demo core 1.0 ",
+ );
+ write(
+ root,
+ "stub/pom.xml",
+ "demo stub 1.0 ",
+ );
+ let top = |m: &MavenModel| m.projects.iter().map(|p| (p.dir.clone(), p.aggregated)).collect::>();
+
+ assert_eq!(top(&load_with_linked(root, None, &[], &[])), [("".into(), false), ("core".into(), true)]);
+ let linked: Vec =
+ ["stub/pom.xml", "./core/pom.xml", "../outside/pom.xml", "missing/pom.xml"].map(String::from).into();
+ assert_eq!(
+ top(&load_with_linked(root, None, &[], &linked)),
+ [("".into(), false), ("core".into(), true), ("stub".into(), false)]
+ );
+
+ // Deeper than the folder search reaches: only the link makes it a Maven project.
+ let deep = tempfile::tempdir().unwrap();
+ write(deep.path(), "a/b/c/tool/pom.xml", "tool ");
+ let settings = ide_core::settings::MavenSettings {
+ linked_projects: vec!["a/b/c/tool/pom.xml".into()],
+ ..Default::default()
+ };
+ assert!(!has_projects(deep.path(), &Default::default()));
+ assert!(has_projects(deep.path(), &settings));
+ assert_eq!(top(&load_linked(deep.path(), &settings)), [("a/b/c/tool".into(), false)]);
+ }
+
#[test]
fn chosen_profiles_bring_their_modules_and_dependencies() {
let tmp = tempfile::tempdir().unwrap();
diff --git a/crates/ide-build/src/sync.rs b/crates/ide-build/src/sync.rs
index 1a2524407..b390b2cca 100644
--- a/crates/ide-build/src/sync.rs
+++ b/crates/ide-build/src/sync.rs
@@ -283,7 +283,12 @@ pub fn sync_maven(
cancel: &CancellationToken,
progress: &dyn Fn(SyncProgress),
) -> IdeResult<()> {
- let mut model = maven::load_with_profiles(root, &options.profiles);
+ let mut model = maven::load_with_linked(
+ root,
+ maven::default_local_repo(),
+ &options.profiles,
+ &options.maven_settings.linked_projects,
+ );
maven::UnlinkedProjects::from_model(&model, &options.maven_settings).filter(&mut model);
std::fs::create_dir_all(cache_dir)?;
let mut cache = MavenCache {
@@ -373,8 +378,25 @@ pub fn sync_gradle(
cancel: &CancellationToken,
progress: &dyn Fn(SyncProgress),
) -> IdeResult<()> {
- let mut model = gradle::load(root).ok_or_else(|| IdeError::not_found("no Gradle build in this project"))?;
- std::fs::create_dir_all(cache_dir)?;
+ let model = gradle::load(root).ok_or_else(|| IdeError::not_found("no Gradle build in this project"))?;
+ sync_gradle_model(root, cache_dir, &model, options, cancel, progress)
+}
+
+/// Syncs this exact linked build; another build's task paths and cache cannot interfere.
+pub fn sync_gradle_model(
+ root: &Path,
+ cache_dir: &Path,
+ model: &GradleModel,
+ options: &SyncOptions,
+ cancel: &CancellationToken,
+ progress: &dyn Fn(SyncProgress),
+) -> IdeResult<()> {
+ if cancel.is_cancelled() {
+ return Err(IdeError::cancelled());
+ }
+ let mut model = model.clone();
+ let cache_dir = gradle::cache_dir(cache_dir, &model.dir);
+ std::fs::create_dir_all(&cache_dir)?;
let init_script = cache_dir.join("openidea-model.gradle");
std::fs::write(&init_script, MODEL_INIT_SCRIPT)?;
let mut args: Vec = vec![
@@ -389,14 +411,24 @@ pub fn sync_gradle(
args.push("--offline".into());
}
progress(SyncProgress { message: "Loading Gradle tasks".into(), fraction: None });
- let out = run(
+ let out = match run(
options.gradle_command.as_deref().unwrap_or(&model.command),
&args,
&root.join(&model.dir),
&options.env,
cancel,
|_| {},
- )?;
+ ) {
+ Ok(out) => out,
+ Err(_) if cancel.is_cancelled() => return Err(IdeError::cancelled()),
+ Err(error) if error.kind == ide_core::error::ErrorKind::Unavailable => {
+ Output { success: false, text: error.message }
+ }
+ Err(error) => return Err(error),
+ };
+ if cancel.is_cancelled() {
+ return Err(IdeError::cancelled());
+ }
let projects = if out.success { parse_reported_projects(&out.text) } else { Vec::new() };
// Hash the build files of the reported projects, or the model is stale right after the sync.
gradle::merge_reported_projects(root, &mut model, &projects);
@@ -407,13 +439,128 @@ pub fn sync_gradle(
tasks: if out.success { gradle::parse_tasks(&out.text) } else { Vec::new() },
projects,
};
- write_cache(cache_dir, GRADLE_CACHE, &cache)
+ write_cache(&cache_dir, GRADLE_CACHE, &cache)
}
#[cfg(test)]
mod tests {
use super::*;
+ #[cfg(unix)]
+ #[test]
+ fn unavailable_gradle_launchers_are_cached_without_stopping_other_builds() {
+ use std::os::unix::fs::PermissionsExt;
+ let temp = tempfile::tempdir().unwrap();
+ let root = temp.path();
+ for dir in ["a", "b"] {
+ std::fs::create_dir(root.join(dir)).unwrap();
+ std::fs::write(root.join(dir).join("build.gradle"), "").unwrap();
+ }
+ let wrapper = root.join("b/gradlew");
+ std::fs::write(&wrapper, "#!/bin/sh\nprintf '%s\\n' 'Build tasks' '-----------' 'healthy - ok'\n").unwrap();
+ std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).unwrap();
+ let settings = ide_core::settings::GradleSettings {
+ linked_projects: Some(vec!["a".into(), "b".into()]),
+ ..Default::default()
+ };
+ let mut models = gradle::load_linked(root, &settings);
+ models[0].command = root.join("a/missing-gradlew").to_string_lossy().into_owned();
+ let cache = root.join("cache");
+ let run = |model: &GradleModel| {
+ sync_gradle_model(root, &cache, model, &SyncOptions::default(), &CancellationToken::new(), &|_| {})
+ };
+ for model in &models {
+ run(model).unwrap();
+ }
+ let loaded = crate::load_linked(root, &cache, None, &Default::default(), &settings);
+ assert!(loaded.gradle[0].sync_error.as_deref().unwrap().contains("cannot start"));
+ assert!(loaded.gradle[1].sync_error.is_none());
+ assert_eq!(loaded.gradle[1].projects[0].tasks[0].name, "healthy");
+ let bad_wrapper = root.join("a/gradlew");
+ std::fs::write(&bad_wrapper, "#!/bin/sh\nexit 0\n").unwrap();
+ std::fs::set_permissions(&bad_wrapper, std::fs::Permissions::from_mode(0o644)).unwrap();
+ models[0].command = bad_wrapper.to_string_lossy().into_owned();
+ run(&models[0]).unwrap();
+ let loaded = crate::load_linked(root, &cache, None, &Default::default(), &settings);
+ assert!(loaded.gradle[0].sync_error.as_deref().unwrap().contains("cannot start"));
+ assert!(loaded.gradle[1].sync_error.is_none());
+ let blocked_cache = root.join("blocked-cache");
+ std::fs::write(&blocked_cache, "a file cannot contain sync caches").unwrap();
+ assert!(
+ sync_gradle_model(
+ root,
+ &blocked_cache,
+ &models[0],
+ &SyncOptions::default(),
+ &CancellationToken::new(),
+ &|_| {}
+ )
+ .is_err()
+ );
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn independent_syncs_do_not_share_tasks_launchers_environments_or_failures() {
+ use std::os::unix::fs::PermissionsExt;
+ let temp = tempfile::tempdir().unwrap();
+ let root = temp.path();
+ for dir in ["a", "b"] {
+ let build = root.join(dir);
+ std::fs::create_dir_all(build.join("app")).unwrap();
+ std::fs::write(build.join("settings.gradle"), "include 'app'").unwrap();
+ std::fs::write(build.join("app/build.gradle"), "").unwrap();
+ let report = serde_json::json!({"path": ":app", "dir": build.join("app")});
+ let launcher = build.join("gradlew");
+ std::fs::write(
+ &launcher,
+ format!("#!/bin/sh\npwd > actual-cwd\nprintf '%s' \"$JAVA_HOME\" > actual-env\nprintf '%s\\n' '@openidea-model\t{report}' 'Build tasks' '-----------' 'app:custom{dir} - custom task'\n"),
+ ).unwrap();
+ std::fs::set_permissions(launcher, std::fs::Permissions::from_mode(0o755)).unwrap();
+ }
+ let settings = ide_core::settings::GradleSettings {
+ linked_projects: Some(vec!["a".into(), "b".into()]),
+ ..Default::default()
+ };
+ let cache = root.join("cache");
+ let models = gradle::load_linked(root, &settings);
+ for model in &models {
+ let options = SyncOptions {
+ env: BTreeMap::from([("JAVA_HOME".into(), format!("/jdk-{}", model.dir))]),
+ ..Default::default()
+ };
+ sync_gradle_model(root, &cache, model, &options, &CancellationToken::new(), &|_| {}).unwrap();
+ assert_eq!(
+ std::fs::read_to_string(root.join(&model.dir).join("actual-env")).unwrap(),
+ format!("/jdk-{}", model.dir)
+ );
+ assert_eq!(
+ std::fs::read_to_string(root.join(&model.dir).join("actual-cwd")).unwrap().trim(),
+ root.join(&model.dir).canonicalize().unwrap().to_string_lossy()
+ );
+ }
+ let load = || crate::load_linked(root, &cache, None, &Default::default(), &settings);
+ let model = load();
+ assert_eq!(model.gradle[0].projects[0].tasks[0].name, "customa");
+ assert_eq!(model.gradle[1].projects[0].tasks[0].name, "customb");
+ assert_ne!(gradle::cache_dir(&cache, "a"), gradle::cache_dir(&cache, "b"));
+ let before = std::fs::read(gradle::cache_dir(&cache, "a").join(GRADLE_CACHE)).unwrap();
+ std::fs::write(root.join("b/gradlew"), "#!/bin/sh\nprintf 'FAILURE: broken b\\n'\nexit 1\n").unwrap();
+ sync_gradle_model(root, &cache, &models[1], &SyncOptions::default(), &CancellationToken::new(), &|_| {})
+ .unwrap();
+ let model = load();
+ assert!(model.gradle[0].sync_error.is_none());
+ assert!(model.gradle[1].sync_error.is_some());
+ assert_eq!(std::fs::read(gradle::cache_dir(&cache, "a").join(GRADLE_CACHE)).unwrap(), before);
+ let cancel = CancellationToken::new();
+ cancel.cancel();
+ assert!(sync_gradle_model(root, &cache, &models[0], &SyncOptions::default(), &cancel, &|_| {}).is_err());
+ assert_eq!(std::fs::read(gradle::cache_dir(&cache, "a").join(GRADLE_CACHE)).unwrap(), before);
+ let unlinked = ide_core::settings::GradleSettings { linked_projects: Some(vec![]), ..Default::default() };
+ assert!(crate::load_linked(root, &cache, None, &Default::default(), &unlinked).gradle.is_empty());
+ assert!(crate::gradle_source_roots(root, &cache, &unlinked).is_empty());
+ }
+
#[cfg(unix)]
#[test]
fn gradle_sync_uses_resolved_command_and_environment() {
@@ -601,7 +748,7 @@ mod tests {
write(&projects);
let first = reported_gradle_projects(&cache_dir);
assert!(Arc::ptr_eq(&first, &reported_gradle_projects(&cache_dir)), "unchanged: not parsed again");
- assert_eq!(crate::gradle_external_roots(&root, &cache_dir), [billing]);
+ assert_eq!(crate::gradle_external_roots(&root, &cache_dir, &Default::default()), [billing]);
write(&projects[..1]);
assert_eq!(reported_gradle_projects(&cache_dir).len(), 1, "a new sync is read");
std::fs::remove_file(cache_dir.join(GRADLE_CACHE)).unwrap();
diff --git a/crates/ide-build/src/web.rs b/crates/ide-build/src/web.rs
index 9f25039ac..fbff22b77 100644
--- a/crates/ide-build/src/web.rs
+++ b/crates/ide-build/src/web.rs
@@ -26,12 +26,12 @@ pub fn artifacts(root: &Path, model: &BuildModel) -> Vec {
});
}
}
- if let Some(gradle) = &model.gradle {
+ for gradle in &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),
+ id: gradle_artifact_id(gradle, &p.path),
name: p.name.clone(),
module: p.dir.clone(),
tool: "gradle".into(),
@@ -42,6 +42,14 @@ pub fn artifacts(root: &Path, model: &BuildModel) -> Vec {
out
}
+fn gradle_artifact_id(build: &crate::GradleModel, path: &str) -> String {
+ if build.dir.is_empty() {
+ format!("gradle:{path}")
+ } else {
+ format!("gradle:{}", serde_json::to_string(&(&build.dir, path)).unwrap())
+ }
+}
+
pub struct WebBuild {
pub command: String,
pub args: Vec,
@@ -150,9 +158,14 @@ pub fn build(root: &Path, model: &BuildModel, id: &str, cache: &Path) -> IdeResu
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) {
+ if let Some(legacy_path) = id.strip_prefix("gradle:") {
+ let identity = serde_json::from_str::<(String, String)>(legacy_path).ok();
+ let (g, path) = match &identity {
+ Some((dir, path)) => (model.gradle.iter().find(|g| g.dir == *dir), path.as_str()),
+ None => (model.gradle.first(), legacy_path),
+ };
+ let g = g.ok_or_else(|| IdeError::invalid("Choose a Gradle WAR module"))?;
+ if !artifacts(root, model).iter().any(|a| a.id == gradle_artifact_id(g, path)) {
return Err(IdeError::invalid("Choose a Gradle WAR module"));
}
let script = cache.join("war.init.gradle");
@@ -186,6 +199,28 @@ pub fn build(root: &Path, model: &BuildModel, id: &str, cache: &Path) -> IdeResu
#[cfg(test)]
mod tests {
+ #[test]
+ fn independent_war_modules_have_distinct_ids_and_build_in_their_own_directory() {
+ let temp = tempfile::tempdir().unwrap();
+ let root = temp.path();
+ for dir in ["a", "b"] {
+ std::fs::create_dir(root.join(dir)).unwrap();
+ std::fs::write(root.join(dir).join("build.gradle"), "plugins { id 'war' }").unwrap();
+ }
+ let settings = ide_core::settings::GradleSettings {
+ linked_projects: Some(vec!["a".into(), "b".into()]),
+ ..Default::default()
+ };
+ let model = crate::load_linked(root, &root.join("cache"), None, &Default::default(), &settings);
+ let artifacts = super::artifacts(root, &model);
+ assert_eq!(artifacts.len(), 2);
+ assert_ne!(artifacts[0].id, artifacts[1].id);
+ for (artifact, dir) in artifacts.iter().zip(["a", "b"]) {
+ let plan = super::build(root, &model, &artifact.id, &root.join(format!("cache/{dir}"))).unwrap();
+ assert_eq!(plan.cwd, dir);
+ }
+ assert_eq!(super::build(root, &model, "gradle::", &root.join("legacy")).unwrap().cwd, "a");
+ }
use super::*;
#[test]
fn maven_war_modules_use_resolved_artifact_file() {
diff --git a/crates/ide-core/src/lib.rs b/crates/ide-core/src/lib.rs
index 023f9d729..7bbb35dad 100644
--- a/crates/ide-core/src/lib.rs
+++ b/crates/ide-core/src/lib.rs
@@ -24,6 +24,7 @@ pub mod text;
pub mod tool_path;
pub mod version_managers;
pub mod workflow_settings;
+pub mod wsl;
pub use error::{IdeError, IdeResult};
pub use event::{EventSink, IdeEvent, NullSink, SharedSink};
diff --git a/crates/ide-core/src/settings.rs b/crates/ide-core/src/settings.rs
index 4b41cfe75..6f65eff4c 100644
--- a/crates/ide-core/src/settings.rs
+++ b/crates/ide-core/src/settings.rs
@@ -828,6 +828,8 @@ pub struct JavaSettings {
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct GradleSettings {
+ /// Linked build directories; absent preserves automatic discovery, empty unlinks all builds.
+ pub linked_projects: Option>,
/// Empty = Project JDK; otherwise a JDK home.
pub jvm: String,
pub distribution: GradleDistribution,
@@ -864,6 +866,8 @@ pub enum GradleDistribution {
pub struct MavenSettings {
/// Project-relative root POMs removed from the Maven project model.
pub unlinked_projects: Vec,
+ /// Project-relative POMs outside the root reactor added as independent top-level projects.
+ pub linked_projects: Vec,
/// 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`.
@@ -1124,6 +1128,16 @@ mod maven_linkage_tests {
mod gradle_settings_tests {
use super::*;
+ #[test]
+ fn absent_and_explicitly_empty_gradle_links_remain_distinct() {
+ let automatic: GradleSettings = serde_json::from_str("{}").unwrap();
+ let unlinked: GradleSettings = serde_json::from_str(r#"{"linkedProjects":[]}"#).unwrap();
+ assert_eq!(automatic.linked_projects, None);
+ assert_eq!(unlinked.linked_projects, Some(vec![]));
+ let restored: GradleSettings = serde_json::from_str(&serde_json::to_string(&unlinked).unwrap()).unwrap();
+ assert_eq!(restored.linked_projects, Some(vec![]));
+ }
+
#[test]
fn defaults_and_round_trip() {
let old: JavaSettings = serde_json::from_str(r#"{"jdk":"/jdk"}"#).unwrap();
@@ -1136,6 +1150,7 @@ mod gradle_settings_tests {
home: "/gradle".into(),
user_home: "/cache/gradle".into(),
run_using: GradleRunUsing::Ide,
+ linked_projects: Some(vec!["backend".into(), "../shared".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);
diff --git a/crates/ide-core/src/wsl.rs b/crates/ide-core/src/wsl.rs
new file mode 100644
index 000000000..985280c4b
--- /dev/null
+++ b/crates/ide-core/src/wsl.rs
@@ -0,0 +1,137 @@
+//! Projects that live inside a WSL distro are reached from Windows through the
+//! `\\wsl.localhost\` (or legacy `\\wsl$\`) share.
+
+use std::path::Path;
+
+/// The WSL launcher on Windows.
+pub const EXE: &str = "wsl.exe";
+
+/// A location inside a WSL distro.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct WslPath {
+ pub distro: String,
+ /// Absolute Linux path, e.g. `/home/me/app`.
+ pub path: String,
+}
+
+/// Splits a WSL share path into distro and Linux path. Pure string work, so it
+/// accepts every spelling the UI and the OS produce: `\\wsl.localhost\X\a`,
+/// `//wsl$/X/a` and the verbatim `\\?\UNC\wsl.localhost\X\a`.
+pub fn parse(path: &Path) -> Option {
+ let s = path.to_string_lossy().replace('\\', "/");
+ let s = match s.strip_prefix("//?/UNC/") {
+ Some(rest) => format!("//{rest}"),
+ None => s,
+ };
+ let rest = s.strip_prefix("//")?;
+ let (host, rest) = rest.split_once('/')?;
+ if !host.eq_ignore_ascii_case("wsl.localhost") && !host.eq_ignore_ascii_case("wsl$") {
+ return None;
+ }
+ let (distro, tail) = rest.split_once('/').unwrap_or((rest, ""));
+ if distro.is_empty() {
+ return None;
+ }
+ Some(WslPath { distro: distro.to_owned(), path: format!("/{}", tail.trim_end_matches('/')) })
+}
+
+/// A distro name `wsl.exe -d` can be given safely: Windows creates them from letters, digits,
+/// `.`, `_` and `-`. A leading `-` would be read as an option, so it is refused too.
+pub fn is_valid_distro(name: &str) -> bool {
+ !name.is_empty()
+ && name.len() <= 64
+ && !name.starts_with('-')
+ && name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
+}
+
+/// Text of `wsl.exe`'s own output: UTF-16LE (with or without BOM) for its management commands
+/// and its error messages, UTF-8 for what a command inside the distro prints.
+pub fn decode_output(raw: &[u8]) -> String {
+ if raw.starts_with(&[0xff, 0xfe]) || raw.get(1) == Some(&0) {
+ let units: Vec = raw.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes(*c)).collect();
+ String::from_utf16_lossy(&units)
+ } else {
+ String::from_utf8_lossy(raw).into_owned()
+ }
+}
+
+/// Distro names from the output of `wsl.exe -l -q`. That output is UTF-16LE (UTF-8 when
+/// `WSL_UTF8=1`), one name per line; names that fail [`is_valid_distro`] are dropped, which also
+/// removes the localized "no installed distributions" message `wsl.exe` prints when there are none.
+pub fn parse_distro_list(raw: &[u8]) -> Vec {
+ let text = decode_output(raw);
+ let mut names: Vec = Vec::new();
+ for line in text.lines() {
+ let name = line.trim_matches(|c: char| c.is_whitespace() || c == '\u{feff}');
+ if is_valid_distro(name) && !names.iter().any(|n| n == name) {
+ names.push(name.to_owned());
+ }
+ }
+ names
+}
+
+/// Installed WSL distros. Empty off Windows, when WSL is not installed or when `wsl.exe` fails.
+pub fn list_distros() -> Vec {
+ #[cfg(windows)]
+ {
+ let mut cmd = std::process::Command::new(EXE);
+ cmd.args(["-l", "-q"]);
+ crate::process::prepare(&mut cmd);
+ match cmd.output() {
+ Ok(out) if out.status.success() => parse_distro_list(&out.stdout),
+ _ => Vec::new(),
+ }
+ }
+ #[cfg(not(windows))]
+ Vec::new()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn p(s: &str) -> Option<(String, String)> {
+ parse(Path::new(s)).map(|w| (w.distro, w.path))
+ }
+
+ #[test]
+ fn every_spelling_of_the_share_is_recognised() {
+ let want = Some(("Ubuntu-24.04".to_owned(), "/home/me/app".to_owned()));
+ assert_eq!(p(r"\\wsl.localhost\Ubuntu-24.04\home\me\app"), want);
+ assert_eq!(p("//wsl.localhost/Ubuntu-24.04/home/me/app"), want);
+ assert_eq!(p(r"\\wsl$\Ubuntu-24.04\home\me\app\"), want);
+ assert_eq!(p(r"\\?\UNC\WSL.LOCALHOST\Ubuntu-24.04\home\me\app"), want);
+ }
+
+ #[test]
+ fn distro_root_and_foreign_paths() {
+ assert_eq!(p(r"\\wsl.localhost\Debian"), Some(("Debian".to_owned(), "/".to_owned())));
+ assert_eq!(p(r"\\server\share\a"), None);
+ assert_eq!(p(r"\\wsl.localhost\"), None);
+ assert_eq!(p("C:/Users/me"), None);
+ assert_eq!(p("/home/me"), None);
+ }
+
+ fn utf16(s: &str) -> Vec {
+ std::iter::once(0xfeffu16).chain(s.encode_utf16()).flat_map(u16::to_le_bytes).collect()
+ }
+
+ #[test]
+ fn distro_list_is_decoded_from_utf16_and_utf8() {
+ let want = vec!["Ubuntu-24.04".to_owned(), "Debian".to_owned()];
+ assert_eq!(parse_distro_list(&utf16("Ubuntu-24.04\r\nDebian\r\n\r\n")), want);
+ assert_eq!(parse_distro_list(b"Ubuntu-24.04\nDebian\nDebian\n"), want);
+ assert!(
+ parse_distro_list(&utf16("Windows Subsystem for Linux has no installed distributions.\r\n")).is_empty()
+ );
+ assert!(parse_distro_list(b"").is_empty());
+ }
+
+ #[test]
+ fn distro_names_cannot_smuggle_options_or_paths() {
+ for bad in ["", "-d", "--exec", "a b", "a/b", r"a\b", "a;b", "a$(x)", "a\"b"] {
+ assert!(!is_valid_distro(bad), "{bad}");
+ }
+ assert!(is_valid_distro("Ubuntu-22.04") && is_valid_distro("docker_desktop"));
+ }
+}
diff --git a/crates/ide-dap/src/lib.rs b/crates/ide-dap/src/lib.rs
index f2790ad54..5d3012377 100644
--- a/crates/ide-dap/src/lib.rs
+++ b/crates/ide-dap/src/lib.rs
@@ -302,6 +302,23 @@ pub struct DebugManager {
data_breakpoints: Mutex>,
}
+/// Whether the debugged project depends on the Flutter SDK, so it must go
+/// through `flutter debug_adapter` instead of the plain Dart one.
+///
+/// `dart debug_adapter` cannot start a program that imports `dart:ui`, and only
+/// the Flutter adapter answers `hotReload` / `hotRestart`. `dir` is the launch
+/// `cwd`; the program's own directory is tried first because a module inside a
+/// larger repository has its `pubspec.yaml` above the program, not above `cwd`.
+fn is_flutter_project(dir: &Path, program: Option<&Path>) -> bool {
+ let mut start: Vec<&Path> = program.and_then(Path::parent).into_iter().collect();
+ start.push(dir);
+ start
+ .iter()
+ .filter_map(|d| d.ancestors().take(6).find(|a| a.join("pubspec.yaml").is_file()))
+ .filter_map(|root| std::fs::read_to_string(root.join("pubspec.yaml")).ok())
+ .any(|s| s.contains("sdk: flutter"))
+}
+
fn no_session(s: DebugSessionId) -> IdeError {
IdeError::not_found(format!("no debug session {}", s.0))
}
@@ -570,6 +587,10 @@ impl DebugManager {
custom_command: impl FnOnce(&str) -> Option,
) -> IdeResult {
let mut arguments = launch_arguments(&config)?;
+ // An IDE-side marker (never sent to the adapter): the session is a Flutter
+ // one regardless of what the working directory holds.
+ let force_flutter =
+ arguments.as_object_mut().and_then(|o| o.remove("__flutter")).and_then(|v| v.as_bool()).unwrap_or(false);
let cwd = config
.cwd
.clone()
@@ -601,6 +622,15 @@ impl DebugManager {
spec.command = python;
}
}
+ // Flutter: a project depending on the Flutter SDK needs `flutter debug_adapter`
+ // (the Dart adapter cannot start a program importing `dart:ui`, and hot reload
+ // is Flutter-only). Plain Dart projects keep the configured `dart`.
+ if config.adapter == "dart" && spec.command == "dart" {
+ let program = arguments.get("program").and_then(Value::as_str).map(Path::new);
+ if force_flutter || is_flutter_project(&cwd, program) {
+ spec.command = "flutter".into();
+ }
+ }
let id = DebugSessionId::next();
let debug_server = take_debug_server(&mut arguments)?;
if let Some(server) = &debug_server {
@@ -1236,6 +1266,19 @@ impl DebugManager {
Ok(())
}
+ /// Flutter's DAP adapter: injects the changed sources into the running app.
+ /// Not part of the DAP standard; adapters that do not know the request reject it.
+ pub async fn hot_reload(&self, s: DebugSessionId) -> IdeResult<()> {
+ self.session(s)?.conn.request_timeout("hotReload", json!({ "reason": "manual" }), REQUEST_TIMEOUT).await?;
+ Ok(())
+ }
+
+ /// Flutter's DAP adapter: restarts the app without preserving state.
+ pub async fn hot_restart(&self, s: DebugSessionId) -> IdeResult<()> {
+ self.session(s)?.conn.request_timeout("hotRestart", json!({ "reason": "manual" }), REQUEST_TIMEOUT).await?;
+ Ok(())
+ }
+
pub async fn step(&self, s: DebugSessionId, thread_id: i64, kind: StepKind) -> IdeResult<()> {
let command = match kind {
StepKind::Over => "next",
diff --git a/crates/ide-dap/src/tests.rs b/crates/ide-dap/src/tests.rs
index 1e5647b7c..e1efd7101 100644
--- a/crates/ide-dap/src/tests.rs
+++ b/crates/ide-dap/src/tests.rs
@@ -1269,3 +1269,19 @@ async fn a_plugin_language_runs_its_debug_adapter_from_the_approved_program() {
assert_eq!(err.kind, ide_core::error::ErrorKind::PermissionDenied, "{}", err.message);
let _ = std::fs::remove_dir_all(&dir);
}
+
+#[test]
+fn flutter_projects_are_found_from_the_program_or_the_working_directory() {
+ let root = std::env::temp_dir().join(format!("openidea-flutter-{}", std::process::id()));
+ let app = root.join("app");
+ let tool = root.join("tool");
+ std::fs::create_dir_all(app.join("lib")).unwrap();
+ std::fs::create_dir_all(tool.join("bin")).unwrap();
+ std::fs::write(app.join("pubspec.yaml"), "name: app\ndependencies:\n flutter:\n sdk: flutter\n").unwrap();
+ std::fs::write(tool.join("pubspec.yaml"), "name: tool\ndependencies:\n args: ^2.0.0\n").unwrap();
+ assert!(is_flutter_project(&app, None));
+ assert!(is_flutter_project(&root, Some(&app.join("lib/main.dart"))));
+ assert!(!is_flutter_project(&tool, Some(&tool.join("bin/main.dart"))));
+ assert!(!is_flutter_project(&root, None));
+ std::fs::remove_dir_all(&root).unwrap();
+}
diff --git a/crates/ide-db/src/clickhouse.rs b/crates/ide-db/src/clickhouse.rs
index 34e869dce..9d04f9c7d 100644
--- a/crates/ide-db/src/clickhouse.rs
+++ b/crates/ide-db/src/clickhouse.rs
@@ -150,6 +150,7 @@ impl ClickhousePool {
self.0.client.read().clone().ok_or_else(|| IdeError::new(ErrorKind::Io, "ClickHouse connection is closed"))
}
+ /// `max_rows`: the rows the server may send (the skipped ones included).
fn request(&self, client: &Client, query_id: &str, max_rows: usize, initialized: bool) -> RequestBuilder {
let mut request = client
.post(self.0.endpoint.clone())
@@ -177,6 +178,17 @@ impl ClickhousePool {
}
pub async fn execute(&self, sql: &str, max_rows: usize, cancel: &CancellationToken) -> IdeResult {
+ self.execute_from(sql, 0, max_rows, cancel).await
+ }
+
+ /// [`execute`](Self::execute), reading past the first `skip` rows without parsing or keeping them.
+ pub async fn execute_from(
+ &self,
+ sql: &str,
+ skip: usize,
+ max_rows: usize,
+ cancel: &CancellationToken,
+ ) -> IdeResult {
if transaction_statement(sql) {
return Err(transaction_error());
}
@@ -188,7 +200,7 @@ impl ClickhousePool {
};
let client = self.client()?;
let query_id = uuid::Uuid::new_v4().to_string();
- let request = self.request(&client, &query_id, max_rows, *session).body(sql.to_owned());
+ let request = self.request(&client, &query_id, skip.saturating_add(max_rows), *session).body(sql.to_owned());
let started = Instant::now();
let fetch = async {
let mut response = request.send().await.map_err(http_error)?;
@@ -216,7 +228,7 @@ impl ClickhousePool {
}
(Vec::new(), Vec::new(), false)
} else {
- let mut reader = RowReader::new(max_rows);
+ let mut reader = RowReader::new(skip, max_rows);
while let Some(chunk) = response.chunk().await.map_err(http_error)? {
reader.push(&chunk)?;
}
@@ -309,11 +321,13 @@ impl ClickhousePool {
type DecodedResult = (Vec, Vec>>, bool);
-/// Decodes a streamed [`FORMAT`] body. Keeps the first `max_rows` rows; later rows only set
-/// `has_more` and are read past unparsed, so a grid limit bounds memory however large the
-/// block `result_overflow_mode=break` lets the server send. A line that is not a JSON array
+/// Decodes a streamed [`FORMAT`] body. Reads past the first `skip` rows unparsed (a later
+/// page), then keeps `max_rows` rows; later rows only set `has_more` and are read past
+/// unparsed, so a grid limit bounds memory however large the block
+/// `result_overflow_mode=break` lets the server send. A line that is not a JSON array
/// starts the exception ClickHouse writes when a query fails mid-stream.
struct RowReader {
+ skip: usize,
max_rows: usize,
pending: Vec,
names: Option>,
@@ -324,8 +338,17 @@ struct RowReader {
}
impl RowReader {
- fn new(max_rows: usize) -> Self {
- Self { max_rows, pending: Vec::new(), names: None, types: None, rows: Vec::new(), more: false, exception: None }
+ fn new(skip: usize, max_rows: usize) -> Self {
+ Self {
+ skip,
+ max_rows,
+ pending: Vec::new(),
+ names: None,
+ types: None,
+ rows: Vec::new(),
+ more: false,
+ exception: None,
+ }
}
fn push(&mut self, chunk: &[u8]) -> IdeResult<()> {
@@ -356,9 +379,15 @@ impl RowReader {
self.exception = Some(format!("{}\n", String::from_utf8_lossy(line)));
return Ok(());
}
- if self.names.is_some() && self.types.is_some() && self.rows.len() >= self.max_rows {
- self.more = true;
- return Ok(());
+ if self.names.is_some() && self.types.is_some() {
+ if self.skip > 0 {
+ self.skip -= 1;
+ return Ok(());
+ }
+ if self.rows.len() >= self.max_rows {
+ self.more = true;
+ return Ok(());
+ }
}
let Ok(values) = serde_json::from_slice::>>(text) else {
self.exception = Some(format!("{}\n", String::from_utf8_lossy(line)));
@@ -547,7 +576,11 @@ mod tests {
}
fn decode(body: &[u8], max_rows: usize, chunk: usize) -> IdeResult {
- let mut reader = RowReader::new(max_rows);
+ decode_from(body, 0, max_rows, chunk)
+ }
+
+ fn decode_from(body: &[u8], skip: usize, max_rows: usize, chunk: usize) -> IdeResult {
+ let mut reader = RowReader::new(skip, max_rows);
for part in body.chunks(chunk) {
reader.push(part)?;
}
@@ -586,6 +619,18 @@ mod tests {
assert!(decode(b"[\"x\"]\n[\"UInt8\"]\n[1, 2]\n", 1, 64).is_err());
}
+ #[test]
+ fn a_later_page_skips_rows_without_keeping_them() {
+ let body = b"[\"n\"]\n[\"UInt8\"]\n[1]\n[2]\n[3]\n[4]\n[5]\n";
+ let page = |skip, max| {
+ let (_, rows, more) = decode_from(body, skip, max, 3).unwrap();
+ (rows.into_iter().map(|r| r[0].clone().unwrap()).collect::>(), more)
+ };
+ assert_eq!(page(2, 2), (vec!["3".to_owned(), "4".to_owned()], true));
+ assert_eq!(page(4, 2), (vec!["5".to_owned()], false));
+ assert_eq!(page(9, 2), (Vec::::new(), false));
+ }
+
#[test]
fn rows_past_the_limit_are_skipped_but_a_later_exception_still_fails() {
let mut body = String::from("[\"n\"]\n[\"UInt64\"]\n");
diff --git a/crates/ide-db/src/conn.rs b/crates/ide-db/src/conn.rs
index 94bd01348..020dc0c6c 100644
--- a/crates/ide-db/src/conn.rs
+++ b/crates/ide-db/src/conn.rs
@@ -337,10 +337,13 @@ fn result_columns(row: &R) -> Vec {
.collect()
}
-/// Streams the results of `$sql` on executor `$exec`, keeping at most `$max` rows
-/// with their cells read as `$mode`.
+/// Streams the results of `$sql` on executor `$exec`, reading past the first `$skip`
+/// rows without decoding them, then keeping at most `$max` rows with their cells read as `$mode`.
macro_rules! fetch {
- ($exec:expr, $sql:expr, $max:expr, $mode:expr) => {{
+ ($exec:expr, $sql:expr, $max:expr, $mode:expr) => {
+ fetch!($exec, $sql, 0usize, $max, $mode)
+ };
+ ($exec:expr, $sql:expr, $skip:expr, $max:expr, $mode:expr) => {{
use futures::StreamExt;
let mut stream = sqlx::raw_sql(sqlx::AssertSqlSafe($sql.to_owned())).fetch_many($exec);
let mut columns: Vec = Vec::new();
@@ -349,6 +352,7 @@ macro_rules! fetch {
let mut has_more = false;
let mut failure = None;
let mut first: Option = None;
+ let mut to_skip: usize = $skip;
while let Some(item) = stream.next().await {
first.get_or_insert_with(Instant::now);
match item {
@@ -357,6 +361,10 @@ macro_rules! fetch {
if columns.is_empty() {
columns = result_columns(&row);
}
+ if to_skip > 0 {
+ to_skip -= 1;
+ continue;
+ }
if rows.len() >= $max {
has_more = true;
break;
@@ -386,7 +394,8 @@ async fn mssql_command(conn: &mut MssqlClient, sql: &str) -> IdeResult<()> {
Ok(())
}
-async fn mssql_fetch(conn: &mut MssqlClient, sql: &str, max: usize) -> IdeResult {
+/// Like [`fetch!`] for SQL Server: the first `skip` rows are read past without decoding.
+async fn mssql_fetch(conn: &mut MssqlClient, sql: &str, skip: usize, max: usize) -> IdeResult {
let mut stream = conn.simple_query(sql).await.map_err(mssql_err)?;
let first = Some(Instant::now());
let columns = stream
@@ -399,8 +408,13 @@ async fn mssql_fetch(conn: &mut MssqlClient, sql: &str, max: usize) -> IdeResult
.collect();
let mut rows = Vec::new();
let mut has_more = false;
+ let mut to_skip = skip;
while let Some(item) = stream.next().await {
if let tiberius::QueryItem::Row(row) = item.map_err(mssql_err)? {
+ if to_skip > 0 {
+ to_skip -= 1;
+ continue;
+ }
if rows.len() >= max {
has_more = true;
break;
@@ -734,7 +748,7 @@ impl Pool {
let fetched: IdeResult = match self {
Pool::SqlServer(p) => {
let mut conn = p.get_owned().await.map_err(mssql_pool_err)?;
- mssql_fetch(&mut conn, sql, max).await
+ mssql_fetch(&mut conn, sql, 0, max).await
}
_ => with_pool!(self, p => fetch!(p, sql, max, Cells::Text)),
};
@@ -744,11 +758,23 @@ impl Pool {
/// Runs one statement and keeps at most `max_rows` rows. Cancelling stops
/// the statement on the server (PostgreSQL, MySQL) and drops the connection.
pub async fn execute(&self, sql: &str, max_rows: usize, cancel: &CancellationToken) -> IdeResult {
+ self.execute_from(sql, 0, max_rows, cancel).await
+ }
+
+ /// [`execute`](Self::execute), reading past the first `skip` rows without keeping them
+ /// (a later page of a result: only that page is decoded and returned).
+ pub async fn execute_from(
+ &self,
+ sql: &str,
+ skip: usize,
+ max_rows: usize,
+ cancel: &CancellationToken,
+ ) -> IdeResult {
if let Pool::Clickhouse(p) = self {
- return p.execute(sql, max_rows, cancel).await;
+ return p.execute_from(sql, skip, max_rows, cancel).await;
}
let mut conn = Held::acquire(self).await?;
- let r = conn.execute(self, sql, max_rows, cancel).await;
+ let r = conn.execute_from(self, sql, skip, max_rows, cancel).await;
if cancel.is_cancelled() {
conn.discard().await;
return Err(IdeError::cancelled());
@@ -874,9 +900,22 @@ impl Held {
sql: &str,
max_rows: usize,
cancel: &CancellationToken,
+ ) -> IdeResult {
+ self.execute_from(pool, sql, 0, max_rows, cancel).await
+ }
+
+ /// [`execute`](Self::execute), reading past the first `skip` rows without keeping them
+ /// (see [`Pool::execute_from`]).
+ pub async fn execute_from(
+ &mut self,
+ pool: &Pool,
+ sql: &str,
+ skip: usize,
+ max_rows: usize,
+ cancel: &CancellationToken,
) -> IdeResult {
if let Held::Clickhouse(c) = self {
- return c.execute(sql, max_rows, cancel).await;
+ return c.execute_from(sql, skip, max_rows, cancel).await;
}
let started = Instant::now();
// `$id`: the query naming the server process, `$kill`: how another connection stops it.
@@ -887,7 +926,7 @@ impl Held {
None => None,
};
let r = tokio::select! {
- r = async { fetch!(&mut **$conn, sql, max_rows, Cells::Display) } => r,
+ r = async { fetch!(&mut **$conn, sql, skip, max_rows, Cells::Display) } => r,
() = cancel.cancelled() => Err(IdeError::cancelled()),
};
if cancel.is_cancelled() {
@@ -916,7 +955,7 @@ impl Held {
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?,
+ r = mssql_fetch(c, sql, skip, max_rows) => r?,
() = cancel.cancelled() => return Err(IdeError::cancelled()),
}
}
diff --git a/crates/ide-db/src/lib.rs b/crates/ide-db/src/lib.rs
index 71545ecbb..b549395d2 100644
--- a/crates/ide-db/src/lib.rs
+++ b/crates/ide-db/src/lib.rs
@@ -1009,7 +1009,20 @@ impl DbManager {
max_rows: usize,
cancel: &CancellationToken,
) -> IdeResult {
- let r = self.run(id, statement, max_rows, cancel).await;
+ self.execute_from(id, statement, 0, max_rows, cancel).await
+ }
+
+ /// [`execute`](Self::execute), reading past the first `skip` rows without keeping
+ /// them: a later page of a result, where only that page is decoded and returned.
+ pub async fn execute_from(
+ &self,
+ id: &str,
+ statement: &str,
+ skip: usize,
+ max_rows: usize,
+ cancel: &CancellationToken,
+ ) -> IdeResult {
+ let r = self.run(id, statement, skip, max_rows, cancel).await;
let ran = match &r {
Ok(_) => true,
Err(e) => !matches!(e.kind, ErrorKind::Cancelled | ErrorKind::PermissionDenied),
@@ -1044,7 +1057,7 @@ impl DbManager {
/// The execution plan of `statement` (explained, not run).
pub async fn explain(&self, id: &str, statement: &str, cancel: &CancellationToken) -> IdeResult> {
let driver = self.pool(id).await?.driver();
- let result = self.run(id, &plan::explain_statement(driver, statement), MAX_META_ROWS, cancel).await?;
+ let result = self.run(id, &plan::explain_statement(driver, statement), 0, MAX_META_ROWS, cancel).await?;
plan::parse(driver, &result)
}
@@ -1052,13 +1065,14 @@ impl DbManager {
&self,
id: &str,
statement: &str,
+ skip: usize,
max_rows: usize,
cancel: &CancellationToken,
) -> IdeResult {
let pool = self.pool(id).await?;
let session = self.sessions.lock().get(id).cloned();
let Some(session) = session else {
- return pool.execute(statement, max_rows, cancel).await;
+ return pool.execute_from(statement, skip, max_rows, cancel).await;
};
if pool.driver() == DbDriver::Clickhouse {
return Err(IdeError::invalid("ClickHouse manual transactions are unsupported"));
@@ -1070,7 +1084,7 @@ impl DbManager {
*held = Some(conn);
}
let conn = held.as_mut().expect("opened above");
- let r = conn.execute(&pool, statement, max_rows, cancel).await;
+ let r = conn.execute_from(&pool, statement, skip, max_rows, cancel).await;
if cancel.is_cancelled() {
// The server rolls the transaction back with the connection.
held.take().expect("opened above").discard().await;
diff --git a/crates/ide-db/tests/sqlite.rs b/crates/ide-db/tests/sqlite.rs
index b800cd154..06bf741a0 100644
--- a/crates/ide-db/tests/sqlite.rs
+++ b/crates/ide-db/tests/sqlite.rs
@@ -1,7 +1,9 @@
//! Introspection, queries and table edits against a real SQLite file.
use ide_core::{CancellationToken, IdeResult};
-use ide_db::{CellValue, DataSource, DbDriver, DbManager, DbOptions, DbProperty, DbRowEdit, DbTableKind, SortKey};
+use ide_db::{
+ CellValue, DataSource, DbDriver, DbManager, DbOptions, DbProperty, DbRowEdit, DbTableKind, QueryResult, SortKey,
+};
async fn setup() -> (tempfile::TempDir, DbManager) {
let tmp = tempfile::tempdir().unwrap();
@@ -123,6 +125,28 @@ async fn queries_with_limits_nulls_and_blobs() -> IdeResult<()> {
Ok(())
}
+#[tokio::test]
+async fn a_later_page_returns_only_its_rows() -> IdeResult<()> {
+ let (_tmp, db) = setup().await;
+ let c = CancellationToken::new();
+ let sql = "SELECT id FROM users ORDER BY id";
+ let ids = |r: &QueryResult| r.rows.iter().map(|row| row[0].clone().unwrap()).collect::>();
+
+ let second = db.execute_from("s1", sql, 1, 1, &c).await?;
+ assert_eq!(ids(&second), vec!["2"]);
+ assert!(second.has_more);
+ assert_eq!(second.columns.len(), 1);
+
+ let last = db.execute_from("s1", sql, 2, 5, &c).await?;
+ assert_eq!(ids(&last), vec!["3"]);
+ assert!(!last.has_more);
+
+ let past = db.execute_from("s1", sql, 9, 5, &c).await?;
+ assert!(past.rows.is_empty() && !past.has_more);
+ assert_eq!(past.columns.len(), 1, "columns of a page past the end come from the prepared statement");
+ Ok(())
+}
+
#[tokio::test]
async fn pages_sorts_filters_and_edits_in_a_transaction() -> IdeResult<()> {
let (_tmp, db) = setup().await;
diff --git a/crates/ide-extension/src/capability.rs b/crates/ide-extension/src/capability.rs
index bf68d5a35..a02c7193f 100644
--- a/crates/ide-extension/src/capability.rs
+++ b/crates/ide-extension/src/capability.rs
@@ -79,7 +79,9 @@ impl Capability {
Self::Secrets => "Store secrets in the system keychain".into(),
Self::Events => "Observe workbench events (files opened, saved, focus)".into(),
Self::InputEvents => "Observe keystrokes and editor input".into(),
- Self::Clipboard => "Read and write the clipboard from its web views".into(),
+ Self::Clipboard => {
+ "Copy to the clipboard from its commands, and read and write it from its web views".into()
+ }
Self::Net(host) => format!("Connect to {host} (from the machine the project is on)"),
Self::Process(bin) => format!("Run {bin} — equivalent to running arbitrary code on this machine"),
Self::FsRead(path) => format!("Read files under {path}, outside the project"),
diff --git a/crates/ide-extension/src/manifest.rs b/crates/ide-extension/src/manifest.rs
index d079cff2d..aaeeabf67 100644
--- a/crates/ide-extension/src/manifest.rs
+++ b/crates/ide-extension/src/manifest.rs
@@ -32,7 +32,7 @@ use std::path::{Component, Path, PathBuf};
pub const MANIFEST_FILE: &str = "plugin.toml";
/// The plugin API (engine) version this host implements.
-pub const ENGINE_VERSION: &str = "1.0.0";
+pub const ENGINE_VERSION: &str = "1.1.0";
/// The 0.1 API, still accepted for contribution-only packages and 0.1 components.
pub const LEGACY_API: &str = "0.1";
/// Marketplace categories, in the order they are shown.
diff --git a/crates/ide-host/src/lib.rs b/crates/ide-host/src/lib.rs
index 2685d7bf1..70fcfb9b4 100644
--- a/crates/ide-host/src/lib.rs
+++ b/crates/ide-host/src/lib.rs
@@ -86,6 +86,8 @@ pub struct Host {
project_requests: RwLock>>,
/// Shutdowns of projects closed with [`Host::close_project_detached`].
closing: parking_lot::Mutex>>,
+ /// One open at a time per window (see [`Host::opening`]).
+ open_locks: parking_lot::Mutex>>>,
}
/// How long exit waits for project shutdown before giving up.
@@ -135,6 +137,7 @@ impl Host {
requests: Default::default(),
project_requests: RwLock::default(),
closing: Default::default(),
+ open_locks: Default::default(),
};
host.apply_proxy();
host
@@ -401,6 +404,13 @@ impl Host {
self.requests.cancel(id);
}
+ /// The lock an open of `window` holds from its check for the current project to its
+ /// insert: two opens racing (a double click, a launch path and a restore) would each start
+ /// an index, language servers and watchers, and the second would replace the first unclosed.
+ pub(crate) fn opening(&self, window: &str) -> Arc> {
+ self.open_locks.lock().entry(window.to_owned()).or_default().clone()
+ }
+
pub(crate) fn insert_project(&self, window: &str, project: Arc) {
self.projects.write().insert(window.to_owned(), project);
}
diff --git a/crates/ide-host/src/notebook.rs b/crates/ide-host/src/notebook.rs
index fb5d65cb1..413bb3c92 100644
--- a/crates/ide-host/src/notebook.rs
+++ b/crates/ide-host/src/notebook.rs
@@ -1,7 +1,7 @@
//! 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_core::{CancellationToken, IdeError, IdeEvent, IdeResult, IpcPath, SharedSink};
use ide_notebook::kernel::{KernelManager, KernelOutput};
use ide_notebook::{NotebookDocument, NotebookEdit, NotebookSnapshot};
use parking_lot::Mutex;
@@ -17,7 +17,7 @@ use tokio::sync::{Mutex as AsyncMutex, watch};
#[serde(rename_all = "camelCase")]
pub struct NotebookOutputEvent {
pub id: String,
- pub path: String,
+ pub path: IpcPath,
pub cell: String,
pub msg_id: String,
pub kind: String,
@@ -536,7 +536,7 @@ fn commit_output(
};
Ok(Some(NotebookOutputEvent {
id: document.id.clone(),
- path: document.path.to_string_lossy().into_owned(),
+ path: document.path.clone().into(),
cell: cell.to_owned(),
msg_id: output.msg_id,
kind: output.kind,
@@ -592,6 +592,16 @@ mod tests {
other.shutdown().await;
}
+ #[tokio::test]
+ async fn the_notebook_path_reaches_the_ui_as_the_project_tree_names_it() {
+ let (dir, service, _) = fixture();
+ let ui_path =
+ format!("{}/session.ipynb", ide_core::ipc_path::to_ipc(&dunce::canonicalize(dir.path()).unwrap()));
+ let snapshot = service.open(Path::new(&ui_path)).unwrap();
+ assert_eq!(serde_json::to_value(&snapshot).unwrap()["path"], ui_path);
+ service.shutdown().await;
+ }
+
#[tokio::test(start_paused = true)]
async fn failed_close_keeps_the_incarnation_recoverable_until_a_retry_succeeds() {
let (dir, service, initial) = fixture();
diff --git a/crates/ide-host/src/ops/agent.rs b/crates/ide-host/src/ops/agent.rs
index 9662dc079..3a30a7b73 100644
--- a/crates/ide-host/src/ops/agent.rs
+++ b/crates/ide-host/src/ops/agent.rs
@@ -8,8 +8,11 @@ use ide_agent::{
AgentChange, AgentCommand, AgentEntry, AgentEvent, AgentProbe, AgentPrompt, AgentProvider, AgentQueued, AgentTask,
NewAgentTask,
};
-use ide_core::{IdeError, IdeResult, IpcPath, RequestId};
+use ide_core::error::ErrorKind;
+use ide_core::{IdeError, IdeEvent, IdeResult, IpcPath, RequestId, SharedSink, TaskId};
use ide_tools::InstallEvent;
+use std::collections::HashSet;
+use std::sync::{LazyLock, Mutex};
/// Streams the project's agent events to this window; returns every task.
pub fn subscribe(host: &Host, window: &str, on_event: Emit) -> IdeResult> {
@@ -58,7 +61,39 @@ pub async fn registry_list(host: &Host, _window: &str, request: RequestId) -> Id
let result =
tokio::task::spawn_blocking(move || ide_agent::registry::list(&paths, &cancel)).await.map_err(join_err);
host.end_request(request);
- result?
+ let mut registry = result??;
+ let installing = installing();
+ for agent in &mut registry.agents {
+ agent.installing = installing.contains(&agent.id);
+ }
+ Ok(registry)
+}
+
+/// Registry installs in progress, by agent id. An install outlives the Settings page
+/// that started it, so a second one of the same agent is refused rather than racing it
+/// for the same folder.
+static INSTALLING: LazyLock>> = LazyLock::new(Mutex::default);
+
+fn installing() -> std::sync::MutexGuard<'static, HashSet> {
+ INSTALLING.lock().unwrap_or_else(|e| e.into_inner())
+}
+
+/// Marks agent `id` as installing until dropped.
+struct Installing(String);
+
+impl Installing {
+ fn begin(id: &str) -> IdeResult {
+ if !installing().insert(id.to_owned()) {
+ return Err(IdeError::new(ErrorKind::AlreadyExists, format!("{id} is already being installed")));
+ }
+ Ok(Self(id.to_owned()))
+ }
+}
+
+impl Drop for Installing {
+ fn drop(&mut self) {
+ installing().remove(&self.0);
+ }
}
/// Installs the registry's current version of agent `id` (also to update it), streaming
@@ -72,11 +107,43 @@ pub async fn registry_install(
on_event: Channel,
) -> IdeResult<()> {
let paths = registry_paths(host);
+ let installing = Installing::begin(&id)?;
let cancel = host.begin_request(request);
+ // Also a background task of every project window, so it stays visible and can be
+ // stopped there once the Settings page is closed.
+ let task = TaskId::next();
+ let title = format!("Installing {id}");
+ let sinks: Vec = host.projects().iter().map(|p| p.sink.clone()).collect();
+ let report = move |message: Option, fraction: Option, done: bool| {
+ for sink in &sinks {
+ sink.emit(IdeEvent::Task {
+ id: task,
+ title: title.clone(),
+ message: message.clone(),
+ fraction,
+ done,
+ cancellable: !done,
+ });
+ }
+ };
let result = tokio::task::spawn_blocking(move || {
- ide_agent::registry::install(&paths, &id, &cancel, &|e| {
+ let _installing = installing;
+ let _cancel = ide_core::task::on_cancel(task, {
+ let cancel = cancel.clone();
+ move || cancel.cancel()
+ });
+ report(None, None, false);
+ let result = ide_agent::registry::install(&paths, &id, &cancel, &|e| {
+ match &e {
+ InstallEvent::Line { text } => report(Some(text.clone()), None, false),
+ InstallEvent::Progress { fraction, message } => {
+ report(Some(message.clone()), fraction.map(|f| f as f32), false)
+ }
+ }
let _ = on_event.send(e);
- })
+ });
+ report(None, Some(1.0), true);
+ result
})
.await
.map_err(join_err);
@@ -263,6 +330,20 @@ fn validate_project_root(root: &std::path::Path, requested: Option<&std::path::P
#[cfg(test)]
mod tests {
use super::*;
+
+ #[test]
+ fn an_agent_installs_once_at_a_time() {
+ let first = Installing::begin("test-agent").unwrap();
+ assert!(installing().contains("test-agent"));
+ let again = Installing::begin("test-agent").err().unwrap();
+ assert_eq!(again.kind, ErrorKind::AlreadyExists);
+ let other = Installing::begin("test-other-agent").unwrap();
+ drop(first);
+ assert!(!installing().contains("test-agent"));
+ drop(Installing::begin("test-agent").unwrap());
+ drop(other);
+ }
+
#[test]
fn agent_roots_are_exact_window_or_workspace_members() {
let tmp = tempfile::tempdir().unwrap();
diff --git a/crates/ide-host/src/ops/build.rs b/crates/ide-host/src/ops/build.rs
index 15337380c..074cbcad5 100644
--- a/crates/ide-host/src/ops/build.rs
+++ b/crates/ide-host/src/ops/build.rs
@@ -32,6 +32,7 @@ pub fn load(root: &Path, cache: &Path, settings: &Settings) -> BuildModel {
cache,
ide_sdk::apply::maven_local_repository(root, settings),
&settings.languages.java.maven,
+ &settings.languages.java.gradle,
);
let jdk = ide_sdk::apply::project_jdk(root, settings);
if let Some(m) = &mut model.maven {
@@ -45,7 +46,7 @@ pub fn load(root: &Path, cache: &Path, settings: &Settings) -> BuildModel {
p.options = options.clone();
}
}
- if let Some(g) = &mut model.gradle {
+ for g in &mut model.gradle {
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();
}
@@ -65,11 +66,12 @@ pub fn load(root: &Path, cache: &Path, settings: &Settings) -> BuildModel {
let hint = m.projects.iter().find_map(|p| missing(p.java_version));
explain(&mut m.sync_error, hint);
}
- if let Some(g) = &mut model.gradle
- && g.sync_error.is_some()
- {
- let hint = if settings.languages.java.gradle.jvm.trim().is_empty() { missing(g.java_version) } else { None };
- explain(&mut g.sync_error, hint);
+ for g in &mut model.gradle {
+ if g.sync_error.is_some() {
+ let hint =
+ if settings.languages.java.gradle.jvm.trim().is_empty() { missing(g.java_version) } else { None };
+ explain(&mut g.sync_error, hint);
+ }
}
model
}
@@ -94,9 +96,89 @@ pub async fn build_model(host: &Host, window: &str) -> IdeResult {
tokio::task::spawn_blocking(move || load(&root, &cache, &settings)).await.map_err(join_err)
}
+fn linked_gradle_paths(
+ root: &Path,
+ settings: &ide_core::settings::GradleSettings,
+ paths: &[String],
+ link: bool,
+) -> IdeResult> {
+ let existing = settings
+ .linked_projects
+ .clone()
+ .unwrap_or_else(|| ide_build::gradle::load_linked(root, settings).into_iter().map(|g| g.dir).collect());
+ let mut linked = Vec::new();
+ for path in existing {
+ let normalized = ide_build::gradle::normalize_linked_projects(root, std::slice::from_ref(&path))
+ .unwrap_or_else(|_| vec![path]);
+ for path in normalized {
+ if !linked.contains(&path) {
+ linked.push(path);
+ }
+ }
+ }
+ if link {
+ let directories: Vec = paths
+ .iter()
+ .map(|path| {
+ let path = root.join(path);
+ let is_script = path.file_name().and_then(|n| n.to_str()).is_some_and(|name| {
+ ide_build::gradle::SETTINGS_FILES.contains(&name) || ide_build::gradle::BUILD_FILES.contains(&name)
+ });
+ if is_script && path.is_file() {
+ path.parent().unwrap_or(root).to_string_lossy().into_owned()
+ } else {
+ path.to_string_lossy().into_owned()
+ }
+ })
+ .collect();
+ for path in ide_build::gradle::normalize_linked_projects(root, &directories)? {
+ if !linked.contains(&path) {
+ linked.push(path);
+ }
+ }
+ } else {
+ let unlinked: Vec = paths.iter().map(|p| ide_build::gradle::linked_project_key(root, p)).collect();
+ linked.retain(|path| !paths.contains(path) && !unlinked.contains(path));
+ }
+ Ok(linked)
+}
+
+async fn set_gradle_projects(host: &Host, window: &str, paths: Vec, link: bool) -> IdeResult {
+ host.require_trust(window)?;
+ let project = host.project(window)?;
+ tokio::task::spawn_blocking(move || {
+ if ide_workspace::workspace::Workspace::load(&project.root)?.is_some() {
+ return Err(IdeError::invalid("Open a workspace member as a project to link Gradle builds"));
+ }
+ let linked =
+ linked_gradle_paths(&project.root, &project.settings.effective().languages.java.gradle, &paths, link)?;
+ let key = "languages.java.gradle.linkedProjects";
+ let settings =
+ project.settings.set(ide_workspace::settings::SettingsScope::Project, key, serde_json::json!(linked))?;
+ project.refresh_index_scope();
+ project.lsp.reconfigure_soon();
+ project.sink.emit(IdeEvent::SettingsChanged { key: key.into() });
+ project.sink.emit(IdeEvent::ProjectRootsChanged { root: project.root.clone().into() });
+ Ok::(load(&project.root, &project.build_cache_dir(), &settings))
+ })
+ .await
+ .map_err(join_err)?
+}
+
+/// Links independent builds without changing their Gradle scripts.
+pub async fn gradle_link_projects(host: &Host, window: &str, paths: Vec) -> IdeResult {
+ set_gradle_projects(host, window, paths, true).await
+}
+
+/// Stops importing the selected independent builds; files and saved run configurations stay intact.
+pub async fn gradle_unlink_projects(host: &Host, window: &str, paths: Vec) -> IdeResult {
+ set_gradle_projects(host, window, paths, false).await
+}
+
/// Runs Maven (`help:effective-pom dependency:tree`) or Gradle (`tasks --all`) in the
/// background, reporting progress on `on_progress`; cancellable with `cancel_request(request)`.
/// Returns the refreshed model; a failed build is reported in its `syncError`.
+#[allow(clippy::too_many_arguments)]
pub async fn build_sync(
host: &Host,
window: &str,
@@ -104,6 +186,7 @@ pub async fn build_sync(
request: RequestId,
offline: bool,
profiles: Vec,
+ build_dir: Option,
on_progress: Channel,
) -> IdeResult {
host.require_trust(window)?;
@@ -122,6 +205,8 @@ pub async fn build_sync(
project.sink.emit(ide_core::IdeEvent::ProjectRootsChanged { root: project.root.clone().into() });
}
let token = host.begin_project_request(window, request);
+ let completion_token = token.clone();
+ let original_links = settings.languages.java.gradle.linked_projects.clone();
let result = tokio::task::spawn_blocking(move || {
let current = load(&root, &cache, &settings);
let env = match tool {
@@ -131,7 +216,7 @@ pub async fn build_sync(
.and_then(|m| m.projects.iter().find(|p| !p.env.is_empty()))
.map(|p| p.env.clone())
.unwrap_or_default(),
- BuildTool::Gradle => current.gradle.as_ref().map(|g| g.env.clone()).unwrap_or_default(),
+ BuildTool::Gradle => BTreeMap::new(),
};
let options = SyncOptions {
offline,
@@ -140,14 +225,31 @@ pub async fn build_sync(
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()),
+ gradle_command: None,
};
let progress = |p: SyncProgress| {
let _ = on_progress.send(p);
};
match tool {
BuildTool::Maven => ide_build::sync_maven(&root, &cache, &options, &token, &progress)?,
- BuildTool::Gradle => ide_build::sync_gradle(&root, &cache, &options, &token, &progress)?,
+ BuildTool::Gradle => {
+ if let Some(dir) = &build_dir
+ && !current.gradle.iter().any(|g| &g.dir == dir)
+ {
+ return Err(IdeError::not_found("Gradle project is no longer linked"));
+ }
+ for g in current.gradle.iter().filter(|g| build_dir.as_ref().is_none_or(|dir| dir == &g.dir)) {
+ if token.is_cancelled() {
+ return Err(IdeError::cancelled());
+ }
+ let options =
+ SyncOptions { env: g.env.clone(), gradle_command: Some(g.command.clone()), ..options.clone() };
+ ide_build::sync_gradle_model(&root, &cache, g, &options, &token, &progress)?;
+ }
+ if token.is_cancelled() {
+ return Err(IdeError::cancelled());
+ }
+ }
}
Ok::(load(&root, &cache, &settings))
})
@@ -155,6 +257,18 @@ pub async fn build_sync(
.map_err(join_err);
host.end_request(request);
let model = result??;
+ if completion_token.is_cancelled() {
+ return Err(IdeError::cancelled());
+ }
+ if tool == BuildTool::Gradle && original_links != project.settings.effective().languages.java.gradle.linked_projects
+ {
+ // A late sync must not re-import a build that was unlinked while it ran.
+ return tokio::task::spawn_blocking(move || {
+ load(&project.root, &project.build_cache_dir(), &project.settings.effective())
+ })
+ .await
+ .map_err(join_err);
+ }
if tool == BuildTool::Gradle {
// The sync may report project folders outside the project (`includeFlat`): index and list them.
project.refresh_index_scope();
@@ -247,20 +361,26 @@ pub async fn gradle_dependencies(
configuration: String,
offline: bool,
request: RequestId,
+ build_dir: Option,
) -> IdeResult> {
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_project_request(window, request);
let result = tokio::task::spawn_blocking(move || {
- let gradle = load(&root, &cache, &settings).gradle;
+ let model = load(&root, &cache, &settings);
+ let gradle = model
+ .gradle
+ .iter()
+ .find(|g| build_dir.as_ref().is_none_or(|dir| &g.dir == dir))
+ .ok_or_else(|| IdeError::not_found("Gradle project is no longer linked"))?;
let options = SyncOptions {
offline,
- env: gradle.as_ref().map(|g| g.env.clone()).unwrap_or_default(),
- gradle_command: gradle.as_ref().map(|g| g.command.clone()),
+ env: gradle.env.clone(),
+ gradle_command: Some(gradle.command.clone()),
..Default::default()
};
- ide_build::gradle_dependencies(&root, &options, &gradle_path, &configuration, &token)
+ ide_build::gradle_dependencies(&root, gradle, &options, &gradle_path, &configuration, &token)
})
.await
.map_err(join_err);
@@ -382,6 +502,11 @@ pub async fn build_run(
/// Commands of this module the remote server answers.
pub(crate) fn register(d: &mut Dispatcher) {
d.add("build_model", |c| async move { build_model(&c.host, &c.window).await });
+ d.add("gradle_link_projects", |c| async move { gradle_link_projects(&c.host, &c.window, c.arg("paths")?).await });
+ d.add(
+ "gradle_unlink_projects",
+ |c| async move { gradle_unlink_projects(&c.host, &c.window, c.arg("paths")?).await },
+ );
d.add("build_sync", |c| async move {
build_sync(
&c.host,
@@ -390,6 +515,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
c.arg("request")?,
c.arg("offline")?,
c.arg("profiles")?,
+ c.arg("buildDir")?,
c.sender("onProgress")?,
)
.await
@@ -405,6 +531,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
c.arg("configuration")?,
c.arg("offline")?,
c.arg("request")?,
+ c.arg("buildDir")?,
)
.await
});
@@ -432,6 +559,48 @@ pub(crate) fn register(d: &mut Dispatcher) {
mod gradle_settings_tests {
use super::*;
+ #[test]
+ fn linking_retains_the_implicit_build_and_unlinking_all_stays_empty() {
+ let tmp = tempfile::tempdir().unwrap();
+ let root = dunce::canonicalize(tmp.path()).unwrap();
+ for name in ["a", "b"] {
+ std::fs::create_dir(root.join(name)).unwrap();
+ std::fs::write(root.join(name).join("settings.gradle"), format!("rootProject.name = '{name}'")).unwrap();
+ }
+ let mut settings = ide_core::settings::GradleSettings::default();
+ let linked = linked_gradle_paths(&root, &settings, &["b/settings.gradle".into()], true).unwrap();
+ assert_eq!(linked, ["a", "b"]);
+ settings.linked_projects = Some(linked);
+ assert_eq!(linked_gradle_paths(&root, &settings, &["b".into()], true).unwrap(), ["a", "b"]);
+ assert!(linked_gradle_paths(&root, &settings, &["missing".into()], true).is_err());
+ settings.linked_projects = Some(vec!["./a".into(), root.join("b").to_string_lossy().into_owned()]);
+ settings.linked_projects = Some(linked_gradle_paths(&root, &settings, &["a".into()], false).unwrap());
+ assert_eq!(settings.linked_projects.as_ref().unwrap(), &["b"]);
+ settings.linked_projects = Some(linked_gradle_paths(&root, &settings, &["b".into()], false).unwrap());
+ assert!(ide_build::gradle::load_linked(&root, &settings).is_empty());
+ assert!(root.join("a/settings.gradle").is_file());
+ assert!(root.join("b/settings.gradle").is_file());
+ }
+
+ #[test]
+ fn settings_resolve_each_linked_builds_own_wrapper() {
+ let tmp = tempfile::tempdir().unwrap();
+ let root = dunce::canonicalize(tmp.path()).unwrap();
+ let mut settings = Settings::default();
+ settings.languages.java.gradle.linked_projects = Some(vec!["a".into(), "b".into()]);
+ let executable = if cfg!(windows) { "gradlew.bat" } else { "gradlew" };
+ for dir in ["a", "b"] {
+ std::fs::create_dir(root.join(dir)).unwrap();
+ std::fs::write(root.join(dir).join("build.gradle"), "plugins { id 'java' }").unwrap();
+ std::fs::write(root.join(dir).join(executable), "").unwrap();
+ }
+ let model = load(&root, &root.join("cache"), &settings);
+ assert_eq!(model.gradle.len(), 2);
+ for g in model.gradle {
+ assert_eq!(g.command, root.join(&g.dir).join(executable).to_string_lossy());
+ }
+ }
+
#[test]
fn model_for_sync_tasks_and_compile_uses_gradle_settings() {
let tmp = tempfile::tempdir().unwrap();
@@ -440,13 +609,13 @@ mod gradle_settings_tests {
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();
+ let default = load(root, &root.join("cache"), &settings).gradle.remove(0);
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();
+ let model = load(root, &root.join("cache"), &settings).gradle.remove(0);
assert_eq!(
model.command,
root.join("gradle-install")
diff --git a/crates/ide-host/src/ops/db.rs b/crates/ide-host/src/ops/db.rs
index 102bf2742..079542f26 100644
--- a/crates/ide-host/src/ops/db.rs
+++ b/crates/ide-host/src/ops/db.rs
@@ -123,13 +123,14 @@ pub(crate) fn register(d: &mut Dispatcher) {
statements(&c.host, &c.window, &id, &text, from, to)
});
d.add("db_execute", |c| async move {
- let (request, id, statements, max_rows) = (
+ let (request, id, statements, skip, max_rows) = (
c.arg::("request")?,
c.arg::("id")?,
c.arg::>("statements")?,
+ c.arg::>("skip")?.unwrap_or(0),
c.arg::("maxRows")?,
);
- execute(&c.host, &c.window, request, &id, statements, max_rows).await
+ execute(&c.host, &c.window, request, &id, statements, skip, max_rows).await
});
d.add("db_table_page", |c| async move {
let (request, id, schema, table) = (
@@ -325,20 +326,22 @@ pub fn statements(host: &Host, window: &str, id: &str, text: &str, from: u32, to
}
/// Runs `statements` in order on data source `id`, stopping at the first
-/// error. Cancel with `cancel_request(request)`.
+/// error. Each result reads past its first `skip` rows (a later page) and keeps
+/// `max_rows`. Cancel with `cancel_request(request)`.
pub async fn execute(
host: &Host,
window: &str,
request: RequestId,
id: &str,
statements: Vec,
+ skip: u32,
max_rows: u32,
) -> IdeResult> {
let db = host.project(window)?.db.clone();
let cancel = host.begin_request(request);
let mut out = Vec::new();
for statement in statements {
- match db.execute(id, &statement.text, max_rows.max(1) as usize, &cancel).await {
+ match db.execute_from(id, &statement.text, skip as usize, max_rows.max(1) as usize, &cancel).await {
Ok(result) => out.push(StatementOutcome { statement, result: Some(result), error: None }),
Err(e) if e.kind == ide_core::error::ErrorKind::Cancelled => {
host.end_request(request);
diff --git a/crates/ide-host/src/ops/debug.rs b/crates/ide-host/src/ops/debug.rs
index 0d17a593b..827223544 100644
--- a/crates/ide-host/src/ops/debug.rs
+++ b/crates/ide-host/src/ops/debug.rs
@@ -162,6 +162,14 @@ pub async fn debug_pause(host: &Host, _window: &str, session: DebugSessionId, th
host.debugger.pause(session, thread_id).await
}
+pub async fn debug_hot_reload(host: &Host, _window: &str, session: DebugSessionId) -> IdeResult<()> {
+ host.debugger.hot_reload(session).await
+}
+
+pub async fn debug_hot_restart(host: &Host, _window: &str, session: DebugSessionId) -> IdeResult<()> {
+ host.debugger.hot_restart(session).await
+}
+
pub async fn debug_step(
host: &Host,
_window: &str,
diff --git a/crates/ide-host/src/ops/project.rs b/crates/ide-host/src/ops/project.rs
index 459a96d51..8d71234c8 100644
--- a/crates/ide-host/src/ops/project.rs
+++ b/crates/ide-host/src/ops/project.rs
@@ -196,7 +196,9 @@ pub fn set_settings(
}
let roots_changed = key == "projectStructure"
|| key.starts_with("projectStructure.")
- || "languages.java.maven.unlinkedProjects".starts_with(key);
+ || "languages.java.maven.unlinkedProjects".starts_with(key)
+ || "languages.java.maven.linkedProjects".starts_with(key)
+ || "languages.java.gradle.linkedProjects".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.") {
diff --git a/crates/ide-host/src/ops/routes.rs b/crates/ide-host/src/ops/routes.rs
index cbf2965e0..3d6f2e1ce 100644
--- a/crates/ide-host/src/ops/routes.rs
+++ b/crates/ide-host/src/ops/routes.rs
@@ -20,7 +20,7 @@ const MAX_FILE_BYTES: u64 = 4 << 20;
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct RouteSource {
- pub path: String,
+ pub path: ide_core::IpcPath,
pub text: String,
}
@@ -51,7 +51,7 @@ pub(crate) fn sources_in(root: &Path, excludes: &[String]) -> Vec {
return;
}
bytes.fetch_add(text.len(), Ordering::Relaxed);
- found.push(RouteSource { path: path.to_string_lossy().into_owned(), text });
+ found.push(RouteSource { path: path.to_path_buf().into(), text });
});
let mut files = found.into_inner().unwrap_or_default();
files.sort_by(|a, b| a.path.cmp(&b.path));
@@ -116,7 +116,7 @@ mod tests {
std::fs::write(tmp.path().join("src/Notes.txt"), "@GetMapping(\"/a\")").unwrap();
let files = sources_in(tmp.path(), &[]);
assert_eq!(files.len(), 1, "{:?}", files.iter().map(|f| &f.path).collect::>());
- assert!(Path::new(&files[0].path).ends_with("src/Ctl.java"), "{}", files[0].path);
+ assert!(files[0].path.ends_with("src/Ctl.java"), "{:?}", files[0].path);
assert!(files[0].text.contains("@GetMapping"));
}
@@ -129,6 +129,6 @@ mod tests {
std::fs::write(tmp.path().join("build/B.java"), "@RequestMapping(\"/b\")").unwrap();
let files = sources_in(tmp.path(), &["build/**".to_owned()]);
assert_eq!(files.len(), 1, "{:?}", files.iter().map(|f| &f.path).collect::>());
- assert!(Path::new(&files[0].path).ends_with("module/src/A.java"), "{}", files[0].path);
+ assert!(files[0].path.ends_with("module/src/A.java"), "{:?}", files[0].path);
}
}
diff --git a/crates/ide-host/src/ops/sdk.rs b/crates/ide-host/src/ops/sdk.rs
index 535426d8a..702c35c2d 100644
--- a/crates/ide-host/src/ops/sdk.rs
+++ b/crates/ide-host/src/ops/sdk.rs
@@ -23,8 +23,26 @@ pub struct LocatedTools {
/// Installed interpreters / toolchains of `kind`; project-local ones first.
pub async fn sdk_detect(host: &Host, window: &str, kind: SdkKind) -> IdeResult> {
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)
+ let project = host.project(window).ok();
+ let root = project.as_ref().map(|p| p.root.clone());
+ // The interpreter the project uses is a choice even where detection doesn't look (a
+ // virtualenv in a subfolder, one picked by path), so it shows by name and version.
+ let current = match (&project, kind) {
+ (Some(p), SdkKind::Python) => ide_sdk::apply::python_interpreter(&p.root, &p.settings.effective()),
+ _ => None,
+ };
+ tokio::task::spawn_blocking(move || {
+ let mut sdks = ide_sdk::detect(kind, root.as_deref());
+ if let Some(path) = current
+ && !sdks.iter().any(|s| *s.path == *path)
+ && let Some(sdk) = ide_sdk::python_interpreter_sdk(&path, root.as_deref())
+ {
+ sdks.push(sdk);
+ }
+ sdks
+ })
+ .await
+ .map_err(join_err)
}
/// The interpreter the project uses: the configured one, else its virtualenv.
diff --git a/crates/ide-host/src/ops/vcs/commands.rs b/crates/ide-host/src/ops/vcs/commands.rs
index 4075dd9dc..9dc44f29a 100644
--- a/crates/ide-host/src/ops/vcs/commands.rs
+++ b/crates/ide-host/src/ops/vcs/commands.rs
@@ -405,28 +405,6 @@ pub async fn vcs_log_details_batch(
result
}
-pub async fn vcs_log_index(
- host: &Host,
- window: &str,
- repo: Option,
- request: Option,
-) -> IdeResult {
- let project = LogAccess::new(host, window, repo.as_deref());
- let cancel =
- request.map_or_else(ide_core::CancellationToken::new, |id| host.begin_project_read_request(window, id));
- let result = blocking(move || {
- project.with_log_repo_at(repo.as_deref(), |r| {
- r.enable_log_index(&project.cache);
- if request.is_some() { r.index_log(&cancel) } else { Ok(r.log_index_progress()) }
- })
- })
- .await;
- if let Some(request) = request {
- host.end_request(request);
- }
- result
-}
-
pub async fn vcs_revision_children(
host: &Host,
window: &str,
@@ -492,7 +470,7 @@ pub async fn vcs_add_to_ignore(
window: &str,
paths: Vec,
destination: IgnoreDestination,
-) -> IdeResult> {
+) -> IdeResult> {
host.require_trust(window)?;
let project = host.project(window)?;
blocking(move || {
@@ -509,7 +487,7 @@ 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(|files| files.into_iter().map(|p| p.to_string_lossy().into_owned()).collect())
+ result.map(|files| files.into_iter().map(Into::into).collect())
})
.await
}
@@ -1222,10 +1200,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("vcs_log_details_batch", |c| async move {
vcs_log_details_batch(&c.host, &c.window, c.arg("repo")?, c.arg("revisions")?, c.arg("request")?).await
});
- d.add(
- "vcs_log_index",
- |c| async move { vcs_log_index(&c.host, &c.window, c.arg("repo")?, c.arg("request")?).await },
- );
d.add("vcs_revision_children", |c| async move {
vcs_revision_children(&c.host, &c.window, c.arg("repo")?, c.arg("revision")?, c.arg("directory")?).await
});
diff --git a/crates/ide-host/src/plugins/api.rs b/crates/ide-host/src/plugins/api.rs
index 1b1c997eb..a09e61a1e 100644
--- a/crates/ide-host/src/plugins/api.rs
+++ b/crates/ide-host/src/plugins/api.rs
@@ -2,6 +2,7 @@
//! Capabilities were checked by the runtime before any of this runs.
use super::{PluginEvent, PluginEventSink};
+use ide_core::ipc_path::to_ipc;
use ide_core::{CancellationToken, TextEdit};
use ide_extension::Package;
use ide_language::ProgramPin;
@@ -324,6 +325,18 @@ impl HostApi for PluginApi {
.emit(client, PluginEvent::Notification { plugin_id: self.id().to_owned(), level, title, body });
}
+ fn copy_to_clipboard(&self, client: Option<&str>, text: String) -> Result<(), HostError> {
+ let client = from_window(client, "copy to the clipboard")?;
+ self.shared.sink.emit(Some(client), PluginEvent::CopyToClipboard { plugin_id: self.id().to_owned(), text });
+ Ok(())
+ }
+
+ fn open_url(&self, client: Option<&str>, url: String) -> Result<(), HostError> {
+ let client = from_window(client, "open a URL")?;
+ self.shared.sink.emit(Some(client), PluginEvent::OpenUrl { plugin_id: self.id().to_owned(), url });
+ Ok(())
+ }
+
fn status_item(&self, id: &str, item: Option) {
let key = (self.id().to_owned(), id.to_owned());
{
@@ -407,7 +420,7 @@ impl HostApi for PluginApi {
let info = repo.branch_info().map_err(failed)?;
let changed = repo.status().map_err(failed)?.len();
Ok(Some(RepositoryStatus {
- root: repo.root().to_string_lossy().into_owned(),
+ root: to_ipc(repo.root()),
branch: info.head,
commit: info.head_commit,
upstream: info.upstream,
@@ -418,6 +431,12 @@ impl HostApi for PluginApi {
}))
}
+ fn vcs_remotes(&self, root: &Path) -> Result, HostError> {
+ let Some(repo) = ide_vcs::Repo::discover(root).map_err(failed)? else { return Ok(Vec::new()) };
+ let remotes = repo.remotes().map_err(failed)?;
+ Ok(remotes.into_iter().map(|r| (r.name, without_credentials(&r.url))).collect())
+ }
+
fn vcs_fetch(&self, root: &Path, cancel: &CancellationToken) -> Result<(), HostError> {
let workspace = self.shared.workspace();
if !workspace.is_some_and(|w| w.trusted(root)) {
@@ -449,7 +468,7 @@ impl HostApi for PluginApi {
}
let Ok(rel) = entry.path().strip_prefix(root) else { continue };
if matcher.is_match(rel) {
- out.push(entry.path().to_string_lossy().into_owned());
+ out.push(to_ipc(entry.path()));
}
}
out.sort();
@@ -788,6 +807,19 @@ mod tests {
PluginApi { pkg: Package::new(manifest, dir.to_path_buf()), shared }
}
+ #[test]
+ fn repository_and_search_paths_match_ipc_events() {
+ let tmp = tempfile::tempdir().unwrap();
+ assert!(std::process::Command::new("git").args(["init", "-q"]).arg(tmp.path()).status().unwrap().success());
+ let root = tmp.path().canonicalize().unwrap();
+ std::fs::create_dir(root.join("src")).unwrap();
+ let file = root.join("src/Example.java");
+ std::fs::write(&file, "class Example {}\n").unwrap();
+ let api = api(&root, Arc::default());
+ assert_eq!(api.find_files(&root, "**/*.java", 8).unwrap(), [to_ipc(&file)]);
+ assert_eq!(api.vcs_status(&root).unwrap().unwrap().root, to_ipc(&root));
+ }
+
#[test]
fn programs_run_only_as_approved() {
let tmp = tempfile::tempdir().unwrap();
@@ -812,3 +844,42 @@ mod tests {
);
}
}
+
+/// The window a user action invoked the plugin from: browser tabs and clipboard writes
+/// follow what the user did, never background work (which would reach every window).
+fn from_window<'a>(client: Option<&'a str>, what: &str) -> Result<&'a str, HostError> {
+ client.ok_or_else(|| HostError::Invalid(format!("can only {what} from a command the user invoked")))
+}
+
+/// `url` without the `user:password@` a remote may carry (`https://user:token@host/…`):
+/// `vcs.read` must not hand out credentials. scp-style `git@host:path` has no password.
+fn without_credentials(url: &str) -> String {
+ let Some((scheme, rest)) = url.split_once("://") else { return url.to_owned() };
+ let authority_end = rest.find('/').unwrap_or(rest.len());
+ match rest[..authority_end].rfind('@') {
+ Some(at) => format!("{scheme}://{}", &rest[at + 1..]),
+ None => url.to_owned(),
+ }
+}
+
+#[cfg(test)]
+mod credential_tests {
+ use super::*;
+
+ #[test]
+ fn remote_urls_lose_their_credentials() {
+ for (url, expected) in [
+ ("https://user:ghp_token@github.com/o/r.git", "https://github.com/o/r.git"),
+ ("https://token@gitlab.com/o/r", "https://gitlab.com/o/r"),
+ ("ssh://git@host:2222/o/r.git", "ssh://host:2222/o/r.git"),
+ ("https://github.com/o/r.git", "https://github.com/o/r.git"),
+ ("https://github.com/o/r@v1", "https://github.com/o/r@v1"),
+ ("git@github.com:o/r.git", "git@github.com:o/r.git"),
+ ("/srv/git/r.git", "/srv/git/r.git"),
+ ] {
+ assert_eq!(without_credentials(url), expected, "{url}");
+ }
+ assert!(matches!(from_window(None, "open a URL"), Err(HostError::Invalid(_))));
+ assert_eq!(from_window(Some("main"), "open a URL").unwrap(), "main");
+ }
+}
diff --git a/crates/ide-host/src/plugins/intellij.rs b/crates/ide-host/src/plugins/intellij.rs
index 3ccc62fa5..3fda40fac 100644
--- a/crates/ide-host/src/plugins/intellij.rs
+++ b/crates/ide-host/src/plugins/intellij.rs
@@ -422,7 +422,8 @@ struct IdeDir {
plugins: PathBuf,
}
-/// The settings folders of JetBrains IDEs and Android Studio (2020.1 and later), newest first.
+/// The settings folders of JetBrains IDEs and Android Studio (2020.1 and later), newest first, one
+/// per product: its newest version's (an upgrade leaves the older ones behind).
fn ide_dirs(layout: Layout, home: &Path, env: impl Fn(&str) -> Option) -> Vec {
let var = |key: &str| env(key).filter(|v| !v.is_empty()).map(PathBuf::from);
let (config_root, data_root) = match layout {
@@ -462,6 +463,8 @@ fn ide_dirs(layout: Layout, home: &Path, env: impl Fn(&str) -> Option)
}
}
out.sort_by(|a, b| b.version.cmp(&a.version).then_with(|| a.label.cmp(&b.label)));
+ let mut products = std::collections::HashSet::new();
+ out.retain(|ide| products.insert(ide.product.clone()));
out
}
@@ -784,6 +787,13 @@ mod tests {
};
let mac = mk(home.join("Library/Application Support/JetBrains/IntelliJIdea2025.2"));
mk(home.join("Library/Application Support/JetBrains/Toolbox"));
+ // An upgrade leaves the older version's folder behind: only the newest of a product counts.
+ mk(home.join("Library/Application Support/JetBrains/IntelliJIdea2024.3"));
+ mk(home.join("Library/Application Support/JetBrains/IntelliJIdea2025.10"));
+ let dirs = ide_dirs(Layout::Mac, home, |_| None);
+ assert_eq!(dirs.len(), 1);
+ assert_eq!(dirs[0].label, "IntelliJ IDEA 2025.10");
+ std::fs::remove_dir(home.join("Library/Application Support/JetBrains/IntelliJIdea2025.10")).unwrap();
let dirs = ide_dirs(Layout::Mac, home, |_| None);
assert_eq!(dirs.len(), 1);
assert_eq!(dirs[0].label, "IntelliJ IDEA 2025.2");
diff --git a/crates/ide-host/src/plugins/remote_host.rs b/crates/ide-host/src/plugins/remote_host.rs
index 9c8690239..7b845a84d 100644
--- a/crates/ide-host/src/plugins/remote_host.rs
+++ b/crates/ide-host/src/plugins/remote_host.rs
@@ -115,6 +115,8 @@ pub fn forwarded(event: &PluginEvent) -> bool {
| PluginEvent::ViewMessage { .. }
| PluginEvent::OpenTerminal { .. }
| PluginEvent::OpenLocation { .. }
+ | PluginEvent::CopyToClipboard { .. }
+ | PluginEvent::OpenUrl { .. }
)
}
diff --git a/crates/ide-host/src/plugins/types.rs b/crates/ide-host/src/plugins/types.rs
index bd5e197ce..53c737e54 100644
--- a/crates/ide-host/src/plugins/types.rs
+++ b/crates/ide-host/src/plugins/types.rs
@@ -197,6 +197,12 @@ pub enum PluginEvent {
/// Plugin languages, or the servers they may start, changed: re-fetch
/// `languages` (the host re-detects open documents: [`crate::Host::plugin_languages_changed`]).
LanguagesChanged,
+ /// Copy `text` to the system clipboard.
+ #[serde(rename_all = "camelCase")]
+ CopyToClipboard { plugin_id: String, text: String },
+ /// Open `url` (`http(s)://`) in the system browser.
+ #[serde(rename_all = "camelCase")]
+ OpenUrl { plugin_id: String, url: String },
}
/// Delivers events to the UI. `client` is the window that invoked the plugin
diff --git a/crates/ide-host/src/project.rs b/crates/ide-host/src/project.rs
index af9ebb4a4..0bdd984c9 100644
--- a/crates/ide-host/src/project.rs
+++ b/crates/ide-host/src/project.rs
@@ -77,6 +77,8 @@ impl Host {
lap = std::time::Instant::now();
};
let root = dunce::canonicalize(root)?;
+ let opening = self.opening(window);
+ let _opening = opening.lock();
// 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
@@ -230,6 +232,11 @@ impl Host {
if cfg.name == "jdtls" {
let (excluded_poms, build_files) = jdtls_maven_import(root, &effective);
ide_sdk::apply::configure_maven_import(cfg, root, &excluded_poms, build_files.as_deref());
+ ide_sdk::apply::configure_gradle_import(
+ cfg,
+ effective.languages.java.gradle.linked_projects.as_deref(),
+ build_files.as_deref(),
+ );
}
}));
}
@@ -564,6 +571,8 @@ impl Host {
return Err(IdeError::invalid(format!("{} is a folder", file.display())));
}
let dir = file.parent().map(Path::to_path_buf).ok_or_else(|| IdeError::invalid("no parent folder"))?;
+ let opening = self.opening(window);
+ let _opening = opening.lock();
if let Ok(current) = self.project(window)
&& current.light
{
@@ -876,8 +885,14 @@ impl Project {
/// Effective source / test / resource / excluded roots (detected + Project Structure marks).
pub fn roots(&self) -> ProjectRoots {
- let detected = with_gradle_roots(&self.root, &self.build_cache_dir(), &self.detected_roots.read());
- linked_roots(&self.root, &self.settings.effective(), &detected)
+ let settings = self.settings.effective();
+ let detected = with_gradle_roots(
+ &self.root,
+ &self.build_cache_dir(),
+ &settings.languages.java.gradle,
+ &self.detected_roots.read(),
+ );
+ linked_roots(&self.root, &settings, &detected)
}
/// Where the Maven / Gradle tool windows cache what the build tools reported.
@@ -1078,7 +1093,7 @@ fn content_roots(settings: &ide_core::settings::Settings, root: &Path, build_cac
}
let mut out: Vec = Vec::new();
let user = settings.project_structure.content_roots.iter().map(|r| PathBuf::from(r.trim()));
- for p in user.chain(ide_build::gradle_external_roots(root, build_cache)) {
+ for p in user.chain(ide_build::gradle_external_roots(root, build_cache, &settings.languages.java.gradle)) {
if p.is_absolute() && p.is_dir() && !p.starts_with(root) && !out.contains(&p) {
out.push(p);
}
@@ -1182,31 +1197,93 @@ pub(crate) fn jdtls_maven_import(
settings: &ide_core::settings::Settings,
) -> (Vec, Option>) {
let maven = &settings.languages.java.maven;
- if maven.unlinked_projects.is_empty() {
+ let gradle = &settings.languages.java.gradle;
+ if maven.unlinked_projects.is_empty() && gradle.linked_projects.is_none() {
return (Vec::new(), None);
}
- let model = ide_build::maven::load(root);
+ let model = ide_build::maven::load_linked(root, maven);
let unlinked = ide_build::maven::UnlinkedProjects::from_model(&model, maven);
let excluded = unlinked.poms().map(PathBuf::from).collect();
- let build_files = unlinked.import_poms(&model).map(|poms| {
- let gradle = ide_build::gradle::load(root).and_then(|g| {
+ let poms = unlinked.import_poms(&model).or_else(|| {
+ gradle.linked_projects.as_ref().map(|_| {
+ model
+ .projects
+ .iter()
+ .filter(|p| !p.aggregated && !unlinked.contains(&p.pom))
+ .map(|p| p.pom.clone())
+ .collect()
+ })
+ });
+ let build_files = poms.map(|poms| {
+ let gradle = ide_build::gradle::load_linked(root, gradle).into_iter().filter_map(|g| {
let dir = root.join(&g.dir);
let names = ide_build::gradle::SETTINGS_FILES.iter().chain(ide_build::gradle::BUILD_FILES);
names.map(|n| dir.join(n)).find(|f| f.is_file())
});
- poms.iter().map(|pom| root.join(pom)).chain(gradle).map(|f| ide_lsp::path_to_uri_string(&f)).collect()
+ let eclipse = if settings.languages.java.gradle.linked_projects.is_some() {
+ independent_eclipse_projects(root)
+ } else {
+ Vec::new()
+ };
+ poms.iter()
+ .map(|pom| root.join(pom))
+ .chain(gradle)
+ .chain(eclipse)
+ .map(|f| ide_lsp::path_to_uri_string(&f))
+ .collect()
});
(excluded, build_files)
}
+/// Explicit Gradle imports must retain Eclipse projects without re-importing generated build metadata.
+fn independent_eclipse_projects(root: &Path) -> Vec {
+ let managed = |dir: &Path| {
+ std::iter::once("pom.xml")
+ .chain(ide_build::gradle::SETTINGS_FILES.iter().copied())
+ .chain(ide_build::gradle::BUILD_FILES.iter().copied())
+ .any(|name| dir.join(name).is_file())
+ };
+ if managed(root) {
+ return Vec::new();
+ }
+ let walker = ignore::WalkBuilder::new(root)
+ .standard_filters(false)
+ .filter_entry(move |entry| {
+ if !entry.file_type().is_some_and(|kind| kind.is_dir()) {
+ return false;
+ }
+ if entry.depth() > 0
+ && (entry.file_name().to_string_lossy().starts_with('.')
+ || matches!(
+ entry.file_name().to_str(),
+ Some("node_modules" | "target" | "build" | "dist" | "out" | "bin" | "vendor")
+ )
+ || entry.path().parent().is_some_and(|parent| parent.join(".project").is_file()))
+ {
+ return false;
+ }
+ !managed(entry.path())
+ })
+ .build();
+ let mut projects: Vec<_> =
+ walker.flatten().map(|entry| entry.path().join(".project")).filter(|path| path.is_file()).collect();
+ projects.sort();
+ projects
+}
+
/// The detected roots with the last Gradle sync's source sets first: Gradle evaluated what
/// static detection guesses. Folders outside the project keep their absolute path, which
/// `normalize` turns into the same form the UI compares (`Users/me/svc/src/main/java`).
-fn with_gradle_roots(root: &Path, build_cache: &Path, detected: &[SourceFolder]) -> Vec {
+fn with_gradle_roots(
+ root: &Path,
+ build_cache: &Path,
+ settings: &ide_core::settings::GradleSettings,
+ detected: &[SourceFolder],
+) -> Vec {
use ide_build::gradle::ReportedRoot;
use ide_workspace::roots::RootKind;
let mut out: Vec = Vec::new();
- for (dir, kind) in ide_build::gradle_source_roots(build_cache) {
+ for (dir, kind) in ide_build::gradle_source_roots(root, build_cache, settings) {
if !dir.is_dir() {
continue;
}
@@ -1376,6 +1453,37 @@ pub fn apply_git_executable(settings: &ide_core::settings::Settings) {
#[cfg(test)]
mod maven_linkage_tests {
+ #[test]
+ fn explicit_gradle_links_preserve_independent_eclipse_projects() {
+ let tmp = tempfile::tempdir().unwrap();
+ let root = tmp.path();
+ for dir in
+ ["linked", "unlinked", "eclipse", "nested/eclipse", "unlinked/subproject", "maven", "build/generated"]
+ {
+ std::fs::create_dir_all(root.join(dir)).unwrap();
+ std::fs::write(root.join(dir).join(".project"), " ").unwrap();
+ }
+ for dir in ["linked", "unlinked"] {
+ std::fs::write(root.join(dir).join("settings.gradle"), "").unwrap();
+ }
+ std::fs::write(root.join("maven/pom.xml"), " ").unwrap();
+ let mut settings = ide_core::settings::Settings::default();
+ settings.languages.java.gradle.linked_projects = Some(vec!["linked".into()]);
+ let (_, files) = super::jdtls_maven_import(root, &settings);
+ let files = files.unwrap();
+ let uri = |path: &str| ide_lsp::path_to_uri_string(&root.join(path));
+ assert!(files.contains(&uri("linked/settings.gradle")));
+ assert!(files.contains(&uri("maven/pom.xml")));
+ assert!(files.contains(&uri("eclipse/.project")));
+ assert!(files.contains(&uri("nested/eclipse/.project")));
+ assert!(!files.iter().any(|path| path.contains("unlinked") || path.contains("build/generated")));
+ assert!(!files.contains(&uri("linked/.project")));
+ assert!(!files.contains(&uri("maven/.project")));
+ settings.languages.java.gradle.linked_projects = Some(vec![]);
+ let (_, files) = super::jdtls_maven_import(root, &settings);
+ assert_eq!(files.unwrap(), [uri("maven/pom.xml"), uri("eclipse/.project"), uri("nested/eclipse/.project")]);
+ }
+
#[test]
fn jdtls_imports_the_linked_modules_and_the_gradle_build_once_a_reactor_module_is_unlinked() {
let tmp = tempfile::tempdir().unwrap();
@@ -1473,7 +1581,8 @@ mod gradle_roots_tests {
std::fs::write(cache.join("gradle.json"), json.to_string()).unwrap();
let guessed = SourceFolder { path: "src/it/java".into(), kind: RootKind::Source, detected_from: None };
let other = SourceFolder { path: "web".into(), kind: RootKind::Source, detected_from: None };
- let out = with_gradle_roots(&root, &cache, &[guessed, other.clone()]);
+ std::fs::write(root.join("build.gradle"), "").unwrap();
+ let out = with_gradle_roots(&root, &cache, &Default::default(), &[guessed, other.clone()]);
let abs = |p: PathBuf| ide_workspace::roots::normalize(&s(p));
let got: Vec<(String, RootKind)> = out.iter().map(|f| (f.path.clone(), f.kind)).collect();
assert_eq!(
diff --git a/crates/ide-host/src/trust.rs b/crates/ide-host/src/trust.rs
index 2ddd6859d..ca3194b50 100644
--- a/crates/ide-host/src/trust.rs
+++ b/crates/ide-host/src/trust.rs
@@ -217,6 +217,8 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
| "lsp_resend_diagnostics"
| "recent_projects"
| "remove_recent_project"
+ | "recent_project_groups"
+ | "set_recent_project_groups"
| "open_project"
| "close_project"
| "project_current"
@@ -249,6 +251,8 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
| "remote_disconnect"
| "remote_connect"
| "remote_bootstrap_list"
+ | "wsl_distros"
+ | "wsl_resolve"
| "remote_list"
| "remote_open"
| "remote_of"
@@ -312,7 +316,6 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
| "vcs_log_users"
| "vcs_log_details"
| "vcs_log_details_batch"
- | "vcs_log_index"
| "vcs_revision_children"
| "vcs_revision_files"
| "vcs_open_external_log"
diff --git a/crates/ide-host/tests/gradle_linkage.rs b/crates/ide-host/tests/gradle_linkage.rs
new file mode 100644
index 000000000..d53c41f4a
--- /dev/null
+++ b/crates/ide-host/tests/gradle_linkage.rs
@@ -0,0 +1,49 @@
+use ide_core::NullSink;
+use ide_host::{Host, HostConfig, ops};
+use std::sync::Arc;
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn links_are_validated_persisted_and_unlinked_without_removing_files() {
+ let temp = tempfile::tempdir().unwrap();
+ let base = dunce::canonicalize(temp.path()).unwrap();
+ let root = base.join("workspace");
+ for dir in [root.join("a"), root.join("b"), base.join("external")] {
+ std::fs::create_dir_all(&dir).unwrap();
+ std::fs::write(dir.join("settings.gradle"), "rootProject.name = 'same-name'").unwrap();
+ }
+ let config = base.join("config");
+ let host = Host::new(HostConfig {
+ config_dir: config.clone(),
+ data_dir: base.join("data"),
+ local_data_dir: base.join("local"),
+ languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
+ syntax: Arc::default(),
+ sink_for: Box::new(|_| Arc::new(NullSink)),
+ owner: |window| window,
+ runtime: tokio::runtime::Handle::current(),
+ project_environment: false,
+ });
+ host.open_project("main", &root).unwrap();
+ assert!(ops::build::gradle_link_projects(&host, "main", vec!["b".into()]).await.is_err());
+ host.set_project_trust(&root, true, false).await.unwrap();
+ let model = ops::build::gradle_link_projects(&host, "main", vec!["b/settings.gradle".into()]).await.unwrap();
+ assert_eq!(model.gradle.iter().map(|g| g.dir.as_str()).collect::>(), ["a", "b"]);
+ let saved =
+ || ide_workspace::SettingsStore::load(&config, Some(&root)).effective().languages.java.gradle.linked_projects;
+ assert_eq!(saved(), Some(vec!["a".into(), "b".into()]));
+ assert!(ops::build::gradle_link_projects(&host, "main", vec!["missing".into()]).await.is_err());
+ assert_eq!(saved(), Some(vec!["a".into(), "b".into()]));
+ let outside = base.join("external").to_string_lossy().into_owned();
+ let model = ops::build::gradle_link_projects(&host, "main", vec![outside.clone()]).await.unwrap();
+ assert_eq!(model.gradle.len(), 3);
+ let model = ops::build::gradle_unlink_projects(&host, "main", vec!["a".into(), "b".into(), outside]).await.unwrap();
+ assert!(model.gradle.is_empty());
+ assert_eq!(saved(), Some(vec![]));
+ for dir in [root.join("a"), root.join("b"), base.join("external")] {
+ assert!(dir.join("settings.gradle").is_file());
+ }
+ let model = ops::build::gradle_link_projects(&host, "main", vec!["b".into()]).await.unwrap();
+ assert_eq!(model.gradle.len(), 1);
+ assert_eq!(model.gradle[0].dir, "b");
+ host.shutdown().await;
+}
diff --git a/crates/ide-host/tests/open_project.rs b/crates/ide-host/tests/open_project.rs
new file mode 100644
index 000000000..37ec7c417
--- /dev/null
+++ b/crates/ide-host/tests/open_project.rs
@@ -0,0 +1,46 @@
+//! Opening the project a window is already opening reuses it: racing opens (a double
+//! click, a launch path and a restore) must not start a second index and language servers.
+
+use ide_core::NullSink;
+use ide_host::{Host, HostConfig};
+use std::sync::{Arc, Barrier};
+
+#[test]
+fn racing_opens_of_one_window_share_one_project() {
+ let tmp = tempfile::tempdir().unwrap();
+ let root = dunce::canonicalize(tmp.path()).unwrap().join("project");
+ std::fs::create_dir_all(root.join("src")).unwrap();
+ std::fs::write(root.join("src/Main.java"), "class Main {}\n").unwrap();
+ let runtime = tokio::runtime::Builder::new_multi_thread().worker_threads(2).enable_all().build().unwrap();
+ let _guard = runtime.enter();
+ let host = Arc::new(Host::new(HostConfig {
+ config_dir: tmp.path().join("config"),
+ data_dir: tmp.path().join("data"),
+ local_data_dir: tmp.path().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: runtime.handle().clone(),
+ project_environment: false,
+ }));
+ host.trust.decide(&root, true, false).unwrap();
+
+ let start = Arc::new(Barrier::new(4));
+ let opens: Vec<_> = (0..4)
+ .map(|_| {
+ let (host, root, start) = (host.clone(), root.clone(), start.clone());
+ std::thread::spawn(move || {
+ start.wait();
+ host.open_project("main", &root).unwrap()
+ })
+ })
+ .collect();
+ let projects: Vec<_> = opens.into_iter().map(|t| t.join().unwrap()).collect();
+
+ let current = host.project("main").unwrap();
+ for project in &projects {
+ assert!(Arc::ptr_eq(project, ¤t), "every open returns the window's one project");
+ }
+ host.close_project("main");
+}
diff --git a/crates/ide-host/tests/plugins.rs b/crates/ide-host/tests/plugins.rs
index 299ece223..ee5db393a 100644
--- a/crates/ide-host/tests/plugins.rs
+++ b/crates/ide-host/tests/plugins.rs
@@ -6,6 +6,7 @@
//! `plugins/build.sh ` after changing their source or `wit/`.
use ide_core::error::ErrorKind;
+use ide_core::ipc_path::to_ipc;
use ide_host::plugins::*;
use ide_plugin::{EditorContext, HostError, Invocation, Limits};
use parking_lot::Mutex;
@@ -214,7 +215,7 @@ fn fixture(limits: Limits) -> Fixture {
std::fs::create_dir_all(project.join("src")).unwrap();
std::fs::write(project.join("src/notes.txt"), "one two three\nfour five\n").unwrap();
std::fs::write(tmp.path().join("secret.txt"), "top secret").unwrap();
- let project = project.canonicalize().unwrap();
+ let project = dunce::canonicalize(project).unwrap();
let events: Events = Arc::default();
let sink = {
let events = events.clone();
@@ -359,7 +360,7 @@ fn read_file_is_confined_to_the_project() {
let inv = Invocation { client: None, workspace_root: None, editor: None };
let err = f.host.run_command("misbehave", "read", Some("src/notes.txt"), inv).unwrap_err().message;
assert!(err.contains("no project is open"), "{err}");
- assert_eq!(f.run("misbehave", "root", None).unwrap().unwrap(), f.project.to_string_lossy());
+ assert_eq!(f.run("misbehave", "root", None).unwrap().unwrap(), to_ipc(&f.project));
assert_eq!(f.status("misbehave"), PluginStatus::Active);
}
@@ -1740,7 +1741,7 @@ fn official_checkstyle_checks_on_save() {
let java = f.project.join("src/A.java");
std::fs::write(&java, "class A {\n\tint x;\n}\n").unwrap();
let saved = |f: &Fixture| {
- f.host.post_events(Some("main"), vec![WorkbenchEvent::FileSaved(java.to_string_lossy().into_owned())]);
+ f.host.post_events(Some("main"), vec![WorkbenchEvent::FileSaved(to_ipc(&java))]);
f.host.flush_events_at(Instant::now() + Duration::from_secs(3));
};
saved(&f);
@@ -1793,7 +1794,7 @@ fn official_license_header_reports_fixes_and_scans() {
f.workspace.diagnostics.lock().get(&("openidea.license-header".into(), path.to_path_buf())).cloned()
};
- f.host.post_events(Some("main"), vec![WorkbenchEvent::FileOpened(a.to_string_lossy().into_owned())]);
+ f.host.post_events(Some("main"), vec![WorkbenchEvent::FileOpened(to_ipc(&a))]);
f.host.flush_events_at(Instant::now() + Duration::from_secs(3));
assert!(f.host.wait_events(Duration::from_secs(20)));
let d = diagnostics(&a).expect("published on open");
@@ -1818,7 +1819,7 @@ fn official_license_header_reports_fixes_and_scans() {
f.host.run_command("openidea.license-header", "scan", None, f.inv()).unwrap();
assert_eq!(diagnostics(&b).map(|d| d.len()), Some(1), "found by the scan");
std::fs::write(&a, "// Copyright (c) 2026 X\nfn a() {}\n").unwrap();
- f.host.post_events(Some("main"), vec![WorkbenchEvent::FileSaved(a.to_string_lossy().into_owned())]);
+ f.host.post_events(Some("main"), vec![WorkbenchEvent::FileSaved(to_ipc(&a))]);
f.host.flush_events_at(Instant::now() + Duration::from_secs(3));
assert!(f.host.wait_events(Duration::from_secs(20)));
assert_eq!(diagnostics(&a).map(|d| d.len()), Some(0), "cleared once the header is there");
@@ -2077,6 +2078,62 @@ fn views_open_project_files_at_a_line() {
assert!(opened.is_none());
}
+#[test]
+fn vcs_remotes_clipboard_and_open_url() {
+ let f = fixture(Limits::default());
+ let info = f.install(&sample("jobs"));
+ assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error);
+
+ // Not a git repository yet: `vcs-remotes` returns an empty list.
+ assert_eq!(f.run("jobs", "remotes", None).unwrap().as_deref(), Some(""));
+
+ git(&f.project, &["init", "-b", "main"]);
+ git(&f.project, &["remote", "add", "origin", "git@github.com:l0ng-ai/openidea.git"]);
+ git(&f.project, &["remote", "add", "upstream", "https://github.com/org/openidea"]);
+ assert_eq!(
+ f.run("jobs", "remotes", None).unwrap().as_deref(),
+ Some("origin=git@github.com:l0ng-ai/openidea.git\nupstream=https://github.com/org/openidea")
+ );
+
+ f.host.set_capability("jobs", "vcs.read", false).unwrap();
+ assert!(f.run("jobs", "remotes", None).unwrap_err().contains("vcs.read"), "needs vcs.read");
+
+ // `ui.copy-to-clipboard` requires `clipboard` and addresses the calling window.
+ f.take_events();
+ f.run("jobs", "copy", Some("https://github.com/l0ng-ai/openidea")).unwrap();
+ let copied = f.take_events().into_iter().find_map(|(client, e)| match e {
+ PluginEvent::CopyToClipboard { plugin_id, text } => Some((client, plugin_id, text)),
+ _ => None,
+ });
+ assert_eq!(copied, Some((Some("main".into()), "jobs".into(), "https://github.com/l0ng-ai/openidea".into())));
+
+ f.host.set_capability("jobs", "clipboard", false).unwrap();
+ f.take_events();
+ assert!(f.run("jobs", "copy", Some("secret")).unwrap_err().contains("clipboard"), "needs clipboard");
+ assert!(f.take_events().iter().all(|(_, e)| !matches!(e, PluginEvent::CopyToClipboard { .. })));
+
+ // `ui.open-url` accepts http(s) URLs and rejects other schemes.
+ f.take_events();
+ f.run("jobs", "open-url", Some("https://github.com/l0ng-ai/openidea/blob/main/README.md#L1")).unwrap();
+ let opened = f.take_events().into_iter().find_map(|(client, e)| match e {
+ PluginEvent::OpenUrl { plugin_id, url } => Some((client, plugin_id, url)),
+ _ => None,
+ });
+ assert_eq!(
+ opened,
+ Some(
+ (Some("main".into()), "jobs".into(), "https://github.com/l0ng-ai/openidea/blob/main/README.md#L1".into(),)
+ )
+ );
+
+ for bad in ["javascript:alert(1)", "file:///etc/passwd", ""] {
+ f.take_events();
+ let err = f.run("jobs", "open-url", Some(bad)).unwrap_err();
+ assert!(err.contains("http:// or https://"), "{bad:?}: {err}");
+ assert!(f.take_events().iter().all(|(_, e)| !matches!(e, PluginEvent::OpenUrl { .. })));
+ }
+}
+
#[test]
fn views_can_use_an_svg_icon_from_the_package() {
let f = fixture(Limits::default());
diff --git a/crates/ide-host/tests/project_smoke.rs b/crates/ide-host/tests/project_smoke.rs
index f8e7aed0a..9ed4cd4f4 100644
--- a/crates/ide-host/tests/project_smoke.rs
+++ b/crates/ide-host/tests/project_smoke.rs
@@ -223,7 +223,8 @@ async fn kotlin_gradle() {
.await;
let p = open("kotlin-gradle").await;
let model = ops::build::build_model(&p.host, WINDOW).await.unwrap();
- let gradle = model.gradle.expect("a Gradle model");
+ assert_eq!(model.gradle.len(), 1, "one independent Gradle build");
+ let gradle = &model.gradle[0];
assert_eq!(gradle.projects.iter().map(|g| g.name.as_str()).collect::>(), ["openidea-fixture-library"]);
}
@@ -231,8 +232,9 @@ async fn kotlin_gradle() {
async fn sync(fixture: &str, tool: ide_build::BuildTool) -> ide_build::BuildModel {
let p = open(fixture).await;
let progress = ops::Channel::new(|_: ide_build::SyncProgress| {});
- let model =
- ops::build::build_sync(&p.host, WINDOW, tool, ide_core::RequestId(1), false, vec![], progress).await.unwrap();
+ let model = ops::build::build_sync(&p.host, WINDOW, tool, ide_core::RequestId(1), false, vec![], None, progress)
+ .await
+ .unwrap();
// The cached sync is what the next open shows.
let reloaded = ops::build::build_model(&p.host, WINDOW).await.unwrap();
assert_eq!(reloaded, model);
@@ -253,7 +255,9 @@ async fn maven_sync() {
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "project smoke: needs Gradle and a JDK"]
async fn gradle_sync() {
- let gradle = sync("kotlin-gradle", ide_build::BuildTool::Gradle).await.gradle.unwrap();
+ let model = sync("kotlin-gradle", ide_build::BuildTool::Gradle).await;
+ assert_eq!(model.gradle.len(), 1, "one independent Gradle build");
+ let gradle = &model.gradle[0];
assert_eq!(gradle.sync_error, None);
assert!(gradle.synced_at.is_some());
let tasks: Vec<&str> = gradle.projects[0].tasks.iter().map(|t| t.name.as_str()).collect();
diff --git a/crates/ide-host/tests/trust.rs b/crates/ide-host/tests/trust.rs
index a5ec8d056..b50d5abce 100644
--- a/crates/ide-host/tests/trust.rs
+++ b/crates/ide-host/tests/trust.rs
@@ -249,6 +249,7 @@ async fn malicious_project_stays_editable_then_starts_in_place_after_trust() {
ide_core::RequestId(472),
false,
vec![],
+ None,
ops::Channel::new(|_| {}),
)
};
diff --git a/crates/ide-jsonrpc/src/lib.rs b/crates/ide-jsonrpc/src/lib.rs
index f78646ce2..6aa79979b 100644
--- a/crates/ide-jsonrpc/src/lib.rs
+++ b/crates/ide-jsonrpc/src/lib.rs
@@ -49,6 +49,10 @@ pub mod framing {
}
return Ok(None);
}
+ // A UTF-16LE line break is `\n\0`: the cut after `\n` leaves a NUL at the start of the
+ // next line, which would turn the `Content-Length` after it into noise.
+ let nuls = line.iter().take_while(|&&b| b == 0).count();
+ line.drain(..nuls);
if line.last() != Some(&b'\n') {
if n as u64 >= MAX_HEADER_LINE {
return Err(Error::new(ErrorKind::InvalidData, "header line too long"));
diff --git a/crates/ide-jsonrpc/src/tests.rs b/crates/ide-jsonrpc/src/tests.rs
index 9efee601c..c851cf66d 100644
--- a/crates/ide-jsonrpc/src/tests.rs
+++ b/crates/ide-jsonrpc/src/tests.rs
@@ -69,6 +69,21 @@ mod framing_tests {
assert_eq!(noise[2], "Picked up JAVA_TOOL_OPTIONS: -Dx");
}
+ #[tokio::test]
+ async fn utf16_output_of_wsl_exe_before_a_message_does_not_swallow_it() {
+ // `wsl.exe` reports its own errors as UTF-16LE with a BOM; the real server only starts
+ // after it, so the first message must still be read whole.
+ let notice = "The Windows Subsystem for Linux instance has terminated.";
+ let mut input: Vec = [0xff, 0xfe].into();
+ input.extend(format!("{notice}\r\n").encode_utf16().flat_map(u16::to_le_bytes));
+ input.extend(b"Content-Length: 2\r\n\r\n{}");
+ let (msgs, noise) = read_with_noise(&input).await;
+ assert_eq!(msgs.unwrap(), vec![b"{}".to_vec()]);
+ // The text is kept for the error shown to the user, whatever the BOM decodes to.
+ let ascii: String = noise.concat().chars().filter(char::is_ascii).filter(|c| *c != '\0').collect();
+ assert_eq!(ascii.trim(), notice);
+ }
+
#[tokio::test]
async fn unterminated_output_then_eof_is_reported() {
// `pause` prints its prompt without a newline and the process is gone.
diff --git a/crates/ide-notebook/src/document.rs b/crates/ide-notebook/src/document.rs
index 9eb328422..85d7661f6 100644
--- a/crates/ide-notebook/src/document.rs
+++ b/crates/ide-notebook/src/document.rs
@@ -9,7 +9,7 @@ use std::{
time::{Duration, Instant},
};
-use ide_core::{IdeError, IdeResult, error::ErrorKind};
+use ide_core::{IdeError, IdeResult, IpcPath, error::ErrorKind};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use specta::Type;
@@ -22,7 +22,7 @@ use crate::kernel::KernelOutput;
#[serde(rename_all = "camelCase")]
pub struct NotebookSnapshot {
pub id: String,
- pub path: String,
+ pub path: IpcPath,
pub version: u32,
pub dirty: bool,
pub can_undo: bool,
@@ -188,7 +188,7 @@ impl NotebookDocument {
pub fn snapshot(&self) -> NotebookSnapshot {
NotebookSnapshot {
id: self.id.clone(),
- path: self.path.to_string_lossy().into_owned(),
+ path: self.path.clone().into(),
version: self.version,
dirty: self.is_dirty(),
can_undo: !self.execution_active && !self.undo.is_empty(),
diff --git a/crates/ide-plugin/Cargo.toml b/crates/ide-plugin/Cargo.toml
index 0c3bf091b..da304e85e 100644
--- a/crates/ide-plugin/Cargo.toml
+++ b/crates/ide-plugin/Cargo.toml
@@ -22,3 +22,4 @@ wasmtime-wasi = { version = "49.0.4", default-features = false, features = ["p2"
[dev-dependencies]
tempfile.workspace = true
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "time"] }
+wat = "1.258"
diff --git a/crates/ide-plugin/src/runtime.rs b/crates/ide-plugin/src/runtime.rs
index 55c38e33f..e7fe25558 100644
--- a/crates/ide-plugin/src/runtime.rs
+++ b/crates/ide-plugin/src/runtime.rs
@@ -13,6 +13,7 @@ use crate::types::{
};
use crate::wit_host::{bindings, providers_bindings, syntax_bindings, views_bindings};
use ide_core::CancellationToken;
+use ide_core::ipc_path::to_ipc;
use ide_extension::Capability;
use parking_lot::Mutex;
use std::path::Path;
@@ -32,7 +33,7 @@ use views_bindings::exports::openidea::plugin::view_provider as wit_view;
use views_bindings::openidea::plugin::view_types as wit_vt;
/// The API version of the world above, `major.minor` of [`host_api`].
-pub const API_VERSION: &str = "1.0";
+pub const API_VERSION: &str = "1.1";
const EPOCH_TICK: Duration = Duration::from_millis(10);
pub(crate) const MAX_TEXT: usize = 1 << 20;
pub(crate) const MAX_TITLE: usize = 256;
@@ -45,6 +46,14 @@ pub const CANCELLED: &str = "was cancelled";
/// documents. Capabilities are already checked when these are called.
pub trait HostApi: Send + Sync {
fn notify(&self, client: Option<&str>, level: NotificationLevel, title: String, body: String);
+ fn copy_to_clipboard(&self, client: Option<&str>, text: String) -> Result<(), HostError> {
+ let _ = (client, text);
+ Ok(())
+ }
+ fn open_url(&self, client: Option<&str>, url: String) -> Result<(), HostError> {
+ let _ = (client, url);
+ Ok(())
+ }
/// Sets (`Some`) or removes the plugin's status bar item `id`.
fn status_item(&self, id: &str, item: Option);
/// Roots of the open projects.
@@ -66,6 +75,10 @@ pub trait HostApi: Send + Sync {
fn storage_set(&self, key: &str, value: Option) -> Result<(), HostError>;
fn storage_keys(&self, prefix: &str) -> Result, HostError>;
fn vcs_status(&self, root: &Path) -> Result, HostError>;
+ fn vcs_remotes(&self, root: &Path) -> Result, HostError> {
+ let _ = root;
+ Ok(Vec::new())
+ }
fn vcs_fetch(&self, root: &Path, cancel: &CancellationToken) -> Result<(), HostError>;
/// Subscribes to (or unsubscribes from) a kind of workbench event.
/// Project files matching `glob` (root-relative), at most `limit`.
@@ -199,8 +212,10 @@ impl PluginEngine {
/// Rejects a component this host can't run, with a message that says what
/// to do: one importing interfaces outside the plugin API and WASI, or one
/// built for a plugin API this host doesn't implement (another major, or a
- /// newer minor). Returns the API version the component was built for
- /// (`None`: it uses no plugin interface).
+ /// newer minor), or one that doesn't link: an import the host lacks or
+ /// declares with another type (same API version, different WIT), or a
+ /// missing plugin export. Returns the API version the component was built
+ /// for (`None`: it uses no plugin interface).
pub fn check_component(&self, component: &Component) -> Result, String> {
let ty = component.component_type();
let imports: Vec = ty.imports(&self.engine).map(|(name, _)| name.to_owned()).collect();
@@ -217,10 +232,20 @@ impl PluginEngine {
if let Some(v) = &built_for {
check_api(v, &host_api())?;
}
+ let pre = self.linker.instantiate_pre(component).map_err(|e| link_error(&format!("{e:#}")))?;
+ bindings::PluginIndices::new(&pre).map_err(|e| link_error(&format!("{e:#}")))?;
Ok(built_for)
}
}
+/// Explains a component that doesn't link against this host's plugin API.
+fn link_error(detail: &str) -> String {
+ format!(
+ "the component doesn't match the plugin API of this IDE ({API_VERSION}): {detail}. \
+ If it was built for a newer OpenIDEA, update OpenIDEA; otherwise rebuild it with the {API_VERSION} SDK"
+ )
+}
+
/// Interface names of the plugin API start with this (`openidea:plugin/log@1.0.0`).
const PLUGIN_PACKAGE_PREFIX: &str = "openidea:plugin/";
@@ -530,11 +555,7 @@ impl Runtime {
arm(&mut store, &setup.limits, setup.limits.timeout).map_err(|e| e.to_string())?;
let api_hint = |e: wasmtime::Error| {
let msg = describe_trap(&e, &setup.limits);
- if msg.contains("openidea:plugin/") {
- format!("{msg} (the component was built for a different plugin API; this IDE runs API {API_VERSION})")
- } else {
- msg
- }
+ if msg.contains("openidea:plugin/") { link_error(&msg) } else { msg }
};
let pre = engine.linker.instantiate_pre(component).map_err(api_hint)?;
let plugin_indices = bindings::PluginIndices::new(&pre).map_err(api_hint)?;
@@ -915,12 +936,7 @@ fn location_from_wit(l: wit_types::Location) -> Location {
}
fn doc_to_wit(d: &ProviderDocument) -> wit_prov::Document {
- wit_prov::Document {
- path: d.path.to_string_lossy().into_owned(),
- language: d.language.clone(),
- version: d.version,
- text: d.text.clone(),
- }
+ wit_prov::Document { path: to_ipc(&d.path), language: d.language.clone(), version: d.version, text: d.text.clone() }
}
/// Resets the budgets for the next call: fuel, deadline, cancellation.
@@ -992,7 +1008,7 @@ fn code_action_to_wit(a: &CodeAction) -> wit_prov::CodeAction {
.edits
.iter()
.map(|(path, edits)| wit_prov::FileEdit {
- path: path.to_string_lossy().into_owned(),
+ path: to_ipc(path),
edits: edits
.iter()
.map(|e| wit_types::TextEdit { range: range_to_wit(e.range), text: e.new_text.clone() })
@@ -1111,6 +1127,27 @@ fn event_to_wit_view(e: ViewEvent) -> wit_vt::ViewEvent {
mod tests {
use super::*;
+ #[cfg(windows)]
+ #[test]
+ fn provider_and_resolved_action_paths_match_ipc_events() {
+ for (path, expected) in [
+ (r"C:\project\Example.java", "C:/project/Example.java"),
+ (r"\\?\C:\project\Example.java", "C:/project/Example.java"),
+ (r"\\server\share\Example.java", "//server/share/Example.java"),
+ ] {
+ let doc = ProviderDocument { path: path.into(), ..Default::default() };
+ assert_eq!(doc_to_wit(&doc).path, expected);
+ let action = CodeAction {
+ title: "Fix".into(),
+ kind: None,
+ preferred: false,
+ edits: vec![(path.into(), Vec::new())],
+ data: None,
+ };
+ assert_eq!(code_action_to_wit(&action).edits[0].path, expected);
+ }
+ }
+
fn ext(kind: &str) -> wit_types::Extension {
wit_types::Extension { kind: kind.into(), data: "{}".into() }
}
diff --git a/crates/ide-plugin/src/wit_host.rs b/crates/ide-plugin/src/wit_host.rs
index bcd53cc81..a1f7b7533 100644
--- a/crates/ide-plugin/src/wit_host.rs
+++ b/crates/ide-plugin/src/wit_host.rs
@@ -5,6 +5,7 @@
use crate::runtime::{StoreData, clip};
use crate::sandbox;
use crate::types::{EditorContext, EventKind, HostError, HttpRequest, NotificationLevel, StatusItem, net_host};
+use ide_core::ipc_path::to_ipc;
use ide_extension::Capability;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
@@ -242,6 +243,24 @@ impl ui::Host for StoreData {
self.host.status_item(&clip(id, 64), None);
Ok(())
}
+
+ fn copy_to_clipboard(&mut self, text: String) -> Fallible<()> {
+ if let Err(e) = self.require(Capability::Clipboard) {
+ return Ok(Err(e));
+ }
+ let client = self.invocation.client.clone();
+ Ok(self.host.copy_to_clipboard(client.as_deref(), clip(text, crate::runtime::MAX_TEXT)).map_err(to_wit))
+ }
+
+ fn open_url(&mut self, url: String) -> Fallible<()> {
+ let trimmed = url.trim();
+ let valid = (trimmed.starts_with("https://") || trimmed.starts_with("http://")) && trimmed.len() <= 8192;
+ if !valid {
+ return Ok(Err(wit::Error::Invalid(format!("{url:?}: expected an http:// or https:// URL"))));
+ }
+ let client = self.invocation.client.clone();
+ Ok(self.host.open_url(client.as_deref(), trimmed.to_owned()).map_err(to_wit))
+ }
}
impl storage::Host for StoreData {
@@ -292,7 +311,8 @@ impl workspace::HostProject for StoreData {
if let Err(e) = self.require(Capability::ProjectRead) {
return Ok(Err(e));
}
- Ok(Ok(self.table.get(&this)?.root.to_string_lossy().into_owned()))
+ // Paths reach plugins in IPC form (`/` on Windows), as the UI's event paths do, so they compare as text.
+ Ok(Ok(to_ipc(&self.table.get(&this)?.root)))
}
fn read_file(&mut self, this: Resource, path: String) -> Fallible {
@@ -328,7 +348,7 @@ impl editor::Host for StoreData {}
impl editor::HostTextEditor for StoreData {
fn path(&mut self, this: Resource) -> wasmtime::Result {
- Ok(self.table.get(&this)?.ctx.path.to_string_lossy().into_owned())
+ Ok(to_ipc(&self.table.get(&this)?.ctx.path))
}
fn language(&mut self, this: Resource) -> wasmtime::Result {
@@ -438,6 +458,14 @@ impl vcs::Host for StoreData {
let token = self.cancel.token();
Ok(self.host.vcs_fetch(&root, &token).map_err(to_wit))
}
+
+ fn remotes(&mut self, project: Resource) -> Fallible> {
+ if let Err(e) = self.require(Capability::VcsRead) {
+ return Ok(Err(e));
+ }
+ let root = self.table.get(&project)?.root.clone();
+ Ok(self.host.vcs_remotes(&root).map_err(to_wit))
+ }
}
impl events::Host for StoreData {
diff --git a/crates/ide-plugin/tests/check_component.rs b/crates/ide-plugin/tests/check_component.rs
new file mode 100644
index 000000000..6294a2266
--- /dev/null
+++ b/crates/ide-plugin/tests/check_component.rs
@@ -0,0 +1,43 @@
+//! `PluginEngine::check_component` (what installing and `openidea-plugin check`
+//! run) refuses a component the IDE could not start: every import has to link
+//! against the host, function by function, and the plugin world's exports have
+//! to be there, not only the interface names and their API version.
+
+use ide_plugin::PluginEngine;
+
+fn check(wat: &str) -> Result, String> {
+ let engine = PluginEngine::new().unwrap();
+ let component = engine.compile_bytes(&wat::parse_str(wat).unwrap()).unwrap();
+ engine.check_component(&component)
+}
+
+#[test]
+fn components_the_host_cannot_link_are_refused() {
+ let cases = [
+ // As dengliu.gitlink 0.1.0 was: built from unreleased WIT that added functions under the same version.
+ (
+ "a function the host's interface lacks",
+ r#"(component (import "openidea:plugin/ui@1.0.0" (instance
+ (export "not-a-host-function" (func (param "text" string))))))"#,
+ "not-a-host-function",
+ ),
+ (
+ "a function with another signature",
+ r#"(component (import "openidea:plugin/ui@1.0.0" (instance
+ (export "show-notification" (func (param "title" string))))))"#,
+ "show-notification",
+ ),
+ (
+ "an interface the host lacks",
+ r#"(component (import "openidea:plugin/clipboard@1.0.0" (instance
+ (export "copy" (func (param "text" string))))))"#,
+ "openidea:plugin/clipboard@1.0.0",
+ ),
+ ("no plugin exports", r#"(component (import "openidea:plugin/ui@1.0.0" (instance)))"#, "lifecycle"),
+ ];
+ for (case, wat, names) in cases {
+ let err = check(wat).expect_err(case);
+ assert!(err.contains(names), "{case}: {err}");
+ assert!(err.contains("update OpenIDEA") && err.contains("rebuild"), "{case}: {err}");
+ }
+}
diff --git a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm
index 368de83b3..22be24d04 100644
Binary files a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm differ
diff --git a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources
index f811c07c0..a78a811bf 100644
--- a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources
+++ b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources
@@ -1 +1 @@
-1f7402e2b1b4602e4073eb61837c3dc8840f9a4f7275d800dbd6fcf6ce5e40c3
+455d848dd27f242bb3e9bf420558cbc8c200853f51ada3d481635ac9771803f3
diff --git a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm
index 0899f53e8..6cc5e751a 100644
Binary files a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm differ
diff --git a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources
index 3a0de87f3..30532e592 100644
--- a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources
+++ b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources
@@ -1 +1 @@
-4f6cd5b5e0c12e7dcbccf66a6ad97276bf968bc3ad26f0fd08e1f7765cbfee14
+f7e1ae06a90460ed1d2319bfc51f848654df145fa451f3d959b32776b2b25ce3
diff --git a/crates/ide-plugin/tests/fixtures/jobs/plugin.toml b/crates/ide-plugin/tests/fixtures/jobs/plugin.toml
index 8fc682061..7453c9ec5 100644
--- a/crates/ide-plugin/tests/fixtures/jobs/plugin.toml
+++ b/crates/ide-plugin/tests/fixtures/jobs/plugin.toml
@@ -12,7 +12,7 @@ activation = ["on-view:jobs", "on-command:*"]
[capabilities]
required = ["process:jobs"]
-optional = ["project.read"]
+optional = ["project.read", "vcs.read", "clipboard"]
[[contributes.views]]
id = "jobs"
diff --git a/crates/ide-plugin/tests/fixtures/jobs/src/lib.rs b/crates/ide-plugin/tests/fixtures/jobs/src/lib.rs
index fe28d779a..131ef2cce 100644
--- a/crates/ide-plugin/tests/fixtures/jobs/src/lib.rs
+++ b/crates/ide-plugin/tests/fixtures/jobs/src/lib.rs
@@ -32,9 +32,22 @@ impl Jobs {
}
impl Plugin for Jobs {
- fn run_command(&mut self, id: &str, _ctx: &Invocation, _arg: Option<&str>) -> Result , Error> {
+ fn run_command(&mut self, id: &str, ctx: &Invocation, arg: Option<&str>) -> Result , Error> {
match id {
"follow" => self.follow().map(|()| None),
+ "remotes" => {
+ let project = ctx.project().ok_or("no project is open")?;
+ let remotes = project.vcs_remotes()?;
+ Ok(Some(remotes.into_iter().map(|(n, u)| format!("{n}={u}")).collect::>().join("\n")))
+ }
+ "copy" => {
+ host::copy_to_clipboard(arg.unwrap_or_default())?;
+ Ok(None)
+ }
+ "open-url" => {
+ host::open_url(arg.unwrap_or_default())?;
+ Ok(None)
+ }
_ => Err(Error::NotFound(format!("command {id}"))),
}
}
diff --git a/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm
index 37db117ec..70f536a98 100644
Binary files a/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm differ
diff --git a/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources
index f95fc1ae3..d08b0b0e8 100644
--- a/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources
+++ b/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources
@@ -1 +1 @@
-3d4dd1b6365c7fbafdd3c1f99f4405e5ee40fc5e56c97da960adfe7af2e7edb8
+12973c7ca7330fc50eb16718fd769d412401b5a92bd014f4804659e4a272122f
diff --git a/crates/ide-plugin/tests/fixtures/misbehave/src/lib.rs b/crates/ide-plugin/tests/fixtures/misbehave/src/lib.rs
index 890bf845b..35be7a7a0 100644
--- a/crates/ide-plugin/tests/fixtures/misbehave/src/lib.rs
+++ b/crates/ide-plugin/tests/fixtures/misbehave/src/lib.rs
@@ -5,8 +5,8 @@ wit_bindgen::generate!({
inline: "
package fixture:misbehave;
world misbehave {
- include openidea:plugin/plugin-syntax@1.0.0;
- export openidea:plugin/providers@1.0.0;
+ include openidea:plugin/plugin-syntax@1.1.0;
+ export openidea:plugin/providers@1.1.0;
}
",
path: "../../../../../wit",
diff --git a/crates/ide-run/src/detect.rs b/crates/ide-run/src/detect.rs
index acb7e87dc..b7bc88027 100644
--- a/crates/ide-run/src/detect.rs
+++ b/crates/ide-run/src/detect.rs
@@ -564,9 +564,8 @@ fn spring_boot_apps(root: &Path, build: &Path, out: &mut Vec) {
Some(m) => format!("application:{m}:{class}"),
None => format!("application:{class}"),
};
- let mut app = config(id, simple, "spring-boot", "java", &["-cp", "${classpath}", &class])
- .in_dir(module_rel.as_ref().map(PathBuf::from))
- .debug("java-debug");
+ let mut app =
+ config(id, simple, "spring-boot", "java", &["-cp", "${classpath}", &class]).debug("java-debug");
app.extras.module = Some(module_rel.clone().unwrap_or_default());
out.push(app);
}
diff --git a/crates/ide-run/src/import/jetbrains.rs b/crates/ide-run/src/import/jetbrains.rs
index 6d9dac06b..57ebe2c12 100644
--- a/crates/ide-run/src/import/jetbrains.rs
+++ b/crates/ide-run/src/import/jetbrains.rs
@@ -3,8 +3,9 @@
//! its templates (`default="true"`) and temporary configurations. A configuration found in more
//! than one place is taken from the first, in that order.
//!
-//! Python, Django server, Django tests, pytest, npm, Jest, Vitest, Node.js, Shell Script and
-//! compound configurations become the matching kinds; other types are skipped with their type.
+//! Python, Django server, Django tests, pytest, npm, Jest, Vitest, Node.js, Shell Script,
+//! Application, Spring Boot and compound configurations become the matching kinds; other types
+//! are skipped with their type.
//! `$PROJECT_DIR$` (and `$MODULE_DIR$`, `$MODULE_WORKING_DIR$`, `$ContentRoot$`,
//! `$ProjectFileDir$`, all the project directory here) and `$USER_HOME$` are replaced; another
//! path variable skips the configuration.
@@ -162,6 +163,8 @@ const VITEST: &str = "JavaScriptTestRunnerVitest";
const NODE: &str = "NodeJSConfigurationType";
const SHELL: &str = "ShConfigurationType";
const COMPOUND: &str = "CompoundRunConfigurationType";
+const APPLICATION: &str = "Application";
+const SPRING_BOOT: &str = "SpringBootApplicationConfigurationType";
type Res = Result;
@@ -329,6 +332,8 @@ impl Cx<'_> {
JEST | VITEST => self.js_tests(name, c, ty == VITEST, &mut ignored)?,
NODE => self.node(name, c, &mut ignored)?,
SHELL => self.shell(name, c)?,
+ APPLICATION => self.java_app(name, c, false, &mut ignored)?,
+ SPRING_BOOT => self.java_app(name, c, true, &mut ignored)?,
COMPOUND => {
members = c.children_named("toRun").filter_map(|t| t.attr("name")).map(str::to_owned).collect();
config(name, "compound", String::new(), Vec::new(), None)
@@ -695,6 +700,54 @@ impl Cx<'_> {
Ok(rc)
}
+ // ------------------------------------------------------------- Java
+
+ /// IDEA's Application and Spring Boot types, as the `application` / `spring-boot` forms run
+ /// them: `java -cp ${classpath} [--spring.profiles.active=…] `
+ /// with the module's runtime classpath.
+ fn java_app(&self, name: &str, c: &El, spring: bool, ignored: &mut Vec) -> Res {
+ let main = if spring { "SPRING_BOOT_MAIN_CLASS" } else { "MAIN_CLASS_NAME" };
+ let Some(main) = self.text(c.option(main))? else { return Err(SkipReason::NothingToRun) };
+ let (_, cwd) = self.cwd(c.option("WORKING_DIRECTORY"))?;
+ let mut args = self.words(c.option("VM_PARAMETERS"))?;
+ args.extend(["-cp".to_owned(), "${classpath}".to_owned(), main]);
+ if spring && let Some(profiles) = self.text(c.option("ACTIVE_PROFILES"))? {
+ args.push(format!("--spring.profiles.active={profiles}"));
+ }
+ args.extend(self.words(c.option("PROGRAM_PARAMETERS"))?);
+ let mut rc =
+ config(name, if spring { "spring-boot" } else { "application" }, "java".into(), args, Some("java-debug"));
+ rc.cwd = cwd.map(Into::into);
+ self.envs(c, &mut rc);
+ if let Some(module) = c.child("module").and_then(|m| m.attr("name")).filter(|m| !m.is_empty()) {
+ match module_dir(self.root, module) {
+ Some(dir) => rc.extras.module = Some(dir),
+ None => ignored.push(format!("module {module}")),
+ }
+ }
+ // Options the forms have no field for.
+ if c.flag("ALTERNATIVE_JRE_PATH_ENABLED") {
+ ignored.push("JRE".into());
+ }
+ if c.flag("INCLUDE_PROVIDED_SCOPE") {
+ ignored.push("provided scope".into());
+ }
+ if c.child("classpathModifications").is_some_and(|m| !m.children.is_empty()) {
+ ignored.push("classpath modifications".into());
+ }
+ if spring {
+ for (option, label) in [("HIDE_BANNER", "hide banner"), ("DEBUG_MODE", "debug output")] {
+ if c.flag(option) {
+ ignored.push(label.into());
+ }
+ }
+ if c.child("additionalParameters").is_some_and(|p| !p.children.is_empty()) {
+ ignored.push("override configuration properties".into());
+ }
+ }
+ Ok(rc)
+ }
+
// ------------------------------------------------------------- Shell
/// A script file (with its interpreter) or script text, kept as the Shell Script form's fields.
@@ -835,6 +888,56 @@ fn module_count(root: &Path) -> usize {
.sum()
}
+/// The folder (relative, `""` for the project's own) of IDEA module `name`: a Maven module is
+/// named after its `artifactId` (usually its folder), a Gradle one `..`.
+fn module_dir(root: &Path, name: &str) -> Option {
+ let folder = |dir: &Path| dir.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
+ let exact = |dir: &Path| folder(dir) == name || pom_artifact(dir).is_some_and(|a| a == name);
+ if let Some(dir) = find_module(root, &exact) {
+ return Some(dir);
+ }
+ let gradle = name.strip_suffix(".main").or_else(|| name.strip_suffix(".test")).unwrap_or(name);
+ let last = gradle.rsplit('.').next().filter(|last| *last != name)?;
+ find_module(root, &|dir: &Path| folder(dir) == last)
+}
+
+/// The shallowest build folder under `root` (itself included) that `names` accepts.
+fn find_module(root: &Path, names: &dyn Fn(&Path) -> bool) -> Option {
+ const BUILDS: [&str; 3] = ["pom.xml", "build.gradle", "build.gradle.kts"];
+ let mut level = vec![root.to_path_buf()];
+ for _ in 0..5 {
+ if let Some(found) = level.iter().find(|d| BUILDS.iter().any(|b| d.join(b).is_file()) && names(d)) {
+ let rel = found.strip_prefix(root).ok()?;
+ return Some(rel.to_string_lossy().replace('\\', "/"));
+ }
+ let mut next: Vec = level
+ .iter()
+ .filter_map(|d| std::fs::read_dir(d).ok())
+ .flat_map(|entries| entries.flatten().map(|e| e.path()))
+ .filter(|p| p.is_dir())
+ .filter(|p| {
+ let n = p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
+ !n.starts_with('.') && !["node_modules", "target", "build", "out", "src"].contains(&n.as_str())
+ })
+ .collect();
+ next.sort();
+ level = next;
+ }
+ None
+}
+
+/// The `artifactId` of the module's own `pom.xml` (not its parent's).
+fn pom_artifact(dir: &Path) -> Option {
+ let pom = std::fs::read_to_string(dir.join("pom.xml")).ok()?;
+ let own = match (pom.find(""), pom.find(" ")) {
+ (Some(start), Some(end)) if start < end => format!("{}{}", &pom[..start], &pom[end..]),
+ _ => pom,
+ };
+ let start = own.find("")? + "".len();
+ let end = start + own[start..].find(" ")?;
+ Some(own[start..end].trim().to_owned())
+}
+
/// `--settings` from PyCharm's "Settings" field: a file (`mysite/settings/dev.py`, relative to
/// the project) becomes its module relative to the working directory; a module stays as it is.
fn settings_module(value: &str, root: &Path, dir: &Path) -> String {
diff --git a/crates/ide-run/src/tomcat.rs b/crates/ide-run/src/tomcat.rs
index 4eb198b37..ab49e617d 100644
--- a/crates/ide-run/src/tomcat.rs
+++ b/crates/ide-run/src/tomcat.rs
@@ -204,7 +204,7 @@ pub fn explode(war: &Path, destination: &Path, resources_only: bool) -> IdeResul
}
/// Updates the exploded artifact deployment from the module's own class output directories and
-/// its web resources (`src/main/webapp`), file by file and only what changed, instead of
+/// its web resources ([`web_sources`]), file by file and only what changed, instead of
/// rebuilding and re-exploding the WAR. `module` and `class_paths` are absolute.
/// `Ok(false)`: not possible this way (not deployed from the artifact yet, the module's output
/// isn't on the classpath, or a module it depends on was compiled since the last update, whose
@@ -232,9 +232,10 @@ pub fn sync_outputs(base: &Path, module: &Path, class_paths: &[PathBuf], resourc
for dir in own {
sync_tree(dir, dir, &classes, Path::new("WEB-INF/classes"), resources_only, &mut current)?;
}
- let webapp = module.join("src/main/webapp");
- if webapp.is_dir() {
- sync_tree(&webapp, &webapp, &dest, Path::new(""), resources_only, &mut current)?;
+ for (dir, target) in web_sources(module) {
+ if dir.is_dir() {
+ sync_tree(&dir, &dir, &dest.join(&target), &target, resources_only, &mut current)?;
+ }
}
for rel in previous.iter().filter(|p| !current.contains(*p)) {
let path = dest.join(rel);
@@ -246,6 +247,59 @@ pub fn sync_outputs(base: &Path, module: &Path, class_paths: &[PathBuf], resourc
Ok(true)
}
+/// The folders whose files the WAR puts at its root (or under the second path): the web app
+/// folder (`src/main/webapp`, maven-war-plugin's `warSourceDirectory`, Gradle's
+/// `webAppDirName` / `webAppDirectory`), then maven-war-plugin's `webResources`.
+pub fn web_sources(module: &Path) -> Vec<(PathBuf, PathBuf)> {
+ let rel = |dir: &str| {
+ let dir = ["${project.basedir}/", "${basedir}/"].iter().find_map(|p| dir.strip_prefix(p)).unwrap_or(dir);
+ module.join(dir.trim())
+ };
+ if let Ok(pom) = fs::read_to_string(module.join("pom.xml"))
+ && let Some(war) = war_plugin(&pom)
+ {
+ let mut out = vec![(rel(tag(war, "warSourceDirectory").unwrap_or("src/main/webapp")), PathBuf::new())];
+ let mut rest = tag_block(war, "webResources").unwrap_or_default();
+ while let Some(start) = rest.find("") {
+ let end = rest[start..].find(" ").map_or(rest.len(), |e| start + e);
+ let resource = &rest[start..end];
+ if let Some(dir) = tag(resource, "directory") {
+ out.push((rel(dir), PathBuf::from(tag(resource, "targetPath").unwrap_or_default())));
+ }
+ rest = &rest[end..];
+ }
+ return out;
+ }
+ let gradle = ["build.gradle", "build.gradle.kts"].iter().find_map(|f| fs::read_to_string(module.join(f)).ok());
+ let web_app_dir = gradle.as_deref().and_then(|text| {
+ text.lines()
+ .filter(|l| !l.trim_start().starts_with("//"))
+ .find(|l| l.contains("webAppDirName") || l.contains("webAppDirectory"))
+ .and_then(|l| l.split(['"', '\'']).nth(1).map(str::to_owned))
+ });
+ vec![(rel(web_app_dir.as_deref().unwrap_or("src/main/webapp")), PathBuf::new())]
+}
+
+/// The `` element of maven-war-plugin in `pom`, if it is configured there.
+fn war_plugin(pom: &str) -> Option<&str> {
+ let at = pom.find("maven-war-plugin ")?;
+ let start = pom[..at].rfind("")?;
+ let end = pom[at..].find(" ").map_or(pom.len(), |e| at + e);
+ Some(&pom[start..end])
+}
+
+/// The text of the first `` element in `xml`.
+fn tag<'a>(xml: &'a str, name: &str) -> Option<&'a str> {
+ tag_block(xml, name).map(str::trim).filter(|t| !t.is_empty())
+}
+
+fn tag_block<'a>(xml: &'a str, name: &str) -> Option<&'a str> {
+ let open = format!("<{name}>");
+ let start = xml.find(&open)? + open.len();
+ let end = start + xml[start..].find(&format!("{name}>"))?;
+ Some(&xml[start..end])
+}
+
fn modified_since(dir: &Path, since: std::time::SystemTime) -> bool {
let Ok(entries) = fs::read_dir(dir) else { return false };
entries.flatten().any(|e| match e.file_type() {
@@ -574,6 +628,83 @@ mod tests {
fs::File::create(dep.join("Core.class")).unwrap().set_modified(later).unwrap();
assert!(!sync_outputs(&base, &module, &paths, false).unwrap());
}
+ #[test]
+ fn web_sources_follow_the_war_plugin_and_gradle_settings() {
+ let tmp = tempfile::tempdir().unwrap();
+ let module = tmp.path();
+ let rel = |sources: Vec<(PathBuf, PathBuf)>| -> Vec<(String, String)> {
+ sources
+ .into_iter()
+ .map(|(d, t)| {
+ let d = d.strip_prefix(module).unwrap().to_string_lossy().replace('\\', "/");
+ (d, t.to_string_lossy().into_owned())
+ })
+ .collect()
+ };
+ assert_eq!(rel(web_sources(module)), [("src/main/webapp".to_owned(), String::new())]);
+
+ // An Eclipse-style layout: the JSPs come in through webResources.
+ fs::write(
+ module.join("pom.xml"),
+ r#"
+ maven-compiler-plugin
+
+ org.apache.maven.plugins
+ maven-war-plugin
+
+ ${basedir}/web
+
+ WebContent **/*
+ extra static
+
+
+
+ "#,
+ )
+ .unwrap();
+ assert_eq!(
+ rel(web_sources(module)),
+ [
+ ("web".to_owned(), String::new()),
+ ("WebContent".to_owned(), String::new()),
+ ("extra".to_owned(), "static".to_owned())
+ ]
+ );
+
+ fs::remove_file(module.join("pom.xml")).unwrap();
+ fs::write(
+ module.join("build.gradle"),
+ "plugins { id 'war' }\n// webAppDirName = 'old'\nwebAppDirName = 'WebContent'\n",
+ )
+ .unwrap();
+ assert_eq!(rel(web_sources(module)), [("WebContent".to_owned(), String::new())]);
+ }
+
+ #[test]
+ fn updates_web_resources_outside_src_main_webapp() {
+ let tmp = tempfile::tempdir().unwrap();
+ let root = tmp.path();
+ let (base, module) = (root.join("base"), root.join("web"));
+ let (dest, out) = (base.join("deployment"), module.join("target/classes"));
+ for dir in [dest.join("WEB-INF/classes"), out.clone(), module.join("WebContent/jsp")] {
+ fs::create_dir_all(dir).unwrap();
+ }
+ fs::write(
+ module.join("pom.xml"),
+ "maven-war-plugin \
+ WebContent ",
+ )
+ .unwrap();
+ fs::write(dest.join("index.jsp"), "old").unwrap();
+ fs::write(module.join("WebContent/jsp/list.jsp"), "new").unwrap();
+ fs::write(base.join("artifact-path.txt"), "web.war").unwrap();
+
+ assert!(sync_outputs(&base, &module, std::slice::from_ref(&out), true).unwrap());
+ assert_eq!(fs::read_to_string(dest.join("jsp/list.jsp")).unwrap(), "new");
+ // Files the WAR brought that no source here maps to stay.
+ assert_eq!(fs::read_to_string(dest.join("index.jsp")).unwrap(), "old");
+ }
+
#[test]
fn deployment_errors_identify_the_checked_path() {
let tmp = tempfile::tempdir().unwrap();
diff --git a/crates/ide-run/tests/detect.rs b/crates/ide-run/tests/detect.rs
index 80e54c385..aebe520c2 100644
--- a/crates/ide-run/tests/detect.rs
+++ b/crates/ide-run/tests/detect.rs
@@ -215,7 +215,7 @@ fn builds_nested_in_a_folder_of_repositories() {
assert_eq!(app.name, "DromaraApplication");
assert_eq!(app.kind, "spring-boot");
assert_eq!(cmdline(app), "java -cp ${classpath} org.dromara.DromaraApplication");
- assert_eq!(app.cwd.as_deref(), Some(Path::new("RuoYi-Vue-Plus/ruoyi-admin")));
+ assert_eq!(app.cwd, None);
assert_eq!(app.extras.module.as_deref(), Some("RuoYi-Vue-Plus/ruoyi-admin"));
assert_eq!(app.debug_adapter.as_deref(), Some("java-debug"));
let package = find(&c, "maven:package:RuoYi-Vue-Plus");
@@ -258,7 +258,7 @@ fn spring_boot_application_of_a_repository_nested_one_or_two_levels_down() {
let module = format!("{prefix}ruoyi-ai/ruoyi-admin");
let app = find(&c, &format!("application:{module}:org.ruoyi.RuoYiAIApplication"));
assert_eq!((app.name.as_str(), app.kind.as_str()), ("RuoYiAIApplication", "spring-boot"), "{prefix}");
- assert_eq!(app.cwd.as_deref(), Some(Path::new(&module)));
+ assert_eq!((app.cwd.as_deref(), app.extras.module.as_deref()), (None, Some(module.as_str())), "{prefix}");
assert!(ids(&c).contains(&format!("maven:spring-boot:run:{prefix}ruoyi-ai").as_str()), "{prefix}");
}
}
@@ -295,9 +295,9 @@ fn nested_builds_of_every_kind_and_depth() {
}
assert!(find(&c, "gradle:test:services/api").is_test);
let api = find(&c, "application:services/api/app:com.acme.ApiKt");
- assert_eq!(api.cwd.as_deref(), Some(Path::new("services/api/app")));
+ assert_eq!((api.cwd.as_deref(), api.extras.module.as_deref()), (None, Some("services/api/app")));
let billing = find(&c, "application:billing:Billing");
- assert_eq!(billing.cwd.as_deref(), Some(Path::new("billing")));
+ assert_eq!((billing.cwd.as_deref(), billing.extras.module.as_deref()), (None, Some("billing")));
let builds: Vec<&str> =
ids(&c).into_iter().filter(|id| id.starts_with("maven:package") || id.starts_with("gradle:build")).collect();
assert_eq!(builds, ["gradle:build:services/api", "maven:package:billing"]);
diff --git a/crates/ide-run/tests/import_jetbrains.rs b/crates/ide-run/tests/import_jetbrains.rs
index f2f1da94d..d50d714df 100644
--- a/crates/ide-run/tests/import_jetbrains.rs
+++ b/crates/ide-run/tests/import_jetbrains.rs
@@ -239,8 +239,8 @@ fn workspace_run_manager_skips_templates_and_temporary_configurations() {
-
-
+
+
"#
)),
@@ -272,7 +272,7 @@ fn workspace_run_manager_skips_templates_and_temporary_configurations() {
assert_eq!(get(&r, "Custom").args, s(&["-m", "pytest", "tests/a.py", "--maxfail=2"]));
assert_eq!(reason(&r, "Unittests in tests"), &SkipReason::UnsupportedType { type_: "tests (Unittests)".into() });
- assert_eq!(reason(&r, "Main"), &SkipReason::UnsupportedType { type_: "Application".into() });
+ assert_eq!(reason(&r, "Main"), &SkipReason::UnsupportedType { type_: "JUnit".into() });
let dev = get(&r, "dev");
assert_eq!((dev.kind.as_str(), dev.command.as_str()), ("npm", "pnpm"));
@@ -526,14 +526,11 @@ fn sources_are_offered_when_their_files_exist() {
),
);
assert!(available(dir.path()).is_empty());
- write(dir.path(), ".idea/workspace.xml", &workspace(r#" "#));
+ write(dir.path(), ".idea/workspace.xml", &workspace(r#" "#));
assert_eq!(available(dir.path()), [RunImportSource::Jetbrains]);
write(dir.path(), ".vscode/launch.json", "{}");
assert_eq!(available(dir.path()), [RunImportSource::Vscode, RunImportSource::Jetbrains]);
- assert_eq!(
- reason(&import_from(dir.path(), &[]), "App"),
- &SkipReason::UnsupportedType { type_: "Application".into() }
- );
+ assert_eq!(reason(&import_from(dir.path(), &[]), "App"), &SkipReason::UnsupportedType { type_: "JUnit".into() });
}
#[test]
@@ -702,3 +699,91 @@ fn flags_defaults_inherited_variables_and_nested_django_tests() {
assert_eq!((tests.kind.as_str(), tests.is_test), ("python", true));
assert_eq!(tests.args[..3], s(&["backend/manage.py", "test", "orders"]));
}
+
+#[test]
+fn java_applications_and_spring_boot_keep_their_module() {
+ let dir = tempfile::tempdir().unwrap();
+ let root = dunce::canonicalize(dir.path()).unwrap();
+ write(&root, "pom.xml", "shop pom ");
+ write(
+ &root,
+ "example-service/pom.xml",
+ "shop example-service ",
+ );
+ // IDEA names a Maven module after its artifactId, which needn't be its folder.
+ write(
+ &root,
+ "services/api/pom.xml",
+ "shop api-service ",
+ );
+ write(
+ &root,
+ ".idea/workspace.xml",
+ &workspace(
+ r#"
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ "#,
+ ),
+ );
+
+ let r = import_from(&root, &[]);
+
+ let boot = get(&r, "ExampleApplication");
+ assert_eq!(boot.kind, "spring-boot");
+ assert_eq!(boot.command, "java");
+ assert_eq!(
+ boot.args,
+ s(&[
+ "-Xmx512m",
+ "-Dfile.encoding=UTF-8",
+ "-cp",
+ "${classpath}",
+ "com.example.ExampleApplication",
+ "--spring.profiles.active=dev,local",
+ "--server.port=8081",
+ ])
+ );
+ assert_eq!(boot.extras.module.as_deref(), Some("example-service"));
+ assert_eq!(cwd(boot), Some(PathBuf::from("example-service")));
+ assert_eq!(boot.env.get("TZ").map(String::as_str), Some("UTC"));
+ assert_eq!(boot.debug_adapter.as_deref(), Some("java-debug"));
+ assert_eq!(ignored(&r, "ExampleApplication"), ["before launch", "hide banner"]);
+
+ let api = get(&r, "Api");
+ assert_eq!(api.kind, "application");
+ assert_eq!(api.args, s(&["-cp", "${classpath}", "com.example.api.Main"]));
+ assert_eq!(api.extras.module.as_deref(), Some("services/api"));
+ assert_eq!(ignored(&r, "Api"), ["JRE"]);
+
+ // A module that isn't here any more: the form finds it by the main class.
+ assert_eq!(get(&r, "Gone").extras.module, None);
+ assert_eq!(ignored(&r, "Gone"), ["module removed-module"]);
+
+ assert_eq!(reason(&r, "NoMain"), &SkipReason::NothingToRun);
+}
diff --git a/crates/ide-sdk/src/apply.rs b/crates/ide-sdk/src/apply.rs
index bfa331397..11888554c 100644
--- a/crates/ide-sdk/src/apply.rs
+++ b/crates/ide-sdk/src/apply.rs
@@ -429,6 +429,36 @@ pub fn configure_maven_import(
}
}
+/// Explicit Gradle links constrain discovery and isolate JDT's persisted import state.
+pub fn configure_gradle_import(
+ cfg: &mut LanguageServerConfig,
+ linked: Option<&[String]>,
+ build_files: Option<&[String]>,
+) {
+ let Some(linked) = linked.filter(|_| cfg.name == "jdtls") else { return };
+ merge_init_options(
+ cfg,
+ json!({
+ "settings": {"java": {"import": {"gradle": {"enabled": !linked.is_empty()}}}},
+ "projectConfigurations": build_files.unwrap_or_default()
+ }),
+ );
+ if let Some(settings) = &mut cfg.settings {
+ merge(settings, json!({"java": {"import": {"gradle": {"enabled": !linked.is_empty()}}}}));
+ }
+ if let Some(index) = cfg.args.iter().position(|arg| arg == "-data")
+ && let Some(workspace) = cfg.args.get_mut(index + 1)
+ {
+ let mut hash = 0xcbf29ce484222325u64;
+ for dir in linked {
+ for byte in dir.bytes().chain([0]) {
+ hash = (hash ^ u64::from(byte)).wrapping_mul(0x100000001b3);
+ }
+ }
+ *workspace = format!("{workspace}-gradle-{hash:016x}");
+ }
+}
+
pub(crate) fn merge_init_options(cfg: &mut LanguageServerConfig, layer: Value) {
let mut current =
cfg.initialization_options.as_ref().and_then(|v| serde_json::to_value(v).ok()).unwrap_or_else(|| json!({}));
@@ -1218,6 +1248,25 @@ mod java_compiler_tests {
mod maven_linkage_tests {
use super::*;
+ #[test]
+ fn explicit_gradle_links_scope_jdt_import_and_unlink_all_disables_gradle() {
+ let mut cfg = custom_server("jdtls", &LanguageServerSettings::default());
+ cfg.args = vec!["-data".into(), "/cache/jdtls".into()];
+ let files = ["file:///p/a/settings.gradle".to_owned(), "file:///p/maven/pom.xml".to_owned()];
+ configure_gradle_import(&mut cfg, Some(&["a".into()]), Some(&files));
+ let options = serde_json::to_value(&cfg.initialization_options).unwrap();
+ assert_eq!(options["projectConfigurations"], json!(files));
+ assert_eq!(options.pointer("/settings/java/import/gradle/enabled"), Some(&json!(true)));
+ assert!(cfg.args[1].starts_with("/cache/jdtls-gradle-"));
+ let linked_cache = cfg.args[1].clone();
+ cfg.args[1] = "/cache/jdtls".into();
+ configure_gradle_import(&mut cfg, Some(&[]), Some(&files[1..]));
+ let options = serde_json::to_value(&cfg.initialization_options).unwrap();
+ assert_eq!(options["projectConfigurations"], json!(&files[1..]));
+ assert_eq!(options.pointer("/settings/java/import/gradle/enabled"), Some(&json!(false)));
+ assert_ne!(linked_cache, cfg.args[1]);
+ }
+
#[test]
fn import_excludes_unlinked_modules_and_changes_persistent_workspace() {
let tmp = tempfile::tempdir().unwrap();
diff --git a/crates/ide-sdk/src/discover.rs b/crates/ide-sdk/src/discover.rs
index e18b2dae8..d5141e270 100644
--- a/crates/ide-sdk/src/discover.rs
+++ b/crates/ide-sdk/src/discover.rs
@@ -184,6 +184,12 @@ fn python(root: Option<&Path>) -> Vec {
par_map(&candidates, |(path, source)| python_sdk(path, source, root))
}
+/// Interpreter `path` (one the project is set to, wherever it lives) as an SDK, named
+/// like the detected ones.
+pub fn python_interpreter_sdk(path: &Path, root: Option<&Path>) -> Option {
+ python_sdk(path, if venv_of(path).is_some() { "venv" } else { "system" }, root)
+}
+
pub(crate) fn python_sdk(path: &Path, source: &str, root: Option<&Path>) -> Option {
let version = query(path, &["--version"], VERSION_TIMEOUT)
.and_then(|v| v.strip_prefix("Python ").map(str::to_owned))
@@ -193,10 +199,11 @@ pub(crate) fn python_sdk(path: &Path, source: &str, root: Option<&Path>) -> Opti
let label = match (&venv, source) {
(Some(home), _) => {
let dir = home.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
- // `.venv` inside a project reads better as the project name, like PyCharm.
+ // `.venv` inside a project reads better as the folder that holds it (the project,
+ // or a member such as `backend`), like PyCharm.
let owner = home.parent().and_then(|p| p.file_name()).map(|n| n.to_string_lossy().into_owned());
match (dir.starts_with('.') || dir == "venv" || dir == "env", owner, root) {
- (true, Some(owner), Some(root)) if home.parent() == Some(root) => format!("Python {short} ({owner})"),
+ (true, Some(owner), Some(root)) if home.starts_with(root) => format!("Python {short} ({owner})"),
_ => format!("Python {short} ({dir})"),
}
}
diff --git a/crates/ide-sdk/src/lib.rs b/crates/ide-sdk/src/lib.rs
index 35d524173..90c5cae24 100644
--- a/crates/ide-sdk/src/lib.rs
+++ b/crates/ide-sdk/src/lib.rs
@@ -28,7 +28,7 @@ use serde::{Deserialize, Serialize};
use specta::Type;
use std::path::{Path, PathBuf};
-pub use discover::{detect, jdk_sdk};
+pub use discover::{detect, jdk_sdk, python_interpreter_sdk};
/// What [`detect`] looks for.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
diff --git a/crates/ide-sdk/src/tests.rs b/crates/ide-sdk/src/tests.rs
index 7a8bd1033..84a45bb8d 100644
--- a/crates/ide-sdk/src/tests.rs
+++ b/crates/ide-sdk/src/tests.rs
@@ -76,6 +76,23 @@ fn project_venv_is_the_default_interpreter() {
assert_eq!(python_interpreter(tmp.path(), &s).unwrap(), Path::new("/opt/py/bin/python3"));
}
+#[test]
+fn an_interpreter_outside_detection_is_named_like_a_detected_one() {
+ let tmp = tempfile::tempdir().unwrap();
+ let root = tmp.path().join("shop");
+ let member = fake_venv(&root.join("backend"), ".venv");
+ let sdk = crate::python_interpreter_sdk(&member, Some(&root)).unwrap();
+ assert_eq!(
+ (sdk.name.as_str(), sdk.version.as_deref(), sdk.source.as_str()),
+ ("Python 3.12 (backend)", Some("3.12.1"), "venv")
+ );
+ let own = fake_venv(&root, ".venv");
+ assert_eq!(crate::python_interpreter_sdk(&own, Some(&root)).unwrap().name, "Python 3.12 (shop)");
+ // Outside the project the folder name says which one it is.
+ let elsewhere = fake_venv(&tmp.path().join("other"), ".venv");
+ assert_eq!(crate::python_interpreter_sdk(&elsewhere, Some(&root)).unwrap().name, "Python 3.12 (.venv)");
+}
+
#[test]
fn pyright_gets_python_path_and_venv_env() {
let tmp = tempfile::tempdir().unwrap();
diff --git a/crates/ide-terminal/src/lib.rs b/crates/ide-terminal/src/lib.rs
index f08b3a069..ebe53e311 100644
--- a/crates/ide-terminal/src/lib.rs
+++ b/crates/ide-terminal/src/lib.rs
@@ -60,6 +60,9 @@ struct Session {
writer: Mutex>,
killer: Mutex>,
exited: Arc,
+ started: Instant,
+ /// The first input was logged (startup diagnostics, see `spawn`).
+ input_logged: AtomicBool,
}
type Sessions = Arc>>>;
@@ -92,7 +95,8 @@ impl TerminalManager {
let size = pty_size(options.cols, options.rows);
let pair = native_pty_system().openpty(size).map_err(|e| IdeError::internal(format!("openpty failed: {e}")))?;
- let (shell, mut args) = resolve_shell(options.shell.as_deref(), &options.args);
+ let wsl = if cfg!(windows) { wsl_shell(&options) } else { None };
+ let (shell, mut args) = wsl.clone().unwrap_or_else(|| resolve_shell(options.shell.as_deref(), &options.args));
let mut env = terminal_env(&options.env);
if let Some(msystem) = default_msystem(&shell, &options.env, std::env::var("MSYSTEM").ok().as_deref()) {
env.insert("MSYSTEM".to_owned(), msystem.to_owned());
@@ -105,7 +109,8 @@ impl TerminalManager {
}
let mut cmd = CommandBuilder::new(&shell);
cmd.args(&args);
- cmd.cwd(resolve_cwd(&options.cwd));
+ // wsl.exe takes the directory through `--cd`; a UNC share is no valid process cwd.
+ cmd.cwd(if wsl.is_some() { resolve_cwd(Path::new("")) } else { resolve_cwd(&options.cwd) });
for (k, v) in ide_core::process::host_env_fixes() {
match v {
Some(v) => cmd.env(k, v),
@@ -136,25 +141,34 @@ impl TerminalManager {
writer: Mutex::new(writer),
killer: Mutex::new(child.clone_killer()),
exited: exited.clone(),
+ started: Instant::now(),
+ input_logged: AtomicBool::new(false),
});
self.sessions.lock().insert(id, session);
let (tx, rx) = mpsc::channel::();
let spawned = spawn_reader(id, reader, tx.clone())
.and_then(|_| spawn_waiter(id, child, tx))
- .and_then(|_| spawn_pump(id, rx, on_output, exited, self.sessions.clone(), sink));
+ .and_then(|_| spawn_pump(id, rx, on_output, exited, self.sessions.clone(), sink, Instant::now()));
if let Err(e) = spawned {
if let Some(s) = self.sessions.lock().remove(&id) {
let _ = s.killer.lock().kill();
}
return Err(IdeError::internal(format!("failed to start terminal threads: {e}")));
}
- tracing::debug!(%id, shell, pid = ?info.pid, "terminal spawned");
+ // Info level, with the first output and input below: a session that starts but never shows a
+ // prompt can then be told apart in a user's log (no output, or no reply to the shell's query).
+ tracing::info!(%id, shell, pid = ?info.pid, cols = size.cols, rows = size.rows, "terminal spawned");
Ok(info)
}
pub fn write(&self, id: TerminalId, data: &str) -> IdeResult<()> {
let session = self.session(id)?;
+ if !session.input_logged.swap(true, Ordering::Relaxed) {
+ let cursor_report = data.starts_with("\x1b[") && data.ends_with('R');
+ let ms = session.started.elapsed().as_millis();
+ tracing::info!(%id, ms, bytes = data.len(), cursor_report, "terminal first input");
+ }
let mut w = session.writer.lock();
w.write_all(data.as_bytes())?;
w.flush()?;
@@ -387,8 +401,19 @@ fn spawn_pump(
exited: Arc,
sessions: Sessions,
sink: SharedSink,
+ started: Instant,
) -> std::io::Result<()> {
std::thread::Builder::new().name(format!("openidea-term-pump-{}", id.0)).spawn(move || {
+ let first_output = std::cell::Cell::new(true);
+ let on_output = move |text: String| {
+ if first_output.replace(false) {
+ // ConPTY asks for the cursor position (`ESC[6n`) and waits for the answer.
+ let cursor_query = text.contains("\x1b[6n");
+ let ms = started.elapsed().as_millis();
+ tracing::info!(%id, ms, bytes = text.len(), cursor_query, "terminal first output");
+ }
+ on_output(text)
+ };
let mut decoder = Utf8Decoder::new();
let mut pending = String::new();
let mut batch_started: Option = None;
@@ -456,6 +481,16 @@ fn pty_size(cols: u16, rows: u16) -> PtySize {
}
}
+/// `wsl.exe` in the distro of a `\\wsl.localhost\...` folder, so its terminal is a Linux
+/// shell and not PowerShell (#925). An explicitly chosen shell or arguments win.
+fn wsl_shell(options: &TerminalOptions) -> Option<(String, Vec)> {
+ if options.shell.as_deref().is_some_and(|s| !s.trim().is_empty()) || !options.args.is_empty() {
+ return None;
+ }
+ let w = ide_core::wsl::parse(&options.cwd)?;
+ Some((ide_core::wsl::EXE.into(), vec!["-d".into(), w.distro, "--cd".into(), w.path]))
+}
+
fn resolve_cwd(cwd: &Path) -> PathBuf {
if cwd.is_dir() {
return cwd.to_path_buf();
@@ -504,7 +539,7 @@ pub fn available_shells() -> Vec {
}
#[cfg(windows)]
{
- for exe in ["pwsh.exe", "powershell.exe", "cmd.exe", "wsl.exe"] {
+ for exe in ["pwsh.exe", "powershell.exe", "cmd.exe", ide_core::wsl::EXE] {
if let Some(p) = find_in_path(exe) {
add(p);
}
@@ -859,6 +894,24 @@ mod tests {
assert!(h.events.try_recv().is_err() && other_events.try_recv().is_err(), "no cross delivery");
}
+ #[test]
+ fn wsl_folder_gets_the_distro_shell_unless_one_is_chosen() {
+ let mut o = sh("true");
+ o.shell = None;
+ o.args = vec![];
+ let (distro, linux_path) = ("Debian-12", "/srv/app");
+ let want = (ide_core::wsl::EXE.to_owned(), vec!["-d".into(), distro.into(), "--cd".into(), linux_path.into()]);
+ for host in ["wsl.localhost", "wsl$"] {
+ o.cwd = PathBuf::from(format!(r"\\{host}\{distro}{}", linux_path.replace('/', r"\"))).into();
+ assert_eq!(wsl_shell(&o), Some(want.clone()), "{host}");
+ }
+ o.shell = Some("pwsh.exe".into());
+ assert_eq!(wsl_shell(&o), None);
+ o.shell = None;
+ o.cwd = PathBuf::from("/tmp").into();
+ assert_eq!(wsl_shell(&o), None);
+ }
+
#[test]
fn shell_resolution() {
let (shell, args) = resolve_shell(Some("/bin/zsh"), &[]);
diff --git a/crates/ide-vcs/src/history.rs b/crates/ide-vcs/src/history.rs
index be0da59bf..14873f7b3 100644
--- a/crates/ide-vcs/src/history.rs
+++ b/crates/ide-vcs/src/history.rs
@@ -9,44 +9,88 @@ use std::collections::{HashMap, HashSet};
/// Log "Text or hash" filter: the message (substring or regex), or a hash prefix.
enum TextFilter {
- Plain { needle: String, match_case: bool },
+ /// Commits `git log --grep` selected, or whose id starts with `hash`.
+ Selected {
+ ids: HashSet,
+ hash: Option,
+ },
+ /// Case-insensitive non-ASCII text: git folds case only for ASCII under the C locale.
+ Folded(String),
Regex(regex_lite::Regex),
}
impl TextFilter {
- fn new(text: &str, regex: bool, match_case: bool) -> IdeResult> {
+ fn new(
+ r: &Repo,
+ scopes: &[String],
+ q: &LogQuery,
+ text: &str,
+ cancel: &CancellationToken,
+ ) -> IdeResult > {
let text = text.trim();
if text.is_empty() {
return Ok(None);
}
- if regex {
+ if q.text_regex {
let re = regex_lite::RegexBuilder::new(text)
- .case_insensitive(!match_case)
+ .case_insensitive(!q.text_match_case)
.multi_line(true)
.build()
.map_err(|e| IdeError::invalid(format!("invalid regular expression: {e}")))?;
return Ok(Some(Self::Regex(re)));
}
- let needle = if match_case { text.to_string() } else { text.to_lowercase() };
- Ok(Some(Self::Plain { needle, match_case }))
+ if !q.text_match_case && !text.is_ascii() {
+ return Ok(Some(Self::Folded(text.to_lowercase())));
+ }
+ let mut filter = vec!["--fixed-strings".to_string(), format!("--grep={text}")];
+ if !q.text_match_case {
+ filter.push("--regexp-ignore-case".into());
+ }
+ let ids = git_select(r, scopes, &filter, &[], q.first_parent, cancel)?;
+ let hash = (text.len() >= 4 && text.bytes().all(|b| b.is_ascii_hexdigit())).then(|| text.to_ascii_lowercase());
+ Ok(Some(Self::Selected { ids, hash }))
}
- fn matches(&self, message: &str, id: &ObjectId) -> bool {
- match self {
- Self::Regex(re) => re.is_match(message),
- Self::Plain { needle, match_case } => {
- let hash = needle.len() >= 4 && needle.bytes().all(|b| b.is_ascii_hexdigit());
- if hash && id.to_string().starts_with(&needle.to_ascii_lowercase()) {
- return true;
- }
- if *match_case {
- message.contains(needle.as_str())
- } else {
- message.to_lowercase().contains(needle.as_str())
- }
+ fn matches(&self, repo: &gix::Repository, index: &mut crate::log_index::LogIndex, id: ObjectId) -> IdeResult {
+ Ok(match self {
+ Self::Regex(re) => re.is_match(&index.message(repo, id)?),
+ Self::Folded(needle) => index.message(repo, id)?.to_lowercase().contains(needle.as_str()),
+ Self::Selected { ids, hash } => {
+ ids.contains(&id) || hash.as_ref().is_some_and(|h| id.to_string().starts_with(h.as_str()))
}
+ })
+ }
+}
+
+/// Commits git selects from each scope with `filter`. Path limiting uses git's history
+/// simplification and, when the repository has them, changed-path Bloom filters.
+fn git_select(
+ r: &Repo,
+ scopes: &[String],
+ filter: &[String],
+ paths: &[String],
+ first_parent: bool,
+ cancel: &CancellationToken,
+) -> IdeResult> {
+ let mut ids = HashSet::new();
+ for scope in scopes {
+ let mut args =
+ vec!["log".to_string(), "--format=%H".into(), "--no-show-signature".into(), "--no-follow".into()];
+ if first_parent {
+ args.push("--first-parent".into());
+ }
+ args.extend(filter.iter().cloned());
+ args.push("--end-of-options".into());
+ args.extend(scope.split_whitespace().map(str::to_string));
+ args.push("--".into());
+ args.extend(paths.iter().map(|p| format!(":(literal){p}")));
+ let output =
+ crate::cli::run_cancellable(r.root(), &args.iter().map(String::as_str).collect::>(), cancel)?;
+ for line in output.stdout_str().lines() {
+ ids.insert(ObjectId::from_hex(line.as_bytes()).map_err(gx)?);
}
}
+ Ok(ids)
}
pub(crate) fn log(r: &Repo, q: &LogQuery, cancel: &CancellationToken) -> IdeResult> {
@@ -173,87 +217,71 @@ fn log_indexed(
} else {
None
};
+ let scopes = if let Some(range) = range {
+ vec![range.to_string()]
+ } else {
+ let mut scopes = ranges.iter().map(|s| s.to_string()).collect::>();
+ if !tips.is_empty() {
+ scopes.push(tips.iter().map(ToString::to_string).collect::