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: + + WeChat group QR code + ## 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 + jdk21true + 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", + "demomain1.0pomcore", + ); + write( + root, + "core/pom.xml", + "democore1.0", + ); + write( + root, + "stub/pom.xml", + "demostub1.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!(""))?; + 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**/* + extrastatic + + + + "#, + ) + .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() { - -