mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-07 18:38:33 -10:00
Compare commits
38
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0486a6826 | ||
|
|
3c87a8eea7 | ||
|
|
aac924517c | ||
|
|
b1347dd331 | ||
|
|
fc2496c3f0 | ||
|
|
0f26b72d07 | ||
|
|
ad09a76212 | ||
|
|
8568a70a05 | ||
|
|
a20207110e | ||
|
|
74b2d5f5d7 | ||
|
|
297b15a7b1 | ||
|
|
97144421d2 | ||
|
|
200ed6a528 | ||
|
|
4666c4b9a5 | ||
|
|
ce43a7aa70 | ||
|
|
f911196f7d | ||
|
|
8dea5904f2 | ||
|
|
c7d32f9e23 | ||
|
|
3f9512056d | ||
|
|
1283af8a41 | ||
|
|
d37b96002b | ||
|
|
364bb5ef50 | ||
|
|
4d503ef976 | ||
|
|
75b1704f78 | ||
|
|
06d78cf524 | ||
|
|
fff2c7110c | ||
|
|
9fbd57ed15 | ||
|
|
aea0b33585 | ||
|
|
e537dd43a0 | ||
|
|
c821ab144c | ||
|
|
6767a5ff8c | ||
|
|
a233d85fcc | ||
|
|
4e675c1171 | ||
|
|
8ebeaa2663 | ||
|
|
ed7b0f88ef | ||
|
|
9d4e778c7b | ||
|
|
c8e3fe9d2e | ||
|
|
4410da60a2 |
No files matched your search
@@ -117,6 +117,8 @@ 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
|
||||
@@ -172,8 +174,65 @@ 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]
|
||||
needs: [create-release, build, server, updater-manifest]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/github-script@v7
|
||||
|
||||
Generated
+53
-33
@@ -3974,7 +3974,7 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "ide-agent"
|
||||
version = "0.1.23"
|
||||
version = "0.1.24"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"ide-core",
|
||||
@@ -3995,7 +3995,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-analysis"
|
||||
version = "0.1.23"
|
||||
version = "0.1.24"
|
||||
dependencies = [
|
||||
"ast-grep-core",
|
||||
"ide-core",
|
||||
@@ -4009,7 +4009,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-build"
|
||||
version = "0.1.23"
|
||||
version = "0.1.24"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"dunce",
|
||||
@@ -4024,24 +4024,30 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-clipboard"
|
||||
version = "0.1.23"
|
||||
version = "0.1.24"
|
||||
dependencies = [
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-core"
|
||||
version = "0.1.23"
|
||||
version = "0.1.24"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
"specta-typescript",
|
||||
"tempfile",
|
||||
"thiserror 2.0.21",
|
||||
"tokio-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-coverage"
|
||||
version = "0.1.23"
|
||||
version = "0.1.24"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-run",
|
||||
@@ -4055,7 +4061,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-dap"
|
||||
version = "0.1.23"
|
||||
version = "0.1.24"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-jsonrpc",
|
||||
@@ -4072,7 +4078,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-db"
|
||||
version = "0.1.23"
|
||||
version = "0.1.24"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"futures",
|
||||
@@ -4091,7 +4097,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-docker"
|
||||
version = "0.1.23"
|
||||
version = "0.1.24"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"libc",
|
||||
@@ -4103,9 +4109,20 @@ 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.23"
|
||||
version = "0.1.24"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-vcs",
|
||||
@@ -4119,7 +4136,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-host"
|
||||
version = "0.1.23"
|
||||
version = "0.1.24"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"dunce",
|
||||
@@ -4160,7 +4177,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-http"
|
||||
version = "0.1.23"
|
||||
version = "0.1.24"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"parking_lot",
|
||||
@@ -4178,7 +4195,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-index"
|
||||
version = "0.1.23"
|
||||
version = "0.1.24"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"criterion",
|
||||
@@ -4200,7 +4217,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-jsonrpc"
|
||||
version = "0.1.23"
|
||||
version = "0.1.24"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"futures",
|
||||
@@ -4216,7 +4233,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-language"
|
||||
version = "0.1.23"
|
||||
version = "0.1.24"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"criterion",
|
||||
@@ -4270,7 +4287,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-lsp"
|
||||
version = "0.1.23"
|
||||
version = "0.1.24"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"globset",
|
||||
@@ -4293,7 +4310,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-plugin"
|
||||
version = "0.1.23"
|
||||
version = "0.1.24"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-language",
|
||||
@@ -4311,7 +4328,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-profiler"
|
||||
version = "0.1.23"
|
||||
version = "0.1.24"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-run",
|
||||
@@ -4324,7 +4341,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-remote"
|
||||
version = "0.1.23"
|
||||
version = "0.1.24"
|
||||
dependencies = [
|
||||
"globset",
|
||||
"ide-core",
|
||||
@@ -4341,7 +4358,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-run"
|
||||
version = "0.1.23"
|
||||
version = "0.1.24"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"ide-core",
|
||||
@@ -4361,7 +4378,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-sdk"
|
||||
version = "0.1.23"
|
||||
version = "0.1.24"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"ide-core",
|
||||
@@ -4381,7 +4398,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-search"
|
||||
version = "0.1.23"
|
||||
version = "0.1.24"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"globset",
|
||||
@@ -4401,7 +4418,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-spell"
|
||||
version = "0.1.23"
|
||||
version = "0.1.24"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"parking_lot",
|
||||
@@ -4414,7 +4431,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-ssh"
|
||||
version = "0.1.23"
|
||||
version = "0.1.24"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"flate2",
|
||||
@@ -4433,7 +4450,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-syntax"
|
||||
version = "0.1.23"
|
||||
version = "0.1.24"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-language",
|
||||
@@ -4448,7 +4465,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-terminal"
|
||||
version = "0.1.23"
|
||||
version = "0.1.24"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"libc",
|
||||
@@ -4461,7 +4478,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-text"
|
||||
version = "0.1.23"
|
||||
version = "0.1.24"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"ide-core",
|
||||
@@ -4473,7 +4490,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-tools"
|
||||
version = "0.1.23"
|
||||
version = "0.1.24"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"hex",
|
||||
@@ -4494,11 +4511,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-vcs"
|
||||
version = "0.1.23"
|
||||
version = "0.1.24"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"gix",
|
||||
"ide-core",
|
||||
"ignore",
|
||||
"libc",
|
||||
"regex-lite",
|
||||
"serde",
|
||||
@@ -4511,11 +4529,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-vfs"
|
||||
version = "0.1.23"
|
||||
version = "0.1.24"
|
||||
dependencies = [
|
||||
"chardetng",
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
"gix",
|
||||
"globset",
|
||||
"ide-core",
|
||||
"ignore",
|
||||
@@ -4532,7 +4551,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-web"
|
||||
version = "0.1.23"
|
||||
version = "0.1.24"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"open",
|
||||
@@ -4548,7 +4567,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-workspace"
|
||||
version = "0.1.23"
|
||||
version = "0.1.24"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dunce",
|
||||
@@ -5973,7 +5992,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openidea"
|
||||
version = "0.1.23"
|
||||
version = "0.1.24"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dirs 6.0.0",
|
||||
@@ -5987,6 +6006,7 @@ dependencies = [
|
||||
"ide-dap",
|
||||
"ide-db",
|
||||
"ide-docker",
|
||||
"ide-fonts",
|
||||
"ide-github",
|
||||
"ide-host",
|
||||
"ide-http",
|
||||
|
||||
+3
-2
@@ -3,7 +3,7 @@ resolver = "3"
|
||||
members = ["crates/*", "src-tauri"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.23"
|
||||
version = "0.1.24"
|
||||
edition = "2024"
|
||||
rust-version = "1.96"
|
||||
license = "Apache-2.0"
|
||||
@@ -36,6 +36,7 @@ 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" }
|
||||
@@ -87,7 +88,7 @@ tree-sitter = "0.25"
|
||||
lsp-types = "0.97"
|
||||
gix = { version = "0.89", default-features = false }
|
||||
portable-pty = "0.9"
|
||||
reqwest = { version = "0.13", default-features = false, features = ["rustls-no-provider", "http2", "charset", "gzip", "deflate", "brotli"] }
|
||||
reqwest = { version = "0.13", default-features = false, features = ["rustls-no-provider", "http2", "charset", "socks", "gzip", "deflate", "brotli"] }
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] }
|
||||
rustls-platform-verifier = "0.7"
|
||||
sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio", "tls-rustls-ring-webpki", "sqlite", "postgres", "mysql"] }
|
||||
|
||||
@@ -28,6 +28,19 @@ icon set, copied unmodified (some renamed) from JetBrains' open-source repositor
|
||||
|
||||
At render time `src/ui/icons.tsx` may recolour monochrome icons to `currentColor`.
|
||||
|
||||
## JetBrains IntelliJ Platform diff algorithm
|
||||
|
||||
`src/features/vcs/comparison/**` is a TypeScript port of the text comparison of
|
||||
[intellij-community](https://github.com/JetBrains/intellij-community) (Apache-2.0):
|
||||
`platform/util/diff` (`com.intellij.util.diff`: Diff, Reindexer, MyersLCS,
|
||||
PatienceIntLCS, UniqueLCS; `com.intellij.diff.comparison`: ByLineRt, ByWordRt,
|
||||
ByCharRt, ChunkOptimizer, ChangeCorrector, LineFragmentSplitter, TrimUtil,
|
||||
ComparisonUtil, iterables) and `platform/diff-impl` (ComparisonManagerImpl's
|
||||
`compareLinesInner` and `processBlocks`). `__tests__/ideaCases.json` is converted
|
||||
from its tests (`LineComparisonUtilTest`, `WordComparisonUtilTest`,
|
||||
`SplitComparisonUtilTest`). Modified from the originals. Copyright 2000-2026
|
||||
JetBrains s.r.o. and contributors. Apache License 2.0 (below).
|
||||
|
||||
`src/ui/openidea/**` holds OpenIDEA's own file-type icons drawn in the expui style for
|
||||
languages whose JetBrains icons are not open source (Go, Rust, Ruby, Dart, Lua, Zig,
|
||||
Svelte, …). Those using the page-with-corner-badge layout (`goMod`, `goWork`,
|
||||
|
||||
@@ -4,6 +4,39 @@
|
||||
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> {
|
||||
@@ -64,7 +97,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.via != AgentConfigVia::Option && !covered(o));
|
||||
config.retain(|o| o.id == CURSOR_APPROVAL || (o.via != AgentConfigVia::Option && !covered(o)));
|
||||
let legacy = std::mem::take(config);
|
||||
config.extend(fresh);
|
||||
config.extend(legacy);
|
||||
|
||||
@@ -46,6 +46,8 @@ 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`.
|
||||
@@ -112,7 +114,13 @@ impl Shared {
|
||||
}
|
||||
|
||||
fn known_config(&self, provider: &str) -> Vec<AgentConfigOption> {
|
||||
self.known.lock().get(provider).map(|k| k.config.clone()).unwrap_or_default()
|
||||
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
|
||||
}
|
||||
|
||||
/// The commands the agent offered last time in this project.
|
||||
@@ -264,6 +272,7 @@ 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);
|
||||
@@ -389,6 +398,7 @@ 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(),
|
||||
@@ -512,7 +522,7 @@ impl AgentManager {
|
||||
o.clone()
|
||||
};
|
||||
let live = task.live.lock().clone().filter(|l| !l.peer.is_closed());
|
||||
if let Some(live) = live {
|
||||
if let Some(live) = live.filter(|_| option != config::CURSOR_APPROVAL) {
|
||||
let (method, mut params) = config::set_request(&target, &value);
|
||||
params["sessionId"] = json!(live.session);
|
||||
let reply: Value =
|
||||
@@ -530,6 +540,7 @@ impl AgentManager {
|
||||
}
|
||||
s.task.model = model_of(&s.task.config).or(s.task.model.take());
|
||||
}
|
||||
task.approve_pending();
|
||||
task.persist();
|
||||
task.emit_task();
|
||||
Ok(())
|
||||
@@ -577,6 +588,7 @@ impl AgentManager {
|
||||
usage: None,
|
||||
queue: Vec::new(),
|
||||
resets_ms: None,
|
||||
retry: None,
|
||||
..s.task.clone()
|
||||
};
|
||||
TaskFile {
|
||||
@@ -784,7 +796,8 @@ impl AgentManager {
|
||||
|
||||
impl Task {
|
||||
fn restore(file: TaskFile, shared: Arc<Shared>) -> Arc<Self> {
|
||||
let task = file.task;
|
||||
let mut task = file.task;
|
||||
config::add_client_options(&task.provider, &mut task.config);
|
||||
let conversation = Conversation::restore(file.entries, file.subagents);
|
||||
Arc::new(Self {
|
||||
id: task.id.clone(),
|
||||
@@ -830,9 +843,22 @@ 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();
|
||||
@@ -1156,10 +1182,17 @@ 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;
|
||||
@@ -1203,7 +1236,7 @@ impl Task {
|
||||
s.wanted
|
||||
.iter()
|
||||
.filter_map(|c| {
|
||||
let o = s.task.config.iter().find(|o| o.id == c.id)?;
|
||||
let o = s.task.config.iter().find(|o| o.id == c.id && o.id != config::CURSOR_APPROVAL)?;
|
||||
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()));
|
||||
@@ -1264,6 +1297,12 @@ 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());
|
||||
@@ -1332,6 +1371,8 @@ 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.
|
||||
@@ -1374,6 +1415,7 @@ 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
|
||||
@@ -1397,6 +1439,22 @@ 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);
|
||||
@@ -1556,6 +1614,17 @@ fn is_zero(n: &u32) -> bool {
|
||||
*n == 0
|
||||
}
|
||||
|
||||
/// The retry a [`RETRY_UPDATE`] announces, timed from `now`.
|
||||
fn retry_of(update: &Value, now: f64) -> Option<AgentRetry> {
|
||||
let n = |k: &str| update.get(k).and_then(Value::as_u64);
|
||||
Some(AgentRetry {
|
||||
attempt: u32::try_from(n("attempt")?).unwrap_or(u32::MAX),
|
||||
max_retries: n("maxRetries").and_then(|m| u32::try_from(m).ok()),
|
||||
retry_at_ms: now + n("retryDelayMs").unwrap_or(0) as f64,
|
||||
error: update.get("error").and_then(Value::as_str).filter(|e| !e.is_empty()).map(str::to_owned),
|
||||
})
|
||||
}
|
||||
|
||||
fn model_of(config: &[AgentConfigOption]) -> Option<String> {
|
||||
config.iter().find(|o| o.category.as_deref() == Some("model")).and_then(|o| o.current.clone())
|
||||
}
|
||||
|
||||
@@ -81,6 +81,21 @@ 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,6 +917,20 @@ 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,6 +5,7 @@
|
||||
//! 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;
|
||||
@@ -427,6 +428,23 @@ impl Translator {
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
// Claude Code retries a failed API request on its own; without this the turn looks stuck.
|
||||
Some("api_retry") => {
|
||||
let n = |k: &str| msg.get(k).and_then(Value::as_u64);
|
||||
let error = msg.get("error").and_then(|e| {
|
||||
e.as_str().map(str::to_owned).or_else(|| e.get("message")?.as_str().map(str::to_owned))
|
||||
});
|
||||
vec![Update {
|
||||
session: self.root.clone(),
|
||||
update: json!({
|
||||
"sessionUpdate": RETRY_UPDATE,
|
||||
"attempt": n("attempt").unwrap_or(1),
|
||||
"maxRetries": n("max_retries"),
|
||||
"retryDelayMs": n("retry_delay_ms").unwrap_or(0),
|
||||
"error": error,
|
||||
}),
|
||||
}]
|
||||
}
|
||||
Some("task_notification") if self.children.get(&tool_use).is_some_and(|c| c.background != Some(false)) => {
|
||||
let stop = match msg.get("status").and_then(Value::as_str) {
|
||||
Some("failed") => "error",
|
||||
|
||||
@@ -199,7 +199,12 @@ fn invalid(message: impl Into<String>) -> ResponseError {
|
||||
fn from_codex(e: RpcError) -> ResponseError {
|
||||
match e {
|
||||
RpcError::Server { code, message, data } => {
|
||||
let code = if message.to_lowercase().contains("auth") { AUTH_REQUIRED } else { code };
|
||||
// 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 };
|
||||
ResponseError { code, message, data }
|
||||
}
|
||||
RpcError::Closed => error(codes::INTERNAL_ERROR, "Codex exited"),
|
||||
@@ -426,10 +431,48 @@ 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"))?;
|
||||
self.call("thread/rollback", json!({ "threadId": session, "numTurns": turns })).await?;
|
||||
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),
|
||||
}
|
||||
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,6 +593,104 @@ async fn resume_replays_history_then_forks_and_rolls_back() {
|
||||
assert_eq!(sent(&frames, "thread/unsubscribe")[0]["params"], json!({"threadId": thread}));
|
||||
}
|
||||
|
||||
async fn rollback_replies(turns: u32, replies: Vec<(&str, Value)>) -> (Result<Value, RpcError>, Vec<Value>) {
|
||||
let frames = replies
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.flat_map(|(i, (method, mut reply))| {
|
||||
let id = i + 1;
|
||||
reply["id"] = json!(id);
|
||||
[(true, json!({ "id": id, "method": method })), (false, reply)]
|
||||
})
|
||||
.collect();
|
||||
let transcript = Transcript { meta: Value::Null, frames };
|
||||
let (ours, theirs) = tokio::io::duplex(1 << 16);
|
||||
let acp = attach(Arc::new(Client::default()), |events| {
|
||||
let (read, write) = tokio::io::split(ours);
|
||||
Ok(Peer::connect_framed(read, write, events, Framing::Lines))
|
||||
})
|
||||
.unwrap();
|
||||
let (read, write) = tokio::io::split(theirs);
|
||||
let player = tokio::spawn(async move { play(&transcript, read, write).await });
|
||||
let result = request(&acp, "_openidea/rollback", json!({ "sessionId": "s", "turns": turns })).await;
|
||||
let (sent, _read, _write) = player.await.unwrap();
|
||||
acp.kill();
|
||||
(result, sent)
|
||||
}
|
||||
|
||||
fn removed_rollback() -> Value {
|
||||
json!({ "error": { "code": -32600, "message": "Invalid request: unknown variant `thread/rollback`, expected one of `thread/revert`, `account/gatewayOAuth/read`, `getAuthStatus`" } })
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn removed_rollback_reverts_before_the_target_turn_across_pages() {
|
||||
let (result, frames) = rollback_replies(
|
||||
3,
|
||||
vec![
|
||||
("thread/rollback", removed_rollback()),
|
||||
(
|
||||
"thread/turns/list",
|
||||
json!({ "result": { "data": [{ "id": "last" }, { "id": "middle" }], "nextCursor": "older" } }),
|
||||
),
|
||||
(
|
||||
"thread/turns/list",
|
||||
json!({ "result": { "data": [{ "id": "edited" }, { "id": "retained" }], "nextCursor": null } }),
|
||||
),
|
||||
("thread/revert", json!({ "result": { "thread": { "id": "s", "turns": [] } } })),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(result.unwrap(), json!({}));
|
||||
let pages = sent(&frames, "thread/turns/list");
|
||||
assert_eq!(pages[0]["params"], json!({ "threadId": "s", "sortDirection": "desc", "limit": 3, "cursor": null }));
|
||||
assert_eq!(pages[1]["params"]["cursor"], "older");
|
||||
assert_eq!(pages[1]["params"]["limit"], 1);
|
||||
assert_eq!(sent(&frames, "thread/revert")[0]["params"], json!({ "threadId": "s", "beforeTurnId": "edited" }));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn method_not_found_reverts_the_latest_turn() {
|
||||
let (result, frames) = rollback_replies(
|
||||
1,
|
||||
vec![
|
||||
("thread/rollback", json!({ "error": { "code": -32601, "message": "Method not found" } })),
|
||||
("thread/turns/list", json!({ "result": { "data": [{ "id": "last" }], "nextCursor": null } })),
|
||||
("thread/revert", json!({ "result": {} })),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(result.unwrap(), json!({}));
|
||||
assert_eq!(sent(&frames, "thread/revert")[0]["params"]["beforeTurnId"], "last");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn rollback_refusals_are_not_retried_as_revert() {
|
||||
let (result, frames) = rollback_replies(
|
||||
1,
|
||||
vec![("thread/rollback", json!({ "error": { "code": -32600, "message": "a turn is still running" } }))],
|
||||
)
|
||||
.await;
|
||||
assert!(result.unwrap_err().to_string().contains("a turn is still running"));
|
||||
assert_eq!(frames.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn incomplete_or_repeated_history_pages_do_not_revert_the_wrong_turn() {
|
||||
for next in [Value::Null, json!("older")] {
|
||||
let (result, frames) = rollback_replies(
|
||||
3,
|
||||
vec![
|
||||
("thread/rollback", removed_rollback()),
|
||||
("thread/turns/list", json!({ "result": { "data": [{ "id": "last" }], "nextCursor": "older" } })),
|
||||
("thread/turns/list", json!({ "result": { "data": [], "nextCursor": next } })),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
assert!(result.unwrap_err().to_string().contains("reload the task"));
|
||||
assert!(sent(&frames, "thread/revert").is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn steering_adds_to_the_running_turn() {
|
||||
let (results, _client, frames, _) = replay(Scenario::Steer).await;
|
||||
|
||||
@@ -709,3 +709,75 @@ async fn usage_limits_and_late_settings() {
|
||||
let config = mgr.task(&task.id).unwrap().state.lock().task.config.clone();
|
||||
assert_eq!(config.iter().find(|o| o.id == "effort").unwrap().current.as_deref(), Some("high"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_updates_become_a_countdown() {
|
||||
let update =
|
||||
json!({"sessionUpdate": RETRY_UPDATE, "attempt": 2, "maxRetries": 10, "retryDelayMs": 1500, "error": ""});
|
||||
assert_eq!(
|
||||
retry_of(&update, 1000.0),
|
||||
Some(AgentRetry { attempt: 2, max_retries: Some(10), retry_at_ms: 2500.0, error: None })
|
||||
);
|
||||
assert_eq!(retry_of(&json!({"sessionUpdate": RETRY_UPDATE}), 0.0), None, "no attempt");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn cursor_approval_switch_resolves_pending_and_survives_restart() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let agent = Arc::new(FakeAgent::default());
|
||||
let mgr = manager(tmp.path(), agent.clone());
|
||||
let new = || NewAgentTask {
|
||||
prompt: text("Fix"),
|
||||
provider: "cursor".into(),
|
||||
config: Vec::new(),
|
||||
isolation: Isolation::Local,
|
||||
base: None,
|
||||
};
|
||||
let task = mgr.create(new()).await.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
|
||||
mgr.set_config(&task.id, config::CURSOR_APPROVAL, "bypass".into()).await.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
assert!(
|
||||
mgr.entries(&task.id, None)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|e| matches!(e, AgentEntry::Permission { chosen: Some(name), .. } if name == "Allow"))
|
||||
);
|
||||
// Agent config updates must not erase the client's approval setting.
|
||||
mgr.prompt(&task.id, text("late option"), false).await.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
let t = mgr.task(&task.id).unwrap();
|
||||
assert!(config::bypass_approvals("cursor", &t.state.lock().task.config));
|
||||
t.kill();
|
||||
mgr.prompt(&task.id, text("Fix again"), false).await.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
assert!(!agent.calls.lock().iter().any(|c| c.contains("bypass")));
|
||||
assert!(!config::bypass_approvals("cursor", &mgr.shared.known_config("cursor")));
|
||||
mgr.set_config(&task.id, config::CURSOR_APPROVAL, "ask".into()).await.unwrap();
|
||||
mgr.prompt(&task.id, text("Fix once more"), false).await.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
|
||||
mgr.cancel(&task.id, None).unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
// An unrelated new conversation still asks, even after the cache was updated.
|
||||
let other = mgr.create(new()).await.unwrap();
|
||||
wait_for(&mgr, &other.id, AgentStatus::NeedsInput).await;
|
||||
mgr.shutdown();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn cursor_approval_choice_applies_before_first_prompt() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mgr = manager(tmp.path(), Arc::new(FakeAgent::default()));
|
||||
let task = mgr
|
||||
.create(NewAgentTask {
|
||||
prompt: text("Fix"),
|
||||
provider: "cursor".into(),
|
||||
config: vec![AgentConfigChoice { id: config::CURSOR_APPROVAL.into(), value: "bypass".into() }],
|
||||
isolation: Isolation::Local,
|
||||
base: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
mgr.shutdown();
|
||||
}
|
||||
@@ -45,8 +45,12 @@ 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).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
command.args(args).current_dir(cwd).envs(env).envs(encoding.env).stdin(Stdio::null());
|
||||
command.stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::process::CommandExt;
|
||||
@@ -86,6 +90,7 @@ 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,8 +1,13 @@
|
||||
[package]
|
||||
name = "ide-clipboard"
|
||||
description = "The OS clipboard's file list (Explorer <-> project view): Windows CF_HDROP read/write, no window or message loop"
|
||||
description = "The OS clipboard's file list (Explorer <-> project view): Windows CF_HDROP or macOS NSPasteboard file URLs, no window or message loop"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
objc2 = { version = "0.6", default-features = false, features = ["std"] }
|
||||
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSDictionary", "NSEnumerator", "NSObject", "NSString", "NSURL", "NSValue"] }
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSPasteboard"] }
|
||||
@@ -7,9 +7,12 @@
|
||||
//! in full rather than rendered on demand, so no window or message loop is
|
||||
//! needed and the list survives after `CloseClipboard`.
|
||||
//!
|
||||
//! 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.
|
||||
//! 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.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -218,7 +221,78 @@ mod imp {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[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")))]
|
||||
mod imp {
|
||||
use std::path::PathBuf;
|
||||
|
||||
|
||||
@@ -13,3 +13,6 @@ specta.workspace = true
|
||||
specta-typescript.workspace = true
|
||||
thiserror.workspace = true
|
||||
tokio-util.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
@@ -12,12 +12,15 @@ 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;
|
||||
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};
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
//! 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");
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,8 @@ pub fn prepare(cmd: &mut Command) -> &mut Command {
|
||||
/// `GST_PLUGIN_*`, ... into the mounted image for the IDE itself; a host program that
|
||||
/// inherits them loads the image's (older) libraries and plugins next to its own, e.g.
|
||||
/// `curl: symbol lookup error ... nghttp2_...`. The IDE process keeps its environment
|
||||
/// (WebKit and GTK still need it). Then applies the [`set_env_overlay`] variables.
|
||||
/// (WebKit and GTK still need it). Then applies the [`set_env_overlay`] variables and
|
||||
/// those of a manual HTTP proxy ([`crate::proxy_settings::ProxySettings::child_env`]).
|
||||
/// Variables the call site has already set are kept. A no-op outside an AppImage
|
||||
/// without an overlay.
|
||||
pub fn host_env(cmd: &mut Command) -> &mut Command {
|
||||
@@ -89,12 +90,16 @@ fn apply_changes(
|
||||
vars
|
||||
}
|
||||
|
||||
/// The AppImage fixes, then the overlay (which wins for a name both change); one entry per name.
|
||||
/// The AppImage fixes, the overlay, then the manual proxy's (a later one wins); one entry per name.
|
||||
fn child_env_changes() -> Vec<(OsString, Option<OsString>)> {
|
||||
let mut changes = appimage_fixes();
|
||||
let overlay = env_overlay();
|
||||
changes.retain(|(k, _)| !overlay.iter().any(|(o, _)| o == k));
|
||||
changes.extend(overlay.iter().cloned());
|
||||
// A manual HTTP proxy reaches tools that read `HTTP_PROXY` (language servers, git, curl).
|
||||
let proxy = crate::proxy_settings::proxy().child_env();
|
||||
changes.retain(|(k, _)| !proxy.iter().any(|(p, _)| p == k));
|
||||
changes.extend(proxy);
|
||||
changes
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
//! Appearance & Behavior › System Settings › HTTP Proxy, and the proxy the IDE's own
|
||||
//! connections use: [`set_proxy`] makes a configuration current, every outgoing
|
||||
//! request asks [`proxy`] (the HTTP client, `curl` downloads, the updater), and with a
|
||||
//! manual proxy child processes get `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY`.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::ffi::OsString;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ProxyMode {
|
||||
/// Every connection goes direct.
|
||||
None,
|
||||
/// The system's proxy: `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` of the environment.
|
||||
#[default]
|
||||
Auto,
|
||||
/// The proxy configured here.
|
||||
Manual,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ProxyKind {
|
||||
#[default]
|
||||
Http,
|
||||
Socks,
|
||||
}
|
||||
|
||||
impl ProxyKind {
|
||||
pub fn default_port(self) -> u16 {
|
||||
match self {
|
||||
ProxyKind::Http => 80,
|
||||
ProxyKind::Socks => 1080,
|
||||
}
|
||||
}
|
||||
|
||||
fn scheme(self) -> &'static str {
|
||||
match self {
|
||||
ProxyKind::Http => "http",
|
||||
// Host names resolve on the proxy.
|
||||
ProxyKind::Socks => "socks5h",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct ProxySettings {
|
||||
pub mode: ProxyMode,
|
||||
pub kind: ProxyKind,
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
/// Hosts reached directly, comma-separated; `*` matches any characters (`*.example.com`, `192.168.*`).
|
||||
pub no_proxy: String,
|
||||
/// Send `login` / `password` to the proxy.
|
||||
pub authenticate: bool,
|
||||
pub login: String,
|
||||
/// Kept in the saved passwords rather than `settings.toml`; filled in when settings are read.
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
impl Default for ProxySettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
mode: ProxyMode::Auto,
|
||||
kind: ProxyKind::Http,
|
||||
host: String::new(),
|
||||
port: ProxyKind::Http.default_port(),
|
||||
no_proxy: String::new(),
|
||||
authenticate: false,
|
||||
login: String::new(),
|
||||
password: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ProxySettings {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("ProxySettings")
|
||||
.field("mode", &self.mode)
|
||||
.field("kind", &self.kind)
|
||||
.field("host", &self.host)
|
||||
.field("port", &self.port)
|
||||
.field("no_proxy", &self.no_proxy)
|
||||
.field("authenticate", &self.authenticate)
|
||||
.field("login", &self.login)
|
||||
.field("password", &if self.password.is_empty() { "" } else { "***" })
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// How a connection to some host is made.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ProxyRoute {
|
||||
Direct,
|
||||
/// Whatever the environment says (the HTTP library's / `curl`'s own proxy lookup).
|
||||
System,
|
||||
/// Through this proxy URL, credentials included (`http://user:pass@proxy:3128`).
|
||||
Via(String),
|
||||
}
|
||||
|
||||
/// Loopback hosts never go through the manual proxy.
|
||||
const LOOPBACK: [&str; 3] = ["localhost", "127.0.0.1", "::1"];
|
||||
|
||||
impl ProxySettings {
|
||||
/// A manual proxy with a host: what [`Self::route`] sends connections through.
|
||||
pub fn manual(&self) -> bool {
|
||||
self.mode == ProxyMode::Manual && !self.host.trim().is_empty()
|
||||
}
|
||||
|
||||
/// What is wrong with the manual configuration, if anything.
|
||||
pub fn problem(&self) -> Option<String> {
|
||||
if self.mode != ProxyMode::Manual {
|
||||
return None;
|
||||
}
|
||||
let host = self.host.trim();
|
||||
if host.is_empty() {
|
||||
return Some("Proxy host is empty".into());
|
||||
}
|
||||
if host.contains(['/', '@', ' ']) {
|
||||
return Some(format!("Invalid proxy host: {host}"));
|
||||
}
|
||||
if self.port == 0 {
|
||||
return Some("Proxy port must be between 1 and 65535".into());
|
||||
}
|
||||
if self.authenticate && self.login.trim().is_empty() {
|
||||
return Some("Proxy login is empty".into());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Whether `host` is reached directly: loopback, or a "No proxy for" entry.
|
||||
pub fn bypasses(&self, host: &str) -> bool {
|
||||
let host = host.trim().trim_start_matches('[').trim_end_matches(']').to_ascii_lowercase();
|
||||
if LOOPBACK.contains(&host.as_str()) {
|
||||
return true;
|
||||
}
|
||||
self.no_proxy_entries().any(|pattern| glob_match(&pattern, &host))
|
||||
}
|
||||
|
||||
fn no_proxy_entries(&self) -> impl Iterator<Item = String> + '_ {
|
||||
self.no_proxy.split([',', ';', '\n']).map(|e| e.trim().to_ascii_lowercase()).filter(|e| !e.is_empty())
|
||||
}
|
||||
|
||||
/// The manual proxy as a URL, with the credentials when it authenticates.
|
||||
pub fn proxy_url(&self) -> Option<String> {
|
||||
if !self.manual() {
|
||||
return None;
|
||||
}
|
||||
let host = self.host.trim();
|
||||
let host = if host.contains(':') && !host.starts_with('[') { format!("[{host}]") } else { host.to_owned() };
|
||||
let auth = if self.authenticate && !self.login.is_empty() {
|
||||
format!("{}:{}@", encode_userinfo(&self.login), encode_userinfo(&self.password))
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
Some(format!("{}://{auth}{host}:{}", self.kind.scheme(), self.port))
|
||||
}
|
||||
|
||||
/// How to connect to `host` (`None`: not known yet, e.g. the request has no URL).
|
||||
pub fn route(&self, host: Option<&str>) -> ProxyRoute {
|
||||
match self.mode {
|
||||
ProxyMode::None => ProxyRoute::Direct,
|
||||
ProxyMode::Auto => ProxyRoute::System,
|
||||
ProxyMode::Manual => match self.proxy_url() {
|
||||
Some(_) if host.is_some_and(|h| self.bypasses(h)) => ProxyRoute::Direct,
|
||||
Some(url) => ProxyRoute::Via(url),
|
||||
None => ProxyRoute::Direct,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Self::route`] for the host of `url` (`https://user@host:443/path`).
|
||||
pub fn route_for_url(&self, url: &str) -> ProxyRoute {
|
||||
self.route(url_host(url).as_deref())
|
||||
}
|
||||
|
||||
/// Arguments that make a `curl` download of `url` follow this configuration. The
|
||||
/// manual proxy itself comes from the environment (see [`Self::child_env`]), which
|
||||
/// keeps the credentials out of the command line.
|
||||
pub fn curl_args(&self, url: &str) -> Vec<&'static str> {
|
||||
match self.route_for_url(url) {
|
||||
ProxyRoute::Direct => vec!["--noproxy", "*"],
|
||||
ProxyRoute::System => Vec::new(),
|
||||
// Only this configuration decides what bypasses the proxy.
|
||||
ProxyRoute::Via(_) => vec!["--noproxy", ""],
|
||||
}
|
||||
}
|
||||
|
||||
/// Variables child processes get with a manual proxy (`HTTP_PROXY`, `HTTPS_PROXY`,
|
||||
/// `ALL_PROXY`, `NO_PROXY` and their lowercase forms); none otherwise.
|
||||
pub fn child_env(&self) -> Vec<(OsString, Option<OsString>)> {
|
||||
let Some(url) = self.proxy_url() else { return Vec::new() };
|
||||
let mut no_proxy: Vec<String> = LOOPBACK.iter().map(|h| (*h).to_owned()).collect();
|
||||
// `*.example.com` is `.example.com` to curl, Go, Python and Node.
|
||||
no_proxy.extend(self.no_proxy_entries().map(|e| e.strip_prefix('*').map(str::to_owned).unwrap_or(e)));
|
||||
no_proxy.dedup();
|
||||
let no_proxy = no_proxy.join(",");
|
||||
let mut env = Vec::new();
|
||||
for name in ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY"] {
|
||||
env.push((OsString::from(name), Some(OsString::from(&url))));
|
||||
env.push((OsString::from(name.to_ascii_lowercase()), Some(OsString::from(&url))));
|
||||
}
|
||||
env.push((OsString::from("NO_PROXY"), Some(OsString::from(&no_proxy))));
|
||||
env.push((OsString::from("no_proxy"), Some(OsString::from(no_proxy))));
|
||||
env
|
||||
}
|
||||
}
|
||||
|
||||
/// Case-insensitive host glob (`pattern` is lowercase): `*` matches any run of characters.
|
||||
fn glob_match(pattern: &str, host: &str) -> bool {
|
||||
let (p, h) = (pattern.as_bytes(), host.as_bytes());
|
||||
let (mut pi, mut hi) = (0, 0);
|
||||
let mut star: Option<(usize, usize)> = None;
|
||||
while hi < h.len() {
|
||||
if pi < p.len() && p[pi] == b'*' {
|
||||
star = Some((pi, hi));
|
||||
pi += 1;
|
||||
} else if pi < p.len() && p[pi] == h[hi] {
|
||||
pi += 1;
|
||||
hi += 1;
|
||||
} else if let Some((sp, sh)) = star {
|
||||
pi = sp + 1;
|
||||
hi = sh + 1;
|
||||
star = Some((sp, sh + 1));
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
p[pi..].iter().all(|&c| c == b'*')
|
||||
}
|
||||
|
||||
fn encode_userinfo(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for b in s.bytes() {
|
||||
if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
|
||||
out.push(b as char);
|
||||
} else {
|
||||
out.push_str(&format!("%{b:02X}"));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The host of `url`, without brackets, userinfo or port.
|
||||
fn url_host(url: &str) -> Option<String> {
|
||||
let rest = url.split_once("://").map_or(url, |(_, r)| r);
|
||||
let authority = rest.split(['/', '?', '#']).next()?;
|
||||
let authority = authority.rsplit_once('@').map_or(authority, |(_, a)| a);
|
||||
let host =
|
||||
if let Some(v6) = authority.strip_prefix('[') { v6.split(']').next()? } else { authority.split(':').next()? };
|
||||
(!host.is_empty()).then(|| host.to_ascii_lowercase())
|
||||
}
|
||||
|
||||
static CURRENT: RwLock<Option<Arc<ProxySettings>>> = RwLock::new(None);
|
||||
|
||||
/// Makes `settings` (password filled in) the proxy of every later connection and child process.
|
||||
pub fn set_proxy(settings: ProxySettings) {
|
||||
*CURRENT.write().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(settings));
|
||||
}
|
||||
|
||||
/// The current proxy configuration (the default, auto-detect, until [`set_proxy`]).
|
||||
pub fn proxy() -> Arc<ProxySettings> {
|
||||
CURRENT.read().unwrap_or_else(|e| e.into_inner()).clone().unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn manual(host: &str, port: u16) -> ProxySettings {
|
||||
ProxySettings { mode: ProxyMode::Manual, host: host.into(), port, ..Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_is_the_default_and_defers_to_the_system() {
|
||||
let p = ProxySettings::default();
|
||||
assert_eq!(p.mode, ProxyMode::Auto);
|
||||
assert_eq!(p.route_for_url("https://example.com/"), ProxyRoute::System);
|
||||
assert!(p.curl_args("https://example.com/").is_empty());
|
||||
assert!(p.child_env().is_empty());
|
||||
assert_eq!(p.problem(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_proxy_goes_direct() {
|
||||
let p = ProxySettings { mode: ProxyMode::None, ..manual("proxy", 3128) };
|
||||
assert_eq!(p.route_for_url("https://example.com/"), ProxyRoute::Direct);
|
||||
assert_eq!(p.curl_args("https://example.com/"), ["--noproxy", "*"]);
|
||||
assert!(p.child_env().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_http_and_socks_urls() {
|
||||
let p = manual("proxy.corp", 3128);
|
||||
assert_eq!(p.route_for_url("https://example.com/x"), ProxyRoute::Via("http://proxy.corp:3128".into()));
|
||||
assert_eq!(p.curl_args("https://example.com/x"), ["--noproxy", ""]);
|
||||
let socks = ProxySettings { kind: ProxyKind::Socks, ..manual("10.0.0.1", 1080) };
|
||||
assert_eq!(socks.proxy_url().as_deref(), Some("socks5h://10.0.0.1:1080"));
|
||||
assert_eq!(manual("::1", 8080).proxy_url().as_deref(), Some("http://[::1]:8080"));
|
||||
// Manual without a host connects directly.
|
||||
assert_eq!(manual(" ", 80).route(Some("example.com")), ProxyRoute::Direct);
|
||||
assert!(manual(" ", 80).problem().is_some());
|
||||
assert!(manual("proxy", 0).problem().is_some());
|
||||
assert!(manual("http://proxy", 80).problem().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credentials_are_percent_encoded() {
|
||||
let p = ProxySettings {
|
||||
authenticate: true,
|
||||
login: "dom\\me".into(),
|
||||
password: "p@ss:w/rd".into(),
|
||||
..manual("proxy", 3128)
|
||||
};
|
||||
assert_eq!(p.proxy_url().as_deref(), Some("http://dom%5Cme:p%40ss%3Aw%2Frd@proxy:3128"));
|
||||
// Not sent without "Proxy authentication".
|
||||
let off = ProxySettings { authenticate: false, ..p.clone() };
|
||||
assert_eq!(off.proxy_url().as_deref(), Some("http://proxy:3128"));
|
||||
assert!(ProxySettings { login: " ".into(), ..p.clone() }.problem().is_some());
|
||||
assert!(!format!("{p:?}").contains("p@ss"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_proxy_for_list_matches_globs_and_loopback() {
|
||||
let p = ProxySettings { no_proxy: "*.example.com, 192.168.*,Intranet".into(), ..manual("proxy", 3128) };
|
||||
assert!(p.bypasses("api.example.com"));
|
||||
assert!(p.bypasses("a.b.EXAMPLE.com"));
|
||||
assert!(!p.bypasses("example.com"));
|
||||
assert!(!p.bypasses("example.com.evil.org"));
|
||||
assert!(p.bypasses("192.168.1.20"));
|
||||
assert!(p.bypasses("intranet"));
|
||||
assert!(!p.bypasses("intranet2"));
|
||||
assert!(p.bypasses("localhost") && p.bypasses("127.0.0.1") && p.bypasses("[::1]"));
|
||||
assert_eq!(p.route_for_url("https://[email protected]:8443/v1"), ProxyRoute::Direct);
|
||||
assert_eq!(p.curl_args("http://192.168.0.5/"), ["--noproxy", "*"]);
|
||||
assert!(matches!(p.route_for_url("https://github.com"), ProxyRoute::Via(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn child_processes_get_the_manual_proxy() {
|
||||
let p = ProxySettings {
|
||||
no_proxy: "*.corp, 10.*".into(),
|
||||
authenticate: true,
|
||||
login: "me".into(),
|
||||
password: "pw".into(),
|
||||
..manual("proxy", 3128)
|
||||
};
|
||||
let env = p.child_env();
|
||||
let get = |k: &str| env.iter().find(|(n, _)| n == k).and_then(|(_, v)| v.clone()).unwrap();
|
||||
assert_eq!(get("HTTPS_PROXY"), "http://me:pw@proxy:3128");
|
||||
assert_eq!(get("http_proxy"), "http://me:pw@proxy:3128");
|
||||
assert_eq!(get("NO_PROXY"), "localhost,127.0.0.1,::1,.corp,10.*");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_hosts() {
|
||||
assert_eq!(url_host("https://Example.com:443/a?b").as_deref(), Some("example.com"));
|
||||
assert_eq!(url_host("http://u:p@[::1]:80/").as_deref(), Some("::1"));
|
||||
assert_eq!(url_host("example.com/x").as_deref(), Some("example.com"));
|
||||
assert_eq!(url_host("file:///tmp/x"), None);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use std::collections::BTreeMap;
|
||||
|
||||
pub use crate::editor_settings::*;
|
||||
pub use crate::inspection_settings::*;
|
||||
pub use crate::proxy_settings::{ProxyKind, ProxyMode, ProxySettings};
|
||||
pub use crate::remote_settings::*;
|
||||
pub use crate::workflow_settings::*;
|
||||
|
||||
@@ -25,6 +26,8 @@ pub struct Settings {
|
||||
/// keymap's own overrides. `keymap` may name one of these ids.
|
||||
pub custom_keymaps: BTreeMap<String, CustomKeymap>,
|
||||
pub notifications: NotificationSettings,
|
||||
/// System Settings › HTTP Proxy (global only).
|
||||
pub proxy: ProxySettings,
|
||||
/// Interpreters / toolchains per language (Python, Node.js, Rust, Go, TypeScript).
|
||||
pub languages: LanguagesSettings,
|
||||
/// Project Structure: source / test / resource / excluded folders (project-relative).
|
||||
@@ -134,6 +137,7 @@ impl Default for Settings {
|
||||
keymap_overrides: BTreeMap::new(),
|
||||
custom_keymaps: BTreeMap::new(),
|
||||
notifications: NotificationSettings::default(),
|
||||
proxy: ProxySettings::default(),
|
||||
languages: LanguagesSettings::default(),
|
||||
project_structure: ProjectStructureSettings::default(),
|
||||
vcs: VcsSettings::default(),
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
//! 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())));
|
||||
}
|
||||
}
|
||||
+78
-14
@@ -88,10 +88,46 @@ 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) -> Option<String>
|
||||
fn cell<R: Row>(row: &R, i: usize, mode: Cells) -> 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>,
|
||||
{
|
||||
@@ -99,18 +135,14 @@ where
|
||||
if raw.is_null() {
|
||||
return None;
|
||||
}
|
||||
let ty = raw.type_info().name().to_ascii_uppercase();
|
||||
if ty.contains("BLOB") || ty.contains("BINARY") {
|
||||
let raw = row.try_get_raw(i).ok()?;
|
||||
if mode == Cells::Display && is_binary::<R::Database>(&raw.type_info()) {
|
||||
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| String::from_utf8(b).unwrap_or_else(|e| hex(e.as_bytes())))
|
||||
<Vec<u8> as Decode<R::Database>>::decode(raw).ok().map(|b| bytes_text(b, mode))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -122,9 +154,10 @@ fn result_columns<R: Row>(row: &R) -> Vec<ResultColumn> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Streams the results of `$sql` on executor `$exec`, keeping at most `$max` rows.
|
||||
/// Streams the results of `$sql` on executor `$exec`, keeping at most `$max` rows
|
||||
/// with their cells read as `$mode`.
|
||||
macro_rules! fetch {
|
||||
($exec:expr, $sql:expr, $max:expr) => {{
|
||||
($exec:expr, $sql:expr, $max:expr, $mode: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();
|
||||
@@ -145,7 +178,7 @@ macro_rules! fetch {
|
||||
has_more = true;
|
||||
break;
|
||||
}
|
||||
rows.push((0..row.len()).map(|i| cell(&row, i)).collect());
|
||||
rows.push((0..row.len()).map(|i| cell(&row, i, $mode)).collect());
|
||||
}
|
||||
Err(e) => {
|
||||
failure = Some(db_err(e));
|
||||
@@ -167,7 +200,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);
|
||||
let fetched: IdeResult<Fetched> = fetch!($conn, $sql, 1, Cells::Text);
|
||||
fetched
|
||||
.ok()
|
||||
.and_then(|f| f.1.into_iter().next())
|
||||
@@ -431,9 +464,9 @@ impl Pool {
|
||||
with_pool!(self, p => p.is_closed())
|
||||
}
|
||||
|
||||
/// Runs `sql` (no row limit beyond `max`), for introspection.
|
||||
/// Runs `sql` (no row limit beyond `max`), for introspection: cells are text (see [`Cells::Text`]).
|
||||
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));
|
||||
let fetched: IdeResult<Fetched> = with_pool!(self, p => fetch!(p, sql, max, Cells::Text));
|
||||
Ok(fetched?.1)
|
||||
}
|
||||
|
||||
@@ -530,7 +563,7 @@ impl Held {
|
||||
None => None,
|
||||
};
|
||||
let r = tokio::select! {
|
||||
r = async { fetch!(&mut **$conn, sql, max_rows) } => r,
|
||||
r = async { fetch!(&mut **$conn, sql, max_rows, Cells::Display) } => r,
|
||||
() = cancel.cancelled() => Err(IdeError::cancelled()),
|
||||
};
|
||||
if cancel.is_cancelled() {
|
||||
@@ -649,4 +682,35 @@ 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}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
//! 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
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
[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"] }
|
||||
@@ -0,0 +1,264 @@
|
||||
//! 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -395,6 +395,8 @@ mod tests {
|
||||
assert_eq!(changed_names(&before, &after), ["B", "C", "D"]);
|
||||
}
|
||||
|
||||
// Used by the Unix-only post-start tests.
|
||||
#[cfg_attr(not(unix), allow(dead_code))]
|
||||
#[derive(Default)]
|
||||
struct Events(Mutex<Vec<IdeEvent>>);
|
||||
impl EventSink for Events {
|
||||
@@ -403,6 +405,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(not(unix), allow(dead_code))]
|
||||
fn run(cmd: LifecycleCommand, root: &Path) -> Vec<IdeEvent> {
|
||||
let events = Arc::new(Events::default());
|
||||
let sink: SharedSink = events.clone();
|
||||
@@ -410,6 +413,7 @@ mod tests {
|
||||
events.0.lock().clone()
|
||||
}
|
||||
|
||||
#[cfg_attr(not(unix), allow(dead_code))]
|
||||
fn failure(events: &[IdeEvent]) -> Option<String> {
|
||||
events.iter().find_map(|e| match e {
|
||||
IdeEvent::Notify { level: NotifyLevel::Error, body, .. } => body.clone(),
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
pub mod environment;
|
||||
pub mod ops;
|
||||
mod project;
|
||||
mod proxy;
|
||||
mod requests;
|
||||
pub mod rpc;
|
||||
pub mod shell_env;
|
||||
@@ -56,6 +57,8 @@ pub struct Host {
|
||||
pub remote: Arc<ide_remote::RemoteService>,
|
||||
/// What child processes get beyond this process's environment (Reload Environment, `remoteEnv`).
|
||||
pub environment: environment::Environment,
|
||||
/// The HTTP proxy's saved password (see `proxy.rs`).
|
||||
proxy_password: ide_ssh::secrets::SecretFile,
|
||||
pub(crate) sink_for: SinkFactory,
|
||||
owner: fn(&str) -> &str,
|
||||
pub runtime: tokio::runtime::Handle,
|
||||
@@ -71,7 +74,8 @@ impl Host {
|
||||
let global_settings = SettingsStore::load(&config_dir, None);
|
||||
let ssh = Arc::new(ide_ssh::SshManager::new(data_dir.join("ssh-passwords.json")));
|
||||
ssh.set_configs(global_settings.effective().ssh.configs);
|
||||
Self {
|
||||
let host = Self {
|
||||
proxy_password: ide_ssh::secrets::SecretFile::new(data_dir.join("proxy-password.json")),
|
||||
remote: Arc::new(ide_remote::RemoteService::new(ssh.clone(), data_dir.join("remote"))),
|
||||
ssh,
|
||||
environment: environment::Environment::new(project_environment, data_dir.clone()),
|
||||
@@ -89,7 +93,9 @@ impl Host {
|
||||
runtime,
|
||||
projects: RwLock::default(),
|
||||
requests: Default::default(),
|
||||
}
|
||||
};
|
||||
host.apply_proxy();
|
||||
host
|
||||
}
|
||||
|
||||
/// The tree-sitter grammars (blocks only if start-up warm-up is still running).
|
||||
|
||||
@@ -6,6 +6,13 @@ 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) {
|
||||
@@ -20,6 +27,26 @@ 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,
|
||||
@@ -355,3 +382,46 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
|c| async move { debug_redefine_classes(&c.host, &c.window, c.arg("session")?).await },
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn launch(adapter: &str, request: &str, extra: serde_json::Value) -> LaunchConfig {
|
||||
LaunchConfig {
|
||||
name: "DromaraApplication".into(),
|
||||
adapter: adapter.into(),
|
||||
request: request.into(),
|
||||
program: None,
|
||||
args: vec![],
|
||||
cwd: None,
|
||||
env: Default::default(),
|
||||
extra_json: Some(extra.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn vm_args(config: &LaunchConfig) -> serde_json::Value {
|
||||
serde_json::from_str::<serde_json::Value>(config.extra_json.as_deref().unwrap()).unwrap()["vmArgs"].clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn java_debug_launches_on_windows_write_utf8() {
|
||||
let extra =
|
||||
serde_json::json!({"mainClass": "org.dromara.DromaraApplication", "vmArgs": "-Xmx1g", "debugServer": 1});
|
||||
let fixed = match_java_debug_encoding(launch("java-debug", "launch", extra.clone()), true);
|
||||
assert_eq!(
|
||||
vm_args(&fixed),
|
||||
"-Xmx1g -Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8 -Dsun.stdout.encoding=UTF-8 -Dsun.stderr.encoding=UTF-8"
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(fixed.extra_json.as_deref().unwrap()).unwrap()["mainClass"],
|
||||
"org.dromara.DromaraApplication"
|
||||
);
|
||||
// Attaching, other adapters and other platforms are left alone.
|
||||
for (adapter, request, windows) in
|
||||
[("java-debug", "attach", true), ("debugpy", "launch", true), ("java-debug", "launch", false)]
|
||||
{
|
||||
assert_eq!(vm_args(&match_java_debug_encoding(launch(adapter, request, extra.clone()), windows)), "-Xmx1g");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ pub mod java;
|
||||
pub mod lsp;
|
||||
pub mod nav;
|
||||
pub mod project;
|
||||
mod python;
|
||||
pub mod refactor;
|
||||
pub mod run;
|
||||
pub mod sdk;
|
||||
|
||||
@@ -43,10 +43,10 @@ pub fn current(host: &Host, window: &str) -> Option<ProjectInfo> {
|
||||
|
||||
/// Effective settings for the window's project, or global settings without one.
|
||||
pub fn settings(host: &Host, window: &str) -> Settings {
|
||||
match host.project(window) {
|
||||
host.with_proxy_password(match host.project(window) {
|
||||
Ok(p) => p.settings.effective(),
|
||||
Err(_) => host.global_settings.effective(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn roots(host: &Host, window: &str) -> IdeResult<ide_workspace::ProjectRoots> {
|
||||
@@ -64,7 +64,8 @@ pub struct SettingsApplied {
|
||||
|
||||
/// Sets `key` to `value` (JSON text, e.g. `14` or `"dark"`) for the window's
|
||||
/// project or globally, and applies it to the open projects: file defaults,
|
||||
/// file types, language servers, index scope and SSH configurations.
|
||||
/// file types, language servers, index scope, SSH configurations and the HTTP proxy
|
||||
/// (whose password goes to the saved passwords, not the settings file).
|
||||
pub fn set_settings(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
@@ -77,6 +78,11 @@ pub fn set_settings(
|
||||
let current = host.project(window).ok();
|
||||
// A LightEdit window has no project layer: its values are global.
|
||||
let project_scope = matches!(scope, SettingsScope::Project) && current.as_ref().is_some_and(|p| !p.light);
|
||||
let is_proxy = key == "proxy" || key.starts_with("proxy.");
|
||||
let Some(value) = host.take_proxy_password(key, value)? else {
|
||||
host.apply_proxy();
|
||||
return Ok(SettingsApplied { settings: settings(host, window), project_scope, roots_changed: None });
|
||||
};
|
||||
let settings = match ¤t {
|
||||
Some(p) if project_scope => p.settings.set(scope, key, value)?,
|
||||
Some(p) => {
|
||||
@@ -113,6 +119,9 @@ pub fn set_settings(
|
||||
if key == "ssh" || key.starts_with("ssh.") {
|
||||
host.ssh.set_configs(host.global_settings.effective().ssh.configs);
|
||||
}
|
||||
if is_proxy {
|
||||
host.apply_proxy();
|
||||
}
|
||||
// A new interpreter / toolchain / server executable restarts the language servers it affects.
|
||||
if ["languages", "tools", "languageServers"].iter().any(|k| key == *k || key.starts_with(&format!("{k}."))) {
|
||||
for project in affected() {
|
||||
@@ -132,5 +141,5 @@ pub fn set_settings(
|
||||
});
|
||||
}
|
||||
let roots_changed = current.as_ref().filter(|_| roots_changed && project_scope).map(|p| p.root.clone().into());
|
||||
Ok(SettingsApplied { settings, project_scope, roots_changed })
|
||||
Ok(SettingsApplied { settings: host.with_proxy_password(settings), project_scope, roots_changed })
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
use crate::project::Project;
|
||||
use ide_core::IdeResult;
|
||||
use ide_dap::LaunchConfig;
|
||||
use ide_workspace::roots::RootKind;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub(super) fn paths(project: &Project) -> Vec<PathBuf> {
|
||||
let mut paths = vec![project.root.clone()];
|
||||
paths.extend(project.content_roots());
|
||||
for root in project.roots().roots {
|
||||
if matches!(root.kind, RootKind::Source | RootKind::Test) {
|
||||
let path = project.root.join(root.path);
|
||||
if !paths.contains(&path) {
|
||||
paths.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
paths
|
||||
}
|
||||
|
||||
pub(super) fn prepare_debug(config: &mut LaunchConfig, paths: &[PathBuf]) -> IdeResult<()> {
|
||||
// Adapter-specific env takes precedence over the common launch fields in DAP.
|
||||
let mut extra: Option<serde_json::Value> = config.extra_json.as_deref().and_then(|s| serde_json::from_str(s).ok());
|
||||
// A custom adapter may run on another host; local paths are not portable.
|
||||
if extra.as_ref().is_some_and(|e| e.get("debugServer").is_some()) {
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(env) = extra.as_mut().and_then(|e| e.get_mut("env")) {
|
||||
if env.is_null() {
|
||||
*env = serde_json::json!({});
|
||||
}
|
||||
if !env.is_object() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut vars = BTreeMap::new();
|
||||
if let Some(value) = env.get("PYTHONPATH").and_then(|v| v.as_str()) {
|
||||
vars.insert("PYTHONPATH".into(), value.to_owned());
|
||||
}
|
||||
ide_run::python_path::extend(&mut vars, paths, env.get("PYTHONPATH").is_none())?;
|
||||
env["PYTHONPATH"] = serde_json::json!(vars["PYTHONPATH"]);
|
||||
config.extra_json = extra.map(|e| e.to_string());
|
||||
} else {
|
||||
ide_run::python_path::extend(&mut config.env, paths, true)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn debug_preserves_remote_paths_and_handles_null_env() {
|
||||
let root = std::env::temp_dir().join("project");
|
||||
let mut config = LaunchConfig {
|
||||
name: "Python".into(),
|
||||
adapter: "debugpy".into(),
|
||||
request: "launch".into(),
|
||||
program: None,
|
||||
args: vec![],
|
||||
cwd: None,
|
||||
env: BTreeMap::new(),
|
||||
extra_json: Some(serde_json::json!({"env": null, "debugServer": "remote:5678"}).to_string()),
|
||||
};
|
||||
let before = config.extra_json.clone();
|
||||
prepare_debug(&mut config, std::slice::from_ref(&root)).unwrap();
|
||||
assert_eq!(config.extra_json, before);
|
||||
assert!(config.env.is_empty());
|
||||
config.extra_json = Some(serde_json::json!({"env": null}).to_string());
|
||||
prepare_debug(&mut config, std::slice::from_ref(&root)).unwrap();
|
||||
let value: serde_json::Value = serde_json::from_str(config.extra_json.as_deref().unwrap()).unwrap();
|
||||
let paths = std::env::split_paths(value["env"]["PYTHONPATH"].as_str().unwrap()).collect::<Vec<_>>();
|
||||
assert_eq!(paths[0], root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debug_uses_the_effective_environment_field() {
|
||||
for extra in [false, true] {
|
||||
let root = std::env::temp_dir().join("project");
|
||||
let vendor = root.join("vendor");
|
||||
let vars = BTreeMap::from([("PYTHONPATH".into(), vendor.to_string_lossy().into_owned())]);
|
||||
let mut config = LaunchConfig {
|
||||
name: "Python".into(),
|
||||
adapter: "debugpy".into(),
|
||||
request: "launch".into(),
|
||||
program: Some("app/main.py".into()),
|
||||
args: vec![],
|
||||
cwd: None,
|
||||
env: vars.clone(),
|
||||
extra_json: extra.then(|| serde_json::json!({"env": vars}).to_string()),
|
||||
};
|
||||
prepare_debug(&mut config, std::slice::from_ref(&root)).unwrap();
|
||||
let vars: BTreeMap<String, String> = if extra {
|
||||
let value: serde_json::Value = serde_json::from_str(config.extra_json.as_deref().unwrap()).unwrap();
|
||||
serde_json::from_value(value["env"].clone()).unwrap()
|
||||
} else {
|
||||
config.env
|
||||
};
|
||||
assert_eq!(std::env::split_paths(&vars["PYTHONPATH"]).collect::<Vec<_>>(), [root, vendor]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,10 @@ 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()) };
|
||||
let options = StartOptions {
|
||||
encoding: Some(encoding).filter(|e| !e.trim().is_empty()),
|
||||
python_paths: super::python::paths(&project),
|
||||
};
|
||||
host.processes.start(&project.root, &config, &options, project.sink.clone(), Box::new(move |c| on_output(c)))
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,18 @@ 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(
|
||||
@@ -42,6 +54,9 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("run_templates", |c| async move { run_templates(&c.host, &c.window) });
|
||||
d.add("run_save_templates", |c| async move { run_save_templates(&c.host, &c.window, c.arg("templates")?) });
|
||||
d.add("run_system_env", |c| async move { Ok(run_system_env(&c.host, &c.window)) });
|
||||
d.add("run_gradle_jvm_args_script", |c| async move {
|
||||
run_gradle_jvm_args_script(&c.host, &c.window, c.arg("jvmArgs")?).await
|
||||
});
|
||||
d.add("run_test_reports", |c| async move {
|
||||
run_test_reports(&c.host, &c.window, c.arg("module")?, c.arg("sinceMs")?).await
|
||||
});
|
||||
|
||||
@@ -242,6 +242,33 @@ 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
|
||||
@@ -680,13 +707,17 @@ 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, source: PatchSource) -> IdeResult<String> {
|
||||
pub async fn vcs_create_patch(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
repo: Option<PathBuf>,
|
||||
source: PatchSource,
|
||||
) -> IdeResult<String> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
project.with_repo(|r| match source {
|
||||
project.with_repo_at(repo.as_deref(), |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),
|
||||
@@ -695,14 +726,24 @@ pub async fn vcs_create_patch(host: &Host, window: &str, source: PatchSource) ->
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn vcs_patch_preview(host: &Host, window: &str, patch: String) -> IdeResult<Vec<PatchFile>> {
|
||||
pub async fn vcs_patch_preview(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
repo: Option<PathBuf>,
|
||||
patch: String,
|
||||
) -> IdeResult<Vec<PatchFile>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo(|r| r.patch_preview(&patch))).await
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.patch_preview(&patch))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_apply_patch(host: &Host, window: &str, patch: String) -> IdeResult<ApplyOutcome> {
|
||||
pub async fn vcs_apply_patch(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
repo: Option<PathBuf>,
|
||||
patch: String,
|
||||
) -> IdeResult<ApplyOutcome> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo(|r| r.apply_patch(&patch))).await
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.apply_patch(&patch))).await
|
||||
}
|
||||
|
||||
/// Shelve Changes: saves the changes of `paths` as a patch on the shelf and reverts them.
|
||||
@@ -786,6 +827,9 @@ 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 });
|
||||
@@ -901,9 +945,16 @@ 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("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_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_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 });
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
//! System Settings › HTTP Proxy on the host: the global settings made current for
|
||||
//! connections and child processes, the password kept in the saved passwords
|
||||
//! (owner-only `proxy-password.json`) instead of `settings.toml`.
|
||||
|
||||
use crate::Host;
|
||||
use ide_core::IdeResult;
|
||||
use ide_core::settings::Settings;
|
||||
|
||||
const PASSWORD_KEY: &str = "httpProxy";
|
||||
|
||||
impl Host {
|
||||
/// `settings` with the saved proxy password filled in, as the Settings dialog edits it.
|
||||
pub fn with_proxy_password(&self, mut settings: Settings) -> Settings {
|
||||
// A password typed into settings.toml by hand counts too, unless one was saved.
|
||||
if let Some(password) = self.proxy_password.get(PASSWORD_KEY) {
|
||||
settings.proxy.password = password;
|
||||
}
|
||||
settings
|
||||
}
|
||||
|
||||
/// Makes the global proxy settings the proxy of every later connection and child process.
|
||||
pub fn apply_proxy(&self) {
|
||||
let settings = self.with_proxy_password(self.global_settings.effective());
|
||||
if let Some(problem) = settings.proxy.problem() {
|
||||
tracing::warn!("HTTP proxy: {problem}");
|
||||
}
|
||||
ide_core::proxy_settings::set_proxy(settings.proxy);
|
||||
}
|
||||
|
||||
/// Takes the proxy password out of a settings write of `key` and saves it.
|
||||
/// Returns the value left to write, `None` when nothing is.
|
||||
pub(crate) fn take_proxy_password(
|
||||
&self,
|
||||
key: &str,
|
||||
mut value: serde_json::Value,
|
||||
) -> IdeResult<Option<serde_json::Value>> {
|
||||
let password = match key {
|
||||
"proxy.password" => Some(value.take()),
|
||||
"proxy" => value.as_object_mut().and_then(|o| o.remove("password")),
|
||||
_ => return Ok(Some(value)),
|
||||
};
|
||||
if let Some(password) = password {
|
||||
match password.as_str().filter(|p| !p.is_empty()) {
|
||||
Some(p) => self.proxy_password.set(PASSWORD_KEY, p)?,
|
||||
None => self.proxy_password.delete(PASSWORD_KEY),
|
||||
}
|
||||
}
|
||||
Ok((key != "proxy.password").then_some(value))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
//! System Settings › HTTP Proxy: applied on write, the password kept out of settings.toml.
|
||||
|
||||
use ide_core::NullSink;
|
||||
use ide_core::proxy_settings::{ProxyMode, ProxyRoute, proxy};
|
||||
use ide_host::{Host, HostConfig, ops};
|
||||
use ide_workspace::SettingsScope;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn host(base: &std::path::Path) -> Arc<Host> {
|
||||
Arc::new(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,
|
||||
}))
|
||||
}
|
||||
|
||||
fn set(host: &Host, key: &str, value: &str) -> ide_core::settings::Settings {
|
||||
ops::project::set_settings(host, "main", SettingsScope::Global, key, value).unwrap().settings
|
||||
}
|
||||
|
||||
fn settings_files(base: &std::path::Path) -> String {
|
||||
let mut text = String::new();
|
||||
for entry in walk(&base.join("config")) {
|
||||
text.push_str(&std::fs::read_to_string(entry).unwrap_or_default());
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
fn walk(dir: &std::path::Path) -> Vec<std::path::PathBuf> {
|
||||
let mut out = Vec::new();
|
||||
for entry in std::fs::read_dir(dir).into_iter().flatten().flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() { out.extend(walk(&path)) } else { out.push(path) }
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn manual_proxy_applies_and_the_password_is_saved_apart() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let host = host(tmp.path());
|
||||
assert_eq!(proxy().mode, ProxyMode::Auto);
|
||||
|
||||
set(&host, "proxy.mode", "\"manual\"");
|
||||
set(&host, "proxy.host", "\"proxy.corp\"");
|
||||
set(&host, "proxy.port", "3128");
|
||||
set(&host, "proxy.authenticate", "true");
|
||||
set(&host, "proxy.login", "\"me\"");
|
||||
let settings = set(&host, "proxy.password", "\"s3cret\"");
|
||||
assert_eq!(settings.proxy.password, "s3cret", "the dialog sees the saved password");
|
||||
assert_eq!(
|
||||
proxy().route_for_url("https://example.com/"),
|
||||
ProxyRoute::Via("http://me:[email protected]:3128".into())
|
||||
);
|
||||
|
||||
assert!(!settings_files(tmp.path()).contains("s3cret"), "no password in settings.toml");
|
||||
let saved = std::fs::read_to_string(tmp.path().join("data/proxy-password.json")).unwrap();
|
||||
assert!(saved.contains("s3cret"));
|
||||
assert_eq!(ops::project::settings(&host, "main").proxy.password, "s3cret");
|
||||
|
||||
// A whole-table write keeps the password out of the file too; an empty one forgets it.
|
||||
let settings = set(&host, "proxy", r#"{"mode":"none","host":"proxy.corp","password":""}"#);
|
||||
assert_eq!(settings.proxy.password, "");
|
||||
assert!(!settings_files(tmp.path()).contains("password"));
|
||||
assert_eq!(proxy().route_for_url("https://example.com/"), ProxyRoute::Direct);
|
||||
|
||||
set(&host, "proxy", r#"{"mode":"auto"}"#);
|
||||
assert_eq!(proxy().mode, ProxyMode::Auto);
|
||||
}
|
||||
@@ -109,7 +109,13 @@ 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("the task to end", || events.0.lock().iter().any(|e| matches!(e, IdeEvent::Task { done: true, .. })));
|
||||
wait_for("postStartCommand to end", || {
|
||||
events
|
||||
.0
|
||||
.lock()
|
||||
.iter()
|
||||
.any(|e| matches!(e, IdeEvent::Task { title, done: true, .. } if title == "postStartCommand"))
|
||||
});
|
||||
assert!(events.0.lock().iter().any(
|
||||
|e| matches!(e, IdeEvent::Task { message: Some(m), title, .. } if m == "post-start-output" && title == "postStartCommand")
|
||||
));
|
||||
|
||||
@@ -108,6 +108,26 @@ 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:?}");
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
//! 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");
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
//! The one place the backend builds an HTTP client: TLS with the platform's
|
||||
//! certificates, timeouts, and the proxy of System Settings › HTTP Proxy.
|
||||
|
||||
use ide_core::proxy_settings::{ProxyMode, ProxyRoute, ProxySettings};
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
use parking_lot::Mutex;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
/// `builder` routed as `proxy` says: direct, the environment's proxy (reqwest's
|
||||
/// default), or the manual proxy except for the "No proxy for" hosts.
|
||||
pub fn with_proxy(builder: reqwest::ClientBuilder, proxy: &ProxySettings) -> reqwest::ClientBuilder {
|
||||
match proxy.mode {
|
||||
ProxyMode::Auto => builder,
|
||||
ProxyMode::None => builder.no_proxy(),
|
||||
ProxyMode::Manual => {
|
||||
let proxy = proxy.clone();
|
||||
// A custom proxy also turns off the environment's.
|
||||
builder.proxy(reqwest::Proxy::custom(move |url| match proxy.route(url.host_str()) {
|
||||
ProxyRoute::Via(via) => Some(via),
|
||||
ProxyRoute::Direct | ProxyRoute::System => None,
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A client for `proxy`.
|
||||
pub fn build(proxy: &ProxySettings) -> IdeResult<reqwest::Client> {
|
||||
use rustls_platform_verifier::BuilderVerifierExt;
|
||||
let fail = |e: String| IdeError::internal(format!("HTTP client: {e}"));
|
||||
let provider = Arc::new(rustls::crypto::ring::default_provider());
|
||||
let tls = rustls::ClientConfig::builder_with_provider(provider)
|
||||
.with_safe_default_protocol_versions()
|
||||
.map_err(|e| fail(e.to_string()))?
|
||||
.with_platform_verifier()
|
||||
.map_err(|e| fail(e.to_string()))?
|
||||
.with_no_client_auth();
|
||||
let builder = reqwest::Client::builder()
|
||||
.tls_backend_preconfigured(tls)
|
||||
.connect_timeout(Duration::from_secs(30))
|
||||
.user_agent(concat!("OpenIDEA-HttpClient/", env!("CARGO_PKG_VERSION")));
|
||||
with_proxy(builder, proxy).build().map_err(|e| fail(e.to_string()))
|
||||
}
|
||||
|
||||
/// The client for the current proxy settings, rebuilt when they change.
|
||||
pub fn shared() -> IdeResult<reqwest::Client> {
|
||||
static CLIENT: Mutex<Option<(Arc<ProxySettings>, reqwest::Client)>> = Mutex::new(None);
|
||||
let proxy = ide_core::proxy_settings::proxy();
|
||||
let mut cached = CLIENT.lock();
|
||||
if let Some((settings, client)) = cached.as_ref()
|
||||
&& **settings == *proxy
|
||||
{
|
||||
return Ok(client.clone());
|
||||
}
|
||||
let client = build(&proxy)?;
|
||||
*cached = Some((proxy, client.clone()));
|
||||
Ok(client)
|
||||
}
|
||||
|
||||
/// HTTP Proxy › Check connection: GETs `url` through `proxy` (the settings as
|
||||
/// edited, not yet applied). Any response but `407` counts as connected.
|
||||
pub async fn check_connection(proxy: &ProxySettings, url: &str, cancel: &CancellationToken) -> IdeResult<String> {
|
||||
if let Some(problem) = proxy.problem() {
|
||||
return Err(IdeError::invalid(problem));
|
||||
}
|
||||
let url = url.trim();
|
||||
let url = if url.contains("://") { url.to_owned() } else { format!("http://{url}") };
|
||||
let client = build(proxy)?;
|
||||
let request = client.get(&url).timeout(Duration::from_secs(30));
|
||||
let response = tokio::select! {
|
||||
r = request.send() => r.map_err(|e| IdeError::new(ide_core::error::ErrorKind::Io, describe(&e)))?,
|
||||
() = cancel.cancelled() => return Err(IdeError::cancelled()),
|
||||
};
|
||||
let status = response.status();
|
||||
if status == reqwest::StatusCode::PROXY_AUTHENTICATION_REQUIRED {
|
||||
return Err(IdeError::new(ide_core::error::ErrorKind::PermissionDenied, "Proxy authentication required (407)"));
|
||||
}
|
||||
Ok(format!("{} {}", status.as_u16(), status.canonical_reason().unwrap_or("")).trim_end().to_owned())
|
||||
}
|
||||
|
||||
/// reqwest errors with their causes ("error sending request: … connection refused").
|
||||
pub(crate) fn describe(e: &reqwest::Error) -> String {
|
||||
use std::error::Error;
|
||||
let mut msg = e.to_string();
|
||||
let mut source = e.source();
|
||||
while let Some(s) = source {
|
||||
let s_msg = s.to_string();
|
||||
if !msg.contains(&s_msg) {
|
||||
msg.push_str(": ");
|
||||
msg.push_str(&s_msg);
|
||||
}
|
||||
source = s.source();
|
||||
}
|
||||
msg
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ide_core::proxy_settings::ProxyKind;
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::net::TcpListener;
|
||||
|
||||
/// A one-shot HTTP server on loopback: answers `200` and hands back the request head.
|
||||
fn server() -> (u16, std::thread::JoinHandle<String>) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let handle = std::thread::spawn(move || {
|
||||
let (stream, _) = listener.accept().unwrap();
|
||||
let mut reader = BufReader::new(stream.try_clone().unwrap());
|
||||
let mut head = String::new();
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
if reader.read_line(&mut line).unwrap() == 0 || line == "\r\n" {
|
||||
break;
|
||||
}
|
||||
head.push_str(&line);
|
||||
}
|
||||
let mut stream = stream;
|
||||
stream.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok").unwrap();
|
||||
head
|
||||
});
|
||||
(port, handle)
|
||||
}
|
||||
|
||||
fn manual(port: u16) -> ProxySettings {
|
||||
ProxySettings { mode: ProxyMode::Manual, host: "127.0.0.1".into(), port, ..Default::default() }
|
||||
}
|
||||
|
||||
async fn get(proxy: &ProxySettings, url: &str) -> IdeResult<String> {
|
||||
check_connection(proxy, url, &CancellationToken::new()).await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn builds_for_every_mode() {
|
||||
for mode in [ProxyMode::None, ProxyMode::Auto, ProxyMode::Manual] {
|
||||
for kind in [ProxyKind::Http, ProxyKind::Socks] {
|
||||
let p = ProxySettings { mode, kind, host: "proxy".into(), port: 3128, ..Default::default() };
|
||||
assert!(build(&p).is_ok(), "{mode:?} {kind:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn manual_proxy_receives_the_request_with_credentials() {
|
||||
let (port, proxy_server) = server();
|
||||
let proxy = ProxySettings { authenticate: true, login: "me".into(), password: "s3cret".into(), ..manual(port) };
|
||||
assert_eq!(get(&proxy, "http://example.invalid/ping").await.unwrap(), "200 OK");
|
||||
let head = proxy_server.join().unwrap();
|
||||
assert!(head.starts_with("GET http://example.invalid/ping HTTP/1.1"), "{head}");
|
||||
// base64("me:s3cret")
|
||||
assert!(head.to_ascii_lowercase().contains("proxy-authorization: basic bwu6cznjcmv0"), "{head}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn manual_proxy_without_authentication_sends_no_credentials() {
|
||||
let (port, proxy_server) = server();
|
||||
let proxy = ProxySettings { login: "me".into(), password: "x".into(), ..manual(port) };
|
||||
get(&proxy, "http://example.invalid/").await.unwrap();
|
||||
assert!(!proxy_server.join().unwrap().to_ascii_lowercase().contains("proxy-authorization"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn no_proxy_hosts_and_no_proxy_mode_connect_directly() {
|
||||
// Nothing listens on the proxy port: a request through it would fail.
|
||||
let dead = TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
|
||||
let (port, target) = server();
|
||||
let listed = ProxySettings { no_proxy: "example.com, 127.0.*".into(), ..manual(dead) };
|
||||
assert!(listed.bypasses("127.0.0.1"));
|
||||
get(&listed, &format!("http://127.0.0.1:{port}/direct")).await.unwrap();
|
||||
assert!(target.join().unwrap().starts_with("GET /direct HTTP/1.1"));
|
||||
|
||||
let (port, target) = server();
|
||||
let none = ProxySettings { mode: ProxyMode::None, ..manual(dead) };
|
||||
get(&none, &format!("http://127.0.0.1:{port}/none")).await.unwrap();
|
||||
assert!(target.join().unwrap().starts_with("GET /none HTTP/1.1"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn check_reports_errors() {
|
||||
let dead = TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
|
||||
let e = get(&manual(dead), "http://example.invalid/").await.unwrap_err();
|
||||
assert!(!e.message.is_empty());
|
||||
let e = get(&manual(0), "http://example.invalid/").await.unwrap_err();
|
||||
assert!(e.message.contains("port"), "{}", e.message);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Preparing (substitution, body includes) and executing requests.
|
||||
|
||||
use crate::client::describe;
|
||||
use crate::env::{Environments, Resolver};
|
||||
use crate::parse::{BodyPart, RawRequest};
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
@@ -7,7 +8,6 @@ use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use std::time::Instant;
|
||||
|
||||
/// Bodies above this are cut (the full size is still reported).
|
||||
@@ -98,29 +98,6 @@ pub fn prepare(
|
||||
})
|
||||
}
|
||||
|
||||
fn client() -> IdeResult<reqwest::Client> {
|
||||
static CLIENT: OnceLock<Result<reqwest::Client, String>> = OnceLock::new();
|
||||
CLIENT
|
||||
.get_or_init(|| {
|
||||
use rustls_platform_verifier::BuilderVerifierExt;
|
||||
let provider = Arc::new(rustls::crypto::ring::default_provider());
|
||||
let tls = rustls::ClientConfig::builder_with_provider(provider)
|
||||
.with_safe_default_protocol_versions()
|
||||
.map_err(|e| e.to_string())?
|
||||
.with_platform_verifier()
|
||||
.map_err(|e| e.to_string())?
|
||||
.with_no_client_auth();
|
||||
reqwest::Client::builder()
|
||||
.tls_backend_preconfigured(tls)
|
||||
.connect_timeout(std::time::Duration::from_secs(30))
|
||||
.user_agent(concat!("OpenIDEA-HttpClient/", env!("CARGO_PKG_VERSION")))
|
||||
.build()
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
.clone()
|
||||
.map_err(|e| IdeError::internal(format!("HTTP client: {e}")))
|
||||
}
|
||||
|
||||
fn looks_textual(content_type: Option<&str>, bytes: &[u8]) -> bool {
|
||||
if let Some(ct) = content_type {
|
||||
let ct = ct.to_ascii_lowercase();
|
||||
@@ -148,7 +125,7 @@ fn looks_textual(content_type: Option<&str>, bytes: &[u8]) -> bool {
|
||||
|
||||
/// Sends `req`; cancelled when `cancel` fires.
|
||||
pub async fn execute(req: &PreparedRequest, cancel: &CancellationToken) -> IdeResult<HttpResponse> {
|
||||
let client = client()?;
|
||||
let client = crate::client::shared()?;
|
||||
let method = reqwest::Method::from_bytes(req.method.as_bytes())
|
||||
.map_err(|_| IdeError::invalid(format!("invalid method {}", req.method)))?;
|
||||
let mut builder = client.request(method, &req.url);
|
||||
@@ -205,22 +182,6 @@ pub async fn execute(req: &PreparedRequest, cancel: &CancellationToken) -> IdeRe
|
||||
})
|
||||
}
|
||||
|
||||
/// reqwest errors with their causes ("error sending request: … connection refused").
|
||||
fn describe(e: &reqwest::Error) -> String {
|
||||
use std::error::Error;
|
||||
let mut msg = e.to_string();
|
||||
let mut source = e.source();
|
||||
while let Some(s) = source {
|
||||
let s_msg = s.to_string();
|
||||
if !msg.contains(&s_msg) {
|
||||
msg.push_str(": ");
|
||||
msg.push_str(&s_msg);
|
||||
}
|
||||
source = s.source();
|
||||
}
|
||||
msg
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
//!
|
||||
//! [`parse`] splits a file into requests, [`env`] resolves `{{variables}}` from
|
||||
//! `http-client(.private).env.json`, [`exec`] sends a request (cancellable,
|
||||
//! timed) and [`history`] keeps the responses.
|
||||
//! timed) and [`history`] keeps the responses. [`client`] builds every HTTP client
|
||||
//! of the backend, with the proxy of System Settings › HTTP Proxy.
|
||||
|
||||
pub mod client;
|
||||
pub mod env;
|
||||
pub mod exec;
|
||||
pub mod history;
|
||||
|
||||
@@ -218,8 +218,17 @@ fn user_did_open(server: &Server, run: &Running, path: &Path, uri: &lsp::Uri, pa
|
||||
}
|
||||
}
|
||||
|
||||
fn unsupported(feature: &str) -> IdeError {
|
||||
IdeError::unavailable(format!("no running language server supports {feature}"))
|
||||
/// 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}"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Folds per-server results: any success wins over errors; errors surface only
|
||||
@@ -830,7 +839,7 @@ impl LspManager {
|
||||
let snap = self.snapshot(doc)?;
|
||||
let targets = self.ready_targets(&snap.servers, pred).await;
|
||||
if targets.is_empty() {
|
||||
return Err(unsupported(feature));
|
||||
return Err(unsupported(&snap.servers, feature));
|
||||
}
|
||||
Ok((snap, targets))
|
||||
}
|
||||
@@ -1183,7 +1192,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("workspace symbols"));
|
||||
return Err(unsupported(&servers, "workspace symbols"));
|
||||
}
|
||||
let requests = targets.iter().map(|t| {
|
||||
let cancel = &cancel;
|
||||
|
||||
@@ -520,6 +520,20 @@ 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())]);
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
//! VM options for the JVMs a Gradle build forks (`bootRun`, `run`, `test`): Gradle has no
|
||||
//! command-line switch for them, so a launch passes an init script that adds them to every
|
||||
//! `JavaExec` and `Test` task (`--init-script <file>`). Gradle's own JVM is left alone.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Groovy string literal (single-quoted, `\` and `'` escaped).
|
||||
fn groovy_string(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len() + 2);
|
||||
out.push('\'');
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'\\' => out.push_str("\\\\"),
|
||||
'\'' => out.push_str("\\'"),
|
||||
'\n' => out.push_str("\\n"),
|
||||
'\r' => out.push_str("\\r"),
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out.push('\'');
|
||||
out
|
||||
}
|
||||
|
||||
/// The init script adding `jvm_args` (one argument each) to the forked JVMs.
|
||||
pub fn init_script(jvm_args: &[String]) -> String {
|
||||
let list = jvm_args.iter().map(|a| groovy_string(a)).collect::<Vec<_>>().join(", ");
|
||||
format!(
|
||||
"// Written by OpenIDEA: VM options of the run configuration.\n\
|
||||
allprojects {{\n \
|
||||
tasks.withType(JavaExec).configureEach {{ jvmArgs([{list}]) }}\n \
|
||||
tasks.withType(Test).configureEach {{ jvmArgs([{list}]) }}\n\
|
||||
}}\n"
|
||||
)
|
||||
}
|
||||
|
||||
/// Where the scripts are written.
|
||||
pub fn default_dir() -> PathBuf {
|
||||
std::env::temp_dir().join("openidea-gradle")
|
||||
}
|
||||
|
||||
/// Writes the script for `jvm_args` under `dir` (named by its content, so launches share
|
||||
/// it and nothing needs deleting) and returns its path.
|
||||
pub fn write_init_script(dir: &Path, jvm_args: &[String]) -> std::io::Result<PathBuf> {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let text = init_script(jvm_args);
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
text.hash(&mut h);
|
||||
let file = dir.join(format!("jvm-args-{:016x}.init.gradle", h.finish()));
|
||||
if std::fs::read_to_string(&file).is_ok_and(|t| t == text) {
|
||||
return Ok(file);
|
||||
}
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let tmp = file.with_extension(format!("{}.tmp", std::process::id()));
|
||||
std::fs::write(&tmp, &text)?;
|
||||
std::fs::rename(&tmp, &file).inspect_err(|_| {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
})?;
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn args(a: &[&str]) -> Vec<String> {
|
||||
a.iter().map(|s| (*s).to_owned()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adds_each_argument_to_java_exec_and_test_tasks() {
|
||||
let script = init_script(&args(&["-Ddemo.flag=on", "--add-opens", "java.base/java.lang=ALL-UNNAMED"]));
|
||||
let list = "['-Ddemo.flag=on', '--add-opens', 'java.base/java.lang=ALL-UNNAMED']";
|
||||
assert!(script.contains(&format!("tasks.withType(JavaExec).configureEach {{ jvmArgs({list}) }}")), "{script}");
|
||||
assert!(script.contains(&format!("tasks.withType(Test).configureEach {{ jvmArgs({list}) }}")), "{script}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escapes_quotes_backslashes_and_keeps_spaces_and_dollars() {
|
||||
let script = init_script(&args(&[r"-Dpath=C:\Program Files\app", "-Dq=it's", "-Dv=${x}"]));
|
||||
assert!(script.contains(r"'-Dpath=C:\\Program Files\\app'"), "{script}");
|
||||
assert!(script.contains(r"'-Dq=it\'s'"), "{script}");
|
||||
// Single-quoted Groovy strings don't interpolate.
|
||||
assert!(script.contains("'-Dv=${x}'"), "{script}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writes_one_file_per_content() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let a = write_init_script(dir.path(), &args(&["-Xmx1g"])).unwrap();
|
||||
let again = write_init_script(dir.path(), &args(&["-Xmx1g"])).unwrap();
|
||||
let b = write_init_script(dir.path(), &args(&["-Xmx2g"])).unwrap();
|
||||
assert_eq!(a, again);
|
||||
assert_ne!(a, b);
|
||||
assert_eq!(std::fs::read_to_string(&a).unwrap(), init_script(&args(&["-Xmx1g"])));
|
||||
assert!(a.to_string_lossy().ends_with(".init.gradle"));
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,11 @@
|
||||
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;
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
//! Makes the output of JVM and Python processes match the console's encoding.
|
||||
//!
|
||||
//! On Windows both write piped output in the ANSI code page (GBK on a Chinese
|
||||
//! system: `javac` errors, Maven / Gradle logs, `print`), while the console decodes
|
||||
//! the configured encoding (UTF-8 by default). They are told which one to use:
|
||||
//! `java` through `stdout.encoding` / `stderr.encoding` (JDK 19+) and their
|
||||
//! `sun.*` forerunners (JDK 8–18), Maven and Gradle through `MAVEN_OPTS` /
|
||||
//! `GRADLE_OPTS`, Python through `PYTHONIOENCODING`.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
const JAVA_PROPERTIES: &[&str] = &["stdout.encoding", "stderr.encoding", "sun.stdout.encoding", "sun.stderr.encoding"];
|
||||
|
||||
/// What a launch needs: `-D` options to put first on a `java` command line, and
|
||||
/// environment variables to set.
|
||||
#[derive(Debug, Default, PartialEq, Eq)]
|
||||
pub struct EncodingFix {
|
||||
pub java_args: Vec<String>,
|
||||
pub env: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
fn is_utf8(encoding: &str) -> bool {
|
||||
matches!(encoding.to_ascii_lowercase().replace('_', "-").as_str(), "utf-8" | "utf8")
|
||||
}
|
||||
|
||||
fn java_options(encoding: &str) -> Vec<String> {
|
||||
JAVA_PROPERTIES.iter().map(|p| format!("-D{p}={encoding}")).collect()
|
||||
}
|
||||
|
||||
fn sets_java_encoding(text: &str) -> bool {
|
||||
JAVA_PROPERTIES.iter().any(|p| text.contains(&format!("-D{p}=")))
|
||||
}
|
||||
|
||||
/// The options for a launch of `program` (`args`, environment `vars` over the
|
||||
/// `inherited` one) whose output is decoded as `encoding`; nothing when the
|
||||
/// process already writes it (UTF-8 off Windows) or the user chose the options.
|
||||
pub fn fix(
|
||||
program: &str,
|
||||
args: &[String],
|
||||
vars: &BTreeMap<String, String>,
|
||||
inherited: impl Fn(&str) -> Option<String>,
|
||||
encoding: &str,
|
||||
windows: bool,
|
||||
) -> EncodingFix {
|
||||
let encoding = encoding.trim();
|
||||
let mut out = EncodingFix::default();
|
||||
if encoding.is_empty() || (!windows && is_utf8(encoding)) {
|
||||
return out;
|
||||
}
|
||||
let current = |key: &str| vars.get(key).cloned().or_else(|| inherited(key));
|
||||
let name = crate::wrap::program_name(program).to_ascii_lowercase();
|
||||
match name.as_str() {
|
||||
"java" | "javaw" => {
|
||||
if !args.iter().any(|a| sets_java_encoding(a))
|
||||
&& !current("JDK_JAVA_OPTIONS").is_some_and(|o| sets_java_encoding(&o))
|
||||
{
|
||||
out.java_args = java_options(encoding);
|
||||
}
|
||||
}
|
||||
"mvn" | "mvnw" | "mvnd" | "gradle" | "gradlew" => {
|
||||
let key = if name.starts_with("gradle") { "GRADLE_OPTS" } else { "MAVEN_OPTS" };
|
||||
let opts = current(key).unwrap_or_default();
|
||||
if !sets_java_encoding(&opts) {
|
||||
let added = java_options(encoding).join(" ");
|
||||
let value = if opts.trim().is_empty() { added } else { format!("{} {added}", opts.trim()) };
|
||||
out.env.push((key.to_owned(), value));
|
||||
}
|
||||
}
|
||||
_ if is_python(&name) && current("PYTHONIOENCODING").is_none_or(|v| v.trim().is_empty()) => {
|
||||
out.env.push(("PYTHONIOENCODING".to_owned(), encoding.to_owned()));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `python`, `python3`, `python3.12`, `py`, and the runners that start one.
|
||||
fn is_python(name: &str) -> bool {
|
||||
name == "python"
|
||||
|| name == "py"
|
||||
|| name.strip_prefix("python").is_some_and(|v| v.chars().all(|c| c.is_ascii_digit() || c == '.'))
|
||||
|| matches!(name, "uv" | "poetry" | "pdm" | "pytest" | "pipenv")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn run(program: &str, args: &[&str], vars: &[(&str, &str)], encoding: &str, windows: bool) -> EncodingFix {
|
||||
let args: Vec<String> = args.iter().map(|a| (*a).to_owned()).collect();
|
||||
let vars: BTreeMap<String, String> = vars.iter().map(|(k, v)| ((*k).to_owned(), (*v).to_owned())).collect();
|
||||
fix(program, &args, &vars, |_| None, encoding, windows)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn java_launches_write_the_console_encoding_on_windows() {
|
||||
let expected = [
|
||||
"-Dstdout.encoding=UTF-8",
|
||||
"-Dstderr.encoding=UTF-8",
|
||||
"-Dsun.stdout.encoding=UTF-8",
|
||||
"-Dsun.stderr.encoding=UTF-8",
|
||||
];
|
||||
// The reported case: a source-file launch through the Oracle `javapath` shim.
|
||||
let java = "C:/Program Files/Common Files/Oracle/Java/javapath/java.exe";
|
||||
for program in [java, "java", "javaw.exe", r"C:\jdk\bin\java.exe"] {
|
||||
let f = run(program, &["Main.java"], &[], "UTF-8", true);
|
||||
assert_eq!(f.java_args, expected, "{program}");
|
||||
assert!(f.env.is_empty());
|
||||
}
|
||||
assert_eq!(run("java", &["-cp", "x", "a.Main"], &[], "GBK", true).java_args[0], "-Dstdout.encoding=GBK");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_changes_where_output_already_matches() {
|
||||
for (program, args) in [("java", vec!["Main.java"]), ("mvn", vec!["package"]), ("python3", vec!["a.py"])] {
|
||||
assert_eq!(run(program, &args, &[], "UTF-8", false), EncodingFix::default(), "{program}");
|
||||
assert_eq!(run(program, &args, &[], "utf8", false), EncodingFix::default(), "{program}");
|
||||
assert_eq!(run(program, &args, &[], "", true), EncodingFix::default(), "{program}");
|
||||
}
|
||||
for program in ["node", "cargo", "go", "npm.cmd", "cmd.exe"] {
|
||||
assert_eq!(run(program, &[], &[], "UTF-8", true), EncodingFix::default(), "{program}");
|
||||
}
|
||||
// A console set to another encoding elsewhere still gets it.
|
||||
assert_eq!(run("java", &["Main"], &[], "GBK", false).java_args.len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options_chosen_by_the_user_win() {
|
||||
assert!(run("java", &["-Dstdout.encoding=GBK", "Main"], &[], "UTF-8", true).java_args.is_empty());
|
||||
assert!(
|
||||
run("java", &["Main"], &[("JDK_JAVA_OPTIONS", "-Dsun.stdout.encoding=GBK")], "UTF-8", true)
|
||||
.java_args
|
||||
.is_empty()
|
||||
);
|
||||
assert!(run("mvn.cmd", &[], &[("MAVEN_OPTS", "-Dstdout.encoding=GBK")], "UTF-8", true).env.is_empty());
|
||||
assert!(run("python.exe", &[], &[("PYTHONIOENCODING", "gbk")], "UTF-8", true).env.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_tools_and_python_get_environment_variables() {
|
||||
let opts =
|
||||
"-Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8 -Dsun.stdout.encoding=UTF-8 -Dsun.stderr.encoding=UTF-8";
|
||||
for program in ["mvn.cmd", "mvnw.cmd", r"C:\maven\bin\mvn.cmd"] {
|
||||
assert_eq!(
|
||||
run(program, &["package"], &[], "UTF-8", true).env,
|
||||
[("MAVEN_OPTS".to_owned(), opts.to_owned())]
|
||||
);
|
||||
}
|
||||
for program in ["gradlew.bat", "gradle.bat"] {
|
||||
assert_eq!(run(program, &["build"], &[], "UTF-8", true).env, [("GRADLE_OPTS".to_owned(), opts.to_owned())]);
|
||||
}
|
||||
// Existing options are kept, from the configuration or the inherited environment.
|
||||
let f = run("mvn.cmd", &[], &[("MAVEN_OPTS", "-Xmx1g")], "UTF-8", true);
|
||||
assert_eq!(f.env, [("MAVEN_OPTS".to_owned(), format!("-Xmx1g {opts}"))]);
|
||||
let inherited =
|
||||
fix("gradlew.bat", &[], &BTreeMap::new(), |k| (k == "GRADLE_OPTS").then(|| "-Xmx2g".into()), "UTF-8", true);
|
||||
assert_eq!(inherited.env, [("GRADLE_OPTS".to_owned(), format!("-Xmx2g {opts}"))]);
|
||||
for program in ["python.exe", "python3", "python3.12", "py.exe", "uv.exe", "poetry"] {
|
||||
assert_eq!(
|
||||
run(program, &[], &[], "UTF-8", true).env,
|
||||
[("PYTHONIOENCODING".to_owned(), "UTF-8".to_owned())],
|
||||
"{program}"
|
||||
);
|
||||
}
|
||||
assert_eq!(run("python3", &[], &[], "GBK", false).env, [("PYTHONIOENCODING".to_owned(), "GBK".to_owned())]);
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,8 @@ 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)]
|
||||
@@ -106,6 +108,8 @@ 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,
|
||||
}
|
||||
|
||||
@@ -141,7 +145,7 @@ pub(crate) fn invocation(config: &RunConfig, cwd: &Path) -> Invocation {
|
||||
display.push(' ');
|
||||
display.push_str("e(a));
|
||||
}
|
||||
Invocation { program, args: config.args.clone(), raw_args: false, display }
|
||||
Invocation { program, args: config.args.clone(), raw_args: false, shell: false, display }
|
||||
}
|
||||
|
||||
/// "Shorten command line" for a `java` launch too long for the platform (see
|
||||
@@ -172,22 +176,25 @@ fn shorten_java(inv: &mut Invocation, config: &RunConfig, cwd: &Path) -> IdeResu
|
||||
}
|
||||
|
||||
/// Tells JVM and Python processes to write the encoding the console decodes (see
|
||||
/// [`crate::output_encoding`]).
|
||||
fn match_console_encoding(inv: &mut Invocation, env: &mut LaunchEnv, config: &RunConfig, options: &StartOptions) {
|
||||
if inv.raw_args {
|
||||
return;
|
||||
}
|
||||
/// [`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;
|
||||
let inherit = !env.clear;
|
||||
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),
|
||||
);
|
||||
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))
|
||||
};
|
||||
env.vars.extend(fix.env);
|
||||
if !fix.java_args.is_empty() {
|
||||
if !fix.java_args.is_empty() && !inv.raw_args {
|
||||
inv.args.splice(0..0, fix.java_args);
|
||||
let mut display = quote(config.command.trim());
|
||||
for a in &inv.args {
|
||||
@@ -196,6 +203,7 @@ fn match_console_encoding(inv: &mut Invocation, env: &mut LaunchEnv, config: &Ru
|
||||
}
|
||||
inv.display = display;
|
||||
}
|
||||
fix.notice
|
||||
}
|
||||
|
||||
/// A launch's pathing jar / argfile, deleted when dropped (the process exited, or never started).
|
||||
@@ -217,6 +225,7 @@ 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(),
|
||||
};
|
||||
}
|
||||
@@ -224,6 +233,7 @@ 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(),
|
||||
}
|
||||
}
|
||||
@@ -303,6 +313,9 @@ 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)?,
|
||||
@@ -313,10 +326,9 @@ impl ProcessManager {
|
||||
cwd: cwd.clone(),
|
||||
env: env.clone(),
|
||||
});
|
||||
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);
|
||||
}
|
||||
// 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) };
|
||||
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);
|
||||
@@ -391,7 +403,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())?;
|
||||
spawn_pump(rx, on_output, entry.clone(), sink, options.encoding.clone(), notice)?;
|
||||
Ok(())
|
||||
})();
|
||||
if let Err(e) = result {
|
||||
@@ -484,7 +496,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()));
|
||||
.and_then(|_| spawn_pump(rx, on_output, entry.clone(), sink, options.encoding.clone(), None));
|
||||
if let Err(e) = result {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
@@ -807,10 +819,16 @@ 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;
|
||||
@@ -840,8 +858,18 @@ fn spawn_pump(
|
||||
flush(&mut pending, pending_stream, &mut batch_started);
|
||||
pending_stream = stream;
|
||||
}
|
||||
let dec = if stream == Stream::Stdout { &mut out_dec } else { &mut err_dec };
|
||||
dec.decode_into(&bytes, &mut pending);
|
||||
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),
|
||||
}
|
||||
if !pending.is_empty() && batch_started.is_none() {
|
||||
batch_started = Some(Instant::now());
|
||||
}
|
||||
@@ -865,8 +893,15 @@ fn spawn_pump(
|
||||
}
|
||||
}
|
||||
flush(&mut pending, pending_stream, &mut batch_started);
|
||||
for (stream, dec) in [(Stream::Stdout, &mut out_dec), (Stream::Stderr, &mut err_dec)] {
|
||||
let rest = dec.finish();
|
||||
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());
|
||||
}
|
||||
if !rest.is_empty() {
|
||||
on_output(chunk(stream, rest));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
//! Python imports see the same content/source roots as the IDE.
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub fn extend(env: &mut BTreeMap<String, String>, roots: &[PathBuf], inherit: bool) -> IdeResult<()> {
|
||||
if roots.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let inherited = if inherit { crate::system_env().remove("PYTHONPATH") } else { None };
|
||||
let existing = env.get("PYTHONPATH").or(inherited.as_ref());
|
||||
let mut paths = Vec::new();
|
||||
for root in roots {
|
||||
if !paths.contains(root) {
|
||||
paths.push(root.clone());
|
||||
}
|
||||
}
|
||||
if let Some(existing) = existing {
|
||||
for path in std::env::split_paths(existing) {
|
||||
if !paths.contains(&path) {
|
||||
paths.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
let joined =
|
||||
std::env::join_paths(paths).map_err(|e| IdeError::invalid(format!("invalid Python import path: {e}")))?;
|
||||
env.insert("PYTHONPATH".into(), joined.to_string_lossy().into_owned());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn keeps_configured_paths_without_duplicates_or_system_leaks() {
|
||||
let root = std::env::temp_dir().join("python project");
|
||||
let other = root.join("vendor");
|
||||
let value = std::env::join_paths([&other, &root]).unwrap().to_string_lossy().into_owned();
|
||||
let mut env = BTreeMap::from([("PYTHONPATH".into(), value)]);
|
||||
extend(&mut env, std::slice::from_ref(&root), false).unwrap();
|
||||
assert_eq!(std::env::split_paths(&env["PYTHONPATH"]).collect::<Vec<_>>(), [root, other]);
|
||||
}
|
||||
}
|
||||
@@ -169,3 +169,45 @@ fn extras_round_trip_and_local_file() {
|
||||
assert_eq!(load_templates(dir.path()).unwrap(), vec![template]);
|
||||
assert_eq!(load_user_configs(dir.path()).unwrap(), moved);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jvm_options_persist_in_the_project_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let vm = "-Ddemo.flag=on --add-opens java.base/java.lang=ALL-UNNAMED '-Dname=a b'";
|
||||
let args =
|
||||
["-Ddemo.flag=on", "--add-opens", "java.base/java.lang=ALL-UNNAMED", "-Dname=a b", "-cp", "${classpath}"];
|
||||
let mut app = sample().remove(0);
|
||||
app.id = "user:demo".into();
|
||||
app.kind = "spring-boot".into();
|
||||
app.command = "java".into();
|
||||
app.args =
|
||||
args.into_iter().chain(["a.DemoApplication", "--spring.profiles.active=dev"]).map(String::from).collect();
|
||||
app.extras.params = BTreeMap::from([
|
||||
("mainClass".into(), "a.DemoApplication".into()),
|
||||
("vmOptions".into(), vm.into()),
|
||||
("activeProfiles".into(), "dev".into()),
|
||||
("classpath".into(), "${classpath}".into()),
|
||||
]);
|
||||
save_user_configs(dir.path(), std::slice::from_ref(&app)).unwrap();
|
||||
let text = fs::read_to_string(user_configs_path(dir.path())).unwrap();
|
||||
assert!(text.contains(&format!("vmOptions = \"{vm}\"")), "{text}");
|
||||
assert_eq!(load_user_configs(dir.path()).unwrap(), vec![app.clone()]);
|
||||
|
||||
// A file written before the form had these fields loads with its command line only.
|
||||
fs::write(
|
||||
user_configs_path(dir.path()),
|
||||
"[[configurations]]\nid = \"user:old\"\nname = \"Old\"\nkind = \"spring-boot\"\ncommand = \"java\"\n\
|
||||
args = [\"-Xmx1g\", \"-cp\", \"${classpath}\", \"a.App\"]\n",
|
||||
)
|
||||
.unwrap();
|
||||
let old = load_user_configs(dir.path()).unwrap();
|
||||
assert_eq!(old[0].args, ["-Xmx1g", "-cp", "${classpath}", "a.App"]);
|
||||
assert!(old[0].extras.params.is_empty());
|
||||
|
||||
// Templates keep default VM options for new configurations.
|
||||
let mut template = app;
|
||||
template.id = "template:spring-boot".into();
|
||||
template.extras.params = BTreeMap::from([("vmOptions".into(), "-Xmx512m".into())]);
|
||||
ide_run::save_templates(dir.path(), std::slice::from_ref(&template)).unwrap();
|
||||
assert_eq!(ide_run::load_templates(dir.path()).unwrap()[0].extras.params["vmOptions"], "-Xmx512m");
|
||||
}
|
||||
@@ -229,6 +229,39 @@ fn builds_nested_in_a_folder_of_repositories() {
|
||||
assert_eq!(c.iter().filter(|c| c.kind == "spring-boot").count(), 1);
|
||||
}
|
||||
|
||||
/// RuoYi-AI opened from the folder above it (`ites-agent/ruoyi-ai`), or two folders above:
|
||||
/// its application class builds the `SpringApplication` itself.
|
||||
#[test]
|
||||
fn spring_boot_application_of_a_repository_nested_one_or_two_levels_down() {
|
||||
const MAIN: &str = "package org.ruoyi;\n\nimport org.springframework.boot.SpringApplication;\n\
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;\n\n\
|
||||
/**\n * 启动程序\n *\n * @author Lion Li\n */\n@SpringBootApplication\n\
|
||||
public class RuoYiAIApplication {\n\n public static void main(String[] args) {\n\
|
||||
\x20 SpringApplication application = new SpringApplication(RuoYiAIApplication.class);\n\
|
||||
\x20 application.run(args);\n\
|
||||
\x20 System.out.println(\"(♥◠‿◠)ノ゙ RuoYi-AI启动成功 ლ(´ڡ`ლ)\");\n }\n}\n";
|
||||
for prefix in ["", "ites-agent/"] {
|
||||
let file = |p: &str| format!("{prefix}ruoyi-ai/{p}");
|
||||
let files = [
|
||||
(file("pom.xml"), PARENT_POM),
|
||||
(file("ruoyi-admin/pom.xml"), BOOT_POM),
|
||||
(file("ruoyi-admin/src/main/java/org/ruoyi/RuoYiAIApplication.java"), MAIN),
|
||||
(file("ruoyi-admin/src/main/java/org/ruoyi/RuoYiAIServletInitializer.java"), "package org.ruoyi;"),
|
||||
(file("ruoyi-common/pom.xml"), "<project><packaging>pom</packaging></project>"),
|
||||
(file("ruoyi-modules/ruoyi-chat/pom.xml"), "<project/>"),
|
||||
(file("docs/README.md"), "docs"),
|
||||
];
|
||||
let files: Vec<(&str, &str)> = files.iter().map(|(p, t)| (p.as_str(), *t)).collect();
|
||||
let dir = project(&files);
|
||||
let c = detect(dir.path());
|
||||
let module = format!("{prefix}ruoyi-ai/ruoyi-admin");
|
||||
let app = find(&c, &format!("application:{module}:org.ruoyi.RuoYiAIApplication"));
|
||||
assert_eq!((app.name.as_str(), app.kind.as_str()), ("RuoYiAIApplication", "spring-boot"), "{prefix}");
|
||||
assert_eq!(app.cwd.as_deref(), Some(Path::new(&module)));
|
||||
assert!(ids(&c).contains(&format!("maven:spring-boot:run:{prefix}ruoyi-ai").as_str()), "{prefix}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_builds_of_every_kind_and_depth() {
|
||||
let dir = project(&[
|
||||
|
||||
@@ -414,3 +414,57 @@ 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");
|
||||
}
|
||||
}
|
||||
+37
-11
@@ -3,9 +3,10 @@
|
||||
|
||||
use crate::discover::{project_venv_python, workspace_tsdk};
|
||||
use crate::exec::{path_with, search_path};
|
||||
use crate::{angular, godot, vue};
|
||||
use crate::{angular, godot, pinned, 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};
|
||||
@@ -28,14 +29,22 @@ pub fn setting_path(root: &Path, s: &str) -> Option<PathBuf> {
|
||||
}
|
||||
|
||||
/// The project interpreter: the configured one, else a project virtualenv
|
||||
/// (`.venv`, `venv`, …) in `dir` or the project root.
|
||||
/// (`.venv`, `venv`, …) in the project root, else the Python the project pins for mise / asdf.
|
||||
pub fn python_interpreter(root: &Path, s: &Settings) -> Option<PathBuf> {
|
||||
setting_path(root, &s.languages.python.interpreter).or_else(|| project_venv_python(root))
|
||||
setting_path(root, &s.languages.python.interpreter)
|
||||
.or_else(|| project_venv_python(root))
|
||||
.or_else(|| pinned::pinned_python(root))
|
||||
}
|
||||
|
||||
/// The configured `node` executable's directory.
|
||||
/// The configured `node` executable's directory, else the `bin` of the Node.js the
|
||||
/// project pins for mise / asdf.
|
||||
pub(crate) fn node_dir(root: &Path, s: &Settings) -> Option<PathBuf> {
|
||||
setting_path(root, &s.languages.node.interpreter).and_then(|p| p.parent().map(Path::to_path_buf))
|
||||
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()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The configured Rust toolchain's `bin` directory and rustup toolchain name.
|
||||
@@ -54,8 +63,10 @@ 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`.
|
||||
@@ -64,11 +75,12 @@ 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`).
|
||||
/// The project JDK (`languages.java.jdk`), else the JDK the project pins for mise / asdf.
|
||||
pub fn project_jdk(root: &Path, s: &Settings) -> Option<crate::java::Jdk> {
|
||||
jdk_setting(root, &s.languages.java.jdk)?
|
||||
.inspect_err(|p| tracing::warn!("project JDK {} is not a JDK home", p.display()))
|
||||
.ok()
|
||||
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)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The JDK chosen to run jdtls (`languages.java.jdtlsJdk`): `Ok(None)` when not
|
||||
@@ -85,10 +97,18 @@ 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`.
|
||||
/// executable itself, `<home>/bin/mvn` or `<bin>/mvn`; else of the Maven the project
|
||||
/// pins for mise / asdf when it has no Maven wrapper.
|
||||
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());
|
||||
@@ -458,6 +478,7 @@ 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);
|
||||
@@ -529,6 +550,11 @@ pub fn apply_to_run_configs(configs: &mut [RunConfig], root: &Path, s: &Settings
|
||||
if let Some(jdk) = &jdk {
|
||||
c.env.entry("JAVA_HOME".into()).or_insert_with(|| jdk.home.to_string_lossy().into_owned());
|
||||
}
|
||||
if c.kind == "gradle"
|
||||
&& let Some(bin) = gradle.as_ref().filter(|b| b.is_dir())
|
||||
{
|
||||
path_dirs.push(bin.clone());
|
||||
}
|
||||
if c.kind == "maven" && c.detected {
|
||||
if let Some(mvn) = &mvn {
|
||||
c.command = mvn.to_string_lossy().into_owned();
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! Finding installed interpreters and toolchains.
|
||||
|
||||
use crate::exec::{query, search_path, which};
|
||||
use crate::{Sdk, SdkKind, bin_dir, exe, home_dir, venv_of};
|
||||
use crate::{Sdk, SdkKind, bin_dir, exe, home_dir, pinned, venv_of};
|
||||
use ide_core::version_managers::{self, Tool};
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
@@ -11,7 +12,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> {
|
||||
match kind {
|
||||
let mut out = match kind {
|
||||
SdkKind::Python => python(root),
|
||||
SdkKind::Node => node(),
|
||||
SdkKind::PackageManager => package_managers(),
|
||||
@@ -20,7 +21,32 @@ 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).
|
||||
@@ -99,6 +125,9 @@ 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()
|
||||
@@ -118,7 +147,7 @@ fn python(root: Option<&Path>) -> Vec<Sdk> {
|
||||
}
|
||||
}
|
||||
for dir in std::env::split_paths(&search_path()) {
|
||||
if dir.ends_with(".pyenv/shims") {
|
||||
if dir.ends_with(".pyenv/shims") || version_managers::is_shims_dir(&dir) {
|
||||
continue;
|
||||
}
|
||||
let mut names: Vec<String> = std::fs::read_dir(&dir)
|
||||
@@ -177,7 +206,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, "uv") => format!("Python {short} (uv)"),
|
||||
(None, tool @ ("uv" | "mise" | "asdf")) => format!("Python {short} ({tool})"),
|
||||
_ => format!("Python {short}"),
|
||||
};
|
||||
Some(Sdk {
|
||||
@@ -209,8 +238,10 @@ fn subdirs(dir: &Path) -> Vec<PathBuf> {
|
||||
|
||||
fn node() -> Vec<Sdk> {
|
||||
let mut found: Vec<(PathBuf, &'static str)> = Vec::new();
|
||||
if let Some(p) = which("node") {
|
||||
found.push((p, "path"));
|
||||
// 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(home) = home_dir() {
|
||||
for v in subdirs(&home.join(".nvm/versions/node")).into_iter().rev() {
|
||||
@@ -223,6 +254,9 @@ 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"));
|
||||
}
|
||||
@@ -323,7 +357,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
|
||||
if let Some(go) = path_go.as_ref().filter(|p| !version_managers::is_shim(p))
|
||||
&& let Some(root) = query(go, &["env", "GOROOT"], VERSION_TIMEOUT)
|
||||
{
|
||||
roots.push((PathBuf::from(root), "path"));
|
||||
@@ -341,6 +375,9 @@ 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()))
|
||||
@@ -411,7 +448,7 @@ pub fn jdk_sdk(j: &crate::java::Jdk) -> Sdk {
|
||||
} else if crate::java::is_downloaded(&j.home) {
|
||||
"downloaded"
|
||||
} else {
|
||||
"system"
|
||||
version_managers::manager_of(&j.home).unwrap_or("system")
|
||||
};
|
||||
Sdk {
|
||||
path: j.home.clone().into(),
|
||||
@@ -466,6 +503,10 @@ fn maven() -> Vec<Sdk> {
|
||||
for d in subdirs(&home.join(".sdkman/candidates/maven")) {
|
||||
found.push((d, "sdkman"));
|
||||
}
|
||||
for (d, manager) in version_managers::installs(Tool::Maven) {
|
||||
found.extend(subdirs(&d).into_iter().map(|s| (s, manager)));
|
||||
found.push((d, manager));
|
||||
}
|
||||
// Distributions the Maven wrapper downloaded: `dists/apache-maven-3.9.6-bin/<hash>/apache-maven-3.9.6`,
|
||||
// or since wrapper 3.3 the home itself, `dists/apache-maven-3.9.6/<hash>`.
|
||||
for dist in subdirs(&home.join(".m2/wrapper/dists")) {
|
||||
|
||||
@@ -67,7 +67,7 @@ pub fn is_downloaded(home: &Path) -> bool {
|
||||
}
|
||||
|
||||
/// JDK homes in the usual install locations (macOS `JavaVirtualMachines`,
|
||||
/// `/usr/lib/jvm`, SDKMAN, IDEA's `~/.jdks`, Homebrew, Windows vendor
|
||||
/// `/usr/lib/jvm`, SDKMAN, mise, asdf, 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,6 +110,10 @@ 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)
|
||||
@@ -336,6 +340,7 @@ 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) {
|
||||
@@ -529,9 +534,12 @@ mod tests {
|
||||
&bundles,
|
||||
None,
|
||||
);
|
||||
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 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");
|
||||
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" }));
|
||||
@@ -600,7 +608,8 @@ 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);
|
||||
assert_ne!(cfg.args[1], plain.args[1], "a separate workspace, so everything is rebuilt with the agent");
|
||||
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");
|
||||
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");
|
||||
|
||||
+149
-2
@@ -105,6 +105,80 @@ 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> {
|
||||
@@ -127,7 +201,6 @@ 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",
|
||||
@@ -222,7 +295,6 @@ 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",
|
||||
@@ -239,6 +311,81 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
fn keep(user: &[&str]) -> Vec<String> {
|
||||
default_vm_args(&strings(user))
|
||||
}
|
||||
|
||||
fn without(drop: &[&str]) -> Vec<String> {
|
||||
DEFAULT_VM_ARGS.iter().filter(|a| !drop.contains(a)).map(|a| a.to_string()).collect()
|
||||
}
|
||||
|
||||
const PARALLEL: &[&str] = &["-XX:+UseParallelGC", "-XX:GCTimeRatio=4", "-XX:AdaptiveSizePolicyWeight=90"];
|
||||
|
||||
#[test]
|
||||
fn user_options_override_the_defaults() {
|
||||
assert_eq!(keep(&[]), without(&[]));
|
||||
assert_eq!(keep(&["-javaagent:/opt/lombok.jar", "-Dlog.level=ALL"]), without(&[]), "unrelated options");
|
||||
let cases: &[(&[&str], &[&str])] = &[
|
||||
(&["-Xmx4G"], &["-Xmx2G"]),
|
||||
(&["-Xmx512m"], &["-Xmx2G"]),
|
||||
(&["-XX:MaxHeapSize=3g"], &["-Xmx2G"]),
|
||||
(&["-XX:MaxRAMPercentage=40"], &["-Xmx2G"]),
|
||||
(&["-Xms1G"], &["-Xms100m"]),
|
||||
(&["-XX:InitialHeapSize=256m"], &["-Xms100m"]),
|
||||
(&["-XX:+UseG1GC"], PARALLEL),
|
||||
(&["-XX:+UseZGC", "-XX:+ZGenerational"], PARALLEL),
|
||||
(&["-XX:+UseShenandoahGC"], PARALLEL),
|
||||
(&["-XX:+UseSerialGC"], PARALLEL),
|
||||
(&["-XX:-UseParallelGC"], PARALLEL),
|
||||
(&["-XX:+UseParallelGC"], PARALLEL),
|
||||
(&["-XX:GCTimeRatio=9"], &["-XX:GCTimeRatio=4"]),
|
||||
(&["-Xlog:gc*:file=gc.log"], &["-Xlog:disable"]),
|
||||
(&["-Dsun.zip.disableMemoryMapping=false"], &["-Dsun.zip.disableMemoryMapping=true"]),
|
||||
(&["-Daether.dependencyCollector.impl=df"], &["-Daether.dependencyCollector.impl=bf"]),
|
||||
];
|
||||
for (user, dropped) in cases {
|
||||
assert_eq!(keep(user), without(dropped), "{user:?}");
|
||||
}
|
||||
// Not collectors: they keep ours.
|
||||
assert_eq!(keep(&["-XX:+UseStringDeduplication", "-XX:+UseCompressedOops"]), without(&[]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_go_ahead_of_the_users_options_once() {
|
||||
let user = strings(&["--jvm-arg=-Xmx4G", "-data", "/ws", "--jvm-arg", "-XX:+UseG1GC"]);
|
||||
let mut args = user.clone();
|
||||
add_default_vm_args(&mut args);
|
||||
let dropped: Vec<&str> = PARALLEL.iter().copied().chain(["-Xmx2G"]).collect();
|
||||
let expected: Vec<String> =
|
||||
without(&dropped).into_iter().map(|a| format!("--jvm-arg={a}")).chain(user).collect();
|
||||
assert_eq!(args, expected);
|
||||
add_default_vm_args(&mut args);
|
||||
assert_eq!(args, expected, "idempotent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_command_line_with_defaults() {
|
||||
// JDK 25 on Windows, "-Xmx3G" in the settings' JVM options.
|
||||
let tools = r"C:\Users\张 三\AppData\Roaming\ai.l0ng.openidea\tools\jdtls";
|
||||
let install = JdtlsInstall {
|
||||
launcher: PathBuf::from(format!(r"{tools}\plugins\org.eclipse.equinox.launcher_1.7.0.jar")),
|
||||
config: PathBuf::from(format!(r"{tools}\config_win")),
|
||||
};
|
||||
let mut script = strings(&["-data", r"C:\Users\张 三\ws", "--jvm-arg=-Xmx3G"]);
|
||||
add_default_vm_args(&mut script);
|
||||
let args = java_args(&install, 25, &script);
|
||||
let jar = args.iter().position(|a| a == "-jar").unwrap();
|
||||
let jvm = &args[..jar];
|
||||
for flag in ["-XX:+UseParallelGC", "-XX:GCTimeRatio=4", "-Xms100m", "-Xlog:disable", "-Xmx3G"] {
|
||||
assert!(jvm.contains(&flag.to_owned()), "{flag}: {jvm:?}");
|
||||
}
|
||||
let heap: Vec<&String> = jvm.iter().filter(|a| option_key(a).as_deref() == Some("Xmx")).collect();
|
||||
assert_eq!(heap, ["-Xmx3G"], "one heap limit, the user's");
|
||||
assert!(!jvm.iter().any(|a| a == "-Xms1G"), "{jvm:?}");
|
||||
assert_eq!(args[jar + 1], format!(r"{tools}\plugins\org.eclipse.equinox.launcher_1.7.0.jar"));
|
||||
assert_eq!(args[jar + 2..], strings(&["-data", r"C:\Users\张 三\ws"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unix_command_line() {
|
||||
let install = JdtlsInstall {
|
||||
|
||||
@@ -16,6 +16,7 @@ pub mod libraries;
|
||||
pub mod locate;
|
||||
pub mod lombok;
|
||||
mod npm;
|
||||
pub mod pinned;
|
||||
pub mod python;
|
||||
mod vue;
|
||||
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
//! 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")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
//! 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,6 +1,6 @@
|
||||
//! Saved SSH passwords / key passphrases of SSH configurations, in the app's
|
||||
//! data directory rather than the OS credential store (no system prompts).
|
||||
//! One JSON object, configuration id → secret; owner-only on Unix.
|
||||
//! Saved passwords (SSH passwords / key passphrases by configuration id, the HTTP
|
||||
//! proxy's), in the app's data directory rather than the OS credential store (no
|
||||
//! system prompts). One JSON object, key → secret; owner-only on Unix.
|
||||
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use parking_lot::Mutex;
|
||||
@@ -35,14 +35,14 @@ impl SecretFile {
|
||||
if all.remove(key).is_some()
|
||||
&& let Err(e) = self.write(&all)
|
||||
{
|
||||
tracing::warn!("saved SSH passwords: {}", e.message);
|
||||
tracing::warn!("{}", e.message);
|
||||
}
|
||||
}
|
||||
|
||||
fn read(&self) -> BTreeMap<String, String> {
|
||||
match std::fs::read(&self.path) {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|e| {
|
||||
tracing::warn!("saved SSH passwords: {}: {e}", self.path.display());
|
||||
tracing::warn!("saved passwords: {}: {e}", self.path.display());
|
||||
BTreeMap::new()
|
||||
}),
|
||||
Err(_) => BTreeMap::new(),
|
||||
@@ -50,7 +50,7 @@ impl SecretFile {
|
||||
}
|
||||
|
||||
fn write(&self, all: &BTreeMap<String, String>) -> IdeResult<()> {
|
||||
let err = |e: std::io::Error| IdeError::unavailable(format!("saved SSH passwords: {e}"));
|
||||
let err = |e: std::io::Error| IdeError::unavailable(format!("saved passwords: {}: {e}", self.path.display()));
|
||||
if let Some(dir) = self.path.parent() {
|
||||
std::fs::create_dir_all(dir).map_err(err)?;
|
||||
}
|
||||
|
||||
@@ -157,8 +157,10 @@ pub fn extend_process_path(app_data: &Path) {
|
||||
}
|
||||
}
|
||||
|
||||
/// `path` (a `PATH` value) with [`tools_bin_dirs`] in front, those it lacks.
|
||||
/// `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.
|
||||
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));
|
||||
|
||||
@@ -198,6 +198,8 @@ impl Http {
|
||||
let mut cmd = Command::new(&self.curl);
|
||||
own_group(ide_core::process::prepare(&mut cmd));
|
||||
cmd.args(["-fsSL", "--retry", "2", "--connect-timeout", "20", "-A", "openidea"]);
|
||||
// System Settings › HTTP Proxy (a manual proxy comes in through `prepare`'s environment).
|
||||
cmd.args(ide_core::proxy_settings::proxy().curl_args(url));
|
||||
if self.https_only {
|
||||
cmd.args(["--proto", "=https", "--proto-redir", "=https"]);
|
||||
}
|
||||
|
||||
@@ -8,11 +8,13 @@ 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"
|
||||
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
//! Literal ignore rules for Project view's Git › Add to .gitignore.
|
||||
use crate::{Repo, cli};
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
use std::fs;
|
||||
use std::io::{Read, Write};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum IgnoreDestination {
|
||||
Gitignore,
|
||||
LocalExclude,
|
||||
}
|
||||
|
||||
struct IgnoreRule {
|
||||
pattern: String,
|
||||
relative: PathBuf,
|
||||
directory: bool,
|
||||
}
|
||||
|
||||
/// Validate the whole selection before writing. Each destination is replaced atomically;
|
||||
/// a failure in a later repository may leave earlier destinations updated (safe to retry).
|
||||
/// Never modifies the index or a selected file.
|
||||
pub fn add_to_ignore(paths: &[PathBuf], destination: IgnoreDestination) -> IdeResult<Vec<PathBuf>> {
|
||||
let mut edits: BTreeMap<PathBuf, Vec<IgnoreRule>> = BTreeMap::new();
|
||||
let mut verify = Vec::new();
|
||||
for path in paths {
|
||||
// Validate before filesystem access: APFS cannot even stat invalid UTF-8 names.
|
||||
path.to_str().ok_or_else(|| IdeError::invalid("Path is not UTF-8"))?;
|
||||
if !path.is_absolute() || path.components().any(|c| matches!(c, Component::ParentDir)) {
|
||||
return Err(IdeError::invalid("Select an absolute path without '..'"));
|
||||
}
|
||||
no_symlinks(path)?;
|
||||
let meta = fs::symlink_metadata(path)?;
|
||||
if !meta.is_file() && !meta.is_dir() {
|
||||
return Err(IdeError::invalid("Only regular files and directories can be ignored"));
|
||||
}
|
||||
// A selected nested repository directory is an entry of its enclosing repository.
|
||||
let start = path.parent().ok_or_else(|| IdeError::invalid("Cannot ignore a filesystem root"))?;
|
||||
let repo = Repo::discover(start)?.ok_or_else(|| IdeError::invalid("Selection is not in a Git repository"))?;
|
||||
let rel =
|
||||
path.strip_prefix(repo.root()).map_err(|_| IdeError::invalid("Selection is outside its repository"))?;
|
||||
let literal = pattern(rel, meta.is_dir())?;
|
||||
if rel.components().any(|c| c.as_os_str() == ".git") {
|
||||
return Err(IdeError::invalid("Git administration files cannot be ignored"));
|
||||
}
|
||||
let rel_str = rel.to_str().ok_or_else(|| IdeError::invalid("Path is not UTF-8"))?;
|
||||
let tracked = cli::run(repo.root(), &["--literal-pathspecs", "ls-files", "-z", "--", rel_str], None)?;
|
||||
if !tracked.stdout.is_empty() {
|
||||
return Err(IdeError::invalid(format!(
|
||||
"{} contains tracked files. Ignore rules only affect unversioned files; no files were untracked.",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
// check-ignore rejects --literal-pathspecs; a ./ prefix prevents pathspec
|
||||
// magic in valid filenames such as :(top)private from being interpreted.
|
||||
let literal_query = format!("./{rel_str}");
|
||||
let ignored = cli::run_raw(repo.root(), &["check-ignore", "-q", "--", &literal_query], None)?;
|
||||
match ignored.code {
|
||||
Some(0) => continue,
|
||||
Some(1) => {}
|
||||
_ => return Err(IdeError::invalid(ignored.stdout_str() + &String::from_utf8_lossy(&ignored.stderr))),
|
||||
}
|
||||
verify.push((repo.root().to_path_buf(), rel_str.to_owned()));
|
||||
let (file, rule, base) = match destination {
|
||||
IgnoreDestination::LocalExclude => {
|
||||
let output = cli::run(
|
||||
repo.root(),
|
||||
&["rev-parse", "--path-format=absolute", "--git-path", "info/exclude"],
|
||||
None,
|
||||
)?;
|
||||
// Strip only Git's output terminator; spaces are valid in the path.
|
||||
let raw = output.stdout_str();
|
||||
let file = crate::repo::absolute(Path::new(
|
||||
raw.strip_suffix("\r\n").or_else(|| raw.strip_suffix('\n')).unwrap_or(&raw),
|
||||
))?;
|
||||
let common = crate::repo::absolute(repo.gix()?.common_dir())?;
|
||||
if file != common.join("info/exclude") {
|
||||
return Err(IdeError::invalid("Git returned an unexpected exclude path"));
|
||||
}
|
||||
(file, literal, repo.root())
|
||||
}
|
||||
IgnoreDestination::Gitignore => {
|
||||
let base = start
|
||||
.ancestors()
|
||||
.take_while(|p| p.starts_with(repo.root()))
|
||||
.find(|p| p.join(".gitignore").symlink_metadata().is_ok())
|
||||
.unwrap_or(repo.root());
|
||||
(base.join(".gitignore"), pattern(path.strip_prefix(base).unwrap(), meta.is_dir())?, base)
|
||||
}
|
||||
};
|
||||
no_symlinks(file.parent().unwrap())?;
|
||||
validate_file(&file)?;
|
||||
edits.entry(file).or_default().push(IgnoreRule {
|
||||
pattern: rule,
|
||||
relative: path.strip_prefix(base).unwrap().to_path_buf(),
|
||||
directory: meta.is_dir(),
|
||||
});
|
||||
}
|
||||
let mut changed = Vec::new();
|
||||
for (file, rules) in edits {
|
||||
append(&file, &rules).map_err(|e| {
|
||||
IdeError::new(
|
||||
e.kind,
|
||||
format!(
|
||||
"Could not update {}: {}. Earlier destinations may have succeeded; refresh and retry.",
|
||||
file.display(),
|
||||
e.message
|
||||
),
|
||||
)
|
||||
})?;
|
||||
changed.push(file);
|
||||
}
|
||||
for (root, rel) in verify {
|
||||
if cli::run_raw(&root, &["check-ignore", "-q", "--", &format!("./{rel}")], None)?.code != Some(0) {
|
||||
return Err(IdeError::invalid(format!(
|
||||
"Rule saved, but {rel} is still visible to Git. A higher-priority .gitignore rule may override it; edit that file and retry."
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
fn pattern(path: &Path, directory: bool) -> IdeResult<String> {
|
||||
let mut rule = String::from("/");
|
||||
for (i, part) in path.components().enumerate() {
|
||||
let Component::Normal(part) = part else { return Err(IdeError::invalid("Unsafe ignore path")) };
|
||||
let part = part.to_str().ok_or_else(|| IdeError::invalid("Path is not UTF-8"))?;
|
||||
if part.contains(['\n', '\r', '\0']) {
|
||||
return Err(IdeError::invalid(
|
||||
"Paths containing a newline or NUL cannot be represented safely in an ignore file",
|
||||
));
|
||||
}
|
||||
if i > 0 {
|
||||
rule.push('/');
|
||||
}
|
||||
for ch in part.chars() {
|
||||
if matches!(ch, '\\' | '*' | '?' | '[' | ']' | '#' | '!' | ' ') {
|
||||
rule.push('\\');
|
||||
}
|
||||
rule.push(ch);
|
||||
}
|
||||
}
|
||||
if rule == "/" {
|
||||
return Err(IdeError::invalid("Cannot ignore a repository root"));
|
||||
}
|
||||
if directory {
|
||||
rule.push('/');
|
||||
}
|
||||
Ok(rule)
|
||||
}
|
||||
|
||||
/// Refuse symlinked ancestors too: writing through them could affect another repository.
|
||||
fn no_symlinks(path: &Path) -> IdeResult<()> {
|
||||
for part in path.ancestors() {
|
||||
match fs::symlink_metadata(part) {
|
||||
Ok(m) if m.file_type().is_symlink() => {
|
||||
return Err(IdeError::invalid(format!("Refusing symlink path: {}", part.display())));
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_file(file: &Path) -> IdeResult<()> {
|
||||
match fs::symlink_metadata(file) {
|
||||
Ok(m) if !m.is_file() || m.file_type().is_symlink() => {
|
||||
Err(IdeError::invalid("Ignore destination must be a regular file, not a symlink"))
|
||||
}
|
||||
Ok(m) if m.permissions().readonly() => {
|
||||
Err(IdeError::invalid(format!("{} is read-only; make it writable and retry", file.display())))
|
||||
}
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq)]
|
||||
struct Snapshot {
|
||||
bytes: Vec<u8>,
|
||||
modified: Option<std::time::SystemTime>,
|
||||
permissions: fs::Permissions,
|
||||
}
|
||||
|
||||
fn snapshot(file: &Path) -> IdeResult<Option<Snapshot>> {
|
||||
validate_file(file)?;
|
||||
let mut options = fs::OpenOptions::new();
|
||||
options.read(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
options.custom_flags(libc::O_NOFOLLOW);
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::fs::OpenOptionsExt;
|
||||
// Open the reparse point itself rather than following a last-component link.
|
||||
options.custom_flags(0x0020_0000); // FILE_FLAG_OPEN_REPARSE_POINT
|
||||
}
|
||||
match options.open(file) {
|
||||
Ok(mut handle) => {
|
||||
let metadata = handle.metadata()?;
|
||||
if !metadata.is_file() || metadata.file_type().is_symlink() {
|
||||
return Err(IdeError::invalid("Ignore destination must be a regular file"));
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
handle.read_to_end(&mut bytes)?;
|
||||
Ok(Some(Snapshot { bytes, modified: metadata.modified().ok(), permissions: metadata.permissions() }))
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn append(file: &Path, rules: &[IgnoreRule]) -> IdeResult<()> {
|
||||
no_symlinks(file.parent().unwrap())?;
|
||||
fs::create_dir_all(file.parent().unwrap())?;
|
||||
// Conventional sibling lock: create_new never follows a preexisting symlink.
|
||||
let lock_path = file.with_file_name(format!("{}.lock", file.file_name().unwrap().to_string_lossy()));
|
||||
let lock = fs::OpenOptions::new().write(true).create_new(true).open(&lock_path);
|
||||
// Keep lock cleanup scoped to successfully acquired locks only.
|
||||
let _lock = Lock {
|
||||
file: lock.map_err(|_| {
|
||||
IdeError::invalid(format!("{} is locked or not writable; close other writers and retry", file.display()))
|
||||
})?,
|
||||
path: lock_path,
|
||||
};
|
||||
let before = snapshot(file)?;
|
||||
let bytes = before.as_ref().map(|s| s.bytes.as_slice()).unwrap_or_default();
|
||||
let text =
|
||||
std::str::from_utf8(bytes).map_err(|_| IdeError::invalid("Ignore file is not UTF-8; edit it manually"))?;
|
||||
if text.contains('\0') {
|
||||
return Err(IdeError::invalid("Ignore file contains NUL; edit it manually"));
|
||||
}
|
||||
let newline = if text.contains("\r\n") { "\r\n" } else { "\n" };
|
||||
// Identical text earlier in the file may have been overridden by a later
|
||||
// negation. Deduplicate only rules that this file still applies to the path.
|
||||
let mut builder = ignore::gitignore::GitignoreBuilder::new("");
|
||||
for (index, line) in text.lines().enumerate() {
|
||||
let line = if index == 0 { line.trim_start_matches('\u{feff}') } else { line };
|
||||
if let Err(error) = builder.add_line(None, line) {
|
||||
tracing::debug!("{}: {error}", file.display());
|
||||
}
|
||||
}
|
||||
let existing = builder.build().map_err(|e| IdeError::invalid(format!("Cannot parse ignore rules: {e}")))?;
|
||||
let mut requested = HashSet::new();
|
||||
let mut updated = bytes.to_vec();
|
||||
for rule in rules {
|
||||
if !requested.insert(rule.pattern.as_str())
|
||||
|| existing.matched_path_or_any_parents(&rule.relative, rule.directory).is_ignore()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if !updated.is_empty() && !updated.ends_with(b"\n") {
|
||||
updated.extend_from_slice(newline.as_bytes());
|
||||
}
|
||||
updated.extend_from_slice(rule.pattern.as_bytes());
|
||||
updated.extend_from_slice(newline.as_bytes());
|
||||
}
|
||||
if updated == bytes {
|
||||
return Ok(());
|
||||
}
|
||||
let mut temp = tempfile::NamedTempFile::new_in(file.parent().unwrap())?;
|
||||
let permissions = match fs::metadata(file) {
|
||||
Ok(meta) => meta.permissions(),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => _lock.file.metadata()?.permissions(),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
temp.as_file().set_permissions(permissions)?;
|
||||
temp.write_all(&updated)?;
|
||||
temp.as_file().sync_all()?;
|
||||
replace_if_unchanged(file, before, temp)
|
||||
}
|
||||
|
||||
fn replace_if_unchanged(file: &Path, before: Option<Snapshot>, temp: tempfile::NamedTempFile) -> IdeResult<()> {
|
||||
no_symlinks(file.parent().unwrap())?;
|
||||
if snapshot(file)? != before {
|
||||
return Err(IdeError::invalid("Ignore file changed while preparing the update; retry"));
|
||||
}
|
||||
temp.persist(file).map_err(|e| IdeError::from(e.error))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct Lock {
|
||||
file: fs::File,
|
||||
path: PathBuf,
|
||||
}
|
||||
impl Drop for Lock {
|
||||
fn drop(&mut self) {
|
||||
let _ = &self.file;
|
||||
let _ = fs::remove_file(&self.path);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn concurrent_writer_is_detected_without_overwriting_it() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let file = root.join(".gitignore");
|
||||
fs::write(&file, "before\n").unwrap();
|
||||
let before = snapshot(&file).unwrap();
|
||||
let mut temp = tempfile::NamedTempFile::new_in(&root).unwrap();
|
||||
temp.write_all(b"before\n/new\n").unwrap();
|
||||
fs::write(&file, "external writer\n").unwrap();
|
||||
assert!(replace_if_unchanged(&file, before, temp).unwrap_err().message.contains("changed"));
|
||||
assert_eq!(fs::read_to_string(file).unwrap(), "external writer\n");
|
||||
}
|
||||
}
|
||||
@@ -441,6 +441,7 @@ pub mod changelists;
|
||||
mod cli;
|
||||
mod diff;
|
||||
mod history;
|
||||
mod ignore;
|
||||
mod manage;
|
||||
mod ops;
|
||||
mod patch;
|
||||
@@ -454,6 +455,7 @@ mod write;
|
||||
|
||||
pub use cli::{detected_git_executable, git_version, set_askpass_env, set_git_executable};
|
||||
pub use diff::build_partial_patch;
|
||||
pub use ignore::{IgnoreDestination, add_to_ignore};
|
||||
pub use manage::{MergeOptions, RebaseOptions, RemoteEdit, Worktree};
|
||||
pub use remotes::Remote;
|
||||
pub use roots::Repos;
|
||||
|
||||
@@ -50,7 +50,7 @@ pub(crate) fn discover(path: &Path) -> IdeResult<Option<Repo>> {
|
||||
Ok(Some(Repo { root, git_dir, inner: std::sync::Mutex::new(cache), ignored: Default::default() }))
|
||||
}
|
||||
|
||||
fn absolute(p: &Path) -> IdeResult<PathBuf> {
|
||||
pub(crate) 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();
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
//! Runtime tests against real temporary repositories and the Git executable.
|
||||
use ide_vcs::{IgnoreDestination::*, add_to_ignore};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
fn git(root: &Path, args: &[&str]) -> String {
|
||||
let output = Command::new("git")
|
||||
.current_dir(root)
|
||||
.args(args)
|
||||
.env_remove("GIT_DIR")
|
||||
.env_remove("GIT_WORK_TREE")
|
||||
.env_remove("GIT_INDEX_FILE")
|
||||
.env("GIT_CONFIG_NOSYSTEM", "1")
|
||||
.env("GIT_CONFIG_GLOBAL", if cfg!(windows) { "NUL" } else { "/dev/null" })
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(output.status.success(), "{args:?}: {}", String::from_utf8_lossy(&output.stderr));
|
||||
String::from_utf8(output.stdout).unwrap()
|
||||
}
|
||||
struct Fixture {
|
||||
_tmp: tempfile::TempDir,
|
||||
root: PathBuf,
|
||||
}
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap().join("repo");
|
||||
std::fs::create_dir(&root).unwrap();
|
||||
git(&root, &["init", "-q"]);
|
||||
git(&root, &["config", "core.excludesFile", if cfg!(windows) { "NUL" } else { "/dev/null" }]);
|
||||
Self { _tmp: tmp, root }
|
||||
}
|
||||
fn file(&self, name: &str) -> PathBuf {
|
||||
let path = self.root.join(name);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&path, "data\n").unwrap();
|
||||
path
|
||||
}
|
||||
fn ignored(&self, rel: &str) {
|
||||
assert!(!git(&self.root, &["check-ignore", "--", &format!("./{rel}")]).is_empty());
|
||||
assert!(
|
||||
git(&self.root, &["--literal-pathspecs", "status", "--porcelain", "--untracked-files=all", "--", rel])
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_literals_multiselect_and_directory_are_anchored() {
|
||||
let fx = Fixture::new();
|
||||
let mut names = vec!["has space", "#hash", "!bang", "[ab].txt", "-dash", "中文.txt"];
|
||||
// Win32 filenames cannot contain wildcard characters or end in a space.
|
||||
if cfg!(unix) {
|
||||
names.extend(["has space ", "[ab]*?.txt", ":(top)literal", ":!literal", ":(glob)*"]);
|
||||
}
|
||||
let mut paths: Vec<_> = names.iter().map(|n| fx.file(n)).collect();
|
||||
fx.file("literal");
|
||||
fx.file("cache/a.txt");
|
||||
fx.file("other/cache/a.txt");
|
||||
paths.push(fx.root.join("cache"));
|
||||
for name in &names {
|
||||
fx.file(&format!("other/{name}"));
|
||||
}
|
||||
add_to_ignore(&paths, Gitignore).unwrap();
|
||||
for name in &names {
|
||||
fx.ignored(name);
|
||||
}
|
||||
fx.ignored("cache/a.txt");
|
||||
let status = git(&fx.root, &["status", "--porcelain", "--untracked-files=all"]);
|
||||
assert!(status.contains("other/cache/a.txt"));
|
||||
assert!(status.contains("other/!bang"));
|
||||
assert!(status.lines().any(|line| line == "?? literal"));
|
||||
let text = std::fs::read_to_string(fx.root.join(".gitignore")).unwrap();
|
||||
assert!(text.contains("/cache/\n"));
|
||||
assert!(text.contains("/\\[ab\\].txt\n"));
|
||||
if cfg!(unix) {
|
||||
assert!(text.contains("/\\[ab\\]\\*\\?.txt\n"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_exclude_is_local_preserves_comments_crlf_and_deduplicates() {
|
||||
let fx = Fixture::new();
|
||||
let path = fx.file("private.txt");
|
||||
let exclude = fx.root.join(".git/info/exclude");
|
||||
std::fs::write(&exclude, "# keep this\r\n/other\r\n# no final newline").unwrap();
|
||||
add_to_ignore(&[path.clone(), path.clone()], LocalExclude).unwrap();
|
||||
let once = std::fs::read(&exclude).unwrap();
|
||||
assert_eq!(
|
||||
String::from_utf8(once.clone()).unwrap(),
|
||||
"# keep this\r\n/other\r\n# no final newline\r\n/private.txt\r\n"
|
||||
);
|
||||
add_to_ignore(&[path], LocalExclude).unwrap();
|
||||
assert_eq!(std::fs::read(&exclude).unwrap(), once);
|
||||
fx.ignored("private.txt");
|
||||
assert!(git(&fx.root, &["status", "--porcelain"]).is_empty());
|
||||
assert!(!fx.root.join(".gitignore").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nearest_existing_ignore_and_nested_repositories_use_correct_bases() {
|
||||
let fx = Fixture::new();
|
||||
let a = fx.file("pkg/deep/a.txt");
|
||||
std::fs::write(fx.root.join("pkg/.gitignore"), "# nested\n").unwrap();
|
||||
add_to_ignore(&[a], Gitignore).unwrap();
|
||||
fx.ignored("pkg/deep/a.txt");
|
||||
assert_eq!(std::fs::read_to_string(fx.root.join("pkg/.gitignore")).unwrap(), "# nested\n/deep/a.txt\n");
|
||||
assert!(!fx.root.join(".gitignore").exists());
|
||||
let b = fx.file("nested/b.txt");
|
||||
git(&fx.root.join("nested"), &["init", "-q"]);
|
||||
add_to_ignore(&[b], Gitignore).unwrap();
|
||||
assert_eq!(std::fs::read_to_string(fx.root.join("nested/.gitignore")).unwrap(), "/b.txt\n");
|
||||
assert!(!fx.root.join(".gitignore").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracked_files_and_directories_fail_before_any_write() {
|
||||
let fx = Fixture::new();
|
||||
let tracked = fx.file("dir/tracked");
|
||||
let new = fx.file("new");
|
||||
git(&fx.root, &["add", "dir/tracked"]);
|
||||
for path in [tracked, fx.root.join("dir")] {
|
||||
let error = add_to_ignore(&[new.clone(), path], Gitignore).unwrap_err();
|
||||
assert!(error.message.contains("tracked files"));
|
||||
assert!(!fx.root.join(".gitignore").exists());
|
||||
}
|
||||
assert!(git(&fx.root, &["diff", "--cached", "--name-only"]).contains("dir/tracked"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worktree_and_submodule_git_files_resolve_common_exclude() {
|
||||
let fx = Fixture::new();
|
||||
fx.file("initial");
|
||||
git(&fx.root, &["add", "initial"]);
|
||||
git(
|
||||
&fx.root,
|
||||
&[
|
||||
"-c",
|
||||
"user.name=Test",
|
||||
"-c",
|
||||
"[email protected]",
|
||||
"-c",
|
||||
"commit.gpgsign=false",
|
||||
"commit",
|
||||
"-qm",
|
||||
"initial",
|
||||
],
|
||||
);
|
||||
let worktree = fx.root.parent().unwrap().join("linked");
|
||||
git(&fx.root, &["worktree", "add", "-qb", "linked", worktree.to_str().unwrap()]);
|
||||
let p = worktree.join("private");
|
||||
std::fs::write(&p, "x").unwrap();
|
||||
add_to_ignore(&[p], LocalExclude).unwrap();
|
||||
assert!(std::fs::read_to_string(fx.root.join(".git/info/exclude")).unwrap().contains("/private\n"));
|
||||
assert_eq!(git(&worktree, &["check-ignore", "private"]), "private\n");
|
||||
assert!(git(&worktree, &["status", "--porcelain"]).is_empty());
|
||||
// A real gitfile layout like a submodule, with metadata outside its worktree.
|
||||
let sub = fx.root.join("sub");
|
||||
std::fs::create_dir(&sub).unwrap();
|
||||
let metadata = fx.root.join(".git/modules/sub");
|
||||
std::fs::create_dir_all(metadata.parent().unwrap()).unwrap();
|
||||
git(&sub, &["init", "-q", "--separate-git-dir", metadata.to_str().unwrap()]);
|
||||
std::fs::write(sub.join("tracked"), "x").unwrap();
|
||||
git(&sub, &["add", "tracked"]);
|
||||
git(
|
||||
&sub,
|
||||
&[
|
||||
"-c",
|
||||
"user.name=Test",
|
||||
"-c",
|
||||
"[email protected]",
|
||||
"-c",
|
||||
"commit.gpgsign=false",
|
||||
"commit",
|
||||
"-qm",
|
||||
"submodule initial",
|
||||
],
|
||||
);
|
||||
let commit = git(&sub, &["rev-parse", "HEAD"]);
|
||||
std::fs::write(
|
||||
fx.root.join(".gitmodules"),
|
||||
"[submodule \"sub\"]\n\tpath = sub\n\turl = https://example.invalid/sub.git\n",
|
||||
)
|
||||
.unwrap();
|
||||
git(&fx.root, &["update-index", "--add", "--cacheinfo", "160000", commit.trim(), "sub"]);
|
||||
assert!(add_to_ignore(std::slice::from_ref(&sub), LocalExclude).unwrap_err().message.contains("tracked files"));
|
||||
std::fs::write(sub.join("private"), "x").unwrap();
|
||||
add_to_ignore(&[sub.join("private")], LocalExclude).unwrap();
|
||||
assert!(std::fs::read_to_string(metadata.join("info/exclude")).unwrap().contains("/private\n"));
|
||||
assert_eq!(git(&sub, &["check-ignore", "private"]), "private\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lock_failure_invalid_destination_and_traversal_preserve_data() {
|
||||
let fx = Fixture::new();
|
||||
let path = fx.file("new");
|
||||
let file = fx.root.join(".gitignore");
|
||||
std::fs::write(&file, "# original\n").unwrap();
|
||||
std::fs::write(fx.root.join(".gitignore.lock"), "another writer").unwrap();
|
||||
assert!(add_to_ignore(std::slice::from_ref(&path), Gitignore).unwrap_err().message.contains("locked"));
|
||||
assert_eq!(std::fs::read_to_string(&file).unwrap(), "# original\n");
|
||||
assert_eq!(std::fs::read_to_string(fx.root.join(".gitignore.lock")).unwrap(), "another writer");
|
||||
assert!(add_to_ignore(&[fx.root.join("dir/../new")], Gitignore).is_err());
|
||||
std::fs::remove_file(fx.root.join(".gitignore.lock")).unwrap();
|
||||
std::fs::write(&file, b"# invalid\xff").unwrap();
|
||||
assert!(add_to_ignore(&[path], Gitignore).is_err());
|
||||
assert_eq!(std::fs::read(file).unwrap(), b"# invalid\xff");
|
||||
assert!(!fx.root.join(".gitignore.lock").exists());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn backslash_is_literal_newlines_and_symlinks_are_rejected() {
|
||||
use std::os::unix::fs::symlink;
|
||||
let fx = Fixture::new();
|
||||
let backslash = fx.file("back\\slash");
|
||||
add_to_ignore(&[backslash], Gitignore).unwrap();
|
||||
fx.ignored("back\\slash");
|
||||
let original = std::fs::read(fx.root.join(".gitignore")).unwrap();
|
||||
for name in ["line\nname", "line\rname"] {
|
||||
let path = fx.file(name);
|
||||
assert!(add_to_ignore(&[path], Gitignore).is_err());
|
||||
}
|
||||
let outside = fx.root.parent().unwrap().join("outside");
|
||||
std::fs::write(&outside, "safe").unwrap();
|
||||
std::fs::remove_file(fx.root.join(".gitignore")).unwrap();
|
||||
symlink(&outside, fx.root.join(".gitignore")).unwrap();
|
||||
assert!(add_to_ignore(&[fx.file("new")], Gitignore).is_err());
|
||||
assert_eq!(std::fs::read_to_string(&outside).unwrap(), "safe");
|
||||
std::fs::remove_file(fx.root.join(".gitignore")).unwrap();
|
||||
std::fs::write(fx.root.join(".gitignore"), &original).unwrap();
|
||||
symlink(fx.root.parent().unwrap(), fx.root.join("link")).unwrap();
|
||||
assert!(add_to_ignore(&[fx.root.join("link/outside")], LocalExclude).is_err());
|
||||
assert_eq!(std::fs::read(fx.root.join(".gitignore")).unwrap(), original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overridden_local_rule_is_not_duplicated_and_explains_precedence() {
|
||||
let fx = Fixture::new();
|
||||
let path = fx.file("private");
|
||||
std::fs::write(fx.root.join(".gitignore"), "!/private\n").unwrap();
|
||||
let error = add_to_ignore(std::slice::from_ref(&path), LocalExclude).unwrap_err();
|
||||
assert!(error.message.contains("higher-priority"));
|
||||
let exclude = fx.root.join(".git/info/exclude");
|
||||
let once = std::fs::read(&exclude).unwrap();
|
||||
assert!(add_to_ignore(&[path], LocalExclude).is_err());
|
||||
assert_eq!(std::fs::read(exclude).unwrap(), once);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readonly_and_nonfile_destinations_remain_untouched() {
|
||||
let fx = Fixture::new();
|
||||
let path = fx.file("new");
|
||||
let file = fx.root.join(".gitignore");
|
||||
std::fs::create_dir(&file).unwrap();
|
||||
assert!(add_to_ignore(std::slice::from_ref(&path), Gitignore).is_err());
|
||||
std::fs::remove_dir(&file).unwrap();
|
||||
std::fs::write(&file, "# original\n").unwrap();
|
||||
let original = std::fs::metadata(&file).unwrap().permissions();
|
||||
let mut permissions = original.clone();
|
||||
permissions.set_readonly(true);
|
||||
std::fs::set_permissions(&file, permissions).unwrap();
|
||||
let result = add_to_ignore(&[path], Gitignore);
|
||||
std::fs::set_permissions(&file, original).unwrap();
|
||||
assert!(result.unwrap_err().message.contains("read-only"));
|
||||
assert_eq!(std::fs::read_to_string(file).unwrap(), "# original\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_bytes_are_preserved_for_newline_and_bom_variants() {
|
||||
for original in [b"# comment".as_slice(), b"unrelated\\", b"\xef\xbb\xbf# BOM\r\n", b"# LF\n", b""] {
|
||||
let fx = Fixture::new();
|
||||
let path = fx.file("new");
|
||||
let file = fx.root.join(".gitignore");
|
||||
std::fs::write(&file, original).unwrap();
|
||||
add_to_ignore(&[path], Gitignore).unwrap();
|
||||
assert!(std::fs::read(file).unwrap().starts_with(original));
|
||||
fx.ignored("new");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn non_utf8_paths_are_rejected_without_creating_ignore_files() {
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
let fx = Fixture::new();
|
||||
let path = fx.root.join(std::ffi::OsString::from_vec(b"invalid\xff".to_vec()));
|
||||
// APFS rejects creation of this filename; the request must fail clearly before I/O.
|
||||
assert!(add_to_ignore(&[path], Gitignore).unwrap_err().message.contains("UTF-8"));
|
||||
assert!(!fx.root.join(".gitignore").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn later_negations_allow_reignoring_shared_and_local_paths_without_batch_duplicates() {
|
||||
for destination in [Gitignore, LocalExclude] {
|
||||
let fx = Fixture::new();
|
||||
let path = fx.file("private");
|
||||
let file = match destination {
|
||||
Gitignore => fx.root.join(".gitignore"),
|
||||
LocalExclude => fx.root.join(".git/info/exclude"),
|
||||
};
|
||||
std::fs::write(&file, "/private\n!/private\n").unwrap();
|
||||
assert!(git(&fx.root, &["status", "--porcelain", "--untracked-files=all"]).contains("private"));
|
||||
add_to_ignore(&[path.clone(), path.clone()], destination).unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&file).unwrap(), "/private\n!/private\n/private\n");
|
||||
fx.ignored("private");
|
||||
let once = std::fs::read(&file).unwrap();
|
||||
add_to_ignore(&[path], destination).unwrap();
|
||||
assert_eq!(std::fs::read(file).unwrap(), once);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
dunce.workspace = true
|
||||
gix = { workspace = true, features = ["sha1"] }
|
||||
ide-core.workspace = true
|
||||
serde.workspace = true
|
||||
specta.workspace = true
|
||||
|
||||
@@ -80,20 +80,24 @@ 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() }
|
||||
Self { repo_root, repo_level, cache: Mutex::default(), nested: 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 = root.join(".git").join("info").join("exclude");
|
||||
let exclude = gix::discover(root)
|
||||
.ok()
|
||||
.map(|r| r.common_dir().join("info/exclude"))
|
||||
.unwrap_or_else(|| root.join(".git/info/exclude"));
|
||||
if exclude.is_file() {
|
||||
builder.add(exclude);
|
||||
}
|
||||
@@ -115,11 +119,13 @@ 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) {
|
||||
@@ -133,6 +139,7 @@ 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);
|
||||
}
|
||||
|
||||
@@ -169,6 +176,23 @@ impl GitignoreStack {
|
||||
|
||||
/// Whether `path` (absolute) is ignored. `is_dir` refers to `path` itself.
|
||||
pub fn is_ignored(&self, path: &Path, is_dir: bool) -> bool {
|
||||
// A project's watcher can span independent nested repositories. Their local
|
||||
// excludes and ignore ancestry must stop at their own repository boundary.
|
||||
if let Some(parent) = path.parent() {
|
||||
let nested = self
|
||||
.nested
|
||||
.lock()
|
||||
.entry(parent.to_path_buf())
|
||||
.or_insert_with(|| {
|
||||
find_repo_root(parent)
|
||||
.filter(|root| self.repo_root.as_ref() != Some(root))
|
||||
.map(|_| Arc::new(Self::new(parent)))
|
||||
})
|
||||
.clone();
|
||||
if let Some(nested) = nested {
|
||||
return nested.is_ignored(path, is_dir);
|
||||
}
|
||||
}
|
||||
if self.repo_root.as_deref() == Some(path) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ type OnChange = Box<dyn Fn(&FsBatch) + Send + Sync>;
|
||||
pub struct Watcher {
|
||||
inner: Inner,
|
||||
excludes: SharedExcludes,
|
||||
rules: Arc<GitignoreStack>,
|
||||
}
|
||||
|
||||
type SharedExcludes = Arc<RwLock<ExcludeMatcher>>;
|
||||
@@ -74,6 +75,7 @@ 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(),
|
||||
@@ -87,7 +89,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 })
|
||||
Ok(Self { inner, excludes: shared, rules })
|
||||
}
|
||||
|
||||
/// Replaces the exclude globs (Settings › Project Structure / File Types
|
||||
@@ -97,6 +99,12 @@ 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 {
|
||||
@@ -374,13 +382,13 @@ fn merge(prev: Option<Change>, next: Change) -> Change {
|
||||
pub(crate) struct EventFilter {
|
||||
root: PathBuf,
|
||||
excludes: SharedExcludes,
|
||||
gitignore: GitignoreStack,
|
||||
gitignore: Arc<GitignoreStack>,
|
||||
}
|
||||
|
||||
impl EventFilter {
|
||||
pub fn new(root: PathBuf, excludes: &[String]) -> Self {
|
||||
Self {
|
||||
gitignore: GitignoreStack::new(&root),
|
||||
gitignore: Arc::new(GitignoreStack::new(&root)),
|
||||
excludes: Arc::new(RwLock::new(ExcludeMatcher::new(excludes))),
|
||||
root,
|
||||
}
|
||||
|
||||
@@ -321,3 +321,47 @@ fn watcher_reports_changes() {
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
assert_eq!(batches.lock().len(), n, "no events after drop");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worktree_and_nested_repository_local_excludes_are_reflected_in_listings() {
|
||||
fn git(root: &std::path::Path, args: &[&str]) {
|
||||
let out = std::process::Command::new("git").current_dir(root).args(args).output().unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
}
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap().join("repo");
|
||||
std::fs::create_dir(&root).unwrap();
|
||||
git(&root, &["init", "-q"]);
|
||||
std::fs::write(root.join("initial"), "x").unwrap();
|
||||
git(&root, &["add", "initial"]);
|
||||
git(
|
||||
&root,
|
||||
&[
|
||||
"-c",
|
||||
"user.name=Test",
|
||||
"-c",
|
||||
"[email protected]",
|
||||
"-c",
|
||||
"commit.gpgsign=false",
|
||||
"commit",
|
||||
"-qm",
|
||||
"initial",
|
||||
],
|
||||
);
|
||||
let linked = root.parent().unwrap().join("linked");
|
||||
git(&root, &["worktree", "add", "-qb", "linked", linked.to_str().unwrap()]);
|
||||
std::fs::write(linked.join("private"), "x").unwrap();
|
||||
std::fs::write(root.join(".git/info/exclude"), "/private\n").unwrap();
|
||||
let entries = ide_vfs::read_dir(&linked, &[]).unwrap();
|
||||
assert!(entries.iter().find(|e| e.name == "private").unwrap().ignored);
|
||||
assert!(!entries.iter().find(|e| e.name == "private").unwrap().excluded);
|
||||
let nested = linked.join("nested");
|
||||
std::fs::create_dir(&nested).unwrap();
|
||||
git(&nested, &["init", "-q"]);
|
||||
std::fs::write(nested.join("private"), "x").unwrap();
|
||||
let entries = ide_vfs::read_dir(&nested, &[]).unwrap();
|
||||
assert!(!entries.iter().find(|e| e.name == "private").unwrap().ignored);
|
||||
std::fs::write(nested.join(".git/info/exclude"), "/private\n").unwrap();
|
||||
let entries = ide_vfs::read_dir(&nested, &[]).unwrap();
|
||||
assert!(entries.iter().find(|e| e.name == "private").unwrap().ignored);
|
||||
}
|
||||
@@ -295,7 +295,7 @@ a token.
|
||||
|
||||
<!-- BEGIN GENERATED TOKEN REFERENCE: pnpm gen:theme-docs -->
|
||||
|
||||
219 themeable tokens: 113 in the UI theme file, 106 in the editor colour scheme file.
|
||||
222 themeable tokens: 116 in the UI theme file, 106 in the editor colour scheme file.
|
||||
|
||||
### Surfaces (UI theme)
|
||||
|
||||
@@ -374,6 +374,9 @@ 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)
|
||||
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
/**
|
||||
* 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
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* 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"));
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/** Linux browser acceptance: synthetic MemoryFs + mocked native IPC, real menu/action/refresh UI. */
|
||||
import { expect, test } from "./fixtures";
|
||||
|
||||
for (const destination of ["gitignore", "localExclude"] as const) {
|
||||
test(`ignore ${destination}: menu dispatch and immediate tree/status refresh`, async ({ workbench: page }) => {
|
||||
await page.evaluate(async (destination) => {
|
||||
const fsModule = "/src/features/project-tree/fs.ts";
|
||||
const storeModule = "/src/features/project-tree/store.ts";
|
||||
const ipcModule = "/src/platform/ipc.ts";
|
||||
const { projectFs } = await import(fsModule);
|
||||
const { reloadAll } = await import(storeModule);
|
||||
const { commands } = await import(ipcModule);
|
||||
const root = "/dev/acme-api";
|
||||
const path = `${root}/scratch.log`;
|
||||
const fs = projectFs(root);
|
||||
await fs.createFile(path);
|
||||
let written = false;
|
||||
commands.vcsAddToIgnore = async (paths: string[], actual: string) => {
|
||||
if (actual !== destination || paths.length !== 1 || paths[0] !== path)
|
||||
throw new Error("Wrong ignore destination or paths");
|
||||
written = true;
|
||||
fs.ignored.add(path);
|
||||
return { status: "ok", data: null };
|
||||
};
|
||||
commands.vcsStatus = async () => {
|
||||
document.body.dataset.ignoreStatusRefresh = written ? "after-write" : "before-write";
|
||||
return {
|
||||
status: "ok",
|
||||
data: written
|
||||
? []
|
||||
: [{ path, relPath: "scratch.log", staged: null, unstaged: "untracked", renamedFrom: null }],
|
||||
};
|
||||
};
|
||||
commands.vcsRoots = async () => ({
|
||||
status: "ok",
|
||||
data: [{ root, operation: null, head: "main", headCommit: null }],
|
||||
});
|
||||
commands.vcsBranchInfo = async () => ({
|
||||
status: "ok",
|
||||
data: { head: "main", headCommit: null, upstream: null, ahead: 0, behind: 0, operation: null },
|
||||
});
|
||||
const vcsModule = "/src/features/vcs/store.ts";
|
||||
const { useVcs } = await import(vcsModule);
|
||||
useVcs.setState({ available: true, root, repo: root, branchesLoaded: false, stashesLoaded: false });
|
||||
await reloadAll();
|
||||
const banner = document.createElement("div");
|
||||
banner.textContent = "LINUX BROWSER E2E · SYNTHETIC FIXTURE · NATIVE IPC MOCKED";
|
||||
Object.assign(banner.style, {
|
||||
position: "fixed",
|
||||
bottom: "28px",
|
||||
right: "18px",
|
||||
zIndex: "999999",
|
||||
padding: "10px 14px",
|
||||
background: "#fff4ce",
|
||||
color: "#362a00",
|
||||
font: "bold 12px sans-serif",
|
||||
border: "1px solid #9e8500",
|
||||
borderRadius: "5px",
|
||||
});
|
||||
document.body.append(banner);
|
||||
}, destination);
|
||||
await page.getByRole("treeitem", { name: "scratch.log", exact: true }).click({ button: "right" });
|
||||
await page.getByRole("menuitem", { name: "Git", exact: true }).hover();
|
||||
await page.getByRole("menuitem", { name: "Add to .gitignore", exact: true }).hover();
|
||||
await expect(page.getByRole("menuitem", { name: ".gitignore", exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("menuitem", { name: ".git/info/exclude", exact: true })).toBeVisible();
|
||||
const captureDir = process.env.OPENIDEA_ACCEPTANCE_DIR;
|
||||
if (captureDir && destination === "gitignore")
|
||||
await page.screenshot({ path: `${captureDir}/01-ignore-destinations.png` });
|
||||
await page
|
||||
.getByRole("menuitem", { name: destination === "gitignore" ? ".gitignore" : ".git/info/exclude", exact: true })
|
||||
.click();
|
||||
await expect
|
||||
.poll(async () =>
|
||||
page.evaluate(async () => {
|
||||
const module = "/src/features/project-tree/store.ts";
|
||||
const { useProjectTree } = await import(module);
|
||||
return useProjectTree
|
||||
.getState()
|
||||
.listings.get("/dev/acme-api")
|
||||
?.find((entry: { name: string }) => entry.name === "scratch.log")?.ignored;
|
||||
}),
|
||||
)
|
||||
.toBe(true);
|
||||
await expect
|
||||
.poll(async () =>
|
||||
page.evaluate(async () => {
|
||||
const module = "/src/features/vcs/store.ts";
|
||||
const { useVcs } = await import(module);
|
||||
return useVcs.getState().status.length;
|
||||
}),
|
||||
)
|
||||
.toBe(0);
|
||||
await expect(page.locator("body")).toHaveAttribute("data-ignore-status-refresh", "after-write");
|
||||
// Display fixture rule text in the actual editor. This is browser memory, not a native disk read.
|
||||
await page.evaluate(async (destination) => {
|
||||
const module = "/src/workbench/contributions.ts";
|
||||
const { editorService } = await import(module);
|
||||
await editorService().openFile(
|
||||
`/dev/acme-api/${destination === "gitignore" ? ".gitignore" : ".git/info/exclude"}`,
|
||||
{ focus: true },
|
||||
);
|
||||
}, destination);
|
||||
const content = page.locator(".cm-editor .cm-content").first();
|
||||
await content.fill(
|
||||
`# Synthetic acceptance fixture: ${destination === "gitignore" ? "shared repository rules" : ".git/info/exclude: local rules (common to linked worktrees)"}\n/scratch.log\n`,
|
||||
);
|
||||
if (captureDir)
|
||||
await page.screenshot({
|
||||
path: `${captureDir}/${destination === "gitignore" ? "02-shared-ignore-effect" : "03-local-exclude-effect"}.png`,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -35,3 +35,16 @@ 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");
|
||||
}
|
||||
});
|
||||
+11
-1
@@ -2,7 +2,7 @@
|
||||
"name": "openidea",
|
||||
"private": true,
|
||||
"description": "An open-source IDE inspired by IntelliJ IDEA",
|
||||
"version": "0.1.23",
|
||||
"version": "0.1.24",
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -24,6 +24,14 @@
|
||||
"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",
|
||||
@@ -71,8 +79,10 @@
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"jsdom": "^30.1.3",
|
||||
"lint-staged": "^17.6.0",
|
||||
"oxfmt": "^0.74.0",
|
||||
"oxlint": "^1.89.0",
|
||||
"simple-git-hooks": "^2.14.0",
|
||||
"tsrs": "^0.20.0-ts7.1.0-beta",
|
||||
"typescript": "~6.0.3",
|
||||
"vite": "^8.0.16",
|
||||
|
||||
Generated
+54
-14
@@ -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])))
|
||||
version: 7.0.1(@testing-library/[email protected])([email protected](@types/[email protected])([email protected])([email protected](@types/[email protected])([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,16 +137,22 @@ importers:
|
||||
version: 19.3.0(@types/[email protected])
|
||||
'@vitejs/plugin-react':
|
||||
specifier: ^6.0.2
|
||||
version: 6.1.2([email protected](@types/[email protected]))
|
||||
version: 6.1.2([email protected](@types/[email protected])([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
|
||||
@@ -155,10 +161,10 @@ importers:
|
||||
version: 6.0.3
|
||||
vite:
|
||||
specifier: ^8.0.16
|
||||
version: 8.3.4(@types/[email protected])
|
||||
version: 8.3.4(@types/[email protected])([email protected])
|
||||
vitest:
|
||||
specifier: ^5.0.5
|
||||
version: 5.0.5(@types/[email protected])([email protected])([email protected](@types/[email protected]))
|
||||
version: 5.0.5(@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected]))
|
||||
|
||||
packages:
|
||||
|
||||
@@ -1581,6 +1587,11 @@ 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==}
|
||||
|
||||
@@ -1748,6 +1759,10 @@ 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'}
|
||||
@@ -1755,6 +1770,10 @@ 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'}
|
||||
@@ -1936,6 +1955,11 @@ 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'}
|
||||
@@ -2698,7 +2722,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])))':
|
||||
'@testing-library/[email protected](@testing-library/[email protected])([email protected](@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])))':
|
||||
dependencies:
|
||||
'@adobe/css-tools': 4.5.0
|
||||
'@testing-library/dom': 10.4.2
|
||||
@@ -2708,7 +2732,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]))
|
||||
vitest: 5.0.5(@types/[email protected])([email protected])([email protected](@types/[email protected])([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:
|
||||
@@ -2899,19 +2923,19 @@ snapshots:
|
||||
d3-selection: 3.0.0
|
||||
d3-transition: 3.0.1([email protected])
|
||||
|
||||
'@vitejs/[email protected]([email protected](@types/[email protected]))':
|
||||
'@vitejs/[email protected]([email protected](@types/[email protected])([email protected]))':
|
||||
dependencies:
|
||||
'@rolldown/pluginutils': 1.0.1
|
||||
vite: 8.3.4(@types/[email protected])
|
||||
vite: 8.3.4(@types/[email protected])([email protected])
|
||||
|
||||
'@vitest/[email protected]([email protected](@types/[email protected]))':
|
||||
'@vitest/[email protected]([email protected](@types/[email protected])([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])
|
||||
vite: 8.3.4(@types/[email protected])([email protected])
|
||||
|
||||
'@vitest/[email protected]': {}
|
||||
|
||||
@@ -3331,6 +3355,14 @@ 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]: {}
|
||||
@@ -3525,10 +3557,14 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
min-indent: 1.0.1
|
||||
@@ -3582,7 +3618,7 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected](@types/[email protected]):
|
||||
[email protected](@types/[email protected])([email protected]):
|
||||
dependencies:
|
||||
lightningcss: 1.33.0
|
||||
picomatch: 4.0.7
|
||||
@@ -3592,11 +3628,12 @@ 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](@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])):
|
||||
dependencies:
|
||||
'@types/chai': 5.2.3
|
||||
'@vitest/mocker': 5.0.5([email protected](@types/[email protected]))
|
||||
'@vitest/mocker': 5.0.5([email protected](@types/[email protected])([email protected]))
|
||||
chai: 6.3.0
|
||||
es-module-lexer: 2.3.2
|
||||
expect-type: 1.4.0
|
||||
@@ -3607,7 +3644,7 @@ snapshots:
|
||||
tinybench: 6.2.2
|
||||
tinyexec: 1.3.1
|
||||
tinyglobby: 0.2.17
|
||||
vite: 8.3.4(@types/[email protected])
|
||||
vite: 8.3.4(@types/[email protected])([email protected])
|
||||
why-is-node-running: 3.2.1
|
||||
optionalDependencies:
|
||||
'@types/node': 26.6.5
|
||||
@@ -3639,6 +3676,9 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
optional: true
|
||||
|
||||
[email protected](@types/[email protected])([email protected])([email protected]):
|
||||
optionalDependencies:
|
||||
'@types/react': 19.3.0
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
allowBuilds:
|
||||
simple-git-hooks: true
|
||||
@@ -111,6 +111,9 @@ export const ICLS_ATTRIBUTES: Record<string, Array<[Part, string]>> = {
|
||||
["FOREGROUND", "--fg-editor"],
|
||||
["BACKGROUND", "--bg-editor"],
|
||||
],
|
||||
DIFF_MODIFIED: [["BACKGROUND", "--diff-modified"]],
|
||||
DIFF_INSERTED: [["BACKGROUND", "--diff-inserted"]],
|
||||
DIFF_DELETED: [["BACKGROUND", "--diff-deleted"]],
|
||||
DEFAULT_KEYWORD: [["FOREGROUND", "--syn-keyword"]],
|
||||
DEFAULT_STRING: [["FOREGROUND", "--syn-string"]],
|
||||
DEFAULT_NUMBER: [["FOREGROUND", "--syn-number"]],
|
||||
|
||||
@@ -36,6 +36,7 @@ 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
|
||||
|
||||
@@ -128,7 +128,7 @@ pub fn settings_get(window: Window, state: State<'_, AppState>) -> Settings {
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn settings_get_global(state: State<'_, AppState>) -> Settings {
|
||||
state.global_settings.effective()
|
||||
state.host.with_proxy_password(state.global_settings.effective())
|
||||
}
|
||||
|
||||
/// `value` is JSON text, e.g. `14` or `"dark"`. Every window (for a project
|
||||
@@ -196,11 +196,9 @@ pub async fn settings_import(window: Window, state: State<'_, AppState>, path: P
|
||||
tauri::async_runtime::spawn(async move { lsp.reconfigure().await });
|
||||
}
|
||||
crate::commands::ssh::apply_ssh_settings(&state);
|
||||
state.host.apply_proxy();
|
||||
state.sink.emit(ide_core::IdeEvent::SettingsChanged { key: String::new() });
|
||||
let settings = match state.project(window.label()) {
|
||||
Ok(p) => p.settings.effective(),
|
||||
Err(_) => state.global_settings.effective(),
|
||||
};
|
||||
let settings = ide_host::ops::project::settings(&state.host, window.label());
|
||||
Ok(SettingsImport { settings, storage })
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
//! 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()))
|
||||
}
|
||||
@@ -76,3 +76,11 @@ pub fn http_history_get(window: Window, state: State<'_, AppState>, id: String)
|
||||
pub fn http_history_clear(window: Window, state: State<'_, AppState>) -> IdeResult<()> {
|
||||
state.project(window.label())?.http_history.clear()
|
||||
}
|
||||
|
||||
/// System Settings › HTTP Proxy › Check connection: GETs `url` through `proxy` as
|
||||
/// edited (not yet applied); the response status, or why there was none.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn proxy_check_connection(proxy: ide_core::settings::ProxySettings, url: String) -> IdeResult<String> {
|
||||
ide_http::client::check_connection(&proxy, &url, &ide_core::CancellationToken::new()).await
|
||||
}
|
||||
@@ -20,6 +20,7 @@ pub mod deploy;
|
||||
pub mod doc;
|
||||
pub mod docker;
|
||||
pub mod env;
|
||||
pub mod fonts;
|
||||
pub mod fs;
|
||||
pub mod github;
|
||||
pub mod history;
|
||||
@@ -109,6 +110,7 @@ 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,
|
||||
@@ -199,6 +201,7 @@ 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,
|
||||
@@ -266,6 +269,7 @@ 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,
|
||||
@@ -394,6 +398,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
http::http_history,
|
||||
http::http_history_get,
|
||||
http::http_history_clear,
|
||||
http::proxy_check_connection,
|
||||
db::db_sources,
|
||||
db::db_save_sources,
|
||||
db::db_set_password,
|
||||
|
||||
@@ -89,6 +89,17 @@ pub fn run_system_env(state: State<'_, AppState>) -> std::collections::BTreeMap<
|
||||
ide_host::ops::run::run_system_env(&state.host, "")
|
||||
}
|
||||
|
||||
/// Writes the Gradle init script adding `jvm_args` to the JVMs the build forks; returns its path.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn run_gradle_jvm_args_script(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
jvm_args: Vec<String>,
|
||||
) -> IdeResult<String> {
|
||||
ide_host::ops::run::run_gradle_jvm_args_script(&state.host, window.label(), jvm_args).await
|
||||
}
|
||||
|
||||
/// Results from the JUnit XML reports (Surefire, Failsafe, Gradle) of the module at
|
||||
/// `module` (relative to the project root) written since `since_ms` (Unix epoch, ms).
|
||||
#[tauri::command]
|
||||
|
||||
@@ -108,6 +108,17 @@ 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<()> {
|
||||
@@ -520,20 +531,35 @@ pub async fn vcs_diff_revisions(
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
/// Shelve Changes: saves the changes of `paths` as a patch on the shelf and reverts them.
|
||||
|
||||
@@ -295,7 +295,8 @@ fn updater(app: &AppHandle) -> IdeResult<Option<tauri_plugin_updater::Updater>>
|
||||
return Ok(None);
|
||||
}
|
||||
let env = update_endpoint_env();
|
||||
let mut builder = app.updater_builder();
|
||||
let proxy = ide_core::proxy_settings::proxy();
|
||||
let mut builder = app.updater_builder().configure_client(move |b| ide_http::client::with_proxy(b, &proxy));
|
||||
if let Some(url) = env {
|
||||
let url = url.parse().map_err(|e| IdeError::invalid(format!("OPENIDEA_UPDATE_ENDPOINT: {e}")))?;
|
||||
builder = builder.endpoints(vec![url]).map_err(|e| IdeError::invalid(e.to_string()))?;
|
||||
|
||||
@@ -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 native child window `settings-<parent>` (one per project
|
||||
//! Settings open in a separate 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
|
||||
/// native child window centred over its parent, sized like IDEA's Settings.
|
||||
/// separate window centred over its parent, sized like IDEA's Settings.
|
||||
pub fn open_settings_window(
|
||||
app: &AppHandle,
|
||||
parent: &WebviewWindow,
|
||||
@@ -699,13 +699,17 @@ pub fn open_settings_window(
|
||||
config.maximized = false;
|
||||
config.fullscreen = false;
|
||||
config.drag_drop_enabled = false;
|
||||
// macOS shows a child window as soon as it is attached, before the page loads: paint the theme's frame colour.
|
||||
// Paint the theme's frame colour before the page loads.
|
||||
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 window = WebviewWindowBuilder::from_config(app, &config)
|
||||
.and_then(|b| b.parent(&parent))
|
||||
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
|
||||
.and_then(|b| b.build())
|
||||
.map_err(|e| IdeError::internal(format!("cannot create settings window: {e}")))?;
|
||||
apply_zoom(app, &window);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "OpenIDEA",
|
||||
"version": "0.1.23",
|
||||
"version": "0.1.24",
|
||||
"identifier": "ai.l0ng.openidea",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
|
||||
+69
-3
@@ -152,6 +152,11 @@ 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).
|
||||
@@ -399,6 +404,7 @@ 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 })),
|
||||
@@ -469,9 +475,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: (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 })),
|
||||
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 })),
|
||||
/** 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")),
|
||||
@@ -515,6 +521,8 @@ 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).
|
||||
@@ -850,6 +858,11 @@ export const commands = {
|
||||
httpHistory: () => typedError<HttpHistoryEntry[], IdeError>(__TAURI_INVOKE("http_history")),
|
||||
httpHistoryGet: (id: string) => typedError<HttpExchange, IdeError>(__TAURI_INVOKE("http_history_get", { id })),
|
||||
httpHistoryClear: () => typedError<null, IdeError>(__TAURI_INVOKE("http_history_clear")),
|
||||
/**
|
||||
* System Settings › HTTP Proxy › Check connection: GETs `url` through `proxy` as
|
||||
* edited (not yet applied); the response status, or why there was none.
|
||||
*/
|
||||
proxyCheckConnection: (proxy: ProxySettings, url: string) => typedError<string, IdeError>(__TAURI_INVOKE("proxy_check_connection", { proxy, url })),
|
||||
dbSources: () => typedError<DataSource[], IdeError>(__TAURI_INVOKE("db_sources")),
|
||||
dbSaveSources: (sources: DataSource[]) => typedError<null, IdeError>(__TAURI_INVOKE("db_save_sources", { sources })),
|
||||
/**
|
||||
@@ -1174,6 +1187,15 @@ 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`. */
|
||||
@@ -1233,6 +1255,11 @@ 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 = {
|
||||
@@ -2958,6 +2985,15 @@ 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,
|
||||
@@ -3303,6 +3339,8 @@ export type IdentifierOccurrence = {
|
||||
qualified: boolean,
|
||||
};
|
||||
|
||||
export type IgnoreDestination = "gitignore" | "localExclude";
|
||||
|
||||
export type IndentConfig = IndentConfig_Serialize | IndentConfig_Deserialize;
|
||||
|
||||
export type IndentConfig_Deserialize = {
|
||||
@@ -4511,6 +4549,30 @@ export type ProjectStructureSettings = {
|
||||
excludeFiles?: string,
|
||||
};
|
||||
|
||||
export type ProxyKind = "http" | "socks";
|
||||
|
||||
export type ProxyMode =
|
||||
/** Every connection goes direct. */
|
||||
"none" |
|
||||
/** The system's proxy: `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` of the environment. */
|
||||
"auto" |
|
||||
/** The proxy configured here. */
|
||||
"manual";
|
||||
|
||||
export type ProxySettings = {
|
||||
mode?: ProxyMode,
|
||||
kind?: ProxyKind,
|
||||
host?: string,
|
||||
port?: number,
|
||||
/** Hosts reached directly, comma-separated; `*` matches any characters (`*.example.com`, `192.168.*`). */
|
||||
noProxy?: string,
|
||||
/** Send `login` / `password` to the proxy. */
|
||||
authenticate?: boolean,
|
||||
login?: string,
|
||||
/** Kept in the saved passwords rather than `settings.toml`; filled in when settings are read. */
|
||||
password?: string,
|
||||
};
|
||||
|
||||
export type PruneTarget = "containers" |
|
||||
/** Dangling images only. */
|
||||
"images" |
|
||||
@@ -5139,6 +5201,8 @@ export type Settings_Deserialize = {
|
||||
*/
|
||||
customKeymaps?: { [key in string]: CustomKeymap },
|
||||
notifications?: NotificationSettings,
|
||||
/** System Settings › HTTP Proxy (global only). */
|
||||
proxy?: ProxySettings,
|
||||
/** Interpreters / toolchains per language (Python, Node.js, Rust, Go, TypeScript). */
|
||||
languages?: LanguagesSettings,
|
||||
/** Project Structure: source / test / resource / excluded folders (project-relative). */
|
||||
@@ -5192,6 +5256,8 @@ export type Settings_Serialize = {
|
||||
*/
|
||||
customKeymaps: { [key in string]: CustomKeymap },
|
||||
notifications: NotificationSettings,
|
||||
/** System Settings › HTTP Proxy (global only). */
|
||||
proxy: ProxySettings,
|
||||
/** Interpreters / toolchains per language (Python, Node.js, Rust, Go, TypeScript). */
|
||||
languages: LanguagesSettings,
|
||||
/** Project Structure: source / test / resource / excluded folders (project-relative). */
|
||||
|
||||
@@ -102,6 +102,7 @@ const EXT_LANG: Record<string, string> = {
|
||||
kts: "kotlin",
|
||||
java: "java",
|
||||
py: "python",
|
||||
gd: "gdscript",
|
||||
go: "go",
|
||||
json: "json",
|
||||
md: "markdown",
|
||||
@@ -182,6 +183,15 @@ 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,16 +1,19 @@
|
||||
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";
|
||||
@@ -42,7 +45,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), indentUnit.of(" "), extensions],
|
||||
extensions: [EditorState.allowMultipleSelections.of(true), extensions, indentUnit.of(" ")],
|
||||
}),
|
||||
parent: document.body,
|
||||
});
|
||||
@@ -259,3 +262,79 @@ describe("enter in a line comment", async () => {
|
||||
expect(enterInLineComment(make("f(|x)", ext))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fallback indentation for GDScript", () => {
|
||||
const gd = [
|
||||
EditorState.tabSize.of(4),
|
||||
indentUnit.of("\t"),
|
||||
commentTokens({
|
||||
id: "gdscript",
|
||||
name: "GDScript",
|
||||
extensions: ["gd"],
|
||||
uiMode: null,
|
||||
lineComment: "#",
|
||||
blockComment: null,
|
||||
indent: { tabSize: 4, insertSpaces: false },
|
||||
}),
|
||||
];
|
||||
|
||||
it("indents with a tab after a function header ending in a colon", () => {
|
||||
const v = make("func test() -> void:|", gd);
|
||||
insertNewlineAndIndent(v);
|
||||
expect(show(v)).toBe("func test() -> void:\n\t|");
|
||||
});
|
||||
|
||||
it("indents nested blocks and ignores trailing comments", () => {
|
||||
const v = make("func test() -> void:\n\tif x > 0: # check|", gd);
|
||||
insertNewlineAndIndent(v);
|
||||
expect(show(v)).toBe("func test() -> void:\n\tif x > 0: # check\n\t\t|");
|
||||
});
|
||||
|
||||
it("does not indent after a comment ending in a colon", () => {
|
||||
const v = make("\t# note:|", gd);
|
||||
insertNewlineAndIndent(v);
|
||||
expect(show(v)).toBe("\t# note:\n\t|");
|
||||
});
|
||||
|
||||
it("dedents after pass, return, break, and continue", () => {
|
||||
const v = make("func test() -> void:\n\tpass|", gd);
|
||||
insertNewlineAndIndent(v);
|
||||
expect(show(v)).toBe("func test() -> void:\n\tpass\n|");
|
||||
|
||||
const w = make("func test() -> int:\n\tif ready:\n\t\treturn 1|", gd);
|
||||
insertNewlineAndIndent(w);
|
||||
expect(show(w)).toBe("func test() -> int:\n\tif ready:\n\t\treturn 1\n\t|");
|
||||
});
|
||||
|
||||
it("completes a GDScript function header with a colon and indents the next line", () => {
|
||||
const v = make("func test() -> void|", gd);
|
||||
completeStatement("gdscript")(v);
|
||||
expect(show(v)).toBe("func test() -> void:\n\t|");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fallback indentation boundaries", () => {
|
||||
const fallback = (id: string, lineComment = "#") =>
|
||||
commentTokens({
|
||||
id,
|
||||
name: id,
|
||||
extensions: [],
|
||||
uiMode: null,
|
||||
lineComment,
|
||||
blockComment: null,
|
||||
indent: null,
|
||||
});
|
||||
it.each([
|
||||
["gdscript", "var items = {|}", "var items = {\n |\n}"],
|
||||
["gdscript", 'var text = "unfinished:|', 'var text = "unfinished:\n|'],
|
||||
["gdscript", 'var text = "unfinished {|', 'var text = "unfinished {\n|'],
|
||||
["gdscript", 'var text = "#" # comment:|', 'var text = "#" # comment:\n|'],
|
||||
["gdscript", "if ready: # note|", "if ready: # note\n |"],
|
||||
["elixir", "if ready, do: value|", "if ready, do: value\n|"],
|
||||
["lua", "if ready then work() end|", "if ready then work() end\n|"],
|
||||
])("handles %s: %s", (id, input, expected) => {
|
||||
const v = make(input, fallback(id));
|
||||
insertNewlineAndIndent(v);
|
||||
expect(show(v)).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -539,6 +539,7 @@ 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",
|
||||
@@ -621,7 +622,11 @@ export function completeStatementSpec(state: EditorState, language: string): Tra
|
||||
}
|
||||
let insert = "";
|
||||
if (SEMICOLON_LANGS.has(language) && !comment && text.trim() && !/[;{},:]$/.test(text)) insert = ";";
|
||||
if (language === "python" && BLOCK_HEAD.test(text.replace(/^\s*(def|elif)\b/, "if")) && !text.endsWith(":"))
|
||||
if (
|
||||
(language === "python" || language === "gdscript") &&
|
||||
BLOCK_HEAD.test(text.replace(/^\s*(def|elif|static\s+func)\b/, "if")) &&
|
||||
!text.endsWith(":")
|
||||
)
|
||||
insert = ":";
|
||||
const opensBlock =
|
||||
text.endsWith("{") ||
|
||||
|
||||
@@ -5,7 +5,13 @@
|
||||
* 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 } from "@codemirror/language";
|
||||
import {
|
||||
LRLanguage,
|
||||
LanguageDescription,
|
||||
LanguageSupport,
|
||||
indentService,
|
||||
language as languageFacet,
|
||||
} 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";
|
||||
@@ -118,11 +124,103 @@ export function loadedSupport(desc: LanguageDescription | null): LanguageSupport
|
||||
return desc?.support ?? null;
|
||||
}
|
||||
|
||||
/** 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 }]));
|
||||
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;
|
||||
}
|
||||
@@ -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)),
|
||||
compartments.comments.of(commentTokens(o.info, o.ctx.language)),
|
||||
compartments.indent.of(indentExtension(o.indent ?? effectiveIndent(o.settings, o.info))),
|
||||
compartments.readOnly.of(readOnlyExtension(o.ctx.readOnly)),
|
||||
compartments.wrap.of(wrapExtension(o.settings, o.ctx.path, o.wrapOverride)),
|
||||
|
||||
@@ -101,6 +101,7 @@ describe("smart backspace", () => {
|
||||
it("applies in 'proper indent' mode except in indentation-based languages", () => {
|
||||
expect(smartBackspaceApplies("autoindent", "java")).toBe(true);
|
||||
expect(smartBackspaceApplies("autoindent", "python")).toBe(false);
|
||||
expect(smartBackspaceApplies("autoindent", "gdscript")).toBe(false);
|
||||
expect(smartBackspaceApplies("nearest", "java")).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ export const tabKeymap: Extension = keymap.of([{ key: "Tab", run: insertTab }]);
|
||||
// --- Backspace -------------------------------------------------------------------------------
|
||||
|
||||
/** Languages whose layout is indentation: IDEA keeps "to nearest indent position" there. */
|
||||
const NEAREST_INDENT_LANGS = new Set(["python", "yaml", "shellscript"]);
|
||||
const NEAREST_INDENT_LANGS = new Set(["python", "gdscript", "yaml", "shellscript"]);
|
||||
|
||||
export function smartBackspaceApplies(mode: string, language: string | undefined): boolean {
|
||||
return mode === "autoindent" && !NEAREST_INDENT_LANGS.has(language ?? "");
|
||||
|
||||
@@ -422,7 +422,7 @@ export class DocModel {
|
||||
compartments.wrap.reconfigure(wrapExtension(editor, this.path, this.softWrapOverride)),
|
||||
compartments.sticky.reconfigure(stickyExtension(editor)),
|
||||
compartments.font.reconfigure(fontExtension(editor)),
|
||||
compartments.comments.reconfigure(commentTokens(this.info)),
|
||||
compartments.comments.reconfigure(commentTokens(this.info, this.meta.language)),
|
||||
compartments.lineNumbers.reconfigure(lineNumbersExtension(editor)),
|
||||
compartments.folding.reconfigure(foldingFor(editor)),
|
||||
compartments.caret.reconfigure(caretFor(editor)),
|
||||
|
||||
Loaded 100 of 259 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user