mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-08 08:08:16 -10:00
Compare commits
54
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3950f8a66 | ||
|
|
ad1fdf9e0e | ||
|
|
54502e0a17 | ||
|
|
e129840651 | ||
|
|
77f86616c0 | ||
|
|
89ffde304b | ||
|
|
1248c3231e | ||
|
|
ff9f821621 | ||
|
|
5279443ac5 | ||
|
|
e648c0c46f | ||
|
|
791c957284 | ||
|
|
59071ac426 | ||
|
|
ad27a7064a | ||
|
|
116b1624b3 | ||
|
|
de0f9d6ecd | ||
|
|
6d55eb4aa9 | ||
|
|
a247f1ce67 | ||
|
|
f16ec29f4a | ||
|
|
02d30b1609 | ||
|
|
01e09565b7 | ||
|
|
1f70b134de | ||
|
|
11fd64f399 | ||
|
|
8a884b6362 | ||
|
|
481d44930f | ||
|
|
f5895d0336 | ||
|
|
c3c1d88d10 | ||
|
|
a0ac988963 | ||
|
|
d5c4066748 | ||
|
|
8d784ac164 | ||
|
|
7ffaa0b5da | ||
|
|
ab2b53ff80 | ||
|
|
2ba731f0ec | ||
|
|
742e504719 | ||
|
|
ebe059e00f | ||
|
|
e17e59ce7d | ||
|
|
8021a8ccca | ||
|
|
5a6a6f7e05 | ||
|
|
a7f0f8110b | ||
|
|
9669e58398 | ||
|
|
d939dca303 | ||
|
|
23378e0cc2 | ||
|
|
c83264ae75 | ||
|
|
2fc3535300 | ||
|
|
d96ff8e881 | ||
|
|
3b02e6899d | ||
|
|
948e93e1a2 | ||
|
|
5d19baf06b | ||
|
|
89309e40ba | ||
|
|
9f2e6b570f | ||
|
|
2d20e5a203 | ||
|
|
7c60737b69 | ||
|
|
7e9acdc59b | ||
|
|
2013ddaac2 | ||
|
|
ce898ea0c6 |
No files matched your search
+17
-15
@@ -15,6 +15,8 @@ concurrency:
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
# Line tables keep test backtraces readable; full debuginfo only adds build time and cache size.
|
||||
CARGO_PROFILE_DEV_DEBUG: line-tables-only
|
||||
|
||||
jobs:
|
||||
rust:
|
||||
@@ -25,10 +27,10 @@ jobs:
|
||||
os: [macos-latest, ubuntu-22.04, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: pnpm/action-setup@v5
|
||||
with: { version: 11 }
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v5
|
||||
with: { node-version: 22, cache: pnpm }
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with: { components: "clippy, rustfmt" }
|
||||
@@ -62,7 +64,7 @@ jobs:
|
||||
name: Remote server (linux musl)
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with: { targets: x86_64-unknown-linux-musl }
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
@@ -79,7 +81,7 @@ jobs:
|
||||
env:
|
||||
CARGO_TARGET_DIR: ${{ github.workspace }}/target-plugins
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with: { targets: wasm32-wasip2, components: "clippy, rustfmt" }
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
@@ -100,9 +102,9 @@ jobs:
|
||||
cargo test --locked --manifest-path "$dir/Cargo.toml"
|
||||
echo "::endgroup::"
|
||||
done
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: pnpm/action-setup@v5
|
||||
with: { version: 11 }
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v5
|
||||
with: { node-version: 22 }
|
||||
# Plugin web pages (`web-src/`, their own pnpm projects): typecheck and test.
|
||||
- name: Web pages typecheck and test
|
||||
@@ -126,7 +128,7 @@ jobs:
|
||||
name: Cargo deny
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: taiki-e/install-action@cargo-deny
|
||||
- run: cargo deny check
|
||||
@@ -135,10 +137,10 @@ jobs:
|
||||
name: Frontend
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: pnpm/action-setup@v5
|
||||
with: { version: 11 }
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v5
|
||||
with: { node-version: 22, cache: pnpm }
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm typecheck
|
||||
@@ -154,16 +156,16 @@ jobs:
|
||||
# Browsers and their system libraries are preinstalled; keep the tag in step with @playwright/test.
|
||||
container: mcr.microsoft.com/playwright:v1.64.1-noble
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: pnpm/action-setup@v5
|
||||
with: { version: 11 }
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v5
|
||||
with: { node-version: 22, cache: pnpm }
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm e2e
|
||||
env:
|
||||
HOME: /root
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@v6
|
||||
if: failure()
|
||||
with:
|
||||
name: playwright-report
|
||||
|
||||
@@ -2,6 +2,12 @@ name: Language smoke
|
||||
# Everyday code intelligence (completion, go to definition, Find Usages, hover, diagnostics
|
||||
# with a quick fix, an intention, formatting, rename) per language, against the language
|
||||
# server the app starts by default: crates/ide-host/tests/language_smoke.rs.
|
||||
# Opening the fixture with its build tool and SDK (Maven / Gradle sync, the virtualenv, the
|
||||
# JDK): the ignored tests of crates/ide-host/tests/project_smoke.rs.
|
||||
# Then running and testing each fixture through the run store and openidea-server
|
||||
# (Run, Run tests, the test tree, Rerun Failed Tests): smoke/run.test.ts; and debugging its
|
||||
# entry point with the debugger the IDE installs (breakpoint, frames, variables, evaluate,
|
||||
# Step Over, Resume): smoke/debug.test.ts.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
@@ -14,12 +20,19 @@ on:
|
||||
- "fixtures/**"
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- "src/features/run/**"
|
||||
- "src/features/debugger/**"
|
||||
- "src/platform/**"
|
||||
- "smoke/**"
|
||||
- "package.json"
|
||||
- "pnpm-lock.yaml"
|
||||
- ".github/workflows/language-smoke.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: language-smoke-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
# A new push supersedes a PR's run; on main every merge runs to the end (and saves the cache).
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
@@ -53,9 +66,10 @@ jobs:
|
||||
shared-key: language-smoke
|
||||
save-if: ${{ github.ref == 'refs/heads/main' && matrix.language == 'rust' }}
|
||||
|
||||
- if: matrix.language == 'typescript'
|
||||
uses: actions/setup-node@v4
|
||||
with: { node-version: 22 }
|
||||
- uses: pnpm/action-setup@v4
|
||||
with: { version: 11 }
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 22, cache: pnpm }
|
||||
- if: matrix.language == 'typescript'
|
||||
run: npm install --global "@vtsls/language-server@$VTSLS" "typescript@$TYPESCRIPT"
|
||||
|
||||
@@ -95,3 +109,16 @@ jobs:
|
||||
|
||||
- run: cargo test --locked -p ide-host --test language_smoke --no-run
|
||||
- run: cargo test --locked -p ide-host --test language_smoke -- --ignored --exact ${{ matrix.language }}
|
||||
- if: matrix.language == 'java'
|
||||
run: cargo test --locked -p ide-host --test project_smoke -- --ignored --exact maven_sync jdk
|
||||
- if: matrix.language == 'kotlin'
|
||||
run: cargo test --locked -p ide-host --test project_smoke -- --ignored --exact gradle_sync
|
||||
- if: matrix.language == 'python'
|
||||
run: cargo test --locked -p ide-host --test project_smoke -- --ignored --exact python_venv
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: cargo build --locked -p ide-host --bin openidea-server
|
||||
- run: pnpm smoke smoke/run.test.ts -t "^${{ matrix.language }}$"
|
||||
- run: pnpm smoke smoke/debug.test.ts -t "^${{ matrix.language }}$"
|
||||
# The IDE installs the debugger from GitHub releases: authenticated, against rate limits.
|
||||
env: { GITHUB_TOKEN: "${{ github.token }}" }
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
if: github.event.pull_request.user.type != 'Bot' && !github.event.pull_request.draft
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/github-script@v7
|
||||
- uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const pr = context.payload.pull_request;
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
release_id: ${{ steps.release.outputs.id }}
|
||||
official_plugin_keys: ${{ steps.plugin-keys.outputs.keys }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with: { ref: "${{ env.TAG }}" }
|
||||
- name: Tag matches app version
|
||||
run: |
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
echo "keys=$key" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- id: release
|
||||
uses: actions/github-script@v7
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const tag = process.env.TAG;
|
||||
@@ -99,11 +99,11 @@ jobs:
|
||||
target: x86_64-pc-windows-msvc
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with: { ref: "${{ env.TAG }}" }
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: pnpm/action-setup@v5
|
||||
with: { version: 11 }
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v5
|
||||
with: { node-version: 22, cache: pnpm }
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with: { targets: "${{ matrix.target }}" }
|
||||
@@ -173,7 +173,7 @@ jobs:
|
||||
- { runner: macos-latest, target: x86_64-apple-darwin, os: macos, arch: x86_64 }
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with: { ref: "${{ env.TAG }}" }
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with: { targets: "${{ matrix.target }}" }
|
||||
@@ -220,7 +220,7 @@ jobs:
|
||||
echo "::warning::PLUGIN_SIGNING_KEY is not set: the official plugins are not published"
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
if: steps.key.outputs.skip != 'true'
|
||||
with: { ref: "${{ env.TAG }}" }
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
@@ -229,10 +229,10 @@ jobs:
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
if: steps.key.outputs.skip != 'true'
|
||||
with: { key: release-plugins }
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: pnpm/action-setup@v5
|
||||
if: steps.key.outputs.skip != 'true'
|
||||
with: { version: 11 }
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v5
|
||||
if: steps.key.outputs.skip != 'true'
|
||||
with: { node-version: 22 }
|
||||
# Builds the web pages; fails when a checked-in component isn't built from these sources.
|
||||
@@ -277,7 +277,7 @@ jobs:
|
||||
needs: [create-release, build]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/github-script@v7
|
||||
- uses: actions/github-script@v8
|
||||
env:
|
||||
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
|
||||
with:
|
||||
@@ -337,7 +337,7 @@ jobs:
|
||||
needs: [create-release, build, server, plugins, updater-manifest]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/github-script@v7
|
||||
- uses: actions/github-script@v8
|
||||
env:
|
||||
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
|
||||
with:
|
||||
|
||||
@@ -69,6 +69,11 @@ Shared agent skills live in `.agents/skills/` (`.claude/skills/` links to them):
|
||||
- `cargo nextest run --workspace` (or `cargo test`), `cargo clippy --workspace --all-targets -- -D warnings`, `cargo fmt --all`.
|
||||
- `cargo test -p ide-host --test language_smoke -- --ignored --exact <language>` — code intelligence
|
||||
on `fixtures/` with that language's real server installed (CI: `Language smoke`, pinned versions).
|
||||
- `pnpm smoke smoke/run.test.ts -t <language>` — run and test that language's fixture through the
|
||||
run store and a real `openidea-server` (build it first: `cargo build -p ide-host --bin openidea-server`).
|
||||
- `pnpm smoke smoke/debug.test.ts -t <language>` — debug that fixture's entry point the same way, with
|
||||
the debugger the IDE installs (on macOS, CodeLLDB and Delve may need Developer Mode:
|
||||
`sudo DevToolsSecurity -enable`).
|
||||
- `pnpm test`, `pnpm e2e`, `pnpm lint`, `pnpm format:check`, `pnpm knip`, `pnpm typecheck`,
|
||||
`pnpm gen:bindings`.
|
||||
- `pnpm perf` (UI interaction latency / frames / leaks), `pnpm perf:rust` (criterion
|
||||
@@ -116,8 +121,8 @@ Several worktrees can be developed and run side by side.
|
||||
default MCP session belongs to this worktree.
|
||||
- Keep each worktree's Cargo output directory separate; do not point `CARGO_TARGET_DIR` or
|
||||
`target` symlinks at a shared directory.
|
||||
- Each worktree's first build is cold (around 5 minutes) and its Cargo output takes
|
||||
15–30 GB. Avoid running several heavy builds on one machine at the same time.
|
||||
- Each worktree's first build is cold (several minutes) and `cargo build` output takes
|
||||
around 9 GB; tests, clippy and incremental builds add more. Avoid running several heavy builds on one machine at the same time.
|
||||
- Stop only processes started for the current worktree/session. Never use broad
|
||||
process-name kills such as `pkill openidea`.
|
||||
- When done: stop that worktree's app and dev session, delete its build output, then
|
||||
|
||||
Generated
+280
-32
@@ -123,6 +123,17 @@ version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "apple-native-keyring-store"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b350bfd03649e07aa05c0a81b3e15934374e585c98204a57e20b9d49f49bb9a"
|
||||
dependencies = [
|
||||
"keyring-core",
|
||||
"log",
|
||||
"security-framework",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arbitrary"
|
||||
version = "1.5.0"
|
||||
@@ -156,6 +167,27 @@ version = "0.7.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
|
||||
|
||||
[[package]]
|
||||
name = "ashpd"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39"
|
||||
dependencies = [
|
||||
"enumflags2",
|
||||
"futures-channel",
|
||||
"futures-util",
|
||||
"rand 0.9.5",
|
||||
"raw-window-handle",
|
||||
"serde",
|
||||
"serde_repr",
|
||||
"tokio",
|
||||
"url",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-protocols",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ast-grep-core"
|
||||
version = "0.40.1"
|
||||
@@ -1600,6 +1632,7 @@ dependencies = [
|
||||
"lock_api",
|
||||
"once_cell",
|
||||
"parking_lot_core",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1795,6 +1828,15 @@ dependencies = [
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dlib"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a"
|
||||
dependencies = [
|
||||
"libloading",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dlopen2"
|
||||
version = "0.8.2"
|
||||
@@ -2163,15 +2205,6 @@ dependencies = [
|
||||
"rustc_version",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "file-id"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e1fc6a637b6dc58414714eddd9170ff187ecb0933d4c7024d1abbd23a3cc26e9"
|
||||
dependencies = [
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filedescriptor"
|
||||
version = "0.8.3"
|
||||
@@ -4120,7 +4153,7 @@ version = "0.1.36"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-run",
|
||||
"quick-xml",
|
||||
"quick-xml 0.42.0",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
@@ -4272,7 +4305,7 @@ dependencies = [
|
||||
"ide-workspace",
|
||||
"ignore",
|
||||
"parking_lot",
|
||||
"quick-xml",
|
||||
"quick-xml 0.42.0",
|
||||
"regex-syntax",
|
||||
"reqwest",
|
||||
"rustix",
|
||||
@@ -4514,7 +4547,7 @@ dependencies = [
|
||||
"libc",
|
||||
"parking_lot",
|
||||
"portable-pty",
|
||||
"quick-xml",
|
||||
"quick-xml 0.42.0",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
@@ -4585,6 +4618,8 @@ dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"flate2",
|
||||
"ide-core",
|
||||
"keyring",
|
||||
"keyring-core",
|
||||
"parking_lot",
|
||||
"russh",
|
||||
"russh-config",
|
||||
@@ -4595,6 +4630,7 @@ dependencies = [
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4634,6 +4670,7 @@ dependencies = [
|
||||
"proptest",
|
||||
"ropey",
|
||||
"serde",
|
||||
"similar",
|
||||
"specta",
|
||||
]
|
||||
|
||||
@@ -4662,12 +4699,14 @@ dependencies = [
|
||||
name = "ide-vcs"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
"gix",
|
||||
"ide-core",
|
||||
"ignore",
|
||||
"libc",
|
||||
"redb",
|
||||
"regex-lite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4688,7 +4727,7 @@ dependencies = [
|
||||
"globset",
|
||||
"ide-core",
|
||||
"ignore",
|
||||
"notify-debouncer-full",
|
||||
"notify",
|
||||
"parking_lot",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4735,6 +4774,7 @@ dependencies = [
|
||||
"toml 0.9.12+spec-1.1.0",
|
||||
"toml_edit 0.25.17+spec-1.1.0",
|
||||
"tracing",
|
||||
"uuid",
|
||||
"zip",
|
||||
]
|
||||
|
||||
@@ -5212,6 +5252,33 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "keyring"
|
||||
version = "4.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2270074a3d26bcac93c1dc5d2845eb4c089e8d761ccf6e0ea266a16004640627"
|
||||
dependencies = [
|
||||
"apple-native-keyring-store",
|
||||
"keyring-core",
|
||||
"windows-native-keyring-store",
|
||||
"zbus-secret-service-keyring-store",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "keyring-core"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29fbf4b551298c67925f38cf341a1ada797a75f1837bdf8ae1fc8e2ebc6efb59"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dashmap",
|
||||
"log",
|
||||
"regex",
|
||||
"ron",
|
||||
"serde",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kqueue"
|
||||
version = "1.2.1"
|
||||
@@ -5634,19 +5701,6 @@ dependencies = [
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "notify-debouncer-full"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "375bd3a138be7bfeff3480e4a623df4cbfb55b79df617c055cd810ba466fa078"
|
||||
dependencies = [
|
||||
"file-id",
|
||||
"log",
|
||||
"notify",
|
||||
"notify-types",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "notify-types"
|
||||
version = "2.1.0"
|
||||
@@ -5675,6 +5729,30 @@ dependencies = [
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
|
||||
dependencies = [
|
||||
"num-bigint 0.4.8",
|
||||
"num-complex",
|
||||
"num-integer",
|
||||
"num-iter",
|
||||
"num-rational",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.4.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.5.1"
|
||||
@@ -5687,6 +5765,15 @@ dependencies = [
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-complex"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.2"
|
||||
@@ -5702,6 +5789,27 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-iter"
|
||||
version = "0.1.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-rational"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
|
||||
dependencies = [
|
||||
"num-bigint 0.4.8",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
@@ -6590,7 +6698,7 @@ checksum = "65dca778f41c79ebbe8b2b2139fee86f01b766b67130d108c7c2b8ffbd949f77"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"indexmap 2.14.2",
|
||||
"quick-xml",
|
||||
"quick-xml 0.42.0",
|
||||
"serde",
|
||||
"time",
|
||||
]
|
||||
@@ -6635,6 +6743,12 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pollster"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3"
|
||||
|
||||
[[package]]
|
||||
name = "poly1305"
|
||||
version = "0.9.1"
|
||||
@@ -6895,6 +7009,15 @@ version = "1.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.41.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.42.0"
|
||||
@@ -7172,18 +7295,18 @@ version = "0.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
|
||||
dependencies = [
|
||||
"ashpd",
|
||||
"block2 0.6.2",
|
||||
"dispatch2",
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"gtk-sys",
|
||||
"js-sys",
|
||||
"log",
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation 0.3.2",
|
||||
"pollster",
|
||||
"raw-window-handle",
|
||||
"urlencoding",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
@@ -7204,6 +7327,19 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ron"
|
||||
version = "0.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "97b3fe97c18ff24299ea56f802c5693eb34266fb0bc8317f4e101debc8a3ceaf"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"typeid",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ropey"
|
||||
version = "1.6.1"
|
||||
@@ -7270,7 +7406,7 @@ dependencies = [
|
||||
"md5",
|
||||
"ml-kem",
|
||||
"module-lattice",
|
||||
"num-bigint",
|
||||
"num-bigint 0.5.1",
|
||||
"p256",
|
||||
"p384",
|
||||
"p521",
|
||||
@@ -7601,6 +7737,12 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scoped-tls"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"
|
||||
|
||||
[[package]]
|
||||
name = "scopeguard"
|
||||
version = "1.2.0"
|
||||
@@ -7651,6 +7793,25 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "secret-service"
|
||||
version = "5.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5107b24b91445dd2aa449a258a1807b63240942157292354dc5bfdbeb8bc6db8"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"cbc",
|
||||
"futures-util",
|
||||
"getrandom 0.4.3",
|
||||
"hkdf",
|
||||
"hybrid-array",
|
||||
"num",
|
||||
"once_cell",
|
||||
"serde",
|
||||
"sha2 0.11.1",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
@@ -9139,7 +9300,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.3.4",
|
||||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
@@ -9327,6 +9488,7 @@ dependencies = [
|
||||
"signal-hook-registry",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
"tracing",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
@@ -10663,6 +10825,66 @@ dependencies = [
|
||||
"wasmtime",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-backend"
|
||||
version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"downcast-rs",
|
||||
"rustix",
|
||||
"scoped-tls",
|
||||
"smallvec",
|
||||
"wayland-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-client"
|
||||
version = "0.31.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"rustix",
|
||||
"wayland-backend",
|
||||
"wayland-scanner",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-protocols"
|
||||
version = "0.32.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-scanner",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-scanner"
|
||||
version = "0.31.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quick-xml 0.41.0",
|
||||
"quote",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-sys"
|
||||
version = "0.31.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be"
|
||||
dependencies = [
|
||||
"dlib",
|
||||
"log",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.106"
|
||||
@@ -10948,6 +11170,19 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-native-keyring-store"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "063426e76fdec7438d56bb777f67e318a84a25c707b07e575cb8b78e10c028f8"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"keyring-core",
|
||||
"regex",
|
||||
"windows-sys 0.61.2",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-numerics"
|
||||
version = "0.3.1"
|
||||
@@ -11474,6 +11709,7 @@ dependencies = [
|
||||
"rustix",
|
||||
"serde",
|
||||
"serde_repr",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"uds_windows",
|
||||
"uuid",
|
||||
@@ -11484,6 +11720,17 @@ dependencies = [
|
||||
"zvariant",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus-secret-service-keyring-store"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "74801d001b9e7729adb4f1825b67b398185fed424749aa3d8bacf70417137d9a"
|
||||
dependencies = [
|
||||
"keyring-core",
|
||||
"secret-service",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus_macros"
|
||||
version = "5.19.0"
|
||||
@@ -11689,6 +11936,7 @@ dependencies = [
|
||||
"endi",
|
||||
"enumflags2",
|
||||
"serde",
|
||||
"url",
|
||||
"winnow 1.0.4",
|
||||
"zcheapstr",
|
||||
"zvariant_derive",
|
||||
|
||||
+9
-1
@@ -76,7 +76,6 @@ ignore = "0.4"
|
||||
base64 = "0.22"
|
||||
globset = "0.4"
|
||||
notify = "8"
|
||||
notify-debouncer-full = "0.6"
|
||||
trash = "5"
|
||||
blake3 = "1"
|
||||
dunce = "1"
|
||||
@@ -106,6 +105,9 @@ sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio",
|
||||
tiberius = { version = "0.13", default-features = false, features = ["tds73", "rustls", "sql-browser-tokio"] }
|
||||
bb8 = "0.9"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
# OS credential stores
|
||||
keyring = "4.2"
|
||||
keyring-core = "1"
|
||||
|
||||
# testing
|
||||
insta = { version = "1", features = ["yaml"] }
|
||||
@@ -115,8 +117,14 @@ tempfile = "3"
|
||||
[profile.dev]
|
||||
# Keep debug builds snappy while giving dependencies decent speed.
|
||||
opt-level = 0
|
||||
# Full debug info is most of target/. Line tables keep file:line in our backtraces, but a
|
||||
# debugger can't show locals; re-enable with `--config profile.dev.debug=true`.
|
||||
debug = "line-tables-only"
|
||||
[profile.dev.package."*"]
|
||||
opt-level = 2
|
||||
# Dependency frames keep function names only. Re-enable one with
|
||||
# `--config 'profile.dev.package.<crate>.debug=true'`.
|
||||
debug = false
|
||||
|
||||
[profile.release]
|
||||
codegen-units = 1
|
||||
|
||||
@@ -52,6 +52,15 @@ Their original copyright headers and Apache-2.0 licensing are retained.
|
||||
|
||||
At render time `src/ui/icons.tsx` may recolour monochrome icons to `currentColor`.
|
||||
|
||||
## JetBrains IntelliJ Platform VCS log graph algorithms
|
||||
|
||||
`crates/ide-vcs/src/bek.rs` and `src/features/vcs/graph.ts` adapt the Bek ordering,
|
||||
graph layout and LinearBek merge linearization algorithms from
|
||||
[intellij-community](https://github.com/JetBrains/intellij-community/tree/1d70d5c54302d655758edbd2f808fb8dea8b1764/platform/vcs-log/graph/src/com/intellij/vcs/log/graph)
|
||||
(`impl/facade/sort/bek`, `impl/permanent/GraphLayoutBuilder.kt` and
|
||||
`linearBek/LinearBekGraphBuilder.java`). Modified from the originals.
|
||||
Copyright 2000-2026 JetBrains s.r.o. and contributors. Apache License 2.0 (below).
|
||||
|
||||
## JetBrains IntelliJ Platform diff algorithm
|
||||
|
||||
`src/features/vcs/comparison/**` is a TypeScript port of the text comparison of
|
||||
|
||||
@@ -36,6 +36,23 @@ impl Default for InspectionSettings {
|
||||
}
|
||||
}
|
||||
|
||||
impl InspectionSettings {
|
||||
/// Overrides of the profile in effect; `Default`'s when `profile` names none that exists.
|
||||
pub fn active(&self) -> &BTreeMap<String, InspectionOverride> {
|
||||
let custom = |level: &str, key: &str| match level {
|
||||
"ide" => self.ide_profiles.get(key),
|
||||
"project" => self.project_profiles.get(key),
|
||||
_ => None,
|
||||
};
|
||||
match self.profile.as_str() {
|
||||
"project" => &self.project,
|
||||
id => {
|
||||
id.split_once(':').and_then(|(level, key)| custom(level, key)).map_or(&self.default, |p| &p.overrides)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A named profile made with Copy.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
@@ -111,4 +128,20 @@ mod tests {
|
||||
let back: InspectionSettings = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap();
|
||||
assert_eq!(back, s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_profile_falls_back_to_default() {
|
||||
let off = |id: &str| {
|
||||
BTreeMap::from([(id.to_owned(), InspectionOverride { enabled: Some(false), ..Default::default() })])
|
||||
};
|
||||
let mut s = InspectionSettings { default: off("a"), project: off("b"), ..Default::default() };
|
||||
s.ide_profiles.insert("strict".into(), CustomInspectionProfile { name: "Strict".into(), overrides: off("c") });
|
||||
assert!(s.active().contains_key("a"));
|
||||
s.profile = "project".into();
|
||||
assert!(s.active().contains_key("b"));
|
||||
s.profile = "ide:strict".into();
|
||||
assert!(s.active().contains_key("c"));
|
||||
s.profile = "project:gone".into();
|
||||
assert!(s.active().contains_key("a"));
|
||||
}
|
||||
}
|
||||
@@ -716,6 +716,12 @@ pub struct PhpSettings {
|
||||
/// PHP version the language server checks the code against (`7.4`); empty = from
|
||||
/// `composer.json` (`config.platform.php`, else the lowest version `require.php` allows).
|
||||
pub language_level: String,
|
||||
/// Folders outside the project whose code the language server indexes too (a platform a
|
||||
/// module extends); absolute, `~/`, or relative to the project.
|
||||
pub include_paths: Vec<String>,
|
||||
/// Intelephense stub sets loaded on top of its defaults and the `ext-*` entries of
|
||||
/// `composer.json` (`wordpress`).
|
||||
pub stubs: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -589,11 +589,12 @@ impl DebugManager {
|
||||
if let Some(command) = custom_command(&spec.command) {
|
||||
spec.command = command;
|
||||
}
|
||||
// debugpy: run the adapter with the debuggee's interpreter (venvs carry their own debugpy).
|
||||
// debugpy: run the adapter with the debuggee's interpreter (venvs carry their own debugpy),
|
||||
// or with the launcher's when the debuggee runs through a manager (`uv run python`).
|
||||
if config.adapter == "debugpy" {
|
||||
let python = match arguments.get("python") {
|
||||
Some(Value::String(p)) => Some(p.clone()),
|
||||
Some(Value::Array(a)) => a.first().and_then(Value::as_str).map(str::to_owned),
|
||||
let python = match (arguments.get("debugLauncherPython"), arguments.get("python")) {
|
||||
(Some(Value::String(p)), _) | (_, Some(Value::String(p))) => Some(p.clone()),
|
||||
(_, Some(Value::Array(a))) => a.first().and_then(Value::as_str).map(str::to_owned),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(python) = python {
|
||||
@@ -651,8 +652,19 @@ impl DebugManager {
|
||||
{
|
||||
arguments["project"] = json!(real);
|
||||
}
|
||||
if config.adapter == "codelldb" && arguments.get("sourceLanguages").is_none() {
|
||||
arguments["sourceLanguages"] = json!(["rust"]);
|
||||
if config.adapter == "codelldb" {
|
||||
if arguments.get("sourceLanguages").is_none() {
|
||||
arguments["sourceLanguages"] = json!(["rust"]);
|
||||
}
|
||||
// The Debug Console and Evaluate Expression take expressions, as with every other
|
||||
// debugger; LLDB commands still run with a leading backtick.
|
||||
match arguments.get_mut("_adapterSettings") {
|
||||
None => arguments["_adapterSettings"] = json!({"consoleMode": "evaluate"}),
|
||||
Some(Value::Object(settings)) => {
|
||||
settings.entry("consoleMode").or_insert_with(|| json!("evaluate"));
|
||||
}
|
||||
Some(_) => {}
|
||||
}
|
||||
}
|
||||
let port = debug_server.map(|s| s.port);
|
||||
self.start_session(id, owner, config.name, config.adapter, None, config.request, arguments, spec, port).await
|
||||
|
||||
@@ -111,8 +111,8 @@ impl Session {
|
||||
if let Some(path) = self.kotlin_mirror.lock().as_ref().and_then(|m| m.adapter_path(path)) {
|
||||
return path;
|
||||
}
|
||||
if self.adapter == "java-debug" {
|
||||
let native = java_source_path(&path.to_string_lossy());
|
||||
if self.adapter == "java-debug" || (cfg!(windows) && self.adapter == "netcoredbg") {
|
||||
let native = windows_source_path(&path.to_string_lossy());
|
||||
self.resolved_paths.lock().insert(native.clone(), path.to_path_buf());
|
||||
return native;
|
||||
}
|
||||
@@ -133,7 +133,11 @@ impl Session {
|
||||
if let Some(real) = self.kotlin_mirror.lock().as_ref().and_then(|m| m.client_path(Path::new(path))) {
|
||||
return Some(real);
|
||||
}
|
||||
let key = if self.adapter == "java-debug" { java_source_path(path) } else { PathBuf::from(path) };
|
||||
let key = if self.adapter == "java-debug" || (cfg!(windows) && self.adapter == "netcoredbg") {
|
||||
windows_source_path(path)
|
||||
} else {
|
||||
PathBuf::from(path)
|
||||
};
|
||||
self.resolved_paths.lock().get(&key).cloned().or_else(|| {
|
||||
(self.adapter == "java-debug" && is_windows_source_path(path))
|
||||
.then(|| PathBuf::from(path.replace('\\', "/")))
|
||||
@@ -265,8 +269,8 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
// java-debug treats a forward-slash drive path as a URI (scheme "C").
|
||||
// Keep DAP path mode, but use native Windows separators for Java source lookup.
|
||||
// java-debug and Windows netcoredbg need native separators for source lookup.
|
||||
// Keep DAP path mode, but normalize Windows drive and UNC paths.
|
||||
fn is_windows_source_path(path: &str) -> bool {
|
||||
let bytes = path.as_bytes();
|
||||
(bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && matches!(bytes[2], b'/' | b'\\'))
|
||||
@@ -274,7 +278,7 @@ fn is_windows_source_path(path: &str) -> bool {
|
||||
|| path.starts_with(r"\\")
|
||||
}
|
||||
|
||||
fn java_source_path(path: &str) -> PathBuf {
|
||||
fn windows_source_path(path: &str) -> PathBuf {
|
||||
if is_windows_source_path(path) { PathBuf::from(path.replace('/', r"\")) } else { PathBuf::from(path) }
|
||||
}
|
||||
|
||||
|
||||
@@ -698,6 +698,37 @@ async fn js_debug_launch_gets_port_and_workspace_folder() {
|
||||
assert_eq!(launch["type"], "pwa-node");
|
||||
}
|
||||
|
||||
/// CodeLLDB treats console input as LLDB commands unless told otherwise: Evaluate Expression
|
||||
/// and the Debug Console send expressions, so its console evaluates them. A launch
|
||||
/// configuration that picks a console mode keeps it.
|
||||
#[tokio::test]
|
||||
async fn codelldb_console_evaluates_expressions() {
|
||||
let launch_with = |extra: Value| async move {
|
||||
let h = harness(Script::default());
|
||||
let config = LaunchConfig {
|
||||
name: "demo".into(),
|
||||
adapter: "codelldb".into(),
|
||||
request: "launch".into(),
|
||||
program: Some("/p/target/debug/demo".into()),
|
||||
args: vec![],
|
||||
cwd: Some("/p".into()),
|
||||
env: BTreeMap::new(),
|
||||
extra_json: Some(extra.to_string()),
|
||||
};
|
||||
h.start(config).await.unwrap();
|
||||
h.request("launch")["arguments"].clone()
|
||||
};
|
||||
let launch = launch_with(json!({})).await;
|
||||
assert_eq!(launch["_adapterSettings"]["consoleMode"], "evaluate");
|
||||
assert_eq!(launch["sourceLanguages"], json!(["rust"]));
|
||||
|
||||
let launch =
|
||||
launch_with(json!({"_adapterSettings": {"consoleMode": "commands", "showDisassembly": "never"}})).await;
|
||||
assert_eq!(launch["_adapterSettings"], json!({"consoleMode": "commands", "showDisassembly": "never"}));
|
||||
let launch = launch_with(json!({"_adapterSettings": {"showDisassembly": "never"}})).await;
|
||||
assert_eq!(launch["_adapterSettings"], json!({"consoleMode": "evaluate", "showDisassembly": "never"}));
|
||||
}
|
||||
|
||||
/// An adapter that is already listening (java-debug inside jdtls): the client
|
||||
/// connects over TCP to the `debugServer` port instead of spawning anything.
|
||||
#[tokio::test]
|
||||
@@ -1110,6 +1141,23 @@ async fn kotlin_official_release_ten_no_delay_runs() {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[tokio::test]
|
||||
async fn netcoredbg_windows_source_paths_round_trip() {
|
||||
let client = "D:/work/LoginController.cs";
|
||||
let native = r"D:\work\LoginController.cs";
|
||||
let h = harness(Script { source_path: Some(native.into()), ..Script::default() });
|
||||
let path = PathBuf::from(client);
|
||||
h.manager.set_breakpoints(path.clone(), vec![bp(25)]).await.unwrap();
|
||||
let mut cfg = config();
|
||||
cfg.adapter = "netcoredbg".into();
|
||||
let id = h.start(cfg).await.unwrap();
|
||||
assert_eq!(h.request("setBreakpoints")["arguments"]["source"]["path"], native);
|
||||
let frames = h.manager.stack_trace(id, 1).await.unwrap();
|
||||
assert_eq!(frames[0].path.as_deref(), Some(path.as_path()));
|
||||
h.manager.stop(id).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn java_windows_source_paths_for_launch_and_attach() {
|
||||
for request in ["launch", "attach"] {
|
||||
|
||||
@@ -29,3 +29,4 @@ uuid.workspace = true
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
|
||||
tempfile.workspace = true
|
||||
ide-ssh = { workspace = true, features = ["testing"] }
|
||||
+153
-15
@@ -213,6 +213,8 @@ pub struct DbManager {
|
||||
/// App-wide SSH connections and host-key trust.
|
||||
ssh: Arc<SshManager>,
|
||||
saved_passwords: SecretStore,
|
||||
/// Directory of the global data sources and their saved passwords ([`DbManager::with_global_sources`]).
|
||||
global: Option<(PathBuf, SecretStore)>,
|
||||
history: Arc<HistoryStore>,
|
||||
/// Records executed statements in the history off the async threads, in order.
|
||||
recorder: std::sync::mpsc::Sender<(String, String, f64)>,
|
||||
@@ -339,6 +341,7 @@ impl DbManager {
|
||||
}
|
||||
Self {
|
||||
saved_passwords: SecretStore::new(passwords, &root),
|
||||
global: None,
|
||||
history,
|
||||
recorder,
|
||||
root,
|
||||
@@ -351,8 +354,42 @@ impl DbManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Also lists the global data sources stored in `dir` (the app data directory).
|
||||
pub fn with_global_sources(mut self, dir: PathBuf) -> Self {
|
||||
let passwords = self.saved_passwords.scoped(&dir);
|
||||
self.global = Some((dir, passwords));
|
||||
self
|
||||
}
|
||||
|
||||
/// The project's data sources, then the global ones (a project one wins on the same id).
|
||||
pub fn sources(&self) -> IdeResult<Vec<DataSource>> {
|
||||
source::load(&self.root)
|
||||
let mut all = source::load(&self.root)?;
|
||||
let global: Vec<_> = self.global_sources().into_iter().filter(|g| all.iter().all(|s| s.id != g.id)).collect();
|
||||
all.extend(global);
|
||||
Ok(all)
|
||||
}
|
||||
|
||||
fn global_sources(&self) -> Vec<DataSource> {
|
||||
let Some((dir, _)) = &self.global else { return Vec::new() };
|
||||
source::load_global(dir).unwrap_or_else(|e| {
|
||||
tracing::warn!("global data sources: {}", e.message);
|
||||
Vec::new()
|
||||
})
|
||||
}
|
||||
|
||||
/// Where the saved passwords of data source `id` live: app-wide for a global one.
|
||||
fn saved_for(&self, id: &str) -> &SecretStore {
|
||||
match &self.global {
|
||||
Some((_, store)) if self.global_sources().iter().any(|s| s.id == id) => store,
|
||||
_ => &self.saved_passwords,
|
||||
}
|
||||
}
|
||||
|
||||
fn store_of(&self, source: &DataSource) -> &SecretStore {
|
||||
match &self.global {
|
||||
Some((_, store)) if source.global => store,
|
||||
_ => &self.saved_passwords,
|
||||
}
|
||||
}
|
||||
|
||||
/// The connection settings of target `target` ([`DataSource::resolved`]), with
|
||||
@@ -372,18 +409,43 @@ impl DbManager {
|
||||
}
|
||||
|
||||
/// Saves the data source list; connections of removed or changed sources are closed.
|
||||
/// Global ones (`global`) go to the app-wide list, the others to the project's.
|
||||
pub async fn save_sources(&self, sources: Vec<DataSource>) -> IdeResult<()> {
|
||||
let old = self.sources().unwrap_or_default();
|
||||
source::save(&self.root, &sources)?;
|
||||
let (global, project): (Vec<_>, Vec<_>) = sources.iter().cloned().partition(|s| s.global);
|
||||
match &self.global {
|
||||
// Rewritten only when it changes: other projects' windows edit the same file.
|
||||
Some((dir, _)) => {
|
||||
if global != old.iter().filter(|s| s.global).cloned().collect::<Vec<_>>() {
|
||||
source::save_global(dir, &global)?;
|
||||
}
|
||||
}
|
||||
None if !global.is_empty() => return Err(IdeError::invalid("global data sources are not available here")),
|
||||
None => {}
|
||||
}
|
||||
source::save(&self.root, &project)?;
|
||||
for o in old {
|
||||
match sources.iter().find(|s| s.id == o.id) {
|
||||
Some(n) if *n == o => {}
|
||||
gone_or_changed => {
|
||||
if gone_or_changed.is_none() {
|
||||
for k in [o.id.clone(), secrets::ssh_key(&o.id)] {
|
||||
self.session_passwords.lock().remove(&k);
|
||||
self.saved_passwords.delete(&k);
|
||||
let keys = [o.id.clone(), secrets::ssh_key(&o.id)];
|
||||
match gone_or_changed {
|
||||
None => {
|
||||
for k in &keys {
|
||||
self.session_passwords.lock().remove(k);
|
||||
self.store_of(&o).delete(k);
|
||||
}
|
||||
}
|
||||
// Made global or project-level: its saved passwords move along.
|
||||
Some(n) if n.global != o.global => {
|
||||
for k in &keys {
|
||||
if let Some(p) = self.store_of(&o).get(k) {
|
||||
self.store_of(n).set(k, &p)?;
|
||||
self.store_of(&o).delete(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(_) => {}
|
||||
}
|
||||
self.disconnect_source(&o.id).await;
|
||||
}
|
||||
@@ -395,7 +457,7 @@ impl DbManager {
|
||||
/// Remembers `password` for the session, and in the saved passwords when
|
||||
/// the data source saves passwords (`None` forgets it).
|
||||
pub async fn set_password(&self, id: &str, password: Option<String>, save: bool) -> IdeResult<()> {
|
||||
self.remember(id.to_owned(), password, save).await
|
||||
self.remember(id, id.to_owned(), password, save).await
|
||||
}
|
||||
|
||||
/// [`DbManager::set_password`] for the SSH password / key passphrase.
|
||||
@@ -407,10 +469,10 @@ impl DbManager {
|
||||
self.ssh.set_secret(&c, secret, save);
|
||||
return Ok(());
|
||||
}
|
||||
self.remember(secrets::ssh_key(id), secret, save).await
|
||||
self.remember(id, secrets::ssh_key(id), secret, save).await
|
||||
}
|
||||
|
||||
async fn remember(&self, key: String, secret: Option<String>, save: bool) -> IdeResult<()> {
|
||||
async fn remember(&self, id: &str, key: String, secret: Option<String>, save: bool) -> IdeResult<()> {
|
||||
match &secret {
|
||||
Some(p) => {
|
||||
self.session_passwords.lock().insert(key.clone(), p.clone());
|
||||
@@ -419,10 +481,11 @@ impl DbManager {
|
||||
self.session_passwords.lock().remove(&key);
|
||||
}
|
||||
}
|
||||
let store = self.saved_for(id);
|
||||
let result = match (secret, save) {
|
||||
(Some(p), true) => self.saved_passwords.set(&key, &p),
|
||||
(Some(p), true) => store.set(&key, &p),
|
||||
_ => {
|
||||
self.saved_passwords.delete(&key);
|
||||
store.delete(&key);
|
||||
Ok(())
|
||||
}
|
||||
};
|
||||
@@ -433,18 +496,18 @@ impl DbManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn secret(&self, key: String, saved: bool) -> Option<String> {
|
||||
async fn secret(&self, source: &DataSource, key: String, saved: bool) -> Option<String> {
|
||||
if let Some(p) = self.session_passwords.lock().get(&key) {
|
||||
return Some(p.clone());
|
||||
}
|
||||
if !saved {
|
||||
return None;
|
||||
}
|
||||
self.saved_passwords.get(&key)
|
||||
self.store_of(source).get(&key)
|
||||
}
|
||||
|
||||
async fn password(&self, source: &DataSource) -> Option<String> {
|
||||
match self.secret(source.id.clone(), source.driver != DbDriver::Sqlite && source.save_password).await {
|
||||
match self.secret(source, source.id.clone(), source.driver != DbDriver::Sqlite && source.save_password).await {
|
||||
Some(p) => Some(p),
|
||||
None => source.url_password(),
|
||||
}
|
||||
@@ -500,7 +563,7 @@ impl DbManager {
|
||||
let session = if config.is_empty() {
|
||||
let secret = match ssh_secret {
|
||||
Some(s) => Some(s),
|
||||
None => self.secret(secrets::ssh_key(&source.id), ssh.save_secret).await,
|
||||
None => self.secret(source, secrets::ssh_key(&source.id), ssh.save_secret).await,
|
||||
};
|
||||
let target = SshTarget {
|
||||
host: ssh.host.clone(),
|
||||
@@ -1098,6 +1161,81 @@ impl DbManager {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ide_ssh::secrets::testing::files_containing;
|
||||
use std::path::Path;
|
||||
|
||||
fn manager(dir: &Path) -> DbManager {
|
||||
ide_ssh::secrets::testing::use_memory_store();
|
||||
let data = dir.join("data");
|
||||
let ssh = Arc::new(SshManager::new(data.join("ssh-passwords.json")));
|
||||
DbManager::new(dir.join("project"), data.join("db-passwords.json"), data.join("db-history.json"), ssh)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn saved_passwords_stay_off_disk() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
manager(tmp.path()).set_password("s1", Some("hunter2-db".into()), true).await.unwrap();
|
||||
manager(tmp.path()).set_ssh_secret("s1", Some("hunter2-tunnel".into()), true).await.unwrap();
|
||||
assert_eq!(files_containing(tmp.path(), "hunter2-db"), Vec::<PathBuf>::new());
|
||||
assert_eq!(files_containing(tmp.path(), "hunter2-tunnel"), Vec::<PathBuf>::new());
|
||||
let reopened = manager(tmp.path());
|
||||
assert_eq!(reopened.secret(&DataSource::default(), "s1".into(), true).await.as_deref(), Some("hunter2-db"));
|
||||
assert_eq!(
|
||||
reopened.secret(&DataSource::default(), secrets::ssh_key("s1"), true).await.as_deref(),
|
||||
Some("hunter2-tunnel")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn moves_plaintext_passwords_of_older_versions_off_disk() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let project = tmp.path().join("project").to_string_lossy().into_owned();
|
||||
let legacy = serde_json::json!({ project: { "s1": "old-db-pw" }, "/other": { "s2": "other-pw" } });
|
||||
std::fs::create_dir_all(tmp.path().join("data")).unwrap();
|
||||
std::fs::write(tmp.path().join("data/db-passwords.json"), legacy.to_string()).unwrap();
|
||||
let m = manager(tmp.path());
|
||||
assert_eq!(m.secret(&DataSource::default(), "s1".into(), true).await.as_deref(), Some("old-db-pw"));
|
||||
assert_eq!(files_containing(tmp.path(), "old-db-pw"), Vec::<PathBuf>::new());
|
||||
assert_eq!(files_containing(tmp.path(), "other-pw"), Vec::<PathBuf>::new());
|
||||
let other = SecretStore::new(tmp.path().join("data/db-passwords.json"), Path::new("/other"));
|
||||
assert_eq!(other.get("s2").as_deref(), Some("other-pw"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn global_data_sources_are_shared_by_projects_with_their_passwords() {
|
||||
ide_ssh::secrets::testing::use_memory_store();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (app, one, two) = (tmp.path().join("app"), tmp.path().join("one"), tmp.path().join("two"));
|
||||
let manager = |root: &std::path::Path| {
|
||||
let ssh = Arc::new(SshManager::new(app.join("ssh-passwords.json")));
|
||||
DbManager::new(root.to_path_buf(), app.join("passwords.json"), app.join("history.json"), ssh)
|
||||
.with_global_sources(app.clone())
|
||||
};
|
||||
let (a, b) = (manager(&one), manager(&two));
|
||||
let source = |id: &str, global: bool| DataSource {
|
||||
id: id.into(),
|
||||
name: id.into(),
|
||||
driver: DbDriver::Postgres,
|
||||
save_password: true,
|
||||
global,
|
||||
..Default::default()
|
||||
};
|
||||
a.save_sources(vec![source("local", false), source("shared", false)]).await.unwrap();
|
||||
a.set_password("shared", Some("pw".into()), true).await.unwrap();
|
||||
// Made global: listed in the other project too, with its saved password.
|
||||
a.save_sources(vec![source("local", false), source("shared", true)]).await.unwrap();
|
||||
let ids = |m: &DbManager| m.sources().unwrap().into_iter().map(|s| (s.id, s.global)).collect::<Vec<_>>();
|
||||
assert_eq!(ids(&a), [("local".into(), false), ("shared".into(), true)]);
|
||||
assert_eq!(ids(&b), [("shared".into(), true)]);
|
||||
assert_eq!(b.password(&b.source("shared").unwrap()).await.as_deref(), Some("pw"));
|
||||
let project_file = std::fs::read_to_string(one.join(".openidea/data-sources.toml")).unwrap();
|
||||
assert!(!project_file.contains("shared") && !project_file.contains("global"), "{project_file}");
|
||||
assert!(!std::fs::read_to_string(app.join("data-sources.toml")).unwrap().contains("global"));
|
||||
// Made project-level again: gone from the other project, the password stays with it.
|
||||
a.save_sources(vec![source("local", false), source("shared", false)]).await.unwrap();
|
||||
assert!(b.sources().unwrap().is_empty());
|
||||
assert_eq!(a.password(&a.source("shared").unwrap()).await.as_deref(), Some("pw"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn clickhouse_rejects_manual_transactions_and_table_edits_without_connecting() {
|
||||
|
||||
@@ -1,20 +1,15 @@
|
||||
//! Saved data source passwords, in the app's data directory rather than the OS
|
||||
//! credential store (no system prompts). One JSON file for all projects, keyed
|
||||
//! by project root, then credential key; owner-only on Unix.
|
||||
//! Saved data source passwords, in the OS credential store like the other saved
|
||||
//! passwords ([`SecretFile`]). One index file for all projects; each credential is
|
||||
//! keyed by project root, then credential key.
|
||||
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use parking_lot::Mutex;
|
||||
use ide_core::IdeResult;
|
||||
use ide_ssh::secrets::SecretFile;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Project root → credential key → secret.
|
||||
type Contents = BTreeMap<String, BTreeMap<String, String>>;
|
||||
|
||||
/// Serialises read-modify-write across the projects sharing the file.
|
||||
static LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
pub struct SecretStore {
|
||||
path: PathBuf,
|
||||
file: SecretFile,
|
||||
project: String,
|
||||
}
|
||||
|
||||
@@ -23,61 +18,41 @@ pub fn ssh_key(id: &str) -> String {
|
||||
format!("{id}#ssh")
|
||||
}
|
||||
|
||||
fn file_key(project: &str, key: &str) -> String {
|
||||
format!("{project}|{key}")
|
||||
}
|
||||
|
||||
/// Older versions' file: project root → credential key → secret.
|
||||
fn legacy(value: serde_json::Value) -> Option<BTreeMap<String, String>> {
|
||||
let all: BTreeMap<String, BTreeMap<String, String>> = serde_json::from_value(value).ok()?;
|
||||
Some(
|
||||
all.into_iter()
|
||||
.flat_map(|(project, keys)| keys.into_iter().map(move |(k, s)| (file_key(&project, &k), s)))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
impl SecretStore {
|
||||
pub fn new(path: PathBuf, root: &Path) -> Self {
|
||||
Self { path, project: root.to_string_lossy().into_owned() }
|
||||
let file = SecretFile::with_legacy(path.clone(), legacy);
|
||||
Self { path, file, project: root.to_string_lossy().into_owned() }
|
||||
}
|
||||
|
||||
/// The same file, keyed by `scope` instead (global data sources: the app data directory).
|
||||
pub fn scoped(&self, scope: &Path) -> Self {
|
||||
Self::new(self.path.clone(), scope)
|
||||
}
|
||||
|
||||
pub fn get(&self, key: &str) -> Option<String> {
|
||||
let _g = LOCK.lock();
|
||||
self.read().remove(&self.project)?.remove(key)
|
||||
self.file.get(&file_key(&self.project, key))
|
||||
}
|
||||
|
||||
pub fn set(&self, key: &str, secret: &str) -> IdeResult<()> {
|
||||
let _g = LOCK.lock();
|
||||
let mut all = self.read();
|
||||
all.entry(self.project.clone()).or_default().insert(key.to_owned(), secret.to_owned());
|
||||
self.write(&all)
|
||||
self.file.set(&file_key(&self.project, key), secret)
|
||||
}
|
||||
|
||||
pub fn delete(&self, key: &str) {
|
||||
let _g = LOCK.lock();
|
||||
let mut all = self.read();
|
||||
let Some(project) = all.get_mut(&self.project) else { return };
|
||||
if project.remove(key).is_none() {
|
||||
return;
|
||||
}
|
||||
if project.is_empty() {
|
||||
all.remove(&self.project);
|
||||
}
|
||||
if let Err(e) = self.write(&all) {
|
||||
tracing::warn!("saved passwords: {}", e.message);
|
||||
}
|
||||
}
|
||||
|
||||
fn read(&self) -> Contents {
|
||||
match std::fs::read(&self.path) {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|e| {
|
||||
tracing::warn!("saved passwords: {}: {e}", self.path.display());
|
||||
Contents::new()
|
||||
}),
|
||||
Err(_) => Contents::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn write(&self, all: &Contents) -> IdeResult<()> {
|
||||
let err = |e: std::io::Error| IdeError::unavailable(format!("saved passwords: {e}"));
|
||||
if let Some(dir) = self.path.parent() {
|
||||
std::fs::create_dir_all(dir).map_err(err)?;
|
||||
}
|
||||
let tmp = self.path.with_extension("json.tmp");
|
||||
let mut options = std::fs::OpenOptions::new();
|
||||
options.write(true).create(true).truncate(true);
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
|
||||
let bytes = serde_json::to_vec_pretty(all).map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
std::io::Write::write_all(&mut options.open(&tmp).map_err(err)?, &bytes).map_err(err)?;
|
||||
std::fs::rename(&tmp, &self.path).map_err(err)
|
||||
self.file.delete(&file_key(&self.project, key))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +62,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn keeps_projects_apart_and_drops_empty_ones() {
|
||||
ide_ssh::secrets::testing::use_memory_store();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let file = tmp.path().join("db-passwords.json");
|
||||
let a = SecretStore::new(file.clone(), Path::new("/p/a"));
|
||||
|
||||
+42
-10
@@ -1,6 +1,7 @@
|
||||
//! Data sources: per-project connection settings in `.openidea/data-sources.toml`.
|
||||
//! Passwords never go there: saved ones live in [`crate::secrets`], the others
|
||||
//! only in memory for the session.
|
||||
//! Data sources: per-project connection settings in `.openidea/data-sources.toml`, and
|
||||
//! global ones (IDEA's Make Global: listed in every project) in `data-sources.toml` of the
|
||||
//! app data directory. Passwords never go there: saved ones live in [`crate::secrets`],
|
||||
//! the others only in memory for the session.
|
||||
|
||||
use ide_core::{IdeError, IdeResult, IpcPath, project_dir};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -181,6 +182,9 @@ pub struct DataSource {
|
||||
pub options: DbOptions,
|
||||
pub introspection: DbIntrospection,
|
||||
pub properties: Vec<DbProperty>,
|
||||
/// Stored app-wide and listed in every project. Implied by the file it is read from,
|
||||
/// so not written to it.
|
||||
pub global: bool,
|
||||
}
|
||||
|
||||
impl Default for DataSource {
|
||||
@@ -209,6 +213,7 @@ impl Default for DataSource {
|
||||
options: DbOptions::default(),
|
||||
introspection: DbIntrospection::default(),
|
||||
properties: Vec::new(),
|
||||
global: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -397,9 +402,32 @@ struct SourcesFile {
|
||||
sources: Vec<DataSource>,
|
||||
}
|
||||
|
||||
/// The project's data sources.
|
||||
pub fn load(root: &Path) -> IdeResult<Vec<DataSource>> {
|
||||
let path = project_dir::path(root, FILE);
|
||||
let text = match std::fs::read_to_string(&path) {
|
||||
read(&project_dir::path(root, FILE))
|
||||
}
|
||||
|
||||
/// Saves the project's data sources.
|
||||
pub fn save(root: &Path, sources: &[DataSource]) -> IdeResult<()> {
|
||||
write(&project_dir::path(root, FILE), sources)
|
||||
}
|
||||
|
||||
/// The global data sources stored in `dir` (the app data directory).
|
||||
pub fn load_global(dir: &Path) -> IdeResult<Vec<DataSource>> {
|
||||
let mut sources = read(&dir.join(FILE))?;
|
||||
for s in &mut sources {
|
||||
s.global = true;
|
||||
}
|
||||
Ok(sources)
|
||||
}
|
||||
|
||||
/// Saves the global data sources in `dir`.
|
||||
pub fn save_global(dir: &Path, sources: &[DataSource]) -> IdeResult<()> {
|
||||
write(&dir.join(FILE), sources)
|
||||
}
|
||||
|
||||
fn read(path: &Path) -> IdeResult<Vec<DataSource>> {
|
||||
let text = match std::fs::read_to_string(path) {
|
||||
Ok(t) => t,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
|
||||
Err(e) => return Err(e.into()),
|
||||
@@ -408,19 +436,23 @@ pub fn load(root: &Path) -> IdeResult<Vec<DataSource>> {
|
||||
Ok(file.sources)
|
||||
}
|
||||
|
||||
pub fn save(root: &Path, sources: &[DataSource]) -> IdeResult<()> {
|
||||
fn write(path: &Path, sources: &[DataSource]) -> IdeResult<()> {
|
||||
for s in sources {
|
||||
if s.id.is_empty() || s.name.trim().is_empty() {
|
||||
return Err(IdeError::invalid("every data source needs an id and a name"));
|
||||
}
|
||||
}
|
||||
let path = project_dir::path(root, FILE);
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
let text = toml::to_string_pretty(&SourcesFile { sources: sources.to_vec() })
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
std::fs::write(path, text)?;
|
||||
let internal = |e: &dyn std::fmt::Display| IdeError::internal(e.to_string());
|
||||
let mut value = toml::Value::try_from(SourcesFile { sources: sources.to_vec() }).map_err(|e| internal(&e))?;
|
||||
if let Some(list) = value.get_mut("data-source").and_then(toml::Value::as_array_mut) {
|
||||
for table in list.iter_mut().filter_map(toml::Value::as_table_mut) {
|
||||
table.remove("global");
|
||||
}
|
||||
}
|
||||
std::fs::write(path, toml::to_string_pretty(&value).map_err(|e| internal(&e))?)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -364,7 +364,7 @@ pub fn package_hash(dir: &Path, files: &[PathBuf]) -> std::io::Result<String> {
|
||||
sorted.dedup();
|
||||
let mut hasher = blake3::Hasher::new();
|
||||
for rel in sorted {
|
||||
hasher.update(rel.to_string_lossy().as_bytes());
|
||||
hasher.update(hashed_path(rel).as_bytes());
|
||||
hasher.update(&[0]);
|
||||
hasher.update_reader(std::fs::File::open(dir.join(rel))?)?;
|
||||
hasher.update(&[0]);
|
||||
@@ -372,6 +372,12 @@ pub fn package_hash(dir: &Path, files: &[PathBuf]) -> std::io::Result<String> {
|
||||
Ok(hasher.finalize().to_hex().to_string())
|
||||
}
|
||||
|
||||
/// A package-relative path as hashed: `/`-separated on every OS, so a package has one digest
|
||||
/// (a walked `web\index.html` on Windows is `web/index.html` everywhere else).
|
||||
fn hashed_path(rel: &Path) -> String {
|
||||
rel.components().map(|c| c.as_os_str().to_string_lossy()).collect::<Vec<_>>().join("/")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -480,4 +486,20 @@ mod tests {
|
||||
let groups = by_risk(&caps(&["storage", "process:git", "net:x.io"]));
|
||||
assert_eq!(groups.iter().map(|g| g.0).collect::<Vec<_>>(), [Risk::High, Risk::Medium, Risk::Low]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hashes_paths_with_forward_slashes_on_every_os() {
|
||||
assert_eq!(hashed_path(Path::new("web/index.html")), "web/index.html");
|
||||
assert_eq!(hashed_path(Path::new("plugin.wasm")), "plugin.wasm");
|
||||
#[cfg(windows)]
|
||||
assert_eq!(hashed_path(Path::new(r"web\index.html")), "web/index.html");
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir(tmp.path().join("web")).unwrap();
|
||||
std::fs::write(tmp.path().join("web").join("index.html"), "<p>").unwrap();
|
||||
// A walked path (native separators) hashes like the manifest's `/` spelling.
|
||||
assert_eq!(
|
||||
package_hash(tmp.path(), &[Path::new("web").join("index.html")]).unwrap(),
|
||||
package_hash(tmp.path(), &["web/index.html".into()]).unwrap()
|
||||
);
|
||||
}
|
||||
}
|
||||
+124
-54
@@ -25,7 +25,7 @@ use ide_language::{LanguageRegistry, Syntax};
|
||||
use ide_run::ProcessManager;
|
||||
use ide_terminal::TerminalManager;
|
||||
use ide_workspace::SettingsStore;
|
||||
use parking_lot::RwLock;
|
||||
use parking_lot::{Mutex, RwLock};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
@@ -74,15 +74,23 @@ pub struct Host {
|
||||
pub environment: environment::Environment,
|
||||
/// The HTTP proxy's saved password (see `proxy.rs`).
|
||||
proxy_password: ide_ssh::secrets::SecretFile,
|
||||
/// The proxy password when it could not be saved (no credential store).
|
||||
proxy_session_password: Mutex<Option<String>>,
|
||||
pub(crate) sink_for: SinkFactory,
|
||||
owner: fn(&str) -> &str,
|
||||
pub runtime: tokio::runtime::Handle,
|
||||
/// One project per window, keyed by window label.
|
||||
projects: RwLock<HashMap<String, Arc<Project>>>,
|
||||
pub(crate) external_logs: RwLock<HashMap<String, Vec<Arc<ide_vcs::Repo>>>>,
|
||||
requests: requests::Requests,
|
||||
project_requests: RwLock<HashMap<String, Vec<RequestId>>>,
|
||||
/// Shutdowns of projects closed with [`Host::close_project_detached`].
|
||||
closing: parking_lot::Mutex<Vec<std::thread::JoinHandle<()>>>,
|
||||
}
|
||||
|
||||
/// How long exit waits for project shutdown before giving up.
|
||||
const EXIT_SHUTDOWN_WAIT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
|
||||
impl Host {
|
||||
pub fn new(config: HostConfig) -> Self {
|
||||
let HostConfig {
|
||||
@@ -102,6 +110,7 @@ impl Host {
|
||||
let host = Self {
|
||||
trust: trust::TrustStore::load(&config_dir, &data_dir),
|
||||
proxy_password: ide_ssh::secrets::SecretFile::new(data_dir.join("proxy-password.json")),
|
||||
proxy_session_password: Mutex::default(),
|
||||
remote: Arc::new(ide_remote::RemoteService::new(ssh.clone(), data_dir.join("remote"))),
|
||||
ssh,
|
||||
environment: environment::Environment::new(project_environment, local_data_dir.clone()),
|
||||
@@ -122,8 +131,10 @@ impl Host {
|
||||
owner,
|
||||
runtime,
|
||||
projects: RwLock::default(),
|
||||
external_logs: RwLock::default(),
|
||||
requests: Default::default(),
|
||||
project_requests: RwLock::default(),
|
||||
closing: Default::default(),
|
||||
};
|
||||
host.apply_proxy();
|
||||
host
|
||||
@@ -177,45 +188,69 @@ impl Host {
|
||||
}
|
||||
}
|
||||
|
||||
/// Closes `window`'s project and waits until its language servers, kernels and
|
||||
/// debug sessions have shut down. Never call this on the UI event-loop thread:
|
||||
/// the shutdown runs on the async runtime, whose workers may themselves be
|
||||
/// waiting for the event loop (IPC responses, window queries). Use
|
||||
/// [`Self::close_project_detached`] there.
|
||||
pub fn close_project(&self, window: &str) {
|
||||
let project = self.projects.write().remove(window);
|
||||
if let Some(project) = project {
|
||||
if !project.light {
|
||||
self.environment.project_closed();
|
||||
}
|
||||
if let Some(bridge) = &project.kotlin_bridge {
|
||||
bridge.stop();
|
||||
}
|
||||
project.documents.clear_workspace_edit_history();
|
||||
project.agents.shutdown();
|
||||
project.notebooks.stop_admission();
|
||||
// What the closing window started goes with it — the builds and searches it
|
||||
// asked for, its runs, its shells and its debug sessions — and the language
|
||||
// servers finish shutting down before the window is gone, as IDEA closes a
|
||||
// project's processes.
|
||||
for request in self.project_requests.write().remove(self.owner_window(window)).unwrap_or_default() {
|
||||
self.cancel_request(request);
|
||||
}
|
||||
self.processes.kill_project(&project.root);
|
||||
self.terminals.kill_key(self.owner_window(window));
|
||||
self.shutdown_project_workers_blocking(
|
||||
vec![project.notebooks.clone()],
|
||||
project.kotlin_bridge.iter().cloned().collect(),
|
||||
Some(project.lsp.clone()),
|
||||
Some((self.debugger.clone(), window.to_owned())),
|
||||
);
|
||||
if let Some(shutdown) = self.begin_close(window) {
|
||||
self.wait_blocking(shutdown);
|
||||
}
|
||||
}
|
||||
|
||||
/// Synchronous project/window destruction must finish owned kernel cleanup,
|
||||
/// not enqueue work which can be lost as the desktop runtime exits.
|
||||
fn shutdown_project_workers_blocking(
|
||||
/// Like [`Self::close_project`], but returns once the project is detached from
|
||||
/// the window; its workers finish shutting down in the background, and
|
||||
/// [`Self::shutdown_notebooks_on_exit`] waits for them before the process exits.
|
||||
pub fn close_project_detached(&self, window: &str) {
|
||||
if let Some(shutdown) = self.begin_close(window) {
|
||||
let mut closing = self.closing.lock();
|
||||
closing.retain(|handle| !handle.is_finished());
|
||||
closing.push(shutdown);
|
||||
}
|
||||
}
|
||||
|
||||
/// Detaches the project from `window` and stops what it started, synchronously,
|
||||
/// so the window can open another project at once. Returns the thread that
|
||||
/// finishes shutting down its workers.
|
||||
fn begin_close(&self, window: &str) -> Option<std::thread::JoinHandle<()>> {
|
||||
self.external_logs.write().remove(self.owner_window(window));
|
||||
for request in self.project_requests.write().remove(self.owner_window(window)).unwrap_or_default() {
|
||||
self.cancel_request(request);
|
||||
}
|
||||
let project = self.projects.write().remove(window)?;
|
||||
if !project.light {
|
||||
self.environment.project_closed();
|
||||
}
|
||||
if let Some(bridge) = &project.kotlin_bridge {
|
||||
bridge.stop();
|
||||
}
|
||||
project.documents.clear_workspace_edit_history();
|
||||
project.agents.shutdown();
|
||||
project.notebooks.stop_admission();
|
||||
// What the closing window started goes with it — the builds and searches it
|
||||
// asked for, its runs, its shells and its debug sessions — and its language
|
||||
// servers shut down.
|
||||
self.processes.kill_project(&project.root);
|
||||
self.terminals.kill_key(self.owner_window(window));
|
||||
Some(self.spawn_project_shutdown(
|
||||
vec![project.notebooks.clone()],
|
||||
project.kotlin_bridge.iter().cloned().collect(),
|
||||
Some(project.lsp.clone()),
|
||||
Some((self.debugger.clone(), window.to_owned())),
|
||||
))
|
||||
}
|
||||
|
||||
/// Project/window destruction must finish owned kernel cleanup, not enqueue
|
||||
/// work which can be lost as the desktop runtime exits: the returned thread is
|
||||
/// joined by the closer or, for a detached close, before exit.
|
||||
fn spawn_project_shutdown(
|
||||
&self,
|
||||
notebooks: Vec<Arc<notebook::NotebookService>>,
|
||||
bridges: Vec<Arc<kotlin_bridge::KotlinBridge>>,
|
||||
lsp: Option<Arc<ide_lsp::LspManager>>,
|
||||
debug: Option<(Arc<ide_dap::DebugManager>, String)>,
|
||||
) {
|
||||
) -> std::thread::JoinHandle<()> {
|
||||
for bridge in &bridges {
|
||||
bridge.stop();
|
||||
}
|
||||
@@ -223,25 +258,37 @@ impl Host {
|
||||
service.stop_admission();
|
||||
}
|
||||
let runtime = self.runtime.clone();
|
||||
let wait = move || {
|
||||
let worker = std::thread::spawn(move || {
|
||||
runtime.block_on(async move {
|
||||
for bridge in bridges {
|
||||
bridge.shutdown().await;
|
||||
}
|
||||
for service in notebooks {
|
||||
service.shutdown().await;
|
||||
}
|
||||
if let Some(lsp) = lsp {
|
||||
lsp.shutdown_all().await;
|
||||
}
|
||||
if let Some((debug, key)) = debug {
|
||||
debug.stop_key(&key).await;
|
||||
}
|
||||
});
|
||||
std::thread::spawn(move || {
|
||||
runtime.block_on(async move {
|
||||
for bridge in bridges {
|
||||
bridge.shutdown().await;
|
||||
}
|
||||
for service in notebooks {
|
||||
service.shutdown().await;
|
||||
}
|
||||
if let Some(lsp) = lsp {
|
||||
lsp.shutdown_all().await;
|
||||
}
|
||||
if let Some((debug, key)) = debug {
|
||||
debug.stop_key(&key).await;
|
||||
}
|
||||
});
|
||||
if let Err(error) = worker.join() {
|
||||
tracing::error!("notebook shutdown thread failed: {error:?}");
|
||||
})
|
||||
}
|
||||
|
||||
/// Waits for detached closes, so a project opened next never races the
|
||||
/// language servers of one still shutting down (e.g. over a shared workspace).
|
||||
pub(crate) fn wait_detached_closes(&self) {
|
||||
let pending: Vec<_> = self.closing.lock().drain(..).collect();
|
||||
for shutdown in pending {
|
||||
self.wait_blocking(shutdown);
|
||||
}
|
||||
}
|
||||
|
||||
fn wait_blocking(&self, shutdown: std::thread::JoinHandle<()>) {
|
||||
let wait = move || {
|
||||
if let Err(error) = shutdown.join() {
|
||||
tracing::error!("project shutdown thread failed: {error:?}");
|
||||
}
|
||||
};
|
||||
if tokio::runtime::Handle::try_current()
|
||||
@@ -253,15 +300,32 @@ impl Host {
|
||||
}
|
||||
}
|
||||
|
||||
/// Called before the desktop runtime exits, including update/restart exits.
|
||||
/// Called before the desktop runtime exits, including update/restart exits, on
|
||||
/// the event-loop thread: waits for detached closes and the open projects'
|
||||
/// kernels, but no longer than [`EXIT_SHUTDOWN_WAIT`], so a runtime worker
|
||||
/// stuck on the event loop cannot keep the process from exiting.
|
||||
pub fn shutdown_notebooks_on_exit(&self) {
|
||||
let projects = self.projects();
|
||||
self.shutdown_project_workers_blocking(
|
||||
let mut pending: Vec<_> = self.closing.lock().drain(..).collect();
|
||||
pending.push(self.spawn_project_shutdown(
|
||||
projects.iter().map(|project| project.notebooks.clone()).collect(),
|
||||
projects.iter().filter_map(|project| project.kotlin_bridge.clone()).collect(),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
));
|
||||
let deadline = std::time::Instant::now() + EXIT_SHUTDOWN_WAIT;
|
||||
while pending.iter().any(|handle| !handle.is_finished()) {
|
||||
if std::time::Instant::now() >= deadline {
|
||||
tracing::warn!("project shutdown did not finish within {EXIT_SHUTDOWN_WAIT:?}; exiting anyway");
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
}
|
||||
for handle in pending {
|
||||
if let Err(error) = handle.join() {
|
||||
tracing::error!("project shutdown thread failed: {error:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Called before desktop exit, which may bypass document destructors.
|
||||
@@ -311,14 +375,20 @@ impl Host {
|
||||
}
|
||||
|
||||
pub fn begin_project_request(&self, window: &str, id: RequestId) -> CancellationToken {
|
||||
let token = self.begin_request(id);
|
||||
self.project_requests.write().entry(self.owner_window(window).to_owned()).or_default().push(id);
|
||||
let token = self.begin_project_read_request(window, id);
|
||||
if self.project(window).is_ok_and(|p| !p.is_trusted()) {
|
||||
token.cancel();
|
||||
}
|
||||
token
|
||||
}
|
||||
|
||||
/// Read-only work remains cancellable/disposed with its window in restricted projects.
|
||||
pub fn begin_project_read_request(&self, window: &str, id: RequestId) -> CancellationToken {
|
||||
let token = self.begin_request(id);
|
||||
self.project_requests.write().entry(self.owner_window(window).to_owned()).or_default().push(id);
|
||||
token
|
||||
}
|
||||
|
||||
pub fn end_request(&self, id: RequestId) {
|
||||
for ids in self.project_requests.write().values_mut() {
|
||||
ids.retain(|entry| *entry != id);
|
||||
|
||||
@@ -18,7 +18,7 @@ pub async fn blocking<T: Send + 'static>(f: impl FnOnce() -> IdeResult<T> + Send
|
||||
/// editor buffer, so syntax features and language-server requests work in it.
|
||||
pub async fn doc_open_virtual(host: &Host, window: &str, uri: String) -> IdeResult<DocumentInfo> {
|
||||
let project = host.project(window)?;
|
||||
let vd = project.lsp.virtual_document(&uri).await?;
|
||||
let vd = crate::ops::lsp::lsp_virtual_document(host, window, uri).await?;
|
||||
blocking(move || project.documents.open_virtual(&vd.uri, vd.language_id, &vd.text)).await
|
||||
}
|
||||
|
||||
|
||||
@@ -137,16 +137,23 @@ pub fn set_writable(host: &Host, window: &str, path: PathBuf, writable: bool) ->
|
||||
pub async fn delete(host: &Host, window: &str, path: PathBuf) -> IdeResult<()> {
|
||||
let history = host.project(window).ok().map(|p| p.history.clone());
|
||||
host.blocking(move || {
|
||||
let paths = ide_workspace::file_ops::deletion_paths(&path)?;
|
||||
if let Some(h) = history {
|
||||
h.record_deletion(&path, DELETE_SNAPSHOT_LIMIT);
|
||||
for p in paths {
|
||||
h.record_deletion(&p, DELETE_SNAPSHOT_LIMIT);
|
||||
}
|
||||
}
|
||||
ide_vfs::delete(&path)
|
||||
ide_workspace::file_ops::delete(&path)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn copy(host: &Host, from: PathBuf, to: PathBuf) -> IdeResult<()> {
|
||||
host.blocking(move || ide_vfs::copy(&from, &to)).await
|
||||
host.blocking(move || ide_workspace::file_ops::copy(&from, &to)).await
|
||||
}
|
||||
|
||||
pub async fn rename(host: &Host, from: PathBuf, to: PathBuf) -> IdeResult<()> {
|
||||
host.blocking(move || ide_workspace::file_ops::rename(&from, &to)).await
|
||||
}
|
||||
|
||||
/// Reads a text file without opening it as a document; prefers the unsaved buffer.
|
||||
|
||||
@@ -260,6 +260,9 @@ pub async fn lsp_implementations(
|
||||
/// Contents of a read-only language-server document, including JDT library
|
||||
/// classes and disk class files. Cached per project.
|
||||
pub async fn lsp_virtual_document(host: &Host, window: &str, uri: String) -> IdeResult<VirtualDocument> {
|
||||
if uri.starts_with("git-revision:") {
|
||||
return crate::ops::vcs::revision_virtual_document(host, window, &uri).await;
|
||||
}
|
||||
host.project(window)?.lsp.virtual_document(&uri).await
|
||||
}
|
||||
|
||||
@@ -438,12 +441,30 @@ pub async fn lsp_apply_code_action(host: &Host, window: &str, doc: DocId, action
|
||||
let edit = edit.clone();
|
||||
blocking(move || apply_edit(&documents, &edit)).await?;
|
||||
}
|
||||
if let Some(command) = action.command {
|
||||
if let Some(command) = action.command
|
||||
&& !(action.edit.is_some() && is_client_follow_up(&project, doc, None, &command).await)
|
||||
{
|
||||
project.lsp.execute_command(doc, command).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A command after a code action's edit that no server provides: what other editors'
|
||||
/// extensions do once the edit is applied (vtsls' `_typescript.didOrganizeImports`). The edit
|
||||
/// is the action; there is nothing to run.
|
||||
async fn is_client_follow_up(
|
||||
project: &crate::project::Project,
|
||||
doc: DocId,
|
||||
server: Option<ide_core::ServerId>,
|
||||
command: &Command,
|
||||
) -> bool {
|
||||
let provided = project.lsp.provides_command(doc, server, &command.command).await;
|
||||
if !provided {
|
||||
tracing::debug!("skipping `{}` after the code action's edit: no server provides it", command.command);
|
||||
}
|
||||
!provided
|
||||
}
|
||||
|
||||
pub async fn lsp_prepare_rename(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeResult<Option<Range>> {
|
||||
host.project(window)?.lsp.prepare_rename(doc, pos).await
|
||||
}
|
||||
@@ -676,6 +697,7 @@ pub async fn lsp_execute_code_action(
|
||||
if cancel.is_cancelled() {
|
||||
return Err(ide_core::IdeError::cancelled());
|
||||
}
|
||||
let edited = action.edit.is_some();
|
||||
if let Some(edit) = action.edit {
|
||||
let documents = project.documents.clone();
|
||||
tokio::task::spawn_blocking(move || apply_edit(&documents, &edit))
|
||||
@@ -683,6 +705,9 @@ pub async fn lsp_execute_code_action(
|
||||
.map_err(|e| ide_core::IdeError::internal(e.to_string()))??;
|
||||
}
|
||||
match action.command {
|
||||
Some(command) if edited && is_client_follow_up(&project, doc, Some(action.server), &command).await => {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
Some(command) => project
|
||||
.lsp
|
||||
.execute_action_command(doc, action.server, command, answers, cancel)
|
||||
|
||||
@@ -3,7 +3,7 @@ use crate::Host;
|
||||
use crate::Project;
|
||||
use crate::rpc::Dispatcher;
|
||||
use ide_core::{DocId, IdeResult, IpcPath, Location, Pos};
|
||||
use ide_index::{FileMatch, IndexStatus, SymbolFilter, SymbolMatch};
|
||||
use ide_index::{ExtensionCount, FileMatch, IndexStatus, SymbolFilter, SymbolMatch};
|
||||
use ide_language::{FileReference, WebReference};
|
||||
use ide_workspace::file_refs::{self, PathCompletion};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -32,6 +32,11 @@ pub fn index_status(host: &Host, window: &str) -> IdeResult<IndexStatus> {
|
||||
Ok(host.project(window)?.index.status())
|
||||
}
|
||||
|
||||
/// The project's file extensions, most common first.
|
||||
pub fn index_file_extensions(host: &Host, window: &str) -> IdeResult<Vec<ExtensionCount>> {
|
||||
Ok(host.project(window)?.index.file_extensions())
|
||||
}
|
||||
|
||||
pub fn find_files(host: &Host, window: &str, query: String, limit: u32) -> IdeResult<Vec<FileMatch>> {
|
||||
Ok(host.project(window)?.index.find_files(&query, limit as usize))
|
||||
}
|
||||
@@ -160,6 +165,7 @@ pub async fn web_unresolved_references(host: &Host, window: &str, doc: DocId) ->
|
||||
/// Commands of this module the remote server answers.
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("index_status", |c| async move { index_status(&c.host, &c.window) });
|
||||
d.add("index_file_extensions", |c| async move { index_file_extensions(&c.host, &c.window) });
|
||||
d.add("find_files", |c| async move { find_files(&c.host, &c.window, c.arg("query")?, c.arg("limit")?) });
|
||||
d.add("find_dirs", |c| async move { find_dirs(&c.host, &c.window, c.arg("query")?, c.arg("limit")?) });
|
||||
d.add("find_symbols", |c| async move {
|
||||
|
||||
@@ -17,6 +17,8 @@ pub struct ProjectInfo {
|
||||
pub settings: Settings,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub workspace: Option<ide_workspace::workspace::Workspace>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub unity: Option<ide_workspace::unity::UnityProjectInfo>,
|
||||
}
|
||||
|
||||
pub fn info(project: &Project) -> ProjectInfo {
|
||||
@@ -33,6 +35,7 @@ pub fn info(project: &Project) -> ProjectInfo {
|
||||
settings,
|
||||
root: project.root.clone().into(),
|
||||
workspace: ide_workspace::workspace::Workspace::load(&project.root).ok().flatten(),
|
||||
unity: ide_workspace::unity::detect(&project.root),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,7 +175,12 @@ pub fn set_settings(
|
||||
host.apply_proxy();
|
||||
}
|
||||
// A new interpreter / toolchain / server executable restarts the language servers it affects.
|
||||
let code_style = key == "editor" || key == "editor.codeStyle" || key.starts_with("editor.codeStyle.");
|
||||
// So do inspections that are compiler options (`ide-sdk` `configure_java_compiler`).
|
||||
let code_style = key == "editor"
|
||||
|| key == "editor.codeStyle"
|
||||
|| key.starts_with("editor.codeStyle.")
|
||||
|| key == "inspections"
|
||||
|| key.starts_with("inspections.");
|
||||
let toolchain = ["languages", "tools", "languageServers", "projectStructure"]
|
||||
.iter()
|
||||
.any(|k| key == *k || key.starts_with(&format!("{k}.")));
|
||||
|
||||
@@ -142,6 +142,21 @@ pub async fn run_gradle_jvm_args_script(_host: &Host, _window: &str, jvm_args: V
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
}
|
||||
|
||||
/// Writes one of the IDE's Gradle init scripts (`text`; the file is named `<name>-<hash>`) on
|
||||
/// the machine that runs the build, for `--init-script`, and returns its path.
|
||||
pub async fn run_gradle_init_script(_host: &Host, _window: &str, name: String, text: String) -> IdeResult<String> {
|
||||
if name.is_empty() || !name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-') {
|
||||
return Err(IdeError::invalid(format!("invalid init script name {name:?}")));
|
||||
}
|
||||
tokio::task::spawn_blocking(move || {
|
||||
ide_run::gradle_jvm::write_script(&ide_run::gradle_jvm::default_dir(), &name, &text)
|
||||
.map(|p| p.to_string_lossy().into_owned())
|
||||
.map_err(|e| IdeError::internal(format!("cannot write the Gradle init script: {e}")))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
}
|
||||
|
||||
/// Results from the JUnit XML reports (Surefire, Failsafe, Gradle) of the module at
|
||||
/// `module` (relative to the project root) written since `since_ms` (Unix epoch, ms).
|
||||
pub async fn run_test_reports(
|
||||
@@ -308,6 +323,9 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("run_gradle_jvm_args_script", |c| async move {
|
||||
run_gradle_jvm_args_script(&c.host, &c.window, c.arg("jvmArgs")?).await
|
||||
});
|
||||
d.add("run_gradle_init_script", |c| async move {
|
||||
run_gradle_init_script(&c.host, &c.window, c.arg("name")?, c.arg("text")?).await
|
||||
});
|
||||
d.add("run_test_reports", |c| async move {
|
||||
run_test_reports(&c.host, &c.window, c.arg("module")?, c.arg("sinceMs")?).await
|
||||
});
|
||||
|
||||
@@ -34,6 +34,42 @@ pub fn sdk_python_effective(host: &Host, window: &str) -> IdeResult<Option<ide_c
|
||||
Ok(ide_sdk::apply::python_interpreter(&project.root, &project.settings.effective()).map(Into::into))
|
||||
}
|
||||
|
||||
/// The PHP language level of the project `path` belongs to (a workspace member, by path, as
|
||||
/// the language server configuration picks it).
|
||||
#[derive(Clone, Debug, serde::Serialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PhpLanguageLevels {
|
||||
/// The one Intelephense checks against; `None`: its default.
|
||||
pub effective: Option<ide_sdk::php::PhpLanguageLevel>,
|
||||
/// The one Auto picks, from `composer.json`.
|
||||
pub auto: Option<ide_sdk::php::PhpLanguageLevel>,
|
||||
/// The workspace member `path` belongs to: its own settings choose the level, not the
|
||||
/// workspace's. `None`: the opened project.
|
||||
pub member: Option<ide_core::IpcPath>,
|
||||
}
|
||||
|
||||
pub async fn sdk_php_language_level(host: &Host, window: &str, path: PathBuf) -> IdeResult<PhpLanguageLevels> {
|
||||
let project = host.project(window)?;
|
||||
let (project_root, project_settings) = (project.root.clone(), project.settings.effective());
|
||||
let config_dir = host.config_dir.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let member = ide_workspace::workspace::Workspace::load(&project_root).ok().flatten().and_then(|w| {
|
||||
w.projects.into_iter().filter(|p| path.starts_with(p)).max_by_key(|p| p.components().count())
|
||||
});
|
||||
let (root, settings) = match &member {
|
||||
Some(member) => (member, ide_workspace::SettingsStore::load(&config_dir, Some(member)).effective()),
|
||||
None => (&project_root, project_settings),
|
||||
};
|
||||
PhpLanguageLevels {
|
||||
effective: ide_sdk::php::language_level(root, &settings),
|
||||
auto: ide_sdk::php::composer_level(root),
|
||||
member: member.map(ide_core::IpcPath),
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(join_err)
|
||||
}
|
||||
|
||||
/// SDK / library source folders shown under "External Libraries" in the Project view,
|
||||
/// with the Maven / Gradle dependency jars of the build model.
|
||||
pub async fn sdk_project_libraries(host: &Host, window: &str) -> IdeResult<Vec<ide_sdk::libraries::LibraryGroup>> {
|
||||
@@ -144,6 +180,10 @@ pub async fn sdk_python_pip(
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("sdk_detect", |c| async move { sdk_detect(&c.host, &c.window, c.arg("kind")?).await });
|
||||
d.add("sdk_python_effective", |c| async move { sdk_python_effective(&c.host, &c.window) });
|
||||
d.add(
|
||||
"sdk_php_language_level",
|
||||
|c| async move { sdk_php_language_level(&c.host, &c.window, c.arg("path")?).await },
|
||||
);
|
||||
d.add("sdk_project_libraries", |c| async move { sdk_project_libraries(&c.host, &c.window).await });
|
||||
d.add("sdk_locate_tools", |c| async move {
|
||||
sdk_locate_tools(&c.host, &c.window, c.arg("names")?, c.arg("near")?).await
|
||||
|
||||
@@ -12,6 +12,76 @@ use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::apply_git_executable;
|
||||
|
||||
/// Read-only repository access also works in a standalone log window.
|
||||
struct LogAccess {
|
||||
project: Option<std::sync::Arc<Project>>,
|
||||
external: Option<std::sync::Arc<Repo>>,
|
||||
cache: PathBuf,
|
||||
}
|
||||
|
||||
/// Immutable Git objects exposed through the editor's ordinary read-only VFS.
|
||||
pub async fn revision_virtual_document(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
uri: &str,
|
||||
) -> IdeResult<ide_lsp::types::VirtualDocument> {
|
||||
#[derive(Deserialize)]
|
||||
struct Target {
|
||||
repo: PathBuf,
|
||||
revision: String,
|
||||
path: PathBuf,
|
||||
}
|
||||
let encoded = uri
|
||||
.strip_prefix("git-revision:")
|
||||
.and_then(|s| s.split('/').next())
|
||||
.ok_or_else(|| ide_core::IdeError::invalid("invalid Git revision URI"))?;
|
||||
if encoded.len() > 32768 || encoded.len() % 2 != 0 || !encoded.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
return Err(ide_core::IdeError::invalid("invalid Git revision URI"));
|
||||
}
|
||||
let bytes = (0..encoded.len())
|
||||
.step_by(2)
|
||||
.map(|i| {
|
||||
u8::from_str_radix(&encoded[i..i + 2], 16)
|
||||
.map_err(|_| ide_core::IdeError::invalid("invalid Git revision URI"))
|
||||
})
|
||||
.collect::<IdeResult<Vec<_>>>()?;
|
||||
let target: Target = serde_json::from_slice(&bytes).map_err(|e| ide_core::IdeError::invalid(e.to_string()))?;
|
||||
let path = target.repo.join(&target.path);
|
||||
let text = vcs_file_at(host, window, path, Some(target.revision.clone()))
|
||||
.await?
|
||||
.ok_or_else(|| ide_core::IdeError::not_found("file is absent at this revision"))?;
|
||||
Ok(ide_lsp::types::VirtualDocument {
|
||||
uri: uri.into(),
|
||||
language_id: host.languages.detect(&target.path, text.lines().next()),
|
||||
text,
|
||||
title: target.path.file_name().unwrap_or_default().to_string_lossy().into_owned(),
|
||||
description: Some(format!("{} · {}", target.repo.display(), target.revision)),
|
||||
decompiled: false,
|
||||
})
|
||||
}
|
||||
impl LogAccess {
|
||||
fn new(host: &Host, window: &str, at: Option<&Path>) -> Self {
|
||||
let project = host.project(window).ok();
|
||||
let external = host
|
||||
.external_logs
|
||||
.read()
|
||||
.get(host.owner_window(window))
|
||||
.and_then(|rs| rs.iter().find(|r| Some(r.root()) == at).cloned())
|
||||
.filter(|repo| project.as_ref().is_none_or(|p| p.repos().at_root(repo.root()).is_none()));
|
||||
Self { project, external, cache: host.local_data_dir.join("cache/git-log") }
|
||||
}
|
||||
fn with_log_repo_at<T>(&self, at: Option<&Path>, f: impl FnOnce(&Repo) -> IdeResult<T>) -> IdeResult<T> {
|
||||
if let Some(repo) = &self.external {
|
||||
repo.enable_log_index(&self.cache);
|
||||
return f(repo);
|
||||
}
|
||||
self.project
|
||||
.as_ref()
|
||||
.ok_or_else(|| ide_core::IdeError::unavailable("external Git log is not open"))?
|
||||
.with_log_repo_at(at, f)
|
||||
}
|
||||
}
|
||||
|
||||
/// A repository of the project (IDEA's VCS root).
|
||||
#[derive(Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -182,8 +252,8 @@ pub fn vcs_roots(host: &Host, window: &str) -> IdeResult<Vec<VcsRoot>> {
|
||||
}
|
||||
|
||||
pub async fn vcs_branch_info(host: &Host, window: &str, repo: Option<PathBuf>) -> IdeResult<BranchInfo> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.branch_info())).await
|
||||
let project = LogAccess::new(host, window, repo.as_deref());
|
||||
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.branch_info())).await
|
||||
}
|
||||
|
||||
/// Gutter change markers for a file, computed against its current (unsaved) text.
|
||||
@@ -197,9 +267,21 @@ pub async fn vcs_line_hunks(host: &Host, window: &str, path: PathBuf) -> IdeResu
|
||||
}
|
||||
|
||||
pub async fn vcs_file_at(host: &Host, window: &str, path: PathBuf, rev: Option<String>) -> IdeResult<Option<String>> {
|
||||
let project = host.project(window)?;
|
||||
let project = host.project(window).ok();
|
||||
let external = host.external_logs.read().get(host.owner_window(window)).and_then(|rs| {
|
||||
rs.iter().filter(|r| path.starts_with(r.root())).max_by_key(|r| r.root().components().count()).cloned()
|
||||
});
|
||||
blocking(move || {
|
||||
project.with_repo_of(&path, |r| r.file_at(&path, rev.as_deref(), project.vcs_encoding(&path, rev.as_deref())?))
|
||||
if let Some(project) = project {
|
||||
return project.with_log_repo_of(&path, |r| {
|
||||
r.file_at(&path, rev.as_deref(), project.vcs_encoding(&path, rev.as_deref())?)
|
||||
});
|
||||
}
|
||||
external.ok_or_else(|| ide_core::IdeError::unavailable("external Git log is not open"))?.file_at(
|
||||
&path,
|
||||
rev.as_deref(),
|
||||
encoding_rs::UTF_8,
|
||||
)
|
||||
})
|
||||
.await
|
||||
}
|
||||
@@ -246,8 +328,124 @@ pub async fn vcs_block_history(
|
||||
}
|
||||
|
||||
pub async fn vcs_log(host: &Host, window: &str, repo: Option<PathBuf>, query: LogQuery) -> IdeResult<Vec<Commit>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.log(&query))).await
|
||||
let project = LogAccess::new(host, window, repo.as_deref());
|
||||
let request = query.request;
|
||||
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);
|
||||
r.log_cancellable(&query, &cancel)
|
||||
})
|
||||
})
|
||||
.await;
|
||||
if let Some(id) = request {
|
||||
host.end_request(id);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub async fn vcs_log_refs(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
repo: Option<PathBuf>,
|
||||
) -> IdeResult<std::collections::BTreeMap<String, String>> {
|
||||
let project = LogAccess::new(host, window, repo.as_deref());
|
||||
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.log_refs())).await
|
||||
}
|
||||
|
||||
pub async fn vcs_log_users(host: &Host, window: &str, repo: Option<PathBuf>) -> IdeResult<Vec<String>> {
|
||||
let project = LogAccess::new(host, window, repo.as_deref());
|
||||
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.log_users())).await
|
||||
}
|
||||
|
||||
pub async fn vcs_open_external_log(host: &Host, window: &str, path: PathBuf) -> IdeResult<ide_core::IpcPath> {
|
||||
let path = dunce::canonicalize(path)?;
|
||||
let repo = Repo::discover(&path)?.ok_or_else(|| ide_core::IdeError::unavailable("folder is not a Git worktree"))?;
|
||||
let root = repo.root().to_path_buf();
|
||||
repo.set_execution_allowed(false);
|
||||
repo.enable_log_index(&host.local_data_dir.join("cache/git-log"));
|
||||
if let Ok(project) = host.project(window) {
|
||||
project.open_external_log(&root)?;
|
||||
}
|
||||
let mut logs = host.external_logs.write();
|
||||
let roots = logs.entry(host.owner_window(window).to_string()).or_default();
|
||||
if !roots.iter().any(|r| r.root() == root) {
|
||||
roots.push(std::sync::Arc::new(repo));
|
||||
}
|
||||
Ok(root.into())
|
||||
}
|
||||
|
||||
pub async fn vcs_log_details(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
repo: Option<PathBuf>,
|
||||
revision: String,
|
||||
) -> IdeResult<LogDetails> {
|
||||
let project = LogAccess::new(host, window, repo.as_deref());
|
||||
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.log_details(&revision))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_log_details_batch(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
repo: Option<PathBuf>,
|
||||
revisions: Vec<String>,
|
||||
request: RequestId,
|
||||
) -> IdeResult<Vec<LogDetails>> {
|
||||
let project = LogAccess::new(host, window, repo.as_deref());
|
||||
let cancel = host.begin_project_read_request(window, request);
|
||||
let result = blocking(move || {
|
||||
project.with_log_repo_at(repo.as_deref(), |r| {
|
||||
revisions.iter().map(|id| r.log_details_cancellable(id, &cancel)).collect()
|
||||
})
|
||||
})
|
||||
.await;
|
||||
host.end_request(request);
|
||||
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,
|
||||
repo: Option<PathBuf>,
|
||||
revision: String,
|
||||
directory: String,
|
||||
) -> IdeResult<Vec<String>> {
|
||||
let project = LogAccess::new(host, window, repo.as_deref());
|
||||
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.revision_children(&revision, &directory))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_revision_files(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
repo: Option<PathBuf>,
|
||||
revision: String,
|
||||
) -> IdeResult<Vec<String>> {
|
||||
let project = LogAccess::new(host, window, repo.as_deref());
|
||||
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.revision_files(&revision))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_commit_files(
|
||||
@@ -256,8 +454,8 @@ pub async fn vcs_commit_files(
|
||||
repo: Option<PathBuf>,
|
||||
commit: String,
|
||||
) -> IdeResult<Vec<ChangedFile>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.commit_files(&commit))).await
|
||||
let project = LogAccess::new(host, window, repo.as_deref());
|
||||
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.commit_files(&commit))).await
|
||||
}
|
||||
|
||||
/// Combined changes of several commits, `commits` oldest first.
|
||||
@@ -267,13 +465,13 @@ pub async fn vcs_commits_changes(
|
||||
repo: Option<PathBuf>,
|
||||
commits: Vec<String>,
|
||||
) -> IdeResult<Vec<RevisionChange>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.commits_changes(&commits))).await
|
||||
let project = LogAccess::new(host, window, repo.as_deref());
|
||||
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.commits_changes(&commits))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_branches(host: &Host, window: &str, repo: Option<PathBuf>) -> IdeResult<Vec<Branch>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.branches())).await
|
||||
let project = LogAccess::new(host, window, repo.as_deref());
|
||||
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.branches())).await
|
||||
}
|
||||
|
||||
pub async fn vcs_stashes(host: &Host, window: &str, repo: Option<PathBuf>) -> IdeResult<Vec<Stash>> {
|
||||
@@ -628,8 +826,8 @@ pub async fn vcs_config_get(
|
||||
repo: Option<PathBuf>,
|
||||
key: String,
|
||||
) -> IdeResult<Option<String>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.config_get(&key))).await
|
||||
let project = LogAccess::new(host, window, repo.as_deref());
|
||||
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.config_get(&key))).await
|
||||
}
|
||||
|
||||
/// Local history operation that may take a while (hooks, large checkouts); git's
|
||||
@@ -690,8 +888,8 @@ pub async fn vcs_create_tag(
|
||||
}
|
||||
|
||||
pub async fn vcs_tags(host: &Host, window: &str, repo: Option<PathBuf>) -> IdeResult<Vec<String>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.tags())).await
|
||||
let project = LogAccess::new(host, window, repo.as_deref());
|
||||
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.tags())).await
|
||||
}
|
||||
|
||||
/// Merge dialog: merges `branches` into the current branch.
|
||||
@@ -727,8 +925,8 @@ pub async fn vcs_rebase_with(
|
||||
}
|
||||
|
||||
pub async fn vcs_remotes(host: &Host, window: &str, repo: Option<PathBuf>) -> IdeResult<Vec<Remote>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.remotes())).await
|
||||
let project = LogAccess::new(host, window, repo.as_deref());
|
||||
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.remotes())).await
|
||||
}
|
||||
|
||||
pub async fn vcs_edit_remote(host: &Host, window: &str, repo: Option<PathBuf>, edit: RemoteEdit) -> IdeResult<()> {
|
||||
@@ -815,8 +1013,8 @@ pub async fn vcs_diff_with_worktree(
|
||||
repo: Option<PathBuf>,
|
||||
rev: String,
|
||||
) -> IdeResult<Vec<ChangedFile>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.diff_with_worktree(&rev))).await
|
||||
let project = LogAccess::new(host, window, repo.as_deref());
|
||||
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.diff_with_worktree(&rev))).await
|
||||
}
|
||||
|
||||
/// Files that differ between two revisions (the "N files updated" of Update Project).
|
||||
@@ -827,8 +1025,8 @@ pub async fn vcs_diff_revisions(
|
||||
from: String,
|
||||
to: String,
|
||||
) -> IdeResult<Vec<ChangedFile>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.diff_revisions(&from, &to))).await
|
||||
let project = LogAccess::new(host, window, repo.as_deref());
|
||||
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.diff_revisions(&from, &to))).await
|
||||
}
|
||||
|
||||
/// The best common ancestor of `a` and `b` (`git merge-base`), if any: the New Pull
|
||||
@@ -1015,6 +1213,25 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
vcs_block_history(&c.host, &c.window, request, path, text, lines).await
|
||||
});
|
||||
d.add("vcs_log", |c| async move { vcs_log(&c.host, &c.window, c.arg("repo")?, c.arg("query")?).await });
|
||||
d.add("vcs_log_refs", |c| async move { vcs_log_refs(&c.host, &c.window, c.arg("repo")?).await });
|
||||
d.add("vcs_log_users", |c| async move { vcs_log_users(&c.host, &c.window, c.arg("repo")?).await });
|
||||
d.add("vcs_open_external_log", |c| async move { vcs_open_external_log(&c.host, &c.window, c.arg("path")?).await });
|
||||
d.add("vcs_log_details", |c| async move {
|
||||
vcs_log_details(&c.host, &c.window, c.arg("repo")?, c.arg("revision")?).await
|
||||
});
|
||||
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
|
||||
});
|
||||
d.add("vcs_revision_files", |c| async move {
|
||||
vcs_revision_files(&c.host, &c.window, c.arg("repo")?, c.arg("revision")?).await
|
||||
});
|
||||
d.add("vcs_commit_files", |c| async move {
|
||||
vcs_commit_files(&c.host, &c.window, c.arg("repo")?, c.arg("commit")?).await
|
||||
});
|
||||
|
||||
@@ -667,6 +667,17 @@ impl HostApi for PluginApi {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn open_location(&self, client: Option<&str>, path: &Path, line: u32, column: u32) -> Result<(), HostError> {
|
||||
let event = PluginEvent::OpenLocation {
|
||||
plugin_id: self.id().to_owned(),
|
||||
path: path.to_path_buf().into(),
|
||||
line,
|
||||
column,
|
||||
};
|
||||
self.shared.sink.emit(client, event);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn setting(&self, root: Option<&Path>, key: &str) -> Option<String> {
|
||||
let schema: Vec<super::ui_points::SettingSchema> = self
|
||||
.shared
|
||||
|
||||
@@ -165,6 +165,16 @@ impl PluginHost {
|
||||
self.save_state();
|
||||
}
|
||||
|
||||
/// The marketplace's plugins: read again when `refresh`, else the last index
|
||||
/// read. Reads it when none has been read yet (first run, nothing cached).
|
||||
pub fn market_listing(&self, refresh: bool) -> IdeResult<Vec<MarketPlugin>> {
|
||||
if refresh || (self.market.read().is_none() && self.market_url().is_some()) {
|
||||
self.refresh_market()
|
||||
} else {
|
||||
Ok(self.market_plugins())
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the index again, keeps it, and applies its revocations. Announces
|
||||
/// updates of installed plugins it hasn't announced yet.
|
||||
pub fn refresh_market(&self) -> IdeResult<Vec<MarketPlugin>> {
|
||||
|
||||
@@ -114,6 +114,7 @@ pub fn forwarded(event: &PluginEvent) -> bool {
|
||||
| PluginEvent::ViewChanged { .. }
|
||||
| PluginEvent::ViewMessage { .. }
|
||||
| PluginEvent::OpenTerminal { .. }
|
||||
| PluginEvent::OpenLocation { .. }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -192,6 +192,8 @@ pub enum PluginEvent {
|
||||
args: Vec<String>,
|
||||
cwd: ide_core::IpcPath,
|
||||
},
|
||||
#[serde(rename_all = "camelCase")]
|
||||
OpenLocation { plugin_id: String, path: ide_core::IpcPath, line: u32, column: u32 },
|
||||
/// Plugin languages, or the servers they may start, changed: re-fetch
|
||||
/// `languages` (the host re-detects open documents: [`crate::Host::plugin_languages_changed`]).
|
||||
LanguagesChanged,
|
||||
|
||||
@@ -378,9 +378,19 @@ pub(super) fn view(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
|
||||
}
|
||||
let mut out = UiDecl::new(d.id.clone(), d.title.clone(), json(&d)?);
|
||||
out.files = files;
|
||||
if let Some(icon) = d.icon.as_deref().filter(|i| i.ends_with(".svg")) {
|
||||
let path = std::path::PathBuf::from(icon);
|
||||
let svg = super::icons::read_icon(pkg, &path)?;
|
||||
out.assets.push((view_icon_asset(&d.id), svg));
|
||||
out.files.push(path);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn view_icon_asset(id: &str) -> String {
|
||||
format!("viewIcon/{id}")
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct CustomEditorDecl {
|
||||
|
||||
@@ -34,6 +34,8 @@ pub struct Project {
|
||||
pub lsp: Arc<LspManager>,
|
||||
/// Git repositories: the one containing the project root and those nested below it.
|
||||
pub repos: Arc<RwLock<Repos>>,
|
||||
/// Explicitly opened external logs remain read-only and outside project indexing.
|
||||
pub(crate) external_log_repos: RwLock<Vec<Arc<Repo>>>,
|
||||
/// Incrementally maintained syntax trees of the open documents.
|
||||
pub syntax: Arc<SyntaxCache>,
|
||||
/// Tree-sitter structure queries (LSP fallback) over `syntax`.
|
||||
@@ -86,6 +88,7 @@ impl Host {
|
||||
return Ok(current);
|
||||
}
|
||||
self.close_project(window);
|
||||
self.wait_detached_closes();
|
||||
let sink: SharedSink = (self.sink_for)(window);
|
||||
// Before anything the project starts: its children get the project's environment.
|
||||
let trusted = Arc::new(AtomicBool::new(self.trust.trusted(&root)?));
|
||||
@@ -207,6 +210,7 @@ impl Host {
|
||||
cfg.launch_error = Some(e);
|
||||
None
|
||||
});
|
||||
ide_sdk::apply::configure_java_compiler(cfg, &data_dir, &effective.inspections);
|
||||
ide_sdk::java::configure_jdtls(
|
||||
cfg,
|
||||
server_root,
|
||||
@@ -462,6 +466,7 @@ impl Host {
|
||||
index,
|
||||
lsp,
|
||||
repos,
|
||||
external_log_repos: RwLock::default(),
|
||||
structure: Arc::new(StructureService::new(syntax.clone())),
|
||||
syntax,
|
||||
detected_roots,
|
||||
@@ -516,12 +521,15 @@ impl Host {
|
||||
root: &Path,
|
||||
project_key: &str,
|
||||
) -> (Arc<ide_db::DbManager>, Arc<ide_http::History>, Arc<ide_agent::AgentManager>) {
|
||||
let db = Arc::new(ide_db::DbManager::new(
|
||||
root.to_path_buf(),
|
||||
self.data_dir.join("db-passwords.json"),
|
||||
self.data_dir.join("db-history.json"),
|
||||
self.ssh.clone(),
|
||||
));
|
||||
let db = Arc::new(
|
||||
ide_db::DbManager::new(
|
||||
root.to_path_buf(),
|
||||
self.data_dir.join("db-passwords.json"),
|
||||
self.data_dir.join("db-history.json"),
|
||||
self.ssh.clone(),
|
||||
)
|
||||
.with_global_sources(self.data_dir.clone()),
|
||||
);
|
||||
let http_history = Arc::new(ide_http::History::new(self.data_dir.join("http-history").join(project_key)));
|
||||
let agents = Arc::new(ide_agent::AgentManager::new(
|
||||
ide_agent::AgentPaths {
|
||||
@@ -563,6 +571,7 @@ impl Host {
|
||||
return Ok(current);
|
||||
}
|
||||
self.close_project(window);
|
||||
self.wait_detached_closes();
|
||||
let sink: SharedSink = (self.sink_for)(window);
|
||||
let settings = Arc::new(SettingsStore::load(&self.config_dir, None));
|
||||
let effective = settings.effective();
|
||||
@@ -595,6 +604,7 @@ impl Host {
|
||||
index,
|
||||
lsp,
|
||||
repos: Arc::default(),
|
||||
external_log_repos: RwLock::default(),
|
||||
structure: Arc::new(StructureService::new(syntax.clone())),
|
||||
syntax,
|
||||
detected_roots: Arc::default(),
|
||||
@@ -692,7 +702,14 @@ fn scan_repositories(
|
||||
build_cache: &Path,
|
||||
) {
|
||||
let ignored = settings.effective().ignored_names();
|
||||
let skip = |p: &Path| p.file_name().is_some_and(|n| ignored.is_ignored_name(&n.to_string_lossy()));
|
||||
let skip = |p: &Path| {
|
||||
p.file_name().is_some_and(|n| {
|
||||
let name = n.to_string_lossy();
|
||||
ignored.is_ignored_name(&name)
|
||||
|| (ide_workspace::unity::GENERATED_DIRS.contains(&name.as_ref())
|
||||
&& p.parent().is_some_and(ide_workspace::unity::is_project))
|
||||
})
|
||||
};
|
||||
let mut roots = vec![root.to_path_buf()];
|
||||
roots.extend(content_roots(&settings.effective(), root, build_cache));
|
||||
match Repos::discover_roots(&roots, &skip) {
|
||||
@@ -739,6 +756,51 @@ impl Project {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn open_external_log(&self, path: &Path) -> IdeResult<PathBuf> {
|
||||
let root = dunce::canonicalize(path)?;
|
||||
let repo = Repo::discover(&root)?.ok_or_else(|| IdeError::unavailable("folder is not a Git worktree"))?;
|
||||
let root = repo.root().to_path_buf();
|
||||
repo.set_execution_allowed(false);
|
||||
let mut external = self.external_log_repos.write();
|
||||
if !external.iter().any(|r| r.root() == root) {
|
||||
external.push(Arc::new(repo));
|
||||
}
|
||||
Ok(root)
|
||||
}
|
||||
|
||||
/// Log read operations may also use an explicitly opened external worktree.
|
||||
/// Mutations continue to use `with_repo_at` and cannot reach these repositories.
|
||||
pub fn with_log_repo_at<T>(&self, at: Option<&Path>, f: impl FnOnce(&Repo) -> IdeResult<T>) -> IdeResult<T> {
|
||||
if let Some(path) = at
|
||||
&& self.repos().at_root(path).is_none()
|
||||
{
|
||||
let repo = self
|
||||
.external_log_repos
|
||||
.read()
|
||||
.iter()
|
||||
.find(|r| r.root() == path)
|
||||
.cloned()
|
||||
.ok_or_else(|| IdeError::unavailable("external Git log is not open"))?;
|
||||
return f(&repo);
|
||||
}
|
||||
self.with_repo_at(at, f)
|
||||
}
|
||||
|
||||
pub fn with_log_repo_of<T>(&self, path: &Path, f: impl FnOnce(&Repo) -> IdeResult<T>) -> IdeResult<T> {
|
||||
if self.repos().for_path(path).is_some() {
|
||||
return self.with_repo_of(path, f);
|
||||
}
|
||||
let repo = self
|
||||
.external_log_repos
|
||||
.read()
|
||||
.iter()
|
||||
.filter(|r| path.starts_with(r.root()))
|
||||
.max_by_key(|r| r.root().components().count())
|
||||
.cloned()
|
||||
.ok_or_else(|| IdeError::unavailable("path is not in an open Git log"))?;
|
||||
f(&repo)
|
||||
}
|
||||
|
||||
/// Runs `f` with the repository `path` (a file or folder) belongs to.
|
||||
pub fn with_repo_of<T>(&self, path: &Path, f: impl FnOnce(&Repo) -> IdeResult<T>) -> IdeResult<T> {
|
||||
match self.repos().for_path(path) {
|
||||
@@ -955,7 +1017,7 @@ impl Project {
|
||||
return encoding_rs::Encoding::for_label(loaded.encoding.as_bytes())
|
||||
.ok_or_else(|| ide_core::IdeError::invalid("unknown file encoding"));
|
||||
}
|
||||
let bytes = self.with_repo_of(path, |r| r.file_bytes_at(path, rev))?.unwrap_or_default();
|
||||
let bytes = self.with_log_repo_of(path, |r| r.file_bytes_at(path, rev))?.unwrap_or_default();
|
||||
Ok(ide_vfs::detect_encoding(&bytes, fallback))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! System Settings › HTTP Proxy on the host: the global settings made current for
|
||||
//! connections and child processes, the password kept in the saved passwords
|
||||
//! (owner-only `proxy-password.json`) instead of `settings.toml`.
|
||||
//! (the OS credential store) instead of `settings.toml`, or for the session
|
||||
//! when there is no credential store.
|
||||
|
||||
use crate::Host;
|
||||
use ide_core::IdeResult;
|
||||
@@ -12,7 +13,9 @@ impl Host {
|
||||
/// `settings` with the saved proxy password filled in, as the Settings dialog edits it.
|
||||
pub fn with_proxy_password(&self, mut settings: Settings) -> Settings {
|
||||
// A password typed into settings.toml by hand counts too, unless one was saved.
|
||||
if let Some(password) = self.proxy_password.get(PASSWORD_KEY) {
|
||||
let session = || self.proxy_session_password.lock().clone();
|
||||
// The session copy is the latest write that could not be saved.
|
||||
if let Some(password) = session().or_else(|| self.proxy_password.get(PASSWORD_KEY)) {
|
||||
settings.proxy.password = password;
|
||||
}
|
||||
settings
|
||||
@@ -40,11 +43,70 @@ impl Host {
|
||||
_ => return Ok(Some(value)),
|
||||
};
|
||||
if let Some(password) = password {
|
||||
match password.as_str().filter(|p| !p.is_empty()) {
|
||||
Some(p) => self.proxy_password.set(PASSWORD_KEY, p)?,
|
||||
let password = password.as_str().filter(|p| !p.is_empty());
|
||||
let mut session = self.proxy_session_password.lock();
|
||||
*session = None;
|
||||
match password {
|
||||
Some(p) => {
|
||||
if let Err(e) = self.proxy_password.set(PASSWORD_KEY, p) {
|
||||
tracing::warn!("the HTTP proxy password is kept for this session only: {}", e.message);
|
||||
*session = Some(p.to_owned());
|
||||
}
|
||||
}
|
||||
None => self.proxy_password.delete(PASSWORD_KEY),
|
||||
}
|
||||
}
|
||||
Ok((key != "proxy.password").then_some(value))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ide_ssh::secrets::{SecretFile, StoreError, StoreResult, Vault};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A machine without a credential store (a headless Linux host).
|
||||
struct NoStore;
|
||||
|
||||
impl Vault for NoStore {
|
||||
fn available(&self) -> bool {
|
||||
false
|
||||
}
|
||||
fn get(&self, _: &str, _: &str) -> StoreResult<String> {
|
||||
Err(StoreError::NoDefaultStore)
|
||||
}
|
||||
fn set(&self, _: &str, _: &str, _: &str) -> StoreResult<()> {
|
||||
Err(StoreError::NoDefaultStore)
|
||||
}
|
||||
fn delete(&self, _: &str, _: &str) -> StoreResult<()> {
|
||||
Err(StoreError::NoDefaultStore)
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn without_a_credential_store_the_password_lasts_the_session() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut host = Host::new(crate::HostConfig {
|
||||
config_dir: tmp.path().join("config"),
|
||||
data_dir: tmp.path().join("data"),
|
||||
local_data_dir: tmp.path().join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(ide_core::NullSink)),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
});
|
||||
let path = tmp.path().join("data/proxy-password.json");
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&path, r#"{"httpProxy": "old"}"#).unwrap();
|
||||
host.proxy_password = SecretFile::with_vault(path, |v| serde_json::from_value(v).ok(), Arc::new(NoStore));
|
||||
|
||||
let left = host.take_proxy_password("proxy.password", serde_json::json!("s3cret")).unwrap();
|
||||
assert_eq!(left, None);
|
||||
assert_eq!(host.with_proxy_password(Settings::default()).proxy.password, "s3cret");
|
||||
host.take_proxy_password("proxy.password", serde_json::json!("")).unwrap();
|
||||
assert_eq!(host.with_proxy_password(Settings::default()).proxy.password, "");
|
||||
}
|
||||
}
|
||||
@@ -188,7 +188,7 @@ impl Dispatcher {
|
||||
ops::fs::create_file_bytes(&c.host, c.arg("path")?, c.arg("data")?).await
|
||||
})
|
||||
.add("fs_create_dir", |c| async move { ide_vfs::create_dir(&c.arg::<PathBuf>("path")?) })
|
||||
.add("fs_rename", |c| async move { ide_vfs::rename(&c.arg::<PathBuf>("from")?, &c.arg::<PathBuf>("to")?) })
|
||||
.add("fs_rename", |c| async move { ops::fs::rename(&c.host, c.arg("from")?, c.arg("to")?).await })
|
||||
.add("fs_set_writable", |c| async move {
|
||||
ops::fs::set_writable(&c.host, &c.window, c.arg("path")?, c.arg("writable")?)
|
||||
})
|
||||
|
||||
@@ -222,6 +222,7 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
|
||||
| "project_current"
|
||||
| "project_roots"
|
||||
| "index_status"
|
||||
| "index_file_extensions"
|
||||
| "jvm_file_presentations"
|
||||
| "deploy_coverage"
|
||||
| "deploy_test_connection"
|
||||
@@ -307,6 +308,14 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
|
||||
| "vcs_blame"
|
||||
| "vcs_block_history"
|
||||
| "vcs_log"
|
||||
| "vcs_log_refs"
|
||||
| "vcs_log_users"
|
||||
| "vcs_log_details"
|
||||
| "vcs_log_details_batch"
|
||||
| "vcs_log_index"
|
||||
| "vcs_revision_children"
|
||||
| "vcs_revision_files"
|
||||
| "vcs_open_external_log"
|
||||
| "vcs_commit_files"
|
||||
| "vcs_commits_changes"
|
||||
| "vcs_branches"
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
use ide_core::NullSink;
|
||||
use ide_host::{Host, HostConfig, ops};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
fn options(root: &std::path::Path) -> ide_terminal::TerminalOptions {
|
||||
ide_terminal::TerminalOptions {
|
||||
@@ -52,3 +53,53 @@ fn closing_a_project_closes_its_terminals_and_keeps_other_windows() {
|
||||
host.terminals.kill_key("other");
|
||||
});
|
||||
}
|
||||
|
||||
/// The desktop closes a project on the event-loop thread while runtime workers may be
|
||||
/// blocked waiting for that thread (IPC responses, window queries): closing must not
|
||||
/// wait for workers that need the runtime, and exit still waits for them once free.
|
||||
#[test]
|
||||
fn detached_close_returns_while_runtime_workers_wait_for_the_closing_thread() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap().join("project");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let runtime = tokio::runtime::Builder::new_multi_thread().worker_threads(1).enable_all().build().unwrap();
|
||||
let _guard = runtime.enter();
|
||||
let host = Arc::new(Host::new(HostConfig {
|
||||
config_dir: tmp.path().join("config"),
|
||||
data_dir: tmp.path().join("data"),
|
||||
local_data_dir: tmp.path().join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(NullSink)),
|
||||
owner: |w| w,
|
||||
runtime: runtime.handle().clone(),
|
||||
project_environment: false,
|
||||
}));
|
||||
host.trust.decide(&root, true, false).unwrap();
|
||||
host.open_project("main", &root).unwrap();
|
||||
|
||||
// Occupy the only runtime worker until "the event loop" answers.
|
||||
let (started_tx, started_rx) = std::sync::mpsc::channel();
|
||||
let (release_tx, release_rx) = std::sync::mpsc::channel::<()>();
|
||||
runtime.spawn(async move {
|
||||
started_tx.send(()).unwrap();
|
||||
let _ = release_rx.recv();
|
||||
});
|
||||
started_rx.recv().unwrap();
|
||||
|
||||
let (closed_tx, closed_rx) = std::sync::mpsc::channel();
|
||||
let closer = host.clone();
|
||||
std::thread::spawn(move || {
|
||||
closer.close_project_detached("main");
|
||||
let _ = closed_tx.send(());
|
||||
});
|
||||
let returned = closed_rx.recv_timeout(Duration::from_secs(5));
|
||||
release_tx.send(()).unwrap();
|
||||
assert!(returned.is_ok(), "closing waited for a runtime worker that waits for the closing thread");
|
||||
assert!(host.project("main").is_err(), "the window no longer has the project");
|
||||
host.open_project("main", &root).expect("the window opens a project once the close has finished");
|
||||
|
||||
let started = Instant::now();
|
||||
host.shutdown_notebooks_on_exit();
|
||||
assert!(started.elapsed() < Duration::from_secs(5), "exit joined the finished close instead of timing out");
|
||||
}
|
||||
@@ -196,6 +196,28 @@ fn write_index(
|
||||
path.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
/// First run: nothing cached yet, so listing the marketplace reads its index
|
||||
/// instead of showing an empty one; later listings use the index read.
|
||||
#[test]
|
||||
fn the_first_marketplace_listing_reads_the_index() {
|
||||
let f = fixture();
|
||||
assert!(f.host.market_listing(false).unwrap().is_empty(), "no marketplace configured");
|
||||
|
||||
let market = f.work.join("market");
|
||||
std::fs::create_dir_all(&market).unwrap();
|
||||
let key = PublisherKey::from_seed([3; 32]);
|
||||
let (_, entry) = signed_hello(&market, "1.0.0", &key, &f.host);
|
||||
let url = write_index(&market, &key, vec![entry], vec![], false);
|
||||
f.host.set_market_url(Some(url.clone()));
|
||||
|
||||
let listed = f.host.market_listing(false).unwrap();
|
||||
assert_eq!(listed.iter().map(|p| p.entry.id.as_str()).collect::<Vec<_>>(), ["acme.hello"]);
|
||||
|
||||
std::fs::remove_file(&url).unwrap();
|
||||
assert_eq!(f.host.market_listing(false).unwrap().len(), 1, "the index read is kept");
|
||||
assert!(f.host.market_listing(true).is_err(), "refresh reads the index again");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_marketplace_plugins_install_and_revocations_disable_them() {
|
||||
let f = fixture();
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
//! Explicit external logs allow history reads without adding a writable project root.
|
||||
use ide_core::NullSink;
|
||||
use ide_host::{Host, HostConfig, ops::vcs};
|
||||
use std::{path::Path, process::Command, sync::Arc};
|
||||
|
||||
fn git(root: &Path, args: &[&str]) -> String {
|
||||
let output = Command::new("git")
|
||||
.current_dir(root)
|
||||
.args(["-c", "user.name=Test", "-c", "[email protected]", "-c", "commit.gpgsign=false"])
|
||||
.args(args)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
String::from_utf8(output.stdout).unwrap().trim().to_owned()
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn external_history_is_readable_and_never_becomes_a_write_target() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let base = dunce::canonicalize(temp.path()).unwrap();
|
||||
let root = base.join("project");
|
||||
let external = base.join("external");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
std::fs::create_dir_all(&external).unwrap();
|
||||
git(&external, &["init", "-b", "main"]);
|
||||
let deleted = external.join("deleted.txt");
|
||||
std::fs::write(&deleted, "historical content\n").unwrap();
|
||||
git(&external, &["add", "."]);
|
||||
git(&external, &["commit", "-m", "initial"]);
|
||||
let first = git(&external, &["rev-parse", "HEAD"]);
|
||||
git(&external, &["rm", "deleted.txt"]);
|
||||
git(&external, &["commit", "-m", "delete"]);
|
||||
let head = git(&external, &["rev-parse", "HEAD"]);
|
||||
let host = Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
data_dir: base.join("data"),
|
||||
local_data_dir: base.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: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
});
|
||||
// A standalone editor needs no project, indexer, or writable VCS root.
|
||||
vcs::vcs_open_external_log(&host, "standalone", external.clone()).await.unwrap();
|
||||
let standalone = vcs::vcs_log(&host, "standalone", Some(external.clone()), Default::default()).await.unwrap();
|
||||
assert_eq!(standalone[0].id, head);
|
||||
let details = vcs::vcs_log_details_batch(
|
||||
&host,
|
||||
"standalone",
|
||||
Some(external.clone()),
|
||||
vec![head.clone(), first.clone()],
|
||||
ide_core::RequestId(987),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(details.iter().all(|d| d.signature == "N"));
|
||||
assert_eq!(
|
||||
vcs::vcs_revision_children(&host, "standalone", Some(external.clone()), first.clone(), String::new())
|
||||
.await
|
||||
.unwrap(),
|
||||
vec!["deleted.txt"]
|
||||
);
|
||||
assert!(host.project("standalone").is_err());
|
||||
host.trust.decide(&external, false, false).unwrap();
|
||||
let restricted = host.open_project("restricted", &external).unwrap();
|
||||
assert!(!restricted.is_trusted());
|
||||
vcs::vcs_open_external_log(&host, "restricted", external.clone()).await.unwrap();
|
||||
let restricted_log = vcs::vcs_log(
|
||||
&host,
|
||||
"restricted",
|
||||
Some(external.clone()),
|
||||
ide_vcs::LogQuery { request: Some(ide_core::RequestId(988)), summary_only: true, ..Default::default() },
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(restricted_log[0].id, head);
|
||||
assert_eq!(
|
||||
vcs::vcs_log_details_batch(
|
||||
&host,
|
||||
"restricted",
|
||||
Some(external.clone()),
|
||||
vec![head.clone()],
|
||||
ide_core::RequestId(989)
|
||||
)
|
||||
.await
|
||||
.unwrap()[0]
|
||||
.signature,
|
||||
"N"
|
||||
);
|
||||
host.close_project("restricted");
|
||||
let payload =
|
||||
serde_json::to_vec(&serde_json::json!({ "repo": external, "revision": first, "path": "deleted.txt" })).unwrap();
|
||||
let hex = payload.iter().map(|b| format!("{b:02x}")).collect::<String>();
|
||||
let document =
|
||||
vcs::revision_virtual_document(&host, "standalone", &format!("git-revision:{hex}/deleted.txt")).await.unwrap();
|
||||
assert_eq!(document.text, "historical content\n");
|
||||
host.trust.decide(&root, true, false).unwrap();
|
||||
let project = host.open_project("main", &root).unwrap();
|
||||
assert!(vcs::vcs_log(&host, "main", Some(external.clone()), Default::default()).await.is_err());
|
||||
vcs::vcs_open_external_log(&host, "main", external.clone()).await.unwrap();
|
||||
let log = vcs::vcs_log(&host, "main", Some(external.clone()), Default::default()).await.unwrap();
|
||||
assert_eq!(log.iter().map(|c| c.id.as_str()).collect::<Vec<_>>(), vec![head.as_str(), first.as_str()]);
|
||||
assert_eq!(
|
||||
vcs::vcs_file_at(&host, "main", deleted.clone(), Some(first.clone())).await.unwrap().as_deref(),
|
||||
Some("historical content\n")
|
||||
);
|
||||
assert!(project.repos().at_root(&external).is_none());
|
||||
assert!(vcs::vcs_checkout(&host, "main", Some(external.clone()), first.clone(), false, None).await.is_err());
|
||||
assert!(vcs::vcs_apply_patch(&host, "main", Some(external.clone()), String::new()).await.is_err());
|
||||
assert_eq!(git(&external, &["rev-parse", "HEAD"]), head);
|
||||
assert!(!deleted.exists());
|
||||
host.close_project("main");
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Language smoke tests: the everyday code intelligence of each language — completion, go to
|
||||
//! definition, Find Usages, hover, diagnostics with a quick fix, an intention, rename and
|
||||
//! formatting — on its `fixtures/` project, through the `ops` the app's commands call and the
|
||||
//! definition, Find Usages, hover, diagnostics with a quick fix, an intention, rename,
|
||||
//! formatting, parameter info, the Structure view, auto-import, Optimize Imports and moving a
|
||||
//! file — on its `fixtures/` project, through the `ops` the app's commands call and the
|
||||
//! language server the app starts by default.
|
||||
//!
|
||||
//! `#[ignore]`d because they need the servers installed. CI's `Language smoke` job installs
|
||||
@@ -68,6 +69,35 @@ struct Checks {
|
||||
indent: Indent,
|
||||
/// Renaming the symbol to the new name edits these files.
|
||||
rename: (At, &'static str, &'static [&'static str]),
|
||||
/// Parameter info in a call being typed: a signature naming this parameter.
|
||||
signature: (Typed, &'static str),
|
||||
/// The Structure view of a file: these symbols, at any depth.
|
||||
outline: (&'static str, &'static [&'static str]),
|
||||
/// Completing a name the file doesn't import yet also imports it.
|
||||
auto_import: AutoImport,
|
||||
/// Optimize Imports, with an import typed after a line.
|
||||
organize_imports: Option<(Typed, Imports)>,
|
||||
/// Moving a file updates its references: (file, new path, a file that then contains this).
|
||||
file_move: Option<(&'static str, &'static str, &'static str, &'static str)>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct AutoImport {
|
||||
/// The line typed, ending in the start of the name.
|
||||
typed: Typed,
|
||||
/// The completion item.
|
||||
label: &'static str,
|
||||
/// What the item's import adds to the file.
|
||||
result: &'static str,
|
||||
}
|
||||
|
||||
/// What Optimize Imports does with the typed import.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum Imports {
|
||||
/// Removes it (unused).
|
||||
Removed,
|
||||
/// Sorts it before this line.
|
||||
Before(&'static str),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
@@ -432,6 +462,146 @@ impl Smoke {
|
||||
}
|
||||
}
|
||||
|
||||
async fn signature(&self, call: Typed, parameter: &str) {
|
||||
let (doc, pos, before) = self.type_line(call).await;
|
||||
self.retry("parameter info", self.warm_up, || async {
|
||||
let found = ops::lsp::lsp_signature_help(&self.host, WINDOW, doc, pos).await;
|
||||
match &found {
|
||||
Ok(Some(h)) if h.signatures.iter().any(|s| s.label.contains(parameter)) => Ok(()),
|
||||
_ => Err(found.map(|h| h.map(|h| h.signatures.into_iter().map(|s| s.label).collect::<Vec<_>>()))),
|
||||
}
|
||||
})
|
||||
.await;
|
||||
self.restore(doc, &before).await;
|
||||
}
|
||||
|
||||
async fn outline(&self, file: &str, symbols: &[&str]) {
|
||||
let (doc, _) = self.doc(file).await;
|
||||
fn names(items: &[ide_core::OutlineItem], out: &mut BTreeSet<String>) {
|
||||
for item in items {
|
||||
out.insert(item.name.clone());
|
||||
names(&item.children, out);
|
||||
}
|
||||
}
|
||||
self.retry("structure", self.warm_up, || async {
|
||||
let found = ops::doc::doc_outline(&self.host, WINDOW, doc).await;
|
||||
let mut shown = BTreeSet::new();
|
||||
if let Ok(items) = &found {
|
||||
names(items, &mut shown);
|
||||
}
|
||||
if symbols.iter().all(|s| shown.contains(*s)) { Ok(()) } else { Err(shown) }
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn auto_import(&self, a: AutoImport) {
|
||||
let (doc, pos, before) = self.type_line(a.typed).await;
|
||||
assert!(!before.contains(a.result), "{a:?}: the file already contains the result");
|
||||
self.retry("auto-import completion", self.warm_up, || async {
|
||||
let list = ops::lsp::lsp_completion(&self.host, WINDOW, RequestId(6), doc, pos, None).await;
|
||||
let items = match &list {
|
||||
Ok(l) => l.items.iter().filter(|i| i.label == a.label).cloned().collect::<Vec<_>>(),
|
||||
Err(e) => return Err(format!("{e}")),
|
||||
};
|
||||
let text = self.doc(a.typed.file).await.1;
|
||||
let mut imports = Vec::new();
|
||||
for item in items {
|
||||
// Servers compute the import when the item is resolved (on selection).
|
||||
let item = if item.additional_edits.is_empty() {
|
||||
ops::lsp::lsp_resolve_completion(&self.host, WINDOW, doc, item.clone()).await.unwrap_or(item)
|
||||
} else {
|
||||
item
|
||||
};
|
||||
let mut document = ide_text::Document::new(&text);
|
||||
if document.apply_edits(&item.additional_edits).is_ok() && document.text().contains(a.result) {
|
||||
return Ok(());
|
||||
}
|
||||
// Or accepting it runs a server command that edits the file (kotlin-lsp).
|
||||
if let Some(command) = item.command.clone() {
|
||||
let ran = ops::lsp::lsp_execute_command(&self.host, WINDOW, doc, command).await;
|
||||
let text = self.doc(a.typed.file).await.1;
|
||||
if ran.is_ok() && text.contains(a.result) {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
imports.push(format!("{:?} {:?}", item.additional_edits, item.command));
|
||||
}
|
||||
let labels: Vec<String> = list.map(|l| l.items.into_iter().take(20).map(|i| i.label).collect()).unwrap();
|
||||
Err(format!("{:?} items' imports: {imports:?}; items: {labels:?}", a.label))
|
||||
})
|
||||
.await;
|
||||
self.restore(doc, &before).await;
|
||||
}
|
||||
|
||||
async fn organize_imports(&self, import: Typed, expect: Imports) {
|
||||
let (doc, _, before) = self.type_line(import).await;
|
||||
let typed = import.text.trim();
|
||||
let organized = |text: &str| match expect {
|
||||
Imports::Removed => !text.lines().any(|l| l.trim() == typed),
|
||||
Imports::Before(line) => {
|
||||
let at = |needle: &str| text.lines().position(|l| l.trim() == needle);
|
||||
matches!((at(typed), at(line)), (Some(a), Some(b)) if a < b)
|
||||
}
|
||||
};
|
||||
self.retry("optimize imports", self.warm_up, || async {
|
||||
let text = self.doc(import.file).await.1;
|
||||
// The whole file, as Optimize Imports asks (positions past the end fail on jdtls).
|
||||
let last = text.split('\n').count() as u32 - 1;
|
||||
let whole = Range::new(Pos::new(0, 0), Pos::new(last, utf16_len(text.rsplit('\n').next().unwrap_or(""))));
|
||||
let kind = "source.organizeImports".to_string();
|
||||
let found = ops::lsp::lsp_code_actions(&self.host, WINDOW, doc, whole, vec![kind.clone()]).await;
|
||||
let actions: Vec<_> = match &found {
|
||||
Ok(a) => a.iter().filter(|a| a.kind.as_ref().is_some_and(|k| k.starts_with(&kind))).cloned().collect(),
|
||||
Err(e) => return Err(format!("{e}")),
|
||||
};
|
||||
let Some(action) = actions.iter().find(|a| a.is_preferred).or(actions.first()).cloned() else {
|
||||
return Err(format!(
|
||||
"no {kind} action: {:?}",
|
||||
found.map(|a| a.into_iter().map(|a| a.title).collect::<Vec<_>>())
|
||||
));
|
||||
};
|
||||
ops::lsp::lsp_apply_code_action(&self.host, WINDOW, doc, action).await.map_err(|e| e.to_string())?;
|
||||
let text = self.doc(import.file).await.1;
|
||||
if organized(&text) { Ok(()) } else { Err(text) }
|
||||
})
|
||||
.await;
|
||||
self.restore(doc, &before).await;
|
||||
}
|
||||
|
||||
async fn file_move(&self, from: &str, to: &str, referencing: &str, result: &str) {
|
||||
// The servers know the referencing file (opened, as in an editor).
|
||||
let _ = self.doc(referencing).await;
|
||||
let moves = vec![ide_workspace::FileMove { from: self.path(from).into(), to: self.path(to).into() }];
|
||||
let edit = self
|
||||
.retry("move file", self.warm_up, || async {
|
||||
let found =
|
||||
ops::refactor::refactor_file_move_edits(&self.host, WINDOW, RequestId(7), moves.clone()).await;
|
||||
let want = self.path(referencing);
|
||||
match &found {
|
||||
Ok(e)
|
||||
if e.edit
|
||||
.files
|
||||
.iter()
|
||||
.any(|f| matches!(f, ide_core::FileEdit::Edit { path, .. } if **path == *want)) =>
|
||||
{
|
||||
Ok(e.edit.clone())
|
||||
}
|
||||
_ => Err(found),
|
||||
}
|
||||
})
|
||||
.await;
|
||||
ops::refactor::refactor_move_files(&self.host, WINDOW, moves, edit, "Move".into())
|
||||
.await
|
||||
.unwrap_or_else(|e| self.fail("move file", e));
|
||||
if !self.path(to).is_file() {
|
||||
self.fail("move file", format_args!("{to} doesn't exist after the move"));
|
||||
}
|
||||
let text = self.doc(referencing).await.1;
|
||||
if !text.contains(result) {
|
||||
self.fail("move file", format_args!("{referencing} has no {result:?} after the move:\n{text}"));
|
||||
}
|
||||
}
|
||||
|
||||
async fn run(&self, c: &Checks) {
|
||||
self.definition(c.definition.0, c.definition.1).await;
|
||||
self.references(c.references.0, c.references.1).await;
|
||||
@@ -440,7 +610,17 @@ impl Smoke {
|
||||
self.quick_fix(c.quick_fix).await;
|
||||
self.intention(c.intention.0, c.intention.1).await;
|
||||
self.format(c.format.0, c.indent, c.format.1).await;
|
||||
self.signature(c.signature.0, c.signature.1).await;
|
||||
self.outline(c.outline.0, c.outline.1).await;
|
||||
self.auto_import(c.auto_import).await;
|
||||
if let Some((import, expect)) = c.organize_imports {
|
||||
self.organize_imports(import, expect).await;
|
||||
}
|
||||
self.rename(c.rename.0, c.rename.1, c.rename.2).await;
|
||||
// Last: it moves a file the other checks use.
|
||||
if let Some((from, to, referencing, result)) = c.file_move {
|
||||
self.file_move(from, to, referencing, result).await;
|
||||
}
|
||||
let shutdown = self.host.project(WINDOW).unwrap().lsp.clone();
|
||||
let _ = tokio::time::timeout(Duration::from_secs(20), shutdown.shutdown_all()).await;
|
||||
}
|
||||
@@ -480,6 +660,22 @@ const TYPESCRIPT: Checks = Checks {
|
||||
"formatPrice",
|
||||
&["src/index.ts", "src/money.ts"],
|
||||
),
|
||||
signature: (typed("src/index.ts", "const total = cart.total();", " cart.add("), "product"),
|
||||
outline: ("src/cart.ts", &["Cart", "add", "total"]),
|
||||
auto_import: AutoImport {
|
||||
typed: typed("src/index.ts", "const total = cart.total();", " addMon"),
|
||||
label: "addMoney",
|
||||
result: "addMoney",
|
||||
},
|
||||
organize_imports: Some((
|
||||
typed(
|
||||
"src/index.ts",
|
||||
"import { cents, formatMoney } from \"./money.ts\";",
|
||||
"import { readFileSync } from \"node:fs\";",
|
||||
),
|
||||
Imports::Removed,
|
||||
)),
|
||||
file_move: Some(("src/money.ts", "src/lib/money.ts", "src/index.ts", "\"./lib/money.ts\"")),
|
||||
};
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
@@ -521,6 +717,19 @@ const PYTHON: Checks = Checks {
|
||||
"TaskBoard",
|
||||
&["src/tasks/cli.py", "src/tasks/models.py"],
|
||||
),
|
||||
signature: (typed("src/tasks/cli.py", "tasks = build_demo()", " tasks.add("), "title"),
|
||||
outline: ("src/tasks/models.py", &["Priority", "Task", "TaskList", "pending"]),
|
||||
auto_import: AutoImport {
|
||||
typed: typed("src/tasks/cli.py", "tasks = build_demo()", " stamp = datetim"),
|
||||
label: "datetime",
|
||||
result: "import datetime",
|
||||
},
|
||||
organize_imports: Some((
|
||||
typed("src/tasks/models.py", "from enum import IntEnum", "import abc"),
|
||||
Imports::Before("import os"),
|
||||
)),
|
||||
// basedpyright has no `workspace/willRenameFiles`.
|
||||
file_move: None,
|
||||
};
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
@@ -558,6 +767,16 @@ const RUST: Checks = Checks {
|
||||
),
|
||||
indent: Indent::Spaces(4),
|
||||
rename: (at("src/parser.rs", "pub fn parse(text: &str)", "parse"), "parse_all", &["src/main.rs", "src/parser.rs"]),
|
||||
signature: (typed("src/main.rs", "let mut inventory = Inventory::new();", " inventory.take("), "sku"),
|
||||
outline: ("src/lib.rs", &["Inventory", "Item", "take"]),
|
||||
auto_import: AutoImport {
|
||||
typed: typed("src/main.rs", "let mut inventory = Inventory::new();", " let _seen = HashSe"),
|
||||
label: "HashSet",
|
||||
result: "use std::collections::HashSet;",
|
||||
},
|
||||
// rust-analyzer has no `source.organizeImports`.
|
||||
organize_imports: None,
|
||||
file_move: Some(("src/parser.rs", "src/csv.rs", "src/lib.rs", "pub mod csv;")),
|
||||
};
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
@@ -597,6 +816,16 @@ const GO: Checks = Checks {
|
||||
"Open",
|
||||
&["cmd/server/main.go", "internal/store/store.go"],
|
||||
),
|
||||
signature: (typed("cmd/server/main.go", "s := store.New()", "\ts.Create("), "title"),
|
||||
outline: ("internal/store/store.go", &["Store", "New", "(*Store).Create"]),
|
||||
auto_import: AutoImport {
|
||||
typed: typed("cmd/server/main.go", "s := store.New()", "\t_ = strings.ToUpp"),
|
||||
label: "ToUpper",
|
||||
result: "\t\"strings\"",
|
||||
},
|
||||
organize_imports: Some((typed("cmd/server/main.go", "\"strconv\"", "\t\"sort\""), Imports::Removed)),
|
||||
// gopls renames packages, not files: moving a Go file changes no references.
|
||||
file_move: None,
|
||||
};
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
@@ -653,6 +882,37 @@ const JAVA: Checks = Checks {
|
||||
"submit",
|
||||
&["src/main/java/com/example/orders/App.java", "src/main/java/com/example/orders/OrderBook.java"],
|
||||
),
|
||||
signature: (
|
||||
typed(
|
||||
"src/main/java/com/example/orders/App.java",
|
||||
"long revenue = book.revenueCents();",
|
||||
" book.place(",
|
||||
),
|
||||
"customer",
|
||||
),
|
||||
outline: (
|
||||
"src/main/java/com/example/orders/OrderBook.java",
|
||||
&["OrderBook", "place(String, String, int, long)", "revenueCents()"],
|
||||
),
|
||||
auto_import: AutoImport {
|
||||
typed: typed(
|
||||
"src/main/java/com/example/orders/App.java",
|
||||
"long revenue = book.revenueCents();",
|
||||
" ArrayLis",
|
||||
),
|
||||
label: "ArrayList",
|
||||
result: "import java.util.ArrayList;",
|
||||
},
|
||||
organize_imports: Some((
|
||||
typed("src/main/java/com/example/orders/OrderBook.java", "import java.util.Optional;", "import java.util.Map;"),
|
||||
Imports::Removed,
|
||||
)),
|
||||
file_move: Some((
|
||||
"src/main/java/com/example/orders/OrderBook.java",
|
||||
"src/main/java/com/example/orders/Ledger.java",
|
||||
"src/main/java/com/example/orders/App.java",
|
||||
"Ledger book",
|
||||
)),
|
||||
};
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
@@ -705,6 +965,26 @@ const KOTLIN: Checks = Checks {
|
||||
"find",
|
||||
&["src/main/kotlin/com/example/library/App.kt", "src/main/kotlin/com/example/library/Library.kt"],
|
||||
),
|
||||
signature: (
|
||||
typed("src/main/kotlin/com/example/library/App.kt", "val library = Library()", " library.lend("),
|
||||
"isbn",
|
||||
),
|
||||
outline: ("src/main/kotlin/com/example/library/Library.kt", &["Library", "lend", "search"]),
|
||||
auto_import: AutoImport {
|
||||
typed: typed(
|
||||
"src/main/kotlin/com/example/library/App.kt",
|
||||
"val library = Library()",
|
||||
" val stamp = LocalDateTi",
|
||||
),
|
||||
label: "LocalDateTime",
|
||||
result: "import java.time.LocalDateTime",
|
||||
},
|
||||
organize_imports: Some((
|
||||
typed("src/main/kotlin/com/example/library/App.kt", "import java.time.LocalDate", "import java.util.UUID"),
|
||||
Imports::Removed,
|
||||
)),
|
||||
// kotlin-lsp has no `workspace/willRenameFiles`.
|
||||
file_move: None,
|
||||
};
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
|
||||
@@ -2045,6 +2045,83 @@ fn pinned_programs_fill_views_and_open_terminals() {
|
||||
assert_eq!(args, ["follow", "build", "--lines=5"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn views_open_project_files_at_a_line() {
|
||||
let f = fixture(Limits::default());
|
||||
let info = f.install(&sample("jobs"));
|
||||
assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error);
|
||||
std::fs::write(f.project.join("jobs.log"), "build\ndeploy\nfailed\n").unwrap();
|
||||
let press = |button: &str| {
|
||||
f.take_events();
|
||||
let result = f.host.view_event("jobs", "jobs", ide_plugin::ViewEvent::Button { id: button.into() }, f.inv());
|
||||
let opened = f.take_events().into_iter().find_map(|(client, e)| match e {
|
||||
PluginEvent::OpenLocation { path, line, column, .. } => Some((client, path.0, line, column)),
|
||||
_ => None,
|
||||
});
|
||||
(result, opened)
|
||||
};
|
||||
|
||||
let (result, opened) = press("log");
|
||||
assert!(result.is_ok(), "{result:?}");
|
||||
let (client, path, line, column) = opened.expect("a location");
|
||||
assert_eq!((client.as_deref(), line, column), (Some("main"), 2, 4));
|
||||
assert_eq!(path.canonicalize().unwrap(), f.project.join("jobs.log").canonicalize().unwrap());
|
||||
|
||||
let (result, opened) = press("outside");
|
||||
assert!(result.is_err(), "a path outside the project is refused");
|
||||
assert!(opened.is_none());
|
||||
|
||||
f.host.set_capability("jobs", "project.read", false).unwrap();
|
||||
let (result, opened) = press("log");
|
||||
assert!(result.unwrap_err().message.contains("project.read"), "needs project.read");
|
||||
assert!(opened.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn views_can_use_an_svg_icon_from_the_package() {
|
||||
let f = fixture(Limits::default());
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let clean = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect width="16" height="16" fill="#08f"/></svg>"##;
|
||||
let package = |dir: &str, icon: &str, svg: &str| {
|
||||
let pkg = tmp.path().join(dir);
|
||||
for (path, text) in [("panel/index.html", "<p>panel</p>"), ("icons/panel.svg", svg)] {
|
||||
std::fs::create_dir_all(pkg.join(path).parent().unwrap()).unwrap();
|
||||
std::fs::write(pkg.join(path), text).unwrap();
|
||||
}
|
||||
std::fs::write(
|
||||
pkg.join("plugin.toml"),
|
||||
format!(
|
||||
r#"id = "panel-icon"
|
||||
name = "Panel Icon"
|
||||
version = "1.0.0"
|
||||
engines.openidea = "^1.0"
|
||||
|
||||
[[contributes.views]]
|
||||
id = "panel"
|
||||
title = "Panel"
|
||||
icon = "{icon}"
|
||||
location = "bottom"
|
||||
web = "panel/index.html"
|
||||
"#
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
pkg
|
||||
};
|
||||
|
||||
assert!(f.host.install_from_dir(&package("missing", "icons/missing.svg", clean), &Consent::all()).is_err());
|
||||
let scripted = clean.replace("<rect", "<script>alert(1)</script><rect");
|
||||
assert!(f.host.install_from_dir(&package("scripted", "icons/panel.svg", &scripted), &Consent::all()).is_err());
|
||||
|
||||
let info = f.install(&package("clean", "icons/panel.svg", clean));
|
||||
assert!(info.error.is_none(), "{:?}", info.error);
|
||||
let view = f.host.ui_items("views").into_iter().find(|v| v.key == "panel").expect("the view");
|
||||
assert_eq!(view.decl["icon"].as_str(), Some("icons/panel.svg"));
|
||||
let assets = f.host.assets("panel-icon", "viewIcon/panel").unwrap();
|
||||
assert_eq!(assets.len(), 1);
|
||||
assert!(assets[0].1.contains("<rect"));
|
||||
}
|
||||
|
||||
/// A plugin with web views and no code: its pages and their files are
|
||||
/// installed with it and served only from the declared directories, while
|
||||
/// it is enabled and only for the installed version; file icons and
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
//! Opening each `fixtures/` project the way the app does, through the `ops` its commands call:
|
||||
//! the source and test roots detected from its build file, the index (files and symbols),
|
||||
//! Search Everywhere / Go to Class / Go to Symbol answered from it and kept current as files
|
||||
//! come and go, and the Maven / Gradle model.
|
||||
//!
|
||||
//! The `#[ignore]`d tests run Maven, Gradle, python3 and a JDK 21 (CI's `Language smoke`
|
||||
//! java, kotlin and python jobs):
|
||||
//! `cargo test -p ide-host --test project_smoke -- --ignored`.
|
||||
|
||||
use ide_host::{Host, HostConfig, ops};
|
||||
use ide_index::SymbolFilter;
|
||||
use ide_workspace::roots::RootKind;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const WINDOW: &str = "main";
|
||||
|
||||
struct Fixture {
|
||||
dir: &'static str,
|
||||
/// Detected roots: (project-relative path, kind).
|
||||
roots: &'static [(&'static str, RootKind)],
|
||||
/// Search Everywhere › Files: (query, file found).
|
||||
file: (&'static str, &'static str),
|
||||
/// Go to Class: (name, file declaring it).
|
||||
class: (&'static str, &'static str),
|
||||
/// Go to Symbol: (name, file declaring it).
|
||||
symbol: (&'static str, &'static str),
|
||||
/// A file created while the project is open: (path, text declaring the class `FreshWidget`).
|
||||
added: (&'static str, &'static str),
|
||||
}
|
||||
|
||||
struct Opened {
|
||||
host: Arc<Host>,
|
||||
root: PathBuf,
|
||||
_tmp: tempfile::TempDir,
|
||||
}
|
||||
|
||||
fn copy_dir(from: &Path, to: &Path) {
|
||||
std::fs::create_dir_all(to).unwrap();
|
||||
for entry in std::fs::read_dir(from).unwrap().flatten() {
|
||||
let (src, dst) = (entry.path(), to.join(entry.file_name()));
|
||||
if entry.file_type().unwrap().is_dir() {
|
||||
copy_dir(&src, &dst);
|
||||
} else {
|
||||
std::fs::copy(&src, &dst).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn open(fixture: &str) -> Opened {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap().join(fixture);
|
||||
copy_dir(&Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures").join(fixture), &root);
|
||||
let app = tmp.path().join("app");
|
||||
let host = Arc::new(Host::new(HostConfig {
|
||||
config_dir: app.join("config"),
|
||||
data_dir: app.join("data"),
|
||||
local_data_dir: app.join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(|_| {})),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
}));
|
||||
host.trust.decide(&root, true, false).unwrap();
|
||||
ops::project::open(&host, WINDOW, &root).await.unwrap();
|
||||
Opened { host, root, _tmp: tmp }
|
||||
}
|
||||
|
||||
/// Polls `check` until it holds; fails with its last answer.
|
||||
async fn until<T: std::fmt::Debug>(what: &str, timeout: Duration, mut check: impl FnMut() -> Result<(), T>) {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
match check() {
|
||||
Ok(()) => return,
|
||||
Err(last) if Instant::now() > deadline => panic!("{what}: timed out after {timeout:?}; last: {last:?}"),
|
||||
Err(_) => tokio::time::sleep(Duration::from_millis(100)).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Opened {
|
||||
fn files(&self, query: &str) -> Vec<String> {
|
||||
let found = ops::nav::find_files(&self.host, WINDOW, query.into(), 20).unwrap();
|
||||
found.into_iter().map(|f| f.rel_path).collect()
|
||||
}
|
||||
|
||||
fn symbols(&self, query: &str, filter: SymbolFilter) -> Vec<(String, String)> {
|
||||
let found = ops::nav::find_symbols(&self.host, WINDOW, query.into(), filter, 20).unwrap();
|
||||
found.into_iter().map(|s| (s.name, s.rel_path)).collect()
|
||||
}
|
||||
|
||||
fn has_symbol(&self, name: &str, file: &str, filter: SymbolFilter) -> Result<(), Vec<(String, String)>> {
|
||||
let found = self.symbols(name, filter);
|
||||
if found.iter().any(|(n, f)| n == name && f == file) { Ok(()) } else { Err(found) }
|
||||
}
|
||||
}
|
||||
|
||||
async fn smoke(f: Fixture) {
|
||||
let p = open(f.dir).await;
|
||||
until("indexing", Duration::from_secs(30), || {
|
||||
let s = ops::nav::index_status(&p.host, WINDOW).unwrap();
|
||||
if !s.indexing && s.files > 0 && s.symbols > 0 { Ok(()) } else { Err(s) }
|
||||
})
|
||||
.await;
|
||||
|
||||
let roots = ops::project::roots(&p.host, WINDOW).unwrap().roots;
|
||||
for (path, kind) in f.roots {
|
||||
assert!(
|
||||
roots.iter().any(|r| r.path == *path && r.kind == *kind),
|
||||
"{}: no {kind:?} root {path:?} in {roots:?}",
|
||||
f.dir
|
||||
);
|
||||
}
|
||||
|
||||
let files = p.files(f.file.0);
|
||||
assert!(files.iter().any(|r| r == f.file.1), "{}: files for {:?}: {files:?}", f.dir, f.file.0);
|
||||
if let Err(found) = p.has_symbol(f.class.0, f.class.1, SymbolFilter::Types) {
|
||||
panic!("{}: Go to Class {:?}: {found:?}", f.dir, f.class.0);
|
||||
}
|
||||
if let Err(found) = p.has_symbol(f.symbol.0, f.symbol.1, SymbolFilter::All) {
|
||||
panic!("{}: Go to Symbol {:?}: {found:?}", f.dir, f.symbol.0);
|
||||
}
|
||||
|
||||
// The index follows the file system while the project is open.
|
||||
let (added, text) = f.added;
|
||||
std::fs::write(p.root.join(added), text).unwrap();
|
||||
until("a new file's class", Duration::from_secs(20), || p.has_symbol("FreshWidget", added, SymbolFilter::Types))
|
||||
.await;
|
||||
std::fs::remove_file(p.root.join(added)).unwrap();
|
||||
until("a deleted file's class", Duration::from_secs(20), || {
|
||||
let found = p.symbols("FreshWidget", SymbolFilter::Types);
|
||||
if found.is_empty() { Ok(()) } else { Err(found) }
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn rust_app() {
|
||||
smoke(Fixture {
|
||||
dir: "rust-app",
|
||||
roots: &[("src", RootKind::Source), ("tests", RootKind::Test)],
|
||||
file: ("pars", "src/parser.rs"),
|
||||
class: ("Inventory", "src/lib.rs"),
|
||||
symbol: ("low_stock", "src/lib.rs"),
|
||||
added: ("src/fresh.rs", "pub struct FreshWidget;\n"),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn ts_app() {
|
||||
smoke(Fixture {
|
||||
dir: "ts-app",
|
||||
roots: &[("src", RootKind::Source), ("tests", RootKind::Test)],
|
||||
file: ("cart", "src/cart.ts"),
|
||||
class: ("Cart", "src/cart.ts"),
|
||||
symbol: ("formatMoney", "src/money.ts"),
|
||||
added: ("src/fresh.ts", "export class FreshWidget {}\n"),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn python_app() {
|
||||
smoke(Fixture {
|
||||
dir: "python-app",
|
||||
roots: &[("src", RootKind::Source), ("tests", RootKind::Test)],
|
||||
file: ("models", "src/tasks/models.py"),
|
||||
class: ("TaskList", "src/tasks/models.py"),
|
||||
symbol: ("build_demo", "src/tasks/cli.py"),
|
||||
added: ("src/tasks/fresh.py", "class FreshWidget:\n pass\n"),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn go_app() {
|
||||
smoke(Fixture {
|
||||
dir: "go-app",
|
||||
roots: &[("", RootKind::Source)],
|
||||
file: ("store", "internal/store/store.go"),
|
||||
class: ("Store", "internal/store/store.go"),
|
||||
symbol: ("writeJSON", "cmd/server/main.go"),
|
||||
added: ("internal/store/fresh.go", "package store\n\ntype FreshWidget struct{}\n"),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn java_app() {
|
||||
smoke(Fixture {
|
||||
dir: "java-app",
|
||||
roots: &[("src/main/java", RootKind::Source), ("src/test/java", RootKind::Test)],
|
||||
file: ("OrderBook", "src/main/java/com/example/orders/OrderBook.java"),
|
||||
class: ("OrderBook", "src/main/java/com/example/orders/OrderBook.java"),
|
||||
symbol: ("revenueCents", "src/main/java/com/example/orders/OrderBook.java"),
|
||||
added: (
|
||||
"src/main/java/com/example/orders/FreshWidget.java",
|
||||
"package com.example.orders;\n\npublic class FreshWidget {}\n",
|
||||
),
|
||||
})
|
||||
.await;
|
||||
let p = open("java-app").await;
|
||||
let model = ops::build::build_model(&p.host, WINDOW).await.unwrap();
|
||||
let maven = model.maven.expect("a Maven model");
|
||||
assert_eq!(maven.projects.iter().map(|m| m.artifact_id.as_str()).collect::<Vec<_>>(), ["openidea-fixture-orders"]);
|
||||
assert!(maven.projects[0].dependencies.iter().any(|d| d.artifact_id == "junit-jupiter"), "{maven:?}");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn kotlin_gradle() {
|
||||
smoke(Fixture {
|
||||
dir: "kotlin-gradle",
|
||||
roots: &[("src/main/kotlin", RootKind::Source), ("src/test/kotlin", RootKind::Test)],
|
||||
file: ("Library", "src/main/kotlin/com/example/library/Library.kt"),
|
||||
class: ("Library", "src/main/kotlin/com/example/library/Library.kt"),
|
||||
symbol: ("giveBack", "src/main/kotlin/com/example/library/Library.kt"),
|
||||
added: ("src/main/kotlin/com/example/library/Fresh.kt", "package com.example.library\n\nclass FreshWidget\n"),
|
||||
})
|
||||
.await;
|
||||
let p = open("kotlin-gradle").await;
|
||||
let model = ops::build::build_model(&p.host, WINDOW).await.unwrap();
|
||||
let gradle = model.gradle.expect("a Gradle model");
|
||||
assert_eq!(gradle.projects.iter().map(|g| g.name.as_str()).collect::<Vec<_>>(), ["openidea-fixture-library"]);
|
||||
}
|
||||
|
||||
/// Maven / Gradle sync (Reload in the build tool window): the build's own model.
|
||||
async fn sync(fixture: &str, tool: ide_build::BuildTool) -> ide_build::BuildModel {
|
||||
let p = open(fixture).await;
|
||||
let progress = ops::Channel::new(|_: ide_build::SyncProgress| {});
|
||||
let model =
|
||||
ops::build::build_sync(&p.host, WINDOW, tool, ide_core::RequestId(1), false, vec![], progress).await.unwrap();
|
||||
// The cached sync is what the next open shows.
|
||||
let reloaded = ops::build::build_model(&p.host, WINDOW).await.unwrap();
|
||||
assert_eq!(reloaded, model);
|
||||
model
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[ignore = "project smoke: needs Maven and a JDK"]
|
||||
async fn maven_sync() {
|
||||
let maven = sync("java-app", ide_build::BuildTool::Maven).await.maven.unwrap();
|
||||
assert_eq!(maven.sync_error, None);
|
||||
assert!(maven.synced_at.is_some());
|
||||
let junit = maven.projects[0].dependencies.iter().find(|d| d.artifact_id == "junit-jupiter").unwrap();
|
||||
// The resolved tree, not only the declaration.
|
||||
assert!(!junit.children.is_empty(), "{junit:?}");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[ignore = "project smoke: needs Gradle and a JDK"]
|
||||
async fn gradle_sync() {
|
||||
let gradle = sync("kotlin-gradle", ide_build::BuildTool::Gradle).await.gradle.unwrap();
|
||||
assert_eq!(gradle.sync_error, None);
|
||||
assert!(gradle.synced_at.is_some());
|
||||
let tasks: Vec<&str> = gradle.projects[0].tasks.iter().map(|t| t.name.as_str()).collect();
|
||||
// The build's own tasks: the application and Kotlin plugins' too.
|
||||
for task in ["run", "compileKotlin", "test"] {
|
||||
assert!(tasks.contains(&task), "no {task} in {tasks:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[ignore = "project smoke: needs python3"]
|
||||
async fn python_venv() {
|
||||
let p = open("python-app").await;
|
||||
let made = std::process::Command::new("python3").args(["-m", "venv", ".venv"]).current_dir(&p.root).status();
|
||||
assert!(made.unwrap().success());
|
||||
// The project's virtualenv: listed first, and the interpreter the project uses.
|
||||
let found = ops::sdk::sdk_detect(&p.host, WINDOW, ide_sdk::SdkKind::Python).await.unwrap();
|
||||
assert_eq!(found.first().map(|s| s.source.as_str()), Some("venv"), "{found:?}");
|
||||
let effective = ops::sdk::sdk_python_effective(&p.host, WINDOW).unwrap().expect("an interpreter");
|
||||
assert!(effective.starts_with(p.root.join(".venv")), "{effective:?}");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[ignore = "project smoke: needs JDK 21"]
|
||||
async fn jdk() {
|
||||
let p = open("java-app").await;
|
||||
let found = ops::sdk::sdk_detect(&p.host, WINDOW, ide_sdk::SdkKind::Jdk).await.unwrap();
|
||||
assert!(found.iter().any(|s| s.version.as_deref().is_some_and(|v| v.starts_with("21"))), "{found:?}");
|
||||
}
|
||||
@@ -43,6 +43,7 @@ fn walk(dir: &std::path::Path) -> Vec<std::path::PathBuf> {
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn manual_proxy_applies_and_the_password_is_saved_apart() {
|
||||
ide_ssh::secrets::testing::use_memory_store();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let host = host(tmp.path());
|
||||
assert_eq!(proxy().mode, ProxyMode::Auto);
|
||||
@@ -61,7 +62,7 @@ async fn manual_proxy_applies_and_the_password_is_saved_apart() {
|
||||
|
||||
assert!(!settings_files(tmp.path()).contains("s3cret"), "no password in settings.toml");
|
||||
let saved = std::fs::read_to_string(tmp.path().join("data/proxy-password.json")).unwrap();
|
||||
assert!(saved.contains("s3cret"));
|
||||
assert!(!saved.contains("s3cret"), "the password is in the credential store, not on disk: {saved}");
|
||||
assert_eq!(ops::project::settings(&host, "main").proxy.password, "s3cret");
|
||||
|
||||
// A whole-table write keeps the password out of the file too; an empty one forgets it.
|
||||
|
||||
@@ -141,3 +141,19 @@ async fn workspace_cmake_before_launch_tasks_resolve_in_the_member() {
|
||||
assert!(configs.iter().any(|c| c.id == task.target && c.cwd == build.cwd));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn gradle_init_scripts_are_written_by_the_host_that_runs_the_build() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let host = host(tmp.path());
|
||||
let text = "allprojects { }\n".to_string();
|
||||
let path =
|
||||
ops::run::run_gradle_init_script(&host, "main", "openidea-gradle-tests".into(), text.clone()).await.unwrap();
|
||||
assert!(path.contains("openidea-gradle-tests-"), "{path}");
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), text);
|
||||
let again = ops::run::run_gradle_init_script(&host, "main", "openidea-gradle-tests".into(), text).await.unwrap();
|
||||
assert_eq!(again, path);
|
||||
// A name is not a path.
|
||||
let escaped = ops::run::run_gradle_init_script(&host, "main", "../escape".into(), String::new()).await;
|
||||
assert!(escaped.is_err(), "{escaped:?}");
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
use ide_core::{NullSink, RequestId};
|
||||
use ide_host::{Host, HostConfig, ops};
|
||||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn opens_unity_without_generated_solutions_and_excludes_caches_from_the_index() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let base = dunce::canonicalize(temp.path()).unwrap();
|
||||
let root = base.join("Game");
|
||||
for name in ["Assets/Scripts", "Packages", "ProjectSettings", "Library/PackageCache", "Temp", "Logs"] {
|
||||
std::fs::create_dir_all(root.join(name)).unwrap();
|
||||
}
|
||||
std::fs::write(root.join("ProjectSettings/ProjectVersion.txt"), "m_EditorVersion: 6000.0.62f1\n").unwrap();
|
||||
std::fs::write(root.join("Assets/Scripts/Player.cs"), "class Player {}\n").unwrap();
|
||||
std::fs::write(root.join("Packages/manifest.json"), "{}\n").unwrap();
|
||||
std::fs::write(root.join("Library/PackageCache/Cached.cs"), "class Cached {}\n").unwrap();
|
||||
std::fs::write(root.join("Temp/Temporary.cs"), "class Temporary {}\n").unwrap();
|
||||
std::fs::write(root.join("Logs/Editor.log"), "log\n").unwrap();
|
||||
let host = Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
data_dir: base.join("data"),
|
||||
local_data_dir: base.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: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
});
|
||||
let project = host.open_project("main", &root).unwrap();
|
||||
let info = ops::project::info(&project);
|
||||
assert_eq!(info.name, "Game");
|
||||
let unity = info.unity.unwrap();
|
||||
assert_eq!(unity.editor_version.as_deref(), Some("6000.0.62f1"));
|
||||
assert!(unity.projects.is_empty() && unity.solutions.is_empty());
|
||||
assert!(project.index.wait_idle(Duration::from_secs(20)));
|
||||
assert_eq!(project.index.find_files("Player.cs", 100).len(), 1);
|
||||
for name in ["Cached.cs", "Temporary.cs", "Editor.log"] {
|
||||
assert!(project.index.find_files(name, 100).is_empty(), "indexed generated file {name}");
|
||||
}
|
||||
let hits = Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
let out = hits.clone();
|
||||
let query = serde_json::from_value(serde_json::json!({
|
||||
"pattern": "class", "isRegex": false, "caseSensitive": false, "wholeWord": false,
|
||||
"include": [], "exclude": [], "scope": { "kind": "project" }, "maxMatches": 100,
|
||||
}))
|
||||
.unwrap();
|
||||
ops::search::start(
|
||||
&host,
|
||||
"main",
|
||||
RequestId::next(),
|
||||
query,
|
||||
Arc::new(move |event| {
|
||||
if let ops::search::SearchEvent::File { file } = event {
|
||||
out.lock().unwrap().push(file.rel_path);
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(*hits.lock().unwrap(), ["Assets/Scripts/Player.cs"]);
|
||||
let listing = ops::fs::read_dir(&host, "main", root.clone()).await.unwrap();
|
||||
assert!(listing.iter().any(|e| e.name == "Library" && e.excluded));
|
||||
assert!(project.exclude_globs().contains(&"/Obj".to_owned()));
|
||||
|
||||
// Project metadata refreshes without reopening when Unity generates its solution.
|
||||
std::fs::write(root.join("Game.sln"), "").unwrap();
|
||||
std::fs::write(root.join("Assembly-CSharp.csproj"), "").unwrap();
|
||||
let unity = ops::project::info(&project).unity.unwrap();
|
||||
assert_eq!(unity.solutions, ["Game.sln"]);
|
||||
assert_eq!(unity.projects, ["Assembly-CSharp.csproj"]);
|
||||
|
||||
// Desktop commands and remote RPC share these host operations.
|
||||
let original = root.join("Assets/Scripts/Player.cs");
|
||||
let renamed = root.join("Assets/Scripts/Hero.cs");
|
||||
let copied = root.join("Assets/Scripts/HeroCopy.cs");
|
||||
let meta = |path: &std::path::Path| -> std::path::PathBuf {
|
||||
let mut name = path.as_os_str().to_os_string();
|
||||
name.push(".meta");
|
||||
name.into()
|
||||
};
|
||||
let original_metadata = "fileFormatVersion: 2\nguid: 11111111111111111111111111111111\n";
|
||||
std::fs::write(meta(&original), original_metadata).unwrap();
|
||||
ops::fs::rename(&host, original.clone(), renamed.clone()).await.unwrap();
|
||||
assert!(!original.exists() && !meta(&original).exists());
|
||||
assert_eq!(std::fs::read_to_string(meta(&renamed)).unwrap(), original_metadata);
|
||||
ops::fs::copy(&host, renamed.clone(), copied.clone()).await.unwrap();
|
||||
assert!(copied.exists());
|
||||
assert_ne!(std::fs::read_to_string(meta(&copied)).unwrap(), original_metadata);
|
||||
assert_eq!(std::fs::read_to_string(meta(&renamed)).unwrap(), original_metadata);
|
||||
host.close_project("main");
|
||||
|
||||
let ordinary = base.join("ConsoleApp");
|
||||
std::fs::create_dir(&ordinary).unwrap();
|
||||
std::fs::write(ordinary.join("App.csproj"), "").unwrap();
|
||||
let project = host.open_project("main", &ordinary).unwrap();
|
||||
assert!(ops::project::info(&project).unity.is_none());
|
||||
assert!(!project.exclude_globs().contains(&"/Library".to_owned()));
|
||||
host.close_project("main");
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
//! A day with Git on a `fixtures/` project, through the `ops` the app's commands call: an edit
|
||||
//! marked in the gutter, Local Changes and its diff, a commit in the log, a branch, a stash, and
|
||||
//! a merge that conflicts, resolved and committed. Open documents follow every step.
|
||||
|
||||
use ide_core::DocId;
|
||||
use ide_host::{Host, HostConfig, ops};
|
||||
use ide_vcs::{ChangeKind, CommitOptions, FileStatus, HunkKind, LogQuery};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
const WINDOW: &str = "main";
|
||||
/// The line every branch changes, in `src/lib.rs`.
|
||||
const LINE: &str = "pub fn format_price(cents: u64) -> String {";
|
||||
|
||||
fn git(root: &Path, args: &[&str]) -> String {
|
||||
let out = Command::new("git").current_dir(root).args(args).output().unwrap();
|
||||
assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
|
||||
String::from_utf8(out.stdout).unwrap()
|
||||
}
|
||||
|
||||
fn copy_dir(from: &Path, to: &Path) {
|
||||
std::fs::create_dir_all(to).unwrap();
|
||||
for entry in std::fs::read_dir(from).unwrap().flatten() {
|
||||
let (src, dst) = (entry.path(), to.join(entry.file_name()));
|
||||
if entry.file_type().unwrap().is_dir() {
|
||||
copy_dir(&src, &dst);
|
||||
} else {
|
||||
std::fs::copy(&src, &dst).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Flow {
|
||||
host: Arc<Host>,
|
||||
root: PathBuf,
|
||||
_tmp: tempfile::TempDir,
|
||||
}
|
||||
|
||||
impl Flow {
|
||||
async fn new(fixture: &str) -> Self {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let base = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let root = base.join(fixture);
|
||||
copy_dir(&Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures").join(fixture), &root);
|
||||
git(&root, &["init", "-q", "-b", "main"]);
|
||||
for (key, value) in [
|
||||
("user.name", "Test"),
|
||||
("user.email", "[email protected]"),
|
||||
("commit.gpgsign", "false"),
|
||||
("core.autocrlf", "false"),
|
||||
] {
|
||||
git(&root, &["config", key, value]);
|
||||
}
|
||||
git(&root, &["add", "-A"]);
|
||||
git(&root, &["commit", "-q", "-m", "Initial commit"]);
|
||||
let host = Arc::new(Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
data_dir: base.join("data"),
|
||||
local_data_dir: base.join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(|_| {})),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
}));
|
||||
host.trust.decide(&root, true, false).unwrap();
|
||||
let project = ops::project::open(&host, WINDOW, &root).await.unwrap();
|
||||
for _ in 0..500 {
|
||||
if !project.repos().is_empty() {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
Flow { host, root, _tmp: tmp }
|
||||
}
|
||||
|
||||
async fn open(&self, file: &str) -> DocId {
|
||||
ops::doc::open(&self.host, WINDOW, self.root.join(file)).await.unwrap().id
|
||||
}
|
||||
|
||||
async fn text(&self, doc: DocId) -> String {
|
||||
ops::doc::snapshot(&self.host, WINDOW, doc).await.unwrap().text
|
||||
}
|
||||
|
||||
/// Types `with` over `LINE` in the editor (unsaved).
|
||||
async fn edit_line(&self, doc: DocId, with: &str) {
|
||||
let text = self.text(doc).await;
|
||||
let current = text.lines().find(|l| l.starts_with("pub fn format_price")).unwrap().to_owned();
|
||||
let project = self.host.project(WINDOW).unwrap();
|
||||
project.documents.replace_all(doc, &text.replacen(¤t, with, 1)).unwrap();
|
||||
}
|
||||
|
||||
async fn save(&self, doc: DocId) {
|
||||
ops::doc::save(&self.host, WINDOW, doc).await.unwrap();
|
||||
}
|
||||
|
||||
async fn status(&self) -> Vec<FileStatus> {
|
||||
ops::vcs::status(&self.host, WINDOW).await.unwrap()
|
||||
}
|
||||
|
||||
async fn commit(&self, files: &[&str], message: &str) -> String {
|
||||
let paths = files.iter().map(|f| self.root.join(f)).collect();
|
||||
ops::vcs::vcs_stage(&self.host, WINDOW, paths).await.unwrap();
|
||||
let options = CommitOptions { run_hooks: true, ..Default::default() };
|
||||
ops::vcs::vcs_commit(&self.host, WINDOW, None, message.into(), options).await.unwrap()
|
||||
}
|
||||
|
||||
async fn head(&self) -> ide_vcs::Commit {
|
||||
let log = ops::vcs::vcs_log(&self.host, WINDOW, None, LogQuery::default()).await.unwrap();
|
||||
log.into_iter().next().expect("a commit")
|
||||
}
|
||||
}
|
||||
|
||||
fn changed<'a>(status: &'a [FileStatus], file: &str) -> Option<&'a FileStatus> {
|
||||
status.iter().find(|s| s.rel_path == file)
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn change_commit_branch_stash_and_merge_a_conflict() {
|
||||
let f = Flow::new("rust-app").await;
|
||||
let lib_path = f.root.join("src/lib.rs");
|
||||
let lib = f.open("src/lib.rs").await;
|
||||
let original = f.text(lib).await;
|
||||
let line = original.lines().position(|l| l == LINE).expect("the fixture's line") as u32;
|
||||
|
||||
// An edit in the editor: a modified line in the gutter, against HEAD, before saving.
|
||||
let base = "pub fn format_price(cents: u64) -> String { // base";
|
||||
f.edit_line(lib, base).await;
|
||||
let hunks = ops::vcs::vcs_line_hunks(&f.host, WINDOW, lib_path.clone()).await.unwrap();
|
||||
assert!(
|
||||
matches!(&hunks[..], [h] if h.kind == HunkKind::Modified && h.start == line && h.old_text.contains(LINE)),
|
||||
"{hunks:?}"
|
||||
);
|
||||
assert!(f.status().await.is_empty(), "nothing saved yet");
|
||||
|
||||
// Saved: Local Changes lists it, and its diff shows the edit.
|
||||
f.save(lib).await;
|
||||
let status = f.status().await;
|
||||
assert_eq!(changed(&status, "src/lib.rs").and_then(|s| s.unstaged), Some(ChangeKind::Modified), "{status:?}");
|
||||
let diff = ops::vcs::vcs_diff_text(&f.host, WINDOW, lib_path.clone(), false).await.unwrap();
|
||||
assert!(diff.contains(&format!("-{LINE}")) && diff.contains(&format!("+{base}")), "{diff}");
|
||||
|
||||
// Commit: the log's head; nothing left to commit; no gutter marks.
|
||||
let id = f.commit(&["src/lib.rs"], "Mark the base").await;
|
||||
let head = f.head().await;
|
||||
assert_eq!((head.id.as_str(), head.summary.as_str()), (id.as_str(), "Mark the base"));
|
||||
assert!(f.status().await.is_empty());
|
||||
assert!(ops::vcs::vcs_line_hunks(&f.host, WINDOW, lib_path.clone()).await.unwrap().is_empty());
|
||||
|
||||
// A branch changes the line its way.
|
||||
ops::vcs::vcs_create_branch(&f.host, WINDOW, None, "feature".into(), None, true).await.unwrap();
|
||||
let theirs = "pub fn format_price(cents: u64) -> String { // feature";
|
||||
f.edit_line(lib, theirs).await;
|
||||
f.save(lib).await;
|
||||
f.commit(&["src/lib.rs"], "Feature's change").await;
|
||||
let branches = ops::vcs::vcs_branches(&f.host, WINDOW, None).await.unwrap();
|
||||
assert!(branches.iter().any(|b| b.name == "feature" && b.current), "{branches:?}");
|
||||
|
||||
// Back on main, the open file shows main's text.
|
||||
ops::vcs::vcs_checkout(&f.host, WINDOW, None, "main".into(), false, None).await.unwrap();
|
||||
assert!(f.text(lib).await.contains(base));
|
||||
|
||||
// Stash: work in progress put aside and brought back.
|
||||
let main_rs = f.open("src/main.rs").await;
|
||||
let wip = format!("{}// work in progress\n", f.text(main_rs).await);
|
||||
f.host.project(WINDOW).unwrap().documents.replace_all(main_rs, &wip).unwrap();
|
||||
f.save(main_rs).await;
|
||||
ops::vcs::vcs_stash_push(&f.host, WINDOW, None, Some("wip".into()), false, false).await.unwrap();
|
||||
assert!(f.status().await.is_empty());
|
||||
assert!(!f.text(main_rs).await.contains("work in progress"));
|
||||
let stashes = ops::vcs::vcs_stashes(&f.host, WINDOW, None).await.unwrap();
|
||||
assert!(matches!(&stashes[..], [s] if s.message.contains("wip")), "{stashes:?}");
|
||||
ops::vcs::vcs_stash_apply(&f.host, WINDOW, None, 0, true, false, None).await.unwrap();
|
||||
assert_eq!(f.text(main_rs).await, wip);
|
||||
assert!(ops::vcs::vcs_stashes(&f.host, WINDOW, None).await.unwrap().is_empty());
|
||||
ops::vcs::vcs_discard(&f.host, WINDOW, vec![f.root.join("src/main.rs")]).await.unwrap();
|
||||
assert!(f.status().await.is_empty());
|
||||
|
||||
// Main changes the same line: merging the branch conflicts.
|
||||
let ours = "pub fn format_price(cents: u64) -> String { // main";
|
||||
f.edit_line(lib, ours).await;
|
||||
f.save(lib).await;
|
||||
f.commit(&["src/lib.rs"], "Main's change").await;
|
||||
let merged = ops::vcs::vcs_merge(&f.host, WINDOW, None, "feature".into()).await;
|
||||
let status = f.status().await;
|
||||
let conflicted = changed(&status, "src/lib.rs").map(|s| (s.staged, s.unstaged));
|
||||
assert!(
|
||||
matches!(conflicted, Some((Some(ChangeKind::Conflicted), _) | (_, Some(ChangeKind::Conflicted)))),
|
||||
"merge: {merged:?}; status: {status:?}"
|
||||
);
|
||||
// The open file shows the conflict as Git left it.
|
||||
assert!(f.text(lib).await.contains("<<<<<<<"), "{}", f.text(lib).await);
|
||||
let versions = ops::vcs::vcs_conflict_versions(&f.host, WINDOW, lib_path.clone()).await.unwrap();
|
||||
let has = |side: &Option<String>, line: &str| side.as_deref().is_some_and(|t| t.contains(line));
|
||||
assert!(has(&versions.base, base) && has(&versions.ours, ours) && has(&versions.theirs, theirs), "{versions:?}");
|
||||
|
||||
// Resolved (Accept Theirs) and committed: a merge commit, nothing left.
|
||||
let resolved = versions.theirs.clone().unwrap();
|
||||
f.host.project(WINDOW).unwrap().documents.replace_all(lib, &resolved).unwrap();
|
||||
f.save(lib).await;
|
||||
f.commit(&["src/lib.rs"], "Merge feature").await;
|
||||
let head = f.head().await;
|
||||
assert_eq!(head.parents.len(), 2, "{head:?}");
|
||||
assert!(f.status().await.is_empty());
|
||||
assert_eq!(std::fs::read_to_string(&lib_path).unwrap(), resolved);
|
||||
}
|
||||
@@ -175,3 +175,66 @@ async fn contended_version_reads_do_not_block_the_async_worker() {
|
||||
assert!(watchdog.join().unwrap(), "request {request_kind} blocked the only async worker");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn code_actions_skip_a_command_no_server_provides_after_their_edit() {
|
||||
use ide_lsp::types::{CodeAction, Command};
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let file = tmp.path().join("imports.txt");
|
||||
std::fs::write(&file, "unused\nused\n").unwrap();
|
||||
let host = Host::new(HostConfig {
|
||||
config_dir: tmp.path().join("config"),
|
||||
data_dir: tmp.path().join("data"),
|
||||
local_data_dir: tmp.path().join("local-data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(|_| {})),
|
||||
owner: |window| window,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
});
|
||||
host.open_light("main", &file).unwrap();
|
||||
let info = ops::doc::open(&host, "main", file.clone()).await.unwrap();
|
||||
// Optimize Imports as vtsls offers it: the edit, then a command its client runs itself.
|
||||
let action = |edit: bool| CodeAction {
|
||||
title: "Organize Imports".into(),
|
||||
kind: Some("source.organizeImports".into()),
|
||||
is_preferred: false,
|
||||
disabled_reason: None,
|
||||
edit: edit.then(|| {
|
||||
serde_json::from_value(json!({ "files": [{ "kind": "edit", "path": file, "edits": [
|
||||
{ "range": { "start": { "line": 0, "col": 0 }, "end": { "line": 1, "col": 0 } }, "new_text": "" }
|
||||
] }] }))
|
||||
.unwrap()
|
||||
}),
|
||||
command: Some(Command {
|
||||
title: String::new(),
|
||||
command: "_typescript.didOrganizeImports".into(),
|
||||
arguments: vec![],
|
||||
}),
|
||||
data: None,
|
||||
server: ide_core::ServerId(1),
|
||||
};
|
||||
let follow_up = action(true).command.unwrap();
|
||||
let asked = ops::lsp::lsp_resolve_command(
|
||||
&host,
|
||||
"main",
|
||||
ide_core::RequestId(1),
|
||||
info.id,
|
||||
ide_core::ServerId(1),
|
||||
follow_up,
|
||||
vec![],
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(asked, Ok(None)), "{asked:?}");
|
||||
ops::lsp::lsp_execute_code_action(&host, "main", ide_core::RequestId(2), info.id, action(true), vec![])
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(ops::doc::snapshot(&host, "main", info.id).await.unwrap().text, "used\n");
|
||||
ops::lsp::lsp_apply_code_action(&host, "main", info.id, action(true)).await.unwrap();
|
||||
assert_eq!(ops::doc::snapshot(&host, "main", info.id).await.unwrap().text, "");
|
||||
// Without an edit the command is the action: it still can't run.
|
||||
let ran =
|
||||
ops::lsp::lsp_execute_code_action(&host, "main", ide_core::RequestId(3), info.id, action(false), vec![]).await;
|
||||
assert!(ran.is_err(), "{ran:?}");
|
||||
}
|
||||
@@ -114,3 +114,54 @@ async fn linked_projects_share_navigation_but_keep_files_repositories_and_run_di
|
||||
assert_eq!(automatic.index.find_files("same.txt", 100).len(), 1);
|
||||
host.close_project("main");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn php_language_level_follows_the_member_of_the_file() {
|
||||
use ide_sdk::php::{PhpLanguageLevel, PhpLevelSource};
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let base = dunce::canonicalize(temp.path()).unwrap();
|
||||
let (api, legacy) = (base.join("api"), base.join("legacy"));
|
||||
for (member, composer) in [
|
||||
(&api, r#"{"require": {"php": "^7.4"}}"#),
|
||||
(&legacy, r#"{"require": {"php": "^7.4"}, "config": {"platform": {"php": "8.0.5"}}}"#),
|
||||
] {
|
||||
std::fs::create_dir(member).unwrap();
|
||||
std::fs::write(member.join("composer.json"), composer).unwrap();
|
||||
}
|
||||
let root = base.join("workspace");
|
||||
Workspace::save(&root, "PHP", vec![api.clone(), legacy.clone()], true).unwrap();
|
||||
std::fs::write(root.join("composer.json"), r#"{"require": {"php": "^8.2"}}"#).unwrap();
|
||||
let config_dir = base.join("config");
|
||||
ide_workspace::SettingsStore::load(&config_dir, Some(&legacy))
|
||||
.set(ide_workspace::SettingsScope::Project, "languages.php.languageLevel", "8.3".into())
|
||||
.unwrap();
|
||||
let host = Host::new(HostConfig {
|
||||
config_dir,
|
||||
data_dir: base.join("data"),
|
||||
local_data_dir: base.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: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
});
|
||||
host.trust.decide(&root, true, false).unwrap();
|
||||
host.open_project("main", &root).unwrap();
|
||||
let found = |version: &str, source| Some(PhpLanguageLevel { version: version.into(), source });
|
||||
let level = async |path: std::path::PathBuf| ops::sdk::sdk_php_language_level(&host, "main", path).await.unwrap();
|
||||
|
||||
let level_of_api = level(api.join("src/Invoice.php")).await;
|
||||
assert_eq!(level_of_api.effective, found("7.4.0", PhpLevelSource::Require));
|
||||
assert_eq!(level_of_api.auto, level_of_api.effective);
|
||||
assert_eq!(level_of_api.member.as_deref(), Some(api.as_path()));
|
||||
// The member's own settings, not the workspace's.
|
||||
let level_of_legacy = level(legacy.join("index.php")).await;
|
||||
assert_eq!(level_of_legacy.effective, found("8.3.0", PhpLevelSource::Settings));
|
||||
assert_eq!(level_of_legacy.auto, found("8.0.5", PhpLevelSource::Platform));
|
||||
// Outside every member: the workspace's own.
|
||||
let level_elsewhere = level(base.join("elsewhere.php")).await;
|
||||
assert_eq!(level_elsewhere.effective, found("8.2.0", PhpLevelSource::Require));
|
||||
assert_eq!(level_elsewhere.member, None);
|
||||
host.close_project("main");
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use ide_language::{LanguageRegistry, Syntax};
|
||||
use parking_lot::Mutex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::Ordering;
|
||||
@@ -64,6 +65,14 @@ pub struct IndexStatus {
|
||||
pub symbols: u32,
|
||||
}
|
||||
|
||||
/// How many indexed files share an extension (lower case, without the dot).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExtensionCount {
|
||||
pub extension: String,
|
||||
pub files: u32,
|
||||
}
|
||||
|
||||
pub struct ProjectIndex {
|
||||
shared: Arc<Shared>,
|
||||
worker: Mutex<Option<JoinHandle<()>>>,
|
||||
@@ -159,6 +168,27 @@ impl ProjectIndex {
|
||||
}
|
||||
}
|
||||
|
||||
/// The extensions of the indexed files, most common first: the project's file
|
||||
/// types, for suggestions such as Find in Files' file masks. Dotfiles
|
||||
/// (`.gitignore`) have no extension.
|
||||
pub fn file_extensions(&self) -> Vec<ExtensionCount> {
|
||||
let d = self.shared.data.read();
|
||||
let mut counts: HashMap<String, u32> = HashMap::new();
|
||||
for file in d.files.iter().flatten() {
|
||||
let name = &file.rel[file.name_start as usize..];
|
||||
if let Some((stem, extension)) = name.rsplit_once('.')
|
||||
&& !stem.is_empty()
|
||||
&& !extension.is_empty()
|
||||
{
|
||||
*counts.entry(extension.to_ascii_lowercase()).or_default() += 1;
|
||||
}
|
||||
}
|
||||
let mut out: Vec<ExtensionCount> =
|
||||
counts.into_iter().map(|(extension, files)| ExtensionCount { extension, files }).collect();
|
||||
out.sort_by(|a, b| b.files.cmp(&a.files).then_with(|| a.extension.cmp(&b.extension)));
|
||||
out
|
||||
}
|
||||
|
||||
/// Fuzzy file search. IDEA-like ranking: file-name matches beat directory
|
||||
/// matches; `/` in the query matches path segments; recently opened files boost.
|
||||
pub fn find_files(&self, query: &str, limit: usize) -> Vec<FileMatch> {
|
||||
|
||||
@@ -111,6 +111,28 @@ fn indexes_files_and_symbols_honouring_excludes() {
|
||||
assert_eq!(m.path, fx.root.join("src/main.rs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn counts_file_extensions_most_common_first() {
|
||||
let fx = Fixture::new(&[
|
||||
("src/a.rs", "fn a() {}\n"),
|
||||
("src/b.RS", "fn b() {}\n"),
|
||||
("src/lib.rs", "fn c() {}\n"),
|
||||
("Cargo.toml", "[package]\n"),
|
||||
("README.md", "# r\n"),
|
||||
("docs/guide.md", "# g\n"),
|
||||
(".gitignore", "target\n"),
|
||||
("Makefile", "all:\n"),
|
||||
("target/debug/gen.rs", "fn generated() {}\n"),
|
||||
]);
|
||||
let idx = fx.open();
|
||||
let counts: Vec<(String, u32)> = idx.file_extensions().into_iter().map(|c| (c.extension, c.files)).collect();
|
||||
assert_eq!(
|
||||
counts,
|
||||
[("rs".into(), 3), ("md".into(), 2), ("toml".into(), 1)],
|
||||
"case-folded, excluded folders and extension-less files left out"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_ranking_is_idea_like() {
|
||||
let fx = sample();
|
||||
|
||||
@@ -23,6 +23,7 @@ mod scope;
|
||||
mod server;
|
||||
mod tcp;
|
||||
mod text;
|
||||
mod ts_file_moves;
|
||||
mod tsserver_bridge;
|
||||
pub mod types;
|
||||
mod uri;
|
||||
@@ -1692,6 +1693,20 @@ impl LspManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether a server of `doc` (`server`, when given) provides the command `name`. Code actions
|
||||
/// can carry commands other editors' extensions run themselves after the edit (vtsls'
|
||||
/// `_typescript.didOrganizeImports`): no server provides those.
|
||||
pub async fn provides_command(&self, doc: DocId, server: Option<ide_core::ServerId>, name: &str) -> bool {
|
||||
let servers = match self.snapshot(doc) {
|
||||
Ok(snap) => snap.servers,
|
||||
Err(_) => self.servers.lock().clone(),
|
||||
};
|
||||
self.ready_targets(&servers, |c| c.commands().iter().any(|c| c == name))
|
||||
.await
|
||||
.iter()
|
||||
.any(|t| server.is_none_or(|s| t.server.id == s))
|
||||
}
|
||||
|
||||
async fn command_target(&self, doc: DocId, server: ide_core::ServerId, name: &str) -> IdeResult<Target> {
|
||||
let snap = self.snapshot(doc)?;
|
||||
self.ready_targets(&snap.servers, |c| c.commands().iter().any(|c| c == name))
|
||||
@@ -1710,6 +1725,10 @@ impl LspManager {
|
||||
answers: Vec<FormAnswer>,
|
||||
cancel: CancellationToken,
|
||||
) -> IdeResult<Option<CommandForm>> {
|
||||
// Nothing to ask for a command its server doesn't provide (executing it decides).
|
||||
if !self.provides_command(doc, Some(server), &command.command).await {
|
||||
return Ok(None);
|
||||
}
|
||||
let t = self.command_target(doc, server, &command.command).await?;
|
||||
let interactive = t.server.name == "gopls"
|
||||
&& t.caps()
|
||||
@@ -1992,7 +2011,30 @@ impl LspManager {
|
||||
servers.iter().zip(ready).filter_map(|(s, run)| Some(Target { server: s.clone(), run: run? })).collect();
|
||||
let mut out = FileRenameEdits::default();
|
||||
for t in targets {
|
||||
let Some(params) = Self::file_op_params(&t, "willRename", &moves) else { continue };
|
||||
let Some(params) = Self::file_op_params(&t, "willRename", &moves) else {
|
||||
if t.server.config.tsserver_project && t.caps().commands().iter().any(|c| c == ts_file_moves::COMMAND) {
|
||||
out.servers.push(t.server.name.clone());
|
||||
let opened = self.open_for_file_op(&t, &moves);
|
||||
for (from, to, _) in &moves {
|
||||
let r: IdeResult<Value> = self
|
||||
.call(
|
||||
&t,
|
||||
"workspace/executeCommand",
|
||||
ts_file_moves::request(from, to),
|
||||
Some(&cancel),
|
||||
LONG_TIMEOUT,
|
||||
)
|
||||
.await;
|
||||
match r {
|
||||
Ok(result) => out.edit.files.extend(ts_file_moves::edit(&result).files),
|
||||
Err(e) if e.kind == ErrorKind::Cancelled && cancel.is_cancelled() => return Err(e),
|
||||
Err(e) => out.errors.push(e.message),
|
||||
}
|
||||
}
|
||||
drop(opened);
|
||||
}
|
||||
continue;
|
||||
};
|
||||
out.servers.push(t.server.name.clone());
|
||||
let opened = self.open_for_file_op(&t, &moves);
|
||||
let r: IdeResult<Option<lsp::WorkspaceEdit>> =
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
//! Reference updates for moved files from vtsls, which has no `workspace/willRenameFiles`:
|
||||
//! it updates imports itself after `didRenameFiles`, behind a prompt the client answers. The
|
||||
//! IDE asks tsserver for the same edits up front (`getEditsForFileRename`, through
|
||||
//! `typescript.tsserverRequest`), so they join the move's preview and undo, and the server's
|
||||
//! own update is off (`updateImportsOnFileMove.enabled = "never"` in `languages/*.toml`).
|
||||
|
||||
use ide_core::{FileEdit, Pos, Range, TextEdit, WorkspaceEdit};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub(crate) const COMMAND: &str = "typescript.tsserverRequest";
|
||||
|
||||
/// The `typescript.tsserverRequest` arguments asking for the edits of moving `from` to `to`.
|
||||
pub(crate) fn request(from: &std::path::Path, to: &std::path::Path) -> Value {
|
||||
json!({
|
||||
"command": COMMAND,
|
||||
"arguments": ["getEditsForFileRename", { "oldFilePath": from, "newFilePath": to }],
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Location {
|
||||
line: u32,
|
||||
offset: u32,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Change {
|
||||
start: Location,
|
||||
end: Location,
|
||||
new_text: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct FileChanges {
|
||||
file_name: PathBuf,
|
||||
text_changes: Vec<Change>,
|
||||
}
|
||||
|
||||
/// tsserver's `FileCodeEdits[]` (the command's `body`; 1-based lines and UTF-16 offsets) as an edit.
|
||||
pub(crate) fn edit(result: &Value) -> WorkspaceEdit {
|
||||
let body = result.get("body").unwrap_or(result);
|
||||
let files: Vec<FileChanges> = serde_json::from_value(body.clone()).unwrap_or_default();
|
||||
let pos = |l: &Location| Pos::new(l.line.saturating_sub(1), l.offset.saturating_sub(1));
|
||||
WorkspaceEdit {
|
||||
files: files
|
||||
.into_iter()
|
||||
.filter(|f| !f.text_changes.is_empty())
|
||||
.map(|f| FileEdit::Edit {
|
||||
path: f.file_name.into(),
|
||||
edits: f
|
||||
.text_changes
|
||||
.into_iter()
|
||||
.map(|c| TextEdit { range: Range::new(pos(&c.start), pos(&c.end)), new_text: c.new_text })
|
||||
.collect(),
|
||||
version: None,
|
||||
})
|
||||
.collect(),
|
||||
preconditions: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn converts_tsserver_file_code_edits() {
|
||||
let result = json!({ "success": true, "body": [
|
||||
{ "fileName": "/p/src/index.ts", "textChanges": [
|
||||
{ "start": { "line": 2, "offset": 31 }, "end": { "line": 2, "offset": 41 }, "newText": "./lib/money.ts" }
|
||||
] },
|
||||
{ "fileName": "/p/src/unchanged.ts", "textChanges": [] }
|
||||
] });
|
||||
let edit = edit(&result);
|
||||
assert_eq!(edit.files.len(), 1);
|
||||
let FileEdit::Edit { path, edits, version } = &edit.files[0] else { panic!("{edit:?}") };
|
||||
assert_eq!(**path, *std::path::Path::new("/p/src/index.ts"));
|
||||
assert_eq!(
|
||||
edits,
|
||||
&[TextEdit { range: Range::new(Pos::new(1, 30), Pos::new(1, 40)), new_text: "./lib/money.ts".into() }]
|
||||
);
|
||||
assert!(version.is_none());
|
||||
assert!(super::edit(&json!({ "success": false })).files.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -2336,6 +2336,41 @@ async fn will_and_did_rename_files_honour_filters() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn will_rename_files_asks_tsserver_when_the_server_has_no_file_operations() {
|
||||
// vtsls: no `willRename` filters, but tsserver's `getEditsForFileRename` through its command.
|
||||
let mut caps = incremental_caps();
|
||||
caps["executeCommandProvider"] = json!({ "commands": ["typescript.tsserverRequest"] });
|
||||
let h = harness_configured("typescript", "ts", &[("vtsls", caps)], |cfg| cfg.tsserver_project = true);
|
||||
let money = h.root.join("money.ts");
|
||||
let index = h.root.join("index.ts");
|
||||
std::fs::write(&money, "export const one = 1;\n").unwrap();
|
||||
std::fs::write(&index, "import { one } from \"./money\";\n").unwrap();
|
||||
let s = h.launcher.state("vtsls");
|
||||
*s.responder.lock() = Some(Arc::new(move |_, method, p| {
|
||||
(method == "workspace/executeCommand" && p["arguments"][0] == "getEditsForFileRename").then(|| {
|
||||
json!({ "success": true, "body": [{ "fileName": p["arguments"][1]["oldFilePath"].as_str().unwrap().replace("money.ts", "index.ts"),
|
||||
"textChanges": [{ "start": { "line": 1, "offset": 22 }, "end": { "line": 1, "offset": 29 }, "newText": "./lib/money" }] }] })
|
||||
})
|
||||
}));
|
||||
let to = h.root.join("lib/money.ts");
|
||||
let result =
|
||||
h.mgr.will_rename_files(&[(money.clone(), to.clone())], ide_core::CancellationToken::new()).await.unwrap();
|
||||
assert_eq!(result.servers, vec!["vtsls".to_owned()]);
|
||||
assert!(result.errors.is_empty(), "{:?}", result.errors);
|
||||
match &result.edit.files[..] {
|
||||
[FileEdit::Edit { path, edits, version: None }] => {
|
||||
assert_eq!(path, &index);
|
||||
assert_eq!(edits[0].range, rng(0, 21, 0, 28));
|
||||
assert_eq!(edits[0].new_text, "./lib/money");
|
||||
}
|
||||
other => panic!("unexpected edit {other:?}"),
|
||||
}
|
||||
let asked = s.requested("workspace/executeCommand");
|
||||
let asked = asked.iter().find(|r| r["arguments"][0] == "getEditsForFileRename").unwrap();
|
||||
assert_eq!(asked["arguments"][1], json!({ "oldFilePath": money, "newFilePath": to }));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ inspect code
|
||||
|
||||
struct InspectTestHost {
|
||||
|
||||
@@ -105,6 +105,7 @@ pub trait HostApi: Send + Sync {
|
||||
args: Vec<String>,
|
||||
cwd: Option<&Path>,
|
||||
) -> Result<(), HostError>;
|
||||
fn open_location(&self, client: Option<&str>, path: &Path, line: u32, column: u32) -> Result<(), HostError>;
|
||||
/// A setting of the plugin as JSON (project over global over the schema default).
|
||||
fn setting(&self, root: Option<&Path>, key: &str) -> Option<String>;
|
||||
}
|
||||
|
||||
@@ -640,6 +640,21 @@ impl views::Host for StoreData {
|
||||
let title = clip(title, crate::runtime::MAX_TITLE);
|
||||
Ok(self.host.open_terminal(client.as_deref(), title, &program, args, cwd.as_deref()).map_err(to_wit))
|
||||
}
|
||||
|
||||
fn open_location(&mut self, path: String, line: u32, column: u32) -> Fallible<()> {
|
||||
if let Err(e) = self.require(Capability::ProjectRead) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
let Some(root) = self.invocation.workspace_root.clone() else {
|
||||
return Ok(Err(wit::Error::Invalid("no project is open in this window".into())));
|
||||
};
|
||||
let real = match sandbox::resolve_in_root(&root, &path) {
|
||||
Ok(real) => real,
|
||||
Err(e) => return Ok(Err(wit::Error::Invalid(e))),
|
||||
};
|
||||
let client = self.invocation.client.clone();
|
||||
Ok(self.host.open_location(client.as_deref(), &real, line, column).map_err(to_wit))
|
||||
}
|
||||
}
|
||||
|
||||
impl settings::Host for StoreData {
|
||||
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
a50d1eb41ab8dd0c9c8d6bae8266906cd6eb5de8e6f6ca6f681990389472bb5b
|
||||
1f7402e2b1b4602e4073eb61837c3dc8840f9a4f7275d800dbd6fcf6ce5e40c3
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
2fb6aa879562733bf5b7f11dabe8fc6a415c9ef695868fe7845417a4245d2dc8
|
||||
4f6cd5b5e0c12e7dcbccf66a6ad97276bf968bc3ad26f0fd08e1f7765cbfee14
|
||||
@@ -12,6 +12,7 @@ activation = ["on-view:jobs", "on-command:*"]
|
||||
|
||||
[capabilities]
|
||||
required = ["process:jobs"]
|
||||
optional = ["project.read"]
|
||||
|
||||
[[contributes.views]]
|
||||
id = "jobs"
|
||||
|
||||
@@ -64,6 +64,8 @@ impl Views for Jobs {
|
||||
return self.follow();
|
||||
}
|
||||
ViewEvent::Button(b) if b == "follow" => return self.follow(),
|
||||
ViewEvent::Button(b) if b == "log" => return host::open_location("jobs.log", 2, 4),
|
||||
ViewEvent::Button(b) if b == "outside" => return host::open_location("../outside.log", 0, 0),
|
||||
_ => return Ok(()),
|
||||
}
|
||||
host::refresh_view(VIEW);
|
||||
|
||||
@@ -1 +1 @@
|
||||
25b0f419a68a3b1651d3c2774d834b05e40ede52bc5ed8c0eba6d27a204efe82
|
||||
3d4dd1b6365c7fbafdd3c1f99f4405e5ee40fc5e56c97da960adfe7af2e7edb8
|
||||
@@ -143,7 +143,10 @@ impl Mappings {
|
||||
}
|
||||
let (l, rest, _) = best?;
|
||||
// A `..` from the server must not leave the local folder.
|
||||
if rest.split('/').any(|s| s == ".." || s == ".") {
|
||||
if rest.split('/').filter(|s| !s.is_empty()).any(|s| {
|
||||
let mut components = Path::new(s).components();
|
||||
!matches!(components.next(), Some(Component::Normal(_))) || components.next().is_some()
|
||||
}) {
|
||||
return None;
|
||||
}
|
||||
Some(rest.split('/').filter(|s| !s.is_empty()).fold(l.clone(), |p, s| p.join(s)))
|
||||
@@ -307,6 +310,43 @@ mod tests {
|
||||
assert_eq!(local_folder("/src/", &root), Some(root.join("src")));
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn native_anchors_never_replace_the_project_relative_fallback() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("project");
|
||||
let inside = root.join("src");
|
||||
let outside = tmp.path().join("outside");
|
||||
let missing = tmp.path().join("missing");
|
||||
std::fs::create_dir_all(&inside).unwrap();
|
||||
std::fs::create_dir(&outside).unwrap();
|
||||
for slash in ["\\", "/"] {
|
||||
let spelling = |path: &Path| path.to_string_lossy().replace('\\', slash);
|
||||
assert_eq!(local_folder(&spelling(&inside), &root), Some(inside.clone()));
|
||||
assert_eq!(local_folder(&spelling(&outside), &root), None);
|
||||
assert_eq!(local_folder(&spelling(&missing), &root), Some(root.join(posix(&missing))));
|
||||
assert_eq!(local_folder(&format!("{slash}src{slash}"), &root), Some(inside.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_paths_cannot_replace_or_leave_a_local_mapping() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let m = Mappings::new(&server(vec![]), root, "/srv");
|
||||
for remote in ["/srv/../outside", "/srv/./file"] {
|
||||
assert_eq!(m.local(remote), None, "{remote}");
|
||||
}
|
||||
#[cfg(windows)]
|
||||
for remote in
|
||||
["/srv/..\\outside", "/srv/C:\\outside", "/srv/\\outside", "/srv/dir\\..\\outside", "/srv/C:/outside"]
|
||||
{
|
||||
assert_eq!(m.local(remote), None, "{remote}");
|
||||
}
|
||||
assert_eq!(m.local("/srv/dir/file"), Some(root.join("dir/file")));
|
||||
assert_eq!(m.local("/srv"), Some(root.to_path_buf()));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn absolute_symlink_inside_project_resolves_to_the_project_spelling() {
|
||||
|
||||
+82
-10
@@ -5,7 +5,7 @@ use crate::discover::{project_venv_python, workspace_tsdk};
|
||||
use crate::exec::{path_with, search_path};
|
||||
use crate::{angular, godot, pinned, vue};
|
||||
use crate::{bin_dir, exe, venv_of};
|
||||
use ide_core::settings::{LanguageServerSettings, Settings};
|
||||
use ide_core::settings::{InspectionLevel, InspectionSettings, LanguageServerSettings, Settings};
|
||||
use ide_core::version_managers::Tool;
|
||||
use ide_language::{LanguageConfig, LanguageServerConfig};
|
||||
use ide_run::RunConfig;
|
||||
@@ -442,15 +442,41 @@ pub(crate) fn merge_init_options(cfg: &mut LanguageServerConfig, layer: Value) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Editor › Inspections › Java: "Serializable class without 'serialVersionUID'" (off by default,
|
||||
/// as in IntelliJ IDEA). ECJ reports it, so it is a compiler option rather than a filter.
|
||||
pub const SERIAL_VERSION_INSPECTION: &str = "java:missing-serial-version";
|
||||
|
||||
/// ECJ's level for a missing `serialVersionUID`, from the inspection profile in effect.
|
||||
fn missing_serial_version(inspections: &InspectionSettings) -> Option<&'static str> {
|
||||
let o = inspections.active().get(SERIAL_VERSION_INSPECTION)?;
|
||||
if o.enabled != Some(true) {
|
||||
return None;
|
||||
}
|
||||
Some(match o.severity {
|
||||
Some(InspectionLevel::Error) => "error",
|
||||
None | Some(InspectionLevel::Warning) => "warning",
|
||||
Some(_) => "info",
|
||||
})
|
||||
}
|
||||
|
||||
/// Workspace compiler defaults; Eclipse project preferences still take precedence.
|
||||
pub(crate) fn configure_java_compiler(cfg: &mut LanguageServerConfig, data_dir: &Path) {
|
||||
let file = data_dir.join("lsp/jdtls/compiler-defaults.prefs");
|
||||
/// Each variant has its own file, so changing an inspection restarts the server with it.
|
||||
pub fn configure_java_compiler(cfg: &mut LanguageServerConfig, data_dir: &Path, inspections: &InspectionSettings) {
|
||||
const DEFAULTS: &str = "org.eclipse.jdt.core.compiler.problem.forbiddenReference=warning\n\
|
||||
org.eclipse.jdt.core.compiler.problem.discouragedReference=warning\n";
|
||||
let serial = missing_serial_version(inspections);
|
||||
let (name, content) = match serial {
|
||||
None => ("compiler-defaults.prefs".to_owned(), DEFAULTS.to_owned()),
|
||||
Some(level) => (
|
||||
format!("compiler-defaults-serial-{level}.prefs"),
|
||||
format!("{DEFAULTS}org.eclipse.jdt.core.compiler.problem.missingSerialVersion={level}\n"),
|
||||
),
|
||||
};
|
||||
let file = data_dir.join("lsp/jdtls").join(name);
|
||||
let write = || -> ide_core::IdeResult<()> {
|
||||
std::fs::create_dir_all(file.parent().expect("compiler preferences directory"))?;
|
||||
if std::fs::read_to_string(&file).ok().as_deref() != Some(DEFAULTS) {
|
||||
ide_vfs::write_atomic(&file, DEFAULTS.as_bytes())?;
|
||||
if std::fs::read_to_string(&file).ok().as_deref() != Some(content.as_str()) {
|
||||
ide_vfs::write_atomic(&file, content.as_bytes())?;
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
@@ -612,9 +638,18 @@ pub fn configure_server(cfg: &mut LanguageServerConfig, root: &Path, server_root
|
||||
|
||||
// Intelephense reads it as `intelephense.environment.phpVersion` (`workspace/configuration`).
|
||||
if name == "intelephense"
|
||||
&& let Some(version) = crate::php::language_level(root, s)
|
||||
&& let Some(level) = crate::php::language_level(root, s)
|
||||
{
|
||||
merge_init_options(cfg, json!({ "environment": { "phpVersion": version } }));
|
||||
merge_init_options(cfg, json!({ "environment": { "phpVersion": level.version } }));
|
||||
}
|
||||
if name == "intelephense" {
|
||||
if let Some(stubs) = crate::php::intelephense_stubs(root, s) {
|
||||
merge_init_options(cfg, json!({ "stubs": stubs }));
|
||||
}
|
||||
let include_paths = crate::php::include_paths(root, s);
|
||||
if !include_paths.is_empty() {
|
||||
merge_init_options(cfg, json!({ "environment": { "includePaths": include_paths } }));
|
||||
}
|
||||
}
|
||||
|
||||
// Node-based servers (`#!/usr/bin/env node`) run on the selected Node.js.
|
||||
@@ -1086,13 +1121,50 @@ mod java_compiler_tests {
|
||||
serde_json::to_value(cfg.initialization_options.as_ref().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serial_version_inspection_sets_the_compiler_option() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let url = |inspections: &InspectionSettings| {
|
||||
let mut cfg = custom_server("jdtls", &LanguageServerSettings::default());
|
||||
configure_java_compiler(&mut cfg, tmp.path(), inspections);
|
||||
options(&cfg).pointer("/settings/java/settings/url").unwrap().as_str().unwrap().to_owned()
|
||||
};
|
||||
let set = |o: ide_core::settings::InspectionOverride| {
|
||||
let mut s = InspectionSettings::default();
|
||||
s.default.insert(SERIAL_VERSION_INSPECTION.into(), o);
|
||||
s
|
||||
};
|
||||
let off = url(&InspectionSettings::default());
|
||||
assert!(!std::fs::read_to_string(&off).unwrap().contains("missingSerialVersion"));
|
||||
let on = url(&set(ide_core::settings::InspectionOverride { enabled: Some(true), ..Default::default() }));
|
||||
assert_ne!(on, off, "a new file, so the server restarts with it");
|
||||
assert!(std::fs::read_to_string(&on).unwrap().ends_with("compiler.problem.missingSerialVersion=warning\n"));
|
||||
let error = url(&set(ide_core::settings::InspectionOverride {
|
||||
enabled: Some(true),
|
||||
severity: Some(InspectionLevel::Error),
|
||||
..Default::default()
|
||||
}));
|
||||
assert!(std::fs::read_to_string(error).unwrap().ends_with("missingSerialVersion=error\n"));
|
||||
let weak = url(&set(ide_core::settings::InspectionOverride {
|
||||
enabled: Some(true),
|
||||
severity: Some(InspectionLevel::WeakWarning),
|
||||
..Default::default()
|
||||
}));
|
||||
assert!(std::fs::read_to_string(weak).unwrap().ends_with("missingSerialVersion=info\n"));
|
||||
let severity_only = url(&set(ide_core::settings::InspectionOverride {
|
||||
severity: Some(InspectionLevel::Error),
|
||||
..Default::default()
|
||||
}));
|
||||
assert_eq!(severity_only, off, "off by default");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compiler_defaults_live_in_the_cache_and_preserve_other_options() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let cache = tmp.path().join("cache with spaces 测试");
|
||||
let mut cfg = custom_server("jdtls", &LanguageServerSettings::default());
|
||||
merge_init_options(&mut cfg, json!({"settings": {"java": {"format": {"tabSize": 4}}}}));
|
||||
configure_java_compiler(&mut cfg, &cache);
|
||||
configure_java_compiler(&mut cfg, &cache, &InspectionSettings::default());
|
||||
let options = options(&cfg);
|
||||
let path = PathBuf::from(options.pointer("/settings/java/settings/url").unwrap().as_str().unwrap());
|
||||
assert!(path.starts_with(&cache));
|
||||
@@ -1103,7 +1175,7 @@ mod java_compiler_tests {
|
||||
}
|
||||
assert_eq!(options.pointer("/settings/java/format/tabSize"), Some(&json!(4)));
|
||||
let modified = std::fs::metadata(&path).unwrap().modified().unwrap();
|
||||
configure_java_compiler(&mut cfg, &cache);
|
||||
configure_java_compiler(&mut cfg, &cache, &InspectionSettings::default());
|
||||
assert_eq!(std::fs::metadata(path).unwrap().modified().unwrap(), modified);
|
||||
assert_eq!(self::options(&cfg), options);
|
||||
}
|
||||
@@ -1113,7 +1185,7 @@ mod java_compiler_tests {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut cfg = custom_server("jdtls", &LanguageServerSettings::default());
|
||||
merge_init_options(&mut cfg, json!({"settings": {"java": {"settings": {"url": "own.prefs"}}}}));
|
||||
configure_java_compiler(&mut cfg, tmp.path());
|
||||
configure_java_compiler(&mut cfg, tmp.path(), &InspectionSettings::default());
|
||||
assert_eq!(options(&cfg).pointer("/settings/java/settings/url"), Some(&json!("own.prefs")));
|
||||
|
||||
for settings in [
|
||||
|
||||
@@ -369,7 +369,6 @@ pub fn configure_jdtls(
|
||||
if cfg.name != "jdtls" {
|
||||
return;
|
||||
}
|
||||
crate::apply::configure_java_compiler(cfg, data_dir);
|
||||
// A Lombok agent from the user's JVM arguments wins.
|
||||
let own_agent = crate::lombok::agent_in_args(cfg.args.iter().map(String::as_str));
|
||||
let agent = own_agent.as_deref().or(lombok);
|
||||
|
||||
+177
-11
@@ -1,24 +1,53 @@
|
||||
//! The PHP language level: the one chosen in Settings › PHP, else the one the root
|
||||
//! `composer.json` declares (`config.platform.php`, else the lowest version `require.php` allows);
|
||||
//! and the Intelephense storage that goes with it.
|
||||
//! the Intelephense storage that goes with it; and the stub sets and include paths Intelephense indexes.
|
||||
|
||||
use crate::apply::{merge, merge_init_options};
|
||||
use crate::apply::{merge, merge_init_options, setting_path};
|
||||
use ide_core::settings::Settings;
|
||||
use ide_language::LanguageServerConfig;
|
||||
use serde_json::{Value, json};
|
||||
use std::path::Path;
|
||||
|
||||
/// The project's PHP version as `X.Y.Z`; `None` when neither the settings nor
|
||||
/// `composer.json` name one.
|
||||
pub fn language_level(root: &Path, s: &Settings) -> Option<String> {
|
||||
/// A PHP version and where it comes from.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PhpLanguageLevel {
|
||||
/// `X.Y.Z`.
|
||||
pub version: String,
|
||||
pub source: PhpLevelSource,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum PhpLevelSource {
|
||||
/// `languages.php.languageLevel`.
|
||||
Settings,
|
||||
/// `config.platform.php` of `composer.json`.
|
||||
Platform,
|
||||
/// The lowest version `require.php` of `composer.json` allows.
|
||||
Require,
|
||||
}
|
||||
|
||||
/// The project's PHP version: the one chosen in the settings, else [`composer_level`];
|
||||
/// `None` when neither names one.
|
||||
pub fn language_level(root: &Path, s: &Settings) -> Option<PhpLanguageLevel> {
|
||||
let explicit = s.languages.php.language_level.trim();
|
||||
if !explicit.is_empty() {
|
||||
return version(explicit).map(dotted);
|
||||
return version(explicit).map(|v| PhpLanguageLevel::new(v, PhpLevelSource::Settings));
|
||||
}
|
||||
composer_level(root)
|
||||
}
|
||||
|
||||
/// The PHP version the root `composer.json` declares (the setting's Auto):
|
||||
/// `config.platform.php`, else the lowest version `require.php` allows.
|
||||
pub fn composer_level(root: &Path) -> Option<PhpLanguageLevel> {
|
||||
let text = std::fs::read_to_string(root.join("composer.json")).ok()?;
|
||||
let composer: Value = serde_json::from_str(&text).ok()?;
|
||||
let platform = composer.pointer("/config/platform/php").and_then(Value::as_str).and_then(version);
|
||||
platform.or_else(|| lowest_allowed(composer.pointer("/require/php")?.as_str()?)).map(dotted)
|
||||
if let Some(platform) = composer.pointer("/config/platform/php").and_then(Value::as_str).and_then(version) {
|
||||
return Some(PhpLanguageLevel::new(platform, PhpLevelSource::Platform));
|
||||
}
|
||||
lowest_allowed(composer.pointer("/require/php")?.as_str()?)
|
||||
.map(|v| PhpLanguageLevel::new(v, PhpLevelSource::Require))
|
||||
}
|
||||
|
||||
/// Gives each PHP version its own Intelephense workspace storage (below `data_dir`).
|
||||
@@ -48,10 +77,74 @@ pub fn configure_intelephense_storage(cfg: &mut LanguageServerConfig, data_dir:
|
||||
merge_init_options(cfg, json!({ "storagePath": storage.to_string_lossy() }));
|
||||
}
|
||||
|
||||
/// The stub sets Intelephense loads when `stubs` is not set (Intelephense 1.18.5). Setting
|
||||
/// `stubs` replaces them, so additions are sent together with these.
|
||||
#[rustfmt::skip]
|
||||
const DEFAULT_STUBS: &[&str] = &[
|
||||
"apache", "bcmath", "bz2", "calendar", "com_dotnet", "Core", "ctype", "curl", "date", "dba", "dom", "enchant",
|
||||
"exif", "fileinfo", "filter", "fpm", "ftp", "gd", "hash", "iconv", "imap", "intl", "json", "ldap", "libxml",
|
||||
"mbstring", "mcrypt", "mssql", "mysqli", "oci8", "odbc", "openssl", "pcntl", "pcre", "PDO", "pgsql", "Phar",
|
||||
"posix", "pspell", "random", "readline", "Reflection", "regex", "session", "shmop", "SimpleXML", "snmp", "soap",
|
||||
"sockets", "sodium", "SPL", "sqlite3", "standard", "superglobals", "sybase", "sysvmsg", "sysvsem", "sysvshm",
|
||||
"tidy", "tokenizer", "uri", "xml", "xmlreader", "xmlrpc", "xmlwriter", "Zend OPcache", "zip", "zlib",
|
||||
];
|
||||
|
||||
/// Stub sets outside the defaults whose names are not lower case; the rest are the lower-case
|
||||
/// extension name.
|
||||
const MIXED_CASE_STUBS: &[&str] =
|
||||
&["Ev", "FFI", "LuaSandbox", "Parle", "SQLite", "SaxonC", "SplType", "ZendCache", "ZendDebugger", "ZendUtils"];
|
||||
|
||||
/// `Zend OPcache`, `zend-opcache` and `zend_opcache` name the same stubs.
|
||||
fn stub_key(name: &str) -> String {
|
||||
name.chars().filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_lowercase()).collect()
|
||||
}
|
||||
|
||||
/// The `stubs` Intelephense loads: its defaults plus a stub set for each `ext-*` that the root
|
||||
/// `composer.json` requires (`require`, `require-dev`) and those in Settings › PHP. `None` when
|
||||
/// that adds nothing to the defaults, so the server keeps its own.
|
||||
pub fn intelephense_stubs(root: &Path, s: &Settings) -> Option<Vec<String>> {
|
||||
let composer: Option<Value> =
|
||||
std::fs::read_to_string(root.join("composer.json")).ok().and_then(|t| serde_json::from_str(&t).ok());
|
||||
let extensions = ["require", "require-dev"]
|
||||
.into_iter()
|
||||
.filter_map(|section| composer.as_ref()?.get(section)?.as_object())
|
||||
.flat_map(|deps| deps.keys())
|
||||
.filter_map(|dep| dep.strip_prefix("ext-"))
|
||||
.map(|ext| {
|
||||
let key = stub_key(ext);
|
||||
MIXED_CASE_STUBS
|
||||
.iter()
|
||||
.find(|stub| stub_key(stub) == key)
|
||||
.map_or(ext.to_ascii_lowercase(), |s| s.to_string())
|
||||
});
|
||||
let configured = s.languages.php.stubs.iter().map(|stub| stub.trim().to_owned());
|
||||
let mut stubs: Vec<String> = DEFAULT_STUBS.iter().map(|s| s.to_string()).collect();
|
||||
let mut known: std::collections::HashSet<String> = stubs.iter().map(|s| stub_key(s)).collect();
|
||||
for stub in extensions.chain(configured) {
|
||||
if !stub.is_empty() && known.insert(stub_key(&stub)) {
|
||||
stubs.push(stub);
|
||||
}
|
||||
}
|
||||
(stubs.len() > DEFAULT_STUBS.len()).then_some(stubs)
|
||||
}
|
||||
|
||||
/// The folders in Settings › PHP › Include paths, resolved against the project.
|
||||
pub fn include_paths(root: &Path, s: &Settings) -> Vec<String> {
|
||||
s.languages
|
||||
.php
|
||||
.include_paths
|
||||
.iter()
|
||||
.filter_map(|p| setting_path(root, p))
|
||||
.map(|p| p.to_string_lossy().into_owned())
|
||||
.collect()
|
||||
}
|
||||
|
||||
type Version = (u32, u32, u32);
|
||||
|
||||
fn dotted((major, minor, patch): Version) -> String {
|
||||
format!("{major}.{minor}.{patch}")
|
||||
impl PhpLanguageLevel {
|
||||
fn new((major, minor, patch): Version, source: PhpLevelSource) -> Self {
|
||||
Self { version: format!("{major}.{minor}.{patch}"), source }
|
||||
}
|
||||
}
|
||||
|
||||
/// `7`, `7.4`, `v7.4.3`, `8.1.*`, `7.4.0-dev`: missing parts are 0.
|
||||
@@ -105,13 +198,18 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
fn level(composer: Option<&str>, explicit: &str) -> Option<String> {
|
||||
levels(composer, explicit).0.map(|l| l.version)
|
||||
}
|
||||
|
||||
/// The effective level and the composer one.
|
||||
fn levels(composer: Option<&str>, explicit: &str) -> (Option<PhpLanguageLevel>, Option<PhpLanguageLevel>) {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
if let Some(composer) = composer {
|
||||
std::fs::write(tmp.path().join("composer.json"), composer).unwrap();
|
||||
}
|
||||
let mut s = Settings::default();
|
||||
s.languages.php.language_level = explicit.into();
|
||||
language_level(tmp.path(), &s)
|
||||
(language_level(tmp.path(), &s), composer_level(tmp.path()))
|
||||
}
|
||||
|
||||
fn required(constraint: &str) -> Option<String> {
|
||||
@@ -201,6 +299,74 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn stubs(composer: Option<Value>, configured: &[&str]) -> Option<Vec<String>> {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
if let Some(composer) = composer {
|
||||
std::fs::write(tmp.path().join("composer.json"), composer.to_string()).unwrap();
|
||||
}
|
||||
let mut s = Settings::default();
|
||||
s.languages.php.stubs = configured.iter().map(|s| s.to_string()).collect();
|
||||
intelephense_stubs(tmp.path(), &s)
|
||||
}
|
||||
|
||||
fn added(stubs: Option<Vec<String>>) -> Vec<String> {
|
||||
let stubs = stubs.expect("stubs");
|
||||
assert_eq!(stubs[..DEFAULT_STUBS.len()], DEFAULT_STUBS.iter().map(|s| s.to_string()).collect::<Vec<_>>());
|
||||
stubs[DEFAULT_STUBS.len()..].to_vec()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn required_extensions_and_configured_stubs_follow_the_defaults() {
|
||||
let composer = json!({
|
||||
"require": {"php": "^8.1", "ext-redis": "*", "ext-json": "*", "monolog/monolog": "^3"},
|
||||
"require-dev": {"ext-Imagick": "*", "ext-ffi": "*", "ext-redis": "*"}
|
||||
});
|
||||
assert_eq!(
|
||||
added(stubs(Some(composer), &[" wordpress ", "Redis", ""])),
|
||||
["redis", "imagick", "FFI", "wordpress"]
|
||||
);
|
||||
assert_eq!(added(stubs(None, &["wordpress"])), ["wordpress"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_beyond_the_defaults_sends_nothing() {
|
||||
assert_eq!(stubs(None, &[]), None);
|
||||
assert_eq!(stubs(Some(json!({"require": {"php": "^8.1"}})), &[]), None);
|
||||
// Default stub sets under their extension names: `ext-pdo`, `ext-zend-opcache`, `ext-simplexml`.
|
||||
let defaults =
|
||||
json!({"require": {"ext-pdo": "*", "ext-zend-opcache": "*", "ext-simplexml": "*", "ext-json": "*"}});
|
||||
assert_eq!(stubs(Some(defaults), &["SPL", "core"]), None);
|
||||
assert_eq!(stubs(Some(json!("not an object")), &[]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn include_paths_resolve_against_the_project() {
|
||||
let root = Path::new("/work/module");
|
||||
let mut s = Settings::default();
|
||||
s.languages.php.include_paths = vec!["../platform".into(), "/opt/prestashop".into(), " ".into()];
|
||||
assert_eq!(
|
||||
include_paths(root, &s),
|
||||
[root.join("../platform").to_string_lossy().into_owned(), "/opt/prestashop".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_level_names_its_source() {
|
||||
let found = |version: &str, source| Some(PhpLanguageLevel { version: version.into(), source });
|
||||
let require = r#"{"require": {"php": "^7.4"}}"#;
|
||||
let platform = r#"{"require": {"php": "^7.4"}, "config": {"platform": {"php": "8.2.1"}}}"#;
|
||||
|
||||
let require_level = found("7.4.0", PhpLevelSource::Require);
|
||||
assert_eq!(levels(Some(require), ""), (require_level.clone(), require_level.clone()));
|
||||
let platform_level = found("8.2.1", PhpLevelSource::Platform);
|
||||
assert_eq!(levels(Some(platform), ""), (platform_level.clone(), platform_level));
|
||||
// An explicit level: the composer one is still what Auto would pick.
|
||||
assert_eq!(levels(Some(require), "8.1"), (found("8.1.0", PhpLevelSource::Settings), require_level));
|
||||
assert_eq!(levels(None, "8.1"), (found("8.1.0", PhpLevelSource::Settings), None));
|
||||
assert_eq!(levels(None, ""), (None, None));
|
||||
assert_eq!(levels(Some(r#"{"require": {"php": "*"}}"#), ""), (None, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_level_wins() {
|
||||
let composer = r#"{"require": {"php": "^7.4"}, "config": {"platform": {"php": "7.4.33"}}}"#;
|
||||
|
||||
@@ -1325,6 +1325,30 @@ fn intelephense_gets_the_php_language_level() {
|
||||
assert_eq!(options(cfg), json!({"clearCache": true, "environment": {"phpVersion": "8.1.0"}}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intelephense_gets_the_required_stubs_and_include_paths() {
|
||||
use serde_json::json;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
std::fs::write(tmp.path().join("composer.json"), r#"{"require": {"php": "^8.1", "ext-redis": "*"}}"#).unwrap();
|
||||
let mut s = Settings::default();
|
||||
s.languages.php.stubs = vec!["wordpress".into()];
|
||||
s.languages.php.include_paths = vec!["../platform".into()];
|
||||
let mut cfg = server("intelephense");
|
||||
configure_server(&mut cfg, tmp.path(), tmp.path(), &s);
|
||||
let options = serde_json::to_value(cfg.initialization_options).unwrap();
|
||||
let stubs: Vec<&str> = options["stubs"].as_array().unwrap().iter().filter_map(|v| v.as_str()).collect();
|
||||
assert_eq!(stubs.len(), 70);
|
||||
assert_eq!((stubs[0], &stubs[68..]), ("apache", &["redis", "wordpress"][..]));
|
||||
assert_eq!(
|
||||
options["environment"],
|
||||
json!({"phpVersion": "8.1.0", "includePaths": [tmp.path().join("../platform").to_string_lossy()]})
|
||||
);
|
||||
|
||||
let mut other = server("phpactor");
|
||||
configure_server(&mut other, tmp.path(), tmp.path(), &s);
|
||||
assert_eq!(other.initialization_options, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn go_constraints_change_only_protocol_settings() {
|
||||
use serde_json::json;
|
||||
|
||||
@@ -14,15 +14,20 @@ tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
parking_lot.workspace = true
|
||||
dirs.workspace = true
|
||||
uuid.workspace = true
|
||||
keyring.workspace = true
|
||||
keyring-core.workspace = true
|
||||
russh = { version = "0.64", default-features = false, features = ["ring", "rsa", "flate2"] }
|
||||
russh-config = "0.58.0"
|
||||
russh-sftp = "2.4"
|
||||
flate2 = "1"
|
||||
|
||||
[features]
|
||||
# `testing`: an in-process SSH / SFTP server for tests of crates built on this one.
|
||||
testing = []
|
||||
# `testing`: an in-process SSH / SFTP server for tests of crates built on this one,
|
||||
# and an in-memory credential store in place of the OS one.
|
||||
testing = ["keyring-core/sample"]
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
|
||||
tempfile.workspace = true
|
||||
keyring-core = { workspace = true, features = ["sample"] }
|
||||
@@ -216,6 +216,7 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
fn store() -> (tempfile::TempDir, GitCredentials) {
|
||||
crate::secrets::testing::use_memory_store();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("git-credentials.json");
|
||||
(tmp, GitCredentials { saved: Arc::new(SecretFile::new(path)), sessions: Arc::default() })
|
||||
|
||||
+499
-31
@@ -1,61 +1,289 @@
|
||||
//! Saved passwords (SSH passwords / key passphrases by configuration id, the HTTP
|
||||
//! proxy's), in the app's data directory rather than the OS credential store (no
|
||||
//! system prompts). One JSON object, key → secret; owner-only on Unix.
|
||||
//! Saved passwords (SSH passwords / key passphrases by configuration id, git HTTPS
|
||||
//! credentials, the HTTP proxy's) in the OS credential store: Windows Credential
|
||||
//! Manager, the macOS Keychain, the Secret Service on Linux. The file at `path` only
|
||||
//! indexes them (an id and the keys, never a secret); owner-only on Unix.
|
||||
//!
|
||||
//! Older versions kept the secrets themselves in that file: they are moved to the
|
||||
//! credential store on first use. Without a credential store (a headless Linux
|
||||
//! host) such a file is still read and forgetting a secret rewrites it without
|
||||
//! that one, but no new secret is written to disk: saving fails and callers keep
|
||||
//! the secret for the session only.
|
||||
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
pub use keyring_core::{Error as StoreError, Result as StoreResult};
|
||||
use parking_lot::Mutex;
|
||||
use std::collections::BTreeMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
const SERVICE: &str = "ai.l0ng.openidea";
|
||||
|
||||
/// Serialises read-modify-write of the index files (several stores may share one).
|
||||
static LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
/// Reads the secrets of an older version's file, by key (`None`: not that format).
|
||||
pub type Legacy = fn(serde_json::Value) -> Option<BTreeMap<String, String>>;
|
||||
|
||||
/// Where the secrets are kept: the default credential store, or a stand-in in tests.
|
||||
pub trait Vault: Send + Sync {
|
||||
fn available(&self) -> bool;
|
||||
fn get(&self, service: &str, key: &str) -> StoreResult<String>;
|
||||
fn set(&self, service: &str, key: &str, secret: &str) -> StoreResult<()>;
|
||||
fn delete(&self, service: &str, key: &str) -> StoreResult<()>;
|
||||
}
|
||||
|
||||
/// `keyring_core`'s default store: the OS's, unless a test installed another one.
|
||||
struct DefaultStore;
|
||||
|
||||
impl DefaultStore {
|
||||
fn entry(&self, service: &str, key: &str) -> StoreResult<keyring_core::Entry> {
|
||||
if !self.available() {
|
||||
return Err(StoreError::NoDefaultStore);
|
||||
}
|
||||
keyring_core::Entry::new(service, key)
|
||||
}
|
||||
}
|
||||
|
||||
impl Vault for DefaultStore {
|
||||
fn available(&self) -> bool {
|
||||
if keyring_core::get_default_store().is_some() {
|
||||
return true;
|
||||
}
|
||||
static WARNED: std::sync::Once = std::sync::Once::new();
|
||||
match keyring::Entry::store_status() {
|
||||
Ok(()) => true,
|
||||
Err(e) => {
|
||||
WARNED.call_once(|| tracing::warn!("saved passwords: no credential store: {e}"));
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get(&self, service: &str, key: &str) -> StoreResult<String> {
|
||||
self.entry(service, key)?.get_password()
|
||||
}
|
||||
|
||||
fn set(&self, service: &str, key: &str, secret: &str) -> StoreResult<()> {
|
||||
self.entry(service, key)?.set_password(secret)
|
||||
}
|
||||
|
||||
fn delete(&self, service: &str, key: &str) -> StoreResult<()> {
|
||||
self.entry(service, key)?.delete_credential()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SecretFile {
|
||||
path: PathBuf,
|
||||
lock: Mutex<()>,
|
||||
legacy: Legacy,
|
||||
vault: Arc<dyn Vault>,
|
||||
}
|
||||
|
||||
/// The file's contents. `id` keeps the credentials of different data directories
|
||||
/// (and of tests) apart in the one OS store.
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct Index {
|
||||
id: String,
|
||||
keys: BTreeSet<String>,
|
||||
}
|
||||
|
||||
enum Contents {
|
||||
Index(Index),
|
||||
/// An older version's file that could not be moved, and why.
|
||||
Plaintext(BTreeMap<String, String>, String),
|
||||
}
|
||||
|
||||
fn service(id: &str) -> String {
|
||||
format!("{SERVICE}/{id}")
|
||||
}
|
||||
|
||||
fn new_id() -> String {
|
||||
uuid::Uuid::new_v4().to_string()
|
||||
}
|
||||
|
||||
impl SecretFile {
|
||||
pub fn new(path: PathBuf) -> Self {
|
||||
Self { path, lock: Mutex::new(()) }
|
||||
Self::with_legacy(path, |v| serde_json::from_value(v).ok())
|
||||
}
|
||||
|
||||
pub fn with_legacy(path: PathBuf, legacy: Legacy) -> Self {
|
||||
Self::with_vault(path, legacy, Arc::new(DefaultStore))
|
||||
}
|
||||
|
||||
pub fn with_vault(path: PathBuf, legacy: Legacy, vault: Arc<dyn Vault>) -> Self {
|
||||
Self { path, legacy, vault }
|
||||
}
|
||||
|
||||
pub fn get(&self, key: &str) -> Option<String> {
|
||||
let _g = self.lock.lock();
|
||||
self.read().remove(key)
|
||||
let _g = LOCK.lock();
|
||||
match self.load() {
|
||||
Contents::Index(mut index) if index.keys.contains(key) => match self.vault.get(&service(&index.id), key) {
|
||||
Ok(secret) => Some(secret),
|
||||
Err(StoreError::NoEntry) => {
|
||||
// Removed outside the app: stop listing it.
|
||||
index.keys.remove(key);
|
||||
self.write_logged(&index);
|
||||
None
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("saved passwords: {key}: {e}");
|
||||
None
|
||||
}
|
||||
},
|
||||
Contents::Index(_) => None,
|
||||
Contents::Plaintext(mut old, _) => old.remove(key),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set(&self, key: &str, secret: &str) -> IdeResult<()> {
|
||||
let _g = self.lock.lock();
|
||||
let mut all = self.read();
|
||||
all.insert(key.to_owned(), secret.to_owned());
|
||||
self.write(&all)
|
||||
let _g = LOCK.lock();
|
||||
let mut index = match self.load() {
|
||||
Contents::Index(index) => index,
|
||||
Contents::Plaintext(_, why) => return Err(unavailable(&why)),
|
||||
};
|
||||
let service = service(&index.id);
|
||||
self.vault.set(&service, key, secret).map_err(|e| unavailable(&e.to_string()))?;
|
||||
if index.keys.insert(key.to_owned())
|
||||
&& let Err(e) = self.write(&index)
|
||||
{
|
||||
let _ = self.vault.delete(&service, key);
|
||||
return Err(e);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete(&self, key: &str) {
|
||||
let _g = self.lock.lock();
|
||||
let mut all = self.read();
|
||||
if all.remove(key).is_some()
|
||||
&& let Err(e) = self.write(&all)
|
||||
{
|
||||
tracing::warn!("{}", e.message);
|
||||
let _g = LOCK.lock();
|
||||
match self.load() {
|
||||
Contents::Index(mut index) => {
|
||||
if !index.keys.contains(key) {
|
||||
return;
|
||||
}
|
||||
match self.vault.delete(&service(&index.id), key) {
|
||||
Ok(()) | Err(StoreError::NoEntry) => {}
|
||||
Err(e) => {
|
||||
// Kept in the index so that the next delete tries again.
|
||||
tracing::warn!("saved passwords: could not forget {key}: {e}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
index.keys.remove(key);
|
||||
self.write_logged(&index);
|
||||
}
|
||||
Contents::Plaintext(mut old, _) => {
|
||||
if old.remove(key).is_none() {
|
||||
return;
|
||||
}
|
||||
// A move halfway may have copied it already.
|
||||
if let Some(id) = self.pending_id()
|
||||
&& let Err(e) = self.vault.delete(&service(&id), key)
|
||||
&& !matches!(e, StoreError::NoEntry)
|
||||
{
|
||||
tracing::warn!("saved passwords: could not forget {key}: {e}");
|
||||
return;
|
||||
}
|
||||
self.write_logged(&old);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every stored key, sorted (the secrets themselves stay private).
|
||||
pub fn keys(&self) -> Vec<String> {
|
||||
let _g = self.lock.lock();
|
||||
self.read().into_keys().collect()
|
||||
}
|
||||
|
||||
fn read(&self) -> BTreeMap<String, String> {
|
||||
match std::fs::read(&self.path) {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|e| {
|
||||
tracing::warn!("saved passwords: {}: {e}", self.path.display());
|
||||
BTreeMap::new()
|
||||
}),
|
||||
Err(_) => BTreeMap::new(),
|
||||
let _g = LOCK.lock();
|
||||
match self.load() {
|
||||
Contents::Index(index) => index.keys.into_iter().collect(),
|
||||
Contents::Plaintext(old, _) => old.into_keys().collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn write(&self, all: &BTreeMap<String, String>) -> IdeResult<()> {
|
||||
fn load(&self) -> Contents {
|
||||
let value = match std::fs::read(&self.path) {
|
||||
Err(_) => return Contents::Index(Index { id: new_id(), keys: BTreeSet::new() }),
|
||||
Ok(bytes) => match serde_json::from_slice::<serde_json::Value>(&bytes) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return self.set_aside(&e.to_string()),
|
||||
},
|
||||
};
|
||||
if let Ok(index) = serde_json::from_value::<Index>(value.clone()) {
|
||||
return Contents::Index(index);
|
||||
}
|
||||
match (self.legacy)(value) {
|
||||
Some(old) => self.migrate(old),
|
||||
None => self.set_aside("unknown format"),
|
||||
}
|
||||
}
|
||||
|
||||
/// An unreadable file is renamed rather than overwritten (it may index
|
||||
/// credentials, or hold secrets); the store starts over.
|
||||
fn set_aside(&self, why: &str) -> Contents {
|
||||
let aside = self.path.with_extension(format!("json.unreadable-{}", new_id()));
|
||||
tracing::warn!("saved passwords: {}: {why}; moved to {}", self.path.display(), aside.display());
|
||||
if let Err(e) = std::fs::rename(&self.path, &aside) {
|
||||
// Not overwritten either: nothing is saved until it can be moved.
|
||||
return Contents::Plaintext(BTreeMap::new(), format!("{}: {e}", self.path.display()));
|
||||
}
|
||||
Contents::Index(Index { id: new_id(), keys: BTreeSet::new() })
|
||||
}
|
||||
|
||||
fn pending_path(&self) -> PathBuf {
|
||||
self.path.with_extension("json.migrating")
|
||||
}
|
||||
|
||||
/// The id of a move that has not finished (see [`SecretFile::migrate`]).
|
||||
fn pending_id(&self) -> Option<String> {
|
||||
let id = std::fs::read_to_string(self.pending_path()).ok()?;
|
||||
Some(id.trim().to_owned()).filter(|id| !id.is_empty())
|
||||
}
|
||||
|
||||
/// Moves an older version's secrets to the credential store, key by key; a secret
|
||||
/// the store refuses for good (too long, say) is forgotten, and the user is asked
|
||||
/// for it again. The old file is replaced only when everything else was moved.
|
||||
/// Until then the id waits in `<file>.migrating`, so a retry after a failure or a
|
||||
/// crash overwrites the same credentials instead of copying them again.
|
||||
fn migrate(&self, old: BTreeMap<String, String>) -> Contents {
|
||||
if !self.vault.available() {
|
||||
return Contents::Plaintext(old, "no credential store".into());
|
||||
}
|
||||
let pending = self.pending_path();
|
||||
let id = match self.pending_id() {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
let id = new_id();
|
||||
if let Err(e) = std::fs::write(&pending, &id) {
|
||||
return Contents::Plaintext(old, format!("{}: {e}", pending.display()));
|
||||
}
|
||||
id
|
||||
}
|
||||
};
|
||||
let mut index = Index { id, keys: BTreeSet::new() };
|
||||
for (key, secret) in &old {
|
||||
match self.vault.set(&service(&index.id), key, secret) {
|
||||
Ok(()) => {
|
||||
index.keys.insert(key.clone());
|
||||
}
|
||||
Err(e @ (StoreError::TooLong(..) | StoreError::Invalid(..))) => {
|
||||
tracing::warn!("saved passwords: {key} does not fit the credential store and is forgotten: {e}");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("saved passwords: could not move {key} to the credential store: {e}");
|
||||
return Contents::Plaintext(old, e.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Err(e) = self.write(&index) {
|
||||
return Contents::Plaintext(old, e.message);
|
||||
}
|
||||
let _ = std::fs::remove_file(&pending);
|
||||
Contents::Index(index)
|
||||
}
|
||||
|
||||
fn write_logged(&self, contents: &impl Serialize) {
|
||||
if let Err(e) = self.write(contents) {
|
||||
tracing::warn!("{}", e.message);
|
||||
}
|
||||
}
|
||||
|
||||
fn write(&self, contents: &impl Serialize) -> IdeResult<()> {
|
||||
let err = |e: std::io::Error| IdeError::unavailable(format!("saved passwords: {}: {e}", self.path.display()));
|
||||
if let Some(dir) = self.path.parent() {
|
||||
std::fs::create_dir_all(dir).map_err(err)?;
|
||||
@@ -65,18 +293,258 @@ impl SecretFile {
|
||||
options.write(true).create(true).truncate(true);
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
|
||||
let bytes = serde_json::to_vec_pretty(all).map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
let bytes = serde_json::to_vec_pretty(contents).map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
std::io::Write::write_all(&mut options.open(&tmp).map_err(err)?, &bytes).map_err(err)?;
|
||||
std::fs::rename(&tmp, &self.path).map_err(err)
|
||||
}
|
||||
}
|
||||
|
||||
fn unavailable(why: &str) -> IdeError {
|
||||
IdeError::unavailable(format!("saved passwords: {why}"))
|
||||
}
|
||||
|
||||
/// Helpers for tests of crates that save secrets (feature `testing`).
|
||||
#[cfg(any(test, feature = "testing"))]
|
||||
pub mod testing {
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Makes an in-memory credential store the default, so that the test never
|
||||
/// touches the user's keychain. Call it before the first secret is saved.
|
||||
pub fn use_memory_store() {
|
||||
static ONCE: std::sync::Once = std::sync::Once::new();
|
||||
ONCE.call_once(|| keyring_core::set_default_store(keyring_core::sample::Store::new().expect("memory store")));
|
||||
}
|
||||
|
||||
/// Every file under `dir` whose bytes contain `needle`.
|
||||
pub fn files_containing(dir: &Path, needle: &str) -> Vec<PathBuf> {
|
||||
let mut found = Vec::new();
|
||||
let mut pending = vec![dir.to_path_buf()];
|
||||
while let Some(d) = pending.pop() {
|
||||
for entry in std::fs::read_dir(&d).into_iter().flatten().flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
pending.push(path);
|
||||
} else if std::fs::read(&path).is_ok_and(|b| b.windows(needle.len()).any(|w| w == needle.as_bytes())) {
|
||||
found.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::testing::{files_containing, use_memory_store};
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// A credential store that can be absent, or fail on chosen keys.
|
||||
#[derive(Default)]
|
||||
struct FakeVault {
|
||||
absent: bool,
|
||||
entries: Mutex<HashMap<(String, String), String>>,
|
||||
failing: Mutex<HashMap<String, fn() -> StoreError>>,
|
||||
}
|
||||
|
||||
impl FakeVault {
|
||||
fn fail(&self, key: &str, error: fn() -> StoreError) {
|
||||
self.failing.lock().insert(key.to_owned(), error);
|
||||
}
|
||||
|
||||
fn heal(&self) {
|
||||
self.failing.lock().clear();
|
||||
}
|
||||
|
||||
fn check(&self, key: &str) -> StoreResult<()> {
|
||||
if self.absent {
|
||||
return Err(StoreError::NoDefaultStore);
|
||||
}
|
||||
self.failing.lock().get(key).map_or(Ok(()), |e| Err(e()))
|
||||
}
|
||||
|
||||
fn secrets(&self) -> Vec<String> {
|
||||
let mut all: Vec<_> = self.entries.lock().values().cloned().collect();
|
||||
all.sort();
|
||||
all
|
||||
}
|
||||
}
|
||||
|
||||
impl Vault for FakeVault {
|
||||
fn available(&self) -> bool {
|
||||
!self.absent
|
||||
}
|
||||
fn get(&self, service: &str, key: &str) -> StoreResult<String> {
|
||||
self.check(key)?;
|
||||
self.entries.lock().get(&(service.to_owned(), key.to_owned())).cloned().ok_or(StoreError::NoEntry)
|
||||
}
|
||||
fn set(&self, service: &str, key: &str, secret: &str) -> StoreResult<()> {
|
||||
self.check(key)?;
|
||||
self.entries.lock().insert((service.to_owned(), key.to_owned()), secret.to_owned());
|
||||
Ok(())
|
||||
}
|
||||
fn delete(&self, service: &str, key: &str) -> StoreResult<()> {
|
||||
self.check(key)?;
|
||||
self.entries.lock().remove(&(service.to_owned(), key.to_owned())).map(|_| ()).ok_or(StoreError::NoEntry)
|
||||
}
|
||||
}
|
||||
|
||||
fn platform() -> StoreError {
|
||||
StoreError::PlatformFailure("locked".into())
|
||||
}
|
||||
|
||||
fn with_fake(path: &std::path::Path, vault: &Arc<FakeVault>) -> SecretFile {
|
||||
SecretFile::with_vault(path.to_path_buf(), |v| serde_json::from_value(v).ok(), vault.clone())
|
||||
}
|
||||
|
||||
const OLD: &str = r#"{"https://a.example": "me\nold-a", "https://b.example": "me\nold-b"}"#;
|
||||
|
||||
#[test]
|
||||
fn saved_secrets_never_reach_disk() {
|
||||
use_memory_store();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("ssh-passwords.json");
|
||||
SecretFile::new(path.clone()).set("a", "hunter2-ssh").unwrap();
|
||||
assert_eq!(files_containing(tmp.path(), "hunter2-ssh"), Vec::<PathBuf>::new());
|
||||
let reopened = SecretFile::new(path);
|
||||
assert_eq!(reopened.get("a").as_deref(), Some("hunter2-ssh"));
|
||||
assert_eq!(reopened.keys(), ["a"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moves_plaintext_files_of_older_versions_off_disk() {
|
||||
use_memory_store();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("git-credentials.json");
|
||||
std::fs::write(&path, r#"{"https://example.com": "me\nold-pw"}"#).unwrap();
|
||||
let file = SecretFile::new(path.clone());
|
||||
assert_eq!(file.keys(), ["https://example.com"]);
|
||||
assert_eq!(files_containing(tmp.path(), "old-pw"), Vec::<PathBuf>::new());
|
||||
assert_eq!(SecretFile::new(path).get("https://example.com").as_deref(), Some("me\nold-pw"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_move_keeps_the_old_file_and_the_retry_converges() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("git-credentials.json");
|
||||
std::fs::write(&path, OLD).unwrap();
|
||||
let vault = Arc::new(FakeVault::default());
|
||||
vault.fail("https://b.example", platform);
|
||||
|
||||
let file = with_fake(&path, &vault);
|
||||
assert_eq!(file.get("https://a.example").as_deref(), Some("me\nold-a"));
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), OLD, "the old file is untouched");
|
||||
assert!(file.set("https://c.example", "me\nnew").unwrap_err().message.contains("locked"));
|
||||
|
||||
vault.heal();
|
||||
assert_eq!(file.keys(), ["https://a.example", "https://b.example"]);
|
||||
assert_eq!(files_containing(tmp.path(), "old-a"), Vec::<PathBuf>::new());
|
||||
assert_eq!(vault.secrets(), ["me\nold-a", "me\nold-b"], "the retry overwrote, not copied");
|
||||
assert!(!path.with_extension("json.migrating").exists());
|
||||
assert_eq!(with_fake(&path, &vault).get("https://b.example").as_deref(), Some("me\nold-b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forgetting_during_a_pending_move_forgets_the_copy_too() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("git-credentials.json");
|
||||
std::fs::write(&path, OLD).unwrap();
|
||||
let vault = Arc::new(FakeVault::default());
|
||||
vault.fail("https://b.example", platform);
|
||||
|
||||
let file = with_fake(&path, &vault);
|
||||
file.delete("https://a.example");
|
||||
vault.heal();
|
||||
assert_eq!(file.keys(), ["https://b.example"]);
|
||||
assert_eq!(vault.secrets(), ["me\nold-b"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_secret_the_store_refuses_for_good_is_forgotten_and_does_not_block_saving() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("git-credentials.json");
|
||||
std::fs::write(&path, OLD).unwrap();
|
||||
let vault = Arc::new(FakeVault::default());
|
||||
vault.fail("https://b.example", || StoreError::TooLong("password".into(), 2560));
|
||||
|
||||
let file = with_fake(&path, &vault);
|
||||
assert_eq!(file.keys(), ["https://a.example"]);
|
||||
assert_eq!(files_containing(tmp.path(), "old-b"), Vec::<PathBuf>::new());
|
||||
file.set("https://c.example", "me\nnew").unwrap();
|
||||
assert_eq!(file.get("https://c.example").as_deref(), Some("me\nnew"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_credential_store_the_old_file_is_read_and_nothing_new_is_saved() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("git-credentials.json");
|
||||
std::fs::write(&path, OLD).unwrap();
|
||||
let vault = Arc::new(FakeVault { absent: true, ..Default::default() });
|
||||
|
||||
let file = with_fake(&path, &vault);
|
||||
assert_eq!(file.get("https://a.example").as_deref(), Some("me\nold-a"));
|
||||
assert!(file.set("https://c.example", "me\nnew").unwrap_err().message.contains("no credential store"));
|
||||
assert_eq!(files_containing(tmp.path(), "me\nnew"), Vec::<PathBuf>::new());
|
||||
assert!(!path.with_extension("json.migrating").exists());
|
||||
|
||||
file.delete("https://a.example");
|
||||
assert_eq!(file.keys(), ["https://b.example"]);
|
||||
assert_eq!(files_containing(tmp.path(), "old-a"), Vec::<PathBuf>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forgets_only_what_the_store_forgot() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let vault = Arc::new(FakeVault::default());
|
||||
let file = with_fake(&tmp.path().join("ssh-passwords.json"), &vault);
|
||||
file.set("a", "pw").unwrap();
|
||||
vault.fail("a", platform);
|
||||
file.delete("a");
|
||||
assert_eq!(file.keys(), ["a"], "still listed, so that forgetting can be retried");
|
||||
vault.heal();
|
||||
file.delete("a");
|
||||
assert_eq!(file.keys(), Vec::<String>::new());
|
||||
assert_eq!(vault.secrets(), Vec::<String>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_secret_removed_outside_the_app_is_no_longer_listed() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let vault = Arc::new(FakeVault::default());
|
||||
let file = with_fake(&tmp.path().join("ssh-passwords.json"), &vault);
|
||||
file.set("a", "pw").unwrap();
|
||||
vault.entries.lock().clear();
|
||||
assert_eq!(file.get("a"), None);
|
||||
assert_eq!(file.keys(), Vec::<String>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_index_write_takes_the_secret_back_out_of_the_store() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("ssh-passwords.json");
|
||||
std::fs::create_dir(&path).unwrap();
|
||||
let vault = Arc::new(FakeVault::default());
|
||||
assert!(with_fake(&path, &vault).set("a", "pw").is_err());
|
||||
assert_eq!(vault.secrets(), Vec::<String>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unreadable_file_is_set_aside_not_overwritten() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("ssh-passwords.json");
|
||||
std::fs::write(&path, "{ not json").unwrap();
|
||||
let vault = Arc::new(FakeVault::default());
|
||||
let file = with_fake(&path, &vault);
|
||||
assert_eq!(file.keys(), Vec::<String>::new());
|
||||
file.set("a", "pw").unwrap();
|
||||
std::fs::write(&path, "{ not json either").unwrap();
|
||||
assert_eq!(file.keys(), Vec::<String>::new());
|
||||
assert_eq!(files_containing(tmp.path(), "{ not json").len(), 2, "both kept, neither overwritten");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stores_and_forgets_secrets() {
|
||||
use_memory_store();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let file = SecretFile::new(tmp.path().join("ssh-passwords.json"));
|
||||
file.set("a", "pw").unwrap();
|
||||
|
||||
@@ -267,19 +267,29 @@ impl Session {
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
let failed = |code: Option<u32>| {
|
||||
let err = String::from_utf8_lossy(&stderr.lock()).trim().to_owned();
|
||||
io(format!("cannot write {remote} on {} ({code:?}): {err}", self.label))
|
||||
};
|
||||
// A command that exits early (no gzip, a folder it cannot write) stops reading: a
|
||||
// write would wait for channel window forever, so each one races the exit.
|
||||
let mut exit = std::pin::pin!(process.wait());
|
||||
let mut sent = 0;
|
||||
for chunk in packed.chunks(256 * 1024) {
|
||||
process.write(chunk).await?;
|
||||
tokio::select! {
|
||||
written = process.write(chunk) => written?,
|
||||
code = &mut exit => return Err(failed(code)),
|
||||
}
|
||||
sent += chunk.len();
|
||||
progress(sent as f64 / packed.len().max(1) as f64);
|
||||
}
|
||||
process.eof().await?;
|
||||
match process.wait().await {
|
||||
tokio::select! {
|
||||
closed = process.eof() => closed?,
|
||||
code = &mut exit => return Err(failed(code)),
|
||||
}
|
||||
match exit.await {
|
||||
Some(0) => Ok(()),
|
||||
code => {
|
||||
let err = String::from_utf8_lossy(&stderr.lock()).trim().to_owned();
|
||||
Err(io(format!("cannot write {remote} on {} ({code:?}): {err}", self.label)))
|
||||
}
|
||||
code => Err(failed(code)),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
//! Saved passwords against the real OS credential store (Windows Credential
|
||||
//! Manager, macOS Keychain, Secret Service).
|
||||
|
||||
use ide_ssh::secrets::SecretFile;
|
||||
|
||||
/// Forgets every key of the file, also when the test panics.
|
||||
struct Forget<'a>(&'a SecretFile);
|
||||
|
||||
impl Drop for Forget<'_> {
|
||||
fn drop(&mut self) {
|
||||
for key in self.0.keys() {
|
||||
self.0.delete(&key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "uses the OS credential store"]
|
||||
fn saves_moves_and_forgets_in_the_os_store() {
|
||||
assert!(keyring::Entry::store_status().is_ok(), "no OS credential store");
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("git-credentials.json");
|
||||
std::fs::write(&path, r#"{"https://old.example": "me\nold-os-pw"}"#).unwrap();
|
||||
let file = SecretFile::new(path.clone());
|
||||
let forget = Forget(&file);
|
||||
file.set("https://new.example", "me\nnew-os-pw").unwrap();
|
||||
let on_disk = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(!on_disk.contains("old-os-pw") && !on_disk.contains("new-os-pw"), "{on_disk}");
|
||||
|
||||
let reopened = SecretFile::new(path);
|
||||
assert_eq!(reopened.get("https://old.example").as_deref(), Some("me\nold-os-pw"));
|
||||
assert_eq!(reopened.get("https://new.example").as_deref(), Some("me\nnew-os-pw"));
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let index: serde_json::Value = serde_json::from_str(&on_disk).unwrap();
|
||||
let service = format!("ai.l0ng.openidea/{}", index["id"].as_str().unwrap());
|
||||
let listed = std::process::Command::new("cmdkey").arg("/list").output().unwrap();
|
||||
let listed = String::from_utf8_lossy(&listed.stdout);
|
||||
assert_eq!(listed.matches(service.as_str()).count(), 2, "{listed}");
|
||||
}
|
||||
drop(forget);
|
||||
assert_eq!(reopened.keys(), Vec::<String>::new());
|
||||
assert_eq!(reopened.get("https://old.example"), None);
|
||||
}
|
||||
@@ -269,3 +269,26 @@ async fn runs_commands_and_streams_processes() {
|
||||
assert_eq!(process.wait().await, Some(3));
|
||||
assert_eq!(*seen.lock(), ("ran: python3 main.py".to_owned(), "oops".to_owned()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn upload_reports_a_command_that_stops_reading() {
|
||||
let env = env().await;
|
||||
let session = env.connect_trusted(&env.target(SshAuth::Password), Some("secret")).await.ok().unwrap();
|
||||
// Incompressible and larger than the channel window: the command exits (status 3, `oops`)
|
||||
// without reading it, as when the host has no gzip or the folder is not writable.
|
||||
let mut state = 0x9e37_79b9_7f4a_7c15_u64;
|
||||
let data: Vec<u8> = (0..8 << 20)
|
||||
.map(|_| {
|
||||
state ^= state << 13;
|
||||
state ^= state >> 7;
|
||||
state ^= state << 17;
|
||||
state as u8
|
||||
})
|
||||
.collect();
|
||||
let put = session.put("/home/dev/.openidea/server/openidea-server", &data);
|
||||
let err = tokio::time::timeout(std::time::Duration::from_secs(20), put)
|
||||
.await
|
||||
.expect("the upload must end when the command exits")
|
||||
.unwrap_err();
|
||||
assert!(err.message.contains("oops"), "{}", err.message);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ ide-core.workspace = true
|
||||
# Hence no `unicode_lines` / `cr_lines` (a test guards against feature unification).
|
||||
ropey = { version = "1.6", default-features = false, features = ["simd"] }
|
||||
serde.workspace = true
|
||||
similar = { version = "2.7", default-features = false }
|
||||
specta.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -73,6 +73,10 @@ fn bench(c: &mut Criterion) {
|
||||
BatchSize::SmallInput,
|
||||
)
|
||||
});
|
||||
let two_edits = text.replacen("value_100 ", "renamed_100 ", 1).replacen("value_9000 ", "renamed_9000 ", 1);
|
||||
c.bench_function("text/diff_two_edits_1mb", |b| b.iter(|| base.diff(black_box(&two_edits))));
|
||||
let every_line = text.replace("compute", "calculate");
|
||||
c.bench_function("text/diff_every_line_1mb", |b| b.iter(|| base.diff(black_box(&every_line))));
|
||||
c.bench_function("text/offset_to_pos_round_trip", |b| {
|
||||
let mut rng = Lcg(3);
|
||||
let len = base.len_utf16();
|
||||
|
||||
+124
-31
@@ -14,6 +14,7 @@ use ide_core::{ChangeSet, IdeError, IdeResult, Pos, Range, TextChange, TextEdit}
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::borrow::Cow;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
pub use ropey;
|
||||
|
||||
@@ -66,6 +67,36 @@ impl LineEnding {
|
||||
}
|
||||
}
|
||||
|
||||
/// How long [`Document::diff`] may look for separate regions, so a reload never stalls on a huge file.
|
||||
const DIFF_BUDGET: Duration = Duration::from_millis(50);
|
||||
|
||||
type LineRange = std::ops::Range<usize>;
|
||||
|
||||
/// Byte lengths of the common prefix and suffix; they never overlap.
|
||||
fn common_ends(a: &str, b: &str) -> (usize, usize) {
|
||||
let shared = |a: &mut dyn Iterator<Item = char>, b: &mut dyn Iterator<Item = char>| -> usize {
|
||||
a.zip(b).take_while(|(x, y)| x == y).map(|(x, _)| x.len_utf8()).sum()
|
||||
};
|
||||
let prefix = shared(&mut a.chars(), &mut b.chars());
|
||||
let suffix = shared(&mut a[prefix..].chars().rev(), &mut b[prefix..].chars().rev());
|
||||
(prefix, suffix)
|
||||
}
|
||||
|
||||
/// Byte offset of each line, then the total length.
|
||||
fn line_starts(lines: &[&str]) -> Vec<usize> {
|
||||
let mut at = 0;
|
||||
let mut starts = vec![0];
|
||||
starts.extend(lines.iter().map(|line| {
|
||||
at += line.len();
|
||||
at
|
||||
}));
|
||||
starts
|
||||
}
|
||||
|
||||
fn utf16_len(text: &str) -> u32 {
|
||||
text.chars().map(|c| c.len_utf16() as u32).sum()
|
||||
}
|
||||
|
||||
/// Converts `\r\n` and lone `\r` to `\n`. Borrows when there is nothing to do.
|
||||
pub fn normalize_line_endings(text: &str) -> Cow<'_, str> {
|
||||
if !text.contains('\r') {
|
||||
@@ -334,42 +365,63 @@ impl Document {
|
||||
cs
|
||||
}
|
||||
|
||||
/// The minimal single-change (common prefix/suffix) change set turning the
|
||||
/// current text into `text` (after line-ending normalization). Empty when equal.
|
||||
/// The change set turning the current text into `text` (after line-ending
|
||||
/// normalization), one change per changed region. Empty when equal. A single change
|
||||
/// spanning two distant edits would drag the editor's caret and view to the first one.
|
||||
pub fn diff(&self, text: &str) -> ChangeSet {
|
||||
self.diff_until(text, Instant::now() + DIFF_BUDGET)
|
||||
}
|
||||
|
||||
/// Past `deadline` the regions get coarser, never wrong.
|
||||
fn diff_until(&self, text: &str, deadline: Instant) -> ChangeSet {
|
||||
let new = normalize_line_endings(text);
|
||||
let old_chars = self.rope.len_chars();
|
||||
let old = self.rope.to_string();
|
||||
let (prefix, suffix) = common_ends(&old, &new);
|
||||
let old_rest = &old[prefix..old.len() - suffix];
|
||||
let new_rest = &new[prefix..new.len() - suffix];
|
||||
|
||||
let mut prefix_chars = 0usize;
|
||||
let mut prefix_bytes = 0usize;
|
||||
for (a, b) in self.rope.chars().zip(new.chars()) {
|
||||
if a != b {
|
||||
break;
|
||||
let old_lines: Vec<&str> = old_rest.split_inclusive('\n').collect();
|
||||
let new_lines: Vec<&str> = new_rest.split_inclusive('\n').collect();
|
||||
let old_starts = line_starts(&old_lines);
|
||||
let new_starts = line_starts(&new_lines);
|
||||
|
||||
let mut changes = Vec::new();
|
||||
let mut utf16_at = utf16_len(&old[..prefix]);
|
||||
let mut old_at = 0;
|
||||
let mut region: Option<(LineRange, LineRange)> = None;
|
||||
let ops =
|
||||
similar::capture_diff_slices_deadline(similar::Algorithm::Myers, &old_lines, &new_lines, Some(deadline));
|
||||
let mut flush = |region: Option<(LineRange, LineRange)>| {
|
||||
let Some((old_range, new_range)) = region else { return };
|
||||
let region_old = &old_rest[old_starts[old_range.start]..old_starts[old_range.end]];
|
||||
let region_new = &new_rest[new_starts[new_range.start]..new_starts[new_range.end]];
|
||||
let (head, tail) = common_ends(region_old, region_new);
|
||||
let start = old_starts[old_range.start] + head;
|
||||
let end = old_starts[old_range.end] - tail;
|
||||
let from = utf16_at + utf16_len(&old_rest[old_at..start]);
|
||||
let to = from + utf16_len(&old_rest[start..end]);
|
||||
changes.push(TextChange { from, to, insert: region_new[head..region_new.len() - tail].to_owned() });
|
||||
utf16_at = to;
|
||||
old_at = end;
|
||||
};
|
||||
// Positions come from the lengths: `similar` can report a stale start for the empty
|
||||
// side of an op (an insert after an equal run still names the run's first old line).
|
||||
let (mut old_line, mut new_line) = (0, 0);
|
||||
for op in ops {
|
||||
let old_end = old_line + op.old_range().len();
|
||||
let new_end = new_line + op.new_range().len();
|
||||
if op.tag() == similar::DiffTag::Equal {
|
||||
flush(region.take());
|
||||
} else {
|
||||
region = Some(match region.take() {
|
||||
Some((o, n)) => (o.start..old_end, n.start..new_end),
|
||||
None => (old_line..old_end, new_line..new_end),
|
||||
});
|
||||
}
|
||||
prefix_chars += 1;
|
||||
prefix_bytes += b.len_utf8();
|
||||
(old_line, new_line) = (old_end, new_end);
|
||||
}
|
||||
let new_rest = &new[prefix_bytes..];
|
||||
let new_rest_chars = new_rest.chars().count();
|
||||
if prefix_chars == old_chars && new_rest_chars == 0 {
|
||||
return ChangeSet::default();
|
||||
}
|
||||
|
||||
let max_suffix = (old_chars - prefix_chars).min(new_rest_chars);
|
||||
let mut suffix_chars = 0usize;
|
||||
let mut suffix_bytes = 0usize;
|
||||
for (a, b) in self.rope.chars_at(old_chars).reversed().zip(new_rest.chars().rev()).take(max_suffix) {
|
||||
if a != b {
|
||||
break;
|
||||
}
|
||||
suffix_chars += 1;
|
||||
suffix_bytes += b.len_utf8();
|
||||
}
|
||||
|
||||
let from = self.rope.char_to_utf16_cu(prefix_chars) as u32;
|
||||
let to = self.rope.char_to_utf16_cu(old_chars - suffix_chars) as u32;
|
||||
let insert = new_rest[..new_rest.len() - suffix_bytes].to_owned();
|
||||
ChangeSet { changes: vec![TextChange { from, to, insert }] }
|
||||
flush(region);
|
||||
ChangeSet { changes }
|
||||
}
|
||||
|
||||
// ---- coordinate conversion (UTF-16 offset ⇄ Pos ⇄ byte offset) ----
|
||||
@@ -606,4 +658,45 @@ mod tests {
|
||||
assert_eq!(set.changes, vec![tc(2, 2, "x")]);
|
||||
assert_eq!(doc.text(), "😀x😀");
|
||||
}
|
||||
|
||||
fn numbered_lines(n: usize) -> String {
|
||||
(0..n).map(|i| format!("line {i}\n")).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_keeps_distant_edits_apart() {
|
||||
let old = numbered_lines(400);
|
||||
let new = old.replace("line 20\n", "line 20x\n").replace("line 299\n", "line 299x\n");
|
||||
let at = |line: &str| (old.find(line).unwrap() + line.len() - 1) as u32;
|
||||
let set = Document::new(&old).diff(&new);
|
||||
assert_eq!(
|
||||
set.changes,
|
||||
vec![tc(at("line 20\n"), at("line 20\n"), "x"), tc(at("line 299\n"), at("line 299\n"), "x")]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_handles_added_and_removed_lines() {
|
||||
let old = "a\nb\nc\nd\ne\nf\n";
|
||||
let set = Document::new(old).diff("a\nnew\nb\nc\ne\nf\n");
|
||||
assert_eq!(set.changes, vec![tc(2, 2, "new\n"), tc(5, 7, "")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_counts_utf16_past_astral_text() {
|
||||
let old = "😀 one\nsame\n😀 two\n";
|
||||
let set = Document::new(old).diff("😀 ONE\nsame\n😀 TWO\n");
|
||||
assert_eq!(set.changes, vec![tc(3, 6, "ONE"), tc(15, 18, "TWO")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_past_its_deadline_is_coarser_but_still_correct() {
|
||||
let old = numbered_lines(5000);
|
||||
let new = old.replace("line 20\n", "line 20x\n").replace("line 4000\n", "line 4000x\n");
|
||||
let doc = Document::new(&old);
|
||||
let set = doc.diff_until(&new, std::time::Instant::now());
|
||||
let mut replayed = Document::new(&old);
|
||||
replayed.apply(0, &set).unwrap();
|
||||
assert_eq!(replayed.text(), new);
|
||||
}
|
||||
}
|
||||
@@ -5,3 +5,4 @@
|
||||
# It is recommended to check this file in to source control so that
|
||||
# everyone who runs the test benefits from these saved cases.
|
||||
cc afa0785335731ca8adc050b09312843aeb3ff05dc895963d09d9f728c553717c # shrinks to doc_text = "a", picks = [(0, 0, ""), (0, 0, ""), (658842129343018477, 1922606258417419361, ""), (0, 4297423518677873183, ""), (953525717999286983, 557348908835472853, "a"), (0, 0, "b"), (0, 1653396554766652711, "")], seed = 7072935092038531240
|
||||
cc dd6ec32be77e8ad96d0ce3ef14ad9d72d5ba2a06f227ea693bfdee62debf08b7 # shrinks to a = "中é\n\n", b = "\n\na"
|
||||
@@ -176,7 +176,7 @@ proptest! {
|
||||
let set = doc.replace_all(&b);
|
||||
prop_assert_eq!(doc.text(), b.clone());
|
||||
prop_assert_eq!(naive_apply(&a, &set), b.clone());
|
||||
prop_assert!(set.changes.len() <= 1);
|
||||
prop_assert!(set.changes.windows(2).all(|w| w[0].to < w[1].from));
|
||||
prop_assert_eq!(set.changes.is_empty(), a == b);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,12 @@
|
||||
//!
|
||||
//! Language servers find managed tools once [`extend_process_path`] has put
|
||||
//! [`tools_bin_dirs`] on the process `PATH` at startup.
|
||||
//!
|
||||
//! GitHub API downloads prefer `GITHUB_TOKEN`, then `GH_TOKEN`, then the active
|
||||
//! `github.com` account from `gh auth token --hostname github.com`. The CLI is
|
||||
//! optional; absent credentials retain anonymous downloads. Tokens are passed
|
||||
//! to curl over stdin, never stored in tool manifests or command arguments, and
|
||||
//! are not sent to other download origins. CLI lookup is cancellable and bounded.
|
||||
|
||||
pub mod archive;
|
||||
mod bundled;
|
||||
@@ -389,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")
|
||||
@@ -485,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.
|
||||
|
||||
+346
-17
@@ -6,9 +6,9 @@ use ide_core::error::ErrorKind;
|
||||
use ide_core::output_encoding::decode_tool_output;
|
||||
use ide_core::process::{kill_tree, own_group};
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
use std::io::{BufRead, BufReader, Read};
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::path::Path;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::mpsc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
@@ -144,6 +144,99 @@ fn strip_ansi(s: &str) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
const MAX_GITHUB_TOKEN: usize = 4096;
|
||||
|
||||
fn valid_github_token(value: String) -> Option<String> {
|
||||
let value = value.trim();
|
||||
(!value.is_empty() && value.len() <= MAX_GITHUB_TOKEN && value.bytes().all(|b| b.is_ascii_graphic()))
|
||||
.then(|| value.to_owned())
|
||||
}
|
||||
|
||||
fn environment_github_token(read: impl Fn(&str) -> Option<String>) -> Option<String> {
|
||||
["GITHUB_TOKEN", "GH_TOKEN"].into_iter().find_map(|key| read(key).and_then(valid_github_token))
|
||||
}
|
||||
|
||||
/// Ask gh for the active github.com account on each request, so switching or
|
||||
/// logging out takes effect without persisting a second copy of the credential.
|
||||
fn read_gh_token(gh: &Path, cancel: &CancellationToken, timeout: Duration) -> IdeResult<Option<String>> {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let mut cmd = Command::new(gh);
|
||||
own_group(ide_core::process::prepare(&mut cmd));
|
||||
cmd.args(["auth", "token", "--hostname", "github.com"])
|
||||
.env("GH_PROMPT_DISABLED", "1")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null());
|
||||
let Ok(mut child) = cmd.spawn() else { return Ok(None) };
|
||||
let stdout = child.stdout.take().expect("piped");
|
||||
let (tx, rx) = mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let mut bytes = Vec::new();
|
||||
let _ = stdout.take((MAX_GITHUB_TOKEN + 2) as u64).read_to_end(&mut bytes);
|
||||
let _ = tx.send(bytes);
|
||||
});
|
||||
let started = Instant::now();
|
||||
let mut status = None;
|
||||
let mut output = None;
|
||||
let bytes = loop {
|
||||
if cancel.is_cancelled() || started.elapsed() >= timeout {
|
||||
kill_tree(&mut child);
|
||||
return if cancel.is_cancelled() { Err(IdeError::cancelled()) } else { Ok(None) };
|
||||
}
|
||||
if status.is_none() {
|
||||
match child.try_wait() {
|
||||
Ok(value) => status = value,
|
||||
Err(_) => {
|
||||
kill_tree(&mut child);
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(status) = status {
|
||||
if !status.success() {
|
||||
kill_tree(&mut child);
|
||||
return Ok(None);
|
||||
}
|
||||
if let Some(bytes) = output.take() {
|
||||
break bytes;
|
||||
}
|
||||
}
|
||||
match rx.recv_timeout(Duration::from_millis(15)) {
|
||||
Ok(bytes) => output = Some(bytes),
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => {}
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => std::thread::sleep(Duration::from_millis(15)),
|
||||
}
|
||||
};
|
||||
if bytes.len() > MAX_GITHUB_TOKEN + 1 {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(String::from_utf8(bytes).ok().and_then(valid_github_token))
|
||||
}
|
||||
|
||||
fn send_auth(child: &mut Child, token: Option<&str>) -> IdeResult<()> {
|
||||
if let Some(token) = token {
|
||||
let result = child
|
||||
.stdin
|
||||
.take()
|
||||
.expect("authenticated request has piped stdin")
|
||||
.write_all(format!("Authorization: Bearer {token}\n").as_bytes());
|
||||
if let Err(error) = result {
|
||||
kill_tree(child);
|
||||
return Err(IdeError::unavailable(format!("cannot pass download credentials to curl: {error}")));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn redact_auth(message: &str, token: Option<&str>) -> String {
|
||||
match token {
|
||||
Some(token) => message.replace(token, "[redacted]"),
|
||||
None => message.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `curl` checks certificate revocation with Schannel and takes
|
||||
/// `--ssl-revoke-best-effort` (7.70+). Schannel fails the whole download with
|
||||
/// CRYPT_E_REVOCATION_OFFLINE when the CRL/OCSP servers are unreachable (corporate
|
||||
@@ -174,6 +267,8 @@ fn curl_takes_revoke_best_effort(version: &str) -> bool {
|
||||
/// mainstream Linux), so the IDE carries no TLS stack of its own.
|
||||
pub struct Http {
|
||||
curl: std::path::PathBuf,
|
||||
gh: Option<std::path::PathBuf>,
|
||||
explicit_github_token: Option<String>,
|
||||
https_only: bool,
|
||||
revoke_best_effort: bool,
|
||||
}
|
||||
@@ -182,8 +277,10 @@ impl Http {
|
||||
pub fn new(search_path: &std::ffi::OsStr) -> IdeResult<Self> {
|
||||
let curl = which::which_in("curl", Some(search_path), ".")
|
||||
.map_err(|_| IdeError::unavailable("`curl` is required to download tools"))?;
|
||||
let gh = which::which_in("gh", Some(search_path), ".").ok();
|
||||
let explicit_github_token = environment_github_token(|key| std::env::var(key).ok());
|
||||
let revoke_best_effort = cfg!(windows) && revoke_best_effort(&curl);
|
||||
Ok(Self { curl, https_only: false, revoke_best_effort })
|
||||
Ok(Self { curl, gh, explicit_github_token, https_only: false, revoke_best_effort })
|
||||
}
|
||||
|
||||
/// Refuses every protocol but HTTPS, redirects included.
|
||||
@@ -192,8 +289,28 @@ impl Http {
|
||||
self
|
||||
}
|
||||
|
||||
fn base(&self, url: &str) -> Command {
|
||||
fn base(&self, url: &str, cancel: &CancellationToken) -> IdeResult<(Command, Option<String>)> {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let github = url.starts_with("https://api.github.com/");
|
||||
let token = if github {
|
||||
match &self.explicit_github_token {
|
||||
Some(token) => Some(token.clone()),
|
||||
None => match &self.gh {
|
||||
Some(gh) => read_gh_token(gh, cancel, Duration::from_secs(5))?,
|
||||
None => None,
|
||||
},
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut cmd = Command::new(&self.curl);
|
||||
// A user curlrc must not enable verbose token logging or forwarding
|
||||
// Authorization across origins on authenticated API requests.
|
||||
if token.is_some() {
|
||||
cmd.arg("-q");
|
||||
}
|
||||
own_group(ide_core::process::prepare(&mut cmd));
|
||||
cmd.args(["-fsSL", "--retry", "2", "--connect-timeout", "20", "-A", "openidea"]);
|
||||
// System Settings › HTTP Proxy (a manual proxy comes in through `prepare`'s environment).
|
||||
@@ -204,13 +321,14 @@ impl Http {
|
||||
if self.revoke_best_effort {
|
||||
cmd.arg("--ssl-revoke-best-effort");
|
||||
}
|
||||
if url.starts_with("https://api.github.com/")
|
||||
&& let Ok(token) = std::env::var("GITHUB_TOKEN").or_else(|_| std::env::var("GH_TOKEN"))
|
||||
&& !token.is_empty()
|
||||
{
|
||||
cmd.arg("-H").arg(format!("Authorization: Bearer {token}"));
|
||||
cmd.stdin(Stdio::null());
|
||||
if token.is_some() {
|
||||
// Keep credentials out of process arguments, inherited environment,
|
||||
// and files. curl strips Authorization on cross-origin redirects.
|
||||
cmd.args(["--header", "@-", "--proto-redir", "=https"]);
|
||||
cmd.env_remove("GH_TOKEN").env_remove("GITHUB_TOKEN").stdin(Stdio::piped());
|
||||
}
|
||||
cmd
|
||||
Ok((cmd, token))
|
||||
}
|
||||
|
||||
/// GETs `url` as text.
|
||||
@@ -235,15 +353,16 @@ impl Http {
|
||||
empty_on_404: bool,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<String> {
|
||||
let mut cmd = self.base(url);
|
||||
let (mut cmd, token) = self.base(url, cancel)?;
|
||||
if empty_on_404 {
|
||||
cmd.args(["--write-out", "\n%{http_code}"]);
|
||||
}
|
||||
if let Some(max) = max_bytes {
|
||||
cmd.arg("--max-filesize").arg(max.to_string());
|
||||
}
|
||||
cmd.arg(url).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
cmd.arg(url).stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
let mut child = cmd.spawn().map_err(|e| IdeError::unavailable(format!("cannot run curl: {e}")))?;
|
||||
send_auth(&mut child, token.as_deref())?;
|
||||
let stdout = child.stdout.take().expect("piped");
|
||||
let mut stderr = child.stderr.take().expect("piped");
|
||||
// Past the limit the pipe closes, which stops curl.
|
||||
@@ -280,7 +399,10 @@ impl Http {
|
||||
let mut err = Vec::new();
|
||||
let _ = stderr.read_to_end(&mut err);
|
||||
let err = decode_tool_output(&err);
|
||||
return Err(IdeError::new(ErrorKind::Io, format!("GET {url} failed: {}", err.trim())));
|
||||
return Err(IdeError::new(
|
||||
ErrorKind::Io,
|
||||
format!("GET {url} failed: {}", redact_auth(err.trim(), token.as_deref())),
|
||||
));
|
||||
}
|
||||
String::from_utf8(body).map_err(|_| IdeError::new(ErrorKind::Protocol, format!("{url}: response is not UTF-8")))
|
||||
}
|
||||
@@ -294,9 +416,10 @@ impl Http {
|
||||
cancel: &CancellationToken,
|
||||
emit: Emit,
|
||||
) -> IdeResult<()> {
|
||||
let mut cmd = self.base(url);
|
||||
cmd.arg("-o").arg(dest).arg(url).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::piped());
|
||||
let (mut cmd, token) = self.base(url, cancel)?;
|
||||
cmd.arg("-o").arg(dest).arg(url).stdout(Stdio::null()).stderr(Stdio::piped());
|
||||
let mut child = cmd.spawn().map_err(|e| IdeError::unavailable(format!("cannot run curl: {e}")))?;
|
||||
send_auth(&mut child, token.as_deref())?;
|
||||
let mut stderr = child.stderr.take().expect("piped");
|
||||
let err_t = std::thread::spawn(move || {
|
||||
let mut s = Vec::new();
|
||||
@@ -327,7 +450,10 @@ impl Http {
|
||||
if !status.success() {
|
||||
return Err(IdeError::new(
|
||||
ErrorKind::Io,
|
||||
format!("download of {url} failed: {}", err_t.join().unwrap_or_default().trim()),
|
||||
format!(
|
||||
"download of {url} failed: {}",
|
||||
redact_auth(err_t.join().unwrap_or_default().trim(), token.as_deref())
|
||||
),
|
||||
));
|
||||
}
|
||||
let bytes = std::fs::metadata(dest).map(|m| m.len()).unwrap_or(0);
|
||||
@@ -490,7 +616,13 @@ mod tests {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let curl = tmp.path().join("curl");
|
||||
let http = Http { curl: curl.clone(), https_only: false, revoke_best_effort: false };
|
||||
let http = Http {
|
||||
curl: curl.clone(),
|
||||
gh: None,
|
||||
explicit_github_token: None,
|
||||
https_only: false,
|
||||
revoke_best_effort: false,
|
||||
};
|
||||
for (code, exit, expected) in [(404, 22, Some("[]")), (200, 0, Some("payload")), (403, 22, None), (0, 7, None)]
|
||||
{
|
||||
std::fs::write(&curl, format!("#!/bin/sh\nprintf 'payload\\n{code:03}'\nexit {exit}\n")).unwrap();
|
||||
@@ -595,3 +727,200 @@ mod input_tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod github_auth_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn explicit_tokens_keep_precedence_and_reject_header_injection() {
|
||||
let read = |key: &str| Some(if key == "GITHUB_TOKEN" { "first" } else { "second" }.to_owned());
|
||||
assert_eq!(environment_github_token(read).as_deref(), Some("first"));
|
||||
assert_eq!(
|
||||
environment_github_token(|key| Some(if key == "GITHUB_TOKEN" { "" } else { "second" }.into())).as_deref(),
|
||||
Some("second")
|
||||
);
|
||||
for bad in ["", "two words", "token\r\nX-Injected: yes", "token\0"] {
|
||||
assert!(valid_github_token(bad.into()).is_none());
|
||||
}
|
||||
assert!(valid_github_token("a".repeat(MAX_GITHUB_TOKEN + 1)).is_none());
|
||||
assert_eq!(valid_github_token("gho_example\n".into()).as_deref(), Some("gho_example"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
mod unix {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn quote(path: &Path) -> String {
|
||||
format!("'{}'", path.display().to_string().replace('\'', "'\\''"))
|
||||
}
|
||||
struct Fixture {
|
||||
dir: tempfile::TempDir,
|
||||
http: Http,
|
||||
}
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let curl = dir.path().join("curl");
|
||||
let gh = dir.path().join("gh");
|
||||
let fixture = Self {
|
||||
dir,
|
||||
http: Http {
|
||||
curl,
|
||||
gh: Some(gh),
|
||||
explicit_github_token: None,
|
||||
https_only: false,
|
||||
revoke_best_effort: false,
|
||||
},
|
||||
};
|
||||
fixture.script(
|
||||
"gh",
|
||||
&format!(
|
||||
"printf '%s\\n' \"$@\" >> {}\n/bin/cat {}",
|
||||
quote(&fixture.path("gh-args")),
|
||||
quote(&fixture.path("token"))
|
||||
),
|
||||
);
|
||||
fs::write(fixture.path("token"), "gho_fake_first\n").unwrap();
|
||||
fixture.curl("printf '{\"ok\":true}'");
|
||||
fixture
|
||||
}
|
||||
fn path(&self, name: &str) -> PathBuf {
|
||||
self.dir.path().join(name)
|
||||
}
|
||||
fn script(&self, name: &str, body: &str) {
|
||||
let path = self.path(name);
|
||||
fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap();
|
||||
fs::set_permissions(path, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
}
|
||||
fn curl(&self, body: &str) {
|
||||
self.script(
|
||||
"curl",
|
||||
&format!(
|
||||
"printf '%s\\n' \"$@\" > {}\n/bin/cat > {}\n{body}",
|
||||
quote(&self.path("curl-args")),
|
||||
quote(&self.path("headers"))
|
||||
),
|
||||
);
|
||||
}
|
||||
fn get(&self, url: &str) -> IdeResult<String> {
|
||||
self.http.get_text(url, &CancellationToken::new())
|
||||
}
|
||||
}
|
||||
const API: &str = "https://api.github.com/repos/example/project/releases/latest";
|
||||
|
||||
#[test]
|
||||
fn active_gh_account_is_read_per_request_and_token_is_not_an_argument() {
|
||||
let f = Fixture::new();
|
||||
assert_eq!(f.get(API).unwrap(), "{\"ok\":true}");
|
||||
assert_eq!(fs::read_to_string(f.path("headers")).unwrap(), "Authorization: Bearer gho_fake_first\n");
|
||||
let args = fs::read_to_string(f.path("curl-args")).unwrap();
|
||||
assert!(!args.contains("gho_fake"));
|
||||
assert!(args.starts_with("-q\n"));
|
||||
assert!(args.contains("--header\n@-\n--proto-redir\n=https\n"));
|
||||
assert!(!args.contains("--location-trusted"));
|
||||
assert_eq!(fs::read_to_string(f.path("gh-args")).unwrap(), "auth\ntoken\n--hostname\ngithub.com\n");
|
||||
fs::write(f.path("token"), "gho_fake_second\n").unwrap();
|
||||
f.get(API).unwrap();
|
||||
assert_eq!(fs::read_to_string(f.path("headers")).unwrap(), "Authorization: Bearer gho_fake_second\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_token_avoids_gh_and_non_github_urls_receive_no_credentials() {
|
||||
let mut f = Fixture::new();
|
||||
f.http.explicit_github_token = Some("explicit_fake".into());
|
||||
f.get(API).unwrap();
|
||||
assert!(!f.path("gh-args").exists());
|
||||
assert_eq!(fs::read_to_string(f.path("headers")).unwrap(), "Authorization: Bearer explicit_fake\n");
|
||||
f.http.explicit_github_token = None;
|
||||
for url in [
|
||||
"https://example.com/file",
|
||||
"http://api.github.com/repos/x",
|
||||
"https://api.github.com.evil.example/file",
|
||||
"https://[email protected]/file",
|
||||
"https://github.com/example/project/releases/download/v1/file",
|
||||
] {
|
||||
f.get(url).unwrap();
|
||||
assert_eq!(fs::read_to_string(f.path("headers")).unwrap(), "");
|
||||
assert!(!f.path("gh-args").exists());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_logged_out_or_malformed_gh_falls_back_to_anonymous() {
|
||||
let mut f = Fixture::new();
|
||||
for body in ["exit 1", "printf 'fake\\nX-Injected: yes\\n'", "printf ''", "printf 'fake'; exit 1"] {
|
||||
f.script("gh", body);
|
||||
f.get(API).unwrap();
|
||||
assert_eq!(fs::read_to_string(f.path("headers")).unwrap(), "");
|
||||
}
|
||||
f.http.gh = None;
|
||||
f.get(API).unwrap();
|
||||
assert_eq!(fs::read_to_string(f.path("headers")).unwrap(), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn download_auth_and_errors_do_not_expose_the_token() {
|
||||
let f = Fixture::new();
|
||||
let dest = f.path("download");
|
||||
f.curl(&format!("printf downloaded > {}", quote(&dest)));
|
||||
f.http.download(API, &dest, None, &CancellationToken::new(), &|_| {}).unwrap();
|
||||
assert_eq!(fs::read_to_string(&dest).unwrap(), "downloaded");
|
||||
assert!(fs::read_to_string(f.path("headers")).unwrap().contains("gho_fake_first"));
|
||||
f.curl(&format!("/bin/cat {} >&2; exit 22", quote(&f.path("headers"))));
|
||||
let error = f.get(API).unwrap_err().to_string();
|
||||
assert!(error.contains("[redacted]"));
|
||||
assert!(!error.contains("gho_fake_first"));
|
||||
let error = f.http.download(API, &dest, None, &CancellationToken::new(), &|_| {}).unwrap_err().to_string();
|
||||
assert!(!error.contains("gho_fake_first"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credential_lookup_is_bounded_and_cancellable_even_with_inherited_stdout() {
|
||||
let f = Fixture::new();
|
||||
// The parent exits but a child keeps stdout open: joining a reader
|
||||
// without checking the deadline would hang here.
|
||||
f.script("gh", "/bin/sleep 5 &\nexit 0");
|
||||
let started = Instant::now();
|
||||
assert!(
|
||||
read_gh_token(f.http.gh.as_ref().unwrap(), &CancellationToken::new(), Duration::from_millis(60))
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert!(started.elapsed() < Duration::from_secs(3));
|
||||
f.script("gh", "/bin/sleep 5");
|
||||
let cancel = CancellationToken::new();
|
||||
let trigger = cancel.clone();
|
||||
let thread = std::thread::spawn(move || {
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
trigger.cancel();
|
||||
});
|
||||
assert_eq!(f.http.get_text(API, &cancel).unwrap_err().kind, ErrorKind::Cancelled);
|
||||
thread.join().unwrap();
|
||||
assert!(!f.path("curl-args").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires an active gh github.com login and network access"]
|
||||
fn live_github_release_uses_authenticated_quota() {
|
||||
let http = Http::new(&std::env::var_os("PATH").unwrap()).unwrap();
|
||||
assert!(http.gh.is_some());
|
||||
let cancel = CancellationToken::new();
|
||||
let body = http.get_text_limited("https://api.github.com/rate_limit", Some(64 * 1024), &cancel).unwrap();
|
||||
let response: serde_json::Value = serde_json::from_str(&body).unwrap();
|
||||
assert!(response["resources"]["core"]["limit"].as_u64().unwrap() > 60);
|
||||
let body = http
|
||||
.get_text_limited(
|
||||
"https://api.github.com/repos/redhat-developer/vscode-xml/releases/latest",
|
||||
Some(1024 * 1024),
|
||||
&cancel,
|
||||
)
|
||||
.unwrap();
|
||||
let response: serde_json::Value = serde_json::from_str(&body).unwrap();
|
||||
assert!(response["tag_name"].is_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,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() {
|
||||
|
||||
@@ -15,6 +15,8 @@ serde_json.workspace = true
|
||||
specta.workspace = true
|
||||
gix = { workspace = true, features = ["sha1", "sha256", "status", "blob-diff", "revision", "index", "dirwalk", "parallel"] }
|
||||
tracing.workspace = true
|
||||
redb.workspace = true
|
||||
blake3.workspace = true
|
||||
tempfile.workspace = true
|
||||
which = "8"
|
||||
regex-lite = "0.1"
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
//! Bek branch partitioning and bottom-up block merge.
|
||||
//! Adapted from JetBrains intellij-community BekBranchCreator/BekBranch/Merger,
|
||||
//! Copyright 2000-2026 JetBrains s.r.o. and contributors, Apache-2.0.
|
||||
use std::cmp::Reverse;
|
||||
use std::collections::{BinaryHeap, HashSet};
|
||||
|
||||
pub(crate) fn sort(parents: &[Vec<usize>], times: &[i64], heads: &[usize]) -> Vec<usize> {
|
||||
let n = parents.len();
|
||||
let mut children = vec![Vec::new(); n];
|
||||
for (i, ps) in parents.iter().enumerate() {
|
||||
for &p in ps {
|
||||
children[p].push(i);
|
||||
}
|
||||
}
|
||||
let mut layout = vec![0usize; n];
|
||||
let mut important = Vec::new();
|
||||
let mut next_layout = 1;
|
||||
for &head in heads {
|
||||
if layout[head] != 0 {
|
||||
continue;
|
||||
}
|
||||
important.push(head);
|
||||
let mut stack = vec![head];
|
||||
while let Some(&node) = stack.last() {
|
||||
let first = layout[node] == 0;
|
||||
if first {
|
||||
layout[node] = next_layout;
|
||||
}
|
||||
if let Some(&p) = parents[node].iter().find(|&&p| layout[p] == 0) {
|
||||
stack.push(p);
|
||||
} else {
|
||||
if first {
|
||||
next_layout += 1;
|
||||
}
|
||||
stack.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut done = vec![false; n];
|
||||
let mut restrictions = vec![HashSet::new(); n];
|
||||
let mut releases = vec![Vec::new(); n];
|
||||
let mut branches = Vec::new();
|
||||
for head in important {
|
||||
if done[head] {
|
||||
continue;
|
||||
}
|
||||
let start = layout[head];
|
||||
done[head] = true;
|
||||
let mut branch = vec![head];
|
||||
let mut stack = vec![head];
|
||||
while let Some(&node) = stack.last() {
|
||||
let mut next = None;
|
||||
for &p in parents[node].iter().rev() {
|
||||
if done[p] {
|
||||
if layout[p] < start && restrictions[node].insert(p) {
|
||||
releases[p].push(node);
|
||||
}
|
||||
} else if layout[node] <= layout[p] && !children[p].iter().any(|&c| !done[c] && layout[c] <= layout[p])
|
||||
{
|
||||
next = Some(p);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if let Some(p) = next {
|
||||
done[p] = true;
|
||||
branch.push(p);
|
||||
stack.push(p);
|
||||
} else {
|
||||
stack.pop();
|
||||
}
|
||||
}
|
||||
branches.push(branch);
|
||||
}
|
||||
let mut owner = vec![0usize; n];
|
||||
for (b, nodes) in branches.iter().enumerate() {
|
||||
for &node in nodes {
|
||||
owner[node] = b;
|
||||
}
|
||||
}
|
||||
let mut remaining: Vec<_> = branches.iter().map(Vec::len).collect();
|
||||
let mut prepared = vec![None; branches.len()];
|
||||
let mut queue = BinaryHeap::new();
|
||||
let prepare = |b: usize,
|
||||
remaining: &[usize],
|
||||
restrictions: &[HashSet<usize>],
|
||||
prepared: &mut [Option<usize>],
|
||||
queue: &mut BinaryHeap<Reverse<(i64, usize)>>| {
|
||||
let end = remaining[b];
|
||||
if end == 0 || prepared[b].is_some() || !restrictions[branches[b][end - 1]].is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut start = end - 1;
|
||||
while start > 0 {
|
||||
let up = branches[b][start - 1];
|
||||
let down = branches[b][start];
|
||||
if !restrictions[up].is_empty() {
|
||||
break;
|
||||
}
|
||||
let connects = parents[up].contains(&down);
|
||||
if !(parents[up].len() > 1 && connects) {
|
||||
if !connects {
|
||||
break;
|
||||
}
|
||||
let delta = times[up].abs_diff(times[down]);
|
||||
if delta > 3 * 24 * 3600 || (start + 20 < end && delta > 4 * 3600) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
start -= 1;
|
||||
}
|
||||
prepared[b] = Some(start);
|
||||
queue.push(Reverse((times[branches[b][start]], b)));
|
||||
};
|
||||
for b in 0..branches.len() {
|
||||
prepare(b, &remaining, &restrictions, &mut prepared, &mut queue);
|
||||
}
|
||||
let mut result = vec![0; n];
|
||||
let mut offset = n;
|
||||
while let Some(Reverse((_, b))) = queue.pop() {
|
||||
let start = prepared[b].take().unwrap();
|
||||
let part = &branches[b][start..remaining[b]];
|
||||
offset -= part.len();
|
||||
result[offset..offset + part.len()].copy_from_slice(part);
|
||||
remaining[b] = start;
|
||||
let mut released = HashSet::from([b]);
|
||||
for &node in part {
|
||||
for &up in &releases[node] {
|
||||
restrictions[up].remove(&node);
|
||||
let branch = owner[up];
|
||||
if restrictions[up].is_empty() && remaining[branch] > 0 && branches[branch][remaining[branch] - 1] == up
|
||||
{
|
||||
released.insert(branch);
|
||||
}
|
||||
}
|
||||
}
|
||||
for branch in released {
|
||||
prepare(branch, &remaining, &restrictions, &mut prepared, &mut queue);
|
||||
}
|
||||
}
|
||||
debug_assert_eq!(offset, 0, "Bek must place every node");
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn branch_blocks_preserve_every_edge_under_clock_skew_and_interleaved_refs() {
|
||||
let mut seed = 17u64;
|
||||
let mut random = || {
|
||||
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
|
||||
seed as usize
|
||||
};
|
||||
for _ in 0..200 {
|
||||
let n = 70;
|
||||
let parents: Vec<Vec<usize>> = (0..n)
|
||||
.map(|i| {
|
||||
let mut ps = Vec::new();
|
||||
if i + 1 < n {
|
||||
for _ in 0..(random() % 3 + 1) {
|
||||
let p = i + 1 + random() % (n - i - 1);
|
||||
if !ps.contains(&p) {
|
||||
ps.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
ps
|
||||
})
|
||||
.collect();
|
||||
let times: Vec<_> = (0..n).map(|_| (random() % 1_000_000) as i64).collect();
|
||||
let mut children = vec![0; n];
|
||||
for ps in &parents {
|
||||
for &p in ps {
|
||||
children[p] += 1;
|
||||
}
|
||||
}
|
||||
let mut heads: Vec<_> = (0..n).filter(|&i| children[i] == 0 || i % 11 == 0).collect();
|
||||
heads.sort_by_cached_key(|_| random());
|
||||
let result = sort(&parents, ×, &heads);
|
||||
assert_eq!(result.iter().copied().collect::<HashSet<_>>().len(), n);
|
||||
let mut positions = vec![0; n];
|
||||
for (i, &node) in result.iter().enumerate() {
|
||||
positions[node] = i;
|
||||
}
|
||||
for (node, ps) in parents.iter().enumerate() {
|
||||
for &p in ps {
|
||||
assert!(positions[node] < positions[p]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+735
-272
File diff suppressed because it is too large.
Load diff
+254
-3
@@ -112,22 +112,64 @@ pub struct Commit {
|
||||
pub id: String,
|
||||
pub short_id: String,
|
||||
pub parents: Vec<String>,
|
||||
/// Connections in the filtered graph; never use these as revision/diff parents.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub graph_parents: Option<Vec<String>>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub graph_dashed: Vec<bool>,
|
||||
/// Repository-relative filename at this revision in rename-following history.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub history_path: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub log_position: Option<u32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub log_total: Option<u32>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub graph_boundary_parents: Vec<String>,
|
||||
/// Reachability from HEAD, computed independently of the visible filters.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub on_current_branch: Option<bool>,
|
||||
pub author: String,
|
||||
pub email: String,
|
||||
#[serde(default)]
|
||||
pub committer: String,
|
||||
#[serde(default)]
|
||||
pub committer_email: String,
|
||||
pub time_ms: f64,
|
||||
pub summary: String,
|
||||
pub message: String,
|
||||
#[serde(default)]
|
||||
pub message_truncated: bool,
|
||||
pub refs: Vec<String>,
|
||||
/// Committer time (Log › View Options › Commit Timestamp).
|
||||
#[serde(default)]
|
||||
pub commit_time_ms: f64,
|
||||
/// Per parent, under a user or text filter: the link passes commits the filter hides
|
||||
/// (`parents` then name the nearest shown ancestors). Empty when no parent is rewritten.
|
||||
/// Legacy alias of `graph_dashed`, indexed by displayed parents.
|
||||
#[serde(default)]
|
||||
#[specta(optional)]
|
||||
pub dashed: Vec<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LogDetails {
|
||||
pub message: String,
|
||||
pub containing_branches: Vec<String>,
|
||||
/// Git's verification code: G/U good, B bad, E error, N unsigned, X/Y expired, R revoked.
|
||||
pub signature: String,
|
||||
pub signer: String,
|
||||
pub key: String,
|
||||
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")]
|
||||
@@ -137,13 +179,29 @@ pub enum LogOrder {
|
||||
Topo,
|
||||
/// `--date-order`: commit time, but never a parent before its children.
|
||||
Date,
|
||||
// Topological grouping with incoming merge parents visited before the main line.
|
||||
Standard,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LogQuery {
|
||||
#[serde(default)]
|
||||
pub speed_search: Option<String>,
|
||||
#[serde(default)]
|
||||
pub search_fields: Vec<String>,
|
||||
/// Jump directly to an indexed window containing this commit (50 rows of context).
|
||||
#[serde(default)]
|
||||
pub seek: Option<String>,
|
||||
#[serde(default)]
|
||||
pub retain_until: Option<String>,
|
||||
pub branch: Option<String>,
|
||||
pub path: Option<ide_core::IpcPath>,
|
||||
#[serde(default)]
|
||||
pub paths: Vec<ide_core::IpcPath>,
|
||||
/// File history follows renames; structure filters retain fixed paths.
|
||||
#[serde(default)]
|
||||
pub follow: bool,
|
||||
pub author: Option<String>,
|
||||
pub text: Option<String>,
|
||||
pub skip: u32,
|
||||
@@ -152,6 +210,9 @@ pub struct LogQuery {
|
||||
/// not the left). Takes precedence over `branch`.
|
||||
#[serde(default)]
|
||||
pub range: Option<String>,
|
||||
/// Additional ranges unioned with each other and with selected branches.
|
||||
#[serde(default)]
|
||||
pub ranges: Vec<String>,
|
||||
/// `text` is a regular expression (Log filter `.*`).
|
||||
#[serde(default)]
|
||||
pub text_regex: bool,
|
||||
@@ -165,6 +226,8 @@ pub struct LogQuery {
|
||||
#[serde(default)]
|
||||
pub authors: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub authors_exact: bool,
|
||||
#[serde(default)]
|
||||
pub order: LogOrder,
|
||||
/// Follow only the first parent of merges (`--first-parent`).
|
||||
#[serde(default)]
|
||||
@@ -179,6 +242,13 @@ pub struct LogQuery {
|
||||
pub since_ms: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub until_ms: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub request: Option<ide_core::RequestId>,
|
||||
#[serde(default)]
|
||||
pub summary_only: bool,
|
||||
/// Inclusive refresh boundary. Clients join only verified linear prefixes.
|
||||
#[serde(default)]
|
||||
pub stop_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
@@ -487,6 +557,7 @@ pub struct ChangelistState {
|
||||
pub files: std::collections::BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
mod bek;
|
||||
mod blame;
|
||||
mod block_history;
|
||||
pub mod changelists;
|
||||
@@ -496,6 +567,7 @@ mod diff;
|
||||
mod history;
|
||||
mod ignore;
|
||||
mod index_writes;
|
||||
mod log_index;
|
||||
mod manage;
|
||||
mod ops;
|
||||
mod patch;
|
||||
@@ -599,6 +671,9 @@ pub struct Repo {
|
||||
git_dir: PathBuf,
|
||||
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>>,
|
||||
}
|
||||
|
||||
impl Repo {
|
||||
@@ -738,7 +813,183 @@ impl Repo {
|
||||
/// `refs` decorations are `HEAD`, branch names (`main`, `origin/main`) and `tag: <name>`.
|
||||
pub fn log(&self, query: &LogQuery) -> IdeResult<Vec<Commit>> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
history::log(self, query)
|
||||
history::log(self, query, &CancellationToken::new())
|
||||
}
|
||||
pub fn log_cancellable(&self, query: &LogQuery, cancel: &CancellationToken) -> IdeResult<Vec<Commit>> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
history::log(self, query, cancel)
|
||||
}
|
||||
/// Enables the disposable, content-addressed log cache outside the worktree.
|
||||
pub fn enable_log_index(&self, cache_dir: &Path) {
|
||||
let key = blake3::hash(self.git_dir.to_string_lossy().as_bytes()).to_hex().to_string();
|
||||
self.log_index.lock().unwrap_or_else(|p| p.into_inner()).open(&cache_dir.join(key));
|
||||
}
|
||||
/// Reference names and peeled commit ids, including tags and detached HEAD.
|
||||
pub fn log_refs(&self) -> IdeResult<std::collections::BTreeMap<String, String>> {
|
||||
let repo = self.gix()?;
|
||||
Ok(history::decorations(&repo)?
|
||||
.into_iter()
|
||||
.flat_map(|(id, names)| names.into_iter().map(move |name| (name, id.to_string())))
|
||||
.collect())
|
||||
}
|
||||
pub fn log_users(&self) -> IdeResult<Vec<String>> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
let out = cli::run(self.root(), &["log", "--all", "--format=%an <%ae>"], None)?;
|
||||
Ok(out
|
||||
.stdout_str()
|
||||
.lines()
|
||||
.filter(|line| !line.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect::<std::collections::BTreeSet<_>>()
|
||||
.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())
|
||||
}
|
||||
pub fn log_details_cancellable(&self, revision: &str, cancel: &CancellationToken) -> IdeResult<LogDetails> {
|
||||
let allowed = self.execution_allowed.load(std::sync::atomic::Ordering::Acquire);
|
||||
let _scope = crate::ExecutionScope::enter(allowed);
|
||||
let repo = self.gix()?;
|
||||
let object =
|
||||
repo.find_object(repo::resolve(&repo, revision)?).map_err(repo::gx)?.peel_to_commit().map_err(repo::gx)?;
|
||||
let commit_id = object.id;
|
||||
let id = commit_id.to_string();
|
||||
let contains = format!("--contains={id}");
|
||||
let branches =
|
||||
cli::run_cancellable(self.root(), &["branch", "-a", &contains, "--format=%(refname:short)"], cancel)?;
|
||||
let cache_key = format!("{allowed}:{id}");
|
||||
let cached = self.log_signatures.lock().unwrap_or_else(|p| p.into_inner()).get(&cache_key).cloned();
|
||||
let mut details = if let Some(details) = cached {
|
||||
details
|
||||
} else {
|
||||
let end = object.data.windows(2).position(|s| s == b"\n\n").unwrap_or(object.data.len());
|
||||
let headers = &object.data[..end];
|
||||
let signed = headers.windows(8).any(|s| s == b"\ngpgsig ") || headers.windows(8).any(|s| s == b"\ngpgsig-");
|
||||
let output = if allowed && signed {
|
||||
Some(cli::run_cancellable(
|
||||
self.root(),
|
||||
&["log", "-1", "--format=%G?%x00%GS%x00%GK%x00%GF", "--end-of-options", &id],
|
||||
cancel,
|
||||
)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let text = output.as_ref().map(|out| out.stdout_str()).unwrap_or_default();
|
||||
let fields: Vec<_> = text.trim_end().split('\0').collect();
|
||||
let message = {
|
||||
let mut index = self.log_index.lock().unwrap_or_else(|p| p.into_inner());
|
||||
let message = index.message(&repo, commit_id)?;
|
||||
index.flush();
|
||||
message
|
||||
};
|
||||
let details = LogDetails {
|
||||
message,
|
||||
containing_branches: Vec::new(),
|
||||
signature: if !signed {
|
||||
"N".into()
|
||||
} else {
|
||||
fields.first().filter(|s| !s.is_empty()).unwrap_or(&"E").to_string()
|
||||
},
|
||||
signer: fields.get(1).unwrap_or(&"").to_string(),
|
||||
key: fields.get(2).unwrap_or(&"").to_string(),
|
||||
fingerprint: fields.get(3).unwrap_or(&"").to_string(),
|
||||
};
|
||||
self.log_signatures.lock().unwrap_or_else(|p| p.into_inner()).insert(cache_key, details.clone());
|
||||
details
|
||||
};
|
||||
details.containing_branches =
|
||||
branches.stdout_str().lines().filter(|s| !s.ends_with("/HEAD")).map(str::to_string).collect();
|
||||
Ok(details)
|
||||
}
|
||||
|
||||
/// Lists one immutable revision directory. Directory names end in `/`.
|
||||
pub fn revision_children(&self, revision: &str, directory: &str) -> IdeResult<Vec<String>> {
|
||||
let repo = self.gix()?;
|
||||
let id = repo::resolve(&repo, revision)?.to_string();
|
||||
let directory = self.rel_path(std::path::Path::new(directory))?;
|
||||
let tree = if directory.is_empty() { id } else { format!("{id}:{directory}") };
|
||||
let out = cli::run(self.root(), &["ls-tree", "-z", "--", &tree], None)?;
|
||||
Ok(out
|
||||
.stdout
|
||||
.split(|b| *b == 0)
|
||||
.filter_map(|line| {
|
||||
let split = line.iter().position(|b| *b == b'\t')?;
|
||||
let name = String::from_utf8_lossy(&line[split + 1..]);
|
||||
let dir = line.starts_with(b"040000 ");
|
||||
Some(format!(
|
||||
"{}{name}{}",
|
||||
if directory.is_empty() { String::new() } else { format!("{directory}/") },
|
||||
if dir { "/" } else { "" }
|
||||
))
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub fn revision_files(&self, revision: &str) -> IdeResult<Vec<String>> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
let repo = self.gix()?;
|
||||
let id = repo::resolve(&repo, revision)?.to_string();
|
||||
let out = cli::run(self.root(), &["ls-tree", "-r", "-z", "--name-only", "--", &id], None)?;
|
||||
Ok(out
|
||||
.stdout
|
||||
.split(|b| *b == 0)
|
||||
.filter(|p| !p.is_empty())
|
||||
.map(|p| String::from_utf8_lossy(p).into_owned())
|
||||
.collect())
|
||||
}
|
||||
/// Files changed by `commit` relative to its first parent (renames detected).
|
||||
pub fn commit_files(&self, commit: &str) -> IdeResult<Vec<ChangedFile>> {
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
//! Immutable commit metadata, persisted by object id. Ref decorations and graph
|
||||
//! projections are deliberately never cached as commit metadata.
|
||||
|
||||
use crate::Commit;
|
||||
use crate::repo::{gx, to_string};
|
||||
use gix::ObjectId;
|
||||
use gix::bstr::ByteSlice;
|
||||
use ide_core::IdeResult;
|
||||
use redb::{Database, MultimapTableDefinition, ReadableDatabase, TableDefinition};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex, OnceLock, Weak};
|
||||
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>,
|
||||
pub positions: HashMap<String, usize>,
|
||||
}
|
||||
impl IndexedView {
|
||||
pub fn new(commits: Vec<Commit>) -> Self {
|
||||
let positions = commits.iter().enumerate().map(|(i, c)| (c.id.clone(), i)).collect();
|
||||
Self { commits, positions }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct LogIndex {
|
||||
opened: bool,
|
||||
db: Option<Arc<Database>>,
|
||||
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>>,
|
||||
pub nodes: HashMap<ObjectId, (Vec<ObjectId>, i64)>,
|
||||
pub shallow: HashSet<ObjectId>,
|
||||
}
|
||||
|
||||
impl LogIndex {
|
||||
/// A permanent DAG: refresh discovers only new objects, then joins arbitrary
|
||||
/// changed tips against known ancestry. Reachability is recomputed for resets/deleted refs.
|
||||
pub fn graph(
|
||||
&mut self,
|
||||
repo: &gix::Repository,
|
||||
tips: &[ObjectId],
|
||||
shallow: &HashSet<ObjectId>,
|
||||
first_parent: bool,
|
||||
cancel: &ide_core::CancellationToken,
|
||||
) -> IdeResult<Vec<ObjectId>> {
|
||||
if &self.shallow != shallow {
|
||||
self.nodes.clear();
|
||||
self.shallow = shallow.clone();
|
||||
}
|
||||
let mut pending = tips.to_vec();
|
||||
while let Some(id) = pending.pop() {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(ide_core::IdeError::cancelled());
|
||||
}
|
||||
if self.nodes.contains_key(&id) {
|
||||
continue;
|
||||
}
|
||||
let record = self.get(repo, id)?;
|
||||
let parents: Vec<_> = if shallow.contains(&id) {
|
||||
Vec::new()
|
||||
} else {
|
||||
record.parents.iter().map(|s| ObjectId::from_hex(s.as_bytes()).map_err(gx)).collect::<IdeResult<_>>()?
|
||||
};
|
||||
pending.extend(parents.iter().copied());
|
||||
self.nodes.insert(id, (parents, (record.commit_time_ms / 1000.0) as i64));
|
||||
}
|
||||
let mut seen = HashSet::new();
|
||||
let mut ids = Vec::new();
|
||||
let mut pending = tips.to_vec();
|
||||
while let Some(id) = pending.pop() {
|
||||
if !seen.insert(id) {
|
||||
continue;
|
||||
}
|
||||
ids.push(id);
|
||||
if let Some((parents, _)) = self.nodes.get(&id) {
|
||||
pending.extend(parents.iter().take(if first_parent { 1 } else { usize::MAX }).rev().copied());
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
self.opened = true;
|
||||
// Several windows can read the same worktree; redb permits one owning
|
||||
// handle per file, shared here rather than silently disabling its index.
|
||||
static DATABASES: OnceLock<Mutex<HashMap<std::path::PathBuf, Weak<Database>>>> = OnceLock::new();
|
||||
let path = dir.join("commits.redb");
|
||||
let mut databases = DATABASES.get_or_init(Mutex::default).lock().unwrap_or_else(|p| p.into_inner());
|
||||
self.db = databases.get(&path).and_then(Weak::upgrade).or_else(|| {
|
||||
let db = Arc::new(std::fs::create_dir_all(dir).ok().and_then(|_| Database::create(&path).ok())?);
|
||||
databases.insert(path, Arc::downgrade(&db));
|
||||
Some(db)
|
||||
});
|
||||
}
|
||||
|
||||
pub fn get(&mut self, repo: &gix::Repository, id: ObjectId) -> IdeResult<Arc<Commit>> {
|
||||
if let Some(commit) = self.memory.get(&id) {
|
||||
return Ok(commit.clone());
|
||||
}
|
||||
let key = id.to_string();
|
||||
let cached = self.db.as_ref().and_then(|db| {
|
||||
let txn = db.begin_read().ok()?;
|
||||
let table = txn.open_table(COMMITS).ok()?;
|
||||
let value = table.get(key.as_str()).ok()??;
|
||||
serde_json::from_slice::<Commit>(value.value()).ok().filter(|c| c.id == key)
|
||||
});
|
||||
let commit = if let Some(commit) = cached {
|
||||
Arc::new(commit)
|
||||
} else {
|
||||
let commit = repo.find_commit(id).map_err(gx)?;
|
||||
let author = commit.author().map_err(gx)?;
|
||||
let committer = commit.committer().map_err(gx)?;
|
||||
let raw = commit.message_raw_sloppy();
|
||||
let summary = raw.split(|b| *b == b'\n').find(|l| !l.trim().is_empty()).unwrap_or_default().trim();
|
||||
let summary_text = to_string(summary.as_bstr());
|
||||
let record = Arc::new(Commit {
|
||||
id: key.clone(),
|
||||
short_id: key[..key.len().min(8)].to_string(),
|
||||
parents: commit.parent_ids().map(|p| p.to_string()).collect(),
|
||||
graph_parents: None,
|
||||
graph_dashed: Vec::new(),
|
||||
dashed: Vec::new(),
|
||||
on_current_branch: None,
|
||||
author: to_string(author.name.trim().as_bstr()),
|
||||
email: to_string(author.email.trim().as_bstr()),
|
||||
committer: to_string(committer.name.trim().as_bstr()),
|
||||
committer_email: to_string(committer.email.trim().as_bstr()),
|
||||
time_ms: author.time().map(|t| t.seconds as f64 * 1000.0).unwrap_or(0.0),
|
||||
commit_time_ms: committer.time().map(|t| t.seconds as f64 * 1000.0).unwrap_or(0.0),
|
||||
summary: summary_text.clone(),
|
||||
message: summary_text,
|
||||
message_truncated: raw.trim() != summary,
|
||||
history_path: None,
|
||||
log_position: None,
|
||||
log_total: None,
|
||||
graph_boundary_parents: Vec::new(),
|
||||
refs: Vec::new(),
|
||||
});
|
||||
if self.db.is_some() {
|
||||
self.dirty.push(record.clone());
|
||||
}
|
||||
record
|
||||
};
|
||||
// Keep hot pages in memory; older metadata stays in the persistent index.
|
||||
if self.memory.len() >= 4096 {
|
||||
self.flush();
|
||||
self.memory.clear();
|
||||
}
|
||||
self.memory.insert(id, commit.clone());
|
||||
Ok(commit)
|
||||
}
|
||||
|
||||
pub fn message(&mut self, repo: &gix::Repository, id: ObjectId) -> IdeResult<String> {
|
||||
if let Some((_, message)) = self.dirty_messages.iter().find(|(key, _)| key == &id.to_string()) {
|
||||
return Ok(message.clone());
|
||||
}
|
||||
let key = id.to_string();
|
||||
let cached = self.db.as_ref().and_then(|db| {
|
||||
let txn = db.begin_read().ok()?;
|
||||
let table = txn.open_table(MESSAGES).ok()?;
|
||||
Some(table.get(key.as_str()).ok()??.value().to_string())
|
||||
});
|
||||
if let Some(message) = cached {
|
||||
return Ok(message);
|
||||
}
|
||||
let message = to_string(repo.find_commit(id).map_err(gx)?.message_raw_sloppy()).trim_end().to_string();
|
||||
if self.db.is_some() {
|
||||
self.dirty_messages.push((key, message.clone()));
|
||||
}
|
||||
Ok(message)
|
||||
}
|
||||
|
||||
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()
|
||||
{
|
||||
return;
|
||||
}
|
||||
let result = (|| -> Result<(), Box<dyn std::error::Error>> {
|
||||
let Some(db) = &self.db else {
|
||||
return Ok(());
|
||||
};
|
||||
let txn = db.begin_write()?;
|
||||
{
|
||||
let mut table = txn.open_table(COMMITS)?;
|
||||
for commit in &self.dirty {
|
||||
let bytes = serde_json::to_vec(commit.as_ref())?;
|
||||
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(())
|
||||
})();
|
||||
if let Err(error) = result {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_user(user: &str) -> String {
|
||||
user.chars().filter(|c| c.is_alphanumeric()).flat_map(char::to_lowercase).collect()
|
||||
}
|
||||
|
||||
pub(crate) fn matches_user(query: &str, name: &str, email: &str) -> bool {
|
||||
let query_name = query.split('<').next().unwrap_or(query).trim();
|
||||
let query_email = query.split('<').nth(1).map(|s| s.trim_end_matches('>')).unwrap_or(query);
|
||||
let target =
|
||||
[normalize_user(name), normalize_user(email), normalize_user(email.split('@').next().unwrap_or(email))];
|
||||
[query_name, query_email, query_email.split('@').next().unwrap_or(query_email)]
|
||||
.iter()
|
||||
.any(|alias| target.contains(&normalize_user(alias)))
|
||||
}
|
||||
@@ -64,6 +64,9 @@ pub(crate) fn discover(path: &Path) -> IdeResult<Option<Repo>> {
|
||||
git_dir,
|
||||
inner: std::sync::Mutex::new(cache),
|
||||
ignored: Default::default(),
|
||||
log_index: Default::default(),
|
||||
index_progress: Default::default(),
|
||||
log_signatures: Default::default(),
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
use ide_vcs::{LogOrder, LogQuery, Repo};
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
fn git(dir: &Path, args: &[&str]) -> String {
|
||||
let out = Command::new("git")
|
||||
.current_dir(dir)
|
||||
.args([
|
||||
"-c",
|
||||
"init.defaultBranch=main",
|
||||
"-c",
|
||||
"user.name=Test",
|
||||
"-c",
|
||||
"[email protected]",
|
||||
"-c",
|
||||
"commit.gpgsign=false",
|
||||
])
|
||||
.args(args)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
||||
}
|
||||
|
||||
fn commit(dir: &Path, file: &str, message: &str) -> String {
|
||||
std::fs::write(dir.join(file), message).unwrap();
|
||||
git(dir, &["add", "-A"]);
|
||||
git(dir, &["commit", "-qm", message]);
|
||||
git(dir, &["rev-parse", "HEAD"])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn branch_and_independent_ranges_form_a_union() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path();
|
||||
git(dir, &["init", "-q"]);
|
||||
let root = commit(dir, "a", "root");
|
||||
let first = commit(dir, "a", "first");
|
||||
let second = commit(dir, "a", "second");
|
||||
let third = commit(dir, "a", "third");
|
||||
git(dir, &["checkout", "-qb", "side", &root]);
|
||||
let side = commit(dir, "b", "side");
|
||||
let repo = Repo::discover(dir).unwrap().unwrap();
|
||||
for order in [LogOrder::Topo, LogOrder::Date, LogOrder::Standard] {
|
||||
let log = repo
|
||||
.log(&LogQuery {
|
||||
branches: vec!["side".into()],
|
||||
ranges: vec![format!("{root}..{first}"), format!("{second}..{third}")],
|
||||
order,
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
let got: std::collections::HashSet<_> = log.iter().map(|c| c.id.clone()).collect();
|
||||
assert_eq!(got, [root.clone(), first.clone(), third.clone(), side.clone()].into());
|
||||
let ranges = repo
|
||||
.log(&LogQuery {
|
||||
ranges: vec![format!("{root}..{first}"), format!("{second}..{third}")],
|
||||
order,
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
ranges.iter().map(|c| c.id.clone()).collect::<std::collections::HashSet<_>>(),
|
||||
[first.clone(), third.clone()].into()
|
||||
);
|
||||
let positions: std::collections::HashMap<_, _> =
|
||||
log.iter().enumerate().map(|(i, c)| (c.id.as_str(), i)).collect();
|
||||
for (i, c) in log.iter().enumerate() {
|
||||
for p in c.graph_parents.as_ref().unwrap() {
|
||||
assert!(positions.get(p.as_str()).is_none_or(|p| *p > i));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lazy_summaries_seek_and_index_survive_refresh_and_restart() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path();
|
||||
git(dir, &["init", "-q"]);
|
||||
let mut ids = Vec::new();
|
||||
for i in 0..70 {
|
||||
ids.push(commit(dir, "a", &format!("summary {i}\n\nneedle body {i}")));
|
||||
}
|
||||
let cache = tempfile::tempdir().unwrap();
|
||||
let repo = Repo::discover(dir).unwrap().unwrap();
|
||||
repo.enable_log_index(cache.path());
|
||||
let query = LogQuery { summary_only: true, limit: 5, ..Default::default() };
|
||||
let page = repo.log(&query).unwrap();
|
||||
assert_eq!(page[0].message, "summary 69");
|
||||
assert!(page[0].message_truncated);
|
||||
assert_eq!(page[0].log_position, Some(0));
|
||||
assert_eq!(page[0].log_total, Some(70));
|
||||
let window = repo.log(&LogQuery { seek: Some(ids[5].clone()), limit: 20, ..query.clone() }).unwrap();
|
||||
assert!(window.iter().any(|c| c.id == ids[5]));
|
||||
assert!(window[0].log_position.unwrap() > 0);
|
||||
assert!(repo.log(&LogQuery { request: Some(ide_core::RequestId(1)), ..query.clone() }).is_ok());
|
||||
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();
|
||||
assert_eq!(refreshed[0].id, new);
|
||||
assert_eq!(refreshed.len(), 71);
|
||||
drop(repo);
|
||||
let repo = Repo::discover(dir).unwrap().unwrap();
|
||||
repo.enable_log_index(cache.path());
|
||||
let hit = repo.log(&LogQuery { text: Some("needle body 69".into()), ..query }).unwrap();
|
||||
assert_eq!(hit[0].id, ids[69]);
|
||||
assert_eq!(repo.log_details(&ids[69]).unwrap().message, "summary 69\n\nneedle body 69");
|
||||
assert_eq!(repo.log_details(&ids[69]).unwrap().signature, "N");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn followed_history_carries_the_filename_at_each_revision() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path();
|
||||
git(dir, &["init", "-q"]);
|
||||
let first = commit(dir, "old.txt", "original");
|
||||
git(dir, &["mv", "old.txt", "new.txt"]);
|
||||
git(dir, &["commit", "-qm", "rename"]);
|
||||
let renamed = git(dir, &["rev-parse", "HEAD"]);
|
||||
let tip = commit(dir, "new.txt", "latest");
|
||||
let repo = Repo::discover(dir).unwrap().unwrap();
|
||||
let log = repo.log(&LogQuery { path: Some("new.txt".into()), follow: true, ..Default::default() }).unwrap();
|
||||
assert_eq!(
|
||||
log.iter().map(|c| (c.id.as_str(), c.history_path.as_deref())).collect::<Vec<_>>(),
|
||||
vec![(tip.as_str(), Some("new.txt")), (renamed.as_str(), Some("new.txt")), (first.as_str(), Some("old.txt"))]
|
||||
);
|
||||
assert_eq!(repo.revision_children(&first, "").unwrap(), vec!["old.txt"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detail_filters_connect_visible_ancestors_without_changing_revision_parents() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path();
|
||||
git(dir, &["init", "-q"]);
|
||||
let a = commit(dir, "a", "keep root");
|
||||
commit(dir, "b", "hidden middle");
|
||||
git(dir, &["commit", "--amend", "--no-edit", "--author=Other <[email protected]>"]);
|
||||
let b = git(dir, &["rev-parse", "HEAD"]);
|
||||
let c = commit(dir, "a", "keep tip");
|
||||
let repo = Repo::discover(dir).unwrap().unwrap();
|
||||
for q in [
|
||||
LogQuery { text: Some("keep".into()), ..Default::default() },
|
||||
LogQuery { path: Some("a".into()), ..Default::default() },
|
||||
LogQuery { authors: vec!["Test".into()], authors_exact: true, ..Default::default() },
|
||||
] {
|
||||
let page = repo.log(&LogQuery { limit: 1, ..q.clone() }).unwrap();
|
||||
assert_eq!(page[0].id, c);
|
||||
assert_eq!(page[0].parents, vec![b.clone()]);
|
||||
assert_eq!(page[0].graph_parents, Some(vec![a.clone()]));
|
||||
assert_eq!(page[0].graph_dashed, vec![true]);
|
||||
assert_eq!(page[0].on_current_branch, Some(true));
|
||||
let next = repo.log(&LogQuery { skip: 1, limit: 1, ..q }).unwrap();
|
||||
assert_eq!(next[0].id, a);
|
||||
assert_eq!(next[0].graph_parents, Some(vec![]));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_history_includes_commits_reachable_only_from_tags() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path();
|
||||
git(dir, &["init", "-q"]);
|
||||
let root = commit(dir, "a", "root");
|
||||
let tip = commit(dir, "a", "tagged tip");
|
||||
git(dir, &["tag", "v1"]);
|
||||
git(dir, &["reset", "--hard", &root]);
|
||||
let repo = Repo::discover(dir).unwrap().unwrap();
|
||||
let log = repo.log(&LogQuery::default()).unwrap();
|
||||
let tagged = log.iter().find(|c| c.id == tip).unwrap();
|
||||
assert!(tagged.refs.contains(&"tag: v1".into()));
|
||||
assert_eq!(tagged.on_current_branch, Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projection_does_not_truncate_long_hidden_chains() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path();
|
||||
git(dir, &["init", "-q"]);
|
||||
let mut stream = String::new();
|
||||
for i in 0..4100 {
|
||||
let message = if i == 0 || i == 4099 { "keep" } else { "hide" };
|
||||
stream.push_str(&format!(
|
||||
"commit refs/heads/main\ncommitter Test <[email protected]> {} +0000\ndata {}\n{}\n\n",
|
||||
1000 + i,
|
||||
message.len(),
|
||||
message
|
||||
));
|
||||
}
|
||||
let mut child = Command::new("git")
|
||||
.current_dir(dir)
|
||||
.args(["fast-import", "--quiet"])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::null())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
child.stdin.take().unwrap().write_all(stream.as_bytes()).unwrap();
|
||||
assert!(child.wait().unwrap().success());
|
||||
let repo = Repo::discover(dir).unwrap().unwrap();
|
||||
let log = repo.log(&LogQuery { text: Some("keep".into()), ..Default::default() }).unwrap();
|
||||
assert_eq!(log.len(), 2);
|
||||
assert_eq!(log[0].graph_parents, Some(vec![log[1].id.clone()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_and_shallow_history_remain_topological_with_clock_skew() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let source = tmp.path().join("source");
|
||||
std::fs::create_dir(&source).unwrap();
|
||||
git(&source, &["init", "-q"]);
|
||||
let make = |message: &str, parent: Option<&str>, time: &str| {
|
||||
std::fs::write(source.join("a.txt"), message).unwrap();
|
||||
git(&source, &["add", "a.txt"]);
|
||||
let tree = git(&source, &["write-tree"]);
|
||||
let mut cmd = Command::new("git");
|
||||
cmd.current_dir(&source).args([
|
||||
"-c",
|
||||
"user.name=Test",
|
||||
"-c",
|
||||
"[email protected]",
|
||||
"commit-tree",
|
||||
&tree,
|
||||
"-m",
|
||||
message,
|
||||
]);
|
||||
if let Some(p) = parent {
|
||||
cmd.args(["-p", p]);
|
||||
}
|
||||
let out = cmd.env("GIT_AUTHOR_DATE", time).env("GIT_COMMITTER_DATE", time).output().unwrap();
|
||||
assert!(out.status.success());
|
||||
String::from_utf8(out.stdout).unwrap().trim().to_string()
|
||||
};
|
||||
let root = make("root", None, "@5000 +0000");
|
||||
let left = make("left", Some(&root), "@1000 +0000");
|
||||
let right = make("right", Some(&root), "@2000 +0000");
|
||||
git(&source, &["update-ref", "refs/heads/main", &left]);
|
||||
git(&source, &["update-ref", "refs/heads/side", &right]);
|
||||
let shallow = tmp.path().join("shallow");
|
||||
git(
|
||||
tmp.path(),
|
||||
&[
|
||||
"clone",
|
||||
"-q",
|
||||
"--depth=2",
|
||||
"--no-single-branch",
|
||||
&format!("file://{}", source.display()),
|
||||
shallow.to_str().unwrap(),
|
||||
],
|
||||
);
|
||||
for dir in [&source, &shallow] {
|
||||
let repo = Repo::discover(dir).unwrap().unwrap();
|
||||
for order in [LogOrder::Date, LogOrder::Topo, LogOrder::Standard] {
|
||||
let log = repo.log(&LogQuery { order, path: Some("a.txt".into()), ..Default::default() }).unwrap();
|
||||
assert_eq!(log.len(), 3);
|
||||
let positions: std::collections::HashMap<_, _> =
|
||||
log.iter().enumerate().map(|(i, c)| (c.id.as_str(), i)).collect();
|
||||
for (i, c) in log.iter().enumerate() {
|
||||
for parent in c.graph_parents.as_ref().unwrap() {
|
||||
assert!(positions.get(parent.as_str()).is_none_or(|p| *p > i));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_paths_exact_authors_patterns_and_rename_history() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path();
|
||||
git(dir, &["init", "-q"]);
|
||||
let root = commit(dir, "old.txt", "root");
|
||||
git(dir, &["mv", "old.txt", "new.txt"]);
|
||||
git(dir, &["commit", "-qm", "rename"]);
|
||||
let rename = git(dir, &["rev-parse", "HEAD"]);
|
||||
let tip = commit(dir, "other.txt", "another file");
|
||||
git(dir, &["branch", "feature/log"]);
|
||||
git(dir, &["branch", "feature/skip", &root]);
|
||||
let repo = Repo::discover(dir).unwrap().unwrap();
|
||||
let log = repo.log(&LogQuery { paths: vec!["new.txt".into(), "other.txt".into()], ..Default::default() }).unwrap();
|
||||
assert_eq!(log.iter().map(|c| c.id.clone()).collect::<Vec<_>>(), vec![tip.clone(), rename.clone()]);
|
||||
let log = repo.log(&LogQuery { path: Some("new.txt".into()), follow: true, ..Default::default() }).unwrap();
|
||||
assert_eq!(log.iter().map(|c| c.id.clone()).collect::<Vec<_>>(), vec![rename, root.clone()]);
|
||||
let log = repo
|
||||
.log(&LogQuery { branches: vec!["feature/.*".into(), "-feature/skip".into()], ..Default::default() })
|
||||
.unwrap();
|
||||
assert_eq!(log[0].id, tip);
|
||||
std::fs::write(dir.join("other.txt"), "different author").unwrap();
|
||||
git(dir, &["add", "-A"]);
|
||||
git(dir, &["-c", "user.name=Test User", "-c", "[email protected]", "commit", "-qm", "different author"]);
|
||||
let log = repo.log(&LogQuery { authors: vec!["Test".into()], authors_exact: true, ..Default::default() }).unwrap();
|
||||
assert_eq!(log.len(), 3);
|
||||
assert!(log.iter().all(|c| c.author == "Test"));
|
||||
assert!(repo.log_users().unwrap().contains(&"Test User <[email protected]>".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_and_path_cache_survive_restart_without_caching_refs_or_filters() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path().join("repo");
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
git(&dir, &["init", "-q"]);
|
||||
let first = commit(&dir, "a", "first\n\nComplete commit body");
|
||||
git(&dir, &["tag", "v1"]);
|
||||
git(&dir, &["tag", "-a", "annotated", "-m", "release"]);
|
||||
let cache = tmp.path().join("cache");
|
||||
{
|
||||
let repo = Repo::discover(&dir).unwrap().unwrap();
|
||||
repo.enable_log_index(&cache);
|
||||
let log = repo.log(&LogQuery { path: Some("a".into()), summary_only: true, ..Default::default() }).unwrap();
|
||||
assert!(log[0].message_truncated);
|
||||
assert_eq!(log[0].message, "first");
|
||||
assert_eq!(repo.log_details(&first).unwrap().message, "first\n\nComplete commit body");
|
||||
let details = repo.log_details("annotated").unwrap();
|
||||
assert_eq!(details.message, "first\n\nComplete commit body");
|
||||
assert_eq!(details.signature, "N");
|
||||
assert!(details.containing_branches.contains(&"main".to_string()));
|
||||
assert_eq!(repo.revision_files(&first).unwrap(), vec!["a"]);
|
||||
}
|
||||
let second = commit(&dir, "a", "second");
|
||||
git(&dir, &["tag", "-f", "v1"]);
|
||||
let repo = Repo::discover(&dir).unwrap().unwrap();
|
||||
repo.enable_log_index(&cache);
|
||||
let log = repo.log(&LogQuery { path: Some("a".into()), ..Default::default() }).unwrap();
|
||||
assert_eq!(log[0].id, second);
|
||||
assert!(log[0].refs.contains(&"tag: v1".into()));
|
||||
assert!(!log[1].refs.contains(&"tag: v1".into()));
|
||||
assert!(log[1].message.contains("Complete commit body"));
|
||||
assert!(!log[1].message_truncated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelled_query_does_not_publish_a_partial_page() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
git(tmp.path(), &["init", "-q"]);
|
||||
commit(tmp.path(), "a", "root");
|
||||
let repo = Repo::discover(tmp.path()).unwrap().unwrap();
|
||||
let cancel = ide_core::CancellationToken::new();
|
||||
cancel.cancel();
|
||||
assert_eq!(
|
||||
repo.log_cancellable(&LogQuery::default(), &cancel).unwrap_err().kind,
|
||||
ide_core::error::ErrorKind::Cancelled
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shallow_boundaries_do_not_reconnect_to_locally_present_ancestors() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path();
|
||||
git(dir, &["init", "-q"]);
|
||||
let root = commit(dir, "a", "keep root");
|
||||
let boundary = commit(dir, "a", "hidden boundary");
|
||||
let tip = commit(dir, "a", "keep tip");
|
||||
git(dir, &["tag", "root", &root]);
|
||||
std::fs::write(dir.join(".git/shallow"), format!("{boundary}\n")).unwrap();
|
||||
let repo = Repo::discover(dir).unwrap().unwrap();
|
||||
for order in [LogOrder::Date, LogOrder::Topo, LogOrder::Standard] {
|
||||
for text in [None, Some("keep".to_string())] {
|
||||
let log = repo.log(&LogQuery { order, text, path: Some("a".into()), ..Default::default() }).unwrap();
|
||||
let boundary_row = log.iter().find(|c| c.id == boundary);
|
||||
if let Some(row) = boundary_row {
|
||||
assert_eq!(row.parents, vec![root.clone()]);
|
||||
assert_eq!(row.graph_parents, Some(vec![]));
|
||||
} else {
|
||||
assert_eq!(log.iter().find(|c| c.id == tip).unwrap().graph_parents, Some(vec![]));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_branch_exclusions_treat_punctuation_literally() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path();
|
||||
git(dir, &["init", "-q"]);
|
||||
let root = commit(dir, "a", "root");
|
||||
git(dir, &["branch", "release/1.0"]);
|
||||
let tip = commit(dir, "a", "tip");
|
||||
git(dir, &["branch", "release/1x0"]);
|
||||
let repo = Repo::discover(dir).unwrap().unwrap();
|
||||
let log = repo
|
||||
.log(&LogQuery { branches: vec!["release/.*".into(), "-release/1.0".into()], ..Default::default() })
|
||||
.unwrap();
|
||||
assert_eq!(log.iter().map(|c| c.id.as_str()).collect::<Vec<_>>(), vec![tip.as_str(), root.as_str()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mode_only_changes_appear_in_path_history() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path();
|
||||
git(dir, &["init", "-q"]);
|
||||
let root = commit(dir, "script", "root");
|
||||
git(dir, &["update-index", "--chmod=+x", "script"]);
|
||||
git(dir, &["commit", "-qm", "make executable"]);
|
||||
let tip = git(dir, &["rev-parse", "HEAD"]);
|
||||
let repo = Repo::discover(dir).unwrap().unwrap();
|
||||
let log = repo.log(&LogQuery { path: Some("script".into()), ..Default::default() }).unwrap();
|
||||
assert_eq!(log.iter().map(|c| c.id.as_str()).collect::<Vec<_>>(), vec![tip.as_str(), root.as_str()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_paths_are_simplified_together_at_merges() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path();
|
||||
git(dir, &["init", "-q"]);
|
||||
commit(dir, "a", "a0");
|
||||
commit(dir, "b", "b0");
|
||||
git(dir, &["branch", "side"]);
|
||||
commit(dir, "a", "left");
|
||||
git(dir, &["checkout", "side"]);
|
||||
commit(dir, "b", "right");
|
||||
git(dir, &["checkout", "main"]);
|
||||
git(dir, &["merge", "--no-ff", "side", "-m", "merge both paths"]);
|
||||
let repo = Repo::discover(dir).unwrap().unwrap();
|
||||
let expected = git(dir, &["log", "--topo-order", "--format=%H", "--", "a", "b"]);
|
||||
for order in [LogOrder::Topo, LogOrder::Date, LogOrder::Standard] {
|
||||
let log = repo
|
||||
.log(&LogQuery {
|
||||
branch: Some("HEAD".into()),
|
||||
paths: vec!["a".into(), "b".into()],
|
||||
order,
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(log[0].summary, "merge both paths");
|
||||
assert_eq!(
|
||||
log.iter().map(|c| c.id.as_str()).collect::<std::collections::HashSet<_>>(),
|
||||
expected.lines().collect()
|
||||
);
|
||||
assert_eq!(log[0].parents.len(), 2);
|
||||
assert_eq!(log[0].graph_parents.as_ref().unwrap().len(), 2);
|
||||
}
|
||||
}
|
||||
@@ -64,7 +64,7 @@ fn ids(commits: &[Commit]) -> Vec<String> {
|
||||
}
|
||||
|
||||
fn parents_of(commits: &[Commit], id: &str) -> Vec<String> {
|
||||
commits.iter().find(|c| c.id == id).unwrap().parents.clone()
|
||||
commits.iter().find(|c| c.id == id).unwrap().graph_parents.clone().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -75,6 +75,14 @@ fn date_order_follows_commit_time_and_reports_it() {
|
||||
assert_eq!(out[0].commit_time_ms, 5_000_000.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incoming_order_groups_the_merged_branch_before_the_main_parent() {
|
||||
let h = history();
|
||||
let out = log(&h, LogQuery { order: LogOrder::Standard, ..Default::default() });
|
||||
assert_eq!(ids(&out), vec![h.c3.clone(), h.m.clone(), h.s1.clone(), h.c2.clone(), h.c1.clone()]);
|
||||
assert_eq!(out[1].parents, vec![h.c2.clone(), h.s1.clone()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_merges_links_children_to_the_merge_parents() {
|
||||
let h = history();
|
||||
@@ -90,6 +98,7 @@ fn first_parent_skips_merged_branches() {
|
||||
let out = log(&h, LogQuery { first_parent: true, ..Default::default() });
|
||||
assert_eq!(ids(&out), vec![h.c3.clone(), h.m.clone(), h.c2.clone(), h.c1.clone()]);
|
||||
assert_eq!(parents_of(&out, &h.m), vec![h.c2.clone()]);
|
||||
assert_eq!(out.iter().find(|c| c.id == h.m).unwrap().parents, vec![h.c2.clone(), h.s1.clone()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -201,19 +210,26 @@ fn a_user_filter_links_shown_commits_past_hidden_ones() {
|
||||
let log = repo.log(&LogQuery { author: Some("me@".into()), ..Default::default() }).unwrap();
|
||||
let links: Vec<(&str, Vec<&str>, Vec<bool>)> = log
|
||||
.iter()
|
||||
.map(|c| (c.summary.as_str(), c.parents.iter().map(String::as_str).collect(), c.dashed.clone()))
|
||||
.map(|c| {
|
||||
(
|
||||
c.summary.as_str(),
|
||||
c.graph_parents.as_ref().unwrap().iter().map(String::as_str).collect(),
|
||||
if c.graph_dashed.iter().any(|d| *d) { c.graph_dashed.clone() } else { Vec::new() },
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
links,
|
||||
[("m3", vec![m2.as_str()], vec![]), ("m2", vec![m1.as_str()], vec![true]), ("m1", vec![], vec![]),],
|
||||
"m2's parent o1 is hidden: m2 links to m1, dashed"
|
||||
);
|
||||
assert!(!log.iter().any(|c| c.parents.contains(&o1)));
|
||||
assert!(!log.iter().any(|c| c.graph_parents.as_ref().unwrap().contains(&o1)));
|
||||
assert_eq!(log[1].parents, vec![o1]);
|
||||
|
||||
// A page ends at m2: walking on still links it to m1.
|
||||
let page = repo.log(&LogQuery { author: Some("me@".into()), limit: 2, ..Default::default() }).unwrap();
|
||||
assert_eq!(page.len(), 2);
|
||||
assert_eq!(page[1].parents, std::slice::from_ref(&m1));
|
||||
assert_eq!(page[1].graph_parents.as_deref().unwrap(), std::slice::from_ref(&m1));
|
||||
let next = repo.log(&LogQuery { author: Some("me@".into()), skip: 2, limit: 2, ..Default::default() }).unwrap();
|
||||
assert_eq!(next.iter().map(|c| c.id.clone()).collect::<Vec<_>>(), std::slice::from_ref(&m1));
|
||||
let _ = m3;
|
||||
|
||||
@@ -13,7 +13,7 @@ serde.workspace = true
|
||||
specta.workspace = true
|
||||
ignore.workspace = true
|
||||
globset.workspace = true
|
||||
notify-debouncer-full.workspace = true
|
||||
notify.workspace = true
|
||||
encoding_rs.workspace = true
|
||||
chardetng.workspace = true
|
||||
trash.workspace = true
|
||||
|
||||
@@ -256,6 +256,11 @@ pub fn set_writable(path: &Path, writable: bool) -> IdeResult<()> {
|
||||
pub fn rename(from: &Path, to: &Path) -> IdeResult<()> {
|
||||
ops::rename(from, to)
|
||||
}
|
||||
/// Checks source existence and destination conflicts without mutation, using
|
||||
/// the same filesystem identity rules as [`rename`].
|
||||
pub fn validate_rename(from: &Path, to: &Path) -> IdeResult<()> {
|
||||
ops::validate_rename(from, to)
|
||||
}
|
||||
/// Moves to the OS trash (recoverable), like IDEA's safe delete for files.
|
||||
/// Deletes permanently when the trash is unavailable.
|
||||
pub fn delete(path: &Path) -> IdeResult<()> {
|
||||
|
||||
@@ -88,7 +88,7 @@ fn same_file(a: &Path, b: &Path) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn rename(from: &Path, to: &Path) -> IdeResult<()> {
|
||||
pub(crate) fn validate_rename(from: &Path, to: &Path) -> IdeResult<()> {
|
||||
if !exists(from) {
|
||||
return Err(not_found(from));
|
||||
}
|
||||
@@ -99,6 +99,14 @@ pub(crate) fn rename(from: &Path, to: &Path) -> IdeResult<()> {
|
||||
if exists(to) && !same_file(from, to) {
|
||||
return Err(already_exists(to));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn rename(from: &Path, to: &Path) -> IdeResult<()> {
|
||||
validate_rename(from, to)?;
|
||||
if from == to {
|
||||
return Ok(());
|
||||
}
|
||||
ensure_parent(to)?;
|
||||
match fs::rename(from, to) {
|
||||
Ok(()) => Ok(()),
|
||||
|
||||
+163
-92
@@ -12,9 +12,8 @@
|
||||
use crate::FsBatch;
|
||||
use crate::rules::{ExcludeMatcher, GitignoreStack};
|
||||
use ide_core::{IdeError, IdeEvent, IdeResult, SharedSink};
|
||||
use notify_debouncer_full::notify::event::{ModifyKind, RenameMode};
|
||||
use notify_debouncer_full::notify::{self, EventKind, PollWatcher, RecommendedWatcher, RecursiveMode, WatcherKind};
|
||||
use notify_debouncer_full::{DebounceEventResult, DebouncedEvent, Debouncer, NoCache, new_debouncer_opt};
|
||||
use notify::event::{ModifyKind, RenameMode};
|
||||
use notify::{Event, EventKind, PollWatcher, RecommendedWatcher, RecursiveMode, WatcherKind};
|
||||
use parking_lot::{Condvar, Mutex, RwLock};
|
||||
use std::collections::{BTreeMap, BTreeSet, HashSet};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
@@ -62,10 +61,9 @@ pub struct Watcher {
|
||||
|
||||
type SharedExcludes = Arc<RwLock<ExcludeMatcher>>;
|
||||
|
||||
enum Inner {
|
||||
Recursive(#[allow(dead_code)] Debouncer<RecommendedWatcher, NoCache>),
|
||||
Polling(#[allow(dead_code)] Debouncer<PollWatcher, NoCache>),
|
||||
PerDirectory { state: Arc<DirState>, thread: Option<JoinHandle<()>> },
|
||||
struct Inner {
|
||||
state: Arc<DirState>,
|
||||
thread: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl Watcher {
|
||||
@@ -131,80 +129,32 @@ impl Watcher {
|
||||
|
||||
/// Number of OS-level watches (1 in recursive and flat mode).
|
||||
pub fn watch_count(&self) -> usize {
|
||||
match &self.inner {
|
||||
Inner::Recursive(_) | Inner::Polling(_) => 1,
|
||||
Inner::PerDirectory { state, .. } => state.watched.lock().len(),
|
||||
}
|
||||
self.inner.state.watched.lock().len()
|
||||
}
|
||||
|
||||
fn start_native(root: &Path, filter: EventFilter, emit: Emit, mode: WatchMode) -> IdeResult<Inner> {
|
||||
let config = notify::Config::default();
|
||||
match mode {
|
||||
WatchMode::Recursive => {
|
||||
Self::start_debounced(root, filter, emit, RecursiveMode::Recursive, config).map(Inner::Recursive)
|
||||
}
|
||||
WatchMode::Flat => {
|
||||
Self::start_debounced(root, filter, emit, RecursiveMode::NonRecursive, config).map(Inner::Recursive)
|
||||
}
|
||||
WatchMode::PerDirectory => Self::start_per_directory::<RecommendedWatcher>(root, filter, emit, config),
|
||||
}
|
||||
Self::start_queued::<RecommendedWatcher>(root, filter, emit, mode, notify::Config::default())
|
||||
}
|
||||
|
||||
/// Polls per directory unless flat: a recursive poll would rescan
|
||||
/// `node_modules` and `target` every round.
|
||||
fn start_polling(root: &Path, filter: EventFilter, emit: Emit, mode: WatchMode) -> IdeResult<Inner> {
|
||||
let config = notify::Config::default().with_poll_interval(POLL_INTERVAL);
|
||||
match mode {
|
||||
WatchMode::Flat => {
|
||||
Self::start_debounced(root, filter, emit, RecursiveMode::NonRecursive, config).map(Inner::Polling)
|
||||
}
|
||||
WatchMode::Recursive | WatchMode::PerDirectory => {
|
||||
Self::start_per_directory::<PollWatcher>(root, filter, emit, config)
|
||||
}
|
||||
}
|
||||
let mode = if mode == WatchMode::Flat { mode } else { WatchMode::PerDirectory };
|
||||
Self::start_queued::<PollWatcher>(root, filter, emit, mode, config)
|
||||
}
|
||||
|
||||
fn start_debounced<W: notify::Watcher>(
|
||||
root: &Path,
|
||||
filter: EventFilter,
|
||||
emit: Emit,
|
||||
recursive: RecursiveMode,
|
||||
config: notify::Config,
|
||||
) -> IdeResult<Debouncer<W, NoCache>> {
|
||||
let polled = W::kind() == WatcherKind::PollWatcher;
|
||||
// No file-id cache: building it walks and stats the whole tree before `watch` returns,
|
||||
// which made opening large projects block for seconds. Renames arrive as remove + create.
|
||||
let handler = move |result: DebounceEventResult| match result {
|
||||
Ok(mut events) => {
|
||||
if polled {
|
||||
events.retain(|e| !is_dir_touch(&e.event));
|
||||
}
|
||||
let (batch, _) = filter.batch_detailed(&events);
|
||||
if !batch.is_empty() {
|
||||
emit(&batch);
|
||||
}
|
||||
}
|
||||
Err(errors) => {
|
||||
for e in errors {
|
||||
tracing::warn!("file watcher: {e}");
|
||||
}
|
||||
}
|
||||
};
|
||||
let mut debouncer = new_debouncer_opt::<_, W, _>(DEBOUNCE, None, handler, NoCache, config)
|
||||
.map_err(|e| io_error(format!("cannot start file watcher: {e}")))?;
|
||||
debouncer.watch(root, recursive).map_err(|e| io_error(format!("cannot watch {}: {e}", root.display())))?;
|
||||
Ok(debouncer)
|
||||
}
|
||||
|
||||
fn start_per_directory<W: notify::Watcher + Send + 'static>(
|
||||
fn start_queued<W: notify::Watcher + Send + 'static>(
|
||||
root: &Path,
|
||||
filter: EventFilter,
|
||||
emit: Emit,
|
||||
mode: WatchMode,
|
||||
config: notify::Config,
|
||||
) -> IdeResult<Inner> {
|
||||
let polled = W::kind() == WatcherKind::PollWatcher;
|
||||
let state = Arc::new(DirState {
|
||||
filter,
|
||||
mode,
|
||||
watcher: Mutex::new(None),
|
||||
watched: Mutex::default(),
|
||||
queue: Mutex::default(),
|
||||
@@ -228,8 +178,11 @@ impl Watcher {
|
||||
)
|
||||
.map_err(|e| io_error(format!("cannot start file watcher: {e}")))?;
|
||||
*state.watcher.lock() = Some(Box::new(watcher));
|
||||
let mut created = Vec::new();
|
||||
state.watch_tree(root, false, &mut created);
|
||||
if mode == WatchMode::PerDirectory {
|
||||
state.watch_tree(root, false, &mut Vec::new());
|
||||
} else {
|
||||
state.add_watch(root);
|
||||
}
|
||||
if state.watched.lock().is_empty() {
|
||||
return Err(io_error(format!("cannot watch {}", root.display())));
|
||||
}
|
||||
@@ -240,20 +193,22 @@ impl Watcher {
|
||||
.spawn(move || state.run(&*emit))
|
||||
.map_err(|e| io_error(format!("cannot start file watcher: {e}")))?
|
||||
};
|
||||
Ok(Inner::PerDirectory { state, thread: Some(thread) })
|
||||
Ok(Inner { state, thread: Some(thread) })
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Watcher {
|
||||
impl Drop for Inner {
|
||||
fn drop(&mut self) {
|
||||
if let Inner::PerDirectory { state, thread } = &mut self.inner {
|
||||
let state = &self.state;
|
||||
{
|
||||
let _queue = state.queue.lock();
|
||||
state.stop.store(true, Ordering::SeqCst);
|
||||
state.wake.notify_all();
|
||||
let watcher = state.watcher.lock().take();
|
||||
drop(watcher);
|
||||
if let Some(t) = thread.take() {
|
||||
let _ = t.join();
|
||||
}
|
||||
}
|
||||
let watcher = state.watcher.lock().take();
|
||||
drop(watcher);
|
||||
if let Some(t) = self.thread.take() {
|
||||
let _ = t.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -268,10 +223,10 @@ fn is_dir_touch(event: ¬ify::Event) -> bool {
|
||||
matches!(event.kind, EventKind::Modify(_)) && event.paths.iter().all(|p| p.is_dir())
|
||||
}
|
||||
|
||||
/// State of the per-directory strategy, shared with the notify callback
|
||||
/// (weakly) and the debounce thread.
|
||||
/// Shared with the notify callback (weakly) and the debounce thread.
|
||||
struct DirState {
|
||||
filter: EventFilter,
|
||||
mode: WatchMode,
|
||||
watcher: Mutex<Option<Box<dyn notify::Watcher + Send>>>,
|
||||
watched: Mutex<HashSet<PathBuf>>,
|
||||
queue: Mutex<Vec<(notify::Event, Instant)>>,
|
||||
@@ -310,9 +265,13 @@ impl DirState {
|
||||
}
|
||||
std::mem::take(&mut *q)
|
||||
};
|
||||
let events: Vec<DebouncedEvent> = events.into_iter().map(|(e, t)| DebouncedEvent::new(e, t)).collect();
|
||||
// A scan may publish a path before its create event is delivered:
|
||||
// keep create + remove pairs so the consumer still sees the removal.
|
||||
let events: Vec<Event> = events.into_iter().map(|(e, _)| e).collect();
|
||||
let (mut batch, rule_dirs) = self.filter.batch_detailed(&events);
|
||||
self.update_watches(&mut batch, &rule_dirs);
|
||||
if self.mode == WatchMode::PerDirectory {
|
||||
self.update_watches(&mut batch, &rule_dirs);
|
||||
}
|
||||
if !batch.is_empty() && !self.stopped() {
|
||||
emit(&batch);
|
||||
}
|
||||
@@ -363,7 +322,9 @@ impl DirState {
|
||||
}
|
||||
let mut guard = self.watcher.lock();
|
||||
let Some(w) = guard.as_mut() else { return false };
|
||||
match w.watch(dir, RecursiveMode::NonRecursive) {
|
||||
let recursive =
|
||||
if self.mode == WatchMode::Recursive { RecursiveMode::Recursive } else { RecursiveMode::NonRecursive };
|
||||
match w.watch(dir, recursive) {
|
||||
Ok(()) => {
|
||||
self.watched.lock().insert(dir.to_path_buf());
|
||||
true
|
||||
@@ -555,17 +516,17 @@ impl EventFilter {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn batch(&self, events: &[DebouncedEvent]) -> FsBatch {
|
||||
pub fn batch(&self, events: &[Event]) -> FsBatch {
|
||||
self.batch_detailed(events).0
|
||||
}
|
||||
|
||||
/// The coalesced, filtered batch plus the directories whose ignore rules
|
||||
/// changed (their cached matchers have been rebuilt already).
|
||||
pub fn batch_detailed(&self, events: &[DebouncedEvent]) -> (FsBatch, Vec<PathBuf>) {
|
||||
pub fn batch_detailed(&self, events: &[Event]) -> (FsBatch, Vec<PathBuf>) {
|
||||
let mut changes: BTreeMap<PathBuf, Change> = BTreeMap::new();
|
||||
for ev in events {
|
||||
let paths = &ev.event.paths;
|
||||
match ev.event.kind {
|
||||
let paths = &ev.paths;
|
||||
match ev.kind {
|
||||
EventKind::Access(_) => {}
|
||||
EventKind::Create(_) => paths.iter().for_each(|p| self.classify(p, Change::Created, &mut changes)),
|
||||
EventKind::Remove(_) => paths.iter().for_each(|p| self.classify(p, Change::Removed, &mut changes)),
|
||||
@@ -601,7 +562,7 @@ impl EventFilter {
|
||||
rule_dirs.insert(dir.to_path_buf());
|
||||
}
|
||||
}
|
||||
if events.iter().flat_map(|e| &e.event.paths).any(|p| p.ends_with(".git/info/exclude")) {
|
||||
if events.iter().flat_map(|e| &e.paths).any(|p| p.ends_with(".git/info/exclude")) {
|
||||
self.gitignore.reload_repo_level();
|
||||
rule_dirs.insert(self.root.clone());
|
||||
}
|
||||
@@ -669,11 +630,125 @@ impl EventFilter {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use notify_debouncer_full::notify::Event;
|
||||
use notify_debouncer_full::notify::event::{CreateKind, DataChange, RemoveKind};
|
||||
use notify::event::{CreateKind, DataChange, RemoveKind};
|
||||
|
||||
fn ev(kind: EventKind, paths: Vec<PathBuf>) -> DebouncedEvent {
|
||||
DebouncedEvent::new(Event { kind, paths, attrs: Default::default() }, Instant::now())
|
||||
fn ev(kind: EventKind, paths: Vec<PathBuf>) -> Event {
|
||||
Event { kind, paths, attrs: Default::default() }
|
||||
}
|
||||
|
||||
const TRANSIENT_PATHS: &[&str] = &["fresh.txt", "src/fresh.rs", "src/tasks/fresh.py"];
|
||||
|
||||
/// Delivers a whole debounce window without depending on OS scheduling.
|
||||
struct TransientWatcher(Box<dyn notify::EventHandler>);
|
||||
|
||||
impl notify::Watcher for TransientWatcher {
|
||||
fn new<F: notify::EventHandler>(handler: F, _: notify::Config) -> notify::Result<Self> {
|
||||
Ok(Self(Box::new(handler)))
|
||||
}
|
||||
|
||||
fn watch(&mut self, root: &Path, _: RecursiveMode) -> notify::Result<()> {
|
||||
for rel in TRANSIENT_PATHS {
|
||||
for kind in [
|
||||
EventKind::Create(CreateKind::File),
|
||||
EventKind::Modify(ModifyKind::Data(DataChange::Content)),
|
||||
EventKind::Remove(RemoveKind::File),
|
||||
] {
|
||||
self.0.handle_event(Ok(Event::new(kind).add_path(root.join(rel))));
|
||||
}
|
||||
}
|
||||
for kind in [EventKind::Create(CreateKind::Folder), EventKind::Remove(RemoveKind::Folder)] {
|
||||
self.0.handle_event(Ok(Event::new(kind).add_path(root.join("new"))));
|
||||
}
|
||||
for (rel, kind) in [
|
||||
("from.py", EventKind::Create(CreateKind::File)),
|
||||
("from.py", EventKind::Modify(ModifyKind::Name(RenameMode::From))),
|
||||
("to.py", EventKind::Modify(ModifyKind::Name(RenameMode::To))),
|
||||
("to.py", EventKind::Remove(RemoveKind::File)),
|
||||
] {
|
||||
self.0.handle_event(Ok(Event::new(kind).add_path(root.join(rel))));
|
||||
}
|
||||
for kind in [EventKind::Remove(RemoveKind::File), EventKind::Create(CreateKind::File)] {
|
||||
self.0.handle_event(Ok(Event::new(kind).add_path(root.join("kept.py"))));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn unwatch(&mut self, _: &Path) -> notify::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn kind() -> WatcherKind {
|
||||
WatcherKind::NullWatcher
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debounce_preserves_transient_removals_and_atomic_replacements() {
|
||||
for mode in [WatchMode::Recursive, WatchMode::Flat, WatchMode::PerDirectory] {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
std::fs::write(root.join("kept.py"), "class Kept: pass\n").unwrap();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let emit: Emit = Arc::new(move |b| tx.send(b.clone()).unwrap());
|
||||
let _watcher = Watcher::start_queued::<TransientWatcher>(
|
||||
&root,
|
||||
EventFilter::new(root.clone(), &[]),
|
||||
emit,
|
||||
mode,
|
||||
notify::Config::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let batch = rx.recv_timeout(Duration::from_secs(5)).unwrap();
|
||||
let removed: BTreeSet<_> = batch.removed.into_iter().collect();
|
||||
let expected =
|
||||
TRANSIENT_PATHS.iter().copied().chain(["new", "from.py", "to.py"]).map(|rel| root.join(rel)).collect();
|
||||
assert_eq!(removed, expected, "{mode:?}: scans may have already published transient paths");
|
||||
assert_eq!(batch.modified, vec![root.join("kept.py")]);
|
||||
assert!(batch.created.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn native_watch_reports_quickly_removed_files() {
|
||||
for mode in [WatchMode::Recursive, WatchMode::Flat, WatchMode::PerDirectory] {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
std::fs::create_dir_all(root.join("src/tasks")).unwrap();
|
||||
let batches: Arc<Mutex<Vec<FsBatch>>> = Arc::default();
|
||||
let watcher = {
|
||||
let batches = batches.clone();
|
||||
Watcher::start_with(
|
||||
&root,
|
||||
Vec::new(),
|
||||
Arc::new(ide_core::NullSink),
|
||||
Box::new(move |b| batches.lock().push(b.clone())),
|
||||
mode,
|
||||
)
|
||||
.unwrap()
|
||||
};
|
||||
assert!(watcher.polling().is_none());
|
||||
assert_eq!(watcher.watch_count(), if mode == WatchMode::PerDirectory { 3 } else { 1 });
|
||||
let paths: Vec<_> = TRANSIENT_PATHS
|
||||
.iter()
|
||||
.filter(|rel| mode != WatchMode::Flat || !rel.contains('/'))
|
||||
.map(|rel| root.join(rel))
|
||||
.collect();
|
||||
for path in &paths {
|
||||
std::fs::write(path, "class FreshWidget:\n pass\n").unwrap();
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
assert!(
|
||||
wait_for(&batches, |b| paths.iter().all(|p| b.iter().any(|batch| batch.removed.contains(p)))),
|
||||
"{mode:?}: {:?}",
|
||||
batches.lock()
|
||||
);
|
||||
drop(watcher);
|
||||
let count = batches.lock().len();
|
||||
std::fs::write(root.join("after-drop.txt"), "x").unwrap();
|
||||
std::thread::sleep(DEBOUNCE * 2);
|
||||
assert_eq!(batches.lock().len(), count, "no callback after drop in {mode:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -822,8 +897,7 @@ mod tests {
|
||||
}
|
||||
let filter = EventFilter::new(root.clone(), &[]);
|
||||
let modify = EventKind::Modify(ModifyKind::Data(DataChange::Content));
|
||||
let touch =
|
||||
|paths: &[&str]| -> Vec<DebouncedEvent> { paths.iter().map(|p| ev(modify, vec![root.join(p)])).collect() };
|
||||
let touch = |paths: &[&str]| -> Vec<Event> { paths.iter().map(|p| ev(modify, vec![root.join(p)])).collect() };
|
||||
let (batch, rules) = filter.batch_detailed(&touch(&["pkg/a.tmp", "pkg/gen/x.rs", "pkg/keep.rs", "b.tmp"]));
|
||||
assert_eq!(batch.modified, vec![root.join("b.tmp"), root.join("pkg/keep.rs")]);
|
||||
assert_eq!(batch.ignored, vec![root.join("pkg/a.tmp")], "nested rules, but not inside ignored folders");
|
||||
@@ -873,10 +947,7 @@ mod tests {
|
||||
}
|
||||
|
||||
fn watcher_state(w: &Watcher) -> &DirState {
|
||||
match &w.inner {
|
||||
Inner::PerDirectory { state, .. } => state,
|
||||
Inner::Recursive(_) | Inner::Polling(_) => panic!("not per-directory"),
|
||||
}
|
||||
&w.inner.state
|
||||
}
|
||||
|
||||
fn wait_for(events: &Mutex<Vec<FsBatch>>, pred: impl Fn(&[FsBatch]) -> bool) -> bool {
|
||||
|
||||
@@ -21,6 +21,7 @@ blake3.workspace = true
|
||||
tracing.workspace = true
|
||||
tempfile.workspace = true
|
||||
globset.workspace = true
|
||||
uuid.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
|
||||
@@ -401,8 +401,11 @@ impl Documents {
|
||||
if edit.files.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
// Validate project rules before path resolution can erase linked components.
|
||||
crate::file_ops::validate_workspace_edit_paths(edit)?;
|
||||
let mut edit = normalize_edit(edit)?;
|
||||
let captured = !edit.preconditions.is_empty();
|
||||
crate::file_ops::expand_workspace_edit(&mut edit)?;
|
||||
self.prepare_workspace_edit(&mut edit)?;
|
||||
self.validate_preconditions(&edit.preconditions)?;
|
||||
for file in &edit.files {
|
||||
|
||||
@@ -120,6 +120,8 @@ impl FileMoves {
|
||||
if moves.is_empty() {
|
||||
return Err(IdeError::invalid("nothing to move"));
|
||||
}
|
||||
let expanded = crate::file_ops::expand_moves(moves)?;
|
||||
let moves = expanded.as_slice();
|
||||
for m in moves {
|
||||
if !m.from.exists() {
|
||||
return Err(IdeError::not_found(format!("{} does not exist", m.from.display())));
|
||||
@@ -145,16 +147,9 @@ impl FileMoves {
|
||||
restore(documents, &paths, &before);
|
||||
return Err(e);
|
||||
}
|
||||
let mut done: Vec<&FileMove> = Vec::new();
|
||||
for m in moves {
|
||||
if let Err(e) = ide_vfs::rename(&m.from, &m.to) {
|
||||
for d in done.iter().rev() {
|
||||
let _ = ide_vfs::rename(&d.to, &d.from);
|
||||
}
|
||||
restore(documents, &paths, &before);
|
||||
return Err(e);
|
||||
}
|
||||
done.push(m);
|
||||
if let Err(e) = crate::file_ops::move_files(moves) {
|
||||
restore(documents, &paths, &before);
|
||||
return Err(e);
|
||||
}
|
||||
let mut edited = Vec::with_capacity(paths.len());
|
||||
for (p, before) in paths.iter().zip(before) {
|
||||
@@ -196,27 +191,21 @@ impl FileMoves {
|
||||
.ok_or_else(|| IdeError::not_found("the refactoring can no longer be undone"))?;
|
||||
records.remove(i)
|
||||
};
|
||||
for m in &record.moves {
|
||||
if !m.to.exists() {
|
||||
return Err(IdeError::not_found(format!("{} no longer exists", m.to.display())));
|
||||
}
|
||||
if m.from.exists() {
|
||||
return Err(IdeError::new(
|
||||
ide_core::error::ErrorKind::AlreadyExists,
|
||||
format!("{} exists again", m.from.display()),
|
||||
));
|
||||
}
|
||||
let back: Vec<_> =
|
||||
record.moves.iter().rev().map(|m| FileMove { from: m.to.clone(), to: m.from.clone() }).collect();
|
||||
if let Err(e) = crate::file_ops::move_files(&back) {
|
||||
self.records.lock().push(record);
|
||||
return Err(e);
|
||||
}
|
||||
let mut outcome = UndoOutcome::default();
|
||||
for m in record.moves.iter().rev() {
|
||||
ide_vfs::rename(&m.to, &m.from)?;
|
||||
outcome.moves.push(FileMove { from: m.to.clone(), to: m.from.clone() });
|
||||
}
|
||||
let back: Vec<FileMove> = outcome.moves.clone();
|
||||
let mut outcome = UndoOutcome { moves: back.clone(), conflicts: Vec::new() };
|
||||
for f in &record.edited {
|
||||
let path = moved_path(&f.path, &back);
|
||||
match read_text(documents, &path) {
|
||||
Ok(now) if now == f.after => write_text(documents, &path, &f.before)?,
|
||||
Ok(now) if now == f.after => {
|
||||
if write_text(documents, &path, &f.before).is_err() {
|
||||
outcome.conflicts.push(path.into());
|
||||
}
|
||||
}
|
||||
Ok(now) if now == f.before => {}
|
||||
_ => outcome.conflicts.push(path.into()),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
//! Project-aware file operations. Plain files retain the VFS behavior; project
|
||||
//! rules may expand a resource operation before the common transaction runs.
|
||||
|
||||
use crate::FileMove;
|
||||
use ide_core::{IdeError, IdeResult, WorkspaceEdit};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
mod project;
|
||||
|
||||
pub fn copy(from: &Path, to: &Path) -> IdeResult<()> {
|
||||
project::copy(from, to).unwrap_or_else(|| ide_vfs::copy(from, to))
|
||||
}
|
||||
|
||||
pub fn delete(path: &Path) -> IdeResult<()> {
|
||||
project::delete(path).unwrap_or_else(|| ide_vfs::delete(path))
|
||||
}
|
||||
|
||||
/// Files Local History must capture before deletion.
|
||||
pub fn deletion_paths(path: &Path) -> IdeResult<Vec<PathBuf>> {
|
||||
project::deletion_paths(path).unwrap_or_else(|| Ok(vec![path.to_path_buf()]))
|
||||
}
|
||||
|
||||
pub fn rename(from: &Path, to: &Path) -> IdeResult<()> {
|
||||
if !project::has_move_rules(from, to) {
|
||||
return ide_vfs::rename(from, to);
|
||||
}
|
||||
let moves = expand_moves(&[FileMove { from: from.into(), to: to.into() }])?;
|
||||
move_files(&moves)
|
||||
}
|
||||
|
||||
pub(crate) fn expand_moves(moves: &[FileMove]) -> IdeResult<Vec<FileMove>> {
|
||||
let expanded = project::expand_moves(moves)?;
|
||||
validate_moves(&expanded)?;
|
||||
Ok(expanded)
|
||||
}
|
||||
|
||||
fn path_key(path: &Path) -> PathBuf {
|
||||
#[cfg(windows)]
|
||||
return path.to_string_lossy().replace('\\', "/").to_lowercase().into();
|
||||
#[cfg(not(windows))]
|
||||
path.to_path_buf()
|
||||
}
|
||||
|
||||
fn validate_moves(moves: &[FileMove]) -> IdeResult<()> {
|
||||
let mut sources = std::collections::HashSet::new();
|
||||
let mut targets = std::collections::HashSet::new();
|
||||
for m in moves {
|
||||
ide_vfs::validate_rename(&m.from, &m.to)?;
|
||||
let from = path_key(&m.from);
|
||||
let to = path_key(&m.to);
|
||||
if to != from && to.starts_with(&from) {
|
||||
return Err(IdeError::invalid("Cannot move a directory into itself"));
|
||||
}
|
||||
if !sources.insert(from) || !targets.insert(to) {
|
||||
return Err(IdeError::invalid("Duplicate source or destination in file operation"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn move_files(moves: &[FileMove]) -> IdeResult<()> {
|
||||
execute_moves(moves, &|from, to| project::rename_entry(from, to).unwrap_or_else(|| ide_vfs::rename(from, to)))
|
||||
}
|
||||
|
||||
/// Executes an expanded batch. Rollback never overwrites a newly occupied path;
|
||||
/// failures report where the caller can recover the remaining files.
|
||||
pub(crate) fn execute_moves(moves: &[FileMove], rename: &dyn Fn(&Path, &Path) -> IdeResult<()>) -> IdeResult<()> {
|
||||
validate_moves(moves)?;
|
||||
for (i, m) in moves.iter().enumerate() {
|
||||
if let Err(mut error) = rename(&m.from, &m.to) {
|
||||
for done in moves[..i].iter().rev() {
|
||||
if let Err(rollback) =
|
||||
ide_vfs::validate_rename(&done.to, &done.from).and_then(|()| rename(&done.to, &done.from))
|
||||
{
|
||||
error.message.push_str(&format!(
|
||||
"; rollback {} -> {} failed: {rollback}",
|
||||
done.to.display(),
|
||||
done.from.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn validate_workspace_edit_paths(edit: &WorkspaceEdit) -> IdeResult<()> {
|
||||
project::validate_workspace_edit_paths(edit)
|
||||
}
|
||||
|
||||
pub(crate) fn expand_workspace_edit(edit: &mut WorkspaceEdit) -> IdeResult<()> {
|
||||
project::expand_workspace_edit(edit)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
//! The integration boundary for project-specific resource rules. These rules
|
||||
//! never replace the plain VFS path unless an operation touches project assets.
|
||||
|
||||
use crate::{FileMove, unity::assets};
|
||||
use ide_core::{FileEdit, IdeResult, WorkspaceEdit};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub(super) fn has_move_rules(from: &Path, to: &Path) -> bool {
|
||||
assets::project_root(from).is_some() || assets::project_root(to).is_some()
|
||||
}
|
||||
|
||||
pub(super) fn copy(from: &Path, to: &Path) -> Option<IdeResult<()>> {
|
||||
has_move_rules(from, to).then(|| assets::copy(from, to))
|
||||
}
|
||||
|
||||
pub(super) fn delete(path: &Path) -> Option<IdeResult<()>> {
|
||||
assets::project_root(path).map(|_| assets::delete(path))
|
||||
}
|
||||
|
||||
pub(super) fn deletion_paths(path: &Path) -> Option<IdeResult<Vec<PathBuf>>> {
|
||||
assets::project_root(path).map(|_| assets::deletion_paths(path))
|
||||
}
|
||||
|
||||
pub(super) fn rename_entry(from: &Path, to: &Path) -> Option<IdeResult<()>> {
|
||||
has_move_rules(from, to).then(|| assets::move_one(from, to))
|
||||
}
|
||||
|
||||
pub(super) fn expand_moves(moves: &[FileMove]) -> IdeResult<Vec<FileMove>> {
|
||||
if moves.iter().any(|m| has_move_rules(&m.from, &m.to)) { assets::expand_moves(moves) } else { Ok(moves.to_vec()) }
|
||||
}
|
||||
|
||||
fn has_edit_rules(edit: &WorkspaceEdit) -> bool {
|
||||
edit.files.iter().any(|file| match file {
|
||||
FileEdit::Rename { from, to } => has_move_rules(from, to),
|
||||
FileEdit::Delete { path, .. } => assets::project_root(path).is_some(),
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn validate_workspace_edit_paths(edit: &WorkspaceEdit) -> IdeResult<()> {
|
||||
if has_edit_rules(edit) {
|
||||
assets::validate_workspace_edit_paths(edit)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn expand_workspace_edit(edit: &mut WorkspaceEdit) -> IdeResult<()> {
|
||||
if has_edit_rules(edit) {
|
||||
assets::expand_workspace_edit(edit)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -7,6 +7,7 @@ pub mod documents;
|
||||
pub mod editorconfig;
|
||||
pub mod file_defaults;
|
||||
pub mod file_moves;
|
||||
pub mod file_ops;
|
||||
pub mod file_refs;
|
||||
pub mod history;
|
||||
pub mod memory;
|
||||
@@ -15,6 +16,7 @@ pub mod recent;
|
||||
pub mod roots;
|
||||
pub mod settings;
|
||||
pub mod text_refs;
|
||||
pub mod unity;
|
||||
pub mod window_groups;
|
||||
pub mod workspace;
|
||||
|
||||
|
||||
Loaded 100 of 333 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user