mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-10 02:31:27 -10:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ba50a9d67 | ||
|
|
c1dece1164 |
No files matched your search
@@ -117,8 +117,6 @@ jobs:
|
||||
releaseId: ${{ needs.create-release.outputs.release_id }}
|
||||
tagName: ${{ env.TAG }}
|
||||
args: --target ${{ matrix.target }}
|
||||
# The parallel builds would race on one latest.json; `updater-manifest` writes it once.
|
||||
uploadUpdaterJson: false
|
||||
# tauri-action notarizes the .app but not the DMG around it; notarize and
|
||||
# staple the DMG too, then replace the uploaded asset.
|
||||
- name: Notarize DMG
|
||||
@@ -174,65 +172,8 @@ jobs:
|
||||
if command -v sha256sum >/dev/null; then sha256sum "$ASSET"; else shasum -a 256 "$ASSET"; fi > "$ASSET.sha256"
|
||||
gh release upload "$TAG" "$ASSET" "$ASSET.sha256" --clobber
|
||||
|
||||
# The updater manifest (latest.json), built once from the signed bundles every build uploaded.
|
||||
# Fails when a platform is missing, so a release never ships a manifest without it.
|
||||
updater-manifest:
|
||||
needs: [create-release, build]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/github-script@v7
|
||||
env:
|
||||
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const release_id = Number(process.env.RELEASE_ID);
|
||||
const tag = process.env.TAG;
|
||||
const assets = await github.paginate(github.rest.repos.listReleaseAssets, { owner, repo, release_id });
|
||||
const sigs = assets.filter((a) => a.name.endsWith(".sig"));
|
||||
if (sigs.length === 0) {
|
||||
core.notice("no signed bundles (TAURI_SIGNING_PRIVATE_KEY unset): skipping latest.json");
|
||||
return;
|
||||
}
|
||||
// Bundle suffix → updater platform keys, as tauri-action names them.
|
||||
const kinds = [
|
||||
[/_amd64\.AppImage$/, ["linux-x86_64", "linux-x86_64-appimage"]],
|
||||
[/_amd64\.deb$/, ["linux-x86_64-deb"]],
|
||||
[/\.x86_64\.rpm$/, ["linux-x86_64-rpm"]],
|
||||
[/_aarch64\.app\.tar\.gz$/, ["darwin-aarch64", "darwin-aarch64-app"]],
|
||||
[/_x64\.app\.tar\.gz$/, ["darwin-x86_64", "darwin-x86_64-app"]],
|
||||
[/_x64_en-US\.msi$/, ["windows-x86_64", "windows-x86_64-msi"]],
|
||||
[/_x64-setup\.exe$/, ["windows-x86_64-nsis"]],
|
||||
];
|
||||
const platforms = {};
|
||||
for (const sig of sigs) {
|
||||
const bundle = sig.name.slice(0, -".sig".length);
|
||||
const kind = kinds.find(([re]) => re.test(bundle));
|
||||
if (!kind) continue;
|
||||
const { data } = await github.rest.repos.getReleaseAsset({
|
||||
owner, repo, asset_id: sig.id, headers: { accept: "application/octet-stream" },
|
||||
});
|
||||
const signature = Buffer.from(data).toString("utf8").trim();
|
||||
const url = `https://github.com/${owner}/${repo}/releases/download/${tag}/${encodeURIComponent(bundle)}`;
|
||||
for (const key of kind[1]) platforms[key] = { signature, url };
|
||||
}
|
||||
const missing = kinds.flatMap(([, keys]) => keys).filter((k) => !platforms[k]);
|
||||
if (missing.length) {
|
||||
core.setFailed(`latest.json would miss ${missing.join(", ")}`);
|
||||
return;
|
||||
}
|
||||
const manifest = { version: tag.replace(/^v/, ""), notes: "", pub_date: new Date().toISOString(), platforms };
|
||||
const old = assets.find((a) => a.name === "latest.json");
|
||||
if (old) await github.rest.repos.deleteReleaseAsset({ owner, repo, asset_id: old.id });
|
||||
await github.rest.repos.uploadReleaseAsset({
|
||||
owner, repo, release_id, name: "latest.json",
|
||||
data: JSON.stringify(manifest, null, 2),
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
core.info(`latest.json: ${Object.keys(platforms).sort().join(", ")}`);
|
||||
|
||||
publish:
|
||||
needs: [create-release, build, server, updater-manifest]
|
||||
needs: [create-release, build, server]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/github-script@v7
|
||||
|
||||
Generated
+33
-53
@@ -3974,7 +3974,7 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "ide-agent"
|
||||
version = "0.1.24"
|
||||
version = "0.1.23"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"ide-core",
|
||||
@@ -3995,7 +3995,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-analysis"
|
||||
version = "0.1.24"
|
||||
version = "0.1.23"
|
||||
dependencies = [
|
||||
"ast-grep-core",
|
||||
"ide-core",
|
||||
@@ -4009,7 +4009,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-build"
|
||||
version = "0.1.24"
|
||||
version = "0.1.23"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"dunce",
|
||||
@@ -4024,30 +4024,24 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-clipboard"
|
||||
version = "0.1.24"
|
||||
dependencies = [
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
]
|
||||
version = "0.1.23"
|
||||
|
||||
[[package]]
|
||||
name = "ide-core"
|
||||
version = "0.1.24"
|
||||
version = "0.1.23"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
"specta-typescript",
|
||||
"tempfile",
|
||||
"thiserror 2.0.21",
|
||||
"tokio-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-coverage"
|
||||
version = "0.1.24"
|
||||
version = "0.1.23"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-run",
|
||||
@@ -4061,7 +4055,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-dap"
|
||||
version = "0.1.24"
|
||||
version = "0.1.23"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-jsonrpc",
|
||||
@@ -4078,7 +4072,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-db"
|
||||
version = "0.1.24"
|
||||
version = "0.1.23"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"futures",
|
||||
@@ -4097,7 +4091,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-docker"
|
||||
version = "0.1.24"
|
||||
version = "0.1.23"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"libc",
|
||||
@@ -4109,20 +4103,9 @@ dependencies = [
|
||||
"which",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-fonts"
|
||||
version = "0.1.24"
|
||||
dependencies = [
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-text",
|
||||
"serde",
|
||||
"specta",
|
||||
"windows",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-github"
|
||||
version = "0.1.24"
|
||||
version = "0.1.23"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-vcs",
|
||||
@@ -4136,7 +4119,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-host"
|
||||
version = "0.1.24"
|
||||
version = "0.1.23"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"dunce",
|
||||
@@ -4177,7 +4160,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-http"
|
||||
version = "0.1.24"
|
||||
version = "0.1.23"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"parking_lot",
|
||||
@@ -4195,7 +4178,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-index"
|
||||
version = "0.1.24"
|
||||
version = "0.1.23"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"criterion",
|
||||
@@ -4217,7 +4200,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-jsonrpc"
|
||||
version = "0.1.24"
|
||||
version = "0.1.23"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"futures",
|
||||
@@ -4233,7 +4216,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-language"
|
||||
version = "0.1.24"
|
||||
version = "0.1.23"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"criterion",
|
||||
@@ -4287,7 +4270,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-lsp"
|
||||
version = "0.1.24"
|
||||
version = "0.1.23"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"globset",
|
||||
@@ -4310,7 +4293,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-plugin"
|
||||
version = "0.1.24"
|
||||
version = "0.1.23"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-language",
|
||||
@@ -4328,7 +4311,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-profiler"
|
||||
version = "0.1.24"
|
||||
version = "0.1.23"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-run",
|
||||
@@ -4341,7 +4324,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-remote"
|
||||
version = "0.1.24"
|
||||
version = "0.1.23"
|
||||
dependencies = [
|
||||
"globset",
|
||||
"ide-core",
|
||||
@@ -4358,7 +4341,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-run"
|
||||
version = "0.1.24"
|
||||
version = "0.1.23"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"ide-core",
|
||||
@@ -4378,7 +4361,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-sdk"
|
||||
version = "0.1.24"
|
||||
version = "0.1.23"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"ide-core",
|
||||
@@ -4398,7 +4381,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-search"
|
||||
version = "0.1.24"
|
||||
version = "0.1.23"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"globset",
|
||||
@@ -4418,7 +4401,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-spell"
|
||||
version = "0.1.24"
|
||||
version = "0.1.23"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"parking_lot",
|
||||
@@ -4431,7 +4414,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-ssh"
|
||||
version = "0.1.24"
|
||||
version = "0.1.23"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"flate2",
|
||||
@@ -4450,7 +4433,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-syntax"
|
||||
version = "0.1.24"
|
||||
version = "0.1.23"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-language",
|
||||
@@ -4465,7 +4448,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-terminal"
|
||||
version = "0.1.24"
|
||||
version = "0.1.23"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"libc",
|
||||
@@ -4478,7 +4461,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-text"
|
||||
version = "0.1.24"
|
||||
version = "0.1.23"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"ide-core",
|
||||
@@ -4490,7 +4473,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-tools"
|
||||
version = "0.1.24"
|
||||
version = "0.1.23"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"hex",
|
||||
@@ -4511,12 +4494,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-vcs"
|
||||
version = "0.1.24"
|
||||
version = "0.1.23"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"gix",
|
||||
"ide-core",
|
||||
"ignore",
|
||||
"libc",
|
||||
"regex-lite",
|
||||
"serde",
|
||||
@@ -4529,12 +4511,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-vfs"
|
||||
version = "0.1.24"
|
||||
version = "0.1.23"
|
||||
dependencies = [
|
||||
"chardetng",
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
"gix",
|
||||
"globset",
|
||||
"ide-core",
|
||||
"ignore",
|
||||
@@ -4551,7 +4532,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-web"
|
||||
version = "0.1.24"
|
||||
version = "0.1.23"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"open",
|
||||
@@ -4567,7 +4548,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-workspace"
|
||||
version = "0.1.24"
|
||||
version = "0.1.23"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dunce",
|
||||
@@ -5992,7 +5973,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openidea"
|
||||
version = "0.1.24"
|
||||
version = "0.1.23"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dirs 6.0.0",
|
||||
@@ -6006,7 +5987,6 @@ dependencies = [
|
||||
"ide-dap",
|
||||
"ide-db",
|
||||
"ide-docker",
|
||||
"ide-fonts",
|
||||
"ide-github",
|
||||
"ide-host",
|
||||
"ide-http",
|
||||
|
||||
+1
-2
@@ -3,7 +3,7 @@ resolver = "3"
|
||||
members = ["crates/*", "src-tauri"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.24"
|
||||
version = "0.1.23"
|
||||
edition = "2024"
|
||||
rust-version = "1.96"
|
||||
license = "Apache-2.0"
|
||||
@@ -36,7 +36,6 @@ ide-web = { path = "crates/ide-web" }
|
||||
ide-spell = { path = "crates/ide-spell" }
|
||||
ide-agent = { path = "crates/ide-agent" }
|
||||
ide-clipboard = { path = "crates/ide-clipboard" }
|
||||
ide-fonts = { path = "crates/ide-fonts" }
|
||||
ide-ssh = { path = "crates/ide-ssh" }
|
||||
ide-remote = { path = "crates/ide-remote" }
|
||||
ide-host = { path = "crates/ide-host" }
|
||||
|
||||
@@ -28,19 +28,6 @@ icon set, copied unmodified (some renamed) from JetBrains' open-source repositor
|
||||
|
||||
At render time `src/ui/icons.tsx` may recolour monochrome icons to `currentColor`.
|
||||
|
||||
## JetBrains IntelliJ Platform diff algorithm
|
||||
|
||||
`src/features/vcs/comparison/**` is a TypeScript port of the text comparison of
|
||||
[intellij-community](https://github.com/JetBrains/intellij-community) (Apache-2.0):
|
||||
`platform/util/diff` (`com.intellij.util.diff`: Diff, Reindexer, MyersLCS,
|
||||
PatienceIntLCS, UniqueLCS; `com.intellij.diff.comparison`: ByLineRt, ByWordRt,
|
||||
ByCharRt, ChunkOptimizer, ChangeCorrector, LineFragmentSplitter, TrimUtil,
|
||||
ComparisonUtil, iterables) and `platform/diff-impl` (ComparisonManagerImpl's
|
||||
`compareLinesInner` and `processBlocks`). `__tests__/ideaCases.json` is converted
|
||||
from its tests (`LineComparisonUtilTest`, `WordComparisonUtilTest`,
|
||||
`SplitComparisonUtilTest`). Modified from the originals. Copyright 2000-2026
|
||||
JetBrains s.r.o. and contributors. Apache License 2.0 (below).
|
||||
|
||||
`src/ui/openidea/**` holds OpenIDEA's own file-type icons drawn in the expui style for
|
||||
languages whose JetBrains icons are not open source (Go, Rust, Ruby, Dart, Lua, Zig,
|
||||
Svelte, …). Those using the page-with-corner-badge layout (`goMod`, `goWork`,
|
||||
|
||||
@@ -4,39 +4,6 @@
|
||||
use crate::model::{AgentCommand, AgentConfigOption, AgentConfigValue, AgentConfigVia, AgentUsage};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Cursor exposes execution modes over ACP, but leaves approval policy to its client.
|
||||
pub(crate) const CURSOR_APPROVAL: &str = "openidea.cursor.approval";
|
||||
|
||||
pub(crate) fn add_client_options(provider: &str, config: &mut Vec<AgentConfigOption>) {
|
||||
if provider != "cursor" || config.iter().any(|o| o.id == CURSOR_APPROVAL) {
|
||||
return;
|
||||
}
|
||||
config.push(AgentConfigOption {
|
||||
id: CURSOR_APPROVAL.into(),
|
||||
name: "Approvals".into(),
|
||||
description: Some("Tool approvals for this Cursor conversation".into()),
|
||||
category: Some("approval".into()),
|
||||
current: Some("ask".into()),
|
||||
via: AgentConfigVia::Option,
|
||||
values: vec![
|
||||
AgentConfigValue {
|
||||
value: "ask".into(),
|
||||
name: "Ask for approval".into(),
|
||||
description: Some("Show permission requests from Cursor".into()),
|
||||
},
|
||||
AgentConfigValue {
|
||||
value: "bypass".into(),
|
||||
name: "Bypass approvals".into(),
|
||||
description: Some("Automatically approve tool requests in this conversation".into()),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn bypass_approvals(provider: &str, config: &[AgentConfigOption]) -> bool {
|
||||
provider == "cursor" && config.iter().any(|o| o.id == CURSOR_APPROVAL && o.current.as_deref() == Some("bypass"))
|
||||
}
|
||||
|
||||
/// The settings in a `session/new` or `session/load` result: its select `configOptions`,
|
||||
/// plus the older `modes` / `models` fields for what those don't cover.
|
||||
pub(crate) fn options_of(result: &Value) -> Vec<AgentConfigOption> {
|
||||
@@ -97,7 +64,7 @@ pub(crate) fn parse_options(options: &Value) -> Vec<AgentConfigOption> {
|
||||
pub(crate) fn merge_update(config: &mut Vec<AgentConfigOption>, options: &Value) {
|
||||
let fresh = parse_options(options);
|
||||
let covered = |o: &AgentConfigOption| fresh.iter().any(|f| f.category.is_some() && f.category == o.category);
|
||||
config.retain(|o| o.id == CURSOR_APPROVAL || (o.via != AgentConfigVia::Option && !covered(o)));
|
||||
config.retain(|o| o.via != AgentConfigVia::Option && !covered(o));
|
||||
let legacy = std::mem::take(config);
|
||||
config.extend(fresh);
|
||||
config.extend(legacy);
|
||||
|
||||
@@ -46,8 +46,6 @@ const STDERR_LINES: usize = 20;
|
||||
/// Updates of a session's state rather than its conversation.
|
||||
const SESSION_STATE: [&str; 5] =
|
||||
["session_info_update", "config_option_update", "current_mode_update", "available_commands_update", "usage_update"];
|
||||
/// A native agent's own update: it is about to retry a failed API request (see [`AgentRetry`]).
|
||||
pub(crate) const RETRY_UPDATE: &str = "openidea_retry";
|
||||
/// Referenced files up to this size are embedded in the prompt (when the agent takes it).
|
||||
const MAX_EMBED: u64 = 256 * 1024;
|
||||
/// How long a probe waits for the agent to answer `initialize`.
|
||||
@@ -114,13 +112,7 @@ impl Shared {
|
||||
}
|
||||
|
||||
fn known_config(&self, provider: &str) -> Vec<AgentConfigOption> {
|
||||
let mut config = self.known.lock().get(provider).map(|k| k.config.clone()).unwrap_or_default();
|
||||
config::add_client_options(provider, &mut config);
|
||||
// Approval bypass belongs to the conversation, never to another new task.
|
||||
if let Some(o) = config.iter_mut().find(|o| o.id == config::CURSOR_APPROVAL) {
|
||||
o.current = Some("ask".into());
|
||||
}
|
||||
config
|
||||
self.known.lock().get(provider).map(|k| k.config.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The commands the agent offered last time in this project.
|
||||
@@ -272,7 +264,6 @@ impl AgentManager {
|
||||
if matches!(f.task.status, AgentStatus::Running | AgentStatus::NeedsInput | AgentStatus::Starting) {
|
||||
f.task.status = AgentStatus::Ready;
|
||||
}
|
||||
f.task.retry = None;
|
||||
let task = Task::restore(f, shared.clone());
|
||||
task.state.lock().conversation.settle(now_ms());
|
||||
tasks.insert(task.id.clone(), task);
|
||||
@@ -398,7 +389,6 @@ impl AgentManager {
|
||||
queue: Vec::new(),
|
||||
capabilities: self.shared.probes.lock().get(&spec.id).map(|p| p.capabilities).unwrap_or_default(),
|
||||
resets_ms: None,
|
||||
retry: None,
|
||||
};
|
||||
let file = TaskFile {
|
||||
task: task.clone(),
|
||||
@@ -522,7 +512,7 @@ impl AgentManager {
|
||||
o.clone()
|
||||
};
|
||||
let live = task.live.lock().clone().filter(|l| !l.peer.is_closed());
|
||||
if let Some(live) = live.filter(|_| option != config::CURSOR_APPROVAL) {
|
||||
if let Some(live) = live {
|
||||
let (method, mut params) = config::set_request(&target, &value);
|
||||
params["sessionId"] = json!(live.session);
|
||||
let reply: Value =
|
||||
@@ -540,7 +530,6 @@ impl AgentManager {
|
||||
}
|
||||
s.task.model = model_of(&s.task.config).or(s.task.model.take());
|
||||
}
|
||||
task.approve_pending();
|
||||
task.persist();
|
||||
task.emit_task();
|
||||
Ok(())
|
||||
@@ -588,7 +577,6 @@ impl AgentManager {
|
||||
usage: None,
|
||||
queue: Vec::new(),
|
||||
resets_ms: None,
|
||||
retry: None,
|
||||
..s.task.clone()
|
||||
};
|
||||
TaskFile {
|
||||
@@ -796,8 +784,7 @@ impl AgentManager {
|
||||
|
||||
impl Task {
|
||||
fn restore(file: TaskFile, shared: Arc<Shared>) -> Arc<Self> {
|
||||
let mut task = file.task;
|
||||
config::add_client_options(&task.provider, &mut task.config);
|
||||
let task = file.task;
|
||||
let conversation = Conversation::restore(file.entries, file.subagents);
|
||||
Arc::new(Self {
|
||||
id: task.id.clone(),
|
||||
@@ -843,22 +830,9 @@ impl Task {
|
||||
self.shared.emit(AgentEvent::Task { task });
|
||||
}
|
||||
|
||||
/// Shows (or clears) the agent's pending retry; emitted only when it changes.
|
||||
fn set_retry(&self, retry: Option<AgentRetry>) {
|
||||
{
|
||||
let mut s = self.state.lock();
|
||||
if s.task.retry == retry {
|
||||
return;
|
||||
}
|
||||
s.task.retry = retry;
|
||||
}
|
||||
self.emit_task();
|
||||
}
|
||||
|
||||
fn set_status(&self, status: AgentStatus, error: Option<String>) {
|
||||
{
|
||||
let mut s = self.state.lock();
|
||||
s.task.retry = None;
|
||||
s.task.status = status;
|
||||
s.task.error = error;
|
||||
s.task.updated_ms = now_ms();
|
||||
@@ -1182,17 +1156,10 @@ impl Task {
|
||||
let s = self.state.lock();
|
||||
(if known { reported } else { s.task.config.clone() }, s.wanted.clone())
|
||||
};
|
||||
config::add_client_options(&provider_id, &mut options);
|
||||
self.shared.remember(&provider_id, Some(&options), None);
|
||||
for choice in &wanted {
|
||||
// An option the session doesn't offer yet is set once it does (`apply_late_choices`).
|
||||
let Some(o) = options.iter().find(|o| o.id == choice.id).cloned() else { continue };
|
||||
if o.id == config::CURSOR_APPROVAL {
|
||||
if o.values.iter().any(|v| v.value == choice.value) {
|
||||
options.iter_mut().find(|o| o.id == choice.id).unwrap().current = Some(choice.value.clone());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let already = known && o.current.as_deref() == Some(choice.value.as_str());
|
||||
if already || !o.values.iter().any(|v| v.value == choice.value) {
|
||||
continue;
|
||||
@@ -1236,7 +1203,7 @@ impl Task {
|
||||
s.wanted
|
||||
.iter()
|
||||
.filter_map(|c| {
|
||||
let o = s.task.config.iter().find(|o| o.id == c.id && o.id != config::CURSOR_APPROVAL)?;
|
||||
let o = s.task.config.iter().find(|o| o.id == c.id)?;
|
||||
let due = o.current.as_deref() != Some(c.value.as_str())
|
||||
&& o.values.iter().any(|v| v.value == c.value)
|
||||
&& sent.insert((c.id.clone(), c.value.clone()));
|
||||
@@ -1297,12 +1264,6 @@ impl Task {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if kind == RETRY_UPDATE {
|
||||
if !self.replaying.load(Ordering::Acquire) {
|
||||
self.set_retry(retry_of(update, now_ms()));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if kind == "session_info_update" {
|
||||
// The agent's own title for the session replaces the first line of the prompt.
|
||||
let title = update.get("title").and_then(Value::as_str).map(str::trim).filter(|t| !t.is_empty());
|
||||
@@ -1371,8 +1332,6 @@ impl Task {
|
||||
}
|
||||
|
||||
fn apply(self: &Arc<Self>, session: Option<&str>, kind: &str, update: &Value) {
|
||||
// Output again: the retried request went through.
|
||||
self.set_retry(None);
|
||||
let applied = self.state.lock().conversation.apply(session, update, now_ms());
|
||||
self.mark_dirty(applied);
|
||||
// A subagent can start or finish after the turn that started it: saved right away.
|
||||
@@ -1415,7 +1374,6 @@ impl Task {
|
||||
let entry = self.push_entry(AgentEntry::Permission { id, title, options: options.clone(), chosen: None });
|
||||
self.permissions.lock().insert(id, tx);
|
||||
self.set_status(AgentStatus::NeedsInput, None);
|
||||
self.approve_pending();
|
||||
|
||||
let answer = rx.recv().ok().flatten();
|
||||
let name = answer
|
||||
@@ -1439,22 +1397,6 @@ impl Task {
|
||||
}
|
||||
}
|
||||
|
||||
fn approve_pending(&self) {
|
||||
let s = self.state.lock();
|
||||
if self.cancel_pending.load(Ordering::Acquire) || !config::bypass_approvals(&s.task.provider, &s.task.config) {
|
||||
return;
|
||||
}
|
||||
let mut pending = self.permissions.lock();
|
||||
for entry in s.conversation.main.entries.iter().rev() {
|
||||
if let AgentEntry::Permission { id, options, chosen: None, .. } = entry
|
||||
&& let Some(option) = options.iter().find(|o| o.kind == "allow_once")
|
||||
&& let Some(reply) = pending.remove(id)
|
||||
{
|
||||
let _ = reply.send(Some(option.id.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn cancel_permissions(&self) {
|
||||
for (_, reply) in self.permissions.lock().drain() {
|
||||
let _ = reply.send(None);
|
||||
@@ -1614,17 +1556,6 @@ fn is_zero(n: &u32) -> bool {
|
||||
*n == 0
|
||||
}
|
||||
|
||||
/// The retry a [`RETRY_UPDATE`] announces, timed from `now`.
|
||||
fn retry_of(update: &Value, now: f64) -> Option<AgentRetry> {
|
||||
let n = |k: &str| update.get(k).and_then(Value::as_u64);
|
||||
Some(AgentRetry {
|
||||
attempt: u32::try_from(n("attempt")?).unwrap_or(u32::MAX),
|
||||
max_retries: n("maxRetries").and_then(|m| u32::try_from(m).ok()),
|
||||
retry_at_ms: now + n("retryDelayMs").unwrap_or(0) as f64,
|
||||
error: update.get("error").and_then(Value::as_str).filter(|e| !e.is_empty()).map(str::to_owned),
|
||||
})
|
||||
}
|
||||
|
||||
fn model_of(config: &[AgentConfigOption]) -> Option<String> {
|
||||
config.iter().find(|o| o.category.as_deref() == Some("model")).and_then(|o| o.current.clone())
|
||||
}
|
||||
|
||||
@@ -81,21 +81,6 @@ pub struct AgentTask {
|
||||
/// When a [`AgentStatus::Limited`] task's limit resets (ms since the epoch), if the agent said.
|
||||
#[serde(default)]
|
||||
pub resets_ms: Option<f64>,
|
||||
/// The agent is retrying a failed API request (connection lost, overloaded…); cleared
|
||||
/// by its next output or any status change.
|
||||
#[serde(default)]
|
||||
pub retry: Option<AgentRetry>,
|
||||
}
|
||||
|
||||
/// An API request the agent is about to retry.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentRetry {
|
||||
pub attempt: u32,
|
||||
pub max_retries: Option<u32>,
|
||||
/// When the next attempt starts (ms since the epoch).
|
||||
pub retry_at_ms: f64,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// One value of a select-type session setting.
|
||||
|
||||
@@ -917,20 +917,6 @@ fn pending_starts_round_trip() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_retries_are_announced() {
|
||||
let mut t = Translator::new("s", PathBuf::from("/w"), true);
|
||||
let out = t.on_message(&json!({"type": "system", "subtype": "api_retry", "attempt": 3, "max_retries": 10,
|
||||
"retry_delay_ms": 4000, "error_status": null, "error": "Connection error."}));
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].session, "s");
|
||||
assert_eq!(
|
||||
out[0].update,
|
||||
json!({"sessionUpdate": crate::RETRY_UPDATE, "attempt": 3, "maxRetries": 10, "retryDelayMs": 4000,
|
||||
"error": "Connection error."})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streamed_text_is_not_sent_twice() {
|
||||
let mut t = Translator::new("s", PathBuf::from("/w"), true);
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
//! Agent tool use that launched them: their messages carry it as `parent_tool_use_id`.
|
||||
|
||||
use super::tools;
|
||||
use crate::RETRY_UPDATE;
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::path::PathBuf;
|
||||
@@ -428,23 +427,6 @@ impl Translator {
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
// Claude Code retries a failed API request on its own; without this the turn looks stuck.
|
||||
Some("api_retry") => {
|
||||
let n = |k: &str| msg.get(k).and_then(Value::as_u64);
|
||||
let error = msg.get("error").and_then(|e| {
|
||||
e.as_str().map(str::to_owned).or_else(|| e.get("message")?.as_str().map(str::to_owned))
|
||||
});
|
||||
vec![Update {
|
||||
session: self.root.clone(),
|
||||
update: json!({
|
||||
"sessionUpdate": RETRY_UPDATE,
|
||||
"attempt": n("attempt").unwrap_or(1),
|
||||
"maxRetries": n("max_retries"),
|
||||
"retryDelayMs": n("retry_delay_ms").unwrap_or(0),
|
||||
"error": error,
|
||||
}),
|
||||
}]
|
||||
}
|
||||
Some("task_notification") if self.children.get(&tool_use).is_some_and(|c| c.background != Some(false)) => {
|
||||
let stop = match msg.get("status").and_then(Value::as_str) {
|
||||
Some("failed") => "error",
|
||||
|
||||
@@ -199,12 +199,7 @@ fn invalid(message: impl Into<String>) -> ResponseError {
|
||||
fn from_codex(e: RpcError) -> ResponseError {
|
||||
match e {
|
||||
RpcError::Server { code, message, data } => {
|
||||
// Protocol errors can list method names containing "auth"; they are not sign-in failures.
|
||||
let protocol = matches!(
|
||||
code,
|
||||
codes::PARSE_ERROR | codes::INVALID_REQUEST | codes::METHOD_NOT_FOUND | codes::INVALID_PARAMS
|
||||
);
|
||||
let code = if !protocol && message.to_lowercase().contains("auth") { AUTH_REQUIRED } else { code };
|
||||
let code = if message.to_lowercase().contains("auth") { AUTH_REQUIRED } else { code };
|
||||
ResponseError { code, message, data }
|
||||
}
|
||||
RpcError::Closed => error(codes::INTERNAL_ERROR, "Codex exited"),
|
||||
@@ -431,48 +426,10 @@ impl CodexShim {
|
||||
async fn rollback(&self, params: &Value) -> Result<Value, ResponseError> {
|
||||
let session = str_param(params, "sessionId")?;
|
||||
let turns = params.get("turns").and_then(Value::as_u64).filter(|n| *n > 0).ok_or_else(|| invalid("turns"))?;
|
||||
match self.call("thread/rollback", json!({ "threadId": session, "numTurns": turns })).await {
|
||||
Ok(_) => {}
|
||||
Err(e)
|
||||
if e.code == codes::METHOD_NOT_FOUND
|
||||
|| (e.code == codes::INVALID_REQUEST
|
||||
&& e.message.contains("unknown variant `thread/rollback`")) =>
|
||||
{
|
||||
self.revert(session, turns).await?;
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
self.call("thread/rollback", json!({ "threadId": session, "numTurns": turns })).await?;
|
||||
Ok(json!({}))
|
||||
}
|
||||
|
||||
/// New Codex versions replace rollback counts with an exclusive turn id.
|
||||
async fn revert(&self, session: &str, mut remaining: u64) -> Result<(), ResponseError> {
|
||||
let mut cursor = Value::Null;
|
||||
let mut seen = HashSet::new();
|
||||
loop {
|
||||
let page = self
|
||||
.call(
|
||||
"thread/turns/list",
|
||||
json!({ "threadId": session, "sortDirection": "desc", "limit": remaining.min(100), "cursor": cursor }),
|
||||
)
|
||||
.await?;
|
||||
let turns = page.get("data").and_then(Value::as_array).ok_or_else(|| invalid("Codex returned no turns"))?;
|
||||
if remaining <= turns.len() as u64 {
|
||||
let before = str_param(&turns[remaining as usize - 1], "id")?;
|
||||
self.call("thread/revert", json!({ "threadId": session, "beforeTurnId": before })).await?;
|
||||
return Ok(());
|
||||
}
|
||||
remaining -= turns.len() as u64;
|
||||
let next = page.get("nextCursor").and_then(Value::as_str);
|
||||
let Some(next) = next.filter(|next| seen.insert((*next).to_owned())) else {
|
||||
return Err(invalid(
|
||||
"Codex history has fewer turns than the conversation; reload the task before editing",
|
||||
));
|
||||
};
|
||||
cursor = json!(next);
|
||||
}
|
||||
}
|
||||
|
||||
/// Stops following the session's thread (codex unloads it once nothing follows it).
|
||||
async fn close(&self, params: &Value) -> Result<Value, ResponseError> {
|
||||
let session = str_param(params, "sessionId")?;
|
||||
|
||||
@@ -593,104 +593,6 @@ async fn resume_replays_history_then_forks_and_rolls_back() {
|
||||
assert_eq!(sent(&frames, "thread/unsubscribe")[0]["params"], json!({"threadId": thread}));
|
||||
}
|
||||
|
||||
async fn rollback_replies(turns: u32, replies: Vec<(&str, Value)>) -> (Result<Value, RpcError>, Vec<Value>) {
|
||||
let frames = replies
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.flat_map(|(i, (method, mut reply))| {
|
||||
let id = i + 1;
|
||||
reply["id"] = json!(id);
|
||||
[(true, json!({ "id": id, "method": method })), (false, reply)]
|
||||
})
|
||||
.collect();
|
||||
let transcript = Transcript { meta: Value::Null, frames };
|
||||
let (ours, theirs) = tokio::io::duplex(1 << 16);
|
||||
let acp = attach(Arc::new(Client::default()), |events| {
|
||||
let (read, write) = tokio::io::split(ours);
|
||||
Ok(Peer::connect_framed(read, write, events, Framing::Lines))
|
||||
})
|
||||
.unwrap();
|
||||
let (read, write) = tokio::io::split(theirs);
|
||||
let player = tokio::spawn(async move { play(&transcript, read, write).await });
|
||||
let result = request(&acp, "_openidea/rollback", json!({ "sessionId": "s", "turns": turns })).await;
|
||||
let (sent, _read, _write) = player.await.unwrap();
|
||||
acp.kill();
|
||||
(result, sent)
|
||||
}
|
||||
|
||||
fn removed_rollback() -> Value {
|
||||
json!({ "error": { "code": -32600, "message": "Invalid request: unknown variant `thread/rollback`, expected one of `thread/revert`, `account/gatewayOAuth/read`, `getAuthStatus`" } })
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn removed_rollback_reverts_before_the_target_turn_across_pages() {
|
||||
let (result, frames) = rollback_replies(
|
||||
3,
|
||||
vec![
|
||||
("thread/rollback", removed_rollback()),
|
||||
(
|
||||
"thread/turns/list",
|
||||
json!({ "result": { "data": [{ "id": "last" }, { "id": "middle" }], "nextCursor": "older" } }),
|
||||
),
|
||||
(
|
||||
"thread/turns/list",
|
||||
json!({ "result": { "data": [{ "id": "edited" }, { "id": "retained" }], "nextCursor": null } }),
|
||||
),
|
||||
("thread/revert", json!({ "result": { "thread": { "id": "s", "turns": [] } } })),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(result.unwrap(), json!({}));
|
||||
let pages = sent(&frames, "thread/turns/list");
|
||||
assert_eq!(pages[0]["params"], json!({ "threadId": "s", "sortDirection": "desc", "limit": 3, "cursor": null }));
|
||||
assert_eq!(pages[1]["params"]["cursor"], "older");
|
||||
assert_eq!(pages[1]["params"]["limit"], 1);
|
||||
assert_eq!(sent(&frames, "thread/revert")[0]["params"], json!({ "threadId": "s", "beforeTurnId": "edited" }));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn method_not_found_reverts_the_latest_turn() {
|
||||
let (result, frames) = rollback_replies(
|
||||
1,
|
||||
vec![
|
||||
("thread/rollback", json!({ "error": { "code": -32601, "message": "Method not found" } })),
|
||||
("thread/turns/list", json!({ "result": { "data": [{ "id": "last" }], "nextCursor": null } })),
|
||||
("thread/revert", json!({ "result": {} })),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(result.unwrap(), json!({}));
|
||||
assert_eq!(sent(&frames, "thread/revert")[0]["params"]["beforeTurnId"], "last");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn rollback_refusals_are_not_retried_as_revert() {
|
||||
let (result, frames) = rollback_replies(
|
||||
1,
|
||||
vec![("thread/rollback", json!({ "error": { "code": -32600, "message": "a turn is still running" } }))],
|
||||
)
|
||||
.await;
|
||||
assert!(result.unwrap_err().to_string().contains("a turn is still running"));
|
||||
assert_eq!(frames.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn incomplete_or_repeated_history_pages_do_not_revert_the_wrong_turn() {
|
||||
for next in [Value::Null, json!("older")] {
|
||||
let (result, frames) = rollback_replies(
|
||||
3,
|
||||
vec![
|
||||
("thread/rollback", removed_rollback()),
|
||||
("thread/turns/list", json!({ "result": { "data": [{ "id": "last" }], "nextCursor": "older" } })),
|
||||
("thread/turns/list", json!({ "result": { "data": [], "nextCursor": next } })),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
assert!(result.unwrap_err().to_string().contains("reload the task"));
|
||||
assert!(sent(&frames, "thread/revert").is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn steering_adds_to_the_running_turn() {
|
||||
let (results, _client, frames, _) = replay(Scenario::Steer).await;
|
||||
|
||||
@@ -709,75 +709,3 @@ async fn usage_limits_and_late_settings() {
|
||||
let config = mgr.task(&task.id).unwrap().state.lock().task.config.clone();
|
||||
assert_eq!(config.iter().find(|o| o.id == "effort").unwrap().current.as_deref(), Some("high"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_updates_become_a_countdown() {
|
||||
let update =
|
||||
json!({"sessionUpdate": RETRY_UPDATE, "attempt": 2, "maxRetries": 10, "retryDelayMs": 1500, "error": ""});
|
||||
assert_eq!(
|
||||
retry_of(&update, 1000.0),
|
||||
Some(AgentRetry { attempt: 2, max_retries: Some(10), retry_at_ms: 2500.0, error: None })
|
||||
);
|
||||
assert_eq!(retry_of(&json!({"sessionUpdate": RETRY_UPDATE}), 0.0), None, "no attempt");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn cursor_approval_switch_resolves_pending_and_survives_restart() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let agent = Arc::new(FakeAgent::default());
|
||||
let mgr = manager(tmp.path(), agent.clone());
|
||||
let new = || NewAgentTask {
|
||||
prompt: text("Fix"),
|
||||
provider: "cursor".into(),
|
||||
config: Vec::new(),
|
||||
isolation: Isolation::Local,
|
||||
base: None,
|
||||
};
|
||||
let task = mgr.create(new()).await.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
|
||||
mgr.set_config(&task.id, config::CURSOR_APPROVAL, "bypass".into()).await.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
assert!(
|
||||
mgr.entries(&task.id, None)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|e| matches!(e, AgentEntry::Permission { chosen: Some(name), .. } if name == "Allow"))
|
||||
);
|
||||
// Agent config updates must not erase the client's approval setting.
|
||||
mgr.prompt(&task.id, text("late option"), false).await.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
let t = mgr.task(&task.id).unwrap();
|
||||
assert!(config::bypass_approvals("cursor", &t.state.lock().task.config));
|
||||
t.kill();
|
||||
mgr.prompt(&task.id, text("Fix again"), false).await.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
assert!(!agent.calls.lock().iter().any(|c| c.contains("bypass")));
|
||||
assert!(!config::bypass_approvals("cursor", &mgr.shared.known_config("cursor")));
|
||||
mgr.set_config(&task.id, config::CURSOR_APPROVAL, "ask".into()).await.unwrap();
|
||||
mgr.prompt(&task.id, text("Fix once more"), false).await.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
|
||||
mgr.cancel(&task.id, None).unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
// An unrelated new conversation still asks, even after the cache was updated.
|
||||
let other = mgr.create(new()).await.unwrap();
|
||||
wait_for(&mgr, &other.id, AgentStatus::NeedsInput).await;
|
||||
mgr.shutdown();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn cursor_approval_choice_applies_before_first_prompt() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mgr = manager(tmp.path(), Arc::new(FakeAgent::default()));
|
||||
let task = mgr
|
||||
.create(NewAgentTask {
|
||||
prompt: text("Fix"),
|
||||
provider: "cursor".into(),
|
||||
config: vec![AgentConfigChoice { id: config::CURSOR_APPROVAL.into(), value: "bypass".into() }],
|
||||
isolation: Isolation::Local,
|
||||
base: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
mgr.shutdown();
|
||||
}
|
||||
@@ -45,12 +45,8 @@ pub(crate) fn run(
|
||||
mut on_line: impl FnMut(Stream, &str),
|
||||
) -> IdeResult<Output> {
|
||||
tracing::info!("build: {program} {} (in {})", args.join(" "), cwd.display());
|
||||
// Output is read as UTF-8: JVMs on Windows are told to write it (see `ide_core::output_encoding`).
|
||||
let encoding =
|
||||
ide_core::output_encoding::fix(program, args, env, |k| std::env::var(k).ok(), "UTF-8", cfg!(windows));
|
||||
let mut command = Command::new(program);
|
||||
command.args(args).current_dir(cwd).envs(env).envs(encoding.env).stdin(Stdio::null());
|
||||
command.stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
command.args(args).current_dir(cwd).envs(env).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::process::CommandExt;
|
||||
@@ -90,7 +86,6 @@ pub(crate) fn run(
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
match rx.recv_timeout(Duration::from_millis(100)) {
|
||||
Ok((_, line)) if encoding.notice.as_ref() == Some(&line) => {}
|
||||
Ok((stream, line)) => {
|
||||
on_line(stream, &line);
|
||||
text.push_str(&line);
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
[package]
|
||||
name = "ide-clipboard"
|
||||
description = "The OS clipboard's file list (Explorer <-> project view): Windows CF_HDROP or macOS NSPasteboard file URLs, no window or message loop"
|
||||
description = "The OS clipboard's file list (Explorer <-> project view): Windows CF_HDROP read/write, no window or message loop"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
objc2 = { version = "0.6", default-features = false, features = ["std"] }
|
||||
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSDictionary", "NSEnumerator", "NSObject", "NSString", "NSURL", "NSValue"] }
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSPasteboard"] }
|
||||
@@ -7,12 +7,9 @@
|
||||
//! in full rather than rendered on demand, so no window or message loop is
|
||||
//! needed and the list survives after `CloseClipboard`.
|
||||
//!
|
||||
//! macOS keeps one `public.file-url` item per file on the general
|
||||
//! `NSPasteboard`; `NSURL` reads and writes them, so Finder's copies paste here
|
||||
//! and ours paste in Finder.
|
||||
//!
|
||||
//! Other platforms (`text/uri-list` on X11) are not wired up: [`files`] is
|
||||
//! empty and [`set_files`] fails there.
|
||||
//! Other platforms keep file lists elsewhere (`public.file-url` on macOS,
|
||||
//! `text/uri-list` on X11) and are not wired up: [`files`] is empty and
|
||||
//! [`set_files`] fails there.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -221,78 +218,7 @@ mod imp {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod imp {
|
||||
use objc2::ClassType;
|
||||
use objc2::rc::{Retained, autoreleasepool};
|
||||
use objc2::runtime::{AnyClass, AnyObject, ProtocolObject};
|
||||
use objc2_app_kit::{
|
||||
NSPasteboard, NSPasteboardReadingOptionKey, NSPasteboardURLReadingFileURLsOnlyKey, NSPasteboardWriting,
|
||||
};
|
||||
use objc2_foundation::{NSArray, NSDictionary, NSNumber, NSURL};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// `NSURL` as the one class to read, limited to file URLs: a web link the
|
||||
/// user copied is not a file to paste.
|
||||
fn read_file_urls<R>(
|
||||
f: impl FnOnce(&NSPasteboard, &NSArray<AnyClass>, &NSDictionary<NSPasteboardReadingOptionKey, AnyObject>) -> R,
|
||||
) -> R {
|
||||
autoreleasepool(|_| {
|
||||
let board = NSPasteboard::generalPasteboard();
|
||||
let classes = NSArray::from_slice(&[NSURL::class()]);
|
||||
let yes = NSNumber::new_bool(true);
|
||||
// SAFETY: the key is an AppKit constant, valid for the process' lifetime.
|
||||
let key = unsafe { NSPasteboardURLReadingFileURLsOnlyKey };
|
||||
let options = NSDictionary::from_slices(&[key], &[yes.as_ref() as &AnyObject]);
|
||||
f(&board, &classes, &options)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn has_files() -> bool {
|
||||
// SAFETY: the class list holds `NSURL`, which reads from pasteboards,
|
||||
// and the option takes an `NSNumber`.
|
||||
read_file_urls(|board, classes, options| unsafe {
|
||||
board.canReadObjectForClasses_options(classes, Some(options))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn files() -> Vec<PathBuf> {
|
||||
read_file_urls(|board, classes, options| {
|
||||
// SAFETY: as in `has_files`; every object read for `NSURL` is one.
|
||||
let Some(objects) = (unsafe { board.readObjectsForClasses_options(classes, Some(options)) }) else {
|
||||
return Vec::new();
|
||||
};
|
||||
objects
|
||||
.iter()
|
||||
.filter_map(|object| object.downcast::<NSURL>().ok())
|
||||
.filter_map(|url| url.to_file_path())
|
||||
// Finder ends a folder's URL in `/`; the project view names it without.
|
||||
.map(|path| path.components().collect())
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_files(paths: &[PathBuf]) -> bool {
|
||||
autoreleasepool(|_| {
|
||||
// Folders get a directory URL (ending in `/`), as Finder writes them.
|
||||
let urls: Vec<Retained<NSURL>> = paths
|
||||
.iter()
|
||||
.filter_map(|p| if p.is_dir() { NSURL::from_directory_path(p) } else { NSURL::from_file_path(p) })
|
||||
.collect();
|
||||
if urls.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let writers: Vec<&ProtocolObject<dyn NSPasteboardWriting>> =
|
||||
urls.iter().map(|url| ProtocolObject::from_ref(&**url)).collect();
|
||||
let board = NSPasteboard::generalPasteboard();
|
||||
// Replacing the list also drops the text the user copied before.
|
||||
board.clearContents();
|
||||
board.writeObjects(&NSArray::from_slice(&writers))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(any(windows, target_os = "macos")))]
|
||||
#[cfg(not(windows))]
|
||||
mod imp {
|
||||
use std::path::PathBuf;
|
||||
|
||||
|
||||
@@ -13,6 +13,3 @@ specta.workspace = true
|
||||
specta-typescript.workspace = true
|
||||
thiserror.workspace = true
|
||||
tokio-util.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
@@ -12,7 +12,6 @@ pub mod ignored;
|
||||
pub mod inspection_settings;
|
||||
pub mod ipc_path;
|
||||
pub mod jsonc;
|
||||
pub mod output_encoding;
|
||||
pub mod process;
|
||||
pub mod project_dir;
|
||||
pub mod proxy_settings;
|
||||
@@ -20,7 +19,6 @@ pub mod remote_settings;
|
||||
pub mod settings;
|
||||
pub mod task;
|
||||
pub mod text;
|
||||
pub mod version_managers;
|
||||
pub mod workflow_settings;
|
||||
|
||||
pub use error::{IdeError, IdeResult};
|
||||
|
||||
@@ -1,412 +0,0 @@
|
||||
//! Makes the output of JVM and Python processes match the encoding the IDE decodes.
|
||||
//!
|
||||
//! On Windows both write piped output in the ANSI code page (GBK on a Chinese
|
||||
//! system: `System.out.println`, `javac` errors, Maven / Gradle logs, `print`), while
|
||||
//! the console decodes the configured encoding (UTF-8 by default). They are told
|
||||
//! which one to use:
|
||||
//! - `java`: `stdout.encoding` / `stderr.encoding` (JDK 19+) and their `sun.*`
|
||||
//! forerunners (JDK 8–18) on the command line, plus `file.encoding=UTF-8` for a
|
||||
//! UTF-8 console, the default charset of JDK 18+, which logging frameworks' console
|
||||
//! appenders write in on older JDKs too.
|
||||
//! - Build tools and launcher scripts (Maven, Gradle, sbt, …): the same `std*.encoding`
|
||||
//! properties in `JAVA_TOOL_OPTIONS`, the one variable every JVM reads, so the JVMs
|
||||
//! they fork get them too (`spring-boot:run`, Surefire, `bootRun`, test workers).
|
||||
//! Each JVM then reports "Picked up JAVA_TOOL_OPTIONS: …" on stderr (which a build tool
|
||||
//! may copy to its stdout); that line is hidden again ([`NoticeFilter`]). `file.encoding` is left alone there: it would change
|
||||
//! how a build reads sources that declare no encoding.
|
||||
//! - Python: `PYTHONIOENCODING`.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
const STD_PROPERTIES: &[&str] = &["stdout.encoding", "stderr.encoding", "sun.stdout.encoding", "sun.stderr.encoding"];
|
||||
const FILE_ENCODING: &str = "file.encoding";
|
||||
const TOOL_OPTIONS: &str = "JAVA_TOOL_OPTIONS";
|
||||
|
||||
/// Build tools and launcher scripts that run on (and may fork) JVMs.
|
||||
const JVM_TOOLS: &[&str] =
|
||||
&["mvn", "mvnw", "mvnd", "gradle", "gradlew", "sbt", "ant", "kotlin", "kotlinc", "groovy", "scala", "jbang"];
|
||||
|
||||
/// What a launch needs: `-D` options to put first on a `java` command line,
|
||||
/// environment variables to set, and the notice line its JVMs print because of them.
|
||||
#[derive(Debug, Default, PartialEq, Eq)]
|
||||
pub struct EncodingFix {
|
||||
pub java_args: Vec<String>,
|
||||
pub env: Vec<(String, String)>,
|
||||
/// "Picked up JAVA_TOOL_OPTIONS: …" for a variable set here alone, to drop from the output.
|
||||
pub notice: Option<String>,
|
||||
}
|
||||
|
||||
pub fn is_utf8(encoding: &str) -> bool {
|
||||
matches!(encoding.trim().to_ascii_lowercase().replace('_', "-").as_str(), "utf-8" | "utf8")
|
||||
}
|
||||
|
||||
/// File name of `command` without a Windows executable extension: `C:\jdk\bin\java.exe` → `java`.
|
||||
fn program_name(command: &str) -> String {
|
||||
let name = command.rsplit(['/', '\\']).next().unwrap_or(command).to_ascii_lowercase();
|
||||
for ext in [".exe", ".cmd", ".bat"] {
|
||||
if let Some(stem) = name.strip_suffix(ext) {
|
||||
return stem.to_owned();
|
||||
}
|
||||
}
|
||||
name
|
||||
}
|
||||
|
||||
fn sets(text: &str, properties: &[&str]) -> bool {
|
||||
properties.iter().any(|p| text.contains(&format!("-D{p}=")))
|
||||
}
|
||||
|
||||
fn options(properties: &[&str], encoding: &str) -> Vec<String> {
|
||||
properties.iter().map(|p| format!("-D{p}={encoding}")).collect()
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Kind {
|
||||
Java,
|
||||
JvmTool,
|
||||
Python,
|
||||
Other,
|
||||
}
|
||||
|
||||
fn kind(program: &str) -> Kind {
|
||||
let name = program_name(program);
|
||||
if name == "java" || name == "javaw" {
|
||||
Kind::Java
|
||||
} else if JVM_TOOLS.contains(&name.as_str()) {
|
||||
Kind::JvmTool
|
||||
} else if is_python(&name) {
|
||||
Kind::Python
|
||||
} else {
|
||||
Kind::Other
|
||||
}
|
||||
}
|
||||
|
||||
/// The options for a launch of `program` (`args`, environment `vars` over the
|
||||
/// `inherited` one) whose output is decoded as `encoding`; nothing when the
|
||||
/// process already writes it (UTF-8 off Windows) or the user chose the options.
|
||||
pub fn fix(
|
||||
program: &str,
|
||||
args: &[String],
|
||||
vars: &BTreeMap<String, String>,
|
||||
inherited: impl Fn(&str) -> Option<String>,
|
||||
encoding: &str,
|
||||
windows: bool,
|
||||
) -> EncodingFix {
|
||||
fix_kind(kind(program), Some(args), vars, inherited, encoding, windows)
|
||||
}
|
||||
|
||||
/// [`fix`] for a command line run by the shell (`cmd /C "mvn spring-boot:run"`), whose
|
||||
/// arguments cannot be changed: its first word decides, and only variables are set.
|
||||
pub fn fix_command_line(
|
||||
line: &str,
|
||||
vars: &BTreeMap<String, String>,
|
||||
inherited: impl Fn(&str) -> Option<String>,
|
||||
encoding: &str,
|
||||
windows: bool,
|
||||
) -> EncodingFix {
|
||||
let line = line.trim_start();
|
||||
let first = match line.chars().next() {
|
||||
Some(q @ ('"' | '\'')) => line[1..].split(q).next(),
|
||||
_ => line.split(char::is_whitespace).next(),
|
||||
}
|
||||
.unwrap_or_default();
|
||||
fix_kind(kind(first), None, vars, inherited, encoding, windows)
|
||||
}
|
||||
|
||||
fn fix_kind(
|
||||
kind: Kind,
|
||||
args: Option<&[String]>,
|
||||
vars: &BTreeMap<String, String>,
|
||||
inherited: impl Fn(&str) -> Option<String>,
|
||||
encoding: &str,
|
||||
windows: bool,
|
||||
) -> EncodingFix {
|
||||
let encoding = encoding.trim();
|
||||
let mut out = EncodingFix::default();
|
||||
if encoding.is_empty() || (!windows && is_utf8(encoding)) {
|
||||
return out;
|
||||
}
|
||||
let current = |key: &str| vars.get(key).cloned().or_else(|| inherited(key)).unwrap_or_default();
|
||||
let tool_options = current(TOOL_OPTIONS);
|
||||
match (kind, args) {
|
||||
(Kind::Java, Some(args)) => {
|
||||
let java_options = format!("{} {tool_options}", current("JDK_JAVA_OPTIONS"));
|
||||
let chosen =
|
||||
|properties: &[&str]| args.iter().any(|a| sets(a, properties)) || sets(&java_options, properties);
|
||||
if !chosen(STD_PROPERTIES) {
|
||||
out.java_args = options(STD_PROPERTIES, encoding);
|
||||
}
|
||||
if is_utf8(encoding) && !chosen(&[FILE_ENCODING]) {
|
||||
out.java_args.push(format!("-D{FILE_ENCODING}=UTF-8"));
|
||||
}
|
||||
}
|
||||
(Kind::Java | Kind::JvmTool, _) => {
|
||||
if !sets(&tool_options, STD_PROPERTIES) {
|
||||
let added = options(STD_PROPERTIES, encoding).join(" ");
|
||||
let value = match tool_options.trim() {
|
||||
"" => {
|
||||
out.notice = Some(format!("Picked up {TOOL_OPTIONS}: {added}"));
|
||||
added
|
||||
}
|
||||
own => format!("{own} {added}"),
|
||||
};
|
||||
out.env.push((TOOL_OPTIONS.to_owned(), value));
|
||||
}
|
||||
}
|
||||
(Kind::Python, _) if current("PYTHONIOENCODING").trim().is_empty() => {
|
||||
out.env.push(("PYTHONIOENCODING".to_owned(), encoding.to_owned()));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `vmArgs` of a launch by java-debug, which decodes the JVM's output as `encoding`
|
||||
/// (its launch attribute, UTF-8 when unset) and sets `file.encoding` itself.
|
||||
pub fn java_debug_vm_args(vm_args: &str, encoding: Option<&str>, windows: bool) -> Option<String> {
|
||||
let encoding = encoding.map(str::trim).filter(|e| !e.is_empty()).unwrap_or("UTF-8");
|
||||
if (!windows && is_utf8(encoding)) || sets(vm_args, STD_PROPERTIES) {
|
||||
return None;
|
||||
}
|
||||
let added = options(STD_PROPERTIES, encoding).join(" ");
|
||||
Some(match vm_args.trim() {
|
||||
"" => added,
|
||||
own => format!("{own} {added}"),
|
||||
})
|
||||
}
|
||||
|
||||
/// `python`, `python3`, `python3.12`, `py`, and the runners that start one.
|
||||
fn is_python(name: &str) -> bool {
|
||||
name == "python"
|
||||
|| name == "py"
|
||||
|| name.strip_prefix("python").is_some_and(|v| v.chars().all(|c| c.is_ascii_digit() || c == '.'))
|
||||
|| matches!(name, "uv" | "poetry" | "pdm" | "pytest" | "pipenv")
|
||||
}
|
||||
|
||||
/// Drops whole lines equal to `notice` from a stream of decoded text (the
|
||||
/// [`EncodingFix::notice`] each JVM prints), passing everything else through.
|
||||
/// The start of a line is held back only while it can still become the notice.
|
||||
#[derive(Debug)]
|
||||
pub struct NoticeFilter {
|
||||
notice: String,
|
||||
held: String,
|
||||
at_line_start: bool,
|
||||
}
|
||||
|
||||
impl NoticeFilter {
|
||||
pub fn new(notice: String) -> Self {
|
||||
Self { notice, held: String::new(), at_line_start: true }
|
||||
}
|
||||
|
||||
pub fn filter(&mut self, text: &str, out: &mut String) {
|
||||
for piece in text.split_inclusive('\n') {
|
||||
if !self.at_line_start && self.held.is_empty() {
|
||||
out.push_str(piece);
|
||||
self.at_line_start = piece.ends_with('\n');
|
||||
continue;
|
||||
}
|
||||
self.held.push_str(piece);
|
||||
if self.held.ends_with('\n') {
|
||||
if self.held.trim_end_matches(['\n', '\r']) != self.notice {
|
||||
out.push_str(&self.held);
|
||||
}
|
||||
self.held.clear();
|
||||
self.at_line_start = true;
|
||||
} else if !self.could_be_notice() {
|
||||
out.push_str(&self.held);
|
||||
self.held.clear();
|
||||
self.at_line_start = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The held text is the start of the notice, or all of it before a `\r\n`.
|
||||
fn could_be_notice(&self) -> bool {
|
||||
match self.held.strip_suffix('\r') {
|
||||
Some(line) => line == self.notice,
|
||||
None => self.notice.starts_with(self.held.as_str()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The held-back start of a line, at the end of the stream.
|
||||
pub fn finish(&mut self) -> String {
|
||||
std::mem::take(&mut self.held)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const STD_UTF8: [&str; 4] = [
|
||||
"-Dstdout.encoding=UTF-8",
|
||||
"-Dstderr.encoding=UTF-8",
|
||||
"-Dsun.stdout.encoding=UTF-8",
|
||||
"-Dsun.stderr.encoding=UTF-8",
|
||||
];
|
||||
const TOOL_UTF8: &str =
|
||||
"-Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8 -Dsun.stdout.encoding=UTF-8 -Dsun.stderr.encoding=UTF-8";
|
||||
|
||||
fn vars(vars: &[(&str, &str)]) -> BTreeMap<String, String> {
|
||||
vars.iter().map(|(k, v)| ((*k).to_owned(), (*v).to_owned())).collect()
|
||||
}
|
||||
|
||||
fn run(program: &str, args: &[&str], env: &[(&str, &str)], encoding: &str, windows: bool) -> EncodingFix {
|
||||
let args: Vec<String> = args.iter().map(|a| (*a).to_owned()).collect();
|
||||
fix(program, &args, &vars(env), |_| None, encoding, windows)
|
||||
}
|
||||
|
||||
fn tool_env(f: &EncodingFix) -> Option<&str> {
|
||||
f.env.iter().find(|(k, _)| k == TOOL_OPTIONS).map(|(_, v)| v.as_str())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn java_launches_write_the_console_encoding_on_windows() {
|
||||
let mut expected: Vec<&str> = STD_UTF8.to_vec();
|
||||
expected.push("-Dfile.encoding=UTF-8");
|
||||
// A source-file launch through the Oracle `javapath` shim, and an Application / Spring Boot launch.
|
||||
let java = "C:/Program Files/Common Files/Oracle/Java/javapath/java.exe";
|
||||
for program in [java, "java", "javaw.exe", r"C:\jdk\bin\java.exe", "JAVA.EXE"] {
|
||||
for args in [&["Main.java"][..], &["-cp", "a;b", "org.dromara.DromaraApplication"]] {
|
||||
let f = run(program, args, &[], "UTF-8", true);
|
||||
assert_eq!(f.java_args, expected, "{program}");
|
||||
assert!(f.env.is_empty() && f.notice.is_none());
|
||||
}
|
||||
}
|
||||
// Another console encoding: the streams follow it, the default charset stays the platform's.
|
||||
assert_eq!(
|
||||
run("java", &["-cp", "x", "a.Main"], &[], "GBK", true).java_args,
|
||||
[
|
||||
"-Dstdout.encoding=GBK",
|
||||
"-Dstderr.encoding=GBK",
|
||||
"-Dsun.stdout.encoding=GBK",
|
||||
"-Dsun.stderr.encoding=GBK"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_changes_where_output_already_matches() {
|
||||
for (program, args) in [("java", vec!["Main.java"]), ("mvn", vec!["package"]), ("python3", vec!["a.py"])] {
|
||||
assert_eq!(run(program, &args, &[], "UTF-8", false), EncodingFix::default(), "{program}");
|
||||
assert_eq!(run(program, &args, &[], "utf8", false), EncodingFix::default(), "{program}");
|
||||
assert_eq!(run(program, &args, &[], "", true), EncodingFix::default(), "{program}");
|
||||
}
|
||||
for program in ["node", "cargo", "go", "npm.cmd", "cmd.exe", "javac-wrapper"] {
|
||||
assert_eq!(run(program, &[], &[], "UTF-8", true), EncodingFix::default(), "{program}");
|
||||
}
|
||||
// A console set to another encoding elsewhere still gets it.
|
||||
assert_eq!(run("java", &["Main"], &[], "GBK", false).java_args.len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options_chosen_by_the_user_win() {
|
||||
let f = run("java", &["-Dstdout.encoding=GBK", "Main"], &[], "UTF-8", true);
|
||||
assert_eq!(f.java_args, ["-Dfile.encoding=UTF-8"]);
|
||||
let f = run("java", &["-Dfile.encoding=GBK", "Main"], &[], "UTF-8", true);
|
||||
assert_eq!(f.java_args, STD_UTF8);
|
||||
for key in ["JDK_JAVA_OPTIONS", TOOL_OPTIONS] {
|
||||
let f = run("java", &["Main"], &[(key, "-Dsun.stdout.encoding=GBK -Dfile.encoding=GBK")], "UTF-8", true);
|
||||
assert!(f.java_args.is_empty(), "{key}");
|
||||
}
|
||||
assert!(run("mvn.cmd", &[], &[(TOOL_OPTIONS, "-Dstdout.encoding=GBK")], "UTF-8", true).env.is_empty());
|
||||
assert!(run("python.exe", &[], &[("PYTHONIOENCODING", "gbk")], "UTF-8", true).env.is_empty());
|
||||
}
|
||||
|
||||
/// Maven's `spring-boot:run` and Surefire, Gradle's `bootRun` and test workers fork JVMs
|
||||
/// that never see `MAVEN_OPTS` / `GRADLE_OPTS`: the options go where every JVM reads them.
|
||||
#[test]
|
||||
fn build_tools_reach_the_jvms_they_fork() {
|
||||
for program in [
|
||||
"mvn.cmd",
|
||||
"mvnw.cmd",
|
||||
r"C:\maven\bin\mvn.cmd",
|
||||
"mvnd.cmd",
|
||||
"gradlew.bat",
|
||||
"gradle.bat",
|
||||
"sbt.bat",
|
||||
"kotlinc.bat",
|
||||
] {
|
||||
let f = run(program, &["spring-boot:run"], &[], "UTF-8", true);
|
||||
assert_eq!(tool_env(&f), Some(TOOL_UTF8), "{program}");
|
||||
assert_eq!(f.notice.as_deref(), Some(format!("Picked up JAVA_TOOL_OPTIONS: {TOOL_UTF8}").as_str()));
|
||||
assert!(f.java_args.is_empty());
|
||||
assert!(!tool_env(&f).unwrap().contains("file.encoding"), "a build's source encoding is left alone");
|
||||
}
|
||||
// Existing options are kept, from the configuration or the inherited environment; the
|
||||
// JVMs then print the user's own variable, which stays visible.
|
||||
let f = run("mvn.cmd", &[], &[(TOOL_OPTIONS, "-javaagent:a.jar")], "UTF-8", true);
|
||||
assert_eq!(tool_env(&f), Some(format!("-javaagent:a.jar {TOOL_UTF8}").as_str()));
|
||||
assert!(f.notice.is_none());
|
||||
let inherited =
|
||||
fix("gradlew.bat", &[], &BTreeMap::new(), |k| (k == TOOL_OPTIONS).then(|| "-Xss4m".into()), "UTF-8", true);
|
||||
assert_eq!(tool_env(&inherited), Some(format!("-Xss4m {TOOL_UTF8}").as_str()));
|
||||
for program in ["python.exe", "python3", "python3.12", "py.exe", "uv.exe", "poetry"] {
|
||||
assert_eq!(
|
||||
run(program, &[], &[], "UTF-8", true).env,
|
||||
[("PYTHONIOENCODING".to_owned(), "UTF-8".to_owned())],
|
||||
"{program}"
|
||||
);
|
||||
}
|
||||
assert_eq!(run("python3", &[], &[], "GBK", false).env, [("PYTHONIOENCODING".to_owned(), "GBK".to_owned())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_command_lines_get_variables_from_their_first_word() {
|
||||
let line = |l: &str| fix_command_line(l, &BTreeMap::new(), |_| None, "UTF-8", true);
|
||||
for l in [
|
||||
"mvn spring-boot:run -pl ruoyi-admin",
|
||||
"\"C:\\Program Files\\Maven\\bin\\mvn.cmd\" test",
|
||||
"java -jar app.jar",
|
||||
] {
|
||||
let f = line(l);
|
||||
assert_eq!(tool_env(&f), Some(TOOL_UTF8), "{l}");
|
||||
assert!(f.java_args.is_empty());
|
||||
}
|
||||
assert_eq!(line("python manage.py runserver").env[0].0, "PYTHONIOENCODING");
|
||||
assert_eq!(line("npm run dev && echo done"), EncodingFix::default());
|
||||
assert_eq!(fix_command_line("mvn test", &BTreeMap::new(), |_| None, "UTF-8", false), EncodingFix::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn java_debug_launches_write_what_java_debug_decodes() {
|
||||
assert_eq!(java_debug_vm_args("", None, true).as_deref(), Some(TOOL_UTF8));
|
||||
assert_eq!(java_debug_vm_args("-Xmx1g", Some(""), true), Some(format!("-Xmx1g {TOOL_UTF8}")));
|
||||
assert_eq!(java_debug_vm_args("", Some("GBK"), true).unwrap().split(' ').next(), Some("-Dstdout.encoding=GBK"));
|
||||
assert_eq!(java_debug_vm_args("-Dstdout.encoding=GBK", None, true), None);
|
||||
assert_eq!(java_debug_vm_args("-Xmx1g", None, false), None);
|
||||
assert!(java_debug_vm_args("", Some("GBK"), false).is_some());
|
||||
}
|
||||
|
||||
fn filtered(chunks: &[&str]) -> String {
|
||||
let mut f = NoticeFilter::new(format!("Picked up JAVA_TOOL_OPTIONS: {TOOL_UTF8}"));
|
||||
let mut out = String::new();
|
||||
for c in chunks {
|
||||
f.filter(c, &mut out);
|
||||
}
|
||||
out.push_str(&f.finish());
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notice_lines_are_dropped_wherever_the_chunks_split() {
|
||||
let notice = format!("Picked up JAVA_TOOL_OPTIONS: {TOOL_UTF8}");
|
||||
let text =
|
||||
format!("{notice}\r\nerror: one\n{notice}\nPicked up nothing\nPicked up JAVA_TOOL_OPTIONS: -Dx\nlast");
|
||||
let expected = "error: one\nPicked up nothing\nPicked up JAVA_TOOL_OPTIONS: -Dx\nlast";
|
||||
assert_eq!(filtered(&[&text]), expected);
|
||||
// Every split point, also inside the notice and between `\r` and `\n`.
|
||||
for i in 1..text.len() {
|
||||
let (a, b) = text.split_at(i);
|
||||
assert_eq!(filtered(&[a, b]), expected, "split at {i}");
|
||||
}
|
||||
// A line that only starts like it is not held: text mid-line passes through at once.
|
||||
let mut f = NoticeFilter::new(notice.clone());
|
||||
let mut out = String::new();
|
||||
f.filter("Pick a number: ", &mut out);
|
||||
assert_eq!(out, "Pick a number: ");
|
||||
f.filter("Picked", &mut out);
|
||||
assert_eq!(out, "Pick a number: Picked");
|
||||
// A prefix at the end of the stream comes out on finish.
|
||||
assert_eq!(filtered(&["Picked up"]), "Picked up");
|
||||
}
|
||||
}
|
||||
@@ -1,241 +0,0 @@
|
||||
//! Version managers that keep one install per tool version (mise, asdf): where their
|
||||
//! installs and shims live. SDK detection lists the installs; child processes get the
|
||||
//! shims on `PATH` when the manager's shell hook didn't run (an app started from the Dock,
|
||||
//! `mise activate` only in an interactive rc file).
|
||||
|
||||
use std::ffi::{OsStr, OsString};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub const MISE: &str = "mise";
|
||||
pub const ASDF: &str = "asdf";
|
||||
|
||||
/// A toolchain the version managers install.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Tool {
|
||||
Java,
|
||||
Node,
|
||||
Python,
|
||||
Go,
|
||||
Maven,
|
||||
Gradle,
|
||||
}
|
||||
|
||||
impl Tool {
|
||||
/// The tool's names in mise and asdf plugins (`nodejs`, `golang` are asdf's).
|
||||
pub fn names(self) -> &'static [&'static str] {
|
||||
match self {
|
||||
Tool::Java => &["java"],
|
||||
Tool::Node => &["node", "nodejs"],
|
||||
Tool::Python => &["python"],
|
||||
Tool::Go => &["go", "golang"],
|
||||
Tool::Maven => &["maven"],
|
||||
Tool::Gradle => &["gradle"],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One version manager's data directory (`installs/`, `shims/`).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Manager {
|
||||
pub name: &'static str,
|
||||
pub dir: PathBuf,
|
||||
}
|
||||
|
||||
impl Manager {
|
||||
pub fn shims(&self) -> PathBuf {
|
||||
self.dir.join("shims")
|
||||
}
|
||||
|
||||
/// `installs/<tool>/<version>` directories of `tool`, sorted. The alias links mise
|
||||
/// adds next to an install (`21 -> ./21.0.2`, `latest`) are left out; a link to an
|
||||
/// install elsewhere (`mise link`) is kept.
|
||||
pub fn installs(&self, tool: Tool) -> Vec<PathBuf> {
|
||||
let mut out = Vec::new();
|
||||
for name in tool.names() {
|
||||
let Ok(entries) = std::fs::read_dir(self.dir.join("installs").join(name)) else { continue };
|
||||
for e in entries.flatten() {
|
||||
let path = e.path();
|
||||
let alias = e.file_type().is_ok_and(|t| t.is_symlink())
|
||||
&& std::fs::read_link(&path).is_ok_and(|t| t.is_relative() && t.components().count() <= 2);
|
||||
if !alias && path.is_dir() {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
|
||||
/// The install of `tool` named `version` exactly (alias links included).
|
||||
pub fn install_named(&self, tool: Tool, version: &str) -> Option<PathBuf> {
|
||||
if version.is_empty() || version.contains(['/', '\\']) || version.starts_with('.') {
|
||||
return None;
|
||||
}
|
||||
tool.names().iter().map(|n| self.dir.join("installs").join(n).join(version)).find(|p| p.is_dir())
|
||||
}
|
||||
}
|
||||
|
||||
/// mise's and asdf's data directories that exist, as this process's environment (and the
|
||||
/// login shell's) set them.
|
||||
pub fn managers() -> Vec<Manager> {
|
||||
let lookup = |k: &str| {
|
||||
crate::process::host_var_os(k)
|
||||
.or_else(|| crate::process::login_env()?.iter().find(|(n, _)| n == k).map(|(_, v)| v.clone()))
|
||||
};
|
||||
let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).map(PathBuf::from);
|
||||
managers_with(&lookup, home.as_deref()).into_iter().filter(|m| m.dir.is_dir()).collect()
|
||||
}
|
||||
|
||||
/// mise: `MISE_DATA_DIR`, else `$XDG_DATA_HOME/mise`, else `~/.local/share/mise`
|
||||
/// (`%LOCALAPPDATA%\mise` on Windows). asdf: `ASDF_DATA_DIR`, else `~/.asdf`.
|
||||
pub fn managers_with(env: &dyn Fn(&str) -> Option<OsString>, home: Option<&Path>) -> Vec<Manager> {
|
||||
let var = |k: &str| env(k).filter(|v| !v.is_empty()).map(PathBuf::from);
|
||||
let mise = var("MISE_DATA_DIR")
|
||||
.or_else(|| var("XDG_DATA_HOME").map(|d| d.join("mise")))
|
||||
.or_else(|| if cfg!(windows) { var("LOCALAPPDATA").map(|d| d.join("mise")) } else { None })
|
||||
.or_else(|| home.map(|h| h.join(".local/share/mise")));
|
||||
let asdf = var("ASDF_DATA_DIR").or_else(|| home.map(|h| h.join(".asdf")));
|
||||
let mut out = Vec::new();
|
||||
out.extend(mise.map(|dir| Manager { name: MISE, dir }));
|
||||
out.extend(asdf.map(|dir| Manager { name: ASDF, dir }));
|
||||
out
|
||||
}
|
||||
|
||||
/// Every install of `tool` with the manager it came from, mise's first.
|
||||
pub fn installs(tool: Tool) -> Vec<(PathBuf, &'static str)> {
|
||||
managers().iter().flat_map(|m| m.installs(tool).into_iter().map(|p| (p, m.name))).collect()
|
||||
}
|
||||
|
||||
/// The manager whose installs `path` lies in.
|
||||
pub fn manager_of(path: &Path) -> Option<&'static str> {
|
||||
manager_in(&managers(), path)
|
||||
}
|
||||
|
||||
pub fn manager_in(managers: &[Manager], path: &Path) -> Option<&'static str> {
|
||||
managers.iter().find_map(|m| {
|
||||
let installs = m.dir.join("installs");
|
||||
let real = dunce::canonicalize(&installs).ok();
|
||||
(path.starts_with(&installs) || real.is_some_and(|r| path.starts_with(r))).then_some(m.name)
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether `path` is a version manager's shim (it picks the version by the working directory).
|
||||
pub fn is_shim(path: &Path) -> bool {
|
||||
path.parent().is_some_and(is_shims_dir)
|
||||
}
|
||||
|
||||
/// Whether `dir` is a version manager's `shims` folder.
|
||||
pub fn is_shims_dir(dir: &Path) -> bool {
|
||||
managers().iter().any(|m| m.shims() == dir)
|
||||
}
|
||||
|
||||
/// `path` (a `PATH` value) with the shims of a manager it doesn't reach yet: mise's in
|
||||
/// front (a mise shim runs the next tool on `PATH` when no version is set), asdf's at the
|
||||
/// end (an asdf shim fails then). A `PATH` that has a manager's shims or installs already
|
||||
/// (its shell hook ran) is left as it is for that manager.
|
||||
pub fn path_with_shims(managers: &[Manager], path: &OsStr) -> OsString {
|
||||
let mut dirs: Vec<PathBuf> = if path.is_empty() { Vec::new() } else { std::env::split_paths(path).collect() };
|
||||
let before = dirs.len();
|
||||
for m in managers {
|
||||
let shims = m.shims();
|
||||
let installs = m.dir.join("installs");
|
||||
if !shims.is_dir() || dirs.iter().any(|d| d == &shims || d.starts_with(&installs)) {
|
||||
continue;
|
||||
}
|
||||
if m.name == MISE {
|
||||
dirs.insert(0, shims);
|
||||
} else {
|
||||
dirs.push(shims);
|
||||
}
|
||||
}
|
||||
if dirs.len() == before {
|
||||
return path.to_owned();
|
||||
}
|
||||
std::env::join_paths(dirs).unwrap_or_else(|_| path.to_owned())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(unix)]
|
||||
fn mkdir(p: &Path) {
|
||||
std::fs::create_dir_all(p).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_dirs_follow_the_environment() {
|
||||
let home = Path::new("/home/me");
|
||||
let none = |_: &str| None;
|
||||
let dirs: Vec<_> = managers_with(&none, Some(home)).into_iter().map(|m| (m.name, m.dir)).collect();
|
||||
if !cfg!(windows) {
|
||||
assert_eq!(dirs[0], (MISE, home.join(".local/share/mise")));
|
||||
}
|
||||
assert_eq!(dirs[1], (ASDF, home.join(".asdf")));
|
||||
|
||||
let xdg = |k: &str| (k == "XDG_DATA_HOME").then(|| OsString::from("/xdg"));
|
||||
assert_eq!(managers_with(&xdg, Some(home))[0].dir, Path::new("/xdg/mise"));
|
||||
|
||||
let custom = |k: &str| match k {
|
||||
"MISE_DATA_DIR" => Some(OsString::from("/opt/mise")),
|
||||
"XDG_DATA_HOME" => Some(OsString::from("/xdg")),
|
||||
"ASDF_DATA_DIR" => Some(OsString::from("/opt/asdf")),
|
||||
_ => None,
|
||||
};
|
||||
let dirs: Vec<_> = managers_with(&custom, Some(home)).into_iter().map(|m| m.dir).collect();
|
||||
assert_eq!(dirs, [PathBuf::from("/opt/mise"), PathBuf::from("/opt/asdf")]);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn installs_skip_alias_links() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mise = Manager { name: MISE, dir: tmp.path().join("mise") };
|
||||
let node = mise.dir.join("installs/node");
|
||||
mkdir(&node.join("20.11.0"));
|
||||
mkdir(&node.join("22.1.0"));
|
||||
std::os::unix::fs::symlink("./22.1.0", node.join("22")).unwrap();
|
||||
std::os::unix::fs::symlink("./22.1.0", node.join("latest")).unwrap();
|
||||
let elsewhere = tmp.path().join("opt-node");
|
||||
mkdir(&elsewhere);
|
||||
std::os::unix::fs::symlink(&elsewhere, node.join("linked")).unwrap();
|
||||
std::fs::write(node.join(".mise.backend.toml"), "").unwrap();
|
||||
|
||||
let names: Vec<_> =
|
||||
mise.installs(Tool::Node).iter().map(|p| p.file_name().unwrap().to_string_lossy().into_owned()).collect();
|
||||
assert_eq!(names, ["20.11.0", "22.1.0", "linked"]);
|
||||
assert_eq!(mise.install_named(Tool::Node, "22"), Some(node.join("22")));
|
||||
assert_eq!(mise.install_named(Tool::Node, "18"), None);
|
||||
assert_eq!(mise.install_named(Tool::Node, "../node"), None);
|
||||
|
||||
// asdf's plugin names.
|
||||
let asdf = Manager { name: ASDF, dir: tmp.path().join("asdf") };
|
||||
mkdir(&asdf.dir.join("installs/golang/1.22.1"));
|
||||
assert_eq!(asdf.installs(Tool::Go), [asdf.dir.join("installs/golang/1.22.1")]);
|
||||
assert_eq!(manager_in(&[mise.clone(), asdf.clone()], &node.join("20.11.0/bin/node")), Some(MISE));
|
||||
assert_eq!(manager_in(&[mise, asdf], tmp.path()), None);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn shims_join_a_path_the_hook_did_not_set() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mise = Manager { name: MISE, dir: tmp.path().join("mise") };
|
||||
let asdf = Manager { name: ASDF, dir: tmp.path().join("asdf") };
|
||||
let managers = [mise.clone(), asdf.clone()];
|
||||
let path = OsString::from("/usr/bin:/bin");
|
||||
assert_eq!(path_with_shims(&managers, &path), path, "no shims installed");
|
||||
|
||||
mkdir(&mise.shims());
|
||||
mkdir(&asdf.shims());
|
||||
let got = path_with_shims(&managers, &path);
|
||||
let want = format!("{}:/usr/bin:/bin:{}", mise.shims().display(), asdf.shims().display());
|
||||
assert_eq!(got, OsString::from(want));
|
||||
assert_eq!(path_with_shims(&managers, &got), got, "applied once");
|
||||
|
||||
// `mise activate` put an install on PATH: mise is active, only asdf's shims join.
|
||||
let active = format!("{}:/usr/bin", mise.dir.join("installs/node/22.1.0/bin").display());
|
||||
let got = path_with_shims(&managers, OsStr::new(&active));
|
||||
assert_eq!(got, OsString::from(format!("{active}:{}", asdf.shims().display())));
|
||||
}
|
||||
}
|
||||
+14
-78
@@ -88,46 +88,10 @@ fn hex(bytes: &[u8]) -> String {
|
||||
s
|
||||
}
|
||||
|
||||
/// How [`cell`] shows a value whose bytes are not text.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum Cells {
|
||||
/// Result grids: binary values as `0x…` hex.
|
||||
Display,
|
||||
/// Catalog queries: names and definitions, so bytes are read as UTF-8 whatever the
|
||||
/// column's type (MySQL flags `utf8mb3_bin` catalog columns BINARY, and sends every
|
||||
/// column as binary under `character_set_results = binary`).
|
||||
Text,
|
||||
}
|
||||
|
||||
/// Whether a value of type `ty` is binary data (see [`binary_type`]).
|
||||
fn is_binary<DB: sqlx::Database>(ty: &DB::TypeInfo) -> bool
|
||||
where
|
||||
String: sqlx::Type<DB>,
|
||||
{
|
||||
binary_type(ty.name(), <String as sqlx::Type<DB>>::compatible(ty))
|
||||
}
|
||||
|
||||
/// A BLOB / BINARY type that the driver does not read as text: MySQL flags text in a
|
||||
/// binary collation (`utf8mb4_bin`) BINARY too, and only the `binary` collation is bytes.
|
||||
fn binary_type(name: &str, text: bool) -> bool {
|
||||
let name = name.to_ascii_uppercase();
|
||||
(name.contains("BLOB") || name.contains("BINARY")) && !text
|
||||
}
|
||||
|
||||
/// Bytes as text: UTF-8, else hex (grids) or with invalid sequences replaced (catalog names).
|
||||
fn bytes_text(bytes: Vec<u8>, mode: Cells) -> String {
|
||||
match (String::from_utf8(bytes), mode) {
|
||||
(Ok(s), _) => s,
|
||||
(Err(e), Cells::Display) => hex(e.as_bytes()),
|
||||
(Err(e), Cells::Text) => String::from_utf8_lossy(e.as_bytes()).into_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The text of column `i` of `row`.
|
||||
fn cell<R: Row>(row: &R, i: usize, mode: Cells) -> Option<String>
|
||||
fn cell<R: Row>(row: &R, i: usize) -> Option<String>
|
||||
where
|
||||
usize: ColumnIndex<R>,
|
||||
String: sqlx::Type<R::Database>,
|
||||
for<'r> String: Decode<'r, R::Database>,
|
||||
for<'r> Vec<u8>: Decode<'r, R::Database>,
|
||||
{
|
||||
@@ -135,14 +99,18 @@ where
|
||||
if raw.is_null() {
|
||||
return None;
|
||||
}
|
||||
if mode == Cells::Display && is_binary::<R::Database>(&raw.type_info()) {
|
||||
let ty = raw.type_info().name().to_ascii_uppercase();
|
||||
if ty.contains("BLOB") || ty.contains("BINARY") {
|
||||
let raw = row.try_get_raw(i).ok()?;
|
||||
return <Vec<u8> as Decode<R::Database>>::decode(raw).ok().map(|b| hex(&b));
|
||||
}
|
||||
match <String as Decode<R::Database>>::decode(raw) {
|
||||
Ok(s) => Some(s),
|
||||
Err(_) => {
|
||||
let raw = row.try_get_raw(i).ok()?;
|
||||
<Vec<u8> as Decode<R::Database>>::decode(raw).ok().map(|b| bytes_text(b, mode))
|
||||
<Vec<u8> as Decode<R::Database>>::decode(raw)
|
||||
.ok()
|
||||
.map(|b| String::from_utf8(b).unwrap_or_else(|e| hex(e.as_bytes())))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -154,10 +122,9 @@ fn result_columns<R: Row>(row: &R) -> Vec<ResultColumn> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Streams the results of `$sql` on executor `$exec`, keeping at most `$max` rows
|
||||
/// with their cells read as `$mode`.
|
||||
/// Streams the results of `$sql` on executor `$exec`, keeping at most `$max` rows.
|
||||
macro_rules! fetch {
|
||||
($exec:expr, $sql:expr, $max:expr, $mode:expr) => {{
|
||||
($exec:expr, $sql:expr, $max:expr) => {{
|
||||
use futures::StreamExt;
|
||||
let mut stream = sqlx::raw_sql(sqlx::AssertSqlSafe($sql.to_owned())).fetch_many($exec);
|
||||
let mut columns: Vec<ResultColumn> = Vec::new();
|
||||
@@ -178,7 +145,7 @@ macro_rules! fetch {
|
||||
has_more = true;
|
||||
break;
|
||||
}
|
||||
rows.push((0..row.len()).map(|i| cell(&row, i, $mode)).collect());
|
||||
rows.push((0..row.len()).map(|i| cell(&row, i)).collect());
|
||||
}
|
||||
Err(e) => {
|
||||
failure = Some(db_err(e));
|
||||
@@ -200,7 +167,7 @@ type Fetched = (Vec<ResultColumn>, Vec<Vec<Option<String>>>, Option<u64>, bool,
|
||||
/// The server-side id of a connection (to cancel its statement from another one).
|
||||
macro_rules! backend_id {
|
||||
($conn:expr, $sql:expr) => {{
|
||||
let fetched: IdeResult<Fetched> = fetch!($conn, $sql, 1, Cells::Text);
|
||||
let fetched: IdeResult<Fetched> = fetch!($conn, $sql, 1);
|
||||
fetched
|
||||
.ok()
|
||||
.and_then(|f| f.1.into_iter().next())
|
||||
@@ -464,9 +431,9 @@ impl Pool {
|
||||
with_pool!(self, p => p.is_closed())
|
||||
}
|
||||
|
||||
/// Runs `sql` (no row limit beyond `max`), for introspection: cells are text (see [`Cells::Text`]).
|
||||
/// Runs `sql` (no row limit beyond `max`), for introspection.
|
||||
pub async fn rows(&self, sql: &str, max: usize) -> IdeResult<Vec<Vec<Option<String>>>> {
|
||||
let fetched: IdeResult<Fetched> = with_pool!(self, p => fetch!(p, sql, max, Cells::Text));
|
||||
let fetched: IdeResult<Fetched> = with_pool!(self, p => fetch!(p, sql, max));
|
||||
Ok(fetched?.1)
|
||||
}
|
||||
|
||||
@@ -563,7 +530,7 @@ impl Held {
|
||||
None => None,
|
||||
};
|
||||
let r = tokio::select! {
|
||||
r = async { fetch!(&mut **$conn, sql, max_rows, Cells::Display) } => r,
|
||||
r = async { fetch!(&mut **$conn, sql, max_rows) } => r,
|
||||
() = cancel.cancelled() => Err(IdeError::cancelled()),
|
||||
};
|
||||
if cancel.is_cancelled() {
|
||||
@@ -682,35 +649,4 @@ mod tests {
|
||||
let s = DataSource { properties: vec![prop("cache_size", "-2000")], ..Default::default() };
|
||||
assert_eq!(Setup::of(&s).unwrap().params, [("cache_size".to_owned(), "-2000".to_owned())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_bytes_typed_values_are_binary() {
|
||||
// (type name as the driver reports it, the driver reads it as text, binary)
|
||||
let cases = [
|
||||
("VARBINARY", false, true),
|
||||
("BLOB", false, true),
|
||||
("LONGBLOB", false, true),
|
||||
("BINARY", false, true),
|
||||
// MySQL catalog columns (`utf8mb3_bin`) and `utf8mb4_bin` text: flagged BINARY.
|
||||
("VARBINARY", true, false),
|
||||
("BLOB", true, false),
|
||||
("VARCHAR", true, false),
|
||||
("TEXT", true, false),
|
||||
("BYTEA", false, false),
|
||||
("INT", false, false),
|
||||
];
|
||||
for (name, text, binary) in cases {
|
||||
assert_eq!(binary_type(name, text), binary, "{name} text={text}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_bytes_are_names_and_grid_bytes_are_hex() {
|
||||
// `oi_数据` as MySQL sends it under `character_set_results = binary`.
|
||||
let name = "oi_数据".as_bytes().to_vec();
|
||||
assert_eq!(bytes_text(name.clone(), Cells::Text), "oi_数据");
|
||||
assert_eq!(bytes_text(name, Cells::Display), "oi_数据");
|
||||
assert_eq!(bytes_text(vec![0xCA, 0xFE], Cells::Display), "0xCAFE");
|
||||
assert_eq!(bytes_text(vec![b'a', 0xFF], Cells::Text), "a\u{FFFD}");
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
//! Introspection against a real MySQL / MariaDB server, skipped unless
|
||||
//! `OPENIDEA_TEST_MYSQL=host:port:user:password` names a throwaway server.
|
||||
|
||||
use ide_core::{CancellationToken, IdeResult};
|
||||
use ide_db::{DataSource, DbDriver, DbManager, DbOptions};
|
||||
|
||||
const SCHEMAS: [&str; 2] = ["oi_bin_meta", "oi_数据"];
|
||||
|
||||
fn server() -> Option<(String, u16, String, String)> {
|
||||
let v = std::env::var("OPENIDEA_TEST_MYSQL").ok()?;
|
||||
let mut it = v.splitn(4, ':');
|
||||
Some((it.next()?.into(), it.next()?.parse().ok()?, it.next()?.into(), it.next().unwrap_or("").into()))
|
||||
}
|
||||
|
||||
async fn manager(tmp: &tempfile::TempDir, startup_script: &str) -> Option<DbManager> {
|
||||
let (host, port, user, password) = server()?;
|
||||
let root = tmp.path().to_path_buf();
|
||||
let ssh = std::sync::Arc::new(ide_ssh::SshManager::new(root.join("ssh-passwords.json")));
|
||||
let db = DbManager::new(root.clone(), root.join("passwords.json"), root.join("history.json"), ssh);
|
||||
db.save_sources(vec![DataSource {
|
||||
id: "m".into(),
|
||||
name: "mysql".into(),
|
||||
driver: DbDriver::Mysql,
|
||||
host,
|
||||
port: Some(port),
|
||||
user,
|
||||
options: DbOptions { startup_script: startup_script.into(), ..Default::default() },
|
||||
..Default::default()
|
||||
}])
|
||||
.await
|
||||
.unwrap();
|
||||
db.set_password("m", Some(password), false).await.unwrap();
|
||||
Some(db)
|
||||
}
|
||||
|
||||
async fn create_schemas(db: &DbManager) {
|
||||
let c = CancellationToken::new();
|
||||
for s in SCHEMAS {
|
||||
for stmt in [
|
||||
format!("DROP DATABASE IF EXISTS `{s}`"),
|
||||
format!("CREATE DATABASE `{s}` CHARACTER SET utf8mb4 COLLATE utf8mb4_bin"),
|
||||
format!(
|
||||
"CREATE TABLE `{s}`.`用户` (id INT PRIMARY KEY, name VARCHAR(20) COLLATE utf8mb4_bin, raw VARBINARY(4))"
|
||||
),
|
||||
format!("CREATE INDEX `名字` ON `{s}`.`用户` (name)"),
|
||||
format!("INSERT INTO `{s}`.`用户` VALUES (1, 'ann', 0xCAFE)"),
|
||||
] {
|
||||
db.execute("m", &stmt, 10, &c).await.unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn assert_metadata_is_text(db: &DbManager) -> IdeResult<()> {
|
||||
let schemas = db.schemas("m").await?;
|
||||
for s in SCHEMAS {
|
||||
assert!(schemas.iter().any(|n| n == s), "{s} missing from {schemas:?}");
|
||||
let tables = db.tables("m", s).await?;
|
||||
assert_eq!(tables.iter().map(|t| t.name.as_str()).collect::<Vec<_>>(), ["用户"], "tables of {s}");
|
||||
let d = db.table_details("m", s, "用户").await?;
|
||||
let cols: Vec<_> = d.columns.iter().map(|c| c.name.as_str()).collect();
|
||||
assert_eq!(cols, ["id", "name", "raw"]);
|
||||
assert!(d.indexes.iter().any(|i| i.name == "名字"), "{:?}", d.indexes);
|
||||
assert!(db.ddl("m", s, "用户").await?.contains("CREATE TABLE `用户`"));
|
||||
}
|
||||
let completion = db.completion("m").await?;
|
||||
let json = serde_json::to_string(&completion).unwrap();
|
||||
assert!(json.contains("oi_数据") && json.contains("用户"), "{json}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// One test: both phases share the schemas they create.
|
||||
#[tokio::test]
|
||||
async fn metadata_names_are_text_whatever_the_column_collation() -> IdeResult<()> {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let Some(db) = manager(&tmp, "").await else { return Ok(()) };
|
||||
create_schemas(&db).await;
|
||||
// MySQL 8 sends `utf8mb3_bin` catalog names flagged BINARY (lower_case_table_names = 0).
|
||||
assert_metadata_is_text(&db).await?;
|
||||
// `_bin` text is text in the grid; true binary stays hex.
|
||||
let r = db.execute("m", "SELECT name, raw FROM `oi_数据`.`用户`", 10, &CancellationToken::new()).await?;
|
||||
assert_eq!(r.rows, vec![vec![Some("ann".to_owned()), Some("0xCAFE".to_owned())]]);
|
||||
|
||||
// Every text column arrives with the `binary` collation.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let raw = manager(&tmp, "SET character_set_results = binary").await.unwrap();
|
||||
assert_metadata_is_text(&raw).await
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
[package]
|
||||
name = "ide-fonts"
|
||||
description = "Font families installed on this machine, for the font pickers: CoreText, DirectWrite or fontconfig"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde.workspace = true
|
||||
specta.workspace = true
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
objc2-core-foundation = { version = "0.3", default-features = false, features = ["std", "CFArray", "CFDictionary", "CFNumber", "CFString"] }
|
||||
objc2-core-text = { version = "0.3", default-features = false, features = ["std", "CTFontCollection", "CTFontDescriptor", "CTFontTraits"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite"] }
|
||||
@@ -1,264 +0,0 @@
|
||||
//! The font families installed on this machine, for the font pickers in
|
||||
//! Settings (editor, typography fallback, terminal, UI font).
|
||||
//!
|
||||
//! Each platform asks the font system the WebView itself resolves CSS
|
||||
//! `font-family` names against, so every listed name renders: CoreText on
|
||||
//! macOS, DirectWrite on Windows, fontconfig (`fc-list`) on Linux and the BSDs.
|
||||
//! The list is read once per run and cached; the first read can take a few
|
||||
//! hundred milliseconds, so callers run it off the UI and command threads.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// One installed family. `monospaced` is the font system's own flag (fixed
|
||||
/// pitch; on Linux also fontconfig's dual-width CJK monospace).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FontFamily {
|
||||
pub name: String,
|
||||
pub monospaced: bool,
|
||||
}
|
||||
|
||||
/// The installed families, sorted case-insensitively, read on first use.
|
||||
pub fn families() -> &'static [FontFamily] {
|
||||
static CACHE: OnceLock<Vec<FontFamily>> = OnceLock::new();
|
||||
CACHE.get_or_init(|| normalize(imp::list()))
|
||||
}
|
||||
|
||||
/// Merges the per-face records a font system reports into one entry per
|
||||
/// family: trimmed, without empty or hidden (`.`-prefixed system UI) names,
|
||||
/// monospaced when any face is, sorted case-insensitively.
|
||||
pub fn normalize(faces: impl IntoIterator<Item = (String, bool)>) -> Vec<FontFamily> {
|
||||
let mut by_key: BTreeMap<(String, String), bool> = BTreeMap::new();
|
||||
for (name, mono) in faces {
|
||||
let name = name.trim();
|
||||
if name.is_empty() || name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
*by_key.entry((name.to_lowercase(), name.to_owned())).or_default() |= mono;
|
||||
}
|
||||
let mut out: Vec<FontFamily> = Vec::with_capacity(by_key.len());
|
||||
for ((key, name), monospaced) in by_key {
|
||||
// Same family under two casings (one font file each): keep the first.
|
||||
if let Some(last) = out.last_mut()
|
||||
&& last.name.to_lowercase() == key
|
||||
{
|
||||
last.monospaced |= monospaced;
|
||||
continue;
|
||||
}
|
||||
out.push(FontFamily { name, monospaced });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Parses `fc-list -f '%{family[0]}\t%{spacing}\n'`: one line per face, the
|
||||
/// spacing empty (proportional) or fontconfig's number or name for it.
|
||||
pub fn parse_fc_list(output: &str) -> Vec<(String, bool)> {
|
||||
output
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let (family, spacing) = line.split_once('\t').unwrap_or((line, ""));
|
||||
let family = family.replace("\\-", "-");
|
||||
let mono = match spacing.trim() {
|
||||
"dual" | "mono" | "charcell" => true,
|
||||
s => s.parse::<u32>().is_ok_and(|n| n >= 90),
|
||||
};
|
||||
(!family.trim().is_empty()).then_some((family, mono))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod imp {
|
||||
use objc2_core_foundation::{CFArray, CFDictionary, CFNumber, CFString, CFType};
|
||||
use objc2_core_text::{
|
||||
CTFontCollection, CTFontDescriptor, CTFontSymbolicTraits, kCTFontFamilyNameAttribute, kCTFontSymbolicTrait,
|
||||
kCTFontTraitsAttribute,
|
||||
};
|
||||
|
||||
/// Every face CoreText makes available to this process, as (family, fixed pitch).
|
||||
pub(crate) fn list() -> Vec<(String, bool)> {
|
||||
// SAFETY: CoreText's documented types: the matching descriptors array
|
||||
// holds CTFontDescriptors and the traits dictionary maps CFString keys.
|
||||
unsafe {
|
||||
let collection = CTFontCollection::from_available_fonts(None);
|
||||
let Some(descriptors) = collection.matching_font_descriptors() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let descriptors: &CFArray<CTFontDescriptor> = descriptors.cast_unchecked();
|
||||
descriptors
|
||||
.iter()
|
||||
.filter_map(|d| {
|
||||
let name = d.attribute(kCTFontFamilyNameAttribute)?.downcast::<CFString>().ok()?.to_string();
|
||||
let traits = d
|
||||
.attribute(kCTFontTraitsAttribute)
|
||||
.and_then(|t| t.downcast::<CFDictionary>().ok())
|
||||
.and_then(|t| t.cast_unchecked::<CFString, CFType>().get(kCTFontSymbolicTrait))
|
||||
.and_then(|n| n.downcast::<CFNumber>().ok())
|
||||
.and_then(|n| n.as_i64())
|
||||
.unwrap_or(0);
|
||||
Some((name, traits as u32 & CTFontSymbolicTraits::TraitMonoSpace.0 != 0))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
mod imp {
|
||||
use windows::Win32::Graphics::DirectWrite::{
|
||||
DWRITE_FACTORY_TYPE_SHARED, DWRITE_FONT_STRETCH_NORMAL, DWRITE_FONT_STYLE_NORMAL, DWRITE_FONT_WEIGHT_NORMAL,
|
||||
DWriteCreateFactory, IDWriteFactory, IDWriteFont1, IDWriteLocalizedStrings,
|
||||
};
|
||||
use windows::core::{BOOL, Interface, w};
|
||||
|
||||
/// The system font collection's families, named in English when the
|
||||
/// family has an English name (what CSS matches), else its first name.
|
||||
pub(crate) fn list() -> Vec<(String, bool)> {
|
||||
// SAFETY: plain DirectWrite COM calls on interfaces we own.
|
||||
unsafe { read() }.unwrap_or_default()
|
||||
}
|
||||
|
||||
unsafe fn read() -> windows::core::Result<Vec<(String, bool)>> {
|
||||
let factory: IDWriteFactory = unsafe { DWriteCreateFactory(DWRITE_FACTORY_TYPE_SHARED) }?;
|
||||
let mut collection = None;
|
||||
unsafe { factory.GetSystemFontCollection(&mut collection, false) }?;
|
||||
let Some(collection) = collection else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for i in 0..unsafe { collection.GetFontFamilyCount() } {
|
||||
let Ok(family) = (unsafe { collection.GetFontFamily(i) }) else {
|
||||
continue;
|
||||
};
|
||||
let Some(name) = unsafe { family.GetFamilyNames() }.ok().and_then(|n| unsafe { english(&n) }) else {
|
||||
continue;
|
||||
};
|
||||
let mono = unsafe {
|
||||
family.GetFirstMatchingFont(
|
||||
DWRITE_FONT_WEIGHT_NORMAL,
|
||||
DWRITE_FONT_STRETCH_NORMAL,
|
||||
DWRITE_FONT_STYLE_NORMAL,
|
||||
)
|
||||
}
|
||||
.ok()
|
||||
.and_then(|f| f.cast::<IDWriteFont1>().ok())
|
||||
.is_some_and(|f| unsafe { f.IsMonospacedFont() }.as_bool());
|
||||
out.push((name, mono));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
unsafe fn english(names: &IDWriteLocalizedStrings) -> Option<String> {
|
||||
let mut index = 0u32;
|
||||
let mut exists = BOOL(0);
|
||||
unsafe { names.FindLocaleName(w!("en-us"), &mut index, &mut exists) }.ok()?;
|
||||
if !exists.as_bool() {
|
||||
index = 0;
|
||||
}
|
||||
let len = unsafe { names.GetStringLength(index) }.ok()? as usize;
|
||||
let mut buf = vec![0u16; len + 1];
|
||||
unsafe { names.GetString(index, &mut buf) }.ok()?;
|
||||
Some(String::from_utf16_lossy(&buf[..len]))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
mod imp {
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
/// fontconfig's view, which WebKitGTK resolves names with. Empty when
|
||||
/// `fc-list` isn't installed.
|
||||
pub(crate) fn list() -> Vec<(String, bool)> {
|
||||
Command::new("fc-list")
|
||||
.args(["-f", "%{family[0]}\t%{spacing}\n"])
|
||||
.stdin(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.map(|o| super::parse_fc_list(&String::from_utf8_lossy(&o.stdout)))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
mod imp {
|
||||
pub(crate) fn list() -> Vec<(String, bool)> {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn fam(name: &str, monospaced: bool) -> FontFamily {
|
||||
FontFamily { name: name.into(), monospaced }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_merges_faces_sorts_and_drops_hidden() {
|
||||
let faces = [
|
||||
("Menlo", true),
|
||||
("Arial", false),
|
||||
("Menlo", true),
|
||||
(".SF NS Mono", true),
|
||||
(" ", false),
|
||||
("arial", false),
|
||||
("PingFang SC", false),
|
||||
("Courier New", false),
|
||||
("Courier New", true),
|
||||
("andale mono", true),
|
||||
]
|
||||
.map(|(n, m)| (n.to_owned(), m));
|
||||
assert_eq!(
|
||||
normalize(faces),
|
||||
vec![
|
||||
fam("andale mono", true),
|
||||
fam("Arial", false),
|
||||
fam("Courier New", true),
|
||||
fam("Menlo", true),
|
||||
fam("PingFang SC", false),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fc_list_spacing_marks_monospace() {
|
||||
let out = "DejaVu Sans Mono\t100\nDejaVu Sans\t\nNoto Sans Mono CJK SC\t90\nTerminus\t110\nCantarell\nFira\\-Code\tmono\n\t100\n";
|
||||
assert_eq!(
|
||||
parse_fc_list(out),
|
||||
[
|
||||
("DejaVu Sans Mono", true),
|
||||
("DejaVu Sans", false),
|
||||
("Noto Sans Mono CJK SC", true),
|
||||
("Terminus", true),
|
||||
("Cantarell", false),
|
||||
("Fira-Code", true),
|
||||
]
|
||||
.map(|(n, m)| (n.to_owned(), m))
|
||||
);
|
||||
}
|
||||
|
||||
/// The real font system: every desktop OS ships some fonts, at least one
|
||||
/// of them monospaced, and the list comes back sorted without duplicates.
|
||||
#[cfg(any(target_os = "macos", windows))]
|
||||
#[test]
|
||||
fn lists_installed_families() {
|
||||
let list = families();
|
||||
assert!(list.len() > 20, "only {} families", list.len());
|
||||
assert!(list.iter().any(|f| f.monospaced));
|
||||
assert!(list.iter().any(|f| !f.monospaced));
|
||||
let keys: Vec<String> = list.iter().map(|f| f.name.to_lowercase()).collect();
|
||||
assert!(keys.windows(2).all(|w| w[0] < w[1]));
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let menlo = list.iter().find(|f| f.name == "Menlo").expect("Menlo ships with macOS");
|
||||
assert!(menlo.monospaced);
|
||||
assert!(list.iter().any(|f| f.name == "Helvetica Neue" && !f.monospaced));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,13 +6,6 @@ use std::path::PathBuf;
|
||||
|
||||
/// Starts a session owned by this window: its events (and those of child sessions) go here only.
|
||||
pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> IdeResult<DebugSessionId> {
|
||||
let mut config = match_java_debug_encoding(config, cfg!(windows));
|
||||
if config.adapter == "debugpy"
|
||||
&& config.request == "launch"
|
||||
&& let Ok(project) = host.project(window)
|
||||
{
|
||||
super::python::prepare_debug(&mut config, &super::python::paths(&project))?;
|
||||
}
|
||||
let label = window;
|
||||
let owner = SessionOwner::new(host.owner_window(label), host.window_sink(label));
|
||||
let settings = match host.project(label) {
|
||||
@@ -27,26 +20,6 @@ pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> Ide
|
||||
host.debugger.start_with(config, owner, custom).await
|
||||
}
|
||||
|
||||
/// A JVM launched by java-debug writes what java-debug decodes its output as
|
||||
/// (see [`ide_core::output_encoding::java_debug_vm_args`]).
|
||||
fn match_java_debug_encoding(mut config: LaunchConfig, windows: bool) -> LaunchConfig {
|
||||
if config.adapter != "java-debug" || config.request != "launch" {
|
||||
return config;
|
||||
}
|
||||
let Some(serde_json::Value::Object(mut extra)) =
|
||||
config.extra_json.as_deref().and_then(|e| serde_json::from_str(e).ok())
|
||||
else {
|
||||
return config;
|
||||
};
|
||||
let vm_args = extra.get("vmArgs").and_then(|v| v.as_str()).unwrap_or_default();
|
||||
let encoding = extra.get("encoding").and_then(|v| v.as_str());
|
||||
if let Some(vm_args) = ide_core::output_encoding::java_debug_vm_args(vm_args, encoding, windows) {
|
||||
extra.insert("vmArgs".into(), vm_args.into());
|
||||
config.extra_json = Some(serde_json::Value::Object(extra).to_string());
|
||||
}
|
||||
config
|
||||
}
|
||||
|
||||
pub async fn debug_set_breakpoints(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
@@ -382,46 +355,3 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
|c| async move { debug_redefine_classes(&c.host, &c.window, c.arg("session")?).await },
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn launch(adapter: &str, request: &str, extra: serde_json::Value) -> LaunchConfig {
|
||||
LaunchConfig {
|
||||
name: "DromaraApplication".into(),
|
||||
adapter: adapter.into(),
|
||||
request: request.into(),
|
||||
program: None,
|
||||
args: vec![],
|
||||
cwd: None,
|
||||
env: Default::default(),
|
||||
extra_json: Some(extra.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn vm_args(config: &LaunchConfig) -> serde_json::Value {
|
||||
serde_json::from_str::<serde_json::Value>(config.extra_json.as_deref().unwrap()).unwrap()["vmArgs"].clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn java_debug_launches_on_windows_write_utf8() {
|
||||
let extra =
|
||||
serde_json::json!({"mainClass": "org.dromara.DromaraApplication", "vmArgs": "-Xmx1g", "debugServer": 1});
|
||||
let fixed = match_java_debug_encoding(launch("java-debug", "launch", extra.clone()), true);
|
||||
assert_eq!(
|
||||
vm_args(&fixed),
|
||||
"-Xmx1g -Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8 -Dsun.stdout.encoding=UTF-8 -Dsun.stderr.encoding=UTF-8"
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(fixed.extra_json.as_deref().unwrap()).unwrap()["mainClass"],
|
||||
"org.dromara.DromaraApplication"
|
||||
);
|
||||
// Attaching, other adapters and other platforms are left alone.
|
||||
for (adapter, request, windows) in
|
||||
[("java-debug", "attach", true), ("debugpy", "launch", true), ("java-debug", "launch", false)]
|
||||
{
|
||||
assert_eq!(vm_args(&match_java_debug_encoding(launch(adapter, request, extra.clone()), windows)), "-Xmx1g");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,6 @@ pub mod java;
|
||||
pub mod lsp;
|
||||
pub mod nav;
|
||||
pub mod project;
|
||||
mod python;
|
||||
pub mod refactor;
|
||||
pub mod run;
|
||||
pub mod sdk;
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
use crate::project::Project;
|
||||
use ide_core::IdeResult;
|
||||
use ide_dap::LaunchConfig;
|
||||
use ide_workspace::roots::RootKind;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub(super) fn paths(project: &Project) -> Vec<PathBuf> {
|
||||
let mut paths = vec![project.root.clone()];
|
||||
paths.extend(project.content_roots());
|
||||
for root in project.roots().roots {
|
||||
if matches!(root.kind, RootKind::Source | RootKind::Test) {
|
||||
let path = project.root.join(root.path);
|
||||
if !paths.contains(&path) {
|
||||
paths.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
paths
|
||||
}
|
||||
|
||||
pub(super) fn prepare_debug(config: &mut LaunchConfig, paths: &[PathBuf]) -> IdeResult<()> {
|
||||
// Adapter-specific env takes precedence over the common launch fields in DAP.
|
||||
let mut extra: Option<serde_json::Value> = config.extra_json.as_deref().and_then(|s| serde_json::from_str(s).ok());
|
||||
// A custom adapter may run on another host; local paths are not portable.
|
||||
if extra.as_ref().is_some_and(|e| e.get("debugServer").is_some()) {
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(env) = extra.as_mut().and_then(|e| e.get_mut("env")) {
|
||||
if env.is_null() {
|
||||
*env = serde_json::json!({});
|
||||
}
|
||||
if !env.is_object() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut vars = BTreeMap::new();
|
||||
if let Some(value) = env.get("PYTHONPATH").and_then(|v| v.as_str()) {
|
||||
vars.insert("PYTHONPATH".into(), value.to_owned());
|
||||
}
|
||||
ide_run::python_path::extend(&mut vars, paths, env.get("PYTHONPATH").is_none())?;
|
||||
env["PYTHONPATH"] = serde_json::json!(vars["PYTHONPATH"]);
|
||||
config.extra_json = extra.map(|e| e.to_string());
|
||||
} else {
|
||||
ide_run::python_path::extend(&mut config.env, paths, true)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn debug_preserves_remote_paths_and_handles_null_env() {
|
||||
let root = std::env::temp_dir().join("project");
|
||||
let mut config = LaunchConfig {
|
||||
name: "Python".into(),
|
||||
adapter: "debugpy".into(),
|
||||
request: "launch".into(),
|
||||
program: None,
|
||||
args: vec![],
|
||||
cwd: None,
|
||||
env: BTreeMap::new(),
|
||||
extra_json: Some(serde_json::json!({"env": null, "debugServer": "remote:5678"}).to_string()),
|
||||
};
|
||||
let before = config.extra_json.clone();
|
||||
prepare_debug(&mut config, std::slice::from_ref(&root)).unwrap();
|
||||
assert_eq!(config.extra_json, before);
|
||||
assert!(config.env.is_empty());
|
||||
config.extra_json = Some(serde_json::json!({"env": null}).to_string());
|
||||
prepare_debug(&mut config, std::slice::from_ref(&root)).unwrap();
|
||||
let value: serde_json::Value = serde_json::from_str(config.extra_json.as_deref().unwrap()).unwrap();
|
||||
let paths = std::env::split_paths(value["env"]["PYTHONPATH"].as_str().unwrap()).collect::<Vec<_>>();
|
||||
assert_eq!(paths[0], root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debug_uses_the_effective_environment_field() {
|
||||
for extra in [false, true] {
|
||||
let root = std::env::temp_dir().join("project");
|
||||
let vendor = root.join("vendor");
|
||||
let vars = BTreeMap::from([("PYTHONPATH".into(), vendor.to_string_lossy().into_owned())]);
|
||||
let mut config = LaunchConfig {
|
||||
name: "Python".into(),
|
||||
adapter: "debugpy".into(),
|
||||
request: "launch".into(),
|
||||
program: Some("app/main.py".into()),
|
||||
args: vec![],
|
||||
cwd: None,
|
||||
env: vars.clone(),
|
||||
extra_json: extra.then(|| serde_json::json!({"env": vars}).to_string()),
|
||||
};
|
||||
prepare_debug(&mut config, std::slice::from_ref(&root)).unwrap();
|
||||
let vars: BTreeMap<String, String> = if extra {
|
||||
let value: serde_json::Value = serde_json::from_str(config.extra_json.as_deref().unwrap()).unwrap();
|
||||
serde_json::from_value(value["env"].clone()).unwrap()
|
||||
} else {
|
||||
config.env
|
||||
};
|
||||
assert_eq!(std::env::split_paths(&vars["PYTHONPATH"]).collect::<Vec<_>>(), [root, vendor]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -43,10 +43,7 @@ pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit
|
||||
return Err(IdeError::unavailable(ide_sdk::godot::NOT_FOUND));
|
||||
}
|
||||
let encoding = settings.console.encoding;
|
||||
let options = StartOptions {
|
||||
encoding: Some(encoding).filter(|e| !e.trim().is_empty()),
|
||||
python_paths: super::python::paths(&project),
|
||||
};
|
||||
let options = StartOptions { encoding: Some(encoding).filter(|e| !e.trim().is_empty()) };
|
||||
host.processes.start(&project.root, &config, &options, project.sink.clone(), Box::new(move |c| on_output(c)))
|
||||
}
|
||||
|
||||
|
||||
@@ -21,18 +21,6 @@ pub fn run_system_env(_host: &Host, _window: &str) -> std::collections::BTreeMap
|
||||
ide_run::system_env()
|
||||
}
|
||||
|
||||
/// Writes the Gradle init script that adds `jvm_args` to the JVMs the build forks
|
||||
/// (`bootRun`, `run`, tests) and returns its path, for `--init-script`.
|
||||
pub async fn run_gradle_jvm_args_script(_host: &Host, _window: &str, jvm_args: Vec<String>) -> IdeResult<String> {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
ide_run::gradle_jvm::write_init_script(&ide_run::gradle_jvm::default_dir(), &jvm_args)
|
||||
.map(|p| p.to_string_lossy().into_owned())
|
||||
.map_err(|e| IdeError::internal(format!("cannot write the Gradle init script: {e}")))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
}
|
||||
|
||||
/// Results from the JUnit XML reports (Surefire, Failsafe, Gradle) of the module at
|
||||
/// `module` (relative to the project root) written since `since_ms` (Unix epoch, ms).
|
||||
pub async fn run_test_reports(
|
||||
@@ -54,9 +42,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("run_templates", |c| async move { run_templates(&c.host, &c.window) });
|
||||
d.add("run_save_templates", |c| async move { run_save_templates(&c.host, &c.window, c.arg("templates")?) });
|
||||
d.add("run_system_env", |c| async move { Ok(run_system_env(&c.host, &c.window)) });
|
||||
d.add("run_gradle_jvm_args_script", |c| async move {
|
||||
run_gradle_jvm_args_script(&c.host, &c.window, c.arg("jvmArgs")?).await
|
||||
});
|
||||
d.add("run_test_reports", |c| async move {
|
||||
run_test_reports(&c.host, &c.window, c.arg("module")?, c.arg("sinceMs")?).await
|
||||
});
|
||||
|
||||
@@ -242,33 +242,6 @@ pub async fn vcs_stage(host: &Host, window: &str, paths: Vec<PathBuf>) -> IdeRes
|
||||
blocking(move || per_repo(&project, &paths, None, |r, paths| r.stage(paths))).await
|
||||
}
|
||||
|
||||
/// Project-host operation (also registered with the remote dispatcher).
|
||||
pub async fn vcs_add_to_ignore(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
paths: Vec<PathBuf>,
|
||||
destination: IgnoreDestination,
|
||||
) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
for path in &paths {
|
||||
if path == &project.root || !path.starts_with(&project.root) {
|
||||
return Err(ide_core::IdeError::invalid("Ignore selection is outside the project"));
|
||||
}
|
||||
}
|
||||
apply_git_executable(&project.settings.effective());
|
||||
let result = ide_vcs::add_to_ignore(&paths, destination);
|
||||
// Exclude may live outside a worktree's watched directory. Refresh filters and UI explicitly.
|
||||
project.watcher.reload_ignore_rules();
|
||||
for (_, watcher) in project.content_watchers.lock().iter() {
|
||||
watcher.reload_ignore_rules();
|
||||
}
|
||||
project.sink.emit(ide_core::IdeEvent::VcsChanged { root: project.root.clone().into() });
|
||||
result.map(|_| ())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn vcs_unstage(host: &Host, window: &str, paths: Vec<PathBuf>) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || per_repo(&project, &paths, None, |r, paths| r.unstage(paths))).await
|
||||
@@ -707,17 +680,13 @@ pub async fn vcs_diff_revisions(
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.diff_revisions(&from, &to))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_create_patch(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
repo: Option<PathBuf>,
|
||||
source: PatchSource,
|
||||
) -> IdeResult<String> {
|
||||
pub async fn vcs_create_patch(host: &Host, window: &str, source: PatchSource) -> IdeResult<String> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
project.with_repo_at(repo.as_deref(), |r| match source {
|
||||
project.with_repo(|r| match source {
|
||||
PatchSource::Changes { paths, reverse } => {
|
||||
let paths: Vec<PathBuf> = paths.into_iter().map(Into::into).collect();
|
||||
in_primary_repo(&project, &paths)?;
|
||||
r.create_patch(&paths, reverse)
|
||||
}
|
||||
PatchSource::Commits { commits, reverse } => r.create_commits_patch(&commits, reverse),
|
||||
@@ -726,24 +695,14 @@ pub async fn vcs_create_patch(
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn vcs_patch_preview(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
repo: Option<PathBuf>,
|
||||
patch: String,
|
||||
) -> IdeResult<Vec<PatchFile>> {
|
||||
pub async fn vcs_patch_preview(host: &Host, window: &str, patch: String) -> IdeResult<Vec<PatchFile>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.patch_preview(&patch))).await
|
||||
blocking(move || project.with_repo(|r| r.patch_preview(&patch))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_apply_patch(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
repo: Option<PathBuf>,
|
||||
patch: String,
|
||||
) -> IdeResult<ApplyOutcome> {
|
||||
pub async fn vcs_apply_patch(host: &Host, window: &str, patch: String) -> IdeResult<ApplyOutcome> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.apply_patch(&patch))).await
|
||||
blocking(move || project.with_repo(|r| r.apply_patch(&patch))).await
|
||||
}
|
||||
|
||||
/// Shelve Changes: saves the changes of `paths` as a patch on the shelf and reverts them.
|
||||
@@ -827,9 +786,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("vcs_branches", |c| async move { vcs_branches(&c.host, &c.window, c.arg("repo")?).await });
|
||||
d.add("vcs_stashes", |c| async move { vcs_stashes(&c.host, &c.window, c.arg("repo")?).await });
|
||||
d.add("vcs_stage", |c| async move { vcs_stage(&c.host, &c.window, c.arg("paths")?).await });
|
||||
d.add("vcs_add_to_ignore", |c| async move {
|
||||
vcs_add_to_ignore(&c.host, &c.window, c.arg("paths")?, c.arg("destination")?).await
|
||||
});
|
||||
d.add("vcs_unstage", |c| async move { vcs_unstage(&c.host, &c.window, c.arg("paths")?).await });
|
||||
d.add("vcs_stage_patch", |c| async move { vcs_stage_patch(&c.host, &c.window, c.arg("patch")?).await });
|
||||
d.add("vcs_discard", |c| async move { vcs_discard(&c.host, &c.window, c.arg("paths")?).await });
|
||||
@@ -945,16 +901,9 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("vcs_diff_revisions", |c| async move {
|
||||
vcs_diff_revisions(&c.host, &c.window, c.arg("repo")?, c.arg("from")?, c.arg("to")?).await
|
||||
});
|
||||
d.add("vcs_create_patch", |c| async move {
|
||||
vcs_create_patch(&c.host, &c.window, c.arg("repo")?, c.arg("source")?).await
|
||||
});
|
||||
d.add("vcs_patch_preview", |c| async move {
|
||||
vcs_patch_preview(&c.host, &c.window, c.arg("repo")?, c.arg("patch")?).await
|
||||
});
|
||||
d.add(
|
||||
"vcs_apply_patch",
|
||||
|c| async move { vcs_apply_patch(&c.host, &c.window, c.arg("repo")?, c.arg("patch")?).await },
|
||||
);
|
||||
d.add("vcs_create_patch", |c| async move { vcs_create_patch(&c.host, &c.window, c.arg("source")?).await });
|
||||
d.add("vcs_patch_preview", |c| async move { vcs_patch_preview(&c.host, &c.window, c.arg("patch")?).await });
|
||||
d.add("vcs_apply_patch", |c| async move { vcs_apply_patch(&c.host, &c.window, c.arg("patch")?).await });
|
||||
d.add("vcs_shelve", |c| async move { vcs_shelve(&c.host, &c.window, c.arg("name")?, c.arg("paths")?).await });
|
||||
d.add("vcs_shelves", |c| async move { vcs_shelves(&c.host, &c.window) });
|
||||
d.add("vcs_unshelve", |c| async move { vcs_unshelve(&c.host, &c.window, c.arg("id")?, c.arg("remove")?).await });
|
||||
|
||||
@@ -109,13 +109,7 @@ fn server_environment_from_login_shell_and_devcontainer() {
|
||||
let started = project.join("started.txt");
|
||||
wait_for("postStartCommand", || std::fs::read_to_string(&started).is_ok_and(|s| s.ends_with('\n')));
|
||||
assert_eq!(std::fs::read_to_string(&started).unwrap(), "two-demo\n");
|
||||
wait_for("postStartCommand to end", || {
|
||||
events
|
||||
.0
|
||||
.lock()
|
||||
.iter()
|
||||
.any(|e| matches!(e, IdeEvent::Task { title, done: true, .. } if title == "postStartCommand"))
|
||||
});
|
||||
wait_for("the task to end", || events.0.lock().iter().any(|e| matches!(e, IdeEvent::Task { done: true, .. })));
|
||||
assert!(events.0.lock().iter().any(
|
||||
|e| matches!(e, IdeEvent::Task { message: Some(m), title, .. } if m == "post-start-output" && title == "postStartCommand")
|
||||
));
|
||||
|
||||
@@ -108,26 +108,6 @@ async fn serves_projects_files_documents_search_and_runs() {
|
||||
invoke(&peer, "vcs_init", json!({ "dir": project })).await.unwrap();
|
||||
until("git repository", || project.join(".git").is_dir()).await;
|
||||
|
||||
// Real project-host ignore writes travel through the remote command dispatcher.
|
||||
assert!(Dispatcher::standard().commands().contains(&"vcs_add_to_ignore"));
|
||||
let ignore_root = dunce::canonicalize(&project).unwrap();
|
||||
for (name, destination) in [("shared.tmp", "gitignore"), ("private.tmp", "localExclude")] {
|
||||
let path = ignore_root.join(name);
|
||||
std::fs::write(&path, "temporary").unwrap();
|
||||
invoke(&peer, "vcs_add_to_ignore", json!({ "paths": [path], "destination": destination })).await.unwrap();
|
||||
let entries = invoke(&peer, "fs_read_dir", json!({ "path": ignore_root })).await.unwrap();
|
||||
let entry = entries.as_array().unwrap().iter().find(|e| e["name"] == name).unwrap();
|
||||
assert_eq!(entry["ignored"], true);
|
||||
assert_eq!(entry["excluded"], false);
|
||||
}
|
||||
assert!(std::fs::read_to_string(project.join(".gitignore")).unwrap().contains("/shared.tmp"));
|
||||
assert!(std::fs::read_to_string(project.join(".git/info/exclude")).unwrap().contains("/private.tmp"));
|
||||
let outside = tmp.path().join("outside.tmp");
|
||||
std::fs::write(&outside, "keep").unwrap();
|
||||
assert!(
|
||||
invoke(&peer, "vcs_add_to_ignore", json!({ "paths": [outside], "destination": "gitignore" })).await.is_err()
|
||||
);
|
||||
|
||||
// Unknown commands and bad arguments come back as IdeErrors.
|
||||
let e = invoke(&peer, "window_minimize", json!({})).await.unwrap_err();
|
||||
assert!(e.message.contains("not available remotely"), "{e:?}");
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
//! Repository-scoped patch operations must never fall back to the primary root.
|
||||
use ide_core::NullSink;
|
||||
use ide_host::{Host, HostConfig, ops::vcs};
|
||||
use std::{path::Path, process::Command, sync::Arc, time::Duration};
|
||||
|
||||
fn git(root: &Path, args: &[&str]) -> String {
|
||||
let output = Command::new("git").current_dir(root).args(args).output().unwrap();
|
||||
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
String::from_utf8(output.stdout).unwrap().trim().to_owned()
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn patches_read_and_write_the_requested_repository() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let base = dunce::canonicalize(temp.path()).unwrap();
|
||||
let project_root = base.join("project");
|
||||
let roots = [project_root.join("front"), project_root.join("back")];
|
||||
let mut revisions = Vec::new();
|
||||
for (i, root) in roots.iter().enumerate() {
|
||||
std::fs::create_dir_all(root).unwrap();
|
||||
git(root, &["init", "-b", "main"]);
|
||||
git(root, &["config", "user.name", "Test"]);
|
||||
git(root, &["config", "user.email", "[email protected]"]);
|
||||
std::fs::write(root.join("same.txt"), format!("root {i}\n")).unwrap();
|
||||
git(root, &["add", "."]);
|
||||
git(root, &["commit", "-m", "initial"]);
|
||||
std::fs::write(root.join("same.txt"), format!("changed {i}\n")).unwrap();
|
||||
git(root, &["commit", "-am", "change"]);
|
||||
revisions.push(git(root, &["rev-parse", "HEAD"]));
|
||||
}
|
||||
let host = Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
data_dir: base.join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(NullSink)),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
});
|
||||
let project = host.open_project("main", &project_root).unwrap();
|
||||
for _ in 0..500 {
|
||||
if project.repos().all().len() == 2 {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
assert_eq!(project.repos().all().len(), 2);
|
||||
for (i, root) in roots.iter().enumerate() {
|
||||
let patch = vcs::vcs_create_patch(
|
||||
&host,
|
||||
"main",
|
||||
Some(root.clone()),
|
||||
vcs::PatchSource::Commits { commits: vec![revisions[i].clone()], reverse: true },
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(patch.contains(&format!("-changed {i}")));
|
||||
assert_eq!(vcs::vcs_patch_preview(&host, "main", Some(root.clone()), patch.clone()).await.unwrap().len(), 1);
|
||||
vcs::vcs_apply_patch(&host, "main", Some(root.clone()), patch).await.unwrap();
|
||||
assert_eq!(std::fs::read_to_string(root.join("same.txt")).unwrap(), format!("root {i}\n"));
|
||||
}
|
||||
assert!(vcs::vcs_apply_patch(&host, "main", Some(base.join("outside")), String::new()).await.is_err());
|
||||
host.close_project("main");
|
||||
}
|
||||
@@ -218,17 +218,8 @@ fn user_did_open(server: &Server, run: &Running, path: &Path, uri: &lsp::Uri, pa
|
||||
}
|
||||
}
|
||||
|
||||
/// No target for `feature`: names the running servers that lack it, so the UI can
|
||||
/// tell "the server can't do this" from "no server is running" (the UI's
|
||||
/// `unavailableText` matches the "… doesn't support …" wording).
|
||||
fn unsupported(servers: &[Arc<Server>], feature: &str) -> IdeError {
|
||||
let running: Vec<&str> = servers.iter().filter(|s| s.running().is_some()).map(|s| s.name.as_str()).collect();
|
||||
if running.is_empty() {
|
||||
IdeError::unavailable(format!("no running language server supports {feature}"))
|
||||
} else {
|
||||
let servers = running.join(", ");
|
||||
IdeError::unavailable(format!("{servers} doesn't support {feature}"))
|
||||
}
|
||||
fn unsupported(feature: &str) -> IdeError {
|
||||
IdeError::unavailable(format!("no running language server supports {feature}"))
|
||||
}
|
||||
|
||||
/// Folds per-server results: any success wins over errors; errors surface only
|
||||
@@ -839,7 +830,7 @@ impl LspManager {
|
||||
let snap = self.snapshot(doc)?;
|
||||
let targets = self.ready_targets(&snap.servers, pred).await;
|
||||
if targets.is_empty() {
|
||||
return Err(unsupported(&snap.servers, feature));
|
||||
return Err(unsupported(feature));
|
||||
}
|
||||
Ok((snap, targets))
|
||||
}
|
||||
@@ -1192,7 +1183,7 @@ impl LspManager {
|
||||
let servers: Vec<Arc<Server>> = self.servers.lock().clone();
|
||||
let targets = self.ready_targets(&servers, |c| c.has("workspaceSymbolProvider")).await;
|
||||
if targets.is_empty() {
|
||||
return Err(unsupported(&servers, "workspace symbols"));
|
||||
return Err(unsupported("workspace symbols"));
|
||||
}
|
||||
let requests = targets.iter().map(|t| {
|
||||
let cancel = &cancel;
|
||||
|
||||
@@ -520,20 +520,6 @@ async fn inline_completion_only_from_servers_that_advertise_it() {
|
||||
assert!(plain.requested("textDocument/inlineCompletion").is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn unsupported_feature_names_the_running_servers_that_lack_it() {
|
||||
let h = harness(&[("plain", incremental_caps()), ("other", json!({ "textDocumentSync": 2 }))]);
|
||||
let (doc, _) = h.open("a.mk", "let x = fo");
|
||||
for name in ["plain", "other"] {
|
||||
h.wait_status(name, ServerStatus::Ready).await;
|
||||
}
|
||||
let e = h.mgr.inline_completion(doc, Pos::new(0, 10), false, Default::default()).await.unwrap_err();
|
||||
assert_eq!(e.kind, ide_core::error::ErrorKind::Unavailable);
|
||||
assert_eq!(e.message, "plain, other doesn't support inline completion");
|
||||
let e = h.mgr.type_hierarchy(doc, Pos::new(0, 4), HierarchyDirection::Incoming).await.unwrap_err();
|
||||
assert_eq!(e.message, "plain, other doesn't support type hierarchy");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn diagnostics_become_events_and_feed_code_actions() {
|
||||
let h = harness(&[("mock", incremental_caps())]);
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
//! VM options for the JVMs a Gradle build forks (`bootRun`, `run`, `test`): Gradle has no
|
||||
//! command-line switch for them, so a launch passes an init script that adds them to every
|
||||
//! `JavaExec` and `Test` task (`--init-script <file>`). Gradle's own JVM is left alone.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Groovy string literal (single-quoted, `\` and `'` escaped).
|
||||
fn groovy_string(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len() + 2);
|
||||
out.push('\'');
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'\\' => out.push_str("\\\\"),
|
||||
'\'' => out.push_str("\\'"),
|
||||
'\n' => out.push_str("\\n"),
|
||||
'\r' => out.push_str("\\r"),
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out.push('\'');
|
||||
out
|
||||
}
|
||||
|
||||
/// The init script adding `jvm_args` (one argument each) to the forked JVMs.
|
||||
pub fn init_script(jvm_args: &[String]) -> String {
|
||||
let list = jvm_args.iter().map(|a| groovy_string(a)).collect::<Vec<_>>().join(", ");
|
||||
format!(
|
||||
"// Written by OpenIDEA: VM options of the run configuration.\n\
|
||||
allprojects {{\n \
|
||||
tasks.withType(JavaExec).configureEach {{ jvmArgs([{list}]) }}\n \
|
||||
tasks.withType(Test).configureEach {{ jvmArgs([{list}]) }}\n\
|
||||
}}\n"
|
||||
)
|
||||
}
|
||||
|
||||
/// Where the scripts are written.
|
||||
pub fn default_dir() -> PathBuf {
|
||||
std::env::temp_dir().join("openidea-gradle")
|
||||
}
|
||||
|
||||
/// Writes the script for `jvm_args` under `dir` (named by its content, so launches share
|
||||
/// it and nothing needs deleting) and returns its path.
|
||||
pub fn write_init_script(dir: &Path, jvm_args: &[String]) -> std::io::Result<PathBuf> {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let text = init_script(jvm_args);
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
text.hash(&mut h);
|
||||
let file = dir.join(format!("jvm-args-{:016x}.init.gradle", h.finish()));
|
||||
if std::fs::read_to_string(&file).is_ok_and(|t| t == text) {
|
||||
return Ok(file);
|
||||
}
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let tmp = file.with_extension(format!("{}.tmp", std::process::id()));
|
||||
std::fs::write(&tmp, &text)?;
|
||||
std::fs::rename(&tmp, &file).inspect_err(|_| {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
})?;
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn args(a: &[&str]) -> Vec<String> {
|
||||
a.iter().map(|s| (*s).to_owned()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adds_each_argument_to_java_exec_and_test_tasks() {
|
||||
let script = init_script(&args(&["-Ddemo.flag=on", "--add-opens", "java.base/java.lang=ALL-UNNAMED"]));
|
||||
let list = "['-Ddemo.flag=on', '--add-opens', 'java.base/java.lang=ALL-UNNAMED']";
|
||||
assert!(script.contains(&format!("tasks.withType(JavaExec).configureEach {{ jvmArgs({list}) }}")), "{script}");
|
||||
assert!(script.contains(&format!("tasks.withType(Test).configureEach {{ jvmArgs({list}) }}")), "{script}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escapes_quotes_backslashes_and_keeps_spaces_and_dollars() {
|
||||
let script = init_script(&args(&[r"-Dpath=C:\Program Files\app", "-Dq=it's", "-Dv=${x}"]));
|
||||
assert!(script.contains(r"'-Dpath=C:\\Program Files\\app'"), "{script}");
|
||||
assert!(script.contains(r"'-Dq=it\'s'"), "{script}");
|
||||
// Single-quoted Groovy strings don't interpolate.
|
||||
assert!(script.contains("'-Dv=${x}'"), "{script}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writes_one_file_per_content() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let a = write_init_script(dir.path(), &args(&["-Xmx1g"])).unwrap();
|
||||
let again = write_init_script(dir.path(), &args(&["-Xmx1g"])).unwrap();
|
||||
let b = write_init_script(dir.path(), &args(&["-Xmx2g"])).unwrap();
|
||||
assert_eq!(a, again);
|
||||
assert_ne!(a, b);
|
||||
assert_eq!(std::fs::read_to_string(&a).unwrap(), init_script(&args(&["-Xmx1g"])));
|
||||
assert!(a.to_string_lossy().ends_with(".init.gradle"));
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,10 @@
|
||||
mod config;
|
||||
mod detect;
|
||||
pub mod envfile;
|
||||
pub mod gradle_jvm;
|
||||
pub mod java_cmdline;
|
||||
pub mod junit;
|
||||
mod output_encoding;
|
||||
mod process;
|
||||
pub mod python_path;
|
||||
mod utf8;
|
||||
pub mod which;
|
||||
pub mod wrap;
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
//! Makes the output of JVM and Python processes match the console's encoding.
|
||||
//!
|
||||
//! On Windows both write piped output in the ANSI code page (GBK on a Chinese
|
||||
//! system: `javac` errors, Maven / Gradle logs, `print`), while the console decodes
|
||||
//! the configured encoding (UTF-8 by default). They are told which one to use:
|
||||
//! `java` through `stdout.encoding` / `stderr.encoding` (JDK 19+) and their
|
||||
//! `sun.*` forerunners (JDK 8–18), Maven and Gradle through `MAVEN_OPTS` /
|
||||
//! `GRADLE_OPTS`, Python through `PYTHONIOENCODING`.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
const JAVA_PROPERTIES: &[&str] = &["stdout.encoding", "stderr.encoding", "sun.stdout.encoding", "sun.stderr.encoding"];
|
||||
|
||||
/// What a launch needs: `-D` options to put first on a `java` command line, and
|
||||
/// environment variables to set.
|
||||
#[derive(Debug, Default, PartialEq, Eq)]
|
||||
pub struct EncodingFix {
|
||||
pub java_args: Vec<String>,
|
||||
pub env: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
fn is_utf8(encoding: &str) -> bool {
|
||||
matches!(encoding.to_ascii_lowercase().replace('_', "-").as_str(), "utf-8" | "utf8")
|
||||
}
|
||||
|
||||
fn java_options(encoding: &str) -> Vec<String> {
|
||||
JAVA_PROPERTIES.iter().map(|p| format!("-D{p}={encoding}")).collect()
|
||||
}
|
||||
|
||||
fn sets_java_encoding(text: &str) -> bool {
|
||||
JAVA_PROPERTIES.iter().any(|p| text.contains(&format!("-D{p}=")))
|
||||
}
|
||||
|
||||
/// The options for a launch of `program` (`args`, environment `vars` over the
|
||||
/// `inherited` one) whose output is decoded as `encoding`; nothing when the
|
||||
/// process already writes it (UTF-8 off Windows) or the user chose the options.
|
||||
pub fn fix(
|
||||
program: &str,
|
||||
args: &[String],
|
||||
vars: &BTreeMap<String, String>,
|
||||
inherited: impl Fn(&str) -> Option<String>,
|
||||
encoding: &str,
|
||||
windows: bool,
|
||||
) -> EncodingFix {
|
||||
let encoding = encoding.trim();
|
||||
let mut out = EncodingFix::default();
|
||||
if encoding.is_empty() || (!windows && is_utf8(encoding)) {
|
||||
return out;
|
||||
}
|
||||
let current = |key: &str| vars.get(key).cloned().or_else(|| inherited(key));
|
||||
let name = crate::wrap::program_name(program).to_ascii_lowercase();
|
||||
match name.as_str() {
|
||||
"java" | "javaw" => {
|
||||
if !args.iter().any(|a| sets_java_encoding(a))
|
||||
&& !current("JDK_JAVA_OPTIONS").is_some_and(|o| sets_java_encoding(&o))
|
||||
{
|
||||
out.java_args = java_options(encoding);
|
||||
}
|
||||
}
|
||||
"mvn" | "mvnw" | "mvnd" | "gradle" | "gradlew" => {
|
||||
let key = if name.starts_with("gradle") { "GRADLE_OPTS" } else { "MAVEN_OPTS" };
|
||||
let opts = current(key).unwrap_or_default();
|
||||
if !sets_java_encoding(&opts) {
|
||||
let added = java_options(encoding).join(" ");
|
||||
let value = if opts.trim().is_empty() { added } else { format!("{} {added}", opts.trim()) };
|
||||
out.env.push((key.to_owned(), value));
|
||||
}
|
||||
}
|
||||
_ if is_python(&name) && current("PYTHONIOENCODING").is_none_or(|v| v.trim().is_empty()) => {
|
||||
out.env.push(("PYTHONIOENCODING".to_owned(), encoding.to_owned()));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `python`, `python3`, `python3.12`, `py`, and the runners that start one.
|
||||
fn is_python(name: &str) -> bool {
|
||||
name == "python"
|
||||
|| name == "py"
|
||||
|| name.strip_prefix("python").is_some_and(|v| v.chars().all(|c| c.is_ascii_digit() || c == '.'))
|
||||
|| matches!(name, "uv" | "poetry" | "pdm" | "pytest" | "pipenv")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn run(program: &str, args: &[&str], vars: &[(&str, &str)], encoding: &str, windows: bool) -> EncodingFix {
|
||||
let args: Vec<String> = args.iter().map(|a| (*a).to_owned()).collect();
|
||||
let vars: BTreeMap<String, String> = vars.iter().map(|(k, v)| ((*k).to_owned(), (*v).to_owned())).collect();
|
||||
fix(program, &args, &vars, |_| None, encoding, windows)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn java_launches_write_the_console_encoding_on_windows() {
|
||||
let expected = [
|
||||
"-Dstdout.encoding=UTF-8",
|
||||
"-Dstderr.encoding=UTF-8",
|
||||
"-Dsun.stdout.encoding=UTF-8",
|
||||
"-Dsun.stderr.encoding=UTF-8",
|
||||
];
|
||||
// The reported case: a source-file launch through the Oracle `javapath` shim.
|
||||
let java = "C:/Program Files/Common Files/Oracle/Java/javapath/java.exe";
|
||||
for program in [java, "java", "javaw.exe", r"C:\jdk\bin\java.exe"] {
|
||||
let f = run(program, &["Main.java"], &[], "UTF-8", true);
|
||||
assert_eq!(f.java_args, expected, "{program}");
|
||||
assert!(f.env.is_empty());
|
||||
}
|
||||
assert_eq!(run("java", &["-cp", "x", "a.Main"], &[], "GBK", true).java_args[0], "-Dstdout.encoding=GBK");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_changes_where_output_already_matches() {
|
||||
for (program, args) in [("java", vec!["Main.java"]), ("mvn", vec!["package"]), ("python3", vec!["a.py"])] {
|
||||
assert_eq!(run(program, &args, &[], "UTF-8", false), EncodingFix::default(), "{program}");
|
||||
assert_eq!(run(program, &args, &[], "utf8", false), EncodingFix::default(), "{program}");
|
||||
assert_eq!(run(program, &args, &[], "", true), EncodingFix::default(), "{program}");
|
||||
}
|
||||
for program in ["node", "cargo", "go", "npm.cmd", "cmd.exe"] {
|
||||
assert_eq!(run(program, &[], &[], "UTF-8", true), EncodingFix::default(), "{program}");
|
||||
}
|
||||
// A console set to another encoding elsewhere still gets it.
|
||||
assert_eq!(run("java", &["Main"], &[], "GBK", false).java_args.len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options_chosen_by_the_user_win() {
|
||||
assert!(run("java", &["-Dstdout.encoding=GBK", "Main"], &[], "UTF-8", true).java_args.is_empty());
|
||||
assert!(
|
||||
run("java", &["Main"], &[("JDK_JAVA_OPTIONS", "-Dsun.stdout.encoding=GBK")], "UTF-8", true)
|
||||
.java_args
|
||||
.is_empty()
|
||||
);
|
||||
assert!(run("mvn.cmd", &[], &[("MAVEN_OPTS", "-Dstdout.encoding=GBK")], "UTF-8", true).env.is_empty());
|
||||
assert!(run("python.exe", &[], &[("PYTHONIOENCODING", "gbk")], "UTF-8", true).env.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_tools_and_python_get_environment_variables() {
|
||||
let opts =
|
||||
"-Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8 -Dsun.stdout.encoding=UTF-8 -Dsun.stderr.encoding=UTF-8";
|
||||
for program in ["mvn.cmd", "mvnw.cmd", r"C:\maven\bin\mvn.cmd"] {
|
||||
assert_eq!(
|
||||
run(program, &["package"], &[], "UTF-8", true).env,
|
||||
[("MAVEN_OPTS".to_owned(), opts.to_owned())]
|
||||
);
|
||||
}
|
||||
for program in ["gradlew.bat", "gradle.bat"] {
|
||||
assert_eq!(run(program, &["build"], &[], "UTF-8", true).env, [("GRADLE_OPTS".to_owned(), opts.to_owned())]);
|
||||
}
|
||||
// Existing options are kept, from the configuration or the inherited environment.
|
||||
let f = run("mvn.cmd", &[], &[("MAVEN_OPTS", "-Xmx1g")], "UTF-8", true);
|
||||
assert_eq!(f.env, [("MAVEN_OPTS".to_owned(), format!("-Xmx1g {opts}"))]);
|
||||
let inherited =
|
||||
fix("gradlew.bat", &[], &BTreeMap::new(), |k| (k == "GRADLE_OPTS").then(|| "-Xmx2g".into()), "UTF-8", true);
|
||||
assert_eq!(inherited.env, [("GRADLE_OPTS".to_owned(), format!("-Xmx2g {opts}"))]);
|
||||
for program in ["python.exe", "python3", "python3.12", "py.exe", "uv.exe", "poetry"] {
|
||||
assert_eq!(
|
||||
run(program, &[], &[], "UTF-8", true).env,
|
||||
[("PYTHONIOENCODING".to_owned(), "UTF-8".to_owned())],
|
||||
"{program}"
|
||||
);
|
||||
}
|
||||
assert_eq!(run("python3", &[], &[], "GBK", false).env, [("PYTHONIOENCODING".to_owned(), "GBK".to_owned())]);
|
||||
}
|
||||
}
|
||||
@@ -77,8 +77,6 @@ impl Entry {
|
||||
pub struct StartOptions {
|
||||
/// Output encoding label (`UTF-8`, `GBK`, `windows-1252`…); `None` = UTF-8.
|
||||
pub encoding: Option<String>,
|
||||
/// Project content and source roots added to Python launch environments.
|
||||
pub python_paths: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -108,8 +106,6 @@ pub(crate) struct Invocation {
|
||||
pub args: Vec<String>,
|
||||
/// Passed verbatim (Windows `cmd /S /C "…"`: cmd does not parse C runtime quoting).
|
||||
pub raw_args: bool,
|
||||
/// A command line run by the platform shell (`display` is the line).
|
||||
pub shell: bool,
|
||||
pub display: String,
|
||||
}
|
||||
|
||||
@@ -145,7 +141,7 @@ pub(crate) fn invocation(config: &RunConfig, cwd: &Path) -> Invocation {
|
||||
display.push(' ');
|
||||
display.push_str("e(a));
|
||||
}
|
||||
Invocation { program, args: config.args.clone(), raw_args: false, shell: false, display }
|
||||
Invocation { program, args: config.args.clone(), raw_args: false, display }
|
||||
}
|
||||
|
||||
/// "Shorten command line" for a `java` launch too long for the platform (see
|
||||
@@ -176,25 +172,22 @@ fn shorten_java(inv: &mut Invocation, config: &RunConfig, cwd: &Path) -> IdeResu
|
||||
}
|
||||
|
||||
/// Tells JVM and Python processes to write the encoding the console decodes (see
|
||||
/// [`ide_core::output_encoding`]); returns the notice line to hide from the output.
|
||||
fn match_console_encoding(
|
||||
inv: &mut Invocation,
|
||||
env: &mut LaunchEnv,
|
||||
config: &RunConfig,
|
||||
options: &StartOptions,
|
||||
) -> Option<String> {
|
||||
use ide_core::output_encoding;
|
||||
/// [`crate::output_encoding`]).
|
||||
fn match_console_encoding(inv: &mut Invocation, env: &mut LaunchEnv, config: &RunConfig, options: &StartOptions) {
|
||||
if inv.raw_args {
|
||||
return;
|
||||
}
|
||||
let inherit = !env.clear;
|
||||
let inherited = |key: &str| if inherit { std::env::var(key).ok() } else { None };
|
||||
let encoding = options.encoding.as_deref().unwrap_or("UTF-8");
|
||||
let fix = if inv.shell {
|
||||
output_encoding::fix_command_line(&inv.display, &env.vars, inherited, encoding, cfg!(windows))
|
||||
} else {
|
||||
let program = inv.program.to_string_lossy();
|
||||
output_encoding::fix(&program, &inv.args, &env.vars, inherited, encoding, cfg!(windows))
|
||||
};
|
||||
let fix = crate::output_encoding::fix(
|
||||
&inv.program.to_string_lossy(),
|
||||
&inv.args,
|
||||
&env.vars,
|
||||
|key| if inherit { std::env::var(key).ok() } else { None },
|
||||
options.encoding.as_deref().unwrap_or("UTF-8"),
|
||||
cfg!(windows),
|
||||
);
|
||||
env.vars.extend(fix.env);
|
||||
if !fix.java_args.is_empty() && !inv.raw_args {
|
||||
if !fix.java_args.is_empty() {
|
||||
inv.args.splice(0..0, fix.java_args);
|
||||
let mut display = quote(config.command.trim());
|
||||
for a in &inv.args {
|
||||
@@ -203,7 +196,6 @@ fn match_console_encoding(
|
||||
}
|
||||
inv.display = display;
|
||||
}
|
||||
fix.notice
|
||||
}
|
||||
|
||||
/// A launch's pathing jar / argfile, deleted when dropped (the process exited, or never started).
|
||||
@@ -225,7 +217,6 @@ fn shell_invocation(command: &str, windows: bool, comspec: Option<String>) -> In
|
||||
program: comspec.filter(|c| !c.is_empty()).unwrap_or_else(|| "cmd.exe".into()).into(),
|
||||
args: vec!["/D".into(), "/S".into(), "/C".into(), format!("\"{command}\"")],
|
||||
raw_args: true,
|
||||
shell: true,
|
||||
display: command.into(),
|
||||
};
|
||||
}
|
||||
@@ -233,7 +224,6 @@ fn shell_invocation(command: &str, windows: bool, comspec: Option<String>) -> In
|
||||
program: "/bin/sh".into(),
|
||||
args: vec!["-c".into(), command.into()],
|
||||
raw_args: false,
|
||||
shell: true,
|
||||
display: command.into(),
|
||||
}
|
||||
}
|
||||
@@ -313,9 +303,6 @@ impl ProcessManager {
|
||||
return Err(IdeError::invalid("input cannot be redirected from a file when emulating a terminal"));
|
||||
}
|
||||
let mut env = launch_env(root, config)?;
|
||||
if matches!(config.kind.as_str(), "python" | "pytest") {
|
||||
crate::python_path::extend(&mut env.vars, &options.python_paths, !env.clear)?;
|
||||
}
|
||||
let on_output: OutputFn = Arc::from(on_output);
|
||||
let on_output = match extras.output_file.as_deref() {
|
||||
Some(file) => tee_output(&resolve_cwd(root, Some(Path::new(file))), on_output)?,
|
||||
@@ -326,9 +313,10 @@ impl ProcessManager {
|
||||
cwd: cwd.clone(),
|
||||
env: env.clone(),
|
||||
});
|
||||
// A terminal re-encodes output itself; pipes carry the process's bytes as they are.
|
||||
let notice =
|
||||
if extras.emulate_terminal { None } else { match_console_encoding(&mut inv, &mut env, config, options) };
|
||||
if !extras.emulate_terminal {
|
||||
// A terminal re-encodes output itself; pipes carry the process's bytes as they are.
|
||||
match_console_encoding(&mut inv, &mut env, config, options);
|
||||
}
|
||||
let temp = shorten_java(&mut inv, config, &cwd)?;
|
||||
if extras.emulate_terminal {
|
||||
return self.start_pty(config, &inv, &cwd, &env, stop, options, sink, on_output, temp);
|
||||
@@ -403,7 +391,7 @@ impl ProcessManager {
|
||||
if let Some(err) = stderr {
|
||||
spawn_reader(id, Stream::Stderr, err, tx.clone())?;
|
||||
}
|
||||
spawn_pump(rx, on_output, entry.clone(), sink, options.encoding.clone(), notice)?;
|
||||
spawn_pump(rx, on_output, entry.clone(), sink, options.encoding.clone())?;
|
||||
Ok(())
|
||||
})();
|
||||
if let Err(e) = result {
|
||||
@@ -496,7 +484,7 @@ impl ProcessManager {
|
||||
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let result = spawn_reader(id, Stream::Stdout, reader, tx.clone())
|
||||
.and_then(|_| spawn_pump(rx, on_output, entry.clone(), sink, options.encoding.clone(), None));
|
||||
.and_then(|_| spawn_pump(rx, on_output, entry.clone(), sink, options.encoding.clone()));
|
||||
if let Err(e) = result {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
@@ -819,16 +807,10 @@ fn spawn_pump(
|
||||
entry: Arc<Entry>,
|
||||
sink: SharedSink,
|
||||
encoding: Option<String>,
|
||||
notice: Option<String>,
|
||||
) -> std::io::Result<()> {
|
||||
std::thread::Builder::new().name(format!("openidea-run-pump-{}", entry.id.0)).spawn(move || {
|
||||
let mut out_dec = OutputDecoder::for_label(encoding.as_deref());
|
||||
let mut err_dec = OutputDecoder::for_label(encoding.as_deref());
|
||||
// "Picked up JAVA_TOOL_OPTIONS: …" from every JVM given the console encoding, on
|
||||
// either stream (`spring-boot:run` copies its fork's stderr to Maven's stdout).
|
||||
let filter = || notice.clone().map(ide_core::output_encoding::NoticeFilter::new);
|
||||
let (mut out_notice, mut err_notice) = (filter(), filter());
|
||||
let mut decoded = String::new();
|
||||
let mut pending = String::new();
|
||||
let mut pending_stream = Stream::Stdout;
|
||||
let mut batch_started: Option<Instant> = None;
|
||||
@@ -858,18 +840,8 @@ fn spawn_pump(
|
||||
flush(&mut pending, pending_stream, &mut batch_started);
|
||||
pending_stream = stream;
|
||||
}
|
||||
let (dec, notice) = match stream {
|
||||
Stream::Stdout => (&mut out_dec, &mut out_notice),
|
||||
Stream::Stderr => (&mut err_dec, &mut err_notice),
|
||||
};
|
||||
match notice {
|
||||
Some(filter) => {
|
||||
dec.decode_into(&bytes, &mut decoded);
|
||||
filter.filter(&decoded, &mut pending);
|
||||
decoded.clear();
|
||||
}
|
||||
None => dec.decode_into(&bytes, &mut pending),
|
||||
}
|
||||
let dec = if stream == Stream::Stdout { &mut out_dec } else { &mut err_dec };
|
||||
dec.decode_into(&bytes, &mut pending);
|
||||
if !pending.is_empty() && batch_started.is_none() {
|
||||
batch_started = Some(Instant::now());
|
||||
}
|
||||
@@ -893,15 +865,8 @@ fn spawn_pump(
|
||||
}
|
||||
}
|
||||
flush(&mut pending, pending_stream, &mut batch_started);
|
||||
for (stream, dec, notice) in
|
||||
[(Stream::Stdout, &mut out_dec, &mut out_notice), (Stream::Stderr, &mut err_dec, &mut err_notice)]
|
||||
{
|
||||
let mut rest = dec.finish();
|
||||
if let Some(filter) = notice {
|
||||
let tail = std::mem::take(&mut rest);
|
||||
filter.filter(&tail, &mut rest);
|
||||
rest.push_str(&filter.finish());
|
||||
}
|
||||
for (stream, dec) in [(Stream::Stdout, &mut out_dec), (Stream::Stderr, &mut err_dec)] {
|
||||
let rest = dec.finish();
|
||||
if !rest.is_empty() {
|
||||
on_output(chunk(stream, rest));
|
||||
}
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
//! Python imports see the same content/source roots as the IDE.
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub fn extend(env: &mut BTreeMap<String, String>, roots: &[PathBuf], inherit: bool) -> IdeResult<()> {
|
||||
if roots.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let inherited = if inherit { crate::system_env().remove("PYTHONPATH") } else { None };
|
||||
let existing = env.get("PYTHONPATH").or(inherited.as_ref());
|
||||
let mut paths = Vec::new();
|
||||
for root in roots {
|
||||
if !paths.contains(root) {
|
||||
paths.push(root.clone());
|
||||
}
|
||||
}
|
||||
if let Some(existing) = existing {
|
||||
for path in std::env::split_paths(existing) {
|
||||
if !paths.contains(&path) {
|
||||
paths.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
let joined =
|
||||
std::env::join_paths(paths).map_err(|e| IdeError::invalid(format!("invalid Python import path: {e}")))?;
|
||||
env.insert("PYTHONPATH".into(), joined.to_string_lossy().into_owned());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn keeps_configured_paths_without_duplicates_or_system_leaks() {
|
||||
let root = std::env::temp_dir().join("python project");
|
||||
let other = root.join("vendor");
|
||||
let value = std::env::join_paths([&other, &root]).unwrap().to_string_lossy().into_owned();
|
||||
let mut env = BTreeMap::from([("PYTHONPATH".into(), value)]);
|
||||
extend(&mut env, std::slice::from_ref(&root), false).unwrap();
|
||||
assert_eq!(std::env::split_paths(&env["PYTHONPATH"]).collect::<Vec<_>>(), [root, other]);
|
||||
}
|
||||
}
|
||||
@@ -169,45 +169,3 @@ fn extras_round_trip_and_local_file() {
|
||||
assert_eq!(load_templates(dir.path()).unwrap(), vec![template]);
|
||||
assert_eq!(load_user_configs(dir.path()).unwrap(), moved);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jvm_options_persist_in_the_project_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let vm = "-Ddemo.flag=on --add-opens java.base/java.lang=ALL-UNNAMED '-Dname=a b'";
|
||||
let args =
|
||||
["-Ddemo.flag=on", "--add-opens", "java.base/java.lang=ALL-UNNAMED", "-Dname=a b", "-cp", "${classpath}"];
|
||||
let mut app = sample().remove(0);
|
||||
app.id = "user:demo".into();
|
||||
app.kind = "spring-boot".into();
|
||||
app.command = "java".into();
|
||||
app.args =
|
||||
args.into_iter().chain(["a.DemoApplication", "--spring.profiles.active=dev"]).map(String::from).collect();
|
||||
app.extras.params = BTreeMap::from([
|
||||
("mainClass".into(), "a.DemoApplication".into()),
|
||||
("vmOptions".into(), vm.into()),
|
||||
("activeProfiles".into(), "dev".into()),
|
||||
("classpath".into(), "${classpath}".into()),
|
||||
]);
|
||||
save_user_configs(dir.path(), std::slice::from_ref(&app)).unwrap();
|
||||
let text = fs::read_to_string(user_configs_path(dir.path())).unwrap();
|
||||
assert!(text.contains(&format!("vmOptions = \"{vm}\"")), "{text}");
|
||||
assert_eq!(load_user_configs(dir.path()).unwrap(), vec![app.clone()]);
|
||||
|
||||
// A file written before the form had these fields loads with its command line only.
|
||||
fs::write(
|
||||
user_configs_path(dir.path()),
|
||||
"[[configurations]]\nid = \"user:old\"\nname = \"Old\"\nkind = \"spring-boot\"\ncommand = \"java\"\n\
|
||||
args = [\"-Xmx1g\", \"-cp\", \"${classpath}\", \"a.App\"]\n",
|
||||
)
|
||||
.unwrap();
|
||||
let old = load_user_configs(dir.path()).unwrap();
|
||||
assert_eq!(old[0].args, ["-Xmx1g", "-cp", "${classpath}", "a.App"]);
|
||||
assert!(old[0].extras.params.is_empty());
|
||||
|
||||
// Templates keep default VM options for new configurations.
|
||||
let mut template = app;
|
||||
template.id = "template:spring-boot".into();
|
||||
template.extras.params = BTreeMap::from([("vmOptions".into(), "-Xmx512m".into())]);
|
||||
ide_run::save_templates(dir.path(), std::slice::from_ref(&template)).unwrap();
|
||||
assert_eq!(ide_run::load_templates(dir.path()).unwrap()[0].extras.params["vmOptions"], "-Xmx512m");
|
||||
}
|
||||
@@ -229,39 +229,6 @@ fn builds_nested_in_a_folder_of_repositories() {
|
||||
assert_eq!(c.iter().filter(|c| c.kind == "spring-boot").count(), 1);
|
||||
}
|
||||
|
||||
/// RuoYi-AI opened from the folder above it (`ites-agent/ruoyi-ai`), or two folders above:
|
||||
/// its application class builds the `SpringApplication` itself.
|
||||
#[test]
|
||||
fn spring_boot_application_of_a_repository_nested_one_or_two_levels_down() {
|
||||
const MAIN: &str = "package org.ruoyi;\n\nimport org.springframework.boot.SpringApplication;\n\
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;\n\n\
|
||||
/**\n * 启动程序\n *\n * @author Lion Li\n */\n@SpringBootApplication\n\
|
||||
public class RuoYiAIApplication {\n\n public static void main(String[] args) {\n\
|
||||
\x20 SpringApplication application = new SpringApplication(RuoYiAIApplication.class);\n\
|
||||
\x20 application.run(args);\n\
|
||||
\x20 System.out.println(\"(♥◠‿◠)ノ゙ RuoYi-AI启动成功 ლ(´ڡ`ლ)\");\n }\n}\n";
|
||||
for prefix in ["", "ites-agent/"] {
|
||||
let file = |p: &str| format!("{prefix}ruoyi-ai/{p}");
|
||||
let files = [
|
||||
(file("pom.xml"), PARENT_POM),
|
||||
(file("ruoyi-admin/pom.xml"), BOOT_POM),
|
||||
(file("ruoyi-admin/src/main/java/org/ruoyi/RuoYiAIApplication.java"), MAIN),
|
||||
(file("ruoyi-admin/src/main/java/org/ruoyi/RuoYiAIServletInitializer.java"), "package org.ruoyi;"),
|
||||
(file("ruoyi-common/pom.xml"), "<project><packaging>pom</packaging></project>"),
|
||||
(file("ruoyi-modules/ruoyi-chat/pom.xml"), "<project/>"),
|
||||
(file("docs/README.md"), "docs"),
|
||||
];
|
||||
let files: Vec<(&str, &str)> = files.iter().map(|(p, t)| (p.as_str(), *t)).collect();
|
||||
let dir = project(&files);
|
||||
let c = detect(dir.path());
|
||||
let module = format!("{prefix}ruoyi-ai/ruoyi-admin");
|
||||
let app = find(&c, &format!("application:{module}:org.ruoyi.RuoYiAIApplication"));
|
||||
assert_eq!((app.name.as_str(), app.kind.as_str()), ("RuoYiAIApplication", "spring-boot"), "{prefix}");
|
||||
assert_eq!(app.cwd.as_deref(), Some(Path::new(&module)));
|
||||
assert!(ids(&c).contains(&format!("maven:spring-boot:run:{prefix}ruoyi-ai").as_str()), "{prefix}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_builds_of_every_kind_and_depth() {
|
||||
let dir = project(&[
|
||||
|
||||
@@ -414,57 +414,3 @@ fn long_java_classpath_is_shortened() {
|
||||
.unwrap_or_else(|| panic!("no pathing jar / argfile in {first}"));
|
||||
assert!(!Path::new(file).exists(), "{file} not deleted");
|
||||
}
|
||||
|
||||
/// A build tool (here a fake `mvn` that forks like `spring-boot:run`) gets the console
|
||||
/// encoding in `JAVA_TOOL_OPTIONS`, and the notice its JVMs print about it is hidden.
|
||||
#[test]
|
||||
fn build_tools_get_the_console_encoding_for_every_jvm() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let h = harness();
|
||||
let mvn = h.root.path().join("mvn");
|
||||
let script = "#!/bin/sh\nnotice() { echo \"Picked up JAVA_TOOL_OPTIONS: $JAVA_TOOL_OPTIONS\" >&2; }\n\
|
||||
notice; echo \"maven $JAVA_TOOL_OPTIONS\"; notice 2>&1; printf 'Consume Time\\243\\272 1 ms\\n' >&2\n";
|
||||
std::fs::write(&mvn, script).unwrap();
|
||||
std::fs::set_permissions(&mvn, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let chunks: Chunks = Arc::default();
|
||||
let sink = chunks.clone();
|
||||
let options = StartOptions { encoding: Some("GBK".into()), ..Default::default() };
|
||||
let config = cfg("t:mvn", mvn.to_str().unwrap(), &["spring-boot:run"]);
|
||||
let info =
|
||||
h.pm.start(h.root.path(), &config, &options, h.sink.clone(), Box::new(move |c| sink.lock().push(c))).unwrap();
|
||||
wait_exit(&h, info.id, Duration::from_secs(10));
|
||||
assert_eq!(
|
||||
text(&chunks, stdout),
|
||||
"maven -Dstdout.encoding=GBK -Dstderr.encoding=GBK -Dsun.stdout.encoding=GBK -Dsun.stderr.encoding=GBK\n"
|
||||
);
|
||||
assert_eq!(text(&chunks, stderr), "Consume Time: 1 ms\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn python_scripts_import_sibling_packages_and_preserve_env_file_paths() {
|
||||
for (kind, source_dir) in [("python", ""), ("pytest", "src")] {
|
||||
let h = harness();
|
||||
let root = h.root.path();
|
||||
let source = root.join(source_dir);
|
||||
for dir in [root.join("app"), source.join("helpers"), root.join("vendor")] {
|
||||
std::fs::create_dir_all(dir).unwrap();
|
||||
}
|
||||
std::fs::write(source.join("helpers/__init__.py"), "def hello(): return 'import OK'\n").unwrap();
|
||||
std::fs::write(root.join("vendor/custom.py"), "VALUE = 'vendor OK'\n").unwrap();
|
||||
std::fs::write(
|
||||
root.join("app/main.py"),
|
||||
"from helpers import hello\nfrom custom import VALUE\nprint(hello(), VALUE)\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(root.join(".env"), format!("PYTHONPATH={}\n", root.join("vendor").display())).unwrap();
|
||||
let mut config = cfg("python-imports", "python3", &["app/main.py"]);
|
||||
config.kind = kind.into();
|
||||
config.extras.env_files = vec![".env".into()];
|
||||
let options = StartOptions { python_paths: vec![root.to_owned(), source], ..Default::default() };
|
||||
let chunks: Chunks = Arc::default();
|
||||
let sink = chunks.clone();
|
||||
let info = h.pm.start(root, &config, &options, h.sink.clone(), Box::new(move |c| sink.lock().push(c))).unwrap();
|
||||
assert_eq!(wait_exit(&h, info.id, Duration::from_secs(10)), Some(0), "{}", text(&chunks, stderr));
|
||||
assert_eq!(text(&chunks, stdout), "import OK vendor OK\n");
|
||||
}
|
||||
}
|
||||
+11
-37
@@ -3,10 +3,9 @@
|
||||
|
||||
use crate::discover::{project_venv_python, workspace_tsdk};
|
||||
use crate::exec::{path_with, search_path};
|
||||
use crate::{angular, godot, pinned, vue};
|
||||
use crate::{angular, godot, vue};
|
||||
use crate::{bin_dir, exe, venv_of};
|
||||
use ide_core::settings::{LanguageServerSettings, Settings};
|
||||
use ide_core::version_managers::Tool;
|
||||
use ide_language::{LanguageConfig, LanguageServerConfig};
|
||||
use ide_run::RunConfig;
|
||||
use serde_json::{Value, json};
|
||||
@@ -29,22 +28,14 @@ pub fn setting_path(root: &Path, s: &str) -> Option<PathBuf> {
|
||||
}
|
||||
|
||||
/// The project interpreter: the configured one, else a project virtualenv
|
||||
/// (`.venv`, `venv`, …) in the project root, else the Python the project pins for mise / asdf.
|
||||
/// (`.venv`, `venv`, …) in `dir` or the project root.
|
||||
pub fn python_interpreter(root: &Path, s: &Settings) -> Option<PathBuf> {
|
||||
setting_path(root, &s.languages.python.interpreter)
|
||||
.or_else(|| project_venv_python(root))
|
||||
.or_else(|| pinned::pinned_python(root))
|
||||
setting_path(root, &s.languages.python.interpreter).or_else(|| project_venv_python(root))
|
||||
}
|
||||
|
||||
/// The configured `node` executable's directory, else the `bin` of the Node.js the
|
||||
/// project pins for mise / asdf.
|
||||
/// The configured `node` executable's directory.
|
||||
pub(crate) fn node_dir(root: &Path, s: &Settings) -> Option<PathBuf> {
|
||||
match setting_path(root, &s.languages.node.interpreter) {
|
||||
Some(p) => p.parent().map(Path::to_path_buf),
|
||||
None => pinned::pinned_install(root, Tool::Node)
|
||||
.map(|(dir, _)| dir.join("bin"))
|
||||
.filter(|bin| bin.join(exe("node")).is_file()),
|
||||
}
|
||||
setting_path(root, &s.languages.node.interpreter).and_then(|p| p.parent().map(Path::to_path_buf))
|
||||
}
|
||||
|
||||
/// The configured Rust toolchain's `bin` directory and rustup toolchain name.
|
||||
@@ -63,10 +54,8 @@ pub(crate) fn rust_bin(root: &Path, s: &Settings) -> Option<(PathBuf, Option<Str
|
||||
Some((bin, toolchain))
|
||||
}
|
||||
|
||||
/// The configured GOROOT, else the Go the project pins for mise / asdf.
|
||||
pub(crate) fn goroot(root: &Path, s: &Settings) -> Option<PathBuf> {
|
||||
setting_path(root, &s.languages.go.goroot)
|
||||
.or_else(|| pinned::pinned_install(root, Tool::Go).and_then(|(dir, _)| pinned::goroot_in_install(&dir)))
|
||||
}
|
||||
|
||||
/// A JDK setting: a JDK home, its `bin` or its `bin/java`.
|
||||
@@ -75,12 +64,11 @@ fn jdk_setting(root: &Path, value: &str) -> Option<Result<crate::java::Jdk, Path
|
||||
Some(p.ancestors().take(3).find_map(crate::java::jdk_at).ok_or(p))
|
||||
}
|
||||
|
||||
/// The project JDK (`languages.java.jdk`), else the JDK the project pins for mise / asdf.
|
||||
/// The project JDK (`languages.java.jdk`).
|
||||
pub fn project_jdk(root: &Path, s: &Settings) -> Option<crate::java::Jdk> {
|
||||
match jdk_setting(root, &s.languages.java.jdk) {
|
||||
Some(jdk) => jdk.inspect_err(|p| tracing::warn!("project JDK {} is not a JDK home", p.display())).ok(),
|
||||
None => pinned::pinned_install(root, Tool::Java).and_then(|(dir, _)| pinned::jdk_in_install(&dir)),
|
||||
}
|
||||
jdk_setting(root, &s.languages.java.jdk)?
|
||||
.inspect_err(|p| tracing::warn!("project JDK {} is not a JDK home", p.display()))
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// The JDK chosen to run jdtls (`languages.java.jdtlsJdk`): `Ok(None)` when not
|
||||
@@ -97,18 +85,10 @@ pub fn jdtls_jdk(root: &Path, s: &Settings) -> Result<Option<crate::java::Jdk>,
|
||||
}
|
||||
|
||||
/// `mvn` of the configured Maven home (`languages.java.maven.home`): the
|
||||
/// executable itself, `<home>/bin/mvn` or `<bin>/mvn`; else of the Maven the project
|
||||
/// pins for mise / asdf when it has no Maven wrapper.
|
||||
/// executable itself, `<home>/bin/mvn` or `<bin>/mvn`.
|
||||
pub fn maven_command(root: &Path, s: &Settings) -> Option<PathBuf> {
|
||||
let p = setting_path(root, &s.languages.java.maven.home)?;
|
||||
let name = if cfg!(windows) { "mvn.cmd" } else { "mvn" };
|
||||
let Some(p) = setting_path(root, &s.languages.java.maven.home) else {
|
||||
if root.join("mvnw").is_file() || root.join("mvnw.cmd").is_file() {
|
||||
return None;
|
||||
}
|
||||
let (dir, _) = pinned::pinned_install(root, Tool::Maven)?;
|
||||
let homes = std::iter::once(dir.clone()).chain(std::fs::read_dir(&dir).ok()?.flatten().map(|e| e.path()));
|
||||
return homes.map(|h| h.join("bin").join(name)).find(|c| c.is_file());
|
||||
};
|
||||
let found = [p.clone(), p.join("bin").join(name), p.join(name)].into_iter().find(|c| c.is_file());
|
||||
if found.is_none() {
|
||||
tracing::warn!("Maven home {} has no {name}", p.display());
|
||||
@@ -478,7 +458,6 @@ pub fn apply_to_run_configs(configs: &mut [RunConfig], root: &Path, s: &Settings
|
||||
let jdk = project_jdk(root, s);
|
||||
let mvn = maven_command(root, s);
|
||||
let mvn_options = maven_options(root, s);
|
||||
let gradle = pinned::pinned_install(root, Tool::Gradle).map(|(dir, _)| dir.join("bin"));
|
||||
|
||||
for c in configs.iter_mut() {
|
||||
resolve_run_command(c, root, s);
|
||||
@@ -550,11 +529,6 @@ pub fn apply_to_run_configs(configs: &mut [RunConfig], root: &Path, s: &Settings
|
||||
if let Some(jdk) = &jdk {
|
||||
c.env.entry("JAVA_HOME".into()).or_insert_with(|| jdk.home.to_string_lossy().into_owned());
|
||||
}
|
||||
if c.kind == "gradle"
|
||||
&& let Some(bin) = gradle.as_ref().filter(|b| b.is_dir())
|
||||
{
|
||||
path_dirs.push(bin.clone());
|
||||
}
|
||||
if c.kind == "maven" && c.detected {
|
||||
if let Some(mvn) = &mvn {
|
||||
c.command = mvn.to_string_lossy().into_owned();
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
//! Finding installed interpreters and toolchains.
|
||||
|
||||
use crate::exec::{query, search_path, which};
|
||||
use crate::{Sdk, SdkKind, bin_dir, exe, home_dir, pinned, venv_of};
|
||||
use ide_core::version_managers::{self, Tool};
|
||||
use crate::{Sdk, SdkKind, bin_dir, exe, home_dir, venv_of};
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
@@ -12,7 +11,7 @@ const VERSION_TIMEOUT: Duration = Duration::from_secs(4);
|
||||
/// Installed SDKs of `kind`, project-local ones (`root`) first. Blocking: runs
|
||||
/// `--version` queries in parallel.
|
||||
pub fn detect(kind: SdkKind, root: Option<&Path>) -> Vec<Sdk> {
|
||||
let mut out = match kind {
|
||||
match kind {
|
||||
SdkKind::Python => python(root),
|
||||
SdkKind::Node => node(),
|
||||
SdkKind::PackageManager => package_managers(),
|
||||
@@ -21,32 +20,7 @@ pub fn detect(kind: SdkKind, root: Option<&Path>) -> Vec<Sdk> {
|
||||
SdkKind::TypeScript => typescript(root),
|
||||
SdkKind::Jdk => jdk(),
|
||||
SdkKind::Maven => maven(),
|
||||
};
|
||||
if let Some(root) = root {
|
||||
pinned_first(&mut out, kind, root);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn tool_of(kind: SdkKind) -> Option<Tool> {
|
||||
match kind {
|
||||
SdkKind::Python => Some(Tool::Python),
|
||||
SdkKind::Node => Some(Tool::Node),
|
||||
SdkKind::Go => Some(Tool::Go),
|
||||
SdkKind::Jdk => Some(Tool::Java),
|
||||
SdkKind::Maven => Some(Tool::Maven),
|
||||
SdkKind::PackageManager | SdkKind::Rust | SdkKind::TypeScript => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves the install the project pins for mise / asdf after the project's own SDKs
|
||||
/// (virtualenvs), ahead of the rest.
|
||||
fn pinned_first(sdks: &mut Vec<Sdk>, kind: SdkKind, root: &Path) {
|
||||
let Some((dir, _)) = tool_of(kind).and_then(|t| pinned::pinned_install(root, t)) else { return };
|
||||
let Some(i) = sdks.iter().position(|s| s.path.starts_with(&dir)) else { return };
|
||||
let at = sdks.iter().take_while(|s| s.source == "venv").count().min(i);
|
||||
let sdk = sdks.remove(i);
|
||||
sdks.insert(at, sdk);
|
||||
}
|
||||
|
||||
/// Runs `f` over `items` on scoped threads (version queries spawn processes).
|
||||
@@ -125,9 +99,6 @@ fn python(root: Option<&Path>) -> Vec<Sdk> {
|
||||
for v in subdirs(&home.join(".local/share/uv/python")) {
|
||||
found.push((v.join(bin_dir()).join(exe("python3")), "uv"));
|
||||
}
|
||||
for (v, manager) in version_managers::installs(Tool::Python) {
|
||||
found.extend(pinned::python_in_install(&v).map(|p| (p, manager)));
|
||||
}
|
||||
let mut conda_roots: Vec<PathBuf> =
|
||||
["miniconda3", "anaconda3", "miniforge3", "mambaforge", "micromamba", "opt/miniconda3"]
|
||||
.iter()
|
||||
@@ -147,7 +118,7 @@ fn python(root: Option<&Path>) -> Vec<Sdk> {
|
||||
}
|
||||
}
|
||||
for dir in std::env::split_paths(&search_path()) {
|
||||
if dir.ends_with(".pyenv/shims") || version_managers::is_shims_dir(&dir) {
|
||||
if dir.ends_with(".pyenv/shims") {
|
||||
continue;
|
||||
}
|
||||
let mut names: Vec<String> = std::fs::read_dir(&dir)
|
||||
@@ -206,7 +177,7 @@ pub(crate) fn python_sdk(path: &Path, source: &str, root: Option<&Path>) -> Opti
|
||||
format!("Python {short} (conda: {})", env.unwrap_or_default())
|
||||
}
|
||||
(None, "pyenv") => format!("Python {short} (pyenv)"),
|
||||
(None, tool @ ("uv" | "mise" | "asdf")) => format!("Python {short} ({tool})"),
|
||||
(None, "uv") => format!("Python {short} (uv)"),
|
||||
_ => format!("Python {short}"),
|
||||
};
|
||||
Some(Sdk {
|
||||
@@ -238,10 +209,8 @@ fn subdirs(dir: &Path) -> Vec<PathBuf> {
|
||||
|
||||
fn node() -> Vec<Sdk> {
|
||||
let mut found: Vec<(PathBuf, &'static str)> = Vec::new();
|
||||
// A mise / asdf shim picks the version by directory: its installs are listed instead.
|
||||
if let Some(p) = which("node").filter(|p| !version_managers::is_shim(p)) {
|
||||
let source = version_managers::manager_of(&p).unwrap_or("path");
|
||||
found.push((p, source));
|
||||
if let Some(p) = which("node") {
|
||||
found.push((p, "path"));
|
||||
}
|
||||
if let Some(home) = home_dir() {
|
||||
for v in subdirs(&home.join(".nvm/versions/node")).into_iter().rev() {
|
||||
@@ -254,9 +223,6 @@ fn node() -> Vec<Sdk> {
|
||||
found.push((v.join("installation/bin").join(exe("node")), "fnm"));
|
||||
}
|
||||
}
|
||||
for (v, manager) in version_managers::installs(Tool::Node).into_iter().rev() {
|
||||
found.push((v.join("bin").join(exe("node")), manager));
|
||||
}
|
||||
for p in ["/opt/homebrew/bin/node", "/usr/local/bin/node", "/usr/bin/node"] {
|
||||
found.push((PathBuf::from(p), "system"));
|
||||
}
|
||||
@@ -357,7 +323,7 @@ fn go() -> Vec<Sdk> {
|
||||
let path_go = which("go");
|
||||
let default_gopath =
|
||||
path_go.as_ref().and_then(|go| query(go, &["env", "GOPATH"], VERSION_TIMEOUT)).map(PathBuf::from);
|
||||
if let Some(go) = path_go.as_ref().filter(|p| !version_managers::is_shim(p))
|
||||
if let Some(go) = &path_go
|
||||
&& let Some(root) = query(go, &["env", "GOROOT"], VERSION_TIMEOUT)
|
||||
{
|
||||
roots.push((PathBuf::from(root), "path"));
|
||||
@@ -375,9 +341,6 @@ fn go() -> Vec<Sdk> {
|
||||
}
|
||||
}
|
||||
}
|
||||
for (d, manager) in version_managers::installs(Tool::Go) {
|
||||
roots.extend(pinned::goroot_in_install(&d).map(|r| (r, manager)));
|
||||
}
|
||||
let mut seen = HashSet::new();
|
||||
roots.retain(|(r, _)| {
|
||||
r.join("bin").join(exe("go")).is_file() && seen.insert(r.canonicalize().unwrap_or_else(|_| r.clone()))
|
||||
@@ -448,7 +411,7 @@ pub fn jdk_sdk(j: &crate::java::Jdk) -> Sdk {
|
||||
} else if crate::java::is_downloaded(&j.home) {
|
||||
"downloaded"
|
||||
} else {
|
||||
version_managers::manager_of(&j.home).unwrap_or("system")
|
||||
"system"
|
||||
};
|
||||
Sdk {
|
||||
path: j.home.clone().into(),
|
||||
@@ -503,10 +466,6 @@ fn maven() -> Vec<Sdk> {
|
||||
for d in subdirs(&home.join(".sdkman/candidates/maven")) {
|
||||
found.push((d, "sdkman"));
|
||||
}
|
||||
for (d, manager) in version_managers::installs(Tool::Maven) {
|
||||
found.extend(subdirs(&d).into_iter().map(|s| (s, manager)));
|
||||
found.push((d, manager));
|
||||
}
|
||||
// Distributions the Maven wrapper downloaded: `dists/apache-maven-3.9.6-bin/<hash>/apache-maven-3.9.6`,
|
||||
// or since wrapper 3.3 the home itself, `dists/apache-maven-3.9.6/<hash>`.
|
||||
for dist in subdirs(&home.join(".m2/wrapper/dists")) {
|
||||
|
||||
@@ -67,7 +67,7 @@ pub fn is_downloaded(home: &Path) -> bool {
|
||||
}
|
||||
|
||||
/// JDK homes in the usual install locations (macOS `JavaVirtualMachines`,
|
||||
/// `/usr/lib/jvm`, SDKMAN, mise, asdf, IDEA's `~/.jdks`, Homebrew, Windows vendor
|
||||
/// `/usr/lib/jvm`, SDKMAN, IDEA's `~/.jdks`, Homebrew, Windows vendor
|
||||
/// directories, the IDE's downloads), `JAVA_HOME` first, then the `java` on `PATH`.
|
||||
fn scan() -> Vec<Jdk> {
|
||||
let mut homes: Vec<PathBuf> = Vec::new();
|
||||
@@ -110,10 +110,6 @@ fn scan() -> Vec<Jdk> {
|
||||
homes.extend([dir.join("Contents/Home"), dir.join("libexec/openjdk.jdk/Contents/Home"), dir]);
|
||||
}
|
||||
}
|
||||
// mise keeps a macOS JDK as its bundle (`<version>/Contents/Home`), asdf flattens it.
|
||||
for (dir, _) in ide_core::version_managers::installs(ide_core::version_managers::Tool::Java) {
|
||||
homes.extend([dir.join("Contents/Home"), dir]);
|
||||
}
|
||||
let mut out: Vec<Jdk> = Vec::new();
|
||||
for h in homes {
|
||||
if let Some(jdk) = jdk_at(&h)
|
||||
@@ -340,7 +336,6 @@ pub fn configure_jdtls(
|
||||
{
|
||||
cfg.args.push(format!("--jvm-arg=-javaagent:{}", jar.to_string_lossy()));
|
||||
}
|
||||
crate::jdtls::add_default_vm_args(&mut cfg.args);
|
||||
let mut all: Vec<Jdk> = Vec::new();
|
||||
for j in project_jdk.into_iter().chain(jdks).chain(server_jdk) {
|
||||
if !all.contains(j) {
|
||||
@@ -534,12 +529,9 @@ mod tests {
|
||||
&bundles,
|
||||
None,
|
||||
);
|
||||
let defaults: Vec<String> = crate::jdtls::DEFAULT_VM_ARGS.iter().map(|a| format!("--jvm-arg={a}")).collect();
|
||||
let n = defaults.len();
|
||||
assert_eq!(cfg.args[..n], defaults, "our JVM options come first");
|
||||
assert_eq!(cfg.args[n], "-data");
|
||||
assert!(cfg.args[n + 1].starts_with("/data/lsp/jdtls/"), "{}", cfg.args[n + 1]);
|
||||
assert_eq!(cfg.args[n + 2], "--jvm-arg=-Djava.import.generatesMetadataFilesAtProjectRoot=false");
|
||||
assert_eq!(cfg.args[0], "-data");
|
||||
assert!(cfg.args[1].starts_with("/data/lsp/jdtls/"), "{}", cfg.args[1]);
|
||||
assert_eq!(cfg.args[2], "--jvm-arg=-Djava.import.generatesMetadataFilesAtProjectRoot=false");
|
||||
let opts = serde_json::to_value(cfg.initialization_options.clone().unwrap()).unwrap();
|
||||
let rt = &opts["settings"]["java"]["configuration"]["runtimes"];
|
||||
assert_eq!(rt[0], json!({ "name": "JavaSE-1.8", "path": "/jdk8" }));
|
||||
@@ -608,8 +600,7 @@ mod tests {
|
||||
assert!(cfg.args.contains(&agent), "{:?}", cfg.args);
|
||||
let mut plain = jdtls();
|
||||
configure_jdtls(&mut plain, Path::new("/p"), Path::new("/data"), &[], None, None, JDTLS_MIN_JAVA, &[], None);
|
||||
let data = |c: &LanguageServerConfig| c.args[c.args.iter().position(|a| a == "-data").unwrap() + 1].clone();
|
||||
assert_ne!(data(&cfg), data(&plain), "a separate workspace, so everything is rebuilt with the agent");
|
||||
assert_ne!(cfg.args[1], plain.args[1], "a separate workspace, so everything is rebuilt with the agent");
|
||||
let before = cfg.clone();
|
||||
configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &[], None, None, JDTLS_MIN_JAVA, &[], Some(jar));
|
||||
assert_eq!(cfg, before, "idempotent");
|
||||
|
||||
+2
-149
@@ -105,80 +105,6 @@ fn split_script_args(args: &[String]) -> ScriptArgs {
|
||||
out
|
||||
}
|
||||
|
||||
/// JVM options jdtls runs with unless the user's set the same thing: a parallel collector
|
||||
/// tuned to give memory back to the OS (resident memory stays near what is live instead of
|
||||
/// growing to the maximum heap), a 2 GB heap limit (the JVM's own is a quarter of physical
|
||||
/// memory), no JVM logging on stdout (the LSP channel), and no scan for installed JDKs at
|
||||
/// start (they are passed as `java.configuration.runtimes`).
|
||||
pub const DEFAULT_VM_ARGS: &[&str] = &[
|
||||
"-XX:+UseParallelGC",
|
||||
"-XX:GCTimeRatio=4",
|
||||
"-XX:AdaptiveSizePolicyWeight=90",
|
||||
"-Dsun.zip.disableMemoryMapping=true",
|
||||
"-Xmx2G",
|
||||
"-Xms100m",
|
||||
"-Xlog:disable",
|
||||
"-DDetectVMInstallationsJob.disabled=true",
|
||||
"-Daether.dependencyCollector.impl=bf",
|
||||
];
|
||||
|
||||
/// What a JVM option sets, so two options setting the same thing can be told apart:
|
||||
/// `-Xmx2G` and `-XX:MaxRAMPercentage=50` both set the heap limit.
|
||||
fn option_key(arg: &str) -> Option<String> {
|
||||
let key = if let Some(name) = arg.strip_prefix("-XX:") {
|
||||
let name = name.trim_start_matches(['+', '-']);
|
||||
let name = name.split_once('=').map_or(name, |(n, _)| n);
|
||||
match name {
|
||||
"MaxHeapSize" | "MaxRAM" | "MaxRAMPercentage" | "MaxRAMFraction" => "Xmx".to_owned(),
|
||||
"InitialHeapSize" | "InitialRAMPercentage" | "InitialRAMFraction" => "Xms".to_owned(),
|
||||
_ if is_collector(name) => "collector".to_owned(),
|
||||
_ => format!("XX:{name}"),
|
||||
}
|
||||
} else if let Some(prop) = arg.strip_prefix("-D") {
|
||||
format!("D:{}", prop.split_once('=').map_or(prop, |(k, _)| k))
|
||||
} else if arg.starts_with("-Xlog") {
|
||||
"Xlog".to_owned()
|
||||
} else if arg.starts_with("-Xmx") {
|
||||
"Xmx".to_owned()
|
||||
} else if arg.starts_with("-Xms") {
|
||||
"Xms".to_owned()
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
Some(key)
|
||||
}
|
||||
|
||||
/// `UseParallelGC`, `UseG1GC`, `UseZGC`, `UseShenandoahGC`, `UseSerialGC`, `UseEpsilonGC`…
|
||||
fn is_collector(name: &str) -> bool {
|
||||
name.starts_with("Use") && name.ends_with("GC") && name.len() > "UseGC".len()
|
||||
}
|
||||
|
||||
/// Our tuning of the parallel collector, meaningless (or rejected) with another one.
|
||||
const COLLECTOR_TUNING: &[&str] = &["GCTimeRatio", "AdaptiveSizePolicyWeight"];
|
||||
|
||||
/// [`DEFAULT_VM_ARGS`] less what `user` (the JVM options already on the command line)
|
||||
/// sets itself. Choosing a collector drops our collector and its tuning altogether.
|
||||
pub fn default_vm_args(user: &[String]) -> Vec<String> {
|
||||
let user_keys: Vec<String> = user.iter().filter_map(|a| option_key(a)).collect();
|
||||
let own_collector = user_keys.iter().any(|k| k == "collector");
|
||||
DEFAULT_VM_ARGS
|
||||
.iter()
|
||||
.filter(|arg| {
|
||||
let key = option_key(arg).expect("default JVM options are keyed");
|
||||
let tuning = COLLECTOR_TUNING.iter().any(|t| key == format!("XX:{t}"));
|
||||
!user_keys.contains(&key) && !(own_collector && (key == "collector" || tuning))
|
||||
})
|
||||
.map(|a| a.to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Adds the [`DEFAULT_VM_ARGS`] the launch doesn't override to `args` (meant for the
|
||||
/// launcher script, so as `--jvm-arg=`), ahead of the user's own.
|
||||
pub fn add_default_vm_args(args: &mut Vec<String>) {
|
||||
let defaults = default_vm_args(&split_script_args(args).jvm);
|
||||
args.splice(0..0, defaults.into_iter().map(|a| format!("--jvm-arg={a}")));
|
||||
}
|
||||
|
||||
/// The JVM command line `bin/jdtls` builds: its system properties and module
|
||||
/// options, the user's `--jvm-arg`s, then the Equinox launcher with `-data`.
|
||||
pub fn java_args(install: &JdtlsInstall, java_major: u32, script_args: &[String]) -> Vec<String> {
|
||||
@@ -201,6 +127,7 @@ pub fn java_args(install: &JdtlsInstall, java_major: u32, script_args: &[String]
|
||||
[
|
||||
"-Dosgi.sharedConfiguration.area.readOnly=true",
|
||||
"-Dosgi.configuration.cascaded=true",
|
||||
"-Xms1G",
|
||||
"--add-modules=ALL-SYSTEM",
|
||||
"--add-opens",
|
||||
"java.base/java.util=ALL-UNNAMED",
|
||||
@@ -295,6 +222,7 @@ mod tests {
|
||||
&format!(r"-Dosgi.sharedConfiguration.area={tools}\config_win"),
|
||||
"-Dosgi.sharedConfiguration.area.readOnly=true",
|
||||
"-Dosgi.configuration.cascaded=true",
|
||||
"-Xms1G",
|
||||
"--add-modules=ALL-SYSTEM",
|
||||
"--add-opens",
|
||||
"java.base/java.util=ALL-UNNAMED",
|
||||
@@ -311,81 +239,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
fn keep(user: &[&str]) -> Vec<String> {
|
||||
default_vm_args(&strings(user))
|
||||
}
|
||||
|
||||
fn without(drop: &[&str]) -> Vec<String> {
|
||||
DEFAULT_VM_ARGS.iter().filter(|a| !drop.contains(a)).map(|a| a.to_string()).collect()
|
||||
}
|
||||
|
||||
const PARALLEL: &[&str] = &["-XX:+UseParallelGC", "-XX:GCTimeRatio=4", "-XX:AdaptiveSizePolicyWeight=90"];
|
||||
|
||||
#[test]
|
||||
fn user_options_override_the_defaults() {
|
||||
assert_eq!(keep(&[]), without(&[]));
|
||||
assert_eq!(keep(&["-javaagent:/opt/lombok.jar", "-Dlog.level=ALL"]), without(&[]), "unrelated options");
|
||||
let cases: &[(&[&str], &[&str])] = &[
|
||||
(&["-Xmx4G"], &["-Xmx2G"]),
|
||||
(&["-Xmx512m"], &["-Xmx2G"]),
|
||||
(&["-XX:MaxHeapSize=3g"], &["-Xmx2G"]),
|
||||
(&["-XX:MaxRAMPercentage=40"], &["-Xmx2G"]),
|
||||
(&["-Xms1G"], &["-Xms100m"]),
|
||||
(&["-XX:InitialHeapSize=256m"], &["-Xms100m"]),
|
||||
(&["-XX:+UseG1GC"], PARALLEL),
|
||||
(&["-XX:+UseZGC", "-XX:+ZGenerational"], PARALLEL),
|
||||
(&["-XX:+UseShenandoahGC"], PARALLEL),
|
||||
(&["-XX:+UseSerialGC"], PARALLEL),
|
||||
(&["-XX:-UseParallelGC"], PARALLEL),
|
||||
(&["-XX:+UseParallelGC"], PARALLEL),
|
||||
(&["-XX:GCTimeRatio=9"], &["-XX:GCTimeRatio=4"]),
|
||||
(&["-Xlog:gc*:file=gc.log"], &["-Xlog:disable"]),
|
||||
(&["-Dsun.zip.disableMemoryMapping=false"], &["-Dsun.zip.disableMemoryMapping=true"]),
|
||||
(&["-Daether.dependencyCollector.impl=df"], &["-Daether.dependencyCollector.impl=bf"]),
|
||||
];
|
||||
for (user, dropped) in cases {
|
||||
assert_eq!(keep(user), without(dropped), "{user:?}");
|
||||
}
|
||||
// Not collectors: they keep ours.
|
||||
assert_eq!(keep(&["-XX:+UseStringDeduplication", "-XX:+UseCompressedOops"]), without(&[]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_go_ahead_of_the_users_options_once() {
|
||||
let user = strings(&["--jvm-arg=-Xmx4G", "-data", "/ws", "--jvm-arg", "-XX:+UseG1GC"]);
|
||||
let mut args = user.clone();
|
||||
add_default_vm_args(&mut args);
|
||||
let dropped: Vec<&str> = PARALLEL.iter().copied().chain(["-Xmx2G"]).collect();
|
||||
let expected: Vec<String> =
|
||||
without(&dropped).into_iter().map(|a| format!("--jvm-arg={a}")).chain(user).collect();
|
||||
assert_eq!(args, expected);
|
||||
add_default_vm_args(&mut args);
|
||||
assert_eq!(args, expected, "idempotent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_command_line_with_defaults() {
|
||||
// JDK 25 on Windows, "-Xmx3G" in the settings' JVM options.
|
||||
let tools = r"C:\Users\张 三\AppData\Roaming\ai.l0ng.openidea\tools\jdtls";
|
||||
let install = JdtlsInstall {
|
||||
launcher: PathBuf::from(format!(r"{tools}\plugins\org.eclipse.equinox.launcher_1.7.0.jar")),
|
||||
config: PathBuf::from(format!(r"{tools}\config_win")),
|
||||
};
|
||||
let mut script = strings(&["-data", r"C:\Users\张 三\ws", "--jvm-arg=-Xmx3G"]);
|
||||
add_default_vm_args(&mut script);
|
||||
let args = java_args(&install, 25, &script);
|
||||
let jar = args.iter().position(|a| a == "-jar").unwrap();
|
||||
let jvm = &args[..jar];
|
||||
for flag in ["-XX:+UseParallelGC", "-XX:GCTimeRatio=4", "-Xms100m", "-Xlog:disable", "-Xmx3G"] {
|
||||
assert!(jvm.contains(&flag.to_owned()), "{flag}: {jvm:?}");
|
||||
}
|
||||
let heap: Vec<&String> = jvm.iter().filter(|a| option_key(a).as_deref() == Some("Xmx")).collect();
|
||||
assert_eq!(heap, ["-Xmx3G"], "one heap limit, the user's");
|
||||
assert!(!jvm.iter().any(|a| a == "-Xms1G"), "{jvm:?}");
|
||||
assert_eq!(args[jar + 1], format!(r"{tools}\plugins\org.eclipse.equinox.launcher_1.7.0.jar"));
|
||||
assert_eq!(args[jar + 2..], strings(&["-data", r"C:\Users\张 三\ws"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unix_command_line() {
|
||||
let install = JdtlsInstall {
|
||||
|
||||
@@ -16,7 +16,6 @@ pub mod libraries;
|
||||
pub mod locate;
|
||||
pub mod lombok;
|
||||
mod npm;
|
||||
pub mod pinned;
|
||||
pub mod python;
|
||||
mod vue;
|
||||
|
||||
|
||||
@@ -1,233 +0,0 @@
|
||||
//! Tool versions a project pins for mise / asdf (`mise.toml`, `.tool-versions`), and the
|
||||
//! installs that match them.
|
||||
|
||||
use ide_core::version_managers::{self, Manager, Tool};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// mise's config files in a directory, the one that wins first; `.tool-versions` (asdf's,
|
||||
/// read by mise too) last.
|
||||
const CONFIG_FILES: [&str; 9] = [
|
||||
"mise.local.toml",
|
||||
".mise.local.toml",
|
||||
"mise.toml",
|
||||
".mise.toml",
|
||||
"mise/config.toml",
|
||||
".mise/config.toml",
|
||||
".config/mise.toml",
|
||||
".config/mise/config.toml",
|
||||
".tool-versions",
|
||||
];
|
||||
|
||||
/// The version of `tool` pinned for `root`: the closest directory from `root` up that
|
||||
/// pins it, then the global config (`~/.config/mise/config.toml`).
|
||||
pub fn pinned_version(root: &Path, tool: Tool) -> Option<String> {
|
||||
pinned_version_in(root, &global_configs(), tool)
|
||||
}
|
||||
|
||||
fn pinned_version_in(root: &Path, global: &[PathBuf], tool: Tool) -> Option<String> {
|
||||
let files = root.ancestors().flat_map(|dir| CONFIG_FILES.iter().map(move |f| dir.join(f)));
|
||||
files.chain(global.iter().cloned()).find_map(|file| {
|
||||
let text = std::fs::read_to_string(&file).ok()?;
|
||||
if file.file_name().is_some_and(|n| n == ".tool-versions") {
|
||||
tool_versions_entry(&text, tool)
|
||||
} else {
|
||||
mise_toml_entry(&text, tool)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn global_configs() -> Vec<PathBuf> {
|
||||
let var = |k: &str| ide_core::process::host_var_os(k).filter(|v| !v.is_empty()).map(PathBuf::from);
|
||||
if let Some(file) = var("MISE_GLOBAL_CONFIG_FILE") {
|
||||
return vec![file];
|
||||
}
|
||||
let dir = var("MISE_CONFIG_DIR")
|
||||
.or_else(|| var("XDG_CONFIG_HOME").map(|d| d.join("mise")))
|
||||
.or_else(|| crate::home_dir().map(|h| h.join(".config/mise")));
|
||||
dir.map(|d| vec![d.join("config.toml")]).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The tool's key under `[tools]`: its name, or with a backend (`core:java`, `asdf:maven`).
|
||||
fn is_key_for(key: &str, tool: Tool) -> bool {
|
||||
let name = key.rsplit_once(':').map_or(key, |(_, n)| n);
|
||||
let name = name.rsplit_once('/').map_or(name, |(_, n)| n);
|
||||
tool.names().contains(&name)
|
||||
}
|
||||
|
||||
/// `node = "20"`, `python = ["3.12", "3.11"]` (the first) or `java = { version = "21" }`.
|
||||
fn mise_toml_entry(text: &str, tool: Tool) -> Option<String> {
|
||||
let doc: toml::Table = toml::from_str(text).ok()?;
|
||||
let tools = doc.get("tools")?.as_table()?;
|
||||
let value = tools.iter().find(|(k, _)| is_key_for(k, tool)).map(|(_, v)| v)?;
|
||||
let first = match value {
|
||||
toml::Value::Array(a) => a.first()?,
|
||||
v => v,
|
||||
};
|
||||
let version = match first {
|
||||
toml::Value::String(s) => s.as_str(),
|
||||
toml::Value::Table(t) => t.get("version")?.as_str()?,
|
||||
_ => return None,
|
||||
};
|
||||
Some(version.trim().to_owned()).filter(|v| !v.is_empty())
|
||||
}
|
||||
|
||||
/// `nodejs 20.11.0 18.19.0` (the first version).
|
||||
fn tool_versions_entry(text: &str, tool: Tool) -> Option<String> {
|
||||
text.lines().find_map(|line| {
|
||||
let line = line.split('#').next()?;
|
||||
let mut words = line.split_whitespace();
|
||||
let name = words.next()?;
|
||||
is_key_for(name, tool).then(|| words.next().map(str::to_owned))?
|
||||
})
|
||||
}
|
||||
|
||||
/// Numbers in a version, for ordering (`temurin-21.0.2+13` → 21, 0, 2, 13).
|
||||
fn version_key(v: &str) -> Vec<u64> {
|
||||
v.split(|c: char| !c.is_ascii_digit()).filter_map(|n| n.parse().ok()).collect()
|
||||
}
|
||||
|
||||
/// Whether the install `dir_name` is what mise resolves `version` to: the same name, or a
|
||||
/// longer one it is a prefix of (`21` → `21.0.2`, `temurin-21` → `temurin-21.0.2+13.0.LTS`).
|
||||
fn matches(dir_name: &str, version: &str) -> bool {
|
||||
dir_name == version || dir_name.strip_prefix(version).is_some_and(|rest| rest.starts_with(['.', '-', '+', '_']))
|
||||
}
|
||||
|
||||
/// The install of `tool` that `version` selects among `managers`' installs.
|
||||
fn install_for(managers: &[Manager], tool: Tool, version: &str) -> Option<(PathBuf, &'static str)> {
|
||||
if let Some(path) = version.strip_prefix("path:") {
|
||||
let path = PathBuf::from(path.trim());
|
||||
return path.is_dir().then_some((path, version_managers::MISE));
|
||||
}
|
||||
managers.iter().find_map(|m| {
|
||||
if let Some(exact) = m.install_named(tool, version) {
|
||||
return Some((dunce::canonicalize(&exact).unwrap_or(exact), m.name));
|
||||
}
|
||||
let installs = m.installs(tool);
|
||||
let name = |p: &PathBuf| p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
let best = if version == "latest" {
|
||||
installs.into_iter().max_by_key(|p| version_key(&name(p)))
|
||||
} else {
|
||||
installs.into_iter().filter(|p| matches(&name(p), version)).max_by_key(|p| version_key(&name(p)))
|
||||
};
|
||||
best.map(|p| (p, m.name))
|
||||
})
|
||||
}
|
||||
|
||||
/// The mise / asdf install of `tool` that `root` pins, with the manager's name.
|
||||
pub fn pinned_install(root: &Path, tool: Tool) -> Option<(PathBuf, &'static str)> {
|
||||
let version = pinned_version(root, tool)?;
|
||||
install_for(&version_managers::managers(), tool, &version)
|
||||
}
|
||||
|
||||
/// The pinned Python's executable.
|
||||
pub(crate) fn pinned_python(root: &Path) -> Option<PathBuf> {
|
||||
let (dir, _) = pinned_install(root, Tool::Python)?;
|
||||
python_in_install(&dir)
|
||||
}
|
||||
|
||||
/// `bin/python3` of a Python install (`python.exe` at its root on Windows).
|
||||
pub(crate) fn python_in_install(dir: &Path) -> Option<PathBuf> {
|
||||
let candidates = if cfg!(windows) {
|
||||
vec![dir.join("python.exe")]
|
||||
} else {
|
||||
vec![dir.join("bin/python3"), dir.join("bin/python")]
|
||||
};
|
||||
candidates.into_iter().find(|p| p.is_file())
|
||||
}
|
||||
|
||||
/// A Go install's GOROOT: the install itself (mise) or its `go` folder (asdf).
|
||||
pub(crate) fn goroot_in_install(dir: &Path) -> Option<PathBuf> {
|
||||
[dir.to_path_buf(), dir.join("go")].into_iter().find(|d| d.join("bin").join(crate::exe("go")).is_file())
|
||||
}
|
||||
|
||||
/// A JDK install's home: the install itself, or `Contents/Home` of a macOS bundle.
|
||||
pub(crate) fn jdk_in_install(dir: &Path) -> Option<crate::java::Jdk> {
|
||||
[dir.join("Contents/Home"), dir.to_path_buf()].iter().find_map(|h| crate::java::jdk_at(h))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn write(path: &Path, text: &str) {
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, text).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_mise_toml_forms() {
|
||||
let toml = r#"
|
||||
[env]
|
||||
NODE_ENV = "dev"
|
||||
[tools]
|
||||
node = "20"
|
||||
python = ["3.12", "3.11"]
|
||||
"core:java" = { version = "temurin-21", os = ["macos"] }
|
||||
"asdf:mise-plugins/mise-maven" = "3.9.6"
|
||||
"#;
|
||||
assert_eq!(mise_toml_entry(toml, Tool::Node).as_deref(), Some("20"));
|
||||
assert_eq!(mise_toml_entry(toml, Tool::Python).as_deref(), Some("3.12"));
|
||||
assert_eq!(mise_toml_entry(toml, Tool::Java).as_deref(), Some("temurin-21"));
|
||||
assert_eq!(mise_toml_entry(toml, Tool::Maven).as_deref(), None, "the plugin is mise-maven, not maven");
|
||||
assert_eq!(mise_toml_entry("[tools]\nmaven = '3.9.6'\n", Tool::Maven).as_deref(), Some("3.9.6"));
|
||||
assert_eq!(mise_toml_entry(toml, Tool::Go), None);
|
||||
assert_eq!(mise_toml_entry("not toml [", Tool::Node), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_tool_versions() {
|
||||
let text = "# pinned\nnodejs 20.11.0 18.19.0\ngolang 1.22.1 # comment\njava\n";
|
||||
assert_eq!(tool_versions_entry(text, Tool::Node).as_deref(), Some("20.11.0"));
|
||||
assert_eq!(tool_versions_entry(text, Tool::Go).as_deref(), Some("1.22.1"));
|
||||
assert_eq!(tool_versions_entry(text, Tool::Java), None);
|
||||
assert_eq!(tool_versions_entry(text, Tool::Python), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_closest_config_wins_then_the_global_one() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let repo = tmp.path().join("repo");
|
||||
let app = repo.join("apps/web");
|
||||
std::fs::create_dir_all(&app).unwrap();
|
||||
let global = tmp.path().join("global/config.toml");
|
||||
write(&global, "[tools]\nnode = 'lts'\npython = '3.13'\ngo = '1.23'\n");
|
||||
write(&repo.join("mise.toml"), "[tools]\nnode = '20'\njava = '21'\n");
|
||||
write(&repo.join(".tool-versions"), "nodejs 18.0.0\npython 3.11.9\n");
|
||||
write(&app.join(".mise.toml"), "[tools]\nnode = '22'\n");
|
||||
let g = [global];
|
||||
assert_eq!(pinned_version_in(&app, &g, Tool::Node).as_deref(), Some("22"));
|
||||
assert_eq!(pinned_version_in(&repo, &g, Tool::Node).as_deref(), Some("20"), "mise.toml over .tool-versions");
|
||||
assert_eq!(pinned_version_in(&app, &g, Tool::Python).as_deref(), Some("3.11.9"));
|
||||
assert_eq!(pinned_version_in(&app, &g, Tool::Java).as_deref(), Some("21"));
|
||||
assert_eq!(pinned_version_in(&app, &g, Tool::Go).as_deref(), Some("1.23"));
|
||||
assert_eq!(pinned_version_in(&app, &g, Tool::Gradle), None);
|
||||
write(&repo.join("mise.local.toml"), "[tools]\nnode = '21'\n");
|
||||
assert_eq!(pinned_version_in(&repo, &g, Tool::Node).as_deref(), Some("21"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefixes_resolve_to_the_newest_matching_install() {
|
||||
assert!(matches("21.0.2", "21"));
|
||||
assert!(matches("temurin-21.0.2+13.0.LTS", "temurin-21"));
|
||||
assert!(!matches("210.0", "21"));
|
||||
assert!(!matches("3.1.0", "3.12"));
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mise = Manager { name: version_managers::MISE, dir: tmp.path().join("mise") };
|
||||
let asdf = Manager { name: version_managers::ASDF, dir: tmp.path().join("asdf") };
|
||||
for v in ["20.9.0", "20.11.1", "22.1.0"] {
|
||||
std::fs::create_dir_all(mise.dir.join("installs/node").join(v)).unwrap();
|
||||
}
|
||||
std::fs::create_dir_all(asdf.dir.join("installs/nodejs/18.19.0")).unwrap();
|
||||
let managers = [mise.clone(), asdf.clone()];
|
||||
let pick = |v: &str| install_for(&managers, Tool::Node, v).map(|(p, m)| (p.file_name().unwrap().to_owned(), m));
|
||||
assert_eq!(pick("20"), Some(("20.11.1".into(), "mise")));
|
||||
assert_eq!(pick("22.1.0"), Some(("22.1.0".into(), "mise")));
|
||||
assert_eq!(pick("latest"), Some(("22.1.0".into(), "mise")));
|
||||
assert_eq!(pick("18"), Some(("18.19.0".into(), "asdf")));
|
||||
assert_eq!(pick("16"), None);
|
||||
let external = tmp.path().join("node-dev");
|
||||
std::fs::create_dir_all(&external).unwrap();
|
||||
let path = format!("path:{}", external.display());
|
||||
assert_eq!(install_for(&managers, Tool::Node, &path), Some((external, "mise")));
|
||||
}
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
//! mise / asdf installs: detection and the project's pinned versions. One test in its
|
||||
//! own binary: it points `MISE_DATA_DIR` / `ASDF_DATA_DIR` at a fake layout.
|
||||
#![cfg(unix)]
|
||||
|
||||
use ide_core::settings::Settings;
|
||||
use ide_run::RunConfig;
|
||||
use ide_sdk::{Sdk, SdkKind, detect};
|
||||
use std::collections::BTreeMap;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
fn write(path: &Path, text: &str) {
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, text).unwrap();
|
||||
}
|
||||
|
||||
/// An executable that prints `out` (a `--version` answer).
|
||||
fn script(path: &Path, out: &str) {
|
||||
write(path, &format!("#!/bin/sh\necho '{out}'\n"));
|
||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
}
|
||||
|
||||
fn jdk(home: &Path, version: &str) {
|
||||
write(&home.join("release"), &format!("IMPLEMENTOR=\"Eclipse Adoptium\"\nJAVA_VERSION=\"{version}\"\n"));
|
||||
script(&home.join("bin/java"), version);
|
||||
}
|
||||
|
||||
fn find<'a>(sdks: &'a [Sdk], dir: &Path) -> &'a Sdk {
|
||||
sdks.iter().find(|s| s.path.starts_with(dir)).unwrap_or_else(|| panic!("{} not in {sdks:#?}", dir.display()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mise_and_asdf_installs_are_detected_and_pinned_versions_used() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let tmp = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let mise = tmp.join("mise");
|
||||
let asdf = tmp.join("asdf");
|
||||
// SAFETY: the only test in this binary; nothing else reads the environment yet.
|
||||
unsafe {
|
||||
std::env::set_var("HOME", tmp.join("home"));
|
||||
std::env::set_var("MISE_DATA_DIR", &mise);
|
||||
std::env::set_var("ASDF_DATA_DIR", &asdf);
|
||||
std::env::set_var("MISE_GLOBAL_CONFIG_FILE", tmp.join("no-global.toml"));
|
||||
std::env::remove_var("JAVA_HOME");
|
||||
}
|
||||
|
||||
let installs = mise.join("installs");
|
||||
jdk(&installs.join("java/21.0.2/Contents/Home"), "21.0.2"); // macOS bundle
|
||||
jdk(&asdf.join("installs/java/temurin-17.0.10+7"), "17.0.10");
|
||||
for v in ["20.11.1", "22.1.0"] {
|
||||
script(&installs.join("node").join(v).join("bin/node"), &format!("v{v}"));
|
||||
}
|
||||
std::os::unix::fs::symlink("./22.1.0", installs.join("node/22")).unwrap();
|
||||
script(&installs.join("python/3.12.1/bin/python3"), "Python 3.12.1");
|
||||
let goroot = asdf.join("installs/golang/1.22.1/go");
|
||||
write(&goroot.join("VERSION"), "go1.22.1\n");
|
||||
script(&goroot.join("bin/go"), "go version go1.22.1");
|
||||
let maven = installs.join("maven/3.9.6");
|
||||
script(&maven.join("bin/mvn"), "Apache Maven 3.9.6");
|
||||
write(&maven.join("lib/maven-core-3.9.6.jar"), "");
|
||||
script(&installs.join("gradle/8.7/bin/gradle"), "Gradle 8.7");
|
||||
|
||||
let project = tmp.join("project");
|
||||
write(
|
||||
&project.join("mise.toml"),
|
||||
"[tools]\njava = '21'\nnode = '20'\npython = '3.12'\nmaven = '3.9'\ngradle = '8.7'\n",
|
||||
);
|
||||
write(&project.join(".tool-versions"), "golang 1.22.1\nnodejs 22.1.0\n");
|
||||
|
||||
let jdks = detect(SdkKind::Jdk, Some(&project));
|
||||
let mise_jdk = find(&jdks, &installs.join("java/21.0.2"));
|
||||
assert_eq!((mise_jdk.source.as_str(), mise_jdk.name.as_str()), ("mise", "Eclipse Adoptium 21.0.2"));
|
||||
assert_eq!(find(&jdks, &asdf.join("installs/java")).source, "asdf");
|
||||
assert_eq!(jdks[0].path, mise_jdk.path, "the pinned JDK first");
|
||||
|
||||
let nodes = detect(SdkKind::Node, Some(&project));
|
||||
assert_eq!(nodes[0].path, installs.join("node/20.11.1/bin/node"), "mise.toml over .tool-versions");
|
||||
assert_eq!(find(&nodes, &installs.join("node/22.1.0")).source, "mise");
|
||||
assert_eq!(nodes.iter().filter(|s| s.source == "mise").count(), 2, "the `22` alias is not listed again");
|
||||
|
||||
let pythons = detect(SdkKind::Python, Some(&project));
|
||||
assert_eq!(find(&pythons, &installs.join("python")).name, "Python 3.12 (mise)");
|
||||
|
||||
let gos = detect(SdkKind::Go, Some(&project));
|
||||
let go = find(&gos, &goroot);
|
||||
assert_eq!((go.source.as_str(), go.version.as_deref()), ("asdf", Some("1.22.1")));
|
||||
|
||||
let mavens = detect(SdkKind::Maven, Some(&project));
|
||||
let mvn = find(&mavens, &maven);
|
||||
assert_eq!((mvn.source.as_str(), mvn.version.as_deref()), ("mise", Some("3.9.6")));
|
||||
|
||||
// Nothing configured in Settings: the pinned versions apply.
|
||||
let s = Settings::default();
|
||||
let jdk = ide_sdk::apply::project_jdk(&project, &s).expect("pinned JDK");
|
||||
assert_eq!((jdk.major, jdk.home.clone()), (21, installs.join("java/21.0.2/Contents/Home")));
|
||||
assert_eq!(ide_sdk::apply::python_interpreter(&project, &s), Some(installs.join("python/3.12.1/bin/python3")));
|
||||
assert_eq!(ide_sdk::apply::maven_command(&project, &s), Some(maven.join("bin/mvn")));
|
||||
|
||||
let run = |kind: &str, command: &str| RunConfig {
|
||||
id: format!("{kind}:x"),
|
||||
name: "x".into(),
|
||||
kind: kind.into(),
|
||||
command: command.into(),
|
||||
args: vec![],
|
||||
cwd: None,
|
||||
env: BTreeMap::new(),
|
||||
detected: true,
|
||||
is_test: false,
|
||||
debug_adapter: None,
|
||||
allow_parallel: false,
|
||||
extras: Default::default(),
|
||||
};
|
||||
let mut configs = [run("npm", "npm"), run("go", "go"), run("gradle", "gradle"), run("maven", "mvn")];
|
||||
ide_sdk::apply::apply_to_run_configs(&mut configs, &project, &s);
|
||||
let path_of = |c: &RunConfig| -> Vec<PathBuf> { std::env::split_paths(&c.env["PATH"]).collect() };
|
||||
assert_eq!(path_of(&configs[0])[0], installs.join("node/20.11.1/bin"));
|
||||
assert_eq!(configs[1].command, goroot.join("bin/go").to_string_lossy());
|
||||
assert_eq!(configs[1].env["GOROOT"], goroot.to_string_lossy());
|
||||
assert_eq!(path_of(&configs[2])[0], installs.join("gradle/8.7/bin"));
|
||||
assert_eq!(configs[2].env["JAVA_HOME"], jdk.home.to_string_lossy());
|
||||
assert_eq!(configs[3].command, maven.join("bin/mvn").to_string_lossy());
|
||||
|
||||
// The project's Maven wrapper keeps precedence over a pinned Maven.
|
||||
script(&project.join("mvnw"), "");
|
||||
assert_eq!(ide_sdk::apply::maven_command(&project, &s), None);
|
||||
|
||||
// A version the project pins but that isn't installed: nothing is made up.
|
||||
write(&project.join("mise.toml"), "[tools]\njava = '25'\n");
|
||||
assert_eq!(ide_sdk::apply::project_jdk(&project, &s), None);
|
||||
}
|
||||
@@ -157,10 +157,8 @@ pub fn extend_process_path(app_data: &Path) {
|
||||
}
|
||||
}
|
||||
|
||||
/// `path` (a `PATH` value) with [`tools_bin_dirs`] in front, those it lacks, and the
|
||||
/// mise / asdf shims when their shell hook didn't put them there.
|
||||
/// `path` (a `PATH` value) with [`tools_bin_dirs`] in front, those it lacks.
|
||||
pub fn path_with_tools(app_data: &Path, path: &std::ffi::OsStr) -> OsString {
|
||||
let path = &ide_core::version_managers::path_with_shims(&ide_core::version_managers::managers(), path);
|
||||
let mut dirs = tools_bin_dirs(app_data);
|
||||
let current: Vec<PathBuf> = if path.is_empty() { Vec::new() } else { std::env::split_paths(path).collect() };
|
||||
dirs.retain(|d| !current.contains(d));
|
||||
|
||||
@@ -8,13 +8,11 @@ license.workspace = true
|
||||
[dependencies]
|
||||
dunce.workspace = true
|
||||
ide-core.workspace = true
|
||||
ignore.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
specta.workspace = true
|
||||
gix = { workspace = true, features = ["sha1", "status", "blob-diff", "revision", "index", "dirwalk", "parallel"] }
|
||||
tracing.workspace = true
|
||||
tempfile.workspace = true
|
||||
which = "8"
|
||||
regex-lite = "0.1"
|
||||
|
||||
|
||||
@@ -1,318 +0,0 @@
|
||||
//! Literal ignore rules for Project view's Git › Add to .gitignore.
|
||||
use crate::{Repo, cli};
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
use std::fs;
|
||||
use std::io::{Read, Write};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum IgnoreDestination {
|
||||
Gitignore,
|
||||
LocalExclude,
|
||||
}
|
||||
|
||||
struct IgnoreRule {
|
||||
pattern: String,
|
||||
relative: PathBuf,
|
||||
directory: bool,
|
||||
}
|
||||
|
||||
/// Validate the whole selection before writing. Each destination is replaced atomically;
|
||||
/// a failure in a later repository may leave earlier destinations updated (safe to retry).
|
||||
/// Never modifies the index or a selected file.
|
||||
pub fn add_to_ignore(paths: &[PathBuf], destination: IgnoreDestination) -> IdeResult<Vec<PathBuf>> {
|
||||
let mut edits: BTreeMap<PathBuf, Vec<IgnoreRule>> = BTreeMap::new();
|
||||
let mut verify = Vec::new();
|
||||
for path in paths {
|
||||
// Validate before filesystem access: APFS cannot even stat invalid UTF-8 names.
|
||||
path.to_str().ok_or_else(|| IdeError::invalid("Path is not UTF-8"))?;
|
||||
if !path.is_absolute() || path.components().any(|c| matches!(c, Component::ParentDir)) {
|
||||
return Err(IdeError::invalid("Select an absolute path without '..'"));
|
||||
}
|
||||
no_symlinks(path)?;
|
||||
let meta = fs::symlink_metadata(path)?;
|
||||
if !meta.is_file() && !meta.is_dir() {
|
||||
return Err(IdeError::invalid("Only regular files and directories can be ignored"));
|
||||
}
|
||||
// A selected nested repository directory is an entry of its enclosing repository.
|
||||
let start = path.parent().ok_or_else(|| IdeError::invalid("Cannot ignore a filesystem root"))?;
|
||||
let repo = Repo::discover(start)?.ok_or_else(|| IdeError::invalid("Selection is not in a Git repository"))?;
|
||||
let rel =
|
||||
path.strip_prefix(repo.root()).map_err(|_| IdeError::invalid("Selection is outside its repository"))?;
|
||||
let literal = pattern(rel, meta.is_dir())?;
|
||||
if rel.components().any(|c| c.as_os_str() == ".git") {
|
||||
return Err(IdeError::invalid("Git administration files cannot be ignored"));
|
||||
}
|
||||
let rel_str = rel.to_str().ok_or_else(|| IdeError::invalid("Path is not UTF-8"))?;
|
||||
let tracked = cli::run(repo.root(), &["--literal-pathspecs", "ls-files", "-z", "--", rel_str], None)?;
|
||||
if !tracked.stdout.is_empty() {
|
||||
return Err(IdeError::invalid(format!(
|
||||
"{} contains tracked files. Ignore rules only affect unversioned files; no files were untracked.",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
// check-ignore rejects --literal-pathspecs; a ./ prefix prevents pathspec
|
||||
// magic in valid filenames such as :(top)private from being interpreted.
|
||||
let literal_query = format!("./{rel_str}");
|
||||
let ignored = cli::run_raw(repo.root(), &["check-ignore", "-q", "--", &literal_query], None)?;
|
||||
match ignored.code {
|
||||
Some(0) => continue,
|
||||
Some(1) => {}
|
||||
_ => return Err(IdeError::invalid(ignored.stdout_str() + &String::from_utf8_lossy(&ignored.stderr))),
|
||||
}
|
||||
verify.push((repo.root().to_path_buf(), rel_str.to_owned()));
|
||||
let (file, rule, base) = match destination {
|
||||
IgnoreDestination::LocalExclude => {
|
||||
let output = cli::run(
|
||||
repo.root(),
|
||||
&["rev-parse", "--path-format=absolute", "--git-path", "info/exclude"],
|
||||
None,
|
||||
)?;
|
||||
// Strip only Git's output terminator; spaces are valid in the path.
|
||||
let raw = output.stdout_str();
|
||||
let file = crate::repo::absolute(Path::new(
|
||||
raw.strip_suffix("\r\n").or_else(|| raw.strip_suffix('\n')).unwrap_or(&raw),
|
||||
))?;
|
||||
let common = crate::repo::absolute(repo.gix()?.common_dir())?;
|
||||
if file != common.join("info/exclude") {
|
||||
return Err(IdeError::invalid("Git returned an unexpected exclude path"));
|
||||
}
|
||||
(file, literal, repo.root())
|
||||
}
|
||||
IgnoreDestination::Gitignore => {
|
||||
let base = start
|
||||
.ancestors()
|
||||
.take_while(|p| p.starts_with(repo.root()))
|
||||
.find(|p| p.join(".gitignore").symlink_metadata().is_ok())
|
||||
.unwrap_or(repo.root());
|
||||
(base.join(".gitignore"), pattern(path.strip_prefix(base).unwrap(), meta.is_dir())?, base)
|
||||
}
|
||||
};
|
||||
no_symlinks(file.parent().unwrap())?;
|
||||
validate_file(&file)?;
|
||||
edits.entry(file).or_default().push(IgnoreRule {
|
||||
pattern: rule,
|
||||
relative: path.strip_prefix(base).unwrap().to_path_buf(),
|
||||
directory: meta.is_dir(),
|
||||
});
|
||||
}
|
||||
let mut changed = Vec::new();
|
||||
for (file, rules) in edits {
|
||||
append(&file, &rules).map_err(|e| {
|
||||
IdeError::new(
|
||||
e.kind,
|
||||
format!(
|
||||
"Could not update {}: {}. Earlier destinations may have succeeded; refresh and retry.",
|
||||
file.display(),
|
||||
e.message
|
||||
),
|
||||
)
|
||||
})?;
|
||||
changed.push(file);
|
||||
}
|
||||
for (root, rel) in verify {
|
||||
if cli::run_raw(&root, &["check-ignore", "-q", "--", &format!("./{rel}")], None)?.code != Some(0) {
|
||||
return Err(IdeError::invalid(format!(
|
||||
"Rule saved, but {rel} is still visible to Git. A higher-priority .gitignore rule may override it; edit that file and retry."
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
fn pattern(path: &Path, directory: bool) -> IdeResult<String> {
|
||||
let mut rule = String::from("/");
|
||||
for (i, part) in path.components().enumerate() {
|
||||
let Component::Normal(part) = part else { return Err(IdeError::invalid("Unsafe ignore path")) };
|
||||
let part = part.to_str().ok_or_else(|| IdeError::invalid("Path is not UTF-8"))?;
|
||||
if part.contains(['\n', '\r', '\0']) {
|
||||
return Err(IdeError::invalid(
|
||||
"Paths containing a newline or NUL cannot be represented safely in an ignore file",
|
||||
));
|
||||
}
|
||||
if i > 0 {
|
||||
rule.push('/');
|
||||
}
|
||||
for ch in part.chars() {
|
||||
if matches!(ch, '\\' | '*' | '?' | '[' | ']' | '#' | '!' | ' ') {
|
||||
rule.push('\\');
|
||||
}
|
||||
rule.push(ch);
|
||||
}
|
||||
}
|
||||
if rule == "/" {
|
||||
return Err(IdeError::invalid("Cannot ignore a repository root"));
|
||||
}
|
||||
if directory {
|
||||
rule.push('/');
|
||||
}
|
||||
Ok(rule)
|
||||
}
|
||||
|
||||
/// Refuse symlinked ancestors too: writing through them could affect another repository.
|
||||
fn no_symlinks(path: &Path) -> IdeResult<()> {
|
||||
for part in path.ancestors() {
|
||||
match fs::symlink_metadata(part) {
|
||||
Ok(m) if m.file_type().is_symlink() => {
|
||||
return Err(IdeError::invalid(format!("Refusing symlink path: {}", part.display())));
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_file(file: &Path) -> IdeResult<()> {
|
||||
match fs::symlink_metadata(file) {
|
||||
Ok(m) if !m.is_file() || m.file_type().is_symlink() => {
|
||||
Err(IdeError::invalid("Ignore destination must be a regular file, not a symlink"))
|
||||
}
|
||||
Ok(m) if m.permissions().readonly() => {
|
||||
Err(IdeError::invalid(format!("{} is read-only; make it writable and retry", file.display())))
|
||||
}
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq)]
|
||||
struct Snapshot {
|
||||
bytes: Vec<u8>,
|
||||
modified: Option<std::time::SystemTime>,
|
||||
permissions: fs::Permissions,
|
||||
}
|
||||
|
||||
fn snapshot(file: &Path) -> IdeResult<Option<Snapshot>> {
|
||||
validate_file(file)?;
|
||||
let mut options = fs::OpenOptions::new();
|
||||
options.read(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
options.custom_flags(libc::O_NOFOLLOW);
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::fs::OpenOptionsExt;
|
||||
// Open the reparse point itself rather than following a last-component link.
|
||||
options.custom_flags(0x0020_0000); // FILE_FLAG_OPEN_REPARSE_POINT
|
||||
}
|
||||
match options.open(file) {
|
||||
Ok(mut handle) => {
|
||||
let metadata = handle.metadata()?;
|
||||
if !metadata.is_file() || metadata.file_type().is_symlink() {
|
||||
return Err(IdeError::invalid("Ignore destination must be a regular file"));
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
handle.read_to_end(&mut bytes)?;
|
||||
Ok(Some(Snapshot { bytes, modified: metadata.modified().ok(), permissions: metadata.permissions() }))
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn append(file: &Path, rules: &[IgnoreRule]) -> IdeResult<()> {
|
||||
no_symlinks(file.parent().unwrap())?;
|
||||
fs::create_dir_all(file.parent().unwrap())?;
|
||||
// Conventional sibling lock: create_new never follows a preexisting symlink.
|
||||
let lock_path = file.with_file_name(format!("{}.lock", file.file_name().unwrap().to_string_lossy()));
|
||||
let lock = fs::OpenOptions::new().write(true).create_new(true).open(&lock_path);
|
||||
// Keep lock cleanup scoped to successfully acquired locks only.
|
||||
let _lock = Lock {
|
||||
file: lock.map_err(|_| {
|
||||
IdeError::invalid(format!("{} is locked or not writable; close other writers and retry", file.display()))
|
||||
})?,
|
||||
path: lock_path,
|
||||
};
|
||||
let before = snapshot(file)?;
|
||||
let bytes = before.as_ref().map(|s| s.bytes.as_slice()).unwrap_or_default();
|
||||
let text =
|
||||
std::str::from_utf8(bytes).map_err(|_| IdeError::invalid("Ignore file is not UTF-8; edit it manually"))?;
|
||||
if text.contains('\0') {
|
||||
return Err(IdeError::invalid("Ignore file contains NUL; edit it manually"));
|
||||
}
|
||||
let newline = if text.contains("\r\n") { "\r\n" } else { "\n" };
|
||||
// Identical text earlier in the file may have been overridden by a later
|
||||
// negation. Deduplicate only rules that this file still applies to the path.
|
||||
let mut builder = ignore::gitignore::GitignoreBuilder::new("");
|
||||
for (index, line) in text.lines().enumerate() {
|
||||
let line = if index == 0 { line.trim_start_matches('\u{feff}') } else { line };
|
||||
if let Err(error) = builder.add_line(None, line) {
|
||||
tracing::debug!("{}: {error}", file.display());
|
||||
}
|
||||
}
|
||||
let existing = builder.build().map_err(|e| IdeError::invalid(format!("Cannot parse ignore rules: {e}")))?;
|
||||
let mut requested = HashSet::new();
|
||||
let mut updated = bytes.to_vec();
|
||||
for rule in rules {
|
||||
if !requested.insert(rule.pattern.as_str())
|
||||
|| existing.matched_path_or_any_parents(&rule.relative, rule.directory).is_ignore()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if !updated.is_empty() && !updated.ends_with(b"\n") {
|
||||
updated.extend_from_slice(newline.as_bytes());
|
||||
}
|
||||
updated.extend_from_slice(rule.pattern.as_bytes());
|
||||
updated.extend_from_slice(newline.as_bytes());
|
||||
}
|
||||
if updated == bytes {
|
||||
return Ok(());
|
||||
}
|
||||
let mut temp = tempfile::NamedTempFile::new_in(file.parent().unwrap())?;
|
||||
let permissions = match fs::metadata(file) {
|
||||
Ok(meta) => meta.permissions(),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => _lock.file.metadata()?.permissions(),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
temp.as_file().set_permissions(permissions)?;
|
||||
temp.write_all(&updated)?;
|
||||
temp.as_file().sync_all()?;
|
||||
replace_if_unchanged(file, before, temp)
|
||||
}
|
||||
|
||||
fn replace_if_unchanged(file: &Path, before: Option<Snapshot>, temp: tempfile::NamedTempFile) -> IdeResult<()> {
|
||||
no_symlinks(file.parent().unwrap())?;
|
||||
if snapshot(file)? != before {
|
||||
return Err(IdeError::invalid("Ignore file changed while preparing the update; retry"));
|
||||
}
|
||||
temp.persist(file).map_err(|e| IdeError::from(e.error))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct Lock {
|
||||
file: fs::File,
|
||||
path: PathBuf,
|
||||
}
|
||||
impl Drop for Lock {
|
||||
fn drop(&mut self) {
|
||||
let _ = &self.file;
|
||||
let _ = fs::remove_file(&self.path);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn concurrent_writer_is_detected_without_overwriting_it() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let file = root.join(".gitignore");
|
||||
fs::write(&file, "before\n").unwrap();
|
||||
let before = snapshot(&file).unwrap();
|
||||
let mut temp = tempfile::NamedTempFile::new_in(&root).unwrap();
|
||||
temp.write_all(b"before\n/new\n").unwrap();
|
||||
fs::write(&file, "external writer\n").unwrap();
|
||||
assert!(replace_if_unchanged(&file, before, temp).unwrap_err().message.contains("changed"));
|
||||
assert_eq!(fs::read_to_string(file).unwrap(), "external writer\n");
|
||||
}
|
||||
}
|
||||
@@ -441,7 +441,6 @@ pub mod changelists;
|
||||
mod cli;
|
||||
mod diff;
|
||||
mod history;
|
||||
mod ignore;
|
||||
mod manage;
|
||||
mod ops;
|
||||
mod patch;
|
||||
@@ -455,7 +454,6 @@ mod write;
|
||||
|
||||
pub use cli::{detected_git_executable, git_version, set_askpass_env, set_git_executable};
|
||||
pub use diff::build_partial_patch;
|
||||
pub use ignore::{IgnoreDestination, add_to_ignore};
|
||||
pub use manage::{MergeOptions, RebaseOptions, RemoteEdit, Worktree};
|
||||
pub use remotes::Remote;
|
||||
pub use roots::Repos;
|
||||
|
||||
@@ -50,7 +50,7 @@ pub(crate) fn discover(path: &Path) -> IdeResult<Option<Repo>> {
|
||||
Ok(Some(Repo { root, git_dir, inner: std::sync::Mutex::new(cache), ignored: Default::default() }))
|
||||
}
|
||||
|
||||
pub(crate) fn absolute(p: &Path) -> IdeResult<PathBuf> {
|
||||
fn absolute(p: &Path) -> IdeResult<PathBuf> {
|
||||
let p = if p.is_absolute() { p.to_path_buf() } else { std::env::current_dir()?.join(p) };
|
||||
// Lexically clean `.`/`..` without resolving symlinks, keeping the caller's spelling.
|
||||
let mut out = PathBuf::new();
|
||||
|
||||
@@ -1,311 +0,0 @@
|
||||
//! Runtime tests against real temporary repositories and the Git executable.
|
||||
use ide_vcs::{IgnoreDestination::*, add_to_ignore};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
fn git(root: &Path, args: &[&str]) -> String {
|
||||
let output = Command::new("git")
|
||||
.current_dir(root)
|
||||
.args(args)
|
||||
.env_remove("GIT_DIR")
|
||||
.env_remove("GIT_WORK_TREE")
|
||||
.env_remove("GIT_INDEX_FILE")
|
||||
.env("GIT_CONFIG_NOSYSTEM", "1")
|
||||
.env("GIT_CONFIG_GLOBAL", if cfg!(windows) { "NUL" } else { "/dev/null" })
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(output.status.success(), "{args:?}: {}", String::from_utf8_lossy(&output.stderr));
|
||||
String::from_utf8(output.stdout).unwrap()
|
||||
}
|
||||
struct Fixture {
|
||||
_tmp: tempfile::TempDir,
|
||||
root: PathBuf,
|
||||
}
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap().join("repo");
|
||||
std::fs::create_dir(&root).unwrap();
|
||||
git(&root, &["init", "-q"]);
|
||||
git(&root, &["config", "core.excludesFile", if cfg!(windows) { "NUL" } else { "/dev/null" }]);
|
||||
Self { _tmp: tmp, root }
|
||||
}
|
||||
fn file(&self, name: &str) -> PathBuf {
|
||||
let path = self.root.join(name);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&path, "data\n").unwrap();
|
||||
path
|
||||
}
|
||||
fn ignored(&self, rel: &str) {
|
||||
assert!(!git(&self.root, &["check-ignore", "--", &format!("./{rel}")]).is_empty());
|
||||
assert!(
|
||||
git(&self.root, &["--literal-pathspecs", "status", "--porcelain", "--untracked-files=all", "--", rel])
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_literals_multiselect_and_directory_are_anchored() {
|
||||
let fx = Fixture::new();
|
||||
let mut names = vec!["has space", "#hash", "!bang", "[ab].txt", "-dash", "中文.txt"];
|
||||
// Win32 filenames cannot contain wildcard characters or end in a space.
|
||||
if cfg!(unix) {
|
||||
names.extend(["has space ", "[ab]*?.txt", ":(top)literal", ":!literal", ":(glob)*"]);
|
||||
}
|
||||
let mut paths: Vec<_> = names.iter().map(|n| fx.file(n)).collect();
|
||||
fx.file("literal");
|
||||
fx.file("cache/a.txt");
|
||||
fx.file("other/cache/a.txt");
|
||||
paths.push(fx.root.join("cache"));
|
||||
for name in &names {
|
||||
fx.file(&format!("other/{name}"));
|
||||
}
|
||||
add_to_ignore(&paths, Gitignore).unwrap();
|
||||
for name in &names {
|
||||
fx.ignored(name);
|
||||
}
|
||||
fx.ignored("cache/a.txt");
|
||||
let status = git(&fx.root, &["status", "--porcelain", "--untracked-files=all"]);
|
||||
assert!(status.contains("other/cache/a.txt"));
|
||||
assert!(status.contains("other/!bang"));
|
||||
assert!(status.lines().any(|line| line == "?? literal"));
|
||||
let text = std::fs::read_to_string(fx.root.join(".gitignore")).unwrap();
|
||||
assert!(text.contains("/cache/\n"));
|
||||
assert!(text.contains("/\\[ab\\].txt\n"));
|
||||
if cfg!(unix) {
|
||||
assert!(text.contains("/\\[ab\\]\\*\\?.txt\n"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_exclude_is_local_preserves_comments_crlf_and_deduplicates() {
|
||||
let fx = Fixture::new();
|
||||
let path = fx.file("private.txt");
|
||||
let exclude = fx.root.join(".git/info/exclude");
|
||||
std::fs::write(&exclude, "# keep this\r\n/other\r\n# no final newline").unwrap();
|
||||
add_to_ignore(&[path.clone(), path.clone()], LocalExclude).unwrap();
|
||||
let once = std::fs::read(&exclude).unwrap();
|
||||
assert_eq!(
|
||||
String::from_utf8(once.clone()).unwrap(),
|
||||
"# keep this\r\n/other\r\n# no final newline\r\n/private.txt\r\n"
|
||||
);
|
||||
add_to_ignore(&[path], LocalExclude).unwrap();
|
||||
assert_eq!(std::fs::read(&exclude).unwrap(), once);
|
||||
fx.ignored("private.txt");
|
||||
assert!(git(&fx.root, &["status", "--porcelain"]).is_empty());
|
||||
assert!(!fx.root.join(".gitignore").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nearest_existing_ignore_and_nested_repositories_use_correct_bases() {
|
||||
let fx = Fixture::new();
|
||||
let a = fx.file("pkg/deep/a.txt");
|
||||
std::fs::write(fx.root.join("pkg/.gitignore"), "# nested\n").unwrap();
|
||||
add_to_ignore(&[a], Gitignore).unwrap();
|
||||
fx.ignored("pkg/deep/a.txt");
|
||||
assert_eq!(std::fs::read_to_string(fx.root.join("pkg/.gitignore")).unwrap(), "# nested\n/deep/a.txt\n");
|
||||
assert!(!fx.root.join(".gitignore").exists());
|
||||
let b = fx.file("nested/b.txt");
|
||||
git(&fx.root.join("nested"), &["init", "-q"]);
|
||||
add_to_ignore(&[b], Gitignore).unwrap();
|
||||
assert_eq!(std::fs::read_to_string(fx.root.join("nested/.gitignore")).unwrap(), "/b.txt\n");
|
||||
assert!(!fx.root.join(".gitignore").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracked_files_and_directories_fail_before_any_write() {
|
||||
let fx = Fixture::new();
|
||||
let tracked = fx.file("dir/tracked");
|
||||
let new = fx.file("new");
|
||||
git(&fx.root, &["add", "dir/tracked"]);
|
||||
for path in [tracked, fx.root.join("dir")] {
|
||||
let error = add_to_ignore(&[new.clone(), path], Gitignore).unwrap_err();
|
||||
assert!(error.message.contains("tracked files"));
|
||||
assert!(!fx.root.join(".gitignore").exists());
|
||||
}
|
||||
assert!(git(&fx.root, &["diff", "--cached", "--name-only"]).contains("dir/tracked"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worktree_and_submodule_git_files_resolve_common_exclude() {
|
||||
let fx = Fixture::new();
|
||||
fx.file("initial");
|
||||
git(&fx.root, &["add", "initial"]);
|
||||
git(
|
||||
&fx.root,
|
||||
&[
|
||||
"-c",
|
||||
"user.name=Test",
|
||||
"-c",
|
||||
"[email protected]",
|
||||
"-c",
|
||||
"commit.gpgsign=false",
|
||||
"commit",
|
||||
"-qm",
|
||||
"initial",
|
||||
],
|
||||
);
|
||||
let worktree = fx.root.parent().unwrap().join("linked");
|
||||
git(&fx.root, &["worktree", "add", "-qb", "linked", worktree.to_str().unwrap()]);
|
||||
let p = worktree.join("private");
|
||||
std::fs::write(&p, "x").unwrap();
|
||||
add_to_ignore(&[p], LocalExclude).unwrap();
|
||||
assert!(std::fs::read_to_string(fx.root.join(".git/info/exclude")).unwrap().contains("/private\n"));
|
||||
assert_eq!(git(&worktree, &["check-ignore", "private"]), "private\n");
|
||||
assert!(git(&worktree, &["status", "--porcelain"]).is_empty());
|
||||
// A real gitfile layout like a submodule, with metadata outside its worktree.
|
||||
let sub = fx.root.join("sub");
|
||||
std::fs::create_dir(&sub).unwrap();
|
||||
let metadata = fx.root.join(".git/modules/sub");
|
||||
std::fs::create_dir_all(metadata.parent().unwrap()).unwrap();
|
||||
git(&sub, &["init", "-q", "--separate-git-dir", metadata.to_str().unwrap()]);
|
||||
std::fs::write(sub.join("tracked"), "x").unwrap();
|
||||
git(&sub, &["add", "tracked"]);
|
||||
git(
|
||||
&sub,
|
||||
&[
|
||||
"-c",
|
||||
"user.name=Test",
|
||||
"-c",
|
||||
"[email protected]",
|
||||
"-c",
|
||||
"commit.gpgsign=false",
|
||||
"commit",
|
||||
"-qm",
|
||||
"submodule initial",
|
||||
],
|
||||
);
|
||||
let commit = git(&sub, &["rev-parse", "HEAD"]);
|
||||
std::fs::write(
|
||||
fx.root.join(".gitmodules"),
|
||||
"[submodule \"sub\"]\n\tpath = sub\n\turl = https://example.invalid/sub.git\n",
|
||||
)
|
||||
.unwrap();
|
||||
git(&fx.root, &["update-index", "--add", "--cacheinfo", "160000", commit.trim(), "sub"]);
|
||||
assert!(add_to_ignore(std::slice::from_ref(&sub), LocalExclude).unwrap_err().message.contains("tracked files"));
|
||||
std::fs::write(sub.join("private"), "x").unwrap();
|
||||
add_to_ignore(&[sub.join("private")], LocalExclude).unwrap();
|
||||
assert!(std::fs::read_to_string(metadata.join("info/exclude")).unwrap().contains("/private\n"));
|
||||
assert_eq!(git(&sub, &["check-ignore", "private"]), "private\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lock_failure_invalid_destination_and_traversal_preserve_data() {
|
||||
let fx = Fixture::new();
|
||||
let path = fx.file("new");
|
||||
let file = fx.root.join(".gitignore");
|
||||
std::fs::write(&file, "# original\n").unwrap();
|
||||
std::fs::write(fx.root.join(".gitignore.lock"), "another writer").unwrap();
|
||||
assert!(add_to_ignore(std::slice::from_ref(&path), Gitignore).unwrap_err().message.contains("locked"));
|
||||
assert_eq!(std::fs::read_to_string(&file).unwrap(), "# original\n");
|
||||
assert_eq!(std::fs::read_to_string(fx.root.join(".gitignore.lock")).unwrap(), "another writer");
|
||||
assert!(add_to_ignore(&[fx.root.join("dir/../new")], Gitignore).is_err());
|
||||
std::fs::remove_file(fx.root.join(".gitignore.lock")).unwrap();
|
||||
std::fs::write(&file, b"# invalid\xff").unwrap();
|
||||
assert!(add_to_ignore(&[path], Gitignore).is_err());
|
||||
assert_eq!(std::fs::read(file).unwrap(), b"# invalid\xff");
|
||||
assert!(!fx.root.join(".gitignore.lock").exists());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn backslash_is_literal_newlines_and_symlinks_are_rejected() {
|
||||
use std::os::unix::fs::symlink;
|
||||
let fx = Fixture::new();
|
||||
let backslash = fx.file("back\\slash");
|
||||
add_to_ignore(&[backslash], Gitignore).unwrap();
|
||||
fx.ignored("back\\slash");
|
||||
let original = std::fs::read(fx.root.join(".gitignore")).unwrap();
|
||||
for name in ["line\nname", "line\rname"] {
|
||||
let path = fx.file(name);
|
||||
assert!(add_to_ignore(&[path], Gitignore).is_err());
|
||||
}
|
||||
let outside = fx.root.parent().unwrap().join("outside");
|
||||
std::fs::write(&outside, "safe").unwrap();
|
||||
std::fs::remove_file(fx.root.join(".gitignore")).unwrap();
|
||||
symlink(&outside, fx.root.join(".gitignore")).unwrap();
|
||||
assert!(add_to_ignore(&[fx.file("new")], Gitignore).is_err());
|
||||
assert_eq!(std::fs::read_to_string(&outside).unwrap(), "safe");
|
||||
std::fs::remove_file(fx.root.join(".gitignore")).unwrap();
|
||||
std::fs::write(fx.root.join(".gitignore"), &original).unwrap();
|
||||
symlink(fx.root.parent().unwrap(), fx.root.join("link")).unwrap();
|
||||
assert!(add_to_ignore(&[fx.root.join("link/outside")], LocalExclude).is_err());
|
||||
assert_eq!(std::fs::read(fx.root.join(".gitignore")).unwrap(), original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overridden_local_rule_is_not_duplicated_and_explains_precedence() {
|
||||
let fx = Fixture::new();
|
||||
let path = fx.file("private");
|
||||
std::fs::write(fx.root.join(".gitignore"), "!/private\n").unwrap();
|
||||
let error = add_to_ignore(std::slice::from_ref(&path), LocalExclude).unwrap_err();
|
||||
assert!(error.message.contains("higher-priority"));
|
||||
let exclude = fx.root.join(".git/info/exclude");
|
||||
let once = std::fs::read(&exclude).unwrap();
|
||||
assert!(add_to_ignore(&[path], LocalExclude).is_err());
|
||||
assert_eq!(std::fs::read(exclude).unwrap(), once);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readonly_and_nonfile_destinations_remain_untouched() {
|
||||
let fx = Fixture::new();
|
||||
let path = fx.file("new");
|
||||
let file = fx.root.join(".gitignore");
|
||||
std::fs::create_dir(&file).unwrap();
|
||||
assert!(add_to_ignore(std::slice::from_ref(&path), Gitignore).is_err());
|
||||
std::fs::remove_dir(&file).unwrap();
|
||||
std::fs::write(&file, "# original\n").unwrap();
|
||||
let original = std::fs::metadata(&file).unwrap().permissions();
|
||||
let mut permissions = original.clone();
|
||||
permissions.set_readonly(true);
|
||||
std::fs::set_permissions(&file, permissions).unwrap();
|
||||
let result = add_to_ignore(&[path], Gitignore);
|
||||
std::fs::set_permissions(&file, original).unwrap();
|
||||
assert!(result.unwrap_err().message.contains("read-only"));
|
||||
assert_eq!(std::fs::read_to_string(file).unwrap(), "# original\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_bytes_are_preserved_for_newline_and_bom_variants() {
|
||||
for original in [b"# comment".as_slice(), b"unrelated\\", b"\xef\xbb\xbf# BOM\r\n", b"# LF\n", b""] {
|
||||
let fx = Fixture::new();
|
||||
let path = fx.file("new");
|
||||
let file = fx.root.join(".gitignore");
|
||||
std::fs::write(&file, original).unwrap();
|
||||
add_to_ignore(&[path], Gitignore).unwrap();
|
||||
assert!(std::fs::read(file).unwrap().starts_with(original));
|
||||
fx.ignored("new");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn non_utf8_paths_are_rejected_without_creating_ignore_files() {
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
let fx = Fixture::new();
|
||||
let path = fx.root.join(std::ffi::OsString::from_vec(b"invalid\xff".to_vec()));
|
||||
// APFS rejects creation of this filename; the request must fail clearly before I/O.
|
||||
assert!(add_to_ignore(&[path], Gitignore).unwrap_err().message.contains("UTF-8"));
|
||||
assert!(!fx.root.join(".gitignore").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn later_negations_allow_reignoring_shared_and_local_paths_without_batch_duplicates() {
|
||||
for destination in [Gitignore, LocalExclude] {
|
||||
let fx = Fixture::new();
|
||||
let path = fx.file("private");
|
||||
let file = match destination {
|
||||
Gitignore => fx.root.join(".gitignore"),
|
||||
LocalExclude => fx.root.join(".git/info/exclude"),
|
||||
};
|
||||
std::fs::write(&file, "/private\n!/private\n").unwrap();
|
||||
assert!(git(&fx.root, &["status", "--porcelain", "--untracked-files=all"]).contains("private"));
|
||||
add_to_ignore(&[path.clone(), path.clone()], destination).unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&file).unwrap(), "/private\n!/private\n/private\n");
|
||||
fx.ignored("private");
|
||||
let once = std::fs::read(&file).unwrap();
|
||||
add_to_ignore(&[path], destination).unwrap();
|
||||
assert_eq!(std::fs::read(file).unwrap(), once);
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
dunce.workspace = true
|
||||
gix = { workspace = true, features = ["sha1"] }
|
||||
ide-core.workspace = true
|
||||
serde.workspace = true
|
||||
specta.workspace = true
|
||||
|
||||
@@ -80,24 +80,20 @@ pub(crate) struct GitignoreStack {
|
||||
/// `.git/info/exclude` and the global excludes file, rooted at the repo root.
|
||||
repo_level: RwLock<Vec<Gitignore>>,
|
||||
cache: Mutex<HashMap<PathBuf, Option<Arc<Gitignore>>>>,
|
||||
nested: Mutex<HashMap<PathBuf, Option<Arc<GitignoreStack>>>>,
|
||||
}
|
||||
|
||||
impl GitignoreStack {
|
||||
pub fn new(near: &Path) -> Self {
|
||||
let repo_root = find_repo_root(near);
|
||||
let repo_level = RwLock::new(Self::load_repo_level(repo_root.as_deref()));
|
||||
Self { repo_root, repo_level, cache: Mutex::default(), nested: Mutex::default() }
|
||||
Self { repo_root, repo_level, cache: Mutex::default() }
|
||||
}
|
||||
|
||||
fn load_repo_level(repo_root: Option<&Path>) -> Vec<Gitignore> {
|
||||
let mut repo_level = Vec::new();
|
||||
if let Some(root) = repo_root {
|
||||
let mut builder = GitignoreBuilder::new(root);
|
||||
let exclude = gix::discover(root)
|
||||
.ok()
|
||||
.map(|r| r.common_dir().join("info/exclude"))
|
||||
.unwrap_or_else(|| root.join(".git/info/exclude"));
|
||||
let exclude = root.join(".git").join("info").join("exclude");
|
||||
if exclude.is_file() {
|
||||
builder.add(exclude);
|
||||
}
|
||||
@@ -119,13 +115,11 @@ impl GitignoreStack {
|
||||
|
||||
/// Re-reads `.git/info/exclude` and the global excludes file.
|
||||
pub fn reload_repo_level(&self) {
|
||||
self.nested.lock().clear();
|
||||
*self.repo_level.write() = Self::load_repo_level(self.repo_root.as_deref());
|
||||
}
|
||||
|
||||
/// Forgets the cached rules of `dir` and every directory below it.
|
||||
pub fn invalidate_tree(&self, dir: &Path) {
|
||||
self.nested.lock().clear();
|
||||
let mut cache = self.cache.lock();
|
||||
// Lookups always populate every ancestor, so no key below `dir` exists without `dir`.
|
||||
if cache.contains_key(dir) {
|
||||
@@ -139,7 +133,6 @@ impl GitignoreStack {
|
||||
|
||||
/// Forgets the cached rules of `dir` (after its `.gitignore` changed).
|
||||
pub fn invalidate(&self, dir: &Path) {
|
||||
self.nested.lock().clear();
|
||||
self.cache.lock().remove(dir);
|
||||
}
|
||||
|
||||
@@ -176,23 +169,6 @@ impl GitignoreStack {
|
||||
|
||||
/// Whether `path` (absolute) is ignored. `is_dir` refers to `path` itself.
|
||||
pub fn is_ignored(&self, path: &Path, is_dir: bool) -> bool {
|
||||
// A project's watcher can span independent nested repositories. Their local
|
||||
// excludes and ignore ancestry must stop at their own repository boundary.
|
||||
if let Some(parent) = path.parent() {
|
||||
let nested = self
|
||||
.nested
|
||||
.lock()
|
||||
.entry(parent.to_path_buf())
|
||||
.or_insert_with(|| {
|
||||
find_repo_root(parent)
|
||||
.filter(|root| self.repo_root.as_ref() != Some(root))
|
||||
.map(|_| Arc::new(Self::new(parent)))
|
||||
})
|
||||
.clone();
|
||||
if let Some(nested) = nested {
|
||||
return nested.is_ignored(path, is_dir);
|
||||
}
|
||||
}
|
||||
if self.repo_root.as_deref() == Some(path) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -50,7 +50,6 @@ type OnChange = Box<dyn Fn(&FsBatch) + Send + Sync>;
|
||||
pub struct Watcher {
|
||||
inner: Inner,
|
||||
excludes: SharedExcludes,
|
||||
rules: Arc<GitignoreStack>,
|
||||
}
|
||||
|
||||
type SharedExcludes = Arc<RwLock<ExcludeMatcher>>;
|
||||
@@ -75,7 +74,6 @@ impl Watcher {
|
||||
let root = dunce::canonicalize(root)?;
|
||||
let filter = EventFilter::new(root.clone(), &excludes);
|
||||
let shared = filter.excludes.clone();
|
||||
let rules = filter.gitignore.clone();
|
||||
let emit = move |batch: &FsBatch| {
|
||||
sink.emit(IdeEvent::FsChanged {
|
||||
created: batch.created.iter().map(Into::into).collect(),
|
||||
@@ -89,7 +87,7 @@ impl Watcher {
|
||||
WatchMode::Flat => Self::start_recursive(root, filter, emit, RecursiveMode::NonRecursive),
|
||||
WatchMode::PerDirectory => Self::start_per_directory(root, filter, emit),
|
||||
}?;
|
||||
Ok(Self { inner, excludes: shared, rules })
|
||||
Ok(Self { inner, excludes: shared })
|
||||
}
|
||||
|
||||
/// Replaces the exclude globs (Settings › Project Structure / File Types
|
||||
@@ -99,12 +97,6 @@ impl Watcher {
|
||||
*self.excludes.write() = ExcludeMatcher::new(excludes);
|
||||
}
|
||||
|
||||
/// Explicit refresh for writes to Git common metadata outside the watched root.
|
||||
pub fn reload_ignore_rules(&self) {
|
||||
self.rules.reload_repo_level();
|
||||
self.rules.invalidate_tree(self.rules.repo_root().unwrap_or(Path::new("/")));
|
||||
}
|
||||
|
||||
/// Number of OS-level watches (1 in recursive and flat mode).
|
||||
pub fn watch_count(&self) -> usize {
|
||||
match &self.inner {
|
||||
@@ -382,13 +374,13 @@ fn merge(prev: Option<Change>, next: Change) -> Change {
|
||||
pub(crate) struct EventFilter {
|
||||
root: PathBuf,
|
||||
excludes: SharedExcludes,
|
||||
gitignore: Arc<GitignoreStack>,
|
||||
gitignore: GitignoreStack,
|
||||
}
|
||||
|
||||
impl EventFilter {
|
||||
pub fn new(root: PathBuf, excludes: &[String]) -> Self {
|
||||
Self {
|
||||
gitignore: Arc::new(GitignoreStack::new(&root)),
|
||||
gitignore: GitignoreStack::new(&root),
|
||||
excludes: Arc::new(RwLock::new(ExcludeMatcher::new(excludes))),
|
||||
root,
|
||||
}
|
||||
|
||||
@@ -321,47 +321,3 @@ fn watcher_reports_changes() {
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
assert_eq!(batches.lock().len(), n, "no events after drop");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worktree_and_nested_repository_local_excludes_are_reflected_in_listings() {
|
||||
fn git(root: &std::path::Path, args: &[&str]) {
|
||||
let out = std::process::Command::new("git").current_dir(root).args(args).output().unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
}
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap().join("repo");
|
||||
std::fs::create_dir(&root).unwrap();
|
||||
git(&root, &["init", "-q"]);
|
||||
std::fs::write(root.join("initial"), "x").unwrap();
|
||||
git(&root, &["add", "initial"]);
|
||||
git(
|
||||
&root,
|
||||
&[
|
||||
"-c",
|
||||
"user.name=Test",
|
||||
"-c",
|
||||
"[email protected]",
|
||||
"-c",
|
||||
"commit.gpgsign=false",
|
||||
"commit",
|
||||
"-qm",
|
||||
"initial",
|
||||
],
|
||||
);
|
||||
let linked = root.parent().unwrap().join("linked");
|
||||
git(&root, &["worktree", "add", "-qb", "linked", linked.to_str().unwrap()]);
|
||||
std::fs::write(linked.join("private"), "x").unwrap();
|
||||
std::fs::write(root.join(".git/info/exclude"), "/private\n").unwrap();
|
||||
let entries = ide_vfs::read_dir(&linked, &[]).unwrap();
|
||||
assert!(entries.iter().find(|e| e.name == "private").unwrap().ignored);
|
||||
assert!(!entries.iter().find(|e| e.name == "private").unwrap().excluded);
|
||||
let nested = linked.join("nested");
|
||||
std::fs::create_dir(&nested).unwrap();
|
||||
git(&nested, &["init", "-q"]);
|
||||
std::fs::write(nested.join("private"), "x").unwrap();
|
||||
let entries = ide_vfs::read_dir(&nested, &[]).unwrap();
|
||||
assert!(!entries.iter().find(|e| e.name == "private").unwrap().ignored);
|
||||
std::fs::write(nested.join(".git/info/exclude"), "/private\n").unwrap();
|
||||
let entries = ide_vfs::read_dir(&nested, &[]).unwrap();
|
||||
assert!(entries.iter().find(|e| e.name == "private").unwrap().ignored);
|
||||
}
|
||||
@@ -295,7 +295,7 @@ a token.
|
||||
|
||||
<!-- BEGIN GENERATED TOKEN REFERENCE: pnpm gen:theme-docs -->
|
||||
|
||||
222 themeable tokens: 116 in the UI theme file, 106 in the editor colour scheme file.
|
||||
219 themeable tokens: 113 in the UI theme file, 106 in the editor colour scheme file.
|
||||
|
||||
### Surfaces (UI theme)
|
||||
|
||||
@@ -374,9 +374,6 @@ a token.
|
||||
| `--gutter-modified` | `#375fad` | `#88adf7` |
|
||||
| `--gutter-added` | `#549159` | `#7fc784` |
|
||||
| `--gutter-deleted` | `#868a91` | `#767a8a` |
|
||||
| `--diff-modified` | `#385570` | `#c2d8f2` |
|
||||
| `--diff-inserted` | `#294436` | `#bee6be` |
|
||||
| `--diff-deleted` | `#484a4a` | `#d6d6d6` |
|
||||
|
||||
### Syntax (editor scheme)
|
||||
|
||||
|
||||
@@ -1,336 +0,0 @@
|
||||
/**
|
||||
* The diff viewer over a real git repository (`diffRepo.ts`): each case opens
|
||||
* HEAD against the working copy and checks the IDEA-style change types of
|
||||
* lines and words, the difference count against `git diff`, and that the
|
||||
* 10,000-line files open, scroll and navigate in every viewer mode.
|
||||
*/
|
||||
import { rmSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { gitHunks, readSides, tempDiffRepo } from "./diffRepo";
|
||||
import { expect, test } from "./fixtures";
|
||||
|
||||
type Kind = "modified" | "inserted" | "deleted";
|
||||
|
||||
interface Opts {
|
||||
mode?: "split" | "unified";
|
||||
/** Padded panes (true), or panes joined by a curved divider (false, the default). */
|
||||
alignChanges?: boolean;
|
||||
syncScroll?: boolean;
|
||||
collapse?: boolean;
|
||||
ignore?: "none" | "trim" | "whitespace" | "whitespaceAndLines";
|
||||
}
|
||||
|
||||
let repo = "";
|
||||
test.beforeAll(() => {
|
||||
repo = tempDiffRepo();
|
||||
});
|
||||
test.afterAll(() => rmSync(dirname(repo), { recursive: true, force: true }));
|
||||
|
||||
const toolbar = (page: Page) => page.getByRole("toolbar", { name: "Diff" });
|
||||
const counter = (page: Page) =>
|
||||
toolbar(page)
|
||||
.locator("span")
|
||||
.filter({ hasText: /differen|identical/ });
|
||||
|
||||
/** Opens HEAD ↔ working tree of `path` and waits until the difference count shows; returns the time it took. */
|
||||
async function openCase(page: Page, path: string, opts: Opts = {}): Promise<number> {
|
||||
const { left, right } = readSides(repo, path);
|
||||
const options = {
|
||||
mode: "split",
|
||||
ignore: "none",
|
||||
highlight: "words",
|
||||
collapse: false,
|
||||
alignChanges: true,
|
||||
syncScroll: true,
|
||||
...opts,
|
||||
};
|
||||
const started = Date.now();
|
||||
await page.evaluate(
|
||||
async ({ path, left, right, options }) => {
|
||||
localStorage.setItem("openidea.vcs.diffOptions", JSON.stringify(options));
|
||||
const actions = "/src/platform/actions.ts";
|
||||
const { executeAction } = await import(/* @vite-ignore */ actions);
|
||||
await executeAction("diff.compareTexts", {
|
||||
path,
|
||||
title: path,
|
||||
left: { text: left, label: "HEAD" },
|
||||
right: { text: right, label: "Working tree" },
|
||||
});
|
||||
},
|
||||
{ path, left, right, options },
|
||||
);
|
||||
await expect(counter(page)).toBeVisible({ timeout: 20_000 });
|
||||
const elapsed = Date.now() - started;
|
||||
// Word highlights land a microtask after both panes exist.
|
||||
await page.evaluate(() => new Promise(requestAnimationFrame));
|
||||
return elapsed;
|
||||
}
|
||||
|
||||
interface Side {
|
||||
lines: Kind[];
|
||||
words: [string, Kind][];
|
||||
}
|
||||
|
||||
/** Change types of the rendered lines and words of each pane (`a` / `b`; unified: `b` only). */
|
||||
async function rendered(page: Page): Promise<{ a: Side; b: Side; deletedChunks: number }> {
|
||||
return page
|
||||
.locator(".cm-mergeView, .cm-editor.cm-merge-b")
|
||||
.first()
|
||||
.evaluate((el) => {
|
||||
const root = el.closest("[role=region]") ?? el;
|
||||
const kind = (e: Element, prefix: string) =>
|
||||
(e.className.match(new RegExp(`${prefix}-(modified|inserted|deleted)`))?.[1] ?? null) as Kind | null;
|
||||
const side = (cls: string) => {
|
||||
const ed = root.querySelector(`.cm-editor.cm-merge-${cls}`);
|
||||
const lines = [...(ed?.querySelectorAll(".cm-content > .cm-line") ?? [])]
|
||||
.map((l) => kind(l, "cm-diffLine"))
|
||||
.filter((k): k is Kind => !!k);
|
||||
const words = [...(ed?.querySelectorAll(".cm-diffWord") ?? [])].map(
|
||||
(w) => [w.textContent ?? "", kind(w, "cm-diffWord")!] as [string, Kind],
|
||||
);
|
||||
return { lines, words };
|
||||
};
|
||||
return { a: side("a"), b: side("b"), deletedChunks: root.querySelectorAll(".cm-deletedChunk").length };
|
||||
});
|
||||
}
|
||||
|
||||
const differences = (n: number) => (n === 1 ? "1 difference" : `${n} differences`);
|
||||
|
||||
async function expectCount(page: Page, path: string, n = gitHunks(repo, path)) {
|
||||
await expect(counter(page)).toHaveText(new RegExp(`^(${differences(n)}|\\d+ of ${n} differences)$`));
|
||||
}
|
||||
|
||||
const all = (n: number, k: Kind) => Array<Kind>(n).fill(k);
|
||||
|
||||
async function shot(page: Page, name: string) {
|
||||
const path = test.info().outputPath(`${name}.png`);
|
||||
await page.getByRole("region").last().screenshot({ path, animations: "disabled" });
|
||||
await test.info().attach(name, { path, contentType: "image/png" });
|
||||
}
|
||||
|
||||
test.describe("small cases (side-by-side, Highlight words)", () => {
|
||||
const cases: {
|
||||
path: string;
|
||||
a: Kind[];
|
||||
b: Kind[];
|
||||
wordsA?: [string, Kind][];
|
||||
wordsB?: [string, Kind][];
|
||||
}[] = [
|
||||
{
|
||||
path: "src/words.ts",
|
||||
a: all(2, "modified"),
|
||||
b: all(2, "modified"),
|
||||
wordsA: [["true", "modified"]],
|
||||
wordsB: [
|
||||
["false", "modified"],
|
||||
[" x,", "inserted"],
|
||||
],
|
||||
},
|
||||
{ path: "src/insert.ts", a: [], b: all(5, "inserted"), wordsA: [], wordsB: [] },
|
||||
{ path: "src/delete.ts", a: all(4, "deleted"), b: [], wordsA: [], wordsB: [] },
|
||||
{
|
||||
path: "docs/readme-zh.md",
|
||||
a: ["modified"],
|
||||
b: ["modified"],
|
||||
wordsA: [["测试", "modified"]],
|
||||
wordsB: [["示例", "modified"]],
|
||||
},
|
||||
{
|
||||
path: "docs/emoji.md",
|
||||
a: ["modified"],
|
||||
b: ["modified"],
|
||||
wordsA: [["👋", "modified"]],
|
||||
wordsB: [["🌍", "modified"]],
|
||||
},
|
||||
{
|
||||
path: "data/no-eol.txt",
|
||||
a: ["modified"],
|
||||
b: ["modified"],
|
||||
wordsA: [["beta", "modified"]],
|
||||
wordsB: [["gamma", "modified"]],
|
||||
},
|
||||
{ path: "src/indent.py", a: all(3, "modified"), b: all(3, "modified") },
|
||||
{ path: "src/moved.ts", a: all(5, "deleted"), b: all(5, "inserted"), wordsA: [], wordsB: [] },
|
||||
{ path: "src/new-file.ts", a: [], b: all(6, "inserted"), wordsA: [], wordsB: [] },
|
||||
{ path: "src/deleted-file.ts", a: all(6, "deleted"), b: [], wordsA: [], wordsB: [] },
|
||||
];
|
||||
for (const c of cases)
|
||||
test(c.path, async ({ workbench: page }) => {
|
||||
await openCase(page, c.path);
|
||||
await expectCount(page, c.path);
|
||||
const { a, b } = await rendered(page);
|
||||
expect(a.lines, "left lines").toEqual(c.a);
|
||||
expect(b.lines, "right lines").toEqual(c.b);
|
||||
if (c.wordsA) expect(a.words, "left words").toEqual(c.wordsA);
|
||||
if (c.wordsB) expect(b.words, "right words").toEqual(c.wordsB);
|
||||
});
|
||||
|
||||
test("a renamed field with a comment added reads as one modified block", async ({ workbench: page }) => {
|
||||
await openCase(page, "src/options.ts");
|
||||
await expectCount(page, "src/options.ts", 1);
|
||||
const { a, b } = await rendered(page);
|
||||
expect(a.lines).toEqual(["modified"]);
|
||||
expect(b.lines).toEqual(all(6, "modified"));
|
||||
expect(a.words).toEqual([["syncScroll", "modified"]]);
|
||||
expect(b.words.every(([, k]) => k === "modified")).toBe(true);
|
||||
const text = b.words.map(([t]) => t).join("\n");
|
||||
expect(text).toMatch(/^\/\*\*\n/);
|
||||
expect(text).toMatch(/syncScrolling$/);
|
||||
await shot(page, "options");
|
||||
});
|
||||
|
||||
test("an edit in the middle of a 44,000-character minified line", async ({ workbench: page }) => {
|
||||
const path = "dist/app.min.js";
|
||||
await openCase(page, path);
|
||||
await expectCount(page, path);
|
||||
// Long lines render only around the horizontal viewport: scroll to the edit.
|
||||
const { left } = readSides(repo, path);
|
||||
const at = left.indexOf("f(1000,") / left.length;
|
||||
for (const side of ["a", "b"])
|
||||
await page
|
||||
.locator(`.cm-editor.cm-merge-${side} .cm-scroller`)
|
||||
.evaluate((s, at) => (s.scrollLeft = at * s.scrollWidth - s.clientWidth / 2), at);
|
||||
await expect.poll(async () => (await rendered(page)).a.words).toEqual([["f", "modified"]]);
|
||||
const { a, b } = await rendered(page);
|
||||
expect(a.lines).toEqual(["modified"]);
|
||||
expect(b.lines).toEqual(["modified"]);
|
||||
expect(b.words).toEqual([["g", "modified"]]);
|
||||
await shot(page, "minified");
|
||||
});
|
||||
|
||||
test("a final newline added", async ({ workbench: page }) => {
|
||||
await openCase(page, "data/add-eol.txt");
|
||||
await expectCount(page, "data/add-eol.txt");
|
||||
const { a, b } = await rendered(page);
|
||||
// IDEA compares lines without their breaks: an empty line inserted, "beta" unchanged.
|
||||
expect(a.lines).toEqual([]);
|
||||
expect(b.lines).toEqual(["inserted"]);
|
||||
await shot(page, "add-eol");
|
||||
});
|
||||
|
||||
test("ignoring whitespace hides an indentation-only change", async ({ workbench: page }) => {
|
||||
await openCase(page, "src/indent.py", { ignore: "whitespace" });
|
||||
await expect(counter(page)).toHaveText("No differences");
|
||||
expect((await rendered(page)).b.lines).toEqual([]);
|
||||
});
|
||||
|
||||
test("unified viewer shows old lines deleted and new lines inserted", async ({ workbench: page }) => {
|
||||
await openCase(page, "src/options.ts", { mode: "unified" });
|
||||
await expectCount(page, "src/options.ts", 1);
|
||||
const { b, deletedChunks } = await rendered(page);
|
||||
expect(deletedChunks).toBe(1);
|
||||
expect(b.lines).toEqual(all(6, "inserted"));
|
||||
await shot(page, "options-unified");
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("10,000-line files", () => {
|
||||
test.describe.configure({ timeout: 60_000 });
|
||||
|
||||
const modes: [string, Opts][] = [
|
||||
["padded", {}],
|
||||
["sync scrolling", { alignChanges: false }],
|
||||
["unified", { mode: "unified" }],
|
||||
["collapsed", { collapse: true }],
|
||||
];
|
||||
|
||||
/** Scrolls the diff to the end: the padded panes scroll together, the others from the right (or unified) pane. */
|
||||
async function scrollToEnd(page: Page) {
|
||||
await page
|
||||
.locator(".cm-editor.cm-merge-b")
|
||||
.first()
|
||||
.evaluate((ed) => {
|
||||
const merge = ed.closest<HTMLElement>(".cm-mergeView");
|
||||
const s =
|
||||
merge && merge.scrollHeight > merge.clientHeight ? merge : ed.querySelector<HTMLElement>(".cm-scroller")!;
|
||||
s.scrollTop = s.scrollHeight;
|
||||
});
|
||||
await page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
|
||||
}
|
||||
|
||||
/** Text of the first line fully shown in each pane. */
|
||||
async function topLines(page: Page) {
|
||||
return page
|
||||
.locator(".cm-mergeView")
|
||||
.first()
|
||||
.evaluate((mv) => {
|
||||
const top = (side: string) => {
|
||||
const ed = mv.querySelector(`.cm-editor.cm-merge-${side}`)!;
|
||||
const r = mv.getBoundingClientRect();
|
||||
const s = ed.querySelector(".cm-scroller")!.getBoundingClientRect();
|
||||
const y = Math.max(r.top, s.top);
|
||||
return [...ed.querySelectorAll(".cm-content > .cm-line")].find((l) => l.getBoundingClientRect().top >= y)
|
||||
?.textContent;
|
||||
};
|
||||
return [top("a"), top("b")];
|
||||
});
|
||||
}
|
||||
|
||||
for (const [mode, opts] of modes) {
|
||||
test(`scattered edits, inserts and deletes (${mode})`, async ({ workbench: page }) => {
|
||||
const path = "big/scattered.ts";
|
||||
const ms = await openCase(page, path, opts);
|
||||
test.info().annotations.push({ type: "open", description: `${ms} ms` });
|
||||
expect(ms, "time to open").toBeLessThan(8_000);
|
||||
await expectCount(page, path, 42);
|
||||
// The first change is a single edited identifier.
|
||||
await page.locator(".cm-editor.cm-merge-b .cm-content").first().click();
|
||||
await page.keyboard.press("Meta+ArrowUp");
|
||||
await page.keyboard.press("F7");
|
||||
await expect(counter(page)).toHaveText("1 of 42 differences");
|
||||
const first = await rendered(page);
|
||||
if (opts.mode !== "unified") {
|
||||
expect(first.a.words).toContainEqual(["compute", "modified"]);
|
||||
expect(first.b.words).toContainEqual(["computeFast", "modified"]);
|
||||
}
|
||||
// The last difference is reachable and still highlighted.
|
||||
await page.keyboard.press("Meta+ArrowDown");
|
||||
await page.keyboard.press("Shift+F7");
|
||||
await expect(counter(page)).toHaveText("42 of 42 differences");
|
||||
const last = await rendered(page);
|
||||
if (opts.mode === "unified") expect(last.b.lines).toContain("inserted");
|
||||
else expect(last.b.words).toContainEqual(["computeFast", "modified"]);
|
||||
await scrollToEnd(page);
|
||||
if (opts.mode !== "unified") {
|
||||
const [a, b] = await topLines(page);
|
||||
expect(a, "panes line up at the end").toBe(b);
|
||||
// Collapsed, the unchanged tail folds away.
|
||||
if (!opts.collapse) expect(b).toMatch(/value99\d\d/);
|
||||
}
|
||||
await shot(page, `scattered-${mode.replace(" ", "-")}`);
|
||||
});
|
||||
}
|
||||
|
||||
test("every tenth line edited: 1,000 differences", async ({ workbench: page }) => {
|
||||
const path = "big/every-tenth.ts";
|
||||
const ms = await openCase(page, path, { alignChanges: false });
|
||||
test.info().annotations.push({ type: "open", description: `${ms} ms` });
|
||||
expect(ms, "time to open").toBeLessThan(8_000);
|
||||
await expectCount(page, path, 1_000);
|
||||
await scrollToEnd(page);
|
||||
const { a, b } = await rendered(page);
|
||||
expect(b.words.length).toBeGreaterThan(0);
|
||||
expect(b.words.every(([t, k]) => t === "entry" && k === "modified")).toBe(true);
|
||||
expect(a.words.every(([t, k]) => t === "item" && k === "modified")).toBe(true);
|
||||
await shot(page, "every-tenth");
|
||||
});
|
||||
|
||||
for (const [mode, opts] of modes.slice(0, 3))
|
||||
test(`every line edited: one 10,000-line block (${mode})`, async ({ workbench: page }) => {
|
||||
const path = "big/rewrite.ts";
|
||||
const ms = await openCase(page, path, opts);
|
||||
test.info().annotations.push({ type: "open", description: `${ms} ms` });
|
||||
expect(ms, "time to open").toBeLessThan(8_000);
|
||||
await expectCount(page, path, 1);
|
||||
await scrollToEnd(page);
|
||||
const { b } = await rendered(page);
|
||||
if (opts.mode === "unified") expect(b.lines.every((k) => k === "inserted")).toBe(true);
|
||||
else {
|
||||
expect(b.lines.length).toBeGreaterThan(10);
|
||||
expect(b.lines.every((k) => k === "modified")).toBe(true);
|
||||
expect(b.words).toContainEqual(["let", "modified"]);
|
||||
}
|
||||
await shot(page, `rewrite-${mode.replace(" ", "-")}`);
|
||||
});
|
||||
});
|
||||
-189
@@ -1,189 +0,0 @@
|
||||
/**
|
||||
* A git repository of diff viewer cases: each file is committed, then changed
|
||||
* in the working tree (or added / deleted), covering word edits, pure inserts
|
||||
* and deletes, whitespace, CJK, missing final newlines, moves, a minified line
|
||||
* and 10,000-line files. The e2e specs open HEAD against the working copy; run
|
||||
* `node e2e/diffRepo.ts <dir>` to build it for manual QA in the app.
|
||||
*/
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
|
||||
export interface DiffCase {
|
||||
path: string;
|
||||
/** Committed content; null for a file added in the working tree. */
|
||||
before: string | null;
|
||||
/** Working-tree content; null for a deleted file. */
|
||||
after: string | null;
|
||||
}
|
||||
|
||||
const lines = (n: number, line: (i: number) => string) => Array.from({ length: n }, (_, i) => line(i) + "\n").join("");
|
||||
|
||||
/** A 10,000-line source file; `edit(i)` may replace line `i` (or return null to drop it). */
|
||||
function bigFile(edit: (i: number, line: string) => string | string[] | null = (_, l) => l) {
|
||||
const out: string[] = [];
|
||||
for (let i = 0; i < 10_000; i++) {
|
||||
const line = i % 50 === 0 ? `// section ${i / 50}` : `export const value${i} = compute(${i}, "item-${i}");`;
|
||||
const e = edit(i, line);
|
||||
if (e !== null) out.push(...(Array.isArray(e) ? e : [e]));
|
||||
}
|
||||
return out.map((l) => l + "\n").join("");
|
||||
}
|
||||
|
||||
const OPTIONS_BEFORE = `export interface DiffViewOptions {
|
||||
mode: DiffMode;
|
||||
ignore: IgnorePolicy;
|
||||
highlight: HighlightPolicy;
|
||||
collapse: boolean;
|
||||
syncScroll: boolean;
|
||||
}
|
||||
`;
|
||||
|
||||
const OPTIONS_AFTER = `export interface DiffViewOptions {
|
||||
mode: DiffMode;
|
||||
ignore: IgnorePolicy;
|
||||
highlight: HighlightPolicy;
|
||||
collapse: boolean;
|
||||
/**
|
||||
* IDEA's synchronized scrolling of the side-by-side panes: unpadded panes
|
||||
* that carry each other along, with a curved divider. Off, the panes are
|
||||
* padded so unchanged lines line up and scroll together.
|
||||
*/
|
||||
syncScrolling: boolean;
|
||||
}
|
||||
`;
|
||||
|
||||
const FUNCS = ["parse", "format", "validate", "render", "save"].map(
|
||||
(name) => `function ${name}(input) {\n const result = ${name}Impl(input);\n return result;\n}\n`,
|
||||
);
|
||||
|
||||
const MINIFIED = Array.from({ length: 2_000 }, (_, i) => `var a${i}=f(${i},"k${i}");`).join("");
|
||||
|
||||
export const CASES: readonly DiffCase[] = [
|
||||
// A word edited and a comment added: one modified block (the reported case).
|
||||
{ path: "src/options.ts", before: OPTIONS_BEFORE, after: OPTIONS_AFTER },
|
||||
{
|
||||
path: "src/words.ts",
|
||||
before: "const flag = true;\ncall(a, b);\nconst keep = 1;\n",
|
||||
after: "const flag = false;\ncall(a, x, b);\nconst keep = 1;\n",
|
||||
},
|
||||
{
|
||||
path: "src/insert.ts",
|
||||
before: lines(10, (i) => `line ${i}`),
|
||||
after: lines(10, (i) => `line ${i}`).replace("line 5\n", "line 5\n" + lines(5, (i) => `added ${i}`)),
|
||||
},
|
||||
{
|
||||
path: "src/delete.ts",
|
||||
before: lines(12, (i) => `line ${i}`),
|
||||
after: lines(12, (i) => `line ${i}`).replace(
|
||||
lines(4, (i) => `line ${i + 4}`),
|
||||
"",
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "src/indent.py",
|
||||
before: "def f():\n if x:\n return 1\n return 0\n",
|
||||
after: "def f():\n if x:\n return 1\n return 0\n",
|
||||
},
|
||||
{ path: "docs/readme-zh.md", before: "# 说明\n\n这是一个测试文件。\n", after: "# 说明\n\n这是一个示例文件。\n" },
|
||||
{ path: "docs/emoji.md", before: "Hello 👋 world\n", after: "Hello 🌍 world\n" },
|
||||
{ path: "data/no-eol.txt", before: "alpha\nkeep\nbeta", after: "alpha\nkeep\ngamma" },
|
||||
{ path: "data/add-eol.txt", before: "alpha\nbeta", after: "alpha\nbeta\n" },
|
||||
{ path: "src/moved.ts", before: FUNCS.join("\n"), after: [...FUNCS.slice(1), FUNCS[0]!].join("\n") },
|
||||
{ path: "src/new-file.ts", before: null, after: lines(6, (i) => `export const n${i} = ${i};`) },
|
||||
{ path: "src/deleted-file.ts", before: lines(6, (i) => `export const d${i} = ${i};`), after: null },
|
||||
{
|
||||
path: "dist/app.min.js",
|
||||
before: MINIFIED + "\n",
|
||||
after: MINIFIED.replace('f(1000,"k1000")', 'g(1000,"k1000")') + "\n",
|
||||
},
|
||||
// 10,000 lines: 40 scattered word edits, a 200-line insert and a 100-line delete.
|
||||
{
|
||||
path: "big/scattered.ts",
|
||||
before: bigFile(),
|
||||
after: bigFile((i, l) =>
|
||||
i === 5_000
|
||||
? [l, ...Array.from({ length: 200 }, (_, k) => `export const inserted${k} = ${k};`)]
|
||||
: i >= 8_000 && i < 8_100
|
||||
? null
|
||||
: i % 250 === 125
|
||||
? l.replace("compute(", "computeFast(")
|
||||
: l,
|
||||
),
|
||||
},
|
||||
// 10,000 lines, every tenth one edited: 1,000 differences.
|
||||
{
|
||||
path: "big/every-tenth.ts",
|
||||
before: bigFile(),
|
||||
after: bigFile((i, l) => (i % 10 === 5 ? l.replace('"item-', '"entry-') : l)),
|
||||
},
|
||||
// 10,000 lines, every line edited: one 10,000-line modified block.
|
||||
{
|
||||
path: "big/rewrite.ts",
|
||||
before: bigFile(),
|
||||
after: bigFile((_, l) => l.replace("export const", "export let").replace("// section", "// part")),
|
||||
},
|
||||
];
|
||||
|
||||
const git = (cwd: string, ...args: string[]) =>
|
||||
execFileSync("git", args, { cwd, encoding: "utf8", maxBuffer: 64 << 20, stdio: ["ignore", "pipe", "pipe"] });
|
||||
|
||||
/** Builds the repository in `dir` (created, or emptied first). */
|
||||
export function buildDiffRepo(dir: string): string {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const write = (path: string, text: string) => {
|
||||
mkdirSync(dirname(join(dir, path)), { recursive: true });
|
||||
writeFileSync(join(dir, path), text);
|
||||
};
|
||||
git(dir, "init", "-q", "-b", "main");
|
||||
git(dir, "config", "user.email", "[email protected]");
|
||||
git(dir, "config", "user.name", "e2e");
|
||||
git(dir, "config", "core.autocrlf", "false");
|
||||
for (const c of CASES) if (c.before !== null) write(c.path, c.before);
|
||||
git(dir, "add", "-A");
|
||||
git(dir, "commit", "-qm", "initial");
|
||||
for (const c of CASES) {
|
||||
if (c.after === null) rmSync(join(dir, c.path));
|
||||
else write(c.path, c.after);
|
||||
}
|
||||
// Intent-to-add, so `git diff HEAD` lists the new files too.
|
||||
const added = CASES.filter((c) => c.before === null).map((c) => c.path);
|
||||
if (added.length) git(dir, "add", "-N", "--", ...added);
|
||||
return dir;
|
||||
}
|
||||
|
||||
export function tempDiffRepo(): string {
|
||||
return buildDiffRepo(join(mkdtempSync(join(tmpdir(), "openidea-diff-")), "repo"));
|
||||
}
|
||||
|
||||
/** HEAD and working-tree texts of `path` ("" when missing on that side). */
|
||||
export function readSides(dir: string, path: string): { left: string; right: string } {
|
||||
let left = "";
|
||||
try {
|
||||
left = git(dir, "show", `HEAD:${path}`);
|
||||
} catch {
|
||||
// added in the working tree
|
||||
}
|
||||
let right = "";
|
||||
try {
|
||||
right = readFileSync(join(dir, path), "utf8");
|
||||
} catch {
|
||||
// deleted in the working tree
|
||||
}
|
||||
return { left, right };
|
||||
}
|
||||
|
||||
/** Hunks of `git diff -U0` for `path`, the reference count of line differences. */
|
||||
export function gitHunks(dir: string, path: string): number {
|
||||
const out = git(dir, "diff", "-U0", "--no-color", "--diff-algorithm=patience", "HEAD", "--", path);
|
||||
return out.split("\n").filter((l) => l.startsWith("@@")).length;
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const dir = resolve(process.argv[2] ?? "diff-repo");
|
||||
buildDiffRepo(dir);
|
||||
console.log(`Built ${CASES.length} diff cases in ${dir}`);
|
||||
console.log(git(dir, "status", "--short"));
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
/** Linux browser acceptance: synthetic MemoryFs + mocked native IPC, real menu/action/refresh UI. */
|
||||
import { expect, test } from "./fixtures";
|
||||
|
||||
for (const destination of ["gitignore", "localExclude"] as const) {
|
||||
test(`ignore ${destination}: menu dispatch and immediate tree/status refresh`, async ({ workbench: page }) => {
|
||||
await page.evaluate(async (destination) => {
|
||||
const fsModule = "/src/features/project-tree/fs.ts";
|
||||
const storeModule = "/src/features/project-tree/store.ts";
|
||||
const ipcModule = "/src/platform/ipc.ts";
|
||||
const { projectFs } = await import(fsModule);
|
||||
const { reloadAll } = await import(storeModule);
|
||||
const { commands } = await import(ipcModule);
|
||||
const root = "/dev/acme-api";
|
||||
const path = `${root}/scratch.log`;
|
||||
const fs = projectFs(root);
|
||||
await fs.createFile(path);
|
||||
let written = false;
|
||||
commands.vcsAddToIgnore = async (paths: string[], actual: string) => {
|
||||
if (actual !== destination || paths.length !== 1 || paths[0] !== path)
|
||||
throw new Error("Wrong ignore destination or paths");
|
||||
written = true;
|
||||
fs.ignored.add(path);
|
||||
return { status: "ok", data: null };
|
||||
};
|
||||
commands.vcsStatus = async () => {
|
||||
document.body.dataset.ignoreStatusRefresh = written ? "after-write" : "before-write";
|
||||
return {
|
||||
status: "ok",
|
||||
data: written
|
||||
? []
|
||||
: [{ path, relPath: "scratch.log", staged: null, unstaged: "untracked", renamedFrom: null }],
|
||||
};
|
||||
};
|
||||
commands.vcsRoots = async () => ({
|
||||
status: "ok",
|
||||
data: [{ root, operation: null, head: "main", headCommit: null }],
|
||||
});
|
||||
commands.vcsBranchInfo = async () => ({
|
||||
status: "ok",
|
||||
data: { head: "main", headCommit: null, upstream: null, ahead: 0, behind: 0, operation: null },
|
||||
});
|
||||
const vcsModule = "/src/features/vcs/store.ts";
|
||||
const { useVcs } = await import(vcsModule);
|
||||
useVcs.setState({ available: true, root, repo: root, branchesLoaded: false, stashesLoaded: false });
|
||||
await reloadAll();
|
||||
const banner = document.createElement("div");
|
||||
banner.textContent = "LINUX BROWSER E2E · SYNTHETIC FIXTURE · NATIVE IPC MOCKED";
|
||||
Object.assign(banner.style, {
|
||||
position: "fixed",
|
||||
bottom: "28px",
|
||||
right: "18px",
|
||||
zIndex: "999999",
|
||||
padding: "10px 14px",
|
||||
background: "#fff4ce",
|
||||
color: "#362a00",
|
||||
font: "bold 12px sans-serif",
|
||||
border: "1px solid #9e8500",
|
||||
borderRadius: "5px",
|
||||
});
|
||||
document.body.append(banner);
|
||||
}, destination);
|
||||
await page.getByRole("treeitem", { name: "scratch.log", exact: true }).click({ button: "right" });
|
||||
await page.getByRole("menuitem", { name: "Git", exact: true }).hover();
|
||||
await page.getByRole("menuitem", { name: "Add to .gitignore", exact: true }).hover();
|
||||
await expect(page.getByRole("menuitem", { name: ".gitignore", exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("menuitem", { name: ".git/info/exclude", exact: true })).toBeVisible();
|
||||
const captureDir = process.env.OPENIDEA_ACCEPTANCE_DIR;
|
||||
if (captureDir && destination === "gitignore")
|
||||
await page.screenshot({ path: `${captureDir}/01-ignore-destinations.png` });
|
||||
await page
|
||||
.getByRole("menuitem", { name: destination === "gitignore" ? ".gitignore" : ".git/info/exclude", exact: true })
|
||||
.click();
|
||||
await expect
|
||||
.poll(async () =>
|
||||
page.evaluate(async () => {
|
||||
const module = "/src/features/project-tree/store.ts";
|
||||
const { useProjectTree } = await import(module);
|
||||
return useProjectTree
|
||||
.getState()
|
||||
.listings.get("/dev/acme-api")
|
||||
?.find((entry: { name: string }) => entry.name === "scratch.log")?.ignored;
|
||||
}),
|
||||
)
|
||||
.toBe(true);
|
||||
await expect
|
||||
.poll(async () =>
|
||||
page.evaluate(async () => {
|
||||
const module = "/src/features/vcs/store.ts";
|
||||
const { useVcs } = await import(module);
|
||||
return useVcs.getState().status.length;
|
||||
}),
|
||||
)
|
||||
.toBe(0);
|
||||
await expect(page.locator("body")).toHaveAttribute("data-ignore-status-refresh", "after-write");
|
||||
// Display fixture rule text in the actual editor. This is browser memory, not a native disk read.
|
||||
await page.evaluate(async (destination) => {
|
||||
const module = "/src/workbench/contributions.ts";
|
||||
const { editorService } = await import(module);
|
||||
await editorService().openFile(
|
||||
`/dev/acme-api/${destination === "gitignore" ? ".gitignore" : ".git/info/exclude"}`,
|
||||
{ focus: true },
|
||||
);
|
||||
}, destination);
|
||||
const content = page.locator(".cm-editor .cm-content").first();
|
||||
await content.fill(
|
||||
`# Synthetic acceptance fixture: ${destination === "gitignore" ? "shared repository rules" : ".git/info/exclude: local rules (common to linked worktrees)"}\n/scratch.log\n`,
|
||||
);
|
||||
if (captureDir)
|
||||
await page.screenshot({
|
||||
path: `${captureDir}/${destination === "gitignore" ? "02-shared-ignore-effect" : "03-local-exclude-effect"}.png`,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -35,16 +35,3 @@ test("type-ahead jumps to a matching node", async ({ workbench: page }) => {
|
||||
await page.keyboard.type("READ");
|
||||
await expect(tree.locator('[role="treeitem"][aria-selected="true"]')).toHaveText("README.md");
|
||||
});
|
||||
|
||||
test("Git ignore submenu offers shared and local destinations for files and folders", async ({ workbench: page }) => {
|
||||
for (const name of ["README.md", "src"]) {
|
||||
await page.getByRole("treeitem", { name, exact: true }).click({ button: "right" });
|
||||
await page.getByRole("menuitem", { name: "Git", exact: true }).hover();
|
||||
await page.getByRole("menuitem", { name: "Add to .gitignore", exact: true }).hover();
|
||||
await expect(page.getByRole("menuitem", { name: ".gitignore", exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("menuitem", { name: ".git/info/exclude", exact: true })).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await page.keyboard.press("Escape");
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
});
|
||||
+1
-11
@@ -2,7 +2,7 @@
|
||||
"name": "openidea",
|
||||
"private": true,
|
||||
"description": "An open-source IDE inspired by IntelliJ IDEA",
|
||||
"version": "0.1.24",
|
||||
"version": "0.1.23",
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -24,14 +24,6 @@
|
||||
"perf": "node perf/run.ts",
|
||||
"perf:rust": "node perf/rust.ts"
|
||||
},
|
||||
"simple-git-hooks": {
|
||||
"pre-commit": "pnpm lint-staged"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,jsx,mjs,cjs,ts,tsx,mts,cts}": "oxlint --fix --no-error-on-unmatched-pattern",
|
||||
"*": "oxfmt --no-error-on-unmatched-pattern",
|
||||
"*.rs": "rustfmt --edition 2024"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.20.3",
|
||||
"@codemirror/commands": "^6.11.1",
|
||||
@@ -79,10 +71,8 @@
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"jsdom": "^30.1.3",
|
||||
"lint-staged": "^17.6.0",
|
||||
"oxfmt": "^0.74.0",
|
||||
"oxlint": "^1.89.0",
|
||||
"simple-git-hooks": "^2.14.0",
|
||||
"tsrs": "^0.20.0-ts7.1.0-beta",
|
||||
"typescript": "~6.0.3",
|
||||
"vite": "^8.0.16",
|
||||
|
||||
Generated
+14
-54
@@ -116,7 +116,7 @@ importers:
|
||||
version: 2.12.3
|
||||
'@testing-library/jest-dom':
|
||||
specifier: ^7.0.1
|
||||
version: 7.0.1(@testing-library/[email protected])([email protected](@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])))
|
||||
version: 7.0.1(@testing-library/[email protected])([email protected](@types/[email protected])([email protected])([email protected](@types/[email protected])))
|
||||
'@testing-library/react':
|
||||
specifier: ^16.3.3
|
||||
version: 16.3.3(@testing-library/[email protected])(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
@@ -137,22 +137,16 @@ importers:
|
||||
version: 19.3.0(@types/[email protected])
|
||||
'@vitejs/plugin-react':
|
||||
specifier: ^6.0.2
|
||||
version: 6.1.2([email protected](@types/[email protected])([email protected]))
|
||||
version: 6.1.2([email protected](@types/[email protected]))
|
||||
jsdom:
|
||||
specifier: ^30.1.3
|
||||
version: 30.1.3
|
||||
lint-staged:
|
||||
specifier: ^17.6.0
|
||||
version: 17.6.0
|
||||
oxfmt:
|
||||
specifier: ^0.74.0
|
||||
version: 0.74.0
|
||||
oxlint:
|
||||
specifier: ^1.89.0
|
||||
version: 1.89.0
|
||||
simple-git-hooks:
|
||||
specifier: ^2.14.0
|
||||
version: 2.14.0
|
||||
tsrs:
|
||||
specifier: ^0.20.0-ts7.1.0-beta
|
||||
version: 0.20.0-ts7.1.0-beta
|
||||
@@ -161,10 +155,10 @@ importers:
|
||||
version: 6.0.3
|
||||
vite:
|
||||
specifier: ^8.0.16
|
||||
version: 8.3.4(@types/[email protected])([email protected])
|
||||
version: 8.3.4(@types/[email protected])
|
||||
vitest:
|
||||
specifier: ^5.0.5
|
||||
version: 5.0.5(@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected]))
|
||||
version: 5.0.5(@types/[email protected])([email protected])([email protected](@types/[email protected]))
|
||||
|
||||
packages:
|
||||
|
||||
@@ -1587,11 +1581,6 @@ packages:
|
||||
resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-nOhBfYkZEwkFtNzD5pK9s9LkcWoxJ+whLzEnhqi9nl7zTB5n7tg8+9iYgMGx2Wm3VlLu8cbNj0RACzY+nWi5iQ==}
|
||||
engines: {node: '>=22.22.1'}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==}
|
||||
|
||||
@@ -1759,10 +1748,6 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-kkTORPAuxQz2g1QUuN0J8yGWT28tjGBfPOdJ4xT7Vbeu30veKUZQIoID2qGgCDAakaQn59UeZJJ4cFGB8PVP2A==}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -1770,10 +1755,6 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==}
|
||||
engines: {node: '>=0.6.19'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -1955,11 +1936,6 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==}
|
||||
engines: {node: '>= 14.6'}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A==}
|
||||
engines: {node: '>=12.20.0'}
|
||||
@@ -2722,7 +2698,7 @@ snapshots:
|
||||
picocolors: 1.1.1
|
||||
pretty-format: 27.5.1
|
||||
|
||||
'@testing-library/[email protected](@testing-library/[email protected])([email protected](@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])))':
|
||||
'@testing-library/[email protected](@testing-library/[email protected])([email protected](@types/[email protected])([email protected])([email protected](@types/[email protected])))':
|
||||
dependencies:
|
||||
'@adobe/css-tools': 4.5.0
|
||||
'@testing-library/dom': 10.4.2
|
||||
@@ -2732,7 +2708,7 @@ snapshots:
|
||||
picocolors: 1.1.1
|
||||
redent: 3.0.0
|
||||
optionalDependencies:
|
||||
vitest: 5.0.5(@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected]))
|
||||
vitest: 5.0.5(@types/[email protected])([email protected])([email protected](@types/[email protected]))
|
||||
|
||||
'@testing-library/[email protected](@testing-library/[email protected])(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])':
|
||||
dependencies:
|
||||
@@ -2923,19 +2899,19 @@ snapshots:
|
||||
d3-selection: 3.0.0
|
||||
d3-transition: 3.0.1([email protected])
|
||||
|
||||
'@vitejs/[email protected]([email protected](@types/[email protected])([email protected]))':
|
||||
'@vitejs/[email protected]([email protected](@types/[email protected]))':
|
||||
dependencies:
|
||||
'@rolldown/pluginutils': 1.0.1
|
||||
vite: 8.3.4(@types/[email protected])([email protected])
|
||||
vite: 8.3.4(@types/[email protected])
|
||||
|
||||
'@vitest/[email protected]([email protected](@types/[email protected])([email protected]))':
|
||||
'@vitest/[email protected]([email protected](@types/[email protected]))':
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
'@vitest/spy': 5.0.5
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 1.4.3
|
||||
optionalDependencies:
|
||||
vite: 8.3.4(@types/[email protected])([email protected])
|
||||
vite: 8.3.4(@types/[email protected])
|
||||
|
||||
'@vitest/[email protected]': {}
|
||||
|
||||
@@ -3355,14 +3331,6 @@ snapshots:
|
||||
lightningcss-win32-arm64-msvc: 1.33.0
|
||||
lightningcss-win32-x64-msvc: 1.33.0
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
picomatch: 4.0.7
|
||||
string-argv: 0.3.2
|
||||
tinyexec: 1.3.1
|
||||
optionalDependencies:
|
||||
yaml: 2.9.1
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
@@ -3557,14 +3525,10 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
min-indent: 1.0.1
|
||||
@@ -3618,7 +3582,7 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected](@types/[email protected])([email protected]):
|
||||
[email protected](@types/[email protected]):
|
||||
dependencies:
|
||||
lightningcss: 1.33.0
|
||||
picomatch: 4.0.7
|
||||
@@ -3628,12 +3592,11 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/node': 26.6.5
|
||||
fsevents: 2.3.3
|
||||
yaml: 2.9.1
|
||||
|
||||
[email protected](@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])):
|
||||
[email protected](@types/[email protected])([email protected])([email protected](@types/[email protected])):
|
||||
dependencies:
|
||||
'@types/chai': 5.2.3
|
||||
'@vitest/mocker': 5.0.5([email protected](@types/[email protected])([email protected]))
|
||||
'@vitest/mocker': 5.0.5([email protected](@types/[email protected]))
|
||||
chai: 6.3.0
|
||||
es-module-lexer: 2.3.2
|
||||
expect-type: 1.4.0
|
||||
@@ -3644,7 +3607,7 @@ snapshots:
|
||||
tinybench: 6.2.2
|
||||
tinyexec: 1.3.1
|
||||
tinyglobby: 0.2.17
|
||||
vite: 8.3.4(@types/[email protected])([email protected])
|
||||
vite: 8.3.4(@types/[email protected])
|
||||
why-is-node-running: 3.2.1
|
||||
optionalDependencies:
|
||||
'@types/node': 26.6.5
|
||||
@@ -3676,9 +3639,6 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
optional: true
|
||||
|
||||
[email protected](@types/[email protected])([email protected])([email protected]):
|
||||
optionalDependencies:
|
||||
'@types/react': 19.3.0
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
allowBuilds:
|
||||
simple-git-hooks: true
|
||||
@@ -111,9 +111,6 @@ export const ICLS_ATTRIBUTES: Record<string, Array<[Part, string]>> = {
|
||||
["FOREGROUND", "--fg-editor"],
|
||||
["BACKGROUND", "--bg-editor"],
|
||||
],
|
||||
DIFF_MODIFIED: [["BACKGROUND", "--diff-modified"]],
|
||||
DIFF_INSERTED: [["BACKGROUND", "--diff-inserted"]],
|
||||
DIFF_DELETED: [["BACKGROUND", "--diff-deleted"]],
|
||||
DEFAULT_KEYWORD: [["FOREGROUND", "--syn-keyword"]],
|
||||
DEFAULT_STRING: [["FOREGROUND", "--syn-string"]],
|
||||
DEFAULT_NUMBER: [["FOREGROUND", "--syn-number"]],
|
||||
|
||||
@@ -36,7 +36,6 @@ dirs.workspace = true
|
||||
ide-core.workspace = true
|
||||
dunce.workspace = true
|
||||
ide-clipboard.workspace = true
|
||||
ide-fonts.workspace = true
|
||||
ide-text.workspace = true
|
||||
ide-vfs.workspace = true
|
||||
ide-workspace.workspace = true
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
//! Font families installed on this machine, for the font pickers in Settings.
|
||||
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use ide_fonts::FontFamily;
|
||||
|
||||
/// Every installed family, sorted, each flagged monospaced or not. Read once
|
||||
/// per run (off the command threads) and cached.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn system_font_families() -> IdeResult<Vec<FontFamily>> {
|
||||
tauri::async_runtime::spawn_blocking(|| ide_fonts::families().to_vec())
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))
|
||||
}
|
||||
@@ -20,7 +20,6 @@ pub mod deploy;
|
||||
pub mod doc;
|
||||
pub mod docker;
|
||||
pub mod env;
|
||||
pub mod fonts;
|
||||
pub mod fs;
|
||||
pub mod github;
|
||||
pub mod history;
|
||||
@@ -110,7 +109,6 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
clipboard::clipboard_file_list,
|
||||
clipboard::clipboard_has_files,
|
||||
clipboard::clipboard_set_file_list,
|
||||
fonts::system_font_families,
|
||||
doc::doc_open,
|
||||
doc::doc_open_virtual,
|
||||
doc::doc_close,
|
||||
@@ -201,7 +199,6 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
vcs::vcs_branches,
|
||||
vcs::vcs_stashes,
|
||||
vcs::vcs_stage,
|
||||
vcs::vcs_add_to_ignore,
|
||||
vcs::vcs_unstage,
|
||||
vcs::vcs_stage_patch,
|
||||
vcs::vcs_discard,
|
||||
@@ -269,7 +266,6 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
run::run_list,
|
||||
run::run_close_stdin,
|
||||
run::run_resize,
|
||||
run::run_gradle_jvm_args_script,
|
||||
run::run_test_reports,
|
||||
build::build_model,
|
||||
build::build_sync,
|
||||
|
||||
@@ -89,17 +89,6 @@ pub fn run_system_env(state: State<'_, AppState>) -> std::collections::BTreeMap<
|
||||
ide_host::ops::run::run_system_env(&state.host, "")
|
||||
}
|
||||
|
||||
/// Writes the Gradle init script adding `jvm_args` to the JVMs the build forks; returns its path.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn run_gradle_jvm_args_script(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
jvm_args: Vec<String>,
|
||||
) -> IdeResult<String> {
|
||||
ide_host::ops::run::run_gradle_jvm_args_script(&state.host, window.label(), jvm_args).await
|
||||
}
|
||||
|
||||
/// Results from the JUnit XML reports (Surefire, Failsafe, Gradle) of the module at
|
||||
/// `module` (relative to the project root) written since `since_ms` (Unix epoch, ms).
|
||||
#[tauri::command]
|
||||
|
||||
@@ -108,17 +108,6 @@ pub async fn vcs_stage(window: Window, state: State<'_, AppState>, paths: Vec<Pa
|
||||
ide_host::ops::vcs::vcs_stage(&state.host, window.label(), paths).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn vcs_add_to_ignore(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
paths: Vec<PathBuf>,
|
||||
destination: IgnoreDestination,
|
||||
) -> IdeResult<()> {
|
||||
ide_host::ops::vcs::vcs_add_to_ignore(&state.host, window.label(), paths, destination).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn vcs_unstage(window: Window, state: State<'_, AppState>, paths: Vec<PathBuf>) -> IdeResult<()> {
|
||||
@@ -531,35 +520,20 @@ pub async fn vcs_diff_revisions(
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn vcs_create_patch(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
repo: Option<PathBuf>,
|
||||
source: PatchSource,
|
||||
) -> IdeResult<String> {
|
||||
ide_host::ops::vcs::vcs_create_patch(&state.host, window.label(), repo, source).await
|
||||
pub async fn vcs_create_patch(window: Window, state: State<'_, AppState>, source: PatchSource) -> IdeResult<String> {
|
||||
ide_host::ops::vcs::vcs_create_patch(&state.host, window.label(), source).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn vcs_patch_preview(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
repo: Option<PathBuf>,
|
||||
patch: String,
|
||||
) -> IdeResult<Vec<PatchFile>> {
|
||||
ide_host::ops::vcs::vcs_patch_preview(&state.host, window.label(), repo, patch).await
|
||||
pub async fn vcs_patch_preview(window: Window, state: State<'_, AppState>, patch: String) -> IdeResult<Vec<PatchFile>> {
|
||||
ide_host::ops::vcs::vcs_patch_preview(&state.host, window.label(), patch).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn vcs_apply_patch(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
repo: Option<PathBuf>,
|
||||
patch: String,
|
||||
) -> IdeResult<ApplyOutcome> {
|
||||
ide_host::ops::vcs::vcs_apply_patch(&state.host, window.label(), repo, patch).await
|
||||
pub async fn vcs_apply_patch(window: Window, state: State<'_, AppState>, patch: String) -> IdeResult<ApplyOutcome> {
|
||||
ide_host::ops::vcs::vcs_apply_patch(&state.host, window.label(), patch).await
|
||||
}
|
||||
|
||||
/// Shelve Changes: saves the changes of `paths` as a patch on the shelf and reverts them.
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! (`window_take_launch`), and re-taken whenever [`LAUNCH_EVENT`] fires (a
|
||||
//! second app instance asking an already-loaded window to open a path).
|
||||
//!
|
||||
//! Settings open in a separate window `settings-<parent>` (one per project
|
||||
//! Settings open in a native child window `settings-<parent>` (one per project
|
||||
//! window) that shares its parent's project (see [`AppState::project`]); its
|
||||
//! [`Launch::settings_page`] tells it which page to show.
|
||||
|
||||
@@ -666,7 +666,7 @@ pub fn cycle(app: &AppHandle, current: &str, forward: bool) {
|
||||
}
|
||||
|
||||
/// Opens (or focuses) the settings window of `parent` showing `page`: a
|
||||
/// separate window centred over its parent, sized like IDEA's Settings.
|
||||
/// native child window centred over its parent, sized like IDEA's Settings.
|
||||
pub fn open_settings_window(
|
||||
app: &AppHandle,
|
||||
parent: &WebviewWindow,
|
||||
@@ -699,17 +699,13 @@ pub fn open_settings_window(
|
||||
config.maximized = false;
|
||||
config.fullscreen = false;
|
||||
config.drag_drop_enabled = false;
|
||||
// Paint the theme's frame colour before the page loads.
|
||||
// macOS shows a child window as soon as it is attached, before the page loads: paint the theme's frame colour.
|
||||
if let Some(color) = background.as_deref().and_then(hex_color) {
|
||||
config.background_color = Some(color);
|
||||
}
|
||||
windows.set_launch(&label, Launch { settings_page: page, new_projects, ..Default::default() });
|
||||
let builder = WebviewWindowBuilder::from_config(app, &config);
|
||||
// AppKit child windows can disappear when dragged to another display's Space.
|
||||
// The settings label already tracks ownership and parent-close cleanup.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let builder = builder.and_then(|b| b.parent(&parent));
|
||||
let window = builder
|
||||
let window = WebviewWindowBuilder::from_config(app, &config)
|
||||
.and_then(|b| b.parent(&parent))
|
||||
.and_then(|b| b.build())
|
||||
.map_err(|e| IdeError::internal(format!("cannot create settings window: {e}")))?;
|
||||
apply_zoom(app, &window);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "OpenIDEA",
|
||||
"version": "0.1.24",
|
||||
"version": "0.1.23",
|
||||
"identifier": "ai.l0ng.openidea",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
|
||||
+3
-36
@@ -152,11 +152,6 @@ export const commands = {
|
||||
* false when the clipboard stayed busy; the caller keeps working either way.
|
||||
*/
|
||||
clipboardSetFileList: (paths: string[]) => typedError<boolean, IdeError>(__TAURI_INVOKE("clipboard_set_file_list", { paths })),
|
||||
/**
|
||||
* Every installed family, sorted, each flagged monospaced or not. Read once
|
||||
* per run (off the command threads) and cached.
|
||||
*/
|
||||
systemFontFamilies: () => typedError<FontFamily[], IdeError>(__TAURI_INVOKE("system_font_families")),
|
||||
/**
|
||||
* Opens a document. Async: reading and decoding a large file must not block
|
||||
* the main thread (synchronous commands run on it).
|
||||
@@ -404,7 +399,6 @@ export const commands = {
|
||||
vcsBranches: (repo: string | null) => typedError<Branch[], IdeError>(__TAURI_INVOKE("vcs_branches", { repo })),
|
||||
vcsStashes: (repo: string | null) => typedError<Stash[], IdeError>(__TAURI_INVOKE("vcs_stashes", { repo })),
|
||||
vcsStage: (paths: string[]) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_stage", { paths })),
|
||||
vcsAddToIgnore: (paths: string[], destination: IgnoreDestination) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_add_to_ignore", { paths, destination })),
|
||||
vcsUnstage: (paths: string[]) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_unstage", { paths })),
|
||||
vcsStagePatch: (patch: string) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_stage_patch", { patch })),
|
||||
vcsDiscard: (paths: string[]) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_discard", { paths })),
|
||||
@@ -475,9 +469,9 @@ export const commands = {
|
||||
vcsDiffWithWorktree: (repo: string | null, rev: string) => typedError<ChangedFile[], IdeError>(__TAURI_INVOKE("vcs_diff_with_worktree", { repo, rev })),
|
||||
/** Files that differ between two revisions (the "N files updated" of Update Project). */
|
||||
vcsDiffRevisions: (repo: string | null, from: string, to: string) => typedError<ChangedFile[], IdeError>(__TAURI_INVOKE("vcs_diff_revisions", { repo, from, to })),
|
||||
vcsCreatePatch: (repo: string | null, source: PatchSource) => typedError<string, IdeError>(__TAURI_INVOKE("vcs_create_patch", { repo, source })),
|
||||
vcsPatchPreview: (repo: string | null, patch: string) => typedError<PatchFile[], IdeError>(__TAURI_INVOKE("vcs_patch_preview", { repo, patch })),
|
||||
vcsApplyPatch: (repo: string | null, patch: string) => typedError<ApplyOutcome, IdeError>(__TAURI_INVOKE("vcs_apply_patch", { repo, patch })),
|
||||
vcsCreatePatch: (source: PatchSource) => typedError<string, IdeError>(__TAURI_INVOKE("vcs_create_patch", { source })),
|
||||
vcsPatchPreview: (patch: string) => typedError<PatchFile[], IdeError>(__TAURI_INVOKE("vcs_patch_preview", { patch })),
|
||||
vcsApplyPatch: (patch: string) => typedError<ApplyOutcome, IdeError>(__TAURI_INVOKE("vcs_apply_patch", { patch })),
|
||||
/** Shelve Changes: saves the changes of `paths` as a patch on the shelf and reverts them. */
|
||||
vcsShelve: (name: string, paths: string[]) => typedError<ShelvedChangeList, IdeError>(__TAURI_INVOKE("vcs_shelve", { name, paths })),
|
||||
vcsShelves: () => typedError<ShelvedChangeList[], IdeError>(__TAURI_INVOKE("vcs_shelves")),
|
||||
@@ -521,8 +515,6 @@ export const commands = {
|
||||
runCloseStdin: (id: RunId) => typedError<null, IdeError>(__TAURI_INVOKE("run_close_stdin", { id })),
|
||||
/** Terminal size of a run started with "Emulate terminal in output console". */
|
||||
runResize: (id: RunId, cols: number, rows: number) => typedError<null, IdeError>(__TAURI_INVOKE("run_resize", { id, cols, rows })),
|
||||
/** Writes the Gradle init script adding `jvm_args` to the JVMs the build forks; returns its path. */
|
||||
runGradleJvmArgsScript: (jvmArgs: string[]) => typedError<string, IdeError>(__TAURI_INVOKE("run_gradle_jvm_args_script", { jvmArgs })),
|
||||
/**
|
||||
* Results from the JUnit XML reports (Surefire, Failsafe, Gradle) of the module at
|
||||
* `module` (relative to the project root) written since `since_ms` (Unix epoch, ms).
|
||||
@@ -1187,15 +1179,6 @@ export type AgentRegistry = {
|
||||
warning: string | null,
|
||||
};
|
||||
|
||||
/** An API request the agent is about to retry. */
|
||||
export type AgentRetry = {
|
||||
attempt: number,
|
||||
maxRetries: number | null,
|
||||
/** When the next attempt starts (ms since the epoch). */
|
||||
retryAtMs: number | null,
|
||||
error: string | null,
|
||||
};
|
||||
|
||||
/** Where an agent comes from. */
|
||||
export type AgentSource = { kind: "builtin" } |
|
||||
/** Defined by the user in `<config>/agents/<id>.toml`. */
|
||||
@@ -1255,11 +1238,6 @@ export type AgentTask = {
|
||||
capabilities?: AgentCapabilities,
|
||||
/** When a [`AgentStatus::Limited`] task's limit resets (ms since the epoch), if the agent said. */
|
||||
resetsMs?: number | null,
|
||||
/**
|
||||
* The agent is retrying a failed API request (connection lost, overloaded…); cleared
|
||||
* by its next output or any status change.
|
||||
*/
|
||||
retry?: AgentRetry | null,
|
||||
};
|
||||
|
||||
export type AgentToolDiff = {
|
||||
@@ -2985,15 +2963,6 @@ export type FoldingSettings = {
|
||||
docComments?: boolean,
|
||||
};
|
||||
|
||||
/**
|
||||
* One installed family. `monospaced` is the font system's own flag (fixed
|
||||
* pitch; on Linux also fontconfig's dual-width CJK monospace).
|
||||
*/
|
||||
export type FontFamily = {
|
||||
name: string,
|
||||
monospaced: boolean,
|
||||
};
|
||||
|
||||
export type FormatOptions = {
|
||||
tabSize: number,
|
||||
insertSpaces: boolean,
|
||||
@@ -3339,8 +3308,6 @@ export type IdentifierOccurrence = {
|
||||
qualified: boolean,
|
||||
};
|
||||
|
||||
export type IgnoreDestination = "gitignore" | "localExclude";
|
||||
|
||||
export type IndentConfig = IndentConfig_Serialize | IndentConfig_Deserialize;
|
||||
|
||||
export type IndentConfig_Deserialize = {
|
||||
|
||||
@@ -102,7 +102,6 @@ const EXT_LANG: Record<string, string> = {
|
||||
kts: "kotlin",
|
||||
java: "java",
|
||||
py: "python",
|
||||
gd: "gdscript",
|
||||
go: "go",
|
||||
json: "json",
|
||||
md: "markdown",
|
||||
@@ -183,15 +182,6 @@ const MEM_LANGUAGES: LanguageInfo[] = [
|
||||
blockComment: null,
|
||||
indent: null,
|
||||
},
|
||||
{
|
||||
id: "gdscript",
|
||||
name: "GDScript",
|
||||
extensions: ["gd"],
|
||||
uiMode: null,
|
||||
lineComment: "#",
|
||||
blockComment: null,
|
||||
indent: { tabSize: 4, insertSpaces: false },
|
||||
},
|
||||
{
|
||||
id: "json",
|
||||
name: "JSON",
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { EditorSelection, EditorState, type Extension } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { insertNewlineAndIndent } from "@codemirror/commands";
|
||||
import { indentService, indentUnit } from "@codemirror/language";
|
||||
import {
|
||||
addCaretsToLineEnds,
|
||||
commentLine,
|
||||
completeStatement,
|
||||
joinLines,
|
||||
startNewLine,
|
||||
startNewLineBefore,
|
||||
toggleCase,
|
||||
unselectOccurrence,
|
||||
} from "./editing";
|
||||
import { commentTokens } from "./languages";
|
||||
import { toggleCaret } from "./multiCaret";
|
||||
import { enterInLineComment, lineCommentStyle } from "./comments";
|
||||
import { languages } from "@codemirror/language-data";
|
||||
@@ -45,7 +42,7 @@ function make(text: string, extensions: Extension = []) {
|
||||
state: EditorState.create({
|
||||
doc,
|
||||
selection: EditorSelection.create(ranges.length ? ranges : [EditorSelection.cursor(0)]),
|
||||
extensions: [EditorState.allowMultipleSelections.of(true), extensions, indentUnit.of(" ")],
|
||||
extensions: [EditorState.allowMultipleSelections.of(true), indentUnit.of(" "), extensions],
|
||||
}),
|
||||
parent: document.body,
|
||||
});
|
||||
@@ -262,79 +259,3 @@ describe("enter in a line comment", async () => {
|
||||
expect(enterInLineComment(make("f(|x)", ext))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fallback indentation for GDScript", () => {
|
||||
const gd = [
|
||||
EditorState.tabSize.of(4),
|
||||
indentUnit.of("\t"),
|
||||
commentTokens({
|
||||
id: "gdscript",
|
||||
name: "GDScript",
|
||||
extensions: ["gd"],
|
||||
uiMode: null,
|
||||
lineComment: "#",
|
||||
blockComment: null,
|
||||
indent: { tabSize: 4, insertSpaces: false },
|
||||
}),
|
||||
];
|
||||
|
||||
it("indents with a tab after a function header ending in a colon", () => {
|
||||
const v = make("func test() -> void:|", gd);
|
||||
insertNewlineAndIndent(v);
|
||||
expect(show(v)).toBe("func test() -> void:\n\t|");
|
||||
});
|
||||
|
||||
it("indents nested blocks and ignores trailing comments", () => {
|
||||
const v = make("func test() -> void:\n\tif x > 0: # check|", gd);
|
||||
insertNewlineAndIndent(v);
|
||||
expect(show(v)).toBe("func test() -> void:\n\tif x > 0: # check\n\t\t|");
|
||||
});
|
||||
|
||||
it("does not indent after a comment ending in a colon", () => {
|
||||
const v = make("\t# note:|", gd);
|
||||
insertNewlineAndIndent(v);
|
||||
expect(show(v)).toBe("\t# note:\n\t|");
|
||||
});
|
||||
|
||||
it("dedents after pass, return, break, and continue", () => {
|
||||
const v = make("func test() -> void:\n\tpass|", gd);
|
||||
insertNewlineAndIndent(v);
|
||||
expect(show(v)).toBe("func test() -> void:\n\tpass\n|");
|
||||
|
||||
const w = make("func test() -> int:\n\tif ready:\n\t\treturn 1|", gd);
|
||||
insertNewlineAndIndent(w);
|
||||
expect(show(w)).toBe("func test() -> int:\n\tif ready:\n\t\treturn 1\n\t|");
|
||||
});
|
||||
|
||||
it("completes a GDScript function header with a colon and indents the next line", () => {
|
||||
const v = make("func test() -> void|", gd);
|
||||
completeStatement("gdscript")(v);
|
||||
expect(show(v)).toBe("func test() -> void:\n\t|");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fallback indentation boundaries", () => {
|
||||
const fallback = (id: string, lineComment = "#") =>
|
||||
commentTokens({
|
||||
id,
|
||||
name: id,
|
||||
extensions: [],
|
||||
uiMode: null,
|
||||
lineComment,
|
||||
blockComment: null,
|
||||
indent: null,
|
||||
});
|
||||
it.each([
|
||||
["gdscript", "var items = {|}", "var items = {\n |\n}"],
|
||||
["gdscript", 'var text = "unfinished:|', 'var text = "unfinished:\n|'],
|
||||
["gdscript", 'var text = "unfinished {|', 'var text = "unfinished {\n|'],
|
||||
["gdscript", 'var text = "#" # comment:|', 'var text = "#" # comment:\n|'],
|
||||
["gdscript", "if ready: # note|", "if ready: # note\n |"],
|
||||
["elixir", "if ready, do: value|", "if ready, do: value\n|"],
|
||||
["lua", "if ready then work() end|", "if ready then work() end\n|"],
|
||||
])("handles %s: %s", (id, input, expected) => {
|
||||
const v = make(input, fallback(id));
|
||||
insertNewlineAndIndent(v);
|
||||
expect(show(v)).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -539,7 +539,6 @@ export const SEMICOLON_LANGS = new Set([
|
||||
/** Blocks close with a keyword (`end`) or by indentation: never complete them with braces. */
|
||||
export const KEYWORD_BLOCK_LANGS = new Set([
|
||||
"python",
|
||||
"gdscript",
|
||||
"ruby",
|
||||
"lua",
|
||||
"elixir",
|
||||
@@ -622,11 +621,7 @@ export function completeStatementSpec(state: EditorState, language: string): Tra
|
||||
}
|
||||
let insert = "";
|
||||
if (SEMICOLON_LANGS.has(language) && !comment && text.trim() && !/[;{},:]$/.test(text)) insert = ";";
|
||||
if (
|
||||
(language === "python" || language === "gdscript") &&
|
||||
BLOCK_HEAD.test(text.replace(/^\s*(def|elif|static\s+func)\b/, "if")) &&
|
||||
!text.endsWith(":")
|
||||
)
|
||||
if (language === "python" && BLOCK_HEAD.test(text.replace(/^\s*(def|elif)\b/, "if")) && !text.endsWith(":"))
|
||||
insert = ":";
|
||||
const opensBlock =
|
||||
text.endsWith("{") ||
|
||||
|
||||
@@ -5,13 +5,7 @@
|
||||
* and indentation fall back to the backend definition when the mode lacks them.
|
||||
*/
|
||||
import { EditorState, Prec, type Extension } from "@codemirror/state";
|
||||
import {
|
||||
LRLanguage,
|
||||
LanguageDescription,
|
||||
LanguageSupport,
|
||||
indentService,
|
||||
language as languageFacet,
|
||||
} from "@codemirror/language";
|
||||
import { LRLanguage, LanguageDescription, LanguageSupport } from "@codemirror/language";
|
||||
import { styleTags, tags as t } from "@lezer/highlight";
|
||||
import { languages as languageData } from "@codemirror/language-data";
|
||||
import type { DocBackend, LanguageInfo } from "../backend";
|
||||
@@ -124,103 +118,11 @@ export function loadedSupport(desc: LanguageDescription | null): LanguageSupport
|
||||
return desc?.support ?? null;
|
||||
}
|
||||
|
||||
const COLON_INDENT_LANGS = new Set(["gdscript", "python"]);
|
||||
const DEDENT_STATEMENT = /^\s*(?:pass|break|continue|return)\b/;
|
||||
const KEYWORD_OPEN_LANGS = new Set(["elixir", "ruby", "lua"]);
|
||||
const KEYWORD_OPEN =
|
||||
/\b(?:do(?:\s*\|[^|]*\|)?|then|->)$|^\s*(?:def|defp|defmodule|class|module|function|local\s+function|if|unless|elsif|elseif|else|while|until|for|case|begin|cond|with|receive|try)\b/;
|
||||
const NO_INDENT_LANGS = new Set(["plaintext", "markdown", "log", "diff", "ignore", "gitattributes"]);
|
||||
|
||||
function opensBlock(code: string, language: string | undefined): boolean {
|
||||
if (!code) return false;
|
||||
if (/[{([]$/.test(code)) return true;
|
||||
if (COLON_INDENT_LANGS.has(language ?? "") && code.endsWith(":")) return true;
|
||||
if (language === "elixir") return /\bdo$|->$/.test(code);
|
||||
if (KEYWORD_OPEN_LANGS.has(language ?? "") && !/\bend$/.test(code) && KEYWORD_OPEN.test(code)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function dedentsAfter(code: string, language: string | undefined): boolean {
|
||||
if (!COLON_INDENT_LANGS.has(language ?? "")) return false;
|
||||
return DEDENT_STATEMENT.test(code) && !/[,\\]$/.test(code);
|
||||
}
|
||||
|
||||
/** Hide literal contents before interpreting punctuation as block syntax. */
|
||||
function indentationCode(text: string, lineComment: string | undefined): string {
|
||||
let code = "";
|
||||
let quote: string | null = null;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const ch = text[i]!;
|
||||
if (quote) {
|
||||
if (ch === "\\") i++;
|
||||
else if (ch === quote) quote = null;
|
||||
} else if (ch === '"' || ch === "'" || ch === "`") {
|
||||
quote = ch;
|
||||
code += "x";
|
||||
} else if (lineComment && text.startsWith(lineComment, i)) break;
|
||||
else code += ch;
|
||||
}
|
||||
return code.trimEnd();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback indentation for languages without a CodeMirror mode (GDScript, Zig,
|
||||
* Elixir, HCL, Terraform…): preserves the previous line's indentation and
|
||||
* indents one unit deeper after a block opener (`:`, `{`, `(`, `[` or keyword).
|
||||
*/
|
||||
export function fallbackIndent(language?: string): Extension {
|
||||
return indentService.of((cx, pos) => {
|
||||
if (cx.state.facet(languageFacet)) return undefined;
|
||||
const lineComment = cx.state.languageDataAt<{ line?: string }>("commentTokens", pos)[0]?.line;
|
||||
const stripComment = (text: string) => indentationCode(text, lineComment);
|
||||
|
||||
const after = cx.textAfterPos(pos);
|
||||
const closingBracket = /^\s*[}\])]/.test(after);
|
||||
|
||||
if (cx.simulatedBreak != null) {
|
||||
const line = cx.state.doc.lineAt(pos);
|
||||
const before = line.text.slice(0, pos - line.from);
|
||||
const base = cx.countColumn(before, before.search(/\S|$/));
|
||||
if (/\S/.test(before)) {
|
||||
const code = stripComment(before);
|
||||
if (closingBracket) return /[{([]$/.test(code) ? base : Math.max(0, base - cx.unit);
|
||||
if (opensBlock(code, language)) return base + cx.unit;
|
||||
if (dedentsAfter(code, language)) return Math.max(0, base - cx.unit);
|
||||
return base;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
const curLine = cx.state.doc.lineAt(pos);
|
||||
for (let n = curLine.number - 1; n >= 1; n--) {
|
||||
const prev = cx.state.doc.line(n);
|
||||
if (!/\S/.test(prev.text)) continue;
|
||||
const base = cx.countColumn(prev.text, prev.text.search(/\S|$/));
|
||||
const code = stripComment(prev.text);
|
||||
if (closingBracket) return /[{([]$/.test(code) ? base : Math.max(0, base - cx.unit);
|
||||
if (opensBlock(code, language)) return base + cx.unit;
|
||||
if (dedentsAfter(code, language)) return Math.max(0, base - cx.unit);
|
||||
return base;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
/** Comment tokens and fallback indentation from the backend definition, below the mode's own data. */
|
||||
export function commentTokens(info: LanguageInfo | undefined, language = info?.id): Extension {
|
||||
const lang = language ?? info?.id;
|
||||
const hasComments = !!info && !!(info.lineComment || info.blockComment);
|
||||
const needsIndent = !!lang && !NO_INDENT_LANGS.has(lang);
|
||||
if (!hasComments && !needsIndent) return [];
|
||||
const data: Record<string, unknown> = {};
|
||||
if (hasComments) {
|
||||
const tokens: { line?: string; block?: { open: string; close: string } } = {};
|
||||
if (info!.lineComment) tokens.line = info!.lineComment;
|
||||
if (info!.blockComment) tokens.block = { open: info!.blockComment[0], close: info!.blockComment[1] };
|
||||
data.commentTokens = tokens;
|
||||
}
|
||||
if (needsIndent) data.indentOnInput = /^\s*[}\])]$/;
|
||||
const out: Extension[] = [Prec.lowest(EditorState.languageData.of(() => [data]))];
|
||||
if (needsIndent) out.push(Prec.lowest(fallbackIndent(lang)));
|
||||
return out;
|
||||
/** Comment tokens from the backend definition, below the mode's own data. */
|
||||
export function commentTokens(info: LanguageInfo | undefined): Extension {
|
||||
if (!info || (!info.lineComment && !info.blockComment)) return [];
|
||||
const tokens: { line?: string; block?: { open: string; close: string } } = {};
|
||||
if (info.lineComment) tokens.line = info.lineComment;
|
||||
if (info.blockComment) tokens.block = { open: info.blockComment[0], close: info.blockComment[1] };
|
||||
return Prec.lowest(EditorState.languageData.of(() => [{ commentTokens: tokens }]));
|
||||
}
|
||||
@@ -355,7 +355,7 @@ export function baseExtensions(o: SetupOptions): Extension {
|
||||
tabKeymap,
|
||||
keymap.of([...closeBracketsKeymap, ...editorBaseKeymap, indentWithTab]),
|
||||
compartments.language.of(o.language ?? []),
|
||||
compartments.comments.of(commentTokens(o.info, o.ctx.language)),
|
||||
compartments.comments.of(commentTokens(o.info)),
|
||||
compartments.indent.of(indentExtension(o.indent ?? effectiveIndent(o.settings, o.info))),
|
||||
compartments.readOnly.of(readOnlyExtension(o.ctx.readOnly)),
|
||||
compartments.wrap.of(wrapExtension(o.settings, o.ctx.path, o.wrapOverride)),
|
||||
|
||||
@@ -101,7 +101,6 @@ describe("smart backspace", () => {
|
||||
it("applies in 'proper indent' mode except in indentation-based languages", () => {
|
||||
expect(smartBackspaceApplies("autoindent", "java")).toBe(true);
|
||||
expect(smartBackspaceApplies("autoindent", "python")).toBe(false);
|
||||
expect(smartBackspaceApplies("autoindent", "gdscript")).toBe(false);
|
||||
expect(smartBackspaceApplies("nearest", "java")).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ export const tabKeymap: Extension = keymap.of([{ key: "Tab", run: insertTab }]);
|
||||
// --- Backspace -------------------------------------------------------------------------------
|
||||
|
||||
/** Languages whose layout is indentation: IDEA keeps "to nearest indent position" there. */
|
||||
const NEAREST_INDENT_LANGS = new Set(["python", "gdscript", "yaml", "shellscript"]);
|
||||
const NEAREST_INDENT_LANGS = new Set(["python", "yaml", "shellscript"]);
|
||||
|
||||
export function smartBackspaceApplies(mode: string, language: string | undefined): boolean {
|
||||
return mode === "autoindent" && !NEAREST_INDENT_LANGS.has(language ?? "");
|
||||
|
||||
@@ -422,7 +422,7 @@ export class DocModel {
|
||||
compartments.wrap.reconfigure(wrapExtension(editor, this.path, this.softWrapOverride)),
|
||||
compartments.sticky.reconfigure(stickyExtension(editor)),
|
||||
compartments.font.reconfigure(fontExtension(editor)),
|
||||
compartments.comments.reconfigure(commentTokens(this.info, this.meta.language)),
|
||||
compartments.comments.reconfigure(commentTokens(this.info)),
|
||||
compartments.lineNumbers.reconfigure(lineNumbersExtension(editor)),
|
||||
compartments.folding.reconfigure(foldingFor(editor)),
|
||||
compartments.caret.reconfigure(caretFor(editor)),
|
||||
|
||||
@@ -9,15 +9,14 @@
|
||||
import { ViewPlugin, type EditorView, type ViewUpdate } from "@codemirror/view";
|
||||
import { commands, type CodeAction, type Diagnostic } from "@/platform/ipc";
|
||||
import { editorPrefs } from "@/platform/editorSettings";
|
||||
import { docOf, isUnavailable, lspRequest } from "./lsp";
|
||||
import { docOf, lspRequest } from "./lsp";
|
||||
import { diagnosticsFor, subscribeDiagnostics } from "./diagnostics";
|
||||
import { applyCodeAction } from "./intentions";
|
||||
import { offsetsToRange, rangeToOffsets } from "./positions";
|
||||
import { featureBackoff } from "./availability";
|
||||
import { serverBackoff } from "./availability";
|
||||
|
||||
const IDLE_MS = 1500;
|
||||
const IMPORT_TITLE = /\bimport\b/i;
|
||||
const importBackoff = featureBackoff();
|
||||
|
||||
/** The single import fix among `actions`, if exactly one exists. */
|
||||
export function unambiguousImport(actions: readonly CodeAction[]): CodeAction | null {
|
||||
@@ -66,7 +65,7 @@ const autoImportPlugin = ViewPlugin.fromClass(
|
||||
const view = this.view;
|
||||
const doc = docOf(view);
|
||||
if ((!prefs.addUnambiguous && !prefs.optimizeOnTheFly) || this.running || !doc || doc.readOnly) return;
|
||||
if (!view.dom.isConnected || !view.hasFocus || importBackoff.active(doc.doc)) return;
|
||||
if (!view.dom.isConnected || !view.hasFocus || serverBackoff.active(doc.doc)) return;
|
||||
this.running = true;
|
||||
try {
|
||||
const caretLine = view.state.doc.lineAt(view.state.selection.main.head).number - 1;
|
||||
@@ -76,9 +75,8 @@ const autoImportPlugin = ViewPlugin.fromClass(
|
||||
this.dirty = false;
|
||||
await this.optimize(caretLine);
|
||||
}
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// Servers without these code actions: nothing to do.
|
||||
if (isUnavailable(e)) importBackoff.mark(doc.doc);
|
||||
} finally {
|
||||
this.running = false;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ vi.mock("@/platform/ipc", async (orig) => {
|
||||
};
|
||||
});
|
||||
|
||||
const { onServerSettled, featureBackoff, installAvailabilityEvents } = await import("./availability");
|
||||
const { onServerSettled } = await import("./availability");
|
||||
|
||||
const status = (s: string) => {
|
||||
for (const fn of listeners) fn({ type: "languageServer", server: 1, status: s } as unknown as IdeEvent);
|
||||
@@ -57,43 +57,3 @@ describe("onServerSettled", () => {
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("featureBackoff", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
listeners.clear();
|
||||
});
|
||||
|
||||
it("leaves a document alone until the back-off expires", () => {
|
||||
vi.useFakeTimers();
|
||||
const backoff = featureBackoff(1000);
|
||||
expect(backoff.active(1)).toBe(false);
|
||||
backoff.mark(1);
|
||||
expect(backoff.active(1)).toBe(true);
|
||||
expect(backoff.active(2)).toBe(false);
|
||||
vi.advanceTimersByTime(1001);
|
||||
expect(backoff.active(1)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps features independent: one feature backing off does not stop another", () => {
|
||||
const inlay = featureBackoff();
|
||||
const completion = featureBackoff();
|
||||
inlay.mark(1);
|
||||
expect(inlay.active(1)).toBe(true);
|
||||
expect(completion.active(1)).toBe(false);
|
||||
});
|
||||
|
||||
it("is cleared for every feature when a server becomes ready", () => {
|
||||
const a = featureBackoff();
|
||||
const b = featureBackoff();
|
||||
const off = installAvailabilityEvents();
|
||||
a.mark(1);
|
||||
b.mark(2);
|
||||
status("starting");
|
||||
expect(a.active(1)).toBe(true);
|
||||
status("ready");
|
||||
expect(a.active(1)).toBe(false);
|
||||
expect(b.active(2)).toBe(false);
|
||||
off();
|
||||
});
|
||||
});
|
||||
@@ -1,45 +1,29 @@
|
||||
/**
|
||||
* Per-document, per-feature back-off after `unavailable` errors, so background
|
||||
* features (completion while typing, lightbulb, inlay hints…) don't hammer the
|
||||
* backend for files without a language server, or for a feature the server
|
||||
* doesn't offer. Each feature owns its back-off: a server lacking one
|
||||
* capability (Godot has no inlay hints, say) must not switch off the others.
|
||||
* All of them are cleared when any server becomes ready.
|
||||
* Per-document back-off after `unavailable` errors, so background features
|
||||
* (completion while typing, lightbulb, inlay hints…) don't hammer the backend
|
||||
* for files without a language server. Cleared when any server becomes ready.
|
||||
*/
|
||||
import { isTauri, onIdeEvent } from "@/platform/ipc";
|
||||
import type { LspRefreshKind } from "@/bindings";
|
||||
|
||||
const DEFAULT_BACKOFF_MS = 15_000;
|
||||
const all = new Set<Map<number, number>>();
|
||||
const BACKOFF_MS = 15_000;
|
||||
const until = new Map<number, number>();
|
||||
|
||||
export interface FeatureBackoff {
|
||||
/** Stop asking about `doc` for a while. */
|
||||
mark(doc: number): void;
|
||||
/** Whether `doc` is still being left alone. */
|
||||
active(doc: number): boolean;
|
||||
}
|
||||
|
||||
/** A back-off for one feature; give each feature its own. */
|
||||
export function featureBackoff(ms = DEFAULT_BACKOFF_MS): FeatureBackoff {
|
||||
const until = new Map<number, number>();
|
||||
all.add(until);
|
||||
return {
|
||||
mark(doc) {
|
||||
until.set(doc, Date.now() + ms);
|
||||
},
|
||||
active(doc) {
|
||||
const t = until.get(doc);
|
||||
if (t === undefined) return false;
|
||||
if (t > Date.now()) return true;
|
||||
until.delete(doc);
|
||||
return false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function clearBackoffs() {
|
||||
for (const until of all) until.clear();
|
||||
}
|
||||
export const serverBackoff = {
|
||||
mark(doc: number) {
|
||||
until.set(doc, Date.now() + BACKOFF_MS);
|
||||
},
|
||||
active(doc: number): boolean {
|
||||
const t = until.get(doc);
|
||||
if (t === undefined) return false;
|
||||
if (t > Date.now()) return true;
|
||||
until.delete(doc);
|
||||
return false;
|
||||
},
|
||||
clear() {
|
||||
until.clear();
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Calls `fn` when data of `kind` fetched earlier may be stale: the server asked
|
||||
@@ -101,6 +85,6 @@ export function onServerSettled(fn: () => void): () => void {
|
||||
|
||||
export function installAvailabilityEvents(): () => void {
|
||||
return onIdeEvent("languageServer", (e) => {
|
||||
if (e.status === "ready") clearBackoffs();
|
||||
if (e.status === "ready") serverBackoff.clear();
|
||||
});
|
||||
}
|
||||
@@ -1,72 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import type { OutlineItem, References } from "@/platform/ipc";
|
||||
import { _addEditor, _removeEditor, codeLenses, documentSynced } from "@/editor/api";
|
||||
|
||||
const ipc = vi.hoisted(() => ({
|
||||
docOutline: vi.fn(),
|
||||
lspReferences: vi.fn(),
|
||||
cancelRequest: vi.fn<(id: number) => Promise<void>>(async () => {}),
|
||||
}));
|
||||
vi.mock("@/platform/ipc", async (orig) => {
|
||||
const real = await orig<typeof import("@/platform/ipc")>();
|
||||
return { ...real, isTauri: () => true, commands: { ...real.commands, ...ipc } };
|
||||
});
|
||||
const executeAction = vi.hoisted(() => vi.fn(async () => true));
|
||||
vi.mock("@/platform/actions", () => ({ executeAction }));
|
||||
|
||||
const { lensTargets, usagesLensExtension } = await import("./codeLens");
|
||||
const { lspDoc } = await import("./lsp");
|
||||
const { useProject } = await import("@/platform/project");
|
||||
const { emitLocalIdeEvent } = await import("@/platform/ipc");
|
||||
|
||||
const ctx = { doc: 7, path: "/p/a.rs", language: "rust", uiMode: "Rust", readOnly: false };
|
||||
const views: EditorView[] = [];
|
||||
const ok = <T>(data: T) => ({ status: "ok" as const, data });
|
||||
const refs = (n: number, incomplete: References["incomplete"] = null) =>
|
||||
ok({
|
||||
locations: Array.from({ length: n }, () => ({ path: "/p/b.rs", range: item("x", "function", 0).range })),
|
||||
incomplete,
|
||||
});
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((r) => (resolve = r));
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function editor(count = 1, sync?: () => Promise<void>) {
|
||||
const view = new EditorView({
|
||||
state: EditorState.create({
|
||||
doc: Array.from({ length: count }, (_, i) => `fn f${i}() {}`).join("\n"),
|
||||
extensions: [lspDoc.of(ctx), usagesLensExtension(), ...(sync ? [documentSynced.of(sync)] : [])],
|
||||
}),
|
||||
parent: document.body,
|
||||
});
|
||||
views.push(view);
|
||||
return view;
|
||||
}
|
||||
|
||||
const titles = (view: EditorView) => view.state.facet(codeLenses).map((l) => l.title);
|
||||
const settle = () => vi.advanceTimersByTimeAsync(1000);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
ipc.docOutline.mockReset().mockResolvedValue(ok([item("f0", "function", 0)]));
|
||||
ipc.lspReferences.mockReset().mockResolvedValue(refs(2));
|
||||
ipc.cancelRequest.mockClear();
|
||||
executeAction.mockClear();
|
||||
useProject.setState({
|
||||
settings: { editor: { inlay: { codeVisionUsages: true, codeVisionAuthor: false } } } as never,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
views.splice(0).forEach((v) => v.destroy());
|
||||
useProject.setState({ settings: null });
|
||||
vi.useRealTimers();
|
||||
});
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OutlineItem } from "@/platform/ipc";
|
||||
import { lensTargets } from "./codeLens";
|
||||
|
||||
const item = (name: string, kind: OutlineItem["kind"], line: number, children: OutlineItem[] = []): OutlineItem => {
|
||||
const r = { start: { line, col: 0 }, end: { line, col: 1 } };
|
||||
@@ -84,123 +18,3 @@ describe("usages lens targets", () => {
|
||||
expect(lensTargets(items).map((t) => t.name)).toEqual(["Cart", "count"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("background usage counts", () => {
|
||||
it("uses the server's existing scope and leaves full searches to the explicit action", async () => {
|
||||
const view = editor();
|
||||
await settle();
|
||||
expect(ipc.lspReferences).toHaveBeenCalledWith(expect.any(Number), 7, { line: 0, col: 0 }, false, false);
|
||||
expect(titles(view)).toEqual(["2 usages"]);
|
||||
view.state.facet(codeLenses)[0]!.run!(view, new MouseEvent("click"));
|
||||
expect(executeAction).toHaveBeenCalledWith("find.showUsages");
|
||||
});
|
||||
|
||||
it("allows a full search even when the server's current scope has no usages", async () => {
|
||||
ipc.lspReferences.mockResolvedValue(refs(0));
|
||||
const view = editor();
|
||||
await settle();
|
||||
expect(titles(view)).toEqual(["no usages"]);
|
||||
view.state.facet(codeLenses)[0]!.run!(view, new MouseEvent("click"));
|
||||
expect(executeAction).toHaveBeenCalledWith("find.showUsages");
|
||||
});
|
||||
|
||||
it("does not cache an incomplete count while indexing, and retries on server refresh", async () => {
|
||||
ipc.lspReferences.mockResolvedValueOnce(refs(0, "indexing"));
|
||||
const view = editor();
|
||||
await settle();
|
||||
expect(titles(view)).toEqual([]);
|
||||
emitLocalIdeEvent({ type: "lspRefresh", kind: "codeLens", server: 1 });
|
||||
await settle();
|
||||
expect(ipc.lspReferences).toHaveBeenCalledTimes(2);
|
||||
expect(titles(view)).toEqual(["2 usages"]);
|
||||
});
|
||||
|
||||
it("cancels obsolete requests immediately on edits and ignores their late replies", async () => {
|
||||
const old = deferred<ReturnType<typeof refs>>();
|
||||
ipc.lspReferences.mockReturnValueOnce(old.promise);
|
||||
const view = editor();
|
||||
await settle();
|
||||
const request = ipc.lspReferences.mock.calls[0]![0];
|
||||
view.dispatch({ changes: { from: view.state.doc.length, insert: " " } });
|
||||
expect(ipc.cancelRequest).toHaveBeenCalledWith(request);
|
||||
await settle();
|
||||
expect(titles(view)).toEqual(["2 usages"]);
|
||||
old.resolve(refs(99));
|
||||
await settle();
|
||||
expect(titles(view)).toEqual(["2 usages"]);
|
||||
});
|
||||
|
||||
it("retries incomplete counts when indexing ends without a code-lens refresh", async () => {
|
||||
ipc.lspReferences.mockResolvedValueOnce(refs(0, "indexing"));
|
||||
const view = editor();
|
||||
emitLocalIdeEvent({ type: "languageServer", server: 1, name: "rust-analyzer", status: "busy", message: null });
|
||||
await settle();
|
||||
expect(titles(view)).toEqual([]);
|
||||
await settle();
|
||||
emitLocalIdeEvent({ type: "languageServer", server: 1, name: "rust-analyzer", status: "ready", message: null });
|
||||
await settle();
|
||||
expect(ipc.lspReferences).toHaveBeenCalledTimes(2);
|
||||
expect(titles(view)).toEqual(["2 usages"]);
|
||||
});
|
||||
|
||||
it("does not repeat complete counts when the server finishes other work", async () => {
|
||||
const view = editor();
|
||||
emitLocalIdeEvent({ type: "languageServer", server: 1, name: "rust-analyzer", status: "busy", message: null });
|
||||
await settle();
|
||||
await settle();
|
||||
emitLocalIdeEvent({ type: "languageServer", server: 1, name: "rust-analyzer", status: "ready", message: null });
|
||||
await settle();
|
||||
expect(ipc.lspReferences).toHaveBeenCalledTimes(1);
|
||||
expect(titles(view)).toEqual(["2 usages"]);
|
||||
});
|
||||
|
||||
it("cancels all in-flight requests on destroy without starting the next batch", async () => {
|
||||
const pending = deferred<ReturnType<typeof refs>>();
|
||||
ipc.docOutline.mockResolvedValue(ok(Array.from({ length: 8 }, (_, i) => item(`f${i}`, "function", i))));
|
||||
ipc.lspReferences.mockReturnValue(pending.promise);
|
||||
const view = editor(8);
|
||||
await settle();
|
||||
expect(ipc.lspReferences).toHaveBeenCalledTimes(4);
|
||||
const requests = ipc.lspReferences.mock.calls.map(([id]) => id);
|
||||
view.destroy();
|
||||
views.splice(views.indexOf(view), 1);
|
||||
expect(ipc.cancelRequest.mock.calls.map(([id]) => id)).toEqual(requests);
|
||||
pending.resolve(refs(1));
|
||||
await settle();
|
||||
expect(ipc.lspReferences).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it("cancels pending counts when the feature is disabled", async () => {
|
||||
const pending = deferred<ReturnType<typeof refs>>();
|
||||
ipc.lspReferences.mockReturnValue(pending.promise);
|
||||
const view = editor();
|
||||
await settle();
|
||||
useProject.setState({
|
||||
settings: { editor: { inlay: { codeVisionUsages: false, codeVisionAuthor: false } } } as never,
|
||||
});
|
||||
expect(ipc.cancelRequest).toHaveBeenCalledWith(ipc.lspReferences.mock.calls[0]![0]);
|
||||
pending.resolve(refs(2));
|
||||
await settle();
|
||||
expect(titles(view)).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not send obsolete requests after waiting for document synchronization", async () => {
|
||||
const synced = deferred<void>();
|
||||
const view = editor(1, () => synced.promise);
|
||||
const handle = { view, ctx };
|
||||
_addEditor(handle);
|
||||
try {
|
||||
await settle();
|
||||
expect(ipc.docOutline).not.toHaveBeenCalled();
|
||||
view.destroy();
|
||||
views.splice(views.indexOf(view), 1);
|
||||
synced.resolve();
|
||||
await settle();
|
||||
expect(ipc.docOutline).not.toHaveBeenCalled();
|
||||
expect(ipc.lspReferences).not.toHaveBeenCalled();
|
||||
expect(ipc.cancelRequest).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
_removeEditor(handle);
|
||||
}
|
||||
});
|
||||
});
|
||||
+20
-50
@@ -9,11 +9,11 @@ import { EditorSelection, MapMode, StateEffect, StateField, type Extension, type
|
||||
import { EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view";
|
||||
import { codeLenses, type CodeLens } from "@/editor/api";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { commands, nextRequestId, type OutlineItem, type RequestId, type SymbolKind } from "@/platform/ipc";
|
||||
import { commands, nextRequestId, type OutlineItem, type SymbolKind } from "@/platform/ipc";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { debounce, docOf, isUnavailable, lspRequest } from "./lsp";
|
||||
import { posToOffset } from "./positions";
|
||||
import { featureBackoff, onLspRefresh, onServerSettled } from "./availability";
|
||||
import { onLspRefresh, serverBackoff } from "./availability";
|
||||
import { editorPrefs } from "@/platform/editorSettings";
|
||||
import { authorLabel, authorSummary, authorTooltip, blameFor, onBlameInvalidated } from "./authorLens";
|
||||
import { tx } from "@/platform/i18n";
|
||||
@@ -35,7 +35,6 @@ const MAX_PER_PASS = 40;
|
||||
const CONCURRENCY = 4;
|
||||
/** Lines above/below the viewport that are counted too, so scrolling feels instant. */
|
||||
const MARGIN_LINES = 60;
|
||||
const usagesBackoff = featureBackoff();
|
||||
|
||||
export interface LensTarget {
|
||||
name: string;
|
||||
@@ -106,17 +105,14 @@ function openUsages(view: EditorView, offset: number) {
|
||||
}
|
||||
|
||||
class UsagesLensPlugin {
|
||||
private schedule = debounce(() => void this.run(), 900);
|
||||
private refresh = debounce(() => void this.run(), 900);
|
||||
/** `${line}:${col}:${name}` → count, valid for `cacheDoc`. */
|
||||
private cache = new Map<string, number>();
|
||||
private cacheDoc: Text | null = null;
|
||||
private outline: { doc: Text; targets: LensTarget[] } | null = null;
|
||||
private generation = 0;
|
||||
private pending = new Set<RequestId>();
|
||||
private incomplete = false;
|
||||
private unsubscribe: () => void;
|
||||
private offReady: () => void;
|
||||
private offSettled: () => void;
|
||||
private offBlame: () => void;
|
||||
|
||||
constructor(private view: EditorView) {
|
||||
@@ -129,9 +125,6 @@ class UsagesLensPlugin {
|
||||
this.cache.clear();
|
||||
this.refresh();
|
||||
});
|
||||
this.offSettled = onServerSettled(() => {
|
||||
if (this.incomplete) this.refresh();
|
||||
});
|
||||
this.offBlame = onBlameInvalidated(() => this.refresh());
|
||||
}
|
||||
|
||||
@@ -139,32 +132,19 @@ class UsagesLensPlugin {
|
||||
if (u.docChanged || u.viewportChanged) this.refresh();
|
||||
}
|
||||
|
||||
private cancel() {
|
||||
this.generation++;
|
||||
for (const id of this.pending) void commands.cancelRequest(id).catch(() => {});
|
||||
this.pending.clear();
|
||||
}
|
||||
|
||||
private refresh() {
|
||||
this.cancel();
|
||||
this.schedule();
|
||||
}
|
||||
|
||||
private async run() {
|
||||
const view = this.view;
|
||||
if (!view.dom.isConnected) return;
|
||||
const doc = docOf(view);
|
||||
if (!doc) return;
|
||||
const gen = this.generation;
|
||||
const usages = usagesEnabled() && !usagesBackoff.active(doc.doc);
|
||||
const gen = ++this.generation;
|
||||
const usages = usagesEnabled() && !serverBackoff.active(doc.doc);
|
||||
const authors = authorEnabled() && !doc.virtual;
|
||||
if (!usages && !authors) {
|
||||
if (view.state.field(lensField).length) view.dispatch({ effects: setLenses.of([]) });
|
||||
return;
|
||||
}
|
||||
const text = view.state.doc;
|
||||
const wanted = () => gen === this.generation && view.state.doc === text;
|
||||
this.incomplete = false;
|
||||
// Blame runs beside the reference requests; it never holds up the counts.
|
||||
const blame = authors ? blameFor(doc.path, text) : Promise.resolve(null);
|
||||
if (this.cacheDoc !== text) {
|
||||
@@ -173,8 +153,8 @@ class UsagesLensPlugin {
|
||||
}
|
||||
try {
|
||||
if (this.outline?.doc !== text) {
|
||||
const items = await lspRequest(() => commands.docOutline(doc.doc), wanted);
|
||||
if (!wanted()) return;
|
||||
const items = await lspRequest(() => commands.docOutline(doc.doc));
|
||||
if (gen !== this.generation || view.state.doc !== text) return;
|
||||
this.outline = { doc: text, targets: lensTargets(items) };
|
||||
}
|
||||
const { from, to } = view.viewport;
|
||||
@@ -189,30 +169,21 @@ class UsagesLensPlugin {
|
||||
for (let i = 0; i < todo.length && !unavailable; i += CONCURRENCY) {
|
||||
await Promise.all(
|
||||
todo.slice(i, i + CONCURRENCY).map(async (t) => {
|
||||
const request = nextRequestId();
|
||||
try {
|
||||
const { locations: refs, incomplete } = await lspRequest(() => {
|
||||
this.pending.add(request);
|
||||
// Opening candidate files for each count triggers expensive server analysis.
|
||||
// Only an explicit Find Usages should widen the server's scope.
|
||||
return commands.lspReferences(request, doc.doc, { line: t.line, col: t.col }, false, false);
|
||||
}, wanted);
|
||||
if (wanted()) {
|
||||
if (incomplete) this.incomplete = true;
|
||||
else this.cache.set(key(t), refs.length);
|
||||
}
|
||||
const { locations: refs } = await lspRequest(() =>
|
||||
commands.lspReferences(nextRequestId(), doc.doc, { line: t.line, col: t.col }, false, true),
|
||||
);
|
||||
this.cache.set(key(t), refs.length);
|
||||
} catch (e) {
|
||||
if (wanted() && isUnavailable(e)) unavailable = true;
|
||||
} finally {
|
||||
this.pending.delete(request);
|
||||
if (isUnavailable(e)) unavailable = true;
|
||||
}
|
||||
}),
|
||||
);
|
||||
if (!wanted()) return;
|
||||
if (gen !== this.generation || view.state.doc !== text) return;
|
||||
}
|
||||
if (unavailable) usagesBackoff.mark(doc.doc);
|
||||
if (unavailable) serverBackoff.mark(doc.doc);
|
||||
const lines = await blame;
|
||||
if (!wanted()) return;
|
||||
if (gen !== this.generation || view.state.doc !== text) return;
|
||||
const lenses: CodeLens[] = [];
|
||||
for (const t of visible) {
|
||||
const offset = posToOffset(text, { line: t.line, col: t.col });
|
||||
@@ -224,24 +195,23 @@ class UsagesLensPlugin {
|
||||
lenses.push({
|
||||
pos: offset,
|
||||
title: usagesLabel(n),
|
||||
tooltip: tx`Show usages of ${t.name}`,
|
||||
tooltip: n ? tx`Show usages of ${t.name}` : undefined,
|
||||
order: 10,
|
||||
run: (v) => openUsages(v, Math.min(offset, v.state.doc.length)),
|
||||
run: n ? (v) => openUsages(v, Math.min(offset, v.state.doc.length)) : undefined,
|
||||
});
|
||||
}
|
||||
if (!sameLenses(view.state.field(lensField), lenses)) view.dispatch({ effects: setLenses.of(lenses) });
|
||||
} catch (e) {
|
||||
if (wanted() && isUnavailable(e)) usagesBackoff.mark(doc.doc);
|
||||
if (isUnavailable(e)) serverBackoff.mark(doc.doc);
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.schedule.cancel();
|
||||
this.refresh.cancel();
|
||||
this.unsubscribe();
|
||||
this.offReady();
|
||||
this.offSettled();
|
||||
this.offBlame();
|
||||
this.cancel();
|
||||
this.generation++;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ import { isEmptyMarkup, renderCode, renderMarkup } from "./markdown";
|
||||
import { selectableText } from "@/ui/selectableText";
|
||||
import { iconNode } from "./iconMarkup";
|
||||
import { parameterInfoOrLeave, triggerSignatureHelp } from "./signature";
|
||||
import { featureBackoff } from "./availability";
|
||||
import { serverBackoff } from "./availability";
|
||||
import { pathCompletionSource } from "./fileReferences";
|
||||
import { editorPrefs } from "@/platform/editorSettings";
|
||||
import { tx } from "@/platform/i18n";
|
||||
@@ -410,9 +410,6 @@ interface RichCompletion extends Completion {
|
||||
typeText?: string;
|
||||
}
|
||||
|
||||
/** Documents the server could not complete in, so typing doesn't ask again until a server is ready. */
|
||||
const completionBackoff = featureBackoff();
|
||||
|
||||
async function lspCompletionSource(ctx: CompletionContext): Promise<CompletionResult | null> {
|
||||
const doc = ctx.state.facet(lspDoc);
|
||||
if (!doc || doc.readOnly) return null;
|
||||
@@ -420,7 +417,8 @@ async function lspCompletionSource(ctx: CompletionContext): Promise<CompletionRe
|
||||
const trigger = triggerCharacter(ctx.state.sliceDoc(Math.max(0, ctx.pos - 2), ctx.pos));
|
||||
if (!ctx.explicit && !word && !trigger) return null;
|
||||
if (!ctx.explicit && word && !trigger && /^\d/.test(word.text)) return null;
|
||||
if (completionBackoff.active(doc.doc)) return fallback(ctx);
|
||||
if (serverBackoff.active(doc.doc)) return fallback(ctx);
|
||||
|
||||
cancelInflight();
|
||||
const id = nextRequestId();
|
||||
inflight = id;
|
||||
@@ -435,7 +433,7 @@ async function lspCompletionSource(ctx: CompletionContext): Promise<CompletionRe
|
||||
);
|
||||
} catch (e) {
|
||||
if (isUnavailable(e)) {
|
||||
completionBackoff.mark(doc.doc);
|
||||
serverBackoff.mark(doc.doc);
|
||||
return fallback(ctx);
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { showHint, showPendingHint } from "./hint";
|
||||
|
||||
let view: EditorView;
|
||||
const hint = () => document.querySelector(".cm-lsp-hint")?.textContent ?? null;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
view = new EditorView({ state: EditorState.create({ doc: "foo()" }), parent: document.body });
|
||||
});
|
||||
afterEach(() => {
|
||||
view.destroy();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("pending hints", () => {
|
||||
it("does not flash for fast requests", () => {
|
||||
const done = showPendingHint(view, "Waiting…");
|
||||
vi.advanceTimersByTime(100);
|
||||
done();
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(hint()).toBeNull();
|
||||
});
|
||||
|
||||
it("shows a slow request and removes its hint on completion", () => {
|
||||
const done = showPendingHint(view, "Waiting…");
|
||||
vi.advanceTimersByTime(250);
|
||||
expect(hint()).toBe("Waiting…");
|
||||
done();
|
||||
expect(hint()).toBeNull();
|
||||
});
|
||||
|
||||
it("does not dismiss a newer hint when an old request finishes", () => {
|
||||
const done = showPendingHint(view, "Waiting…");
|
||||
vi.advanceTimersByTime(250);
|
||||
showHint(view, "New error", { tone: "error" });
|
||||
done();
|
||||
expect(hint()).toBe("New error");
|
||||
});
|
||||
|
||||
it("does not overwrite a newer hint while waiting to show progress", () => {
|
||||
const done = showPendingHint(view, "Waiting…");
|
||||
vi.advanceTimersByTime(100);
|
||||
showHint(view, "New error", { tone: "error" });
|
||||
vi.advanceTimersByTime(150);
|
||||
expect(hint()).toBe("New error");
|
||||
done();
|
||||
expect(hint()).toBe("New error");
|
||||
});
|
||||
|
||||
it.each(["edit", "move", "destroy"])("does not show stale progress after %s", (change) => {
|
||||
const done = showPendingHint(view, "Waiting…");
|
||||
if (change === "edit") view.dispatch({ changes: { from: 0, insert: "x" } });
|
||||
if (change === "move") view.dispatch({ selection: { anchor: 3 } });
|
||||
if (change === "destroy") view.destroy();
|
||||
vi.advanceTimersByTime(250);
|
||||
done();
|
||||
expect(hint()).toBeNull();
|
||||
});
|
||||
});
|
||||
+1
-26
@@ -67,36 +67,11 @@ export function showHint(
|
||||
timers.set(
|
||||
view,
|
||||
setTimeout(() => {
|
||||
if (view.dom.isConnected && view.state.field(hintField, false)) view.dispatch({ effects: setHint.of(null) });
|
||||
if (view.state.field(hintField, false)) view.dispatch({ effects: setHint.of(null) });
|
||||
}, opts.timeout ?? 4000),
|
||||
);
|
||||
}
|
||||
|
||||
/** Show progress only for slow requests; the disposer removes only its own hint. */
|
||||
export function showPendingHint(view: EditorView, content: string): () => void {
|
||||
const { doc, selection } = view.state;
|
||||
const previous = view.state.field(hintField, false);
|
||||
let shown: Hint | null = null;
|
||||
const timer = setTimeout(() => {
|
||||
if (
|
||||
!view.dom.isConnected ||
|
||||
view.state.doc !== doc ||
|
||||
!view.state.selection.eq(selection) ||
|
||||
view.state.field(hintField, false) !== previous
|
||||
)
|
||||
return;
|
||||
showHint(view, content, { timeout: 30_000 });
|
||||
shown = view.state.field(hintField);
|
||||
}, 250);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
if (shown && view.state.field(hintField, false) === shown) {
|
||||
clearTimeout(timers.get(view));
|
||||
if (view.dom.isConnected) hideHint(view);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function hideHint(view: EditorView): boolean {
|
||||
if (!view.state.field(hintField, false)) return false;
|
||||
view.dispatch({ effects: setHint.of(null) });
|
||||
|
||||
@@ -15,7 +15,7 @@ import { useProject } from "@/platform/project";
|
||||
import { editorPrefs } from "@/platform/editorSettings";
|
||||
import { debounce, docOf, isUnavailable, lspRequest } from "./lsp";
|
||||
import { offsetsToRange, posToOffset } from "./positions";
|
||||
import { featureBackoff, onLspRefresh } from "./availability";
|
||||
import { onLspRefresh, serverBackoff } from "./availability";
|
||||
import { gotoTargets } from "./navigation";
|
||||
import { tx } from "@/platform/i18n";
|
||||
|
||||
@@ -30,8 +30,6 @@ async function setEditorSetting(key: string, value: unknown) {
|
||||
useProject.setState({ settings: await call(commands.settingsSet("global", `editor.${key}`, JSON.stringify(value))) });
|
||||
}
|
||||
|
||||
const inlayBackoff = featureBackoff();
|
||||
|
||||
/** A type hint after an identifier can resolve that identifier's inferred type. */
|
||||
function typeHintOffset(view: EditorView, at: number): number | null {
|
||||
if (at <= 0 || at > view.state.doc.length) return null;
|
||||
@@ -185,7 +183,7 @@ const inlayPlugin = ViewPlugin.fromClass(
|
||||
if (!view.dom.isConnected) return;
|
||||
const doc = docOf(view);
|
||||
if (!doc) return;
|
||||
if (!enabled(doc.language) || inlayBackoff.active(doc.doc)) {
|
||||
if (!enabled(doc.language) || serverBackoff.active(doc.doc)) {
|
||||
if (view.state.field(inlayField).size) view.dispatch({ effects: setInlays.of(Decoration.none) });
|
||||
return;
|
||||
}
|
||||
@@ -205,7 +203,7 @@ const inlayPlugin = ViewPlugin.fromClass(
|
||||
),
|
||||
});
|
||||
} catch (e) {
|
||||
if (isUnavailable(e)) inlayBackoff.mark(doc.doc);
|
||||
if (isUnavailable(e)) serverBackoff.mark(doc.doc);
|
||||
}
|
||||
}
|
||||
destroy() {
|
||||
|
||||
@@ -21,7 +21,6 @@ const {
|
||||
ghostText,
|
||||
inlineCompletionExtension,
|
||||
nextWord,
|
||||
registerInlineCompletionActions,
|
||||
requestInline,
|
||||
toSuggestions,
|
||||
} = await import("./inlineCompletion");
|
||||
@@ -137,49 +136,3 @@ describe("inline completion", () => {
|
||||
expect(nextWord(" ")).toBe(" ");
|
||||
});
|
||||
});
|
||||
|
||||
describe("inline completion without a provider", () => {
|
||||
const unavailable = (message: string) => ({
|
||||
status: "error" as const,
|
||||
error: { kind: "unavailable" as const, message },
|
||||
});
|
||||
const hintText = () => document.querySelector(".cm-lsp-hint")?.textContent ?? null;
|
||||
|
||||
async function invoke(view: EditorView) {
|
||||
const { useEditorRegistry } = await import("@/editor/api");
|
||||
const { getAction } = await import("@/platform/actions");
|
||||
useEditorRegistry.setState({ active: { view, ctx: view.state.facet(lspDoc)! } as never });
|
||||
const off = registerInlineCompletionActions();
|
||||
void getAction("editor.inlineCompletion.trigger")!.run({ ctx: {} } as never);
|
||||
off();
|
||||
}
|
||||
|
||||
it.each([
|
||||
[
|
||||
"names the running server that lacks it",
|
||||
"jdtls doesn't support inline completion",
|
||||
"jdtls doesn't support inline completion",
|
||||
],
|
||||
[
|
||||
"says no server runs when none does",
|
||||
"no running language server supports inline completion",
|
||||
"No language server available",
|
||||
],
|
||||
])("on explicit invocation %s", async (_, message, shown) => {
|
||||
const view = editor("String s = ");
|
||||
ipc.lspInlineCompletion.mockResolvedValueOnce(unavailable(message));
|
||||
await invoke(view);
|
||||
await vi.waitFor(() => expect(hintText()).toBe(shown));
|
||||
});
|
||||
|
||||
it("stays silent while typing", async () => {
|
||||
const view = editor("String s = ");
|
||||
ipc.lspInlineCompletion.mockResolvedValue(unavailable("jdtls doesn't support inline completion"));
|
||||
expect(await requestInline(view, true)).toBe(false);
|
||||
view.dispatch({ changes: { from: 11, insert: "x" }, userEvent: "input.type" });
|
||||
await new Promise((r) => setTimeout(r, 120));
|
||||
expect(hintText()).toBeNull();
|
||||
expect(ghostDom(view)).toBeNull();
|
||||
ipc.lspInlineCompletion.mockReset();
|
||||
});
|
||||
});
|
||||
@@ -21,13 +21,11 @@ import {
|
||||
type Command,
|
||||
type InlineCompletionItem,
|
||||
type RequestId,
|
||||
unavailableText,
|
||||
} from "@/platform/ipc";
|
||||
import { activeEditor } from "@/editor/api";
|
||||
import { docOf, isUnavailable, lspRequest } from "./lsp";
|
||||
import { offsetToPos, rangeToOffsets } from "./positions";
|
||||
import { showHint } from "./hint";
|
||||
import { featureBackoff } from "./availability";
|
||||
|
||||
/** A suggestion in document offsets: `text` replaces `[from, to)`. */
|
||||
export interface Suggestion {
|
||||
@@ -145,7 +143,7 @@ function enabledFor(language: string): boolean {
|
||||
return p.enabled && !p.disabledLanguages.includes(language);
|
||||
}
|
||||
|
||||
const backoff = featureBackoff(BACKOFF_MS);
|
||||
const backoff = new Map<number, number>();
|
||||
let focused: EditorView | null = null;
|
||||
let inflight: RequestId | null = null;
|
||||
|
||||
@@ -160,7 +158,7 @@ export async function requestInline(view: EditorView, automatic: boolean): Promi
|
||||
const state = view.state;
|
||||
const sel = state.selection;
|
||||
if (!doc || sel.ranges.length !== 1 || !sel.main.empty || view.state.readOnly) return false;
|
||||
if (automatic && backoff.active(doc.doc)) return false;
|
||||
if (automatic && (backoff.get(doc.doc) ?? 0) > Date.now()) return false;
|
||||
cancelInflight();
|
||||
const id = (inflight = nextRequestId());
|
||||
const caret = sel.main.head;
|
||||
@@ -174,7 +172,7 @@ export async function requestInline(view: EditorView, automatic: boolean): Promi
|
||||
view.dispatch({ effects: setGhost.of(suggestions.length ? { items: suggestions, index: 0, caret } : null) });
|
||||
return suggestions.length > 0;
|
||||
} catch (e) {
|
||||
if (isUnavailable(e)) backoff.mark(doc.doc);
|
||||
if (isUnavailable(e)) backoff.set(doc.doc, Date.now() + BACKOFF_MS);
|
||||
else if (!isCancelled(e)) console.debug("[lsp] inline completion", e);
|
||||
if (!automatic && !isCancelled(e)) throw e;
|
||||
return false;
|
||||
@@ -314,7 +312,7 @@ export function registerInlineCompletionActions(): () => void {
|
||||
try {
|
||||
if (!(await requestInline(v, false))) showHint(v, "No inline suggestions");
|
||||
} catch (e) {
|
||||
showHint(v, isUnavailable(e) ? unavailableText(e) : String(e));
|
||||
showHint(v, isUnavailable(e) ? "No language server provides inline completion" : String(e));
|
||||
}
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -18,7 +18,7 @@ import { formatLines } from "./format";
|
||||
import { pythonMemberIntentions } from "./pythonMembers";
|
||||
import { caretAnchor, openPopup, type ListPopupItem, type ListPreview } from "./popupStore";
|
||||
import { editPreviewLines } from "./workspaceEdit";
|
||||
import { featureBackoff } from "./availability";
|
||||
import { serverBackoff } from "./availability";
|
||||
import { iconNode } from "./iconMarkup";
|
||||
import { editorPrefs } from "@/platform/editorSettings";
|
||||
import { spellingIntentions } from "./spelling";
|
||||
@@ -27,8 +27,6 @@ import { SPELLING_INSPECTION } from "@/platform/inspections";
|
||||
import { editInspectionSettings, intentionControls } from "@/platform/inspectionControls";
|
||||
import { tx } from "@/platform/i18n";
|
||||
|
||||
const codeActionBackoff = featureBackoff();
|
||||
|
||||
const isQuickFix = (a: CodeAction) => !!a.kind && (a.kind === "quickfix" || a.kind.startsWith("quickfix."));
|
||||
|
||||
/** IDEA ordering: quick fixes first, preferred before others, disabled last; stable otherwise. */
|
||||
@@ -514,14 +512,14 @@ const bulbPlugin = ViewPlugin.fromClass(
|
||||
const state = this.view.state;
|
||||
const typoFix = spellingIntentions(this.view).length > 0;
|
||||
const local = typoFix || contributedIntentions(this.view).length > 0;
|
||||
const backoff = codeActionBackoff.active(doc.doc);
|
||||
const backoff = serverBackoff.active(doc.doc);
|
||||
if (backoff && !local) return;
|
||||
let actions: CodeAction[] = [];
|
||||
if (!backoff) {
|
||||
try {
|
||||
actions = await fetchIntentions(this.view);
|
||||
} catch (e) {
|
||||
if (isUnavailable(e)) codeActionBackoff.mark(doc.doc);
|
||||
if (isUnavailable(e)) serverBackoff.mark(doc.doc);
|
||||
if (!local) return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,7 @@ vi.mock("@/platform/ipc", async (orig) => {
|
||||
return { ...actual, isTauri: () => true };
|
||||
});
|
||||
|
||||
const { lspRequest, reportLspError } = await import("./lsp");
|
||||
const { IpcError } = await import("@/platform/ipc");
|
||||
const { lspRequest } = await import("./lsp");
|
||||
|
||||
describe("lspRequest", () => {
|
||||
it("waits until open editors' edits reached the backend", async () => {
|
||||
@@ -28,27 +27,3 @@ describe("lspRequest", () => {
|
||||
view.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("reportLspError", () => {
|
||||
const unavailable = (message: string) => new IpcError({ kind: "unavailable", message });
|
||||
const hintText = () => document.querySelector(".cm-lsp-hint")?.textContent ?? null;
|
||||
|
||||
it.each([
|
||||
["jdtls doesn't support type hierarchy", "jdtls doesn't support type hierarchy"],
|
||||
["pyright, ruff doesn't support code lens", "pyright, ruff doesn't support code lens"],
|
||||
["no running language server supports rename", "No language server available"],
|
||||
["jdtls is not running", "No language server available"],
|
||||
])("explicit %s → %s", (message, shown) => {
|
||||
const view = new EditorView({ state: EditorState.create({ doc: "x" }), parent: document.body });
|
||||
reportLspError(view, unavailable(message), true);
|
||||
expect(hintText()).toBe(shown);
|
||||
view.destroy();
|
||||
});
|
||||
|
||||
it("stays silent for background requests", () => {
|
||||
const view = new EditorView({ state: EditorState.create({ doc: "x" }), parent: document.body });
|
||||
reportLspError(view, unavailable("jdtls doesn't support inlay hints"), false);
|
||||
expect(hintText()).toBeNull();
|
||||
view.destroy();
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@
|
||||
import { Facet } from "@codemirror/state";
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import { whenAllSynced, type EditorDocContext } from "@/editor/api";
|
||||
import { IpcError, call, isCancelled, isTauri, unavailableText } from "@/platform/ipc";
|
||||
import { IpcError, call, isCancelled, isTauri } from "@/platform/ipc";
|
||||
import type { IdeError } from "@/bindings";
|
||||
import { showHint } from "./hint";
|
||||
|
||||
@@ -44,8 +44,7 @@ export const NO_SERVER = "No language server available";
|
||||
|
||||
/**
|
||||
* Error policy: background requests fail silently; explicit actions show a
|
||||
* small hint at the caret ("No language server available" for missing servers,
|
||||
* "jdtls doesn't support X" when the running server lacks the feature).
|
||||
* small hint at the caret ("No language server available" for missing servers).
|
||||
*/
|
||||
export function reportLspError(view: EditorView | null, e: unknown, explicit: boolean) {
|
||||
if (isCancelled(e)) return;
|
||||
@@ -53,7 +52,7 @@ export function reportLspError(view: EditorView | null, e: unknown, explicit: bo
|
||||
if (!isUnavailable(e)) console.debug("[lsp]", e);
|
||||
return;
|
||||
}
|
||||
showHint(view, isUnavailable(e) ? unavailableText(e, NO_SERVER) : e instanceof Error ? e.message : String(e));
|
||||
showHint(view, isUnavailable(e) ? NO_SERVER : e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
|
||||
/** Runs `fn` once `ms` after the last call; `cancel()` drops a pending run. */
|
||||
|
||||
@@ -46,11 +46,11 @@ import {
|
||||
localHighlights,
|
||||
type DeclarationTarget,
|
||||
} from "./syntaxFallback";
|
||||
import { showHint, showPendingHint } from "./hint";
|
||||
import { showHint } from "./hint";
|
||||
import { caretAnchor, openPopup, type ListPopupItem } from "./popupStore";
|
||||
import { identifierAt, readManyFileLines } from "./files";
|
||||
import { documentLinks, followReference, knownLinks, linkReference, referenceAt, textReference } from "./links";
|
||||
import { featureBackoff } from "./availability";
|
||||
import { serverBackoff } from "./availability";
|
||||
import { requestHover } from "./hover";
|
||||
import { declarationSummary, renderCode } from "./markdown";
|
||||
import { editorPrefs } from "@/platform/editorSettings";
|
||||
@@ -60,8 +60,6 @@ import { isNavigationGesture, onNavigationModifier } from "@/platform/navigation
|
||||
import { create } from "zustand";
|
||||
import { tx } from "@/platform/i18n";
|
||||
|
||||
const highlightsBackoff = featureBackoff();
|
||||
|
||||
export async function navigateTo(loc: Location) {
|
||||
await editorService().openFile(loc.path, {
|
||||
selection: { line: loc.range.start.line, col: loc.range.start.col },
|
||||
@@ -166,12 +164,7 @@ export async function gotoTargets(view: EditorView, kind: GotoKind, at?: number)
|
||||
const declaration = kind === "definition" || kind === "declaration";
|
||||
let serverDown = false;
|
||||
try {
|
||||
const done = showPendingHint(view, tx`Finding navigation target…`);
|
||||
try {
|
||||
locs = dedupeLocations(await gotoAfterEdits(view, () => lspRequest(() => commands.lspGoto(doc.doc, pos, kind))));
|
||||
} finally {
|
||||
done();
|
||||
}
|
||||
locs = dedupeLocations(await gotoAfterEdits(view, () => lspRequest(() => commands.lspGoto(doc.doc, pos, kind))));
|
||||
} catch (e) {
|
||||
if (isCancelled(e) || !declaration) {
|
||||
reportLspError(view, e, true);
|
||||
@@ -580,7 +573,7 @@ async function fetchHighlights(view: EditorView, sticky: boolean) {
|
||||
);
|
||||
};
|
||||
let lspError: unknown = null;
|
||||
if (!highlightsBackoff.active(doc.doc)) {
|
||||
if (!serverBackoff.active(doc.doc)) {
|
||||
try {
|
||||
const items = await lspRequest(() => commands.lspDocumentHighlights(doc.doc, offsetToPos(state.doc, head)));
|
||||
if (stale()) return;
|
||||
@@ -589,7 +582,7 @@ async function fetchHighlights(view: EditorView, sticky: boolean) {
|
||||
return;
|
||||
} catch (e) {
|
||||
if (isCancelled(e)) return;
|
||||
if (isUnavailable(e)) highlightsBackoff.mark(doc.doc);
|
||||
if (isUnavailable(e)) serverBackoff.mark(doc.doc);
|
||||
lspError = e;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import type { commands } from "@/platform/ipc";
|
||||
|
||||
const lspGoto = vi.hoisted(() => vi.fn<typeof commands.lspGoto>());
|
||||
vi.mock("@/platform/ipc", async (orig) => {
|
||||
const real = await orig<typeof import("@/platform/ipc")>();
|
||||
return { ...real, isTauri: () => true, commands: { ...real.commands, lspGoto } };
|
||||
});
|
||||
|
||||
const { gotoTargets } = await import("./navigation");
|
||||
const { lspDoc } = await import("./lsp");
|
||||
const { provideEditorService } = await import("@/workbench/contributions");
|
||||
const openFile = vi.fn(async () => {});
|
||||
provideEditorService({ openFile } as never);
|
||||
|
||||
let view: EditorView;
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
openFile.mockClear();
|
||||
view = new EditorView({
|
||||
state: EditorState.create({
|
||||
doc: "run()",
|
||||
extensions: [lspDoc.of({ doc: 1, path: "/p/main.rs", language: "rust", uiMode: "Rust", readOnly: false })],
|
||||
}),
|
||||
parent: document.body,
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
view.destroy();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("slow navigation feedback", () => {
|
||||
it("shows progress while finding a definition, then opens the target and clears the hint", async () => {
|
||||
let resolve!: (result: Awaited<ReturnType<typeof commands.lspGoto>>) => void;
|
||||
lspGoto.mockImplementationOnce(() => new Promise((r) => (resolve = r)));
|
||||
const pending = gotoTargets(view, "definition");
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
expect(document.querySelector(".cm-lsp-hint")?.textContent).toBe("Finding navigation target…");
|
||||
expect(openFile).not.toHaveBeenCalled();
|
||||
resolve({
|
||||
status: "ok",
|
||||
data: [{ path: "/p/lib.rs", range: { start: { line: 3, col: 4 }, end: { line: 3, col: 7 } }, uri: null }],
|
||||
});
|
||||
await pending;
|
||||
expect(openFile).toHaveBeenCalledWith("/p/lib.rs", { selection: { line: 3, col: 4 }, focus: true });
|
||||
expect(document.querySelector(".cm-lsp-hint")).toBeNull();
|
||||
});
|
||||
|
||||
it("replaces progress with the server error", async () => {
|
||||
let resolve!: (result: Awaited<ReturnType<typeof commands.lspGoto>>) => void;
|
||||
lspGoto.mockImplementationOnce(() => new Promise((r) => (resolve = r)));
|
||||
const pending = gotoTargets(view, "implementation");
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
resolve({ status: "error", error: { kind: "protocol", message: "Server timed out" } });
|
||||
await pending;
|
||||
expect(document.querySelector(".cm-lsp-hint")?.textContent).toBe("Server timed out");
|
||||
expect(openFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Loaded 100 of 238 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user