Compare commits

..
Author SHA1 Message Date
l0ng-aiandClaude 0ba50a9d67 fix(host): Allow the Unix-only post-start test helpers on Windows
Co-Authored-By: Claude <[email protected]>
2026-10-29 10:25:29 +08:00
l0ng-aiandClaude c1dece1164 feat(settings): HTTP Proxy settings for the IDE's outgoing connections
Add Appearance & Behavior › System Settings › HTTP Proxy with three modes:
No proxy, Auto-detect proxy settings (the default; environment proxy as
before) and Manual proxy configuration (HTTP or SOCKS, host and port, a
comma-separated "No proxy for" list with `*` wildcards, optional proxy
authentication). "Check connection…" GETs a URL with the settings as edited
and reports the status or the error.

- ide-core: `ProxySettings` plus routing (no-proxy globs, loopback always
  direct, credentials percent-encoded), the current proxy for the process,
  curl arguments and child-process variables.
- ide-http: `client::build` / `client::shared` is the one HTTP client
  constructor; the HTTP Client requests and the update check go through it.
- ide-tools: curl downloads (tools, JDKs, debug adapters, agent registry,
  remote server) follow the setting.
- A manual proxy reaches child processes (language servers, debug adapters,
  terminals, git) as HTTP_PROXY / HTTPS_PROXY / ALL_PROXY / NO_PROXY.
- The proxy password is kept in the owner-only saved passwords file
  (`proxy-password.json` in the app data directory), not in settings.toml.

Co-Authored-By: Claude <[email protected]>
2026-10-29 10:19:11 +08:00
238 changed files with 1495 additions and 16416 deletions

No files matched your search

+1 -60
View File
@@ -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
View File
@@ -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
View File
@@ -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" }
-13
View File
@@ -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`,
+1 -34
View File
@@ -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);
+4 -73
View File
@@ -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())
}
-15
View File
@@ -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",
+2 -45
View File
@@ -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;
-72
View File
@@ -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();
}
+1 -6
View File
@@ -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 -6
View File
@@ -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"] }
+4 -78
View File
@@ -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;
-3
View File
@@ -13,6 +13,3 @@ specta.workspace = true
specta-typescript.workspace = true
thiserror.workspace = true
tokio-util.workspace = true
[dev-dependencies]
tempfile.workspace = true
-2
View File
@@ -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};
-412
View File
@@ -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");
}
}
-241
View File
@@ -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
View File
@@ -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}");
}
}
-87
View File
@@ -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
}
-17
View File
@@ -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"] }
-264
View File
@@ -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));
}
}
}
-70
View File
@@ -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");
}
}
}
-1
View File
@@ -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;
-103
View File
@@ -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]);
}
}
}
+1 -4
View File
@@ -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)))
}
-15
View File
@@ -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
});
+10 -61
View File
@@ -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 });
+1 -7
View File
@@ -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")
));
-20
View File
@@ -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:?}");
-65
View File
@@ -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");
}
+4 -13
View File
@@ -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;
-14
View File
@@ -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())]);
-97
View File
@@ -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"));
}
}
+1 -2
View File
@@ -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;
+167
View File
@@ -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())]);
}
}
+25 -60
View File
@@ -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(&quote(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));
}
-44
View File
@@ -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]);
}
}
-42
View File
@@ -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");
}
-33
View File
@@ -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(&[
-54
View File
@@ -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
View File
@@ -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();
+8 -49
View File
@@ -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")) {
+5 -14
View File
@@ -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
View File
@@ -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 {
-1
View File
@@ -16,7 +16,6 @@ pub mod libraries;
pub mod locate;
pub mod lombok;
mod npm;
pub mod pinned;
pub mod python;
mod vue;
-233
View File
@@ -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")));
}
}
-130
View File
@@ -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);
}
+1 -3
View File
@@ -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));
-2
View File
@@ -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"
-318
View File
@@ -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");
}
}
-2
View File
@@ -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;
+1 -1
View File
@@ -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();
-311
View File
@@ -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);
}
}
-1
View File
@@ -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
+2 -26
View File
@@ -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;
}
+3 -11
View File
@@ -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,
}
-44
View File
@@ -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);
}
+1 -4
View File
@@ -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)
-336
View File
@@ -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
View File
@@ -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"));
}
-113
View File
@@ -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`,
});
});
}
-13
View File
@@ -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
View File
@@ -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",
+14 -54
View File
@@ -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
-2
View File
@@ -1,2 +0,0 @@
allowBuilds:
simple-git-hooks: true
-3
View File
@@ -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"]],
-1
View File
@@ -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
-14
View File
@@ -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()))
}
-4
View File
@@ -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,
-11
View File
@@ -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]
+6 -32
View File
@@ -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.
+5 -9
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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 = {
-10
View File
@@ -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 -80
View File
@@ -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);
});
});
+1 -6
View File
@@ -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("{") ||
+8 -106
View File
@@ -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 }]));
}
+1 -1
View File
@@ -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)),
-1
View File
@@ -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);
});
+1 -1
View File
@@ -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 ?? "");
+1 -1
View File
@@ -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)),
+4 -6
View File
@@ -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;
}
+1 -41
View File
@@ -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();
});
});
+21 -37
View File
@@ -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();
});
}
+3 -189
View File
@@ -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
View File
@@ -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++;
}
}
+4 -6
View File
@@ -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;
-62
View File
@@ -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
View File
@@ -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) });
+3 -5
View File
@@ -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() {
-47
View File
@@ -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();
});
});
+4 -6
View File
@@ -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));
}
}),
},
+3 -5
View File
@@ -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;
}
}
+1 -26
View File
@@ -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();
});
});
+3 -4
View File
@@ -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. */
+5 -12
View File
@@ -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;
}
}
-62
View File
@@ -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