дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-12 04:35:39 +02:00
Порівняти коміти
56
Коміти
| Автор | SHA1 | Дата | |
|---|---|---|---|
|
|
2114b18f7a | ||
|
|
29bbaf3b78 | ||
|
|
b30657ec50 | ||
|
|
fd5f4b8639 | ||
|
|
0567bb88bf | ||
|
|
a7e968e869 | ||
|
|
e9a464cfef | ||
|
|
c23696c51f | ||
|
|
3e6e9ed255 | ||
|
|
cbc6b24c5f | ||
|
|
bb4f9ed180 | ||
|
|
f82808a82f | ||
|
|
1899483751 | ||
|
|
6d9bd6b43b | ||
|
|
2d4b25fc34 | ||
|
|
4c5df98bb8 | ||
|
|
46d288b456 | ||
|
|
7c5ef7a97b | ||
|
|
ae3973f11a | ||
|
|
e5efcfbf60 | ||
|
|
6f62917730 | ||
|
|
1b0fe7dcc6 | ||
|
|
7d1b54fae3 | ||
|
|
cb010fac2f | ||
|
|
684f52b3dd | ||
|
|
e8bc577e9d | ||
|
|
b1348ee03a | ||
|
|
e99d05d7a8 | ||
|
|
0e4cf1059a | ||
|
|
b4bcd8cce7 | ||
|
|
cb5e7081fa | ||
|
|
034b2b9f65 | ||
|
|
7ea3c59d05 | ||
|
|
6717b0948a | ||
|
|
8302cd1d6f | ||
|
|
c05670f4fd | ||
|
|
2f9658c367 | ||
|
|
40aa7e4899 | ||
|
|
fba1f02375 | ||
|
|
053ca43368 | ||
|
|
8fedc2efba | ||
|
|
2f2fa79cbe | ||
|
|
65d49619dd | ||
|
|
7f28161869 | ||
|
|
fc1493e1a4 | ||
|
|
efda6834ad | ||
|
|
e92e867019 | ||
|
|
8d8c7a481b | ||
|
|
0b3ca2fc9a | ||
|
|
2414773d61 | ||
|
|
e4181c36ea | ||
|
|
5063f6756e | ||
|
|
1a2f5308a0 | ||
|
|
cd04593d04 | ||
|
|
38241e7fae | ||
|
|
2e6d6a05b9 |
No files matched your search
@@ -0,0 +1,83 @@
|
||||
---
|
||||
name: eval
|
||||
description: Run the code-intelligence evals (`pnpm eval`), pick the worst failing scenario, diagnose it from the attached timeline, fix it, prove the fix with before / after numbers measured on this machine without regressing other scenarios, and open one PR. Use for "跑 eval", "优化 Java 代码智能", "eval 失败了", an issue labelled `eval`, or any change to Java navigation / usages / gutter markers that needs numbers.
|
||||
---
|
||||
|
||||
# Eval loop: measure → fix → prove
|
||||
|
||||
`pnpm eval` drives the production workbench in Chromium against a release `openidea-server`
|
||||
with real language servers (Java / jdtls for now) and judges every scenario on what the user
|
||||
sees: time to feedback, to the first result, to the final one, and the results themselves vs
|
||||
`evals/<language>/golden/`. One machine, before and after: numbers from different machines or
|
||||
from the nightly host don't judge a fix.
|
||||
|
||||
## 0. Preconditions
|
||||
|
||||
- `jdtls` on PATH and `JAVA_HOME` pointing at a JDK of the pinned `JAVA` version (projects
|
||||
compile against it, and the JDK scenarios' results are in its sources; jdtls itself runs on
|
||||
a newer installed JDK when it needs one). Pinned versions: `.github/tool-versions.env`. A
|
||||
mismatch prints a warning and the report records both: say so in the PR when it applies.
|
||||
- First run builds `cargo build --release -p ide-host --bin openidea-server` and the perf
|
||||
bundle; later iterations of a frontend-only change can use `--no-build` after a
|
||||
`pnpm exec vite build --mode perf --outDir target/eval/dist`, a backend-only one after the
|
||||
cargo build alone.
|
||||
- Run evals one at a time on this machine, with nothing heavy (builds, other evals) beside them.
|
||||
|
||||
## 1. Baseline on the base commit
|
||||
|
||||
```sh
|
||||
git switch --detach origin/main # or the fix branch's merge base
|
||||
pnpm eval --save-baseline # target/eval/baseline.json, this machine only
|
||||
```
|
||||
|
||||
Read `target/eval/report.json`. Each verdict has `status`, `failures` (`kind`: `error`,
|
||||
`timeout`, `degraded`, `correctness`, `budget`, `golden`), `regressions`, `repro` (the command
|
||||
that reruns it) and, for failures, `diagnostics`: every command sent to the server in the
|
||||
slowest run's window (`cmd`, `start`, `ms`, `ok`, `bytes`, `items`), background tasks, server
|
||||
status changes and the server's log tail.
|
||||
|
||||
## 2. Pick one problem
|
||||
|
||||
Worst first: wrong or missing results and silent degradation, then timeouts, then the
|
||||
feedback budget (100 ms), then the slowest `completeMs`. One problem per PR. Say which
|
||||
scenario and metric, with its value vs budget.
|
||||
|
||||
## 3. Diagnose from the timeline
|
||||
|
||||
- Commands in the window ordered by `start`: what ran, what waited on what, what's serial
|
||||
that could overlap, which request returned many `items`/`bytes`, which failed.
|
||||
- `feedbackMs` far above 16 ms with a fast `firstResultMs`: the UI shows nothing while it
|
||||
waits (a hint or popup that only appears after a delay).
|
||||
- A task still running across the window: background work competing with the request.
|
||||
- For the frontend side rerun with `--trace` and open `target/eval/traces/<id>@<cond>.json`
|
||||
in Chrome's Performance panel.
|
||||
- Name the cause at class level (as in `issue-fix`): it usually affects sibling scenarios.
|
||||
|
||||
## 4. Fix, then rerun that scenario until it passes
|
||||
|
||||
```sh
|
||||
pnpm eval --only <scenario> --condition <warm|cold> # the verdict's `repro`
|
||||
```
|
||||
|
||||
Correctness is enforced, so a faster answer that drops, truncates or serves stale results
|
||||
fails. Don't trade a budget miss for a degraded answer.
|
||||
|
||||
## 5. Full suite, then PR
|
||||
|
||||
```sh
|
||||
pnpm eval # vs the baseline from step 1: no new failures or regressions
|
||||
```
|
||||
|
||||
PR per `issue-fix` §6, plus in **How it was verified** the before / after table from
|
||||
`target/eval/report.md` (both on this machine, commits named) and the tool versions line.
|
||||
|
||||
## Guard rails
|
||||
|
||||
- Goldens (`evals/*/golden/`) and budgets (`evals/budgets.json`) are human-owned. Don't edit
|
||||
them, and don't run `--update-golden`, to make a scenario pass. When a golden looks wrong
|
||||
(a tool bump, a deliberate behaviour change), stop and say so: a PR that touches them gets
|
||||
`needs-decision`.
|
||||
- Don't weaken a scenario's checks or timeouts in `evals/<language>/scenarios.ts` to pass.
|
||||
Adding a scenario (for a case the sweep or a user found) is fine; record its golden with
|
||||
`--update-golden --only <id>` and ask a person to review the file.
|
||||
- A flaky timing (passes on rerun) is not a regression: rerun once before acting on one.
|
||||
Symlink
@@ -0,0 +1 @@
|
||||
../../.agents/skills/eval
|
||||
@@ -0,0 +1,14 @@
|
||||
# Language server and tool versions for Language smoke (.github/workflows/language-smoke.yml)
|
||||
# and the code-intelligence evals (pnpm eval). Pinned so a release can't fail an unrelated
|
||||
# PR or move a golden result; bump them deliberately (and re-record goldens: pnpm eval --update-golden).
|
||||
VTSLS=0.3.0
|
||||
TYPESCRIPT=6.0.3
|
||||
BASEDPYRIGHT=1.40.2
|
||||
RUFF=0.17.3
|
||||
GOPLS=v0.24.0
|
||||
JDTLS=1.63.0-202610191322
|
||||
KOTLIN_LSP=263.7084.0
|
||||
GRADLE=8.14.3
|
||||
INTELEPHENSE=1.18.5
|
||||
# The JDK projects compile against (JAVA_HOME): library navigation opens its sources.
|
||||
JAVA=21
|
||||
@@ -24,6 +24,7 @@ on:
|
||||
- "package.json"
|
||||
- "pnpm-lock.yaml"
|
||||
- ".github/workflows/language-smoke.yml"
|
||||
- ".github/tool-versions.env"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
types: [opened, synchronize, reopened]
|
||||
@@ -38,16 +39,6 @@ concurrency:
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
# Pinned so a server release can't fail an unrelated PR; bump them deliberately.
|
||||
VTSLS: 0.3.0
|
||||
TYPESCRIPT: 6.0.3
|
||||
BASEDPYRIGHT: 1.40.2
|
||||
RUFF: 0.17.3
|
||||
GOPLS: v0.24.0
|
||||
JDTLS: 1.63.0-202610191322
|
||||
KOTLIN_LSP: 263.7084.0
|
||||
GRADLE: 8.14.3
|
||||
INTELEPHENSE: 1.18.5
|
||||
|
||||
jobs:
|
||||
smoke:
|
||||
@@ -61,6 +52,8 @@ jobs:
|
||||
timeout-minutes: 40
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# Tool versions, shared with the evals (pnpm eval).
|
||||
- run: grep -E '^[A-Z0-9_]+=' .github/tool-versions.env >> "$GITHUB_ENV"
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with: { components: "rust-analyzer, rustfmt" }
|
||||
# One build cache for every language; main saves it.
|
||||
@@ -96,7 +89,7 @@ jobs:
|
||||
|
||||
- if: matrix.language == 'java' || matrix.language == 'kotlin'
|
||||
uses: actions/setup-java@v4
|
||||
with: { distribution: temurin, java-version: "21" }
|
||||
with: { distribution: temurin, java-version: "${{ env.JAVA }}" }
|
||||
- if: matrix.language == 'java'
|
||||
run: |
|
||||
mkdir -p "$RUNNER_TEMP/jdtls"
|
||||
|
||||
@@ -40,6 +40,7 @@ CLAUDE.local.md
|
||||
.claude/skills/*
|
||||
!.claude/skills/verify
|
||||
!.claude/skills/issue-fix
|
||||
!.claude/skills/eval
|
||||
|
||||
# Local reference screenshots
|
||||
pycharm-screenshots/
|
||||
@@ -9,9 +9,11 @@
|
||||
"dist",
|
||||
"target",
|
||||
"src/bindings.ts",
|
||||
"evals/*/golden",
|
||||
"src/editor/core/cm/angular/parser*.ts",
|
||||
"src/editor/core/cm/jsp/parser*.ts",
|
||||
"src/editor/core/cm/astro/parser*.ts",
|
||||
"src/editor/core/cm/django/parser*.ts",
|
||||
"plugins/themes.md",
|
||||
"pnpm-lock.yaml",
|
||||
"**/*.toml",
|
||||
|
||||
+11
-3
@@ -24,8 +24,8 @@ Visual style: dark, low-contrast greys, one cool accent
|
||||
Plugin docs: `plugins/README.md`, `plugins/themes.md`.
|
||||
- `java/` — JDT LS extension bundles (`java-usages`, `kotlin-bridge`) and the patched
|
||||
Lombok agent, embedded into the app by `ide-sdk` / `ide-tools`.
|
||||
- `e2e/` — Playwright UI tests (`pnpm e2e`). `fixtures/` — sample projects per language
|
||||
for manual and automated testing.
|
||||
- `e2e/` — Playwright UI tests (`pnpm e2e`). `evals/` — code-intelligence evals (`pnpm eval`).
|
||||
`fixtures/` — sample projects per language for manual and automated testing.
|
||||
- `docs/` — the Mintlify documentation site (`docs.json` navigation, `en/` and `zh/` pages);
|
||||
preview with `npx mint dev` in `docs/`.
|
||||
|
||||
@@ -59,9 +59,12 @@ Unclear product decisions → `needs-decision` and stop.
|
||||
|
||||
## Skills
|
||||
|
||||
Do not use Impeccable or its related skills in this repository, including globally installed copies.
|
||||
|
||||
Shared agent skills live in `.agents/skills/` (`.claude/skills/` links to them):
|
||||
`verify` drives the running app through Tauri MCP and captures screenshot evidence;
|
||||
`issue-fix` takes an issue from reproduction to one PR.
|
||||
`issue-fix` takes an issue from reproduction to one PR;
|
||||
`eval` takes a failing code-intelligence eval to a fix with before / after numbers.
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -81,6 +84,11 @@ Shared agent skills live in `.agents/skills/` (`.claude/skills/` links to them):
|
||||
`pnpm gen:bindings`.
|
||||
- `pnpm perf` (UI interaction latency / frames / leaks), `pnpm perf:rust` (criterion
|
||||
benches vs budgets). Budgets: `perf/budgets.json`.
|
||||
- `pnpm eval [--only <id>] [--runs n] [--save-baseline]` — Java code intelligence (navigation,
|
||||
usages, gutter markers) in the production workbench against a release `openidea-server` and
|
||||
jdtls: time to feedback / first result / complete, results vs `evals/<language>/golden/`.
|
||||
Report: `target/eval/report.json`. Goldens and `evals/budgets.json` are changed by people only.
|
||||
Tool versions: `.github/tool-versions.env` (shared with Language smoke).
|
||||
- `pnpm release:prepare <version>` — the release commit's changes: app version everywhere,
|
||||
`Cargo.lock`, and the plugin API snapshot when `wit/` moved to a new version.
|
||||
|
||||
|
||||
Створено
+68
-1
@@ -4303,6 +4303,7 @@ dependencies = [
|
||||
"ide-core",
|
||||
"ide-http",
|
||||
"ide-ssh",
|
||||
"notify",
|
||||
"oracledb",
|
||||
"parking_lot",
|
||||
"reqwest",
|
||||
@@ -4618,6 +4619,7 @@ name = "ide-lsp"
|
||||
version = "0.1.42"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
"futures",
|
||||
"globset",
|
||||
"ide-core",
|
||||
@@ -4795,6 +4797,7 @@ dependencies = [
|
||||
"tempfile",
|
||||
"toml 0.9.12+spec-1.1.0",
|
||||
"tracing",
|
||||
"url",
|
||||
"zip",
|
||||
]
|
||||
|
||||
@@ -4844,11 +4847,15 @@ dependencies = [
|
||||
"parking_lot",
|
||||
"russh",
|
||||
"russh-sftp",
|
||||
"rustls",
|
||||
"rustls-platform-verifier",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
"suppaftp",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tracing",
|
||||
"uuid",
|
||||
]
|
||||
@@ -5547,6 +5554,29 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy-regex"
|
||||
version = "3.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0a2cce8a22920a29fdcc8c9170aa5d29edd9fbcf8bea856e24c2cb1c2419f67"
|
||||
dependencies = [
|
||||
"lazy-regex-proc_macros",
|
||||
"once_cell",
|
||||
"regex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy-regex-proc_macros"
|
||||
version = "3.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6f1e4b1f1d8c71edb4ff2b2150ec4606445fd2ddf67ddc097655daf59286baa6"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"regex",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.1"
|
||||
@@ -6913,6 +6943,26 @@ dependencies = [
|
||||
"siphasher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project"
|
||||
version = "1.1.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924"
|
||||
dependencies = [
|
||||
"pin-project-internal",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-internal"
|
||||
version = "1.1.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
@@ -8961,6 +9011,23 @@ version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "suppaftp"
|
||||
version = "12.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b58fcf921745cca6f0127c9f822069947da8c6dbeff52e9004750bcc448171f"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
"lazy-regex",
|
||||
"log",
|
||||
"pin-project",
|
||||
"rustls-pki-types",
|
||||
"thiserror 2.0.21",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "swift-rs"
|
||||
version = "1.0.9"
|
||||
@@ -9569,7 +9636,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.3.4",
|
||||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
|
||||
@@ -1338,15 +1338,19 @@ impl AgentManager {
|
||||
/// Files the task changed since it started.
|
||||
pub async fn changes(&self, id: &str) -> IdeResult<Vec<AgentChange>> {
|
||||
let task = self.task(id)?;
|
||||
let (cwd, base) = task.cwd_and_base();
|
||||
let at = task.base();
|
||||
let before = task.state.lock().before.clone();
|
||||
blocking(move || Ok(worktree::without_untouched(worktree::changes(&cwd, &base)?, &before))).await
|
||||
blocking(move || at.changes(&before)).await
|
||||
}
|
||||
|
||||
/// Content of `path` (in the task's directory) when the task started.
|
||||
pub async fn base_text(&self, id: &str, path: PathBuf) -> IdeResult<Option<String>> {
|
||||
let (cwd, base) = self.task(id)?.cwd_and_base();
|
||||
blocking(move || worktree::file_at(&cwd, &path, &base)).await
|
||||
let at = self.task(id)?.base();
|
||||
blocking(move || match at.rev()? {
|
||||
Some(rev) => worktree::file_at(at.cwd(), &path, &rev),
|
||||
None => Ok(None),
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Rollback: puts the task's changed files among `rel_paths` (all with `None`) back as they
|
||||
@@ -1356,16 +1360,17 @@ impl AgentManager {
|
||||
if task.state.lock().busy {
|
||||
return Err(IdeError::invalid("the agent is working: stop it before rolling back its changes"));
|
||||
}
|
||||
let (cwd, base) = task.cwd_and_base();
|
||||
let at = task.base();
|
||||
let (before, saved) = {
|
||||
let s = task.state.lock();
|
||||
(s.before.clone(), s.saved.as_ref().map(|name| self.shared.paths.store_dir.join(name)))
|
||||
};
|
||||
let restamped = blocking(move || {
|
||||
let changes = worktree::without_untouched(worktree::changes(&cwd, &base)?, &before);
|
||||
let changes = at.changes(&before)?;
|
||||
let rev = at.rev()?;
|
||||
let mut restamped = Vec::new();
|
||||
for change in changes.iter().filter(|c| rel_paths.as_ref().is_none_or(|p| p.contains(&c.rel_path))) {
|
||||
worktree::restore(&cwd, &base, change, &before, saved.as_deref())?;
|
||||
worktree::restore(at.cwd(), rev.as_deref(), change, &before, saved.as_deref())?;
|
||||
// Back to the user's own edits: not a change of the task any more.
|
||||
if before.contains_key(&change.rel_path) {
|
||||
restamped.push((change.rel_path.clone(), worktree::stamp(&change.path.0)));
|
||||
@@ -1469,15 +1474,15 @@ impl Task {
|
||||
})
|
||||
}
|
||||
|
||||
fn cwd_and_base(&self) -> (PathBuf, String) {
|
||||
fn base(&self) -> worktree::TaskBase {
|
||||
let s = self.state.lock();
|
||||
// In the project's own checkout the user commits too: compare against the current HEAD,
|
||||
// as the Commit tool window does, so commits made since the task started drop out.
|
||||
let base = match s.task.isolation {
|
||||
Isolation::Local => None,
|
||||
Isolation::Worktree => s.task.base_commit.clone(),
|
||||
};
|
||||
(s.task.cwd.0.clone(), base.unwrap_or_else(|| "HEAD".into()))
|
||||
let cwd = s.task.cwd.0.clone();
|
||||
match s.task.isolation {
|
||||
Isolation::Local => worktree::TaskBase::Local { cwd, start: s.task.base_commit.clone() },
|
||||
Isolation::Worktree => {
|
||||
worktree::TaskBase::Worktree { cwd, base: s.task.base_commit.clone().unwrap_or_else(|| "HEAD".into()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_task(&self) {
|
||||
|
||||
@@ -492,7 +492,7 @@ for line in sys.stdin:
|
||||
}
|
||||
let concurrent = async {
|
||||
if outcome == "session_report" {
|
||||
tokio::time::timeout(Duration::from_secs(2), async {
|
||||
tokio::time::timeout(Duration::from_secs(10), async {
|
||||
while !root.join("initialized").exists() {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
@@ -564,7 +564,7 @@ for line in sys.stdin:
|
||||
};
|
||||
assert_eq!(subtypes, expected, "{outcome}");
|
||||
let pid = std::fs::read_to_string(root.join("pid")).unwrap();
|
||||
tokio::time::timeout(Duration::from_secs(2), async {
|
||||
tokio::time::timeout(Duration::from_secs(10), async {
|
||||
while std::process::Command::new("kill").args(["-0", pid.trim()]).output().unwrap().status.success() {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
@@ -2869,6 +2869,209 @@ async fn rolling_back_restores_the_start_of_the_task_and_keeps_the_users_own_edi
|
||||
assert!(!dir.join("store").join(format!("{}.before", task.id)).exists());
|
||||
}
|
||||
|
||||
/// A git repository in a temporary folder, for local tasks whose checkout moves under them.
|
||||
struct Checkout {
|
||||
_tmp: tempfile::TempDir,
|
||||
dir: PathBuf,
|
||||
eol: Eol,
|
||||
}
|
||||
|
||||
/// How the checkout keeps line endings: as committed, or CRLF on disk with `core.autocrlf`
|
||||
/// (Git for Windows' default), where the files' bytes never equal the committed blobs.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum Eol {
|
||||
Lf,
|
||||
Crlf,
|
||||
}
|
||||
|
||||
const EOLS: [Eol; 2] = [Eol::Lf, Eol::Crlf];
|
||||
|
||||
impl Checkout {
|
||||
fn new(eol: Eol) -> Self {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let checkout = Self { _tmp: tmp, dir, eol };
|
||||
checkout.git(&["init", "-q", "-b", "main"]);
|
||||
let autocrlf = match eol {
|
||||
Eol::Lf => "false",
|
||||
Eol::Crlf => "true",
|
||||
};
|
||||
checkout.git(&["config", "core.autocrlf", autocrlf]);
|
||||
checkout.write(".gitignore", "store/\nworktrees/\nagents/\nmodels.json\n");
|
||||
checkout
|
||||
}
|
||||
|
||||
fn git(&self, args: &[&str]) {
|
||||
let ok = std::process::Command::new("git")
|
||||
.args(["-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false"])
|
||||
.args(args)
|
||||
.current_dir(&self.dir)
|
||||
.status()
|
||||
.unwrap()
|
||||
.success();
|
||||
assert!(ok, "{args:?}");
|
||||
}
|
||||
|
||||
fn commit(&self, message: &str) {
|
||||
self.git(&["add", "."]);
|
||||
self.git(&["commit", "-q", "-m", message]);
|
||||
}
|
||||
|
||||
fn write(&self, rel: &str, text: &str) {
|
||||
let text = match self.eol {
|
||||
Eol::Lf => text.to_owned(),
|
||||
Eol::Crlf => text.replace('\n', "\r\n"),
|
||||
};
|
||||
std::fs::write(self.dir.join(rel), text).unwrap();
|
||||
}
|
||||
|
||||
/// The file's text with LF line endings (a rollback writes the committed bytes back).
|
||||
fn read(&self, rel: &str) -> String {
|
||||
std::fs::read_to_string(self.dir.join(rel)).unwrap().replace("\r\n", "\n")
|
||||
}
|
||||
|
||||
/// A local task, stopped so it can be rolled back.
|
||||
async fn task(&self) -> (AgentManager, AgentTask) {
|
||||
let mgr = manager(&self.dir, Arc::new(FakeAgent::default()));
|
||||
let task = mgr
|
||||
.create(NewAgentTask {
|
||||
project_root: None,
|
||||
prompt: text("Hello"),
|
||||
provider: "claude".into(),
|
||||
config: Vec::new(),
|
||||
isolation: Isolation::Local,
|
||||
base: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
|
||||
mgr.cancel(&task.id, None).unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
(mgr, task)
|
||||
}
|
||||
}
|
||||
|
||||
async fn changed(mgr: &AgentManager, id: &str) -> Vec<String> {
|
||||
mgr.changes(id).await.unwrap().into_iter().map(|c| c.rel_path).collect()
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn work_uncommitted_by_a_reset_during_a_local_task_is_not_rolled_back() {
|
||||
for eol in EOLS {
|
||||
let repo = Checkout::new(eol);
|
||||
repo.write("a.txt", "base\n");
|
||||
repo.write("mine.txt", "base\n");
|
||||
repo.commit("init");
|
||||
repo.write("mine.txt", "my work\n");
|
||||
repo.commit("my work");
|
||||
let (mgr, task) = repo.task().await;
|
||||
repo.write("a.txt", "agent\n");
|
||||
// The user takes the last commit back into the working tree (`git reset --soft`, an
|
||||
// undone commit): its changes are the user's, not the agent's.
|
||||
repo.git(&["reset", "-q", "--soft", "HEAD~1"]);
|
||||
assert_eq!(changed(&mgr, &task.id).await, ["a.txt"], "{eol:?}");
|
||||
|
||||
let base = mgr.base_text(&task.id, repo.dir.join("a.txt")).await.unwrap();
|
||||
assert_eq!(base.map(|b| b.replace("\r\n", "\n")).as_deref(), Some("base\n"), "{eol:?}");
|
||||
mgr.rollback_changes(&task.id, None).await.unwrap();
|
||||
assert_eq!(repo.read("a.txt"), "base\n", "{eol:?}");
|
||||
assert_eq!(repo.read("mine.txt"), "my work\n", "{eol:?}: the user's work survives the rollback");
|
||||
assert!(changed(&mgr, &task.id).await.is_empty(), "{eol:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn files_a_mixed_reset_during_a_local_task_leaves_behind_are_not_rolled_back() {
|
||||
for eol in EOLS {
|
||||
let repo = Checkout::new(eol);
|
||||
repo.write("a.txt", "base\n");
|
||||
repo.write("mine.txt", "base\n");
|
||||
repo.commit("init");
|
||||
repo.write("mine.txt", "my work\n");
|
||||
repo.write("mine-new.txt", "my new file\n");
|
||||
repo.commit("my work");
|
||||
let (mgr, task) = repo.task().await;
|
||||
repo.write("a.txt", "agent\n");
|
||||
// `git reset HEAD~1`: the commit's new file is untracked now, its edit unstaged.
|
||||
repo.git(&["reset", "-q", "HEAD~1"]);
|
||||
assert_eq!(changed(&mgr, &task.id).await, ["a.txt"], "{eol:?}");
|
||||
|
||||
mgr.rollback_changes(&task.id, None).await.unwrap();
|
||||
assert_eq!(repo.read("a.txt"), "base\n", "{eol:?}");
|
||||
assert_eq!(repo.read("mine.txt"), "my work\n", "{eol:?}");
|
||||
assert_eq!(repo.read("mine-new.txt"), "my new file\n", "{eol:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_branch_switched_to_during_a_local_task_is_left_alone() {
|
||||
for eol in EOLS {
|
||||
let repo = Checkout::new(eol);
|
||||
repo.write("a.txt", "base\n");
|
||||
repo.write("other.txt", "base\n");
|
||||
repo.commit("init");
|
||||
repo.git(&["checkout", "-q", "-b", "feature"]);
|
||||
repo.write("other.txt", "feature\n");
|
||||
repo.write("only-feature.txt", "feature\n");
|
||||
repo.commit("feature");
|
||||
repo.git(&["checkout", "-q", "main"]);
|
||||
repo.write("only-main.txt", "main\n");
|
||||
repo.commit("main");
|
||||
let (mgr, task) = repo.task().await;
|
||||
repo.write("a.txt", "agent\n");
|
||||
// The agent's uncommitted edit comes along to a branch that doesn't contain the task's start.
|
||||
repo.git(&["checkout", "-q", "feature"]);
|
||||
assert_eq!(changed(&mgr, &task.id).await, ["a.txt"], "{eol:?}");
|
||||
|
||||
mgr.rollback_changes(&task.id, None).await.unwrap();
|
||||
assert_eq!(repo.read("a.txt"), "base\n", "{eol:?}");
|
||||
assert_eq!(repo.read("other.txt"), "feature\n", "{eol:?}");
|
||||
assert_eq!(repo.read("only-feature.txt"), "feature\n", "{eol:?}");
|
||||
assert!(!repo.dir.join("only-main.txt").exists(), "{eol:?}");
|
||||
assert!(changed(&mgr, &task.id).await.is_empty(), "{eol:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn commits_made_during_a_local_task_are_kept_by_its_rollback() {
|
||||
for eol in EOLS {
|
||||
let repo = Checkout::new(eol);
|
||||
repo.write("a.txt", "base\n");
|
||||
repo.commit("init");
|
||||
let (mgr, task) = repo.task().await;
|
||||
repo.write("a.txt", "agent\n");
|
||||
repo.write("b.txt", "agent\n");
|
||||
// The user commits what the agent did to `a.txt` so far: it drops out of the task's changes.
|
||||
repo.git(&["add", "a.txt"]);
|
||||
repo.git(&["commit", "-q", "-m", "keep"]);
|
||||
assert_eq!(changed(&mgr, &task.id).await, ["b.txt"], "{eol:?}");
|
||||
repo.write("a.txt", "agent\nagain\n");
|
||||
assert_eq!(changed(&mgr, &task.id).await, ["a.txt", "b.txt"], "{eol:?}");
|
||||
|
||||
mgr.rollback_changes(&task.id, None).await.unwrap();
|
||||
assert_eq!(repo.read("a.txt"), "agent\n", "{eol:?}: back to the commit, not to before it");
|
||||
assert!(!repo.dir.join("b.txt").exists(), "{eol:?}");
|
||||
assert!(changed(&mgr, &task.id).await.is_empty(), "{eol:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_local_task_in_a_repository_without_commits_lists_and_rolls_back_its_changes() {
|
||||
for eol in EOLS {
|
||||
let repo = Checkout::new(eol);
|
||||
repo.write("mine.txt", "mine\n");
|
||||
let (mgr, task) = repo.task().await;
|
||||
repo.write("new.txt", "agent\n");
|
||||
assert_eq!(changed(&mgr, &task.id).await, ["new.txt"], "{eol:?}");
|
||||
assert_eq!(mgr.base_text(&task.id, repo.dir.join("new.txt")).await.unwrap(), None, "{eol:?}");
|
||||
|
||||
mgr.rollback_changes(&task.id, None).await.unwrap();
|
||||
assert!(!repo.dir.join("new.txt").exists(), "{eol:?}");
|
||||
assert_eq!(repo.read("mine.txt"), "mine\n", "{eol:?}");
|
||||
assert!(changed(&mgr, &task.id).await.is_empty(), "{eol:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn security_policy_auto_approves_whitelist_and_rejects_blacklist() {
|
||||
use ide_core::settings::AgentSecuritySettings;
|
||||
|
||||
@@ -117,14 +117,123 @@ pub(crate) fn changes(cwd: &Path, base: &str) -> IdeResult<Vec<AgentChange>> {
|
||||
let tracked: std::collections::HashSet<String> = out.iter().map(|c| c.rel_path.clone()).collect();
|
||||
for s in repo.status()? {
|
||||
if s.unstaged == Some(ChangeKind::Untracked) && !tracked.contains(&s.rel_path) {
|
||||
let added = std::fs::read_to_string(&*s.path).map_or(0, |t| t.lines().count() as u32);
|
||||
out.push(AgentChange { path: s.path, rel_path: s.rel_path, kind: ChangeKind::Added, added, removed: 0 });
|
||||
out.push(added(s.path, s.rel_path));
|
||||
}
|
||||
}
|
||||
out.sort_by(|a, b| a.rel_path.cmp(&b.rel_path));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn added(path: IpcPath, rel_path: String) -> AgentChange {
|
||||
let added = std::fs::read_to_string(&*path).map_or(0, |t| t.lines().count() as u32);
|
||||
AgentChange { path, rel_path, kind: ChangeKind::Added, added, removed: 0 }
|
||||
}
|
||||
|
||||
fn crlf_to_lf(bytes: &[u8]) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(bytes.len());
|
||||
for (i, &b) in bytes.iter().enumerate() {
|
||||
if !(b == b'\r' && bytes.get(i + 1) == Some(&b'\n')) {
|
||||
out.push(b);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Where a task works and what its changes are measured against.
|
||||
pub(crate) enum TaskBase {
|
||||
/// A worktree of its own: everything since the commit it was created from, the agent's
|
||||
/// own commits included.
|
||||
Worktree { cwd: PathBuf, base: String },
|
||||
/// The project's checkout, where the user works and commits too. `start` is the commit it
|
||||
/// started from (`None` in a repository without commits, or an imported session).
|
||||
Local { cwd: PathBuf, start: Option<String> },
|
||||
}
|
||||
|
||||
impl TaskBase {
|
||||
pub(crate) fn cwd(&self) -> &Path {
|
||||
match self {
|
||||
Self::Worktree { cwd, .. } | Self::Local { cwd, .. } => cwd,
|
||||
}
|
||||
}
|
||||
|
||||
/// The files the task changed, less those changed before it started and untouched since.
|
||||
pub(crate) fn changes(&self, before: &BTreeMap<String, Stamp>) -> IdeResult<Vec<AgentChange>> {
|
||||
let (cwd, start) = match self {
|
||||
Self::Worktree { cwd, base } => return Ok(without_untouched(changes(cwd, base)?, before)),
|
||||
Self::Local { cwd, start } => (cwd, start.as_deref()),
|
||||
};
|
||||
let Some(repo) = discover(cwd)? else { return Ok(Vec::new()) };
|
||||
// What is uncommitted, as the Commit tool window lists it: commits made since the task
|
||||
// started drop out.
|
||||
let head = repo.branch_info()?.head_commit;
|
||||
let listed = match &head {
|
||||
Some(head) => changes(cwd, head)?,
|
||||
None => {
|
||||
let mut all: Vec<AgentChange> = repo
|
||||
.status()?
|
||||
.into_iter()
|
||||
.filter(|s| s.path.is_file())
|
||||
.map(|s| added(s.path, s.rel_path))
|
||||
.collect();
|
||||
all.sort_by(|a, b| a.rel_path.cmp(&b.rel_path));
|
||||
all
|
||||
}
|
||||
};
|
||||
let listed = without_untouched(listed, before);
|
||||
let Some(start) = start.filter(|s| head.as_deref() != Some(*s)) else { return Ok(listed) };
|
||||
// HEAD moved (a reset, a checkout, a pull): a file that is as it was when the task
|
||||
// started is the user's doing, not the task's. Git compares tracked files, with its
|
||||
// line-ending and other filters: the bytes on disk and in the commit differ under
|
||||
// `core.autocrlf`.
|
||||
let since_start: std::collections::HashSet<String> = repo
|
||||
.diff_with_worktree(start)?
|
||||
.into_iter()
|
||||
.flat_map(|f| [Some(f.path), f.old_path].into_iter().flatten())
|
||||
.collect();
|
||||
let untracked: std::collections::HashSet<String> = repo
|
||||
.status()?
|
||||
.into_iter()
|
||||
.filter(|s| s.unstaged == Some(ChangeKind::Untracked))
|
||||
.map(|s| s.rel_path)
|
||||
.collect();
|
||||
Ok(listed
|
||||
.into_iter()
|
||||
.filter(|c| {
|
||||
if before.contains_key(&c.rel_path) {
|
||||
return true;
|
||||
}
|
||||
let Ok(Some(at_start)) = repo.file_bytes_at(&c.path.0, Some(start)) else { return true };
|
||||
if !untracked.contains(&c.rel_path) {
|
||||
return since_start.contains(&c.rel_path);
|
||||
}
|
||||
// Untracked since (a mixed reset): git doesn't diff it, so compare the bytes,
|
||||
// CRLF on disk matching LF committed.
|
||||
!std::fs::read(&c.path.0).is_ok_and(|now| now == at_start || crlf_to_lf(&now) == at_start)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// The revision the task's changed files go back to on rollback, and are compared with:
|
||||
/// the commit the task started from, or the current `HEAD` once the user committed on top
|
||||
/// of it, so those commits stay. `None` while the repository has no commits.
|
||||
pub(crate) fn rev(&self) -> IdeResult<Option<String>> {
|
||||
let (cwd, start) = match self {
|
||||
Self::Worktree { base, .. } => return Ok(Some(base.clone())),
|
||||
Self::Local { cwd, start } => (cwd, start.as_deref()),
|
||||
};
|
||||
let Some(repo) = discover(cwd)? else { return Ok(None) };
|
||||
let head = repo.branch_info()?.head_commit;
|
||||
// A start commit git no longer knows (garbage-collected) can't be gone back to.
|
||||
let start = start.filter(|s| repo.merge_base(s, s).ok().flatten().is_some());
|
||||
Ok(match (start, head) {
|
||||
(Some(start), Some(head)) if start != head && repo.merge_base(start, &head)?.as_deref() != Some(start) => {
|
||||
Some(start.to_owned())
|
||||
}
|
||||
(start, head) => head.or_else(|| start.map(str::to_owned)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Copies the task's `changes` (against `base`) from the worktree `cwd` into the
|
||||
/// local checkout `root`, uncommitted: the user reviews and commits them with the
|
||||
/// VCS tools. Files the checkout changed itself are left alone and reported, so
|
||||
@@ -220,11 +329,11 @@ pub(crate) fn save_before(cwd: &Path, before: &BTreeMap<String, Stamp>, dir: &Pa
|
||||
}
|
||||
|
||||
/// Rolls `change` back: to the copy saved when the task started when the file had changes
|
||||
/// then (`before`, `saved` holding the copies), otherwise to `base` (removed when `base`
|
||||
/// lacks it).
|
||||
/// then (`before`, `saved` holding the copies), otherwise to `rev` (removed when `rev` lacks
|
||||
/// it, or when there is no `rev`: the repository had no commits).
|
||||
pub(crate) fn restore(
|
||||
cwd: &Path,
|
||||
base: &str,
|
||||
rev: Option<&str>,
|
||||
change: &AgentChange,
|
||||
before: &BTreeMap<String, Stamp>,
|
||||
saved: Option<&Path>,
|
||||
@@ -242,10 +351,13 @@ pub(crate) fn restore(
|
||||
})?;
|
||||
Some(std::fs::read(copy)?)
|
||||
}
|
||||
None => {
|
||||
let repo = Repo::discover(cwd)?.ok_or_else(|| IdeError::invalid("not in a git repository"))?;
|
||||
repo.file_bytes_at(path, Some(base))?
|
||||
}
|
||||
None => match rev {
|
||||
Some(rev) => {
|
||||
let repo = Repo::discover(cwd)?.ok_or_else(|| IdeError::invalid("not in a git repository"))?;
|
||||
repo.file_bytes_at(path, Some(rev))?
|
||||
}
|
||||
None => None,
|
||||
},
|
||||
};
|
||||
match content {
|
||||
Some(bytes) => {
|
||||
@@ -490,6 +602,39 @@ mod tests {
|
||||
assert_eq!(again.accepted, ["a.txt", "gone.txt", "new.txt"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_local_task_goes_back_to_its_start_unless_the_user_committed_on_top_of_it() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let repo = tmp.path().join("repo");
|
||||
std::fs::create_dir(&repo).unwrap();
|
||||
git(&repo, &["init", "-q", "-b", "main"]);
|
||||
let local = |start: Option<&str>| TaskBase::Local { cwd: repo.clone(), start: start.map(str::to_owned) };
|
||||
let head = || head_of(&repo).unwrap().1;
|
||||
let commit = |name: &str| {
|
||||
std::fs::write(repo.join(name), "x\n").unwrap();
|
||||
git(&repo, &["add", "."]);
|
||||
git(&repo, &["commit", "-q", "-m", name]);
|
||||
head_of(&repo).unwrap().1
|
||||
};
|
||||
|
||||
assert_eq!(local(None).rev().unwrap(), None, "no commits yet");
|
||||
let first = commit("a.txt");
|
||||
assert_eq!(local(None).rev().unwrap(), Some(first.clone()), "an imported session: HEAD");
|
||||
let start = commit("b.txt");
|
||||
assert_eq!(local(Some(&start)).rev().unwrap(), Some(start.clone()), "HEAD where it started");
|
||||
let ahead = commit("c.txt");
|
||||
assert_eq!(local(Some(&start)).rev().unwrap(), Some(ahead), "committed on top: HEAD");
|
||||
git(&repo, &["reset", "-q", "--soft", &first]);
|
||||
assert_eq!(local(Some(&start)).rev().unwrap(), Some(start.clone()), "moved back: the start");
|
||||
git(&repo, &["checkout", "-q", "-b", "side"]);
|
||||
commit("d.txt");
|
||||
assert_eq!(local(Some(&start)).rev().unwrap(), Some(start.clone()), "another branch: the start");
|
||||
let gone = "0123456789abcdef0123456789abcdef01234567";
|
||||
assert_eq!(local(Some(gone)).rev().unwrap(), Some(head()), "an unknown start: HEAD");
|
||||
let worktree = TaskBase::Worktree { cwd: repo.clone(), base: start.clone() };
|
||||
assert_eq!(worktree.rev().unwrap(), Some(start));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accept_leaves_files_the_checkout_changed_itself() {
|
||||
let (_tmp, repo, created) = accept_repo();
|
||||
|
||||
@@ -12,11 +12,16 @@ use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
|
||||
/// `p` as the UI sees it: `C:\a\b` and `\\?\C:\a\b` become `C:/a/b`.
|
||||
/// `p` as the UI sees it: `C:\a\b`, `c:\a\b` and `\\?\C:\a\b` become `C:/a/b`.
|
||||
pub fn to_ipc(p: &Path) -> String {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
dunce::simplified(p).to_string_lossy().replace('\\', "/")
|
||||
let mut s = dunce::simplified(p).to_string_lossy().replace('\\', "/");
|
||||
// The UI compares path strings; a drive letter is case-insensitive.
|
||||
if s.as_bytes().get(1) == Some(&b':') {
|
||||
s[..1].make_ascii_uppercase();
|
||||
}
|
||||
s
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
@@ -144,5 +149,8 @@ mod tests {
|
||||
assert_eq!(to_ipc(Path::new(r"\\?\C:\a\b")), "C:/a/b");
|
||||
assert_eq!(to_ipc(&Path::new("C:/a").join("b")), "C:/a/b");
|
||||
assert_eq!(serde_json::to_string(&IpcPath::from(r"C:\a\b")).unwrap(), r#""C:/a/b""#);
|
||||
assert_eq!(to_ipc(Path::new(r"c:\a\b")), "C:/a/b");
|
||||
assert_eq!(to_ipc(Path::new(r"\\?\d:\a")), "D:/a");
|
||||
assert_eq!(to_ipc(Path::new(r"\\server\share\a")), "//server/share/a");
|
||||
}
|
||||
}
|
||||
@@ -108,9 +108,32 @@ impl Default for DeploymentSettings {
|
||||
pub enum DeploymentType {
|
||||
#[default]
|
||||
Sftp,
|
||||
Ftp,
|
||||
/// FTP secured with `AUTH TLS` (explicit FTPS).
|
||||
Ftps,
|
||||
Local,
|
||||
}
|
||||
|
||||
/// Host and login of an FTP / FTPS deployment server. The password is kept with
|
||||
/// the saved passwords, under the server's id.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct FtpConnection {
|
||||
pub host: String,
|
||||
/// `None`: 21.
|
||||
pub port: Option<u16>,
|
||||
/// Empty: anonymous login.
|
||||
pub user: String,
|
||||
/// Passive mode (the client opens data connections); off: active mode.
|
||||
pub passive: bool,
|
||||
}
|
||||
|
||||
impl Default for FtpConnection {
|
||||
fn default() -> Self {
|
||||
Self { host: String::new(), port: None, user: String::new(), passive: true }
|
||||
}
|
||||
}
|
||||
|
||||
/// A deployment filesystem and how project folders map onto it.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
@@ -120,6 +143,8 @@ pub struct DeploymentServer {
|
||||
pub name: String,
|
||||
/// [`SshConfiguration::id`].
|
||||
pub ssh_config: String,
|
||||
/// FTP / FTPS servers.
|
||||
pub ftp: FtpConnection,
|
||||
/// Absolute remote folder the mappings are relative to; empty = the login directory.
|
||||
pub root_path: String,
|
||||
/// Web server URL of the root (Open in Browser on the deployed file).
|
||||
@@ -225,8 +250,11 @@ mod deployment_tests {
|
||||
serde_json::from_str(r#"{"id":"existing","sshConfig":"ssh","rootPath":"/srv"}"#).unwrap();
|
||||
assert_eq!(legacy.kind, DeploymentType::Sftp);
|
||||
assert_eq!(legacy.ssh_config, "ssh");
|
||||
for kind in [DeploymentType::Sftp, DeploymentType::Local] {
|
||||
let server = DeploymentServer { kind, ..legacy.clone() };
|
||||
assert!(legacy.ftp.passive);
|
||||
let ftp =
|
||||
FtpConnection { host: "ftp.example.com".into(), port: Some(2121), user: "web".into(), passive: false };
|
||||
for kind in [DeploymentType::Sftp, DeploymentType::Ftp, DeploymentType::Ftps, DeploymentType::Local] {
|
||||
let server = DeploymentServer { kind, ftp: ftp.clone(), ..legacy.clone() };
|
||||
assert_eq!(
|
||||
serde_json::from_str::<DeploymentServer>(&serde_json::to_string(&server).unwrap()).unwrap(),
|
||||
server
|
||||
|
||||
@@ -805,6 +805,9 @@ pub struct FileSettings {
|
||||
pub file_types: BTreeMap<String, FileTypeAssociation>,
|
||||
/// Override File Type: file (project-relative and `/`-separated, or absolute) → language id.
|
||||
pub file_type_overrides: BTreeMap<String, String>,
|
||||
/// File Types › Files opened in associated applications: file-name patterns
|
||||
/// (`*.xlsx`) opened with the system application instead of an editor.
|
||||
pub associated_applications: Vec<String>,
|
||||
/// Compose detection choices, keyed by project-relative file path.
|
||||
pub compose_file_overrides: BTreeMap<String, ComposeFileOverride>,
|
||||
}
|
||||
@@ -841,6 +844,15 @@ impl Default for FileSettings {
|
||||
local_history_max_mb: 512,
|
||||
file_types: BTreeMap::new(),
|
||||
file_type_overrides: BTreeMap::new(),
|
||||
associated_applications: vec![
|
||||
"*.xlsx".into(),
|
||||
"*.xls".into(),
|
||||
"*.docx".into(),
|
||||
"*.doc".into(),
|
||||
"*.pptx".into(),
|
||||
"*.ppt".into(),
|
||||
"*.pdf".into(),
|
||||
],
|
||||
compose_file_overrides: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
@@ -1191,6 +1203,8 @@ pub struct MavenSettings {
|
||||
pub threads: String,
|
||||
/// Active Maven profiles (`-P`), comma-separated; empty leaves profiles to the POM/settings.
|
||||
pub active_profiles: String,
|
||||
/// Delegate IDE build/run actions to Maven: launches build with Maven (`javac`) instead of jdtls (ECJ).
|
||||
pub delegate_build: bool,
|
||||
}
|
||||
|
||||
impl MavenSettings {
|
||||
@@ -1695,10 +1709,12 @@ mod maven_linkage_tests {
|
||||
fn maven_runner_jre_defaults_and_round_trips() {
|
||||
let old: MavenSettings = serde_json::from_str(r#"{"home":"/opt/maven"}"#).unwrap();
|
||||
assert!(old.runner_jre.is_empty());
|
||||
assert!(!old.delegate_build);
|
||||
assert!(MavenSettings::default().runner_jre.is_empty());
|
||||
let settings = MavenSettings { runner_jre: "/opt/jdk-21".into(), ..old };
|
||||
let settings = MavenSettings { runner_jre: "/opt/jdk-21".into(), delegate_build: true, ..old };
|
||||
let encoded = serde_json::to_string(&settings).unwrap();
|
||||
assert!(encoded.contains(r#""runnerJre":"/opt/jdk-21""#));
|
||||
assert!(encoded.contains(r#""delegateBuild":true"#));
|
||||
assert_eq!(serde_json::from_str::<MavenSettings>(&encoded).unwrap(), settings);
|
||||
}
|
||||
|
||||
|
||||
@@ -138,8 +138,11 @@ impl WorkspaceEdit {
|
||||
continue;
|
||||
}
|
||||
*expected = expected.or(version);
|
||||
// Repeats within one entry are intended (LSP inserts at one position
|
||||
// apply in order); only an alias entry repeats what is already merged.
|
||||
let merged = own.len();
|
||||
for edit in edits {
|
||||
if !own.contains(&edit) {
|
||||
if !own[..merged].contains(&edit) {
|
||||
own.push(edit);
|
||||
}
|
||||
}
|
||||
@@ -277,10 +280,40 @@ pub struct OutlineItem {
|
||||
/// Fuzzy-match highlight: indices (in chars of the displayed string) that matched.
|
||||
pub type MatchIndices = Vec<u32>;
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
#[cfg(test)]
|
||||
mod workspace_edit_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn repeated_edits_within_one_entry_survive_but_repeats_across_entries_do_not() {
|
||||
let path = PathBuf::from("missing-dir/main.ts");
|
||||
let at = |line, text: &str| TextEdit {
|
||||
range: Range::new(Pos::new(line, 0), Pos::new(line, 0)),
|
||||
new_text: text.into(),
|
||||
};
|
||||
let entry = |edits: Vec<TextEdit>| FileEdit::Edit { path: path.clone().into(), edits, version: None };
|
||||
let (nl, brace, other) = (at(0, "\n"), at(1, "}"), at(2, "x"));
|
||||
for (entries, expected) in [
|
||||
(vec![vec![nl.clone(), nl.clone()]], vec![nl.clone(), nl.clone()]),
|
||||
(vec![vec![brace.clone(), nl.clone(), brace.clone()]], vec![brace.clone(), nl.clone(), brace.clone()]),
|
||||
(vec![vec![nl.clone(), nl.clone()], vec![nl.clone(), nl.clone()]], vec![nl.clone(), nl.clone()]),
|
||||
(vec![vec![nl.clone()], vec![nl.clone(), other.clone()]], vec![nl.clone(), other.clone()]),
|
||||
(
|
||||
vec![vec![nl.clone()], vec![other.clone(), other.clone()]],
|
||||
vec![nl.clone(), other.clone(), other.clone()],
|
||||
),
|
||||
] {
|
||||
let mut workspace =
|
||||
WorkspaceEdit { files: entries.clone().into_iter().map(entry).collect(), ..Default::default() };
|
||||
workspace.deduplicate();
|
||||
assert_eq!(workspace.files, [entry(expected.clone())], "{entries:?}");
|
||||
let once = workspace.clone();
|
||||
workspace.deduplicate();
|
||||
assert_eq!(workspace, once, "{entries:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn aliases_are_one_edit_but_resource_operations_preserve_order() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -621,9 +621,9 @@ fn step_settled(session: &Session, sent: Result<Value, DapError>) -> Result<Valu
|
||||
sent
|
||||
}
|
||||
|
||||
/// Delve compiles the debuggee to `__debug_bin` in the session's cwd (on Windows
|
||||
/// `__debug_bin.exe` plus `__debug_bin.exe<digits>` in DAP mode) and removes it on a
|
||||
/// graceful exit — a shutdown it doesn't always reach, so the files pile up. Sweep
|
||||
/// Delve compiles the debuggee to `__debug_bin` in the session's cwd (in DAP mode a
|
||||
/// temp-file name, `__debug_bin<digits>`; on Windows with `.exe` before the digits) and
|
||||
/// removes it on a graceful exit — a shutdown it doesn't always reach, so the files pile up. Sweep
|
||||
/// the leftovers once the session is down. Runs after `session_closed`, so Delve
|
||||
/// has already exited or been killed and holds no file locks; a leftover of a
|
||||
/// still-building run is removed on that run's own shutdown.
|
||||
@@ -632,14 +632,10 @@ fn remove_delve_binaries(cwd: &Path) {
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name();
|
||||
let Some(name) = name.to_str() else { continue };
|
||||
// Windows writes `__debug_bin.exe` (and `.exe<digits>` in DAP mode); other
|
||||
// platforms keep the bare name. Suffixes like `.go` are user files.
|
||||
let suffix = name.strip_prefix("__debug_bin");
|
||||
let leftover = suffix.is_some_and(|s| {
|
||||
s.is_empty()
|
||||
|| s == ".exe"
|
||||
|| (s.starts_with(".exe") && s[".exe".len()..].chars().all(|c| c.is_ascii_digit()))
|
||||
});
|
||||
// `__debug_bin`, then `.exe` on Windows, then DAP mode's temp-file digits.
|
||||
// Suffixes like `.go` are user files.
|
||||
let suffix = name.strip_prefix("__debug_bin").map(|s| s.strip_prefix(".exe").unwrap_or(s));
|
||||
let leftover = suffix.is_some_and(|s| s.chars().all(|c| c.is_ascii_digit()));
|
||||
if !leftover || entry.file_type().is_ok_and(|t| !t.is_file()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1472,6 +1472,7 @@ fn godot_registry(port: u16, command: &str, args: &[&str]) -> Arc<LanguageRegist
|
||||
indent: None,
|
||||
ui_mode: None,
|
||||
framework_ui_modes: Vec::new(),
|
||||
framework_files: Vec::new(),
|
||||
formatter: None,
|
||||
scratch_template: None,
|
||||
tree_sitter: None,
|
||||
@@ -2095,20 +2096,20 @@ async fn stop_kills_the_program_started_by_the_reported_launcher() {
|
||||
fn delve_leftovers_are_removed_without_touching_other_files() {
|
||||
let dir = std::env::temp_dir().join(format!("openidea-delve-bin-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
for name in ["__debug_bin", "__debug_bin.exe", "__debug_bin.exe3323301925"] {
|
||||
for name in ["__debug_bin", "__debug_bin833767175", "__debug_bin.exe", "__debug_bin.exe3323301925"] {
|
||||
std::fs::write(dir.join(name), b"bin").unwrap();
|
||||
}
|
||||
// Lookalikes that are not Delve's own binaries stay: sources, a future
|
||||
// in-progress build, and a directory that happens to share the name.
|
||||
for name in ["__debug_bin.go", "__debug_bin.go.exe", "main.go"] {
|
||||
for name in ["__debug_bin.go", "__debug_bin.go.exe", "__debug_bin12.go", "main.go"] {
|
||||
std::fs::write(dir.join(name), b"keep").unwrap();
|
||||
}
|
||||
std::fs::create_dir(dir.join("__debug_bin.dir")).unwrap();
|
||||
remove_delve_binaries(&dir);
|
||||
for name in ["__debug_bin", "__debug_bin.exe", "__debug_bin.exe3323301925"] {
|
||||
for name in ["__debug_bin", "__debug_bin833767175", "__debug_bin.exe", "__debug_bin.exe3323301925"] {
|
||||
assert!(!dir.join(name).exists(), "{name} should be gone");
|
||||
}
|
||||
for name in ["__debug_bin.go", "__debug_bin.go.exe", "main.go", "__debug_bin.dir"] {
|
||||
for name in ["__debug_bin.go", "__debug_bin.go.exe", "__debug_bin12.go", "main.go", "__debug_bin.dir"] {
|
||||
assert!(dir.join(name).exists(), "{name} should stay");
|
||||
}
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
|
||||
@@ -17,6 +17,7 @@ blake3.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
parking_lot.workspace = true
|
||||
notify.workspace = true
|
||||
futures.workspace = true
|
||||
sqlx.workspace = true
|
||||
tiberius.workspace = true
|
||||
|
||||
@@ -12,6 +12,8 @@ pub mod secrets;
|
||||
pub mod source;
|
||||
pub mod split;
|
||||
pub mod sql;
|
||||
mod watch;
|
||||
pub use watch::SourcesWatch;
|
||||
|
||||
pub use conn::{Held, Pool, QueryResult, ResultColumn};
|
||||
pub use history::HistoryEntry;
|
||||
@@ -235,6 +237,9 @@ pub struct DbManager {
|
||||
/// Manual-commit data sources, each with the connection of its open
|
||||
/// transaction (taken by the first statement after a commit / rollback).
|
||||
sessions: Mutex<HashMap<String, Session>>,
|
||||
/// The data sources as last read from disk, for [`DbManager::reconcile_sources`]
|
||||
/// (`None` until [`DbManager::watch_sources`]).
|
||||
on_disk: Mutex<Option<Vec<DataSource>>>,
|
||||
}
|
||||
|
||||
type Session = Arc<tokio::sync::Mutex<Option<Held>>>;
|
||||
@@ -362,6 +367,7 @@ impl DbManager {
|
||||
tunnel_open: tokio::sync::Mutex::new(()),
|
||||
ssh,
|
||||
session_passwords: Mutex::default(),
|
||||
on_disk: Mutex::default(),
|
||||
sessions: Mutex::default(),
|
||||
}
|
||||
}
|
||||
@@ -474,9 +480,45 @@ impl DbManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Our own write is not reported as an edit on disk.
|
||||
if let Some(known) = self.on_disk.lock().as_mut() {
|
||||
*known = self.sources().unwrap_or(sources);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Watches the data source files (the project's and the global one) for edits made
|
||||
/// outside the IDE; `on_change` runs on a background thread once they settle, and
|
||||
/// should call [`DbManager::reconcile_sources`]. Stops when the result is dropped.
|
||||
pub fn watch_sources(&self, on_change: Box<dyn Fn() + Send>) -> IdeResult<SourcesWatch> {
|
||||
*self.on_disk.lock() = Some(self.sources().unwrap_or_default());
|
||||
SourcesWatch::start(&self.root, self.global.as_ref().map(|(dir, _)| dir.as_path()), on_change)
|
||||
}
|
||||
|
||||
/// Re-reads the data sources after their files changed on disk: connections of
|
||||
/// removed or changed sources close, as on [`DbManager::save_sources`] (saved
|
||||
/// passwords stay, the source may come back). Returns whether the list the tree
|
||||
/// shows changed, or the files no longer parse (the tree reports the error).
|
||||
pub async fn reconcile_sources(&self) -> bool {
|
||||
let now = match self.sources() {
|
||||
Ok(now) => now,
|
||||
Err(e) => {
|
||||
tracing::warn!("data sources: {}", e.message);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
let before = self.on_disk.lock().replace(now.clone()).unwrap_or_default();
|
||||
if before == now {
|
||||
return false;
|
||||
}
|
||||
for o in &before {
|
||||
if now.iter().find(|s| s.id == o.id) != Some(o) {
|
||||
self.disconnect_source(&o.id).await;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// 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<()> {
|
||||
|
||||
@@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::path::Path;
|
||||
|
||||
const FILE: &str = "data-sources.toml";
|
||||
pub(crate) const FILE: &str = "data-sources.toml";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
//! Data source files edited outside the Database tool window (by hand, by a coding
|
||||
//! agent, by `git checkout`) are picked up while the project is open, as IDEA does
|
||||
//! for `dataSources.xml`. The project file tree's watcher is no help here: it never
|
||||
//! reports a modification inside a gitignored `.openidea/`.
|
||||
|
||||
use crate::source::FILE;
|
||||
use ide_core::{IdeError, IdeResult, project_dir};
|
||||
use notify::{RecommendedWatcher, RecursiveMode, Watcher};
|
||||
use parking_lot::Mutex;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Weak, mpsc};
|
||||
use std::time::Duration;
|
||||
|
||||
/// How long a burst of writes (an editor's save, an agent's rewrite) settles before
|
||||
/// the files are read.
|
||||
const SETTLE: Duration = Duration::from_millis(200);
|
||||
|
||||
/// Watches `<root>/.openidea/data-sources.toml` and `<global>/data-sources.toml`;
|
||||
/// stops when dropped.
|
||||
pub struct SourcesWatch {
|
||||
_watcher: Arc<Mutex<RecommendedWatcher>>,
|
||||
}
|
||||
|
||||
impl SourcesWatch {
|
||||
/// Calls `on_change` once a burst of changes to either file settled, on a
|
||||
/// background thread.
|
||||
pub fn start(root: &Path, global: Option<&Path>, on_change: Box<dyn Fn() + Send>) -> IdeResult<Self> {
|
||||
let config: PathBuf = root.join(project_dir::DIR);
|
||||
let (tx, rx) = mpsc::channel::<()>();
|
||||
let relevant = {
|
||||
let config = config.clone();
|
||||
move |p: &Path| p.file_name().is_some_and(|n| n == FILE) || p == config
|
||||
};
|
||||
let watcher = notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
|
||||
if let Ok(e) = res
|
||||
&& e.paths.iter().any(|p| relevant(p))
|
||||
{
|
||||
let _ = tx.send(());
|
||||
}
|
||||
})
|
||||
.map_err(|e| IdeError::internal(format!("data sources watcher: {e}")))?;
|
||||
let watcher = Arc::new(Mutex::new(watcher));
|
||||
{
|
||||
let mut w = watcher.lock();
|
||||
// The root (not recursively) sees `.openidea/` itself appear or go.
|
||||
w.watch(root, RecursiveMode::NonRecursive)
|
||||
.map_err(|e| IdeError::internal(format!("data sources watcher: {e}")))?;
|
||||
if let Some(dir) = global.filter(|d| d.is_dir())
|
||||
&& let Err(e) = w.watch(dir, RecursiveMode::NonRecursive)
|
||||
{
|
||||
tracing::warn!("global data sources watcher: {e}");
|
||||
}
|
||||
}
|
||||
let mut watching = watch_config(&watcher, &config, false);
|
||||
let weak: Weak<Mutex<RecommendedWatcher>> = Arc::downgrade(&watcher);
|
||||
std::thread::Builder::new().name("openidea-db-sources-watch".into()).spawn(move || {
|
||||
while rx.recv().is_ok() {
|
||||
// Wait until the writes stop.
|
||||
loop {
|
||||
match rx.recv_timeout(SETTLE) {
|
||||
Ok(()) => {}
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => break,
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => return,
|
||||
}
|
||||
}
|
||||
let Some(watcher) = weak.upgrade() else { return };
|
||||
watching = watch_config(&watcher, &config, watching);
|
||||
drop(watcher);
|
||||
on_change();
|
||||
}
|
||||
})?;
|
||||
Ok(Self { _watcher: watcher })
|
||||
}
|
||||
}
|
||||
|
||||
/// Watches `.openidea/` once it exists (again: a removed folder loses its watch);
|
||||
/// returns whether it is watched.
|
||||
fn watch_config(watcher: &Mutex<RecommendedWatcher>, config: &Path, watching: bool) -> bool {
|
||||
if !config.is_dir() {
|
||||
return false;
|
||||
}
|
||||
if watching {
|
||||
return true;
|
||||
}
|
||||
match watcher.lock().watch(config, RecursiveMode::NonRecursive) {
|
||||
Ok(()) => true,
|
||||
Err(e) => {
|
||||
tracing::warn!("data sources watcher {}: {e}", config.display());
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::Instant;
|
||||
|
||||
fn wait_for(count: &AtomicUsize, at_least: usize) -> bool {
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
while Instant::now() < deadline {
|
||||
if count.load(Ordering::SeqCst) >= at_least {
|
||||
return true;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn counting() -> (Arc<AtomicUsize>, Box<dyn Fn() + Send>) {
|
||||
let count = Arc::new(AtomicUsize::new(0));
|
||||
let c = count.clone();
|
||||
(count, Box::new(move || _ = c.fetch_add(1, Ordering::SeqCst)))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reports_project_and_global_files_and_a_config_folder_created_later() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let (project, global) = (root.join("p"), root.join("app"));
|
||||
std::fs::create_dir_all(&project).unwrap();
|
||||
std::fs::create_dir_all(&global).unwrap();
|
||||
let (count, on_change) = counting();
|
||||
let _watch = SourcesWatch::start(&project, Some(&global), on_change).unwrap();
|
||||
|
||||
// No `.openidea/` yet: it is created, then the file written into it.
|
||||
std::fs::create_dir_all(project.join(".openidea")).unwrap();
|
||||
assert!(wait_for(&count, 1));
|
||||
let seen = count.load(Ordering::SeqCst);
|
||||
std::fs::write(project.join(".openidea/data-sources.toml"), "").unwrap();
|
||||
assert!(wait_for(&count, seen + 1), "project file");
|
||||
|
||||
let seen = count.load(Ordering::SeqCst);
|
||||
std::fs::write(global.join("data-sources.toml"), "").unwrap();
|
||||
assert!(wait_for(&count, seen + 1), "global file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_other_files() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
std::fs::create_dir_all(root.join(".openidea")).unwrap();
|
||||
let (count, on_change) = counting();
|
||||
let _watch = SourcesWatch::start(&root, None, on_change).unwrap();
|
||||
std::fs::write(root.join(".openidea/settings.toml"), "").unwrap();
|
||||
std::fs::write(root.join("main.py"), "").unwrap();
|
||||
std::thread::sleep(SETTLE * 3);
|
||||
assert_eq!(count.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
}
|
||||
@@ -342,3 +342,31 @@ async fn applies_driver_properties_and_the_startup_script_on_connect() -> IdeRes
|
||||
assert_eq!(db.schemas("s1").await?, vec!["main"]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn data_sources_edited_on_disk_close_their_connections() -> IdeResult<()> {
|
||||
let (tmp, db) = setup().await;
|
||||
std::fs::File::create(tmp.path().join("other.db")).unwrap();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let _watch = db.watch_sources(Box::new(move || _ = tx.send(())))?;
|
||||
db.pool("s1").await?;
|
||||
assert_eq!(db.connected(), vec!["s1"]);
|
||||
|
||||
// Our own save is no edit on disk.
|
||||
let mut sources = db.sources()?;
|
||||
sources[0].name = "renamed".into();
|
||||
db.save_sources(sources.clone()).await?;
|
||||
assert!(!db.reconcile_sources().await);
|
||||
|
||||
// Edited outside the IDE (by hand, by an agent): the watcher reports it, and the
|
||||
// connection made with the old settings closes.
|
||||
db.pool("s1").await?;
|
||||
sources[0].file = Some("other.db".into());
|
||||
ide_db::source::save(tmp.path(), &sources)?;
|
||||
rx.recv_timeout(std::time::Duration::from_secs(10)).expect("the watcher reports the edit");
|
||||
assert!(db.reconcile_sources().await);
|
||||
assert!(db.connected().is_empty());
|
||||
assert_eq!(db.sources()?[0].file.as_ref().map(|f| f.0.clone()), Some("other.db".into()));
|
||||
assert!(!db.reconcile_sources().await);
|
||||
Ok(())
|
||||
}
|
||||
@@ -84,6 +84,14 @@ pub struct NotebookService {
|
||||
registry: Arc<Mutex<Registry>>,
|
||||
kernels: Arc<KernelManager>,
|
||||
sink: SharedSink,
|
||||
observer: parking_lot::RwLock<Option<Arc<dyn NotebookObserver>>>,
|
||||
}
|
||||
|
||||
/// Learns each open notebook's contents after every change of them, and when it closes (the
|
||||
/// language servers' copy of its code cells).
|
||||
pub trait NotebookObserver: Send + Sync {
|
||||
fn changed(&self, path: &Path, snapshot: &NotebookSnapshot);
|
||||
fn closed(&self, path: &Path);
|
||||
}
|
||||
|
||||
impl NotebookService {
|
||||
@@ -121,7 +129,23 @@ impl NotebookService {
|
||||
}
|
||||
}),
|
||||
);
|
||||
Arc::new(Self { registry, kernels: Arc::new(kernels), sink })
|
||||
Arc::new(Self { registry, kernels: Arc::new(kernels), sink, observer: Default::default() })
|
||||
}
|
||||
|
||||
pub fn set_observer(&self, observer: Arc<dyn NotebookObserver>) {
|
||||
*self.observer.write() = Some(observer);
|
||||
}
|
||||
|
||||
/// Called with the document's state locked, so observers see its changes in order.
|
||||
fn changed(&self, path: &Path, snapshot: &NotebookSnapshot) {
|
||||
if let Some(observer) = self.observer.read().clone() {
|
||||
observer.changed(path, snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
/// The file of an open notebook.
|
||||
pub fn path_of(&self, id: &str) -> IdeResult<PathBuf> {
|
||||
Ok(self.resolve(id)?.path.clone())
|
||||
}
|
||||
|
||||
pub fn open(&self, path: &Path) -> IdeResult<NotebookSnapshot> {
|
||||
@@ -136,6 +160,7 @@ impl NotebookService {
|
||||
}
|
||||
let document = NotebookDocument::load(&path)?;
|
||||
let initial = document.snapshot();
|
||||
self.changed(&path, &initial);
|
||||
let id = initial.id.clone();
|
||||
registry.paths.insert(path.clone(), id.clone());
|
||||
registry.documents.insert(
|
||||
@@ -173,7 +198,9 @@ impl NotebookService {
|
||||
let document = self.resolve(id)?;
|
||||
let mut state = document.state.lock();
|
||||
mutable(&state)?;
|
||||
state.document.edit(version, edit)
|
||||
let snapshot = state.document.edit(version, edit)?;
|
||||
self.changed(&document.path, &snapshot);
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
pub fn save(&self, id: &str) -> IdeResult<NotebookSnapshot> {
|
||||
@@ -190,7 +217,9 @@ impl NotebookService {
|
||||
if state.active.is_some() {
|
||||
return Err(IdeError::unavailable("stop notebook execution before undo or redo"));
|
||||
}
|
||||
if redo { state.document.redo() } else { state.document.undo() }
|
||||
let snapshot = if redo { state.document.redo() } else { state.document.undo() }?;
|
||||
self.changed(&document.path, &snapshot);
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
pub async fn interrupt(&self, id: &str) -> IdeResult<()> {
|
||||
@@ -238,7 +267,10 @@ impl NotebookService {
|
||||
snapshot(&document)
|
||||
} else {
|
||||
self.kernels.shutdown_kernel(&document.path).await?;
|
||||
document.state.lock().document.reload()
|
||||
let mut state = document.state.lock();
|
||||
let snapshot = state.document.reload()?;
|
||||
self.changed(&document.path, &snapshot);
|
||||
Ok(snapshot)
|
||||
}
|
||||
}
|
||||
.await;
|
||||
@@ -284,6 +316,10 @@ impl NotebookService {
|
||||
let mut registry = self.registry.lock();
|
||||
registry.documents.remove(id);
|
||||
registry.paths.remove(&document.path);
|
||||
drop(registry);
|
||||
if let Some(observer) = self.observer.read().clone() {
|
||||
observer.closed(&document.path);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -572,6 +608,49 @@ mod tests {
|
||||
KernelOutput { kind: kind.into(), content, msg_id: "actual-parent-request".into() }
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_observer_follows_every_change_of_an_open_notebook_and_its_close() {
|
||||
#[derive(Default)]
|
||||
struct Seen(Mutex<Vec<String>>);
|
||||
impl NotebookObserver for Seen {
|
||||
fn changed(&self, path: &Path, snapshot: &NotebookSnapshot) {
|
||||
let sources: Vec<&str> = snapshot.cells.iter().map(|c| c.source.as_str()).collect();
|
||||
let name = path.file_name().unwrap().to_string_lossy();
|
||||
self.0.lock().push(format!("{name}: {}", sources.join(" | ")));
|
||||
}
|
||||
fn closed(&self, path: &Path) {
|
||||
self.0.lock().push(format!("closed {}", path.file_name().unwrap().to_string_lossy()));
|
||||
}
|
||||
}
|
||||
let (dir, service, initial) = fixture();
|
||||
let seen = Arc::new(Seen::default());
|
||||
service.set_observer(seen.clone());
|
||||
service.close(&initial.id).await.unwrap();
|
||||
let opened = service.open(&dir.path().join("session.ipynb")).unwrap();
|
||||
assert_eq!(
|
||||
service.path_of(&opened.id).unwrap(),
|
||||
dunce::canonicalize(dir.path().join("session.ipynb")).unwrap()
|
||||
);
|
||||
let edit = NotebookEdit::SetSource { cell: "second".into(), source: "print(x + 1)".into() };
|
||||
let edited = service.edit(&opened.id, opened.version, edit).unwrap();
|
||||
service.undo(&opened.id, false).unwrap();
|
||||
// A second view of the open notebook changes nothing.
|
||||
service.open(&dir.path().join("session.ipynb")).unwrap();
|
||||
assert!(edited.version > opened.version);
|
||||
service.close(&opened.id).await.unwrap();
|
||||
assert_eq!(
|
||||
*seen.0.lock(),
|
||||
[
|
||||
"closed session.ipynb",
|
||||
"session.ipynb: x = 1 | print(x) | notes",
|
||||
"session.ipynb: x = 1 | print(x + 1) | notes",
|
||||
"session.ipynb: x = 1 | print(x) | notes",
|
||||
"closed session.ipynb",
|
||||
]
|
||||
);
|
||||
service.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn canonical_split_opens_share_incarnation_but_reopen_and_projects_do_not() {
|
||||
let (dir, service, initial) = fixture();
|
||||
|
||||
@@ -169,6 +169,25 @@ pub async fn remote_rename(state: &Host, connection: FsConnection, from: String,
|
||||
state.remote.remote_rename(&connection, &from, &to).await
|
||||
}
|
||||
|
||||
/// Remote Host › Download to…: remote files and folders into a local folder, each under its
|
||||
/// own name.
|
||||
pub async fn remote_download_into(
|
||||
window: &str,
|
||||
state: &Host,
|
||||
connection: FsConnection,
|
||||
paths: Vec<String>,
|
||||
target: IpcPath,
|
||||
request: RequestId,
|
||||
) -> IdeResult<TransferReport> {
|
||||
let project = state.project(window)?;
|
||||
let token = state.begin_request(request);
|
||||
let (mut report, done) = progress(project.sink.clone(), "Downloading", token.clone());
|
||||
let r = state.remote.download_into(&connection, &paths, &target, &token, &mut report).await;
|
||||
done();
|
||||
state.end_request(request);
|
||||
r
|
||||
}
|
||||
|
||||
pub async fn deploy_test_connection(state: &Host, connection: FsConnection, path: String) -> IdeResult<()> {
|
||||
state.remote.test_connection(&connection, &path).await
|
||||
}
|
||||
@@ -197,6 +216,17 @@ pub(crate) fn register(d: &mut crate::rpc::Dispatcher) {
|
||||
d.add("remote_of", |c| async move { Ok(remote_of(&c.host, c.arg("path")?)) });
|
||||
d.add("remote_delete", |c| async move { remote_delete(&c.host, c.arg("connection")?, c.arg("path")?).await });
|
||||
d.add("remote_mkdir", |c| async move { remote_mkdir(&c.host, c.arg("connection")?, c.arg("path")?).await });
|
||||
d.add("remote_download_into", |c| async move {
|
||||
remote_download_into(
|
||||
&c.window,
|
||||
&c.host,
|
||||
c.arg("connection")?,
|
||||
c.arg("paths")?,
|
||||
c.arg("target")?,
|
||||
c.arg("request")?,
|
||||
)
|
||||
.await
|
||||
});
|
||||
d.add("remote_rename", |c| async move {
|
||||
remote_rename(&c.host, c.arg("connection")?, c.arg("from")?, c.arg("to")?).await
|
||||
});
|
||||
|
||||
@@ -19,7 +19,17 @@ pub async fn blocking<T: Send + 'static>(f: impl FnOnce() -> IdeResult<T> + Send
|
||||
pub async fn doc_open_virtual(host: &Host, window: &str, uri: String) -> IdeResult<DocumentInfo> {
|
||||
let project = host.project(window)?;
|
||||
let vd = crate::ops::lsp::lsp_virtual_document(host, window, uri).await?;
|
||||
blocking(move || project.documents.open_virtual(&vd.uri, vd.language_id, &vd.text)).await
|
||||
blocking(move || {
|
||||
project.documents.open_virtual(
|
||||
&vd.uri,
|
||||
vd.language_id,
|
||||
&vd.text,
|
||||
&vd.encoding,
|
||||
vd.has_bom,
|
||||
vd.can_reload_encoding,
|
||||
)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// The CodeMirror mode `path` gets when opened (`DocumentInfo::ui_mode`),
|
||||
@@ -30,6 +40,8 @@ pub async fn doc_ui_mode(host: &Host, window: &str, path: PathBuf) -> IdeResult<
|
||||
}
|
||||
|
||||
/// "Reload with Encoding" (`reload`) or "Convert to Encoding"; returns the new encoding and BOM state.
|
||||
/// Virtual documents re-read their origin (archive, language server, Git blob)
|
||||
/// instead of a disk file; converting them would write to it, so it stays refused.
|
||||
pub async fn doc_set_encoding(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
@@ -38,6 +50,14 @@ pub async fn doc_set_encoding(
|
||||
reload: bool,
|
||||
) -> IdeResult<DocumentEncoding> {
|
||||
let project = host.project(window)?;
|
||||
if reload && let Some(uri) = project.documents.virtual_uri(doc)? {
|
||||
let vd = if uri.starts_with("git-revision:") {
|
||||
crate::ops::vcs::revision_virtual_document(host, window, &uri, Some(&encoding)).await?
|
||||
} else {
|
||||
project.lsp.reload_virtual(&uri, &encoding).await?
|
||||
};
|
||||
return host.blocking(move || project.documents.replace_virtual(doc, &vd.text, &vd.encoding, vd.has_bom)).await;
|
||||
}
|
||||
host.blocking(move || project.documents.set_encoding(doc, &encoding, reload)).await
|
||||
}
|
||||
|
||||
|
||||
@@ -176,8 +176,11 @@ async fn binary_and_virtual_documents_are_skipped() {
|
||||
std::fs::write(&path, b"\0\0binary payload").unwrap();
|
||||
let binary = f.open(&path).await;
|
||||
assert!(binary.read_only);
|
||||
let virtual_doc =
|
||||
f.project.documents.open_virtual("test://virtual", ide_core::LanguageId::plain_text(), "virtual").unwrap();
|
||||
let virtual_doc = f
|
||||
.project
|
||||
.documents
|
||||
.open_virtual("test://virtual", ide_core::LanguageId::plain_text(), "virtual", "utf-8", false, false)
|
||||
.unwrap();
|
||||
std::fs::write(&path, "now ordinary text").unwrap();
|
||||
f.events.lock().clear();
|
||||
f.sync().await;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use super::Channel;
|
||||
use crate::Host;
|
||||
use ide_core::{IdeResult, RequestId};
|
||||
use ide_core::{IdeError, IdeResult, RequestId};
|
||||
use ide_vfs::FileEntry;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub async fn entry_kind(host: &Host, path: PathBuf) -> IdeResult<ide_vfs::EntryKind> {
|
||||
host.blocking(move || {
|
||||
@@ -165,3 +165,46 @@ pub async fn read_text(host: &Host, window: &str, path: PathBuf) -> IdeResult<St
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// The file `window` may hand to the OS's default application: `path` itself, resolved, when it is a
|
||||
/// file inside the window's project (its folder or a content root). The window must be trusted and
|
||||
/// open locally; a remote window's files live on its own machine and are not launched here.
|
||||
pub fn system_open_target(host: &Host, window: &str, path: PathBuf) -> IdeResult<PathBuf> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let real =
|
||||
dunce::canonicalize(&path).map_err(|_| IdeError::not_found(format!("{}: no such file", path.display())))?;
|
||||
if !real.is_file() {
|
||||
return Err(IdeError::invalid(format!("{}: not a file", path.display())));
|
||||
}
|
||||
let roots: Vec<PathBuf> = std::iter::once(project.root.clone())
|
||||
.chain(project.content_roots())
|
||||
.filter_map(|root| dunce::canonicalize(root).ok())
|
||||
.collect();
|
||||
if !inside_any_root(&real, &roots) {
|
||||
return Err(IdeError::invalid(format!("{}: outside the project", path.display())));
|
||||
}
|
||||
Ok(real)
|
||||
}
|
||||
|
||||
/// Whether `path` lies inside one of `roots`, comparing whole path components (`/a/bc` is not inside `/a/b`).
|
||||
pub(crate) fn inside_any_root(path: &Path, roots: &[PathBuf]) -> bool {
|
||||
roots.iter().any(|root| path.starts_with(root))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::inside_any_root;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[test]
|
||||
fn system_open_is_limited_to_paths_inside_a_project_root() {
|
||||
let roots = vec![PathBuf::from("/work/app"), PathBuf::from("/work/shared")];
|
||||
assert!(inside_any_root(Path::new("/work/app/docs/spec.pdf"), &roots));
|
||||
assert!(inside_any_root(Path::new("/work/shared/a.docx"), &roots));
|
||||
assert!(inside_any_root(Path::new("/work/app"), &roots));
|
||||
assert!(!inside_any_root(Path::new("/work/app2/spec.pdf"), &roots));
|
||||
assert!(!inside_any_root(Path::new("/home/me/.ssh/id_rsa"), &roots));
|
||||
assert!(!inside_any_root(Path::new("/work/spec.pdf"), &[]));
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,7 @@ async fn resolve_launch(
|
||||
let build = policy != BuildPolicy::NoBuild;
|
||||
let full = policy == BuildPolicy::Rebuild;
|
||||
let build_kind = if full { compile::BuildKind::Rebuild } else { compile::BuildKind::Build };
|
||||
let (model, external, delegated) = {
|
||||
let (model, external, delegated, prefer_maven) = {
|
||||
let project = project.clone();
|
||||
let settings = settings.clone();
|
||||
let module = module.clone();
|
||||
@@ -58,15 +58,17 @@ async fn resolve_launch(
|
||||
let non_java = build && compile::launch_has_non_java_sources(&project.root, &model, &module, test);
|
||||
let is_maven =
|
||||
model.maven.as_ref().is_some_and(|m| m.projects.iter().any(|p| project.root.join(&p.dir) == module));
|
||||
let prefer_maven = settings.languages.java.maven.delegate_build;
|
||||
let delegated = build
|
||||
&& delegates_to_external_build(
|
||||
is_maven,
|
||||
policy,
|
||||
non_java,
|
||||
cache::has_record(&project.root, &project.build_cache_dir(), &module),
|
||||
prefer_maven,
|
||||
);
|
||||
let external = non_java || delegated;
|
||||
(model, external, delegated)
|
||||
(model, external, delegated, prefer_maven)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
@@ -79,6 +81,7 @@ async fn resolve_launch(
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let _external_build = external_build;
|
||||
let built = if delegated {
|
||||
let policy = delegated_policy(policy, prefer_maven, info.error_count);
|
||||
let outputs = project.java_launch_snapshots.lock().build(
|
||||
&project.root,
|
||||
&model,
|
||||
@@ -169,6 +172,17 @@ async fn resolve_launch(
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
}
|
||||
|
||||
/// With Settings › Maven › Delegate IDE build/run actions to Maven, javac's verdict replaces
|
||||
/// JDT's. Classes JDT wrote for sources with errors look up to date to Maven's incremental
|
||||
/// compiler, so a launch JDT reports errors for recompiles everything.
|
||||
fn delegated_policy(policy: BuildPolicy, prefer_maven: bool, jdt_errors: u32) -> BuildPolicy {
|
||||
if prefer_maven && jdt_errors > 0 && matches!(policy, BuildPolicy::Automatic | BuildPolicy::Make) {
|
||||
BuildPolicy::Rebuild
|
||||
} else {
|
||||
policy
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_external_build_result(info: &mut JavaLaunchInfo, failure: Option<String>, clean: bool) {
|
||||
info.build = Some(if let Some(failure) = failure {
|
||||
info.hints.push(failure);
|
||||
@@ -241,8 +255,15 @@ fn apply_library_classpath(
|
||||
/// Whether a launch should be built by the project's build system instead of jdtls.
|
||||
/// A Rebuild has to clean the output; jdtls' full build only recompiles sources, so
|
||||
/// Maven modules always delegate for it, even when every source is Java.
|
||||
fn delegates_to_external_build(is_maven: bool, policy: BuildPolicy, non_java: bool, has_record: bool) -> bool {
|
||||
is_maven && (policy == BuildPolicy::Rebuild || non_java || has_record)
|
||||
/// `prefer_maven` is Settings › Maven › Delegate IDE build/run actions to Maven.
|
||||
fn delegates_to_external_build(
|
||||
is_maven: bool,
|
||||
policy: BuildPolicy,
|
||||
non_java: bool,
|
||||
has_record: bool,
|
||||
prefer_maven: bool,
|
||||
) -> bool {
|
||||
is_maven && (prefer_maven || policy == BuildPolicy::Rebuild || non_java || has_record)
|
||||
}
|
||||
|
||||
fn same_entry(a: &str, b: &str, windows: bool) -> bool {
|
||||
@@ -321,14 +342,29 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn delegated_launches_with_jdt_errors_recompile_with_maven() {
|
||||
assert_eq!(delegated_policy(BuildPolicy::Make, true, 1), BuildPolicy::Rebuild);
|
||||
assert_eq!(delegated_policy(BuildPolicy::Automatic, true, 2), BuildPolicy::Rebuild);
|
||||
assert_eq!(delegated_policy(BuildPolicy::Make, true, 0), BuildPolicy::Make);
|
||||
assert_eq!(delegated_policy(BuildPolicy::Make, false, 1), BuildPolicy::Make);
|
||||
assert_eq!(
|
||||
delegated_policy(BuildPolicy::MakeAndContinueOnErrors, true, 1),
|
||||
BuildPolicy::MakeAndContinueOnErrors
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebuild_delegates_maven_builds_to_the_build_system() {
|
||||
assert!(delegates_to_external_build(true, BuildPolicy::Rebuild, false, false));
|
||||
assert!(delegates_to_external_build(true, BuildPolicy::Automatic, true, false));
|
||||
assert!(delegates_to_external_build(true, BuildPolicy::Make, false, true));
|
||||
assert!(!delegates_to_external_build(true, BuildPolicy::Automatic, false, false));
|
||||
assert!(!delegates_to_external_build(true, BuildPolicy::Make, false, false));
|
||||
assert!(!delegates_to_external_build(false, BuildPolicy::Rebuild, false, false));
|
||||
assert!(delegates_to_external_build(true, BuildPolicy::Rebuild, false, false, false));
|
||||
assert!(delegates_to_external_build(true, BuildPolicy::Automatic, true, false, false));
|
||||
assert!(delegates_to_external_build(true, BuildPolicy::Make, false, true, false));
|
||||
assert!(!delegates_to_external_build(true, BuildPolicy::Automatic, false, false, false));
|
||||
assert!(!delegates_to_external_build(true, BuildPolicy::Make, false, false, false));
|
||||
assert!(!delegates_to_external_build(false, BuildPolicy::Rebuild, false, false, false));
|
||||
assert!(delegates_to_external_build(true, BuildPolicy::Automatic, false, false, true));
|
||||
assert!(delegates_to_external_build(true, BuildPolicy::Make, false, false, true));
|
||||
assert!(!delegates_to_external_build(false, BuildPolicy::Automatic, false, false, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -314,10 +314,12 @@ pub async fn lsp_goto(host: &Host, window: &str, doc: DocId, pos: Pos, kind: Got
|
||||
&& let Some(plugins) = crate::plugins::editor_plugin_host()
|
||||
{
|
||||
let project = host.project(window)?;
|
||||
let (path, version) = project.documents.identity(doc)?;
|
||||
if let Some(found) = plugins.navigation_at(&project.root, &path, version, pos, Some(window), |p| {
|
||||
project.documents.by_path(p).and_then(|id| project.documents.version(id).ok())
|
||||
}) {
|
||||
// A notebook cell is a language-server document only: no plugin navigates in it.
|
||||
if let Ok((path, version)) = project.documents.identity(doc)
|
||||
&& let Some(found) = plugins.navigation_at(&project.root, &path, version, pos, Some(window), |p| {
|
||||
project.documents.by_path(p).and_then(|id| project.documents.version(id).ok())
|
||||
})
|
||||
{
|
||||
return Ok(found.targets);
|
||||
}
|
||||
}
|
||||
@@ -544,7 +546,7 @@ pub async fn lsp_goto_implementation(
|
||||
/// classes and disk class files. Cached per project.
|
||||
pub async fn lsp_virtual_document(host: &Host, window: &str, uri: String) -> IdeResult<VirtualDocument> {
|
||||
if uri.starts_with("git-revision:") {
|
||||
return crate::ops::vcs::revision_virtual_document(host, window, &uri).await;
|
||||
return crate::ops::vcs::revision_virtual_document(host, window, &uri, None).await;
|
||||
}
|
||||
host.project(window)?.lsp.virtual_document(&uri).await
|
||||
}
|
||||
|
||||
@@ -19,6 +19,13 @@ pub fn get(host: &Host, window: &str, id: String) -> IdeResult<NotebookSnapshot>
|
||||
host.project(window)?.notebooks.get(&id)
|
||||
}
|
||||
|
||||
/// The language-server documents of the notebook's code cells (empty when no server syncs notebooks).
|
||||
pub fn cell_docs(host: &Host, window: &str, id: String) -> IdeResult<Vec<ide_lsp::NotebookCellDoc>> {
|
||||
let project = host.project(window)?;
|
||||
let path = project.notebooks.path_of(&id)?;
|
||||
Ok(project.lsp.notebook_cells(&path))
|
||||
}
|
||||
|
||||
pub async fn edit(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
@@ -132,6 +139,7 @@ impl Drop for RequestGuard<'_> {
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("notebook_open", |c| async move { open(&c.host, &c.window, c.arg("path")?).await });
|
||||
d.add("notebook_get", |c| async move { get(&c.host, &c.window, c.arg("id")?) });
|
||||
d.add("notebook_cell_docs", |c| async move { cell_docs(&c.host, &c.window, c.arg("id")?) });
|
||||
d.add("notebook_edit", |c| async move {
|
||||
edit(&c.host, &c.window, c.arg("id")?, c.arg("version")?, c.arg("edit")?).await
|
||||
});
|
||||
|
||||
@@ -20,10 +20,13 @@ struct LogAccess {
|
||||
}
|
||||
|
||||
/// Immutable Git objects exposed through the editor's ordinary read-only VFS.
|
||||
/// `encoding` re-decodes the blob with it ("Reload with Encoding") instead of
|
||||
/// the project's detection.
|
||||
pub async fn revision_virtual_document(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
uri: &str,
|
||||
encoding: Option<&str>,
|
||||
) -> IdeResult<ide_lsp::types::VirtualDocument> {
|
||||
#[derive(Deserialize)]
|
||||
struct Target {
|
||||
@@ -47,7 +50,7 @@ pub async fn revision_virtual_document(
|
||||
.collect::<IdeResult<Vec<_>>>()?;
|
||||
let target: Target = serde_json::from_slice(&bytes).map_err(|e| ide_core::IdeError::invalid(e.to_string()))?;
|
||||
let path = target.repo.join(&target.path);
|
||||
let text = vcs_file_at(host, window, path, Some(target.revision.clone()))
|
||||
let (text, charset, has_bom) = revision_text(host, window, &path, &target.revision, encoding)
|
||||
.await?
|
||||
.ok_or_else(|| ide_core::IdeError::not_found("file is absent at this revision"))?;
|
||||
Ok(ide_lsp::types::VirtualDocument {
|
||||
@@ -57,8 +60,63 @@ pub async fn revision_virtual_document(
|
||||
title: target.path.file_name().unwrap_or_default().to_string_lossy().into_owned(),
|
||||
description: Some(format!("{} · {}", target.repo.display(), target.revision)),
|
||||
decompiled: false,
|
||||
encoding: charset.name().to_ascii_lowercase(),
|
||||
has_bom,
|
||||
can_reload_encoding: true,
|
||||
})
|
||||
}
|
||||
|
||||
/// Revision text with the charset it was decoded with and whether a matching
|
||||
/// BOM was stripped; `None` when the file is absent at the revision.
|
||||
async fn revision_text(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
path: &Path,
|
||||
rev: &str,
|
||||
encoding: Option<&str>,
|
||||
) -> IdeResult<Option<(String, &'static encoding_rs::Encoding, bool)>> {
|
||||
let project = host.project(window).ok();
|
||||
let external = host.external_logs.read().get(host.owner_window(window)).and_then(|rs| {
|
||||
rs.iter().filter(|r| path.starts_with(r.root())).max_by_key(|r| r.root().components().count()).cloned()
|
||||
});
|
||||
let (path, rev, encoding) = (path.to_path_buf(), rev.to_owned(), encoding.map(str::to_owned));
|
||||
blocking(move || {
|
||||
fn with_repo<T>(
|
||||
project: &Option<std::sync::Arc<Project>>,
|
||||
external: &Option<std::sync::Arc<ide_vcs::Repo>>,
|
||||
path: &Path,
|
||||
f: impl FnOnce(&ide_vcs::Repo) -> IdeResult<T>,
|
||||
) -> IdeResult<T> {
|
||||
match project {
|
||||
Some(project) => project.with_log_repo_of(path, f),
|
||||
None => f(external
|
||||
.as_ref()
|
||||
.ok_or_else(|| ide_core::IdeError::unavailable("external Git log is not open"))?),
|
||||
}
|
||||
}
|
||||
let (text_encoding, charset) = match &encoding {
|
||||
Some(label) => {
|
||||
let charset = encoding_rs::Encoding::for_label(label.trim().as_bytes())
|
||||
.ok_or_else(|| ide_core::IdeError::invalid(format!("unknown encoding {label:?}")))?;
|
||||
(ide_vcs::TextEncoding::from(charset), charset)
|
||||
}
|
||||
None => match &project {
|
||||
Some(project) => {
|
||||
let text_encoding = project.vcs_encoding(&path, Some(&rev))?;
|
||||
(text_encoding, text_encoding.charset)
|
||||
}
|
||||
None => (ide_vcs::TextEncoding::from(encoding_rs::UTF_8), encoding_rs::UTF_8),
|
||||
},
|
||||
};
|
||||
let bytes = with_repo(&project, &external, &path, |r| r.file_bytes_at(&path, Some(&rev)))?;
|
||||
let Some(bytes) = bytes else { return Ok(None) };
|
||||
let has_bom = encoding_rs::Encoding::for_bom(&bytes).is_some_and(|(enc, _)| enc == charset);
|
||||
let text =
|
||||
with_repo(&project, &external, &path, |r| r.file_at(&path, Some(&rev), text_encoding))?.unwrap_or_default();
|
||||
Ok(Some((text, charset, has_bom)))
|
||||
})
|
||||
.await
|
||||
}
|
||||
impl LogAccess {
|
||||
fn new(host: &Host, window: &str, at: Option<&Path>) -> Self {
|
||||
let project = host.project(window).ok();
|
||||
|
||||
@@ -121,9 +121,14 @@ pub(crate) struct NavigationIndex {
|
||||
fn contains(range: Range, pos: Pos) -> bool {
|
||||
(range.start.line, range.start.col) <= (pos.line, pos.col) && (pos.line, pos.col) < (range.end.line, range.end.col)
|
||||
}
|
||||
/// Inside the document, start not after end. An empty range marks a position (a template file's
|
||||
/// definition at its start, say): navigation lands there, but it has no text to rename.
|
||||
fn valid_range(lines: &[usize], range: Range) -> bool {
|
||||
let pos = |p: Pos| lines.get(p.line as usize).is_some_and(|&length| p.col as usize <= length);
|
||||
pos(range.start) && pos(range.end) && (range.start.line, range.start.col) < (range.end.line, range.end.col)
|
||||
pos(range.start) && pos(range.end) && (range.start.line, range.start.col) <= (range.end.line, range.end.col)
|
||||
}
|
||||
fn is_empty(range: Range) -> bool {
|
||||
(range.start.line, range.start.col) == (range.end.line, range.end.col)
|
||||
}
|
||||
|
||||
impl PluginHost {
|
||||
@@ -280,12 +285,20 @@ impl PluginHost {
|
||||
.chain(&edges.references)
|
||||
.any(|a| a.key.is_empty() || a.key.len() > 1024 || !valid_range(&lines, a.range))
|
||||
{
|
||||
return Err(IdeError::invalid("invalid navigation anchors"));
|
||||
return Err(IdeError::invalid(format!(
|
||||
"{}: invalid navigation anchors in {}",
|
||||
plugin.id(),
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
if edges.markers.len() > MAX_EDGES
|
||||
|| edges.markers.iter().any(|a| a.key.is_empty() || a.key.len() > 1024 || !valid_range(&lines, a.range))
|
||||
{
|
||||
return Err(IdeError::invalid("invalid navigation markers"));
|
||||
return Err(IdeError::invalid(format!(
|
||||
"{}: invalid navigation markers in {}",
|
||||
plugin.id(),
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
let mut index = self.navigation.lock();
|
||||
if index.epochs.get(root).copied() != generation {
|
||||
@@ -406,7 +419,7 @@ impl PluginHost {
|
||||
let answer = result.get_or_insert_with(|| IndexedSymbol { range: primary.range, files: vec![] });
|
||||
for (path, file) in &graph.files {
|
||||
let mut ranges = std::collections::BTreeSet::new();
|
||||
for candidate in &file.edges.definitions {
|
||||
for candidate in file.edges.definitions.iter().filter(|a| !is_empty(a.range)) {
|
||||
if targets.contains(&Location::file(path.clone(), candidate.range))
|
||||
|| (targets.is_empty() && file.scope == source.scope && keys.contains(candidate.key.as_str()))
|
||||
{
|
||||
@@ -414,7 +427,7 @@ impl PluginHost {
|
||||
}
|
||||
}
|
||||
let mut matches = HashMap::new();
|
||||
for candidate in &file.edges.references {
|
||||
for candidate in file.edges.references.iter().filter(|a| !is_empty(a.range)) {
|
||||
let same = *matches.entry(candidate.key.as_str()).or_insert_with(|| {
|
||||
let resolved = graph.scoped_targets(&candidate.key, &file.scope);
|
||||
resolved.iter().any(|target| targets.contains(target))
|
||||
@@ -580,3 +593,21 @@ impl ExtensionPoint for IndexersPoint {
|
||||
Ok(Prepared::new(d.extensions.join(", "), || Ok(Disposable::new(|| {}))).capability(Capability::ProjectRead))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn empty_ranges_inside_the_document_are_valid_positions() {
|
||||
let lines = [3, 0];
|
||||
let range = |a: (u32, u32), b: (u32, u32)| Range::new(Pos::new(a.0, a.1), Pos::new(b.0, b.1));
|
||||
assert!(valid_range(&lines, range((0, 0), (0, 0))));
|
||||
assert!(valid_range(&lines, range((1, 0), (1, 0))));
|
||||
assert!(valid_range(&lines, range((0, 1), (0, 3))));
|
||||
assert!(!valid_range(&lines, range((0, 2), (0, 1))), "reversed");
|
||||
assert!(!valid_range(&lines, range((0, 4), (0, 4))), "past the line end");
|
||||
assert!(!valid_range(&lines, range((2, 0), (2, 0))), "past the last line");
|
||||
assert!(is_empty(range((0, 0), (0, 0))) && !is_empty(range((0, 0), (0, 1))));
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,8 @@ pub struct Project {
|
||||
/// watchers for (a virtualenv elsewhere, a path dependency).
|
||||
lsp_watchers: Mutex<Vec<(PathBuf, Watcher)>>,
|
||||
pub(crate) _scratch_watcher: Option<Watcher>,
|
||||
/// Picks up data sources edited on disk outside the Database tool window.
|
||||
_db_sources_watch: Option<ide_db::SourcesWatch>,
|
||||
/// A LightEdit window's loose files ([`Host::open_light`]): global settings only,
|
||||
/// nothing indexed, no language servers, repositories or project services.
|
||||
pub light: bool,
|
||||
@@ -536,10 +538,12 @@ impl Host {
|
||||
tracing::info!("opened {} in window {window} in {} ms {laps:?}", root.display(), started.elapsed().as_millis());
|
||||
|
||||
let (db, http_history, agents) = self.project_services(&root, &project_key, &settings);
|
||||
let db_sources_watch = db_sources_watch(&db, &sink, &self.runtime);
|
||||
let notebooks = crate::notebook::NotebookService::new(
|
||||
self.local_data_dir.join("notebook-runtime").join(uuid::Uuid::new_v4().to_string()),
|
||||
sink.clone(),
|
||||
);
|
||||
notebooks.set_observer(Arc::new(NotebookLspObserver(lsp.clone())));
|
||||
ide_agent::registry::maintain(crate::ops::agent::registry_paths(self));
|
||||
let plugin_diagnostics =
|
||||
crate::plugins::diagnostics::Diagnostics::new(sink.clone(), self.runtime.clone(), &documents, &root);
|
||||
@@ -576,6 +580,7 @@ impl Host {
|
||||
content_watchers: Mutex::new(content_watchers),
|
||||
lsp_watchers: Mutex::new(Vec::new()),
|
||||
_scratch_watcher: scratch_watcher,
|
||||
_db_sources_watch: db_sources_watch,
|
||||
files_events_active,
|
||||
light: false,
|
||||
});
|
||||
@@ -738,6 +743,7 @@ impl Host {
|
||||
content_watchers: Mutex::new(Vec::new()),
|
||||
lsp_watchers: Mutex::new(Vec::new()),
|
||||
_scratch_watcher: None,
|
||||
_db_sources_watch: None,
|
||||
files_events_active: Arc::new(AtomicBool::new(false)),
|
||||
light: true,
|
||||
root: dir,
|
||||
@@ -790,6 +796,28 @@ fn documents(sink: &SharedSink, languages: &Arc<LanguageRegistry>, root: Option<
|
||||
)
|
||||
}
|
||||
|
||||
/// Data sources edited on disk outside the Database tool window (by hand, by a coding
|
||||
/// agent): connections made with the old settings close and the tree re-reads them,
|
||||
/// without reopening the project.
|
||||
fn db_sources_watch(
|
||||
db: &Arc<ide_db::DbManager>,
|
||||
sink: &SharedSink,
|
||||
runtime: &tokio::runtime::Handle,
|
||||
) -> Option<ide_db::SourcesWatch> {
|
||||
let (weak, sink, runtime) = (Arc::downgrade(db), sink.clone(), runtime.clone());
|
||||
db.watch_sources(Box::new(move || {
|
||||
let Some(db) = weak.upgrade() else { return };
|
||||
let sink = sink.clone();
|
||||
runtime.spawn(async move {
|
||||
if db.reconcile_sources().await {
|
||||
sink.emit(IdeEvent::DbSourcesChanged);
|
||||
}
|
||||
});
|
||||
}))
|
||||
.inspect_err(|e| tracing::warn!("{}", e.message))
|
||||
.ok()
|
||||
}
|
||||
|
||||
fn light_watcher(
|
||||
dir: &Path,
|
||||
sink: &SharedSink,
|
||||
@@ -1231,7 +1259,7 @@ fn content_roots(settings: &ide_core::settings::Settings, root: &Path, build_cac
|
||||
|
||||
/// The roots a file may navigate into: its own root (the workspace member containing it, or
|
||||
/// the project root) plus that root's configured dependencies. A workspace member depends
|
||||
/// only on the roots picked per member in Settings › Project Structure › Dependencies —
|
||||
/// only on the roots picked per member in Project Structure › Dependencies —
|
||||
/// being attached to the workspace never makes its code navigable by itself.
|
||||
/// Cached navigation roots per (project, file), with the instant they were computed.
|
||||
type NavigationRootsCache = HashMap<(PathBuf, PathBuf), (Instant, Vec<PathBuf>)>;
|
||||
@@ -1578,6 +1606,25 @@ impl DocumentObserver for LspObserver {
|
||||
}
|
||||
}
|
||||
|
||||
/// Feeds the code cells of open notebooks to the Python language servers that sync notebooks.
|
||||
struct NotebookLspObserver(Arc<LspManager>);
|
||||
|
||||
impl crate::notebook::NotebookObserver for NotebookLspObserver {
|
||||
fn changed(&self, path: &Path, snapshot: &ide_notebook::NotebookSnapshot) {
|
||||
let cells: Vec<ide_lsp::NotebookCell> = snapshot
|
||||
.cells
|
||||
.iter()
|
||||
.filter(|c| c.cell_type == "code")
|
||||
.map(|c| ide_lsp::NotebookCell { id: c.id.clone(), source: c.source.clone() })
|
||||
.collect();
|
||||
// Notebooks run Python kernels only.
|
||||
self.0.sync_notebook(path, &LanguageId::new("python"), &cells);
|
||||
}
|
||||
fn closed(&self, path: &Path) {
|
||||
self.0.close_notebook(path);
|
||||
}
|
||||
}
|
||||
|
||||
/// Feeds document edits into the per-project syntax tree cache.
|
||||
struct SyntaxObserver(Arc<SyntaxCache>);
|
||||
|
||||
|
||||
@@ -93,9 +93,15 @@ async fn external_history_is_readable_and_never_becomes_a_write_target() {
|
||||
let payload =
|
||||
serde_json::to_vec(&serde_json::json!({ "repo": external, "revision": first, "path": "deleted.txt" })).unwrap();
|
||||
let hex = payload.iter().map(|b| format!("{b:02x}")).collect::<String>();
|
||||
let document =
|
||||
vcs::revision_virtual_document(&host, "standalone", &format!("git-revision:{hex}/deleted.txt")).await.unwrap();
|
||||
let uri = format!("git-revision:{hex}/deleted.txt");
|
||||
let document = vcs::revision_virtual_document(&host, "standalone", &uri, None).await.unwrap();
|
||||
assert_eq!(document.text, "historical content\n");
|
||||
assert_eq!(document.encoding, "utf-8");
|
||||
assert!(document.can_reload_encoding);
|
||||
// Reload with Encoding re-decodes the blob with the chosen charset.
|
||||
let reloaded = vcs::revision_virtual_document(&host, "standalone", &uri, Some("utf-16le")).await.unwrap();
|
||||
assert_eq!(reloaded.encoding, "utf-16le");
|
||||
assert_ne!(reloaded.text, document.text);
|
||||
host.trust.decide(&root, true, false).unwrap();
|
||||
let project = host.open_project("main", &root).unwrap();
|
||||
assert!(vcs::vcs_log(&host, "main", Some(external.clone()), Default::default()).await.is_err());
|
||||
|
||||
@@ -330,6 +330,7 @@ async fn local_deployment_runs_on_the_project_host() {
|
||||
"remote_delete",
|
||||
"remote_mkdir",
|
||||
"remote_rename",
|
||||
"remote_download_into",
|
||||
"db_sources",
|
||||
"db_save_sources",
|
||||
"db_set_password",
|
||||
|
||||
@@ -94,6 +94,7 @@ async fn application_commands_work_in_restricted_projects_and_without_a_project(
|
||||
"remote_delete",
|
||||
"remote_mkdir",
|
||||
"remote_rename",
|
||||
"remote_download_into",
|
||||
"remote_import_local",
|
||||
"remote_bootstrap_exec",
|
||||
"deploy_upload",
|
||||
|
||||
@@ -55,6 +55,10 @@ pub struct LanguageConfig {
|
||||
/// the first one whose root marker is found applies.
|
||||
#[serde(default)]
|
||||
pub framework_ui_modes: Vec<FrameworkUiMode>,
|
||||
/// Files of this language in a framework's projects that their extension alone would
|
||||
/// give to another language (Django templates are `.html` files in `templates`).
|
||||
#[serde(default)]
|
||||
pub framework_files: Vec<FrameworkFiles>,
|
||||
#[serde(default)]
|
||||
pub tree_sitter: Option<String>,
|
||||
#[serde(default)]
|
||||
@@ -90,6 +94,18 @@ pub struct FrameworkUiMode {
|
||||
pub root_markers: Vec<String>,
|
||||
}
|
||||
|
||||
/// Files in a framework's folder: a file with one of `extensions` below a folder named
|
||||
/// `folder`, which has a root marker in a directory above it.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub struct FrameworkFiles {
|
||||
pub folder: String,
|
||||
/// Without the dot, compared ignoring case.
|
||||
pub extensions: Vec<String>,
|
||||
/// Matched like language servers' `root-markers`, e.g. `manage.py`.
|
||||
pub root_markers: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all(serialize = "camelCase", deserialize = "kebab-case"))]
|
||||
pub struct IndentConfig {
|
||||
|
||||
@@ -22,6 +22,7 @@ const BUILTIN: &[(&str, &str)] = &[
|
||||
("d", include_str!("../../../languages/d.toml")),
|
||||
("dart", include_str!("../../../languages/dart.toml")),
|
||||
("diff", include_str!("../../../languages/diff.toml")),
|
||||
("django-html", include_str!("../../../languages/django-html.toml")),
|
||||
("dockerfile", include_str!("../../../languages/dockerfile.toml")),
|
||||
("dotenv", include_str!("../../../languages/dotenv.toml")),
|
||||
("elixir", include_str!("../../../languages/elixir.toml")),
|
||||
@@ -371,14 +372,13 @@ impl LanguageRegistry {
|
||||
.map(|f| f.ui_mode.clone())
|
||||
}
|
||||
|
||||
/// Creating or removing `path` can change a file's `framework_ui_mode`:
|
||||
/// it is a framework's root marker.
|
||||
/// Creating or removing `path` can change a file's `framework_ui_mode` or its
|
||||
/// language (`framework-files`): it is a framework's root marker.
|
||||
pub fn is_framework_marker(&self, path: &Path) -> bool {
|
||||
self.state()
|
||||
.languages
|
||||
.iter()
|
||||
.flat_map(|l| &l.framework_ui_modes)
|
||||
.any(|f| f.root_markers.iter().any(|m| crate::markers::is_marker_path(path, m)))
|
||||
let state = self.state();
|
||||
let modes = state.languages.iter().flat_map(|l| &l.framework_ui_modes).map(|f| &f.root_markers);
|
||||
let files = state.languages.iter().flat_map(|l| &l.framework_files).map(|f| &f.root_markers);
|
||||
modes.chain(files).flatten().any(|m| crate::markers::is_marker_path(path, m))
|
||||
}
|
||||
|
||||
/// Detects by Override File Type, exact file name, then file-name pattern
|
||||
@@ -400,6 +400,9 @@ impl LanguageRegistry {
|
||||
if let Some((_, i)) = t.name_globs.iter().find(|(p, _)| wildcard_match(p, file_name)) {
|
||||
return languages[*i].id.clone();
|
||||
}
|
||||
if let Some(l) = languages.iter().find(|l| l.framework_files.iter().any(|f| is_framework_file(f, path))) {
|
||||
return l.id.clone();
|
||||
}
|
||||
let lower = file_name.to_ascii_lowercase();
|
||||
let mut rest = lower.as_str();
|
||||
while let Some(dot) = rest.find('.') {
|
||||
@@ -420,6 +423,19 @@ impl LanguageRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
/// `path` is one of `files`: its extension is listed, and the nearest folder above it named
|
||||
/// `files.folder` has a root marker in a directory above that folder.
|
||||
fn is_framework_file(files: &crate::FrameworkFiles, path: &Path) -> bool {
|
||||
let Some(ext) = path.extension().and_then(|e| e.to_str()) else { return false };
|
||||
if !files.extensions.iter().any(|e| e.eq_ignore_ascii_case(ext)) {
|
||||
return false;
|
||||
}
|
||||
path.ancestors()
|
||||
.skip(1)
|
||||
.find(|dir| dir.file_name().is_some_and(|name| name == files.folder.as_str()))
|
||||
.is_some_and(|folder| crate::marked_root(None, folder, &files.root_markers).is_some())
|
||||
}
|
||||
|
||||
/// `*` matches any (possibly empty) run of characters, `?` exactly one; everything else is literal.
|
||||
fn wildcard_match(pattern: &str, name: &str) -> bool {
|
||||
let p: Vec<char> = pattern.chars().collect();
|
||||
@@ -497,6 +513,7 @@ mod tests {
|
||||
"Brainfuck",
|
||||
"Cobol",
|
||||
"C#",
|
||||
"Django Template",
|
||||
"Clojure",
|
||||
"ClojureScript",
|
||||
"Closure Stylesheets (GSS)",
|
||||
@@ -651,6 +668,7 @@ mod tests {
|
||||
("python", vec!["basedpyright", "pyright", "ty", "pyrefly"]),
|
||||
("typescript", vec!["vtsls", "typescript-language-server"]),
|
||||
("java", vec!["jdtls", "kmp-lsp"]),
|
||||
("kotlin", vec!["kotlin-lsp", "kotlin-language-server", "kmp-lsp"]),
|
||||
] {
|
||||
let cfg = reg.get(&LanguageId::new(lang)).unwrap();
|
||||
let got: Vec<(&str, bool)> = cfg
|
||||
@@ -801,6 +819,36 @@ mod tests {
|
||||
assert!(!reg.is_framework_marker(&root.join("apps/admin/package.json")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn django_projects_make_html_in_templates_folders_templates() {
|
||||
let reg = reg();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let detect = |file: &str| reg.detect(&root.join(file), None).as_str().to_owned();
|
||||
std::fs::create_dir_all(root.join("src/orders/templates/orders/email")).unwrap();
|
||||
std::fs::create_dir_all(root.join("site/templates")).unwrap();
|
||||
std::fs::write(root.join("src/manage.py"), "").unwrap();
|
||||
assert_eq!(detect("src/orders/templates/orders/detail.html"), "django-html");
|
||||
assert_eq!(detect("src/orders/templates/orders/email/body.TXT"), "django-html");
|
||||
assert_eq!(detect("src/templates/base.html"), "django-html");
|
||||
assert_eq!(detect("src/orders/templates/orders/data.json"), "json");
|
||||
assert_eq!(detect("src/orders/static/orders/page.html"), "html");
|
||||
// No `manage.py` above this `templates` folder.
|
||||
assert_eq!(detect("site/templates/index.html"), "html");
|
||||
assert_eq!(detect("page.djhtml"), "django-html");
|
||||
assert!(reg.is_framework_marker(&root.join("src/manage.py")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_django_fixture_has_templates_and_a_page() {
|
||||
let reg = reg();
|
||||
let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/django-app");
|
||||
let detect = |file: &str| reg.detect(&fixture.join(file), None).as_str().to_owned();
|
||||
assert_eq!(detect("shop/templates/shop/product_list.html"), "django-html");
|
||||
assert_eq!(detect("shop/templates/shop/email/order.txt"), "django-html");
|
||||
assert_eq!(detect("pages/promo.html"), "html");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markers_outside_the_project_do_not_make_angular_templates() {
|
||||
let reg = reg();
|
||||
|
||||
@@ -29,4 +29,5 @@ zip.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
encoding_rs.workspace = true
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "test-util"] }
|
||||
@@ -163,7 +163,9 @@ pub fn client_capabilities() -> Value {
|
||||
"typeHierarchy": { "dynamicRegistration": false }
|
||||
},
|
||||
// rust-analyzer: report when the project has loaded (`quiescent`).
|
||||
"experimental": { "serverStatusNotification": true }
|
||||
"experimental": { "serverStatusNotification": true },
|
||||
// Jupyter notebooks: code cells are synced with `notebookDocument/*` (see `notebook.rs`).
|
||||
"notebookDocument": { "synchronization": { "dynamicRegistration": false, "executionSummarySupport": false } }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -426,6 +428,34 @@ impl ServerCaps {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Whether `notebookDocumentSync` takes Jupyter notebooks (`file:`) with cells of `language`:
|
||||
/// a selector that names no notebook, or `jupyter-notebook` / `*`, and no cell languages or
|
||||
/// `language` among them.
|
||||
pub fn syncs_notebooks(&self, language: &str) -> bool {
|
||||
let Some(selectors) = self.raw.pointer("/notebookDocumentSync/notebookSelector").and_then(Value::as_array)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
selectors.iter().any(|s| {
|
||||
let notebook = match s.get("notebook") {
|
||||
None | Some(Value::Null) => true,
|
||||
Some(Value::String(kind)) => kind == "jupyter-notebook" || kind == "*",
|
||||
Some(filter) => {
|
||||
filter
|
||||
.get("notebookType")
|
||||
.and_then(Value::as_str)
|
||||
.is_none_or(|t| t == "jupyter-notebook" || t == "*")
|
||||
&& filter.get("scheme").and_then(Value::as_str).is_none_or(|s| s == "file")
|
||||
}
|
||||
};
|
||||
let cells = match s.get("cells").and_then(Value::as_array) {
|
||||
None => true,
|
||||
Some(cells) => cells.iter().any(|c| c.get("language").and_then(Value::as_str) == Some(language)),
|
||||
};
|
||||
notebook && cells && (s.get("notebook").is_some() || s.get("cells").is_some())
|
||||
})
|
||||
}
|
||||
|
||||
/// `workspace.fileOperations.<op>` registration options (`op`: `willRename`, `didRename`).
|
||||
pub fn file_operation(&self, op: &str) -> Option<&Value> {
|
||||
self.raw.pointer(&format!("/workspace/fileOperations/{op}")).filter(|v| v.is_object())
|
||||
@@ -668,6 +698,26 @@ mod tests {
|
||||
assert_eq!(c.code_action_kinds(), Some(vec!["refactor.inline".into()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notebook_sync_selectors() {
|
||||
let syncs = |selector: Value| {
|
||||
ServerCaps::parse(json!({ "notebookDocumentSync": { "notebookSelector": [selector] } }))
|
||||
.syncs_notebooks("python")
|
||||
};
|
||||
// basedpyright
|
||||
assert!(syncs(json!({
|
||||
"notebook": { "scheme": "file", "notebookType": "jupyter-notebook" }, "cells": [{ "language": "python" }]
|
||||
})));
|
||||
// Ruff: any notebook with Python cells.
|
||||
assert!(syncs(json!({ "cells": [{ "language": "python" }] })));
|
||||
assert!(syncs(json!({ "notebook": "*" })));
|
||||
assert!(!syncs(json!({ "notebook": "interactive" })));
|
||||
assert!(!syncs(json!({ "notebook": { "notebookType": "jupyter-notebook", "scheme": "untitled" } })));
|
||||
assert!(!syncs(json!({ "cells": [{ "language": "r" }] })));
|
||||
assert!(!syncs(json!({})));
|
||||
assert!(!ServerCaps::parse(json!({ "textDocumentSync": 2 })).syncs_notebooks("python"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn language_definitions_use_known_feature_names() {
|
||||
for lang in ide_language::LanguageRegistry::load(None).unwrap().all() {
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
//! go.mod as GoLand shows it: available upgrades, vulnerabilities and deprecated modules are
|
||||
//! reported as soon as the file is opened. gopls reports upgrades only from its "Check for
|
||||
//! upgrades" code lens, so that runs once per go.mod and session; its results (and the
|
||||
//! vulnerabilities from the `vulncheck` setting) come back as gopls diagnostics with quick fixes.
|
||||
//! gopls never reports a deprecated module, so `go list -m -u` does, on open and on save.
|
||||
|
||||
use crate::LspManager;
|
||||
use crate::server::Server;
|
||||
use ide_core::{DocId, Pos, Range};
|
||||
use serde::Deserialize;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
pub(crate) const CHECK_UPGRADES: &str = "gopls.check_upgrades";
|
||||
/// gopls answers code lenses once it has loaded the module.
|
||||
const ATTEMPTS: u32 = 10;
|
||||
const RETRY: Duration = Duration::from_secs(2);
|
||||
/// Producer of the deprecation diagnostics (one full set per go.mod).
|
||||
const DEPRECATIONS: &str = "go-mod-deprecations";
|
||||
|
||||
fn is_go_mod(path: &Path) -> bool {
|
||||
path.file_name().is_some_and(|n| n == "go.mod")
|
||||
}
|
||||
|
||||
impl LspManager {
|
||||
pub(crate) async fn check_go_mod(&self, doc: DocId, path: &Path, server: Arc<Server>) {
|
||||
if server.name != "gopls" || !is_go_mod(path) {
|
||||
return;
|
||||
}
|
||||
self.report_deprecated_modules(doc, &server).await;
|
||||
if !self.go_mod_checked.lock().insert(path.to_owned()) {
|
||||
return;
|
||||
}
|
||||
for attempt in 0..ATTEMPTS {
|
||||
if attempt > 0 {
|
||||
tokio::time::sleep(RETRY).await;
|
||||
}
|
||||
if !self.docs.lock().contains_key(&doc) {
|
||||
break;
|
||||
}
|
||||
let Ok(lenses) = self.code_lens(doc).await else { continue };
|
||||
let Some(command) = lenses.into_iter().filter_map(|l| l.command).find(|c| c.command == CHECK_UPGRADES)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if let Err(e) = self.execute_command(doc, command).await {
|
||||
tracing::debug!("gopls: {CHECK_UPGRADES} for {}: {}", path.display(), e.message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Not checked after all (closed, or gopls never offered the lens): a later open retries.
|
||||
self.go_mod_checked.lock().remove(path);
|
||||
}
|
||||
|
||||
/// After a save of a go.mod: its requirements may have changed.
|
||||
pub(crate) fn go_mod_saved(&self, doc: DocId) {
|
||||
let server = {
|
||||
let docs = self.docs.lock();
|
||||
let Some(d) = docs.get(&doc).filter(|d| is_go_mod(&d.path)) else { return };
|
||||
let Some(a) = d.servers.iter().find(|a| a.server.name == "gopls") else { return };
|
||||
a.server.clone()
|
||||
};
|
||||
let Some(me) = self.me.upgrade() else { return };
|
||||
self.runtime.spawn(async move { me.report_deprecated_modules(doc, &server).await });
|
||||
}
|
||||
|
||||
async fn report_deprecated_modules(&self, doc: DocId, server: &Server) {
|
||||
let Some((path, text)) = self.docs.lock().get(&doc).map(|d| (d.path.clone(), d.text.as_str().to_owned()))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let required = required_modules(&text);
|
||||
if required.is_empty() {
|
||||
return;
|
||||
}
|
||||
let config = server.effective_config();
|
||||
let search = crate::launch::server_search_path(&config);
|
||||
let Some(dir) = path.parent().map(Path::to_path_buf) else { return };
|
||||
let modules: Vec<String> = required.iter().map(|r| r.path.clone()).collect();
|
||||
let listed = tokio::task::spawn_blocking(move || list_deprecations(&dir, &search, &modules)).await;
|
||||
let Ok(Some(deprecated)) = listed else { return };
|
||||
// The document changed meanwhile: its own save runs again.
|
||||
if self.docs.lock().get(&doc).is_none_or(|d| d.text.as_str() != text) {
|
||||
return;
|
||||
}
|
||||
let items = required
|
||||
.into_iter()
|
||||
.filter_map(|r| {
|
||||
let message = deprecated.iter().find(|(p, _)| *p == r.path)?.1.trim();
|
||||
Some(ide_core::Diagnostic {
|
||||
range: r.range,
|
||||
severity: ide_core::Severity::Warning,
|
||||
message: format!("{} is deprecated: {message}", r.path),
|
||||
source: Some("go".into()),
|
||||
code: None,
|
||||
unnecessary: false,
|
||||
deprecated: true,
|
||||
server: Some("gopls".into()),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
self.sink().emit(ide_core::IdeEvent::Diagnostics { path: path.into(), source: DEPRECATIONS.into(), items });
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
struct Required {
|
||||
path: String,
|
||||
/// The module path in go.mod (UTF-16 columns).
|
||||
range: Range,
|
||||
}
|
||||
|
||||
/// Modules of go.mod's `require` directives, single-line or in a block.
|
||||
fn required_modules(text: &str) -> Vec<Required> {
|
||||
let mut out = Vec::new();
|
||||
let mut in_block = false;
|
||||
for (line, raw) in text.lines().enumerate() {
|
||||
let code = raw.split("//").next().unwrap_or("");
|
||||
let trimmed = code.trim_start();
|
||||
let entry = if in_block {
|
||||
if trimmed.starts_with(')') {
|
||||
in_block = false;
|
||||
continue;
|
||||
}
|
||||
trimmed
|
||||
} else if let Some(rest) = trimmed.strip_prefix("require").filter(|r| r.starts_with(char::is_whitespace)) {
|
||||
let rest = rest.trim_start();
|
||||
if rest.starts_with('(') {
|
||||
in_block = true;
|
||||
continue;
|
||||
}
|
||||
rest
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
let mut words = entry.split_whitespace();
|
||||
let (Some(module), Some(_version)) = (words.next(), words.next()) else { continue };
|
||||
let module = module.trim_matches('"');
|
||||
let Some(start) = code.find(module) else { continue };
|
||||
let col = |byte: usize| code[..byte].encode_utf16().count() as u32;
|
||||
let line = line as u32;
|
||||
out.push(Required {
|
||||
path: module.to_owned(),
|
||||
range: Range { start: Pos { line, col: col(start) }, end: Pos { line, col: col(start + module.len()) } },
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ListedModule {
|
||||
#[serde(rename = "Path")]
|
||||
path: String,
|
||||
#[serde(rename = "Deprecated", default)]
|
||||
deprecated: Option<String>,
|
||||
}
|
||||
|
||||
/// `(module, message)` of each deprecated module in `modules`; `None` when `go` failed.
|
||||
fn list_deprecations(dir: &Path, search: &std::ffi::OsStr, modules: &[String]) -> Option<Vec<(String, String)>> {
|
||||
let go: PathBuf = ide_core::tool_path::find_command("go", search, dir)?;
|
||||
let output = ide_core::process::prepare(&mut std::process::Command::new(go))
|
||||
.args(["list", "-m", "-u", "-e", "-json"])
|
||||
.args(modules)
|
||||
.current_dir(dir)
|
||||
.env("PATH", search)
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.output()
|
||||
.ok()?;
|
||||
if output.stdout.is_empty() {
|
||||
tracing::debug!("go list -m -u in {}: {}", dir.display(), output.status);
|
||||
return None;
|
||||
}
|
||||
Some(parse_deprecations(&output.stdout))
|
||||
}
|
||||
|
||||
fn parse_deprecations(json: &[u8]) -> Vec<(String, String)> {
|
||||
serde_json::Deserializer::from_slice(json)
|
||||
.into_iter::<ListedModule>()
|
||||
.map_while(Result::ok)
|
||||
.filter_map(|m| Some((m.path, m.deprecated.filter(|d| !d.trim().is_empty())?)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn at(line: u32, from: u32, to: u32) -> Range {
|
||||
Range { start: Pos { line, col: from }, end: Pos { line, col: to } }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_required_modules_in_blocks_and_single_lines() {
|
||||
let text = "module example.com/m\n\ngo 1.24\n\nrequire github.com/a/b v1.0.0 // indirect\n\
|
||||
require (\n\tgithub.com/c/d v1.2.0\n\t\"github.com/e/é\" v0.1.0 // indirect\n)\n\
|
||||
// require github.com/x/y v1.0.0\nreplace github.com/c/d => ../d\ntool golang.org/x/tools/cmd/stringer\n";
|
||||
assert_eq!(
|
||||
required_modules(text),
|
||||
vec![
|
||||
Required { path: "github.com/a/b".into(), range: at(4, 8, 22) },
|
||||
Required { path: "github.com/c/d".into(), range: at(6, 1, 15) },
|
||||
Required { path: "github.com/e/é".into(), range: at(7, 2, 16) },
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_deprecations_from_the_go_list_stream() {
|
||||
let json = br#"{"Path": "github.com/golang/protobuf", "Version": "v1.5.4",
|
||||
"Deprecated": "Use the \"google.golang.org/protobuf\" module instead."}
|
||||
{"Path": "github.com/sergi/go-diff", "Version": "v1.3.1"}
|
||||
{"Path": "example.com/blank", "Deprecated": " "}"#;
|
||||
assert_eq!(
|
||||
parse_deprecations(json),
|
||||
vec![(
|
||||
"github.com/golang/protobuf".to_owned(),
|
||||
"Use the \"google.golang.org/protobuf\" module instead.".to_owned()
|
||||
)]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ use ide_core::{IdeEvent, TaskId};
|
||||
use ide_jsonrpc::{Handler, codes};
|
||||
use lsp_types as lsp;
|
||||
use serde_json::{Value, json};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Weak};
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -229,7 +230,11 @@ impl ClientHandler {
|
||||
return;
|
||||
}
|
||||
};
|
||||
let Some(path) = uri_to_path(params.uri.as_str()) else { return };
|
||||
let Some(path) = uri_to_path(params.uri.as_str())
|
||||
.or_else(|| crate::notebook::is_cell_uri(params.uri.as_str()).then(|| PathBuf::from(params.uri.as_str())))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if self.server.name == "jdtls"
|
||||
&& let Some(failure) = crate::jdtls::build_plan_failure(&path, &self.server.root, ¶ms.diagnostics)
|
||||
{
|
||||
@@ -361,6 +366,19 @@ impl ClientHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/// Shown once per session when tsserver keeps aborting at its heap limit; the UI adds a
|
||||
/// button that opens the language server settings.
|
||||
const TSSERVER_OUT_OF_MEMORY: &str = "tsserver stopped because it reached its memory limit and is restarted, so code intelligence may pause or miss results. Raise typescript.tsserver.maxTsServerMemory (in MB) in the server's Workspace Configuration.";
|
||||
|
||||
/// Whether a vtsls log line reports tsserver aborting, which is how Node exits at its heap
|
||||
/// limit: exit code 134 on Windows, `SIGABRT` elsewhere.
|
||||
fn tsserver_ran_out_of_memory(text: &str) -> bool {
|
||||
text.split_once("TSServer exited. ").is_some_and(|(_, rest)| {
|
||||
let rest = rest.trim_end();
|
||||
rest.starts_with("Code: 134.") || rest.ends_with("Signal: SIGABRT")
|
||||
})
|
||||
}
|
||||
|
||||
impl Handler for ClientHandler {
|
||||
fn on_notification(&self, method: &str, params: Value) {
|
||||
let Some(mgr) = self.mgr.upgrade() else { return };
|
||||
@@ -375,6 +393,16 @@ impl Handler for ClientHandler {
|
||||
"window/showMessage" => self.show_message(&mgr, ¶ms),
|
||||
"window/logMessage" => {
|
||||
let text = params.get("message").and_then(Value::as_str).unwrap_or_default();
|
||||
if self.server.config.tsserver_project && tsserver_ran_out_of_memory(text) {
|
||||
tracing::warn!("[{}] {text}", self.server.name);
|
||||
if mgr.first_message(&self.server.name, TSSERVER_OUT_OF_MEMORY) {
|
||||
mgr.sink().emit(IdeEvent::Notify {
|
||||
level: NotifyLevel::Error,
|
||||
title: "TypeScript server ran out of memory".into(),
|
||||
body: Some(TSSERVER_OUT_OF_MEMORY.into()),
|
||||
});
|
||||
}
|
||||
}
|
||||
if self.server.name == "jdtls"
|
||||
&& text.contains("Can't use Java ")
|
||||
&& text.contains(" to import Gradle project ")
|
||||
@@ -518,3 +546,19 @@ fn merge(base: &mut Value, layer: Value) {
|
||||
(base, layer) => *base = layer,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::tsserver_ran_out_of_memory;
|
||||
|
||||
#[test]
|
||||
fn tsserver_aborts_are_out_of_memory() {
|
||||
assert!(tsserver_ran_out_of_memory("TSServer exited. Code: 134. Signal: null"));
|
||||
assert!(tsserver_ran_out_of_memory("TSServer exited. Code: null. Signal: SIGABRT"));
|
||||
assert!(!tsserver_ran_out_of_memory("TSServer exited. Code: 0. Signal: null"));
|
||||
assert!(!tsserver_ran_out_of_memory("TSServer exited. Code: null. Signal: SIGTERM"));
|
||||
assert!(!tsserver_ran_out_of_memory("TSServer exited. Code: 1340. Signal: null"));
|
||||
assert!(!tsserver_ran_out_of_memory("Code: 134."));
|
||||
assert!(tsserver_ran_out_of_memory("[error] TSServer exited. Code: 134. Signal: null\n"));
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const SERVER: &str = "jdtls";
|
||||
pub(crate) const SERVER: &str = "jdtls";
|
||||
|
||||
pub(crate) mod configuration;
|
||||
mod formatter;
|
||||
@@ -1314,15 +1314,22 @@ mod tests {
|
||||
std::fs::write(&file, format!("{}=warning\n{}=ignore\nother=error\n", ACCESS_OPTIONS[0], ACCESS_OPTIONS[1]))
|
||||
.unwrap();
|
||||
let mut cfg: ide_language::LanguageServerConfig = toml::from_str("name = 'jdtls'\ncommand = 'jdtls'").unwrap();
|
||||
cfg.initialization_options = Some(
|
||||
toml::Value::try_from(json!({"settings": {"java": {"settings": {"url": file.to_string_lossy()}}}}))
|
||||
.unwrap(),
|
||||
);
|
||||
let with_url = |cfg: &mut ide_language::LanguageServerConfig, url: String| {
|
||||
cfg.initialization_options =
|
||||
Some(toml::Value::try_from(json!({"settings": {"java": {"settings": {"url": url}}}})).unwrap());
|
||||
};
|
||||
with_url(&mut cfg, crate::uri::path_to_uri_string(&file));
|
||||
assert_eq!(compiler_defaults(&cfg), json!({}));
|
||||
for prefix in ["", "--jvm-arg="] {
|
||||
cfg.args = vec![format!("{prefix}-Djava.import.generatesMetadataFilesAtProjectRoot=false")];
|
||||
assert_eq!(compiler_defaults(&cfg), json!({ACCESS_OPTIONS[0]: "warning", ACCESS_OPTIONS[1]: "ignore"}));
|
||||
}
|
||||
with_url(&mut cfg, file.to_string_lossy().into_owned());
|
||||
assert_eq!(
|
||||
compiler_defaults(&cfg),
|
||||
json!({ACCESS_OPTIONS[0]: "warning", ACCESS_OPTIONS[1]: "ignore"}),
|
||||
"a path"
|
||||
);
|
||||
cfg.args.push("--jvm-arg=-Djava.import.generatesMetadataFilesAtProjectRoot=true".into());
|
||||
assert_eq!(compiler_defaults(&cfg), json!({}), "the final JVM property wins");
|
||||
cfg.args.pop();
|
||||
|
||||
@@ -146,7 +146,7 @@ fn required_env_set(config: &LanguageServerConfig) -> bool {
|
||||
}
|
||||
|
||||
/// A configured `PATH` (e.g. a selected toolchain prepended) also resolves the binary.
|
||||
fn server_search_path(config: &LanguageServerConfig) -> OsString {
|
||||
pub(crate) fn server_search_path(config: &LanguageServerConfig) -> OsString {
|
||||
config.env.get("PATH").map(OsString::from).unwrap_or_else(search_path)
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ mod convert;
|
||||
mod external_project;
|
||||
mod file_ops;
|
||||
mod file_watch;
|
||||
mod go_mod;
|
||||
mod go_scope;
|
||||
mod handler;
|
||||
mod injections;
|
||||
@@ -24,6 +25,7 @@ mod kotlin_lsp;
|
||||
mod launch;
|
||||
mod library_source;
|
||||
mod loading;
|
||||
mod notebook;
|
||||
mod pull;
|
||||
mod scope;
|
||||
mod server;
|
||||
@@ -41,6 +43,7 @@ pub use inspect::{FileInspection, InspectHost, InspectOptions};
|
||||
pub use jdtls::jvm_inspect::JdtJvmInspector;
|
||||
pub use kotlin_lsp::{KotlinDebugServer, KotlinDebugUnavailable};
|
||||
pub use launch::{LaunchError, Launcher, ProcessLauncher};
|
||||
pub use notebook::{NotebookCell, NotebookCellDoc, cell_uri};
|
||||
pub use scope::{CandidateFiles, CandidateFn, CandidateQuery, MAX_CANDIDATES};
|
||||
pub use uri::{is_virtual_uri, path_to_uri_string, uri_target, uri_to_path};
|
||||
|
||||
@@ -114,6 +117,8 @@ struct DocState {
|
||||
servers: Vec<Attached>,
|
||||
/// Open YAML document whose filename does not match a Compose glob and whose content is Compose.
|
||||
compose_detected: bool,
|
||||
/// The notebook a code cell belongs to: synced through `notebookDocument/*`, not as a file.
|
||||
notebook: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl DocState {
|
||||
@@ -221,11 +226,15 @@ pub struct LspManager {
|
||||
configuration_cache: Arc<Mutex<jdtls::configuration::ConfigurationCache>>,
|
||||
/// `window/showMessage` texts already surfaced; servers repeat them on every restart/reconfigure.
|
||||
shown_messages: Mutex<HashSet<(String, String)>>,
|
||||
/// go.mod files whose upgrades gopls has been asked to check this session.
|
||||
go_mod_checked: Mutex<HashSet<PathBuf>>,
|
||||
/// Virtual (non-`file:`) URIs a server returned, by the server that owns them.
|
||||
virtual_origins: Mutex<HashMap<String, ide_core::ServerId>>,
|
||||
/// Contents fetched for virtual URIs.
|
||||
virtual_docs: Mutex<HashMap<String, VirtualDocument>>,
|
||||
injections: injections::State,
|
||||
/// Open notebooks by path. Locked before `docs` when both are.
|
||||
notebooks: Mutex<HashMap<PathBuf, notebook::NotebookState>>,
|
||||
}
|
||||
|
||||
/// Fetched virtual documents kept before the cache is reset.
|
||||
@@ -326,6 +335,41 @@ const RENAME_RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis
|
||||
/// Implementation requests of one line-marker pass in flight at once.
|
||||
const IMPLEMENTATION_CONCURRENCY: usize = 4;
|
||||
|
||||
/// How a virtual document's content was decoded, and whether it can be
|
||||
/// re-decoded ("Reload with Encoding").
|
||||
struct Decoding {
|
||||
encoding: String,
|
||||
has_bom: bool,
|
||||
can_reload: bool,
|
||||
}
|
||||
|
||||
impl Decoding {
|
||||
/// Text the server decoded itself: no raw bytes here to re-read.
|
||||
fn server_text() -> Self {
|
||||
Decoding { encoding: "utf-8".into(), has_bom: false, can_reload: false }
|
||||
}
|
||||
}
|
||||
|
||||
/// jdtls' source attachment of a `jdt:` class (`java.project.resolveSourceAttachment`).
|
||||
struct SourceAttachment {
|
||||
path: Option<String>,
|
||||
encoding: Option<String>,
|
||||
can_edit_encoding: bool,
|
||||
}
|
||||
|
||||
/// Reads a `jar:` document's source: an entry as is, a class from the sources
|
||||
/// jar next to its binary. `encoding` decodes with it instead of detecting.
|
||||
fn read_jar_source(uri: &str, encoding: Option<&str>) -> Option<(String, ide_vfs::LoadedText)> {
|
||||
match library_source::parse(uri) {
|
||||
Some(e) if !e.entry.ends_with(".class") => {
|
||||
library_source::read_entry(&e.jar, &e.entry, encoding).map(|loaded| (e.entry, loaded))
|
||||
}
|
||||
Some(_) => library_source::class_source(uri, encoding)
|
||||
.and_then(|(source, loaded)| Some((library_source::parse(&source)?.entry, loaded))),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Library classes in `jar:` targets (the official Kotlin LSP) go to their source
|
||||
/// in the artifact's sources jar when it is cached, at the declaration of the
|
||||
/// name navigated from; [`LspManager::virtual_document`] serves it.
|
||||
@@ -340,8 +384,9 @@ async fn library_sources(snap: &DocSnap, pos: Pos, locs: Vec<Location>) -> Vec<L
|
||||
let Some(uri) = loc.uri.as_deref().filter(|u| u.starts_with("jar:") && u.ends_with(".class")) else {
|
||||
return loc;
|
||||
};
|
||||
let Some((source, text)) = library_source::class_source(uri) else { return loc };
|
||||
let range = name.as_deref().map(|n| library_source::declaration_range(&text, n)).unwrap_or_default();
|
||||
let Some((source, loaded)) = library_source::class_source(uri, None) else { return loc };
|
||||
let range =
|
||||
name.as_deref().map(|n| library_source::declaration_range(&loaded.text, n)).unwrap_or_default();
|
||||
Location { path: source.clone().into(), range, uri: Some(source) }
|
||||
})
|
||||
.collect()
|
||||
@@ -433,9 +478,11 @@ impl LspManager {
|
||||
debug_servers: Mutex::new(HashMap::new()),
|
||||
configuration_cache: Arc::new(Mutex::new(Default::default())),
|
||||
shown_messages: Mutex::new(HashSet::new()),
|
||||
go_mod_checked: Mutex::new(HashSet::new()),
|
||||
virtual_origins: Mutex::new(HashMap::new()),
|
||||
virtual_docs: Mutex::new(HashMap::new()),
|
||||
injections: injections::State::default(),
|
||||
notebooks: Mutex::new(HashMap::new()),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -574,8 +621,13 @@ impl LspManager {
|
||||
/// enabled in settings.
|
||||
fn attach_fallbacks(&self) {
|
||||
let _gate = self.injections.gate.lock();
|
||||
let open: Vec<(DocId, LanguageId, PathBuf)> =
|
||||
self.docs.lock().iter().map(|(id, d)| (*id, d.language.clone(), d.path.clone())).collect();
|
||||
let open: Vec<(DocId, LanguageId, PathBuf)> = self
|
||||
.docs
|
||||
.lock()
|
||||
.iter()
|
||||
.filter(|(_, d)| d.notebook.is_none())
|
||||
.map(|(id, d)| (*id, d.language.clone(), d.path.clone()))
|
||||
.collect();
|
||||
let wanted: Vec<(DocId, Vec<Arc<Server>>)> =
|
||||
open.into_iter().map(|(id, language, path)| (id, self.servers_for(&language, &path))).collect();
|
||||
let mut start = Vec::new();
|
||||
@@ -608,6 +660,11 @@ impl LspManager {
|
||||
for (doc, server) in opened {
|
||||
self.schedule_pull(doc, Some(&server), Duration::ZERO);
|
||||
}
|
||||
for server in self.attach_notebook_servers() {
|
||||
if !start.iter().any(|s| s.id == server.id) {
|
||||
start.push(server);
|
||||
}
|
||||
}
|
||||
for server in &start {
|
||||
self.ensure_started(server);
|
||||
}
|
||||
@@ -827,7 +884,7 @@ impl LspManager {
|
||||
false
|
||||
} else {
|
||||
let open_close = run.caps.read().open_close;
|
||||
for (id, d) in docs.iter_mut() {
|
||||
for (id, d) in docs.iter_mut().filter(|(_, d)| d.notebook.is_none()) {
|
||||
let params = d.did_open_params();
|
||||
for a in
|
||||
d.servers.iter_mut().filter(|a| a.server.id == server.id && a.opened_gen != Some(generation))
|
||||
@@ -849,6 +906,7 @@ impl LspManager {
|
||||
for doc in reopened {
|
||||
self.schedule_pull(doc, Some(&server), Duration::ZERO);
|
||||
}
|
||||
self.reopen_notebooks(&server, &run);
|
||||
self.check_clang_tidy(&server).await;
|
||||
self.check_vue_navigation(&server).await;
|
||||
server.refresh_busy_status(&self.sink);
|
||||
@@ -1032,6 +1090,7 @@ impl LspManager {
|
||||
servers: Vec<Arc<Server>>,
|
||||
) {
|
||||
let canonical = dunce::canonicalize(&path).unwrap_or_else(|_| path.clone());
|
||||
let canonical_path = canonical.clone();
|
||||
let compose_detected = language.as_str() == "yaml" && compose::is_content_detected_compose_file(&path, text);
|
||||
let mut state = DocState {
|
||||
path,
|
||||
@@ -1042,6 +1101,7 @@ impl LspManager {
|
||||
text: Arc::new(DocText::new(text)),
|
||||
servers: servers.iter().map(|s| Attached { server: s.clone(), opened_gen: None }).collect(),
|
||||
compose_detected,
|
||||
notebook: None,
|
||||
};
|
||||
let compose_added = {
|
||||
let mut docs = self.docs.lock();
|
||||
@@ -1062,6 +1122,13 @@ impl LspManager {
|
||||
self.schedule_pull(doc, None, Duration::ZERO);
|
||||
for s in &servers {
|
||||
self.ensure_started(s);
|
||||
// Waits for a server still starting, so it is not tied to `running()` below.
|
||||
if s.name == "gopls"
|
||||
&& let Some(me) = self.me.upgrade()
|
||||
{
|
||||
let (path, server) = (canonical_path.clone(), s.clone());
|
||||
self.runtime.spawn(async move { me.check_go_mod(doc, &path, server).await });
|
||||
}
|
||||
if s.running().is_some()
|
||||
&& let Some(me) = self.me.upgrade()
|
||||
{
|
||||
@@ -1166,7 +1233,7 @@ impl LspManager {
|
||||
return;
|
||||
}
|
||||
let docs = self.docs.lock();
|
||||
for d in docs.values() {
|
||||
for d in docs.values().filter(|d| d.notebook.is_none()) {
|
||||
if !d.servers.iter().any(|a| a.server.id == server.id && a.opened_gen == Some(generation)) {
|
||||
continue;
|
||||
}
|
||||
@@ -1203,6 +1270,7 @@ impl LspManager {
|
||||
drop(docs);
|
||||
self.schedule_pull(doc, None, Duration::ZERO);
|
||||
self.pull_dependents(doc);
|
||||
self.go_mod_saved(doc);
|
||||
}
|
||||
|
||||
pub fn did_close(&self, doc: DocId) {
|
||||
@@ -1239,13 +1307,23 @@ impl LspManager {
|
||||
fn snapshot(&self, doc: DocId) -> IdeResult<DocSnap> {
|
||||
let docs = self.docs.lock();
|
||||
let d = docs.get(&doc).ok_or_else(|| IdeError::not_found(format!("{doc} is not open")))?;
|
||||
// A cell is asked only of the servers its notebook is open on (those that sync notebooks).
|
||||
let servers = d
|
||||
.servers
|
||||
.iter()
|
||||
.filter(|a| {
|
||||
d.notebook.is_none()
|
||||
|| a.opened_gen.is_some_and(|g| a.server.running().is_some_and(|run| run.generation == g))
|
||||
})
|
||||
.map(|a| a.server.clone())
|
||||
.collect();
|
||||
Ok(DocSnap {
|
||||
version: d.version,
|
||||
path: d.path.clone(),
|
||||
uri: d.uri.clone(),
|
||||
language: d.language.clone(),
|
||||
text: d.text.clone(),
|
||||
servers: d.servers.iter().map(|a| a.server.clone()).collect(),
|
||||
servers,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3020,8 +3098,9 @@ impl LspManager {
|
||||
let original = uri.to_owned();
|
||||
let archive = sources.clone();
|
||||
let found = tokio::task::spawn_blocking(move || {
|
||||
library_source::source_in_archive(&original, &archive).map(|(uri, text)| {
|
||||
let range = name.as_deref().map(|n| library_source::declaration_range(&text, n)).unwrap_or_default();
|
||||
library_source::source_in_archive(&original, &archive, None).map(|(uri, loaded)| {
|
||||
let range =
|
||||
name.as_deref().map(|n| library_source::declaration_range(&loaded.text, n)).unwrap_or_default();
|
||||
Location { path: uri.clone().into(), uri: Some(uri), range }
|
||||
})
|
||||
})
|
||||
@@ -3063,28 +3142,10 @@ impl LspManager {
|
||||
if scheme == "jar" {
|
||||
let found = {
|
||||
let uri = uri.to_owned();
|
||||
tokio::task::spawn_blocking(move || match library_source::parse(&uri) {
|
||||
Some(e) if !e.entry.ends_with(".class") => {
|
||||
library_source::read_entry(&e.jar, &e.entry).map(|text| (e.entry, text))
|
||||
}
|
||||
Some(_) => library_source::class_source(&uri)
|
||||
.and_then(|(source, text)| Some((library_source::parse(&source)?.entry, text))),
|
||||
None => None,
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
tokio::task::spawn_blocking(move || read_jar_source(&uri, None)).await.ok().flatten()
|
||||
};
|
||||
if let Some((entry, text)) = found {
|
||||
let title = entry.rsplit('/').next().unwrap_or(&entry).to_owned();
|
||||
return Ok(self.store_virtual(
|
||||
uri,
|
||||
text,
|
||||
Some(title),
|
||||
None,
|
||||
&[],
|
||||
self.origin_server(uri).map(|s| s.id),
|
||||
));
|
||||
if let Some((entry, loaded)) = found {
|
||||
return Ok(self.store_jar_entry(uri, entry, loaded));
|
||||
}
|
||||
}
|
||||
let class_file = scheme == "jdt" || (scheme == "file" && uri.ends_with(".class"));
|
||||
@@ -3155,7 +3216,21 @@ impl LspManager {
|
||||
continue;
|
||||
}
|
||||
};
|
||||
return Ok(self.store_virtual(uri, text, None, description, &languages, Some(t.server.id)));
|
||||
// jdtls decodes attached sources itself: its attachment encoding is
|
||||
// the document's real charset, and editable ones can be re-decoded.
|
||||
let decoding = if scheme == "jdt" && t.server.name == jdtls::SERVER && !virtual_doc::is_decompiled(&text) {
|
||||
match self.jdt_source_attachment(uri).await {
|
||||
Some(att) if att.path.is_some() => Decoding {
|
||||
encoding: att.encoding.unwrap_or_else(|| "utf-8".into()),
|
||||
has_bom: false,
|
||||
can_reload: att.can_edit_encoding,
|
||||
},
|
||||
_ => Decoding::server_text(),
|
||||
}
|
||||
} else {
|
||||
Decoding::server_text()
|
||||
};
|
||||
return Ok(self.store_virtual(uri, text, None, description, &languages, Some(t.server.id), decoding));
|
||||
}
|
||||
if jar_class && error.is_none() {
|
||||
return Err(IdeError::not_found(format!(
|
||||
@@ -3167,6 +3242,7 @@ impl LspManager {
|
||||
}
|
||||
|
||||
/// Caches `text` as the virtual document `uri`, titled `title` (else after the URI).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn store_virtual(
|
||||
&self,
|
||||
uri: &str,
|
||||
@@ -3175,6 +3251,7 @@ impl LspManager {
|
||||
description: Option<String>,
|
||||
languages: &[LanguageId],
|
||||
server: Option<ide_core::ServerId>,
|
||||
decoding: Decoding,
|
||||
) -> VirtualDocument {
|
||||
let decompiled = if virtual_doc::scheme(uri) == "csharp" {
|
||||
virtual_doc::is_csharp_decompiled(uri)
|
||||
@@ -3195,6 +3272,9 @@ impl LspManager {
|
||||
title,
|
||||
description: description.or_else(|| virtual_doc::description(uri)),
|
||||
decompiled,
|
||||
encoding: decoding.encoding,
|
||||
has_bom: decoding.has_bom,
|
||||
can_reload_encoding: decoding.can_reload,
|
||||
};
|
||||
if let Some(server) = server {
|
||||
self.virtual_origins.lock().entry(uri.to_owned()).or_insert(server);
|
||||
@@ -3207,6 +3287,163 @@ impl LspManager {
|
||||
doc
|
||||
}
|
||||
|
||||
/// Caches a decoded `jar:` entry under the document's original URI, titled
|
||||
/// after the entry; the backend owns these bytes, so they can be re-decoded.
|
||||
fn store_jar_entry(&self, uri: &str, entry: String, loaded: ide_vfs::LoadedText) -> VirtualDocument {
|
||||
let title = entry.rsplit('/').next().unwrap_or(&entry).to_owned();
|
||||
self.store_virtual(
|
||||
uri,
|
||||
loaded.text,
|
||||
Some(title),
|
||||
None,
|
||||
&[],
|
||||
self.origin_server(uri).map(|s| s.id),
|
||||
Decoding { encoding: loaded.encoding, has_bom: loaded.has_bom, can_reload: true },
|
||||
)
|
||||
}
|
||||
|
||||
/// The source attachment jdtls resolves for a `jdt:` class; `None` when the
|
||||
/// command is unavailable or the class's container cannot be queried.
|
||||
async fn jdt_source_attachment(&self, uri: &str) -> Option<SourceAttachment> {
|
||||
let result = self
|
||||
.jdtls_command(
|
||||
&self.project_root,
|
||||
"java.project.resolveSourceAttachment",
|
||||
vec![json!({ "classFileUri": uri })],
|
||||
)
|
||||
.await
|
||||
.ok()?;
|
||||
if result.get("errorMessage").and_then(Value::as_str).is_some_and(|s| !s.is_empty()) {
|
||||
return None;
|
||||
}
|
||||
let attrs = result.get("attributes")?;
|
||||
let non_empty = |key: &str| attrs.get(key).and_then(Value::as_str).filter(|s| !s.is_empty()).map(str::to_owned);
|
||||
Some(SourceAttachment {
|
||||
path: non_empty("sourceAttachmentPath"),
|
||||
encoding: non_empty("sourceAttachmentEncoding"),
|
||||
can_edit_encoding: attrs.get("canEditEncoding").and_then(Value::as_bool).unwrap_or(true),
|
||||
})
|
||||
}
|
||||
|
||||
/// Updates a `jdt:` class's source attachment encoding. The command drops
|
||||
/// the attachment when the path is omitted, so the resolved path is always
|
||||
/// sent along with the encoding.
|
||||
async fn update_jdt_source_encoding(&self, uri: &str, path: &str, encoding: &str) -> IdeResult<()> {
|
||||
let result = self
|
||||
.jdtls_command(
|
||||
&self.project_root,
|
||||
"java.project.updateSourceAttachment",
|
||||
vec![json!({
|
||||
"classFileUri": uri,
|
||||
"attributes": { "sourceAttachmentPath": path, "sourceAttachmentEncoding": encoding },
|
||||
})],
|
||||
)
|
||||
.await?;
|
||||
if let Some(error) = result.get("errorMessage").and_then(Value::as_str).filter(|s| !s.is_empty()) {
|
||||
return Err(IdeError::unavailable(error));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Waits until the resolved attachment reports `encoding`; `false` once
|
||||
/// `until` passes. Maven applies attachment updates in a background job,
|
||||
/// so the new encoding is not visible immediately after the update.
|
||||
async fn wait_for_jdt_source_encoding(&self, uri: &str, encoding: &str, until: tokio::time::Instant) -> bool {
|
||||
loop {
|
||||
match self.jdt_source_attachment(uri).await {
|
||||
Some(att) if att.encoding.as_deref() == Some(encoding) => return true,
|
||||
_ if tokio::time::Instant::now() < until => {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(150)).await;
|
||||
}
|
||||
_ => return false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// "Reload with Encoding" for a virtual document: re-reads its origin with
|
||||
/// `encoding` and replaces the cached content. `jar:` entries are re-read
|
||||
/// here; `jdt:` sources are decoded by jdtls, so its source attachment
|
||||
/// encoding is updated and the contents fetched again (keeping the server's
|
||||
/// text and positions in sync). Server-generated content cannot be re-decoded.
|
||||
pub async fn reload_virtual(&self, uri: &str, encoding: &str) -> IdeResult<VirtualDocument> {
|
||||
ide_vfs::encode("", encoding, false)?;
|
||||
match virtual_doc::scheme(uri) {
|
||||
"jar" => {
|
||||
let (owned_uri, owned_encoding) = (uri.to_owned(), encoding.to_owned());
|
||||
let found = tokio::task::spawn_blocking(move || read_jar_source(&owned_uri, Some(&owned_encoding)))
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
let Some((entry, loaded)) = found else {
|
||||
return Err(IdeError::not_found(format!(
|
||||
"no source is available for {}",
|
||||
virtual_doc::title(uri, false)
|
||||
)));
|
||||
};
|
||||
Ok(self.store_jar_entry(uri, entry, loaded))
|
||||
}
|
||||
"jdt" if self.origin_server(uri).is_none_or(|s| s.name == jdtls::SERVER) => {
|
||||
let Some(att) = self.jdt_source_attachment(uri).await else {
|
||||
return Err(IdeError::unavailable("jdtls cannot resolve the source attachment of this class"));
|
||||
};
|
||||
let Some(path) = att.path else {
|
||||
return Err(IdeError::invalid("this class has no attached sources to re-decode"));
|
||||
};
|
||||
if !att.can_edit_encoding {
|
||||
return Err(IdeError::invalid("the source attachment encoding of this library cannot be changed"));
|
||||
}
|
||||
self.update_jdt_source_encoding(uri, &path, encoding).await?;
|
||||
// Maven containers apply the update in a background job (persist
|
||||
// attached sources, then refresh the classpath): wait until the
|
||||
// resolved attachment reports the new encoding, else the refetch
|
||||
// below would still decode with the old one.
|
||||
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(10);
|
||||
let first_try = tokio::time::Instant::now() + std::time::Duration::from_secs(3);
|
||||
if !self.wait_for_jdt_source_encoding(uri, encoding, first_try).await {
|
||||
// jdtls matches the existing encoding attribute by reference
|
||||
// (`==`), so an entry restored from the saved container state
|
||||
// gains a duplicate attribute and keeps the old value. That
|
||||
// update still refreshes the container, after which a second
|
||||
// update replaces the value correctly.
|
||||
self.update_jdt_source_encoding(uri, &path, encoding).await?;
|
||||
if !self.wait_for_jdt_source_encoding(uri, encoding, deadline).await {
|
||||
return Err(IdeError::unavailable(
|
||||
"jdtls did not apply the source attachment encoding; try reloading again",
|
||||
));
|
||||
}
|
||||
}
|
||||
// JDT keeps the class file's decoded buffer cached, so
|
||||
// classFileContents would still return the old decoding: re-read
|
||||
// the attached source entry here, like `jar:` documents. The
|
||||
// server's open document is kept in sync by the regular
|
||||
// document change flow.
|
||||
let (owned_uri, owned_path, owned_encoding) = (uri.to_owned(), path.clone(), encoding.to_owned());
|
||||
let found = tokio::task::spawn_blocking(move || {
|
||||
library_source::source_in_archive(&owned_uri, Path::new(&owned_path), Some(&owned_encoding))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
if let Some((_source_uri, loaded)) = found {
|
||||
return Ok(self.store_virtual(
|
||||
uri,
|
||||
loaded.text,
|
||||
None,
|
||||
None,
|
||||
&[],
|
||||
self.origin_server(uri).map(|s| s.id),
|
||||
Decoding { encoding: loaded.encoding, has_bom: loaded.has_bom, can_reload: true },
|
||||
));
|
||||
}
|
||||
// Attachments that are not readable archives (e.g. folders):
|
||||
// fall back to the server's contents.
|
||||
self.virtual_docs.lock().remove(uri);
|
||||
self.virtual_document(uri).await
|
||||
}
|
||||
_ => Err(IdeError::invalid(
|
||||
"this document's content is generated by a language server and cannot be re-decoded with another encoding",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Called when a server sends `workspace/applyEdit`; installed by `src-tauri`
|
||||
/// so edits flow through `ide-workspace::Documents`.
|
||||
pub fn set_config_hook(&self, hook: Box<ConfigFn>) {
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
//! it in the Maven or Gradle cache.
|
||||
|
||||
use ide_core::{Pos, Range};
|
||||
use ide_vfs::LoadedText;
|
||||
use percent_encoding::{AsciiSet, CONTROLS, percent_decode_str, utf8_percent_encode};
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// An entry of an archive named by a `jar:` URI.
|
||||
@@ -57,29 +57,32 @@ impl JarEntry {
|
||||
}
|
||||
}
|
||||
|
||||
/// The text of `entry` in the archive `jar`, when it is a UTF-8 file.
|
||||
pub(crate) fn read_entry(jar: &Path, entry: &str) -> Option<String> {
|
||||
let mut zip = zip::ZipArchive::new(std::fs::File::open(jar).ok()?).ok()?;
|
||||
let mut file = zip.by_name(entry).ok()?;
|
||||
let mut text = String::new();
|
||||
file.read_to_string(&mut text).ok()?;
|
||||
Some(text)
|
||||
/// The text of `entry` in the archive `jar`, decoded like a file opened in the
|
||||
/// editor; `encoding` decodes with it instead of detecting ("Reload with
|
||||
/// Encoding"). `None` for binary or unreadable entries.
|
||||
pub(crate) fn read_entry(jar: &Path, entry: &str, encoding: Option<&str>) -> Option<LoadedText> {
|
||||
let path = ide_vfs::archive_entry_path(jar, entry);
|
||||
let loaded = match encoding {
|
||||
Some(enc) => ide_vfs::load_text_as(&path, enc).ok()?,
|
||||
None => ide_vfs::load_text(&path).ok()?,
|
||||
};
|
||||
(!loaded.binary).then_some(loaded)
|
||||
}
|
||||
|
||||
/// The library source for a class entry: its `.kt` / `.java` file in the
|
||||
/// artifact's sources jar, as `(uri, text)`.
|
||||
pub(crate) fn class_source(uri: &str) -> Option<(String, String)> {
|
||||
pub(crate) fn class_source(uri: &str, encoding: Option<&str>) -> Option<(String, LoadedText)> {
|
||||
let jar = parse(uri)?;
|
||||
let class = jar.entry.strip_suffix(".class")?;
|
||||
for sources in sources_jars(&jar.jar) {
|
||||
if let Some((entry, text)) = source_entry(&sources, class) {
|
||||
if let Some((entry, text)) = source_entry(&sources, class, encoding) {
|
||||
return Some((jar.uri_of(&sources, &entry), text));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub(crate) fn source_in_archive(uri: &str, sources: &Path) -> Option<(String, String)> {
|
||||
pub(crate) fn source_in_archive(uri: &str, sources: &Path, encoding: Option<&str>) -> Option<(String, LoadedText)> {
|
||||
let jar = if uri.starts_with("jdt://contents/") {
|
||||
let path = uri.split('?').next()?.strip_prefix("jdt://contents/")?;
|
||||
let parts: Vec<_> = path.split('/').collect();
|
||||
@@ -96,7 +99,7 @@ pub(crate) fn source_in_archive(uri: &str, sources: &Path) -> Option<(String, St
|
||||
parse(uri)?
|
||||
};
|
||||
let class = jar.entry.strip_suffix(".class")?;
|
||||
let (entry, text) = source_entry(sources, class)?;
|
||||
let (entry, text) = source_entry(sources, class, encoding)?;
|
||||
Some((jar.uri_of(sources, &entry), text))
|
||||
}
|
||||
|
||||
@@ -117,8 +120,8 @@ fn sources_jars(jar: &Path) -> Vec<PathBuf> {
|
||||
}
|
||||
|
||||
/// The source file of `class` (`pkg/Name`, `pkg/Outer$Inner`, `pkg/FileKt`) in `sources`.
|
||||
fn source_entry(sources: &Path, class: &str) -> Option<(String, String)> {
|
||||
let mut zip = zip::ZipArchive::new(std::fs::File::open(sources).ok()?).ok()?;
|
||||
fn source_entry(sources: &Path, class: &str, encoding: Option<&str>) -> Option<(String, LoadedText)> {
|
||||
let zip = zip::ZipArchive::new(std::fs::File::open(sources).ok()?).ok()?;
|
||||
let (package, name) = class.rsplit_once('/').unwrap_or(("", class));
|
||||
let outer = name.split('$').next().unwrap_or(name);
|
||||
let dir = if package.is_empty() { String::new() } else { format!("{package}/") };
|
||||
@@ -127,13 +130,9 @@ fn source_entry(sources: &Path, class: &str) -> Option<(String, String)> {
|
||||
if let Some(file) = outer.strip_suffix("Kt").filter(|f| !f.is_empty()) {
|
||||
candidates.push(format!("{dir}{file}.kt"));
|
||||
}
|
||||
let read = |zip: &mut zip::ZipArchive<std::fs::File>, entry: &str| {
|
||||
let mut text = String::new();
|
||||
zip.by_name(entry).ok()?.read_to_string(&mut text).ok()?;
|
||||
Some(text)
|
||||
};
|
||||
let read = |entry: &str| read_entry(sources, entry, encoding);
|
||||
for entry in &candidates {
|
||||
if let Some(text) = read(&mut zip, entry) {
|
||||
if let Some(text) = read(entry) {
|
||||
return Some((entry.clone(), text));
|
||||
}
|
||||
}
|
||||
@@ -145,8 +144,8 @@ fn source_entry(sources: &Path, class: &str) -> Option<(String, String)> {
|
||||
.collect();
|
||||
let jvm_name = format!("@file:JvmName(\"{outer}\")");
|
||||
for entry in kotlin_files {
|
||||
if let Some(text) = read(&mut zip, &entry)
|
||||
&& text.contains(&jvm_name)
|
||||
if let Some(text) = read(&entry)
|
||||
&& text.text.contains(&jvm_name)
|
||||
{
|
||||
return Some((entry, text));
|
||||
}
|
||||
@@ -233,11 +232,15 @@ mod tests {
|
||||
use std::io::Write;
|
||||
|
||||
fn jar(path: &Path, entries: &[(&str, &str)]) {
|
||||
jar_bytes(path, &entries.iter().map(|(n, t)| (*n, t.as_bytes())).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
fn jar_bytes(path: &Path, entries: &[(&str, &[u8])]) {
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
let mut zip = zip::ZipWriter::new(std::fs::File::create(path).unwrap());
|
||||
for (name, text) in entries {
|
||||
for (name, bytes) in entries {
|
||||
zip.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zip.write_all(text.as_bytes()).unwrap();
|
||||
zip.write_all(bytes).unwrap();
|
||||
}
|
||||
zip.finish().unwrap();
|
||||
}
|
||||
@@ -274,12 +277,12 @@ mod tests {
|
||||
"jdt://contents/lib-1.jar/p/Lib.java?=project/escaped-jar-path",
|
||||
"jar:file:///m2/g/lib/1/lib-1.jar!/p/Lib$Inner.class",
|
||||
] {
|
||||
let (uri, text) = source_in_archive(uri, &sources).unwrap();
|
||||
let (uri, text) = source_in_archive(uri, &sources, None).unwrap();
|
||||
assert!(uri.ends_with("library%20sources.zip!/p/Lib.java"), "{uri}");
|
||||
assert!(text.contains("class Lib"));
|
||||
assert!(text.text.contains("class Lib"));
|
||||
}
|
||||
assert!(source_in_archive("jdt://contents/lib.jar/p/Missing.class", &sources).is_none());
|
||||
assert!(source_in_archive("csharp:/p/App.csproj/decompiled/Lib.cs", &sources).is_none());
|
||||
assert!(source_in_archive("jdt://contents/lib.jar/p/Missing.class", &sources, None).is_none());
|
||||
assert!(source_in_archive("csharp:/p/App.csproj/decompiled/Lib.cs", &sources, None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -312,20 +315,103 @@ mod tests {
|
||||
("org/springframework/boot/Builder.class", "Builder.kt"),
|
||||
("Root.class", "Root.java"),
|
||||
] {
|
||||
let (source_uri, text) = class_source(&uri(class)).unwrap_or_else(|| panic!("{class} in {binary:?}"));
|
||||
let (source_uri, text) =
|
||||
class_source(&uri(class), None).unwrap_or_else(|| panic!("{class} in {binary:?}"));
|
||||
let dir = class.rsplit_once('/').map_or(String::new(), |(p, _)| format!("{p}/"));
|
||||
assert!(source_uri.ends_with(&format!("-sources.jar!/{dir}{file}")), "{source_uri}");
|
||||
assert_eq!(
|
||||
parse(&source_uri).and_then(|e| read_entry(&e.jar, &e.entry)).as_deref(),
|
||||
Some(text.as_str())
|
||||
parse(&source_uri).and_then(|e| read_entry(&e.jar, &e.entry, None)).map(|l| l.text),
|
||||
Some(text.text.clone())
|
||||
);
|
||||
}
|
||||
assert_eq!(class_source(&uri("org/springframework/boot/Missing.class")), None);
|
||||
assert!(class_source(&uri("org/springframework/boot/Missing.class"), None).is_none());
|
||||
}
|
||||
// No sources jar: nothing to show from here.
|
||||
let lone = tmp.path().join("lone/x-1.jar");
|
||||
jar(&lone, &[]);
|
||||
assert_eq!(class_source(&format!("jar://{}!/X.class", lone.to_string_lossy().replace('\\', "/"))), None);
|
||||
assert!(class_source(&format!("jar://{}!/X.class", lone.to_string_lossy().replace('\\', "/")), None).is_none());
|
||||
}
|
||||
|
||||
/// The reported case: a library source file saved as GBK bytes, not UTF-8.
|
||||
const GBK_SAMPLE: &str = "package example.encoding;\n\n/** 中文编码测试。 */\npublic final class EncodingSample {\n public static String message() {\n return \"中文编码测试:是否启用\";\n }\n}\n";
|
||||
|
||||
#[test]
|
||||
fn archive_entries_decode_like_files_opened_in_the_editor() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let gbk = encoding_rs::GBK.encode(GBK_SAMPLE).0.into_owned();
|
||||
// encoding_rs' encode() falls back to UTF-8 for UTF-16 (WHATWG has no encoder for it).
|
||||
let mut utf16 = vec![0xFF, 0xFE];
|
||||
utf16.extend(GBK_SAMPLE.encode_utf16().flat_map(u16::to_le_bytes));
|
||||
let cases: Vec<(&str, Vec<u8>, &str, &str, bool)> = vec![
|
||||
("utf8.java", GBK_SAMPLE.as_bytes().to_vec(), GBK_SAMPLE, "utf-8", false),
|
||||
("bom.java", [b"\xEF\xBB\xBF", GBK_SAMPLE.as_bytes()].concat(), GBK_SAMPLE, "utf-8", true),
|
||||
("gbk.java", gbk, GBK_SAMPLE, "gbk", false),
|
||||
("utf16.java", utf16, GBK_SAMPLE, "utf-16le", true),
|
||||
];
|
||||
for (name, bytes, text, encoding, has_bom) in cases {
|
||||
let jar_path = tmp.path().join(format!("lib-{name}.jar"));
|
||||
jar_bytes(&jar_path, &[(&format!("p/{name}"), &bytes)]);
|
||||
let loaded = read_entry(&jar_path, &format!("p/{name}"), None).unwrap_or_else(|| panic!("{name}"));
|
||||
assert_eq!(loaded.text, text, "{name}");
|
||||
assert_eq!(loaded.encoding, encoding, "{name}");
|
||||
assert_eq!(loaded.has_bom, has_bom, "{name}");
|
||||
assert!(!loaded.binary, "{name}");
|
||||
}
|
||||
// Binary entries are not shown as text.
|
||||
let jar_path = tmp.path().join("bin.jar");
|
||||
jar_bytes(&jar_path, &[("p/img.png", &[0x89, b'P', b'N', b'G', 0, 0, 0, 0])]);
|
||||
assert!(read_entry(&jar_path, "p/img.png", None).is_none());
|
||||
assert!(read_entry(&jar_path, "p/missing.java", None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reload_decodes_archive_entries_with_the_chosen_encoding() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let jar_path = tmp.path().join("lib.jar");
|
||||
let gbk = encoding_rs::GBK.encode(GBK_SAMPLE).0.into_owned();
|
||||
jar_bytes(&jar_path, &[("p/EncodingSample.java", &gbk)]);
|
||||
let loaded = read_entry(&jar_path, "p/EncodingSample.java", Some("gbk")).unwrap();
|
||||
assert_eq!((loaded.text.as_str(), loaded.encoding.as_str()), (GBK_SAMPLE, "gbk"));
|
||||
// An explicit encoding wins over detection, even when it decodes to replacement characters.
|
||||
let forced = read_entry(&jar_path, "p/EncodingSample.java", Some("utf-8")).unwrap();
|
||||
assert!(forced.text.contains('\u{FFFD}'));
|
||||
assert_eq!(forced.encoding, "utf-8");
|
||||
assert!(read_entry(&jar_path, "p/EncodingSample.java", Some("not-an-encoding")).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn class_sources_in_maven_caches_decode_non_utf8() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let m2 = tmp.path().join("m2/com/example/encoding-sample/1.0.0");
|
||||
let gbk = encoding_rs::GBK.encode(GBK_SAMPLE).0.into_owned();
|
||||
let facade = "@file:JvmName(\"SampleUtils\")\npackage example.encoding\n\n/** 中文注释。 */\nfun message() = \"中文编码测试:是否启用\"\n";
|
||||
let facade_gbk = encoding_rs::GBK.encode(facade).0.into_owned();
|
||||
jar(&m2.join("encoding-sample-1.0.0.jar"), &[]);
|
||||
jar_bytes(
|
||||
&m2.join("encoding-sample-1.0.0-sources.jar"),
|
||||
&[("example/encoding/EncodingSample.java", &gbk), ("example/encoding/Renamed.kt", &facade_gbk)],
|
||||
);
|
||||
let uri = |class: &str| {
|
||||
format!("jar://{}!/{class}", m2.join("encoding-sample-1.0.0.jar").to_string_lossy().replace('\\', "/"))
|
||||
};
|
||||
for (class, text) in
|
||||
[("example/encoding/EncodingSample.class", GBK_SAMPLE), ("example/encoding/SampleUtils.class", facade)]
|
||||
{
|
||||
let (source_uri, loaded) = class_source(&uri(class), None).unwrap_or_else(|| panic!("{class}"));
|
||||
assert!(
|
||||
source_uri.ends_with("-sources.jar!/example/encoding/EncodingSample.java")
|
||||
|| source_uri.ends_with("-sources.jar!/example/encoding/Renamed.kt")
|
||||
);
|
||||
assert_eq!(loaded.text, text, "{class}");
|
||||
assert_eq!(loaded.encoding, "gbk", "{class}");
|
||||
// The manually attached archive path decodes the same way.
|
||||
let jdt = "jdt://contents/encoding-sample-1.0.0.jar/example.encoding/EncodingSample.class?=project/x";
|
||||
let (_, attached) = source_in_archive(jdt, &m2.join("encoding-sample-1.0.0-sources.jar"), None).unwrap();
|
||||
assert_eq!((attached.text.as_str(), attached.encoding.as_str()), (GBK_SAMPLE, "gbk"));
|
||||
}
|
||||
// Reload with an explicit encoding re-reads the same source.
|
||||
let (_, reloaded) = class_source(&uri("example/encoding/EncodingSample.class"), Some("gbk")).unwrap();
|
||||
assert_eq!((reloaded.text.as_str(), reloaded.encoding.as_str()), (GBK_SAMPLE, "gbk"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
//! Jupyter notebooks on servers that sync them with LSP 3.17 `notebookDocument/*` (basedpyright,
|
||||
//! Ruff). Each code cell is a document of its own, `vscode-notebook-cell:<notebook path>#<cell id>`,
|
||||
//! kept in `docs` under a [`DocId`]: completion, hover and the other requests address a cell like
|
||||
//! any open file. Only opening, changing and closing go through the notebook methods; a change in
|
||||
//! the cells' order closes and reopens the notebook.
|
||||
|
||||
use crate::server::{Running, Server};
|
||||
use crate::text::DocText;
|
||||
use crate::{Attached, DocState, LspManager, uri};
|
||||
use ide_core::{DocId, LanguageId, ServerId};
|
||||
use lsp_types as lsp;
|
||||
use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, utf8_percent_encode};
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
use specta::Type;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
|
||||
const CELL_SCHEME: &str = "vscode-notebook-cell";
|
||||
const NOTEBOOK_TYPE: &str = "jupyter-notebook";
|
||||
/// `NotebookCellKind.Code`.
|
||||
const CODE_CELL: u32 = 2;
|
||||
/// Escaped in a cell id: nbformat ids are `[a-zA-Z0-9-_]`, older notebooks may have others.
|
||||
const FRAGMENT: &AsciiSet = &NON_ALPHANUMERIC.remove(b'-').remove(b'_');
|
||||
|
||||
/// A code cell as the notebook holds it.
|
||||
pub struct NotebookCell {
|
||||
pub id: String,
|
||||
pub source: String,
|
||||
}
|
||||
|
||||
/// The document the language servers know a code cell by.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NotebookCellDoc {
|
||||
pub cell: String,
|
||||
pub doc: DocId,
|
||||
/// Also the `path` of the cell's diagnostics and of locations in it.
|
||||
pub uri: String,
|
||||
}
|
||||
|
||||
pub(crate) struct NotebookState {
|
||||
uri: lsp::Uri,
|
||||
language: LanguageId,
|
||||
version: i32,
|
||||
/// Code cells in notebook order.
|
||||
cells: Vec<(String, DocId)>,
|
||||
servers: Vec<Arc<Server>>,
|
||||
/// Servers it is open on, by instance generation.
|
||||
opened: Vec<(ServerId, u64)>,
|
||||
}
|
||||
|
||||
/// The URI of cell `cell` of the notebook at `path`: the notebook's `file:` URI with the cell
|
||||
/// scheme and the cell id as fragment (as VS Code names cells; basedpyright expects the scheme).
|
||||
pub fn cell_uri(path: &Path, cell: &str) -> String {
|
||||
let file = uri::path_to_uri_string(path);
|
||||
let rest = file.strip_prefix("file://").unwrap_or(&file);
|
||||
// `file:///C:/a` → `…:/C:/a`; a share (`file://server/s/a`) keeps its authority.
|
||||
let authority = if rest.starts_with('/') { "" } else { "//" };
|
||||
format!("{CELL_SCHEME}:{authority}{rest}#{}", utf8_percent_encode(cell, FRAGMENT))
|
||||
}
|
||||
|
||||
pub(crate) fn is_cell_uri(uri: &str) -> bool {
|
||||
uri.strip_prefix(CELL_SCHEME).is_some_and(|rest| rest.starts_with(':'))
|
||||
}
|
||||
|
||||
fn parse_uri(s: &str) -> Option<lsp::Uri> {
|
||||
lsp::Uri::from_str(s).inspect_err(|e| tracing::debug!("not a valid URI {s}: {e}")).ok()
|
||||
}
|
||||
|
||||
impl NotebookState {
|
||||
fn opened_on(&self, server: ServerId, generation: u64) -> bool {
|
||||
self.opened.contains(&(server, generation))
|
||||
}
|
||||
|
||||
/// The servers it is open on whose instance is still the one it was opened on.
|
||||
fn open_targets(&self) -> Vec<(Arc<Server>, Running)> {
|
||||
self.servers
|
||||
.iter()
|
||||
.filter_map(|s| {
|
||||
let run = s.running()?;
|
||||
self.opened_on(s.id, run.generation).then(|| (s.clone(), run))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl LspManager {
|
||||
/// Opens the notebook at `path` on its language's servers that sync notebooks, or brings
|
||||
/// them up to date with `cells` (its code cells, in order).
|
||||
pub fn sync_notebook(&self, path: &Path, language: &LanguageId, cells: &[NotebookCell]) {
|
||||
let mut notebooks = self.notebooks.lock();
|
||||
let mut changed = Vec::new();
|
||||
let start = match notebooks.get_mut(path) {
|
||||
None => {
|
||||
let Some(notebook_uri) = parse_uri(&uri::path_to_uri_string(path)) else { return };
|
||||
let servers = self.servers_for(language, path);
|
||||
let mut state = NotebookState {
|
||||
uri: notebook_uri,
|
||||
language: language.clone(),
|
||||
version: 1,
|
||||
cells: Vec::new(),
|
||||
servers: servers.clone(),
|
||||
opened: Vec::new(),
|
||||
};
|
||||
self.set_cells(path, &mut state, cells);
|
||||
self.open_notebook(&mut state, None);
|
||||
notebooks.insert(path.to_owned(), state);
|
||||
servers
|
||||
}
|
||||
Some(state) => {
|
||||
let same_cells =
|
||||
state.cells.len() == cells.len() && state.cells.iter().zip(cells).all(|((id, _), c)| *id == c.id);
|
||||
if same_cells {
|
||||
changed = self.change_cells(state, cells);
|
||||
} else {
|
||||
self.close_notebook_on_servers(state);
|
||||
state.version += 1;
|
||||
self.set_cells(path, state, cells);
|
||||
self.open_notebook(state, None);
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
drop(notebooks);
|
||||
for server in &start {
|
||||
self.ensure_started(server);
|
||||
}
|
||||
for doc in changed {
|
||||
self.schedule_pull(doc, None, crate::pull::AFTER_EDIT);
|
||||
}
|
||||
}
|
||||
|
||||
/// Closes the notebook at `path` on its servers and forgets its cells.
|
||||
pub fn close_notebook(&self, path: &Path) {
|
||||
let Some(mut state) = self.notebooks.lock().remove(path) else { return };
|
||||
self.close_notebook_on_servers(&mut state);
|
||||
let removed: Vec<DocId> = state.cells.iter().map(|(_, doc)| *doc).collect();
|
||||
self.remove_cells(&state.servers, &removed);
|
||||
}
|
||||
|
||||
/// The documents of the notebook's code cells, in notebook order; empty when it isn't open.
|
||||
pub fn notebook_cells(&self, path: &Path) -> Vec<NotebookCellDoc> {
|
||||
let notebooks = self.notebooks.lock();
|
||||
let Some(state) = notebooks.get(path) else { return Vec::new() };
|
||||
let docs = self.docs.lock();
|
||||
state
|
||||
.cells
|
||||
.iter()
|
||||
.filter_map(|(cell, doc)| {
|
||||
Some(NotebookCellDoc { cell: cell.clone(), doc: *doc, uri: docs.get(doc)?.uri.as_str().to_owned() })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Opens the notebooks of `server`'s language on its instance that just became ready.
|
||||
pub(crate) fn reopen_notebooks(&self, server: &Arc<Server>, run: &Running) {
|
||||
let mut pull = Vec::new();
|
||||
{
|
||||
let mut notebooks = self.notebooks.lock();
|
||||
for state in notebooks.values_mut() {
|
||||
if state.servers.iter().any(|s| s.id == server.id) && !state.opened_on(server.id, run.generation) {
|
||||
self.open_notebook(state, Some(server));
|
||||
pull.extend(state.cells.iter().map(|(_, doc)| *doc));
|
||||
}
|
||||
}
|
||||
}
|
||||
for doc in pull {
|
||||
self.schedule_pull(doc, Some(server), std::time::Duration::ZERO);
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds servers of the notebooks' languages that were not wanted when they opened (a
|
||||
/// fallback for a missing server, one enabled in settings) and returns them to be started.
|
||||
pub(crate) fn attach_notebook_servers(&self) -> Vec<Arc<Server>> {
|
||||
let mut start = Vec::new();
|
||||
let mut notebooks = self.notebooks.lock();
|
||||
for (path, state) in notebooks.iter_mut() {
|
||||
let added: Vec<Arc<Server>> = self
|
||||
.servers_for(&state.language, path)
|
||||
.into_iter()
|
||||
.filter(|s| !state.servers.iter().any(|known| known.id == s.id))
|
||||
.collect();
|
||||
if added.is_empty() {
|
||||
continue;
|
||||
}
|
||||
{
|
||||
let mut docs = self.docs.lock();
|
||||
for (_, doc) in &state.cells {
|
||||
if let Some(d) = docs.get_mut(doc) {
|
||||
d.servers.extend(added.iter().map(|s| Attached { server: s.clone(), opened_gen: None }));
|
||||
}
|
||||
}
|
||||
}
|
||||
state.servers.extend(added.iter().cloned());
|
||||
for server in &added {
|
||||
self.open_notebook(state, Some(server));
|
||||
}
|
||||
start.extend(added);
|
||||
}
|
||||
start
|
||||
}
|
||||
|
||||
/// Replaces the cell documents with `cells`: kept cells keep their [`DocId`].
|
||||
fn set_cells(&self, path: &Path, state: &mut NotebookState, cells: &[NotebookCell]) {
|
||||
let mut docs = self.docs.lock();
|
||||
let previous = std::mem::take(&mut state.cells);
|
||||
for cell in cells {
|
||||
let kept = previous.iter().find(|(id, _)| *id == cell.id).map(|(_, doc)| *doc);
|
||||
if let Some(doc) = kept
|
||||
&& let Some(d) = docs.get_mut(&doc)
|
||||
{
|
||||
if d.text.as_str() != cell.source {
|
||||
Arc::make_mut(&mut d.text).set(&cell.source);
|
||||
}
|
||||
d.version += 1;
|
||||
state.cells.push((cell.id.clone(), doc));
|
||||
continue;
|
||||
}
|
||||
let uri_string = cell_uri(path, &cell.id);
|
||||
let Some(cell_uri) = parse_uri(&uri_string) else { continue };
|
||||
let doc = DocId::next();
|
||||
let key = PathBuf::from(&uri_string);
|
||||
docs.insert(
|
||||
doc,
|
||||
DocState {
|
||||
canonical: key.clone(),
|
||||
path: key,
|
||||
uri: cell_uri,
|
||||
language: state.language.clone(),
|
||||
version: 1,
|
||||
text: Arc::new(DocText::new(&cell.source)),
|
||||
servers: state.servers.iter().map(|s| Attached { server: s.clone(), opened_gen: None }).collect(),
|
||||
compose_detected: false,
|
||||
notebook: Some(path.to_owned()),
|
||||
},
|
||||
);
|
||||
state.cells.push((cell.id.clone(), doc));
|
||||
}
|
||||
drop(docs);
|
||||
let removed: Vec<DocId> = previous
|
||||
.into_iter()
|
||||
.filter(|(_, doc)| !state.cells.iter().any(|(_, d)| d == doc))
|
||||
.map(|(_, d)| d)
|
||||
.collect();
|
||||
self.remove_cells(&state.servers, &removed);
|
||||
}
|
||||
|
||||
/// Sends the cells whose text differs from `cells` as one change; returns their documents.
|
||||
fn change_cells(&self, state: &mut NotebookState, cells: &[NotebookCell]) -> Vec<DocId> {
|
||||
let mut docs = self.docs.lock();
|
||||
let mut changed = Vec::new();
|
||||
let mut content = Vec::new();
|
||||
for ((_, doc), cell) in state.cells.iter().zip(cells) {
|
||||
let Some(d) = docs.get_mut(doc) else { continue };
|
||||
if d.text.as_str() == cell.source {
|
||||
continue;
|
||||
}
|
||||
Arc::make_mut(&mut d.text).set(&cell.source);
|
||||
d.version += 1;
|
||||
content.push(json!({
|
||||
"document": { "uri": d.uri, "version": d.version },
|
||||
"changes": [{ "text": cell.source }],
|
||||
}));
|
||||
changed.push(*doc);
|
||||
}
|
||||
drop(docs);
|
||||
if content.is_empty() {
|
||||
return changed;
|
||||
}
|
||||
state.version += 1;
|
||||
let params = json!({
|
||||
"notebookDocument": { "uri": state.uri, "version": state.version },
|
||||
"change": { "cells": { "textContent": content } },
|
||||
});
|
||||
for (server, run) in state.open_targets() {
|
||||
if let Err(e) = run.peer.notify("notebookDocument/didChange", params.clone()) {
|
||||
tracing::debug!("{}: notebookDocument/didChange: {e}", server.name);
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// Opens the notebook on its running servers that sync notebooks (`only`: on that one).
|
||||
fn open_notebook(&self, state: &mut NotebookState, only: Option<&Arc<Server>>) {
|
||||
let mut docs = self.docs.lock();
|
||||
let mut cells = Vec::new();
|
||||
let mut texts = Vec::new();
|
||||
for (_, doc) in &state.cells {
|
||||
let Some(d) = docs.get(doc) else { continue };
|
||||
cells.push(json!({ "kind": CODE_CELL, "document": d.uri }));
|
||||
texts.push(json!({
|
||||
"uri": d.uri,
|
||||
"languageId": d.language.as_str(),
|
||||
"version": d.version,
|
||||
"text": d.text.as_str(),
|
||||
}));
|
||||
}
|
||||
let params = json!({
|
||||
"notebookDocument": { "uri": state.uri, "notebookType": NOTEBOOK_TYPE, "version": state.version, "cells": cells },
|
||||
"cellTextDocuments": texts,
|
||||
});
|
||||
let servers: Vec<Arc<Server>> =
|
||||
state.servers.iter().filter(|s| only.is_none_or(|o| o.id == s.id)).cloned().collect();
|
||||
for server in servers {
|
||||
let Some(run) = server.running() else { continue };
|
||||
if state.opened_on(server.id, run.generation) || !run.caps.read().syncs_notebooks(state.language.as_str()) {
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = run.peer.notify("notebookDocument/didOpen", params.clone()) {
|
||||
tracing::debug!("{}: notebookDocument/didOpen: {e}", server.name);
|
||||
continue;
|
||||
}
|
||||
state.opened.retain(|(id, _)| *id != server.id);
|
||||
state.opened.push((server.id, run.generation));
|
||||
for (_, doc) in &state.cells {
|
||||
if let Some(a) = docs.get_mut(doc).and_then(|d| d.servers.iter_mut().find(|a| a.server.id == server.id))
|
||||
{
|
||||
a.opened_gen = Some(run.generation);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn close_notebook_on_servers(&self, state: &mut NotebookState) {
|
||||
let cells: Vec<Value> = {
|
||||
let docs = self.docs.lock();
|
||||
state.cells.iter().filter_map(|(_, doc)| docs.get(doc)).map(|d| json!({ "uri": d.uri })).collect()
|
||||
};
|
||||
let params = json!({ "notebookDocument": { "uri": state.uri }, "cellTextDocuments": cells });
|
||||
for (server, run) in state.open_targets() {
|
||||
if let Err(e) = run.peer.notify("notebookDocument/didClose", params.clone()) {
|
||||
tracing::debug!("{}: notebookDocument/didClose: {e}", server.name);
|
||||
}
|
||||
}
|
||||
state.opened.clear();
|
||||
let mut docs = self.docs.lock();
|
||||
for (_, doc) in &state.cells {
|
||||
if let Some(d) = docs.get_mut(doc) {
|
||||
d.servers.iter_mut().for_each(|a| a.opened_gen = None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Forgets cell documents and clears what their servers reported about them: a server need
|
||||
/// not clear the diagnostics of a cell that left the notebook.
|
||||
fn remove_cells(&self, servers: &[Arc<Server>], removed: &[DocId]) {
|
||||
let gone: Vec<DocState> = {
|
||||
let mut docs = self.docs.lock();
|
||||
removed.iter().filter_map(|doc| docs.remove(doc)).collect()
|
||||
};
|
||||
for d in gone {
|
||||
for server in servers {
|
||||
let run = server.running();
|
||||
self.forget_pulled(server, &d.path, run.as_ref());
|
||||
let enc = run.as_ref().map(|r| r.caps.read().encoding).unwrap_or_default();
|
||||
let shown = server.set_diagnostics(d.path.clone(), Vec::new());
|
||||
self.emit_diagnostics(server, d.path.clone(), shown, enc);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cell_uris_name_the_notebook_file_and_the_cell() {
|
||||
let root = if cfg!(windows) { PathBuf::from(r"C:\work\my nb") } else { PathBuf::from("/work/my nb") };
|
||||
let expected = if cfg!(windows) {
|
||||
"vscode-notebook-cell:/C:/work/my%20nb/a.ipynb#f3-x_1%2E"
|
||||
} else {
|
||||
"vscode-notebook-cell:/work/my%20nb/a.ipynb#f3-x_1%2E"
|
||||
};
|
||||
let uri = cell_uri(&root.join("a.ipynb"), "f3-x_1.");
|
||||
assert_eq!(uri, expected);
|
||||
assert!(is_cell_uri(&uri) && parse_uri(&uri).is_some());
|
||||
assert!(!is_cell_uri("file:///a.ipynb") && !is_cell_uri("vscode-notebook-cellx:/a"));
|
||||
}
|
||||
}
|
||||
@@ -357,6 +357,14 @@ pub struct VirtualDocument {
|
||||
pub description: Option<String>,
|
||||
/// Generated Java from a decompiler, rather than real source or a disassembler stub.
|
||||
pub decompiled: bool,
|
||||
/// Charset the content was decoded with (a WHATWG label, e.g. `gbk`);
|
||||
/// `utf-8` for text the server decoded itself.
|
||||
pub encoding: String,
|
||||
pub has_bom: bool,
|
||||
/// "Reload with Encoding" can re-decode the origin's bytes (archive
|
||||
/// entries, JDT sources with an editable attachment encoding); false for
|
||||
/// server-generated content such as decompiled code.
|
||||
pub can_reload_encoding: bool,
|
||||
}
|
||||
|
||||
/// Reference updates for a file / folder move (`workspace/willRenameFiles`).
|
||||
|
||||
@@ -23,9 +23,20 @@ const PATH: &AsciiSet = &CONTROLS
|
||||
.add(b'}');
|
||||
|
||||
pub fn path_to_uri_string(path: &Path) -> String {
|
||||
let s = path.to_string_lossy();
|
||||
let s = if cfg!(windows) { s.replace('\\', "/") } else { s.into_owned() };
|
||||
let s = if cfg!(windows) {
|
||||
dunce::simplified(path).to_string_lossy().replace('\\', "/")
|
||||
} else {
|
||||
path.to_string_lossy().into_owned()
|
||||
};
|
||||
let mut out = String::from("file://");
|
||||
// `\\server\share\p` → `file://server/share/p` (RFC 8089 Appendix E.3).
|
||||
let unc = s.strip_prefix("//?/UNC/").or_else(|| s.strip_prefix("//")).filter(|_| cfg!(windows));
|
||||
if let Some(unc) = unc {
|
||||
let (host, rest) = unc.split_at(unc.find('/').unwrap_or(unc.len()));
|
||||
out.extend(utf8_percent_encode(host, PATH));
|
||||
out.extend(utf8_percent_encode(rest, PATH));
|
||||
return out;
|
||||
}
|
||||
if !s.starts_with('/') {
|
||||
out.push('/');
|
||||
}
|
||||
@@ -41,21 +52,30 @@ pub fn path_to_uri(path: &Path) -> lsp_types::Uri {
|
||||
})
|
||||
}
|
||||
|
||||
/// Converts a `file:` URI to a path; `None` for other schemes.
|
||||
/// Converts a `file:` URI to a path; `None` for other schemes. On Windows a
|
||||
/// host names a share (`file://server/share/p` → `\\server\share\p`) and the
|
||||
/// drive letter is upper-cased, as the rest of the IDE spells it.
|
||||
pub fn uri_to_path(uri: &str) -> Option<PathBuf> {
|
||||
let rest = uri.strip_prefix("file://").or_else(|| uri.strip_prefix("FILE://"))?;
|
||||
// Skip an authority (`file://localhost/...`).
|
||||
let path = match rest.find('/') {
|
||||
Some(0) => rest,
|
||||
Some(i) if rest[..i].eq_ignore_ascii_case("localhost") => &rest[i..],
|
||||
_ => return None,
|
||||
};
|
||||
let (host, path) = rest.split_at(rest.find('/')?);
|
||||
let local = host.is_empty() || host.eq_ignore_ascii_case("localhost");
|
||||
let path = path.split(['?', '#']).next().unwrap_or(path);
|
||||
let decoded = percent_decode_str(path).decode_utf8().ok()?;
|
||||
if cfg!(windows) {
|
||||
if !local {
|
||||
let host = percent_decode_str(host).decode_utf8().ok()?;
|
||||
return Some(PathBuf::from(format!(r"\\{host}{}", decoded.replace('/', "\\"))));
|
||||
}
|
||||
// `/C:/x` → `C:\x`
|
||||
let trimmed = decoded.strip_prefix('/').filter(|s| s.as_bytes().get(1) == Some(&b':')).unwrap_or(&decoded);
|
||||
return Some(PathBuf::from(trimmed.replace('/', "\\")));
|
||||
let mut path = trimmed.replace('/', "\\");
|
||||
if path.as_bytes().get(1) == Some(&b':') {
|
||||
path[..1].make_ascii_uppercase();
|
||||
}
|
||||
return Some(PathBuf::from(path));
|
||||
}
|
||||
if !local {
|
||||
return None;
|
||||
}
|
||||
Some(PathBuf::from(decoded.into_owned()))
|
||||
}
|
||||
@@ -89,6 +109,40 @@ mod tests {
|
||||
assert_eq!(uri_to_path("jdt://contents/x"), None);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn remote_hosts_are_not_local_paths() {
|
||||
assert_eq!(uri_to_path("file://server/share/a.ts"), None);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn windows_uris() {
|
||||
for (uri, path) in [
|
||||
("file:///C:/a%20b/c.rs", r"C:\a b\c.rs"),
|
||||
("file:///c:/x/y.ts", r"C:\x\y.ts"),
|
||||
("file:///c%3A/x/y.ts", r"C:\x\y.ts"),
|
||||
("file:///C%3a/x", r"C:\x"),
|
||||
("file://localhost/d:/x", r"D:\x"),
|
||||
("file://server/share/a.ts", r"\\server\share\a.ts"),
|
||||
("file://server/share/a%20b/c.ts", r"\\server\share\a b\c.ts"),
|
||||
("file:////server/share/a.ts", r"\\server\share\a.ts"),
|
||||
] {
|
||||
let got = uri_to_path(uri).unwrap();
|
||||
assert_eq!(got.as_os_str(), path, "{uri}");
|
||||
}
|
||||
assert_eq!(uri_to_path("file://server"), None);
|
||||
for (path, uri, back) in [
|
||||
(r"C:\a b\c.rs", "file:///C:/a%20b/c.rs", r"C:\a b\c.rs"),
|
||||
(r"\\?\C:\x\y.ts", "file:///C:/x/y.ts", r"C:\x\y.ts"),
|
||||
(r"\\server\share\a b.ts", "file://server/share/a%20b.ts", r"\\server\share\a b.ts"),
|
||||
(r"\\?\UNC\server\share\a.ts", "file://server/share/a.ts", r"\\server\share\a.ts"),
|
||||
] {
|
||||
assert_eq!(path_to_uri(Path::new(path)).as_str(), uri, "{path}");
|
||||
assert_eq!(uri_to_path(uri).unwrap().as_os_str(), back, "{path}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn virtual_targets() {
|
||||
let jdt = "jdt://contents/rt.jar/java.lang/String.class?=p/%5C/x.jar%3Cjava.lang(String.class";
|
||||
|
||||
@@ -306,6 +306,7 @@ fn harness_configured(
|
||||
indent: None,
|
||||
ui_mode: None,
|
||||
framework_ui_modes: Vec::new(),
|
||||
framework_files: Vec::new(),
|
||||
formatter: None,
|
||||
scratch_template: None,
|
||||
tree_sitter: None,
|
||||
@@ -3668,6 +3669,340 @@ async fn jdt_class_file_contents() {
|
||||
assert_eq!(source.title, "WithSources.java");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn jdt_library_sources_reload_with_the_attachment_encoding() {
|
||||
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
|
||||
h.open("App.java", "import example.encoding.EncodingSample;\n");
|
||||
h.wait_status("jdtls", ServerStatus::Ready).await;
|
||||
let s = h.launcher.state("jdtls");
|
||||
let jdt = "jdt://contents/encoding-sample-1.0.jar/example.encoding/EncodingSample.class?=demo/%5C/m2%5C/encoding-sample-1.0.jar%3Cexample.encoding%28EncodingSample.class";
|
||||
|
||||
// The mock jdtls decodes the GBK attachment with the configured charset:
|
||||
// until the encoding is updated, the text arrives garbled (U+FFFD).
|
||||
let source = "package example.encoding;\n\n/** 中文编码测试。 */\npublic final class EncodingSample {}";
|
||||
let garbled =
|
||||
"package example.encoding;\n\n/** \u{FFFD}\u{FFFD}\u{FFFD}\u{FFFD} */\npublic final class EncodingSample {}";
|
||||
let attachment_encoding: Arc<Mutex<Option<String>>> = Arc::default();
|
||||
*s.responder.lock() = Some(Arc::new({
|
||||
let encoding = attachment_encoding.clone();
|
||||
move |_, method, params| match method {
|
||||
"java/classFileContents" => {
|
||||
let text = if encoding.lock().as_deref() == Some("gbk") { source } else { garbled };
|
||||
Some(json!(text))
|
||||
}
|
||||
"workspace/executeCommand" => match params["command"].as_str()? {
|
||||
"java.project.resolveSourceAttachment" => Some(json!({
|
||||
"errorMessage": null,
|
||||
"attributes": {
|
||||
"jarPath": "/m2/encoding-sample-1.0.jar",
|
||||
"sourceAttachmentPath": "/m2/encoding-sample-1.0-sources.jar",
|
||||
"sourceAttachmentEncoding": encoding.lock().clone(),
|
||||
"canEditEncoding": true,
|
||||
}
|
||||
})),
|
||||
"java.project.updateSourceAttachment" => {
|
||||
let attrs = ¶ms["arguments"][0]["attributes"];
|
||||
*encoding.lock() = attrs["sourceAttachmentEncoding"].as_str().map(str::to_owned);
|
||||
Some(json!({ "errorMessage": null, "attributes": null }))
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}));
|
||||
|
||||
let vd = h.mgr.virtual_document(jdt).await.unwrap();
|
||||
assert_eq!(vd.text, garbled);
|
||||
assert_eq!(vd.encoding, "utf-8", "no attachment encoding configured yet");
|
||||
assert!(vd.can_reload_encoding);
|
||||
|
||||
let reloaded = h.mgr.reload_virtual(jdt, "gbk").await.unwrap();
|
||||
assert_eq!(reloaded.text, source);
|
||||
assert_eq!(reloaded.encoding, "gbk");
|
||||
assert!(reloaded.can_reload_encoding);
|
||||
// The update kept the attachment path (omitting it would drop the sources)
|
||||
// and set the encoding; the contents were fetched again.
|
||||
let updates: Vec<Value> = s
|
||||
.requested("workspace/executeCommand")
|
||||
.into_iter()
|
||||
.filter(|p| p["command"] == "java.project.updateSourceAttachment")
|
||||
.collect();
|
||||
assert_eq!(updates.len(), 1);
|
||||
assert_eq!(updates[0]["arguments"][0]["classFileUri"], jdt);
|
||||
assert_eq!(updates[0]["arguments"][0]["attributes"]["sourceAttachmentPath"], "/m2/encoding-sample-1.0-sources.jar");
|
||||
assert_eq!(updates[0]["arguments"][0]["attributes"]["sourceAttachmentEncoding"], "gbk");
|
||||
assert!(s.requested("java/classFileContents").len() >= 2);
|
||||
// The cache serves the re-decoded text without another round trip.
|
||||
assert_eq!(h.mgr.virtual_document(jdt).await.unwrap().text, source);
|
||||
// Round trip back to UTF-8.
|
||||
let back = h.mgr.reload_virtual(jdt, "utf-8").await.unwrap();
|
||||
assert_eq!(back.text, garbled);
|
||||
assert_eq!(back.encoding, "utf-8");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn jdt_reload_waits_for_the_maven_container_refresh() {
|
||||
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
|
||||
h.open("App.java", "class App {}\n");
|
||||
h.wait_status("jdtls", ServerStatus::Ready).await;
|
||||
let s = h.launcher.state("jdtls");
|
||||
let jdt = "jdt://contents/lib.jar/p/Lib.class?=demo/%5C/m2%5C/lib.jar%3Cp%28Lib.class";
|
||||
// m2e persists and refreshes the container in a background job: resolve
|
||||
// keeps reporting the old encoding for a few calls after the update.
|
||||
let state: Arc<Mutex<(Option<String>, usize)>> = Arc::default();
|
||||
*s.responder.lock() = Some(Arc::new({
|
||||
let state = state.clone();
|
||||
move |_, method, params| match method {
|
||||
"java/classFileContents" => {
|
||||
let enc = state.lock().0.clone();
|
||||
Some(json!(if enc.as_deref() == Some("gbk") {
|
||||
"package p; /** 中文 */ class Lib {}"
|
||||
} else {
|
||||
"package p; /** \u{FFFD} */ class Lib {}"
|
||||
}))
|
||||
}
|
||||
"workspace/executeCommand" => match params["command"].as_str()? {
|
||||
"java.project.resolveSourceAttachment" => {
|
||||
let mut st = state.lock();
|
||||
st.1 += 1;
|
||||
let reported = if st.1 > 2 { st.0.clone() } else { None };
|
||||
Some(json!({
|
||||
"attributes": {
|
||||
"sourceAttachmentPath": "/m2/lib-sources.jar",
|
||||
"sourceAttachmentEncoding": reported,
|
||||
"canEditEncoding": true,
|
||||
}
|
||||
}))
|
||||
}
|
||||
"java.project.updateSourceAttachment" => {
|
||||
let mut st = state.lock();
|
||||
st.0 = params["arguments"][0]["attributes"]["sourceAttachmentEncoding"].as_str().map(str::to_owned);
|
||||
st.1 = 0;
|
||||
Some(json!({ "errorMessage": null }))
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}));
|
||||
let vd = h.mgr.virtual_document(jdt).await.unwrap();
|
||||
assert!(vd.text.contains('\u{FFFD}'));
|
||||
let reloaded = h.mgr.reload_virtual(jdt, "gbk").await.unwrap();
|
||||
assert_eq!(reloaded.text, "package p; /** 中文 */ class Lib {}");
|
||||
assert_eq!(reloaded.encoding, "gbk");
|
||||
assert!(state.lock().1 >= 3, "polled until the container reported the new encoding");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn jdt_reload_retries_when_a_restored_container_ignores_the_update() {
|
||||
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
|
||||
h.open("App.java", "class App {}\n");
|
||||
h.wait_status("jdtls", ServerStatus::Ready).await;
|
||||
let s = h.launcher.state("jdtls");
|
||||
let jdt = "jdt://contents/lib.jar/p/Lib.class?=demo/%5C/m2%5C/lib.jar%3Cp%28Lib.class";
|
||||
// jdtls replaces the encoding attribute by reference equality: an entry
|
||||
// restored from the saved container state keeps the old value on the first
|
||||
// update (a duplicate attribute is appended); only a second update, after
|
||||
// the container was refreshed, applies the new one.
|
||||
let state: Arc<Mutex<(Option<String>, usize)>> = Arc::default();
|
||||
*s.responder.lock() = Some(Arc::new({
|
||||
let state = state.clone();
|
||||
move |_, method, params| match method {
|
||||
"java/classFileContents" => {
|
||||
let enc = state.lock().0.clone();
|
||||
Some(json!(if enc.as_deref() == Some("gbk") {
|
||||
"package p; /** 中文 */ class Lib {}"
|
||||
} else {
|
||||
"package p; /** \u{FFFD} */ class Lib {}"
|
||||
}))
|
||||
}
|
||||
"workspace/executeCommand" => match params["command"].as_str()? {
|
||||
"java.project.resolveSourceAttachment" => Some(json!({
|
||||
"attributes": {
|
||||
"sourceAttachmentPath": "/m2/lib-sources.jar",
|
||||
"sourceAttachmentEncoding": state.lock().0.clone(),
|
||||
"canEditEncoding": true,
|
||||
}
|
||||
})),
|
||||
"java.project.updateSourceAttachment" => {
|
||||
let mut st = state.lock();
|
||||
st.1 += 1;
|
||||
if st.1 > 1 {
|
||||
st.0 = params["arguments"][0]["attributes"]["sourceAttachmentEncoding"]
|
||||
.as_str()
|
||||
.map(str::to_owned);
|
||||
}
|
||||
Some(json!({ "errorMessage": null }))
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}));
|
||||
let vd = h.mgr.virtual_document(jdt).await.unwrap();
|
||||
assert!(vd.text.contains('\u{FFFD}'));
|
||||
let reloaded = h.mgr.reload_virtual(jdt, "gbk").await.unwrap();
|
||||
assert_eq!(reloaded.text, "package p; /** 中文 */ class Lib {}");
|
||||
assert_eq!(reloaded.encoding, "gbk");
|
||||
let updates: Vec<Value> = s
|
||||
.requested("workspace/executeCommand")
|
||||
.into_iter()
|
||||
.filter(|p| p["command"] == "java.project.updateSourceAttachment")
|
||||
.collect();
|
||||
assert_eq!(updates.len(), 2, "the ignored first update was retried once");
|
||||
assert!(updates.iter().all(|u| u["arguments"][0]["attributes"]["sourceAttachmentPath"] == "/m2/lib-sources.jar"));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn jdt_reload_reads_the_attachment_when_the_server_buffer_is_stale() {
|
||||
use std::io::Write;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let sources = tmp.path().join("lib-sources.jar");
|
||||
let source = "package p; /** 中文 */ class Lib {}";
|
||||
let gbk = encoding_rs::GBK.encode(source).0.into_owned();
|
||||
let mut zip = zip::ZipWriter::new(std::fs::File::create(&sources).unwrap());
|
||||
zip.start_file("p/Lib.java", zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zip.write_all(&gbk).unwrap();
|
||||
zip.finish().unwrap();
|
||||
|
||||
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
|
||||
h.open("App.java", "class App {}\n");
|
||||
h.wait_status("jdtls", ServerStatus::Ready).await;
|
||||
let s = h.launcher.state("jdtls");
|
||||
let jdt = "jdt://contents/lib.jar/p/Lib.class?=demo/%5C/m2%5C/lib.jar%3Cp%28Lib.class";
|
||||
// JDT caches the decoded buffer of a class file: even after the attachment
|
||||
// encoding is updated, classFileContents keeps returning the old decoding.
|
||||
let encoding: Arc<Mutex<Option<String>>> = Arc::default();
|
||||
*s.responder.lock() = Some(Arc::new({
|
||||
let encoding = encoding.clone();
|
||||
let sources = sources.clone();
|
||||
move |_, method, params| match method {
|
||||
"java/classFileContents" => Some(json!("package p; /** \u{FFFD} */ class Lib {}")),
|
||||
"workspace/executeCommand" => match params["command"].as_str()? {
|
||||
"java.project.resolveSourceAttachment" => Some(json!({
|
||||
"attributes": {
|
||||
"sourceAttachmentPath": sources.to_string_lossy(),
|
||||
"sourceAttachmentEncoding": encoding.lock().clone(),
|
||||
"canEditEncoding": true,
|
||||
}
|
||||
})),
|
||||
"java.project.updateSourceAttachment" => {
|
||||
*encoding.lock() =
|
||||
params["arguments"][0]["attributes"]["sourceAttachmentEncoding"].as_str().map(str::to_owned);
|
||||
Some(json!({ "errorMessage": null }))
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}));
|
||||
let vd = h.mgr.virtual_document(jdt).await.unwrap();
|
||||
assert!(vd.text.contains('\u{FFFD}'));
|
||||
let reloaded = h.mgr.reload_virtual(jdt, "gbk").await.unwrap();
|
||||
assert_eq!(reloaded.text, source, "read from the attachment, not the stale server buffer");
|
||||
assert_eq!(reloaded.encoding, "gbk");
|
||||
assert!(reloaded.can_reload_encoding);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn jdt_reload_is_refused_when_the_sources_cannot_be_re_decoded() {
|
||||
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
|
||||
h.open("App.java", "class App {}\n");
|
||||
h.wait_status("jdtls", ServerStatus::Ready).await;
|
||||
let s = h.launcher.state("jdtls");
|
||||
let class =
|
||||
|name: &str| format!("jdt://contents/lib.jar/p/{name}.class?=demo/%5C/m2%5C/lib.jar%3Cp%28{name}.class");
|
||||
s.respond("java/classFileContents", json!("package p;\npublic class Lib {}"));
|
||||
|
||||
// A container that does not allow editing the attachment encoding.
|
||||
*s.responder.lock() = Some(Arc::new(|_, method, params| match method {
|
||||
"workspace/executeCommand" if params["command"] == "java.project.resolveSourceAttachment" => Some(json!({
|
||||
"attributes": { "sourceAttachmentPath": "/m2/lib-sources.jar", "canEditEncoding": false }
|
||||
})),
|
||||
_ => None,
|
||||
}));
|
||||
let vd = h.mgr.virtual_document(&class("ReadOnly")).await.unwrap();
|
||||
assert!(!vd.can_reload_encoding);
|
||||
let err = h.mgr.reload_virtual(&class("ReadOnly"), "gbk").await.unwrap_err();
|
||||
assert!(err.message.contains("cannot be changed"), "{err}");
|
||||
|
||||
// Decompiled content has no attached sources to re-decode.
|
||||
*s.responder.lock() = Some(Arc::new(|_, method, params| match method {
|
||||
"workspace/executeCommand" if params["command"] == "java.project.resolveSourceAttachment" => Some(json!({
|
||||
"attributes": { "sourceAttachmentPath": null, "canEditEncoding": true }
|
||||
})),
|
||||
_ => None,
|
||||
}));
|
||||
s.respond(
|
||||
"java/classFileContents",
|
||||
json!("// Source code is decompiled from a .class file using FernFlower decompiler (from Intellij IDEA).\npackage p;\npublic class Lib {}"),
|
||||
);
|
||||
let decompiled = h.mgr.virtual_document(&class("Decompiled")).await.unwrap();
|
||||
assert!(decompiled.decompiled && !decompiled.can_reload_encoding);
|
||||
let err = h.mgr.reload_virtual(&class("Decompiled"), "gbk").await.unwrap_err();
|
||||
assert!(err.message.contains("no attached sources"), "{err}");
|
||||
|
||||
// The resolve command fails (unqueryable container): no reload offered.
|
||||
s.errors.lock().insert("workspace/executeCommand".into(), (-32603, "unsupported".into()));
|
||||
let unresolved = h.mgr.virtual_document(&class("Unresolved")).await.unwrap();
|
||||
assert_eq!(unresolved.encoding, "utf-8");
|
||||
assert!(!unresolved.can_reload_encoding);
|
||||
let err = h.mgr.reload_virtual(&class("Unresolved"), "gbk").await.unwrap_err();
|
||||
assert!(err.message.contains("cannot resolve"), "{err}");
|
||||
|
||||
// Server-generated content of other schemes cannot be re-decoded at all.
|
||||
let err = h.mgr.reload_virtual("csharp:/p/App.csproj/decompiled/Lib.cs", "gbk").await.unwrap_err();
|
||||
assert!(err.message.contains("generated by a language server"), "{err}");
|
||||
// Unknown encodings are refused before any server round trip.
|
||||
assert!(h.mgr.reload_virtual(&class("ReadOnly"), "not-an-encoding").await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn jar_library_sources_reload_with_another_encoding() {
|
||||
use std::io::Write;
|
||||
let repo = tempfile::tempdir().unwrap();
|
||||
let dir = repo.path().join("com/example/encoding-sample/1.0.0");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let source = "package example.encoding\n\n/** 中文编码测试。 */\nfun message() = \"中文编码测试:是否启用\"\n";
|
||||
let gbk = encoding_rs::GBK.encode(source).0.into_owned();
|
||||
let jar_bytes = |path: &Path, entries: &[(&str, &[u8])]| {
|
||||
let mut zip = zip::ZipWriter::new(std::fs::File::create(path).unwrap());
|
||||
for (name, bytes) in entries {
|
||||
zip.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zip.write_all(bytes).unwrap();
|
||||
}
|
||||
zip.finish().unwrap();
|
||||
};
|
||||
jar_bytes(&dir.join("encoding-sample-1.0.jar"), &[("example/encoding/EncodingSampleKt.class", b"\0")]);
|
||||
jar_bytes(&dir.join("encoding-sample-1.0-sources.jar"), &[("example/encoding/EncodingSample.kt", &gbk)]);
|
||||
let jar_uri = |jar: &Path, entry: &str| format!("jar://{}!/{entry}", uri_of(jar).trim_start_matches("file://"));
|
||||
|
||||
let h = harness_for("kotlin", "kt", &[("kotlin-lsp", incremental_caps())], &[]);
|
||||
h.open("Main.kt", "fun main() {}\n");
|
||||
h.wait_status("kotlin-lsp", ServerStatus::Ready).await;
|
||||
let class = jar_uri(&dir.join("encoding-sample-1.0.jar"), "example/encoding/EncodingSampleKt.class");
|
||||
let entry = jar_uri(&dir.join("encoding-sample-1.0-sources.jar"), "example/encoding/EncodingSample.kt");
|
||||
|
||||
let vd = h.mgr.virtual_document(&class).await.unwrap();
|
||||
assert_eq!(vd.text, source);
|
||||
assert_eq!(vd.encoding, "gbk");
|
||||
assert!(vd.can_reload_encoding);
|
||||
// An explicit encoding wins over detection, even when it garbles the text.
|
||||
let forced = h.mgr.reload_virtual(&class, "utf-8").await.unwrap();
|
||||
assert!(forced.text.contains('\u{FFFD}'));
|
||||
assert_eq!(forced.encoding, "utf-8");
|
||||
let back = h.mgr.reload_virtual(&class, "gbk").await.unwrap();
|
||||
assert_eq!((back.text.as_str(), back.encoding.as_str()), (source, "gbk"));
|
||||
// Non-class entries reload the same way.
|
||||
let entry_vd = h.mgr.virtual_document(&entry).await.unwrap();
|
||||
assert_eq!((entry_vd.text.as_str(), entry_vd.encoding.as_str()), (source, "gbk"));
|
||||
let entry_reload = h.mgr.reload_virtual(&entry, "gbk").await.unwrap();
|
||||
assert_eq!(entry_reload.text, source);
|
||||
// Missing sources fail cleanly.
|
||||
let missing = jar_uri(&dir.join("encoding-sample-1.0.jar"), "example/encoding/Missing.class");
|
||||
assert!(h.mgr.reload_virtual(&missing, "gbk").await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn class_file_opening_routes_sources_and_binary_documents() {
|
||||
let mut caps = incremental_caps();
|
||||
@@ -6446,3 +6781,142 @@ async fn gomod_files_are_served_by_gopls() {
|
||||
assert_eq!(items[0].message, "missing requirement");
|
||||
mgr.shutdown_all().await;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ notebooks
|
||||
|
||||
fn notebook_caps() -> Value {
|
||||
let mut caps = incremental_caps();
|
||||
caps["notebookDocumentSync"] = json!({
|
||||
"notebookSelector": [{
|
||||
"notebook": { "scheme": "file", "notebookType": "jupyter-notebook" },
|
||||
"cells": [{ "language": "python" }]
|
||||
}]
|
||||
});
|
||||
caps
|
||||
}
|
||||
|
||||
fn cells(cells: &[(&str, &str)]) -> Vec<ide_lsp::NotebookCell> {
|
||||
cells.iter().map(|(id, source)| ide_lsp::NotebookCell { id: (*id).into(), source: (*source).into() }).collect()
|
||||
}
|
||||
|
||||
fn cell_texts(open: &Value) -> Vec<(String, String)> {
|
||||
open["cellTextDocuments"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|c| (c["uri"].as_str().unwrap().to_owned(), c["text"].as_str().unwrap().to_owned()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn notebook_cells_are_documents_of_the_servers_that_sync_notebooks() {
|
||||
let h = harness_for("python", "py", &[("nb", notebook_caps()), ("plain", incremental_caps())], &[]);
|
||||
let path = h.root.join("analysis.ipynb");
|
||||
let python = LanguageId::new("python");
|
||||
h.mgr.sync_notebook(&path, &python, &cells(&[("a", "import os\n"), ("b", "os.pa")]));
|
||||
h.wait_status("nb", ServerStatus::Ready).await;
|
||||
h.wait_status("plain", ServerStatus::Ready).await;
|
||||
let (nb, plain) = (h.launcher.state("nb"), h.launcher.state("plain"));
|
||||
eventually(|| !nb.notified("notebookDocument/didOpen").is_empty()).await;
|
||||
|
||||
let docs = h.mgr.notebook_cells(&path);
|
||||
assert_eq!(docs.iter().map(|d| d.cell.as_str()).collect::<Vec<_>>(), ["a", "b"]);
|
||||
assert_eq!(docs[0].uri, ide_lsp::cell_uri(&path, "a"));
|
||||
let open = &nb.notified("notebookDocument/didOpen")[0];
|
||||
assert_eq!(open["notebookDocument"]["uri"], uri_of(&path));
|
||||
assert_eq!(open["notebookDocument"]["notebookType"], "jupyter-notebook");
|
||||
assert_eq!(open["notebookDocument"]["cells"][1], json!({ "kind": 2, "document": docs[1].uri }));
|
||||
assert_eq!(
|
||||
cell_texts(open),
|
||||
[(docs[0].uri.clone(), "import os\n".to_owned()), (docs[1].uri.clone(), "os.pa".to_owned())]
|
||||
);
|
||||
assert!(plain.notified("notebookDocument/didOpen").is_empty() && plain.notified("textDocument/didOpen").is_empty());
|
||||
|
||||
// Requests address a cell like a file, and go only to the server it is open on.
|
||||
nb.respond("textDocument/completion", json!({ "isIncomplete": false, "items": [{ "label": "path" }] }));
|
||||
plain.respond("textDocument/completion", json!({ "isIncomplete": false, "items": [{ "label": "wrong" }] }));
|
||||
let list = h.mgr.completion(docs[1].doc, Pos::new(0, 5), None, Default::default()).await.unwrap();
|
||||
assert_eq!(list.items.iter().map(|i| i.label.as_str()).collect::<Vec<_>>(), ["path"]);
|
||||
assert_eq!(nb.requested("textDocument/completion")[0]["textDocument"]["uri"], docs[1].uri);
|
||||
assert!(plain.requested("textDocument/completion").is_empty());
|
||||
|
||||
// Its diagnostics are reported under the cell's URI.
|
||||
nb.peer()
|
||||
.notify(
|
||||
"textDocument/publishDiagnostics",
|
||||
json!({ "uri": docs[1].uri, "diagnostics": [{
|
||||
"range": {"start":{"line":0,"character":3},"end":{"line":0,"character":5}}, "message": "no attribute pa"
|
||||
}] }),
|
||||
)
|
||||
.unwrap();
|
||||
let cell_path = PathBuf::from(&docs[1].uri);
|
||||
eventually(|| {
|
||||
h.events
|
||||
.lock()
|
||||
.iter()
|
||||
.any(|e| matches!(e, IdeEvent::Diagnostics { path, items, .. } if path.0 == cell_path && items.len() == 1))
|
||||
})
|
||||
.await;
|
||||
|
||||
h.mgr.close_notebook(&path);
|
||||
eventually(|| !nb.notified("notebookDocument/didClose").is_empty()).await;
|
||||
assert_eq!(nb.notified("notebookDocument/didClose")[0]["cellTextDocuments"].as_array().unwrap().len(), 2);
|
||||
assert!(h.mgr.notebook_cells(&path).is_empty());
|
||||
assert!(h.mgr.completion(docs[1].doc, Pos::new(0, 5), None, Default::default()).await.is_err());
|
||||
let last = h
|
||||
.events
|
||||
.lock()
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
IdeEvent::Diagnostics { path, items, source } if path.0 == cell_path && source == "nb" => Some(items.len()),
|
||||
_ => None,
|
||||
})
|
||||
.next_back();
|
||||
assert_eq!(last, Some(0), "a closed notebook's diagnostics are cleared");
|
||||
h.mgr.shutdown_all().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn notebook_edits_change_cell_text_and_reorders_reopen_it() {
|
||||
let h = harness_for("python", "py", &[("nb", notebook_caps())], &[]);
|
||||
let path = h.root.join("n.ipynb");
|
||||
let python = LanguageId::new("python");
|
||||
h.mgr.sync_notebook(&path, &python, &cells(&[("a", "x = 1\n"), ("b", "print(x)\n")]));
|
||||
h.wait_status("nb", ServerStatus::Ready).await;
|
||||
let nb = h.launcher.state("nb");
|
||||
eventually(|| nb.notified("notebookDocument/didOpen").len() == 1).await;
|
||||
let before = h.mgr.notebook_cells(&path);
|
||||
|
||||
// Typing in one cell sends only that cell's text.
|
||||
h.mgr.sync_notebook(&path, &python, &cells(&[("a", "x = 1\n"), ("b", "print(x + 1)\n")]));
|
||||
h.mgr.sync_notebook(&path, &python, &cells(&[("a", "x = 1\n"), ("b", "print(x + 1)\n")]));
|
||||
eventually(|| !nb.notified("notebookDocument/didChange").is_empty()).await;
|
||||
let changes = nb.notified("notebookDocument/didChange");
|
||||
assert_eq!(changes.len(), 1, "an unchanged notebook sends nothing");
|
||||
let content = &changes[0]["change"]["cells"]["textContent"];
|
||||
assert_eq!(content.as_array().unwrap().len(), 1);
|
||||
assert_eq!(content[0]["document"]["uri"], before[1].uri);
|
||||
assert_eq!(content[0]["changes"], json!([{ "text": "print(x + 1)\n" }]));
|
||||
assert_eq!(changes[0]["notebookDocument"]["version"], 2);
|
||||
|
||||
// Inserting and moving cells reopens the notebook; kept cells keep their documents.
|
||||
h.mgr.sync_notebook(&path, &python, &cells(&[("c", "import math\n"), ("b", "print(x + 1)\n"), ("a", "x = 1\n")]));
|
||||
eventually(|| nb.notified("notebookDocument/didOpen").len() == 2).await;
|
||||
assert_eq!(nb.notified("notebookDocument/didClose").len(), 1);
|
||||
let after = h.mgr.notebook_cells(&path);
|
||||
assert_eq!(after.iter().map(|d| d.cell.as_str()).collect::<Vec<_>>(), ["c", "b", "a"]);
|
||||
assert_eq!((after[1].doc, after[2].doc), (before[1].doc, before[0].doc));
|
||||
let reopened = &nb.notified("notebookDocument/didOpen")[1];
|
||||
assert_eq!(
|
||||
cell_texts(reopened).into_iter().map(|(_, t)| t).collect::<Vec<_>>(),
|
||||
["import math\n", "print(x + 1)\n", "x = 1\n"]
|
||||
);
|
||||
|
||||
// A restarted server gets the notebook again.
|
||||
let launches = nb.launches.load(Ordering::SeqCst);
|
||||
let server = h.mgr.servers()[0].id;
|
||||
h.mgr.restart(server).await.unwrap();
|
||||
eventually(|| nb.launches.load(Ordering::SeqCst) > launches && nb.notified("notebookDocument/didOpen").len() == 3)
|
||||
.await;
|
||||
h.mgr.shutdown_all().await;
|
||||
}
|
||||
@@ -55,6 +55,7 @@ fn lang(id: &str, ext: &str, server: LanguageServerConfig) -> LanguageConfig {
|
||||
indent: None,
|
||||
ui_mode: None,
|
||||
framework_ui_modes: Vec::new(),
|
||||
framework_files: Vec::new(),
|
||||
formatter: None,
|
||||
scratch_template: None,
|
||||
tree_sitter: None,
|
||||
@@ -919,3 +920,99 @@ async fn angular_language_server_end_to_end() {
|
||||
}
|
||||
tokio::time::timeout(Duration::from_secs(10), mgr.shutdown_all()).await.unwrap();
|
||||
}
|
||||
|
||||
/// A notebook's code cells on basedpyright and Ruff (LSP 3.17 notebook sync): names flow from
|
||||
/// earlier cells, IPython magics parse, and locations and diagnostics name the cells.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[ignore = "requires ruff and basedpyright on PATH"]
|
||||
async fn basedpyright_and_ruff_serve_notebook_cells() {
|
||||
use ide_lsp::{NotebookCell, cell_uri};
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path().canonicalize().unwrap();
|
||||
let root = dunce::simplified(&root).to_path_buf();
|
||||
std::fs::write(root.join("pyproject.toml"), "[tool.ruff.lint]\nselect = [\"F\"]\n").unwrap();
|
||||
let path = root.join("analysis.ipynb");
|
||||
std::fs::write(&path, "{}").unwrap();
|
||||
let mut python = LanguageRegistry::load(None).unwrap().get(&LanguageId::new("python")).unwrap();
|
||||
python.language_servers.retain(|server| matches!(server.name.as_str(), "basedpyright" | "ruff"));
|
||||
let (mgr, events) = manager(&root, LanguageRegistry::from_configs(vec![python]));
|
||||
let cell = |id: &str, source: &str| NotebookCell { id: id.into(), source: source.into() };
|
||||
let first = "import os\nimport sys\n\ndef area(r: float) -> float:\n return 3.14 * r * r\n";
|
||||
let second = "%matplotlib inline\nsize: int = area(2)\nos.pa";
|
||||
mgr.sync_notebook(&path, &LanguageId::new("python"), &[cell("first", first), cell("second", second)]);
|
||||
for name in ["basedpyright", "ruff"] {
|
||||
retry(name, Duration::from_secs(60), || {
|
||||
let status = last_status(&events, name);
|
||||
if let Some((ServerStatus::Crashed | ServerStatus::Missing | ServerStatus::Misconfigured, msg)) = &status {
|
||||
panic!("{name} failed: {msg:?}");
|
||||
}
|
||||
async move { matches!(status, Some((ServerStatus::Ready | ServerStatus::Busy, _))).then_some(()) }
|
||||
})
|
||||
.await;
|
||||
}
|
||||
let docs = mgr.notebook_cells(&path);
|
||||
let b = docs[1].doc;
|
||||
let (a_uri, b_uri) = (cell_uri(&path, "first"), cell_uri(&path, "second"));
|
||||
assert_eq!((docs[0].uri.as_str(), docs[1].uri.as_str()), (a_uri.as_str(), b_uri.as_str()));
|
||||
|
||||
// `os`, imported in the first cell, completes in the second.
|
||||
let labels = retry("completion in a cell", Duration::from_secs(30), || {
|
||||
let mgr = mgr.clone();
|
||||
async move {
|
||||
let list = mgr.completion(b, Pos::new(2, 5), Some(".".into()), Default::default()).await.ok()?;
|
||||
let labels: Vec<String> = list.items.into_iter().map(|i| i.label).collect();
|
||||
labels.iter().any(|l| l == "path").then_some(labels)
|
||||
}
|
||||
})
|
||||
.await;
|
||||
assert!(labels.iter().any(|l| l == "path"), "{labels:?}");
|
||||
|
||||
let hover = mgr.hover(b, Pos::new(1, 13), Default::default()).await.unwrap().expect("hover on `area`");
|
||||
assert!(
|
||||
hover.contents.value.contains("area") && hover.contents.value.contains("float"),
|
||||
"{}",
|
||||
hover.contents.value
|
||||
);
|
||||
|
||||
let defs = mgr.goto(b, Pos::new(1, 13), GotoKind::Definition).await.unwrap();
|
||||
assert_eq!(defs.len(), 1, "{defs:?}");
|
||||
assert_eq!((defs[0].path.to_path_buf(), defs[0].range.start.line), (std::path::PathBuf::from(&a_uri), 3));
|
||||
|
||||
let diagnostics = |source: &'static str, uri: String| {
|
||||
let events = events.clone();
|
||||
move || {
|
||||
let items = events.lock().iter().rev().find_map(|e| match e {
|
||||
IdeEvent::Diagnostics { path, source: s, items } if s == source && path.0 == Path::new(&uri) => {
|
||||
Some(items.clone())
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
async move { items.filter(|items| !items.is_empty()) }
|
||||
}
|
||||
};
|
||||
// `float` is not assignable to `int`; the magic on line 0 is no error.
|
||||
let typed =
|
||||
retry("basedpyright cell diagnostics", Duration::from_secs(30), diagnostics("basedpyright", b_uri)).await;
|
||||
assert!(typed.iter().any(|d| d.range.start.line == 1), "{typed:?}");
|
||||
assert!(typed.iter().all(|d| d.range.start.line != 0), "{typed:?}");
|
||||
// F401: `sys` is unused.
|
||||
let linted = retry("ruff cell diagnostics", Duration::from_secs(30), diagnostics("ruff", a_uri)).await;
|
||||
assert!(linted.iter().any(|d| d.code.as_deref() == Some("F401") && d.range.start.line == 1), "{linted:?}");
|
||||
|
||||
// An edit reaches both servers: the error is fixed.
|
||||
let fixed = "%matplotlib inline\nsize: float = area(2)\nos.path";
|
||||
mgr.sync_notebook(&path, &LanguageId::new("python"), &[cell("first", first), cell("second", fixed)]);
|
||||
retry("diagnostics after the fix", Duration::from_secs(30), || {
|
||||
let uri = cell_uri(&path, "second");
|
||||
let clear = events.lock().iter().rev().find_map(|e| match e {
|
||||
IdeEvent::Diagnostics { path, source, items } if source == "basedpyright" && path.0 == Path::new(&uri) => {
|
||||
Some(items.is_empty())
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
async move { (clear == Some(true)).then_some(()) }
|
||||
})
|
||||
.await;
|
||||
mgr.close_notebook(&path);
|
||||
tokio::time::timeout(Duration::from_secs(10), mgr.shutdown_all()).await.unwrap();
|
||||
}
|
||||
@@ -69,7 +69,7 @@ pub struct TransferError {
|
||||
}
|
||||
|
||||
impl TransferReport {
|
||||
fn fail(&mut self, path: impl Into<String>, e: IdeError) {
|
||||
pub(crate) fn fail(&mut self, path: impl Into<String>, e: IdeError) {
|
||||
self.errors.push(TransferError { path: path.into(), message: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Deployment over SFTP or local folders, and SSH run targets.
|
||||
//! Deployment over SFTP, FTP or local folders, and SSH run targets.
|
||||
//! Shared by IPC commands and project save/delete hooks.
|
||||
|
||||
use crate::deploy::{Deployment, FileStamp, SyncEntry, SyncStatus, SyncStep, TransferReport, status};
|
||||
@@ -89,6 +89,10 @@ impl RemoteService {
|
||||
let session = self.ssh.session(ssh).await?;
|
||||
Ok(RemoteFs::new(session.sftp().await?))
|
||||
}
|
||||
FsConnection::Ftp { server, secure, ftp } => {
|
||||
let password = self.ssh.password(&ide_ssh::ftp::secret_key(server));
|
||||
Ok(RemoteFs::Ftp(ide_ssh::ftp::FtpFs::connect(ftp, *secure, password).await?))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,6 +290,70 @@ impl RemoteService {
|
||||
r
|
||||
}
|
||||
|
||||
/// Remote Host › Download to…: `remotes` (folders recursively) into the local folder `dir`,
|
||||
/// each under its own name, without the server path above it. Existing files are replaced.
|
||||
pub async fn download_into(
|
||||
&self,
|
||||
connection: &FsConnection,
|
||||
remotes: &[String],
|
||||
dir: &Path,
|
||||
cancel: &CancellationToken,
|
||||
progress: &mut (dyn FnMut(&str, usize, usize) + Send),
|
||||
) -> IdeResult<TransferReport> {
|
||||
let fs = self.fs(connection).await?;
|
||||
let mut report = TransferReport::default();
|
||||
let mut folders = Vec::new();
|
||||
let mut jobs = Vec::new();
|
||||
for remote in remotes {
|
||||
let Some(top) = download_target(dir, remote, remote) else {
|
||||
report.skipped += 1;
|
||||
continue;
|
||||
};
|
||||
match fs.stat(remote).await {
|
||||
Ok(Some(e)) if e.is_dir => {
|
||||
folders.push(top);
|
||||
let mut entries = Vec::new();
|
||||
if let Err(e) = remote_tree(&fs, remote, cancel, &mut entries).await {
|
||||
report.fail(remote.clone(), e);
|
||||
}
|
||||
for entry in entries {
|
||||
match download_target(dir, remote, &entry.path) {
|
||||
Some(local) if entry.is_dir => folders.push(local),
|
||||
Some(local) => jobs.push((entry.path, local)),
|
||||
None => report.skipped += 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Some(_)) => jobs.push((remote.clone(), top)),
|
||||
Ok(None) => report.fail(remote.clone(), IdeError::not_found(format!("{remote} does not exist"))),
|
||||
Err(e) => report.fail(remote.clone(), e),
|
||||
}
|
||||
}
|
||||
for folder in &folders {
|
||||
if let Err(e) = tokio::fs::create_dir_all(folder).await {
|
||||
report.fail(folder.display().to_string(), e.into());
|
||||
}
|
||||
}
|
||||
for (i, (remote, local)) in jobs.iter().enumerate() {
|
||||
if cancel.is_cancelled() {
|
||||
break;
|
||||
}
|
||||
progress(remote, i, jobs.len());
|
||||
if let Some(parent) = local.parent()
|
||||
&& let Err(e) = tokio::fs::create_dir_all(parent).await
|
||||
{
|
||||
report.fail(remote.clone(), e.into());
|
||||
continue;
|
||||
}
|
||||
match fs.download(remote, local).await {
|
||||
Ok(()) => report.files += 1,
|
||||
Err(e) => report.fail(remote.clone(), e),
|
||||
}
|
||||
}
|
||||
fs.close().await;
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
// --- save hook -----------------------------------------------------------------
|
||||
|
||||
/// After a document save: uploads a remote file's copy, or (automatic upload)
|
||||
@@ -488,6 +556,11 @@ pub fn connection(server: &DeploymentServer) -> FsConnection {
|
||||
match server.kind {
|
||||
DeploymentType::Local => FsConnection::Local,
|
||||
DeploymentType::Sftp => FsConnection::Sftp { ssh: server.ssh_config.clone() },
|
||||
DeploymentType::Ftp | DeploymentType::Ftps => FsConnection::Ftp {
|
||||
server: server.id.clone(),
|
||||
secure: server.kind == DeploymentType::Ftps,
|
||||
ftp: server.ftp.clone(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -495,5 +568,73 @@ fn cache_key(connection: &FsConnection) -> String {
|
||||
match connection {
|
||||
FsConnection::Local => "local".to_owned(),
|
||||
FsConnection::Sftp { ssh } => format!("sftp-{ssh}"),
|
||||
FsConnection::Ftp { server, .. } => format!("ftp-{server}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Files and folders under remote folder `dir` (symlinked folders are not followed).
|
||||
async fn remote_tree(
|
||||
fs: &RemoteFs,
|
||||
dir: &str,
|
||||
cancel: &CancellationToken,
|
||||
out: &mut Vec<RemoteEntry>,
|
||||
) -> IdeResult<()> {
|
||||
for e in fs.list(dir).await? {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let descend = e.is_dir && !e.is_symlink;
|
||||
let path = e.path.clone();
|
||||
out.push(e);
|
||||
if descend {
|
||||
Box::pin(remote_tree(fs, &path, cancel, out)).await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Where remote `path`, inside the downloaded `selected` file or folder, lands under local `dir`:
|
||||
/// `dir/<name of selected>/<rest>`. `None` for a name that would leave `dir`.
|
||||
fn download_target(dir: &Path, selected: &str, path: &str) -> Option<PathBuf> {
|
||||
let selected = selected.trim_end_matches('/');
|
||||
let name = selected.rsplit('/').next()?;
|
||||
let rest = path.trim_end_matches('/').strip_prefix(selected)?;
|
||||
if !rest.is_empty() && !rest.starts_with('/') {
|
||||
return None;
|
||||
}
|
||||
let mut local = dir.to_path_buf();
|
||||
for part in std::iter::once(name).chain(rest.split('/').filter(|p| !p.is_empty())) {
|
||||
if matches!(part, "" | "." | "..") || part.contains('\\') || (cfg!(windows) && part.contains(':')) {
|
||||
return None;
|
||||
}
|
||||
local.push(part);
|
||||
}
|
||||
Some(local)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod download_into_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn downloads_keep_only_the_selected_name() {
|
||||
let dir = Path::new("/p/project");
|
||||
let selected = "/home/u/www/site/current/drush";
|
||||
assert_eq!(download_target(dir, selected, selected), Some(dir.join("drush")));
|
||||
assert_eq!(download_target(dir, "/home/u/www/site/current/drush/", selected), Some(dir.join("drush")));
|
||||
assert_eq!(
|
||||
download_target(dir, selected, "/home/u/www/site/current/drush/src/a.php"),
|
||||
Some(dir.join("drush").join("src").join("a.php"))
|
||||
);
|
||||
assert_eq!(download_target(dir, "/srv/app.log", "/srv/app.log"), Some(dir.join("app.log")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_that_would_leave_the_folder_are_refused() {
|
||||
let dir = Path::new("/p/project");
|
||||
assert_eq!(download_target(dir, "/srv/a", "/srv/a/../../etc/passwd"), None);
|
||||
assert_eq!(download_target(dir, "/srv/a", "/srv/ab/x"), None, "a sibling with the same prefix");
|
||||
assert_eq!(download_target(dir, "/", "/etc"), None, "the root has no name");
|
||||
assert_eq!(download_target(dir, "/srv/..", "/srv/.."), None);
|
||||
}
|
||||
}
|
||||
@@ -319,3 +319,37 @@ async fn browser_mappings_resolve_symlinks_and_download_to_the_project() {
|
||||
assert!(report.errors.is_empty());
|
||||
assert_eq!(std::fs::read_to_string(local).unwrap(), "from the server");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn download_into_copies_only_the_selected_items() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let server = tmp.path().join("srv/www/site/current");
|
||||
let project = tmp.path().join("project");
|
||||
std::fs::create_dir_all(server.join("drush/sub")).unwrap();
|
||||
std::fs::create_dir_all(server.join("drush/empty")).unwrap();
|
||||
std::fs::create_dir_all(&project).unwrap();
|
||||
std::fs::write(server.join("drush/a.txt"), "a").unwrap();
|
||||
std::fs::write(server.join("drush/sub/b.txt"), "b").unwrap();
|
||||
std::fs::write(server.join("index.php"), "php").unwrap();
|
||||
std::fs::write(project.join("index.php"), "old").unwrap();
|
||||
let service = service(tmp.path());
|
||||
let remotes = [server.join("drush"), server.join("index.php")].map(|p| p.to_str().unwrap().to_owned());
|
||||
let report = service
|
||||
.download_into(&FsConnection::Local, &remotes, &project, &CancellationToken::new(), &mut |_, _, _| {})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(report.files, 3, "{report:?}");
|
||||
assert!(report.errors.is_empty(), "{report:?}");
|
||||
assert_eq!(std::fs::read_to_string(project.join("drush/a.txt")).unwrap(), "a");
|
||||
assert_eq!(std::fs::read_to_string(project.join("drush/sub/b.txt")).unwrap(), "b");
|
||||
assert!(project.join("drush/empty").is_dir());
|
||||
assert_eq!(std::fs::read_to_string(project.join("index.php")).unwrap(), "php");
|
||||
assert!(!project.join("srv").exists() && !project.join("www").exists());
|
||||
|
||||
let missing = [server.join("gone").to_str().unwrap().to_owned()];
|
||||
let report = service
|
||||
.download_into(&FsConnection::Local, &missing, &project, &CancellationToken::new(), &mut |_, _, _| {})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!((report.files, report.errors.len()), (0, 1), "{report:?}");
|
||||
}
|
||||
@@ -19,4 +19,5 @@ specta.workspace = true
|
||||
tempfile.workspace = true
|
||||
toml.workspace = true
|
||||
tracing.workspace = true
|
||||
url = "2"
|
||||
zip.workspace = true
|
||||
@@ -876,9 +876,10 @@ org.eclipse.jdt.core.compiler.codegen.targetPlatform={jdt_ver}\n"
|
||||
tracing::warn!("jdtls: cannot write compiler defaults: {e}");
|
||||
return;
|
||||
}
|
||||
// jdtls accepts absolute filesystem paths as well as URLs (including paths
|
||||
// containing spaces and Windows backslashes). Explicit server settings win later.
|
||||
let defaults = json!({"settings": {"java": {"settings": {"url": file.to_string_lossy()}}}});
|
||||
// A `file:` URI: jdtls parses the setting as a URI when matching watched-file
|
||||
// changes, which fails on a bare path with spaces. Explicit server settings win later.
|
||||
let url = url::Url::from_file_path(&file).map_or_else(|()| file.to_string_lossy().into_owned(), String::from);
|
||||
let defaults = json!({"settings": {"java": {"settings": {"url": url}}}});
|
||||
let existing = cfg.initialization_options.clone();
|
||||
merge_init_options(cfg, defaults);
|
||||
if let Some(existing) = existing {
|
||||
@@ -1824,13 +1825,18 @@ mod java_compiler_tests {
|
||||
serde_json::to_value(cfg.initialization_options.as_ref().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn prefs_path(options: &Value) -> PathBuf {
|
||||
let url = options.pointer("/settings/java/settings/url").unwrap().as_str().unwrap();
|
||||
url::Url::parse(url).unwrap().to_file_path().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()
|
||||
prefs_path(&options(&cfg))
|
||||
};
|
||||
let set = |o: ide_core::settings::InspectionOverride| {
|
||||
let mut s = InspectionSettings::default();
|
||||
@@ -1869,7 +1875,9 @@ mod java_compiler_tests {
|
||||
merge_init_options(&mut cfg, json!({"settings": {"java": {"format": {"tabSize": 4}}}}));
|
||||
configure_java_compiler(&mut cfg, &cache, &InspectionSettings::default());
|
||||
let options = options(&cfg);
|
||||
let path = PathBuf::from(options.pointer("/settings/java/settings/url").unwrap().as_str().unwrap());
|
||||
let url = options.pointer("/settings/java/settings/url").unwrap().as_str().unwrap();
|
||||
assert!(url.starts_with("file://") && !url.contains(' '), "{url}");
|
||||
let path = prefs_path(&options);
|
||||
assert!(path.starts_with(&cache));
|
||||
let prefs = std::fs::read_to_string(&path).unwrap();
|
||||
assert_eq!(prefs.lines().count(), 2);
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
use crate::exec::{executables_in, query, search_path, which};
|
||||
use crate::{Sdk, SdkKind, bin_dir, exe, home_dir, pinned, venv_of};
|
||||
use ide_core::process::host_var_os;
|
||||
use ide_core::version_managers::{self, Tool};
|
||||
use std::collections::HashSet;
|
||||
use std::ffi::OsString;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -132,6 +134,66 @@ fn conda_env_list_prefixes(output: &str) -> Vec<PathBuf> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The host's directory conventions; a parameter so tests cover every platform.
|
||||
#[derive(Clone, Copy)]
|
||||
enum Host {
|
||||
Windows,
|
||||
Mac,
|
||||
Unix,
|
||||
}
|
||||
|
||||
impl Host {
|
||||
fn current() -> Self {
|
||||
if cfg!(windows) {
|
||||
Self::Windows
|
||||
} else if cfg!(target_os = "macos") {
|
||||
Self::Mac
|
||||
} else {
|
||||
Self::Unix
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where Poetry creates virtualenvs: `POETRY_VIRTUALENVS_PATH`, else `virtualenvs` in its
|
||||
/// cache dir — `POETRY_CACHE_DIR`, else `%LOCALAPPDATA%\pypoetry\Cache` (Windows),
|
||||
/// `~/Library/Caches/pypoetry` (macOS) or `$XDG_CACHE_HOME/pypoetry` / `~/.cache/pypoetry`.
|
||||
fn poetry_venv_dirs(host: Host, home: &Path, env: impl Fn(&str) -> Option<OsString>) -> Vec<PathBuf> {
|
||||
let var = |key: &str| env(key).filter(|v| !v.is_empty()).map(PathBuf::from);
|
||||
let cache = var("POETRY_CACHE_DIR").unwrap_or_else(|| match host {
|
||||
Host::Windows => {
|
||||
let local = var("LOCALAPPDATA").unwrap_or_else(|| home.join("AppData").join("Local"));
|
||||
local.join("pypoetry").join("Cache")
|
||||
}
|
||||
Host::Mac => home.join("Library/Caches/pypoetry"),
|
||||
Host::Unix => var("XDG_CACHE_HOME").unwrap_or_else(|| home.join(".cache")).join("pypoetry"),
|
||||
});
|
||||
var("POETRY_VIRTUALENVS_PATH").into_iter().chain([cache.join("virtualenvs")]).collect()
|
||||
}
|
||||
|
||||
/// Where uv installs Pythons (`uv python dir`): `UV_PYTHON_INSTALL_DIR`, else `python` in its
|
||||
/// data dir — `%APPDATA%\uv` on Windows (`%APPDATA%\uv\data` when an older uv made it), else
|
||||
/// `$XDG_DATA_HOME/uv` / `~/.local/share/uv` (older uv on macOS: `~/Library/Application Support/uv`).
|
||||
fn uv_python_dirs(host: Host, home: &Path, env: impl Fn(&str) -> Option<OsString>) -> Vec<PathBuf> {
|
||||
let var = |key: &str| env(key).filter(|v| !v.is_empty()).map(PathBuf::from);
|
||||
let data = match host {
|
||||
Host::Windows => {
|
||||
let roaming = var("APPDATA").unwrap_or_else(|| home.join("AppData").join("Roaming"));
|
||||
vec![roaming.join("uv"), roaming.join("uv").join("data")]
|
||||
}
|
||||
Host::Mac | Host::Unix => {
|
||||
let xdg = var("XDG_DATA_HOME").unwrap_or_else(|| home.join(".local/share")).join("uv");
|
||||
let legacy = matches!(host, Host::Mac).then(|| home.join("Library/Application Support/uv"));
|
||||
[xdg].into_iter().chain(legacy).collect()
|
||||
}
|
||||
};
|
||||
var("UV_PYTHON_INSTALL_DIR").into_iter().chain(data.into_iter().map(|d| d.join("python"))).collect()
|
||||
}
|
||||
|
||||
/// The interpreter of a uv-managed install: `python.exe` at its root on Windows, `bin/python3` elsewhere.
|
||||
fn uv_python(install: &Path) -> Option<PathBuf> {
|
||||
[install.join(exe("python")), install.join("bin").join(exe("python3"))].into_iter().find(|p| p.is_file())
|
||||
}
|
||||
|
||||
fn python(root: Option<&Path>) -> Vec<Sdk> {
|
||||
let mut found: Vec<(PathBuf, &'static str)> = Vec::new();
|
||||
if let Some(root) = root {
|
||||
@@ -155,8 +217,10 @@ fn python(root: Option<&Path>) -> Vec<Sdk> {
|
||||
}
|
||||
if let Some(home) = home_dir() {
|
||||
// Virtualenvs managed by virtualenvwrapper / pipenv / poetry.
|
||||
for dir in [".virtualenvs", ".local/share/virtualenvs", "Library/Caches/pypoetry/virtualenvs"] {
|
||||
for env in subdirs(&home.join(dir)) {
|
||||
let env_dirs = [".virtualenvs", ".local/share/virtualenvs"].map(|d| home.join(d));
|
||||
let var = |key: &str| host_var_os(key);
|
||||
for dir in env_dirs.into_iter().chain(poetry_venv_dirs(Host::current(), &home, var)) {
|
||||
for env in subdirs(&dir) {
|
||||
found.push((env.join(bin_dir()).join(exe("python")), "venv"));
|
||||
}
|
||||
}
|
||||
@@ -164,8 +228,8 @@ fn python(root: Option<&Path>) -> Vec<Sdk> {
|
||||
for v in subdirs(&pyenv.join("versions")) {
|
||||
found.push((v.join(bin_dir()).join(exe("python")), "pyenv"));
|
||||
}
|
||||
for v in subdirs(&home.join(".local/share/uv/python")) {
|
||||
found.push((v.join(bin_dir()).join(exe("python3")), "uv"));
|
||||
for dir in uv_python_dirs(Host::current(), &home, var) {
|
||||
found.extend(subdirs(&dir).iter().filter_map(|v| uv_python(v)).map(|p| (p, "uv")));
|
||||
}
|
||||
for (v, manager) in version_managers::installs(Tool::Python) {
|
||||
found.extend(pinned::python_in_install(&v).map(|p| (p, manager)));
|
||||
@@ -998,3 +1062,101 @@ legacy C:\\Users\\dev\\.conda\\envs\\legacy
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod env_manager_dir_tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn env(vars: &[(&str, &str)]) -> impl Fn(&str) -> Option<OsString> {
|
||||
let vars: HashMap<String, OsString> = vars.iter().map(|(k, v)| ((*k).to_owned(), (*v).into())).collect();
|
||||
move |key| vars.get(key).cloned()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn poetry_venvs_follow_each_platforms_cache_dir() {
|
||||
let home = Path::new("/home/me");
|
||||
let local = Path::new("C:/Users/me/AppData/Local");
|
||||
assert_eq!(
|
||||
poetry_venv_dirs(Host::Windows, home, env(&[("LOCALAPPDATA", "C:/Users/me/AppData/Local")])),
|
||||
vec![local.join("pypoetry").join("Cache").join("virtualenvs")]
|
||||
);
|
||||
assert_eq!(
|
||||
poetry_venv_dirs(Host::Windows, home, env(&[])),
|
||||
vec![home.join("AppData").join("Local").join("pypoetry").join("Cache").join("virtualenvs")]
|
||||
);
|
||||
assert_eq!(
|
||||
poetry_venv_dirs(Host::Mac, home, env(&[])),
|
||||
vec![home.join("Library").join("Caches").join("pypoetry").join("virtualenvs")]
|
||||
);
|
||||
assert_eq!(
|
||||
poetry_venv_dirs(Host::Unix, home, env(&[])),
|
||||
vec![home.join(".cache").join("pypoetry").join("virtualenvs")]
|
||||
);
|
||||
assert_eq!(
|
||||
poetry_venv_dirs(Host::Unix, home, env(&[("XDG_CACHE_HOME", "/xdg/cache")])),
|
||||
vec![Path::new("/xdg/cache").join("pypoetry").join("virtualenvs")]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn poetry_overrides_come_first_and_empty_ones_are_ignored() {
|
||||
let home = Path::new("/home/me");
|
||||
assert_eq!(
|
||||
poetry_venv_dirs(
|
||||
Host::Unix,
|
||||
home,
|
||||
env(&[("POETRY_VIRTUALENVS_PATH", "/envs"), ("POETRY_CACHE_DIR", "/cache")])
|
||||
),
|
||||
vec![PathBuf::from("/envs"), Path::new("/cache").join("virtualenvs")]
|
||||
);
|
||||
assert_eq!(
|
||||
poetry_venv_dirs(Host::Unix, home, env(&[("POETRY_VIRTUALENVS_PATH", ""), ("POETRY_CACHE_DIR", "")])),
|
||||
vec![home.join(".cache").join("pypoetry").join("virtualenvs")]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uv_pythons_follow_each_platforms_data_dir() {
|
||||
let home = Path::new("/home/me");
|
||||
let roaming = Path::new("C:/Users/me/AppData/Roaming");
|
||||
assert_eq!(
|
||||
uv_python_dirs(Host::Windows, home, env(&[("APPDATA", "C:/Users/me/AppData/Roaming")])),
|
||||
vec![roaming.join("uv").join("python"), roaming.join("uv").join("data").join("python")]
|
||||
);
|
||||
assert_eq!(
|
||||
uv_python_dirs(Host::Mac, home, env(&[])),
|
||||
vec![
|
||||
home.join(".local").join("share").join("uv").join("python"),
|
||||
home.join("Library").join("Application Support").join("uv").join("python"),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
uv_python_dirs(Host::Unix, home, env(&[("XDG_DATA_HOME", "/xdg/data")])),
|
||||
vec![Path::new("/xdg/data").join("uv").join("python")]
|
||||
);
|
||||
assert_eq!(
|
||||
uv_python_dirs(Host::Unix, home, env(&[("UV_PYTHON_INSTALL_DIR", "/pythons")])),
|
||||
vec![PathBuf::from("/pythons"), home.join(".local").join("share").join("uv").join("python")]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_uv_install_finds_its_interpreter_on_either_layout() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
// Windows: python.exe at the install root.
|
||||
let windows = tmp.path().join("cpython-3.13.15-windows-x86_64-none");
|
||||
std::fs::create_dir_all(&windows).unwrap();
|
||||
std::fs::write(windows.join(exe("python")), "").unwrap();
|
||||
assert_eq!(uv_python(&windows), Some(windows.join(exe("python"))));
|
||||
// Unix: bin/python3.
|
||||
let unix = tmp.path().join("cpython-3.13.15-linux-x86_64-gnu");
|
||||
std::fs::create_dir_all(unix.join("bin")).unwrap();
|
||||
std::fs::write(unix.join("bin").join(exe("python3")), "").unwrap();
|
||||
assert_eq!(uv_python(&unix), Some(unix.join("bin").join(exe("python3"))));
|
||||
// uv's own bookkeeping folders (`.temp`, `.cache`) hold no interpreter.
|
||||
let temp = tmp.path().join(".temp");
|
||||
std::fs::create_dir_all(&temp).unwrap();
|
||||
assert_eq!(uv_python(&temp), None);
|
||||
}
|
||||
}
|
||||
@@ -1541,7 +1541,7 @@ pub fn usages_bundle(data_dir: &Path) -> Option<PathBuf> {
|
||||
install_bundle(
|
||||
data_dir,
|
||||
"java-usages",
|
||||
"1.12.0",
|
||||
"1.13.0",
|
||||
include_bytes!("../../../java/java-usages/openidea-java-usages.jar"),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "ide-ssh"
|
||||
description = "SSH connections: configurations, host keys, authentication, remote commands, SFTP, port forwarding and askpass"
|
||||
description = "SSH connections: configurations, host keys, authentication, remote commands, SFTP and FTP files, port forwarding and askpass"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
@@ -20,6 +20,10 @@ keyring-core.workspace = true
|
||||
russh = { version = "0.64", default-features = false, features = ["ring", "rsa", "flate2"] }
|
||||
russh-sftp = "2.4"
|
||||
flate2 = "1"
|
||||
suppaftp = { version = "12.2.1", default-features = false, features = ["tokio-rustls-ring"] }
|
||||
rustls.workspace = true
|
||||
rustls-platform-verifier.workspace = true
|
||||
tokio-rustls = { version = "0.26", default-features = false }
|
||||
|
||||
[features]
|
||||
# `testing`: an in-process SSH / SFTP server for tests of crates built on this one,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! Filesystem operations shared by deployment and remote development bootstrap.
|
||||
use crate::ftp::FtpFs;
|
||||
use crate::sftp::{RemoteEntry, SftpFs};
|
||||
use ide_core::remote_settings::FtpConnection;
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
use russh_sftp::client::SftpSession;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -9,12 +11,21 @@ use std::path::Path;
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum FsConnection {
|
||||
Sftp { ssh: String },
|
||||
Sftp {
|
||||
ssh: String,
|
||||
},
|
||||
/// `server`: the deployment server whose saved password is used.
|
||||
Ftp {
|
||||
server: String,
|
||||
secure: bool,
|
||||
ftp: FtpConnection,
|
||||
},
|
||||
Local,
|
||||
}
|
||||
|
||||
pub enum RemoteFs {
|
||||
Sftp(SftpFs),
|
||||
Ftp(FtpFs),
|
||||
Local,
|
||||
}
|
||||
|
||||
@@ -27,6 +38,7 @@ impl RemoteFs {
|
||||
pub async fn home(&self) -> IdeResult<String> {
|
||||
match self {
|
||||
Self::Sftp(fs) => fs.home().await,
|
||||
Self::Ftp(fs) => Ok(fs.home()),
|
||||
Self::Local => {
|
||||
dirs::home_dir().map(|p| path_string(&p)).ok_or_else(|| IdeError::invalid("Home folder is unavailable"))
|
||||
}
|
||||
@@ -36,6 +48,7 @@ impl RemoteFs {
|
||||
pub async fn resolve(&self, path: &str) -> IdeResult<String> {
|
||||
match self {
|
||||
Self::Sftp(fs) => fs.resolve(path).await,
|
||||
Self::Ftp(fs) => Ok(fs.resolve(path)),
|
||||
Self::Local => {
|
||||
let expanded = crate::expand(path.trim());
|
||||
let path = if path.trim().is_empty() || path.trim() == "~" {
|
||||
@@ -52,6 +65,7 @@ impl RemoteFs {
|
||||
pub async fn stat(&self, path: &str) -> IdeResult<Option<RemoteEntry>> {
|
||||
match self {
|
||||
Self::Sftp(fs) => fs.stat(path).await,
|
||||
Self::Ftp(fs) => fs.stat(path).await,
|
||||
Self::Local => match tokio::fs::metadata(path).await {
|
||||
Ok(meta) => Ok(Some(local_entry(path, meta).await?)),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
@@ -63,6 +77,7 @@ impl RemoteFs {
|
||||
pub async fn list(&self, dir: &str) -> IdeResult<Vec<RemoteEntry>> {
|
||||
match self {
|
||||
Self::Sftp(fs) => fs.list(dir).await,
|
||||
Self::Ftp(fs) => fs.list(dir).await,
|
||||
Self::Local => {
|
||||
let mut read = tokio::fs::read_dir(dir).await?;
|
||||
let mut entries = Vec::new();
|
||||
@@ -81,6 +96,7 @@ impl RemoteFs {
|
||||
pub async fn read(&self, path: &str) -> IdeResult<Vec<u8>> {
|
||||
match self {
|
||||
Self::Sftp(fs) => fs.read(path).await,
|
||||
Self::Ftp(fs) => fs.read(path).await,
|
||||
Self::Local => Ok(tokio::fs::read(path).await?),
|
||||
}
|
||||
}
|
||||
@@ -88,6 +104,7 @@ impl RemoteFs {
|
||||
pub async fn write(&self, path: &str, data: &[u8], mtime: Option<u32>) -> IdeResult<()> {
|
||||
match self {
|
||||
Self::Sftp(fs) => fs.write(path, data, mtime).await,
|
||||
Self::Ftp(fs) => fs.write(path, data, mtime).await,
|
||||
Self::Local => {
|
||||
if let Some(parent) = Path::new(path).parent() {
|
||||
tokio::fs::create_dir_all(parent).await?;
|
||||
@@ -104,6 +121,7 @@ impl RemoteFs {
|
||||
pub async fn upload(&self, local: &Path, remote: &str, preserve_time: bool) -> IdeResult<()> {
|
||||
match self {
|
||||
Self::Sftp(fs) => fs.upload(local, remote, preserve_time).await,
|
||||
Self::Ftp(fs) => fs.upload(local, remote, preserve_time).await,
|
||||
Self::Local => {
|
||||
reject_same_file(local, Path::new(remote)).await?;
|
||||
copy_file(local, Path::new(remote)).await?;
|
||||
@@ -122,6 +140,7 @@ impl RemoteFs {
|
||||
}
|
||||
match self {
|
||||
Self::Sftp(fs) => fs.upload_new(local, remote, cancel).await,
|
||||
Self::Ftp(fs) => fs.upload_new(local, remote, cancel).await,
|
||||
Self::Local => {
|
||||
let mut input = tokio::fs::File::open(local).await?;
|
||||
let mut output = tokio::fs::OpenOptions::new().write(true).create_new(true).open(remote).await?;
|
||||
@@ -145,6 +164,7 @@ impl RemoteFs {
|
||||
pub async fn symlink(&self, target: &str, link: &str) -> IdeResult<()> {
|
||||
match self {
|
||||
Self::Sftp(fs) => fs.symlink(target, link).await,
|
||||
Self::Ftp(_) => Err(IdeError::invalid("Cannot create a symbolic link over FTP")),
|
||||
#[cfg(unix)]
|
||||
Self::Local => Ok(tokio::fs::symlink(target, link).await?),
|
||||
#[cfg(not(unix))]
|
||||
@@ -155,6 +175,7 @@ impl RemoteFs {
|
||||
pub async fn create_dir(&self, path: &str) -> IdeResult<()> {
|
||||
match self {
|
||||
Self::Sftp(fs) => fs.create_dir(path).await,
|
||||
Self::Ftp(fs) => fs.create_dir(path).await,
|
||||
Self::Local => Ok(tokio::fs::create_dir(path).await?),
|
||||
}
|
||||
}
|
||||
@@ -162,6 +183,7 @@ impl RemoteFs {
|
||||
pub async fn download(&self, remote: &str, local: &Path) -> IdeResult<()> {
|
||||
match self {
|
||||
Self::Sftp(fs) => fs.download(remote, local).await,
|
||||
Self::Ftp(fs) => fs.download(remote, local).await,
|
||||
Self::Local => {
|
||||
reject_same_file(Path::new(remote), local).await?;
|
||||
copy_file(Path::new(remote), local).await
|
||||
@@ -172,6 +194,7 @@ impl RemoteFs {
|
||||
pub async fn mkdir_all(&self, path: &str) -> IdeResult<()> {
|
||||
match self {
|
||||
Self::Sftp(fs) => fs.mkdir_all(path).await,
|
||||
Self::Ftp(fs) => fs.mkdir_all(path).await,
|
||||
Self::Local => Ok(tokio::fs::create_dir_all(path).await?),
|
||||
}
|
||||
}
|
||||
@@ -179,6 +202,7 @@ impl RemoteFs {
|
||||
pub async fn remove(&self, path: &str) -> IdeResult<()> {
|
||||
match self {
|
||||
Self::Sftp(fs) => fs.remove(path).await,
|
||||
Self::Ftp(fs) => fs.remove(path).await,
|
||||
Self::Local => {
|
||||
let meta = tokio::fs::symlink_metadata(path).await?;
|
||||
if meta.is_dir() {
|
||||
@@ -194,6 +218,7 @@ impl RemoteFs {
|
||||
pub async fn rename(&self, from: &str, to: &str) -> IdeResult<()> {
|
||||
match self {
|
||||
Self::Sftp(fs) => fs.rename(from, to).await,
|
||||
Self::Ftp(fs) => fs.rename(from, to).await,
|
||||
Self::Local => Ok(tokio::fs::rename(from, to).await?),
|
||||
}
|
||||
}
|
||||
@@ -237,8 +262,10 @@ impl RemoteFs {
|
||||
}
|
||||
|
||||
pub async fn close(&self) {
|
||||
if let Self::Sftp(fs) = self {
|
||||
fs.close().await;
|
||||
match self {
|
||||
Self::Sftp(fs) => fs.close().await,
|
||||
Self::Ftp(fs) => fs.close().await,
|
||||
Self::Local => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
//! Remote files over FTP, or FTPS with `AUTH TLS`. Remote paths are POSIX strings.
|
||||
|
||||
use crate::sftp::{RemoteEntry, join, parent};
|
||||
use ide_core::remote_settings::FtpConnection;
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult, error::ErrorKind};
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
use suppaftp::list::{File, ListParser, PosixPexQuery};
|
||||
use suppaftp::tokio::{AsyncRustlsConnector, AsyncRustlsFtpStream};
|
||||
use suppaftp::types::FileType;
|
||||
use suppaftp::{FtpError, Mode, Status};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
|
||||
/// Key of an FTP deployment server's password among the saved passwords.
|
||||
pub fn secret_key(server: &str) -> String {
|
||||
format!("ftp:{server}")
|
||||
}
|
||||
|
||||
/// The server's reply text (it starts with the reply code), or the error.
|
||||
fn describe(e: &FtpError) -> String {
|
||||
match e {
|
||||
FtpError::UnexpectedResponse(r) => r.as_string().unwrap_or_else(|_| r.to_string()),
|
||||
FtpError::SecureError(s) => s.trim_start_matches("Secure error: ").to_owned(),
|
||||
e => e.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn err(path: &str, e: FtpError) -> IdeError {
|
||||
let kind = match &e {
|
||||
FtpError::UnexpectedResponse(r) if r.status == Status::FileUnavailable => ErrorKind::NotFound,
|
||||
FtpError::UnexpectedResponse(r) if r.status == Status::NotLoggedIn => ErrorKind::PermissionDenied,
|
||||
_ => ErrorKind::Io,
|
||||
};
|
||||
IdeError::new(kind, format!("{path}: {}", describe(&e)))
|
||||
}
|
||||
|
||||
fn tls() -> IdeResult<AsyncRustlsConnector> {
|
||||
use rustls_platform_verifier::BuilderVerifierExt;
|
||||
let fail = |e: String| IdeError::internal(format!("FTPS: {e}"));
|
||||
let provider = Arc::new(rustls::crypto::ring::default_provider());
|
||||
let config = rustls::ClientConfig::builder_with_provider(provider)
|
||||
.with_safe_default_protocol_versions()
|
||||
.map_err(|e| fail(e.to_string()))?
|
||||
.with_platform_verifier()
|
||||
.map_err(|e| fail(e.to_string()))?
|
||||
.with_no_client_auth();
|
||||
Ok(tokio_rustls::TlsConnector::from(Arc::new(config)).into())
|
||||
}
|
||||
|
||||
fn permissions(f: &File) -> String {
|
||||
[PosixPexQuery::Owner, PosixPexQuery::Group, PosixPexQuery::Others]
|
||||
.into_iter()
|
||||
.flat_map(|q| {
|
||||
[
|
||||
if f.can_read(q) { 'r' } else { '-' },
|
||||
if f.can_write(q) { 'w' } else { '-' },
|
||||
if f.can_execute(q) { 'x' } else { '-' },
|
||||
]
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn seconds(t: SystemTime) -> Option<u32> {
|
||||
t.duration_since(UNIX_EPOCH).ok()?.as_secs().try_into().ok()
|
||||
}
|
||||
|
||||
/// `YYYYMMDDHHMMSS` (UTC) for `MFMT`.
|
||||
fn mfmt_time(seconds: u32) -> String {
|
||||
let days = i64::from(seconds / 86_400);
|
||||
let rem = seconds % 86_400;
|
||||
// Civil date from days since 1970-01-01 (Howard Hinnant's algorithm).
|
||||
let z = days + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z - era * 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let day = doy - (153 * mp + 2) / 5 + 1;
|
||||
let month = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let year = yoe + era * 400 + i64::from(month <= 2);
|
||||
format!("{year:04}{month:02}{day:02}{:02}{:02}{:02}", rem / 3600, rem / 60 % 60, rem % 60)
|
||||
}
|
||||
|
||||
/// One logged-in FTP control connection; operations take turns on it.
|
||||
pub struct FtpFs {
|
||||
ftp: tokio::sync::Mutex<AsyncRustlsFtpStream>,
|
||||
home: String,
|
||||
/// `MLSD` listings (exact UTC times); otherwise `LIST`, with `MDTM` for times when supported.
|
||||
mlsd: bool,
|
||||
mdtm: bool,
|
||||
}
|
||||
|
||||
impl FtpFs {
|
||||
/// Connects and logs in; an empty user logs in anonymously.
|
||||
pub async fn connect(c: &FtpConnection, secure: bool, password: Option<String>) -> IdeResult<Self> {
|
||||
let host = c.host.trim();
|
||||
if host.is_empty() {
|
||||
return Err(IdeError::invalid("the deployment server has no FTP host"));
|
||||
}
|
||||
let port = c.port.unwrap_or(21);
|
||||
let user = c.user.trim();
|
||||
let label = if user.is_empty() { format!("{host}:{port}") } else { format!("{user}@{host}:{port}") };
|
||||
let (user, password) = if user.is_empty() {
|
||||
("anonymous", "anonymous@".to_owned())
|
||||
} else {
|
||||
let password = password.ok_or_else(|| {
|
||||
IdeError::new(ErrorKind::PermissionDenied, format!("FTP password required for {label}"))
|
||||
})?;
|
||||
(user, password)
|
||||
};
|
||||
let connect = async {
|
||||
let mut ftp = AsyncRustlsFtpStream::connect((host, port)).await.map_err(|e| err(&label, e))?;
|
||||
if secure {
|
||||
ftp = ftp.into_secure(tls()?, host).await.map_err(|e| err(&label, e))?;
|
||||
}
|
||||
IdeResult::Ok(ftp)
|
||||
};
|
||||
let mut ftp = tokio::time::timeout(CONNECT_TIMEOUT, connect)
|
||||
.await
|
||||
.map_err(|_| IdeError::new(ErrorKind::Io, format!("{label}: connection timed out")))??;
|
||||
if let Err(e) = ftp.login(user, password.as_str()).await {
|
||||
return Err(match e {
|
||||
FtpError::UnexpectedResponse(r) if r.status == Status::NotLoggedIn => {
|
||||
let reply = describe(&FtpError::UnexpectedResponse(r));
|
||||
IdeError::new(
|
||||
ErrorKind::PermissionDenied,
|
||||
format!("FTP authentication failed for {label}: {reply}"),
|
||||
)
|
||||
}
|
||||
e => err(&label, e),
|
||||
});
|
||||
}
|
||||
ftp.transfer_type(FileType::Binary).await.map_err(|e| err(&label, e))?;
|
||||
if c.passive {
|
||||
ftp.set_mode(Mode::Passive);
|
||||
// Servers behind NAT often announce a private address; the data
|
||||
// connection goes to the host of the control connection instead.
|
||||
ftp.set_passive_nat_workaround(true);
|
||||
} else {
|
||||
ftp = ftp.active_mode(CONNECT_TIMEOUT);
|
||||
}
|
||||
let home = ftp.pwd().await.map_err(|e| err(&label, e))?;
|
||||
let features = ftp.feat().await.unwrap_or_default();
|
||||
let (mlsd, mdtm) = (features.contains_key("MLST"), features.contains_key("MDTM"));
|
||||
Ok(Self { ftp: tokio::sync::Mutex::new(ftp), home, mlsd, mdtm })
|
||||
}
|
||||
|
||||
pub fn home(&self) -> String {
|
||||
self.home.clone()
|
||||
}
|
||||
|
||||
/// Absolute form of `path`; `~` and `~/…` are relative to the login directory.
|
||||
pub fn resolve(&self, path: &str) -> String {
|
||||
let path = path.trim();
|
||||
if path.is_empty() || path == "~" {
|
||||
return self.home.clone();
|
||||
}
|
||||
if let Some(rest) = path.strip_prefix("~/") {
|
||||
return join(&self.home, rest);
|
||||
}
|
||||
if path.starts_with('/') { path.to_owned() } else { join(&self.home, path) }
|
||||
}
|
||||
|
||||
async fn is_dir(&self, path: &str) -> bool {
|
||||
self.ftp.lock().await.cwd(path).await.is_ok()
|
||||
}
|
||||
|
||||
/// The entry of `path` as its parent lists it (FTP has no portable stat).
|
||||
pub async fn stat(&self, path: &str) -> IdeResult<Option<RemoteEntry>> {
|
||||
let path = path.trim_end_matches('/');
|
||||
let Some(dir) = parent(path) else {
|
||||
return Ok(self.is_dir("/").await.then(|| RemoteEntry {
|
||||
name: "/".into(),
|
||||
path: "/".into(),
|
||||
is_dir: true,
|
||||
is_symlink: false,
|
||||
size: 0,
|
||||
mtime: None,
|
||||
permissions: String::new(),
|
||||
}));
|
||||
};
|
||||
let name = &path[dir.len()..].trim_start_matches('/');
|
||||
match self.list(dir).await {
|
||||
Ok(entries) => Ok(entries.into_iter().find(|e| e.name == *name)),
|
||||
Err(e) if e.kind == ErrorKind::NotFound => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Entries of `dir`, folders first, then by name; symlinks to folders count as folders.
|
||||
pub async fn list(&self, dir: &str) -> IdeResult<Vec<RemoteEntry>> {
|
||||
let lines = {
|
||||
let mut ftp = self.ftp.lock().await;
|
||||
if self.mlsd { ftp.mlsd(Some(dir)).await } else { ftp.list(Some(dir)).await }.map_err(|e| err(dir, e))?
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for line in lines {
|
||||
// Permissions only when the listing has them (MLSD's optional `unix.mode`, a POSIX `LIST` line).
|
||||
let parsed = if self.mlsd {
|
||||
ListParser::parse_mlsd(&line).map(|f| (f, line.to_ascii_lowercase().contains("unix.mode=")))
|
||||
} else {
|
||||
ListParser::parse_posix(&line)
|
||||
.map(|f| (f, true))
|
||||
.or_else(|_| ListParser::parse_dos(&line).map(|f| (f, false)))
|
||||
};
|
||||
let Ok((f, has_mode)) = parsed else { continue };
|
||||
let name = f.name().to_owned();
|
||||
// A name with `/` (from a hostile server) would point elsewhere once joined.
|
||||
if name.is_empty() || name == "." || name == ".." || name.contains('/') {
|
||||
continue;
|
||||
}
|
||||
let path = join(dir, &name);
|
||||
let is_dir = f.is_directory() || (f.is_symlink() && self.is_dir(&path).await);
|
||||
// `LIST` times are to the minute at best, in the server's time zone.
|
||||
let mtime = if self.mlsd {
|
||||
seconds(f.modified())
|
||||
} else if self.mdtm && !is_dir {
|
||||
let exact = self.ftp.lock().await.mdtm(&path).await;
|
||||
exact.ok().and_then(|t| t.and_utc().timestamp().try_into().ok())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
out.push(RemoteEntry {
|
||||
is_dir,
|
||||
is_symlink: f.is_symlink(),
|
||||
size: f.size() as u64,
|
||||
mtime,
|
||||
permissions: if has_mode { permissions(&f) } else { String::new() },
|
||||
name,
|
||||
path,
|
||||
});
|
||||
}
|
||||
out.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub async fn read(&self, path: &str) -> IdeResult<Vec<u8>> {
|
||||
let mut ftp = self.ftp.lock().await;
|
||||
let mut stream = ftp.retr_as_stream(path).await.map_err(|e| err(path, e))?;
|
||||
let mut buf = Vec::new();
|
||||
stream.read_to_end(&mut buf).await?;
|
||||
stream.finish().await.map_err(|e| err(path, e))?;
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
async fn store(
|
||||
&self,
|
||||
path: &str,
|
||||
input: &mut (impl tokio::io::AsyncRead + Unpin),
|
||||
cancel: Option<&CancellationToken>,
|
||||
) -> IdeResult<()> {
|
||||
if let Some(dir) = parent(path) {
|
||||
self.mkdir_all(dir).await?;
|
||||
}
|
||||
let mut ftp = self.ftp.lock().await;
|
||||
let mut stream = ftp.put_with_stream(path).await.map_err(|e| err(path, e))?;
|
||||
match cancel {
|
||||
Some(cancel) => crate::filesystem::copy_stream(input, &mut stream, cancel).await?,
|
||||
None => {
|
||||
tokio::io::copy(input, &mut stream).await?;
|
||||
}
|
||||
}
|
||||
stream.finish().await.map_err(|e| err(path, e))
|
||||
}
|
||||
|
||||
async fn set_mtime(&self, path: &str, seconds: u32) {
|
||||
let command = format!("MFMT {} {path}", mfmt_time(seconds));
|
||||
if let Err(e) = self.ftp.lock().await.custom_command(command, &[Status::File]).await {
|
||||
tracing::debug!("FTP: cannot set the time of {path}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes `data`, creating missing parent folders.
|
||||
pub async fn write(&self, path: &str, data: &[u8], mtime: Option<u32>) -> IdeResult<()> {
|
||||
self.store(path, &mut &data[..], None).await?;
|
||||
if let Some(t) = mtime {
|
||||
self.set_mtime(path, t).await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn upload(&self, local: &Path, remote: &str, preserve_time: bool) -> IdeResult<()> {
|
||||
let mut input = tokio::fs::File::open(local).await?;
|
||||
self.store(remote, &mut input, None).await?;
|
||||
if let Some(t) = preserve_time.then(|| crate::sftp::mtime_of(local)).flatten() {
|
||||
self.set_mtime(remote, t).await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Uploads unless `remote` exists (FTP has no exclusive create: checked first).
|
||||
pub async fn upload_new(&self, local: &Path, remote: &str, cancel: &CancellationToken) -> IdeResult<()> {
|
||||
if self.stat(remote).await?.is_some() {
|
||||
return Err(IdeError::new(ErrorKind::AlreadyExists, format!("{remote} already exists")));
|
||||
}
|
||||
let mut input = tokio::fs::File::open(local).await?;
|
||||
let result = self.store(remote, &mut input, Some(cancel)).await;
|
||||
if result.is_err() {
|
||||
let _ = self.ftp.lock().await.rm(remote).await;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub async fn create_dir(&self, path: &str) -> IdeResult<()> {
|
||||
self.ftp.lock().await.mkdir(path).await.map_err(|e| err(path, e))
|
||||
}
|
||||
|
||||
pub async fn download(&self, remote: &str, local: &Path) -> IdeResult<()> {
|
||||
if let Some(dir) = local.parent() {
|
||||
tokio::fs::create_dir_all(dir).await?;
|
||||
}
|
||||
let mut ftp = self.ftp.lock().await;
|
||||
let mut stream = ftp.retr_as_stream(remote).await.map_err(|e| err(remote, e))?;
|
||||
let mut output = tokio::fs::File::create(local).await?;
|
||||
tokio::io::copy(&mut stream, &mut output).await?;
|
||||
output.flush().await?;
|
||||
stream.finish().await.map_err(|e| err(remote, e))
|
||||
}
|
||||
|
||||
/// `mkdir -p`.
|
||||
pub async fn mkdir_all(&self, dir: &str) -> IdeResult<()> {
|
||||
if dir.is_empty() || dir == "/" || self.is_dir(dir).await {
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(p) = parent(dir) {
|
||||
Box::pin(self.mkdir_all(p)).await?;
|
||||
}
|
||||
match self.ftp.lock().await.mkdir(dir).await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) => {
|
||||
if self.is_dir(dir).await {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(err(dir, e))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes a file, or a folder with its contents (a symlink itself, never its target).
|
||||
pub async fn remove(&self, path: &str) -> IdeResult<()> {
|
||||
let entry = self.stat(path).await?.ok_or_else(|| IdeError::not_found(format!("{path} does not exist")))?;
|
||||
if entry.is_dir && !entry.is_symlink {
|
||||
for e in self.list(path).await? {
|
||||
if e.is_dir && !e.is_symlink {
|
||||
Box::pin(self.remove(&e.path)).await?;
|
||||
} else {
|
||||
self.ftp.lock().await.rm(&e.path).await.map_err(|x| err(&e.path, x))?;
|
||||
}
|
||||
}
|
||||
self.ftp.lock().await.rmdir(path).await.map_err(|e| err(path, e))
|
||||
} else {
|
||||
self.ftp.lock().await.rm(path).await.map_err(|e| err(path, e))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn rename(&self, from: &str, to: &str) -> IdeResult<()> {
|
||||
self.ftp.lock().await.rename(from, to).await.map_err(|e| err(from, e))
|
||||
}
|
||||
|
||||
pub async fn close(&self) {
|
||||
let _ = self.ftp.lock().await.quit().await;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn formats_mfmt_times_in_utc() {
|
||||
assert_eq!(mfmt_time(0), "19700101000000");
|
||||
assert_eq!(mfmt_time(951_782_400), "20000229000000");
|
||||
assert_eq!(mfmt_time(1_794_484_245), "20261112115045");
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
//! SSH for every remote feature: configurations and `~/.ssh/config` hosts,
|
||||
//! host-key trust, authentication (password, key, agent), remote commands and
|
||||
//! processes, SFTP, local port forwarding, and askpass for `ssh` / `git`.
|
||||
//! processes, SFTP (and FTP for deployment), local port forwarding, and askpass for `ssh` / `git`.
|
||||
|
||||
pub mod askpass;
|
||||
pub mod filesystem;
|
||||
pub mod ftp;
|
||||
pub mod git_creds;
|
||||
pub mod manager;
|
||||
pub mod secrets;
|
||||
|
||||
@@ -105,6 +105,11 @@ impl SshManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// A password entered this session or saved under `key` (not an SSH configuration's).
|
||||
pub fn password(&self, key: &str) -> Option<String> {
|
||||
self.entered.lock().get(key).cloned().or_else(|| self.saved.get(key))
|
||||
}
|
||||
|
||||
/// The known password / passphrase of configuration `id`.
|
||||
pub fn secret(&self, config: &SshConfiguration) -> Option<String> {
|
||||
if let Some(s) = self.entered.lock().get(&config.id) {
|
||||
|
||||
@@ -177,7 +177,10 @@ fn interchangeable_servers_list_their_alternatives() {
|
||||
assert_eq!(find(&list, "vtsls").alternatives, ["vtsls", "typescript-language-server"]);
|
||||
for name in ["jdtls", "kmp-lsp"] {
|
||||
assert_eq!(find(&list, name).alternatives, ["jdtls", "kmp-lsp"]);
|
||||
assert_eq!(find(&list, name).languages, ["Java"]);
|
||||
assert_eq!(find(&list, name).languages, if name == "kmp-lsp" { &["Java", "Kotlin"][..] } else { &["Java"] });
|
||||
}
|
||||
for name in ["kotlin-lsp", "kotlin-language-server"] {
|
||||
assert_eq!(find(&list, name).alternatives, ["kotlin-lsp", "kotlin-language-server", "kmp-lsp"]);
|
||||
}
|
||||
// A regular server after a fallback starts no group with it.
|
||||
assert!(find(&list, "angular-language-server").alternatives.is_empty());
|
||||
|
||||
@@ -5,6 +5,7 @@ use crate::{ChangeKind, FileStatus, Repo};
|
||||
use gix::status::index_worktree::Item as WtItem;
|
||||
use gix::status::plumbing::index_as_worktree::{Change as WtChange, EntryStatus};
|
||||
use ide_core::IdeResult;
|
||||
use ide_core::ignored::IgnoredNames;
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -15,10 +16,43 @@ struct Entry {
|
||||
}
|
||||
|
||||
pub(crate) fn status(r: &Repo) -> IdeResult<Vec<FileStatus>> {
|
||||
let ignored = r.ignored.read().unwrap_or_else(|p| p.into_inner()).clone();
|
||||
// gitoxide stops at the first IO error (a directory it may not read), where git warns
|
||||
// and lists the rest: git answers then.
|
||||
let (map, conflicts) = gix_status(r, &ignored).or_else(|e| {
|
||||
tracing::debug!("status via gitoxide failed, asking git: {}", e.message);
|
||||
cli_status(r, &ignored)
|
||||
})?;
|
||||
Ok(map
|
||||
.into_iter()
|
||||
.filter(|(_, e)| e.staged.is_some() || e.unstaged.is_some())
|
||||
// Ignored names mean "the IDE treats these as nonexistent": hide their tracked
|
||||
// changes too, not just untracked hits above. Conflicts stay visible so merges
|
||||
// cannot stall on a file the user cannot see.
|
||||
.filter(|(rel, _)| conflicts.contains(rel) || !ignored.is_ignored_path(rel))
|
||||
.map(|(rel, mut e)| {
|
||||
if conflicts.contains(&rel) {
|
||||
e.staged = None;
|
||||
e.renamed_from = None;
|
||||
e.unstaged = Some(ChangeKind::Conflicted);
|
||||
}
|
||||
FileStatus {
|
||||
path: r.abs_path(&rel).into(),
|
||||
rel_path: rel,
|
||||
staged: e.staged,
|
||||
unstaged: e.unstaged,
|
||||
renamed_from: e.renamed_from,
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
type Entries = (BTreeMap<String, Entry>, HashSet<String>);
|
||||
|
||||
fn gix_status(r: &Repo, ignored: &IgnoredNames) -> IdeResult<Entries> {
|
||||
let repo = r.gix()?;
|
||||
let mut map: BTreeMap<String, Entry> = BTreeMap::new();
|
||||
let mut conflicts: HashSet<String> = HashSet::new();
|
||||
let ignored = r.ignored.read().unwrap_or_else(|p| p.into_inner()).clone();
|
||||
|
||||
// Stamp before status can read the index: stamping after the load could capture
|
||||
// an external `git switch`, `add` or commit's new index and allow the stale
|
||||
@@ -118,28 +152,60 @@ pub(crate) fn status(r: &Repo) -> IdeResult<Vec<FileStatus>> {
|
||||
}
|
||||
}
|
||||
|
||||
Ok(map
|
||||
.into_iter()
|
||||
.filter(|(_, e)| e.staged.is_some() || e.unstaged.is_some())
|
||||
// Ignored names mean "the IDE treats these as nonexistent": hide their tracked
|
||||
// changes too, not just untracked hits above. Conflicts stay visible so merges
|
||||
// cannot stall on a file the user cannot see.
|
||||
.filter(|(rel, _)| conflicts.contains(rel) || !ignored.is_ignored_path(rel))
|
||||
.map(|(rel, mut e)| {
|
||||
if conflicts.contains(&rel) {
|
||||
e.staged = None;
|
||||
e.renamed_from = None;
|
||||
e.unstaged = Some(ChangeKind::Conflicted);
|
||||
Ok((map, conflicts))
|
||||
}
|
||||
|
||||
/// `git status --porcelain=v2`: what git lists, unreadable directories skipped.
|
||||
fn cli_status(r: &Repo, ignored: &IgnoredNames) -> IdeResult<Entries> {
|
||||
let out = crate::cli::run(r.root(), &["status", "--porcelain=v2", "-z", "--untracked-files=all"], None)?;
|
||||
Ok(parse_porcelain_v2(&String::from_utf8_lossy(&out.stdout), ignored))
|
||||
}
|
||||
|
||||
fn parse_porcelain_v2(out: &str, ignored: &IgnoredNames) -> Entries {
|
||||
let kind = |c: u8| match c {
|
||||
b'M' | b'T' => Some(ChangeKind::Modified),
|
||||
b'A' | b'C' => Some(ChangeKind::Added),
|
||||
b'D' => Some(ChangeKind::Deleted),
|
||||
b'R' => Some(ChangeKind::Renamed),
|
||||
_ => None,
|
||||
};
|
||||
let mut map: BTreeMap<String, Entry> = BTreeMap::new();
|
||||
let mut conflicts: HashSet<String> = HashSet::new();
|
||||
let mut records = out.split('\0');
|
||||
while let Some(record) = records.next() {
|
||||
let (tag, rest) = record.split_once(' ').unwrap_or((record, ""));
|
||||
match tag {
|
||||
// `1 XY sub mH mI mW hH hI path`, `2 … Xscore path` + NUL + the original path.
|
||||
"1" | "2" => {
|
||||
let fields = if tag == "1" { 8 } else { 9 };
|
||||
let mut parts = rest.splitn(fields, ' ');
|
||||
let xy = parts.next().unwrap_or("..").as_bytes();
|
||||
let Some(path) = parts.nth(fields - 2) else { continue };
|
||||
let e = map.entry(path.to_owned()).or_default();
|
||||
e.staged = xy.first().copied().and_then(kind);
|
||||
// Intent to add shows as `.A`; worktree renames are off, as with gitoxide.
|
||||
e.unstaged = xy.get(1).copied().and_then(kind).filter(|k| *k != ChangeKind::Renamed);
|
||||
if tag == "2" {
|
||||
let from = records.next().unwrap_or_default();
|
||||
if e.staged == Some(ChangeKind::Renamed) {
|
||||
e.renamed_from = Some(from.to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
FileStatus {
|
||||
path: r.abs_path(&rel).into(),
|
||||
rel_path: rel,
|
||||
staged: e.staged,
|
||||
unstaged: e.unstaged,
|
||||
renamed_from: e.renamed_from,
|
||||
// `u XY sub m1 m2 m3 mW h1 h2 h3 path`
|
||||
"u" => {
|
||||
if let Some(path) = rest.splitn(10, ' ').nth(9) {
|
||||
conflicts.insert(path.to_owned());
|
||||
map.entry(path.to_owned()).or_default().unstaged = Some(ChangeKind::Conflicted);
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
"?" if !rest.is_empty() && !ignored.is_ignored_path(rest) => {
|
||||
map.entry(rest.to_owned()).or_default().unstaged = Some(ChangeKind::Untracked);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
(map, conflicts)
|
||||
}
|
||||
|
||||
/// Lets a test land an external index write in the window between the status run
|
||||
@@ -227,4 +293,59 @@ mod tests {
|
||||
let staged = Command::new("git").current_dir(dir).args(["diff", "--cached", "--stat"]).output().unwrap();
|
||||
assert!(staged.stdout.is_empty(), "{}", String::from_utf8_lossy(&staged.stdout));
|
||||
}
|
||||
|
||||
/// git's porcelain answer (used when gitoxide can't read the tree) matches gitoxide's.
|
||||
#[test]
|
||||
fn git_and_gitoxide_report_the_same_status() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path();
|
||||
git(dir, &["init", "-q"]);
|
||||
git(dir, &["config", "user.name", "Test User"]);
|
||||
git(dir, &["config", "user.email", "[email protected]"]);
|
||||
git(dir, &["config", "commit.gpgsign", "false"]);
|
||||
for f in ["modified.txt", "staged.txt", "deleted.txt", "staged_del.txt", "both.txt", "conflict.txt"] {
|
||||
std::fs::write(dir.join(f), "a\n").unwrap();
|
||||
}
|
||||
std::fs::write(dir.join("rename_me.txt"), "some longer content\nthat is stable\nfor rename detection\n")
|
||||
.unwrap();
|
||||
commit_all(dir, "base");
|
||||
git(dir, &["checkout", "-q", "-b", "side"]);
|
||||
std::fs::write(dir.join("conflict.txt"), "side\n").unwrap();
|
||||
commit_all(dir, "side");
|
||||
git(dir, &["checkout", "-q", "main"]);
|
||||
std::fs::write(dir.join("conflict.txt"), "main\n").unwrap();
|
||||
commit_all(dir, "main");
|
||||
let merge = Command::new("git").current_dir(dir).args(["merge", "-q", "side"]).output().unwrap();
|
||||
assert!(!merge.status.success());
|
||||
|
||||
std::fs::write(dir.join("modified.txt"), "b\n").unwrap();
|
||||
std::fs::write(dir.join("staged.txt"), "b\n").unwrap();
|
||||
git(dir, &["add", "staged.txt"]);
|
||||
std::fs::remove_file(dir.join("deleted.txt")).unwrap();
|
||||
git(dir, &["rm", "-q", "staged_del.txt"]);
|
||||
std::fs::write(dir.join("both.txt"), "b\n").unwrap();
|
||||
git(dir, &["add", "both.txt"]);
|
||||
std::fs::write(dir.join("both.txt"), "c\n").unwrap();
|
||||
git(dir, &["mv", "rename_me.txt", "renamed with space.txt"]);
|
||||
std::fs::create_dir_all(dir.join("new dir")).unwrap();
|
||||
std::fs::write(dir.join("new dir/untracked.txt"), "u\n").unwrap();
|
||||
std::fs::write(dir.join("intent.txt"), "i\n").unwrap();
|
||||
git(dir, &["add", "-N", "intent.txt"]);
|
||||
|
||||
let repo = Repo::discover(dir).unwrap().expect("repository found");
|
||||
let ignored = IgnoredNames::default();
|
||||
let show = |(map, conflicts): Entries| {
|
||||
let mut c: Vec<_> = conflicts.into_iter().collect();
|
||||
c.sort();
|
||||
let m: Vec<_> = map
|
||||
.into_iter()
|
||||
.map(|(k, e)| format!("{k}: {:?} {:?} {:?}", e.staged, e.unstaged, e.renamed_from))
|
||||
.collect();
|
||||
(m, c)
|
||||
};
|
||||
let from_git = show(cli_status(&repo, &ignored).unwrap());
|
||||
assert_eq!(from_git, show(gix_status(&repo, &ignored).unwrap()));
|
||||
assert!(from_git.0.iter().any(|l| l.starts_with("renamed with space.txt: Some(Renamed)")), "{from_git:?}");
|
||||
assert_eq!(from_git.1, ["conflict.txt"]);
|
||||
}
|
||||
}
|
||||
@@ -2015,3 +2015,24 @@ fn reword_refuses_commits_outside_the_current_branch() {
|
||||
fx.git(&["checkout", "-q", "main"]);
|
||||
assert!(fx.repo().reword(&side, "other").is_err());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn status_skips_a_directory_it_cannot_read() {
|
||||
// `git status` warns "could not open directory: Permission denied" and lists the rest.
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let fx = Fixture::new();
|
||||
fx.write("a.txt", "a\n");
|
||||
fx.commit_all("init");
|
||||
fx.write("a.txt", "b\n");
|
||||
fx.write("new.txt", "n\n");
|
||||
let locked = fx.path("locked");
|
||||
std::fs::create_dir(&locked).unwrap();
|
||||
std::fs::write(locked.join("inside.txt"), "x").unwrap();
|
||||
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap();
|
||||
let st = fx.repo().status();
|
||||
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let st = st.unwrap();
|
||||
assert_eq!(status_of(&st, "a.txt").unstaged, Some(ChangeKind::Modified));
|
||||
assert_eq!(status_of(&st, "new.txt").unstaged, Some(ChangeKind::Untracked));
|
||||
}
|
||||
@@ -359,6 +359,32 @@ fn sync_error_is_tolerable(e: &std::io::Error) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// On Windows a file can't be replaced while another process has it open without
|
||||
/// delete sharing: an antivirus or indexer scan, a sync client, a program reading
|
||||
/// its config. Those let go within moments, so the replacing rename is retried briefly.
|
||||
const RENAME_ATTEMPTS: u32 = 10;
|
||||
const RENAME_PAUSE: std::time::Duration = std::time::Duration::from_millis(100);
|
||||
|
||||
/// Access denied (os error 5), sharing and lock violations (32, 33) on Windows.
|
||||
fn held_by_another_process(e: &std::io::Error) -> bool {
|
||||
cfg!(windows) && matches!(e.raw_os_error(), Some(5 | 32 | 33))
|
||||
}
|
||||
|
||||
fn retrying(
|
||||
attempts: u32,
|
||||
pause: std::time::Duration,
|
||||
transient: impl Fn(&std::io::Error) -> bool,
|
||||
mut op: impl FnMut() -> std::io::Result<()>,
|
||||
) -> std::io::Result<()> {
|
||||
for _ in 1..attempts {
|
||||
match op() {
|
||||
Err(e) if transient(&e) => std::thread::sleep(pause),
|
||||
r => return r,
|
||||
}
|
||||
}
|
||||
op()
|
||||
}
|
||||
|
||||
/// Writes `bytes` to `path` atomically: temp file in the same directory, fsync,
|
||||
/// rename over the target. Preserves the permissions of an existing file and
|
||||
/// writes through symlinks. Falls back to an in-place write when the directory
|
||||
@@ -441,7 +467,7 @@ fn write_atomic_impl(path: &Path, bytes: &[u8], allow_in_place: bool, private: b
|
||||
fs::set_permissions(&tmp, permissions)?;
|
||||
}
|
||||
drop(file);
|
||||
fs::rename(&tmp, &target)
|
||||
retrying(RENAME_ATTEMPTS, RENAME_PAUSE, held_by_another_process, || fs::rename(&tmp, &target))
|
||||
})();
|
||||
if let Err(e) = result {
|
||||
let _ = fs::remove_file(&tmp);
|
||||
@@ -539,6 +565,58 @@ mod tests {
|
||||
assert_eq!(fs::metadata(&target).unwrap().permissions().mode() & 0o777, 0o640);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacing_is_retried_only_while_a_file_is_held() {
|
||||
use std::io::{Error, ErrorKind};
|
||||
let in_use = || Error::from_raw_os_error(32);
|
||||
let transient = |e: &Error| e.raw_os_error() == Some(32);
|
||||
let zero = std::time::Duration::ZERO;
|
||||
for (fails, outcome) in [(0, true), (3, true), (RENAME_ATTEMPTS - 1, true), (RENAME_ATTEMPTS, false)] {
|
||||
let mut calls = 0;
|
||||
let r = retrying(RENAME_ATTEMPTS, zero, transient, || {
|
||||
calls += 1;
|
||||
if calls <= fails { Err(in_use()) } else { Ok(()) }
|
||||
});
|
||||
assert_eq!(r.is_ok(), outcome, "{fails} failures");
|
||||
assert_eq!(calls, (fails + 1).min(RENAME_ATTEMPTS));
|
||||
}
|
||||
let mut calls = 0;
|
||||
let r = retrying(RENAME_ATTEMPTS, zero, transient, || {
|
||||
calls += 1;
|
||||
Err(Error::from(ErrorKind::NotFound))
|
||||
});
|
||||
assert_eq!(r.unwrap_err().kind(), ErrorKind::NotFound);
|
||||
assert_eq!(calls, 1, "other errors fail at once");
|
||||
|
||||
for code in [5, 32, 33] {
|
||||
assert_eq!(held_by_another_process(&Error::from_raw_os_error(code)), cfg!(windows), "os error {code}");
|
||||
}
|
||||
assert!(!held_by_another_process(&Error::from(ErrorKind::NotFound)));
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn saving_waits_for_a_windows_file_lock_to_be_released() {
|
||||
use std::os::windows::fs::OpenOptionsExt;
|
||||
use windows_sys::Win32::Storage::FileSystem::{FILE_SHARE_READ, FILE_SHARE_WRITE};
|
||||
// No sharing at all (os error 32), and readers that don't allow deletion (os error 5).
|
||||
for share in [0, FILE_SHARE_READ | FILE_SHARE_WRITE] {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("config.json");
|
||||
fs::write(&path, b"old").unwrap();
|
||||
let held = fs::OpenOptions::new().read(true).share_mode(share).open(&path).unwrap();
|
||||
let release = std::thread::spawn(move || {
|
||||
std::thread::sleep(std::time::Duration::from_millis(300));
|
||||
drop(held);
|
||||
});
|
||||
let result = write_atomic(&path, b"new");
|
||||
release.join().unwrap();
|
||||
result.unwrap();
|
||||
assert_eq!(fs::read(&path).unwrap(), b"new", "share mode {share}");
|
||||
assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 1, "no temp file left behind");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn strict_write_does_not_truncate_when_sibling_creation_is_denied() {
|
||||
|
||||
@@ -25,6 +25,11 @@ pub struct DocumentInfo {
|
||||
pub line_ending: LineEnding,
|
||||
pub read_only: bool,
|
||||
pub dirty: bool,
|
||||
/// Whether "Reload with Encoding" can re-decode this document's source
|
||||
/// bytes: true for files and virtual documents the backend can re-read
|
||||
/// (archive entries, JDT sources with an editable attachment encoding,
|
||||
/// Git revisions); false for server-generated content (decompiled code).
|
||||
pub can_reload_encoding: bool,
|
||||
/// Larger than `ide_vfs::LARGE_FILE_THRESHOLD`: language servers, syntax
|
||||
/// structure and folding are disabled; the editor should drop heavy features.
|
||||
#[specta(optional)]
|
||||
@@ -143,6 +148,9 @@ struct DocState {
|
||||
observed: bool,
|
||||
binary: bool,
|
||||
dirty: bool,
|
||||
can_reload_encoding: bool,
|
||||
/// The user picked `encoding`; reloads keep it instead of detecting again.
|
||||
encoding_chosen: bool,
|
||||
/// Disk state we last loaded or wrote; changes matching it are our own.
|
||||
disk_mtime: f64,
|
||||
disk_len: u64,
|
||||
@@ -339,6 +347,7 @@ impl Documents {
|
||||
line_ending: st.doc.line_ending(),
|
||||
read_only: st.read_only,
|
||||
dirty: st.dirty,
|
||||
can_reload_encoding: st.can_reload_encoding,
|
||||
large: st.large,
|
||||
editorconfig: st.editorconfig.clone(),
|
||||
uri: st.uri.clone(),
|
||||
@@ -412,6 +421,8 @@ impl Documents {
|
||||
observed,
|
||||
binary: loaded.binary,
|
||||
dirty: false,
|
||||
can_reload_encoding: true,
|
||||
encoding_chosen: false,
|
||||
disk_mtime: loaded.mtime_ms,
|
||||
disk_len: disk_len(path),
|
||||
closed: false,
|
||||
@@ -449,8 +460,19 @@ impl Documents {
|
||||
|
||||
/// Opens (or re-references) a read-only virtual document: contents a
|
||||
/// language server provides for a non-`file:` URI. It is keyed by the URI,
|
||||
/// never touches the disk and cannot be edited or saved.
|
||||
pub fn open_virtual(&self, uri: &str, language: LanguageId, text: &str) -> IdeResult<DocumentInfo> {
|
||||
/// never touches the disk and cannot be edited or saved. `encoding` /
|
||||
/// `has_bom` describe how the content was decoded (not a guess);
|
||||
/// `can_reload_encoding` tells whether [`Documents::replace_virtual`] can
|
||||
/// re-decode it ("Reload with Encoding").
|
||||
pub fn open_virtual(
|
||||
&self,
|
||||
uri: &str,
|
||||
language: LanguageId,
|
||||
text: &str,
|
||||
encoding: &str,
|
||||
has_bom: bool,
|
||||
can_reload_encoding: bool,
|
||||
) -> IdeResult<DocumentInfo> {
|
||||
let _operation = self.operations.lock();
|
||||
let key = PathBuf::from(uri);
|
||||
if let Some(info) = self.reopen(&key) {
|
||||
@@ -462,8 +484,8 @@ impl Documents {
|
||||
doc: Document::new(text),
|
||||
language,
|
||||
ui_mode: None,
|
||||
encoding: "utf-8".into(),
|
||||
has_bom: false,
|
||||
encoding: encoding.into(),
|
||||
has_bom,
|
||||
native_to_ascii: false,
|
||||
read_only: true,
|
||||
large,
|
||||
@@ -471,6 +493,8 @@ impl Documents {
|
||||
observed: true,
|
||||
binary: false,
|
||||
dirty: false,
|
||||
can_reload_encoding,
|
||||
encoding_chosen: false,
|
||||
disk_mtime: 0.0,
|
||||
disk_len: 0,
|
||||
closed: false,
|
||||
@@ -846,6 +870,7 @@ impl Documents {
|
||||
let loaded = Self::load_as(&st.path, encoding, enabled)?;
|
||||
st.native_to_ascii = enabled;
|
||||
st.encoding = loaded.encoding.clone();
|
||||
st.encoding_chosen = true;
|
||||
st.has_bom = loaded.has_bom;
|
||||
st.disk_mtime = loaded.mtime_ms;
|
||||
st.disk_len = disk_len(&st.path);
|
||||
@@ -864,12 +889,45 @@ impl Documents {
|
||||
ide_vfs::encode(&st.doc.text_for_save(), encoding, has_bom)?;
|
||||
}
|
||||
st.encoding = encoding.to_owned();
|
||||
st.encoding_chosen = true;
|
||||
st.has_bom = has_bom;
|
||||
self.set_dirty(doc, st, true);
|
||||
Ok(DocumentEncoding { encoding: st.encoding.clone(), has_bom: st.has_bom })
|
||||
})
|
||||
}
|
||||
|
||||
/// The URI of a virtual document, or `None` for a file-backed one.
|
||||
pub fn virtual_uri(&self, doc: DocId) -> IdeResult<Option<String>> {
|
||||
self.with_doc(doc, |st| Ok(st.uri.clone()))
|
||||
}
|
||||
|
||||
/// Re-decoded content of a virtual document ("Reload with Encoding"):
|
||||
/// replaces the text and the encoding label, keeping the document
|
||||
/// read-only and clean. The caller re-reads the origin (archive entry,
|
||||
/// language server, Git blob) with the chosen encoding first.
|
||||
pub fn replace_virtual(
|
||||
&self,
|
||||
doc: DocId,
|
||||
text: &str,
|
||||
encoding: &str,
|
||||
has_bom: bool,
|
||||
) -> IdeResult<DocumentEncoding> {
|
||||
self.with_doc(doc, |st| {
|
||||
if st.uri.is_none() {
|
||||
return Err(IdeError::invalid("not a virtual document"));
|
||||
}
|
||||
ide_vfs::encode("", encoding, false)?;
|
||||
st.doc.set_line_ending(LineEnding::detect(text));
|
||||
self.replace_locked(doc, st, text)?;
|
||||
st.encoding = encoding.to_owned();
|
||||
st.has_bom = has_bom;
|
||||
st.large = text.len() as u64 > ide_vfs::LARGE_FILE_THRESHOLD;
|
||||
// Reloading is not an edit: the buffer matches its source again.
|
||||
self.set_dirty(doc, st, false);
|
||||
Ok(DocumentEncoding { encoding: st.encoding.clone(), has_bom: st.has_bom })
|
||||
})
|
||||
}
|
||||
|
||||
/// Adds or removes the BOM on a UTF-8 document; the change is written on save.
|
||||
pub fn set_bom(&self, doc: DocId, has_bom: bool) -> IdeResult<DocumentEncoding> {
|
||||
self.with_doc(doc, |st| {
|
||||
@@ -968,8 +1026,13 @@ impl Documents {
|
||||
Self::load_as(&st.path, &st.encoding, st.native_to_ascii)?
|
||||
} else {
|
||||
let defaults = self.file_defaults();
|
||||
let loaded = Self::load_with_defaults(&st.path, &defaults)?;
|
||||
st.native_to_ascii = defaults.native_to_ascii_for(&st.path);
|
||||
let native_to_ascii = defaults.native_to_ascii_for(&st.path);
|
||||
let loaded = if st.encoding_chosen {
|
||||
Self::load_as(&st.path, &st.encoding, native_to_ascii)?
|
||||
} else {
|
||||
Self::load_with_defaults(&st.path, &defaults)?
|
||||
};
|
||||
st.native_to_ascii = native_to_ascii;
|
||||
loaded
|
||||
};
|
||||
st.disk_mtime = loaded.mtime_ms;
|
||||
|
||||
@@ -748,6 +748,7 @@ impl Documents {
|
||||
let result = self.with_doc(*id, |state| {
|
||||
self.replace_locked(*id, state, &before.doc.text())?;
|
||||
state.encoding = before.encoding.clone();
|
||||
state.encoding_chosen = before.encoding_chosen;
|
||||
state.has_bom = before.has_bom;
|
||||
state.doc.set_line_ending(before.doc.line_ending());
|
||||
state.disk_mtime = fs::metadata(&state.path).map(|m| ide_vfs::mtime_ms(&m)).unwrap_or(0.0);
|
||||
@@ -789,7 +790,11 @@ impl Documents {
|
||||
return Err(stale(path));
|
||||
}
|
||||
let defaults = self.file_defaults();
|
||||
let loaded = ide_vfs::load_text_with_fallback(path, defaults.fallback_for(path))?;
|
||||
let loaded = if state.encoding_chosen {
|
||||
Self::load_as(path, &state.encoding, state.native_to_ascii)?
|
||||
} else {
|
||||
ide_vfs::load_text_with_fallback(path, defaults.fallback_for(path))?
|
||||
};
|
||||
self.replace_locked(id, state, &loaded.text)?;
|
||||
state.encoding = loaded.encoding;
|
||||
state.has_bom = loaded.has_bom;
|
||||
|
||||
@@ -505,6 +505,48 @@ fn reload_and_convert_encoding() {
|
||||
assert!(f.docs.set_encoding(info.id, "gbk", true).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_changes_keep_a_chosen_encoding() {
|
||||
// Valid UTF-8 bytes, so detection alone would read them as UTF-8.
|
||||
let (first, changed) = (b"caf\xC3\xA9".as_slice(), b"caf\xC3\xA9 \xC3\xA8".as_slice());
|
||||
for (encoding, reload) in [("windows-1252", true), ("gbk", true), ("windows-1252", false)] {
|
||||
let f = fixture();
|
||||
let path = f.root.join("legacy.txt");
|
||||
fs::write(&path, first).unwrap();
|
||||
let info = f.docs.open(&path).unwrap();
|
||||
assert_eq!(info.encoding, "utf-8");
|
||||
if reload {
|
||||
f.docs.set_encoding(info.id, encoding, true).unwrap();
|
||||
} else {
|
||||
// Convert, then save: the file is now in the chosen encoding.
|
||||
f.docs.replace_all(info.id, "café").unwrap();
|
||||
f.docs.set_encoding(info.id, encoding, false).unwrap();
|
||||
f.docs.save(info.id).unwrap();
|
||||
}
|
||||
let expected =
|
||||
|bytes: &[u8]| encoding_rs::Encoding::for_label(encoding.as_bytes()).unwrap().decode(bytes).0.into_owned();
|
||||
let changed = if reload { changed.to_vec() } else { ide_vfs::encode("café è", encoding, false).unwrap() };
|
||||
fs::write(&path, &changed).unwrap();
|
||||
f.docs.on_disk_changed(std::slice::from_ref(&path));
|
||||
let snapshot = f.docs.snapshot(info.id).unwrap();
|
||||
assert_eq!(snapshot.text, expected(&changed), "{encoding} reload={reload}");
|
||||
assert_eq!(snapshot.encoding, encoding, "{encoding} reload={reload}");
|
||||
assert!(!snapshot.dirty);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_changes_redetect_an_encoding_the_user_never_chose() {
|
||||
let f = fixture();
|
||||
let path = f.root.join("plain.txt");
|
||||
fs::write(&path, "plain").unwrap();
|
||||
let info = f.docs.open(&path).unwrap();
|
||||
fs::write(&path, ide_vfs::encode("wide", "utf-16le", true).unwrap()).unwrap();
|
||||
f.docs.on_disk_changed(std::slice::from_ref(&path));
|
||||
let snapshot = f.docs.snapshot(info.id).unwrap();
|
||||
assert_eq!((snapshot.text.as_str(), snapshot.encoding.as_str()), ("wide", "utf-16le"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utf8_bom_is_added_and_removed_on_save() {
|
||||
let f = fixture();
|
||||
@@ -656,14 +698,14 @@ fn editorconfig_applies_on_open_and_save() {
|
||||
fn virtual_documents_are_read_only_and_keyed_by_uri() {
|
||||
let f = fixture();
|
||||
let uri = "jdt://contents/a.jar/p/C.class?=x";
|
||||
let info = f.docs.open_virtual(uri, LanguageId::new("java"), "class C {}\n").unwrap();
|
||||
let info = f.docs.open_virtual(uri, LanguageId::new("java"), "class C {}\n", "utf-8", false, true).unwrap();
|
||||
assert_eq!(info.uri.as_deref(), Some(uri));
|
||||
assert_eq!(info.path, PathBuf::from(uri));
|
||||
assert!(info.read_only && !info.dirty);
|
||||
assert_eq!(info.language.as_str(), "java");
|
||||
|
||||
// Re-opening shares the document; edits and saves never reach a file.
|
||||
let again = f.docs.open_virtual(uri, LanguageId::new("java"), "ignored").unwrap();
|
||||
let again = f.docs.open_virtual(uri, LanguageId::new("java"), "ignored", "utf-8", false, true).unwrap();
|
||||
assert_eq!(again.id, info.id);
|
||||
assert_eq!(again.text, "class C {}\n");
|
||||
let err = f.docs.edit(info.id, info.version, change(0, 0, "x")).unwrap_err();
|
||||
@@ -677,6 +719,68 @@ fn virtual_documents_are_read_only_and_keyed_by_uri() {
|
||||
assert_eq!(*f.rec.log.lock(), vec!["open v0 java".to_owned(), "close".to_owned()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn virtual_documents_carry_their_encoding_and_reload_capability() {
|
||||
let f = fixture();
|
||||
// Archive sources decoded by the backend report the charset actually used.
|
||||
let uri = "jar:///m2/lib-1.0-sources.jar!/p/C.java";
|
||||
let info = f.docs.open_virtual(uri, LanguageId::new("java"), "中文", "gbk", false, true).unwrap();
|
||||
assert_eq!((info.encoding.as_str(), info.has_bom, info.can_reload_encoding), ("gbk", false, true));
|
||||
let bom =
|
||||
f.docs.open_virtual("jar:///m2/b.jar!/p/B.java", LanguageId::new("java"), "x", "utf-8", true, true).unwrap();
|
||||
assert!(bom.has_bom);
|
||||
// Server-generated content (decompiled) cannot be re-decoded.
|
||||
let decompiled = f
|
||||
.docs
|
||||
.open_virtual(
|
||||
"csharp:/p/App.csproj/decompiled/Lib.cs",
|
||||
LanguageId::new("csharp"),
|
||||
"class Lib {}",
|
||||
"utf-8",
|
||||
false,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!decompiled.can_reload_encoding);
|
||||
// File-backed documents can always be re-read from disk.
|
||||
let path = f.root.join("f.txt");
|
||||
fs::write(&path, "x").unwrap();
|
||||
assert!(f.docs.open(&path).unwrap().can_reload_encoding);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn virtual_documents_reload_with_another_encoding_without_becoming_editable() {
|
||||
let f = fixture();
|
||||
let uri = "jdt://contents/a.jar/p/C.class?=x";
|
||||
let info = f.docs.open_virtual(uri, LanguageId::new("java"), "涓枃", "utf-8", false, true).unwrap();
|
||||
let encoding = f.docs.replace_virtual(info.id, "中文", "gbk", false).unwrap();
|
||||
assert_eq!((encoding.encoding.as_str(), encoding.has_bom), ("gbk", false));
|
||||
let snap = f.docs.snapshot(info.id).unwrap();
|
||||
assert_eq!(snap.text, "中文");
|
||||
assert_eq!(snap.encoding, "gbk");
|
||||
assert!(snap.read_only && !snap.dirty, "reloading is not an edit");
|
||||
assert!(snap.version > info.version);
|
||||
assert_eq!(f.docs.virtual_uri(info.id).unwrap().as_deref(), Some(uri));
|
||||
// The replacement reached observers and the event stream (editors resync).
|
||||
assert!(f.rec.log.lock().iter().any(|l| l.starts_with("change v") && l.contains("中文")));
|
||||
assert!(f.events.lock().iter().any(|e| matches!(e, IdeEvent::DocPatched { doc, .. } if *doc == info.id)));
|
||||
|
||||
// Read-only protection is unchanged.
|
||||
assert_eq!(f.docs.edit(info.id, snap.version, change(0, 0, "x")).unwrap_err().kind, ErrorKind::PermissionDenied);
|
||||
assert!(f.docs.set_encoding(info.id, "utf-8", false).is_err());
|
||||
assert!(f.docs.set_encoding(info.id, "gbk", true).is_err(), "the op layer routes virtual reloads");
|
||||
assert!(f.docs.set_bom(info.id, true).is_err());
|
||||
f.docs.save(info.id).unwrap();
|
||||
|
||||
// Unknown encodings are refused; file-backed documents are not virtual.
|
||||
assert!(f.docs.replace_virtual(info.id, "x", "not-an-encoding", false).is_err());
|
||||
let path = f.root.join("f.txt");
|
||||
fs::write(&path, "x").unwrap();
|
||||
let file = f.docs.open(&path).unwrap();
|
||||
assert!(f.docs.replace_virtual(file.id, "y", "utf-8", false).is_err());
|
||||
assert_eq!(f.docs.virtual_uri(file.id).unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggling_the_writable_bit_updates_the_open_document() {
|
||||
let f = fixture();
|
||||
|
||||
@@ -162,6 +162,21 @@ fn undo_id(events: &Mutex<Vec<IdeEvent>>) -> u32 {
|
||||
.expect("successful transaction has one undo record")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_keeps_the_encoding_chosen_for_a_file_opened_after_the_edit() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("legacy.txt");
|
||||
std::fs::write(&path, b"abc\xC3\xA9").unwrap();
|
||||
let (docs, events) = recorded_documents();
|
||||
docs.apply_workspace_edit(&WorkspaceEdit { files: vec![edit(&path, "new")], ..Default::default() }).unwrap();
|
||||
let info = docs.open(&path).unwrap();
|
||||
docs.set_encoding(info.id, "windows-1252", true).unwrap();
|
||||
assert_eq!(docs.snapshot(info.id).unwrap().text, "newé");
|
||||
docs.undo_workspace_edit(undo_id(&events)).unwrap();
|
||||
let snapshot = docs.snapshot(info.id).unwrap();
|
||||
assert_eq!((snapshot.text.as_str(), snapshot.encoding.as_str()), ("abcé", "windows-1252"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_document_version_rejects_the_whole_edit() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -515,6 +530,18 @@ fn duplicate_edits_keep_versions_and_apply_one_replacement() {
|
||||
assert_eq!(std::fs::read_to_string(path).unwrap(), "new_long");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_inserts_at_one_position_all_apply_once_per_entry() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("main.ts");
|
||||
std::fs::write(&path, "f(").unwrap();
|
||||
let docs = documents();
|
||||
let close = TextEdit { range: Range::new(Pos::new(0, 2), Pos::new(0, 2)), new_text: ")".into() };
|
||||
let entry = FileEdit::Edit { path: path.clone().into(), version: None, edits: vec![close.clone(), close] };
|
||||
docs.apply_workspace_edit(&WorkspaceEdit { files: vec![entry.clone(), entry], ..Default::default() }).unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), "f())");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_accepts_typing_that_was_reverted_to_the_saved_refactoring() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -61,16 +61,17 @@ When you edit a POM in the editor, the tool window shows **Build files changed s
|
||||
|
||||
**Settings › Build, Execution, Deployment › Maven**:
|
||||
|
||||
| Setting | What it does |
|
||||
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **JRE** | JDK for Maven sync, builds, goals and tests |
|
||||
| **Maven home path** | A Maven installation to use instead of the project's `mvnw` |
|
||||
| **User settings file** | Passed as `-s` |
|
||||
| **Toolchains file** | Passed as `-t` |
|
||||
| **Local repository** | Passed as `-Dmaven.repo.local` |
|
||||
| **Active profiles** | Comma-separated profiles (`-P`); `!id` excludes one |
|
||||
| **Thread count** | Passed as `-T` (for example `4` or `2C`) |
|
||||
| **Use Maven Daemon (mvnd)** | Runs builds, sync and run configurations through `mvnd`; falls back to `mvn` if it is missing or the JDK is older than 17 |
|
||||
| Setting | What it does |
|
||||
| ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Delegate IDE build/run actions to Maven** | Run and Debug build Maven modules with Maven (`javac`) instead of jdtls |
|
||||
| **JRE** | JDK for Maven sync, builds, goals and tests |
|
||||
| **Maven home path** | A Maven installation to use instead of the project's `mvnw` |
|
||||
| **User settings file** | Passed as `-s` |
|
||||
| **Toolchains file** | Passed as `-t` |
|
||||
| **Local repository** | Passed as `-Dmaven.repo.local` |
|
||||
| **Active profiles** | Comma-separated profiles (`-P`); `!id` excludes one |
|
||||
| **Thread count** | Passed as `-T` (for example `4` or `2C`) |
|
||||
| **Use Maven Daemon (mvnd)** | Runs builds, sync and run configurations through `mvnd`; falls back to `mvn` if it is missing or the JDK is older than 17 |
|
||||
|
||||
## Gradle
|
||||
|
||||
|
||||
@@ -66,6 +66,7 @@ The data source dialog has a name, an optional **Color:** (tints the tree and co
|
||||
|
||||
- Project data sources: `.openidea/data-sources.toml` in the project.
|
||||
- Global data sources: `data-sources.toml` in the IDE's data directory (see [Data directories](/en/reference/data-directories)).
|
||||
- You can edit these files by hand or have a coding agent edit them while the project is open: the Database tool window picks up the change, and connections of data sources whose settings changed or that were removed are closed.
|
||||
- Passwords are never written to these files. Saved passwords go to the OS credential store (Keychain, Windows Credential Manager or Secret Service). Unsaved ones are kept in memory for the session; OpenIDEA asks for them when it connects.
|
||||
|
||||
## Browse the database
|
||||
|
||||
@@ -28,6 +28,8 @@ You can change single shortcuts on top of any keymap, or duplicate one into your
|
||||
|
||||
The defaults already match IDEA: the keymap, tool window numbers (`⌘1` / `Alt+1` for Project, `⌘9` / `Alt+9` for Git), `⇧⇧` / `Shift Shift` for Search Everywhere and `⌥↵` / `Alt+Enter` for context actions.
|
||||
|
||||
A tool window's options menu (`⋮`) has IDEA's **View Mode**: **Dock Pinned** (the default); **Dock Unpinned**, which hides the window when you click into the editor or another tool window; and **Undock**, which shows the window over the editor without resizing it and hides it the same way. Float and Window are not available.
|
||||
|
||||
### Import settings
|
||||
|
||||
Use **File › Manage IDE Settings › Import Settings from JetBrains IDE…** and pick one of the JetBrains IDEs found on this computer. To use a `settings.zip` exported from a JetBrains IDE instead, choose **File › Manage IDE Settings › Import Settings…** and select the zip.
|
||||
|
||||
@@ -54,6 +54,14 @@ Use **Add Code Cell** and **Add Markdown Cell** at the end of the notebook to ap
|
||||
|
||||
Code cells show the execution count (`[3]`) next to the cell toolbar. Raw cells and cell types OpenIDEA doesn't know are shown read-only; their content is kept as it is.
|
||||
|
||||
## Code intelligence
|
||||
|
||||
Code cells get completion, quick documentation on hover, parameter info and error highlighting from the project's Python language server, as in a `.py` file. The server reads the code cells in order, so names defined in one cell are known in the cells below it, and IPython syntax such as `%matplotlib inline` or `!pip list` isn't reported as an error.
|
||||
|
||||
`⌘`-click (macOS) or `Ctrl`-click (Windows/Linux) a name to go to its declaration, in another cell or in a library.
|
||||
|
||||
This needs a language server that supports notebooks: **basedpyright** (the default) and **Ruff** do. With other Python servers, code cells only get syntax highlighting.
|
||||
|
||||
## Keyboard
|
||||
|
||||
| Action | Keys |
|
||||
|
||||
@@ -133,7 +133,7 @@ Opening a folder under `\\wsl.localhost\<distribution>\` also opens it through t
|
||||
- **Run targets** (**Settings › Build, Execution, Deployment › Run Targets**) run a configuration on an SSH host. See
|
||||
[Run configurations](/en/guide/run).
|
||||
- **Data sources** can connect through an SSH tunnel. See [Database](/en/guide/database).
|
||||
- **Tools › Deployment** uploads, downloads and syncs project files with a server over SFTP.
|
||||
- **Tools › Deployment** uploads, downloads and syncs project files with a server over SFTP, FTP or FTPS.
|
||||
|
||||
## Limits
|
||||
|
||||
|
||||
@@ -144,6 +144,8 @@ Add tasks under **Modify options › Add before launch task**. They run in order
|
||||
|
||||
For Java, Maven and Gradle configurations, the **Build** option picks how the project is built first: **Automatic (incremental)**, **Build (Make)**, **Rebuild**, **No build** or **Build, no error check**.
|
||||
|
||||
Maven modules are built by jdtls (ECJ) for **Automatic** and **Build (Make)**, and by Maven for **Rebuild**. Turn on **Settings › Build, Execution, Deployment › Maven › Delegate IDE build/run actions to Maven** to build them with Maven (the project's `javac`) every time; editor **Problems** still come from jdtls.
|
||||
|
||||
### Compound configurations
|
||||
|
||||
A **Compound** configuration starts several configurations at once. Run it to start all of them, or Debug it to debug each of them. Stop from the run widget stops them all.
|
||||
|
||||
@@ -11,9 +11,10 @@ Each open file gets its language from the definitions, checked in this order:
|
||||
|
||||
1. A per-file **Override File Type** (in the context menu of a file in the project tree or of an editor tab).
|
||||
2. An exact file name (`Dockerfile`, `go.mod`) or a file name pattern.
|
||||
3. The extension, longest match first (`.html.erb` before `.erb`).
|
||||
4. The first line: a shebang such as `#!/usr/bin/env python3`, or a pattern such as `<?php`.
|
||||
5. Otherwise the file is plain text.
|
||||
3. A framework's folder: an `.html` or `.txt` file in a `templates` folder with a `manage.py` above it is a Django template.
|
||||
4. The extension, longest match first (`.html.erb` before `.erb`).
|
||||
5. The first line: a shebang such as `#!/usr/bin/env python3`, or a pattern such as `<?php`.
|
||||
6. Otherwise the file is plain text.
|
||||
|
||||
You can add or remove file name patterns per language in **Settings › Editor › File Types** (**Recognized File Types**).
|
||||
|
||||
@@ -106,6 +107,7 @@ The table lists every built-in language definition. Server and debugger names in
|
||||
| D | `d` | `.d`, `.di` | Yes | — | serve-d | — |
|
||||
| Dart | `dart` | `.dart` | Yes | Yes | dart | dart |
|
||||
| Diff | `diff` | `.diff`, `.patch`, `.rej` | Yes | — | — | — |
|
||||
| Django Template | `django-html` | `.djhtml`, `templates/*.html`, `*.txt` | Yes | Yes | **vscode-html-language-server** | — |
|
||||
| Dockerfile | `dockerfile` | `.dockerfile`, `.containerfile` | Yes | — | **dockerfile-language-server** | — |
|
||||
| Elixir | `elixir` | `.ex`, `.exs` | Yes | Yes | **elixir-ls** | **elixir-ls-debugger** |
|
||||
| ERB | `erb` | `.erb`, `.html.erb`, `.rhtml`, `.js.erb`, `.text.erb`, … | Yes | — | — | — |
|
||||
@@ -133,7 +135,7 @@ The table lists every built-in language definition. Server and debugger names in
|
||||
| JSON with Comments | `jsonc` | `.jsonc`, `.code-workspace`, `.code-snippets` | Yes | Yes | **vscode-json-language-server** | — |
|
||||
| JSP | `jsp` | `.jsp`, `.jspx`, `.jspf`, `.jsph`, `.tag`, … | Yes | — | — | — |
|
||||
| Julia | `julia` | `.jl` | Yes | — | julia-languageserver | — |
|
||||
| Kotlin | `kotlin` | `.kt`, `.kts` | Yes | Yes | **kotlin-lsp** → **kotlin-language-server** | **kotlin-debug-adapter** |
|
||||
| Kotlin | `kotlin` | `.kt`, `.kts` | Yes | Yes | **kotlin-lsp** → **kotlin-language-server** → **kmp-lsp** | **kotlin-debug-adapter** |
|
||||
| LaTeX | `latex` | `.tex`, `.ltx`, `.sty`, `.cls`, `.dtx`, … | Yes | — | **texlab** | — |
|
||||
| Less | `less` | `.less` | Yes | — | **vscode-css-language-server** | — |
|
||||
| Log | `log` | `.log` | Yes | — | — | — |
|
||||
|
||||
@@ -7,12 +7,12 @@ Java code insight comes from the Eclipse JDT Language Server (jdtls), extended w
|
||||
|
||||
## Language servers
|
||||
|
||||
| Language | Server | Fallback | Debugger |
|
||||
| ---------------------- | ----------------------------------------- | ------------------------------------------------------- | --------------------------------------- |
|
||||
| Java | Eclipse JDT Language Server (`jdtls`) | `kmp-lsp` | Microsoft java-debug, loaded into jdtls |
|
||||
| Kotlin | Kotlin LSP (JetBrains, `kotlin-lsp`) | Kotlin Language Server (fwcd, `kotlin-language-server`) | Kotlin Debug Adapter (fwcd) |
|
||||
| Groovy, Gradle scripts | `groovy-language-server` (you provide it) | | |
|
||||
| JSP | none (highlighting only) | | |
|
||||
| Language | Server | Fallback | Debugger |
|
||||
| ---------------------- | ----------------------------------------- | ------------------------------------------------------------------ | --------------------------------------- |
|
||||
| Java | Eclipse JDT Language Server (`jdtls`) | `kmp-lsp` | Microsoft java-debug, loaded into jdtls |
|
||||
| Kotlin | Kotlin LSP (JetBrains, `kotlin-lsp`) | Kotlin Language Server (fwcd, `kotlin-language-server`), `kmp-lsp` | Kotlin Debug Adapter (fwcd) |
|
||||
| Groovy, Gradle scripts | `groovy-language-server` (you provide it) | | |
|
||||
| JSP | none (highlighting only) | | |
|
||||
|
||||
A fallback starts only while the server listed before it is not installed. To pick one explicitly, open **Settings › Languages & Frameworks › Language Servers**, select either server and use **Language server for Java** (or Kotlin): **Automatic** uses the first one available, **None** turns code insight off. The choice can be global or for the current project.
|
||||
|
||||
@@ -86,7 +86,7 @@ When the project uses Lombok (in its build file, an inherited parent POM or Grad
|
||||
|
||||
jdtls imports Maven and Gradle projects (including the Gradle wrapper) automatically. A folder without a build file is opened as a plain Java project, and jars under `lib/` are added to its classpath. OpenIDEA also shows **Maven** and **Gradle** tool windows for running goals and tasks, reloading, profiles and dependency analysis. Their settings are in **Settings › Build, Execution, Deployment › Maven** and **Gradle**:
|
||||
|
||||
- Maven: **JRE**, **Maven home path** (default: the project's `mvnw`, else `mvn` on `PATH`), **User settings file**, **Toolchains file**, **Local repository**, **Active profiles**, **Thread count** and **Use Maven Daemon (mvnd)**. The profiles you choose in the Maven tool window are passed to jdtls too, so the editor sees the same profile-specific dependencies.
|
||||
- Maven: **Delegate IDE build/run actions to Maven** (Run and Debug build with Maven's `javac` instead of jdtls), **JRE**, **Maven home path** (default: the project's `mvnw`, else `mvn` on `PATH`), **User settings file**, **Toolchains file**, **Local repository**, **Active profiles**, **Thread count** and **Use Maven Daemon (mvnd)**. The profiles you choose in the Maven tool window are passed to jdtls too, so the editor sees the same profile-specific dependencies.
|
||||
- Gradle: **Build and run using** (Gradle or OpenIDEA), **Gradle JVM**, **Distribution** (wrapper or local installation), **Gradle home** and **Gradle user home**.
|
||||
|
||||
The IDE passes its proxy settings to jdtls, so dependency resolution works behind a proxy. See [Build tools](/en/guide/build-tools) for the tool windows.
|
||||
@@ -141,5 +141,6 @@ Navigation between MyBatis mapper interfaces and their XML statements is availab
|
||||
- One jdtls runs per project window. With several large projects open at once, memory adds up. Lower `-Xmx` in **JVM options** or close windows you are not using. **Share JDT LS across builds** helps only within one window.
|
||||
- Without jdtls (for example with `kmp-lsp` only), Application configurations, classpath-based features and Java debugging are not available.
|
||||
- Java tests run through Maven or Gradle. A project without either build tool has no test runner.
|
||||
- The jdtls compiler (ECJ) is stricter than javac in some places. Known case: a generic method call used directly as an `if`, `while`, `for` or `do` condition or a ternary condition — for example `if (getCacheObject(key))` with a `<T> T getCacheObject(String key)` method — compiles with javac but ECJ reports `Type mismatch: cannot convert from Object to boolean`. Assigning the call to a `boolean` variable first compiles on both (see [#2624](https://github.com/l0ng-ai/openidea/issues/2624)).
|
||||
- The Kotlin bridge is Maven only and exposes compiled Kotlin classes to Java. It does not give Java-to-Kotlin source navigation or refactoring.
|
||||
- Groovy needs a `groovy-language-server` you build yourself, and JSP has syntax highlighting only.
|
||||
@@ -125,6 +125,12 @@ See [Debugging](/en/guide/debugging) for breakpoints, stepping and the debugger
|
||||
|
||||
Django is the only framework with dedicated support: the `manage.py runserver` and `manage.py test` configurations, the **Django tests** configuration type (target, settings module, options), running single `TestCase` tests from the gutter, and Django mode in the debugger. Other frameworks (Flask, FastAPI and so on) run as ordinary scripts or console scripts.
|
||||
|
||||
### Django templates
|
||||
|
||||
The HTML and text files in a `templates` folder of a Django project (one with a `manage.py` above it) open as **Django Template** files: the template tags, variables, filters and comments are highlighted on top of the HTML, and block tags (`{% block %}`, `{% if %}`, `{% for %}`, `{% with %}` and the others of Django and its contrib libraries) fold, indent their content and highlight their end tag. Typing `{%`, `{{` or `{#` closes the pair, finishing a block tag adds its end tag, and `{% end` completes the end tags of the open blocks. **Comment with Line Comment** puts each line in its own `{# … #}`, and **Comment with Block Comment** over several lines uses `{% comment %}`. The HTML language server and Emmet work in the markup between the tags.
|
||||
|
||||
An HTML file elsewhere with Django tags shows a banner that switches it to Django Template or keeps it as HTML; either choice is the file's **Override File Type**.
|
||||
|
||||
## Limitations
|
||||
|
||||
- Only one type checker runs at a time. Ruff runs only in projects with a Ruff configuration.
|
||||
|
||||
@@ -52,7 +52,6 @@ exclude = ["**/.git", "**/node_modules", "**/target", "**/dist"]
|
||||
| Editor | General (Auto Import, Appearance, Breadcrumbs, Code Completion, Code Folding, Editor Tabs, Gutter Icons, Inline Completion, Postfix Completion, Rich-Text Copy, Smart Keys, Sticky Lines); Code Editing; Font; Color Scheme; Code Style; Inspections; Natural Languages (Spelling); File and Code Templates; File Encodings; Live Templates; File Types; Inlay Hints; TODO; Vim |
|
||||
| Plugins | Plugins |
|
||||
| Version Control | Commit; Confirmation; Directory Mappings; Issue Navigation; Log; Shelf; Git; GitHub |
|
||||
| Project Structure | Project Structure |
|
||||
| Build, Execution, Deployment | CMake; Gradle; Maven; Console; Debugger (Data Views, Stepping); Python Debugger; Run/Debug; Run Targets; Deployment (Options) |
|
||||
| Languages & Frameworks | Language Servers; Go (Build Tags, Go Modules); Java; Markdown; Node.js; PHP; Python Interpreter; Prettier; oxfmt; Ruff; Ruby; Rust; TypeScript |
|
||||
| Tools | Agents (Chat); Terminal; Actions on Save; External Tools; SSH Configurations; MCP Server |
|
||||
|
||||
@@ -61,16 +61,17 @@ Maven 和 Gradle 使用设置里配置的 wrapper 或本地安装(见下文)
|
||||
|
||||
**设置 › 构建、执行、部署 › Maven**:
|
||||
|
||||
| 设置 | 作用 |
|
||||
| ---------------------------- | ------------------------------------------------------------------------------- |
|
||||
| **JRE** | Maven 同步、构建、目标和测试使用的 JDK |
|
||||
| **Maven 主路径** | 使用指定的 Maven 安装,而不是项目里的 `mvnw` |
|
||||
| **用户设置文件** | 作为 `-s` 传入 |
|
||||
| **Toolchains 文件** | 作为 `-t` 传入 |
|
||||
| **本地仓库** | 作为 `-Dmaven.repo.local` 传入 |
|
||||
| **活动 profiles** | 逗号分隔的 profile(`-P`),`!id` 表示排除 |
|
||||
| **线程数** | 作为 `-T` 传入(如 `4` 或 `2C`) |
|
||||
| **使用 Maven Daemon (mvnd)** | 构建、同步和运行配置都通过 `mvnd` 执行;找不到 mvnd 或 JDK 低于 17 时退回 `mvn` |
|
||||
| 设置 | 作用 |
|
||||
| ------------------------------------ | ------------------------------------------------------------------------------- |
|
||||
| **将 IDE 构建/运行操作委托给 Maven** | 运行和调试时用 Maven(`javac`)构建 Maven 模块,而不是 jdtls |
|
||||
| **JRE** | Maven 同步、构建、目标和测试使用的 JDK |
|
||||
| **Maven 主路径** | 使用指定的 Maven 安装,而不是项目里的 `mvnw` |
|
||||
| **用户设置文件** | 作为 `-s` 传入 |
|
||||
| **Toolchains 文件** | 作为 `-t` 传入 |
|
||||
| **本地仓库** | 作为 `-Dmaven.repo.local` 传入 |
|
||||
| **活动 profiles** | 逗号分隔的 profile(`-P`),`!id` 表示排除 |
|
||||
| **线程数** | 作为 `-T` 传入(如 `4` 或 `2C`) |
|
||||
| **使用 Maven Daemon (mvnd)** | 构建、同步和运行配置都通过 `mvnd` 执行;找不到 mvnd 或 JDK 低于 17 时退回 `mvn` |
|
||||
|
||||
## Gradle
|
||||
|
||||
|
||||
@@ -66,6 +66,7 @@ description: "连接 SQL 数据库、浏览架构、在查询控制台中执行
|
||||
|
||||
- 项目数据源:项目中的 `.openidea/data-sources.toml`。
|
||||
- 全局数据源:IDE 数据目录下的 `data-sources.toml`(见 [数据目录](/zh/reference/data-directories))。
|
||||
- 项目打开时,可以手动或让编码代理编辑这些文件:数据库工具窗口会自动读取改动,设置被修改或被删除的数据源的连接会被关闭。
|
||||
- 密码从不写入这些文件。保存的密码放在操作系统凭据存储中(钥匙串、Windows 凭据管理器或 Secret Service);未保存的密码只在本次会话的内存中,连接时会询问。
|
||||
|
||||
## 浏览数据库
|
||||
|
||||
@@ -28,6 +28,8 @@ OpenIDEA 内置六套快捷键方案:
|
||||
|
||||
默认设置已经和 IDEA 一致:快捷键方案、工具窗口编号(`⌘1` / `Alt+1` 打开项目,`⌘9` / `Alt+9` 打开 Git)、`⇧⇧` / `Shift Shift` 打开随处搜索、`⌥↵` / `Alt+Enter` 显示上下文操作。
|
||||
|
||||
工具窗口的选项菜单(`⋮`)提供 IDEA 的**视图模式**:**停靠固定**(默认);**停靠取消固定**,点击编辑器或其他工具窗口时隐藏该窗口;**取消停靠**,窗口显示在编辑器上方,不改变编辑器大小,并以同样方式隐藏。不提供“浮动”和“窗口”模式。
|
||||
|
||||
### 导入设置
|
||||
|
||||
使用 **文件 › 管理 IDE 设置 › 从 JetBrains IDE 导入设置…**,从本机找到的 JetBrains IDE 中选择一个。如果手上是从 JetBrains IDE 导出的 `settings.zip`,则选择 **文件 › 管理 IDE 设置 › 导入设置…** 并选中该 zip 文件。
|
||||
|
||||
@@ -54,6 +54,14 @@ OpenIDEA 用笔记本编辑器打开 `.ipynb` 文件。你可以编辑代码单
|
||||
|
||||
代码单元格的工具栏旁显示执行序号(如 `[3]`)。原始单元格和 OpenIDEA 不认识的单元格类型以只读方式显示,内容原样保留。
|
||||
|
||||
## 代码智能
|
||||
|
||||
和 `.py` 文件一样,代码单元格可以使用项目 Python 语言服务器提供的补全、悬停快速文档、参数信息和错误高亮。语言服务器按顺序读取代码单元格,所以前面单元格定义的名称在后面的单元格里可以识别;`%matplotlib inline`、`!pip list` 这类 IPython 语法也不会被报成错误。
|
||||
|
||||
按住 `⌘`(macOS)或 `Ctrl`(Windows/Linux)点击名称,可以跳转到它的声明,无论声明在其他单元格还是库中。
|
||||
|
||||
这需要支持笔记本的语言服务器:**basedpyright**(默认)和 **Ruff** 都支持。使用其他 Python 语言服务器时,代码单元格只有语法高亮。
|
||||
|
||||
## 快捷键
|
||||
|
||||
| 操作 | 按键 |
|
||||
|
||||
@@ -105,7 +105,7 @@ OpenIDEA 自带 SSH 客户端,自行读取 `~/.ssh/config`,支持 `Host`、`
|
||||
|
||||
- **运行目标**(**设置 › 构建、执行、部署 › 运行目标**)可以在 SSH 主机上执行运行配置,见[运行配置](/zh/guide/run)。
|
||||
- **数据源**可以通过 SSH 隧道连接,见[数据库](/zh/guide/database)。
|
||||
- **工具 › 部署**通过 SFTP 与服务器上传、下载和同步项目文件。
|
||||
- **工具 › 部署**通过 SFTP、FTP 或 FTPS 与服务器上传、下载和同步项目文件。
|
||||
|
||||
## 限制
|
||||
|
||||
|
||||
@@ -140,6 +140,8 @@ description: "创建、编辑和启动运行配置,从装订区运行代码,
|
||||
|
||||
Java、Maven、Gradle 配置还可以通过 **构建** 选项决定启动前怎么构建:**自动(增量)**、**构建(Make)**、**重新构建**、**不构建** 或 **构建,不检查错误**。
|
||||
|
||||
Maven 模块在 **自动** 和 **构建(Make)** 时由 jdtls(ECJ)构建,**重新构建** 时由 Maven 构建。打开 **设置 › 构建、执行、部署 › Maven › 将 IDE 构建/运行操作委托给 Maven** 后每次都用 Maven(项目的 `javac`)构建;编辑器的 **问题** 仍来自 jdtls。
|
||||
|
||||
### 复合配置
|
||||
|
||||
**复合** 配置一次启动多个配置:运行它会启动全部成员,调试它会逐个调试每个成员。在运行微件上点停止会把它们一起停掉。
|
||||
|
||||
@@ -11,9 +11,10 @@ OpenIDEA 用一个小的定义文件 `languages/<id>.toml` 描述每种语言:
|
||||
|
||||
1. 单个文件的 **覆盖文件类型**(在项目树中文件的右键菜单或编辑器标签页的右键菜单里)。
|
||||
2. 精确文件名(`Dockerfile`、`go.mod`)或文件名模式。
|
||||
3. 扩展名,优先匹配最长的(`.html.erb` 先于 `.erb`)。
|
||||
4. 文件第一行:例如 `#!/usr/bin/env python3` 这样的 shebang,或 `<?php` 这类模式。
|
||||
5. 都不匹配时按纯文本处理。
|
||||
3. 框架目录:上层目录有 `manage.py` 的 `templates` 目录中的 `.html` 或 `.txt` 文件是 Django 模板。
|
||||
4. 扩展名,优先匹配最长的(`.html.erb` 先于 `.erb`)。
|
||||
5. 文件第一行:例如 `#!/usr/bin/env python3` 这样的 shebang,或 `<?php` 这类模式。
|
||||
6. 都不匹配时按纯文本处理。
|
||||
|
||||
在 **设置 › 编辑器 › 文件类型**(**已识别的文件类型**)中可以为某种语言添加或移除文件名模式。
|
||||
|
||||
@@ -101,6 +102,7 @@ IDE 安装工具从不需要 `sudo`。工具装在本地数据目录下 IDE 自
|
||||
| D | `d` | `.d`, `.di` | 是 | — | serve-d | — |
|
||||
| Dart | `dart` | `.dart` | 是 | 是 | dart | dart |
|
||||
| Diff | `diff` | `.diff`, `.patch`, `.rej` | 是 | — | — | — |
|
||||
| Django Template | `django-html` | `.djhtml`, `templates/*.html`, `*.txt` | 是 | 是 | **vscode-html-language-server** | — |
|
||||
| Dockerfile | `dockerfile` | `.dockerfile`, `.containerfile` | 是 | — | **dockerfile-language-server** | — |
|
||||
| Elixir | `elixir` | `.ex`, `.exs` | 是 | 是 | **elixir-ls** | **elixir-ls-debugger** |
|
||||
| ERB | `erb` | `.erb`, `.html.erb`, `.rhtml`, `.js.erb`, `.text.erb`, … | 是 | — | — | — |
|
||||
@@ -128,7 +130,7 @@ IDE 安装工具从不需要 `sudo`。工具装在本地数据目录下 IDE 自
|
||||
| JSON with Comments | `jsonc` | `.jsonc`, `.code-workspace`, `.code-snippets` | 是 | 是 | **vscode-json-language-server** | — |
|
||||
| JSP | `jsp` | `.jsp`, `.jspx`, `.jspf`, `.jsph`, `.tag`, … | 是 | — | — | — |
|
||||
| Julia | `julia` | `.jl` | 是 | — | julia-languageserver | — |
|
||||
| Kotlin | `kotlin` | `.kt`, `.kts` | 是 | 是 | **kotlin-lsp** → **kotlin-language-server** | **kotlin-debug-adapter** |
|
||||
| Kotlin | `kotlin` | `.kt`, `.kts` | 是 | 是 | **kotlin-lsp** → **kotlin-language-server** → **kmp-lsp** | **kotlin-debug-adapter** |
|
||||
| LaTeX | `latex` | `.tex`, `.ltx`, `.sty`, `.cls`, `.dtx`, … | 是 | — | **texlab** | — |
|
||||
| Less | `less` | `.less` | 是 | — | **vscode-css-language-server** | — |
|
||||
| Log | `log` | `.log` | 是 | — | — | — |
|
||||
|
||||
@@ -7,12 +7,12 @@ Java 的代码智能由 Eclipse JDT Language Server(jdtls)提供,OpenIDEA
|
||||
|
||||
## 语言服务器
|
||||
|
||||
| 语言 | 服务器 | 备选 | 调试器 |
|
||||
| ------------------- | -------------------------------------- | -------------------------------------------------------- | ------------------------------------- |
|
||||
| Java | Eclipse JDT Language Server(`jdtls`) | `kmp-lsp` | Microsoft java-debug,加载在 jdtls 内 |
|
||||
| Kotlin | Kotlin LSP(JetBrains,`kotlin-lsp`) | Kotlin Language Server(fwcd,`kotlin-language-server`) | Kotlin Debug Adapter(fwcd) |
|
||||
| Groovy、Gradle 脚本 | `groovy-language-server`(需自行提供) | | |
|
||||
| JSP | 无(仅语法高亮) | | |
|
||||
| 语言 | 服务器 | 备选 | 调试器 |
|
||||
| ------------------- | -------------------------------------- | ------------------------------------------------------------------- | ------------------------------------- |
|
||||
| Java | Eclipse JDT Language Server(`jdtls`) | `kmp-lsp` | Microsoft java-debug,加载在 jdtls 内 |
|
||||
| Kotlin | Kotlin LSP(JetBrains,`kotlin-lsp`) | Kotlin Language Server(fwcd,`kotlin-language-server`)、`kmp-lsp` | Kotlin Debug Adapter(fwcd) |
|
||||
| Groovy、Gradle 脚本 | `groovy-language-server`(需自行提供) | | |
|
||||
| JSP | 无(仅语法高亮) | | |
|
||||
|
||||
备选服务器只在排在它前面的服务器未安装时才会启动。想手动指定,打开 **设置 › 语言和框架 › 语言服务器**,选中其中一个服务器,在 **Java 语言服务器**(或 Kotlin 语言服务器)里选择:**自动** 使用第一个可用的,**无** 关闭代码智能。这个选择可以全局生效,也可以只对当前项目生效。
|
||||
|
||||
@@ -86,7 +86,7 @@ jdtls 的工作区保存在 IDE 的数据目录(`lsp/jdtls/`)中,不在项
|
||||
|
||||
jdtls 会自动导入 Maven 和 Gradle 项目(包括 Gradle wrapper)。没有构建文件的目录会作为普通 Java 项目打开,`lib/` 下的 jar 会加入类路径。OpenIDEA 还提供 **Maven** 和 **Gradle** 工具窗口,用来执行目标和任务、重新加载、切换 profile 以及分析依赖。相关设置在 **设置 › 构建、执行、部署 › Maven** 和 **Gradle**:
|
||||
|
||||
- Maven:**JRE**、**Maven 主路径**(默认用项目的 `mvnw`,没有则用 `PATH` 上的 `mvn`)、**用户设置文件**、**Toolchains 文件**、**本地仓库**、**活动 profiles**、**线程数** 和 **使用 Maven Daemon (mvnd)**。在 Maven 工具窗口里选的 profile 也会传给 jdtls,编辑器看到的依赖与该 profile 一致。
|
||||
- Maven:**将 IDE 构建/运行操作委托给 Maven**(运行和调试时用 Maven 的 `javac` 而不是 jdtls 构建)、**JRE**、**Maven 主路径**(默认用项目的 `mvnw`,没有则用 `PATH` 上的 `mvn`)、**用户设置文件**、**Toolchains 文件**、**本地仓库**、**活动 profiles**、**线程数** 和 **使用 Maven Daemon (mvnd)**。在 Maven 工具窗口里选的 profile 也会传给 jdtls,编辑器看到的依赖与该 profile 一致。
|
||||
- Gradle:**构建和运行使用**(Gradle 或 OpenIDEA)、**Gradle 使用的 JVM**、**分发**(Gradle 包装器或本地安装)、**Gradle 主路径** 和 **Gradle 用户主目录**。
|
||||
|
||||
IDE 会把代理设置传给 jdtls,所以在代理环境下也能解析依赖。工具窗口的用法见[构建工具](/zh/guide/build-tools)。
|
||||
@@ -141,5 +141,6 @@ MyBatis mapper 接口与 XML 语句之间的跳转由 MyBatis Navigator 插件
|
||||
- 每个项目窗口运行一个 jdtls,同时打开多个大项目时内存会叠加。可以在 **JVM 选项** 中调低 `-Xmx`,或关闭暂时不用的窗口。**在构建间共享 JDT LS** 只在单个窗口内有效。
|
||||
- 没有 jdtls 时(例如只用 `kmp-lsp`),“应用程序”配置、依赖类路径的功能和 Java 调试都不可用。
|
||||
- Java 测试通过 Maven 或 Gradle 运行,两者都没有的项目没有测试运行器。
|
||||
- jdtls 自带的 ECJ 编译器有些地方比 javac 严格。已知例子:把泛型方法调用直接用作 `if`、`while`、`for`、`do` 条件或三元运算符条件——例如 `<T> T getCacheObject(String key)` 方法配合 `if (getCacheObject(key))`——javac 可以编译,ECJ 则报 `Type mismatch: cannot convert from Object to boolean`。先把调用赋值给 `boolean` 变量即可两边都编译通过(见 [#2624](https://github.com/l0ng-ai/openidea/issues/2624))。
|
||||
- Kotlin 桥接只支持 Maven,只把编译后的 Kotlin 类暴露给 Java,不提供从 Java 到 Kotlin 源码的跳转或重构。
|
||||
- Groovy 需要自行构建 `groovy-language-server`,JSP 只有语法高亮。
|
||||
@@ -125,6 +125,12 @@ Python 调试使用 debugpy。开始调试时如果缺少 debugpy,OpenIDEA 会
|
||||
|
||||
目前只有 Django 有专门支持:`manage.py runserver` 和 `manage.py test` 运行配置、**Django 测试** 配置类型(目标、settings 模块、选项)、在左侧栏运行单个 `TestCase` 测试,以及调试器的 Django 模式。Flask、FastAPI 等其他框架按普通脚本或命令行入口运行。
|
||||
|
||||
### Django 模板
|
||||
|
||||
Django 项目(上层目录有 `manage.py`)中 `templates` 目录里的 HTML 和文本文件以 **Django Template** 打开:模板标签、变量、过滤器和注释在 HTML 之上高亮,块标签(`{% block %}`、`{% if %}`、`{% for %}`、`{% with %}` 以及 Django 和其 contrib 库的其他块标签)可以折叠、缩进内容并高亮对应的结束标签。输入 `{%`、`{{` 或 `{#` 会自动补全成对符号,写完块标签会自动添加结束标签,输入 `{% end` 会补全当前打开块的结束标签。**使用行注释** 会把每一行放进各自的 `{# … #}`,多行的 **使用块注释** 则用 `{% comment %}`。HTML 语言服务器和 Emmet 在标签之间的标记中照常工作。
|
||||
|
||||
其他位置含有 Django 标签的 HTML 文件会显示一个横幅,可以切换为 Django Template 或保留为 HTML;两种选择都会成为该文件的 **覆盖文件类型**。
|
||||
|
||||
## 限制
|
||||
|
||||
- 同一时间只运行一个类型检查器。Ruff 只在有 Ruff 配置的项目中运行。
|
||||
|
||||
@@ -52,7 +52,6 @@ exclude = ["**/.git", "**/node_modules", "**/target", "**/dist"]
|
||||
| 编辑器 | 常规(自动导入、外观、痕迹导航、代码补全、代码折叠、编辑器标签页、装订区域图标、内联补全、后缀补全、富文本复制、智能键、粘滞行);代码编辑;字体;配色方案;代码样式;检查;自然语言(拼写);文件和代码模板;文件编码;实时模板;文件类型;嵌入提示;TODO;Vim |
|
||||
| 插件 | 插件 |
|
||||
| 版本控制 | 提交;确认;目录映射;问题导航;日志;搁置;Git;GitHub |
|
||||
| 项目结构 | 项目结构 |
|
||||
| 构建、执行、部署 | CMake;Gradle;Maven;控制台;调试器(数据视图、单步执行);Python 调试器;运行/调试;运行目标;部署(选项) |
|
||||
| 语言和框架 | 语言服务器;Go(构建标签、Go 模块);Java;Markdown;Node.js;PHP;Python 解释器;Prettier;oxfmt;Ruff;Ruby;Rust;TypeScript |
|
||||
| 工具 | 智能体(聊天);终端;保存时操作;外部工具;SSH 配置;MCP 服务器 |
|
||||
|
||||
@@ -383,6 +383,14 @@ test("keeps mermaid `<style>` rules scoped to the rendered svg after id rewritin
|
||||
expect(await css(reply, ".flowchart-link", "fill")).toBe("none");
|
||||
});
|
||||
|
||||
test("renders KaTeX math in mermaid labels", async ({ page }) => {
|
||||
const diagram = ["```mermaid", "flowchart LR", ' A["$$x^2 + \\frac{a}{b}$$"] --> B', "```"].join("\n");
|
||||
const reply = await openReply(page, `Diagram:\n\n${diagram}`);
|
||||
const svg = reply.locator("[data-mermaid-state='ok'] .mermaid-output svg");
|
||||
await expect(svg).toBeVisible();
|
||||
await expect(svg.locator("math mfrac")).toHaveCount(1);
|
||||
});
|
||||
|
||||
test("keeps mermaid flowchart nodes and sequence control blocks off solid black", async ({ page }) => {
|
||||
const flow = [
|
||||
"```mermaid",
|
||||
|
||||
@@ -16,6 +16,8 @@ export const test = base.extend<{ workbench: Page }>({
|
||||
await page.goto("/");
|
||||
await expect(page.getByRole("tree", { name: "Project" })).toBeVisible();
|
||||
await expect(page.getByRole("treeitem", { name: /acme-api/ })).toBeVisible();
|
||||
// The start-up splash swallows raw mouse presses until it has faded out.
|
||||
await expect(page.locator("#splash")).toHaveCount(0);
|
||||
await provide(page);
|
||||
expect(errors, "uncaught page errors").toEqual([]);
|
||||
},
|
||||
|
||||
@@ -78,8 +78,6 @@ const settle = (page: Page) =>
|
||||
page.evaluate(() => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done))));
|
||||
|
||||
async function fileRow(page: Page) {
|
||||
// The start-up splash swallows raw mouse presses until it has faded out.
|
||||
await expect(page.locator("#splash")).toHaveCount(0);
|
||||
const file = page.getByRole("tree", { name: "Project" }).getByRole("treeitem", { name: "settings.gradle.kts" });
|
||||
await file.scrollIntoViewIfNeeded();
|
||||
const content = file.locator("[data-project-path]");
|
||||
|
||||
@@ -85,16 +85,13 @@ for (const id of ["editor.fileTemplates", "editor.liveTemplates"]) {
|
||||
}
|
||||
|
||||
test("Project Structure keeps every details pane usable in a compact window", async ({ workbench: page }) => {
|
||||
await page.keyboard.press("Meta+,");
|
||||
await expect(page.getByRole("dialog", { name: "Settings", exact: true })).toBeVisible();
|
||||
await page.evaluate(async () => {
|
||||
const path = "/src/features/settings/model/staging.ts";
|
||||
(await import(/* @vite-ignore */ path)).selectPage("projectStructure");
|
||||
});
|
||||
await page.keyboard.press("Meta+;");
|
||||
const dialog = page.getByRole("dialog", { name: "Project Structure", exact: true });
|
||||
await expect(dialog).toBeVisible();
|
||||
await page.setViewportSize({ width: 520, height: 760 });
|
||||
for (const section of ["Modules", "Libraries", "Facets", "Artifacts", "SDKs"]) {
|
||||
await page.getByRole("tab", { name: section, exact: true }).click();
|
||||
const details = page.locator("[class*='splitLayout'] > [class*='detailPane']");
|
||||
await dialog.getByRole("button", { name: section, exact: true }).click();
|
||||
const details = dialog.locator("[class*='splitLayout'] > [class*='detailPane']");
|
||||
await expect(details).toBeAttached();
|
||||
expect((await details.boundingBox())!.width).toBeGreaterThan(200);
|
||||
}
|
||||
|
||||
@@ -93,6 +93,36 @@ test("terminal highlights visible occurrences of selected text in a single overl
|
||||
activeSession()!.term.clearSelection();
|
||||
});
|
||||
await expect(terminal.locator(".openidea-terminal-selection-match:visible")).toHaveCount(0);
|
||||
|
||||
// Full-scrollback trim shifts a visible match into the cached margin row above viewportY; scrolling up must highlight it.
|
||||
await page.evaluate(async () => {
|
||||
const path = "/src/features/terminal/runtime.ts";
|
||||
const { activeSession } = await import(path);
|
||||
const s = activeSession()!;
|
||||
const rows = s.term.rows;
|
||||
s.term.options.scrollback = 4;
|
||||
s.term.reset();
|
||||
const total = rows + 4;
|
||||
let payload = "";
|
||||
for (let i = 0; i < total - 1; i++) {
|
||||
payload += `${i === 4 ? "needle hit" : `row-${i}`}\r\n`;
|
||||
}
|
||||
payload += "needle sel";
|
||||
await new Promise<void>((resolve) => s.term.write(payload, resolve));
|
||||
s.term.select(0, total - 1, 6);
|
||||
});
|
||||
await expect(terminal.locator(".openidea-terminal-selection-match:visible")).toHaveCount(1);
|
||||
|
||||
await page.evaluate(async () => {
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
const path = "/src/features/terminal/runtime.ts";
|
||||
const { activeSession } = await import(path);
|
||||
const s = activeSession()!;
|
||||
await new Promise<void>((resolve) => s.term.write("\r\nnext", resolve));
|
||||
s.term.scrollLines(-1);
|
||||
});
|
||||
await expect(terminal.locator(".openidea-terminal-selection-match:visible")).toHaveCount(1);
|
||||
await expect(terminal.locator(".openidea-terminal-selection-match:visible")).toHaveCSS("top", "0px");
|
||||
});
|
||||
|
||||
test("terminal ⌘F highlights active/inactive search matches and seeds with a new selection", async ({
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, openFromTree, test } from "./fixtures";
|
||||
|
||||
const project = (page: Page) => page.locator('[data-toolwindow="project"]');
|
||||
const editorArea = (page: Page) => page.locator("[data-editor-area]");
|
||||
|
||||
async function setViewMode(page: Page, mode: "Dock Pinned" | "Dock Unpinned" | "Undock") {
|
||||
await project(page).getByRole("button", { name: "Options" }).click();
|
||||
await page.getByRole("menuitem", { name: "View Mode" }).click();
|
||||
await page.getByRole("menuitemcheckbox", { name: mode }).click();
|
||||
}
|
||||
|
||||
test("Undock shows the Project window over the editor; focusing the editor hides it", async ({ workbench: page }) => {
|
||||
const editor = await openFromTree(page, "build.gradle.kts");
|
||||
const docked = (await editorArea(page).boundingBox())!;
|
||||
|
||||
await setViewMode(page, "Undock");
|
||||
await expect(project(page)).toBeVisible();
|
||||
const area = (await editorArea(page).boundingBox())!;
|
||||
// The editor takes the Project window's place and keeps it while the window shows over it.
|
||||
expect(area.x).toBeLessThan(docked.x);
|
||||
expect(area.width).toBeGreaterThan(docked.width);
|
||||
const over = (await project(page).boundingBox())!;
|
||||
expect(over.x + over.width).toBeGreaterThan(area.x + 100);
|
||||
|
||||
// Its inner edge resizes it over the editor.
|
||||
const edge = (await page.getByRole("separator", { name: "Resize undocked left tool window" }).boundingBox())!;
|
||||
await page.mouse.move(edge.x + edge.width / 2, edge.y + edge.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(edge.x + edge.width / 2 + 100, edge.y + edge.height / 2, { steps: 5 });
|
||||
await page.mouse.up();
|
||||
expect((await project(page).boundingBox())!.width).toBeCloseTo(over.width + 100, -1);
|
||||
expect(await editorArea(page).boundingBox()).toEqual(area);
|
||||
|
||||
// Right of the window, which covers the editor's left part.
|
||||
const content = editor.locator(".cm-content");
|
||||
await content.click({ position: { x: (await content.boundingBox())!.width * 0.75, y: 40 } });
|
||||
await expect(project(page)).toBeHidden();
|
||||
expect(await editorArea(page).boundingBox()).toEqual(area);
|
||||
|
||||
await page.keyboard.press("Meta+1");
|
||||
await expect(project(page)).toBeVisible();
|
||||
await expect(page.getByRole("tree", { name: "Project" })).toBeFocused();
|
||||
expect(await editorArea(page).boundingBox()).toEqual(area);
|
||||
});
|
||||
|
||||
test("a menu opened from an undocked window leaves it open", async ({ workbench: page }) => {
|
||||
await setViewMode(page, "Undock");
|
||||
await project(page).getByRole("button", { name: "Options" }).click();
|
||||
await expect(page.getByRole("menuitem", { name: "View Mode" })).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(project(page)).toBeVisible();
|
||||
await page.getByRole("treeitem", { name: /acme-api/ }).click({ button: "right" });
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(project(page)).toBeVisible();
|
||||
});
|
||||
|
||||
test("Dock Unpinned docks the window and hides it when another tool window takes focus", async ({
|
||||
workbench: page,
|
||||
}) => {
|
||||
const docked = (await editorArea(page).boundingBox())!;
|
||||
await setViewMode(page, "Dock Unpinned");
|
||||
expect(await editorArea(page).boundingBox()).toEqual(docked);
|
||||
|
||||
await page.getByRole("tree", { name: "Project" }).focus();
|
||||
await page.keyboard.press("Meta+6");
|
||||
await expect(page.locator('[data-toolwindow="problems"]')).toBeVisible();
|
||||
await expect(project(page)).toBeHidden();
|
||||
|
||||
// Back to Dock Pinned: the window stays when focus moves on.
|
||||
await page.keyboard.press("Meta+1");
|
||||
await setViewMode(page, "Dock Pinned");
|
||||
await page.keyboard.press("Meta+6");
|
||||
await expect(project(page)).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$comment": "Human-owned: agents don't change this file or evals/*/golden/ (a PR that does is labelled needs-decision). `*Ms` are upper bounds on medians (the user's view: input → first painted frame), `recall` / `precision` lower bounds vs the golden. Keys: `<scenario>` or `<scenario>@<condition>`. Regressions vs target/eval/baseline.json need both the relative tolerance and the absolute floor.",
|
||||
"regressionTolerance": 0.2,
|
||||
"regressionFloorMs": 200,
|
||||
"defaults": {
|
||||
"feedbackMs": 100,
|
||||
"recall": 1,
|
||||
"precision": 1
|
||||
},
|
||||
"scenarios": {}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"tools": {
|
||||
"jdtls": "1.63.0-202610191322",
|
||||
"java": "21.0.13.0.2 (Homebrew)"
|
||||
},
|
||||
"groups": {
|
||||
"completions": [
|
||||
"add",
|
||||
"addAll",
|
||||
"addFirst",
|
||||
"addLast",
|
||||
"cast",
|
||||
"clear",
|
||||
"contains",
|
||||
"containsAll",
|
||||
"else",
|
||||
"equals",
|
||||
"for",
|
||||
"forEach",
|
||||
"fori",
|
||||
"get",
|
||||
"getClass",
|
||||
"getFirst",
|
||||
"getLast",
|
||||
"hashCode",
|
||||
"if",
|
||||
"indexOf",
|
||||
"isEmpty",
|
||||
"iterator",
|
||||
"lastIndexOf",
|
||||
"listIterator",
|
||||
"nn",
|
||||
"not",
|
||||
"notify",
|
||||
"notifyAll",
|
||||
"null",
|
||||
"orders.nnull",
|
||||
"orders.opt",
|
||||
"orders.syserr",
|
||||
"orders.sysouf",
|
||||
"orders.sysout",
|
||||
"orders.sysoutv",
|
||||
"par",
|
||||
"parallelStream",
|
||||
"remove",
|
||||
"removeAll",
|
||||
"removeFirst",
|
||||
"removeIf",
|
||||
"removeLast",
|
||||
"replaceAll",
|
||||
"retainAll",
|
||||
"return",
|
||||
"reversed",
|
||||
"set",
|
||||
"size",
|
||||
"sort",
|
||||
"sout",
|
||||
"spliterator",
|
||||
"stream",
|
||||
"subList",
|
||||
"throw",
|
||||
"toArray",
|
||||
"toString",
|
||||
"try",
|
||||
"var",
|
||||
"wait",
|
||||
"while"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"tools": {
|
||||
"jdtls": "1.63.0-202610191322",
|
||||
"java": "21.0.13.0.2 (Homebrew)"
|
||||
},
|
||||
"groups": {
|
||||
"completions": [
|
||||
"book.nnull",
|
||||
"book.opt",
|
||||
"book.syserr",
|
||||
"book.sysouf",
|
||||
"book.sysout",
|
||||
"book.sysoutv",
|
||||
"byCustomer",
|
||||
"cast",
|
||||
"else",
|
||||
"equals",
|
||||
"for",
|
||||
"fori",
|
||||
"getClass",
|
||||
"hashCode",
|
||||
"if",
|
||||
"largest",
|
||||
"nn",
|
||||
"not",
|
||||
"notify",
|
||||
"notifyAll",
|
||||
"null",
|
||||
"par",
|
||||
"place",
|
||||
"return",
|
||||
"revenueCents",
|
||||
"size",
|
||||
"sout",
|
||||
"throw",
|
||||
"toString",
|
||||
"try",
|
||||
"var",
|
||||
"wait",
|
||||
"while"
|
||||
]
|
||||
}
|
||||
}
|
||||
Loaded 100 of 380 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Заблокувати користувача