Compare commits

...
Author SHA1 Message Date
l0ng-ai 5526ed5865 fix(java): replace module outputs with launch snapshots on Windows
Maven launches copy each module's class directory into a snapshot and put
the snapshot on the classpath in place of the original. The originals came
from `module.join("target/classes")`, which on Windows reads
`...\app\target/classes`, while JDT LS reports `...\app\target\classes`.
Compared as text, the original was never removed, so every resource was on
the classpath twice; MyBatis then failed with "XML fragments parsed from
previous mappers already contains value", and any classpath* scan saw
duplicates. Mixed-language builds deduplicated their outputs the same way.

Launch class directories are joined per component, and classpath entries
are compared by a key that ignores the verbatim prefix, the separator,
trailing separators and, on Windows, case.
2026-11-03 20:27:11 +08:00
Park Jong Hyun 4b1614af1f fix(spell): close a Markdown code fence only on a matching fence (#986)
Any ``` line closed an open fence, so a ```` block showing a ``` block ended early and the inner code was spell checked as prose. Fences now close as in CommonMark: the same character, at least as long as the opening fence, and no info string.

Co-authored-by: fhrmrk <[email protected]>
2026-11-03 19:29:16 +08:00
Park Jong Hyun bdeca5803b fix(spell): skip backslash commands like \brief in comments (#985)
Comments and Markdown went through the string-escape rule, which drops only the letter after a backslash, so the Doxygen command `\brief` was reported as the typo "rief". Outside string literals the whole word after a backslash is skipped now (Doxygen / TeX commands, escapes quoted in comments); string literals still skip only the escape letter.

Co-authored-by: fhrmrk <[email protected]>
2026-11-03 19:29:03 +08:00
Park Jong Hyun 839bd0cdb0 fix(http): add http:// to a URL whose query holds another URL (#988)
A request URL without a scheme defaults to http://, but the check was `contains("://")`, so `GET localhost:8080/login?next=http://…` was sent as is and failed to parse. Only a `://` after a leading RFC 3986 scheme counts now, both for HTTP Client requests and the proxy connection check.

Co-authored-by: fhrmrk <[email protected]>
2026-11-03 19:28:21 +08:00
Park Jong Hyun ebf2c2f8e3 fix(web): redirect a served root without a trailing slash (#987)
`/<token>/<key>` has no segments after the key, so the trailing-slash flag kept its initial value and the root's index.html was served at a URL without the slash; its relative links then resolved above the root and returned 404. The flag now comes from the path, so the root redirects to `/<token>/<key>/` like any other directory.

Co-authored-by: fhrmrk <[email protected]>
2026-11-03 19:28:10 +08:00
Jaw c599265ced fix(format): re-detect indents after Reformat Code (#957)
After Reformat Code rewrote a file's indents (e.g. spaces to tabs per Code Style), the editor kept the indents detected when the file was opened: tabs rendered at the old width, the status bar showed the old indent, and Enter inserted the old indent, mixing tabs and spaces until the file was reopened.

Whole-file format edits from language servers and contributed formatters are now tagged `format.reformat`, and after such a transaction the document runs the open-time indent detection again and reapplies its settings, so every split picks up the new indent.

Refs #935.

Co-authored-by: jaw52 <[email protected]>
2026-11-03 19:25:48 +08:00
l0ng-ai 867774b88a Merge pull request #607 from a-rookie-of-C-language/fix/settings-window-shadow-595
fix(windows): de-parent settings window so DWM paints its shadow
2026-11-03 19:22:02 +08:00
l0ng-ai d54e852c38 fix(java): bundle Lombok with the ECJ constructor-call fix (#1027)
JDT LS 1.63's ECJ 3.46.200 keeps a constructor's super()/this() call as the first of its statements. Lombok 1.18.48 wrapped that call in the @SneakyThrows try block (ECJ internal error, whole workspace build failed), emitted @NonNull checks before it, and generated constructors without it.

The bundled agent is now 1.18.48-openidea.2: v1.18.48 with lombok#4059 and the upstream constructor-call fix lombok#4089. The new file name gives JDT LS a new workspace, so classes from the previous agent are rebuilt.

Fixes #1000
Fixes #830
2026-11-03 19:07:33 +08:00
l0ng-ai efd03630b6 fix(navigation): label string-literal targets by their literal (#1025)
Navigation targets that start at a string literal (a Spring @Value("${key}") consumer reached from a configuration key) are named by the literal instead of the first identifier fragment inside it, which showed as "$". Applies to the declaration chooser, the quick-definition popup and the gutter line-marker chooser.
2026-11-03 18:40:26 +08:00
l0ng-ai bbc6a2bc4c fix(run): keep toolchain-derived values out of saved run configurations (#1023)
The run list applies Languages & Frameworks to every configuration it returns (interpreter or toolchain command, PATH, VIRTUAL_ENV, JAVA_HOME, GOROOT, GOPATH, Go module variables, RUSTUP_TOOLCHAIN), and every save sends the user configurations back from that list, so the derived values were written to run.toml: reopening a configuration showed the IDE's whole PATH in its environment, and since applied values never replace existing ones, a saved configuration kept the old toolchain after the interpreter or SDK changed.

Saving now drops what settings derive: an environment entry or an absolute command counts as derived when applying settings to the configuration without it produces it again. The user's own values, including ones for the same variables, are kept. Covers python, pytest, django-test, npm, cargo, go, maven, tomcat, ruby, rake, rspec and godot; Gradle stays as saved, as the run list already leaves it unapplied.
2026-11-03 18:17:00 +08:00
arookieofc cc4a29fa5d chore(ci): reformat types.ts and regenerate bindings to keep main green (#990)
Reformats src/features/run/types.ts with oxfmt and moves RubySettings to its sorted position in the generated src/bindings.ts.
2026-11-03 18:14:03 +08:00
l0ng-ai 5ad179a9e6 perf(java): stop configuration indexing from blocking navigation and keep it warm (#1012)
Java Go to Definition consults the Spring configuration command only inside @Value / getProperty / getRequiredProperty arguments (tree-sitter check), so ordinary Ctrl+click and Ctrl+hover go straight to JDT instead of waiting on a project-wide index rebuild.

The java-usages bundle (1.2.1) keeps its configuration snapshot across editor open/close, builds, saves of non-Java resources and reconciles; it invalidates on Java model changes and unsaved Java edits only.

Fixes #998
Fixes #1002
2026-11-03 18:12:43 +08:00
Jaw 291fa9affe feat(window): close other / left / right project tabs from the window-group tab menu (#965)
Right-clicking a project tab in a merged window group offers Close Other Tabs, Close Tabs to the Left and Close Tabs to the Right (left/right disabled at the ends); the same commands are in the Command Palette and can take shortcuts.

The backend closes the projects one at a time, focusing each so its own unsaved-changes prompt is visible, as Quit does. Cancelling a prompt stops the batch and leaves that project and the rest open; the clicked tab stays and is selected when the batch finishes. Starting Quit clears a pending batch.

Closes #942.

Co-authored-by: jaw52 <[email protected]>
2026-11-03 17:57:42 +08:00
Azeem Hassniandazeemhassni ecdd43a55a fix(vfs): poll for changes when the OS file watcher cannot start (#906)
Opening a project failed outright when the OS file watcher could not start (inotify limits used up, a file system without change notifications). The watcher now falls back to polling the same non-ignored directories every 2s, per directory unless flat so excluded trees like `node_modules` and `target` are not rescanned, and the project opens with an "External file changes sync may be slow" warning that gives the reason. Directory mtime changes seen only by polling are dropped so folders are not reported as modified.

Fixes #891.

Co-authored-by: azeemhassni <[email protected]>
2026-11-03 17:37:40 +08:00
Jaw ec465964d2 feat(editor): highlight JSP pages (#937)
JSP files had no highlighting and showed a `?` icon; the HTML mode left directives, scriptlets, JSP comments and EL as plain text and flagged `<c:out/>` / `<jsp:include/>` as unclosed elements.

- A JSP grammar layer (comments, directives, scriptlets, expressions, declarations, EL) with HTML and Java overlays, so markup split by JSP elements still nests and braces match across scriptlets.
- EL as its own grammar top: member/index/method calls, namespaced functions, keyword operators, ternaries, lambdas and collection literals.
- Block comments use `<%-- --%>` in markup and `//` in scriptlets; JSP comments and scriptlets fold.
- `.jsp .jspf .jspx .jsph .tag .tagf .tagx` map to the JSP language with a file icon; `gen:jsp-grammar` regenerates the parser and a test checks it matches the grammar.

Closes #870.

Co-authored-by: jaw52 <[email protected]>
2026-11-03 17:29:59 +08:00
l0ng-ai 59766278c5 fix(i18n): translate counts inside the Update Project and Hot Swap results (#994)
In Chinese the Update Project notification read "已在 2 commits 中更新 3 files" and the Hot Swap result "已重新加载 3 classes". Both sentences were translated, but the counts inside them were English phrases passed in as placeholder values, which are only looked up as exact catalog entries.

Each count is now its own source text per variant (`{count} file` / `{count} files`, `{count} commit` / `{count} commits`, `{count} class` / `{count} classes`), built in the current language before it goes into the sentence. These were the only two places where a translated sentence receives an English count phrase as a value.
2026-11-03 15:56:05 +08:00
arookieofcanda-rookie-of-C-language cc4ab5d4a8 feat(vcs): push notifications say how many commits went where (#961)
After a push the notification only said "Push successful". It now says what went out, for example "Pushed 3 commits to origin/main", counting the commits the branch was ahead of its upstream before the push. This covers Push and pushing a branch to its own upstream. A push across several repositories stays generic unless exactly one of them had commits to send. The title is translated as a whole for each count.

Fixes #954.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-03 15:35:01 +08:00
ntbang0901 22a5f21a90 perf(vfs): list directories off the main thread (#973)
`fs_read_dir` was a synchronous Tauri command, so every directory listing ran on the main thread. On a slow file system such as a network share (`\\wsl.localhost\...`), each stat while expanding the project tree stalled the whole UI. The listing now runs on the blocking pool, as the other disk-heavy commands do, both for the desktop command and over RPC.

Refs #916.

Co-authored-by: ntbang0901 <[email protected]>
2026-11-03 15:32:19 +08:00
arookieofcanda-rookie-of-C-language 5e7ee5e081 fix(agents): delete agent tasks whose worktree git no longer recognizes (#979)
Deleting a worktree-isolated agent task together with its worktree failed for good with "fatal: '<path>' is not a working tree" once git no longer recognized the worktree: its registration was pruned, the repository moved, or the worktree's `.git` file was deleted.

When `git worktree remove --force` fails but the folder is still there and lies inside the agent worktrees directory, the folder is now removed directly, so the task can always be deleted. A stale registration is pruned by git later. Paths outside that directory (the task's cwd comes from its stored record) keep git's error and are never deleted by hand.

Fixes #969.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-03 15:25:45 +08:00
Kevin f67c478b8b fix(ui): wrap long tooltip text inside the bubble (#983)
Hovering an editor tab with a long path showed a tooltip whose text ran past the bubble: tooltips never wrapped (`white-space: nowrap`) and were capped at 360px.

Tooltip text now wraps inside the bubble, breaking long paths where needed, and the shortcut stays on one line next to it. The bubble grows up to 720px or the window width less 32px, so most paths still fit on one line. Short tooltips look as before (same size and line height), and newlines in tooltip text are still collapsed.

Fixes #968.

Co-authored-by: yangyezhuang <[email protected]>
2026-11-03 15:22:26 +08:00
Roberto Scinoccaandretsef 002401e8a7 feat(languages): add ERB with an HTML + Ruby editor mode (#720)
Ruby gets an ERB editor mode (HTML with embedded Ruby), Ruby / Rake / RSpec run configuration types, and a Settings › Languages & Frameworks › Ruby page for choosing the interpreter (Auto picks the `.ruby-version` / mise / asdf install, else `ruby` on PATH).

- Run: `.rb` scripts run from the editor context menu, `_spec.rb` / `_test.rb` files narrow an RSpec or Rake (`SPEC=` / `TEST=`) configuration, and Rakefile tasks are detected; Rake and RSpec go through `bundle exec` when the project has a Gemfile. The chosen interpreter's `ruby`, `bundle`, `rake` and `rspec` are used and its bin directory is put on PATH.
- Configuration forms rebuild from a raw command line keeping option values with their options (`--format documentation`, `-I lib`), and a new Ruby configuration opens in the Ruby form with the script still to fill in.
- External Libraries lists the Ruby standard library and the bundle's gems, including those Bundler installs inside the project (`vendor/bundle`).
- Ruby is part of the frontend settings defaults and normalization, so the selected interpreter survives a settings round-trip.

Closes #719

Co-authored-by: retsef <[email protected]>
2026-11-03 15:05:09 +08:00
arookieofcanda-rookie-of-C-language fcfe26474d fix(format): apply the Code Style indent override to reformat requests (#949)
Settings › Editor › Code Style › Java › Tabs and Indents had no effect on Reformat Code: a file indented with 4 spaces stayed that way after choosing "Use tab character" with tab size and indent 1.

There were two causes. The reformat request sent the editor's indent, which comes from the indents detected in the file, instead of the Code Style; and jdt.ls takes the per-level indent width (`indentation.size`) only from its formatter profile, never from the request. With an explicit Tabs and Indents override for the language, Reformat Code now asks for the Code Style indent (EditorConfig still applies, the detected indents don't; without an override the editor's indent is kept). The jdt.ls profile carries the same override: it is patched into the bundled IntelliJ profile, or put into a minimal generated profile for the default formatter, and custom profiles are left alone. An unset Indent follows the tab size, as in Code Style. With tabs the request sends the tab size; every editor of a document gets the override when it is created, splits and reopened tabs included, not only after a settings change.

Checked with jdt.ls 1.63 on JDK 25: tab character, tab size 1 and indent 1 reformat a 4-space file to one tab per level with both the default and the IntelliJ formatter.

Fixes #935.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-03 14:56:56 +08:00
arookieofcanda-rookie-of-C-language 18e16e76a4 fix(desktop): pass the real window label to project-requiring commands (#960)
Add Interpreter (creating a virtualenv), the Python packages list and pip install/uninstall, Settings › Git › Test, JVM listen-mode debugging and Create Patch › Save to file failed with "no project is open in this window", or ran without the project.

Their commands passed an empty window label to ops that resolve the calling window's project. They now pass the real window label, as the other project-scoped commands do. A sweep of every command that passes an empty label found exactly these seven ops using it: `sdk_locate_tools`, `sdk_python_create_venv`, `sdk_python_packages`, `sdk_python_pip`, `vcs_git_version`, `debug_listen_for_jvm` and `vcs_write_patch`. Without the window, the last one skipped the project's disk sync, so a patch saved inside the project showed up as an external change.

Fixes #955.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-03 14:50:49 +08:00
Piettro b55c398fb5 feat(python): add Pyrefly language server (#911)
Pyrefly (`pyrefly lsp`) joins basedpyright, Pyright and ty as a fallback Python language server. It is installable from Tools as a managed virtualenv tool and is listed among the interchangeable type checkers, before Ruff, so Ruff still runs after them.

Pyrefly only reports type errors in projects with a Pyrefly configuration (`pyrefly.toml` or `[tool.pyrefly]`); hover, completion and navigation work without one. A real-server test (ignored by default, needs `pyrefly` on PATH) covers startup, go-to-definition and diagnostics.

Co-authored-by: pcastro93 <[email protected]>
2026-11-03 14:42:45 +08:00
Jaw 038c23a2c8 feat(vcs): tint test files green in the changes tree (#940)
The changes tree in the Commit and Local Changes views now gives test files a light green background, so test changes stand out from production ones. A file counts as a test when it lies under a test source root (detected or marked by hand) or is named like a test (`*.test.ts`, `*Test.java`, …), the same check the project tree uses; folders under a test root get the tint too. Hover and selection still win.

The colour is a new `--bg-file-tests` token derived from `--success` and `--bg-island`, so every theme gets a matching tint, and it follows test roots as they are marked or unmarked.

Fixes #931.

Co-authored-by: jaw52 <[email protected]>
2026-11-03 14:40:18 +08:00
arookieofcanda-rookie-of-C-language 2161a2ca03 fix(lsp): serialize the jdt.ls classpath resolve with the build lock (#946)
Running a main class in a multi-module Maven project could fail with "Cannot resolve the classpath: jdtls: Attempted to beginRule: P/<dependency>, does not match outer scope rule: P/<module>".

`java.project.getClasspaths` and `getSettings` ran outside the jdt.ls build lock, so they could overlap a build or an m2e refresh of a sibling module that takes a nested Eclipse scheduling rule. The launch now resolves the classpath and JDK under the same build lock the build commands use. A delegated external build already holds that lock across the launch, so its callers pass `build_lock_held` instead of waiting on it again, which would deadlock.

Fixes #933.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-03 14:39:29 +08:00
youngtseup c50d212a1d fix(app): keep update progress visible on the welcome screen (#951)
Check for Updates on the welcome screen showed nothing while the request was out: the check ran only as a status-bar task, and the welcome screen has no status bar. Leaving a project also dropped every notification, including a running update download.

A check started with no project or LightEdit window open now shows its progress in a balloon (keyed like the result, so the result replaces it); project windows keep it in the status bar. A project switch clears only project notifications and keeps the app-wide update ones.

Co-authored-by: youngtseup <[email protected]>
2026-11-03 14:38:19 +08:00
Piettro 843ff74d5d fix(tools): name uv as the installer for Python servers when it makes the virtualenv (#936)
Tools and the install log named "pip (virtualenv)" for Python language servers (basedpyright, Pyright, ty, Ruff) even when the virtualenv is made with uv, which `install_venv` prefers whenever uv is on `PATH`.

The label now comes from the installer that will actually run: "uv (virtualenv)" when uv is on `PATH`, unchanged otherwise. debugpy's private copy still says pip, since it is always installed with `python -m pip`.

Co-authored-by: pcastro93 <[email protected]>
2026-11-03 14:37:26 +08:00
arookieofcanda-rookie-of-C-language 53e76e29c6 fix(vcs): discover worktrees created while the project is open (#930)
A linked worktree created below the project while it was open never joined the repository set: its files had no status entries or gutter marks until the project was reopened.

A linked worktree's `.git` is a gitfile and its HEAD lives under `<main>/.git/worktrees/<name>/`. The watcher classified both as non-metadata, `.git/worktrees` was never watched, and the repository rescan only reacted to `<repo>/.git/HEAD`. The watcher now reports the `.git` path and `.git/worktrees/**` (without `.lock` files) as VCS changes and watches `.git/worktrees`; the rescan accepts a worktree's `.git`; and the Add/Remove Worktree commands refresh the repository set directly, so both the in-IDE and the external `git worktree` paths are covered.

Fixes #927.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-03 14:35:52 +08:00
arookieofc 86d082974f Merge pull request #2 from pcastro93/wayland-csd-shadow
fix(linux): draw GTK's CSD shadow on frameless windows under Wayland
2026-11-03 13:27:35 +08:00
Piettro CastroandClaude Opus 5.5 ec5302d838 fix(linux): draw GTK's CSD shadow on frameless windows under Wayland
tao makes frameless Wayland windows client-side decorated (an empty GTK
title bar, so the compositor adds no frame) but leaves them undecorated,
so GTK draws no shadow or resize margin and dark windows blend into
whatever is behind them. Decorating the window after build keeps that
empty title bar: GTK adds the theme's shadow and resize edges, nothing
else. Applies to project windows and the Settings window; X11 is
unchanged (decorating there would bring back the WM frame).

- Detect Wayland from GDK's backend (tao's own test), not env vars.
- Measure windows by GTK's content size, which excludes the shadow
  margin, so remembered geometry and Settings placement don't grow.
- Skip the in-page resize handles on Wayland; GTK provides the edges.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 00:14:14 -05:00
arookieofcanda-rookie-of-C-language 00e717d8fd feat(vcs): diff preview in the Git window's Local Changes tab (#714)
The Local Changes tab of the Git tool window showed only the changes tree; a file's diff was visible only after opening a diff tab.

It now previews the selected file's working-tree diff beside the tree when the window is docked at the bottom and below it when docked on a side. The preview follows the selection (arrow keys included), shows a placeholder for no or multiple selection, and can be turned off with View Options › Show Diff Preview (persisted; the selection is kept when toggling).

The preview reuses the diff editor, whose toolbar did not fit a side-docked window: the editor is now a size container, the whitespace and highlighting pickers move to the settings menu below 640px and the separators go below 380px, buttons no longer shrink and the differences counter truncates.

Fixes #710.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-03 10:18:39 +08:00
Deng Liuanddengliu 4f64ca3149 feat(agents): enable 1-click ACP registry install and updates for GitHub Copilot (#773)
GitHub Copilot, Qwen Code and Mistral Vibe were listed in the ACP Registry under their registry ids (github-copilot-cli, qwen-code, mistral-vibe), so they showed as unrelated third-party agents and could not be installed over the built-in providers. Registry ids now map to the built-in ids: the rows use the built-in name and icon, install and update into <config>/agents/<id>.toml, show "x.y.z available" when the registry has a newer version, and fall back to the built-in command on PATH after Remove. Installing never overwrites a hand-written agent config, and Remove only deletes a registry install. Changing installed providers reloads them and drops stale probes, so New Task sees the installed version right away.

Co-authored-by: dengliu <[email protected]>
2026-11-03 10:10:31 +08:00
l0ng-ai 93712a1588 chore: add bug report and feature request issue forms (#878)
Structured issue forms for bug reports and feature requests: environment, steps, expected/actual behaviour and logs for bugs; problem, proposal and alternatives for features. Both ask reporters to search existing issues first and keep one problem per issue; blank issues are disabled and the latest release is linked from the chooser. Forms do not require a particular language.

Refs #860
2026-11-03 09:39:11 +08:00
arookieofc dc14a46a17 Merge pull request #1 from fuqiuluo/fix/pr-607-revert-edge
fix(linux): keep settings window edge subtle
2026-11-03 08:42:45 +08:00
arookieofcanda-rookie-of-C-language 416b65d65e test(editor): make the detach-tab save-failure document dirty (#924)
Since #920, saves skip clean documents, so the detach-tab save-failure test never reached its mocked failing save. The test now edits the document first, keeping its intent: a failing save keeps the tab and opens no window.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-03 08:31:19 +08:00
l0ng-aiandClaude Opus 5.5 0a24b79b50 fix(editor): skip clean saves and refresh buffers after disk operations (#920)
Root cause, two parts:
- An explicit save (Save All before a checkout, before running, before Commit) also wrote documents that were not modified, running save actions such as stripping trailing whitespace and adding a final newline on them — files the user never touched gained whitespace-only changes. Saves now skip clean documents, as autosave already did.
- After our own VCS operations rewrote files (checkout, rollback, stash, patch, ignore, commit/worktree hooks, Directory Compare), clean editor buffers were only refreshed by the file watcher, which can miss a rewrite that keeps the length and the timestamp's millisecond. Those operations now reload clean buffers synchronously before returning, keeping each document's chosen encoding; modified buffers keep the existing conflict handling.

Fixes #893

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 07:54:34 +08:00
l0ng-aiandClaude Opus 5.5 d9929bbe44 fix(vcs): resolve shelf files against their owning repository (#919)
Root cause: shelved file paths are relative to the repository they were shelved from, but Compare with Local, Show Diff, navigation and Unshelve joined them to the selected VCS root. In a project folder that is not itself a repository (or holds several), the local file was not found and Compare with Local stayed disabled. Shelves now record their repository (older shelves fall back to the primary repository), imported patches record the current one, and every shelf action resolves paths against it, with Windows separators handled. Stash and Apply Patch already kept their repository.

Fixes #875

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 07:52:55 +08:00
fuqiuluo d830d21d46 fix(linux): keep the settings edge above content 2026-11-03 07:20:03 +08:00
fuqiuluo fd8161a5a7 Revert "fix(linux): make the frameless settings window edge readable"
This reverts commit bbb1be69a2.
2026-11-03 07:15:24 +08:00
arookieofc bbb1be69a2 fix(linux): make the frameless settings window edge readable
#595 follow-up: the 1px --border-window hairline is nearly invisible on
the dark theme (#40434a against a dark desktop), which fuqiuluo confirmed
on Fedora 44 GNOME Wayland after the first edge commit. Blend the
foreground tone into the ring so the boundary reads on every theme;
still CSS-only, since Wayland gives undecorated windows no compositor
shadow to paint outside the surface.
2026-11-03 07:05:16 +08:00
kkmihaiandkkMihai 7af2b098cc feat(terminal): paste clipboard media as file paths (#880)
Pasting an image, audio or video from the clipboard into a local terminal saves the bytes to the per-user terminal cache (20 MB limit, files older than 24 h cleaned up on the next paste, private permissions) and inserts the shell-quoted path without running anything. Copied files paste their original paths, plain text pastes unchanged, and SSH sessions keep the existing remote image-paste route. Paths are quoted per shell (zsh/bash, fish, PowerShell, cmd.exe); fish quoting now escapes backslashes and single quotes.

Fixes #838

Co-authored-by: kkMihai <[email protected]>
2026-11-03 02:09:17 +08:00
Florent Vandflorentvdry db5251efe6 fix(plugins): install plugin packages from a .zip (#896)
Root cause: Install Plugin from Disk only opened a folder picker and the host could only copy folders, so a zipped plugin could not be installed. The picker now accepts a .zip or a package's plugin.toml, and the host's install_from_path unpacks a zip to a temporary folder and runs it through the existing install_from_dir validation. The package may sit at the archive root or in its only folder (archiver metadata such as __MACOSX is ignored). Archives with unsafe paths or symbolic links, more than 10,000 entries or over 1 GB unpacked are refused, and a zip without plugin.toml (e.g. an IntelliJ plugin) gets a clear error.

Fixes #892

Co-authored-by: florentvdry <[email protected]>
2026-11-03 01:44:28 +08:00
arookieofcanda-rookie-of-C-language eb633f6ca6 feat(run): run npm scripts from package.json gutter icons (#898)
Every entry under the top-level `scripts` of a package.json gets a run gutter icon with Run/Debug for that script. It uses the detected npm run configuration (re-detected at click time) and otherwise a temporary configuration built with the same package-manager precedence as backend detection: the `packageManager` field, then the lockfile, inheriting the workspace root's manager for members. Workspace member configuration ids use the full relative directory, as the backend does. The "Show run icons" preference hides these icons too, and the markers are real buttons with translated titles.

Fixes #894

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-03 00:37:14 +08:00
ntbang0901 7c7d656a50 fix(run): select and copy the whole console output with Select All (#903)
Root cause: Cmd/Ctrl+A in the Run console fell through to the browser and selected the whole window, and copying only saw the rows the virtualizer had mounted. Select All now selects the console only (also in its context menu), and copying after it — keyboard, context menu or native copy — takes the whole buffer. copySelectionOnKeyDown accepts a textOf callback for other views that do not mount all their text.

Refs #746

Co-authored-by: ntbang0901 <[email protected]>
2026-11-02 23:41:21 +08:00
Jonas Zuberbuehlerandjonaszuberbuehler b3c0efd804 fix(web): keep the built-in server inside the served root (#887)
Root cause: the built-in web server rejected `..` segments but followed symlinks, so a committed link such as `x -> /` served any file on disk, also in Restricted Mode. The served root is canonicalized per request, and the requested file — and the index.html served for a directory — is refused with 404 when its real path lies outside it. The server opens the checked real path, and Content-Type still follows the requested name. Links inside the project (e.g. pnpm node_modules) keep working.

Fixes #886

Co-authored-by: jonaszuberbuehler <[email protected]>
2026-11-02 23:39:32 +08:00
l0ng-ai c0c56dac8a fix(run): enable Spring console colours with manual classpaths (#888)
Spring Boot runs with a manual classpath (or no classpath placeholder) skip Java classpath resolution and so also skipped the console colour VM option. The shared console VM options are now applied on that launch path too; explicit spring.output.ansi.enabled settings are kept and resolved classpaths get the option once.
2026-11-02 23:37:52 +08:00
Andrea Basileandandreabasile 51988985c5 fix(run): run a Python script with the project interpreter, not the runserver command line (#897)
Root cause: projectPython built the interpreter prefix from the first Python configuration by dropping only the .py arguments, so a Django `manage.py runserver` config left `runserver` in the prefix. The prefix now ends where the program starts (`-m` or the script), so program arguments such as `runserver` and `test` never reach it; `uv run python` is kept. Covers both plain scripts and pytest files derived from the same prefix.

Co-authored-by: andreabasile <[email protected]>
2026-11-02 23:37:47 +08:00
arookieofcanda-rookie-of-C-language ece2b2a59e feat(templates): New Go Module template for go.mod files (#884)
New ▸ Go Module creates go.mod with `module $PROJECT_NAME$` and a `go 1.20` directive. The go.mod language, icon and gopls were already registered; only the file template was missing. It also appears in Search Everywhere and Settings ▸ File and Code Templates.

Fixes #882

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-02 23:37:41 +08:00
l0ng-aiandClaude Opus 5.5 edfe6c7b65 chore: v0.1.34
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 23:11:56 +08:00
d939a19747 feat(run): Django test configurations with results in the test tree (#869)
Django projects get a detected `manage.py test` configuration next to runserver and a Django tests configuration type (command, runner arguments, target, settings module, options). Results stream into the test tree with failures, errors, skips and subtests, failed tests navigate to their source line, Rerun Failed Tests narrows the command to the failed labels, and single tests run from the gutter.

Co-authored-by: andreabasile <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 22:49:04 +08:00
qiaojianlongandClaude Opus 5.5 d981603f3c feat(java): navigate Spring configuration keys and usages (#823)
YAML/properties keys stayed inside the generic YAML language server, so Spring configuration bindings and their consumers were unreachable from the editor. A new command in the bundled JDT LS extension (java-usages 1.2.0) resolves keys to @ConfigurationProperties members (prefixes, nested/inherited members, records, constructor aliases, collections), @Value placeholders and PropertyResolver calls, and back from Java to configuration entries; Find Usages merges configuration consumers and declarations with the native Java results. Several profiles or modules are offered as candidates. Closes #796.

Co-authored-by: qjlqyf <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 22:23:09 +08:00
7fdf1d62c3 fix(refactor): preserve versions and recover multi-file workspace edits (#488)
Multi-file refactorings could drop LSP document versions, overwrite content changed after a preview, or leave earlier files saved after a later write failed. Workspace edits now keep server versions and preview fingerprints, are validated on a staging copy, snapshot disk and open buffers, and roll back on commit failure (retaining recovery files if rollback fails). The whole edit is one Undo/Redo step in editors and in the notification. Version reads and transaction work run off the async workers. Closes #461.

Co-authored-by: shohruh-genius <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 22:18:16 +08:00
4e9442b499 feat(search): find a Spring endpoint by its request path (#820)
Search Everywhere lists Spring Web endpoints matched by request path (context path tolerated) or handler method name, and Enter opens the handler. The host scans Java/Kotlin sources that name a mapping; http-client parses and indexes them and hands the entries to search through an endpoint contribution, so search does not import http-client. The index is invalidated when a Java/Kotlin file changes.

Route source path assertions compare by path component so they hold on Windows.

Co-authored-by: misswell <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 22:18:11 +08:00
arookieofc 58ff3e93d0 fix(linux): paint a hairline edge on the frameless settings window
Linux compositors draw no shadow for undecorated windows (Wayland has
no server-side decorations at all), so the frameless settings window is
indistinguishable from the desktop there — #595's follow-up after the
Windows DWM-shadow fix. Paint a 1px --border-window inset ring around
the window root: the webview draws the same hairline on every
windowing system.
2026-11-02 20:50:29 +08:00
arookieofc 795f8e85f1 fix(windows): de-parent settings window on Windows so DWM paints its shadow
An owned child (WS_POPUP) of a borderless parent gets no DWM shadow,
leaving the frameless settings window flat against its project window.
Ownership is already tracked by the settings-<parent> label (parent-close
cleanup in on_window_event, menu modality), so make the settings window
top-level on Windows; macOS and Linux keep the native parent link.
2026-11-02 20:50:29 +08:00
ntbang0901 8ea8c98a39 feat(maven): reload a stale Maven build after external changes to its files (#780)
When a pom.xml changes outside the IDE (a VCS update, another tool) and the model is stale afterwards, the Maven build now reloads on its own; edits saved from the editor still only show "Load Maven Changes". A change counts as external unless the IDE itself saved that file moments before, so a build file open in a tab that a tool rewrites also reloads.

Co-authored-by: ntbang0901 <[email protected]>
2026-11-02 20:37:03 +08:00
Andrea Basileandandreabasile a623e5d385 feat(python): add ruff language server for lint, format and organize imports (#859)
In projects configured for Ruff (ruff.toml, .ruff.toml, or pyproject.toml with a [tool.ruff] table), ruff server runs next to basedpyright as a managed pip tool: its lint diagnostics, Reformat Code / format on save, Optimize Imports and Code Cleanup's fix-all. It is listed after the type checkers, so hover, navigation and rename stay with them; projects without a Ruff configuration never start or install it. Root markers accept file#table to require a TOML table, and a hand-defined server named ruff now only overrides how the built-in one launches. Verified with the real-servers end-to-end test against ruff and basedpyright.

Co-authored-by: andreabasile <[email protected]>
2026-11-02 20:27:24 +08:00
l0ng-ai e584f044d7 fix(java): say when a configured JDK path no longer exists (#764)
A JDK path chosen in settings that was later removed (a version manager replacing it on upgrade) was reported as "is not a JDK home", which reads as a wrong folder layout. A missing path is now reported as missing, with how to fix it; the project JDK's log warning uses the same wording.

Fixes #741
2026-11-02 20:21:21 +08:00
kevinh df9a9e0be0 feat(terminal): reorder terminal tabs by dragging them (#836)
The Terminal tool window's tab strip uses the drag and drop Tabs already supports for editor tabs, with its own drag type so terminal and editor tabs don't mix; dropping moves the session tab to the insertion point.

Fixes #775

Co-authored-by: kevinh458 <[email protected]>
2026-11-02 20:21:14 +08:00
Bernard Ograh 91c15ff31b refactor(core): share process-tree kill helpers across tool crates (#853)
Five crates (tools, build, vcs, github, docker) each carried their own copy of killing a child and what it started, and only the tools installer killed the whole tree on Windows, so cancelling git, gh, docker or Maven/Gradle there left descendants running. ide-core now provides own_group, kill_tree and terminate_tree (process-group signals on Unix, taskkill /T /F on Windows) and the copies are removed. Unix behaviour is unchanged; the Windows path was not run locally.

Co-authored-by: bograh <[email protected]>
2026-11-02 20:21:07 +08:00
misswellandmisswell f8105e5473 fix(maven): list every goal a plugin declares, not just the bound one (#841)
Plugin goals came from a built-in table plus the goals bound in <executions>, so a plugin outside the table (maven-checkstyle-plugin) showed only its bound goal. The Plugins section now also reads META-INF/maven/plugin.xml from the plugin jar in the local repository (honouring <localRepository> of the settings file in use), cached per jar, with the table as fallback when the jar is not downloaded yet.

Co-authored-by: misswell <[email protected]>
2026-11-02 20:17:37 +08:00
HEOJUNFO 53b5052bbb fix(editor): let the scrollbar thumb drag where overview marks cover it (#807)
The overview lane lies over the vertical scrollbar and its marks take the pointer, so a mark under the thumb swallowed the press; a new or fully changed file is one lane-high mark, so its thumb could not be dragged at all. While the pointer is over the thumb (computed from the scroller's metrics and the 32px minimum thumb) the marks now let the pointer through; elsewhere clicking a mark still jumps to its line.

Fixes #752

Co-authored-by: HEOJUNFO <[email protected]>
2026-11-02 20:17:29 +08:00
kevinh 4ea7d32074 fix(settings): confirm before exiting the IDE by default (#834)
Confirm before exiting the IDE now defaults to on in both the Rust settings and the frontend schema; the exit dialog's "Don't ask again" still turns it off.

Fixes #749

Co-authored-by: kevinh458 <[email protected]>
2026-11-02 20:16:07 +08:00
ntbang0901 502a9dc7dd fix(kotlin): compile a folder of sibling Maven projects in the Kotlin bridge (#842)
compile::plan only fell back to Gradle when the folder had no build file, so a folder of sibling Maven projects failed with "no supported build system" in the Kotlin editor compilation and Build Project. It now falls back to Maven when the model has Maven projects, after the existing Gradle fallback.

Fixes #814

Co-authored-by: ntbang0901 <[email protected]>
2026-11-02 20:15:59 +08:00
misswellandmisswell 78b23df7b5 fix(host): close a project's background processes with it (#852)
Closing a project only cancelled its agents and notebooks; its window's requests, run processes, terminal shells and debug sessions kept running until app exit, and the language server shutdown was a detached task a closing app could drop (orphaned jdtls). Closing now cancels the window's requests, kills its run processes and shells, stops its debug sessions and waits for the language servers to shut down; other windows are untouched. Terminals are keyed by owner window so they can be closed per window.

Co-authored-by: misswell <[email protected]>
2026-11-02 20:15:51 +08:00
kkmihaiandkkMihai 362ae3535c fix(trust): keep LightEdit sessions restricted (#857)
LightEdit created its project as trusted and refresh_project_trust skips light projects, so opening a loose notebook detected Python environments in the file's folder and could run a repository-shipped .venv/bin/python without a trust prompt. LightEdit now starts restricted and stays restricted when folder trust changes, since one session can hold files from unrelated folders; notebooks still open and save, while interpreter discovery, kernel execution and external formatting are refused. Opening the folder as a trusted project enables execution as before.

Fixes #849

Co-authored-by: kkMihai <[email protected]>
2026-11-02 20:15:42 +08:00
kkmihaiandkkMihai f6fbc0e5db fix(run): preserve console output selection (#854)
The console virtualizes its rows, so a selection that scrolled out of the rendered window lost its rows and copying returned only part of it. Selected rows (and, while dragging, the rows between anchor and pointer) now stay mounted, auto-follow pauses while selecting, and each row ends with a line break so multi-line copies keep their newlines. Covered by unit tests and Playwright selection tests.

Fixes #839

Co-authored-by: kkMihai <[email protected]>
2026-11-02 20:13:44 +08:00
kevinh 2a6cc686eb feat(project-view): show Maven and Gradle module folders in bold (#835)
Module folders below the project root are shown in bold in the Project view, from the same build presentation the module icons use; the root and plain folders keep their style.

Fixes #813

Co-authored-by: kevinh458 <[email protected]>
2026-11-02 20:13:31 +08:00
kkmihaiandkkMihai 64b8b5cea9 fix(code-insight): keep usage highlights readable (#855)
Usage previews rendered the matched occurrence through tokenSpans, so the token colour rules outranked the inherited --fg-search-match and left syntax colours on the search-match background (about 1.1:1 in Islands Light). Render the match as plain text so it takes the search-match foreground; syntax colours stay on the surrounding code. Covered by unit tests and a Playwright contrast check across light, dark, high-contrast and mixed editor schemes.

Fixes #851

Co-authored-by: kkMihai <[email protected]>
2026-11-02 20:09:27 +08:00
arookieofcanda-rookie-of-C-language e3f8932e21 test(sdk): match the too-old-JDK path as test_jdk writes it (#840)
The jdtls server-JDK test asserted the "/jdk21" literal, but test_jdk now materializes real fixture directories, so the message carries platform-native separators and the assertion failed on Windows. Match the fixture's own home path instead.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-02 20:07:48 +08:00
l0ng-ai 3be40bd57c fix(java): run the language server on a JDK so the debugger can start (#815)
The managed runtime for the Java language server was a Temurin JRE, which lacks jdk.jdi. java-debug runs inside the jdtls JVM, so Debug failed with NoClassDefFoundError com/sun/jdi/VMDisconnectedException and "debug adapter did not answer initialize in time" while Run worked. Provision a full JDK instead and replace an already-downloaded JRE runtime (Temurin's JRE lists jdk.jdi in release MODULES although the image omits it, so suitability checks IMAGE_TYPE, javac and the modules); auto-picked installed runtimes must also carry jdk.compiler, jdk.jdi and jdk.unsupported, and a user-chosen language-server JDK without them gets a clear warning. Fixes #765.
2026-11-02 15:08:20 +08:00
l0ng-ai 7375f30422 fix(vcs): keep merge editor panes scrollable while they scroll together (#758)
Scrolling a pane set the other two panes' scrollTop; their scroll events arrived a frame later, after the sync guard was cleared, were taken as user scrolls and written back, so the pane being scrolled was pulled back. Sync now maps positions by content and ignores the echoes of its own writes; a pane that cannot scroll no longer drags the others. Next/Previous Difference (F7 / Shift+F7) go through the keymap instead of a fixed key handler. Fixes #744.
2026-11-02 14:58:23 +08:00
ntbang0901 a8e12ff7c4 feat(run): reuse the configuration of a main class run from its context (#783)
Running or debugging a main class from the editor, gutter or Project view now selects an existing saved Application or Spring Boot configuration of the same main class and module, keeping its VM options, environment and profiles, instead of creating a temporary one.

Co-authored-by: ntbang0901 <[email protected]>
2026-11-02 14:57:15 +08:00
ntbang0901 47f68862c8 feat(project-view): restore the persisted expansion state as the user left it (#782)
The Project view restored only expanded paths inside the project roots and always re-expanded the roots. Restore the persisted state as is (a collapsed root stays collapsed, nodes outside it such as content roots stay open); only a first open expands the roots.

Co-authored-by: ntbang0901 <[email protected]>
2026-11-02 14:57:11 +08:00
ntbang0901 193e6a4840 feat(run): colour the console of a Spring Boot run (#781)
Spring Boot configurations get -Dspring.output.ansi.enabled=always: the Run console renders ANSI colours, but Spring only enables them for a terminal and the process runs on pipes. A configuration that sets the property keeps its value.

Co-authored-by: ntbang0901 <[email protected]>
2026-11-02 14:57:07 +08:00
arookieofcanda-rookie-of-C-language 95188d9f40 fix(vcs): fall back to plain blame when git rejects --contents with a revision (#787)
Git before 2.41 refuses `blame --contents - <revision>` ("cannot use --contents with final commit object name"), so blame failed outright on e.g. Ubuntu 22.04's git 2.34. On that error, blame the revision plainly and mark the edited lines as uncommitted from the diff against it, the same path restricted mode already uses. Fixes #755.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-02 14:33:14 +08:00
arookieofcanda-rookie-of-C-language 6184330876 test(tools): compare the oxfmt script path by components on every platform (#786)
package_bin joins the manifest's bin entry (bin/oxfmt) onto a platform path, so on Windows the separators differ from the expected path and the oxfmt formatter test failed. Compare path components instead.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-02 14:33:11 +08:00
l0ng-ai 8e8a998abe feat(vcs): inline blame for the current line (#778)
Show the current line's author, relative date and commit summary as a dimmed hint after the line end, with the full message, date and hash on hover and the commit opened on click. Uncommitted lines read "You, now · Uncommitted changes". Reuses the blame command with a per-version cache and a debounced caret; skipped outside repositories, for untracked/ignored and very large files. Off by default: Settings › Version Control › Git › Show inline blame for the current line. Fixes #742.
2026-11-02 12:11:12 +08:00
misswellandmisswell 743612d4ec fix(java): let jdtls read jars JDK 25 rejects (#771)
jdtls runs on JDK 25, whose ZipFile validates the ZIP64 extra field and rejects jars written by older tools (aspectjweaver 1.8.10: "Invalid CEN header (invalid zip64 extra data field size)"). ECJ then reports the dependency as unreadable, the module gets a build path error and never compiles, and running its main class fails with a missing class. Pass -Djdk.util.zip.disableZip64ExtraFieldValidation=true to jdtls unless the user set the property; set_jvm_property now owns the set-once, user-value-wins rule shared with the proxy properties.

Co-authored-by: misswell <[email protected]>
2026-11-02 12:05:25 +08:00
d0whc3r 44c603f7db feat: add oxfmt/oxlint support for JS and TS projects (#717)
Below an oxfmt configuration (.oxfmtrc.json/.jsonc, oxfmt.config.ts/.mts) Reformat Code runs oxfmt instead of Prettier, with the same external-formatter settings; oxfmt formats whole files, so a selection formats the file with a notice. oxlint --lsp serves JS/TS/React/Vue/Svelte files below an oxlint config. Both are managed npm tools; a missing oxfmt gets the same Install prompt and error handling as Prettier, and formatter messages are translated for either tool.

Co-authored-by: d0whc3r <[email protected]>
2026-11-02 11:22:49 +08:00
l0ng-ai e2f8e8d5bc fix(cpp): explain missing clang-tidy checks with Apple's clangd (#770)
Apple's clangd (/usr/bin/clangd, Xcode and CommandLineTools toolchains) is built without clang-tidy, so on a Mac without a managed clangd the checks from .clang-tidy never appear. When a clangd server for C, C++, Objective-C or Objective-C++ resolves to Apple's binary and the project has a .clang-tidy, show one warning per session with Install (managed LLVM clangd, which already takes precedence once installed) and Language Servers… actions. Fixes #692.
2026-11-02 11:14:27 +08:00
l0ng-ai ff97d7724c fix(ui): give AsciiDoc files their own icon (#763)
AsciiDoc became a recognized language in #731 but .adoc/.asciidoc still used the generic text glyph, failing the icon catalog test that requires every detected language to have a specific icon. Add a dedicated AsciiDoc icon (light/dark) and map both extensions to it.
2026-11-02 10:47:30 +08:00
kkmihaiandkkMihai 239753df9f fix(editor): speed up rapid file opening (#734)
Rapid double-clicks queued their opens one after another and an earlier open finishing late took the active tab and focus back. Each open now carries a sequence/activation generation: only the newest request activates and focuses, older ones still add their tab. Pending file probes are shared, document and syntax loading overlap, and repeated folder double-clicks are handled. A pending navigation selection survives a later plain open of any file; a newer selection still wins. Refs #724.

Co-authored-by: kkMihai <[email protected]>
2026-11-02 10:38:02 +08:00
arookieofcanda-rookie-of-C-language c8cc69a56e feat(language): add D language support (#728)
Register D (.d/.di) with comments, indentation, the built-in D highlighting mode, a file icon, and serve-d as its language server (dub.json/dub.sdl roots). Closes #727.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-02 10:04:03 +08:00
arookieofcanda-rookie-of-C-language c5ff0de1ee fix(terminal): open the file a link names, not a same-named one (#711)
resolvePath only understood POSIX paths, so on Windows the direct lookup never matched and every click fell back to a project-wide search by bare file name, which opened any same-named file. Resolve Windows drive paths and backslashes, and make the fallback query include the link's directory and check the hit's project-relative path ends with it; a bare-name match is used only for links without a directory. Fixes #705.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-02 10:03:59 +08:00
Bl0ck fb55847afa feat(editor): recognize AsciiDoc files with basic highlighting (#731)
Register .adoc/.asciidoc as AsciiDoc in the language catalog (backend registry and in-memory backend) with // line comments, and add a lightweight CodeMirror stream mode for headings, attributes, delimited blocks, emphasis, inline code, URLs and comments. Refs #708.

Co-authored-by: Bl0ck154 <[email protected]>
2026-11-02 10:01:04 +08:00
misswellandmisswell 2653021389 fix(java): pass the IDE proxy to language-server JVMs (#745)
A JVM reads none of HTTP_PROXY/HTTPS_PROXY, so jdtls went direct and a repository that accepts the connection but never answers stalled the Maven import. ProxySettings::jvm_args turns the proxy configuration into JVM system properties (useSystemProxies for Auto, http/https/nonProxyHosts for a manual HTTP proxy, socks* for SOCKS), passed to jdtls as --jvm-arg; a property the user set themselves (matched by exact name) wins. Maven sync keeps Maven's normal snapshot update policy.

Co-authored-by: misswell <[email protected]>
2026-11-02 10:00:32 +08:00
arookieofcanda-rookie-of-C-language e7f00ee7ce fix(tools): pick the platform archive for kotlin-lsp (#726)
kotlin-lsp releases renamed their archives to kotlin-server-*, so no rule matched and the loose kotlin-*.zip fallback picked the Windows zip on every OS. Match the current names per platform: .sit (a zip archive) on macOS, .tar.gz on Linux, .win.zip on Windows; extract .sit as zip; drop the loose fallback.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-02 09:59:54 +08:00
Jasmeet Singhandjasi381 4015a5b71f fix(maven): read Maven 4 <subprojects> as modules (#700)
Maven 4 (model 4.1.0) declares child projects in <subprojects>/<subproject> instead of <modules>/<module>. The POM reader only read <modules>, so the children of a Maven 4 aggregator were missing. Projects and profiles now read both.

Co-authored-by: jasi381 <[email protected]>
2026-11-01 21:46:36 +08:00
Dimas Victoranddvictor357 88619505c3 feat(terminal): paste clipboard images into remote SSH terminals (#699)
In a remote-project window, pasting a clipboard image into the terminal uploads it to the SSH host that owns the terminal (new term_store_image command over the existing remote RPC) and inserts the POSIX-quoted path after a successful upload, without a newline. Images go to a private per-user terminal-images directory (0700/0600, UUID names, no-follow descriptor-relative writes) and expire after 24 hours on the next upload. Failures notify and insert nothing; text paste and local terminals are unchanged. Images over 20 MiB are rejected in both the frontend and the host.

Co-authored-by: dvictor357 <[email protected]>
2026-11-01 21:42:15 +08:00
Jasmeet Singhandjasi381 0d0de01831 fix(java): re-sync jdtls after a Gradle sync so new dependencies resolve (#694)
After a Gradle sync, build_sync did not send java/projectConfigurationsUpdate to jdtls (only the Maven path did), so jdtls kept its cached classpath and dependencies newly added to build.gradle stayed unresolved. The update is now sent for both Maven and Gradle, listing each project's build file.

Co-authored-by: jasi381 <[email protected]>
2026-11-01 21:03:17 +08:00
arookieofcanda-rookie-of-C-language 9ce3c4cd7e test(inspections): expect the Cargo group (#695)
The dependency-update inspection added a Cargo built-in group; the inspections tree test still expected the previous group list.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-01 20:46:03 +08:00
arookieofcandl0ng-ai 74062497bd fix(core): read the Windows user PATH past REG_EXPAND_SZ quirks (#690)
* fix(core): read the Windows user PATH past REG_EXPAND_SZ quirks

`RegGetValueW` reports the unexpanded size of a `REG_EXPAND_SZ` value, so
a user `Path` that grows when its `%VAR%` references are expanded was
silently dropped. And when the terminating NUL leaked into the read
buffer with a stray UTF-16 unit behind it, `trim_end_matches('\0')`
left that unit in the string and `env::set_var("PATH", ...)` panicked
("strings passed to WinAPI cannot contain NULs", #683).

Re-read at the size the failed call reports, and cut the decoded string
at the first NUL instead of trimming trailing ones. Covered by a test
against a throwaway key under HKCU\Software.

* test: cover registry terminator corruption and refresh inspection groups

---------

Co-authored-by: l0ng-ai <[email protected]>
2026-11-01 20:31:32 +08:00
l0ng-ai 8d7ce81249 fix(macos): open project windows as tabs per the system tab preference (#693)
Project windows only joined a tab group when merged explicitly or restored at startup; the macOS "Prefer tabs when opening documents" setting was never read, so opening another project or a workspace always created a separate window.

New project windows now follow that preference: with Always they join the active project window's tab group (forming one if needed) and are selected; with In Full Screen they do so only while the active window is full screen; with Manually they stay separate. This covers every path that creates a project window — Open, recent projects, workspaces, drag and drop, and opening from the command line or Finder — while LightEdit windows and restored groups keep their behaviour. New Workspace now opens through the same Open project in policy as Open Workspace, and a race when toggling full screen during the join is fixed.

Fixes #678
2026-11-01 20:14:08 +08:00
l0ng-ai b3abc8edd1 fix(remote): browse remote directories without an open project (#691)
Browse in the Remote Development dialog failed with "no project is open in this window" on the welcome screen, and in untrusted project windows: remote_bootstrap_list was not classified in the restricted-mode allowlist, so IPC authorization required a trusted project before the command reached SSH. The listing itself already used the selected SSH configuration and never depended on a project.

remote_bootstrap_list and deploy_test_connection are now allowed alongside the other application-level remote commands; both only connect or list remote paths and run no project code. Every other command used by the dialog and the welcome screen's remote entries was checked and is already classified.

Fixes #672
2026-11-01 20:09:08 +08:00
l0ng-ai 245fe3cdf4 feat(run): .NET project run and debug configurations (#686)
.NET projects had no run configurations.

Projects from .csproj files (and those listed in .sln/.slnx) now get configurations: executable projects (OutputType Exe/WinExe, or the Web SDK) get a .NET Project configuration running dotnet run, one per Project launch profile in Properties/launchSettings.json with its application URL and environment shown; test projects (Microsoft.NET.Test.Sdk) get dotnet test. The form edits project, target framework, launch profile, program arguments and environment, which override the profile's values. Debug builds the project, resolves the output assembly through MSBuild and launches it under netcoredbg, offering to install netcoredbg when missing. The source-text catalog test now ignores Rust character literals when collecting backend strings.

Part of #615
2026-11-01 19:34:31 +08:00
l0ng-ai b5dbd3c020 feat(cargo): dependency version inspections and updates (#684)
Cargo.toml had no way to tell that a dependency had a newer release.

Dependencies in [dependencies], [dev-dependencies], [build-dependencies], target tables and [workspace.dependencies] now get a weak warning when the newest stable release is outside the version requirement, with a quick fix that rewrites the requirement at its original precision ("0.7" → "0.10") in both string and inline-table forms. Versions complete inside the version string, newest first, without yanked releases. Path, git and workspace-inherited dependencies are skipped, prereleases are only suggested to prerelease requirements, and failures stay silent.

Versions come from the crates.io sparse index through the backend's HTTP client, with an in-memory and on-disk cache and coalesced requests, behind a dependency version source shared by file-type parsers so other manifests can be added later. semver is now used directly; it was already linked into the app.

Fixes #479
2026-11-01 19:30:28 +08:00
l0ng-ai 4034853f91 fix(windows): keep maximized windows within the work area (#644)
* fix(windows): keep maximized windows within the work area

* fix(windows): constrain final maximized outer placement

* Revert "fix(windows): constrain final maximized outer placement"

This reverts commit e481c900ca.
2026-11-01 19:21:20 +08:00
l0ng-ai 93328d7895 Store rebuildable Windows data in Local (#652)
* fix: store rebuildable Windows data in Local

* fix(windows): preserve relocated tool wrappers and local tool consumers
2026-11-01 19:18:37 +08:00
l0ng-ai b9d3ce7723 fix(java): align debug JVM encoding with Run (#674) 2026-11-01 19:16:25 +08:00
l0ng-ai be7e2ff710 fix(debugger): keep execution highlights synchronized (#681)
Inline debugger values looked up source identifiers in a plain object, so a name like toString on a nearby line resolved to an inherited Object.prototype member and crashed the editor's inline-values plugin. The execution-point subscription lived in that plugin, so after the crash the highlighted line stayed on the old position while the stack had moved on, and resume or stop did not clear it.

Locals are now kept in a prototype-free dictionary and looked up by own property, and the execution point is synced by its own plugin so an inline-value failure can't freeze it; hidden editors resync when remounted. Stepping, resume, run to cursor, reset frame and smart step clear the old execution point as soon as the request is sent, stale stack/locals responses from a previous stop are ignored, and another thread's continued event no longer clears the selected thread's execution point.

Fixes #668
2026-11-01 19:04:04 +08:00
l0ng-ai 210f9a78ac feat(run): project CMake profiles (#679)
CMake run configurations used fixed arguments (-B build, Debug) and only recognised existing build directories.

Projects can now define CMake profiles under Settings › Build, Execution, Deployment › CMake: build type, generator, CMake arguments, build directory (default cmake-build-<type>), build arguments and environment. Each profile gets its own Configure, Build, CTest and target run configurations, named after the profile when there are several. Configure runs first only while the build directory has no CMakeCache.txt, and a generator change clears the stale cache before configuring. Projects without profiles keep the previous detection. Arguments are stored as lists and edited with the same quoting rules as program arguments. Toolchain selection is not part of this change.

Refs #655
2026-11-01 19:02:00 +08:00
l0ng-ai 49f9c57bcc docs: remove the README build section 2026-11-01 18:46:40 +08:00
l0ng-ai 07609ef9c6 chore: v0.1.33 2026-11-01 18:35:25 +08:00
l0ng-ai bf2427bd3a feat(java): selectable Java formatting profiles (#680)
Java formatting comes from jdtls' Eclipse formatter, whose defaults differ from the IntelliJ IDEA code style. Settings › Editor › Code Style › Java now offers a formatting profile: Default (unchanged), IntelliJ IDEA, or a custom Eclipse formatter XML (absolute or project-relative path).

The IntelliJ IDEA profile is a bundled Eclipse formatter XML that follows IDEA's default Java code style item by item (indents, aligned multi-line parameters, annotation placement, else/catch/finally on the same line, array initializers, lambdas, blank lines, comments not reflowed). Manual line breaks are kept; lines longer than 120 columns wrap where necessary, which differs from IDEA's default of not wrapping. The profile reaches a running jdtls through didChangeConfiguration without a restart; an unreadable custom file shows a warning and leaves code intelligence running. Code style changes reconfigure language servers without scheduling Kotlin bridge builds.

Also drops two catalog entries whose texts are no longer used.

Fixes #649
2026-11-01 18:26:24 +08:00
fuqiuluo 041e72106b fix(linux): restore window geometry after GTK mapping (#619)
On Linux the window-state plugin restored the saved size before GTK's decorations and output scale had settled, so the saved physical inner size went through the creation-time CSD/scale conversion and drifted on every restart (52 px per axis under Mutter, the remembered size collapsing on other setups), and maximize transitions overwrote the remembered normal size.

Linux now builds project and settings windows hidden and restores them after the first configure, keeping the normal size apart from maximize, fullscreen and minimize, protecting it during transitions, and snapshotting each window before close instead of querying GTK during exit. The state file format is unchanged; macOS and Windows keep the plugin as before.

Verified under Mutter and Weston: three restarts at 1234×678, three maximized restarts followed by unmaximize, the settings window, several project windows across a full exit, and window groups all restore exactly. macOS restore is unchanged. Not run: GNOME on real hardware, mixed-DPI monitors, X11.

Fixes #594
Fixes #636
2026-11-01 18:05:05 +08:00
l0ng-ai aa2c24c648 fix(agents): make tool window sessions usable in narrow layouts 2026-11-01 18:00:54 +08:00
arookieofcanda-rookie-of-C-language 3a87c697b2 fix(ide-tools): move run_input above the tests module (#677)
clippy::items_after_test_module failed the workspace clippy run because run_input was defined after the first test module in crates/ide-tools/src/process.rs. Move it above; no code changes.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-01 17:48:38 +08:00
l0ng-ai 87bf89d688 feat(debugger): add paused editor evaluation entry points (#676)
While a debug session is paused, Alt/Option+click on a variable or expression runs Quick Evaluate at the clicked position (the selection when the click falls inside it) and shows the value next to it; outside a paused session Alt+click keeps its editor behaviour. The editor context menu offers Evaluate Expression… seeded with the selection or the expression at the caret while paused.

Mouse shortcuts now pass the clicked editor and position to the action, and the binding is only consumed when its action is enabled.

Fixes #664
2026-11-01 17:19:00 +08:00
l0ng-ai f6d7facd82 fix(window): stop macOS traffic lights jittering and fix local open from remote windows (#641)
AppKit puts the title bar container and window buttons back at their defaults on every live resize step, title change and fullscreen exit. They were corrected from window notifications after that layout had been drawn, so the buttons bounced between positions, or stayed at the defaults when no notification followed. The frames are now corrected in a main run loop observer ordered just before the Core Animation commit, project windows are created hidden on macOS so the buttons are placed before the first frame, and repeated title bar height updates are skipped.

Choosing This Window for a local project in a remote window was routed to the remote host or skipped when the path matched the remote root. open_project and light_edit_open now run on the local backend and leave remote mode once they succeed, project trust for these opens is checked locally, and a failed open cancels the pending close.
2026-11-01 16:58:49 +08:00
l0ng-ai ec5193f827 feat(format): external formatting with managed Prettier (#671)
Reformat Code only asked the language server for formatting, so files whose server has no formatting capability (Markdown with marksman, and others depending on the server) could not be formatted.

Languages can now declare an external formatter. Prettier is the first: a managed tool installed on demand, or the project's own node_modules/prettier, an explicit path, or PATH. It runs on the backend copy of the document with the file's path so Prettier resolves the project's config, overrides and ignore files, and its result is applied as one undoable edit. Language servers that format a file keep doing so unless Prettier is preferred in Settings › Languages › Prettier; formatting on save is opt-in. Parsers without range support format the whole file and say so.

Errors carry structured tool data, so a missing Prettier reuses the shared install prompt and Prettier diagnostics or timeouts show as non-sticky notifications.

Fixes #639
2026-11-01 16:54:31 +08:00
l0ng-ai 9bb44cbeb3 feat(java): resolve compiled Kotlin APIs in Maven Java editors (#669)
Java files in mixed Maven projects could not resolve Kotlin types from the same module or upstream reactor modules, because Kotlin compiler output never reached JDT's Java model.

A per-project bridge compiles saved Kotlin sources with the project's own Maven setup into private staging output (a bundled Maven extension redirects outputs and records each module's Kotlin compile result) and mounts the Kotlin classes as marked libraries through a bundled JDT command, leaving m2e/JRE containers and other entries untouched. The last successful snapshot stays mounted while rebuilding; a new one is published when every Kotlin target compiles, even if javac fails, so Java callers of a renamed or removed Kotlin API show their errors in the editor. Kotlin compile failures keep the previous API and are reported under the kotlin-bridge Problems source. Removing the last Kotlin file withdraws the libraries. Changes under build outputs and excluded directories never trigger a rebuild.

Part of #428
2026-11-01 16:33:44 +08:00
arookieofcanda-rookie-of-C-language 8d38aa4a9c feat(editor): navigate to definition on a plain middle-button click (#666)
The editor's mousedown handler ignored every button except the left one, so a middle click fell through to the webview's autoscroll. A plain middle click now moves the caret to the clicked symbol and runs go-to-definition, suppressing autoscroll; a keymap shortcut bound to Button2 still takes precedence, and modifier + middle combinations stay unbound.

Fixes #663

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-01 16:17:18 +08:00
arookieofcanda-rookie-of-C-language 683f8d8826 test(jdtls): compare resolved source paths as paths, not strings (#665)
java_class_file joins the package path with std::path, so on Windows the result uses backslashes while the test's expectation kept the literal forward slash from "p/Outer.java". Compare as Path so separator style does not matter.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-01 16:16:14 +08:00
l0ng-ai f19eacc336 fix(java): skip unchanged Maven launch builds (#667)
Run/Debug of a Maven module with Kotlin/Groovy/Scala sources rebuilt the module and its reactor dependencies with Maven before every launch, even when nothing had changed.

Each successful launch build now records a fingerprint of its inputs (sources and resource roots of the dependency closure, module and parent POMs, imported BOMs, wrapper/.mvn/settings/toolchains, JDK, command line and environment) together with an immutable snapshot of its outputs in the project cache. A later launch with the same fingerprint reuses the snapshot without running Maven; Run and Debug share it, application and test launches are kept apart. Inputs that cannot be resolved reliably (dynamic roots overridden by profiles or -D) always rebuild. A failed, cancelled or concurrently edited build never publishes a reusable record.

Superseded snapshots stay available while JVMs of the current session may still load classes and are removed when the project closes; snapshots left by earlier sessions and no longer referenced are cleaned on the first launch.

Fixes #530
2026-11-01 16:08:16 +08:00
l0ng-ai d3e63d8ff0 feat(search): select and open multiple search results (#670)
Search Everywhere and Recent Files lists now support multi-selection: Shift+Up/Down and Shift+click extend a range from the anchor, Cmd/Ctrl+click toggles a row, and Enter opens every selected file, symbol or text result in list order, leaving focus on the lead row. Actions in a mixed selection are skipped; a single selection behaves as before. Selection resets when the query or tab changes.

The row-selection model used by Find in Files moves to src/ui so the shared List can opt into modifier selection.

Fixes #647
2026-11-01 16:05:16 +08:00
arookieofcanda-rookie-of-C-language 5c0a5239fa feat(appearance): fallback font for the custom UI font (#660)
Adds a UI fallback font under Settings › Appearance › Use custom font, so glyphs missing from the chosen UI font (e.g. CJK in Menlo) render in a font the user picks instead of the system default. The fallback joins the UI font stack used by menus, the project tree and dialogs, and resolves macOS user fonts through the shared font loader.

Also keeps the custom font controls wrapping together at narrow widths and covers save/clear/disable, stack composition and settings round-trip in tests.

Fixes #654

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-01 15:53:24 +08:00
arookieofcanda-rookie-of-C-language f8d6f9df77 fix(editor): Shift+F6 renames the file when no language server covers it (#653)
In files no language server covers (Markdown, .properties, plain text), Shift+F6 only showed a transient "no running language server supports rename" hint at the caret, so the default rename shortcut looked broken.

Root cause: when prepare_rename reported no server and the syntax fallback declined, startRename fell through to the error hint. It now hands off to project.rename, which opens the file rename dialog for the active file; a running server that lacks rename keeps its hint.

Verified on macOS: Shift+F6 in .md and .properties editors renames the file, symbol rename in TypeScript is unchanged, and Shift+F6 in the project view still renames the selected file.

Fixes #651

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-01 15:45:35 +08:00
l0ng-ai 0c54266d03 fix(java): build mixed-language Maven dependencies before Java launch (#659)
Run/Debug of a Java-only module that depends on a Kotlin/Groovy/Scala module in the same reactor launched with stale classes or failed with Unresolved compilation problem, and a successful Maven build was still reported with the old JDT compile errors.

Root cause: the pre-launch external build only checked the launch module's own sources, and Maven shared target/classes with JDT autobuild.

Fix: walk the launch module's reactor dependency closure (transitive, test scope aware) for non-Java sources; when found, run Maven from the reactor root (-pl <module> -am clean compile, test-compile for test launches) and block the launch on failure or cancel with the Maven errors in the console. JDT autobuild is paused while Maven runs and restored afterwards, and the launch uses a per-launch snapshot of the closure's output so JDT cannot overwrite it. A successful Maven build no longer shows the stale JDT warning. Pure Java projects and Gradle keep their previous path; the debugger adapter choice still follows the launch module.

Verified on macOS with single- and multi-module Maven projects (new Kotlin values in Run and Debug, Kotlin error blocks the launch, cancel, pure Java does not invoke Maven). Editor type resolution for Kotlin in Java is handled separately.

Part of #428
2026-11-01 15:18:53 +08:00
l0ng-ai 1716f3e062 feat(diff): compare directories and synchronize entries (#661)
Two folders can now be compared from the project view (Compare Directories with two folders selected, or Compare With… on one folder, also in Find Action). The result tree marks entries as only on left, only on right, contents differ or identical, with a name filter and a show-identical toggle; double-click opens the existing diff editor, and files or whole subdirectories can be copied to either side (overwrite and delete ask first, extra files in the target are kept). Comparison runs in the background with progress and cancel, honours the project's exclusions, and works for remote projects through the same file commands.

The comparison tree and diff preview are shared with the Git folder/revision comparison.

Verified on macOS (subdirectories, Chinese names, binary files, copy both ways, delete, cancel on large files); Windows and Linux not run.

Fixes #520
2026-11-01 15:14:13 +08:00
l0ng-ai 482241ebb9 feat(java): open class files as decompiled sources with breakpoints (#657)
Opening a .class file showed binary content. Class files from library jars, project output directories, navigation, Go to Class and debugger frames now all go through one route to JDT class documents:

- Library classes open the attached source, or the FernFlower decompilation when there is none, read-only and marked as decompiled. java-debug's decompiled-source support is enabled, so breakpoints set in decompiled code map to the original lines and hit, and stepping into such classes lands on the right line.
- Output-directory classes (target/classes, build/classes) open their project source when it exists; otherwise the decompiled text is shown read-only with breakpoints disabled, because jdt.ls returns no line mapping for classes outside the Java model.
- The preferred content provider id is corrected to fernflowerContentProvider (attached sources still win), and the decompiled marker recognises the current FernFlower header.

Verified on macOS with a Maven project: library class from Libraries and Go to Class, breakpoint hit and step into decompiled code, output class with and without source. Not run on Windows, Linux or Gradle projects.

Fixes #394
2026-11-01 15:04:28 +08:00
l0ng-ai 7c72411dd7 fix(trust): allow application-level commands in restricted mode (#648)
Help › About showed no version, build or runtime in untrusted project windows and on the welcome screen.

Root cause: the restricted-mode allowlist added with project trust missed app_build_info and other application-level commands, so they were rejected wherever the window had no trusted project; the frontend then cached the failed build info for the whole session.

Fix: classify all registered commands and allow the 24 that only read application information or change the user's own application state (build info, log location, update checks, cache invalidation, file clipboard, JDK download, Docker settings, database export paths, patch export, system environment); JDK download now uses the application tool directory instead of project tool settings. Execution commands (run, build, debug, language servers, tool install from project settings) stay blocked. Build info is retried after a failure instead of being cached. Verified on macOS in an untrusted project and on the welcome screen; Windows not run.

Related to #519
2026-11-01 13:45:25 +08:00
l0ng-ai 376501a5fe fix(properties): keep existing Unicode escapes when saving with native-to-ascii (#650)
With Transparent native-to-ascii conversion on, pasting or typing existing \uXXXX escapes into a .properties file and saving wrote them back as \u005CuXXXX, so the file on disk changed meaning.

Root cause: the shared properties encoder escaped the backslash of any valid ASCII Unicode escape. It now writes ASCII escapes as they are and only converts non-ASCII characters; a literal backslash right before a non-ASCII character is still protected. The same encoder serves every write path, so normal/auto save, find and replace in open and closed files, workspace edits, Local History restore and partial Git stage/commit are all covered.

Verified on macOS following the reporter's steps (paste escapes, save, reload externally); Windows and Linux not run.

Fixes #519
2026-11-01 13:42:28 +08:00
l0ng-ai 21925b7f4b fix(terminal): activate virtualenvs using the resolved terminal shell (#646)
New terminals typed the virtualenv activation command for the wrong shell: on Windows the default terminal is PowerShell, but the activation line was chosen from $COMSPEC (cmd), so PowerShell just echoed "...\activate.bat" and the project interpreter was not activated.

Root cause: terminal_activation guessed the shell from $SHELL / $COMSPEC instead of the shell the PTY actually starts. Same class on macOS/Linux when the inherited $SHELL differs from the login shell the terminal uses, and for a blank configured shell.

Fix: the host resolves the shell once with the terminal's own resolve_shell and uses it both to start the PTY and to pick the activation command; terminal_activation takes the resolved shell and no longer reads the environment. Tests cover PowerShell/cmd paths, blank settings and a host-level run with an unrelated $SHELL. Verified in the app on macOS (default zsh and configured bash); not run on Windows.

Fixes #631
2026-11-01 12:40:03 +08:00
l0ng-ai 1e898367a5 fix(fonts): render unavailable macOS user fonts through binary IPC (#643)
On macOS versions where WKWebView refuses user-installed fonts by family name (fonts in ~/Library/Fonts or /Library/Fonts), the editor, terminal, Markdown and UI fell back to the default font and Nerd Font icons showed as boxes; only a webfont loaded from the file's bytes renders.

Root cause: the shared font resolver only ever referred to fonts by local family name, so every consumer depended on the WebView resolving user fonts by name. A URL-based webfont is blocked by the existing font-src policy, which stays unchanged.

Fix: the shared resolver probes whether a family renders by name (on a document-attached canvas; a detached canvas falls back even when the font works) and only when it does not, fetches the enumerated user font files over a raw-bytes IPC (user fonts only, looked up by family, no paths accepted) and registers them with the FontFace API under a stable alias. Only the faces nearest regular/bold and their italics are loaded, once per family. Covers editor and editor fallback, diff/merge views, terminal font, fallback and built-in Nerd fallback (re-measured after loading), embedded terminal, settings preview, custom UI font, Markdown and Mermaid. A debug-only environment switch reproduces the WebView restriction for testing.

Verified on macOS 26 with the restriction simulated (icons boxes before, correct after) and without it (no font bytes transferred, no regression). Not run on the reporter's macOS version, Windows or Linux; other platforms keep the previous behaviour.

Fixes #592
2026-11-01 12:23:08 +08:00
l0ng-ai 7a58d9f253 fix(java): bundle Lombok agent with Equinox config lookup fix (#642)
lombok.config was ignored in Java builds (e.g. lombok.accessors.chain=true produced void setters), and jdt.ls wrote those classes into target/classes, breaking later Maven builds.

Root cause: Lombok calls getModuleClassLoader(boolean), which newer Equinox removed; the exception is swallowed, Lombok resolves the wrong source path and finds no lombok.config at all, so every config key falls back to its default. Affects every Java project using lombok.config.

Fix: ship Lombok 1.18.48 with the upstream fix projectlombok/lombok#4059 (lombok-1.18.48-openidea.1, embedded in the app, no download) as the jdt.ls agent; the versioned path also resets the jdt.ls workspace so stale output is rebuilt. A user-configured agent still takes precedence. Return to Maven Central once an official release includes the fix (source and checksum in plugins/lombok/README.md).

Verified on macOS: 5 clean builds with chained setters, identical output to official 1.18.48 without lombok.config, the app rebuilds already-poisoned target/classes, and Run works. Windows and Linux not run.

Fixes #565
2026-11-01 12:08:44 +08:00
arookieofcanda-rookie-of-C-language ad80e903e3 feat(settings): add "Show tool window badges" (#637)
Counts on the tool window stripe icons (changed files on Commit, problems, agent tasks needing input, running processes) can be turned off in Settings › Appearance & Behavior › Appearance › Tool Windows. On by default; the setting is stored in appearance settings and survives restarts.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-01 11:38:26 +08:00
arookieofcanda-rookie-of-C-language 9847fd2c01 feat(agents): dock a session in the Agents tool window (#588)
Agent sessions can be opened in the Agents tool window beside the editor (Open in Tool Window / Open in Editor in the task menus, or by dragging between the editor and the tool window); opening in an editor tab stays the default.

Fixes in review:
- A docked conversation kept the previous task's subagent view after switching tasks: the session's local state is now keyed by task.
- Moving a conversation between the editor and the tool window lost its scroll position: position and follow-output are kept per conversation (task or subagent).
- In a short bottom tool window the message area shrank to a few pixels: wide windows place the task list beside the conversation, narrow ones cap the list height.
- Drag and drop and list selection now go through the agents.dockTask / undockTask / openTask actions.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-01 11:31:53 +08:00
arookieofcanda-rookie-of-C-language fa26ba842c fix(tests): update code action tests for the interactive apply path (#638)
applyCodeAction now resolves the picked action and executes it (lspResolveCodeAction → lspExecuteCodeAction) instead of calling lspApplyCodeAction, but refactorThis.test.ts and runKind.test.ts still mocked and asserted the old command, so main's frontend tests failed (4 tests). The mocks now follow the new commands; safeDelete.test.ts's negative assertion still holds.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-01 11:16:44 +08:00
l0ng-ai 16033c481b Revert "fix(editor): pause expensive decorations during fast scroll bursts (#401)" (#635)
This reverts commit c938d712b5.
2026-11-01 10:30:09 +08:00
l0ng-ai 2c3eabb786 feat(go): support interactive Generate actions (#632)
Go's test generation and interface stubs were reachable only through context actions, because Generate filtered their titles, and implementing an interface on a plain struct wasn't available since the client didn't support gopls's interactive commands.

Generate and the editor's context menu now list `Add test for ...`, `Declare missing methods ...` and `Add methods to ... implement ...`, and a generated test file opens in the editor. For gopls only, the client advertises `interactiveResolve` and runs its `command/resolve` handshake: a prompt titled from the server asks for the interface, shows validation errors in place, and Cancel writes nothing. Requests are cancellable and other servers and existing commands are unchanged.

Fixes #620
2026-11-01 10:27:36 +08:00
arookieofcandl0ng-ai c938d712b5 fix(editor): pause expensive decorations during fast scroll bursts (#401)
* fix(editor): pause expensive decorations during fast scroll bursts

Add a scrollThrottle plugin that flags the editor with a .cm-scrolling
class while the wheel is moving. While the flag is up the sticky-scroll
header and indent guides are hidden via theme CSS, and their view
plugins skip per-viewport re-measures/rebuilds; a trailing scrollSettled
effect re-runs them once the wheel rests for 140ms. This keeps fast
mouse-wheel scrolling from painting rows of stale decorations over
freshly rendered code.

Fixes l0ng-ai/openidea#375

* fix(editor): keep decorations painted during scroll bursts

The scroll throttle hid the sticky header and indent guides entirely while
scrolling, which flickered on every burst - and the sticky header is exactly
what you want to see while scrolling. Drop the display/background hiding from
the theme and keep the previous paint instead: the plugins skip re-measuring
during a burst and catch up once on the trailing scrollSettled update.

* fix(editor): throttle the first scroll event and queued sticky measurements

---------

Co-authored-by: l0ng-ai <[email protected]>
2026-11-01 10:09:47 +08:00
l0ng-ai 7cf4f7948d fix(run): pass plain Windows paths to Tomcat (#608) 2026-11-01 10:09:40 +08:00
l0ng-ai b281447da8 fix(search): mark outdated find results and block stale replacements (#630)
While a new query was debouncing or validating, Find and Replace in Files kept showing the previous query's rows, count and highlights, and Replace stayed enabled. Replace, Replace All in This File and Replace All then sent the new pattern with the old positions, so `order` changed to `ord` could rewrite `order` as `Xer`.

From the moment query, flags, mask, scope or context change, old rows are dimmed and lose their highlights, replacement preview and count until the new search's first results arrive. All replace buttons and keyboard paths are blocked meanwhile, and a pending Replace All confirmation rechecks that its results are still current. Opening a retained row still works.

Refs #623
2026-11-01 09:55:43 +08:00
arookieofcanda-rookie-of-C-language 1e913c2334 fix(languages): highlight .svelte files via the HTML ui mode (#627)
`.svelte` files got highlighting only from semantic tokens, because the language had no `ui-mode` and no CodeMirror language loaded. It now uses the HTML mode, which parses `<script lang="ts">` as TypeScript and `<style>` as CSS. Svelte blocks and markup expressions stay plain text.

Closes #626

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-01 09:51:22 +08:00
l0ng-ai 52cb592692 fix(clipboard): read the macOS pasteboard on the main thread (#629)
Clipboard file-list reads and writes ran on background threads, while AppKit and WebKit use the general pasteboard on the main thread. Its type cache isn't safe against that, so the app crashed with a segfault or heap corruption in `-[NSPasteboard _updateTypeCacheIfNeeded]`. Rechecking the clipboard on window focus made this frequent, sometimes within seconds of opening a project.

On macOS these commands now run on the main thread and await the result without blocking a command thread; other platforms are unchanged. `ide-clipboard` documents and debug-asserts the requirement. A concurrent read stress test that crashed main within a second completed about 650,000 reads over four minutes with no crash.
2026-11-01 09:44:41 +08:00
l0ng-ai bbaf2c89a0 fix(search): keep Find and Replace in Files footer actions inside the dialog (#628)
The Replace in Files footer has more actions than fit beside its keyboard hint, so buttons shrank below their labels, overlapped, and ran past the dialog edge. Buttons and the Preview checkbox now keep their size, the hint truncates first, and the actions wrap to a second row aligned right. The results and preview areas shrink when the footer wraps or the window is short. Find in Files shares the footer.
2026-11-01 09:29:42 +08:00
Ning YuanLin 6456f0e404 feat(java): add read/write filters for Java field usages (#569)
Find Usages mixed reads and writes of a Java field, including Lombok getter and setter calls, because jdtls has no read/write classification in its references. The usages popup and Find window now have Read and Write toggles. Read-write accesses stay visible in either mode and unclassified results are kept.

Classification comes from a jdtls extension in `plugins/java-usages`, embedded as a jar and installed beside the other bundles. The checked-in jar was rebuilt from source with ECJ and matches byte for byte. Any extension failure falls back to standard references, so a jdtls upgrade can lose the filter but not Find Usages; rebuilding the jar on an upgrade is manual and described in the plugin README. Older installed jars are removed. Other languages keep their plain result count.

Co-authored-by: NingYuanLin <[email protected]>
2026-11-01 09:09:15 +08:00
l0ng-ai c3d569d3e3 feat(run): update Spring Boot resources and application policies (#603)
Running Spring Boot main-class configurations had no Update action, and incremental Java builds did not publish edited resources to the running JVM. They now share the Update action, toolbar and build runner with Tomcat: resources go through the module's Maven or Gradle build and are published to the resolved runtime classpath, including Gradle's separate jdt.ls output directory. Class updates use scoped HotSwap when available and explain the restart requirement otherwise.

Both configuration types get "On 'Update' action" and "On frame deactivation" policies. A missing policy keeps the chooser and does no deactivation update. Focus tracking debounces external changes and prevents overlapping updates.

Verified on macOS with Maven, Gradle, Tomcat 10.1 and HotSwap. Windows and Linux focus detection were not run. On Linux a native GTK dialog can count as deactivation when no webview window is focused.

Fixes #580
2026-11-01 08:35:18 +08:00
l0ng-ai 0458424f9c fix(project): expire stale file clipboard after OS replacement (#598)
Project Paste could reuse an earlier file copy after another app replaced the OS clipboard, and Cut files stayed dimmed. The tree now tracks the hand-off to the OS clipboard and rechecks it on window focus and just before Paste. A local Copy or Cut is cleared once the OS holds a different or empty file list; failed writes and clipboard read errors keep it. The native adapters now distinguish read failures from empty lists.

In remote windows, Copy and Cut write newline-joined server paths as plain text, tree Paste keeps the server source, and a later OS file list replaces the remote copy and imports those client files. A plain-text replacement stays undetectable there without a clipboard read that may prompt.

Verified on macOS, locally and against a remote host. The Windows clipboard code was not run.
2026-11-01 08:32:33 +08:00
arookieofcanda-rookie-of-C-language 4395975664 fix(terminal): paste dropped file paths at the prompt instead of opening them (#605)
Dropping a file on a terminal fell through to the window-wide drop, which opened it in the editor or asked to open a folder as a project. It now pastes the shell-quoted paths at the prompt, separated by spaces and with no newline, so nothing runs until the user presses Enter.

Each terminal, including the Run console, accepts only drops inside its own host. Windows paths use double quotes. Remote windows keep the existing drop, since a local path means nothing on the remote host. Verified on macOS with a simulated drop; a native Finder drag and Windows quoting were not run.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-01 08:30:50 +08:00
arookieofcanda-rookie-of-C-language 0bf597c2fd fix(search): make the search popups draggable (#625)
Find in Files, Replace in Files and Search Everywhere have no window title bar, so they could not be moved off the editor underneath. Dialogs can now be dragged from any element marked `data-dialog-drag`: the headers of those search popups, the title of any titled popup, and the existing window title bars and alert bodies, which now use the same mark. Controls inside a handle never start a drag.

Also waits for a review thread inlay in a test where it can mount a tick later.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-01 08:13:15 +08:00
arookieofcanda-rookie-of-C-language 95e79116d1 feat(project-tree): show a file's members below it (#624)
Finding a declaration in a file meant opening the Structure view. A new Project view option, Show Members, lets files expand into their declarations, including nested ones, and clicking or activating a member opens the file at that symbol. It is off by default.

Outlines load when a file expands, including expansion restored on reopening a project or by turning the option on later. They refresh on save and on changes on disk, with stale in-flight results discarded. Files with no outline lose their chevron. File operations, drag and the context menu skip member rows. Also removes a duplicate Sort Folders First menu entry.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-01 07:31:26 +08:00
arookieofcanda-rookie-of-C-language cde74394d7 fix(welcome): space the landing actions evenly (#612)
The landing's three actions were flex items that hugged their labels, so the gap between icons followed the text length. They are now equal-width grid columns, which spaces the icons evenly in every locale.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-01 05:19:57 +08:00
arookieofcanda-rookie-of-C-language 9ca65b4df6 fix(ui): use dedicated icons for Rust, Cargo and ignore files (#616)
`.rs`, `Cargo.toml` and `Cargo.lock` used custom badges or the generic lock, and `.gitignore` used the git logo. They now have dedicated Rust, Cargo and Cargo lock icons with dark variants, and `.gitignore` and `.gitignore_global` share the ignored-file icon with the other ignore files. The icons' source and MIT licence are recorded in `THIRD_PARTY_LICENSES.md`.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-01 05:15:12 +08:00
l0ng-ai 2841f97145 fix(agents): use icon-only composer actions (#613)
The composers' primary actions were text buttons. Start, Send and Queue, plus Send in edit-and-resend, now share an icon-only submit button that keeps the translated name as its accessible label and tooltip, and shows a spinner while the action is pending. Stop is unchanged.
2026-11-01 04:18:32 +08:00
l0ng-ai 0be97c4323 fix(java): apply unmanaged project source roots (#611)
Source and Test roots marked in Project Structure never reached jdtls, so projects without Maven, Gradle or Eclipse metadata relied on jdtls's inference. That inference could treat a package directory as the source root, which gave wrong-package and unresolved-import errors in the editor and in Run.

Marked roots now go to jdtls as `java.project.sourcePaths`. Unmarked projects use `src/main/java` and `src/test/java`, or `src`; other layouts keep the inference. Changing the roots restarts only jdtls, with a separate workspace per source model, because a cached invisible project does not pick up new folders. Maven, Gradle and Eclipse projects keep their own model. Ant layouts count as unmanaged, since jdtls has no Ant importer.

Settings sent to jdtls are now merged the same way for initialization, configuration requests and change notifications. A Run with no editor open finds a trigger file inside the source roots so the project still imports.
2026-11-01 03:49:40 +08:00
l0ng-ai 0411a5b08a fix(settings): show SSH password and passphrase inputs (#609)
The SSH configuration form only asked for a password after a failed Test Connection, so choosing Password showed nothing but a "Save password" checkbox. It now shows a Password row, or a Passphrase row for key pairs.

The typed secret is staged with the other settings changes: it is written on Apply / OK and discarded on Cancel or Reset, and Test Connection uses it without storing it. An emptied field leaves the stored secret alone. Deployment, run target and data source forms have their own secret inputs and are unchanged.
2026-11-01 03:33:25 +08:00
l0ng-ai f48cd85d0b fix(editor): support custom folding in block comments (#606)
Native folding matched comment node names case-sensitively and stripped delimiters character by character, so XML comments were skipped and the `<` in editor-fold tags was consumed. The frontend hardcoded its own comment prefixes. Both now strip the configured comment tokens from the language definitions, so region and editor-fold markers work in block comment languages (XML, HTML, Vue, SVG, CSS and others), and Navigate > Custom Folding lists them. Languages without a bundled grammar get a fallback for own-line markers. Rust and frontend tests share one fixture over 22 language configurations.

Fixes #583
2026-11-01 02:28:07 +08:00
l0ng-ai 9e35cfc064 feat(vcs): compare folder selections with revisions across repositories (#602)
Compare with Branch or Revision only handled one file, so reviewing a folder meant opening each file. Folders, the project root and multiple selections now open a changes tree against the chosen revision, with per-file diffs on demand. A single file keeps its direct diff.

Each path resolves against its own repository through the root registry. In a multi-repo workspace the picker offers shared revisions such as "main in 3 repositories" as well as per-repository ones, results are grouped by repository, and content outside any repository or a repository missing the revision is stated in the result. Listing is cancellable and off the UI thread, includes worktree and untracked changes, and a rename leaving the selection shows as a delete. Show Diff on folders reuses the same tree.

Verified on macOS, and against a remote host over the server protocol. A remote window, Windows and Linux were not run.

Fixes #567
2026-11-01 01:43:15 +08:00
l0ng-ai 0a71f6be88 feat(editor): paste copied files as Markdown links (#599)
Pasting files copied in the project view into a Markdown document now inserts `[](relative/path)`, one link per line, encoded the same way as Markdown images. Other editors and terminals keep inserting the raw path, copied images keep their ![](...) route, and remote windows keep raw paths because the clipboard holds client paths. A context-menu Paste that the native command rejects falls back to reading the clipboard.

Fixes #573
2026-11-01 01:41:58 +08:00
l0ng-ai b3c3150489 fix(remote): run commands on the main thread after authorization (#600)
Authorization for a remote window resumes on a Tokio worker, so the generated handler ran synchronous commands there, including ones reaching AppKit such as the clipboard check, while local windows run them on the main thread. The handler is now dispatched back to the main thread after authorization, and the request is rejected if dispatch fails.

Two app aborts seen in remote windows unwound through this task. The abort was not reproduced and its throw site is unconfirmed. Logging confirmed the thread change. An audit found no remote-only blocking path in the synchronous commands that move to the main thread.
2026-11-01 01:31:01 +08:00
l0ng-ai 1d937d52e5 fix(i18n): remove stale remote import entries from the zh catalog (#590)
The remote import change left two zh entries whose source text is not in the scanned source, which failed textCatalog.test.ts on main.
2026-11-01 00:16:00 +08:00
l0ng-ai 5f57ce962e fix(project): import local files into remote project trees (#587)
Pasting or dropping OS files into a remote project tree sent a client-local source path to the server, so nothing was copied. Local-source imports are now carried separately from project copies and uploaded over the session SFTP channel: streamed in bounded chunks, created exclusively, cancellable across all selected paths under one task, with partial files removed on failure. Symlinks and Unix file modes are preserved, and special files are skipped and reported. Project-internal paste keeps working when the OS clipboard is briefly unavailable.

Native drop positions were assumed to be physical pixels, which shifted the target far from the pointer on Retina displays and with IDE zoom. A shared helper converts them to CSS coordinates for the project tree, the editor and Markdown drop handling, and the agent composer. Composer image drops are now read on the client in remote windows.

Verified on macOS against a live SSH host. Windows and Linux were not run: the Windows conversion is unchanged, the Linux one changed. Copy out from a remote tree to the local machine still needs a download workflow.
2026-11-01 00:14:56 +08:00
arookieofcanda-rookie-of-C-language 95b45c095d fix(project): paste copied files into editors and terminals as paths (#576)
Copying files in the project view wrote only the file list to the OS clipboard, so pasting into an editor did nothing. The copy now also writes the newline-separated paths as text, which other applications can paste.

WKWebView hides that text when file URLs are present and delivers a paste event with only Files, so the IDE editor and terminal still received nothing. A shared helper now reads the OS file list when a paste has files but no text, and inserts the paths. It covers Cmd+V and context-menu Paste in the editor, the terminal and embedded terminals. Markdown image paste runs first and keeps its behaviour.

Verified on macOS. The Windows clipboard change was tested by the contributor at the clipboard level only.

Refs #573

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-01 00:13:18 +08:00
l0ng-ai 9dc736de09 fix(java): explain recovery for unimported launch modules (#585)
Java launch preparation forwarded jdtls project-lookup failures unchanged, so a Maven module missing from the parent build or a wrongly inferred module showed a raw jdtls error in every Run and Debug entry point. Launch preparation now names the selected module and explains how to recover: wait for import, check the run configuration and the parent build, or open the folder as a project.

Covers the "does not belong to any Java project", "references non-existing project" and "not a Java project" responses. Cancellation, missing server, crash, timeout and build errors keep their existing handling. Import membership and module inference are unchanged.

Refs #197
2026-10-31 23:56:19 +08:00
arookieofcanda-rookie-of-C-language 369c63979d fix(project): keep the tree viewport when expanding shifts rows above (#579)
The tree followed the lead row by index, so expanding or collapsing a folder above the selection shifted the index and scrolled back to the selected row. It now scrolls when the lead changes. A lead whose row has not loaded yet is kept pending and scrolled to once when the row appears, so preselected rows and async loads still come into view.

Covers keyboard navigation, speed search, Select Opened File and autoscroll from source, which all still scroll to the new lead.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-10-31 23:44:34 +08:00
iGinkandiGink 347e3525d2 feat(editor): close editor tabs with Shift+click (#581)
Add Shift+left-click as a way to close editor tabs. The tab strip gets a closeOnShiftClick option, enabled for the editor area only, so tool window tabs such as Run and Terminal still select on Shift+click. Keyboard activation with Shift held still selects, non-closable tabs still select, and Shift+click does not start a tab drag.

Co-authored-by: iGink <[email protected]>
2026-10-31 23:38:44 +08:00
l0ng-ai 5280c9e584 fix(agent): report Claude Code turns that fail instead of ending them as ready (#582)
Prompts are sent with the origin `human` and their results echo it, but
`belongs` treated any result with an origin as another turn's. The result
was ignored and the turn ended on `command_lifecycle`, which dropped the
failure the turn had recorded. A signed-out Claude Code showed an empty
reply and a Ready task, with no error.

Only origins other than `human` now mark a result as foreign, so failures,
usage limits and the `max_tokens` and `refusal` stop reasons reach the task.
A turn ended by `command_lifecycle` also keeps its recorded failure.
2026-10-31 23:14:37 +08:00
l0ng-ai 67cad01ba7 chore: v0.1.32 2026-10-31 22:38:36 +08:00
l0ng-ai ad92b1577f fix(window): align macOS traffic lights with IDE zoom (#574)
IDE zoom changed the toolbar height while the macOS window buttons stayed at a fixed vertical position. The buttons are now centred in the title bar using its measured height and the zoom, and repositioned after AppKit layout updates. Zooming out keeps enough room for them so they do not cover the project icon, and compact mode follows the same alignment.

Windows align from the saved zoom and compact setting when they are built, so the buttons do not move once the page loads. The height uses the zoom the webview applies, so an out-of-range setting cannot misplace them.
2026-10-31 22:38:16 +08:00
l0ng-ai 06109aae95 chore: v0.1.31 2026-10-31 22:26:48 +08:00
arookieofcanda-rookie-of-C-language b2d1188049 fix(terminal): keep focus in the terminal after its context menu closes (#566)
Right-clicking the terminal did not focus it, and closing any context menu sent focus to the editor. Popup recorded the element to restore in an effect, after Menu had already focused its first item, so it restored the menu item; focus then fell to the body and the workbench fallback focused the editor.

Popup now records the focused element while rendering, and the terminal takes focus when its context menu opens. Enter after the menu closes goes to the shell. This covers every context menu: focus returns to what held it before the menu opened.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-10-31 21:43:20 +08:00
l0ng-ai 5329ddc469 chore: v0.1.30 2026-10-31 21:32:28 +08:00
l0ng-ai 2faa64fb68 test(project): limit Unix-only trust test imports to Unix (#571)
The trust integration tests that use these imports only compile on Unix, so they were unused on Windows and failed cargo clippy -D warnings.
2026-10-31 21:23:16 +08:00
l0ng-ai d6f6525ca5 fix(scratch): add visible language search field 2026-10-31 21:21:50 +08:00
719 changed files with 44674 additions and 2467 deletions

No files matched your search

+69
View File
@@ -0,0 +1,69 @@
name: Bug report
description: Something in OpenIDEA doesn't work as expected
labels: [bug]
body:
- type: markdown
attributes:
value: |
Thanks for reporting! Keep logs and error messages exactly as they appear.
One problem per issue, please. If you have several, open one issue for each.
- type: checkboxes
id: checks
attributes:
label: Before you start
options:
- label: I searched [existing issues](https://github.com/l0ng-ai/openidea/issues?q=is%3Aissue) and this hasn't been reported yet.
required: true
- label: I'm on the [latest release](https://github.com/l0ng-ai/openidea/releases/latest), or I noted below that I can't update.
required: true
- type: textarea
id: environment
attributes:
label: Environment
description: |
Open **About OpenIDEA** (macOS: OpenIDEA menu; Windows/Linux: Help menu), click **Copy Info**, and paste it here. Also mention the project type if it matters (e.g. Gradle multi-module, Spring Boot, Go workspace) and whether you use remote development.
render: text
placeholder: |
About OpenIDEA
Version: 0.1.33
Build: ...
Platform: macOS aarch64
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to reproduce
description: Start from opening OpenIDEA or a project. A small sample project or snippet helps a lot.
placeholder: |
1. Open a Gradle project whose settings.gradle includes modules in a loop
2. Open the Gradle tool window
3. ...
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual behavior
description: What happened instead. Screenshots or a recording are welcome.
validations:
required: true
- type: textarea
id: logs
attributes:
label: Logs
description: |
Use **Help → Show Log in Finder / Explorer / Files** to find `openidea.log` and paste the lines around the problem, or attach the file. Include console output or error messages too. Check for tokens or passwords before posting.
render: text
- type: textarea
id: extra
attributes:
label: Additional context
description: Anything else, such as whether it worked in an earlier version.
+5
View File
@@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: Download the latest release
url: https://github.com/l0ng-ai/openidea/releases/latest
about: Many problems are already fixed. Please check the latest version before reporting.
@@ -0,0 +1,38 @@
name: Feature request
description: Suggest a new feature or an improvement
labels: [enhancement]
body:
- type: markdown
attributes:
value: |
One request per issue, please.
- type: checkboxes
id: checks
attributes:
label: Before you start
options:
- label: I searched [existing issues](https://github.com/l0ng-ai/openidea/issues?q=is%3Aissue) and this hasn't been requested yet.
required: true
- type: textarea
id: problem
attributes:
label: Problem
description: What are you trying to do, and what gets in the way today?
validations:
required: true
- type: textarea
id: proposal
attributes:
label: Proposed solution
description: How you'd like it to work. Mockups or examples from other tools are welcome.
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives and workarounds
description: Other approaches you considered, or what you do now instead.
- type: textarea
id: extra
attributes:
label: Additional context
+1
View File
@@ -10,6 +10,7 @@
"target",
"src/bindings.ts",
"src/editor/core/cm/angular/parser*.ts",
"src/editor/core/cm/jsp/parser*.ts",
"docs/plugins/themes.md",
"pnpm-lock.yaml",
"**/*.toml",
Generated
+52 -38
View File
@@ -4038,7 +4038,7 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "ide-agent"
version = "0.1.29"
version = "0.1.34"
dependencies = [
"dunce",
"encoding_rs",
@@ -4061,7 +4061,7 @@ dependencies = [
[[package]]
name = "ide-analysis"
version = "0.1.29"
version = "0.1.34"
dependencies = [
"ast-grep-core",
"ide-core",
@@ -4075,22 +4075,22 @@ dependencies = [
[[package]]
name = "ide-build"
version = "0.1.29"
version = "0.1.34"
dependencies = [
"dirs 6.0.0",
"dunce",
"ide-core",
"libc",
"serde",
"serde_json",
"specta",
"tempfile",
"tracing",
"zip",
]
[[package]]
name = "ide-clipboard"
version = "0.1.29"
version = "0.1.34"
dependencies = [
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
@@ -4099,10 +4099,11 @@ dependencies = [
[[package]]
name = "ide-core"
version = "0.1.29"
version = "0.1.34"
dependencies = [
"dunce",
"encoding_rs",
"libc",
"serde",
"serde_json",
"specta",
@@ -4115,7 +4116,7 @@ dependencies = [
[[package]]
name = "ide-coverage"
version = "0.1.29"
version = "0.1.34"
dependencies = [
"ide-core",
"ide-run",
@@ -4129,7 +4130,7 @@ dependencies = [
[[package]]
name = "ide-dap"
version = "0.1.29"
version = "0.1.34"
dependencies = [
"ide-core",
"ide-jsonrpc",
@@ -4147,7 +4148,7 @@ dependencies = [
[[package]]
name = "ide-db"
version = "0.1.29"
version = "0.1.34"
dependencies = [
"bb8",
"dirs 6.0.0",
@@ -4169,10 +4170,9 @@ dependencies = [
[[package]]
name = "ide-docker"
version = "0.1.29"
version = "0.1.34"
dependencies = [
"ide-core",
"libc",
"serde",
"serde_json",
"specta",
@@ -4183,22 +4183,22 @@ dependencies = [
[[package]]
name = "ide-fonts"
version = "0.1.29"
version = "0.1.34"
dependencies = [
"objc2-core-foundation",
"objc2-core-text",
"serde",
"serde_json",
"specta",
"windows",
]
[[package]]
name = "ide-github"
version = "0.1.29"
version = "0.1.34"
dependencies = [
"ide-core",
"ide-vcs",
"libc",
"serde",
"serde_json",
"specta",
@@ -4208,7 +4208,7 @@ dependencies = [
[[package]]
name = "ide-host"
version = "0.1.29"
version = "0.1.34"
dependencies = [
"dirs 6.0.0",
"dunce",
@@ -4240,6 +4240,7 @@ dependencies = [
"ide-vfs",
"ide-workspace",
"parking_lot",
"rustix",
"serde",
"serde_json",
"specta",
@@ -4249,11 +4250,12 @@ dependencies = [
"tracing",
"tracing-subscriber",
"uuid",
"windows-sys 0.61.2",
]
[[package]]
name = "ide-http"
version = "0.1.29"
version = "0.1.34"
dependencies = [
"ide-core",
"parking_lot",
@@ -4271,7 +4273,7 @@ dependencies = [
[[package]]
name = "ide-index"
version = "0.1.29"
version = "0.1.34"
dependencies = [
"blake3",
"criterion",
@@ -4293,7 +4295,7 @@ dependencies = [
[[package]]
name = "ide-jsonrpc"
version = "0.1.29"
version = "0.1.34"
dependencies = [
"encoding_rs",
"futures",
@@ -4309,7 +4311,7 @@ dependencies = [
[[package]]
name = "ide-language"
version = "0.1.29"
version = "0.1.34"
dependencies = [
"blake3",
"criterion",
@@ -4364,7 +4366,7 @@ dependencies = [
[[package]]
name = "ide-lsp"
version = "0.1.29"
version = "0.1.34"
dependencies = [
"dunce",
"futures",
@@ -4373,6 +4375,7 @@ dependencies = [
"ide-jsonrpc",
"ide-language",
"ide-text",
"ide-vfs",
"ignore",
"lsp-types",
"parking_lot",
@@ -4388,7 +4391,7 @@ dependencies = [
[[package]]
name = "ide-notebook"
version = "0.1.29"
version = "0.1.34"
dependencies = [
"futures",
"ide-core",
@@ -4411,7 +4414,7 @@ dependencies = [
[[package]]
name = "ide-plugin"
version = "0.1.29"
version = "0.1.34"
dependencies = [
"ide-core",
"ide-language",
@@ -4425,11 +4428,12 @@ dependencies = [
"tracing",
"wasmtime",
"wasmtime-wasi",
"zip",
]
[[package]]
name = "ide-profiler"
version = "0.1.29"
version = "0.1.34"
dependencies = [
"ide-core",
"ide-run",
@@ -4442,7 +4446,7 @@ dependencies = [
[[package]]
name = "ide-remote"
version = "0.1.29"
version = "0.1.34"
dependencies = [
"globset",
"ide-core",
@@ -4459,7 +4463,7 @@ dependencies = [
[[package]]
name = "ide-run"
version = "0.1.29"
version = "0.1.34"
dependencies = [
"dunce",
"encoding_rs",
@@ -4482,7 +4486,7 @@ dependencies = [
[[package]]
name = "ide-sdk"
version = "0.1.29"
version = "0.1.34"
dependencies = [
"dunce",
"ide-core",
@@ -4501,7 +4505,7 @@ dependencies = [
[[package]]
name = "ide-search"
version = "0.1.29"
version = "0.1.34"
dependencies = [
"criterion",
"globset",
@@ -4521,7 +4525,7 @@ dependencies = [
[[package]]
name = "ide-spell"
version = "0.1.29"
version = "0.1.34"
dependencies = [
"ide-core",
"parking_lot",
@@ -4534,7 +4538,7 @@ dependencies = [
[[package]]
name = "ide-ssh"
version = "0.1.29"
version = "0.1.34"
dependencies = [
"dirs 6.0.0",
"flate2",
@@ -4553,7 +4557,7 @@ dependencies = [
[[package]]
name = "ide-syntax"
version = "0.1.29"
version = "0.1.34"
dependencies = [
"ide-core",
"ide-language",
@@ -4568,7 +4572,7 @@ dependencies = [
[[package]]
name = "ide-terminal"
version = "0.1.29"
version = "0.1.34"
dependencies = [
"ide-core",
"libc",
@@ -4581,7 +4585,7 @@ dependencies = [
[[package]]
name = "ide-text"
version = "0.1.29"
version = "0.1.34"
dependencies = [
"criterion",
"ide-core",
@@ -4593,7 +4597,7 @@ dependencies = [
[[package]]
name = "ide-tools"
version = "0.1.29"
version = "0.1.34"
dependencies = [
"flate2",
"hex",
@@ -4614,7 +4618,7 @@ dependencies = [
[[package]]
name = "ide-vcs"
version = "0.1.29"
version = "0.1.34"
dependencies = [
"dunce",
"encoding_rs",
@@ -4633,7 +4637,7 @@ dependencies = [
[[package]]
name = "ide-vfs"
version = "0.1.29"
version = "0.1.34"
dependencies = [
"chardetng",
"dunce",
@@ -4655,7 +4659,7 @@ dependencies = [
[[package]]
name = "ide-web"
version = "0.1.29"
version = "0.1.34"
dependencies = [
"ide-core",
"open",
@@ -4671,7 +4675,7 @@ dependencies = [
[[package]]
name = "ide-workspace"
version = "0.1.29"
version = "0.1.34"
dependencies = [
"blake3",
"dunce",
@@ -4681,6 +4685,7 @@ dependencies = [
"ide-text",
"ide-vfs",
"parking_lot",
"semver",
"serde",
"serde_json",
"specta",
@@ -5806,6 +5811,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [
"bitflags 2.13.2",
"block2 0.6.2",
"dispatch2",
"objc2 0.6.5",
]
@@ -6141,11 +6147,13 @@ dependencies = [
[[package]]
name = "openidea"
version = "0.1.29"
version = "0.1.34"
dependencies = [
"blake3",
"block2 0.6.2",
"dirs 6.0.0",
"dunce",
"gtk",
"ide-agent",
"ide-analysis",
"ide-build",
@@ -6180,6 +6188,11 @@ dependencies = [
"ide-vfs",
"ide-web",
"ide-workspace",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
"objc2-web-kit 0.3.2",
"parking_lot",
"serde",
"serde_json",
@@ -6198,6 +6211,7 @@ dependencies = [
"tracing",
"tracing-appender",
"tracing-subscriber",
"windows-sys 0.61.2",
]
[[package]]
+1 -1
View File
@@ -3,7 +3,7 @@ resolver = "3"
members = ["crates/*", "src-tauri"]
[workspace.package]
version = "0.1.29"
version = "0.1.34"
edition = "2024"
rust-version = "1.96"
license = "Apache-2.0"
-20
View File
@@ -14,8 +14,6 @@
&nbsp;·&nbsp;
<a href="#benchmark">Benchmark</a>
&nbsp;·&nbsp;
<a href="#build">Build</a>
&nbsp;·&nbsp;
<a href="https://discord.gg/2Zy4yn7gP">Discord</a>
</p>
@@ -47,24 +45,6 @@ IDEA analyses Python more deeply, so "ready" doesn't mean the same work on
both sides. Both are measured from the outside, the same way: see
[`perf/compare`](perf/compare) to run it on your machine and projects.
## Build
```sh
pnpm install
pnpm tauri dev
```
Rust 1.96, Node 22, pnpm and the [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/).
Native notebook execution tests are opt-in and need two distinct Python environments
with `ipykernel` installed:
```sh
OPENIDEA_TEST_PYTHON=/first/env/bin/python \
OPENIDEA_TEST_PYTHON_SECOND=/second/env/bin/python \
cargo test -p ide-notebook --test kernel_runtime -- --ignored --test-threads=1
```
## License
[Apache 2.0](LICENSE). Bundled third-party assets are listed in [`THIRD_PARTY_LICENSES.md`](THIRD_PARTY_LICENSES.md).
+49 -1
View File
@@ -1,5 +1,14 @@
# Third-party licenses
## Project Lombok
`plugins/lombok/lombok-1.18.48-openidea.2.jar` contains Project Lombok v1.18.48 with the Equinox
classloader fix from [projectlombok/lombok#4059](https://github.com/projectlombok/lombok/pull/4059)
and the ECJ constructor-call fix from [projectlombok/lombok#4089](https://github.com/projectlombok/lombok/pull/4089).
Copyright (C) 2009-2021 The Project Lombok Authors. MIT license and bundled component
notices are retained in [plugins/lombok/LICENSE](plugins/lombok/LICENSE) and inside the jar.
Source tag, patch commits and build instructions: [plugins/lombok/README.md](plugins/lombok/README.md).
## JetBrains IntelliJ Platform icons
`src/ui/expui/**` contains SVG icons from the IntelliJ Platform "new UI" (expui)
@@ -27,6 +36,20 @@ icon set, copied unmodified (some renamed) from JetBrains' open-source repositor
`fileTypes/hashiCorp`, Apache-2.0); `makefile` (`expui/makefile` →
`fileTypes/makefile`, MIT, Copyright (c) 2016 Victor Kropp — full text below).
The read/write usage filter assets (`actions/showReadAccess{,_dark}.svg` and
`actions/showWriteAccess{,_dark}.svg`) are copied unmodified from
`platform/icons/src/expui/actions/` at intellij-community commit
[`e992ff4750b4c36afe2caadfa8b191a8f36fb7a2`](https://github.com/JetBrains/intellij-community/tree/e992ff4750b4c36afe2caadfa8b191a8f36fb7a2/platform/icons/src/expui/actions).
Their original copyright headers and Apache-2.0 licensing are retained.
- [intellij-rust](https://github.com/intellij-rust/intellij-rust) (MIT):
`src/main/resources/icons/expui/rustFile{,_dark}.svg` →
`src/ui/expui/fileTypes/rust{,_dark}.svg`;
`src/main/resources/icons/expui/cargo{,_dark}.svg` and `cargoLock{,_dark}.svg` →
`src/ui/expui/fileTypes/cargo{,_dark}.svg` and `cargoLock{,_dark}.svg`.
Copyright (c) 2015 Aleksey Kladov, Evgeny Kurbatsky, Alexey Kudinkin and contributors;
Copyright (c) 2016 JetBrains. MIT licence, full text below.
At render time `src/ui/icons.tsx` may recolour monochrome icons to `currentColor`.
## JetBrains IntelliJ Platform diff algorithm
@@ -43,7 +66,7 @@ from its tests (`LineComparisonUtilTest`, `WordComparisonUtilTest`,
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,
languages whose JetBrains icons are not open source (Go, Ruby, Dart, Lua, Zig,
Svelte, …). Those using the page-with-corner-badge layout (`goMod`, `goWork`,
`dotenv`, `readme`, `lock`, `license`) reuse the page outline of the Apache-2.0
expui `fileTypes/h.svg`; that outline is modified from the original by JetBrains.
@@ -54,6 +77,31 @@ The lettering in `src-tauri/icons/source/openidea-wordmark*.svg` is outlined fro
Inter Tight (Copyright 2022 The Inter Project Authors, SIL Open Font License 1.1,
https://github.com/rsms/inter-tight).
### MIT License (Rust and Cargo icons)
The MIT License (MIT)
Copyright (c) 2015 Aleksey Kladov, Evgeny Kurbatsky, Alexey Kudinkin and contributors
Copyright (c) 2016 JetBrains
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
### MIT License (makefile icon)
Copyright (c) 2016 Victor Kropp
+18 -2
View File
@@ -314,10 +314,25 @@ impl AgentManager {
source: p.source.clone(),
})
.collect();
*self.shared.providers.write() = specs;
let mut prev = self.shared.providers.write();
let mut probes = self.shared.probes.lock();
probes.retain(|id, _| {
let old = prev.iter().find(|p| &p.id == id);
let new = specs.iter().find(|p| &p.id == id);
matches!((old, new), (Some(a), Some(b)) if a.command == b.command && a.args == b.args && a.env == b.env)
});
*prev = specs;
list
}
/// Re-reads configured/installed agents, drops cached probes, and notifies subscribers.
pub fn reload_providers(&self) {
let specs = provider::load(self.shared.paths.custom_agents_dir.as_deref());
*self.shared.providers.write() = specs;
self.shared.probes.lock().clear();
self.shared.emit(AgentEvent::Providers);
}
/// The slash commands to complete for `provider`, in `task`'s directory or (without one)
/// in the project: what the agent offered last time there, unadvertised builtins,
/// and, for Claude Code, skills and custom commands on disk it has not listed.
@@ -808,7 +823,8 @@ impl AgentManager {
// A copy of the task (a fork) may work in the same worktree.
let shared = self.tasks.read().values().any(|t| t.id != id && t.state.lock().task.cwd.0 == cwd);
if remove_worktree && !shared && isolation == Isolation::Worktree && cwd.exists() {
blocking(move || worktree::remove(&root, &cwd)).await?;
let worktrees = self.shared.paths.worktrees_dir.clone();
blocking(move || worktree::remove(&root, &worktrees, &cwd)).await?;
}
self.tasks.write().remove(id);
*task.deleted.lock() = true;
+2
View File
@@ -413,4 +413,6 @@ pub enum AgentEvent {
index: u32,
entry: AgentEntry,
},
/// The installed or configured agent providers changed.
Providers,
}
+7 -2
View File
@@ -915,7 +915,11 @@ impl Session {
} else {
"end_turn"
};
finished = st.turn.take().map(|t| (t, TurnEnd::Stop(stop)));
finished = st.turn.take().map(|mut t| {
// Claude Code reports a turn that failed as cancelled here.
let failure = t.failure.take().filter(|_| !t.cancelled);
(t, failure.unwrap_or(TurnEnd::Stop(stop)))
});
}
}
"assistant" if msg.get("parent_tool_use_id").is_none_or(Value::is_null) => {
@@ -1139,7 +1143,8 @@ where
/// Whether `result` ends `turn`: it answers the turn's last message, or (stopped) any of
/// its messages, or names none (the CLI's own error before the turn began).
fn belongs(turn: &Turn, result: &Value) -> bool {
if result.get("origin").is_some_and(|o| !o.is_null()) {
// Prompts are sent with the origin `human`, and their results echo it.
if result.get("origin").is_some_and(|o| !o.is_null() && o.get("kind").and_then(Value::as_str) != Some("human")) {
return false;
}
let mut uuids = strings(result.get("user_message_uuids"));
@@ -738,6 +738,50 @@ async fn a_stop_ahead_of_its_prompt_cancels_it() {
assert!(cli.sent.lock()[0].iter().all(|m| m["type"] != "user"), "nothing was sent");
}
#[tokio::test(flavor = "multi_thread")]
async fn a_failed_turn_reports_its_failure() {
let text = "Failed to authenticate: OAuth session expired and could not be refreshed";
let lifecycle = |uuid: &str, state: &str| {
Frame::Cli(json!({ "type": "command_lifecycle", "command_uuid": uuid, "state": state }))
};
let result = |uuid: &str| {
Frame::Cli(json!({
"type": "result", "subtype": "success", "is_error": true, "result": text, "stop_reason": "stop_sequence",
"terminal_reason": "api_error", "origin": { "kind": "human" }, "user_message_uuids": [uuid],
}))
};
// As Claude Code sends it, and with the turn's end ahead of its result.
for lifecycle_first in [false, true] {
let mut process = load_fixture("simple").remove(0);
process.truncate(6);
let Some(Frame::Shim(prompt)) = process.last() else { panic!("the fixture's prompt") };
let uuid = prompt["uuid"].as_str().unwrap().to_owned();
process.push(Frame::Cli(json!({
"type": "assistant", "error": "authentication_failed", "parent_tool_use_id": null,
"message": { "id": "m", "model": "<synthetic>", "content": [{ "type": "text", "text": text }] },
})));
if lifecycle_first {
process.extend([lifecycle(&uuid, "cancelled"), result(&uuid)]);
} else {
process.extend([result(&uuid), lifecycle(&uuid, "cancelled")]);
}
let cli = Arc::new(Replay {
processes: Mutex::new(VecDeque::from([process])),
sent: Arc::default(),
launched: Mutex::default(),
errors: Arc::default(),
});
let (dir, cwd) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
let h =
Harness::connect(cli.clone(), dir.path().into(), dir.path().join("p"), cwd.path().into(), Vec::new()).await;
let s = h.new_session().await;
let Err(RpcError::Server { message, .. }) = h.prompt(&s, "hi").await else {
panic!("the turn should fail (lifecycle first: {lifecycle_first})");
};
assert_eq!(message, format!("Claude Code authentication failed: {text}"));
}
}
/// Fork and undo against the real CLI, across connections, in a symlinked directory
/// (macOS `/tmp`).
#[tokio::test(flavor = "multi_thread")]
@@ -829,6 +873,7 @@ fn which_result_ends_a_turn() {
assert!(!belongs(&t, &json!({"user_message_uuids": ["a"]})), "a steer superseded it");
assert!(belongs(&t, &json!({"subtype": "error_during_execution"})), "names no message");
assert!(!belongs(&t, &json!({"origin": {"kind": "task-notification"}})), "Claude Code's own turn");
assert!(belongs(&t, &json!({"origin": {"kind": "human"}, "user_message_uuids": ["b"]})), "the prompt's own result");
assert!(belongs(&turn(&["a", "b"], true), &json!({"user_message_uuids": ["a"]})));
}
@@ -169,7 +169,7 @@ impl Translator {
Some("tool_use") => {
// The input streams after this: shown by name until the message has it.
let (id, name) = (str_of(&block, "id"), str_of(&block, "name"));
if !id.is_empty() && !tools::is_plan(&name) && !(tools::is_agent(&name) && self.subagents) {
if !(id.is_empty() || tools::is_plan(&name) || tools::is_agent(&name) && self.subagents) {
out.extend(self.ensure_call(&id, &name, &json!({}), &session));
}
}
+14 -4
View File
@@ -80,9 +80,16 @@ fn builtins() -> Vec<ProviderSpec> {
.collect()
}
/// Whether `id` names a built-in agent.
/// Whether `id` names a built-in agent that needs no registry install (native drivers
/// and `npx -y` adapters). CLI built-ins (`copilot`, `gemini`, …) can be installed and
/// updated from the ACP Registry into `<config>/agents/<id>.toml`.
pub(crate) fn is_builtin(id: &str) -> bool {
BUILTIN.iter().any(|b| b.0 == id)
BUILTIN.iter().any(|&(bid, _, driver, cmd, _)| bid == id && (driver != Driver::Acp || cmd.first() == Some(&"npx")))
}
/// Display name of a built-in agent, when `id` is one.
pub(crate) fn builtin_name(id: &str) -> Option<&'static str> {
BUILTIN.iter().find(|&&(bid, _, _, _, _)| bid == id).map(|&(_, name, _, _, _)| name)
}
/// An agent file, `<config>/agents/<id>.toml`.
@@ -162,7 +169,7 @@ pub(crate) fn load(custom_dir: Option<&Path>) -> Vec<ProviderSpec> {
Some(r) => AgentSource::Registry { version: r.version, update: None },
None => AgentSource::Custom,
};
let spec = ProviderSpec {
let mut spec = ProviderSpec {
name: agent.name.unwrap_or_else(|| id.clone()),
driver: agent.driver,
command: agent.command,
@@ -173,7 +180,10 @@ pub(crate) fn load(custom_dir: Option<&Path>) -> Vec<ProviderSpec> {
id,
};
match specs.iter_mut().find(|s| s.id == spec.id) {
Some(existing) => *existing = spec,
Some(existing) => {
spec.login_hint = existing.login_hint.clone();
*existing = spec;
}
None => specs.push(spec),
}
}
+77 -3
View File
@@ -165,6 +165,13 @@ fn parse(text: &str) -> IdeResult<Vec<Entry>> {
.into_iter()
.filter_map(|v| serde_json::from_value::<Entry>(v).ok())
.filter(|e| safe_segment(&e.id) && safe_segment(&e.version))
.map(|mut e| {
e.id = canonical_id(&e.id).to_owned();
if let Some(name) = provider::builtin_name(&e.id) {
e.name = name.to_owned();
}
e
})
.collect())
}
@@ -232,11 +239,16 @@ fn choose<'a>(entry: &'a Entry, platform: Platform, on_path: &dyn Fn(&str) -> bo
None
}
/// Registry agents that are built-in ones under another id.
const BUILTIN_ALIASES: &[&str] = &["github-copilot-cli", "qwen-code", "mistral-vibe"];
/// Registry ids that map to a shorter built-in provider id (`<config>/agents/<id>.toml`).
const BUILTIN_ALIASES: &[(&str, &str)] =
&[("github-copilot-cli", "copilot"), ("qwen-code", "qwen"), ("mistral-vibe", "vibe")];
fn canonical_id(id: &str) -> &str {
BUILTIN_ALIASES.iter().find_map(|&(alias, canonical)| (alias == id).then_some(canonical)).unwrap_or(id)
}
fn is_builtin(id: &str) -> bool {
provider::is_builtin(id) || BUILTIN_ALIASES.contains(&id)
provider::is_builtin(canonical_id(id))
}
fn on_path(command: &str) -> bool {
@@ -387,6 +399,7 @@ fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
/// Installs (or updates to) the registry's current version of `id`, streaming progress
/// through `emit`, and points the agent file at it.
pub fn install(paths: &RegistryPaths, id: &str, cancel: &CancellationToken, emit: Emit) -> IdeResult<()> {
let id = canonical_id(id);
// The saved index is what the user picked from; fetch only when there is none.
let text = match fs::read_to_string(&paths.cache_file) {
Ok(text) => text,
@@ -618,6 +631,7 @@ fn install_uv(
/// Deletes a registry agent's file and installed files.
pub fn remove(paths: &RegistryPaths, id: &str) -> IdeResult<()> {
let id = canonical_id(id);
if !safe_segment(id) {
return Err(IdeError::invalid(format!("invalid agent id {id}")));
}
@@ -946,4 +960,64 @@ mod tests {
assert!(remove(&p, "amp-acp").is_err());
assert!(p.agent_file("amp-acp").exists());
}
#[test]
fn maps_github_copilot_cli_to_builtin_copilot_and_tracks_updates() {
let dir = tempfile::tempdir().unwrap();
let p = paths(dir.path());
let index = r#"{"agents":[
{"id":"github-copilot-cli","name":"Copilot","version":"1.0.105",
"description":"GitHub's AI pair programmer",
"distribution":{"npx":{"package":"@github/[email protected]","args":["--acp"]}}}
]}"#;
let entries = parse(index).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!((entries[0].id.as_str(), entries[0].name.as_str()), ("copilot", "GitHub Copilot"));
let shown = visible(&p, &entries, MAC_ARM, &|c| c == "npm" || c == "node");
assert_eq!(shown.len(), 1);
assert_eq!(
(shown[0].id.as_str(), shown[0].name.as_str(), shown[0].installed.as_deref()),
("copilot", "GitHub Copilot", None)
);
CustomAgent {
name: Some("GitHub Copilot".into()),
driver: Driver::Acp,
command: "/data/tools/copilot/1.0.104/node_modules/.bin/copilot".into(),
args: vec!["--acp".into()],
env: BTreeMap::new(),
registry: Some(RegistryRef { id: "copilot".into(), version: "1.0.104".into() }),
}
.write(&p.agent_file("copilot"))
.unwrap();
let shown = visible(&p, &entries, MAC_ARM, &|c| c == "npm" || c == "node");
assert_eq!((shown[0].installed.as_deref(), shown[0].update), (Some("1.0.104"), true));
write_atomic(&p.cache_file, index.as_bytes()).unwrap();
let spec = provider::load(Some(&p.agents_dir)).into_iter().find(|s| s.id == "copilot").unwrap();
assert_eq!(spec.name, "GitHub Copilot");
assert_eq!(spec.login_hint, "Run `copilot` in a terminal and sign in.");
assert_eq!(spec.source, AgentSource::Registry { version: "1.0.104".into(), update: None });
let mut providers = vec![AgentProvider {
id: spec.id,
name: spec.name,
command: spec.command,
installed: true,
config: vec![],
commands: vec![],
source: spec.source,
}];
mark_updates(&p, &mut providers);
assert_eq!(
providers[0].source,
AgentSource::Registry { version: "1.0.104".into(), update: Some("1.0.105".into()) }
);
remove(&p, "github-copilot-cli").unwrap();
let fallback = provider::load(Some(&p.agents_dir)).into_iter().find(|s| s.id == "copilot").unwrap();
assert_eq!(fallback.source, AgentSource::Builtin);
}
}
+75
View File
@@ -942,3 +942,78 @@ async fn task_project_root_controls_local_and_worktree_repositories() {
}
assert_eq!(git(&["worktree", "list", "--porcelain"]).matches("worktree ").count(), 1);
}
#[tokio::test(flavor = "multi_thread")]
async fn reload_providers_updates_registry_install_clears_stale_probes_and_notifies_subscribers() {
let tmp = tempfile::tempdir().unwrap();
let agents_dir = tmp.path().join("agents");
std::fs::create_dir_all(&agents_dir).unwrap();
let fake_bin = tmp.path().join(if cfg!(windows) { "copilot.cmd" } else { "copilot" });
std::fs::write(&fake_bin, "").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&fake_bin, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let agent = Arc::new(FakeAgent::default());
let connector: Connector = {
let agent = agent.clone();
Arc::new(move |_spec, _cwd, client, _stderr| {
let (a, b) = duplex(1 << 20);
let (ar, aw) = tokio::io::split(a);
let (br, bw) = tokio::io::split(b);
let server = Peer::connect_framed(br, bw, agent.clone(), Framing::Lines);
agent.servers.lock().push(server);
Ok(Peer::connect_framed(ar, aw, client, Framing::Lines))
})
};
let mgr = AgentManager::with_connector(
AgentPaths {
project_root: tmp.path().to_path_buf(),
store_dir: tmp.path().join("store"),
worktrees_dir: tmp.path().join("worktrees"),
models_file: tmp.path().join("models.json"),
custom_agents_dir: Some(agents_dir.clone()),
home_dir: None,
},
tokio::runtime::Handle::current(),
connector,
);
// Seed a stale failed probe for copilot before installing its custom TOML.
mgr.shared.probes.lock().insert(
"copilot".into(),
AgentProbe {
provider: "copilot".into(),
name: None,
version: None,
protocol: "ACP".into(),
capabilities: AgentCapabilities::default(),
auth_methods: Vec::new(),
error: Some("copilot --acp was not found on PATH.".into()),
},
);
let events = Arc::new(Mutex::new(Vec::new()));
let events_sink = events.clone();
mgr.subscribe(Arc::new(move |ev| events_sink.lock().push(ev)));
let toml = format!(
"name = \"GitHub Copilot\"\ncommand = {:?}\nargs = [\"--acp\", \"--stdio\"]\n",
fake_bin.to_string_lossy()
);
std::fs::write(agents_dir.join("copilot.toml"), toml).unwrap();
mgr.reload_providers();
assert!(events.lock().iter().any(|ev| matches!(ev, AgentEvent::Providers)));
assert!(!mgr.shared.probes.lock().contains_key("copilot"));
let copilot = mgr.providers().into_iter().find(|p| p.id == "copilot").unwrap();
assert!(copilot.installed);
assert!(copilot.command.contains(&*fake_bin.to_string_lossy()));
let probe = mgr.probe("copilot", false).await.unwrap();
assert!(probe.error.is_none());
}
+51 -4
View File
@@ -48,8 +48,24 @@ pub(crate) fn create(project_root: &Path, worktrees_dir: &Path, title: &str, bas
Ok(Created { path, branch, base, base_commit })
}
pub(crate) fn remove(project_root: &Path, path: &Path) -> IdeResult<()> {
repo_at(project_root)?.remove_worktree(path, true)
/// `git worktree remove --force -- <path>`. When git no longer recognizes the
/// worktree (a pruned registration, a moved repository, a deleted `.git` file)
/// it fails with "is not a working tree". A folder under `worktrees_dir` is still
/// ours, so it is removed directly to keep the task deletable (a stale
/// registration prunes later); anything else keeps git's error.
pub(crate) fn remove(project_root: &Path, worktrees_dir: &Path, path: &Path) -> IdeResult<()> {
let repo = repo_at(project_root)?;
let err = match repo.remove_worktree(path, true) {
Ok(()) => return Ok(()),
Err(e) => e,
};
if !path.exists() {
return Ok(());
}
if !path.starts_with(worktrees_dir) || path == worktrees_dir {
return Err(err);
}
std::fs::remove_dir_all(path).map_err(|e| IdeError::internal(e.to_string()))
}
/// Files under `cwd` that differ from `base` (untracked files included), with line counts.
@@ -153,7 +169,7 @@ mod tests {
assert!(snapshot(&dir).unwrap().is_empty());
assert_eq!(file_at(&dir, &dir.join("a.txt"), "HEAD").unwrap(), None);
assert!(create(&dir, &tmp.path().join("wt"), "Edit A", None).is_err());
assert!(remove(&dir, &dir).is_err());
assert!(remove(&dir, tmp.path(), &dir).is_err());
}
fn git(dir: &Path, args: &[&str]) {
@@ -202,7 +218,38 @@ mod tests {
// A second task with the same title gets its own worktree.
assert_eq!(create(&repo, &tmp.path().join("wt"), "Edit A", None).unwrap().branch, "agent/edit-a-2");
remove(&repo, &created.path).unwrap();
remove(&repo, &tmp.path().join("wt"), &created.path).unwrap();
assert!(!created.path.exists());
}
#[test]
fn a_broken_worktree_registration_still_leaves_a_deletable_folder() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("repo");
std::fs::create_dir(&repo).unwrap();
git(&repo, &["init", "-q", "-b", "main"]);
std::fs::write(repo.join("a.txt"), "one\n").unwrap();
git(&repo, &["add", "."]);
git(&repo, &["commit", "-q", "-m", "init"]);
// A pruned registration (the repository moved, `git worktree prune` ran).
let created = create(&repo, &tmp.path().join("wt"), "Edit A", None).unwrap();
let registration = repo.join(".git/worktrees").join(created.path.file_name().unwrap());
std::fs::remove_dir_all(registration).unwrap();
remove(&repo, &tmp.path().join("wt"), &created.path).unwrap();
assert!(!created.path.exists());
// A deleted `.git` file in the worktree itself.
let created = create(&repo, &tmp.path().join("wt"), "Edit B", None).unwrap();
std::fs::remove_file(created.path.join(".git")).unwrap();
remove(&repo, &tmp.path().join("wt"), &created.path).unwrap();
assert!(!created.path.exists());
// A folder outside the worktrees directory is never deleted by hand.
let elsewhere = tmp.path().join("elsewhere");
std::fs::create_dir(&elsewhere).unwrap();
std::fs::write(elsewhere.join("keep.txt"), "x\n").unwrap();
assert!(remove(&repo, &tmp.path().join("wt"), &elsewhere).is_err());
assert!(elsewhere.join("keep.txt").exists());
}
}
+1 -3
View File
@@ -8,14 +8,12 @@ license.workspace = true
[dependencies]
dunce.workspace = true
ide-core.workspace = true
zip.workspace = true
serde.workspace = true
serde_json.workspace = true
specta.workspace = true
tracing.workspace = true
dirs.workspace = true
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[dev-dependencies]
tempfile.workspace = true
+218
View File
@@ -0,0 +1,218 @@
//! Resource processing by the project's build tool, then publishing to the live JVM's output directory.
use crate::BuildModel;
use ide_core::{IdeError, IdeResult};
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
pub struct ResourceBuild {
pub command: String,
pub args: Vec<String>,
pub cwd: String,
pub env: BTreeMap<String, String>,
pub kind: &'static str,
}
fn literal(value: &str) -> String {
format!("'{}'", value.replace('\\', "\\\\").replace('\'', "\\'"))
}
pub fn plan(root: &Path, model: &BuildModel, module: &Path, cache: &Path) -> IdeResult<ResourceBuild> {
fs::create_dir_all(cache)?;
let manifest = cache.join("resource-output.txt");
if manifest.exists() {
fs::remove_file(&manifest)?;
}
let module = dunce::canonicalize(root.join(module))?;
if let Some(p) = model.maven.as_ref().and_then(|m| m.projects.iter().find(|p| root.join(&p.dir) == module)) {
let mut args = p.options.clone();
args.extend([
"process-resources".into(),
"help:evaluate".into(),
"-Dexpression=project.build.outputDirectory".into(),
format!("-Doutput={}", manifest.display()),
]);
return Ok(ResourceBuild {
command: p.command.clone(),
args,
cwd: p.dir.clone(),
env: p.env.clone(),
kind: "maven",
});
}
if let Some(g) = &model.gradle
&& let Some(p) = g.projects.iter().find(|p| root.join(&p.dir) == module)
{
let script = cache.join("resources.init.gradle");
fs::write(
&script,
format!(
r#"gradle.projectsEvaluated {{
def p = gradle.rootProject.project({})
def resources = p.tasks.named('processResources')
p.tasks.register('openideaUpdateResources') {{
dependsOn resources
doLast {{ new File({}).setText(resources.get().destinationDir.absolutePath, 'UTF-8') }}
}}
}}
"#,
literal(&p.path),
literal(&manifest.to_string_lossy())
),
)?;
return Ok(ResourceBuild {
command: g.command.clone(),
args: vec![
"--init-script".into(),
script.to_string_lossy().into_owned(),
format!("{}:openideaUpdateResources", p.path.trim_end_matches(':')),
],
cwd: g.dir.clone(),
env: g.env.clone(),
kind: "gradle",
});
}
Err(IdeError::invalid("The Spring Boot module must belong to a linked Maven or Gradle build"))
}
pub fn publish(root: &Path, cache: &Path, class_paths: &[String], main_class: &str) -> IdeResult<()> {
let output = PathBuf::from(fs::read_to_string(cache.join("resource-output.txt"))?.trim());
if !output.is_absolute() || !output.is_dir() {
return Err(IdeError::invalid("The resource build did not report an output directory"));
}
let output = dunce::canonicalize(output)?;
let paths: Vec<_> = class_paths.iter().filter_map(|p| dunce::canonicalize(root.join(p)).ok()).collect();
// A separate resource directory is already on the runtime classpath (e.g. build/resources/main).
if paths.contains(&output) {
return Ok(());
}
// jdtls uses bin/main for Gradle, rather than Gradle's build/resources/main.
let class_file = main_class.split('.').collect::<PathBuf>().with_extension("class");
let destination = paths.iter().find(|p| p.join(&class_file).is_file()).ok_or_else(|| {
IdeError::invalid("Cannot find the running main class output directory; restart the application")
})?;
let previous: Vec<String> = fs::read(cache.join("published-resources.json"))
.ok()
.and_then(|bytes| serde_json::from_slice(&bytes).ok())
.unwrap_or_default();
let mut current = Vec::new();
copy_resources(&output, &output, destination, &mut current)?;
for rel in previous.iter().filter(|p| !current.contains(p)) {
let path = destination.join(rel);
if path.is_file() {
fs::remove_file(path)?;
}
}
fs::write(
cache.join("published-resources.json"),
serde_json::to_vec(&current).map_err(|e| IdeError::internal(e.to_string()))?,
)?;
Ok(())
}
fn copy_resources(root: &Path, from: &Path, to: &Path, copied: &mut Vec<String>) -> IdeResult<()> {
for entry in fs::read_dir(from)? {
let entry = entry?;
let path = entry.path();
let kind = entry.file_type()?;
if kind.is_symlink() {
continue;
}
if kind.is_dir() {
copy_resources(root, &path, to, copied)?;
} else if kind.is_file() && path.extension().is_none_or(|e| e != "class") {
let rel = path.strip_prefix(root).map_err(|e| IdeError::internal(e.to_string()))?;
let dest = to.join(rel);
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent)?;
}
fs::copy(&path, dest)?;
copied.push(rel.to_string_lossy().into_owned());
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resource_plans_use_the_selected_module_and_build_configuration() {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
fs::create_dir(root.join("app")).unwrap();
let mut model = BuildModel {
maven: Some(crate::MavenModel {
projects: vec![crate::MavenProject {
dir: "app".into(),
command: "/tools/mvnw".into(),
options: vec!["-Pdev".into()],
env: BTreeMap::from([("JAVA_HOME".into(), "/jdk".into())]),
..Default::default()
}],
..Default::default()
}),
..Default::default()
};
let cache = root.join("cache");
let maven = plan(&root, &model, Path::new("app"), &cache).unwrap();
assert_eq!(maven.command, "/tools/mvnw");
assert_eq!(maven.cwd, "app");
assert_eq!(maven.args[0..2], ["-Pdev", "process-resources"]);
assert!(maven.args.contains(&"-Dexpression=project.build.outputDirectory".into()));
assert_eq!(maven.env["JAVA_HOME"], "/jdk");
model.maven = None;
model.gradle = Some(crate::GradleModel {
command: "/tools/gradlew".into(),
projects: vec![crate::GradleProject { path: ":app".into(), dir: "app".into(), ..Default::default() }],
..Default::default()
});
let gradle = plan(&root, &model, Path::new("app"), &cache).unwrap();
assert_eq!(gradle.command, "/tools/gradlew");
assert_eq!(gradle.args.last().unwrap(), ":app:openideaUpdateResources");
let script = fs::read_to_string(cache.join("resources.init.gradle")).unwrap();
assert!(script.contains("processResources") && script.contains("destinationDir.absolutePath"));
assert!(!script.contains("build/resources/main")); // The task owns custom output locations.
}
#[test]
fn publishes_maven_and_gradle_resource_outputs_to_the_runtime_classpath() {
for layout in ["target/classes", "build/resources/main", "custom resource output"] {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let cache = root.join("cache");
let output = root.join(layout);
let runtime = if layout == "target/classes" { output.clone() } else { root.join("bin/main") };
fs::create_dir_all(output.join("static")).unwrap();
fs::create_dir_all(runtime.join("example")).unwrap();
fs::create_dir_all(&cache).unwrap();
fs::write(cache.join("resource-output.txt"), output.to_string_lossy().as_bytes()).unwrap();
fs::write(output.join("static/index.html"), "after").unwrap();
fs::write(output.join("unrelated.class"), "do not publish").unwrap();
fs::write(runtime.join("example/Application.class"), "running").unwrap();
publish(root, &cache, &[runtime.to_string_lossy().into_owned()], "example.Application").unwrap();
assert_eq!(fs::read_to_string(runtime.join("static/index.html")).unwrap(), "after");
assert_eq!(fs::read_to_string(runtime.join("example/Application.class")).unwrap(), "running");
if output != runtime {
assert!(!runtime.join("unrelated.class").exists());
fs::remove_file(output.join("static/index.html")).unwrap();
publish(root, &cache, &[runtime.to_string_lossy().into_owned()], "example.Application").unwrap();
assert!(!runtime.join("static/index.html").exists());
}
}
}
#[test]
fn leaves_separate_runtime_resource_directories_in_place_and_rejects_jar_only_runs() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let output = root.join("build/resources/main");
let cache = root.join("cache");
fs::create_dir_all(&output).unwrap();
fs::create_dir(&cache).unwrap();
fs::write(cache.join("resource-output.txt"), output.to_string_lossy().as_bytes()).unwrap();
publish(root, &cache, &[output.to_string_lossy().into_owned()], "a.Main").unwrap();
assert!(publish(root, &cache, &["app.jar".into()], "a.Main").is_err());
}
}
+321
View File
@@ -0,0 +1,321 @@
//! Metadata fingerprints for successful Maven launch builds.
use super::*;
use std::fs;
use std::hash::{Hash, Hasher};
use std::time::SystemTime;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct LaunchFingerprint {
version: u32,
parameters: String,
files: BTreeMap<PathBuf, Option<(u64, SystemTime)>>,
}
fn scan(
path: &Path,
files: &mut BTreeMap<PathBuf, Option<(u64, SystemTime)>>,
ancestors: &mut HashSet<PathBuf>,
cancel: &CancellationToken,
) -> IdeResult<()> {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let meta = match fs::metadata(path) {
Ok(meta) => meta,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
files.insert(path.to_path_buf(), None);
return Ok(());
}
Err(e) => return Err(e.into()),
};
if meta.is_dir() {
let canonical = dunce::canonicalize(path)?;
if !ancestors.insert(canonical.clone()) {
return Err(IdeError::internal("cyclic launch input directory"));
}
// Directory mtimes change on irrelevant JDT writes. File paths detect additions/deletions.
files.insert(path.to_path_buf(), None);
for entry in fs::read_dir(path)? {
scan(&entry?.path(), files, ancestors, cancel)?;
}
ancestors.remove(&canonical);
} else if meta.is_file() {
files.insert(path.to_path_buf(), Some((meta.len(), meta.modified()?)));
} else {
return Err(IdeError::internal("unsupported launch input file"));
}
Ok(())
}
/// `None` means an input cannot be resolved safely: build normally without reuse.
/// All filesystem work belongs on the caller's blocking thread.
pub fn launch_fingerprint(
root: &Path,
model: &BuildModel,
module: &Path,
test: bool,
local_repo: Option<PathBuf>,
cancel: &CancellationToken,
) -> IdeResult<Option<LaunchFingerprint>> {
let Some(m) = &model.maven else { return Ok(None) };
let projects = maven_launch_projects(root, m, module, test);
if projects.is_empty() {
return Ok(None);
}
let Some(mut inputs) = maven::launch_inputs(root, &projects, local_repo) else { return Ok(None) };
let plan = launch_plan(root, model, module, test)?;
let mut environment: BTreeMap<String, String> = std::env::vars()
.map(|(key, value)| (key.to_ascii_uppercase(), value))
.filter(|(key, _)| {
key.starts_with("MAVEN_")
|| key.starts_with("JAVA_")
|| key.starts_with("JDK_")
|| matches!(
key.as_str(),
"PATH" | "HOME" | "LANG" | "LC_ALL" | "_JAVA_OPTIONS" | "M2_HOME" | "MAVEN_HOME"
)
})
.collect();
// Explicit step values override the inherited environment just as they do in Command.
for step in &plan.steps {
environment.extend(step.env.iter().map(|(key, value)| (key.to_ascii_uppercase(), value.clone())));
for dir in step.cwd.ancestors() {
inputs.extend([dir.join(".mvn"), dir.join("mvnw"), dir.join("mvnw.cmd")]);
if dir == root {
break;
}
}
let program = process::program_path(&step.program, &step.env);
inputs.push(program.clone());
// Maven installed via a symlink still reads conf from its distribution.
if let Ok(program) = dunce::canonicalize(program)
&& let Some(home) = program.parent().and_then(Path::parent)
{
for conf in ["conf", "libexec/conf"] {
inputs.extend([home.join(conf).join("settings.xml"), home.join(conf).join("toolchains.xml")]);
}
}
if !environment.contains_key("JAVA_HOME") {
let java = process::program_path(if cfg!(windows) { "java.exe" } else { "java" }, &step.env);
inputs.push(java.clone());
if let Ok(java) = dunce::canonicalize(java)
&& let Some(home) = java.parent().and_then(Path::parent)
{
inputs.push(home.join("release"));
}
}
for (i, arg) in step.args.iter().enumerate() {
if matches!(arg.as_str(), "-s" | "--settings" | "-gs" | "--global-settings" | "-t" | "--toolchains")
&& let Some(file) = step.args.get(i + 1)
{
inputs.push(step.cwd.join(file));
}
for prefix in ["--settings=", "--global-settings=", "--toolchains="] {
if let Some(file) = arg.strip_prefix(prefix) {
inputs.push(step.cwd.join(file));
}
}
}
}
if let Some(home) = dirs::home_dir() {
inputs.extend([home.join(".m2/settings.xml"), home.join(".m2/toolchains.xml")]);
}
for key in ["MAVEN_HOME", "M2_HOME"] {
if let Some(home) = environment.get(key) {
inputs.extend([Path::new(home).join("conf/settings.xml"), Path::new(home).join("conf/toolchains.xml")]);
}
}
if let Some(home) = environment.get("JAVA_HOME") {
inputs.extend([
Path::new(home).join("release"),
Path::new(home).join("lib/modules"),
Path::new(home).join("lib/tools.jar"),
Path::new(home).join(if cfg!(windows) { "bin/java.exe" } else { "bin/java" }),
]);
}
let steps: Vec<_> =
plan.steps.iter().map(|s| (process::program_path(&s.program, &s.env), &s.args, &s.cwd, &s.env)).collect();
// Profiles and plugins can read arbitrary environment variables. Keep a signature
// of the inherited environment without persisting unrelated credentials.
let inherited: BTreeMap<_, _> = std::env::vars_os().collect();
let mut inherited_hash = std::hash::DefaultHasher::new();
inherited.hash(&mut inherited_hash);
let parameters =
serde_json::to_string(&(root, module, test, steps, environment, inherited_hash.finish(), projects))
.map_err(|e| IdeError::internal(e.to_string()))?;
let mut files = BTreeMap::new();
for path in inputs {
scan(&path, &mut files, &mut HashSet::new(), cancel)?;
}
// Store project inputs relative to the project, including each file's relative path.
let files = files.into_iter().map(|(p, stamp)| (p.strip_prefix(root).unwrap_or(&p).to_path_buf(), stamp)).collect();
Ok(Some(LaunchFingerprint { version: 1, parameters, files }))
}
#[cfg(test)]
mod tests {
use super::*;
fn write(root: &Path, rel: &str, value: &str) {
let path = root.join(rel);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, value).unwrap();
}
fn fixture(root: &Path) -> BuildModel {
write(
root,
"pom.xml",
r#"<project><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version>
<packaging>pom</packaging><modules><module>lib</module><module>app</module></modules></project>"#,
);
write(
root,
"lib/pom.xml",
r#"<project><parent><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version></parent>
<artifactId>lib</artifactId><build><sourceDirectory>code</sourceDirectory><resources><resource><directory>conf</directory></resource></resources></build></project>"#,
);
write(
root,
"app/pom.xml",
r#"<project><parent><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version></parent>
<artifactId>app</artifactId><dependencies><dependency><groupId>demo</groupId><artifactId>lib</artifactId><version>1</version></dependency></dependencies></project>"#,
);
write(root, "lib/src/main/kotlin/Channel.kt", "value");
write(root, "app/src/main/resources/app.properties", "key=value");
write(root, "lib/conf/custom.properties", "custom=value");
write(root, "lib/code/Custom.java", "class Custom {}");
BuildModel { maven: Some(maven::load(root)), ..Default::default() }
}
fn fingerprint(root: &Path, model: &BuildModel) -> Option<LaunchFingerprint> {
launch_fingerprint(root, model, &root.join("app"), false, None, &CancellationToken::new()).unwrap()
}
#[test]
fn unchanged_inputs_match_but_source_resource_pom_add_delete_and_mtime_invalidate() {
for rel in [
"lib/src/main/kotlin/Channel.kt",
"app/src/main/resources/app.properties",
"lib/conf/custom.properties",
"lib/code/Custom.java",
"app/pom.xml",
"lib/pom.xml",
"pom.xml",
".mvn/maven.config",
"lib/src/main/groovy/New.groovy",
"app/src/test/scala/Test.scala",
] {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let model = fixture(root);
let before = fingerprint(root, &model);
assert!(before.is_some());
assert_eq!(before, fingerprint(root, &model), "unchanged {rel}");
let serialized = serde_json::to_vec(&before).unwrap();
assert_eq!(before, serde_json::from_slice(&serialized).unwrap());
let value = if rel.ends_with("pom.xml") {
format!("{}<!-- changed -->", fs::read_to_string(root.join(rel)).unwrap())
} else {
"changed input".to_owned()
};
write(root, rel, &value);
let changed = fingerprint(root, &model);
assert_ne!(before, changed, "changed/added {rel}");
let file = fs::OpenOptions::new().write(true).open(root.join(rel)).unwrap();
file.set_modified(SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(42)).unwrap();
let touched = fingerprint(root, &model);
assert_ne!(changed, touched, "mtime-only {rel}");
fs::remove_file(root.join(rel)).unwrap();
assert_ne!(touched, fingerprint(root, &model), "deleted {rel}");
}
}
#[test]
fn profile_options_jdk_goal_and_wrapper_invalidate_without_using_class_outputs() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let mut model = fixture(root);
let before = fingerprint(root, &model);
write(root, "lib/target/classes/Channel.class", "JDT stub");
write(root, "app/target/classes/Main.class", "JDT stub");
assert_eq!(before, fingerprint(root, &model));
let mut previous = before.clone();
for option in ["-Pdev", "-Pprod", "-DskipTests", "-o"] {
model.maven.as_mut().unwrap().projects[0].options = vec![option.into()];
let current = fingerprint(root, &model);
assert_ne!(previous, current, "{option}");
previous = current;
}
model.maven.as_mut().unwrap().projects[0].options.clear();
model.maven.as_mut().unwrap().projects[0]
.env
.insert("JAVA_HOME".into(), root.join("jdk").to_string_lossy().into_owned());
let jdk = fingerprint(root, &model);
assert_ne!(before, jdk);
write(root, "jdk/release", "JAVA_VERSION=21");
assert_ne!(jdk, fingerprint(root, &model));
model.maven.as_mut().unwrap().projects[0].env.clear();
let test = launch_fingerprint(root, &model, &root.join("app"), true, None, &CancellationToken::new())
.unwrap()
.unwrap();
assert_ne!(before, Some(test));
write(root, "mvnw", "wrapper changed");
assert_ne!(before, fingerprint(root, &model));
}
#[test]
fn unresolved_roots_and_cancel_never_claim_up_to_date() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let model = fixture(root);
write(
root,
"lib/pom.xml",
"<project><artifactId>lib</artifactId><build><sourceDirectory>${unknown}/src</sourceDirectory></build></project>",
);
assert!(
launch_fingerprint(root, &model, &root.join("app"), false, None, &CancellationToken::new())
.unwrap()
.is_none()
);
let model = fixture(root);
let cancel = CancellationToken::new();
cancel.cancel();
assert!(launch_fingerprint(root, &model, &root.join("app"), false, None, &cancel).is_err());
}
#[test]
fn external_parent_and_selected_settings_files_invalidate() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("project");
let repo = dir.path().join("repository");
write(
&repo,
"demo/parent/1/parent-1.pom",
"<project><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version></project>",
);
write(
&root,
"pom.xml",
"<project><parent><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version><relativePath/></parent><artifactId>app</artifactId></project>",
);
let mut model = crate::load(&root, &root.join("cache"), Some(repo.clone()));
let settings = dir.path().join("custom-settings.xml");
fs::write(&settings, "<settings/>").unwrap();
model.maven.as_mut().unwrap().projects[0].options = vec!["-s".into(), settings.to_string_lossy().into_owned()];
let get =
|| launch_fingerprint(&root, &model, &root, false, Some(repo.clone()), &CancellationToken::new()).unwrap();
let before = get();
assert!(before.is_some());
write(
&repo,
"demo/parent/1/parent-1.pom",
"<project><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version><!-- changed --></project>",
);
let parent = get();
assert_ne!(before, parent);
fs::write(settings, "<settings><activeProfiles><activeProfile>dev</activeProfile></activeProfiles></settings>")
.unwrap();
assert_ne!(parent, get());
}
}
+277 -11
View File
@@ -3,8 +3,11 @@
//! background with cancellation and parses the compiler output into messages
//! for the Build tool window, the Problems view and the editor.
mod fingerprint;
mod parse;
pub use fingerprint::{LaunchFingerprint, launch_fingerprint};
use crate::process::{self, Stream};
use crate::{BuildModel, maven};
use ide_core::{CancellationToken, Diagnostic, IdeError, IdeResult, Pos, Range, Severity};
@@ -155,8 +158,11 @@ fn detect_dir(dir: &Path, model: &BuildModel, root: &Path) -> Option<BuildSystem
}
/// `root/rel`, without a trailing separator for the root itself.
/// `root` / `rel`, joined per `/`-separated component: on Windows a single join of
/// `a/b` keeps the `/`, and such a path does not compare equal to the same directory
/// as other tools (JDT LS) spell it.
fn subdir(root: &Path, rel: &str) -> PathBuf {
if rel.is_empty() { root.to_path_buf() } else { root.join(rel) }
rel.split('/').filter(|part| !part.is_empty()).fold(root.to_path_buf(), |path, part| path.join(part))
}
fn rel_str(root: &Path, dir: &Path) -> String {
@@ -182,8 +188,10 @@ pub fn plan(root: &Path, model: &BuildModel, kind: BuildKind, file: Option<&Path
None => None,
};
let dir = module_dir.clone().unwrap_or_else(|| root.to_path_buf());
// A folder of sibling projects has no build file itself; its Maven projects are built one by one.
let system = detect_dir(&dir, model, root)
.or_else(|| if model.gradle.is_some() { Some(BuildSystem::Gradle) } else { None })
.or_else(|| model.maven.as_ref().filter(|m| !m.projects.is_empty()).map(|_| BuildSystem::Maven))
.ok_or_else(|| IdeError::not_found("no supported build system (Maven, Gradle, Cargo, Go, TypeScript) found"))?;
let module = module_dir.as_deref().filter(|d| *d != root);
let title = match module {
@@ -237,12 +245,7 @@ fn maven_steps(root: &Path, model: &BuildModel, kind: BuildKind, module: Option<
if !m.projects.iter().any(|p| p.dir == rel) {
return Err(IdeError::not_found("no linked Maven project found for module"));
}
let top = tops
.iter()
.filter(|t| t.dir.is_empty() || rel == t.dir || rel.starts_with(&format!("{}/", t.dir)))
.max_by_key(|t| t.dir.len())
.copied()
.unwrap_or(tops[0]);
let top = maven_reactor(m, &rel).ok_or_else(|| IdeError::not_found("no linked Maven project found for module"))?;
let top_dir = subdir(root, &top.dir);
if module == top_dir {
return Ok(vec![step(top, &[])]);
@@ -611,6 +614,90 @@ pub fn has_non_java_sources(module: &Path) -> bool {
walk(&module.join("src"), 12)
}
fn reactor_projects<'a>(m: &'a maven::MavenModel, top: &'a maven::MavenProject) -> Vec<&'a maven::MavenProject> {
let mut projects = Vec::new();
let mut pending = vec![top];
let mut seen = HashSet::new();
while let Some(p) = pending.pop() {
if !seen.insert(&p.pom) {
continue;
}
projects.push(p);
pending.extend(m.projects.iter().filter(|child| p.modules.contains(&child.pom)));
}
projects
}
fn maven_reactor<'a>(m: &'a maven::MavenModel, module: &str) -> Option<&'a maven::MavenProject> {
m.projects.iter().filter(|p| !p.aggregated).find(|top| reactor_projects(m, top).iter().any(|p| p.dir == module))
}
/// Launch module first, followed by its upstream modules in the same Maven reactor.
/// External artifacts and unrelated linked reactors are never rebuilt for this launch.
fn maven_launch_projects<'a>(
root: &Path,
m: &'a maven::MavenModel,
module: &Path,
test: bool,
) -> Vec<&'a maven::MavenProject> {
let rel = rel_str(root, module);
let Some(top) = maven_reactor(m, &rel) else { return Vec::new() };
let reactor = reactor_projects(m, top);
let Some(start) = reactor.iter().find(|p| p.dir == rel) else { return Vec::new() };
let mut pending = vec![*start];
let mut seen = HashSet::new();
let mut projects = Vec::new();
while let Some(p) = pending.pop() {
if !seen.insert(&p.pom) {
continue;
}
projects.push(p);
for dep in &p.dependencies {
if !dep.resolved() || (dep.scope == "test" && (!test || p.pom != start.pom)) {
continue;
}
if let Some(upstream) = reactor.iter().find(|u| {
u.group_id == dep.group_id
&& u.artifact_id == dep.artifact_id
&& dep.version.as_ref().is_none_or(|v| *v == u.version)
}) {
pending.push(*upstream);
}
}
}
projects
}
/// Whether a launch requires the external JVM compiler. Maven includes upstream
/// reactor dependencies; adapter selection remains a separate, module-local decision.
pub fn launch_has_non_java_sources(root: &Path, model: &BuildModel, module: &Path, test: bool) -> bool {
if let Some(m) = &model.maven {
let projects = maven_launch_projects(root, m, module, test);
if !projects.is_empty() {
return projects.iter().any(|p| has_non_java_sources(&subdir(root, &p.dir)));
}
}
has_non_java_sources(module)
}
fn launch_plan(root: &Path, model: &BuildModel, module: &Path, test: bool) -> IdeResult<BuildPlan> {
let kind = if model.maven.as_ref().is_some_and(|m| m.projects.iter().any(|p| subdir(root, &p.dir) == module)) {
// JDT may have left newer error stubs in Maven's output directories.
BuildKind::Rebuild
} else {
BuildKind::Build
};
let mut plan = plan(root, model, kind, Some(module))?;
if plan.system == BuildSystem::Maven && !test {
for step in &mut plan.steps {
if let Some(goal) = step.args.last_mut() {
*goal = "compile".into();
}
}
}
Ok(plan)
}
/// Compiles the module at `module` with its build system before a JVM launch and
/// returns the class directories it wrote, to put ahead of the jdtls classpath.
pub fn build_for_launch(
@@ -620,7 +707,7 @@ pub fn build_for_launch(
test: bool,
cancel: &CancellationToken,
) -> IdeResult<Vec<PathBuf>> {
let plan = plan(root, model, BuildKind::Build, Some(module))?;
let plan = launch_plan(root, model, module, test)?;
let result = run(root, &plan, cancel, &|_| {})?;
if !result.success {
let errors: Vec<String> = result
@@ -635,6 +722,14 @@ pub fn build_for_launch(
.collect();
return Err(IdeError::internal(format!("Build failed:\n{}", errors.join("\n"))));
}
if plan.system == BuildSystem::Maven
&& let Some(m) = &model.maven
{
return Ok(maven_launch_projects(root, m, module, test)
.iter()
.flat_map(|p| launch_class_dirs(plan.system, &subdir(root, &p.dir), test && subdir(root, &p.dir) == module))
.collect());
}
Ok(launch_class_dirs(plan.system, module, test))
}
@@ -645,11 +740,11 @@ fn launch_class_dirs(system: BuildSystem, module: &Path, test: bool) -> Vec<Path
.iter()
.flat_map(|s| {
["classes/kotlin", "classes/groovy", "classes/scala", "classes/java", "resources"]
.map(|d| module.join("build").join(d).join(s))
.map(|d| subdir(module, &format!("build/{d}/{s}")))
})
.collect(),
BuildSystem::Maven if test => vec![module.join("target/test-classes"), module.join("target/classes")],
BuildSystem::Maven => vec![module.join("target/classes")],
BuildSystem::Maven if test => vec![subdir(module, "target/test-classes"), subdir(module, "target/classes")],
BuildSystem::Maven => vec![subdir(module, "target/classes")],
_ => Vec::new(),
};
dirs.into_iter().filter(|d| d.is_dir()).collect()
@@ -659,6 +754,30 @@ fn launch_class_dirs(system: BuildSystem, module: &Path, test: bool) -> Vec<Path
mod tests {
use super::*;
/// Launch outputs use the platform separator throughout, so they compare equal to
/// the same directories as JDT LS reports them.
#[test]
fn launch_dirs_use_one_separator() {
let root = tempfile::tempdir().unwrap();
let module = subdir(root.path(), "parent/app");
for dir in ["target/classes", "target/test-classes", "build/classes/java/main", "build/resources/test"] {
std::fs::create_dir_all(subdir(&module, dir)).unwrap();
}
let foreign = if std::path::MAIN_SEPARATOR == '\\' { '/' } else { '\\' };
let dirs = [
launch_class_dirs(BuildSystem::Maven, &module, true),
launch_class_dirs(BuildSystem::Gradle, &module, true),
]
.concat();
assert_eq!(dirs.len(), 4, "{dirs:?}");
for dir in &dirs {
let suffix = dir.strip_prefix(root.path()).unwrap().to_string_lossy().into_owned();
assert!(!suffix.contains(foreign), "{suffix}");
}
assert_eq!(subdir(root.path(), ""), root.path());
assert_eq!(subdir(root.path(), "a//b/"), root.path().join("a").join("b"));
}
fn write(root: &Path, rel: &str, text: &str) {
let p = root.join(rel);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
@@ -724,6 +843,17 @@ mod tests {
("pnpm", &["run".to_owned(), "build".to_owned()][..])
);
let siblings = tmp.path().join("siblings");
write(&siblings, "core/pom.xml", "<project><artifactId>core</artifactId></project>");
write(&siblings, "system/pom.xml", "<project><artifactId>system</artifactId></project>");
let model = crate::load(&siblings, &tmp.path().join("cache3"), None);
let p = plan(&siblings, &model, BuildKind::Build, None).unwrap();
assert_eq!(p.system, BuildSystem::Maven);
assert_eq!(
p.steps.iter().map(|s| s.cwd.clone()).collect::<Vec<_>>(),
[siblings.join("core"), siblings.join("system")]
);
let none = tmp.path().join("none");
std::fs::create_dir_all(&none).unwrap();
assert!(plan(&none, &BuildModel::default(), BuildKind::Build, None).is_err());
@@ -821,6 +951,142 @@ mod tests {
}
}
fn reactor_model() -> BuildModel {
let project = |dir: &str, deps: &[&str]| maven::MavenProject {
pom: format!("{dir}/pom.xml"),
dir: dir.into(),
group_id: "probe".into(),
artifact_id: dir.into(),
version: "1".into(),
aggregated: true,
dependencies: deps
.iter()
.map(|d| maven::MavenDependency {
group_id: "probe".into(),
artifact_id: (*d).into(),
version: Some("1".into()),
scope: "compile".into(),
..Default::default()
})
.collect(),
..Default::default()
};
let mut projects = vec![
project("app", &["middle"]),
project("middle", &["lib"]),
project("lib", &["app"]),
project("unrelated", &[]),
];
projects.insert(
0,
maven::MavenProject {
pom: "pom.xml".into(),
artifact_id: "reactor".into(),
command: "mvnw".into(),
modules: projects.iter().map(|p| p.pom.clone()).collect(),
options: vec!["-s".into(), "settings.xml".into()],
env: BTreeMap::from([("JAVA_HOME".into(), "/jdk".into())]),
..Default::default()
},
);
BuildModel { maven: Some(maven::MavenModel { projects, ..Default::default() }), ..Default::default() }
}
#[test]
fn mixed_launch_detection_follows_only_reactor_dependencies() {
for extension in ["kt", "groovy", "scala"] {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let mut model = reactor_model();
write(root, "app/src/main/java/Application.java", "class Application {}");
write(root, &format!("unrelated/src/main/java/Other.{extension}"), "");
assert!(!launch_has_non_java_sources(root, &model, &root.join("app"), false));
write(root, &format!("lib/src/main/java/Channel.{extension}"), "");
assert!(launch_has_non_java_sources(root, &model, &root.join("app"), false));
assert_eq!(
maven_launch_projects(root, model.maven.as_ref().unwrap(), &root.join("app"), false).len(),
3,
"cycles terminate"
);
let m = model.maven.as_mut().unwrap();
m.projects[2].dependencies[0].scope = "test".into();
assert!(
!launch_has_non_java_sources(root, &model, &root.join("app"), true),
"upstream test dependencies are not transitive"
);
let m = model.maven.as_mut().unwrap();
m.projects[1].dependencies[0].scope = "test".into();
m.projects[2].dependencies[0].scope = "compile".into();
assert!(!launch_has_non_java_sources(root, &model, &root.join("app"), false));
assert!(launch_has_non_java_sources(root, &model, &root.join("app"), true));
model.maven.as_mut().unwrap().projects[2].dependencies[0].version = Some("external".into());
assert!(!launch_has_non_java_sources(root, &model, &root.join("app"), true));
model.maven.as_mut().unwrap().projects[2].dependencies[0].version = Some("1".into());
model.maven.as_mut().unwrap().projects[3].aggregated = false;
model.maven.as_mut().unwrap().projects[0].modules.retain(|p| p != "lib/pom.xml");
assert!(
!launch_has_non_java_sources(root, &model, &root.join("app"), true),
"another linked reactor is external"
);
}
}
#[test]
fn maven_launch_steps_use_reactor_settings_and_launch_scope() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(root, "app/pom.xml", "<project/>");
let model = reactor_model();
for (test, goal) in [(false, "compile"), (true, "test-compile")] {
let plan = launch_plan(root, &model, &root.join("app"), test).unwrap();
let step = &plan.steps[0];
assert_eq!(step.cwd, root);
assert_eq!(step.program, "mvnw");
assert_eq!(step.env["JAVA_HOME"], "/jdk");
assert_eq!(
step.args,
["-B", "-Dstyle.color=never", "-s", "settings.xml", "-pl", "app", "-am", "clean", goal]
);
}
}
#[cfg(unix)]
#[test]
fn external_launch_returns_upstream_outputs_and_blocks_failure_or_cancellation() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(root, "app/pom.xml", "<project/>");
write(
root,
"mvnw",
"#!/bin/sh\nmkdir -p app/target/classes middle/target/classes lib/target/classes app/target/test-classes\n",
);
let wrapper = root.join("mvnw");
std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).unwrap();
let mut model = reactor_model();
model.maven.as_mut().unwrap().projects[0].command = wrapper.to_string_lossy().into_owned();
let dirs = build_for_launch(root, &model, &root.join("app"), true, &CancellationToken::new()).unwrap();
assert_eq!(
dirs,
["app/target/test-classes", "app/target/classes", "middle/target/classes", "lib/target/classes"]
.map(|p| root.join(p))
);
write(
root,
"mvnw",
"#!/bin/sh\necho '[ERROR] Kotlin compilation failed: unresolved reference broken' >&2\nexit 1\n",
);
let error = build_for_launch(root, &model, &root.join("app"), false, &CancellationToken::new()).unwrap_err();
assert!(error.message.contains("Kotlin compilation failed"), "{}", error.message);
let cancel = CancellationToken::new();
cancel.cancel();
assert_eq!(
build_for_launch(root, &model, &root.join("app"), false, &cancel).unwrap_err().kind,
ide_core::error::ErrorKind::Cancelled
);
}
#[test]
fn non_java_launch_class_dirs() {
let tmp = tempfile::tempdir().unwrap();
+475
View File
@@ -0,0 +1,475 @@
//! Private Maven builds for JDT's Kotlin binary libraries. Never use launch outputs.
use crate::BuildModel;
use crate::compile::{self, BuildEvent, BuildKind, BuildMessage};
use ide_core::{CancellationToken, IdeError, IdeResult};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Module {
key: String,
project_uri: String,
output: PathBuf,
test_output: Option<PathBuf>,
dependencies: Vec<Dependency>,
has_kotlin: bool,
kotlin_goals: Vec<KotlinGoal>,
}
#[derive(Clone, Debug, Deserialize)]
struct KotlinGoal {
execution: String,
status: String,
}
#[derive(Clone, Debug, Deserialize)]
struct Dependency {
key: String,
scope: String,
#[serde(rename = "type")]
kind: Option<String>,
classifier: Option<String>,
}
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
pub struct Library {
pub path: PathBuf,
pub test: bool,
}
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ProjectLibraries {
pub project_uri: String,
pub libraries: Vec<Library>,
}
pub struct Outcome {
pub projects: Vec<ProjectLibraries>,
pub messages: Vec<BuildMessage>,
pub success: bool,
}
/// Inspect sources, excluding generated/build/VCS trees and never following symlinks.
pub fn has_kotlin(root: &Path) -> bool {
let Ok(entries) = std::fs::read_dir(root) else { return false };
entries.flatten().any(|entry| {
let Ok(kind) = entry.file_type() else { return false };
if kind.is_file() {
entry.path().extension().is_some_and(|e| e == "kt")
} else {
kind.is_dir()
&& !matches!(
entry.file_name().to_str(),
Some("target" | "build" | "out" | "bin" | "node_modules" | ".git" | ".openidea")
)
&& has_kotlin(&entry.path())
}
})
}
/// Run the project's configured wrapper/settings/JDK and lifecycle, with an output
/// redirector loaded before Maven calculates its lifecycle execution plans.
pub fn build(
root: &Path,
model: &BuildModel,
bundle: &Path,
generation: &Path,
cancel: &CancellationToken,
) -> IdeResult<Outcome> {
std::fs::create_dir_all(generation)?;
let mut plan = compile::plan(root, model, BuildKind::Build, None)?;
if plan.system != compile::BuildSystem::Maven {
return Err(IdeError::invalid("Kotlin bridge requires a Maven build root"));
}
let mut manifests = Vec::new();
for (index, step) in plan.steps.iter_mut().enumerate() {
step.args.push("--fail-at-end".into());
let directory = generation.join(format!("reactor-{index}"));
std::fs::create_dir_all(&directory)?;
manifests.push(directory.join("modules.json"));
let mut extensions = vec![bundle.to_path_buf()];
step.args.retain(|arg| {
if let Some(value) = arg.strip_prefix("-Dmaven.ext.class.path=") {
extensions.extend(std::env::split_paths(value));
false
} else {
true
}
});
let extension_path = std::env::join_paths(extensions).map_err(|e| IdeError::invalid(e.to_string()))?;
step.args.push(format!("-Dmaven.ext.class.path={}", extension_path.to_string_lossy()));
step.args.push(format!("-Dopenidea.kotlin.staging={}", directory.join("staging").display()));
}
let mut messages = Vec::new();
// Independent linked reactors must still run after another reactor's javac fails.
for step in &plan.steps {
let mut reactor = plan.clone();
reactor.steps = vec![step.clone()];
let result = compile::run(root, &reactor, cancel, &|event| {
if let BuildEvent::Output { text, .. } = event {
tracing::debug!("kotlin bridge: {}", text.trim_end());
}
})?;
messages.extend(result.messages);
}
// javac diagnostics belong to JDT, including when a Kotlin target was skipped.
messages.retain(|message| {
message.path.as_ref().is_some_and(|path| PathBuf::from(path.clone()).extension().is_some_and(|e| e == "kt"))
});
let mut modules = Vec::new();
for manifest in manifests {
let parsed: Vec<Module> = serde_json::from_slice(&std::fs::read(manifest)?)
.map_err(|e| IdeError::internal(format!("Kotlin module manifest: {e}")))?;
modules.extend(parsed);
}
messages.extend(kotlin_failures(&modules));
if messages.iter().any(|message| message.severity == ide_core::Severity::Error) {
return Ok(Outcome { projects: Vec::new(), messages, success: false });
}
let pending = generation.join("libraries.pending");
let published = generation.join("libraries");
std::fs::create_dir_all(&pending)?;
let mut libraries = BTreeMap::new();
for (index, module) in modules.iter().enumerate() {
let mut outputs = ModuleLibraries::default();
for (source, target, name) in [
(Some(&module.output), &mut outputs.main, format!("main-{index}")),
(module.test_output.as_ref(), &mut outputs.test, format!("test-{index}")),
] {
let Some(source) = source else { continue };
if !source.starts_with(generation) {
return Err(IdeError::internal("Kotlin build output escaped staging"));
}
if copy_kotlin(source, &pending.join(&name), cancel)? {
*target = Some(published.join(name));
}
}
libraries.insert(module.key.clone(), outputs);
}
publish(&pending, &published, cancel)?;
Ok(Outcome { projects: consumers(&modules, &libraries), messages, success: true })
}
fn kotlin_failures(modules: &[Module]) -> Vec<BuildMessage> {
let mut messages = Vec::new();
for module in modules {
if !module.has_kotlin {
continue;
}
if module.kotlin_goals.is_empty() {
messages.push(kotlin_failure(module, "compile target was not executed"));
}
for goal in &module.kotlin_goals {
if goal.status != "success" {
messages.push(kotlin_failure(module, &format!("{} {}", goal.execution, goal.status)));
}
}
}
messages
}
fn kotlin_failure(module: &Module, reason: &str) -> BuildMessage {
BuildMessage {
severity: ide_core::Severity::Error,
text: format!("Kotlin compilation for {}: {reason}", module.key),
path: None,
range: None,
code: None,
}
}
fn publish(pending: &Path, published: &Path, cancel: &CancellationToken) -> IdeResult<()> {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
std::fs::rename(pending, published)?;
Ok(())
}
#[derive(Default)]
struct ModuleLibraries {
main: Option<PathBuf>,
test: Option<PathBuf>,
}
fn consumers(modules: &[Module], libraries: &BTreeMap<String, ModuleLibraries>) -> Vec<ProjectLibraries> {
modules
.iter()
.map(|consumer| {
let mut visible = Vec::<Library>::new();
let mut add = |path: &Option<PathBuf>, test: bool| {
if let Some(path) = path {
if let Some(previous) = visible.iter_mut().find(|entry| &entry.path == path) {
previous.test &= test;
} else {
visible.push(Library { path: path.clone(), test });
}
}
};
if let Some(own) = libraries.get(&consumer.key) {
add(&own.test, true);
add(&own.main, false);
}
// Maven's resolved artifacts already apply exclusions, mediation and optional
// propagation. Preserve their classpath order, after the module's own output.
for dependency in &consumer.dependencies {
let Some(output) = libraries.get(&dependency.key) else { continue };
let test = match dependency.scope.as_str() {
"compile" | "provided" | "system" | "" => false,
"test" | "runtime" => true,
_ => continue,
};
match (dependency.kind.as_deref(), dependency.classifier.as_deref()) {
(Some("test-jar"), None | Some("tests")) | (Some("jar"), Some("tests")) => add(&output.test, test),
(None | Some("jar"), None | Some("")) => add(&output.main, test),
_ => {}
}
}
ProjectLibraries { project_uri: consumer.project_uri.clone(), libraries: visible }
})
.collect()
}
fn copy_kotlin(source: &Path, destination: &Path, cancel: &CancellationToken) -> IdeResult<bool> {
let Ok(entries) = std::fs::read_dir(source) else { return Ok(false) };
let mut found = false;
for entry in entries {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let entry = entry?;
let kind = entry.file_type()?;
let path = entry.path();
let output = destination.join(entry.file_name());
if kind.is_dir() {
found |= copy_kotlin(&path, &output, cancel)?;
} else if kind.is_file() {
let bytes = std::fs::read(&path)?;
let class = path.extension().is_some_and(|e| e == "class");
let metadata = path.extension().is_some_and(|e| e == "kotlin_module");
if metadata || (class && kotlin_class(&bytes).unwrap_or(false)) {
std::fs::create_dir_all(destination)?;
std::fs::write(output, bytes)?;
found |= class;
}
}
}
Ok(found)
}
/// Read the actual class-level SourceFile attribute, rather than searching strings
/// (a Java constant containing "Something.kt" must never become a Kotlin library).
fn kotlin_class(bytes: &[u8]) -> Option<bool> {
struct Reader<'a> {
bytes: &'a [u8],
pos: usize,
}
impl<'a> Reader<'a> {
fn take(&mut self, count: usize) -> Option<&'a [u8]> {
let value = self.bytes.get(self.pos..self.pos.checked_add(count)?)?;
self.pos += count;
Some(value)
}
fn u2(&mut self) -> Option<usize> {
Some(u16::from_be_bytes(self.take(2)?.try_into().ok()?) as usize)
}
fn u4(&mut self) -> Option<usize> {
Some(u32::from_be_bytes(self.take(4)?.try_into().ok()?) as usize)
}
fn attributes(&mut self) -> Option<()> {
for _ in 0..self.u2()? {
self.u2()?;
let n = self.u4()?;
self.take(n)?;
}
Some(())
}
}
let mut reader = Reader { bytes, pos: 0 };
if reader.take(4)? != [0xca, 0xfe, 0xba, 0xbe] {
return None;
}
reader.take(4)?;
let count = reader.u2()?;
let mut utf8 = BTreeMap::new();
let mut index = 1;
while index < count {
match reader.take(1)?[0] {
1 => {
let n = reader.u2()?;
utf8.insert(index, reader.take(n)?);
}
3 | 4 | 9..=12 | 17 | 18 => {
reader.take(4)?;
}
5 | 6 => {
reader.take(8)?;
index += 1;
}
7 | 8 | 16 | 19 | 20 => {
reader.take(2)?;
}
15 => {
reader.take(3)?;
}
_ => return None,
}
index += 1;
}
reader.take(6)?;
let interfaces = reader.u2()?;
reader.take(interfaces.checked_mul(2)?)?;
for _ in 0..2 {
for _ in 0..reader.u2()? {
reader.take(6)?;
reader.attributes()?;
}
}
for _ in 0..reader.u2()? {
let name = reader.u2()?;
let length = reader.u4()?;
if utf8.get(&name).copied() == Some(b"SourceFile".as_slice()) && length == 2 {
let source = reader.u2()?;
return Some(utf8.get(&source)?.ends_with(b".kt"));
}
reader.take(length)?;
}
Some(false)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_kotlin_target_must_succeed_even_when_maven_fails_elsewhere() {
let mut modules: Vec<Module> = serde_json::from_str(r#"[
{"key":"lib","projectUri":"lib","output":"lib","dependencies":[],"hasKotlin":true,
"kotlinGoals":[{"execution":"main:compile","status":"success"},{"execution":"test:test-compile","status":"success"}]},
{"key":"java","projectUri":"java","output":"java","dependencies":[],"hasKotlin":false,"kotlinGoals":[]}
]"#).unwrap();
assert!(kotlin_failures(&modules).is_empty());
for status in ["failed", "skipped"] {
modules[0].kotlin_goals[1].status = status.into();
let failures = kotlin_failures(&modules);
assert_eq!(failures.len(), 1);
assert!(failures[0].text.contains(&format!("test:test-compile {status}")));
}
modules[0].kotlin_goals.clear();
assert_eq!(kotlin_failures(&modules).len(), 1);
modules[0].has_kotlin = false;
assert!(kotlin_failures(&modules).is_empty());
assert!(
serde_json::from_str::<Vec<Module>>(
r#"[{"key":"old","projectUri":"old","output":"old","dependencies":[]}]"#
)
.is_err()
);
}
#[test]
fn respects_mavens_resolved_closure_and_scopes() {
let json = r#"[
{"key":"zapp","projectUri":"app","output":"app", "hasKotlin":false,"kotlinGoals":[],"dependencies":[{"key":"mid","scope":"compile","optional":false},{"key":"lib","scope":"compile","optional":false}]},
{"key":"mid","projectUri":"mid","output":"mid", "hasKotlin":false,"kotlinGoals":[],"dependencies":[
{"key":"lib","scope":"compile","optional":false},
{"key":"optional","scope":"compile","optional":true},
{"key":"test","scope":"test","optional":false},
{"key":"provided","scope":"provided","optional":false}]},
{"key":"lib","projectUri":"lib","output":"lib","hasKotlin":false,"kotlinGoals":[],"dependencies":[]}
]"#;
let modules: Vec<Module> = serde_json::from_str(json).unwrap();
let libraries = ["zapp", "lib", "optional", "test", "provided"]
.into_iter()
.map(|key| (key.to_owned(), ModuleLibraries { main: Some(PathBuf::from(key)), test: None }))
.collect();
let result = consumers(&modules, &libraries);
assert_eq!(
result[0].libraries,
vec![Library { path: "zapp".into(), test: false }, Library { path: "lib".into(), test: false },]
);
assert_eq!(result[1].libraries.len(), 4);
assert!(result[1].libraries.iter().find(|l| l.path == Path::new("test")).unwrap().test);
}
#[test]
fn test_outputs_are_private_unless_maven_resolves_a_test_jar() {
let modules: Vec<Module> = serde_json::from_str(r#"[
{"key":"lib","projectUri":"lib","output":"lib","hasKotlin":false,"kotlinGoals":[],"dependencies":[]},
{"key":"app","projectUri":"app","output":"app","hasKotlin":false,"kotlinGoals":[],"dependencies":[{"key":"lib","scope":"compile","type":"jar","classifier":null}]},
{"key":"test-app","projectUri":"test-app","output":"app","hasKotlin":false,"kotlinGoals":[],"dependencies":[{"key":"lib","scope":"test","type":"test-jar","classifier":"tests"}]}
]"#).unwrap();
let libraries =
BTreeMap::from([("lib".into(), ModuleLibraries { main: Some("main".into()), test: Some("test".into()) })]);
let result = consumers(&modules, &libraries);
assert_eq!(
result[0].libraries,
vec![Library { path: "test".into(), test: true }, Library { path: "main".into(), test: false }]
);
assert_eq!(result[1].libraries, vec![Library { path: "main".into(), test: false }]);
assert_eq!(result[2].libraries, vec![Library { path: "test".into(), test: true }]);
}
#[test]
fn publishes_complete_directories_and_cancellation_keeps_them_private() {
let directory = tempfile::tempdir().unwrap();
let pending = directory.path().join("pending");
let final_path = directory.path().join("published");
std::fs::create_dir(&pending).unwrap();
std::fs::write(pending.join("Channel.class"), "complete").unwrap();
let cancelled = CancellationToken::new();
cancelled.cancel();
assert!(publish(&pending, &final_path, &cancelled).is_err());
assert!(!final_path.exists());
assert!(pending.exists());
publish(&pending, &final_path, &CancellationToken::new()).unwrap();
assert!(!pending.exists());
assert_eq!(std::fs::read(final_path.join("Channel.class")).unwrap(), b"complete");
}
#[test]
fn never_runs_another_build_system_with_maven_output_properties() {
let directory = tempfile::tempdir().unwrap();
std::fs::write(directory.path().join("Cargo.toml"), "").unwrap();
let error = build(
directory.path(),
&BuildModel::default(),
Path::new("unused.jar"),
&directory.path().join("private"),
&CancellationToken::new(),
)
.err()
.unwrap();
assert_eq!(error.message, "Kotlin bridge requires a Maven build root");
}
#[test]
fn does_not_find_deleted_sources_in_build_outputs() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("target/generated")).unwrap();
std::fs::write(dir.path().join("target/generated/Old.kt"), "").unwrap();
assert!(!has_kotlin(dir.path()));
std::fs::write(dir.path().join("New.kt"), "").unwrap();
assert!(has_kotlin(dir.path()));
}
#[test]
fn parses_sourcefile_and_rejects_truncated_classes() {
let mut class = vec![0xca, 0xfe, 0xba, 0xbe, 0, 0, 0, 52, 0, 3, 1, 0, 10];
class.extend_from_slice(b"SourceFile");
class.extend_from_slice(&[1, 0, 10]);
class.extend_from_slice(b"Channel.kt");
class.extend_from_slice(&[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 2, 0, 2]);
assert_eq!(kotlin_class(&class), Some(true));
for end in 0..class.len() {
assert!(kotlin_class(&class[..end]).is_none());
}
let offset = 26;
class[offset..offset + 10].copy_from_slice(b"Other.java");
assert_eq!(kotlin_class(&class), Some(false));
}
}
+2
View File
@@ -5,8 +5,10 @@
//! and merges what the last sync ([`sync_maven`] / [`sync_gradle`], which run
//! Maven / Gradle in the background) cached for the project.
pub mod application_update;
pub mod compile;
pub mod gradle;
pub mod kotlin_bridge;
pub mod maven;
pub mod pom_edit;
mod process;
+314 -13
View File
@@ -11,7 +11,9 @@ use crate::xml::{self, Element};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::collections::{BTreeMap, HashMap, HashSet};
use std::io::Read;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
/// Phases shown under "Lifecycle" (IDEA's default list).
pub const LIFECYCLE: &[&str] =
@@ -285,6 +287,14 @@ fn texts(e: &Element, path: &str, item: &str) -> Vec<String> {
e.list(path, item).iter().map(|m| m.text.trim().to_owned()).filter(|m| !m.is_empty()).collect()
}
/// Child modules of a project or profile: `<modules>` (model 4.0.0) plus `<subprojects>`,
/// which Maven 4 (model 4.1.0) uses instead.
fn module_names(e: &Element) -> Vec<String> {
let mut names = texts(e, "modules", "module");
names.extend(texts(e, "subprojects", "subproject"));
names
}
fn read_build(e: &Element, plugins: &mut Vec<RawPlugin>, managed: &mut Vec<RawPlugin>) {
plugins.extend(e.list("build/plugins", "plugin").into_iter().filter_map(read_plugin));
managed.extend(e.list("build/pluginManagement/plugins", "plugin").into_iter().filter_map(read_plugin));
@@ -308,7 +318,7 @@ fn read_raw(project: &Element) -> RawPom {
name: project.text_of("name"),
parent,
properties: read_properties(project.child("properties")),
modules: texts(project, "modules", "module"),
modules: module_names(project),
deps: project.list("dependencies", "dependency").into_iter().filter_map(read_dep).collect(),
managed: project
.list("dependencyManagement/dependencies", "dependency")
@@ -329,7 +339,7 @@ fn read_raw(project: &Element) -> RawPom {
let mut profile = RawProfile {
id: p.text_of("id").unwrap_or_else(|| "default".into()),
active_by_default: p.at("activation/activeByDefault").is_some_and(|a| a.text.trim() == "true"),
modules: texts(p, "modules", "module"),
modules: module_names(p),
properties: read_properties(p.child("properties")),
deps: p.list("dependencies", "dependency").into_iter().filter_map(read_dep).collect(),
..RawProfile::default()
@@ -448,13 +458,20 @@ struct Resolver {
/// `~/.m2/repository`, or `<localRepository>` from `~/.m2/settings.xml`.
pub(crate) fn default_local_repo() -> Option<PathBuf> {
let m2 = dirs::home_dir()?.join(".m2");
if let Some(settings) = std::fs::read_to_string(m2.join("settings.xml")).ok().and_then(|t| xml::parse(&t))
&& let Some(repo) = settings.text_of("localRepository")
{
let home = dirs::home_dir().map(|h| h.to_string_lossy().into_owned()).unwrap_or_default();
return Some(PathBuf::from(repo.replace("${user.home}", &home)));
local_repository_of(&m2.join("settings.xml")).or(Some(m2.join("repository")))
}
/// `<localRepository>` of a settings file, when it names one: the repository a build
/// with `-s <file>` resolves into, which is not necessarily the user's default.
pub fn local_repository_of(settings: &Path) -> Option<PathBuf> {
let text = std::fs::read_to_string(settings).ok()?;
let root = xml::parse(&text)?;
let repo = root.text_of("localRepository")?.trim().to_owned();
if repo.is_empty() {
return None;
}
Some(m2.join("repository"))
let home = dirs::home_dir().map(|h| h.to_string_lossy().into_owned()).unwrap_or_default();
Some(PathBuf::from(repo.replace("${user.home}", &home)))
}
impl Resolver {
@@ -601,6 +618,72 @@ impl Resolver {
}
}
/// Files and source/resource trees consumed by a launch build. Unresolved custom
/// roots disable reuse rather than silently omitting an input.
pub(crate) fn launch_inputs(
root: &Path,
projects: &[&MavenProject],
local_repo: Option<PathBuf>,
) -> Option<Vec<PathBuf>> {
fn roots(el: &Element, parent: &str, dir: &Path, out: &mut Vec<PathBuf>) -> Option<()> {
let input =
matches!(el.name.as_str(), "sourceDirectory" | "testSourceDirectory" | "sourceDir" | "testSourceDir")
|| (matches!(parent, "sources" | "sourceDirs" | "testSources" | "testSourceDirs")
&& matches!(el.name.as_str(), "source" | "sourceDir" | "testSourceDir"))
|| (matches!(parent, "resource" | "testResource" | "source" | "testSource") && el.name == "directory")
|| (parent == "filters" && el.name == "filter");
// Profile activation may depend on the presence of an otherwise unrelated file.
let activation = parent == "file" && matches!(el.name.as_str(), "exists" | "missing");
if input || activation {
// Other properties can be overridden by -D, profiles or settings.
// Without Maven's effective roots, conservatively disable reuse for them.
let value = el
.text
.trim()
.replace("${project.basedir}", &dir.to_string_lossy())
.replace("${basedir}", &dir.to_string_lossy());
if value.contains("${") {
return None;
}
if !value.is_empty() {
out.push(dir.join(value));
}
}
for child in &el.children {
roots(child, &el.name, dir, out)?;
}
Some(())
}
let mut resolver = Resolver::new(local_repo.or_else(default_local_repo));
let mut out = Vec::new();
for project in projects {
let dir = root.join(&project.dir);
// Include all conventional language/resource roots, including nonstandard files under src.
out.push(dir.join("src"));
let pom = root.join(&project.pom);
resolver.context(&pom, 0);
let mut path = pom;
let mut seen = HashSet::new();
loop {
let canonical = dunce::canonicalize(&path).ok()?;
if !seen.insert(canonical) || seen.len() > MAX_PARENT_DEPTH {
return None;
}
out.push(path.clone());
let text = std::fs::read_to_string(&path).ok()?;
roots(&xml::parse(&text)?, "", &dir, &mut out)?;
let raw = resolver.pom(&path)?;
let Some(parent) = raw.parent else { break };
path = resolver.parent_path(path.parent()?, &parent)?;
}
}
// Also track imported BOMs read while resolving inheritance.
out.extend(resolver.poms.into_keys());
out.sort();
out.dedup();
Some(out)
}
// ---------------------------------------------------------------- plugins
/// `maven-clean-plugin` → `clean`, `spring-boot-maven-plugin` → `spring-boot`.
@@ -614,7 +697,76 @@ pub fn goal_prefix(artifact_id: &str) -> String {
artifact_id.strip_suffix("-plugin").unwrap_or(artifact_id).to_owned()
}
/// Goals of well-known plugins (their plugin descriptors are not read).
/// Goals of every plugin jar read so far, by the path of that jar.
static DESCRIPTOR: OnceLock<Mutex<HashMap<String, Vec<String>>>> = OnceLock::new();
/// `<goal>` names of the plugin descriptor inside its jar (`META-INF/maven/plugin.xml`),
/// which is where Maven itself takes the list `plugin:help` prints. Empty when the jar
/// is not in the local repository yet.
fn descriptor_goals(repo: &Path, group_id: &str, artifact_id: &str, version: &str) -> Vec<String> {
// Keyed by the jar itself, not by its coordinates: a repository can be replaced
// between runs, and a test's empty repository must not answer for another's.
// One `push` per group segment: the layout is `<repo>/<group as directories>/…` on
// every platform, Windows' separator included.
let mut dir = repo.to_path_buf();
dir.extend(group_id.split('.'));
dir.push(artifact_id);
dir.push(version);
let Some(jar) = plugin_jar(&dir, artifact_id, version) else { return Vec::new() };
let key = jar.to_string_lossy().into_owned();
let cache = DESCRIPTOR.get_or_init(|| Mutex::new(HashMap::new()));
if let Some(cached) = cache.lock().unwrap_or_else(|e| e.into_inner()).get(&key) {
return cached.clone();
}
// A failed read is not remembered: the jar may be half written by Maven right now.
let Some(goals) = read_descriptor(&jar) else { return Vec::new() };
cache.lock().unwrap_or_else(|e| e.into_inner()).insert(key, goals.clone());
goals
}
/// The plugin's jar in its version directory: `<artifactId>-<version>.jar`, or — for a
/// SNAPSHOT installed from a repository, whose file name carries a timestamp — the newest
/// `<artifactId>-*.jar` that is neither sources nor javadoc.
fn plugin_jar(dir: &Path, artifact_id: &str, version: &str) -> Option<PathBuf> {
let exact = dir.join(format!("{artifact_id}-{version}.jar"));
if exact.is_file() {
return Some(exact);
}
let prefix = format!("{artifact_id}-");
let mut best: Option<(std::time::SystemTime, PathBuf)> = None;
for entry in std::fs::read_dir(dir).ok()?.flatten() {
let path = entry.path();
let Some(name) = path.file_name().and_then(|n| n.to_str()) else { continue };
if !name.starts_with(&prefix) || !name.ends_with(".jar") || name.ends_with("-sources.jar") {
continue;
}
if name.ends_with("-javadoc.jar") {
continue;
}
let Ok(modified) = entry.metadata().and_then(|m| m.modified()) else { continue };
if best.as_ref().is_none_or(|(at, _)| modified > *at) {
best = Some((modified, path));
}
}
best.map(|(_, p)| p)
}
fn read_descriptor(jar: &Path) -> Option<Vec<String>> {
let file = std::fs::File::open(jar).ok()?;
let mut archive = zip::ZipArchive::new(file).ok()?;
let mut entry = archive.by_name("META-INF/maven/plugin.xml").ok()?;
let mut text = String::new();
entry.read_to_string(&mut text).ok()?;
let root = xml::parse(&text)?;
let mut goals: Vec<String> =
root.list("mojos", "mojo").iter().filter_map(|m| m.text_of("goal")).map(|g| g.trim().to_owned()).collect();
goals.retain(|g| !g.is_empty());
goals.sort();
goals.dedup();
Some(goals)
}
/// Goals of well-known plugins, for a jar that is not in the local repository yet.
fn known_goals(artifact_id: &str) -> &'static [&'static str] {
match artifact_id {
"maven-clean-plugin" => &["clean", "help"],
@@ -766,9 +918,27 @@ fn lifecycle_plugins(packaging: &str) -> &'static [&'static str] {
}
}
/// A plugin as shown in the tree: execution goals plus the plugin's known goals, sorted.
pub(crate) fn plugin(group_id: &str, artifact_id: &str, version: Option<String>, goals: &[String]) -> MavenPlugin {
/// A plugin as shown in the tree: the goals its jar declares, the ones known by name for
/// the common plugins, and the ones the project binds in `<executions>`, sorted. IDEA lists
/// a plugin's goals the same way, which is why a plugin outside the table (checkstyle, say)
/// still shows every goal it offers instead of only the bound one.
pub(crate) fn plugin(
group_id: &str,
artifact_id: &str,
version: Option<String>,
goals: &[String],
local_repo: Option<&Path>,
) -> MavenPlugin {
let mut all: Vec<String> = known_goals(artifact_id).iter().map(|g| (*g).to_owned()).collect();
let repo = local_repo.map(Path::to_path_buf).or_else(default_local_repo);
// An unresolved `${…}` version names no jar: the table and the bound goals stand in.
if let (Some(repo), Some(version)) = (repo.as_deref(), version.as_deref().filter(|v| !v.contains("${"))) {
for g in descriptor_goals(repo, group_id, artifact_id, version) {
if !all.contains(&g) {
all.push(g);
}
}
}
for g in goals {
if !all.contains(g) {
all.push(g.clone());
@@ -933,7 +1103,7 @@ pub(crate) fn load_with_repo(root: &Path, local_repo: Option<PathBuf>) -> MavenM
.or_else(|| ctx.managed_plugins.iter().find(|m| m.key() == p.key()).and_then(|m| m.version.clone()))
.map(|v| i(&v))
.filter(|v| !v.contains("${"));
plugin(&i(&p.group_id), &i(&p.artifact_id), version, &p.goals)
plugin(&i(&p.group_id), &i(&p.artifact_id), version, &p.goals, resolver.local_repo.as_deref())
})
.collect();
@@ -1045,6 +1215,16 @@ pub fn build_files(root: &Path, model: &MavenModel) -> Vec<PathBuf> {
/// Projects and their plugins / dependencies from `mvn help:effective-pom` output
/// (`<project>` or `<projects>`), keyed `groupId:artifactId`.
pub fn parse_effective_pom(text: &str) -> Vec<(String, Vec<MavenPlugin>, Vec<MavenDependency>)> {
parse_effective_pom_in(text, None)
}
/// The same, with the local repository the project actually builds against (a
/// `-Dmaven.repo.local` of the build, say): the plugin goals come from jars there,
/// rather than from the user's default one.
pub fn parse_effective_pom_in(
text: &str,
local_repo: Option<&Path>,
) -> Vec<(String, Vec<MavenPlugin>, Vec<MavenDependency>)> {
let Some(root) = xml::parse(text) else { return Vec::new() };
let projects: Vec<&Element> =
if root.name == "projects" { root.children_named("project").collect() } else { vec![&root] };
@@ -1056,7 +1236,7 @@ pub fn parse_effective_pom(text: &str) -> Vec<(String, Vec<MavenPlugin>, Vec<Mav
let plugins = raw
.plugins
.iter()
.map(|pl| plugin(&pl.group_id, &pl.artifact_id, pl.version.clone(), &pl.goals))
.map(|pl| plugin(&pl.group_id, &pl.artifact_id, pl.version.clone(), &pl.goals, local_repo))
.collect();
let deps = raw
.deps
@@ -1187,6 +1367,107 @@ pub fn parse_dependency_tree(output: &str) -> Vec<(String, Vec<MavenDependency>)
mod tests {
use super::*;
/// A plugin jar in `repo` whose descriptor declares `goals`.
fn write_plugin_jar(repo: &Path, group: &str, artifact: &str, version: &str, goals: &[&str]) {
let dir = repo.join(group.replace('.', "/")).join(artifact).join(version);
std::fs::create_dir_all(&dir).unwrap();
let mojos: String = goals.iter().map(|g| format!("<mojo><goal>{g}</goal></mojo>")).collect();
let descriptor = format!("<plugin><mojos>{mojos}</mojos></plugin>");
let file = std::fs::File::create(dir.join(format!("{artifact}-{version}.jar"))).unwrap();
let mut zip = zip::ZipWriter::new(file);
zip.start_file("META-INF/maven/plugin.xml", zip::write::SimpleFileOptions::default()).unwrap();
std::io::Write::write_all(&mut zip, descriptor.as_bytes()).unwrap();
zip.finish().unwrap();
}
#[test]
fn a_plugin_lists_the_goals_its_jar_declares() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path();
write_plugin_jar(
repo,
"org.apache.maven.plugins",
"maven-checkstyle-plugin",
"3.3.1",
&["checkstyle", "checkstyle-aggregate", "help", "check"],
);
let p = plugin(
"org.apache.maven.plugins",
"maven-checkstyle-plugin",
Some("3.3.1".into()),
&["check".to_owned()],
Some(repo),
);
assert_eq!(p.goals, ["check", "checkstyle", "checkstyle-aggregate", "help"], "every goal the plugin offers");
assert_eq!(p.prefix, "checkstyle");
}
#[test]
fn a_settings_file_names_the_repository_to_read_goals_from() {
let tmp = tempfile::tempdir().unwrap();
let settings = tmp.path().join("settings.xml");
std::fs::write(
&settings,
"<settings><localRepository>${user.home}/repo-from-settings</localRepository></settings>",
)
.unwrap();
let repo = local_repository_of(&settings).unwrap();
assert!(repo.ends_with("repo-from-settings"), "{}", repo.display());
assert!(!repo.to_string_lossy().contains("${user.home}"));
// A settings file that names none leaves Maven's default alone.
let plain = tmp.path().join("plain.xml");
std::fs::write(&plain, "<settings><offline>true</offline></settings>").unwrap();
assert!(local_repository_of(&plain).is_none());
assert!(local_repository_of(&tmp.path().join("missing.xml")).is_none());
}
#[test]
fn effective_pom_goals_come_from_the_local_repository_it_is_given() {
let tmp = tempfile::tempdir().unwrap();
write_plugin_jar(tmp.path(), "org.apache.maven.plugins", "maven-demo-plugin", "1.0", &["run", "help"]);
let text = "<project><groupId>g</groupId><artifactId>a</artifactId><build><plugins>\
<plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-demo-plugin</artifactId>\
<version>1.0</version><executions><execution><goals><goal>run</goal></goals></execution></executions>\
</plugin></plugins></build></project>";
let with_repo = parse_effective_pom_in(text, Some(tmp.path()));
assert_eq!(with_repo[0].1[0].goals, ["help", "run"], "the jar's own goals come first");
// Without that repository the plugin is unknown: only what the project binds.
assert_eq!(parse_effective_pom(text)[0].1[0].goals, ["run"]);
}
#[test]
fn a_snapshot_plugin_is_found_by_its_timestamped_jar() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("g/p/maven-demo-plugin/1.0-SNAPSHOT");
std::fs::create_dir_all(&dir).unwrap();
let descriptor = "<plugin><mojos><mojo><goal>run</goal></mojo></mojos></plugin>";
let file = std::fs::File::create(dir.join("maven-demo-plugin-1.0-20240101.120000-3.jar")).unwrap();
let mut zip = zip::ZipWriter::new(file);
zip.start_file("META-INF/maven/plugin.xml", zip::write::SimpleFileOptions::default()).unwrap();
std::io::Write::write_all(&mut zip, descriptor.as_bytes()).unwrap();
zip.finish().unwrap();
// A sibling sources jar must not win.
std::fs::File::create(dir.join("maven-demo-plugin-1.0-20240101.120000-3-sources.jar")).unwrap();
let p = plugin("g.p", "maven-demo-plugin", Some("1.0-SNAPSHOT".into()), &[], Some(tmp.path()));
assert_eq!(p.goals, ["run"]);
}
#[test]
fn a_plugin_without_its_jar_keeps_the_table_and_the_bound_goals() {
let tmp = tempfile::tempdir().unwrap();
let p = plugin(
"org.apache.maven.plugins",
"maven-checkstyle-plugin",
Some("3.3.1".into()),
&["check".to_owned()],
Some(tmp.path()),
);
assert_eq!(p.goals, ["check"], "nothing beyond what the project binds");
let known =
plugin("org.apache.maven.plugins", "maven-compiler-plugin", Some("3.13.0".into()), &[], Some(tmp.path()));
assert_eq!(known.goals, ["compile", "help", "testCompile"], "the common plugins keep their table");
}
fn write(dir: &Path, rel: &str, text: &str) {
let p = dir.join(rel);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
@@ -1210,6 +1491,26 @@ mod tests {
</profiles>
</project>"#;
#[test]
fn maven_4_subprojects_are_modules() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(
root,
"pom.xml",
r#"<project xmlns="http://maven.apache.org/POM/4.1.0"><modelVersion>4.1.0</modelVersion>
<groupId>demo</groupId><artifactId>parent</artifactId><version>1.0</version><packaging>pom</packaging>
<subprojects><subproject>core</subproject></subprojects>
<profiles><profile><id>extra</id><subprojects><subproject>extra</subproject></subprojects></profile></profiles>
</project>"#,
);
write(root, "core/pom.xml", "<project><artifactId>core</artifactId></project>");
write(root, "extra/pom.xml", "<project><artifactId>extra</artifactId></project>");
let model = load_with_repo(root, None);
assert_eq!(model.projects[0].modules, ["core/pom.xml"]);
assert!(model.projects.iter().any(|p| p.pom == "core/pom.xml" && p.aggregated));
}
#[test]
fn multi_module_parent_properties_and_managed_versions() {
let tmp = tempfile::tempdir().unwrap();
+5 -20
View File
@@ -2,11 +2,12 @@
//! line to a callback, and cancellation kills the whole process group (the
//! `mvnw` / `gradlew` wrapper scripts start the JVM as a child).
use ide_core::process::kill_tree;
use ide_core::{CancellationToken, IdeError, IdeResult};
use std::collections::BTreeMap;
use std::io::{BufRead, BufReader, Read};
use std::path::Path;
use std::process::{Child, Command, Stdio};
use std::process::{Command, Stdio};
use std::sync::mpsc;
use std::time::Duration;
@@ -22,22 +23,10 @@ pub(crate) struct Output {
pub text: String,
}
fn kill_tree(child: &mut Child) {
#[cfg(unix)]
if let Ok(pid) = i32::try_from(child.id()) {
// SAFETY: plain syscall; the group was created by `process_group(0)` at spawn.
unsafe {
libc::kill(-pid, libc::SIGKILL);
}
}
let _ = child.kill();
let _ = child.wait();
}
/// `program` as spawnable: a bare name (`mvn`, `mvn.cmd`) resolves on the IDE's
/// search path — the login shell's `PATH` plus version-manager shims — since the
/// app's own `PATH` can be minimal and `Command` would not see `mvn` there.
fn program_path(program: &str, env: &BTreeMap<String, String>) -> std::path::PathBuf {
pub(crate) fn program_path(program: &str, env: &BTreeMap<String, String>) -> std::path::PathBuf {
let path = std::path::Path::new(program);
if !path.is_relative() || path.components().count() > 1 {
return path.to_path_buf();
@@ -80,12 +69,8 @@ pub(crate) fn run_with_env(
let mut command = Command::new(&program);
command.args(args).current_dir(cwd).envs(env).envs(encoding.env.iter().map(|(k, v)| (k, v))).stdin(Stdio::null());
command.stdout(Stdio::piped()).stderr(Stdio::piped());
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
command.process_group(0);
}
ide_core::process::prepare(&mut command);
// Its own group: `mvnw` / `gradlew` start the JVM as a child, and cancelling kills both.
ide_core::process::prepare(ide_core::process::own_group(&mut command));
if clear_env {
command.env_clear().envs(env).envs(encoding.env);
}
+74 -4
View File
@@ -48,6 +48,11 @@ struct CachedMavenProject {
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
struct MavenCache {
/// Bumped whenever what the cache holds stops matching what the model would be
/// built as now (plugin goals read from a descriptor, say): an older cache is
/// dropped instead of shadowing the model.
#[serde(default)]
version: u32,
hash: String,
at_ms: f64,
error: Option<String>,
@@ -66,6 +71,7 @@ struct GradleCache {
}
const MAVEN_CACHE: &str = "maven.json";
const MAVEN_CACHE_VERSION: u32 = 1;
/// `dependency:tree` of a plugin version whose `-Dverbose` lists the occurrences Maven
/// omitted (conflicts, duplicates): verbose output was dropped in 3.0 and is back since 3.2.
@@ -121,6 +127,9 @@ fn now_ms() -> f64 {
/// Merges the cached effective build into `model`.
pub fn apply_maven_cache(root: &Path, cache_dir: &Path, model: &mut MavenModel) {
let Some(cache) = read_cache::<MavenCache>(cache_dir, MAVEN_CACHE) else { return };
if cache.version != MAVEN_CACHE_VERSION {
return; // built by an older IDE: what it holds no longer matches the model
}
model.synced_at = Some(cache.at_ms);
model.sync_error = cache.error;
let changed = cache.hash != hash_files(&maven::build_files(root, model));
@@ -211,8 +220,31 @@ pub fn sync_maven(
let mut model = maven::load(root);
maven::UnlinkedProjects::from_model(&model, &options.maven_settings).filter(&mut model);
std::fs::create_dir_all(cache_dir)?;
let mut cache =
MavenCache { hash: hash_files(&maven::build_files(root, &model)), verbose: true, ..MavenCache::default() };
let mut cache = MavenCache {
version: MAVEN_CACHE_VERSION,
hash: hash_files(&maven::build_files(root, &model)),
verbose: true,
..MavenCache::default()
};
// Where the build resolves its jars, and therefore where the plugin goals are read
// from: `-Dmaven.repo.local=` first, then `<localRepository>` of the `-s` settings,
// then the user's default repository (which `plugin()` resolves itself).
let local_repo: Option<PathBuf> =
options.maven_options.iter().find_map(|a| a.strip_prefix("-Dmaven.repo.local=")).map(PathBuf::from).or_else(
|| {
let mut args = options.maven_options.iter();
while let Some(arg) = args.next() {
let path = match arg.as_str() {
"-s" | "--settings" => args.next().map(String::as_str),
_ => arg.strip_prefix("-s=").or_else(|| arg.strip_prefix("--settings=")),
};
if let Some(path) = path {
return maven::local_repository_of(Path::new(path));
}
}
None
},
);
let mut errors: Vec<String> = Vec::new();
for (n, top) in model.projects.iter().filter(|p| !p.aggregated).enumerate() {
let effective = cache_dir.join(format!("effective-pom-{n}.xml"));
@@ -241,8 +273,9 @@ pub fn sync_maven(
});
}
})?;
let effective_projects =
std::fs::read_to_string(&effective).map(|t| maven::parse_effective_pom(&t)).unwrap_or_default();
let effective_projects = std::fs::read_to_string(&effective)
.map(|t| maven::parse_effective_pom_in(&t, local_repo.as_deref()))
.unwrap_or_default();
let trees = maven::parse_dependency_tree(&out.text);
for (key, plugins, deps) in effective_projects {
let dependencies = trees.iter().find(|(k, _)| *k == key).map(|(_, t)| t.clone()).unwrap_or(deps);
@@ -332,6 +365,7 @@ mod tests {
let mut model = maven::load_with_repo(&root, None);
assert!(model.synced_at.is_none());
let cache = MavenCache {
version: MAVEN_CACHE_VERSION,
hash: hash_files(&maven::build_files(&root, &model)),
at_ms: 5.0,
error: None,
@@ -343,6 +377,7 @@ mod tests {
"maven-clean-plugin",
Some("3.2.0".into()),
&[],
None,
)],
dependencies: vec![MavenDependency { artifact_id: "dep".into(), ..MavenDependency::default() }],
}],
@@ -368,6 +403,41 @@ mod tests {
assert_eq!(model.projects[0].dependencies[0].artifact_id, "fresh");
}
#[test]
fn a_cache_from_an_older_ide_is_ignored() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("p");
let cache_dir = tmp.path().join("cache");
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("pom.xml"), "<project><groupId>g</groupId><artifactId>a</artifactId></project>")
.unwrap();
let mut model = maven::load_with_repo(&root, None);
let cache = MavenCache {
// What an older build wrote: no plugin descriptors were read back then, so
// its goal lists would shadow the ones the model now knows.
version: 0,
hash: hash_files(&maven::build_files(&root, &model)),
at_ms: 5.0,
error: None,
verbose: true,
projects: vec![CachedMavenProject {
key: "g:a".into(),
plugins: vec![maven::plugin(
"org.apache.maven.plugins",
"maven-clean-plugin",
Some("3.2.0".into()),
&[],
None,
)],
dependencies: vec![MavenDependency { artifact_id: "cached".into(), ..MavenDependency::default() }],
}],
};
write_cache(&cache_dir, MAVEN_CACHE, &cache).unwrap();
apply_maven_cache(&root, &cache_dir, &mut model);
assert!(model.synced_at.is_none(), "the cache is dropped, the model stays unsynced");
assert!(model.projects[0].dependencies.iter().all(|d| d.artifact_id != "cached"));
}
#[test]
fn gradle_cache_replaces_tasks_per_project() {
let tmp = tempfile::tempdir().unwrap();
+84 -25
View File
@@ -1,6 +1,8 @@
//! The file list on the OS clipboard: what Explorer copies when the user
//! copies files, so the project view can paste them, and what the project view
//! writes so Explorer can paste the files it copied (IDEA does both).
//! writes so Explorer can paste the files it copied (IDEA does both). The
//! written list comes with the paths as text, so a paste into an editor
//! inserts them instead of doing nothing.
//!
//! Windows keeps it as `CF_HDROP`: a `DROPFILES` header and a double-NUL
//! terminated list of absolute paths, wide when `fWide` is set. The data is set
@@ -10,6 +12,9 @@
//! 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.
//! On macOS, [`files`], [`has_files`] and [`set_files`] must run on the main
//! thread: AppKit and WebKit also access the general pasteboard there, and its
//! mutable type cache cannot be protected by a mutex in this crate.
//!
//! Other platforms (`text/uri-list` on X11) are not wired up: [`files`] is
//! empty and [`set_files`] fails there.
@@ -20,7 +25,8 @@ use std::path::PathBuf;
const HEADER: usize = 20;
/// The absolute paths on the clipboard, empty when it holds no file list.
pub fn files() -> Vec<PathBuf> {
/// Read failures remain errors so callers can retain an internal copy.
pub fn files() -> std::io::Result<Vec<PathBuf>> {
imp::files()
}
@@ -29,9 +35,8 @@ pub fn has_files() -> bool {
imp::has_files()
}
/// Replaces the clipboard with `paths`. False when it is busy or holds nothing
/// to write; the clipboard is a shared resource, so callers treat this as
/// best-effort.
/// Replaces the clipboard with `paths`. True only when the file list was
/// accepted; writing just the accompanying text is not a successful hand-off.
pub fn set_files(paths: &[PathBuf]) -> bool {
if paths.is_empty() {
return false;
@@ -111,6 +116,8 @@ mod imp {
/// `CF_HDROP`, the clipboard format holding a file list.
const CF_HDROP: u32 = 15;
/// `CF_UNICODETEXT`, so an editor paste of copied files inserts the paths.
const CF_UNICODETEXT: u32 = 13;
/// An `HGLOBAL` or a `HANDLE`: both are opaque pointers here.
type Handle = *mut c_void;
@@ -156,25 +163,29 @@ mod imp {
(unsafe { IsClipboardFormatAvailable(CF_HDROP) }) != 0
}
pub fn files() -> Vec<PathBuf> {
if !has_files() || !open() {
return Vec::new();
pub fn files() -> std::io::Result<Vec<PathBuf>> {
if !open() {
return Err(std::io::Error::last_os_error());
}
if !has_files() {
unsafe { CloseClipboard() };
return Ok(Vec::new());
}
let memory = unsafe { GetClipboardData(CF_HDROP) };
let paths = if memory.is_null() {
Vec::new()
Err(std::io::Error::last_os_error())
} else {
// SAFETY: the clipboard keeps the block alive while it is open, and
// `GlobalSize` bounds the bytes read from it.
unsafe {
let data = GlobalLock(memory);
if data.is_null() {
Vec::new()
Err(std::io::Error::last_os_error())
} else {
let bytes = std::slice::from_raw_parts(data as *const u8, GlobalSize(memory));
let paths = decode(bytes);
GlobalUnlock(memory);
paths
Ok(paths)
}
}
};
@@ -182,12 +193,37 @@ mod imp {
paths
}
/// An `HGLOBAL` holding `text` as UTF-16 with a terminating NUL, and its
/// byte size. Null when the allocation failed.
fn global_text(text: &str) -> (Handle, usize) {
let mut units: Vec<u16> = text.encode_utf16().collect();
units.push(0);
let bytes = units.len() * 2;
unsafe {
let memory = GlobalAlloc(GMEM_MOVEABLE, bytes);
if memory.is_null() {
return (ptr::null_mut(), 0);
}
let data = GlobalLock(memory);
if data.is_null() {
GlobalFree(memory);
return (ptr::null_mut(), 0);
}
ptr::copy_nonoverlapping(units.as_ptr() as *const u8, data as *mut u8, bytes);
GlobalUnlock(memory);
(memory, bytes)
}
}
pub fn set_files(paths: &[PathBuf]) -> bool {
let bytes = encode(paths);
if bytes.len() <= HEADER {
return false;
}
// SAFETY: the block is allocated, filled, handed to the clipboard and
// The paths as text, newline-separated, so pasting into an editor
// inserts them (IDEA writes the list and the text together).
let text = paths.iter().map(|p| p.to_string_lossy().into_owned()).collect::<Vec<_>>().join("\r\n");
// SAFETY: the blocks are allocated, filled, handed to the clipboard and
// freed only on the paths where the clipboard did not take ownership.
unsafe {
let memory = GlobalAlloc(GMEM_MOVEABLE, bytes.len());
@@ -205,17 +241,26 @@ mod imp {
GlobalFree(memory);
return false;
}
// Replacing the list also drops the text the user copied before.
// Replacing the list also drops the text the user copied before;
// the paths' own text is set back with it.
if EmptyClipboard() == 0 {
CloseClipboard();
GlobalFree(memory);
return false;
}
let taken = !SetClipboardData(CF_HDROP, memory).is_null();
CloseClipboard();
if !taken {
GlobalFree(memory);
}
let (text_memory, _) = global_text(&text);
if !text_memory.is_null() {
// The text alone still pastes somewhere useful.
let text_taken = !SetClipboardData(CF_UNICODETEXT, text_memory).is_null();
if !text_taken {
GlobalFree(text_memory);
}
}
CloseClipboard();
taken
}
}
@@ -223,13 +268,14 @@ mod imp {
#[cfg(target_os = "macos")]
mod imp {
use objc2::ClassType;
use objc2::rc::{Retained, autoreleasepool};
use objc2::runtime::{AnyClass, AnyObject, ProtocolObject};
use objc2::{ClassType, MainThreadMarker};
use objc2_app_kit::{
NSPasteboard, NSPasteboardReadingOptionKey, NSPasteboardURLReadingFileURLsOnlyKey, NSPasteboardWriting,
NSPasteboard, NSPasteboardReadingOptionKey, NSPasteboardTypeString, NSPasteboardURLReadingFileURLsOnlyKey,
NSPasteboardWriting,
};
use objc2_foundation::{NSArray, NSDictionary, NSNumber, NSURL};
use objc2_foundation::{NSArray, NSDictionary, NSNumber, NSString, NSURL};
use std::path::PathBuf;
/// `NSURL` as the one class to read, limited to file URLs: a web link the
@@ -237,6 +283,7 @@ mod imp {
fn read_file_urls<R>(
f: impl FnOnce(&NSPasteboard, &NSArray<AnyClass>, &NSDictionary<NSPasteboardReadingOptionKey, AnyObject>) -> R,
) -> R {
debug_assert!(MainThreadMarker::new().is_some(), "NSPasteboard access requires the main thread");
autoreleasepool(|_| {
let board = NSPasteboard::generalPasteboard();
let classes = NSArray::from_slice(&[NSURL::class()]);
@@ -256,23 +303,28 @@ mod imp {
})
}
pub fn files() -> Vec<PathBuf> {
pub fn files() -> std::io::Result<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();
return if unsafe { board.canReadObjectForClasses_options(classes, Some(options)) } {
Err(std::io::Error::other("Cannot read clipboard files"))
} else {
Ok(Vec::new())
};
};
objects
Ok(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()
.collect())
})
}
pub fn set_files(paths: &[PathBuf]) -> bool {
debug_assert!(MainThreadMarker::new().is_some(), "NSPasteboard access requires the main thread");
autoreleasepool(|_| {
// Folders get a directory URL (ending in `/`), as Finder writes them.
let urls: Vec<Retained<NSURL>> = paths
@@ -285,9 +337,16 @@ mod imp {
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.
// Replacing the list also drops the text the user copied before;
// the paths' own text is set back with it, so an editor paste
// inserts them (IDEA writes the list and the text together).
board.clearContents();
board.writeObjects(&NSArray::from_slice(&writers))
let written = board.writeObjects(&NSArray::from_slice(&writers));
let text = NSString::from_str(
&paths.iter().map(|p| p.to_string_lossy().into_owned()).collect::<Vec<_>>().join("\n"),
);
board.setString_forType(&text, unsafe { NSPasteboardTypeString });
written
})
}
}
@@ -300,8 +359,8 @@ mod imp {
false
}
pub fn files() -> Vec<PathBuf> {
Vec::new()
pub fn files() -> std::io::Result<Vec<PathBuf>> {
Ok(Vec::new())
}
pub fn set_files(_paths: &[PathBuf]) -> bool {
+4 -1
View File
@@ -18,5 +18,8 @@ tokio-util.workspace = true
[dev-dependencies]
tempfile.workspace = true
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Win32_Globalization", "Win32_System_Registry", "Win32_Foundation"] }
windows-sys = { version = "0.61", features = ["Win32_Globalization", "Win32_Security", "Win32_System_Registry", "Win32_Foundation"] }
+6
View File
@@ -371,6 +371,12 @@ pub struct TextAttributesOverride {
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct LanguageCodeStyle {
/// Java formatter: `default` | `intellij` | `custom` (other languages ignore it).
#[serde(skip_serializing_if = "Option::is_none")]
pub formatter: Option<String>,
/// Eclipse formatter XML; relative paths start at the project root.
#[serde(skip_serializing_if = "Option::is_none")]
pub formatter_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub use_tab: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
+52 -1
View File
@@ -12,6 +12,15 @@ pub type IdeResult<T> = Result<T, IdeError>;
pub struct IdeError {
pub kind: ErrorKind,
pub message: String,
/// Structured detail for errors the UI acts on; `null` for plain errors.
#[specta(optional)]
pub data: Option<ErrorData>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum ErrorData {
Tool { tool: String, missing: bool },
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
@@ -24,6 +33,7 @@ pub enum ErrorKind {
/// Document version mismatch; the UI must resync the full text.
OutOfSync,
Cancelled,
TimedOut,
/// A required external tool (language server, git, debugger) is unavailable.
Unavailable,
Io,
@@ -33,7 +43,21 @@ pub enum ErrorKind {
impl IdeError {
pub fn new(kind: ErrorKind, message: impl Into<String>) -> Self {
Self { kind, message: message.into() }
Self { kind, message: message.into(), data: None }
}
pub fn with_tool(mut self, tool: impl Into<String>) -> Self {
if self.data.is_none() {
self.data = Some(ErrorData::Tool { tool: tool.into(), missing: false });
}
self
}
pub fn missing_tool(tool: impl Into<String>) -> Self {
let tool = tool.into();
Self {
kind: ErrorKind::NotFound,
message: format!("{tool} is not installed"),
data: Some(ErrorData::Tool { tool, missing: true }),
}
}
pub fn not_found(m: impl Into<String>) -> Self {
Self::new(ErrorKind::NotFound, m)
@@ -65,3 +89,30 @@ impl From<std::io::Error> for IdeError {
Self::new(kind, e.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tool_errors_round_trip_without_changing_diagnostics() {
let message = "[error] file.css: Unexpected token\n 1 | }\n";
let error = IdeError::invalid(message).with_tool("prettier");
let decoded: IdeError = serde_json::from_str(&serde_json::to_string(&error).unwrap()).unwrap();
assert_eq!(decoded.message, message);
assert_eq!(decoded.data, Some(ErrorData::Tool { tool: "prettier".into(), missing: false }));
let missing = IdeError::missing_tool("prettier").with_tool("prettier");
assert_eq!(missing.kind, ErrorKind::NotFound);
assert_eq!(missing.data, Some(ErrorData::Tool { tool: "prettier".into(), missing: true }));
}
#[test]
fn legacy_errors_need_no_data() {
let error: IdeError = serde_json::from_str(r#"{"kind":"unavailable","message":"no server"}"#).unwrap();
assert!(error.data.is_none());
assert_eq!(
serde_json::to_value(error).unwrap(),
serde_json::json!({"kind":"unavailable","message":"no server","data":null})
);
}
}
+15
View File
@@ -36,12 +36,21 @@ pub enum IdeEvent {
base_version: u32,
version: u32,
changes: Vec<TextChange>,
/// Part of a grouped transaction; the editor must not record a file-local undo.
#[serde(default)]
#[specta(optional)]
workspace_edit: bool,
},
/// A document's dirty flag changed (saved / modified externally).
DocDirty {
doc: DocId,
dirty: bool,
},
/// A complete workspace edit can be undone as one operation.
WorkspaceEditApplied {
id: u32,
paths: Vec<crate::IpcPath>,
},
/// An open document's language changed (Settings › File Types, Override File
/// Type), or its UI mode (a framework's root marker appeared or went).
DocLanguage {
@@ -82,6 +91,12 @@ pub enum IdeEvent {
status: ServerStatus,
message: Option<String>,
},
/// A running server lacks a capability; the UI offers the managed tool as a replacement.
LanguageServerWarning {
name: String,
message: String,
install_tool: String,
},
/// A stopped language server left the project's list (disabled in settings, or a
/// fallback that a preferred server took over from again).
LanguageServerRemoved {
+25
View File
@@ -0,0 +1,25 @@
//! Capabilities needed by the JVM hosting Java language-server bundles.
use std::path::Path;
/// jdtls' javac bundle, java-debug / hot code replace, and Lombok / Gradle tooling.
const BUNDLE_MODULES: &[&str] = &["jdk.compiler", "jdk.jdi", "jdk.unsupported"];
/// Require a full JDK, including the modules used by jdtls and its bundles.
/// Temurin JREs can list the build JDK's modules in `release` even when the runtime
/// image omits them, so module metadata alone does not establish suitability.
pub fn supports_java_bundles(home: &Path) -> bool {
let Ok(release) = std::fs::read_to_string(home.join("release")) else { return false };
let property =
|key: &str| release.lines().find_map(|line| line.trim().strip_prefix(key)).map(|v| v.trim().trim_matches('"'));
let javac = if cfg!(windows) { "javac.exe" } else { "javac" };
if property("IMAGE_TYPE=").is_some_and(|image| image.eq_ignore_ascii_case("jre"))
|| !home.join("bin").join(javac).is_file()
{
return false;
}
match property("MODULES=") {
Some(modules) => BUNDLE_MODULES.iter().all(|required| modules.split_whitespace().any(|m| m == *required)),
None => BUNDLE_MODULES.iter().all(|m| home.join("jmods").join(format!("{m}.jmod")).is_file()),
}
}
+1
View File
@@ -11,6 +11,7 @@ pub mod ids;
pub mod ignored;
pub mod inspection_settings;
pub mod ipc_path;
pub mod java_runtime;
pub mod jsonc;
pub mod output_encoding;
pub mod process;
+59 -10
View File
@@ -215,13 +215,21 @@ fn fix_kind(
}
/// `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> {
/// (its launch attribute, UTF-8 when unset). java-debug sets `file.encoding` only
/// when that attribute is nonblank; use the same defaults and user overrides as [`fix`].
pub fn java_debug_vm_args(
vm_args: &str,
vars: &BTreeMap<String, String>,
inherited: impl Fn(&str) -> Option<String>,
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) {
let added = fix_kind(Kind::Java, Some(&[vm_args.to_owned()]), vars, inherited, encoding, windows).java_args;
if added.is_empty() {
return None;
}
let added = options(STD_PROPERTIES, encoding).join(" ");
let added = added.join(" ");
Some(match vm_args.trim() {
"" => added,
own => format!("{own} {added}"),
@@ -446,12 +454,53 @@ mod tests {
#[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());
let debug =
|args: &str, encoding, windows| java_debug_vm_args(args, &BTreeMap::new(), |_| None, encoding, windows);
for encoding in [None, Some(""), Some(" "), Some("UTF-8"), Some("utf8")] {
let streams = encoding.filter(|e| !e.trim().is_empty()).unwrap_or("UTF-8");
let utf8 = format!("{} -Dfile.encoding=UTF-8", options(STD_PROPERTIES, streams).join(" "));
assert_eq!(debug("", encoding, true), Some(utf8.clone()));
assert_eq!(debug("-Xmx1g", encoding, true), Some(format!("-Xmx1g {utf8}")));
assert_eq!(debug("-Xmx1g", encoding, false), None);
}
for windows in [true, false] {
assert_eq!(debug("", Some("GBK"), windows), Some(options(STD_PROPERTIES, "GBK").join(" ")));
}
}
#[test]
fn java_debug_preserves_user_options_independently() {
let debug = |args: &str| java_debug_vm_args(args, &BTreeMap::new(), |_| None, None, true);
assert_eq!(debug("-Dfile.encoding=GBK"), Some(format!("-Dfile.encoding=GBK {TOOL_UTF8}")));
for property in STD_PROPERTIES {
let args = format!("-D{property}=GBK");
assert_eq!(debug(&args), Some(format!("{args} -Dfile.encoding=UTF-8")));
assert_eq!(debug(&format!("{args} -Dfile.encoding=GBK")), None);
}
for key in ["JDK_JAVA_OPTIONS", TOOL_OPTIONS] {
for (own, expected) in [
("-Dfile.encoding=GBK", Some(TOOL_UTF8.to_owned())),
("-Dstdout.encoding=GBK", Some("-Dfile.encoding=UTF-8".to_owned())),
("-Dstdout.encoding=GBK -Dfile.encoding=GBK", None),
] {
assert_eq!(java_debug_vm_args("", &vars(&[(key, own)]), |_| None, None, true), expected);
assert_eq!(
java_debug_vm_args("", &BTreeMap::new(), |k| (k == key).then(|| own.to_owned()), None, true),
expected
);
}
// A launch environment value overrides the inherited one, as in Run.
assert_eq!(
java_debug_vm_args(
"",
&vars(&[(key, "")]),
|k| (k == key).then(|| "-Dfile.encoding=GBK".into()),
None,
true
),
Some(format!("{TOOL_UTF8} -Dfile.encoding=UTF-8"))
);
}
}
fn filtered(chunks: &[&str]) -> String {
+269 -39
View File
@@ -1,8 +1,11 @@
//! Child-process helpers shared by every crate that spawns tools.
use std::ffi::{OsStr, OsString};
use std::process::Command;
#[cfg(windows)]
use std::process::Stdio;
use std::process::{Child, Command};
use std::sync::{Arc, RwLock};
use std::time::Duration;
/// Windows `CREATE_NO_WINDOW` creation flag, for call sites that combine it
/// with other flags (and then call [`host_env`] themselves). Prefer [`prepare`] otherwise.
@@ -25,6 +28,70 @@ pub fn prepare(cmd: &mut Command) -> &mut Command {
host_env(cmd)
}
/// Starts the child as the leader of its own process group on Unix, so [`kill_tree`] and
/// [`terminate_tree`] reach what it starts in turn (`gh` → `git`, `npm` → `node`, `mvnw` →
/// the JVM). A no-op elsewhere: on Windows the tree is found from the child's process id.
pub fn own_group(cmd: &mut Command) -> &mut Command {
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
cmd.process_group(0);
}
cmd
}
/// Kills the child and everything it started, then reaps it.
///
/// - Unix: `SIGKILL` to its process group, so spawn it with [`own_group`].
/// - Windows: `taskkill /T /F`, which walks the tree from the child's process id.
pub fn kill_tree(child: &mut Child) {
#[cfg(unix)]
signal_group(child, libc::SIGKILL);
#[cfg(windows)]
taskkill_tree(child);
let _ = child.kill();
let _ = child.wait();
}
/// [`kill_tree`] that first asks the group to exit: `SIGTERM`, then `grace`, then `SIGKILL`.
/// Git removes its `index.lock` on `SIGTERM`, where `SIGKILL` leaves it behind and the next
/// git command fails. Windows has no such signal to send from outside the child's console,
/// so there it is [`kill_tree`].
pub fn terminate_tree(child: &mut Child, grace: Duration) {
#[cfg(unix)]
{
signal_group(child, libc::SIGTERM);
// Sleep rather than `try_wait`: the child stays unreaped, so its process id (and the
// group id) cannot be reused before the `SIGKILL` below.
std::thread::sleep(grace);
}
#[cfg(not(unix))]
let _ = grace;
kill_tree(child);
}
#[cfg(unix)]
fn signal_group(child: &Child, signal: libc::c_int) {
if let Ok(pid) = libc::pid_t::try_from(child.id()) {
// SAFETY: plain syscall. The child is not reaped yet, so `-pid` is still its group (or
// no group at all when it was not spawned with `own_group`, and the call fails).
unsafe {
libc::kill(-pid, signal);
}
}
}
#[cfg(windows)]
fn taskkill_tree(child: &Child) {
let mut taskkill = Command::new("taskkill");
prepare(&mut taskkill)
.args(["/T", "/F", "/PID", &child.id().to_string()])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
let _ = taskkill.status();
}
/// Gives `cmd` the host's environment back when the IDE runs from an AppImage. `AppRun` and
/// its hooks point `LD_LIBRARY_PATH`, `PATH`, `XDG_DATA_DIRS`, `GIO_MODULE_DIR`, `GTK_PATH`,
/// `GST_PLUGIN_*`, ... into the mounted image for the IDE itself; a host program that
@@ -245,10 +312,7 @@ pub fn find_executable(name: &str, search: &OsStr) -> Option<std::path::PathBuf>
#[cfg(windows)]
pub fn user_path() -> Option<OsString> {
use windows_sys::Win32::Foundation::ERROR_SUCCESS;
use windows_sys::Win32::System::Registry::{
HKEY, HKEY_CURRENT_USER, KEY_READ, RRF_RT_REG_EXPAND_SZ, RRF_RT_REG_SZ, RegCloseKey, RegGetValueW,
RegOpenKeyExW,
};
use windows_sys::Win32::System::Registry::{HKEY, HKEY_CURRENT_USER, KEY_READ, RegCloseKey, RegOpenKeyExW};
fn wide(s: &str) -> Vec<u16> {
s.encode_utf16().chain(std::iter::once(0)).collect()
@@ -259,45 +323,53 @@ pub fn user_path() -> Option<OsString> {
if RegOpenKeyExW(HKEY_CURRENT_USER, wide("Environment").as_ptr(), 0, KEY_READ, &mut key) != ERROR_SUCCESS {
return None;
}
let read = || {
// Only `Path`; `REG_EXPAND_SZ` is accepted so `%USERPROFILE%\…` entries expand.
let mut kind = 0u32;
let mut len = 0u32;
let name = wide("Path");
let status = RegGetValueW(
key,
std::ptr::null(),
name.as_ptr(),
RRF_RT_REG_SZ | RRF_RT_REG_EXPAND_SZ,
&mut kind,
std::ptr::null_mut(),
&mut len,
);
if status != ERROR_SUCCESS || len == 0 {
return None;
}
let mut buf = vec![0u16; (len as usize).div_ceil(2)];
let status = RegGetValueW(
key,
std::ptr::null(),
name.as_ptr(),
RRF_RT_REG_SZ | RRF_RT_REG_EXPAND_SZ,
&mut kind,
buf.as_mut_ptr() as *mut _,
&mut len,
);
if status != ERROR_SUCCESS {
return None;
}
let text = String::from_utf16_lossy(&buf);
Some(text.trim_end_matches('\0').to_owned())
};
let value = read();
let value = read_registry_string(key, &wide("Path"));
RegCloseKey(key);
value.filter(|v| !v.trim().is_empty()).map(OsString::from)
}
}
/// The string of value `name` under open `key`: `REG_SZ` as-is, `REG_EXPAND_SZ` expanded
/// against the current environment. `None` when the value is missing or unreadable.
///
/// A `REG_EXPAND_SZ` value's first size query reports the unexpanded size, so a value
/// that grows when expanded is re-read at the size the failed call reports. And the
/// terminating NUL can leak into the read buffer with a stray UTF-16 unit behind it —
/// seen with a `REG_EXPAND_SZ` user `Path`, where it made `env::set_var("PATH", …)`
/// panic with "strings passed to WinAPI cannot contain NULs" — so the text is cut at
/// the first NUL, not just stripped of trailing ones.
#[cfg(windows)]
fn read_registry_string(key: windows_sys::Win32::System::Registry::HKEY, name: &[u16]) -> Option<String> {
use windows_sys::Win32::Foundation::{ERROR_MORE_DATA, ERROR_SUCCESS};
use windows_sys::Win32::System::Registry::{RRF_RT_REG_EXPAND_SZ, RRF_RT_REG_SZ, RegGetValueW};
const FLAGS: u32 = RRF_RT_REG_SZ | RRF_RT_REG_EXPAND_SZ;
let mut kind = 0u32;
let mut len = 0u32;
// SAFETY: `name` is NUL-terminated and the size query writes only `kind` and `len`.
let status =
unsafe { RegGetValueW(key, std::ptr::null(), name.as_ptr(), FLAGS, &mut kind, std::ptr::null_mut(), &mut len) };
if status != ERROR_SUCCESS || len == 0 {
return None;
}
let buf = loop {
let mut buf = vec![0u16; len as usize / 2 + 1];
// SAFETY: as above; the buffer holds at least the `len` bytes the call may write.
let status = unsafe {
RegGetValueW(key, std::ptr::null(), name.as_ptr(), FLAGS, &mut kind, buf.as_mut_ptr() as *mut _, &mut len)
};
if status == ERROR_SUCCESS {
break buf;
}
if status != ERROR_MORE_DATA || len == 0 {
return None;
}
};
let text = String::from_utf16_lossy(&buf);
let end = text.find('\0').unwrap_or(text.len());
Some(text[..end].to_owned())
}
/// The user's own `PATH` entries; `None` outside Windows (see the Windows impl).
#[cfg(not(windows))]
pub fn user_path() -> Option<OsString> {
@@ -334,6 +406,96 @@ mod tests {
}
}
#[cfg(windows)]
#[test]
fn registry_strings_expand_and_stay_terminator_free() {
use windows_sys::Win32::Foundation::ERROR_SUCCESS;
use windows_sys::Win32::System::Registry::{
HKEY, HKEY_CURRENT_USER, KEY_READ, KEY_WRITE, REG_EXPAND_SZ, REG_SZ, RegCloseKey, RegCreateKeyExW,
RegDeleteTreeW, RegSetValueExW,
};
fn wide(s: &str) -> Vec<u16> {
s.encode_utf16().chain(std::iter::once(0)).collect()
}
struct TestState {
key: HKEY,
name: String,
variable: String,
previous: Option<OsString>,
}
impl Drop for TestState {
fn drop(&mut self) {
// SAFETY: the key belongs to this test; Windows environment mutation is safe.
unsafe {
RegCloseKey(self.key);
RegDeleteTreeW(HKEY_CURRENT_USER, wide(&self.name).as_ptr());
match &self.previous {
Some(value) => std::env::set_var(&self.variable, value),
None => std::env::remove_var(&self.variable),
}
}
}
}
let name = format!("Software\\ai.l0ng.openidea.registry-read-test-{}", std::process::id());
let variable = format!("OPENIDEA_TEST_REGVAR_{}", std::process::id());
let long = "x".repeat(600);
let mut key: HKEY = std::ptr::null_mut();
let opened = unsafe {
RegCreateKeyExW(
HKEY_CURRENT_USER,
wide(&name).as_ptr(),
0,
std::ptr::null(),
0,
KEY_READ | KEY_WRITE,
std::ptr::null(),
&mut key,
std::ptr::null_mut(),
)
};
assert_eq!(opened, ERROR_SUCCESS, "the test key must be creatable under HKCU\\Software");
let _cleanup = TestState { key, name, previous: std::env::var_os(&variable), variable: variable.clone() };
// SAFETY: Windows permits environment mutation. No other test uses this process-specific name.
unsafe { std::env::set_var(&variable, &long) };
let set = |value: &str, kind: u32, data: &[u16]| {
// SAFETY: `key` is open for writing and `data` holds `data.len() * 2` bytes.
assert_eq!(
unsafe {
RegSetValueExW(
key,
wide(value).as_ptr(),
0,
kind,
data.as_ptr() as *const _,
(data.len() * 2) as u32,
)
},
ERROR_SUCCESS
);
};
let read = |value: &str| read_registry_string(key, &wide(value));
set("Plain", REG_SZ, &wide("plain"));
set("Expand", REG_EXPAND_SZ, &wide(&format!("%{variable}%\\bin")));
set("ExpandLong", REG_EXPAND_SZ, &wide(&format!("%{variable}%")));
// Reproduce the reported interior terminator followed by a stale UTF-16 unit.
set("TrailingGarbage", REG_SZ, &wide("C:\\Zed\\bin\0n"));
set("ExpandTrailingGarbage", REG_EXPAND_SZ, &wide(&format!("%{variable}%\\bin\0n")));
set("Empty", REG_SZ, &wide(""));
let plain = read("Plain");
let expand = read("Expand");
let expand_long = read("ExpandLong");
assert_eq!(plain.as_deref(), Some("plain"));
assert_eq!(expand.as_deref(), Some(format!("{long}\\bin")).as_deref());
// Longer than its unexpanded form: needs the re-read at the expanded size.
assert_eq!(expand_long.as_deref(), Some(long.as_str()));
assert_eq!(read("TrailingGarbage").as_deref(), Some("C:\\Zed\\bin"));
assert_eq!(read("ExpandTrailingGarbage").as_deref(), Some(format!("{long}\\bin")).as_deref());
assert_eq!(read("Empty").as_deref(), Some(""));
assert_eq!(read("Missing"), None);
}
#[test]
fn append_path_entries_keeps_the_order_and_adds_what_is_missing() {
let join = |dirs: &[&str]| std::env::join_paths(dirs).unwrap();
@@ -502,3 +664,71 @@ mod tests {
assert_eq!(find_executable("nope", &search), None);
}
}
#[cfg(all(test, unix))]
mod kill_tests {
use super::*;
use std::path::Path;
use std::time::Instant;
/// Runs `script` in `sh` as its own group's leader and waits for it to write `ready`.
fn start(script: &str, ready: &Path) -> Child {
let mut cmd = Command::new("sh");
cmd.arg("-c").arg(script);
let child = own_group(&mut cmd).spawn().unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
while !ready.exists() {
assert!(Instant::now() < deadline, "the script never got ready");
std::thread::sleep(Duration::from_millis(10));
}
child
}
fn assert_gone(pid: libc::pid_t) {
let deadline = Instant::now() + Duration::from_secs(5);
// SAFETY: signal 0 only checks that the process exists.
while unsafe { libc::kill(pid, 0) } == 0 {
assert!(Instant::now() < deadline, "process {pid} survived");
std::thread::sleep(Duration::from_millis(20));
}
}
#[test]
fn kill_tree_kills_what_the_child_started() {
let dir = tempfile::tempdir().unwrap();
let pid_file = dir.path().join("pid");
let script = format!("sleep 30 & echo $! > '{0}.tmp' && mv '{0}.tmp' '{0}'; wait", pid_file.display());
let mut child = start(&script, &pid_file);
let grandchild: libc::pid_t = std::fs::read_to_string(&pid_file).unwrap().trim().parse().unwrap();
kill_tree(&mut child);
assert!(child.try_wait().unwrap().is_some(), "the child is reaped");
assert_gone(grandchild);
}
#[test]
fn terminate_tree_lets_the_child_clean_up() {
let dir = tempfile::tempdir().unwrap();
let (ready, cleaned) = (dir.path().join("ready"), dir.path().join("cleaned"));
let script = format!(
"trap 'touch \"{}\"; exit 0' TERM; touch \"{}\"; sleep 30 & wait",
cleaned.display(),
ready.display()
);
let mut child = start(&script, &ready);
terminate_tree(&mut child, Duration::from_millis(500));
assert!(cleaned.exists(), "the SIGTERM handler ran before the SIGKILL");
assert!(child.try_wait().unwrap().is_some(), "the child is reaped");
}
#[test]
fn terminate_tree_still_kills_a_child_that_ignores_sigterm() {
let dir = tempfile::tempdir().unwrap();
let ready = dir.path().join("ready");
let script = format!("trap '' TERM; touch \"{}\"; while :; do sleep 1; done", ready.display());
let mut child = start(&script, &ready);
let started = Instant::now();
terminate_tree(&mut child, Duration::from_millis(50));
assert!(child.try_wait().unwrap().is_some(), "the child is reaped");
assert!(started.elapsed() < Duration::from_secs(5));
}
}
+80
View File
@@ -207,6 +207,58 @@ impl ProxySettings {
env.push((OsString::from("no_proxy"), Some(OsString::from(no_proxy))));
env
}
/// System properties that make a JVM follow this configuration. A JVM reads none of
/// [`Self::child_env`]: a Java tool (jetty, Maven's resolver, a language server's own
/// HTTP client) goes direct without these, and a repository its proxy answers in
/// milliseconds can then hang it instead.
pub fn jvm_args(&self) -> Vec<String> {
match self.mode {
// macOS and Windows JVMs read the system proxy with this one.
ProxyMode::Auto => vec!["-Djava.net.useSystemProxies=true".into()],
ProxyMode::None => Vec::new(),
ProxyMode::Manual if self.manual() => self.manual_jvm_args(),
ProxyMode::Manual => Vec::new(),
}
}
fn manual_jvm_args(&self) -> Vec<String> {
let (host, port) = (self.host.trim(), self.port);
match self.kind {
ProxyKind::Http => {
// HTTPS hosts read `https.proxyHost`, plain ones `http.proxyHost`.
let mut args = vec![
format!("-Dhttp.proxyHost={host}"),
format!("-Dhttp.proxyPort={port}"),
format!("-Dhttps.proxyHost={host}"),
format!("-Dhttps.proxyPort={port}"),
];
let hosts = self.non_proxy_hosts();
if !hosts.is_empty() {
args.push(format!("-Dhttp.nonProxyHosts={}", hosts.join("|")));
}
args
}
// SOCKS proxies every TCP connection, so there is one property pair.
ProxyKind::Socks => vec![
format!("-DsocksProxyHost={host}"),
format!("-DsocksProxyPort={port}"),
"-Djava.net.useSystemProxies=false".into(),
],
}
}
/// [`Self::no_proxy`] in the JVM's `http.nonProxyHosts` format: `|` between host
/// globs. CIDR ranges and IPv6 literals have no equivalent there and are dropped.
fn non_proxy_hosts(&self) -> Vec<String> {
let mut hosts: Vec<String> = LOOPBACK.iter().filter(|h| !h.contains(':')).map(|h| (*h).to_owned()).collect();
for entry in self.no_proxy_entries() {
if !entry.contains(['/', ':', '[', ']']) && !hosts.contains(&entry) {
hosts.push(entry);
}
}
hosts
}
}
/// Case-insensitive host glob (`pattern` is lowercase): `*` matches any run of characters.
@@ -292,6 +344,34 @@ mod tests {
assert!(p.child_env().is_empty());
}
#[test]
fn jvm_args_carry_the_proxy_as_system_properties() {
// A JVM reads none of `child_env`: a Java language server needs these to not go
// direct while curl, git and Node follow the environment.
assert_eq!(ProxySettings::default().jvm_args(), ["-Djava.net.useSystemProxies=true"]);
assert!(ProxySettings { mode: ProxyMode::None, ..manual("proxy", 3128) }.jvm_args().is_empty());
assert_eq!(
manual("proxy.corp", 3128).jvm_args(),
[
"-Dhttp.proxyHost=proxy.corp",
"-Dhttp.proxyPort=3128",
"-Dhttps.proxyHost=proxy.corp",
"-Dhttps.proxyPort=3128",
"-Dhttp.nonProxyHosts=localhost|127.0.0.1",
]
);
let socks = ProxySettings { kind: ProxyKind::Socks, ..manual("10.0.0.1", 1080) };
assert_eq!(socks.jvm_args()[..2], ["-DsocksProxyHost=10.0.0.1", "-DsocksProxyPort=1080"]);
// `http.nonProxyHosts` takes host globs: CIDR ranges and IPv6 literals drop out.
let p = ProxySettings {
no_proxy: "*.corp.com, 172.0.0.0/8, [::1], ::1, localhost".into(),
..manual("proxy.corp", 3128)
};
assert_eq!(p.jvm_args().last().unwrap(), "-Dhttp.nonProxyHosts=localhost|127.0.0.1|*.corp.com");
// A manual proxy without a host connects directly, and so does its JVM.
assert!(manual(" ", 80).jvm_args().is_empty());
}
#[test]
fn manual_http_and_socks_urls() {
let p = manual("proxy.corp", 3128);
+140 -1
View File
@@ -58,6 +58,43 @@ pub struct Settings {
pub deployment: DeploymentSettings,
/// Build, Execution, Deployment › Run Targets.
pub run_targets: Vec<RunTarget>,
pub cmake: CMakeSettings,
}
/// Absent profiles retain automatic discovery for existing projects.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct CMakeSettings {
#[serde(skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub profiles: Option<Vec<CMakeProfile>>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct CMakeProfile {
pub name: String,
pub build_type: String,
pub generator: String,
pub cmake_args: Vec<String>,
/// Empty uses cmake-build-<lowercase build type>.
pub build_dir: String,
pub build_args: Vec<String>,
pub env: BTreeMap<String, String>,
}
impl Default for CMakeProfile {
fn default() -> Self {
Self {
name: "Debug".into(),
build_type: "Debug".into(),
generator: String::new(),
cmake_args: Vec::new(),
build_dir: String::new(),
build_args: Vec::new(),
env: BTreeMap::new(),
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
@@ -154,6 +191,7 @@ impl Default for Settings {
ssh: SshSettings::default(),
deployment: DeploymentSettings::default(),
run_targets: Vec::new(),
cmake: CMakeSettings::default(),
}
}
}
@@ -197,7 +235,7 @@ impl Default for GeneralSettings {
fn default() -> Self {
Self {
reopen_last_project: true,
confirm_exit: false,
confirm_exit: true,
open_project_in: "ask".into(),
default_project_directory: String::new(),
save_on_idle: false,
@@ -276,6 +314,8 @@ pub struct AppearanceSettings {
/// Apply `ui_font_family` / `ui_font_size` (otherwise the theme's).
pub use_custom_font: bool,
pub ui_font_family: String,
/// Font tried when `ui_font_family` lacks a glyph; empty = none.
pub ui_font_fallback: String,
pub contrast_scrollbars: bool,
/// Adjust colours for red-green vision deficiency.
pub red_green_vision: bool,
@@ -292,6 +332,7 @@ pub struct AppearanceSettings {
pub editor_antialiasing: String,
pub show_tool_window_bars: bool,
pub show_tool_window_names: bool,
pub show_tool_window_badges: bool,
pub widescreen_tool_windows: bool,
/// Status bar widgets shown (`true`) or hidden (`false`) by the user
/// (status bar context menu); widgets not listed use their default.
@@ -325,6 +366,7 @@ impl Default for AppearanceSettings {
zoom: 100,
use_custom_font: false,
ui_font_family: "Inter".into(),
ui_font_fallback: String::new(),
contrast_scrollbars: false,
red_green_vision: false,
different_tool_window_background: false,
@@ -338,6 +380,7 @@ impl Default for AppearanceSettings {
editor_antialiasing: "greyscale".into(),
show_tool_window_bars: true,
show_tool_window_names: false,
show_tool_window_badges: true,
widescreen_tool_windows: false,
status_bar_widgets: BTreeMap::new(),
main_toolbar: None,
@@ -503,7 +546,49 @@ pub struct LanguagesSettings {
pub rust: RustSettings,
pub go: GoSettings,
pub typescript: TypeScriptSettings,
pub prettier: PrettierSettings,
pub java: JavaSettings,
pub ruby: RubySettings,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct PrettierSettings {
/// Empty path: project node_modules, managed installation, then PATH.
pub path: String,
pub languages: Vec<String>,
/// Prefer Prettier even when a language server offers formatting.
pub prefer: bool,
pub run_on_save: bool,
}
impl Default for PrettierSettings {
fn default() -> Self {
Self {
path: String::new(),
languages: [
"markdown",
"yaml",
"css",
"scss",
"less",
"html",
"graphql",
"javascript",
"javascriptreact",
"typescript",
"typescriptreact",
"json",
"jsonc",
"vue",
]
.into_iter()
.map(String::from)
.collect(),
prefer: false,
run_on_save: false,
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
@@ -521,6 +606,13 @@ impl Default for PythonSettings {
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct RubySettings {
/// Project interpreter executable; empty = auto-detect (`.ruby-version`, mise / asdf, else `ruby` on PATH).
pub interpreter: String,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct NodeSettings {
@@ -719,6 +811,53 @@ impl Settings {
mod tests {
use super::*;
#[test]
fn cmake_profile_argument_arrays_round_trip() {
let mut settings = Settings::default();
assert!(settings.cmake.profiles.is_none());
assert!(CMakeProfile::default().cmake_args.is_empty());
assert!(CMakeProfile::default().build_args.is_empty());
let profile = CMakeProfile {
cmake_args: ["-DMESSAGE=two words", "-DQUOTE=\"hello\"", ""].map(String::from).into(),
build_args: ["-j", "8", "--", "LABEL=it's a build"].map(String::from).into(),
..Default::default()
};
settings.cmake.profiles = Some(vec![profile.clone()]);
let value = serde_json::to_value(&settings).unwrap();
assert_eq!(value["cmake"]["profiles"][0]["cmakeArgs"], serde_json::json!(profile.cmake_args));
assert_eq!(value["cmake"]["profiles"][0]["buildArgs"], serde_json::json!(profile.build_args));
assert_eq!(serde_json::from_value::<Settings>(value).unwrap(), settings);
}
#[test]
fn ui_font_fallback_defaults_empty_and_round_trips() {
let old: Settings = serde_json::from_str(r#"{"appearance":{"useCustomFont":true}}"#).unwrap();
assert!(old.appearance.ui_font_fallback.is_empty());
for family in ["", "Songti SC", "Maple Mono NF CN"] {
let mut settings = Settings::default();
settings.appearance.ui_font_fallback = family.into();
let json = serde_json::to_value(&settings).unwrap();
assert_eq!(json["appearance"]["uiFontFallback"], family);
let restored: Settings = serde_json::from_value(json).unwrap();
assert_eq!(restored.appearance.ui_font_fallback, family);
}
}
#[test]
fn tool_window_badges_default_on_and_round_trip() {
let old: Settings = serde_json::from_str(r#"{"appearance":{"showToolWindowNames":true}}"#).unwrap();
assert!(old.appearance.show_tool_window_badges);
assert!(Settings::default().appearance.show_tool_window_badges);
for enabled in [false, true] {
let mut settings = Settings::default();
settings.appearance.show_tool_window_badges = enabled;
let json = serde_json::to_value(&settings).unwrap();
assert_eq!(json["appearance"]["showToolWindowBadges"], enabled);
let restored: Settings = serde_json::from_value(json).unwrap();
assert_eq!(restored.appearance.show_tool_window_badges, enabled);
}
}
#[test]
fn main_menu_style_defaults_and_round_trips() {
let old: Settings = serde_json::from_str(r#"{"appearance":{"compactMode":true}}"#).unwrap();
+44 -8
View File
@@ -97,6 +97,16 @@ pub struct TextEdit {
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
pub struct WorkspaceEdit {
pub files: Vec<FileEdit>,
/// Content fingerprints captured before presenting an edit for confirmation.
#[serde(default)]
#[specta(optional)]
pub preconditions: Vec<WorkspaceEditPrecondition>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
pub struct WorkspaceEditPrecondition {
pub path: crate::IpcPath,
pub hash: String,
}
impl WorkspaceEdit {
@@ -108,17 +118,26 @@ impl WorkspaceEdit {
let mut resolve_aliases = true;
for file in std::mem::take(&mut self.files) {
match file {
FileEdit::Edit { path, edits } => {
FileEdit::Edit { path, edits, version } => {
let path = if resolve_aliases {
dunce::canonicalize(&path).unwrap_or_else(|_| path.to_path_buf())
} else {
path.to_path_buf()
};
let index = *batch.entry(path.clone()).or_insert_with(|| {
files.push(FileEdit::Edit { path: path.into(), edits: Vec::new() });
files.push(FileEdit::Edit { path: path.clone().into(), edits: Vec::new(), version });
files.len() - 1
});
let FileEdit::Edit { edits: own, .. } = &mut files[index] else { unreachable!() };
let FileEdit::Edit { edits: own, version: expected, .. } = &mut files[index] else {
unreachable!()
};
if expected.is_some() && version.is_some() && *expected != version {
// Keep incompatible constraints so application rejects them
// before changing any file instead of silently losing one.
files.push(FileEdit::Edit { path: path.into(), edits, version });
continue;
}
*expected = expected.or(version);
for edit in edits {
if !own.contains(&edit) {
own.push(edit);
@@ -140,10 +159,26 @@ impl WorkspaceEdit {
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum FileEdit {
Edit { path: crate::IpcPath, edits: Vec<TextEdit> },
Create { path: crate::IpcPath, overwrite: bool },
Rename { from: crate::IpcPath, to: crate::IpcPath },
Delete { path: crate::IpcPath, recursive: bool },
Edit {
path: crate::IpcPath,
edits: Vec<TextEdit>,
/// LSP TextDocumentEdit version; null/unversioned edits use content preconditions.
#[serde(default)]
#[specta(optional)]
version: Option<i32>,
},
Create {
path: crate::IpcPath,
overwrite: bool,
},
Rename {
from: crate::IpcPath,
to: crate::IpcPath,
},
Delete {
path: crate::IpcPath,
recursive: bool,
},
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Type)]
@@ -254,7 +289,7 @@ mod workspace_edit_tests {
std::fs::write(&real, "add()").unwrap();
std::os::unix::fs::symlink(&real, &alias).unwrap();
let change = TextEdit { range: Range::new(Pos::new(0, 0), Pos::new(0, 3)), new_text: "sum_values".into() };
let edit = |path: PathBuf| FileEdit::Edit { path: path.into(), edits: vec![change.clone()] };
let edit = |path: PathBuf| FileEdit::Edit { path: path.into(), edits: vec![change.clone()], version: None };
let mut workspace = WorkspaceEdit {
files: vec![
edit(real.clone()),
@@ -262,6 +297,7 @@ mod workspace_edit_tests {
FileEdit::Create { path: dir.path().join("other").into(), overwrite: false },
edit(real),
],
..Default::default()
};
workspace.deduplicate();
assert_eq!(workspace.files.len(), 3);
+2
View File
@@ -18,6 +18,7 @@ pub enum Tool {
Go,
Maven,
Gradle,
Ruby,
}
impl Tool {
@@ -30,6 +31,7 @@ impl Tool {
Tool::Go => &["go", "golang"],
Tool::Maven => &["maven"],
Tool::Gradle => &["gradle"],
Tool::Ruby => &["ruby"],
}
}
}
+3
View File
@@ -41,6 +41,8 @@ impl Default for VcsSettings {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct GitSettings {
/// Show blame after the current editor line.
pub inline_blame: bool,
/// Path to the git executable; empty = auto-detect.
pub executable: String,
/// Default of Update Project: `merge` | `rebase`.
@@ -71,6 +73,7 @@ pub struct GitSettings {
impl Default for GitSettings {
fn default() -> Self {
Self {
inline_blame: false,
executable: String::new(),
update_method: "merge".into(),
auto_update_on_push_rejected: false,
+10
View File
@@ -1389,6 +1389,16 @@ impl DebugManager {
})
}
/// Stops every session `key` started: the window that opened the project is closing.
pub async fn stop_key(&self, key: &str) {
let ids: Vec<DebugSessionId> = self.sessions(Some(key)).into_iter().map(|s| s.id).collect();
for id in ids {
if let Err(e) = self.stop(id).await {
tracing::debug!("debug session {id:?} did not stop: {e}");
}
}
}
/// Stops a session and its child sessions.
pub async fn stop(&self, s: DebugSessionId) -> IdeResult<()> {
let session = self.session(s)?;
+1
View File
@@ -905,6 +905,7 @@ fn godot_registry(port: u16, command: &str, args: &[&str]) -> Arc<LanguageRegist
indent: None,
ui_mode: None,
framework_ui_modes: Vec::new(),
formatter: None,
tree_sitter: None,
language_servers: vec![],
debugger: Some(ide_language::DebuggerConfig {
-3
View File
@@ -13,8 +13,5 @@ specta.workspace = true
tracing.workspace = true
which = "8"
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[dev-dependencies]
tempfile.workspace = true
+10 -28
View File
@@ -1,11 +1,16 @@
//! Child processes of the docker CLI: bounded runs and cancellable line streams.
use ide_core::process::terminate_tree;
use ide_core::{CancellationToken, IdeError, IdeResult};
use std::io::Read;
use std::process::{Child, Command, Stdio};
use std::sync::mpsc;
use std::time::{Duration, Instant};
/// How long a cancelled or timed-out child's process group (CLI plugins such as compose)
/// gets to exit on `SIGTERM`, and clean up, before `SIGKILL`.
const KILL_GRACE: Duration = Duration::from_millis(20);
pub(crate) struct Output {
pub stdout: String,
pub stderr: String,
@@ -20,36 +25,13 @@ pub(crate) enum Stream {
fn prepare(cmd: &mut Command) {
cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
cmd.process_group(0);
}
ide_core::process::prepare(cmd);
ide_core::process::prepare(ide_core::process::own_group(cmd));
}
fn spawn(cmd: &mut Command) -> IdeResult<Child> {
cmd.spawn().map_err(|e| IdeError::unavailable(format!("could not run docker: {e}")))
}
/// Kills the child and, on Unix, its whole process group (CLI plugins such as compose).
pub(crate) fn kill(child: &mut Child) {
#[cfg(unix)]
{
let pid = child.id() as libc::pid_t;
// SAFETY: plain syscall; the child leads its own process group (`prepare`).
unsafe {
libc::kill(-pid, libc::SIGTERM);
}
std::thread::sleep(Duration::from_millis(20));
unsafe {
libc::kill(-pid, libc::SIGKILL);
}
}
let _ = child.kill();
let _ = child.wait();
}
fn read_all(mut pipe: impl Read + Send + 'static) -> std::thread::JoinHandle<String> {
std::thread::spawn(move || {
let mut buf = Vec::new();
@@ -88,11 +70,11 @@ pub(crate) fn run_with_input(
break status;
}
if cancel.is_some_and(CancellationToken::is_cancelled) {
kill(&mut child);
terminate_tree(&mut child, KILL_GRACE);
return Err(IdeError::cancelled());
}
if Instant::now() >= deadline {
kill(&mut child);
terminate_tree(&mut child, KILL_GRACE);
return Err(IdeError::unavailable("docker did not respond in time"));
}
std::thread::sleep(Duration::from_millis(10));
@@ -166,7 +148,7 @@ pub(crate) fn stream(
let mut stderr_tail: Vec<String> = Vec::new();
while open > 0 {
if cancel.is_cancelled() {
kill(&mut child);
terminate_tree(&mut child, KILL_GRACE);
return Err(IdeError::cancelled());
}
match rx.recv_timeout(Duration::from_millis(50)) {
@@ -189,7 +171,7 @@ pub(crate) fn stream(
break status;
}
if cancel.is_cancelled() {
kill(&mut child);
terminate_tree(&mut child, KILL_GRACE);
return Err(IdeError::cancelled());
}
std::thread::sleep(Duration::from_millis(10));
+2 -1
View File
@@ -7,10 +7,11 @@ license.workspace = true
[dependencies]
serde.workspace = true
serde_json.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-foundation = { version = "0.3", default-features = false, features = ["std", "CFArray", "CFDictionary", "CFNumber", "CFString", "CFURL"] }
objc2-core-text = { version = "0.3", default-features = false, features = ["std", "CTFontCollection", "CTFontDescriptor", "CTFontTraits"] }
[target.'cfg(windows)'.dependencies]
+239 -4
View File
@@ -1,8 +1,7 @@
//! 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
//! Each platform asks the native font system for installed families: CoreText on
//! macOS, DirectWrite on Windows, fontconfig (`fc-list`) on Linux and the BSDs.
//! The list is read on first use and cached until explicitly refreshed; the
//! query can take a few hundred milliseconds, so callers run it off the UI and
@@ -84,14 +83,164 @@ pub fn parse_fc_list(output: &str) -> Vec<(String, bool)> {
.collect()
}
/// Binary font bundle: little-endian u32 JSON-header length, a face metadata
/// array, then raw font bytes. Paths never cross IPC; only CoreText-enumerated
/// files under user font directories can be read, selected by family name.
/// Includes only the nearest regular/bold faces and their optional italics.
pub fn user_font_data(family: &str) -> std::io::Result<Vec<u8>> {
#[cfg(target_os = "macos")]
{
let entries = imp::user_faces();
bundle_family(family, &entries)
}
#[cfg(not(target_os = "macos"))]
{
let _ = family;
Err(std::io::Error::new(std::io::ErrorKind::Unsupported, "User font loading is macOS only"))
}
}
#[cfg(any(target_os = "macos", test))]
#[derive(Debug)]
struct UserFace {
family: String,
path: std::path::PathBuf,
weight: u16,
italic: bool,
}
#[cfg(any(target_os = "macos", test))]
fn user_font_path(path: &std::path::Path, roots: &[std::path::PathBuf]) -> Option<std::path::PathBuf> {
let path = path.canonicalize().ok()?;
roots.iter().any(|root| path.starts_with(root)).then_some(path)
}
#[cfg(any(target_os = "macos", test))]
fn family_faces<'a>(family: &str, entries: &'a [UserFace]) -> Vec<&'a UserFace> {
let mut selected: Vec<&UserFace> = Vec::new();
for italic in [false, true] {
for weight in [400u16, 700] {
let closest = entries
.iter()
.filter(|e| e.family.eq_ignore_ascii_case(family.trim()) && e.italic == italic)
.min_by_key(|e| (e.weight.abs_diff(weight), e.weight, &e.path));
if let Some(face) = closest
&& !selected.iter().any(|f| std::ptr::eq(*f, face))
{
selected.push(face);
}
}
}
selected
}
#[cfg(any(target_os = "macos", test))]
fn bundle_family(family: &str, entries: &[UserFace]) -> std::io::Result<Vec<u8>> {
#[derive(Serialize)]
struct Face {
weight: u16,
style: &'static str,
offset: usize,
length: usize,
}
let mut faces = Vec::new();
let mut data = Vec::new();
let mut files = BTreeMap::new();
// Bound large CJK families to regular/bold and their nearest italic faces.
for entry in family_faces(family, entries) {
// Reject a file replaced by a symlink after enumeration.
if entry.path.canonicalize()? != entry.path {
return Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "Font file changed"));
}
let (offset, length) = if let Some(range) = files.get(&entry.path) {
*range
} else {
let bytes = std::fs::read(&entry.path)?;
let range = (data.len(), bytes.len());
data.extend(bytes);
files.insert(entry.path.clone(), range);
range
};
faces.push(Face {
weight: entry.weight,
style: if entry.italic { "italic" } else { "normal" },
offset,
length,
});
}
if faces.is_empty() {
return Err(std::io::Error::new(std::io::ErrorKind::NotFound, "No installed user font family"));
}
let header = serde_json::to_vec(&faces)?;
let mut out = (header.len() as u32).to_le_bytes().to_vec();
out.extend(header);
out.extend(data);
Ok(out)
}
#[cfg(target_os = "macos")]
mod imp {
use objc2_core_foundation::{CFArray, CFDictionary, CFNumber, CFString, CFType};
use objc2_core_foundation::{CFArray, CFDictionary, CFNumber, CFString, CFType, CFURL};
use objc2_core_text::{
CTFontCollection, CTFontDescriptor, CTFontSymbolicTraits, kCTFontFamilyNameAttribute, kCTFontSymbolicTrait,
kCTFontTraitsAttribute,
kCTFontTraitsAttribute, kCTFontURLAttribute, kCTFontWeightTrait,
};
pub(crate) fn user_faces() -> Vec<super::UserFace> {
let mut roots = vec![std::path::PathBuf::from("/Library/Fonts")];
if let Some(home) = std::env::var_os("HOME") {
roots.push(std::path::PathBuf::from(home).join("Library/Fonts"));
}
let roots: Vec<_> = roots.into_iter().filter_map(|r| r.canonicalize().ok()).collect();
// SAFETY: CoreText descriptors carry documented CFString, CFURL and trait dictionary values.
unsafe {
let collection = CTFontCollection::from_available_fonts(None);
let Some(descriptors) = collection.matching_font_descriptors() else { return Vec::new() };
let mut seen = std::collections::BTreeSet::new();
descriptors
.cast_unchecked::<CTFontDescriptor>()
.iter()
.filter_map(|d| {
let family = d.attribute(kCTFontFamilyNameAttribute)?.downcast::<CFString>().ok()?.to_string();
let path = d.attribute(kCTFontURLAttribute)?.downcast::<CFURL>().ok()?.to_file_path()?;
let path = super::user_font_path(&path, &roots)?;
let traits = d.attribute(kCTFontTraitsAttribute)?.downcast::<CFDictionary>().ok()?;
let traits = traits.cast_unchecked::<CFString, CFType>();
let symbolic = traits
.get(kCTFontSymbolicTrait)
.and_then(|n| n.downcast::<CFNumber>().ok())
.and_then(|n| n.as_i64())
.unwrap_or(0) as u32;
let weight = traits
.get(kCTFontWeightTrait)
.and_then(|n| n.downcast::<CFNumber>().ok())
.and_then(|n| n.as_f64())
.unwrap_or(0.0);
// CoreText's normalized weight values corresponding to CSS's nine standard weights.
let weight = [
(-0.8, 100),
(-0.6, 200),
(-0.4, 300),
(0.0, 400),
(0.23, 500),
(0.3, 600),
(0.4, 700),
(0.56, 800),
(0.62, 900),
]
.into_iter()
.min_by(|(a, _), (b, _)| (weight - a).abs().total_cmp(&(weight - b).abs()))?
.1;
let italic = symbolic & CTFontSymbolicTraits::TraitItalic.0 != 0;
if !seen.insert((family.clone(), path.clone(), weight, italic)) {
return None;
}
Some(super::UserFace { family, path, weight, italic })
})
.collect()
}
}
/// Every face CoreText makes available to this process, as (family, fixed pitch).
pub(crate) fn list(_refresh: bool) -> Vec<(String, bool)> {
// SAFETY: CoreText's documented types: the matching descriptors array
@@ -210,6 +359,92 @@ mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
#[test]
fn selects_only_regular_bold_and_matching_italics_from_dense_families() {
let entries: Vec<_> = [false, true]
.into_iter()
.flat_map(|italic| {
(100..=900).step_by(100).map(move |weight| UserFace {
family: "CJK".into(),
path: format!("{weight}-{italic}.ttf").into(),
weight,
italic,
})
})
.collect();
let selected = family_faces(" cjk ", &entries);
assert_eq!(
selected.iter().map(|f| (f.weight, f.italic)).collect::<Vec<_>>(),
[(400, false), (700, false), (400, true), (700, true),]
);
assert!(family_faces("unknown", &entries).is_empty());
}
#[test]
fn selects_nearest_available_weights_once_with_optional_italics() {
let entries: Vec<_> = [(300, false), (500, false), (800, false), (500, true)]
.into_iter()
.map(|(weight, italic)| UserFace {
family: "Sparse".into(),
path: format!("{weight}-{italic}.ttf").into(),
weight,
italic,
})
.collect();
let selected = family_faces("Sparse", &entries);
assert_eq!(
selected.iter().map(|f| (f.weight, f.italic)).collect::<Vec<_>>(),
[(300, false), (800, false), (500, true),]
);
assert_eq!(family_faces("Sparse", &entries[..1]).len(), 1);
}
#[test]
fn bundle_only_reads_enumerated_family_faces() {
let root = std::env::current_dir().unwrap().join(format!("../../.font-bundle-test-{}", std::process::id()));
std::fs::create_dir_all(&root).unwrap();
let root = root.canonicalize().unwrap();
let regular = root.join("regular.ttf");
let bold = root.join("bold.ttf");
std::fs::write(&regular, [1, 2, 3]).unwrap();
std::fs::write(&bold, [4, 5]).unwrap();
let entries = [
UserFace { family: "Test Nerd Font".into(), path: regular.clone(), weight: 400, italic: false },
UserFace { family: "Test Nerd Font".into(), path: bold, weight: 700, italic: false },
// A non-selected face is never opened, even if its file has disappeared.
UserFace { family: "Test Nerd Font".into(), path: root.join("thin.ttf"), weight: 100, italic: false },
];
let result = bundle_family("test nerd font", &entries).unwrap();
let len = u32::from_le_bytes(result[..4].try_into().unwrap()) as usize;
let faces: serde_json::Value = serde_json::from_slice(&result[4..4 + len]).unwrap();
assert_eq!(faces[0]["weight"], 400);
assert_eq!(faces[1]["weight"], 700);
assert_eq!(faces.as_array().unwrap().len(), 2);
assert_eq!(&result[4 + len..], &[1, 2, 3, 4, 5]);
// Existing files are not inputs: neither paths nor unenumerated families grant access.
assert!(bundle_family(regular.to_str().unwrap(), &entries).is_err());
assert!(bundle_family("Unenumerated", &entries).is_err());
assert!(user_font_path(&regular, &[]).is_none());
assert!(user_font_path(&regular, std::slice::from_ref(&root)).is_some());
// Multiple selected descriptors in a collection still transfer the file only once.
let shared = [
UserFace { family: "Collection".into(), path: regular.clone(), weight: 400, italic: false },
UserFace { family: "Collection".into(), path: regular, weight: 700, italic: false },
];
let result = bundle_family("Collection", &shared).unwrap();
let len = u32::from_le_bytes(result[..4].try_into().unwrap()) as usize;
let faces: serde_json::Value = serde_json::from_slice(&result[4..4 + len]).unwrap();
assert_eq!(faces[0]["offset"], faces[1]["offset"]);
assert_eq!(&result[4 + len..], &[1, 2, 3]);
#[cfg(unix)]
{
let link = root.join("outside.ttf");
std::os::unix::fs::symlink(std::env::current_exe().unwrap(), &link).unwrap();
assert!(user_font_path(&link, std::slice::from_ref(&root)).is_none());
}
std::fs::remove_dir_all(root).unwrap();
}
fn fam(name: &str, monospaced: bool) -> FontFamily {
FontFamily { name: name.into(), monospaced }
}
-3
View File
@@ -13,6 +13,3 @@ serde_json.workspace = true
specta.workspace = true
tracing.workspace = true
which = "8"
[target.'cfg(unix)'.dependencies]
libc = "0.2"
+10 -28
View File
@@ -1,11 +1,16 @@
//! Child processes of the gh CLI: bounded runs and cancellable line streams.
use ide_core::process::terminate_tree;
use ide_core::{CancellationToken, IdeError, IdeResult};
use std::io::Read;
use std::process::{Child, Command, Stdio};
use std::sync::mpsc;
use std::time::{Duration, Instant};
/// How long a cancelled or timed-out child's process group (gh spawns git and helpers)
/// gets to exit on `SIGTERM` before `SIGKILL`: git removes its lock files on `SIGTERM`.
const KILL_GRACE: Duration = Duration::from_millis(20);
pub(crate) struct Output {
pub stdout: String,
pub stderr: String,
@@ -20,36 +25,13 @@ pub(crate) enum Stream {
fn prepare(cmd: &mut Command) {
cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
cmd.process_group(0);
}
ide_core::process::prepare(cmd);
ide_core::process::prepare(ide_core::process::own_group(cmd));
}
fn spawn(cmd: &mut Command) -> IdeResult<Child> {
cmd.spawn().map_err(|e| IdeError::unavailable(format!("could not run gh: {e}")))
}
/// Kills the child and, on Unix, its whole process group (gh spawns git and helpers).
pub(crate) fn kill(child: &mut Child) {
#[cfg(unix)]
{
let pid = child.id() as libc::pid_t;
// SAFETY: plain syscall; the child leads its own process group (`prepare`).
unsafe {
libc::kill(-pid, libc::SIGTERM);
}
std::thread::sleep(Duration::from_millis(20));
unsafe {
libc::kill(-pid, libc::SIGKILL);
}
}
let _ = child.kill();
let _ = child.wait();
}
fn read_all(mut pipe: impl Read + Send + 'static) -> std::thread::JoinHandle<String> {
std::thread::spawn(move || {
let mut buf = Vec::new();
@@ -92,11 +74,11 @@ pub(crate) fn run_with_input(
break status;
}
if cancel.is_some_and(CancellationToken::is_cancelled) {
kill(&mut child);
terminate_tree(&mut child, KILL_GRACE);
return Err(IdeError::cancelled());
}
if Instant::now() >= deadline {
kill(&mut child);
terminate_tree(&mut child, KILL_GRACE);
return Err(IdeError::unavailable("gh did not respond in time"));
}
std::thread::sleep(Duration::from_millis(10));
@@ -170,7 +152,7 @@ pub(crate) fn stream(
let mut stderr_tail: Vec<String> = Vec::new();
while open > 0 {
if cancel.is_cancelled() {
kill(&mut child);
terminate_tree(&mut child, KILL_GRACE);
return Err(IdeError::cancelled());
}
match rx.recv_timeout(Duration::from_millis(50)) {
@@ -193,7 +175,7 @@ pub(crate) fn stream(
break status;
}
if cancel.is_cancelled() {
kill(&mut child);
terminate_tree(&mut child, KILL_GRACE);
return Err(IdeError::cancelled());
}
std::thread::sleep(Duration::from_millis(10));
+8 -1
View File
@@ -48,7 +48,14 @@ parking_lot.workspace = true
dunce.workspace = true
dirs.workspace = true
uuid.workspace = true
tempfile.workspace = true
[dev-dependencies]
ide-ssh = { workspace = true, features = ["testing"] }
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "io-util", "test-util"] }
tempfile.workspace = true
[target.'cfg(unix)'.dependencies]
rustix = { version = "1.1", features = ["fs", "process"] }
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem"] }
+1
View File
@@ -55,6 +55,7 @@ async fn run(base: PathBuf) -> i32 {
let sink_peer = peer.clone();
let host = Arc::new(Host::new(HostConfig {
config_dir,
local_data_dir: data_dir.clone(),
data_dir,
languages,
syntax: Arc::default(),
+476
View File
@@ -0,0 +1,476 @@
//! Storage policy and best-effort migration of rebuildable desktop data.
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
/// Unknown entries and recovery/user content deliberately stay in roaming data.
pub fn is_local(entry: &Path) -> bool {
["tools", "language-server-runtimes", "index", "cache", "lsp", "notebook-runtime", "server", "logs"]
.iter()
.any(|root| entry.starts_with(root))
|| entry.starts_with("agents/tools")
|| entry == Path::new("agents/registry.json")
|| (entry.components().count() == 1
&& entry.file_name().is_some_and(|name| name.to_string_lossy().starts_with("java-usages-"))
&& entry.extension().is_some_and(|ext| ext == "jar" || ext == "tmp"))
}
pub fn data_path(data: &Path, local: &Path, entry: &Path) -> PathBuf {
if is_local(entry) { local.join(entry) } else { data.join(entry) }
}
#[derive(Debug, PartialEq, Eq)]
enum Action {
Skip,
Rename,
}
fn action(same_root: bool, source_exists: bool, target_exists: bool) -> Action {
if same_root || !source_exists || target_exists { Action::Skip } else { Action::Rename }
}
#[derive(Debug, PartialEq, Eq)]
enum RenameFailure {
Copy,
KeepSource,
}
fn rename_failure(error: &io::Error) -> RenameFailure {
if error.kind() == io::ErrorKind::CrossesDevices { RenameFailure::Copy } else { RenameFailure::KeepSource }
}
/// Only Windows splits these paths. Renames are cheap; cross-volume copies run in
/// the background. Consumers always use Local, even when migration fails.
pub fn migrate(data: &Path, local: &Path) {
if cfg!(target_os = "windows") && data != local {
migrate_entries(data, local);
}
}
fn migrate_entries(data: &Path, local: &Path) {
let mut entries: Vec<PathBuf> = [
"language-server-runtimes",
"index",
"cache",
"lsp",
"notebook-runtime",
"server",
"java-usages-1.1.1.jar",
"java-usages-1.2.0.jar",
"agents/registry.json",
]
.into_iter()
.map(PathBuf::from)
.collect();
// Virtual environments contain absolute interpreter paths. Keep the old copy
// intact; these tools are installed afresh in Local when requested. Registry
// agents also have absolute commands in user config, so leave legacy installs.
match fs::read_dir(data.join("tools")) {
Ok(tools) => {
for entry in tools {
let entry = match entry {
Ok(entry) => entry,
Err(error) => {
tracing::warn!(%error, "cannot enumerate legacy tool; keeping old data, using Local");
continue;
}
};
let name = entry.file_name();
let venv = ide_tools::registry::TOOLS.iter().any(|tool| {
name == tool.name && matches!(tool.method, ide_tools::registry::Method::PipVenv { .. })
});
if venv {
tracing::info!(?name, "leaving non-relocatable legacy tool; reinstall into Local when needed");
} else {
entries.push(Path::new("tools").join(name));
}
}
}
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => tracing::warn!(%error, "cannot enumerate legacy tools; keeping old data, using Local"),
}
let mut copies = Vec::new();
for entry in entries {
let source = data.join(&entry);
let target = data_path(data, local, &entry);
match try_rename(&source, &target) {
Ok(true) => copies.push((source, target)),
Ok(false) => {}
Err(error) => {
tracing::warn!(?source, ?target, %error, "data migration failed; keeping old data, using Local")
}
}
}
if !copies.is_empty() {
let result = std::thread::Builder::new().name("openidea-data-migration".into()).spawn(move || {
for (source, target) in copies {
tracing::info!(?source, ?target, "copying legacy data in background");
if let Err(error) = copy_then_remove(&source, &target) {
tracing::warn!(?source, ?target, %error, "data copy failed; keeping old data, using Local");
}
}
});
if let Err(error) = result {
tracing::warn!(%error, "cannot start data migration worker; keeping old data, using Local");
}
}
}
/// Returns true when a cross-volume copy is needed.
fn try_rename(source: &Path, target: &Path) -> io::Result<bool> {
if action(source == target, source.try_exists()?, target.try_exists()?) == Action::Skip {
return Ok(false);
}
if fs::symlink_metadata(source)?.file_type().is_symlink() {
return Err(io::Error::other("refusing to migrate a linked data entry"));
}
fs::create_dir_all(target.parent().ok_or_else(|| io::Error::other("missing data parent"))?)?;
// Relative wrappers work at the old location too, so even a failed rename
// leaves the old installation usable and needs no rollback.
relocate_shims(source, source)?;
match rename_missing(source, target) {
Ok(()) => {
tracing::info!(?source, ?target, "moved legacy data to Local");
Ok(false)
}
Err(error) if rename_failure(&error) == RenameFailure::Copy => Ok(true),
Err(error) => Err(error),
}
}
fn copy_then_remove(source: &Path, target: &Path) -> io::Result<()> {
if target.try_exists()? {
return Ok(());
}
let parent = target.parent().ok_or_else(|| io::Error::other("missing data parent"))?;
fs::create_dir_all(parent)?;
let staging = parent.join(format!(".migration-{}", uuid::Uuid::new_v4()));
let result = (|| {
copy_tree(source, &staging)?;
relocate_shims(&staging, source)?;
// A tool may have been installed or a cache rebuilt while copying. Never
// merge into or overwrite it, and retain the original for recovery.
if target.try_exists()? {
tracing::info!(?source, ?target, "Local data created during migration; retaining legacy copy");
return Ok(());
}
rename_missing(&staging, target)?;
remove_entry(source)?;
tracing::info!(?source, ?target, "copied legacy data to Local");
Ok(())
})();
if staging.exists() {
let _ = remove_entry(&staging);
}
result
}
// std::fs::rename may replace even an empty directory on modern Windows.
#[cfg(windows)]
fn rename_missing(source: &Path, target: &Path) -> io::Result<()> {
use std::os::windows::ffi::OsStrExt;
use windows_sys::Win32::Storage::FileSystem::MoveFileExW;
// canonicalize supplies extended-length paths, including redirected UNC roots.
let source = fs::canonicalize(source)?;
let target = fs::canonicalize(target.parent().ok_or_else(|| io::Error::other("missing data parent"))?)?
.join(target.file_name().ok_or_else(|| io::Error::other("missing data name"))?);
let wide = |path: &Path| -> io::Result<Vec<u16>> {
let mut value: Vec<u16> = path.as_os_str().encode_wide().collect();
if value.contains(&0) {
return Err(io::Error::new(io::ErrorKind::InvalidInput, "NUL in data path"));
}
value.push(0);
Ok(value)
};
let source = wide(&source)?;
let target = wide(&target)?;
// SAFETY: both paths are valid NUL-terminated UTF-16 buffers. No flags means
// neither replacement nor implicit cross-volume copying is permitted.
if unsafe { MoveFileExW(source.as_ptr(), target.as_ptr(), 0) } == 0 {
Err(io::Error::last_os_error())
} else {
Ok(())
}
}
// Filesystem tests run on Unix too; production migration is Windows-only.
#[cfg(not(windows))]
fn rename_missing(source: &Path, target: &Path) -> io::Result<()> {
if target.try_exists()? {
return Err(io::Error::from(io::ErrorKind::AlreadyExists));
}
fs::rename(source, target)
}
fn remove_entry(path: &Path) -> io::Result<()> {
if fs::symlink_metadata(path)?.is_dir() { fs::remove_dir_all(path) } else { fs::remove_file(path) }
}
fn copy_tree(source: &Path, target: &Path) -> io::Result<()> {
let metadata = fs::symlink_metadata(source)?;
if metadata.file_type().is_symlink() {
return Err(io::Error::other("refusing to follow a link during data migration"));
}
if metadata.is_dir() {
fs::create_dir(target)?;
for entry in fs::read_dir(source)? {
let entry = entry?;
copy_tree(&entry.path(), &target.join(entry.file_name()))?;
}
} else if metadata.is_file() {
fs::copy(source, target)?;
} else {
return Err(io::Error::other("unsupported data entry"));
}
Ok(())
}
/// Make our absolute .cmd wrappers relative to their bin directory. They remain
/// valid in both locations if the move must be rolled back. npm shims already use
/// relative paths; payloads and user configuration are never rewritten.
fn relocate_shims(root: &Path, source: &Path) -> io::Result<()> {
if source.parent().and_then(Path::file_name).is_none_or(|name| name != "tools") {
return Ok(());
}
let bin = root.join("bin");
if !bin.is_dir() {
return Ok(());
}
for entry in fs::read_dir(bin)? {
let path = entry?.path();
if path.extension().is_none_or(|ext| ext != "cmd") {
continue;
}
let text = fs::read_to_string(&path)?;
if text.starts_with("@echo off\r\n") {
let updated = relative_wrapper(&text, source);
if updated != text {
fs::write(path, updated)?;
}
}
}
Ok(())
}
fn relative_wrapper(text: &str, source: &Path) -> String {
// PathBuf can contain both separators (notably roots joined with "tools/demo").
// Compare normalized paths, but retain every other byte of the managed wrapper.
let normalized = text.replace('\\', "/");
let prefix = source.to_string_lossy().replace('\\', "/");
#[cfg(windows)]
let (normalized, prefix) = (normalized.to_ascii_lowercase(), prefix.to_ascii_lowercase());
let mut updated = text.to_owned();
let offsets: Vec<_> = normalized.match_indices(&prefix).map(|(offset, _)| offset).collect();
for offset in offsets.into_iter().rev() {
let end = offset + prefix.len();
if normalized.as_bytes().get(end) == Some(&b'/') {
updated.replace_range(offset..end, "%~dp0..");
}
}
updated
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wrappers_accept_mixed_separators_without_rewriting_neighbor_paths() {
let text = "@echo off\r\n\"C:\\old\\tools/demo\\payload.exe\" \"C:\\old\\tools/demo-other\\x\" %*\r\n";
assert_eq!(
relative_wrapper(text, Path::new("C:/old/tools/demo")),
"@echo off\r\n\"%~dp0..\\payload.exe\" \"C:\\old\\tools/demo-other\\x\" %*\r\n"
);
}
#[test]
fn storage_policy_is_conservative_and_preserves_unix_layout() {
for entry in [
"tools/jdks",
"language-server-runtimes",
"lsp/jdtls",
"index/x",
"cache/x",
"notebook-runtime",
"server",
"logs",
"agents/tools",
"agents/registry.json",
"java-usages-1.1.1.jar",
"java-usages-1.2.0.jar",
] {
assert!(is_local(Path::new(entry)), "{entry}");
assert_eq!(
data_path(Path::new("roaming"), Path::new("local"), Path::new(entry)),
Path::new("local").join(entry)
);
assert_eq!(
data_path(Path::new("data"), Path::new("data"), Path::new(entry)),
Path::new("data").join(entry)
);
}
for entry in [
"git-credentials.json",
"history",
"remote",
"scratches",
"consoles",
"plugins",
"workspaces",
"agents/models.json",
"agents/project",
"unknown",
] {
assert!(!is_local(Path::new(entry)), "{entry}");
assert_eq!(
data_path(Path::new("roaming"), Path::new("local"), Path::new(entry)),
Path::new("roaming").join(entry)
);
}
}
#[cfg(not(target_os = "windows"))]
#[test]
fn unix_startup_does_not_migrate_even_if_given_distinct_roots() {
assert_eq!(dirs::data_dir(), dirs::data_local_dir());
let tmp = tempfile::tempdir().unwrap();
let old = tmp.path().join("old");
let new = tmp.path().join("new");
fs::create_dir_all(old.join("index")).unwrap();
migrate(&old, &new);
assert!(old.join("index").exists());
assert!(!new.exists());
}
#[test]
fn decisions_cover_missing_existing_and_failed_renames() {
assert_eq!(action(true, true, false), Action::Skip);
assert_eq!(action(false, false, false), Action::Skip);
assert_eq!(action(false, true, true), Action::Skip);
assert_eq!(action(false, true, false), Action::Rename);
assert_eq!(rename_failure(&io::Error::from(io::ErrorKind::CrossesDevices)), RenameFailure::Copy);
for kind in [io::ErrorKind::PermissionDenied, io::ErrorKind::AlreadyExists, io::ErrorKind::NotFound] {
assert_eq!(rename_failure(&io::Error::from(kind)), RenameFailure::KeepSource);
}
}
#[test]
fn same_volume_moves_and_second_start_does_not_overwrite() {
let tmp = tempfile::tempdir().unwrap();
let old = tmp.path().join("old");
let new = tmp.path().join("new");
fs::create_dir_all(old.join("tools/demo/bin")).unwrap();
let wrapper = format!("@echo off\r\n\"{}/payload.exe\" %*\r\n", old.join("tools/demo").display());
fs::write(old.join("tools/demo/bin/demo.cmd"), wrapper).unwrap();
fs::create_dir_all(old.join("tools/ty/venv")).unwrap();
fs::write(old.join("git-credentials.json"), "secret").unwrap();
fs::create_dir_all(old.join("index")).unwrap();
migrate_entries(&old, &new);
assert!(!old.join("tools/demo").exists());
assert!(old.join("tools/ty/venv").exists());
assert!(old.join("git-credentials.json").exists());
assert!(fs::read_to_string(new.join("tools/demo/bin/demo.cmd")).unwrap().contains("%~dp0.."));
fs::create_dir_all(old.join("index")).unwrap();
fs::write(old.join("index/legacy"), "old").unwrap();
migrate_entries(&old, &new);
assert!(old.join("index/legacy").exists());
assert!(!new.join("index/legacy").exists());
}
#[cfg(unix)]
#[test]
fn copy_failure_does_not_publish_or_remove_source() {
let tmp = tempfile::tempdir().unwrap();
let old = tmp.path().join("old");
let new = tmp.path().join("new");
fs::create_dir(&old).unwrap();
fs::write(old.join("payload"), "old").unwrap();
std::os::unix::fs::symlink(old.join("payload"), old.join("link")).unwrap();
assert!(copy_then_remove(&old, &new).is_err());
assert!(old.join("payload").exists());
assert!(!new.exists());
assert_eq!(fs::read_dir(tmp.path()).unwrap().count(), 1);
}
#[test]
fn staged_copy_publishes_complete_data_and_preserves_existing_target() {
let tmp = tempfile::tempdir().unwrap();
let old = tmp.path().join("old");
let new = tmp.path().join("new");
fs::create_dir_all(old.join("nested")).unwrap();
fs::write(old.join("nested/tool.exe"), "payload").unwrap();
copy_then_remove(&old, &new).unwrap();
assert!(!old.exists());
assert_eq!(fs::read_to_string(new.join("nested/tool.exe")).unwrap(), "payload");
fs::create_dir(&old).unwrap();
fs::write(old.join("legacy"), "old").unwrap();
copy_then_remove(&old, &new).unwrap();
assert!(old.join("legacy").exists());
assert!(!new.join("legacy").exists());
let old_file = tmp.path().join("registry-old.json");
let new_file = tmp.path().join("registry-new.json");
fs::write(&old_file, "cached registry").unwrap();
copy_then_remove(&old_file, &new_file).unwrap();
assert!(!old_file.exists());
assert_eq!(fs::read_to_string(new_file).unwrap(), "cached registry");
}
#[test]
fn publication_refuses_existing_files_and_empty_directories() {
let tmp = tempfile::tempdir().unwrap();
for directory in [false, true] {
let old = tmp.path().join(if directory { "old-dir" } else { "old-file" });
let new = tmp.path().join(if directory { "new-dir" } else { "new-file" });
if directory {
fs::create_dir(&old).unwrap();
fs::create_dir(&new).unwrap();
fs::write(old.join("payload"), "old").unwrap();
} else {
fs::write(&old, "old").unwrap();
fs::write(&new, "new").unwrap();
}
assert!(rename_missing(&old, &new).is_err());
assert!(old.exists() && new.exists());
if directory {
assert!(!new.join("payload").exists());
} else {
assert_eq!(fs::read_to_string(new).unwrap(), "new");
}
}
}
#[test]
fn wrapper_failure_keeps_the_old_installation() {
let tmp = tempfile::tempdir().unwrap();
let old = tmp.path().join("old/tools/demo");
let new = tmp.path().join("new/tools/demo");
fs::create_dir_all(old.join("bin")).unwrap();
fs::write(old.join("bin/demo.cmd"), [0xff, 0xfe]).unwrap();
assert!(try_rename(&old, &new).is_err());
assert!(old.join("bin/demo.cmd").exists());
assert!(!new.exists());
}
#[test]
fn failures_keep_source_without_publishing_partial_data() {
let tmp = tempfile::tempdir().unwrap();
let old = tmp.path().join("old");
let blocker = tmp.path().join("blocker");
fs::create_dir(&old).unwrap();
fs::write(old.join("payload"), "old").unwrap();
fs::write(&blocker, "file").unwrap();
assert!(try_rename(&old, &blocker.join("target")).is_err());
assert!(copy_then_remove(&old, &blocker.join("target")).is_err());
assert!(old.join("payload").exists());
assert!(copy_then_remove(&tmp.path().join("missing"), &tmp.path().join("new")).is_err());
assert!(!tmp.path().join("new").exists());
assert!(
!fs::read_dir(tmp.path())
.unwrap()
.flatten()
.any(|e| e.file_name().to_string_lossy().starts_with(".migration-"))
);
}
}
+135
View File
@@ -0,0 +1,135 @@
//! Select the document's formatting provider before executing it on this host.
use crate::Host;
use ide_core::event::NotifyLevel;
use ide_core::settings::PrettierSettings;
use ide_core::{DocId, IdeError, IdeEvent, IdeResult, Range, TextEdit};
use ide_language::FormatterConfig;
use ide_lsp::types::FormatOptions;
use ide_tools::{Tools, formatter};
use std::path::Path;
const OXFMT_CONFIGS: [&str; 4] = [".oxfmtrc.json", ".oxfmtrc.jsonc", "oxfmt.config.ts", "oxfmt.config.mts"];
/// oxfmt below an oxfmt configuration file, else Prettier. The Prettier
/// settings (languages, preference, on save) apply to both.
fn external_tool(file: &Path) -> &'static str {
let oxfmt = OXFMT_CONFIGS.iter().any(|name| formatter::find_up(file, Path::new(name)).is_some());
if oxfmt { "oxfmt" } else { "prettier" }
}
fn external_selected(config: Option<&FormatterConfig>, language: &str, settings: &PrettierSettings, lsp: bool) -> bool {
config.is_some_and(|c| c.tool == "prettier")
&& settings.languages.iter().any(|l| l == language)
&& (settings.prefer || !lsp)
}
pub async fn format(
host: &Host,
window: &str,
doc: DocId,
range: Option<Range>,
options: FormatOptions,
) -> IdeResult<Vec<TextEdit>> {
let project = host.project(window)?;
let snap = project.documents.snapshot(doc)?;
let settings = project.settings.effective();
let prettier = &settings.languages.prettier;
let language = project.languages.get(&snap.language);
let config = language.as_ref().and_then(|l| l.formatter.as_ref());
let candidate =
config.is_some_and(|c| c.tool == "prettier") && prettier.languages.iter().any(|l| l == snap.language.as_str());
let lsp = if candidate && !prettier.prefer { project.lsp.can_format(doc).await? } else { false };
if !external_selected(config, snap.language.as_str(), prettier, lsp) {
if options.on_save && !settings.editor.format_on_save {
return Ok(Vec::new());
}
return project.lsp.format(doc, range, options).await;
}
if options.on_save && !prettier.run_on_save {
return Ok(Vec::new());
}
host.require_trust(window)?;
if snap.read_only {
return Err(IdeError::invalid("The file is read-only"));
}
let config = config.unwrap().clone();
let tool = external_tool(&snap.path);
let name = formatter::display_name(tool);
let custom = ide_sdk::apply::setting_path(&project.root, &prettier.path).filter(|_| tool == "prettier");
let tools = Tools::new(&host.local_data_dir);
let whole_selection = range.is_some() && !formatter::supports_range(tool, &config);
let edits: IdeResult<Vec<TextEdit>> = tokio::task::spawn_blocking(move || {
let text = ide_text::Document::new(&snap.text);
let offsets = range.map(|r| (text.pos_to_offset(r.start), text.pos_to_offset(r.end)));
let mut command = tools.formatter_command(tool, &snap.path, custom.as_deref())?;
let dir = snap.path.parent().ok_or_else(|| IdeError::invalid(format!("{name}: file has no directory")))?;
command.current_dir(dir);
command.args(formatter::arguments(tool, &config, &snap.path, options.tab_size, options.insert_spaces, offsets));
let formatted = formatter::run(tool, command, &snap.text, &Default::default())?;
Ok(text
.diff(&formatted)
.changes
.into_iter()
.map(|c| TextEdit {
range: Range::new(text.offset_to_pos(c.from), text.offset_to_pos(c.to)),
new_text: c.insert,
})
.collect())
})
.await
.map_err(|e| IdeError::internal(e.to_string()))?;
if edits.is_ok() && whole_selection {
project.sink.emit(IdeEvent::Notify {
level: NotifyLevel::Info,
title: name.into(),
body: Some(format!(
"{name} does not support selection formatting for this language; formatted the whole file"
)),
});
}
edits.map_err(|e| e.with_tool(tool))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn language_enablement_and_preference_preserve_lsp_formatters() {
let languages = ide_language::LanguageRegistry::load(None).unwrap();
let mut settings = PrettierSettings::default();
for lang in languages.all() {
for lsp in [false, true] {
let configured = lang.formatter.as_ref().is_some_and(|f| f.tool == "prettier");
assert_eq!(
external_selected(lang.formatter.as_ref(), lang.id.as_str(), &settings, lsp),
configured && !lsp,
"{}",
lang.id
);
}
}
let ts = languages.get(&ide_core::LanguageId::new("typescript")).unwrap();
settings.prefer = true;
assert!(external_selected(ts.formatter.as_ref(), "typescript", &settings, true));
settings.languages.clear();
assert!(!external_selected(ts.formatter.as_ref(), "typescript", &settings, false));
assert!(!external_selected(None, "rust", &settings, false));
}
#[test]
fn oxfmt_config_selects_oxfmt_over_prettier() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::create_dir_all(root.join("web/src")).unwrap();
let (web, api) = (root.join("web/src/app.ts"), root.join("api/main.ts"));
assert_eq!(external_tool(&web), "prettier");
for config in OXFMT_CONFIGS {
std::fs::write(root.join("web").join(config), "{}").unwrap();
assert_eq!(external_tool(&web), "oxfmt", "{config}");
assert_eq!(external_tool(&api), "prettier", "{config}");
std::fs::remove_file(root.join("web").join(config)).unwrap();
}
}
}
+429
View File
@@ -0,0 +1,429 @@
//! Saved Kotlin -> isolated Maven output -> marked JDT libraries.
use ide_build::kotlin_bridge::{self, Outcome, ProjectLibraries};
use ide_core::event::NotifyLevel;
use ide_core::{CancellationToken, Diagnostic, IdeEvent, Pos, Range, Severity, SharedSink};
use ide_lsp::LspManager;
use ide_workspace::SettingsStore;
use ide_workspace::documents::DocumentObserver;
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
use std::time::Duration;
use tokio::sync::Notify;
const DELAY: Duration = Duration::from_millis(750);
const SOURCE: &str = "kotlin-bridge";
const COMMAND: &str = "openidea.kotlin.replace";
#[derive(Default)]
struct Revision {
generation: u64,
cancel: CancellationToken,
}
impl Revision {
fn change(&mut self) {
self.cancel.cancel();
self.generation += 1;
self.cancel = CancellationToken::new();
}
fn current(&self, generation: u64) -> bool {
self.generation == generation && !self.cancel.is_cancelled()
}
}
#[derive(Clone, Default, Serialize, Deserialize)]
struct MavenOptions {
offline: bool,
profiles: Vec<String>,
}
pub(crate) struct KotlinBridge {
root: PathBuf,
cache: PathBuf,
bundle: Option<PathBuf>,
settings: Arc<SettingsStore>,
lsp: Arc<LspManager>,
sink: SharedSink,
trusted: Arc<AtomicBool>,
lifetime: CancellationToken,
revision: Mutex<Revision>,
changed: Notify,
options: Mutex<MavenOptions>,
worker: Mutex<Option<tokio::task::JoinHandle<()>>>,
}
impl KotlinBridge {
pub(crate) fn new(
root: PathBuf,
cache: PathBuf,
data: &Path,
settings: Arc<SettingsStore>,
lsp: Arc<LspManager>,
sink: SharedSink,
trusted: Arc<AtomicBool>,
) -> Arc<Self> {
let options = std::fs::read(cache.join("kotlin-bridge-options.json"))
.ok()
.and_then(|bytes| serde_json::from_slice(&bytes).ok())
.unwrap_or_default();
Arc::new(Self {
root,
cache,
bundle: ide_sdk::java::kotlin_bridge_bundle(data),
settings,
lsp,
sink,
trusted,
lifetime: CancellationToken::new(),
revision: Mutex::new(Revision::default()),
changed: Notify::new(),
options: Mutex::new(options),
worker: Mutex::new(None),
})
}
pub(crate) fn start(self: &Arc<Self>, runtime: &tokio::runtime::Handle) {
let bridge = self.clone();
*self.worker.lock() = Some(runtime.spawn(async move { bridge.run().await }));
self.schedule();
}
pub(crate) fn configure_maven(&self, offline: bool, profiles: Vec<String>) {
let options = MavenOptions { offline, profiles };
if let Ok(bytes) = serde_json::to_vec(&options) {
let _ = std::fs::create_dir_all(&self.cache);
let _ = std::fs::write(self.cache.join("kotlin-bridge-options.json"), bytes);
}
*self.options.lock() = options;
self.schedule();
}
pub(crate) async fn shutdown(&self) {
self.stop();
let worker = self.worker.lock().take();
if let Some(worker) = worker {
let _ = worker.await;
}
}
pub(crate) fn schedule(&self) {
if self.lifetime.is_cancelled() {
return;
}
self.revision.lock().change();
self.changed.notify_one();
}
pub(crate) fn files_changed<'a>(&self, mut paths: impl Iterator<Item = &'a PathBuf>) {
let settings = self.settings.effective();
if paths.any(|path| relevant_change(&self.root, path, &settings)) {
self.schedule();
}
}
pub(crate) fn stop(&self) {
self.lifetime.cancel();
self.revision.lock().cancel.cancel();
}
async fn replace(&self, projects: &[ProjectLibraries], cancel: &CancellationToken) -> ide_core::IdeResult<()> {
let snapshot = serde_json::to_value(projects).map_err(|e| ide_core::IdeError::internal(e.to_string()))?;
let response = self.lsp.jdtls_command_cancellable(&self.root, COMMAND, vec![snapshot], cancel).await?;
if response.as_u64().is_none() {
return Err(ide_core::IdeError::unavailable(
"Kotlin bridge command did not acknowledge its library snapshot",
));
}
Ok(())
}
async fn run(self: Arc<Self>) {
let mut diagnostics = BTreeSet::new();
let mut last_failure = String::new();
let mut initialized = false;
// Also retain directories from unacknowledged replacements: JDT may have applied them.
let mut published = Vec::new();
loop {
tokio::select! {
_ = self.lifetime.cancelled() => return,
_ = self.changed.notified() => {},
}
let (generation, cancel) = {
let state = self.revision.lock();
(state.generation, state.cancel.clone())
};
if !self.trusted.load(Ordering::Acquire) {
continue;
}
if !debounce(&cancel, &self.lifetime).await {
continue;
}
let root = self.root.clone();
let cache = self.cache.clone();
let settings = self.settings.effective();
let has_sources = tokio::task::spawn_blocking(move || {
let model = crate::ops::build::load(&root, &cache, &settings);
let enabled =
model.maven.as_ref().is_some_and(|m| !m.projects.is_empty()) && kotlin_bridge::has_kotlin(&root);
(model, enabled)
})
.await;
let Ok((mut model, enabled)) = has_sources else { continue };
if !self.revision.lock().current(generation) {
continue;
}
if !enabled {
if self.cache.join("kotlin-bridge").exists()
&& let Err(error) = self.replace(&[], &cancel).await
{
if !cancel.is_cancelled() {
tracing::warn!("Kotlin library withdrawal: {error}");
}
continue;
}
for old in published.drain(..) {
remove_directory(old).await;
}
self.clear_diagnostics(&mut diagnostics);
last_failure.clear();
continue;
}
if let Some(maven) = &mut model.maven {
let options = self.options.lock();
for module in &mut maven.projects {
if options.offline {
module.options.push("-o".into());
}
if !options.profiles.is_empty() {
module.options.push(format!("-P{}", options.profiles.join(",")));
}
}
}
let Some(bundle) = self.bundle.clone() else {
self.failure("Kotlin bridge bundle is unavailable".into(), &mut diagnostics, &mut last_failure);
continue;
};
// Clear entries persisted by an earlier incarnation only before our first build.
if !initialized {
if let Err(error) = self.replace(&[], &cancel).await {
if !cancel.is_cancelled() {
self.failure(error.message, &mut diagnostics, &mut last_failure);
}
continue;
}
initialized = true;
}
let directory = self.cache.join(format!("kotlin-bridge/{}", uuid::Uuid::new_v4()));
let staging = directory.clone();
let root = self.root.clone();
let token = cancel.clone();
let result =
tokio::task::spawn_blocking(move || kotlin_bridge::build(&root, &model, &bundle, &staging, &token))
.await;
if !self.revision.lock().current(generation) || self.lifetime.is_cancelled() {
remove_directory(directory).await;
continue;
}
match result {
Ok(Ok(Outcome { success: true, projects, .. })) => {
match self.replace(&projects, &cancel).await {
Ok(()) => {
for old in published.drain(..) {
remove_directory(old).await;
}
published.push(directory);
self.clear_diagnostics(&mut diagnostics);
last_failure.clear();
}
Err(error) => {
// Keep both snapshots until an acknowledged replacement.
published.push(directory);
if !cancel.is_cancelled() {
self.failure(error.message, &mut diagnostics, &mut last_failure);
}
}
}
}
other => {
let text = match other {
Ok(Ok(outcome)) => outcome
.messages
.iter()
.filter(|m| m.severity == Severity::Error)
.map(|m| m.text.as_str())
.collect::<Vec<_>>()
.join("\n"),
Ok(Err(error)) => error.message,
Err(error) => error.to_string(),
};
self.failure(text, &mut diagnostics, &mut last_failure);
remove_directory(directory).await;
}
}
}
}
fn clear_diagnostics(&self, paths: &mut BTreeSet<PathBuf>) {
for path in std::mem::take(paths) {
self.sink.emit(IdeEvent::Diagnostics { path: path.into(), source: SOURCE.into(), items: Vec::new() });
}
}
fn failure(&self, text: String, paths: &mut BTreeSet<PathBuf>, previous: &mut String) {
let text = if text.is_empty() { "Kotlin compilation failed".to_owned() } else { text };
let text = format!("{text}\nJava temporarily uses the last successfully compiled Kotlin API.");
self.clear_diagnostics(paths);
let path = self.root.join("pom.xml");
paths.insert(path.clone());
self.sink.emit(IdeEvent::Diagnostics {
path: path.into(),
source: SOURCE.into(),
items: vec![Diagnostic {
range: Range { start: Pos { line: 0, col: 0 }, end: Pos { line: 0, col: 0 } },
severity: Severity::Error,
message: text.clone(),
source: Some(SOURCE.into()),
server: Some(SOURCE.into()),
code: None,
unnecessary: false,
deprecated: false,
}],
});
if previous != &text {
self.sink.emit(IdeEvent::Notify {
level: NotifyLevel::Error,
title: "Kotlin editor compilation failed".into(),
body: Some(text.clone()),
});
*previous = text;
}
}
}
fn relevant_change(root: &Path, path: &Path, settings: &ide_core::settings::Settings) -> bool {
let input = path.extension().is_some_and(|e| e == "kt")
|| path.file_name().is_some_and(|n| n == "pom.xml" || n == "mvnw" || n == "mvnw.cmd")
|| path.components().any(|c| c.as_os_str() == ".mvn");
let Ok(relative) = path.strip_prefix(root) else {
// Content roots outside the project only contribute Kotlin sources.
return path.extension().is_some_and(|e| e == "kt");
};
// Build outputs and generated sources (e.g. KSP's `.kt` under target/) never trigger,
// so a build cannot schedule itself.
if relative.components().any(|c| {
matches!(
c.as_os_str().to_str(),
Some("target" | "build" | "out" | "bin" | ".git" | "node_modules" | ".openidea")
)
}) || ide_vfs::ExcludeMatcher::new(&settings.exclude_globs()).is_excluded(relative)
{
return false;
}
if input {
return true;
}
if path.extension().is_some() {
return false;
}
// Works for deleted directories too; the module POM remains on disk.
path.ancestors().take_while(|ancestor| ancestor.starts_with(root)).any(|ancestor| {
ancestor.file_name().is_some_and(|name| name == "src")
&& ancestor.parent().is_some_and(|module| module.join("pom.xml").is_file())
})
}
async fn debounce(cancel: &CancellationToken, lifetime: &CancellationToken) -> bool {
tokio::select! {
_ = lifetime.cancelled() => false,
_ = cancel.cancelled() => false,
_ = tokio::time::sleep(DELAY) => true,
}
}
async fn remove_directory(path: PathBuf) {
let _ = tokio::task::spawn_blocking(move || std::fs::remove_dir_all(path)).await;
}
impl DocumentObserver for KotlinBridge {
fn saved(&self, _doc: ide_core::DocId, path: &Path, _text: &str) {
self.files_changed(std::iter::once(&path.to_path_buf()));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn target_package_directory_does_not_trigger() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("pom.xml"), "").unwrap();
assert!(!relevant_change(dir.path(), &dir.path().join("target/classes/probe"), &Default::default()));
let generated = dir.path().join("target/generated-sources/ksp/main/kotlin/Gen.kt");
assert!(!relevant_change(dir.path(), &generated, &Default::default()));
assert!(relevant_change(dir.path(), &dir.path().join("src/main/kotlin/A.kt"), &Default::default()));
}
#[test]
fn deleted_source_package_triggers_unless_excluded() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("module")).unwrap();
std::fs::write(dir.path().join("module/pom.xml"), "").unwrap();
let path = dir.path().join("module/src/main/kotlin/pkg");
let mut settings = ide_core::settings::Settings::default();
assert!(relevant_change(dir.path(), &path, &settings));
settings.project_structure.excluded.push("module/src/main/kotlin".into());
assert!(!relevant_change(dir.path(), &path, &settings));
assert!(!relevant_change(dir.path(), &dir.path().join("module/src/build/pkg"), &Default::default()));
}
#[test]
fn extensionless_root_file_does_not_trigger() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("pom.xml"), "").unwrap();
assert!(!relevant_change(dir.path(), &dir.path().join("LICENSE"), &Default::default()));
}
#[tokio::test(start_paused = true)]
async fn burst_saves_cancel_the_pending_debounce() {
let mut state = Revision::default();
state.change();
let token = state.cancel.clone();
let lifetime = CancellationToken::new();
let pending_lifetime = lifetime.clone();
let waiting = tokio::spawn(async move { debounce(&token, &pending_lifetime).await });
tokio::task::yield_now().await;
tokio::time::advance(DELAY - Duration::from_millis(1)).await;
assert!(!waiting.is_finished());
for _ in 0..20 {
state.change();
}
assert!(!waiting.await.unwrap());
let current = state.generation;
assert!(debounce(&state.cancel, &lifetime).await);
assert!(state.current(current));
lifetime.cancel();
assert!(!debounce(&state.cancel, &lifetime).await);
}
#[test]
fn superseded_and_cancelled_builds_cannot_publish() {
let mut state = Revision::default();
state.change();
let first = state.generation;
let token = state.cancel.clone();
state.change();
assert!(token.is_cancelled());
assert!(!state.current(first));
assert!(state.current(state.generation));
state.cancel.cancel();
assert!(!state.current(state.generation));
}
}
+85 -8
View File
@@ -3,8 +3,11 @@
//! wraps one [`Host`] and adds windows, menus and plugins; `openidea-server`
//! runs one on a remote machine and serves it over JSON-RPC ([`rpc`]).
pub mod data_dirs;
pub mod environment;
mod formatter;
mod java_runtime;
mod kotlin_bridge;
pub mod notebook;
pub mod ops;
mod project;
@@ -31,7 +34,10 @@ pub type SinkFactory = Box<dyn Fn(&str) -> SharedSink + Send + Sync>;
pub struct HostConfig {
pub config_dir: PathBuf,
/// Persistent user state, including credentials and recovery history.
pub data_dir: PathBuf,
/// Machine-local tools and rebuildable caches.
pub local_data_dir: PathBuf,
pub languages: Arc<LanguageRegistry>,
/// Grammars, possibly still being built on another thread.
pub syntax: Arc<OnceLock<Arc<Syntax>>>,
@@ -47,10 +53,14 @@ pub struct HostConfig {
pub struct Host {
pub trust: trust::TrustStore,
pub config_dir: PathBuf,
/// Persistent user state, including credentials and recovery history.
pub data_dir: PathBuf,
/// Machine-local tools and rebuildable caches.
pub local_data_dir: PathBuf,
pub languages: Arc<LanguageRegistry>,
syntax: Arc<OnceLock<Arc<Syntax>>>,
pub global_settings: SettingsStore,
pub dependency_versions: Arc<ide_workspace::dependencies::VersionCache>,
pub terminals: TerminalManager,
pub processes: ProcessManager,
/// Debug sessions (adapters run on this machine, next to the project).
@@ -74,8 +84,17 @@ pub struct Host {
impl Host {
pub fn new(config: HostConfig) -> Self {
let HostConfig { config_dir, data_dir, languages, syntax, sink_for, owner, runtime, project_environment } =
config;
let HostConfig {
config_dir,
data_dir,
local_data_dir,
languages,
syntax,
sink_for,
owner,
runtime,
project_environment,
} = config;
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);
@@ -84,8 +103,11 @@ impl Host {
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()),
environment: environment::Environment::new(project_environment, local_data_dir.clone()),
global_settings,
dependency_versions: Arc::new(ide_workspace::dependencies::VersionCache::new(
data_dir.join("cache/dependency-versions"),
)),
// Sessions, runs and terminals report to the window that started them (`window_sink`).
terminals: TerminalManager::new(),
processes: ProcessManager::new(),
@@ -94,6 +116,7 @@ impl Host {
syntax,
config_dir,
data_dir,
local_data_dir,
sink_for,
owner,
runtime,
@@ -148,17 +171,42 @@ impl Host {
if !project.light {
self.environment.project_closed();
}
if let Some(bridge) = &project.kotlin_bridge {
bridge.stop();
}
project.documents.clear_workspace_edit_history();
project.agents.shutdown();
project.notebooks.stop_admission();
self.shutdown_notebooks_blocking(vec![project.notebooks.clone()]);
let lsp = project.lsp.clone();
self.runtime.spawn(async move { lsp.shutdown_all().await });
// What the closing window started goes with it — the builds and searches it
// asked for, its runs, its shells and its debug sessions — and the language
// servers finish shutting down before the window is gone, as IDEA closes a
// project's processes.
for request in self.project_requests.write().remove(self.owner_window(window)).unwrap_or_default() {
self.cancel_request(request);
}
self.processes.kill_project(&project.root);
self.terminals.kill_key(self.owner_window(window));
self.shutdown_project_workers_blocking(
vec![project.notebooks.clone()],
project.kotlin_bridge.iter().cloned().collect(),
Some(project.lsp.clone()),
Some((self.debugger.clone(), window.to_owned())),
);
}
}
/// Synchronous project/window destruction must finish owned kernel cleanup,
/// not enqueue work which can be lost as the desktop runtime exits.
fn shutdown_notebooks_blocking(&self, notebooks: Vec<Arc<notebook::NotebookService>>) {
fn shutdown_project_workers_blocking(
&self,
notebooks: Vec<Arc<notebook::NotebookService>>,
bridges: Vec<Arc<kotlin_bridge::KotlinBridge>>,
lsp: Option<Arc<ide_lsp::LspManager>>,
debug: Option<(Arc<ide_dap::DebugManager>, String)>,
) {
for bridge in &bridges {
bridge.stop();
}
for service in &notebooks {
service.stop_admission();
}
@@ -166,9 +214,18 @@ impl Host {
let wait = move || {
let worker = std::thread::spawn(move || {
runtime.block_on(async move {
for bridge in bridges {
bridge.shutdown().await;
}
for service in notebooks {
service.shutdown().await;
}
if let Some(lsp) = lsp {
lsp.shutdown_all().await;
}
if let Some((debug, key)) = debug {
debug.stop_key(&key).await;
}
});
});
if let Err(error) = worker.join() {
@@ -186,7 +243,20 @@ impl Host {
/// Called before the desktop runtime exits, including update/restart exits.
pub fn shutdown_notebooks_on_exit(&self) {
self.shutdown_notebooks_blocking(self.projects().iter().map(|project| project.notebooks.clone()).collect());
let projects = self.projects();
self.shutdown_project_workers_blocking(
projects.iter().map(|project| project.notebooks.clone()).collect(),
projects.iter().filter_map(|project| project.kotlin_bridge.clone()).collect(),
None,
None,
);
}
/// Called before desktop exit, which may bypass document destructors.
pub fn clear_workspace_edit_history_on_exit(&self) {
for project in self.projects() {
project.documents.clear_workspace_edit_history();
}
}
/// Stops what this host started: notebook kernels, language servers, agents, runs and terminals.
@@ -197,12 +267,19 @@ impl Host {
self.terminals.kill_all();
let projects: Vec<_> = self.projects.write().drain().map(|(_, p)| p).collect();
for project in &projects {
if let Some(bridge) = &project.kotlin_bridge {
bridge.stop();
}
project.documents.clear_workspace_edit_history();
project.notebooks.stop_admission();
project.agents.shutdown();
}
// Kernel cleanup owns its interrupt/termination/reaping deadlines. An
// outer LSP timeout must never abort it before owned children are reaped.
for project in &projects {
if let Some(bridge) = &project.kotlin_bridge {
bridge.shutdown().await;
}
project.notebooks.shutdown().await;
}
let stop_language_servers = async {
+11 -3
View File
@@ -25,7 +25,7 @@ pub fn providers(host: &Host, window: &str) -> IdeResult<Vec<AgentProvider>> {
/// Where ACP Registry agents are saved (`<config>/agents`, like the user's own) and installed.
pub(crate) fn registry_paths(host: &Host) -> RegistryPaths {
RegistryPaths::new(&host.config_dir, &host.data_dir)
RegistryPaths::new(&host.config_dir, &host.local_data_dir)
}
fn join_err(e: impl std::fmt::Display) -> IdeError {
@@ -63,13 +63,21 @@ pub async fn registry_install(
.await
.map_err(join_err);
host.end_request(request);
result?
result??;
for project in host.projects() {
project.agents.reload_providers();
}
Ok(())
}
/// Removes a registry agent: its agent file and installed files.
pub async fn registry_remove(host: &Host, _window: &str, id: String) -> IdeResult<()> {
let paths = registry_paths(host);
tokio::task::spawn_blocking(move || ide_agent::registry::remove(&paths, &id)).await.map_err(join_err)?
tokio::task::spawn_blocking(move || ide_agent::registry::remove(&paths, &id)).await.map_err(join_err)??;
for project in host.projects() {
project.agents.reload_providers();
}
Ok(())
}
/// Slash commands to complete for `provider` in `task`'s directory, or in the project
+26 -13
View File
@@ -97,6 +97,7 @@ pub async fn build_sync(
host.require_trust(window)?;
let project = host.project(window)?;
let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective());
let bridge_profiles = profiles.clone();
let token = host.begin_project_request(window, request);
let result = tokio::task::spawn_blocking(move || {
let current = load(&root, &cache, &settings);
@@ -132,19 +133,29 @@ pub async fn build_sync(
host.end_request(request);
let model = result??;
if tool == BuildTool::Maven
&& let Some(maven) = &model.maven
&& model.maven.is_some()
&& let Some(bridge) = &project.kotlin_bridge
{
// jdtls re-imports on pom changes but misses artifacts added to ~/.m2
// meanwhile ("missing artifact" stays until Invalidate Caches); asking it
// to update the project configurations re-resolves them (#436).
let lsp = project.lsp.clone();
let uris = maven.projects.iter().map(|p| ide_lsp::path_to_uri_string(&project.root.join(&p.pom))).collect();
tokio::spawn(async move {
if let Err(e) = lsp.java_update_project_configs(&project.root, uris).await {
tracing::debug!("java/projectConfigurationsUpdate: {e}");
}
});
bridge.configure_maven(offline, bridge_profiles);
}
// jdtls re-imports on pom changes but misses artifacts added to ~/.m2
// meanwhile ("missing artifact" stays until Invalidate Caches); asking it
// to update the project configurations re-resolves them (#436). The same
// holds for Gradle: it reuses its cached classpath on restart, so a
// dependency added to `build.gradle` stays unresolved until told to sync.
let build_files: Vec<String> = match tool {
BuildTool::Maven => model.maven.iter().flat_map(|m| m.projects.iter().map(|p| p.pom.clone())).collect(),
BuildTool::Gradle => {
model.gradle.iter().flat_map(|g| g.projects.iter().filter_map(|p| p.build_file.clone())).collect()
}
};
let lsp = project.lsp.clone();
let uris = build_files.iter().map(|f| ide_lsp::path_to_uri_string(&project.root.join(f))).collect();
tokio::spawn(async move {
if let Err(e) = lsp.java_update_project_configs(&project.root, uris).await {
tracing::debug!("java/projectConfigurationsUpdate: {e}");
}
});
Ok(model)
}
@@ -199,8 +210,10 @@ pub async fn maven_exclude_dependency(
let (path, text, range) = declaring_pom(&project, &pom, &dependency)?;
match ide_build::pom_edit::add_exclusion(&text, &dependency, &exclusion) {
Some(Exclusion::Edit(edit)) => {
let edit =
WorkspaceEdit { files: vec![FileEdit::Edit { path: path.clone().into(), edits: vec![edit] }] };
let edit = WorkspaceEdit {
files: vec![FileEdit::Edit { path: path.clone().into(), edits: vec![edit], version: None }],
..Default::default()
};
apply_workspace_edit(&project.documents, &edit)?;
}
Some(Exclusion::Present) => {}
+19 -4
View File
@@ -39,7 +39,7 @@ pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> Ide
.await?;
}
if config.adapter == ide_dap::kotlin::ADAPTER {
let data_dir = host.data_dir.clone();
let data_dir = host.local_data_dir.clone();
let runtime_root = config.cwd.clone().map(PathBuf::from).unwrap_or_default();
let runtime_settings = settings.clone();
let runtime = host
@@ -96,7 +96,13 @@ fn match_java_debug_encoding(mut config: LaunchConfig, windows: bool) -> LaunchC
};
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) {
if let Some(vm_args) = ide_core::output_encoding::java_debug_vm_args(
vm_args,
&config.env,
|key| std::env::var(key).ok(),
encoding,
windows,
) {
extra.insert("vmArgs".into(), vm_args.into());
config.extra_json = Some(serde_json::Value::Object(extra).to_string());
}
@@ -452,7 +458,7 @@ mod tests {
program: None,
args: vec![],
cwd: None,
env: Default::default(),
env: [("JDK_JAVA_OPTIONS".into(), "".into()), ("JAVA_TOOL_OPTIONS".into(), "".into())].into(),
extra_json: Some(extra.to_string()),
}
}
@@ -468,7 +474,7 @@ mod tests {
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"
"-Xmx1g -Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8 -Dsun.stdout.encoding=UTF-8 -Dsun.stderr.encoding=UTF-8 -Dfile.encoding=UTF-8"
);
assert_eq!(
serde_json::from_str::<serde_json::Value>(fixed.extra_json.as_deref().unwrap()).unwrap()["mainClass"],
@@ -481,4 +487,13 @@ mod tests {
assert_eq!(vm_args(&match_java_debug_encoding(launch(adapter, request, extra.clone()), windows)), "-Xmx1g");
}
}
#[test]
fn java_debug_launches_respect_environment_encoding() {
for key in ["JDK_JAVA_OPTIONS", "JAVA_TOOL_OPTIONS"] {
let mut config = launch("java-debug", "launch", serde_json::json!({"vmArgs": "-Dstdout.encoding=GBK"}));
config.env.insert(key.into(), "-Dfile.encoding=GBK".into());
assert_eq!(vm_args(&match_java_debug_encoding(config, true)), "-Dstdout.encoding=GBK");
}
}
}
+124
View File
@@ -0,0 +1,124 @@
//! Repository version lookups for manifest inspections and completion.
use crate::{Host, rpc::Dispatcher};
use ide_core::{CancellationToken, DocId, IdeError, IdeResult, RequestId};
use ide_workspace::dependencies::{DependencyVersionSource, DependencyVersions, cargo};
struct CratesIo;
impl DependencyVersionSource for CratesIo {
fn id(&self) -> &'static str {
"crates-io"
}
fn fetch(&self, package: &str, cancel: &CancellationToken) -> IdeResult<Vec<String>> {
let path = cargo::index_path(package).ok_or_else(|| IdeError::internal("invalid crate name"))?;
let search_path = std::env::var_os("PATH").unwrap_or_default();
let text = ide_tools::Http::new(&search_path)?.https_only().get_text_limited(
&format!("https://index.crates.io/{path}"),
Some(16 * 1024 * 1024),
cancel,
)?;
cargo::parse_index(&text).ok_or_else(|| IdeError::internal("invalid sparse index"))
}
}
/// `offset` limits a completion request to its version string; inspections omit it.
pub async fn dependency_versions(
host: &Host,
window: &str,
doc: DocId,
offset: Option<u32>,
request: RequestId,
) -> IdeResult<Vec<DependencyVersions>> {
let project = host.project(window)?;
let cache = host.dependency_versions.clone();
let cancel = host.begin_request(request);
let result = host
.blocking(move || {
let snap = project.documents.snapshot(doc)?;
if snap.large || snap.read_only || snap.path.file_name().is_none_or(|n| n != "Cargo.toml") {
return Ok(Vec::new());
}
let mut out = Vec::new();
for dependency in cargo::dependencies(&snap.text) {
if offset.is_some_and(|pos| pos < dependency.from || pos > dependency.to) {
continue;
}
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let versions = cache.versions(&CratesIo, &dependency.package, &cancel)?;
let update = cargo::update(&dependency.requirement, &versions);
out.push(DependencyVersions { dependency, versions, update });
}
Ok(out)
})
.await;
host.end_request(request);
result
}
pub(crate) fn register(d: &mut Dispatcher) {
d.add("dependency_versions", |c| async move {
dependency_versions(&c.host, &c.window, c.arg("doc")?, c.arg("offset")?, c.arg("request")?).await
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::HostConfig;
use std::sync::Arc;
struct Source;
impl DependencyVersionSource for Source {
fn id(&self) -> &'static str {
"crates-io"
}
fn fetch(&self, _: &str, _: &CancellationToken) -> IdeResult<Vec<String>> {
Ok(vec!["1.10.0".into(), "0.2.1".into()])
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn host_uses_synced_document_cache_and_cursor_filter_without_network() {
let temp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(temp.path()).unwrap();
let root = base.join("project");
std::fs::create_dir(&root).unwrap();
let text = "# 中文 😀\n[dependencies]\ntokio = { version = \"0.2\", features = [\"full\"] }\nserde = \"1.0.100\"\nlocal = { path = \"../x\", version = \"0.2\" }\n";
let path = root.join("Cargo.toml");
std::fs::write(&path, text).unwrap();
let host = Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(|_| {})),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
let project = host.open_project("main", &root).unwrap();
let info = project.documents.open(&path).unwrap();
for package in ["tokio", "serde"] {
host.dependency_versions.versions(&Source, package, &CancellationToken::new()).unwrap();
}
let all = dependency_versions(&host, "main", info.id, None, RequestId(410)).await.unwrap();
assert_eq!(all.len(), 2);
assert_eq!(all[0].update.as_ref().unwrap().replacement, "1.10");
assert!(all[1].update.is_none());
let offset = all[1].dependency.from;
let at_cursor = dependency_versions(&host, "main", info.id, Some(offset), RequestId(411)).await.unwrap();
assert_eq!(at_cursor.len(), 1);
assert_eq!(at_cursor[0].dependency.package, "serde");
assert!(dependency_versions(&host, "main", info.id, Some(0), RequestId(412)).await.unwrap().is_empty());
host.cancel_request(RequestId(413));
assert!(dependency_versions(&host, "main", info.id, None, RequestId(413)).await.is_err());
let other = root.join("other.toml");
std::fs::write(&other, text).unwrap();
let info = project.documents.open(&other).unwrap();
assert!(dependency_versions(&host, "main", info.id, None, RequestId(414)).await.unwrap().is_empty());
host.close_project("main");
}
}
+1 -1
View File
@@ -127,7 +127,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
doc_set_encoding(&c.host, &c.window, c.arg("doc")?, c.arg("encoding")?, c.arg("reload")?).await
});
d.add("doc_set_line_ending", |c| async move {
doc_set_line_ending(&c.host, &c.window, c.arg("doc")?, c.arg("lineEnding")?)
blocking(move || doc_set_line_ending(&c.host, &c.window, c.arg("doc")?, c.arg("lineEnding")?)).await
});
d.add("doc_outline", |c| async move { doc_outline(&c.host, &c.window, c.arg("doc")?).await });
d.add("doc_folding", |c| async move { doc_folding(&c.host, &c.window, c.arg("doc")?).await });
+1
View File
@@ -30,6 +30,7 @@ impl Fixture {
let host = Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(move |_| {
+89 -18
View File
@@ -1,29 +1,100 @@
use super::Channel;
use crate::Host;
use ide_core::IdeResult;
use ide_core::{IdeResult, RequestId};
use ide_vfs::FileEntry;
use std::path::PathBuf;
pub async fn entry_kind(host: &Host, path: PathBuf) -> IdeResult<ide_vfs::EntryKind> {
host.blocking(move || {
let meta = std::fs::metadata(path)?;
Ok(if meta.is_dir() { ide_vfs::EntryKind::Dir } else { ide_vfs::EntryKind::File })
})
.await
}
pub async fn compare_directories(
host: &Host,
window: &str,
left: PathBuf,
right: PathBuf,
request: RequestId,
on_progress: Channel<ide_vfs::DirectoryCompareProgress>,
) -> IdeResult<Vec<ide_vfs::DirectoryCompareEntry>> {
let project = host.project(window)?;
let cancel = host.begin_request(request);
let result = host
.blocking(move || {
let last = std::cell::Cell::new(std::time::Instant::now() - std::time::Duration::from_secs(1));
ide_vfs::compare_directories(&left, &right, Some(&project.root), &project.exclude_globs(), &cancel, |p| {
// Bound IPC traffic even when hashing multi-gigabyte files.
if last.get().elapsed() >= std::time::Duration::from_millis(80) || p.total == Some(p.compared) {
let _ = on_progress.send(p);
last.set(std::time::Instant::now());
}
})
})
.await;
host.end_request(request);
result
}
pub async fn copy_merge(
host: &Host,
window: &str,
source_root: PathBuf,
destination_root: PathBuf,
path: PathBuf,
overwrite: bool,
) -> IdeResult<()> {
if path.as_os_str().is_empty() || path.components().any(|c| !matches!(c, std::path::Component::Normal(_))) {
return Err(ide_core::IdeError::invalid("expected a relative comparison entry path"));
}
let project = host.project(window)?;
host.blocking(move || {
let from = source_root.join(&path);
let to = destination_root.join(&path);
if overwrite {
project.history.record_deletion(&to, DELETE_SNAPSHOT_LIMIT);
}
project.with_disk_sync(|| {
ide_vfs::copy_merge(
&from,
&to,
overwrite,
Some(&project.root),
&project.exclude_globs(),
(&source_root, &destination_root),
)
})
})
.await
}
/// Files of a deleted folder snapshotted in Local History.
const DELETE_SNAPSHOT_LIMIT: usize = 2000;
pub fn read_dir(host: &Host, window: &str, path: PathBuf) -> IdeResult<Vec<FileEntry>> {
let excludes = host
.project(window)
.map(|p| {
let globs = p.exclude_globs();
// `read_dir` matches relative to the enclosing repository: re-anchor
// project-root patterns (`/build`) when the project is a repo subfolder.
let repo = p.root.ancestors().find(|d| d.join(".git").exists());
match repo.and_then(|r| p.root.strip_prefix(r).ok()).filter(|rel| !rel.as_os_str().is_empty()) {
Some(rel) => {
let rel = rel.to_string_lossy().replace('\\', "/");
globs.into_iter().map(|g| if g.starts_with('/') { format!("/{rel}{g}") } else { g }).collect()
/// Runs on the blocking pool: on a network share (`\\wsl.localhost`) every stat is slow.
pub async fn read_dir(host: &Host, window: &str, path: PathBuf) -> IdeResult<Vec<FileEntry>> {
let project = host.project(window);
host.blocking(move || {
let excludes = project
.map(|p| {
let globs = p.exclude_globs();
// `read_dir` matches relative to the enclosing repository: re-anchor
// project-root patterns (`/build`) when the project is a repo subfolder.
let repo = p.root.ancestors().find(|d| d.join(".git").exists());
match repo.and_then(|r| p.root.strip_prefix(r).ok()).filter(|rel| !rel.as_os_str().is_empty()) {
Some(rel) => {
let rel = rel.to_string_lossy().replace('\\', "/");
globs.into_iter().map(|g| if g.starts_with('/') { format!("/{rel}{g}") } else { g }).collect()
}
None => globs,
}
None => globs,
}
})
.unwrap_or_default();
ide_vfs::read_dir(&path, &excludes)
})
.unwrap_or_default();
ide_vfs::read_dir(&path, &excludes)
})
.await
}
/// Creates an empty file; inside a project, honours the EditorConfig
+156 -9
View File
@@ -1,11 +1,13 @@
//! Java launch support: classpath / JDK resolution and the java-debug server (jdtls).
pub(crate) mod cache;
use crate::Host;
use crate::rpc::Dispatcher;
use super::build::load;
use ide_build::compile;
use ide_core::{CancellationToken, IdeError, IdeResult, RequestId};
use ide_lsp::jdtls::JavaLaunchInfo;
use ide_lsp::jdtls::{JavaBuildStatus, JavaLaunchInfo};
use std::path::PathBuf;
/// Cancellable with `cancel_request(request)`.
@@ -39,26 +41,52 @@ async fn resolve_launch(
let project = host.project(window)?;
let module = project.root.join(module);
let settings = project.settings.effective();
// Let jdtls finish its build before the external compiler writes Kotlin/Scala classes.
let mut info = project.lsp.java_launch(&module, test, build, cancel).await?;
let (model, external, delegated) = {
let project = project.clone();
let settings = settings.clone();
let module = module.clone();
tokio::task::spawn_blocking(move || {
project.java_launch_snapshots.lock().initialize();
let model = load(&project.root, &project.build_cache_dir(), &settings);
let non_java = build && compile::launch_has_non_java_sources(&project.root, &model, &module, test);
let delegated = build
&& model.maven.as_ref().is_some_and(|m| m.projects.iter().any(|p| project.root.join(&p.dir) == module))
&& (non_java || cache::has_record(&project.root, &project.build_cache_dir(), &module));
let external = non_java || delegated;
(model, external, delegated)
})
.await
.map_err(|e| IdeError::internal(e.to_string()))?
};
let external_build = if delegated { Some(project.lsp.java_external_build(&module, cancel).await?) } else { None };
let mut info = project.lsp.java_launch(&module, test, build && !delegated, delegated, cancel).await?;
let lsp_ms = started.elapsed().as_millis();
let data_dir = host.data_dir.clone();
let data_dir = host.local_data_dir.clone();
let cancel = cancel.clone();
tokio::task::spawn_blocking(move || {
let built = if build && compile::has_non_java_sources(&module) {
compile::build_for_launch(
let _external_build = external_build;
let built = if delegated {
let outputs = project.java_launch_snapshots.lock().build(
&project.root,
&load(&project.root, &project.build_cache_dir(), &settings),
&model,
&module,
test,
ide_sdk::apply::maven_local_repository(&project.root, &settings),
&cancel,
)?
)?;
let copies = use_snapshot(&mut info, &outputs.originals, &outputs.snapshot);
info.build = Some(JavaBuildStatus::Succeeded);
info.errors.clear();
info.error_count = 0;
copies
} else if external {
compile::build_for_launch(&project.root, &model, &module, test, &cancel)?
} else {
Vec::new()
};
let mut class_paths: Vec<String> = built.iter().map(|p| p.to_string_lossy().into_owned()).collect();
for p in std::mem::take(&mut info.class_paths) {
if !class_paths.contains(&p) {
if !class_paths.iter().any(|c| same_entry(c, &p, cfg!(windows))) {
class_paths.push(p);
}
}
@@ -103,6 +131,55 @@ async fn resolve_launch(
.map_err(|e| IdeError::internal(e.to_string()))?
}
/// A classpath entry as a comparable key. Entries come from JDT LS and from our own
/// build model, which spell one directory differently on Windows (`\\?\` prefix,
/// `/` or `\\`, drive-letter case); compared as text, a module output stayed on the
/// classpath next to its launch snapshot and every resource was found twice.
fn entry_key(path: &str, windows: bool) -> String {
let path = match path.strip_prefix(r"\\?\UNC\") {
Some(share) => format!(r"\\{share}"),
None => path.strip_prefix(r"\\?\").unwrap_or(path).to_owned(),
};
let mut key = if windows { path.replace('\\', "/") } else { path };
while key.len() > 1 && key.ends_with('/') {
key.pop();
}
if windows { key.to_lowercase() } else { key }
}
fn same_entry(a: &str, b: &str, windows: bool) -> bool {
a == b || entry_key(a, windows) == entry_key(b, windows)
}
fn use_snapshot(info: &mut JavaLaunchInfo, built: &[PathBuf], snapshot: &std::path::Path) -> Vec<PathBuf> {
use_snapshot_on(info, built, snapshot, cfg!(windows))
}
fn use_snapshot_on(
info: &mut JavaLaunchInfo,
built: &[PathBuf],
snapshot: &std::path::Path,
windows: bool,
) -> Vec<PathBuf> {
let mut copies = Vec::new();
for (index, source) in built.iter().enumerate() {
let dest = snapshot.join(index.to_string());
let original = source.to_string_lossy();
info.class_paths.retain(|p| !same_entry(p, &original, windows));
let mut modular = false;
for path in &mut info.module_paths {
if same_entry(path, &original, windows) {
*path = dest.to_string_lossy().into_owned();
modular = true;
}
}
if !modular {
copies.push(dest);
}
}
copies
}
/// Port of java-debug's DAP server inside the jdtls serving `module` (started on demand).
pub async fn java_debug_server(host: &Host, window: &str, module: PathBuf) -> IdeResult<u16> {
host.require_trust(window)?;
@@ -132,3 +209,73 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("java_debug_server", |c| async move { java_debug_server(&c.host, &c.window, c.arg("module")?).await });
d.add("java_build_workspace", |c| async move { java_build_workspace(&c.host, &c.window, c.arg("module")?).await });
}
#[cfg(test)]
mod tests {
use super::*;
/// JDT LS and our build model spell a module output differently on Windows; the
/// snapshot must still replace it, or resources (MyBatis mapper XML) load twice.
#[test]
fn snapshots_replace_outputs_however_windows_spells_them() {
let built = PathBuf::from(r"D:\workspace\arm\arm-web\target/classes");
let jdtls = [
r"D:\workspace\arm\arm-web\target\classes",
r"d:\workspace\arm\arm-web\target\classes",
r"D:/workspace/arm/arm-web/target/classes/",
r"\\?\D:\workspace\arm\arm-web\target\classes",
];
for spelled in jdtls {
let mut info = JavaLaunchInfo {
class_paths: vec![spelled.into(), r"D:\m2\mybatis.jar".into()],
module_paths: vec![spelled.into()],
..Default::default()
};
use_snapshot_on(&mut info, std::slice::from_ref(&built), std::path::Path::new(r"C:\cache\launch-x"), true);
assert_eq!(info.class_paths, [r"D:\m2\mybatis.jar"], "{spelled}");
assert_eq!(
info.module_paths,
[std::path::Path::new(r"C:\cache\launch-x").join("0").to_string_lossy()],
"{spelled}"
);
}
assert!(same_entry(r"\\?\UNC\server\share\app\target\classes", r"\\server\share\app\target/classes", true));
// Different directories stay different, and other platforms keep case and `\`.
assert!(!same_entry(r"D:\a\target\classes", r"D:\a\target\test-classes", true));
assert!(!same_entry("/work/App/target/classes", "/work/app/target/classes", false));
assert!(!same_entry(r"/work/a\b", "/work/a/b", false));
assert!(same_entry("/work/app/target/classes/", "/work/app/target/classes", false));
}
#[test]
fn launch_snapshots_survive_jdt_overwrites_and_replace_module_outputs() {
let root = tempfile::tempdir().unwrap();
let app = root.path().join("app/target/classes");
let lib = root.path().join("lib/target/classes");
for dir in [&app, &lib] {
std::fs::create_dir_all(dir).unwrap();
std::fs::write(dir.join("Channel.class"), "Maven bytecode").unwrap();
}
let mut info = JavaLaunchInfo {
class_paths: vec![app.to_string_lossy().into_owned(), "stdlib.jar".into()],
module_paths: vec![lib.to_string_lossy().into_owned()],
..Default::default()
};
let snapshot = tempfile::tempdir().unwrap();
for (i, source) in [&app, &lib].iter().enumerate() {
ide_vfs::copy(source, &snapshot.path().join(i.to_string())).unwrap();
}
let paths = use_snapshot(&mut info, &[app.clone(), lib.clone()], snapshot.path());
assert_eq!(paths, [snapshot.path().join("0")]);
assert_eq!(info.class_paths, ["stdlib.jar"]);
assert_eq!(info.module_paths, [snapshot.path().join("1").to_string_lossy().into_owned()]);
std::fs::write(app.join("Channel.class"), "JDT error stub").unwrap();
std::fs::remove_dir_all(lib).unwrap();
for index in ["0", "1"] {
assert_eq!(
std::fs::read_to_string(snapshot.path().join(index).join("Channel.class")).unwrap(),
"Maven bytecode"
);
}
}
}
+371
View File
@@ -0,0 +1,371 @@
//! Immutable Maven outputs retained in the project cache, including across close/reopen.
use ide_build::compile::{self, LaunchFingerprint};
use ide_core::{CancellationToken, IdeError, IdeResult};
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Serialize, Deserialize)]
pub(super) struct Outputs {
fingerprint: Option<LaunchFingerprint>,
pub snapshot: PathBuf,
pub originals: Vec<PathBuf>,
}
impl Outputs {
fn reusable(&self, fingerprint: &Option<LaunchFingerprint>, snapshots: &Path) -> bool {
fingerprint.is_some()
&& self.fingerprint == *fingerprint
&& self.snapshot.parent() == Some(snapshots)
&& !self.originals.is_empty()
&& (0..self.originals.len()).all(|i| self.snapshot.join(i.to_string()).is_dir())
}
}
fn record_path(root: &Path, cache: &Path, module: &Path, test: bool) -> Option<PathBuf> {
Some(cache.join("java-launch/records").join(module.strip_prefix(root).ok()?).join(if test {
"test.json"
} else {
"main.json"
}))
}
pub(super) fn has_record(root: &Path, cache: &Path, module: &Path) -> bool {
[false, true].into_iter().any(|test| record_path(root, cache, module, test).is_some_and(|record| record.is_file()))
}
/// Owned by Project: running JVMs may still load superseded outputs until it drops.
pub(crate) struct Session {
cache: PathBuf,
initialized: bool,
unreferenced: Vec<PathBuf>,
}
impl Session {
pub(crate) fn new(cache: PathBuf) -> Self {
Self { cache, initialized: false, unreferenced: Vec::new() }
}
pub(super) fn initialize(&mut self) {
if !self.initialized {
self.initialized = true;
if let Err(error) = sweep(&self.cache) {
tracing::warn!(%error, "Cannot clean previous Java launch snapshots");
}
}
}
pub(super) fn build(
&mut self,
root: &Path,
model: &ide_build::BuildModel,
module: &Path,
test: bool,
local_repo: Option<PathBuf>,
cancel: &CancellationToken,
) -> IdeResult<Outputs> {
self.initialize();
let cache = &self.cache;
let record =
record_path(root, cache, module, test).ok_or_else(|| IdeError::internal("launch outside project"))?;
let snapshots = cache.join("java-launch/snapshots");
let fingerprint = || match compile::launch_fingerprint(root, model, module, test, local_repo.clone(), cancel) {
Ok(value) => Ok(value),
Err(e) if cancel.is_cancelled() => Err(e),
Err(e) => {
tracing::debug!(error = %e, "Cannot fingerprint Maven launch; rebuilding");
Ok(None)
}
};
let before = fingerprint()?;
let previous = fs::read(&record).ok().and_then(|bytes| serde_json::from_slice::<Outputs>(&bytes).ok());
if let Some(outputs) = previous.as_ref()
&& outputs.reusable(&before, &snapshots)
{
tracing::info!(module = %module.display(), test, "Reusing Maven launch snapshot");
return Ok(previous.unwrap());
}
// Retain a non-reusable record even on failure, so removing the final mixed
// source still delegates to Maven to clean its old classes on the next launch.
fs::create_dir_all(record.parent().unwrap())?;
fs::write(&record, b"null")?;
if let Some(previous) = previous {
self.unreferenced.push(previous.snapshot);
}
let originals = compile::build_for_launch(root, model, module, test, cancel)?;
fs::create_dir_all(&snapshots)?;
let snapshot = tempfile::Builder::new().prefix("launch-").tempdir_in(&snapshots)?.keep();
// Retain every new snapshot even if copying or publishing its record fails.
self.unreferenced.push(snapshot.clone());
for (i, source) in originals.iter().enumerate() {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
ide_vfs::copy(source, &snapshot.join(i.to_string()))?;
}
let after = fingerprint()?;
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let mut outputs = Outputs { fingerprint: None, snapshot, originals };
if before.is_some() && before == after {
outputs.fingerprint = before;
fs::create_dir_all(record.parent().unwrap())?;
let mut file = tempfile::NamedTempFile::new_in(record.parent().unwrap())?;
serde_json::to_writer(file.as_file_mut(), &outputs).map_err(|e| IdeError::internal(e.to_string()))?;
file.persist(&record).map_err(|e| IdeError::from(e.error))?;
self.unreferenced.retain(|path| path != &outputs.snapshot);
}
Ok(outputs)
}
}
impl Drop for Session {
fn drop(&mut self) {
// Recheck all records: another module or launch mode may reference a path.
if let Ok(referenced) = referenced_snapshots(&self.cache) {
let snapshots = self.cache.join("java-launch/snapshots");
for path in &self.unreferenced {
remove_unreferenced(path, &snapshots, &referenced);
}
}
}
}
fn referenced_snapshots(cache: &Path) -> std::io::Result<HashSet<PathBuf>> {
let mut referenced = HashSet::new();
let records = cache.join("java-launch/records");
if !records.try_exists()? {
return Ok(referenced);
}
let mut pending = vec![records];
while let Some(dir) = pending.pop() {
for entry in fs::read_dir(dir)? {
let entry = entry?;
let kind = entry.file_type()?;
if kind.is_dir() {
pending.push(entry.path());
} else if kind.is_file() && entry.path().extension().is_some_and(|ext| ext == "json") {
let outputs: Option<Outputs> = serde_json::from_slice(&fs::read(entry.path())?)?;
if let Some(outputs) = outputs
&& let Ok(path) = fs::canonicalize(outputs.snapshot)
{
referenced.insert(path);
}
}
}
}
Ok(referenced)
}
fn remove_unreferenced(path: &Path, snapshots: &Path, referenced: &HashSet<PathBuf>) {
// Only real launch-* directories directly inside snapshots, never symlinks.
if path.parent() != Some(snapshots)
|| !path.file_name().is_some_and(|name| name.to_string_lossy().starts_with("launch-"))
|| !fs::symlink_metadata(path).is_ok_and(|meta| meta.file_type().is_dir())
{
return;
}
if let (Ok(path), Ok(snapshots)) = (fs::canonicalize(path), fs::canonicalize(snapshots))
&& path.parent() == Some(snapshots.as_path())
&& !referenced.contains(&path)
&& let Err(error) = fs::remove_dir_all(&path)
{
tracing::warn!(%error, path = %path.display(), "Cannot remove Java launch snapshot");
}
}
fn sweep(cache: &Path) -> std::io::Result<()> {
let referenced = referenced_snapshots(cache)?;
let snapshots = cache.join("java-launch/snapshots");
if snapshots.try_exists()? {
for entry in fs::read_dir(&snapshots)? {
remove_unreferenced(&entry?.path(), &snapshots, &referenced);
}
}
Ok(())
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
use std::os::unix::fs::PermissionsExt;
fn fixture(root: &Path) -> ide_build::BuildModel {
fs::write(
root.join("pom.xml"),
r#"<project><groupId>demo</groupId><artifactId>app</artifactId><version>1</version></project>"#,
)
.unwrap();
let script = root.join("mvnw");
fs::write(&script, "#!/bin/sh\necho build >> calls\nif [ -f src/main/kotlin/broken ]; then exit 1; fi\nmkdir -p target/classes\ncp src/main/kotlin/Main.kt target/classes/Main.class\n").unwrap();
fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
fs::create_dir_all(root.join("src/main/kotlin")).unwrap();
fs::write(root.join("src/main/kotlin/Main.kt"), "first").unwrap();
ide_build::load(root, &root.join("cache"), None)
}
#[test]
fn successful_snapshots_reuse_across_close_and_rebuild_for_changes_missing_outputs_or_failure() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let model = fixture(root);
let cache = root.join("cache");
let cancel = CancellationToken::new();
let mut session = Session::new(cache.clone());
let mut build = || session.build(root, &model, root, false, None, &cancel);
let first = build().unwrap();
let snapshot = first.snapshot.clone();
drop(first);
// Dropping an Outputs value leaves the persisted snapshot reusable.
let second = build().unwrap();
assert_eq!(snapshot, second.snapshot);
assert_eq!(fs::read_to_string(root.join("calls")).unwrap().lines().count(), 1);
fs::write(root.join("target/classes/Main.class"), "JDT stub").unwrap();
assert_eq!(snapshot, build().unwrap().snapshot);
assert_eq!(fs::read_to_string(snapshot.join("0/Main.class")).unwrap(), "first");
fs::write(root.join("src/main/kotlin/Main.kt"), "updated").unwrap();
let updated = build().unwrap();
assert_ne!(snapshot, updated.snapshot);
assert_eq!(fs::read_to_string(updated.snapshot.join("0/Main.class")).unwrap(), "updated");
assert!(snapshot.join("0/Main.class").is_file(), "previous JVMs may still load it");
fs::remove_dir_all(updated.snapshot.join("0")).unwrap();
let rebuilt = build().unwrap();
assert_ne!(rebuilt.snapshot, updated.snapshot);
fs::write(root.join("src/main/kotlin/broken"), "broken").unwrap();
assert!(build().is_err());
assert_eq!(fs::read(cache.join("java-launch/records/main.json")).unwrap(), b"null");
fs::remove_file(root.join("src/main/kotlin/broken")).unwrap();
assert_ne!(rebuilt.snapshot, build().unwrap().snapshot, "a failed build invalidates success");
cancel.cancel();
assert!(build().is_err());
assert_eq!(fs::read_to_string(root.join("calls")).unwrap().lines().count(), 5);
}
#[test]
fn session_drop_removes_superseded_snapshots_and_preserves_records_across_reopen() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let model = fixture(root);
let cache = root.join("cache");
let cancel = CancellationToken::new();
let mut session = Session::new(cache.clone());
let first = session.build(root, &model, root, false, None, &cancel).unwrap();
fs::write(root.join("src/main/kotlin/Main.kt"), "updated").unwrap();
let second = session.build(root, &model, root, false, None, &cancel).unwrap();
assert!(first.snapshot.is_dir());
assert!(second.snapshot.is_dir());
// Another mode can keep a superseded snapshot alive.
let test_record = cache.join("java-launch/records/nested/test.json");
fs::create_dir_all(test_record.parent().unwrap()).unwrap();
fs::write(&test_record, serde_json::to_vec(&first).unwrap()).unwrap();
drop(session);
assert!(first.snapshot.is_dir());
assert!(second.snapshot.is_dir());
let mut session = Session::new(cache.clone());
let reused = session.build(root, &model, root, false, None, &cancel).unwrap();
assert_eq!(reused.snapshot, second.snapshot);
fs::write(root.join("src/main/kotlin/Main.kt"), "third version").unwrap();
let third = session.build(root, &model, root, false, None, &cancel).unwrap();
assert!(second.snapshot.is_dir());
drop(session);
assert!(!second.snapshot.exists());
assert!(first.snapshot.is_dir());
assert!(third.snapshot.is_dir());
assert_eq!(fs::read_to_string(root.join("calls")).unwrap().lines().count(), 3);
}
#[test]
fn initial_sweep_only_removes_unreferenced_direct_snapshot_directories() {
let dir = tempfile::tempdir().unwrap();
let cache = dir.path().join("cache");
let snapshots = cache.join("java-launch/snapshots");
let retained = snapshots.join("launch-retained");
let orphan = snapshots.join("launch-orphan");
let other = snapshots.join("other");
let nested = other.join("launch-nested");
let outside = dir.path().join("launch-outside");
for path in [&retained, &orphan, &nested, &outside] {
fs::create_dir_all(path).unwrap();
}
fs::write(outside.join("classes"), "keep").unwrap();
std::os::unix::fs::symlink(&outside, snapshots.join("launch-link")).unwrap();
fs::write(snapshots.join("launch-file"), "keep").unwrap();
let record = cache.join("java-launch/records/deep/module/test.json");
fs::create_dir_all(record.parent().unwrap()).unwrap();
let outputs = Outputs { fingerprint: None, snapshot: retained.clone(), originals: Vec::new() };
fs::write(record, serde_json::to_vec(&outputs).unwrap()).unwrap();
let mut session = Session::new(cache);
session.initialize();
assert!(!orphan.exists());
assert!(retained.is_dir());
assert!(nested.is_dir());
assert!(outside.join("classes").is_file());
assert!(snapshots.join("launch-link").is_symlink());
assert!(snapshots.join("launch-file").is_file());
// Initialization must not sweep snapshots still in use by this session.
fs::create_dir_all(&orphan).unwrap();
session.initialize();
assert!(orphan.is_dir());
session.unreferenced.extend([outside.clone(), snapshots.join("../../launch-outside"), nested]);
drop(session);
assert!(outside.join("classes").is_file());
assert!(other.join("launch-nested").is_dir());
}
#[test]
fn failed_record_publish_retains_snapshot_until_session_drop() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let model = fixture(root);
let script = root.join("mvnw");
let mut text = fs::read_to_string(&script).unwrap();
text.push_str("rm cache/java-launch/records/main.json\nmkdir cache/java-launch/records/main.json\n");
fs::write(script, text).unwrap();
let cache = root.join("cache");
let mut session = Session::new(cache.clone());
assert!(session.build(root, &model, root, false, None, &CancellationToken::new()).is_err());
assert_eq!(session.unreferenced.len(), 1);
let snapshot = session.unreferenced[0].clone();
assert!(snapshot.join("0/Main.class").is_file());
drop(session);
assert!(!snapshot.exists());
}
#[test]
fn edits_during_build_do_not_publish_a_success_record() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let model = fixture(root);
let script = root.join("mvnw");
let mut text = fs::read_to_string(&script).unwrap();
text.push_str("echo edited-during-build >> src/main/kotlin/Main.kt\n");
fs::write(script, text).unwrap();
let cache = root.join("cache");
let mut session = Session::new(cache.clone());
let outputs = session.build(root, &model, root, false, None, &CancellationToken::new()).unwrap();
assert!(outputs.fingerprint.is_none());
assert_eq!(fs::read(cache.join("java-launch/records/main.json")).unwrap(), b"null");
assert!(outputs.snapshot.join("0/Main.class").is_file());
drop(session);
assert!(!outputs.snapshot.exists());
}
#[test]
fn deleting_the_last_mixed_source_keeps_maven_delegation_after_failure() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let model = fixture(root);
let cache = root.join("cache");
let cancel = CancellationToken::new();
let mut session = Session::new(cache.clone());
let first = session.build(root, &model, root, false, None, &cancel).unwrap();
fs::remove_file(root.join("src/main/kotlin/Main.kt")).unwrap();
assert!(!compile::launch_has_non_java_sources(root, &model, root, false));
assert!(has_record(root, &cache, root));
assert!(session.build(root, &model, root, false, None, &cancel).is_err());
assert!(has_record(root, &cache, root));
fs::write(root.join("src/main/kotlin/Main.kt"), "restored").unwrap();
let restored = session.build(root, &model, root, false, None, &cancel).unwrap();
assert_ne!(first.snapshot, restored.snapshot);
}
}
+202 -29
View File
@@ -1,9 +1,33 @@
use super::doc::blocking;
use crate::Host;
use crate::apply_workspace_edit as apply_edit;
use crate::rpc::Dispatcher;
use ide_core::{DocId, IdeResult, Location, Pos, Range, RequestId, ServerId, TextEdit, WorkspaceEdit};
use ide_lsp::types::*;
async fn prepare_actions(
project: std::sync::Arc<crate::Project>,
doc: DocId,
version: u32,
mut actions: Vec<CodeAction>,
) -> IdeResult<Vec<CodeAction>> {
blocking(move || {
if project.documents.version(doc)? != version {
return Err(ide_core::IdeError::new(
ide_core::error::ErrorKind::OutOfSync,
"document changed while computing code actions; request them again",
));
}
for action in &mut actions {
if let Some(edit) = &mut action.edit {
project.documents.prepare_workspace_edit(edit)?;
}
}
Ok(actions)
})
.await
}
pub async fn lsp_completion(
host: &Host,
window: &str,
@@ -83,12 +107,18 @@ pub async fn lsp_implementations(
result
}
/// Contents of a virtual document (library class, decompiled code) that a
/// language server returned a non-`file:` URI for. Cached per project.
/// Contents of a read-only language-server document, including JDT library
/// classes and disk class files. Cached per project.
pub async fn lsp_virtual_document(host: &Host, window: &str, uri: String) -> IdeResult<VirtualDocument> {
host.project(window)?.lsp.virtual_document(&uri).await
}
/// Opens compiled classes through JDT, with project source taking priority on disk.
pub async fn lsp_resolve_class_file(host: &Host, window: &str, path: std::path::PathBuf) -> IdeResult<String> {
let archive = ide_vfs::split_archive_path(&path);
host.project(window)?.lsp.java_class_file(&path, archive).await
}
/// References of the symbol at `pos`. `project_wide` also searches project files
/// outside the language server's analysis scope; bulk scans over many symbols
/// leave it off. Cancel with `cancel_request(request)`.
@@ -132,7 +162,11 @@ pub async fn lsp_code_actions(
range: Range,
only: Vec<String>,
) -> IdeResult<Vec<CodeAction>> {
host.project(window)?.lsp.code_actions(doc, range, only).await
let project = host.project(window)?;
let documents = project.documents.clone();
let version = blocking(move || documents.version(doc)).await?;
let actions = project.lsp.code_actions(doc, range, only).await?;
prepare_actions(project, doc, version, actions).await
}
/// Cancellable code actions with the kinds the servers advertise (Refactor This, refactoring availability).
@@ -144,11 +178,15 @@ pub async fn lsp_code_action_query(
range: Range,
only: Vec<String>,
) -> IdeResult<CodeActionQuery> {
let lsp = host.project(window)?.lsp.clone();
let project = host.project(window)?;
let documents = project.documents.clone();
let version = blocking(move || documents.version(doc)).await?;
let cancel = host.begin_request(request);
let result = lsp.code_action_query(doc, range, only, cancel).await;
let result = project.lsp.code_action_query(doc, range, only, cancel).await;
host.end_request(request);
result
let mut result = result?;
result.actions = prepare_actions(project, doc, version, result.actions).await?;
Ok(result)
}
/// Resolves a code action's edit without applying it (intention preview).
@@ -161,19 +199,28 @@ pub async fn lsp_resolve_code_action(
if action.edit.is_some() {
return Ok(action);
}
host.project(window)?.lsp.resolve_code_action(doc, action).await
let project = host.project(window)?;
let documents = project.documents.clone();
let version = blocking(move || documents.version(doc)).await?;
let action = project.lsp.resolve_code_action(doc, action).await?;
Ok(prepare_actions(project, doc, version, vec![action]).await?.remove(0))
}
/// Resolves (if needed), applies the edit, then runs the command of a code action.
pub async fn lsp_apply_code_action(host: &Host, window: &str, doc: DocId, action: CodeAction) -> IdeResult<()> {
let project = host.project(window)?;
let action = if action.edit.is_none() && action.command.is_none() {
project.lsp.resolve_code_action(doc, action).await?
let documents = project.documents.clone();
let version = blocking(move || documents.version(doc)).await?;
let action = project.lsp.resolve_code_action(doc, action).await?;
prepare_actions(project.clone(), doc, version, vec![action]).await?.remove(0)
} else {
action
};
if let Some(edit) = &action.edit {
apply_edit(&project.documents, edit)?;
let documents = project.documents.clone();
let edit = edit.clone();
blocking(move || apply_edit(&documents, &edit)).await?;
}
if let Some(command) = action.command {
project.lsp.execute_command(doc, command).await?;
@@ -194,11 +241,24 @@ pub async fn lsp_rename(
pos: Pos,
new_name: String,
) -> IdeResult<Renamed> {
let lsp = host.project(window)?.lsp.clone();
let project = host.project(window)?;
let documents = project.documents.clone();
let version = blocking(move || documents.version(doc)).await?;
let cancel = host.begin_request(request);
let result = lsp.rename(doc, pos, new_name, cancel).await;
let result = project.lsp.rename(doc, pos, new_name, cancel).await;
host.end_request(request);
result
let mut renamed = result?;
blocking(move || {
if project.documents.version(doc)? != version {
return Err(ide_core::IdeError::new(
ide_core::error::ErrorKind::OutOfSync,
"document changed while computing rename; request it again",
));
}
project.documents.prepare_workspace_edit(&mut renamed.edit)?;
Ok(renamed)
})
.await
}
pub async fn lsp_format(
@@ -208,7 +268,7 @@ pub async fn lsp_format(
range: Option<Range>,
options: FormatOptions,
) -> IdeResult<Vec<TextEdit>> {
host.project(window)?.lsp.format(doc, range, options).await
crate::formatter::format(host, window, doc, range, options).await
}
pub async fn lsp_inlay_hints(host: &Host, window: &str, doc: DocId, range: Range) -> IdeResult<Vec<InlayHint>> {
@@ -263,25 +323,53 @@ pub fn lsp_resend_diagnostics(host: &Host, window: &str) -> IdeResult<()> {
}
pub async fn lsp_restart(host: &Host, window: &str, server: ServerId) -> IdeResult<()> {
host.project(window)?.lsp.restart(server).await
let project = host.project(window)?;
let java = project.lsp.servers().iter().any(|s| s.id == server && s.name == "jdtls");
project.lsp.restart(server).await?;
if java && let Some(bridge) = &project.kotlin_bridge {
bridge.schedule();
}
Ok(())
}
/// Applies a (previewed) workspace edit, e.g. after rename confirmation.
pub fn apply_workspace_edit(host: &Host, window: &str, edit: WorkspaceEdit) -> IdeResult<()> {
pub async fn apply_workspace_edit(host: &Host, window: &str, edit: WorkspaceEdit) -> IdeResult<()> {
let project = host.project(window)?;
// Multi-file refactorings leave a Local History point to go back to.
let paths: Vec<&std::path::Path> = edit
.files
.iter()
.filter_map(|f| match f {
ide_core::FileEdit::Edit { path, .. } => Some(&**path),
_ => None,
})
.collect();
if edit.files.len() > 1 {
project.record_before_refactoring(&paths, "Apply changes");
}
apply_edit(&project.documents, &edit)
blocking(move || {
// Multi-file refactorings leave a Local History point to go back to.
let paths: Vec<&std::path::Path> = edit
.files
.iter()
.filter_map(|f| match f {
ide_core::FileEdit::Edit { path, .. } => Some(&**path),
_ => None,
})
.collect();
if edit.files.len() > 1 {
project.record_before_refactoring(&paths, "Apply changes");
}
apply_edit(&project.documents, &edit)
})
.await
}
pub async fn prepare_workspace_edit(host: &Host, window: &str, mut edit: WorkspaceEdit) -> IdeResult<WorkspaceEdit> {
let project = host.project(window)?;
blocking(move || {
project.documents.prepare_workspace_edit(&mut edit)?;
Ok(edit)
})
.await
}
pub async fn undo_workspace_edit(host: &Host, window: &str, id: u32) -> IdeResult<()> {
let project = host.project(window)?;
blocking(move || project.documents.undo_workspace_edit(id)).await
}
pub async fn redo_workspace_edit(host: &Host, window: &str, id: u32) -> IdeResult<()> {
let project = host.project(window)?;
blocking(move || project.documents.redo_workspace_edit(id)).await
}
/// Runs a server command (`workspace/executeCommand`), e.g. a completion item's
@@ -290,6 +378,58 @@ pub async fn lsp_execute_command(host: &Host, window: &str, doc: DocId, command:
host.project(window)?.lsp.execute_command(doc, command).await
}
/// Resolves an interactive command without applying edits or executing it.
pub async fn lsp_resolve_command(
host: &Host,
window: &str,
request: RequestId,
doc: DocId,
server: ServerId,
command: Command,
answers: Vec<FormAnswer>,
) -> IdeResult<Option<CommandForm>> {
let lsp = host.project(window)?.lsp.clone();
let cancel = host.begin_request(request);
let result = lsp.resolve_command(doc, server, command, answers, cancel).await;
host.end_request(request);
result
}
/// Applies an explicitly invoked action after the interactive answers have been validated.
pub async fn lsp_execute_code_action(
host: &Host,
window: &str,
request: RequestId,
doc: DocId,
action: CodeAction,
answers: Vec<FormAnswer>,
) -> IdeResult<Vec<ide_core::IpcPath>> {
let project = host.project(window)?;
let cancel = host.begin_request(request);
let result = async {
if cancel.is_cancelled() {
return Err(ide_core::IdeError::cancelled());
}
if let Some(edit) = action.edit {
let documents = project.documents.clone();
tokio::task::spawn_blocking(move || apply_edit(&documents, &edit))
.await
.map_err(|e| ide_core::IdeError::internal(e.to_string()))??;
}
match action.command {
Some(command) => project
.lsp
.execute_action_command(doc, action.server, command, answers, cancel)
.await
.map(|paths| paths.into_iter().map(ide_core::IpcPath::from).collect()),
None => Ok(Vec::new()),
}
}
.await;
host.end_request(request);
result
}
/// Commands of this module the remote server answers.
pub(crate) fn register(d: &mut Dispatcher) {
d.add("lsp_completion", |c| async move {
@@ -315,6 +455,10 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("lsp_implementations", |c| async move {
lsp_implementations(&c.host, &c.window, c.arg("request")?, c.arg("doc")?, c.arg("positions")?).await
});
d.add(
"lsp_resolve_class_file",
|c| async move { lsp_resolve_class_file(&c.host, &c.window, c.arg("path")?).await },
);
d.add("lsp_virtual_document", |c| async move { lsp_virtual_document(&c.host, &c.window, c.arg("uri")?).await });
d.add("lsp_references", |c| async move {
lsp_references(
@@ -373,7 +517,36 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("lsp_servers", |c| async move { lsp_servers(&c.host, &c.window) });
d.add("lsp_resend_diagnostics", |c| async move { lsp_resend_diagnostics(&c.host, &c.window) });
d.add("lsp_restart", |c| async move { lsp_restart(&c.host, &c.window, c.arg("server")?).await });
d.add("apply_workspace_edit", |c| async move { apply_workspace_edit(&c.host, &c.window, c.arg("edit")?) });
d.add("apply_workspace_edit", |c| async move { apply_workspace_edit(&c.host, &c.window, c.arg("edit")?).await });
d.add("lsp_resolve_command", |c| async move {
lsp_resolve_command(
&c.host,
&c.window,
c.arg("request")?,
c.arg("doc")?,
c.arg("server")?,
c.arg("command")?,
c.arg("answers")?,
)
.await
});
d.add("lsp_execute_code_action", |c| async move {
lsp_execute_code_action(
&c.host,
&c.window,
c.arg("request")?,
c.arg("doc")?,
c.arg("action")?,
c.arg("answers")?,
)
.await
});
d.add(
"prepare_workspace_edit",
|c| async move { prepare_workspace_edit(&c.host, &c.window, c.arg("edit")?).await },
);
d.add("redo_workspace_edit", |c| async move { redo_workspace_edit(&c.host, &c.window, c.arg("id")?).await });
d.add("undo_workspace_edit", |c| async move { undo_workspace_edit(&c.host, &c.window, c.arg("id")?).await });
d.add("lsp_execute_command", |c| async move {
lsp_execute_command(&c.host, &c.window, c.arg("doc")?, c.arg("command")?).await
});
+3
View File
@@ -8,6 +8,7 @@ pub mod build;
pub mod bytes;
pub mod coverage;
pub mod debug;
pub mod dependencies;
pub mod deploy;
pub mod doc;
pub mod env;
@@ -21,6 +22,7 @@ pub mod notebook;
pub mod project;
mod python;
pub mod refactor;
pub mod routes;
pub mod run;
pub mod sdk;
pub mod search;
@@ -69,6 +71,7 @@ pub fn register_all(d: &mut crate::rpc::Dispatcher) {
analysis::register(d);
syntax::register(d);
spell::register(d);
dependencies::register(d);
history::register(d);
coverage::register(d);
build::register(d);
+9 -1
View File
@@ -172,8 +172,16 @@ pub fn set_settings(
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}."))) {
let code_style = key == "editor" || key == "editor.codeStyle" || key.starts_with("editor.codeStyle.");
let toolchain = ["languages", "tools", "languageServers", "projectStructure"]
.iter()
.any(|k| key == *k || key.starts_with(&format!("{k}.")));
// Code style only reaches servers as settings (e.g. jdtls' formatter profile); builds don't depend on it.
if code_style || toolchain {
for project in affected() {
if toolchain && let Some(bridge) = &project.kotlin_bridge {
bridge.schedule();
}
let lsp = project.lsp.clone();
host.runtime.spawn(async move { lsp.reconfigure().await });
}
+13 -5
View File
@@ -1,7 +1,6 @@
//! File move / rename refactoring: reference updates from the language
//! servers, the move itself (one undoable step) and its undo.
use crate::Host;
use crate::apply_workspace_edit;
use crate::rpc::Dispatcher;
use ide_core::{IdeResult, RequestId, WorkspaceEdit};
@@ -26,7 +25,12 @@ pub async fn refactor_file_move_edits(
let token = host.begin_request(request);
let result = project.lsp.will_rename_files(&pairs(&moves), token).await;
host.end_request(request);
result
let mut result = result?;
blocking(move || {
project.documents.prepare_workspace_edit(&mut result.edit)?;
Ok(result)
})
.await
}
/// Applies `edit` (from [`refactor_file_move_edits`], possibly empty), moves the
@@ -42,7 +46,7 @@ pub async fn refactor_move_files(
let p = project.clone();
let moved = moves.clone();
let id = blocking(move || {
let apply = |e: &WorkspaceEdit| apply_workspace_edit(&p.documents, e);
let apply = |e: &WorkspaceEdit| p.documents.apply_file_move_edits(e);
p.moves.perform(&p.documents, Some(&p.history), &moved, &edit, &label, &apply)
})
.await?;
@@ -77,13 +81,17 @@ pub async fn refactor_file_move_text_edits(
let cancel = token.clone();
let result = blocking(move || {
let replacements = ide_workspace::text_refs::move_replacements(&project.root, &project.roots(), &moves);
let edit = ide_workspace::text_refs::find_text_occurrences(
let mut edit = ide_workspace::text_refs::find_text_occurrences(
&project.root,
&project.exclude_globs(),
&replacements,
&|| cancel.is_cancelled(),
);
if cancel.is_cancelled() { Err(ide_core::IdeError::cancelled()) } else { Ok(edit) }
if cancel.is_cancelled() {
return Err(ide_core::IdeError::cancelled());
}
project.documents.prepare_workspace_edit(&mut edit)?;
Ok(edit)
})
.await;
host.end_request(request);
+134
View File
@@ -0,0 +1,134 @@
//! Spring Web mappings, source side: the project's Java and Kotlin files that could
//! declare a route, with their text. The UI owns the annotation parser and the index
//! it feeds, so this side stays a cheap scan.
use crate::Host;
use ide_core::IdeResult;
use serde::{Deserialize, Serialize};
use specta::Type;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::sync::atomic::{AtomicUsize, Ordering};
/// Files handed out at most, and bytes in total: a project's controllers fit well
/// inside these, and the UI indexes them once.
const MAX_FILES: usize = 1024;
const MAX_BYTES: usize = 48 << 20;
/// A single source bigger than this is not a controller.
const MAX_FILE_BYTES: u64 = 4 << 20;
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct RouteSource {
pub path: String,
pub text: String,
}
/// The files Spring mappings can live in: Java and Kotlin sources.
fn declares_mappings(path: &Path) -> bool {
matches!(path.extension().and_then(|e| e.to_str()), Some("java" | "kt" | "kts"))
}
/// Files under `root` whose text names a mapping: `ignore` rules and `excludes`
/// decide what counts, and the UI ranks the matches itself.
pub(crate) fn sources_in(root: &Path, excludes: &[String]) -> Vec<RouteSource> {
let found: Mutex<Vec<RouteSource>> = Mutex::new(Vec::new());
let bytes = AtomicUsize::new(0);
ide_vfs::walk_files(root, excludes, |path| {
if !declares_mappings(path) || bytes.load(Ordering::Relaxed) >= MAX_BYTES {
return;
}
if std::fs::metadata(path).map_or(true, |m| m.len() > MAX_FILE_BYTES) {
return;
}
let Ok(text) = std::fs::read_to_string(path) else { return };
// Only a file that names a mapping annotation is worth parsing.
if !text.contains("Mapping") {
return;
}
let Ok(mut found) = found.lock() else { return };
if found.len() >= MAX_FILES {
return;
}
bytes.fetch_add(text.len(), Ordering::Relaxed);
found.push(RouteSource { path: path.to_string_lossy().into_owned(), text });
});
let mut files = found.into_inner().unwrap_or_default();
files.sort_by(|a, b| a.path.cmp(&b.path));
files
}
/// The folders to scan: the project folder itself first, then content roots outside it
/// (`content_roots` lists only the ones outside, so the project folder is never implied —
/// forgetting it scans nothing).
fn scan_roots(root: &Path, content_roots: Vec<PathBuf>) -> Vec<PathBuf> {
let mut roots = vec![root.to_path_buf()];
roots.extend(content_roots);
roots
}
/// Every mapping source of the project, for the request-path index.
pub fn spring_route_sources(host: &Host, window: &str) -> IdeResult<Vec<RouteSource>> {
let project = host.project(window)?;
let excludes = project.search_excludes();
let mut out = Vec::new();
for root in scan_roots(&project.root, project.content_roots()) {
out.extend(sources_in(&root, &excludes));
}
out.sort_by(|a, b| a.path.cmp(&b.path));
out.dedup_by(|a, b| a.path == b.path);
match out.len() {
0 => tracing::warn!("spring routes: no mapping source under {:?} (excludes: {})", project.root, excludes.len()),
n => tracing::info!("spring routes: {n} mapping sources under {:?}", project.root),
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_project_folder_is_scanned_even_without_other_content_roots() {
let root = Path::new("/p/demo");
assert_eq!(scan_roots(root, Vec::new()), [PathBuf::from("/p/demo")]);
assert_eq!(
scan_roots(root, vec![PathBuf::from("/p/elsewhere")]),
[PathBuf::from("/p/demo"), PathBuf::from("/p/elsewhere")]
);
}
#[test]
fn only_java_and_kotlin_sources_are_read() {
for (name, expected) in
[("A.java", true), ("B.kt", true), ("C.kts", true), ("D.txt", false), ("pom.xml", false), ("noext", false)]
{
assert_eq!(declares_mappings(Path::new(name)), expected, "{name}");
}
}
#[test]
fn files_that_name_no_mapping_are_left_out() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join("src")).unwrap();
std::fs::write(tmp.path().join("src/Ctl.java"), "class Ctl { @GetMapping(\"/a\") void a() {} }").unwrap();
std::fs::write(tmp.path().join("src/Plain.java"), "class Plain { void a() {} }").unwrap();
std::fs::write(tmp.path().join("src/Notes.txt"), "@GetMapping(\"/a\")").unwrap();
let files = sources_in(tmp.path(), &[]);
assert_eq!(files.len(), 1, "{:?}", files.iter().map(|f| &f.path).collect::<Vec<_>>());
assert!(Path::new(&files[0].path).ends_with("src/Ctl.java"), "{}", files[0].path);
assert!(files[0].text.contains("@GetMapping"));
}
#[test]
fn excluded_folders_are_skipped() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join("module/src")).unwrap();
std::fs::create_dir_all(tmp.path().join("build")).unwrap();
std::fs::write(tmp.path().join("module/src/A.java"), "@RequestMapping(\"/a\")").unwrap();
std::fs::write(tmp.path().join("build/B.java"), "@RequestMapping(\"/b\")").unwrap();
let files = sources_in(tmp.path(), &["build/**".to_owned()]);
assert_eq!(files.len(), 1, "{:?}", files.iter().map(|f| &f.path).collect::<Vec<_>>());
assert!(Path::new(&files[0].path).ends_with("module/src/A.java"), "{}", files[0].path);
}
}
+17 -5
View File
@@ -50,10 +50,11 @@ fn detected_and_saved(
suppressed: &[String],
) -> Vec<RunConfig> {
let excluded_poms = crate::project::unlinked_maven_poms(root, settings);
let mut all: Vec<RunConfig> = ide_run::detect_excluding(root, &excluded_poms)
.into_iter()
.filter(|d| !suppressed.contains(&d.id) && !saved.iter().any(|u| u.id == d.id))
.collect();
let mut all: Vec<RunConfig> =
ide_run::detect_with_profiles(root, &excluded_poms, settings.cmake.profiles.as_deref())
.into_iter()
.filter(|d| !suppressed.contains(&d.id) && !saved.iter().any(|u| u.id == d.id))
.collect();
all.extend(saved.iter().cloned());
for config in all.iter_mut().filter(|c| c.kind != "gradle") {
ide_sdk::apply::apply_to_run_configs(std::slice::from_mut(config), root, settings);
@@ -61,13 +62,24 @@ fn detected_and_saved(
all
}
/// Saves the user configurations. They come from [`configs`], toolchains applied, so what
/// settings derived is dropped again: only the user's own choices reach `run.toml`.
pub fn save_configs(
host: &Host,
window: &str,
configs: &[RunConfig],
removed_detected_ids: &[String],
) -> IdeResult<()> {
ide_run::save_user_configs_with_removals(&host.project(window)?.root, configs, removed_detected_ids)
let project = host.project(window)?;
let settings = project.settings.effective();
let configs: Vec<RunConfig> = configs
.iter()
.map(|c| match c.kind.as_str() {
"gradle" => c.clone(),
_ => ide_sdk::apply::without_derived(c, &project.root, &settings),
})
.collect();
ide_run::save_user_configs_with_removals(&project.root, &configs, removed_detected_ids)
}
pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit<OutputChunk>) -> IdeResult<RunInfo> {
+98
View File
@@ -5,6 +5,95 @@ use crate::rpc::Dispatcher;
use ide_core::{IdeError, IdeResult};
use ide_run::RunConfig;
pub async fn run_dotnet_resolve(host: &Host, window: &str, config: RunConfig, debug: bool) -> IdeResult<RunConfig> {
host.require_trust(window)?;
if !matches!(config.kind.as_str(), "dotnet" | "dotnet-test")
|| config.extras.target.as_ref().is_some_and(|s| !s.is_empty())
{
return Err(IdeError::invalid(".NET Project requires the local machine"));
}
let project = host.project(window)?;
let mut config = host.blocking(move || ide_run::dotnet::resolve(&project.root, config)).await?;
if debug {
let root = host.project(window)?.root.clone();
let target = ide_run::dotnet::target_path(&root, &config).await?;
config.extras.params.insert("targetPath".into(), target);
}
Ok(config)
}
fn application_cache(project: &crate::project::Project, id: &str) -> std::path::PathBuf {
use std::hash::{Hash, Hasher};
let mut hash = std::collections::hash_map::DefaultHasher::new();
id.hash(&mut hash);
project.cache_dir.join("application-update").join(format!("{:x}", hash.finish()))
}
pub async fn run_application_build(
host: &Host,
window: &str,
config: RunConfig,
module: std::path::PathBuf,
) -> IdeResult<RunConfig> {
host.require_trust(window)?;
if config.kind != "spring-boot" || config.extras.target.as_ref().is_some_and(|t| !t.is_empty()) {
return Err(IdeError::invalid("Application resource updates require a local Spring Boot configuration"));
}
let project = host.project(window)?;
tokio::task::spawn_blocking(move || {
let model = crate::ops::build::load(&project.root, &project.build_cache_dir(), &project.settings.effective());
let plan = ide_build::application_update::plan(
&project.root,
&model,
&module,
&application_cache(&project, &config.id),
)?;
let mut env = plan.env;
env.extend(config.env);
Ok(RunConfig {
id: format!("application-build:{}", config.id),
name: format!("Update {}", config.name),
kind: plan.kind.into(),
command: plan.command,
args: plan.args,
cwd: Some(project.root.join(plan.cwd).into()),
env,
detected: false,
is_test: false,
debug_adapter: None,
allow_parallel: false,
extras: ide_run::RunExtras {
include_system_env: config.extras.include_system_env,
env_files: config.extras.env_files,
..Default::default()
},
})
})
.await
.map_err(|e| IdeError::internal(e.to_string()))?
}
pub async fn run_application_publish(
host: &Host,
window: &str,
config: RunConfig,
class_paths: Vec<String>,
main_class: String,
) -> IdeResult<()> {
host.require_trust(window)?;
let project = host.project(window)?;
tokio::task::spawn_blocking(move || {
ide_build::application_update::publish(
&project.root,
&application_cache(&project, &config.id),
&class_paths,
&main_class,
)
})
.await
.map_err(|e| IdeError::internal(e.to_string()))?
}
/// Configuration templates: per-type defaults applied to new configurations.
pub fn run_templates(host: &Host, window: &str) -> IdeResult<Vec<RunConfig>> {
let root = host.project(window)?.root.clone();
@@ -134,6 +223,15 @@ pub async fn run_tomcat_update(host: &Host, window: &str, config: RunConfig, mod
/// Commands of this module the remote server answers.
pub(crate) fn register(d: &mut Dispatcher) {
d.add("run_dotnet_resolve", |c| async move {
run_dotnet_resolve(&c.host, &c.window, c.arg("config")?, c.arg("debug")?).await
});
d.add("run_application_build", |c| async move {
run_application_build(&c.host, &c.window, c.arg("config")?, c.arg("module")?).await
});
d.add("run_application_publish", |c| async move {
run_application_publish(&c.host, &c.window, c.arg("config")?, c.arg("classPaths")?, c.arg("mainClass")?).await
});
d.add("run_tomcat_home", |c| async move { run_tomcat_home(&c.host, &c.window, c.arg("home")?).await });
d.add("run_tomcat_artifacts", |c| async move { run_tomcat_artifacts(&c.host, &c.window).await });
d.add("run_tomcat_build", |c| async move { run_tomcat_build(&c.host, &c.window, c.arg("config")?).await });
+57 -6
View File
@@ -3,6 +3,21 @@ use crate::Host;
use ide_core::{IdeResult, TerminalId};
use ide_terminal::{TerminalInfo, TerminalOptions};
mod images;
const MAX_IMAGE_BYTES: usize = 20 * 1024 * 1024;
pub async fn store_image(host: &Host, id: TerminalId, data: Vec<u8>, mime: String) -> IdeResult<String> {
if data.len() > MAX_IMAGE_BYTES {
return Err(ide_core::IdeError::invalid("image is too large to paste (limit 20 MB)"));
}
if !host.terminals.contains(id) {
return Err(ide_core::IdeError::invalid("terminal session not found"));
}
let data_dir = host.data_dir.clone();
host.blocking(move || images::store(&data_dir, &data, &mime)).await
}
pub fn spawn(
host: &Host,
window: &str,
@@ -16,12 +31,17 @@ pub fn spawn(
options.args.clear();
options.env = global.env;
}
// Python › "Activate virtualenv": typed into the new shell, like PyCharm.
let activation =
host.project(window).ok().filter(|p| p.is_trusted()).and_then(|p| {
ide_sdk::apply::terminal_activation(&p.root, &p.settings.effective(), options.shell.as_deref())
});
let info = host.terminals.spawn(options, host.window_sink(window), Box::new(move |chunk| on_output(chunk)))?;
let (shell, args) = ide_terminal::resolve_shell(options.shell.as_deref(), &options.args);
options.shell = Some(shell.clone());
options.args = args;
// Python › "Activate virtualenv": typed into the same shell the PTY starts.
let activation = host
.project(window)
.ok()
.filter(|p| p.is_trusted())
.and_then(|p| ide_sdk::apply::terminal_activation(&p.root, &p.settings.effective(), &shell));
let key = host.owner_window(window).to_owned();
let info = host.terminals.spawn(options, key, host.window_sink(window), Box::new(move |chunk| on_output(chunk)))?;
if let Some(line) = activation {
let _ = host.terminals.write(info.id, &format!(" {line}\r"));
}
@@ -43,3 +63,34 @@ pub fn kill(host: &Host, id: TerminalId) -> IdeResult<()> {
pub fn is_busy(host: &Host, id: TerminalId) -> IdeResult<bool> {
host.terminals.is_busy(id)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::HostConfig;
use ide_core::{NullSink, error::ErrorKind};
use std::sync::Arc;
#[tokio::test]
async fn image_size_limit_is_checked_before_storage() {
let root = tempfile::tempdir().unwrap();
let host = Host::new(HostConfig {
config_dir: root.path().join("config"),
data_dir: root.path().join("data"),
local_data_dir: root.path().join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
owner: |window| window,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
let id = TerminalId::next();
let error = store_image(&host, id, vec![0; MAX_IMAGE_BYTES + 1], "image/png".into()).await.unwrap_err();
assert_eq!(error.kind, ErrorKind::InvalidInput);
assert_eq!(error.message, "image is too large to paste (limit 20 MB)");
let error = store_image(&host, id, vec![0; MAX_IMAGE_BYTES], "image/png".into()).await.unwrap_err();
assert_eq!(error.message, "terminal session not found");
assert!(!host.data_dir.join("terminal-images").exists());
}
}
+533
View File
@@ -0,0 +1,533 @@
use ide_core::{IdeError, IdeResult};
use std::path::Path;
const LIFETIME: std::time::Duration = std::time::Duration::from_secs(24 * 60 * 60);
const DIRECTORY: &str = "terminal-images";
fn extension(mime: &str) -> IdeResult<&str> {
match mime {
"image/png" => Ok("png"),
"image/jpeg" => Ok("jpg"),
"image/gif" => Ok("gif"),
"image/webp" => Ok("webp"),
"image/bmp" => Ok("bmp"),
"image/svg+xml" => Ok("svg"),
"image/tiff" => Ok("tiff"),
"image/x-icon" => Ok("ico"),
"audio/wav" | "audio/x-wav" => Ok("wav"),
"audio/mpeg" => Ok("mp3"),
"audio/mp4" => Ok("m4a"),
"audio/ogg" => Ok("ogg"),
"audio/webm" | "video/webm" => Ok("webm"),
"audio/flac" => Ok("flac"),
"audio/aac" => Ok("aac"),
"video/mp4" => Ok("mp4"),
"video/ogg" => Ok("ogv"),
"video/quicktime" => Ok("mov"),
_ => Err(IdeError::invalid("unsupported clipboard media type")),
}
}
fn owned_name(name: &str) -> bool {
let name = name.strip_prefix(".partial-").unwrap_or(name);
let Some((id, ext)) = name.strip_prefix("image-").and_then(|s| s.split_once('.')) else { return false };
id.len() == 32
&& id.bytes().all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
&& matches!(
ext,
"png"
| "jpg"
| "gif"
| "webp"
| "bmp"
| "svg"
| "tiff"
| "ico"
| "wav"
| "mp3"
| "m4a"
| "ogg"
| "webm"
| "flac"
| "aac"
| "mp4"
| "ogv"
| "mov"
)
}
#[cfg(unix)]
mod unix {
use super::*;
use rustix::fs::{self, AtFlags, Dir, Mode, OFlags};
use std::fs::File;
use std::io::{self, Write};
use std::os::unix::fs::MetadataExt;
use std::time::SystemTime;
fn open_directory(data_dir: &Path) -> io::Result<File> {
if !data_dir.is_absolute() {
return Err(io::Error::new(io::ErrorKind::InvalidInput, "data directory must be absolute"));
}
// The configured data directory and its ancestors are the host's trust boundary.
let parent =
fs::open(data_dir, OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC, Mode::empty())?;
match fs::mkdirat(&parent, DIRECTORY, Mode::from_bits_truncate(0o700)) {
Ok(()) | Err(rustix::io::Errno::EXIST) => (),
Err(e) => return Err(e.into()),
}
let dir: File = fs::openat(
&parent,
DIRECTORY,
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
Mode::empty(),
)?
.into();
let meta = dir.metadata()?;
if meta.uid() != rustix::process::geteuid().as_raw() || meta.mode() & 0o7777 != 0o700 {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"terminal image directory is not private or owned by the current user",
));
}
Ok(dir)
}
fn cleanup(dir: &File, now: SystemTime) -> io::Result<()> {
for entry in Dir::read_from(dir)? {
let entry = entry?;
let name = entry.file_name();
if !name.to_str().is_ok_and(owned_name) {
continue;
}
// Never follow symlinks; opening nonblocking avoids hanging on an unexpected FIFO.
let fd = match fs::openat(
dir,
name,
OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::NONBLOCK | OFlags::CLOEXEC,
Mode::empty(),
) {
Ok(fd) => fd,
Err(_) => continue,
};
let file = File::from(fd);
let meta = file.metadata()?;
if meta.is_file()
&& meta.uid() == rustix::process::geteuid().as_raw()
&& meta
.modified()
.ok()
.and_then(|time| now.duration_since(time).ok())
.is_some_and(|age| age >= LIFETIME)
{
match fs::unlinkat(dir, name, AtFlags::empty()) {
Ok(()) | Err(rustix::io::Errno::NOENT) => (),
Err(error) => return Err(error.into()),
}
}
}
Ok(())
}
fn store_with(
data_dir: &Path,
data: &[u8],
mime: &str,
now: SystemTime,
write: impl FnOnce(&mut File, &[u8]) -> io::Result<()>,
) -> IdeResult<String> {
let ext = extension(mime)?;
let dir = open_directory(data_dir)?;
cleanup(&dir, now)?;
let name = format!("image-{}.{}", uuid::Uuid::new_v4().simple(), ext);
let temporary = format!(".partial-{name}");
let fd = fs::openat(
&dir,
temporary.as_str(),
OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::NOFOLLOW | OFlags::CLOEXEC,
Mode::from_bits_truncate(0o600),
)
.map_err(io::Error::from)?;
let mut file = File::from(fd);
if let Err(error) = write(&mut file, data) {
let _ = fs::unlinkat(&dir, temporary.as_str(), AtFlags::empty());
return Err(error.into());
}
if let Err(error) = fs::linkat(&dir, temporary.as_str(), &dir, name.as_str(), AtFlags::empty()) {
let _ = fs::unlinkat(&dir, temporary.as_str(), AtFlags::empty());
return Err(io::Error::from(error).into());
}
let _ = fs::unlinkat(&dir, temporary.as_str(), AtFlags::empty());
Ok(data_dir.join(DIRECTORY).join(name).to_string_lossy().into_owned())
}
pub(in crate::ops::term) fn store(data_dir: &Path, data: &[u8], mime: &str) -> IdeResult<String> {
store_with(data_dir, data, mime, SystemTime::now(), |file, bytes| file.write_all(bytes))
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::os::unix::fs::{PermissionsExt, symlink};
use std::time::Duration;
#[test]
fn stores_bytes_with_private_modes_and_unpredictable_names() {
let root = tempfile::tempdir().unwrap();
let first = store(root.path(), b"image bytes", "image/png").unwrap();
let second = store(root.path(), b"other", "image/jpeg").unwrap();
assert_ne!(first, second);
assert_eq!(fs::read(&first).unwrap(), b"image bytes");
assert_eq!(fs::metadata(&first).unwrap().permissions().mode() & 0o777, 0o600);
assert_eq!(fs::metadata(root.path().join(DIRECTORY)).unwrap().permissions().mode() & 0o777, 0o700);
assert!(owned_name(Path::new(&first).file_name().unwrap().to_str().unwrap()));
}
#[test]
fn expiry_only_removes_owned_regular_files_at_boundary() {
let root = tempfile::tempdir().unwrap();
let dir = open_directory(root.path()).unwrap();
let path = root.path().join(DIRECTORY);
let now = SystemTime::now();
let names = [
"image-00000000000000000000000000000001.png",
"image-00000000000000000000000000000002.png",
"image-00000000000000000000000000000003.png",
"unrelated.png",
];
for (name, time) in names.iter().zip([
now - LIFETIME,
now - LIFETIME + Duration::from_secs(1),
now + Duration::from_secs(1),
now - LIFETIME,
]) {
let file = File::create(path.join(name)).unwrap();
file.set_times(fs::FileTimes::new().set_modified(time)).unwrap();
}
let link = path.join("image-00000000000000000000000000000004.png");
symlink(path.join(names[0]), &link).unwrap();
cleanup(&dir, now).unwrap();
assert!(!path.join(names[0]).exists());
for name in &names[1..] {
assert!(path.join(name).exists());
}
assert!(fs::symlink_metadata(link).unwrap().file_type().is_symlink());
}
#[test]
fn rejects_symlink_and_insecure_directory() {
let root = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let target = root.path().join(DIRECTORY);
symlink(outside.path(), &target).unwrap();
assert!(store(root.path(), b"x", "image/png").is_err());
fs::remove_file(&target).unwrap();
fs::create_dir(&target).unwrap();
fs::set_permissions(&target, fs::Permissions::from_mode(0o755)).unwrap();
assert!(store(root.path(), b"x", "image/png").is_err());
assert_eq!(fs::read_dir(outside.path()).unwrap().count(), 0);
}
#[test]
fn failed_write_removes_partial_file() {
let root = tempfile::tempdir().unwrap();
let result = store_with(root.path(), b"partial", "image/png", SystemTime::now(), |file, bytes| {
file.write_all(bytes)?;
Err(io::Error::other("simulated failure"))
});
assert!(result.is_err());
assert_eq!(fs::read_dir(root.path().join(DIRECTORY)).unwrap().count(), 0);
}
}
}
#[cfg(windows)]
mod windows {
use super::*;
use std::fs::{self, File, OpenOptions};
use std::io::{self, Write};
use std::os::windows::fs::{MetadataExt, OpenOptionsExt};
use std::time::SystemTime;
use windows_sys::Win32::Storage::FileSystem::{
FILE_ATTRIBUTE_REPARSE_POINT, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_READ,
FILE_SHARE_WRITE,
};
fn open_directory(path: &Path) -> io::Result<File> {
if !path.is_absolute() {
return Err(io::Error::new(io::ErrorKind::InvalidInput, "data directory must be absolute"));
}
let dir = OpenOptions::new()
.read(true)
.share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE)
.custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT)
.open(path)?;
let meta = dir.metadata()?;
if !meta.is_dir() || meta.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"terminal media directory must not be a reparse point",
));
}
Ok(dir)
}
fn cleanup(path: &Path, now: SystemTime) -> io::Result<()> {
for entry in fs::read_dir(path)? {
let entry = entry?;
if !entry.file_name().to_str().is_some_and(owned_name) {
continue;
}
let file = match OpenOptions::new().read(true).custom_flags(FILE_FLAG_OPEN_REPARSE_POINT).open(entry.path())
{
Ok(file) => file,
Err(_) => continue,
};
let meta = file.metadata()?;
if meta.is_file()
&& meta.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT == 0
&& meta
.modified()
.ok()
.and_then(|time| now.duration_since(time).ok())
.is_some_and(|age| age >= LIFETIME)
{
match fs::remove_file(entry.path()) {
Ok(()) => (),
Err(error) if error.kind() == io::ErrorKind::NotFound => (),
Err(error) => return Err(error),
}
}
}
Ok(())
}
fn store_with(
data_dir: &Path,
data: &[u8],
mime: &str,
now: SystemTime,
write: impl FnOnce(&mut File, &[u8]) -> io::Result<()>,
) -> IdeResult<String> {
let ext = extension(mime)?;
let _parent = open_directory(data_dir)?;
let path = data_dir.join(DIRECTORY);
match fs::create_dir(&path) {
Ok(()) => (),
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => (),
Err(error) => return Err(error.into()),
}
let _dir = open_directory(&path)?;
cleanup(&path, now)?;
let name = format!("image-{}.{}", uuid::Uuid::new_v4().simple(), ext);
let target = path.join(&name);
let temporary = path.join(format!(".partial-{name}"));
let mut file = OpenOptions::new().write(true).create_new(true).open(&temporary)?;
let result = write(&mut file, data);
drop(file);
if let Err(error) = result {
let _ = fs::remove_file(&temporary);
return Err(error.into());
}
if let Err(error) = fs::hard_link(&temporary, &target) {
let _ = fs::remove_file(&temporary);
return Err(error.into());
}
let _ = fs::remove_file(&temporary);
Ok(target.to_string_lossy().into_owned())
}
pub(in crate::ops::term) fn store(data_dir: &Path, data: &[u8], mime: &str) -> IdeResult<String> {
store_with(data_dir, data, mime, SystemTime::now(), |file, bytes| file.write_all(bytes))
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
fn junction(path: &Path, target: &Path) {
let output = std::process::Command::new("powershell.exe")
.args([
"-NoProfile",
"-NonInteractive",
"-Command",
"New-Item -ItemType Junction -Path $env:OPENIDEA_TEST_JUNCTION -Target $env:OPENIDEA_TEST_TARGET -ErrorAction Stop | Out-Null",
])
.env("OPENIDEA_TEST_JUNCTION", path)
.env("OPENIDEA_TEST_TARGET", target)
.output()
.unwrap();
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
}
#[test]
fn rejects_reparse_data_and_storage_directories() {
let root = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let link = root.path().join("data");
junction(&link, outside.path());
assert!(store(&link, b"x", "image/png").is_err());
assert_eq!(fs::read_dir(outside.path()).unwrap().count(), 0);
fs::remove_dir(&link).unwrap();
let link = root.path().join(DIRECTORY);
junction(&link, outside.path());
assert!(store(root.path(), b"x", "image/png").is_err());
assert_eq!(fs::read_dir(outside.path()).unwrap().count(), 0);
fs::remove_dir(&link).unwrap();
}
#[test]
fn expiry_only_removes_owned_regular_media_at_boundary() {
let root = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let path = root.path().join(DIRECTORY);
fs::create_dir(&path).unwrap();
let _dir = open_directory(&path).unwrap();
let now = SystemTime::now();
let names = [
"image-00000000000000000000000000000001.png",
"image-00000000000000000000000000000002.mp3",
".partial-image-00000000000000000000000000000003.mp4",
"image-00000000000000000000000000000004.webm",
"image-00000000000000000000000000000005.wav",
"unrelated.png",
"image-00000000000000000000000000000006.exe",
];
for (name, time) in names.iter().zip([
now - LIFETIME,
now - LIFETIME,
now - LIFETIME,
now - LIFETIME + Duration::from_secs(1),
now + Duration::from_secs(1),
now - LIFETIME,
now - LIFETIME,
]) {
let file = File::create(path.join(name)).unwrap();
file.set_times(fs::FileTimes::new().set_modified(time)).unwrap();
}
let link = path.join("image-00000000000000000000000000000007.png");
junction(&link, outside.path());
let directory = path.join("image-00000000000000000000000000000008.png");
fs::create_dir(&directory).unwrap();
cleanup(&path, now).unwrap();
for name in &names[..3] {
assert!(!path.join(name).exists());
}
for name in &names[3..] {
assert!(path.join(name).exists());
}
assert!(directory.is_dir());
assert!(fs::symlink_metadata(&link).unwrap().file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0);
fs::remove_dir(link).unwrap();
}
#[test]
fn failed_write_removes_partial_file() {
let root = tempfile::tempdir().unwrap();
let result = store_with(root.path(), b"partial", "audio/ogg", SystemTime::now(), |file, bytes| {
file.write_all(bytes)?;
Err(io::Error::other("simulated failure"))
});
assert!(result.is_err());
assert_eq!(fs::read_dir(root.path().join(DIRECTORY)).unwrap().count(), 0);
}
#[test]
fn publication_preserves_existing_target() {
let root = tempfile::tempdir().unwrap();
let path = root.path().join(DIRECTORY);
let result = store_with(root.path(), b"new", "video/mp4", SystemTime::now(), |file, bytes| {
file.write_all(bytes)?;
let temporary = fs::read_dir(&path)?.next().unwrap()?.file_name();
let target = temporary.to_str().unwrap().strip_prefix(".partial-").unwrap();
fs::write(path.join(target), b"existing")
});
assert!(result.is_err());
let files: Vec<_> = fs::read_dir(&path).unwrap().map(|entry| entry.unwrap().path()).collect();
assert_eq!(files.len(), 1);
assert_eq!(fs::read(&files[0]).unwrap(), b"existing");
assert!(!files[0].file_name().unwrap().to_str().unwrap().starts_with(".partial-"));
}
#[test]
fn storage_handles_prevent_directory_replacement() {
let root = tempfile::tempdir().unwrap();
let path = root.path().join(DIRECTORY);
let result = store_with(root.path(), b"x", "image/png", SystemTime::now(), |file, bytes| {
assert!(fs::rename(&path, root.path().join("replaced")).is_err());
file.write_all(bytes)
});
assert_eq!(fs::read(result.unwrap()).unwrap(), b"x");
}
}
}
#[cfg(unix)]
pub(super) use unix::store;
#[cfg(windows)]
pub(super) use windows::store;
#[cfg(not(any(unix, windows)))]
pub(super) fn store(_data_dir: &Path, _data: &[u8], _mime: &str) -> IdeResult<String> {
Err(IdeError::internal("private terminal image storage is unsupported on this platform"))
}
#[cfg(all(test, any(unix, windows)))]
mod tests {
use super::*;
#[test]
fn stores_supported_media_with_expected_extensions_and_unique_names() {
let root = tempfile::tempdir().unwrap();
let types = [
("image/png", "png"),
("image/jpeg", "jpg"),
("image/gif", "gif"),
("image/webp", "webp"),
("image/bmp", "bmp"),
("image/svg+xml", "svg"),
("image/tiff", "tiff"),
("image/x-icon", "ico"),
("audio/wav", "wav"),
("audio/x-wav", "wav"),
("audio/mpeg", "mp3"),
("audio/mp4", "m4a"),
("audio/ogg", "ogg"),
("audio/webm", "webm"),
("audio/flac", "flac"),
("audio/aac", "aac"),
("video/mp4", "mp4"),
("video/webm", "webm"),
("video/ogg", "ogv"),
("video/quicktime", "mov"),
];
let mut paths = std::collections::HashSet::new();
for (mime, ext) in types {
let path = store(root.path(), mime.as_bytes(), mime).unwrap();
assert_eq!(std::fs::read(&path).unwrap(), mime.as_bytes());
assert_eq!(Path::new(&path).extension().unwrap(), ext);
assert!(owned_name(Path::new(&path).file_name().unwrap().to_str().unwrap()));
assert!(paths.insert(path));
}
assert_eq!(std::fs::read_dir(root.path().join(DIRECTORY)).unwrap().count(), types.len());
}
#[test]
fn rejects_unsupported_mime_before_creating_storage() {
let root = tempfile::tempdir().unwrap();
for mime in ["text/plain", "application/octet-stream", "image/unknown", "audio/unknown", "video/unknown", ""] {
let error = store(root.path(), b"x", mime).unwrap_err();
assert_eq!(error.kind, ide_core::error::ErrorKind::InvalidInput);
assert_eq!(error.message, "unsupported clipboard media type");
}
assert!(!root.path().join(DIRECTORY).exists());
}
#[test]
fn rejects_relative_data_directory() {
assert!(store(Path::new("relative-data"), b"x", "image/png").is_err());
}
}
+6 -6
View File
@@ -12,7 +12,7 @@ use std::path::PathBuf;
/// The installer, reporting the executables chosen in the window's settings.
fn tools(host: &Host, window: &str) -> IdeResult<Tools> {
host.require_trust(window)?;
let data = host.data_dir.clone();
let data = host.local_data_dir.clone();
let (settings, root) = match host.project(window) {
Ok(project) => (project.settings.effective(), project.root.clone()),
Err(_) => (host.global_settings.effective(), Default::default()),
@@ -100,11 +100,11 @@ pub fn jdk_download_vendors(_host: &Host, _window: &str) -> Vec<JdkVendor> {
/// Java versions `vendor` publishes; cancellable with `cancel_request(request)`.
pub async fn jdk_download_releases(
host: &Host,
window: &str,
_window: &str,
vendor: String,
request: RequestId,
) -> IdeResult<JdkReleases> {
let tools = tools(host, window)?;
let tools = Tools::new(&host.local_data_dir);
let cancel = host.begin_request(request);
let result = tokio::task::spawn_blocking(move || tools.jdk_releases(&vendor, &cancel)).await.map_err(join_err);
host.end_request(request);
@@ -113,7 +113,7 @@ pub async fn jdk_download_releases(
/// Where Download JDK puts JDKs unless told otherwise.
pub fn jdk_download_location(host: &Host, _window: &str) -> IdeResult<ide_core::IpcPath> {
Ok(ide_tools::jdk::jdks_dir(&host.data_dir).into())
Ok(ide_tools::jdk::jdks_dir(&host.local_data_dir).into())
}
/// Downloads the latest `vendor` JDK of Java `version` into `location` (default:
@@ -123,14 +123,14 @@ pub fn jdk_download_location(host: &Host, _window: &str) -> IdeResult<ide_core::
#[allow(clippy::too_many_arguments)]
pub async fn jdk_download(
host: &Host,
window: &str,
_window: &str,
vendor: String,
version: u32,
location: Option<PathBuf>,
request: RequestId,
on_event: Channel<InstallEvent>,
) -> IdeResult<Sdk> {
let tools = tools(host, window)?;
let tools = Tools::new(&host.local_data_dir);
let location = location.filter(|p| !p.as_os_str().is_empty());
let cancel = host.begin_request(request);
let result = tokio::task::spawn_blocking(move || {
+111 -28
View File
@@ -123,18 +123,18 @@ fn per_repo(
Ok(())
}
/// Shelf and patches work in the primary repository only: changes of a nested one
/// fail instead of being left out silently.
fn in_primary_repo(project: &Project, paths: &[PathBuf]) -> IdeResult<()> {
/// A shelf is one patch for one owning repository. Validate the whole selection before reverting files.
fn shelf_repository(project: &Project, paths: &[PathBuf]) -> IdeResult<PathBuf> {
let repos = project.repos();
let primary = repos.primary().map(|r| r.root());
match paths.iter().find(|p| repos.for_path(p).map(|r| r.root()) != primary) {
Some(p) => Err(ide_core::IdeError::invalid(format!(
"{} is in another git repository; shelving and patches support the project's main repository only",
p.display()
))),
None => Ok(()),
let root = paths
.first()
.and_then(|p| repos.for_path(p))
.map(|r| r.root())
.ok_or_else(|| ide_core::IdeError::invalid("select files in a git repository to shelve"))?;
if paths.iter().any(|p| repos.for_path(p).map(|r| r.root()) != Some(root)) {
return Err(ide_core::IdeError::invalid("select files from a single git repository to shelve"));
}
Ok(root.to_path_buf())
}
async fn blocking<T: Send + 'static>(f: impl FnOnce() -> IdeResult<T> + Send + 'static) -> IdeResult<T> {
@@ -281,7 +281,7 @@ pub async fn vcs_add_to_ignore(
}
}
apply_git_executable(&project.settings.effective());
let result = ide_vcs::add_to_ignore(&paths, destination);
let result = project.with_disk_sync(|| 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() {
@@ -322,7 +322,7 @@ pub async fn vcs_commit(
// Commit hooks may run for a long time.
let project = host.project(window)?;
blocking(move || {
project.with_repo_at(repo.as_deref(), |r| {
project.with_repo_recorded(repo.as_deref(), None, |r| {
r.commit_with(&message, &options, &|path| project.vcs_encoding(path, None))
})
})
@@ -729,7 +729,13 @@ pub async fn vcs_add_worktree(
host.require_trust(window)?;
let project = host.project(window)?;
blocking(move || {
project.with_repo_at(repo.as_deref(), |r| r.add_worktree(&path, &commitish, new_branch.as_deref()))
let result = project
.with_repo_recorded(repo.as_deref(), None, |r| r.add_worktree(&path, &commitish, new_branch.as_deref()));
// A worktree below the project is a new repository: rescan so its files
// join the status and gutter queries (the watcher picks the gitfile up
// too, but only as a debounced event later).
project.refresh_repositories();
result
})
.await
}
@@ -743,7 +749,39 @@ pub async fn vcs_remove_worktree(
) -> IdeResult<()> {
host.require_trust(window)?;
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.remove_worktree(&path, force))).await
blocking(move || {
let result = project.with_repo_at(repo.as_deref(), |r| r.remove_worktree(&path, force));
project.refresh_repositories();
result
})
.await
}
pub async fn vcs_compare_scope(host: &Host, window: &str, paths: Vec<PathBuf>) -> IdeResult<CompareScope> {
let project = host.project(window)?;
blocking(move || Ok(project.repos().compare_scope(&paths))).await
}
pub async fn vcs_compare_paths(
host: &Host,
window: &str,
paths: Vec<PathBuf>,
revision: String,
repositories: Option<Vec<PathBuf>>,
request: RequestId,
) -> IdeResult<CompareResult> {
let project = host.project(window)?;
let cancel = host.begin_request(request);
let result = blocking(move || {
let settings = project.settings.effective();
if project.is_trusted() {
apply_git_executable(&settings);
}
project.repos().compare_paths(&paths, &revision, repositories.as_deref(), &cancel)
})
.await;
host.end_request(request);
result
}
/// Compare with Local: tracked files that differ between `rev` and the working tree.
@@ -819,7 +857,7 @@ pub async fn vcs_apply_patch(
) -> IdeResult<ApplyOutcome> {
host.require_trust(window)?;
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.apply_patch(&patch))).await
blocking(move || project.with_repo_recorded(repo.as_deref(), None, |r| r.apply_patch(&patch))).await
}
/// Shelve Changes: saves the changes of `paths` as a patch on the shelf and reverts them.
@@ -827,16 +865,23 @@ pub async fn vcs_shelve(host: &Host, window: &str, name: String, paths: Vec<Path
host.require_trust(window)?;
let project = host.project(window)?;
blocking(move || {
in_primary_repo(&project, &paths)?;
let repo = shelf_repository(&project, &paths)?;
keep_private(&project.root);
project.with_repo_recorded(None, Some("Shelve"), |r| r.shelve(&shelf_dir(&project.root), &name, &paths))
project.with_repo_recorded(Some(&repo), Some("Shelve"), |r| r.shelve(&shelf_dir(&project.root), &name, &paths))
})
.await
}
pub fn vcs_shelves(host: &Host, window: &str) -> IdeResult<Vec<ShelvedChangeList>> {
let project = host.project(window)?;
ide_vcs::shelf::list(&shelf_dir(&project.root))
let mut shelves = ide_vcs::shelf::list(&shelf_dir(&project.root))?;
let primary = project.repos().primary().map(|r| ide_core::IpcPath::from(r.root().to_path_buf()));
for entry in &mut shelves {
if entry.repo.is_none() {
entry.repo = primary.clone();
}
}
Ok(shelves)
}
/// Unshelve: applies the shelved patch (3-way merging; conflicts are left for the
@@ -851,7 +896,8 @@ pub async fn vcs_unshelve(
host.require_trust(window)?;
let project = host.project(window)?;
blocking(move || {
project.with_repo_recorded(None, Some("Unshelve"), |r| {
let repo = ide_vcs::shelf::repository(&shelf_dir(&project.root), &id)?;
project.with_repo_recorded(repo.as_deref(), Some("Unshelve"), |r| {
ide_vcs::shelf::unshelve_path(r, &shelf_dir(&project.root), &id, remove, path.as_deref())
})
})
@@ -875,12 +921,30 @@ pub fn vcs_shelf_patch(host: &Host, window: &str, id: String, path: Option<Strin
pub async fn vcs_shelf_diff(host: &Host, window: &str, id: String) -> IdeResult<Vec<ide_vcs::shelf::ShelfDiffFile>> {
let project = host.project(window)?;
blocking(move || project.with_repo(|r| ide_vcs::shelf::diff_files(r, &shelf_dir(&project.root), &id))).await
blocking(move || {
let repo = ide_vcs::shelf::repository(&shelf_dir(&project.root), &id)?;
project.with_repo_at(repo.as_deref(), |r| ide_vcs::shelf::diff_files(r, &shelf_dir(&project.root), &id))
})
.await
}
pub async fn vcs_shelf_import(host: &Host, window: &str, name: String, patch: String) -> IdeResult<()> {
pub async fn vcs_shelf_import(
host: &Host,
window: &str,
repo: Option<PathBuf>,
name: String,
patch: String,
) -> IdeResult<()> {
let project = host.project(window)?;
blocking(move || ide_vcs::shelf::import_patch(&shelf_dir(&project.root), &name, &patch)).await
blocking(move || {
if repo.is_none() && project.repos().is_empty() {
return ide_vcs::shelf::import_patch(&shelf_dir(&project.root), &name, &patch, None);
}
project.with_repo_at(repo.as_deref(), |r| {
ide_vcs::shelf::import_patch(&shelf_dir(&project.root), &name, &patch, Some(r.root()))
})
})
.await
}
pub fn vcs_changelists_load(host: &Host, window: &str) -> IdeResult<ChangelistState> {
@@ -895,13 +959,20 @@ pub fn vcs_changelists_save(host: &Host, window: &str, changelists: ChangelistSt
}
/// Create Patch › Save to file.
pub async fn vcs_write_patch(_host: &Host, _window: &str, path: PathBuf, patch: String) -> IdeResult<()> {
pub async fn vcs_write_patch(host: &Host, window: &str, path: PathBuf, patch: String) -> IdeResult<()> {
let project = host.project(window).ok();
blocking(move || {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
let write = || {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
std::fs::write(&path, patch)?;
Ok(())
};
match project {
Some(project) => project.with_disk_sync(write),
None => write(),
}
std::fs::write(&path, patch)?;
Ok(())
})
.await
}
@@ -1040,6 +1111,18 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("vcs_remove_worktree", |c| async move {
vcs_remove_worktree(&c.host, &c.window, c.arg("repo")?, c.arg("path")?, c.arg("force")?).await
});
d.add("vcs_compare_scope", |c| async move { vcs_compare_scope(&c.host, &c.window, c.arg("paths")?).await });
d.add("vcs_compare_paths", |c| async move {
vcs_compare_paths(
&c.host,
&c.window,
c.arg("paths")?,
c.arg("revision")?,
c.arg("repositories")?,
c.arg("request")?,
)
.await
});
d.add("vcs_diff_with_worktree", |c| async move {
vcs_diff_with_worktree(&c.host, &c.window, c.arg("repo")?, c.arg("rev")?).await
});
@@ -1068,7 +1151,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("vcs_shelf_rename", |c| async move { vcs_shelf_rename(&c.host, &c.window, c.arg("id")?, c.arg("name")?) });
d.add("vcs_shelf_diff", |c| async move { vcs_shelf_diff(&c.host, &c.window, c.arg("id")?).await });
d.add("vcs_shelf_import", |c| async move {
vcs_shelf_import(&c.host, &c.window, c.arg("name")?, c.arg("patch")?).await
vcs_shelf_import(&c.host, &c.window, c.arg("repo")?, c.arg("name")?, c.arg("patch")?).await
});
d.add("vcs_shelf_patch", |c| async move { vcs_shelf_patch(&c.host, &c.window, c.arg("id")?, c.arg("path")?) });
d.add("vcs_changelists_load", |c| async move { vcs_changelists_load(&c.host, &c.window) });
+136 -57
View File
@@ -42,6 +42,8 @@ pub struct Project {
pub detected_roots: Arc<RwLock<Vec<SourceFolder>>>,
/// Per-project cache directory (build tool sync results…).
pub cache_dir: PathBuf,
/// Superseded launch snapshots retained until this project session ends.
pub(crate) java_launch_snapshots: Mutex<crate::ops::java::cache::Session>,
/// Database tool window: data sources and their connections.
pub db: Arc<ide_db::DbManager>,
/// HTTP Client responses (IDEA's `.idea/httpRequests`), in the app data dir.
@@ -50,6 +52,7 @@ pub struct Project {
pub agents: Arc<ide_agent::AgentManager>,
/// Native notebooks and owned kernels, scoped to this project incarnation.
pub notebooks: Arc<crate::notebook::NotebookService>,
pub(crate) kotlin_bridge: Option<Arc<crate::kotlin_bridge::KotlinBridge>>,
pub(crate) watcher: Watcher,
/// Watchers of the Project Structure content roots outside the project folder.
pub(crate) content_watchers: Mutex<Vec<(PathBuf, Watcher)>>,
@@ -109,7 +112,7 @@ impl Host {
let project_key = project_key(&root);
let index = ProjectIndex::open(
&root,
&self.data_dir.join("index").join(&project_key),
&self.local_data_dir.join("index").join(&project_key),
index_roots(&root, extra_roots.clone()),
excludes.clone(),
languages.clone(),
@@ -124,8 +127,10 @@ impl Host {
{
let settings = settings.clone();
let root = root.clone();
let data_dir = self.data_dir.clone();
let data_dir = self.local_data_dir.clone();
let config_dir = self.config_dir.clone();
let sink = sink.clone();
let formatter_problem = std::sync::Mutex::new(None);
lsp.set_config_hook(Box::new(move |cfg, server_root| {
let member = ide_workspace::workspace::Workspace::load(&root).ok().flatten().and_then(|w| {
w.projects.into_iter().filter(|p| server_root.starts_with(p)).max_by_key(|p| p.components().count())
@@ -139,8 +144,40 @@ impl Host {
ide_sdk::apply::configure_server(cfg, root, server_root, &effective);
ide_sdk::java::cap_java_home(cfg, &ide_sdk::java::installed_jdks());
if cfg.name == "jdtls" {
let bundles: Vec<PathBuf> =
ide_tools::managed_bundle(&data_dir, "java-debug").into_iter().collect();
let problem = ide_lsp::jdtls::configure_formatter(
cfg,
effective.editor.code_style.languages.get("java"),
root,
&data_dir,
effective.language_servers.get("jdtls"),
)
.err()
.map(|e| e.to_string());
let mut previous = formatter_problem.lock().unwrap();
if problem != *previous {
if let Some(body) = &problem {
sink.emit(IdeEvent::Notify {
level: ide_core::event::NotifyLevel::Warning,
title: "Java formatter configuration unavailable".into(),
body: Some(body.clone()),
});
}
*previous = problem;
}
drop(previous);
ide_lsp::jdtls::configure_source_paths(
cfg,
ide_workspace::roots::unmanaged_java_source_paths(
root,
server_root,
&effective.project_structure,
),
);
let bundles: Vec<PathBuf> = ide_tools::managed_bundle(&data_dir, "java-debug")
.into_iter()
.chain(ide_sdk::java::usages_bundle(&data_dir))
.chain(ide_sdk::java::kotlin_bridge_bundle(&data_dir))
.collect();
let min_java = ide_sdk::java::jdtls_min_java(cfg);
let jdks = ide_sdk::java::jdtls_jdks(min_java, &data_dir);
let lombok = ide_sdk::lombok::lombok_agent(
@@ -177,7 +214,7 @@ impl Host {
{
let settings = settings.clone();
let root = root.clone();
let data_dir = self.data_dir.clone();
let data_dir = self.local_data_dir.clone();
let sink = sink.clone();
lsp.set_prepare_hook(Box::new(move |cfg, _, cancel| {
let effective = settings.effective();
@@ -216,11 +253,22 @@ impl Host {
trusted: trusted.clone(),
}));
let kotlin_bridge = crate::kotlin_bridge::KotlinBridge::new(
root.clone(),
self.local_data_dir.join("cache").join(&project_key).join("build"),
&self.local_data_dir,
settings.clone(),
lsp.clone(),
sink.clone(),
trusted.clone(),
);
documents.add_observer(kotlin_bridge.clone());
let repos = Arc::new(RwLock::new(Repos::enclosing(&root)?));
for repo in repos.read().all() {
repo.set_execution_allowed(trusted.load(Ordering::Acquire));
}
let watcher = {
let kotlin_bridge = kotlin_bridge.clone();
let repos = repos.clone();
let settings = settings.clone();
let index = index.clone();
@@ -241,6 +289,7 @@ impl Host {
Box::new(move |batch| {
let changed: Vec<PathBuf> = batch.created.iter().chain(&batch.modified).cloned().collect();
index.update(&changed, &batch.removed);
kotlin_bridge.files_changed(changed.iter().chain(&batch.removed));
lsp.project_files_changed(batch.created.iter().chain(&batch.removed));
// Atomic saves by external tools surface as `created`.
documents.on_disk_changed(&changed);
@@ -276,15 +325,35 @@ impl Host {
sink.emit(IdeEvent::ProjectRootsChanged { root: root.clone().into() });
}
}
// A repository was cloned / initialised / removed below the project: its
// `.git/HEAD` (the watcher reports nothing else of `.git`) appeared or went.
// A repository was cloned / initialised / removed below the project:
// its `.git/HEAD` appeared or went. A linked worktree's `.git` is a
// plain gitfile, and that file is the only marker written inside the
// worktree itself, so the `.git` path counts too.
let known = repos.read().roots();
let git_dirs = changed.iter().chain(&batch.removed).chain(&batch.vcs).any(|p| {
let Some(dir) = p.ends_with(".git/HEAD").then(|| p.parent().and_then(Path::parent)).flatten()
else {
return false;
let mut git_dirs = changed.iter().chain(&batch.removed).chain(&batch.vcs).any(|p| {
let dir = if p.ends_with(".git/HEAD") {
p.parent().and_then(Path::parent)
} else if p.ends_with(".git") {
p.parent()
} else {
None
};
known.iter().any(|r| r == dir) != p.is_file()
let Some(dir) = dir else { return false };
known.iter().any(|r| r == dir) != p.exists()
});
// A linked worktree was registered or unregistered in the main
// gitdir: `git worktree add` / `remove` creates or deletes
// `.git/worktrees/<name>`. Its own gitfile is written before the
// watcher can watch the new directory, so this entry is what
// Linux per-directory watchers see.
git_dirs |= batch.vcs.iter().any(|p| {
let rest: Vec<_> = p
.components()
.skip_while(|c| c.as_os_str() != ".git")
.skip(1) // the `.git` component itself
.map(|c| c.as_os_str().to_string_lossy().into_owned())
.collect();
matches!(rest.as_slice(), [first, ..] if first == "worktrees" && rest.len() <= 2)
});
if git_dirs {
scan_repositories(&root, &repos, &settings, &sink);
@@ -296,6 +365,13 @@ impl Host {
}),
)?
};
if let Some(reason) = watcher.polling() {
sink.emit(IdeEvent::Notify {
level: ide_core::event::NotifyLevel::Warning,
title: "External file changes sync may be slow".into(),
body: Some(reason.into()),
});
}
// Scratch files live outside the project; watch them too so the
// Project view and open editors follow changes made there.
@@ -320,7 +396,7 @@ impl Host {
let content_watchers = extra_roots
.iter()
.filter_map(|r| {
let w = content_root_watcher(r, &excludes, &sink, &index, &documents, &history)?;
let w = content_root_watcher(r, &excludes, &sink, &index, &documents, &history, Some(&kotlin_bridge))?;
Some((r.clone(), w))
})
.collect();
@@ -336,7 +412,7 @@ impl Host {
let (db, http_history, agents) = self.project_services(&root, &project_key);
let notebooks = crate::notebook::NotebookService::new(
self.data_dir.join("notebook-runtime").join(uuid::Uuid::new_v4().to_string()),
self.local_data_dir.join("notebook-runtime").join(uuid::Uuid::new_v4().to_string()),
sink.clone(),
);
ide_agent::registry::maintain(crate::ops::agent::registry_paths(self));
@@ -355,11 +431,15 @@ impl Host {
structure: Arc::new(StructureService::new(syntax.clone())),
syntax,
detected_roots,
cache_dir: self.data_dir.join("cache").join(&project_key),
cache_dir: self.local_data_dir.join("cache").join(&project_key),
java_launch_snapshots: Mutex::new(crate::ops::java::cache::Session::new(
self.local_data_dir.join("cache").join(&project_key).join("build"),
)),
db,
http_history,
agents,
notebooks,
kotlin_bridge: Some(kotlin_bridge),
watcher,
content_watchers: Mutex::new(content_watchers),
_scratch_watcher: scratch_watcher,
@@ -378,6 +458,9 @@ impl Host {
}));
}
self.insert_project(window, project.clone());
if let Some(bridge) = &project.kotlin_bridge {
bridge.start(&self.runtime);
}
if let Some(cmd) = post_start {
self.environment.spawn_post_start(cmd, project.root.clone(), project.sink.clone());
}
@@ -453,11 +536,11 @@ impl Host {
let project_key = project_key(&dir);
let (db, http_history, agents) = self.project_services(&dir, &project_key);
let notebooks = crate::notebook::NotebookService::new(
self.data_dir.join("notebook-runtime").join(uuid::Uuid::new_v4().to_string()),
self.local_data_dir.join("notebook-runtime").join(uuid::Uuid::new_v4().to_string()),
sink.clone(),
);
let project = Arc::new(Project {
trusted: Arc::new(AtomicBool::new(true)),
trusted: Arc::new(AtomicBool::new(false)),
sink,
languages,
settings,
@@ -470,11 +553,15 @@ impl Host {
structure: Arc::new(StructureService::new(syntax.clone())),
syntax,
detected_roots: Arc::default(),
cache_dir: self.data_dir.join("cache").join(&project_key),
cache_dir: self.local_data_dir.join("cache").join(&project_key),
java_launch_snapshots: Mutex::new(crate::ops::java::cache::Session::new(
self.local_data_dir.join("cache").join(&project_key).join("build"),
)),
db,
http_history,
agents,
notebooks,
kotlin_bridge: None,
watcher,
content_watchers: Mutex::new(Vec::new()),
_scratch_watcher: None,
@@ -622,17 +709,24 @@ impl Project {
self.with_repo_at(None, f)
}
pub(crate) fn with_disk_sync<T>(&self, f: impl FnOnce() -> IdeResult<T>) -> IdeResult<T> {
let result = f();
// Failed operations can also leave changed files (for example merge conflicts).
self.documents.sync_after_disk_change();
result
}
/// Runs a VCS operation that rewrites the working tree (checkout, merge,
/// rebase, pull, reset, stash, rollback): Local History gets a label and a
/// snapshot of every locally changed file before, and a label after.
/// `None` runs `f` like [`with_repo`](Self::with_repo) (operations that keep the working tree).
/// Clean buffers follow the resulting files before returning; `None` omits the history labels.
pub fn with_repo_recorded<T>(
&self,
at: Option<&Path>,
operation: Option<&str>,
f: impl FnOnce(&Repo) -> IdeResult<T>,
) -> IdeResult<T> {
let Some(name) = operation else { return self.with_repo_at(at, f) };
let Some(name) = operation else { return self.with_disk_sync(|| self.with_repo_at(at, f)) };
self.history_label(&format!("Before {name}"));
if let Ok(changed) = self.with_repo_at(at, |r| r.status()) {
for file in changed.iter().take(HISTORY_BATCH_LIMIT) {
@@ -641,7 +735,7 @@ impl Project {
}
}
}
let result = self.with_repo_at(at, f);
let result = self.with_disk_sync(|| self.with_repo_at(at, f));
self.history_label(&format!("After {name}"));
result
}
@@ -768,9 +862,15 @@ impl Project {
if watchers.iter().any(|(p, _)| *p == r) {
continue;
}
if let Some(w) =
content_root_watcher(&r, &excludes, &self.sink, &self.index, &self.documents, &self.history)
{
if let Some(w) = content_root_watcher(
&r,
&excludes,
&self.sink,
&self.index,
&self.documents,
&self.history,
self.kotlin_bridge.as_ref(),
) {
watchers.push((r, w));
}
}
@@ -815,37 +915,7 @@ impl Project {
/// Applies a multi-file edit through the document layer so open editors update live.
pub fn apply_workspace_edit(documents: &Documents, edit: &ide_core::WorkspaceEdit) -> IdeResult<()> {
use ide_core::FileEdit;
let mut edit = edit.clone();
edit.deduplicate();
for file in &edit.files {
match file {
FileEdit::Edit { path, edits } => {
// Files not open in an editor are opened transiently and closed again.
let (doc, transient) = match documents.by_path(path) {
Some(doc) => (doc, false),
None => (documents.open(path)?.id, true),
};
let result = documents.apply_edits(doc, edits).and_then(|_| documents.save(doc));
if transient {
documents.close(doc);
}
result?;
}
FileEdit::Create { path, overwrite } => {
if path.exists() {
if *overwrite {
ide_vfs::save_text(path, "", "utf-8", false)?;
}
} else {
ide_vfs::create_file(path)?;
}
}
FileEdit::Rename { from, to } => ide_vfs::rename(from, to)?,
FileEdit::Delete { path, .. } => ide_vfs::delete(path)?,
}
}
Ok(())
documents.apply_workspace_edit(edit)
}
/// Files the watcher reports per batch that Local History snapshots.
@@ -902,7 +972,9 @@ fn content_root_watcher(
index: &Arc<ProjectIndex>,
documents: &Arc<Documents>,
history: &Arc<LocalHistory>,
kotlin_bridge: Option<&Arc<crate::kotlin_bridge::KotlinBridge>>,
) -> Option<Watcher> {
let kotlin_bridge = kotlin_bridge.cloned();
let (index, documents, history) = (index.clone(), documents.clone(), history.clone());
Watcher::start(
dir,
@@ -911,6 +983,9 @@ fn content_root_watcher(
Box::new(move |batch| {
let changed: Vec<PathBuf> = batch.created.iter().chain(&batch.modified).cloned().collect();
index.update(&changed, &batch.removed);
if let Some(bridge) = &kotlin_bridge {
bridge.files_changed(changed.iter().chain(&batch.removed));
}
documents.on_disk_changed(&changed);
record_external_changes(&history, &changed, &batch.removed);
}),
@@ -1182,10 +1257,14 @@ mod workspace_edit_tests {
let doc = documents.open(&alias).unwrap();
let changes = |path: PathBuf| FileEdit::Edit {
path: path.into(),
version: None,
edits: vec![TextEdit { range: Range::new(Pos::new(0, 0), Pos::new(0, 3)), new_text: "sum_values".into() }],
};
apply_workspace_edit(&documents, &WorkspaceEdit { files: vec![changes(real.clone()), changes(alias)] })
.unwrap();
apply_workspace_edit(
&documents,
&WorkspaceEdit { files: vec![changes(real.clone()), changes(alias)], ..Default::default() },
)
.unwrap();
assert_eq!(std::fs::read_to_string(real).unwrap(), "sum_values();\n");
assert_eq!(documents.snapshot(doc.id).unwrap().text, "sum_values();\n");
}
+34 -3
View File
@@ -178,7 +178,8 @@ impl Dispatcher {
Ok(())
})
.add("cancel_task", |c| async move { Ok(ide_core::task::cancel(c.arg::<TaskId>("id")?)) })
.add("fs_read_dir", |c| async move { ops::fs::read_dir(&c.host, &c.window, c.arg("path")?) })
.add("fs_read_dir", |c| async move { ops::fs::read_dir(&c.host, &c.window, c.arg("path")?).await })
.add("fs_entry_kind", |c| async move { ops::fs::entry_kind(&c.host, c.arg("path")?).await })
.add("fs_create_file", |c| async move { ops::fs::create_file(&c.host, &c.window, c.arg("path")?) })
.add("fs_create_file_bytes", |c| async move {
ops::fs::create_file_bytes(&c.host, c.arg("path")?, c.arg("data")?).await
@@ -190,11 +191,38 @@ impl Dispatcher {
})
.add("fs_delete", |c| async move { ops::fs::delete(&c.host, &c.window, c.arg("path")?).await })
.add("fs_copy", |c| async move { ops::fs::copy(&c.host, c.arg("from")?, c.arg("to")?).await })
.add("fs_copy_merge", |c| async move {
ops::fs::copy_merge(
&c.host,
&c.window,
c.arg("sourceRoot")?,
c.arg("destinationRoot")?,
c.arg("path")?,
c.arg("overwrite")?,
)
.await
})
.add("fs_compare_directories", |c| async move {
ops::fs::compare_directories(
&c.host,
&c.window,
c.arg("left")?,
c.arg("right")?,
c.arg("request")?,
c.sender("onProgress")?,
)
.await
})
.add("fs_read_text", |c| async move { ops::fs::read_text(&c.host, &c.window, c.arg("path")?).await })
.add("doc_open", |c| async move { ops::doc::open(&c.host, &c.window, c.arg("path")?).await })
.add("doc_close", |c| async move { ops::doc::close(&c.host, &c.window, c.arg("doc")?) })
.add("doc_close", |c| async move {
ops::doc::blocking(move || ops::doc::close(&c.host, &c.window, c.arg("doc")?)).await
})
.add("doc_edit", |c| async move {
ops::doc::edit(&c.host, &c.window, c.arg("doc")?, c.arg("baseVersion")?, c.arg("changes")?)
ops::doc::blocking(move || {
ops::doc::edit(&c.host, &c.window, c.arg("doc")?, c.arg("baseVersion")?, c.arg("changes")?)
})
.await
})
.add("doc_save", |c| async move { ops::doc::save(&c.host, &c.window, c.arg("doc")?).await })
.add("doc_save_all", |c| async move { ops::doc::save_all(&c.host, &c.window).await })
@@ -208,6 +236,9 @@ impl Dispatcher {
let on_output = c.channel("onOutput")?;
ops::term::spawn(&c.host, &c.window, c.arg("options")?, on_output)
})
.add("term_store_image", |c| async move {
ops::term::store_image(&c.host, c.arg("id")?, c.arg("data")?, c.arg("mime")?).await
})
.add("term_write", |c| async move { ops::term::write(&c.host, c.arg("id")?, &c.arg::<String>("data")?) })
.add("term_resize", |c| async move { ops::term::resize(&c.host, c.arg("id")?, c.arg("cols")?, c.arg("rows")?) })
.add("term_kill", |c| async move { ops::term::kill(&c.host, c.arg("id")?) })
+37 -3
View File
@@ -106,6 +106,9 @@ impl crate::Host {
if previous == trusted {
continue;
}
if let Some(bridge) = &project.kotlin_bridge {
bridge.schedule();
}
if !trusted {
project.lsp.set_execution_allowed(false);
}
@@ -132,9 +135,7 @@ impl crate::Host {
for request in requests {
self.cancel_request(request);
}
for session in self.debugger.sessions(Some(&window)) {
let _ = self.debugger.stop(session.id).await;
}
self.debugger.stop_key(&window).await;
}
}
project.lsp.reconfigure().await;
@@ -175,6 +176,30 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
matches!(
cmd,
"app_info"
| "app_build_info"
| "app_log_location"
| "app_check_update"
| "app_startup_update_check"
| "app_install_update"
| "caches_schedule_invalidation"
| "clear_caches"
| "clipboard_file_list"
| "clipboard_has_files"
| "clipboard_set_file_list"
| "jdk_download_vendors"
| "jdk_download_releases"
| "jdk_download_location"
| "jdk_download"
| "run_system_env"
| "run_gradle_jvm_args_script"
| "proxy_check_connection"
| "docker_settings"
| "docker_save_settings"
| "docker_set_registry_password"
| "docker_run_env"
| "db_console_path"
| "db_export_file"
| "vcs_write_patch"
| "app_metrics"
| "app_quit"
| "app_cancel_quit"
@@ -184,6 +209,10 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
| "menu_model"
| "menu_configure"
| "menu_set_disabled"
| "prepare_workspace_edit"
| "apply_workspace_edit"
| "undo_workspace_edit"
| "redo_workspace_edit"
| "lsp_servers"
| "lsp_resend_diagnostics"
| "recent_projects"
@@ -195,6 +224,7 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
| "index_status"
| "jvm_file_presentations"
| "deploy_coverage"
| "deploy_test_connection"
| "find_files"
| "find_symbols"
| "mark_recent"
@@ -216,6 +246,7 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
| "remote_current"
| "remote_disconnect"
| "remote_connect"
| "remote_bootstrap_list"
| "remote_list"
| "remote_open"
| "remote_of"
@@ -235,6 +266,7 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
| "agent_registry_remove"
| "coverage_import"
| "system_font_families"
| "user_font_data"
| "plugin_subscribe"
| "plugin_list"
| "plugin_set_active_file"
@@ -276,6 +308,8 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
| "vcs_remotes"
| "vcs_worktrees"
| "vcs_diff_with_worktree"
| "vcs_compare_scope"
| "vcs_compare_paths"
| "vcs_diff_revisions"
| "vcs_merge_base"
| "vcs_shelves"
+54
View File
@@ -0,0 +1,54 @@
//! Closing a project takes the background processes of its window with it, as IDEA does,
//! and leaves the other windows' processes alone.
#![cfg(unix)]
use ide_core::NullSink;
use ide_host::{Host, HostConfig, ops};
use std::sync::Arc;
fn options(root: &std::path::Path) -> ide_terminal::TerminalOptions {
ide_terminal::TerminalOptions {
cwd: root.to_path_buf().into(),
// A fixed shell: the test must not depend on the login shell of this machine.
shell: Some("/bin/sh".into()),
args: vec![],
env: Default::default(),
cols: 80,
rows: 24,
shell_integration: false,
}
}
#[test]
fn closing_a_project_closes_its_terminals_and_keeps_other_windows() {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap().join("project");
std::fs::create_dir_all(&root).unwrap();
let runtime = tokio::runtime::Builder::new_multi_thread().worker_threads(2).enable_all().build().unwrap();
runtime.block_on(async {
let host = Arc::new(Host::new(HostConfig {
config_dir: tmp.path().join("config"),
data_dir: tmp.path().join("data"),
local_data_dir: tmp.path().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,
}));
host.trust.decide(&root, true, false).unwrap();
host.open_project("main", &root).unwrap();
let project_shell =
ops::term::spawn(&host, "main", options(&root), Arc::new(|_| {})).expect("project terminal");
let other_shell = ops::term::spawn(&host, "other", options(&root), Arc::new(|_| {})).expect("other terminal");
assert_eq!(host.terminals.list().len(), 2);
host.close_project("main");
let left: Vec<_> = host.terminals.list().into_iter().map(|t| t.id).collect();
assert!(!left.contains(&project_shell.id), "the closing window's shell is gone");
assert!(left.contains(&other_shell.id), "another window keeps its shell");
host.terminals.kill_key("other");
});
}
+39
View File
@@ -0,0 +1,39 @@
use ide_core::NullSink;
use ide_host::{Host, HostConfig, ops};
use std::sync::Arc;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn split_storage_keeps_user_history_and_uses_local_tools_and_caches() {
let tmp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(tmp.path()).unwrap();
let data = base.join("roaming");
let local = base.join("local");
let root = base.join("project");
std::fs::create_dir(&root).unwrap();
std::fs::write(root.join("notes.txt"), "hello\n").unwrap();
let host = Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: data.clone(),
local_data_dir: local.clone(),
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,
});
assert_eq!(
std::path::PathBuf::from(ops::tools::jdk_download_location(&host, "main").unwrap()),
local.join("tools/jdks")
);
let light = host.open_light("main", &root.join("notes.txt")).unwrap();
assert!(light.cache_dir.starts_with(&local));
assert!(data.join("history").exists());
assert!(!local.join("history").exists());
let project = host.open_project("main", &root).unwrap();
assert!(project.cache_dir.starts_with(&local));
assert!(local.join("index").exists());
assert!(!data.join("index").exists());
assert!(!data.join("tools").exists());
host.shutdown().await;
}
+102
View File
@@ -9,6 +9,7 @@ fn host(base: &std::path::Path) -> Arc<Host> {
Arc::new(Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
@@ -77,3 +78,104 @@ async fn light_windows_ignore_project_scoped_settings() {
assert!(!applied.project_scope);
assert!(!dir.join(".openidea").exists(), "no project settings next to the file");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn light_windows_stay_restricted_when_folder_trust_changes() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("loose");
let other = tmp.path().join("other");
std::fs::create_dir_all(&root).unwrap();
std::fs::create_dir_all(&other).unwrap();
std::fs::write(root.join("one.py"), "print(1)\n").unwrap();
std::fs::write(other.join("two.js"), "const value = 1;\n").unwrap();
let host = host(&tmp.path().join("app"));
host.global_settings
.set(ide_workspace::SettingsScope::Global, "languages.prettier.prefer", serde_json::json!(true))
.unwrap();
let light = host.open_light("main", &root.join("one.py")).unwrap();
assert!(!light.is_trusted());
assert!(light.require_trust().is_err());
host.set_project_trust(&root, true, false).await.unwrap();
assert!(!light.is_trusted());
let trusted_folder = host.open_light("second", &root.join("one.py")).unwrap();
assert!(!trusted_folder.is_trusted());
let again = host.open_light("main", &other.join("two.js")).unwrap();
assert!(Arc::ptr_eq(&light, &again));
assert!(!again.is_trusted());
host.set_project_trust(&root, true, true).await.unwrap();
assert!(!light.is_trusted());
assert!(!trusted_folder.is_trusted());
for command in ["sdk_detect", "sdk_locate_tools", "notebook_execute", "notebook_restart", "run_start"] {
assert!(host.authorize_command("main", command).is_err(), "{command}");
}
let doc = ops::doc::open(&host, "main", other.join("two.js")).await.unwrap();
let error = ops::lsp::lsp_format(
&host,
"main",
doc.id,
None,
ide_lsp::types::FormatOptions { tab_size: 2, insert_spaces: true, on_save: false },
)
.await
.unwrap_err();
assert_eq!(error.kind, ide_core::error::ErrorKind::Unavailable);
assert!(error.message.contains("Restricted Mode"));
host.set_project_trust(&root, false, false).await.unwrap();
assert!(!light.is_trusted());
assert!(!trusted_folder.is_trusted());
host.set_project_trust(&root, true, false).await.unwrap();
let project = host.open_project("main", &root).unwrap();
assert!(!project.light);
assert!(project.is_trusted());
assert!(project.require_trust().is_ok());
host.shutdown().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn light_notebooks_open_without_interpreter_detection_or_execution() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("loose");
let bin = root.join(".venv").join(if cfg!(windows) { "Scripts" } else { "bin" });
std::fs::create_dir_all(&bin).unwrap();
std::fs::write(root.join(".venv/pyvenv.cfg"), "home = python\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let python = bin.join("python");
std::fs::write(&python, "#!/bin/sh\nprintf probed > \"$0.probed\"\nprintf 'Python 3.12.0\\n'\n").unwrap();
std::fs::set_permissions(&python, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let file = root.join("analysis.ipynb");
std::fs::write(
&file,
r#"{"cells":[{"id":"cell","cell_type":"code","source":["print(1)"],"metadata":{},"outputs":[],"execution_count":null}],"metadata":{},"nbformat":4,"nbformat_minor":5}"#,
)
.unwrap();
let host = host(&tmp.path().join("app"));
host.open_light("main", &file).unwrap();
let notebook = ops::notebook::open(&host, "main", file.to_string_lossy().into()).await.unwrap();
assert!(ops::notebook::save(&host, "main", notebook.id.clone()).await.is_ok());
let error = ops::sdk::sdk_detect(&host, "main", ide_sdk::SdkKind::Python).await.unwrap_err();
assert!(error.message.contains("Restricted Mode"));
assert!(ops::sdk::sdk_python_effective(&host, "main").is_err());
assert!(ops::sdk::sdk_locate_tools(&host, "main", vec!["prettier".into()], Some(root)).await.is_err());
let error = ops::notebook::restart(&host, "main", notebook.id.clone()).await.unwrap_err();
assert!(error.message.contains("Restricted Mode"));
let error = ops::notebook::execute(
&host,
"main",
notebook.id,
vec![notebook.cells[0].id.clone()],
None,
ide_core::RequestId(849),
ops::Channel::new(|_| {}),
)
.await
.unwrap_err();
assert!(error.message.contains("Restricted Mode"));
assert!(!bin.join("python.probed").exists());
host.shutdown().await;
}
+5 -3
View File
@@ -20,6 +20,7 @@ fn host(base: &Path) -> Host {
Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
@@ -104,7 +105,7 @@ async fn properties_search_git_and_document_coordinates_agree() {
"main",
opened.id,
opened.version,
ChangeSet { changes: vec![TextChange { from: 10, to: 16, insert: "世界".into() }] },
ChangeSet { changes: vec![TextChange { from: 10, to: 16, insert: r"\u4E16\u754C".into() }] },
)
.unwrap();
let hunks = vcs::vcs_line_hunks(&host, "main", path.clone()).await.unwrap();
@@ -129,7 +130,7 @@ async fn properties_search_git_and_document_coordinates_agree() {
assert!(project.documents.snapshot(opened.id).unwrap().version > snap.version);
assert!(vcs::vcs_line_hunks(&host, "main", path.clone()).await.unwrap().is_empty());
// Partial commits identify hunks in displayed line coordinates as well.
let partial_text = displayed.replace("target", "世界").replace("logical", "changed");
let partial_text = displayed.replace("target", r"\u4E16\u754C").replace("logical", "changed");
project.documents.replace_all(opened.id, &partial_text).unwrap();
doc::save(&host, "main", opened.id).await.unwrap();
let hunks = ide_vcs::diff_hunks(displayed, &partial_text);
@@ -169,7 +170,7 @@ async fn properties_search_git_and_document_coordinates_agree() {
"main",
RequestId(520),
query,
"世界".into(),
r"\u4E16\u754C".into(),
false,
vec![ide_search::ReplaceTarget {
path: closed_path.clone().into(),
@@ -181,5 +182,6 @@ async fn properties_search_git_and_document_coordinates_agree() {
.unwrap();
assert_eq!(std::fs::read_to_string(&closed_path).unwrap(), r"value=\u4F60\u597D\uD83D\uDE00\u4E16\u754C");
assert!(project.documents.by_path(&closed_path).is_none());
assert_eq!(project.text_of(&closed_path).unwrap(), "value=你好😀世界");
host.close_project("main");
}
+1
View File
@@ -10,6 +10,7 @@ fn host(base: &std::path::Path) -> Arc<Host> {
Arc::new(Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
+1
View File
@@ -79,6 +79,7 @@ fn server_environment_from_login_shell_and_devcontainer() {
let host = Arc::new(Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(move |_| sink_events.clone() as SharedSink),
+70
View File
@@ -56,6 +56,7 @@ async fn serves_projects_files_documents_search_and_runs() {
let host = Arc::new(Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(move |w| rpc::event_sink(sink_peer.clone(), Some(w.to_owned()))),
@@ -87,6 +88,40 @@ async fn serves_projects_files_documents_search_and_runs() {
}
let call = Invoke { window: "unknown".into(), cmd: "coverage_collect".into(), args: json!({}) };
assert!(peer.request::<_, Value>("invoke", call, None).await.is_err());
// Manual workspace edits remain available in Restricted Mode, like doc/fs
// editing. They do not ask a language server to execute project commands.
let manual = project.join("src/manual.txt");
std::fs::write(&manual, "old").unwrap();
let edit = json!({ "files": [{ "kind": "edit", "path": manual, "edits": [{
"range": { "start": { "line": 0, "col": 0 }, "end": { "line": 0, "col": 3 } },
"new_text": "new"
}] }] });
let edit = invoke(&peer, "prepare_workspace_edit", json!({ "edit": edit })).await.unwrap();
assert_eq!(edit["preconditions"].as_array().unwrap().len(), 1);
invoke(&peer, "apply_workspace_edit", json!({ "edit": edit })).await.unwrap();
until("workspace edit history", || {
client.events.lock().iter().any(|event| matches!(event.event, ide_core::IdeEvent::WorkspaceEditApplied { .. }))
})
.await;
let undo_id = client
.events
.lock()
.iter()
.find_map(|event| match &event.event {
ide_core::IdeEvent::WorkspaceEditApplied { id, .. } => Some(*id),
_ => None,
})
.unwrap();
assert_eq!(std::fs::read_to_string(&manual).unwrap(), "new");
invoke(&peer, "undo_workspace_edit", json!({ "id": undo_id })).await.unwrap();
assert_eq!(std::fs::read_to_string(&manual).unwrap(), "old");
invoke(&peer, "redo_workspace_edit", json!({ "id": undo_id })).await.unwrap();
assert_eq!(std::fs::read_to_string(&manual).unwrap(), "new");
assert_eq!(invoke(&peer, "project_current", json!({})).await.unwrap()["trusted"], false);
for command in ["lsp_execute_command", "lsp_apply_code_action"] {
let error = invoke(&peer, command, json!({})).await.unwrap_err();
assert!(error.message.contains("Restricted Mode"), "{command}: {error:?}");
}
invoke(&peer, "project_trust_set", json!({"path": project, "trusted": true, "folder": false})).await.unwrap();
let current = invoke(&peer, "project_current", json!({})).await.unwrap();
assert_eq!(current["name"], "demo");
@@ -94,6 +129,39 @@ async fn serves_projects_files_documents_search_and_runs() {
let entries = invoke(&peer, "fs_read_dir", json!({ "path": project })).await.unwrap();
assert!(entries.as_array().unwrap().iter().any(|e| e["name"] == "src"), "{entries}");
// Directory comparisons, streamed progress and confirmed copies share the remote filesystem.
let left = project.join("compare-left");
let right = project.join("compare-right");
std::fs::create_dir_all(&left).unwrap();
std::fs::create_dir_all(&right).unwrap();
std::fs::write(left.join("中文.bin"), [0, 255, 1]).unwrap();
std::fs::write(right.join("中文.bin"), [0, 254, 1]).unwrap();
assert_eq!(invoke(&peer, "fs_entry_kind", json!({"path": left})).await.unwrap(), "dir");
assert_eq!(invoke(&peer, "fs_entry_kind", json!({"path": left.join("中文.bin")})).await.unwrap(), "file");
let compare_args =
|request| json!({"left": left, "right": right, "request": request, "onProgress": {"$channel": 520}});
let rows = invoke(&peer, "fs_compare_directories", compare_args(520)).await.unwrap();
assert_eq!(rows[0]["status"], "different");
assert!(client.channels.lock().iter().any(|c| c.id == 520 && c.data["compared"] == 1));
let copy_args =
|overwrite| json!({"sourceRoot": left, "destinationRoot": right, "path": "中文.bin", "overwrite": overwrite});
let error = invoke(&peer, "fs_copy_merge", copy_args(false)).await.unwrap_err();
assert_eq!(error.kind, ide_core::error::ErrorKind::AlreadyExists);
invoke(&peer, "fs_copy_merge", copy_args(true)).await.unwrap();
let rows = invoke(&peer, "fs_compare_directories", compare_args(521)).await.unwrap();
assert_eq!(rows[0]["status"], "equal");
invoke(&peer, "cancel_request", json!({"id": 522})).await.unwrap();
let error = invoke(&peer, "fs_compare_directories", compare_args(522)).await.unwrap_err();
assert_eq!(error.kind, ide_core::error::ErrorKind::Cancelled);
let error = invoke(
&peer,
"fs_copy_merge",
json!({"sourceRoot": left, "destinationRoot": right, "path": "../escape", "overwrite": true}),
)
.await
.unwrap_err();
assert_eq!(error.kind, ide_core::error::ErrorKind::InvalidInput);
// JVM icons use the same read-only protocol as local windows.
let java = project.join("src/Foo.java");
let kotlin = project.join("src/Singleton.kt");
@@ -228,6 +296,7 @@ async fn local_deployment_runs_on_the_project_host() {
let host = Arc::new(Host::new(HostConfig {
config_dir: tmp.path().join("config"),
data_dir: tmp.path().join("data"),
local_data_dir: tmp.path().join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(move |w| rpc::event_sink(sink_slot.clone(), Some(w.to_owned()))),
@@ -295,6 +364,7 @@ async fn properties_conversion_through_remote_dispatch_without_a_remote_host() {
let host = Arc::new(Host::new(HostConfig {
config_dir: tmp.path().join("config"),
data_dir: tmp.path().join("data"),
local_data_dir: tmp.path().join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(ide_core::NullSink)),
+1
View File
@@ -6,6 +6,7 @@ fn host(base: &std::path::Path) -> Arc<Host> {
Arc::new(Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
@@ -0,0 +1,63 @@
//! Own test process: the inherited shell deliberately differs from the login shell.
#![cfg(unix)]
use ide_core::NullSink;
use ide_host::{Host, HostConfig, ops};
use std::sync::Arc;
use std::time::{Duration, Instant};
#[test]
fn terminal_activation_uses_the_resolved_default_shell() {
// SAFETY: no runtime or worker threads have been started.
unsafe { std::env::set_var("SHELL", "/not-the-terminal/docker") };
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap().join("project");
let bin = root.join(".venv/bin");
std::fs::create_dir_all(&bin).unwrap();
std::fs::write(root.join(".venv/pyvenv.cfg"), "home = /usr/bin\n").unwrap();
std::fs::write(bin.join("python"), "").unwrap();
for script in ["activate", "activate.fish", "activate.csh", "activate.nu", "Activate.ps1"] {
std::fs::write(bin.join(script), "echo activated > activation.marker\n").unwrap();
}
let runtime = tokio::runtime::Builder::new_multi_thread().worker_threads(2).enable_all().build().unwrap();
runtime.block_on(async {
let host = Arc::new(Host::new(HostConfig {
config_dir: tmp.path().join("config"),
data_dir: tmp.path().join("data"),
local_data_dir: tmp.path().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,
}));
host.trust.decide(&root, true, false).unwrap();
host.open_project("main", &root).unwrap();
for shell in [None, Some("".to_owned()), Some(" \t ".to_owned())] {
let terminal = ops::term::spawn(
&host,
"main",
ide_terminal::TerminalOptions {
cwd: root.clone().into(),
shell,
args: vec![],
env: Default::default(),
cols: 80,
rows: 24,
shell_integration: false,
},
Arc::new(|_| {}),
)
.unwrap();
let deadline = Instant::now() + Duration::from_secs(10);
while !std::fs::read_to_string(root.join("activation.marker")).is_ok_and(|s| s.trim() == "activated") {
assert!(Instant::now() < deadline, "default terminal shell must run the activation script");
tokio::time::sleep(Duration::from_millis(20)).await;
}
host.terminals.kill(terminal.id).unwrap();
std::fs::remove_file(root.join("activation.marker")).unwrap();
}
host.shutdown().await;
});
}
+51
View File
@@ -0,0 +1,51 @@
#![cfg(any(unix, windows))]
use ide_core::{NullSink, TerminalId};
use ide_host::{Host, HostConfig, ops};
use ide_terminal::TerminalOptions;
use std::sync::Arc;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn storage_requires_a_registered_terminal() {
let root = tempfile::tempdir().unwrap();
let data_dir = root.path().join("data");
std::fs::create_dir(&data_dir).unwrap();
let host = Host::new(HostConfig {
config_dir: root.path().join("config"),
data_dir: data_dir.clone(),
local_data_dir: data_dir.clone(),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
owner: |window| window,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
assert!(ops::term::store_image(&host, TerminalId::next(), vec![1], "image/png".into()).await.is_err());
assert!(!data_dir.join("terminal-images").exists());
let terminal = host
.terminals
.spawn(
TerminalOptions {
cwd: root.path().into(),
shell: Some(if cfg!(windows) { "powershell.exe" } else { "/bin/sh" }.into()),
args: if cfg!(windows) { vec!["-NoLogo".into(), "-NoProfile".into()] } else { vec![] },
env: Default::default(),
cols: 80,
rows: 24,
shell_integration: false,
},
"main".into(),
Arc::new(NullSink),
Box::new(|_| {}),
)
.unwrap();
for mime in ["image/png", "audio/mpeg", "video/mp4"] {
let path = ops::term::store_image(&host, terminal.id, vec![1, 2, 3], mime.into()).await.unwrap();
assert_eq!(std::fs::read(path).unwrap(), [1, 2, 3]);
}
assert!(ops::term::store_image(&host, terminal.id, vec![1], "application/octet-stream".into()).await.is_err());
host.terminals.kill(terminal.id).unwrap();
assert!(ops::term::store_image(&host, terminal.id, vec![4], "image/png".into()).await.is_err());
assert_eq!(std::fs::read_dir(data_dir.join("terminal-images")).unwrap().count(), 3);
}
+153 -1
View File
@@ -1,13 +1,17 @@
use ide_core::NullSink;
use ide_host::{Host, HostConfig, ops, trust::TrustStore};
#[cfg(unix)]
use ide_host::ops;
use ide_host::{Host, HostConfig, trust::TrustStore};
use std::path::Path;
use std::sync::Arc;
#[cfg(unix)]
use std::time::{Duration, Instant};
fn host(base: &Path, environment: bool) -> Arc<Host> {
Arc::new(Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
@@ -17,6 +21,154 @@ fn host(base: &Path, environment: bool) -> Arc<Host> {
}))
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn application_commands_work_in_restricted_projects_and_without_a_project() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("project");
std::fs::create_dir(&root).unwrap();
let host = host(&tmp.path().join("app"), false);
assert!(host.project("welcome").is_err());
assert!(ide_host::ops::project::roots(&host, "welcome").is_err());
assert!(!host.open_project("main", &root).unwrap().is_trusted());
for command in [
"app_info",
"app_build_info",
"app_log_location",
"app_check_update",
"app_startup_update_check",
"app_install_update",
"caches_schedule_invalidation",
"clear_caches",
"clipboard_file_list",
"clipboard_has_files",
"clipboard_set_file_list",
"jdk_download_vendors",
"jdk_download_releases",
"jdk_download_location",
"jdk_download",
"run_system_env",
"run_gradle_jvm_args_script",
"proxy_check_connection",
"docker_settings",
"docker_save_settings",
"docker_set_registry_password",
"docker_run_env",
"db_console_path",
"db_export_file",
"vcs_write_patch",
"remote_bootstrap_list",
"deploy_test_connection",
] {
assert!(ide_host::trust::restricted_command_allowed(command), "{command}");
for window in ["main", "welcome"] {
assert!(host.authorize_command(window, command).is_ok(), "{window}: {command}");
}
}
for command in [
"run_start",
"build_sync",
"debug_start",
"sdk_detect",
"tools_list",
"tools_install",
"tools_uninstall",
"plugin_install_dir",
"plugin_reload",
"vcs_git_version",
"docker_compose_op",
"db_execute",
"remote_delete",
"remote_mkdir",
"remote_rename",
"remote_import_local",
"remote_bootstrap_exec",
"deploy_upload",
"deploy_download",
"deploy_delete_remote",
"deploy_sync",
"unknown_command",
] {
assert!(!ide_host::trust::restricted_command_allowed(command), "{command}");
for window in ["main", "welcome"] {
assert!(host.authorize_command(window, command).is_err(), "{window}: {command}");
}
}
// These requests validate the vendor before any network access, independently of project trust.
for window in ["main", "welcome"] {
let error = ide_host::ops::tools::jdk_download_releases(
&host,
window,
"unknown-vendor".into(),
ide_core::RequestId(901),
)
.await
.unwrap_err();
assert_eq!(error.kind, ide_core::error::ErrorKind::InvalidInput);
let error = ide_host::ops::tools::jdk_download(
&host,
window,
"unknown-vendor".into(),
25,
None,
ide_core::RequestId(902),
ide_host::ops::Channel::new(|_| {}),
)
.await
.unwrap_err();
assert_eq!(error.kind, ide_core::error::ErrorKind::InvalidInput);
}
host.shutdown().await;
}
#[tokio::test]
async fn welcome_window_browses_selected_ssh_host_without_executing_commands() {
use ide_core::remote_settings::{SshAuth, SshConfiguration};
use ide_ssh::{FsConnection, SshFiles, SshManager};
let tmp = tempfile::tempdir().unwrap();
let server_root = tmp.path().join("server");
std::fs::create_dir_all(server_root.join("projects/demo")).unwrap();
std::fs::write(server_root.join("projects/README.md"), "remote file").unwrap();
let port = ide_ssh::testing::sshd(server_root, Arc::new(|cmd| panic!("unexpected SSH exec: {cmd}"))).await;
let ssh = Arc::new(SshManager::with_files(
tmp.path().join("secrets.json"),
SshFiles { known_hosts: tmp.path().join("known_hosts"), config: None },
));
ssh.set_configs(vec![SshConfiguration {
id: "selected-host".into(),
host: "127.0.0.1".into(),
port: Some(port),
user: "dev".into(),
auth: SshAuth::Password,
..Default::default()
}]);
ssh.set_secret("selected-host", Some("secret".into()), false);
assert!(ssh.session("selected-host").await.is_err());
ssh.trust_host("selected-host").await.unwrap();
let mut host = host(&tmp.path().join("app"), false);
let state = Arc::get_mut(&mut host).unwrap();
state.ssh = ssh;
state.remote = Arc::new(ide_remote::RemoteService::new(state.ssh.clone(), tmp.path().join("cache")));
assert!(host.project("welcome").is_err());
host.authorize_command("welcome", "remote_bootstrap_list").unwrap();
let connection = FsConnection::Sftp { ssh: "selected-host".into() };
let entries = host.remote.list(&connection, "/projects").await.unwrap();
assert!(entries.iter().any(|e| e.path == "/projects/demo" && e.is_dir));
assert!(entries.iter().any(|e| e.name == "README.md" && !e.is_dir));
assert!(host.remote.list(&connection, "/projects/demo").await.unwrap().is_empty());
host.authorize_command("welcome", "deploy_test_connection").unwrap();
host.remote.test_connection(&connection, "/projects").await.unwrap();
let local = tmp.path().join("local-deployment");
std::fs::create_dir(&local).unwrap();
ide_host::ops::deploy::deploy_test_connection(&host, FsConnection::Local, local.to_str().unwrap().into())
.await
.unwrap();
assert_eq!(std::fs::read_dir(local).unwrap().count(), 0, "write probe is cleaned up");
assert!(host.project("welcome").is_err());
host.shutdown().await;
}
#[test]
fn persists_user_decisions_locations_revocation_and_one_time_migration() {
let tmp = tempfile::tempdir().unwrap();
@@ -12,6 +12,7 @@ async fn restricted_status_does_not_select_the_project_git_for_other_windows() {
let host = Arc::new(Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
+240
View File
@@ -0,0 +1,240 @@
use ide_core::{ChangeSet, NullSink, TextChange};
use ide_host::{Host, HostConfig, Project, ops::vcs};
use std::{path::Path, process::Command, sync::Arc, time::Duration};
const ORIGINAL: &str = "SELECT *\nFROM patients\n\tLIMIT 10 OFFSET 0";
const OTHER: &str = "SELECT id\nFROM patients\n\tLIMIT 20 OFFSET 0";
fn git(root: &Path, args: &[&str]) -> String {
let out = Command::new("git").current_dir(root).args(args).output().unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
String::from_utf8(out.stdout).unwrap()
}
struct Fixture {
host: Host,
project: Arc<Project>,
path: std::path::PathBuf,
doc: ide_core::DocId,
_temp: tempfile::TempDir,
}
impl Fixture {
async fn new() -> Self {
let temp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(temp.path()).unwrap();
let root = base.join("project");
// The command must synchronize documents even when watcher delivery is excluded.
let path = root.join("node_modules/query.sql");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
git(&root, &["init", "-b", "main"]);
git(&root, &["config", "user.name", "Test"]);
git(&root, &["config", "user.email", "[email protected]"]);
git(&root, &["config", "commit.gpgsign", "false"]);
git(&root, &["config", "core.autocrlf", "false"]);
std::fs::write(&path, ORIGINAL).unwrap();
git(&root, &["add", "-f", "node_modules/query.sql"]);
git(&root, &["commit", "-m", "original"]);
git(&root, &["switch", "-c", "other"]);
std::fs::write(&path, OTHER).unwrap();
git(&root, &["commit", "-am", "other"]);
git(&root, &["switch", "main"]);
let host = Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_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,
});
host.trust.decide(&root, true, false).unwrap();
let project = host.open_project("main", &root).unwrap();
for _ in 0..500 {
if !project.repos().is_empty() {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
let doc = project.documents.open(&path).unwrap().id;
Self { host, project, path, doc, _temp: temp }
}
fn saved_edit(&self, text: &str) {
self.project.documents.replace_all(self.doc, text).unwrap();
self.project.documents.save(self.doc).unwrap();
}
fn assert_clean(&self, text: &str) {
let snapshot = self.project.documents.snapshot(self.doc).unwrap();
assert_eq!(snapshot.text, text);
assert!(!snapshot.dirty);
self.project.documents.save_all().unwrap();
assert_eq!(std::fs::read_to_string(&self.path).unwrap(), text);
}
}
impl Drop for Fixture {
fn drop(&mut self) {
self.host.close_project("main");
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn checkout_reloads_clean_open_documents_before_returning() {
let f = Fixture::new().await;
vcs::vcs_checkout(&f.host, "main", None, "other".into(), false, None).await.unwrap();
f.assert_clean(OTHER);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn rollback_reloads_clean_open_documents_before_returning() {
let f = Fixture::new().await;
f.saved_edit(&format!("{ORIGINAL}\n"));
vcs::vcs_discard(&f.host, "main", vec![f.path.clone()]).await.unwrap();
f.assert_clean(ORIGINAL);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn stash_and_unstash_reload_clean_open_documents_before_returning() {
let f = Fixture::new().await;
let edited = format!("{ORIGINAL}\n");
f.saved_edit(&edited);
vcs::vcs_stash_push(&f.host, "main", None, None, false, false).await.unwrap();
f.assert_clean(ORIGINAL);
vcs::vcs_stash_apply(&f.host, "main", None, 0, true, false, None).await.unwrap();
f.assert_clean(&edited);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn applying_patch_reloads_clean_open_documents_before_returning() {
let f = Fixture::new().await;
std::fs::write(&f.path, OTHER).unwrap();
let patch = git(&f.project.root, &["diff"]);
git(&f.project.root, &["restore", "."]);
vcs::vcs_apply_patch(&f.host, "main", None, patch).await.unwrap();
f.assert_clean(OTHER);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn writing_patch_file_reloads_its_clean_open_document() {
let f = Fixture::new().await;
vcs::vcs_write_patch(&f.host, "main", f.path.clone(), OTHER.into()).await.unwrap();
f.assert_clean(OTHER);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn directory_compare_overwrite_reloads_clean_open_documents() {
let f = Fixture::new().await;
let base = f.project.root.parent().unwrap();
let source = base.join("source");
let destination = base.join("destination");
std::fs::create_dir_all(&source).unwrap();
std::fs::create_dir_all(&destination).unwrap();
std::fs::write(source.join("query.sql"), OTHER).unwrap();
let path = destination.join("query.sql");
std::fs::write(&path, ORIGINAL).unwrap();
let doc = f.project.documents.open(&path).unwrap();
ide_host::ops::fs::copy_merge(&f.host, "main", source, destination, "query.sql".into(), true).await.unwrap();
let snapshot = f.project.documents.snapshot(doc.id).unwrap();
assert_eq!(snapshot.text, OTHER);
assert!(!snapshot.dirty);
f.project.documents.save_all().unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), OTHER);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn failed_working_tree_operation_synchronizes_partial_disk_changes() {
let f = Fixture::new().await;
let result: ide_core::IdeResult<()> = f.project.with_repo_recorded(None, Some("Git merge"), |_| {
std::fs::write(&f.path, OTHER).unwrap();
Err(ide_core::IdeError::invalid("merge stopped with conflicts"))
});
assert!(result.is_err());
f.assert_clean(OTHER);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn working_tree_operation_reloads_same_size_same_timestamp_changes() {
let f = Fixture::new().await;
let modified = std::fs::metadata(&f.path).unwrap().modified().unwrap();
let replacement = ORIGINAL.replace("LIMIT 10", "LIMIT 20");
f.project
.with_repo_recorded(None, Some("Git checkout"), |_| {
std::fs::write(&f.path, &replacement).unwrap();
std::fs::File::options()
.write(true)
.open(&f.path)
.unwrap()
.set_times(std::fs::FileTimes::new().set_modified(modified))
.unwrap();
Ok(())
})
.unwrap();
f.assert_clean(&replacement);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn working_tree_operation_does_not_discard_unsaved_edits() {
let f = Fixture::new().await;
let version = f.project.documents.snapshot(f.doc).unwrap().version;
f.project
.documents
.edit(f.doc, version, ChangeSet { changes: vec![TextChange { from: 0, to: 0, insert: "-- local\n".into() }] })
.unwrap();
vcs::vcs_checkout(&f.host, "main", None, "other".into(), false, None).await.unwrap();
let snapshot = f.project.documents.snapshot(f.doc).unwrap();
assert_eq!(snapshot.text, format!("-- local\n{ORIGINAL}"));
assert!(snapshot.dirty);
assert_eq!(std::fs::read_to_string(&f.path).unwrap(), OTHER);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn adding_local_exclude_reloads_open_ignore_document() {
let f = Fixture::new().await;
let path = f.project.root.join(".git/info/exclude");
let doc = f.project.documents.open(&path).unwrap();
let ignored = f.project.root.join("generated.txt");
std::fs::write(&ignored, "generated\n").unwrap();
vcs::vcs_add_to_ignore(&f.host, "main", vec![ignored], ide_vcs::IgnoreDestination::LocalExclude).await.unwrap();
let disk = std::fs::read_to_string(&path).unwrap();
assert!(disk.contains("/generated.txt"));
assert_eq!(f.project.documents.snapshot(doc.id).unwrap().text, disk);
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn commit_hook_reloads_clean_open_documents() {
use std::os::unix::fs::PermissionsExt;
let f = Fixture::new().await;
f.saved_edit(&format!("{ORIGINAL}\n"));
git(&f.project.root, &["add", "-u"]);
let hook = f.project.root.join(".git/hooks/pre-commit");
std::fs::write(&hook, "#!/bin/sh\nprintf 'SELECT hook;' > node_modules/query.sql\n").unwrap();
std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).unwrap();
vcs::vcs_commit(
&f.host,
"main",
None,
"commit with hook".into(),
ide_vcs::CommitOptions { run_hooks: true, ..Default::default() },
)
.await
.unwrap();
f.assert_clean("SELECT hook;");
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn worktree_checkout_hook_reloads_clean_open_documents() {
use std::os::unix::fs::PermissionsExt;
let f = Fixture::new().await;
let hook = f.project.root.join(".git/hooks/post-checkout");
std::fs::write(&hook, "#!/bin/sh\nprintf 'SELECT hook;' > ../project/node_modules/query.sql\n").unwrap();
std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).unwrap();
let linked = f.project.root.parent().unwrap().join("linked");
vcs::vcs_add_worktree(&f.host, "main", None, linked, "other".into(), None).await.unwrap();
f.assert_clean("SELECT hook;");
}
+1
View File
@@ -28,6 +28,7 @@ async fn gbk_document_gutter_diff_stage_and_rollback_share_encoding() {
let host = Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
+1
View File
@@ -31,6 +31,7 @@ async fn patches_read_and_write_the_requested_repository() {
let host = Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
+123
View File
@@ -0,0 +1,123 @@
//! Shelves retain their repository even when the project root is above or below it.
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]) {
let output = Command::new("git").current_dir(root).args(args).output().unwrap();
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
}
async fn check_shelves(layout: &str) {
let temp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(temp.path()).unwrap();
let project_root = base.join("project");
let roots = match layout {
"same" => vec![project_root.clone()],
"below" => vec![base.clone()],
"two" => vec![project_root.join("项目A"), project_root.join("repoB")],
_ => vec![project_root.join("项目A")],
};
std::fs::create_dir_all(project_root.join("dirB")).unwrap();
for (i, root) in roots.iter().enumerate() {
std::fs::create_dir_all(root.join("src")).unwrap();
git(root, &["init", "-b", "main"]);
git(root, &["config", "user.name", "Test"]);
git(root, &["config", "user.email", "[email protected]"]);
std::fs::write(root.join("src/same.txt"), format!("base {i}\n")).unwrap();
git(root, &["add", "src"]);
git(root, &["commit", "-m", "initial"]);
std::fs::write(root.join("src/same.txt"), format!("shelved {i}\n")).unwrap();
}
let host = Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_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,
});
host.trust.decide(&project_root, true, false).unwrap();
let project = host.open_project("main", &project_root).unwrap();
for _ in 0..500 {
if project.repos().all().len() == roots.len() {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
assert_eq!(project.repos().all().len(), roots.len());
let primary = project.repos().primary().unwrap().root().to_path_buf();
if roots.len() == 2 {
assert!(
vcs::vcs_shelve(&host, "main", "mixed".into(), roots.iter().map(|r| r.join("src/same.txt")).collect())
.await
.is_err()
);
assert!(vcs::vcs_shelves(&host, "main").unwrap().is_empty());
}
for (i, root) in roots.iter().enumerate() {
let entry = vcs::vcs_shelve(&host, "main", "work".into(), vec![root.join("src/same.txt")]).await.unwrap();
assert_eq!(entry.repo.as_deref(), Some(root.as_path()));
assert_eq!(std::fs::read_to_string(root.join("src/same.txt")).unwrap(), format!("base {i}\n"));
let listed = vcs::vcs_shelves(&host, "main").unwrap();
assert_eq!(listed.iter().find(|s| s.id == entry.id).unwrap().repo, entry.repo);
let diffs = vcs::vcs_shelf_diff(&host, "main", entry.id.clone()).await.unwrap();
assert_eq!(diffs[0].before.as_deref(), Some(format!("base {i}\n").as_str()));
assert_eq!(diffs[0].after.as_deref(), Some(format!("shelved {i}\n").as_str()));
vcs::vcs_shelf_rename(&host, "main", entry.id.clone(), "renamed".into()).unwrap();
let patch = vcs::vcs_shelf_patch(&host, "main", entry.id.clone(), None).unwrap();
vcs::vcs_shelf_import(&host, "main", Some(root.clone()), "imported".into(), patch).await.unwrap();
let imported = vcs::vcs_shelves(&host, "main").unwrap().into_iter().find(|s| s.name == "imported").unwrap();
assert_eq!(imported.repo, entry.repo);
vcs::vcs_shelf_delete(&host, "main", imported.id, None).unwrap();
if root != &primary {
let info = ide_core::project_dir::path(&project_root, "shelf").join(&entry.id).join("shelf.json");
let original = std::fs::read(&info).unwrap();
let mut metadata: serde_json::Value = serde_json::from_slice(&original).unwrap();
metadata["repo"] = serde_json::json!(base.join("missing-repository"));
std::fs::write(&info, serde_json::to_vec(&metadata).unwrap()).unwrap();
assert!(vcs::vcs_shelf_diff(&host, "main", entry.id.clone()).await.is_err());
assert!(vcs::vcs_unshelve(&host, "main", entry.id.clone(), false, None).await.is_err());
assert_eq!(std::fs::read_to_string(root.join("src/same.txt")).unwrap(), format!("base {i}\n"));
std::fs::write(info, original).unwrap();
}
vcs::vcs_unshelve(&host, "main", entry.id.clone(), false, Some("src/same.txt".into())).await.unwrap();
assert_eq!(std::fs::read_to_string(root.join("src/same.txt")).unwrap(), format!("shelved {i}\n"));
// Old shelves were always made in the primary repository; missing ownership keeps that meaning.
if root == &primary {
let info = ide_core::project_dir::path(&project_root, "shelf").join(&entry.id).join("shelf.json");
let mut metadata: serde_json::Value = serde_json::from_slice(&std::fs::read(&info).unwrap()).unwrap();
metadata.as_object_mut().unwrap().remove("repo");
std::fs::write(info, serde_json::to_vec(&metadata).unwrap()).unwrap();
assert_eq!(
vcs::vcs_shelves(&host, "main").unwrap().into_iter().find(|s| s.id == entry.id).unwrap().repo,
entry.repo
);
assert_eq!(
vcs::vcs_shelf_diff(&host, "main", entry.id).await.unwrap()[0].after.as_deref(),
Some(format!("shelved {i}\n").as_str())
);
}
}
host.close_project("main");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn shelf_at_repo_root() {
check_shelves("same").await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn shelf_in_non_git_project() {
check_shelves("above").await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn shelves_in_two_repositories() {
check_shelves("two").await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn shelf_in_project_below_repository() {
check_shelves("below").await;
}
+93
View File
@@ -0,0 +1,93 @@
use ide_core::NullSink;
use ide_host::{Host, HostConfig, Project, ops::vcs};
use std::{path::PathBuf, process::Command, sync::Arc, time::Duration};
fn git(root: &std::path::Path, args: &[&str]) {
let out = Command::new("git").current_dir(root).args(args).output().unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
}
async fn wait_for_repos(project: &Arc<Project>, count: usize) {
for _ in 0..200 {
if project.repos().all().len() >= count {
return;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
panic!("expected {} repositories, found {}", count, project.repos().all().len());
}
struct Fixture {
host: Host,
project: Arc<Project>,
root: PathBuf,
_temp: tempfile::TempDir,
}
impl Fixture {
async fn new() -> Self {
let temp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(temp.path()).unwrap();
let root = base.join("project");
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]"]);
git(&root, &["config", "commit.gpgsign", "false"]);
git(&root, &["config", "core.autocrlf", "false"]);
std::fs::write(root.join("file.txt"), "original\n").unwrap();
git(&root, &["add", "."]);
git(&root, &["commit", "-m", "original"]);
let host = Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_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,
});
host.trust.decide(&root, true, false).unwrap();
let project = host.open_project("main", &root).unwrap();
wait_for_repos(&project, 1).await;
Self { host, project, root, _temp: temp }
}
}
impl Drop for Fixture {
fn drop(&mut self) {
self.host.close_project("main");
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn command_added_worktree_joins_the_repository_set() {
let f = Fixture::new().await;
let linked = f.root.join("linked");
vcs::vcs_add_worktree(&f.host, "main", None, linked.clone(), "main".into(), Some("topic".into())).await.unwrap();
let roots = f.project.repos().roots();
assert!(roots.contains(&linked), "worktree below the project did not join the repository set: {roots:?}");
assert_eq!(roots.len(), 2);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn removed_worktree_leaves_the_repository_set() {
let f = Fixture::new().await;
let linked = f.root.join("linked");
vcs::vcs_add_worktree(&f.host, "main", None, linked.clone(), "main".into(), Some("topic".into())).await.unwrap();
assert_eq!(f.project.repos().all().len(), 2);
vcs::vcs_remove_worktree(&f.host, "main", None, linked, false).await.unwrap();
assert_eq!(f.project.repos().all().len(), 1);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn watcher_picks_up_a_worktree_added_below_the_project() {
let f = Fixture::new().await;
let linked = f.root.join("watched");
git(&f.root, &["worktree", "add", "watched", "-b", "watched"]);
wait_for_repos(&f.project, 2).await;
let roots = f.project.repos().roots();
assert!(roots.contains(&linked), "watcher did not discover the worktree added below the project: {roots:?}");
}
+177
View File
@@ -0,0 +1,177 @@
use ide_core::{IdeEvent, SharedSink, WorkspaceEdit, error::ErrorKind};
use ide_host::{Host, HostConfig, ops};
use parking_lot::Mutex;
use serde_json::json;
use std::sync::Arc;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn preview_apply_and_grouped_undo_use_the_shared_host_commands() {
let tmp = tempfile::tempdir().unwrap();
let first = tmp.path().join("first.txt");
let second = tmp.path().join("second.txt");
for path in [&first, &second] {
std::fs::write(path, "old\n").unwrap();
}
let events: Arc<Mutex<Vec<IdeEvent>>> = Arc::default();
let sink: SharedSink = {
let events = events.clone();
Arc::new(move |event| events.lock().push(event))
};
let host = Host::new(HostConfig {
config_dir: tmp.path().join("config"),
data_dir: tmp.path().join("data"),
local_data_dir: tmp.path().join("local-data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(move |_| sink.clone()),
owner: |window| window,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
host.open_light("main", &first).unwrap();
let info = ops::doc::open(&host, "main", first.clone()).await.unwrap();
let edit: WorkspaceEdit = serde_json::from_value(json!({ "files": [
{ "kind": "edit", "path": first, "version": info.version, "edits": [{ "range": { "start": { "line": 0, "col": 0 }, "end": { "line": 0, "col": 3 } }, "new_text": "new" }] },
{ "kind": "edit", "path": second, "edits": [{ "range": { "start": { "line": 0, "col": 0 }, "end": { "line": 0, "col": 3 } }, "new_text": "new" }] }
] })).unwrap();
let preview = ops::lsp::prepare_workspace_edit(&host, "main", edit.clone()).await.unwrap();
assert_eq!(preview.preconditions.len(), 2);
std::fs::write(&second, "own\n").unwrap();
let error = ops::lsp::apply_workspace_edit(&host, "main", preview).await.unwrap_err();
assert_eq!(error.kind, ErrorKind::OutOfSync);
assert_eq!(std::fs::read_to_string(&first).unwrap(), "old\n");
let preview = ops::lsp::prepare_workspace_edit(&host, "main", edit).await.unwrap();
ops::lsp::apply_workspace_edit(&host, "main", preview).await.unwrap();
assert_eq!(std::fs::read_to_string(&first).unwrap(), "new\n");
assert_eq!(std::fs::read_to_string(&second).unwrap(), "new\n");
let id = events
.lock()
.iter()
.find_map(|event| match event {
IdeEvent::WorkspaceEditApplied { id, .. } => Some(*id),
_ => None,
})
.unwrap();
ops::lsp::undo_workspace_edit(&host, "main", id).await.unwrap();
assert_eq!(std::fs::read_to_string(&first).unwrap(), "old\n");
assert_eq!(std::fs::read_to_string(&second).unwrap(), "own\n");
assert_eq!(ops::doc::snapshot(&host, "main", info.id).await.unwrap().text, "old\n");
ops::lsp::redo_workspace_edit(&host, "main", id).await.unwrap();
assert_eq!(std::fs::read_to_string(&first).unwrap(), "new\n");
assert_eq!(std::fs::read_to_string(&second).unwrap(), "new\n");
host.clear_workspace_edit_history_on_exit();
assert_eq!(ops::lsp::undo_workspace_edit(&host, "main", id).await.unwrap_err().kind, ErrorKind::NotFound);
}
#[tokio::test(flavor = "current_thread")]
async fn contended_version_reads_do_not_block_the_async_worker() {
use ide_core::{LanguageId, Pos, Range, RequestId};
use ide_workspace::documents::DocumentObserver;
use std::sync::mpsc;
use std::time::Duration;
struct PausedWrite {
entered: Mutex<Option<tokio::sync::oneshot::Sender<()>>>,
release: Mutex<mpsc::Receiver<()>>,
}
impl DocumentObserver for PausedWrite {
fn changed(
&self,
_: ide_core::DocId,
_: &std::path::Path,
_: u32,
_: &[ide_text::AppliedChange],
_: &dyn Fn() -> String,
) {
if let Some(entered) = self.entered.lock().take() {
let _ = entered.send(());
self.release.lock().recv_timeout(Duration::from_secs(10)).unwrap();
}
}
}
for request_kind in 0..5 {
let tmp = tempfile::tempdir().unwrap();
let file = tmp.path().join("main.txt");
std::fs::write(&file, "old").unwrap();
let host = Arc::new(Host::new(HostConfig {
config_dir: tmp.path().join("config"),
data_dir: tmp.path().join("data"),
local_data_dir: tmp.path().join("local-data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(ide_core::NullSink)),
owner: |window| window,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
}));
let project = host.open_light("main", &file).unwrap();
let doc = project.documents.open(&file).unwrap().id;
assert_eq!(project.documents.snapshot(doc).unwrap().language, LanguageId::plain_text());
let (entered_tx, entered_rx) = tokio::sync::oneshot::channel();
let (release_tx, release_rx) = mpsc::channel();
project.documents.add_observer(Arc::new(PausedWrite {
entered: Mutex::new(Some(entered_tx)),
release: Mutex::new(release_rx),
}));
let documents = project.documents.clone();
let writer = std::thread::spawn(move || {
documents
.apply_workspace_edit(
&serde_json::from_value(json!({ "files": [{
"kind": "edit", "path": file, "edits": [{ "range": {
"start": { "line": 0, "col": 0 }, "end": { "line": 0, "col": 3 }
}, "new_text": "new" }]
}] }))
.unwrap(),
)
.unwrap();
});
entered_rx.await.unwrap();
let (heartbeat_tx, heartbeat_rx) = mpsc::channel();
// Always release the writer, even on the old blocking implementation.
let watchdog = std::thread::spawn(move || {
let responsive = heartbeat_rx.recv_timeout(Duration::from_secs(2)).is_ok();
release_tx.send(()).unwrap();
responsive
});
let (started_tx, started_rx) = tokio::sync::oneshot::channel();
let task = tokio::spawn(async move {
let _ = started_tx.send(());
let action =
|| serde_json::from_value(json!({ "title": "test", "isPreferred": false, "server": 1 })).unwrap();
match request_kind {
0 => {
let _ = ops::lsp::lsp_code_actions(&host, "main", doc, Range::point(Pos::new(0, 0)), vec![]).await;
}
1 => {
let _ = ops::lsp::lsp_code_action_query(
&host,
"main",
RequestId(1),
doc,
Range::point(Pos::new(0, 0)),
vec![],
)
.await;
}
2 => {
let _ = ops::lsp::lsp_resolve_code_action(&host, "main", doc, action()).await;
}
3 => {
let _ = ops::lsp::lsp_apply_code_action(&host, "main", doc, action()).await;
}
_ => {
let _ =
ops::lsp::lsp_rename(&host, "main", RequestId(1), doc, Pos::new(0, 0), "other".into()).await;
}
}
});
started_rx.await.unwrap();
let _ = heartbeat_tx.send(());
task.await.unwrap();
writer.join().unwrap();
assert!(watchdog.join().unwrap(), "request {request_kind} blocked the only async worker");
}
}
+1
View File
@@ -22,6 +22,7 @@ async fn linked_projects_share_navigation_but_keep_files_repositories_and_run_di
let host = Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
+11 -1
View File
@@ -57,6 +57,16 @@ pub fn shared() -> IdeResult<reqwest::Client> {
Ok(client)
}
/// `url`, with `http://` in front unless it starts with a scheme: a `://` later
/// on (`localhost:8080/login?next=http://…`) does not count.
pub(crate) fn with_scheme(url: &str) -> String {
let has_scheme = url.split_once("://").is_some_and(|(scheme, _)| {
scheme.starts_with(|c: char| c.is_ascii_alphabetic())
&& scheme.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
});
if has_scheme { url.to_owned() } else { format!("http://{url}") }
}
/// 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> {
@@ -64,7 +74,7 @@ pub async fn check_connection(proxy: &ProxySettings, url: &str, cancel: &Cancell
return Err(IdeError::invalid(problem));
}
let url = url.trim();
let url = if url.contains("://") { url.to_owned() } else { format!("http://{url}") };
let url = with_scheme(url);
let client = build(proxy)?;
let request = client.get(&url).timeout(Duration::from_secs(30));
let response = tokio::select! {
+16 -4
View File
@@ -1,6 +1,6 @@
//! Preparing (substitution, body includes) and executing requests.
use crate::client::describe;
use crate::client::{describe, with_scheme};
use crate::env::{Environments, Resolver};
use crate::parse::{BodyPart, RawRequest};
use ide_core::{CancellationToken, IdeError, IdeResult};
@@ -84,10 +84,8 @@ pub fn prepare(
Some((_, host)) => format!("http://{host}{url}"),
None => return Err(IdeError::invalid(format!("relative URL {url} needs a Host header"))),
}
} else if !url.contains("://") {
format!("http://{url}")
} else {
url
with_scheme(&url)
};
Ok(PreparedRequest {
name: raw.block.name.clone(),
@@ -214,6 +212,20 @@ mod tests {
assert_eq!(p.url, "http://localhost:9000/status");
}
#[test]
fn urls_without_a_scheme_default_to_http() {
let url = |text: &str| {
let f = parse(text);
prepare(&f.requests[0], &f.variables, &Environments::default(), None, None).unwrap().url
};
assert_eq!(url("GET localhost:8080/health\n"), "http://localhost:8080/health");
assert_eq!(
url("GET localhost:8080/login?next=http://localhost:3000/\n"),
"http://localhost:8080/login?next=http://localhost:3000/"
);
assert_eq!(url("GET https://api.test/cb?u=http://x\n"), "https://api.test/cb?u=http://x");
}
#[tokio::test]
async fn executes_against_a_local_server() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
+19 -4
View File
@@ -15,9 +15,9 @@ mod syntax;
pub use markers::marked_root;
pub use registry::{FileTypes, LanguageRegistry};
pub use syntax::{
CSS_CLASS, CSS_ID, CSS_VARIABLE, FileReference, Literal, LiteralKind, MAX_CACHED_TREES, MAX_PARSE_BYTES,
ParsedTree, SpellingKind, SpellingRegion, Syntax, SyntaxCache, SyntaxCacheStats, SyntaxHighlightKind,
SyntaxHighlights, TreeError, WebReference, WebReferenceKind, merge_visibility,
CSS_CLASS, CSS_ID, CSS_VARIABLE, ConfigurationKey, FileReference, Literal, LiteralKind, MAX_CACHED_TREES,
MAX_PARSE_BYTES, ParsedTree, SpellingKind, SpellingRegion, Syntax, SyntaxCache, SyntaxCacheStats,
SyntaxHighlightKind, SyntaxHighlights, TreeError, WebReference, WebReferenceKind, merge_visibility,
};
/// Parsed form of `languages/<id>.toml`. Backend-only; the UI gets [`LanguageInfo`].
@@ -53,6 +53,19 @@ pub struct LanguageConfig {
pub language_servers: Vec<LanguageServerConfig>,
#[serde(default)]
pub debugger: Option<DebuggerConfig>,
/// External formatter used when no language server can format, or selected in settings.
#[serde(default)]
pub formatter: Option<FormatterConfig>,
}
/// An external formatting tool and its language-specific parser.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct FormatterConfig {
pub tool: String,
pub parser: String,
#[serde(default)]
pub range: bool,
}
/// A CodeMirror mode for the files of a framework's projects.
@@ -83,7 +96,9 @@ pub struct LanguageServerConfig {
pub args: Vec<String>,
#[serde(default)]
pub env: BTreeMap<String, String>,
/// Files whose presence marks a workspace root, e.g. `Cargo.toml`.
/// Files whose presence marks a workspace root, e.g. `Cargo.toml`;
/// `file#table` requires the TOML file to contain that dotted table, e.g.
/// `pyproject.toml#tool.ruff`.
#[serde(default)]
pub root_markers: Vec<String>,
/// Serve only files below a root marker, not the whole project: a framework's
+43 -2
View File
@@ -19,9 +19,13 @@ pub fn marked_root(project_root: Option<&Path>, file: &Path, markers: &[String])
best
}
/// `dir` contains `marker`: a relative path, or a file name pattern with one
/// `*` (`*.csproj`).
/// `dir` contains `marker`: a relative path, a file name pattern with one
/// `*` (`*.csproj`), or a TOML file name (no pattern) and a dotted table it
/// must contain (`pyproject.toml#tool.ruff`).
fn has_marker(dir: &Path, marker: &str) -> bool {
if let Some((file, table)) = marker.split_once('#') {
return has_toml_table(&dir.join(file), table);
}
let Some((prefix, suffix)) = marker.split_once('*') else {
return dir.join(marker).exists();
};
@@ -30,9 +34,23 @@ fn has_marker(dir: &Path, marker: &str) -> bool {
})
}
/// `file` parses as TOML and has the dotted `table` (`tool.ruff`).
fn has_toml_table(file: &Path, table: &str) -> bool {
let Ok(text) = std::fs::read_to_string(file) else { return false };
let value = match toml::from_str::<toml::Value>(&text) {
Ok(value) => value,
Err(e) => {
tracing::debug!("root marker {}: {e}", file.display());
return false;
}
};
table.split('.').try_fold(&value, |v, key| v.get(key)).is_some_and(toml::Value::is_table)
}
/// `path` is (or, after its creation or removal, was) a root marker of the
/// directory above it.
pub(crate) fn is_marker_path(path: &Path, marker: &str) -> bool {
let marker = marker.split_once('#').map_or(marker, |(file, _)| file);
match marker.split_once('*') {
None => path.ends_with(marker),
Some((prefix, suffix)) => {
@@ -74,6 +92,29 @@ mod tests {
assert_eq!(marked_root(None, &file, &markers), Some(home.to_path_buf()));
}
#[test]
fn toml_markers_need_their_table() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let file = root.join("svc/app/main.py");
std::fs::create_dir_all(file.parent().unwrap()).unwrap();
let markers = vec!["ruff.toml".to_string(), "pyproject.toml#tool.ruff".to_string()];
std::fs::write(root.join("pyproject.toml"), "[project]\nname = \"x\"\n[tool.black]\n").unwrap();
assert_eq!(marked_root(Some(root), &file, &markers), None);
std::fs::write(root.join("svc/pyproject.toml"), "[tool.ruff.lint]\nselect = [\"E\"]\n").unwrap();
assert_eq!(marked_root(Some(root), &file, &markers), Some(root.join("svc")));
// Outermost wins: an inline table counts; a value that is no table, or a
// file that does not parse, does not.
std::fs::write(root.join("pyproject.toml"), "tool = { ruff = { line-length = 100 } }\n").unwrap();
assert_eq!(marked_root(Some(root), &file, &markers), Some(root.to_path_buf()));
std::fs::write(root.join("pyproject.toml"), "tool = { ruff = 1 }\n").unwrap();
assert_eq!(marked_root(Some(root), &file, &markers), Some(root.join("svc")));
std::fs::write(root.join("pyproject.toml"), "[tool.ruff]\n[broken").unwrap();
assert_eq!(marked_root(Some(root), &file, &markers), Some(root.join("svc")));
assert!(is_marker_path(&root.join("svc/pyproject.toml"), "pyproject.toml#tool.ruff"));
assert!(!is_marker_path(&root.join("svc/setup.cfg"), "pyproject.toml#tool.ruff"));
}
#[test]
fn patterns_match_file_names() {
let tmp = tempfile::tempdir().unwrap();
Loaded 100 of 719 files, more files were not shown because too many files have changed in this diff. Show more