mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-07 18:38:33 -10:00
Compare commits
65
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
352e8f718d | ||
|
|
9bd42381b2 | ||
|
|
47e5b0f59c | ||
|
|
4a217a4473 | ||
|
|
1aec21b43f | ||
|
|
d3d0fd23c6 | ||
|
|
c3b7e34b22 | ||
|
|
91d6c7de57 | ||
|
|
abb7b1d962 | ||
|
|
c57156ef4b | ||
|
|
f55cdcdc31 | ||
|
|
7d267ab50e | ||
|
|
50986088f9 | ||
|
|
7a0f60377c | ||
|
|
945f09347e | ||
|
|
2924824e7b | ||
|
|
26e3ecc3a0 | ||
|
|
456b329d73 | ||
|
|
ecda2223eb | ||
|
|
b0f65769c0 | ||
|
|
9bf28dd451 | ||
|
|
3025b274f5 | ||
|
|
bdf94926ec | ||
|
|
893a549b23 | ||
|
|
3ef247c55f | ||
|
|
10a3ac92cf | ||
|
|
43c85ce8f3 | ||
|
|
ac436160b5 | ||
|
|
a3fe841e37 | ||
|
|
ed4a8b50c1 | ||
|
|
e7952ce3d8 | ||
|
|
c5951ac5d5 | ||
|
|
a0595f5c12 | ||
|
|
090a1cd6df | ||
|
|
2b86dd2d9f | ||
|
|
566190e94b | ||
|
|
8869f1ce1c | ||
|
|
04fe65328e | ||
|
|
0d80e2c05a | ||
|
|
ee5d912157 | ||
|
|
4557811ef8 | ||
|
|
47f7e07a20 | ||
|
|
90e336761d | ||
|
|
73be53b6d9 | ||
|
|
2760c91bde | ||
|
|
a5fb380960 | ||
|
|
8dedf5e73b | ||
|
|
75c4ca0be8 | ||
|
|
7856fabbfe | ||
|
|
4a90f124c6 | ||
|
|
bb4e2db47f | ||
|
|
fec391b9c1 | ||
|
|
43af99d3ec | ||
|
|
3e88fa1f39 | ||
|
|
6b89984863 | ||
|
|
a083571537 | ||
|
|
d3cb7c14ca | ||
|
|
293b41dd53 | ||
|
|
0dfdbbc323 | ||
|
|
682fee633d | ||
|
|
dce57ca83a | ||
|
|
b674d0f2e8 | ||
|
|
001381459e | ||
|
|
38ac62f1a4 | ||
|
|
29c54c243a |
No files matched your search
@@ -9,7 +9,8 @@
|
||||
|
||||
<!-- Required. Commands you actually ran and their results; what else this could
|
||||
affect and how you checked it still works.
|
||||
UI changes: before/after screenshots. Multi-step interactions: a recording.
|
||||
Visible UI changes: before/after screenshots (not for removed UI or copy-only
|
||||
changes). Multi-step interactions: a recording.
|
||||
Backend changes: logs or command output. -->
|
||||
|
||||
## Review focus
|
||||
|
||||
@@ -7,6 +7,9 @@ on:
|
||||
types: [opened, synchronize, reopened]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
# A newer push supersedes the run in progress, on main too.
|
||||
@@ -19,8 +22,50 @@ env:
|
||||
CARGO_PROFILE_DEV_DEBUG: line-tables-only
|
||||
|
||||
jobs:
|
||||
# Which parts a pull request touches, so jobs it can't affect skip and leave the
|
||||
# shared runners to others. Pushes and manual runs always run everything.
|
||||
changes:
|
||||
name: Changes
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
pull-requests: read
|
||||
outputs:
|
||||
rust: ${{ steps.classify.outputs.rust }}
|
||||
frontend: ${{ steps.classify.outputs.frontend }}
|
||||
steps:
|
||||
- id: classify
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "$GITHUB_EVENT_NAME" != pull_request ]; then
|
||||
echo "rust=true" >> "$GITHUB_OUTPUT"
|
||||
echo "frontend=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
gh api --paginate "repos/$GITHUB_REPOSITORY/pulls/$PR/files" \
|
||||
--jq '.[] | .filename, (.previous_filename // empty)' > files.txt
|
||||
cat files.txt
|
||||
docs='^(docs/|\.agents/|\.claude/|\.idea/|\.github/ISSUE_TEMPLATE/|\.github/pull_request_template\.md$|[^/]+\.md$|LICENSE$|NOTICE$)'
|
||||
# Frontend-only paths. The Rust code embeds a few files under src/; those stay out.
|
||||
web='^(src/|e2e/|perf/|public/|scripts/|smoke/|index\.html$|vite\.config\.ts$|tsconfig[^/]*\.json$|knip\.ts$|playwright\.config\.ts$|\.oxlintrc\.json$|\.oxfmtrc\.json$)'
|
||||
embedded='^src/(bindings\.ts|styles/tokens\.css|features/plugins/views/frame-bridge\.js|platform/keymaps/)'
|
||||
# A job runs when any changed file falls outside the paths that can't affect it.
|
||||
rust=false
|
||||
if grep -Ev "$docs|$web" files.txt | grep -q . || grep -Eq "$embedded" files.txt; then rust=true; fi
|
||||
frontend=false
|
||||
if grep -Ev "$docs" files.txt | grep -q .; then frontend=true; fi
|
||||
echo "rust=$rust frontend=$frontend"
|
||||
echo "rust=$rust" >> "$GITHUB_OUTPUT"
|
||||
echo "frontend=$frontend" >> "$GITHUB_OUTPUT"
|
||||
|
||||
rust:
|
||||
name: Rust (${{ matrix.os }})
|
||||
needs: changes
|
||||
if: needs.changes.outputs.rust == 'true'
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -62,6 +107,9 @@ jobs:
|
||||
# keep that build working.
|
||||
server:
|
||||
name: Remote server (linux musl)
|
||||
needs: changes
|
||||
if: needs.changes.outputs.rust == 'true'
|
||||
timeout-minutes: 40
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
@@ -77,6 +125,9 @@ jobs:
|
||||
# own workspaces (the root excludes them), so the jobs above don't see them.
|
||||
plugins:
|
||||
name: Plugins (SDK, official, fixtures)
|
||||
needs: changes
|
||||
if: needs.changes.outputs.rust == 'true'
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-22.04
|
||||
env:
|
||||
CARGO_TARGET_DIR: ${{ github.workspace }}/target-plugins
|
||||
@@ -126,6 +177,9 @@ jobs:
|
||||
|
||||
deny:
|
||||
name: Cargo deny
|
||||
needs: changes
|
||||
if: needs.changes.outputs.rust == 'true'
|
||||
timeout-minutes: 15
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
@@ -135,6 +189,9 @@ jobs:
|
||||
|
||||
frontend:
|
||||
name: Frontend
|
||||
needs: changes
|
||||
if: needs.changes.outputs.frontend == 'true'
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
@@ -152,6 +209,9 @@ jobs:
|
||||
|
||||
e2e:
|
||||
name: E2E
|
||||
needs: changes
|
||||
if: needs.changes.outputs.frontend == 'true'
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-24.04
|
||||
# Browsers and their system libraries are preinstalled; keep the tag in step with @playwright/test.
|
||||
container: mcr.microsoft.com/playwright:v1.64.1-noble
|
||||
|
||||
@@ -11,10 +11,7 @@ name: Language smoke
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
types: [opened, synchronize, reopened]
|
||||
paths:
|
||||
paths: &paths
|
||||
- "crates/**"
|
||||
- "languages/**"
|
||||
- "fixtures/**"
|
||||
@@ -27,6 +24,10 @@ on:
|
||||
- "package.json"
|
||||
- "pnpm-lock.yaml"
|
||||
- ".github/workflows/language-smoke.yml"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
types: [opened, synchronize, reopened]
|
||||
paths: *paths
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
|
||||
@@ -37,6 +37,16 @@ jobs:
|
||||
echo "::error::tag $TAG does not match tauri.conf.json version $app"
|
||||
exit 1
|
||||
fi
|
||||
# A shipped plugin API is frozen: its WIT must be the snapshot api_frozen.rs checks later
|
||||
# versions against (plugins/README.md, "Evolving the plugin API").
|
||||
- name: Plugin API snapshot matches wit/
|
||||
run: |
|
||||
api() { sed -n 's/^package openidea:plugin@\(.*\);$/\1/p' "$1/plugin.wit"; }
|
||||
snapshot=sdk/rust/openidea-plugin/tests/api/released
|
||||
if [ "$(api wit)" != "$(api "$snapshot")" ]; then
|
||||
echo "::error::plugin API $(api wit) ships without a snapshot ($snapshot is $(api "$snapshot")): copy wit/*.wit to $snapshot/"
|
||||
exit 1
|
||||
fi
|
||||
# The official plugins' public key (from PLUGIN_SIGNING_KEY, the seed
|
||||
# `openidea-plugin keygen` writes), built into the app: their packages
|
||||
# must carry its signature whatever the marketplace index lists. Without
|
||||
|
||||
@@ -51,7 +51,8 @@ Visual style: dark, low-contrast greys, one cool accent
|
||||
|
||||
Follow `CONTRIBUTING.md` and `.github/pull_request_template.md`: one problem per PR
|
||||
(`Closes #<n>` when it resolves an issue), reproduce first, keep the diff focused, short plain description, and a
|
||||
**How it was verified** section with commands run, results and (for UI) before/after screenshots.
|
||||
**How it was verified** section with commands run, results and, when the change alters what users
|
||||
see, before/after screenshots (not for UI removed along with a feature, copy-only or non-visual changes).
|
||||
Unclear product decisions → `needs-decision` and stop.
|
||||
|
||||
## Skills
|
||||
|
||||
+3
-2
@@ -25,8 +25,9 @@ Project layout, coding rules, commands and checks are in [AGENTS.md](AGENTS.md).
|
||||
- Fill in the template. Keep the description short and in plain words. If it
|
||||
doesn't fit in a few sentences, the PR is probably too big.
|
||||
- **How it was verified** is required: the commands you ran and their results,
|
||||
and what else the change could affect. UI changes need before/after screenshots;
|
||||
multi-step interactions need a recording.
|
||||
and what else the change could affect. Changes to what users see need before/after
|
||||
screenshots; multi-step interactions need a recording. UI that goes away with a removed
|
||||
feature, copy-only and non-visual changes don't; say what you checked instead.
|
||||
- If a change needs a product decision you can't make, ask in the issue and stop
|
||||
rather than guessing.
|
||||
|
||||
|
||||
Generated
+82
-39
@@ -1496,7 +1496,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"syn 3.0.9",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3001,7 +3001,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c48eccd894bd15555b0346470e63491ded94aefe76b3fcb1052084b5a393238"
|
||||
dependencies = [
|
||||
"bstr",
|
||||
"hashbrown 0.17.1",
|
||||
"hashbrown 0.16.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4071,7 +4071,7 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "ide-agent"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4094,7 +4094,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-analysis"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"ast-grep-core",
|
||||
"ide-core",
|
||||
@@ -4106,9 +4106,20 @@ dependencies = [
|
||||
"tree-sitter",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-android"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"serde",
|
||||
"specta",
|
||||
"tempfile",
|
||||
"which",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-build"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"dunce",
|
||||
@@ -4123,7 +4134,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-clipboard"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
@@ -4132,7 +4143,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-core"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4149,7 +4160,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-coverage"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-run",
|
||||
@@ -4163,7 +4174,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-dap"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-jsonrpc",
|
||||
@@ -4181,7 +4192,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-db"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"bb8",
|
||||
"dirs 6.0.0",
|
||||
@@ -4207,7 +4218,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-docker"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"serde",
|
||||
@@ -4220,7 +4231,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-extension"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"blake3",
|
||||
@@ -4240,7 +4251,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-fonts"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-text",
|
||||
@@ -4252,7 +4263,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-github"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-vcs",
|
||||
@@ -4265,7 +4276,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-host"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"criterion",
|
||||
@@ -4326,7 +4337,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-http"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"parking_lot",
|
||||
@@ -4344,7 +4355,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-index"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"criterion",
|
||||
@@ -4366,7 +4377,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-jsonrpc"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"futures",
|
||||
@@ -4383,7 +4394,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-language"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"criterion",
|
||||
@@ -4439,7 +4450,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-lsp"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"futures",
|
||||
@@ -4465,7 +4476,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-notebook"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"ide-core",
|
||||
@@ -4488,7 +4499,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-plugin"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"ide-core",
|
||||
@@ -4504,11 +4515,12 @@ dependencies = [
|
||||
"tracing",
|
||||
"wasmtime",
|
||||
"wasmtime-wasi",
|
||||
"wat",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-profiler"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-run",
|
||||
@@ -4521,7 +4533,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-remote"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"globset",
|
||||
"ide-core",
|
||||
@@ -4538,7 +4550,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-run"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4561,7 +4573,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-sdk"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"ide-core",
|
||||
@@ -4580,7 +4592,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-search"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"globset",
|
||||
@@ -4600,7 +4612,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-spell"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"parking_lot",
|
||||
@@ -4613,7 +4625,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-ssh"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"flate2",
|
||||
@@ -4635,7 +4647,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-syntax"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-language",
|
||||
@@ -4650,7 +4662,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-terminal"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"libc",
|
||||
@@ -4663,7 +4675,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-text"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"ide-core",
|
||||
@@ -4676,7 +4688,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-tools"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"hex",
|
||||
@@ -4697,7 +4709,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-vcs"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dunce",
|
||||
@@ -4718,7 +4730,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-vfs"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"chardetng",
|
||||
"dunce",
|
||||
@@ -4740,7 +4752,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-web"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"open",
|
||||
@@ -4756,7 +4768,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-workspace"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dunce",
|
||||
@@ -6297,7 +6309,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openidea"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"blake3",
|
||||
@@ -6307,6 +6319,7 @@ dependencies = [
|
||||
"gtk",
|
||||
"ide-agent",
|
||||
"ide-analysis",
|
||||
"ide-android",
|
||||
"ide-build",
|
||||
"ide-clipboard",
|
||||
"ide-core",
|
||||
@@ -6361,10 +6374,12 @@ dependencies = [
|
||||
"tauri-plugin-updater",
|
||||
"tauri-plugin-window-state",
|
||||
"tauri-specta",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-appender",
|
||||
"tracing-subscriber",
|
||||
"windows",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
@@ -8362,7 +8377,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d204abcbdf8e88729306a8d0ca01a79d8a49969fe1f84696909d6dbc4321c1a"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
"hashbrown 0.17.1",
|
||||
"hashbrown 0.16.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -10305,6 +10320,12 @@ version = "1.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ca718471876992059f251e33aafc334346ada08042b7661dd1fe313a9bced557"
|
||||
|
||||
[[package]]
|
||||
name = "universal-hash"
|
||||
version = "0.6.1"
|
||||
@@ -10825,6 +10846,28 @@ dependencies = [
|
||||
"wasmtime",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wast"
|
||||
version = "258.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b912cfe5696dfaae736e344fc82082dbd63609abbaeca3926466c0b10d9fecc"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"leb128fmt",
|
||||
"memchr",
|
||||
"unicode-width",
|
||||
"wasm-encoder",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wat"
|
||||
version = "1.258.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3069154948f5737a8bcd5c55dcdf2c2b59ca776057732e49821167bde268ec6e"
|
||||
dependencies = [
|
||||
"wast",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-backend"
|
||||
version = "0.3.17"
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ members = ["crates/*", "src-tauri"]
|
||||
exclude = ["sdk", "plugins"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
edition = "2024"
|
||||
rust-version = "1.96"
|
||||
license = "Apache-2.0"
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
·
|
||||
<a href="#plugins">Plugins</a>
|
||||
·
|
||||
<a href="https://discord.gg/2Zy4yn7gP">Discord</a>
|
||||
<a href="#community">Community</a>
|
||||
</p>
|
||||
|
||||
https://github.com/user-attachments/assets/234dd54c-1255-4b3a-b35c-533d78d737c0
|
||||
@@ -57,6 +57,13 @@ Install them from Settings › Plugins, or start your own with
|
||||
`openidea-plugin new` — see [Writing OpenIDEA plugins](plugins/README.md) and
|
||||
the [official plugins](plugins/official). The plugin API is still a preview.
|
||||
|
||||
## Community
|
||||
|
||||
- [Discord](https://discord.gg/2Zy4yn7gP) — questions, feedback and release news.
|
||||
- WeChat group — scan to join:
|
||||
|
||||
<img src="docs/assets/wechat-group.png" alt="WeChat group QR code" width="200">
|
||||
|
||||
## License
|
||||
|
||||
[Apache 2.0](LICENSE). Bundled third-party assets are listed in [`THIRD_PARTY_LICENSES.md`](THIRD_PARTY_LICENSES.md).
|
||||
|
||||
@@ -76,6 +76,8 @@ 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)]
|
||||
@@ -332,6 +334,7 @@ fn visible(
|
||||
distribution,
|
||||
no_checksum,
|
||||
installed,
|
||||
installing: false,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "ide-android"
|
||||
description = "Android devices and logcat for the Logcat tool window, driven through the adb CLI"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ide-core.workspace = true
|
||||
serde.workspace = true
|
||||
specta.workspace = true
|
||||
which = "8"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
@@ -0,0 +1,222 @@
|
||||
//! Android devices and their logs, driven through the `adb` CLI (like git is
|
||||
//! driven through its CLI).
|
||||
//!
|
||||
//! The Logcat tool window needs two things from a device: that it is there, and
|
||||
//! what it prints. Both come from `adb`, which the Android SDK installs — the
|
||||
//! IDE does not bundle it.
|
||||
//!
|
||||
//! Every function blocks; callers run them off the UI / command threads.
|
||||
|
||||
mod model;
|
||||
mod process;
|
||||
|
||||
pub use model::*;
|
||||
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::sync::OnceLock;
|
||||
use std::time::Duration;
|
||||
|
||||
/// `adb devices` is quick unless it starts the daemon, which can take a moment.
|
||||
const DEVICES: Duration = Duration::from_secs(20);
|
||||
/// `adb logcat -c` only truncates the buffers.
|
||||
const CLEAR: Duration = Duration::from_secs(15);
|
||||
/// How much of the buffer to show before following, so opening the window is not empty.
|
||||
const TAIL_LINES: u32 = 200;
|
||||
/// Logcat can print thousands of lines a second: batches keep the IPC channel sane,
|
||||
/// while `IDLE` makes sure a quiet stream still shows its last line promptly.
|
||||
const BATCH_IDLE: Duration = Duration::from_millis(60);
|
||||
const BATCH_MAX: usize = 300;
|
||||
|
||||
fn exe(name: &str) -> String {
|
||||
if cfg!(windows) { format!("{name}.exe") } else { name.to_owned() }
|
||||
}
|
||||
|
||||
/// Android SDK locations, most specific first: the environment, then the usual
|
||||
/// per-platform installs (a GUI app on macOS does not see a shell's `ANDROID_HOME`).
|
||||
fn sdk_roots() -> Vec<PathBuf> {
|
||||
let mut roots: Vec<PathBuf> = ["ANDROID_HOME", "ANDROID_SDK_ROOT"]
|
||||
.iter()
|
||||
.filter_map(|key| std::env::var_os(key).map(PathBuf::from))
|
||||
.collect();
|
||||
let home = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).map(PathBuf::from);
|
||||
if let Some(home) = home.filter(|_| !cfg!(windows)) {
|
||||
roots.push(home.join("Library/Android/sdk"));
|
||||
roots.push(home.join("Android/Sdk"));
|
||||
}
|
||||
if cfg!(windows)
|
||||
&& let Some(local) = std::env::var_os("LOCALAPPDATA").map(PathBuf::from)
|
||||
{
|
||||
roots.push(local.join("Android/Sdk"));
|
||||
}
|
||||
roots
|
||||
}
|
||||
|
||||
/// Locates the adb executable once per process; `OPENIDEA_ADB` overrides.
|
||||
pub fn adb_executable() -> Option<PathBuf> {
|
||||
static ADB: OnceLock<Option<PathBuf>> = OnceLock::new();
|
||||
ADB.get_or_init(|| {
|
||||
if let Some(explicit) = std::env::var_os("OPENIDEA_ADB").map(PathBuf::from).filter(|p| p.is_file()) {
|
||||
return Some(explicit);
|
||||
}
|
||||
let name = exe("adb");
|
||||
if let Some(found) =
|
||||
sdk_roots().into_iter().map(|root| root.join("platform-tools").join(&name)).find(|p| p.is_file())
|
||||
{
|
||||
return Some(found);
|
||||
}
|
||||
if let Ok(found) = which::which("adb") {
|
||||
return Some(found);
|
||||
}
|
||||
let mut candidates: Vec<PathBuf> = if cfg!(windows) {
|
||||
vec![PathBuf::from(r"C:\platform-tools\adb.exe")]
|
||||
} else {
|
||||
["/usr/local/bin/adb", "/opt/homebrew/bin/adb", "/usr/bin/adb"].iter().map(PathBuf::from).collect()
|
||||
};
|
||||
candidates.extend([
|
||||
PathBuf::from("/opt/android-sdk/platform-tools/adb"),
|
||||
PathBuf::from("/usr/lib/android-sdk/platform-tools/adb"),
|
||||
]);
|
||||
candidates.into_iter().find(|p: &PathBuf| p.is_file())
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
fn executable() -> IdeResult<PathBuf> {
|
||||
adb_executable().ok_or_else(|| {
|
||||
IdeError::unavailable(
|
||||
"adb was not found: install the Android SDK platform-tools, or set ANDROID_HOME (or OPENIDEA_ADB)",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// The directory holding adb, for command lines that expect `adb` on `PATH`.
|
||||
pub fn platform_tools_dir() -> Option<&'static Path> {
|
||||
static DIR: OnceLock<Option<PathBuf>> = OnceLock::new();
|
||||
DIR.get_or_init(|| adb_executable().as_deref().and_then(Path::parent).map(Path::to_path_buf)).as_deref()
|
||||
}
|
||||
|
||||
/// Connected devices, online ones first; starting the adb daemon when needed.
|
||||
pub fn devices() -> IdeResult<Vec<AndroidDevice>> {
|
||||
let adb = executable()?;
|
||||
let mut cmd = Command::new(adb);
|
||||
cmd.args(["devices", "-l"]);
|
||||
let out = process::run(cmd, DEVICES, None)?;
|
||||
if !out.success {
|
||||
let message = out.stderr.trim();
|
||||
return Err(IdeError::unavailable(if message.is_empty() {
|
||||
"adb devices failed".to_owned()
|
||||
} else {
|
||||
format!("adb devices failed: {message}")
|
||||
}));
|
||||
}
|
||||
Ok(parse_devices(&out.stdout))
|
||||
}
|
||||
|
||||
/// Empties the device's log buffers (`adb logcat -c`).
|
||||
pub fn clear_log(serial: &str, cancel: &CancellationToken) -> IdeResult<()> {
|
||||
let adb = executable()?;
|
||||
let mut cmd = Command::new(adb);
|
||||
cmd.args(["-s", serial, "logcat", "-c"]);
|
||||
let out = process::run(cmd, CLEAR, Some(cancel))?;
|
||||
if out.success {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(IdeError::unavailable(format!("adb logcat -c failed: {}", out.stderr.trim())))
|
||||
}
|
||||
}
|
||||
|
||||
fn command(adb: &Path, serial: &str, tail: Option<u32>) -> Command {
|
||||
let mut cmd = Command::new(adb);
|
||||
cmd.args(["-s", serial, "logcat", "-v", "threadtime"]);
|
||||
if let Some(lines) = tail {
|
||||
// Recent history first, then follow: `-T <count>` is lines, not an exit.
|
||||
cmd.arg("-T").arg(lines.to_string());
|
||||
}
|
||||
cmd
|
||||
}
|
||||
|
||||
/// Follows `serial`'s log until `cancel` fires, handing lines over in batches.
|
||||
/// With `clear`, the buffers are emptied first so the window starts clean.
|
||||
pub fn logcat(
|
||||
serial: &str,
|
||||
clear: bool,
|
||||
cancel: &CancellationToken,
|
||||
on_batch: &mut dyn FnMut(Vec<LogcatLine>),
|
||||
) -> IdeResult<()> {
|
||||
let adb = executable()?;
|
||||
if clear {
|
||||
clear_log(serial, cancel)?;
|
||||
}
|
||||
let mut parse = |line: &str| Some(parse_line(line));
|
||||
let (ok, stderr) = match process::stream_batched(
|
||||
command(&adb, serial, Some(TAIL_LINES)),
|
||||
cancel,
|
||||
BATCH_IDLE,
|
||||
BATCH_MAX,
|
||||
&mut parse,
|
||||
on_batch,
|
||||
) {
|
||||
Ok(result) => result,
|
||||
// The window closed or switched devices: stopping is the expected end.
|
||||
Err(e) if e.kind == ide_core::error::ErrorKind::Cancelled => return Ok(()),
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
if ok || cancel.is_cancelled() {
|
||||
return Ok(());
|
||||
}
|
||||
// `-T` is old (API 21) but adb forwards logcat options it does not know on
|
||||
// very old devices; retry without the history rather than showing nothing.
|
||||
let unsupported = stderr.iter().any(|line| {
|
||||
line.contains("Unrecognized option") || line.contains("unknown option") || line.contains("Invalid option")
|
||||
});
|
||||
if unsupported {
|
||||
let (ok, stderr) = match process::stream_batched(
|
||||
command(&adb, serial, None),
|
||||
cancel,
|
||||
BATCH_IDLE,
|
||||
BATCH_MAX,
|
||||
&mut parse,
|
||||
on_batch,
|
||||
) {
|
||||
Ok(result) => result,
|
||||
Err(e) if e.kind == ide_core::error::ErrorKind::Cancelled => return Ok(()),
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
if ok || cancel.is_cancelled() {
|
||||
return Ok(());
|
||||
}
|
||||
return Err(failure(serial, &stderr));
|
||||
}
|
||||
Err(failure(serial, &stderr))
|
||||
}
|
||||
|
||||
fn failure(serial: &str, stderr: &[String]) -> IdeError {
|
||||
let message = stderr.join("\n");
|
||||
IdeError::unavailable(if message.trim().is_empty() {
|
||||
format!("adb logcat on {serial} stopped")
|
||||
} else {
|
||||
format!("adb logcat on {serial} stopped: {message}")
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn adb_candidates_are_absolute() {
|
||||
for root in sdk_roots() {
|
||||
assert!(root.is_absolute(), "{root:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn executable_is_absolute_when_found() {
|
||||
if let Some(adb) = adb_executable() {
|
||||
assert!(adb.is_absolute());
|
||||
assert!(adb.is_file());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
//! What the Logcat tool window shows: the devices `adb` reports and the lines
|
||||
//! `adb logcat` prints, parsed from its `threadtime` format.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
|
||||
/// One entry of `adb devices -l`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AndroidDevice {
|
||||
/// `adb -s` value: a USB serial number, `emulator-5554`, or `host:port`.
|
||||
pub serial: String,
|
||||
/// `device` (usable), `offline`, `unauthorized` or `no permissions`.
|
||||
pub state: String,
|
||||
pub model: Option<String>,
|
||||
pub product: Option<String>,
|
||||
/// The device's own name from the build (`ro.product.device`).
|
||||
pub device: Option<String>,
|
||||
pub transport_id: Option<String>,
|
||||
}
|
||||
|
||||
impl AndroidDevice {
|
||||
/// Ready for `logcat` / `install`.
|
||||
pub fn is_online(&self) -> bool {
|
||||
self.state == "device"
|
||||
}
|
||||
|
||||
/// What the picker calls it: the model when known, the serial otherwise.
|
||||
pub fn label(&self) -> &str {
|
||||
self.model.as_deref().filter(|m| !m.trim().is_empty()).unwrap_or(&self.serial)
|
||||
}
|
||||
}
|
||||
|
||||
/// The single-letter logcat priority, in increasing severity.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum LogcatLevel {
|
||||
Verbose,
|
||||
Debug,
|
||||
Info,
|
||||
Warn,
|
||||
Error,
|
||||
Fatal,
|
||||
/// A line logcat prints outside the message format (`--------- beginning of main`).
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl LogcatLevel {
|
||||
pub fn letter(self) -> char {
|
||||
match self {
|
||||
Self::Verbose => 'V',
|
||||
Self::Debug => 'D',
|
||||
Self::Info => 'I',
|
||||
Self::Warn => 'W',
|
||||
Self::Error => 'E',
|
||||
Self::Fatal => 'F',
|
||||
Self::Unknown => '?',
|
||||
}
|
||||
}
|
||||
|
||||
fn parse(c: char) -> Self {
|
||||
match c.to_ascii_uppercase() {
|
||||
'V' => Self::Verbose,
|
||||
'D' => Self::Debug,
|
||||
'I' => Self::Info,
|
||||
'W' => Self::Warn,
|
||||
'E' => Self::Error,
|
||||
'F' | 'A' => Self::Fatal,
|
||||
_ => Self::Unknown,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One [`LogcatLevel`]-tagged line of `adb logcat -v threadtime`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LogcatLine {
|
||||
/// `MM-DD HH:MM:SS.mmm`, as logcat prints it (no year, device time zone).
|
||||
pub time: String,
|
||||
pub pid: i32,
|
||||
pub tid: i32,
|
||||
pub level: LogcatLevel,
|
||||
pub tag: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl LogcatLine {
|
||||
/// A line logcat printed outside the message format, kept so nothing is lost.
|
||||
fn unparsed(text: &str) -> Self {
|
||||
Self {
|
||||
time: String::new(),
|
||||
pid: 0,
|
||||
tid: 0,
|
||||
level: LogcatLevel::Unknown,
|
||||
tag: String::new(),
|
||||
message: text.to_owned(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses `adb devices -l` output; the daemon's chatter on stderr is not included.
|
||||
pub fn parse_devices(output: &str) -> Vec<AndroidDevice> {
|
||||
let mut devices: Vec<AndroidDevice> = Vec::new();
|
||||
for line in output.lines() {
|
||||
let line = line.trim_end();
|
||||
if line.trim().is_empty() || line.starts_with("List of devices") || line.starts_with('*') {
|
||||
continue;
|
||||
}
|
||||
let mut fields = line.split_whitespace();
|
||||
let (Some(serial), Some(state)) = (fields.next(), fields.next()) else {
|
||||
continue;
|
||||
};
|
||||
// "no permissions (user in plugdev group; are your udev rules wrong?)"
|
||||
// is the one state that carries spaces; the rest is a `key:value` list.
|
||||
let mut device = AndroidDevice {
|
||||
serial: serial.to_owned(),
|
||||
state: state.to_owned(),
|
||||
model: None,
|
||||
product: None,
|
||||
device: None,
|
||||
transport_id: None,
|
||||
};
|
||||
if device.state == "no" {
|
||||
device.state = "no permissions".to_owned();
|
||||
devices.push(device);
|
||||
continue;
|
||||
}
|
||||
for field in fields {
|
||||
let Some((key, value)) = field.split_once(':') else {
|
||||
continue;
|
||||
};
|
||||
match key {
|
||||
"model" => device.model = Some(value.to_owned()),
|
||||
"product" => device.product = Some(value.to_owned()),
|
||||
"device" => device.device = Some(value.to_owned()),
|
||||
"transport_id" => device.transport_id = Some(value.to_owned()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
devices.push(device);
|
||||
}
|
||||
// Online first, then by name, so the picker's default is the device in hand.
|
||||
devices.sort_by(|a, b| b.is_online().cmp(&a.is_online()).then_with(|| a.serial.cmp(&b.serial)));
|
||||
devices
|
||||
}
|
||||
|
||||
/// Parses one `logcat -v threadtime` line:
|
||||
///
|
||||
/// ```text
|
||||
/// 11-04 12:52:39.123 3048 3059 I WindowManager: displayed
|
||||
/// ```
|
||||
///
|
||||
/// Text that does not follow the format still becomes a line, so a device that
|
||||
/// prints something unexpected does not look like a dead stream.
|
||||
pub fn parse_line(text: &str) -> LogcatLine {
|
||||
let Some(line) = parse_formatted(text) else {
|
||||
return LogcatLine::unparsed(text);
|
||||
};
|
||||
line
|
||||
}
|
||||
|
||||
fn parse_formatted(text: &str) -> Option<LogcatLine> {
|
||||
// "MM-DD HH:MM:SS.mmm" is 18 bytes; logcat pads the rest with spaces.
|
||||
let stamp = text.get(..18)?;
|
||||
if !looks_like_stamp(stamp) {
|
||||
return None;
|
||||
}
|
||||
let mut rest = text.get(18..)?;
|
||||
let pid: i32 = take_field(&mut rest)?.parse().ok()?;
|
||||
let tid: i32 = take_field(&mut rest)?.parse().ok()?;
|
||||
let level = LogcatLevel::parse(take_field(&mut rest)?.chars().next()?);
|
||||
let tail = rest.trim_start();
|
||||
// "Tag : message": logcat pads the tag to 8 columns, and the tag
|
||||
// itself never contains a colon followed by a space.
|
||||
let (tag, message) = match tail.split_once(": ") {
|
||||
Some((tag, message)) => (tag.trim_end(), message),
|
||||
None => (tail.trim_end(), ""),
|
||||
};
|
||||
Some(LogcatLine { time: stamp.to_owned(), pid, tid, level, tag: tag.to_owned(), message: message.to_owned() })
|
||||
}
|
||||
|
||||
/// Consumes the next space-separated field of `rest`, leaving the remainder.
|
||||
fn take_field<'a>(rest: &mut &'a str) -> Option<&'a str> {
|
||||
*rest = rest.trim_start();
|
||||
if rest.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
|
||||
let field = &rest[..end];
|
||||
*rest = &rest[end..];
|
||||
Some(field)
|
||||
}
|
||||
|
||||
fn looks_like_stamp(s: &str) -> bool {
|
||||
let bytes = s.as_bytes();
|
||||
bytes.len() == 18
|
||||
&& bytes[2] == b'-'
|
||||
&& bytes[5] == b' '
|
||||
&& bytes[8] == b':'
|
||||
&& bytes[11] == b':'
|
||||
&& bytes[14] == b'.'
|
||||
&& bytes.iter().enumerate().all(|(i, b)| matches!(i, 2 | 5 | 8 | 11 | 14) || b.is_ascii_digit())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reads_adb_devices_with_attributes() {
|
||||
let out = "\
|
||||
List of devices attached
|
||||
HT7A1B2C3D4 device product:generic model:Generic_Phone device:generic transport_id:1
|
||||
emulator-5554 offline transport_id:2
|
||||
0123456789ABCDEF unauthorized usb:1-1 transport_id:3
|
||||
|
||||
";
|
||||
let devices = parse_devices(out);
|
||||
assert_eq!(devices.len(), 3);
|
||||
// Online first, then by serial.
|
||||
assert_eq!(devices[0].serial, "HT7A1B2C3D4");
|
||||
assert_eq!(devices[0].state, "device");
|
||||
assert_eq!(devices[0].model.as_deref(), Some("Generic_Phone"));
|
||||
assert_eq!(devices[0].transport_id.as_deref(), Some("1"));
|
||||
assert!(devices[0].is_online());
|
||||
assert_eq!(devices[1].serial, "0123456789ABCDEF");
|
||||
assert_eq!(devices[1].state, "unauthorized");
|
||||
assert_eq!(devices[1].label(), "0123456789ABCDEF");
|
||||
assert_eq!(devices[2].serial, "emulator-5554");
|
||||
assert!(!devices[2].is_online());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_adb_devices_when_the_list_is_empty() {
|
||||
assert!(parse_devices("List of devices attached\n\n").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_no_permissions() {
|
||||
let devices = parse_devices("ABC\tno permissions (user in plugdev group; are your udev rules wrong?)\n");
|
||||
assert_eq!(devices.len(), 1);
|
||||
assert_eq!(devices[0].state, "no permissions");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_a_threadtime_line() {
|
||||
let line = parse_line("11-04 12:52:39.123 3048 3059 I WindowManager: Displayed activity");
|
||||
assert_eq!(line.time, "11-04 12:52:39.123");
|
||||
assert_eq!(line.pid, 3048);
|
||||
assert_eq!(line.tid, 3059);
|
||||
assert_eq!(line.level, LogcatLevel::Info);
|
||||
assert_eq!(line.tag, "WindowManager");
|
||||
assert_eq!(line.message, "Displayed activity");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_colons_in_the_message() {
|
||||
let line = parse_line("11-04 12:52:39.123 3048 3059 E flutter : java.lang.IllegalStateException: boom");
|
||||
assert_eq!(line.tag, "flutter");
|
||||
assert_eq!(line.level, LogcatLevel::Error);
|
||||
assert_eq!(line.message, "java.lang.IllegalStateException: boom");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_lines_outside_the_format() {
|
||||
let line = parse_line("--------- beginning of main");
|
||||
assert_eq!(line.level, LogcatLevel::Unknown);
|
||||
assert_eq!(line.message, "--------- beginning of main");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn levels_order_by_severity() {
|
||||
assert!(LogcatLevel::Warn > LogcatLevel::Info);
|
||||
assert!(LogcatLevel::Fatal > LogcatLevel::Error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
//! Child processes of the `adb` CLI: bounded runs, and cancellable line streams
|
||||
//! that are handed over in batches (logcat is far too chatty for one message a line).
|
||||
|
||||
use ide_core::process::terminate_tree;
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
use std::io::Read;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::mpsc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// How long a cancelled child gets to exit on `SIGTERM` (and flush) before `SIGKILL`.
|
||||
const KILL_GRACE: Duration = Duration::from_millis(20);
|
||||
|
||||
pub(crate) struct Output {
|
||||
pub stdout: String,
|
||||
pub stderr: String,
|
||||
pub success: bool,
|
||||
}
|
||||
|
||||
fn prepare(cmd: &mut Command) {
|
||||
cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
ide_core::process::prepare(ide_core::process::own_group(cmd));
|
||||
}
|
||||
|
||||
fn spawn(cmd: &mut Command) -> IdeResult<Child> {
|
||||
cmd.spawn().map_err(|e| IdeError::unavailable(format!("could not run adb: {e}")))
|
||||
}
|
||||
|
||||
fn read_all(mut pipe: impl Read + Send + 'static) -> std::thread::JoinHandle<String> {
|
||||
std::thread::spawn(move || {
|
||||
let mut buf = Vec::new();
|
||||
let _ = pipe.read_to_end(&mut buf);
|
||||
ide_core::output_encoding::decode_tool_output(&buf)
|
||||
})
|
||||
}
|
||||
|
||||
/// Runs `cmd` to completion. Fails when it outlives `timeout` or `cancel` fires;
|
||||
/// a non-zero exit is reported in [`Output::success`], not as an error.
|
||||
pub(crate) fn run(mut cmd: Command, timeout: Duration, cancel: Option<&CancellationToken>) -> IdeResult<Output> {
|
||||
prepare(&mut cmd);
|
||||
let mut child = spawn(&mut cmd)?;
|
||||
let out = child.stdout.take().map(read_all);
|
||||
let err = child.stderr.take().map(read_all);
|
||||
let deadline = Instant::now() + timeout;
|
||||
let status = loop {
|
||||
if let Some(status) = child.try_wait()? {
|
||||
break status;
|
||||
}
|
||||
if cancel.is_some_and(CancellationToken::is_cancelled) {
|
||||
terminate_tree(&mut child, KILL_GRACE);
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
terminate_tree(&mut child, KILL_GRACE);
|
||||
return Err(IdeError::unavailable("adb did not respond in time"));
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
};
|
||||
Ok(Output {
|
||||
stdout: out.map(|t| t.join().unwrap_or_default()).unwrap_or_default(),
|
||||
stderr: err.map(|t| t.join().unwrap_or_default()).unwrap_or_default(),
|
||||
success: status.success(),
|
||||
})
|
||||
}
|
||||
|
||||
enum Event {
|
||||
Line(bool, String),
|
||||
Closed,
|
||||
}
|
||||
|
||||
fn spawn_reader(mut pipe: impl Read + Send + 'static, tx: mpsc::Sender<Event>, stdout: bool) {
|
||||
std::thread::spawn(move || {
|
||||
let mut buf = [0u8; 8192];
|
||||
let mut pending: Vec<u8> = Vec::new();
|
||||
let flush = |pending: &mut Vec<u8>| {
|
||||
if !pending.is_empty() {
|
||||
let line = ide_core::output_encoding::decode_tool_output(pending);
|
||||
pending.clear();
|
||||
let _ = tx.send(Event::Line(stdout, line.trim_end().to_string()));
|
||||
}
|
||||
};
|
||||
loop {
|
||||
match pipe.read(&mut buf) {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => {
|
||||
for &b in &buf[..n] {
|
||||
if b == b'\n' {
|
||||
flush(&mut pending);
|
||||
} else if b != b'\r' {
|
||||
pending.push(b);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
flush(&mut pending);
|
||||
let _ = tx.send(Event::Closed);
|
||||
});
|
||||
}
|
||||
|
||||
fn flush<T>(batch: &mut Vec<T>, on_batch: &mut dyn FnMut(Vec<T>)) {
|
||||
if !batch.is_empty() {
|
||||
on_batch(std::mem::take(batch));
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `cmd`, parsing every stdout line with `parse` and handing the results to
|
||||
/// `on_batch` — as soon as `max` are ready, or once the oldest waiting line is `idle`
|
||||
/// old, so neither a quiet nor a steady stream holds lines back. Stderr is kept as a
|
||||
/// tail for the error message. Returns whether the child exited successfully.
|
||||
pub(crate) fn stream_batched<T>(
|
||||
mut cmd: Command,
|
||||
cancel: &CancellationToken,
|
||||
idle: Duration,
|
||||
max: usize,
|
||||
mut parse: impl FnMut(&str) -> Option<T>,
|
||||
on_batch: &mut dyn FnMut(Vec<T>),
|
||||
) -> IdeResult<(bool, Vec<String>)> {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
prepare(&mut cmd);
|
||||
let mut child = spawn(&mut cmd)?;
|
||||
let (tx, rx) = mpsc::channel::<Event>();
|
||||
let mut open = 0;
|
||||
if let Some(out) = child.stdout.take() {
|
||||
spawn_reader(out, tx.clone(), true);
|
||||
open += 1;
|
||||
}
|
||||
if let Some(err) = child.stderr.take() {
|
||||
spawn_reader(err, tx.clone(), false);
|
||||
open += 1;
|
||||
}
|
||||
drop(tx);
|
||||
let mut stderr_tail: Vec<String> = Vec::new();
|
||||
let mut batch: Vec<T> = Vec::with_capacity(max);
|
||||
// When the first line of the waiting batch arrived.
|
||||
let mut since = Instant::now();
|
||||
while open > 0 {
|
||||
if cancel.is_cancelled() {
|
||||
terminate_tree(&mut child, KILL_GRACE);
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let wait = if batch.is_empty() { idle } else { idle.saturating_sub(since.elapsed()) };
|
||||
match rx.recv_timeout(wait) {
|
||||
Ok(Event::Line(true, line)) => {
|
||||
if let Some(item) = parse(&line) {
|
||||
if batch.is_empty() {
|
||||
since = Instant::now();
|
||||
}
|
||||
batch.push(item);
|
||||
if batch.len() >= max || since.elapsed() >= idle {
|
||||
flush(&mut batch, on_batch);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Event::Line(false, line)) => {
|
||||
if !line.trim().is_empty() {
|
||||
stderr_tail.push(line);
|
||||
if stderr_tail.len() > 20 {
|
||||
stderr_tail.remove(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Event::Closed) => open -= 1,
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => flush(&mut batch, on_batch),
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => break,
|
||||
}
|
||||
}
|
||||
flush(&mut batch, on_batch);
|
||||
let status = loop {
|
||||
if let Some(status) = child.try_wait()? {
|
||||
break status;
|
||||
}
|
||||
if cancel.is_cancelled() {
|
||||
terminate_tree(&mut child, KILL_GRACE);
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
};
|
||||
Ok((status.success(), stderr_tail))
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_steady_stream_is_handed_over_while_it_runs() {
|
||||
let mut cmd = Command::new("sh");
|
||||
cmd.args(["-c", "for i in $(seq 1 20); do echo $i; sleep 0.02; done"]);
|
||||
let mut sizes = Vec::new();
|
||||
let (ok, _) = stream_batched(
|
||||
cmd,
|
||||
&CancellationToken::new(),
|
||||
Duration::from_millis(60),
|
||||
300,
|
||||
|line| Some(line.to_owned()),
|
||||
&mut |batch| sizes.push(batch.len()),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(ok);
|
||||
assert_eq!(sizes.iter().sum::<usize>(), 20);
|
||||
assert!(sizes.len() >= 3, "lines arriving every 20 ms waited for the end: {sizes:?}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
//! Drives a real device through `adb`, checking what the Logcat tool window does:
|
||||
//! that the device list parses, that the stream delivers batches, and that
|
||||
//! cancelling stops both the stream and its `adb` process.
|
||||
//!
|
||||
//! Ignored by default (needs a connected device). Run with
|
||||
//! `OPENIDEA_ADB_DEVICE=<serial> cargo test -p ide-android --test device -- --ignored --nocapture`
|
||||
//! — without the variable, the connected device is used when there is exactly one.
|
||||
|
||||
use ide_android::*;
|
||||
use ide_core::CancellationToken;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
fn serial() -> Option<String> {
|
||||
if let Ok(serial) = std::env::var("OPENIDEA_ADB_DEVICE") {
|
||||
return Some(serial);
|
||||
}
|
||||
let online = devices().ok()?.into_iter().filter(AndroidDevice::is_online).collect::<Vec<_>>();
|
||||
(online.len() == 1).then(|| online[0].serial.clone())
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn finds_the_adb_executable() {
|
||||
let adb = adb_executable().expect("adb (set ANDROID_HOME or OPENIDEA_ADB)");
|
||||
println!("adb: {}", adb.display());
|
||||
assert!(adb.is_file());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn lists_connected_devices() {
|
||||
let devices = devices().expect("adb devices");
|
||||
for device in &devices {
|
||||
println!("{:<16} {:<14} {} ({:?})", device.serial, device.state, device.label(), device.transport_id);
|
||||
}
|
||||
assert!(!devices.is_empty(), "no devices connected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn streams_batches_and_stops_on_cancel() {
|
||||
let serial = serial().expect("a single connected device, or OPENIDEA_ADB_DEVICE");
|
||||
let cancel = CancellationToken::new();
|
||||
let stop = cancel.clone();
|
||||
let received = Arc::new(Mutex::new(Vec::<LogcatLine>::new()));
|
||||
let sink = received.clone();
|
||||
let started = Instant::now();
|
||||
let stream = std::thread::spawn(move || {
|
||||
ide_android::logcat(&serial, false, &stop, &mut |batch| {
|
||||
sink.lock().unwrap().extend(batch);
|
||||
})
|
||||
});
|
||||
// Enough to see the tail seed and a few live lines from a device that idles.
|
||||
std::thread::sleep(Duration::from_secs(4));
|
||||
cancel.cancel();
|
||||
stream.join().unwrap().expect("logcat stops cleanly when cancelled");
|
||||
let lines = received.lock().unwrap();
|
||||
println!("{} lines in {:?}", lines.len(), started.elapsed());
|
||||
for line in lines.iter().rev().take(5) {
|
||||
println!("{:<14} {:>6} {}", line.tag, line.pid, line.message);
|
||||
}
|
||||
assert!(!lines.is_empty(), "no lines within 4s");
|
||||
assert!(lines.iter().any(|l| l.level != LogcatLevel::Unknown), "every line failed to parse");
|
||||
}
|
||||
@@ -40,7 +40,7 @@ pub fn plan(root: &Path, model: &BuildModel, module: &Path, cache: &Path) -> Ide
|
||||
kind: "maven",
|
||||
});
|
||||
}
|
||||
if let Some(g) = &model.gradle
|
||||
if let Some(g) = model.gradle_for_module(root, &module)
|
||||
&& let Some(p) = g.projects.iter().find(|p| root.join(&p.dir) == module)
|
||||
{
|
||||
let script = cache.join("resources.init.gradle");
|
||||
@@ -163,11 +163,11 @@ mod tests {
|
||||
assert!(maven.args.contains(&"-Dexpression=project.build.outputDirectory".into()));
|
||||
assert_eq!(maven.env["JAVA_HOME"], "/jdk");
|
||||
model.maven = None;
|
||||
model.gradle = Some(crate::GradleModel {
|
||||
model.gradle = vec![crate::GradleModel {
|
||||
command: "/tools/gradlew".into(),
|
||||
projects: vec![crate::GradleProject { path: ":app".into(), dir: "app".into(), ..Default::default() }],
|
||||
..Default::default()
|
||||
});
|
||||
}];
|
||||
let gradle = plan(&root, &model, Path::new("app"), &cache).unwrap();
|
||||
assert_eq!(gradle.command, "/tools/gradlew");
|
||||
assert_eq!(gradle.args.last().unwrap(), ":app:openideaUpdateResources");
|
||||
|
||||
@@ -147,7 +147,9 @@ 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 has(dir, &["build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts"]) {
|
||||
} else if model.gradle_for_module(root, dir).is_some()
|
||||
|| has(dir, &["build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts"])
|
||||
{
|
||||
Some(BuildSystem::Gradle)
|
||||
} else if has(dir, &["Cargo.toml"]) {
|
||||
Some(BuildSystem::Cargo)
|
||||
@@ -162,16 +164,13 @@ fn detect_dir(dir: &Path, model: &BuildModel, root: &Path) -> Option<BuildSystem
|
||||
}
|
||||
}
|
||||
|
||||
/// `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.
|
||||
/// Resolves a workspace-relative or absolute directory with native path separators.
|
||||
fn subdir(root: &Path, rel: &str) -> PathBuf {
|
||||
rel.split('/').filter(|part| !part.is_empty()).fold(root.to_path_buf(), |path, part| path.join(part))
|
||||
root.join(rel).components().collect()
|
||||
}
|
||||
|
||||
fn rel_str(root: &Path, dir: &Path) -> String {
|
||||
dir.strip_prefix(root).map(|r| r.to_string_lossy().replace('\\', "/")).unwrap_or_default()
|
||||
dir.strip_prefix(root).unwrap_or(dir).to_string_lossy().replace('\\', "/")
|
||||
}
|
||||
|
||||
/// Directories from `file`'s folder up to `root` (inclusive).
|
||||
@@ -192,7 +191,14 @@ pub fn plan(
|
||||
let project_name = root.file_name().map_or_else(|| "project".to_owned(), |n| n.to_string_lossy().into_owned());
|
||||
let module_dir = match file {
|
||||
Some(f) => {
|
||||
let dir = ancestors_within(root, f)
|
||||
let scope = model
|
||||
.gradle
|
||||
.iter()
|
||||
.flat_map(|g| std::iter::once(root.join(&g.dir)).chain(g.projects.iter().map(|p| root.join(&p.dir))))
|
||||
.filter(|dir| f.starts_with(dir))
|
||||
.max_by_key(|dir| dir.components().count())
|
||||
.unwrap_or_else(|| root.to_path_buf());
|
||||
let dir = ancestors_within(&scope, f)
|
||||
.find(|d| detect_dir(d, model, root).is_some())
|
||||
.ok_or_else(|| IdeError::not_found(format!("{} is not in a module", f.display())))?;
|
||||
Some(dir.to_path_buf())
|
||||
@@ -202,7 +208,7 @@ pub fn plan(
|
||||
let dir = module_dir.clone().unwrap_or_else(|| root.to_path_buf());
|
||||
// A folder of sibling projects has no build file itself; its Maven projects are built one by one.
|
||||
let system = detect_dir(&dir, model, root)
|
||||
.or_else(|| if model.gradle.is_some() { Some(BuildSystem::Gradle) } else { None })
|
||||
.or((!model.gradle.is_empty()).then_some(BuildSystem::Gradle))
|
||||
.or_else(|| model.maven.as_ref().filter(|m| !m.projects.is_empty()).map(|_| BuildSystem::Maven))
|
||||
.ok_or_else(|| {
|
||||
IdeError::not_found("no supported build system (Maven, Gradle, Cargo, Go, TypeScript, CMake) found")
|
||||
@@ -270,14 +276,16 @@ fn maven_steps(root: &Path, model: &BuildModel, kind: BuildKind, module: Option<
|
||||
}
|
||||
|
||||
fn gradle_steps(root: &Path, model: &BuildModel, kind: BuildKind, module: Option<&Path>) -> IdeResult<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
|
||||
}
|
||||
};
|
||||
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 prefix = module
|
||||
.and_then(|m| {
|
||||
let rel = rel_str(root, m);
|
||||
@@ -296,7 +304,7 @@ fn gradle_steps(root: &Path, model: &BuildModel, kind: BuildKind, module: Option
|
||||
let mut s = BuildStep::new(&g.command, &["--console=plain"], &subdir(root, &g.dir), Format::Gradle);
|
||||
s.args.extend(tasks);
|
||||
s.env = g.env.clone();
|
||||
Ok(vec![s])
|
||||
vec![s]
|
||||
}
|
||||
|
||||
fn cargo_steps(dir: &Path, kind: BuildKind) -> Vec<BuildStep> {
|
||||
@@ -811,6 +819,79 @@ fn launch_class_dirs(system: BuildSystem, module: &Path, test: bool) -> Vec<Path
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn independent_gradle_builds_keep_their_launchers_and_task_paths() {
|
||||
let wrapper = if cfg!(windows) { "gradlew.bat" } else { "gradlew" };
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path();
|
||||
for dir in ["a", "b"] {
|
||||
std::fs::create_dir_all(root.join(dir).join("app/src/main/java")).unwrap();
|
||||
std::fs::write(root.join(dir).join("settings.gradle"), "include 'app'").unwrap();
|
||||
std::fs::write(root.join(dir).join("app/build.gradle"), "plugins { id 'java' }").unwrap();
|
||||
std::fs::write(root.join(dir).join(wrapper), "").unwrap();
|
||||
}
|
||||
let settings = ide_core::settings::GradleSettings {
|
||||
linked_projects: Some(vec!["a".into(), "b".into()]),
|
||||
..Default::default()
|
||||
};
|
||||
let mut model = crate::load_linked(root, &root.join("cache"), None, &Default::default(), &settings);
|
||||
model.gradle[0].env.insert("JAVA_HOME".into(), "/jdk-a".into());
|
||||
model.gradle[1].env.insert("JAVA_HOME".into(), "/jdk-b".into());
|
||||
let all = super::plan(root, &model, super::BuildKind::Build, None, None).unwrap();
|
||||
assert_eq!(all.steps.len(), 2);
|
||||
assert_eq!(all.steps[0].cwd, root.join("a"));
|
||||
assert_eq!(all.steps[1].cwd, root.join("b"));
|
||||
let module = super::plan(root, &model, super::BuildKind::Rebuild, Some(&root.join("b/app")), None).unwrap();
|
||||
assert_eq!(module.steps.len(), 1);
|
||||
assert_eq!(module.steps[0].program, root.join("b").join(wrapper).to_string_lossy());
|
||||
assert_eq!(module.steps[0].cwd, root.join("b"));
|
||||
assert_eq!(module.steps[0].env["JAVA_HOME"], "/jdk-b");
|
||||
assert_eq!(module.steps[0].args, ["--console=plain", ":app:clean", ":app:classes", ":app:testClasses"]);
|
||||
// A directly linked build owns its module even when another composite reports it too.
|
||||
let direct = model.gradle[1].projects[0].clone();
|
||||
model.gradle[0].projects.push(direct);
|
||||
assert_eq!(model.gradle_for_module(root, &root.join("b")).unwrap().dir, "b");
|
||||
let mut nested = model.gradle[1].projects[1].clone();
|
||||
nested.path = ":b:app".into();
|
||||
model.gradle[0].projects.push(nested);
|
||||
let nested = super::plan(root, &model, super::BuildKind::Build, Some(&root.join("b/app")), None).unwrap();
|
||||
assert_eq!(nested.steps[0].program, root.join("b").join(wrapper).to_string_lossy());
|
||||
assert_eq!(nested.steps[0].args, ["--console=plain", ":app:classes", ":app:testClasses"]);
|
||||
model.gradle.clear();
|
||||
assert!(super::plan(root, &model, super::BuildKind::Build, Some(&root.join("b/app")), None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absolute_linked_roots_build_the_project_and_external_source_module() {
|
||||
let wrapper = if cfg!(windows) { "gradlew.bat" } else { "gradlew" };
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let base = dunce::canonicalize(temp.path()).unwrap();
|
||||
let root = base.join("workspace");
|
||||
let external = base.join("external");
|
||||
std::fs::create_dir(&root).unwrap();
|
||||
std::fs::create_dir_all(external.join("app/src/main/java")).unwrap();
|
||||
std::fs::write(external.join("settings.gradle"), "include 'app'").unwrap();
|
||||
let source = external.join("app/src/main/java/App.java");
|
||||
std::fs::write(&source, "class App {}").unwrap();
|
||||
std::fs::write(external.join(wrapper), "").unwrap();
|
||||
let settings = ide_core::settings::GradleSettings {
|
||||
linked_projects: Some(vec![external.to_string_lossy().into_owned()]),
|
||||
..Default::default()
|
||||
};
|
||||
let mut model = crate::load_linked(&root, &base.join("cache"), None, &Default::default(), &settings);
|
||||
model.gradle[0].env.insert("JAVA_HOME".into(), "/external-jdk".into());
|
||||
let all = super::plan(&root, &model, super::BuildKind::Build, None, None).unwrap();
|
||||
assert_eq!(all.steps[0].cwd, external);
|
||||
assert_eq!(all.steps[0].program, external.join(wrapper).to_string_lossy());
|
||||
let module = super::plan(&root, &model, super::BuildKind::Rebuild, Some(&source), None).unwrap();
|
||||
assert_eq!(module.steps[0].cwd, external);
|
||||
assert_eq!(module.steps[0].env["JAVA_HOME"], "/external-jdk");
|
||||
assert_eq!(module.steps[0].args, ["--console=plain", ":app:clean", ":app:classes", ":app:testClasses"]);
|
||||
let other = base.join("unlinked");
|
||||
std::fs::create_dir(&other).unwrap();
|
||||
std::fs::write(other.join("build.gradle"), "").unwrap();
|
||||
assert!(super::plan(&root, &model, super::BuildKind::Build, Some(&other), None).is_err());
|
||||
}
|
||||
use super::*;
|
||||
|
||||
/// Launch outputs use the platform separator throughout, so they compare equal to
|
||||
|
||||
@@ -435,6 +435,17 @@ 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> {
|
||||
// Linked builds are saved with forward slashes; rebuild the path so the launcher
|
||||
// and module folders use the platform's separators.
|
||||
let dir: PathBuf = root.join(dir).components().collect();
|
||||
if !SETTINGS_FILES.iter().chain(BUILD_FILES).any(|name| dir.join(name).is_file()) {
|
||||
return None;
|
||||
}
|
||||
let settings = read_first(&dir, SETTINGS_FILES).map(|(_, t)| parse_settings(&t)).unwrap_or_default();
|
||||
let root_name =
|
||||
settings.name.unwrap_or_else(|| dir.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default());
|
||||
@@ -455,6 +466,65 @@ pub fn load(root: &Path) -> Option<GradleModel> {
|
||||
Some(GradleModel { dir: rel(root, &dir), projects, java_version, command: wrapper(&dir), ..GradleModel::default() })
|
||||
}
|
||||
|
||||
/// Validates user-selected build folders and stores a stable, deduplicated identity.
|
||||
pub fn normalize_linked_projects(root: &Path, dirs: &[String]) -> ide_core::IdeResult<Vec<String>> {
|
||||
let root = dunce::canonicalize(root)?;
|
||||
let mut out = Vec::new();
|
||||
for dir in dirs {
|
||||
let path = dunce::canonicalize(root.join(dir))?;
|
||||
if !path.is_dir() || !SETTINGS_FILES.iter().chain(BUILD_FILES).any(|name| path.join(name).is_file()) {
|
||||
return Err(ide_core::IdeError::invalid(format!("No Gradle build in {}", path.display())));
|
||||
}
|
||||
let dir = rel(&root, &path);
|
||||
if !out.contains(&dir) {
|
||||
out.push(dir);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The form `normalize_linked_projects` saves `dir` in, also for a folder that no longer
|
||||
/// exists, so a path the UI sends (`C:\…` on Windows) matches the saved entry.
|
||||
pub fn linked_project_key(root: &Path, dir: &str) -> String {
|
||||
match (dunce::canonicalize(root), dunce::canonicalize(root.join(dir))) {
|
||||
(Ok(root), Ok(path)) => rel(&root, &path),
|
||||
_ => dir.replace('\\', "/"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Linked build identities without reading their scripts (also used by index hot paths).
|
||||
pub(crate) fn linked_dirs(root: &Path, settings: &ide_core::settings::GradleSettings) -> Vec<String> {
|
||||
match &settings.linked_projects {
|
||||
None => build_dir(root).map(|dir| rel(root, &dir)).into_iter().collect(),
|
||||
Some(dirs) => {
|
||||
let mut out = Vec::new();
|
||||
for dir in dirs {
|
||||
if let Ok(dirs) = normalize_linked_projects(root, std::slice::from_ref(dir)) {
|
||||
for dir in dirs {
|
||||
if !out.contains(&dir) {
|
||||
out.push(dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_linked(root: &Path, settings: &ide_core::settings::GradleSettings) -> Vec<GradleModel> {
|
||||
linked_dirs(root, settings).iter().filter_map(|dir| load_build(root, Path::new(dir))).collect()
|
||||
}
|
||||
|
||||
/// A build's task paths are local to it, so sync caches must be local to its identity too.
|
||||
pub fn cache_dir(cache: &Path, build_dir: &str) -> PathBuf {
|
||||
if build_dir.is_empty() {
|
||||
return cache.to_path_buf();
|
||||
}
|
||||
let hash = build_dir.bytes().fold(0xcbf29ce484222325u64, |h, b| (h ^ u64::from(b)).wrapping_mul(0x100000001b3));
|
||||
cache.join("gradle").join(format!("{hash:016x}"))
|
||||
}
|
||||
|
||||
/// `JavaLanguageVersion.of(21)` / `sourceCompatibility = JavaVersion.VERSION_17` in a build script.
|
||||
pub fn script_java_version(text: &str) -> Option<u32> {
|
||||
let number = |s: &str| -> Option<u32> {
|
||||
@@ -616,6 +686,65 @@ pub(crate) fn has_war_plugin(script: &str) -> bool {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn a_path_from_the_ui_finds_the_saved_link() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path().join("workspace");
|
||||
let external = temp.path().join("external");
|
||||
for dir in [root.join("a"), external.clone()] {
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("settings.gradle"), "").unwrap();
|
||||
}
|
||||
let native = |p: &std::path::Path| p.to_string_lossy().into_owned();
|
||||
// Saved by linking; unlinking may send a native, absolute or non-canonical spelling.
|
||||
for (dir, sent) in [
|
||||
("a", native(&root.join("a"))),
|
||||
("a", "a".to_owned()),
|
||||
("a", native(&root.join("a").join("..").join("a"))),
|
||||
("../external", native(&external)),
|
||||
] {
|
||||
let saved = super::normalize_linked_projects(&root, &[dir.into()]).unwrap();
|
||||
assert_eq!(super::linked_project_key(&root, &sent), saved[0], "{sent}");
|
||||
}
|
||||
// A folder deleted since it was linked still matches its saved entry.
|
||||
assert_eq!(super::linked_project_key(&root, r"gone\build"), "gone/build");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_linkage_preserves_default_discovery_and_can_unlink_every_build() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path();
|
||||
for dir in ["a", "b"] {
|
||||
std::fs::create_dir(root.join(dir)).unwrap();
|
||||
std::fs::write(root.join(dir).join("settings.gradle"), format!("rootProject.name = '{dir}'")).unwrap();
|
||||
}
|
||||
assert_eq!(super::load_linked(root, &Default::default())[0].dir, "a");
|
||||
let mut settings = ide_core::settings::GradleSettings {
|
||||
linked_projects: Some(vec!["b".into(), "./b".into(), "a".into()]),
|
||||
..Default::default()
|
||||
};
|
||||
let models = super::load_linked(root, &settings);
|
||||
assert_eq!(models.iter().map(|g| g.dir.as_str()).collect::<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,6 +126,7 @@ fn project_path(text: &str) -> String {
|
||||
/// `configuration` and returns its tree.
|
||||
pub fn gradle_dependencies(
|
||||
root: &Path,
|
||||
model: &gradle::GradleModel,
|
||||
options: &SyncOptions,
|
||||
project: &str,
|
||||
configuration: &str,
|
||||
@@ -137,7 +138,6 @@ pub fn gradle_dependencies(
|
||||
if !project.starts_with(':') || project.chars().any(char::is_whitespace) {
|
||||
return Err(IdeError::invalid(format!("not a Gradle project path: {project}")));
|
||||
}
|
||||
let model = gradle::load(root).ok_or_else(|| IdeError::not_found("no Gradle build in this project"))?;
|
||||
let task = if project == ":" { ":dependencies".to_owned() } else { format!("{project}:dependencies") };
|
||||
let mut args: Vec<String> =
|
||||
vec!["--console=plain".into(), "-q".into(), task, "--configuration".into(), configuration.into()];
|
||||
@@ -226,8 +226,12 @@ runtimeClasspath - Runtime classpath of source set 'main'.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let options = SyncOptions::default();
|
||||
let cancel = CancellationToken::new();
|
||||
assert!(gradle_dependencies(tmp.path(), &options, ":", "x --init-script y", &cancel).is_err());
|
||||
assert!(gradle_dependencies(tmp.path(), &options, "app", "runtimeClasspath", &cancel).is_err());
|
||||
assert!(
|
||||
gradle_dependencies(tmp.path(), &Default::default(), &options, ":", "x --init-script y", &cancel).is_err()
|
||||
);
|
||||
assert!(
|
||||
gradle_dependencies(tmp.path(), &Default::default(), &options, "app", "runtimeClasspath", &cancel).is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
@@ -246,8 +250,15 @@ runtimeClasspath - Runtime classpath of source set 'main'.
|
||||
offline: true,
|
||||
..Default::default()
|
||||
};
|
||||
let tree =
|
||||
gradle_dependencies(root, &options, ":app", "testRuntimeClasspath", &CancellationToken::new()).unwrap();
|
||||
let tree = gradle_dependencies(
|
||||
root,
|
||||
&gradle::load(root).unwrap(),
|
||||
&options,
|
||||
":app",
|
||||
"testRuntimeClasspath",
|
||||
&CancellationToken::new(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(tree, vec![dep("org.a", "b", Some("1.0"))]);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(root.join("args")).unwrap().trim(),
|
||||
|
||||
+91
-16
@@ -20,7 +20,9 @@ mod xml;
|
||||
pub use gradle::{GradleModel, GradleProject, GradleTask};
|
||||
pub use gradle_deps::{GradleDependency, gradle_dependencies};
|
||||
pub use maven::{MavenDependency, MavenModel, MavenPlugin, MavenProfile, MavenProject, Omission};
|
||||
pub use sync::{SyncOptions, SyncProgress, maven_profiles, save_maven_profiles, sync_gradle, sync_maven};
|
||||
pub use sync::{
|
||||
SyncOptions, SyncProgress, maven_profiles, save_maven_profiles, sync_gradle, sync_gradle_model, sync_maven,
|
||||
};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
@@ -38,13 +40,31 @@ pub enum BuildTool {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuildModel {
|
||||
pub maven: Option<MavenModel>,
|
||||
pub gradle: Option<GradleModel>,
|
||||
#[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 })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 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())
|
||||
load_linked(
|
||||
root,
|
||||
cache_dir,
|
||||
maven_repo,
|
||||
&ide_core::settings::MavenSettings::default(),
|
||||
&ide_core::settings::GradleSettings::default(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Loads only linked Maven reactors, retaining an empty model so projects can be re-added.
|
||||
@@ -53,31 +73,66 @@ pub fn load_linked(
|
||||
cache_dir: &Path,
|
||||
maven_repo: Option<PathBuf>,
|
||||
settings: &ide_core::settings::MavenSettings,
|
||||
gradle_settings: &ide_core::settings::GradleSettings,
|
||||
) -> BuildModel {
|
||||
let maven = maven::is_maven_project(root).then(|| {
|
||||
let maven = maven::has_projects(root, settings).then(|| {
|
||||
let profiles = sync::maven_profiles(cache_dir);
|
||||
let mut m = maven::load_with(root, maven_repo.or_else(maven::default_local_repo), &profiles);
|
||||
let repo = maven_repo.or_else(maven::default_local_repo);
|
||||
let mut m = maven::load_with_linked(root, repo, &profiles, &settings.linked_projects);
|
||||
maven::UnlinkedProjects::from_model(&m, settings).filter(&mut m);
|
||||
// The user settings' profiles can be chosen too (`-P` applies to them as well).
|
||||
for p in maven::user_settings_file(root, settings).map(|f| maven::settings_profiles(&f)).unwrap_or_default() {
|
||||
if !m.profiles.iter().any(|x| x.id == p.id) {
|
||||
m.profiles.push(p);
|
||||
}
|
||||
}
|
||||
sync::apply_maven_cache(root, cache_dir, &mut m);
|
||||
m
|
||||
});
|
||||
let gradle = gradle::load(root).map(|mut g| {
|
||||
sync::apply_gradle_cache(root, cache_dir, &mut g);
|
||||
g
|
||||
});
|
||||
let gradle = gradle::load_linked(root, gradle_settings)
|
||||
.into_iter()
|
||||
.map(|mut g| {
|
||||
sync::apply_gradle_cache(root, &gradle::cache_dir(cache_dir, &g.dir), &mut g);
|
||||
g
|
||||
})
|
||||
.collect();
|
||||
BuildModel { maven, gradle }
|
||||
}
|
||||
|
||||
/// Gradle project folders outside `root` as of the last sync (`includeFlat` siblings):
|
||||
/// extra content roots, as IDEA lists module roots beside the project folder.
|
||||
/// Reads only the sync's cached projects (no build script), so it is cheap enough for hot paths.
|
||||
pub fn gradle_external_roots(root: &Path, cache_dir: &Path) -> Vec<PathBuf> {
|
||||
gradle::external_roots(root, sync::reported_gradle_projects(cache_dir).iter().map(|p| p.dir.as_str()))
|
||||
pub fn gradle_external_roots(
|
||||
root: &Path,
|
||||
cache_dir: &Path,
|
||||
settings: &ide_core::settings::GradleSettings,
|
||||
) -> Vec<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))
|
||||
}
|
||||
|
||||
/// Source, resource and build folders of the last Gradle sync, absolute.
|
||||
pub fn gradle_source_roots(cache_dir: &Path) -> Vec<(PathBuf, gradle::ReportedRoot)> {
|
||||
gradle::reported_source_roots(&sync::reported_gradle_projects(cache_dir))
|
||||
pub fn gradle_source_roots(
|
||||
root: &Path,
|
||||
cache_dir: &Path,
|
||||
settings: &ide_core::settings::GradleSettings,
|
||||
) -> Vec<(PathBuf, gradle::ReportedRoot)> {
|
||||
let reported = gradle::linked_dirs(root, settings)
|
||||
.iter()
|
||||
.flat_map(|dir| sync::reported_gradle_projects(&gradle::cache_dir(cache_dir, dir)).as_ref().clone())
|
||||
.collect::<Vec<_>>();
|
||||
gradle::reported_source_roots(&reported)
|
||||
}
|
||||
|
||||
/// A library the project depends on, as `groupId:artifactId:version`.
|
||||
@@ -121,7 +176,7 @@ pub fn dependency_coordinates(root: &Path, model: &BuildModel) -> Vec<Dependency
|
||||
});
|
||||
}
|
||||
}
|
||||
if let Some(g) = &model.gradle {
|
||||
for g in &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)| {
|
||||
@@ -147,6 +202,26 @@ pub fn affects_build(rel: &Path) -> bool {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn external_linked_build_is_indexed_before_sync_and_disappears_when_unlinked() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path().join("workspace");
|
||||
let external = temp.path().join("external");
|
||||
std::fs::create_dir(&root).unwrap();
|
||||
std::fs::create_dir(&external).unwrap();
|
||||
std::fs::write(external.join("settings.gradle"), "").unwrap();
|
||||
let mut settings = ide_core::settings::GradleSettings {
|
||||
linked_projects: Some(vec![external.to_string_lossy().into_owned()]),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
gradle_external_roots(&root, &temp.path().join("cache"), &settings),
|
||||
[dunce::canonicalize(&external).unwrap()]
|
||||
);
|
||||
settings.linked_projects = Some(vec![]);
|
||||
assert!(gradle_external_roots(&root, &temp.path().join("cache"), &settings).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_build_systems_and_build_files() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -158,7 +233,7 @@ mod tests {
|
||||
std::fs::write(root.join("build.gradle"), "").unwrap();
|
||||
let m = load(&root, &cache, None);
|
||||
assert_eq!(m.maven.unwrap().projects[0].pom, "svc/pom.xml");
|
||||
assert_eq!(m.gradle.unwrap().projects[0].path, ":");
|
||||
assert_eq!(m.gradle[0].projects[0].path, ":");
|
||||
assert!(affects_build(Path::new("a/pom.xml")) && affects_build(Path::new("settings.gradle.kts")));
|
||||
assert!(!affects_build(Path::new("src/Main.java")));
|
||||
}
|
||||
@@ -205,7 +280,7 @@ mod tests {
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
let gradle = gradle::load(root);
|
||||
let gradle = gradle::load(root).into_iter().collect();
|
||||
let model = BuildModel { maven: Some(maven), gradle };
|
||||
let names: Vec<String> = dependency_coordinates(root, &model)
|
||||
.into_iter()
|
||||
|
||||
@@ -472,6 +472,41 @@ pub(crate) fn default_local_repo() -> Option<PathBuf> {
|
||||
local_repository_of(&m2.join("settings.xml")).or(Some(m2.join("repository")))
|
||||
}
|
||||
|
||||
/// The user `settings.xml` a build reads: the configured one (`~/` and project-relative
|
||||
/// paths allowed), else `~/.m2/settings.xml`.
|
||||
pub fn user_settings_file(root: &Path, settings: &ide_core::settings::MavenSettings) -> Option<PathBuf> {
|
||||
let configured = settings.user_settings.trim();
|
||||
if configured.is_empty() {
|
||||
return Some(dirs::home_dir()?.join(".m2").join("settings.xml"));
|
||||
}
|
||||
let path = match configured.strip_prefix("~/") {
|
||||
Some(rest) => dirs::home_dir()?.join(rest),
|
||||
None => PathBuf::from(configured),
|
||||
};
|
||||
Some(if path.is_absolute() { path } else { root.join(path) })
|
||||
}
|
||||
|
||||
/// The profiles a `settings.xml` defines, for the Profiles list beside the POMs' own (as
|
||||
/// IDEA lists them): checked by default when `<activeProfiles>` names them or they are
|
||||
/// `activeByDefault`.
|
||||
pub fn settings_profiles(settings: &Path) -> Vec<MavenProfile> {
|
||||
let Some(root) = std::fs::read_to_string(settings).ok().and_then(|text| xml::parse(&text)) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let active: Vec<String> =
|
||||
root.list("activeProfiles", "activeProfile").iter().map(|e| e.text.trim().to_owned()).collect();
|
||||
let mut profiles: Vec<MavenProfile> = Vec::new();
|
||||
for profile in root.list("profiles", "profile") {
|
||||
let Some(id) = profile.text_of("id") else { continue };
|
||||
if profiles.iter().any(|p| p.id == id) {
|
||||
continue;
|
||||
}
|
||||
let by_default = profile.at("activation/activeByDefault").is_some_and(|e| e.text.trim() == "true");
|
||||
profiles.push(MavenProfile { active_by_default: by_default || active.contains(&id), id });
|
||||
}
|
||||
profiles
|
||||
}
|
||||
|
||||
/// `<localRepository>` of a settings file, when it names one: the repository a build
|
||||
/// with `-s <file>` resolves into, which is not necessarily the user's default.
|
||||
pub fn local_repository_of(settings: &Path) -> Option<PathBuf> {
|
||||
@@ -1065,10 +1100,9 @@ pub fn load(root: &Path) -> MavenModel {
|
||||
load_with_repo(root, default_local_repo())
|
||||
}
|
||||
|
||||
/// [`load`] with the chosen `profiles` (`-P` entries; `!id` deactivates): their modules,
|
||||
/// dependencies and properties are part of the model, those turned off are not.
|
||||
pub fn load_with_profiles(root: &Path, profiles: &[String]) -> MavenModel {
|
||||
load_with(root, default_local_repo(), profiles)
|
||||
/// [`load`] with the POMs `settings` link (Add Maven Projects).
|
||||
pub fn load_linked(root: &Path, settings: &ide_core::settings::MavenSettings) -> MavenModel {
|
||||
load_with_linked(root, default_local_repo(), &[], &settings.linked_projects)
|
||||
}
|
||||
|
||||
pub(crate) fn load_with_repo(root: &Path, local_repo: Option<PathBuf>) -> MavenModel {
|
||||
@@ -1090,11 +1124,53 @@ 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();
|
||||
// (pom, aggregated)
|
||||
let mut stack: Vec<(PathBuf, bool)> = top_level_poms(root).into_iter().rev().map(|p| (p, false)).collect();
|
||||
let mut tops = top_level_poms(root);
|
||||
for pom in linked_poms(root, linked) {
|
||||
if !tops.contains(&pom) {
|
||||
tops.push(pom);
|
||||
}
|
||||
}
|
||||
// (pom, aggregated); the root reactor and its modules come before linked POMs.
|
||||
let mut stack: Vec<(PathBuf, bool)> = tops.into_iter().rev().map(|p| (p, false)).collect();
|
||||
while let Some((path, aggregated)) = stack.pop() {
|
||||
if model.projects.len() >= MAX_PROJECTS {
|
||||
break;
|
||||
@@ -1216,7 +1292,7 @@ impl UnlinkedProjects {
|
||||
if settings.unlinked_projects.is_empty() {
|
||||
return Self::default();
|
||||
}
|
||||
Self::from_model(&load(root), settings)
|
||||
Self::from_model(&load_linked(root, settings), settings)
|
||||
}
|
||||
|
||||
pub fn from_model(model: &MavenModel, settings: &ide_core::settings::MavenSettings) -> Self {
|
||||
@@ -1495,6 +1571,37 @@ mod tests {
|
||||
assert_eq!(p.prefix, "checkstyle");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_profiles_are_listed_with_the_active_ones_checked() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let file = tmp.path().join("settings.xml");
|
||||
std::fs::write(
|
||||
&file,
|
||||
r#"<settings>
|
||||
<profiles>
|
||||
<profile><id>nexus</id></profile>
|
||||
<profile><id>jdk21</id><activation><activeByDefault>true</activeByDefault></activation></profile>
|
||||
<profile><id>release</id></profile>
|
||||
<profile><id>nexus</id></profile>
|
||||
<profile><activation/></profile>
|
||||
</profiles>
|
||||
<activeProfiles><activeProfile>nexus</activeProfile></activeProfiles>
|
||||
</settings>"#,
|
||||
)
|
||||
.unwrap();
|
||||
let p = |id: &str, active| MavenProfile { id: id.into(), active_by_default: active };
|
||||
assert_eq!(settings_profiles(&file), [p("nexus", true), p("jdk21", true), p("release", false)]);
|
||||
assert!(settings_profiles(&tmp.path().join("missing.xml")).is_empty());
|
||||
|
||||
let mut s = ide_core::settings::MavenSettings::default();
|
||||
let home = dirs::home_dir().unwrap();
|
||||
assert_eq!(user_settings_file(tmp.path(), &s), Some(home.join(".m2/settings.xml")));
|
||||
s.user_settings = "conf/settings.xml".into();
|
||||
assert_eq!(user_settings_file(tmp.path(), &s), Some(tmp.path().join("conf/settings.xml")));
|
||||
s.user_settings = "~/work/settings.xml".into();
|
||||
assert_eq!(user_settings_file(tmp.path(), &s), Some(home.join("work/settings.xml")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_settings_file_names_the_repository_to_read_goals_from() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -1733,6 +1840,49 @@ mod tests {
|
||||
assert!(model.projects.iter().all(|p| p.command == if cfg!(windows) { "mvn.cmd" } else { "mvn" }));
|
||||
}
|
||||
|
||||
/// Add Maven Projects: a POM inside the project that no reactor lists becomes an
|
||||
/// independent top-level project; one the root reactor lists stays its module.
|
||||
#[test]
|
||||
fn linked_poms_outside_the_reactor_are_top_level_projects() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
write(
|
||||
root,
|
||||
"pom.xml",
|
||||
"<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,7 +283,12 @@ pub fn sync_maven(
|
||||
cancel: &CancellationToken,
|
||||
progress: &dyn Fn(SyncProgress),
|
||||
) -> IdeResult<()> {
|
||||
let mut model = maven::load_with_profiles(root, &options.profiles);
|
||||
let mut model = maven::load_with_linked(
|
||||
root,
|
||||
maven::default_local_repo(),
|
||||
&options.profiles,
|
||||
&options.maven_settings.linked_projects,
|
||||
);
|
||||
maven::UnlinkedProjects::from_model(&model, &options.maven_settings).filter(&mut model);
|
||||
std::fs::create_dir_all(cache_dir)?;
|
||||
let mut cache = MavenCache {
|
||||
@@ -373,8 +378,25 @@ pub fn sync_gradle(
|
||||
cancel: &CancellationToken,
|
||||
progress: &dyn Fn(SyncProgress),
|
||||
) -> IdeResult<()> {
|
||||
let mut model = gradle::load(root).ok_or_else(|| IdeError::not_found("no Gradle build in this project"))?;
|
||||
std::fs::create_dir_all(cache_dir)?;
|
||||
let model = gradle::load(root).ok_or_else(|| IdeError::not_found("no Gradle build in this project"))?;
|
||||
sync_gradle_model(root, cache_dir, &model, options, cancel, progress)
|
||||
}
|
||||
|
||||
/// Syncs this exact linked build; another build's task paths and cache cannot interfere.
|
||||
pub fn sync_gradle_model(
|
||||
root: &Path,
|
||||
cache_dir: &Path,
|
||||
model: &GradleModel,
|
||||
options: &SyncOptions,
|
||||
cancel: &CancellationToken,
|
||||
progress: &dyn Fn(SyncProgress),
|
||||
) -> IdeResult<()> {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let mut model = model.clone();
|
||||
let cache_dir = gradle::cache_dir(cache_dir, &model.dir);
|
||||
std::fs::create_dir_all(&cache_dir)?;
|
||||
let init_script = cache_dir.join("openidea-model.gradle");
|
||||
std::fs::write(&init_script, MODEL_INIT_SCRIPT)?;
|
||||
let mut args: Vec<String> = vec![
|
||||
@@ -389,14 +411,24 @@ pub fn sync_gradle(
|
||||
args.push("--offline".into());
|
||||
}
|
||||
progress(SyncProgress { message: "Loading Gradle tasks".into(), fraction: None });
|
||||
let out = run(
|
||||
let out = match run(
|
||||
options.gradle_command.as_deref().unwrap_or(&model.command),
|
||||
&args,
|
||||
&root.join(&model.dir),
|
||||
&options.env,
|
||||
cancel,
|
||||
|_| {},
|
||||
)?;
|
||||
) {
|
||||
Ok(out) => out,
|
||||
Err(_) if cancel.is_cancelled() => return Err(IdeError::cancelled()),
|
||||
Err(error) if error.kind == ide_core::error::ErrorKind::Unavailable => {
|
||||
Output { success: false, text: error.message }
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let projects = if out.success { parse_reported_projects(&out.text) } else { Vec::new() };
|
||||
// Hash the build files of the reported projects, or the model is stale right after the sync.
|
||||
gradle::merge_reported_projects(root, &mut model, &projects);
|
||||
@@ -407,13 +439,128 @@ pub fn sync_gradle(
|
||||
tasks: if out.success { gradle::parse_tasks(&out.text) } else { Vec::new() },
|
||||
projects,
|
||||
};
|
||||
write_cache(cache_dir, GRADLE_CACHE, &cache)
|
||||
write_cache(&cache_dir, GRADLE_CACHE, &cache)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn unavailable_gradle_launchers_are_cached_without_stopping_other_builds() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path();
|
||||
for dir in ["a", "b"] {
|
||||
std::fs::create_dir(root.join(dir)).unwrap();
|
||||
std::fs::write(root.join(dir).join("build.gradle"), "").unwrap();
|
||||
}
|
||||
let wrapper = root.join("b/gradlew");
|
||||
std::fs::write(&wrapper, "#!/bin/sh\nprintf '%s\\n' 'Build tasks' '-----------' 'healthy - ok'\n").unwrap();
|
||||
std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let settings = ide_core::settings::GradleSettings {
|
||||
linked_projects: Some(vec!["a".into(), "b".into()]),
|
||||
..Default::default()
|
||||
};
|
||||
let mut models = gradle::load_linked(root, &settings);
|
||||
models[0].command = root.join("a/missing-gradlew").to_string_lossy().into_owned();
|
||||
let cache = root.join("cache");
|
||||
let run = |model: &GradleModel| {
|
||||
sync_gradle_model(root, &cache, model, &SyncOptions::default(), &CancellationToken::new(), &|_| {})
|
||||
};
|
||||
for model in &models {
|
||||
run(model).unwrap();
|
||||
}
|
||||
let loaded = crate::load_linked(root, &cache, None, &Default::default(), &settings);
|
||||
assert!(loaded.gradle[0].sync_error.as_deref().unwrap().contains("cannot start"));
|
||||
assert!(loaded.gradle[1].sync_error.is_none());
|
||||
assert_eq!(loaded.gradle[1].projects[0].tasks[0].name, "healthy");
|
||||
let bad_wrapper = root.join("a/gradlew");
|
||||
std::fs::write(&bad_wrapper, "#!/bin/sh\nexit 0\n").unwrap();
|
||||
std::fs::set_permissions(&bad_wrapper, std::fs::Permissions::from_mode(0o644)).unwrap();
|
||||
models[0].command = bad_wrapper.to_string_lossy().into_owned();
|
||||
run(&models[0]).unwrap();
|
||||
let loaded = crate::load_linked(root, &cache, None, &Default::default(), &settings);
|
||||
assert!(loaded.gradle[0].sync_error.as_deref().unwrap().contains("cannot start"));
|
||||
assert!(loaded.gradle[1].sync_error.is_none());
|
||||
let blocked_cache = root.join("blocked-cache");
|
||||
std::fs::write(&blocked_cache, "a file cannot contain sync caches").unwrap();
|
||||
assert!(
|
||||
sync_gradle_model(
|
||||
root,
|
||||
&blocked_cache,
|
||||
&models[0],
|
||||
&SyncOptions::default(),
|
||||
&CancellationToken::new(),
|
||||
&|_| {}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn independent_syncs_do_not_share_tasks_launchers_environments_or_failures() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path();
|
||||
for dir in ["a", "b"] {
|
||||
let build = root.join(dir);
|
||||
std::fs::create_dir_all(build.join("app")).unwrap();
|
||||
std::fs::write(build.join("settings.gradle"), "include 'app'").unwrap();
|
||||
std::fs::write(build.join("app/build.gradle"), "").unwrap();
|
||||
let report = serde_json::json!({"path": ":app", "dir": build.join("app")});
|
||||
let launcher = build.join("gradlew");
|
||||
std::fs::write(
|
||||
&launcher,
|
||||
format!("#!/bin/sh\npwd > actual-cwd\nprintf '%s' \"$JAVA_HOME\" > actual-env\nprintf '%s\\n' '@openidea-model\t{report}' 'Build tasks' '-----------' 'app:custom{dir} - custom task'\n"),
|
||||
).unwrap();
|
||||
std::fs::set_permissions(launcher, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
}
|
||||
let settings = ide_core::settings::GradleSettings {
|
||||
linked_projects: Some(vec!["a".into(), "b".into()]),
|
||||
..Default::default()
|
||||
};
|
||||
let cache = root.join("cache");
|
||||
let models = gradle::load_linked(root, &settings);
|
||||
for model in &models {
|
||||
let options = SyncOptions {
|
||||
env: BTreeMap::from([("JAVA_HOME".into(), format!("/jdk-{}", model.dir))]),
|
||||
..Default::default()
|
||||
};
|
||||
sync_gradle_model(root, &cache, model, &options, &CancellationToken::new(), &|_| {}).unwrap();
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(root.join(&model.dir).join("actual-env")).unwrap(),
|
||||
format!("/jdk-{}", model.dir)
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(root.join(&model.dir).join("actual-cwd")).unwrap().trim(),
|
||||
root.join(&model.dir).canonicalize().unwrap().to_string_lossy()
|
||||
);
|
||||
}
|
||||
let load = || crate::load_linked(root, &cache, None, &Default::default(), &settings);
|
||||
let model = load();
|
||||
assert_eq!(model.gradle[0].projects[0].tasks[0].name, "customa");
|
||||
assert_eq!(model.gradle[1].projects[0].tasks[0].name, "customb");
|
||||
assert_ne!(gradle::cache_dir(&cache, "a"), gradle::cache_dir(&cache, "b"));
|
||||
let before = std::fs::read(gradle::cache_dir(&cache, "a").join(GRADLE_CACHE)).unwrap();
|
||||
std::fs::write(root.join("b/gradlew"), "#!/bin/sh\nprintf 'FAILURE: broken b\\n'\nexit 1\n").unwrap();
|
||||
sync_gradle_model(root, &cache, &models[1], &SyncOptions::default(), &CancellationToken::new(), &|_| {})
|
||||
.unwrap();
|
||||
let model = load();
|
||||
assert!(model.gradle[0].sync_error.is_none());
|
||||
assert!(model.gradle[1].sync_error.is_some());
|
||||
assert_eq!(std::fs::read(gradle::cache_dir(&cache, "a").join(GRADLE_CACHE)).unwrap(), before);
|
||||
let cancel = CancellationToken::new();
|
||||
cancel.cancel();
|
||||
assert!(sync_gradle_model(root, &cache, &models[0], &SyncOptions::default(), &cancel, &|_| {}).is_err());
|
||||
assert_eq!(std::fs::read(gradle::cache_dir(&cache, "a").join(GRADLE_CACHE)).unwrap(), before);
|
||||
let unlinked = ide_core::settings::GradleSettings { linked_projects: Some(vec![]), ..Default::default() };
|
||||
assert!(crate::load_linked(root, &cache, None, &Default::default(), &unlinked).gradle.is_empty());
|
||||
assert!(crate::gradle_source_roots(root, &cache, &unlinked).is_empty());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn gradle_sync_uses_resolved_command_and_environment() {
|
||||
@@ -601,7 +748,7 @@ mod tests {
|
||||
write(&projects);
|
||||
let first = reported_gradle_projects(&cache_dir);
|
||||
assert!(Arc::ptr_eq(&first, &reported_gradle_projects(&cache_dir)), "unchanged: not parsed again");
|
||||
assert_eq!(crate::gradle_external_roots(&root, &cache_dir), [billing]);
|
||||
assert_eq!(crate::gradle_external_roots(&root, &cache_dir, &Default::default()), [billing]);
|
||||
write(&projects[..1]);
|
||||
assert_eq!(reported_gradle_projects(&cache_dir).len(), 1, "a new sync is read");
|
||||
std::fs::remove_file(cache_dir.join(GRADLE_CACHE)).unwrap();
|
||||
|
||||
@@ -26,12 +26,12 @@ pub fn artifacts(root: &Path, model: &BuildModel) -> Vec<WebArtifact> {
|
||||
});
|
||||
}
|
||||
}
|
||||
if let Some(gradle) = &model.gradle {
|
||||
for gradle in &model.gradle {
|
||||
for p in &gradle.projects {
|
||||
let script = p.build_file.as_ref().and_then(|f| fs::read_to_string(root.join(f)).ok()).unwrap_or_default();
|
||||
if p.tasks.iter().any(|t| t.name == "war") || crate::gradle::has_war_plugin(&script) {
|
||||
out.push(WebArtifact {
|
||||
id: format!("gradle:{}", p.path),
|
||||
id: gradle_artifact_id(gradle, &p.path),
|
||||
name: p.name.clone(),
|
||||
module: p.dir.clone(),
|
||||
tool: "gradle".into(),
|
||||
@@ -42,6 +42,14 @@ pub fn artifacts(root: &Path, model: &BuildModel) -> Vec<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>,
|
||||
@@ -150,9 +158,14 @@ pub fn build(root: &Path, model: &BuildModel, id: &str, cache: &Path) -> IdeResu
|
||||
maven_project: Some((probe, output)),
|
||||
});
|
||||
}
|
||||
if let Some(path) = id.strip_prefix("gradle:") {
|
||||
let g = model.gradle.as_ref().ok_or_else(|| IdeError::invalid("Choose a Gradle WAR module"))?;
|
||||
if !artifacts(root, model).iter().any(|a| a.id == id) {
|
||||
if let Some(legacy_path) = id.strip_prefix("gradle:") {
|
||||
let identity = serde_json::from_str::<(String, String)>(legacy_path).ok();
|
||||
let (g, path) = match &identity {
|
||||
Some((dir, path)) => (model.gradle.iter().find(|g| g.dir == *dir), path.as_str()),
|
||||
None => (model.gradle.first(), legacy_path),
|
||||
};
|
||||
let g = g.ok_or_else(|| IdeError::invalid("Choose a Gradle WAR module"))?;
|
||||
if !artifacts(root, model).iter().any(|a| a.id == gradle_artifact_id(g, path)) {
|
||||
return Err(IdeError::invalid("Choose a Gradle WAR module"));
|
||||
}
|
||||
let script = cache.join("war.init.gradle");
|
||||
@@ -186,6 +199,28 @@ pub fn build(root: &Path, model: &BuildModel, id: &str, cache: &Path) -> IdeResu
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn independent_war_modules_have_distinct_ids_and_build_in_their_own_directory() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path();
|
||||
for dir in ["a", "b"] {
|
||||
std::fs::create_dir(root.join(dir)).unwrap();
|
||||
std::fs::write(root.join(dir).join("build.gradle"), "plugins { id 'war' }").unwrap();
|
||||
}
|
||||
let settings = ide_core::settings::GradleSettings {
|
||||
linked_projects: Some(vec!["a".into(), "b".into()]),
|
||||
..Default::default()
|
||||
};
|
||||
let model = crate::load_linked(root, &root.join("cache"), None, &Default::default(), &settings);
|
||||
let artifacts = super::artifacts(root, &model);
|
||||
assert_eq!(artifacts.len(), 2);
|
||||
assert_ne!(artifacts[0].id, artifacts[1].id);
|
||||
for (artifact, dir) in artifacts.iter().zip(["a", "b"]) {
|
||||
let plan = super::build(root, &model, &artifact.id, &root.join(format!("cache/{dir}"))).unwrap();
|
||||
assert_eq!(plan.cwd, dir);
|
||||
}
|
||||
assert_eq!(super::build(root, &model, "gradle::", &root.join("legacy")).unwrap().cwd, "a");
|
||||
}
|
||||
use super::*;
|
||||
#[test]
|
||||
fn maven_war_modules_use_resolved_artifact_file() {
|
||||
|
||||
@@ -24,6 +24,7 @@ pub mod text;
|
||||
pub mod tool_path;
|
||||
pub mod version_managers;
|
||||
pub mod workflow_settings;
|
||||
pub mod wsl;
|
||||
|
||||
pub use error::{IdeError, IdeResult};
|
||||
pub use event::{EventSink, IdeEvent, NullSink, SharedSink};
|
||||
|
||||
@@ -828,6 +828,8 @@ pub struct JavaSettings {
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct GradleSettings {
|
||||
/// Linked build directories; absent preserves automatic discovery, empty unlinks all builds.
|
||||
pub linked_projects: Option<Vec<String>>,
|
||||
/// Empty = Project JDK; otherwise a JDK home.
|
||||
pub jvm: String,
|
||||
pub distribution: GradleDistribution,
|
||||
@@ -864,6 +866,8 @@ pub enum GradleDistribution {
|
||||
pub struct MavenSettings {
|
||||
/// Project-relative root POMs removed from the Maven project model.
|
||||
pub unlinked_projects: Vec<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`.
|
||||
@@ -1124,6 +1128,16 @@ mod maven_linkage_tests {
|
||||
mod gradle_settings_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn absent_and_explicitly_empty_gradle_links_remain_distinct() {
|
||||
let automatic: GradleSettings = serde_json::from_str("{}").unwrap();
|
||||
let unlinked: GradleSettings = serde_json::from_str(r#"{"linkedProjects":[]}"#).unwrap();
|
||||
assert_eq!(automatic.linked_projects, None);
|
||||
assert_eq!(unlinked.linked_projects, Some(vec![]));
|
||||
let restored: GradleSettings = serde_json::from_str(&serde_json::to_string(&unlinked).unwrap()).unwrap();
|
||||
assert_eq!(restored.linked_projects, Some(vec![]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_and_round_trip() {
|
||||
let old: JavaSettings = serde_json::from_str(r#"{"jdk":"/jdk"}"#).unwrap();
|
||||
@@ -1136,6 +1150,7 @@ mod gradle_settings_tests {
|
||||
home: "/gradle".into(),
|
||||
user_home: "/cache/gradle".into(),
|
||||
run_using: GradleRunUsing::Ide,
|
||||
linked_projects: Some(vec!["backend".into(), "../shared".into()]),
|
||||
};
|
||||
let restored: Settings = serde_json::from_str(&serde_json::to_string(&settings).unwrap()).unwrap();
|
||||
assert_eq!(restored.languages.java.gradle, settings.languages.java.gradle);
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
//! Projects that live inside a WSL distro are reached from Windows through the
|
||||
//! `\\wsl.localhost\<distro>` (or legacy `\\wsl$\<distro>`) share.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// The WSL launcher on Windows.
|
||||
pub const EXE: &str = "wsl.exe";
|
||||
|
||||
/// A location inside a WSL distro.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct WslPath {
|
||||
pub distro: String,
|
||||
/// Absolute Linux path, e.g. `/home/me/app`.
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
/// Splits a WSL share path into distro and Linux path. Pure string work, so it
|
||||
/// accepts every spelling the UI and the OS produce: `\\wsl.localhost\X\a`,
|
||||
/// `//wsl$/X/a` and the verbatim `\\?\UNC\wsl.localhost\X\a`.
|
||||
pub fn parse(path: &Path) -> Option<WslPath> {
|
||||
let s = path.to_string_lossy().replace('\\', "/");
|
||||
let s = match s.strip_prefix("//?/UNC/") {
|
||||
Some(rest) => format!("//{rest}"),
|
||||
None => s,
|
||||
};
|
||||
let rest = s.strip_prefix("//")?;
|
||||
let (host, rest) = rest.split_once('/')?;
|
||||
if !host.eq_ignore_ascii_case("wsl.localhost") && !host.eq_ignore_ascii_case("wsl$") {
|
||||
return None;
|
||||
}
|
||||
let (distro, tail) = rest.split_once('/').unwrap_or((rest, ""));
|
||||
if distro.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(WslPath { distro: distro.to_owned(), path: format!("/{}", tail.trim_end_matches('/')) })
|
||||
}
|
||||
|
||||
/// A distro name `wsl.exe -d` can be given safely: Windows creates them from letters, digits,
|
||||
/// `.`, `_` and `-`. A leading `-` would be read as an option, so it is refused too.
|
||||
pub fn is_valid_distro(name: &str) -> bool {
|
||||
!name.is_empty()
|
||||
&& name.len() <= 64
|
||||
&& !name.starts_with('-')
|
||||
&& name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
|
||||
}
|
||||
|
||||
/// Text of `wsl.exe`'s own output: UTF-16LE (with or without BOM) for its management commands
|
||||
/// and its error messages, UTF-8 for what a command inside the distro prints.
|
||||
pub fn decode_output(raw: &[u8]) -> String {
|
||||
if raw.starts_with(&[0xff, 0xfe]) || raw.get(1) == Some(&0) {
|
||||
let units: Vec<u16> = raw.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes(*c)).collect();
|
||||
String::from_utf16_lossy(&units)
|
||||
} else {
|
||||
String::from_utf8_lossy(raw).into_owned()
|
||||
}
|
||||
}
|
||||
|
||||
/// Distro names from the output of `wsl.exe -l -q`. That output is UTF-16LE (UTF-8 when
|
||||
/// `WSL_UTF8=1`), one name per line; names that fail [`is_valid_distro`] are dropped, which also
|
||||
/// removes the localized "no installed distributions" message `wsl.exe` prints when there are none.
|
||||
pub fn parse_distro_list(raw: &[u8]) -> Vec<String> {
|
||||
let text = decode_output(raw);
|
||||
let mut names: Vec<String> = Vec::new();
|
||||
for line in text.lines() {
|
||||
let name = line.trim_matches(|c: char| c.is_whitespace() || c == '\u{feff}');
|
||||
if is_valid_distro(name) && !names.iter().any(|n| n == name) {
|
||||
names.push(name.to_owned());
|
||||
}
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
/// Installed WSL distros. Empty off Windows, when WSL is not installed or when `wsl.exe` fails.
|
||||
pub fn list_distros() -> Vec<String> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let mut cmd = std::process::Command::new(EXE);
|
||||
cmd.args(["-l", "-q"]);
|
||||
crate::process::prepare(&mut cmd);
|
||||
match cmd.output() {
|
||||
Ok(out) if out.status.success() => parse_distro_list(&out.stdout),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn p(s: &str) -> Option<(String, String)> {
|
||||
parse(Path::new(s)).map(|w| (w.distro, w.path))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_spelling_of_the_share_is_recognised() {
|
||||
let want = Some(("Ubuntu-24.04".to_owned(), "/home/me/app".to_owned()));
|
||||
assert_eq!(p(r"\\wsl.localhost\Ubuntu-24.04\home\me\app"), want);
|
||||
assert_eq!(p("//wsl.localhost/Ubuntu-24.04/home/me/app"), want);
|
||||
assert_eq!(p(r"\\wsl$\Ubuntu-24.04\home\me\app\"), want);
|
||||
assert_eq!(p(r"\\?\UNC\WSL.LOCALHOST\Ubuntu-24.04\home\me\app"), want);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distro_root_and_foreign_paths() {
|
||||
assert_eq!(p(r"\\wsl.localhost\Debian"), Some(("Debian".to_owned(), "/".to_owned())));
|
||||
assert_eq!(p(r"\\server\share\a"), None);
|
||||
assert_eq!(p(r"\\wsl.localhost\"), None);
|
||||
assert_eq!(p("C:/Users/me"), None);
|
||||
assert_eq!(p("/home/me"), None);
|
||||
}
|
||||
|
||||
fn utf16(s: &str) -> Vec<u8> {
|
||||
std::iter::once(0xfeffu16).chain(s.encode_utf16()).flat_map(u16::to_le_bytes).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distro_list_is_decoded_from_utf16_and_utf8() {
|
||||
let want = vec!["Ubuntu-24.04".to_owned(), "Debian".to_owned()];
|
||||
assert_eq!(parse_distro_list(&utf16("Ubuntu-24.04\r\nDebian\r\n\r\n")), want);
|
||||
assert_eq!(parse_distro_list(b"Ubuntu-24.04\nDebian\nDebian\n"), want);
|
||||
assert!(
|
||||
parse_distro_list(&utf16("Windows Subsystem for Linux has no installed distributions.\r\n")).is_empty()
|
||||
);
|
||||
assert!(parse_distro_list(b"").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distro_names_cannot_smuggle_options_or_paths() {
|
||||
for bad in ["", "-d", "--exec", "a b", "a/b", r"a\b", "a;b", "a$(x)", "a\"b"] {
|
||||
assert!(!is_valid_distro(bad), "{bad}");
|
||||
}
|
||||
assert!(is_valid_distro("Ubuntu-22.04") && is_valid_distro("docker_desktop"));
|
||||
}
|
||||
}
|
||||
@@ -72,7 +72,13 @@ fn builtin(adapter: &str) -> Option<DebuggerConfig> {
|
||||
"stdio",
|
||||
"Install the Kotlin Debug Adapter from Settings > Language Servers",
|
||||
),
|
||||
"debugpy" => cfg("python3", &["-m", "debugpy.adapter"], "stdio", "pip install debugpy"),
|
||||
// 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",
|
||||
),
|
||||
"js-debug" => cfg(
|
||||
"js-debug-adapter",
|
||||
// Without a host it listens on `localhost`, which may resolve to ::1 only.
|
||||
|
||||
@@ -302,6 +302,23 @@ pub struct DebugManager {
|
||||
data_breakpoints: Mutex<Vec<DataBreakpoint>>,
|
||||
}
|
||||
|
||||
/// Whether the debugged project depends on the Flutter SDK, so it must go
|
||||
/// through `flutter debug_adapter` instead of the plain Dart one.
|
||||
///
|
||||
/// `dart debug_adapter` cannot start a program that imports `dart:ui`, and only
|
||||
/// the Flutter adapter answers `hotReload` / `hotRestart`. `dir` is the launch
|
||||
/// `cwd`; the program's own directory is tried first because a module inside a
|
||||
/// larger repository has its `pubspec.yaml` above the program, not above `cwd`.
|
||||
fn is_flutter_project(dir: &Path, program: Option<&Path>) -> bool {
|
||||
let mut start: Vec<&Path> = program.and_then(Path::parent).into_iter().collect();
|
||||
start.push(dir);
|
||||
start
|
||||
.iter()
|
||||
.filter_map(|d| d.ancestors().take(6).find(|a| a.join("pubspec.yaml").is_file()))
|
||||
.filter_map(|root| std::fs::read_to_string(root.join("pubspec.yaml")).ok())
|
||||
.any(|s| s.contains("sdk: flutter"))
|
||||
}
|
||||
|
||||
fn no_session(s: DebugSessionId) -> IdeError {
|
||||
IdeError::not_found(format!("no debug session {}", s.0))
|
||||
}
|
||||
@@ -570,6 +587,10 @@ impl DebugManager {
|
||||
custom_command: impl FnOnce(&str) -> Option<String>,
|
||||
) -> IdeResult<DebugSessionId> {
|
||||
let mut arguments = launch_arguments(&config)?;
|
||||
// An IDE-side marker (never sent to the adapter): the session is a Flutter
|
||||
// one regardless of what the working directory holds.
|
||||
let force_flutter =
|
||||
arguments.as_object_mut().and_then(|o| o.remove("__flutter")).and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
let cwd = config
|
||||
.cwd
|
||||
.clone()
|
||||
@@ -601,6 +622,15 @@ impl DebugManager {
|
||||
spec.command = python;
|
||||
}
|
||||
}
|
||||
// Flutter: a project depending on the Flutter SDK needs `flutter debug_adapter`
|
||||
// (the Dart adapter cannot start a program importing `dart:ui`, and hot reload
|
||||
// is Flutter-only). Plain Dart projects keep the configured `dart`.
|
||||
if config.adapter == "dart" && spec.command == "dart" {
|
||||
let program = arguments.get("program").and_then(Value::as_str).map(Path::new);
|
||||
if force_flutter || is_flutter_project(&cwd, program) {
|
||||
spec.command = "flutter".into();
|
||||
}
|
||||
}
|
||||
let id = DebugSessionId::next();
|
||||
let debug_server = take_debug_server(&mut arguments)?;
|
||||
if let Some(server) = &debug_server {
|
||||
@@ -1236,6 +1266,19 @@ impl DebugManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Flutter's DAP adapter: injects the changed sources into the running app.
|
||||
/// Not part of the DAP standard; adapters that do not know the request reject it.
|
||||
pub async fn hot_reload(&self, s: DebugSessionId) -> IdeResult<()> {
|
||||
self.session(s)?.conn.request_timeout("hotReload", json!({ "reason": "manual" }), REQUEST_TIMEOUT).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Flutter's DAP adapter: restarts the app without preserving state.
|
||||
pub async fn hot_restart(&self, s: DebugSessionId) -> IdeResult<()> {
|
||||
self.session(s)?.conn.request_timeout("hotRestart", json!({ "reason": "manual" }), REQUEST_TIMEOUT).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn step(&self, s: DebugSessionId, thread_id: i64, kind: StepKind) -> IdeResult<()> {
|
||||
let command = match kind {
|
||||
StepKind::Over => "next",
|
||||
|
||||
@@ -1269,3 +1269,19 @@ async fn a_plugin_language_runs_its_debug_adapter_from_the_approved_program() {
|
||||
assert_eq!(err.kind, ide_core::error::ErrorKind::PermissionDenied, "{}", err.message);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flutter_projects_are_found_from_the_program_or_the_working_directory() {
|
||||
let root = std::env::temp_dir().join(format!("openidea-flutter-{}", std::process::id()));
|
||||
let app = root.join("app");
|
||||
let tool = root.join("tool");
|
||||
std::fs::create_dir_all(app.join("lib")).unwrap();
|
||||
std::fs::create_dir_all(tool.join("bin")).unwrap();
|
||||
std::fs::write(app.join("pubspec.yaml"), "name: app\ndependencies:\n flutter:\n sdk: flutter\n").unwrap();
|
||||
std::fs::write(tool.join("pubspec.yaml"), "name: tool\ndependencies:\n args: ^2.0.0\n").unwrap();
|
||||
assert!(is_flutter_project(&app, None));
|
||||
assert!(is_flutter_project(&root, Some(&app.join("lib/main.dart"))));
|
||||
assert!(!is_flutter_project(&tool, Some(&tool.join("bin/main.dart"))));
|
||||
assert!(!is_flutter_project(&root, None));
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
}
|
||||
@@ -150,6 +150,7 @@ impl ClickhousePool {
|
||||
self.0.client.read().clone().ok_or_else(|| IdeError::new(ErrorKind::Io, "ClickHouse connection is closed"))
|
||||
}
|
||||
|
||||
/// `max_rows`: the rows the server may send (the skipped ones included).
|
||||
fn request(&self, client: &Client, query_id: &str, max_rows: usize, initialized: bool) -> RequestBuilder {
|
||||
let mut request = client
|
||||
.post(self.0.endpoint.clone())
|
||||
@@ -177,6 +178,17 @@ impl ClickhousePool {
|
||||
}
|
||||
|
||||
pub async fn execute(&self, sql: &str, max_rows: usize, cancel: &CancellationToken) -> IdeResult<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());
|
||||
}
|
||||
@@ -188,7 +200,7 @@ impl ClickhousePool {
|
||||
};
|
||||
let client = self.client()?;
|
||||
let query_id = uuid::Uuid::new_v4().to_string();
|
||||
let request = self.request(&client, &query_id, max_rows, *session).body(sql.to_owned());
|
||||
let request = self.request(&client, &query_id, skip.saturating_add(max_rows), *session).body(sql.to_owned());
|
||||
let started = Instant::now();
|
||||
let fetch = async {
|
||||
let mut response = request.send().await.map_err(http_error)?;
|
||||
@@ -216,7 +228,7 @@ impl ClickhousePool {
|
||||
}
|
||||
(Vec::new(), Vec::new(), false)
|
||||
} else {
|
||||
let mut reader = RowReader::new(max_rows);
|
||||
let mut reader = RowReader::new(skip, max_rows);
|
||||
while let Some(chunk) = response.chunk().await.map_err(http_error)? {
|
||||
reader.push(&chunk)?;
|
||||
}
|
||||
@@ -309,11 +321,13 @@ impl ClickhousePool {
|
||||
|
||||
type DecodedResult = (Vec<ResultColumn>, Vec<Vec<Option<String>>>, bool);
|
||||
|
||||
/// Decodes a streamed [`FORMAT`] body. Keeps the first `max_rows` rows; later rows only set
|
||||
/// `has_more` and are read past unparsed, so a grid limit bounds memory however large the
|
||||
/// block `result_overflow_mode=break` lets the server send. A line that is not a JSON array
|
||||
/// Decodes a streamed [`FORMAT`] body. Reads past the first `skip` rows unparsed (a later
|
||||
/// page), then keeps `max_rows` rows; later rows only set `has_more` and are read past
|
||||
/// unparsed, so a grid limit bounds memory however large the block
|
||||
/// `result_overflow_mode=break` lets the server send. A line that is not a JSON array
|
||||
/// starts the exception ClickHouse writes when a query fails mid-stream.
|
||||
struct RowReader {
|
||||
skip: usize,
|
||||
max_rows: usize,
|
||||
pending: Vec<u8>,
|
||||
names: Option<Vec<String>>,
|
||||
@@ -324,8 +338,17 @@ struct RowReader {
|
||||
}
|
||||
|
||||
impl RowReader {
|
||||
fn new(max_rows: usize) -> Self {
|
||||
Self { max_rows, pending: Vec::new(), names: None, types: None, rows: Vec::new(), more: false, exception: None }
|
||||
fn new(skip: usize, max_rows: usize) -> Self {
|
||||
Self {
|
||||
skip,
|
||||
max_rows,
|
||||
pending: Vec::new(),
|
||||
names: None,
|
||||
types: None,
|
||||
rows: Vec::new(),
|
||||
more: false,
|
||||
exception: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn push(&mut self, chunk: &[u8]) -> IdeResult<()> {
|
||||
@@ -356,9 +379,15 @@ impl RowReader {
|
||||
self.exception = Some(format!("{}\n", String::from_utf8_lossy(line)));
|
||||
return Ok(());
|
||||
}
|
||||
if self.names.is_some() && self.types.is_some() && self.rows.len() >= self.max_rows {
|
||||
self.more = true;
|
||||
return Ok(());
|
||||
if self.names.is_some() && self.types.is_some() {
|
||||
if self.skip > 0 {
|
||||
self.skip -= 1;
|
||||
return Ok(());
|
||||
}
|
||||
if self.rows.len() >= self.max_rows {
|
||||
self.more = true;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
let Ok(values) = serde_json::from_slice::<Vec<Box<serde_json::value::RawValue>>>(text) else {
|
||||
self.exception = Some(format!("{}\n", String::from_utf8_lossy(line)));
|
||||
@@ -547,7 +576,11 @@ mod tests {
|
||||
}
|
||||
|
||||
fn decode(body: &[u8], max_rows: usize, chunk: usize) -> IdeResult<DecodedResult> {
|
||||
let mut reader = RowReader::new(max_rows);
|
||||
decode_from(body, 0, max_rows, chunk)
|
||||
}
|
||||
|
||||
fn decode_from(body: &[u8], skip: usize, max_rows: usize, chunk: usize) -> IdeResult<DecodedResult> {
|
||||
let mut reader = RowReader::new(skip, max_rows);
|
||||
for part in body.chunks(chunk) {
|
||||
reader.push(part)?;
|
||||
}
|
||||
@@ -586,6 +619,18 @@ mod tests {
|
||||
assert!(decode(b"[\"x\"]\n[\"UInt8\"]\n[1, 2]\n", 1, 64).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_later_page_skips_rows_without_keeping_them() {
|
||||
let body = b"[\"n\"]\n[\"UInt8\"]\n[1]\n[2]\n[3]\n[4]\n[5]\n";
|
||||
let page = |skip, max| {
|
||||
let (_, rows, more) = decode_from(body, skip, max, 3).unwrap();
|
||||
(rows.into_iter().map(|r| r[0].clone().unwrap()).collect::<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");
|
||||
|
||||
+49
-10
@@ -337,10 +337,13 @@ fn result_columns<R: Row>(row: &R) -> Vec<ResultColumn> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Streams the results of `$sql` on executor `$exec`, keeping at most `$max` rows
|
||||
/// with their cells read as `$mode`.
|
||||
/// Streams the results of `$sql` on executor `$exec`, reading past the first `$skip`
|
||||
/// rows without decoding them, then keeping at most `$max` rows with their cells read as `$mode`.
|
||||
macro_rules! fetch {
|
||||
($exec:expr, $sql:expr, $max:expr, $mode:expr) => {{
|
||||
($exec:expr, $sql:expr, $max:expr, $mode:expr) => {
|
||||
fetch!($exec, $sql, 0usize, $max, $mode)
|
||||
};
|
||||
($exec:expr, $sql:expr, $skip:expr, $max:expr, $mode:expr) => {{
|
||||
use futures::StreamExt;
|
||||
let mut stream = sqlx::raw_sql(sqlx::AssertSqlSafe($sql.to_owned())).fetch_many($exec);
|
||||
let mut columns: Vec<ResultColumn> = Vec::new();
|
||||
@@ -349,6 +352,7 @@ 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 {
|
||||
@@ -357,6 +361,10 @@ macro_rules! fetch {
|
||||
if columns.is_empty() {
|
||||
columns = result_columns(&row);
|
||||
}
|
||||
if to_skip > 0 {
|
||||
to_skip -= 1;
|
||||
continue;
|
||||
}
|
||||
if rows.len() >= $max {
|
||||
has_more = true;
|
||||
break;
|
||||
@@ -386,7 +394,8 @@ async fn mssql_command(conn: &mut MssqlClient, sql: &str) -> IdeResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn mssql_fetch(conn: &mut MssqlClient, sql: &str, max: usize) -> IdeResult<Fetched> {
|
||||
/// 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> {
|
||||
let mut stream = conn.simple_query(sql).await.map_err(mssql_err)?;
|
||||
let first = Some(Instant::now());
|
||||
let columns = stream
|
||||
@@ -399,8 +408,13 @@ async fn mssql_fetch(conn: &mut MssqlClient, sql: &str, max: usize) -> IdeResult
|
||||
.collect();
|
||||
let mut rows = Vec::new();
|
||||
let mut has_more = false;
|
||||
let mut to_skip = skip;
|
||||
while let Some(item) = stream.next().await {
|
||||
if let tiberius::QueryItem::Row(row) = item.map_err(mssql_err)? {
|
||||
if to_skip > 0 {
|
||||
to_skip -= 1;
|
||||
continue;
|
||||
}
|
||||
if rows.len() >= max {
|
||||
has_more = true;
|
||||
break;
|
||||
@@ -734,7 +748,7 @@ impl Pool {
|
||||
let fetched: IdeResult<Fetched> = match self {
|
||||
Pool::SqlServer(p) => {
|
||||
let mut conn = p.get_owned().await.map_err(mssql_pool_err)?;
|
||||
mssql_fetch(&mut conn, sql, max).await
|
||||
mssql_fetch(&mut conn, sql, 0, max).await
|
||||
}
|
||||
_ => with_pool!(self, p => fetch!(p, sql, max, Cells::Text)),
|
||||
};
|
||||
@@ -744,11 +758,23 @@ impl Pool {
|
||||
/// Runs one statement and keeps at most `max_rows` rows. Cancelling stops
|
||||
/// the statement on the server (PostgreSQL, MySQL) and drops the connection.
|
||||
pub async fn execute(&self, sql: &str, max_rows: usize, cancel: &CancellationToken) -> IdeResult<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(sql, max_rows, cancel).await;
|
||||
return p.execute_from(sql, skip, max_rows, cancel).await;
|
||||
}
|
||||
let mut conn = Held::acquire(self).await?;
|
||||
let r = conn.execute(self, sql, max_rows, cancel).await;
|
||||
let r = conn.execute_from(self, sql, skip, max_rows, cancel).await;
|
||||
if cancel.is_cancelled() {
|
||||
conn.discard().await;
|
||||
return Err(IdeError::cancelled());
|
||||
@@ -874,9 +900,22 @@ impl Held {
|
||||
sql: &str,
|
||||
max_rows: usize,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<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(sql, max_rows, cancel).await;
|
||||
return c.execute_from(sql, skip, max_rows, cancel).await;
|
||||
}
|
||||
let started = Instant::now();
|
||||
// `$id`: the query naming the server process, `$kill`: how another connection stops it.
|
||||
@@ -887,7 +926,7 @@ impl Held {
|
||||
None => None,
|
||||
};
|
||||
let r = tokio::select! {
|
||||
r = async { fetch!(&mut **$conn, sql, max_rows, Cells::Display) } => r,
|
||||
r = async { fetch!(&mut **$conn, sql, skip, max_rows, Cells::Display) } => r,
|
||||
() = cancel.cancelled() => Err(IdeError::cancelled()),
|
||||
};
|
||||
if cancel.is_cancelled() {
|
||||
@@ -916,7 +955,7 @@ impl Held {
|
||||
Held::SqlServer(c) => {
|
||||
// Tiberius has no attention/cancel API; stop reading and discard the connection.
|
||||
tokio::select! {
|
||||
r = mssql_fetch(c, sql, max_rows) => r?,
|
||||
r = mssql_fetch(c, sql, skip, max_rows) => r?,
|
||||
() = cancel.cancelled() => return Err(IdeError::cancelled()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1009,7 +1009,20 @@ impl DbManager {
|
||||
max_rows: usize,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<QueryResult> {
|
||||
let r = self.run(id, statement, max_rows, cancel).await;
|
||||
self.execute_from(id, statement, 0, max_rows, cancel).await
|
||||
}
|
||||
|
||||
/// [`execute`](Self::execute), reading past the first `skip` rows without keeping
|
||||
/// them: a later page of a result, where only that page is decoded and returned.
|
||||
pub async fn execute_from(
|
||||
&self,
|
||||
id: &str,
|
||||
statement: &str,
|
||||
skip: usize,
|
||||
max_rows: usize,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<QueryResult> {
|
||||
let r = self.run(id, statement, skip, max_rows, cancel).await;
|
||||
let ran = match &r {
|
||||
Ok(_) => true,
|
||||
Err(e) => !matches!(e.kind, ErrorKind::Cancelled | ErrorKind::PermissionDenied),
|
||||
@@ -1044,7 +1057,7 @@ impl DbManager {
|
||||
/// The execution plan of `statement` (explained, not run).
|
||||
pub async fn explain(&self, id: &str, statement: &str, cancel: &CancellationToken) -> IdeResult<Vec<PlanNode>> {
|
||||
let driver = self.pool(id).await?.driver();
|
||||
let result = self.run(id, &plan::explain_statement(driver, statement), MAX_META_ROWS, cancel).await?;
|
||||
let result = self.run(id, &plan::explain_statement(driver, statement), 0, MAX_META_ROWS, cancel).await?;
|
||||
plan::parse(driver, &result)
|
||||
}
|
||||
|
||||
@@ -1052,13 +1065,14 @@ impl DbManager {
|
||||
&self,
|
||||
id: &str,
|
||||
statement: &str,
|
||||
skip: usize,
|
||||
max_rows: usize,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<QueryResult> {
|
||||
let pool = self.pool(id).await?;
|
||||
let session = self.sessions.lock().get(id).cloned();
|
||||
let Some(session) = session else {
|
||||
return pool.execute(statement, max_rows, cancel).await;
|
||||
return pool.execute_from(statement, skip, max_rows, cancel).await;
|
||||
};
|
||||
if pool.driver() == DbDriver::Clickhouse {
|
||||
return Err(IdeError::invalid("ClickHouse manual transactions are unsupported"));
|
||||
@@ -1070,7 +1084,7 @@ impl DbManager {
|
||||
*held = Some(conn);
|
||||
}
|
||||
let conn = held.as_mut().expect("opened above");
|
||||
let r = conn.execute(&pool, statement, max_rows, cancel).await;
|
||||
let r = conn.execute_from(&pool, statement, skip, max_rows, cancel).await;
|
||||
if cancel.is_cancelled() {
|
||||
// The server rolls the transaction back with the connection.
|
||||
held.take().expect("opened above").discard().await;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
//! Introspection, queries and table edits against a real SQLite file.
|
||||
|
||||
use ide_core::{CancellationToken, IdeResult};
|
||||
use ide_db::{CellValue, DataSource, DbDriver, DbManager, DbOptions, DbProperty, DbRowEdit, DbTableKind, SortKey};
|
||||
use ide_db::{
|
||||
CellValue, DataSource, DbDriver, DbManager, DbOptions, DbProperty, DbRowEdit, DbTableKind, QueryResult, SortKey,
|
||||
};
|
||||
|
||||
async fn setup() -> (tempfile::TempDir, DbManager) {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -123,6 +125,28 @@ async fn queries_with_limits_nulls_and_blobs() -> IdeResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_later_page_returns_only_its_rows() -> IdeResult<()> {
|
||||
let (_tmp, db) = setup().await;
|
||||
let c = CancellationToken::new();
|
||||
let sql = "SELECT id FROM users ORDER BY id";
|
||||
let ids = |r: &QueryResult| r.rows.iter().map(|row| row[0].clone().unwrap()).collect::<Vec<_>>();
|
||||
|
||||
let second = db.execute_from("s1", sql, 1, 1, &c).await?;
|
||||
assert_eq!(ids(&second), vec!["2"]);
|
||||
assert!(second.has_more);
|
||||
assert_eq!(second.columns.len(), 1);
|
||||
|
||||
let last = db.execute_from("s1", sql, 2, 5, &c).await?;
|
||||
assert_eq!(ids(&last), vec!["3"]);
|
||||
assert!(!last.has_more);
|
||||
|
||||
let past = db.execute_from("s1", sql, 9, 5, &c).await?;
|
||||
assert!(past.rows.is_empty() && !past.has_more);
|
||||
assert_eq!(past.columns.len(), 1, "columns of a page past the end come from the prepared statement");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pages_sorts_filters_and_edits_in_a_transaction() -> IdeResult<()> {
|
||||
let (_tmp, db) = setup().await;
|
||||
|
||||
@@ -79,7 +79,9 @@ impl Capability {
|
||||
Self::Secrets => "Store secrets in the system keychain".into(),
|
||||
Self::Events => "Observe workbench events (files opened, saved, focus)".into(),
|
||||
Self::InputEvents => "Observe keystrokes and editor input".into(),
|
||||
Self::Clipboard => "Read and write the clipboard from its web views".into(),
|
||||
Self::Clipboard => {
|
||||
"Copy to the clipboard from its commands, and read and write it from its web views".into()
|
||||
}
|
||||
Self::Net(host) => format!("Connect to {host} (from the machine the project is on)"),
|
||||
Self::Process(bin) => format!("Run {bin} — equivalent to running arbitrary code on this machine"),
|
||||
Self::FsRead(path) => format!("Read files under {path}, outside the project"),
|
||||
|
||||
@@ -32,7 +32,7 @@ use std::path::{Component, Path, PathBuf};
|
||||
|
||||
pub const MANIFEST_FILE: &str = "plugin.toml";
|
||||
/// The plugin API (engine) version this host implements.
|
||||
pub const ENGINE_VERSION: &str = "1.0.0";
|
||||
pub const ENGINE_VERSION: &str = "1.1.0";
|
||||
/// The 0.1 API, still accepted for contribution-only packages and 0.1 components.
|
||||
pub const LEGACY_API: &str = "0.1";
|
||||
/// Marketplace categories, in the order they are shown.
|
||||
|
||||
@@ -86,6 +86,8 @@ pub struct Host {
|
||||
project_requests: RwLock<HashMap<String, Vec<RequestId>>>,
|
||||
/// Shutdowns of projects closed with [`Host::close_project_detached`].
|
||||
closing: parking_lot::Mutex<Vec<std::thread::JoinHandle<()>>>,
|
||||
/// One open at a time per window (see [`Host::opening`]).
|
||||
open_locks: parking_lot::Mutex<HashMap<String, Arc<parking_lot::Mutex<()>>>>,
|
||||
}
|
||||
|
||||
/// How long exit waits for project shutdown before giving up.
|
||||
@@ -135,6 +137,7 @@ impl Host {
|
||||
requests: Default::default(),
|
||||
project_requests: RwLock::default(),
|
||||
closing: Default::default(),
|
||||
open_locks: Default::default(),
|
||||
};
|
||||
host.apply_proxy();
|
||||
host
|
||||
@@ -401,6 +404,13 @@ impl Host {
|
||||
self.requests.cancel(id);
|
||||
}
|
||||
|
||||
/// The lock an open of `window` holds from its check for the current project to its
|
||||
/// insert: two opens racing (a double click, a launch path and a restore) would each start
|
||||
/// an index, language servers and watchers, and the second would replace the first unclosed.
|
||||
pub(crate) fn opening(&self, window: &str) -> Arc<parking_lot::Mutex<()>> {
|
||||
self.open_locks.lock().entry(window.to_owned()).or_default().clone()
|
||||
}
|
||||
|
||||
pub(crate) fn insert_project(&self, window: &str, project: Arc<Project>) {
|
||||
self.projects.write().insert(window.to_owned(), project);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Project-incarnation notebook ownership and whole-batch execution admission.
|
||||
use crate::ops::Channel;
|
||||
use ide_core::error::ErrorKind;
|
||||
use ide_core::{CancellationToken, IdeError, IdeEvent, IdeResult, SharedSink};
|
||||
use ide_core::{CancellationToken, IdeError, IdeEvent, IdeResult, IpcPath, SharedSink};
|
||||
use ide_notebook::kernel::{KernelManager, KernelOutput};
|
||||
use ide_notebook::{NotebookDocument, NotebookEdit, NotebookSnapshot};
|
||||
use parking_lot::Mutex;
|
||||
@@ -17,7 +17,7 @@ use tokio::sync::{Mutex as AsyncMutex, watch};
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NotebookOutputEvent {
|
||||
pub id: String,
|
||||
pub path: String,
|
||||
pub path: IpcPath,
|
||||
pub cell: String,
|
||||
pub msg_id: String,
|
||||
pub kind: String,
|
||||
@@ -536,7 +536,7 @@ fn commit_output(
|
||||
};
|
||||
Ok(Some(NotebookOutputEvent {
|
||||
id: document.id.clone(),
|
||||
path: document.path.to_string_lossy().into_owned(),
|
||||
path: document.path.clone().into(),
|
||||
cell: cell.to_owned(),
|
||||
msg_id: output.msg_id,
|
||||
kind: output.kind,
|
||||
@@ -592,6 +592,16 @@ mod tests {
|
||||
other.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_notebook_path_reaches_the_ui_as_the_project_tree_names_it() {
|
||||
let (dir, service, _) = fixture();
|
||||
let ui_path =
|
||||
format!("{}/session.ipynb", ide_core::ipc_path::to_ipc(&dunce::canonicalize(dir.path()).unwrap()));
|
||||
let snapshot = service.open(Path::new(&ui_path)).unwrap();
|
||||
assert_eq!(serde_json::to_value(&snapshot).unwrap()["path"], ui_path);
|
||||
service.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn failed_close_keeps_the_incarnation_recoverable_until_a_retry_succeeds() {
|
||||
let (dir, service, initial) = fixture();
|
||||
|
||||
@@ -8,8 +8,11 @@ use ide_agent::{
|
||||
AgentChange, AgentCommand, AgentEntry, AgentEvent, AgentProbe, AgentPrompt, AgentProvider, AgentQueued, AgentTask,
|
||||
NewAgentTask,
|
||||
};
|
||||
use ide_core::{IdeError, IdeResult, IpcPath, RequestId};
|
||||
use ide_core::error::ErrorKind;
|
||||
use ide_core::{IdeError, IdeEvent, IdeResult, IpcPath, RequestId, SharedSink, TaskId};
|
||||
use ide_tools::InstallEvent;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
|
||||
/// Streams the project's agent events to this window; returns every task.
|
||||
pub fn subscribe(host: &Host, window: &str, on_event: Emit<AgentEvent>) -> IdeResult<Vec<AgentTask>> {
|
||||
@@ -58,7 +61,39 @@ pub async fn registry_list(host: &Host, _window: &str, request: RequestId) -> Id
|
||||
let result =
|
||||
tokio::task::spawn_blocking(move || ide_agent::registry::list(&paths, &cancel)).await.map_err(join_err);
|
||||
host.end_request(request);
|
||||
result?
|
||||
let mut registry = result??;
|
||||
let installing = installing();
|
||||
for agent in &mut registry.agents {
|
||||
agent.installing = installing.contains(&agent.id);
|
||||
}
|
||||
Ok(registry)
|
||||
}
|
||||
|
||||
/// Registry installs in progress, by agent id. An install outlives the Settings page
|
||||
/// that started it, so a second one of the same agent is refused rather than racing it
|
||||
/// for the same folder.
|
||||
static INSTALLING: LazyLock<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);
|
||||
}
|
||||
}
|
||||
|
||||
/// Installs the registry's current version of agent `id` (also to update it), streaming
|
||||
@@ -72,11 +107,43 @@ pub async fn registry_install(
|
||||
on_event: Channel<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 || {
|
||||
ide_agent::registry::install(&paths, &id, &cancel, &|e| {
|
||||
let _installing = installing;
|
||||
let _cancel = ide_core::task::on_cancel(task, {
|
||||
let cancel = cancel.clone();
|
||||
move || cancel.cancel()
|
||||
});
|
||||
report(None, None, false);
|
||||
let result = ide_agent::registry::install(&paths, &id, &cancel, &|e| {
|
||||
match &e {
|
||||
InstallEvent::Line { text } => report(Some(text.clone()), None, false),
|
||||
InstallEvent::Progress { fraction, message } => {
|
||||
report(Some(message.clone()), fraction.map(|f| f as f32), false)
|
||||
}
|
||||
}
|
||||
let _ = on_event.send(e);
|
||||
})
|
||||
});
|
||||
report(None, Some(1.0), true);
|
||||
result
|
||||
})
|
||||
.await
|
||||
.map_err(join_err);
|
||||
@@ -263,6 +330,20 @@ fn validate_project_root(root: &std::path::Path, requested: Option<&std::path::P
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn an_agent_installs_once_at_a_time() {
|
||||
let first = Installing::begin("test-agent").unwrap();
|
||||
assert!(installing().contains("test-agent"));
|
||||
let again = Installing::begin("test-agent").err().unwrap();
|
||||
assert_eq!(again.kind, ErrorKind::AlreadyExists);
|
||||
let other = Installing::begin("test-other-agent").unwrap();
|
||||
drop(first);
|
||||
assert!(!installing().contains("test-agent"));
|
||||
drop(Installing::begin("test-agent").unwrap());
|
||||
drop(other);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_roots_are_exact_window_or_workspace_members() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -32,6 +32,7 @@ pub fn load(root: &Path, cache: &Path, settings: &Settings) -> BuildModel {
|
||||
cache,
|
||||
ide_sdk::apply::maven_local_repository(root, settings),
|
||||
&settings.languages.java.maven,
|
||||
&settings.languages.java.gradle,
|
||||
);
|
||||
let jdk = ide_sdk::apply::project_jdk(root, settings);
|
||||
if let Some(m) = &mut model.maven {
|
||||
@@ -45,7 +46,7 @@ pub fn load(root: &Path, cache: &Path, settings: &Settings) -> BuildModel {
|
||||
p.options = options.clone();
|
||||
}
|
||||
}
|
||||
if let Some(g) = &mut model.gradle {
|
||||
for g in &mut model.gradle {
|
||||
g.env = ide_sdk::apply::gradle_env(root, settings, java_env(jdk.as_ref(), g.java_version));
|
||||
g.command = ide_sdk::apply::gradle_command(root, &root.join(&g.dir), settings).to_string_lossy().into_owned();
|
||||
}
|
||||
@@ -65,11 +66,12 @@ pub fn load(root: &Path, cache: &Path, settings: &Settings) -> BuildModel {
|
||||
let hint = m.projects.iter().find_map(|p| missing(p.java_version));
|
||||
explain(&mut m.sync_error, hint);
|
||||
}
|
||||
if let Some(g) = &mut model.gradle
|
||||
&& g.sync_error.is_some()
|
||||
{
|
||||
let hint = if settings.languages.java.gradle.jvm.trim().is_empty() { missing(g.java_version) } else { None };
|
||||
explain(&mut g.sync_error, hint);
|
||||
for g in &mut model.gradle {
|
||||
if g.sync_error.is_some() {
|
||||
let hint =
|
||||
if settings.languages.java.gradle.jvm.trim().is_empty() { missing(g.java_version) } else { None };
|
||||
explain(&mut g.sync_error, hint);
|
||||
}
|
||||
}
|
||||
model
|
||||
}
|
||||
@@ -94,9 +96,89 @@ pub async fn build_model(host: &Host, window: &str) -> IdeResult<BuildModel> {
|
||||
tokio::task::spawn_blocking(move || load(&root, &cache, &settings)).await.map_err(join_err)
|
||||
}
|
||||
|
||||
fn linked_gradle_paths(
|
||||
root: &Path,
|
||||
settings: &ide_core::settings::GradleSettings,
|
||||
paths: &[String],
|
||||
link: bool,
|
||||
) -> IdeResult<Vec<String>> {
|
||||
let existing = settings
|
||||
.linked_projects
|
||||
.clone()
|
||||
.unwrap_or_else(|| ide_build::gradle::load_linked(root, settings).into_iter().map(|g| g.dir).collect());
|
||||
let mut linked = Vec::new();
|
||||
for path in existing {
|
||||
let normalized = ide_build::gradle::normalize_linked_projects(root, std::slice::from_ref(&path))
|
||||
.unwrap_or_else(|_| vec![path]);
|
||||
for path in normalized {
|
||||
if !linked.contains(&path) {
|
||||
linked.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
if link {
|
||||
let directories: Vec<String> = paths
|
||||
.iter()
|
||||
.map(|path| {
|
||||
let path = root.join(path);
|
||||
let is_script = path.file_name().and_then(|n| n.to_str()).is_some_and(|name| {
|
||||
ide_build::gradle::SETTINGS_FILES.contains(&name) || ide_build::gradle::BUILD_FILES.contains(&name)
|
||||
});
|
||||
if is_script && path.is_file() {
|
||||
path.parent().unwrap_or(root).to_string_lossy().into_owned()
|
||||
} else {
|
||||
path.to_string_lossy().into_owned()
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
for path in ide_build::gradle::normalize_linked_projects(root, &directories)? {
|
||||
if !linked.contains(&path) {
|
||||
linked.push(path);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let unlinked: Vec<String> = paths.iter().map(|p| ide_build::gradle::linked_project_key(root, p)).collect();
|
||||
linked.retain(|path| !paths.contains(path) && !unlinked.contains(path));
|
||||
}
|
||||
Ok(linked)
|
||||
}
|
||||
|
||||
async fn set_gradle_projects(host: &Host, window: &str, paths: Vec<String>, link: bool) -> IdeResult<BuildModel> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
if ide_workspace::workspace::Workspace::load(&project.root)?.is_some() {
|
||||
return Err(IdeError::invalid("Open a workspace member as a project to link Gradle builds"));
|
||||
}
|
||||
let linked =
|
||||
linked_gradle_paths(&project.root, &project.settings.effective().languages.java.gradle, &paths, link)?;
|
||||
let key = "languages.java.gradle.linkedProjects";
|
||||
let settings =
|
||||
project.settings.set(ide_workspace::settings::SettingsScope::Project, key, serde_json::json!(linked))?;
|
||||
project.refresh_index_scope();
|
||||
project.lsp.reconfigure_soon();
|
||||
project.sink.emit(IdeEvent::SettingsChanged { key: key.into() });
|
||||
project.sink.emit(IdeEvent::ProjectRootsChanged { root: project.root.clone().into() });
|
||||
Ok::<BuildModel, IdeError>(load(&project.root, &project.build_cache_dir(), &settings))
|
||||
})
|
||||
.await
|
||||
.map_err(join_err)?
|
||||
}
|
||||
|
||||
/// Links independent builds without changing their Gradle scripts.
|
||||
pub async fn gradle_link_projects(host: &Host, window: &str, paths: Vec<String>) -> IdeResult<BuildModel> {
|
||||
set_gradle_projects(host, window, paths, true).await
|
||||
}
|
||||
|
||||
/// Stops importing the selected independent builds; files and saved run configurations stay intact.
|
||||
pub async fn gradle_unlink_projects(host: &Host, window: &str, paths: Vec<String>) -> IdeResult<BuildModel> {
|
||||
set_gradle_projects(host, window, paths, false).await
|
||||
}
|
||||
|
||||
/// Runs Maven (`help:effective-pom dependency:tree`) or Gradle (`tasks --all`) in the
|
||||
/// background, reporting progress on `on_progress`; cancellable with `cancel_request(request)`.
|
||||
/// Returns the refreshed model; a failed build is reported in its `syncError`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn build_sync(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
@@ -104,6 +186,7 @@ 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)?;
|
||||
@@ -122,6 +205,8 @@ pub async fn build_sync(
|
||||
project.sink.emit(ide_core::IdeEvent::ProjectRootsChanged { root: project.root.clone().into() });
|
||||
}
|
||||
let token = host.begin_project_request(window, request);
|
||||
let completion_token = token.clone();
|
||||
let original_links = settings.languages.java.gradle.linked_projects.clone();
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let current = load(&root, &cache, &settings);
|
||||
let env = match tool {
|
||||
@@ -131,7 +216,7 @@ pub async fn build_sync(
|
||||
.and_then(|m| m.projects.iter().find(|p| !p.env.is_empty()))
|
||||
.map(|p| p.env.clone())
|
||||
.unwrap_or_default(),
|
||||
BuildTool::Gradle => current.gradle.as_ref().map(|g| g.env.clone()).unwrap_or_default(),
|
||||
BuildTool::Gradle => BTreeMap::new(),
|
||||
};
|
||||
let options = SyncOptions {
|
||||
offline,
|
||||
@@ -140,14 +225,31 @@ pub async fn build_sync(
|
||||
maven_command: ide_sdk::apply::maven_command(&root, &settings).map(|p| p.to_string_lossy().into_owned()),
|
||||
maven_options: ide_sdk::apply::maven_options(&root, &settings),
|
||||
maven_settings: settings.languages.java.maven.clone(),
|
||||
gradle_command: current.gradle.as_ref().map(|g| g.command.clone()),
|
||||
gradle_command: None,
|
||||
};
|
||||
let progress = |p: SyncProgress| {
|
||||
let _ = on_progress.send(p);
|
||||
};
|
||||
match tool {
|
||||
BuildTool::Maven => ide_build::sync_maven(&root, &cache, &options, &token, &progress)?,
|
||||
BuildTool::Gradle => ide_build::sync_gradle(&root, &cache, &options, &token, &progress)?,
|
||||
BuildTool::Gradle => {
|
||||
if let Some(dir) = &build_dir
|
||||
&& !current.gradle.iter().any(|g| &g.dir == dir)
|
||||
{
|
||||
return Err(IdeError::not_found("Gradle project is no longer linked"));
|
||||
}
|
||||
for g in current.gradle.iter().filter(|g| build_dir.as_ref().is_none_or(|dir| dir == &g.dir)) {
|
||||
if token.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let options =
|
||||
SyncOptions { env: g.env.clone(), gradle_command: Some(g.command.clone()), ..options.clone() };
|
||||
ide_build::sync_gradle_model(&root, &cache, g, &options, &token, &progress)?;
|
||||
}
|
||||
if token.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok::<BuildModel, IdeError>(load(&root, &cache, &settings))
|
||||
})
|
||||
@@ -155,6 +257,18 @@ pub async fn build_sync(
|
||||
.map_err(join_err);
|
||||
host.end_request(request);
|
||||
let model = result??;
|
||||
if completion_token.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
if tool == BuildTool::Gradle && original_links != project.settings.effective().languages.java.gradle.linked_projects
|
||||
{
|
||||
// A late sync must not re-import a build that was unlinked while it ran.
|
||||
return tokio::task::spawn_blocking(move || {
|
||||
load(&project.root, &project.build_cache_dir(), &project.settings.effective())
|
||||
})
|
||||
.await
|
||||
.map_err(join_err);
|
||||
}
|
||||
if tool == BuildTool::Gradle {
|
||||
// The sync may report project folders outside the project (`includeFlat`): index and list them.
|
||||
project.refresh_index_scope();
|
||||
@@ -247,20 +361,26 @@ pub async fn gradle_dependencies(
|
||||
configuration: String,
|
||||
offline: bool,
|
||||
request: RequestId,
|
||||
build_dir: Option<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 gradle = load(&root, &cache, &settings).gradle;
|
||||
let model = load(&root, &cache, &settings);
|
||||
let gradle = model
|
||||
.gradle
|
||||
.iter()
|
||||
.find(|g| build_dir.as_ref().is_none_or(|dir| &g.dir == dir))
|
||||
.ok_or_else(|| IdeError::not_found("Gradle project is no longer linked"))?;
|
||||
let options = SyncOptions {
|
||||
offline,
|
||||
env: gradle.as_ref().map(|g| g.env.clone()).unwrap_or_default(),
|
||||
gradle_command: gradle.as_ref().map(|g| g.command.clone()),
|
||||
env: gradle.env.clone(),
|
||||
gradle_command: Some(gradle.command.clone()),
|
||||
..Default::default()
|
||||
};
|
||||
ide_build::gradle_dependencies(&root, &options, &gradle_path, &configuration, &token)
|
||||
ide_build::gradle_dependencies(&root, gradle, &options, &gradle_path, &configuration, &token)
|
||||
})
|
||||
.await
|
||||
.map_err(join_err);
|
||||
@@ -382,6 +502,11 @@ pub async fn build_run(
|
||||
/// Commands of this module the remote server answers.
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("build_model", |c| async move { build_model(&c.host, &c.window).await });
|
||||
d.add("gradle_link_projects", |c| async move { gradle_link_projects(&c.host, &c.window, c.arg("paths")?).await });
|
||||
d.add(
|
||||
"gradle_unlink_projects",
|
||||
|c| async move { gradle_unlink_projects(&c.host, &c.window, c.arg("paths")?).await },
|
||||
);
|
||||
d.add("build_sync", |c| async move {
|
||||
build_sync(
|
||||
&c.host,
|
||||
@@ -390,6 +515,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
c.arg("request")?,
|
||||
c.arg("offline")?,
|
||||
c.arg("profiles")?,
|
||||
c.arg("buildDir")?,
|
||||
c.sender("onProgress")?,
|
||||
)
|
||||
.await
|
||||
@@ -405,6 +531,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
c.arg("configuration")?,
|
||||
c.arg("offline")?,
|
||||
c.arg("request")?,
|
||||
c.arg("buildDir")?,
|
||||
)
|
||||
.await
|
||||
});
|
||||
@@ -432,6 +559,48 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
mod gradle_settings_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn linking_retains_the_implicit_build_and_unlinking_all_stays_empty() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
for name in ["a", "b"] {
|
||||
std::fs::create_dir(root.join(name)).unwrap();
|
||||
std::fs::write(root.join(name).join("settings.gradle"), format!("rootProject.name = '{name}'")).unwrap();
|
||||
}
|
||||
let mut settings = ide_core::settings::GradleSettings::default();
|
||||
let linked = linked_gradle_paths(&root, &settings, &["b/settings.gradle".into()], true).unwrap();
|
||||
assert_eq!(linked, ["a", "b"]);
|
||||
settings.linked_projects = Some(linked);
|
||||
assert_eq!(linked_gradle_paths(&root, &settings, &["b".into()], true).unwrap(), ["a", "b"]);
|
||||
assert!(linked_gradle_paths(&root, &settings, &["missing".into()], true).is_err());
|
||||
settings.linked_projects = Some(vec!["./a".into(), root.join("b").to_string_lossy().into_owned()]);
|
||||
settings.linked_projects = Some(linked_gradle_paths(&root, &settings, &["a".into()], false).unwrap());
|
||||
assert_eq!(settings.linked_projects.as_ref().unwrap(), &["b"]);
|
||||
settings.linked_projects = Some(linked_gradle_paths(&root, &settings, &["b".into()], false).unwrap());
|
||||
assert!(ide_build::gradle::load_linked(&root, &settings).is_empty());
|
||||
assert!(root.join("a/settings.gradle").is_file());
|
||||
assert!(root.join("b/settings.gradle").is_file());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_resolve_each_linked_builds_own_wrapper() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let mut settings = Settings::default();
|
||||
settings.languages.java.gradle.linked_projects = Some(vec!["a".into(), "b".into()]);
|
||||
let executable = if cfg!(windows) { "gradlew.bat" } else { "gradlew" };
|
||||
for dir in ["a", "b"] {
|
||||
std::fs::create_dir(root.join(dir)).unwrap();
|
||||
std::fs::write(root.join(dir).join("build.gradle"), "plugins { id 'java' }").unwrap();
|
||||
std::fs::write(root.join(dir).join(executable), "").unwrap();
|
||||
}
|
||||
let model = load(&root, &root.join("cache"), &settings);
|
||||
assert_eq!(model.gradle.len(), 2);
|
||||
for g in model.gradle {
|
||||
assert_eq!(g.command, root.join(&g.dir).join(executable).to_string_lossy());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_for_sync_tasks_and_compile_uses_gradle_settings() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -440,13 +609,13 @@ mod gradle_settings_tests {
|
||||
let wrapper = root.join(if cfg!(windows) { "gradlew.bat" } else { "gradlew" });
|
||||
std::fs::write(&wrapper, "").unwrap();
|
||||
let mut settings = Settings::default();
|
||||
let default = load(root, &root.join("cache"), &settings).gradle.unwrap();
|
||||
let default = load(root, &root.join("cache"), &settings).gradle.remove(0);
|
||||
assert_eq!(default.command, wrapper.to_string_lossy());
|
||||
settings.languages.java.gradle.jvm = "jdk21".into();
|
||||
settings.languages.java.gradle.user_home = "user-home".into();
|
||||
settings.languages.java.gradle.distribution = ide_core::settings::GradleDistribution::Local;
|
||||
settings.languages.java.gradle.home = "gradle-install".into();
|
||||
let model = load(root, &root.join("cache"), &settings).gradle.unwrap();
|
||||
let model = load(root, &root.join("cache"), &settings).gradle.remove(0);
|
||||
assert_eq!(
|
||||
model.command,
|
||||
root.join("gradle-install")
|
||||
|
||||
@@ -123,13 +123,14 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
statements(&c.host, &c.window, &id, &text, from, to)
|
||||
});
|
||||
d.add("db_execute", |c| async move {
|
||||
let (request, id, statements, max_rows) = (
|
||||
let (request, id, statements, skip, max_rows) = (
|
||||
c.arg::<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, max_rows).await
|
||||
execute(&c.host, &c.window, request, &id, statements, skip, max_rows).await
|
||||
});
|
||||
d.add("db_table_page", |c| async move {
|
||||
let (request, id, schema, table) = (
|
||||
@@ -325,20 +326,22 @@ pub fn statements(host: &Host, window: &str, id: &str, text: &str, from: u32, to
|
||||
}
|
||||
|
||||
/// Runs `statements` in order on data source `id`, stopping at the first
|
||||
/// error. Cancel with `cancel_request(request)`.
|
||||
/// error. Each result reads past its first `skip` rows (a later page) and keeps
|
||||
/// `max_rows`. Cancel with `cancel_request(request)`.
|
||||
pub async fn execute(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
request: RequestId,
|
||||
id: &str,
|
||||
statements: Vec<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(id, &statement.text, max_rows.max(1) as usize, &cancel).await {
|
||||
match db.execute_from(id, &statement.text, skip as usize, max_rows.max(1) as usize, &cancel).await {
|
||||
Ok(result) => out.push(StatementOutcome { statement, result: Some(result), error: None }),
|
||||
Err(e) if e.kind == ide_core::error::ErrorKind::Cancelled => {
|
||||
host.end_request(request);
|
||||
|
||||
@@ -162,6 +162,14 @@ pub async fn debug_pause(host: &Host, _window: &str, session: DebugSessionId, th
|
||||
host.debugger.pause(session, thread_id).await
|
||||
}
|
||||
|
||||
pub async fn debug_hot_reload(host: &Host, _window: &str, session: DebugSessionId) -> IdeResult<()> {
|
||||
host.debugger.hot_reload(session).await
|
||||
}
|
||||
|
||||
pub async fn debug_hot_restart(host: &Host, _window: &str, session: DebugSessionId) -> IdeResult<()> {
|
||||
host.debugger.hot_restart(session).await
|
||||
}
|
||||
|
||||
pub async fn debug_step(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
|
||||
@@ -196,7 +196,9 @@ pub fn set_settings(
|
||||
}
|
||||
let roots_changed = key == "projectStructure"
|
||||
|| key.starts_with("projectStructure.")
|
||||
|| "languages.java.maven.unlinkedProjects".starts_with(key);
|
||||
|| "languages.java.maven.unlinkedProjects".starts_with(key)
|
||||
|| "languages.java.maven.linkedProjects".starts_with(key)
|
||||
|| "languages.java.gradle.linkedProjects".starts_with(key);
|
||||
// Content roots and excludes (files.exclude, ignored names, excluded folders) reach the
|
||||
// index and the watchers without reopening the project (the rescan runs in the background).
|
||||
if roots_changed || key == "files" || key.starts_with("files.") {
|
||||
|
||||
@@ -20,7 +20,7 @@ const MAX_FILE_BYTES: u64 = 4 << 20;
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RouteSource {
|
||||
pub path: String,
|
||||
pub path: ide_core::IpcPath,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ pub(crate) fn sources_in(root: &Path, excludes: &[String]) -> Vec<RouteSource> {
|
||||
return;
|
||||
}
|
||||
bytes.fetch_add(text.len(), Ordering::Relaxed);
|
||||
found.push(RouteSource { path: path.to_string_lossy().into_owned(), text });
|
||||
found.push(RouteSource { path: path.to_path_buf().into(), text });
|
||||
});
|
||||
let mut files = found.into_inner().unwrap_or_default();
|
||||
files.sort_by(|a, b| a.path.cmp(&b.path));
|
||||
@@ -116,7 +116,7 @@ mod tests {
|
||||
std::fs::write(tmp.path().join("src/Notes.txt"), "@GetMapping(\"/a\")").unwrap();
|
||||
let files = sources_in(tmp.path(), &[]);
|
||||
assert_eq!(files.len(), 1, "{:?}", files.iter().map(|f| &f.path).collect::<Vec<_>>());
|
||||
assert!(Path::new(&files[0].path).ends_with("src/Ctl.java"), "{}", files[0].path);
|
||||
assert!(files[0].path.ends_with("src/Ctl.java"), "{:?}", files[0].path);
|
||||
assert!(files[0].text.contains("@GetMapping"));
|
||||
}
|
||||
|
||||
@@ -129,6 +129,6 @@ mod tests {
|
||||
std::fs::write(tmp.path().join("build/B.java"), "@RequestMapping(\"/b\")").unwrap();
|
||||
let files = sources_in(tmp.path(), &["build/**".to_owned()]);
|
||||
assert_eq!(files.len(), 1, "{:?}", files.iter().map(|f| &f.path).collect::<Vec<_>>());
|
||||
assert!(Path::new(&files[0].path).ends_with("module/src/A.java"), "{}", files[0].path);
|
||||
assert!(files[0].path.ends_with("module/src/A.java"), "{:?}", files[0].path);
|
||||
}
|
||||
}
|
||||
@@ -23,8 +23,26 @@ pub struct LocatedTools {
|
||||
/// Installed interpreters / toolchains of `kind`; project-local ones first.
|
||||
pub async fn sdk_detect(host: &Host, window: &str, kind: SdkKind) -> IdeResult<Vec<Sdk>> {
|
||||
host.require_trust(window)?;
|
||||
let root = host.project(window).ok().map(|p| p.root.clone());
|
||||
tokio::task::spawn_blocking(move || ide_sdk::detect(kind, root.as_deref())).await.map_err(join_err)
|
||||
let project = host.project(window).ok();
|
||||
let root = project.as_ref().map(|p| p.root.clone());
|
||||
// The interpreter the project uses is a choice even where detection doesn't look (a
|
||||
// virtualenv in a subfolder, one picked by path), so it shows by name and version.
|
||||
let current = match (&project, kind) {
|
||||
(Some(p), SdkKind::Python) => ide_sdk::apply::python_interpreter(&p.root, &p.settings.effective()),
|
||||
_ => None,
|
||||
};
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let mut sdks = ide_sdk::detect(kind, root.as_deref());
|
||||
if let Some(path) = current
|
||||
&& !sdks.iter().any(|s| *s.path == *path)
|
||||
&& let Some(sdk) = ide_sdk::python_interpreter_sdk(&path, root.as_deref())
|
||||
{
|
||||
sdks.push(sdk);
|
||||
}
|
||||
sdks
|
||||
})
|
||||
.await
|
||||
.map_err(join_err)
|
||||
}
|
||||
|
||||
/// The interpreter the project uses: the configured one, else its virtualenv.
|
||||
|
||||
@@ -405,28 +405,6 @@ pub async fn vcs_log_details_batch(
|
||||
result
|
||||
}
|
||||
|
||||
pub async fn vcs_log_index(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
repo: Option<PathBuf>,
|
||||
request: Option<RequestId>,
|
||||
) -> IdeResult<LogIndexProgress> {
|
||||
let project = LogAccess::new(host, window, repo.as_deref());
|
||||
let cancel =
|
||||
request.map_or_else(ide_core::CancellationToken::new, |id| host.begin_project_read_request(window, id));
|
||||
let result = blocking(move || {
|
||||
project.with_log_repo_at(repo.as_deref(), |r| {
|
||||
r.enable_log_index(&project.cache);
|
||||
if request.is_some() { r.index_log(&cancel) } else { Ok(r.log_index_progress()) }
|
||||
})
|
||||
})
|
||||
.await;
|
||||
if let Some(request) = request {
|
||||
host.end_request(request);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub async fn vcs_revision_children(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
@@ -492,7 +470,7 @@ pub async fn vcs_add_to_ignore(
|
||||
window: &str,
|
||||
paths: Vec<PathBuf>,
|
||||
destination: IgnoreDestination,
|
||||
) -> IdeResult<Vec<String>> {
|
||||
) -> IdeResult<Vec<ide_core::IpcPath>> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
@@ -509,7 +487,7 @@ pub async fn vcs_add_to_ignore(
|
||||
watcher.reload_ignore_rules();
|
||||
}
|
||||
project.sink.emit(ide_core::IdeEvent::VcsChanged { root: project.root.clone().into() });
|
||||
result.map(|files| files.into_iter().map(|p| p.to_string_lossy().into_owned()).collect())
|
||||
result.map(|files| files.into_iter().map(Into::into).collect())
|
||||
})
|
||||
.await
|
||||
}
|
||||
@@ -1222,10 +1200,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("vcs_log_details_batch", |c| async move {
|
||||
vcs_log_details_batch(&c.host, &c.window, c.arg("repo")?, c.arg("revisions")?, c.arg("request")?).await
|
||||
});
|
||||
d.add(
|
||||
"vcs_log_index",
|
||||
|c| async move { vcs_log_index(&c.host, &c.window, c.arg("repo")?, c.arg("request")?).await },
|
||||
);
|
||||
d.add("vcs_revision_children", |c| async move {
|
||||
vcs_revision_children(&c.host, &c.window, c.arg("repo")?, c.arg("revision")?, c.arg("directory")?).await
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! Capabilities were checked by the runtime before any of this runs.
|
||||
|
||||
use super::{PluginEvent, PluginEventSink};
|
||||
use ide_core::ipc_path::to_ipc;
|
||||
use ide_core::{CancellationToken, TextEdit};
|
||||
use ide_extension::Package;
|
||||
use ide_language::ProgramPin;
|
||||
@@ -324,6 +325,18 @@ impl HostApi for PluginApi {
|
||||
.emit(client, PluginEvent::Notification { plugin_id: self.id().to_owned(), level, title, body });
|
||||
}
|
||||
|
||||
fn copy_to_clipboard(&self, client: Option<&str>, text: String) -> Result<(), HostError> {
|
||||
let client = from_window(client, "copy to the clipboard")?;
|
||||
self.shared.sink.emit(Some(client), PluginEvent::CopyToClipboard { plugin_id: self.id().to_owned(), text });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn open_url(&self, client: Option<&str>, url: String) -> Result<(), HostError> {
|
||||
let client = from_window(client, "open a URL")?;
|
||||
self.shared.sink.emit(Some(client), PluginEvent::OpenUrl { plugin_id: self.id().to_owned(), url });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn status_item(&self, id: &str, item: Option<StatusItem>) {
|
||||
let key = (self.id().to_owned(), id.to_owned());
|
||||
{
|
||||
@@ -407,7 +420,7 @@ impl HostApi for PluginApi {
|
||||
let info = repo.branch_info().map_err(failed)?;
|
||||
let changed = repo.status().map_err(failed)?.len();
|
||||
Ok(Some(RepositoryStatus {
|
||||
root: repo.root().to_string_lossy().into_owned(),
|
||||
root: to_ipc(repo.root()),
|
||||
branch: info.head,
|
||||
commit: info.head_commit,
|
||||
upstream: info.upstream,
|
||||
@@ -418,6 +431,12 @@ impl HostApi for PluginApi {
|
||||
}))
|
||||
}
|
||||
|
||||
fn vcs_remotes(&self, root: &Path) -> Result<Vec<(String, String)>, HostError> {
|
||||
let Some(repo) = ide_vcs::Repo::discover(root).map_err(failed)? else { return Ok(Vec::new()) };
|
||||
let remotes = repo.remotes().map_err(failed)?;
|
||||
Ok(remotes.into_iter().map(|r| (r.name, without_credentials(&r.url))).collect())
|
||||
}
|
||||
|
||||
fn vcs_fetch(&self, root: &Path, cancel: &CancellationToken) -> Result<(), HostError> {
|
||||
let workspace = self.shared.workspace();
|
||||
if !workspace.is_some_and(|w| w.trusted(root)) {
|
||||
@@ -449,7 +468,7 @@ impl HostApi for PluginApi {
|
||||
}
|
||||
let Ok(rel) = entry.path().strip_prefix(root) else { continue };
|
||||
if matcher.is_match(rel) {
|
||||
out.push(entry.path().to_string_lossy().into_owned());
|
||||
out.push(to_ipc(entry.path()));
|
||||
}
|
||||
}
|
||||
out.sort();
|
||||
@@ -788,6 +807,19 @@ mod tests {
|
||||
PluginApi { pkg: Package::new(manifest, dir.to_path_buf()), shared }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repository_and_search_paths_match_ipc_events() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
assert!(std::process::Command::new("git").args(["init", "-q"]).arg(tmp.path()).status().unwrap().success());
|
||||
let root = tmp.path().canonicalize().unwrap();
|
||||
std::fs::create_dir(root.join("src")).unwrap();
|
||||
let file = root.join("src/Example.java");
|
||||
std::fs::write(&file, "class Example {}\n").unwrap();
|
||||
let api = api(&root, Arc::default());
|
||||
assert_eq!(api.find_files(&root, "**/*.java", 8).unwrap(), [to_ipc(&file)]);
|
||||
assert_eq!(api.vcs_status(&root).unwrap().unwrap().root, to_ipc(&root));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn programs_run_only_as_approved() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -812,3 +844,42 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The window a user action invoked the plugin from: browser tabs and clipboard writes
|
||||
/// follow what the user did, never background work (which would reach every window).
|
||||
fn from_window<'a>(client: Option<&'a str>, what: &str) -> Result<&'a str, HostError> {
|
||||
client.ok_or_else(|| HostError::Invalid(format!("can only {what} from a command the user invoked")))
|
||||
}
|
||||
|
||||
/// `url` without the `user:password@` a remote may carry (`https://user:token@host/…`):
|
||||
/// `vcs.read` must not hand out credentials. scp-style `git@host:path` has no password.
|
||||
fn without_credentials(url: &str) -> String {
|
||||
let Some((scheme, rest)) = url.split_once("://") else { return url.to_owned() };
|
||||
let authority_end = rest.find('/').unwrap_or(rest.len());
|
||||
match rest[..authority_end].rfind('@') {
|
||||
Some(at) => format!("{scheme}://{}", &rest[at + 1..]),
|
||||
None => url.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod credential_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn remote_urls_lose_their_credentials() {
|
||||
for (url, expected) in [
|
||||
("https://user:[email protected]/o/r.git", "https://github.com/o/r.git"),
|
||||
("https://[email protected]/o/r", "https://gitlab.com/o/r"),
|
||||
("ssh://git@host:2222/o/r.git", "ssh://host:2222/o/r.git"),
|
||||
("https://github.com/o/r.git", "https://github.com/o/r.git"),
|
||||
("https://github.com/o/r@v1", "https://github.com/o/r@v1"),
|
||||
("[email protected]:o/r.git", "[email protected]:o/r.git"),
|
||||
("/srv/git/r.git", "/srv/git/r.git"),
|
||||
] {
|
||||
assert_eq!(without_credentials(url), expected, "{url}");
|
||||
}
|
||||
assert!(matches!(from_window(None, "open a URL"), Err(HostError::Invalid(_))));
|
||||
assert_eq!(from_window(Some("main"), "open a URL").unwrap(), "main");
|
||||
}
|
||||
}
|
||||
@@ -422,7 +422,8 @@ struct IdeDir {
|
||||
plugins: PathBuf,
|
||||
}
|
||||
|
||||
/// The settings folders of JetBrains IDEs and Android Studio (2020.1 and later), newest first.
|
||||
/// The settings folders of JetBrains IDEs and Android Studio (2020.1 and later), newest first, one
|
||||
/// per product: its newest version's (an upgrade leaves the older ones behind).
|
||||
fn ide_dirs(layout: Layout, home: &Path, env: impl Fn(&str) -> Option<OsString>) -> Vec<IdeDir> {
|
||||
let var = |key: &str| env(key).filter(|v| !v.is_empty()).map(PathBuf::from);
|
||||
let (config_root, data_root) = match layout {
|
||||
@@ -462,6 +463,8 @@ fn ide_dirs(layout: Layout, home: &Path, env: impl Fn(&str) -> Option<OsString>)
|
||||
}
|
||||
}
|
||||
out.sort_by(|a, b| b.version.cmp(&a.version).then_with(|| a.label.cmp(&b.label)));
|
||||
let mut products = std::collections::HashSet::new();
|
||||
out.retain(|ide| products.insert(ide.product.clone()));
|
||||
out
|
||||
}
|
||||
|
||||
@@ -784,6 +787,13 @@ mod tests {
|
||||
};
|
||||
let mac = mk(home.join("Library/Application Support/JetBrains/IntelliJIdea2025.2"));
|
||||
mk(home.join("Library/Application Support/JetBrains/Toolbox"));
|
||||
// An upgrade leaves the older version's folder behind: only the newest of a product counts.
|
||||
mk(home.join("Library/Application Support/JetBrains/IntelliJIdea2024.3"));
|
||||
mk(home.join("Library/Application Support/JetBrains/IntelliJIdea2025.10"));
|
||||
let dirs = ide_dirs(Layout::Mac, home, |_| None);
|
||||
assert_eq!(dirs.len(), 1);
|
||||
assert_eq!(dirs[0].label, "IntelliJ IDEA 2025.10");
|
||||
std::fs::remove_dir(home.join("Library/Application Support/JetBrains/IntelliJIdea2025.10")).unwrap();
|
||||
let dirs = ide_dirs(Layout::Mac, home, |_| None);
|
||||
assert_eq!(dirs.len(), 1);
|
||||
assert_eq!(dirs[0].label, "IntelliJ IDEA 2025.2");
|
||||
|
||||
@@ -115,6 +115,8 @@ pub fn forwarded(event: &PluginEvent) -> bool {
|
||||
| PluginEvent::ViewMessage { .. }
|
||||
| PluginEvent::OpenTerminal { .. }
|
||||
| PluginEvent::OpenLocation { .. }
|
||||
| PluginEvent::CopyToClipboard { .. }
|
||||
| PluginEvent::OpenUrl { .. }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -197,6 +197,12 @@ pub enum PluginEvent {
|
||||
/// Plugin languages, or the servers they may start, changed: re-fetch
|
||||
/// `languages` (the host re-detects open documents: [`crate::Host::plugin_languages_changed`]).
|
||||
LanguagesChanged,
|
||||
/// Copy `text` to the system clipboard.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
CopyToClipboard { plugin_id: String, text: String },
|
||||
/// Open `url` (`http(s)://`) in the system browser.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
OpenUrl { plugin_id: String, url: String },
|
||||
}
|
||||
|
||||
/// Delivers events to the UI. `client` is the window that invoked the plugin
|
||||
|
||||
+120
-11
@@ -77,6 +77,8 @@ impl Host {
|
||||
lap = std::time::Instant::now();
|
||||
};
|
||||
let root = dunce::canonicalize(root)?;
|
||||
let opening = self.opening(window);
|
||||
let _opening = opening.lock();
|
||||
// Reject a damaged manifest before replacing the currently open project.
|
||||
ide_workspace::workspace::Workspace::load(&root)?;
|
||||
// The window reloaded (or asked again) for the project it already has: keep
|
||||
@@ -230,6 +232,11 @@ impl Host {
|
||||
if cfg.name == "jdtls" {
|
||||
let (excluded_poms, build_files) = jdtls_maven_import(root, &effective);
|
||||
ide_sdk::apply::configure_maven_import(cfg, root, &excluded_poms, build_files.as_deref());
|
||||
ide_sdk::apply::configure_gradle_import(
|
||||
cfg,
|
||||
effective.languages.java.gradle.linked_projects.as_deref(),
|
||||
build_files.as_deref(),
|
||||
);
|
||||
}
|
||||
}));
|
||||
}
|
||||
@@ -564,6 +571,8 @@ impl Host {
|
||||
return Err(IdeError::invalid(format!("{} is a folder", file.display())));
|
||||
}
|
||||
let dir = file.parent().map(Path::to_path_buf).ok_or_else(|| IdeError::invalid("no parent folder"))?;
|
||||
let opening = self.opening(window);
|
||||
let _opening = opening.lock();
|
||||
if let Ok(current) = self.project(window)
|
||||
&& current.light
|
||||
{
|
||||
@@ -876,8 +885,14 @@ impl Project {
|
||||
|
||||
/// Effective source / test / resource / excluded roots (detected + Project Structure marks).
|
||||
pub fn roots(&self) -> ProjectRoots {
|
||||
let detected = with_gradle_roots(&self.root, &self.build_cache_dir(), &self.detected_roots.read());
|
||||
linked_roots(&self.root, &self.settings.effective(), &detected)
|
||||
let settings = self.settings.effective();
|
||||
let detected = with_gradle_roots(
|
||||
&self.root,
|
||||
&self.build_cache_dir(),
|
||||
&settings.languages.java.gradle,
|
||||
&self.detected_roots.read(),
|
||||
);
|
||||
linked_roots(&self.root, &settings, &detected)
|
||||
}
|
||||
|
||||
/// Where the Maven / Gradle tool windows cache what the build tools reported.
|
||||
@@ -1078,7 +1093,7 @@ fn content_roots(settings: &ide_core::settings::Settings, root: &Path, build_cac
|
||||
}
|
||||
let mut out: Vec<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)) {
|
||||
for p in user.chain(ide_build::gradle_external_roots(root, build_cache, &settings.languages.java.gradle)) {
|
||||
if p.is_absolute() && p.is_dir() && !p.starts_with(root) && !out.contains(&p) {
|
||||
out.push(p);
|
||||
}
|
||||
@@ -1182,31 +1197,93 @@ pub(crate) fn jdtls_maven_import(
|
||||
settings: &ide_core::settings::Settings,
|
||||
) -> (Vec<PathBuf>, Option<Vec<String>>) {
|
||||
let maven = &settings.languages.java.maven;
|
||||
if maven.unlinked_projects.is_empty() {
|
||||
let gradle = &settings.languages.java.gradle;
|
||||
if maven.unlinked_projects.is_empty() && gradle.linked_projects.is_none() {
|
||||
return (Vec::new(), None);
|
||||
}
|
||||
let model = ide_build::maven::load(root);
|
||||
let model = ide_build::maven::load_linked(root, maven);
|
||||
let unlinked = ide_build::maven::UnlinkedProjects::from_model(&model, maven);
|
||||
let excluded = unlinked.poms().map(PathBuf::from).collect();
|
||||
let build_files = unlinked.import_poms(&model).map(|poms| {
|
||||
let gradle = ide_build::gradle::load(root).and_then(|g| {
|
||||
let poms = unlinked.import_poms(&model).or_else(|| {
|
||||
gradle.linked_projects.as_ref().map(|_| {
|
||||
model
|
||||
.projects
|
||||
.iter()
|
||||
.filter(|p| !p.aggregated && !unlinked.contains(&p.pom))
|
||||
.map(|p| p.pom.clone())
|
||||
.collect()
|
||||
})
|
||||
});
|
||||
let build_files = poms.map(|poms| {
|
||||
let gradle = ide_build::gradle::load_linked(root, gradle).into_iter().filter_map(|g| {
|
||||
let dir = root.join(&g.dir);
|
||||
let names = ide_build::gradle::SETTINGS_FILES.iter().chain(ide_build::gradle::BUILD_FILES);
|
||||
names.map(|n| dir.join(n)).find(|f| f.is_file())
|
||||
});
|
||||
poms.iter().map(|pom| root.join(pom)).chain(gradle).map(|f| ide_lsp::path_to_uri_string(&f)).collect()
|
||||
let eclipse = if settings.languages.java.gradle.linked_projects.is_some() {
|
||||
independent_eclipse_projects(root)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
poms.iter()
|
||||
.map(|pom| root.join(pom))
|
||||
.chain(gradle)
|
||||
.chain(eclipse)
|
||||
.map(|f| ide_lsp::path_to_uri_string(&f))
|
||||
.collect()
|
||||
});
|
||||
(excluded, build_files)
|
||||
}
|
||||
|
||||
/// Explicit Gradle imports must retain Eclipse projects without re-importing generated build metadata.
|
||||
fn independent_eclipse_projects(root: &Path) -> Vec<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, detected: &[SourceFolder]) -> Vec<SourceFolder> {
|
||||
fn with_gradle_roots(
|
||||
root: &Path,
|
||||
build_cache: &Path,
|
||||
settings: &ide_core::settings::GradleSettings,
|
||||
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(build_cache) {
|
||||
for (dir, kind) in ide_build::gradle_source_roots(root, build_cache, settings) {
|
||||
if !dir.is_dir() {
|
||||
continue;
|
||||
}
|
||||
@@ -1376,6 +1453,37 @@ pub fn apply_git_executable(settings: &ide_core::settings::Settings) {
|
||||
|
||||
#[cfg(test)]
|
||||
mod maven_linkage_tests {
|
||||
#[test]
|
||||
fn explicit_gradle_links_preserve_independent_eclipse_projects() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
for dir in
|
||||
["linked", "unlinked", "eclipse", "nested/eclipse", "unlinked/subproject", "maven", "build/generated"]
|
||||
{
|
||||
std::fs::create_dir_all(root.join(dir)).unwrap();
|
||||
std::fs::write(root.join(dir).join(".project"), "<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();
|
||||
@@ -1473,7 +1581,8 @@ mod gradle_roots_tests {
|
||||
std::fs::write(cache.join("gradle.json"), json.to_string()).unwrap();
|
||||
let guessed = SourceFolder { path: "src/it/java".into(), kind: RootKind::Source, detected_from: None };
|
||||
let other = SourceFolder { path: "web".into(), kind: RootKind::Source, detected_from: None };
|
||||
let out = with_gradle_roots(&root, &cache, &[guessed, other.clone()]);
|
||||
std::fs::write(root.join("build.gradle"), "").unwrap();
|
||||
let out = with_gradle_roots(&root, &cache, &Default::default(), &[guessed, other.clone()]);
|
||||
let abs = |p: PathBuf| ide_workspace::roots::normalize(&s(p));
|
||||
let got: Vec<(String, RootKind)> = out.iter().map(|f| (f.path.clone(), f.kind)).collect();
|
||||
assert_eq!(
|
||||
|
||||
@@ -217,6 +217,8 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
|
||||
| "lsp_resend_diagnostics"
|
||||
| "recent_projects"
|
||||
| "remove_recent_project"
|
||||
| "recent_project_groups"
|
||||
| "set_recent_project_groups"
|
||||
| "open_project"
|
||||
| "close_project"
|
||||
| "project_current"
|
||||
@@ -249,6 +251,8 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
|
||||
| "remote_disconnect"
|
||||
| "remote_connect"
|
||||
| "remote_bootstrap_list"
|
||||
| "wsl_distros"
|
||||
| "wsl_resolve"
|
||||
| "remote_list"
|
||||
| "remote_open"
|
||||
| "remote_of"
|
||||
@@ -312,7 +316,6 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
|
||||
| "vcs_log_users"
|
||||
| "vcs_log_details"
|
||||
| "vcs_log_details_batch"
|
||||
| "vcs_log_index"
|
||||
| "vcs_revision_children"
|
||||
| "vcs_revision_files"
|
||||
| "vcs_open_external_log"
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
use ide_core::NullSink;
|
||||
use ide_host::{Host, HostConfig, ops};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn links_are_validated_persisted_and_unlinked_without_removing_files() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let base = dunce::canonicalize(temp.path()).unwrap();
|
||||
let root = base.join("workspace");
|
||||
for dir in [root.join("a"), root.join("b"), base.join("external")] {
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("settings.gradle"), "rootProject.name = 'same-name'").unwrap();
|
||||
}
|
||||
let config = base.join("config");
|
||||
let host = Host::new(HostConfig {
|
||||
config_dir: config.clone(),
|
||||
data_dir: base.join("data"),
|
||||
local_data_dir: base.join("local"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(NullSink)),
|
||||
owner: |window| window,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
});
|
||||
host.open_project("main", &root).unwrap();
|
||||
assert!(ops::build::gradle_link_projects(&host, "main", vec!["b".into()]).await.is_err());
|
||||
host.set_project_trust(&root, true, false).await.unwrap();
|
||||
let model = ops::build::gradle_link_projects(&host, "main", vec!["b/settings.gradle".into()]).await.unwrap();
|
||||
assert_eq!(model.gradle.iter().map(|g| g.dir.as_str()).collect::<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;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
//! Opening the project a window is already opening reuses it: racing opens (a double
|
||||
//! click, a launch path and a restore) must not start a second index and language servers.
|
||||
|
||||
use ide_core::NullSink;
|
||||
use ide_host::{Host, HostConfig};
|
||||
use std::sync::{Arc, Barrier};
|
||||
|
||||
#[test]
|
||||
fn racing_opens_of_one_window_share_one_project() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap().join("project");
|
||||
std::fs::create_dir_all(root.join("src")).unwrap();
|
||||
std::fs::write(root.join("src/Main.java"), "class Main {}\n").unwrap();
|
||||
let runtime = tokio::runtime::Builder::new_multi_thread().worker_threads(2).enable_all().build().unwrap();
|
||||
let _guard = runtime.enter();
|
||||
let host = Arc::new(Host::new(HostConfig {
|
||||
config_dir: tmp.path().join("config"),
|
||||
data_dir: tmp.path().join("data"),
|
||||
local_data_dir: tmp.path().join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(NullSink)),
|
||||
owner: |w| w,
|
||||
runtime: runtime.handle().clone(),
|
||||
project_environment: false,
|
||||
}));
|
||||
host.trust.decide(&root, true, false).unwrap();
|
||||
|
||||
let start = Arc::new(Barrier::new(4));
|
||||
let opens: Vec<_> = (0..4)
|
||||
.map(|_| {
|
||||
let (host, root, start) = (host.clone(), root.clone(), start.clone());
|
||||
std::thread::spawn(move || {
|
||||
start.wait();
|
||||
host.open_project("main", &root).unwrap()
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let projects: Vec<_> = opens.into_iter().map(|t| t.join().unwrap()).collect();
|
||||
|
||||
let current = host.project("main").unwrap();
|
||||
for project in &projects {
|
||||
assert!(Arc::ptr_eq(project, ¤t), "every open returns the window's one project");
|
||||
}
|
||||
host.close_project("main");
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
//! `plugins/build.sh <dir>` after changing their source or `wit/`.
|
||||
|
||||
use ide_core::error::ErrorKind;
|
||||
use ide_core::ipc_path::to_ipc;
|
||||
use ide_host::plugins::*;
|
||||
use ide_plugin::{EditorContext, HostError, Invocation, Limits};
|
||||
use parking_lot::Mutex;
|
||||
@@ -214,7 +215,7 @@ fn fixture(limits: Limits) -> Fixture {
|
||||
std::fs::create_dir_all(project.join("src")).unwrap();
|
||||
std::fs::write(project.join("src/notes.txt"), "one two three\nfour five\n").unwrap();
|
||||
std::fs::write(tmp.path().join("secret.txt"), "top secret").unwrap();
|
||||
let project = project.canonicalize().unwrap();
|
||||
let project = dunce::canonicalize(project).unwrap();
|
||||
let events: Events = Arc::default();
|
||||
let sink = {
|
||||
let events = events.clone();
|
||||
@@ -359,7 +360,7 @@ fn read_file_is_confined_to_the_project() {
|
||||
let inv = Invocation { client: None, workspace_root: None, editor: None };
|
||||
let err = f.host.run_command("misbehave", "read", Some("src/notes.txt"), inv).unwrap_err().message;
|
||||
assert!(err.contains("no project is open"), "{err}");
|
||||
assert_eq!(f.run("misbehave", "root", None).unwrap().unwrap(), f.project.to_string_lossy());
|
||||
assert_eq!(f.run("misbehave", "root", None).unwrap().unwrap(), to_ipc(&f.project));
|
||||
assert_eq!(f.status("misbehave"), PluginStatus::Active);
|
||||
}
|
||||
|
||||
@@ -1740,7 +1741,7 @@ fn official_checkstyle_checks_on_save() {
|
||||
let java = f.project.join("src/A.java");
|
||||
std::fs::write(&java, "class A {\n\tint x;\n}\n").unwrap();
|
||||
let saved = |f: &Fixture| {
|
||||
f.host.post_events(Some("main"), vec![WorkbenchEvent::FileSaved(java.to_string_lossy().into_owned())]);
|
||||
f.host.post_events(Some("main"), vec![WorkbenchEvent::FileSaved(to_ipc(&java))]);
|
||||
f.host.flush_events_at(Instant::now() + Duration::from_secs(3));
|
||||
};
|
||||
saved(&f);
|
||||
@@ -1793,7 +1794,7 @@ fn official_license_header_reports_fixes_and_scans() {
|
||||
f.workspace.diagnostics.lock().get(&("openidea.license-header".into(), path.to_path_buf())).cloned()
|
||||
};
|
||||
|
||||
f.host.post_events(Some("main"), vec![WorkbenchEvent::FileOpened(a.to_string_lossy().into_owned())]);
|
||||
f.host.post_events(Some("main"), vec![WorkbenchEvent::FileOpened(to_ipc(&a))]);
|
||||
f.host.flush_events_at(Instant::now() + Duration::from_secs(3));
|
||||
assert!(f.host.wait_events(Duration::from_secs(20)));
|
||||
let d = diagnostics(&a).expect("published on open");
|
||||
@@ -1818,7 +1819,7 @@ fn official_license_header_reports_fixes_and_scans() {
|
||||
f.host.run_command("openidea.license-header", "scan", None, f.inv()).unwrap();
|
||||
assert_eq!(diagnostics(&b).map(|d| d.len()), Some(1), "found by the scan");
|
||||
std::fs::write(&a, "// Copyright (c) 2026 X\nfn a() {}\n").unwrap();
|
||||
f.host.post_events(Some("main"), vec![WorkbenchEvent::FileSaved(a.to_string_lossy().into_owned())]);
|
||||
f.host.post_events(Some("main"), vec![WorkbenchEvent::FileSaved(to_ipc(&a))]);
|
||||
f.host.flush_events_at(Instant::now() + Duration::from_secs(3));
|
||||
assert!(f.host.wait_events(Duration::from_secs(20)));
|
||||
assert_eq!(diagnostics(&a).map(|d| d.len()), Some(0), "cleared once the header is there");
|
||||
@@ -2077,6 +2078,62 @@ fn views_open_project_files_at_a_line() {
|
||||
assert!(opened.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vcs_remotes_clipboard_and_open_url() {
|
||||
let f = fixture(Limits::default());
|
||||
let info = f.install(&sample("jobs"));
|
||||
assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error);
|
||||
|
||||
// Not a git repository yet: `vcs-remotes` returns an empty list.
|
||||
assert_eq!(f.run("jobs", "remotes", None).unwrap().as_deref(), Some(""));
|
||||
|
||||
git(&f.project, &["init", "-b", "main"]);
|
||||
git(&f.project, &["remote", "add", "origin", "[email protected]:l0ng-ai/openidea.git"]);
|
||||
git(&f.project, &["remote", "add", "upstream", "https://github.com/org/openidea"]);
|
||||
assert_eq!(
|
||||
f.run("jobs", "remotes", None).unwrap().as_deref(),
|
||||
Some("[email protected]:l0ng-ai/openidea.git\nupstream=https://github.com/org/openidea")
|
||||
);
|
||||
|
||||
f.host.set_capability("jobs", "vcs.read", false).unwrap();
|
||||
assert!(f.run("jobs", "remotes", None).unwrap_err().contains("vcs.read"), "needs vcs.read");
|
||||
|
||||
// `ui.copy-to-clipboard` requires `clipboard` and addresses the calling window.
|
||||
f.take_events();
|
||||
f.run("jobs", "copy", Some("https://github.com/l0ng-ai/openidea")).unwrap();
|
||||
let copied = f.take_events().into_iter().find_map(|(client, e)| match e {
|
||||
PluginEvent::CopyToClipboard { plugin_id, text } => Some((client, plugin_id, text)),
|
||||
_ => None,
|
||||
});
|
||||
assert_eq!(copied, Some((Some("main".into()), "jobs".into(), "https://github.com/l0ng-ai/openidea".into())));
|
||||
|
||||
f.host.set_capability("jobs", "clipboard", false).unwrap();
|
||||
f.take_events();
|
||||
assert!(f.run("jobs", "copy", Some("secret")).unwrap_err().contains("clipboard"), "needs clipboard");
|
||||
assert!(f.take_events().iter().all(|(_, e)| !matches!(e, PluginEvent::CopyToClipboard { .. })));
|
||||
|
||||
// `ui.open-url` accepts http(s) URLs and rejects other schemes.
|
||||
f.take_events();
|
||||
f.run("jobs", "open-url", Some("https://github.com/l0ng-ai/openidea/blob/main/README.md#L1")).unwrap();
|
||||
let opened = f.take_events().into_iter().find_map(|(client, e)| match e {
|
||||
PluginEvent::OpenUrl { plugin_id, url } => Some((client, plugin_id, url)),
|
||||
_ => None,
|
||||
});
|
||||
assert_eq!(
|
||||
opened,
|
||||
Some(
|
||||
(Some("main".into()), "jobs".into(), "https://github.com/l0ng-ai/openidea/blob/main/README.md#L1".into(),)
|
||||
)
|
||||
);
|
||||
|
||||
for bad in ["javascript:alert(1)", "file:///etc/passwd", ""] {
|
||||
f.take_events();
|
||||
let err = f.run("jobs", "open-url", Some(bad)).unwrap_err();
|
||||
assert!(err.contains("http:// or https://"), "{bad:?}: {err}");
|
||||
assert!(f.take_events().iter().all(|(_, e)| !matches!(e, PluginEvent::OpenUrl { .. })));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn views_can_use_an_svg_icon_from_the_package() {
|
||||
let f = fixture(Limits::default());
|
||||
|
||||
@@ -223,7 +223,8 @@ async fn kotlin_gradle() {
|
||||
.await;
|
||||
let p = open("kotlin-gradle").await;
|
||||
let model = ops::build::build_model(&p.host, WINDOW).await.unwrap();
|
||||
let gradle = model.gradle.expect("a Gradle model");
|
||||
assert_eq!(model.gradle.len(), 1, "one independent Gradle build");
|
||||
let gradle = &model.gradle[0];
|
||||
assert_eq!(gradle.projects.iter().map(|g| g.name.as_str()).collect::<Vec<_>>(), ["openidea-fixture-library"]);
|
||||
}
|
||||
|
||||
@@ -231,8 +232,9 @@ async fn kotlin_gradle() {
|
||||
async fn sync(fixture: &str, tool: ide_build::BuildTool) -> ide_build::BuildModel {
|
||||
let p = open(fixture).await;
|
||||
let progress = ops::Channel::new(|_: ide_build::SyncProgress| {});
|
||||
let model =
|
||||
ops::build::build_sync(&p.host, WINDOW, tool, ide_core::RequestId(1), false, vec![], progress).await.unwrap();
|
||||
let model = ops::build::build_sync(&p.host, WINDOW, tool, ide_core::RequestId(1), false, vec![], None, progress)
|
||||
.await
|
||||
.unwrap();
|
||||
// The cached sync is what the next open shows.
|
||||
let reloaded = ops::build::build_model(&p.host, WINDOW).await.unwrap();
|
||||
assert_eq!(reloaded, model);
|
||||
@@ -253,7 +255,9 @@ async fn maven_sync() {
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[ignore = "project smoke: needs Gradle and a JDK"]
|
||||
async fn gradle_sync() {
|
||||
let gradle = sync("kotlin-gradle", ide_build::BuildTool::Gradle).await.gradle.unwrap();
|
||||
let model = sync("kotlin-gradle", ide_build::BuildTool::Gradle).await;
|
||||
assert_eq!(model.gradle.len(), 1, "one independent Gradle build");
|
||||
let gradle = &model.gradle[0];
|
||||
assert_eq!(gradle.sync_error, None);
|
||||
assert!(gradle.synced_at.is_some());
|
||||
let tasks: Vec<&str> = gradle.projects[0].tasks.iter().map(|t| t.name.as_str()).collect();
|
||||
|
||||
@@ -249,6 +249,7 @@ async fn malicious_project_stays_editable_then_starts_in_place_after_trust() {
|
||||
ide_core::RequestId(472),
|
||||
false,
|
||||
vec![],
|
||||
None,
|
||||
ops::Channel::new(|_| {}),
|
||||
)
|
||||
};
|
||||
|
||||
@@ -49,6 +49,10 @@ pub mod framing {
|
||||
}
|
||||
return Ok(None);
|
||||
}
|
||||
// A UTF-16LE line break is `\n\0`: the cut after `\n` leaves a NUL at the start of the
|
||||
// next line, which would turn the `Content-Length` after it into noise.
|
||||
let nuls = line.iter().take_while(|&&b| b == 0).count();
|
||||
line.drain(..nuls);
|
||||
if line.last() != Some(&b'\n') {
|
||||
if n as u64 >= MAX_HEADER_LINE {
|
||||
return Err(Error::new(ErrorKind::InvalidData, "header line too long"));
|
||||
|
||||
@@ -69,6 +69,21 @@ mod framing_tests {
|
||||
assert_eq!(noise[2], "Picked up JAVA_TOOL_OPTIONS: -Dx");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn utf16_output_of_wsl_exe_before_a_message_does_not_swallow_it() {
|
||||
// `wsl.exe` reports its own errors as UTF-16LE with a BOM; the real server only starts
|
||||
// after it, so the first message must still be read whole.
|
||||
let notice = "The Windows Subsystem for Linux instance has terminated.";
|
||||
let mut input: Vec<u8> = [0xff, 0xfe].into();
|
||||
input.extend(format!("{notice}\r\n").encode_utf16().flat_map(u16::to_le_bytes));
|
||||
input.extend(b"Content-Length: 2\r\n\r\n{}");
|
||||
let (msgs, noise) = read_with_noise(&input).await;
|
||||
assert_eq!(msgs.unwrap(), vec![b"{}".to_vec()]);
|
||||
// The text is kept for the error shown to the user, whatever the BOM decodes to.
|
||||
let ascii: String = noise.concat().chars().filter(char::is_ascii).filter(|c| *c != '\0').collect();
|
||||
assert_eq!(ascii.trim(), notice);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unterminated_output_then_eof_is_reported() {
|
||||
// `pause` prints its prompt without a newline and the process is gone.
|
||||
|
||||
@@ -162,7 +162,8 @@ pub fn client_capabilities() -> Value {
|
||||
"callHierarchy": { "dynamicRegistration": false },
|
||||
"typeHierarchy": { "dynamicRegistration": false }
|
||||
},
|
||||
"experimental": {}
|
||||
// rust-analyzer: report when the project has loaded (`quiescent`).
|
||||
"experimental": { "serverStatusNotification": true }
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -234,6 +234,11 @@ impl Handler for ClientHandler {
|
||||
let Some(mgr) = self.mgr.upgrade() else { return };
|
||||
match method {
|
||||
"$/progress" => self.progress(&mgr, params),
|
||||
"experimental/serverStatus" => {
|
||||
if let Some(quiescent) = params.get("quiescent").and_then(Value::as_bool) {
|
||||
self.server.set_loading(mgr.sink(), !quiescent);
|
||||
}
|
||||
}
|
||||
"textDocument/publishDiagnostics" => self.publish_diagnostics(&mgr, params),
|
||||
"window/showMessage" => self.show_message(&mgr, ¶ms),
|
||||
"window/logMessage" => {
|
||||
|
||||
@@ -18,6 +18,7 @@ mod inspect;
|
||||
pub mod jdtls;
|
||||
mod launch;
|
||||
mod library_source;
|
||||
mod loading;
|
||||
mod pull;
|
||||
mod scope;
|
||||
mod server;
|
||||
@@ -216,6 +217,15 @@ fn runtime_handle() -> tokio::runtime::Handle {
|
||||
})
|
||||
}
|
||||
|
||||
fn goto_is_empty(r: &Option<lsp::GotoDefinitionResponse>) -> bool {
|
||||
match r {
|
||||
None => true,
|
||||
Some(lsp::GotoDefinitionResponse::Scalar(_)) => false,
|
||||
Some(lsp::GotoDefinitionResponse::Array(v)) => v.is_empty(),
|
||||
Some(lsp::GotoDefinitionResponse::Link(v)) => v.is_empty(),
|
||||
}
|
||||
}
|
||||
|
||||
fn rpc_err(server: &str, method: &str, e: RpcError) -> IdeError {
|
||||
match e {
|
||||
RpcError::Cancelled => IdeError::cancelled(),
|
||||
@@ -1163,8 +1173,13 @@ impl LspManager {
|
||||
let mut out = Outcome::new();
|
||||
for t in targets.iter().filter(|t| snap.asks(t, pos)) {
|
||||
let r: IdeResult<Option<lsp::Hover>> =
|
||||
self.call(t, "textDocument/hover", snap.pos_params(t, pos), Some(&cancel), DEFAULT_TIMEOUT).await;
|
||||
loading::while_loading(&t.server, Some(&cancel), loading::LOADING_WAIT, Option::is_none, || {
|
||||
self.call(t, "textDocument/hover", snap.pos_params(t, pos), Some(&cancel), DEFAULT_TIMEOUT)
|
||||
})
|
||||
.await;
|
||||
match r {
|
||||
// Still loading: no documentation yet, not an error.
|
||||
Err(e) if e.kind == ErrorKind::TimedOut && t.server.is_loading() => out.value = Some(None),
|
||||
Ok(Some(h)) => {
|
||||
if let Some(h) = convert::hover(&self.conv(t.enc()), &snap.path, h) {
|
||||
return Ok(Some(h));
|
||||
@@ -1254,16 +1269,17 @@ impl LspManager {
|
||||
) -> IdeResult<Vec<Location>> {
|
||||
let mut out = Outcome::new();
|
||||
for t in targets.iter().filter(|t| snap.asks(t, pos)) {
|
||||
match self
|
||||
.call::<_, Option<lsp::GotoDefinitionResponse>>(
|
||||
let r = loading::while_loading(&t.server, cancel, loading::LOADING_WAIT, goto_is_empty, || {
|
||||
self.call::<_, Option<lsp::GotoDefinitionResponse>>(
|
||||
t,
|
||||
method,
|
||||
snap.pos_params(t, pos),
|
||||
cancel,
|
||||
DEFAULT_TIMEOUT,
|
||||
)
|
||||
.await
|
||||
{
|
||||
})
|
||||
.await;
|
||||
match r {
|
||||
Ok(Some(r)) => {
|
||||
let locs = convert::goto_response(&self.conv(t.enc()), r);
|
||||
let locs = library_sources(snap, pos, locs).await;
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
//! Requests answered while a server is still loading the project.
|
||||
//!
|
||||
//! rust-analyzer answers navigation with nothing until it has read the project's
|
||||
//! files, misses macro-generated items until build scripts and proc macros are
|
||||
//! built, and withdraws answers (`ContentModified`) when loading changes its
|
||||
//! data. Such an answer is not final: the request runs again as loading
|
||||
//! progresses, so ⌘B right after opening a project lands once the server can
|
||||
//! answer instead of reporting "not found". Only servers that report loading
|
||||
//! through `experimental/serverStatus` wait; for others nothing changes.
|
||||
|
||||
use crate::server::Server;
|
||||
use ide_core::error::ErrorKind;
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
use std::future::Future;
|
||||
use std::time::Duration;
|
||||
|
||||
/// How long an explicit request waits for a server that is still loading.
|
||||
pub(crate) const LOADING_WAIT: Duration = Duration::from_secs(30);
|
||||
/// Retry interval while it loads, for phases that end without a notification.
|
||||
const POLL: Duration = Duration::from_secs(3);
|
||||
|
||||
/// Runs `query` on `server`. While the server is loading, an empty answer or a
|
||||
/// withdrawn one (`ContentModified`; not a cancellation of `cancel`) runs again
|
||||
/// whenever a loading phase ends, until it answers, loading ends, or `wait`
|
||||
/// passes; then a still-loading server yields a `TimedOut` error saying so.
|
||||
pub(crate) async fn while_loading<R, F, Fut>(
|
||||
server: &Server,
|
||||
cancel: Option<&CancellationToken>,
|
||||
wait: Duration,
|
||||
is_empty: impl Fn(&R) -> bool,
|
||||
query: F,
|
||||
) -> IdeResult<R>
|
||||
where
|
||||
F: Fn() -> Fut,
|
||||
Fut: Future<Output = IdeResult<R>>,
|
||||
{
|
||||
let cancelled = || cancel.is_some_and(CancellationToken::is_cancelled);
|
||||
let deadline = tokio::time::Instant::now() + wait;
|
||||
loop {
|
||||
let phase = server.phase_notified();
|
||||
tokio::pin!(phase);
|
||||
phase.as_mut().enable();
|
||||
let asked_while_loading = server.is_loading();
|
||||
let result = query().await;
|
||||
let retry = match &result {
|
||||
Ok(r) => is_empty(r),
|
||||
Err(e) => e.kind == ErrorKind::Cancelled && !cancelled(),
|
||||
};
|
||||
if !retry || cancelled() || server.running().is_none() {
|
||||
return result;
|
||||
}
|
||||
if !server.is_loading() {
|
||||
// Loading ended while the server answered from its old state: ask once more.
|
||||
if asked_while_loading {
|
||||
continue;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
let now = tokio::time::Instant::now();
|
||||
if now >= deadline {
|
||||
return Err(IdeError::new(ErrorKind::TimedOut, format!("{} is still loading the project", server.name)));
|
||||
}
|
||||
let until = deadline.min(now + POLL);
|
||||
let stop = async {
|
||||
match cancel {
|
||||
Some(c) => c.cancelled().await,
|
||||
None => std::future::pending().await,
|
||||
}
|
||||
};
|
||||
tokio::select! {
|
||||
() = phase => {}
|
||||
() = tokio::time::sleep_until(until) => {}
|
||||
() = stop => return Err(IdeError::cancelled()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -110,6 +110,11 @@ pub struct Server {
|
||||
pub published_notify: Notify,
|
||||
/// Woken when the last `$/progress` task ends.
|
||||
idle_notify: Notify,
|
||||
/// `experimental/serverStatus` (rust-analyzer): `None` until the server sends
|
||||
/// one, then whether it is still loading the project.
|
||||
loading: Mutex<Option<bool>>,
|
||||
/// Woken whenever a `$/progress` task ends or the server's loading state changes.
|
||||
phase_notify: Notify,
|
||||
transient: Mutex<HashMap<PathBuf, Transient>>,
|
||||
pub java_build_lock: Arc<tokio::sync::Mutex<()>>,
|
||||
pub java_autobuild_suspended: std::sync::atomic::AtomicBool,
|
||||
@@ -144,6 +149,8 @@ impl Server {
|
||||
published: Mutex::new(HashMap::new()),
|
||||
published_notify: Notify::new(),
|
||||
idle_notify: Notify::new(),
|
||||
loading: Mutex::new(None),
|
||||
phase_notify: Notify::new(),
|
||||
transient: Mutex::new(HashMap::new()),
|
||||
java_build_lock: Arc::new(tokio::sync::Mutex::new(())),
|
||||
java_autobuild_suspended: std::sync::atomic::AtomicBool::new(false),
|
||||
@@ -389,22 +396,48 @@ impl Server {
|
||||
if self.running().is_none() {
|
||||
return;
|
||||
}
|
||||
let busy = !self.progress.lock().is_empty();
|
||||
let busy = self.is_busy();
|
||||
self.set_status(sink, if busy { ServerStatus::Busy } else { ServerStatus::Ready }, None);
|
||||
}
|
||||
|
||||
/// Whether the server reports work in progress (indexing, analysis).
|
||||
pub fn is_busy(&self) -> bool {
|
||||
!self.progress.lock().is_empty()
|
||||
!self.progress.lock().is_empty() || *self.loading.lock() == Some(true)
|
||||
}
|
||||
|
||||
/// Whether the server says it is still loading the project, so an empty
|
||||
/// answer may not be final. Only `serverStatus` tells: `$/progress` also
|
||||
/// covers background work (indexing, builds) during which answers are final.
|
||||
pub fn is_loading(&self) -> bool {
|
||||
*self.loading.lock() == Some(true)
|
||||
}
|
||||
|
||||
/// Records `experimental/serverStatus`: `quiescent` false while the project loads.
|
||||
pub fn set_loading(&self, sink: &SharedSink, loading: bool) {
|
||||
if self.loading.lock().replace(loading) == Some(loading) {
|
||||
return;
|
||||
}
|
||||
self.refresh_busy_status(sink);
|
||||
self.phase_ended();
|
||||
}
|
||||
|
||||
/// Called after a `$/progress` task ended.
|
||||
pub fn progress_ended(&self) {
|
||||
self.phase_ended();
|
||||
}
|
||||
|
||||
fn phase_ended(&self) {
|
||||
self.phase_notify.notify_waiters();
|
||||
if !self.is_busy() {
|
||||
self.idle_notify.notify_waiters();
|
||||
}
|
||||
}
|
||||
|
||||
/// Woken when the next loading phase ends.
|
||||
pub fn phase_notified(&self) -> tokio::sync::futures::Notified<'_> {
|
||||
self.phase_notify.notified()
|
||||
}
|
||||
|
||||
/// Waits until no `$/progress` task is active; false on timeout or cancellation.
|
||||
pub async fn wait_idle(&self, timeout: Duration, cancel: &CancellationToken) -> bool {
|
||||
let deadline = tokio::time::Instant::now() + timeout;
|
||||
@@ -499,6 +532,7 @@ impl Server {
|
||||
}
|
||||
|
||||
pub fn take_progress(&self) -> Vec<(TaskId, String)> {
|
||||
*self.loading.lock() = None;
|
||||
self.progress.lock().drain().map(|(_, p)| (p.task, p.title)).collect()
|
||||
}
|
||||
|
||||
|
||||
@@ -1009,6 +1009,78 @@ async fn progress_tasks_and_busy_status() {
|
||||
assert!(tasks[2].3);
|
||||
}
|
||||
|
||||
/// While a server says it is loading the project (rust-analyzer: `serverStatus`
|
||||
/// not quiescent) an empty or withdrawn answer is asked again once loading moves
|
||||
/// on. `$/progress` alone (background indexing, `cargo check`) doesn't wait.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn navigation_waits_for_a_server_that_is_still_loading() {
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum Answer {
|
||||
Empty,
|
||||
ContentModified,
|
||||
}
|
||||
for answer in [Answer::Empty, Answer::ContentModified] {
|
||||
let h = harness(&[("mock", incremental_caps())]);
|
||||
let (doc, path) = h.open("a.mk", "fn foo() {}\nfoo();\n");
|
||||
h.wait_status("mock", ServerStatus::Ready).await;
|
||||
let s = h.launcher.state("mock");
|
||||
let peer = s.peer();
|
||||
peer.notify("experimental/serverStatus", json!({ "health": "ok", "quiescent": false })).unwrap();
|
||||
h.wait_status("mock", ServerStatus::Busy).await;
|
||||
if let Answer::ContentModified = answer {
|
||||
s.errors.lock().insert("textDocument/definition".into(), (-32801, "content modified".into()));
|
||||
}
|
||||
let mgr = h.mgr.clone();
|
||||
let goto = tokio::spawn(async move { mgr.goto(doc, Pos::new(1, 1), GotoKind::Definition).await });
|
||||
eventually(|| !s.requested("textDocument/definition").is_empty()).await;
|
||||
assert!(!goto.is_finished(), "{answer:?}: an answer while loading is not final");
|
||||
|
||||
s.errors.lock().clear();
|
||||
let r = json!({"start":{"line":0,"character":3},"end":{"line":0,"character":6}});
|
||||
s.respond("textDocument/definition", json!({ "uri": uri_of(&path), "range": r }));
|
||||
peer.notify("experimental/serverStatus", json!({ "health": "ok", "quiescent": true })).unwrap();
|
||||
let defs = tokio::time::timeout(Duration::from_secs(10), goto).await.unwrap().unwrap().unwrap();
|
||||
assert_eq!(defs, vec![ide_core::Location::file(path.clone(), rng(0, 3, 0, 6))], "{answer:?}");
|
||||
h.wait_status("mock", ServerStatus::Ready).await;
|
||||
}
|
||||
|
||||
// Hover documentation the same way.
|
||||
let h = harness(&[("mock", incremental_caps())]);
|
||||
let (doc, _) = h.open("a.mk", "x");
|
||||
h.wait_status("mock", ServerStatus::Ready).await;
|
||||
let s = h.launcher.state("mock");
|
||||
s.peer().notify("experimental/serverStatus", json!({ "health": "ok", "quiescent": false })).unwrap();
|
||||
h.wait_status("mock", ServerStatus::Busy).await;
|
||||
let mgr = h.mgr.clone();
|
||||
let hover = tokio::spawn(async move { mgr.hover(doc, Pos::new(0, 0), Default::default()).await });
|
||||
eventually(|| !s.requested("textDocument/hover").is_empty()).await;
|
||||
s.respond("textDocument/hover", json!({ "contents": "docs" }));
|
||||
s.peer().notify("experimental/serverStatus", json!({ "health": "ok", "quiescent": true })).unwrap();
|
||||
let hover = tokio::time::timeout(Duration::from_secs(10), hover).await.unwrap().unwrap().unwrap();
|
||||
assert!(hover.is_some());
|
||||
|
||||
// "Nothing here" is the answer at once: rust-analyzer loaded with `cargo check`
|
||||
// running, and a server without `serverStatus` indexing in the background.
|
||||
for quiescent in [Some(true), None] {
|
||||
let h = harness(&[("mock", incremental_caps())]);
|
||||
let (doc, _) = h.open("a.mk", "x");
|
||||
h.wait_status("mock", ServerStatus::Ready).await;
|
||||
let s = h.launcher.state("mock");
|
||||
let peer = s.peer();
|
||||
if let Some(q) = quiescent {
|
||||
peer.notify("experimental/serverStatus", json!({ "health": "ok", "quiescent": q })).unwrap();
|
||||
}
|
||||
peer.notify("$/progress", json!({ "token": "bg", "value": { "kind": "begin", "title": "Indexing" } })).unwrap();
|
||||
h.wait_status("mock", ServerStatus::Busy).await;
|
||||
let defs = tokio::time::timeout(Duration::from_secs(2), h.mgr.goto(doc, Pos::new(0, 0), GotoKind::Definition))
|
||||
.await
|
||||
.unwrap_or_else(|_| panic!("{quiescent:?}: no wait for background work"))
|
||||
.unwrap();
|
||||
assert!(defs.is_empty());
|
||||
assert_eq!(s.requested("textDocument/definition").len(), 1, "{quiescent:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn progress_titled_with_an_executable_path_shows_the_program_name() {
|
||||
let h = harness(&[("mock", incremental_caps())]);
|
||||
@@ -3333,7 +3405,8 @@ async fn interactive_capability_is_scoped_to_gopls() {
|
||||
let (doc, _) = h.open("probe.mk", "x\n");
|
||||
h.wait_status("mock", ServerStatus::Ready).await;
|
||||
let s = h.launcher.state("mock");
|
||||
assert_eq!(s.requested("initialize")[0]["capabilities"]["experimental"], json!({}));
|
||||
let experimental = &s.requested("initialize")[0]["capabilities"]["experimental"];
|
||||
assert!(experimental.get("interactiveResolve").is_none(), "{experimental}");
|
||||
let form = h
|
||||
.mgr
|
||||
.resolve_command(
|
||||
|
||||
@@ -9,7 +9,7 @@ use std::{
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use ide_core::{IdeError, IdeResult, error::ErrorKind};
|
||||
use ide_core::{IdeError, IdeResult, IpcPath, error::ErrorKind};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use specta::Type;
|
||||
@@ -22,7 +22,7 @@ use crate::kernel::KernelOutput;
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NotebookSnapshot {
|
||||
pub id: String,
|
||||
pub path: String,
|
||||
pub path: IpcPath,
|
||||
pub version: u32,
|
||||
pub dirty: bool,
|
||||
pub can_undo: bool,
|
||||
@@ -188,7 +188,7 @@ impl NotebookDocument {
|
||||
pub fn snapshot(&self) -> NotebookSnapshot {
|
||||
NotebookSnapshot {
|
||||
id: self.id.clone(),
|
||||
path: self.path.to_string_lossy().into_owned(),
|
||||
path: self.path.clone().into(),
|
||||
version: self.version,
|
||||
dirty: self.is_dirty(),
|
||||
can_undo: !self.execution_active && !self.undo.is_empty(),
|
||||
|
||||
@@ -22,3 +22,4 @@ wasmtime-wasi = { version = "49.0.4", default-features = false, features = ["p2"
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "time"] }
|
||||
wat = "1.258"
|
||||
@@ -13,6 +13,7 @@ use crate::types::{
|
||||
};
|
||||
use crate::wit_host::{bindings, providers_bindings, syntax_bindings, views_bindings};
|
||||
use ide_core::CancellationToken;
|
||||
use ide_core::ipc_path::to_ipc;
|
||||
use ide_extension::Capability;
|
||||
use parking_lot::Mutex;
|
||||
use std::path::Path;
|
||||
@@ -32,7 +33,7 @@ use views_bindings::exports::openidea::plugin::view_provider as wit_view;
|
||||
use views_bindings::openidea::plugin::view_types as wit_vt;
|
||||
|
||||
/// The API version of the world above, `major.minor` of [`host_api`].
|
||||
pub const API_VERSION: &str = "1.0";
|
||||
pub const API_VERSION: &str = "1.1";
|
||||
const EPOCH_TICK: Duration = Duration::from_millis(10);
|
||||
pub(crate) const MAX_TEXT: usize = 1 << 20;
|
||||
pub(crate) const MAX_TITLE: usize = 256;
|
||||
@@ -45,6 +46,14 @@ pub const CANCELLED: &str = "was cancelled";
|
||||
/// documents. Capabilities are already checked when these are called.
|
||||
pub trait HostApi: Send + Sync {
|
||||
fn notify(&self, client: Option<&str>, level: NotificationLevel, title: String, body: String);
|
||||
fn copy_to_clipboard(&self, client: Option<&str>, text: String) -> Result<(), HostError> {
|
||||
let _ = (client, text);
|
||||
Ok(())
|
||||
}
|
||||
fn open_url(&self, client: Option<&str>, url: String) -> Result<(), HostError> {
|
||||
let _ = (client, url);
|
||||
Ok(())
|
||||
}
|
||||
/// Sets (`Some`) or removes the plugin's status bar item `id`.
|
||||
fn status_item(&self, id: &str, item: Option<StatusItem>);
|
||||
/// Roots of the open projects.
|
||||
@@ -66,6 +75,10 @@ pub trait HostApi: Send + Sync {
|
||||
fn storage_set(&self, key: &str, value: Option<String>) -> Result<(), HostError>;
|
||||
fn storage_keys(&self, prefix: &str) -> Result<Vec<String>, HostError>;
|
||||
fn vcs_status(&self, root: &Path) -> Result<Option<RepositoryStatus>, HostError>;
|
||||
fn vcs_remotes(&self, root: &Path) -> Result<Vec<(String, String)>, HostError> {
|
||||
let _ = root;
|
||||
Ok(Vec::new())
|
||||
}
|
||||
fn vcs_fetch(&self, root: &Path, cancel: &CancellationToken) -> Result<(), HostError>;
|
||||
/// Subscribes to (or unsubscribes from) a kind of workbench event.
|
||||
/// Project files matching `glob` (root-relative), at most `limit`.
|
||||
@@ -199,8 +212,10 @@ impl PluginEngine {
|
||||
/// Rejects a component this host can't run, with a message that says what
|
||||
/// to do: one importing interfaces outside the plugin API and WASI, or one
|
||||
/// built for a plugin API this host doesn't implement (another major, or a
|
||||
/// newer minor). Returns the API version the component was built for
|
||||
/// (`None`: it uses no plugin interface).
|
||||
/// newer minor), or one that doesn't link: an import the host lacks or
|
||||
/// declares with another type (same API version, different WIT), or a
|
||||
/// missing plugin export. Returns the API version the component was built
|
||||
/// for (`None`: it uses no plugin interface).
|
||||
pub fn check_component(&self, component: &Component) -> Result<Option<semver::Version>, String> {
|
||||
let ty = component.component_type();
|
||||
let imports: Vec<String> = ty.imports(&self.engine).map(|(name, _)| name.to_owned()).collect();
|
||||
@@ -217,10 +232,20 @@ impl PluginEngine {
|
||||
if let Some(v) = &built_for {
|
||||
check_api(v, &host_api())?;
|
||||
}
|
||||
let pre = self.linker.instantiate_pre(component).map_err(|e| link_error(&format!("{e:#}")))?;
|
||||
bindings::PluginIndices::new(&pre).map_err(|e| link_error(&format!("{e:#}")))?;
|
||||
Ok(built_for)
|
||||
}
|
||||
}
|
||||
|
||||
/// Explains a component that doesn't link against this host's plugin API.
|
||||
fn link_error(detail: &str) -> String {
|
||||
format!(
|
||||
"the component doesn't match the plugin API of this IDE ({API_VERSION}): {detail}. \
|
||||
If it was built for a newer OpenIDEA, update OpenIDEA; otherwise rebuild it with the {API_VERSION} SDK"
|
||||
)
|
||||
}
|
||||
|
||||
/// Interface names of the plugin API start with this (`openidea:plugin/[email protected]`).
|
||||
const PLUGIN_PACKAGE_PREFIX: &str = "openidea:plugin/";
|
||||
|
||||
@@ -530,11 +555,7 @@ impl Runtime {
|
||||
arm(&mut store, &setup.limits, setup.limits.timeout).map_err(|e| e.to_string())?;
|
||||
let api_hint = |e: wasmtime::Error| {
|
||||
let msg = describe_trap(&e, &setup.limits);
|
||||
if msg.contains("openidea:plugin/") {
|
||||
format!("{msg} (the component was built for a different plugin API; this IDE runs API {API_VERSION})")
|
||||
} else {
|
||||
msg
|
||||
}
|
||||
if msg.contains("openidea:plugin/") { link_error(&msg) } else { msg }
|
||||
};
|
||||
let pre = engine.linker.instantiate_pre(component).map_err(api_hint)?;
|
||||
let plugin_indices = bindings::PluginIndices::new(&pre).map_err(api_hint)?;
|
||||
@@ -915,12 +936,7 @@ fn location_from_wit(l: wit_types::Location) -> Location {
|
||||
}
|
||||
|
||||
fn doc_to_wit(d: &ProviderDocument) -> wit_prov::Document {
|
||||
wit_prov::Document {
|
||||
path: d.path.to_string_lossy().into_owned(),
|
||||
language: d.language.clone(),
|
||||
version: d.version,
|
||||
text: d.text.clone(),
|
||||
}
|
||||
wit_prov::Document { path: to_ipc(&d.path), language: d.language.clone(), version: d.version, text: d.text.clone() }
|
||||
}
|
||||
|
||||
/// Resets the budgets for the next call: fuel, deadline, cancellation.
|
||||
@@ -992,7 +1008,7 @@ fn code_action_to_wit(a: &CodeAction) -> wit_prov::CodeAction {
|
||||
.edits
|
||||
.iter()
|
||||
.map(|(path, edits)| wit_prov::FileEdit {
|
||||
path: path.to_string_lossy().into_owned(),
|
||||
path: to_ipc(path),
|
||||
edits: edits
|
||||
.iter()
|
||||
.map(|e| wit_types::TextEdit { range: range_to_wit(e.range), text: e.new_text.clone() })
|
||||
@@ -1111,6 +1127,27 @@ fn event_to_wit_view(e: ViewEvent) -> wit_vt::ViewEvent {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn provider_and_resolved_action_paths_match_ipc_events() {
|
||||
for (path, expected) in [
|
||||
(r"C:\project\Example.java", "C:/project/Example.java"),
|
||||
(r"\\?\C:\project\Example.java", "C:/project/Example.java"),
|
||||
(r"\\server\share\Example.java", "//server/share/Example.java"),
|
||||
] {
|
||||
let doc = ProviderDocument { path: path.into(), ..Default::default() };
|
||||
assert_eq!(doc_to_wit(&doc).path, expected);
|
||||
let action = CodeAction {
|
||||
title: "Fix".into(),
|
||||
kind: None,
|
||||
preferred: false,
|
||||
edits: vec![(path.into(), Vec::new())],
|
||||
data: None,
|
||||
};
|
||||
assert_eq!(code_action_to_wit(&action).edits[0].path, expected);
|
||||
}
|
||||
}
|
||||
|
||||
fn ext(kind: &str) -> wit_types::Extension {
|
||||
wit_types::Extension { kind: kind.into(), data: "{}".into() }
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
use crate::runtime::{StoreData, clip};
|
||||
use crate::sandbox;
|
||||
use crate::types::{EditorContext, EventKind, HostError, HttpRequest, NotificationLevel, StatusItem, net_host};
|
||||
use ide_core::ipc_path::to_ipc;
|
||||
use ide_extension::Capability;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -242,6 +243,24 @@ impl ui::Host for StoreData {
|
||||
self.host.status_item(&clip(id, 64), None);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn copy_to_clipboard(&mut self, text: String) -> Fallible<()> {
|
||||
if let Err(e) = self.require(Capability::Clipboard) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
let client = self.invocation.client.clone();
|
||||
Ok(self.host.copy_to_clipboard(client.as_deref(), clip(text, crate::runtime::MAX_TEXT)).map_err(to_wit))
|
||||
}
|
||||
|
||||
fn open_url(&mut self, url: String) -> Fallible<()> {
|
||||
let trimmed = url.trim();
|
||||
let valid = (trimmed.starts_with("https://") || trimmed.starts_with("http://")) && trimmed.len() <= 8192;
|
||||
if !valid {
|
||||
return Ok(Err(wit::Error::Invalid(format!("{url:?}: expected an http:// or https:// URL"))));
|
||||
}
|
||||
let client = self.invocation.client.clone();
|
||||
Ok(self.host.open_url(client.as_deref(), trimmed.to_owned()).map_err(to_wit))
|
||||
}
|
||||
}
|
||||
|
||||
impl storage::Host for StoreData {
|
||||
@@ -292,7 +311,8 @@ impl workspace::HostProject for StoreData {
|
||||
if let Err(e) = self.require(Capability::ProjectRead) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
Ok(Ok(self.table.get(&this)?.root.to_string_lossy().into_owned()))
|
||||
// Paths reach plugins in IPC form (`/` on Windows), as the UI's event paths do, so they compare as text.
|
||||
Ok(Ok(to_ipc(&self.table.get(&this)?.root)))
|
||||
}
|
||||
|
||||
fn read_file(&mut self, this: Resource<ProjectRes>, path: String) -> Fallible<String> {
|
||||
@@ -328,7 +348,7 @@ impl editor::Host for StoreData {}
|
||||
|
||||
impl editor::HostTextEditor for StoreData {
|
||||
fn path(&mut self, this: Resource<EditorRes>) -> wasmtime::Result<String> {
|
||||
Ok(self.table.get(&this)?.ctx.path.to_string_lossy().into_owned())
|
||||
Ok(to_ipc(&self.table.get(&this)?.ctx.path))
|
||||
}
|
||||
|
||||
fn language(&mut self, this: Resource<EditorRes>) -> wasmtime::Result<String> {
|
||||
@@ -438,6 +458,14 @@ impl vcs::Host for StoreData {
|
||||
let token = self.cancel.token();
|
||||
Ok(self.host.vcs_fetch(&root, &token).map_err(to_wit))
|
||||
}
|
||||
|
||||
fn remotes(&mut self, project: Resource<ProjectRes>) -> Fallible<Vec<(String, String)>> {
|
||||
if let Err(e) = self.require(Capability::VcsRead) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
let root = self.table.get(&project)?.root.clone();
|
||||
Ok(self.host.vcs_remotes(&root).map_err(to_wit))
|
||||
}
|
||||
}
|
||||
|
||||
impl events::Host for StoreData {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
//! `PluginEngine::check_component` (what installing and `openidea-plugin check`
|
||||
//! run) refuses a component the IDE could not start: every import has to link
|
||||
//! against the host, function by function, and the plugin world's exports have
|
||||
//! to be there, not only the interface names and their API version.
|
||||
|
||||
use ide_plugin::PluginEngine;
|
||||
|
||||
fn check(wat: &str) -> Result<Option<semver::Version>, String> {
|
||||
let engine = PluginEngine::new().unwrap();
|
||||
let component = engine.compile_bytes(&wat::parse_str(wat).unwrap()).unwrap();
|
||||
engine.check_component(&component)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn components_the_host_cannot_link_are_refused() {
|
||||
let cases = [
|
||||
// As dengliu.gitlink 0.1.0 was: built from unreleased WIT that added functions under the same version.
|
||||
(
|
||||
"a function the host's interface lacks",
|
||||
r#"(component (import "openidea:plugin/ui@1.0.0" (instance
|
||||
(export "not-a-host-function" (func (param "text" string))))))"#,
|
||||
"not-a-host-function",
|
||||
),
|
||||
(
|
||||
"a function with another signature",
|
||||
r#"(component (import "openidea:plugin/ui@1.0.0" (instance
|
||||
(export "show-notification" (func (param "title" string))))))"#,
|
||||
"show-notification",
|
||||
),
|
||||
(
|
||||
"an interface the host lacks",
|
||||
r#"(component (import "openidea:plugin/clipboard@1.0.0" (instance
|
||||
(export "copy" (func (param "text" string))))))"#,
|
||||
"openidea:plugin/[email protected]",
|
||||
),
|
||||
("no plugin exports", r#"(component (import "openidea:plugin/[email protected]" (instance)))"#, "lifecycle"),
|
||||
];
|
||||
for (case, wat, names) in cases {
|
||||
let err = check(wat).expect_err(case);
|
||||
assert!(err.contains(names), "{case}: {err}");
|
||||
assert!(err.contains("update OpenIDEA") && err.contains("rebuild"), "{case}: {err}");
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
1f7402e2b1b4602e4073eb61837c3dc8840f9a4f7275d800dbd6fcf6ce5e40c3
|
||||
455d848dd27f242bb3e9bf420558cbc8c200853f51ada3d481635ac9771803f3
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
4f6cd5b5e0c12e7dcbccf66a6ad97276bf968bc3ad26f0fd08e1f7765cbfee14
|
||||
f7e1ae06a90460ed1d2319bfc51f848654df145fa451f3d959b32776b2b25ce3
|
||||
+1
-1
@@ -12,7 +12,7 @@ activation = ["on-view:jobs", "on-command:*"]
|
||||
|
||||
[capabilities]
|
||||
required = ["process:jobs"]
|
||||
optional = ["project.read"]
|
||||
optional = ["project.read", "vcs.read", "clipboard"]
|
||||
|
||||
[[contributes.views]]
|
||||
id = "jobs"
|
||||
|
||||
+14
-1
@@ -32,9 +32,22 @@ impl Jobs {
|
||||
}
|
||||
|
||||
impl Plugin for Jobs {
|
||||
fn run_command(&mut self, id: &str, _ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, Error> {
|
||||
fn run_command(&mut self, id: &str, ctx: &Invocation, arg: Option<&str>) -> Result<Option<String>, Error> {
|
||||
match id {
|
||||
"follow" => self.follow().map(|()| None),
|
||||
"remotes" => {
|
||||
let project = ctx.project().ok_or("no project is open")?;
|
||||
let remotes = project.vcs_remotes()?;
|
||||
Ok(Some(remotes.into_iter().map(|(n, u)| format!("{n}={u}")).collect::<Vec<_>>().join("\n")))
|
||||
}
|
||||
"copy" => {
|
||||
host::copy_to_clipboard(arg.unwrap_or_default())?;
|
||||
Ok(None)
|
||||
}
|
||||
"open-url" => {
|
||||
host::open_url(arg.unwrap_or_default())?;
|
||||
Ok(None)
|
||||
}
|
||||
_ => Err(Error::NotFound(format!("command {id}"))),
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
3d4dd1b6365c7fbafdd3c1f99f4405e5ee40fc5e56c97da960adfe7af2e7edb8
|
||||
12973c7ca7330fc50eb16718fd769d412401b5a92bd014f4804659e4a272122f
|
||||
+2
-2
@@ -5,8 +5,8 @@ wit_bindgen::generate!({
|
||||
inline: "
|
||||
package fixture:misbehave;
|
||||
world misbehave {
|
||||
include openidea:plugin/plugin-syntax@1.0.0;
|
||||
export openidea:plugin/providers@1.0.0;
|
||||
include openidea:plugin/plugin-syntax@1.1.0;
|
||||
export openidea:plugin/providers@1.1.0;
|
||||
}
|
||||
",
|
||||
path: "../../../../../wit",
|
||||
|
||||
@@ -564,9 +564,8 @@ fn spring_boot_apps(root: &Path, build: &Path, out: &mut Vec<RunConfig>) {
|
||||
Some(m) => format!("application:{m}:{class}"),
|
||||
None => format!("application:{class}"),
|
||||
};
|
||||
let mut app = config(id, simple, "spring-boot", "java", &["-cp", "${classpath}", &class])
|
||||
.in_dir(module_rel.as_ref().map(PathBuf::from))
|
||||
.debug("java-debug");
|
||||
let mut app =
|
||||
config(id, simple, "spring-boot", "java", &["-cp", "${classpath}", &class]).debug("java-debug");
|
||||
app.extras.module = Some(module_rel.clone().unwrap_or_default());
|
||||
out.push(app);
|
||||
}
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
//! its templates (`default="true"`) and temporary configurations. A configuration found in more
|
||||
//! than one place is taken from the first, in that order.
|
||||
//!
|
||||
//! Python, Django server, Django tests, pytest, npm, Jest, Vitest, Node.js, Shell Script and
|
||||
//! compound configurations become the matching kinds; other types are skipped with their type.
|
||||
//! Python, Django server, Django tests, pytest, npm, Jest, Vitest, Node.js, Shell Script,
|
||||
//! Application, Spring Boot and compound configurations become the matching kinds; other types
|
||||
//! are skipped with their type.
|
||||
//! `$PROJECT_DIR$` (and `$MODULE_DIR$`, `$MODULE_WORKING_DIR$`, `$ContentRoot$`,
|
||||
//! `$ProjectFileDir$`, all the project directory here) and `$USER_HOME$` are replaced; another
|
||||
//! path variable skips the configuration.
|
||||
@@ -162,6 +163,8 @@ const VITEST: &str = "JavaScriptTestRunnerVitest";
|
||||
const NODE: &str = "NodeJSConfigurationType";
|
||||
const SHELL: &str = "ShConfigurationType";
|
||||
const COMPOUND: &str = "CompoundRunConfigurationType";
|
||||
const APPLICATION: &str = "Application";
|
||||
const SPRING_BOOT: &str = "SpringBootApplicationConfigurationType";
|
||||
|
||||
type Res<T> = Result<T, SkipReason>;
|
||||
|
||||
@@ -329,6 +332,8 @@ impl Cx<'_> {
|
||||
JEST | VITEST => self.js_tests(name, c, ty == VITEST, &mut ignored)?,
|
||||
NODE => self.node(name, c, &mut ignored)?,
|
||||
SHELL => self.shell(name, c)?,
|
||||
APPLICATION => self.java_app(name, c, false, &mut ignored)?,
|
||||
SPRING_BOOT => self.java_app(name, c, true, &mut ignored)?,
|
||||
COMPOUND => {
|
||||
members = c.children_named("toRun").filter_map(|t| t.attr("name")).map(str::to_owned).collect();
|
||||
config(name, "compound", String::new(), Vec::new(), None)
|
||||
@@ -695,6 +700,54 @@ impl Cx<'_> {
|
||||
Ok(rc)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- Java
|
||||
|
||||
/// IDEA's Application and Spring Boot types, as the `application` / `spring-boot` forms run
|
||||
/// them: `java <vm options> -cp ${classpath} <main> [--spring.profiles.active=…] <arguments>`
|
||||
/// with the module's runtime classpath.
|
||||
fn java_app(&self, name: &str, c: &El, spring: bool, ignored: &mut Vec<String>) -> Res<RunConfig> {
|
||||
let main = if spring { "SPRING_BOOT_MAIN_CLASS" } else { "MAIN_CLASS_NAME" };
|
||||
let Some(main) = self.text(c.option(main))? else { return Err(SkipReason::NothingToRun) };
|
||||
let (_, cwd) = self.cwd(c.option("WORKING_DIRECTORY"))?;
|
||||
let mut args = self.words(c.option("VM_PARAMETERS"))?;
|
||||
args.extend(["-cp".to_owned(), "${classpath}".to_owned(), main]);
|
||||
if spring && let Some(profiles) = self.text(c.option("ACTIVE_PROFILES"))? {
|
||||
args.push(format!("--spring.profiles.active={profiles}"));
|
||||
}
|
||||
args.extend(self.words(c.option("PROGRAM_PARAMETERS"))?);
|
||||
let mut rc =
|
||||
config(name, if spring { "spring-boot" } else { "application" }, "java".into(), args, Some("java-debug"));
|
||||
rc.cwd = cwd.map(Into::into);
|
||||
self.envs(c, &mut rc);
|
||||
if let Some(module) = c.child("module").and_then(|m| m.attr("name")).filter(|m| !m.is_empty()) {
|
||||
match module_dir(self.root, module) {
|
||||
Some(dir) => rc.extras.module = Some(dir),
|
||||
None => ignored.push(format!("module {module}")),
|
||||
}
|
||||
}
|
||||
// Options the forms have no field for.
|
||||
if c.flag("ALTERNATIVE_JRE_PATH_ENABLED") {
|
||||
ignored.push("JRE".into());
|
||||
}
|
||||
if c.flag("INCLUDE_PROVIDED_SCOPE") {
|
||||
ignored.push("provided scope".into());
|
||||
}
|
||||
if c.child("classpathModifications").is_some_and(|m| !m.children.is_empty()) {
|
||||
ignored.push("classpath modifications".into());
|
||||
}
|
||||
if spring {
|
||||
for (option, label) in [("HIDE_BANNER", "hide banner"), ("DEBUG_MODE", "debug output")] {
|
||||
if c.flag(option) {
|
||||
ignored.push(label.into());
|
||||
}
|
||||
}
|
||||
if c.child("additionalParameters").is_some_and(|p| !p.children.is_empty()) {
|
||||
ignored.push("override configuration properties".into());
|
||||
}
|
||||
}
|
||||
Ok(rc)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- Shell
|
||||
|
||||
/// A script file (with its interpreter) or script text, kept as the Shell Script form's fields.
|
||||
@@ -835,6 +888,56 @@ fn module_count(root: &Path) -> usize {
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// The folder (relative, `""` for the project's own) of IDEA module `name`: a Maven module is
|
||||
/// named after its `artifactId` (usually its folder), a Gradle one `<project>.<path>.<source set>`.
|
||||
fn module_dir(root: &Path, name: &str) -> Option<String> {
|
||||
let folder = |dir: &Path| dir.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
let exact = |dir: &Path| folder(dir) == name || pom_artifact(dir).is_some_and(|a| a == name);
|
||||
if let Some(dir) = find_module(root, &exact) {
|
||||
return Some(dir);
|
||||
}
|
||||
let gradle = name.strip_suffix(".main").or_else(|| name.strip_suffix(".test")).unwrap_or(name);
|
||||
let last = gradle.rsplit('.').next().filter(|last| *last != name)?;
|
||||
find_module(root, &|dir: &Path| folder(dir) == last)
|
||||
}
|
||||
|
||||
/// The shallowest build folder under `root` (itself included) that `names` accepts.
|
||||
fn find_module(root: &Path, names: &dyn Fn(&Path) -> bool) -> Option<String> {
|
||||
const BUILDS: [&str; 3] = ["pom.xml", "build.gradle", "build.gradle.kts"];
|
||||
let mut level = vec![root.to_path_buf()];
|
||||
for _ in 0..5 {
|
||||
if let Some(found) = level.iter().find(|d| BUILDS.iter().any(|b| d.join(b).is_file()) && names(d)) {
|
||||
let rel = found.strip_prefix(root).ok()?;
|
||||
return Some(rel.to_string_lossy().replace('\\', "/"));
|
||||
}
|
||||
let mut next: Vec<PathBuf> = level
|
||||
.iter()
|
||||
.filter_map(|d| std::fs::read_dir(d).ok())
|
||||
.flat_map(|entries| entries.flatten().map(|e| e.path()))
|
||||
.filter(|p| p.is_dir())
|
||||
.filter(|p| {
|
||||
let n = p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
!n.starts_with('.') && !["node_modules", "target", "build", "out", "src"].contains(&n.as_str())
|
||||
})
|
||||
.collect();
|
||||
next.sort();
|
||||
level = next;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The `artifactId` of the module's own `pom.xml` (not its parent's).
|
||||
fn pom_artifact(dir: &Path) -> Option<String> {
|
||||
let pom = std::fs::read_to_string(dir.join("pom.xml")).ok()?;
|
||||
let own = match (pom.find("<parent>"), pom.find("</parent>")) {
|
||||
(Some(start), Some(end)) if start < end => format!("{}{}", &pom[..start], &pom[end..]),
|
||||
_ => pom,
|
||||
};
|
||||
let start = own.find("<artifactId>")? + "<artifactId>".len();
|
||||
let end = start + own[start..].find("</artifactId>")?;
|
||||
Some(own[start..end].trim().to_owned())
|
||||
}
|
||||
|
||||
/// `--settings` from PyCharm's "Settings" field: a file (`mysite/settings/dev.py`, relative to
|
||||
/// the project) becomes its module relative to the working directory; a module stays as it is.
|
||||
fn settings_module(value: &str, root: &Path, dir: &Path) -> String {
|
||||
|
||||
@@ -204,7 +204,7 @@ pub fn explode(war: &Path, destination: &Path, resources_only: bool) -> IdeResul
|
||||
}
|
||||
|
||||
/// Updates the exploded artifact deployment from the module's own class output directories and
|
||||
/// its web resources (`src/main/webapp`), file by file and only what changed, instead of
|
||||
/// its web resources ([`web_sources`]), file by file and only what changed, instead of
|
||||
/// rebuilding and re-exploding the WAR. `module` and `class_paths` are absolute.
|
||||
/// `Ok(false)`: not possible this way (not deployed from the artifact yet, the module's output
|
||||
/// isn't on the classpath, or a module it depends on was compiled since the last update, whose
|
||||
@@ -232,9 +232,10 @@ pub fn sync_outputs(base: &Path, module: &Path, class_paths: &[PathBuf], resourc
|
||||
for dir in own {
|
||||
sync_tree(dir, dir, &classes, Path::new("WEB-INF/classes"), resources_only, &mut current)?;
|
||||
}
|
||||
let webapp = module.join("src/main/webapp");
|
||||
if webapp.is_dir() {
|
||||
sync_tree(&webapp, &webapp, &dest, Path::new(""), resources_only, &mut current)?;
|
||||
for (dir, target) in web_sources(module) {
|
||||
if dir.is_dir() {
|
||||
sync_tree(&dir, &dir, &dest.join(&target), &target, resources_only, &mut current)?;
|
||||
}
|
||||
}
|
||||
for rel in previous.iter().filter(|p| !current.contains(*p)) {
|
||||
let path = dest.join(rel);
|
||||
@@ -246,6 +247,59 @@ pub fn sync_outputs(base: &Path, module: &Path, class_paths: &[PathBuf], resourc
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// The folders whose files the WAR puts at its root (or under the second path): the web app
|
||||
/// folder (`src/main/webapp`, maven-war-plugin's `warSourceDirectory`, Gradle's
|
||||
/// `webAppDirName` / `webAppDirectory`), then maven-war-plugin's `webResources`.
|
||||
pub fn web_sources(module: &Path) -> Vec<(PathBuf, PathBuf)> {
|
||||
let rel = |dir: &str| {
|
||||
let dir = ["${project.basedir}/", "${basedir}/"].iter().find_map(|p| dir.strip_prefix(p)).unwrap_or(dir);
|
||||
module.join(dir.trim())
|
||||
};
|
||||
if let Ok(pom) = fs::read_to_string(module.join("pom.xml"))
|
||||
&& let Some(war) = war_plugin(&pom)
|
||||
{
|
||||
let mut out = vec![(rel(tag(war, "warSourceDirectory").unwrap_or("src/main/webapp")), PathBuf::new())];
|
||||
let mut rest = tag_block(war, "webResources").unwrap_or_default();
|
||||
while let Some(start) = rest.find("<resource>") {
|
||||
let end = rest[start..].find("</resource>").map_or(rest.len(), |e| start + e);
|
||||
let resource = &rest[start..end];
|
||||
if let Some(dir) = tag(resource, "directory") {
|
||||
out.push((rel(dir), PathBuf::from(tag(resource, "targetPath").unwrap_or_default())));
|
||||
}
|
||||
rest = &rest[end..];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
let gradle = ["build.gradle", "build.gradle.kts"].iter().find_map(|f| fs::read_to_string(module.join(f)).ok());
|
||||
let web_app_dir = gradle.as_deref().and_then(|text| {
|
||||
text.lines()
|
||||
.filter(|l| !l.trim_start().starts_with("//"))
|
||||
.find(|l| l.contains("webAppDirName") || l.contains("webAppDirectory"))
|
||||
.and_then(|l| l.split(['"', '\'']).nth(1).map(str::to_owned))
|
||||
});
|
||||
vec![(rel(web_app_dir.as_deref().unwrap_or("src/main/webapp")), PathBuf::new())]
|
||||
}
|
||||
|
||||
/// The `<plugin>` element of maven-war-plugin in `pom`, if it is configured there.
|
||||
fn war_plugin(pom: &str) -> Option<&str> {
|
||||
let at = pom.find("<artifactId>maven-war-plugin</artifactId>")?;
|
||||
let start = pom[..at].rfind("<plugin>")?;
|
||||
let end = pom[at..].find("</plugin>").map_or(pom.len(), |e| at + e);
|
||||
Some(&pom[start..end])
|
||||
}
|
||||
|
||||
/// The text of the first `<name>` element in `xml`.
|
||||
fn tag<'a>(xml: &'a str, name: &str) -> Option<&'a str> {
|
||||
tag_block(xml, name).map(str::trim).filter(|t| !t.is_empty())
|
||||
}
|
||||
|
||||
fn tag_block<'a>(xml: &'a str, name: &str) -> Option<&'a str> {
|
||||
let open = format!("<{name}>");
|
||||
let start = xml.find(&open)? + open.len();
|
||||
let end = start + xml[start..].find(&format!("</{name}>"))?;
|
||||
Some(&xml[start..end])
|
||||
}
|
||||
|
||||
fn modified_since(dir: &Path, since: std::time::SystemTime) -> bool {
|
||||
let Ok(entries) = fs::read_dir(dir) else { return false };
|
||||
entries.flatten().any(|e| match e.file_type() {
|
||||
@@ -574,6 +628,83 @@ mod tests {
|
||||
fs::File::create(dep.join("Core.class")).unwrap().set_modified(later).unwrap();
|
||||
assert!(!sync_outputs(&base, &module, &paths, false).unwrap());
|
||||
}
|
||||
#[test]
|
||||
fn web_sources_follow_the_war_plugin_and_gradle_settings() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let module = tmp.path();
|
||||
let rel = |sources: Vec<(PathBuf, PathBuf)>| -> Vec<(String, String)> {
|
||||
sources
|
||||
.into_iter()
|
||||
.map(|(d, t)| {
|
||||
let d = d.strip_prefix(module).unwrap().to_string_lossy().replace('\\', "/");
|
||||
(d, t.to_string_lossy().into_owned())
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
assert_eq!(rel(web_sources(module)), [("src/main/webapp".to_owned(), String::new())]);
|
||||
|
||||
// An Eclipse-style layout: the JSPs come in through webResources.
|
||||
fs::write(
|
||||
module.join("pom.xml"),
|
||||
r#"<project><build><plugins>
|
||||
<plugin><artifactId>maven-compiler-plugin</artifactId></plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-war-plugin</artifactId>
|
||||
<configuration>
|
||||
<warSourceDirectory>${basedir}/web</warSourceDirectory>
|
||||
<webResources>
|
||||
<resource><directory>WebContent</directory><includes><include>**/*</include></includes></resource>
|
||||
<resource><directory>extra</directory><targetPath>static</targetPath></resource>
|
||||
</webResources>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins></build></project>"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
rel(web_sources(module)),
|
||||
[
|
||||
("web".to_owned(), String::new()),
|
||||
("WebContent".to_owned(), String::new()),
|
||||
("extra".to_owned(), "static".to_owned())
|
||||
]
|
||||
);
|
||||
|
||||
fs::remove_file(module.join("pom.xml")).unwrap();
|
||||
fs::write(
|
||||
module.join("build.gradle"),
|
||||
"plugins { id 'war' }\n// webAppDirName = 'old'\nwebAppDirName = 'WebContent'\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(rel(web_sources(module)), [("WebContent".to_owned(), String::new())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn updates_web_resources_outside_src_main_webapp() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let (base, module) = (root.join("base"), root.join("web"));
|
||||
let (dest, out) = (base.join("deployment"), module.join("target/classes"));
|
||||
for dir in [dest.join("WEB-INF/classes"), out.clone(), module.join("WebContent/jsp")] {
|
||||
fs::create_dir_all(dir).unwrap();
|
||||
}
|
||||
fs::write(
|
||||
module.join("pom.xml"),
|
||||
"<project><plugin><artifactId>maven-war-plugin</artifactId><configuration><webResources>\
|
||||
<resource><directory>WebContent</directory></resource></webResources></configuration></plugin></project>",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(dest.join("index.jsp"), "old").unwrap();
|
||||
fs::write(module.join("WebContent/jsp/list.jsp"), "new").unwrap();
|
||||
fs::write(base.join("artifact-path.txt"), "web.war").unwrap();
|
||||
|
||||
assert!(sync_outputs(&base, &module, std::slice::from_ref(&out), true).unwrap());
|
||||
assert_eq!(fs::read_to_string(dest.join("jsp/list.jsp")).unwrap(), "new");
|
||||
// Files the WAR brought that no source here maps to stay.
|
||||
assert_eq!(fs::read_to_string(dest.join("index.jsp")).unwrap(), "old");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deployment_errors_identify_the_checked_path() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -215,7 +215,7 @@ fn builds_nested_in_a_folder_of_repositories() {
|
||||
assert_eq!(app.name, "DromaraApplication");
|
||||
assert_eq!(app.kind, "spring-boot");
|
||||
assert_eq!(cmdline(app), "java -cp ${classpath} org.dromara.DromaraApplication");
|
||||
assert_eq!(app.cwd.as_deref(), Some(Path::new("RuoYi-Vue-Plus/ruoyi-admin")));
|
||||
assert_eq!(app.cwd, None);
|
||||
assert_eq!(app.extras.module.as_deref(), Some("RuoYi-Vue-Plus/ruoyi-admin"));
|
||||
assert_eq!(app.debug_adapter.as_deref(), Some("java-debug"));
|
||||
let package = find(&c, "maven:package:RuoYi-Vue-Plus");
|
||||
@@ -258,7 +258,7 @@ fn spring_boot_application_of_a_repository_nested_one_or_two_levels_down() {
|
||||
let module = format!("{prefix}ruoyi-ai/ruoyi-admin");
|
||||
let app = find(&c, &format!("application:{module}:org.ruoyi.RuoYiAIApplication"));
|
||||
assert_eq!((app.name.as_str(), app.kind.as_str()), ("RuoYiAIApplication", "spring-boot"), "{prefix}");
|
||||
assert_eq!(app.cwd.as_deref(), Some(Path::new(&module)));
|
||||
assert_eq!((app.cwd.as_deref(), app.extras.module.as_deref()), (None, Some(module.as_str())), "{prefix}");
|
||||
assert!(ids(&c).contains(&format!("maven:spring-boot:run:{prefix}ruoyi-ai").as_str()), "{prefix}");
|
||||
}
|
||||
}
|
||||
@@ -295,9 +295,9 @@ fn nested_builds_of_every_kind_and_depth() {
|
||||
}
|
||||
assert!(find(&c, "gradle:test:services/api").is_test);
|
||||
let api = find(&c, "application:services/api/app:com.acme.ApiKt");
|
||||
assert_eq!(api.cwd.as_deref(), Some(Path::new("services/api/app")));
|
||||
assert_eq!((api.cwd.as_deref(), api.extras.module.as_deref()), (None, Some("services/api/app")));
|
||||
let billing = find(&c, "application:billing:Billing");
|
||||
assert_eq!(billing.cwd.as_deref(), Some(Path::new("billing")));
|
||||
assert_eq!((billing.cwd.as_deref(), billing.extras.module.as_deref()), (None, Some("billing")));
|
||||
let builds: Vec<&str> =
|
||||
ids(&c).into_iter().filter(|id| id.starts_with("maven:package") || id.starts_with("gradle:build")).collect();
|
||||
assert_eq!(builds, ["gradle:build:services/api", "maven:package:billing"]);
|
||||
|
||||
@@ -239,8 +239,8 @@ fn workspace_run_manager_skips_templates_and_temporary_configurations() {
|
||||
<envs><env name="VITE_API" value="http://localhost:8000" /></envs>
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
<configuration name="Main" type="Application" factoryName="Application">
|
||||
<option name="MAIN_CLASS_NAME" value="shop.Main" />
|
||||
<configuration name="Main" type="JUnit" factoryName="JUnit">
|
||||
<option name="MAIN_CLASS_NAME" value="shop.MainTest" />
|
||||
<method v="2" />
|
||||
</configuration>"#
|
||||
)),
|
||||
@@ -272,7 +272,7 @@ fn workspace_run_manager_skips_templates_and_temporary_configurations() {
|
||||
assert_eq!(get(&r, "Custom").args, s(&["-m", "pytest", "tests/a.py", "--maxfail=2"]));
|
||||
|
||||
assert_eq!(reason(&r, "Unittests in tests"), &SkipReason::UnsupportedType { type_: "tests (Unittests)".into() });
|
||||
assert_eq!(reason(&r, "Main"), &SkipReason::UnsupportedType { type_: "Application".into() });
|
||||
assert_eq!(reason(&r, "Main"), &SkipReason::UnsupportedType { type_: "JUnit".into() });
|
||||
|
||||
let dev = get(&r, "dev");
|
||||
assert_eq!((dev.kind.as_str(), dev.command.as_str()), ("npm", "pnpm"));
|
||||
@@ -526,14 +526,11 @@ fn sources_are_offered_when_their_files_exist() {
|
||||
),
|
||||
);
|
||||
assert!(available(dir.path()).is_empty());
|
||||
write(dir.path(), ".idea/workspace.xml", &workspace(r#"<configuration name="App" type="Application" />"#));
|
||||
write(dir.path(), ".idea/workspace.xml", &workspace(r#"<configuration name="App" type="JUnit" />"#));
|
||||
assert_eq!(available(dir.path()), [RunImportSource::Jetbrains]);
|
||||
write(dir.path(), ".vscode/launch.json", "{}");
|
||||
assert_eq!(available(dir.path()), [RunImportSource::Vscode, RunImportSource::Jetbrains]);
|
||||
assert_eq!(
|
||||
reason(&import_from(dir.path(), &[]), "App"),
|
||||
&SkipReason::UnsupportedType { type_: "Application".into() }
|
||||
);
|
||||
assert_eq!(reason(&import_from(dir.path(), &[]), "App"), &SkipReason::UnsupportedType { type_: "JUnit".into() });
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -702,3 +699,91 @@ fn flags_defaults_inherited_variables_and_nested_django_tests() {
|
||||
assert_eq!((tests.kind.as_str(), tests.is_test), ("python", true));
|
||||
assert_eq!(tests.args[..3], s(&["backend/manage.py", "test", "orders"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn java_applications_and_spring_boot_keep_their_module() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(dir.path()).unwrap();
|
||||
write(&root, "pom.xml", "<project><artifactId>shop</artifactId><packaging>pom</packaging></project>");
|
||||
write(
|
||||
&root,
|
||||
"example-service/pom.xml",
|
||||
"<project><parent><artifactId>shop</artifactId></parent><artifactId>example-service</artifactId></project>",
|
||||
);
|
||||
// IDEA names a Maven module after its artifactId, which needn't be its folder.
|
||||
write(
|
||||
&root,
|
||||
"services/api/pom.xml",
|
||||
"<project><parent><artifactId>shop</artifactId></parent><artifactId>api-service</artifactId></project>",
|
||||
);
|
||||
write(
|
||||
&root,
|
||||
".idea/workspace.xml",
|
||||
&workspace(
|
||||
r#" <configuration name="ExampleApplication" type="SpringBootApplicationConfigurationType" factoryName="Spring Boot">
|
||||
<module name="example-service" />
|
||||
<option name="SPRING_BOOT_MAIN_CLASS" value="com.example.ExampleApplication" />
|
||||
<option name="ACTIVE_PROFILES" value="dev,local" />
|
||||
<option name="VM_PARAMETERS" value="-Xmx512m -Dfile.encoding=UTF-8" />
|
||||
<option name="PROGRAM_PARAMETERS" value="--server.port=8081" />
|
||||
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/example-service" />
|
||||
<option name="HIDE_BANNER" value="true" />
|
||||
<envs>
|
||||
<env name="TZ" value="UTC" />
|
||||
</envs>
|
||||
<method v="2">
|
||||
<option name="Make" enabled="true" />
|
||||
</method>
|
||||
</configuration>
|
||||
<configuration name="Api" type="Application" factoryName="Application">
|
||||
<option name="MAIN_CLASS_NAME" value="com.example.api.Main" />
|
||||
<module name="api-service" />
|
||||
<option name="ALTERNATIVE_JRE_PATH_ENABLED" value="true" />
|
||||
<option name="ALTERNATIVE_JRE_PATH" value="17" />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
<configuration name="Gone" type="Application" factoryName="Application">
|
||||
<option name="MAIN_CLASS_NAME" value="Gone" />
|
||||
<module name="removed-module" />
|
||||
</configuration>
|
||||
<configuration name="NoMain" type="SpringBootApplicationConfigurationType" factoryName="Spring Boot">
|
||||
<module name="example-service" />
|
||||
</configuration>"#,
|
||||
),
|
||||
);
|
||||
|
||||
let r = import_from(&root, &[]);
|
||||
|
||||
let boot = get(&r, "ExampleApplication");
|
||||
assert_eq!(boot.kind, "spring-boot");
|
||||
assert_eq!(boot.command, "java");
|
||||
assert_eq!(
|
||||
boot.args,
|
||||
s(&[
|
||||
"-Xmx512m",
|
||||
"-Dfile.encoding=UTF-8",
|
||||
"-cp",
|
||||
"${classpath}",
|
||||
"com.example.ExampleApplication",
|
||||
"--spring.profiles.active=dev,local",
|
||||
"--server.port=8081",
|
||||
])
|
||||
);
|
||||
assert_eq!(boot.extras.module.as_deref(), Some("example-service"));
|
||||
assert_eq!(cwd(boot), Some(PathBuf::from("example-service")));
|
||||
assert_eq!(boot.env.get("TZ").map(String::as_str), Some("UTC"));
|
||||
assert_eq!(boot.debug_adapter.as_deref(), Some("java-debug"));
|
||||
assert_eq!(ignored(&r, "ExampleApplication"), ["before launch", "hide banner"]);
|
||||
|
||||
let api = get(&r, "Api");
|
||||
assert_eq!(api.kind, "application");
|
||||
assert_eq!(api.args, s(&["-cp", "${classpath}", "com.example.api.Main"]));
|
||||
assert_eq!(api.extras.module.as_deref(), Some("services/api"));
|
||||
assert_eq!(ignored(&r, "Api"), ["JRE"]);
|
||||
|
||||
// A module that isn't here any more: the form finds it by the main class.
|
||||
assert_eq!(get(&r, "Gone").extras.module, None);
|
||||
assert_eq!(ignored(&r, "Gone"), ["module removed-module"]);
|
||||
|
||||
assert_eq!(reason(&r, "NoMain"), &SkipReason::NothingToRun);
|
||||
}
|
||||
@@ -429,6 +429,36 @@ pub fn configure_maven_import(
|
||||
}
|
||||
}
|
||||
|
||||
/// Explicit Gradle links constrain discovery and isolate JDT's persisted import state.
|
||||
pub fn configure_gradle_import(
|
||||
cfg: &mut LanguageServerConfig,
|
||||
linked: Option<&[String]>,
|
||||
build_files: Option<&[String]>,
|
||||
) {
|
||||
let Some(linked) = linked.filter(|_| cfg.name == "jdtls") else { return };
|
||||
merge_init_options(
|
||||
cfg,
|
||||
json!({
|
||||
"settings": {"java": {"import": {"gradle": {"enabled": !linked.is_empty()}}}},
|
||||
"projectConfigurations": build_files.unwrap_or_default()
|
||||
}),
|
||||
);
|
||||
if let Some(settings) = &mut cfg.settings {
|
||||
merge(settings, json!({"java": {"import": {"gradle": {"enabled": !linked.is_empty()}}}}));
|
||||
}
|
||||
if let Some(index) = cfg.args.iter().position(|arg| arg == "-data")
|
||||
&& let Some(workspace) = cfg.args.get_mut(index + 1)
|
||||
{
|
||||
let mut hash = 0xcbf29ce484222325u64;
|
||||
for dir in linked {
|
||||
for byte in dir.bytes().chain([0]) {
|
||||
hash = (hash ^ u64::from(byte)).wrapping_mul(0x100000001b3);
|
||||
}
|
||||
}
|
||||
*workspace = format!("{workspace}-gradle-{hash:016x}");
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn merge_init_options(cfg: &mut LanguageServerConfig, layer: Value) {
|
||||
let mut current =
|
||||
cfg.initialization_options.as_ref().and_then(|v| serde_json::to_value(v).ok()).unwrap_or_else(|| json!({}));
|
||||
@@ -1218,6 +1248,25 @@ mod java_compiler_tests {
|
||||
mod maven_linkage_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn explicit_gradle_links_scope_jdt_import_and_unlink_all_disables_gradle() {
|
||||
let mut cfg = custom_server("jdtls", &LanguageServerSettings::default());
|
||||
cfg.args = vec!["-data".into(), "/cache/jdtls".into()];
|
||||
let files = ["file:///p/a/settings.gradle".to_owned(), "file:///p/maven/pom.xml".to_owned()];
|
||||
configure_gradle_import(&mut cfg, Some(&["a".into()]), Some(&files));
|
||||
let options = serde_json::to_value(&cfg.initialization_options).unwrap();
|
||||
assert_eq!(options["projectConfigurations"], json!(files));
|
||||
assert_eq!(options.pointer("/settings/java/import/gradle/enabled"), Some(&json!(true)));
|
||||
assert!(cfg.args[1].starts_with("/cache/jdtls-gradle-"));
|
||||
let linked_cache = cfg.args[1].clone();
|
||||
cfg.args[1] = "/cache/jdtls".into();
|
||||
configure_gradle_import(&mut cfg, Some(&[]), Some(&files[1..]));
|
||||
let options = serde_json::to_value(&cfg.initialization_options).unwrap();
|
||||
assert_eq!(options["projectConfigurations"], json!(&files[1..]));
|
||||
assert_eq!(options.pointer("/settings/java/import/gradle/enabled"), Some(&json!(false)));
|
||||
assert_ne!(linked_cache, cfg.args[1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_excludes_unlinked_modules_and_changes_persistent_workspace() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -184,6 +184,12 @@ 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))
|
||||
@@ -193,10 +199,11 @@ pub(crate) fn python_sdk(path: &Path, source: &str, root: Option<&Path>) -> Opti
|
||||
let label = match (&venv, source) {
|
||||
(Some(home), _) => {
|
||||
let dir = home.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
// `.venv` inside a project reads better as the project name, like PyCharm.
|
||||
// `.venv` inside a project reads better as the folder that holds it (the project,
|
||||
// or a member such as `backend`), like PyCharm.
|
||||
let owner = home.parent().and_then(|p| p.file_name()).map(|n| n.to_string_lossy().into_owned());
|
||||
match (dir.starts_with('.') || dir == "venv" || dir == "env", owner, root) {
|
||||
(true, Some(owner), Some(root)) if home.parent() == Some(root) => format!("Python {short} ({owner})"),
|
||||
(true, Some(owner), Some(root)) if home.starts_with(root) => format!("Python {short} ({owner})"),
|
||||
_ => format!("Python {short} ({dir})"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub use discover::{detect, jdk_sdk};
|
||||
pub use discover::{detect, jdk_sdk, python_interpreter_sdk};
|
||||
|
||||
/// What [`detect`] looks for.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
|
||||
@@ -76,6 +76,23 @@ fn project_venv_is_the_default_interpreter() {
|
||||
assert_eq!(python_interpreter(tmp.path(), &s).unwrap(), Path::new("/opt/py/bin/python3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_interpreter_outside_detection_is_named_like_a_detected_one() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("shop");
|
||||
let member = fake_venv(&root.join("backend"), ".venv");
|
||||
let sdk = crate::python_interpreter_sdk(&member, Some(&root)).unwrap();
|
||||
assert_eq!(
|
||||
(sdk.name.as_str(), sdk.version.as_deref(), sdk.source.as_str()),
|
||||
("Python 3.12 (backend)", Some("3.12.1"), "venv")
|
||||
);
|
||||
let own = fake_venv(&root, ".venv");
|
||||
assert_eq!(crate::python_interpreter_sdk(&own, Some(&root)).unwrap().name, "Python 3.12 (shop)");
|
||||
// Outside the project the folder name says which one it is.
|
||||
let elsewhere = fake_venv(&tmp.path().join("other"), ".venv");
|
||||
assert_eq!(crate::python_interpreter_sdk(&elsewhere, Some(&root)).unwrap().name, "Python 3.12 (.venv)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pyright_gets_python_path_and_venv_env() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -60,6 +60,9 @@ struct Session {
|
||||
writer: Mutex<Box<dyn Write + Send>>,
|
||||
killer: Mutex<Box<dyn ChildKiller + Send + Sync>>,
|
||||
exited: Arc<AtomicBool>,
|
||||
started: Instant,
|
||||
/// The first input was logged (startup diagnostics, see `spawn`).
|
||||
input_logged: AtomicBool,
|
||||
}
|
||||
|
||||
type Sessions = Arc<Mutex<HashMap<TerminalId, Arc<Session>>>>;
|
||||
@@ -92,7 +95,8 @@ impl TerminalManager {
|
||||
let size = pty_size(options.cols, options.rows);
|
||||
let pair = native_pty_system().openpty(size).map_err(|e| IdeError::internal(format!("openpty failed: {e}")))?;
|
||||
|
||||
let (shell, mut args) = resolve_shell(options.shell.as_deref(), &options.args);
|
||||
let wsl = if cfg!(windows) { wsl_shell(&options) } else { None };
|
||||
let (shell, mut args) = wsl.clone().unwrap_or_else(|| resolve_shell(options.shell.as_deref(), &options.args));
|
||||
let mut env = terminal_env(&options.env);
|
||||
if let Some(msystem) = default_msystem(&shell, &options.env, std::env::var("MSYSTEM").ok().as_deref()) {
|
||||
env.insert("MSYSTEM".to_owned(), msystem.to_owned());
|
||||
@@ -105,7 +109,8 @@ impl TerminalManager {
|
||||
}
|
||||
let mut cmd = CommandBuilder::new(&shell);
|
||||
cmd.args(&args);
|
||||
cmd.cwd(resolve_cwd(&options.cwd));
|
||||
// wsl.exe takes the directory through `--cd`; a UNC share is no valid process cwd.
|
||||
cmd.cwd(if wsl.is_some() { resolve_cwd(Path::new("")) } else { resolve_cwd(&options.cwd) });
|
||||
for (k, v) in ide_core::process::host_env_fixes() {
|
||||
match v {
|
||||
Some(v) => cmd.env(k, v),
|
||||
@@ -136,25 +141,34 @@ impl TerminalManager {
|
||||
writer: Mutex::new(writer),
|
||||
killer: Mutex::new(child.clone_killer()),
|
||||
exited: exited.clone(),
|
||||
started: Instant::now(),
|
||||
input_logged: AtomicBool::new(false),
|
||||
});
|
||||
self.sessions.lock().insert(id, session);
|
||||
|
||||
let (tx, rx) = mpsc::channel::<Msg>();
|
||||
let spawned = spawn_reader(id, reader, tx.clone())
|
||||
.and_then(|_| spawn_waiter(id, child, tx))
|
||||
.and_then(|_| spawn_pump(id, rx, on_output, exited, self.sessions.clone(), sink));
|
||||
.and_then(|_| spawn_pump(id, rx, on_output, exited, self.sessions.clone(), sink, Instant::now()));
|
||||
if let Err(e) = spawned {
|
||||
if let Some(s) = self.sessions.lock().remove(&id) {
|
||||
let _ = s.killer.lock().kill();
|
||||
}
|
||||
return Err(IdeError::internal(format!("failed to start terminal threads: {e}")));
|
||||
}
|
||||
tracing::debug!(%id, shell, pid = ?info.pid, "terminal spawned");
|
||||
// Info level, with the first output and input below: a session that starts but never shows a
|
||||
// prompt can then be told apart in a user's log (no output, or no reply to the shell's query).
|
||||
tracing::info!(%id, shell, pid = ?info.pid, cols = size.cols, rows = size.rows, "terminal spawned");
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
pub fn write(&self, id: TerminalId, data: &str) -> IdeResult<()> {
|
||||
let session = self.session(id)?;
|
||||
if !session.input_logged.swap(true, Ordering::Relaxed) {
|
||||
let cursor_report = data.starts_with("\x1b[") && data.ends_with('R');
|
||||
let ms = session.started.elapsed().as_millis();
|
||||
tracing::info!(%id, ms, bytes = data.len(), cursor_report, "terminal first input");
|
||||
}
|
||||
let mut w = session.writer.lock();
|
||||
w.write_all(data.as_bytes())?;
|
||||
w.flush()?;
|
||||
@@ -387,8 +401,19 @@ fn spawn_pump(
|
||||
exited: Arc<AtomicBool>,
|
||||
sessions: Sessions,
|
||||
sink: SharedSink,
|
||||
started: Instant,
|
||||
) -> std::io::Result<()> {
|
||||
std::thread::Builder::new().name(format!("openidea-term-pump-{}", id.0)).spawn(move || {
|
||||
let first_output = std::cell::Cell::new(true);
|
||||
let on_output = move |text: String| {
|
||||
if first_output.replace(false) {
|
||||
// ConPTY asks for the cursor position (`ESC[6n`) and waits for the answer.
|
||||
let cursor_query = text.contains("\x1b[6n");
|
||||
let ms = started.elapsed().as_millis();
|
||||
tracing::info!(%id, ms, bytes = text.len(), cursor_query, "terminal first output");
|
||||
}
|
||||
on_output(text)
|
||||
};
|
||||
let mut decoder = Utf8Decoder::new();
|
||||
let mut pending = String::new();
|
||||
let mut batch_started: Option<Instant> = None;
|
||||
@@ -456,6 +481,16 @@ fn pty_size(cols: u16, rows: u16) -> PtySize {
|
||||
}
|
||||
}
|
||||
|
||||
/// `wsl.exe` in the distro of a `\\wsl.localhost\...` folder, so its terminal is a Linux
|
||||
/// shell and not PowerShell (#925). An explicitly chosen shell or arguments win.
|
||||
fn wsl_shell(options: &TerminalOptions) -> Option<(String, Vec<String>)> {
|
||||
if options.shell.as_deref().is_some_and(|s| !s.trim().is_empty()) || !options.args.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let w = ide_core::wsl::parse(&options.cwd)?;
|
||||
Some((ide_core::wsl::EXE.into(), vec!["-d".into(), w.distro, "--cd".into(), w.path]))
|
||||
}
|
||||
|
||||
fn resolve_cwd(cwd: &Path) -> PathBuf {
|
||||
if cwd.is_dir() {
|
||||
return cwd.to_path_buf();
|
||||
@@ -504,7 +539,7 @@ pub fn available_shells() -> Vec<String> {
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
for exe in ["pwsh.exe", "powershell.exe", "cmd.exe", "wsl.exe"] {
|
||||
for exe in ["pwsh.exe", "powershell.exe", "cmd.exe", ide_core::wsl::EXE] {
|
||||
if let Some(p) = find_in_path(exe) {
|
||||
add(p);
|
||||
}
|
||||
@@ -859,6 +894,24 @@ mod tests {
|
||||
assert!(h.events.try_recv().is_err() && other_events.try_recv().is_err(), "no cross delivery");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wsl_folder_gets_the_distro_shell_unless_one_is_chosen() {
|
||||
let mut o = sh("true");
|
||||
o.shell = None;
|
||||
o.args = vec![];
|
||||
let (distro, linux_path) = ("Debian-12", "/srv/app");
|
||||
let want = (ide_core::wsl::EXE.to_owned(), vec!["-d".into(), distro.into(), "--cd".into(), linux_path.into()]);
|
||||
for host in ["wsl.localhost", "wsl$"] {
|
||||
o.cwd = PathBuf::from(format!(r"\\{host}\{distro}{}", linux_path.replace('/', r"\"))).into();
|
||||
assert_eq!(wsl_shell(&o), Some(want.clone()), "{host}");
|
||||
}
|
||||
o.shell = Some("pwsh.exe".into());
|
||||
assert_eq!(wsl_shell(&o), None);
|
||||
o.shell = None;
|
||||
o.cwd = PathBuf::from("/tmp").into();
|
||||
assert_eq!(wsl_shell(&o), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_resolution() {
|
||||
let (shell, args) = resolve_shell(Some("/bin/zsh"), &[]);
|
||||
|
||||
@@ -395,6 +395,11 @@ 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")
|
||||
@@ -491,8 +496,32 @@ 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> {
|
||||
self.which_any(&["python3", "python"])
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Executable `cmd` inside `dir`, honouring Windows suffixes.
|
||||
|
||||
@@ -37,6 +37,12 @@ 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 {
|
||||
@@ -700,6 +706,34 @@ 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() {
|
||||
|
||||
+112
-205
@@ -9,44 +9,88 @@ use std::collections::{HashMap, HashSet};
|
||||
|
||||
/// Log "Text or hash" filter: the message (substring or regex), or a hash prefix.
|
||||
enum TextFilter {
|
||||
Plain { needle: String, match_case: bool },
|
||||
/// Commits `git log --grep` selected, or whose id starts with `hash`.
|
||||
Selected {
|
||||
ids: HashSet<ObjectId>,
|
||||
hash: Option<String>,
|
||||
},
|
||||
/// Case-insensitive non-ASCII text: git folds case only for ASCII under the C locale.
|
||||
Folded(String),
|
||||
Regex(regex_lite::Regex),
|
||||
}
|
||||
|
||||
impl TextFilter {
|
||||
fn new(text: &str, regex: bool, match_case: bool) -> IdeResult<Option<Self>> {
|
||||
fn new(
|
||||
r: &Repo,
|
||||
scopes: &[String],
|
||||
q: &LogQuery,
|
||||
text: &str,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<Option<Self>> {
|
||||
let text = text.trim();
|
||||
if text.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
if regex {
|
||||
if q.text_regex {
|
||||
let re = regex_lite::RegexBuilder::new(text)
|
||||
.case_insensitive(!match_case)
|
||||
.case_insensitive(!q.text_match_case)
|
||||
.multi_line(true)
|
||||
.build()
|
||||
.map_err(|e| IdeError::invalid(format!("invalid regular expression: {e}")))?;
|
||||
return Ok(Some(Self::Regex(re)));
|
||||
}
|
||||
let needle = if match_case { text.to_string() } else { text.to_lowercase() };
|
||||
Ok(Some(Self::Plain { needle, match_case }))
|
||||
if !q.text_match_case && !text.is_ascii() {
|
||||
return Ok(Some(Self::Folded(text.to_lowercase())));
|
||||
}
|
||||
let mut filter = vec!["--fixed-strings".to_string(), format!("--grep={text}")];
|
||||
if !q.text_match_case {
|
||||
filter.push("--regexp-ignore-case".into());
|
||||
}
|
||||
let ids = git_select(r, scopes, &filter, &[], q.first_parent, cancel)?;
|
||||
let hash = (text.len() >= 4 && text.bytes().all(|b| b.is_ascii_hexdigit())).then(|| text.to_ascii_lowercase());
|
||||
Ok(Some(Self::Selected { ids, hash }))
|
||||
}
|
||||
|
||||
fn matches(&self, message: &str, id: &ObjectId) -> bool {
|
||||
match self {
|
||||
Self::Regex(re) => re.is_match(message),
|
||||
Self::Plain { needle, match_case } => {
|
||||
let hash = needle.len() >= 4 && needle.bytes().all(|b| b.is_ascii_hexdigit());
|
||||
if hash && id.to_string().starts_with(&needle.to_ascii_lowercase()) {
|
||||
return true;
|
||||
}
|
||||
if *match_case {
|
||||
message.contains(needle.as_str())
|
||||
} else {
|
||||
message.to_lowercase().contains(needle.as_str())
|
||||
}
|
||||
fn matches(&self, repo: &gix::Repository, index: &mut crate::log_index::LogIndex, id: ObjectId) -> IdeResult<bool> {
|
||||
Ok(match self {
|
||||
Self::Regex(re) => re.is_match(&index.message(repo, id)?),
|
||||
Self::Folded(needle) => index.message(repo, id)?.to_lowercase().contains(needle.as_str()),
|
||||
Self::Selected { ids, hash } => {
|
||||
ids.contains(&id) || hash.as_ref().is_some_and(|h| id.to_string().starts_with(h.as_str()))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Commits git selects from each scope with `filter`. Path limiting uses git's history
|
||||
/// simplification and, when the repository has them, changed-path Bloom filters.
|
||||
fn git_select(
|
||||
r: &Repo,
|
||||
scopes: &[String],
|
||||
filter: &[String],
|
||||
paths: &[String],
|
||||
first_parent: bool,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<HashSet<ObjectId>> {
|
||||
let mut ids = HashSet::new();
|
||||
for scope in scopes {
|
||||
let mut args =
|
||||
vec!["log".to_string(), "--format=%H".into(), "--no-show-signature".into(), "--no-follow".into()];
|
||||
if first_parent {
|
||||
args.push("--first-parent".into());
|
||||
}
|
||||
args.extend(filter.iter().cloned());
|
||||
args.push("--end-of-options".into());
|
||||
args.extend(scope.split_whitespace().map(str::to_string));
|
||||
args.push("--".into());
|
||||
args.extend(paths.iter().map(|p| format!(":(literal){p}")));
|
||||
let output =
|
||||
crate::cli::run_cancellable(r.root(), &args.iter().map(String::as_str).collect::<Vec<_>>(), cancel)?;
|
||||
for line in output.stdout_str().lines() {
|
||||
ids.insert(ObjectId::from_hex(line.as_bytes()).map_err(gx)?);
|
||||
}
|
||||
}
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
pub(crate) fn log(r: &Repo, q: &LogQuery, cancel: &CancellationToken) -> IdeResult<Vec<Commit>> {
|
||||
@@ -173,87 +217,71 @@ fn log_indexed(
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let scopes = if let Some(range) = range {
|
||||
vec![range.to_string()]
|
||||
} else {
|
||||
let mut scopes = ranges.iter().map(|s| s.to_string()).collect::<Vec<_>>();
|
||||
if !tips.is_empty() {
|
||||
scopes.push(tips.iter().map(ToString::to_string).collect::<Vec<_>>().join(" "));
|
||||
}
|
||||
scopes
|
||||
};
|
||||
let mut history_paths = HashMap::new();
|
||||
let path_ids = if paths.is_empty() {
|
||||
None
|
||||
} else {
|
||||
} else if q.follow && paths.len() == 1 {
|
||||
let mut ids = HashSet::new();
|
||||
if q.follow && paths.len() == 1 {
|
||||
let rel = &paths[0];
|
||||
let mut args = vec![
|
||||
"log".to_string(),
|
||||
"--format=%x00%H".into(),
|
||||
"--name-status".into(),
|
||||
"-z".into(),
|
||||
"--follow".into(),
|
||||
"--topo-order".into(),
|
||||
];
|
||||
if q.first_parent {
|
||||
args.push("--first-parent".into());
|
||||
}
|
||||
args.push("--end-of-options".into());
|
||||
let scopes = if let Some(range) = range {
|
||||
vec![range.to_string()]
|
||||
} else {
|
||||
let mut scopes = ranges.iter().map(|s| s.to_string()).collect::<Vec<_>>();
|
||||
if !tips.is_empty() {
|
||||
scopes.push(tips.iter().map(ToString::to_string).collect::<Vec<_>>().join(" "));
|
||||
}
|
||||
scopes
|
||||
};
|
||||
for scope in scopes {
|
||||
let mut args = args.clone();
|
||||
args.extend(scope.split_whitespace().map(str::to_string));
|
||||
args.extend(["--".into(), rel.clone()]);
|
||||
let output = crate::cli::run_cancellable(
|
||||
r.root(),
|
||||
&args.iter().map(String::as_str).collect::<Vec<_>>(),
|
||||
cancel,
|
||||
)?;
|
||||
let text = output.stdout_str();
|
||||
let mut tokens = text.split('\0').peekable();
|
||||
let mut current_path = rel.clone();
|
||||
while let Some(token) = tokens.next() {
|
||||
let token = token.trim_start_matches('\n');
|
||||
if let Ok(id) = ObjectId::from_hex(token.as_bytes()) {
|
||||
ids.insert(id);
|
||||
history_paths.insert(id, current_path.clone());
|
||||
} else if token.starts_with('R') && token[1..].bytes().all(|b| b.is_ascii_digit()) {
|
||||
if let (Some(old), Some(new)) = (tokens.next(), tokens.next())
|
||||
&& new == current_path
|
||||
{
|
||||
current_path = old.to_string();
|
||||
}
|
||||
} else if ["A", "D", "M", "T", "U"].contains(&token) {
|
||||
tokens.next();
|
||||
let rel = &paths[0];
|
||||
let mut args = vec![
|
||||
"log".to_string(),
|
||||
"--format=%x00%H".into(),
|
||||
"--name-status".into(),
|
||||
"-z".into(),
|
||||
"--follow".into(),
|
||||
"--topo-order".into(),
|
||||
];
|
||||
if q.first_parent {
|
||||
args.push("--first-parent".into());
|
||||
}
|
||||
args.push("--end-of-options".into());
|
||||
for scope in &scopes {
|
||||
let mut args = args.clone();
|
||||
args.extend(scope.split_whitespace().map(str::to_string));
|
||||
args.extend(["--".into(), rel.clone()]);
|
||||
let output =
|
||||
crate::cli::run_cancellable(r.root(), &args.iter().map(String::as_str).collect::<Vec<_>>(), cancel)?;
|
||||
let text = output.stdout_str();
|
||||
let mut tokens = text.split('\0').peekable();
|
||||
let mut current_path = rel.clone();
|
||||
while let Some(token) = tokens.next() {
|
||||
let token = token.trim_start_matches('\n');
|
||||
if let Ok(id) = ObjectId::from_hex(token.as_bytes()) {
|
||||
ids.insert(id);
|
||||
history_paths.insert(id, current_path.clone());
|
||||
} else if token.starts_with('R') && token[1..].bytes().all(|b| b.is_ascii_digit()) {
|
||||
if let (Some(old), Some(new)) = (tokens.next(), tokens.next())
|
||||
&& new == current_path
|
||||
{
|
||||
current_path = old.to_string();
|
||||
}
|
||||
} else if ["A", "D", "M", "T", "U"].contains(&token) {
|
||||
tokens.next();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let starts = range_ids.as_ref().cloned().unwrap_or_else(|| tips.clone());
|
||||
ids.extend(
|
||||
PathWalk::new(&repo, starts, paths.clone(), q.first_parent, cancel, index)?
|
||||
.map(|item| item.map(|(id, _)| id))
|
||||
.collect::<IdeResult<HashSet<_>>>()?,
|
||||
);
|
||||
}
|
||||
Some(ids)
|
||||
} else {
|
||||
Some(git_select(r, &scopes, &[], &paths, q.first_parent, cancel)?)
|
||||
};
|
||||
let authors: Vec<_> =
|
||||
q.author.iter().chain(&q.authors).map(|a| a.trim().to_lowercase()).filter(|a| !a.is_empty()).collect();
|
||||
let user_candidates = if q.authors_exact && !authors.is_empty() { index.user_candidates(&authors) } else { None };
|
||||
let path_candidates = if !q.follow && !paths.is_empty() { index.path_candidates(&paths) } else { None };
|
||||
let text_candidates = q.text.as_deref().filter(|_| !q.text_regex).and_then(|s| index.message_candidates(s));
|
||||
let mut visibility = Visibility {
|
||||
speed_refs: &decorations,
|
||||
text_candidates,
|
||||
user_candidates,
|
||||
path_candidates,
|
||||
query: q,
|
||||
path_ids,
|
||||
range_ids: range_ids.as_ref().map(|ids| ids.iter().copied().collect()),
|
||||
authors,
|
||||
text: q.text.as_deref().map(|t| TextFilter::new(t, q.text_regex, q.text_match_case)).transpose()?.flatten(),
|
||||
text: q.text.as_deref().map(|t| TextFilter::new(r, &scopes, q, t, cancel)).transpose()?.flatten(),
|
||||
matches: HashMap::new(),
|
||||
projected: HashMap::new(),
|
||||
cancel,
|
||||
@@ -584,9 +612,6 @@ struct Visibility<'a> {
|
||||
range_ids: Option<HashSet<ObjectId>>,
|
||||
authors: Vec<String>,
|
||||
text: Option<TextFilter>,
|
||||
text_candidates: Option<HashSet<String>>,
|
||||
user_candidates: Option<HashSet<String>>,
|
||||
path_candidates: Option<HashSet<String>>,
|
||||
matches: HashMap<ObjectId, bool>,
|
||||
projected: HashMap<ObjectId, Vec<ObjectId>>,
|
||||
cancel: &'a CancellationToken,
|
||||
@@ -602,10 +627,7 @@ impl Visibility<'_> {
|
||||
if let Some(shown) = self.matches.get(&id) {
|
||||
return Ok(*shown);
|
||||
}
|
||||
let indexed = self.index.indexed(&id.to_string());
|
||||
let shown = if (indexed && self.user_candidates.as_ref().is_some_and(|ids| !ids.contains(&id.to_string())))
|
||||
|| (indexed && self.path_candidates.as_ref().is_some_and(|ids| !ids.contains(&id.to_string())))
|
||||
|| !repo.has_object(id)
|
||||
let shown = if !repo.has_object(id)
|
||||
|| self.path_ids.as_ref().is_some_and(|ids| !ids.contains(&id))
|
||||
|| self.range_ids.as_ref().is_some_and(|ids| !ids.contains(&id))
|
||||
{
|
||||
@@ -648,15 +670,7 @@ impl Visibility<'_> {
|
||||
}
|
||||
}))
|
||||
&& match &self.text {
|
||||
Some(text) => {
|
||||
self.text_candidates.as_ref().is_none_or(|ids| {
|
||||
ids.contains(&id.to_string())
|
||||
|| !self.index.indexed(&id.to_string())
|
||||
|| id.to_string().starts_with(
|
||||
self.query.text.as_deref().unwrap_or("").trim().to_ascii_lowercase().as_str(),
|
||||
)
|
||||
}) && text.matches(&self.index.message(repo, id)?, &id)
|
||||
}
|
||||
Some(text) => text.matches(repo, self.index, id)?,
|
||||
None => true,
|
||||
}
|
||||
};
|
||||
@@ -865,113 +879,6 @@ pub(crate) fn decorations(repo: &gix::Repository) -> IdeResult<HashMap<ObjectId,
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
/// Commits touching a path, newest first, with git's default history simplification:
|
||||
/// a commit is shown when its entry for the path differs from every parent, and at a
|
||||
/// merge that kept one parent's version only that parent's line of history is followed.
|
||||
struct PathWalk<'r> {
|
||||
repo: &'r gix::Repository,
|
||||
paths: Vec<String>,
|
||||
queue: std::collections::BinaryHeap<(i64, u64, ObjectId)>,
|
||||
seen: HashSet<ObjectId>,
|
||||
index: &'r mut crate::log_index::LogIndex,
|
||||
seq: u64,
|
||||
first_parent: bool,
|
||||
cancel: &'r CancellationToken,
|
||||
shallow: HashSet<ObjectId>,
|
||||
}
|
||||
|
||||
impl<'r> PathWalk<'r> {
|
||||
fn new(
|
||||
repo: &'r gix::Repository,
|
||||
tips: Vec<ObjectId>,
|
||||
paths: Vec<String>,
|
||||
first_parent: bool,
|
||||
cancel: &'r CancellationToken,
|
||||
index: &'r mut crate::log_index::LogIndex,
|
||||
) -> IdeResult<Self> {
|
||||
let mut walk = PathWalk {
|
||||
repo,
|
||||
paths,
|
||||
queue: Default::default(),
|
||||
seen: Default::default(),
|
||||
index,
|
||||
seq: 0,
|
||||
first_parent,
|
||||
cancel,
|
||||
shallow: repo.shallow_commits().map_err(gx)?.map(|ids| ids.iter().copied().collect()).unwrap_or_default(),
|
||||
};
|
||||
for tip in tips {
|
||||
walk.push(tip)?;
|
||||
}
|
||||
Ok(walk)
|
||||
}
|
||||
|
||||
fn push(&mut self, id: ObjectId) -> IdeResult<()> {
|
||||
if self.cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
if !self.seen.insert(id) || !self.repo.has_object(id) {
|
||||
return Ok(());
|
||||
}
|
||||
let time = self.repo.find_commit(id).map_err(gx)?.time().map(|t| t.seconds).unwrap_or(0);
|
||||
// Max-heap on time; among equal times, earlier insertion first.
|
||||
self.seq += 1;
|
||||
self.queue.push((time, u64::MAX - self.seq, id));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn step(&mut self) -> IdeResult<Option<(ObjectId, Vec<ObjectId>)>> {
|
||||
while let Some((_, _, id)) = self.queue.pop() {
|
||||
let commit = self.repo.find_commit(id).map_err(gx)?;
|
||||
let parents: Vec<ObjectId> = commit.parent_ids().map(|p| p.detach()).collect();
|
||||
// Parents cut off by a shallow boundary are treated as absent.
|
||||
let present: Vec<ObjectId> = parents
|
||||
.iter()
|
||||
.copied()
|
||||
.take(if self.shallow.contains(&id) { 0 } else { usize::MAX })
|
||||
.filter(|p| self.repo.has_object(p))
|
||||
.take(if self.first_parent { 1 } else { usize::MAX })
|
||||
.collect();
|
||||
let mine = self
|
||||
.paths
|
||||
.iter()
|
||||
.map(|path| self.index.path_entry(self.repo, id, path))
|
||||
.collect::<IdeResult<Vec<_>>>()?;
|
||||
let mut same_parent = None;
|
||||
for p in &present {
|
||||
let theirs = self
|
||||
.paths
|
||||
.iter()
|
||||
.map(|path| self.index.path_entry(self.repo, *p, path))
|
||||
.collect::<IdeResult<Vec<_>>>()?;
|
||||
if theirs == mine {
|
||||
same_parent = Some(*p);
|
||||
break;
|
||||
}
|
||||
}
|
||||
match same_parent {
|
||||
Some(p) => self.push(p)?,
|
||||
None => {
|
||||
for p in &present {
|
||||
self.push(*p)?;
|
||||
}
|
||||
if !present.is_empty() || mine.iter().any(Option::is_some) {
|
||||
return Ok(Some((id, parents)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
impl Iterator for PathWalk<'_> {
|
||||
type Item = IdeResult<(ObjectId, Vec<ObjectId>)>;
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
self.step().transpose()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn commit_files(r: &Repo, rev: &str) -> IdeResult<Vec<ChangedFile>> {
|
||||
let repo = r.gix()?;
|
||||
Ok(first_parent_changes(&repo, rev)?.files)
|
||||
|
||||
@@ -162,14 +162,6 @@ pub struct LogDetails {
|
||||
pub fingerprint: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LogIndexProgress {
|
||||
pub indexed: u32,
|
||||
pub total: u32,
|
||||
pub running: bool,
|
||||
}
|
||||
|
||||
/// Order of the Git Log (Graph Options › Sort).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -672,7 +664,6 @@ pub struct Repo {
|
||||
inner: std::sync::Mutex<repo::Cache>,
|
||||
ignored: std::sync::RwLock<IgnoredNames>,
|
||||
log_index: std::sync::Mutex<log_index::LogIndex>,
|
||||
index_progress: std::sync::Mutex<LogIndexProgress>,
|
||||
log_signatures: std::sync::Mutex<std::collections::HashMap<String, LogDetails>>,
|
||||
}
|
||||
|
||||
@@ -844,59 +835,6 @@ impl Repo {
|
||||
.into_iter()
|
||||
.collect())
|
||||
}
|
||||
pub fn log_index_progress(&self) -> LogIndexProgress {
|
||||
self.index_progress.lock().unwrap_or_else(|p| p.into_inner()).clone()
|
||||
}
|
||||
|
||||
/// Incremental persistent inverted index; completed objects survive cancellation/restart.
|
||||
pub fn index_log(&self, cancel: &CancellationToken) -> IdeResult<LogIndexProgress> {
|
||||
{
|
||||
let mut progress = self.index_progress.lock().unwrap_or_else(|p| p.into_inner());
|
||||
if progress.running {
|
||||
return Ok(progress.clone());
|
||||
}
|
||||
progress.running = true;
|
||||
}
|
||||
let result = (|| {
|
||||
let repo = self.gix()?;
|
||||
let out = cli::run_cancellable(self.root(), &["rev-list", "--all", "--topo-order"], cancel)?;
|
||||
let ids: Vec<_> = out
|
||||
.stdout_str()
|
||||
.lines()
|
||||
.map(|id| gix::ObjectId::from_hex(id.as_bytes()).map_err(repo::gx))
|
||||
.collect::<IdeResult<_>>()?;
|
||||
{
|
||||
let mut p = self.index_progress.lock().unwrap_or_else(|p| p.into_inner());
|
||||
p.total = ids.len() as u32;
|
||||
p.indexed = 0;
|
||||
}
|
||||
for (i, id) in ids.iter().enumerate() {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(ide_core::IdeError::cancelled());
|
||||
}
|
||||
let indexed = self.log_index.lock().unwrap_or_else(|p| p.into_inner()).indexed(&id.to_string());
|
||||
if !indexed {
|
||||
let files = self.commit_files(&id.to_string())?;
|
||||
let paths =
|
||||
files.into_iter().flat_map(|f| std::iter::once(f.path).chain(f.old_path)).collect::<Vec<_>>();
|
||||
let mut index = self.log_index.lock().unwrap_or_else(|p| p.into_inner());
|
||||
let c = index.get(&repo, *id)?;
|
||||
let message = index.message(&repo, *id)?;
|
||||
index.index_commit(&c.id, &message, &c.author, &c.email, &paths);
|
||||
if i % 100 == 0 {
|
||||
index.flush();
|
||||
}
|
||||
}
|
||||
self.index_progress.lock().unwrap_or_else(|p| p.into_inner()).indexed = i as u32 + 1;
|
||||
}
|
||||
Ok(())
|
||||
})();
|
||||
self.log_index.lock().unwrap_or_else(|p| p.into_inner()).flush();
|
||||
self.index_progress.lock().unwrap_or_else(|p| p.into_inner()).running = false;
|
||||
result?;
|
||||
Ok(self.log_index_progress())
|
||||
}
|
||||
|
||||
pub fn log_details(&self, revision: &str) -> IdeResult<LogDetails> {
|
||||
self.log_details_cancellable(revision, &CancellationToken::new())
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ use crate::repo::{gx, to_string};
|
||||
use gix::ObjectId;
|
||||
use gix::bstr::ByteSlice;
|
||||
use ide_core::IdeResult;
|
||||
use redb::{Database, MultimapTableDefinition, ReadableDatabase, TableDefinition};
|
||||
use redb::{Database, ReadableDatabase, TableDefinition};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex, OnceLock, Weak};
|
||||
@@ -14,9 +14,6 @@ use std::time::SystemTime;
|
||||
|
||||
const COMMITS: TableDefinition<&str, &[u8]> = TableDefinition::new("summaries-v2");
|
||||
const MESSAGES: TableDefinition<&str, &str> = TableDefinition::new("messages-v1");
|
||||
const PATHS: TableDefinition<&str, &[u8]> = TableDefinition::new("paths-v2");
|
||||
const POSTINGS: MultimapTableDefinition<&str, &str> = MultimapTableDefinition::new("search-v1");
|
||||
const INDEXED: TableDefinition<&str, bool> = TableDefinition::new("indexed-v1");
|
||||
|
||||
pub(crate) struct IndexedView {
|
||||
pub commits: Vec<Commit>,
|
||||
@@ -36,9 +33,6 @@ pub(crate) struct LogIndex {
|
||||
memory: HashMap<ObjectId, Arc<Commit>>,
|
||||
dirty: Vec<Arc<Commit>>,
|
||||
dirty_messages: Vec<(String, String)>,
|
||||
dirty_search: Vec<(String, Vec<String>)>,
|
||||
path_memory: HashMap<(ObjectId, String), Option<(ObjectId, u16)>>,
|
||||
dirty_paths: Vec<(String, Option<(String, u16)>)>,
|
||||
pub head: Option<(ObjectId, Option<SystemTime>)>,
|
||||
pub reachable: HashSet<ObjectId>,
|
||||
pub views: HashMap<String, Arc<IndexedView>>,
|
||||
@@ -92,91 +86,6 @@ impl LogIndex {
|
||||
}
|
||||
Ok(ids)
|
||||
}
|
||||
pub fn indexed(&self, id: &str) -> bool {
|
||||
self.db
|
||||
.as_ref()
|
||||
.and_then(|db| {
|
||||
let txn = db.begin_read().ok()?;
|
||||
let table = txn.open_table(INDEXED).ok()?;
|
||||
Some(table.get(id).ok()??.value())
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn index_commit(&mut self, id: &str, message: &str, author: &str, email: &str, paths: &[String]) {
|
||||
let mut keys: HashSet<String> = Self::grams(message).into_iter().map(|s| format!("m:{s}")).collect();
|
||||
for user in [author, email, email.split('@').next().unwrap_or(email)] {
|
||||
keys.insert(format!("u:{}", normalize_user(user)));
|
||||
}
|
||||
for path in paths {
|
||||
keys.insert(format!("p:{path}"));
|
||||
}
|
||||
self.dirty_search.push((id.into(), keys.into_iter().collect()));
|
||||
}
|
||||
|
||||
fn grams(text: &str) -> Vec<String> {
|
||||
let chars: Vec<_> = text.to_lowercase().chars().collect();
|
||||
chars.windows(3).map(|s| s.iter().collect()).collect::<HashSet<_>>().into_iter().collect()
|
||||
}
|
||||
|
||||
/// Missing/unindexed commits are always checked directly by the caller.
|
||||
pub fn message_candidates(&self, text: &str) -> Option<HashSet<String>> {
|
||||
let grams = Self::grams(text);
|
||||
if grams.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let db = self.db.as_ref()?;
|
||||
let txn = db.begin_read().ok()?;
|
||||
let table = txn.open_multimap_table(POSTINGS).ok()?;
|
||||
let mut candidates: Option<HashSet<String>> = None;
|
||||
for gram in grams {
|
||||
let values = table.get(format!("m:{gram}").as_str()).ok()?;
|
||||
let ids: HashSet<_> = values.filter_map(Result::ok).map(|s| s.value().to_string()).collect();
|
||||
candidates = Some(match candidates {
|
||||
Some(old) => old.intersection(&ids).cloned().collect(),
|
||||
None => ids,
|
||||
});
|
||||
if candidates.as_ref().is_some_and(HashSet::is_empty) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Some(candidates.unwrap_or_default())
|
||||
}
|
||||
|
||||
pub fn user_candidates(&self, authors: &[String]) -> Option<HashSet<String>> {
|
||||
let txn = self.db.as_ref()?.begin_read().ok()?;
|
||||
let table = txn.open_multimap_table(POSTINGS).ok()?;
|
||||
let mut ids = HashSet::new();
|
||||
for author in authors {
|
||||
let name = author.split('<').next().unwrap_or(author).trim();
|
||||
let email = author.split('<').nth(1).map(|s| s.trim_end_matches('>')).unwrap_or(author);
|
||||
for alias in [name, email, email.split('@').next().unwrap_or(email)] {
|
||||
for value in table.get(format!("u:{}", normalize_user(alias)).as_str()).ok()?.filter_map(Result::ok) {
|
||||
ids.insert(value.value().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(ids)
|
||||
}
|
||||
|
||||
pub fn path_candidates(&self, paths: &[String]) -> Option<HashSet<String>> {
|
||||
let txn = self.db.as_ref()?.begin_read().ok()?;
|
||||
let table = txn.open_multimap_table(POSTINGS).ok()?;
|
||||
let mut ids = HashSet::new();
|
||||
for path in paths {
|
||||
for value in table.get(format!("p:{path}").as_str()).ok()?.filter_map(Result::ok) {
|
||||
ids.insert(value.value().to_string());
|
||||
}
|
||||
let prefix = format!("p:{path}/");
|
||||
let end = format!("{prefix}{}", char::MAX);
|
||||
for row in table.range(prefix.as_str()..end.as_str()).ok()?.filter_map(Result::ok) {
|
||||
for value in row.1.filter_map(Result::ok) {
|
||||
ids.insert(value.value().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(ids)
|
||||
}
|
||||
pub fn open(&mut self, dir: &Path) {
|
||||
if self.opened {
|
||||
return;
|
||||
@@ -272,11 +181,7 @@ impl LogIndex {
|
||||
}
|
||||
|
||||
pub fn flush(&mut self) {
|
||||
if self.dirty.is_empty()
|
||||
&& self.dirty_paths.is_empty()
|
||||
&& self.dirty_messages.is_empty()
|
||||
&& self.dirty_search.is_empty()
|
||||
{
|
||||
if self.dirty.is_empty() && self.dirty_messages.is_empty() {
|
||||
return;
|
||||
}
|
||||
let result = (|| -> Result<(), Box<dyn std::error::Error>> {
|
||||
@@ -291,29 +196,12 @@ impl LogIndex {
|
||||
table.insert(commit.id.as_str(), bytes.as_slice())?;
|
||||
}
|
||||
}
|
||||
{
|
||||
let mut table = txn.open_table(PATHS)?;
|
||||
for (key, value) in &self.dirty_paths {
|
||||
let bytes = serde_json::to_vec(value)?;
|
||||
table.insert(key.as_str(), bytes.as_slice())?;
|
||||
}
|
||||
}
|
||||
{
|
||||
let mut table = txn.open_table(MESSAGES)?;
|
||||
for (id, message) in &self.dirty_messages {
|
||||
table.insert(id.as_str(), message.as_str())?;
|
||||
}
|
||||
}
|
||||
{
|
||||
let mut postings = txn.open_multimap_table(POSTINGS)?;
|
||||
let mut indexed = txn.open_table(INDEXED)?;
|
||||
for (id, keys) in &self.dirty_search {
|
||||
for key in keys {
|
||||
postings.insert(key.as_str(), id.as_str())?;
|
||||
}
|
||||
indexed.insert(id.as_str(), true)?;
|
||||
}
|
||||
}
|
||||
txn.commit()?;
|
||||
Ok(())
|
||||
})();
|
||||
@@ -321,52 +209,7 @@ impl LogIndex {
|
||||
tracing::warn!("Git log cache write failed: {error}");
|
||||
}
|
||||
self.dirty_messages.clear();
|
||||
self.dirty_search.clear();
|
||||
self.dirty.clear();
|
||||
self.dirty_paths.clear();
|
||||
}
|
||||
|
||||
pub fn path_entry(
|
||||
&mut self,
|
||||
repo: &gix::Repository,
|
||||
id: ObjectId,
|
||||
path: &str,
|
||||
) -> IdeResult<Option<(ObjectId, u16)>> {
|
||||
let memory_key = (id, path.to_string());
|
||||
if let Some(value) = self.path_memory.get(&memory_key) {
|
||||
return Ok(*value);
|
||||
}
|
||||
let key = format!("{id}\0{path}");
|
||||
let cached = self.db.as_ref().and_then(|db| {
|
||||
let txn = db.begin_read().ok()?;
|
||||
let table = txn.open_table(PATHS).ok()?;
|
||||
let value = table.get(key.as_str()).ok()??;
|
||||
serde_json::from_slice::<Option<(String, u16)>>(value.value()).ok()
|
||||
});
|
||||
let cached = match cached {
|
||||
Some(None) => Some(None),
|
||||
Some(Some((hex, mode))) => ObjectId::from_hex(hex.as_bytes()).ok().map(|id| Some((id, mode))),
|
||||
None => None,
|
||||
};
|
||||
let value = if let Some(value) = cached {
|
||||
value
|
||||
} else {
|
||||
let tree = repo.find_commit(id).map_err(gx)?.tree().map_err(gx)?;
|
||||
let value = tree
|
||||
.lookup_entry_by_path(Path::new(path))
|
||||
.map_err(gx)?
|
||||
.map(|entry| (entry.object_id(), entry.mode().value()));
|
||||
if self.db.is_some() {
|
||||
self.dirty_paths.push((key, value.map(|(id, mode)| (id.to_string(), mode))));
|
||||
}
|
||||
value
|
||||
};
|
||||
if self.path_memory.len() >= 4096 {
|
||||
self.flush();
|
||||
self.path_memory.clear();
|
||||
}
|
||||
self.path_memory.insert(memory_key, value);
|
||||
Ok(value)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -65,7 +65,6 @@ pub(crate) fn discover(path: &Path) -> IdeResult<Option<Repo>> {
|
||||
inner: std::sync::Mutex::new(cache),
|
||||
ignored: Default::default(),
|
||||
log_index: Default::default(),
|
||||
index_progress: Default::default(),
|
||||
log_signatures: Default::default(),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -99,8 +99,6 @@ fn lazy_summaries_seek_and_index_survive_refresh_and_restart() {
|
||||
let cancel = ide_core::CancellationToken::new();
|
||||
cancel.cancel();
|
||||
assert!(repo.log_cancellable(&query, &cancel).is_err());
|
||||
let progress = repo.index_log(&ide_core::CancellationToken::new()).unwrap();
|
||||
assert_eq!((progress.indexed, progress.total, progress.running), (70, 70, false));
|
||||
assert_eq!(repo.log(&LogQuery { text: Some("needle body 5".into()), ..query.clone() }).unwrap().len(), 5);
|
||||
let new = commit(dir, "b", "new head");
|
||||
let refreshed = repo.log(&LogQuery { retain_until: Some(ids[0].clone()), ..query.clone() }).unwrap();
|
||||
@@ -436,3 +434,31 @@ fn multiple_paths_are_simplified_together_at_merges() {
|
||||
assert_eq!(log[0].graph_parents.as_ref().unwrap().len(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paths_are_literal_and_text_matches_bodies_or_hash_prefixes() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path();
|
||||
git(dir, &["init", "-q"]);
|
||||
let star = commit(dir, "[a]", "star\n\nBody Needle");
|
||||
let plain = commit(dir, "a", "plain");
|
||||
let repo = Repo::discover(dir).unwrap().unwrap();
|
||||
let ids = |q: LogQuery| repo.log(&q).unwrap().into_iter().map(|c| c.id).collect::<Vec<_>>();
|
||||
assert_eq!(ids(LogQuery { path: Some("[a]".into()), ..Default::default() }), vec![star.clone()]);
|
||||
assert_eq!(ids(LogQuery { text: Some("body needle".into()), ..Default::default() }), vec![star.clone()]);
|
||||
assert!(ids(LogQuery { text: Some("body needle".into()), text_match_case: true, ..Default::default() }).is_empty());
|
||||
assert_eq!(ids(LogQuery { text: Some(plain[..7].to_uppercase()), ..Default::default() }), vec![plain]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn case_insensitive_text_folds_non_ascii() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path();
|
||||
git(dir, &["init", "-q"]);
|
||||
let hit = commit(dir, "a", "Ärger ПРИВЕТ");
|
||||
commit(dir, "a", "other");
|
||||
let repo = Repo::discover(dir).unwrap().unwrap();
|
||||
let ids = |q: LogQuery| repo.log(&q).unwrap().into_iter().map(|c| c.id).collect::<Vec<_>>();
|
||||
assert_eq!(ids(LogQuery { text: Some("ärger привет".into()), ..Default::default() }), vec![hit.clone()]);
|
||||
assert!(ids(LogQuery { text: Some("ärger".into()), text_match_case: true, ..Default::default() }).is_empty());
|
||||
}
|
||||
@@ -124,7 +124,7 @@ pub fn read_dir(dir: &Path, excludes: &[String]) -> IdeResult<Vec<FileEntry>> {
|
||||
if !fs::metadata(dir)?.is_dir() {
|
||||
return Err(IdeError::invalid(format!("{} is not a directory", dir.display())));
|
||||
}
|
||||
let gitignore = rules::GitignoreStack::new(dir);
|
||||
let gitignore = rules::GitignoreStack::cached(dir);
|
||||
let base = gitignore.repo_root().unwrap_or(dir).to_path_buf();
|
||||
let excludes = ExcludeMatcher::new(excludes);
|
||||
|
||||
|
||||
+112
-5
@@ -8,6 +8,7 @@ use parking_lot::{Mutex, RwLock};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant, SystemTime};
|
||||
|
||||
/// Exclude globs from settings (`files.exclude`), e.g. `**/node_modules`.
|
||||
///
|
||||
@@ -72,6 +73,14 @@ pub(crate) fn find_repo_root(start: &Path) -> Option<PathBuf> {
|
||||
start.ancestors().find(|dir| dir.join(".git").exists()).map(Path::to_path_buf)
|
||||
}
|
||||
|
||||
/// `info/exclude` of the repository at `root` (shared by its worktrees).
|
||||
fn exclude_path(root: &Path) -> PathBuf {
|
||||
gix::discover(root)
|
||||
.ok()
|
||||
.map(|r| r.common_dir().join("info/exclude"))
|
||||
.unwrap_or_else(|| root.join(".git/info/exclude"))
|
||||
}
|
||||
|
||||
/// Layered gitignore matching: per-directory `.gitignore` / `.ignore` from the
|
||||
/// path's directory up to the repository root (or the file system root when
|
||||
/// not in a repository), then `.git/info/exclude` and the global excludes file.
|
||||
@@ -83,6 +92,29 @@ pub(crate) struct GitignoreStack {
|
||||
nested: Mutex<HashMap<PathBuf, Option<Arc<GitignoreStack>>>>,
|
||||
}
|
||||
|
||||
/// How long [`GitignoreStack::cached`] reuses the repository lookup of a directory.
|
||||
const REPO_LEVEL_TTL: Duration = Duration::from_secs(3);
|
||||
|
||||
/// What changes when `.git/info/exclude` is edited.
|
||||
type Stamp = Option<(SystemTime, u64)>;
|
||||
|
||||
struct RepoLevel {
|
||||
at: Instant,
|
||||
repo_root: Option<PathBuf>,
|
||||
/// `<common dir>/info/exclude`, found by a repository discovery.
|
||||
exclude: Option<PathBuf>,
|
||||
stamp: Stamp,
|
||||
rules: Vec<Gitignore>,
|
||||
}
|
||||
|
||||
/// Repo-level rules by the directory they were looked up for.
|
||||
static REPO_LEVEL: Mutex<Option<HashMap<PathBuf, Arc<RepoLevel>>>> = Mutex::new(None);
|
||||
|
||||
fn stamp_of(file: Option<&Path>) -> Stamp {
|
||||
let meta = std::fs::metadata(file?).ok()?;
|
||||
Some((meta.modified().ok()?, meta.len()))
|
||||
}
|
||||
|
||||
impl GitignoreStack {
|
||||
pub fn new(near: &Path) -> Self {
|
||||
let repo_root = find_repo_root(near);
|
||||
@@ -90,15 +122,52 @@ impl GitignoreStack {
|
||||
Self { repo_root, repo_level, cache: Mutex::default(), nested: Mutex::default() }
|
||||
}
|
||||
|
||||
/// [`new`](Self::new) for listing a directory over and over (expanding a tree, a slow share):
|
||||
/// the walk up to the repository root and the repository discovery are reused for
|
||||
/// [`REPO_LEVEL_TTL`]. An edit of `.git/info/exclude` is still seen at once (its mtime and size
|
||||
/// are checked), as is any `.gitignore` / `.ignore`, which are read fresh. Only a repository
|
||||
/// created in the directory, or a change of the global excludes file, can lag by the TTL.
|
||||
pub fn cached(near: &Path) -> Self {
|
||||
let now = Instant::now();
|
||||
let hit = REPO_LEVEL
|
||||
.lock()
|
||||
.as_ref()
|
||||
.and_then(|m| m.get(near))
|
||||
.cloned()
|
||||
.filter(|e| now.duration_since(e.at) < REPO_LEVEL_TTL && stamp_of(e.exclude.as_deref()) == e.stamp);
|
||||
let entry = hit.unwrap_or_else(|| {
|
||||
let repo_root = find_repo_root(near);
|
||||
let exclude = repo_root.as_deref().map(exclude_path);
|
||||
let entry = Arc::new(RepoLevel {
|
||||
at: now,
|
||||
stamp: stamp_of(exclude.as_deref()),
|
||||
rules: Self::load_repo_level_from(repo_root.as_deref(), exclude.as_deref()),
|
||||
repo_root,
|
||||
exclude,
|
||||
});
|
||||
let mut guard = REPO_LEVEL.lock();
|
||||
let map = guard.get_or_insert_with(HashMap::new);
|
||||
map.retain(|_, e| now.duration_since(e.at) < REPO_LEVEL_TTL);
|
||||
map.insert(near.to_path_buf(), entry.clone());
|
||||
entry
|
||||
});
|
||||
Self {
|
||||
repo_root: entry.repo_root.clone(),
|
||||
repo_level: RwLock::new(entry.rules.clone()),
|
||||
cache: Mutex::default(),
|
||||
nested: Mutex::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn load_repo_level(repo_root: Option<&Path>) -> Vec<Gitignore> {
|
||||
Self::load_repo_level_from(repo_root, repo_root.map(exclude_path).as_deref())
|
||||
}
|
||||
|
||||
fn load_repo_level_from(repo_root: Option<&Path>, exclude: Option<&Path>) -> Vec<Gitignore> {
|
||||
let mut repo_level = Vec::new();
|
||||
if let Some(root) = repo_root {
|
||||
let mut builder = GitignoreBuilder::new(root);
|
||||
let exclude = gix::discover(root)
|
||||
.ok()
|
||||
.map(|r| r.common_dir().join("info/exclude"))
|
||||
.unwrap_or_else(|| root.join(".git/info/exclude"));
|
||||
if exclude.is_file() {
|
||||
if let Some(exclude) = exclude.filter(|f| f.is_file()) {
|
||||
builder.add(exclude);
|
||||
}
|
||||
if let Ok(gi) = builder.build()
|
||||
@@ -119,6 +188,9 @@ impl GitignoreStack {
|
||||
|
||||
/// Re-reads `.git/info/exclude` and the global excludes file.
|
||||
pub fn reload_repo_level(&self) {
|
||||
if let (Some(root), Some(map)) = (&self.repo_root, REPO_LEVEL.lock().as_mut()) {
|
||||
map.retain(|dir, _| !dir.starts_with(root));
|
||||
}
|
||||
self.nested.lock().clear();
|
||||
*self.repo_level.write() = Self::load_repo_level(self.repo_root.as_deref());
|
||||
}
|
||||
@@ -298,4 +370,39 @@ mod tests {
|
||||
assert!(anchored.is_excluded(Path::new("build/out.o")));
|
||||
assert!(!anchored.is_excluded(Path::new("web/build/out.o")));
|
||||
}
|
||||
|
||||
fn repo_with_exclude(rule: &str) -> tempfile::TempDir {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir_all(tmp.path().join(".git/info")).unwrap();
|
||||
std::fs::create_dir_all(tmp.path().join("src")).unwrap();
|
||||
std::fs::write(tmp.path().join(".git/info/exclude"), rule).unwrap();
|
||||
tmp
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cached_stack_sees_every_edit_of_the_ignore_files_at_once() {
|
||||
let tmp = repo_with_exclude("secret.txt\n");
|
||||
let (src, secret, note) =
|
||||
(tmp.path().join("src"), tmp.path().join("src/secret.txt"), tmp.path().join("src/note.md"));
|
||||
assert!(GitignoreStack::cached(&src).is_ignored(&secret, false));
|
||||
assert!(!GitignoreStack::cached(&src).is_ignored(¬e, false));
|
||||
std::fs::write(src.join(".gitignore"), "*.md\n").unwrap();
|
||||
assert!(GitignoreStack::cached(&src).is_ignored(¬e, false), "directory .gitignore");
|
||||
std::fs::write(tmp.path().join(".git/info/exclude"), "").unwrap();
|
||||
assert!(!GitignoreStack::cached(&src).is_ignored(&secret, false), "info/exclude");
|
||||
std::fs::write(tmp.path().join(".git/info/exclude"), "note.md\nsecret.txt\n").unwrap();
|
||||
assert!(GitignoreStack::cached(&src).is_ignored(&secret, false), "info/exclude again");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cached_stack_reuses_the_repository_lookup() {
|
||||
let tmp = repo_with_exclude("");
|
||||
let src = tmp.path().join("src");
|
||||
let first = GitignoreStack::cached(&src);
|
||||
let entry = REPO_LEVEL.lock().as_ref().unwrap().get(&src).unwrap().clone();
|
||||
let again = GitignoreStack::cached(&src);
|
||||
assert!(Arc::ptr_eq(&entry, REPO_LEVEL.lock().as_ref().unwrap().get(&src).unwrap()));
|
||||
assert_eq!(first.repo_root(), again.repo_root());
|
||||
assert_eq!(again.repo_root(), Some(tmp.path()));
|
||||
}
|
||||
}
|
||||
@@ -404,6 +404,9 @@ 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,
|
||||
@@ -630,7 +633,7 @@ impl EventFilter {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use notify::event::{CreateKind, DataChange, RemoveKind};
|
||||
use notify::event::{CreateKind, DataChange, MetadataKind, RemoveKind};
|
||||
|
||||
fn ev(kind: EventKind, paths: Vec<PathBuf>) -> Event {
|
||||
Event { kind, paths, attrs: Default::default() }
|
||||
@@ -788,6 +791,24 @@ 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();
|
||||
|
||||
@@ -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, RecentProjects};
|
||||
pub use recent::{RecentProject, RecentProjectGroup, RecentProjects};
|
||||
pub use roots::{ProjectRoots, RootKind, SourceFolder};
|
||||
pub use settings::{SettingsScope, SettingsStore};
|
||||
|
||||
|
||||
@@ -16,10 +16,78 @@ pub struct RecentProject {
|
||||
|
||||
pub const MAX_RECENT_PROJECTS: usize = 30;
|
||||
|
||||
/// Persisted at `<app-config>/recent.json`, newest first.
|
||||
/// 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`.
|
||||
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 {
|
||||
@@ -29,28 +97,40 @@ fn now_ms() -> f64 {
|
||||
impl RecentProjects {
|
||||
pub fn load(config_dir: &Path) -> Self {
|
||||
let file = config_dir.join("recent.json");
|
||||
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()
|
||||
}
|
||||
};
|
||||
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());
|
||||
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()));
|
||||
items.truncate(MAX_RECENT_PROJECTS);
|
||||
Self { file, items: Mutex::new(items) }
|
||||
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) }
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -58,7 +138,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 });
|
||||
items.truncate(MAX_RECENT_PROJECTS);
|
||||
cap(&mut items, &self.groups.lock());
|
||||
self.persist(&items)
|
||||
}
|
||||
|
||||
@@ -83,7 +163,15 @@ impl RecentProjects {
|
||||
if items.len() == before {
|
||||
return Ok(());
|
||||
}
|
||||
self.persist(&items)
|
||||
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(())
|
||||
}
|
||||
|
||||
pub fn paths(&self) -> Vec<PathBuf> {
|
||||
@@ -91,12 +179,7 @@ impl RecentProjects {
|
||||
}
|
||||
|
||||
fn persist(&self, items: &[RecentProject]) -> IdeResult<()> {
|
||||
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(())
|
||||
write_json(&self.file, items)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,6 +214,41 @@ 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();
|
||||
|
||||
Loaded 100 of 373 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user