mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-07 18:38:33 -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
+35
-309
@@ -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]]
|
||||
@@ -4153,7 +4120,7 @@ version = "0.1.36"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-run",
|
||||
"quick-xml 0.42.0",
|
||||
"quick-xml",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
@@ -4305,7 +4272,7 @@ dependencies = [
|
||||
"ide-workspace",
|
||||
"ignore",
|
||||
"parking_lot",
|
||||
"quick-xml 0.42.0",
|
||||
"quick-xml",
|
||||
"regex-syntax",
|
||||
"reqwest",
|
||||
"rustix",
|
||||
@@ -4504,7 +4471,6 @@ dependencies = [
|
||||
"tracing",
|
||||
"wasmtime",
|
||||
"wasmtime-wasi",
|
||||
"wat",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4548,7 +4514,7 @@ dependencies = [
|
||||
"libc",
|
||||
"parking_lot",
|
||||
"portable-pty",
|
||||
"quick-xml 0.42.0",
|
||||
"quick-xml",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
@@ -4619,8 +4585,6 @@ dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"flate2",
|
||||
"ide-core",
|
||||
"keyring",
|
||||
"keyring-core",
|
||||
"parking_lot",
|
||||
"russh",
|
||||
"russh-config",
|
||||
@@ -4631,7 +4595,6 @@ dependencies = [
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4671,7 +4634,6 @@ dependencies = [
|
||||
"proptest",
|
||||
"ropey",
|
||||
"serde",
|
||||
"similar",
|
||||
"specta",
|
||||
]
|
||||
|
||||
@@ -4728,7 +4690,7 @@ dependencies = [
|
||||
"globset",
|
||||
"ide-core",
|
||||
"ignore",
|
||||
"notify",
|
||||
"notify-debouncer-full",
|
||||
"parking_lot",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -5253,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"
|
||||
@@ -5702,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"
|
||||
@@ -5730,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"
|
||||
@@ -5766,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"
|
||||
@@ -5790,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"
|
||||
@@ -6699,7 +6593,7 @@ checksum = "65dca778f41c79ebbe8b2b2139fee86f01b766b67130d108c7c2b8ffbd949f77"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"indexmap 2.14.2",
|
||||
"quick-xml 0.42.0",
|
||||
"quick-xml",
|
||||
"serde",
|
||||
"time",
|
||||
]
|
||||
@@ -6744,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"
|
||||
@@ -7010,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"
|
||||
@@ -7296,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",
|
||||
@@ -7328,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"
|
||||
@@ -7407,7 +7273,7 @@ dependencies = [
|
||||
"md5",
|
||||
"ml-kem",
|
||||
"module-lattice",
|
||||
"num-bigint 0.5.1",
|
||||
"num-bigint",
|
||||
"p256",
|
||||
"p384",
|
||||
"p521",
|
||||
@@ -7738,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"
|
||||
@@ -7794,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"
|
||||
@@ -8363,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]]
|
||||
@@ -9301,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",
|
||||
@@ -9489,7 +9330,6 @@ dependencies = [
|
||||
"signal-hook-registry",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
"tracing",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
@@ -10306,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"
|
||||
@@ -10832,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"
|
||||
@@ -11199,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"
|
||||
@@ -11738,7 +11477,6 @@ dependencies = [
|
||||
"rustix",
|
||||
"serde",
|
||||
"serde_repr",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"uds_windows",
|
||||
"uuid",
|
||||
@@ -11749,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"
|
||||
@@ -11965,7 +11692,6 @@ dependencies = [
|
||||
"endi",
|
||||
"enumflags2",
|
||||
"serde",
|
||||
"url",
|
||||
"winnow 1.0.4",
|
||||
"zcheapstr",
|
||||
"zvariant_derive",
|
||||
|
||||
+1
-9
@@ -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();
|
||||
|
||||
@@ -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,77 +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 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("gradlew"), "").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/gradlew").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/gradlew").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 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("gradlew"), "").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("gradlew").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,56 +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)
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
@@ -675,41 +616,6 @@ pub(crate) fn has_war_plugin(script: &str) -> bool {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[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
-85
@@ -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,60 +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);
|
||||
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`.
|
||||
@@ -170,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)| {
|
||||
@@ -196,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),
|
||||
[external.canonicalize().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();
|
||||
@@ -227,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")));
|
||||
}
|
||||
@@ -274,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()
|
||||
|
||||
@@ -1065,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 {
|
||||
@@ -1089,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;
|
||||
@@ -1257,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 {
|
||||
@@ -1774,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"));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"] {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
+49
-110
@@ -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,13 +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<()>>>,
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@@ -110,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()),
|
||||
@@ -134,7 +126,6 @@ impl Host {
|
||||
external_logs: RwLock::default(),
|
||||
requests: Default::default(),
|
||||
project_requests: RwLock::default(),
|
||||
closing: Default::default(),
|
||||
};
|
||||
host.apply_proxy();
|
||||
host
|
||||
@@ -188,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();
|
||||
}
|
||||
@@ -258,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()
|
||||
@@ -300,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.
|
||||
|
||||
@@ -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,88 +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 {
|
||||
linked.retain(|path| !paths.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,
|
||||
@@ -185,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)?;
|
||||
@@ -204,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 {
|
||||
@@ -215,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,
|
||||
@@ -224,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))
|
||||
})
|
||||
@@ -256,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();
|
||||
@@ -360,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);
|
||||
@@ -501,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,
|
||||
@@ -514,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
|
||||
@@ -530,7 +405,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
c.arg("configuration")?,
|
||||
c.arg("offline")?,
|
||||
c.arg("request")?,
|
||||
c.arg("buildDir")?,
|
||||
)
|
||||
.await
|
||||
});
|
||||
@@ -558,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();
|
||||
@@ -608,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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>> {
|
||||
|
||||
+17
-128
@@ -88,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)?));
|
||||
@@ -210,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,
|
||||
@@ -230,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(),
|
||||
);
|
||||
}
|
||||
}));
|
||||
}
|
||||
@@ -526,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 {
|
||||
@@ -576,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();
|
||||
@@ -881,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.
|
||||
@@ -1089,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);
|
||||
}
|
||||
@@ -1193,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;
|
||||
}
|
||||
@@ -1449,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();
|
||||
@@ -1577,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"
|
||||
|
||||
@@ -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,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(|_| {}),
|
||||
)
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
@@ -199,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();
|
||||
@@ -219,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/";
|
||||
|
||||
@@ -542,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)?;
|
||||
|
||||
@@ -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 = [
|
||||
// dengliu.gitlink 0.1.0: functions added to `ui` on an unreleased branch, still at @1.0.0.
|
||||
(
|
||||
"a function the host's interface lacks",
|
||||
r#"(component (import "openidea:plugin/ui@1.0.0" (instance
|
||||
(export "copy-to-clipboard" (func (param "text" string))))))"#,
|
||||
"copy-to-clipboard",
|
||||
),
|
||||
(
|
||||
"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}");
|
||||
}
|
||||
}
|
||||
+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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+92
-184
@@ -12,8 +12,9 @@
|
||||
use crate::FsBatch;
|
||||
use crate::rules::{ExcludeMatcher, GitignoreStack};
|
||||
use ide_core::{IdeError, IdeEvent, IdeResult, SharedSink};
|
||||
use notify::event::{ModifyKind, RenameMode};
|
||||
use notify::{Event, EventKind, PollWatcher, RecommendedWatcher, RecursiveMode, WatcherKind};
|
||||
use notify_debouncer_full::notify::event::{ModifyKind, RenameMode};
|
||||
use notify_debouncer_full::notify::{self, EventKind, PollWatcher, RecommendedWatcher, RecursiveMode, WatcherKind};
|
||||
use notify_debouncer_full::{DebounceEventResult, DebouncedEvent, Debouncer, NoCache, new_debouncer_opt};
|
||||
use parking_lot::{Condvar, Mutex, RwLock};
|
||||
use std::collections::{BTreeMap, BTreeSet, HashSet};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
@@ -61,9 +62,10 @@ pub struct Watcher {
|
||||
|
||||
type SharedExcludes = Arc<RwLock<ExcludeMatcher>>;
|
||||
|
||||
struct Inner {
|
||||
state: Arc<DirState>,
|
||||
thread: Option<JoinHandle<()>>,
|
||||
enum Inner {
|
||||
Recursive(#[allow(dead_code)] Debouncer<RecommendedWatcher, NoCache>),
|
||||
Polling(#[allow(dead_code)] Debouncer<PollWatcher, NoCache>),
|
||||
PerDirectory { state: Arc<DirState>, thread: Option<JoinHandle<()>> },
|
||||
}
|
||||
|
||||
impl Watcher {
|
||||
@@ -129,32 +131,80 @@ impl Watcher {
|
||||
|
||||
/// Number of OS-level watches (1 in recursive and flat mode).
|
||||
pub fn watch_count(&self) -> usize {
|
||||
self.inner.state.watched.lock().len()
|
||||
match &self.inner {
|
||||
Inner::Recursive(_) | Inner::Polling(_) => 1,
|
||||
Inner::PerDirectory { state, .. } => state.watched.lock().len(),
|
||||
}
|
||||
}
|
||||
|
||||
fn start_native(root: &Path, filter: EventFilter, emit: Emit, mode: WatchMode) -> IdeResult<Inner> {
|
||||
Self::start_queued::<RecommendedWatcher>(root, filter, emit, mode, notify::Config::default())
|
||||
let config = notify::Config::default();
|
||||
match mode {
|
||||
WatchMode::Recursive => {
|
||||
Self::start_debounced(root, filter, emit, RecursiveMode::Recursive, config).map(Inner::Recursive)
|
||||
}
|
||||
WatchMode::Flat => {
|
||||
Self::start_debounced(root, filter, emit, RecursiveMode::NonRecursive, config).map(Inner::Recursive)
|
||||
}
|
||||
WatchMode::PerDirectory => Self::start_per_directory::<RecommendedWatcher>(root, filter, emit, config),
|
||||
}
|
||||
}
|
||||
|
||||
/// Polls per directory unless flat: a recursive poll would rescan
|
||||
/// `node_modules` and `target` every round.
|
||||
fn start_polling(root: &Path, filter: EventFilter, emit: Emit, mode: WatchMode) -> IdeResult<Inner> {
|
||||
let config = notify::Config::default().with_poll_interval(POLL_INTERVAL);
|
||||
let mode = if mode == WatchMode::Flat { mode } else { WatchMode::PerDirectory };
|
||||
Self::start_queued::<PollWatcher>(root, filter, emit, mode, config)
|
||||
match mode {
|
||||
WatchMode::Flat => {
|
||||
Self::start_debounced(root, filter, emit, RecursiveMode::NonRecursive, config).map(Inner::Polling)
|
||||
}
|
||||
WatchMode::Recursive | WatchMode::PerDirectory => {
|
||||
Self::start_per_directory::<PollWatcher>(root, filter, emit, config)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn start_queued<W: notify::Watcher + Send + 'static>(
|
||||
fn start_debounced<W: notify::Watcher>(
|
||||
root: &Path,
|
||||
filter: EventFilter,
|
||||
emit: Emit,
|
||||
recursive: RecursiveMode,
|
||||
config: notify::Config,
|
||||
) -> IdeResult<Debouncer<W, NoCache>> {
|
||||
let polled = W::kind() == WatcherKind::PollWatcher;
|
||||
// No file-id cache: building it walks and stats the whole tree before `watch` returns,
|
||||
// which made opening large projects block for seconds. Renames arrive as remove + create.
|
||||
let handler = move |result: DebounceEventResult| match result {
|
||||
Ok(mut events) => {
|
||||
if polled {
|
||||
events.retain(|e| !is_dir_touch(&e.event));
|
||||
}
|
||||
let (batch, _) = filter.batch_detailed(&events);
|
||||
if !batch.is_empty() {
|
||||
emit(&batch);
|
||||
}
|
||||
}
|
||||
Err(errors) => {
|
||||
for e in errors {
|
||||
tracing::warn!("file watcher: {e}");
|
||||
}
|
||||
}
|
||||
};
|
||||
let mut debouncer = new_debouncer_opt::<_, W, _>(DEBOUNCE, None, handler, NoCache, config)
|
||||
.map_err(|e| io_error(format!("cannot start file watcher: {e}")))?;
|
||||
debouncer.watch(root, recursive).map_err(|e| io_error(format!("cannot watch {}: {e}", root.display())))?;
|
||||
Ok(debouncer)
|
||||
}
|
||||
|
||||
fn start_per_directory<W: notify::Watcher + Send + 'static>(
|
||||
root: &Path,
|
||||
filter: EventFilter,
|
||||
emit: Emit,
|
||||
mode: WatchMode,
|
||||
config: notify::Config,
|
||||
) -> IdeResult<Inner> {
|
||||
let polled = W::kind() == WatcherKind::PollWatcher;
|
||||
let state = Arc::new(DirState {
|
||||
filter,
|
||||
mode,
|
||||
watcher: Mutex::new(None),
|
||||
watched: Mutex::default(),
|
||||
queue: Mutex::default(),
|
||||
@@ -178,11 +228,8 @@ impl Watcher {
|
||||
)
|
||||
.map_err(|e| io_error(format!("cannot start file watcher: {e}")))?;
|
||||
*state.watcher.lock() = Some(Box::new(watcher));
|
||||
if mode == WatchMode::PerDirectory {
|
||||
state.watch_tree(root, false, &mut Vec::new());
|
||||
} else {
|
||||
state.add_watch(root);
|
||||
}
|
||||
let mut created = Vec::new();
|
||||
state.watch_tree(root, false, &mut created);
|
||||
if state.watched.lock().is_empty() {
|
||||
return Err(io_error(format!("cannot watch {}", root.display())));
|
||||
}
|
||||
@@ -193,22 +240,20 @@ impl Watcher {
|
||||
.spawn(move || state.run(&*emit))
|
||||
.map_err(|e| io_error(format!("cannot start file watcher: {e}")))?
|
||||
};
|
||||
Ok(Inner { state, thread: Some(thread) })
|
||||
Ok(Inner::PerDirectory { state, thread: Some(thread) })
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Inner {
|
||||
impl Drop for Watcher {
|
||||
fn drop(&mut self) {
|
||||
let state = &self.state;
|
||||
{
|
||||
let _queue = state.queue.lock();
|
||||
if let Inner::PerDirectory { state, thread } = &mut self.inner {
|
||||
state.stop.store(true, Ordering::SeqCst);
|
||||
state.wake.notify_all();
|
||||
}
|
||||
let watcher = state.watcher.lock().take();
|
||||
drop(watcher);
|
||||
if let Some(t) = self.thread.take() {
|
||||
let _ = t.join();
|
||||
let watcher = state.watcher.lock().take();
|
||||
drop(watcher);
|
||||
if let Some(t) = thread.take() {
|
||||
let _ = t.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -223,10 +268,10 @@ fn is_dir_touch(event: ¬ify::Event) -> bool {
|
||||
matches!(event.kind, EventKind::Modify(_)) && event.paths.iter().all(|p| p.is_dir())
|
||||
}
|
||||
|
||||
/// Shared with the notify callback (weakly) and the debounce thread.
|
||||
/// State of the per-directory strategy, shared with the notify callback
|
||||
/// (weakly) and the debounce thread.
|
||||
struct DirState {
|
||||
filter: EventFilter,
|
||||
mode: WatchMode,
|
||||
watcher: Mutex<Option<Box<dyn notify::Watcher + Send>>>,
|
||||
watched: Mutex<HashSet<PathBuf>>,
|
||||
queue: Mutex<Vec<(notify::Event, Instant)>>,
|
||||
@@ -265,13 +310,9 @@ impl DirState {
|
||||
}
|
||||
std::mem::take(&mut *q)
|
||||
};
|
||||
// A scan may publish a path before its create event is delivered:
|
||||
// keep create + remove pairs so the consumer still sees the removal.
|
||||
let events: Vec<Event> = events.into_iter().map(|(e, _)| e).collect();
|
||||
let events: Vec<DebouncedEvent> = events.into_iter().map(|(e, t)| DebouncedEvent::new(e, t)).collect();
|
||||
let (mut batch, rule_dirs) = self.filter.batch_detailed(&events);
|
||||
if self.mode == WatchMode::PerDirectory {
|
||||
self.update_watches(&mut batch, &rule_dirs);
|
||||
}
|
||||
self.update_watches(&mut batch, &rule_dirs);
|
||||
if !batch.is_empty() && !self.stopped() {
|
||||
emit(&batch);
|
||||
}
|
||||
@@ -322,9 +363,7 @@ impl DirState {
|
||||
}
|
||||
let mut guard = self.watcher.lock();
|
||||
let Some(w) = guard.as_mut() else { return false };
|
||||
let recursive =
|
||||
if self.mode == WatchMode::Recursive { RecursiveMode::Recursive } else { RecursiveMode::NonRecursive };
|
||||
match w.watch(dir, recursive) {
|
||||
match w.watch(dir, RecursiveMode::NonRecursive) {
|
||||
Ok(()) => {
|
||||
self.watched.lock().insert(dir.to_path_buf());
|
||||
true
|
||||
@@ -404,9 +443,6 @@ fn merge(prev: Option<Change>, next: Change) -> Change {
|
||||
match (prev, next) {
|
||||
(None, n) => n,
|
||||
(Some(Vcs), _) | (_, Vcs) => Vcs,
|
||||
// FSEvents reports a deleted file's history as create, remove, then modify: a
|
||||
// modify can't bring back a path that went away, only a create can.
|
||||
(Some(Transient), Modified) => Transient,
|
||||
(Some(Created | Transient), Modified | Created) => Created,
|
||||
(Some(Created | Transient), Removed) => Transient,
|
||||
(Some(Removed), Created | Modified) => Modified,
|
||||
@@ -519,17 +555,17 @@ impl EventFilter {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn batch(&self, events: &[Event]) -> FsBatch {
|
||||
pub fn batch(&self, events: &[DebouncedEvent]) -> FsBatch {
|
||||
self.batch_detailed(events).0
|
||||
}
|
||||
|
||||
/// The coalesced, filtered batch plus the directories whose ignore rules
|
||||
/// changed (their cached matchers have been rebuilt already).
|
||||
pub fn batch_detailed(&self, events: &[Event]) -> (FsBatch, Vec<PathBuf>) {
|
||||
pub fn batch_detailed(&self, events: &[DebouncedEvent]) -> (FsBatch, Vec<PathBuf>) {
|
||||
let mut changes: BTreeMap<PathBuf, Change> = BTreeMap::new();
|
||||
for ev in events {
|
||||
let paths = &ev.paths;
|
||||
match ev.kind {
|
||||
let paths = &ev.event.paths;
|
||||
match ev.event.kind {
|
||||
EventKind::Access(_) => {}
|
||||
EventKind::Create(_) => paths.iter().for_each(|p| self.classify(p, Change::Created, &mut changes)),
|
||||
EventKind::Remove(_) => paths.iter().for_each(|p| self.classify(p, Change::Removed, &mut changes)),
|
||||
@@ -565,7 +601,7 @@ impl EventFilter {
|
||||
rule_dirs.insert(dir.to_path_buf());
|
||||
}
|
||||
}
|
||||
if events.iter().flat_map(|e| &e.paths).any(|p| p.ends_with(".git/info/exclude")) {
|
||||
if events.iter().flat_map(|e| &e.event.paths).any(|p| p.ends_with(".git/info/exclude")) {
|
||||
self.gitignore.reload_repo_level();
|
||||
rule_dirs.insert(self.root.clone());
|
||||
}
|
||||
@@ -633,125 +669,11 @@ impl EventFilter {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use notify::event::{CreateKind, DataChange, MetadataKind, RemoveKind};
|
||||
use notify_debouncer_full::notify::Event;
|
||||
use notify_debouncer_full::notify::event::{CreateKind, DataChange, RemoveKind};
|
||||
|
||||
fn ev(kind: EventKind, paths: Vec<PathBuf>) -> Event {
|
||||
Event { kind, paths, attrs: Default::default() }
|
||||
}
|
||||
|
||||
const TRANSIENT_PATHS: &[&str] = &["fresh.txt", "src/fresh.rs", "src/tasks/fresh.py"];
|
||||
|
||||
/// Delivers a whole debounce window without depending on OS scheduling.
|
||||
struct TransientWatcher(Box<dyn notify::EventHandler>);
|
||||
|
||||
impl notify::Watcher for TransientWatcher {
|
||||
fn new<F: notify::EventHandler>(handler: F, _: notify::Config) -> notify::Result<Self> {
|
||||
Ok(Self(Box::new(handler)))
|
||||
}
|
||||
|
||||
fn watch(&mut self, root: &Path, _: RecursiveMode) -> notify::Result<()> {
|
||||
for rel in TRANSIENT_PATHS {
|
||||
for kind in [
|
||||
EventKind::Create(CreateKind::File),
|
||||
EventKind::Modify(ModifyKind::Data(DataChange::Content)),
|
||||
EventKind::Remove(RemoveKind::File),
|
||||
] {
|
||||
self.0.handle_event(Ok(Event::new(kind).add_path(root.join(rel))));
|
||||
}
|
||||
}
|
||||
for kind in [EventKind::Create(CreateKind::Folder), EventKind::Remove(RemoveKind::Folder)] {
|
||||
self.0.handle_event(Ok(Event::new(kind).add_path(root.join("new"))));
|
||||
}
|
||||
for (rel, kind) in [
|
||||
("from.py", EventKind::Create(CreateKind::File)),
|
||||
("from.py", EventKind::Modify(ModifyKind::Name(RenameMode::From))),
|
||||
("to.py", EventKind::Modify(ModifyKind::Name(RenameMode::To))),
|
||||
("to.py", EventKind::Remove(RemoveKind::File)),
|
||||
] {
|
||||
self.0.handle_event(Ok(Event::new(kind).add_path(root.join(rel))));
|
||||
}
|
||||
for kind in [EventKind::Remove(RemoveKind::File), EventKind::Create(CreateKind::File)] {
|
||||
self.0.handle_event(Ok(Event::new(kind).add_path(root.join("kept.py"))));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn unwatch(&mut self, _: &Path) -> notify::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn kind() -> WatcherKind {
|
||||
WatcherKind::NullWatcher
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debounce_preserves_transient_removals_and_atomic_replacements() {
|
||||
for mode in [WatchMode::Recursive, WatchMode::Flat, WatchMode::PerDirectory] {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
std::fs::write(root.join("kept.py"), "class Kept: pass\n").unwrap();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let emit: Emit = Arc::new(move |b| tx.send(b.clone()).unwrap());
|
||||
let _watcher = Watcher::start_queued::<TransientWatcher>(
|
||||
&root,
|
||||
EventFilter::new(root.clone(), &[]),
|
||||
emit,
|
||||
mode,
|
||||
notify::Config::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let batch = rx.recv_timeout(Duration::from_secs(5)).unwrap();
|
||||
let removed: BTreeSet<_> = batch.removed.into_iter().collect();
|
||||
let expected =
|
||||
TRANSIENT_PATHS.iter().copied().chain(["new", "from.py", "to.py"]).map(|rel| root.join(rel)).collect();
|
||||
assert_eq!(removed, expected, "{mode:?}: scans may have already published transient paths");
|
||||
assert_eq!(batch.modified, vec![root.join("kept.py")]);
|
||||
assert!(batch.created.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn native_watch_reports_quickly_removed_files() {
|
||||
for mode in [WatchMode::Recursive, WatchMode::Flat, WatchMode::PerDirectory] {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
std::fs::create_dir_all(root.join("src/tasks")).unwrap();
|
||||
let batches: Arc<Mutex<Vec<FsBatch>>> = Arc::default();
|
||||
let watcher = {
|
||||
let batches = batches.clone();
|
||||
Watcher::start_with(
|
||||
&root,
|
||||
Vec::new(),
|
||||
Arc::new(ide_core::NullSink),
|
||||
Box::new(move |b| batches.lock().push(b.clone())),
|
||||
mode,
|
||||
)
|
||||
.unwrap()
|
||||
};
|
||||
assert!(watcher.polling().is_none());
|
||||
assert_eq!(watcher.watch_count(), if mode == WatchMode::PerDirectory { 3 } else { 1 });
|
||||
let paths: Vec<_> = TRANSIENT_PATHS
|
||||
.iter()
|
||||
.filter(|rel| mode != WatchMode::Flat || !rel.contains('/'))
|
||||
.map(|rel| root.join(rel))
|
||||
.collect();
|
||||
for path in &paths {
|
||||
std::fs::write(path, "class FreshWidget:\n pass\n").unwrap();
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
assert!(
|
||||
wait_for(&batches, |b| paths.iter().all(|p| b.iter().any(|batch| batch.removed.contains(p)))),
|
||||
"{mode:?}: {:?}",
|
||||
batches.lock()
|
||||
);
|
||||
drop(watcher);
|
||||
let count = batches.lock().len();
|
||||
std::fs::write(root.join("after-drop.txt"), "x").unwrap();
|
||||
std::thread::sleep(DEBOUNCE * 2);
|
||||
assert_eq!(batches.lock().len(), count, "no callback after drop in {mode:?}");
|
||||
}
|
||||
fn ev(kind: EventKind, paths: Vec<PathBuf>) -> DebouncedEvent {
|
||||
DebouncedEvent::new(Event { kind, paths, attrs: Default::default() }, Instant::now())
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -791,24 +713,6 @@ mod tests {
|
||||
assert_eq!(batch.ignored, vec![root.join("debug.log")], "ignored files are reported apart");
|
||||
}
|
||||
|
||||
/// What FSEvents delivers when a file is deleted: its coalesced history flags as
|
||||
/// create, remove, then modify events, all for a path that no longer exists.
|
||||
#[test]
|
||||
fn fsevents_deletion_with_history_flags_is_a_removal() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let filter = EventFilter::new(root.clone(), &[]);
|
||||
let gone = root.join("src/Fresh.java");
|
||||
let batch = filter.batch(&[
|
||||
ev(EventKind::Create(CreateKind::File), vec![gone.clone()]),
|
||||
ev(EventKind::Remove(RemoveKind::File), vec![gone.clone()]),
|
||||
ev(EventKind::Modify(ModifyKind::Metadata(MetadataKind::Extended)), vec![gone.clone()]),
|
||||
ev(EventKind::Modify(ModifyKind::Data(DataChange::Content)), vec![gone.clone()]),
|
||||
]);
|
||||
assert_eq!(batch.removed, vec![gone]);
|
||||
assert!(batch.created.is_empty() && batch.modified.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn atomic_save_temp_files_are_not_reported() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -918,7 +822,8 @@ mod tests {
|
||||
}
|
||||
let filter = EventFilter::new(root.clone(), &[]);
|
||||
let modify = EventKind::Modify(ModifyKind::Data(DataChange::Content));
|
||||
let touch = |paths: &[&str]| -> Vec<Event> { paths.iter().map(|p| ev(modify, vec![root.join(p)])).collect() };
|
||||
let touch =
|
||||
|paths: &[&str]| -> Vec<DebouncedEvent> { paths.iter().map(|p| ev(modify, vec![root.join(p)])).collect() };
|
||||
let (batch, rules) = filter.batch_detailed(&touch(&["pkg/a.tmp", "pkg/gen/x.rs", "pkg/keep.rs", "b.tmp"]));
|
||||
assert_eq!(batch.modified, vec![root.join("b.tmp"), root.join("pkg/keep.rs")]);
|
||||
assert_eq!(batch.ignored, vec![root.join("pkg/a.tmp")], "nested rules, but not inside ignored folders");
|
||||
@@ -968,7 +873,10 @@ mod tests {
|
||||
}
|
||||
|
||||
fn watcher_state(w: &Watcher) -> &DirState {
|
||||
&w.inner.state
|
||||
match &w.inner {
|
||||
Inner::PerDirectory { state, .. } => state,
|
||||
Inner::Recursive(_) | Inner::Polling(_) => panic!("not per-directory"),
|
||||
}
|
||||
}
|
||||
|
||||
fn wait_for(events: &Mutex<Vec<FsBatch>>, pred: impl Fn(&[FsBatch]) -> bool) -> bool {
|
||||
|
||||
@@ -25,7 +25,7 @@ pub use editorconfig::{EditorConfig, EditorConfigProps};
|
||||
pub use file_defaults::FileDefaults;
|
||||
pub use file_moves::{FileMove, FileMoves, UndoOutcome};
|
||||
pub use history::{DirectoryHistory, HistoryEntry, HistoryKind, LocalHistory, Retention};
|
||||
pub use recent::{RecentProject, RecentProjectGroup, RecentProjects};
|
||||
pub use recent::{RecentProject, RecentProjects};
|
||||
pub use roots::{ProjectRoots, RootKind, SourceFolder};
|
||||
pub use settings::{SettingsScope, SettingsStore};
|
||||
|
||||
|
||||
@@ -16,78 +16,10 @@ pub struct RecentProject {
|
||||
|
||||
pub const MAX_RECENT_PROJECTS: usize = 30;
|
||||
|
||||
/// A named group of recent projects on the welcome screen (New Project Group…, Move to Group).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RecentProjectGroup {
|
||||
pub name: String,
|
||||
/// Its projects, in the order they were added.
|
||||
pub paths: Vec<ide_core::IpcPath>,
|
||||
#[serde(default = "expanded")]
|
||||
pub expanded: bool,
|
||||
}
|
||||
|
||||
fn expanded() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Persisted at `<app-config>/recent.json`, newest first; groups at `<app-config>/recent-groups.json`.
|
||||
/// Persisted at `<app-config>/recent.json`, newest first.
|
||||
pub struct RecentProjects {
|
||||
file: PathBuf,
|
||||
items: Mutex<Vec<RecentProject>>,
|
||||
groups_file: PathBuf,
|
||||
groups: Mutex<Vec<RecentProjectGroup>>,
|
||||
}
|
||||
|
||||
fn read_json<T: serde::de::DeserializeOwned>(file: &Path) -> Option<T> {
|
||||
match std::fs::read(file) {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes)
|
||||
.map_err(|e| tracing::warn!("{} is invalid, ignoring: {e}", file.display()))
|
||||
.ok(),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
|
||||
Err(e) => {
|
||||
tracing::warn!("cannot read {}: {e}", file.display());
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_json<T: Serialize + ?Sized>(file: &Path, value: &T) -> IdeResult<()> {
|
||||
if let Some(dir) = file.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
let json = serde_json::to_vec_pretty(value).map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
ide_vfs::write_atomic(file, &json)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Each path in one group at most (the first keeps it), group names trimmed and unique.
|
||||
fn normalize(groups: Vec<RecentProjectGroup>) -> Vec<RecentProjectGroup> {
|
||||
let mut paths = std::collections::HashSet::new();
|
||||
let mut names = std::collections::HashSet::new();
|
||||
groups
|
||||
.into_iter()
|
||||
.filter_map(|mut g| {
|
||||
g.name = g.name.trim().to_owned();
|
||||
if g.name.is_empty() || !names.insert(g.name.clone()) {
|
||||
return None;
|
||||
}
|
||||
g.paths.retain(|p| paths.insert(p.clone()));
|
||||
Some(g)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Keeps the newest [`MAX_RECENT_PROJECTS`] projects outside groups; grouped ones stay.
|
||||
fn cap(items: &mut Vec<RecentProject>, groups: &[RecentProjectGroup]) {
|
||||
let mut ungrouped = 0;
|
||||
items.retain(|p| {
|
||||
if groups.iter().any(|g| g.paths.contains(&p.path)) {
|
||||
return true;
|
||||
}
|
||||
ungrouped += 1;
|
||||
ungrouped <= MAX_RECENT_PROJECTS
|
||||
});
|
||||
}
|
||||
|
||||
fn now_ms() -> f64 {
|
||||
@@ -97,40 +29,28 @@ fn now_ms() -> f64 {
|
||||
impl RecentProjects {
|
||||
pub fn load(config_dir: &Path) -> Self {
|
||||
let file = config_dir.join("recent.json");
|
||||
let groups_file = config_dir.join("recent-groups.json");
|
||||
let mut items: Vec<RecentProject> = read_json(&file).unwrap_or_default();
|
||||
let mut groups = normalize(read_json(&groups_file).unwrap_or_default());
|
||||
let mut items: Vec<RecentProject> = match std::fs::read(&file) {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|e| {
|
||||
tracing::warn!("{} is invalid, ignoring: {e}", file.display());
|
||||
Vec::new()
|
||||
}),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(),
|
||||
Err(e) => {
|
||||
tracing::warn!("cannot read {}: {e}", file.display());
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
items.sort_by(|a, b| b.opened_at_ms.total_cmp(&a.opened_at_ms));
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
items.retain(|p| seen.insert(p.path.clone()));
|
||||
cap(&mut items, &groups);
|
||||
// A group lists only projects that are still recent.
|
||||
for g in &mut groups {
|
||||
g.paths.retain(|p| items.iter().any(|i| i.path == *p));
|
||||
}
|
||||
Self { file, items: Mutex::new(items), groups_file, groups: Mutex::new(groups) }
|
||||
items.truncate(MAX_RECENT_PROJECTS);
|
||||
Self { file, items: Mutex::new(items) }
|
||||
}
|
||||
|
||||
pub fn list(&self) -> Vec<RecentProject> {
|
||||
self.items.lock().clone()
|
||||
}
|
||||
|
||||
pub fn groups(&self) -> Vec<RecentProjectGroup> {
|
||||
self.groups.lock().clone()
|
||||
}
|
||||
|
||||
/// Replaces the groups (paths that are not recent projects are dropped).
|
||||
pub fn set_groups(&self, groups: Vec<RecentProjectGroup>) -> IdeResult<Vec<RecentProjectGroup>> {
|
||||
let items = self.items.lock();
|
||||
let mut groups = normalize(groups);
|
||||
for g in &mut groups {
|
||||
g.paths.retain(|p| items.iter().any(|i| i.path == *p));
|
||||
}
|
||||
write_json(&self.groups_file, &groups)?;
|
||||
*self.groups.lock() = groups.clone();
|
||||
Ok(groups)
|
||||
}
|
||||
|
||||
/// Moves (or adds) `path` to the top of the list.
|
||||
pub fn touch(&self, path: &Path, branch: Option<String>) -> IdeResult<()> {
|
||||
let mut items = self.items.lock();
|
||||
@@ -138,7 +58,7 @@ impl RecentProjects {
|
||||
let name =
|
||||
path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| path.display().to_string());
|
||||
items.insert(0, RecentProject { path: path.into(), name, opened_at_ms: now_ms(), branch });
|
||||
cap(&mut items, &self.groups.lock());
|
||||
items.truncate(MAX_RECENT_PROJECTS);
|
||||
self.persist(&items)
|
||||
}
|
||||
|
||||
@@ -163,15 +83,7 @@ impl RecentProjects {
|
||||
if items.len() == before {
|
||||
return Ok(());
|
||||
}
|
||||
self.persist(&items)?;
|
||||
let mut groups = self.groups.lock();
|
||||
if groups.iter().any(|g| g.paths.iter().any(|p| p == path)) {
|
||||
for g in groups.iter_mut() {
|
||||
g.paths.retain(|p| p != path);
|
||||
}
|
||||
write_json(&self.groups_file, &*groups)?;
|
||||
}
|
||||
Ok(())
|
||||
self.persist(&items)
|
||||
}
|
||||
|
||||
pub fn paths(&self) -> Vec<PathBuf> {
|
||||
@@ -179,7 +91,12 @@ impl RecentProjects {
|
||||
}
|
||||
|
||||
fn persist(&self, items: &[RecentProject]) -> IdeResult<()> {
|
||||
write_json(&self.file, items)
|
||||
if let Some(dir) = self.file.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
let json = serde_json::to_vec_pretty(items).map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
ide_vfs::write_atomic(&self.file, &json)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,41 +131,6 @@ mod tests {
|
||||
assert!(RecentProjects::load(&cfg).list().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn groups_keep_their_projects_and_survive_a_reload() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let recent = RecentProjects::load(tmp.path());
|
||||
for i in 0..3 {
|
||||
recent.touch(&PathBuf::from(format!("/p/proj{i}")), None).unwrap();
|
||||
}
|
||||
let group = |name: &str, paths: &[&str]| RecentProjectGroup {
|
||||
name: name.into(),
|
||||
paths: paths.iter().map(|p| PathBuf::from(p).into()).collect(),
|
||||
expanded: true,
|
||||
};
|
||||
let saved = recent
|
||||
.set_groups(vec![
|
||||
group(" Work ", &["/p/proj0", "/p/gone"]),
|
||||
group("Work", &["/p/proj1"]),
|
||||
group("Oss", &["/p/proj0", "/p/proj2"]),
|
||||
group(" ", &[]),
|
||||
])
|
||||
.unwrap();
|
||||
// Trimmed, unique names; each project in one group; only recent projects.
|
||||
assert_eq!(saved, vec![group("Work", &["/p/proj0"]), group("Oss", &["/p/proj2"])]);
|
||||
assert_eq!(RecentProjects::load(tmp.path()).groups(), saved);
|
||||
|
||||
// A grouped project outlives the cap; removing it from recent projects ungroups it.
|
||||
for i in 3..40 {
|
||||
recent.touch(&PathBuf::from(format!("/p/proj{i}")), None).unwrap();
|
||||
}
|
||||
let paths = recent.paths();
|
||||
assert_eq!(paths.len(), MAX_RECENT_PROJECTS + 2);
|
||||
assert!(paths.contains(&PathBuf::from("/p/proj0")) && paths.contains(&PathBuf::from("/p/proj2")));
|
||||
recent.remove(Path::new("/p/proj2")).unwrap();
|
||||
assert_eq!(RecentProjects::load(tmp.path()).groups(), vec![group("Work", &["/p/proj0"]), group("Oss", &[])]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_shows_a_custom_name_until_reset() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -344,28 +344,6 @@ fn backend_edits_emit_patches() {
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), "ONE\r\n2\r\nTHREE\r\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_change_in_two_places_patches_each() {
|
||||
let f = fixture();
|
||||
let path = f.root.join("two.txt");
|
||||
let old: String = (0..400).map(|i| format!("line {i}\n")).collect();
|
||||
fs::write(&path, &old).unwrap();
|
||||
let id = f.docs.open(&path).unwrap().id;
|
||||
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
let new = old.replace("line 20\n", "line 20x\n").replace("line 299\n", "line 299x\n");
|
||||
fs::write(&path, &new).unwrap();
|
||||
f.docs.on_disk_changed(std::slice::from_ref(&path));
|
||||
|
||||
assert_eq!(f.docs.snapshot(id).unwrap().text, new);
|
||||
let events = f.events.lock();
|
||||
let changes = events.iter().find_map(|e| match e {
|
||||
IdeEvent::DocPatched { changes, .. } => Some(changes),
|
||||
_ => None,
|
||||
});
|
||||
assert_eq!(changes.map(Vec::len), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_changes() {
|
||||
let f = fixture();
|
||||
|
||||
@@ -192,9 +192,7 @@ ship no `.icls` at all keep the built-in syntax colours.
|
||||
| `--danger`, `--warning` | `Component.errorFocusColor`, `Component.warningFocusColor` |
|
||||
| `--bg-tooltip`, `--fg-tooltip`, `--border-tooltip` | `ToolTip.background`, `.foreground`, `.borderColor` |
|
||||
| `--bg-button`, `--border-button` | `Button.startBackground`, `Button.startBorderColor` |
|
||||
| `--bg-tab-selected` | `EditorTabs.underlinedTabBackground` |
|
||||
| `--border-tab-selected` | `EditorTabs.underlinedBorderColor` (Islands), `EditorTabs.underlineColor` |
|
||||
| `--fg-completion-match` | `CompletionPopup.matchForeground` |
|
||||
| `--bg-tab-selected`, `--border-tab-selected` | `EditorTabs.underlinedTabBackground`, `EditorTabs.underlineColor` |
|
||||
| `--bg-toolbar-hover`, `--bg-toolbar-pressed` | `ActionButton.hoverBackground`, `ActionButton.pressedBackground` |
|
||||
| `--bg-stripe-hover`, `--bg-stripe-selected` | `ToolWindow.Button.hoverBackground`, `.selectedBackground` |
|
||||
| `--scrollbar-thumb`, `--scrollbar-thumb-hover` | `ScrollBar.Mac.thumbColor`, `ScrollBar.Mac.hoverThumbColor` |
|
||||
@@ -211,7 +209,6 @@ The full table is `UI_KEYS` in [`src/features/plugins/intellijTheme.ts`](../../s
|
||||
| `--caret`, `--bg-current-line` | colour `CARET_COLOR`, `CARET_ROW_COLOR` |
|
||||
| `--bg-editor-selection` | colour `SELECTION_BACKGROUND` |
|
||||
| `--fg-line-number`, `--fg-line-number-current` | colour `LINE_NUMBERS_COLOR`, `LINE_NUMBER_ON_CARET_ROW_COLOR` |
|
||||
| `--bg-editor-gutter` | colour `EDITOR_GUTTER_BACKGROUND`, else the editor background (as the new UI paints it; `GUTTER_BACKGROUND` is the classic UI's) |
|
||||
| `--border-indent-guide`, `--border-right-margin`, `--fg-whitespace` | colour `INDENT_GUIDE`, `RIGHT_MARGIN_COLOR`, `WHITESPACES` |
|
||||
| `--vcs-*`, `--gutter-*` | colour `FILESTATUS_*`, `MODIFIED_LINES_COLOR`, `ADDED_LINES_COLOR`, `DELETED_LINES_COLOR` |
|
||||
| `--syn-keyword`, `--syn-string`, `--syn-number` | `DEFAULT_KEYWORD`, `DEFAULT_STRING`, `DEFAULT_NUMBER` |
|
||||
@@ -229,9 +226,7 @@ The full table is `UI_KEYS` in [`src/features/plugins/intellijTheme.ts`](../../s
|
||||
| `--syn-self`, `--syn-keyword-arg`, `--syn-docstring`, `--syn-predefined` | `PY.SELF_PARAMETER`, `PY.KEYWORD_ARGUMENT`, `PY.DOC_COMMENT`, `PY.BUILTIN_NAME` |
|
||||
| `--ansi-*` | `CONSOLE_*_OUTPUT` (`CONSOLE_DARKGRAY_OUTPUT` is bright black, `CONSOLE_GRAY_OUTPUT` white) |
|
||||
|
||||
The full tables are `ICLS_COLORS` and `ICLS_ATTRIBUTES` in the same file. An
|
||||
attribute written as `<value />` has no colours of its own and shows as plain
|
||||
text (`TEXT`'s foreground), as in IntelliJ, not with the parent scheme's colour.
|
||||
The full tables are `ICLS_COLORS` and `ICLS_ATTRIBUTES` in the same file.
|
||||
|
||||
### From VS Code (by hand)
|
||||
|
||||
@@ -323,7 +318,7 @@ a token.
|
||||
|
||||
<!-- BEGIN GENERATED TOKEN REFERENCE: pnpm gen:theme-docs -->
|
||||
|
||||
224 themeable tokens: 117 in the UI theme file, 107 in the editor colour scheme file.
|
||||
223 themeable tokens: 117 in the UI theme file, 106 in the editor colour scheme file.
|
||||
|
||||
### Surfaces (UI theme)
|
||||
|
||||
@@ -425,7 +420,6 @@ a token.
|
||||
| `--syn-number` | `#2aacb8` | `#1750eb` |
|
||||
| `--syn-punct` | `#bcbec4` | `#080808` |
|
||||
| `--syn-variable` | `#bcbec4` | `#080808` |
|
||||
| `--syn-global` | `var(--syn-variable)` | `var(--syn-variable)` |
|
||||
| `--syn-param` | `#bcbec4` | `#080808` |
|
||||
| `--syn-property` | `#c77dbb` | `#871094` |
|
||||
| `--syn-constant` | `#c77dbb` | `#871094` |
|
||||
|
||||
@@ -22,15 +22,7 @@ Paths in an existing manifest may be relative to the workspace directory. New an
|
||||
|
||||
Linked folders appear as roots in Project view and participate in indexing, Find in Files, Git repository discovery and language-server root selection. Detected and saved run configurations have project-qualified names and use their member's working directory and launch settings. Build Project combines the linked projects' build plans; Compile File targets its owning project. Workspace editor/settings state belongs to its own directory. Each member retains its repository and project configuration.
|
||||
|
||||
The specialized Maven/Gradle tool-window model still describes the workspace directory itself; it does not yet aggregate independent member models. Workspace creation and management currently target local folders. To link independent Gradle builds in one Gradle tree, open their parent folder as an ordinary project instead of creating a Workspace manifest, then use **Link Gradle Projects…** in the Gradle toolbar or Find Action.
|
||||
|
||||
## Linked Gradle builds in a project
|
||||
|
||||
In an ordinary project window, **Link Gradle Projects…** accepts one or more `settings.gradle`, `settings.gradle.kts`, `build.gradle`, or `build.gradle.kts` files. Each linked build has its own root in the Gradle tool window. Its tasks, dependencies, wrapper, working directory and sync cache stay separate, even when another build has the same `:app` project or task names. Included builds remain under the build that includes them.
|
||||
|
||||
Right-click a build root for **Reload Gradle Project** or **Unlink Gradle Project**. Unlinking only removes the IDE connection; it does not delete files or saved run configurations. The Link button remains available after the last build is unlinked. Linked builds may live outside the opened folder.
|
||||
|
||||
Links are saved in project settings as `languages.java.gradle.linkedProjects`. Until links are configured, existing automatic discovery is unchanged. An explicit empty list means that no Gradle builds are linked. Opening the project again restores its links. A folder first opened by an older version may need one fresh sync because independent builds now have separate caches.
|
||||
The specialized Maven/Gradle tool-window model still describes the workspace directory itself; it does not yet aggregate independent member models. Workspace creation and management currently target local folders.
|
||||
|
||||
## Window group
|
||||
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { expect, test } from "./fixtures";
|
||||
|
||||
const xml = readFileSync(
|
||||
process.env.OPENIDEA_ICLS_FIXTURE ?? "src/platform/__fixtures__/colorSchemes/monokai-php.icls",
|
||||
"utf8",
|
||||
);
|
||||
|
||||
test("imported PHP colours stay separate from Java and preserve regular text on a dark background", async ({
|
||||
workbench: page,
|
||||
}, info) => {
|
||||
await page.evaluate(async (xml) => {
|
||||
const iclsPath = "/src/platform/icls.ts";
|
||||
const projectPath = "/src/platform/project.ts";
|
||||
const backendPath = "/src/editor/core/backend.ts";
|
||||
const contributionsPath = "/src/workbench/contributions.ts";
|
||||
const { parseIcls } = await import(iclsPath);
|
||||
const { useProject } = await import(projectPath);
|
||||
const { docBackend } = await import(backendPath);
|
||||
const { editorService } = await import(contributionsPath);
|
||||
const previous = useProject.getState().settings ?? {};
|
||||
useProject.setState({
|
||||
settings: {
|
||||
...previous,
|
||||
editor: {
|
||||
...previous.editor,
|
||||
colorScheme: "custom-php",
|
||||
customSchemes: { "custom-php": parseIcls(xml).scheme },
|
||||
semanticHighlighting: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
const backend = docBackend();
|
||||
const open = backend.open.bind(backend);
|
||||
backend.open = async (...args: Parameters<typeof open>) => {
|
||||
const result = await open(...args);
|
||||
return args[0].endsWith(".php") ? { ...result, language: "php", uiMode: "PHP" } : result;
|
||||
};
|
||||
backend.files.set(
|
||||
"/dev/acme-api/palette.php",
|
||||
"<?php\n/** Plain PHP documentation */\nclass Palette extends BasePalette {\n var $name = 'Palette';\n function example($value) {\n return $this->name == 10;\n }\n}\n",
|
||||
);
|
||||
await editorService().openFile("/dev/acme-api/palette.php");
|
||||
}, xml);
|
||||
const php = page.locator('.cm-editor[data-language="PHP"]');
|
||||
await expect(php).toBeVisible();
|
||||
await expect(php).toHaveCSS("background-color", "rgb(0, 0, 0)");
|
||||
const token = (text: string) =>
|
||||
php
|
||||
.locator(".cm-content span")
|
||||
.filter({ hasText: text })
|
||||
.filter({ hasNot: php.locator("span span") });
|
||||
await expect(token("$name").first()).toHaveCSS("color", "rgb(72, 216, 238)");
|
||||
await expect(token("'Palette'")).toHaveCSS("color", "rgb(255, 231, 146)");
|
||||
await expect(token("'Palette'")).toHaveCSS("font-weight", "400");
|
||||
await expect(token("class").first()).toHaveCSS("color", "rgb(249, 38, 114)");
|
||||
await expect(token("Plain PHP documentation")).toHaveCSS("font-style", "normal");
|
||||
await expect(token("BasePalette")).toHaveCSS("color", "rgb(174, 129, 255)");
|
||||
await expect(token(";").first()).toHaveCSS("color", "rgb(255, 255, 255)");
|
||||
await expect(token("example")).toHaveCSS("color", "rgb(141, 225, 45)");
|
||||
await expect(token("$name").first()).toHaveCSS("font-weight", "700");
|
||||
await page.evaluate(async () => {
|
||||
const backendPath = "/src/editor/core/backend.ts";
|
||||
const contributionsPath = "/src/workbench/contributions.ts";
|
||||
const { docBackend } = await import(backendPath);
|
||||
const { editorService } = await import(contributionsPath);
|
||||
docBackend().files.set("/dev/acme-api/Palette.java", 'class Palette { String name = "Palette"; }');
|
||||
await editorService().openFile("/dev/acme-api/Palette.java");
|
||||
});
|
||||
const java = page.locator('.cm-editor[data-language="Java"]');
|
||||
await expect(java).toBeVisible();
|
||||
await expect(java.locator(".cm-content span").filter({ hasText: '"Palette"' })).toHaveCSS("font-weight", "700");
|
||||
await page.getByRole("tab", { name: "palette.php", exact: true }).click();
|
||||
await expect(php).toBeVisible();
|
||||
await expect(token("'Palette'")).toHaveCSS("font-weight", "400");
|
||||
await php.screenshot({ path: info.outputPath("php-import.png"), animations: "disabled" });
|
||||
});
|
||||
@@ -1,120 +0,0 @@
|
||||
/** Linked Gradle builds use isolated native stubs; no real builds or files are touched. */
|
||||
import type { GradleModel, RunConfig } from "../src/platform/ipc";
|
||||
import { expect, ipcCalls, stubDesktop, test } from "./fixtures";
|
||||
|
||||
const build = (dir: string): GradleModel => ({
|
||||
dir,
|
||||
command: `/dev/acme-api/${dir}/gradlew`,
|
||||
env: { JAVA_HOME: `/${dir}/jdk` },
|
||||
javaVersion: 21,
|
||||
syncedAt: 1,
|
||||
stale: false,
|
||||
syncError: null,
|
||||
projects: [
|
||||
{ path: ":", name: dir, dir, buildFile: `${dir}/build.gradle`, tasks: [] },
|
||||
{
|
||||
path: ":app",
|
||||
name: "app",
|
||||
dir: `${dir}/app`,
|
||||
buildFile: `${dir}/app/build.gradle`,
|
||||
tasks: [{ name: "assemble", group: "build", description: "Assembles this project" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
test("links independent Gradle roots, runs identical task paths in their own builds and unlinks without deletion", async ({
|
||||
workbench: page,
|
||||
}, info) => {
|
||||
await stubDesktop(page);
|
||||
await page.evaluate(
|
||||
async ({ first, second }) => {
|
||||
let linked = [first];
|
||||
let runId = 0;
|
||||
const model = () => ({ maven: null, gradle: linked });
|
||||
const h = window.__ipc.handlers;
|
||||
h.build_model = model;
|
||||
h.build_sync = model;
|
||||
h.run_configs = () => [];
|
||||
h.run_apply_settings = (args) => args.configs;
|
||||
h.gradle_link_projects = () => {
|
||||
if (!linked.some((g) => g.dir === second.dir)) linked = [...linked, second];
|
||||
return model();
|
||||
};
|
||||
h.gradle_unlink_projects = (args) => {
|
||||
linked = linked.filter((g) => !(args.paths as string[]).includes(g.dir));
|
||||
return model();
|
||||
};
|
||||
h["plugin:dialog|open"] = () => ["/dev/acme-api/second/settings.gradle"];
|
||||
h.run_start = (args) => {
|
||||
const config = args.config as RunConfig;
|
||||
const id = ++runId;
|
||||
setTimeout(() => window.__ipc.emit({ type: "runExited", run: id, code: 0 }), 50);
|
||||
return { id, configId: config.id, name: config.name, pid: 4242 + id, running: true, pty: false };
|
||||
};
|
||||
const storeModule = "/src/features/build-tools/store.ts";
|
||||
const { loadModel } = await import(/* @vite-ignore */ storeModule);
|
||||
await loadModel();
|
||||
const contributionsModule = "/src/workbench/contributions.ts";
|
||||
const { toolWindowService } = await import(/* @vite-ignore */ contributionsModule);
|
||||
toolWindowService().show("gradle", true);
|
||||
},
|
||||
{ first: build("first"), second: build("second") },
|
||||
);
|
||||
|
||||
const tree = page.getByRole("tree", { name: "Gradle projects" });
|
||||
await expect(tree.getByRole("treeitem", { name: "first", exact: true })).toBeVisible();
|
||||
await expect(tree.getByRole("treeitem", { name: "second", exact: true })).toHaveCount(0);
|
||||
await page.screenshot({ path: info.outputPath("gradle-one-root.png"), animations: "disabled" });
|
||||
|
||||
await page.getByRole("button", { name: "Link Gradle Projects", exact: true }).click();
|
||||
await expect
|
||||
.poll(() => ipcCalls(page, "gradle_link_projects"))
|
||||
.toEqual([{ paths: ["/dev/acme-api/second/settings.gradle"] }]);
|
||||
await expect(tree.getByRole("treeitem", { name: "second", exact: true })).toBeVisible();
|
||||
// Reloading the model reads the persisted linkage again.
|
||||
await page.evaluate(async () => {
|
||||
const storeModule = "/src/features/build-tools/store.ts";
|
||||
const { loadModel } = await import(/* @vite-ignore */ storeModule);
|
||||
await loadModel();
|
||||
const actionModule = "/src/platform/actions.ts";
|
||||
const { executeAction } = await import(/* @vite-ignore */ actionModule);
|
||||
await executeAction("gradle.expandAll");
|
||||
await executeAction("gradle.toggleOffline");
|
||||
});
|
||||
const tasks = tree.getByRole("treeitem", { name: /^assemble\b/ });
|
||||
await expect(tasks).toHaveCount(2);
|
||||
await page.screenshot({ path: info.outputPath("gradle-two-roots.png"), animations: "disabled" });
|
||||
|
||||
await tasks.nth(0).dblclick();
|
||||
await expect.poll(async () => (await ipcCalls(page, "run_start")).length).toBe(1);
|
||||
await tasks.nth(1).dblclick();
|
||||
await expect.poll(async () => (await ipcCalls(page, "run_start")).length).toBe(2);
|
||||
const runs = (await ipcCalls(page, "run_start")).map((args) => args.config as RunConfig);
|
||||
expect(runs[0]!.id).not.toBe(runs[1]!.id);
|
||||
for (const [index, dir] of ["first", "second"].entries()) {
|
||||
expect(runs[index]).toMatchObject({
|
||||
command: `/dev/acme-api/${dir}/gradlew`,
|
||||
cwd: dir,
|
||||
args: [":app:assemble", "--offline"],
|
||||
env: { JAVA_HOME: `/${dir}/jdk` },
|
||||
});
|
||||
}
|
||||
|
||||
for (const dir of ["second", "first"]) {
|
||||
await tree.getByRole("treeitem", { name: dir, exact: true }).click({ button: "right" });
|
||||
await page.getByRole("menuitem", { name: "Unlink Gradle Project", exact: true }).click();
|
||||
const dialog = page.getByRole("dialog", { name: "Unlink Gradle project?" });
|
||||
await expect(dialog).toContainText("Files will remain");
|
||||
await dialog.getByRole("button", { name: "Unlink", exact: true }).click();
|
||||
await expect(tree.getByRole("treeitem", { name: dir, exact: true })).toHaveCount(0);
|
||||
}
|
||||
await expect
|
||||
.poll(() => ipcCalls(page, "gradle_unlink_projects"))
|
||||
.toEqual([{ paths: ["second"] }, { paths: ["first"] }]);
|
||||
await expect(page.getByText("No Gradle projects", { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Link Gradle Projects", exact: true })).toBeVisible();
|
||||
expect(
|
||||
await page.evaluate(() => window.__ipc.calls.filter((c) => /^fs_(?:delete|remove|trash)/.test(c.cmd))),
|
||||
).toEqual([]);
|
||||
await page.screenshot({ path: info.outputPath("gradle-unlinked.png"), animations: "disabled" });
|
||||
});
|
||||
@@ -1,28 +0,0 @@
|
||||
import { expect, openFromTree, test } from "./fixtures";
|
||||
|
||||
// Headless Chromium hides scrollbars by default, so nothing native sits at the editor's right
|
||||
// edge and the press reaches the overview lane. The tests in `overviewLane.spec.ts` keep a
|
||||
// scrollbar there instead.
|
||||
test("pressing the overview lane scrolls instead of starting a text selection", async ({ workbench: page }) => {
|
||||
const editor = await openFromTree(page, "build.gradle.kts");
|
||||
await editor.locator(".cm-content").click();
|
||||
await page.keyboard.press("Meta+a");
|
||||
await page.keyboard.insertText(Array.from({ length: 400 }, (_, i) => `val line${i} = ${i}`).join("\n"));
|
||||
await page.keyboard.press("Escape");
|
||||
const scroller = editor.locator(".cm-scroller");
|
||||
await scroller.evaluate((s) => (s.scrollTop = 0));
|
||||
const box = (await scroller.boundingBox())!;
|
||||
// The lane replaces the native scrollbar: a press on it must stay a scrollbar press.
|
||||
const x = box.x + box.width - 5;
|
||||
const y = box.y + box.height - 40;
|
||||
await page.mouse.move(x, y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(x, y - 120, { steps: 8 });
|
||||
await page.mouse.up();
|
||||
const empty = await editor.locator(".cm-content").evaluate((el) => {
|
||||
const view = (el as unknown as { cmTile: { view: { state: { selection: { main: { empty: boolean } } } } } }).cmTile
|
||||
.view;
|
||||
return view.state.selection.main.empty;
|
||||
});
|
||||
expect(empty).toBe(true);
|
||||
});
|
||||
+3
-4
@@ -20,10 +20,9 @@ The fixtures are standalone: `rust-app` declares its own `[workspace]` and
|
||||
|
||||
The language smoke test (`crates/ide-host/tests/language_smoke.rs`) runs completion,
|
||||
navigation, diagnostics, quick fixes, formatting and rename on these projects with real
|
||||
language servers, `smoke/run.test.ts` runs each project's run and test configurations
|
||||
(the intentionally failing test included, then Rerun Failed Tests), and `smoke/debug.test.ts`
|
||||
debugs its entry point from the "Set a breakpoint here" line: when you change a fixture, run
|
||||
them for that language.
|
||||
language servers, and `smoke/run.test.ts` runs each project's run and test configurations
|
||||
(the intentionally failing test included, then Rerun Failed Tests): when you change a
|
||||
fixture, run both for that language.
|
||||
|
||||
## Setup
|
||||
|
||||
|
||||
@@ -62,8 +62,6 @@
|
||||
"text/open_1mb": 2000,
|
||||
"text/apply_keystroke_1mb": 20,
|
||||
"text/apply_100_changes_1mb": 1000,
|
||||
"text/diff_two_edits_1mb": 10000,
|
||||
"text/diff_every_line_1mb": 100000,
|
||||
"text/offset_to_pos_round_trip": 5,
|
||||
"text/pos_to_byte": 5,
|
||||
"text/text_for_save_crlf_1mb": 1000,
|
||||
|
||||
+1
-4
@@ -71,8 +71,7 @@ target through rustup if needed), copies the component to the manifest's
|
||||
`wasm` path and runs `check`. `check` validates the package exactly as
|
||||
_Install Plugin from Disk…_ would — manifest, every contribution, the SVG
|
||||
sanitiser, the component — and that the component imports nothing but the
|
||||
plugin API and WASI and links against the IDE's: every function it imports
|
||||
exists with the same signature, and it exports the plugin world. It prints what the user will be asked to grant.
|
||||
plugin API and WASI. It prints what the user will be asked to grant.
|
||||
|
||||
`link` adds the folder to `links.json` in the IDE's plugins directory
|
||||
(`$OPENIDEA_PLUGINS_DIR`, default `<app-data>/ai.l0ng.openidea/plugins`). Linked
|
||||
@@ -943,8 +942,6 @@ only grows:
|
||||
its `openidea:plugin/*` imports and exports, `PluginEngine::check_component`).
|
||||
Installing and `openidea-plugin check` refuse another major or a newer
|
||||
minor with a message that says to update the IDE or rebuild with the SDK.
|
||||
They also link the component, so one that imports a function this host
|
||||
lacks under the same version (built from unreleased WIT) is refused too.
|
||||
5. **One version number**: the `wit/` package version, `ENGINE_VERSION` and
|
||||
`API_VERSION` agree, and the SDK's `wit/` copy is identical to `wit/`.
|
||||
6. **1.x calls are synchronous and pass documents whole.** Host calls block
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
"--syn-number": "#7fc8f8",
|
||||
"--syn-punct": "#a9b4c2",
|
||||
"--syn-variable": "#d9dfe8",
|
||||
"--syn-global": "#d9dfe8",
|
||||
"--syn-param": "#d9dfe8",
|
||||
"--syn-property": "#b9a3f0",
|
||||
"--syn-constant": "#e58fa3",
|
||||
|
||||
@@ -91,8 +91,6 @@ describe("IntelliJ theme import", () => {
|
||||
"Button": { "default": { "startBackground": "accent" } },
|
||||
"Popup.background": "#181f28",
|
||||
"Label.infoForeground": "#80888F80",
|
||||
"EditorTabs": { "underlineColor": "#405060", "underlinedBorderColor": "#bd93f9" },
|
||||
"CompletionPopup.matchForeground": "#ff79c6",
|
||||
"Tree": { "rowHeight": 24 }
|
||||
}
|
||||
}`;
|
||||
@@ -101,7 +99,6 @@ describe("IntelliJ theme import", () => {
|
||||
<option name="CARET_COLOR" value="ffcc00" />
|
||||
<option name="LINE_NUMBERS_COLOR" value="0" />
|
||||
<option name="UNKNOWN_KEY" value="123456" />
|
||||
<option name="GUTTER_BACKGROUND" value="313335" />
|
||||
</colors>
|
||||
<attributes>
|
||||
<option name="DEFAULT_KEYWORD">
|
||||
@@ -129,9 +126,6 @@ describe("IntelliJ theme import", () => {
|
||||
</value>
|
||||
</option>
|
||||
<option name="DEFAULT_CONSTANT" baseAttributes="DEFAULT_IDENTIFIER" />
|
||||
<option name="DEFAULT_INSTANCE_FIELD">
|
||||
<value />
|
||||
</option>
|
||||
</attributes>
|
||||
</scheme>`;
|
||||
|
||||
@@ -144,9 +138,6 @@ describe("IntelliJ theme import", () => {
|
||||
expect(t.tokens["--bg-selected"]).toBe("#203040");
|
||||
expect(t.tokens["--fg-muted"]).toBe("#80888f80");
|
||||
expect(t.tokens["--fg"]).toBe("#c0c8d0");
|
||||
// Islands borders the selected tab with underlinedBorderColor.
|
||||
expect(t.tokens["--border-tab-selected"]).toBe("#bd93f9");
|
||||
expect(t.tokens["--fg-completion-match"]).toBe("#ff79c6");
|
||||
expect(s).toBeNull();
|
||||
});
|
||||
|
||||
@@ -174,12 +165,9 @@ describe("IntelliJ theme import", () => {
|
||||
"--syn-comment-style": "italic",
|
||||
"--fg-editor": "#a9b7c6",
|
||||
"--bg-editor": "#2b2b2b",
|
||||
// GUTTER_BACKGROUND is the classic UI's: the gutter keeps the editor's background.
|
||||
"--bg-editor-gutter": "#2b2b2b",
|
||||
"--wave-error": "#ff0000",
|
||||
"--editor-error-decoration": "underline wavy #ff0000",
|
||||
// <value />: plain text, not the parent scheme's colour.
|
||||
"--syn-property": "#a9b7c6",
|
||||
});
|
||||
expect(s.tokens["--syn-constant"]).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -1,251 +0,0 @@
|
||||
/**
|
||||
* Debugging each language's `fixtures/` project the way the editor does: a breakpoint on the
|
||||
* entry point's "Set a breakpoint here" line, Debug from the file's context, then the paused
|
||||
* frame, its variables, evaluation, Step Over and Resume, with the debugger the IDE installs.
|
||||
*/
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { cpSync, mkdtempSync, readFileSync, realpathSync, rmSync } from "node:fs";
|
||||
import { get } from "node:http";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterAll, afterEach, beforeAll, expect, test } from "vitest";
|
||||
import { Channel } from "@tauri-apps/api/core";
|
||||
import { commands, type InstallEvent } from "@/bindings";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { call, nextRequestId, onIdeEvent } from "@/platform/ipc";
|
||||
import { useBuildModel } from "@/platform/buildModel";
|
||||
import { openProject, useProject } from "@/platform/project";
|
||||
import { activate as activateBuildTools } from "@/features/build-tools";
|
||||
import { useBuild } from "@/features/build-tools/store";
|
||||
import { activate as activateDebugger } from "@/features/debugger";
|
||||
import { addBreakpoint } from "@/features/debugger/breakpoints/store";
|
||||
import { activeSession, evaluate, useDebug } from "@/features/debugger/state";
|
||||
import { activate as activateRun } from "@/features/run";
|
||||
import { resetRuns, useRun } from "@/features/run/store";
|
||||
import { activateWorkbench } from "@/workbench/activate";
|
||||
import { useNotifications } from "@/workbench/notificationStore";
|
||||
import { connectDesktop } from "./desktop";
|
||||
import { repoRoot, Server } from "./server";
|
||||
|
||||
interface Case {
|
||||
fixture: string;
|
||||
/** Runs in the project copy first (installs its dependencies). */
|
||||
setup?: (root: string) => void;
|
||||
/** The IDE's tool that debugs it. */
|
||||
debugger: string;
|
||||
/** The entry point, debugged from its context. */
|
||||
file: string;
|
||||
/** A variable of the paused frame. */
|
||||
variable: string;
|
||||
/** An expression evaluated there (Evaluate Expression), and its value. */
|
||||
evaluate: [string, RegExp];
|
||||
/** A server: the breakpoint is in this request's handler, and the session is stopped at the end. */
|
||||
request?: string;
|
||||
}
|
||||
|
||||
const CASES: Record<string, Case> = {
|
||||
typescript: {
|
||||
fixture: "ts-app",
|
||||
setup: (root) => execFileSync("npm", ["install", "--no-audit", "--no-fund"], { cwd: root, stdio: "ignore" }),
|
||||
debugger: "js-debug",
|
||||
file: "src/index.ts",
|
||||
variable: "cart",
|
||||
evaluate: ["cart.items.length", /^2$/],
|
||||
},
|
||||
python: {
|
||||
fixture: "python-app",
|
||||
setup: (root) => execFileSync("python3", ["-m", "venv", ".venv"], { cwd: root, stdio: "ignore" }),
|
||||
debugger: "debugpy",
|
||||
file: "src/tasks/cli.py",
|
||||
variable: "tasks",
|
||||
evaluate: ["len(tasks.pending())", /^3$/],
|
||||
},
|
||||
rust: {
|
||||
fixture: "rust-app",
|
||||
debugger: "codelldb",
|
||||
file: "src/main.rs",
|
||||
variable: "inventory",
|
||||
evaluate: ["inventory", /\S/],
|
||||
},
|
||||
go: {
|
||||
fixture: "go-app",
|
||||
debugger: "delve",
|
||||
file: "cmd/server/main.go",
|
||||
variable: "r",
|
||||
evaluate: ["r.Method", /^"GET"$/],
|
||||
request: "http://127.0.0.1:8080/notes?q=openidea",
|
||||
},
|
||||
java: {
|
||||
fixture: "java-app",
|
||||
debugger: "java-debug",
|
||||
file: "src/main/java/com/example/orders/App.java",
|
||||
variable: "book",
|
||||
evaluate: ["book.size()", /^3$/],
|
||||
},
|
||||
kotlin: {
|
||||
fixture: "kotlin-gradle",
|
||||
debugger: "kotlin-debug-adapter",
|
||||
file: "src/main/kotlin/com/example/library/App.kt",
|
||||
variable: "library",
|
||||
evaluate: ["library", /Library/],
|
||||
},
|
||||
};
|
||||
|
||||
let deactivate: (() => void) | null = null;
|
||||
let server: Server | null = null;
|
||||
let disconnect: (() => void) | null = null;
|
||||
let base = "";
|
||||
|
||||
/** Backend events received, for failure messages. */
|
||||
const events: string[] = [];
|
||||
beforeAll(() => {
|
||||
onIdeEvent("*", (e) => events.push(e.type === "debug" ? `debug ${e.session} ${e.event.kind}` : e.type));
|
||||
const disposers = [activateWorkbench(), activateBuildTools(), activateRun(), activateDebugger()];
|
||||
deactivate = () => disposers.reverse().forEach((d) => d());
|
||||
});
|
||||
afterAll(() => deactivate?.());
|
||||
|
||||
afterEach(() => {
|
||||
resetRuns();
|
||||
disconnect?.();
|
||||
server?.close();
|
||||
if (base) rmSync(base, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function open(c: Case): Promise<string> {
|
||||
base = realpathSync(mkdtempSync(join(tmpdir(), "openidea-smoke-")));
|
||||
const root = join(base, c.fixture);
|
||||
cpSync(join(repoRoot, "fixtures", c.fixture), root, { recursive: true });
|
||||
c.setup?.(root);
|
||||
server = await Server.start(join(base, "home"));
|
||||
disconnect = connectDesktop(server);
|
||||
await openProject(root);
|
||||
const trusted = await commands.projectTrustSet(root, true, false);
|
||||
if (trusted.status !== "ok") throw new Error(trusted.error.message);
|
||||
useProject.setState({ project: { ...useProject.getState().project!, trusted: true } });
|
||||
// Run and Debug follow the build (a Gradle project's Application runs through Gradle).
|
||||
await until(
|
||||
"the build model",
|
||||
() => {
|
||||
const error = useBuild.getState().error;
|
||||
if (error) throw new Error(`the build model did not load: ${error}`);
|
||||
return useBuildModel.getState().root === root && useBuildModel.getState().model;
|
||||
},
|
||||
5 * 60_000,
|
||||
);
|
||||
return root;
|
||||
}
|
||||
|
||||
async function install(tool: string) {
|
||||
const tools = await call(commands.toolsList());
|
||||
if (tools.find((t) => t.name === tool)?.status === "installed") return;
|
||||
const channel = new Channel<InstallEvent>();
|
||||
const info = await call(commands.toolsInstall(tool, nextRequestId(), channel));
|
||||
expect(info.status, `${tool} installs`).toBe("installed");
|
||||
}
|
||||
|
||||
/** What the Debug and Run tool windows show, for failure messages. */
|
||||
function shown(): string {
|
||||
const { sessions, console } = useDebug.getState();
|
||||
const debug = Object.values(sessions).map(
|
||||
(s) =>
|
||||
`debug ${s.id} ${s.name} ${s.status} ${s.stopReason ?? ""}\n` +
|
||||
(console[s.id] ?? [])
|
||||
.slice(-40)
|
||||
.map((l) => ` ${l.text}`)
|
||||
.join(""),
|
||||
);
|
||||
const runs = useRun.getState().sessions.map((s) => `run ${s.id} ${s.config.name} ${s.status} ${s.exitCode ?? ""}`);
|
||||
const notes = useNotifications.getState().items.map((n) => `${n.level}: ${n.title} ${n.body ?? ""}`);
|
||||
return [...debug, ...runs, ...notes, `events: ${events.slice(-60).join(", ")}`, server?.log() ?? ""].join("\n");
|
||||
}
|
||||
|
||||
/** Waits for `value` to be truthy; fails with what the tool windows show. */
|
||||
async function until<T>(what: string, value: () => T | null | undefined | false, timeoutMs = 60_000): Promise<T> {
|
||||
const end = Date.now() + timeoutMs;
|
||||
for (;;) {
|
||||
const v = value();
|
||||
if (v) return v;
|
||||
if (Date.now() > end) throw new Error(`timed out waiting for ${what}\n${shown()}`);
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
}
|
||||
}
|
||||
|
||||
/** Requests `url` once the debugged server accepts connections (the response waits for Resume). */
|
||||
async function requestWhenListening(url: string) {
|
||||
for (let i = 0; i < 600; i++) {
|
||||
const connected = await new Promise<boolean>((resolve) => {
|
||||
const req = get(url, (res) => {
|
||||
res.resume();
|
||||
resolve(true);
|
||||
});
|
||||
req.on("socket", (socket) => socket.on("connect", () => resolve(true)));
|
||||
req.on("error", () => resolve(false));
|
||||
});
|
||||
if (connected) return;
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
}
|
||||
|
||||
const paused = () => {
|
||||
const s = activeSession();
|
||||
return s?.status === "paused" && s.frames.length > 0 ? s : null;
|
||||
};
|
||||
|
||||
/** Paused, failing as soon as every session has ended instead. */
|
||||
const pausedOrEnded = () => {
|
||||
const sessions = Object.values(useDebug.getState().sessions);
|
||||
if (sessions.length > 0 && sessions.every((s) => s.status === "terminated"))
|
||||
throw new Error(`the debugger ended before the breakpoint\n${shown()}`);
|
||||
return paused();
|
||||
};
|
||||
|
||||
async function smoke(language: string) {
|
||||
const c = CASES[language]!;
|
||||
const root = await open(c);
|
||||
await install(c.debugger);
|
||||
|
||||
const path = join(root, c.file);
|
||||
const content = readFileSync(path, "utf8");
|
||||
const marker = content.split("\n").findIndex((l) => l.includes("Set a breakpoint here"));
|
||||
const line = marker + 1;
|
||||
addBreakpoint(path, line);
|
||||
|
||||
await executeAction("run.debugContext", { path, content });
|
||||
if (c.request) void requestWhenListening(c.request);
|
||||
const s = await until("the breakpoint", pausedOrEnded, 5 * 60_000);
|
||||
expect(s.stopReason, `${s.stopDescription}\n${shown()}`).toBe("breakpoint");
|
||||
const top = s.frames[0]!;
|
||||
expect(top.path).toBe(path);
|
||||
expect(top.line).toBe(line);
|
||||
|
||||
const scopes = await call(commands.debugScopes(s.id, top.id));
|
||||
const locals = await call(commands.debugVariables(s.id, scopes[0]!.variablesReference));
|
||||
expect(locals.map((v) => v.name)).toContain(c.variable);
|
||||
|
||||
const value = await evaluate(c.evaluate[0], "repl", s);
|
||||
expect(value.value, c.evaluate[0]).toMatch(c.evaluate[1]);
|
||||
|
||||
// Python 3.12 reports a conditional expression's line twice: Step Over may stop on it
|
||||
// (and its breakpoint) again.
|
||||
for (let step = 0; step < 3 && paused()!.frames[0]!.line === line; step++) {
|
||||
const stops = useDebug.getState().stopSeq;
|
||||
await executeAction("debug.stepOver");
|
||||
await until("Step Over", () => useDebug.getState().stopSeq > stops && paused());
|
||||
}
|
||||
const after = paused()!;
|
||||
expect(after.stopReason, shown()).toBe("step");
|
||||
expect(after.frames[0]!.line, shown()).toBeGreaterThan(line);
|
||||
|
||||
await executeAction("debug.resume");
|
||||
if (c.request) {
|
||||
await until("the request to be served", () => useDebug.getState().sessions[s.id]?.status === "running");
|
||||
await executeAction("debug.stop");
|
||||
}
|
||||
// Every session ends (a JVM that listens again as its debugger detaches may be attached once more).
|
||||
await until("the end", () => Object.values(useDebug.getState().sessions).every((d) => d.status === "terminated"));
|
||||
}
|
||||
|
||||
for (const language of Object.keys(CASES)) {
|
||||
test(language, () => smoke(language));
|
||||
}
|
||||
+7
-11
@@ -3,25 +3,23 @@
|
||||
* (callbacks, `Channel`s, the event plugin) in-process, and every app command sent to an
|
||||
* `openidea-server` through the route remote development uses (`src/platform/tauriCore.ts`).
|
||||
*/
|
||||
import { homedir, tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { setInvokeRoute } from "@/platform/tauriCore";
|
||||
import { encodeArgs } from "@/platform/remote";
|
||||
import { Server } from "./server";
|
||||
|
||||
type Callback = (message: unknown) => void;
|
||||
|
||||
// One runtime per page, there from the start as in the app: the frontend registers its event
|
||||
// listener once (`src/platform/ipc.ts`), possibly before a server is connected, and keeps it
|
||||
// across the servers connected one after another.
|
||||
// One runtime per page, as in the app: the frontend registers its event listeners once
|
||||
// (`src/platform/ipc.ts`), so they outlive the servers connected one after another.
|
||||
const callbacks = new Map<number, Callback>();
|
||||
const listeners: { event: string; handler: number }[] = [];
|
||||
const channels = new Map<number, { onmessage: (data: unknown) => void }>();
|
||||
let nextCallback = 1;
|
||||
/** `BaseDirectory` values (`@tauri-apps/api/path`) the frontend resolves. */
|
||||
const DIRECTORIES: Record<number, string> = { 12: tmpdir(), 21: homedir() };
|
||||
let installed = false;
|
||||
|
||||
{
|
||||
function install() {
|
||||
if (installed) return;
|
||||
installed = true;
|
||||
const internals = {
|
||||
metadata: { currentWindow: { label: "main" }, currentWebview: { label: "main", windowLabel: "main" } },
|
||||
transformCallback(cb: Callback) {
|
||||
@@ -40,9 +38,6 @@ const DIRECTORIES: Record<number, string> = { 12: tmpdir(), 21: homedir() };
|
||||
return listeners.length;
|
||||
}
|
||||
if (cmd === "plugin:window|get_all_windows") return [];
|
||||
// This machine's directories (`@tauri-apps/api/path`), as on the desktop.
|
||||
if (cmd === "plugin:path|resolve_directory") return DIRECTORIES[args.directory as number] ?? null;
|
||||
if (cmd === "plugin:path|join") return join(...(args.paths as string[]));
|
||||
if (cmd.startsWith("plugin:")) return null;
|
||||
throw { kind: "unavailable", message: `${cmd} has no route`, detail: null };
|
||||
},
|
||||
@@ -55,6 +50,7 @@ const DIRECTORIES: Record<number, string> = { 12: tmpdir(), 21: homedir() };
|
||||
|
||||
/** Connects this page's frontend to `server`; returns a function that disconnects it. */
|
||||
export function connectDesktop(server: Server): () => void {
|
||||
install();
|
||||
channels.clear();
|
||||
server.onChannel = (id, data) => channels.get(id)?.onmessage(data);
|
||||
server.onEvent = (target, event) => {
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* JSON-RPC with `Content-Length` framing over the server's stdin / stdout.
|
||||
*/
|
||||
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
|
||||
import { appendFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
const PROTOCOL_VERSION = 2;
|
||||
@@ -36,8 +35,6 @@ export class Server {
|
||||
private constructor(private readonly child: ChildProcessWithoutNullStreams) {
|
||||
child.stdout.on("data", (chunk: Buffer) => this.receive(chunk));
|
||||
child.stderr.on("data", (chunk: Buffer) => {
|
||||
// The whole log, when asked for (OPENIDEA_SMOKE_LOG=<file>; OPENIDEA_LOG sets its level).
|
||||
if (process.env.OPENIDEA_SMOKE_LOG) appendFileSync(process.env.OPENIDEA_SMOKE_LOG, chunk);
|
||||
this.stderr.push(chunk.toString());
|
||||
if (this.stderr.length > 200) this.stderr.shift();
|
||||
});
|
||||
|
||||
@@ -16,7 +16,7 @@ dunce.workspace = true
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-dialog = { version = "2", default-features = false, features = ["xdg-portal"] }
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-mcp-bridge = "0.13"
|
||||
tauri-plugin-window-state = "2"
|
||||
tauri-plugin-single-instance = "2"
|
||||
@@ -78,7 +78,7 @@ block2 = "0.6"
|
||||
# Plugin secrets in the login keychain.
|
||||
security-framework = "3.7"
|
||||
objc2 = "0.6"
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSButton", "NSEvent", "NSResponder", "NSScreen", "NSView", "NSWindow"] }
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSButton", "NSResponder", "NSScreen", "NSView", "NSWindow"] }
|
||||
objc2-core-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "CFRunLoop"] }
|
||||
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSNotification", "NSOperation", "NSString"] }
|
||||
# Used only by the debug font-restriction override; Cargo dependency cfg cannot test debug_assertions.
|
||||
|
||||
@@ -2,8 +2,8 @@ use crate::state::AppState;
|
||||
use ide_core::settings::Settings;
|
||||
use ide_core::{EventSink, IdeError, IdeResult, RequestId, TaskId};
|
||||
use ide_language::LanguageInfo;
|
||||
use ide_workspace::RecentProject;
|
||||
use ide_workspace::settings::SettingsScope;
|
||||
use ide_workspace::{RecentProject, RecentProjectGroup};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::path::PathBuf;
|
||||
@@ -49,24 +49,6 @@ pub fn remove_recent_project(state: State<'_, AppState>, path: PathBuf) -> IdeRe
|
||||
state.recent.remove(&path)
|
||||
}
|
||||
|
||||
/// The welcome screen's project groups.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn recent_project_groups(state: State<'_, AppState>) -> Vec<RecentProjectGroup> {
|
||||
state.recent.groups()
|
||||
}
|
||||
|
||||
/// Replaces the project groups; returns them as saved (names trimmed and unique, each
|
||||
/// project in one group, only recent projects).
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn set_recent_project_groups(
|
||||
state: State<'_, AppState>,
|
||||
groups: Vec<RecentProjectGroup>,
|
||||
) -> IdeResult<Vec<RecentProjectGroup>> {
|
||||
state.recent.set_groups(groups)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn open_project(window: Window, path: PathBuf) -> IdeResult<ProjectInfo> {
|
||||
|
||||
@@ -17,32 +17,11 @@ pub async fn build_model(window: Window, state: State<'_, AppState>) -> IdeResul
|
||||
ide_host::ops::build::build_model(&state.host, window.label()).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn gradle_link_projects(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
paths: Vec<String>,
|
||||
) -> IdeResult<BuildModel> {
|
||||
ide_host::ops::build::gradle_link_projects(&state.host, window.label(), paths).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn gradle_unlink_projects(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
paths: Vec<String>,
|
||||
) -> IdeResult<BuildModel> {
|
||||
ide_host::ops::build::gradle_unlink_projects(&state.host, window.label(), paths).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`.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn build_sync(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
@@ -50,7 +29,6 @@ pub async fn build_sync(
|
||||
request: RequestId,
|
||||
offline: bool,
|
||||
profiles: Vec<String>,
|
||||
build_dir: Option<String>,
|
||||
on_progress: Channel<SyncProgress>,
|
||||
) -> IdeResult<BuildModel> {
|
||||
ide_host::ops::build::build_sync(
|
||||
@@ -60,7 +38,6 @@ pub async fn build_sync(
|
||||
request,
|
||||
offline,
|
||||
profiles,
|
||||
build_dir,
|
||||
crate::commands::sender(on_progress),
|
||||
)
|
||||
.await
|
||||
@@ -76,18 +53,9 @@ pub async fn gradle_dependencies(
|
||||
configuration: String,
|
||||
offline: bool,
|
||||
request: RequestId,
|
||||
build_dir: Option<String>,
|
||||
) -> IdeResult<Vec<ide_build::GradleDependency>> {
|
||||
ide_host::ops::build::gradle_dependencies(
|
||||
&state.host,
|
||||
window.label(),
|
||||
gradle_path,
|
||||
configuration,
|
||||
offline,
|
||||
request,
|
||||
build_dir,
|
||||
)
|
||||
.await
|
||||
ide_host::ops::build::gradle_dependencies(&state.host, window.label(), gradle_path, configuration, offline, request)
|
||||
.await
|
||||
}
|
||||
|
||||
/// The `.toml` of a version catalog the build imports as `group:artifact:version`, once a
|
||||
|
||||
@@ -250,10 +250,9 @@ pub async fn db_execute(
|
||||
request: RequestId,
|
||||
id: String,
|
||||
statements: Vec<SqlStatement>,
|
||||
skip: u32,
|
||||
max_rows: u32,
|
||||
) -> IdeResult<Vec<StatementOutcome>> {
|
||||
ide_host::ops::db::execute(&state.host, window.label(), request, &id, statements, skip, max_rows).await
|
||||
ide_host::ops::db::execute(&state.host, window.label(), request, &id, statements, max_rows).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
@@ -45,7 +45,6 @@ pub mod tools;
|
||||
pub mod vcs;
|
||||
pub mod web;
|
||||
pub mod window;
|
||||
pub mod window_drag;
|
||||
pub mod window_group;
|
||||
pub mod workspace;
|
||||
|
||||
@@ -65,8 +64,6 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
app::app_info,
|
||||
app::recent_projects,
|
||||
app::remove_recent_project,
|
||||
app::recent_project_groups,
|
||||
app::set_recent_project_groups,
|
||||
app::open_project,
|
||||
app::project_trust_status,
|
||||
app::project_trust_list,
|
||||
@@ -356,8 +353,6 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
run::run_tomcat_update,
|
||||
run::run_test_reports,
|
||||
build::build_model,
|
||||
build::gradle_link_projects,
|
||||
build::gradle_unlink_projects,
|
||||
build::build_sync,
|
||||
build::gradle_dependencies,
|
||||
build::gradle_published_catalog,
|
||||
@@ -409,7 +404,6 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
window::window_cycle,
|
||||
window::window_toggle_fullscreen,
|
||||
window::window_set_titlebar_height,
|
||||
window_drag::window_start_drag,
|
||||
window::window_take_launch,
|
||||
window::window_close_context,
|
||||
window::window_open_projects,
|
||||
|
||||
@@ -473,7 +473,7 @@ pub async fn plugin_web_read_file(path: PathBuf) -> IdeResult<String> {
|
||||
Ok(base64::engine::general_purpose::STANDARD.encode(bytes))
|
||||
}
|
||||
|
||||
/// The marketplace's plugins: read again when `refresh` or when none was read yet, else the last index read.
|
||||
/// The marketplace's plugins: read again when `refresh`, else the last index read.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn plugin_market(
|
||||
@@ -481,7 +481,7 @@ pub async fn plugin_market(
|
||||
refresh: bool,
|
||||
) -> IdeResult<Vec<ide_host::plugins::MarketPlugin>> {
|
||||
let host = state.plugins.clone();
|
||||
blocking(move || host.market_listing(refresh)).await
|
||||
blocking(move || if refresh { host.refresh_market() } else { Ok(host.market_plugins()) }).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
@@ -221,7 +221,7 @@ pub async fn vcs_add_to_ignore(
|
||||
state: State<'_, AppState>,
|
||||
paths: Vec<PathBuf>,
|
||||
destination: IgnoreDestination,
|
||||
) -> IdeResult<Vec<ide_core::IpcPath>> {
|
||||
) -> IdeResult<Vec<String>> {
|
||||
ide_host::ops::vcs::vcs_add_to_ignore(&state.host, window.label(), paths, destination).await
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
//! Native window drag started from the web title bar.
|
||||
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use tauri::WebviewWindow;
|
||||
|
||||
/// Starts dragging `window` while the primary mouse button is still down; returns whether a drag began.
|
||||
///
|
||||
/// macOS WebKit can deliver a tap's `mousedown` after the button is already up. AppKit would then run
|
||||
/// its drag loop with no release left to end it, and the window follows the pointer until the next click.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn window_start_drag(window: WebviewWindow) -> IdeResult<bool> {
|
||||
#[cfg(target_os = "macos")]
|
||||
if objc2_app_kit::NSEvent::pressedMouseButtons() & 1 == 0 {
|
||||
return Ok(false);
|
||||
}
|
||||
window.start_dragging().map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
Ok(true)
|
||||
}
|
||||
+2
-11
@@ -28,7 +28,7 @@ mod windows;
|
||||
|
||||
use parking_lot::Mutex;
|
||||
use state::AppState;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tauri::{Emitter, Manager};
|
||||
use windows::WindowsState;
|
||||
@@ -40,21 +40,12 @@ pub fn specta_builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
}
|
||||
|
||||
pub fn export_bindings(builder: &tauri_specta::Builder<tauri::Wry>) {
|
||||
let target = Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../src/bindings.ts"));
|
||||
// Rewritten only when it changes: every debug launch exports, also a second instance that just
|
||||
// hands its path over, and a rewrite makes Vite reload the running app's windows.
|
||||
let fresh = std::env::temp_dir().join(format!("openidea-bindings-{}.ts", std::process::id()));
|
||||
builder
|
||||
.export(
|
||||
specta_typescript::Typescript::default().header("// @ts-nocheck\n/* eslint-disable */\n// Generated by tauri-specta. Do not edit; run `pnpm gen:bindings`."),
|
||||
&fresh,
|
||||
concat!(env!("CARGO_MANIFEST_DIR"), "/../src/bindings.ts"),
|
||||
)
|
||||
.expect("failed to export TypeScript bindings");
|
||||
let text = std::fs::read(&fresh).expect("exported TypeScript bindings");
|
||||
let _ = std::fs::remove_file(&fresh);
|
||||
if std::fs::read(target).ok().as_deref() != Some(text.as_slice()) {
|
||||
std::fs::write(target, text).expect("failed to write TypeScript bindings");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
|
||||
@@ -46,9 +46,7 @@ pub fn new_project_tab_target(app: &AppHandle, launch: &Launch) -> IdeResult<Opt
|
||||
let host = app.state::<crate::state::AppState>();
|
||||
host.project(w.label()).is_ok_and(|p| !p.light) || host.remote_dev.get(w.label()).is_some()
|
||||
});
|
||||
// A project-tab group is fullscreen through simple fullscreen, which the native flag misses.
|
||||
let fullscreen =
|
||||
active.as_ref().is_some_and(|w| w.is_fullscreen().unwrap_or(false) || is_simple_fullscreen(w.label()));
|
||||
let fullscreen = active.as_ref().is_some_and(|w| w.is_fullscreen().unwrap_or(false));
|
||||
let light_edit = launch.project.is_none() && launch.remote.is_none();
|
||||
Ok(should_group_new_project(tabbing_preference(app)?, fullscreen, active.is_some(), light_edit)
|
||||
.then_some(active)
|
||||
|
||||
@@ -154,11 +154,9 @@ impl AppState {
|
||||
self.host.open_light(window, file)
|
||||
}
|
||||
|
||||
/// Called on the event-loop thread (window destruction, the `close_project`
|
||||
/// command), so it must not wait for the project's workers to shut down.
|
||||
pub fn close_project(&self, window: &str) {
|
||||
self.remote_dev.disconnect(window);
|
||||
self.host.close_project_detached(window);
|
||||
self.host.close_project(window);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+7
-42
@@ -10,13 +10,6 @@ export const commands = {
|
||||
appInfo: () => __TAURI_INVOKE<AppInfo>("app_info"),
|
||||
recentProjects: () => __TAURI_INVOKE<RecentProject[]>("recent_projects"),
|
||||
removeRecentProject: (path: string) => typedError<null, IdeError>(__TAURI_INVOKE("remove_recent_project", { path })),
|
||||
/** The welcome screen's project groups. */
|
||||
recentProjectGroups: () => __TAURI_INVOKE<RecentProjectGroup[]>("recent_project_groups"),
|
||||
/**
|
||||
* Replaces the project groups; returns them as saved (names trimmed and unique, each
|
||||
* project in one group, only recent projects).
|
||||
*/
|
||||
setRecentProjectGroups: (groups: RecentProjectGroup[]) => typedError<RecentProjectGroup[], IdeError>(__TAURI_INVOKE("set_recent_project_groups", { groups })),
|
||||
openProject: (path: string) => typedError<ProjectInfo_Serialize, IdeError>(__TAURI_INVOKE("open_project", { path })),
|
||||
projectTrustStatus: (path: string) => typedError<TrustStatus, IdeError>(__TAURI_INVOKE("project_trust_status", { path })),
|
||||
projectTrustList: () => typedError<TrustDecisions, IdeError>(__TAURI_INVOKE("project_trust_list")),
|
||||
@@ -498,7 +491,7 @@ export const commands = {
|
||||
vcsBranches: (repo: string | null) => typedError<Branch[], IdeError>(__TAURI_INVOKE("vcs_branches", { repo })),
|
||||
vcsStashes: (repo: string | null) => typedError<Stash[], IdeError>(__TAURI_INVOKE("vcs_stashes", { repo })),
|
||||
vcsStage: (paths: string[]) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_stage", { paths })),
|
||||
vcsAddToIgnore: (paths: string[], destination: IgnoreDestination) => typedError<IpcPath[], IdeError>(__TAURI_INVOKE("vcs_add_to_ignore", { paths, destination })),
|
||||
vcsAddToIgnore: (paths: string[], destination: IgnoreDestination) => typedError<string[], IdeError>(__TAURI_INVOKE("vcs_add_to_ignore", { paths, destination })),
|
||||
vcsUnstage: (paths: string[]) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_unstage", { paths })),
|
||||
vcsStagePatch: (patch: string) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_stage_patch", { patch })),
|
||||
vcsDiscard: (paths: string[]) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_discard", { paths })),
|
||||
@@ -671,16 +664,14 @@ export const commands = {
|
||||
runTestReports: (module: string | null, sinceMs: number | null) => typedError<TestCaseReport[], IdeError>(__TAURI_INVOKE("run_test_reports", { module, sinceMs })),
|
||||
/** Maven and Gradle projects of the window's project, from the build files plus the last sync. */
|
||||
buildModel: () => typedError<BuildModel, IdeError>(__TAURI_INVOKE("build_model")),
|
||||
gradleLinkProjects: (paths: string[]) => typedError<BuildModel, IdeError>(__TAURI_INVOKE("gradle_link_projects", { paths })),
|
||||
gradleUnlinkProjects: (paths: string[]) => typedError<BuildModel, IdeError>(__TAURI_INVOKE("gradle_unlink_projects", { paths })),
|
||||
/**
|
||||
* 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`.
|
||||
*/
|
||||
buildSync: (tool: BuildTool, request: RequestId, offline: boolean, profiles: string[], buildDir: string | null, onProgress: Channel<SyncProgress>) => typedError<BuildModel, IdeError>(__TAURI_INVOKE("build_sync", { tool, request, offline, profiles, buildDir, onProgress })),
|
||||
buildSync: (tool: BuildTool, request: RequestId, offline: boolean, profiles: string[], onProgress: Channel<SyncProgress>) => typedError<BuildModel, IdeError>(__TAURI_INVOKE("build_sync", { tool, request, offline, profiles, onProgress })),
|
||||
/** The resolved dependency tree of a Gradle project for one configuration (Dependency Analyzer). */
|
||||
gradleDependencies: (gradlePath: string, configuration: string, offline: boolean, request: RequestId, buildDir: string | null) => typedError<GradleDependency[], IdeError>(__TAURI_INVOKE("gradle_dependencies", { gradlePath, configuration, offline, request, buildDir })),
|
||||
gradleDependencies: (gradlePath: string, configuration: string, offline: boolean, request: RequestId) => typedError<GradleDependency[], IdeError>(__TAURI_INVOKE("gradle_dependencies", { gradlePath, configuration, offline, request })),
|
||||
/**
|
||||
* The `.toml` of a version catalog the build imports as `group:artifact:version`, once a
|
||||
* build has downloaded it.
|
||||
@@ -820,13 +811,6 @@ export const commands = {
|
||||
windowToggleFullscreen: () => typedError<boolean, IdeError>(__TAURI_INVOKE("window_toggle_fullscreen")),
|
||||
/** Height of the web title bar in native logical pixels (CSS height × IDE zoom). */
|
||||
windowSetTitlebarHeight: (height: number | null) => typedError<null, IdeError>(__TAURI_INVOKE("window_set_titlebar_height", { height })),
|
||||
/**
|
||||
* Starts dragging `window` while the primary mouse button is still down; returns whether a drag began.
|
||||
*
|
||||
* macOS WebKit can deliver a tap's `mousedown` after the button is already up. AppKit would then run
|
||||
* its drag loop with no release left to end it, and the window follows the pointer until the next click.
|
||||
*/
|
||||
windowStartDrag: () => typedError<boolean, IdeError>(__TAURI_INVOKE("window_start_drag")),
|
||||
/** What this window was opened for (taken once). */
|
||||
windowTakeLaunch: () => __TAURI_INVOKE<{
|
||||
/** Project folder to open. */
|
||||
@@ -1015,7 +999,7 @@ export const commands = {
|
||||
pluginWebReadFile: (path: string) => typedError<string, IdeError>(__TAURI_INVOKE("plugin_web_read_file", { path })),
|
||||
/** Gutter markers of plugin `line-markers` providers for an open document. */
|
||||
pluginLineMarkers: (doc: DocId) => typedError<LineMarkerInfo[], IdeError>(__TAURI_INVOKE("plugin_line_markers", { doc })),
|
||||
/** The marketplace's plugins: read again when `refresh` or when none was read yet, else the last index read. */
|
||||
/** The marketplace's plugins: read again when `refresh`, else the last index read. */
|
||||
pluginMarket: (refresh: boolean) => typedError<MarketPlugin[], IdeError>(__TAURI_INVOKE("plugin_market", { refresh })),
|
||||
pluginMarketUrl: () => __TAURI_INVOKE<string | null>("plugin_market_url"),
|
||||
/** Sets the marketplace index URL (an `https://…/index.json` or a local path; `None` for the default). */
|
||||
@@ -1240,7 +1224,7 @@ export const commands = {
|
||||
dbSetManualCommit: (id: string, manual: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("db_set_manual_commit", { id, manual })),
|
||||
dbFinishTransaction: (id: string, commit: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("db_finish_transaction", { id, commit })),
|
||||
dbStatements: (id: string, text: string, from: number, to: number) => typedError<SqlStatement[], IdeError>(__TAURI_INVOKE("db_statements", { id, text, from, to })),
|
||||
dbExecute: (request: RequestId, id: string, statements: SqlStatement[], skip: number, maxRows: number) => typedError<DbStatementOutcome[], IdeError>(__TAURI_INVOKE("db_execute", { request, id, statements, skip, maxRows })),
|
||||
dbExecute: (request: RequestId, id: string, statements: SqlStatement[], maxRows: number) => typedError<DbStatementOutcome[], IdeError>(__TAURI_INVOKE("db_execute", { request, id, statements, maxRows })),
|
||||
dbTablePage: (request: RequestId, id: string, schema: string, table: string, filter: string | null, sort: DbSortKey[], offset: number, limit: number) => typedError<DbTablePage, IdeError>(__TAURI_INVOKE("db_table_page", { request, id, schema, table, filter, sort, offset, limit })),
|
||||
dbApplyEdits: (id: string, schema: string, table: string, edits: DbRowEdit[]) => typedError<number | null, IdeError>(__TAURI_INVOKE("db_apply_edits", { id, schema, table, edits })),
|
||||
dbConsolePath: (id: string) => typedError<IpcPath, IdeError>(__TAURI_INVOKE("db_console_path", { id })),
|
||||
@@ -1867,7 +1851,7 @@ export type BuildMessage = {
|
||||
/** The build systems of a project; `None` when the project does not use one. */
|
||||
export type BuildModel = {
|
||||
maven: MavenModel | null,
|
||||
gradle?: GradleModel[],
|
||||
gradle: GradleModel | null,
|
||||
};
|
||||
|
||||
export type BuildResult = {
|
||||
@@ -2496,11 +2480,6 @@ export type DataSource = {
|
||||
options?: DbOptions,
|
||||
introspection?: DbIntrospection,
|
||||
properties?: DbProperty[],
|
||||
/**
|
||||
* Stored app-wide and listed in every project. Implied by the file it is read from,
|
||||
* so not written to it.
|
||||
*/
|
||||
global?: boolean,
|
||||
};
|
||||
|
||||
/** System Settings › Date Formats (Git log, Local History, blame). */
|
||||
@@ -3933,8 +3912,6 @@ export type GradleRunUsing =
|
||||
|
||||
/** Build Tools › Gradle. */
|
||||
export type GradleSettings = {
|
||||
/** Linked build directories; absent preserves automatic discovery, empty unlinks all builds. */
|
||||
linkedProjects?: string[] | null,
|
||||
/** Empty = Project JDK; otherwise a JDK home. */
|
||||
jvm?: string,
|
||||
distribution?: GradleDistribution,
|
||||
@@ -5138,8 +5115,6 @@ export type MavenProject = {
|
||||
export type MavenSettings = {
|
||||
/** Project-relative root POMs removed from the Maven project model. */
|
||||
unlinkedProjects?: string[],
|
||||
/** Project-relative POMs outside the root reactor added as independent top-level projects. */
|
||||
linkedProjects?: string[],
|
||||
/** Maven home (or its `mvn` executable); empty = the project's `mvnw`, else `mvn` on `PATH`. */
|
||||
home?: string,
|
||||
/** User `settings.xml` (`-s`); empty = `~/.m2/settings.xml`. */
|
||||
@@ -5973,14 +5948,6 @@ export type RecentProject = {
|
||||
branch: string | null,
|
||||
};
|
||||
|
||||
/** A named group of recent projects on the welcome screen (New Project Group…, Move to Group). */
|
||||
export type RecentProjectGroup = {
|
||||
name: string,
|
||||
/** Its projects, in the order they were added. */
|
||||
paths: IpcPath[],
|
||||
expanded?: boolean,
|
||||
};
|
||||
|
||||
export type RefactoringProblem = {
|
||||
severity: ProblemSeverity,
|
||||
message: string,
|
||||
@@ -6031,8 +5998,6 @@ export type RegistryAgent = {
|
||||
installed: string | null,
|
||||
/** Whether [`Self::version`] is newer than the installed one. */
|
||||
update: boolean,
|
||||
/** An install of it is running (it goes on after the Settings page that started it closes). */
|
||||
installing: boolean,
|
||||
};
|
||||
|
||||
export type RegistryDistribution =
|
||||
@@ -6216,7 +6181,7 @@ export type Risk =
|
||||
export type RootKind = "source" | "test" | "resources" | "testResources" | "excluded";
|
||||
|
||||
export type RouteSource = {
|
||||
path: IpcPath,
|
||||
path: string,
|
||||
text: string,
|
||||
};
|
||||
|
||||
|
||||
@@ -9,19 +9,17 @@ import { tags as t, type Tag } from "@lezer/highlight";
|
||||
function token(
|
||||
tag: Tag | Tag[],
|
||||
name: string,
|
||||
defaults: { italic?: string; bold?: string; effect?: string; color?: string } = {},
|
||||
defaults: { italic?: string; bold?: string; effect?: string } = {},
|
||||
): TagStyle {
|
||||
const variable = (suffix: string, fallback: string) =>
|
||||
`var(--syn-${name}${suffix}, ${defaults.color ? `var(--syn-${defaults.color}${suffix}, ${fallback})` : fallback})`;
|
||||
return {
|
||||
tag,
|
||||
color: defaults.color ? variable("", "inherit") : `var(--syn-${name})`,
|
||||
fontStyle: variable("-italic", defaults.italic ?? "inherit"),
|
||||
fontWeight: variable("-bold", defaults.bold ?? "inherit"),
|
||||
backgroundColor: variable("-bg", "transparent"),
|
||||
textDecoration: variable("-effect", defaults.effect ?? "none"),
|
||||
color: `var(--syn-${name})`,
|
||||
fontStyle: `var(--syn-${name}-italic, ${defaults.italic ?? "inherit"})`,
|
||||
fontWeight: `var(--syn-${name}-bold, ${defaults.bold ?? "inherit"})`,
|
||||
backgroundColor: `var(--syn-${name}-bg, transparent)`,
|
||||
textDecoration: `var(--syn-${name}-effect, ${defaults.effect ?? "none"})`,
|
||||
// WebKit takes a decoration with a style or colour only through the prefixed property.
|
||||
WebkitTextDecoration: variable("-effect", defaults.effect ?? "none"),
|
||||
WebkitTextDecoration: `var(--syn-${name}-effect, ${defaults.effect ?? "none"})`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -73,12 +71,10 @@ export const openideaHighlightStyle = HighlightStyle.define([
|
||||
token(t.special(t.brace), "decorator"),
|
||||
token([t.tagName, t.angleBracket], "tag"),
|
||||
token([t.punctuation, t.bracket, t.separator, t.operator, t.derefOperator], "punct"),
|
||||
token(t.bracket, "bracket", { color: "punct" }),
|
||||
token(t.separator, "separator", { color: "punct" }),
|
||||
token(t.invalid, "invalid"),
|
||||
// Markup (Markdown, docs)
|
||||
token(t.heading, "markup-heading", { bold: "var(--fw-code-bold, 700)" }),
|
||||
token(t.processingInstruction, "processing-instruction", { color: "keyword" }),
|
||||
token(t.processingInstruction, "keyword"),
|
||||
{ tag: t.emphasis, fontStyle: "italic" },
|
||||
{ tag: t.strong, fontWeight: "var(--fw-code-bold, 700)" },
|
||||
{ tag: t.strikethrough, textDecoration: "line-through" },
|
||||
|
||||
@@ -65,8 +65,8 @@ class OverviewLane {
|
||||
/** The last pointer position over the editor, to re-check the thumb when it scrolls. */
|
||||
private pointer: { x: number; y: number } | null = null;
|
||||
/**
|
||||
* The scrollbar thumb, drawn and dragged by the lane: the native scrollbar is hidden, so a
|
||||
* press on this strip stays here instead of reaching the text layer as a text drag.
|
||||
* The scrollbar thumb drawn over the marks: the native one sits under the lane, so a mark
|
||||
* the height of the lane (a new file) would hide it (#1014).
|
||||
*/
|
||||
private thumb: HTMLElement;
|
||||
|
||||
@@ -77,7 +77,6 @@ class OverviewLane {
|
||||
this.thumb = document.createElement("div");
|
||||
this.thumb.className = "cm-openidea-overviewThumb";
|
||||
this.dom.addEventListener("mousedown", this.onMouseDown);
|
||||
this.thumb.addEventListener("mousedown", this.onThumbMouseDown);
|
||||
view.dom.addEventListener("mousemove", this.onPointerMove);
|
||||
view.dom.addEventListener("mouseleave", this.onPointerLeave);
|
||||
view.scrollDOM.addEventListener("scroll", this.onScroll, { passive: true });
|
||||
@@ -176,8 +175,8 @@ class OverviewLane {
|
||||
const thumb = scrollbarThumb(s.scrollTop, s.scrollHeight, s.clientHeight);
|
||||
// The lane starts at the top of the editor, the scrollbar at the top of the scroller.
|
||||
const offset = s.getBoundingClientRect().top - this.dom.getBoundingClientRect().top;
|
||||
// The drawn thumb matches the geometry the drag maps onto, so the pointer keeps its grip.
|
||||
return thumb && { top: thumb.top + offset, height: thumb.bottom - thumb.top };
|
||||
// Inset by the thumb's 2px transparent border (global.css).
|
||||
return thumb && { top: thumb.top + offset + 2, height: thumb.bottom - thumb.top - 4 };
|
||||
},
|
||||
write: (thumb) => {
|
||||
this.thumb.style.display = thumb ? "" : "none";
|
||||
@@ -189,8 +188,8 @@ class OverviewLane {
|
||||
}
|
||||
|
||||
/**
|
||||
* Over the thumb the marks let the pointer through, so the thumb can be dragged even where a
|
||||
* mark covers it (a new file is one mark the height of the lane, #752).
|
||||
* Over the scrollbar thumb the marks let the pointer through, so the thumb can be dragged
|
||||
* even where a mark covers it (a new file is one mark the height of the lane, #752).
|
||||
*/
|
||||
private updateThumbPassThrough() {
|
||||
const p = this.pointer;
|
||||
@@ -221,31 +220,6 @@ class OverviewLane {
|
||||
if (this.pointer) this.updateThumbPassThrough();
|
||||
};
|
||||
|
||||
/** The thumb's own press: drag it, keeping the button state inside the lane. */
|
||||
private onThumbMouseDown = (e: MouseEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const s = this.view.scrollDOM;
|
||||
const thumb = scrollbarThumb(s.scrollTop, s.scrollHeight, s.clientHeight);
|
||||
if (!thumb) return;
|
||||
const startY = e.clientY;
|
||||
const startTop = s.scrollTop;
|
||||
const travel = s.scrollHeight - s.clientHeight;
|
||||
// The distance the thumb can move: the track minus the thumb itself.
|
||||
const track = Math.max(1, s.clientHeight - (thumb.bottom - thumb.top));
|
||||
const onMove = (ev: MouseEvent) => {
|
||||
s.scrollTop = startTop + ((ev.clientY - startY) * travel) / track;
|
||||
};
|
||||
const onUp = () => {
|
||||
window.removeEventListener("mousemove", onMove);
|
||||
window.removeEventListener("mouseup", onUp);
|
||||
this.updateThumbPassThrough();
|
||||
};
|
||||
window.addEventListener("mousemove", onMove);
|
||||
window.addEventListener("mouseup", onUp);
|
||||
};
|
||||
|
||||
private onMouseDown = (e: MouseEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
const marks = [...this.dom.querySelectorAll<HTMLElement>(".cm-openidea-overviewMark")].map((el) => {
|
||||
@@ -253,17 +227,7 @@ class OverviewLane {
|
||||
return { top: r.top, bottom: r.bottom, pos: Number(el.dataset.pos) };
|
||||
});
|
||||
const hit = nearestMark(marks, e.clientY);
|
||||
if (!hit) {
|
||||
// Above / below the thumb pages the editor, like the native track it replaces.
|
||||
const s = this.view.scrollDOM;
|
||||
const thumb = scrollbarThumb(s.scrollTop, s.scrollHeight, s.clientHeight);
|
||||
e.preventDefault();
|
||||
if (!thumb) return;
|
||||
const y = e.clientY - s.getBoundingClientRect().top;
|
||||
if (y < thumb.top) s.scrollTop -= s.clientHeight;
|
||||
else if (y > thumb.bottom) s.scrollTop += s.clientHeight;
|
||||
return;
|
||||
}
|
||||
if (!hit) return;
|
||||
e.preventDefault();
|
||||
const state = this.view.state;
|
||||
const pos = Math.min(hit.pos, state.doc.length);
|
||||
@@ -277,7 +241,6 @@ class OverviewLane {
|
||||
|
||||
destroy() {
|
||||
this.dom.removeEventListener("mousedown", this.onMouseDown);
|
||||
this.thumb.removeEventListener("mousedown", this.onThumbMouseDown);
|
||||
this.view.dom.removeEventListener("mousemove", this.onPointerMove);
|
||||
this.view.dom.removeEventListener("mouseleave", this.onPointerLeave);
|
||||
this.view.scrollDOM.removeEventListener("scroll", this.onScroll);
|
||||
|
||||
@@ -3,27 +3,6 @@ import { highlightTree, tagHighlighter, tags as t } from "@lezer/highlight";
|
||||
import { findLanguageDescription, loadSupport } from "./languages";
|
||||
|
||||
describe("language modes", () => {
|
||||
it("distinguishes PHP classes, method declarations and fields for imported schemes", async () => {
|
||||
const support = await loadSupport(findLanguageDescription("PHP", "/tmp/palette.php")!);
|
||||
const source = "<?php class Palette extends BasePalette { var $name; function example() {} }";
|
||||
const found: Array<[string, string]> = [];
|
||||
highlightTree(
|
||||
support!.language.parser.parse(source),
|
||||
tagHighlighter([
|
||||
{ tag: t.className, class: "class" },
|
||||
{ tag: t.function(t.definition(t.variableName)), class: "function" },
|
||||
{ tag: t.definition(t.propertyName), class: "field" },
|
||||
]),
|
||||
(from, to, style) => found.push([source.slice(from, to), style]),
|
||||
);
|
||||
expect(found).toEqual([
|
||||
["Palette", "class"],
|
||||
["BasePalette", "class"],
|
||||
["$name", "field"],
|
||||
["example", "function"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("recognizes AsciiDoc files and loads their lightweight syntax mode", async () => {
|
||||
for (const path of ["/docs/guide.adoc", "/docs/guide.asciidoc"]) {
|
||||
const description = findLanguageDescription("AsciiDoc", path);
|
||||
|
||||
Loaded 100 of 215 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user