mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-08 08:08:16 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15abca599a |
No files matched your search
+15
-17
@@ -15,8 +15,6 @@ concurrency:
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
# Line tables keep test backtraces readable; full debuginfo only adds build time and cache size.
|
||||
CARGO_PROFILE_DEV_DEBUG: line-tables-only
|
||||
|
||||
jobs:
|
||||
rust:
|
||||
@@ -27,10 +25,10 @@ jobs:
|
||||
os: [macos-latest, ubuntu-22.04, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: pnpm/action-setup@v5
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with: { version: 11 }
|
||||
- uses: actions/setup-node@v5
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 22, cache: pnpm }
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with: { components: "clippy, rustfmt" }
|
||||
@@ -64,7 +62,7 @@ jobs:
|
||||
name: Remote server (linux musl)
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with: { targets: x86_64-unknown-linux-musl }
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
@@ -81,7 +79,7 @@ jobs:
|
||||
env:
|
||||
CARGO_TARGET_DIR: ${{ github.workspace }}/target-plugins
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with: { targets: wasm32-wasip2, components: "clippy, rustfmt" }
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
@@ -102,9 +100,9 @@ jobs:
|
||||
cargo test --locked --manifest-path "$dir/Cargo.toml"
|
||||
echo "::endgroup::"
|
||||
done
|
||||
- uses: pnpm/action-setup@v5
|
||||
- uses: pnpm/action-setup@v4
|
||||
with: { version: 11 }
|
||||
- uses: actions/setup-node@v5
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 22 }
|
||||
# Plugin web pages (`web-src/`, their own pnpm projects): typecheck and test.
|
||||
- name: Web pages typecheck and test
|
||||
@@ -128,7 +126,7 @@ jobs:
|
||||
name: Cargo deny
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: taiki-e/install-action@cargo-deny
|
||||
- run: cargo deny check
|
||||
@@ -137,10 +135,10 @@ jobs:
|
||||
name: Frontend
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: pnpm/action-setup@v5
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with: { version: 11 }
|
||||
- uses: actions/setup-node@v5
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 22, cache: pnpm }
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm typecheck
|
||||
@@ -156,16 +154,16 @@ jobs:
|
||||
# Browsers and their system libraries are preinstalled; keep the tag in step with @playwright/test.
|
||||
container: mcr.microsoft.com/playwright:v1.64.1-noble
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: pnpm/action-setup@v5
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with: { version: 11 }
|
||||
- uses: actions/setup-node@v5
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 22, cache: pnpm }
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm e2e
|
||||
env:
|
||||
HOME: /root
|
||||
- uses: actions/upload-artifact@v6
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
with:
|
||||
name: playwright-report
|
||||
|
||||
@@ -2,12 +2,8 @@ name: Language smoke
|
||||
# Everyday code intelligence (completion, go to definition, Find Usages, hover, diagnostics
|
||||
# with a quick fix, an intention, formatting, rename) per language, against the language
|
||||
# server the app starts by default: crates/ide-host/tests/language_smoke.rs.
|
||||
# Opening the fixture with its build tool and SDK (Maven / Gradle sync, the virtualenv, the
|
||||
# JDK): the ignored tests of crates/ide-host/tests/project_smoke.rs.
|
||||
# Then running and testing each fixture through the run store and openidea-server
|
||||
# (Run, Run tests, the test tree, Rerun Failed Tests): smoke/run.test.ts; and debugging its
|
||||
# entry point with the debugger the IDE installs (breakpoint, frames, variables, evaluate,
|
||||
# Step Over, Resume): smoke/debug.test.ts.
|
||||
# (Run, Run tests, the test tree, Rerun Failed Tests): smoke/run.test.ts.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
@@ -21,7 +17,6 @@ on:
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- "src/features/run/**"
|
||||
- "src/features/debugger/**"
|
||||
- "src/platform/**"
|
||||
- "smoke/**"
|
||||
- "package.json"
|
||||
@@ -109,16 +104,7 @@ jobs:
|
||||
|
||||
- run: cargo test --locked -p ide-host --test language_smoke --no-run
|
||||
- run: cargo test --locked -p ide-host --test language_smoke -- --ignored --exact ${{ matrix.language }}
|
||||
- if: matrix.language == 'java'
|
||||
run: cargo test --locked -p ide-host --test project_smoke -- --ignored --exact maven_sync jdk
|
||||
- if: matrix.language == 'kotlin'
|
||||
run: cargo test --locked -p ide-host --test project_smoke -- --ignored --exact gradle_sync
|
||||
- if: matrix.language == 'python'
|
||||
run: cargo test --locked -p ide-host --test project_smoke -- --ignored --exact python_venv
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: cargo build --locked -p ide-host --bin openidea-server
|
||||
- run: pnpm smoke smoke/run.test.ts -t "^${{ matrix.language }}$"
|
||||
- run: pnpm smoke smoke/debug.test.ts -t "^${{ matrix.language }}$"
|
||||
# The IDE installs the debugger from GitHub releases: authenticated, against rate limits.
|
||||
env: { GITHUB_TOKEN: "${{ github.token }}" }
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
if: github.event.pull_request.user.type != 'Bot' && !github.event.pull_request.draft
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/github-script@v8
|
||||
- uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const pr = context.payload.pull_request;
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
release_id: ${{ steps.release.outputs.id }}
|
||||
official_plugin_keys: ${{ steps.plugin-keys.outputs.keys }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v4
|
||||
with: { ref: "${{ env.TAG }}" }
|
||||
- name: Tag matches app version
|
||||
run: |
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
echo "keys=$key" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- id: release
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const tag = process.env.TAG;
|
||||
@@ -99,11 +99,11 @@ jobs:
|
||||
target: x86_64-pc-windows-msvc
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v4
|
||||
with: { ref: "${{ env.TAG }}" }
|
||||
- uses: pnpm/action-setup@v5
|
||||
- uses: pnpm/action-setup@v4
|
||||
with: { version: 11 }
|
||||
- uses: actions/setup-node@v5
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 22, cache: pnpm }
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with: { targets: "${{ matrix.target }}" }
|
||||
@@ -173,7 +173,7 @@ jobs:
|
||||
- { runner: macos-latest, target: x86_64-apple-darwin, os: macos, arch: x86_64 }
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v4
|
||||
with: { ref: "${{ env.TAG }}" }
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with: { targets: "${{ matrix.target }}" }
|
||||
@@ -220,7 +220,7 @@ jobs:
|
||||
echo "::warning::PLUGIN_SIGNING_KEY is not set: the official plugins are not published"
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v4
|
||||
if: steps.key.outputs.skip != 'true'
|
||||
with: { ref: "${{ env.TAG }}" }
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
@@ -229,10 +229,10 @@ jobs:
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
if: steps.key.outputs.skip != 'true'
|
||||
with: { key: release-plugins }
|
||||
- uses: pnpm/action-setup@v5
|
||||
- uses: pnpm/action-setup@v4
|
||||
if: steps.key.outputs.skip != 'true'
|
||||
with: { version: 11 }
|
||||
- uses: actions/setup-node@v5
|
||||
- uses: actions/setup-node@v4
|
||||
if: steps.key.outputs.skip != 'true'
|
||||
with: { node-version: 22 }
|
||||
# Builds the web pages; fails when a checked-in component isn't built from these sources.
|
||||
@@ -277,7 +277,7 @@ jobs:
|
||||
needs: [create-release, build]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/github-script@v8
|
||||
- uses: actions/github-script@v7
|
||||
env:
|
||||
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
|
||||
with:
|
||||
@@ -337,7 +337,7 @@ jobs:
|
||||
needs: [create-release, build, server, plugins, updater-manifest]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/github-script@v8
|
||||
- uses: actions/github-script@v7
|
||||
env:
|
||||
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
|
||||
with:
|
||||
|
||||
@@ -71,9 +71,6 @@ Shared agent skills live in `.agents/skills/` (`.claude/skills/` links to them):
|
||||
on `fixtures/` with that language's real server installed (CI: `Language smoke`, pinned versions).
|
||||
- `pnpm smoke smoke/run.test.ts -t <language>` — run and test that language's fixture through the
|
||||
run store and a real `openidea-server` (build it first: `cargo build -p ide-host --bin openidea-server`).
|
||||
- `pnpm smoke smoke/debug.test.ts -t <language>` — debug that fixture's entry point the same way, with
|
||||
the debugger the IDE installs (on macOS, CodeLLDB and Delve may need Developer Mode:
|
||||
`sudo DevToolsSecurity -enable`).
|
||||
- `pnpm test`, `pnpm e2e`, `pnpm lint`, `pnpm format:check`, `pnpm knip`, `pnpm typecheck`,
|
||||
`pnpm gen:bindings`.
|
||||
- `pnpm perf` (UI interaction latency / frames / leaks), `pnpm perf:rust` (criterion
|
||||
@@ -121,8 +118,8 @@ Several worktrees can be developed and run side by side.
|
||||
default MCP session belongs to this worktree.
|
||||
- Keep each worktree's Cargo output directory separate; do not point `CARGO_TARGET_DIR` or
|
||||
`target` symlinks at a shared directory.
|
||||
- Each worktree's first build is cold (several minutes) and `cargo build` output takes
|
||||
around 9 GB; tests, clippy and incremental builds add more. Avoid running several heavy builds on one machine at the same time.
|
||||
- Each worktree's first build is cold (around 5 minutes) and its Cargo output takes
|
||||
15–30 GB. Avoid running several heavy builds on one machine at the same time.
|
||||
- Stop only processes started for the current worktree/session. Never use broad
|
||||
process-name kills such as `pkill openidea`.
|
||||
- When done: stop that worktree's app and dev session, delete its build output, then
|
||||
|
||||
Generated
+32
-277
@@ -123,17 +123,6 @@ version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "apple-native-keyring-store"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b350bfd03649e07aa05c0a81b3e15934374e585c98204a57e20b9d49f49bb9a"
|
||||
dependencies = [
|
||||
"keyring-core",
|
||||
"log",
|
||||
"security-framework",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arbitrary"
|
||||
version = "1.5.0"
|
||||
@@ -167,27 +156,6 @@ version = "0.7.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
|
||||
|
||||
[[package]]
|
||||
name = "ashpd"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39"
|
||||
dependencies = [
|
||||
"enumflags2",
|
||||
"futures-channel",
|
||||
"futures-util",
|
||||
"rand 0.9.5",
|
||||
"raw-window-handle",
|
||||
"serde",
|
||||
"serde_repr",
|
||||
"tokio",
|
||||
"url",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-protocols",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ast-grep-core"
|
||||
version = "0.40.1"
|
||||
@@ -1632,7 +1600,6 @@ dependencies = [
|
||||
"lock_api",
|
||||
"once_cell",
|
||||
"parking_lot_core",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1828,15 +1795,6 @@ dependencies = [
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dlib"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a"
|
||||
dependencies = [
|
||||
"libloading",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dlopen2"
|
||||
version = "0.8.2"
|
||||
@@ -2205,6 +2163,15 @@ dependencies = [
|
||||
"rustc_version",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "file-id"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e1fc6a637b6dc58414714eddd9170ff187ecb0933d4c7024d1abbd23a3cc26e9"
|
||||
dependencies = [
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filedescriptor"
|
||||
version = "0.8.3"
|
||||
@@ -4153,7 +4120,7 @@ version = "0.1.36"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-run",
|
||||
"quick-xml 0.42.0",
|
||||
"quick-xml",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
@@ -4305,7 +4272,7 @@ dependencies = [
|
||||
"ide-workspace",
|
||||
"ignore",
|
||||
"parking_lot",
|
||||
"quick-xml 0.42.0",
|
||||
"quick-xml",
|
||||
"regex-syntax",
|
||||
"reqwest",
|
||||
"rustix",
|
||||
@@ -4547,7 +4514,7 @@ dependencies = [
|
||||
"libc",
|
||||
"parking_lot",
|
||||
"portable-pty",
|
||||
"quick-xml 0.42.0",
|
||||
"quick-xml",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
@@ -4618,8 +4585,6 @@ dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"flate2",
|
||||
"ide-core",
|
||||
"keyring",
|
||||
"keyring-core",
|
||||
"parking_lot",
|
||||
"russh",
|
||||
"russh-config",
|
||||
@@ -4630,7 +4595,6 @@ dependencies = [
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4670,7 +4634,6 @@ dependencies = [
|
||||
"proptest",
|
||||
"ropey",
|
||||
"serde",
|
||||
"similar",
|
||||
"specta",
|
||||
]
|
||||
|
||||
@@ -4727,7 +4690,7 @@ dependencies = [
|
||||
"globset",
|
||||
"ide-core",
|
||||
"ignore",
|
||||
"notify",
|
||||
"notify-debouncer-full",
|
||||
"parking_lot",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -5252,33 +5215,6 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "keyring"
|
||||
version = "4.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2270074a3d26bcac93c1dc5d2845eb4c089e8d761ccf6e0ea266a16004640627"
|
||||
dependencies = [
|
||||
"apple-native-keyring-store",
|
||||
"keyring-core",
|
||||
"windows-native-keyring-store",
|
||||
"zbus-secret-service-keyring-store",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "keyring-core"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29fbf4b551298c67925f38cf341a1ada797a75f1837bdf8ae1fc8e2ebc6efb59"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dashmap",
|
||||
"log",
|
||||
"regex",
|
||||
"ron",
|
||||
"serde",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kqueue"
|
||||
version = "1.2.1"
|
||||
@@ -5701,6 +5637,19 @@ dependencies = [
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "notify-debouncer-full"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "375bd3a138be7bfeff3480e4a623df4cbfb55b79df617c055cd810ba466fa078"
|
||||
dependencies = [
|
||||
"file-id",
|
||||
"log",
|
||||
"notify",
|
||||
"notify-types",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "notify-types"
|
||||
version = "2.1.0"
|
||||
@@ -5729,30 +5678,6 @@ dependencies = [
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
|
||||
dependencies = [
|
||||
"num-bigint 0.4.8",
|
||||
"num-complex",
|
||||
"num-integer",
|
||||
"num-iter",
|
||||
"num-rational",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.4.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.5.1"
|
||||
@@ -5765,15 +5690,6 @@ dependencies = [
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-complex"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.2"
|
||||
@@ -5789,27 +5705,6 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-iter"
|
||||
version = "0.1.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-rational"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
|
||||
dependencies = [
|
||||
"num-bigint 0.4.8",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
@@ -6698,7 +6593,7 @@ checksum = "65dca778f41c79ebbe8b2b2139fee86f01b766b67130d108c7c2b8ffbd949f77"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"indexmap 2.14.2",
|
||||
"quick-xml 0.42.0",
|
||||
"quick-xml",
|
||||
"serde",
|
||||
"time",
|
||||
]
|
||||
@@ -6743,12 +6638,6 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pollster"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3"
|
||||
|
||||
[[package]]
|
||||
name = "poly1305"
|
||||
version = "0.9.1"
|
||||
@@ -7009,15 +6898,6 @@ version = "1.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.41.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.42.0"
|
||||
@@ -7295,18 +7175,18 @@ version = "0.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
|
||||
dependencies = [
|
||||
"ashpd",
|
||||
"block2 0.6.2",
|
||||
"dispatch2",
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"gtk-sys",
|
||||
"js-sys",
|
||||
"log",
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation 0.3.2",
|
||||
"pollster",
|
||||
"raw-window-handle",
|
||||
"urlencoding",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
@@ -7327,19 +7207,6 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ron"
|
||||
version = "0.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "97b3fe97c18ff24299ea56f802c5693eb34266fb0bc8317f4e101debc8a3ceaf"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"typeid",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ropey"
|
||||
version = "1.6.1"
|
||||
@@ -7406,7 +7273,7 @@ dependencies = [
|
||||
"md5",
|
||||
"ml-kem",
|
||||
"module-lattice",
|
||||
"num-bigint 0.5.1",
|
||||
"num-bigint",
|
||||
"p256",
|
||||
"p384",
|
||||
"p521",
|
||||
@@ -7737,12 +7604,6 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scoped-tls"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"
|
||||
|
||||
[[package]]
|
||||
name = "scopeguard"
|
||||
version = "1.2.0"
|
||||
@@ -7793,25 +7654,6 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "secret-service"
|
||||
version = "5.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5107b24b91445dd2aa449a258a1807b63240942157292354dc5bfdbeb8bc6db8"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"cbc",
|
||||
"futures-util",
|
||||
"getrandom 0.4.3",
|
||||
"hkdf",
|
||||
"hybrid-array",
|
||||
"num",
|
||||
"once_cell",
|
||||
"serde",
|
||||
"sha2 0.11.1",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
@@ -9300,7 +9142,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.3",
|
||||
"getrandom 0.3.4",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
@@ -9488,7 +9330,6 @@ dependencies = [
|
||||
"signal-hook-registry",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
"tracing",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
@@ -10825,66 +10666,6 @@ 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"
|
||||
@@ -11170,19 +10951,6 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-native-keyring-store"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "063426e76fdec7438d56bb777f67e318a84a25c707b07e575cb8b78e10c028f8"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"keyring-core",
|
||||
"regex",
|
||||
"windows-sys 0.61.2",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-numerics"
|
||||
version = "0.3.1"
|
||||
@@ -11709,7 +11477,6 @@ dependencies = [
|
||||
"rustix",
|
||||
"serde",
|
||||
"serde_repr",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"uds_windows",
|
||||
"uuid",
|
||||
@@ -11720,17 +11487,6 @@ dependencies = [
|
||||
"zvariant",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus-secret-service-keyring-store"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "74801d001b9e7729adb4f1825b67b398185fed424749aa3d8bacf70417137d9a"
|
||||
dependencies = [
|
||||
"keyring-core",
|
||||
"secret-service",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus_macros"
|
||||
version = "5.19.0"
|
||||
@@ -11936,7 +11692,6 @@ dependencies = [
|
||||
"endi",
|
||||
"enumflags2",
|
||||
"serde",
|
||||
"url",
|
||||
"winnow 1.0.4",
|
||||
"zcheapstr",
|
||||
"zvariant_derive",
|
||||
|
||||
+1
-9
@@ -76,6 +76,7 @@ ignore = "0.4"
|
||||
base64 = "0.22"
|
||||
globset = "0.4"
|
||||
notify = "8"
|
||||
notify-debouncer-full = "0.6"
|
||||
trash = "5"
|
||||
blake3 = "1"
|
||||
dunce = "1"
|
||||
@@ -105,9 +106,6 @@ sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio",
|
||||
tiberius = { version = "0.13", default-features = false, features = ["tds73", "rustls", "sql-browser-tokio"] }
|
||||
bb8 = "0.9"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
# OS credential stores
|
||||
keyring = "4.2"
|
||||
keyring-core = "1"
|
||||
|
||||
# testing
|
||||
insta = { version = "1", features = ["yaml"] }
|
||||
@@ -117,14 +115,8 @@ tempfile = "3"
|
||||
[profile.dev]
|
||||
# Keep debug builds snappy while giving dependencies decent speed.
|
||||
opt-level = 0
|
||||
# Full debug info is most of target/. Line tables keep file:line in our backtraces, but a
|
||||
# debugger can't show locals; re-enable with `--config profile.dev.debug=true`.
|
||||
debug = "line-tables-only"
|
||||
[profile.dev.package."*"]
|
||||
opt-level = 2
|
||||
# Dependency frames keep function names only. Re-enable one with
|
||||
# `--config 'profile.dev.package.<crate>.debug=true'`.
|
||||
debug = false
|
||||
|
||||
[profile.release]
|
||||
codegen-units = 1
|
||||
|
||||
@@ -36,23 +36,6 @@ impl Default for InspectionSettings {
|
||||
}
|
||||
}
|
||||
|
||||
impl InspectionSettings {
|
||||
/// Overrides of the profile in effect; `Default`'s when `profile` names none that exists.
|
||||
pub fn active(&self) -> &BTreeMap<String, InspectionOverride> {
|
||||
let custom = |level: &str, key: &str| match level {
|
||||
"ide" => self.ide_profiles.get(key),
|
||||
"project" => self.project_profiles.get(key),
|
||||
_ => None,
|
||||
};
|
||||
match self.profile.as_str() {
|
||||
"project" => &self.project,
|
||||
id => {
|
||||
id.split_once(':').and_then(|(level, key)| custom(level, key)).map_or(&self.default, |p| &p.overrides)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A named profile made with Copy.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
@@ -128,20 +111,4 @@ mod tests {
|
||||
let back: InspectionSettings = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap();
|
||||
assert_eq!(back, s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_profile_falls_back_to_default() {
|
||||
let off = |id: &str| {
|
||||
BTreeMap::from([(id.to_owned(), InspectionOverride { enabled: Some(false), ..Default::default() })])
|
||||
};
|
||||
let mut s = InspectionSettings { default: off("a"), project: off("b"), ..Default::default() };
|
||||
s.ide_profiles.insert("strict".into(), CustomInspectionProfile { name: "Strict".into(), overrides: off("c") });
|
||||
assert!(s.active().contains_key("a"));
|
||||
s.profile = "project".into();
|
||||
assert!(s.active().contains_key("b"));
|
||||
s.profile = "ide:strict".into();
|
||||
assert!(s.active().contains_key("c"));
|
||||
s.profile = "project:gone".into();
|
||||
assert!(s.active().contains_key("a"));
|
||||
}
|
||||
}
|
||||
@@ -72,13 +72,7 @@ fn builtin(adapter: &str) -> Option<DebuggerConfig> {
|
||||
"stdio",
|
||||
"Install the Kotlin Debug Adapter from Settings > Language Servers",
|
||||
),
|
||||
// Windows installs `python.exe`; `python3.exe` there is at most a Store alias.
|
||||
"debugpy" => cfg(
|
||||
if cfg!(windows) { "python" } else { "python3" },
|
||||
&["-m", "debugpy.adapter"],
|
||||
"stdio",
|
||||
"pip install debugpy",
|
||||
),
|
||||
"debugpy" => cfg("python3", &["-m", "debugpy.adapter"], "stdio", "pip install debugpy"),
|
||||
"js-debug" => cfg(
|
||||
"js-debug-adapter",
|
||||
// Without a host it listens on `localhost`, which may resolve to ::1 only.
|
||||
|
||||
@@ -111,8 +111,8 @@ impl Session {
|
||||
if let Some(path) = self.kotlin_mirror.lock().as_ref().and_then(|m| m.adapter_path(path)) {
|
||||
return path;
|
||||
}
|
||||
if self.adapter == "java-debug" || (cfg!(windows) && self.adapter == "netcoredbg") {
|
||||
let native = windows_source_path(&path.to_string_lossy());
|
||||
if self.adapter == "java-debug" {
|
||||
let native = java_source_path(&path.to_string_lossy());
|
||||
self.resolved_paths.lock().insert(native.clone(), path.to_path_buf());
|
||||
return native;
|
||||
}
|
||||
@@ -133,11 +133,7 @@ impl Session {
|
||||
if let Some(real) = self.kotlin_mirror.lock().as_ref().and_then(|m| m.client_path(Path::new(path))) {
|
||||
return Some(real);
|
||||
}
|
||||
let key = if self.adapter == "java-debug" || (cfg!(windows) && self.adapter == "netcoredbg") {
|
||||
windows_source_path(path)
|
||||
} else {
|
||||
PathBuf::from(path)
|
||||
};
|
||||
let key = if self.adapter == "java-debug" { java_source_path(path) } else { PathBuf::from(path) };
|
||||
self.resolved_paths.lock().get(&key).cloned().or_else(|| {
|
||||
(self.adapter == "java-debug" && is_windows_source_path(path))
|
||||
.then(|| PathBuf::from(path.replace('\\', "/")))
|
||||
@@ -269,8 +265,8 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
// java-debug and Windows netcoredbg need native separators for source lookup.
|
||||
// Keep DAP path mode, but normalize Windows drive and UNC paths.
|
||||
// java-debug treats a forward-slash drive path as a URI (scheme "C").
|
||||
// Keep DAP path mode, but use native Windows separators for Java source lookup.
|
||||
fn is_windows_source_path(path: &str) -> bool {
|
||||
let bytes = path.as_bytes();
|
||||
(bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && matches!(bytes[2], b'/' | b'\\'))
|
||||
@@ -278,7 +274,7 @@ fn is_windows_source_path(path: &str) -> bool {
|
||||
|| path.starts_with(r"\\")
|
||||
}
|
||||
|
||||
fn windows_source_path(path: &str) -> PathBuf {
|
||||
fn java_source_path(path: &str) -> PathBuf {
|
||||
if is_windows_source_path(path) { PathBuf::from(path.replace('/', r"\")) } else { PathBuf::from(path) }
|
||||
}
|
||||
|
||||
|
||||
@@ -1141,23 +1141,6 @@ async fn kotlin_official_release_ten_no_delay_runs() {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[tokio::test]
|
||||
async fn netcoredbg_windows_source_paths_round_trip() {
|
||||
let client = "D:/work/LoginController.cs";
|
||||
let native = r"D:\work\LoginController.cs";
|
||||
let h = harness(Script { source_path: Some(native.into()), ..Script::default() });
|
||||
let path = PathBuf::from(client);
|
||||
h.manager.set_breakpoints(path.clone(), vec![bp(25)]).await.unwrap();
|
||||
let mut cfg = config();
|
||||
cfg.adapter = "netcoredbg".into();
|
||||
let id = h.start(cfg).await.unwrap();
|
||||
assert_eq!(h.request("setBreakpoints")["arguments"]["source"]["path"], native);
|
||||
let frames = h.manager.stack_trace(id, 1).await.unwrap();
|
||||
assert_eq!(frames[0].path.as_deref(), Some(path.as_path()));
|
||||
h.manager.stop(id).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn java_windows_source_paths_for_launch_and_attach() {
|
||||
for request in ["launch", "attach"] {
|
||||
|
||||
@@ -29,4 +29,3 @@ uuid.workspace = true
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
|
||||
tempfile.workspace = true
|
||||
ide-ssh = { workspace = true, features = ["testing"] }
|
||||
+15
-153
@@ -213,8 +213,6 @@ pub struct DbManager {
|
||||
/// App-wide SSH connections and host-key trust.
|
||||
ssh: Arc<SshManager>,
|
||||
saved_passwords: SecretStore,
|
||||
/// Directory of the global data sources and their saved passwords ([`DbManager::with_global_sources`]).
|
||||
global: Option<(PathBuf, SecretStore)>,
|
||||
history: Arc<HistoryStore>,
|
||||
/// Records executed statements in the history off the async threads, in order.
|
||||
recorder: std::sync::mpsc::Sender<(String, String, f64)>,
|
||||
@@ -341,7 +339,6 @@ impl DbManager {
|
||||
}
|
||||
Self {
|
||||
saved_passwords: SecretStore::new(passwords, &root),
|
||||
global: None,
|
||||
history,
|
||||
recorder,
|
||||
root,
|
||||
@@ -354,42 +351,8 @@ impl DbManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Also lists the global data sources stored in `dir` (the app data directory).
|
||||
pub fn with_global_sources(mut self, dir: PathBuf) -> Self {
|
||||
let passwords = self.saved_passwords.scoped(&dir);
|
||||
self.global = Some((dir, passwords));
|
||||
self
|
||||
}
|
||||
|
||||
/// The project's data sources, then the global ones (a project one wins on the same id).
|
||||
pub fn sources(&self) -> IdeResult<Vec<DataSource>> {
|
||||
let mut all = source::load(&self.root)?;
|
||||
let global: Vec<_> = self.global_sources().into_iter().filter(|g| all.iter().all(|s| s.id != g.id)).collect();
|
||||
all.extend(global);
|
||||
Ok(all)
|
||||
}
|
||||
|
||||
fn global_sources(&self) -> Vec<DataSource> {
|
||||
let Some((dir, _)) = &self.global else { return Vec::new() };
|
||||
source::load_global(dir).unwrap_or_else(|e| {
|
||||
tracing::warn!("global data sources: {}", e.message);
|
||||
Vec::new()
|
||||
})
|
||||
}
|
||||
|
||||
/// Where the saved passwords of data source `id` live: app-wide for a global one.
|
||||
fn saved_for(&self, id: &str) -> &SecretStore {
|
||||
match &self.global {
|
||||
Some((_, store)) if self.global_sources().iter().any(|s| s.id == id) => store,
|
||||
_ => &self.saved_passwords,
|
||||
}
|
||||
}
|
||||
|
||||
fn store_of(&self, source: &DataSource) -> &SecretStore {
|
||||
match &self.global {
|
||||
Some((_, store)) if source.global => store,
|
||||
_ => &self.saved_passwords,
|
||||
}
|
||||
source::load(&self.root)
|
||||
}
|
||||
|
||||
/// The connection settings of target `target` ([`DataSource::resolved`]), with
|
||||
@@ -409,43 +372,18 @@ impl DbManager {
|
||||
}
|
||||
|
||||
/// Saves the data source list; connections of removed or changed sources are closed.
|
||||
/// Global ones (`global`) go to the app-wide list, the others to the project's.
|
||||
pub async fn save_sources(&self, sources: Vec<DataSource>) -> IdeResult<()> {
|
||||
let old = self.sources().unwrap_or_default();
|
||||
let (global, project): (Vec<_>, Vec<_>) = sources.iter().cloned().partition(|s| s.global);
|
||||
match &self.global {
|
||||
// Rewritten only when it changes: other projects' windows edit the same file.
|
||||
Some((dir, _)) => {
|
||||
if global != old.iter().filter(|s| s.global).cloned().collect::<Vec<_>>() {
|
||||
source::save_global(dir, &global)?;
|
||||
}
|
||||
}
|
||||
None if !global.is_empty() => return Err(IdeError::invalid("global data sources are not available here")),
|
||||
None => {}
|
||||
}
|
||||
source::save(&self.root, &project)?;
|
||||
source::save(&self.root, &sources)?;
|
||||
for o in old {
|
||||
match sources.iter().find(|s| s.id == o.id) {
|
||||
Some(n) if *n == o => {}
|
||||
gone_or_changed => {
|
||||
let keys = [o.id.clone(), secrets::ssh_key(&o.id)];
|
||||
match gone_or_changed {
|
||||
None => {
|
||||
for k in &keys {
|
||||
self.session_passwords.lock().remove(k);
|
||||
self.store_of(&o).delete(k);
|
||||
}
|
||||
if gone_or_changed.is_none() {
|
||||
for k in [o.id.clone(), secrets::ssh_key(&o.id)] {
|
||||
self.session_passwords.lock().remove(&k);
|
||||
self.saved_passwords.delete(&k);
|
||||
}
|
||||
// Made global or project-level: its saved passwords move along.
|
||||
Some(n) if n.global != o.global => {
|
||||
for k in &keys {
|
||||
if let Some(p) = self.store_of(&o).get(k) {
|
||||
self.store_of(n).set(k, &p)?;
|
||||
self.store_of(&o).delete(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(_) => {}
|
||||
}
|
||||
self.disconnect_source(&o.id).await;
|
||||
}
|
||||
@@ -457,7 +395,7 @@ impl DbManager {
|
||||
/// Remembers `password` for the session, and in the saved passwords when
|
||||
/// the data source saves passwords (`None` forgets it).
|
||||
pub async fn set_password(&self, id: &str, password: Option<String>, save: bool) -> IdeResult<()> {
|
||||
self.remember(id, id.to_owned(), password, save).await
|
||||
self.remember(id.to_owned(), password, save).await
|
||||
}
|
||||
|
||||
/// [`DbManager::set_password`] for the SSH password / key passphrase.
|
||||
@@ -469,10 +407,10 @@ impl DbManager {
|
||||
self.ssh.set_secret(&c, secret, save);
|
||||
return Ok(());
|
||||
}
|
||||
self.remember(id, secrets::ssh_key(id), secret, save).await
|
||||
self.remember(secrets::ssh_key(id), secret, save).await
|
||||
}
|
||||
|
||||
async fn remember(&self, id: &str, key: String, secret: Option<String>, save: bool) -> IdeResult<()> {
|
||||
async fn remember(&self, key: String, secret: Option<String>, save: bool) -> IdeResult<()> {
|
||||
match &secret {
|
||||
Some(p) => {
|
||||
self.session_passwords.lock().insert(key.clone(), p.clone());
|
||||
@@ -481,11 +419,10 @@ impl DbManager {
|
||||
self.session_passwords.lock().remove(&key);
|
||||
}
|
||||
}
|
||||
let store = self.saved_for(id);
|
||||
let result = match (secret, save) {
|
||||
(Some(p), true) => store.set(&key, &p),
|
||||
(Some(p), true) => self.saved_passwords.set(&key, &p),
|
||||
_ => {
|
||||
store.delete(&key);
|
||||
self.saved_passwords.delete(&key);
|
||||
Ok(())
|
||||
}
|
||||
};
|
||||
@@ -496,18 +433,18 @@ impl DbManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn secret(&self, source: &DataSource, key: String, saved: bool) -> Option<String> {
|
||||
async fn secret(&self, key: String, saved: bool) -> Option<String> {
|
||||
if let Some(p) = self.session_passwords.lock().get(&key) {
|
||||
return Some(p.clone());
|
||||
}
|
||||
if !saved {
|
||||
return None;
|
||||
}
|
||||
self.store_of(source).get(&key)
|
||||
self.saved_passwords.get(&key)
|
||||
}
|
||||
|
||||
async fn password(&self, source: &DataSource) -> Option<String> {
|
||||
match self.secret(source, source.id.clone(), source.driver != DbDriver::Sqlite && source.save_password).await {
|
||||
match self.secret(source.id.clone(), source.driver != DbDriver::Sqlite && source.save_password).await {
|
||||
Some(p) => Some(p),
|
||||
None => source.url_password(),
|
||||
}
|
||||
@@ -563,7 +500,7 @@ impl DbManager {
|
||||
let session = if config.is_empty() {
|
||||
let secret = match ssh_secret {
|
||||
Some(s) => Some(s),
|
||||
None => self.secret(source, secrets::ssh_key(&source.id), ssh.save_secret).await,
|
||||
None => self.secret(secrets::ssh_key(&source.id), ssh.save_secret).await,
|
||||
};
|
||||
let target = SshTarget {
|
||||
host: ssh.host.clone(),
|
||||
@@ -1161,81 +1098,6 @@ impl DbManager {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ide_ssh::secrets::testing::files_containing;
|
||||
use std::path::Path;
|
||||
|
||||
fn manager(dir: &Path) -> DbManager {
|
||||
ide_ssh::secrets::testing::use_memory_store();
|
||||
let data = dir.join("data");
|
||||
let ssh = Arc::new(SshManager::new(data.join("ssh-passwords.json")));
|
||||
DbManager::new(dir.join("project"), data.join("db-passwords.json"), data.join("db-history.json"), ssh)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn saved_passwords_stay_off_disk() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
manager(tmp.path()).set_password("s1", Some("hunter2-db".into()), true).await.unwrap();
|
||||
manager(tmp.path()).set_ssh_secret("s1", Some("hunter2-tunnel".into()), true).await.unwrap();
|
||||
assert_eq!(files_containing(tmp.path(), "hunter2-db"), Vec::<PathBuf>::new());
|
||||
assert_eq!(files_containing(tmp.path(), "hunter2-tunnel"), Vec::<PathBuf>::new());
|
||||
let reopened = manager(tmp.path());
|
||||
assert_eq!(reopened.secret(&DataSource::default(), "s1".into(), true).await.as_deref(), Some("hunter2-db"));
|
||||
assert_eq!(
|
||||
reopened.secret(&DataSource::default(), secrets::ssh_key("s1"), true).await.as_deref(),
|
||||
Some("hunter2-tunnel")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn moves_plaintext_passwords_of_older_versions_off_disk() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let project = tmp.path().join("project").to_string_lossy().into_owned();
|
||||
let legacy = serde_json::json!({ project: { "s1": "old-db-pw" }, "/other": { "s2": "other-pw" } });
|
||||
std::fs::create_dir_all(tmp.path().join("data")).unwrap();
|
||||
std::fs::write(tmp.path().join("data/db-passwords.json"), legacy.to_string()).unwrap();
|
||||
let m = manager(tmp.path());
|
||||
assert_eq!(m.secret(&DataSource::default(), "s1".into(), true).await.as_deref(), Some("old-db-pw"));
|
||||
assert_eq!(files_containing(tmp.path(), "old-db-pw"), Vec::<PathBuf>::new());
|
||||
assert_eq!(files_containing(tmp.path(), "other-pw"), Vec::<PathBuf>::new());
|
||||
let other = SecretStore::new(tmp.path().join("data/db-passwords.json"), Path::new("/other"));
|
||||
assert_eq!(other.get("s2").as_deref(), Some("other-pw"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn global_data_sources_are_shared_by_projects_with_their_passwords() {
|
||||
ide_ssh::secrets::testing::use_memory_store();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (app, one, two) = (tmp.path().join("app"), tmp.path().join("one"), tmp.path().join("two"));
|
||||
let manager = |root: &std::path::Path| {
|
||||
let ssh = Arc::new(SshManager::new(app.join("ssh-passwords.json")));
|
||||
DbManager::new(root.to_path_buf(), app.join("passwords.json"), app.join("history.json"), ssh)
|
||||
.with_global_sources(app.clone())
|
||||
};
|
||||
let (a, b) = (manager(&one), manager(&two));
|
||||
let source = |id: &str, global: bool| DataSource {
|
||||
id: id.into(),
|
||||
name: id.into(),
|
||||
driver: DbDriver::Postgres,
|
||||
save_password: true,
|
||||
global,
|
||||
..Default::default()
|
||||
};
|
||||
a.save_sources(vec![source("local", false), source("shared", false)]).await.unwrap();
|
||||
a.set_password("shared", Some("pw".into()), true).await.unwrap();
|
||||
// Made global: listed in the other project too, with its saved password.
|
||||
a.save_sources(vec![source("local", false), source("shared", true)]).await.unwrap();
|
||||
let ids = |m: &DbManager| m.sources().unwrap().into_iter().map(|s| (s.id, s.global)).collect::<Vec<_>>();
|
||||
assert_eq!(ids(&a), [("local".into(), false), ("shared".into(), true)]);
|
||||
assert_eq!(ids(&b), [("shared".into(), true)]);
|
||||
assert_eq!(b.password(&b.source("shared").unwrap()).await.as_deref(), Some("pw"));
|
||||
let project_file = std::fs::read_to_string(one.join(".openidea/data-sources.toml")).unwrap();
|
||||
assert!(!project_file.contains("shared") && !project_file.contains("global"), "{project_file}");
|
||||
assert!(!std::fs::read_to_string(app.join("data-sources.toml")).unwrap().contains("global"));
|
||||
// Made project-level again: gone from the other project, the password stays with it.
|
||||
a.save_sources(vec![source("local", false), source("shared", false)]).await.unwrap();
|
||||
assert!(b.sources().unwrap().is_empty());
|
||||
assert_eq!(a.password(&a.source("shared").unwrap()).await.as_deref(), Some("pw"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn clickhouse_rejects_manual_transactions_and_table_edits_without_connecting() {
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
//! Saved data source passwords, in the OS credential store like the other saved
|
||||
//! passwords ([`SecretFile`]). One index file for all projects; each credential is
|
||||
//! keyed by project root, then credential key.
|
||||
//! Saved data source passwords, in the app's data directory rather than the OS
|
||||
//! credential store (no system prompts). One JSON file for all projects, keyed
|
||||
//! by project root, then credential key; owner-only on Unix.
|
||||
|
||||
use ide_core::IdeResult;
|
||||
use ide_ssh::secrets::SecretFile;
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use parking_lot::Mutex;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Project root → credential key → secret.
|
||||
type Contents = BTreeMap<String, BTreeMap<String, String>>;
|
||||
|
||||
/// Serialises read-modify-write across the projects sharing the file.
|
||||
static LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
pub struct SecretStore {
|
||||
path: PathBuf,
|
||||
file: SecretFile,
|
||||
project: String,
|
||||
}
|
||||
|
||||
@@ -18,41 +23,61 @@ pub fn ssh_key(id: &str) -> String {
|
||||
format!("{id}#ssh")
|
||||
}
|
||||
|
||||
fn file_key(project: &str, key: &str) -> String {
|
||||
format!("{project}|{key}")
|
||||
}
|
||||
|
||||
/// Older versions' file: project root → credential key → secret.
|
||||
fn legacy(value: serde_json::Value) -> Option<BTreeMap<String, String>> {
|
||||
let all: BTreeMap<String, BTreeMap<String, String>> = serde_json::from_value(value).ok()?;
|
||||
Some(
|
||||
all.into_iter()
|
||||
.flat_map(|(project, keys)| keys.into_iter().map(move |(k, s)| (file_key(&project, &k), s)))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
impl SecretStore {
|
||||
pub fn new(path: PathBuf, root: &Path) -> Self {
|
||||
let file = SecretFile::with_legacy(path.clone(), legacy);
|
||||
Self { path, file, project: root.to_string_lossy().into_owned() }
|
||||
}
|
||||
|
||||
/// The same file, keyed by `scope` instead (global data sources: the app data directory).
|
||||
pub fn scoped(&self, scope: &Path) -> Self {
|
||||
Self::new(self.path.clone(), scope)
|
||||
Self { path, project: root.to_string_lossy().into_owned() }
|
||||
}
|
||||
|
||||
pub fn get(&self, key: &str) -> Option<String> {
|
||||
self.file.get(&file_key(&self.project, key))
|
||||
let _g = LOCK.lock();
|
||||
self.read().remove(&self.project)?.remove(key)
|
||||
}
|
||||
|
||||
pub fn set(&self, key: &str, secret: &str) -> IdeResult<()> {
|
||||
self.file.set(&file_key(&self.project, key), secret)
|
||||
let _g = LOCK.lock();
|
||||
let mut all = self.read();
|
||||
all.entry(self.project.clone()).or_default().insert(key.to_owned(), secret.to_owned());
|
||||
self.write(&all)
|
||||
}
|
||||
|
||||
pub fn delete(&self, key: &str) {
|
||||
self.file.delete(&file_key(&self.project, key))
|
||||
let _g = LOCK.lock();
|
||||
let mut all = self.read();
|
||||
let Some(project) = all.get_mut(&self.project) else { return };
|
||||
if project.remove(key).is_none() {
|
||||
return;
|
||||
}
|
||||
if project.is_empty() {
|
||||
all.remove(&self.project);
|
||||
}
|
||||
if let Err(e) = self.write(&all) {
|
||||
tracing::warn!("saved passwords: {}", e.message);
|
||||
}
|
||||
}
|
||||
|
||||
fn read(&self) -> Contents {
|
||||
match std::fs::read(&self.path) {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|e| {
|
||||
tracing::warn!("saved passwords: {}: {e}", self.path.display());
|
||||
Contents::new()
|
||||
}),
|
||||
Err(_) => Contents::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn write(&self, all: &Contents) -> IdeResult<()> {
|
||||
let err = |e: std::io::Error| IdeError::unavailable(format!("saved passwords: {e}"));
|
||||
if let Some(dir) = self.path.parent() {
|
||||
std::fs::create_dir_all(dir).map_err(err)?;
|
||||
}
|
||||
let tmp = self.path.with_extension("json.tmp");
|
||||
let mut options = std::fs::OpenOptions::new();
|
||||
options.write(true).create(true).truncate(true);
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
|
||||
let bytes = serde_json::to_vec_pretty(all).map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
std::io::Write::write_all(&mut options.open(&tmp).map_err(err)?, &bytes).map_err(err)?;
|
||||
std::fs::rename(&tmp, &self.path).map_err(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +87,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn keeps_projects_apart_and_drops_empty_ones() {
|
||||
ide_ssh::secrets::testing::use_memory_store();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let file = tmp.path().join("db-passwords.json");
|
||||
let a = SecretStore::new(file.clone(), Path::new("/p/a"));
|
||||
|
||||
+10
-42
@@ -1,7 +1,6 @@
|
||||
//! Data sources: per-project connection settings in `.openidea/data-sources.toml`, and
|
||||
//! global ones (IDEA's Make Global: listed in every project) in `data-sources.toml` of the
|
||||
//! app data directory. Passwords never go there: saved ones live in [`crate::secrets`],
|
||||
//! the others only in memory for the session.
|
||||
//! Data sources: per-project connection settings in `.openidea/data-sources.toml`.
|
||||
//! Passwords never go there: saved ones live in [`crate::secrets`], the others
|
||||
//! only in memory for the session.
|
||||
|
||||
use ide_core::{IdeError, IdeResult, IpcPath, project_dir};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -182,9 +181,6 @@ pub struct DataSource {
|
||||
pub options: DbOptions,
|
||||
pub introspection: DbIntrospection,
|
||||
pub properties: Vec<DbProperty>,
|
||||
/// Stored app-wide and listed in every project. Implied by the file it is read from,
|
||||
/// so not written to it.
|
||||
pub global: bool,
|
||||
}
|
||||
|
||||
impl Default for DataSource {
|
||||
@@ -213,7 +209,6 @@ impl Default for DataSource {
|
||||
options: DbOptions::default(),
|
||||
introspection: DbIntrospection::default(),
|
||||
properties: Vec::new(),
|
||||
global: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -402,32 +397,9 @@ struct SourcesFile {
|
||||
sources: Vec<DataSource>,
|
||||
}
|
||||
|
||||
/// The project's data sources.
|
||||
pub fn load(root: &Path) -> IdeResult<Vec<DataSource>> {
|
||||
read(&project_dir::path(root, FILE))
|
||||
}
|
||||
|
||||
/// Saves the project's data sources.
|
||||
pub fn save(root: &Path, sources: &[DataSource]) -> IdeResult<()> {
|
||||
write(&project_dir::path(root, FILE), sources)
|
||||
}
|
||||
|
||||
/// The global data sources stored in `dir` (the app data directory).
|
||||
pub fn load_global(dir: &Path) -> IdeResult<Vec<DataSource>> {
|
||||
let mut sources = read(&dir.join(FILE))?;
|
||||
for s in &mut sources {
|
||||
s.global = true;
|
||||
}
|
||||
Ok(sources)
|
||||
}
|
||||
|
||||
/// Saves the global data sources in `dir`.
|
||||
pub fn save_global(dir: &Path, sources: &[DataSource]) -> IdeResult<()> {
|
||||
write(&dir.join(FILE), sources)
|
||||
}
|
||||
|
||||
fn read(path: &Path) -> IdeResult<Vec<DataSource>> {
|
||||
let text = match std::fs::read_to_string(path) {
|
||||
let path = project_dir::path(root, FILE);
|
||||
let text = match std::fs::read_to_string(&path) {
|
||||
Ok(t) => t,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
|
||||
Err(e) => return Err(e.into()),
|
||||
@@ -436,23 +408,19 @@ fn read(path: &Path) -> IdeResult<Vec<DataSource>> {
|
||||
Ok(file.sources)
|
||||
}
|
||||
|
||||
fn write(path: &Path, sources: &[DataSource]) -> IdeResult<()> {
|
||||
pub fn save(root: &Path, sources: &[DataSource]) -> IdeResult<()> {
|
||||
for s in sources {
|
||||
if s.id.is_empty() || s.name.trim().is_empty() {
|
||||
return Err(IdeError::invalid("every data source needs an id and a name"));
|
||||
}
|
||||
}
|
||||
let path = project_dir::path(root, FILE);
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
let internal = |e: &dyn std::fmt::Display| IdeError::internal(e.to_string());
|
||||
let mut value = toml::Value::try_from(SourcesFile { sources: sources.to_vec() }).map_err(|e| internal(&e))?;
|
||||
if let Some(list) = value.get_mut("data-source").and_then(toml::Value::as_array_mut) {
|
||||
for table in list.iter_mut().filter_map(toml::Value::as_table_mut) {
|
||||
table.remove("global");
|
||||
}
|
||||
}
|
||||
std::fs::write(path, toml::to_string_pretty(&value).map_err(|e| internal(&e))?)?;
|
||||
let text = toml::to_string_pretty(&SourcesFile { sources: sources.to_vec() })
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
std::fs::write(path, text)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -364,7 +364,7 @@ pub fn package_hash(dir: &Path, files: &[PathBuf]) -> std::io::Result<String> {
|
||||
sorted.dedup();
|
||||
let mut hasher = blake3::Hasher::new();
|
||||
for rel in sorted {
|
||||
hasher.update(hashed_path(rel).as_bytes());
|
||||
hasher.update(rel.to_string_lossy().as_bytes());
|
||||
hasher.update(&[0]);
|
||||
hasher.update_reader(std::fs::File::open(dir.join(rel))?)?;
|
||||
hasher.update(&[0]);
|
||||
@@ -372,12 +372,6 @@ pub fn package_hash(dir: &Path, files: &[PathBuf]) -> std::io::Result<String> {
|
||||
Ok(hasher.finalize().to_hex().to_string())
|
||||
}
|
||||
|
||||
/// A package-relative path as hashed: `/`-separated on every OS, so a package has one digest
|
||||
/// (a walked `web\index.html` on Windows is `web/index.html` everywhere else).
|
||||
fn hashed_path(rel: &Path) -> String {
|
||||
rel.components().map(|c| c.as_os_str().to_string_lossy()).collect::<Vec<_>>().join("/")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -486,20 +480,4 @@ mod tests {
|
||||
let groups = by_risk(&caps(&["storage", "process:git", "net:x.io"]));
|
||||
assert_eq!(groups.iter().map(|g| g.0).collect::<Vec<_>>(), [Risk::High, Risk::Medium, Risk::Low]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hashes_paths_with_forward_slashes_on_every_os() {
|
||||
assert_eq!(hashed_path(Path::new("web/index.html")), "web/index.html");
|
||||
assert_eq!(hashed_path(Path::new("plugin.wasm")), "plugin.wasm");
|
||||
#[cfg(windows)]
|
||||
assert_eq!(hashed_path(Path::new(r"web\index.html")), "web/index.html");
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir(tmp.path().join("web")).unwrap();
|
||||
std::fs::write(tmp.path().join("web").join("index.html"), "<p>").unwrap();
|
||||
// A walked path (native separators) hashes like the manifest's `/` spelling.
|
||||
assert_eq!(
|
||||
package_hash(tmp.path(), &[Path::new("web").join("index.html")]).unwrap(),
|
||||
package_hash(tmp.path(), &["web/index.html".into()]).unwrap()
|
||||
);
|
||||
}
|
||||
}
|
||||
+49
-110
@@ -25,7 +25,7 @@ use ide_language::{LanguageRegistry, Syntax};
|
||||
use ide_run::ProcessManager;
|
||||
use ide_terminal::TerminalManager;
|
||||
use ide_workspace::SettingsStore;
|
||||
use parking_lot::{Mutex, RwLock};
|
||||
use parking_lot::RwLock;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
@@ -74,8 +74,6 @@ pub struct Host {
|
||||
pub environment: environment::Environment,
|
||||
/// The HTTP proxy's saved password (see `proxy.rs`).
|
||||
proxy_password: ide_ssh::secrets::SecretFile,
|
||||
/// The proxy password when it could not be saved (no credential store).
|
||||
proxy_session_password: Mutex<Option<String>>,
|
||||
pub(crate) sink_for: SinkFactory,
|
||||
owner: fn(&str) -> &str,
|
||||
pub runtime: tokio::runtime::Handle,
|
||||
@@ -84,13 +82,8 @@ pub struct Host {
|
||||
pub(crate) external_logs: RwLock<HashMap<String, Vec<Arc<ide_vcs::Repo>>>>,
|
||||
requests: requests::Requests,
|
||||
project_requests: RwLock<HashMap<String, Vec<RequestId>>>,
|
||||
/// Shutdowns of projects closed with [`Host::close_project_detached`].
|
||||
closing: parking_lot::Mutex<Vec<std::thread::JoinHandle<()>>>,
|
||||
}
|
||||
|
||||
/// How long exit waits for project shutdown before giving up.
|
||||
const EXIT_SHUTDOWN_WAIT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
|
||||
impl Host {
|
||||
pub fn new(config: HostConfig) -> Self {
|
||||
let HostConfig {
|
||||
@@ -110,7 +103,6 @@ impl Host {
|
||||
let host = Self {
|
||||
trust: trust::TrustStore::load(&config_dir, &data_dir),
|
||||
proxy_password: ide_ssh::secrets::SecretFile::new(data_dir.join("proxy-password.json")),
|
||||
proxy_session_password: Mutex::default(),
|
||||
remote: Arc::new(ide_remote::RemoteService::new(ssh.clone(), data_dir.join("remote"))),
|
||||
ssh,
|
||||
environment: environment::Environment::new(project_environment, local_data_dir.clone()),
|
||||
@@ -134,7 +126,6 @@ impl Host {
|
||||
external_logs: RwLock::default(),
|
||||
requests: Default::default(),
|
||||
project_requests: RwLock::default(),
|
||||
closing: Default::default(),
|
||||
};
|
||||
host.apply_proxy();
|
||||
host
|
||||
@@ -188,69 +179,46 @@ impl Host {
|
||||
}
|
||||
}
|
||||
|
||||
/// Closes `window`'s project and waits until its language servers, kernels and
|
||||
/// debug sessions have shut down. Never call this on the UI event-loop thread:
|
||||
/// the shutdown runs on the async runtime, whose workers may themselves be
|
||||
/// waiting for the event loop (IPC responses, window queries). Use
|
||||
/// [`Self::close_project_detached`] there.
|
||||
pub fn close_project(&self, window: &str) {
|
||||
if let Some(shutdown) = self.begin_close(window) {
|
||||
self.wait_blocking(shutdown);
|
||||
}
|
||||
}
|
||||
|
||||
/// Like [`Self::close_project`], but returns once the project is detached from
|
||||
/// the window; its workers finish shutting down in the background, and
|
||||
/// [`Self::shutdown_notebooks_on_exit`] waits for them before the process exits.
|
||||
pub fn close_project_detached(&self, window: &str) {
|
||||
if let Some(shutdown) = self.begin_close(window) {
|
||||
let mut closing = self.closing.lock();
|
||||
closing.retain(|handle| !handle.is_finished());
|
||||
closing.push(shutdown);
|
||||
}
|
||||
}
|
||||
|
||||
/// Detaches the project from `window` and stops what it started, synchronously,
|
||||
/// so the window can open another project at once. Returns the thread that
|
||||
/// finishes shutting down its workers.
|
||||
fn begin_close(&self, window: &str) -> Option<std::thread::JoinHandle<()>> {
|
||||
self.external_logs.write().remove(self.owner_window(window));
|
||||
for request in self.project_requests.write().remove(self.owner_window(window)).unwrap_or_default() {
|
||||
self.cancel_request(request);
|
||||
}
|
||||
let project = self.projects.write().remove(window)?;
|
||||
if !project.light {
|
||||
self.environment.project_closed();
|
||||
let project = self.projects.write().remove(window);
|
||||
if let Some(project) = project {
|
||||
if !project.light {
|
||||
self.environment.project_closed();
|
||||
}
|
||||
if let Some(bridge) = &project.kotlin_bridge {
|
||||
bridge.stop();
|
||||
}
|
||||
project.documents.clear_workspace_edit_history();
|
||||
project.agents.shutdown();
|
||||
project.notebooks.stop_admission();
|
||||
// What the closing window started goes with it — the builds and searches it
|
||||
// asked for, its runs, its shells and its debug sessions — and the language
|
||||
// servers finish shutting down before the window is gone, as IDEA closes a
|
||||
// project's processes.
|
||||
self.processes.kill_project(&project.root);
|
||||
self.terminals.kill_key(self.owner_window(window));
|
||||
self.shutdown_project_workers_blocking(
|
||||
vec![project.notebooks.clone()],
|
||||
project.kotlin_bridge.iter().cloned().collect(),
|
||||
Some(project.lsp.clone()),
|
||||
Some((self.debugger.clone(), window.to_owned())),
|
||||
);
|
||||
}
|
||||
if let Some(bridge) = &project.kotlin_bridge {
|
||||
bridge.stop();
|
||||
}
|
||||
project.documents.clear_workspace_edit_history();
|
||||
project.agents.shutdown();
|
||||
project.notebooks.stop_admission();
|
||||
// What the closing window started goes with it — the builds and searches it
|
||||
// asked for, its runs, its shells and its debug sessions — and its language
|
||||
// servers shut down.
|
||||
self.processes.kill_project(&project.root);
|
||||
self.terminals.kill_key(self.owner_window(window));
|
||||
Some(self.spawn_project_shutdown(
|
||||
vec![project.notebooks.clone()],
|
||||
project.kotlin_bridge.iter().cloned().collect(),
|
||||
Some(project.lsp.clone()),
|
||||
Some((self.debugger.clone(), window.to_owned())),
|
||||
))
|
||||
}
|
||||
|
||||
/// Project/window destruction must finish owned kernel cleanup, not enqueue
|
||||
/// work which can be lost as the desktop runtime exits: the returned thread is
|
||||
/// joined by the closer or, for a detached close, before exit.
|
||||
fn spawn_project_shutdown(
|
||||
/// Synchronous project/window destruction must finish owned kernel cleanup,
|
||||
/// not enqueue work which can be lost as the desktop runtime exits.
|
||||
fn shutdown_project_workers_blocking(
|
||||
&self,
|
||||
notebooks: Vec<Arc<notebook::NotebookService>>,
|
||||
bridges: Vec<Arc<kotlin_bridge::KotlinBridge>>,
|
||||
lsp: Option<Arc<ide_lsp::LspManager>>,
|
||||
debug: Option<(Arc<ide_dap::DebugManager>, String)>,
|
||||
) -> std::thread::JoinHandle<()> {
|
||||
) {
|
||||
for bridge in &bridges {
|
||||
bridge.stop();
|
||||
}
|
||||
@@ -258,37 +226,25 @@ impl Host {
|
||||
service.stop_admission();
|
||||
}
|
||||
let runtime = self.runtime.clone();
|
||||
std::thread::spawn(move || {
|
||||
runtime.block_on(async move {
|
||||
for bridge in bridges {
|
||||
bridge.shutdown().await;
|
||||
}
|
||||
for service in notebooks {
|
||||
service.shutdown().await;
|
||||
}
|
||||
if let Some(lsp) = lsp {
|
||||
lsp.shutdown_all().await;
|
||||
}
|
||||
if let Some((debug, key)) = debug {
|
||||
debug.stop_key(&key).await;
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
/// Waits for detached closes, so a project opened next never races the
|
||||
/// language servers of one still shutting down (e.g. over a shared workspace).
|
||||
pub(crate) fn wait_detached_closes(&self) {
|
||||
let pending: Vec<_> = self.closing.lock().drain(..).collect();
|
||||
for shutdown in pending {
|
||||
self.wait_blocking(shutdown);
|
||||
}
|
||||
}
|
||||
|
||||
fn wait_blocking(&self, shutdown: std::thread::JoinHandle<()>) {
|
||||
let wait = move || {
|
||||
if let Err(error) = shutdown.join() {
|
||||
tracing::error!("project shutdown thread failed: {error:?}");
|
||||
let worker = std::thread::spawn(move || {
|
||||
runtime.block_on(async move {
|
||||
for bridge in bridges {
|
||||
bridge.shutdown().await;
|
||||
}
|
||||
for service in notebooks {
|
||||
service.shutdown().await;
|
||||
}
|
||||
if let Some(lsp) = lsp {
|
||||
lsp.shutdown_all().await;
|
||||
}
|
||||
if let Some((debug, key)) = debug {
|
||||
debug.stop_key(&key).await;
|
||||
}
|
||||
});
|
||||
});
|
||||
if let Err(error) = worker.join() {
|
||||
tracing::error!("notebook shutdown thread failed: {error:?}");
|
||||
}
|
||||
};
|
||||
if tokio::runtime::Handle::try_current()
|
||||
@@ -300,32 +256,15 @@ impl Host {
|
||||
}
|
||||
}
|
||||
|
||||
/// Called before the desktop runtime exits, including update/restart exits, on
|
||||
/// the event-loop thread: waits for detached closes and the open projects'
|
||||
/// kernels, but no longer than [`EXIT_SHUTDOWN_WAIT`], so a runtime worker
|
||||
/// stuck on the event loop cannot keep the process from exiting.
|
||||
/// Called before the desktop runtime exits, including update/restart exits.
|
||||
pub fn shutdown_notebooks_on_exit(&self) {
|
||||
let projects = self.projects();
|
||||
let mut pending: Vec<_> = self.closing.lock().drain(..).collect();
|
||||
pending.push(self.spawn_project_shutdown(
|
||||
self.shutdown_project_workers_blocking(
|
||||
projects.iter().map(|project| project.notebooks.clone()).collect(),
|
||||
projects.iter().filter_map(|project| project.kotlin_bridge.clone()).collect(),
|
||||
None,
|
||||
None,
|
||||
));
|
||||
let deadline = std::time::Instant::now() + EXIT_SHUTDOWN_WAIT;
|
||||
while pending.iter().any(|handle| !handle.is_finished()) {
|
||||
if std::time::Instant::now() >= deadline {
|
||||
tracing::warn!("project shutdown did not finish within {EXIT_SHUTDOWN_WAIT:?}; exiting anyway");
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
}
|
||||
for handle in pending {
|
||||
if let Err(error) = handle.join() {
|
||||
tracing::error!("project shutdown thread failed: {error:?}");
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// Called before desktop exit, which may bypass document destructors.
|
||||
|
||||
@@ -175,12 +175,7 @@ pub fn set_settings(
|
||||
host.apply_proxy();
|
||||
}
|
||||
// A new interpreter / toolchain / server executable restarts the language servers it affects.
|
||||
// So do inspections that are compiler options (`ide-sdk` `configure_java_compiler`).
|
||||
let code_style = key == "editor"
|
||||
|| key == "editor.codeStyle"
|
||||
|| key.starts_with("editor.codeStyle.")
|
||||
|| key == "inspections"
|
||||
|| key.starts_with("inspections.");
|
||||
let code_style = key == "editor" || key == "editor.codeStyle" || key.starts_with("editor.codeStyle.");
|
||||
let toolchain = ["languages", "tools", "languageServers", "projectStructure"]
|
||||
.iter()
|
||||
.any(|k| key == *k || key.starts_with(&format!("{k}.")));
|
||||
|
||||
@@ -165,16 +165,6 @@ impl PluginHost {
|
||||
self.save_state();
|
||||
}
|
||||
|
||||
/// The marketplace's plugins: read again when `refresh`, else the last index
|
||||
/// read. Reads it when none has been read yet (first run, nothing cached).
|
||||
pub fn market_listing(&self, refresh: bool) -> IdeResult<Vec<MarketPlugin>> {
|
||||
if refresh || (self.market.read().is_none() && self.market_url().is_some()) {
|
||||
self.refresh_market()
|
||||
} else {
|
||||
Ok(self.market_plugins())
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the index again, keeps it, and applies its revocations. Announces
|
||||
/// updates of installed plugins it hasn't announced yet.
|
||||
pub fn refresh_market(&self) -> IdeResult<Vec<MarketPlugin>> {
|
||||
|
||||
@@ -88,7 +88,6 @@ impl Host {
|
||||
return Ok(current);
|
||||
}
|
||||
self.close_project(window);
|
||||
self.wait_detached_closes();
|
||||
let sink: SharedSink = (self.sink_for)(window);
|
||||
// Before anything the project starts: its children get the project's environment.
|
||||
let trusted = Arc::new(AtomicBool::new(self.trust.trusted(&root)?));
|
||||
@@ -210,7 +209,6 @@ impl Host {
|
||||
cfg.launch_error = Some(e);
|
||||
None
|
||||
});
|
||||
ide_sdk::apply::configure_java_compiler(cfg, &data_dir, &effective.inspections);
|
||||
ide_sdk::java::configure_jdtls(
|
||||
cfg,
|
||||
server_root,
|
||||
@@ -521,15 +519,12 @@ impl Host {
|
||||
root: &Path,
|
||||
project_key: &str,
|
||||
) -> (Arc<ide_db::DbManager>, Arc<ide_http::History>, Arc<ide_agent::AgentManager>) {
|
||||
let db = Arc::new(
|
||||
ide_db::DbManager::new(
|
||||
root.to_path_buf(),
|
||||
self.data_dir.join("db-passwords.json"),
|
||||
self.data_dir.join("db-history.json"),
|
||||
self.ssh.clone(),
|
||||
)
|
||||
.with_global_sources(self.data_dir.clone()),
|
||||
);
|
||||
let db = Arc::new(ide_db::DbManager::new(
|
||||
root.to_path_buf(),
|
||||
self.data_dir.join("db-passwords.json"),
|
||||
self.data_dir.join("db-history.json"),
|
||||
self.ssh.clone(),
|
||||
));
|
||||
let http_history = Arc::new(ide_http::History::new(self.data_dir.join("http-history").join(project_key)));
|
||||
let agents = Arc::new(ide_agent::AgentManager::new(
|
||||
ide_agent::AgentPaths {
|
||||
@@ -571,7 +566,6 @@ impl Host {
|
||||
return Ok(current);
|
||||
}
|
||||
self.close_project(window);
|
||||
self.wait_detached_closes();
|
||||
let sink: SharedSink = (self.sink_for)(window);
|
||||
let settings = Arc::new(SettingsStore::load(&self.config_dir, None));
|
||||
let effective = settings.effective();
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
//! System Settings › HTTP Proxy on the host: the global settings made current for
|
||||
//! connections and child processes, the password kept in the saved passwords
|
||||
//! (the OS credential store) instead of `settings.toml`, or for the session
|
||||
//! when there is no credential store.
|
||||
//! (owner-only `proxy-password.json`) instead of `settings.toml`.
|
||||
|
||||
use crate::Host;
|
||||
use ide_core::IdeResult;
|
||||
@@ -13,9 +12,7 @@ impl Host {
|
||||
/// `settings` with the saved proxy password filled in, as the Settings dialog edits it.
|
||||
pub fn with_proxy_password(&self, mut settings: Settings) -> Settings {
|
||||
// A password typed into settings.toml by hand counts too, unless one was saved.
|
||||
let session = || self.proxy_session_password.lock().clone();
|
||||
// The session copy is the latest write that could not be saved.
|
||||
if let Some(password) = session().or_else(|| self.proxy_password.get(PASSWORD_KEY)) {
|
||||
if let Some(password) = self.proxy_password.get(PASSWORD_KEY) {
|
||||
settings.proxy.password = password;
|
||||
}
|
||||
settings
|
||||
@@ -43,70 +40,11 @@ impl Host {
|
||||
_ => return Ok(Some(value)),
|
||||
};
|
||||
if let Some(password) = password {
|
||||
let password = password.as_str().filter(|p| !p.is_empty());
|
||||
let mut session = self.proxy_session_password.lock();
|
||||
*session = None;
|
||||
match password {
|
||||
Some(p) => {
|
||||
if let Err(e) = self.proxy_password.set(PASSWORD_KEY, p) {
|
||||
tracing::warn!("the HTTP proxy password is kept for this session only: {}", e.message);
|
||||
*session = Some(p.to_owned());
|
||||
}
|
||||
}
|
||||
match password.as_str().filter(|p| !p.is_empty()) {
|
||||
Some(p) => self.proxy_password.set(PASSWORD_KEY, p)?,
|
||||
None => self.proxy_password.delete(PASSWORD_KEY),
|
||||
}
|
||||
}
|
||||
Ok((key != "proxy.password").then_some(value))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ide_ssh::secrets::{SecretFile, StoreError, StoreResult, Vault};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A machine without a credential store (a headless Linux host).
|
||||
struct NoStore;
|
||||
|
||||
impl Vault for NoStore {
|
||||
fn available(&self) -> bool {
|
||||
false
|
||||
}
|
||||
fn get(&self, _: &str, _: &str) -> StoreResult<String> {
|
||||
Err(StoreError::NoDefaultStore)
|
||||
}
|
||||
fn set(&self, _: &str, _: &str, _: &str) -> StoreResult<()> {
|
||||
Err(StoreError::NoDefaultStore)
|
||||
}
|
||||
fn delete(&self, _: &str, _: &str) -> StoreResult<()> {
|
||||
Err(StoreError::NoDefaultStore)
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn without_a_credential_store_the_password_lasts_the_session() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut host = Host::new(crate::HostConfig {
|
||||
config_dir: tmp.path().join("config"),
|
||||
data_dir: tmp.path().join("data"),
|
||||
local_data_dir: tmp.path().join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(ide_core::NullSink)),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
});
|
||||
let path = tmp.path().join("data/proxy-password.json");
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&path, r#"{"httpProxy": "old"}"#).unwrap();
|
||||
host.proxy_password = SecretFile::with_vault(path, |v| serde_json::from_value(v).ok(), Arc::new(NoStore));
|
||||
|
||||
let left = host.take_proxy_password("proxy.password", serde_json::json!("s3cret")).unwrap();
|
||||
assert_eq!(left, None);
|
||||
assert_eq!(host.with_proxy_password(Settings::default()).proxy.password, "s3cret");
|
||||
host.take_proxy_password("proxy.password", serde_json::json!("")).unwrap();
|
||||
assert_eq!(host.with_proxy_password(Settings::default()).proxy.password, "");
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@
|
||||
use ide_core::NullSink;
|
||||
use ide_host::{Host, HostConfig, ops};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
fn options(root: &std::path::Path) -> ide_terminal::TerminalOptions {
|
||||
ide_terminal::TerminalOptions {
|
||||
@@ -53,53 +52,3 @@ fn closing_a_project_closes_its_terminals_and_keeps_other_windows() {
|
||||
host.terminals.kill_key("other");
|
||||
});
|
||||
}
|
||||
|
||||
/// The desktop closes a project on the event-loop thread while runtime workers may be
|
||||
/// blocked waiting for that thread (IPC responses, window queries): closing must not
|
||||
/// wait for workers that need the runtime, and exit still waits for them once free.
|
||||
#[test]
|
||||
fn detached_close_returns_while_runtime_workers_wait_for_the_closing_thread() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap().join("project");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let runtime = tokio::runtime::Builder::new_multi_thread().worker_threads(1).enable_all().build().unwrap();
|
||||
let _guard = runtime.enter();
|
||||
let host = Arc::new(Host::new(HostConfig {
|
||||
config_dir: tmp.path().join("config"),
|
||||
data_dir: tmp.path().join("data"),
|
||||
local_data_dir: tmp.path().join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(NullSink)),
|
||||
owner: |w| w,
|
||||
runtime: runtime.handle().clone(),
|
||||
project_environment: false,
|
||||
}));
|
||||
host.trust.decide(&root, true, false).unwrap();
|
||||
host.open_project("main", &root).unwrap();
|
||||
|
||||
// Occupy the only runtime worker until "the event loop" answers.
|
||||
let (started_tx, started_rx) = std::sync::mpsc::channel();
|
||||
let (release_tx, release_rx) = std::sync::mpsc::channel::<()>();
|
||||
runtime.spawn(async move {
|
||||
started_tx.send(()).unwrap();
|
||||
let _ = release_rx.recv();
|
||||
});
|
||||
started_rx.recv().unwrap();
|
||||
|
||||
let (closed_tx, closed_rx) = std::sync::mpsc::channel();
|
||||
let closer = host.clone();
|
||||
std::thread::spawn(move || {
|
||||
closer.close_project_detached("main");
|
||||
let _ = closed_tx.send(());
|
||||
});
|
||||
let returned = closed_rx.recv_timeout(Duration::from_secs(5));
|
||||
release_tx.send(()).unwrap();
|
||||
assert!(returned.is_ok(), "closing waited for a runtime worker that waits for the closing thread");
|
||||
assert!(host.project("main").is_err(), "the window no longer has the project");
|
||||
host.open_project("main", &root).expect("the window opens a project once the close has finished");
|
||||
|
||||
let started = Instant::now();
|
||||
host.shutdown_notebooks_on_exit();
|
||||
assert!(started.elapsed() < Duration::from_secs(5), "exit joined the finished close instead of timing out");
|
||||
}
|
||||
@@ -196,28 +196,6 @@ fn write_index(
|
||||
path.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
/// First run: nothing cached yet, so listing the marketplace reads its index
|
||||
/// instead of showing an empty one; later listings use the index read.
|
||||
#[test]
|
||||
fn the_first_marketplace_listing_reads_the_index() {
|
||||
let f = fixture();
|
||||
assert!(f.host.market_listing(false).unwrap().is_empty(), "no marketplace configured");
|
||||
|
||||
let market = f.work.join("market");
|
||||
std::fs::create_dir_all(&market).unwrap();
|
||||
let key = PublisherKey::from_seed([3; 32]);
|
||||
let (_, entry) = signed_hello(&market, "1.0.0", &key, &f.host);
|
||||
let url = write_index(&market, &key, vec![entry], vec![], false);
|
||||
f.host.set_market_url(Some(url.clone()));
|
||||
|
||||
let listed = f.host.market_listing(false).unwrap();
|
||||
assert_eq!(listed.iter().map(|p| p.entry.id.as_str()).collect::<Vec<_>>(), ["acme.hello"]);
|
||||
|
||||
std::fs::remove_file(&url).unwrap();
|
||||
assert_eq!(f.host.market_listing(false).unwrap().len(), 1, "the index read is kept");
|
||||
assert!(f.host.market_listing(true).is_err(), "refresh reads the index again");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_marketplace_plugins_install_and_revocations_disable_them() {
|
||||
let f = fixture();
|
||||
|
||||
@@ -1,285 +0,0 @@
|
||||
//! Opening each `fixtures/` project the way the app does, through the `ops` its commands call:
|
||||
//! the source and test roots detected from its build file, the index (files and symbols),
|
||||
//! Search Everywhere / Go to Class / Go to Symbol answered from it and kept current as files
|
||||
//! come and go, and the Maven / Gradle model.
|
||||
//!
|
||||
//! The `#[ignore]`d tests run Maven, Gradle, python3 and a JDK 21 (CI's `Language smoke`
|
||||
//! java, kotlin and python jobs):
|
||||
//! `cargo test -p ide-host --test project_smoke -- --ignored`.
|
||||
|
||||
use ide_host::{Host, HostConfig, ops};
|
||||
use ide_index::SymbolFilter;
|
||||
use ide_workspace::roots::RootKind;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const WINDOW: &str = "main";
|
||||
|
||||
struct Fixture {
|
||||
dir: &'static str,
|
||||
/// Detected roots: (project-relative path, kind).
|
||||
roots: &'static [(&'static str, RootKind)],
|
||||
/// Search Everywhere › Files: (query, file found).
|
||||
file: (&'static str, &'static str),
|
||||
/// Go to Class: (name, file declaring it).
|
||||
class: (&'static str, &'static str),
|
||||
/// Go to Symbol: (name, file declaring it).
|
||||
symbol: (&'static str, &'static str),
|
||||
/// A file created while the project is open: (path, text declaring the class `FreshWidget`).
|
||||
added: (&'static str, &'static str),
|
||||
}
|
||||
|
||||
struct Opened {
|
||||
host: Arc<Host>,
|
||||
root: PathBuf,
|
||||
_tmp: tempfile::TempDir,
|
||||
}
|
||||
|
||||
fn copy_dir(from: &Path, to: &Path) {
|
||||
std::fs::create_dir_all(to).unwrap();
|
||||
for entry in std::fs::read_dir(from).unwrap().flatten() {
|
||||
let (src, dst) = (entry.path(), to.join(entry.file_name()));
|
||||
if entry.file_type().unwrap().is_dir() {
|
||||
copy_dir(&src, &dst);
|
||||
} else {
|
||||
std::fs::copy(&src, &dst).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn open(fixture: &str) -> Opened {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap().join(fixture);
|
||||
copy_dir(&Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures").join(fixture), &root);
|
||||
let app = tmp.path().join("app");
|
||||
let host = Arc::new(Host::new(HostConfig {
|
||||
config_dir: app.join("config"),
|
||||
data_dir: app.join("data"),
|
||||
local_data_dir: app.join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(|_| {})),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
}));
|
||||
host.trust.decide(&root, true, false).unwrap();
|
||||
ops::project::open(&host, WINDOW, &root).await.unwrap();
|
||||
Opened { host, root, _tmp: tmp }
|
||||
}
|
||||
|
||||
/// Polls `check` until it holds; fails with its last answer.
|
||||
async fn until<T: std::fmt::Debug>(what: &str, timeout: Duration, mut check: impl FnMut() -> Result<(), T>) {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
match check() {
|
||||
Ok(()) => return,
|
||||
Err(last) if Instant::now() > deadline => panic!("{what}: timed out after {timeout:?}; last: {last:?}"),
|
||||
Err(_) => tokio::time::sleep(Duration::from_millis(100)).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Opened {
|
||||
fn files(&self, query: &str) -> Vec<String> {
|
||||
let found = ops::nav::find_files(&self.host, WINDOW, query.into(), 20).unwrap();
|
||||
found.into_iter().map(|f| f.rel_path).collect()
|
||||
}
|
||||
|
||||
fn symbols(&self, query: &str, filter: SymbolFilter) -> Vec<(String, String)> {
|
||||
let found = ops::nav::find_symbols(&self.host, WINDOW, query.into(), filter, 20).unwrap();
|
||||
found.into_iter().map(|s| (s.name, s.rel_path)).collect()
|
||||
}
|
||||
|
||||
fn has_symbol(&self, name: &str, file: &str, filter: SymbolFilter) -> Result<(), Vec<(String, String)>> {
|
||||
let found = self.symbols(name, filter);
|
||||
if found.iter().any(|(n, f)| n == name && f == file) { Ok(()) } else { Err(found) }
|
||||
}
|
||||
}
|
||||
|
||||
async fn smoke(f: Fixture) {
|
||||
let p = open(f.dir).await;
|
||||
until("indexing", Duration::from_secs(30), || {
|
||||
let s = ops::nav::index_status(&p.host, WINDOW).unwrap();
|
||||
if !s.indexing && s.files > 0 && s.symbols > 0 { Ok(()) } else { Err(s) }
|
||||
})
|
||||
.await;
|
||||
|
||||
let roots = ops::project::roots(&p.host, WINDOW).unwrap().roots;
|
||||
for (path, kind) in f.roots {
|
||||
assert!(
|
||||
roots.iter().any(|r| r.path == *path && r.kind == *kind),
|
||||
"{}: no {kind:?} root {path:?} in {roots:?}",
|
||||
f.dir
|
||||
);
|
||||
}
|
||||
|
||||
let files = p.files(f.file.0);
|
||||
assert!(files.iter().any(|r| r == f.file.1), "{}: files for {:?}: {files:?}", f.dir, f.file.0);
|
||||
if let Err(found) = p.has_symbol(f.class.0, f.class.1, SymbolFilter::Types) {
|
||||
panic!("{}: Go to Class {:?}: {found:?}", f.dir, f.class.0);
|
||||
}
|
||||
if let Err(found) = p.has_symbol(f.symbol.0, f.symbol.1, SymbolFilter::All) {
|
||||
panic!("{}: Go to Symbol {:?}: {found:?}", f.dir, f.symbol.0);
|
||||
}
|
||||
|
||||
// The index follows the file system while the project is open.
|
||||
let (added, text) = f.added;
|
||||
std::fs::write(p.root.join(added), text).unwrap();
|
||||
until("a new file's class", Duration::from_secs(20), || p.has_symbol("FreshWidget", added, SymbolFilter::Types))
|
||||
.await;
|
||||
std::fs::remove_file(p.root.join(added)).unwrap();
|
||||
until("a deleted file's class", Duration::from_secs(20), || {
|
||||
let found = p.symbols("FreshWidget", SymbolFilter::Types);
|
||||
if found.is_empty() { Ok(()) } else { Err(found) }
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn rust_app() {
|
||||
smoke(Fixture {
|
||||
dir: "rust-app",
|
||||
roots: &[("src", RootKind::Source), ("tests", RootKind::Test)],
|
||||
file: ("pars", "src/parser.rs"),
|
||||
class: ("Inventory", "src/lib.rs"),
|
||||
symbol: ("low_stock", "src/lib.rs"),
|
||||
added: ("src/fresh.rs", "pub struct FreshWidget;\n"),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn ts_app() {
|
||||
smoke(Fixture {
|
||||
dir: "ts-app",
|
||||
roots: &[("src", RootKind::Source), ("tests", RootKind::Test)],
|
||||
file: ("cart", "src/cart.ts"),
|
||||
class: ("Cart", "src/cart.ts"),
|
||||
symbol: ("formatMoney", "src/money.ts"),
|
||||
added: ("src/fresh.ts", "export class FreshWidget {}\n"),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn python_app() {
|
||||
smoke(Fixture {
|
||||
dir: "python-app",
|
||||
roots: &[("src", RootKind::Source), ("tests", RootKind::Test)],
|
||||
file: ("models", "src/tasks/models.py"),
|
||||
class: ("TaskList", "src/tasks/models.py"),
|
||||
symbol: ("build_demo", "src/tasks/cli.py"),
|
||||
added: ("src/tasks/fresh.py", "class FreshWidget:\n pass\n"),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn go_app() {
|
||||
smoke(Fixture {
|
||||
dir: "go-app",
|
||||
roots: &[("", RootKind::Source)],
|
||||
file: ("store", "internal/store/store.go"),
|
||||
class: ("Store", "internal/store/store.go"),
|
||||
symbol: ("writeJSON", "cmd/server/main.go"),
|
||||
added: ("internal/store/fresh.go", "package store\n\ntype FreshWidget struct{}\n"),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn java_app() {
|
||||
smoke(Fixture {
|
||||
dir: "java-app",
|
||||
roots: &[("src/main/java", RootKind::Source), ("src/test/java", RootKind::Test)],
|
||||
file: ("OrderBook", "src/main/java/com/example/orders/OrderBook.java"),
|
||||
class: ("OrderBook", "src/main/java/com/example/orders/OrderBook.java"),
|
||||
symbol: ("revenueCents", "src/main/java/com/example/orders/OrderBook.java"),
|
||||
added: (
|
||||
"src/main/java/com/example/orders/FreshWidget.java",
|
||||
"package com.example.orders;\n\npublic class FreshWidget {}\n",
|
||||
),
|
||||
})
|
||||
.await;
|
||||
let p = open("java-app").await;
|
||||
let model = ops::build::build_model(&p.host, WINDOW).await.unwrap();
|
||||
let maven = model.maven.expect("a Maven model");
|
||||
assert_eq!(maven.projects.iter().map(|m| m.artifact_id.as_str()).collect::<Vec<_>>(), ["openidea-fixture-orders"]);
|
||||
assert!(maven.projects[0].dependencies.iter().any(|d| d.artifact_id == "junit-jupiter"), "{maven:?}");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn kotlin_gradle() {
|
||||
smoke(Fixture {
|
||||
dir: "kotlin-gradle",
|
||||
roots: &[("src/main/kotlin", RootKind::Source), ("src/test/kotlin", RootKind::Test)],
|
||||
file: ("Library", "src/main/kotlin/com/example/library/Library.kt"),
|
||||
class: ("Library", "src/main/kotlin/com/example/library/Library.kt"),
|
||||
symbol: ("giveBack", "src/main/kotlin/com/example/library/Library.kt"),
|
||||
added: ("src/main/kotlin/com/example/library/Fresh.kt", "package com.example.library\n\nclass FreshWidget\n"),
|
||||
})
|
||||
.await;
|
||||
let p = open("kotlin-gradle").await;
|
||||
let model = ops::build::build_model(&p.host, WINDOW).await.unwrap();
|
||||
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,7 +43,6 @@ fn walk(dir: &std::path::Path) -> Vec<std::path::PathBuf> {
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn manual_proxy_applies_and_the_password_is_saved_apart() {
|
||||
ide_ssh::secrets::testing::use_memory_store();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let host = host(tmp.path());
|
||||
assert_eq!(proxy().mode, ProxyMode::Auto);
|
||||
@@ -62,7 +61,7 @@ async fn manual_proxy_applies_and_the_password_is_saved_apart() {
|
||||
|
||||
assert!(!settings_files(tmp.path()).contains("s3cret"), "no password in settings.toml");
|
||||
let saved = std::fs::read_to_string(tmp.path().join("data/proxy-password.json")).unwrap();
|
||||
assert!(!saved.contains("s3cret"), "the password is in the credential store, not on disk: {saved}");
|
||||
assert!(saved.contains("s3cret"));
|
||||
assert_eq!(ops::project::settings(&host, "main").proxy.password, "s3cret");
|
||||
|
||||
// A whole-table write keeps the password out of the file too; an empty one forgets it.
|
||||
|
||||
@@ -5,7 +5,7 @@ use crate::discover::{project_venv_python, workspace_tsdk};
|
||||
use crate::exec::{path_with, search_path};
|
||||
use crate::{angular, godot, pinned, vue};
|
||||
use crate::{bin_dir, exe, venv_of};
|
||||
use ide_core::settings::{InspectionLevel, InspectionSettings, LanguageServerSettings, Settings};
|
||||
use ide_core::settings::{LanguageServerSettings, Settings};
|
||||
use ide_core::version_managers::Tool;
|
||||
use ide_language::{LanguageConfig, LanguageServerConfig};
|
||||
use ide_run::RunConfig;
|
||||
@@ -442,41 +442,15 @@ pub(crate) fn merge_init_options(cfg: &mut LanguageServerConfig, layer: Value) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Editor › Inspections › Java: "Serializable class without 'serialVersionUID'" (off by default,
|
||||
/// as in IntelliJ IDEA). ECJ reports it, so it is a compiler option rather than a filter.
|
||||
pub const SERIAL_VERSION_INSPECTION: &str = "java:missing-serial-version";
|
||||
|
||||
/// ECJ's level for a missing `serialVersionUID`, from the inspection profile in effect.
|
||||
fn missing_serial_version(inspections: &InspectionSettings) -> Option<&'static str> {
|
||||
let o = inspections.active().get(SERIAL_VERSION_INSPECTION)?;
|
||||
if o.enabled != Some(true) {
|
||||
return None;
|
||||
}
|
||||
Some(match o.severity {
|
||||
Some(InspectionLevel::Error) => "error",
|
||||
None | Some(InspectionLevel::Warning) => "warning",
|
||||
Some(_) => "info",
|
||||
})
|
||||
}
|
||||
|
||||
/// Workspace compiler defaults; Eclipse project preferences still take precedence.
|
||||
/// Each variant has its own file, so changing an inspection restarts the server with it.
|
||||
pub fn configure_java_compiler(cfg: &mut LanguageServerConfig, data_dir: &Path, inspections: &InspectionSettings) {
|
||||
pub(crate) fn configure_java_compiler(cfg: &mut LanguageServerConfig, data_dir: &Path) {
|
||||
let file = data_dir.join("lsp/jdtls/compiler-defaults.prefs");
|
||||
const DEFAULTS: &str = "org.eclipse.jdt.core.compiler.problem.forbiddenReference=warning\n\
|
||||
org.eclipse.jdt.core.compiler.problem.discouragedReference=warning\n";
|
||||
let serial = missing_serial_version(inspections);
|
||||
let (name, content) = match serial {
|
||||
None => ("compiler-defaults.prefs".to_owned(), DEFAULTS.to_owned()),
|
||||
Some(level) => (
|
||||
format!("compiler-defaults-serial-{level}.prefs"),
|
||||
format!("{DEFAULTS}org.eclipse.jdt.core.compiler.problem.missingSerialVersion={level}\n"),
|
||||
),
|
||||
};
|
||||
let file = data_dir.join("lsp/jdtls").join(name);
|
||||
let write = || -> ide_core::IdeResult<()> {
|
||||
std::fs::create_dir_all(file.parent().expect("compiler preferences directory"))?;
|
||||
if std::fs::read_to_string(&file).ok().as_deref() != Some(content.as_str()) {
|
||||
ide_vfs::write_atomic(&file, content.as_bytes())?;
|
||||
if std::fs::read_to_string(&file).ok().as_deref() != Some(DEFAULTS) {
|
||||
ide_vfs::write_atomic(&file, DEFAULTS.as_bytes())?;
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
@@ -1121,50 +1095,13 @@ mod java_compiler_tests {
|
||||
serde_json::to_value(cfg.initialization_options.as_ref().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serial_version_inspection_sets_the_compiler_option() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let url = |inspections: &InspectionSettings| {
|
||||
let mut cfg = custom_server("jdtls", &LanguageServerSettings::default());
|
||||
configure_java_compiler(&mut cfg, tmp.path(), inspections);
|
||||
options(&cfg).pointer("/settings/java/settings/url").unwrap().as_str().unwrap().to_owned()
|
||||
};
|
||||
let set = |o: ide_core::settings::InspectionOverride| {
|
||||
let mut s = InspectionSettings::default();
|
||||
s.default.insert(SERIAL_VERSION_INSPECTION.into(), o);
|
||||
s
|
||||
};
|
||||
let off = url(&InspectionSettings::default());
|
||||
assert!(!std::fs::read_to_string(&off).unwrap().contains("missingSerialVersion"));
|
||||
let on = url(&set(ide_core::settings::InspectionOverride { enabled: Some(true), ..Default::default() }));
|
||||
assert_ne!(on, off, "a new file, so the server restarts with it");
|
||||
assert!(std::fs::read_to_string(&on).unwrap().ends_with("compiler.problem.missingSerialVersion=warning\n"));
|
||||
let error = url(&set(ide_core::settings::InspectionOverride {
|
||||
enabled: Some(true),
|
||||
severity: Some(InspectionLevel::Error),
|
||||
..Default::default()
|
||||
}));
|
||||
assert!(std::fs::read_to_string(error).unwrap().ends_with("missingSerialVersion=error\n"));
|
||||
let weak = url(&set(ide_core::settings::InspectionOverride {
|
||||
enabled: Some(true),
|
||||
severity: Some(InspectionLevel::WeakWarning),
|
||||
..Default::default()
|
||||
}));
|
||||
assert!(std::fs::read_to_string(weak).unwrap().ends_with("missingSerialVersion=info\n"));
|
||||
let severity_only = url(&set(ide_core::settings::InspectionOverride {
|
||||
severity: Some(InspectionLevel::Error),
|
||||
..Default::default()
|
||||
}));
|
||||
assert_eq!(severity_only, off, "off by default");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compiler_defaults_live_in_the_cache_and_preserve_other_options() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let cache = tmp.path().join("cache with spaces 测试");
|
||||
let mut cfg = custom_server("jdtls", &LanguageServerSettings::default());
|
||||
merge_init_options(&mut cfg, json!({"settings": {"java": {"format": {"tabSize": 4}}}}));
|
||||
configure_java_compiler(&mut cfg, &cache, &InspectionSettings::default());
|
||||
configure_java_compiler(&mut cfg, &cache);
|
||||
let options = options(&cfg);
|
||||
let path = PathBuf::from(options.pointer("/settings/java/settings/url").unwrap().as_str().unwrap());
|
||||
assert!(path.starts_with(&cache));
|
||||
@@ -1175,7 +1112,7 @@ mod java_compiler_tests {
|
||||
}
|
||||
assert_eq!(options.pointer("/settings/java/format/tabSize"), Some(&json!(4)));
|
||||
let modified = std::fs::metadata(&path).unwrap().modified().unwrap();
|
||||
configure_java_compiler(&mut cfg, &cache, &InspectionSettings::default());
|
||||
configure_java_compiler(&mut cfg, &cache);
|
||||
assert_eq!(std::fs::metadata(path).unwrap().modified().unwrap(), modified);
|
||||
assert_eq!(self::options(&cfg), options);
|
||||
}
|
||||
@@ -1185,7 +1122,7 @@ mod java_compiler_tests {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut cfg = custom_server("jdtls", &LanguageServerSettings::default());
|
||||
merge_init_options(&mut cfg, json!({"settings": {"java": {"settings": {"url": "own.prefs"}}}}));
|
||||
configure_java_compiler(&mut cfg, tmp.path(), &InspectionSettings::default());
|
||||
configure_java_compiler(&mut cfg, tmp.path());
|
||||
assert_eq!(options(&cfg).pointer("/settings/java/settings/url"), Some(&json!("own.prefs")));
|
||||
|
||||
for settings in [
|
||||
|
||||
@@ -369,6 +369,7 @@ pub fn configure_jdtls(
|
||||
if cfg.name != "jdtls" {
|
||||
return;
|
||||
}
|
||||
crate::apply::configure_java_compiler(cfg, data_dir);
|
||||
// A Lombok agent from the user's JVM arguments wins.
|
||||
let own_agent = crate::lombok::agent_in_args(cfg.args.iter().map(String::as_str));
|
||||
let agent = own_agent.as_deref().or(lombok);
|
||||
|
||||
@@ -14,20 +14,15 @@ tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
parking_lot.workspace = true
|
||||
dirs.workspace = true
|
||||
uuid.workspace = true
|
||||
keyring.workspace = true
|
||||
keyring-core.workspace = true
|
||||
russh = { version = "0.64", default-features = false, features = ["ring", "rsa", "flate2"] }
|
||||
russh-config = "0.58.0"
|
||||
russh-sftp = "2.4"
|
||||
flate2 = "1"
|
||||
|
||||
[features]
|
||||
# `testing`: an in-process SSH / SFTP server for tests of crates built on this one,
|
||||
# and an in-memory credential store in place of the OS one.
|
||||
testing = ["keyring-core/sample"]
|
||||
# `testing`: an in-process SSH / SFTP server for tests of crates built on this one.
|
||||
testing = []
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
|
||||
tempfile.workspace = true
|
||||
keyring-core = { workspace = true, features = ["sample"] }
|
||||
@@ -216,7 +216,6 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
fn store() -> (tempfile::TempDir, GitCredentials) {
|
||||
crate::secrets::testing::use_memory_store();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("git-credentials.json");
|
||||
(tmp, GitCredentials { saved: Arc::new(SecretFile::new(path)), sessions: Arc::default() })
|
||||
|
||||
+31
-499
@@ -1,289 +1,61 @@
|
||||
//! Saved passwords (SSH passwords / key passphrases by configuration id, git HTTPS
|
||||
//! credentials, the HTTP proxy's) in the OS credential store: Windows Credential
|
||||
//! Manager, the macOS Keychain, the Secret Service on Linux. The file at `path` only
|
||||
//! indexes them (an id and the keys, never a secret); owner-only on Unix.
|
||||
//!
|
||||
//! Older versions kept the secrets themselves in that file: they are moved to the
|
||||
//! credential store on first use. Without a credential store (a headless Linux
|
||||
//! host) such a file is still read and forgetting a secret rewrites it without
|
||||
//! that one, but no new secret is written to disk: saving fails and callers keep
|
||||
//! the secret for the session only.
|
||||
//! Saved passwords (SSH passwords / key passphrases by configuration id, the HTTP
|
||||
//! proxy's), in the app's data directory rather than the OS credential store (no
|
||||
//! system prompts). One JSON object, key → secret; owner-only on Unix.
|
||||
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
pub use keyring_core::{Error as StoreError, Result as StoreResult};
|
||||
use parking_lot::Mutex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
const SERVICE: &str = "ai.l0ng.openidea";
|
||||
|
||||
/// Serialises read-modify-write of the index files (several stores may share one).
|
||||
static LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
/// Reads the secrets of an older version's file, by key (`None`: not that format).
|
||||
pub type Legacy = fn(serde_json::Value) -> Option<BTreeMap<String, String>>;
|
||||
|
||||
/// Where the secrets are kept: the default credential store, or a stand-in in tests.
|
||||
pub trait Vault: Send + Sync {
|
||||
fn available(&self) -> bool;
|
||||
fn get(&self, service: &str, key: &str) -> StoreResult<String>;
|
||||
fn set(&self, service: &str, key: &str, secret: &str) -> StoreResult<()>;
|
||||
fn delete(&self, service: &str, key: &str) -> StoreResult<()>;
|
||||
}
|
||||
|
||||
/// `keyring_core`'s default store: the OS's, unless a test installed another one.
|
||||
struct DefaultStore;
|
||||
|
||||
impl DefaultStore {
|
||||
fn entry(&self, service: &str, key: &str) -> StoreResult<keyring_core::Entry> {
|
||||
if !self.available() {
|
||||
return Err(StoreError::NoDefaultStore);
|
||||
}
|
||||
keyring_core::Entry::new(service, key)
|
||||
}
|
||||
}
|
||||
|
||||
impl Vault for DefaultStore {
|
||||
fn available(&self) -> bool {
|
||||
if keyring_core::get_default_store().is_some() {
|
||||
return true;
|
||||
}
|
||||
static WARNED: std::sync::Once = std::sync::Once::new();
|
||||
match keyring::Entry::store_status() {
|
||||
Ok(()) => true,
|
||||
Err(e) => {
|
||||
WARNED.call_once(|| tracing::warn!("saved passwords: no credential store: {e}"));
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get(&self, service: &str, key: &str) -> StoreResult<String> {
|
||||
self.entry(service, key)?.get_password()
|
||||
}
|
||||
|
||||
fn set(&self, service: &str, key: &str, secret: &str) -> StoreResult<()> {
|
||||
self.entry(service, key)?.set_password(secret)
|
||||
}
|
||||
|
||||
fn delete(&self, service: &str, key: &str) -> StoreResult<()> {
|
||||
self.entry(service, key)?.delete_credential()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SecretFile {
|
||||
path: PathBuf,
|
||||
legacy: Legacy,
|
||||
vault: Arc<dyn Vault>,
|
||||
}
|
||||
|
||||
/// The file's contents. `id` keeps the credentials of different data directories
|
||||
/// (and of tests) apart in the one OS store.
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct Index {
|
||||
id: String,
|
||||
keys: BTreeSet<String>,
|
||||
}
|
||||
|
||||
enum Contents {
|
||||
Index(Index),
|
||||
/// An older version's file that could not be moved, and why.
|
||||
Plaintext(BTreeMap<String, String>, String),
|
||||
}
|
||||
|
||||
fn service(id: &str) -> String {
|
||||
format!("{SERVICE}/{id}")
|
||||
}
|
||||
|
||||
fn new_id() -> String {
|
||||
uuid::Uuid::new_v4().to_string()
|
||||
lock: Mutex<()>,
|
||||
}
|
||||
|
||||
impl SecretFile {
|
||||
pub fn new(path: PathBuf) -> Self {
|
||||
Self::with_legacy(path, |v| serde_json::from_value(v).ok())
|
||||
}
|
||||
|
||||
pub fn with_legacy(path: PathBuf, legacy: Legacy) -> Self {
|
||||
Self::with_vault(path, legacy, Arc::new(DefaultStore))
|
||||
}
|
||||
|
||||
pub fn with_vault(path: PathBuf, legacy: Legacy, vault: Arc<dyn Vault>) -> Self {
|
||||
Self { path, legacy, vault }
|
||||
Self { path, lock: Mutex::new(()) }
|
||||
}
|
||||
|
||||
pub fn get(&self, key: &str) -> Option<String> {
|
||||
let _g = LOCK.lock();
|
||||
match self.load() {
|
||||
Contents::Index(mut index) if index.keys.contains(key) => match self.vault.get(&service(&index.id), key) {
|
||||
Ok(secret) => Some(secret),
|
||||
Err(StoreError::NoEntry) => {
|
||||
// Removed outside the app: stop listing it.
|
||||
index.keys.remove(key);
|
||||
self.write_logged(&index);
|
||||
None
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("saved passwords: {key}: {e}");
|
||||
None
|
||||
}
|
||||
},
|
||||
Contents::Index(_) => None,
|
||||
Contents::Plaintext(mut old, _) => old.remove(key),
|
||||
}
|
||||
let _g = self.lock.lock();
|
||||
self.read().remove(key)
|
||||
}
|
||||
|
||||
pub fn set(&self, key: &str, secret: &str) -> IdeResult<()> {
|
||||
let _g = LOCK.lock();
|
||||
let mut index = match self.load() {
|
||||
Contents::Index(index) => index,
|
||||
Contents::Plaintext(_, why) => return Err(unavailable(&why)),
|
||||
};
|
||||
let service = service(&index.id);
|
||||
self.vault.set(&service, key, secret).map_err(|e| unavailable(&e.to_string()))?;
|
||||
if index.keys.insert(key.to_owned())
|
||||
&& let Err(e) = self.write(&index)
|
||||
{
|
||||
let _ = self.vault.delete(&service, key);
|
||||
return Err(e);
|
||||
}
|
||||
Ok(())
|
||||
let _g = self.lock.lock();
|
||||
let mut all = self.read();
|
||||
all.insert(key.to_owned(), secret.to_owned());
|
||||
self.write(&all)
|
||||
}
|
||||
|
||||
pub fn delete(&self, key: &str) {
|
||||
let _g = LOCK.lock();
|
||||
match self.load() {
|
||||
Contents::Index(mut index) => {
|
||||
if !index.keys.contains(key) {
|
||||
return;
|
||||
}
|
||||
match self.vault.delete(&service(&index.id), key) {
|
||||
Ok(()) | Err(StoreError::NoEntry) => {}
|
||||
Err(e) => {
|
||||
// Kept in the index so that the next delete tries again.
|
||||
tracing::warn!("saved passwords: could not forget {key}: {e}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
index.keys.remove(key);
|
||||
self.write_logged(&index);
|
||||
}
|
||||
Contents::Plaintext(mut old, _) => {
|
||||
if old.remove(key).is_none() {
|
||||
return;
|
||||
}
|
||||
// A move halfway may have copied it already.
|
||||
if let Some(id) = self.pending_id()
|
||||
&& let Err(e) = self.vault.delete(&service(&id), key)
|
||||
&& !matches!(e, StoreError::NoEntry)
|
||||
{
|
||||
tracing::warn!("saved passwords: could not forget {key}: {e}");
|
||||
return;
|
||||
}
|
||||
self.write_logged(&old);
|
||||
}
|
||||
let _g = self.lock.lock();
|
||||
let mut all = self.read();
|
||||
if all.remove(key).is_some()
|
||||
&& let Err(e) = self.write(&all)
|
||||
{
|
||||
tracing::warn!("{}", e.message);
|
||||
}
|
||||
}
|
||||
|
||||
/// Every stored key, sorted (the secrets themselves stay private).
|
||||
pub fn keys(&self) -> Vec<String> {
|
||||
let _g = LOCK.lock();
|
||||
match self.load() {
|
||||
Contents::Index(index) => index.keys.into_iter().collect(),
|
||||
Contents::Plaintext(old, _) => old.into_keys().collect(),
|
||||
let _g = self.lock.lock();
|
||||
self.read().into_keys().collect()
|
||||
}
|
||||
|
||||
fn read(&self) -> BTreeMap<String, String> {
|
||||
match std::fs::read(&self.path) {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|e| {
|
||||
tracing::warn!("saved passwords: {}: {e}", self.path.display());
|
||||
BTreeMap::new()
|
||||
}),
|
||||
Err(_) => BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn load(&self) -> Contents {
|
||||
let value = match std::fs::read(&self.path) {
|
||||
Err(_) => return Contents::Index(Index { id: new_id(), keys: BTreeSet::new() }),
|
||||
Ok(bytes) => match serde_json::from_slice::<serde_json::Value>(&bytes) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return self.set_aside(&e.to_string()),
|
||||
},
|
||||
};
|
||||
if let Ok(index) = serde_json::from_value::<Index>(value.clone()) {
|
||||
return Contents::Index(index);
|
||||
}
|
||||
match (self.legacy)(value) {
|
||||
Some(old) => self.migrate(old),
|
||||
None => self.set_aside("unknown format"),
|
||||
}
|
||||
}
|
||||
|
||||
/// An unreadable file is renamed rather than overwritten (it may index
|
||||
/// credentials, or hold secrets); the store starts over.
|
||||
fn set_aside(&self, why: &str) -> Contents {
|
||||
let aside = self.path.with_extension(format!("json.unreadable-{}", new_id()));
|
||||
tracing::warn!("saved passwords: {}: {why}; moved to {}", self.path.display(), aside.display());
|
||||
if let Err(e) = std::fs::rename(&self.path, &aside) {
|
||||
// Not overwritten either: nothing is saved until it can be moved.
|
||||
return Contents::Plaintext(BTreeMap::new(), format!("{}: {e}", self.path.display()));
|
||||
}
|
||||
Contents::Index(Index { id: new_id(), keys: BTreeSet::new() })
|
||||
}
|
||||
|
||||
fn pending_path(&self) -> PathBuf {
|
||||
self.path.with_extension("json.migrating")
|
||||
}
|
||||
|
||||
/// The id of a move that has not finished (see [`SecretFile::migrate`]).
|
||||
fn pending_id(&self) -> Option<String> {
|
||||
let id = std::fs::read_to_string(self.pending_path()).ok()?;
|
||||
Some(id.trim().to_owned()).filter(|id| !id.is_empty())
|
||||
}
|
||||
|
||||
/// Moves an older version's secrets to the credential store, key by key; a secret
|
||||
/// the store refuses for good (too long, say) is forgotten, and the user is asked
|
||||
/// for it again. The old file is replaced only when everything else was moved.
|
||||
/// Until then the id waits in `<file>.migrating`, so a retry after a failure or a
|
||||
/// crash overwrites the same credentials instead of copying them again.
|
||||
fn migrate(&self, old: BTreeMap<String, String>) -> Contents {
|
||||
if !self.vault.available() {
|
||||
return Contents::Plaintext(old, "no credential store".into());
|
||||
}
|
||||
let pending = self.pending_path();
|
||||
let id = match self.pending_id() {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
let id = new_id();
|
||||
if let Err(e) = std::fs::write(&pending, &id) {
|
||||
return Contents::Plaintext(old, format!("{}: {e}", pending.display()));
|
||||
}
|
||||
id
|
||||
}
|
||||
};
|
||||
let mut index = Index { id, keys: BTreeSet::new() };
|
||||
for (key, secret) in &old {
|
||||
match self.vault.set(&service(&index.id), key, secret) {
|
||||
Ok(()) => {
|
||||
index.keys.insert(key.clone());
|
||||
}
|
||||
Err(e @ (StoreError::TooLong(..) | StoreError::Invalid(..))) => {
|
||||
tracing::warn!("saved passwords: {key} does not fit the credential store and is forgotten: {e}");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("saved passwords: could not move {key} to the credential store: {e}");
|
||||
return Contents::Plaintext(old, e.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Err(e) = self.write(&index) {
|
||||
return Contents::Plaintext(old, e.message);
|
||||
}
|
||||
let _ = std::fs::remove_file(&pending);
|
||||
Contents::Index(index)
|
||||
}
|
||||
|
||||
fn write_logged(&self, contents: &impl Serialize) {
|
||||
if let Err(e) = self.write(contents) {
|
||||
tracing::warn!("{}", e.message);
|
||||
}
|
||||
}
|
||||
|
||||
fn write(&self, contents: &impl Serialize) -> IdeResult<()> {
|
||||
fn write(&self, all: &BTreeMap<String, String>) -> IdeResult<()> {
|
||||
let err = |e: std::io::Error| IdeError::unavailable(format!("saved passwords: {}: {e}", self.path.display()));
|
||||
if let Some(dir) = self.path.parent() {
|
||||
std::fs::create_dir_all(dir).map_err(err)?;
|
||||
@@ -293,258 +65,18 @@ impl SecretFile {
|
||||
options.write(true).create(true).truncate(true);
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
|
||||
let bytes = serde_json::to_vec_pretty(contents).map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
let bytes = serde_json::to_vec_pretty(all).map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
std::io::Write::write_all(&mut options.open(&tmp).map_err(err)?, &bytes).map_err(err)?;
|
||||
std::fs::rename(&tmp, &self.path).map_err(err)
|
||||
}
|
||||
}
|
||||
|
||||
fn unavailable(why: &str) -> IdeError {
|
||||
IdeError::unavailable(format!("saved passwords: {why}"))
|
||||
}
|
||||
|
||||
/// Helpers for tests of crates that save secrets (feature `testing`).
|
||||
#[cfg(any(test, feature = "testing"))]
|
||||
pub mod testing {
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Makes an in-memory credential store the default, so that the test never
|
||||
/// touches the user's keychain. Call it before the first secret is saved.
|
||||
pub fn use_memory_store() {
|
||||
static ONCE: std::sync::Once = std::sync::Once::new();
|
||||
ONCE.call_once(|| keyring_core::set_default_store(keyring_core::sample::Store::new().expect("memory store")));
|
||||
}
|
||||
|
||||
/// Every file under `dir` whose bytes contain `needle`.
|
||||
pub fn files_containing(dir: &Path, needle: &str) -> Vec<PathBuf> {
|
||||
let mut found = Vec::new();
|
||||
let mut pending = vec![dir.to_path_buf()];
|
||||
while let Some(d) = pending.pop() {
|
||||
for entry in std::fs::read_dir(&d).into_iter().flatten().flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
pending.push(path);
|
||||
} else if std::fs::read(&path).is_ok_and(|b| b.windows(needle.len()).any(|w| w == needle.as_bytes())) {
|
||||
found.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::testing::{files_containing, use_memory_store};
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// A credential store that can be absent, or fail on chosen keys.
|
||||
#[derive(Default)]
|
||||
struct FakeVault {
|
||||
absent: bool,
|
||||
entries: Mutex<HashMap<(String, String), String>>,
|
||||
failing: Mutex<HashMap<String, fn() -> StoreError>>,
|
||||
}
|
||||
|
||||
impl FakeVault {
|
||||
fn fail(&self, key: &str, error: fn() -> StoreError) {
|
||||
self.failing.lock().insert(key.to_owned(), error);
|
||||
}
|
||||
|
||||
fn heal(&self) {
|
||||
self.failing.lock().clear();
|
||||
}
|
||||
|
||||
fn check(&self, key: &str) -> StoreResult<()> {
|
||||
if self.absent {
|
||||
return Err(StoreError::NoDefaultStore);
|
||||
}
|
||||
self.failing.lock().get(key).map_or(Ok(()), |e| Err(e()))
|
||||
}
|
||||
|
||||
fn secrets(&self) -> Vec<String> {
|
||||
let mut all: Vec<_> = self.entries.lock().values().cloned().collect();
|
||||
all.sort();
|
||||
all
|
||||
}
|
||||
}
|
||||
|
||||
impl Vault for FakeVault {
|
||||
fn available(&self) -> bool {
|
||||
!self.absent
|
||||
}
|
||||
fn get(&self, service: &str, key: &str) -> StoreResult<String> {
|
||||
self.check(key)?;
|
||||
self.entries.lock().get(&(service.to_owned(), key.to_owned())).cloned().ok_or(StoreError::NoEntry)
|
||||
}
|
||||
fn set(&self, service: &str, key: &str, secret: &str) -> StoreResult<()> {
|
||||
self.check(key)?;
|
||||
self.entries.lock().insert((service.to_owned(), key.to_owned()), secret.to_owned());
|
||||
Ok(())
|
||||
}
|
||||
fn delete(&self, service: &str, key: &str) -> StoreResult<()> {
|
||||
self.check(key)?;
|
||||
self.entries.lock().remove(&(service.to_owned(), key.to_owned())).map(|_| ()).ok_or(StoreError::NoEntry)
|
||||
}
|
||||
}
|
||||
|
||||
fn platform() -> StoreError {
|
||||
StoreError::PlatformFailure("locked".into())
|
||||
}
|
||||
|
||||
fn with_fake(path: &std::path::Path, vault: &Arc<FakeVault>) -> SecretFile {
|
||||
SecretFile::with_vault(path.to_path_buf(), |v| serde_json::from_value(v).ok(), vault.clone())
|
||||
}
|
||||
|
||||
const OLD: &str = r#"{"https://a.example": "me\nold-a", "https://b.example": "me\nold-b"}"#;
|
||||
|
||||
#[test]
|
||||
fn saved_secrets_never_reach_disk() {
|
||||
use_memory_store();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("ssh-passwords.json");
|
||||
SecretFile::new(path.clone()).set("a", "hunter2-ssh").unwrap();
|
||||
assert_eq!(files_containing(tmp.path(), "hunter2-ssh"), Vec::<PathBuf>::new());
|
||||
let reopened = SecretFile::new(path);
|
||||
assert_eq!(reopened.get("a").as_deref(), Some("hunter2-ssh"));
|
||||
assert_eq!(reopened.keys(), ["a"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moves_plaintext_files_of_older_versions_off_disk() {
|
||||
use_memory_store();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("git-credentials.json");
|
||||
std::fs::write(&path, r#"{"https://example.com": "me\nold-pw"}"#).unwrap();
|
||||
let file = SecretFile::new(path.clone());
|
||||
assert_eq!(file.keys(), ["https://example.com"]);
|
||||
assert_eq!(files_containing(tmp.path(), "old-pw"), Vec::<PathBuf>::new());
|
||||
assert_eq!(SecretFile::new(path).get("https://example.com").as_deref(), Some("me\nold-pw"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_move_keeps_the_old_file_and_the_retry_converges() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("git-credentials.json");
|
||||
std::fs::write(&path, OLD).unwrap();
|
||||
let vault = Arc::new(FakeVault::default());
|
||||
vault.fail("https://b.example", platform);
|
||||
|
||||
let file = with_fake(&path, &vault);
|
||||
assert_eq!(file.get("https://a.example").as_deref(), Some("me\nold-a"));
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), OLD, "the old file is untouched");
|
||||
assert!(file.set("https://c.example", "me\nnew").unwrap_err().message.contains("locked"));
|
||||
|
||||
vault.heal();
|
||||
assert_eq!(file.keys(), ["https://a.example", "https://b.example"]);
|
||||
assert_eq!(files_containing(tmp.path(), "old-a"), Vec::<PathBuf>::new());
|
||||
assert_eq!(vault.secrets(), ["me\nold-a", "me\nold-b"], "the retry overwrote, not copied");
|
||||
assert!(!path.with_extension("json.migrating").exists());
|
||||
assert_eq!(with_fake(&path, &vault).get("https://b.example").as_deref(), Some("me\nold-b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forgetting_during_a_pending_move_forgets_the_copy_too() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("git-credentials.json");
|
||||
std::fs::write(&path, OLD).unwrap();
|
||||
let vault = Arc::new(FakeVault::default());
|
||||
vault.fail("https://b.example", platform);
|
||||
|
||||
let file = with_fake(&path, &vault);
|
||||
file.delete("https://a.example");
|
||||
vault.heal();
|
||||
assert_eq!(file.keys(), ["https://b.example"]);
|
||||
assert_eq!(vault.secrets(), ["me\nold-b"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_secret_the_store_refuses_for_good_is_forgotten_and_does_not_block_saving() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("git-credentials.json");
|
||||
std::fs::write(&path, OLD).unwrap();
|
||||
let vault = Arc::new(FakeVault::default());
|
||||
vault.fail("https://b.example", || StoreError::TooLong("password".into(), 2560));
|
||||
|
||||
let file = with_fake(&path, &vault);
|
||||
assert_eq!(file.keys(), ["https://a.example"]);
|
||||
assert_eq!(files_containing(tmp.path(), "old-b"), Vec::<PathBuf>::new());
|
||||
file.set("https://c.example", "me\nnew").unwrap();
|
||||
assert_eq!(file.get("https://c.example").as_deref(), Some("me\nnew"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_credential_store_the_old_file_is_read_and_nothing_new_is_saved() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("git-credentials.json");
|
||||
std::fs::write(&path, OLD).unwrap();
|
||||
let vault = Arc::new(FakeVault { absent: true, ..Default::default() });
|
||||
|
||||
let file = with_fake(&path, &vault);
|
||||
assert_eq!(file.get("https://a.example").as_deref(), Some("me\nold-a"));
|
||||
assert!(file.set("https://c.example", "me\nnew").unwrap_err().message.contains("no credential store"));
|
||||
assert_eq!(files_containing(tmp.path(), "me\nnew"), Vec::<PathBuf>::new());
|
||||
assert!(!path.with_extension("json.migrating").exists());
|
||||
|
||||
file.delete("https://a.example");
|
||||
assert_eq!(file.keys(), ["https://b.example"]);
|
||||
assert_eq!(files_containing(tmp.path(), "old-a"), Vec::<PathBuf>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forgets_only_what_the_store_forgot() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let vault = Arc::new(FakeVault::default());
|
||||
let file = with_fake(&tmp.path().join("ssh-passwords.json"), &vault);
|
||||
file.set("a", "pw").unwrap();
|
||||
vault.fail("a", platform);
|
||||
file.delete("a");
|
||||
assert_eq!(file.keys(), ["a"], "still listed, so that forgetting can be retried");
|
||||
vault.heal();
|
||||
file.delete("a");
|
||||
assert_eq!(file.keys(), Vec::<String>::new());
|
||||
assert_eq!(vault.secrets(), Vec::<String>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_secret_removed_outside_the_app_is_no_longer_listed() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let vault = Arc::new(FakeVault::default());
|
||||
let file = with_fake(&tmp.path().join("ssh-passwords.json"), &vault);
|
||||
file.set("a", "pw").unwrap();
|
||||
vault.entries.lock().clear();
|
||||
assert_eq!(file.get("a"), None);
|
||||
assert_eq!(file.keys(), Vec::<String>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_index_write_takes_the_secret_back_out_of_the_store() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("ssh-passwords.json");
|
||||
std::fs::create_dir(&path).unwrap();
|
||||
let vault = Arc::new(FakeVault::default());
|
||||
assert!(with_fake(&path, &vault).set("a", "pw").is_err());
|
||||
assert_eq!(vault.secrets(), Vec::<String>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unreadable_file_is_set_aside_not_overwritten() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("ssh-passwords.json");
|
||||
std::fs::write(&path, "{ not json").unwrap();
|
||||
let vault = Arc::new(FakeVault::default());
|
||||
let file = with_fake(&path, &vault);
|
||||
assert_eq!(file.keys(), Vec::<String>::new());
|
||||
file.set("a", "pw").unwrap();
|
||||
std::fs::write(&path, "{ not json either").unwrap();
|
||||
assert_eq!(file.keys(), Vec::<String>::new());
|
||||
assert_eq!(files_containing(tmp.path(), "{ not json").len(), 2, "both kept, neither overwritten");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stores_and_forgets_secrets() {
|
||||
use_memory_store();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let file = SecretFile::new(tmp.path().join("ssh-passwords.json"));
|
||||
file.set("a", "pw").unwrap();
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
//! Saved passwords against the real OS credential store (Windows Credential
|
||||
//! Manager, macOS Keychain, Secret Service).
|
||||
|
||||
use ide_ssh::secrets::SecretFile;
|
||||
|
||||
/// Forgets every key of the file, also when the test panics.
|
||||
struct Forget<'a>(&'a SecretFile);
|
||||
|
||||
impl Drop for Forget<'_> {
|
||||
fn drop(&mut self) {
|
||||
for key in self.0.keys() {
|
||||
self.0.delete(&key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "uses the OS credential store"]
|
||||
fn saves_moves_and_forgets_in_the_os_store() {
|
||||
assert!(keyring::Entry::store_status().is_ok(), "no OS credential store");
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("git-credentials.json");
|
||||
std::fs::write(&path, r#"{"https://old.example": "me\nold-os-pw"}"#).unwrap();
|
||||
let file = SecretFile::new(path.clone());
|
||||
let forget = Forget(&file);
|
||||
file.set("https://new.example", "me\nnew-os-pw").unwrap();
|
||||
let on_disk = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(!on_disk.contains("old-os-pw") && !on_disk.contains("new-os-pw"), "{on_disk}");
|
||||
|
||||
let reopened = SecretFile::new(path);
|
||||
assert_eq!(reopened.get("https://old.example").as_deref(), Some("me\nold-os-pw"));
|
||||
assert_eq!(reopened.get("https://new.example").as_deref(), Some("me\nnew-os-pw"));
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let index: serde_json::Value = serde_json::from_str(&on_disk).unwrap();
|
||||
let service = format!("ai.l0ng.openidea/{}", index["id"].as_str().unwrap());
|
||||
let listed = std::process::Command::new("cmdkey").arg("/list").output().unwrap();
|
||||
let listed = String::from_utf8_lossy(&listed.stdout);
|
||||
assert_eq!(listed.matches(service.as_str()).count(), 2, "{listed}");
|
||||
}
|
||||
drop(forget);
|
||||
assert_eq!(reopened.keys(), Vec::<String>::new());
|
||||
assert_eq!(reopened.get("https://old.example"), None);
|
||||
}
|
||||
@@ -12,7 +12,6 @@ ide-core.workspace = true
|
||||
# Hence no `unicode_lines` / `cr_lines` (a test guards against feature unification).
|
||||
ropey = { version = "1.6", default-features = false, features = ["simd"] }
|
||||
serde.workspace = true
|
||||
similar = { version = "2.7", default-features = false }
|
||||
specta.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -73,10 +73,6 @@ fn bench(c: &mut Criterion) {
|
||||
BatchSize::SmallInput,
|
||||
)
|
||||
});
|
||||
let two_edits = text.replacen("value_100 ", "renamed_100 ", 1).replacen("value_9000 ", "renamed_9000 ", 1);
|
||||
c.bench_function("text/diff_two_edits_1mb", |b| b.iter(|| base.diff(black_box(&two_edits))));
|
||||
let every_line = text.replace("compute", "calculate");
|
||||
c.bench_function("text/diff_every_line_1mb", |b| b.iter(|| base.diff(black_box(&every_line))));
|
||||
c.bench_function("text/offset_to_pos_round_trip", |b| {
|
||||
let mut rng = Lcg(3);
|
||||
let len = base.len_utf16();
|
||||
|
||||
+31
-124
@@ -14,7 +14,6 @@ use ide_core::{ChangeSet, IdeError, IdeResult, Pos, Range, TextChange, TextEdit}
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::borrow::Cow;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
pub use ropey;
|
||||
|
||||
@@ -67,36 +66,6 @@ impl LineEnding {
|
||||
}
|
||||
}
|
||||
|
||||
/// How long [`Document::diff`] may look for separate regions, so a reload never stalls on a huge file.
|
||||
const DIFF_BUDGET: Duration = Duration::from_millis(50);
|
||||
|
||||
type LineRange = std::ops::Range<usize>;
|
||||
|
||||
/// Byte lengths of the common prefix and suffix; they never overlap.
|
||||
fn common_ends(a: &str, b: &str) -> (usize, usize) {
|
||||
let shared = |a: &mut dyn Iterator<Item = char>, b: &mut dyn Iterator<Item = char>| -> usize {
|
||||
a.zip(b).take_while(|(x, y)| x == y).map(|(x, _)| x.len_utf8()).sum()
|
||||
};
|
||||
let prefix = shared(&mut a.chars(), &mut b.chars());
|
||||
let suffix = shared(&mut a[prefix..].chars().rev(), &mut b[prefix..].chars().rev());
|
||||
(prefix, suffix)
|
||||
}
|
||||
|
||||
/// Byte offset of each line, then the total length.
|
||||
fn line_starts(lines: &[&str]) -> Vec<usize> {
|
||||
let mut at = 0;
|
||||
let mut starts = vec![0];
|
||||
starts.extend(lines.iter().map(|line| {
|
||||
at += line.len();
|
||||
at
|
||||
}));
|
||||
starts
|
||||
}
|
||||
|
||||
fn utf16_len(text: &str) -> u32 {
|
||||
text.chars().map(|c| c.len_utf16() as u32).sum()
|
||||
}
|
||||
|
||||
/// Converts `\r\n` and lone `\r` to `\n`. Borrows when there is nothing to do.
|
||||
pub fn normalize_line_endings(text: &str) -> Cow<'_, str> {
|
||||
if !text.contains('\r') {
|
||||
@@ -365,63 +334,42 @@ impl Document {
|
||||
cs
|
||||
}
|
||||
|
||||
/// The change set turning the current text into `text` (after line-ending
|
||||
/// normalization), one change per changed region. Empty when equal. A single change
|
||||
/// spanning two distant edits would drag the editor's caret and view to the first one.
|
||||
/// The minimal single-change (common prefix/suffix) change set turning the
|
||||
/// current text into `text` (after line-ending normalization). Empty when equal.
|
||||
pub fn diff(&self, text: &str) -> ChangeSet {
|
||||
self.diff_until(text, Instant::now() + DIFF_BUDGET)
|
||||
}
|
||||
|
||||
/// Past `deadline` the regions get coarser, never wrong.
|
||||
fn diff_until(&self, text: &str, deadline: Instant) -> ChangeSet {
|
||||
let new = normalize_line_endings(text);
|
||||
let old = self.rope.to_string();
|
||||
let (prefix, suffix) = common_ends(&old, &new);
|
||||
let old_rest = &old[prefix..old.len() - suffix];
|
||||
let new_rest = &new[prefix..new.len() - suffix];
|
||||
let old_chars = self.rope.len_chars();
|
||||
|
||||
let old_lines: Vec<&str> = old_rest.split_inclusive('\n').collect();
|
||||
let new_lines: Vec<&str> = new_rest.split_inclusive('\n').collect();
|
||||
let old_starts = line_starts(&old_lines);
|
||||
let new_starts = line_starts(&new_lines);
|
||||
|
||||
let mut changes = Vec::new();
|
||||
let mut utf16_at = utf16_len(&old[..prefix]);
|
||||
let mut old_at = 0;
|
||||
let mut region: Option<(LineRange, LineRange)> = None;
|
||||
let ops =
|
||||
similar::capture_diff_slices_deadline(similar::Algorithm::Myers, &old_lines, &new_lines, Some(deadline));
|
||||
let mut flush = |region: Option<(LineRange, LineRange)>| {
|
||||
let Some((old_range, new_range)) = region else { return };
|
||||
let region_old = &old_rest[old_starts[old_range.start]..old_starts[old_range.end]];
|
||||
let region_new = &new_rest[new_starts[new_range.start]..new_starts[new_range.end]];
|
||||
let (head, tail) = common_ends(region_old, region_new);
|
||||
let start = old_starts[old_range.start] + head;
|
||||
let end = old_starts[old_range.end] - tail;
|
||||
let from = utf16_at + utf16_len(&old_rest[old_at..start]);
|
||||
let to = from + utf16_len(&old_rest[start..end]);
|
||||
changes.push(TextChange { from, to, insert: region_new[head..region_new.len() - tail].to_owned() });
|
||||
utf16_at = to;
|
||||
old_at = end;
|
||||
};
|
||||
// Positions come from the lengths: `similar` can report a stale start for the empty
|
||||
// side of an op (an insert after an equal run still names the run's first old line).
|
||||
let (mut old_line, mut new_line) = (0, 0);
|
||||
for op in ops {
|
||||
let old_end = old_line + op.old_range().len();
|
||||
let new_end = new_line + op.new_range().len();
|
||||
if op.tag() == similar::DiffTag::Equal {
|
||||
flush(region.take());
|
||||
} else {
|
||||
region = Some(match region.take() {
|
||||
Some((o, n)) => (o.start..old_end, n.start..new_end),
|
||||
None => (old_line..old_end, new_line..new_end),
|
||||
});
|
||||
let mut prefix_chars = 0usize;
|
||||
let mut prefix_bytes = 0usize;
|
||||
for (a, b) in self.rope.chars().zip(new.chars()) {
|
||||
if a != b {
|
||||
break;
|
||||
}
|
||||
(old_line, new_line) = (old_end, new_end);
|
||||
prefix_chars += 1;
|
||||
prefix_bytes += b.len_utf8();
|
||||
}
|
||||
flush(region);
|
||||
ChangeSet { changes }
|
||||
let new_rest = &new[prefix_bytes..];
|
||||
let new_rest_chars = new_rest.chars().count();
|
||||
if prefix_chars == old_chars && new_rest_chars == 0 {
|
||||
return ChangeSet::default();
|
||||
}
|
||||
|
||||
let max_suffix = (old_chars - prefix_chars).min(new_rest_chars);
|
||||
let mut suffix_chars = 0usize;
|
||||
let mut suffix_bytes = 0usize;
|
||||
for (a, b) in self.rope.chars_at(old_chars).reversed().zip(new_rest.chars().rev()).take(max_suffix) {
|
||||
if a != b {
|
||||
break;
|
||||
}
|
||||
suffix_chars += 1;
|
||||
suffix_bytes += b.len_utf8();
|
||||
}
|
||||
|
||||
let from = self.rope.char_to_utf16_cu(prefix_chars) as u32;
|
||||
let to = self.rope.char_to_utf16_cu(old_chars - suffix_chars) as u32;
|
||||
let insert = new_rest[..new_rest.len() - suffix_bytes].to_owned();
|
||||
ChangeSet { changes: vec![TextChange { from, to, insert }] }
|
||||
}
|
||||
|
||||
// ---- coordinate conversion (UTF-16 offset ⇄ Pos ⇄ byte offset) ----
|
||||
@@ -658,45 +606,4 @@ mod tests {
|
||||
assert_eq!(set.changes, vec![tc(2, 2, "x")]);
|
||||
assert_eq!(doc.text(), "😀x😀");
|
||||
}
|
||||
|
||||
fn numbered_lines(n: usize) -> String {
|
||||
(0..n).map(|i| format!("line {i}\n")).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_keeps_distant_edits_apart() {
|
||||
let old = numbered_lines(400);
|
||||
let new = old.replace("line 20\n", "line 20x\n").replace("line 299\n", "line 299x\n");
|
||||
let at = |line: &str| (old.find(line).unwrap() + line.len() - 1) as u32;
|
||||
let set = Document::new(&old).diff(&new);
|
||||
assert_eq!(
|
||||
set.changes,
|
||||
vec![tc(at("line 20\n"), at("line 20\n"), "x"), tc(at("line 299\n"), at("line 299\n"), "x")]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_handles_added_and_removed_lines() {
|
||||
let old = "a\nb\nc\nd\ne\nf\n";
|
||||
let set = Document::new(old).diff("a\nnew\nb\nc\ne\nf\n");
|
||||
assert_eq!(set.changes, vec![tc(2, 2, "new\n"), tc(5, 7, "")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_counts_utf16_past_astral_text() {
|
||||
let old = "😀 one\nsame\n😀 two\n";
|
||||
let set = Document::new(old).diff("😀 ONE\nsame\n😀 TWO\n");
|
||||
assert_eq!(set.changes, vec![tc(3, 6, "ONE"), tc(15, 18, "TWO")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_past_its_deadline_is_coarser_but_still_correct() {
|
||||
let old = numbered_lines(5000);
|
||||
let new = old.replace("line 20\n", "line 20x\n").replace("line 4000\n", "line 4000x\n");
|
||||
let doc = Document::new(&old);
|
||||
let set = doc.diff_until(&new, std::time::Instant::now());
|
||||
let mut replayed = Document::new(&old);
|
||||
replayed.apply(0, &set).unwrap();
|
||||
assert_eq!(replayed.text(), new);
|
||||
}
|
||||
}
|
||||
@@ -5,4 +5,3 @@
|
||||
# It is recommended to check this file in to source control so that
|
||||
# everyone who runs the test benefits from these saved cases.
|
||||
cc afa0785335731ca8adc050b09312843aeb3ff05dc895963d09d9f728c553717c # shrinks to doc_text = "a", picks = [(0, 0, ""), (0, 0, ""), (658842129343018477, 1922606258417419361, ""), (0, 4297423518677873183, ""), (953525717999286983, 557348908835472853, "a"), (0, 0, "b"), (0, 1653396554766652711, "")], seed = 7072935092038531240
|
||||
cc dd6ec32be77e8ad96d0ce3ef14ad9d72d5ba2a06f227ea693bfdee62debf08b7 # shrinks to a = "中é\n\n", b = "\n\na"
|
||||
@@ -176,7 +176,7 @@ proptest! {
|
||||
let set = doc.replace_all(&b);
|
||||
prop_assert_eq!(doc.text(), b.clone());
|
||||
prop_assert_eq!(naive_apply(&a, &set), b.clone());
|
||||
prop_assert!(set.changes.windows(2).all(|w| w[0].to < w[1].from));
|
||||
prop_assert!(set.changes.len() <= 1);
|
||||
prop_assert_eq!(set.changes.is_empty(), a == b);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,12 +8,6 @@
|
||||
//!
|
||||
//! Language servers find managed tools once [`extend_process_path`] has put
|
||||
//! [`tools_bin_dirs`] on the process `PATH` at startup.
|
||||
//!
|
||||
//! GitHub API downloads prefer `GITHUB_TOKEN`, then `GH_TOKEN`, then the active
|
||||
//! `github.com` account from `gh auth token --hostname github.com`. The CLI is
|
||||
//! optional; absent credentials retain anonymous downloads. Tokens are passed
|
||||
//! to curl over stdin, never stored in tool manifests or command arguments, and
|
||||
//! are not sent to other download origins. CLI lookup is cancellable and bounded.
|
||||
|
||||
pub mod archive;
|
||||
mod bundled;
|
||||
@@ -395,11 +389,6 @@ pub fn parse_version(text: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// A Windows App Execution Alias (`%LOCALAPPDATA%\Microsoft\WindowsApps\python.exe`).
|
||||
fn is_app_execution_alias(path: &Path) -> bool {
|
||||
path.parent().and_then(Path::file_name).is_some_and(|d| d.eq_ignore_ascii_case("WindowsApps"))
|
||||
}
|
||||
|
||||
/// A tool whose "command" is a plugin jar (e.g. a jdtls bundle) rather than an executable.
|
||||
fn is_bundle(command: &str) -> bool {
|
||||
command.ends_with(".jar")
|
||||
@@ -496,32 +485,8 @@ impl Tools {
|
||||
cmd
|
||||
}
|
||||
|
||||
/// The interpreter that installs and probes Python tools. Windows installs `python.exe`
|
||||
/// and the `py` launcher, not `python3.exe`; its `WindowsApps` entries are App Execution
|
||||
/// Aliases that only open the Store unless the Store's Python is installed, so they count
|
||||
/// only when they run.
|
||||
fn python(&self) -> Option<PathBuf> {
|
||||
if self.platform.os != Os::Windows {
|
||||
return self.which_any(&["python3", "python"]);
|
||||
}
|
||||
["python", "py", "python3"]
|
||||
.into_iter()
|
||||
.flat_map(|command| {
|
||||
std::env::split_paths(&self.system_path).filter_map(move |dir| {
|
||||
if dir.as_os_str().is_empty() {
|
||||
return None;
|
||||
}
|
||||
let search = std::env::join_paths([dir]).ok()?;
|
||||
ide_core::tool_path::find_command(command, &search, &self.root)
|
||||
})
|
||||
})
|
||||
.find(|p| {
|
||||
!is_app_execution_alias(p) || {
|
||||
let mut c = self.python_command(p);
|
||||
c.arg("--version");
|
||||
run_quiet(c, Duration::from_secs(5)).is_some_and(|(ok, _)| ok)
|
||||
}
|
||||
})
|
||||
self.which_any(&["python3", "python"])
|
||||
}
|
||||
|
||||
/// Executable `cmd` inside `dir`, honouring Windows suffixes.
|
||||
|
||||
+17
-346
@@ -6,9 +6,9 @@ use ide_core::error::ErrorKind;
|
||||
use ide_core::output_encoding::decode_tool_output;
|
||||
use ide_core::process::{kill_tree, own_group};
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::io::{BufRead, BufReader, Read};
|
||||
use std::path::Path;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::mpsc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
@@ -144,99 +144,6 @@ fn strip_ansi(s: &str) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
const MAX_GITHUB_TOKEN: usize = 4096;
|
||||
|
||||
fn valid_github_token(value: String) -> Option<String> {
|
||||
let value = value.trim();
|
||||
(!value.is_empty() && value.len() <= MAX_GITHUB_TOKEN && value.bytes().all(|b| b.is_ascii_graphic()))
|
||||
.then(|| value.to_owned())
|
||||
}
|
||||
|
||||
fn environment_github_token(read: impl Fn(&str) -> Option<String>) -> Option<String> {
|
||||
["GITHUB_TOKEN", "GH_TOKEN"].into_iter().find_map(|key| read(key).and_then(valid_github_token))
|
||||
}
|
||||
|
||||
/// Ask gh for the active github.com account on each request, so switching or
|
||||
/// logging out takes effect without persisting a second copy of the credential.
|
||||
fn read_gh_token(gh: &Path, cancel: &CancellationToken, timeout: Duration) -> IdeResult<Option<String>> {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let mut cmd = Command::new(gh);
|
||||
own_group(ide_core::process::prepare(&mut cmd));
|
||||
cmd.args(["auth", "token", "--hostname", "github.com"])
|
||||
.env("GH_PROMPT_DISABLED", "1")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null());
|
||||
let Ok(mut child) = cmd.spawn() else { return Ok(None) };
|
||||
let stdout = child.stdout.take().expect("piped");
|
||||
let (tx, rx) = mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let mut bytes = Vec::new();
|
||||
let _ = stdout.take((MAX_GITHUB_TOKEN + 2) as u64).read_to_end(&mut bytes);
|
||||
let _ = tx.send(bytes);
|
||||
});
|
||||
let started = Instant::now();
|
||||
let mut status = None;
|
||||
let mut output = None;
|
||||
let bytes = loop {
|
||||
if cancel.is_cancelled() || started.elapsed() >= timeout {
|
||||
kill_tree(&mut child);
|
||||
return if cancel.is_cancelled() { Err(IdeError::cancelled()) } else { Ok(None) };
|
||||
}
|
||||
if status.is_none() {
|
||||
match child.try_wait() {
|
||||
Ok(value) => status = value,
|
||||
Err(_) => {
|
||||
kill_tree(&mut child);
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(status) = status {
|
||||
if !status.success() {
|
||||
kill_tree(&mut child);
|
||||
return Ok(None);
|
||||
}
|
||||
if let Some(bytes) = output.take() {
|
||||
break bytes;
|
||||
}
|
||||
}
|
||||
match rx.recv_timeout(Duration::from_millis(15)) {
|
||||
Ok(bytes) => output = Some(bytes),
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => {}
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => std::thread::sleep(Duration::from_millis(15)),
|
||||
}
|
||||
};
|
||||
if bytes.len() > MAX_GITHUB_TOKEN + 1 {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(String::from_utf8(bytes).ok().and_then(valid_github_token))
|
||||
}
|
||||
|
||||
fn send_auth(child: &mut Child, token: Option<&str>) -> IdeResult<()> {
|
||||
if let Some(token) = token {
|
||||
let result = child
|
||||
.stdin
|
||||
.take()
|
||||
.expect("authenticated request has piped stdin")
|
||||
.write_all(format!("Authorization: Bearer {token}\n").as_bytes());
|
||||
if let Err(error) = result {
|
||||
kill_tree(child);
|
||||
return Err(IdeError::unavailable(format!("cannot pass download credentials to curl: {error}")));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn redact_auth(message: &str, token: Option<&str>) -> String {
|
||||
match token {
|
||||
Some(token) => message.replace(token, "[redacted]"),
|
||||
None => message.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `curl` checks certificate revocation with Schannel and takes
|
||||
/// `--ssl-revoke-best-effort` (7.70+). Schannel fails the whole download with
|
||||
/// CRYPT_E_REVOCATION_OFFLINE when the CRL/OCSP servers are unreachable (corporate
|
||||
@@ -267,8 +174,6 @@ fn curl_takes_revoke_best_effort(version: &str) -> bool {
|
||||
/// mainstream Linux), so the IDE carries no TLS stack of its own.
|
||||
pub struct Http {
|
||||
curl: std::path::PathBuf,
|
||||
gh: Option<std::path::PathBuf>,
|
||||
explicit_github_token: Option<String>,
|
||||
https_only: bool,
|
||||
revoke_best_effort: bool,
|
||||
}
|
||||
@@ -277,10 +182,8 @@ impl Http {
|
||||
pub fn new(search_path: &std::ffi::OsStr) -> IdeResult<Self> {
|
||||
let curl = which::which_in("curl", Some(search_path), ".")
|
||||
.map_err(|_| IdeError::unavailable("`curl` is required to download tools"))?;
|
||||
let gh = which::which_in("gh", Some(search_path), ".").ok();
|
||||
let explicit_github_token = environment_github_token(|key| std::env::var(key).ok());
|
||||
let revoke_best_effort = cfg!(windows) && revoke_best_effort(&curl);
|
||||
Ok(Self { curl, gh, explicit_github_token, https_only: false, revoke_best_effort })
|
||||
Ok(Self { curl, https_only: false, revoke_best_effort })
|
||||
}
|
||||
|
||||
/// Refuses every protocol but HTTPS, redirects included.
|
||||
@@ -289,28 +192,8 @@ impl Http {
|
||||
self
|
||||
}
|
||||
|
||||
fn base(&self, url: &str, cancel: &CancellationToken) -> IdeResult<(Command, Option<String>)> {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let github = url.starts_with("https://api.github.com/");
|
||||
let token = if github {
|
||||
match &self.explicit_github_token {
|
||||
Some(token) => Some(token.clone()),
|
||||
None => match &self.gh {
|
||||
Some(gh) => read_gh_token(gh, cancel, Duration::from_secs(5))?,
|
||||
None => None,
|
||||
},
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
fn base(&self, url: &str) -> Command {
|
||||
let mut cmd = Command::new(&self.curl);
|
||||
// A user curlrc must not enable verbose token logging or forwarding
|
||||
// Authorization across origins on authenticated API requests.
|
||||
if token.is_some() {
|
||||
cmd.arg("-q");
|
||||
}
|
||||
own_group(ide_core::process::prepare(&mut cmd));
|
||||
cmd.args(["-fsSL", "--retry", "2", "--connect-timeout", "20", "-A", "openidea"]);
|
||||
// System Settings › HTTP Proxy (a manual proxy comes in through `prepare`'s environment).
|
||||
@@ -321,14 +204,13 @@ impl Http {
|
||||
if self.revoke_best_effort {
|
||||
cmd.arg("--ssl-revoke-best-effort");
|
||||
}
|
||||
cmd.stdin(Stdio::null());
|
||||
if token.is_some() {
|
||||
// Keep credentials out of process arguments, inherited environment,
|
||||
// and files. curl strips Authorization on cross-origin redirects.
|
||||
cmd.args(["--header", "@-", "--proto-redir", "=https"]);
|
||||
cmd.env_remove("GH_TOKEN").env_remove("GITHUB_TOKEN").stdin(Stdio::piped());
|
||||
if url.starts_with("https://api.github.com/")
|
||||
&& let Ok(token) = std::env::var("GITHUB_TOKEN").or_else(|_| std::env::var("GH_TOKEN"))
|
||||
&& !token.is_empty()
|
||||
{
|
||||
cmd.arg("-H").arg(format!("Authorization: Bearer {token}"));
|
||||
}
|
||||
Ok((cmd, token))
|
||||
cmd
|
||||
}
|
||||
|
||||
/// GETs `url` as text.
|
||||
@@ -353,16 +235,15 @@ impl Http {
|
||||
empty_on_404: bool,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<String> {
|
||||
let (mut cmd, token) = self.base(url, cancel)?;
|
||||
let mut cmd = self.base(url);
|
||||
if empty_on_404 {
|
||||
cmd.args(["--write-out", "\n%{http_code}"]);
|
||||
}
|
||||
if let Some(max) = max_bytes {
|
||||
cmd.arg("--max-filesize").arg(max.to_string());
|
||||
}
|
||||
cmd.arg(url).stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
cmd.arg(url).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
let mut child = cmd.spawn().map_err(|e| IdeError::unavailable(format!("cannot run curl: {e}")))?;
|
||||
send_auth(&mut child, token.as_deref())?;
|
||||
let stdout = child.stdout.take().expect("piped");
|
||||
let mut stderr = child.stderr.take().expect("piped");
|
||||
// Past the limit the pipe closes, which stops curl.
|
||||
@@ -399,10 +280,7 @@ impl Http {
|
||||
let mut err = Vec::new();
|
||||
let _ = stderr.read_to_end(&mut err);
|
||||
let err = decode_tool_output(&err);
|
||||
return Err(IdeError::new(
|
||||
ErrorKind::Io,
|
||||
format!("GET {url} failed: {}", redact_auth(err.trim(), token.as_deref())),
|
||||
));
|
||||
return Err(IdeError::new(ErrorKind::Io, format!("GET {url} failed: {}", err.trim())));
|
||||
}
|
||||
String::from_utf8(body).map_err(|_| IdeError::new(ErrorKind::Protocol, format!("{url}: response is not UTF-8")))
|
||||
}
|
||||
@@ -416,10 +294,9 @@ impl Http {
|
||||
cancel: &CancellationToken,
|
||||
emit: Emit,
|
||||
) -> IdeResult<()> {
|
||||
let (mut cmd, token) = self.base(url, cancel)?;
|
||||
cmd.arg("-o").arg(dest).arg(url).stdout(Stdio::null()).stderr(Stdio::piped());
|
||||
let mut cmd = self.base(url);
|
||||
cmd.arg("-o").arg(dest).arg(url).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::piped());
|
||||
let mut child = cmd.spawn().map_err(|e| IdeError::unavailable(format!("cannot run curl: {e}")))?;
|
||||
send_auth(&mut child, token.as_deref())?;
|
||||
let mut stderr = child.stderr.take().expect("piped");
|
||||
let err_t = std::thread::spawn(move || {
|
||||
let mut s = Vec::new();
|
||||
@@ -450,10 +327,7 @@ impl Http {
|
||||
if !status.success() {
|
||||
return Err(IdeError::new(
|
||||
ErrorKind::Io,
|
||||
format!(
|
||||
"download of {url} failed: {}",
|
||||
redact_auth(err_t.join().unwrap_or_default().trim(), token.as_deref())
|
||||
),
|
||||
format!("download of {url} failed: {}", err_t.join().unwrap_or_default().trim()),
|
||||
));
|
||||
}
|
||||
let bytes = std::fs::metadata(dest).map(|m| m.len()).unwrap_or(0);
|
||||
@@ -616,13 +490,7 @@ mod tests {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let curl = tmp.path().join("curl");
|
||||
let http = Http {
|
||||
curl: curl.clone(),
|
||||
gh: None,
|
||||
explicit_github_token: None,
|
||||
https_only: false,
|
||||
revoke_best_effort: false,
|
||||
};
|
||||
let http = Http { curl: curl.clone(), https_only: false, revoke_best_effort: false };
|
||||
for (code, exit, expected) in [(404, 22, Some("[]")), (200, 0, Some("payload")), (403, 22, None), (0, 7, None)]
|
||||
{
|
||||
std::fs::write(&curl, format!("#!/bin/sh\nprintf 'payload\\n{code:03}'\nexit {exit}\n")).unwrap();
|
||||
@@ -727,200 +595,3 @@ mod input_tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod github_auth_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn explicit_tokens_keep_precedence_and_reject_header_injection() {
|
||||
let read = |key: &str| Some(if key == "GITHUB_TOKEN" { "first" } else { "second" }.to_owned());
|
||||
assert_eq!(environment_github_token(read).as_deref(), Some("first"));
|
||||
assert_eq!(
|
||||
environment_github_token(|key| Some(if key == "GITHUB_TOKEN" { "" } else { "second" }.into())).as_deref(),
|
||||
Some("second")
|
||||
);
|
||||
for bad in ["", "two words", "token\r\nX-Injected: yes", "token\0"] {
|
||||
assert!(valid_github_token(bad.into()).is_none());
|
||||
}
|
||||
assert!(valid_github_token("a".repeat(MAX_GITHUB_TOKEN + 1)).is_none());
|
||||
assert_eq!(valid_github_token("gho_example\n".into()).as_deref(), Some("gho_example"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
mod unix {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn quote(path: &Path) -> String {
|
||||
format!("'{}'", path.display().to_string().replace('\'', "'\\''"))
|
||||
}
|
||||
struct Fixture {
|
||||
dir: tempfile::TempDir,
|
||||
http: Http,
|
||||
}
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let curl = dir.path().join("curl");
|
||||
let gh = dir.path().join("gh");
|
||||
let fixture = Self {
|
||||
dir,
|
||||
http: Http {
|
||||
curl,
|
||||
gh: Some(gh),
|
||||
explicit_github_token: None,
|
||||
https_only: false,
|
||||
revoke_best_effort: false,
|
||||
},
|
||||
};
|
||||
fixture.script(
|
||||
"gh",
|
||||
&format!(
|
||||
"printf '%s\\n' \"$@\" >> {}\n/bin/cat {}",
|
||||
quote(&fixture.path("gh-args")),
|
||||
quote(&fixture.path("token"))
|
||||
),
|
||||
);
|
||||
fs::write(fixture.path("token"), "gho_fake_first\n").unwrap();
|
||||
fixture.curl("printf '{\"ok\":true}'");
|
||||
fixture
|
||||
}
|
||||
fn path(&self, name: &str) -> PathBuf {
|
||||
self.dir.path().join(name)
|
||||
}
|
||||
fn script(&self, name: &str, body: &str) {
|
||||
let path = self.path(name);
|
||||
fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap();
|
||||
fs::set_permissions(path, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
}
|
||||
fn curl(&self, body: &str) {
|
||||
self.script(
|
||||
"curl",
|
||||
&format!(
|
||||
"printf '%s\\n' \"$@\" > {}\n/bin/cat > {}\n{body}",
|
||||
quote(&self.path("curl-args")),
|
||||
quote(&self.path("headers"))
|
||||
),
|
||||
);
|
||||
}
|
||||
fn get(&self, url: &str) -> IdeResult<String> {
|
||||
self.http.get_text(url, &CancellationToken::new())
|
||||
}
|
||||
}
|
||||
const API: &str = "https://api.github.com/repos/example/project/releases/latest";
|
||||
|
||||
#[test]
|
||||
fn active_gh_account_is_read_per_request_and_token_is_not_an_argument() {
|
||||
let f = Fixture::new();
|
||||
assert_eq!(f.get(API).unwrap(), "{\"ok\":true}");
|
||||
assert_eq!(fs::read_to_string(f.path("headers")).unwrap(), "Authorization: Bearer gho_fake_first\n");
|
||||
let args = fs::read_to_string(f.path("curl-args")).unwrap();
|
||||
assert!(!args.contains("gho_fake"));
|
||||
assert!(args.starts_with("-q\n"));
|
||||
assert!(args.contains("--header\n@-\n--proto-redir\n=https\n"));
|
||||
assert!(!args.contains("--location-trusted"));
|
||||
assert_eq!(fs::read_to_string(f.path("gh-args")).unwrap(), "auth\ntoken\n--hostname\ngithub.com\n");
|
||||
fs::write(f.path("token"), "gho_fake_second\n").unwrap();
|
||||
f.get(API).unwrap();
|
||||
assert_eq!(fs::read_to_string(f.path("headers")).unwrap(), "Authorization: Bearer gho_fake_second\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_token_avoids_gh_and_non_github_urls_receive_no_credentials() {
|
||||
let mut f = Fixture::new();
|
||||
f.http.explicit_github_token = Some("explicit_fake".into());
|
||||
f.get(API).unwrap();
|
||||
assert!(!f.path("gh-args").exists());
|
||||
assert_eq!(fs::read_to_string(f.path("headers")).unwrap(), "Authorization: Bearer explicit_fake\n");
|
||||
f.http.explicit_github_token = None;
|
||||
for url in [
|
||||
"https://example.com/file",
|
||||
"http://api.github.com/repos/x",
|
||||
"https://api.github.com.evil.example/file",
|
||||
"https://[email protected]/file",
|
||||
"https://github.com/example/project/releases/download/v1/file",
|
||||
] {
|
||||
f.get(url).unwrap();
|
||||
assert_eq!(fs::read_to_string(f.path("headers")).unwrap(), "");
|
||||
assert!(!f.path("gh-args").exists());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_logged_out_or_malformed_gh_falls_back_to_anonymous() {
|
||||
let mut f = Fixture::new();
|
||||
for body in ["exit 1", "printf 'fake\\nX-Injected: yes\\n'", "printf ''", "printf 'fake'; exit 1"] {
|
||||
f.script("gh", body);
|
||||
f.get(API).unwrap();
|
||||
assert_eq!(fs::read_to_string(f.path("headers")).unwrap(), "");
|
||||
}
|
||||
f.http.gh = None;
|
||||
f.get(API).unwrap();
|
||||
assert_eq!(fs::read_to_string(f.path("headers")).unwrap(), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn download_auth_and_errors_do_not_expose_the_token() {
|
||||
let f = Fixture::new();
|
||||
let dest = f.path("download");
|
||||
f.curl(&format!("printf downloaded > {}", quote(&dest)));
|
||||
f.http.download(API, &dest, None, &CancellationToken::new(), &|_| {}).unwrap();
|
||||
assert_eq!(fs::read_to_string(&dest).unwrap(), "downloaded");
|
||||
assert!(fs::read_to_string(f.path("headers")).unwrap().contains("gho_fake_first"));
|
||||
f.curl(&format!("/bin/cat {} >&2; exit 22", quote(&f.path("headers"))));
|
||||
let error = f.get(API).unwrap_err().to_string();
|
||||
assert!(error.contains("[redacted]"));
|
||||
assert!(!error.contains("gho_fake_first"));
|
||||
let error = f.http.download(API, &dest, None, &CancellationToken::new(), &|_| {}).unwrap_err().to_string();
|
||||
assert!(!error.contains("gho_fake_first"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credential_lookup_is_bounded_and_cancellable_even_with_inherited_stdout() {
|
||||
let f = Fixture::new();
|
||||
// The parent exits but a child keeps stdout open: joining a reader
|
||||
// without checking the deadline would hang here.
|
||||
f.script("gh", "/bin/sleep 5 &\nexit 0");
|
||||
let started = Instant::now();
|
||||
assert!(
|
||||
read_gh_token(f.http.gh.as_ref().unwrap(), &CancellationToken::new(), Duration::from_millis(60))
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert!(started.elapsed() < Duration::from_secs(3));
|
||||
f.script("gh", "/bin/sleep 5");
|
||||
let cancel = CancellationToken::new();
|
||||
let trigger = cancel.clone();
|
||||
let thread = std::thread::spawn(move || {
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
trigger.cancel();
|
||||
});
|
||||
assert_eq!(f.http.get_text(API, &cancel).unwrap_err().kind, ErrorKind::Cancelled);
|
||||
thread.join().unwrap();
|
||||
assert!(!f.path("curl-args").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires an active gh github.com login and network access"]
|
||||
fn live_github_release_uses_authenticated_quota() {
|
||||
let http = Http::new(&std::env::var_os("PATH").unwrap()).unwrap();
|
||||
assert!(http.gh.is_some());
|
||||
let cancel = CancellationToken::new();
|
||||
let body = http.get_text_limited("https://api.github.com/rate_limit", Some(64 * 1024), &cancel).unwrap();
|
||||
let response: serde_json::Value = serde_json::from_str(&body).unwrap();
|
||||
assert!(response["resources"]["core"]["limit"].as_u64().unwrap() > 60);
|
||||
let body = http
|
||||
.get_text_limited(
|
||||
"https://api.github.com/repos/redhat-developer/vscode-xml/releases/latest",
|
||||
Some(1024 * 1024),
|
||||
&cancel,
|
||||
)
|
||||
.unwrap();
|
||||
let response: serde_json::Value = serde_json::from_str(&body).unwrap();
|
||||
assert!(response["tag_name"].is_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,12 +37,6 @@ impl Env {
|
||||
fs::write(&p, format!("#!/bin/sh\n{body}\n")).unwrap();
|
||||
archive::make_executable(&p).unwrap();
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn script(&self, dir: &Path, name: &str, body: &str) {
|
||||
fs::create_dir_all(dir).unwrap();
|
||||
fs::write(dir.join(format!("{name}.cmd")), format!("@echo off\r\n{body}\r\n")).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn find<'a>(list: &'a [ToolInfo], name: &str) -> &'a ToolInfo {
|
||||
@@ -706,34 +700,6 @@ fn rust_analyzer_managed_copy_survives_rustup_becoming_available() {
|
||||
assert!(!env.root.join("rust-analyzer").exists());
|
||||
}
|
||||
|
||||
/// Windows has `python.exe` and `py`, not `python3.exe`; the `WindowsApps` aliases only count
|
||||
/// when the Store's Python behind them runs.
|
||||
#[test]
|
||||
fn windows_python_skips_store_aliases_that_do_not_run() {
|
||||
let env = Env::new();
|
||||
let aliases = env.sys.parent().unwrap().join("Microsoft").join("WindowsApps");
|
||||
env.script(&aliases, "python", "exit 9009");
|
||||
env.script(&aliases, "python3", "exit 9009");
|
||||
env.script(&env.sys, "py", "echo Python 3.12.0");
|
||||
let windows = Platform { os: Os::Windows, arch: platform::Arch::X64 };
|
||||
let found = || {
|
||||
let dirs = std::env::join_paths([aliases.clone(), env.sys.clone()]).unwrap();
|
||||
Tools::with_paths(env.root.clone(), dirs, windows).python().map(|python| {
|
||||
let dir = python.parent().unwrap().file_name().unwrap().to_string_lossy().into_owned();
|
||||
format!("{dir}/{}", python.file_stem().unwrap().to_string_lossy())
|
||||
})
|
||||
};
|
||||
assert_eq!(found().as_deref(), Some("sysbin/py"));
|
||||
fs::remove_file(env.sys.join(if cfg!(windows) { "py.cmd" } else { "py" })).unwrap();
|
||||
assert_eq!(found(), None, "only broken aliases are available");
|
||||
env.script(&env.sys, "python3", "echo Python 3.12.0");
|
||||
assert_eq!(found().as_deref(), Some("sysbin/python3"), "python3 is the final fallback");
|
||||
env.script(&env.sys, "python", "echo Python 3.12.0");
|
||||
assert_eq!(found().as_deref(), Some("sysbin/python"), "a broken alias must not hide a later interpreter");
|
||||
env.script(&aliases, "python", "echo Python 3.13.0");
|
||||
assert_eq!(found().as_deref(), Some("WindowsApps/python"), "the Store's Python runs");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn python_probes_run_in_an_empty_private_directory() {
|
||||
|
||||
@@ -13,7 +13,7 @@ serde.workspace = true
|
||||
specta.workspace = true
|
||||
ignore.workspace = true
|
||||
globset.workspace = true
|
||||
notify.workspace = true
|
||||
notify-debouncer-full.workspace = true
|
||||
encoding_rs.workspace = true
|
||||
chardetng.workspace = true
|
||||
trash.workspace = true
|
||||
|
||||
+92
-163
@@ -12,8 +12,9 @@
|
||||
use crate::FsBatch;
|
||||
use crate::rules::{ExcludeMatcher, GitignoreStack};
|
||||
use ide_core::{IdeError, IdeEvent, IdeResult, SharedSink};
|
||||
use notify::event::{ModifyKind, RenameMode};
|
||||
use notify::{Event, EventKind, PollWatcher, RecommendedWatcher, RecursiveMode, WatcherKind};
|
||||
use notify_debouncer_full::notify::event::{ModifyKind, RenameMode};
|
||||
use notify_debouncer_full::notify::{self, EventKind, PollWatcher, RecommendedWatcher, RecursiveMode, WatcherKind};
|
||||
use notify_debouncer_full::{DebounceEventResult, DebouncedEvent, Debouncer, NoCache, new_debouncer_opt};
|
||||
use parking_lot::{Condvar, Mutex, RwLock};
|
||||
use std::collections::{BTreeMap, BTreeSet, HashSet};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
@@ -61,9 +62,10 @@ pub struct Watcher {
|
||||
|
||||
type SharedExcludes = Arc<RwLock<ExcludeMatcher>>;
|
||||
|
||||
struct Inner {
|
||||
state: Arc<DirState>,
|
||||
thread: Option<JoinHandle<()>>,
|
||||
enum Inner {
|
||||
Recursive(#[allow(dead_code)] Debouncer<RecommendedWatcher, NoCache>),
|
||||
Polling(#[allow(dead_code)] Debouncer<PollWatcher, NoCache>),
|
||||
PerDirectory { state: Arc<DirState>, thread: Option<JoinHandle<()>> },
|
||||
}
|
||||
|
||||
impl Watcher {
|
||||
@@ -129,32 +131,80 @@ impl Watcher {
|
||||
|
||||
/// Number of OS-level watches (1 in recursive and flat mode).
|
||||
pub fn watch_count(&self) -> usize {
|
||||
self.inner.state.watched.lock().len()
|
||||
match &self.inner {
|
||||
Inner::Recursive(_) | Inner::Polling(_) => 1,
|
||||
Inner::PerDirectory { state, .. } => state.watched.lock().len(),
|
||||
}
|
||||
}
|
||||
|
||||
fn start_native(root: &Path, filter: EventFilter, emit: Emit, mode: WatchMode) -> IdeResult<Inner> {
|
||||
Self::start_queued::<RecommendedWatcher>(root, filter, emit, mode, notify::Config::default())
|
||||
let config = notify::Config::default();
|
||||
match mode {
|
||||
WatchMode::Recursive => {
|
||||
Self::start_debounced(root, filter, emit, RecursiveMode::Recursive, config).map(Inner::Recursive)
|
||||
}
|
||||
WatchMode::Flat => {
|
||||
Self::start_debounced(root, filter, emit, RecursiveMode::NonRecursive, config).map(Inner::Recursive)
|
||||
}
|
||||
WatchMode::PerDirectory => Self::start_per_directory::<RecommendedWatcher>(root, filter, emit, config),
|
||||
}
|
||||
}
|
||||
|
||||
/// Polls per directory unless flat: a recursive poll would rescan
|
||||
/// `node_modules` and `target` every round.
|
||||
fn start_polling(root: &Path, filter: EventFilter, emit: Emit, mode: WatchMode) -> IdeResult<Inner> {
|
||||
let config = notify::Config::default().with_poll_interval(POLL_INTERVAL);
|
||||
let mode = if mode == WatchMode::Flat { mode } else { WatchMode::PerDirectory };
|
||||
Self::start_queued::<PollWatcher>(root, filter, emit, mode, config)
|
||||
match mode {
|
||||
WatchMode::Flat => {
|
||||
Self::start_debounced(root, filter, emit, RecursiveMode::NonRecursive, config).map(Inner::Polling)
|
||||
}
|
||||
WatchMode::Recursive | WatchMode::PerDirectory => {
|
||||
Self::start_per_directory::<PollWatcher>(root, filter, emit, config)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn start_queued<W: notify::Watcher + Send + 'static>(
|
||||
fn start_debounced<W: notify::Watcher>(
|
||||
root: &Path,
|
||||
filter: EventFilter,
|
||||
emit: Emit,
|
||||
recursive: RecursiveMode,
|
||||
config: notify::Config,
|
||||
) -> IdeResult<Debouncer<W, NoCache>> {
|
||||
let polled = W::kind() == WatcherKind::PollWatcher;
|
||||
// No file-id cache: building it walks and stats the whole tree before `watch` returns,
|
||||
// which made opening large projects block for seconds. Renames arrive as remove + create.
|
||||
let handler = move |result: DebounceEventResult| match result {
|
||||
Ok(mut events) => {
|
||||
if polled {
|
||||
events.retain(|e| !is_dir_touch(&e.event));
|
||||
}
|
||||
let (batch, _) = filter.batch_detailed(&events);
|
||||
if !batch.is_empty() {
|
||||
emit(&batch);
|
||||
}
|
||||
}
|
||||
Err(errors) => {
|
||||
for e in errors {
|
||||
tracing::warn!("file watcher: {e}");
|
||||
}
|
||||
}
|
||||
};
|
||||
let mut debouncer = new_debouncer_opt::<_, W, _>(DEBOUNCE, None, handler, NoCache, config)
|
||||
.map_err(|e| io_error(format!("cannot start file watcher: {e}")))?;
|
||||
debouncer.watch(root, recursive).map_err(|e| io_error(format!("cannot watch {}: {e}", root.display())))?;
|
||||
Ok(debouncer)
|
||||
}
|
||||
|
||||
fn start_per_directory<W: notify::Watcher + Send + 'static>(
|
||||
root: &Path,
|
||||
filter: EventFilter,
|
||||
emit: Emit,
|
||||
mode: WatchMode,
|
||||
config: notify::Config,
|
||||
) -> IdeResult<Inner> {
|
||||
let polled = W::kind() == WatcherKind::PollWatcher;
|
||||
let state = Arc::new(DirState {
|
||||
filter,
|
||||
mode,
|
||||
watcher: Mutex::new(None),
|
||||
watched: Mutex::default(),
|
||||
queue: Mutex::default(),
|
||||
@@ -178,11 +228,8 @@ impl Watcher {
|
||||
)
|
||||
.map_err(|e| io_error(format!("cannot start file watcher: {e}")))?;
|
||||
*state.watcher.lock() = Some(Box::new(watcher));
|
||||
if mode == WatchMode::PerDirectory {
|
||||
state.watch_tree(root, false, &mut Vec::new());
|
||||
} else {
|
||||
state.add_watch(root);
|
||||
}
|
||||
let mut created = Vec::new();
|
||||
state.watch_tree(root, false, &mut created);
|
||||
if state.watched.lock().is_empty() {
|
||||
return Err(io_error(format!("cannot watch {}", root.display())));
|
||||
}
|
||||
@@ -193,22 +240,20 @@ impl Watcher {
|
||||
.spawn(move || state.run(&*emit))
|
||||
.map_err(|e| io_error(format!("cannot start file watcher: {e}")))?
|
||||
};
|
||||
Ok(Inner { state, thread: Some(thread) })
|
||||
Ok(Inner::PerDirectory { state, thread: Some(thread) })
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Inner {
|
||||
impl Drop for Watcher {
|
||||
fn drop(&mut self) {
|
||||
let state = &self.state;
|
||||
{
|
||||
let _queue = state.queue.lock();
|
||||
if let Inner::PerDirectory { state, thread } = &mut self.inner {
|
||||
state.stop.store(true, Ordering::SeqCst);
|
||||
state.wake.notify_all();
|
||||
}
|
||||
let watcher = state.watcher.lock().take();
|
||||
drop(watcher);
|
||||
if let Some(t) = self.thread.take() {
|
||||
let _ = t.join();
|
||||
let watcher = state.watcher.lock().take();
|
||||
drop(watcher);
|
||||
if let Some(t) = thread.take() {
|
||||
let _ = t.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -223,10 +268,10 @@ fn is_dir_touch(event: ¬ify::Event) -> bool {
|
||||
matches!(event.kind, EventKind::Modify(_)) && event.paths.iter().all(|p| p.is_dir())
|
||||
}
|
||||
|
||||
/// Shared with the notify callback (weakly) and the debounce thread.
|
||||
/// State of the per-directory strategy, shared with the notify callback
|
||||
/// (weakly) and the debounce thread.
|
||||
struct DirState {
|
||||
filter: EventFilter,
|
||||
mode: WatchMode,
|
||||
watcher: Mutex<Option<Box<dyn notify::Watcher + Send>>>,
|
||||
watched: Mutex<HashSet<PathBuf>>,
|
||||
queue: Mutex<Vec<(notify::Event, Instant)>>,
|
||||
@@ -265,13 +310,9 @@ impl DirState {
|
||||
}
|
||||
std::mem::take(&mut *q)
|
||||
};
|
||||
// A scan may publish a path before its create event is delivered:
|
||||
// keep create + remove pairs so the consumer still sees the removal.
|
||||
let events: Vec<Event> = events.into_iter().map(|(e, _)| e).collect();
|
||||
let events: Vec<DebouncedEvent> = events.into_iter().map(|(e, t)| DebouncedEvent::new(e, t)).collect();
|
||||
let (mut batch, rule_dirs) = self.filter.batch_detailed(&events);
|
||||
if self.mode == WatchMode::PerDirectory {
|
||||
self.update_watches(&mut batch, &rule_dirs);
|
||||
}
|
||||
self.update_watches(&mut batch, &rule_dirs);
|
||||
if !batch.is_empty() && !self.stopped() {
|
||||
emit(&batch);
|
||||
}
|
||||
@@ -322,9 +363,7 @@ impl DirState {
|
||||
}
|
||||
let mut guard = self.watcher.lock();
|
||||
let Some(w) = guard.as_mut() else { return false };
|
||||
let recursive =
|
||||
if self.mode == WatchMode::Recursive { RecursiveMode::Recursive } else { RecursiveMode::NonRecursive };
|
||||
match w.watch(dir, recursive) {
|
||||
match w.watch(dir, RecursiveMode::NonRecursive) {
|
||||
Ok(()) => {
|
||||
self.watched.lock().insert(dir.to_path_buf());
|
||||
true
|
||||
@@ -516,17 +555,17 @@ impl EventFilter {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn batch(&self, events: &[Event]) -> FsBatch {
|
||||
pub fn batch(&self, events: &[DebouncedEvent]) -> FsBatch {
|
||||
self.batch_detailed(events).0
|
||||
}
|
||||
|
||||
/// The coalesced, filtered batch plus the directories whose ignore rules
|
||||
/// changed (their cached matchers have been rebuilt already).
|
||||
pub fn batch_detailed(&self, events: &[Event]) -> (FsBatch, Vec<PathBuf>) {
|
||||
pub fn batch_detailed(&self, events: &[DebouncedEvent]) -> (FsBatch, Vec<PathBuf>) {
|
||||
let mut changes: BTreeMap<PathBuf, Change> = BTreeMap::new();
|
||||
for ev in events {
|
||||
let paths = &ev.paths;
|
||||
match ev.kind {
|
||||
let paths = &ev.event.paths;
|
||||
match ev.event.kind {
|
||||
EventKind::Access(_) => {}
|
||||
EventKind::Create(_) => paths.iter().for_each(|p| self.classify(p, Change::Created, &mut changes)),
|
||||
EventKind::Remove(_) => paths.iter().for_each(|p| self.classify(p, Change::Removed, &mut changes)),
|
||||
@@ -562,7 +601,7 @@ impl EventFilter {
|
||||
rule_dirs.insert(dir.to_path_buf());
|
||||
}
|
||||
}
|
||||
if events.iter().flat_map(|e| &e.paths).any(|p| p.ends_with(".git/info/exclude")) {
|
||||
if events.iter().flat_map(|e| &e.event.paths).any(|p| p.ends_with(".git/info/exclude")) {
|
||||
self.gitignore.reload_repo_level();
|
||||
rule_dirs.insert(self.root.clone());
|
||||
}
|
||||
@@ -630,125 +669,11 @@ impl EventFilter {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use notify::event::{CreateKind, DataChange, RemoveKind};
|
||||
use notify_debouncer_full::notify::Event;
|
||||
use notify_debouncer_full::notify::event::{CreateKind, DataChange, RemoveKind};
|
||||
|
||||
fn ev(kind: EventKind, paths: Vec<PathBuf>) -> Event {
|
||||
Event { kind, paths, attrs: Default::default() }
|
||||
}
|
||||
|
||||
const TRANSIENT_PATHS: &[&str] = &["fresh.txt", "src/fresh.rs", "src/tasks/fresh.py"];
|
||||
|
||||
/// Delivers a whole debounce window without depending on OS scheduling.
|
||||
struct TransientWatcher(Box<dyn notify::EventHandler>);
|
||||
|
||||
impl notify::Watcher for TransientWatcher {
|
||||
fn new<F: notify::EventHandler>(handler: F, _: notify::Config) -> notify::Result<Self> {
|
||||
Ok(Self(Box::new(handler)))
|
||||
}
|
||||
|
||||
fn watch(&mut self, root: &Path, _: RecursiveMode) -> notify::Result<()> {
|
||||
for rel in TRANSIENT_PATHS {
|
||||
for kind in [
|
||||
EventKind::Create(CreateKind::File),
|
||||
EventKind::Modify(ModifyKind::Data(DataChange::Content)),
|
||||
EventKind::Remove(RemoveKind::File),
|
||||
] {
|
||||
self.0.handle_event(Ok(Event::new(kind).add_path(root.join(rel))));
|
||||
}
|
||||
}
|
||||
for kind in [EventKind::Create(CreateKind::Folder), EventKind::Remove(RemoveKind::Folder)] {
|
||||
self.0.handle_event(Ok(Event::new(kind).add_path(root.join("new"))));
|
||||
}
|
||||
for (rel, kind) in [
|
||||
("from.py", EventKind::Create(CreateKind::File)),
|
||||
("from.py", EventKind::Modify(ModifyKind::Name(RenameMode::From))),
|
||||
("to.py", EventKind::Modify(ModifyKind::Name(RenameMode::To))),
|
||||
("to.py", EventKind::Remove(RemoveKind::File)),
|
||||
] {
|
||||
self.0.handle_event(Ok(Event::new(kind).add_path(root.join(rel))));
|
||||
}
|
||||
for kind in [EventKind::Remove(RemoveKind::File), EventKind::Create(CreateKind::File)] {
|
||||
self.0.handle_event(Ok(Event::new(kind).add_path(root.join("kept.py"))));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn unwatch(&mut self, _: &Path) -> notify::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn kind() -> WatcherKind {
|
||||
WatcherKind::NullWatcher
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debounce_preserves_transient_removals_and_atomic_replacements() {
|
||||
for mode in [WatchMode::Recursive, WatchMode::Flat, WatchMode::PerDirectory] {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
std::fs::write(root.join("kept.py"), "class Kept: pass\n").unwrap();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let emit: Emit = Arc::new(move |b| tx.send(b.clone()).unwrap());
|
||||
let _watcher = Watcher::start_queued::<TransientWatcher>(
|
||||
&root,
|
||||
EventFilter::new(root.clone(), &[]),
|
||||
emit,
|
||||
mode,
|
||||
notify::Config::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let batch = rx.recv_timeout(Duration::from_secs(5)).unwrap();
|
||||
let removed: BTreeSet<_> = batch.removed.into_iter().collect();
|
||||
let expected =
|
||||
TRANSIENT_PATHS.iter().copied().chain(["new", "from.py", "to.py"]).map(|rel| root.join(rel)).collect();
|
||||
assert_eq!(removed, expected, "{mode:?}: scans may have already published transient paths");
|
||||
assert_eq!(batch.modified, vec![root.join("kept.py")]);
|
||||
assert!(batch.created.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn native_watch_reports_quickly_removed_files() {
|
||||
for mode in [WatchMode::Recursive, WatchMode::Flat, WatchMode::PerDirectory] {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
std::fs::create_dir_all(root.join("src/tasks")).unwrap();
|
||||
let batches: Arc<Mutex<Vec<FsBatch>>> = Arc::default();
|
||||
let watcher = {
|
||||
let batches = batches.clone();
|
||||
Watcher::start_with(
|
||||
&root,
|
||||
Vec::new(),
|
||||
Arc::new(ide_core::NullSink),
|
||||
Box::new(move |b| batches.lock().push(b.clone())),
|
||||
mode,
|
||||
)
|
||||
.unwrap()
|
||||
};
|
||||
assert!(watcher.polling().is_none());
|
||||
assert_eq!(watcher.watch_count(), if mode == WatchMode::PerDirectory { 3 } else { 1 });
|
||||
let paths: Vec<_> = TRANSIENT_PATHS
|
||||
.iter()
|
||||
.filter(|rel| mode != WatchMode::Flat || !rel.contains('/'))
|
||||
.map(|rel| root.join(rel))
|
||||
.collect();
|
||||
for path in &paths {
|
||||
std::fs::write(path, "class FreshWidget:\n pass\n").unwrap();
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
assert!(
|
||||
wait_for(&batches, |b| paths.iter().all(|p| b.iter().any(|batch| batch.removed.contains(p)))),
|
||||
"{mode:?}: {:?}",
|
||||
batches.lock()
|
||||
);
|
||||
drop(watcher);
|
||||
let count = batches.lock().len();
|
||||
std::fs::write(root.join("after-drop.txt"), "x").unwrap();
|
||||
std::thread::sleep(DEBOUNCE * 2);
|
||||
assert_eq!(batches.lock().len(), count, "no callback after drop in {mode:?}");
|
||||
}
|
||||
fn ev(kind: EventKind, paths: Vec<PathBuf>) -> DebouncedEvent {
|
||||
DebouncedEvent::new(Event { kind, paths, attrs: Default::default() }, Instant::now())
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -897,7 +822,8 @@ mod tests {
|
||||
}
|
||||
let filter = EventFilter::new(root.clone(), &[]);
|
||||
let modify = EventKind::Modify(ModifyKind::Data(DataChange::Content));
|
||||
let touch = |paths: &[&str]| -> Vec<Event> { paths.iter().map(|p| ev(modify, vec![root.join(p)])).collect() };
|
||||
let touch =
|
||||
|paths: &[&str]| -> Vec<DebouncedEvent> { paths.iter().map(|p| ev(modify, vec![root.join(p)])).collect() };
|
||||
let (batch, rules) = filter.batch_detailed(&touch(&["pkg/a.tmp", "pkg/gen/x.rs", "pkg/keep.rs", "b.tmp"]));
|
||||
assert_eq!(batch.modified, vec![root.join("b.tmp"), root.join("pkg/keep.rs")]);
|
||||
assert_eq!(batch.ignored, vec![root.join("pkg/a.tmp")], "nested rules, but not inside ignored folders");
|
||||
@@ -947,7 +873,10 @@ mod tests {
|
||||
}
|
||||
|
||||
fn watcher_state(w: &Watcher) -> &DirState {
|
||||
&w.inner.state
|
||||
match &w.inner {
|
||||
Inner::PerDirectory { state, .. } => state,
|
||||
Inner::Recursive(_) | Inner::Polling(_) => panic!("not per-directory"),
|
||||
}
|
||||
}
|
||||
|
||||
fn wait_for(events: &Mutex<Vec<FsBatch>>, pred: impl Fn(&[FsBatch]) -> bool) -> bool {
|
||||
|
||||
@@ -344,28 +344,6 @@ fn backend_edits_emit_patches() {
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), "ONE\r\n2\r\nTHREE\r\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_change_in_two_places_patches_each() {
|
||||
let f = fixture();
|
||||
let path = f.root.join("two.txt");
|
||||
let old: String = (0..400).map(|i| format!("line {i}\n")).collect();
|
||||
fs::write(&path, &old).unwrap();
|
||||
let id = f.docs.open(&path).unwrap().id;
|
||||
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
let new = old.replace("line 20\n", "line 20x\n").replace("line 299\n", "line 299x\n");
|
||||
fs::write(&path, &new).unwrap();
|
||||
f.docs.on_disk_changed(std::slice::from_ref(&path));
|
||||
|
||||
assert_eq!(f.docs.snapshot(id).unwrap().text, new);
|
||||
let events = f.events.lock();
|
||||
let changes = events.iter().find_map(|e| match e {
|
||||
IdeEvent::DocPatched { changes, .. } => Some(changes),
|
||||
_ => None,
|
||||
});
|
||||
assert_eq!(changes.map(Vec::len), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_changes() {
|
||||
let f = fixture();
|
||||
|
||||
@@ -318,7 +318,7 @@ a token.
|
||||
|
||||
<!-- BEGIN GENERATED TOKEN REFERENCE: pnpm gen:theme-docs -->
|
||||
|
||||
224 themeable tokens: 117 in the UI theme file, 107 in the editor colour scheme file.
|
||||
223 themeable tokens: 117 in the UI theme file, 106 in the editor colour scheme file.
|
||||
|
||||
### Surfaces (UI theme)
|
||||
|
||||
@@ -420,7 +420,6 @@ a token.
|
||||
| `--syn-number` | `#2aacb8` | `#1750eb` |
|
||||
| `--syn-punct` | `#bcbec4` | `#080808` |
|
||||
| `--syn-variable` | `#bcbec4` | `#080808` |
|
||||
| `--syn-global` | `var(--syn-variable)` | `var(--syn-variable)` |
|
||||
| `--syn-param` | `#bcbec4` | `#080808` |
|
||||
| `--syn-property` | `#c77dbb` | `#871094` |
|
||||
| `--syn-constant` | `#c77dbb` | `#871094` |
|
||||
|
||||
+3
-4
@@ -20,10 +20,9 @@ The fixtures are standalone: `rust-app` declares its own `[workspace]` and
|
||||
|
||||
The language smoke test (`crates/ide-host/tests/language_smoke.rs`) runs completion,
|
||||
navigation, diagnostics, quick fixes, formatting and rename on these projects with real
|
||||
language servers, `smoke/run.test.ts` runs each project's run and test configurations
|
||||
(the intentionally failing test included, then Rerun Failed Tests), and `smoke/debug.test.ts`
|
||||
debugs its entry point from the "Set a breakpoint here" line: when you change a fixture, run
|
||||
them for that language.
|
||||
language servers, and `smoke/run.test.ts` runs each project's run and test configurations
|
||||
(the intentionally failing test included, then Rerun Failed Tests): when you change a
|
||||
fixture, run both for that language.
|
||||
|
||||
## Setup
|
||||
|
||||
|
||||
@@ -62,8 +62,6 @@
|
||||
"text/open_1mb": 2000,
|
||||
"text/apply_keystroke_1mb": 20,
|
||||
"text/apply_100_changes_1mb": 1000,
|
||||
"text/diff_two_edits_1mb": 10000,
|
||||
"text/diff_every_line_1mb": 100000,
|
||||
"text/offset_to_pos_round_trip": 5,
|
||||
"text/pos_to_byte": 5,
|
||||
"text/text_for_save_crlf_1mb": 1000,
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
"--syn-number": "#7fc8f8",
|
||||
"--syn-punct": "#a9b4c2",
|
||||
"--syn-variable": "#d9dfe8",
|
||||
"--syn-global": "#d9dfe8",
|
||||
"--syn-param": "#d9dfe8",
|
||||
"--syn-property": "#b9a3f0",
|
||||
"--syn-constant": "#e58fa3",
|
||||
|
||||
@@ -1,251 +0,0 @@
|
||||
/**
|
||||
* Debugging each language's `fixtures/` project the way the editor does: a breakpoint on the
|
||||
* entry point's "Set a breakpoint here" line, Debug from the file's context, then the paused
|
||||
* frame, its variables, evaluation, Step Over and Resume, with the debugger the IDE installs.
|
||||
*/
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { cpSync, mkdtempSync, readFileSync, realpathSync, rmSync } from "node:fs";
|
||||
import { get } from "node:http";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterAll, afterEach, beforeAll, expect, test } from "vitest";
|
||||
import { Channel } from "@tauri-apps/api/core";
|
||||
import { commands, type InstallEvent } from "@/bindings";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { call, nextRequestId, onIdeEvent } from "@/platform/ipc";
|
||||
import { useBuildModel } from "@/platform/buildModel";
|
||||
import { openProject, useProject } from "@/platform/project";
|
||||
import { activate as activateBuildTools } from "@/features/build-tools";
|
||||
import { useBuild } from "@/features/build-tools/store";
|
||||
import { activate as activateDebugger } from "@/features/debugger";
|
||||
import { addBreakpoint } from "@/features/debugger/breakpoints/store";
|
||||
import { activeSession, evaluate, useDebug } from "@/features/debugger/state";
|
||||
import { activate as activateRun } from "@/features/run";
|
||||
import { resetRuns, useRun } from "@/features/run/store";
|
||||
import { activateWorkbench } from "@/workbench/activate";
|
||||
import { useNotifications } from "@/workbench/notificationStore";
|
||||
import { connectDesktop } from "./desktop";
|
||||
import { repoRoot, Server } from "./server";
|
||||
|
||||
interface Case {
|
||||
fixture: string;
|
||||
/** Runs in the project copy first (installs its dependencies). */
|
||||
setup?: (root: string) => void;
|
||||
/** The IDE's tool that debugs it. */
|
||||
debugger: string;
|
||||
/** The entry point, debugged from its context. */
|
||||
file: string;
|
||||
/** A variable of the paused frame. */
|
||||
variable: string;
|
||||
/** An expression evaluated there (Evaluate Expression), and its value. */
|
||||
evaluate: [string, RegExp];
|
||||
/** A server: the breakpoint is in this request's handler, and the session is stopped at the end. */
|
||||
request?: string;
|
||||
}
|
||||
|
||||
const CASES: Record<string, Case> = {
|
||||
typescript: {
|
||||
fixture: "ts-app",
|
||||
setup: (root) => execFileSync("npm", ["install", "--no-audit", "--no-fund"], { cwd: root, stdio: "ignore" }),
|
||||
debugger: "js-debug",
|
||||
file: "src/index.ts",
|
||||
variable: "cart",
|
||||
evaluate: ["cart.items.length", /^2$/],
|
||||
},
|
||||
python: {
|
||||
fixture: "python-app",
|
||||
setup: (root) => execFileSync("python3", ["-m", "venv", ".venv"], { cwd: root, stdio: "ignore" }),
|
||||
debugger: "debugpy",
|
||||
file: "src/tasks/cli.py",
|
||||
variable: "tasks",
|
||||
evaluate: ["len(tasks.pending())", /^3$/],
|
||||
},
|
||||
rust: {
|
||||
fixture: "rust-app",
|
||||
debugger: "codelldb",
|
||||
file: "src/main.rs",
|
||||
variable: "inventory",
|
||||
evaluate: ["inventory", /\S/],
|
||||
},
|
||||
go: {
|
||||
fixture: "go-app",
|
||||
debugger: "delve",
|
||||
file: "cmd/server/main.go",
|
||||
variable: "r",
|
||||
evaluate: ["r.Method", /^"GET"$/],
|
||||
request: "http://127.0.0.1:8080/notes?q=openidea",
|
||||
},
|
||||
java: {
|
||||
fixture: "java-app",
|
||||
debugger: "java-debug",
|
||||
file: "src/main/java/com/example/orders/App.java",
|
||||
variable: "book",
|
||||
evaluate: ["book.size()", /^3$/],
|
||||
},
|
||||
kotlin: {
|
||||
fixture: "kotlin-gradle",
|
||||
debugger: "kotlin-debug-adapter",
|
||||
file: "src/main/kotlin/com/example/library/App.kt",
|
||||
variable: "library",
|
||||
evaluate: ["library", /Library/],
|
||||
},
|
||||
};
|
||||
|
||||
let deactivate: (() => void) | null = null;
|
||||
let server: Server | null = null;
|
||||
let disconnect: (() => void) | null = null;
|
||||
let base = "";
|
||||
|
||||
/** Backend events received, for failure messages. */
|
||||
const events: string[] = [];
|
||||
beforeAll(() => {
|
||||
onIdeEvent("*", (e) => events.push(e.type === "debug" ? `debug ${e.session} ${e.event.kind}` : e.type));
|
||||
const disposers = [activateWorkbench(), activateBuildTools(), activateRun(), activateDebugger()];
|
||||
deactivate = () => disposers.reverse().forEach((d) => d());
|
||||
});
|
||||
afterAll(() => deactivate?.());
|
||||
|
||||
afterEach(() => {
|
||||
resetRuns();
|
||||
disconnect?.();
|
||||
server?.close();
|
||||
if (base) rmSync(base, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function open(c: Case): Promise<string> {
|
||||
base = realpathSync(mkdtempSync(join(tmpdir(), "openidea-smoke-")));
|
||||
const root = join(base, c.fixture);
|
||||
cpSync(join(repoRoot, "fixtures", c.fixture), root, { recursive: true });
|
||||
c.setup?.(root);
|
||||
server = await Server.start(join(base, "home"));
|
||||
disconnect = connectDesktop(server);
|
||||
await openProject(root);
|
||||
const trusted = await commands.projectTrustSet(root, true, false);
|
||||
if (trusted.status !== "ok") throw new Error(trusted.error.message);
|
||||
useProject.setState({ project: { ...useProject.getState().project!, trusted: true } });
|
||||
// Run and Debug follow the build (a Gradle project's Application runs through Gradle).
|
||||
await until(
|
||||
"the build model",
|
||||
() => {
|
||||
const error = useBuild.getState().error;
|
||||
if (error) throw new Error(`the build model did not load: ${error}`);
|
||||
return useBuildModel.getState().root === root && useBuildModel.getState().model;
|
||||
},
|
||||
5 * 60_000,
|
||||
);
|
||||
return root;
|
||||
}
|
||||
|
||||
async function install(tool: string) {
|
||||
const tools = await call(commands.toolsList());
|
||||
if (tools.find((t) => t.name === tool)?.status === "installed") return;
|
||||
const channel = new Channel<InstallEvent>();
|
||||
const info = await call(commands.toolsInstall(tool, nextRequestId(), channel));
|
||||
expect(info.status, `${tool} installs`).toBe("installed");
|
||||
}
|
||||
|
||||
/** What the Debug and Run tool windows show, for failure messages. */
|
||||
function shown(): string {
|
||||
const { sessions, console } = useDebug.getState();
|
||||
const debug = Object.values(sessions).map(
|
||||
(s) =>
|
||||
`debug ${s.id} ${s.name} ${s.status} ${s.stopReason ?? ""}\n` +
|
||||
(console[s.id] ?? [])
|
||||
.slice(-40)
|
||||
.map((l) => ` ${l.text}`)
|
||||
.join(""),
|
||||
);
|
||||
const runs = useRun.getState().sessions.map((s) => `run ${s.id} ${s.config.name} ${s.status} ${s.exitCode ?? ""}`);
|
||||
const notes = useNotifications.getState().items.map((n) => `${n.level}: ${n.title} ${n.body ?? ""}`);
|
||||
return [...debug, ...runs, ...notes, `events: ${events.slice(-60).join(", ")}`, server?.log() ?? ""].join("\n");
|
||||
}
|
||||
|
||||
/** Waits for `value` to be truthy; fails with what the tool windows show. */
|
||||
async function until<T>(what: string, value: () => T | null | undefined | false, timeoutMs = 60_000): Promise<T> {
|
||||
const end = Date.now() + timeoutMs;
|
||||
for (;;) {
|
||||
const v = value();
|
||||
if (v) return v;
|
||||
if (Date.now() > end) throw new Error(`timed out waiting for ${what}\n${shown()}`);
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
}
|
||||
}
|
||||
|
||||
/** Requests `url` once the debugged server accepts connections (the response waits for Resume). */
|
||||
async function requestWhenListening(url: string) {
|
||||
for (let i = 0; i < 600; i++) {
|
||||
const connected = await new Promise<boolean>((resolve) => {
|
||||
const req = get(url, (res) => {
|
||||
res.resume();
|
||||
resolve(true);
|
||||
});
|
||||
req.on("socket", (socket) => socket.on("connect", () => resolve(true)));
|
||||
req.on("error", () => resolve(false));
|
||||
});
|
||||
if (connected) return;
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
}
|
||||
|
||||
const paused = () => {
|
||||
const s = activeSession();
|
||||
return s?.status === "paused" && s.frames.length > 0 ? s : null;
|
||||
};
|
||||
|
||||
/** Paused, failing as soon as every session has ended instead. */
|
||||
const pausedOrEnded = () => {
|
||||
const sessions = Object.values(useDebug.getState().sessions);
|
||||
if (sessions.length > 0 && sessions.every((s) => s.status === "terminated"))
|
||||
throw new Error(`the debugger ended before the breakpoint\n${shown()}`);
|
||||
return paused();
|
||||
};
|
||||
|
||||
async function smoke(language: string) {
|
||||
const c = CASES[language]!;
|
||||
const root = await open(c);
|
||||
await install(c.debugger);
|
||||
|
||||
const path = join(root, c.file);
|
||||
const content = readFileSync(path, "utf8");
|
||||
const marker = content.split("\n").findIndex((l) => l.includes("Set a breakpoint here"));
|
||||
const line = marker + 1;
|
||||
addBreakpoint(path, line);
|
||||
|
||||
await executeAction("run.debugContext", { path, content });
|
||||
if (c.request) void requestWhenListening(c.request);
|
||||
const s = await until("the breakpoint", pausedOrEnded, 5 * 60_000);
|
||||
expect(s.stopReason, `${s.stopDescription}\n${shown()}`).toBe("breakpoint");
|
||||
const top = s.frames[0]!;
|
||||
expect(top.path).toBe(path);
|
||||
expect(top.line).toBe(line);
|
||||
|
||||
const scopes = await call(commands.debugScopes(s.id, top.id));
|
||||
const locals = await call(commands.debugVariables(s.id, scopes[0]!.variablesReference));
|
||||
expect(locals.map((v) => v.name)).toContain(c.variable);
|
||||
|
||||
const value = await evaluate(c.evaluate[0], "repl", s);
|
||||
expect(value.value, c.evaluate[0]).toMatch(c.evaluate[1]);
|
||||
|
||||
// Python 3.12 reports a conditional expression's line twice: Step Over may stop on it
|
||||
// (and its breakpoint) again.
|
||||
for (let step = 0; step < 3 && paused()!.frames[0]!.line === line; step++) {
|
||||
const stops = useDebug.getState().stopSeq;
|
||||
await executeAction("debug.stepOver");
|
||||
await until("Step Over", () => useDebug.getState().stopSeq > stops && paused());
|
||||
}
|
||||
const after = paused()!;
|
||||
expect(after.stopReason, shown()).toBe("step");
|
||||
expect(after.frames[0]!.line, shown()).toBeGreaterThan(line);
|
||||
|
||||
await executeAction("debug.resume");
|
||||
if (c.request) {
|
||||
await until("the request to be served", () => useDebug.getState().sessions[s.id]?.status === "running");
|
||||
await executeAction("debug.stop");
|
||||
}
|
||||
// Every session ends (a JVM that listens again as its debugger detaches may be attached once more).
|
||||
await until("the end", () => Object.values(useDebug.getState().sessions).every((d) => d.status === "terminated"));
|
||||
}
|
||||
|
||||
for (const language of Object.keys(CASES)) {
|
||||
test(language, () => smoke(language));
|
||||
}
|
||||
+7
-11
@@ -3,25 +3,23 @@
|
||||
* (callbacks, `Channel`s, the event plugin) in-process, and every app command sent to an
|
||||
* `openidea-server` through the route remote development uses (`src/platform/tauriCore.ts`).
|
||||
*/
|
||||
import { homedir, tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { setInvokeRoute } from "@/platform/tauriCore";
|
||||
import { encodeArgs } from "@/platform/remote";
|
||||
import { Server } from "./server";
|
||||
|
||||
type Callback = (message: unknown) => void;
|
||||
|
||||
// One runtime per page, there from the start as in the app: the frontend registers its event
|
||||
// listener once (`src/platform/ipc.ts`), possibly before a server is connected, and keeps it
|
||||
// across the servers connected one after another.
|
||||
// One runtime per page, as in the app: the frontend registers its event listeners once
|
||||
// (`src/platform/ipc.ts`), so they outlive the servers connected one after another.
|
||||
const callbacks = new Map<number, Callback>();
|
||||
const listeners: { event: string; handler: number }[] = [];
|
||||
const channels = new Map<number, { onmessage: (data: unknown) => void }>();
|
||||
let nextCallback = 1;
|
||||
/** `BaseDirectory` values (`@tauri-apps/api/path`) the frontend resolves. */
|
||||
const DIRECTORIES: Record<number, string> = { 12: tmpdir(), 21: homedir() };
|
||||
let installed = false;
|
||||
|
||||
{
|
||||
function install() {
|
||||
if (installed) return;
|
||||
installed = true;
|
||||
const internals = {
|
||||
metadata: { currentWindow: { label: "main" }, currentWebview: { label: "main", windowLabel: "main" } },
|
||||
transformCallback(cb: Callback) {
|
||||
@@ -40,9 +38,6 @@ const DIRECTORIES: Record<number, string> = { 12: tmpdir(), 21: homedir() };
|
||||
return listeners.length;
|
||||
}
|
||||
if (cmd === "plugin:window|get_all_windows") return [];
|
||||
// This machine's directories (`@tauri-apps/api/path`), as on the desktop.
|
||||
if (cmd === "plugin:path|resolve_directory") return DIRECTORIES[args.directory as number] ?? null;
|
||||
if (cmd === "plugin:path|join") return join(...(args.paths as string[]));
|
||||
if (cmd.startsWith("plugin:")) return null;
|
||||
throw { kind: "unavailable", message: `${cmd} has no route`, detail: null };
|
||||
},
|
||||
@@ -55,6 +50,7 @@ const DIRECTORIES: Record<number, string> = { 12: tmpdir(), 21: homedir() };
|
||||
|
||||
/** Connects this page's frontend to `server`; returns a function that disconnects it. */
|
||||
export function connectDesktop(server: Server): () => void {
|
||||
install();
|
||||
channels.clear();
|
||||
server.onChannel = (id, data) => channels.get(id)?.onmessage(data);
|
||||
server.onEvent = (target, event) => {
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* JSON-RPC with `Content-Length` framing over the server's stdin / stdout.
|
||||
*/
|
||||
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
|
||||
import { appendFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
const PROTOCOL_VERSION = 2;
|
||||
@@ -36,8 +35,6 @@ export class Server {
|
||||
private constructor(private readonly child: ChildProcessWithoutNullStreams) {
|
||||
child.stdout.on("data", (chunk: Buffer) => this.receive(chunk));
|
||||
child.stderr.on("data", (chunk: Buffer) => {
|
||||
// The whole log, when asked for (OPENIDEA_SMOKE_LOG=<file>; OPENIDEA_LOG sets its level).
|
||||
if (process.env.OPENIDEA_SMOKE_LOG) appendFileSync(process.env.OPENIDEA_SMOKE_LOG, chunk);
|
||||
this.stderr.push(chunk.toString());
|
||||
if (this.stderr.length > 200) this.stderr.shift();
|
||||
});
|
||||
|
||||
@@ -16,7 +16,7 @@ dunce.workspace = true
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-dialog = { version = "2", default-features = false, features = ["xdg-portal"] }
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-mcp-bridge = "0.13"
|
||||
tauri-plugin-window-state = "2"
|
||||
tauri-plugin-single-instance = "2"
|
||||
|
||||
@@ -473,7 +473,7 @@ pub async fn plugin_web_read_file(path: PathBuf) -> IdeResult<String> {
|
||||
Ok(base64::engine::general_purpose::STANDARD.encode(bytes))
|
||||
}
|
||||
|
||||
/// The marketplace's plugins: read again when `refresh` or when none was read yet, else the last index read.
|
||||
/// The marketplace's plugins: read again when `refresh`, else the last index read.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn plugin_market(
|
||||
@@ -481,7 +481,7 @@ pub async fn plugin_market(
|
||||
refresh: bool,
|
||||
) -> IdeResult<Vec<ide_host::plugins::MarketPlugin>> {
|
||||
let host = state.plugins.clone();
|
||||
blocking(move || host.market_listing(refresh)).await
|
||||
blocking(move || if refresh { host.refresh_market() } else { Ok(host.market_plugins()) }).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
+2
-11
@@ -28,7 +28,7 @@ mod windows;
|
||||
|
||||
use parking_lot::Mutex;
|
||||
use state::AppState;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tauri::{Emitter, Manager};
|
||||
use windows::WindowsState;
|
||||
@@ -40,21 +40,12 @@ pub fn specta_builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
}
|
||||
|
||||
pub fn export_bindings(builder: &tauri_specta::Builder<tauri::Wry>) {
|
||||
let target = Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../src/bindings.ts"));
|
||||
// Rewritten only when it changes: every debug launch exports, also a second instance that just
|
||||
// hands its path over, and a rewrite makes Vite reload the running app's windows.
|
||||
let fresh = std::env::temp_dir().join(format!("openidea-bindings-{}.ts", std::process::id()));
|
||||
builder
|
||||
.export(
|
||||
specta_typescript::Typescript::default().header("// @ts-nocheck\n/* eslint-disable */\n// Generated by tauri-specta. Do not edit; run `pnpm gen:bindings`."),
|
||||
&fresh,
|
||||
concat!(env!("CARGO_MANIFEST_DIR"), "/../src/bindings.ts"),
|
||||
)
|
||||
.expect("failed to export TypeScript bindings");
|
||||
let text = std::fs::read(&fresh).expect("exported TypeScript bindings");
|
||||
let _ = std::fs::remove_file(&fresh);
|
||||
if std::fs::read(target).ok().as_deref() != Some(text.as_slice()) {
|
||||
std::fs::write(target, text).expect("failed to write TypeScript bindings");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
|
||||
@@ -154,11 +154,9 @@ impl AppState {
|
||||
self.host.open_light(window, file)
|
||||
}
|
||||
|
||||
/// Called on the event-loop thread (window destruction, the `close_project`
|
||||
/// command), so it must not wait for the project's workers to shut down.
|
||||
pub fn close_project(&self, window: &str) {
|
||||
self.remote_dev.disconnect(window);
|
||||
self.host.close_project_detached(window);
|
||||
self.host.close_project(window);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-6
@@ -999,7 +999,7 @@ export const commands = {
|
||||
pluginWebReadFile: (path: string) => typedError<string, IdeError>(__TAURI_INVOKE("plugin_web_read_file", { path })),
|
||||
/** Gutter markers of plugin `line-markers` providers for an open document. */
|
||||
pluginLineMarkers: (doc: DocId) => typedError<LineMarkerInfo[], IdeError>(__TAURI_INVOKE("plugin_line_markers", { doc })),
|
||||
/** The marketplace's plugins: read again when `refresh` or when none was read yet, else the last index read. */
|
||||
/** The marketplace's plugins: read again when `refresh`, else the last index read. */
|
||||
pluginMarket: (refresh: boolean) => typedError<MarketPlugin[], IdeError>(__TAURI_INVOKE("plugin_market", { refresh })),
|
||||
pluginMarketUrl: () => __TAURI_INVOKE<string | null>("plugin_market_url"),
|
||||
/** Sets the marketplace index URL (an `https://…/index.json` or a local path; `None` for the default). */
|
||||
@@ -2480,11 +2480,6 @@ export type DataSource = {
|
||||
options?: DbOptions,
|
||||
introspection?: DbIntrospection,
|
||||
properties?: DbProperty[],
|
||||
/**
|
||||
* Stored app-wide and listed in every project. Implied by the file it is read from,
|
||||
* so not written to it.
|
||||
*/
|
||||
global?: boolean,
|
||||
};
|
||||
|
||||
/** System Settings › Date Formats (Git log, Local History, blame). */
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import { ChangeSet, Text } from "@codemirror/state";
|
||||
import type { TextChange } from "@/platform/ipc";
|
||||
import { DocSync, toTextChanges } from "./DocSync";
|
||||
import { minimalChanges } from "./diff";
|
||||
import { minimalChange } from "./diff";
|
||||
|
||||
function apply(text: string, changes: TextChange[]) {
|
||||
let out = "";
|
||||
@@ -14,10 +14,6 @@ function apply(text: string, changes: TextChange[]) {
|
||||
return out + text.slice(pos);
|
||||
}
|
||||
|
||||
function numberedLines(count: number) {
|
||||
return Array.from({ length: count }, (_, i) => `line ${i}\n`).join("");
|
||||
}
|
||||
|
||||
class OutOfSync extends Error {
|
||||
kind = "outOfSync";
|
||||
}
|
||||
@@ -230,46 +226,6 @@ describe("DocSync", () => {
|
||||
expect(sync.idle).toBe(true);
|
||||
});
|
||||
|
||||
it("a hard resync touches only the regions that differ", async () => {
|
||||
const initial = numberedLines(40);
|
||||
const { server, view, type, frame, host } = setup(initial);
|
||||
server.failNext = { kind: "permissionDenied", message: "read-only" };
|
||||
type(initial.indexOf("line 2\n"), initial.indexOf("line 2\n"), "#");
|
||||
type(view.text.toString().indexOf("line 37\n"), view.text.toString().indexOf("line 37\n"), "#");
|
||||
frame();
|
||||
await server.processAll();
|
||||
await flushMicrotasks();
|
||||
expect(view.text.toString()).toBe(initial);
|
||||
const touched: number[] = [];
|
||||
host.applyRemote.mock.calls[0]![0].iterChanges((fromA) => touched.push(fromA));
|
||||
expect(touched).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("a snapshot resync patches each region a lost event changed", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const initial = numberedLines(40);
|
||||
const { server, view, type, frame, host } = setup(initial);
|
||||
type(initial.length, initial.length, "tail\n");
|
||||
frame();
|
||||
const lost = [
|
||||
{ from: initial.indexOf("line 2\n"), to: initial.indexOf("line 2\n"), insert: "#" },
|
||||
{ from: initial.indexOf("line 37\n"), to: initial.indexOf("line 37\n"), insert: "#" },
|
||||
];
|
||||
server.patch(lost);
|
||||
await server.processAll();
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
await server.processAll();
|
||||
await flushMicrotasks();
|
||||
expect(view.text.toString()).toBe(server.text);
|
||||
const touched: number[] = [];
|
||||
host.applyRemote.mock.calls[0]![0].iterChanges((fromA) => touched.push(fromA));
|
||||
expect(touched).toHaveLength(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("whenIdle resolves after everything is acked", async () => {
|
||||
const { server, sync, type } = setup("");
|
||||
type(0, 0, "a");
|
||||
@@ -323,22 +279,10 @@ describe("DocSync", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("minimalChanges", () => {
|
||||
describe("minimalChange", () => {
|
||||
it("trims common prefix and suffix", () => {
|
||||
expect(minimalChanges("hello world", "hello brave world")).toEqual([{ from: 6, to: 6, insert: "brave " }]);
|
||||
expect(minimalChanges("abc", "abc")).toEqual([]);
|
||||
expect(apply("aaa", minimalChanges("aaa", "a"))).toBe("a");
|
||||
});
|
||||
|
||||
it("keeps distant edits apart", () => {
|
||||
const before = numberedLines(40);
|
||||
const after = before.replace("line 2\n", "line two\n").replace("line 37\n", "line thirty-seven\n");
|
||||
const changes = minimalChanges(before, after);
|
||||
expect(changes).toHaveLength(2);
|
||||
expect(apply(before, changes)).toBe(after);
|
||||
});
|
||||
|
||||
it("rebuilds text across astral characters", () => {
|
||||
expect(apply("a😀b\n😁c", minimalChanges("a😀b\n😁c", "a😁b\n😀c"))).toBe("a😁b\n😀c");
|
||||
expect(minimalChange("hello world", "hello brave world")).toEqual({ from: 6, to: 6, insert: "brave " });
|
||||
expect(minimalChange("abc", "abc")).toBeNull();
|
||||
expect(minimalChange("aaa", "a")).toEqual({ from: 1, to: 3, insert: "" });
|
||||
});
|
||||
});
|
||||
@@ -26,7 +26,7 @@
|
||||
*/
|
||||
import { ChangeSet, Text } from "@codemirror/state";
|
||||
import type { TextChange } from "@/platform/ipc";
|
||||
import { minimalChanges } from "./diff";
|
||||
import { minimalChange } from "./diff";
|
||||
|
||||
export interface SyncTransport {
|
||||
edit(baseVersion: number, changes: TextChange[]): Promise<number>;
|
||||
@@ -258,9 +258,9 @@ export class DocSync {
|
||||
}
|
||||
if (gen !== this.generation || this.disposed) return;
|
||||
if (snap.version > this.serverVersion) {
|
||||
const changes = minimalChanges(this.confirmed.toString(), snap.text);
|
||||
const change = minimalChange(this.confirmed.toString(), snap.text);
|
||||
this.pendingRemote = this.pendingRemote.filter((p) => p.version > snap.version);
|
||||
this.applyRemoteNow({ baseVersion: this.serverVersion, version: snap.version, changes });
|
||||
this.applyRemoteNow({ baseVersion: this.serverVersion, version: snap.version, changes: change ? [change] : [] });
|
||||
this.drainRemote();
|
||||
}
|
||||
this.resend();
|
||||
@@ -329,9 +329,9 @@ export class DocSync {
|
||||
this.pendingRemote = this.pendingRemote.filter((p) => p.version > snap.version);
|
||||
this.confirmed = Text.of(snap.text.split("\n"));
|
||||
this.serverVersion = snap.version;
|
||||
const changes = minimalChanges(local, snap.text);
|
||||
if (changes.length) {
|
||||
const cs = ChangeSet.of(changes, local.length);
|
||||
const change = minimalChange(local, snap.text);
|
||||
if (change) {
|
||||
const cs = ChangeSet.of([change], local.length);
|
||||
this.localText = this.confirmed;
|
||||
this.host.applyRemote(cs);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
import { diff } from "@codemirror/merge";
|
||||
import type { TextChange } from "@/platform/ipc";
|
||||
|
||||
/**
|
||||
* The changes turning `a` into `b`, one per changed region; empty when equal. Used for resyncs:
|
||||
* a single change spanning two distant edits would drag the caret and view to the first one.
|
||||
* The timeout keeps a huge file from stalling the UI; past it the regions only get coarser.
|
||||
* The single replacement turning `a` into `b` (common prefix / suffix
|
||||
* trimmed), or null when equal. Linear time; used for resyncs, where the
|
||||
* backend's own patches are already minimal.
|
||||
*/
|
||||
export function minimalChanges(a: string, b: string): TextChange[] {
|
||||
return diff(a, b, { timeout: 50 }).map((c) => ({ from: c.fromA, to: c.toA, insert: b.slice(c.fromB, c.toB) }));
|
||||
export function minimalChange(a: string, b: string): TextChange | null {
|
||||
if (a === b) return null;
|
||||
const max = Math.min(a.length, b.length);
|
||||
let start = 0;
|
||||
while (start < max && a.charCodeAt(start) === b.charCodeAt(start)) start++;
|
||||
let endA = a.length;
|
||||
let endB = b.length;
|
||||
while (endA > start && endB > start && a.charCodeAt(endA - 1) === b.charCodeAt(endB - 1)) {
|
||||
endA--;
|
||||
endB--;
|
||||
}
|
||||
return { from: start, to: endA, insert: b.slice(start, endB) };
|
||||
}
|
||||
@@ -432,11 +432,6 @@ export function DatabaseView() {
|
||||
</span>
|
||||
)}
|
||||
<span className={labelClass}>{label(n)}</span>
|
||||
{n.kind === "source" && n.source.global && (
|
||||
<span className={styles.treeDetail} title={tx("Available in all projects")}>
|
||||
{tx("global")}
|
||||
</span>
|
||||
)}
|
||||
{d && (
|
||||
<span
|
||||
className={
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { SourceDialog } from "./SourceDialog";
|
||||
|
||||
const savePassword = () => screen.getByRole("checkbox", { name: "Save password" }) as HTMLInputElement;
|
||||
|
||||
describe("new data source", () => {
|
||||
it("does not save the database password unless asked to", () => {
|
||||
render(<SourceDialog initial={null} />);
|
||||
expect(savePassword().checked).toBe(false);
|
||||
});
|
||||
|
||||
it("does not save the SSH tunnel password unless asked to", () => {
|
||||
render(<SourceDialog initial={null} />);
|
||||
fireEvent.click(screen.getByRole("tab", { name: "SSH/SSL" }));
|
||||
fireEvent.click(screen.getByRole("checkbox", { name: "Use SSH tunnel" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "SSH authentication type" }));
|
||||
fireEvent.click(within(screen.getByRole("listbox")).getByText("Password"));
|
||||
expect(savePassword().checked).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -110,7 +110,7 @@ const blankSsh = (): DbSsh => ({
|
||||
user: "",
|
||||
auth: "openSsh",
|
||||
keyFile: "",
|
||||
saveSecret: false,
|
||||
saveSecret: true,
|
||||
});
|
||||
|
||||
const blankOptions = (): DbOptions => ({
|
||||
@@ -155,7 +155,7 @@ function blank(): DataSource {
|
||||
user: "",
|
||||
file: null,
|
||||
ssl: "prefer",
|
||||
savePassword: false,
|
||||
savePassword: true,
|
||||
readOnly: false,
|
||||
color: null,
|
||||
comment: "",
|
||||
@@ -1069,10 +1069,6 @@ export function SourceDialog({
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
{/* IDEA's Make Global: stored with the IDE and listed in every project. */}
|
||||
<span title={tx("Available in all projects")}>
|
||||
<Checkbox checked={!!s.global} onChange={(global) => set({ global })} label="Global" />
|
||||
</span>
|
||||
</div>
|
||||
<span />
|
||||
{showComment ? (
|
||||
|
||||
@@ -11,7 +11,7 @@ import { useProject } from "@/platform/project";
|
||||
import { applicationConfig, jvmModuleDir, jvmTestConfig } from "./application";
|
||||
import { buildRows } from "./ConfigChooser";
|
||||
import { jdwpLaunch } from "./javaDebug";
|
||||
import { firstTopLevelType, jvmMainCandidates, scanJvmTargets } from "./jvmTargets";
|
||||
import { jvmMainCandidates, scanJvmTargets } from "./jvmTargets";
|
||||
import { adoptTemporary, MAX_TEMPORARY, saveTemporary, testReportEvents, useRun } from "./store";
|
||||
import { detectRunTargets, targetConfig } from "./targets";
|
||||
|
||||
@@ -94,47 +94,6 @@ describe("scanJvmTargets (Java)", () => {
|
||||
[16, "test", "a.b.OrderTest$Fixture", "inner", false],
|
||||
]);
|
||||
});
|
||||
|
||||
it("treats the public void methods of a TestNG class-level @Test as tests", () => {
|
||||
const src = [
|
||||
"package a.b;", // 0
|
||||
"import org.testng.annotations.*;",
|
||||
'@Test(groups = "unit")',
|
||||
"public class PriceTest {", // 3
|
||||
" @BeforeMethod public void setUp() {}",
|
||||
" public void rounds() {}", // 5
|
||||
' @Test(dataProvider = "rows")',
|
||||
" public void parses(String row) {}", // 7
|
||||
" @DataProvider public Object[][] rows() { return null; }",
|
||||
' @DataProvider(name = "more") public void notATest() {}',
|
||||
" void packagePrivate() {}",
|
||||
" private void util() {}",
|
||||
" public static void shared() {}",
|
||||
" public int value() { return 1; }",
|
||||
"}",
|
||||
].join("\n");
|
||||
expect(pick(src, "src/test/java/a/b/PriceTest.java")).toEqual([
|
||||
[3, "suite", "a.b.PriceTest", null, false],
|
||||
[5, "test", "a.b.PriceTest", "rounds", false],
|
||||
[7, "test", "a.b.PriceTest", "parses", false],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("firstTopLevelType", () => {
|
||||
it("is the first top-level class, interface, enum, record or annotation, with the package", () => {
|
||||
const src = [
|
||||
"package demo;",
|
||||
"import java.util.List; // class Commented {}",
|
||||
'/* enum Old {} */ @Tag(type = List.class, name = "class Str")',
|
||||
"interface Shape { class Nested {} }",
|
||||
"public class Test { public static void main(String[] args) {} }",
|
||||
].join("\n");
|
||||
expect(firstTopLevelType(src)).toBe("demo.Shape");
|
||||
expect(firstTopLevelType("record Point(int x) {}\nclass Test {}")).toBe("Point");
|
||||
expect(firstTopLevelType("@interface Marker {}\nclass Test {}")).toBe("Marker");
|
||||
expect(firstTopLevelType("void main() {\n IO.println(1);\n}\n")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("scanJvmTargets (Kotlin)", () => {
|
||||
@@ -379,18 +338,6 @@ describe("debugging build tool configurations", () => {
|
||||
expect(merged.env.JAVA_TOOL_OPTIONS).toMatch(/^-Dfile\.encoding=UTF-8 -agentlib:jdwp=/);
|
||||
});
|
||||
|
||||
it("debugs a single Java file: the agent before the file, or for java only on a javac line", () => {
|
||||
const source = jdwpLaunch(cfg({ id: "f", kind: "java-file", command: "/bin/java", args: ["Hi.java"] }))!;
|
||||
expect(source.args).toEqual([expect.stringMatching(AGENT), "Hi.java"]);
|
||||
const line = "mkdir -p /tmp/c && /bin/javac -g -d /tmp/c Test.java && /bin/java -cp /tmp/c Test";
|
||||
const compiled = jdwpLaunch(
|
||||
cfg({ id: "f", kind: "java-file", command: line, env: { JDK_JAVA_OPTIONS: "-Xmx1g" } }),
|
||||
)!;
|
||||
expect(compiled).toMatchObject({ command: line, args: [] });
|
||||
expect(compiled.env.JDK_JAVA_OPTIONS).toMatch(/^-Xmx1g -agentlib:jdwp=/);
|
||||
expect(compiled.env.JAVA_TOOL_OPTIONS).toBeUndefined();
|
||||
});
|
||||
|
||||
it("leaves Application configurations and other debuggers alone", () => {
|
||||
for (const kind of ["application", "spring-boot"])
|
||||
expect(jdwpLaunch(cfg({ id: "a", kind, command: "java", debugAdapter: "java-debug" }))).toBeNull();
|
||||
|
||||
@@ -40,12 +40,8 @@ function withProperty(args: readonly string[], key: string, value: string): stri
|
||||
export function jdwpLaunch(config: RunConfig): RunConfig | null {
|
||||
if (config.kind === "tomcat") return { ...config, params: { ...config.params, tomcatDebug: "true" } };
|
||||
const args = config.args;
|
||||
// A single source file (`java Hi.java`): the agent goes before the file. Compiled first
|
||||
// (`javac … && java -cp …`): through `JDK_JAVA_OPTIONS`, which only the `java` launcher reads.
|
||||
if (config.kind === "java-file")
|
||||
return args.length
|
||||
? { ...config, args: [JDWP_AGENT, ...args] }
|
||||
: { ...config, env: withOption(config.env, "JDK_JAVA_OPTIONS", JDWP_AGENT) };
|
||||
// A single source file (`java Hi.java`): the agent goes before the file.
|
||||
if (config.kind === "java-file") return { ...config, args: [JDWP_AGENT, ...args] };
|
||||
if (config.kind === "maven" || /(^|\/)mvnw?(\.cmd)?$/.test(config.command)) {
|
||||
if (args.some((a) => a === "spring-boot:run" || /:spring-boot-maven-plugin:[^:]*:run$/.test(a)))
|
||||
return { ...config, args: withProperty(args, "spring-boot.run.jvmArguments", JDWP_AGENT) };
|
||||
|
||||
@@ -89,8 +89,6 @@ function tokenize(code: string): Token[] {
|
||||
}
|
||||
|
||||
const TEST_ANNOTATIONS = new Set(["Test", "ParameterizedTest", "RepeatedTest", "TestFactory", "TestTemplate"]);
|
||||
/** TestNG methods that set up or feed tests; not tests themselves under a class-level `@Test`. */
|
||||
const TESTNG_NOT_TESTS = /^(Before|After)\w*$|^(DataProvider|Factory)$/;
|
||||
const TYPE_KEYWORDS = new Set(["class", "interface", "enum", "record", "object"]);
|
||||
const NOT_TYPES = new Set(["new", "return", "throw", "else", "case", "assert", "yield", "await", "this", "super"]);
|
||||
const KOTLIN_DECL = /^(fun|val|var|class|object|interface|enum|data|sealed|abstract|open|private|public|internal|@)$/;
|
||||
@@ -248,16 +246,7 @@ function scan(src: string, relPath: string, kotlin: boolean) {
|
||||
const modifiers: string[] = [];
|
||||
for (let k = i - 1; k >= 0 && !MEMBER_END.has(tokens[k]!.text); k--) modifiers.push(tokens[k]!.text);
|
||||
const owner = top();
|
||||
// TestNG `@Test` on a class (JUnit's is method-only): its public `void` methods are tests.
|
||||
const classLevelTest =
|
||||
!kotlin &&
|
||||
owner?.kind === "class" &&
|
||||
owner.annotations.includes("Test") &&
|
||||
prev === "void" &&
|
||||
modifiers.includes("public") &&
|
||||
!modifiers.includes("static") &&
|
||||
!annotations.some((a) => TESTNG_NOT_TESTS.test(a));
|
||||
if ((annotations.some((a) => TEST_ANNOTATIONS.has(a)) || classLevelTest) && owner) {
|
||||
if (annotations.some((a) => TEST_ANNOTATIONS.has(a)) && owner) {
|
||||
const className = qualify(binaryName(frames));
|
||||
out.push({ line: tokens[i]!.line, kind: "test", className, method: name });
|
||||
for (let f = frames.length; f > 0; f--) {
|
||||
@@ -349,24 +338,6 @@ function extendsClause(tokens: Token[], at: number): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Binary name of a Java file's first top-level type: the class the source launcher (`java Hi.java`) runs. */
|
||||
export function firstTopLevelType(src: string): string | null {
|
||||
const code = stripCommentsAndStrings(src);
|
||||
const pkg = /^\s*package\s+([\w.]+)/m.exec(code)?.[1] ?? "";
|
||||
const tokens = tokenize(code);
|
||||
let depth = 0;
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
const t = tokens[i]!.text;
|
||||
if (t === "{") depth++;
|
||||
else if (t === "}") depth = Math.max(0, depth - 1);
|
||||
else if (depth === 0 && TYPE_KEYWORDS.has(t) && tokens[i - 1]?.text !== ".") {
|
||||
const name = tokens[i + 1]?.text;
|
||||
if (isIdent(name)) return pkg ? `${pkg}.${name}` : name!;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Simple name of a binary class name (`a.b.Outer$Inner` → `Inner`). */
|
||||
export function simpleClassName(className: string): string {
|
||||
return className.split(/[.$]/).pop() ?? className;
|
||||
|
||||
@@ -344,69 +344,6 @@ describe("single-file producers (scratches)", () => {
|
||||
expect(p.config!.args.at(-1)).toBe("demo.Hi");
|
||||
});
|
||||
|
||||
it("Java: a main class after another top-level class compiles with javac, then runs with java -cp", () => {
|
||||
const src =
|
||||
"class Parent {\n void show() {}\n}\n\npublic class Test {\n" +
|
||||
" public static void main(String[] args) {\n new Parent().show();\n }\n}\n";
|
||||
const input = { path: `${ROOT}/src/Test.java`, root: ROOT, configs: [], content: src };
|
||||
const out = "/tmp/openidea-run/Test-[a-z0-9]+-classes";
|
||||
for (const p of [produceFile(input, ALL), produceContext(input, ALL), produceContext({ ...input, line: 6 }, ALL)]) {
|
||||
expect(p!.config).toMatchObject({
|
||||
id: "context:file:src/Test.java",
|
||||
name: "Test.java",
|
||||
kind: "java-file",
|
||||
args: [],
|
||||
cwd: null,
|
||||
debugAdapter: "java-debug",
|
||||
});
|
||||
expect(p!.config!.command).toMatch(
|
||||
new RegExp(`^mkdir -p (${out}) && /bin/javac -g -d \\1 src/Test\\.java && /bin/java -cp \\1 Test$`),
|
||||
);
|
||||
}
|
||||
// The source launcher runs the first top-level class: a main there keeps `java Test.java`.
|
||||
expect(
|
||||
run(scratch("Test.java", "public class Test {\n public static void main(String[] a) {}\n}\nclass B {}")),
|
||||
).toEqual({ name: "Test.java", command: "/bin/java", args: ["Test.java"], cwd: SCRATCH });
|
||||
// Java 25 compact source file: no class, `java Hi.java` as before.
|
||||
expect(run(scratch("Hi.java", "void main() {\n IO.println(1);\n}\nclass Helper {}\n"))).toMatchObject({
|
||||
command: "/bin/java",
|
||||
args: ["Hi.java"],
|
||||
});
|
||||
});
|
||||
|
||||
it("Java: the gutter runs the main class it marks, nested ones and those of a JDK on Windows too", () => {
|
||||
const src = [
|
||||
"package demo;",
|
||||
"class First {",
|
||||
" public static void main(String[] args) {}",
|
||||
" static class Inner {",
|
||||
" public static void main(String[] args) {}",
|
||||
" }",
|
||||
"}",
|
||||
"public class Second {",
|
||||
" public static void main(String[] args) {}",
|
||||
"}",
|
||||
].join("\n");
|
||||
const at = (line?: number) => produceContext(scratch("Second.java", src, line), ALL)!;
|
||||
// The file and the first class: the source launcher.
|
||||
for (const p of [at(), at(2)]) expect(p.config).toMatchObject({ command: "/bin/java", args: ["Second.java"] });
|
||||
const second = at(8);
|
||||
expect(second).toMatchObject({ name: "Second", scope: "element" });
|
||||
expect(second.config).toMatchObject({ id: `context:file:${SCRATCH}/Second.java#demo.Second`, name: "Second" });
|
||||
expect(second.config!.command).toMatch(/ && \/bin\/java -cp \S+ demo\.Second$/);
|
||||
expect(at(4).config!.command).toMatch(/ && \/bin\/java -cp \S+ 'demo\.First\$Inner'$/);
|
||||
const jdk = "C:\\Program Files\\Java\\jdk-21\\bin\\java.exe";
|
||||
const win: ToolEnv = {
|
||||
tool: (n) => (n === "java" ? { path: jdk, version: null } : null),
|
||||
compileDir: "C:/Users/me/AppData/Local/openidea/run",
|
||||
windows: true,
|
||||
};
|
||||
const p = produceContext(scratch("Second.java", src, 8), win)!;
|
||||
expect(p.config!.command).toMatch(
|
||||
/^\(if not exist (C:\\Users\\me\\AppData\\Local\\openidea\\run\\Second-[a-z0-9]+-classes) mkdir \1\) && "C:\\Program Files\\Java\\jdk-21\\bin\\javac\.exe" -g -d \1 Second\.java && "C:\\Program Files\\Java\\jdk-21\\bin\\java\.exe" -cp \1 demo\.Second$/,
|
||||
);
|
||||
});
|
||||
|
||||
it("Java: a main in a Gradle module outside the project runs on the module's classpath", () => {
|
||||
// `includeFlat 'billing'`: the module folder is a sibling of the project folder.
|
||||
const svc = "/work/billing/modules/core";
|
||||
|
||||
@@ -18,7 +18,6 @@ import { goGenerateConfig } from "./goGenerate";
|
||||
import { goTestParts } from "./goArguments";
|
||||
import { contextConfig, fileRole, projectPython, testBasesFor, testConfigFor } from "./context";
|
||||
import { djangoLabel, djangoTestsOf, hasDjangoTests } from "./djangoTypes";
|
||||
import { firstTopLevelType, scanJvmTargets, simpleClassName } from "./jvmTargets";
|
||||
import { isWindows } from "./jvmTypes";
|
||||
import { inheritedRunTargets } from "./inheritedMain";
|
||||
import { detectRunTargets, targetConfig, targetLanguage, type RunTarget } from "./targets";
|
||||
@@ -544,44 +543,6 @@ function compiled(
|
||||
return { name, scope, config, debug: { build: buildConfig, config: binary } };
|
||||
}
|
||||
|
||||
/**
|
||||
* A single Java file. The source launcher (`java Hi.java`, JEP 330) runs the file's first
|
||||
* top-level type, so another main class (declared after it, nested, or picked in the gutter)
|
||||
* is compiled with the JDK's `javac` into a class directory and run with `java -cp`.
|
||||
*/
|
||||
function javaFile(input: ContextInput, env: ToolEnv, s: FileSite, mainClass?: string): Produced {
|
||||
const tools = toolsFor("java");
|
||||
const java = pick(env, tools);
|
||||
if (!java) return { name: s.file, scope: "file", missing: missing("java", s.file, tools) };
|
||||
const source: Produced = {
|
||||
name: s.file,
|
||||
scope: "file",
|
||||
config: fileConfig(s, "java-file", java.command, [s.arg], "java-debug"),
|
||||
};
|
||||
if (input.content === undefined) return source;
|
||||
const first = firstTopLevelType(input.content);
|
||||
const mains = scanJvmTargets(input.content, s.idPath, false)
|
||||
.filter((t) => t.kind === "main")
|
||||
.map((t) => t.className);
|
||||
const stem = stemOf(s.file);
|
||||
const fileMain =
|
||||
mains.find((c) => c === first) ?? mains.find((c) => c.slice(c.lastIndexOf(".") + 1) === stem) ?? mains[0];
|
||||
const launch = mainClass ?? fileMain;
|
||||
if (!launch || launch === first) return source;
|
||||
const win = !!env.windows;
|
||||
const dir = env.compileDir.replace(/\\/g, "/").replace(/(.)\/+$/, "$1");
|
||||
const out = nativePath(`${dir}/${stem}-${hash(input.path)}-classes`, win);
|
||||
// The `javac` of the JDK that runs the class, so their class file versions match.
|
||||
const javac = java.command.replace(/(^|[\\/])java(\.exe)?$/i, "$1javac$2");
|
||||
const build = `${mkdirLine(out, win)} && ${shellLine([javac, "-g", "-d", out, s.arg], win)}`;
|
||||
const run = shellLine([java.command, "-cp", out, launch], win);
|
||||
const config: RunConfig = {
|
||||
...fileConfig(s, "java-file", `${build} && ${run}`, [], "java-debug"),
|
||||
...(launch === fileMain ? {} : { id: `context:file:${s.idPath}#${launch}`, name: simpleClassName(launch) }),
|
||||
};
|
||||
return { name: config.name, scope: "file", config };
|
||||
}
|
||||
|
||||
function simple(
|
||||
lang: ScriptLanguage,
|
||||
s: FileSite,
|
||||
@@ -820,7 +781,8 @@ export function produceFile(input: ContextInput, env: ToolEnv): Produced | null
|
||||
return simple(lang, s, env, "go", () => ["run", s.arg], "delve");
|
||||
case "java":
|
||||
if (input.content !== undefined && !/\bvoid\s+main\s*\(/.test(input.content)) return null;
|
||||
return javaFile(input, env, s);
|
||||
// JEP 330: `java Hi.java` compiles and runs a single source file.
|
||||
return simple(lang, s, env, "java-file", () => [s.arg], "java-debug");
|
||||
case "rust":
|
||||
if (!hasRustMain(input.content)) {
|
||||
// A standalone file with tests only: run them all.
|
||||
@@ -941,8 +903,6 @@ function elementProduction(input: ContextInput, env: ToolEnv, target: RunTarget)
|
||||
const config = applicationConfig({ className: target.className, spring: target.spring }, rel, input.configs);
|
||||
return { name: config.name, scope: "element", config };
|
||||
}
|
||||
if (target.className && /\.java$/.test(input.path))
|
||||
return { ...javaFile(input, env, s, target.className), scope: "element" };
|
||||
const file = produceFile(input, env);
|
||||
return file ? { ...file, scope: "element" } : null;
|
||||
}
|
||||
|
||||
@@ -10,13 +10,6 @@ describe("parseComparison", () => {
|
||||
expect(parseComparison(['expected: "a b"', ' but was: "a c"'])).toEqual({ expected: '"a b"', actual: '"a c"' });
|
||||
});
|
||||
|
||||
it("TestNG", () => {
|
||||
expect(parseComparison(["java.lang.AssertionError: total expected [3] but found [2]"])).toEqual({
|
||||
expected: "3",
|
||||
actual: "2",
|
||||
});
|
||||
});
|
||||
|
||||
it("kotlin.test", () => {
|
||||
expect(parseComparison(["java.lang.AssertionError: Expected <1>, actual <2>."])).toEqual({
|
||||
expected: "1",
|
||||
|
||||
@@ -21,8 +21,6 @@ export function parseComparison(output: readonly string[]): Comparison | null {
|
||||
// JUnit / opentest4j / AssertJ / Hamcrest-style: `expected: <x> but was: <y>`.
|
||||
if ((m = /expected:\s*(<[\s\S]*?>|\S[^\n]*?)\s*but was:\s*(<[\s\S]*?>|[^\n]*)/.exec(text)))
|
||||
return { expected: unwrap(m[1]!), actual: unwrap(m[2]!) };
|
||||
// TestNG `Assert.assertEquals`: `expected [x] but found [y]`.
|
||||
if ((m = /expected \[([\s\S]*?)\] but found \[([\s\S]*?)\]/.exec(text))) return { expected: m[1]!, actual: m[2]! };
|
||||
// kotlin.test: `Expected <x>, actual <y>.`
|
||||
if ((m = /Expected <([\s\S]*?)>, actual <([\s\S]*?)>/.exec(text))) return { expected: m[1]!, actual: m[2]! };
|
||||
// Rust `assert_eq!`: ` left: x` / ` right: y` (the macro doesn't say which one is expected).
|
||||
|
||||
@@ -21,7 +21,7 @@ const SUREFIRE_CLASS_RE =
|
||||
const SUREFIRE_TOTAL_RE = /^(?:\[\w+\]\s+)?Tests run: (\d+), Failures: (\d+), Errors: (\d+), Skipped: (\d+)\s*$/;
|
||||
// `at org.junit…`, also behind a class loader or module (`at app//org.junit…`, `at java.base@21/java.lang…`).
|
||||
const LIBRARY_FRAME_RE =
|
||||
/^\s*at\s+(?:\S*\/)?(?:org\.junit|junit\.|org\.opentest4j|org\.testng|kotlin\.|jdk\.internal|java\.|javax\.|sun\.|org\.gradle)/;
|
||||
/^\s*at\s+(?:\S*\/)?(?:org\.junit|junit\.|org\.opentest4j|kotlin\.|jdk\.internal|java\.|javax\.|sun\.|org\.gradle)/;
|
||||
|
||||
/** The source position of a stack frame in the project's code, null for library frames. */
|
||||
function frameLocation(line: string): TestLocation | null {
|
||||
|
||||
@@ -471,24 +471,6 @@ org.opentest4j.AssertionFailedError: expected: <3> but was: <2>
|
||||
expect(r.get("com.acme.UserTest")?.own).toBe("passed");
|
||||
expect(r.summary).toMatchObject({ passed: 2, failed: 1 });
|
||||
});
|
||||
|
||||
it("locates a TestNG failure in the test, past TestNG's own frames", () => {
|
||||
const r = results(
|
||||
jvmParser(),
|
||||
`[INFO] Running com.acme.PriceTest
|
||||
[ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.2 s <<< FAILURE! -- in com.acme.PriceTest
|
||||
[ERROR] com.acme.PriceTest.rounds -- Time elapsed: 0.01 s <<< FAILURE!
|
||||
java.lang.AssertionError: expected [3] but found [2]
|
||||
at org.testng.Assert.fail(Assert.java:111)
|
||||
at org.testng.Assert.assertEquals(Assert.java:1080)
|
||||
at com.acme.PriceTest.rounds(PriceTest.java:12)
|
||||
`,
|
||||
);
|
||||
expect(leaf(r, "com.acme.PriceTest", "rounds")?.location).toMatchObject({
|
||||
path: "com/acme/PriceTest.java",
|
||||
line: 12,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("parser selection", () => {
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
type InspectionNode,
|
||||
} from "../pages/editor/inspectionsModel";
|
||||
import { splitPageTarget } from "../model/staging";
|
||||
import { JAVA_SERIAL_VERSION_INSPECTION, validateInspections } from "@/platform/inspections";
|
||||
import { validateInspections } from "@/platform/inspections";
|
||||
import { NATURAL_LANGUAGES_PAGE, SPELLING_PAGE } from "../pages/editor/spelling";
|
||||
import { INSPECTIONS_PAGE } from "../pages/editor/inspections";
|
||||
import { EDITOR_PAGES } from "../pages/editor";
|
||||
@@ -69,7 +69,6 @@ describe("inspections tree", () => {
|
||||
expect(tree.map((g) => g.name)).toEqual([
|
||||
"Cargo",
|
||||
"General",
|
||||
"Java",
|
||||
"JavaScript",
|
||||
"Other",
|
||||
"Proofreading",
|
||||
@@ -110,19 +109,6 @@ describe("inspections tree", () => {
|
||||
expect(checkState(o, python)).toBe("on");
|
||||
});
|
||||
|
||||
it("keeps an inspection that is off by default off until turned on", () => {
|
||||
const serial = find(tree, `Java/${JAVA_SERIAL_VERSION_INSPECTION}`)!;
|
||||
expect(checkState({}, serial)).toBe("off");
|
||||
let o = setEnabled({}, serial, true);
|
||||
expect(o).toEqual({ [JAVA_SERIAL_VERSION_INSPECTION]: { enabled: true } });
|
||||
expect(checkState(o, serial)).toBe("on");
|
||||
o = setEnabled(o, serial, false);
|
||||
expect(o).toEqual({});
|
||||
// Turning a whole group on or off only records what differs from each default.
|
||||
const java = find(tree, "group:Java")!;
|
||||
expect(setEnabled({}, java, true)).toEqual({ [JAVA_SERIAL_VERSION_INSPECTION]: { enabled: true } });
|
||||
});
|
||||
|
||||
it("keeps severity and enabled overrides independent", () => {
|
||||
let o = setLevel({}, "lsp:ruff", "error");
|
||||
o = setEnabled(o, find(tree, "Python/lsp:ruff")!, false);
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
INSPECTION_LEVELS,
|
||||
codeInspectionId,
|
||||
customProfileId,
|
||||
enabledByDefault,
|
||||
newProfileKey,
|
||||
overridesOf,
|
||||
producerInspectionId,
|
||||
@@ -160,7 +159,7 @@ export function inspectionIds(node: InspectionNode): string[] {
|
||||
return [...(node.id ? [node.id] : []), ...node.children.flatMap(inspectionIds)];
|
||||
}
|
||||
|
||||
export const isEnabled = (o: ProfileOverrides, id: string) => o[id]?.enabled ?? enabledByDefault(id);
|
||||
export const isEnabled = (o: ProfileOverrides, id: string) => o[id]?.enabled !== false;
|
||||
|
||||
export type CheckState = "on" | "off" | "mixed";
|
||||
|
||||
@@ -182,12 +181,9 @@ function withOverride(o: ProfileOverrides, id: string, patch: Partial<Inspection
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Turns a node and all its descendants on or off; an inspection back at its default drops the override. */
|
||||
/** Turns a node and all its descendants on or off (on is the default, so it removes the override). */
|
||||
export function setEnabled(o: ProfileOverrides, node: InspectionNode, enabled: boolean): ProfileOverrides {
|
||||
return inspectionIds(node).reduce(
|
||||
(acc, id) => withOverride(acc, id, { enabled: enabled === enabledByDefault(id) ? undefined : enabled }),
|
||||
o,
|
||||
);
|
||||
return inspectionIds(node).reduce((acc, id) => withOverride(acc, id, { enabled: enabled ? undefined : false }), o);
|
||||
}
|
||||
|
||||
/** `undefined` restores the default ("As reported"). */
|
||||
|
||||
@@ -62,9 +62,9 @@
|
||||
.icon {
|
||||
flex: none;
|
||||
}
|
||||
/* The name takes the whole row; when it doesn't fit, the grey detail after it gives way first. */
|
||||
.label {
|
||||
flex: 0 1 auto;
|
||||
flex: 0 0 auto;
|
||||
max-width: 75%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -74,7 +74,6 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
.detail {
|
||||
flex: 0 10000 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -191,9 +190,6 @@
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.stashRow > .detail {
|
||||
flex-shrink: 1;
|
||||
}
|
||||
.stashMessage {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
@@ -1956,9 +1956,10 @@ function x(lane: number) {
|
||||
return LANE / 2 + lane * LANE;
|
||||
}
|
||||
|
||||
/** Straight, also across lanes (IDEA's diagonal edges). */
|
||||
function seg(fromX: number, fromY: number, toX: number, toY: number) {
|
||||
return `M${fromX} ${fromY}L${toX} ${toY}`;
|
||||
if (fromX === toX) return `M${fromX} ${fromY}L${toX} ${toY}`;
|
||||
const mid = (fromY + toY) / 2;
|
||||
return `M${fromX} ${fromY}C${fromX} ${mid} ${toX} ${mid} ${toX} ${toY}`;
|
||||
}
|
||||
|
||||
export function GraphCell({
|
||||
@@ -2007,11 +2008,9 @@ export function GraphCell({
|
||||
if (canNavigate) onNavigate?.(s.targetRow!, target);
|
||||
else if (canFragment) onFragment?.(target!, !!s.dashed, s.source);
|
||||
};
|
||||
const fromX = x(s.fromX ?? s.from);
|
||||
const toX = x(s.toX ?? s.to);
|
||||
const path =
|
||||
seg(fromX, up ? 0 : cy, toX, up ? cy : ROW) +
|
||||
(s.arrow ? arrowHead(up ? fromX : toX, up ? 0 : ROW, up ? -1 : 1) : "");
|
||||
seg(x(s.from), up ? 0 : cy, x(s.to), up ? cy : ROW) +
|
||||
(s.arrow ? arrowHead(x(up ? s.from : s.to), up ? 0 : ROW, up ? -1 : 1) : "");
|
||||
return (
|
||||
<g
|
||||
key={`${up ? "u" : "d"}${i}`}
|
||||
@@ -2034,9 +2033,9 @@ export function GraphCell({
|
||||
>
|
||||
<title>{label}</title>
|
||||
<rect
|
||||
x={Math.min(fromX, toX) - 5}
|
||||
x={Math.min(x(s.from), x(s.to)) - 5}
|
||||
y={up ? 0 : cy}
|
||||
width={Math.max(10, Math.abs(fromX - toX) + 10)}
|
||||
width={Math.max(10, Math.abs(x(s.from) - x(s.to)) + 10)}
|
||||
height={cy}
|
||||
fill="transparent"
|
||||
pointerEvents="none"
|
||||
|
||||
@@ -1,29 +1,19 @@
|
||||
/**
|
||||
* Push dialog: the files changed by the outgoing commits (the selected commit's, or all of
|
||||
* them), with the selected file's diff beside the tree. The diff stays inside the dialog,
|
||||
* which is modal. A single commit also shows its message, author and time.
|
||||
* which is modal.
|
||||
*/
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { ChangedFile, Commit, RevisionChange } from "@/bindings";
|
||||
import { call, commands } from "@/platform/ipc";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { Button, IconButton, Spinner, T } from "@/ui";
|
||||
import { Button, Spinner, T } from "@/ui";
|
||||
import { ComparisonTree } from "./ComparisonTree";
|
||||
import { buildFileTree } from "./fileTree";
|
||||
import { flatFileNodes, toLogChanges, type LogChange } from "./logChanges";
|
||||
import { KIND_COLOR, dirName, fileCount } from "./model";
|
||||
import { toLogChanges, type LogChange } from "./logChanges";
|
||||
import { KIND_COLOR, fileCount } from "./model";
|
||||
import styles from "./common.module.css";
|
||||
|
||||
const GROUP_KEY = "openidea.vcs.push.groupByDirectory";
|
||||
|
||||
function readGroupByDirectory(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(GROUP_KEY) !== "false";
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/** Identifies what `PushChanges` shows; render it with this `key` so a new selection starts afresh. */
|
||||
export const pushChangesKey = (repo: string | null, commits: readonly Commit[]) =>
|
||||
`${repo ?? ""}:${commits.map((c) => c.id).join(" ")}`;
|
||||
@@ -44,7 +34,6 @@ export function PushChanges({
|
||||
const [changes, setChanges] = useState<LogChange[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [current, setCurrent] = useState<LogChange | null>(null);
|
||||
const [byDirectory, setByDirectory] = useState(readGroupByDirectory);
|
||||
|
||||
useEffect(() => {
|
||||
if (!commits.length) return;
|
||||
@@ -61,18 +50,7 @@ export function PushChanges({
|
||||
};
|
||||
}, [repo, commits]);
|
||||
|
||||
const tree = useMemo(
|
||||
() => (!changes ? [] : byDirectory ? buildFileTree(changes, (f) => f.path) : flatFileNodes(changes, (f) => f.path)),
|
||||
[changes, byDirectory],
|
||||
);
|
||||
const toggleGrouping = () => {
|
||||
setByDirectory(!byDirectory);
|
||||
try {
|
||||
localStorage.setItem(GROUP_KEY, String(!byDirectory));
|
||||
} catch {
|
||||
/* kept for this dialog only */
|
||||
}
|
||||
};
|
||||
const tree = useMemo(() => (changes ? buildFileTree(changes, (f) => f.path) : []), [changes]);
|
||||
const count = commits.length;
|
||||
if (!count) return null;
|
||||
return (
|
||||
@@ -87,16 +65,7 @@ export function PushChanges({
|
||||
<T>Show All Commits</T>
|
||||
</Button>
|
||||
)}
|
||||
<IconButton
|
||||
icon="folder"
|
||||
size="small"
|
||||
title={tx("Group by Directory")}
|
||||
pressed={byDirectory}
|
||||
onClick={toggleGrouping}
|
||||
className={styles.pushGrouping}
|
||||
/>
|
||||
</div>
|
||||
{count === 1 && <PushCommitDetails commit={commits[0]!} />}
|
||||
{error ? (
|
||||
<div className={styles.error}>{error}</div>
|
||||
) : !changes ? (
|
||||
@@ -111,31 +80,9 @@ export function PushChanges({
|
||||
diffFor={(c) => c.diff}
|
||||
getColor={(c) => KIND_COLOR[c.kind === "untracked" ? "added" : c.kind]}
|
||||
ariaLabel="Changed files"
|
||||
renderDetails={(n) =>
|
||||
n.file ? (
|
||||
byDirectory ? null : (
|
||||
<span className={styles.fileDir}>{dirName(n.file.path)}</span>
|
||||
)
|
||||
) : (
|
||||
<span className={styles.fileDir}>{n.count}</span>
|
||||
)
|
||||
}
|
||||
renderDetails={(n) => (n.file ? null : <span className={styles.fileDir}>{n.count}</span>)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The selected commit's full message, revision, author and time. */
|
||||
function PushCommitDetails({ commit }: { commit: Commit }) {
|
||||
const date = commit.timeMs ? new Date(commit.timeMs).toLocaleString() : null;
|
||||
return (
|
||||
<section className={styles.pushCommitDetails} aria-label={tx("Commit details")}>
|
||||
<div className={styles.pushCommitMessage}>{commit.message.trimEnd() || commit.summary}</div>
|
||||
<div className={styles.fileDir}>
|
||||
{commit.shortId} {commit.author} <{commit.email}>
|
||||
{date ? " " + tx`on ${date}` : ""}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { computeGraph, straightenLaneChanges } from "../graph";
|
||||
import { computeGraph } from "../graph";
|
||||
|
||||
const c = (id: string, ...parents: string[]) => ({ id, parents });
|
||||
|
||||
@@ -62,47 +62,3 @@ describe("computeGraph", () => {
|
||||
expect(m2.down).toContainEqual({ from: m2.lane, to: 0, color: rows[0]!.color });
|
||||
});
|
||||
});
|
||||
|
||||
describe("straightenLaneChanges", () => {
|
||||
it("draws a lane change as one diagonal across the row boundary, keeping the lanes", () => {
|
||||
// m merges f into main; f branched from a; b sits in lane 0 while lane 1 passes it.
|
||||
const rows = straightenLaneChanges(computeGraph([c("m", "b", "f"), c("f", "a"), c("b", "a"), c("a")]).rows);
|
||||
const [m, f, b, a] = rows;
|
||||
// m → f: leaves m's node towards lane 1 and reaches f's node in a straight line.
|
||||
expect(m!.down[1]).toMatchObject({ from: 0, to: 1, toX: 0.5 });
|
||||
expect(f!.up.find((s) => s.from === 1)).toMatchObject({ from: 1, to: 1, fromX: 0.5 });
|
||||
// Lane 1 converging into a: the vertical line of b's row meets it halfway.
|
||||
expect(a!.up[1]).toMatchObject({ from: 1, to: 0, fromX: 0.5 });
|
||||
expect(b!.down.find((s) => s.from === 1)).toMatchObject({ from: 1, to: 1, toX: 0.5 });
|
||||
// Straight lines stay where they are.
|
||||
expect(m!.down[0]!.toX).toBeUndefined();
|
||||
expect(a!.up[0]!.fromX).toBeUndefined();
|
||||
});
|
||||
|
||||
it("leaves a lane that another line also ends in, and arrows, as they are", () => {
|
||||
const rows = straightenLaneChanges([
|
||||
{
|
||||
lane: 0,
|
||||
color: 0,
|
||||
up: [],
|
||||
down: [
|
||||
{ from: 0, to: 1, color: 0 },
|
||||
{ from: 1, to: 1, color: 1 },
|
||||
],
|
||||
width: 2,
|
||||
},
|
||||
{
|
||||
lane: 1,
|
||||
color: 1,
|
||||
up: [{ from: 1, to: 1, color: 1 }],
|
||||
down: [{ from: 1, to: 2, color: 1, arrow: "down" }],
|
||||
width: 3,
|
||||
},
|
||||
{ lane: 2, color: 1, up: [{ from: 2, to: 2, color: 1 }], down: [], width: 3 },
|
||||
]);
|
||||
expect(rows[0]!.down[0]!.toX).toBeUndefined();
|
||||
expect(rows[1]!.up[0]!.fromX).toBeUndefined();
|
||||
expect(rows[1]!.down[0]!.toX).toBeUndefined();
|
||||
expect(rows[2]!.up[0]!.fromX).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -12,16 +12,7 @@ vi.mock("@/platform/ipc", async (importOriginal) => {
|
||||
import { PushChanges } from "../PushChanges";
|
||||
|
||||
const commit = (id: string, parent: string): Commit =>
|
||||
({
|
||||
id,
|
||||
shortId: id.slice(0, 7),
|
||||
parents: [parent],
|
||||
summary: id,
|
||||
message: id,
|
||||
author: "me",
|
||||
email: "[email protected]",
|
||||
timeMs: 0,
|
||||
}) as unknown as Commit;
|
||||
({ id, shortId: id.slice(0, 7), parents: [parent], summary: id, author: "me", timeMs: 0 }) as unknown as Commit;
|
||||
const newer = commit("bbbbbbbbbb", "aaaaaaaaaa");
|
||||
const older = commit("aaaaaaaaaa", "9999999999");
|
||||
|
||||
@@ -50,36 +41,4 @@ describe("Push dialog changes", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Show All Commits" }));
|
||||
expect(showAll).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows the selected commit's message and author, and lists files flat on request", async () => {
|
||||
localStorage.clear();
|
||||
files.mockResolvedValue({
|
||||
status: "ok",
|
||||
data: [{ path: "src/orders/service.py", oldPath: null, kind: "added" }],
|
||||
});
|
||||
const one = { ...older, message: "Add order service\n\nIntroduces the service.\n", timeMs: 1_700_000_000_000 };
|
||||
render(<PushChanges repo="/r" commits={[one]} />);
|
||||
const details = screen.getByRole("region", { name: "Commit details" });
|
||||
expect(details.textContent).toContain("Add order service\n\nIntroduces the service.");
|
||||
expect(details.textContent).toContain("aaaaaaa me <[email protected]> on ");
|
||||
expect(await screen.findByText("src/orders")).toBeTruthy();
|
||||
expect(screen.getAllByRole("treeitem")).toHaveLength(2);
|
||||
const grouping = screen.getByRole("button", { name: "Group by Directory" });
|
||||
expect(grouping.getAttribute("aria-pressed")).toBe("true");
|
||||
fireEvent.click(grouping);
|
||||
expect(grouping.getAttribute("aria-pressed")).toBe("false");
|
||||
// Flat: one row, the file, with its directory beside the name.
|
||||
const rows = screen.getAllByRole("treeitem");
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]!.textContent).toContain("service.py");
|
||||
expect(rows[0]!.textContent).toContain("src/orders");
|
||||
expect(localStorage.getItem("openidea.vcs.push.groupByDirectory")).toBe("false");
|
||||
});
|
||||
|
||||
it("shows no commit details for several commits", async () => {
|
||||
combined.mockResolvedValue({ status: "ok", data: [] });
|
||||
render(<PushChanges repo="/r" commits={[newer, older]} />);
|
||||
expect(await screen.findByText(/0 files in 2 commits/)).toBeTruthy();
|
||||
expect(screen.queryByRole("region", { name: "Commit details" })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -143,27 +143,6 @@
|
||||
padding: 0 14px 6px;
|
||||
color: var(--fg-subtle);
|
||||
}
|
||||
.pushGrouping {
|
||||
margin-left: auto;
|
||||
}
|
||||
.pushCommitDetails {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
max-height: 96px;
|
||||
margin: 0 14px 8px;
|
||||
padding: 6px 8px;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-small);
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
.pushCommitMessage {
|
||||
color: var(--fg-strong);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.todoList {
|
||||
margin: 8px 0 0;
|
||||
padding-left: 18px;
|
||||
|
||||
@@ -21,9 +21,6 @@ export interface GraphSegment {
|
||||
arrow?: "up" | "down";
|
||||
/** Destination row of a long-edge arrow in the displayed graph. */
|
||||
targetRow?: number;
|
||||
/** Drawn lane positions when they differ from `from` / `to` (see {@link straightenLaneChanges}). */
|
||||
fromX?: number;
|
||||
toX?: number;
|
||||
}
|
||||
|
||||
export interface GraphRow {
|
||||
@@ -506,43 +503,6 @@ export function hideLongEdges(rows: readonly GraphRow[], minLength = LONG_EDGE):
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws a lane change as one straight diagonal across the row boundary, like IDEA: a half-row
|
||||
* segment that changes lane and the vertical half of the neighbouring row that continues it meet
|
||||
* halfway between the two lanes. Only the drawn positions change; `from` / `to` stay the lanes.
|
||||
*/
|
||||
export function straightenLaneChanges(rows: readonly GraphRow[]): GraphRow[] {
|
||||
const out = rows.map((r) => ({ ...r, up: [...r.up], down: [...r.down] }));
|
||||
const vertical = (s: GraphSegment, lane: number) => s.from === lane && s.to === lane && !s.arrow;
|
||||
for (let r = 0; r < out.length; r++) {
|
||||
const row = out[r]!;
|
||||
// Leaving the node towards another lane: continues straight down in the next row.
|
||||
const next = out[r + 1];
|
||||
// Another line ending at the same lane (a merge into a lane that continues) keeps its position.
|
||||
const shared = (list: readonly GraphSegment[], lane: number, end: "from" | "to") =>
|
||||
list.filter((x) => x[end] === lane).length > 1;
|
||||
row.down.forEach((s, i) => {
|
||||
if (s.from === s.to || s.arrow || !next || shared(row.down, s.to, "to")) return;
|
||||
const j = next.up.findIndex((u) => vertical(u, s.to) && u.edge === s.edge && u.fromX === undefined);
|
||||
if (j < 0) return;
|
||||
const mid = (s.from + s.to) / 2;
|
||||
row.down[i] = { ...s, toX: mid };
|
||||
next.up[j] = { ...next.up[j]!, fromX: mid };
|
||||
});
|
||||
// Converging into the node from another lane: continues a vertical line of the previous row.
|
||||
const prev = out[r - 1];
|
||||
row.up.forEach((s, i) => {
|
||||
if (s.from === s.to || s.arrow || !prev || shared(prev.down, s.from, "to")) return;
|
||||
const j = prev.down.findIndex((d) => vertical(d, s.from) && d.edge === s.edge && d.toX === undefined);
|
||||
if (j < 0) return;
|
||||
const mid = (s.from + s.to) / 2;
|
||||
row.up[i] = { ...s, fromX: mid };
|
||||
prev.down[j] = { ...prev.down[j]!, toX: mid };
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Lane colours (tokens), cycled by colour index. */
|
||||
export const LANE_COLORS = [
|
||||
"var(--accent)",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { collapseLinear, computeGraph, hideLongEdges, linearizeMerges, straightenLaneChanges } from "./graph";
|
||||
import { collapseLinear, computeGraph, hideLongEdges, linearizeMerges } from "./graph";
|
||||
import { commitKey, graphCommit, type RepoCommit } from "./multiRoot";
|
||||
import type { LogOptions } from "./logOptions";
|
||||
|
||||
@@ -26,6 +26,5 @@ export function presentLogGraph(
|
||||
const byId = new Map(all.map((c) => [commitKey(c), c]));
|
||||
const commits = graph.map((c) => byId.get(c.id)!);
|
||||
const laid = computeGraph(graph).rows;
|
||||
const rows = straightenLaneChanges(options.longEdges ? laid : hideLongEdges(laid));
|
||||
return { commits, graph, rows, linear, collapsed };
|
||||
return { commits, graph, rows: options.longEdges ? laid : hideLongEdges(laid), linear, collapsed };
|
||||
}
|
||||
@@ -216,14 +216,9 @@ export interface BuiltinInspection {
|
||||
description: string;
|
||||
/** Highlighting level when the profile sets none (else as reported). */
|
||||
defaultLevel?: InspectionLevel;
|
||||
/** `false`: off until a profile turns it on. */
|
||||
defaultEnabled?: boolean;
|
||||
options?: readonly InspectionOptionSpec[];
|
||||
}
|
||||
|
||||
/** Turned on, the Java language server's compiler reports it (`ide-sdk` `SERIAL_VERSION_INSPECTION`). */
|
||||
export const JAVA_SERIAL_VERSION_INSPECTION = "java:missing-serial-version";
|
||||
|
||||
/** Producer key of the Typo inspection's diagnostics. */
|
||||
export const SPELLING_PRODUCER = "spelling";
|
||||
export const SPELLING_INSPECTION = "builtin:spelling";
|
||||
@@ -288,16 +283,6 @@ export const BUILTIN_INSPECTIONS: readonly BuiltinInspection[] = [
|
||||
group: "General",
|
||||
description: "Compiler errors and warnings of the last Build Project, shown at their source positions.",
|
||||
},
|
||||
{
|
||||
id: JAVA_SERIAL_VERSION_INSPECTION,
|
||||
producer: "jdt-serial-version",
|
||||
name: "Serializable class without 'serialVersionUID'",
|
||||
group: "Java",
|
||||
description:
|
||||
"Reports classes that implement Serializable but declare no serialVersionUID field. Alt+Enter on the class name adds a default or a generated ID. The Java language server checks it, and restarts when this is changed.",
|
||||
defaultLevel: "warning",
|
||||
defaultEnabled: false,
|
||||
},
|
||||
{
|
||||
id: "builtin:sql",
|
||||
producer: "sql-inspections",
|
||||
@@ -325,11 +310,6 @@ export const BUILTIN_INSPECTIONS: readonly BuiltinInspection[] = [
|
||||
const BUILTIN_BY_PRODUCER = new Map(BUILTIN_INSPECTIONS.map((b) => [b.producer, b]));
|
||||
const BUILTIN_BY_ID = new Map(BUILTIN_INSPECTIONS.map((b) => [b.id, b]));
|
||||
|
||||
/** Whether inspection `id` is on when a profile doesn't say. */
|
||||
export function enabledByDefault(id: string): boolean {
|
||||
return BUILTIN_BY_ID.get(id)?.defaultEnabled ?? true;
|
||||
}
|
||||
|
||||
/** Option `key` of inspection `id` under `profile` (its default when unset). */
|
||||
export function inspectionOption(profile: ProfileOverrides, id: string, key: string): boolean {
|
||||
const value = profile[id]?.options?.[key];
|
||||
@@ -383,7 +363,7 @@ export function applyProfile<D extends Diagnostic>(
|
||||
): readonly D[] {
|
||||
const base = producerInspectionId(producer);
|
||||
const own = profile[base];
|
||||
if (!(own?.enabled ?? enabledByDefault(base))) return [];
|
||||
if (own?.enabled === false) return [];
|
||||
const fallback = BUILTIN_BY_PRODUCER.get(producer)?.defaultLevel;
|
||||
let changed = false;
|
||||
const out: D[] = [];
|
||||
|
||||
@@ -32,8 +32,6 @@
|
||||
"Not found": "未找到",
|
||||
"Not found on PATH": "未在 PATH 中找到",
|
||||
"Reports newer crate versions that Cargo.toml requirements do not already allow.": "报告 Cargo.toml 版本要求尚未允许的较新 crate 版本。",
|
||||
"Serializable class without 'serialVersionUID'": "没有 'serialVersionUID' 的可序列化类",
|
||||
"Reports classes that implement Serializable but declare no serialVersionUID field. Alt+Enter on the class name adds a default or a generated ID. The Java language server checks it, and restarts when this is changed.": "报告实现了 Serializable 但没有声明 serialVersionUID 字段的类。在类名上按 Alt+Enter 可添加默认 ID 或生成的 ID。此检查由 Java 语言服务器执行,更改后语言服务器会重启。",
|
||||
"Run `auggie login` in a terminal.": "在终端中运行 `auggie login`。",
|
||||
"Run `claude` in a terminal and sign in.": "在终端中运行 `claude` 并登录。",
|
||||
"Run `codex login` in a terminal.": "在终端中运行 `codex login`。",
|
||||
@@ -477,7 +475,6 @@
|
||||
"Comment": "评论",
|
||||
"Comment:": "注释:",
|
||||
"Commit": "提交",
|
||||
"Commit details": "提交详细信息",
|
||||
"Commit Anyway": "仍然提交",
|
||||
"Commit Changelist…": "提交更改列表…",
|
||||
"Commit Checks": "提交检查",
|
||||
@@ -4242,8 +4239,6 @@
|
||||
"Root markers": "根标记",
|
||||
"Initialization options": "初始化选项",
|
||||
"Global": "全局",
|
||||
"global": "全局",
|
||||
"Available in all projects": "在所有项目中可用",
|
||||
"This project": "此项目",
|
||||
"Executable name or path": "可执行文件名或路径",
|
||||
"Select the server executable": "选择服务器可执行文件",
|
||||
|
||||
@@ -230,8 +230,7 @@ html {
|
||||
|
||||
/* metrics (theme JSON at 100%) */
|
||||
--h-titlebar: 40px;
|
||||
/* tool window stripe button, icons only */
|
||||
--stripe-icon-button: 28px;
|
||||
--h-stripe-button: 48px;
|
||||
--h-header: 40px;
|
||||
--h-tabbar: 38px;
|
||||
--h-tab: 28px;
|
||||
@@ -1352,7 +1351,7 @@ html {
|
||||
--h-row: 20px;
|
||||
--h-list-row: 20px;
|
||||
--h-statusbar: 24px;
|
||||
--stripe-icon-button: 24px;
|
||||
--h-stripe-button: 40px;
|
||||
--h-field: 24px;
|
||||
--h-button: 24px;
|
||||
}
|
||||
|
||||
@@ -159,9 +159,9 @@
|
||||
/* Stripes */
|
||||
.stripe {
|
||||
/* --stripe-drag: the width under the pointer while the stripe edge is dragged. */
|
||||
--stripe-button: var(--stripe-drag, var(--stripe-icon-button));
|
||||
/* Outer padding: narrower than the island gap so the side frame stays close to the status bar's height, as in IDEA. */
|
||||
--stripe-pad: 4px;
|
||||
--stripe-button: var(--stripe-drag, 32px);
|
||||
/* Outer padding: icons only sit centred between equal margins. */
|
||||
--stripe-pad: var(--island-gap);
|
||||
width: calc(var(--stripe-button) + var(--stripe-pad) + var(--island-gap));
|
||||
container-type: inline-size;
|
||||
flex: none;
|
||||
@@ -237,8 +237,8 @@
|
||||
.stripeButton {
|
||||
position: relative;
|
||||
flex: none;
|
||||
width: var(--stripe-icon-button);
|
||||
height: var(--stripe-icon-button);
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
@@ -133,7 +133,8 @@ async function setShowToolWindowNames(on: boolean) {
|
||||
useProject.setState({ settings });
|
||||
}
|
||||
|
||||
/** Stripe button widths with names; icons only follow `--stripe-icon-button` (smaller in compact mode). */
|
||||
/** Stripe button widths: icons only, and the range with names. */
|
||||
const STRIPE_ICONS = 32;
|
||||
const STRIPE_MIN = 44;
|
||||
const STRIPE_DEFAULT = 56;
|
||||
const STRIPE_MAX = 96;
|
||||
@@ -201,8 +202,8 @@ function StripeHandle({
|
||||
}) {
|
||||
const sign = side === "left" ? 1 : -1;
|
||||
// The stripe follows the pointer; names show from STRIPE_MIN (truncated), icons only below it.
|
||||
const resolve = (raw: number, icons: number) => {
|
||||
const px = Math.round(Math.min(STRIPE_MAX, Math.max(icons, raw)));
|
||||
const resolve = (raw: number) => {
|
||||
const px = Math.round(Math.min(STRIPE_MAX, Math.max(STRIPE_ICONS, raw)));
|
||||
return px < STRIPE_MIN ? { names: false, width, px } : { names: true, width: px, px };
|
||||
};
|
||||
|
||||
@@ -213,14 +214,13 @@ function StripeHandle({
|
||||
const el = e.currentTarget;
|
||||
el.setPointerCapture(e.pointerId);
|
||||
const origin = e.clientX;
|
||||
const icons = parseFloat(getComputedStyle(stripe).getPropertyValue("--stripe-icon-button")) || 28;
|
||||
const start = names ? width : icons;
|
||||
const start = names ? width : STRIPE_ICONS;
|
||||
const cursor = document.body.style.cursor;
|
||||
document.body.style.cursor = "col-resize";
|
||||
let next = { names, width, px: start };
|
||||
const move = (ev: globalThis.PointerEvent) => {
|
||||
if (!(ev.buttons & 1)) return end();
|
||||
const r = resolve(start + sign * (ev.clientX - origin), icons);
|
||||
const r = resolve(start + sign * (ev.clientX - origin));
|
||||
if (r.names !== next.names) onPreview(r.names);
|
||||
next = r;
|
||||
stripe.style.setProperty("--stripe-drag", `${r.px}px`);
|
||||
|
||||
Reference in new issue
Block a user