Compare commits

...
Author SHA1 Message Date
l0ng-ai f0486a6826 fix(agents): recover edited draft when sending after rollback fails 2026-10-29 21:26:57 +08:00
l0ng-ai 3c87a8eea7 fix(agents): restore Codex message editing and cancel repaint 2026-10-29 21:09:19 +08:00
l0ng-ai aac924517c fix(agents): preserve Shift selection and add bulk task actions (#283) 2026-10-29 20:53:02 +08:00
l0ng-ai b1347dd331 fix(lsp): keep background usage counts from delaying navigation (#282)
* fix(lsp): keep background usage counts from delaying navigation

* fix(lsp): retry incomplete counts and preserve newer hints
2026-10-29 20:49:01 +08:00
l0ng-ai fc2496c3f0 feat(agents): show unread task indicators 2026-10-29 20:48:02 +08:00
l0ng-ai 0f26b72d07 fix(agents): anchor composer completions below the caret (#278) 2026-10-29 20:37:25 +08:00
ad09a76212 feat(vcs): IntelliJ's diff algorithm and change colouring in the diff viewer (#242)
* feat(vcs): IDEA's diff algorithm and change colouring in the diff viewer

Port IntelliJ's text comparison to TypeScript (src/features/vcs/comparison):
Myers/patience LCS with unique-element discarding, ByLine (smart matching
that skips unimportant lines, chunk optimizer, second-step correction),
ByWord (word chunks, punctuation matching, whitespace policies, line block
splitting) and ComparisonManager's compareLinesInner / processBlocks. The
port runs IntelliJ's own LineComparisonUtilTest, WordComparisonUtilTest and
SplitComparisonUtilTest cases (459 runs), converted to ideaCases.json.

MergeView chunks now come straight from these line fragments, with the
inner word fragments as chunk changes, instead of @codemirror/merge's
presentable diff. Revert and >> / << replace lines as
DiffUtil.applyModification does.

Colour changes as IDEA does: modified (blue), inserted (green) and deleted
(grey) by chunk type, a lighter line background for chunks whose words were
compared, each word fragment in its own type at full line height, a thin
bar for a fragment empty on one side, and the colours carried into the
gutters and the divider bands. New --diff-modified / --diff-inserted /
--diff-deleted theme tokens, imported from .icls DIFF_* attributes.

Add a Playwright suite over a generated git repository of diff cases,
including 10,000-line files, in every viewer mode.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(vcs): preserve unified revert boundaries and bound large diffs

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: l0ng-ai <[email protected]>
2026-10-29 19:59:57 +08:00
Walter WangandWalt von Neumann 8568a70a05 feat(vcs): add shared and local Git ignore actions (#255)
Co-authored-by: Walt von Neumann <[email protected]>
2026-10-29 19:43:44 +08:00
JawandClaude Opus 5.5 a20207110e fix(vcs): follow the editor font settings in diff and merge viewers (#253)
The VCS viewers (diff, merge, local history) hard-coded the default mono
font, size and line height, so Settings > Editor > Font had no effect on
them. Read the editor font variables like the main editor does, and apply
fontExtension next to the caret, kept in step with the settings.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-29 19:43:40 +08:00
l0ng-ai 74b2d5f5d7 fix(vcs): prevent Git log refresh flicker and graph seams (#269) 2026-10-29 19:12:31 +08:00
l0ng-ai 297b15a7b1 fix(agents): expose Cursor approval controls (#265) 2026-10-29 19:12:27 +08:00
l0ng-ai 97144421d2 fix(agents): open local file links in Markdown messages (#263) 2026-10-29 19:12:23 +08:00
JawandClaude Opus 5.5 200ed6a528 fix(project): paste files copied in Finder on macOS (#250)
The OS clipboard's file list was only wired up for Windows (CF_HDROP), so on
macOS the project view never saw files copied in Finder: Paste stayed disabled
and ⌘V did nothing. Read and write the general NSPasteboard's file URLs
instead, so Finder's copies paste into the project view and the project view's
copies paste in Finder.

Closes #246

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-29 18:05:11 +08:00
l0ng-ai 4666c4b9a5 fix(settings): keep macOS settings visible across displays (#254) 2026-10-29 18:05:06 +08:00
JawandClaude Opus 5.5 ce43a7aa70 chore: add pre-commit hook with simple-git-hooks and lint-staged (#252)
Run oxlint --fix and oxfmt on staged files before each commit, and
rustfmt on staged Rust files. simple-git-hooks installs the hook from
its postinstall, allowed in pnpm-workspace.yaml.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-29 17:47:55 +08:00
l0ng-ai f911196f7d ci(release): Write latest.json once after every build (#176)
Each build's tauri-action rewrote the shared latest.json by deleting and re-uploading it, so
parallel macOS builds raced: one failed with 404 on the delete (skipping DMG notarization), or a
platform could silently drop out of the manifest. The builds no longer upload it; a new
updater-manifest job builds it once from the uploaded .sig files and fails if any platform is
missing. publish waits for it.

Claude-Session: https://claude.ai/code/session_01Dq6jQQn8ZhRfkkyj3PRutA
2026-10-29 17:47:47 +08:00
l0ng-ai 8dea5904f2 style(database): format SQL completion regex 2026-10-29 15:39:07 +08:00
l0ng-ai c7d32f9e23 fix(python): include project roots in run and debug imports
Python file launches only exposed the script directory, so imports of sibling project packages failed. Add content, source and test roots to the effective PYTHONPATH after environment resolution, preserving existing paths. Apply the shared behavior to Python/test process launches and debugpy launches while leaving attach and remote adapter paths unchanged.

Refs #216
2026-10-29 15:11:22 +08:00
Kartik 3f9512056d fix(editor): auto-indent blocks in languages without a CodeMirror mode
Languages without a CodeMirror grammar had no indentation service. Add a low-precedence fallback, GDScript smart Backspace and statement completion. Review fixes preserve literal contents, Elixir inline do expressions and paired-bracket Enter indentation; regression tests cover these sibling cases.

Co-authored-by: kartik-madhak <[email protected]>
2026-10-29 15:03:25 +08:00
l0ng-ai 1283af8a41 chore: v0.1.24 2026-10-29 15:03:04 +08:00
l0ng-ai d37b96002b fix: avoid lookbehind in application regexes for older WebKit
Application regex lookbehind prevented older WebKit from loading the startup module graph. Replace lookbehind with boundary checks across run/terminal links, SQL aliases, debugger targets and refactoring; preserve Unicode boundaries, UTF-16 offsets and empty-match progress. Add compatibility and regression tests.

Fixes #224
Refs #208
2026-10-29 15:01:01 +08:00
l0ng-ai 364bb5ef50 fix: resolve frontend and cross-platform CI failures 2026-10-29 14:45:42 +08:00
l0ng-ai 4d503ef976 feat(vcs): support multiple repositories in the Git tool window
The Git tool window followed one globally selected repository even when multiple roots were registered. Aggregate repository logs and branches, preserve repository identity in selections, history tabs, stashes, diffs and patch commands, and capture repository targets for delayed operations and dialogs. Validate tag names against the captured repository.

Fixes #195
2026-10-29 14:30:32 +08:00
胡翀andzzxzz12345 75b1704f78 feat(database): add grid range editing and SQL filters
Add rectangular database cell selection and TSV copy/paste, plus schema-aware SQL completion and highlighting in WHERE and ORDER BY fields. Preserve whitespace in highlighted SQL and match the input caret scroll width so the overlay remains aligned.

Co-authored-by: zzxzz12345 <[email protected]>
2026-10-29 14:21:14 +08:00
Kartik 06d78cf524 fix(lsp): isolate backoff for optional language features
Optional LSP failures shared one backoff with completion and other features. Give each feature its own per-document backoff and clear all feature backoffs when a server becomes ready.

Co-authored-by: kartik-madhak <[email protected]>
2026-10-29 14:14:00 +08:00
l0ng-ai fff2c7110c feat(keymap): Eclipse and Eclipse (macOS) keymap presets (#219)
Settings › Keymap offers Eclipse and Eclipse (macOS), built on the default presets with the bundled Eclipse keymap bindings for every action we have. Numpad keys are matched by physical key only when the active keymap binds them explicitly (other presets keep the + - * / aliases) and display as NumPad keys; undo/redo stay with the focused view.

Not verified on Windows or Linux machines.

Fixes #192

Claude-Session: https://claude.ai/code/session_01Dq6jQQn8ZhRfkkyj3PRutA
2026-10-29 12:10:33 +08:00
l0ng-ai 9fbd57ed15 feat(settings): Python interpreter widget in the status bar (#218)
The status bar shows the project's Python interpreter when the project has Python files or configuration, with a warning state when none is set. Its popup lists detected interpreters (paths shown for same-named ones) and switches immediately through the project settings, which restarts the Python language server and re-resolves run configurations; Add New Interpreter… and Interpreter Settings… open the Python Interpreter settings page.

Fixes #138

Claude-Session: https://claude.ai/code/session_01Dq6jQQn8ZhRfkkyj3PRutA
2026-10-29 12:08:27 +08:00
l0ng-ai aea0b33585 fix(settings): List every installed font family in the font pickers (#225)
The font pickers never read the installed fonts: they probed a hard-coded list of ~30 names with canvas width checks, so anything outside the list never appeared and the monospaced filter changed nothing. A new ide-fonts crate asks the platform font system for families and their fixed-pitch flag (CoreText on macOS, DirectWrite on Windows, fc-list on Linux/BSD), exposed as system_font_families (spawn_blocking, cached). Every font picker — editor font and fallback, terminal font and fallback, UI custom font — uses one FontSelect with prefix type-ahead, a real monospaced filter, and keeps an uninstalled selection visible.

Not verified on Windows or Linux machines.

Fixes #199

Claude-Session: https://claude.ai/code/session_01Dq6jQQn8ZhRfkkyj3PRutA
2026-10-29 12:05:46 +08:00
l0ng-ai e537dd43a0 feat(sdk): Detect mise and asdf toolchains and use the project's pinned versions (#227)
Toolchain detection only knew fixed install locations (SDKMAN, nvm, pyenv…), so per-version managers were invisible and project-pinned versions were ignored; mise also only reaches PATH through an interactive-shell hook. JDK, Node.js, Python, Go and Maven installs under mise (MISE_DATA_DIR / XDG) and asdf (ASDF_DATA_DIR) are now detected, pinned versions from mise.toml variants and .tool-versions are resolved and used when the SDK setting is Automatic, and the managers' shims/installs are added to PATH when the login shell didn't.

Fixes #207

Claude-Session: https://claude.ai/code/session_01Dq6jQQn8ZhRfkkyj3PRutA
2026-10-29 12:04:11 +08:00
l0ng-ai c821ab144c fix(run): Give forked and debugged JVMs the console encoding and render colours in the Debug console (#220)
Only the JVM the IDE launched directly was told the console's output encoding, so forked JVMs (spring-boot:run, Surefire, bootRun, Gradle workers), debugger-launched JVMs and Build-window builds wrote GBK on Windows while the console decoded UTF-8. Every JVM the IDE starts on Windows now gets the console encoding: java gets -Dfile.encoding alongside the std* properties, build tools and their forks get JAVA_TOOL_OPTIONS (std* only) with the IDE's own 'Picked up JAVA_TOOL_OPTIONS' line filtered, shell command lines are recognised by their first word, and java-debug launches get the matching vmArgs. The Debug console now shares the Run console's ANSI parser. Encoding helpers moved to ide-core::output_encoding.

Not verified on a Windows machine.

Fixes #180
Fixes #182

Claude-Session: https://claude.ai/code/session_01Dq6jQQn8ZhRfkkyj3PRutA
2026-10-29 11:58:19 +08:00
l0ng-ai 6767a5ff8c fix(db): Read MySQL catalog names as text, not hex (#212)
MySQL flags text columns with a binary collation as BINARY, so sqlx reports them as VARBINARY/BLOB and cell() formatted them as 0x… — including information_schema names (utf8mb3_bin under lower_case_table_names=0, or anything with character_set_results=binary). The hex schema name was then used in follow-up queries, which matched nothing. Metadata queries now always decode text as UTF-8; result grids show hex only for true binary data. Covers schemas, tables, columns, indexes, foreign keys, triggers, routines, DDL, completion and the table editor's _bin primary keys.

Fixes #196

Claude-Session: https://claude.ai/code/session_01Dq6jQQn8ZhRfkkyj3PRutA
2026-10-29 11:50:28 +08:00
l0ng-ai a233d85fcc feat(agents): Show when Claude Code retries a failed API request (#214)
When Claude Code loses its API connection or is refused, it retries on its own
for up to several minutes. The session showed only "Working…" meanwhile. It
now shows "API request failed, retrying in Ns (attempt 3/10)" with the error
in the tooltip, cleared by the agent's next output or the end of the turn.
2026-10-29 11:46:52 +08:00
l0ng-ai 4e675c1171 fix(java): Start jdtls with a bounded heap that returns memory to the OS (#210)
jdtls was launched with -Xms1G and no heap cap or GC choice, so the JVM grew to a quarter of physical RAM during import and kept it. Default VM args are now ParallelGC with GCTimeRatio/AdaptiveSizePolicyWeight, -Xmx2G -Xms100m, disabled memory-mapped zips and VM-install detection, prepended as --jvm-arg so both the direct and launcher-script paths get them. User JVM options replace the matching default (heap, -D/-XX by name, any -Xlog, any GC flag drops the whole GC group).

Fixes #193

Claude-Session: https://claude.ai/code/session_01Dq6jQQn8ZhRfkkyj3PRutA
2026-10-29 11:46:22 +08:00
l0ng-ai 8ebeaa2663 feat(run): Add VM options to Java run configurations (#206)
Adds Application and Spring Boot run configuration types with main class, VM options, program arguments, classpath and active profiles. Maven passes them through spring-boot.run.jvmArguments / profiles / argLine; Gradle through an init script.

Fixes #187

Claude-Session: https://claude.ai/code/session_01Dq6jQQn8ZhRfkkyj3PRutA
2026-10-29 11:39:20 +08:00
l0ng-ai ed7b0f88ef feat(agents): Dock the Agents tool window at the top of the right side by default (#209)
Closes #191

Claude-Session: https://claude.ai/code/session_01Dq6jQQn8ZhRfkkyj3PRutA
2026-10-29 11:13:12 +08:00
l0ng-aiandClaude 9d4e778c7b feat(settings): HTTP Proxy settings for the IDE's outgoing connections (#194)
* feat(settings): HTTP Proxy settings for the IDE's outgoing connections

Add Appearance & Behavior › System Settings › HTTP Proxy with three modes:
No proxy, Auto-detect proxy settings (the default; environment proxy as
before) and Manual proxy configuration (HTTP or SOCKS, host and port, a
comma-separated "No proxy for" list with `*` wildcards, optional proxy
authentication). "Check connection…" GETs a URL with the settings as edited
and reports the status or the error.

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

Co-Authored-By: Claude <[email protected]>

* fix(host): Allow the Unix-only post-start test helpers on Windows

Co-Authored-By: Claude <[email protected]>

---------

Co-authored-by: Claude <[email protected]>
2026-10-29 10:43:30 +08:00
l0ng-ai c8e3fe9d2e fix(lsp): Name the running server when it lacks a feature (#201)
ide-lsp returned the same `unavailable` error whether no server was running or a running server didn't implement the request, so the UI said no language server was available, and Invoke Inline Completion said no language server provides inline completion, while jdtls was running fine (it doesn't implement textDocument/inlineCompletion). When servers are running but none supports the feature, the error now names them ("jdtls doesn't support inline completion"), and every explicit action shares one message helper: Invoke Inline Completion, go to declaration / implementation / type declaration, quick documentation, parameter info, rename, reformat, intention actions, highlight usages, call / type hierarchy, find usages, safe delete and workspace symbols. With no server running it still says no language server is available. Background requests while typing stay silent.

Fixes #186

Claude-Session: https://claude.ai/code/session_01Dq6jQQn8ZhRfkkyj3PRutA
2026-10-29 10:35:37 +08:00
Jaw 4410da60a2 feat(vcs): IDEA-style synchronized scrolling for the side-by-side diff (#181)
The side-by-side diff gets two independent options. Synchronize Scrolling (toolbar lock, also in the settings menu, on by default) scrolls both panes together; Align Changes (settings menu, off by default) pads the panes so changes line up. With Align Changes off, the panes keep their own heights and a divider of curved bands joins each change, with arrows to apply it; scrolling one pane moves the other through the matching change (anchored at a third of the height), including caret moves and the wheel over the divider. Scroll mapping and the divider look up changes by binary search, so large files with thousands of changes scroll smoothly.

Claude-Session: https://claude.ai/code/session_01Dq6jQQn8ZhRfkkyj3PRutA
Co-authored-by: jaw52 <[email protected]>
2026-10-29 10:25:57 +08:00
259 changed files with 17755 additions and 1556 deletions

No files matched your search

+60 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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"] }
+13
View File
@@ -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`,
+34 -1
View File
@@ -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);
+73 -4
View File
@@ -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())
}
+15
View File
@@ -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",
+45 -2
View File
@@ -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;
+72
View File
@@ -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();
}
+6 -1
View File
@@ -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);
+6 -1
View File
@@ -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"] }
+78 -4
View File
@@ -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;
+3
View File
@@ -13,3 +13,6 @@ specta.workspace = true
specta-typescript.workspace = true
thiserror.workspace = true
tokio-util.workspace = true
[dev-dependencies]
tempfile.workspace = true
+3
View File
@@ -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};
+412
View File
@@ -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");
}
}
+7 -2
View File
@@ -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
}
+365
View File
@@ -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);
}
}
+4
View File
@@ -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(),
+241
View File
@@ -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
View File
@@ -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}");
}
}
+87
View File
@@ -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
}
+17
View File
@@ -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"] }
+264
View File
@@ -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));
}
}
}
+4
View File
@@ -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(),
+8 -2
View File
@@ -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).
+70
View File
@@ -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");
}
}
}
+1
View File
@@ -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;
+13 -4
View File
@@ -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 &current {
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 })
}
+103
View File
@@ -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]);
}
}
}
+4 -1
View File
@@ -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)))
}
+15
View File
@@ -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
});
+61 -10
View File
@@ -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 });
+50
View File
@@ -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))
}
}
+74
View File
@@ -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);
}
+7 -1
View File
@@ -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")
));
+20
View File
@@ -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:?}");
+65
View File
@@ -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");
}
+187
View File
@@ -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);
}
}
+2 -41
View File
@@ -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::*;
+3 -1
View File
@@ -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;
+13 -4
View File
@@ -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;
+14
View File
@@ -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())]);
+97
View File
@@ -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"));
}
}
+2 -1
View File
@@ -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;
-167
View File
@@ -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())]);
}
}
+60 -25
View File
@@ -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(&quote(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));
}
+44
View File
@@ -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]);
}
}
+42
View File
@@ -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");
}
+33
View File
@@ -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(&[
+54
View File
@@ -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
View File
@@ -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();
+49 -8
View File
@@ -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")) {
+14 -5
View File
@@ -67,7 +67,7 @@ pub fn is_downloaded(home: &Path) -> bool {
}
/// JDK homes in the usual install locations (macOS `JavaVirtualMachines`,
/// `/usr/lib/jvm`, SDKMAN, 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
View File
@@ -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 {
+1
View File
@@ -16,6 +16,7 @@ pub mod libraries;
pub mod locate;
pub mod lombok;
mod npm;
pub mod pinned;
pub mod python;
mod vue;
+233
View File
@@ -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")));
}
}
+130
View File
@@ -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);
}
+6 -6
View File
@@ -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)?;
}
+3 -1
View File
@@ -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));
+2
View File
@@ -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"]);
}
+2
View File
@@ -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"
+318
View File
@@ -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");
}
}
+2
View File
@@ -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;
+1 -1
View File
@@ -50,7 +50,7 @@ pub(crate) fn discover(path: &Path) -> IdeResult<Option<Repo>> {
Ok(Some(Repo { root, git_dir, inner: std::sync::Mutex::new(cache), ignored: Default::default() }))
}
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();
+311
View File
@@ -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);
}
}
+1
View File
@@ -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
+26 -2
View File
@@ -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;
}
+11 -3
View File
@@ -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,
}
+44
View File
@@ -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);
}
+4 -1
View File
@@ -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)
+336
View File
@@ -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
View File
@@ -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"));
}
+113
View File
@@ -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`,
});
});
}
+13
View File
@@ -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
View File
@@ -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",
+54 -14
View File
@@ -116,7 +116,7 @@ importers:
version: 2.12.3
'@testing-library/jest-dom':
specifier: ^7.0.1
version: 7.0.1(@testing-library/[email protected])([email protected](@types/[email protected])([email protected])([email protected](@types/[email protected])))
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
+2
View File
@@ -0,0 +1,2 @@
allowBuilds:
simple-git-hooks: true
+3
View File
@@ -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"]],
+1
View File
@@ -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
+3 -5
View File
@@ -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 })
}
+14
View File
@@ -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()))
}
+8
View File
@@ -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
}
+5
View File
@@ -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,
+11
View File
@@ -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]
+32 -6
View File
@@ -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.
+2 -1
View File
@@ -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()))?;
+9 -5
View File
@@ -6,7 +6,7 @@
//! (`window_take_launch`), and re-taken whenever [`LAUNCH_EVENT`] fires (a
//! second app instance asking an already-loaded window to open a path).
//!
//! Settings open in a 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 -1
View File
@@ -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
View File
@@ -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). */
+10
View File
@@ -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",
+80 -1
View File
@@ -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);
});
});
+6 -1
View File
@@ -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("{") ||
+106 -8
View File
@@ -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;
}
+1 -1
View File
@@ -355,7 +355,7 @@ export function baseExtensions(o: SetupOptions): Extension {
tabKeymap,
keymap.of([...closeBracketsKeymap, ...editorBaseKeymap, indentWithTab]),
compartments.language.of(o.language ?? []),
compartments.comments.of(commentTokens(o.info)),
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)),
+1
View File
@@ -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);
});
+1 -1
View File
@@ -47,7 +47,7 @@ export const tabKeymap: Extension = keymap.of([{ key: "Tab", run: insertTab }]);
// --- Backspace -------------------------------------------------------------------------------
/** Languages whose layout is indentation: IDEA keeps "to nearest indent position" there. */
const NEAREST_INDENT_LANGS = new Set(["python", "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 ?? "");
+1 -1
View File
@@ -422,7 +422,7 @@ export class DocModel {
compartments.wrap.reconfigure(wrapExtension(editor, this.path, this.softWrapOverride)),
compartments.sticky.reconfigure(stickyExtension(editor)),
compartments.font.reconfigure(fontExtension(editor)),
compartments.comments.reconfigure(commentTokens(this.info)),
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