mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-08 08:08:16 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ec4032cf7 |
No files matched your search
+15
-17
@@ -15,8 +15,6 @@ concurrency:
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
# Line tables keep test backtraces readable; full debuginfo only adds build time and cache size.
|
||||
CARGO_PROFILE_DEV_DEBUG: line-tables-only
|
||||
|
||||
jobs:
|
||||
rust:
|
||||
@@ -27,10 +25,10 @@ jobs:
|
||||
os: [macos-latest, ubuntu-22.04, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: pnpm/action-setup@v5
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with: { version: 11 }
|
||||
- uses: actions/setup-node@v5
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 22, cache: pnpm }
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with: { components: "clippy, rustfmt" }
|
||||
@@ -64,7 +62,7 @@ jobs:
|
||||
name: Remote server (linux musl)
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with: { targets: x86_64-unknown-linux-musl }
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
@@ -81,7 +79,7 @@ jobs:
|
||||
env:
|
||||
CARGO_TARGET_DIR: ${{ github.workspace }}/target-plugins
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with: { targets: wasm32-wasip2, components: "clippy, rustfmt" }
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
@@ -102,9 +100,9 @@ jobs:
|
||||
cargo test --locked --manifest-path "$dir/Cargo.toml"
|
||||
echo "::endgroup::"
|
||||
done
|
||||
- uses: pnpm/action-setup@v5
|
||||
- uses: pnpm/action-setup@v4
|
||||
with: { version: 11 }
|
||||
- uses: actions/setup-node@v5
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 22 }
|
||||
# Plugin web pages (`web-src/`, their own pnpm projects): typecheck and test.
|
||||
- name: Web pages typecheck and test
|
||||
@@ -128,7 +126,7 @@ jobs:
|
||||
name: Cargo deny
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: taiki-e/install-action@cargo-deny
|
||||
- run: cargo deny check
|
||||
@@ -137,10 +135,10 @@ jobs:
|
||||
name: Frontend
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: pnpm/action-setup@v5
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with: { version: 11 }
|
||||
- uses: actions/setup-node@v5
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 22, cache: pnpm }
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm typecheck
|
||||
@@ -156,16 +154,16 @@ jobs:
|
||||
# Browsers and their system libraries are preinstalled; keep the tag in step with @playwright/test.
|
||||
container: mcr.microsoft.com/playwright:v1.64.1-noble
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: pnpm/action-setup@v5
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with: { version: 11 }
|
||||
- uses: actions/setup-node@v5
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 22, cache: pnpm }
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm e2e
|
||||
env:
|
||||
HOME: /root
|
||||
- uses: actions/upload-artifact@v6
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
with:
|
||||
name: playwright-report
|
||||
|
||||
@@ -2,12 +2,8 @@ name: Language smoke
|
||||
# Everyday code intelligence (completion, go to definition, Find Usages, hover, diagnostics
|
||||
# with a quick fix, an intention, formatting, rename) per language, against the language
|
||||
# server the app starts by default: crates/ide-host/tests/language_smoke.rs.
|
||||
# Opening the fixture with its build tool and SDK (Maven / Gradle sync, the virtualenv, the
|
||||
# JDK): the ignored tests of crates/ide-host/tests/project_smoke.rs.
|
||||
# Then running and testing each fixture through the run store and openidea-server
|
||||
# (Run, Run tests, the test tree, Rerun Failed Tests): smoke/run.test.ts; and debugging its
|
||||
# entry point with the debugger the IDE installs (breakpoint, frames, variables, evaluate,
|
||||
# Step Over, Resume): smoke/debug.test.ts.
|
||||
# (Run, Run tests, the test tree, Rerun Failed Tests): smoke/run.test.ts.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
@@ -21,7 +17,6 @@ on:
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- "src/features/run/**"
|
||||
- "src/features/debugger/**"
|
||||
- "src/platform/**"
|
||||
- "smoke/**"
|
||||
- "package.json"
|
||||
@@ -109,16 +104,7 @@ jobs:
|
||||
|
||||
- run: cargo test --locked -p ide-host --test language_smoke --no-run
|
||||
- run: cargo test --locked -p ide-host --test language_smoke -- --ignored --exact ${{ matrix.language }}
|
||||
- if: matrix.language == 'java'
|
||||
run: cargo test --locked -p ide-host --test project_smoke -- --ignored --exact maven_sync jdk
|
||||
- if: matrix.language == 'kotlin'
|
||||
run: cargo test --locked -p ide-host --test project_smoke -- --ignored --exact gradle_sync
|
||||
- if: matrix.language == 'python'
|
||||
run: cargo test --locked -p ide-host --test project_smoke -- --ignored --exact python_venv
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: cargo build --locked -p ide-host --bin openidea-server
|
||||
- run: pnpm smoke smoke/run.test.ts -t "^${{ matrix.language }}$"
|
||||
- run: pnpm smoke smoke/debug.test.ts -t "^${{ matrix.language }}$"
|
||||
# The IDE installs the debugger from GitHub releases: authenticated, against rate limits.
|
||||
env: { GITHUB_TOKEN: "${{ github.token }}" }
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
if: github.event.pull_request.user.type != 'Bot' && !github.event.pull_request.draft
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/github-script@v8
|
||||
- uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const pr = context.payload.pull_request;
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
release_id: ${{ steps.release.outputs.id }}
|
||||
official_plugin_keys: ${{ steps.plugin-keys.outputs.keys }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v4
|
||||
with: { ref: "${{ env.TAG }}" }
|
||||
- name: Tag matches app version
|
||||
run: |
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
echo "keys=$key" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- id: release
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const tag = process.env.TAG;
|
||||
@@ -99,11 +99,11 @@ jobs:
|
||||
target: x86_64-pc-windows-msvc
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v4
|
||||
with: { ref: "${{ env.TAG }}" }
|
||||
- uses: pnpm/action-setup@v5
|
||||
- uses: pnpm/action-setup@v4
|
||||
with: { version: 11 }
|
||||
- uses: actions/setup-node@v5
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 22, cache: pnpm }
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with: { targets: "${{ matrix.target }}" }
|
||||
@@ -173,7 +173,7 @@ jobs:
|
||||
- { runner: macos-latest, target: x86_64-apple-darwin, os: macos, arch: x86_64 }
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v4
|
||||
with: { ref: "${{ env.TAG }}" }
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with: { targets: "${{ matrix.target }}" }
|
||||
@@ -220,7 +220,7 @@ jobs:
|
||||
echo "::warning::PLUGIN_SIGNING_KEY is not set: the official plugins are not published"
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v4
|
||||
if: steps.key.outputs.skip != 'true'
|
||||
with: { ref: "${{ env.TAG }}" }
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
@@ -229,10 +229,10 @@ jobs:
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
if: steps.key.outputs.skip != 'true'
|
||||
with: { key: release-plugins }
|
||||
- uses: pnpm/action-setup@v5
|
||||
- uses: pnpm/action-setup@v4
|
||||
if: steps.key.outputs.skip != 'true'
|
||||
with: { version: 11 }
|
||||
- uses: actions/setup-node@v5
|
||||
- uses: actions/setup-node@v4
|
||||
if: steps.key.outputs.skip != 'true'
|
||||
with: { node-version: 22 }
|
||||
# Builds the web pages; fails when a checked-in component isn't built from these sources.
|
||||
@@ -277,7 +277,7 @@ jobs:
|
||||
needs: [create-release, build]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/github-script@v8
|
||||
- uses: actions/github-script@v7
|
||||
env:
|
||||
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
|
||||
with:
|
||||
@@ -337,7 +337,7 @@ jobs:
|
||||
needs: [create-release, build, server, plugins, updater-manifest]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/github-script@v8
|
||||
- uses: actions/github-script@v7
|
||||
env:
|
||||
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
|
||||
with:
|
||||
|
||||
@@ -71,9 +71,6 @@ Shared agent skills live in `.agents/skills/` (`.claude/skills/` links to them):
|
||||
on `fixtures/` with that language's real server installed (CI: `Language smoke`, pinned versions).
|
||||
- `pnpm smoke smoke/run.test.ts -t <language>` — run and test that language's fixture through the
|
||||
run store and a real `openidea-server` (build it first: `cargo build -p ide-host --bin openidea-server`).
|
||||
- `pnpm smoke smoke/debug.test.ts -t <language>` — debug that fixture's entry point the same way, with
|
||||
the debugger the IDE installs (on macOS, CodeLLDB and Delve may need Developer Mode:
|
||||
`sudo DevToolsSecurity -enable`).
|
||||
- `pnpm test`, `pnpm e2e`, `pnpm lint`, `pnpm format:check`, `pnpm knip`, `pnpm typecheck`,
|
||||
`pnpm gen:bindings`.
|
||||
- `pnpm perf` (UI interaction latency / frames / leaks), `pnpm perf:rust` (criterion
|
||||
@@ -121,8 +118,8 @@ Several worktrees can be developed and run side by side.
|
||||
default MCP session belongs to this worktree.
|
||||
- Keep each worktree's Cargo output directory separate; do not point `CARGO_TARGET_DIR` or
|
||||
`target` symlinks at a shared directory.
|
||||
- Each worktree's first build is cold (several minutes) and `cargo build` output takes
|
||||
around 9 GB; tests, clippy and incremental builds add more. Avoid running several heavy builds on one machine at the same time.
|
||||
- Each worktree's first build is cold (around 5 minutes) and its Cargo output takes
|
||||
15–30 GB. Avoid running several heavy builds on one machine at the same time.
|
||||
- Stop only processes started for the current worktree/session. Never use broad
|
||||
process-name kills such as `pkill openidea`.
|
||||
- When done: stop that worktree's app and dev session, delete its build output, then
|
||||
|
||||
Generated
+71
-358
@@ -123,17 +123,6 @@ version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "apple-native-keyring-store"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b350bfd03649e07aa05c0a81b3e15934374e585c98204a57e20b9d49f49bb9a"
|
||||
dependencies = [
|
||||
"keyring-core",
|
||||
"log",
|
||||
"security-framework",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arbitrary"
|
||||
version = "1.5.0"
|
||||
@@ -167,27 +156,6 @@ version = "0.7.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
|
||||
|
||||
[[package]]
|
||||
name = "ashpd"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39"
|
||||
dependencies = [
|
||||
"enumflags2",
|
||||
"futures-channel",
|
||||
"futures-util",
|
||||
"rand 0.9.5",
|
||||
"raw-window-handle",
|
||||
"serde",
|
||||
"serde_repr",
|
||||
"tokio",
|
||||
"url",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-protocols",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ast-grep-core"
|
||||
version = "0.40.1"
|
||||
@@ -1496,7 +1464,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1632,7 +1600,6 @@ dependencies = [
|
||||
"lock_api",
|
||||
"once_cell",
|
||||
"parking_lot_core",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1828,15 +1795,6 @@ dependencies = [
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dlib"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a"
|
||||
dependencies = [
|
||||
"libloading",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dlopen2"
|
||||
version = "0.8.2"
|
||||
@@ -2205,6 +2163,15 @@ dependencies = [
|
||||
"rustc_version",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "file-id"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e1fc6a637b6dc58414714eddd9170ff187ecb0933d4c7024d1abbd23a3cc26e9"
|
||||
dependencies = [
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filedescriptor"
|
||||
version = "0.8.3"
|
||||
@@ -3001,7 +2968,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c48eccd894bd15555b0346470e63491ded94aefe76b3fcb1052084b5a393238"
|
||||
dependencies = [
|
||||
"bstr",
|
||||
"hashbrown 0.16.1",
|
||||
"hashbrown 0.17.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4071,7 +4038,7 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "ide-agent"
|
||||
version = "0.1.37"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4094,7 +4061,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-analysis"
|
||||
version = "0.1.37"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"ast-grep-core",
|
||||
"ide-core",
|
||||
@@ -4106,20 +4073,9 @@ 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.37"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"dunce",
|
||||
@@ -4134,7 +4090,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-clipboard"
|
||||
version = "0.1.37"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
@@ -4143,7 +4099,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-core"
|
||||
version = "0.1.37"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4160,11 +4116,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-coverage"
|
||||
version = "0.1.37"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-run",
|
||||
"quick-xml 0.42.0",
|
||||
"quick-xml",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
@@ -4174,7 +4130,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-dap"
|
||||
version = "0.1.37"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-jsonrpc",
|
||||
@@ -4192,7 +4148,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-db"
|
||||
version = "0.1.37"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"bb8",
|
||||
"dirs 6.0.0",
|
||||
@@ -4218,7 +4174,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-docker"
|
||||
version = "0.1.37"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"serde",
|
||||
@@ -4231,7 +4187,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-extension"
|
||||
version = "0.1.37"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"blake3",
|
||||
@@ -4251,7 +4207,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-fonts"
|
||||
version = "0.1.37"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-text",
|
||||
@@ -4263,7 +4219,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-github"
|
||||
version = "0.1.37"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-vcs",
|
||||
@@ -4276,7 +4232,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-host"
|
||||
version = "0.1.37"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"criterion",
|
||||
@@ -4316,7 +4272,7 @@ dependencies = [
|
||||
"ide-workspace",
|
||||
"ignore",
|
||||
"parking_lot",
|
||||
"quick-xml 0.42.0",
|
||||
"quick-xml",
|
||||
"regex-syntax",
|
||||
"reqwest",
|
||||
"rustix",
|
||||
@@ -4337,7 +4293,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-http"
|
||||
version = "0.1.37"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"parking_lot",
|
||||
@@ -4355,7 +4311,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-index"
|
||||
version = "0.1.37"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"criterion",
|
||||
@@ -4377,7 +4333,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-jsonrpc"
|
||||
version = "0.1.37"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"futures",
|
||||
@@ -4394,7 +4350,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-language"
|
||||
version = "0.1.37"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"criterion",
|
||||
@@ -4450,7 +4406,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-lsp"
|
||||
version = "0.1.37"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"futures",
|
||||
@@ -4476,7 +4432,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-notebook"
|
||||
version = "0.1.37"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"ide-core",
|
||||
@@ -4499,7 +4455,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-plugin"
|
||||
version = "0.1.37"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"ide-core",
|
||||
@@ -4515,12 +4471,11 @@ dependencies = [
|
||||
"tracing",
|
||||
"wasmtime",
|
||||
"wasmtime-wasi",
|
||||
"wat",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-profiler"
|
||||
version = "0.1.37"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-run",
|
||||
@@ -4533,7 +4488,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-remote"
|
||||
version = "0.1.37"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"globset",
|
||||
"ide-core",
|
||||
@@ -4550,7 +4505,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-run"
|
||||
version = "0.1.37"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4559,7 +4514,7 @@ dependencies = [
|
||||
"libc",
|
||||
"parking_lot",
|
||||
"portable-pty",
|
||||
"quick-xml 0.42.0",
|
||||
"quick-xml",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
@@ -4573,7 +4528,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-sdk"
|
||||
version = "0.1.37"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"ide-core",
|
||||
@@ -4592,7 +4547,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-search"
|
||||
version = "0.1.37"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"globset",
|
||||
@@ -4612,7 +4567,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-spell"
|
||||
version = "0.1.37"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"parking_lot",
|
||||
@@ -4625,13 +4580,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-ssh"
|
||||
version = "0.1.37"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"flate2",
|
||||
"ide-core",
|
||||
"keyring",
|
||||
"keyring-core",
|
||||
"parking_lot",
|
||||
"russh",
|
||||
"russh-config",
|
||||
@@ -4642,12 +4595,11 @@ dependencies = [
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-syntax"
|
||||
version = "0.1.37"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-language",
|
||||
@@ -4662,7 +4614,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-terminal"
|
||||
version = "0.1.37"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"libc",
|
||||
@@ -4675,20 +4627,19 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-text"
|
||||
version = "0.1.37"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"ide-core",
|
||||
"proptest",
|
||||
"ropey",
|
||||
"serde",
|
||||
"similar",
|
||||
"specta",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-tools"
|
||||
version = "0.1.37"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"hex",
|
||||
@@ -4709,7 +4660,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-vcs"
|
||||
version = "0.1.37"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dunce",
|
||||
@@ -4730,7 +4681,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-vfs"
|
||||
version = "0.1.37"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"chardetng",
|
||||
"dunce",
|
||||
@@ -4739,7 +4690,7 @@ dependencies = [
|
||||
"globset",
|
||||
"ide-core",
|
||||
"ignore",
|
||||
"notify",
|
||||
"notify-debouncer-full",
|
||||
"parking_lot",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4752,7 +4703,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-web"
|
||||
version = "0.1.37"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"open",
|
||||
@@ -4768,7 +4719,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-workspace"
|
||||
version = "0.1.37"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dunce",
|
||||
@@ -5264,33 +5215,6 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "keyring"
|
||||
version = "4.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2270074a3d26bcac93c1dc5d2845eb4c089e8d761ccf6e0ea266a16004640627"
|
||||
dependencies = [
|
||||
"apple-native-keyring-store",
|
||||
"keyring-core",
|
||||
"windows-native-keyring-store",
|
||||
"zbus-secret-service-keyring-store",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "keyring-core"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29fbf4b551298c67925f38cf341a1ada797a75f1837bdf8ae1fc8e2ebc6efb59"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dashmap",
|
||||
"log",
|
||||
"regex",
|
||||
"ron",
|
||||
"serde",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kqueue"
|
||||
version = "1.2.1"
|
||||
@@ -5713,6 +5637,19 @@ dependencies = [
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "notify-debouncer-full"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "375bd3a138be7bfeff3480e4a623df4cbfb55b79df617c055cd810ba466fa078"
|
||||
dependencies = [
|
||||
"file-id",
|
||||
"log",
|
||||
"notify",
|
||||
"notify-types",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "notify-types"
|
||||
version = "2.1.0"
|
||||
@@ -5741,30 +5678,6 @@ dependencies = [
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
|
||||
dependencies = [
|
||||
"num-bigint 0.4.8",
|
||||
"num-complex",
|
||||
"num-integer",
|
||||
"num-iter",
|
||||
"num-rational",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.4.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.5.1"
|
||||
@@ -5777,15 +5690,6 @@ dependencies = [
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-complex"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.2"
|
||||
@@ -5801,27 +5705,6 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-iter"
|
||||
version = "0.1.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-rational"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
|
||||
dependencies = [
|
||||
"num-bigint 0.4.8",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
@@ -6309,7 +6192,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openidea"
|
||||
version = "0.1.37"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"blake3",
|
||||
@@ -6319,7 +6202,6 @@ dependencies = [
|
||||
"gtk",
|
||||
"ide-agent",
|
||||
"ide-analysis",
|
||||
"ide-android",
|
||||
"ide-build",
|
||||
"ide-clipboard",
|
||||
"ide-core",
|
||||
@@ -6374,7 +6256,6 @@ dependencies = [
|
||||
"tauri-plugin-updater",
|
||||
"tauri-plugin-window-state",
|
||||
"tauri-specta",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-appender",
|
||||
@@ -6712,7 +6593,7 @@ checksum = "65dca778f41c79ebbe8b2b2139fee86f01b766b67130d108c7c2b8ffbd949f77"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"indexmap 2.14.2",
|
||||
"quick-xml 0.42.0",
|
||||
"quick-xml",
|
||||
"serde",
|
||||
"time",
|
||||
]
|
||||
@@ -6757,12 +6638,6 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pollster"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3"
|
||||
|
||||
[[package]]
|
||||
name = "poly1305"
|
||||
version = "0.9.1"
|
||||
@@ -7023,15 +6898,6 @@ version = "1.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.41.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.42.0"
|
||||
@@ -7309,18 +7175,18 @@ version = "0.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
|
||||
dependencies = [
|
||||
"ashpd",
|
||||
"block2 0.6.2",
|
||||
"dispatch2",
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"gtk-sys",
|
||||
"js-sys",
|
||||
"log",
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation 0.3.2",
|
||||
"pollster",
|
||||
"raw-window-handle",
|
||||
"urlencoding",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
@@ -7341,19 +7207,6 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ron"
|
||||
version = "0.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "97b3fe97c18ff24299ea56f802c5693eb34266fb0bc8317f4e101debc8a3ceaf"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"typeid",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ropey"
|
||||
version = "1.6.1"
|
||||
@@ -7420,7 +7273,7 @@ dependencies = [
|
||||
"md5",
|
||||
"ml-kem",
|
||||
"module-lattice",
|
||||
"num-bigint 0.5.1",
|
||||
"num-bigint",
|
||||
"p256",
|
||||
"p384",
|
||||
"p521",
|
||||
@@ -7751,12 +7604,6 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scoped-tls"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"
|
||||
|
||||
[[package]]
|
||||
name = "scopeguard"
|
||||
version = "1.2.0"
|
||||
@@ -7807,25 +7654,6 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "secret-service"
|
||||
version = "5.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5107b24b91445dd2aa449a258a1807b63240942157292354dc5bfdbeb8bc6db8"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"cbc",
|
||||
"futures-util",
|
||||
"getrandom 0.4.3",
|
||||
"hkdf",
|
||||
"hybrid-array",
|
||||
"num",
|
||||
"once_cell",
|
||||
"serde",
|
||||
"sha2 0.11.1",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
@@ -8376,7 +8204,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d204abcbdf8e88729306a8d0ca01a79d8a49969fe1f84696909d6dbc4321c1a"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
"hashbrown 0.16.1",
|
||||
"hashbrown 0.17.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -9314,7 +9142,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.3",
|
||||
"getrandom 0.3.4",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
@@ -9502,7 +9330,6 @@ dependencies = [
|
||||
"signal-hook-registry",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
"tracing",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
@@ -10319,12 +10146,6 @@ 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"
|
||||
@@ -10845,88 +10666,6 @@ 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"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"downcast-rs",
|
||||
"rustix",
|
||||
"scoped-tls",
|
||||
"smallvec",
|
||||
"wayland-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-client"
|
||||
version = "0.31.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"rustix",
|
||||
"wayland-backend",
|
||||
"wayland-scanner",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-protocols"
|
||||
version = "0.32.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-scanner",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-scanner"
|
||||
version = "0.31.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quick-xml 0.41.0",
|
||||
"quote",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-sys"
|
||||
version = "0.31.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be"
|
||||
dependencies = [
|
||||
"dlib",
|
||||
"log",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.106"
|
||||
@@ -11212,19 +10951,6 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-native-keyring-store"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "063426e76fdec7438d56bb777f67e318a84a25c707b07e575cb8b78e10c028f8"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"keyring-core",
|
||||
"regex",
|
||||
"windows-sys 0.61.2",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-numerics"
|
||||
version = "0.3.1"
|
||||
@@ -11751,7 +11477,6 @@ dependencies = [
|
||||
"rustix",
|
||||
"serde",
|
||||
"serde_repr",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"uds_windows",
|
||||
"uuid",
|
||||
@@ -11762,17 +11487,6 @@ dependencies = [
|
||||
"zvariant",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus-secret-service-keyring-store"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "74801d001b9e7729adb4f1825b67b398185fed424749aa3d8bacf70417137d9a"
|
||||
dependencies = [
|
||||
"keyring-core",
|
||||
"secret-service",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus_macros"
|
||||
version = "5.19.0"
|
||||
@@ -11978,7 +11692,6 @@ dependencies = [
|
||||
"endi",
|
||||
"enumflags2",
|
||||
"serde",
|
||||
"url",
|
||||
"winnow 1.0.4",
|
||||
"zcheapstr",
|
||||
"zvariant_derive",
|
||||
|
||||
+2
-10
@@ -5,7 +5,7 @@ members = ["crates/*", "src-tauri"]
|
||||
exclude = ["sdk", "plugins"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.37"
|
||||
version = "0.1.36"
|
||||
edition = "2024"
|
||||
rust-version = "1.96"
|
||||
license = "Apache-2.0"
|
||||
@@ -76,6 +76,7 @@ ignore = "0.4"
|
||||
base64 = "0.22"
|
||||
globset = "0.4"
|
||||
notify = "8"
|
||||
notify-debouncer-full = "0.6"
|
||||
trash = "5"
|
||||
blake3 = "1"
|
||||
dunce = "1"
|
||||
@@ -105,9 +106,6 @@ sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio",
|
||||
tiberius = { version = "0.13", default-features = false, features = ["tds73", "rustls", "sql-browser-tokio"] }
|
||||
bb8 = "0.9"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
# OS credential stores
|
||||
keyring = "4.2"
|
||||
keyring-core = "1"
|
||||
|
||||
# testing
|
||||
insta = { version = "1", features = ["yaml"] }
|
||||
@@ -117,14 +115,8 @@ tempfile = "3"
|
||||
[profile.dev]
|
||||
# Keep debug builds snappy while giving dependencies decent speed.
|
||||
opt-level = 0
|
||||
# Full debug info is most of target/. Line tables keep file:line in our backtraces, but a
|
||||
# debugger can't show locals; re-enable with `--config profile.dev.debug=true`.
|
||||
debug = "line-tables-only"
|
||||
[profile.dev.package."*"]
|
||||
opt-level = 2
|
||||
# Dependency frames keep function names only. Re-enable one with
|
||||
# `--config 'profile.dev.package.<crate>.debug=true'`.
|
||||
debug = false
|
||||
|
||||
[profile.release]
|
||||
codegen-units = 1
|
||||
|
||||
@@ -76,8 +76,6 @@ pub struct RegistryAgent {
|
||||
pub installed: Option<String>,
|
||||
/// 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)]
|
||||
@@ -334,7 +332,6 @@ fn visible(
|
||||
distribution,
|
||||
no_checksum,
|
||||
installed,
|
||||
installing: false,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
[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
|
||||
@@ -1,222 +0,0 @@
|
||||
//! 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<PathBuf> {
|
||||
let mut roots: Vec<PathBuf> = ["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<PathBuf> {
|
||||
static ADB: OnceLock<Option<PathBuf>> = 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<PathBuf> = 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<PathBuf> {
|
||||
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<Option<PathBuf>> = 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<Vec<AndroidDevice>> {
|
||||
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<u32>) -> 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 <count>` 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<LogcatLine>),
|
||||
) -> 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,276 +0,0 @@
|
||||
//! 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<String>,
|
||||
pub product: Option<String>,
|
||||
/// The device's own name from the build (`ro.product.device`).
|
||||
pub device: Option<String>,
|
||||
pub transport_id: Option<String>,
|
||||
}
|
||||
|
||||
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<AndroidDevice> {
|
||||
let mut devices: Vec<AndroidDevice> = 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<LogcatLine> {
|
||||
// "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);
|
||||
}
|
||||
}
|
||||
@@ -1,206 +0,0 @@
|
||||
//! 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<Child> {
|
||||
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<String> {
|
||||
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<Output> {
|
||||
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<Event>, stdout: bool) {
|
||||
std::thread::spawn(move || {
|
||||
let mut buf = [0u8; 8192];
|
||||
let mut pending: Vec<u8> = Vec::new();
|
||||
let flush = |pending: &mut Vec<u8>| {
|
||||
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<T>(batch: &mut Vec<T>, on_batch: &mut dyn FnMut(Vec<T>)) {
|
||||
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<T>(
|
||||
mut cmd: Command,
|
||||
cancel: &CancellationToken,
|
||||
idle: Duration,
|
||||
max: usize,
|
||||
mut parse: impl FnMut(&str) -> Option<T>,
|
||||
on_batch: &mut dyn FnMut(Vec<T>),
|
||||
) -> IdeResult<(bool, Vec<String>)> {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
prepare(&mut cmd);
|
||||
let mut child = spawn(&mut cmd)?;
|
||||
let (tx, rx) = mpsc::channel::<Event>();
|
||||
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<String> = Vec::new();
|
||||
let mut batch: Vec<T> = 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::<usize>(), 20);
|
||||
assert!(sizes.len() >= 3, "lines arriving every 20 ms waited for the end: {sizes:?}");
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
//! 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=<serial> 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<String> {
|
||||
if let Ok(serial) = std::env::var("OPENIDEA_ADB_DEVICE") {
|
||||
return Some(serial);
|
||||
}
|
||||
let online = devices().ok()?.into_iter().filter(AndroidDevice::is_online).collect::<Vec<_>>();
|
||||
(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::<LogcatLine>::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");
|
||||
}
|
||||
@@ -40,7 +40,7 @@ pub fn plan(root: &Path, model: &BuildModel, module: &Path, cache: &Path) -> Ide
|
||||
kind: "maven",
|
||||
});
|
||||
}
|
||||
if let Some(g) = model.gradle_for_module(root, &module)
|
||||
if let Some(g) = &model.gradle
|
||||
&& 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 = vec![crate::GradleModel {
|
||||
model.gradle = Some(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");
|
||||
|
||||
@@ -147,9 +147,7 @@ fn detect_dir(dir: &Path, model: &BuildModel, root: &Path) -> Option<BuildSystem
|
||||
let rel = rel_str(root, dir);
|
||||
if model.maven.as_ref().is_some_and(|m| m.projects.iter().any(|p| p.dir == rel)) || has(dir, &["pom.xml"]) {
|
||||
Some(BuildSystem::Maven)
|
||||
} else if model.gradle_for_module(root, dir).is_some()
|
||||
|| has(dir, &["build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts"])
|
||||
{
|
||||
} else if has(dir, &["build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts"]) {
|
||||
Some(BuildSystem::Gradle)
|
||||
} else if has(dir, &["Cargo.toml"]) {
|
||||
Some(BuildSystem::Cargo)
|
||||
@@ -164,13 +162,16 @@ fn detect_dir(dir: &Path, model: &BuildModel, root: &Path) -> Option<BuildSystem
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves a workspace-relative or absolute directory with native path separators.
|
||||
/// `root/rel`, without a trailing separator for the root itself.
|
||||
/// `root` / `rel`, joined per `/`-separated component: on Windows a single join of
|
||||
/// `a/b` keeps the `/`, and such a path does not compare equal to the same directory
|
||||
/// as other tools (JDT LS) spell it.
|
||||
fn subdir(root: &Path, rel: &str) -> PathBuf {
|
||||
root.join(rel).components().collect()
|
||||
rel.split('/').filter(|part| !part.is_empty()).fold(root.to_path_buf(), |path, part| path.join(part))
|
||||
}
|
||||
|
||||
fn rel_str(root: &Path, dir: &Path) -> String {
|
||||
dir.strip_prefix(root).unwrap_or(dir).to_string_lossy().replace('\\', "/")
|
||||
dir.strip_prefix(root).map(|r| r.to_string_lossy().replace('\\', "/")).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Directories from `file`'s folder up to `root` (inclusive).
|
||||
@@ -191,14 +192,7 @@ 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 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)
|
||||
let dir = ancestors_within(root, 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())
|
||||
@@ -208,7 +202,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((!model.gradle.is_empty()).then_some(BuildSystem::Gradle))
|
||||
.or_else(|| if model.gradle.is_some() { Some(BuildSystem::Gradle) } else { None })
|
||||
.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")
|
||||
@@ -276,16 +270,14 @@ fn maven_steps(root: &Path, model: &BuildModel, kind: BuildKind, module: Option<
|
||||
}
|
||||
|
||||
fn gradle_steps(root: &Path, model: &BuildModel, kind: BuildKind, module: Option<&Path>) -> IdeResult<Vec<BuildStep>> {
|
||||
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<BuildStep> {
|
||||
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
|
||||
}
|
||||
};
|
||||
let prefix = module
|
||||
.and_then(|m| {
|
||||
let rel = rel_str(root, m);
|
||||
@@ -304,7 +296,7 @@ fn gradle_step(root: &Path, g: &crate::GradleModel, kind: BuildKind, module: Opt
|
||||
let mut s = BuildStep::new(&g.command, &["--console=plain"], &subdir(root, &g.dir), Format::Gradle);
|
||||
s.args.extend(tasks);
|
||||
s.env = g.env.clone();
|
||||
vec![s]
|
||||
Ok(vec![s])
|
||||
}
|
||||
|
||||
fn cargo_steps(dir: &Path, kind: BuildKind) -> Vec<BuildStep> {
|
||||
@@ -819,79 +811,6 @@ fn launch_class_dirs(system: BuildSystem, module: &Path, test: bool) -> Vec<Path
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn independent_gradle_builds_keep_their_launchers_and_task_paths() {
|
||||
let wrapper = if cfg!(windows) { "gradlew.bat" } else { "gradlew" };
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path();
|
||||
for dir in ["a", "b"] {
|
||||
std::fs::create_dir_all(root.join(dir).join("app/src/main/java")).unwrap();
|
||||
std::fs::write(root.join(dir).join("settings.gradle"), "include 'app'").unwrap();
|
||||
std::fs::write(root.join(dir).join("app/build.gradle"), "plugins { id 'java' }").unwrap();
|
||||
std::fs::write(root.join(dir).join(wrapper), "").unwrap();
|
||||
}
|
||||
let settings = ide_core::settings::GradleSettings {
|
||||
linked_projects: Some(vec!["a".into(), "b".into()]),
|
||||
..Default::default()
|
||||
};
|
||||
let mut model = crate::load_linked(root, &root.join("cache"), None, &Default::default(), &settings);
|
||||
model.gradle[0].env.insert("JAVA_HOME".into(), "/jdk-a".into());
|
||||
model.gradle[1].env.insert("JAVA_HOME".into(), "/jdk-b".into());
|
||||
let all = super::plan(root, &model, super::BuildKind::Build, None, None).unwrap();
|
||||
assert_eq!(all.steps.len(), 2);
|
||||
assert_eq!(all.steps[0].cwd, root.join("a"));
|
||||
assert_eq!(all.steps[1].cwd, root.join("b"));
|
||||
let module = super::plan(root, &model, super::BuildKind::Rebuild, Some(&root.join("b/app")), None).unwrap();
|
||||
assert_eq!(module.steps.len(), 1);
|
||||
assert_eq!(module.steps[0].program, root.join("b").join(wrapper).to_string_lossy());
|
||||
assert_eq!(module.steps[0].cwd, root.join("b"));
|
||||
assert_eq!(module.steps[0].env["JAVA_HOME"], "/jdk-b");
|
||||
assert_eq!(module.steps[0].args, ["--console=plain", ":app:clean", ":app:classes", ":app:testClasses"]);
|
||||
// A directly linked build owns its module even when another composite reports it too.
|
||||
let direct = model.gradle[1].projects[0].clone();
|
||||
model.gradle[0].projects.push(direct);
|
||||
assert_eq!(model.gradle_for_module(root, &root.join("b")).unwrap().dir, "b");
|
||||
let mut nested = model.gradle[1].projects[1].clone();
|
||||
nested.path = ":b:app".into();
|
||||
model.gradle[0].projects.push(nested);
|
||||
let nested = super::plan(root, &model, super::BuildKind::Build, Some(&root.join("b/app")), None).unwrap();
|
||||
assert_eq!(nested.steps[0].program, root.join("b").join(wrapper).to_string_lossy());
|
||||
assert_eq!(nested.steps[0].args, ["--console=plain", ":app:classes", ":app:testClasses"]);
|
||||
model.gradle.clear();
|
||||
assert!(super::plan(root, &model, super::BuildKind::Build, Some(&root.join("b/app")), None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absolute_linked_roots_build_the_project_and_external_source_module() {
|
||||
let wrapper = if cfg!(windows) { "gradlew.bat" } else { "gradlew" };
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let base = dunce::canonicalize(temp.path()).unwrap();
|
||||
let root = base.join("workspace");
|
||||
let external = base.join("external");
|
||||
std::fs::create_dir(&root).unwrap();
|
||||
std::fs::create_dir_all(external.join("app/src/main/java")).unwrap();
|
||||
std::fs::write(external.join("settings.gradle"), "include 'app'").unwrap();
|
||||
let source = external.join("app/src/main/java/App.java");
|
||||
std::fs::write(&source, "class App {}").unwrap();
|
||||
std::fs::write(external.join(wrapper), "").unwrap();
|
||||
let settings = ide_core::settings::GradleSettings {
|
||||
linked_projects: Some(vec![external.to_string_lossy().into_owned()]),
|
||||
..Default::default()
|
||||
};
|
||||
let mut model = crate::load_linked(&root, &base.join("cache"), None, &Default::default(), &settings);
|
||||
model.gradle[0].env.insert("JAVA_HOME".into(), "/external-jdk".into());
|
||||
let all = super::plan(&root, &model, super::BuildKind::Build, None, None).unwrap();
|
||||
assert_eq!(all.steps[0].cwd, external);
|
||||
assert_eq!(all.steps[0].program, external.join(wrapper).to_string_lossy());
|
||||
let module = super::plan(&root, &model, super::BuildKind::Rebuild, Some(&source), None).unwrap();
|
||||
assert_eq!(module.steps[0].cwd, external);
|
||||
assert_eq!(module.steps[0].env["JAVA_HOME"], "/external-jdk");
|
||||
assert_eq!(module.steps[0].args, ["--console=plain", ":app:clean", ":app:classes", ":app:testClasses"]);
|
||||
let other = base.join("unlinked");
|
||||
std::fs::create_dir(&other).unwrap();
|
||||
std::fs::write(other.join("build.gradle"), "").unwrap();
|
||||
assert!(super::plan(&root, &model, super::BuildKind::Build, Some(&other), None).is_err());
|
||||
}
|
||||
use super::*;
|
||||
|
||||
/// Launch outputs use the platform separator throughout, so they compare equal to
|
||||
|
||||
@@ -435,15 +435,6 @@ pub fn external_roots<'a>(root: &Path, dirs: impl IntoIterator<Item = &'a str>)
|
||||
|
||||
pub fn load(root: &Path) -> Option<GradleModel> {
|
||||
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<GradleModel> {
|
||||
let dir = root.join(dir);
|
||||
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());
|
||||
@@ -464,65 +455,6 @@ pub fn load_build(root: &Path, dir: &Path) -> Option<GradleModel> {
|
||||
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<Vec<String>> {
|
||||
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<String> {
|
||||
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<GradleModel> {
|
||||
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<u32> {
|
||||
let number = |s: &str| -> Option<u32> {
|
||||
@@ -684,65 +616,6 @@ 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::<Vec<_>>(), ["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#"
|
||||
|
||||
@@ -126,7 +126,6 @@ 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,
|
||||
@@ -138,6 +137,7 @@ 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<String> =
|
||||
vec!["--console=plain".into(), "-q".into(), task, "--configuration".into(), configuration.into()];
|
||||
@@ -226,12 +226,8 @@ 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(), &Default::default(), &options, ":", "x --init-script y", &cancel).is_err()
|
||||
);
|
||||
assert!(
|
||||
gradle_dependencies(tmp.path(), &Default::default(), &options, "app", "runtimeClasspath", &cancel).is_err()
|
||||
);
|
||||
assert!(gradle_dependencies(tmp.path(), &options, ":", "x --init-script y", &cancel).is_err());
|
||||
assert!(gradle_dependencies(tmp.path(), &options, "app", "runtimeClasspath", &cancel).is_err());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
@@ -250,15 +246,8 @@ runtimeClasspath - Runtime classpath of source set 'main'.
|
||||
offline: true,
|
||||
..Default::default()
|
||||
};
|
||||
let tree = gradle_dependencies(
|
||||
root,
|
||||
&gradle::load(root).unwrap(),
|
||||
&options,
|
||||
":app",
|
||||
"testRuntimeClasspath",
|
||||
&CancellationToken::new(),
|
||||
)
|
||||
.unwrap();
|
||||
let tree =
|
||||
gradle_dependencies(root, &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(),
|
||||
|
||||
+16
-91
@@ -20,9 +20,7 @@ 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_gradle_model, sync_maven,
|
||||
};
|
||||
pub use sync::{SyncOptions, SyncProgress, maven_profiles, save_maven_profiles, sync_gradle, sync_maven};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
@@ -40,31 +38,13 @@ pub enum BuildTool {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuildModel {
|
||||
pub maven: Option<MavenModel>,
|
||||
#[serde(default)]
|
||||
pub gradle: Vec<GradleModel>,
|
||||
}
|
||||
|
||||
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 })
|
||||
})
|
||||
}
|
||||
pub gradle: Option<GradleModel>,
|
||||
}
|
||||
|
||||
/// Static model of the builds under `root`, merged with the sync results cached in `cache_dir`.
|
||||
/// Parent POMs are looked up in `maven_repo` (the configured local repository), else the default one.
|
||||
pub fn load(root: &Path, cache_dir: &Path, maven_repo: Option<PathBuf>) -> BuildModel {
|
||||
load_linked(
|
||||
root,
|
||||
cache_dir,
|
||||
maven_repo,
|
||||
&ide_core::settings::MavenSettings::default(),
|
||||
&ide_core::settings::GradleSettings::default(),
|
||||
)
|
||||
load_linked(root, cache_dir, maven_repo, &ide_core::settings::MavenSettings::default())
|
||||
}
|
||||
|
||||
/// Loads only linked Maven reactors, retaining an empty model so projects can be re-added.
|
||||
@@ -73,66 +53,31 @@ pub fn load_linked(
|
||||
cache_dir: &Path,
|
||||
maven_repo: Option<PathBuf>,
|
||||
settings: &ide_core::settings::MavenSettings,
|
||||
gradle_settings: &ide_core::settings::GradleSettings,
|
||||
) -> BuildModel {
|
||||
let maven = maven::has_projects(root, settings).then(|| {
|
||||
let maven = maven::is_maven_project(root).then(|| {
|
||||
let profiles = sync::maven_profiles(cache_dir);
|
||||
let repo = maven_repo.or_else(maven::default_local_repo);
|
||||
let mut m = maven::load_with_linked(root, repo, &profiles, &settings.linked_projects);
|
||||
let mut m = maven::load_with(root, maven_repo.or_else(maven::default_local_repo), &profiles);
|
||||
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_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();
|
||||
let gradle = gradle::load(root).map(|mut g| {
|
||||
sync::apply_gradle_cache(root, cache_dir, &mut g);
|
||||
g
|
||||
});
|
||||
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,
|
||||
settings: &ide_core::settings::GradleSettings,
|
||||
) -> Vec<PathBuf> {
|
||||
let builds = gradle::linked_dirs(root, settings);
|
||||
let dirs: Vec<String> = 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::<Vec<_>>(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
gradle::external_roots(root, dirs.iter().map(String::as_str))
|
||||
pub fn gradle_external_roots(root: &Path, cache_dir: &Path) -> Vec<PathBuf> {
|
||||
gradle::external_roots(root, sync::reported_gradle_projects(cache_dir).iter().map(|p| p.dir.as_str()))
|
||||
}
|
||||
|
||||
/// Source, resource and build folders of the last Gradle sync, absolute.
|
||||
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::<Vec<_>>();
|
||||
gradle::reported_source_roots(&reported)
|
||||
pub fn gradle_source_roots(cache_dir: &Path) -> Vec<(PathBuf, gradle::ReportedRoot)> {
|
||||
gradle::reported_source_roots(&sync::reported_gradle_projects(cache_dir))
|
||||
}
|
||||
|
||||
/// A library the project depends on, as `groupId:artifactId:version`.
|
||||
@@ -176,7 +121,7 @@ pub fn dependency_coordinates(root: &Path, model: &BuildModel) -> Vec<Dependency
|
||||
});
|
||||
}
|
||||
}
|
||||
for g in &model.gradle {
|
||||
if let Some(g) = &model.gradle {
|
||||
for script in g.projects.iter().filter_map(|p| p.build_file.as_ref()) {
|
||||
let Ok(text) = std::fs::read_to_string(root.join(script)) else { continue };
|
||||
out.extend(gradle::script_dependencies(&text).into_iter().map(|(group_id, artifact_id, version)| {
|
||||
@@ -202,26 +147,6 @@ pub fn affects_build(rel: &Path) -> 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();
|
||||
@@ -233,7 +158,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[0].projects[0].path, ":");
|
||||
assert_eq!(m.gradle.unwrap().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")));
|
||||
}
|
||||
@@ -280,7 +205,7 @@ mod tests {
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
let gradle = gradle::load(root).into_iter().collect();
|
||||
let gradle = gradle::load(root);
|
||||
let model = BuildModel { maven: Some(maven), gradle };
|
||||
let names: Vec<String> = dependency_coordinates(root, &model)
|
||||
.into_iter()
|
||||
|
||||
@@ -472,41 +472,6 @@ pub(crate) fn default_local_repo() -> Option<PathBuf> {
|
||||
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<PathBuf> {
|
||||
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 `<activeProfiles>` names them or they are
|
||||
/// `activeByDefault`.
|
||||
pub fn settings_profiles(settings: &Path) -> Vec<MavenProfile> {
|
||||
let Some(root) = std::fs::read_to_string(settings).ok().and_then(|text| xml::parse(&text)) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let active: Vec<String> =
|
||||
root.list("activeProfiles", "activeProfile").iter().map(|e| e.text.trim().to_owned()).collect();
|
||||
let mut profiles: Vec<MavenProfile> = 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
|
||||
}
|
||||
|
||||
/// `<localRepository>` of a settings file, when it names one: the repository a build
|
||||
/// with `-s <file>` resolves into, which is not necessarily the user's default.
|
||||
pub fn local_repository_of(settings: &Path) -> Option<PathBuf> {
|
||||
@@ -1100,9 +1065,10 @@ pub fn load(root: &Path) -> MavenModel {
|
||||
load_with_repo(root, default_local_repo())
|
||||
}
|
||||
|
||||
/// [`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)
|
||||
/// [`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)
|
||||
}
|
||||
|
||||
pub(crate) fn load_with_repo(root: &Path, local_repo: Option<PathBuf>) -> MavenModel {
|
||||
@@ -1124,53 +1090,11 @@ fn profile_choice(chosen: &[String], id: &str) -> Option<bool> {
|
||||
}
|
||||
|
||||
pub(crate) fn load_with(root: &Path, local_repo: Option<PathBuf>, 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<PathBuf> {
|
||||
let base = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
|
||||
let mut out: Vec<PathBuf> = 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<PathBuf>,
|
||||
profiles: &[String],
|
||||
linked: &[String],
|
||||
) -> MavenModel {
|
||||
let mut resolver = Resolver::new(local_repo, profiles);
|
||||
let mut model = MavenModel::default();
|
||||
let mut seen: HashSet<PathBuf> = HashSet::new();
|
||||
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();
|
||||
// (pom, aggregated)
|
||||
let mut stack: Vec<(PathBuf, bool)> = top_level_poms(root).into_iter().rev().map(|p| (p, false)).collect();
|
||||
while let Some((path, aggregated)) = stack.pop() {
|
||||
if model.projects.len() >= MAX_PROJECTS {
|
||||
break;
|
||||
@@ -1292,7 +1216,7 @@ impl UnlinkedProjects {
|
||||
if settings.unlinked_projects.is_empty() {
|
||||
return Self::default();
|
||||
}
|
||||
Self::from_model(&load_linked(root, settings), settings)
|
||||
Self::from_model(&load(root), settings)
|
||||
}
|
||||
|
||||
pub fn from_model(model: &MavenModel, settings: &ide_core::settings::MavenSettings) -> Self {
|
||||
@@ -1571,37 +1495,6 @@ 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#"<settings>
|
||||
<profiles>
|
||||
<profile><id>nexus</id></profile>
|
||||
<profile><id>jdk21</id><activation><activeByDefault>true</activeByDefault></activation></profile>
|
||||
<profile><id>release</id></profile>
|
||||
<profile><id>nexus</id></profile>
|
||||
<profile><activation/></profile>
|
||||
</profiles>
|
||||
<activeProfiles><activeProfile>nexus</activeProfile></activeProfiles>
|
||||
</settings>"#,
|
||||
)
|
||||
.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();
|
||||
@@ -1840,49 +1733,6 @@ 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",
|
||||
"<project><groupId>demo</groupId><artifactId>main</artifactId><version>1.0</version><packaging>pom</packaging><modules><module>core</module></modules></project>",
|
||||
);
|
||||
write(
|
||||
root,
|
||||
"core/pom.xml",
|
||||
"<project><groupId>demo</groupId><artifactId>core</artifactId><version>1.0</version></project>",
|
||||
);
|
||||
write(
|
||||
root,
|
||||
"stub/pom.xml",
|
||||
"<project><groupId>demo</groupId><artifactId>stub</artifactId><version>1.0</version></project>",
|
||||
);
|
||||
let top = |m: &MavenModel| m.projects.iter().map(|p| (p.dir.clone(), p.aggregated)).collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(top(&load_with_linked(root, None, &[], &[])), [("".into(), false), ("core".into(), true)]);
|
||||
let linked: Vec<String> =
|
||||
["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", "<project><artifactId>tool</artifactId></project>");
|
||||
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();
|
||||
|
||||
@@ -283,12 +283,7 @@ pub fn sync_maven(
|
||||
cancel: &CancellationToken,
|
||||
progress: &dyn Fn(SyncProgress),
|
||||
) -> IdeResult<()> {
|
||||
let mut model = maven::load_with_linked(
|
||||
root,
|
||||
maven::default_local_repo(),
|
||||
&options.profiles,
|
||||
&options.maven_settings.linked_projects,
|
||||
);
|
||||
let mut model = maven::load_with_profiles(root, &options.profiles);
|
||||
maven::UnlinkedProjects::from_model(&model, &options.maven_settings).filter(&mut model);
|
||||
std::fs::create_dir_all(cache_dir)?;
|
||||
let mut cache = MavenCache {
|
||||
@@ -378,25 +373,8 @@ pub fn sync_gradle(
|
||||
cancel: &CancellationToken,
|
||||
progress: &dyn Fn(SyncProgress),
|
||||
) -> IdeResult<()> {
|
||||
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 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 init_script = cache_dir.join("openidea-model.gradle");
|
||||
std::fs::write(&init_script, MODEL_INIT_SCRIPT)?;
|
||||
let mut args: Vec<String> = vec![
|
||||
@@ -411,24 +389,14 @@ pub fn sync_gradle_model(
|
||||
args.push("--offline".into());
|
||||
}
|
||||
progress(SyncProgress { message: "Loading Gradle tasks".into(), fraction: None });
|
||||
let out = match run(
|
||||
let out = 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);
|
||||
@@ -439,128 +407,13 @@ pub fn sync_gradle_model(
|
||||
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() {
|
||||
@@ -748,7 +601,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, &Default::default()), [billing]);
|
||||
assert_eq!(crate::gradle_external_roots(&root, &cache_dir), [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();
|
||||
|
||||
@@ -26,12 +26,12 @@ pub fn artifacts(root: &Path, model: &BuildModel) -> Vec<WebArtifact> {
|
||||
});
|
||||
}
|
||||
}
|
||||
for gradle in &model.gradle {
|
||||
if let Some(gradle) = &model.gradle {
|
||||
for p in &gradle.projects {
|
||||
let script = p.build_file.as_ref().and_then(|f| fs::read_to_string(root.join(f)).ok()).unwrap_or_default();
|
||||
if p.tasks.iter().any(|t| t.name == "war") || crate::gradle::has_war_plugin(&script) {
|
||||
out.push(WebArtifact {
|
||||
id: gradle_artifact_id(gradle, &p.path),
|
||||
id: format!("gradle:{}", p.path),
|
||||
name: p.name.clone(),
|
||||
module: p.dir.clone(),
|
||||
tool: "gradle".into(),
|
||||
@@ -42,14 +42,6 @@ pub fn artifacts(root: &Path, model: &BuildModel) -> Vec<WebArtifact> {
|
||||
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<String>,
|
||||
@@ -158,14 +150,9 @@ pub fn build(root: &Path, model: &BuildModel, id: &str, cache: &Path) -> IdeResu
|
||||
maven_project: Some((probe, output)),
|
||||
});
|
||||
}
|
||||
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)) {
|
||||
if let Some(path) = id.strip_prefix("gradle:") {
|
||||
let g = model.gradle.as_ref().ok_or_else(|| IdeError::invalid("Choose a Gradle WAR module"))?;
|
||||
if !artifacts(root, model).iter().any(|a| a.id == id) {
|
||||
return Err(IdeError::invalid("Choose a Gradle WAR module"));
|
||||
}
|
||||
let script = cache.join("war.init.gradle");
|
||||
@@ -199,28 +186,6 @@ 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() {
|
||||
|
||||
@@ -36,23 +36,6 @@ impl Default for InspectionSettings {
|
||||
}
|
||||
}
|
||||
|
||||
impl InspectionSettings {
|
||||
/// Overrides of the profile in effect; `Default`'s when `profile` names none that exists.
|
||||
pub fn active(&self) -> &BTreeMap<String, InspectionOverride> {
|
||||
let custom = |level: &str, key: &str| match level {
|
||||
"ide" => self.ide_profiles.get(key),
|
||||
"project" => self.project_profiles.get(key),
|
||||
_ => None,
|
||||
};
|
||||
match self.profile.as_str() {
|
||||
"project" => &self.project,
|
||||
id => {
|
||||
id.split_once(':').and_then(|(level, key)| custom(level, key)).map_or(&self.default, |p| &p.overrides)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A named profile made with Copy.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
@@ -128,20 +111,4 @@ mod tests {
|
||||
let back: InspectionSettings = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap();
|
||||
assert_eq!(back, s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_profile_falls_back_to_default() {
|
||||
let off = |id: &str| {
|
||||
BTreeMap::from([(id.to_owned(), InspectionOverride { enabled: Some(false), ..Default::default() })])
|
||||
};
|
||||
let mut s = InspectionSettings { default: off("a"), project: off("b"), ..Default::default() };
|
||||
s.ide_profiles.insert("strict".into(), CustomInspectionProfile { name: "Strict".into(), overrides: off("c") });
|
||||
assert!(s.active().contains_key("a"));
|
||||
s.profile = "project".into();
|
||||
assert!(s.active().contains_key("b"));
|
||||
s.profile = "ide:strict".into();
|
||||
assert!(s.active().contains_key("c"));
|
||||
s.profile = "project:gone".into();
|
||||
assert!(s.active().contains_key("a"));
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,6 @@ 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};
|
||||
|
||||
@@ -828,8 +828,6 @@ 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<Vec<String>>,
|
||||
/// Empty = Project JDK; otherwise a JDK home.
|
||||
pub jvm: String,
|
||||
pub distribution: GradleDistribution,
|
||||
@@ -866,8 +864,6 @@ pub enum GradleDistribution {
|
||||
pub struct MavenSettings {
|
||||
/// Project-relative root POMs removed from the Maven project model.
|
||||
pub unlinked_projects: Vec<String>,
|
||||
/// Project-relative POMs outside the root reactor added as independent top-level projects.
|
||||
pub linked_projects: Vec<String>,
|
||||
/// Maven home (or its `mvn` executable); empty = the project's `mvnw`, else `mvn` on `PATH`.
|
||||
pub home: String,
|
||||
/// User `settings.xml` (`-s`); empty = `~/.m2/settings.xml`.
|
||||
@@ -1128,16 +1124,6 @@ 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();
|
||||
@@ -1150,7 +1136,6 @@ 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);
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
//! Projects that live inside a WSL distro are reached from Windows through the
|
||||
//! `\\wsl.localhost\<distro>` (or legacy `\\wsl$\<distro>`) 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<WslPath> {
|
||||
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<u16> = 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<String> {
|
||||
let text = decode_output(raw);
|
||||
let mut names: Vec<String> = 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<String> {
|
||||
#[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<u8> {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -72,13 +72,7 @@ fn builtin(adapter: &str) -> Option<DebuggerConfig> {
|
||||
"stdio",
|
||||
"Install the Kotlin Debug Adapter from Settings > Language Servers",
|
||||
),
|
||||
// Windows installs `python.exe`; `python3.exe` there is at most a Store alias.
|
||||
"debugpy" => cfg(
|
||||
if cfg!(windows) { "python" } else { "python3" },
|
||||
&["-m", "debugpy.adapter"],
|
||||
"stdio",
|
||||
"pip install debugpy",
|
||||
),
|
||||
"debugpy" => cfg("python3", &["-m", "debugpy.adapter"], "stdio", "pip install debugpy"),
|
||||
"js-debug" => cfg(
|
||||
"js-debug-adapter",
|
||||
// Without a host it listens on `localhost`, which may resolve to ::1 only.
|
||||
|
||||
@@ -302,23 +302,6 @@ pub struct DebugManager {
|
||||
data_breakpoints: Mutex<Vec<DataBreakpoint>>,
|
||||
}
|
||||
|
||||
/// 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))
|
||||
}
|
||||
@@ -587,10 +570,6 @@ impl DebugManager {
|
||||
custom_command: impl FnOnce(&str) -> Option<String>,
|
||||
) -> IdeResult<DebugSessionId> {
|
||||
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()
|
||||
@@ -622,15 +601,6 @@ 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 {
|
||||
@@ -1266,19 +1236,6 @@ 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",
|
||||
|
||||
@@ -111,8 +111,8 @@ impl Session {
|
||||
if let Some(path) = self.kotlin_mirror.lock().as_ref().and_then(|m| m.adapter_path(path)) {
|
||||
return path;
|
||||
}
|
||||
if self.adapter == "java-debug" || (cfg!(windows) && self.adapter == "netcoredbg") {
|
||||
let native = windows_source_path(&path.to_string_lossy());
|
||||
if self.adapter == "java-debug" {
|
||||
let native = java_source_path(&path.to_string_lossy());
|
||||
self.resolved_paths.lock().insert(native.clone(), path.to_path_buf());
|
||||
return native;
|
||||
}
|
||||
@@ -133,11 +133,7 @@ impl Session {
|
||||
if let Some(real) = self.kotlin_mirror.lock().as_ref().and_then(|m| m.client_path(Path::new(path))) {
|
||||
return Some(real);
|
||||
}
|
||||
let key = if self.adapter == "java-debug" || (cfg!(windows) && self.adapter == "netcoredbg") {
|
||||
windows_source_path(path)
|
||||
} else {
|
||||
PathBuf::from(path)
|
||||
};
|
||||
let key = if self.adapter == "java-debug" { java_source_path(path) } else { PathBuf::from(path) };
|
||||
self.resolved_paths.lock().get(&key).cloned().or_else(|| {
|
||||
(self.adapter == "java-debug" && is_windows_source_path(path))
|
||||
.then(|| PathBuf::from(path.replace('\\', "/")))
|
||||
@@ -269,8 +265,8 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
// java-debug and Windows netcoredbg need native separators for source lookup.
|
||||
// Keep DAP path mode, but normalize Windows drive and UNC paths.
|
||||
// java-debug treats a forward-slash drive path as a URI (scheme "C").
|
||||
// Keep DAP path mode, but use native Windows separators for Java source lookup.
|
||||
fn is_windows_source_path(path: &str) -> bool {
|
||||
let bytes = path.as_bytes();
|
||||
(bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && matches!(bytes[2], b'/' | b'\\'))
|
||||
@@ -278,7 +274,7 @@ fn is_windows_source_path(path: &str) -> bool {
|
||||
|| path.starts_with(r"\\")
|
||||
}
|
||||
|
||||
fn windows_source_path(path: &str) -> PathBuf {
|
||||
fn java_source_path(path: &str) -> PathBuf {
|
||||
if is_windows_source_path(path) { PathBuf::from(path.replace('/', r"\")) } else { PathBuf::from(path) }
|
||||
}
|
||||
|
||||
|
||||
@@ -1141,23 +1141,6 @@ async fn kotlin_official_release_ten_no_delay_runs() {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[tokio::test]
|
||||
async fn netcoredbg_windows_source_paths_round_trip() {
|
||||
let client = "D:/work/LoginController.cs";
|
||||
let native = r"D:\work\LoginController.cs";
|
||||
let h = harness(Script { source_path: Some(native.into()), ..Script::default() });
|
||||
let path = PathBuf::from(client);
|
||||
h.manager.set_breakpoints(path.clone(), vec![bp(25)]).await.unwrap();
|
||||
let mut cfg = config();
|
||||
cfg.adapter = "netcoredbg".into();
|
||||
let id = h.start(cfg).await.unwrap();
|
||||
assert_eq!(h.request("setBreakpoints")["arguments"]["source"]["path"], native);
|
||||
let frames = h.manager.stack_trace(id, 1).await.unwrap();
|
||||
assert_eq!(frames[0].path.as_deref(), Some(path.as_path()));
|
||||
h.manager.stop(id).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn java_windows_source_paths_for_launch_and_attach() {
|
||||
for request in ["launch", "attach"] {
|
||||
@@ -1269,19 +1252,3 @@ 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();
|
||||
}
|
||||
@@ -29,4 +29,3 @@ uuid.workspace = true
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
|
||||
tempfile.workspace = true
|
||||
ide-ssh = { workspace = true, features = ["testing"] }
|
||||
@@ -150,7 +150,6 @@ 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())
|
||||
@@ -178,17 +177,6 @@ impl ClickhousePool {
|
||||
}
|
||||
|
||||
pub async fn execute(&self, sql: &str, max_rows: usize, cancel: &CancellationToken) -> IdeResult<QueryResult> {
|
||||
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<QueryResult> {
|
||||
if transaction_statement(sql) {
|
||||
return Err(transaction_error());
|
||||
}
|
||||
@@ -200,7 +188,7 @@ impl ClickhousePool {
|
||||
};
|
||||
let client = self.client()?;
|
||||
let query_id = uuid::Uuid::new_v4().to_string();
|
||||
let request = self.request(&client, &query_id, skip.saturating_add(max_rows), *session).body(sql.to_owned());
|
||||
let request = self.request(&client, &query_id, max_rows, *session).body(sql.to_owned());
|
||||
let started = Instant::now();
|
||||
let fetch = async {
|
||||
let mut response = request.send().await.map_err(http_error)?;
|
||||
@@ -228,7 +216,7 @@ impl ClickhousePool {
|
||||
}
|
||||
(Vec::new(), Vec::new(), false)
|
||||
} else {
|
||||
let mut reader = RowReader::new(skip, max_rows);
|
||||
let mut reader = RowReader::new(max_rows);
|
||||
while let Some(chunk) = response.chunk().await.map_err(http_error)? {
|
||||
reader.push(&chunk)?;
|
||||
}
|
||||
@@ -321,13 +309,11 @@ impl ClickhousePool {
|
||||
|
||||
type DecodedResult = (Vec<ResultColumn>, Vec<Vec<Option<String>>>, bool);
|
||||
|
||||
/// 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
|
||||
/// 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
|
||||
/// starts the exception ClickHouse writes when a query fails mid-stream.
|
||||
struct RowReader {
|
||||
skip: usize,
|
||||
max_rows: usize,
|
||||
pending: Vec<u8>,
|
||||
names: Option<Vec<String>>,
|
||||
@@ -338,17 +324,8 @@ struct RowReader {
|
||||
}
|
||||
|
||||
impl RowReader {
|
||||
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 new(max_rows: usize) -> Self {
|
||||
Self { max_rows, pending: Vec::new(), names: None, types: None, rows: Vec::new(), more: false, exception: None }
|
||||
}
|
||||
|
||||
fn push(&mut self, chunk: &[u8]) -> IdeResult<()> {
|
||||
@@ -379,15 +356,9 @@ impl RowReader {
|
||||
self.exception = Some(format!("{}\n", String::from_utf8_lossy(line)));
|
||||
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(());
|
||||
}
|
||||
if self.names.is_some() && self.types.is_some() && self.rows.len() >= self.max_rows {
|
||||
self.more = true;
|
||||
return Ok(());
|
||||
}
|
||||
let Ok(values) = serde_json::from_slice::<Vec<Box<serde_json::value::RawValue>>>(text) else {
|
||||
self.exception = Some(format!("{}\n", String::from_utf8_lossy(line)));
|
||||
@@ -576,11 +547,7 @@ mod tests {
|
||||
}
|
||||
|
||||
fn decode(body: &[u8], max_rows: usize, chunk: usize) -> IdeResult<DecodedResult> {
|
||||
decode_from(body, 0, max_rows, chunk)
|
||||
}
|
||||
|
||||
fn decode_from(body: &[u8], skip: usize, max_rows: usize, chunk: usize) -> IdeResult<DecodedResult> {
|
||||
let mut reader = RowReader::new(skip, max_rows);
|
||||
let mut reader = RowReader::new(max_rows);
|
||||
for part in body.chunks(chunk) {
|
||||
reader.push(part)?;
|
||||
}
|
||||
@@ -619,18 +586,6 @@ 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::<Vec<_>>(), 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::<String>::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");
|
||||
|
||||
+10
-49
@@ -337,13 +337,10 @@ fn result_columns<R: Row>(row: &R) -> Vec<ResultColumn> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 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`.
|
||||
/// Streams the results of `$sql` on executor `$exec`, keeping at most `$max` rows
|
||||
/// with their cells read as `$mode`.
|
||||
macro_rules! fetch {
|
||||
($exec:expr, $sql:expr, $max:expr, $mode:expr) => {
|
||||
fetch!($exec, $sql, 0usize, $max, $mode)
|
||||
};
|
||||
($exec:expr, $sql:expr, $skip:expr, $max:expr, $mode:expr) => {{
|
||||
($exec:expr, $sql:expr, $max:expr, $mode:expr) => {{
|
||||
use futures::StreamExt;
|
||||
let mut stream = sqlx::raw_sql(sqlx::AssertSqlSafe($sql.to_owned())).fetch_many($exec);
|
||||
let mut columns: Vec<ResultColumn> = Vec::new();
|
||||
@@ -352,7 +349,6 @@ macro_rules! fetch {
|
||||
let mut has_more = false;
|
||||
let mut failure = None;
|
||||
let mut first: Option<Instant> = None;
|
||||
let mut to_skip: usize = $skip;
|
||||
while let Some(item) = stream.next().await {
|
||||
first.get_or_insert_with(Instant::now);
|
||||
match item {
|
||||
@@ -361,10 +357,6 @@ 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;
|
||||
@@ -394,8 +386,7 @@ async fn mssql_command(conn: &mut MssqlClient, sql: &str) -> IdeResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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<Fetched> {
|
||||
async fn mssql_fetch(conn: &mut MssqlClient, sql: &str, max: usize) -> IdeResult<Fetched> {
|
||||
let mut stream = conn.simple_query(sql).await.map_err(mssql_err)?;
|
||||
let first = Some(Instant::now());
|
||||
let columns = stream
|
||||
@@ -408,13 +399,8 @@ async fn mssql_fetch(conn: &mut MssqlClient, sql: &str, skip: usize, max: usize)
|
||||
.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;
|
||||
@@ -748,7 +734,7 @@ impl Pool {
|
||||
let fetched: IdeResult<Fetched> = match self {
|
||||
Pool::SqlServer(p) => {
|
||||
let mut conn = p.get_owned().await.map_err(mssql_pool_err)?;
|
||||
mssql_fetch(&mut conn, sql, 0, max).await
|
||||
mssql_fetch(&mut conn, sql, max).await
|
||||
}
|
||||
_ => with_pool!(self, p => fetch!(p, sql, max, Cells::Text)),
|
||||
};
|
||||
@@ -758,23 +744,11 @@ 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<QueryResult> {
|
||||
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<QueryResult> {
|
||||
if let Pool::Clickhouse(p) = self {
|
||||
return p.execute_from(sql, skip, max_rows, cancel).await;
|
||||
return p.execute(sql, max_rows, cancel).await;
|
||||
}
|
||||
let mut conn = Held::acquire(self).await?;
|
||||
let r = conn.execute_from(self, sql, skip, max_rows, cancel).await;
|
||||
let r = conn.execute(self, sql, max_rows, cancel).await;
|
||||
if cancel.is_cancelled() {
|
||||
conn.discard().await;
|
||||
return Err(IdeError::cancelled());
|
||||
@@ -900,22 +874,9 @@ impl Held {
|
||||
sql: &str,
|
||||
max_rows: usize,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<QueryResult> {
|
||||
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<QueryResult> {
|
||||
if let Held::Clickhouse(c) = self {
|
||||
return c.execute_from(sql, skip, max_rows, cancel).await;
|
||||
return c.execute(sql, max_rows, cancel).await;
|
||||
}
|
||||
let started = Instant::now();
|
||||
// `$id`: the query naming the server process, `$kill`: how another connection stops it.
|
||||
@@ -926,7 +887,7 @@ impl Held {
|
||||
None => None,
|
||||
};
|
||||
let r = tokio::select! {
|
||||
r = async { fetch!(&mut **$conn, sql, skip, max_rows, Cells::Display) } => r,
|
||||
r = async { fetch!(&mut **$conn, sql, max_rows, Cells::Display) } => r,
|
||||
() = cancel.cancelled() => Err(IdeError::cancelled()),
|
||||
};
|
||||
if cancel.is_cancelled() {
|
||||
@@ -955,7 +916,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, skip, max_rows) => r?,
|
||||
r = mssql_fetch(c, sql, max_rows) => r?,
|
||||
() = cancel.cancelled() => return Err(IdeError::cancelled()),
|
||||
}
|
||||
}
|
||||
|
||||
+19
-171
@@ -213,8 +213,6 @@ pub struct DbManager {
|
||||
/// App-wide SSH connections and host-key trust.
|
||||
ssh: Arc<SshManager>,
|
||||
saved_passwords: SecretStore,
|
||||
/// Directory of the global data sources and their saved passwords ([`DbManager::with_global_sources`]).
|
||||
global: Option<(PathBuf, SecretStore)>,
|
||||
history: Arc<HistoryStore>,
|
||||
/// Records executed statements in the history off the async threads, in order.
|
||||
recorder: std::sync::mpsc::Sender<(String, String, f64)>,
|
||||
@@ -341,7 +339,6 @@ impl DbManager {
|
||||
}
|
||||
Self {
|
||||
saved_passwords: SecretStore::new(passwords, &root),
|
||||
global: None,
|
||||
history,
|
||||
recorder,
|
||||
root,
|
||||
@@ -354,42 +351,8 @@ impl DbManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Also lists the global data sources stored in `dir` (the app data directory).
|
||||
pub fn with_global_sources(mut self, dir: PathBuf) -> Self {
|
||||
let passwords = self.saved_passwords.scoped(&dir);
|
||||
self.global = Some((dir, passwords));
|
||||
self
|
||||
}
|
||||
|
||||
/// The project's data sources, then the global ones (a project one wins on the same id).
|
||||
pub fn sources(&self) -> IdeResult<Vec<DataSource>> {
|
||||
let mut all = source::load(&self.root)?;
|
||||
let global: Vec<_> = self.global_sources().into_iter().filter(|g| all.iter().all(|s| s.id != g.id)).collect();
|
||||
all.extend(global);
|
||||
Ok(all)
|
||||
}
|
||||
|
||||
fn global_sources(&self) -> Vec<DataSource> {
|
||||
let Some((dir, _)) = &self.global else { return Vec::new() };
|
||||
source::load_global(dir).unwrap_or_else(|e| {
|
||||
tracing::warn!("global data sources: {}", e.message);
|
||||
Vec::new()
|
||||
})
|
||||
}
|
||||
|
||||
/// Where the saved passwords of data source `id` live: app-wide for a global one.
|
||||
fn saved_for(&self, id: &str) -> &SecretStore {
|
||||
match &self.global {
|
||||
Some((_, store)) if self.global_sources().iter().any(|s| s.id == id) => store,
|
||||
_ => &self.saved_passwords,
|
||||
}
|
||||
}
|
||||
|
||||
fn store_of(&self, source: &DataSource) -> &SecretStore {
|
||||
match &self.global {
|
||||
Some((_, store)) if source.global => store,
|
||||
_ => &self.saved_passwords,
|
||||
}
|
||||
source::load(&self.root)
|
||||
}
|
||||
|
||||
/// The connection settings of target `target` ([`DataSource::resolved`]), with
|
||||
@@ -409,43 +372,18 @@ impl DbManager {
|
||||
}
|
||||
|
||||
/// Saves the data source list; connections of removed or changed sources are closed.
|
||||
/// Global ones (`global`) go to the app-wide list, the others to the project's.
|
||||
pub async fn save_sources(&self, sources: Vec<DataSource>) -> IdeResult<()> {
|
||||
let old = self.sources().unwrap_or_default();
|
||||
let (global, project): (Vec<_>, Vec<_>) = sources.iter().cloned().partition(|s| s.global);
|
||||
match &self.global {
|
||||
// Rewritten only when it changes: other projects' windows edit the same file.
|
||||
Some((dir, _)) => {
|
||||
if global != old.iter().filter(|s| s.global).cloned().collect::<Vec<_>>() {
|
||||
source::save_global(dir, &global)?;
|
||||
}
|
||||
}
|
||||
None if !global.is_empty() => return Err(IdeError::invalid("global data sources are not available here")),
|
||||
None => {}
|
||||
}
|
||||
source::save(&self.root, &project)?;
|
||||
source::save(&self.root, &sources)?;
|
||||
for o in old {
|
||||
match sources.iter().find(|s| s.id == o.id) {
|
||||
Some(n) if *n == o => {}
|
||||
gone_or_changed => {
|
||||
let keys = [o.id.clone(), secrets::ssh_key(&o.id)];
|
||||
match gone_or_changed {
|
||||
None => {
|
||||
for k in &keys {
|
||||
self.session_passwords.lock().remove(k);
|
||||
self.store_of(&o).delete(k);
|
||||
}
|
||||
if gone_or_changed.is_none() {
|
||||
for k in [o.id.clone(), secrets::ssh_key(&o.id)] {
|
||||
self.session_passwords.lock().remove(&k);
|
||||
self.saved_passwords.delete(&k);
|
||||
}
|
||||
// Made global or project-level: its saved passwords move along.
|
||||
Some(n) if n.global != o.global => {
|
||||
for k in &keys {
|
||||
if let Some(p) = self.store_of(&o).get(k) {
|
||||
self.store_of(n).set(k, &p)?;
|
||||
self.store_of(&o).delete(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(_) => {}
|
||||
}
|
||||
self.disconnect_source(&o.id).await;
|
||||
}
|
||||
@@ -457,7 +395,7 @@ impl DbManager {
|
||||
/// Remembers `password` for the session, and in the saved passwords when
|
||||
/// the data source saves passwords (`None` forgets it).
|
||||
pub async fn set_password(&self, id: &str, password: Option<String>, save: bool) -> IdeResult<()> {
|
||||
self.remember(id, id.to_owned(), password, save).await
|
||||
self.remember(id.to_owned(), password, save).await
|
||||
}
|
||||
|
||||
/// [`DbManager::set_password`] for the SSH password / key passphrase.
|
||||
@@ -469,10 +407,10 @@ impl DbManager {
|
||||
self.ssh.set_secret(&c, secret, save);
|
||||
return Ok(());
|
||||
}
|
||||
self.remember(id, secrets::ssh_key(id), secret, save).await
|
||||
self.remember(secrets::ssh_key(id), secret, save).await
|
||||
}
|
||||
|
||||
async fn remember(&self, id: &str, key: String, secret: Option<String>, save: bool) -> IdeResult<()> {
|
||||
async fn remember(&self, key: String, secret: Option<String>, save: bool) -> IdeResult<()> {
|
||||
match &secret {
|
||||
Some(p) => {
|
||||
self.session_passwords.lock().insert(key.clone(), p.clone());
|
||||
@@ -481,11 +419,10 @@ impl DbManager {
|
||||
self.session_passwords.lock().remove(&key);
|
||||
}
|
||||
}
|
||||
let store = self.saved_for(id);
|
||||
let result = match (secret, save) {
|
||||
(Some(p), true) => store.set(&key, &p),
|
||||
(Some(p), true) => self.saved_passwords.set(&key, &p),
|
||||
_ => {
|
||||
store.delete(&key);
|
||||
self.saved_passwords.delete(&key);
|
||||
Ok(())
|
||||
}
|
||||
};
|
||||
@@ -496,18 +433,18 @@ impl DbManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn secret(&self, source: &DataSource, key: String, saved: bool) -> Option<String> {
|
||||
async fn secret(&self, key: String, saved: bool) -> Option<String> {
|
||||
if let Some(p) = self.session_passwords.lock().get(&key) {
|
||||
return Some(p.clone());
|
||||
}
|
||||
if !saved {
|
||||
return None;
|
||||
}
|
||||
self.store_of(source).get(&key)
|
||||
self.saved_passwords.get(&key)
|
||||
}
|
||||
|
||||
async fn password(&self, source: &DataSource) -> Option<String> {
|
||||
match self.secret(source, source.id.clone(), source.driver != DbDriver::Sqlite && source.save_password).await {
|
||||
match self.secret(source.id.clone(), source.driver != DbDriver::Sqlite && source.save_password).await {
|
||||
Some(p) => Some(p),
|
||||
None => source.url_password(),
|
||||
}
|
||||
@@ -563,7 +500,7 @@ impl DbManager {
|
||||
let session = if config.is_empty() {
|
||||
let secret = match ssh_secret {
|
||||
Some(s) => Some(s),
|
||||
None => self.secret(source, secrets::ssh_key(&source.id), ssh.save_secret).await,
|
||||
None => self.secret(secrets::ssh_key(&source.id), ssh.save_secret).await,
|
||||
};
|
||||
let target = SshTarget {
|
||||
host: ssh.host.clone(),
|
||||
@@ -1009,20 +946,7 @@ impl DbManager {
|
||||
max_rows: usize,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<QueryResult> {
|
||||
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<QueryResult> {
|
||||
let r = self.run(id, statement, skip, max_rows, cancel).await;
|
||||
let r = self.run(id, statement, max_rows, cancel).await;
|
||||
let ran = match &r {
|
||||
Ok(_) => true,
|
||||
Err(e) => !matches!(e.kind, ErrorKind::Cancelled | ErrorKind::PermissionDenied),
|
||||
@@ -1057,7 +981,7 @@ impl DbManager {
|
||||
/// The execution plan of `statement` (explained, not run).
|
||||
pub async fn explain(&self, id: &str, statement: &str, cancel: &CancellationToken) -> IdeResult<Vec<PlanNode>> {
|
||||
let driver = self.pool(id).await?.driver();
|
||||
let result = self.run(id, &plan::explain_statement(driver, statement), 0, MAX_META_ROWS, cancel).await?;
|
||||
let result = self.run(id, &plan::explain_statement(driver, statement), MAX_META_ROWS, cancel).await?;
|
||||
plan::parse(driver, &result)
|
||||
}
|
||||
|
||||
@@ -1065,14 +989,13 @@ impl DbManager {
|
||||
&self,
|
||||
id: &str,
|
||||
statement: &str,
|
||||
skip: usize,
|
||||
max_rows: usize,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<QueryResult> {
|
||||
let pool = self.pool(id).await?;
|
||||
let session = self.sessions.lock().get(id).cloned();
|
||||
let Some(session) = session else {
|
||||
return pool.execute_from(statement, skip, max_rows, cancel).await;
|
||||
return pool.execute(statement, max_rows, cancel).await;
|
||||
};
|
||||
if pool.driver() == DbDriver::Clickhouse {
|
||||
return Err(IdeError::invalid("ClickHouse manual transactions are unsupported"));
|
||||
@@ -1084,7 +1007,7 @@ impl DbManager {
|
||||
*held = Some(conn);
|
||||
}
|
||||
let conn = held.as_mut().expect("opened above");
|
||||
let r = conn.execute_from(&pool, statement, skip, max_rows, cancel).await;
|
||||
let r = conn.execute(&pool, statement, max_rows, cancel).await;
|
||||
if cancel.is_cancelled() {
|
||||
// The server rolls the transaction back with the connection.
|
||||
held.take().expect("opened above").discard().await;
|
||||
@@ -1175,81 +1098,6 @@ impl DbManager {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ide_ssh::secrets::testing::files_containing;
|
||||
use std::path::Path;
|
||||
|
||||
fn manager(dir: &Path) -> DbManager {
|
||||
ide_ssh::secrets::testing::use_memory_store();
|
||||
let data = dir.join("data");
|
||||
let ssh = Arc::new(SshManager::new(data.join("ssh-passwords.json")));
|
||||
DbManager::new(dir.join("project"), data.join("db-passwords.json"), data.join("db-history.json"), ssh)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn saved_passwords_stay_off_disk() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
manager(tmp.path()).set_password("s1", Some("hunter2-db".into()), true).await.unwrap();
|
||||
manager(tmp.path()).set_ssh_secret("s1", Some("hunter2-tunnel".into()), true).await.unwrap();
|
||||
assert_eq!(files_containing(tmp.path(), "hunter2-db"), Vec::<PathBuf>::new());
|
||||
assert_eq!(files_containing(tmp.path(), "hunter2-tunnel"), Vec::<PathBuf>::new());
|
||||
let reopened = manager(tmp.path());
|
||||
assert_eq!(reopened.secret(&DataSource::default(), "s1".into(), true).await.as_deref(), Some("hunter2-db"));
|
||||
assert_eq!(
|
||||
reopened.secret(&DataSource::default(), secrets::ssh_key("s1"), true).await.as_deref(),
|
||||
Some("hunter2-tunnel")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn moves_plaintext_passwords_of_older_versions_off_disk() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let project = tmp.path().join("project").to_string_lossy().into_owned();
|
||||
let legacy = serde_json::json!({ project: { "s1": "old-db-pw" }, "/other": { "s2": "other-pw" } });
|
||||
std::fs::create_dir_all(tmp.path().join("data")).unwrap();
|
||||
std::fs::write(tmp.path().join("data/db-passwords.json"), legacy.to_string()).unwrap();
|
||||
let m = manager(tmp.path());
|
||||
assert_eq!(m.secret(&DataSource::default(), "s1".into(), true).await.as_deref(), Some("old-db-pw"));
|
||||
assert_eq!(files_containing(tmp.path(), "old-db-pw"), Vec::<PathBuf>::new());
|
||||
assert_eq!(files_containing(tmp.path(), "other-pw"), Vec::<PathBuf>::new());
|
||||
let other = SecretStore::new(tmp.path().join("data/db-passwords.json"), Path::new("/other"));
|
||||
assert_eq!(other.get("s2").as_deref(), Some("other-pw"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn global_data_sources_are_shared_by_projects_with_their_passwords() {
|
||||
ide_ssh::secrets::testing::use_memory_store();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (app, one, two) = (tmp.path().join("app"), tmp.path().join("one"), tmp.path().join("two"));
|
||||
let manager = |root: &std::path::Path| {
|
||||
let ssh = Arc::new(SshManager::new(app.join("ssh-passwords.json")));
|
||||
DbManager::new(root.to_path_buf(), app.join("passwords.json"), app.join("history.json"), ssh)
|
||||
.with_global_sources(app.clone())
|
||||
};
|
||||
let (a, b) = (manager(&one), manager(&two));
|
||||
let source = |id: &str, global: bool| DataSource {
|
||||
id: id.into(),
|
||||
name: id.into(),
|
||||
driver: DbDriver::Postgres,
|
||||
save_password: true,
|
||||
global,
|
||||
..Default::default()
|
||||
};
|
||||
a.save_sources(vec![source("local", false), source("shared", false)]).await.unwrap();
|
||||
a.set_password("shared", Some("pw".into()), true).await.unwrap();
|
||||
// Made global: listed in the other project too, with its saved password.
|
||||
a.save_sources(vec![source("local", false), source("shared", true)]).await.unwrap();
|
||||
let ids = |m: &DbManager| m.sources().unwrap().into_iter().map(|s| (s.id, s.global)).collect::<Vec<_>>();
|
||||
assert_eq!(ids(&a), [("local".into(), false), ("shared".into(), true)]);
|
||||
assert_eq!(ids(&b), [("shared".into(), true)]);
|
||||
assert_eq!(b.password(&b.source("shared").unwrap()).await.as_deref(), Some("pw"));
|
||||
let project_file = std::fs::read_to_string(one.join(".openidea/data-sources.toml")).unwrap();
|
||||
assert!(!project_file.contains("shared") && !project_file.contains("global"), "{project_file}");
|
||||
assert!(!std::fs::read_to_string(app.join("data-sources.toml")).unwrap().contains("global"));
|
||||
// Made project-level again: gone from the other project, the password stays with it.
|
||||
a.save_sources(vec![source("local", false), source("shared", false)]).await.unwrap();
|
||||
assert!(b.sources().unwrap().is_empty());
|
||||
assert_eq!(a.password(&a.source("shared").unwrap()).await.as_deref(), Some("pw"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn clickhouse_rejects_manual_transactions_and_table_edits_without_connecting() {
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
//! Saved data source passwords, in the OS credential store like the other saved
|
||||
//! passwords ([`SecretFile`]). One index file for all projects; each credential is
|
||||
//! keyed by project root, then credential key.
|
||||
//! Saved data source passwords, in the app's data directory rather than the OS
|
||||
//! credential store (no system prompts). One JSON file for all projects, keyed
|
||||
//! by project root, then credential key; owner-only on Unix.
|
||||
|
||||
use ide_core::IdeResult;
|
||||
use ide_ssh::secrets::SecretFile;
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use parking_lot::Mutex;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Project root → credential key → secret.
|
||||
type Contents = BTreeMap<String, BTreeMap<String, String>>;
|
||||
|
||||
/// Serialises read-modify-write across the projects sharing the file.
|
||||
static LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
pub struct SecretStore {
|
||||
path: PathBuf,
|
||||
file: SecretFile,
|
||||
project: String,
|
||||
}
|
||||
|
||||
@@ -18,41 +23,61 @@ pub fn ssh_key(id: &str) -> String {
|
||||
format!("{id}#ssh")
|
||||
}
|
||||
|
||||
fn file_key(project: &str, key: &str) -> String {
|
||||
format!("{project}|{key}")
|
||||
}
|
||||
|
||||
/// Older versions' file: project root → credential key → secret.
|
||||
fn legacy(value: serde_json::Value) -> Option<BTreeMap<String, String>> {
|
||||
let all: BTreeMap<String, BTreeMap<String, String>> = serde_json::from_value(value).ok()?;
|
||||
Some(
|
||||
all.into_iter()
|
||||
.flat_map(|(project, keys)| keys.into_iter().map(move |(k, s)| (file_key(&project, &k), s)))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
impl SecretStore {
|
||||
pub fn new(path: PathBuf, root: &Path) -> Self {
|
||||
let file = SecretFile::with_legacy(path.clone(), legacy);
|
||||
Self { path, file, project: root.to_string_lossy().into_owned() }
|
||||
}
|
||||
|
||||
/// The same file, keyed by `scope` instead (global data sources: the app data directory).
|
||||
pub fn scoped(&self, scope: &Path) -> Self {
|
||||
Self::new(self.path.clone(), scope)
|
||||
Self { path, project: root.to_string_lossy().into_owned() }
|
||||
}
|
||||
|
||||
pub fn get(&self, key: &str) -> Option<String> {
|
||||
self.file.get(&file_key(&self.project, key))
|
||||
let _g = LOCK.lock();
|
||||
self.read().remove(&self.project)?.remove(key)
|
||||
}
|
||||
|
||||
pub fn set(&self, key: &str, secret: &str) -> IdeResult<()> {
|
||||
self.file.set(&file_key(&self.project, key), secret)
|
||||
let _g = LOCK.lock();
|
||||
let mut all = self.read();
|
||||
all.entry(self.project.clone()).or_default().insert(key.to_owned(), secret.to_owned());
|
||||
self.write(&all)
|
||||
}
|
||||
|
||||
pub fn delete(&self, key: &str) {
|
||||
self.file.delete(&file_key(&self.project, key))
|
||||
let _g = LOCK.lock();
|
||||
let mut all = self.read();
|
||||
let Some(project) = all.get_mut(&self.project) else { return };
|
||||
if project.remove(key).is_none() {
|
||||
return;
|
||||
}
|
||||
if project.is_empty() {
|
||||
all.remove(&self.project);
|
||||
}
|
||||
if let Err(e) = self.write(&all) {
|
||||
tracing::warn!("saved passwords: {}", e.message);
|
||||
}
|
||||
}
|
||||
|
||||
fn read(&self) -> Contents {
|
||||
match std::fs::read(&self.path) {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|e| {
|
||||
tracing::warn!("saved passwords: {}: {e}", self.path.display());
|
||||
Contents::new()
|
||||
}),
|
||||
Err(_) => Contents::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn write(&self, all: &Contents) -> IdeResult<()> {
|
||||
let err = |e: std::io::Error| IdeError::unavailable(format!("saved passwords: {e}"));
|
||||
if let Some(dir) = self.path.parent() {
|
||||
std::fs::create_dir_all(dir).map_err(err)?;
|
||||
}
|
||||
let tmp = self.path.with_extension("json.tmp");
|
||||
let mut options = std::fs::OpenOptions::new();
|
||||
options.write(true).create(true).truncate(true);
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
|
||||
let bytes = serde_json::to_vec_pretty(all).map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
std::io::Write::write_all(&mut options.open(&tmp).map_err(err)?, &bytes).map_err(err)?;
|
||||
std::fs::rename(&tmp, &self.path).map_err(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +87,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn keeps_projects_apart_and_drops_empty_ones() {
|
||||
ide_ssh::secrets::testing::use_memory_store();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let file = tmp.path().join("db-passwords.json");
|
||||
let a = SecretStore::new(file.clone(), Path::new("/p/a"));
|
||||
|
||||
+10
-42
@@ -1,7 +1,6 @@
|
||||
//! Data sources: per-project connection settings in `.openidea/data-sources.toml`, and
|
||||
//! global ones (IDEA's Make Global: listed in every project) in `data-sources.toml` of the
|
||||
//! app data directory. Passwords never go there: saved ones live in [`crate::secrets`],
|
||||
//! the others only in memory for the session.
|
||||
//! Data sources: per-project connection settings in `.openidea/data-sources.toml`.
|
||||
//! Passwords never go there: saved ones live in [`crate::secrets`], the others
|
||||
//! only in memory for the session.
|
||||
|
||||
use ide_core::{IdeError, IdeResult, IpcPath, project_dir};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -182,9 +181,6 @@ pub struct DataSource {
|
||||
pub options: DbOptions,
|
||||
pub introspection: DbIntrospection,
|
||||
pub properties: Vec<DbProperty>,
|
||||
/// Stored app-wide and listed in every project. Implied by the file it is read from,
|
||||
/// so not written to it.
|
||||
pub global: bool,
|
||||
}
|
||||
|
||||
impl Default for DataSource {
|
||||
@@ -213,7 +209,6 @@ impl Default for DataSource {
|
||||
options: DbOptions::default(),
|
||||
introspection: DbIntrospection::default(),
|
||||
properties: Vec::new(),
|
||||
global: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -402,32 +397,9 @@ struct SourcesFile {
|
||||
sources: Vec<DataSource>,
|
||||
}
|
||||
|
||||
/// The project's data sources.
|
||||
pub fn load(root: &Path) -> IdeResult<Vec<DataSource>> {
|
||||
read(&project_dir::path(root, FILE))
|
||||
}
|
||||
|
||||
/// Saves the project's data sources.
|
||||
pub fn save(root: &Path, sources: &[DataSource]) -> IdeResult<()> {
|
||||
write(&project_dir::path(root, FILE), sources)
|
||||
}
|
||||
|
||||
/// The global data sources stored in `dir` (the app data directory).
|
||||
pub fn load_global(dir: &Path) -> IdeResult<Vec<DataSource>> {
|
||||
let mut sources = read(&dir.join(FILE))?;
|
||||
for s in &mut sources {
|
||||
s.global = true;
|
||||
}
|
||||
Ok(sources)
|
||||
}
|
||||
|
||||
/// Saves the global data sources in `dir`.
|
||||
pub fn save_global(dir: &Path, sources: &[DataSource]) -> IdeResult<()> {
|
||||
write(&dir.join(FILE), sources)
|
||||
}
|
||||
|
||||
fn read(path: &Path) -> IdeResult<Vec<DataSource>> {
|
||||
let text = match std::fs::read_to_string(path) {
|
||||
let path = project_dir::path(root, FILE);
|
||||
let text = match std::fs::read_to_string(&path) {
|
||||
Ok(t) => t,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
|
||||
Err(e) => return Err(e.into()),
|
||||
@@ -436,23 +408,19 @@ fn read(path: &Path) -> IdeResult<Vec<DataSource>> {
|
||||
Ok(file.sources)
|
||||
}
|
||||
|
||||
fn write(path: &Path, sources: &[DataSource]) -> IdeResult<()> {
|
||||
pub fn save(root: &Path, sources: &[DataSource]) -> IdeResult<()> {
|
||||
for s in sources {
|
||||
if s.id.is_empty() || s.name.trim().is_empty() {
|
||||
return Err(IdeError::invalid("every data source needs an id and a name"));
|
||||
}
|
||||
}
|
||||
let path = project_dir::path(root, FILE);
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
let internal = |e: &dyn std::fmt::Display| IdeError::internal(e.to_string());
|
||||
let mut value = toml::Value::try_from(SourcesFile { sources: sources.to_vec() }).map_err(|e| internal(&e))?;
|
||||
if let Some(list) = value.get_mut("data-source").and_then(toml::Value::as_array_mut) {
|
||||
for table in list.iter_mut().filter_map(toml::Value::as_table_mut) {
|
||||
table.remove("global");
|
||||
}
|
||||
}
|
||||
std::fs::write(path, toml::to_string_pretty(&value).map_err(|e| internal(&e))?)?;
|
||||
let text = toml::to_string_pretty(&SourcesFile { sources: sources.to_vec() })
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
std::fs::write(path, text)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
//! 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, QueryResult, SortKey,
|
||||
};
|
||||
use ide_db::{CellValue, DataSource, DbDriver, DbManager, DbOptions, DbProperty, DbRowEdit, DbTableKind, SortKey};
|
||||
|
||||
async fn setup() -> (tempfile::TempDir, DbManager) {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -125,28 +123,6 @@ 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::<Vec<_>>();
|
||||
|
||||
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;
|
||||
|
||||
@@ -79,9 +79,7 @@ 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 => {
|
||||
"Copy to the clipboard from its commands, and read and write it from its web views".into()
|
||||
}
|
||||
Self::Clipboard => "Read and write the clipboard 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"),
|
||||
|
||||
@@ -364,7 +364,7 @@ pub fn package_hash(dir: &Path, files: &[PathBuf]) -> std::io::Result<String> {
|
||||
sorted.dedup();
|
||||
let mut hasher = blake3::Hasher::new();
|
||||
for rel in sorted {
|
||||
hasher.update(hashed_path(rel).as_bytes());
|
||||
hasher.update(rel.to_string_lossy().as_bytes());
|
||||
hasher.update(&[0]);
|
||||
hasher.update_reader(std::fs::File::open(dir.join(rel))?)?;
|
||||
hasher.update(&[0]);
|
||||
@@ -372,12 +372,6 @@ pub fn package_hash(dir: &Path, files: &[PathBuf]) -> std::io::Result<String> {
|
||||
Ok(hasher.finalize().to_hex().to_string())
|
||||
}
|
||||
|
||||
/// A package-relative path as hashed: `/`-separated on every OS, so a package has one digest
|
||||
/// (a walked `web\index.html` on Windows is `web/index.html` everywhere else).
|
||||
fn hashed_path(rel: &Path) -> String {
|
||||
rel.components().map(|c| c.as_os_str().to_string_lossy()).collect::<Vec<_>>().join("/")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -486,20 +480,4 @@ mod tests {
|
||||
let groups = by_risk(&caps(&["storage", "process:git", "net:x.io"]));
|
||||
assert_eq!(groups.iter().map(|g| g.0).collect::<Vec<_>>(), [Risk::High, Risk::Medium, Risk::Low]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hashes_paths_with_forward_slashes_on_every_os() {
|
||||
assert_eq!(hashed_path(Path::new("web/index.html")), "web/index.html");
|
||||
assert_eq!(hashed_path(Path::new("plugin.wasm")), "plugin.wasm");
|
||||
#[cfg(windows)]
|
||||
assert_eq!(hashed_path(Path::new(r"web\index.html")), "web/index.html");
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir(tmp.path().join("web")).unwrap();
|
||||
std::fs::write(tmp.path().join("web").join("index.html"), "<p>").unwrap();
|
||||
// A walked path (native separators) hashes like the manifest's `/` spelling.
|
||||
assert_eq!(
|
||||
package_hash(tmp.path(), &[Path::new("web").join("index.html")]).unwrap(),
|
||||
package_hash(tmp.path(), &["web/index.html".into()]).unwrap()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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.1.0";
|
||||
pub const ENGINE_VERSION: &str = "1.0.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.
|
||||
|
||||
+49
-120
@@ -25,7 +25,7 @@ use ide_language::{LanguageRegistry, Syntax};
|
||||
use ide_run::ProcessManager;
|
||||
use ide_terminal::TerminalManager;
|
||||
use ide_workspace::SettingsStore;
|
||||
use parking_lot::{Mutex, RwLock};
|
||||
use parking_lot::RwLock;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
@@ -74,8 +74,6 @@ pub struct Host {
|
||||
pub environment: environment::Environment,
|
||||
/// The HTTP proxy's saved password (see `proxy.rs`).
|
||||
proxy_password: ide_ssh::secrets::SecretFile,
|
||||
/// The proxy password when it could not be saved (no credential store).
|
||||
proxy_session_password: Mutex<Option<String>>,
|
||||
pub(crate) sink_for: SinkFactory,
|
||||
owner: fn(&str) -> &str,
|
||||
pub runtime: tokio::runtime::Handle,
|
||||
@@ -84,15 +82,8 @@ pub struct Host {
|
||||
pub(crate) external_logs: RwLock<HashMap<String, Vec<Arc<ide_vcs::Repo>>>>,
|
||||
requests: requests::Requests,
|
||||
project_requests: RwLock<HashMap<String, Vec<RequestId>>>,
|
||||
/// Shutdowns of projects closed with [`Host::close_project_detached`].
|
||||
closing: parking_lot::Mutex<Vec<std::thread::JoinHandle<()>>>,
|
||||
/// One open at a time per window (see [`Host::opening`]).
|
||||
open_locks: parking_lot::Mutex<HashMap<String, Arc<parking_lot::Mutex<()>>>>,
|
||||
}
|
||||
|
||||
/// How long exit waits for project shutdown before giving up.
|
||||
const EXIT_SHUTDOWN_WAIT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
|
||||
impl Host {
|
||||
pub fn new(config: HostConfig) -> Self {
|
||||
let HostConfig {
|
||||
@@ -112,7 +103,6 @@ impl Host {
|
||||
let host = Self {
|
||||
trust: trust::TrustStore::load(&config_dir, &data_dir),
|
||||
proxy_password: ide_ssh::secrets::SecretFile::new(data_dir.join("proxy-password.json")),
|
||||
proxy_session_password: Mutex::default(),
|
||||
remote: Arc::new(ide_remote::RemoteService::new(ssh.clone(), data_dir.join("remote"))),
|
||||
ssh,
|
||||
environment: environment::Environment::new(project_environment, local_data_dir.clone()),
|
||||
@@ -136,8 +126,6 @@ impl Host {
|
||||
external_logs: RwLock::default(),
|
||||
requests: Default::default(),
|
||||
project_requests: RwLock::default(),
|
||||
closing: Default::default(),
|
||||
open_locks: Default::default(),
|
||||
};
|
||||
host.apply_proxy();
|
||||
host
|
||||
@@ -191,69 +179,46 @@ impl Host {
|
||||
}
|
||||
}
|
||||
|
||||
/// Closes `window`'s project and waits until its language servers, kernels and
|
||||
/// debug sessions have shut down. Never call this on the UI event-loop thread:
|
||||
/// the shutdown runs on the async runtime, whose workers may themselves be
|
||||
/// waiting for the event loop (IPC responses, window queries). Use
|
||||
/// [`Self::close_project_detached`] there.
|
||||
pub fn close_project(&self, window: &str) {
|
||||
if let Some(shutdown) = self.begin_close(window) {
|
||||
self.wait_blocking(shutdown);
|
||||
}
|
||||
}
|
||||
|
||||
/// Like [`Self::close_project`], but returns once the project is detached from
|
||||
/// the window; its workers finish shutting down in the background, and
|
||||
/// [`Self::shutdown_notebooks_on_exit`] waits for them before the process exits.
|
||||
pub fn close_project_detached(&self, window: &str) {
|
||||
if let Some(shutdown) = self.begin_close(window) {
|
||||
let mut closing = self.closing.lock();
|
||||
closing.retain(|handle| !handle.is_finished());
|
||||
closing.push(shutdown);
|
||||
}
|
||||
}
|
||||
|
||||
/// Detaches the project from `window` and stops what it started, synchronously,
|
||||
/// so the window can open another project at once. Returns the thread that
|
||||
/// finishes shutting down its workers.
|
||||
fn begin_close(&self, window: &str) -> Option<std::thread::JoinHandle<()>> {
|
||||
self.external_logs.write().remove(self.owner_window(window));
|
||||
for request in self.project_requests.write().remove(self.owner_window(window)).unwrap_or_default() {
|
||||
self.cancel_request(request);
|
||||
}
|
||||
let project = self.projects.write().remove(window)?;
|
||||
if !project.light {
|
||||
self.environment.project_closed();
|
||||
let project = self.projects.write().remove(window);
|
||||
if let Some(project) = project {
|
||||
if !project.light {
|
||||
self.environment.project_closed();
|
||||
}
|
||||
if let Some(bridge) = &project.kotlin_bridge {
|
||||
bridge.stop();
|
||||
}
|
||||
project.documents.clear_workspace_edit_history();
|
||||
project.agents.shutdown();
|
||||
project.notebooks.stop_admission();
|
||||
// What the closing window started goes with it — the builds and searches it
|
||||
// asked for, its runs, its shells and its debug sessions — and the language
|
||||
// servers finish shutting down before the window is gone, as IDEA closes a
|
||||
// project's processes.
|
||||
self.processes.kill_project(&project.root);
|
||||
self.terminals.kill_key(self.owner_window(window));
|
||||
self.shutdown_project_workers_blocking(
|
||||
vec![project.notebooks.clone()],
|
||||
project.kotlin_bridge.iter().cloned().collect(),
|
||||
Some(project.lsp.clone()),
|
||||
Some((self.debugger.clone(), window.to_owned())),
|
||||
);
|
||||
}
|
||||
if let Some(bridge) = &project.kotlin_bridge {
|
||||
bridge.stop();
|
||||
}
|
||||
project.documents.clear_workspace_edit_history();
|
||||
project.agents.shutdown();
|
||||
project.notebooks.stop_admission();
|
||||
// What the closing window started goes with it — the builds and searches it
|
||||
// asked for, its runs, its shells and its debug sessions — and its language
|
||||
// servers shut down.
|
||||
self.processes.kill_project(&project.root);
|
||||
self.terminals.kill_key(self.owner_window(window));
|
||||
Some(self.spawn_project_shutdown(
|
||||
vec![project.notebooks.clone()],
|
||||
project.kotlin_bridge.iter().cloned().collect(),
|
||||
Some(project.lsp.clone()),
|
||||
Some((self.debugger.clone(), window.to_owned())),
|
||||
))
|
||||
}
|
||||
|
||||
/// Project/window destruction must finish owned kernel cleanup, not enqueue
|
||||
/// work which can be lost as the desktop runtime exits: the returned thread is
|
||||
/// joined by the closer or, for a detached close, before exit.
|
||||
fn spawn_project_shutdown(
|
||||
/// Synchronous project/window destruction must finish owned kernel cleanup,
|
||||
/// not enqueue work which can be lost as the desktop runtime exits.
|
||||
fn shutdown_project_workers_blocking(
|
||||
&self,
|
||||
notebooks: Vec<Arc<notebook::NotebookService>>,
|
||||
bridges: Vec<Arc<kotlin_bridge::KotlinBridge>>,
|
||||
lsp: Option<Arc<ide_lsp::LspManager>>,
|
||||
debug: Option<(Arc<ide_dap::DebugManager>, String)>,
|
||||
) -> std::thread::JoinHandle<()> {
|
||||
) {
|
||||
for bridge in &bridges {
|
||||
bridge.stop();
|
||||
}
|
||||
@@ -261,37 +226,25 @@ impl Host {
|
||||
service.stop_admission();
|
||||
}
|
||||
let runtime = self.runtime.clone();
|
||||
std::thread::spawn(move || {
|
||||
runtime.block_on(async move {
|
||||
for bridge in bridges {
|
||||
bridge.shutdown().await;
|
||||
}
|
||||
for service in notebooks {
|
||||
service.shutdown().await;
|
||||
}
|
||||
if let Some(lsp) = lsp {
|
||||
lsp.shutdown_all().await;
|
||||
}
|
||||
if let Some((debug, key)) = debug {
|
||||
debug.stop_key(&key).await;
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
/// Waits for detached closes, so a project opened next never races the
|
||||
/// language servers of one still shutting down (e.g. over a shared workspace).
|
||||
pub(crate) fn wait_detached_closes(&self) {
|
||||
let pending: Vec<_> = self.closing.lock().drain(..).collect();
|
||||
for shutdown in pending {
|
||||
self.wait_blocking(shutdown);
|
||||
}
|
||||
}
|
||||
|
||||
fn wait_blocking(&self, shutdown: std::thread::JoinHandle<()>) {
|
||||
let wait = move || {
|
||||
if let Err(error) = shutdown.join() {
|
||||
tracing::error!("project shutdown thread failed: {error:?}");
|
||||
let worker = std::thread::spawn(move || {
|
||||
runtime.block_on(async move {
|
||||
for bridge in bridges {
|
||||
bridge.shutdown().await;
|
||||
}
|
||||
for service in notebooks {
|
||||
service.shutdown().await;
|
||||
}
|
||||
if let Some(lsp) = lsp {
|
||||
lsp.shutdown_all().await;
|
||||
}
|
||||
if let Some((debug, key)) = debug {
|
||||
debug.stop_key(&key).await;
|
||||
}
|
||||
});
|
||||
});
|
||||
if let Err(error) = worker.join() {
|
||||
tracing::error!("notebook shutdown thread failed: {error:?}");
|
||||
}
|
||||
};
|
||||
if tokio::runtime::Handle::try_current()
|
||||
@@ -303,32 +256,15 @@ impl Host {
|
||||
}
|
||||
}
|
||||
|
||||
/// Called before the desktop runtime exits, including update/restart exits, on
|
||||
/// the event-loop thread: waits for detached closes and the open projects'
|
||||
/// kernels, but no longer than [`EXIT_SHUTDOWN_WAIT`], so a runtime worker
|
||||
/// stuck on the event loop cannot keep the process from exiting.
|
||||
/// Called before the desktop runtime exits, including update/restart exits.
|
||||
pub fn shutdown_notebooks_on_exit(&self) {
|
||||
let projects = self.projects();
|
||||
let mut pending: Vec<_> = self.closing.lock().drain(..).collect();
|
||||
pending.push(self.spawn_project_shutdown(
|
||||
self.shutdown_project_workers_blocking(
|
||||
projects.iter().map(|project| project.notebooks.clone()).collect(),
|
||||
projects.iter().filter_map(|project| project.kotlin_bridge.clone()).collect(),
|
||||
None,
|
||||
None,
|
||||
));
|
||||
let deadline = std::time::Instant::now() + EXIT_SHUTDOWN_WAIT;
|
||||
while pending.iter().any(|handle| !handle.is_finished()) {
|
||||
if std::time::Instant::now() >= deadline {
|
||||
tracing::warn!("project shutdown did not finish within {EXIT_SHUTDOWN_WAIT:?}; exiting anyway");
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
}
|
||||
for handle in pending {
|
||||
if let Err(error) = handle.join() {
|
||||
tracing::error!("project shutdown thread failed: {error:?}");
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// Called before desktop exit, which may bypass document destructors.
|
||||
@@ -404,13 +340,6 @@ 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<parking_lot::Mutex<()>> {
|
||||
self.open_locks.lock().entry(window.to_owned()).or_default().clone()
|
||||
}
|
||||
|
||||
pub(crate) fn insert_project(&self, window: &str, project: Arc<Project>) {
|
||||
self.projects.write().insert(window.to_owned(), project);
|
||||
}
|
||||
|
||||
@@ -8,11 +8,8 @@ use ide_agent::{
|
||||
AgentChange, AgentCommand, AgentEntry, AgentEvent, AgentProbe, AgentPrompt, AgentProvider, AgentQueued, AgentTask,
|
||||
NewAgentTask,
|
||||
};
|
||||
use ide_core::error::ErrorKind;
|
||||
use ide_core::{IdeError, IdeEvent, IdeResult, IpcPath, RequestId, SharedSink, TaskId};
|
||||
use ide_core::{IdeError, IdeResult, IpcPath, RequestId};
|
||||
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<AgentEvent>) -> IdeResult<Vec<AgentTask>> {
|
||||
@@ -61,39 +58,7 @@ 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);
|
||||
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<Mutex<HashSet<String>>> = LazyLock::new(Mutex::default);
|
||||
|
||||
fn installing() -> std::sync::MutexGuard<'static, HashSet<String>> {
|
||||
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<Self> {
|
||||
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);
|
||||
}
|
||||
result?
|
||||
}
|
||||
|
||||
/// Installs the registry's current version of agent `id` (also to update it), streaming
|
||||
@@ -107,43 +72,11 @@ pub async fn registry_install(
|
||||
on_event: Channel<InstallEvent>,
|
||||
) -> 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<SharedSink> = host.projects().iter().map(|p| p.sink.clone()).collect();
|
||||
let report = move |message: Option<String>, fraction: Option<f32>, 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 || {
|
||||
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)
|
||||
}
|
||||
}
|
||||
ide_agent::registry::install(&paths, &id, &cancel, &|e| {
|
||||
let _ = on_event.send(e);
|
||||
});
|
||||
report(None, Some(1.0), true);
|
||||
result
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(join_err);
|
||||
@@ -330,20 +263,6 @@ 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();
|
||||
|
||||
@@ -32,7 +32,6 @@ 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 {
|
||||
@@ -46,7 +45,7 @@ pub fn load(root: &Path, cache: &Path, settings: &Settings) -> BuildModel {
|
||||
p.options = options.clone();
|
||||
}
|
||||
}
|
||||
for g in &mut model.gradle {
|
||||
if let Some(g) = &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();
|
||||
}
|
||||
@@ -66,12 +65,11 @@ 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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
model
|
||||
}
|
||||
@@ -96,89 +94,9 @@ pub async fn build_model(host: &Host, window: &str) -> IdeResult<BuildModel> {
|
||||
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<Vec<String>> {
|
||||
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<String> = 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<String> = 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<String>, link: bool) -> IdeResult<BuildModel> {
|
||||
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::<BuildModel, IdeError>(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<String>) -> IdeResult<BuildModel> {
|
||||
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<String>) -> IdeResult<BuildModel> {
|
||||
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,
|
||||
@@ -186,7 +104,6 @@ pub async fn build_sync(
|
||||
request: RequestId,
|
||||
offline: bool,
|
||||
profiles: Vec<String>,
|
||||
build_dir: Option<String>,
|
||||
on_progress: Channel<SyncProgress>,
|
||||
) -> IdeResult<BuildModel> {
|
||||
host.require_trust(window)?;
|
||||
@@ -205,8 +122,6 @@ 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 {
|
||||
@@ -216,7 +131,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 => BTreeMap::new(),
|
||||
BuildTool::Gradle => current.gradle.as_ref().map(|g| g.env.clone()).unwrap_or_default(),
|
||||
};
|
||||
let options = SyncOptions {
|
||||
offline,
|
||||
@@ -225,31 +140,14 @@ 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: None,
|
||||
gradle_command: current.gradle.as_ref().map(|g| g.command.clone()),
|
||||
};
|
||||
let progress = |p: SyncProgress| {
|
||||
let _ = on_progress.send(p);
|
||||
};
|
||||
match tool {
|
||||
BuildTool::Maven => ide_build::sync_maven(&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());
|
||||
}
|
||||
}
|
||||
BuildTool::Gradle => ide_build::sync_gradle(&root, &cache, &options, &token, &progress)?,
|
||||
}
|
||||
Ok::<BuildModel, IdeError>(load(&root, &cache, &settings))
|
||||
})
|
||||
@@ -257,18 +155,6 @@ 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();
|
||||
@@ -361,26 +247,20 @@ pub async fn gradle_dependencies(
|
||||
configuration: String,
|
||||
offline: bool,
|
||||
request: RequestId,
|
||||
build_dir: Option<String>,
|
||||
) -> IdeResult<Vec<ide_build::GradleDependency>> {
|
||||
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 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 gradle = load(&root, &cache, &settings).gradle;
|
||||
let options = SyncOptions {
|
||||
offline,
|
||||
env: gradle.env.clone(),
|
||||
gradle_command: Some(gradle.command.clone()),
|
||||
env: gradle.as_ref().map(|g| g.env.clone()).unwrap_or_default(),
|
||||
gradle_command: gradle.as_ref().map(|g| g.command.clone()),
|
||||
..Default::default()
|
||||
};
|
||||
ide_build::gradle_dependencies(&root, gradle, &options, &gradle_path, &configuration, &token)
|
||||
ide_build::gradle_dependencies(&root, &options, &gradle_path, &configuration, &token)
|
||||
})
|
||||
.await
|
||||
.map_err(join_err);
|
||||
@@ -502,11 +382,6 @@ 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,
|
||||
@@ -515,7 +390,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
c.arg("request")?,
|
||||
c.arg("offline")?,
|
||||
c.arg("profiles")?,
|
||||
c.arg("buildDir")?,
|
||||
c.sender("onProgress")?,
|
||||
)
|
||||
.await
|
||||
@@ -531,7 +405,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
c.arg("configuration")?,
|
||||
c.arg("offline")?,
|
||||
c.arg("request")?,
|
||||
c.arg("buildDir")?,
|
||||
)
|
||||
.await
|
||||
});
|
||||
@@ -559,48 +432,6 @@ 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();
|
||||
@@ -609,13 +440,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.remove(0);
|
||||
let default = load(root, &root.join("cache"), &settings).gradle.unwrap();
|
||||
assert_eq!(default.command, wrapper.to_string_lossy());
|
||||
settings.languages.java.gradle.jvm = "jdk21".into();
|
||||
settings.languages.java.gradle.user_home = "user-home".into();
|
||||
settings.languages.java.gradle.distribution = ide_core::settings::GradleDistribution::Local;
|
||||
settings.languages.java.gradle.home = "gradle-install".into();
|
||||
let model = load(root, &root.join("cache"), &settings).gradle.remove(0);
|
||||
let model = load(root, &root.join("cache"), &settings).gradle.unwrap();
|
||||
assert_eq!(
|
||||
model.command,
|
||||
root.join("gradle-install")
|
||||
|
||||
@@ -123,14 +123,13 @@ 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, skip, max_rows) = (
|
||||
let (request, id, statements, max_rows) = (
|
||||
c.arg::<RequestId>("request")?,
|
||||
c.arg::<String>("id")?,
|
||||
c.arg::<Vec<SqlStatement>>("statements")?,
|
||||
c.arg::<Option<u32>>("skip")?.unwrap_or(0),
|
||||
c.arg::<u32>("maxRows")?,
|
||||
);
|
||||
execute(&c.host, &c.window, request, &id, statements, skip, max_rows).await
|
||||
execute(&c.host, &c.window, request, &id, statements, max_rows).await
|
||||
});
|
||||
d.add("db_table_page", |c| async move {
|
||||
let (request, id, schema, table) = (
|
||||
@@ -326,22 +325,20 @@ 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. Each result reads past its first `skip` rows (a later page) and keeps
|
||||
/// `max_rows`. Cancel with `cancel_request(request)`.
|
||||
/// error. Cancel with `cancel_request(request)`.
|
||||
pub async fn execute(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
request: RequestId,
|
||||
id: &str,
|
||||
statements: Vec<SqlStatement>,
|
||||
skip: u32,
|
||||
max_rows: u32,
|
||||
) -> IdeResult<Vec<StatementOutcome>> {
|
||||
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_from(id, &statement.text, skip as usize, max_rows.max(1) as usize, &cancel).await {
|
||||
match db.execute(id, &statement.text, 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);
|
||||
|
||||
@@ -162,14 +162,6 @@ 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,
|
||||
|
||||
@@ -175,12 +175,7 @@ pub fn set_settings(
|
||||
host.apply_proxy();
|
||||
}
|
||||
// A new interpreter / toolchain / server executable restarts the language servers it affects.
|
||||
// So do inspections that are compiler options (`ide-sdk` `configure_java_compiler`).
|
||||
let code_style = key == "editor"
|
||||
|| key == "editor.codeStyle"
|
||||
|| key.starts_with("editor.codeStyle.")
|
||||
|| key == "inspections"
|
||||
|| key.starts_with("inspections.");
|
||||
let code_style = key == "editor" || key == "editor.codeStyle" || key.starts_with("editor.codeStyle.");
|
||||
let toolchain = ["languages", "tools", "languageServers", "projectStructure"]
|
||||
.iter()
|
||||
.any(|k| key == *k || key.starts_with(&format!("{k}.")));
|
||||
@@ -196,9 +191,7 @@ pub fn set_settings(
|
||||
}
|
||||
let roots_changed = key == "projectStructure"
|
||||
|| key.starts_with("projectStructure.")
|
||||
|| "languages.java.maven.unlinkedProjects".starts_with(key)
|
||||
|| "languages.java.maven.linkedProjects".starts_with(key)
|
||||
|| "languages.java.gradle.linkedProjects".starts_with(key);
|
||||
|| "languages.java.maven.unlinkedProjects".starts_with(key);
|
||||
// Content roots and excludes (files.exclude, ignored names, excluded folders) reach the
|
||||
// index and the watchers without reopening the project (the rescan runs in the background).
|
||||
if roots_changed || key == "files" || key.starts_with("files.") {
|
||||
|
||||
@@ -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: ide_core::IpcPath,
|
||||
pub path: String,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ pub(crate) fn sources_in(root: &Path, excludes: &[String]) -> Vec<RouteSource> {
|
||||
return;
|
||||
}
|
||||
bytes.fetch_add(text.len(), Ordering::Relaxed);
|
||||
found.push(RouteSource { path: path.to_path_buf().into(), text });
|
||||
found.push(RouteSource { path: path.to_string_lossy().into_owned(), 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::<Vec<_>>());
|
||||
assert!(files[0].path.ends_with("src/Ctl.java"), "{:?}", files[0].path);
|
||||
assert!(Path::new(&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::<Vec<_>>());
|
||||
assert!(files[0].path.ends_with("module/src/A.java"), "{:?}", files[0].path);
|
||||
assert!(Path::new(&files[0].path).ends_with("module/src/A.java"), "{}", files[0].path);
|
||||
}
|
||||
}
|
||||
@@ -23,26 +23,8 @@ pub struct LocatedTools {
|
||||
/// Installed interpreters / toolchains of `kind`; project-local ones first.
|
||||
pub async fn sdk_detect(host: &Host, window: &str, kind: SdkKind) -> IdeResult<Vec<Sdk>> {
|
||||
host.require_trust(window)?;
|
||||
let 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)
|
||||
let root = host.project(window).ok().map(|p| p.root.clone());
|
||||
tokio::task::spawn_blocking(move || ide_sdk::detect(kind, root.as_deref())).await.map_err(join_err)
|
||||
}
|
||||
|
||||
/// The interpreter the project uses: the configured one, else its virtualenv.
|
||||
|
||||
@@ -492,7 +492,7 @@ pub async fn vcs_add_to_ignore(
|
||||
window: &str,
|
||||
paths: Vec<PathBuf>,
|
||||
destination: IgnoreDestination,
|
||||
) -> IdeResult<Vec<ide_core::IpcPath>> {
|
||||
) -> IdeResult<Vec<String>> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
@@ -509,7 +509,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(Into::into).collect())
|
||||
result.map(|files| files.into_iter().map(|p| p.to_string_lossy().into_owned()).collect())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -324,18 +324,6 @@ 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<StatusItem>) {
|
||||
let key = (self.id().to_owned(), id.to_owned());
|
||||
{
|
||||
@@ -430,12 +418,6 @@ impl HostApi for PluginApi {
|
||||
}))
|
||||
}
|
||||
|
||||
fn vcs_remotes(&self, root: &Path) -> Result<Vec<(String, String)>, 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)) {
|
||||
@@ -830,42 +812,3 @@ 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:[email protected]/o/r.git", "https://github.com/o/r.git"),
|
||||
("https://[email protected]/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"),
|
||||
("[email protected]:o/r.git", "[email protected]: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");
|
||||
}
|
||||
}
|
||||
@@ -422,8 +422,7 @@ struct IdeDir {
|
||||
plugins: PathBuf,
|
||||
}
|
||||
|
||||
/// 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).
|
||||
/// The settings folders of JetBrains IDEs and Android Studio (2020.1 and later), newest first.
|
||||
fn ide_dirs(layout: Layout, home: &Path, env: impl Fn(&str) -> Option<OsString>) -> Vec<IdeDir> {
|
||||
let var = |key: &str| env(key).filter(|v| !v.is_empty()).map(PathBuf::from);
|
||||
let (config_root, data_root) = match layout {
|
||||
@@ -463,8 +462,6 @@ fn ide_dirs(layout: Layout, home: &Path, env: impl Fn(&str) -> Option<OsString>)
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -787,13 +784,6 @@ 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");
|
||||
|
||||
@@ -165,16 +165,6 @@ impl PluginHost {
|
||||
self.save_state();
|
||||
}
|
||||
|
||||
/// The marketplace's plugins: read again when `refresh`, else the last index
|
||||
/// read. Reads it when none has been read yet (first run, nothing cached).
|
||||
pub fn market_listing(&self, refresh: bool) -> IdeResult<Vec<MarketPlugin>> {
|
||||
if refresh || (self.market.read().is_none() && self.market_url().is_some()) {
|
||||
self.refresh_market()
|
||||
} else {
|
||||
Ok(self.market_plugins())
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the index again, keeps it, and applies its revocations. Announces
|
||||
/// updates of installed plugins it hasn't announced yet.
|
||||
pub fn refresh_market(&self) -> IdeResult<Vec<MarketPlugin>> {
|
||||
|
||||
@@ -115,8 +115,6 @@ pub fn forwarded(event: &PluginEvent) -> bool {
|
||||
| PluginEvent::ViewMessage { .. }
|
||||
| PluginEvent::OpenTerminal { .. }
|
||||
| PluginEvent::OpenLocation { .. }
|
||||
| PluginEvent::CopyToClipboard { .. }
|
||||
| PluginEvent::OpenUrl { .. }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -197,12 +197,6 @@ 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
|
||||
|
||||
+17
-132
@@ -77,8 +77,6 @@ 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
|
||||
@@ -90,7 +88,6 @@ impl Host {
|
||||
return Ok(current);
|
||||
}
|
||||
self.close_project(window);
|
||||
self.wait_detached_closes();
|
||||
let sink: SharedSink = (self.sink_for)(window);
|
||||
// Before anything the project starts: its children get the project's environment.
|
||||
let trusted = Arc::new(AtomicBool::new(self.trust.trusted(&root)?));
|
||||
@@ -212,7 +209,6 @@ impl Host {
|
||||
cfg.launch_error = Some(e);
|
||||
None
|
||||
});
|
||||
ide_sdk::apply::configure_java_compiler(cfg, &data_dir, &effective.inspections);
|
||||
ide_sdk::java::configure_jdtls(
|
||||
cfg,
|
||||
server_root,
|
||||
@@ -232,11 +228,6 @@ 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(),
|
||||
);
|
||||
}
|
||||
}));
|
||||
}
|
||||
@@ -528,15 +519,12 @@ impl Host {
|
||||
root: &Path,
|
||||
project_key: &str,
|
||||
) -> (Arc<ide_db::DbManager>, Arc<ide_http::History>, Arc<ide_agent::AgentManager>) {
|
||||
let db = Arc::new(
|
||||
ide_db::DbManager::new(
|
||||
root.to_path_buf(),
|
||||
self.data_dir.join("db-passwords.json"),
|
||||
self.data_dir.join("db-history.json"),
|
||||
self.ssh.clone(),
|
||||
)
|
||||
.with_global_sources(self.data_dir.clone()),
|
||||
);
|
||||
let db = Arc::new(ide_db::DbManager::new(
|
||||
root.to_path_buf(),
|
||||
self.data_dir.join("db-passwords.json"),
|
||||
self.data_dir.join("db-history.json"),
|
||||
self.ssh.clone(),
|
||||
));
|
||||
let http_history = Arc::new(ide_http::History::new(self.data_dir.join("http-history").join(project_key)));
|
||||
let agents = Arc::new(ide_agent::AgentManager::new(
|
||||
ide_agent::AgentPaths {
|
||||
@@ -571,8 +559,6 @@ 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
|
||||
{
|
||||
@@ -580,7 +566,6 @@ impl Host {
|
||||
return Ok(current);
|
||||
}
|
||||
self.close_project(window);
|
||||
self.wait_detached_closes();
|
||||
let sink: SharedSink = (self.sink_for)(window);
|
||||
let settings = Arc::new(SettingsStore::load(&self.config_dir, None));
|
||||
let effective = settings.effective();
|
||||
@@ -885,14 +870,8 @@ impl Project {
|
||||
|
||||
/// Effective source / test / resource / excluded roots (detected + Project Structure marks).
|
||||
pub fn roots(&self) -> ProjectRoots {
|
||||
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)
|
||||
let detected = with_gradle_roots(&self.root, &self.build_cache_dir(), &self.detected_roots.read());
|
||||
linked_roots(&self.root, &self.settings.effective(), &detected)
|
||||
}
|
||||
|
||||
/// Where the Maven / Gradle tool windows cache what the build tools reported.
|
||||
@@ -1093,7 +1072,7 @@ fn content_roots(settings: &ide_core::settings::Settings, root: &Path, build_cac
|
||||
}
|
||||
let mut out: Vec<PathBuf> = 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, &settings.languages.java.gradle)) {
|
||||
for p in user.chain(ide_build::gradle_external_roots(root, build_cache)) {
|
||||
if p.is_absolute() && p.is_dir() && !p.starts_with(root) && !out.contains(&p) {
|
||||
out.push(p);
|
||||
}
|
||||
@@ -1197,93 +1176,31 @@ pub(crate) fn jdtls_maven_import(
|
||||
settings: &ide_core::settings::Settings,
|
||||
) -> (Vec<PathBuf>, Option<Vec<String>>) {
|
||||
let maven = &settings.languages.java.maven;
|
||||
let gradle = &settings.languages.java.gradle;
|
||||
if maven.unlinked_projects.is_empty() && gradle.linked_projects.is_none() {
|
||||
if maven.unlinked_projects.is_empty() {
|
||||
return (Vec::new(), None);
|
||||
}
|
||||
let model = ide_build::maven::load_linked(root, maven);
|
||||
let model = ide_build::maven::load(root);
|
||||
let unlinked = ide_build::maven::UnlinkedProjects::from_model(&model, maven);
|
||||
let excluded = unlinked.poms().map(PathBuf::from).collect();
|
||||
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 build_files = unlinked.import_poms(&model).map(|poms| {
|
||||
let gradle = ide_build::gradle::load(root).and_then(|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())
|
||||
});
|
||||
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()
|
||||
poms.iter().map(|pom| root.join(pom)).chain(gradle).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<PathBuf> {
|
||||
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,
|
||||
settings: &ide_core::settings::GradleSettings,
|
||||
detected: &[SourceFolder],
|
||||
) -> Vec<SourceFolder> {
|
||||
fn with_gradle_roots(root: &Path, build_cache: &Path, detected: &[SourceFolder]) -> Vec<SourceFolder> {
|
||||
use ide_build::gradle::ReportedRoot;
|
||||
use ide_workspace::roots::RootKind;
|
||||
let mut out: Vec<SourceFolder> = Vec::new();
|
||||
for (dir, kind) in ide_build::gradle_source_roots(root, build_cache, settings) {
|
||||
for (dir, kind) in ide_build::gradle_source_roots(build_cache) {
|
||||
if !dir.is_dir() {
|
||||
continue;
|
||||
}
|
||||
@@ -1453,37 +1370,6 @@ 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"), "<projectDescription/>").unwrap();
|
||||
}
|
||||
for dir in ["linked", "unlinked"] {
|
||||
std::fs::write(root.join(dir).join("settings.gradle"), "").unwrap();
|
||||
}
|
||||
std::fs::write(root.join("maven/pom.xml"), "<project/>").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();
|
||||
@@ -1581,8 +1467,7 @@ 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 };
|
||||
std::fs::write(root.join("build.gradle"), "").unwrap();
|
||||
let out = with_gradle_roots(&root, &cache, &Default::default(), &[guessed, other.clone()]);
|
||||
let out = with_gradle_roots(&root, &cache, &[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!(
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
//! System Settings › HTTP Proxy on the host: the global settings made current for
|
||||
//! connections and child processes, the password kept in the saved passwords
|
||||
//! (the OS credential store) instead of `settings.toml`, or for the session
|
||||
//! when there is no credential store.
|
||||
//! (owner-only `proxy-password.json`) instead of `settings.toml`.
|
||||
|
||||
use crate::Host;
|
||||
use ide_core::IdeResult;
|
||||
@@ -13,9 +12,7 @@ impl Host {
|
||||
/// `settings` with the saved proxy password filled in, as the Settings dialog edits it.
|
||||
pub fn with_proxy_password(&self, mut settings: Settings) -> Settings {
|
||||
// A password typed into settings.toml by hand counts too, unless one was saved.
|
||||
let session = || self.proxy_session_password.lock().clone();
|
||||
// The session copy is the latest write that could not be saved.
|
||||
if let Some(password) = session().or_else(|| self.proxy_password.get(PASSWORD_KEY)) {
|
||||
if let Some(password) = self.proxy_password.get(PASSWORD_KEY) {
|
||||
settings.proxy.password = password;
|
||||
}
|
||||
settings
|
||||
@@ -43,70 +40,11 @@ impl Host {
|
||||
_ => return Ok(Some(value)),
|
||||
};
|
||||
if let Some(password) = password {
|
||||
let password = password.as_str().filter(|p| !p.is_empty());
|
||||
let mut session = self.proxy_session_password.lock();
|
||||
*session = None;
|
||||
match password {
|
||||
Some(p) => {
|
||||
if let Err(e) = self.proxy_password.set(PASSWORD_KEY, p) {
|
||||
tracing::warn!("the HTTP proxy password is kept for this session only: {}", e.message);
|
||||
*session = Some(p.to_owned());
|
||||
}
|
||||
}
|
||||
match password.as_str().filter(|p| !p.is_empty()) {
|
||||
Some(p) => self.proxy_password.set(PASSWORD_KEY, p)?,
|
||||
None => self.proxy_password.delete(PASSWORD_KEY),
|
||||
}
|
||||
}
|
||||
Ok((key != "proxy.password").then_some(value))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ide_ssh::secrets::{SecretFile, StoreError, StoreResult, Vault};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A machine without a credential store (a headless Linux host).
|
||||
struct NoStore;
|
||||
|
||||
impl Vault for NoStore {
|
||||
fn available(&self) -> bool {
|
||||
false
|
||||
}
|
||||
fn get(&self, _: &str, _: &str) -> StoreResult<String> {
|
||||
Err(StoreError::NoDefaultStore)
|
||||
}
|
||||
fn set(&self, _: &str, _: &str, _: &str) -> StoreResult<()> {
|
||||
Err(StoreError::NoDefaultStore)
|
||||
}
|
||||
fn delete(&self, _: &str, _: &str) -> StoreResult<()> {
|
||||
Err(StoreError::NoDefaultStore)
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn without_a_credential_store_the_password_lasts_the_session() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut host = Host::new(crate::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(ide_core::NullSink)),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
});
|
||||
let path = tmp.path().join("data/proxy-password.json");
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&path, r#"{"httpProxy": "old"}"#).unwrap();
|
||||
host.proxy_password = SecretFile::with_vault(path, |v| serde_json::from_value(v).ok(), Arc::new(NoStore));
|
||||
|
||||
let left = host.take_proxy_password("proxy.password", serde_json::json!("s3cret")).unwrap();
|
||||
assert_eq!(left, None);
|
||||
assert_eq!(host.with_proxy_password(Settings::default()).proxy.password, "s3cret");
|
||||
host.take_proxy_password("proxy.password", serde_json::json!("")).unwrap();
|
||||
assert_eq!(host.with_proxy_password(Settings::default()).proxy.password, "");
|
||||
}
|
||||
}
|
||||
@@ -217,8 +217,6 @@ 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"
|
||||
@@ -251,8 +249,6 @@ 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"
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
use ide_core::NullSink;
|
||||
use ide_host::{Host, HostConfig, ops};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
fn options(root: &std::path::Path) -> ide_terminal::TerminalOptions {
|
||||
ide_terminal::TerminalOptions {
|
||||
@@ -53,53 +52,3 @@ fn closing_a_project_closes_its_terminals_and_keeps_other_windows() {
|
||||
host.terminals.kill_key("other");
|
||||
});
|
||||
}
|
||||
|
||||
/// The desktop closes a project on the event-loop thread while runtime workers may be
|
||||
/// blocked waiting for that thread (IPC responses, window queries): closing must not
|
||||
/// wait for workers that need the runtime, and exit still waits for them once free.
|
||||
#[test]
|
||||
fn detached_close_returns_while_runtime_workers_wait_for_the_closing_thread() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap().join("project");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let runtime = tokio::runtime::Builder::new_multi_thread().worker_threads(1).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();
|
||||
host.open_project("main", &root).unwrap();
|
||||
|
||||
// Occupy the only runtime worker until "the event loop" answers.
|
||||
let (started_tx, started_rx) = std::sync::mpsc::channel();
|
||||
let (release_tx, release_rx) = std::sync::mpsc::channel::<()>();
|
||||
runtime.spawn(async move {
|
||||
started_tx.send(()).unwrap();
|
||||
let _ = release_rx.recv();
|
||||
});
|
||||
started_rx.recv().unwrap();
|
||||
|
||||
let (closed_tx, closed_rx) = std::sync::mpsc::channel();
|
||||
let closer = host.clone();
|
||||
std::thread::spawn(move || {
|
||||
closer.close_project_detached("main");
|
||||
let _ = closed_tx.send(());
|
||||
});
|
||||
let returned = closed_rx.recv_timeout(Duration::from_secs(5));
|
||||
release_tx.send(()).unwrap();
|
||||
assert!(returned.is_ok(), "closing waited for a runtime worker that waits for the closing thread");
|
||||
assert!(host.project("main").is_err(), "the window no longer has the project");
|
||||
host.open_project("main", &root).expect("the window opens a project once the close has finished");
|
||||
|
||||
let started = Instant::now();
|
||||
host.shutdown_notebooks_on_exit();
|
||||
assert!(started.elapsed() < Duration::from_secs(5), "exit joined the finished close instead of timing out");
|
||||
}
|
||||
@@ -196,28 +196,6 @@ fn write_index(
|
||||
path.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
/// First run: nothing cached yet, so listing the marketplace reads its index
|
||||
/// instead of showing an empty one; later listings use the index read.
|
||||
#[test]
|
||||
fn the_first_marketplace_listing_reads_the_index() {
|
||||
let f = fixture();
|
||||
assert!(f.host.market_listing(false).unwrap().is_empty(), "no marketplace configured");
|
||||
|
||||
let market = f.work.join("market");
|
||||
std::fs::create_dir_all(&market).unwrap();
|
||||
let key = PublisherKey::from_seed([3; 32]);
|
||||
let (_, entry) = signed_hello(&market, "1.0.0", &key, &f.host);
|
||||
let url = write_index(&market, &key, vec![entry], vec![], false);
|
||||
f.host.set_market_url(Some(url.clone()));
|
||||
|
||||
let listed = f.host.market_listing(false).unwrap();
|
||||
assert_eq!(listed.iter().map(|p| p.entry.id.as_str()).collect::<Vec<_>>(), ["acme.hello"]);
|
||||
|
||||
std::fs::remove_file(&url).unwrap();
|
||||
assert_eq!(f.host.market_listing(false).unwrap().len(), 1, "the index read is kept");
|
||||
assert!(f.host.market_listing(true).is_err(), "refresh reads the index again");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_marketplace_plugins_install_and_revocations_disable_them() {
|
||||
let f = fixture();
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
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::<Vec<_>>(), ["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;
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
//! 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");
|
||||
}
|
||||
@@ -2077,62 +2077,6 @@ 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", "[email protected]: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("[email protected]: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());
|
||||
|
||||
@@ -1,289 +0,0 @@
|
||||
//! Opening each `fixtures/` project the way the app does, through the `ops` its commands call:
|
||||
//! the source and test roots detected from its build file, the index (files and symbols),
|
||||
//! Search Everywhere / Go to Class / Go to Symbol answered from it and kept current as files
|
||||
//! come and go, and the Maven / Gradle model.
|
||||
//!
|
||||
//! The `#[ignore]`d tests run Maven, Gradle, python3 and a JDK 21 (CI's `Language smoke`
|
||||
//! java, kotlin and python jobs):
|
||||
//! `cargo test -p ide-host --test project_smoke -- --ignored`.
|
||||
|
||||
use ide_host::{Host, HostConfig, ops};
|
||||
use ide_index::SymbolFilter;
|
||||
use ide_workspace::roots::RootKind;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const WINDOW: &str = "main";
|
||||
|
||||
struct Fixture {
|
||||
dir: &'static str,
|
||||
/// Detected roots: (project-relative path, kind).
|
||||
roots: &'static [(&'static str, RootKind)],
|
||||
/// Search Everywhere › Files: (query, file found).
|
||||
file: (&'static str, &'static str),
|
||||
/// Go to Class: (name, file declaring it).
|
||||
class: (&'static str, &'static str),
|
||||
/// Go to Symbol: (name, file declaring it).
|
||||
symbol: (&'static str, &'static str),
|
||||
/// A file created while the project is open: (path, text declaring the class `FreshWidget`).
|
||||
added: (&'static str, &'static str),
|
||||
}
|
||||
|
||||
struct Opened {
|
||||
host: Arc<Host>,
|
||||
root: PathBuf,
|
||||
_tmp: tempfile::TempDir,
|
||||
}
|
||||
|
||||
fn copy_dir(from: &Path, to: &Path) {
|
||||
std::fs::create_dir_all(to).unwrap();
|
||||
for entry in std::fs::read_dir(from).unwrap().flatten() {
|
||||
let (src, dst) = (entry.path(), to.join(entry.file_name()));
|
||||
if entry.file_type().unwrap().is_dir() {
|
||||
copy_dir(&src, &dst);
|
||||
} else {
|
||||
std::fs::copy(&src, &dst).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn open(fixture: &str) -> Opened {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap().join(fixture);
|
||||
copy_dir(&Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures").join(fixture), &root);
|
||||
let app = tmp.path().join("app");
|
||||
let host = Arc::new(Host::new(HostConfig {
|
||||
config_dir: app.join("config"),
|
||||
data_dir: app.join("data"),
|
||||
local_data_dir: app.join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(|_| {})),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
}));
|
||||
host.trust.decide(&root, true, false).unwrap();
|
||||
ops::project::open(&host, WINDOW, &root).await.unwrap();
|
||||
Opened { host, root, _tmp: tmp }
|
||||
}
|
||||
|
||||
/// Polls `check` until it holds; fails with its last answer.
|
||||
async fn until<T: std::fmt::Debug>(what: &str, timeout: Duration, mut check: impl FnMut() -> Result<(), T>) {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
match check() {
|
||||
Ok(()) => return,
|
||||
Err(last) if Instant::now() > deadline => panic!("{what}: timed out after {timeout:?}; last: {last:?}"),
|
||||
Err(_) => tokio::time::sleep(Duration::from_millis(100)).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Opened {
|
||||
fn files(&self, query: &str) -> Vec<String> {
|
||||
let found = ops::nav::find_files(&self.host, WINDOW, query.into(), 20).unwrap();
|
||||
found.into_iter().map(|f| f.rel_path).collect()
|
||||
}
|
||||
|
||||
fn symbols(&self, query: &str, filter: SymbolFilter) -> Vec<(String, String)> {
|
||||
let found = ops::nav::find_symbols(&self.host, WINDOW, query.into(), filter, 20).unwrap();
|
||||
found.into_iter().map(|s| (s.name, s.rel_path)).collect()
|
||||
}
|
||||
|
||||
fn has_symbol(&self, name: &str, file: &str, filter: SymbolFilter) -> Result<(), Vec<(String, String)>> {
|
||||
let found = self.symbols(name, filter);
|
||||
if found.iter().any(|(n, f)| n == name && f == file) { Ok(()) } else { Err(found) }
|
||||
}
|
||||
}
|
||||
|
||||
async fn smoke(f: Fixture) {
|
||||
let p = open(f.dir).await;
|
||||
until("indexing", Duration::from_secs(30), || {
|
||||
let s = ops::nav::index_status(&p.host, WINDOW).unwrap();
|
||||
if !s.indexing && s.files > 0 && s.symbols > 0 { Ok(()) } else { Err(s) }
|
||||
})
|
||||
.await;
|
||||
|
||||
let roots = ops::project::roots(&p.host, WINDOW).unwrap().roots;
|
||||
for (path, kind) in f.roots {
|
||||
assert!(
|
||||
roots.iter().any(|r| r.path == *path && r.kind == *kind),
|
||||
"{}: no {kind:?} root {path:?} in {roots:?}",
|
||||
f.dir
|
||||
);
|
||||
}
|
||||
|
||||
let files = p.files(f.file.0);
|
||||
assert!(files.iter().any(|r| r == f.file.1), "{}: files for {:?}: {files:?}", f.dir, f.file.0);
|
||||
if let Err(found) = p.has_symbol(f.class.0, f.class.1, SymbolFilter::Types) {
|
||||
panic!("{}: Go to Class {:?}: {found:?}", f.dir, f.class.0);
|
||||
}
|
||||
if let Err(found) = p.has_symbol(f.symbol.0, f.symbol.1, SymbolFilter::All) {
|
||||
panic!("{}: Go to Symbol {:?}: {found:?}", f.dir, f.symbol.0);
|
||||
}
|
||||
|
||||
// The index follows the file system while the project is open.
|
||||
let (added, text) = f.added;
|
||||
std::fs::write(p.root.join(added), text).unwrap();
|
||||
until("a new file's class", Duration::from_secs(20), || p.has_symbol("FreshWidget", added, SymbolFilter::Types))
|
||||
.await;
|
||||
std::fs::remove_file(p.root.join(added)).unwrap();
|
||||
until("a deleted file's class", Duration::from_secs(20), || {
|
||||
let found = p.symbols("FreshWidget", SymbolFilter::Types);
|
||||
if found.is_empty() { Ok(()) } else { Err(found) }
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn rust_app() {
|
||||
smoke(Fixture {
|
||||
dir: "rust-app",
|
||||
roots: &[("src", RootKind::Source), ("tests", RootKind::Test)],
|
||||
file: ("pars", "src/parser.rs"),
|
||||
class: ("Inventory", "src/lib.rs"),
|
||||
symbol: ("low_stock", "src/lib.rs"),
|
||||
added: ("src/fresh.rs", "pub struct FreshWidget;\n"),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn ts_app() {
|
||||
smoke(Fixture {
|
||||
dir: "ts-app",
|
||||
roots: &[("src", RootKind::Source), ("tests", RootKind::Test)],
|
||||
file: ("cart", "src/cart.ts"),
|
||||
class: ("Cart", "src/cart.ts"),
|
||||
symbol: ("formatMoney", "src/money.ts"),
|
||||
added: ("src/fresh.ts", "export class FreshWidget {}\n"),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn python_app() {
|
||||
smoke(Fixture {
|
||||
dir: "python-app",
|
||||
roots: &[("src", RootKind::Source), ("tests", RootKind::Test)],
|
||||
file: ("models", "src/tasks/models.py"),
|
||||
class: ("TaskList", "src/tasks/models.py"),
|
||||
symbol: ("build_demo", "src/tasks/cli.py"),
|
||||
added: ("src/tasks/fresh.py", "class FreshWidget:\n pass\n"),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn go_app() {
|
||||
smoke(Fixture {
|
||||
dir: "go-app",
|
||||
roots: &[("", RootKind::Source)],
|
||||
file: ("store", "internal/store/store.go"),
|
||||
class: ("Store", "internal/store/store.go"),
|
||||
symbol: ("writeJSON", "cmd/server/main.go"),
|
||||
added: ("internal/store/fresh.go", "package store\n\ntype FreshWidget struct{}\n"),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn java_app() {
|
||||
smoke(Fixture {
|
||||
dir: "java-app",
|
||||
roots: &[("src/main/java", RootKind::Source), ("src/test/java", RootKind::Test)],
|
||||
file: ("OrderBook", "src/main/java/com/example/orders/OrderBook.java"),
|
||||
class: ("OrderBook", "src/main/java/com/example/orders/OrderBook.java"),
|
||||
symbol: ("revenueCents", "src/main/java/com/example/orders/OrderBook.java"),
|
||||
added: (
|
||||
"src/main/java/com/example/orders/FreshWidget.java",
|
||||
"package com.example.orders;\n\npublic class FreshWidget {}\n",
|
||||
),
|
||||
})
|
||||
.await;
|
||||
let p = open("java-app").await;
|
||||
let model = ops::build::build_model(&p.host, WINDOW).await.unwrap();
|
||||
let maven = model.maven.expect("a Maven model");
|
||||
assert_eq!(maven.projects.iter().map(|m| m.artifact_id.as_str()).collect::<Vec<_>>(), ["openidea-fixture-orders"]);
|
||||
assert!(maven.projects[0].dependencies.iter().any(|d| d.artifact_id == "junit-jupiter"), "{maven:?}");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn kotlin_gradle() {
|
||||
smoke(Fixture {
|
||||
dir: "kotlin-gradle",
|
||||
roots: &[("src/main/kotlin", RootKind::Source), ("src/test/kotlin", RootKind::Test)],
|
||||
file: ("Library", "src/main/kotlin/com/example/library/Library.kt"),
|
||||
class: ("Library", "src/main/kotlin/com/example/library/Library.kt"),
|
||||
symbol: ("giveBack", "src/main/kotlin/com/example/library/Library.kt"),
|
||||
added: ("src/main/kotlin/com/example/library/Fresh.kt", "package com.example.library\n\nclass FreshWidget\n"),
|
||||
})
|
||||
.await;
|
||||
let p = open("kotlin-gradle").await;
|
||||
let model = ops::build::build_model(&p.host, WINDOW).await.unwrap();
|
||||
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::<Vec<_>>(), ["openidea-fixture-library"]);
|
||||
}
|
||||
|
||||
/// Maven / Gradle sync (Reload in the build tool window): the build's own model.
|
||||
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![], 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);
|
||||
model
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[ignore = "project smoke: needs Maven and a JDK"]
|
||||
async fn maven_sync() {
|
||||
let maven = sync("java-app", ide_build::BuildTool::Maven).await.maven.unwrap();
|
||||
assert_eq!(maven.sync_error, None);
|
||||
assert!(maven.synced_at.is_some());
|
||||
let junit = maven.projects[0].dependencies.iter().find(|d| d.artifact_id == "junit-jupiter").unwrap();
|
||||
// The resolved tree, not only the declaration.
|
||||
assert!(!junit.children.is_empty(), "{junit:?}");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[ignore = "project smoke: needs Gradle and a JDK"]
|
||||
async fn gradle_sync() {
|
||||
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();
|
||||
// The build's own tasks: the application and Kotlin plugins' too.
|
||||
for task in ["run", "compileKotlin", "test"] {
|
||||
assert!(tasks.contains(&task), "no {task} in {tasks:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[ignore = "project smoke: needs python3"]
|
||||
async fn python_venv() {
|
||||
let p = open("python-app").await;
|
||||
let made = std::process::Command::new("python3").args(["-m", "venv", ".venv"]).current_dir(&p.root).status();
|
||||
assert!(made.unwrap().success());
|
||||
// The project's virtualenv: listed first, and the interpreter the project uses.
|
||||
let found = ops::sdk::sdk_detect(&p.host, WINDOW, ide_sdk::SdkKind::Python).await.unwrap();
|
||||
assert_eq!(found.first().map(|s| s.source.as_str()), Some("venv"), "{found:?}");
|
||||
let effective = ops::sdk::sdk_python_effective(&p.host, WINDOW).unwrap().expect("an interpreter");
|
||||
assert!(effective.starts_with(p.root.join(".venv")), "{effective:?}");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[ignore = "project smoke: needs JDK 21"]
|
||||
async fn jdk() {
|
||||
let p = open("java-app").await;
|
||||
let found = ops::sdk::sdk_detect(&p.host, WINDOW, ide_sdk::SdkKind::Jdk).await.unwrap();
|
||||
assert!(found.iter().any(|s| s.version.as_deref().is_some_and(|v| v.starts_with("21"))), "{found:?}");
|
||||
}
|
||||
@@ -43,7 +43,6 @@ fn walk(dir: &std::path::Path) -> Vec<std::path::PathBuf> {
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn manual_proxy_applies_and_the_password_is_saved_apart() {
|
||||
ide_ssh::secrets::testing::use_memory_store();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let host = host(tmp.path());
|
||||
assert_eq!(proxy().mode, ProxyMode::Auto);
|
||||
@@ -62,7 +61,7 @@ async fn manual_proxy_applies_and_the_password_is_saved_apart() {
|
||||
|
||||
assert!(!settings_files(tmp.path()).contains("s3cret"), "no password in settings.toml");
|
||||
let saved = std::fs::read_to_string(tmp.path().join("data/proxy-password.json")).unwrap();
|
||||
assert!(!saved.contains("s3cret"), "the password is in the credential store, not on disk: {saved}");
|
||||
assert!(saved.contains("s3cret"));
|
||||
assert_eq!(ops::project::settings(&host, "main").proxy.password, "s3cret");
|
||||
|
||||
// A whole-table write keeps the password out of the file too; an empty one forgets it.
|
||||
|
||||
@@ -249,7 +249,6 @@ async fn malicious_project_stays_editable_then_starts_in_place_after_trust() {
|
||||
ide_core::RequestId(472),
|
||||
false,
|
||||
vec![],
|
||||
None,
|
||||
ops::Channel::new(|_| {}),
|
||||
)
|
||||
};
|
||||
|
||||
@@ -49,10 +49,6 @@ 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"));
|
||||
|
||||
@@ -69,21 +69,6 @@ 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<u8> = [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.
|
||||
|
||||
@@ -22,4 +22,3 @@ 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"
|
||||
@@ -32,7 +32,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.1";
|
||||
pub const API_VERSION: &str = "1.0";
|
||||
const EPOCH_TICK: Duration = Duration::from_millis(10);
|
||||
pub(crate) const MAX_TEXT: usize = 1 << 20;
|
||||
pub(crate) const MAX_TITLE: usize = 256;
|
||||
@@ -45,14 +45,6 @@ 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<StatusItem>);
|
||||
/// Roots of the open projects.
|
||||
@@ -74,10 +66,6 @@ pub trait HostApi: Send + Sync {
|
||||
fn storage_set(&self, key: &str, value: Option<String>) -> Result<(), HostError>;
|
||||
fn storage_keys(&self, prefix: &str) -> Result<Vec<String>, HostError>;
|
||||
fn vcs_status(&self, root: &Path) -> Result<Option<RepositoryStatus>, HostError>;
|
||||
fn vcs_remotes(&self, root: &Path) -> Result<Vec<(String, String)>, 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`.
|
||||
@@ -211,10 +199,8 @@ 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), 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).
|
||||
/// newer minor). Returns the API version the component was built for
|
||||
/// (`None`: it uses no plugin interface).
|
||||
pub fn check_component(&self, component: &Component) -> Result<Option<semver::Version>, String> {
|
||||
let ty = component.component_type();
|
||||
let imports: Vec<String> = ty.imports(&self.engine).map(|(name, _)| name.to_owned()).collect();
|
||||
@@ -231,20 +217,10 @@ 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/[email protected]`).
|
||||
const PLUGIN_PACKAGE_PREFIX: &str = "openidea:plugin/";
|
||||
|
||||
@@ -554,7 +530,11 @@ 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/") { link_error(&msg) } else { msg }
|
||||
if msg.contains("openidea:plugin/") {
|
||||
format!("{msg} (the component was built for a different plugin API; this IDE runs API {API_VERSION})")
|
||||
} else {
|
||||
msg
|
||||
}
|
||||
};
|
||||
let pre = engine.linker.instantiate_pre(component).map_err(api_hint)?;
|
||||
let plugin_indices = bindings::PluginIndices::new(&pre).map_err(api_hint)?;
|
||||
|
||||
@@ -242,24 +242,6 @@ 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 {
|
||||
@@ -456,14 +438,6 @@ 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<ProjectRes>) -> Fallible<Vec<(String, String)>> {
|
||||
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 {
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
//! `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<Option<semver::Version>, 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/[email protected]",
|
||||
),
|
||||
("no plugin exports", r#"(component (import "openidea:plugin/[email protected]" (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}");
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
455d848dd27f242bb3e9bf420558cbc8c200853f51ada3d481635ac9771803f3
|
||||
1f7402e2b1b4602e4073eb61837c3dc8840f9a4f7275d800dbd6fcf6ce5e40c3
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
f7e1ae06a90460ed1d2319bfc51f848654df145fa451f3d959b32776b2b25ce3
|
||||
4f6cd5b5e0c12e7dcbccf66a6ad97276bf968bc3ad26f0fd08e1f7765cbfee14
|
||||
+1
-1
@@ -12,7 +12,7 @@ activation = ["on-view:jobs", "on-command:*"]
|
||||
|
||||
[capabilities]
|
||||
required = ["process:jobs"]
|
||||
optional = ["project.read", "vcs.read", "clipboard"]
|
||||
optional = ["project.read"]
|
||||
|
||||
[[contributes.views]]
|
||||
id = "jobs"
|
||||
|
||||
+1
-14
@@ -32,22 +32,9 @@ impl Jobs {
|
||||
}
|
||||
|
||||
impl Plugin for Jobs {
|
||||
fn run_command(&mut self, id: &str, ctx: &Invocation, arg: Option<&str>) -> Result<Option<String>, Error> {
|
||||
fn run_command(&mut self, id: &str, _ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, 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::<Vec<_>>().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}"))),
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
12973c7ca7330fc50eb16718fd769d412401b5a92bd014f4804659e4a272122f
|
||||
3d4dd1b6365c7fbafdd3c1f99f4405e5ee40fc5e56c97da960adfe7af2e7edb8
|
||||
+2
-2
@@ -5,8 +5,8 @@ wit_bindgen::generate!({
|
||||
inline: "
|
||||
package fixture:misbehave;
|
||||
world misbehave {
|
||||
include openidea:plugin/plugin-syntax@1.1.0;
|
||||
export openidea:plugin/providers@1.1.0;
|
||||
include openidea:plugin/plugin-syntax@1.0.0;
|
||||
export openidea:plugin/providers@1.0.0;
|
||||
}
|
||||
",
|
||||
path: "../../../../../wit",
|
||||
|
||||
@@ -564,8 +564,9 @@ fn spring_boot_apps(root: &Path, build: &Path, out: &mut Vec<RunConfig>) {
|
||||
Some(m) => format!("application:{m}:{class}"),
|
||||
None => format!("application:{class}"),
|
||||
};
|
||||
let mut app =
|
||||
config(id, simple, "spring-boot", "java", &["-cp", "${classpath}", &class]).debug("java-debug");
|
||||
let mut app = config(id, simple, "spring-boot", "java", &["-cp", "${classpath}", &class])
|
||||
.in_dir(module_rel.as_ref().map(PathBuf::from))
|
||||
.debug("java-debug");
|
||||
app.extras.module = Some(module_rel.clone().unwrap_or_default());
|
||||
out.push(app);
|
||||
}
|
||||
|
||||
@@ -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, None);
|
||||
assert_eq!(app.cwd.as_deref(), Some(Path::new("RuoYi-Vue-Plus/ruoyi-admin")));
|
||||
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(), app.extras.module.as_deref()), (None, Some(module.as_str())), "{prefix}");
|
||||
assert_eq!(app.cwd.as_deref(), Some(Path::new(&module)));
|
||||
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(), api.extras.module.as_deref()), (None, Some("services/api/app")));
|
||||
assert_eq!(api.cwd.as_deref(), Some(Path::new("services/api/app")));
|
||||
let billing = find(&c, "application:billing:Billing");
|
||||
assert_eq!((billing.cwd.as_deref(), billing.extras.module.as_deref()), (None, Some("billing")));
|
||||
assert_eq!(billing.cwd.as_deref(), Some(Path::new("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"]);
|
||||
|
||||
+8
-120
@@ -5,7 +5,7 @@ use crate::discover::{project_venv_python, workspace_tsdk};
|
||||
use crate::exec::{path_with, search_path};
|
||||
use crate::{angular, godot, pinned, vue};
|
||||
use crate::{bin_dir, exe, venv_of};
|
||||
use ide_core::settings::{InspectionLevel, InspectionSettings, LanguageServerSettings, Settings};
|
||||
use ide_core::settings::{LanguageServerSettings, Settings};
|
||||
use ide_core::version_managers::Tool;
|
||||
use ide_language::{LanguageConfig, LanguageServerConfig};
|
||||
use ide_run::RunConfig;
|
||||
@@ -429,36 +429,6 @@ pub fn configure_maven_import(
|
||||
}
|
||||
}
|
||||
|
||||
/// Explicit Gradle links constrain discovery and isolate JDT's persisted import state.
|
||||
pub fn configure_gradle_import(
|
||||
cfg: &mut LanguageServerConfig,
|
||||
linked: Option<&[String]>,
|
||||
build_files: Option<&[String]>,
|
||||
) {
|
||||
let Some(linked) = linked.filter(|_| cfg.name == "jdtls") else { return };
|
||||
merge_init_options(
|
||||
cfg,
|
||||
json!({
|
||||
"settings": {"java": {"import": {"gradle": {"enabled": !linked.is_empty()}}}},
|
||||
"projectConfigurations": build_files.unwrap_or_default()
|
||||
}),
|
||||
);
|
||||
if let Some(settings) = &mut cfg.settings {
|
||||
merge(settings, json!({"java": {"import": {"gradle": {"enabled": !linked.is_empty()}}}}));
|
||||
}
|
||||
if let Some(index) = cfg.args.iter().position(|arg| arg == "-data")
|
||||
&& let Some(workspace) = cfg.args.get_mut(index + 1)
|
||||
{
|
||||
let mut hash = 0xcbf29ce484222325u64;
|
||||
for dir in linked {
|
||||
for byte in dir.bytes().chain([0]) {
|
||||
hash = (hash ^ u64::from(byte)).wrapping_mul(0x100000001b3);
|
||||
}
|
||||
}
|
||||
*workspace = format!("{workspace}-gradle-{hash:016x}");
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn merge_init_options(cfg: &mut LanguageServerConfig, layer: Value) {
|
||||
let mut current =
|
||||
cfg.initialization_options.as_ref().and_then(|v| serde_json::to_value(v).ok()).unwrap_or_else(|| json!({}));
|
||||
@@ -472,41 +442,15 @@ pub(crate) fn merge_init_options(cfg: &mut LanguageServerConfig, layer: Value) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Editor › Inspections › Java: "Serializable class without 'serialVersionUID'" (off by default,
|
||||
/// as in IntelliJ IDEA). ECJ reports it, so it is a compiler option rather than a filter.
|
||||
pub const SERIAL_VERSION_INSPECTION: &str = "java:missing-serial-version";
|
||||
|
||||
/// ECJ's level for a missing `serialVersionUID`, from the inspection profile in effect.
|
||||
fn missing_serial_version(inspections: &InspectionSettings) -> Option<&'static str> {
|
||||
let o = inspections.active().get(SERIAL_VERSION_INSPECTION)?;
|
||||
if o.enabled != Some(true) {
|
||||
return None;
|
||||
}
|
||||
Some(match o.severity {
|
||||
Some(InspectionLevel::Error) => "error",
|
||||
None | Some(InspectionLevel::Warning) => "warning",
|
||||
Some(_) => "info",
|
||||
})
|
||||
}
|
||||
|
||||
/// Workspace compiler defaults; Eclipse project preferences still take precedence.
|
||||
/// Each variant has its own file, so changing an inspection restarts the server with it.
|
||||
pub fn configure_java_compiler(cfg: &mut LanguageServerConfig, data_dir: &Path, inspections: &InspectionSettings) {
|
||||
pub(crate) fn configure_java_compiler(cfg: &mut LanguageServerConfig, data_dir: &Path) {
|
||||
let file = data_dir.join("lsp/jdtls/compiler-defaults.prefs");
|
||||
const DEFAULTS: &str = "org.eclipse.jdt.core.compiler.problem.forbiddenReference=warning\n\
|
||||
org.eclipse.jdt.core.compiler.problem.discouragedReference=warning\n";
|
||||
let serial = missing_serial_version(inspections);
|
||||
let (name, content) = match serial {
|
||||
None => ("compiler-defaults.prefs".to_owned(), DEFAULTS.to_owned()),
|
||||
Some(level) => (
|
||||
format!("compiler-defaults-serial-{level}.prefs"),
|
||||
format!("{DEFAULTS}org.eclipse.jdt.core.compiler.problem.missingSerialVersion={level}\n"),
|
||||
),
|
||||
};
|
||||
let file = data_dir.join("lsp/jdtls").join(name);
|
||||
let write = || -> ide_core::IdeResult<()> {
|
||||
std::fs::create_dir_all(file.parent().expect("compiler preferences directory"))?;
|
||||
if std::fs::read_to_string(&file).ok().as_deref() != Some(content.as_str()) {
|
||||
ide_vfs::write_atomic(&file, content.as_bytes())?;
|
||||
if std::fs::read_to_string(&file).ok().as_deref() != Some(DEFAULTS) {
|
||||
ide_vfs::write_atomic(&file, DEFAULTS.as_bytes())?;
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
@@ -1151,50 +1095,13 @@ mod java_compiler_tests {
|
||||
serde_json::to_value(cfg.initialization_options.as_ref().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serial_version_inspection_sets_the_compiler_option() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let url = |inspections: &InspectionSettings| {
|
||||
let mut cfg = custom_server("jdtls", &LanguageServerSettings::default());
|
||||
configure_java_compiler(&mut cfg, tmp.path(), inspections);
|
||||
options(&cfg).pointer("/settings/java/settings/url").unwrap().as_str().unwrap().to_owned()
|
||||
};
|
||||
let set = |o: ide_core::settings::InspectionOverride| {
|
||||
let mut s = InspectionSettings::default();
|
||||
s.default.insert(SERIAL_VERSION_INSPECTION.into(), o);
|
||||
s
|
||||
};
|
||||
let off = url(&InspectionSettings::default());
|
||||
assert!(!std::fs::read_to_string(&off).unwrap().contains("missingSerialVersion"));
|
||||
let on = url(&set(ide_core::settings::InspectionOverride { enabled: Some(true), ..Default::default() }));
|
||||
assert_ne!(on, off, "a new file, so the server restarts with it");
|
||||
assert!(std::fs::read_to_string(&on).unwrap().ends_with("compiler.problem.missingSerialVersion=warning\n"));
|
||||
let error = url(&set(ide_core::settings::InspectionOverride {
|
||||
enabled: Some(true),
|
||||
severity: Some(InspectionLevel::Error),
|
||||
..Default::default()
|
||||
}));
|
||||
assert!(std::fs::read_to_string(error).unwrap().ends_with("missingSerialVersion=error\n"));
|
||||
let weak = url(&set(ide_core::settings::InspectionOverride {
|
||||
enabled: Some(true),
|
||||
severity: Some(InspectionLevel::WeakWarning),
|
||||
..Default::default()
|
||||
}));
|
||||
assert!(std::fs::read_to_string(weak).unwrap().ends_with("missingSerialVersion=info\n"));
|
||||
let severity_only = url(&set(ide_core::settings::InspectionOverride {
|
||||
severity: Some(InspectionLevel::Error),
|
||||
..Default::default()
|
||||
}));
|
||||
assert_eq!(severity_only, off, "off by default");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compiler_defaults_live_in_the_cache_and_preserve_other_options() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let cache = tmp.path().join("cache with spaces 测试");
|
||||
let mut cfg = custom_server("jdtls", &LanguageServerSettings::default());
|
||||
merge_init_options(&mut cfg, json!({"settings": {"java": {"format": {"tabSize": 4}}}}));
|
||||
configure_java_compiler(&mut cfg, &cache, &InspectionSettings::default());
|
||||
configure_java_compiler(&mut cfg, &cache);
|
||||
let options = options(&cfg);
|
||||
let path = PathBuf::from(options.pointer("/settings/java/settings/url").unwrap().as_str().unwrap());
|
||||
assert!(path.starts_with(&cache));
|
||||
@@ -1205,7 +1112,7 @@ mod java_compiler_tests {
|
||||
}
|
||||
assert_eq!(options.pointer("/settings/java/format/tabSize"), Some(&json!(4)));
|
||||
let modified = std::fs::metadata(&path).unwrap().modified().unwrap();
|
||||
configure_java_compiler(&mut cfg, &cache, &InspectionSettings::default());
|
||||
configure_java_compiler(&mut cfg, &cache);
|
||||
assert_eq!(std::fs::metadata(path).unwrap().modified().unwrap(), modified);
|
||||
assert_eq!(self::options(&cfg), options);
|
||||
}
|
||||
@@ -1215,7 +1122,7 @@ mod java_compiler_tests {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut cfg = custom_server("jdtls", &LanguageServerSettings::default());
|
||||
merge_init_options(&mut cfg, json!({"settings": {"java": {"settings": {"url": "own.prefs"}}}}));
|
||||
configure_java_compiler(&mut cfg, tmp.path(), &InspectionSettings::default());
|
||||
configure_java_compiler(&mut cfg, tmp.path());
|
||||
assert_eq!(options(&cfg).pointer("/settings/java/settings/url"), Some(&json!("own.prefs")));
|
||||
|
||||
for settings in [
|
||||
@@ -1248,25 +1155,6 @@ mod java_compiler_tests {
|
||||
mod maven_linkage_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn explicit_gradle_links_scope_jdt_import_and_unlink_all_disables_gradle() {
|
||||
let mut cfg = custom_server("jdtls", &LanguageServerSettings::default());
|
||||
cfg.args = vec!["-data".into(), "/cache/jdtls".into()];
|
||||
let files = ["file:///p/a/settings.gradle".to_owned(), "file:///p/maven/pom.xml".to_owned()];
|
||||
configure_gradle_import(&mut cfg, Some(&["a".into()]), Some(&files));
|
||||
let options = serde_json::to_value(&cfg.initialization_options).unwrap();
|
||||
assert_eq!(options["projectConfigurations"], json!(files));
|
||||
assert_eq!(options.pointer("/settings/java/import/gradle/enabled"), Some(&json!(true)));
|
||||
assert!(cfg.args[1].starts_with("/cache/jdtls-gradle-"));
|
||||
let linked_cache = cfg.args[1].clone();
|
||||
cfg.args[1] = "/cache/jdtls".into();
|
||||
configure_gradle_import(&mut cfg, Some(&[]), Some(&files[1..]));
|
||||
let options = serde_json::to_value(&cfg.initialization_options).unwrap();
|
||||
assert_eq!(options["projectConfigurations"], json!(&files[1..]));
|
||||
assert_eq!(options.pointer("/settings/java/import/gradle/enabled"), Some(&json!(false)));
|
||||
assert_ne!(linked_cache, cfg.args[1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_excludes_unlinked_modules_and_changes_persistent_workspace() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -184,12 +184,6 @@ fn python(root: Option<&Path>) -> Vec<Sdk> {
|
||||
par_map(&candidates, |(path, source)| python_sdk(path, source, root))
|
||||
}
|
||||
|
||||
/// Interpreter `path` (one the project is set to, wherever it lives) as an SDK, named
|
||||
/// like the detected ones.
|
||||
pub fn python_interpreter_sdk(path: &Path, root: Option<&Path>) -> Option<Sdk> {
|
||||
python_sdk(path, if venv_of(path).is_some() { "venv" } else { "system" }, root)
|
||||
}
|
||||
|
||||
pub(crate) fn python_sdk(path: &Path, source: &str, root: Option<&Path>) -> Option<Sdk> {
|
||||
let version = query(path, &["--version"], VERSION_TIMEOUT)
|
||||
.and_then(|v| v.strip_prefix("Python ").map(str::to_owned))
|
||||
@@ -199,11 +193,10 @@ pub(crate) fn python_sdk(path: &Path, source: &str, root: Option<&Path>) -> Opti
|
||||
let label = match (&venv, source) {
|
||||
(Some(home), _) => {
|
||||
let dir = home.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
// `.venv` inside a project reads better as the folder that holds it (the project,
|
||||
// or a member such as `backend`), like PyCharm.
|
||||
// `.venv` inside a project reads better as the project name, like PyCharm.
|
||||
let owner = home.parent().and_then(|p| p.file_name()).map(|n| n.to_string_lossy().into_owned());
|
||||
match (dir.starts_with('.') || dir == "venv" || dir == "env", owner, root) {
|
||||
(true, Some(owner), Some(root)) if home.starts_with(root) => format!("Python {short} ({owner})"),
|
||||
(true, Some(owner), Some(root)) if home.parent() == Some(root) => format!("Python {short} ({owner})"),
|
||||
_ => format!("Python {short} ({dir})"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -369,6 +369,7 @@ pub fn configure_jdtls(
|
||||
if cfg.name != "jdtls" {
|
||||
return;
|
||||
}
|
||||
crate::apply::configure_java_compiler(cfg, data_dir);
|
||||
// A Lombok agent from the user's JVM arguments wins.
|
||||
let own_agent = crate::lombok::agent_in_args(cfg.args.iter().map(String::as_str));
|
||||
let agent = own_agent.as_deref().or(lombok);
|
||||
|
||||
@@ -28,7 +28,7 @@ use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub use discover::{detect, jdk_sdk, python_interpreter_sdk};
|
||||
pub use discover::{detect, jdk_sdk};
|
||||
|
||||
/// What [`detect`] looks for.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
|
||||
@@ -76,23 +76,6 @@ fn project_venv_is_the_default_interpreter() {
|
||||
assert_eq!(python_interpreter(tmp.path(), &s).unwrap(), Path::new("/opt/py/bin/python3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_interpreter_outside_detection_is_named_like_a_detected_one() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("shop");
|
||||
let member = fake_venv(&root.join("backend"), ".venv");
|
||||
let sdk = crate::python_interpreter_sdk(&member, Some(&root)).unwrap();
|
||||
assert_eq!(
|
||||
(sdk.name.as_str(), sdk.version.as_deref(), sdk.source.as_str()),
|
||||
("Python 3.12 (backend)", Some("3.12.1"), "venv")
|
||||
);
|
||||
let own = fake_venv(&root, ".venv");
|
||||
assert_eq!(crate::python_interpreter_sdk(&own, Some(&root)).unwrap().name, "Python 3.12 (shop)");
|
||||
// Outside the project the folder name says which one it is.
|
||||
let elsewhere = fake_venv(&tmp.path().join("other"), ".venv");
|
||||
assert_eq!(crate::python_interpreter_sdk(&elsewhere, Some(&root)).unwrap().name, "Python 3.12 (.venv)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pyright_gets_python_path_and_venv_env() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -14,20 +14,15 @@ tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
parking_lot.workspace = true
|
||||
dirs.workspace = true
|
||||
uuid.workspace = true
|
||||
keyring.workspace = true
|
||||
keyring-core.workspace = true
|
||||
russh = { version = "0.64", default-features = false, features = ["ring", "rsa", "flate2"] }
|
||||
russh-config = "0.58.0"
|
||||
russh-sftp = "2.4"
|
||||
flate2 = "1"
|
||||
|
||||
[features]
|
||||
# `testing`: an in-process SSH / SFTP server for tests of crates built on this one,
|
||||
# and an in-memory credential store in place of the OS one.
|
||||
testing = ["keyring-core/sample"]
|
||||
# `testing`: an in-process SSH / SFTP server for tests of crates built on this one.
|
||||
testing = []
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
|
||||
tempfile.workspace = true
|
||||
keyring-core = { workspace = true, features = ["sample"] }
|
||||
@@ -216,7 +216,6 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
fn store() -> (tempfile::TempDir, GitCredentials) {
|
||||
crate::secrets::testing::use_memory_store();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("git-credentials.json");
|
||||
(tmp, GitCredentials { saved: Arc::new(SecretFile::new(path)), sessions: Arc::default() })
|
||||
|
||||
+31
-499
@@ -1,289 +1,61 @@
|
||||
//! Saved passwords (SSH passwords / key passphrases by configuration id, git HTTPS
|
||||
//! credentials, the HTTP proxy's) in the OS credential store: Windows Credential
|
||||
//! Manager, the macOS Keychain, the Secret Service on Linux. The file at `path` only
|
||||
//! indexes them (an id and the keys, never a secret); owner-only on Unix.
|
||||
//!
|
||||
//! Older versions kept the secrets themselves in that file: they are moved to the
|
||||
//! credential store on first use. Without a credential store (a headless Linux
|
||||
//! host) such a file is still read and forgetting a secret rewrites it without
|
||||
//! that one, but no new secret is written to disk: saving fails and callers keep
|
||||
//! the secret for the session only.
|
||||
//! Saved passwords (SSH passwords / key passphrases by configuration id, the HTTP
|
||||
//! proxy's), in the app's data directory rather than the OS credential store (no
|
||||
//! system prompts). One JSON object, key → secret; owner-only on Unix.
|
||||
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
pub use keyring_core::{Error as StoreError, Result as StoreResult};
|
||||
use parking_lot::Mutex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
const SERVICE: &str = "ai.l0ng.openidea";
|
||||
|
||||
/// Serialises read-modify-write of the index files (several stores may share one).
|
||||
static LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
/// Reads the secrets of an older version's file, by key (`None`: not that format).
|
||||
pub type Legacy = fn(serde_json::Value) -> Option<BTreeMap<String, String>>;
|
||||
|
||||
/// Where the secrets are kept: the default credential store, or a stand-in in tests.
|
||||
pub trait Vault: Send + Sync {
|
||||
fn available(&self) -> bool;
|
||||
fn get(&self, service: &str, key: &str) -> StoreResult<String>;
|
||||
fn set(&self, service: &str, key: &str, secret: &str) -> StoreResult<()>;
|
||||
fn delete(&self, service: &str, key: &str) -> StoreResult<()>;
|
||||
}
|
||||
|
||||
/// `keyring_core`'s default store: the OS's, unless a test installed another one.
|
||||
struct DefaultStore;
|
||||
|
||||
impl DefaultStore {
|
||||
fn entry(&self, service: &str, key: &str) -> StoreResult<keyring_core::Entry> {
|
||||
if !self.available() {
|
||||
return Err(StoreError::NoDefaultStore);
|
||||
}
|
||||
keyring_core::Entry::new(service, key)
|
||||
}
|
||||
}
|
||||
|
||||
impl Vault for DefaultStore {
|
||||
fn available(&self) -> bool {
|
||||
if keyring_core::get_default_store().is_some() {
|
||||
return true;
|
||||
}
|
||||
static WARNED: std::sync::Once = std::sync::Once::new();
|
||||
match keyring::Entry::store_status() {
|
||||
Ok(()) => true,
|
||||
Err(e) => {
|
||||
WARNED.call_once(|| tracing::warn!("saved passwords: no credential store: {e}"));
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get(&self, service: &str, key: &str) -> StoreResult<String> {
|
||||
self.entry(service, key)?.get_password()
|
||||
}
|
||||
|
||||
fn set(&self, service: &str, key: &str, secret: &str) -> StoreResult<()> {
|
||||
self.entry(service, key)?.set_password(secret)
|
||||
}
|
||||
|
||||
fn delete(&self, service: &str, key: &str) -> StoreResult<()> {
|
||||
self.entry(service, key)?.delete_credential()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SecretFile {
|
||||
path: PathBuf,
|
||||
legacy: Legacy,
|
||||
vault: Arc<dyn Vault>,
|
||||
}
|
||||
|
||||
/// The file's contents. `id` keeps the credentials of different data directories
|
||||
/// (and of tests) apart in the one OS store.
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct Index {
|
||||
id: String,
|
||||
keys: BTreeSet<String>,
|
||||
}
|
||||
|
||||
enum Contents {
|
||||
Index(Index),
|
||||
/// An older version's file that could not be moved, and why.
|
||||
Plaintext(BTreeMap<String, String>, String),
|
||||
}
|
||||
|
||||
fn service(id: &str) -> String {
|
||||
format!("{SERVICE}/{id}")
|
||||
}
|
||||
|
||||
fn new_id() -> String {
|
||||
uuid::Uuid::new_v4().to_string()
|
||||
lock: Mutex<()>,
|
||||
}
|
||||
|
||||
impl SecretFile {
|
||||
pub fn new(path: PathBuf) -> Self {
|
||||
Self::with_legacy(path, |v| serde_json::from_value(v).ok())
|
||||
}
|
||||
|
||||
pub fn with_legacy(path: PathBuf, legacy: Legacy) -> Self {
|
||||
Self::with_vault(path, legacy, Arc::new(DefaultStore))
|
||||
}
|
||||
|
||||
pub fn with_vault(path: PathBuf, legacy: Legacy, vault: Arc<dyn Vault>) -> Self {
|
||||
Self { path, legacy, vault }
|
||||
Self { path, lock: Mutex::new(()) }
|
||||
}
|
||||
|
||||
pub fn get(&self, key: &str) -> Option<String> {
|
||||
let _g = LOCK.lock();
|
||||
match self.load() {
|
||||
Contents::Index(mut index) if index.keys.contains(key) => match self.vault.get(&service(&index.id), key) {
|
||||
Ok(secret) => Some(secret),
|
||||
Err(StoreError::NoEntry) => {
|
||||
// Removed outside the app: stop listing it.
|
||||
index.keys.remove(key);
|
||||
self.write_logged(&index);
|
||||
None
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("saved passwords: {key}: {e}");
|
||||
None
|
||||
}
|
||||
},
|
||||
Contents::Index(_) => None,
|
||||
Contents::Plaintext(mut old, _) => old.remove(key),
|
||||
}
|
||||
let _g = self.lock.lock();
|
||||
self.read().remove(key)
|
||||
}
|
||||
|
||||
pub fn set(&self, key: &str, secret: &str) -> IdeResult<()> {
|
||||
let _g = LOCK.lock();
|
||||
let mut index = match self.load() {
|
||||
Contents::Index(index) => index,
|
||||
Contents::Plaintext(_, why) => return Err(unavailable(&why)),
|
||||
};
|
||||
let service = service(&index.id);
|
||||
self.vault.set(&service, key, secret).map_err(|e| unavailable(&e.to_string()))?;
|
||||
if index.keys.insert(key.to_owned())
|
||||
&& let Err(e) = self.write(&index)
|
||||
{
|
||||
let _ = self.vault.delete(&service, key);
|
||||
return Err(e);
|
||||
}
|
||||
Ok(())
|
||||
let _g = self.lock.lock();
|
||||
let mut all = self.read();
|
||||
all.insert(key.to_owned(), secret.to_owned());
|
||||
self.write(&all)
|
||||
}
|
||||
|
||||
pub fn delete(&self, key: &str) {
|
||||
let _g = LOCK.lock();
|
||||
match self.load() {
|
||||
Contents::Index(mut index) => {
|
||||
if !index.keys.contains(key) {
|
||||
return;
|
||||
}
|
||||
match self.vault.delete(&service(&index.id), key) {
|
||||
Ok(()) | Err(StoreError::NoEntry) => {}
|
||||
Err(e) => {
|
||||
// Kept in the index so that the next delete tries again.
|
||||
tracing::warn!("saved passwords: could not forget {key}: {e}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
index.keys.remove(key);
|
||||
self.write_logged(&index);
|
||||
}
|
||||
Contents::Plaintext(mut old, _) => {
|
||||
if old.remove(key).is_none() {
|
||||
return;
|
||||
}
|
||||
// A move halfway may have copied it already.
|
||||
if let Some(id) = self.pending_id()
|
||||
&& let Err(e) = self.vault.delete(&service(&id), key)
|
||||
&& !matches!(e, StoreError::NoEntry)
|
||||
{
|
||||
tracing::warn!("saved passwords: could not forget {key}: {e}");
|
||||
return;
|
||||
}
|
||||
self.write_logged(&old);
|
||||
}
|
||||
let _g = self.lock.lock();
|
||||
let mut all = self.read();
|
||||
if all.remove(key).is_some()
|
||||
&& let Err(e) = self.write(&all)
|
||||
{
|
||||
tracing::warn!("{}", e.message);
|
||||
}
|
||||
}
|
||||
|
||||
/// Every stored key, sorted (the secrets themselves stay private).
|
||||
pub fn keys(&self) -> Vec<String> {
|
||||
let _g = LOCK.lock();
|
||||
match self.load() {
|
||||
Contents::Index(index) => index.keys.into_iter().collect(),
|
||||
Contents::Plaintext(old, _) => old.into_keys().collect(),
|
||||
let _g = self.lock.lock();
|
||||
self.read().into_keys().collect()
|
||||
}
|
||||
|
||||
fn read(&self) -> BTreeMap<String, String> {
|
||||
match std::fs::read(&self.path) {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|e| {
|
||||
tracing::warn!("saved passwords: {}: {e}", self.path.display());
|
||||
BTreeMap::new()
|
||||
}),
|
||||
Err(_) => BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn load(&self) -> Contents {
|
||||
let value = match std::fs::read(&self.path) {
|
||||
Err(_) => return Contents::Index(Index { id: new_id(), keys: BTreeSet::new() }),
|
||||
Ok(bytes) => match serde_json::from_slice::<serde_json::Value>(&bytes) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return self.set_aside(&e.to_string()),
|
||||
},
|
||||
};
|
||||
if let Ok(index) = serde_json::from_value::<Index>(value.clone()) {
|
||||
return Contents::Index(index);
|
||||
}
|
||||
match (self.legacy)(value) {
|
||||
Some(old) => self.migrate(old),
|
||||
None => self.set_aside("unknown format"),
|
||||
}
|
||||
}
|
||||
|
||||
/// An unreadable file is renamed rather than overwritten (it may index
|
||||
/// credentials, or hold secrets); the store starts over.
|
||||
fn set_aside(&self, why: &str) -> Contents {
|
||||
let aside = self.path.with_extension(format!("json.unreadable-{}", new_id()));
|
||||
tracing::warn!("saved passwords: {}: {why}; moved to {}", self.path.display(), aside.display());
|
||||
if let Err(e) = std::fs::rename(&self.path, &aside) {
|
||||
// Not overwritten either: nothing is saved until it can be moved.
|
||||
return Contents::Plaintext(BTreeMap::new(), format!("{}: {e}", self.path.display()));
|
||||
}
|
||||
Contents::Index(Index { id: new_id(), keys: BTreeSet::new() })
|
||||
}
|
||||
|
||||
fn pending_path(&self) -> PathBuf {
|
||||
self.path.with_extension("json.migrating")
|
||||
}
|
||||
|
||||
/// The id of a move that has not finished (see [`SecretFile::migrate`]).
|
||||
fn pending_id(&self) -> Option<String> {
|
||||
let id = std::fs::read_to_string(self.pending_path()).ok()?;
|
||||
Some(id.trim().to_owned()).filter(|id| !id.is_empty())
|
||||
}
|
||||
|
||||
/// Moves an older version's secrets to the credential store, key by key; a secret
|
||||
/// the store refuses for good (too long, say) is forgotten, and the user is asked
|
||||
/// for it again. The old file is replaced only when everything else was moved.
|
||||
/// Until then the id waits in `<file>.migrating`, so a retry after a failure or a
|
||||
/// crash overwrites the same credentials instead of copying them again.
|
||||
fn migrate(&self, old: BTreeMap<String, String>) -> Contents {
|
||||
if !self.vault.available() {
|
||||
return Contents::Plaintext(old, "no credential store".into());
|
||||
}
|
||||
let pending = self.pending_path();
|
||||
let id = match self.pending_id() {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
let id = new_id();
|
||||
if let Err(e) = std::fs::write(&pending, &id) {
|
||||
return Contents::Plaintext(old, format!("{}: {e}", pending.display()));
|
||||
}
|
||||
id
|
||||
}
|
||||
};
|
||||
let mut index = Index { id, keys: BTreeSet::new() };
|
||||
for (key, secret) in &old {
|
||||
match self.vault.set(&service(&index.id), key, secret) {
|
||||
Ok(()) => {
|
||||
index.keys.insert(key.clone());
|
||||
}
|
||||
Err(e @ (StoreError::TooLong(..) | StoreError::Invalid(..))) => {
|
||||
tracing::warn!("saved passwords: {key} does not fit the credential store and is forgotten: {e}");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("saved passwords: could not move {key} to the credential store: {e}");
|
||||
return Contents::Plaintext(old, e.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Err(e) = self.write(&index) {
|
||||
return Contents::Plaintext(old, e.message);
|
||||
}
|
||||
let _ = std::fs::remove_file(&pending);
|
||||
Contents::Index(index)
|
||||
}
|
||||
|
||||
fn write_logged(&self, contents: &impl Serialize) {
|
||||
if let Err(e) = self.write(contents) {
|
||||
tracing::warn!("{}", e.message);
|
||||
}
|
||||
}
|
||||
|
||||
fn write(&self, contents: &impl Serialize) -> IdeResult<()> {
|
||||
fn write(&self, all: &BTreeMap<String, String>) -> IdeResult<()> {
|
||||
let err = |e: std::io::Error| IdeError::unavailable(format!("saved passwords: {}: {e}", self.path.display()));
|
||||
if let Some(dir) = self.path.parent() {
|
||||
std::fs::create_dir_all(dir).map_err(err)?;
|
||||
@@ -293,258 +65,18 @@ impl SecretFile {
|
||||
options.write(true).create(true).truncate(true);
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
|
||||
let bytes = serde_json::to_vec_pretty(contents).map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
let bytes = serde_json::to_vec_pretty(all).map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
std::io::Write::write_all(&mut options.open(&tmp).map_err(err)?, &bytes).map_err(err)?;
|
||||
std::fs::rename(&tmp, &self.path).map_err(err)
|
||||
}
|
||||
}
|
||||
|
||||
fn unavailable(why: &str) -> IdeError {
|
||||
IdeError::unavailable(format!("saved passwords: {why}"))
|
||||
}
|
||||
|
||||
/// Helpers for tests of crates that save secrets (feature `testing`).
|
||||
#[cfg(any(test, feature = "testing"))]
|
||||
pub mod testing {
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Makes an in-memory credential store the default, so that the test never
|
||||
/// touches the user's keychain. Call it before the first secret is saved.
|
||||
pub fn use_memory_store() {
|
||||
static ONCE: std::sync::Once = std::sync::Once::new();
|
||||
ONCE.call_once(|| keyring_core::set_default_store(keyring_core::sample::Store::new().expect("memory store")));
|
||||
}
|
||||
|
||||
/// Every file under `dir` whose bytes contain `needle`.
|
||||
pub fn files_containing(dir: &Path, needle: &str) -> Vec<PathBuf> {
|
||||
let mut found = Vec::new();
|
||||
let mut pending = vec![dir.to_path_buf()];
|
||||
while let Some(d) = pending.pop() {
|
||||
for entry in std::fs::read_dir(&d).into_iter().flatten().flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
pending.push(path);
|
||||
} else if std::fs::read(&path).is_ok_and(|b| b.windows(needle.len()).any(|w| w == needle.as_bytes())) {
|
||||
found.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::testing::{files_containing, use_memory_store};
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// A credential store that can be absent, or fail on chosen keys.
|
||||
#[derive(Default)]
|
||||
struct FakeVault {
|
||||
absent: bool,
|
||||
entries: Mutex<HashMap<(String, String), String>>,
|
||||
failing: Mutex<HashMap<String, fn() -> StoreError>>,
|
||||
}
|
||||
|
||||
impl FakeVault {
|
||||
fn fail(&self, key: &str, error: fn() -> StoreError) {
|
||||
self.failing.lock().insert(key.to_owned(), error);
|
||||
}
|
||||
|
||||
fn heal(&self) {
|
||||
self.failing.lock().clear();
|
||||
}
|
||||
|
||||
fn check(&self, key: &str) -> StoreResult<()> {
|
||||
if self.absent {
|
||||
return Err(StoreError::NoDefaultStore);
|
||||
}
|
||||
self.failing.lock().get(key).map_or(Ok(()), |e| Err(e()))
|
||||
}
|
||||
|
||||
fn secrets(&self) -> Vec<String> {
|
||||
let mut all: Vec<_> = self.entries.lock().values().cloned().collect();
|
||||
all.sort();
|
||||
all
|
||||
}
|
||||
}
|
||||
|
||||
impl Vault for FakeVault {
|
||||
fn available(&self) -> bool {
|
||||
!self.absent
|
||||
}
|
||||
fn get(&self, service: &str, key: &str) -> StoreResult<String> {
|
||||
self.check(key)?;
|
||||
self.entries.lock().get(&(service.to_owned(), key.to_owned())).cloned().ok_or(StoreError::NoEntry)
|
||||
}
|
||||
fn set(&self, service: &str, key: &str, secret: &str) -> StoreResult<()> {
|
||||
self.check(key)?;
|
||||
self.entries.lock().insert((service.to_owned(), key.to_owned()), secret.to_owned());
|
||||
Ok(())
|
||||
}
|
||||
fn delete(&self, service: &str, key: &str) -> StoreResult<()> {
|
||||
self.check(key)?;
|
||||
self.entries.lock().remove(&(service.to_owned(), key.to_owned())).map(|_| ()).ok_or(StoreError::NoEntry)
|
||||
}
|
||||
}
|
||||
|
||||
fn platform() -> StoreError {
|
||||
StoreError::PlatformFailure("locked".into())
|
||||
}
|
||||
|
||||
fn with_fake(path: &std::path::Path, vault: &Arc<FakeVault>) -> SecretFile {
|
||||
SecretFile::with_vault(path.to_path_buf(), |v| serde_json::from_value(v).ok(), vault.clone())
|
||||
}
|
||||
|
||||
const OLD: &str = r#"{"https://a.example": "me\nold-a", "https://b.example": "me\nold-b"}"#;
|
||||
|
||||
#[test]
|
||||
fn saved_secrets_never_reach_disk() {
|
||||
use_memory_store();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("ssh-passwords.json");
|
||||
SecretFile::new(path.clone()).set("a", "hunter2-ssh").unwrap();
|
||||
assert_eq!(files_containing(tmp.path(), "hunter2-ssh"), Vec::<PathBuf>::new());
|
||||
let reopened = SecretFile::new(path);
|
||||
assert_eq!(reopened.get("a").as_deref(), Some("hunter2-ssh"));
|
||||
assert_eq!(reopened.keys(), ["a"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moves_plaintext_files_of_older_versions_off_disk() {
|
||||
use_memory_store();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("git-credentials.json");
|
||||
std::fs::write(&path, r#"{"https://example.com": "me\nold-pw"}"#).unwrap();
|
||||
let file = SecretFile::new(path.clone());
|
||||
assert_eq!(file.keys(), ["https://example.com"]);
|
||||
assert_eq!(files_containing(tmp.path(), "old-pw"), Vec::<PathBuf>::new());
|
||||
assert_eq!(SecretFile::new(path).get("https://example.com").as_deref(), Some("me\nold-pw"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_move_keeps_the_old_file_and_the_retry_converges() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("git-credentials.json");
|
||||
std::fs::write(&path, OLD).unwrap();
|
||||
let vault = Arc::new(FakeVault::default());
|
||||
vault.fail("https://b.example", platform);
|
||||
|
||||
let file = with_fake(&path, &vault);
|
||||
assert_eq!(file.get("https://a.example").as_deref(), Some("me\nold-a"));
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), OLD, "the old file is untouched");
|
||||
assert!(file.set("https://c.example", "me\nnew").unwrap_err().message.contains("locked"));
|
||||
|
||||
vault.heal();
|
||||
assert_eq!(file.keys(), ["https://a.example", "https://b.example"]);
|
||||
assert_eq!(files_containing(tmp.path(), "old-a"), Vec::<PathBuf>::new());
|
||||
assert_eq!(vault.secrets(), ["me\nold-a", "me\nold-b"], "the retry overwrote, not copied");
|
||||
assert!(!path.with_extension("json.migrating").exists());
|
||||
assert_eq!(with_fake(&path, &vault).get("https://b.example").as_deref(), Some("me\nold-b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forgetting_during_a_pending_move_forgets_the_copy_too() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("git-credentials.json");
|
||||
std::fs::write(&path, OLD).unwrap();
|
||||
let vault = Arc::new(FakeVault::default());
|
||||
vault.fail("https://b.example", platform);
|
||||
|
||||
let file = with_fake(&path, &vault);
|
||||
file.delete("https://a.example");
|
||||
vault.heal();
|
||||
assert_eq!(file.keys(), ["https://b.example"]);
|
||||
assert_eq!(vault.secrets(), ["me\nold-b"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_secret_the_store_refuses_for_good_is_forgotten_and_does_not_block_saving() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("git-credentials.json");
|
||||
std::fs::write(&path, OLD).unwrap();
|
||||
let vault = Arc::new(FakeVault::default());
|
||||
vault.fail("https://b.example", || StoreError::TooLong("password".into(), 2560));
|
||||
|
||||
let file = with_fake(&path, &vault);
|
||||
assert_eq!(file.keys(), ["https://a.example"]);
|
||||
assert_eq!(files_containing(tmp.path(), "old-b"), Vec::<PathBuf>::new());
|
||||
file.set("https://c.example", "me\nnew").unwrap();
|
||||
assert_eq!(file.get("https://c.example").as_deref(), Some("me\nnew"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_credential_store_the_old_file_is_read_and_nothing_new_is_saved() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("git-credentials.json");
|
||||
std::fs::write(&path, OLD).unwrap();
|
||||
let vault = Arc::new(FakeVault { absent: true, ..Default::default() });
|
||||
|
||||
let file = with_fake(&path, &vault);
|
||||
assert_eq!(file.get("https://a.example").as_deref(), Some("me\nold-a"));
|
||||
assert!(file.set("https://c.example", "me\nnew").unwrap_err().message.contains("no credential store"));
|
||||
assert_eq!(files_containing(tmp.path(), "me\nnew"), Vec::<PathBuf>::new());
|
||||
assert!(!path.with_extension("json.migrating").exists());
|
||||
|
||||
file.delete("https://a.example");
|
||||
assert_eq!(file.keys(), ["https://b.example"]);
|
||||
assert_eq!(files_containing(tmp.path(), "old-a"), Vec::<PathBuf>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forgets_only_what_the_store_forgot() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let vault = Arc::new(FakeVault::default());
|
||||
let file = with_fake(&tmp.path().join("ssh-passwords.json"), &vault);
|
||||
file.set("a", "pw").unwrap();
|
||||
vault.fail("a", platform);
|
||||
file.delete("a");
|
||||
assert_eq!(file.keys(), ["a"], "still listed, so that forgetting can be retried");
|
||||
vault.heal();
|
||||
file.delete("a");
|
||||
assert_eq!(file.keys(), Vec::<String>::new());
|
||||
assert_eq!(vault.secrets(), Vec::<String>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_secret_removed_outside_the_app_is_no_longer_listed() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let vault = Arc::new(FakeVault::default());
|
||||
let file = with_fake(&tmp.path().join("ssh-passwords.json"), &vault);
|
||||
file.set("a", "pw").unwrap();
|
||||
vault.entries.lock().clear();
|
||||
assert_eq!(file.get("a"), None);
|
||||
assert_eq!(file.keys(), Vec::<String>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_index_write_takes_the_secret_back_out_of_the_store() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("ssh-passwords.json");
|
||||
std::fs::create_dir(&path).unwrap();
|
||||
let vault = Arc::new(FakeVault::default());
|
||||
assert!(with_fake(&path, &vault).set("a", "pw").is_err());
|
||||
assert_eq!(vault.secrets(), Vec::<String>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unreadable_file_is_set_aside_not_overwritten() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("ssh-passwords.json");
|
||||
std::fs::write(&path, "{ not json").unwrap();
|
||||
let vault = Arc::new(FakeVault::default());
|
||||
let file = with_fake(&path, &vault);
|
||||
assert_eq!(file.keys(), Vec::<String>::new());
|
||||
file.set("a", "pw").unwrap();
|
||||
std::fs::write(&path, "{ not json either").unwrap();
|
||||
assert_eq!(file.keys(), Vec::<String>::new());
|
||||
assert_eq!(files_containing(tmp.path(), "{ not json").len(), 2, "both kept, neither overwritten");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stores_and_forgets_secrets() {
|
||||
use_memory_store();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let file = SecretFile::new(tmp.path().join("ssh-passwords.json"));
|
||||
file.set("a", "pw").unwrap();
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
//! Saved passwords against the real OS credential store (Windows Credential
|
||||
//! Manager, macOS Keychain, Secret Service).
|
||||
|
||||
use ide_ssh::secrets::SecretFile;
|
||||
|
||||
/// Forgets every key of the file, also when the test panics.
|
||||
struct Forget<'a>(&'a SecretFile);
|
||||
|
||||
impl Drop for Forget<'_> {
|
||||
fn drop(&mut self) {
|
||||
for key in self.0.keys() {
|
||||
self.0.delete(&key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "uses the OS credential store"]
|
||||
fn saves_moves_and_forgets_in_the_os_store() {
|
||||
assert!(keyring::Entry::store_status().is_ok(), "no OS credential store");
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("git-credentials.json");
|
||||
std::fs::write(&path, r#"{"https://old.example": "me\nold-os-pw"}"#).unwrap();
|
||||
let file = SecretFile::new(path.clone());
|
||||
let forget = Forget(&file);
|
||||
file.set("https://new.example", "me\nnew-os-pw").unwrap();
|
||||
let on_disk = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(!on_disk.contains("old-os-pw") && !on_disk.contains("new-os-pw"), "{on_disk}");
|
||||
|
||||
let reopened = SecretFile::new(path);
|
||||
assert_eq!(reopened.get("https://old.example").as_deref(), Some("me\nold-os-pw"));
|
||||
assert_eq!(reopened.get("https://new.example").as_deref(), Some("me\nnew-os-pw"));
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let index: serde_json::Value = serde_json::from_str(&on_disk).unwrap();
|
||||
let service = format!("ai.l0ng.openidea/{}", index["id"].as_str().unwrap());
|
||||
let listed = std::process::Command::new("cmdkey").arg("/list").output().unwrap();
|
||||
let listed = String::from_utf8_lossy(&listed.stdout);
|
||||
assert_eq!(listed.matches(service.as_str()).count(), 2, "{listed}");
|
||||
}
|
||||
drop(forget);
|
||||
assert_eq!(reopened.keys(), Vec::<String>::new());
|
||||
assert_eq!(reopened.get("https://old.example"), None);
|
||||
}
|
||||
@@ -60,9 +60,6 @@ struct Session {
|
||||
writer: Mutex<Box<dyn Write + Send>>,
|
||||
killer: Mutex<Box<dyn ChildKiller + Send + Sync>>,
|
||||
exited: Arc<AtomicBool>,
|
||||
started: Instant,
|
||||
/// The first input was logged (startup diagnostics, see `spawn`).
|
||||
input_logged: AtomicBool,
|
||||
}
|
||||
|
||||
type Sessions = Arc<Mutex<HashMap<TerminalId, Arc<Session>>>>;
|
||||
@@ -95,8 +92,7 @@ impl TerminalManager {
|
||||
let size = pty_size(options.cols, options.rows);
|
||||
let pair = native_pty_system().openpty(size).map_err(|e| IdeError::internal(format!("openpty failed: {e}")))?;
|
||||
|
||||
let wsl = if cfg!(windows) { wsl_shell(&options) } else { None };
|
||||
let (shell, mut args) = wsl.clone().unwrap_or_else(|| resolve_shell(options.shell.as_deref(), &options.args));
|
||||
let (shell, mut args) = resolve_shell(options.shell.as_deref(), &options.args);
|
||||
let mut env = terminal_env(&options.env);
|
||||
if let Some(msystem) = default_msystem(&shell, &options.env, std::env::var("MSYSTEM").ok().as_deref()) {
|
||||
env.insert("MSYSTEM".to_owned(), msystem.to_owned());
|
||||
@@ -109,8 +105,7 @@ impl TerminalManager {
|
||||
}
|
||||
let mut cmd = CommandBuilder::new(&shell);
|
||||
cmd.args(&args);
|
||||
// wsl.exe takes the directory through `--cd`; a UNC share is no valid process cwd.
|
||||
cmd.cwd(if wsl.is_some() { resolve_cwd(Path::new("")) } else { resolve_cwd(&options.cwd) });
|
||||
cmd.cwd(resolve_cwd(&options.cwd));
|
||||
for (k, v) in ide_core::process::host_env_fixes() {
|
||||
match v {
|
||||
Some(v) => cmd.env(k, v),
|
||||
@@ -141,34 +136,25 @@ impl TerminalManager {
|
||||
writer: Mutex::new(writer),
|
||||
killer: Mutex::new(child.clone_killer()),
|
||||
exited: exited.clone(),
|
||||
started: Instant::now(),
|
||||
input_logged: AtomicBool::new(false),
|
||||
});
|
||||
self.sessions.lock().insert(id, session);
|
||||
|
||||
let (tx, rx) = mpsc::channel::<Msg>();
|
||||
let spawned = spawn_reader(id, reader, tx.clone())
|
||||
.and_then(|_| spawn_waiter(id, child, tx))
|
||||
.and_then(|_| spawn_pump(id, rx, on_output, exited, self.sessions.clone(), sink, Instant::now()));
|
||||
.and_then(|_| spawn_pump(id, rx, on_output, exited, self.sessions.clone(), sink));
|
||||
if let Err(e) = spawned {
|
||||
if let Some(s) = self.sessions.lock().remove(&id) {
|
||||
let _ = s.killer.lock().kill();
|
||||
}
|
||||
return Err(IdeError::internal(format!("failed to start terminal threads: {e}")));
|
||||
}
|
||||
// Info level, with the first output and input below: a session that starts but never shows a
|
||||
// prompt can then be told apart in a user's log (no output, or no reply to the shell's query).
|
||||
tracing::info!(%id, shell, pid = ?info.pid, cols = size.cols, rows = size.rows, "terminal spawned");
|
||||
tracing::debug!(%id, shell, pid = ?info.pid, "terminal spawned");
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
pub fn write(&self, id: TerminalId, data: &str) -> IdeResult<()> {
|
||||
let session = self.session(id)?;
|
||||
if !session.input_logged.swap(true, Ordering::Relaxed) {
|
||||
let cursor_report = data.starts_with("\x1b[") && data.ends_with('R');
|
||||
let ms = session.started.elapsed().as_millis();
|
||||
tracing::info!(%id, ms, bytes = data.len(), cursor_report, "terminal first input");
|
||||
}
|
||||
let mut w = session.writer.lock();
|
||||
w.write_all(data.as_bytes())?;
|
||||
w.flush()?;
|
||||
@@ -401,19 +387,8 @@ fn spawn_pump(
|
||||
exited: Arc<AtomicBool>,
|
||||
sessions: Sessions,
|
||||
sink: SharedSink,
|
||||
started: Instant,
|
||||
) -> std::io::Result<()> {
|
||||
std::thread::Builder::new().name(format!("openidea-term-pump-{}", id.0)).spawn(move || {
|
||||
let first_output = std::cell::Cell::new(true);
|
||||
let on_output = move |text: String| {
|
||||
if first_output.replace(false) {
|
||||
// ConPTY asks for the cursor position (`ESC[6n`) and waits for the answer.
|
||||
let cursor_query = text.contains("\x1b[6n");
|
||||
let ms = started.elapsed().as_millis();
|
||||
tracing::info!(%id, ms, bytes = text.len(), cursor_query, "terminal first output");
|
||||
}
|
||||
on_output(text)
|
||||
};
|
||||
let mut decoder = Utf8Decoder::new();
|
||||
let mut pending = String::new();
|
||||
let mut batch_started: Option<Instant> = None;
|
||||
@@ -481,16 +456,6 @@ fn pty_size(cols: u16, rows: u16) -> PtySize {
|
||||
}
|
||||
}
|
||||
|
||||
/// `wsl.exe` in the distro of a `\\wsl.localhost\...` folder, so its terminal is a Linux
|
||||
/// shell and not PowerShell (#925). An explicitly chosen shell or arguments win.
|
||||
fn wsl_shell(options: &TerminalOptions) -> Option<(String, Vec<String>)> {
|
||||
if options.shell.as_deref().is_some_and(|s| !s.trim().is_empty()) || !options.args.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let w = ide_core::wsl::parse(&options.cwd)?;
|
||||
Some((ide_core::wsl::EXE.into(), vec!["-d".into(), w.distro, "--cd".into(), w.path]))
|
||||
}
|
||||
|
||||
fn resolve_cwd(cwd: &Path) -> PathBuf {
|
||||
if cwd.is_dir() {
|
||||
return cwd.to_path_buf();
|
||||
@@ -539,7 +504,7 @@ pub fn available_shells() -> Vec<String> {
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
for exe in ["pwsh.exe", "powershell.exe", "cmd.exe", ide_core::wsl::EXE] {
|
||||
for exe in ["pwsh.exe", "powershell.exe", "cmd.exe", "wsl.exe"] {
|
||||
if let Some(p) = find_in_path(exe) {
|
||||
add(p);
|
||||
}
|
||||
@@ -894,24 +859,6 @@ mod tests {
|
||||
assert!(h.events.try_recv().is_err() && other_events.try_recv().is_err(), "no cross delivery");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wsl_folder_gets_the_distro_shell_unless_one_is_chosen() {
|
||||
let mut o = sh("true");
|
||||
o.shell = None;
|
||||
o.args = vec![];
|
||||
let (distro, linux_path) = ("Debian-12", "/srv/app");
|
||||
let want = (ide_core::wsl::EXE.to_owned(), vec!["-d".into(), distro.into(), "--cd".into(), linux_path.into()]);
|
||||
for host in ["wsl.localhost", "wsl$"] {
|
||||
o.cwd = PathBuf::from(format!(r"\\{host}\{distro}{}", linux_path.replace('/', r"\"))).into();
|
||||
assert_eq!(wsl_shell(&o), Some(want.clone()), "{host}");
|
||||
}
|
||||
o.shell = Some("pwsh.exe".into());
|
||||
assert_eq!(wsl_shell(&o), None);
|
||||
o.shell = None;
|
||||
o.cwd = PathBuf::from("/tmp").into();
|
||||
assert_eq!(wsl_shell(&o), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_resolution() {
|
||||
let (shell, args) = resolve_shell(Some("/bin/zsh"), &[]);
|
||||
|
||||
@@ -12,7 +12,6 @@ ide-core.workspace = true
|
||||
# Hence no `unicode_lines` / `cr_lines` (a test guards against feature unification).
|
||||
ropey = { version = "1.6", default-features = false, features = ["simd"] }
|
||||
serde.workspace = true
|
||||
similar = { version = "2.7", default-features = false }
|
||||
specta.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -73,10 +73,6 @@ fn bench(c: &mut Criterion) {
|
||||
BatchSize::SmallInput,
|
||||
)
|
||||
});
|
||||
let two_edits = text.replacen("value_100 ", "renamed_100 ", 1).replacen("value_9000 ", "renamed_9000 ", 1);
|
||||
c.bench_function("text/diff_two_edits_1mb", |b| b.iter(|| base.diff(black_box(&two_edits))));
|
||||
let every_line = text.replace("compute", "calculate");
|
||||
c.bench_function("text/diff_every_line_1mb", |b| b.iter(|| base.diff(black_box(&every_line))));
|
||||
c.bench_function("text/offset_to_pos_round_trip", |b| {
|
||||
let mut rng = Lcg(3);
|
||||
let len = base.len_utf16();
|
||||
|
||||
+31
-124
@@ -14,7 +14,6 @@ use ide_core::{ChangeSet, IdeError, IdeResult, Pos, Range, TextChange, TextEdit}
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::borrow::Cow;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
pub use ropey;
|
||||
|
||||
@@ -67,36 +66,6 @@ impl LineEnding {
|
||||
}
|
||||
}
|
||||
|
||||
/// How long [`Document::diff`] may look for separate regions, so a reload never stalls on a huge file.
|
||||
const DIFF_BUDGET: Duration = Duration::from_millis(50);
|
||||
|
||||
type LineRange = std::ops::Range<usize>;
|
||||
|
||||
/// Byte lengths of the common prefix and suffix; they never overlap.
|
||||
fn common_ends(a: &str, b: &str) -> (usize, usize) {
|
||||
let shared = |a: &mut dyn Iterator<Item = char>, b: &mut dyn Iterator<Item = char>| -> usize {
|
||||
a.zip(b).take_while(|(x, y)| x == y).map(|(x, _)| x.len_utf8()).sum()
|
||||
};
|
||||
let prefix = shared(&mut a.chars(), &mut b.chars());
|
||||
let suffix = shared(&mut a[prefix..].chars().rev(), &mut b[prefix..].chars().rev());
|
||||
(prefix, suffix)
|
||||
}
|
||||
|
||||
/// Byte offset of each line, then the total length.
|
||||
fn line_starts(lines: &[&str]) -> Vec<usize> {
|
||||
let mut at = 0;
|
||||
let mut starts = vec![0];
|
||||
starts.extend(lines.iter().map(|line| {
|
||||
at += line.len();
|
||||
at
|
||||
}));
|
||||
starts
|
||||
}
|
||||
|
||||
fn utf16_len(text: &str) -> u32 {
|
||||
text.chars().map(|c| c.len_utf16() as u32).sum()
|
||||
}
|
||||
|
||||
/// Converts `\r\n` and lone `\r` to `\n`. Borrows when there is nothing to do.
|
||||
pub fn normalize_line_endings(text: &str) -> Cow<'_, str> {
|
||||
if !text.contains('\r') {
|
||||
@@ -365,63 +334,42 @@ impl Document {
|
||||
cs
|
||||
}
|
||||
|
||||
/// The change set turning the current text into `text` (after line-ending
|
||||
/// normalization), one change per changed region. Empty when equal. A single change
|
||||
/// spanning two distant edits would drag the editor's caret and view to the first one.
|
||||
/// The minimal single-change (common prefix/suffix) change set turning the
|
||||
/// current text into `text` (after line-ending normalization). Empty when equal.
|
||||
pub fn diff(&self, text: &str) -> ChangeSet {
|
||||
self.diff_until(text, Instant::now() + DIFF_BUDGET)
|
||||
}
|
||||
|
||||
/// Past `deadline` the regions get coarser, never wrong.
|
||||
fn diff_until(&self, text: &str, deadline: Instant) -> ChangeSet {
|
||||
let new = normalize_line_endings(text);
|
||||
let old = self.rope.to_string();
|
||||
let (prefix, suffix) = common_ends(&old, &new);
|
||||
let old_rest = &old[prefix..old.len() - suffix];
|
||||
let new_rest = &new[prefix..new.len() - suffix];
|
||||
let old_chars = self.rope.len_chars();
|
||||
|
||||
let old_lines: Vec<&str> = old_rest.split_inclusive('\n').collect();
|
||||
let new_lines: Vec<&str> = new_rest.split_inclusive('\n').collect();
|
||||
let old_starts = line_starts(&old_lines);
|
||||
let new_starts = line_starts(&new_lines);
|
||||
|
||||
let mut changes = Vec::new();
|
||||
let mut utf16_at = utf16_len(&old[..prefix]);
|
||||
let mut old_at = 0;
|
||||
let mut region: Option<(LineRange, LineRange)> = None;
|
||||
let ops =
|
||||
similar::capture_diff_slices_deadline(similar::Algorithm::Myers, &old_lines, &new_lines, Some(deadline));
|
||||
let mut flush = |region: Option<(LineRange, LineRange)>| {
|
||||
let Some((old_range, new_range)) = region else { return };
|
||||
let region_old = &old_rest[old_starts[old_range.start]..old_starts[old_range.end]];
|
||||
let region_new = &new_rest[new_starts[new_range.start]..new_starts[new_range.end]];
|
||||
let (head, tail) = common_ends(region_old, region_new);
|
||||
let start = old_starts[old_range.start] + head;
|
||||
let end = old_starts[old_range.end] - tail;
|
||||
let from = utf16_at + utf16_len(&old_rest[old_at..start]);
|
||||
let to = from + utf16_len(&old_rest[start..end]);
|
||||
changes.push(TextChange { from, to, insert: region_new[head..region_new.len() - tail].to_owned() });
|
||||
utf16_at = to;
|
||||
old_at = end;
|
||||
};
|
||||
// Positions come from the lengths: `similar` can report a stale start for the empty
|
||||
// side of an op (an insert after an equal run still names the run's first old line).
|
||||
let (mut old_line, mut new_line) = (0, 0);
|
||||
for op in ops {
|
||||
let old_end = old_line + op.old_range().len();
|
||||
let new_end = new_line + op.new_range().len();
|
||||
if op.tag() == similar::DiffTag::Equal {
|
||||
flush(region.take());
|
||||
} else {
|
||||
region = Some(match region.take() {
|
||||
Some((o, n)) => (o.start..old_end, n.start..new_end),
|
||||
None => (old_line..old_end, new_line..new_end),
|
||||
});
|
||||
let mut prefix_chars = 0usize;
|
||||
let mut prefix_bytes = 0usize;
|
||||
for (a, b) in self.rope.chars().zip(new.chars()) {
|
||||
if a != b {
|
||||
break;
|
||||
}
|
||||
(old_line, new_line) = (old_end, new_end);
|
||||
prefix_chars += 1;
|
||||
prefix_bytes += b.len_utf8();
|
||||
}
|
||||
flush(region);
|
||||
ChangeSet { changes }
|
||||
let new_rest = &new[prefix_bytes..];
|
||||
let new_rest_chars = new_rest.chars().count();
|
||||
if prefix_chars == old_chars && new_rest_chars == 0 {
|
||||
return ChangeSet::default();
|
||||
}
|
||||
|
||||
let max_suffix = (old_chars - prefix_chars).min(new_rest_chars);
|
||||
let mut suffix_chars = 0usize;
|
||||
let mut suffix_bytes = 0usize;
|
||||
for (a, b) in self.rope.chars_at(old_chars).reversed().zip(new_rest.chars().rev()).take(max_suffix) {
|
||||
if a != b {
|
||||
break;
|
||||
}
|
||||
suffix_chars += 1;
|
||||
suffix_bytes += b.len_utf8();
|
||||
}
|
||||
|
||||
let from = self.rope.char_to_utf16_cu(prefix_chars) as u32;
|
||||
let to = self.rope.char_to_utf16_cu(old_chars - suffix_chars) as u32;
|
||||
let insert = new_rest[..new_rest.len() - suffix_bytes].to_owned();
|
||||
ChangeSet { changes: vec![TextChange { from, to, insert }] }
|
||||
}
|
||||
|
||||
// ---- coordinate conversion (UTF-16 offset ⇄ Pos ⇄ byte offset) ----
|
||||
@@ -658,45 +606,4 @@ mod tests {
|
||||
assert_eq!(set.changes, vec![tc(2, 2, "x")]);
|
||||
assert_eq!(doc.text(), "😀x😀");
|
||||
}
|
||||
|
||||
fn numbered_lines(n: usize) -> String {
|
||||
(0..n).map(|i| format!("line {i}\n")).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_keeps_distant_edits_apart() {
|
||||
let old = numbered_lines(400);
|
||||
let new = old.replace("line 20\n", "line 20x\n").replace("line 299\n", "line 299x\n");
|
||||
let at = |line: &str| (old.find(line).unwrap() + line.len() - 1) as u32;
|
||||
let set = Document::new(&old).diff(&new);
|
||||
assert_eq!(
|
||||
set.changes,
|
||||
vec![tc(at("line 20\n"), at("line 20\n"), "x"), tc(at("line 299\n"), at("line 299\n"), "x")]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_handles_added_and_removed_lines() {
|
||||
let old = "a\nb\nc\nd\ne\nf\n";
|
||||
let set = Document::new(old).diff("a\nnew\nb\nc\ne\nf\n");
|
||||
assert_eq!(set.changes, vec![tc(2, 2, "new\n"), tc(5, 7, "")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_counts_utf16_past_astral_text() {
|
||||
let old = "😀 one\nsame\n😀 two\n";
|
||||
let set = Document::new(old).diff("😀 ONE\nsame\n😀 TWO\n");
|
||||
assert_eq!(set.changes, vec![tc(3, 6, "ONE"), tc(15, 18, "TWO")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_past_its_deadline_is_coarser_but_still_correct() {
|
||||
let old = numbered_lines(5000);
|
||||
let new = old.replace("line 20\n", "line 20x\n").replace("line 4000\n", "line 4000x\n");
|
||||
let doc = Document::new(&old);
|
||||
let set = doc.diff_until(&new, std::time::Instant::now());
|
||||
let mut replayed = Document::new(&old);
|
||||
replayed.apply(0, &set).unwrap();
|
||||
assert_eq!(replayed.text(), new);
|
||||
}
|
||||
}
|
||||
@@ -5,4 +5,3 @@
|
||||
# It is recommended to check this file in to source control so that
|
||||
# everyone who runs the test benefits from these saved cases.
|
||||
cc afa0785335731ca8adc050b09312843aeb3ff05dc895963d09d9f728c553717c # shrinks to doc_text = "a", picks = [(0, 0, ""), (0, 0, ""), (658842129343018477, 1922606258417419361, ""), (0, 4297423518677873183, ""), (953525717999286983, 557348908835472853, "a"), (0, 0, "b"), (0, 1653396554766652711, "")], seed = 7072935092038531240
|
||||
cc dd6ec32be77e8ad96d0ce3ef14ad9d72d5ba2a06f227ea693bfdee62debf08b7 # shrinks to a = "中é\n\n", b = "\n\na"
|
||||
@@ -176,7 +176,7 @@ proptest! {
|
||||
let set = doc.replace_all(&b);
|
||||
prop_assert_eq!(doc.text(), b.clone());
|
||||
prop_assert_eq!(naive_apply(&a, &set), b.clone());
|
||||
prop_assert!(set.changes.windows(2).all(|w| w[0].to < w[1].from));
|
||||
prop_assert!(set.changes.len() <= 1);
|
||||
prop_assert_eq!(set.changes.is_empty(), a == b);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,12 +8,6 @@
|
||||
//!
|
||||
//! Language servers find managed tools once [`extend_process_path`] has put
|
||||
//! [`tools_bin_dirs`] on the process `PATH` at startup.
|
||||
//!
|
||||
//! GitHub API downloads prefer `GITHUB_TOKEN`, then `GH_TOKEN`, then the active
|
||||
//! `github.com` account from `gh auth token --hostname github.com`. The CLI is
|
||||
//! optional; absent credentials retain anonymous downloads. Tokens are passed
|
||||
//! to curl over stdin, never stored in tool manifests or command arguments, and
|
||||
//! are not sent to other download origins. CLI lookup is cancellable and bounded.
|
||||
|
||||
pub mod archive;
|
||||
mod bundled;
|
||||
@@ -395,11 +389,6 @@ pub fn parse_version(text: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// A Windows App Execution Alias (`%LOCALAPPDATA%\Microsoft\WindowsApps\python.exe`).
|
||||
fn is_app_execution_alias(path: &Path) -> bool {
|
||||
path.parent().and_then(Path::file_name).is_some_and(|d| d.eq_ignore_ascii_case("WindowsApps"))
|
||||
}
|
||||
|
||||
/// A tool whose "command" is a plugin jar (e.g. a jdtls bundle) rather than an executable.
|
||||
fn is_bundle(command: &str) -> bool {
|
||||
command.ends_with(".jar")
|
||||
@@ -496,32 +485,8 @@ impl Tools {
|
||||
cmd
|
||||
}
|
||||
|
||||
/// The interpreter that installs and probes Python tools. Windows installs `python.exe`
|
||||
/// and the `py` launcher, not `python3.exe`; its `WindowsApps` entries are App Execution
|
||||
/// Aliases that only open the Store unless the Store's Python is installed, so they count
|
||||
/// only when they run.
|
||||
fn python(&self) -> Option<PathBuf> {
|
||||
if self.platform.os != Os::Windows {
|
||||
return self.which_any(&["python3", "python"]);
|
||||
}
|
||||
["python", "py", "python3"]
|
||||
.into_iter()
|
||||
.flat_map(|command| {
|
||||
std::env::split_paths(&self.system_path).filter_map(move |dir| {
|
||||
if dir.as_os_str().is_empty() {
|
||||
return None;
|
||||
}
|
||||
let search = std::env::join_paths([dir]).ok()?;
|
||||
ide_core::tool_path::find_command(command, &search, &self.root)
|
||||
})
|
||||
})
|
||||
.find(|p| {
|
||||
!is_app_execution_alias(p) || {
|
||||
let mut c = self.python_command(p);
|
||||
c.arg("--version");
|
||||
run_quiet(c, Duration::from_secs(5)).is_some_and(|(ok, _)| ok)
|
||||
}
|
||||
})
|
||||
self.which_any(&["python3", "python"])
|
||||
}
|
||||
|
||||
/// Executable `cmd` inside `dir`, honouring Windows suffixes.
|
||||
|
||||
+17
-346
@@ -6,9 +6,9 @@ use ide_core::error::ErrorKind;
|
||||
use ide_core::output_encoding::decode_tool_output;
|
||||
use ide_core::process::{kill_tree, own_group};
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::io::{BufRead, BufReader, Read};
|
||||
use std::path::Path;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::mpsc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
@@ -144,99 +144,6 @@ fn strip_ansi(s: &str) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
const MAX_GITHUB_TOKEN: usize = 4096;
|
||||
|
||||
fn valid_github_token(value: String) -> Option<String> {
|
||||
let value = value.trim();
|
||||
(!value.is_empty() && value.len() <= MAX_GITHUB_TOKEN && value.bytes().all(|b| b.is_ascii_graphic()))
|
||||
.then(|| value.to_owned())
|
||||
}
|
||||
|
||||
fn environment_github_token(read: impl Fn(&str) -> Option<String>) -> Option<String> {
|
||||
["GITHUB_TOKEN", "GH_TOKEN"].into_iter().find_map(|key| read(key).and_then(valid_github_token))
|
||||
}
|
||||
|
||||
/// Ask gh for the active github.com account on each request, so switching or
|
||||
/// logging out takes effect without persisting a second copy of the credential.
|
||||
fn read_gh_token(gh: &Path, cancel: &CancellationToken, timeout: Duration) -> IdeResult<Option<String>> {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let mut cmd = Command::new(gh);
|
||||
own_group(ide_core::process::prepare(&mut cmd));
|
||||
cmd.args(["auth", "token", "--hostname", "github.com"])
|
||||
.env("GH_PROMPT_DISABLED", "1")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null());
|
||||
let Ok(mut child) = cmd.spawn() else { return Ok(None) };
|
||||
let stdout = child.stdout.take().expect("piped");
|
||||
let (tx, rx) = mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let mut bytes = Vec::new();
|
||||
let _ = stdout.take((MAX_GITHUB_TOKEN + 2) as u64).read_to_end(&mut bytes);
|
||||
let _ = tx.send(bytes);
|
||||
});
|
||||
let started = Instant::now();
|
||||
let mut status = None;
|
||||
let mut output = None;
|
||||
let bytes = loop {
|
||||
if cancel.is_cancelled() || started.elapsed() >= timeout {
|
||||
kill_tree(&mut child);
|
||||
return if cancel.is_cancelled() { Err(IdeError::cancelled()) } else { Ok(None) };
|
||||
}
|
||||
if status.is_none() {
|
||||
match child.try_wait() {
|
||||
Ok(value) => status = value,
|
||||
Err(_) => {
|
||||
kill_tree(&mut child);
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(status) = status {
|
||||
if !status.success() {
|
||||
kill_tree(&mut child);
|
||||
return Ok(None);
|
||||
}
|
||||
if let Some(bytes) = output.take() {
|
||||
break bytes;
|
||||
}
|
||||
}
|
||||
match rx.recv_timeout(Duration::from_millis(15)) {
|
||||
Ok(bytes) => output = Some(bytes),
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => {}
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => std::thread::sleep(Duration::from_millis(15)),
|
||||
}
|
||||
};
|
||||
if bytes.len() > MAX_GITHUB_TOKEN + 1 {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(String::from_utf8(bytes).ok().and_then(valid_github_token))
|
||||
}
|
||||
|
||||
fn send_auth(child: &mut Child, token: Option<&str>) -> IdeResult<()> {
|
||||
if let Some(token) = token {
|
||||
let result = child
|
||||
.stdin
|
||||
.take()
|
||||
.expect("authenticated request has piped stdin")
|
||||
.write_all(format!("Authorization: Bearer {token}\n").as_bytes());
|
||||
if let Err(error) = result {
|
||||
kill_tree(child);
|
||||
return Err(IdeError::unavailable(format!("cannot pass download credentials to curl: {error}")));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn redact_auth(message: &str, token: Option<&str>) -> String {
|
||||
match token {
|
||||
Some(token) => message.replace(token, "[redacted]"),
|
||||
None => message.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `curl` checks certificate revocation with Schannel and takes
|
||||
/// `--ssl-revoke-best-effort` (7.70+). Schannel fails the whole download with
|
||||
/// CRYPT_E_REVOCATION_OFFLINE when the CRL/OCSP servers are unreachable (corporate
|
||||
@@ -267,8 +174,6 @@ fn curl_takes_revoke_best_effort(version: &str) -> bool {
|
||||
/// mainstream Linux), so the IDE carries no TLS stack of its own.
|
||||
pub struct Http {
|
||||
curl: std::path::PathBuf,
|
||||
gh: Option<std::path::PathBuf>,
|
||||
explicit_github_token: Option<String>,
|
||||
https_only: bool,
|
||||
revoke_best_effort: bool,
|
||||
}
|
||||
@@ -277,10 +182,8 @@ impl Http {
|
||||
pub fn new(search_path: &std::ffi::OsStr) -> IdeResult<Self> {
|
||||
let curl = which::which_in("curl", Some(search_path), ".")
|
||||
.map_err(|_| IdeError::unavailable("`curl` is required to download tools"))?;
|
||||
let gh = which::which_in("gh", Some(search_path), ".").ok();
|
||||
let explicit_github_token = environment_github_token(|key| std::env::var(key).ok());
|
||||
let revoke_best_effort = cfg!(windows) && revoke_best_effort(&curl);
|
||||
Ok(Self { curl, gh, explicit_github_token, https_only: false, revoke_best_effort })
|
||||
Ok(Self { curl, https_only: false, revoke_best_effort })
|
||||
}
|
||||
|
||||
/// Refuses every protocol but HTTPS, redirects included.
|
||||
@@ -289,28 +192,8 @@ impl Http {
|
||||
self
|
||||
}
|
||||
|
||||
fn base(&self, url: &str, cancel: &CancellationToken) -> IdeResult<(Command, Option<String>)> {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let github = url.starts_with("https://api.github.com/");
|
||||
let token = if github {
|
||||
match &self.explicit_github_token {
|
||||
Some(token) => Some(token.clone()),
|
||||
None => match &self.gh {
|
||||
Some(gh) => read_gh_token(gh, cancel, Duration::from_secs(5))?,
|
||||
None => None,
|
||||
},
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
fn base(&self, url: &str) -> Command {
|
||||
let mut cmd = Command::new(&self.curl);
|
||||
// A user curlrc must not enable verbose token logging or forwarding
|
||||
// Authorization across origins on authenticated API requests.
|
||||
if token.is_some() {
|
||||
cmd.arg("-q");
|
||||
}
|
||||
own_group(ide_core::process::prepare(&mut cmd));
|
||||
cmd.args(["-fsSL", "--retry", "2", "--connect-timeout", "20", "-A", "openidea"]);
|
||||
// System Settings › HTTP Proxy (a manual proxy comes in through `prepare`'s environment).
|
||||
@@ -321,14 +204,13 @@ impl Http {
|
||||
if self.revoke_best_effort {
|
||||
cmd.arg("--ssl-revoke-best-effort");
|
||||
}
|
||||
cmd.stdin(Stdio::null());
|
||||
if token.is_some() {
|
||||
// Keep credentials out of process arguments, inherited environment,
|
||||
// and files. curl strips Authorization on cross-origin redirects.
|
||||
cmd.args(["--header", "@-", "--proto-redir", "=https"]);
|
||||
cmd.env_remove("GH_TOKEN").env_remove("GITHUB_TOKEN").stdin(Stdio::piped());
|
||||
if url.starts_with("https://api.github.com/")
|
||||
&& let Ok(token) = std::env::var("GITHUB_TOKEN").or_else(|_| std::env::var("GH_TOKEN"))
|
||||
&& !token.is_empty()
|
||||
{
|
||||
cmd.arg("-H").arg(format!("Authorization: Bearer {token}"));
|
||||
}
|
||||
Ok((cmd, token))
|
||||
cmd
|
||||
}
|
||||
|
||||
/// GETs `url` as text.
|
||||
@@ -353,16 +235,15 @@ impl Http {
|
||||
empty_on_404: bool,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<String> {
|
||||
let (mut cmd, token) = self.base(url, cancel)?;
|
||||
let mut cmd = self.base(url);
|
||||
if empty_on_404 {
|
||||
cmd.args(["--write-out", "\n%{http_code}"]);
|
||||
}
|
||||
if let Some(max) = max_bytes {
|
||||
cmd.arg("--max-filesize").arg(max.to_string());
|
||||
}
|
||||
cmd.arg(url).stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
cmd.arg(url).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
let mut child = cmd.spawn().map_err(|e| IdeError::unavailable(format!("cannot run curl: {e}")))?;
|
||||
send_auth(&mut child, token.as_deref())?;
|
||||
let stdout = child.stdout.take().expect("piped");
|
||||
let mut stderr = child.stderr.take().expect("piped");
|
||||
// Past the limit the pipe closes, which stops curl.
|
||||
@@ -399,10 +280,7 @@ impl Http {
|
||||
let mut err = Vec::new();
|
||||
let _ = stderr.read_to_end(&mut err);
|
||||
let err = decode_tool_output(&err);
|
||||
return Err(IdeError::new(
|
||||
ErrorKind::Io,
|
||||
format!("GET {url} failed: {}", redact_auth(err.trim(), token.as_deref())),
|
||||
));
|
||||
return Err(IdeError::new(ErrorKind::Io, format!("GET {url} failed: {}", err.trim())));
|
||||
}
|
||||
String::from_utf8(body).map_err(|_| IdeError::new(ErrorKind::Protocol, format!("{url}: response is not UTF-8")))
|
||||
}
|
||||
@@ -416,10 +294,9 @@ impl Http {
|
||||
cancel: &CancellationToken,
|
||||
emit: Emit,
|
||||
) -> IdeResult<()> {
|
||||
let (mut cmd, token) = self.base(url, cancel)?;
|
||||
cmd.arg("-o").arg(dest).arg(url).stdout(Stdio::null()).stderr(Stdio::piped());
|
||||
let mut cmd = self.base(url);
|
||||
cmd.arg("-o").arg(dest).arg(url).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::piped());
|
||||
let mut child = cmd.spawn().map_err(|e| IdeError::unavailable(format!("cannot run curl: {e}")))?;
|
||||
send_auth(&mut child, token.as_deref())?;
|
||||
let mut stderr = child.stderr.take().expect("piped");
|
||||
let err_t = std::thread::spawn(move || {
|
||||
let mut s = Vec::new();
|
||||
@@ -450,10 +327,7 @@ impl Http {
|
||||
if !status.success() {
|
||||
return Err(IdeError::new(
|
||||
ErrorKind::Io,
|
||||
format!(
|
||||
"download of {url} failed: {}",
|
||||
redact_auth(err_t.join().unwrap_or_default().trim(), token.as_deref())
|
||||
),
|
||||
format!("download of {url} failed: {}", err_t.join().unwrap_or_default().trim()),
|
||||
));
|
||||
}
|
||||
let bytes = std::fs::metadata(dest).map(|m| m.len()).unwrap_or(0);
|
||||
@@ -616,13 +490,7 @@ mod tests {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let curl = tmp.path().join("curl");
|
||||
let http = Http {
|
||||
curl: curl.clone(),
|
||||
gh: None,
|
||||
explicit_github_token: None,
|
||||
https_only: false,
|
||||
revoke_best_effort: false,
|
||||
};
|
||||
let http = Http { curl: curl.clone(), https_only: false, revoke_best_effort: false };
|
||||
for (code, exit, expected) in [(404, 22, Some("[]")), (200, 0, Some("payload")), (403, 22, None), (0, 7, None)]
|
||||
{
|
||||
std::fs::write(&curl, format!("#!/bin/sh\nprintf 'payload\\n{code:03}'\nexit {exit}\n")).unwrap();
|
||||
@@ -727,200 +595,3 @@ mod input_tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod github_auth_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn explicit_tokens_keep_precedence_and_reject_header_injection() {
|
||||
let read = |key: &str| Some(if key == "GITHUB_TOKEN" { "first" } else { "second" }.to_owned());
|
||||
assert_eq!(environment_github_token(read).as_deref(), Some("first"));
|
||||
assert_eq!(
|
||||
environment_github_token(|key| Some(if key == "GITHUB_TOKEN" { "" } else { "second" }.into())).as_deref(),
|
||||
Some("second")
|
||||
);
|
||||
for bad in ["", "two words", "token\r\nX-Injected: yes", "token\0"] {
|
||||
assert!(valid_github_token(bad.into()).is_none());
|
||||
}
|
||||
assert!(valid_github_token("a".repeat(MAX_GITHUB_TOKEN + 1)).is_none());
|
||||
assert_eq!(valid_github_token("gho_example\n".into()).as_deref(), Some("gho_example"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
mod unix {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn quote(path: &Path) -> String {
|
||||
format!("'{}'", path.display().to_string().replace('\'', "'\\''"))
|
||||
}
|
||||
struct Fixture {
|
||||
dir: tempfile::TempDir,
|
||||
http: Http,
|
||||
}
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let curl = dir.path().join("curl");
|
||||
let gh = dir.path().join("gh");
|
||||
let fixture = Self {
|
||||
dir,
|
||||
http: Http {
|
||||
curl,
|
||||
gh: Some(gh),
|
||||
explicit_github_token: None,
|
||||
https_only: false,
|
||||
revoke_best_effort: false,
|
||||
},
|
||||
};
|
||||
fixture.script(
|
||||
"gh",
|
||||
&format!(
|
||||
"printf '%s\\n' \"$@\" >> {}\n/bin/cat {}",
|
||||
quote(&fixture.path("gh-args")),
|
||||
quote(&fixture.path("token"))
|
||||
),
|
||||
);
|
||||
fs::write(fixture.path("token"), "gho_fake_first\n").unwrap();
|
||||
fixture.curl("printf '{\"ok\":true}'");
|
||||
fixture
|
||||
}
|
||||
fn path(&self, name: &str) -> PathBuf {
|
||||
self.dir.path().join(name)
|
||||
}
|
||||
fn script(&self, name: &str, body: &str) {
|
||||
let path = self.path(name);
|
||||
fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap();
|
||||
fs::set_permissions(path, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
}
|
||||
fn curl(&self, body: &str) {
|
||||
self.script(
|
||||
"curl",
|
||||
&format!(
|
||||
"printf '%s\\n' \"$@\" > {}\n/bin/cat > {}\n{body}",
|
||||
quote(&self.path("curl-args")),
|
||||
quote(&self.path("headers"))
|
||||
),
|
||||
);
|
||||
}
|
||||
fn get(&self, url: &str) -> IdeResult<String> {
|
||||
self.http.get_text(url, &CancellationToken::new())
|
||||
}
|
||||
}
|
||||
const API: &str = "https://api.github.com/repos/example/project/releases/latest";
|
||||
|
||||
#[test]
|
||||
fn active_gh_account_is_read_per_request_and_token_is_not_an_argument() {
|
||||
let f = Fixture::new();
|
||||
assert_eq!(f.get(API).unwrap(), "{\"ok\":true}");
|
||||
assert_eq!(fs::read_to_string(f.path("headers")).unwrap(), "Authorization: Bearer gho_fake_first\n");
|
||||
let args = fs::read_to_string(f.path("curl-args")).unwrap();
|
||||
assert!(!args.contains("gho_fake"));
|
||||
assert!(args.starts_with("-q\n"));
|
||||
assert!(args.contains("--header\n@-\n--proto-redir\n=https\n"));
|
||||
assert!(!args.contains("--location-trusted"));
|
||||
assert_eq!(fs::read_to_string(f.path("gh-args")).unwrap(), "auth\ntoken\n--hostname\ngithub.com\n");
|
||||
fs::write(f.path("token"), "gho_fake_second\n").unwrap();
|
||||
f.get(API).unwrap();
|
||||
assert_eq!(fs::read_to_string(f.path("headers")).unwrap(), "Authorization: Bearer gho_fake_second\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_token_avoids_gh_and_non_github_urls_receive_no_credentials() {
|
||||
let mut f = Fixture::new();
|
||||
f.http.explicit_github_token = Some("explicit_fake".into());
|
||||
f.get(API).unwrap();
|
||||
assert!(!f.path("gh-args").exists());
|
||||
assert_eq!(fs::read_to_string(f.path("headers")).unwrap(), "Authorization: Bearer explicit_fake\n");
|
||||
f.http.explicit_github_token = None;
|
||||
for url in [
|
||||
"https://example.com/file",
|
||||
"http://api.github.com/repos/x",
|
||||
"https://api.github.com.evil.example/file",
|
||||
"https://[email protected]/file",
|
||||
"https://github.com/example/project/releases/download/v1/file",
|
||||
] {
|
||||
f.get(url).unwrap();
|
||||
assert_eq!(fs::read_to_string(f.path("headers")).unwrap(), "");
|
||||
assert!(!f.path("gh-args").exists());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_logged_out_or_malformed_gh_falls_back_to_anonymous() {
|
||||
let mut f = Fixture::new();
|
||||
for body in ["exit 1", "printf 'fake\\nX-Injected: yes\\n'", "printf ''", "printf 'fake'; exit 1"] {
|
||||
f.script("gh", body);
|
||||
f.get(API).unwrap();
|
||||
assert_eq!(fs::read_to_string(f.path("headers")).unwrap(), "");
|
||||
}
|
||||
f.http.gh = None;
|
||||
f.get(API).unwrap();
|
||||
assert_eq!(fs::read_to_string(f.path("headers")).unwrap(), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn download_auth_and_errors_do_not_expose_the_token() {
|
||||
let f = Fixture::new();
|
||||
let dest = f.path("download");
|
||||
f.curl(&format!("printf downloaded > {}", quote(&dest)));
|
||||
f.http.download(API, &dest, None, &CancellationToken::new(), &|_| {}).unwrap();
|
||||
assert_eq!(fs::read_to_string(&dest).unwrap(), "downloaded");
|
||||
assert!(fs::read_to_string(f.path("headers")).unwrap().contains("gho_fake_first"));
|
||||
f.curl(&format!("/bin/cat {} >&2; exit 22", quote(&f.path("headers"))));
|
||||
let error = f.get(API).unwrap_err().to_string();
|
||||
assert!(error.contains("[redacted]"));
|
||||
assert!(!error.contains("gho_fake_first"));
|
||||
let error = f.http.download(API, &dest, None, &CancellationToken::new(), &|_| {}).unwrap_err().to_string();
|
||||
assert!(!error.contains("gho_fake_first"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credential_lookup_is_bounded_and_cancellable_even_with_inherited_stdout() {
|
||||
let f = Fixture::new();
|
||||
// The parent exits but a child keeps stdout open: joining a reader
|
||||
// without checking the deadline would hang here.
|
||||
f.script("gh", "/bin/sleep 5 &\nexit 0");
|
||||
let started = Instant::now();
|
||||
assert!(
|
||||
read_gh_token(f.http.gh.as_ref().unwrap(), &CancellationToken::new(), Duration::from_millis(60))
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert!(started.elapsed() < Duration::from_secs(3));
|
||||
f.script("gh", "/bin/sleep 5");
|
||||
let cancel = CancellationToken::new();
|
||||
let trigger = cancel.clone();
|
||||
let thread = std::thread::spawn(move || {
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
trigger.cancel();
|
||||
});
|
||||
assert_eq!(f.http.get_text(API, &cancel).unwrap_err().kind, ErrorKind::Cancelled);
|
||||
thread.join().unwrap();
|
||||
assert!(!f.path("curl-args").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires an active gh github.com login and network access"]
|
||||
fn live_github_release_uses_authenticated_quota() {
|
||||
let http = Http::new(&std::env::var_os("PATH").unwrap()).unwrap();
|
||||
assert!(http.gh.is_some());
|
||||
let cancel = CancellationToken::new();
|
||||
let body = http.get_text_limited("https://api.github.com/rate_limit", Some(64 * 1024), &cancel).unwrap();
|
||||
let response: serde_json::Value = serde_json::from_str(&body).unwrap();
|
||||
assert!(response["resources"]["core"]["limit"].as_u64().unwrap() > 60);
|
||||
let body = http
|
||||
.get_text_limited(
|
||||
"https://api.github.com/repos/redhat-developer/vscode-xml/releases/latest",
|
||||
Some(1024 * 1024),
|
||||
&cancel,
|
||||
)
|
||||
.unwrap();
|
||||
let response: serde_json::Value = serde_json::from_str(&body).unwrap();
|
||||
assert!(response["tag_name"].is_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,12 +37,6 @@ impl Env {
|
||||
fs::write(&p, format!("#!/bin/sh\n{body}\n")).unwrap();
|
||||
archive::make_executable(&p).unwrap();
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn script(&self, dir: &Path, name: &str, body: &str) {
|
||||
fs::create_dir_all(dir).unwrap();
|
||||
fs::write(dir.join(format!("{name}.cmd")), format!("@echo off\r\n{body}\r\n")).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn find<'a>(list: &'a [ToolInfo], name: &str) -> &'a ToolInfo {
|
||||
@@ -706,34 +700,6 @@ fn rust_analyzer_managed_copy_survives_rustup_becoming_available() {
|
||||
assert!(!env.root.join("rust-analyzer").exists());
|
||||
}
|
||||
|
||||
/// Windows has `python.exe` and `py`, not `python3.exe`; the `WindowsApps` aliases only count
|
||||
/// when the Store's Python behind them runs.
|
||||
#[test]
|
||||
fn windows_python_skips_store_aliases_that_do_not_run() {
|
||||
let env = Env::new();
|
||||
let aliases = env.sys.parent().unwrap().join("Microsoft").join("WindowsApps");
|
||||
env.script(&aliases, "python", "exit 9009");
|
||||
env.script(&aliases, "python3", "exit 9009");
|
||||
env.script(&env.sys, "py", "echo Python 3.12.0");
|
||||
let windows = Platform { os: Os::Windows, arch: platform::Arch::X64 };
|
||||
let found = || {
|
||||
let dirs = std::env::join_paths([aliases.clone(), env.sys.clone()]).unwrap();
|
||||
Tools::with_paths(env.root.clone(), dirs, windows).python().map(|python| {
|
||||
let dir = python.parent().unwrap().file_name().unwrap().to_string_lossy().into_owned();
|
||||
format!("{dir}/{}", python.file_stem().unwrap().to_string_lossy())
|
||||
})
|
||||
};
|
||||
assert_eq!(found().as_deref(), Some("sysbin/py"));
|
||||
fs::remove_file(env.sys.join(if cfg!(windows) { "py.cmd" } else { "py" })).unwrap();
|
||||
assert_eq!(found(), None, "only broken aliases are available");
|
||||
env.script(&env.sys, "python3", "echo Python 3.12.0");
|
||||
assert_eq!(found().as_deref(), Some("sysbin/python3"), "python3 is the final fallback");
|
||||
env.script(&env.sys, "python", "echo Python 3.12.0");
|
||||
assert_eq!(found().as_deref(), Some("sysbin/python"), "a broken alias must not hide a later interpreter");
|
||||
env.script(&aliases, "python", "echo Python 3.13.0");
|
||||
assert_eq!(found().as_deref(), Some("WindowsApps/python"), "the Store's Python runs");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn python_probes_run_in_an_empty_private_directory() {
|
||||
|
||||
@@ -13,7 +13,7 @@ serde.workspace = true
|
||||
specta.workspace = true
|
||||
ignore.workspace = true
|
||||
globset.workspace = true
|
||||
notify.workspace = true
|
||||
notify-debouncer-full.workspace = true
|
||||
encoding_rs.workspace = true
|
||||
chardetng.workspace = true
|
||||
trash.workspace = true
|
||||
|
||||
Loaded 100 of 394 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user