Compare commits

...
Author SHA1 Message Date
l0ng-ai 434aacc64a chore(plugins): regenerate bindings on top of main 2026-11-04 02:41:46 +08:00
l0ng-ai 58f8929509 test(plugins): the MyBatis test asks again while the first call builds the index 2026-11-04 02:41:46 +08:00
l0ng-ai 14ddd948f8 test(plugins): capability paths are the platform's in the parse test 2026-11-04 02:41:46 +08:00
l0ng-ai 8df7731fa6 chore(plugins): ide-extension at the workspace version in Cargo.lock 2026-11-04 02:41:46 +08:00
l0ng-ai 20281e9b66 fix(plugins): Windows: local marketplace paths, localhost connections, tests
- A marketplace index given as a local Windows path (`C:\market\index.json`)
  resolves its packages next to it; only URLs are joined with `/`.
- HTTP requests to `localhost` try IPv4 loopback first, then IPv6: local
  servers such as Ollama usually listen on IPv4, and on Windows a refused
  `::1` cost about two seconds per request.
- Tests that assumed Unix: an absolute program path is the platform's, a
  manifest rewritten by a test may have CRLF line ends, the precompiled-cache
  test replaces the cache file only after the host that maps it is gone, and
  the MyBatis test lets its first call build the index before asserting.
- The icon theme test's time limits guard against work growing faster than
  the icon count rather than measure the runner.
2026-11-04 02:41:46 +08:00
l0ng-ai 20960c1d96 fix(plugins): soft deadlines hold under load, unique cache writes, CI on every platform
- A provider result produced after its soft deadline is dropped even when it
  is already waiting (the caller was scheduled late or waited on another
  plugin first), so the deadline holds on a busy machine. The result is
  timestamped when the plugin's thread produces it, so a result that came in
  time is still used when it is read late.
- Each precompiled-component write uses its own temporary file: two threads
  storing the same component could rename a half-written file into place.
- Tests: the cache-corruption case replaces the file instead of overwriting
  the one a running component maps; the yield test no longer counts on a
  tick rate; a test that runs a shell script is Unix-only like its
  neighbours.
- Plugin web pages declare `@types/node` for their Vite configs instead of
  finding the repository's.
2026-11-04 02:41:46 +08:00
l0ng-ai 9cc83c77b8 feat(plugins): plugin contributions in the IDE's own settings and menus; official plugins published with releases
- Themes: a plugin theme is an `appearance.theme` value
  (`plugin:<id>/<theme>`), offered by Settings › Appearance (and the
  preferred themes of Sync with OS), Quick Switch, the toolbar gear menu and
  Welcome › Customize. The project window selects it in the plugin host;
  picking a built-in theme clears it. While its plugin is off, the built-in
  theme of its kind shows and the choice is kept.
- File icon themes: chosen in Settings › Appearance › File icon theme and
  kept in `appearance.fileIconTheme`, so every window follows.
- Keymap: plugins' default shortcuts show in Settings › Keymap as the
  defaults of their actions: removable, changeable, resettable and part of
  conflict checks.
- Problems: a plugin's problems are withdrawn when it stops, fails, is
  disabled or uninstalled. Settings › Inspections lists them under Plugins by
  plugin name; stored overrides of `plugin:` producers parse correctly.
- Status bar items of plugins are named in the status bar menu and can be
  hidden.
- Languages added or removed by plugins refresh Override File Type, the
  Code Style pages and Inspections without a restart.
- Uninstalling a plugin deletes the secrets it declared.
- The Settings window follows plugin changes made elsewhere.
- The plugin system is marked Preview (Settings › Plugins, README); Harbor
  Night is versioned 0.1.0 like the other official plugins.
- Release: the official plugins are packed, signed (secret
  PLUGIN_SIGNING_KEY) and indexed into the release as
  `plugins-index.json`; released builds use it as the default marketplace.
  Without the key the step is skipped.
2026-11-04 02:41:46 +08:00
l0ng-ai ac71b7008e feat(plugins): Graphviz, Regex Tester, License Header and Harbor Night official plugins
- Graphviz: `.dot` / `.gv` files open beside a live preview (a web view
  rendering with Graphviz compiled to WebAssembly) in any layout engine,
  with zoom, pan, Fit and SVG / PNG export through the save dialog. What
  Graphviz reports goes from the page to the plugin's code, which publishes
  it as problems on the named line. A DOT language, file icons and a
  New Graphviz Graph template.
- Regex Tester: a tool window whose html view sends pattern, flags, text and
  replacement as you type; the plugin matches with Rust's regex (linear time)
  and answers with matches, groups and the replacement in UTF-16 offsets.
  Use Selection and Test Selection take the editor's selection.
- License Header: files without a copyright / SPDX header are reported when
  they open and on save; a quick fix and a command add one in the file's
  comment syntax (after a shebang or encoding line); a project scan in a
  background task reports the rest. Holder and license are settings.
- Harbor Night becomes an official plugin and the one source of the theme
  files: `openidea-plugin new --template theme`, the theme docs and tests
  start from it.
- Tool windows' renders and events carry the window's active editor, as
  commands do, so a view's buttons can act on what the user is editing.
- knip leaves plugins' web pages (projects of their own) alone.
2026-11-04 02:41:46 +08:00
l0ng-ai 3b78d15d6d refactor(plugins): drop the Kubernetes plugin, samples become test fixtures
- Remove the Kubernetes official plugin.
- `hello` and `language-toy` move from `plugins/` to
  `crates/ide-plugin/tests/fixtures/`: they stay what the host's tests run
  against, but are no longer listed as plugins of the repository.
- A `jobs` fixture takes over what only Kubernetes exercised: a tree view
  filled by a pinned program, a terminal for the activated entry in the
  invoking window, string and number settings, locally and in remote
  projects.
- `plugins/` now holds only the official plugins; `build.sh`, CI and the
  debug plugin catalog follow.
2026-11-04 02:41:46 +08:00
l0ng-ai 3f52439703 chore(plugins): regenerate bindings and reconcile with main
- Regenerated bindings for the merged settings, edits and plugin commands.
- Mouse shortcuts run their action through `executeAction` again.
- Dropped Chinese entries whose templates main already translates.
2026-11-04 02:41:46 +08:00
l0ng-aiandflorentvdry a7b7616da9 feat(plugins): install packages from a .zip or a folder's plugin.toml
Install Plugin from Package File now takes an `.oiplugin`, a `.zip` or a
package folder's `plugin.toml`, and goes through the same install page
(capabilities, signature, consent) as every other install.

- A zip may hold the package at its root or in its only folder, as zipping
  the folder makes; archiver metadata (`__MACOSX`, `.DS_Store`, `._*`) is
  left out. A zip without a `plugin.toml` there, such as an IntelliJ plugin,
  is refused with a message saying so. Unsafe paths, links and size limits
  are checked as for `.oiplugin` files.
- Picking a `plugin.toml` installs its folder.

Co-authored-by: florentvdry <[email protected]>
2026-11-04 02:41:46 +08:00
l0ng-aiandDeng Liu 900f2e6819 feat(plugins): web views, document bridge and the Excalidraw official plugin
Plugins can now ship a web page as the UI of a view or custom editor
(`web = "web/index.html"`), so editors built on web libraries are plugins
rather than part of the core.

- Web views: every file under the page's directory is part of the package
  (checked, copied on install, covered by the digest and signature) and
  served through the `openidea-plugin` URL scheme, under a per-revision
  path with a CSP of its own, into an `<iframe sandbox="allow-scripts">`.
  An update or reinstall reloads open pages.
- Frame bridge (`window.openidea`), shared by html bodies and web pages:
  theme, messages to the plugin's code, `document.get/edit/replace/onChange`
  (versioned edits that join the text editor's undo history, Save and dirty
  state), `saveFile`/`openFile` through the IDE's dialogs, and IDE shortcuts
  the page leaves alone.
- Custom editor layouts: tab, split (Editor and X) or preview over the text
  editor, switchable from the tab's actions.
- New points `fileIcons` and `fileTemplates`; new optional capability
  `clipboard` for web views.
- Excalidraw official plugin: `.excalidraw` / `.excalidraw.json` open as an
  interactive canvas kept in step with the file's text, with self-hosted
  fonts, PNG/SVG export, images, file icons, a New Drawing template and
  JSON highlighting. Closes #732.
- Fixes: a lock-order deadlock between listing plugins and refreshing
  project pins; the dev server ignored edits under `src/features/plugins`.
- `plugins/build.sh` builds and checks web pages; CI typechecks and tests
  them.

Co-authored-by: Deng Liu <[email protected]>
2026-11-04 02:41:46 +08:00
l0ng-ai 78e0c7a23e feat(plugins): finalize plugin API before release, concurrent providers
Plugin API (breaking; the plugin system is unreleased):
- Remove the runtime `contributions` interface (register-command,
  register-language, register-theme). Commands, languages and themes are
  declared in plugin.toml only, so everything a plugin contributes goes
  through the install preview and consent. The registry drops runtime
  scope, `contribute` and `dispose_runtime`; inline `definition` in
  language and theme declarations is gone.
- `completion-item.kind` is a string (the 25 LSP kinds; unknown kinds show
  no icon), like the other open-ended kinds.
- `lifecycle.activate` and `commands.run` return `types.error` instead of a
  string. The host maps it to the matching error kind, and only `failed`
  counts against the plugin: cancelling, a missing grant or an unknown
  command does not. The SDK's `Plugin` returns `Result<_, Error>`; plain
  messages convert to `Error::Failed`.

Runtime:
- Editor providers of several plugins are called at the same time
  (`Actor::submit` + `Pending::wait`): the wait is the slowest plugin's,
  not the sum, so each gets the full edit-path budget.

Versioning and tooling:
- Components are checked at install and by `openidea-plugin check` for the
  plugin API they were built for; another major or a newer minor is
  refused with what to do.
- Tests: the SDK's WIT matches the host's, a simulated next-minor host
  links every shipped component, every host function has an SDK wrapper,
  released APIs only grow (no snapshot until the first release).
- plugins/build.sh builds every checked-in component, records a source
  fingerprint next to it, and `--check` fails on stale components; CI runs
  it plus fmt, clippy (host and wasm) and tests for the SDK, the official
  plugins and the fixtures.
- The API evolution rules and the checklist for a new host function are in
  plugins/README.md; the design notes under docs/ are removed.
2026-11-04 02:41:46 +08:00
l0ng-ai fa003016ef feat(plugins): theme variables for html view frames, kept current without reload
- Inject `data-theme` and stable `--openidea-*` colour and font variables
  (concrete values; the frame has no IDE stylesheet) into html view bodies.
- Post theme changes to the loaded frame instead of rebuilding srcdoc, so a
  theme switch keeps the frame's state; re-send on load to close the race.
- Expose `openidea.theme` / `openidea.onThemeChange` to frame scripts.
- Move `subscribeTheme` / `themeKey` from the Markdown mermaid module to
  `src/platform/themeWatch.ts`, shared by both.
- Document the variables in plugins/README.md.
2026-11-04 02:41:46 +08:00
l0ng-ai fd1f465aeb feat(plugins): extension points, capability consent, async runtime and plugin API 1.0
Rework the plugin system per docs/plugin_plan.md (P0–P8):

- Extension-point registry (ide-extension) so new contribution types register
  without touching the kernel; languages, run detectors, providers, views and
  UI contributions go through it.
- Per-plugin capability consent with a risk-graded ledger; process
  capabilities are pinned to resolved binary paths and hashes.
- Async, actor-per-plugin Wasm runtime with lazy activation, cancellation,
  events, long-running tasks and idle reclamation.
- Plugin host moved into ide-host, shared by local and remote projects.
- Distribution: install/package tooling (openidea-plugin bin) and a static
  market index.
- WIT frozen as openidea:[email protected]; Rust SDK with mock host.
- Ten official validation plugins under plugins/official.
2026-11-04 02:41:46 +08:00
l0ng-ai 63801ba249 fix(github): find pull requests in every git repository of the project (#1084)
A project folder that holds several git repositories without being one itself showed "No GitHub repository" in Pull Requests: every gh command ran in the project folder.

The GitHub remotes now come from each of the project's git repositories, and each remote remembers its repository. gh commands take the repository to run in (the primary one when none is given), and checkout, diff, worktree, merge, push, suggestions and new pull requests use the connected repository.

Fixes #812
2026-11-04 02:36:56 +08:00
Florent Vandflorentvdry 5e7339e4c2 feat(vcs): group commit changes by staging state (#1080)
Commit view › Group By gains "Staging State". When it is on, Changes splits into Staged Changes and Unstaged Changes; a partially staged file appears in both, and its diff compares HEAD with the Index under Staged and the Index with the working copy under Unstaged. The choice is remembered across sessions.

Closes #1067

Co-authored-by: florentvdry <[email protected]>
2026-11-04 02:22:56 +08:00
l0ng-ai 2f75628c87 fix(tasks): show a progress title's executable path as the program name (#1061)
rust-analyzer titles its check with the full command line, starting with the absolute path of cargo, so the status bar and Background Tasks popup showed /Users/…/Application Support/…/bin/cargo check … truncated before the useful part. LSP $/progress and DAP progressStart titles now go through ide_core::task::display_title, which turns a leading absolute executable path (with spaces, Windows drive/UNC, .exe/.cmd/.bat) into the program name.

Fixes #1060
2026-11-04 02:09:13 +08:00
l0ng-ai 8efa7f8ece feat(vcs): Manage Remotes from the Remote node of the Git Log branches pane (#1081)
Manage Remotes… was only in the Git menu. The Remote node of the Git Log branches pane and each remote under it now offer it in their context menu.

Refs #800
2026-11-04 02:07:42 +08:00
l0ng-ai 8bb6dfddc2 fix(maven): unlink modules of a reactor so jdtls stops importing them (#1059)
Only root Maven projects could be unlinked, and jdtls imports an aggregator with all of its modules whatever java.import.exclusions say. A module of a reactor can now be unlinked from the Maven tool window; when one is, jdtls gets initializationOptions.projectConfigurations listing the linked POMs except the aggregators above the unlinked module (plus the Gradle build file if there is one), and a Maven sync only names the POMs jdtls imported. Unlinking whole roots keeps using exclusions.

Fixes #767
2026-11-04 01:53:45 +08:00
l0ng-ai 6eb5584beb feat(deployment): browse for the folders of a mapping (#1078)
Mapping paths had to be typed. The local path and deployment path cells now have a folder button: the local one picks a folder inside the project, the deployment one opens the server's folder chooser at its root path, and both fill in the path relative to their root (the deployment button needs an absolute root path).

Fixes #799
2026-11-04 01:39:00 +08:00
ntbang0901 555226a9bf fix(ui): start pointer drags only while the primary button is held (#1071)
On macOS, with an editable element focused and an input method active, WebKit can deliver a tap as pointerup then pointerdown, so drag trackers saw every later pointermove as a held button: one tap plus a pointer move started a phantom drag (file drag label, tab detach, tool window move) and the next tap dropped it. A drag now starts only from a move with the primary button down, a move without it cancels the press, and late button-less moves after a drag started are ignored so the drop is kept. Resizers, dialog drag/resize, pan and the review gutter end instead; the Project view also listens for pointercancel.

Refs #760

Co-authored-by: ntbang0901 <[email protected]>
2026-11-04 01:31:10 +08:00
l0ng-ai a35e434376 fix(project-view): classify large Java and Kotlin files cut off in their body (#1075)
File icons read the first 64 KiB of a JVM file; a larger file cut off inside its body no longer parsed as a declaration and showed the plain Java file icon. When that happens, the declaration header (up to the first type's opening brace, skipping comments, literals and parenthesised arguments) is classified instead.

Fixes #754
2026-11-04 01:26:44 +08:00
ntbang0901 bd424a3805 feat(gradle): import modules from Gradle itself (includeFlat, included builds, computed includes) (#715)
The Gradle model came from a static parse of settings.gradle, which cannot follow includeFlat, projectDir assignments, computed include lists or included builds, so those modules were missing from the Gradle tool window, the Project view, indexing and run. Sync now runs an init script that prints every evaluated project (identity path, directory, build directory, source sets with test classification, Java version) without resolving dependencies; the static parser stays as the fallback before the first sync. Modules outside the root become content roots, the Project view lists the build's module group above the project folder, an unsynced Gradle build is imported once when opened, and staleness ignores comments and whitespace in build scripts.

Application / Spring Boot configurations of a Gradle module run through a JavaExec task on the source set's runtime classpath by default (Settings › Build Tools › Gradle › Build and run using), with the debugger attached to the forked JVM. The build model reaches run and the Project view through a platform store rather than a cross-feature import.

Fixes #737
Fixes #861

Co-authored-by: ntbang0901 <[email protected]>
2026-11-04 01:09:07 +08:00
xtr-hubandxtr-hub 0c1a64bc2c fix(vim): keep IME compositions out of the document in Normal mode (#1055)
codemirror-vim only discards single-character IME commits in Normal mode, so multi-character commits (Chinese pinyin, Japanese conversions) were written into the document and could replace the selection. In Normal/Visual mode the composition now renders as usual and its whole span is restored when it ends; an ASCII single-character commit (dead keys) is replayed as a Vim key. Composition changes are marked transientChange: they stay out of undo history, the document model, the backend and other split panes. Insert/Replace mode is unchanged.

Closes #1056

Co-authored-by: xtr-hub <[email protected]>
2026-11-04 00:49:09 +08:00
fuqiuluoandfuqiuluo 2b20c0f38e fix(ui): reorder dragged tabs live with a placeholder; drop terminal tabs on a group to join or split (#1054)
Dragging a tab showed no floating copy: the ghost cloned the tab's classes, and .tab { position: relative } loaded after .ghost { position: fixed }. The ghost is now positioned inline, and a tab dragged within its strip stays as an empty slot the other tabs move around; the insertion line for tabs from another strip is drawn inside the edge tabs so the scroller doesn't clip it. A terminal group is now a drop target like an editor pane: the middle joins the group, an edge splits off a new group (dropZoneAt moves to ui and is shared).

Closes #1052

Co-authored-by: fuqiuluo <[email protected]>
2026-11-04 00:41:57 +08:00
l0ng-ai e20eb1acf8 fix(agents): stop idle agents, run tasks locally by default, keep worktrees next to the project (#1050)
A task's agent process lived until the task was archived or the project closed, and stopping it killed only the direct child, so wrappers (npx, codex.cmd, claude shims) left the real agent running. Agents that support session/load are now stopped after 5 minutes idle and restarted with the session reloaded on the next message; agent connections, language servers and the native Claude CLI start in their own process group and are stopped with their whole tree.

New tasks run in the local checkout unless the user picks a worktree, and worktrees go next to the project (<parent>/<project>.worktrees/<task>), on the project's drive and outside its tree.

Fixes #1011
Fixes #1003
2026-11-04 00:38:33 +08:00
matthiasdemauroyandmatthiasdemauroy 39fe96bd01 feat(agents): show Claude usage limits and session cost in the agent task header (#1041)
Claude Code reports the account's 5-hour and weekly usage limits (rate_limit_event) and the session cost (result.total_cost_usd); only the limit status was used. The limits now show as meters next to the context meter in the task header, with the reset time in the tooltip, and Settings › Tools › Agents › Chat turns each meter (context, work time, limits, cost) on or off. Limits are per provider and kept in memory; cost is off by default.

Closes #1057

Co-authored-by: matthiasdemauroy <[email protected]>
2026-11-04 00:28:23 +08:00
l0ng-ai 4eda613c59 fix(maven): inherit the compiler level from parent POMs and pluginManagement (#1064)
A module whose maven-compiler-plugin release/source is configured in a parent POM or in <pluginManagement> had no Java level in the build model, so its JDK fell back to the newest installed one. The level is now read from build/plugins and build/pluginManagement/plugins and inherited down the parent chain. Launching a Java application without a project JDK also takes the module level from the build model.

Refs #753
2026-11-04 00:18:45 +08:00
Andrea Basileandandreabasile 5c642819e2 feat(run): import run configurations from .vscode/launch.json (#1030)
Run/Debug Configurations offers Import from .vscode/launch.json when the project has one (also a Find Action command). debugpy/python launches become Python, Django test, module or pytest configurations with their interpreter; debugpy attach becomes Python Remote Debug; node/pwa-node launches become node or npm configurations, attaches become Attach to Node.js, and node-terminal commands map to npm, a program or a shell line. cwd, env, envFile and the per-OS blocks are applied. Imported configurations join the dialog like new ones and are saved on OK/Apply; names already present are skipped, and a notification lists skipped entries with the reason and keys with no equivalent.

Refs #991

Co-authored-by: andreabasile <[email protected]>
2026-11-04 00:09:38 +08:00
arookieofcanda-rookie-of-C-language f00ce451ac fix(remote): serve the database commands from the remote server (#1029)
A remote window sends a command to openidea-server only when the server registers it; none of the db_* commands were registered, so they ran locally, where a remote window has no project ("no project is open in this window"). The command bodies move from src-tauri into ide_host::ops::db, the Tauri commands become thin wrappers, and the remote dispatcher registers the same ops, with cancellation for execute / table page / explain. db_export_file stays local because exports are written on the user's machine.

Fixes #1019

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-04 00:03:38 +08:00
Bl0ckandl0ng-ai 8e4c9f6f61 fix(titlebar): prevent expanded main menu from covering toolbar widgets (#725) (#729)
* fix(menu): keep dropdowns clear of the status bar

* fix(titlebar): hide right toolbar while hamburger menu is expanded

* test(titlebar): close expanded menu through the focused element

* style(titlebar): follow existing formatter layout for mocked menu

* test(titlebar): expect expanded menu titles instead of hamburger button

---------

Co-authored-by: l0ng-ai <[email protected]>
2026-11-03 23:56:06 +08:00
kevinhandl0ng-ai 6a3a712488 fix(tools): skip an unreachable revocation check when curl downloads on Windows (#833)
Schannel curl fails every download with CRYPT_E_REVOCATION_OFFLINE when the CRL/OCSP servers cannot be reached (corporate proxies, firewalls, some regions), although the certificate is valid, so jdtls and other tools could not be installed. On Windows, pass --ssl-revoke-best-effort when `curl --version` reports 7.70+ with Schannel as the active TLS backend: the check is skipped only when the revocation server is offline, and actually revoked certificates are still rejected. Older curl, which rejects the option, is left as is. Fixes #801.

Co-authored-by: l0ng-ai <[email protected]>
2026-11-03 23:55:58 +08:00
l0ng-ai deab41cfc1 fix(run): find gradlew.bat / mvnw.cmd in the run directory on Windows (#777)
* fix(run): resolve Windows wrapper commands in the run directory

* fix(run): take extensionless names from the run directory only through PATHEXT
2026-11-03 23:55:53 +08:00
d0whc3r c5727141a0 feat(format): make oxfmt a first-class formatter next to Prettier (#1010)
oxfmt ran only below an oxfmt configuration and reused the Prettier settings. It now has its own settings group and page (path, enabled languages, formatting provider, run on save) next to Prettier. For a language with an external formatter, oxfmt formats it when enabled for that language and either an oxfmt configuration is found above the file or oxfmt is the selected provider; otherwise Prettier, when enabled. Turning Prettier off for a language still leaves it to the language server. New Reformat with Prettier / Reformat with oxfmt commands run that tool regardless of the settings and report languages it cannot format.

Co-authored-by: d0whc3r <[email protected]>
2026-11-03 23:54:55 +08:00
l0ng-aiandDPhngNam e8d1c45970 fix(run): UTF-8 default charset for application JVMs forked by Maven and Gradle on Windows (#751)
* fix(run): give the spring-boot:run fork a UTF-8 default charset on Windows

On Windows, mvn spring-boot:run passes the stdout/stderr encoding properties to the
forked application JVM through JAVA_TOOL_OPTIONS, but not file.encoding (it would
change how Maven reads sources). On JDK 8-17, logback / log4j2 console appenders
encode with the default charset (GBK on Chinese Windows) and ignore stdout.encoding,
so Chinese log lines showed as U+FFFD in the UTF-8 Run console.

For a Maven launch with a spring-boot:run goal and a UTF-8 console, add
-Dfile.encoding=UTF-8 to -Dspring-boot.run.jvmArguments, merged into the user's
VM options. It reaches only the forked JVM; Maven's own JVM is unchanged. A
file.encoding the user set (in jvmArguments or JAVA_TOOL_OPTIONS) wins.

Fixes #697

* fix(run): give Gradle JavaExec forks a UTF-8 default charset on Windows

Applications started by Gradle (bootRun, run, custom JavaExec tasks) kept the
ANSI default charset (GBK on Chinese Windows, JDK 8-17) while the Run console
decodes UTF-8. Code that converts text with the default charset then breaks:
Nacos config with Chinese text fails to parse (MalformedInputException) and
logback/log4j2 console output is garbled.

For a UTF-8 console on Windows, a Gradle launch now gets an init script that
adds -Dfile.encoding=UTF-8 to each JavaExec task when it runs, unless the task
or JAVA_TOOL_OPTIONS already sets file.encoding. Gradle's own JVM and compiler
source encoding are unchanged. Maven and Gradle are told apart so the
spring-boot:run rewrite only applies to Maven.

* fix(run): set Gradle charset before build task configuration

---------

Co-authored-by: DPhngNam <[email protected]>
2026-11-03 23:54:01 +08:00
arookieofcandl0ng-ai 8d25b1bcd0 fix(sdk): follow scoop shims to the jdtls release script (#804)
* fix(sdk): follow scoop shims to the jdtls release script

scoop puts a jdtls.exe shim (driven by a jdtls.shim descriptor) on PATH.
launch_directly only recognized the release's own script names, so a scoop
install kept the shim's command line and the configured JDK never applied.
On Windows, follow the shim's path entry to the release script; a shim
without a descriptor is still treated as someone's own wrapper.

Fixes #794

* test(sdk): make the scoop-shim fixture a full JDK

#815 made launch_directly require javac and the jdk.compiler/jdk.jdi/
jdk.unsupported modules, so the shim fixture's bare release file stopped
the launch before the shim path was exercised.

* fix(sdk): follow real Scoop Python shims for jdtls

---------

Co-authored-by: l0ng-ai <[email protected]>
2026-11-03 23:53:56 +08:00
l0ng-ai 0b68cb3e76 fix(windows): enlarge taskbar icon artwork and lettering (#703) 2026-11-03 23:50:53 +08:00
arookieofc 6f7216fb7f fix(terminal): git bash 补全——Windows 上 POSIX shell 也以登录 shell 启动 (#1036)
* fix(terminal): start POSIX shells as login shells on Windows too

Git Bash started argument-less on Windows (resolve_shell only passed
-l on Unix), so it ran as a non-login shell: /etc/profile never ran,
git-completion.bash was not loaded, and git <Tab> had no subcommand
suggestions. Start POSIX shells with -l everywhere, like the Git Bash
shortcut, Windows Terminal and IDEA do.

A bare login bash also runs with Git for Windows' MSYS default profile,
whose PATH leaves git and the mingw64 tools out unless <Git>\cmd is
inherited; default MSYSTEM to MINGW64 (the shortcut's value) unless the
user or the launching process already set it.

* fix(terminal): keep MSYS2 login shells in the working directory

MSYS2's /etc/profile runs cd "$HOME" on a login start unless
CHERE_INVOKING is set, so with the new login args an MSYS2 bash/sh
project terminal would open in the home directory instead of the
project. Default CHERE_INVOKING=1 for Windows bash/sh next to the
MSYSTEM default, still deferring to a value the user or the launching
process set. Git for Windows' /etc/profile has no such cd (verified
locally: a login bash stays in the project directory either way), so
Git Bash users see no change.
2026-11-03 23:50:49 +08:00
kkmihaiandkkMihai a3576fca90 fix(vcs): maximize diffs and keep file navigation (#907)
Diff tabs, including the preview tab, get Maximize Editor / Restore Size in their toolbar (reusing Hide All Tool Windows). Show Diff from the Commit view or the Git log now carries the whole change list: F7 / Shift+F7 at the last or first difference shows a hint, and pressing again moves to the first or last difference of the neighbouring file, skipping binary, oversized and unreadable files. The layout is restored after a preview is replaced and keyboard focus stays in the diff across files. Compare branches stays single-file.

Fixes #904

Co-authored-by: kkMihai <[email protected]>
2026-11-03 23:41:16 +08:00
ntbang0901 c2fa6f37ab feat(vcs): make unresolved merge conflicts visible wherever they come from (#905)
Conflicts left by stash pop or an automatic stash now get the same editor banner (File has unresolved merge conflicts, Resolve conflicts…) as merge conflicts. Complete conflict markers are reported as errors in every language and listed in Problems (severity under Settings › Editor › Inspections › Version Control). The toolbar shows Resolve/Continue and Abort next to the branch while conflicts or an interrupted operation exist, and the Commit view and Branches popup offer Resolve Conflicts even when no operation is in progress.

Co-authored-by: ntbang0901 <[email protected]>
2026-11-03 23:39:26 +08:00
ntbang0901 f752f2db08 feat(vcs): make the merge editor work like IntelliJ's merge tool (#902)
The result pane starts from the base and every change, conflicting or not, gets Accept / Ignore next to its side; accepting one side of a conflict still lets the other side be appended, and the modifier key resolves using that side directly. Adds Resolve Simple Conflicts (word-level), Apply Non-Conflicting Changes from the left, right or both (optionally on opening), Revert Conflict Resolution, Accept Yours / Accept Theirs / Cancel / Apply, Compare Contents between any two versions, and undo back to the conflicted state. CRLF and UTF-16 files are written back exactly, deleted sides and binary files are handled, and the commands are registered as merge.* actions.

Refs #826

Co-authored-by: ntbang0901 <[email protected]>
2026-11-03 23:37:38 +08:00
Florent Vandflorentvdry 2d4d6ee6bf fix(deployment): accept absolute local paths in mappings and name menu entries after the server (#885)
A mapping's local path may now be an absolute path inside the project (the form IntelliJ writes), and a deployment path of . means the server root; absolute paths outside the project are kept as they are. The project-tree Deployment submenu names the default server (Upload to <server>, Download from <server>, Sync with Deployed to <server>…) and gains Sync with Deployed to Default Server. Remote Host downloads use the backend-resolved mapping, which fixes downloads under symlinked or case-different paths.

Fixes #877

Co-authored-by: florentvdry <[email protected]>
2026-11-03 23:35:43 +08:00
Shohruh Ismoilovandshohruh-genius 0396f0d552 feat(vcs): recognize moved and edited code in the diff viewer (#868)
Blocks that were moved unchanged, re-indented or moved and then edited are paired across the split diff: each side gets a link to the other (keyboard accessible), moved lines carry a left accent bar, edits inside a moved block get word-level highlights, and the toolbar counts moved blocks and whitespace-only changes. Staging and reverting still work on the original hunks.

Co-authored-by: shohruh-genius <[email protected]>
2026-11-03 23:26:36 +08:00
whyareyouserious 09402e3e95 feat(vcs): redesign the Clone Repository dialog and support shallow clones (#866)
Clone Repository gets IntelliJ's layout: a source list on the left, a version-control selector, URL history, a directory picker, and a shallow-clone option (history truncated to N commits, git clone --depth N). Entries for hosting platforms without backend support were removed.

Co-authored-by: whyareyouserious <[email protected]>
2026-11-03 23:21:53 +08:00
时雨遥 f5e12238bb feat(terminal): split the tool window into tab groups, as IDEA (#864)
The Terminal could only split panes inside one tab. Split Right / Split Down now create tab groups, each with its own tabs, + and ▾; tabs can be moved between groups by dragging or Split and Move Right/Down, Unsplit merges a group back, and closing a group's last tab focuses the neighbouring group. Split actions are registered as actions and appear in the tab context menu.

Fixes #774

Co-authored-by: StarryKira <[email protected]>
2026-11-03 23:20:11 +08:00
Ravuthzandravuthz 9100187fc8 feat(php): PHPUnit and Composer support and a PHP interpreter setting (#959)
Composer projects and PHPUnit suites are detected (Composer scripts, PHPUnit, artisan serve); PHPUnit runs report into the test tree through --teamcity with gutter runs for classes and methods, file-level runs, rerun failed and a PHPUnit run configuration type. Settings › Languages & Frameworks › PHP selects the CLI interpreter (discovered on PATH, Homebrew, phpbrew, phpenv, Herd, XAMPP, Laragon); it is applied to run configurations and PHP language servers through PATH. TeamCity service messages feed only the test tree and are kept out of the run console, sharing the stdout decoder go test -json uses.

Co-authored-by: ravuthz <[email protected]>
2026-11-03 23:17:52 +08:00
Jaw d6efea8dac feat(markdown): set the preview font size apart from the UI font (#1045)
The Markdown preview had a fixed 14px body and notebook Markdown cells inherited the UI font size, so enlarging them meant scaling the whole UI. Languages & Frameworks › Markdown › Preview font size (application-level, 8–72, following the editor font size until set) now drives the preview and notebook Markdown cells; fenced code scales with it, and notebook code cells and outputs follow the editor font.

Fixes #768

Co-authored-by: jaw52 <[email protected]>
2026-11-03 23:16:53 +08:00
Piettro 71dd9d120b fix(python): keep modules in the working directory from shadowing the stdlib (#964)
Python puts the working directory first on sys.path for -c / -m. venv creation, pip (list/install/uninstall), the library-path probe and the IDE-managed Python tool probes and installs inherited the IDE's own working directory, so a project's typing.py / logging.py there broke them ("partially initialized module 'typing'"). venv creation now runs python -I; pip, the library probe and the tool commands run in an empty private directory (keeping pip install --user packages visible), and relative requirements (., ./pkg, .[dev]) resolve against the project root. The window-label part of the original change already landed in #960.

Refs #955

Co-authored-by: pcastro93 <[email protected]>
2026-11-03 22:58:36 +08:00
Jaw b57272b715 fix(markdown): make Structure autoscroll work with Preview only (#1037)
In Preview only the editor is display:none and the preview only followed it in the split layout, so Structure's Autoscroll to Source moved a hidden caret and Autoscroll from Source never saw the preview scroll. Caret moves without an edit now scroll a Preview-only preview to that line, and the preview publishes the source line on screen through editor/api (setShownLine/useShownLine) so Structure selects the heading being read; it is cleared when the editor comes back or the preview closes.

Fixes #977

Co-authored-by: jaw52 <[email protected]>
2026-11-03 22:50:13 +08:00
小小强andguozhiqiang123 685a9487f3 fix(vcs): wrap long refs in Git log commit details (#999)
Branch and tag labels in the commit details pane inherited the graph row's 180px single-line limit and were clipped. Labels in the details metadata now use the pane width and wrap, including names without break points; graph-row labels keep their single-line ellipsis.

Fixes #996

Co-authored-by: guozhiqiang123 <[email protected]>
2026-11-03 22:44:30 +08:00
l0ng-ai 329ad2c644 fix(ui): treat every second click of a burst as a double-click
WebKit fires `dblclick` only for the second click of a burst. A quick
double-click right after another arrives as `click` with `detail` 3 and 4 and
no `dblclick`, so the second double-click on a folder in the Project view (and
on any tree row, tab or list item handled by `onDoubleClick`) did nothing.

A window-level shim now dispatches a `dblclick` on the click's target for every
later even click of a burst (detail 4, 6, ...), after the page's own click
handlers. Existing `onDoubleClick` handlers and their `stopPropagation` apply
unchanged; an engine that fires its own `dblclick` for that click gets it
dropped as a duplicate.

The Project view test now delivers bursts the way WebKit does (`dblclick` only
at detail 2) and checks both expand modes over 2–6 click bursts.

Refs #724
2026-11-03 22:34:29 +08:00
l0ng-ai eefe166cf7 chore: v0.1.35 2026-11-03 22:31:49 +08:00
l0ng-ai 8c9ddb9879 fix(agents): tidy the Built-in agents settings page
- List rows are no longer text-selectable. Settings pages enable text selection
  for their content, and the shared List inherited it, so dragging or ⌘A
  highlighted every row.
- An agent missing from PATH is shown as a muted row with a small "Not found"
  label instead of an orange warning badge; a custom executable gets an accent
  "Custom" label.
- The details header puts the status (with a status dot) and the launch command
  on one line.
- Reset only appears when a custom executable is set.
- The sign-in hint gets its own "Sign in" heading.
- The list/details frame sizes to its content instead of filling the page.
2026-11-03 22:20:58 +08:00
l0ng-ai d54ef98a59 fix(lsp): open library classes behind jar: URIs (#1048)
The official Kotlin LSP answers navigation into a dependency with a
`jar:///…/lib.jar!/pkg/Name.class` URI but does not serve its contents through
`workspace/textDocumentContent`, so opening the target failed with "no
language server provides the contents of jar:…", locally and remotely.

`jar:` URIs are now resolved by the backend, where the jars are:
- An entry that is not a class (a sources jar entry, a resource) is read from
  its archive.
- A class is shown as its source from the artifact's `-sources.jar` in the
  Maven repository or the Gradle cache (`Name.java`/`Name.kt`, an inner
  class's outer file, a `FileKt` facade's `File.kt`, a `@file:JvmName`
  file); Go to Declaration then lands on the declaration of the name it was
  invoked on.
- Otherwise the server's `decompile` command is used when it offers one, and
  the error says that neither a source nor a decompiler is available.
- Library tabs name the archive and package (`spring-boot-3.5.16.jar ›
  org.springframework.boot`).

Fixes #982
2026-11-03 22:04:05 +08:00
l0ng-ai 98517019e7 fix(editor): say when a file is outside the language server's projects (#1046)
jdtls only checks the syntax of a Java file outside every module's source
folders (or outside any imported project) and answers navigation with
nothing. The editor then said only "Cannot find declaration to go to", and
the server's explanation was a warning squiggle on line 1.

- A banner above the editor explains it while the server reports it, for
  jdtls (not on the classpath of a project / non-project file), gopls (no
  package, build constraints — this replaces the Go-only banner) and
  rust-analyzer (unlinked file). For Java it offers the Maven / Gradle reload
  when available.
- Go to Declaration / Implementation / Type Declaration that finds nothing
  in such a file gives the reason in its hint.

Fixes #827
2026-11-03 22:02:50 +08:00
l0ng-ai ae406ca2fb fix(java): resolve Maven projects with the profiles chosen in the Maven tool window (#1044)
The profiles checked in the Maven tool window only reached `mvn` (sync and
goal runs) and the Kotlin bridge. jdtls imported every module without them,
so a dependency whose version, or module, comes from a profile stayed
unresolved and its classes were reported as missing, while Build Project ran
without `-P` as well.

- A Maven sync records the profiles it ran with. jdtls gets a copy of the
  user settings.xml with them in `<activeProfiles>` (m2e has no other input
  for profiles), sent as a setting so a running server re-imports instead of
  restarting; the file name follows its content so m2e reloads it.
- Toggling a profile reloads the Maven projects (once for several toggles in
  a row, again after a sync that started with the previous choice).
- Build Project passes the synced profiles with `-P`.
- When jdtls finishes an import or project update it does not re-check open
  files, so their errors stayed until the next edit; open Java files are now
  re-checked on its ProjectStatus report. This also covers a POM edit or a
  dependency added to ~/.m2.

Fixes #993
2026-11-03 22:02:40 +08:00
arookieofcanda-rookie-of-C-language 9dbae1d189 feat(agents): configurable executable for every built-in agent (#682)
Settings › Tools › Agents is split into Built-in and ACP Registry tabs. The Built-in tab lists every built-in agent with its status (custom executable or not found on PATH) and, for the selected agent, its default launch line, an Executable field (empty = auto-detect on PATH) with Browse and Reset, and its sign-in hint. Edits are staged with the Settings dialog and written on Apply / OK; Cancel discards them.

The override is written to <config>/agents/<id>.toml, keeping hand-written name, driver, args and env; resetting a command-only override removes the file. AgentProvider now exposes the program, args, built-in defaults, login hint and whether the command is editable: built-ins started through a package runner (npx) and registry installs are read-only, with the reason shown.

Fixes #596

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-03 21:55:51 +08:00
l0ng-ai e4b8b76452 chore: add issue/PR templates, PR check, AGENTS.md and shared agent skills (#1043)
Adds a PR template (linked issue, what and why, how it was verified, review focus), a minimal reproduction field on the bug form and required acceptance criteria on the feature form, and CONTRIBUTING.md with the issue and PR rules. A PR check labels needs-info and leaves one comment when the issue link or the verification section is missing; it only reads the title and description.

AGENTS.md is now tracked, with CLAUDE.md linking to it, and covers layout, rules, commands, the checks CI runs, pitfalls and parallel worktrees. The verify and issue-fix skills are shared under .agents/skills and linked from .claude/skills; the rest of .claude/ stays local.
2026-11-03 21:49:31 +08:00
l0ng-ai e2e699dca9 fix(database): bound large cell previews and use dialog editing (#1040)
Limit grid and export-preview cell text to 1000 UTF-16 code units plus an ellipsis. Edit large values in a wrapped dialog while preserving complete clipboard, export and database values.

Reproduced STATUS_STACK_OVERFLOW (0xC00000FD) in Windows WebView2 against a real PostgreSQL table with nine rows and 8.83 MB of jsonb data. Verified the fix through display, selection, editing, full clipboard copy, JSON file export and PostgreSQL writeback. All PR CI checks passed, including 5625 frontend tests and Rust checks on Windows, macOS and Linux.

Fixes #825
2026-11-03 21:49:26 +08:00
l0ng-ai dcd8ecc5ae fix(java): name reserved Windows device files that abort the Java import (#1032)
On Windows, a project file named after a device (nul, con, aux, prn, com1-9, lpt1-9; nul is typically left by a 2>nul redirect in Git Bash) aborts the Java import: Files.exists reports it missing, Eclipse's refresh builds a child path with an empty name and throws "Path must include project and resource name", so m2e never creates the Java project.

When JDT LS reports that failure on Windows, the server root is scanned for reserved device names (bounded, ignore files not honoured) and a one-time error notification lists them with how to recover. The name check covers COM0-9/LPT0-9 including superscript digits, case-insensitive, with or without extension or trailing spaces. Backend notification texts now go through txTemplate so templated catalog entries translate; zh text added.

Refs #976
2026-11-03 21:09:03 +08:00
l0ng-ai a3b6780823 fix(java): replace module outputs with launch snapshots on Windows (#1038)
Maven launches put a snapshot of each module's class directory on the classpath in place of the original. The originals were built as module.join("target/classes") (`...\app\target/classes` on Windows) while JDT LS reports `...\app\target\classes`; compared as text, the original stayed next to its snapshot, so every resource was on the classpath twice and MyBatis failed with "XML fragments parsed from previous mappers already contains value". Launch class directories are now joined per component, and classpath entries are compared by a key that ignores the verbatim prefix, separators, trailing separators and, on Windows, case.

Fixes #704
2026-11-03 20:59:40 +08:00
arookieofc bd2df0bd3a fix(markdown): keep the preview-only scroll position across remounts (#1009)
Tab switches and mode toggles unmount MarkdownPreview, whose only
scroll state was the DOM scrollTop; Preview Only started over at the
top. Record the preview's own scrollTop in the per-path live markdown
store and restore it on mount (split mode keeps its editor-driven
sync).
2026-11-03 20:55:10 +08:00
Marius Ciepluch 8ca25e1984 fix(markdown): use editor-side tokens in the preview (#992)
* fix(markdown): use editor-side tokens in the preview

The preview paints on var(--bg-editor) but took its text colors from
the UI tokens (--fg, --fg-strong, --fg-secondary), so with a light UI
theme plus a dark editor scheme (e.g. an imported dark .icls) it
rendered black-on-black. The code editor was unaffected because it
uses --fg-editor.

The preview now uses editor-side tokens: text var(--fg-editor),
headings var(--syn-markup-heading), code/pre/th background
var(--bg-editor-inline), blockquote var(--fg-editor) at 80%.

* chore: oxfmt src/features/run/types.ts (fixes format:check broken on main by 002401e8)

* chore: regenerate bindings after rebasing onto main
2026-11-03 20:55:06 +08:00
arookieofc a15712b670 fix(vcs): keep a deleted-upstream branch pushable in the multi-repo Push dialog (#1004)
`@{u}..HEAD` fatally fails when the tracking config remains but the
remote-tracking ref is gone (the upstream branch was deleted on the
remote and the fetch pruned): the repository was dropped from the
multi-repository Push dialog, or, when every repository failed, the
dialog showed the raw fatal and disabled Push. Fall back to recent
history like the single-repository dialog does, so the branch stays
listed and pushable (a plain push recreates the remote branch).
2026-11-03 20:33:41 +08:00
arookieofc 82130d37e5 fix(build): recognize Maven builds whose aggregate POM sits in src/ (#1022) 2026-11-03 20:32:37 +08:00
JawandClaude Opus 5.5 07456b2562 fix(editor): show the scrollbar thumb over overview marks (#1031)
The overview lane lies over the vertical scrollbar, so a mark the height of the lane (a new file added to git is one green VCS mark) hid the native thumb completely. The lane now draws the thumb itself, above its marks and without taking the pointer, at the position of the native one (scrollbarThumb, inset by its 2px border); it switches to the hover color while the pointer is on the thumb. The native thumb is made transparent in editors that have the lane, so the two don't add up, and still takes the drag. Editors without the lane (notebook cells, previews) keep the native thumb.

Fixes #1014

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-03 20:31:57 +08:00
arookieofc 659afe66f5 fix(code-insight): stop call/type hierarchy from nesting recursive symbols forever (#1016)
A recursive symbol got a fresh id on every level (ids carry the parent
chain), so sibling dedupe never matched and each expansion requested the
same level again, forever. Identify items by their position key instead:
a child whose key repeats an ancestor is kept a leaf, marked recursive,
and never expanded.
2026-11-03 20:31:01 +08:00
JawandClaude Opus 5.5 a57267fa32 fix(project-tree): reveal files of linked workspace projects (#915) (#1035)
In a workspace the tree's root is the workspace directory, and the linked projects are separate top-level roots outside it. Select Opened File (and other reveals) only looked inside that root and contributed folders, so a file of a linked project was never expanded, selected or scrolled to. Reveal now resolves the innermost linked project containing the path first; the Project Files pane also counts linked projects as shown.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-03 20:30:07 +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
1107 changed files with 88135 additions and 5818 deletions

No files matched your search

+91
View File
@@ -0,0 +1,91 @@
---
name: issue-fix
description: Triage and fix a GitHub issue (or a user-reported bug) end to end — reproduce, find the root cause, generalise it to every sibling case, fix the class rather than the instance, verify, and open one PR. Use when given an issue URL/number or asked to fix a reported bug and open a PR ("看看这个 issue", "修一下这个 bug", "提 pr").
---
# Fix an issue: from one case to the whole class
The reported case is one sample of a defect class. A fix that only makes the
screenshot go away is not done: the next question is always "does this break for other
languages / platforms / entry points too?". Answer that before anyone asks.
## 1. Read and reproduce
- `gh issue view <n> --comments` (repo `l0ng-ai/openidea`). Read attached logs/screenshots
closely: OS, language, toolchain versions, exact error text.
- Locate the code path and confirm the claim in code, with `file:line` evidence.
Say plainly whether it is a real bug, a missing feature, or user environment.
- Reproduce locally when possible (`fixtures/` projects, `/verify` for runtime). If the
reporter's platform (often Windows/Linux) can't be reproduced here, say so up front.
- Do not ask for a generic environment/log checklist before trying the flow and inspecting the code.
Record the attempt and result; ask only for a fact that actually blocks the next diagnostic step.
Missing fields, limited time, or unavailable automation are not by themselves reasons to ask the reporter.
- Treat a maintainer-confirmed bug as an investigation task even if local reproduction fails. Distinguish
“not reproduced here” from “not a bug”, and do not require the reporter to prove its existence again.
## 2. Name the root cause as a class
Write one sentence of the form "<mechanism> is missing/wrong, so any <X> hits it",
not "<this button> is broken". Typical classes in this codebase:
- Per-language config handled for one language only (`languages/*.toml`, `ide-sdk`).
- Platform gaps: paths, executables, shells, `.cmd`/`.exe`, separators — macOS-only code.
- Toolchain/version discovery: one probe location, no version check, no rescan.
- Two features each implementing half a flow and never joined (feature isolation →
should go through an action/service).
- State not reset on project switch / window reuse; events not scoped per window.
- Failure surfaced as raw stderr/stack trace or retried until "crashed" instead of a
clear status with an action.
- UI: same primitive styled differently in N places.
## 3. Sweep for siblings
Search systematically, not by memory:
- Same pattern elsewhere: `git grep` the function, the config key, the error string,
the hard-coded path/flag. Check every `languages/*.toml` entry and every platform branch.
- Same flow for other languages/runtimes (Java ↔ Kotlin/Gradle, Python ↔ venv/uv,
Node ↔ Deno/Bun, Go, Rust …): would they fail the same way?
- Other callers/entry points of the same flow (menu, context menu, gutter, shortcut, CLI arg).
- Neighbouring failure modes of the same mechanism (not installed / wrong version /
installed but crashes / slow start / installed mid-session).
For broad sweeps spawn one Explore agent with the class description, not the instance.
## 4. Decide the fix at the right altitude
- Fix the mechanism once (shared helper, action, config hook) so siblings are fixed
by construction; don't patch each call site.
- Respect `AGENTS.md` rules (crate boundaries, no cross-feature imports, `IdeResult`,
UTF-16). If the right fix breaks a rule, pick the rule-respecting design and say why.
- Compare with how established IDEs solve it when relevant; prefer the established answer.
- Present: root cause, sibling table, proposed fix, what's deferred. When the user leaves
the choice to you, go ahead without asking again.
## 5. Implement and test the class
- Tests cover the class: table-driven over languages/platforms/states, not just the
reported input. Include the reporter's case verbatim.
- Run the scoped checks (`pnpm vitest run <path>`, `cargo nextest run -p <crate>`), then
`/verify` in the app for UI or runtime behaviour.
## 6. PR
- One branch `fix/<slug>`, one PR unless the user asks otherwise; `Fixes #<n>` in the body.
- Don't stage untracked IDE-state dirs (`.ideal/`, `.openidea/`) or screenshots.
- PR body follows `.github/pull_request_template.md`: `Closes #<n>`, then
**What and why** (user-visible change + root cause at class level, 2–4 sentences),
**How it was verified** (commands run and results, siblings fixed/deferred, what could break and how
it was checked, what was not verified, e.g. "Windows not tested"; for UI changes the before/after
screenshot paths from `/verify`), **Review focus** (optional). Short and plain.
## Report format
Reply in the user's language:
```
Root cause: <one sentence, class level>
Sibling sweep:
| Location / case | Affected? | Action |
Fixed / not covered (why) / not verified (platform)
PR: <url>
```
+57
View File
@@ -0,0 +1,57 @@
---
name: verify
description: Build, launch and drive the OpenIDEA desktop app through the Tauri MCP bridge to verify a change at runtime.
---
# Verify OpenIDEA in the running app
## Launch
- Run the app from the worktree that holds the change: `pnpm tauri dev` in the background, logging
into your task's scratch subdirectory. Use `pnpm dev:app` instead when your checks call
`window.__TAURI__` from `execute_js`. The wrapper gives each worktree its own app identity, data,
settings and Vite port, so other worktrees and the installed app don't interfere. If it reports a
session already running for this worktree, reuse that one.
- Once the app is up, `pnpm dev:status` lists this worktree's MCP bridges. Pass the reported
`appIdentifier` (the port) on every `mcp__tauri__*` call. The MCP server is shared between
sessions: never `driver_session stop` without an `appIdentifier`, and re-check `document.title` /
`location.href` before acting if anything looks off.
- Make sure the binary contains the change: a session already running for this worktree may be an
older build. Restart it after Rust changes; frontend changes reload through Vite.
- After the app restarts, `driver_session start` again for your port (a stale session says "Already connected").
- If the `pnpm tauri dev` process exits while the app window stays open, Vite is gone and any webview
reload shows an empty `#root`. Stop that app and start the session again.
- Stop everything you started when done and nothing else; never use process-name kills.
## Driving
- `manage_window focus` before screenshots / timing-sensitive steps: a background window throttles timers
(debounced LSP / VCS refreshes stall, `setTimeout` promises in `execute_js` time out) and native
screenshots come back black.
- Settings is a separate native window `settings-main`; pass `windowId`, focus it before screenshotting.
- CodeMirror / xterm ignore MCP `type`. Focus the element, then `document.execCommand('insertText', false, text)`.
For xterm Enter, dispatch a `keydown` (`key: 'Enter', keyCode: 13`) on `.xterm-helper-textarea`.
- MCP clicks arrive with `event.detail === 0`; CodeMirror treats them as line-select clicks. Set the caret
through the view instead: `document.querySelector('.cm-content').cmTile.view`.
- MCP `text` strategy can resolve to a hidden node; prefer coordinates (CSS px = image px × 0.9) or CSS selectors.
- CSS-module edits often trigger a full page reload in dev: dialogs close, Run sessions and terminal
scrollback reset, and each reload spawns a fresh shell. Re-open what you were testing.
## Evidence
- For UI or interaction changes, capture `before.png` (on `main` or with the fix reverted) and
`after.png` at the same state and window size, into your task's scratch subdirectory. Multi-step
flows: a numbered series (`after-1.png`, `after-2.png`, ...).
- Report the absolute paths; they go into the PR's **How it was verified** section and the triage
acceptance entry. Never commit screenshots to the feature branch.
## Fixture
`fixtures/` has sample projects (go-app, rust-app, ts-app, kotlin-gradle, polyglot). Copy one into
your scratch directory before editing it, and don't commit the IDE state it creates (`.openidea/`).
## Flows worth driving
⌘⇧O Goto File · ⌘⇧F Find in Files (preview scroll + highlight) · ⌘⇧A Find Action · editor edit/undo/autosave ·
Terminal (Unicode, venv) · Commit view at the default narrow width · Problems · Run → Stop · Project view
New / Delete (nested `a/b.txt` names) · ⌘, Settings search (+ Keymap filter) · Git Log on a repo without commits.
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/issue-fix
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/verify
+75
View File
@@ -0,0 +1,75 @@
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: reproduction
attributes:
label: Minimal reproduction
description: A link to a small public repository, or the smallest files/snippet that trigger the problem. Optional, but it's the fastest way to get a fix.
placeholder: https://github.com/you/repro-project
- 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,48 @@
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: acceptance
attributes:
label: Acceptance criteria
description: How we'll know it's done. One observable behavior per line.
placeholder: |
- [ ] Right-clicking a test class shows "Run with Coverage"
- [ ] Coverage results open in the Coverage tool window
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
+17
View File
@@ -0,0 +1,17 @@
Closes #
## What and why
<!-- 2–4 sentences: the change a user notices, and the root cause.
If it doesn't fit, the PR is probably too big; split it. -->
## How it was verified
<!-- Required. Commands you actually ran and their results; what else this could
affect and how you checked it still works.
UI changes: before/after screenshots. Multi-step interactions: a recording.
Backend changes: logs or command output. -->
## Review focus
<!-- Optional: anything you want reviewers to look at closely. -->
+51
View File
@@ -71,6 +71,57 @@ jobs:
- run: cargo build --locked -p ide-host --bin openidea-server --target x86_64-unknown-linux-musl
- run: target/x86_64-unknown-linux-musl/debug/openidea-server --version
# The plugin SDK, the official plugins and the test fixtures build in their
# own workspaces (the root excludes them), so the jobs above don't see them.
plugins:
name: Plugins (SDK, official, fixtures)
runs-on: ubuntu-22.04
env:
CARGO_TARGET_DIR: ${{ github.workspace }}/target-plugins
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with: { targets: wasm32-wasip2, components: "clippy, rustfmt" }
- uses: Swatinem/rust-cache@v2
with:
key: plugins
cache-directories: target-plugins
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Format, lint (host and wasm) and test each crate
shell: bash
run: |
set -euo pipefail
for dir in sdk/rust/openidea-plugin plugins/official/*/ crates/ide-plugin/tests/fixtures/*/; do
[ -f "$dir/Cargo.toml" ] || continue
echo "::group::$dir"
cargo fmt --check --manifest-path "$dir/Cargo.toml"
cargo clippy --locked --all-targets --manifest-path "$dir/Cargo.toml" -- -D warnings
cargo clippy --locked --target wasm32-wasip2 --manifest-path "$dir/Cargo.toml" -- -D warnings
cargo test --locked --manifest-path "$dir/Cargo.toml"
echo "::endgroup::"
done
- uses: pnpm/action-setup@v4
with: { version: 11 }
- uses: actions/setup-node@v4
with: { node-version: 22 }
# Plugin web pages (`web-src/`, their own pnpm projects): typecheck and test.
- name: Web pages typecheck and test
shell: bash
run: |
set -euo pipefail
for dir in plugins/official/*/web-src/; do
[ -f "$dir/package.json" ] || continue
echo "::group::$dir"
pnpm --dir "$dir" install --frozen-lockfile --ignore-workspace
pnpm --dir "$dir" exec tsc --noEmit
pnpm --dir "$dir" test
echo "::endgroup::"
done
# The host's tests run the checked-in components: they must be built from these sources.
# Web pages aren't checked in: this builds them and checks the result.
- name: Checked-in components are up to date, web pages build
run: plugins/build.sh --check
deny:
name: Cargo deny
runs-on: ubuntu-latest
+69
View File
@@ -0,0 +1,69 @@
name: PR check
on:
# pull_request_target so the check can label PRs from forks. It never checks out
# or runs PR code: it only reads the PR title and description.
pull_request_target:
types: [opened, edited, reopened, ready_for_review]
permissions:
issues: write
pull-requests: write
concurrency:
group: pr-check-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
check:
if: github.event.pull_request.user.type != 'Bot' && !github.event.pull_request.draft
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v7
with:
script: |
const pr = context.payload.pull_request;
const LABEL = 'needs-info';
const MARKER = '<!-- pr-check -->';
// Docs, CI and pure refactors may skip the linked issue.
const NO_ISSUE_TYPES = /^(docs|ci|build|chore|refactor|style|test)(\(.+\))?!?:/;
const body = (pr.body || '').replace(/<!--[\s\S]*?-->/g, '');
const problems = [];
const linksIssue =
/\b(close[sd]?|fix(e[sd])?|resolve[sd]?)\s*:?\s+(#\d+|https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/issues\/\d+)/i.test(body);
if (!linksIssue && !NO_ISSUE_TYPES.test(pr.title)) {
problems.push('Link the issue this PR resolves, e.g. `Closes #123`.');
}
const verified = body.match(/^##+\s*How it was verified\s*$([\s\S]*?)(?=^##+\s|(?![\s\S]))/im);
if (!verified || !verified[1].trim()) {
problems.push('Fill in **How it was verified**: the commands you ran and their results. UI changes need before/after screenshots.');
}
const { owner, repo } = context.repo;
const issue_number = pr.number;
const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number });
const existing = comments.find(c => c.user.type === 'Bot' && c.body.includes(MARKER));
const labeled = pr.labels.some(l => l.name === LABEL);
if (problems.length) {
const text = `${MARKER}\nThanks for the PR. Before review, please update the description (see [CONTRIBUTING.md](https://github.com/${owner}/${repo}/blob/main/CONTRIBUTING.md)):\n\n${problems.map(p => `- ${p}`).join('\n')}\n\nThis check reruns when you edit the description.`;
if (existing) {
await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body: text });
} else {
await github.rest.issues.createComment({ owner, repo, issue_number, body: text });
}
if (!labeled) {
await github.rest.issues.addLabels({ owner, repo, issue_number, labels: [LABEL] });
}
return;
}
// Only undo what this check did: the label is cleared only when our comment exists.
if (existing) {
await github.rest.issues.deleteComment({ owner, repo, comment_id: existing.id });
if (labeled) {
await github.rest.issues.removeLabel({ owner, repo, issue_number, name: LABEL }).catch(() => {});
}
}
+64 -1
View File
@@ -180,6 +180,69 @@ jobs:
if command -v sha256sum >/dev/null; then sha256sum "$ASSET"; else shasum -a 256 "$ASSET"; fi > "$ASSET.sha256"
gh release upload "$TAG" "$ASSET" "$ASSET.sha256" --clobber
# The official plugins, signed, and the marketplace index released apps read
# (releases/latest/download/plugins-index.json). They are not in the
# installers: users install them from Settings › Plugins. Without the
# signing key (secret PLUGIN_SIGNING_KEY: the file `openidea-plugin keygen`
# writes) nothing is published and the release goes on.
plugins:
needs: create-release
name: Official plugins
runs-on: ubuntu-22.04
env:
SIGNING_KEY: ${{ secrets.PLUGIN_SIGNING_KEY }}
steps:
- id: key
run: |
if [ -z "$SIGNING_KEY" ]; then
echo "::warning::PLUGIN_SIGNING_KEY is not set: the official plugins are not published"
echo "skip=true" >> "$GITHUB_OUTPUT"
fi
- uses: actions/checkout@v4
if: steps.key.outputs.skip != 'true'
with: { ref: "${{ env.TAG }}" }
- uses: dtolnay/rust-toolchain@stable
if: steps.key.outputs.skip != 'true'
with: { targets: wasm32-wasip2 }
- uses: Swatinem/rust-cache@v2
if: steps.key.outputs.skip != 'true'
with: { key: release-plugins }
- uses: pnpm/action-setup@v4
if: steps.key.outputs.skip != 'true'
with: { version: 11 }
- uses: actions/setup-node@v4
if: steps.key.outputs.skip != 'true'
with: { node-version: 22 }
# Builds the web pages; fails when a checked-in component isn't built from these sources.
- name: Build
if: steps.key.outputs.skip != 'true'
run: |
plugins/build.sh --check
cargo build --release --locked -p ide-host --bin openidea-plugin
- name: Pack, index and upload
if: steps.key.outputs.skip != 'true'
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
tool="$PWD/target/release/openidea-plugin"
key="$RUNNER_TEMP/openidea.key"
out="$RUNNER_TEMP/market"
(umask 077 && printf '%s' "$SIGNING_KEY" > "$key")
trap 'rm -f "$key"' EXIT
mkdir -p "$out"
for dir in plugins/official/*/; do
id="$(sed -n 's/^id *= *"\(.*\)".*/\1/p' "$dir/plugin.toml" | head -1)"
version="$(sed -n 's/^version *= *"\(.*\)".*/\1/p' "$dir/plugin.toml" | head -1)"
"$tool" pack "$dir" --key "$key" --publisher openidea --out "$out/$id-$version.oiplugin"
done
# The published index's revocations and earlier keys carry over.
curl -fsSL "https://github.com/$GITHUB_REPOSITORY/releases/latest/download/plugins-index.json" \
-o "$out/plugins-index.json" || rm -f "$out/plugins-index.json"
"$tool" index "$out" --publisher openidea --name OpenIDEA --key "$key" --out "$out/plugins-index.json"
gh release upload "$TAG" "$out"/*.oiplugin "$out/plugins-index.json" --clobber
# The updater manifest (latest.json), built once from the signed bundles every build uploaded.
# Fails when a platform is missing, so a release never ships a manifest without it.
updater-manifest:
@@ -243,7 +306,7 @@ jobs:
core.info(`latest.json: ${Object.keys(platforms).sort().join(", ")}`);
publish:
needs: [create-release, build, server, updater-manifest]
needs: [create-release, build, server, plugins, updater-manifest]
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v7
+10 -3
View File
@@ -10,6 +10,9 @@ lerna-debug.log*
node_modules
dist
dist-ssr
# Plugin components are checked in (plugins/build.sh --check keeps them current).
!plugins/official/*/dist
!crates/ide-plugin/tests/fixtures/*/dist
*.local
# Editor directories and files
@@ -30,9 +33,13 @@ target/
test-results/
playwright-report/
# Local AI agent files
CLAUDE.md
.claude/
# Local AI agent files. Shared skills live in .agents/skills; .claude/skills links to them.
CLAUDE.local.md
.claude/*
!.claude/skills/
.claude/skills/*
!.claude/skills/verify
!.claude/skills/issue-fix
# Local reference screenshots
pycharm-screenshots/
+3
View File
@@ -10,10 +10,13 @@
"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",
"fixtures",
"plugins/*/web",
"plugins/official/*/web",
"crates/**/tests/fixtures"
],
"overrides": [
+118
View File
@@ -0,0 +1,118 @@
# OpenIDEA — contributor guide
Rust + Tauri 2 desktop IDE with full-featured, multi-language code intelligence.
Visual style: dark, low-contrast greys, one cool accent
(`src/styles/tokens.css` is the source of truth for colours and metrics).
## Layout
- `crates/ide-*` — backend crates; one domain each. Depend on `ide-core` for shared
types; never reach into another crate's internals.
- `src-tauri/` — glue only: `state.rs` (AppState / Project), `commands/<domain>.rs`
(thin typed IPC adapters), `events.rs`. No domain logic here.
- `src/bindings.ts` — generated by tauri-specta (`pnpm gen:bindings`). Never edit.
- `src/platform/` — ipc (`call`, `onIdeEvent`, `nextRequestId`), actions registry,
keymap + dispatcher, context keys / `when` clauses, project store.
- `src/workbench/contributions.ts` — extension points (tool windows, status items,
overlays, editor kinds) and cross-feature services (editor, tool windows, notifications).
- `src/ui/` — design-system primitives. `src/features/<name>/` — one feature each,
exporting `activate()` registered in `src/features/index.ts`.
- `languages/<id>.toml` — language definitions (detection, comments, LSP, DAP).
- `crates/ide-host` — the `openidea-server` binary for remote development.
- `wit/` — plugin interface definitions; `plugins/` — sample and bundled plugins;
`templates/` — plugin starter templates.
- `e2e/` — Playwright UI tests (`pnpm e2e`). `fixtures/` — sample projects per language
for manual and automated testing.
## Rules
- IPC coordinates: 0-based lines, **UTF-16** columns/offsets everywhere (`ide-core::text`).
- Every command returns `IdeResult<T>`; convert internal errors at the crate edge.
- Streaming → `tauri::ipc::Channel<T>`; cancellable → UI-allocated `RequestId` +
`cancel_request`. Backend → UI notifications → `IdeEvent` via `EventSink`.
- Long work never blocks the UI thread or a Tauri command thread: use
`spawn_blocking`/rayon, honour `CancellationToken`.
- Frontend features never import each other; communicate via actions, contributions
and services. All user-invocable behaviour is an action with a stable id; default
shortcuts live in `src/platform/keymaps/`.
- Styling: CSS Modules + tokens only (no hex literals in components). Inline SVG
icons from `src/ui/icons.tsx`, never emoji. Real `<button>`/`<input>`; `aria-label` on icon buttons.
- User-visible text goes through the text catalog: add every new string to both
`src/platform/locales/text/en.json` and `zh.json` (`textCatalog.test.ts` enforces it).
- Keep comments sparse and purposeful; match surrounding style.
- For GUI verification, prefer Tauri MCP; use Computer Use only when Tauri MCP is
unavailable or cannot cover the required checks.
## Issues & PRs
Follow `CONTRIBUTING.md` and `.github/pull_request_template.md`: one issue per PR with
`Closes #<n>`, reproduce first, keep the diff focused, short plain description, and a
**How it was verified** section with commands run, results and (for UI) before/after screenshots.
Unclear product decisions → `needs-decision` and stop.
## Skills
Shared agent skills live in `.agents/skills/` (`.claude/skills/` links to them):
`verify` drives the running app through Tauri MCP and captures screenshot evidence;
`issue-fix` takes an issue from reproduction to one PR.
## Commands
- `pnpm tauri dev` — run the app with worktree isolation (Node 22.18+).
`pnpm dev:app` also enables the global Tauri API. `pnpm dev:status` reports
this worktree's running MCP bridges and actual ports.
- `pnpm build` — typecheck + bundle frontend.
- `cargo nextest run --workspace` (or `cargo test`), `cargo clippy --workspace --all-targets -- -D warnings`, `cargo fmt --all`.
- `pnpm test`, `pnpm e2e`, `pnpm lint`, `pnpm format:check`, `pnpm knip`, `pnpm typecheck`,
`pnpm gen:bindings`.
- `pnpm perf` (UI interaction latency / frames / leaks), `pnpm perf:rust` (criterion
benches vs budgets). Budgets: `perf/budgets.json`.
## Before opening a PR
Run what CI runs for the parts you touched:
- Rust: `cargo fmt --all --check`, `cargo clippy --workspace --all-targets -- -D warnings`,
`cargo nextest run --workspace`. After changing IPC types, run `pnpm gen:bindings` and
commit `src/bindings.ts`; CI fails if it differs.
- Frontend: `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, `pnpm knip`, `pnpm test`,
`pnpm build`, and `pnpm e2e` for UI flows.
- UI or runtime behaviour: verify in the running app (`.agents/skills/verify`).
## Pitfalls
- Run a subset of frontend tests with `pnpm vitest run <path>`. `pnpm test -- <path>` ignores
the filter and runs the whole suite, which takes several minutes.
- Don't run clippy while iterating: `pnpm tauri dev` compiles anyway. Run
`cargo fmt --all` + `cargo clippy -p <crate> --all-targets -- -D warnings` once, right
before committing. A cold build cache can take around 5 minutes. Run long checks one at
a time, not chained in one shell call that may hit the 10-minute tool timeout.
- `pnpm tauri dev` does a full debug build. Clippy only checks the code, so a passing clippy
doesn't make the first launch fast.
## Parallel worktrees
Several worktrees can be developed and run side by side.
- Create one per task: `git worktree add ../<name> -b <branch> origin/main`, then
`pnpm install` in it.
- Launch with `pnpm tauri dev` / `pnpm dev:app`, not the raw `pnpm exec tauri dev` CLI. The
wrapper starts Vite on a worktree-derived port (advancing if occupied), passes its actual
URL to Tauri, and reports an existing launcher instead of starting another for the same
worktree. HMR uses the Vite port.
- Debug app identity, data, settings, logs and single-instance scope follow the canonical
worktree path captured at build time, independent of launch cwd. Launching the same debug
binary again focuses its existing instance and forwards its path argument. Different
worktrees and the installed release can coexist. `--release` uses normal release identity
and bypasses the development wrapper.
- MCP bridge ports are dynamic (9223–9322). Use `pnpm dev:status`, connect to the reported
port, verify identifier/cwd, and pass `appIdentifier: <port>` explicitly. Do not assume the
default MCP session belongs to this worktree.
- Keep each worktree's Cargo output directory separate; do not point `CARGO_TARGET_DIR` or
`target` symlinks at a shared directory.
- Each worktree's first build is cold (around 5 minutes) and its Cargo output takes
15–30 GB. Avoid running several heavy builds on one machine at the same time.
- Stop only processes started for the current worktree/session. Never use broad
process-name kills such as `pkill openidea`.
- When done: stop that worktree's app and dev session, delete its build output, then
`git worktree remove ../<name>`.
Symlink
+1
View File
@@ -0,0 +1 @@
AGENTS.md
+35
View File
@@ -0,0 +1,35 @@
# Contributing to OpenIDEA
These rules apply to everyone, and to any coding agent working on their behalf.
Project layout, coding rules, commands and checks are in [AGENTS.md](AGENTS.md).
## Issues
- One problem or request per issue. Use the issue forms.
- Bugs: steps that reproduce the problem from a fresh start. A minimal sample
project is the fastest way to get a fix.
- Features: state the problem and the acceptance criteria. A feature is ready to
build once a maintainer labels it `planned`.
- Labels track status: `needs-info` waits on the reporter, `needs-decision` waits
on a maintainer, `planned` is accepted, `needs-acceptance` has a fix waiting to be
checked.
## Pull requests
- One issue per PR, linked with `Closes #123`. Feature PRs need a `planned` issue.
Docs, CI and pure refactors may skip the issue.
- Reproduce the problem first, or write a test that fails before the fix.
- Fix the cause, and check whether the same cause breaks anything nearby.
- Keep the diff focused. Unrelated refactors go in their own PR.
- Title: [Conventional Commits](https://www.conventionalcommits.org/), e.g.
`fix(editor): keep the caret visible after folding`.
- Fill in the template. Keep the description short and in plain words. If it
doesn't fit in a few sentences, the PR is probably too big.
- **How it was verified** is required: the commands you ran and their results,
and what else the change could affect. UI changes need before/after screenshots;
multi-step interactions need a recording.
- If a change needs a product decision you can't make, ask in the issue and stop
rather than guessing.
A check on each PR adds `needs-info` when the issue link or the verification
section is missing, and removes it once they're filled in.
Generated
+82 -40
View File
@@ -4038,7 +4038,7 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "ide-agent"
version = "0.1.33"
version = "0.1.35"
dependencies = [
"dunce",
"encoding_rs",
@@ -4061,7 +4061,7 @@ dependencies = [
[[package]]
name = "ide-analysis"
version = "0.1.33"
version = "0.1.35"
dependencies = [
"ast-grep-core",
"ide-core",
@@ -4075,22 +4075,22 @@ dependencies = [
[[package]]
name = "ide-build"
version = "0.1.33"
version = "0.1.35"
dependencies = [
"dirs 6.0.0",
"dunce",
"ide-core",
"libc",
"serde",
"serde_json",
"specta",
"tempfile",
"tracing",
"zip",
]
[[package]]
name = "ide-clipboard"
version = "0.1.33"
version = "0.1.35"
dependencies = [
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
@@ -4099,10 +4099,11 @@ dependencies = [
[[package]]
name = "ide-core"
version = "0.1.33"
version = "0.1.35"
dependencies = [
"dunce",
"encoding_rs",
"libc",
"serde",
"serde_json",
"specta",
@@ -4115,7 +4116,7 @@ dependencies = [
[[package]]
name = "ide-coverage"
version = "0.1.33"
version = "0.1.35"
dependencies = [
"ide-core",
"ide-run",
@@ -4129,7 +4130,7 @@ dependencies = [
[[package]]
name = "ide-dap"
version = "0.1.33"
version = "0.1.35"
dependencies = [
"ide-core",
"ide-jsonrpc",
@@ -4147,7 +4148,7 @@ dependencies = [
[[package]]
name = "ide-db"
version = "0.1.33"
version = "0.1.35"
dependencies = [
"bb8",
"dirs 6.0.0",
@@ -4169,10 +4170,9 @@ dependencies = [
[[package]]
name = "ide-docker"
version = "0.1.33"
version = "0.1.35"
dependencies = [
"ide-core",
"libc",
"serde",
"serde_json",
"specta",
@@ -4181,9 +4181,29 @@ dependencies = [
"which",
]
[[package]]
name = "ide-extension"
version = "0.1.35"
dependencies = [
"base64 0.22.1",
"blake3",
"ed25519-dalek",
"ide-core",
"parking_lot",
"semver",
"serde",
"serde_json",
"specta",
"specta-typescript",
"tempfile",
"toml 0.9.12+spec-1.1.0",
"tracing",
"zip",
]
[[package]]
name = "ide-fonts"
version = "0.1.33"
version = "0.1.35"
dependencies = [
"objc2-core-foundation",
"objc2-core-text",
@@ -4195,11 +4215,10 @@ dependencies = [
[[package]]
name = "ide-github"
version = "0.1.33"
version = "0.1.35"
dependencies = [
"ide-core",
"ide-vcs",
"libc",
"serde",
"serde_json",
"specta",
@@ -4209,11 +4228,15 @@ dependencies = [
[[package]]
name = "ide-host"
version = "0.1.33"
version = "0.1.35"
dependencies = [
"base64 0.22.1",
"criterion",
"dirs 6.0.0",
"dunce",
"encoding_rs",
"getrandom 0.3.4",
"globset",
"ide-agent",
"ide-analysis",
"ide-build",
@@ -4221,12 +4244,14 @@ dependencies = [
"ide-coverage",
"ide-dap",
"ide-db",
"ide-extension",
"ide-http",
"ide-index",
"ide-jsonrpc",
"ide-language",
"ide-lsp",
"ide-notebook",
"ide-plugin",
"ide-remote",
"ide-run",
"ide-sdk",
@@ -4240,21 +4265,27 @@ dependencies = [
"ide-vcs",
"ide-vfs",
"ide-workspace",
"ignore",
"parking_lot",
"reqwest",
"rustix",
"serde",
"serde_json",
"specta",
"specta-typescript",
"tempfile",
"tokio",
"toml 0.9.12+spec-1.1.0",
"tracing",
"tracing-subscriber",
"uuid",
"windows-sys 0.61.2",
"zip",
]
[[package]]
name = "ide-http"
version = "0.1.33"
version = "0.1.35"
dependencies = [
"ide-core",
"parking_lot",
@@ -4272,7 +4303,7 @@ dependencies = [
[[package]]
name = "ide-index"
version = "0.1.33"
version = "0.1.35"
dependencies = [
"blake3",
"criterion",
@@ -4294,7 +4325,7 @@ dependencies = [
[[package]]
name = "ide-jsonrpc"
version = "0.1.33"
version = "0.1.35"
dependencies = [
"encoding_rs",
"futures",
@@ -4310,11 +4341,12 @@ dependencies = [
[[package]]
name = "ide-language"
version = "0.1.33"
version = "0.1.35"
dependencies = [
"blake3",
"criterion",
"ide-core",
"ide-extension",
"ide-text",
"parking_lot",
"regex-lite",
@@ -4365,7 +4397,7 @@ dependencies = [
[[package]]
name = "ide-lsp"
version = "0.1.33"
version = "0.1.35"
dependencies = [
"dunce",
"futures",
@@ -4386,11 +4418,12 @@ dependencies = [
"tokio",
"toml 0.9.12+spec-1.1.0",
"tracing",
"zip",
]
[[package]]
name = "ide-notebook"
version = "0.1.33"
version = "0.1.35"
dependencies = [
"futures",
"ide-core",
@@ -4413,17 +4446,19 @@ dependencies = [
[[package]]
name = "ide-plugin"
version = "0.1.33"
version = "0.1.35"
dependencies = [
"blake3",
"ide-core",
"ide-language",
"ide-extension",
"parking_lot",
"semver",
"serde",
"serde_json",
"specta",
"tempfile",
"thiserror 2.0.21",
"toml 0.9.12+spec-1.1.0",
"tokio",
"tracing",
"wasmtime",
"wasmtime-wasi",
@@ -4431,7 +4466,7 @@ dependencies = [
[[package]]
name = "ide-profiler"
version = "0.1.33"
version = "0.1.35"
dependencies = [
"ide-core",
"ide-run",
@@ -4444,7 +4479,7 @@ dependencies = [
[[package]]
name = "ide-remote"
version = "0.1.33"
version = "0.1.35"
dependencies = [
"globset",
"ide-core",
@@ -4461,7 +4496,7 @@ dependencies = [
[[package]]
name = "ide-run"
version = "0.1.33"
version = "0.1.35"
dependencies = [
"dunce",
"encoding_rs",
@@ -4484,7 +4519,7 @@ dependencies = [
[[package]]
name = "ide-sdk"
version = "0.1.33"
version = "0.1.35"
dependencies = [
"dunce",
"ide-core",
@@ -4503,7 +4538,7 @@ dependencies = [
[[package]]
name = "ide-search"
version = "0.1.33"
version = "0.1.35"
dependencies = [
"criterion",
"globset",
@@ -4523,7 +4558,7 @@ dependencies = [
[[package]]
name = "ide-spell"
version = "0.1.33"
version = "0.1.35"
dependencies = [
"ide-core",
"parking_lot",
@@ -4536,7 +4571,7 @@ dependencies = [
[[package]]
name = "ide-ssh"
version = "0.1.33"
version = "0.1.35"
dependencies = [
"dirs 6.0.0",
"flate2",
@@ -4555,7 +4590,7 @@ dependencies = [
[[package]]
name = "ide-syntax"
version = "0.1.33"
version = "0.1.35"
dependencies = [
"ide-core",
"ide-language",
@@ -4570,7 +4605,7 @@ dependencies = [
[[package]]
name = "ide-terminal"
version = "0.1.33"
version = "0.1.35"
dependencies = [
"ide-core",
"libc",
@@ -4583,7 +4618,7 @@ dependencies = [
[[package]]
name = "ide-text"
version = "0.1.33"
version = "0.1.35"
dependencies = [
"criterion",
"ide-core",
@@ -4595,7 +4630,7 @@ dependencies = [
[[package]]
name = "ide-tools"
version = "0.1.33"
version = "0.1.35"
dependencies = [
"flate2",
"hex",
@@ -4616,7 +4651,7 @@ dependencies = [
[[package]]
name = "ide-vcs"
version = "0.1.33"
version = "0.1.35"
dependencies = [
"dunce",
"encoding_rs",
@@ -4635,7 +4670,7 @@ dependencies = [
[[package]]
name = "ide-vfs"
version = "0.1.33"
version = "0.1.35"
dependencies = [
"chardetng",
"dunce",
@@ -4657,7 +4692,7 @@ dependencies = [
[[package]]
name = "ide-web"
version = "0.1.33"
version = "0.1.35"
dependencies = [
"ide-core",
"open",
@@ -4673,7 +4708,7 @@ dependencies = [
[[package]]
name = "ide-workspace"
version = "0.1.33"
version = "0.1.35"
dependencies = [
"blake3",
"dunce",
@@ -4683,6 +4718,7 @@ dependencies = [
"ide-text",
"ide-vfs",
"parking_lot",
"semver",
"serde",
"serde_json",
"specta",
@@ -6144,12 +6180,14 @@ dependencies = [
[[package]]
name = "openidea"
version = "0.1.33"
version = "0.1.35"
dependencies = [
"base64 0.22.1",
"blake3",
"block2 0.6.2",
"dirs 6.0.0",
"dunce",
"gtk",
"ide-agent",
"ide-analysis",
"ide-build",
@@ -6159,6 +6197,7 @@ dependencies = [
"ide-dap",
"ide-db",
"ide-docker",
"ide-extension",
"ide-fonts",
"ide-github",
"ide-host",
@@ -6190,6 +6229,8 @@ dependencies = [
"objc2-foundation 0.3.2",
"objc2-web-kit 0.3.2",
"parking_lot",
"percent-encoding",
"security-framework",
"serde",
"serde_json",
"specta",
@@ -6207,6 +6248,7 @@ dependencies = [
"tracing",
"tracing-appender",
"tracing-subscriber",
"windows-sys 0.61.2",
]
[[package]]
+6 -1
View File
@@ -1,9 +1,11 @@
[workspace]
resolver = "3"
members = ["crates/*", "src-tauri"]
# Plugins and the guest SDK build for wasm32-wasip2 in their own workspaces.
exclude = ["sdk", "plugins"]
[workspace.package]
version = "0.1.33"
version = "0.1.35"
edition = "2024"
rust-version = "1.96"
license = "Apache-2.0"
@@ -41,6 +43,8 @@ ide-ssh = { path = "crates/ide-ssh" }
ide-remote = { path = "crates/ide-remote" }
ide-host = { path = "crates/ide-host" }
ide-notebook = { path = "crates/ide-notebook" }
ide-extension = { path = "crates/ide-extension" }
ide-plugin = { path = "crates/ide-plugin" }
# serialization & IPC typing
serde = { version = "1", features = ["derive"] }
@@ -69,6 +73,7 @@ ropey = "1.6"
encoding_rs = "0.8"
chardetng = "0.1"
ignore = "0.4"
base64 = "0.22"
globset = "0.4"
notify = "8"
notify-debouncer-full = "0.6"
+8 -2
View File
@@ -2,8 +2,9 @@
## Project Lombok
`plugins/lombok/lombok-1.18.48-openidea.1.jar` contains Project Lombok v1.18.48 with the Equinox
classloader fix from [projectlombok/lombok#4059](https://github.com/projectlombok/lombok/pull/4059).
`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).
@@ -64,6 +65,11 @@ from its tests (`LineComparisonUtilTest`, `WordComparisonUtilTest`,
`SplitComparisonUtilTest`). Modified from the originals. Copyright 2000-2026
JetBrains s.r.o. and contributors. Apache License 2.0 (below).
`src/features/vcs/mergeDiff3.ts`, `chunkOptimizer.ts` and `mergeModel.ts` also adapt
the IntelliJ Platform merge implementation (`MergeRangeUtil`, `MergeResolveUtil`,
`ChunkOptimizer.LineChunkOptimizer` and `MergeConflictModel`). Modified from the
originals. Copyright 2000-2026 JetBrains s.r.o. and contributors. Apache License 2.0 (below).
`src/ui/openidea/**` holds OpenIDEA's own file-type icons drawn in the expui style for
languages whose JetBrains icons are not open source (Go, Ruby, Dart, Lua, Zig,
Svelte, …). Those using the page-with-corner-badge layout (`goMod`, `goWork`,
+24 -2
View File
@@ -1,7 +1,9 @@
//! Session settings (model, mode, thinking effort…), slash commands and context use, as
//! the agent reports them over ACP.
use crate::model::{AgentCommand, AgentConfigOption, AgentConfigValue, AgentConfigVia, AgentUsage};
use crate::model::{
AgentCommand, AgentConfigOption, AgentConfigValue, AgentConfigVia, AgentCost, AgentLimit, AgentUsage,
};
use serde_json::{Value, json};
/// Cursor exposes execution modes over ACP, but leaves approval policy to its client.
@@ -150,7 +152,27 @@ pub(crate) fn commands_of(update: &Value) -> Vec<AgentCommand> {
/// `usage_update`: `{ used, size }`.
pub(crate) fn usage_of(update: &Value) -> Option<AgentUsage> {
let tokens = |key: &str| update.get(key)?.as_f64().map(|n| n.clamp(0.0, f64::from(u32::MAX)) as u32);
Some(AgentUsage { used: tokens("used")?, size: tokens("size").filter(|s| *s > 0)? })
let cost = update.get("cost").and_then(|c| {
Some(Box::new(AgentCost {
amount: c.get("amount")?.as_f64().filter(|a| a.is_finite() && *a >= 0.0)?,
currency: c.get("currency")?.as_str().filter(|c| !c.is_empty())?.to_owned(),
}))
});
Some(AgentUsage { used: tokens("used")?, size: tokens("size").filter(|s| *s > 0)?, cost })
}
/// The usage-limit windows a [`crate::LIMITS_UPDATE`] carries; malformed entries are skipped.
pub(crate) fn limits_of(update: &Value) -> Vec<AgentLimit> {
let limits = update.get("limits").and_then(Value::as_array).into_iter().flatten();
limits
.filter_map(|l| {
Some(AgentLimit {
window: l.get("window")?.as_str().filter(|w| !w.is_empty())?.to_owned(),
used_percent: l.get("usedPercent")?.as_f64().filter(|p| p.is_finite())?.clamp(0.0, 100.0),
resets_ms: l.get("resetsMs").and_then(Value::as_f64),
})
})
.collect()
}
fn legacy(
+91 -16
View File
@@ -29,7 +29,7 @@ use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
use std::sync::{Arc, Weak};
use std::time::Duration;
@@ -44,11 +44,16 @@ const REPLAY_GRACE: Duration = Duration::from_millis(300);
/// can trail its `session/prompt` response by this much.
const TURN_GRACE: Duration = Duration::from_millis(100);
const STDERR_LINES: usize = 20;
/// An agent idle this long after its turn is stopped when it can reload its session
/// (`session/load`): the next message starts it again where it left off.
const IDLE_RELEASE: Duration = Duration::from_secs(5 * 60);
/// Updates of a session's state rather than its conversation.
const SESSION_STATE: [&str; 5] =
["session_info_update", "config_option_update", "current_mode_update", "available_commands_update", "usage_update"];
/// A native agent's own update: it is about to retry a failed API request (see [`AgentRetry`]).
pub(crate) const RETRY_UPDATE: &str = "openidea_retry";
/// A native agent's own update: the usage limits of its account (see [`AgentLimit`]).
pub(crate) const LIMITS_UPDATE: &str = "openidea_limits";
/// Referenced files up to this size are embedded in the prompt (when the agent takes it).
const MAX_EMBED: u64 = 256 * 1024;
/// How long a probe waits for the agent to answer `initialize`.
@@ -90,6 +95,8 @@ struct Shared {
providers: RwLock<Vec<provider::ProviderSpec>>,
/// Last probe of each provider.
probes: Mutex<HashMap<String, AgentProbe>>,
/// [`IDLE_RELEASE`], in milliseconds (shorter in tests).
idle_release_ms: AtomicU64,
}
/// What an agent offered in its last session, for the New Task page.
@@ -205,6 +212,8 @@ struct Live {
capabilities: AgentCapabilities,
/// `promptCapabilities.embeddedContext`: referenced files are sent with their text.
embedded: bool,
/// `session/load`: the process can be stopped between turns and the session reloaded.
resumable: bool,
}
struct Task {
@@ -226,6 +235,8 @@ struct Task {
cancel_pending: AtomicBool,
/// The agent reported something for the current turn.
turn_started: AtomicBool,
/// Turns sent so far: an idle release scheduled before the latest one is void.
turns: AtomicU64,
/// Settings already sent this session (`(option, value)`), so a refusal is not retried.
config_sent: Mutex<HashSet<(String, String)>>,
dirty: Mutex<BTreeSet<(Session, usize)>>,
@@ -236,6 +247,11 @@ struct Task {
}
impl AgentManager {
#[cfg(test)]
fn set_idle_release(&self, delay: Duration) {
self.shared.idle_release_ms.store(delay.as_millis() as u64, Ordering::Relaxed);
}
/// Loads the project's saved tasks. Spawned work runs on `runtime`.
pub fn new(paths: AgentPaths, runtime: tokio::runtime::Handle) -> Self {
let connector: Connector = Arc::new(|spec, cwd, client, on_stderr| match spec.driver {
@@ -260,6 +276,7 @@ impl AgentManager {
known: Mutex::new(known),
providers,
probes: Mutex::new(HashMap::new()),
idle_release_ms: AtomicU64::new(IDLE_RELEASE.as_millis() as u64),
});
let mut tasks = HashMap::new();
for file in std::fs::read_dir(&shared.paths.store_dir).into_iter().flatten().flatten() {
@@ -301,23 +318,41 @@ impl AgentManager {
let specs = provider::load(self.shared.paths.custom_agents_dir.as_deref());
let list = specs
.iter()
.map(|p| AgentProvider {
id: p.id.clone(),
name: p.name.clone(),
command: std::iter::once(p.command.as_str())
.chain(p.args.iter().map(String::as_str))
.collect::<Vec<_>>()
.join(" "),
installed: provider::installed(&p.command),
config: self.shared.known_config(&p.id),
commands: self.shared.known_commands(&p.id),
source: p.source.clone(),
.map(|p| {
let mut info = provider::describe(p);
info.config = self.shared.known_config(&p.id);
info.commands = self.shared.known_commands(&p.id);
info
})
.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
}
/// Overrides built-in `id`'s command with `command` (the driver and arguments stay
/// the built-in's) through its agent file, or returns it to auto-detection with `None`.
pub fn set_builtin_command(&self, id: &str, command: Option<String>) -> IdeResult<()> {
let Some(dir) = self.shared.paths.custom_agents_dir.clone() else {
return Err(IdeError::unavailable("there is no custom agents directory"));
};
provider::set_builtin_command(&dir, id, command.as_deref())
}
/// 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 +843,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;
@@ -867,6 +903,7 @@ impl Task {
send_now: AtomicBool::new(false),
cancel_pending: AtomicBool::new(false),
turn_started: AtomicBool::new(false),
turns: AtomicU64::new(0),
config_sent: Mutex::new(HashSet::new()),
dirty: Mutex::new(BTreeSet::new()),
flush_scheduled: AtomicBool::new(false),
@@ -1009,12 +1046,42 @@ impl Task {
}
match (next, result) {
(Some(queued), _) => prompt = queued.prompt,
(None, Ok(_)) => return self.set_status(AgentStatus::Ready, None),
(None, Ok(_)) => {
self.set_status(AgentStatus::Ready, None);
return self.release_when_idle();
}
(None, Err(failure)) => return self.fail(failure),
}
}
}
/// Stops the agent's process once the task has stayed idle for [`IDLE_RELEASE`], so
/// finished tasks don't keep an agent (and what it started) running. Only an agent that
/// reloads its session: the next message starts it again with the conversation intact.
fn release_when_idle(self: &Arc<Self>) {
let turn = self.turns.load(Ordering::Acquire);
let delay = Duration::from_millis(self.shared.idle_release_ms.load(Ordering::Relaxed));
let task = Arc::downgrade(self);
self.shared.runtime.spawn(async move {
tokio::time::sleep(delay).await;
let Some(task) = task.upgrade() else { return };
if task.turns.load(Ordering::Acquire) != turn
|| task.state.lock().busy
|| !task.permissions.lock().is_empty()
{
return;
}
let live = {
let mut live = task.live.lock();
if live.as_ref().is_some_and(|l| l.resumable && !l.peer.is_closed()) { live.take() } else { None }
};
if let Some(live) = live {
tracing::debug!("agent task {}: idle, stopping the agent until the next message", task.id);
live.peer.kill();
}
});
}
/// Ends a turn that failed: nothing it started stays open.
fn fail(self: &Arc<Self>, failure: Failure) {
self.cancel_permissions();
@@ -1075,6 +1142,7 @@ impl Task {
/// Sends one prompt, starting the agent first if needed; returns the stop reason.
async fn turn(self: &Arc<Self>, prompt: &AgentPrompt) -> Result<String, Failure> {
self.turns.fetch_add(1, Ordering::AcqRel);
self.turn_started.store(false, Ordering::Release);
self.state.lock().task.resets_ms = None;
let attachments = prompt
@@ -1271,7 +1339,7 @@ impl Task {
}
self.persist();
self.emit_task();
let live = Live { peer, session: session_id, capabilities, embedded };
let live = Live { peer, session: session_id, capabilities, embedded, resumable: can_load };
*self.live.lock() = Some(live.clone());
Ok(live)
}
@@ -1353,6 +1421,13 @@ impl Task {
}
return;
}
if kind == LIMITS_UPDATE {
if !self.replaying.load(Ordering::Acquire) {
let provider = self.state.lock().task.provider.clone();
self.shared.emit(AgentEvent::Limits { provider, limits: config::limits_of(update) });
}
return;
}
if kind == "session_info_update" {
// The agent's own title for the session replaces the first line of the prompt.
let title = update.get("title").and_then(Value::as_str).map(str::trim).filter(|t| !t.is_empty());
+49
View File
@@ -154,6 +154,30 @@ pub struct AgentUsage {
pub used: u32,
/// Size of the context window.
pub size: u32,
/// What the session has cost so far (ACP `usage_update.cost`), when the agent reports it.
// Boxed so that `AgentEvent::Task` stays within clippy's `large_enum_variant` bound on Windows.
#[serde(default)]
pub cost: Option<Box<AgentCost>>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct AgentCost {
pub amount: f64,
/// ISO 4217 code, e.g. `USD`.
pub currency: String,
}
/// One usage-limit window of an agent's account.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct AgentLimit {
/// The window as the agent names it: `five_hour`, `seven_day`, `seven_day_opus`…
pub window: String,
/// Share of the window used, 0–100.
pub used_percent: f64,
/// When the window resets (ms since the epoch), if the agent said.
pub resets_ms: Option<f64>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
@@ -322,6 +346,15 @@ pub struct AgentProvider {
pub name: String,
/// The command line that starts its ACP server.
pub command: String,
/// Executable and arguments actually launched, without flattening paths containing spaces.
pub program: String,
pub args: Vec<String>,
pub default_program: Option<String>,
pub default_args: Vec<String>,
pub login_hint: Option<String>,
pub command_overridden: bool,
pub command_editable: bool,
pub command_read_only_reason: Option<AgentCommandReadOnlyReason>,
/// Whether the command was found (on `PATH`).
pub installed: bool,
/// Settings (models, modes…) the agent offered last time it ran; empty before its first session.
@@ -331,6 +364,15 @@ pub struct AgentProvider {
pub source: AgentSource,
}
/// Why the executable cannot be changed in built-in agent settings.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum AgentCommandReadOnlyReason {
PackageRunner,
Registry,
CustomAgent,
}
/// Where an agent comes from.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(tag = "kind", rename_all = "camelCase")]
@@ -413,4 +455,11 @@ pub enum AgentEvent {
index: u32,
entry: AgentEntry,
},
/// An agent reported the usage limits of its account (shared by every task of `provider`).
Limits {
provider: String,
limits: Vec<AgentLimit>,
},
/// The installed or configured agent providers changed.
Providers,
}
@@ -98,6 +98,8 @@ impl Cli for Process {
use std::process::Stdio;
let mut cmd = tokio::process::Command::new(&l.program);
ide_core::process::prepare(cmd.as_std_mut());
// `claude` may be a wrapper (`claude.cmd` → node): stopping it stops what it started.
ide_core::process::own_group(cmd.as_std_mut());
cmd.args(&l.args)
.current_dir(&l.cwd)
.envs(ide_core::process::shell_env())
@@ -120,6 +122,9 @@ impl Cli for Process {
tokio::select! {
_ = child.wait() => {}
_ = token.cancelled() => {
if let Some(pid) = child.id() {
let _ = tokio::task::spawn_blocking(move || ide_core::process::kill_tree_of(pid)).await;
}
let _ = child.start_kill();
let _ = child.wait().await;
}
@@ -904,6 +904,7 @@ impl Session {
if info.get("status").and_then(Value::as_str) == Some("rejected") {
st.limit_resets_ms = info.get("resetsAt").and_then(Value::as_f64).map(|s| s * 1000.0);
}
updates.extend(st.tr.on_rate_limit(&info));
}
"command_lifecycle" => {
let uuid = msg.get("command_uuid").and_then(Value::as_str);
@@ -1013,6 +1013,39 @@ fn streamed_text_is_not_sent_twice() {
assert_eq!(usage[0].update, json!({"sessionUpdate": "usage_update", "used": 15, "size": 1000}));
}
#[test]
fn rate_limits_and_cost() {
let mut t = Translator::new("s", PathBuf::from("/w"), false);
let info = json!({"status": "allowed", "rateLimitType": "five_hour", "unifiedWindows": {
"five_hour": {"utilization": 0.19, "resetsAt": 100, "status": "allowed"},
"seven_day": {"utilization": 0.09, "resetsAt": 200, "status": "allowed"},
}});
let limits = t.on_rate_limit(&info).unwrap();
assert_eq!(limits.session, "s");
assert_eq!(limits.update["sessionUpdate"], crate::LIMITS_UPDATE);
let windows: Vec<(&str, i64, f64)> = limits.update["limits"]
.as_array()
.unwrap()
.iter()
.map(|l| {
(
l["window"].as_str().unwrap(),
l["usedPercent"].as_f64().unwrap().round() as i64,
l["resetsMs"].as_f64().unwrap(),
)
})
.collect();
assert_eq!(windows, [("five_hour", 19, 100_000.0), ("seven_day", 9, 200_000.0)]);
// Older versions report the window that applies, alone.
let single = t.on_rate_limit(&json!({"rateLimitType": "seven_day", "utilization": 0.5})).unwrap();
assert_eq!(single.update["limits"], json!([{"window": "seven_day", "usedPercent": 50.0, "resetsMs": null}]));
assert_eq!(t.on_rate_limit(&json!({"status": "allowed", "resetsAt": 100})), None, "no utilization, no update");
t.used = Some(10);
let usage = t.on_result(&json!({"total_cost_usd": 0.5, "modelUsage": {"x": {"contextWindow": 1000}}}));
assert_eq!(usage[0].update["cost"], json!({"amount": 0.5, "currency": "USD"}));
}
#[test]
fn tool_calls_plans_and_subagents() {
let mut t = Translator::new("s", PathBuf::from("/w"), true);
@@ -5,7 +5,7 @@
//! Agent tool use that launched them: their messages carry it as `parent_tool_use_id`.
use super::tools;
use crate::RETRY_UPDATE;
use crate::{LIMITS_UPDATE, RETRY_UPDATE};
use serde_json::{Value, json};
use std::collections::{HashMap, VecDeque};
use std::path::PathBuf;
@@ -58,6 +58,8 @@ pub(super) struct Translator {
pub used: Option<u64>,
pub model: Option<String>,
windows: HashMap<String, u64>,
/// The session's cost so far in USD, as the last `result` reported it.
cost_usd: Option<f64>,
}
impl Translator {
@@ -75,6 +77,7 @@ impl Translator {
used: None,
model: None,
windows: HashMap::new(),
cost_usd: None,
}
}
@@ -169,7 +172,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));
}
}
@@ -543,6 +546,9 @@ impl Translator {
self.windows.insert(model.clone(), w);
}
}
if let Some(cost) = result.get("total_cost_usd").and_then(Value::as_f64).filter(|c| c.is_finite()) {
self.cost_usd = Some(cost);
}
self.usage_update().into_iter().collect()
}
@@ -557,9 +563,33 @@ impl Translator {
.or_else(|| self.windows.iter().find(|(k, _)| k.starts_with(m.as_str())).map(|(_, v)| v))
})
.or_else(|| (self.windows.len() == 1).then(|| self.windows.values().next()).flatten())?;
Some(Update {
let mut update = json!({ "sessionUpdate": "usage_update", "used": used, "size": size });
if let Some(amount) = self.cost_usd {
update["cost"] = json!({ "amount": amount, "currency": "USD" });
}
Some(Update { session: self.root.clone(), update })
}
/// The usage-limit windows a `rate_limit_event` reports, as a [`LIMITS_UPDATE`]: every
/// window of `unifiedWindows`, or the single `rateLimitType` window older versions send.
pub fn on_rate_limit(&self, info: &Value) -> Option<Update> {
let window = |name: &str, w: &Value| {
let utilization = w.get("utilization")?.as_f64()?;
Some(json!({
"window": name,
"usedPercent": utilization * 100.0,
"resetsMs": w.get("resetsAt").and_then(Value::as_f64).map(|s| s * 1000.0),
}))
};
let limits: Vec<Value> = match info.get("unifiedWindows").and_then(Value::as_object) {
Some(windows) => windows.iter().filter_map(|(name, w)| window(name, w)).collect(),
None => {
info.get("rateLimitType").and_then(Value::as_str).and_then(|t| window(t, info)).into_iter().collect()
}
};
(!limits.is_empty()).then(|| Update {
session: self.root.clone(),
update: json!({ "sessionUpdate": "usage_update", "used": used, "size": size }),
update: json!({ "sessionUpdate": LIMITS_UPDATE, "limits": limits }),
})
}
}
+227 -5
View File
@@ -2,7 +2,8 @@
//! `<config>/agents/<id>.toml` (written by hand, or by installing one from the ACP
//! Registry, see `registry`).
use crate::AgentSource;
use crate::{AgentCommandReadOnlyReason, AgentProvider, AgentSource};
use ide_core::{IdeError, IdeResult};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
@@ -80,9 +81,92 @@ 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)
}
/// The built-in spec of `id`.
pub(crate) fn builtin_spec(id: &str) -> Option<ProviderSpec> {
builtins().into_iter().find(|s| s.id == id)
}
/// Public launch metadata, keeping the built-in defaults separate from agent-file overrides.
pub(crate) fn describe(spec: &ProviderSpec) -> AgentProvider {
let builtin = builtin_spec(&spec.id);
let reason = if matches!(spec.source, AgentSource::Registry { .. }) {
Some(AgentCommandReadOnlyReason::Registry)
} else if let Some(default) = &builtin {
matches!(default.command.as_str(), "npx" | "bunx" | "uvx").then_some(AgentCommandReadOnlyReason::PackageRunner)
} else {
Some(AgentCommandReadOnlyReason::CustomAgent)
};
AgentProvider {
id: spec.id.clone(),
name: spec.name.clone(),
command: std::iter::once(spec.command.as_str())
.chain(spec.args.iter().map(String::as_str))
.collect::<Vec<_>>()
.join(" "),
program: spec.command.clone(),
args: spec.args.clone(),
default_program: builtin.as_ref().map(|b| b.command.clone()),
default_args: builtin.as_ref().map(|b| b.args.clone()).unwrap_or_default(),
login_hint: Some(spec.login_hint.clone()),
command_overridden: builtin.as_ref().is_some_and(|b| b.command != spec.command),
command_editable: reason.is_none(),
command_read_only_reason: reason,
installed: installed(&spec.command),
config: vec![],
commands: vec![],
source: spec.source.clone(),
}
}
/// Overrides built-in `id`'s command through its agent file, preserving other
/// customizations. `None` restores the built-in command and removes a redundant file.
pub(crate) fn set_builtin_command(custom_dir: &Path, id: &str, command: Option<&str>) -> IdeResult<()> {
let Some(builtin) = builtin_spec(id) else {
return Err(IdeError::invalid(format!("{id} is not a built-in agent")));
};
let file = custom_dir.join(format!("{id}.toml"));
let mut agent = if file.try_exists()? {
CustomAgent::read(&file).map_err(IdeError::invalid)?
} else {
CustomAgent {
name: Some(builtin.name.clone()),
driver: builtin.driver,
command: builtin.command.clone(),
args: builtin.args.clone(),
env: BTreeMap::new(),
registry: None,
}
};
if agent.registry.is_some() {
return Err(IdeError::invalid(format!("{id} is installed from the ACP Registry")));
}
if command.is_none()
&& agent.name.as_deref() == Some(builtin.name.as_str())
&& agent.driver == builtin.driver
&& agent.args == builtin.args
&& agent.env.is_empty()
{
match std::fs::remove_file(&file) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e.into()),
_ => {}
}
return Ok(());
}
agent.command = command.unwrap_or(&builtin.command).to_owned();
agent.write(&file)?;
Ok(())
}
/// An agent file, `<config>/agents/<id>.toml`.
@@ -162,7 +246,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 +257,13 @@ 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();
if spec.source == AgentSource::Custom {
spec.source = AgentSource::Builtin;
}
*existing = spec;
}
None => specs.push(spec),
}
}
@@ -211,6 +301,54 @@ fn is_executable(p: &Path) -> bool {
mod tests {
use super::*;
#[test]
fn launch_metadata_distinguishes_defaults_overrides_and_read_only_agents() {
let dir = tempfile::tempdir().unwrap();
let list = || load(Some(dir.path())).iter().map(describe).collect::<Vec<_>>();
let defaults = list();
let codex = defaults.iter().find(|p| p.id == "codex").unwrap();
assert_eq!(codex.program, "codex");
assert_eq!(codex.default_program.as_deref(), Some("codex"));
assert_eq!(codex.args, ["app-server"]);
assert_eq!(codex.default_args, codex.args);
assert_eq!(codex.command, "codex app-server");
assert_eq!(codex.login_hint.as_deref(), Some("Run `codex login` in a terminal."));
assert!(!codex.command_overridden);
assert!(codex.command_editable);
for p in &defaults {
assert_eq!(p.source, AgentSource::Builtin);
assert!(p.default_program.is_some());
assert_eq!(p.command_editable, !p.id.ends_with("-acp"));
assert_eq!(
p.command_read_only_reason,
p.id.ends_with("-acp").then_some(AgentCommandReadOnlyReason::PackageRunner)
);
}
set_builtin_command(dir.path(), "codex", Some("/path with spaces/codex")).unwrap();
let overridden = list().into_iter().find(|p| p.id == "codex").unwrap();
assert_eq!(overridden.program, "/path with spaces/codex");
assert_eq!(overridden.args, codex.args);
assert_eq!(overridden.default_program, codex.default_program);
assert!(overridden.command_overridden && overridden.command_editable);
assert_eq!(overridden.source, AgentSource::Builtin);
set_builtin_command(dir.path(), "codex", Some("codex")).unwrap();
assert!(!list().iter().find(|p| p.id == "codex").unwrap().command_overridden);
std::fs::write(
dir.path().join("gemini.toml"),
"command = \"/registry/gemini\"\n[registry]\nid = \"gemini\"\nversion = \"1.0\"\n",
)
.unwrap();
let registry = list().into_iter().find(|p| p.id == "gemini").unwrap();
assert_eq!(registry.default_program.as_deref(), Some("gemini"));
assert!(!registry.command_editable);
assert_eq!(registry.command_read_only_reason, Some(AgentCommandReadOnlyReason::Registry));
assert!(matches!(registry.source, AgentSource::Registry { .. }));
std::fs::write(dir.path().join("mine.toml"), "command = \"mine\"\n").unwrap();
let custom = list().into_iter().find(|p| p.id == "mine").unwrap();
assert_eq!(custom.default_program, None);
assert_eq!(custom.command_read_only_reason, Some(AgentCommandReadOnlyReason::CustomAgent));
}
#[test]
fn custom_agents_extend_and_override_the_builtins() {
let dir = tempfile::tempdir().unwrap();
@@ -230,6 +368,90 @@ mod tests {
assert!(installed("sh"));
}
#[test]
fn set_builtin_command_overrides_and_clears_a_builtin() {
let dir = tempfile::tempdir().unwrap();
set_builtin_command(dir.path(), "not-a-builtin", Some("/x")).unwrap_err();
set_builtin_command(dir.path(), "claude", Some("/opt/claude")).unwrap();
let claude = load(Some(dir.path())).into_iter().find(|s| s.id == "claude").unwrap();
assert_eq!((claude.command.as_str(), claude.driver), ("/opt/claude", Driver::Claude));
assert!(claude.args.is_empty());
// A registry install is neither overwritten nor removed by a command override.
let path = dir.path().join("codex.toml");
CustomAgent {
name: None,
driver: Driver::Acp,
command: "amp".into(),
args: vec![],
env: BTreeMap::new(),
registry: Some(RegistryRef { id: "amp-acp".into(), version: "1.0.0".into() }),
}
.write(&path)
.unwrap();
set_builtin_command(dir.path(), "codex", Some("/x")).unwrap_err();
set_builtin_command(dir.path(), "codex", None).unwrap_err();
assert!(path.exists());
// Clearing (twice) returns the built-in to auto-detection.
set_builtin_command(dir.path(), "claude", None).unwrap();
set_builtin_command(dir.path(), "claude", None).unwrap();
assert!(!dir.path().join("claude.toml").exists());
assert_eq!(load(Some(dir.path())).iter().find(|s| s.id == "claude").unwrap().command, "claude");
}
#[test]
fn set_builtin_command_preserves_other_customizations_on_save_and_reset() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("claude.toml");
for field in ["name", "driver", "args", "env", "unnamed"] {
let mut agent = CustomAgent {
name: Some("Claude Code".into()),
driver: Driver::Claude,
command: "/old/claude".into(),
args: vec![],
env: BTreeMap::new(),
registry: None,
};
match field {
"name" => agent.name = Some("My Claude".into()),
"driver" => agent.driver = Driver::Acp,
"args" => agent.args.push("--verbose".into()),
"env" => {
agent.env.insert("K".into(), "v".into());
}
"unnamed" => agent.name = None,
_ => unreachable!(),
}
agent.write(&path).unwrap();
set_builtin_command(dir.path(), "claude", Some("/new/claude")).unwrap();
agent.command = "/new/claude".into();
assert_eq!(CustomAgent::read(&path).unwrap(), agent, "{field}");
set_builtin_command(dir.path(), "claude", None).unwrap();
agent.command = "claude".into();
assert_eq!(CustomAgent::read(&path).unwrap(), agent, "{field}");
}
}
#[test]
fn resetting_a_command_only_override_removes_the_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("codex.toml");
std::fs::write(
&path,
"name = \"Codex\"\ndriver = \"codex\"\ncommand = \"/custom/codex\"\nargs = [\"app-server\"]\n",
)
.unwrap();
set_builtin_command(dir.path(), "codex", None).unwrap();
assert!(!path.exists());
assert_eq!(
load(Some(dir.path())).into_iter().find(|s| s.id == "codex").unwrap(),
builtin_spec("codex").unwrap()
);
}
#[test]
fn agent_files_round_trip_with_their_registry_table() {
let dir = tempfile::tempdir().unwrap();
+70 -12
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}")));
}
@@ -812,15 +826,7 @@ mod tests {
let mut providers: Vec<AgentProvider> = provider::load(Some(&p.agents_dir))
.into_iter()
.filter(|s| s.id == "amp-acp")
.map(|s| AgentProvider {
id: s.id,
name: s.name,
command: s.command,
installed: false,
config: vec![],
commands: vec![],
source: s.source,
})
.map(|s| provider::describe(&s))
.collect();
mark_updates(&p, &mut providers);
assert_eq!(
@@ -946,4 +952,56 @@ 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![provider::describe(&spec)];
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);
}
}
+169 -2
View File
@@ -9,6 +9,8 @@ struct FakeAgent {
/// Every connection's agent side, kept open.
servers: Mutex<Vec<Peer>>,
calls: Mutex<Vec<String>>,
/// Without `loadSession` (a session lives only as long as its process).
no_load: bool,
}
impl FakeAgent {
@@ -89,7 +91,7 @@ impl Handler for FakeAgent {
if params.pointer("/clientCapabilities/subagents").is_some_and(Value::is_object) {
self.calls.lock().push("subagents".into());
}
Ok(json!({"protocolVersion": 1, "agentCapabilities": {"loadSession": true,
Ok(json!({"protocolVersion": 1, "agentCapabilities": {"loadSession": !self.no_load,
"sessionCapabilities": {"fork": {}}, "_meta": {"openidea": {"rollback": true, "steer": true}}}}))
}
"session/new" => Ok(json!({"sessionId": "s1",
@@ -145,7 +147,16 @@ impl Handler for FakeAgent {
json!({"sessionUpdate": "available_commands_update",
"availableCommands": [{"name": "review", "description": "Review", "input": {"hint": "focus"}}]}),
);
self.update(&session, json!({"sessionUpdate": "usage_update", "used": 50, "size": 1000}));
self.update(
&session,
json!({"sessionUpdate": "usage_update", "used": 50, "size": 1000,
"cost": {"amount": 0.25, "currency": "USD"}}),
);
self.update(
&session,
json!({"sessionUpdate": LIMITS_UPDATE,
"limits": [{"window": "five_hour", "usedPercent": 19.0, "resetsMs": 5000.0}]}),
);
self.update(&session, json!({"sessionUpdate": "session_info_update", "title": "Lexer fix"}));
self.say(&session, "Hi");
self.update(
@@ -358,6 +369,10 @@ async fn a_task_runs_asks_for_permission_and_survives_a_restart() {
assert_eq!((modes.via, modes.current.as_deref()), (AgentConfigVia::Mode, Some("ask")));
assert_eq!(t.commands.iter().map(|c| c.name.as_str()).collect::<Vec<_>>(), ["review"]);
assert_eq!(t.usage.as_ref().map(|u| (u.used, u.size)), Some((50, 1000)));
assert_eq!(
t.usage.as_ref().and_then(|u| u.cost.clone()),
Some(Box::new(AgentCost { amount: 0.25, currency: "USD".into() }))
);
assert_eq!(t.title, "Lexer fix", "the agent's title replaces the prompt's first line");
}
mgr.set_config(&task.id, "mode", "code".into()).await.unwrap();
@@ -370,6 +385,18 @@ async fn a_task_runs_asks_for_permission_and_survives_a_restart() {
assert!(
events.iter().any(|e| matches!(e, AgentEvent::Task { task } if task.status == AgentStatus::NeedsInput))
);
let limits = events.iter().find_map(|e| match e {
AgentEvent::Limits { provider, limits } => Some((provider.as_str(), limits.clone())),
_ => None,
});
assert_eq!(
limits,
Some((
task.provider.as_str(),
vec![AgentLimit { window: "five_hour".into(), used_percent: 19.0, resets_ms: Some(5000.0) }]
)),
"an agent's usage limits are its provider's"
);
let last_entry = events.iter().rev().find_map(|e| match e {
AgentEvent::Entry { index, entry, .. } if !matches!(entry, AgentEntry::User { .. }) => {
Some((*index, entry.clone()))
@@ -772,6 +799,18 @@ fn retry_updates_become_a_countdown() {
assert_eq!(retry_of(&json!({"sessionUpdate": RETRY_UPDATE}), 0.0), None, "no attempt");
}
#[test]
fn usage_limits_and_cost_skip_what_is_malformed() {
let limits = config::limits_of(&json!({"sessionUpdate": LIMITS_UPDATE, "limits": [
{"window": "seven_day", "usedPercent": 140.0},
{"window": "", "usedPercent": 10.0},
{"window": "five_hour"},
]}));
assert_eq!(limits, [AgentLimit { window: "seven_day".into(), used_percent: 100.0, resets_ms: None }]);
let usage = config::usage_of(&json!({"used": 1, "size": 2, "cost": {"amount": -1.0, "currency": "USD"}}));
assert_eq!(usage.map(|u| u.cost), Some(None), "a negative cost is dropped, the context use kept");
}
#[tokio::test(flavor = "multi_thread")]
async fn cursor_approval_switch_resolves_pending_and_survives_restart() {
let tmp = tempfile::tempdir().unwrap();
@@ -942,3 +981,131 @@ 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());
}
#[tokio::test(flavor = "multi_thread")]
async fn idle_agents_stop_and_resume_their_session_on_the_next_message() {
let new = || NewAgentTask {
project_root: None,
prompt: text("again"),
provider: "claude".into(),
config: Vec::new(),
isolation: Isolation::Local,
base: None,
};
let live =
|mgr: &AgentManager, id: &str| mgr.task(id).unwrap().live.lock().as_ref().is_some_and(|l| !l.peer.is_closed());
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent::default());
let mgr = manager(tmp.path(), agent.clone());
mgr.set_idle_release(Duration::from_millis(200));
let task = mgr.create(new()).await.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
assert!(live(&mgr, &task.id));
// The process is stopped once the task stays idle…
tokio::time::sleep(Duration::from_millis(600)).await;
assert!(!live(&mgr, &task.id));
assert!(agent.servers.lock()[0].is_closed(), "its connection is closed");
assert_eq!(mgr.task(&task.id).unwrap().state.lock().task.status, AgentStatus::Ready, "not an error");
// …and the next message starts it again on the same session.
mgr.prompt(&task.id, text("again"), false).await.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
assert!(agent.calls.lock().contains(&"session/load".to_owned()));
assert_eq!(
mgr.entries(&task.id, None).unwrap().iter().filter(|e| matches!(e, AgentEntry::Agent { .. })).count(),
2
);
// A new turn before the delay ran out keeps the agent.
mgr.prompt(&task.id, text("again"), false).await.unwrap();
tokio::time::sleep(Duration::from_millis(100)).await;
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
assert!(live(&mgr, &task.id));
mgr.shutdown();
// An agent that can't reload its session keeps running: stopping it would lose the conversation.
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent { no_load: true, ..FakeAgent::default() });
let mgr = manager(tmp.path(), agent.clone());
mgr.set_idle_release(Duration::from_millis(200));
let task = mgr.create(new()).await.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
tokio::time::sleep(Duration::from_millis(600)).await;
assert!(live(&mgr, &task.id));
mgr.shutdown();
}
+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
+44 -4
View File
@@ -158,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 {
@@ -185,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 {
@@ -735,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()
@@ -749,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();
@@ -814,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());
+40
View File
@@ -0,0 +1,40 @@
// OpenIDEA Gradle sync: prints each project as Gradle evaluated it, one JSON line per project.
// Read by `ide_build::gradle::parse_reported_projects`. Dependencies are not resolved: the
// Java language server reads the classpath itself, and resolving is the slow part of an import.
import groovy.json.JsonOutput
def canon = { File f -> f.canonicalPath }
// Init scripts also run in included builds and buildSrc, whose projects reuse `:`, `:core`…:
// the identity path (`:lib:core`, `:buildSrc`) tells them apart and addresses their tasks
// from the root build. Internal API, hence the fallback.
def identity = { Project p -> try { p.identityPath.toString() } catch (ignored) { p.path } }
def describe = { Project p ->
def m = [path: identity(p), dir: canon(p.projectDir), buildDir: canon(p.layout.buildDirectory.get().asFile)]
if (p.gradle.parent != null) m.build = identity(p.rootProject)
def sets = p.extensions.findByName('sourceSets')
if (sets != null) {
// Test source sets as IDEA classifies them: `test`, test fixtures, JVM test suites,
// and sets whose folders the `idea` plugin lists as test sources.
def suites = [] as Set
try { p.extensions.findByName('testing')?.suites?.each { suites << it.sources.name } } catch (ignored) {}
def ideaTests = [] as Set
try { p.extensions.findByName('idea')?.module?.testSources?.files?.each { ideaTests << canon(it) } } catch (ignored) {}
m.sourceSets = sets.collect { s ->
def res = s.resources.srcDirs.collect(canon)
def src = s.allSource.srcDirs.collect(canon) - res
def test = s.name == 'test' || s.name == 'testFixtures' || suites.contains(s.name) ||
s.name.endsWith('Test') || s.name.endsWith('Tests') ||
(!src.isEmpty() && src.every { ideaTests.contains(it) })
[name: s.name, test: test, src: src, res: res]
}
}
try {
def java = p.extensions.findByName('java')
def lv = java?.toolchain?.languageVersion?.getOrNull()
m.java = lv != null ? lv.asInt() : (java?.targetCompatibility?.majorVersion as Integer)
} catch (ignored) {}
println '@openidea-model\t' + JsonOutput.toJson(m)
}
gradle.projectsEvaluated { g -> g.rootProject.allprojects { describe(it) } }
+340 -37
View File
@@ -32,7 +32,8 @@ pub struct GradleModel {
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct GradleProject {
/// Gradle path: `:` for the root project, `:app`, `:libs:core`.
/// Gradle path: `:` for the root project, `:app`, `:libs:core`; for a project of a
/// nested build its identity path (`:lib:core`), which addresses its tasks from the root.
pub path: String,
pub name: String,
/// Project-relative directory.
@@ -40,6 +41,10 @@ pub struct GradleProject {
/// Project-relative build script, if any.
pub build_file: Option<String>,
pub tasks: Vec<GradleTask>,
/// Identity path of the nested build holding the project (`:lib` for `includeBuild`,
/// `:buildSrc`); null for the opened build.
#[serde(default)]
pub build: Option<String>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
@@ -129,35 +134,88 @@ fn include_args(after: &str) -> Vec<String> {
out
}
/// `(root project name, included project paths)` from a settings script.
pub fn parse_settings(text: &str) -> (Option<String>, Vec<String>) {
/// What a settings script declares, read statically.
#[derive(Debug, Default, PartialEq)]
pub struct Settings {
pub name: Option<String>,
/// Included project paths (`:app`), in declaration order.
pub includes: Vec<String>,
/// Project directories that differ from the default `<path>` under the build
/// dir, relative to it: `includeFlat` siblings and literal `projectDir = file(..)`.
pub dirs: BTreeMap<String, String>,
}
/// The included projects, root project name and directory overrides of a settings script.
/// Interpolated (`"$m"`) names and directories cannot be read without Gradle and are skipped.
pub fn parse_settings(text: &str) -> Settings {
let text = strip_comments(text);
let mut name = None;
let mut includes: Vec<String> = Vec::new();
let mut s = Settings::default();
for (i, _) in text.match_indices("rootProject.name") {
let line = text[i + "rootProject.name".len()..].lines().next().unwrap_or_default();
if let Some(n) = literals(line).into_iter().next() {
name = Some(n);
s.name = Some(n);
}
}
let mut from = 0;
while let Some(i) = text[from..].find("include") {
let at = from + i;
from = at + "include".len();
let flat = text[from..].starts_with("Flat");
if flat {
from += "Flat".len();
}
let before_ok = at == 0 || !text[..at].ends_with(|c: char| c.is_alphanumeric() || c == '_' || c == '.');
let after = &text[from..];
let after_ok = after.starts_with(|c: char| c == '(' || c.is_whitespace());
if !before_ok || !after_ok {
continue;
}
for p in include_args(after) {
for p in include_args(after).into_iter().filter(|p| !p.contains('$')) {
let path = if p.starts_with(':') { p } else { format!(":{p}") };
if path.len() > 1 && !includes.contains(&path) {
includes.push(path);
if path.len() <= 1 {
continue;
}
// `includeFlat 'a'` is the sibling directory `../a` of the root project.
if flat {
s.dirs.insert(path.clone(), format!("../{}", path.trim_start_matches(':')));
}
if !s.includes.contains(&path) {
s.includes.push(path);
}
}
}
(name, includes)
// `project(':a').projectDir = file('../a')`; a later assignment wins, as in Gradle.
for line in text.lines() {
let Some((left, right)) = line.split_once(".projectDir") else { continue };
let Some(right) = right.trim_start().strip_prefix('=') else { continue };
let Some(arg) = right.trim_start().strip_prefix("file") else { continue };
let (Some(path), Some(dir)) = (
left.rsplit_once("project").map(|(_, p)| literals(p)).and_then(|l| l.into_iter().next()),
literals(arg).into_iter().next(),
) else {
continue;
};
if !path.contains('$') && !dir.contains('$') {
let path = if path.starts_with(':') { path } else { format!(":{path}") };
s.dirs.insert(path, dir);
}
}
s
}
/// `a/b/../c` → `a/c`, without touching the filesystem.
fn normalize(p: &Path) -> PathBuf {
let mut out = PathBuf::new();
for c in p.components() {
match c {
std::path::Component::CurDir => {}
std::path::Component::ParentDir if out.file_name().is_some() => {
out.pop();
}
c => out.push(c),
}
}
out
}
/// The directory of the Gradle build under `root`: the root itself, or a direct
@@ -235,31 +293,159 @@ pub fn default_tasks(is_root: bool, script: &str) -> Vec<GradleTask> {
}
/// Reads the Gradle build under `root` (no Gradle run), or `None` when there is none.
fn read_first(d: &Path, names: &[&str]) -> Option<(String, String)> {
names.iter().find_map(|n| std::fs::read_to_string(d.join(n)).ok().map(|t| (n.to_string(), t)))
}
/// The project `path` in `pdir`, with the default tasks of its build script.
/// Outside `root` the directory stays absolute (`rel` falls back).
fn project_at(root: &Path, path: String, name: String, pdir: &Path) -> GradleProject {
let script = read_first(pdir, BUILD_FILES);
let tasks = default_tasks(path == ":", script.as_ref().map(|(_, t)| t.as_str()).unwrap_or_default());
GradleProject {
build_file: script.map(|(n, _)| rel(root, &pdir.join(n))),
dir: rel(root, pdir),
path,
name,
tasks,
build: None,
}
}
/// A project as Gradle evaluated it (the sync's init script, `gradle-model.gradle`).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ReportedProject {
pub path: String,
/// Absolute, canonical.
pub dir: String,
/// Absolute output folder (`layout.buildDirectory`).
#[serde(default)]
pub build_dir: Option<String>,
#[serde(default)]
pub source_sets: Vec<ReportedSourceSet>,
/// Toolchain language version, else target compatibility.
#[serde(default)]
pub java: Option<u32>,
/// Identity path of the nested build (included build, buildSrc) reporting the project.
#[serde(default)]
pub build: Option<String>,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct ReportedSourceSet {
pub name: String,
pub test: bool,
/// Absolute source folders (Java, Kotlin, Groovy, …), which need not exist.
#[serde(default)]
pub src: Vec<String>,
/// Absolute resource folders.
#[serde(default)]
pub res: Vec<String>,
}
const MODEL_LINE: &str = "@openidea-model\t";
/// The projects printed by `gradle-model.gradle`; malformed lines are skipped.
pub fn parse_reported_projects(output: &str) -> Vec<ReportedProject> {
output.lines().filter_map(|l| serde_json::from_str(l.trim_end().strip_prefix(MODEL_LINE)?).ok()).collect()
}
/// Source roots of the reported source sets: `(absolute folder, is test, is resources)`.
pub fn reported_source_roots(reported: &[ReportedProject]) -> Vec<(PathBuf, ReportedRoot)> {
let mut out: Vec<(PathBuf, ReportedRoot)> = Vec::new();
for p in reported {
for set in &p.source_sets {
let (src, res) = if set.test {
(ReportedRoot::Test, ReportedRoot::TestResources)
} else {
(ReportedRoot::Source, ReportedRoot::Resources)
};
for (dir, kind) in set.src.iter().map(|d| (d, src)).chain(set.res.iter().map(|d| (d, res))) {
let dir = PathBuf::from(dir);
// A folder shared by `main` and another set is `main`'s, as in IDEA.
if !out.iter().any(|(d, _)| *d == dir) {
out.push((dir, kind));
}
}
}
// IDEA excludes the build folder of every module.
if let Some(b) = &p.build_dir {
out.push((PathBuf::from(b), ReportedRoot::Excluded));
}
}
out
}
/// Role of a folder a Gradle sync reported.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ReportedRoot {
Source,
Test,
Resources,
TestResources,
Excluded,
}
/// Replaces the statically read projects with the ones Gradle reported (root first):
/// settings scripts may compute their projects, which no static reading sees.
pub fn merge_reported_projects(root: &Path, model: &mut GradleModel, reported: &[ReportedProject]) {
if reported.is_empty() {
return;
}
let root_name = model.projects.iter().find(|p| p.path == ":").map(|p| p.name.clone());
model.projects = reported
.iter()
.map(|p| {
let name = match p.path.as_str() {
":" => root_name.clone().unwrap_or_default(),
path => path.rsplit(':').next().unwrap_or_default().to_owned(),
};
let mut project = project_at(root, p.path.clone(), name, Path::new(&p.dir));
// buildSrc is built before the build, never addressable as `:buildSrc:<task>`.
if p.build.as_deref().is_some_and(|b| b.ends_with(":buildSrc")) {
project.tasks.clear();
}
project.build = p.build.clone();
project
})
.collect();
// Each build reports its projects when it is evaluated (nested builds first).
model.projects.sort_by(|a, b| a.path.cmp(&b.path));
// One JDK runs the whole build: the newest any module needs.
if let Some(v) = reported.iter().filter_map(|p| p.java).max() {
model.java_version = Some(v);
}
}
/// Project directories outside `root`, outermost only (`../svc`, not also `../svc/sources/app`):
/// the folders IDEA shows as extra top-level module roots.
pub fn external_roots<'a>(root: &Path, dirs: impl IntoIterator<Item = &'a str>) -> Vec<PathBuf> {
let mut dirs: Vec<PathBuf> = dirs.into_iter().map(|d| root.join(d)).filter(|d| !d.starts_with(root)).collect();
dirs.sort();
dirs.dedup();
let mut out: Vec<PathBuf> = Vec::new();
for d in dirs {
if !out.iter().any(|o| d.starts_with(o)) {
out.push(d);
}
}
out
}
pub fn load(root: &Path) -> Option<GradleModel> {
let dir = build_dir(root)?;
let read = |d: &Path, names: &[&str]| {
names.iter().find_map(|n| std::fs::read_to_string(d.join(n)).ok().map(|t| (n.to_string(), t)))
};
let (name, includes) = read(&dir, SETTINGS_FILES).map(|(_, t)| parse_settings(&t)).unwrap_or_default();
let settings = read_first(&dir, SETTINGS_FILES).map(|(_, t)| parse_settings(&t)).unwrap_or_default();
let root_name =
name.unwrap_or_else(|| dir.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default());
let mut projects = Vec::new();
let mut add = |path: String, name: String, pdir: PathBuf| {
let script = read(&pdir, BUILD_FILES);
let tasks = default_tasks(path == ":", script.as_ref().map(|(_, t)| t.as_str()).unwrap_or_default());
projects.push(GradleProject {
build_file: script.map(|(n, _)| rel(root, &pdir.join(n))),
dir: rel(root, &pdir),
path,
name,
tasks,
});
};
add(":".into(), root_name, dir.clone());
for path in includes {
let pdir = dir.join(path.trim_start_matches(':').replace(':', "/"));
settings.name.unwrap_or_else(|| dir.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default());
let mut projects = vec![project_at(root, ":".into(), root_name, &dir)];
for path in settings.includes {
let pdir = match settings.dirs.get(&path) {
Some(d) => normalize(&dir.join(d)),
None => dir.join(path.trim_start_matches(':').replace(':', "/")),
};
let name = path.rsplit(':').next().unwrap_or_default().to_owned();
add(path, name, pdir);
projects.push(project_at(root, path, name, &pdir));
}
let java_version = projects
.iter()
@@ -358,9 +544,32 @@ pub fn build_files(root: &Path, model: &GradleModel) -> Vec<PathBuf> {
let dir = root.join(&model.dir);
let mut out: Vec<PathBuf> = SETTINGS_FILES.iter().map(|f| dir.join(f)).filter(|p| p.is_file()).collect();
out.extend(model.projects.iter().filter_map(|p| p.build_file.as_ref().map(|f| root.join(f))));
// The settings of each nested build (its root project's folder).
for p in model.projects.iter().filter(|p| p.build.as_deref() == Some(p.path.as_str())) {
let nested = root.join(&p.dir);
out.extend(SETTINGS_FILES.iter().map(|f| nested.join(f)).filter(|f| f.is_file()));
}
// What IDEA's auto-import also watches: properties, the wrapper and version catalogs.
let extra = ["gradle.properties", "gradle/wrapper/gradle-wrapper.properties"].map(|f| dir.join(f));
out.extend(extra.into_iter().filter(|p| p.is_file()));
if let Ok(entries) = std::fs::read_dir(dir.join("gradle")) {
let mut catalogs: Vec<PathBuf> = entries
.flatten()
.map(|e| e.path())
.filter(|p| p.to_string_lossy().ends_with(".versions.toml") && p.is_file())
.collect();
catalogs.sort();
out.extend(catalogs);
}
out
}
/// A build script without comments and with whitespace runs collapsed, so editing a
/// comment or reformatting does not make a sync stale (IDEA hashes lexer tokens alike).
pub(crate) fn script_fingerprint(text: &str) -> String {
strip_comments(text).split_whitespace().collect::<Vec<_>>().join(" ")
}
/// `(project path, task)` from `gradle tasks --all` console output; tasks listed as
/// `app:run` belong to `:app`, unqualified ones to the root project `:`.
pub fn parse_tasks(output: &str) -> Vec<(String, GradleTask)> {
@@ -451,14 +660,60 @@ dependencies {
/* include("block") */
includeBuild("plugins")
"#;
let (name, includes) = parse_settings(kts);
assert_eq!(name.as_deref(), Some("gradle-demo"));
assert_eq!(includes, [":app", ":lib", ":libs:core"]);
let s = parse_settings(kts);
assert_eq!(s.name.as_deref(), Some("gradle-demo"));
assert_eq!(s.includes, [":app", ":lib", ":libs:core"]);
assert!(s.dirs.is_empty());
let groovy = "rootProject.name = 'demo'\ninclude 'a',\n 'b'\ninclude ':c'\nprintln 'x'\n";
let (name, includes) = parse_settings(groovy);
assert_eq!(name.as_deref(), Some("demo"));
assert_eq!(includes, [":a", ":b", ":c"]);
let s = parse_settings(groovy);
assert_eq!(s.name.as_deref(), Some("demo"));
assert_eq!(s.includes, [":a", ":b", ":c"]);
}
#[test]
fn settings_include_flat_and_project_dirs() {
let groovy = r#"
includeFlat 'svc-a'
includeFlat 'svc-b', 'svc-c' // trailing comment
['x', 'y'].each { m -> include ":svc-a:$m" }
include ':wo'
project(':wo').projectDir = file('../svc-a/.worktrees/wo')
project(":dyn").projectDir = file("${rootDir}/dyn")
includeFlated 'not-a-call'
"#;
let s = parse_settings(groovy);
assert_eq!(s.includes, [":svc-a", ":svc-b", ":svc-c", ":wo"]);
assert_eq!(
s.dirs.into_iter().collect::<Vec<_>>(),
[
(":svc-a".into(), "../svc-a".into()),
(":svc-b".into(), "../svc-b".into()),
(":svc-c".into(), "../svc-c".into()),
(":wo".into(), "../svc-a/.worktrees/wo".into()),
]
);
let kts = "includeFlat(\"k\")\nproject(\":k\").projectDir = file(\"../other\")";
let s = parse_settings(kts);
assert_eq!(s.includes, [":k"]);
assert_eq!(s.dirs.get(":k").map(String::as_str), Some("../other"));
}
#[test]
fn loads_include_flat_siblings() {
let tmp = tempfile::tempdir().unwrap();
let ws = tmp.path();
std::fs::create_dir_all(ws.join("root")).unwrap();
std::fs::create_dir_all(ws.join("svc")).unwrap();
std::fs::write(ws.join("root/settings.gradle"), "rootProject.name = 'Root'\nincludeFlat 'svc'").unwrap();
std::fs::write(ws.join("svc/build.gradle"), "plugins { id 'java' }").unwrap();
let root = ws.join("root");
let m = load(&root).unwrap();
let svc = m.projects.iter().find(|p| p.path == ":svc").unwrap();
assert_eq!(Path::new(&svc.dir), ws.join("svc"));
assert_eq!(svc.build_file.as_deref().map(Path::new), Some(ws.join("svc/build.gradle").as_path()));
assert!(root.join(svc.build_file.as_ref().unwrap()).is_file());
}
#[test]
@@ -539,4 +794,52 @@ Pattern: clean<TaskName>: Cleans the output files of a task.
let none = tempfile::tempdir().unwrap();
assert!(load(none.path()).is_none());
}
#[test]
fn merges_included_builds_and_build_src_by_identity_path() {
let tmp = tempfile::tempdir().unwrap();
let ws = tmp.path();
for d in ["shop/app", "shop/buildSrc", "lib/core"] {
std::fs::create_dir_all(ws.join(d)).unwrap();
}
std::fs::write(ws.join("shop/settings.gradle"), "rootProject.name = 'shop'\ninclude 'app'").unwrap();
std::fs::write(ws.join("lib/settings.gradle"), "include 'core'").unwrap();
let root = ws.join("shop");
// In evaluation order, as the init script prints it: nested builds first.
let line = |path: &str, d: &str, build: Option<&str>| {
let project = ReportedProject {
path: path.into(),
dir: ws.join(d).to_string_lossy().into_owned(),
build: build.map(Into::into),
..ReportedProject::default()
};
format!("{MODEL_LINE}{}\n", serde_json::to_string(&project).unwrap())
};
let out = [
line(":lib", "lib", Some(":lib")),
line(":lib:core", "lib/core", Some(":lib")),
line(":buildSrc", "shop/buildSrc", Some(":buildSrc")),
line(":", "shop", None),
line(":app", "shop/app", None),
]
.concat();
let mut model = load(&root).unwrap();
merge_reported_projects(&root, &mut model, &parse_reported_projects(&out));
let projects: Vec<_> =
model.projects.iter().map(|p| (p.path.as_str(), p.name.as_str(), p.build.as_deref())).collect();
assert_eq!(
projects,
[
(":", "shop", None),
(":app", "app", None),
(":buildSrc", "buildSrc", Some(":buildSrc")),
(":lib", "lib", Some(":lib")),
(":lib:core", "core", Some(":lib")),
]
);
let build_src = &model.projects[2];
assert!(build_src.tasks.is_empty(), "buildSrc tasks cannot run from the root build");
assert!(!model.projects[4].tasks.is_empty());
assert!(build_files(&root, &model).contains(&ws.join("lib/settings.gradle")));
}
}
+13 -1
View File
@@ -18,7 +18,7 @@ mod xml;
pub use gradle::{GradleModel, GradleProject, GradleTask};
pub use maven::{MavenDependency, MavenModel, MavenPlugin, MavenProfile, MavenProject, Omission};
pub use sync::{SyncOptions, SyncProgress, sync_gradle, sync_maven};
pub use sync::{SyncOptions, SyncProgress, maven_profiles, save_maven_profiles, sync_gradle, sync_maven};
use serde::{Deserialize, Serialize};
use specta::Type;
@@ -65,6 +65,18 @@ pub fn load_linked(
BuildModel { maven, gradle }
}
/// Gradle project folders outside `root` as of the last sync (`includeFlat` siblings):
/// extra content roots, as IDEA lists module roots beside the project folder.
/// Reads only the sync's cached projects (no build script), so it is cheap enough for hot paths.
pub fn gradle_external_roots(root: &Path, cache_dir: &Path) -> Vec<PathBuf> {
gradle::external_roots(root, sync::reported_gradle_projects(cache_dir).iter().map(|p| p.dir.as_str()))
}
/// Source, resource and build folders of the last Gradle sync, absolute.
pub fn gradle_source_roots(cache_dir: &Path) -> Vec<(PathBuf, gradle::ReportedRoot)> {
gradle::reported_source_roots(&sync::reported_gradle_projects(cache_dir))
}
/// A library the project depends on, as `groupId:artifactId:version`.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct DependencyCoordinate {
+432 -26
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] =
@@ -243,7 +245,8 @@ struct RawPom {
plugins: Vec<RawPlugin>,
managed_plugins: Vec<RawPlugin>,
profiles: Vec<RawProfile>,
/// `<build><plugins>` `maven-compiler-plugin` `<release>` / `<source>`.
/// `maven-compiler-plugin` `<release>` / `<source>` of `<build><plugins>`, else of
/// `<pluginManagement>`.
compiler_release: Option<String>,
}
@@ -285,11 +288,29 @@ 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));
}
/// `maven-compiler-plugin` `<release>` / `<source>` among the plugins at `path`.
fn compiler_level(project: &Element, path: &str) -> Option<String> {
project
.list(path, "plugin")
.into_iter()
.find(|p| p.text_of("artifactId").as_deref() == Some("maven-compiler-plugin"))
.and_then(|p| p.child("configuration"))
.and_then(|c| c.text_of("release").or_else(|| c.text_of("source")))
}
fn read_raw(project: &Element) -> RawPom {
let parent = project.child("parent").and_then(|p| {
Some(ParentRef {
@@ -308,7 +329,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")
@@ -318,18 +339,13 @@ fn read_raw(project: &Element) -> RawPom {
..RawPom::default()
};
read_build(project, &mut pom.plugins, &mut pom.managed_plugins);
if let Some(c) = pom.plugins.iter().position(|p| p.artifact_id == "maven-compiler-plugin") {
let plugin =
project.list("build/plugins", "plugin").into_iter().filter(|p| p.text_of("artifactId").is_some()).nth(c);
if let Some(cfg) = plugin.and_then(|p| p.child("configuration")) {
pom.compiler_release = cfg.text_of("release").or_else(|| cfg.text_of("source"));
}
}
pom.compiler_release =
compiler_level(project, "build/plugins").or_else(|| compiler_level(project, "build/pluginManagement/plugins"));
for p in project.list("profiles", "profile") {
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()
@@ -401,6 +417,9 @@ struct Inherited {
managed: Vec<RawDep>,
plugins: Vec<RawPlugin>,
managed_plugins: Vec<RawPlugin>,
/// The nearest `maven-compiler-plugin` `<release>` / `<source>`: a module usually
/// inherits its compiler level from the parent's (managed) plugin configuration.
compiler_release: Option<String>,
/// `<id>`, `activeByDefault`: profiles of the POM and its ancestors —
/// Maven lets a child enable a parent's profiles with `-P`.
profiles: Vec<(String, bool)>,
@@ -448,13 +467,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 {
@@ -521,6 +547,9 @@ impl Resolver {
merge_by(&mut ctx.deps, &pom.deps, RawDep::key);
merge_plugins(&mut ctx.plugins, &pom.plugins);
merge_plugins(&mut ctx.managed_plugins, &pom.managed_plugins);
if pom.compiler_release.is_some() {
ctx.compiler_release = pom.compiler_release.clone();
}
for p in pom.profiles.iter().filter(|p| p.active_by_default) {
merge_by(&mut ctx.properties, &p.properties, |(k, _)| k.clone());
merge_by(&mut ctx.deps, &p.deps, RawDep::key);
@@ -680,7 +709,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"],
@@ -832,9 +930,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());
@@ -862,13 +978,19 @@ fn rel_path(root: &Path, p: &Path) -> String {
}
/// The POMs that are not modules of another one: the root `pom.xml`, else the
/// shallowest `pom.xml` of each branch (a folder holding several builds).
/// shallowest `pom.xml` of each branch (a folder holding several builds). An
/// aggregate POM directly inside `src/` counts too (issue #1001: `src/pom.xml`
/// with its modules beside it, `src/app/pom.xml` and so on).
fn top_level_poms(root: &Path) -> Vec<PathBuf> {
let top = root.join("pom.xml");
if top.is_file() {
return vec![top];
}
let mut out = Vec::new();
let src_pom = root.join("src").join("pom.xml");
if src_pom.is_file() {
out.push(src_pom);
}
let mut queue: Vec<(PathBuf, usize)> = vec![(root.to_path_buf(), 0)];
while let Some((dir, depth)) = queue.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else { continue };
@@ -882,9 +1004,16 @@ fn top_level_poms(root: &Path) -> Vec<PathBuf> {
}
dirs.sort();
for d in dirs.into_iter().rev() {
if d.join("pom.xml").is_file() {
out.push(d.join("pom.xml"));
} else if depth + 1 < POM_SEARCH_DEPTH {
let pom = d.join("pom.xml");
if pom.is_file() {
out.push(pom);
continue;
}
let src_pom = d.join("src").join("pom.xml");
if src_pom.is_file() {
out.push(src_pom);
}
if depth + 1 < POM_SEARCH_DEPTH {
queue.push((d, depth + 1));
}
}
@@ -999,14 +1128,14 @@ 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();
let jv = ["maven.compiler.release", "java.version", "maven.compiler.source", "maven.compiler.target"]
.iter()
.find_map(|k| props.get(*k).map(|v| i(v)))
.or_else(|| pom.compiler_release.as_deref().map(i))
.or_else(|| ctx.compiler_release.as_deref().map(i))
.and_then(|v| java_version(&v));
// The project can activate the profiles of its own POM and its ancestors'.
@@ -1079,6 +1208,40 @@ impl UnlinkedProjects {
self.poms.iter().map(String::as_str)
}
/// The POMs the Java language server should import when an unlinked POM is a module of a
/// linked reactor, else `None` (excluding the unlinked directories is enough then).
///
/// jdtls imports an aggregator together with all of its modules, whatever its import
/// exclusions say, so a reactor keeping an unlinked module is left out; its other linked
/// modules are listed on their own. Such an aggregator (`pom` packaging) has no sources.
pub fn import_poms(&self, model: &MavenModel) -> Option<Vec<String>> {
let linked: Vec<&MavenProject> = model.projects.iter().filter(|p| !self.contains(&p.pom)).collect();
if !linked.iter().any(|p| p.modules.iter().any(|m| self.contains(m))) {
return None;
}
let by_pom: std::collections::HashMap<String, &MavenProject> =
model.projects.iter().map(|p| (normalized_pom(&p.pom), p)).collect();
// Whether `pom`'s module tree reaches an unlinked POM.
let reaches_unlinked = |pom: &str| {
let mut seen = std::collections::HashSet::new();
let mut pending = vec![normalized_pom(pom)];
while let Some(pom) = pending.pop() {
if !seen.insert(pom.clone()) {
continue;
}
let Some(project) = by_pom.get(&pom) else { continue };
for module in &project.modules {
if self.contains(module) {
return true;
}
pending.push(normalized_pom(module));
}
}
false
};
Some(linked.into_iter().filter(|p| !reaches_unlinked(&p.pom)).map(|p| normalized_pom(&p.pom)).collect())
}
pub fn filter(&self, model: &mut MavenModel) {
model.projects.retain(|p| !self.contains(&p.pom));
for project in &mut model.projects {
@@ -1111,6 +1274,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] };
@@ -1122,7 +1295,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
@@ -1253,6 +1426,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();
@@ -1276,6 +1550,72 @@ 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 modules_inherit_the_compiler_level_of_the_parent_plugin_configuration() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let parent = |build: &str| {
format!(
r#"<project><groupId>demo</groupId><artifactId>parent</artifactId><version>1.0</version><packaging>pom</packaging>
<modules><module>core</module><module>app</module></modules>
<properties><jdk>17</jdk></properties>{build}</project>"#
)
};
let child = |name: &str, build: &str| {
format!(
r#"<project><parent><groupId>demo</groupId><artifactId>parent</artifactId><version>1.0</version></parent>
<artifactId>{name}</artifactId>{build}</project>"#
)
};
let compiler = |level: &str| {
format!(
"<plugin><artifactId>maven-compiler-plugin</artifactId><configuration><source>{level}</source></configuration></plugin>"
)
};
let versions = |model: &MavenModel| -> Vec<(String, Option<u32>)> {
model.projects.iter().map(|p| (p.pom.clone(), p.java_version)).collect()
};
let app_build = format!("<build><plugins>{}</plugins></build>", compiler("21"));
write(root, "core/pom.xml", &child("core", ""));
write(root, "app/pom.xml", &child("app", &app_build));
for build in [
format!("<build><pluginManagement><plugins>{}</plugins></pluginManagement></build>", compiler("${jdk}")),
format!("<build><plugins>{}</plugins></build>", compiler("1.8")),
] {
write(root, "pom.xml", &parent(&build));
let expected = if build.contains("1.8") { 8 } else { 17 };
assert_eq!(
versions(&load_with_repo(root, None)),
[
("pom.xml".to_owned(), Some(expected)),
("core/pom.xml".to_owned(), Some(expected)),
("app/pom.xml".to_owned(), Some(21)),
],
"{build}"
);
}
}
#[test]
fn multi_module_parent_properties_and_managed_versions() {
let tmp = tempfile::tempdir().unwrap();
@@ -1471,6 +1811,25 @@ mod tests {
assert!(!is_maven_project(&root.join("c")));
}
#[test]
fn aggregate_pom_in_src_is_a_build() {
// Issue #1001: `src/pom.xml` aggregating modules under `src/` is recognized,
// with its modules loaded — while `src` stays unsearched otherwise.
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(
root,
"src/pom.xml",
"<project><groupId>g</groupId><artifactId>parent</artifactId><packaging>pom</packaging>\
<modules><module>app</module></modules></project>",
);
write(root, "src/app/pom.xml", "<project><groupId>g</groupId><artifactId>app</artifactId></project>");
let model = load_with_repo(root, None);
let names: Vec<&str> = model.projects.iter().map(|p| p.artifact_id.as_str()).collect();
assert_eq!(names, ["parent", "app"]);
assert!(is_maven_project(root));
}
#[test]
fn property_interpolation() {
let props: HashMap<String, String> =
@@ -1668,4 +2027,51 @@ mod linkage_tests {
UnlinkedProjects::from_model(&original, &MavenSettings::default()).filter(&mut restored);
assert_eq!(restored, original);
}
#[test]
fn language_server_imports_reactor_modules_individually_once_one_is_unlinked() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
// r ─┬─ x
// ├─ y ─┬─ y/y1
// │ └─ y/y2
// └─ z other/ is a separate root.
for (dir, modules) in [
("r", "<modules><module>x</module><module>y</module><module>z</module></modules>"),
("r/x", ""),
("r/y", "<modules><module>y1</module><module>y2</module></modules>"),
("r/y/y1", ""),
("r/y/y2", ""),
("r/z", ""),
("other", ""),
] {
std::fs::create_dir_all(root.join(dir)).unwrap();
std::fs::write(
root.join(dir).join("pom.xml"),
format!("<project><artifactId>{dir}</artifactId>{modules}</project>"),
)
.unwrap();
}
let model = load_with_repo(root, None);
let cases: [(&[&str], Option<&[&str]>); 5] = [
// Nothing unlinked, or whole roots: the import exclusions do it.
(&[], None),
(&["other/pom.xml"], None),
(&["r/pom.xml"], None),
// A module of a linked reactor: every other linked POM, minus the aggregators above it.
(&["r/y/y1/pom.xml"], Some(&["other/pom.xml", "r/x/pom.xml", "r/y/y2/pom.xml", "r/z/pom.xml"])),
(&["./r/y/pom.xml"], Some(&["other/pom.xml", "r/x/pom.xml", "r/z/pom.xml"])),
];
for (unlinked, expected) in cases {
let settings = MavenSettings {
unlinked_projects: unlinked.iter().map(|s| s.to_string()).collect(),
..Default::default()
};
let mut poms = UnlinkedProjects::from_model(&model, &settings).import_poms(&model);
if let Some(poms) = &mut poms {
poms.sort();
}
assert_eq!(poms, expected.map(|e| e.iter().map(|s| s.to_string()).collect::<Vec<_>>()), "{unlinked:?}");
}
}
}
+4 -19
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,18 +23,6 @@ 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.
@@ -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);
}
+272 -12
View File
@@ -4,14 +4,16 @@
//! per project (`maven.json`, `gradle.json` in the cache directory) and merged
//! into the static model by [`crate::load`]; a changed build file marks them stale.
use crate::gradle::{self, GradleModel, GradleTask};
use crate::gradle::{self, GradleModel, GradleTask, ReportedProject, parse_reported_projects};
use crate::maven::{self, MavenDependency, MavenModel, MavenPlugin};
use crate::process::{self, Output};
use ide_core::{CancellationToken, IdeError, IdeResult};
use serde::{Deserialize, Serialize, de::DeserializeOwned};
use specta::Type;
use std::collections::BTreeMap;
use std::collections::{BTreeMap, HashMap};
use std::path::{Path, PathBuf};
use std::sync::{Arc, LazyLock, Mutex};
use std::time::SystemTime;
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
@@ -48,6 +50,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>,
@@ -63,9 +70,16 @@ struct GradleCache {
at_ms: f64,
error: Option<String>,
tasks: Vec<(String, GradleTask)>,
/// The projects as Gradle evaluated them; empty in caches from before they were read.
#[serde(default)]
projects: Vec<ReportedProject>,
}
/// Init script printing every project, its source sets and Java version once configured.
const MODEL_INIT_SCRIPT: &str = include_str!("gradle-model.gradle");
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.
@@ -82,6 +96,24 @@ fn maven_sync_goals(effective: &Path, offline: bool) -> Vec<String> {
]
}
const GRADLE_CACHE: &str = "gradle.json";
const MAVEN_PROFILES: &str = "maven-profiles.json";
/// The Maven profiles (`-P` entries, `!id` deactivates) the project was last synced
/// with: what the IDE resolves it with besides `mvn` (the Java language server, Build Project).
pub fn maven_profiles(cache_dir: &Path) -> Vec<String> {
read_cache(cache_dir, MAVEN_PROFILES).unwrap_or_default()
}
/// Records the profiles of a sync for [`maven_profiles`]; `true` when they changed.
pub fn save_maven_profiles(cache_dir: &Path, profiles: &[String]) -> IdeResult<bool> {
if maven_profiles(cache_dir) == profiles {
return Ok(false);
}
std::fs::create_dir_all(cache_dir)?;
let json = serde_json::to_vec(profiles).map_err(|e| IdeError::internal(e.to_string()))?;
std::fs::write(cache_dir.join(MAVEN_PROFILES), json)?;
Ok(true)
}
fn read_cache<T: DeserializeOwned>(dir: &Path, name: &str) -> Option<T> {
serde_json::from_slice(&std::fs::read(dir.join(name)).ok()?).ok()
@@ -98,6 +130,20 @@ fn write_cache<T: Serialize>(dir: &Path, name: &str, value: &T) -> IdeResult<()>
/// FNV-1a over the paths and contents of `files` (missing files count as empty).
fn hash_files(files: &[PathBuf]) -> String {
hash_contents(files, |f| std::fs::read(f).unwrap_or_default())
}
/// Like IDEA's auto-import CRC: editing a comment or reformatting a build script
/// does not make the last sync stale.
fn hash_gradle_files(files: &[PathBuf]) -> String {
hash_contents(files, |f| {
let text = std::fs::read_to_string(f).unwrap_or_default();
let script = f.extension().is_some_and(|e| e == "gradle" || e == "kts");
if script { gradle::script_fingerprint(&text) } else { text }.into_bytes()
})
}
fn hash_contents(files: &[PathBuf], read: impl Fn(&Path) -> Vec<u8>) -> String {
let mut h: u64 = 0xcbf29ce484222325;
let mut feed = |bytes: &[u8]| {
for b in bytes {
@@ -108,7 +154,7 @@ fn hash_files(files: &[PathBuf]) -> String {
for f in files {
feed(f.to_string_lossy().as_bytes());
feed(&[0]);
feed(&std::fs::read(f).unwrap_or_default());
feed(&read(f));
feed(&[0]);
}
format!("{h:016x}")
@@ -121,6 +167,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));
@@ -141,9 +190,33 @@ pub fn apply_maven_cache(root: &Path, cache_dir: &Path, model: &mut MavenModel)
}
}
/// The projects of the last successful Gradle sync.
/// Parsed once per change of the cache file (modification time and size): content and source
/// roots are read on hot paths (every references search, Find in Files, repository scans).
pub fn reported_gradle_projects(cache_dir: &Path) -> Arc<Vec<ReportedProject>> {
type Memo = HashMap<PathBuf, (Option<SystemTime>, u64, Arc<Vec<ReportedProject>>)>;
static MEMO: LazyLock<Mutex<Memo>> = LazyLock::new(Default::default);
let path = cache_dir.join(GRADLE_CACHE);
let Ok(meta) = std::fs::metadata(&path) else {
MEMO.lock().unwrap_or_else(|e| e.into_inner()).remove(&path);
return Arc::default();
};
let stamp = (meta.modified().ok(), meta.len());
let mut memo = MEMO.lock().unwrap_or_else(|e| e.into_inner());
if let Some((modified, len, projects)) = memo.get(&path)
&& (*modified, *len) == stamp
{
return projects.clone();
}
let projects = Arc::new(read_cache::<GradleCache>(cache_dir, GRADLE_CACHE).map(|c| c.projects).unwrap_or_default());
memo.insert(path, (stamp.0, stamp.1, projects.clone()));
projects
}
/// Merges the cached task list into `model`.
pub fn apply_gradle_cache(root: &Path, cache_dir: &Path, model: &mut GradleModel) {
let Some(cache) = read_cache::<GradleCache>(cache_dir, GRADLE_CACHE) else { return };
gradle::merge_reported_projects(root, model, &cache.projects);
if !cache.tasks.is_empty() {
for p in &mut model.projects {
let tasks: Vec<GradleTask> =
@@ -153,9 +226,11 @@ pub fn apply_gradle_cache(root: &Path, cache_dir: &Path, model: &mut GradleModel
}
}
}
// A successful sync from before projects were cached has not seen computed includes.
let without_projects = cache.error.is_none() && cache.projects.is_empty();
model.synced_at = Some(cache.at_ms);
model.sync_error = cache.error;
model.stale = cache.hash != hash_files(&gradle::build_files(root, model));
model.stale = without_projects || cache.hash != hash_gradle_files(&gradle::build_files(root, model));
}
// ---------------------------------------------------------------- processes
@@ -211,8 +286,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 +339,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);
@@ -271,8 +370,18 @@ pub fn sync_gradle(
cancel: &CancellationToken,
progress: &dyn Fn(SyncProgress),
) -> IdeResult<()> {
let model = gradle::load(root).ok_or_else(|| IdeError::not_found("no Gradle build in this project"))?;
let mut args: Vec<String> = vec!["--console=plain".into(), "-q".into(), "tasks".into(), "--all".into()];
let mut model = gradle::load(root).ok_or_else(|| IdeError::not_found("no Gradle build in this project"))?;
std::fs::create_dir_all(cache_dir)?;
let init_script = cache_dir.join("openidea-model.gradle");
std::fs::write(&init_script, MODEL_INIT_SCRIPT)?;
let mut args: Vec<String> = vec![
"--console=plain".into(),
"-q".into(),
"--init-script".into(),
init_script.to_string_lossy().into_owned(),
"tasks".into(),
"--all".into(),
];
if options.offline {
args.push("--offline".into());
}
@@ -285,11 +394,15 @@ pub fn sync_gradle(
cancel,
|_| {},
)?;
let projects = if out.success { parse_reported_projects(&out.text) } else { Vec::new() };
// Hash the build files of the reported projects, or the model is stale right after the sync.
gradle::merge_reported_projects(root, &mut model, &projects);
let cache = GradleCache {
hash: hash_files(&gradle::build_files(root, &model)),
hash: hash_gradle_files(&gradle::build_files(root, &model)),
at_ms: now_ms(),
error: (!out.success).then(|| error_summary(&out.text)),
tasks: if out.success { gradle::parse_tasks(&out.text) } else { Vec::new() },
projects,
};
write_cache(cache_dir, GRADLE_CACHE, &cache)
}
@@ -332,6 +445,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 +457,7 @@ mod tests {
"maven-clean-plugin",
Some("3.2.0".into()),
&[],
None,
)],
dependencies: vec![MavenDependency { artifact_id: "dep".into(), ..MavenDependency::default() }],
}],
@@ -368,6 +483,128 @@ 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_reported_projects_replace_static_ones() {
let tmp = tempfile::tempdir().unwrap();
let ws = dunce::canonicalize(tmp.path()).unwrap();
let root = ws.join("app");
let billing = ws.join("billing");
std::fs::create_dir_all(root.join("shared")).unwrap();
std::fs::create_dir_all(billing.join("modules/core")).unwrap();
// Computed includes: nothing a static reading can see.
std::fs::write(root.join("settings.gradle"), "rootProject.name = 'Shop'\n['billing'].each { includeFlat it }")
.unwrap();
std::fs::write(billing.join("modules/core/build.gradle"), "apply plugin: 'java'").unwrap();
// Built with serde: a Windows path holds backslashes, which raw JSON text would leave unescaped.
let line = |p: ReportedProject| format!("@openidea-model\t{}\n", serde_json::to_string(&p).unwrap());
let (r, b) = (root.display().to_string(), billing.display().to_string());
let core_src = |p: &str| format!("{b}/modules/core/src/{p}");
let set = |name: &str, test: bool, src: &[&str], res: &[&str]| gradle::ReportedSourceSet {
name: name.into(),
test,
src: src.iter().map(|p| core_src(p)).collect(),
res: res.iter().map(|p| core_src(p)).collect(),
};
// A settings script's own println comes first; task listing lines follow.
let out = format!(
"configuring billing\n{}{}{}@openidea-model\t{{broken\n\nOther tasks\n-----------\nbilling:core:jar\n",
line(ReportedProject { path: ":".into(), dir: r.clone(), ..ReportedProject::default() }),
line(ReportedProject {
path: ":billing".into(),
dir: b.clone(),
java: Some(17),
..ReportedProject::default()
}),
line(ReportedProject {
path: ":billing:core".into(),
dir: format!("{b}/modules/core"),
java: Some(21),
source_sets: vec![
set("main", false, &["main/java"], &["main/resources"]),
set("test", true, &["main/java", "test/java"], &[]),
],
..ReportedProject::default()
}),
);
let projects = parse_reported_projects(&out);
assert_eq!(projects.len(), 3, "the malformed line is skipped");
let src = |p: &str| PathBuf::from(core_src(p));
assert_eq!(
gradle::reported_source_roots(&projects),
[
(src("main/java"), gradle::ReportedRoot::Source),
(src("main/resources"), gradle::ReportedRoot::Resources),
(src("test/java"), gradle::ReportedRoot::Test),
]
);
let mut model = gradle::load(&root).unwrap();
assert_eq!(model.projects.len(), 1);
gradle::merge_reported_projects(&root, &mut model, &projects);
let paths: Vec<_> = model.projects.iter().map(|p| (p.path.as_str(), p.name.as_str())).collect();
assert_eq!(paths, [(":", "Shop"), (":billing", "billing"), (":billing:core", "core")]);
assert_eq!(model.projects[0].dir, "");
assert!(root.join(model.projects[2].build_file.as_ref().unwrap()).is_file());
assert_eq!(
gradle::external_roots(&root, model.projects.iter().map(|p| p.dir.as_str())),
std::slice::from_ref(&billing)
);
assert_eq!(model.java_version, Some(21), "the newest version any module needs");
assert_eq!(
gradle::parse_tasks(&out),
[(":billing:core".into(), GradleTask { name: "jar".into(), group: "other".into(), description: None })]
);
// The cached projects are parsed once per change of the cache file.
let cache_dir = ws.join("cache");
let write = |projects: &[ReportedProject]| {
let cache = GradleCache { projects: projects.to_vec(), ..GradleCache::default() };
write_cache(&cache_dir, GRADLE_CACHE, &cache).unwrap();
};
write(&projects);
let first = reported_gradle_projects(&cache_dir);
assert!(Arc::ptr_eq(&first, &reported_gradle_projects(&cache_dir)), "unchanged: not parsed again");
assert_eq!(crate::gradle_external_roots(&root, &cache_dir), [billing]);
write(&projects[..1]);
assert_eq!(reported_gradle_projects(&cache_dir).len(), 1, "a new sync is read");
std::fs::remove_file(cache_dir.join(GRADLE_CACHE)).unwrap();
assert!(reported_gradle_projects(&cache_dir).is_empty());
}
#[test]
fn gradle_cache_replaces_tasks_per_project() {
let tmp = tempfile::tempdir().unwrap();
@@ -378,16 +615,39 @@ mod tests {
std::fs::write(root.join("app/build.gradle"), "apply plugin: 'java'").unwrap();
let mut model = gradle::load(&root).unwrap();
let cache = GradleCache {
hash: hash_files(&gradle::build_files(&root, &model)),
hash: hash_gradle_files(&gradle::build_files(&root, &model)),
at_ms: 1.0,
error: None,
tasks: vec![(":app".into(), GradleTask { name: "hello".into(), group: "demo".into(), description: None })],
projects: [":", ":app"]
.iter()
.zip([root.clone(), root.join("app")])
.map(|(path, dir)| ReportedProject {
path: (*path).into(),
dir: dir.to_string_lossy().into_owned(),
..Default::default()
})
.collect(),
};
write_cache(&cache_dir, GRADLE_CACHE, &cache).unwrap();
apply_gradle_cache(&root, &cache_dir, &mut model);
assert_eq!(model.projects[1].tasks.len(), 1);
assert!(model.projects[0].tasks.iter().any(|t| t.name == "wrapper"), "root keeps its defaults");
assert!(!model.stale);
let stale_after = |script: &str| {
std::fs::write(root.join("app/build.gradle"), script).unwrap();
let mut m = gradle::load(&root).unwrap();
apply_gradle_cache(&root, &cache_dir, &mut m);
m.stale
};
assert!(!stale_after("// a note\napply plugin: 'java'\n"), "comments and spacing don't count");
assert!(stale_after("apply plugin: 'groovy'"));
std::fs::write(root.join("app/build.gradle"), "apply plugin: 'java'").unwrap();
std::fs::write(root.join("gradle.properties"), "org.gradle.jvmargs=-Xmx2g").unwrap();
let mut m = gradle::load(&root).unwrap();
apply_gradle_cache(&root, &cache_dir, &mut m);
assert!(m.stale, "gradle.properties is watched");
}
#[test]
+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"] }
+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;
+180 -16
View File
@@ -13,7 +13,10 @@
//! they fork get them too (`spring-boot:run`, Surefire, `bootRun`, test workers).
//! Each JVM then reports "Picked up JAVA_TOOL_OPTIONS: …" on stderr (which a build tool
//! may copy to its stdout); that line is hidden again ([`NoticeFilter`]). `file.encoding` is left alone there: it would change
//! how a build reads sources that declare no encoding.
//! how a build reads sources that declare no encoding. The application JVMs a build
//! forks get `file.encoding=UTF-8` for a UTF-8 console instead, through the build's own
//! options: `-Dspring-boot.run.jvmArguments` for Maven `spring-boot:run`, an init script
//! ([`GRADLE_FILE_ENCODING_SCRIPT`]) for Gradle `JavaExec` tasks (`bootRun`, `run`).
//! - Python: `PYTHONIOENCODING`.
use std::collections::BTreeMap;
@@ -75,16 +78,34 @@ fn encoding_for_code_page(cp: u32) -> Option<&'static encoding_rs::Encoding> {
const STD_PROPERTIES: &[&str] = &["stdout.encoding", "stderr.encoding", "sun.stdout.encoding", "sun.stderr.encoding"];
const FILE_ENCODING: &str = "file.encoding";
const TOOL_OPTIONS: &str = "JAVA_TOOL_OPTIONS";
const SPRING_BOOT_JVM_ARGS: &str = "-Dspring-boot.run.jvmArguments=";
/// Build tools and launcher scripts that run on (and may fork) JVMs.
const JVM_TOOLS: &[&str] =
&["mvn", "mvnw", "mvnd", "gradle", "gradlew", "sbt", "ant", "kotlin", "kotlinc", "groovy", "scala", "jbang"];
const JVM_TOOLS: &[&str] = &["sbt", "ant", "kotlin", "kotlinc", "groovy", "scala", "jbang"];
const MAVEN: &[&str] = &["mvn", "mvnw", "mvnd"];
const GRADLE: &[&str] = &["gradle", "gradlew"];
/// Gradle init script giving the `JavaExec` tasks (`bootRun`, `run`, custom ones) a UTF-8
/// default charset before the build configures them, so the build can override it.
/// Gradle's own JVM and the compiler's source encoding are untouched.
pub const GRADLE_FILE_ENCODING_SCRIPT: &str = "\
// Written by OpenIDEA: a UTF-8 default charset for applications run by Gradle, as the Run console decodes UTF-8.
allprojects {
tasks.withType(JavaExec).configureEach {
defaultCharacterEncoding = 'UTF-8'
}
}
";
/// What a launch needs: `-D` options to put first on a `java` command line,
/// environment variables to set, and the notice line its JVMs print because of them.
#[derive(Debug, Default, PartialEq, Eq)]
pub struct EncodingFix {
pub java_args: Vec<String>,
/// The build tool's arguments, rewritten to pass options to the JVM it forks.
pub args: Option<Vec<String>>,
/// Gradle: pass [`GRADLE_FILE_ENCODING_SCRIPT`] with `--init-script`.
pub gradle_init_script: bool,
pub env: Vec<(String, String)>,
/// "Picked up JAVA_TOOL_OPTIONS: …" for a variable set here alone, to drop from the output.
pub notice: Option<String>,
@@ -116,6 +137,8 @@ fn options(properties: &[&str], encoding: &str) -> Vec<String> {
#[derive(Clone, Copy, PartialEq, Eq)]
enum Kind {
Java,
Maven,
Gradle,
JvmTool,
Python,
Other,
@@ -125,6 +148,10 @@ fn kind(program: &str) -> Kind {
let name = program_name(program);
if name == "java" || name == "javaw" {
Kind::Java
} else if MAVEN.contains(&name.as_str()) {
Kind::Maven
} else if GRADLE.contains(&name.as_str()) {
Kind::Gradle
} else if JVM_TOOLS.contains(&name.as_str()) {
Kind::JvmTool
} else if is_python(&name) {
@@ -193,7 +220,17 @@ fn fix_kind(
out.java_args.push(format!("-D{FILE_ENCODING}=UTF-8"));
}
}
(Kind::Java | Kind::JvmTool, _) => {
(Kind::Java | Kind::Maven | Kind::Gradle | Kind::JvmTool, _) => {
if let Some(args) = args
&& is_utf8(encoding)
&& !sets(&tool_options, &[FILE_ENCODING])
{
match kind {
Kind::Maven => out.args = spring_boot_file_encoding(args),
Kind::Gradle => out.gradle_init_script = true,
_ => {}
}
}
if !sets(&tool_options, STD_PROPERTIES) {
let added = options(STD_PROPERTIES, encoding).join(" ");
let value = match tool_options.trim() {
@@ -214,14 +251,42 @@ fn fix_kind(
out
}
/// `vmArgs` of a launch by java-debug, which decodes the JVM's output as `encoding`
/// (its launch attribute, UTF-8 when unset) and sets `file.encoding` itself.
pub fn java_debug_vm_args(vm_args: &str, encoding: Option<&str>, windows: bool) -> Option<String> {
let encoding = encoding.map(str::trim).filter(|e| !e.is_empty()).unwrap_or("UTF-8");
if (!windows && is_utf8(encoding)) || sets(vm_args, STD_PROPERTIES) {
fn spring_boot_file_encoding(args: &[String]) -> Option<Vec<String>> {
if !args.iter().any(|a| a == "spring-boot:run" || (a.contains(":spring-boot-maven-plugin:") && a.ends_with(":run")))
{
return None;
}
let added = options(STD_PROPERTIES, encoding).join(" ");
let option = format!("-D{FILE_ENCODING}=UTF-8");
let mut out = args.to_vec();
match out.iter_mut().rfind(|a| a.starts_with(SPRING_BOOT_JVM_ARGS)) {
Some(own) if sets(own, &[FILE_ENCODING]) => return None,
Some(own) => {
if own.len() > SPRING_BOOT_JVM_ARGS.len() {
own.push(' ');
}
own.push_str(&option);
}
None => out.push(format!("{SPRING_BOOT_JVM_ARGS}{option}")),
}
Some(out)
}
/// `vmArgs` of a launch by java-debug, which decodes the JVM's output as `encoding`
/// (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");
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 = added.join(" ");
Some(match vm_args.trim() {
"" => added,
own => format!("{own} {added}"),
@@ -427,6 +492,64 @@ mod tests {
assert_eq!(run("python3", &[], &[], "GBK", false).env, [("PYTHONIOENCODING".to_owned(), "GBK".to_owned())]);
}
#[test]
fn spring_boot_run_forks_get_a_utf8_default_charset() {
let boot = |args: &[&str], env: &[(&str, &str)]| run("mvn.cmd", args, env, "UTF-8", true).args;
let utf8 = "-Dspring-boot.run.jvmArguments=-Dfile.encoding=UTF-8";
assert_eq!(boot(&["spring-boot:run"], &[]).unwrap(), ["spring-boot:run", utf8]);
let plugin = "org.springframework.boot:spring-boot-maven-plugin:3.2.0:run";
assert_eq!(boot(&["-pl", "app", plugin], &[]).unwrap(), ["-pl", "app", plugin, utf8]);
// The user's VM options are kept; their own `file.encoding` wins.
assert_eq!(
boot(&["spring-boot:run", "-Dspring-boot.run.jvmArguments=-Xmx1g \"-Da=b c\""], &[]).unwrap(),
["spring-boot:run", "-Dspring-boot.run.jvmArguments=-Xmx1g \"-Da=b c\" -Dfile.encoding=UTF-8"]
);
assert_eq!(boot(&["spring-boot:run", "-Dspring-boot.run.jvmArguments="], &[]).unwrap()[1], utf8);
assert!(boot(&["spring-boot:run", "-Dspring-boot.run.jvmArguments=-Dfile.encoding=GBK"], &[]).is_none());
assert!(boot(&["spring-boot:run"], &[(TOOL_OPTIONS, "-Dfile.encoding=GBK")]).is_none());
// Other goals and tools, other consoles, and other platforms are left alone.
assert!(boot(&["package"], &[]).is_none());
assert!(run("mvn", &["spring-boot:run"], &[], "GBK", true).args.is_none());
assert!(run("mvn", &["spring-boot:run"], &[], "UTF-8", false).args.is_none());
}
#[test]
fn forked_application_jvms_get_a_utf8_default_charset() {
// Reported: a Spring Boot app run by the build on Chinese Windows (JDK 8–17, GBK default).
for (tool, args) in [
("C:\\p\\gradlew.bat", &["bootRun"][..]),
("gradle", &[":app:run"]),
("mvnw.cmd", &["spring-boot:run"]),
("mvnd", &["org.springframework.boot:spring-boot-maven-plugin:2.7.18:run"]),
] {
let f = run(tool, args, &[], "UTF-8", true);
let gradle = tool.contains("gradle");
assert_eq!(f.gradle_init_script, gradle, "{tool}");
assert_eq!(f.args.is_some(), !gradle, "{tool}");
// The build tool's own JVM keeps its default charset.
assert!(!tool_env(&f).unwrap().contains("file.encoding"), "{tool}");
// The user's choice, a GBK console and other platforms are left alone.
let own = run(tool, args, &[(TOOL_OPTIONS, "-Dfile.encoding=GBK")], "UTF-8", true);
assert!(!own.gradle_init_script && own.args.is_none(), "{tool}");
let gbk = run(tool, args, &[], "GBK", true);
assert!(!gbk.gradle_init_script && gbk.args.is_none(), "{tool}");
assert_eq!(run(tool, args, &[], "UTF-8", false), EncodingFix::default(), "{tool}");
}
// Shell command lines can't take arguments; other JVM tools fork no application.
assert!(!fix_command_line("gradlew bootRun", &BTreeMap::new(), |_| None, "UTF-8", true).gradle_init_script);
let sbt = run("sbt", &["run"], &[], "UTF-8", true);
assert!(!sbt.gradle_init_script && sbt.args.is_none());
}
#[test]
fn gradle_script_respects_the_builds_own_file_encoding() {
let s = GRADLE_FILE_ENCODING_SCRIPT;
assert!(s.contains("tasks.withType(JavaExec).configureEach"), "{s}");
assert!(s.contains("defaultCharacterEncoding = 'UTF-8'"), "{s}");
assert!(!s.contains("doFirst"), "the build must be able to override the default: {s}");
assert!(!s.contains("withType(Test)") && !s.contains("JavaCompile"), "{s}");
}
#[test]
fn shell_command_lines_get_variables_from_their_first_word() {
let line = |l: &str| fix_command_line(l, &BTreeMap::new(), |_| None, "UTF-8", true);
@@ -446,12 +569,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 {
+316 -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,95 @@ 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`] for a child known by its process id (a `tokio` child): `SIGKILL` to its
/// process group (spawn it with [`own_group`]) or `taskkill /T /F`. Call it before the child
/// is reaped, so the id still names it. The child itself is left to its owner to kill and reap.
pub fn kill_tree_of(pid: u32) {
#[cfg(unix)]
if let Ok(pid) = libc::pid_t::try_from(pid) {
// SAFETY: plain syscall; see `signal_group`.
unsafe {
libc::kill(-pid, libc::SIGKILL);
}
}
#[cfg(windows)]
{
let mut taskkill = Command::new("taskkill");
prepare(&mut taskkill)
.args(["/T", "/F", "/PID", &pid.to_string()])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
let _ = taskkill.status();
}
#[cfg(not(any(unix, windows)))]
let _ = pid;
}
/// [`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 +337,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 +348,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> {
@@ -323,6 +420,28 @@ fn append_path_entries(path: &OsStr, extra: &OsStr) -> OsString {
mod tests {
use super::*;
#[cfg(unix)]
#[test]
fn kill_tree_of_reaches_what_the_child_started() {
use std::io::BufRead;
let mut cmd = Command::new("sh");
own_group(&mut cmd).args(["-c", "sleep 30 & echo $!; wait"]).stdout(std::process::Stdio::piped());
let mut child = cmd.spawn().unwrap();
let mut line = String::new();
std::io::BufReader::new(child.stdout.take().unwrap()).read_line(&mut line).unwrap();
let grandchild: libc::pid_t = line.trim().parse().unwrap();
let alive = |pid: libc::pid_t| unsafe { libc::kill(pid, 0) } == 0;
assert!(alive(grandchild));
kill_tree_of(child.id());
let _ = child.wait();
// Reparented to init once its parent is gone, which reaps it.
let gone = (0..100).any(|_| {
std::thread::sleep(Duration::from_millis(20));
!alive(grandchild)
});
assert!(gone, "the grandchild {grandchild} survived");
}
#[cfg(windows)]
#[test]
fn user_path_reads_the_current_users_path() {
@@ -334,6 +453,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 +711,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);
+1 -1
View File
@@ -132,7 +132,7 @@ pub struct DeploymentServer {
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct DeploymentMapping {
/// Project-relative folder; empty = the project root.
/// Project-relative folder, or absolute inside the project; empty = the project root.
pub local: String,
/// Remote folder, relative to the server root (a leading `/` is the root too).
pub deployment: String,
+119 -6
View File
@@ -59,6 +59,31 @@ pub struct Settings {
/// Build, Execution, Deployment › Run Targets.
pub run_targets: Vec<RunTarget>,
pub cmake: CMakeSettings,
/// Tools › Agents › Chat.
pub agents: AgentSettings,
/// Plugin settings (`[contributes.settings]`): plugin id → its values by key.
#[specta(type = BTreeMap<String, BTreeMap<String, specta_typescript::Unknown>>)]
pub plugins: BTreeMap<String, serde_json::Map<String, serde_json::Value>>,
}
/// What the meters in an agent task's header show.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct AgentSettings {
/// Context window use.
pub show_context_usage: bool,
/// Time the agent spent on the task.
pub show_work_time: bool,
/// The account's usage-limit windows (Claude's 5-hour and weekly limits).
pub show_usage_limits: bool,
/// What the session has cost so far, as the agent reports it.
pub show_cost: bool,
}
impl Default for AgentSettings {
fn default() -> Self {
Self { show_context_usage: true, show_work_time: true, show_usage_limits: true, show_cost: false }
}
}
/// Absent profiles retain automatic discovery for existing projects.
@@ -192,6 +217,8 @@ impl Default for Settings {
deployment: DeploymentSettings::default(),
run_targets: Vec::new(),
cmake: CMakeSettings::default(),
agents: AgentSettings::default(),
plugins: BTreeMap::new(),
}
}
}
@@ -235,7 +262,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,
@@ -300,7 +327,8 @@ impl Default for NotificationGroupSettings {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct AppearanceSettings {
/// `dark` | `light` | `system` | `dark-hc` (high-contrast dark)
/// A built-in theme id (`light`, `dark`, `dark-hc`, …), `system` (legacy
/// "Sync with OS"), or a plugin's theme: `plugin:<plugin-id>/<theme-id>`.
pub theme: String,
pub accent: String,
pub ui_font_size: f32,
@@ -309,6 +337,8 @@ pub struct AppearanceSettings {
pub sync_with_os: bool,
pub preferred_light_theme: String,
pub preferred_dark_theme: String,
/// A plugin's file icon theme (`<plugin-id>/<theme-id>`); empty = the built-in icons.
pub file_icon_theme: String,
/// IDE zoom in percent (50–300).
pub zoom: u32,
/// Apply `ui_font_family` / `ui_font_size` (otherwise the theme's).
@@ -363,6 +393,7 @@ impl Default for AppearanceSettings {
sync_with_os: false,
preferred_light_theme: "light".into(),
preferred_dark_theme: "dark".into(),
file_icon_theme: String::new(),
zoom: 100,
use_custom_font: false,
ui_font_family: "Inter".into(),
@@ -543,25 +574,65 @@ impl Default for FileSettings {
pub struct LanguagesSettings {
pub python: PythonSettings,
pub node: NodeSettings,
pub php: PhpSettings,
pub rust: RustSettings,
pub go: GoSettings,
pub typescript: TypeScriptSettings,
pub prettier: PrettierSettings,
pub prettier: ExternalFormatterSettings,
pub oxfmt: ExternalFormatterSettings,
pub java: JavaSettings,
pub ruby: RubySettings,
pub markdown: MarkdownSettings,
}
/// Languages & Frameworks › Markdown. Like IDEA's, it is application-level only.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct MarkdownSettings {
/// Preview (and notebook Markdown cell) font size in px; unset follows the editor font size.
pub preview_font_size: Option<u32>,
}
impl LanguagesSettings {
pub fn formatter(&self, tool: ExternalFormatter) -> &ExternalFormatterSettings {
match tool {
ExternalFormatter::Prettier => &self.prettier,
ExternalFormatter::Oxfmt => &self.oxfmt,
}
}
}
/// A formatter that runs outside the language servers, with its own settings group.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "lowercase")]
pub enum ExternalFormatter {
Prettier,
Oxfmt,
}
impl ExternalFormatter {
/// The name of the tool in the tool registry.
pub fn name(self) -> &'static str {
match self {
Self::Prettier => "prettier",
Self::Oxfmt => "oxfmt",
}
}
}
/// An external formatter (Prettier, oxfmt) and the languages it formats.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct PrettierSettings {
pub struct ExternalFormatterSettings {
/// 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.
/// Prefer this formatter even when a language server offers formatting.
pub prefer: bool,
pub run_on_save: bool,
}
impl Default for PrettierSettings {
impl Default for ExternalFormatterSettings {
fn default() -> Self {
Self {
path: String::new(),
@@ -605,6 +676,20 @@ impl Default for PythonSettings {
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct PhpSettings {
/// `php` CLI executable; empty = the one on `PATH`.
pub interpreter: String,
}
#[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 {
@@ -709,6 +794,19 @@ pub struct GradleSettings {
pub home: String,
/// Empty = Gradle's default user home.
pub user_home: String,
/// IDEA's "Build and run using": who builds and launches an Application main.
pub run_using: GradleRunUsing,
}
/// IDEA's "Build and run using" for Gradle builds; IDEA defaults to Gradle.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum GradleRunUsing {
/// A `JavaExec` task on the source set's runtime classpath (`gradleRun.gradle`).
#[default]
Gradle,
/// The Java language server's classpath, launched by OpenIDEA.
Ide,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
@@ -850,6 +948,20 @@ mod tests {
}
}
#[test]
fn agent_meters_default_and_round_trip() {
let old: Settings = serde_json::from_str(r#"{"agents":{"showCost":true}}"#).unwrap();
assert!(old.agents.show_cost);
assert!(old.agents.show_context_usage && old.agents.show_work_time && old.agents.show_usage_limits);
assert!(!Settings::default().agents.show_cost);
let mut settings = Settings::default();
settings.agents.show_usage_limits = false;
let json = serde_json::to_value(&settings).unwrap();
assert_eq!(json["agents"]["showUsageLimits"], false);
let restored: Settings = serde_json::from_value(json).unwrap();
assert_eq!(restored.agents, settings.agents);
}
#[test]
fn main_menu_style_defaults_and_round_trips() {
let old: Settings = serde_json::from_str(r#"{"appearance":{"compactMode":true}}"#).unwrap();
@@ -935,6 +1047,7 @@ mod gradle_settings_tests {
distribution,
home: "/gradle".into(),
user_home: "/cache/gradle".into(),
run_using: GradleRunUsing::Ide,
};
let restored: Settings = serde_json::from_str(&serde_json::to_string(&settings).unwrap()).unwrap();
assert_eq!(restored.languages.java.gradle, settings.languages.java.gradle);
+57
View File
@@ -44,6 +44,39 @@ pub fn cancel(id: TaskId) -> bool {
}
}
/// A progress title a tool reported, made readable: a leading absolute executable path
/// (rust-analyzer reports `/…/bin/cargo check`) shows as its program name (`cargo check`).
pub fn display_title(title: &str) -> String {
let is_sep = |c: char| c == '/' || c == '\\';
let b = title.as_bytes();
let absolute = title.starts_with(is_sep)
|| (b.len() > 2 && b[0].is_ascii_alphabetic() && b[1] == b':' && is_sep(b[2] as char));
if !absolute {
return title.to_owned();
}
// The path runs on through spaces (`Application Support/…`) while the next word still
// continues it; the first word without a separator, an option or a new path ends it.
let mut end = title.find(' ').unwrap_or(title.len());
while end < title.len() {
let rest = &title[end + 1..];
let word = rest.split(' ').next().unwrap_or_default();
if word.is_empty() || !word.contains(is_sep) || word.starts_with(is_sep) || word.starts_with('-') {
break;
}
end += 1 + word.len();
}
let (path, rest) = title.split_at(end);
let name = path.rsplit(is_sep).next().unwrap_or(path);
let name = [".exe", ".cmd", ".bat"]
.iter()
.find_map(|ext| {
let cut = name.len().checked_sub(ext.len())?;
(name.is_char_boundary(cut) && name[cut..].eq_ignore_ascii_case(ext)).then(|| &name[..cut])
})
.unwrap_or(name);
if name.is_empty() { title.to_owned() } else { format!("{name}{rest}") }
}
#[cfg(test)]
mod tests {
use super::*;
@@ -71,4 +104,28 @@ mod tests {
assert!(!cancel(other), "an ended task is not cancelled");
assert_eq!(hits.load(Ordering::SeqCst), 1);
}
#[test]
fn display_title_shows_a_leading_executable_path_as_its_name() {
for (title, shown) in [
("/Users/thomas/Library/Application Support/mbx/bin/cargo check", "cargo check"),
("/home/me/.cargo/bin/cargo clippy (#2)", "cargo clippy (#2)"),
(
"/opt/my tools/bin/cargo check --manifest-path /x/Cargo.toml",
"cargo check --manifest-path /x/Cargo.toml",
),
(r"C:\Users\me\.cargo\bin\cargo.exe check", "cargo check"),
(r"C:\Program Files\Rust\bin\cargo.EXE check", "cargo check"),
(r"\\server\share\tools\x.cmd build", "x build"),
("/usr/bin/make", "make"),
("cargo check", "cargo check"),
("Indexing", "Indexing"),
("Building compile-time-deps", "Building compile-time-deps"),
("Loading /src/app (2/3)", "Loading /src/app (2/3)"),
("/", "/"),
("", ""),
] {
assert_eq!(display_title(title), shown, "{title:?}");
}
}
}
+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,
+19 -7
View File
@@ -8,7 +8,7 @@
use ide_core::process::executable_names;
use ide_core::{IdeError, IdeResult};
use ide_language::{DebuggerConfig, LanguageRegistry};
use ide_language::{DebuggerConfig, LanguageRegistry, ProgramPin};
use std::future::Future;
use std::path::{Path, PathBuf};
use std::pin::Pin;
@@ -44,6 +44,8 @@ pub struct AdapterSpec {
/// `host:port` of an adapter inside another program (the Godot editor): connected to
/// when it accepts, else `command` is started.
pub connect: Option<String>,
/// A plugin language's adapter: start exactly this approved program.
pub pinned: Option<ProgramPin>,
}
/// Built-in adapters used when no language definition names `adapter`.
@@ -55,6 +57,7 @@ fn builtin(adapter: &str) -> Option<DebuggerConfig> {
transport: transport.to_owned(),
connect: None,
install_hint: Some(hint.to_owned()),
pinned: None,
};
Some(match adapter {
"codelldb" => cfg(
@@ -133,6 +136,7 @@ pub fn resolve(config: &DebuggerConfig, cwd: PathBuf) -> AdapterSpec {
env: Vec::new(),
server_host: None,
connect: config.connect.clone(),
pinned: config.pinned.clone(),
}
}
@@ -426,12 +430,20 @@ impl AdapterLauncher for ProcessLauncher {
if is_plugin_bundle(&spec.command) {
return Err(not_standalone(&spec));
}
let (command, cwd) = (spec.command.clone(), spec.cwd.clone());
let binary = tokio::task::spawn_blocking(move || resolve_binary(&command, &cwd))
.await
.ok()
.flatten()
.ok_or_else(|| missing(&spec))?;
let binary = match spec.pinned.clone() {
Some(pin) => tokio::task::spawn_blocking(move || pin.verify().map(Path::to_path_buf))
.await
.map_err(|e| IdeError::internal(e.to_string()))?
.map_err(|e| IdeError::new(ide_core::error::ErrorKind::PermissionDenied, e))?,
None => {
let (command, cwd) = (spec.command.clone(), spec.cwd.clone());
tokio::task::spawn_blocking(move || resolve_binary(&command, &cwd))
.await
.ok()
.flatten()
.ok_or_else(|| missing(&spec))?
}
};
let port = match spec.transport {
// An embedded adapter started without `${port}` listens on its usual address.
Transport::Tcp => match &embedded {
+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 -1
View File
@@ -405,7 +405,7 @@ async fn on_event(mgr: &Arc<DebugManager>, s: &Arc<Session>, event: &str, mut bo
let mut progress = s.progress.lock();
let id =
if done { progress.remove(&pid) } else { Some(*progress.entry(pid).or_insert_with(TaskId::next)) };
(id, str_("title").unwrap_or_else(|| s.name.clone()))
(id, str_("title").map(|t| ide_core::task::display_title(&t)).unwrap_or_else(|| s.name.clone()))
};
let Some(id) = id else { return };
s.owner.sink.emit(IdeEvent::Task {
+56
View File
@@ -915,6 +915,7 @@ fn godot_registry(port: u16, command: &str, args: &[&str]) -> Arc<LanguageRegist
transport: "tcp".into(),
connect: Some(format!("127.0.0.1:{port}")),
install_hint: Some("Open the project in the Godot editor".into()),
pinned: None,
}),
};
Arc::new(LanguageRegistry::from_configs(vec![lang]))
@@ -1147,3 +1148,58 @@ async fn java_stack_frames_without_breakpoints_normalize_windows_paths() {
h.manager.stop(id).await.unwrap();
}
}
/// A built-in language definition (GDScript) shipped by a plugin
/// instead: its debugger starts the program approved for the plugin's
/// `process:godot` grant, and refuses once that file changed.
#[cfg(unix)]
#[tokio::test]
async fn a_plugin_language_runs_its_debug_adapter_from_the_approved_program() {
use ide_language::ProgramPin;
use ide_language::plugin_point::{PluginLanguages, parse_definition};
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!("openidea-plugin-dap-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
// Stands in for `godot --headless --editor …`.
let program = dir.join("godot");
std::fs::write(&program, "#!/bin/sh\nexec sleep 30\n").unwrap();
std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o755)).unwrap();
let port = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap().local_addr().unwrap().port();
let mut gdscript = parse_definition(include_str!("../../../languages/gdscript.toml")).unwrap();
let debugger = gdscript.debugger.as_mut().unwrap();
debugger.args.clear();
debugger.connect = Some(format!("127.0.0.1:{port}"));
// No built-in GDScript: it comes from the plugin.
let registry = Arc::new(LanguageRegistry::from_configs(Vec::new()));
let store = PluginLanguages::new();
registry.attach_plugins(store.clone());
let pin = ProgramPin::approve(&program).unwrap();
store.set_process_policy(Arc::new(move |plugin, cap| {
(plugin == "acme.godot" && cap.to_string() == "process:godot").then(|| pin.clone())
}));
let _language = store.add("acme.godot".into(), gdscript);
let log: Log = Arc::default();
let server_log = log.clone();
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
let listener = tokio::net::TcpListener::bind(("127.0.0.1", port)).await.unwrap();
let (stream, _) = listener.accept().await.unwrap();
fake_adapter(stream, server_log, Script::default()).await;
});
let (owner, _events) = recording_owner("main");
let manager = DebugManager::new(registry.clone());
let id = manager.start(godot_launch(&dir), owner).await.unwrap();
assert!(log.lock().iter().any(|m| m["command"] == "launch"));
manager.stop(id).await.unwrap();
// Replaced after approval: refused before anything starts.
std::fs::write(&program, "#!/bin/sh\nexec sleep 31\n").unwrap();
let (owner, _events) = recording_owner("main");
let err = manager.start(godot_launch(&dir), owner).await.unwrap_err();
assert_eq!(err.kind, ide_core::error::ErrorKind::PermissionDenied, "{}", err.message);
let _ = std::fs::remove_dir_all(&dir);
}
-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));
+24
View File
@@ -0,0 +1,24 @@
[package]
name = "ide-extension"
description = "Plugin manifest, extension points, contribution registry and capability model (no Wasm runtime)"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
ide-core.workspace = true
serde.workspace = true
serde_json.workspace = true
specta.workspace = true
specta-typescript.workspace = true
toml.workspace = true
tracing.workspace = true
parking_lot.workspace = true
semver = "1"
blake3.workspace = true
zip.workspace = true
base64.workspace = true
ed25519-dalek = "3"
[dev-dependencies]
tempfile.workspace = true
+277
View File
@@ -0,0 +1,277 @@
//! What a plugin may do beyond pure computation, and how risky each grant is.
//!
//! Capabilities are written as strings in the manifest (`project.read`,
//! `net:crates.io`, `process:cargo`) and stored the same way in the grant
//! ledger. The 0.1 permission names (`project-read`, …) parse as their 0.2
//! equivalents.
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use specta::Type;
use std::fmt;
use std::str::FromStr;
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Capability {
/// Read files inside the project root; the project root itself.
ProjectRead,
/// The focused editor's file, selection and text.
EditorRead,
/// Edit the focused editor (versioned edits).
EditorWrite,
VcsRead,
VcsWrite,
/// The plugin's own key-value store and data directory.
Storage,
/// The plugin's own secrets (kept in the client's OS keychain).
Secrets,
/// Workbench events (file opened, saved, focus…), not keystrokes.
Events,
/// Keystrokes and editor input events.
InputEvents,
/// The system clipboard, from the plugin's web views.
Clipboard,
/// HTTP(S) to one host (`*.example.com` matches subdomains).
Net(String),
/// Run one external program. Full code execution: never fine-grained.
Process(String),
/// Read files outside the project under one path.
FsRead(String),
}
/// How a grant is presented (plan 4.2).
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum Risk {
/// Listed on the install page, one confirmation.
Low,
/// Listed separately with its purpose.
Medium,
/// Separate dialog: "equivalent to running arbitrary code on this machine".
High,
}
impl Capability {
pub fn risk(&self) -> Risk {
match self {
Self::ProjectRead | Self::EditorRead | Self::Storage | Self::Events | Self::VcsRead => Risk::Low,
Self::EditorWrite | Self::VcsWrite | Self::Net(_) | Self::Secrets | Self::InputEvents | Self::Clipboard => {
Risk::Medium
}
Self::Process(_) | Self::FsRead(_) => Risk::High,
}
}
/// Low-risk capabilities that the one-time migration of the 0.1 "declared
/// means granted" behaviour may grant automatically (plan 4.6).
pub fn auto_migrates(&self) -> bool {
self.risk() == Risk::Low
}
/// Short user-facing description for install and grant pages.
pub fn describe(&self) -> String {
match self {
Self::ProjectRead => "Read files in the open project".into(),
Self::EditorRead => "Read the focused editor's file and selection".into(),
Self::EditorWrite => "Change text in the editor".into(),
Self::VcsRead => "Read version control state".into(),
Self::VcsWrite => "Change version control state (commit, fetch, checkout)".into(),
Self::Storage => "Store its own data".into(),
Self::Secrets => "Store secrets in the system keychain".into(),
Self::Events => "Observe workbench events (files opened, saved, focus)".into(),
Self::InputEvents => "Observe keystrokes and editor input".into(),
Self::Clipboard => "Read and write the clipboard from its web views".into(),
Self::Net(host) => format!("Connect to {host} (from the machine the project is on)"),
Self::Process(bin) => format!("Run {bin} — equivalent to running arbitrary code on this machine"),
Self::FsRead(path) => format!("Read files under {path}, outside the project"),
}
}
/// Whether `self`, granted, also covers `wanted` (`net:*.x.com` covers `net:a.x.com`).
pub fn covers(&self, wanted: &Capability) -> bool {
match (self, wanted) {
(Self::Net(pattern), Self::Net(host)) => host_matches(pattern, host),
(Self::FsRead(base), Self::FsRead(path)) => {
std::path::Path::new(path).starts_with(std::path::Path::new(base))
}
_ => self == wanted,
}
}
}
/// `example.com` matches exactly; `*.example.com` matches any subdomain (not the apex).
pub fn host_matches(pattern: &str, host: &str) -> bool {
let host = host.trim_end_matches('.').to_ascii_lowercase();
let pattern = pattern.to_ascii_lowercase();
match pattern.strip_prefix("*.") {
Some(suffix) => host.len() > suffix.len() + 1 && host.ends_with(&format!(".{suffix}")),
None => host == pattern,
}
}
impl fmt::Display for Capability {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::ProjectRead => f.write_str("project.read"),
Self::EditorRead => f.write_str("editor.read"),
Self::EditorWrite => f.write_str("editor.write"),
Self::VcsRead => f.write_str("vcs.read"),
Self::VcsWrite => f.write_str("vcs.write"),
Self::Storage => f.write_str("storage"),
Self::Secrets => f.write_str("secrets"),
Self::Events => f.write_str("events"),
Self::InputEvents => f.write_str("events.input"),
Self::Clipboard => f.write_str("clipboard"),
Self::Net(h) => write!(f, "net:{h}"),
Self::Process(b) => write!(f, "process:{b}"),
Self::FsRead(p) => write!(f, "fs.read:{p}"),
}
}
}
impl FromStr for Capability {
type Err = String;
fn from_str(s: &str) -> Result<Self, String> {
let s = s.trim();
if let Some((kind, arg)) = s.split_once(':') {
let arg = arg.trim();
if arg.is_empty() {
return Err(format!("capability {s:?} needs a value after ':'"));
}
return match kind {
"net" => {
let host = arg.strip_prefix("*.").unwrap_or(arg);
let valid = !host.is_empty()
&& host.len() <= 253
&& host.split('.').all(|label| {
!label.is_empty()
&& label.len() <= 63
&& label.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')
});
if valid { Ok(Self::Net(arg.to_ascii_lowercase())) } else { Err(format!("invalid host in {s:?}")) }
}
"process" => {
if arg.contains(['/', '\\']) && !std::path::Path::new(arg).is_absolute() {
return Err(format!("{s:?}: use a program name or an absolute path"));
}
Ok(Self::Process(arg.to_owned()))
}
"fs.read" => {
if !std::path::Path::new(arg).is_absolute() && !arg.starts_with('~') {
return Err(format!("{s:?}: use an absolute path (or ~/…)"));
}
Ok(Self::FsRead(arg.to_owned()))
}
_ => Err(format!("unknown capability {s:?}")),
};
}
Ok(match s {
"project.read" | "project-read" => Self::ProjectRead,
"editor.read" | "editor-read" => Self::EditorRead,
"editor.write" | "editor-write" => Self::EditorWrite,
"vcs.read" => Self::VcsRead,
"vcs.write" => Self::VcsWrite,
"storage" => Self::Storage,
"secrets" => Self::Secrets,
"events" => Self::Events,
"events.input" => Self::InputEvents,
"clipboard" => Self::Clipboard,
_ => return Err(format!("unknown capability {s:?}")),
})
}
}
impl Serialize for Capability {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.collect_str(self)
}
}
impl<'de> Deserialize<'de> for Capability {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
String::deserialize(d)?.parse().map_err(serde::de::Error::custom)
}
}
/// A capability as the UI shows it.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct CapabilityInfo {
/// `project.read`, `net:crates.io`, …
pub id: String,
pub risk: Risk,
pub description: String,
/// Declared under `optional`: the plugin runs without it.
pub optional: bool,
/// Currently granted.
pub granted: bool,
}
impl CapabilityInfo {
pub fn new(cap: &Capability, optional: bool, granted: bool) -> Self {
Self { id: cap.to_string(), risk: cap.risk(), description: cap.describe(), optional, granted }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_and_prints() {
for s in [
"project.read",
"editor.write",
"vcs.write",
"storage",
"secrets",
"events",
"events.input",
"net:crates.io",
"net:*.github.com",
"process:cargo",
] {
assert_eq!(s.parse::<Capability>().unwrap().to_string(), s);
}
// Absolute paths are the platform's.
let (git, etc) = if cfg!(windows) {
(r"process:C:\Program Files\Git\bin\git.exe", r"fs.read:C:\Windows")
} else {
("process:/usr/bin/git", "fs.read:/etc")
};
for s in [git, etc] {
assert_eq!(s.parse::<Capability>().unwrap().to_string(), s);
}
assert_eq!("project-read".parse::<Capability>().unwrap(), Capability::ProjectRead);
assert!("net:".parse::<Capability>().is_err());
assert!("net:a b".parse::<Capability>().is_err());
assert!("process:./evil".parse::<Capability>().is_err());
assert!("fs.read:rel".parse::<Capability>().is_err());
assert!("network".parse::<Capability>().is_err());
}
#[test]
fn risk_levels() {
assert_eq!(Capability::ProjectRead.risk(), Risk::Low);
assert_eq!(Capability::Net("x".into()).risk(), Risk::Medium);
assert_eq!(Capability::InputEvents.risk(), Risk::Medium);
assert_eq!("clipboard".parse::<Capability>().unwrap(), Capability::Clipboard);
assert_eq!(Capability::Clipboard.risk(), Risk::Medium);
assert_eq!(Capability::Process("cargo".into()).risk(), Risk::High);
assert!(Capability::Storage.auto_migrates() && !Capability::EditorWrite.auto_migrates());
}
#[test]
fn host_patterns() {
assert!(host_matches("crates.io", "crates.io"));
assert!(host_matches("crates.io", "CRATES.IO."));
assert!(!host_matches("crates.io", "static.crates.io"));
assert!(host_matches("*.github.com", "api.github.com"));
assert!(!host_matches("*.github.com", "github.com"));
assert!(!host_matches("*.github.com", "evilgithub.com"));
let any_gh: Capability = "net:*.github.com".parse().unwrap();
assert!(any_gh.covers(&"net:api.github.com".parse().unwrap()));
assert!(Capability::FsRead("/etc".into()).covers(&Capability::FsRead("/etc/hosts".into())));
assert!(!Capability::FsRead("/etc".into()).covers(&Capability::FsRead("/etcx".into())));
}
}
+104
View File
@@ -0,0 +1,104 @@
//! The contribution index: validated UI
//! declarations of installed packages, kept on disk so start-up does not
//! validate them again (sanitising thousands of icon SVGs, say).
//!
//! Only packages with a [`Package::digest`] are indexed: installed copies,
//! which the host never modifies after install. An entry is keyed by that
//! digest, the extension point, the declaration and a salt naming the
//! validation logic (the IDE version), so a new package version, a changed
//! declaration or an IDE upgrade simply misses. A miss or an unreadable entry
//! only costs the validation.
use crate::point::Package;
use crate::ui::UiDecl;
use std::collections::BTreeSet;
use std::path::PathBuf;
/// Bumped when [`UiDecl`]'s serialized shape changes.
const FORMAT: u32 = 1;
pub struct ContributionIndex {
dir: PathBuf,
salt: String,
}
impl ContributionIndex {
/// `salt` names the validators' version (the IDE version).
pub fn new(dir: PathBuf, salt: &str) -> Self {
Self { dir, salt: format!("{FORMAT}:{salt}") }
}
/// Entries start with a prefix of the package digest, for pruning.
fn file(&self, digest: &str, point: &str, decl: &toml::Value) -> PathBuf {
let mut h = blake3::Hasher::new();
for part in [self.salt.as_str(), digest, point, &decl.to_string()] {
h.update(part.as_bytes());
h.update(&[0]);
}
let key = h.finalize().to_hex();
self.dir.join(format!("{}-{}.json", prefix(digest), &key[..32]))
}
pub fn get(&self, pkg: &Package, point: &str, decl: &toml::Value) -> Option<UiDecl> {
let digest = pkg.digest.as_deref()?;
let bytes = std::fs::read(self.file(digest, point, decl)).ok()?;
serde_json::from_slice(&bytes).ok()
}
pub fn put(&self, pkg: &Package, point: &str, decl: &toml::Value, ui: &UiDecl) {
let Some(digest) = pkg.digest.as_deref() else { return };
let Ok(bytes) = serde_json::to_vec(ui) else { return };
let file = self.file(digest, point, decl);
let _ = std::fs::create_dir_all(&self.dir);
let tmp = file.with_extension(format!("tmp{}", std::process::id()));
if std::fs::write(&tmp, bytes).and_then(|()| std::fs::rename(&tmp, &file)).is_err() {
let _ = std::fs::remove_file(&tmp);
}
}
/// Removes entries of packages other than `live` (digests).
pub fn prune<'a>(&self, live: impl IntoIterator<Item = &'a str>) {
let keep: BTreeSet<String> = live.into_iter().map(|d| prefix(d).to_owned()).collect();
let Ok(entries) = std::fs::read_dir(&self.dir) else { return };
for e in entries.flatten() {
let name = e.file_name().to_string_lossy().into_owned();
if !name.split_once('-').is_some_and(|(p, _)| keep.contains(p)) {
let _ = std::fs::remove_file(e.path());
}
}
}
}
fn prefix(digest: &str) -> &str {
let d = digest.strip_prefix("blake3:").unwrap_or(digest);
let end = d.char_indices().nth(16).map_or(d.len(), |(i, _)| i);
&d[..end]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::manifest::Manifest;
#[test]
fn hits_only_the_same_package_point_declaration_and_salt() {
let tmp = tempfile::tempdir().unwrap();
let index = ContributionIndex::new(tmp.path().join("idx"), "1.0");
let manifest =
Manifest::parse("id = \"p\"\nname = \"P\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"\n").unwrap();
let pkg = Package::new(manifest, tmp.path().to_path_buf()).with_digest("blake3:00aa11bb22cc33dd44ee");
let decl: toml::Value = toml::from_str("id = \"x\"").unwrap();
let ui = UiDecl::new("x", "X", serde_json::json!({ "id": "x" }));
assert!(index.get(&pkg, "pt", &decl).is_none());
index.put(&pkg, "pt", &decl, &ui);
assert_eq!(index.get(&pkg, "pt", &decl).unwrap().label, "X");
assert!(index.get(&pkg, "other", &decl).is_none());
assert!(index.get(&pkg, "pt", &toml::from_str("id = \"y\"").unwrap()).is_none());
assert!(ContributionIndex::new(tmp.path().join("idx"), "1.1").get(&pkg, "pt", &decl).is_none());
let unhashed = Package { digest: None, ..pkg.clone() };
assert!(index.get(&unhashed, "pt", &decl).is_none(), "not an installed copy");
index.prune(["blake3:ffff"]);
assert!(index.get(&pkg, "pt", &decl).is_none());
}
}
+483
View File
@@ -0,0 +1,483 @@
//! The grant ledger (plan 4.6): which capabilities the user granted to which
//! plugin, saved as `grants.json` next to the installed plugins.
//!
//! - Installed plugins are keyed by id and remember the version and package
//! hash they were confirmed for. An update that needs no new capability is
//! accepted silently; one that does must be confirmed again.
//! - Linked development plugins and project plugins (`.openidea/plugins/*`)
//! are keyed by their directory, not by hash, so rebuilding does not ask
//! again; new capabilities still do.
//! - `process:<program>` grants record the program's absolute path and file
//! hash at grant time. Calls use that path, never a `PATH` lookup, and fail
//! when the file changed (re-confirmation needed).
use crate::capability::{Capability, Risk};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
pub const LEDGER_FILE: &str = "grants.json";
const FORMAT: u32 = 1;
/// Whose grant an entry is.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(tag = "kind", content = "value", rename_all = "camelCase")]
pub enum Subject {
/// Installed under the plugins directory, by plugin id.
Installed(String),
/// A linked development plugin or a project plugin, by package directory.
Path(PathBuf),
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BinaryGrant {
pub path: PathBuf,
/// blake3 of the file when granted.
pub hash: String,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Grant {
pub plugin_id: String,
/// The version and package hash the user confirmed (installed plugins).
#[serde(default)]
pub version: String,
#[serde(default)]
pub package_hash: String,
/// The user confirmed the plugin (the install page or the project-plugin prompt).
#[serde(default)]
pub confirmed: bool,
/// Granted capabilities (`project.read`, `net:x`, …).
#[serde(default)]
pub granted: BTreeSet<String>,
/// Optional capabilities the user declined (not asked again until re-confirmed).
#[serde(default)]
pub declined: BTreeSet<String>,
/// `process:<program>` → the program confirmed for it.
#[serde(default)]
pub binaries: BTreeMap<String, BinaryGrant>,
/// Migrated from 0.1: capabilities not granted yet are asked for on first
/// use instead of blocking the plugin from loading.
#[serde(default)]
pub ask_on_use: bool,
}
impl Grant {
pub fn has(&self, cap: &Capability) -> bool {
self.granted.iter().filter_map(|s| s.parse::<Capability>().ok()).any(|g| g.covers(cap))
}
}
/// What a plugin (in its current form) needs, to compare with its grant.
#[derive(Clone, Debug)]
pub struct Request<'a> {
pub subject: Subject,
pub plugin_id: &'a str,
pub version: &'a str,
pub package_hash: &'a str,
pub required: Vec<Capability>,
pub optional: Vec<Capability>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Decision {
/// Load with these capabilities. `pending` required ones are asked for when
/// the plugin is first used (plugins migrated from 0.1 only).
Allowed { granted: Vec<Capability>, pending: Vec<Capability> },
/// Ask the user first. `new` lists what was never granted (all of it on first use).
NeedsConfirmation { new: Vec<Capability>, first_time: bool },
}
#[derive(Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct File {
#[serde(default)]
format: u32,
/// The one-time migration from 0.1 ("declared means granted") ran.
#[serde(default)]
migrated: bool,
#[serde(default)]
grants: Vec<(Subject, Grant)>,
}
/// The ledger, loaded into memory; [`Ledger::save`] writes it atomically.
#[derive(Debug, Default)]
pub struct Ledger {
path: Option<PathBuf>,
migrated: bool,
grants: BTreeMap<Subject, Grant>,
}
impl Ledger {
/// Loads `<dir>/grants.json`; a missing or unreadable file gives an empty
/// ledger with `needs_migration()` true.
pub fn load(dir: &Path) -> Self {
let path = dir.join(LEDGER_FILE);
let file: Option<File> = std::fs::read_to_string(&path).ok().and_then(|s| match serde_json::from_str(&s) {
Ok(f) => Some(f),
Err(e) => {
tracing::warn!("{}: {e}; starting with an empty grant ledger", path.display());
None
}
});
let existed = file.is_some();
let file = file.unwrap_or_default();
Self { path: Some(path), migrated: file.migrated || existed, grants: file.grants.into_iter().collect() }
}
pub fn in_memory() -> Self {
Self { path: None, migrated: true, grants: BTreeMap::new() }
}
pub fn save(&self) -> std::io::Result<()> {
let Some(path) = &self.path else { return Ok(()) };
let file = File {
format: FORMAT,
migrated: self.migrated,
grants: self.grants.iter().map(|(s, g)| (s.clone(), g.clone())).collect(),
};
let json = serde_json::to_string_pretty(&file).map_err(std::io::Error::other)?;
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, json)?;
std::fs::rename(&tmp, path)
}
pub fn get(&self, subject: &Subject) -> Option<&Grant> {
self.grants.get(subject)
}
pub fn remove(&mut self, subject: &Subject) -> Option<Grant> {
self.grants.remove(subject)
}
/// Puts back an earlier state of `subject`'s grant (`None`: none), e.g.
/// after rolling back an update.
pub fn restore(&mut self, subject: &Subject, grant: Option<Grant>) {
match grant {
Some(g) => {
self.grants.insert(subject.clone(), g);
}
None => {
self.grants.remove(subject);
}
}
}
/// The one-time migration of 0.1's "declared means granted" has not run yet.
pub fn needs_migration(&self) -> bool {
!self.migrated
}
/// Migrates plugins installed before the ledger existed: low-risk
/// capabilities are granted, the rest are asked on first use.
pub fn migrate(&mut self, installed: &[Request<'_>]) {
for req in installed {
let granted: BTreeSet<String> =
req.required.iter().chain(&req.optional).filter(|c| c.auto_migrates()).map(|c| c.to_string()).collect();
self.grants.entry(req.subject.clone()).or_insert_with(|| Grant {
plugin_id: req.plugin_id.to_owned(),
version: req.version.to_owned(),
package_hash: req.package_hash.to_owned(),
confirmed: true,
granted,
ask_on_use: true,
..Grant::default()
});
}
self.migrated = true;
}
/// Compares what the plugin needs with what was granted.
pub fn decide(&mut self, req: &Request<'_>) -> Decision {
let Some(grant) = self.grants.get_mut(&req.subject) else {
return Decision::NeedsConfirmation { new: req.required.clone(), first_time: true };
};
if !grant.confirmed {
return Decision::NeedsConfirmation { new: req.required.clone(), first_time: true };
}
let new: Vec<Capability> = req.required.iter().filter(|c| !grant.has(c)).cloned().collect();
if !new.is_empty() && !grant.ask_on_use {
return Decision::NeedsConfirmation { new, first_time: false };
}
// Same or fewer needs: accept an update silently (plan 4.6).
if grant.version != req.version || grant.package_hash != req.package_hash {
grant.version = req.version.to_owned();
grant.package_hash = req.package_hash.to_owned();
}
let granted = req.required.iter().chain(&req.optional).filter(|c| grant.has(c)).cloned().collect();
Decision::Allowed { granted, pending: new }
}
/// Records the user's confirmation: `required` plus the accepted optional capabilities.
pub fn confirm(
&mut self,
req: &Request<'_>,
accepted_optional: &[Capability],
binaries: BTreeMap<String, BinaryGrant>,
) {
let grant = self.grants.entry(req.subject.clone()).or_default();
grant.plugin_id = req.plugin_id.to_owned();
grant.version = req.version.to_owned();
grant.package_hash = req.package_hash.to_owned();
grant.confirmed = true;
grant.ask_on_use = false;
for cap in req.required.iter().chain(accepted_optional) {
grant.granted.insert(cap.to_string());
}
for cap in &req.optional {
let s = cap.to_string();
if accepted_optional.contains(cap) {
grant.declined.remove(&s);
} else if !grant.granted.contains(&s) {
grant.declined.insert(s);
}
}
grant.binaries.extend(binaries);
}
/// Grants or revokes one capability (Settings › Plugins › permissions).
pub fn set(&mut self, subject: &Subject, cap: &Capability, granted: bool) -> bool {
let Some(grant) = self.grants.get_mut(subject) else { return false };
let s = cap.to_string();
if granted {
grant.declined.remove(&s);
grant.granted.insert(s)
} else {
if let Capability::Process(_) = cap {
grant.binaries.remove(&s);
}
grant.granted.remove(&s)
}
}
/// Records the program approved for a `process:` grant.
pub fn set_binary(&mut self, subject: &Subject, cap: &Capability, binary: BinaryGrant) {
if let Some(grant) = self.grants.get_mut(subject) {
grant.binaries.insert(cap.to_string(), binary);
}
}
/// Checks a `process:` grant at call time: the program confirmed for it,
/// unchanged since. Errors name what to do.
pub fn check_binary(&self, subject: &Subject, cap: &Capability) -> Result<PathBuf, String> {
let Capability::Process(name) = cap else { return Err(format!("{cap} is not a process capability")) };
let grant = self.grants.get(subject).ok_or_else(|| format!("{cap} was not granted"))?;
let key = cap.to_string();
if !grant.granted.contains(&key) {
return Err(format!("{cap} was not granted"));
}
let bin = grant.binaries.get(&key).ok_or_else(|| format!("{cap}: no program was confirmed for {name}"))?;
match file_hash(&bin.path) {
Ok(hash) if hash == bin.hash => Ok(bin.path.clone()),
Ok(_) => Err(format!(
"{} changed since it was approved for {cap}; confirm the plugin's permissions again",
bin.path.display()
)),
Err(e) => Err(format!("{}: {e}; confirm the plugin's permissions again", bin.path.display())),
}
}
/// The program approved for a granted `process:` capability, without
/// checking the file: whoever starts it verifies the hash then
/// ([`check_binary`](Self::check_binary) or the pin's own check).
pub fn pinned_binary(&self, subject: &Subject, cap: &Capability) -> Option<BinaryGrant> {
let grant = self.grants.get(subject)?;
let key = cap.to_string();
if !grant.granted.contains(&key) {
return None;
}
grant.binaries.get(&key).cloned()
}
pub fn subjects(&self) -> impl Iterator<Item = (&Subject, &Grant)> {
self.grants.iter()
}
}
/// What a confirmation page shows: every capability with its risk, high risk first.
pub fn by_risk(caps: &[Capability]) -> Vec<(Risk, Vec<Capability>)> {
let mut groups: BTreeMap<std::cmp::Reverse<Risk>, Vec<Capability>> = BTreeMap::new();
for c in caps {
groups.entry(std::cmp::Reverse(c.risk())).or_default().push(c.clone());
}
groups.into_iter().map(|(r, v)| (r.0, v)).collect()
}
/// Resolves a `process:` program to an absolute path once, at grant time:
/// absolute names are taken as is, others are looked up in `path_var`.
pub fn resolve_program(name: &str, path_var: Option<&OsStr>) -> Result<PathBuf, String> {
let candidate = Path::new(name);
if candidate.is_absolute() {
return if candidate.is_file() { Ok(candidate.to_path_buf()) } else { Err(format!("{name}: no such program")) };
}
let exts: Vec<String> = if cfg!(windows) {
std::env::var("PATHEXT").unwrap_or_else(|_| ".EXE;.CMD;.BAT".into()).split(';').map(str::to_owned).collect()
} else {
vec![String::new()]
};
for dir in std::env::split_paths(path_var.unwrap_or_default()) {
if !dir.is_absolute() {
continue; // `.` or relative entries could point into the project
}
for ext in &exts {
let p = dir.join(format!("{name}{ext}"));
if is_executable(&p) {
return Ok(dunce_canonical(&p));
}
}
}
Err(format!("{name} was not found on PATH"))
}
fn dunce_canonical(p: &Path) -> PathBuf {
std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf())
}
fn is_executable(p: &Path) -> bool {
let Ok(meta) = std::fs::metadata(p) else { return false };
if !meta.is_file() {
return false;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
meta.permissions().mode() & 0o111 != 0
}
#[cfg(not(unix))]
true
}
/// blake3 of a file, hex.
pub fn file_hash(path: &Path) -> std::io::Result<String> {
let mut hasher = blake3::Hasher::new();
hasher.update_reader(std::fs::File::open(path)?)?;
Ok(hasher.finalize().to_hex().to_string())
}
/// blake3 over a package's files (sorted relative paths and contents).
pub fn package_hash(dir: &Path, files: &[PathBuf]) -> std::io::Result<String> {
let mut sorted: Vec<&PathBuf> = files.iter().collect();
sorted.sort();
sorted.dedup();
let mut hasher = blake3::Hasher::new();
for rel in sorted {
hasher.update(rel.to_string_lossy().as_bytes());
hasher.update(&[0]);
hasher.update_reader(std::fs::File::open(dir.join(rel))?)?;
hasher.update(&[0]);
}
Ok(hasher.finalize().to_hex().to_string())
}
#[cfg(test)]
mod tests {
use super::*;
fn caps(list: &[&str]) -> Vec<Capability> {
list.iter().map(|s| s.parse().unwrap()).collect()
}
fn req<'a>(version: &'a str, hash: &'a str, required: &[&str], optional: &[&str]) -> Request<'a> {
Request {
subject: Subject::Installed("p".into()),
plugin_id: "p",
version,
package_hash: hash,
required: caps(required),
optional: caps(optional),
}
}
#[test]
fn confirmation_then_silent_update_then_reconfirm_on_new_capability() {
let mut l = Ledger::in_memory();
let r1 = req("1.0.0", "h1", &["project.read"], &["net:crates.io"]);
assert_eq!(l.decide(&r1), Decision::NeedsConfirmation { new: caps(&["project.read"]), first_time: true });
l.confirm(&r1, &[], BTreeMap::new());
assert_eq!(
l.decide(&r1),
Decision::Allowed { granted: caps(&["project.read"]), pending: vec![] },
"declined optional"
);
assert!(l.get(&r1.subject).unwrap().declined.contains("net:crates.io"));
// Same needs, new version: silent.
let r2 = req("1.1.0", "h2", &["project.read"], &["net:crates.io"]);
assert!(matches!(l.decide(&r2), Decision::Allowed { .. }));
assert_eq!(l.get(&r2.subject).unwrap().version, "1.1.0");
// A new required capability: ask again, only for it.
let r3 = req("1.2.0", "h3", &["project.read", "editor.write"], &[]);
assert_eq!(l.decide(&r3), Decision::NeedsConfirmation { new: caps(&["editor.write"]), first_time: false });
l.confirm(&r3, &[], BTreeMap::new());
assert!(matches!(l.decide(&r3), Decision::Allowed { .. }));
// Revoking a required capability blocks loading again.
assert!(l.set(&r3.subject, &Capability::EditorWrite, false));
assert!(matches!(l.decide(&r3), Decision::NeedsConfirmation { .. }));
}
#[test]
fn migration_grants_low_risk_only() {
let tmp = tempfile::tempdir().unwrap();
let mut l = Ledger::load(tmp.path());
assert!(l.needs_migration());
let r = req("0.1.0", "h", &["project.read", "editor.write"], &[]);
l.migrate(std::slice::from_ref(&r));
// Loads with the low-risk part; the rest is asked for on first use.
assert_eq!(
l.decide(&r),
Decision::Allowed { granted: caps(&["project.read"]), pending: caps(&["editor.write"]) }
);
l.confirm(&r, &[], BTreeMap::new());
assert_eq!(
l.decide(&r),
Decision::Allowed { granted: caps(&["project.read", "editor.write"]), pending: vec![] }
);
l.save().unwrap();
let again = Ledger::load(tmp.path());
assert!(!again.needs_migration());
assert!(again.get(&r.subject).unwrap().has(&Capability::ProjectRead));
}
#[cfg(unix)]
#[test]
fn binaries_are_pinned_by_path_and_hash() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let bin_dir = tmp.path().join("bin");
std::fs::create_dir(&bin_dir).unwrap();
let tool = bin_dir.join("tool");
std::fs::write(&tool, "#!/bin/sh\necho v1\n").unwrap();
std::fs::set_permissions(&tool, std::fs::Permissions::from_mode(0o755)).unwrap();
let path_var = std::env::join_paths([PathBuf::from("relative"), bin_dir.clone()]).unwrap();
let resolved = resolve_program("tool", Some(&path_var)).unwrap();
assert!(resolved.is_absolute());
assert!(resolve_program("missing", Some(&path_var)).is_err());
let mut l = Ledger::in_memory();
let r = req("1.0.0", "h", &["process:tool"], &[]);
let cap: Capability = "process:tool".parse().unwrap();
let bin = BinaryGrant { path: resolved.clone(), hash: file_hash(&resolved).unwrap() };
l.confirm(&r, &[], [(cap.to_string(), bin)].into());
assert_eq!(l.check_binary(&r.subject, &cap).unwrap(), resolved);
std::fs::write(&tool, "#!/bin/sh\necho evil\n").unwrap();
assert!(l.check_binary(&r.subject, &cap).unwrap_err().contains("changed"));
}
#[test]
fn hashes_and_grouping() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("a"), "1").unwrap();
std::fs::write(tmp.path().join("b"), "2").unwrap();
let h1 = package_hash(tmp.path(), &["a".into(), "b".into()]).unwrap();
assert_eq!(h1, package_hash(tmp.path(), &["b".into(), "a".into()]).unwrap());
std::fs::write(tmp.path().join("b"), "3").unwrap();
assert_ne!(h1, package_hash(tmp.path(), &["a".into(), "b".into()]).unwrap());
let groups = by_risk(&caps(&["storage", "process:git", "net:x.io"]));
assert_eq!(groups.iter().map(|g| g.0).collect::<Vec<_>>(), [Risk::High, Risk::Medium, Risk::Low]);
}
}
+36
View File
@@ -0,0 +1,36 @@
//! The plugin system's kernel, independent of the Wasm runtime:
//!
//! - [`Manifest`]: `plugin.toml` (0.2 format; 0.1 still parses);
//! - [`Capability`]: what a plugin may do, with its [`Risk`] level;
//! - [`ExtensionPoint`]: one kind of contribution, implemented by the crate that
//! owns the feature; [`ExtensionRegistry`] validates a package's declarations
//! ([`ExtensionRegistry::prepare`], no side effects), makes them take effect
//! all or nothing ([`ExtensionRegistry::commit`]) and withdraws them per plugin;
//! - [`UiStore`] / [`UiPoint`]: UI contributions, validated here and projected
//! by the client without the possibility to fail;
//! - [`ledger`]: which capabilities the user granted to which plugin;
//! - [`package`]: the package file, its signature and the marketplace index.
//!
//! Dependency direction: `ide-core` ← `ide-extension` ← feature crates (their
//! extension points) and `ide-plugin` (Wasm) ← `ide-host` (assembly).
mod capability;
mod index;
pub mod ledger;
mod manifest;
pub mod package;
mod point;
mod registry;
mod ui;
mod when;
pub use capability::{Capability, CapabilityInfo, Risk, host_matches};
pub use index::ContributionIndex;
pub use manifest::{
ActivationEvent, Capabilities, ENGINE_VERSION, EVENT_KINDS, LEGACY_API, MANIFEST_FILE, Manifest, check_relative,
engine_compatible, validate_item_id, validate_plugin_id, validate_point_id,
};
pub use point::{Commit, Disposable, ExtensionPoint, MAX_PACKAGE_FILE, Package, Placement, Prepared, decode};
pub use registry::{ContributionInfo, ExtensionRegistry, PreparedSet};
pub use ui::{UiContribution, UiDecl, UiPoint, UiStore, to_json};
pub use when::validate_when;
+501
View File
@@ -0,0 +1,501 @@
//! `plugin.toml`, the package manifest (plan 4.4).
//!
//! ```toml
//! id = "acme.rust-tools" # optional publisher namespace before the first '.'
//! name = "Rust Tools"
//! version = "1.2.0"
//! engines.openidea = "^1.0"
//! wasm = "plugin.wasm"
//! activation = ["on-language:rust", "on-command:check"]
//!
//! [capabilities]
//! required = ["project.read", "editor.write"]
//! optional = ["net:crates.io", "process:cargo"]
//!
//! [[contributes.commands]]
//! id = "check"
//! title = "Check"
//! ```
//!
//! `contributes` is kept as raw TOML: each `contributes.<point>` is parsed and
//! validated by the extension point registered under that id
//! ([`crate::ExtensionRegistry`]), so new contribution types need no change here.
//!
//! Manifests in the 0.1 format (`openidea-api = "0.1"`, `permissions = [...]`)
//! still parse: permissions become required capabilities and the plugin is
//! activated at start-up, as before.
use crate::capability::Capability;
use serde::Deserialize;
use std::collections::BTreeSet;
use std::path::{Component, Path, PathBuf};
pub const MANIFEST_FILE: &str = "plugin.toml";
/// The plugin API (engine) version this host implements.
pub const ENGINE_VERSION: &str = "1.0.0";
/// The 0.1 API, still accepted for contribution-only packages and 0.1 components.
pub const LEGACY_API: &str = "0.1";
#[derive(Clone, Debug, PartialEq)]
pub struct Manifest {
pub id: String,
pub name: String,
pub version: String,
pub description: String,
pub authors: Vec<String>,
pub homepage: Option<String>,
pub license: Option<String>,
/// Release notes of this version (Settings › Plugins › What's New).
pub change_notes: Option<String>,
/// `engines.openidea` (a semver requirement), or the legacy `openidea-api`.
pub engine: String,
/// Written in the 0.1 format.
pub legacy: bool,
/// Component file relative to the package directory.
pub wasm: Option<PathBuf>,
pub activation: Vec<ActivationEvent>,
pub capabilities: Capabilities,
/// `contributes.<point>` → its declarations, validated by the extension points.
pub contributes: toml::Table,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Capabilities {
pub required: Vec<Capability>,
pub optional: Vec<Capability>,
}
impl Capabilities {
pub fn all(&self) -> impl Iterator<Item = &Capability> {
self.required.iter().chain(&self.optional)
}
pub fn is_optional(&self, cap: &Capability) -> bool {
self.optional.contains(cap) && !self.required.contains(cap)
}
}
/// When a plugin's code is instantiated (plan 4.4). Contributions are live
/// without it; code only runs once one of these fires.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum ActivationEvent {
/// One of the plugin's commands is run (`on-command:<id>`; `*` = any).
Command(String),
/// A file of this language is opened.
Language(String),
/// A file matching this glob (project-relative) is opened.
File(String),
/// One of the plugin's views is shown.
View(String),
/// Periodically: `on-schedule:<seconds>`. Does not keep the plugin alive.
Schedule(u32),
/// A workbench event of this kind (`on-event:file-saved`): the plugin is
/// subscribed from the start, and activated to receive it.
Event(String),
/// Shortly after the workbench is ready.
StartupFinished,
}
impl ActivationEvent {
pub fn parse(s: &str) -> Result<Self, String> {
let s = s.trim();
if s == "on-startup-finished" {
return Ok(Self::StartupFinished);
}
let (kind, arg) = s.split_once(':').ok_or_else(|| format!("unknown activation event {s:?}"))?;
let arg = arg.trim();
if arg.is_empty() {
return Err(format!("activation event {s:?} needs a value"));
}
Ok(match kind {
"on-command" => Self::Command(arg.into()),
"on-language" => Self::Language(arg.into()),
"on-file" => Self::File(arg.into()),
"on-view" => Self::View(arg.into()),
"on-schedule" => {
let secs: u32 =
arg.trim_end_matches('s').parse().map_err(|_| format!("{s:?}: expected seconds, e.g. 300"))?;
if secs < 60 {
return Err(format!("{s:?}: the shortest schedule is 60 seconds"));
}
Self::Schedule(secs)
}
"on-event" => {
if !EVENT_KINDS.contains(&arg) {
return Err(format!("{s:?}: unknown event kind (one of {})", EVENT_KINDS.join(", ")));
}
Self::Event(arg.into())
}
_ => return Err(format!("unknown activation event {s:?}")),
})
}
}
/// Workbench event kinds (`on-event:<kind>`, WIT `events.event-kind`).
/// `scheduled` comes from `on-schedule` instead.
pub const EVENT_KINDS: &[&str] = &["file-opened", "file-saved", "editor-focused", "action-performed", "vcs-changed"];
impl std::fmt::Display for ActivationEvent {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Command(c) => write!(f, "on-command:{c}"),
Self::Language(l) => write!(f, "on-language:{l}"),
Self::File(g) => write!(f, "on-file:{g}"),
Self::View(v) => write!(f, "on-view:{v}"),
Self::Schedule(s) => write!(f, "on-schedule:{s}"),
Self::Event(k) => write!(f, "on-event:{k}"),
Self::StartupFinished => f.write_str("on-startup-finished"),
}
}
}
#[derive(Deserialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
struct Raw {
id: String,
name: String,
version: String,
#[serde(default)]
description: String,
#[serde(default)]
authors: Vec<String>,
#[serde(default)]
homepage: Option<String>,
#[serde(default)]
license: Option<String>,
#[serde(default)]
change_notes: Option<String>,
#[serde(default)]
engines: Option<Engines>,
/// 0.1 format.
#[serde(default)]
openidea_api: Option<String>,
#[serde(default)]
wasm: Option<PathBuf>,
#[serde(default)]
activation: Option<Vec<String>>,
#[serde(default)]
capabilities: Option<RawCapabilities>,
/// 0.1 format.
#[serde(default)]
permissions: Option<Vec<String>>,
#[serde(default)]
contributes: toml::Table,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Engines {
openidea: String,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RawCapabilities {
#[serde(default)]
required: Vec<String>,
#[serde(default)]
optional: Vec<String>,
}
impl Manifest {
pub fn parse(src: &str) -> Result<Self, String> {
let raw: Raw = toml::from_str(src).map_err(|e| e.to_string())?;
let (engine, legacy) = match (raw.engines, raw.openidea_api) {
(Some(e), None) => (e.openidea, false),
(None, Some(api)) => (api, true),
(Some(_), Some(_)) => return Err("use `engines.openidea`, not `openidea-api`".into()),
(None, None) => return Err("missing `engines.openidea` (e.g. \"^1.0\")".into()),
};
if legacy && raw.capabilities.is_some() {
return Err("`capabilities` requires `engines.openidea`; 0.1 manifests use `permissions`".into());
}
if !legacy && raw.permissions.is_some() {
return Err("`permissions` is the 0.1 format; use `[capabilities] required = [...]`".into());
}
let parse_caps = |list: Vec<String>| -> Result<Vec<Capability>, String> {
let mut out: Vec<Capability> = Vec::new();
for s in list {
let cap: Capability = s.parse()?;
if !out.contains(&cap) {
out.push(cap);
}
}
Ok(out)
};
let capabilities = match (raw.capabilities, raw.permissions) {
(Some(c), _) => Capabilities { required: parse_caps(c.required)?, optional: parse_caps(c.optional)? },
(None, Some(p)) => Capabilities { required: parse_caps(p)?, optional: Vec::new() },
(None, None) => Capabilities::default(),
};
let activation = match raw.activation {
Some(list) => list.iter().map(|s| ActivationEvent::parse(s)).collect::<Result<Vec<_>, _>>()?,
// 0.1 plugins were activated at start-up; keep that until they move to 1.0.
None if legacy && raw.wasm.is_some() => vec![ActivationEvent::StartupFinished],
None => Vec::new(),
};
let m = Manifest {
id: raw.id,
name: raw.name,
version: raw.version,
description: raw.description,
authors: raw.authors,
homepage: raw.homepage,
license: raw.license,
change_notes: raw.change_notes,
engine,
legacy,
wasm: raw.wasm,
activation,
capabilities,
contributes: raw.contributes,
};
m.validate()?;
Ok(m)
}
pub fn load(dir: &Path) -> Result<Self, String> {
let path = dir.join(MANIFEST_FILE);
let src = std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
Self::parse(&src).map_err(|e| format!("{}: {e}", path.display()))
}
fn validate(&self) -> Result<(), String> {
validate_plugin_id(&self.id)?;
if self.name.trim().is_empty() {
return Err("`name` must not be empty".into());
}
if semver::Version::parse(&self.version).is_err() {
return Err(format!("`version` must be a semantic version like 1.2.3, got {:?}", self.version));
}
if !engine_compatible(&self.engine, self.legacy) {
return Err(if self.legacy {
format!(
"unsupported openidea-api {:?} (this IDE implements {LEGACY_API} and {ENGINE_VERSION})",
self.engine
)
} else {
format!("engines.openidea {:?} does not match this IDE's plugin API {ENGINE_VERSION}", self.engine)
});
}
if let Some(wasm) = &self.wasm {
check_relative(wasm, "wasm")?;
}
if self.wasm.is_none() && !self.activation.is_empty() {
return Err("`activation` needs `wasm`: there is no code to activate".into());
}
let mut seen = BTreeSet::new();
for event in &self.activation {
if !seen.insert(event) {
return Err(format!("duplicate activation event {event}"));
}
}
for (point, decls) in &self.contributes {
validate_point_id(point)?;
if !matches!(decls, toml::Value::Array(_) | toml::Value::Table(_)) {
return Err(format!("`contributes.{point}` must be a table or an array of tables"));
}
}
Ok(())
}
/// The declarations of one extension point: `[[contributes.x]]` gives
/// several, `[contributes.x]` one.
pub fn declarations(&self, point: &str) -> Vec<toml::Value> {
match self.contributes.get(point) {
Some(toml::Value::Array(items)) => items.clone(),
Some(single @ toml::Value::Table(_)) => vec![single.clone()],
_ => Vec::new(),
}
}
/// Number of declarations per extension point (for listings before loading).
pub fn declaration_counts(&self) -> Vec<(String, u32)> {
self.contributes.keys().map(|p| (p.clone(), self.declarations(p).len() as u32)).collect()
}
/// Publisher namespace: the id's part before the first `.` (`acme` in `acme.tools`).
pub fn publisher(&self) -> Option<&str> {
self.id.split_once('.').map(|(p, _)| p)
}
/// Whether `cap` is declared (required or optional).
pub fn declares(&self, cap: &Capability) -> bool {
self.capabilities.all().any(|c| c.covers(cap))
}
}
/// Whether this host can load a plugin written for `engine`.
pub fn engine_compatible(engine: &str, legacy: bool) -> bool {
if legacy {
return engine == LEGACY_API || engine.strip_prefix(LEGACY_API).is_some_and(|rest| rest.starts_with('.'));
}
let Ok(req) = semver::VersionReq::parse(engine) else { return false };
let host = semver::Version::parse(ENGINE_VERSION).expect("valid engine version");
req.matches(&host)
}
/// Segments of `[a-z0-9][a-z0-9_-]*` joined by `.` (an optional publisher
/// namespace first), at most 64 chars.
pub fn validate_plugin_id(id: &str) -> Result<(), String> {
let ok = id.len() <= 64 && !id.is_empty() && id.split('.').all(segment_ok);
if ok {
Ok(())
} else {
Err(format!(
"invalid plugin id {id:?}: use a-z, 0-9, '-', '_' in '.'-separated parts (e.g. acme.tools), max 64"
))
}
}
/// `[a-z0-9][a-z0-9._-]*`, at most 64 chars (commands, themes, views…).
pub fn validate_item_id(id: &str, what: &str) -> Result<(), String> {
let ok_first = id.chars().next().is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit());
let ok_rest = id.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '-' | '_' | '.'));
if ok_first && ok_rest && id.len() <= 64 {
Ok(())
} else {
Err(format!("invalid {what} {id:?}: use a-z, 0-9, '.', '-', '_', starting with a letter or digit (max 64)"))
}
}
/// Extension point ids: `camelCase` or `kebab-case` words, dots allowed (`iconThemes`, `menus`).
pub fn validate_point_id(id: &str) -> Result<(), String> {
let ok = !id.is_empty()
&& id.len() <= 64
&& id.starts_with(|c: char| c.is_ascii_lowercase())
&& id.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '.'));
if ok { Ok(()) } else { Err(format!("invalid contribution point {id:?}")) }
}
fn segment_ok(s: &str) -> bool {
s.chars().next().is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
&& s.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
}
/// Package paths must stay inside the package directory.
pub fn check_relative(path: &Path, what: &str) -> Result<(), String> {
let ok = !path.as_os_str().is_empty() && path.components().all(|c| matches!(c, Component::Normal(_)));
if ok { Ok(()) } else { Err(format!("{what} {:?} must be a relative path inside the plugin", path.display())) }
}
#[cfg(test)]
mod tests {
use super::*;
const FULL: &str = r#"
id = "acme.rust-tools"
name = "Rust Tools"
version = "1.2.0"
engines.openidea = "^1.0"
wasm = "plugin.wasm"
activation = ["on-language:rust", "on-command:check", "on-schedule:300"]
[capabilities]
required = ["project.read", "editor.write"]
optional = ["net:crates.io", "process:cargo"]
[[contributes.commands]]
id = "check"
title = "%check.title%"
[[contributes.menus]]
menu = "editor.context"
command = "check"
when = "editorLang == rust"
[contributes.settings]
schema = "settings.schema.json"
"#;
#[test]
fn event_activation() {
assert_eq!(ActivationEvent::parse("on-event:file-saved"), Ok(ActivationEvent::Event("file-saved".into())));
assert_eq!(ActivationEvent::Event("vcs-changed".into()).to_string(), "on-event:vcs-changed");
let err = ActivationEvent::parse("on-event:keystroke").unwrap_err();
assert!(err.contains("unknown event kind") && err.contains("action-performed"), "{err}");
assert!(ActivationEvent::parse("on-event:scheduled").is_err(), "declared with on-schedule");
}
#[test]
fn parses_the_02_format() {
let m = Manifest::parse(FULL).unwrap();
assert_eq!(m.publisher(), Some("acme"));
assert!(!m.legacy);
assert_eq!(m.activation.len(), 3);
assert_eq!(m.activation[2], ActivationEvent::Schedule(300));
assert_eq!(m.capabilities.required, vec![Capability::ProjectRead, Capability::EditorWrite]);
assert!(m.capabilities.is_optional(&Capability::Process("cargo".into())));
assert_eq!(m.declarations("commands").len(), 1);
assert_eq!(m.declarations("settings").len(), 1, "a single table is one declaration");
assert!(m.declarations("nope").is_empty());
let counts: Vec<_> = m.declaration_counts();
assert!(counts.contains(&("menus".into(), 1)));
}
#[test]
fn parses_the_01_format() {
let m = Manifest::parse(
"id = \"hello\"\nname = \"Hello\"\nversion = \"0.1.0\"\nopenidea-api = \"0.1\"\nwasm = \"dist/plugin.wasm\"\n\
permissions = [\"project-read\", \"editor-write\"]\n[[contributes.commands]]\nid = \"x\"\ntitle = \"X\"",
)
.unwrap();
assert!(m.legacy);
assert_eq!(m.capabilities.required, vec![Capability::ProjectRead, Capability::EditorWrite]);
assert_eq!(m.activation, vec![ActivationEvent::StartupFinished]);
assert_eq!(m.publisher(), None);
}
fn err(src: &str) -> String {
Manifest::parse(src).unwrap_err()
}
#[test]
fn rejects_invalid_manifests() {
let base = |extra: &str| format!("id = \"ok\"\nname = \"N\"\nversion = \"1.0.0\"\n{extra}");
assert!(err(&base("")).contains("engines.openidea"));
assert!(err(&base("engines.openidea = \"^0.3\"")).contains("does not match"));
assert!(err(&base("engines.openidea = \"^2\"")).contains("does not match"));
assert!(err(&base("engines.openidea = \"^0.2\"")).contains("does not match"));
assert!(err(&base("openidea-api = \"0.2\"")).contains("unsupported"));
assert!(err(&base("engines.openidea = \"^1.0\"\npermissions = [\"project-read\"]")).contains("0.1 format"));
assert!(
err(&base("engines.openidea = \"^1.0\"\n[capabilities]\nrequired = [\"network\"]")).contains("unknown")
);
assert!(err(&base("engines.openidea = \"^1.0\"\nwasm = \"../x.wasm\"")).contains("relative path"));
assert!(err(&base("engines.openidea = \"^1.0\"\nactivation = [\"on-command:x\"]")).contains("needs `wasm`"));
assert!(
err(&base("engines.openidea = \"^1.0\"\nwasm = \"p.wasm\"\nactivation = [\"on-boot\"]"))
.contains("unknown")
);
assert!(
err(&base("engines.openidea = \"^1.0\"\nwasm = \"p.wasm\"\nactivation = [\"on-schedule:5\"]"))
.contains("60 seconds")
);
assert!(err(&base("engines.openidea = \"^1.0\"\nbogus = 1")).contains("unknown field"));
assert!(err(&base("engines.openidea = \"^1.0\"\ncontributes.commands = 3")).contains("table"));
assert!(
err("id = \"Bad\"\nname = \"N\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"").contains("plugin id")
);
assert!(
err("id = \"a..b\"\nname = \"N\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"").contains("plugin id")
);
assert!(err("id = \"ok\"\nname = \"N\"\nversion = \"1.0\"\nengines.openidea = \"^1.0\"").contains("version"));
}
#[test]
fn ids() {
assert!(validate_plugin_id("openidea.material-icons").is_ok());
assert!(validate_plugin_id("hello_2").is_ok());
assert!(validate_plugin_id(".x").is_err());
assert!(validate_item_id("word-count.v2_x", "command id").is_ok());
assert!(validate_item_id("-x", "command id").is_err());
assert!(validate_point_id("iconThemes").is_ok());
assert!(validate_point_id("Icon").is_err());
assert!(engine_compatible("1.0", false));
assert!(engine_compatible(">=1.0, <2", false));
assert!(!engine_compatible("^0.2", false), "0.x components don't run on 1.0");
assert!(engine_compatible("0.1.3", true));
assert!(!engine_compatible("0.10", true));
}
}
+465
View File
@@ -0,0 +1,465 @@
//! Plugin distribution: the package file, its
//! signature, and the static marketplace index.
//!
//! - **Package** (`<id>-<version>.oiplugin`): a zip of the manifest, the files
//! its declarations reference, the component and licence files, plus
//! [`SIGNATURE_FILE`] when signed.
//! - **Signature**: Ed25519 over `openidea-plugin/1\n<id>\n<version>\n<digest>`,
//! `digest` being [`package_digest`] of the unpacked files. A signature
//! proves who published the bytes; whether to trust that publisher is the
//! index's call.
//! - **Index** (`index.json`): the publishers and their keys (the trust root),
//! the published plugins, and revocations. A publisher owns the id prefix
//! `<publisher>.`; a revoked key invalidates everything it signed, a
//! revoked plugin version is disabled where it is installed.
use crate::MANIFEST_FILE;
use base64::Engine as _;
use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
use serde::{Deserialize, Serialize};
use std::io::{Read, Write};
use std::path::{Component, Path, PathBuf};
pub const SIGNATURE_FILE: &str = "SIGNATURE.json";
pub const PACKAGE_EXTENSION: &str = "oiplugin";
pub const INDEX_FORMAT: u32 = 1;
/// Limits when unpacking an untrusted package.
const MAX_ENTRIES: usize = 10_000;
const MAX_TOTAL: u64 = 512 << 20;
const B64: base64::engine::GeneralPurpose = base64::engine::general_purpose::STANDARD;
/// Content digest of a package: the manifest plus `files` (relative paths).
pub fn package_digest(dir: &Path, files: &[PathBuf]) -> std::io::Result<String> {
let mut all = files.to_vec();
all.push(PathBuf::from(MANIFEST_FILE));
crate::ledger::package_hash(dir, &all)
}
fn signed_message(id: &str, version: &str, digest: &str) -> Vec<u8> {
format!("openidea-plugin/1\n{id}\n{version}\n{digest}").into_bytes()
}
/// `SIGNATURE.json` of a signed package.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PackageSignature {
pub publisher: String,
/// Ed25519 public key, base64.
pub key: String,
/// Ed25519 signature, base64.
pub signature: String,
}
/// A publisher's key pair (the 32-byte seed, base64, in a file only the publisher has).
pub struct PublisherKey(SigningKey);
impl PublisherKey {
pub fn from_seed(seed: [u8; 32]) -> Self {
Self(SigningKey::from_bytes(&seed))
}
/// The seed as written by `openidea-plugin keygen`.
pub fn from_base64(text: &str) -> Result<Self, String> {
let bytes = B64.decode(text.trim()).map_err(|e| format!("key file: {e}"))?;
let seed: [u8; 32] = bytes.try_into().map_err(|_| "key file: expected a 32-byte key".to_owned())?;
Ok(Self::from_seed(seed))
}
pub fn seed_base64(&self) -> String {
B64.encode(self.0.to_bytes())
}
pub fn public_base64(&self) -> String {
B64.encode(self.0.verifying_key().to_bytes())
}
pub fn sign(&self, publisher: &str, id: &str, version: &str, digest: &str) -> PackageSignature {
let sig = self.0.sign(&signed_message(id, version, digest));
PackageSignature {
publisher: publisher.into(),
key: self.public_base64(),
signature: B64.encode(sig.to_bytes()),
}
}
}
impl PackageSignature {
/// Whether the signature is valid for `id`, `version` and `digest` (the
/// key's validity is checked against an [`Index`]).
pub fn check(&self, id: &str, version: &str, digest: &str) -> Result<(), String> {
let key: [u8; 32] = B64
.decode(&self.key)
.ok()
.and_then(|b| b.try_into().ok())
.ok_or_else(|| "signature: malformed public key".to_owned())?;
let key = VerifyingKey::from_bytes(&key).map_err(|_| "signature: invalid public key".to_owned())?;
let sig: [u8; 64] = B64
.decode(&self.signature)
.ok()
.and_then(|b| b.try_into().ok())
.ok_or_else(|| "signature: malformed".to_owned())?;
key.verify_strict(&signed_message(id, version, digest), &Signature::from_bytes(&sig))
.map_err(|_| "the signature does not match the package (it was changed after signing)".to_owned())
}
}
/// Writes the package `out` from `dir`: the manifest, `files`, and the signature if any.
pub fn pack(dir: &Path, files: &[PathBuf], out: &Path, signature: Option<&PackageSignature>) -> Result<(), String> {
let io = |e: std::io::Error| e.to_string();
let file = std::fs::File::create(out).map_err(io)?;
let mut zip = zip::ZipWriter::new(file);
let options = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
let mut names: Vec<PathBuf> = std::iter::once(PathBuf::from(MANIFEST_FILE)).chain(files.iter().cloned()).collect();
names.sort();
names.dedup();
for rel in names {
let name = rel.to_str().ok_or("non UTF-8 file name")?.replace('\\', "/");
zip.start_file(name, options).map_err(|e| e.to_string())?;
let bytes = std::fs::read(dir.join(&rel)).map_err(|e| format!("{}: {e}", rel.display()))?;
zip.write_all(&bytes).map_err(io)?;
}
if let Some(sig) = signature {
zip.start_file(SIGNATURE_FILE, options).map_err(|e| e.to_string())?;
zip.write_all(serde_json::to_string_pretty(sig).map_err(|e| e.to_string())?.as_bytes()).map_err(io)?;
}
zip.finish().map_err(|e| e.to_string())?;
Ok(())
}
/// What [`unpack`] wrote.
#[derive(Debug)]
pub struct Unpacked {
/// Every file but the manifest and the signature, relative.
pub files: Vec<PathBuf>,
pub signature: Option<PackageSignature>,
}
/// `name` as a relative path that stays inside the package.
fn safe_path(name: &str) -> Option<PathBuf> {
let p = Path::new(name);
(!name.is_empty() && p.components().all(|c| matches!(c, Component::Normal(_)))).then(|| p.to_path_buf())
}
/// Archiver metadata at the top of an archive, not part of a package
/// (`__MACOSX/…`, `.DS_Store`, AppleDouble `._…` files).
fn archiver_metadata(name: &str) -> bool {
let top = name.split('/').next().unwrap_or_default();
top == "__MACOSX" || top == ".DS_Store" || (top.starts_with("._") && top.len() > 2)
}
/// Where the package is inside an archive: at its root (`.oiplugin`, or a zip
/// of the package's contents) or in its only folder (a zip of the package
/// folder). `None` when neither holds a manifest.
fn package_prefix(names: &[String]) -> Option<String> {
if names.iter().any(|n| n == MANIFEST_FILE) {
return Some(String::new());
}
let mut tops = names.iter().filter(|n| !archiver_metadata(n)).map(|n| n.split('/').next().unwrap_or_default());
let top = tops.next()?;
if tops.any(|t| t != top) {
return None;
}
let prefix = format!("{top}/");
names.iter().any(|n| *n == format!("{prefix}{MANIFEST_FILE}")).then_some(prefix)
}
/// Extracts the package `archive` (an `.oiplugin`, or a `.zip` of a package
/// folder or of its contents) into the empty directory `dest`, refusing
/// paths leaving it, links, and oversized contents.
pub fn unpack(archive: &Path, dest: &Path) -> Result<Unpacked, String> {
let file = std::fs::File::open(archive).map_err(|e| format!("{}: {e}", archive.display()))?;
let mut zip =
zip::ZipArchive::new(file).map_err(|e| format!("{}: not a plugin package ({e})", archive.display()))?;
if zip.len() > MAX_ENTRIES {
return Err(format!("more than {MAX_ENTRIES} files"));
}
let mut names = Vec::with_capacity(zip.len());
for i in 0..zip.len() {
let entry = zip.by_index(i).map_err(|e| e.to_string())?;
if !entry.is_dir() {
names.push(entry.name().map_err(|e| e.to_string())?.into_owned());
}
}
// An IntelliJ plugin or any other zip: say what is missing rather than fail later.
let prefix = package_prefix(&names).ok_or_else(|| {
format!("{} is not an OpenIDEA plugin: no {MANIFEST_FILE} at its root or in its only folder", archive.display())
})?;
std::fs::create_dir_all(dest).map_err(|e| e.to_string())?;
let (mut files, mut signature, mut total, mut manifest) = (Vec::new(), None, 0u64, false);
for i in 0..zip.len() {
let mut entry = zip.by_index(i).map_err(|e| e.to_string())?;
if entry.is_dir() {
continue;
}
let full = entry.name().map_err(|e| e.to_string())?.into_owned();
// Outside the package folder there is only archiver metadata (`package_prefix` checked).
let Some(name) = full.strip_prefix(&prefix).map(str::to_owned) else { continue };
if archiver_metadata(&name) {
continue;
}
if entry.is_symlink() {
return Err(format!("{full}: links are not allowed in packages"));
}
let rel = safe_path(&name).ok_or_else(|| format!("invalid path {full:?} in package"))?;
let mut bytes = Vec::new();
entry.by_ref().take(MAX_TOTAL - total + 1).read_to_end(&mut bytes).map_err(|e| e.to_string())?;
total += bytes.len() as u64;
if total > MAX_TOTAL {
return Err(format!("the package unpacks to more than {} MiB", MAX_TOTAL >> 20));
}
if rel == Path::new(SIGNATURE_FILE) {
signature = Some(serde_json::from_slice(&bytes).map_err(|e| format!("{SIGNATURE_FILE}: {e}"))?);
continue;
}
let to = dest.join(&rel);
if let Some(parent) = to.parent() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
std::fs::write(&to, bytes).map_err(|e| e.to_string())?;
if rel == Path::new(MANIFEST_FILE) {
manifest = true;
} else {
files.push(rel);
}
}
if !manifest {
return Err(format!("the package has no {MANIFEST_FILE}"));
}
files.sort();
Ok(Unpacked { files, signature })
}
// ---- index -------------------------------------------------------------------
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct Index {
pub format: u32,
#[serde(default)]
pub publishers: Vec<Publisher>,
#[serde(default)]
pub plugins: Vec<IndexEntry>,
#[serde(default)]
pub revoked: Vec<Revocation>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct Publisher {
/// Owns plugin ids `<id>.…`.
pub id: String,
pub name: String,
pub keys: Vec<KeyEntry>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct KeyEntry {
/// Ed25519 public key, base64.
pub key: String,
/// Packages signed with it are no longer trusted.
#[serde(default)]
pub revoked: bool,
}
/// A published plugin version.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct IndexEntry {
pub id: String,
pub name: String,
pub version: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub authors: Vec<String>,
pub publisher: String,
/// The package, relative to the index (or an absolute URL).
pub download: String,
/// [`package_digest`] of its contents.
pub digest: String,
#[serde(default)]
pub capabilities: Vec<String>,
}
/// A plugin (all versions when `versions` is empty) that must not run.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct Revocation {
pub id: String,
#[serde(default)]
pub versions: Vec<String>,
pub reason: String,
}
impl Index {
pub fn parse(text: &str) -> Result<Self, String> {
let index: Index = serde_json::from_str(text).map_err(|e| format!("index: {e}"))?;
if index.format != INDEX_FORMAT {
return Err(format!("index format {} is not supported (this IDE reads {INDEX_FORMAT})", index.format));
}
Ok(index)
}
/// The revocation that applies to `id` at `version`.
pub fn revocation(&self, id: &str, version: &str) -> Option<&Revocation> {
self.revoked.iter().find(|r| r.id == id && (r.versions.is_empty() || r.versions.iter().any(|v| v == version)))
}
/// Whether `key` (base64) belongs to a publisher and was revoked.
pub fn key_revoked(&self, key: &str) -> bool {
self.publishers.iter().flat_map(|p| &p.keys).any(|k| k.key == key && k.revoked)
}
/// The trusted publisher of a signed package: the signature is valid, its
/// key is one of the publisher's and not revoked, and the publisher owns
/// the plugin's id prefix.
pub fn verify(&self, sig: &PackageSignature, id: &str, version: &str, digest: &str) -> Result<String, String> {
let publisher = self
.publishers
.iter()
.find(|p| p.id == sig.publisher)
.ok_or_else(|| format!("unknown publisher {:?}", sig.publisher))?;
let key = publisher
.keys
.iter()
.find(|k| k.key == sig.key)
.ok_or_else(|| format!("the key is not one of {}'s", publisher.name))?;
if key.revoked {
return Err(format!("{}'s signing key was revoked", publisher.name));
}
if !id.starts_with(&format!("{}.", publisher.id)) {
return Err(format!("{} may only publish plugins named {}.*", publisher.name, publisher.id));
}
sig.check(id, version, digest)?;
Ok(publisher.id.clone())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn package(dir: &Path) -> Vec<PathBuf> {
std::fs::create_dir_all(dir.join("dist")).unwrap();
std::fs::write(dir.join(MANIFEST_FILE), "id = \"acme.tools\"\nversion = \"1.0.0\"").unwrap();
std::fs::write(dir.join("dist/plugin.wasm"), b"\0asm").unwrap();
vec![PathBuf::from("dist/plugin.wasm")]
}
fn index(key: &PublisherKey) -> Index {
Index {
format: INDEX_FORMAT,
publishers: vec![Publisher {
id: "acme".into(),
name: "ACME".into(),
keys: vec![KeyEntry { key: key.public_base64(), revoked: false }],
}],
plugins: Vec::new(),
revoked: vec![Revocation {
id: "acme.bad".into(),
versions: vec!["1.0.0".into()],
reason: "malware".into(),
}],
}
}
#[test]
fn signed_packages_round_trip_and_tampering_is_caught() {
let tmp = tempfile::tempdir().unwrap();
let src = tmp.path().join("src");
let files = package(&src);
let digest = package_digest(&src, &files).unwrap();
let key = PublisherKey::from_seed([7; 32]);
let sig = key.sign("acme", "acme.tools", "1.0.0", &digest);
let out = tmp.path().join("acme.tools-1.0.0.oiplugin");
pack(&src, &files, &out, Some(&sig)).unwrap();
let dest = tmp.path().join("dest");
let unpacked = unpack(&out, &dest).unwrap();
assert_eq!(unpacked.files, files);
assert_eq!(package_digest(&dest, &unpacked.files).unwrap(), digest);
let index = index(&key);
assert_eq!(index.verify(unpacked.signature.as_ref().unwrap(), "acme.tools", "1.0.0", &digest).unwrap(), "acme");
// Changed bytes, another id, someone else's prefix, a revoked key.
assert!(index.verify(&sig, "acme.tools", "1.0.0", "0000").is_err());
assert!(index.verify(&sig, "acme.tools", "1.0.1", &digest).is_err());
let other = PublisherKey::from_seed([9; 32]).sign("acme", "acme.tools", "1.0.0", &digest);
assert!(index.verify(&other, "acme.tools", "1.0.0", &digest).unwrap_err().contains("not one of"));
let foreign = key.sign("acme", "evil.tools", "1.0.0", &digest);
assert!(index.verify(&foreign, "evil.tools", "1.0.0", &digest).unwrap_err().contains("acme.*"));
let mut revoked = index.clone();
revoked.publishers[0].keys[0].revoked = true;
assert!(revoked.verify(&sig, "acme.tools", "1.0.0", &digest).unwrap_err().contains("revoked"));
assert!(revoked.key_revoked(&sig.key));
assert_eq!(index.revocation("acme.bad", "1.0.0").unwrap().reason, "malware");
assert!(index.revocation("acme.bad", "1.0.1").is_none());
assert_eq!(PublisherKey::from_base64(&key.seed_base64()).unwrap().public_base64(), key.public_base64());
}
#[test]
fn unpacking_refuses_paths_leaving_the_package() {
let tmp = tempfile::tempdir().unwrap();
let out = tmp.path().join("evil.oiplugin");
let mut zip = zip::ZipWriter::new(std::fs::File::create(&out).unwrap());
let o = zip::write::SimpleFileOptions::default();
zip.start_file(MANIFEST_FILE, o).unwrap();
zip.write_all(b"id = \"x\"").unwrap();
zip.start_file("../outside.txt", o).unwrap();
zip.write_all(b"x").unwrap();
zip.finish().unwrap();
let err = unpack(&out, &tmp.path().join("dest")).unwrap_err();
assert!(err.contains("invalid path"), "{err}");
assert!(!tmp.path().join("outside.txt").exists());
assert!(Index::parse(r#"{"format": 2}"#).is_err());
}
fn zip_of(path: &Path, entries: &[(&str, &str)]) {
let mut zip = zip::ZipWriter::new(std::fs::File::create(path).unwrap());
for (name, body) in entries {
zip.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
zip.write_all(body.as_bytes()).unwrap();
}
zip.finish().unwrap();
}
#[test]
fn a_zip_holds_the_package_at_its_root_or_in_its_only_folder() {
let tmp = tempfile::tempdir().unwrap();
let manifest = "id = \"acme.tools\"";
let at_root = tmp.path().join("root.zip");
zip_of(&at_root, &[(MANIFEST_FILE, manifest), ("dist/plugin.wasm", "w"), (".DS_Store", "x")]);
let got = unpack(&at_root, &tmp.path().join("a")).unwrap();
assert_eq!(got.files, [PathBuf::from("dist/plugin.wasm")]);
// What zipping the folder in Finder makes: the folder plus `__MACOSX`.
let folder = tmp.path().join("folder.zip");
zip_of(
&folder,
&[
("acme-tools/plugin.toml", manifest),
("acme-tools/dist/plugin.wasm", "w"),
("__MACOSX/acme-tools/._plugin.toml", "x"),
],
);
let dest = tmp.path().join("b");
let got = unpack(&folder, &dest).unwrap();
assert_eq!(got.files, [PathBuf::from("dist/plugin.wasm")]);
assert!(dest.join(MANIFEST_FILE).is_file());
assert!(!dest.join("__MACOSX").exists() && !dest.join("acme-tools").exists());
// Two folders, a manifest one level too deep, or none (an IntelliJ plugin): not a package.
for (name, entries) in [
("two.zip", &[("a/plugin.toml", manifest), ("b/x.txt", "x")][..]),
("deep.zip", &[("a/b/plugin.toml", manifest)][..]),
("intellij.zip", &[("my-plugin/lib/my-plugin.jar", "jar")][..]),
] {
let path = tmp.path().join(name);
zip_of(&path, entries);
let err = unpack(&path, &tmp.path().join(name).with_extension("out")).unwrap_err();
assert!(err.contains("is not an OpenIDEA plugin"), "{name}: {err}");
}
}
}
+175
View File
@@ -0,0 +1,175 @@
//! The extension point contract (plan 4.1).
use crate::capability::Capability;
use crate::manifest::{Manifest, check_relative};
use std::path::{Path, PathBuf};
use std::sync::Arc;
/// Where a contribution takes effect (plan 3, placement rules).
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub enum Placement {
/// Projected to the client (menus, keybindings, themes, icons).
Ui,
/// Takes effect on the machine the project is on (languages, LSP, run configurations).
Workspace,
}
/// The package a declaration comes from, as extension points see it.
#[derive(Clone, Debug)]
pub struct Package {
pub manifest: Arc<Manifest>,
/// Package directory.
pub dir: PathBuf,
/// Hash of the package's files when they never change (an installed
/// copy): validated declarations are then reused from the
/// [`ContributionIndex`](crate::ContributionIndex).
pub digest: Option<Arc<str>>,
}
/// Largest single package file an extension point reads.
pub const MAX_PACKAGE_FILE: u64 = 8 << 20;
impl Package {
pub fn new(manifest: Manifest, dir: PathBuf) -> Self {
Self { manifest: Arc::new(manifest), dir, digest: None }
}
pub fn with_digest(mut self, digest: &str) -> Self {
self.digest = Some(digest.into());
self
}
pub fn id(&self) -> &str {
&self.manifest.id
}
/// Resolves a package-relative path, refusing anything that leaves the
/// package (`..`, absolute paths, symlinks pointing outside).
pub fn resolve(&self, rel: &Path) -> Result<PathBuf, String> {
check_relative(rel, "path")?;
let joined = self.dir.join(rel);
let real = joined.canonicalize().map_err(|_| format!("{}: no such file in the plugin", rel.display()))?;
let root = self.dir.canonicalize().map_err(|e| format!("plugin directory: {e}"))?;
if !real.starts_with(&root) {
return Err(format!("{}: points outside the plugin", rel.display()));
}
Ok(real)
}
pub fn read_bytes(&self, rel: &Path) -> Result<Vec<u8>, String> {
let real = self.resolve(rel)?;
let meta = std::fs::metadata(&real).map_err(|e| format!("{}: {e}", rel.display()))?;
if !meta.is_file() {
return Err(format!("{}: not a file", rel.display()));
}
if meta.len() > MAX_PACKAGE_FILE {
return Err(format!("{}: larger than {} MiB", rel.display(), MAX_PACKAGE_FILE >> 20));
}
std::fs::read(&real).map_err(|e| format!("{}: {e}", rel.display()))
}
pub fn read_text(&self, rel: &Path) -> Result<String, String> {
String::from_utf8(self.read_bytes(rel)?).map_err(|_| format!("{}: not UTF-8 text", rel.display()))
}
}
/// Undoes one committed contribution. Dropping without [`dispose`](Self::dispose) also disposes.
#[must_use = "dropping a Disposable undoes the contribution"]
pub struct Disposable(Option<Box<dyn FnOnce() + Send>>);
impl Disposable {
pub fn new(f: impl FnOnce() + Send + 'static) -> Self {
Self(Some(Box::new(f)))
}
/// A contribution with nothing to undo.
pub fn noop() -> Self {
Self(None)
}
pub fn dispose(mut self) {
if let Some(f) = self.0.take() {
f();
}
}
}
impl Drop for Disposable {
fn drop(&mut self) {
if let Some(f) = self.0.take() {
f();
}
}
}
impl std::fmt::Debug for Disposable {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("Disposable")
}
}
pub type Commit = Box<dyn FnOnce() -> Result<Disposable, String> + Send>;
/// A validated declaration, ready to take effect.
pub struct Prepared {
/// Capabilities this declaration needs (a language with an LSP → `process:<binary>`).
pub capabilities: Vec<Capability>,
/// Package files the declaration references (copied on install).
pub files: Vec<PathBuf>,
/// Identity within the point: a later declaration with the same key replaces
/// the earlier one of the same plugin (re-registering a command).
pub key: Option<String>,
/// User-facing one-liner for the plugin details page ("Insert Date").
pub label: String,
/// Makes it take effect. Must not fail for reasons `prepare` could have checked.
pub commit: Commit,
}
impl Prepared {
pub fn new(label: impl Into<String>, commit: impl FnOnce() -> Result<Disposable, String> + Send + 'static) -> Self {
Self { capabilities: Vec::new(), files: Vec::new(), key: None, label: label.into(), commit: Box::new(commit) }
}
pub fn key(mut self, key: impl Into<String>) -> Self {
self.key = Some(key.into());
self
}
pub fn file(mut self, path: impl Into<PathBuf>) -> Self {
self.files.push(path.into());
self
}
pub fn capability(mut self, cap: Capability) -> Self {
if !self.capabilities.contains(&cap) {
self.capabilities.push(cap);
}
self
}
}
impl std::fmt::Debug for Prepared {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Prepared").field("key", &self.key).field("label", &self.label).finish_non_exhaustive()
}
}
/// A kind of contribution (`contributes.<id>` in the manifest). Implemented by
/// the crate that owns the feature; registered once in an [`crate::ExtensionRegistry`].
pub trait ExtensionPoint: Send + Sync {
/// `"languages"`, `"iconThemes"`, …
fn id(&self) -> &'static str;
fn placement(&self) -> Placement;
/// Parses and validates one declaration without side effects, and works
/// out the capabilities it needs. `Ui` points validate completely here: the
/// client's projection is not allowed to fail.
fn prepare(&self, pkg: &Package, decl: toml::Value) -> Result<Prepared, String>;
}
/// Deserializes a declaration with serde, prefixing errors with the point id.
pub fn decode<T: serde::de::DeserializeOwned>(point: &str, decl: toml::Value) -> Result<T, String> {
decl.try_into().map_err(|e: toml::de::Error| format!("contributes.{point}: {}", e.message()))
}
+305
View File
@@ -0,0 +1,305 @@
//! Extension points plus the contributions committed to them, accounted per plugin.
use crate::capability::Capability;
use crate::point::{Disposable, ExtensionPoint, Package, Placement, Prepared};
use parking_lot::{Mutex, RwLock};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::Arc;
/// A contribution as the plugin details page lists it.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct ContributionInfo {
pub point: String,
pub label: String,
}
struct Entry {
point: &'static str,
key: Option<String>,
label: String,
disposable: Disposable,
}
/// Every declaration of a package, validated; nothing has taken effect yet.
pub struct PreparedSet {
pub plugin: String,
/// Union of what the declarations need (beyond the manifest's own list).
pub capabilities: Vec<Capability>,
/// Package files the declarations reference.
pub files: Vec<PathBuf>,
/// Declarations for extension points this host does not know (skipped).
pub warnings: Vec<String>,
items: Vec<(&'static str, Prepared)>,
}
impl PreparedSet {
pub fn is_empty(&self) -> bool {
self.items.is_empty()
}
pub fn len(&self) -> usize {
self.items.len()
}
}
impl std::fmt::Debug for PreparedSet {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PreparedSet")
.field("plugin", &self.plugin)
.field("items", &self.items.len())
.field("warnings", &self.warnings)
.finish()
}
}
#[derive(Default)]
pub struct ExtensionRegistry {
points: RwLock<BTreeMap<&'static str, Arc<dyn ExtensionPoint>>>,
/// plugin id → its contributions in commit order.
entries: Mutex<BTreeMap<String, Vec<Entry>>>,
}
impl ExtensionRegistry {
pub fn new() -> Self {
Self::default()
}
/// Adds an extension point. Ids are unique.
pub fn register(&self, point: Arc<dyn ExtensionPoint>) -> Result<(), String> {
let id = point.id();
crate::manifest::validate_point_id(id)?;
let mut points = self.points.write();
if points.contains_key(id) {
return Err(format!("extension point {id:?} is already registered"));
}
points.insert(id, point);
Ok(())
}
pub fn point_ids(&self) -> Vec<&'static str> {
self.points.read().keys().copied().collect()
}
pub fn placement(&self, point: &str) -> Option<Placement> {
self.points.read().get(point).map(|p| p.placement())
}
fn point(&self, id: &str) -> Option<Arc<dyn ExtensionPoint>> {
self.points.read().get(id).cloned()
}
/// Validates every `contributes.<point>` declaration of `pkg`. Fails as a
/// whole on the first invalid declaration; unknown points are skipped with
/// a warning (forward compatibility).
pub fn prepare(&self, pkg: &Package) -> Result<PreparedSet, String> {
let mut set = PreparedSet {
plugin: pkg.id().to_owned(),
capabilities: Vec::new(),
files: Vec::new(),
warnings: Vec::new(),
items: Vec::new(),
};
for point_id in pkg.manifest.contributes.keys() {
let Some(point) = self.point(point_id) else {
let msg = format!("contributes.{point_id}: not supported by this version of the IDE; skipped");
tracing::warn!(plugin = %pkg.id(), "{msg}");
set.warnings.push(msg);
continue;
};
for (i, decl) in pkg.manifest.declarations(point_id).into_iter().enumerate() {
let prepared = point
.prepare(pkg, decl)
.map_err(|e| format!("contributes.{point_id}[{i}]: {}", strip_prefix(&e, point_id)))?;
for cap in &prepared.capabilities {
if !set.capabilities.contains(cap) {
set.capabilities.push(cap.clone());
}
}
for f in &prepared.files {
if !set.files.contains(f) {
set.files.push(f.clone());
}
}
set.items.push((point.id(), prepared));
}
}
Ok(set)
}
/// Makes a prepared set take effect, all or nothing: if one commit fails,
/// the ones before it are disposed in reverse order.
pub fn commit(&self, set: PreparedSet) -> Result<(), String> {
let mut committed: Vec<Entry> = Vec::with_capacity(set.items.len());
for (point, prepared) in set.items {
let Prepared { key, label, commit, .. } = prepared;
match commit() {
Ok(disposable) => committed.push(Entry { point, key, label, disposable }),
Err(e) => {
tracing::error!(plugin = %set.plugin, "contributes.{point}: commit failed: {e}");
while let Some(entry) = committed.pop() {
entry.disposable.dispose();
}
return Err(format!("contributes.{point}: {e}"));
}
}
}
let replaced = {
let mut entries = self.entries.lock();
let list = entries.entry(set.plugin).or_default();
let mut replaced = Vec::new();
for new in &committed {
if let Some(key) = &new.key
&& let Some(pos) = list.iter().position(|e| e.point == new.point && e.key.as_ref() == Some(key))
{
replaced.push(list.remove(pos));
}
}
list.extend(committed);
replaced
};
for old in replaced.into_iter().rev() {
old.disposable.dispose();
}
Ok(())
}
/// Withdraws everything `plugin` contributed, newest first.
pub fn dispose_plugin(&self, plugin: &str) -> bool {
let list = self.entries.lock().remove(plugin).unwrap_or_default();
let any = !list.is_empty();
for entry in list.into_iter().rev() {
entry.disposable.dispose();
}
any
}
pub fn contributions(&self, plugin: &str) -> Vec<ContributionInfo> {
self.entries
.lock()
.get(plugin)
.map(|list| {
list.iter().map(|e| ContributionInfo { point: e.point.into(), label: e.label.clone() }).collect()
})
.unwrap_or_default()
}
/// How many contributions `plugin` has at `point`.
pub fn count(&self, plugin: &str, point: &str) -> usize {
self.entries.lock().get(plugin).map_or(0, |list| list.iter().filter(|e| e.point == point).count())
}
/// Plugins with at least one contribution.
pub fn plugins(&self) -> Vec<String> {
self.entries.lock().iter().filter(|(_, l)| !l.is_empty()).map(|(id, _)| id.clone()).collect()
}
}
fn strip_prefix<'a>(e: &'a str, point: &str) -> &'a str {
e.strip_prefix(&format!("contributes.{point}: ")).unwrap_or(e)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::manifest::Manifest;
use std::sync::atomic::{AtomicUsize, Ordering};
/// Records live items in a shared list; `fail = true` makes commit fail.
struct Recording {
id: &'static str,
live: Arc<Mutex<Vec<String>>>,
commits: Arc<AtomicUsize>,
}
impl ExtensionPoint for Recording {
fn id(&self) -> &'static str {
self.id
}
fn placement(&self) -> Placement {
Placement::Ui
}
fn prepare(&self, _pkg: &Package, decl: toml::Value) -> Result<Prepared, String> {
let name = decl.get("name").and_then(|v| v.as_str()).ok_or("missing name")?.to_owned();
let fail = decl.get("fail").and_then(|v| v.as_bool()).unwrap_or(false);
let (live, commits) = (self.live.clone(), self.commits.clone());
let label = name.clone();
let mut p = Prepared::new(label, move || {
commits.fetch_add(1, Ordering::SeqCst);
if fail {
return Err("boom".into());
}
live.lock().push(name.clone());
Ok(Disposable::new(move || live.lock().retain(|n| *n != name)))
});
if let Some(f) = decl.get("file").and_then(|v| v.as_str()) {
p = p.file(f);
}
if decl.get("key").and_then(|v| v.as_bool()).unwrap_or(false) {
p = p.key("k");
}
Ok(p)
}
}
fn setup() -> (ExtensionRegistry, Arc<Mutex<Vec<String>>>) {
let reg = ExtensionRegistry::new();
let live = Arc::new(Mutex::new(Vec::new()));
let commits = Arc::new(AtomicUsize::new(0));
reg.register(Arc::new(Recording { id: "things", live: live.clone(), commits: commits.clone() })).unwrap();
reg.register(Arc::new(Recording { id: "others", live: live.clone(), commits })).unwrap();
(reg, live)
}
fn pkg(contributes: &str) -> Package {
let src = format!("id = \"p\"\nname = \"P\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"\n{contributes}");
Package::new(Manifest::parse(&src).unwrap(), PathBuf::from("/nonexistent"))
}
#[test]
fn commits_and_disposes_per_plugin() {
let (reg, live) = setup();
assert!(
reg.register(Arc::new(Recording { id: "things", live: live.clone(), commits: Default::default() }))
.is_err()
);
let p = pkg(
"[[contributes.things]]\nname = \"a\"\nfile = \"x.json\"\n[[contributes.things]]\nname = \"b\"\n[contributes.others]\nname = \"c\"",
);
let set = reg.prepare(&p).unwrap();
assert_eq!(set.len(), 3);
assert_eq!(set.files, [PathBuf::from("x.json")]);
assert!(live.lock().is_empty(), "prepare has no side effects");
reg.commit(set).unwrap();
assert_eq!(*live.lock(), ["c", "a", "b"]);
assert_eq!(reg.count("p", "things"), 2);
assert_eq!(reg.contributions("p").len(), 3);
assert!(reg.dispose_plugin("p"));
assert!(live.lock().is_empty());
}
#[test]
fn a_failed_commit_rolls_back() {
let (reg, live) = setup();
let p = pkg("[[contributes.things]]\nname = \"a\"\n[[contributes.things]]\nname = \"b\"\nfail = true");
let err = reg.commit(reg.prepare(&p).unwrap()).unwrap_err();
assert!(err.contains("boom"));
assert!(live.lock().is_empty(), "a was undone");
assert!(reg.contributions("p").is_empty());
}
#[test]
fn invalid_declarations_fail_prepare_and_unknown_points_warn() {
let (reg, _) = setup();
let err = reg.prepare(&pkg("[[contributes.things]]\nnope = 1")).unwrap_err();
assert_eq!(err, "contributes.things[0]: missing name");
let set =
reg.prepare(&pkg("[[contributes.futureThing]]\nx = 1\n[[contributes.things]]\nname = \"a\"")).unwrap();
assert_eq!(set.len(), 1);
assert_eq!(set.warnings.len(), 1);
assert!(set.warnings[0].contains("futureThing"));
}
}
+247
View File
@@ -0,0 +1,247 @@
//! UI contributions: validated declarations projected to the client.
//!
//! A `Ui` extension point validates a declaration completely in Rust and
//! commits it as JSON into the [`UiStore`]; the client receives the store's
//! snapshot and projects each item onto an existing `register*` function
//! (`defineUiPoint` in `src/features/plugins/projection.ts`). Large payloads
//! (icon SVGs) go into the store's assets and are fetched on demand.
use crate::index::ContributionIndex;
use crate::point::{Disposable, ExtensionPoint, Package, Placement, Prepared};
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use specta::Type;
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
/// One projected declaration.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct UiContribution {
pub plugin_id: String,
/// Extension point id (`commands`, `iconThemes`, …).
pub point: String,
/// Stable within (plugin, point): the client keys its registrations on it.
pub key: String,
/// The validated declaration, shaped by the point.
#[specta(type = specta_typescript::Unknown)]
pub decl: serde_json::Value,
}
struct Item {
seq: u64,
contribution: UiContribution,
}
/// Committed UI contributions of all plugins.
#[derive(Default)]
pub struct UiStore {
items: RwLock<Vec<Item>>,
/// (plugin, asset id) → content.
assets: RwLock<BTreeMap<(String, String), Arc<str>>>,
seq: AtomicU64,
revision: AtomicU64,
index: RwLock<Option<Arc<ContributionIndex>>>,
}
impl UiStore {
pub fn new() -> Arc<Self> {
Arc::new(Self::default())
}
/// Adds an item; the returned disposable removes it.
pub fn insert(self: &Arc<Self>, contribution: UiContribution) -> Disposable {
let seq = self.seq.fetch_add(1, Ordering::Relaxed);
self.items.write().push(Item { seq, contribution });
self.revision.fetch_add(1, Ordering::Release);
let store = Arc::downgrade(self);
Disposable::new(move || {
if let Some(store) = store.upgrade() {
store.items.write().retain(|i| i.seq != seq);
store.revision.fetch_add(1, Ordering::Release);
}
})
}
/// Stores assets of a plugin (ids are unique per plugin; later ones replace).
pub fn insert_assets(self: &Arc<Self>, plugin: &str, assets: Vec<(String, String)>) -> Disposable {
let keys: Vec<(String, String)> = assets.iter().map(|(id, _)| (plugin.to_owned(), id.clone())).collect();
{
let mut map = self.assets.write();
for (id, content) in assets {
map.insert((plugin.to_owned(), id), Arc::from(content));
}
}
let store = Arc::downgrade(self);
Disposable::new(move || {
if let Some(store) = store.upgrade() {
let mut map = store.assets.write();
for k in keys {
map.remove(&k);
}
}
})
}
pub fn asset(&self, plugin: &str, id: &str) -> Option<Arc<str>> {
self.assets.read().get(&(plugin.to_owned(), id.to_owned())).cloned()
}
/// Assets of `plugin` whose id starts with `prefix`.
pub fn assets_with_prefix(&self, plugin: &str, prefix: &str) -> Vec<(String, Arc<str>)> {
self.assets
.read()
.range((plugin.to_owned(), prefix.to_owned())..)
.take_while(|((p, id), _)| p == plugin && id.starts_with(prefix))
.map(|((_, id), v)| (id.clone(), v.clone()))
.collect()
}
/// Where [`UiPoint`]s keep validated declarations of installed packages.
pub fn set_index(&self, index: Option<Arc<ContributionIndex>>) {
*self.index.write() = index;
}
/// Bumped on every change: clients compare it to skip re-projection.
pub fn revision(&self) -> u64 {
self.revision.load(Ordering::Acquire)
}
pub fn snapshot(&self) -> Vec<UiContribution> {
self.items.read().iter().map(|i| i.contribution.clone()).collect()
}
/// Items of one point, optionally of one plugin.
pub fn items(&self, point: &str, plugin: Option<&str>) -> Vec<UiContribution> {
self.items
.read()
.iter()
.filter(|i| i.contribution.point == point && plugin.is_none_or(|p| i.contribution.plugin_id == p))
.map(|i| i.contribution.clone())
.collect()
}
}
/// What a [`UiPoint`]'s validator produces for one declaration.
#[derive(Debug, Serialize, Deserialize)]
pub struct UiDecl {
pub key: String,
pub label: String,
pub json: serde_json::Value,
/// Package files referenced (copied on install).
pub files: Vec<PathBuf>,
/// Large validated payloads fetched separately by the client.
pub assets: Vec<(String, String)>,
}
impl UiDecl {
pub fn new(key: impl Into<String>, label: impl Into<String>, json: serde_json::Value) -> Self {
Self { key: key.into(), label: label.into(), json, files: Vec::new(), assets: Vec::new() }
}
}
type Validate = dyn Fn(&Package, toml::Value) -> Result<UiDecl, String> + Send + Sync;
/// A `Ui` extension point defined by a validator: committing stores the
/// validated JSON (and assets) in the [`UiStore`]. Most UI contribution types
/// (menus, keybindings, status bar items, icon themes) are one of these.
pub struct UiPoint {
id: &'static str,
store: Arc<UiStore>,
validate: Box<Validate>,
}
impl UiPoint {
pub fn new(
id: &'static str,
store: Arc<UiStore>,
validate: impl Fn(&Package, toml::Value) -> Result<UiDecl, String> + Send + Sync + 'static,
) -> Self {
Self { id, store, validate: Box::new(validate) }
}
}
impl ExtensionPoint for UiPoint {
fn id(&self) -> &'static str {
self.id
}
fn placement(&self) -> Placement {
Placement::Ui
}
fn prepare(&self, pkg: &Package, decl: toml::Value) -> Result<Prepared, String> {
let index = pkg.digest.as_ref().and_then(|_| self.store.index.read().clone());
let ui = match index.as_ref().and_then(|i| i.get(pkg, self.id, &decl)) {
Some(ui) => ui,
None => match &index {
Some(i) => {
let ui = (self.validate)(pkg, decl.clone())?;
i.put(pkg, self.id, &decl, &ui);
ui
}
None => (self.validate)(pkg, decl)?,
},
};
let UiDecl { key, label, json, files, assets } = ui;
let store = self.store.clone();
let contribution =
UiContribution { plugin_id: pkg.id().to_owned(), point: self.id.to_owned(), key: key.clone(), decl: json };
let plugin = pkg.id().to_owned();
let mut prepared = Prepared::new(label, move || {
let item = store.insert(contribution);
if assets.is_empty() {
return Ok(item);
}
let assets = store.insert_assets(&plugin, assets);
Ok(Disposable::new(move || {
item.dispose();
assets.dispose();
}))
})
.key(key);
prepared.files = files;
Ok(prepared)
}
}
/// TOML declaration → JSON for the client.
pub fn to_json(v: &toml::Value) -> serde_json::Value {
serde_json::to_value(v).unwrap_or(serde_json::Value::Null)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::manifest::Manifest;
use crate::registry::ExtensionRegistry;
#[test]
fn ui_point_projects_into_the_store() {
let store = UiStore::new();
let reg = ExtensionRegistry::new();
reg.register(Arc::new(UiPoint::new("badges", store.clone(), |_pkg, decl| {
let text = decl.get("text").and_then(|v| v.as_str()).ok_or("missing text")?.to_owned();
let mut d = UiDecl::new(text.clone(), text.clone(), serde_json::json!({ "text": text }));
d.assets.push((format!("badge/{text}"), "<svg/>".into()));
Ok(d)
})))
.unwrap();
let src = "id = \"p\"\nname = \"P\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"\n\
[[contributes.badges]]\ntext = \"hi\"";
let pkg = Package::new(Manifest::parse(src).unwrap(), PathBuf::from("/x"));
let rev = store.revision();
reg.commit(reg.prepare(&pkg).unwrap()).unwrap();
assert!(store.revision() > rev);
let items = store.items("badges", Some("p"));
assert_eq!(items.len(), 1);
assert_eq!(items[0].decl["text"], "hi");
assert_eq!(store.asset("p", "badge/hi").as_deref(), Some("<svg/>"));
assert_eq!(store.assets_with_prefix("p", "badge/").len(), 1);
reg.dispose_plugin("p");
assert!(store.snapshot().is_empty());
assert!(store.asset("p", "badge/hi").is_none());
}
}
+136
View File
@@ -0,0 +1,136 @@
//! Syntax check for `when` clauses in UI declarations. The client evaluates
//! them (`src/platform/context.ts`); validating here keeps the client's
//! projection from failing. Grammar (same as the client):
//!
//! ```text
//! expr := or ; or := and ("||" and)* ; and := unary ("&&" unary)*
//! unary := "!" unary | "(" expr ")" | cmp ; cmp := key (("==" | "!=") value)?
//! ```
const MAX_LEN: usize = 512;
#[derive(Debug, PartialEq)]
enum Tok<'a> {
Id(&'a str),
Str,
Op(&'a str),
}
fn tokenize(src: &str) -> Result<Vec<Tok<'_>>, String> {
let mut out = Vec::new();
let b = src.as_bytes();
let mut i = 0;
while i < b.len() {
let c = b[i] as char;
if c.is_whitespace() {
i += 1;
} else if ["&&", "||", "==", "!="].iter().any(|op| src[i..].starts_with(op)) {
out.push(Tok::Op(&src[i..i + 2]));
i += 2;
} else if matches!(c, '!' | '(' | ')') {
out.push(Tok::Op(&src[i..i + 1]));
i += 1;
} else if c == '\'' || c == '"' {
let end = src[i + 1..].find(c).ok_or_else(|| format!("unterminated string in when clause: {src}"))?;
out.push(Tok::Str);
i += end + 2;
} else {
let len = src[i..]
.find(|ch: char| !(ch.is_alphanumeric() || matches!(ch, '_' | '.' | '-' | ':' | '/')))
.unwrap_or(src.len() - i);
if len == 0 {
return Err(format!("unexpected '{c}' in when clause: {src}"));
}
out.push(Tok::Id(&src[i..i + len]));
i += len;
}
}
Ok(out)
}
/// Checks a `when` clause; empty means "always".
pub fn validate_when(src: &str) -> Result<(), String> {
if src.len() > MAX_LEN {
return Err(format!("when clause longer than {MAX_LEN} bytes"));
}
if src.trim().is_empty() {
return Ok(());
}
let toks = tokenize(src)?;
let mut p = 0;
parse_or(&toks, &mut p, src)?;
if p != toks.len() {
return Err(format!("unexpected token in when clause: {src}"));
}
Ok(())
}
fn parse_or(t: &[Tok], p: &mut usize, src: &str) -> Result<(), String> {
parse_and(t, p, src)?;
while t.get(*p) == Some(&Tok::Op("||")) {
*p += 1;
parse_and(t, p, src)?;
}
Ok(())
}
fn parse_and(t: &[Tok], p: &mut usize, src: &str) -> Result<(), String> {
parse_unary(t, p, src)?;
while t.get(*p) == Some(&Tok::Op("&&")) {
*p += 1;
parse_unary(t, p, src)?;
}
Ok(())
}
fn parse_unary(t: &[Tok], p: &mut usize, src: &str) -> Result<(), String> {
match t.get(*p) {
Some(Tok::Op("!")) => {
*p += 1;
parse_unary(t, p, src)
}
Some(Tok::Op("(")) => {
*p += 1;
parse_or(t, p, src)?;
if t.get(*p) != Some(&Tok::Op(")")) {
return Err(format!("missing ')' in when clause: {src}"));
}
*p += 1;
Ok(())
}
Some(Tok::Id(_)) => {
*p += 1;
if matches!(t.get(*p), Some(Tok::Op("==" | "!="))) {
*p += 1;
match t.get(*p) {
Some(Tok::Id(_) | Tok::Str) => *p += 1,
_ => return Err(format!("missing value after comparison in when clause: {src}")),
}
}
Ok(())
}
_ => Err(format!("expected a context key in when clause: {src}")),
}
}
#[cfg(test)]
mod tests {
use super::validate_when;
#[test]
fn accepts_the_client_grammar() {
for ok in [
"",
"editorFocus",
"editorLang == rust",
"editorLang != 'rust' && !readOnly",
"(a || b) && toolWindow.project.active",
"resourceScheme == file:/x",
] {
assert!(validate_when(ok).is_ok(), "{ok}");
}
for bad in ["a &&", "(a", "a == ", "== a", "a b", "a $ b", "'x", "!()"] {
assert!(validate_when(bad).is_err(), "{bad}");
}
}
}
-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));
+21
View File
@@ -30,15 +30,23 @@ ide-terminal.workspace = true
ide-run.workspace = true
ide-sdk.workspace = true
ide-http.workspace = true
reqwest.workspace = true
ignore.workspace = true
base64.workspace = true
getrandom = "0.3"
globset.workspace = true
ide-db.workspace = true
ide-ssh.workspace = true
ide-remote.workspace = true
ide-jsonrpc.workspace = true
ide-agent.workspace = true
ide-notebook.workspace = true
ide-extension.workspace = true
ide-plugin.workspace = true
ide-tools = { path = "../ide-tools" }
serde.workspace = true
serde_json.workspace = true
toml.workspace = true
specta.workspace = true
specta-typescript.workspace = true
tokio = { workspace = true, features = ["io-std"] }
@@ -51,4 +59,17 @@ 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"] }
criterion = { version = "0.5", default-features = false }
zip.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"] }
[[bench]]
name = "plugins"
harness = false
+149
View File
@@ -0,0 +1,149 @@
//! `cargo bench -p ide-host`: the plugin runtime budgets, checked by `pnpm perf:rust` against
//! `perf/budgets.json` › rust (`plugins/…`).
//!
//! - `load_50`: start-up load of 50 installed plugins (20 with code, 10
//! languages with a syntax provider, 10 icon themes, 10 declarations only),
//! from the contribution index; no Wasm is instantiated.
//! - `cold_activation`: an idle-reclaimed plugin's next command: activation
//! from the precompiled component plus the call.
//! - `syntax_highlights_2k_lines`: an edit-path call (Solidity highlighting).
use criterion::{Criterion, black_box, criterion_group, criterion_main};
use ide_core::LanguageId;
use ide_host::plugins::{Consent, PluginHost};
use ide_plugin::{Invocation, Limits};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
fn repo() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../..").canonicalize().unwrap()
}
fn copy_dir(from: &Path, to: &Path) {
std::fs::create_dir_all(to).unwrap();
for e in std::fs::read_dir(from).unwrap().flatten() {
let name = e.file_name();
if matches!(name.to_str(), Some("target" | "src" | "Cargo.lock" | ".gitignore")) {
continue;
}
let dst = to.join(&name);
if e.path().is_dir() { copy_dir(&e.path(), &dst) } else { std::fs::copy(e.path(), &dst).map(|_| ()).unwrap() }
}
}
/// A copy of `src` installed under a new id (and new language / theme ids).
fn variant(work: &Path, src: &Path, n: usize, rename: &[(&str, String)]) -> PathBuf {
let dir = work.join(format!("pkg{n}"));
copy_dir(src, &dir);
for file in ["plugin.toml", "languages/solidity.toml"] {
let path = dir.join(file);
if let Ok(mut text) = std::fs::read_to_string(&path) {
for (from, to) in rename {
text = text.replace(from, to);
}
std::fs::write(&path, text).unwrap();
}
}
dir
}
fn sink() -> Arc<dyn ide_host::plugins::PluginEventSink> {
Arc::new(|_: Option<&str>, _| {})
}
fn install_50(plugins: &Path, work: &Path) {
let host = PluginHost::new(plugins.to_path_buf(), sink(), Limits::default()).unwrap();
host.load_all();
let root = repo();
let mut n = 0;
let mut install = |src: PathBuf, rename: Vec<(&str, String)>| {
let pkg = variant(work, &src, n, &rename);
n += 1;
host.install_from_dir(&pkg, &Consent::default()).unwrap();
};
for i in 0..20 {
install(
root.join("crates/ide-plugin/tests/fixtures/hello"),
vec![("id = \"hello\"", format!("id = \"bench.hello{i}\""))],
);
}
for i in 0..10 {
install(
root.join("plugins/official/solidity"),
vec![
("openidea.solidity", format!("bench.solidity{i}")),
("id = \"solidity\"", format!("id = \"solidity{i}\"")),
("extensions = [\"sol\"]", format!("extensions = [\"sol{i}\"]")),
("on-language:solidity", format!("on-language:solidity{i}")),
("id = \"forge-", format!("id = \"f{i}-")),
("id = \"hardhat-", format!("id = \"h{i}-")),
],
);
}
for i in 0..10 {
install(
root.join("plugins/official/material-icons"),
vec![
("openidea.material-icons", format!("bench.icons{i}")),
("id = \"material\"", format!("id = \"material{i}\"")),
],
);
}
for i in 0..10 {
let dir = work.join(format!("static{i}"));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("plugin.toml"),
format!(
"id = \"bench.static{i}\"\nname = \"Static {i}\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"\n\
[[contributes.commands]]\nid = \"a\"\ntitle = \"Static {i}: A\"\nwhen = \"editorLang == rust\"\n"
),
)
.unwrap();
host.install_from_dir(&dir, &Consent::default()).unwrap();
}
}
fn bench(c: &mut Criterion) {
let tmp = tempfile::tempdir().unwrap();
let plugins = tmp.path().join("plugins");
install_50(&plugins, &tmp.path().join("work"));
let mut g = c.benchmark_group("plugins");
g.sample_size(20);
g.bench_function("load_50", |b| {
b.iter(|| {
let host = PluginHost::new(plugins.clone(), sink(), Limits::default()).unwrap();
host.load_all();
assert_eq!(host.instances(), 0, "start-up instantiates no Wasm");
black_box(host.list(None).plugins.len())
})
});
let limits = Limits { idle_timeout: Some(Duration::ZERO), ..Limits::default() };
let host = Arc::new(PluginHost::new(plugins.clone(), sink(), limits).unwrap());
host.load_all();
host.warm_component_cache();
g.bench_function("cold_activation", |b| {
b.iter(|| {
host.tick();
// Fails for want of an editor, after activating and running.
let r = host.run_command("bench.hello0", "word-count", None, Invocation::default());
black_box(r.is_err())
})
});
let provider = host.syntax_provider();
let lang = LanguageId::new("solidity0");
let unit = "contract Token {\n uint256 public total;\n mapping(address => uint256) balances;\n event Sent(address to, uint256 v);\n function send(address to, uint256 v) public returns (bool) {\n // move funds\n balances[to] += v;\n emit Sent(to, v);\n return true;\n }\n}\n";
let text = unit.repeat(2000 / unit.lines().count());
assert!(provider.highlights(&lang, &text).is_some());
g.bench_function("syntax_highlights_2k_lines", |b| {
b.iter(|| black_box(provider.highlights(&lang, &text).map(|t| t.len())))
});
g.finish();
}
criterion_group!(benches, bench);
criterion_main!(benches);
@@ -0,0 +1,624 @@
//! `openidea-plugin`: scaffolds, builds, checks and links OpenIDEA plugins.
//!
//! ```text
//! openidea-plugin new <dir> [--template command|theme|icon-theme|language] [--id ID] [--name NAME] [--sdk PATH]
//! openidea-plugin build [dir] [--debug] # cargo → dist/plugin.wasm, then `check`
//! openidea-plugin check [dir] # validate the package as the IDE would install it
//! openidea-plugin link [dir] # load the folder in the IDE without installing it
//! openidea-plugin unlink [dir]
//! openidea-plugin links # list linked folders
//! openidea-plugin keygen <key-file> # a publisher's Ed25519 key
//! openidea-plugin pack [dir] [--key F --publisher ID] [--out FILE] # <id>-<version>.oiplugin
//! openidea-plugin index <dir> --publisher ID --name NAME --key F # marketplace index.json
//! ```
//!
//! `link` writes `links.json` in the IDE's plugins directory
//! (`$OPENIDEA_PLUGINS_DIR`, or the app data directory); the IDE picks the
//! change up when Settings › Plugins is opened or refreshed. Linked plugins
//! are re-read from the folder, so `build` + refresh is the edit loop.
mod templates;
use ide_extension::{Manifest, validate_plugin_id};
use ide_host::plugins::PluginHost;
use ide_plugin::{Limits, PluginEngine};
use std::path::{Path, PathBuf};
use std::process::{Command, ExitCode};
use std::sync::Arc;
const USAGE: &str = "\
usage: openidea-plugin <command> [options]
commands:
new <dir> [--template T] [--id ID] [--name NAME] [--sdk PATH]
create a plugin; T = command (default), theme, icon-theme, language
build [dir] [--debug] compile a Rust plugin to dist/plugin.wasm and check it
check [dir] validate the package as the IDE would install it
link [dir] load the folder in the IDE without installing it
unlink [dir] stop loading a linked folder
links list linked folders
keygen <key-file> create a publisher signing key (keep the file secret)
pack [dir] [--key F --publisher ID] [--out FILE]
write <id>-<version>.oiplugin, signed with the key
index <dir> --publisher ID --name NAME --key F [--out FILE]
write the marketplace index.json for the packages in dir
environment:
OPENIDEA_PLUGINS_DIR the IDE's plugins directory (default: the app data directory)";
fn main() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
let result = match args.first().map(String::as_str) {
Some("new") => cmd_new(&args[1..]),
Some("build") => cmd_build(&args[1..]),
Some("check") => dir_arg(&args[1..]).and_then(|d| check(&d)),
Some("link") => dir_arg(&args[1..]).and_then(|d| link(&d, true)),
Some("unlink") => dir_arg(&args[1..]).and_then(|d| link(&d, false)),
Some("links") => list_links(),
Some("keygen") => cmd_keygen(&args[1..]),
Some("pack") => cmd_pack(&args[1..]),
Some("index") => cmd_index(&args[1..]),
Some("--version" | "-V") => {
println!("openidea-plugin {} (plugin API {})", env!("CARGO_PKG_VERSION"), ide_extension::ENGINE_VERSION);
Ok(())
}
Some("help" | "--help" | "-h") | None => {
println!("{USAGE}");
Ok(())
}
Some(other) => Err(format!("unknown command {other:?}\n\n{USAGE}")),
};
match result {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {e}");
ExitCode::FAILURE
}
}
}
/// Splits `args` into positionals and `--flag value` / `--switch` options.
struct Args {
positional: Vec<String>,
options: Vec<(String, Option<String>)>,
}
impl Args {
fn parse(args: &[String], switches: &[&str], valued: &[&str]) -> Result<Self, String> {
let mut out = Args { positional: Vec::new(), options: Vec::new() };
let mut it = args.iter();
while let Some(a) = it.next() {
if let Some(name) = a.strip_prefix("--") {
let (name, inline) = match name.split_once('=') {
Some((n, v)) => (n, Some(v.to_owned())),
None => (name, None),
};
if switches.contains(&name) && inline.is_none() {
out.options.push((name.into(), None));
} else if valued.contains(&name) {
let v = inline.or_else(|| it.next().cloned()).ok_or_else(|| format!("--{name} needs a value"))?;
out.options.push((name.into(), Some(v)));
} else {
return Err(format!("unknown option --{name}"));
}
} else {
out.positional.push(a.clone());
}
}
Ok(out)
}
fn value(&self, name: &str) -> Option<&str> {
self.options.iter().rev().find(|(n, _)| n == name).and_then(|(_, v)| v.as_deref())
}
fn has(&self, name: &str) -> bool {
self.options.iter().any(|(n, _)| n == name)
}
fn dir(&self) -> Result<PathBuf, String> {
match self.positional.as_slice() {
[] => Ok(PathBuf::from(".")),
[d] => Ok(PathBuf::from(d)),
_ => Err("expected at most one directory".into()),
}
}
}
fn dir_arg(args: &[String]) -> Result<PathBuf, String> {
Args::parse(args, &[], &[])?.dir()
}
fn canonical(dir: &Path) -> Result<PathBuf, String> {
dunce::canonicalize(dir).map_err(|e| format!("{}: {e}", dir.display()))
}
// ---------------------------------------------------------------- new
fn cmd_new(args: &[String]) -> Result<(), String> {
let a = Args::parse(args, &[], &["template", "id", "name", "sdk"])?;
let [dir] = a.positional.as_slice() else {
return Err("usage: openidea-plugin new <dir> [--template T] [--id ID] [--name NAME]".into());
};
let dir = PathBuf::from(dir);
let template = templates::Template::parse(a.value("template").unwrap_or("command"))?;
let base = dir.file_name().and_then(|n| n.to_str()).ok_or("the directory needs a name")?;
let id = match a.value("id") {
Some(id) => id.to_owned(),
None => slugify(base),
};
validate_plugin_id(&id)?;
let name = match a.value("name") {
Some(n) => n.to_owned(),
None => title_case(id.rsplit('.').next().unwrap_or(&id)),
};
if name.trim().is_empty() || name.contains(['"', '\\', '\n']) {
return Err("--name must not be empty or contain quotes, backslashes or newlines".into());
}
if dir.exists() && std::fs::read_dir(&dir).map_err(|e| e.to_string())?.next().is_some() {
return Err(format!("{} already exists and is not empty", dir.display()));
}
std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
let dir = canonical(&dir)?;
let sdk = match template {
templates::Template::Command => Some(sdk_path(a.value("sdk"), &dir)?),
_ => None,
};
let vars = templates::Vars { id: &id, name: &name, sdk: sdk.as_deref() };
let files = templates::render(template, &vars);
for (rel, content) in &files {
let path = dir.join(rel);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
}
std::fs::write(&path, content).map_err(|e| format!("{}: {e}", path.display()))?;
}
// Generated packages must be valid as written.
Manifest::load(&dir).map_err(|e| format!("internal error: the template is invalid: {e}"))?;
println!("created {} ({id}) in {}", template.label(), dir.display());
let shown = std::env::current_dir().ok().and_then(|cwd| dir.strip_prefix(cwd).ok().map(Path::to_path_buf));
let shown = shown.as_deref().unwrap_or(&dir).display().to_string();
println!();
println!("next:");
if template == templates::Template::Command {
println!(" cargo test --manifest-path {shown}/Cargo.toml # unit tests against the mock host");
println!(" openidea-plugin build {shown}");
} else {
println!(" openidea-plugin check {shown}");
}
println!(" openidea-plugin link {shown} # then Settings › Plugins");
Ok(())
}
/// `My Cool_Plugin` → `my-cool-plugin`.
fn slugify(s: &str) -> String {
let mut out = String::new();
for c in s.chars() {
match c {
'a'..='z' | '0'..='9' | '.' => out.push(c),
'A'..='Z' => out.push(c.to_ascii_lowercase()),
_ if !out.ends_with('-') && !out.is_empty() => out.push('-'),
_ => {}
}
}
out.trim_matches(['-', '.']).to_owned()
}
/// `my-cool_plugin` → `My Cool Plugin`.
fn title_case(s: &str) -> String {
s.split(['-', '_'])
.filter(|w| !w.is_empty())
.map(|w| {
let mut c = w.chars();
c.next().map(|f| f.to_uppercase().chain(c).collect::<String>()).unwrap_or_default()
})
.collect::<Vec<_>>()
.join(" ")
}
/// The SDK dependency path written into a new plugin's Cargo.toml: `--sdk`,
/// or the SDK of the source tree this tool was built from.
fn sdk_path(explicit: Option<&str>, plugin_dir: &Path) -> Result<String, String> {
let sdk = match explicit {
Some(p) => PathBuf::from(p),
None => PathBuf::from(concat!(env!("CARGO_MANIFEST_DIR"), "/../../sdk/rust/openidea-plugin")),
};
let sdk = dunce::canonicalize(&sdk).map_err(|_| {
format!("the plugin SDK was not found at {}; pass --sdk <path to sdk/rust/openidea-plugin>", sdk.display())
})?;
if !sdk.join("Cargo.toml").is_file() {
return Err(format!("{} is not the openidea-plugin SDK (no Cargo.toml)", sdk.display()));
}
let path = relative(plugin_dir, &sdk).unwrap_or(sdk);
Ok(path.to_string_lossy().replace('\\', "/"))
}
/// `to` relative to `from` when they share a root (keeps packages in a repo movable).
fn relative(from: &Path, to: &Path) -> Option<PathBuf> {
let (f, t): (Vec<_>, Vec<_>) = (from.components().collect(), to.components().collect());
let common = f.iter().zip(&t).take_while(|(a, b)| a == b).count();
if common <= 1 {
return None;
}
let mut out = PathBuf::new();
for _ in common..f.len() {
out.push("..");
}
for c in &t[common..] {
out.push(c);
}
Some(out)
}
// ---------------------------------------------------------------- build / check
fn cmd_build(args: &[String]) -> Result<(), String> {
let a = Args::parse(args, &["debug"], &[])?;
let dir = canonical(&a.dir()?)?;
let manifest = Manifest::load(&dir)?;
let cargo_toml = dir.join("Cargo.toml");
if !cargo_toml.is_file() {
if manifest.wasm.is_some() {
return Err(format!(
"{} has no Cargo.toml: build the component with your toolchain, then run `check`",
dir.display()
));
}
println!("{} has no code to build", manifest.id);
return check(&dir);
}
let wasm_rel = manifest.wasm.clone().ok_or("plugin.toml has no `wasm = \"dist/plugin.wasm\"`")?;
let crate_name = crate_name(&cargo_toml)?;
ensure_wasm_target();
let release = !a.has("debug");
let target_dir = std::env::var_os("CARGO_TARGET_DIR").map(PathBuf::from).unwrap_or_else(|| dir.join("target"));
let mut cmd = Command::new(std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()));
cmd.arg("build").arg("--manifest-path").arg(&cargo_toml).args(["--target", "wasm32-wasip2"]);
if release {
cmd.arg("--release");
}
cmd.env("CARGO_TARGET_DIR", &target_dir);
println!("building {crate_name} for wasm32-wasip2 ({})", if release { "release" } else { "debug" });
let status = cmd.status().map_err(|e| format!("cannot run cargo: {e}"))?;
if !status.success() {
return Err("cargo build failed".into());
}
let built = target_dir
.join("wasm32-wasip2")
.join(if release { "release" } else { "debug" })
.join(format!("{}.wasm", crate_name.replace('-', "_")));
if !built.is_file() {
return Err(format!("{} was not produced: is `crate-type = [\"cdylib\"]` set in Cargo.toml?", built.display()));
}
let out = dir.join(&wasm_rel);
if let Some(parent) = out.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
}
std::fs::copy(&built, &out).map_err(|e| format!("{}: {e}", out.display()))?;
let size = std::fs::metadata(&out).map(|m| m.len()).unwrap_or(0);
println!(" -> {} ({})", wasm_rel.display(), human_size(size));
check(&dir)
}
fn crate_name(cargo_toml: &Path) -> Result<String, String> {
let src = std::fs::read_to_string(cargo_toml).map_err(|e| format!("{}: {e}", cargo_toml.display()))?;
let doc: toml::Table = src.parse().map_err(|e| format!("{}: {e}", cargo_toml.display()))?;
let package = doc.get("package").and_then(|p| p.as_table());
let lib_name = doc.get("lib").and_then(|l| l.get("name")).and_then(|n| n.as_str());
lib_name
.or_else(|| package.and_then(|p| p.get("name")).and_then(|n| n.as_str()))
.map(str::to_owned)
.ok_or_else(|| format!("{}: no [package] name", cargo_toml.display()))
}
/// Installs the target through rustup when it is missing (no-op without rustup).
fn ensure_wasm_target() {
let Ok(out) = Command::new("rustup").args(["target", "list", "--installed"]).output() else { return };
if String::from_utf8_lossy(&out.stdout).lines().any(|l| l.trim() == "wasm32-wasip2") {
return;
}
println!("installing the wasm32-wasip2 target");
let _ = Command::new("rustup").args(["target", "add", "wasm32-wasip2"]).status();
}
/// Validates `dir` exactly as Install from Disk would, and checks the
/// component imports only the plugin API.
fn check(dir: &Path) -> Result<(), String> {
let dir = canonical(dir)?;
let scratch = std::env::temp_dir().join(format!("openidea-plugin-check-{}", std::process::id()));
let result = (|| {
let host = PluginHost::new(scratch.clone(), Arc::new(|_: Option<&str>, _| {}), Limits::default())
.map_err(|e| e.to_string())?;
let preview = host.preview_install(&dir).map_err(|e| e.to_string())?;
let manifest = Manifest::load(&dir)?;
println!("{} {} — {}", preview.id, preview.version, preview.name);
if manifest.legacy {
println!(" note: plugin.toml uses the 0.1 format (`openidea-api`); see plugins/README.md to move to 0.2");
}
if let Some(wasm) = &manifest.wasm {
let engine = PluginEngine::new()?;
let component = engine.compile(&dir.join(wasm))?;
let built_for = engine.check_component(&component)?;
let size = std::fs::metadata(dir.join(wasm)).map(|m| m.len()).unwrap_or(0);
let api = built_for.map(|v| format!(", plugin API {}.{}", v.major, v.minor)).unwrap_or_default();
println!(" code: {} ({}{api})", wasm.display(), human_size(size));
} else {
println!(" code: none (contributions only)");
}
if !manifest.activation.is_empty() {
let events: Vec<String> = manifest.activation.iter().map(ToString::to_string).collect();
println!(" activation: {}", events.join(", "));
}
for c in &preview.contributions {
println!(" contributes {} × {}", c.count, c.point);
}
for c in &preview.capabilities {
let kind = if c.optional { "optional" } else { "required" };
println!(" capability {} ({kind}, {:?} risk): {}", c.id, c.risk, c.description);
}
for w in &preview.warnings {
println!(" warning: {w}");
}
println!("ok");
Ok(())
})();
let _ = std::fs::remove_dir_all(&scratch);
result
}
fn human_size(bytes: u64) -> String {
match bytes {
b if b >= 1 << 20 => format!("{:.1} MiB", b as f64 / (1 << 20) as f64),
b if b >= 1 << 10 => format!("{:.1} KiB", b as f64 / 1024.0),
b => format!("{b} B"),
}
}
// ---------------------------------------------------------------- link
fn plugins_dir() -> Result<PathBuf, String> {
if let Some(dir) = std::env::var_os("OPENIDEA_PLUGINS_DIR") {
return Ok(PathBuf::from(dir));
}
dirs::data_dir()
.map(|d| d.join("ai.l0ng.openidea").join("plugins"))
.ok_or_else(|| "cannot find the app data directory; set OPENIDEA_PLUGINS_DIR".into())
}
#[derive(Default, serde::Serialize, serde::Deserialize)]
struct Links {
#[serde(default)]
paths: Vec<PathBuf>,
}
fn read_links(file: &Path) -> Result<Links, String> {
match std::fs::read_to_string(file) {
Ok(s) => serde_json::from_str(&s).map_err(|e| format!("{}: {e}", file.display())),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Links::default()),
Err(e) => Err(format!("{}: {e}", file.display())),
}
}
fn link(dir: &Path, add: bool) -> Result<(), String> {
let plugins = plugins_dir()?;
let file = plugins.join("links.json");
let mut links = read_links(&file)?;
let dir = if add {
let dir = canonical(dir)?;
let m = Manifest::load(&dir)?;
if let Some(wasm) = &m.wasm
&& !dir.join(wasm).is_file()
{
println!("note: {} does not exist yet; run `openidea-plugin build` first", wasm.display());
}
if links.paths.contains(&dir) {
println!("{} is already linked", dir.display());
return Ok(());
}
println!("linked {} ({})", m.id, dir.display());
links.paths.push(dir.clone());
dir
} else {
let dir = dunce::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf());
let before = links.paths.len();
links.paths.retain(|p| p != &dir);
if links.paths.len() == before {
return Err(format!("{} is not linked", dir.display()));
}
println!("unlinked {}", dir.display());
dir
};
let _ = dir;
std::fs::create_dir_all(&plugins).map_err(|e| format!("{}: {e}", plugins.display()))?;
let json = serde_json::to_string_pretty(&links).map_err(|e| e.to_string())?;
let tmp = file.with_extension("json.tmp");
std::fs::write(&tmp, json)
.and_then(|()| std::fs::rename(&tmp, &file))
.map_err(|e| format!("{}: {e}", file.display()))?;
println!("open Settings › Plugins in OpenIDEA to load the change");
Ok(())
}
fn list_links() -> Result<(), String> {
let links = read_links(&plugins_dir()?.join("links.json"))?;
if links.paths.is_empty() {
println!("no linked plugins");
}
for p in links.paths {
let id = Manifest::load(&p).map(|m| m.id).unwrap_or_else(|e| format!("invalid: {e}"));
println!("{id}\t{}", p.display());
}
Ok(())
}
// ---------------------------------------------------------------- distribution
use ide_extension::package::{self, Index, IndexEntry, KeyEntry, PublisherKey};
fn read_key(path: &str) -> Result<PublisherKey, String> {
PublisherKey::from_base64(&std::fs::read_to_string(path).map_err(|e| format!("{path}: {e}"))?)
}
fn cmd_keygen(args: &[String]) -> Result<(), String> {
let a = Args::parse(args, &[], &[])?;
let [file] = a.positional.as_slice() else { return Err("usage: openidea-plugin keygen <key-file>".into()) };
if Path::new(file).exists() {
return Err(format!("{file} exists; not overwriting a key"));
}
let mut seed = [0u8; 32];
getrandom::fill(&mut seed).map_err(|e| format!("random: {e}"))?;
let key = PublisherKey::from_seed(seed);
std::fs::write(file, key.seed_base64()).map_err(|e| format!("{file}: {e}"))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(file, std::fs::Permissions::from_mode(0o600));
}
println!("public key: {}", key.public_base64());
println!("register it with your publisher id in the marketplace index; keep {file} secret");
Ok(())
}
/// A scratch host to list a package's files as the IDE would.
fn with_scratch_host<T>(f: impl FnOnce(&PluginHost) -> Result<T, String>) -> Result<T, String> {
let scratch = std::env::temp_dir().join(format!("openidea-plugin-pack-{}", std::process::id()));
let result = PluginHost::new(scratch.clone(), Arc::new(|_: Option<&str>, _| {}), Limits::default())
.map_err(|e| e.to_string())
.and_then(|h| f(&h));
let _ = std::fs::remove_dir_all(&scratch);
result
}
fn cmd_pack(args: &[String]) -> Result<(), String> {
let a = Args::parse(args, &[], &["key", "publisher", "out"])?;
let dir = canonical(&a.dir()?)?;
check(&dir)?;
let manifest = Manifest::load(&dir)?;
let mut files = with_scratch_host(|h| h.package_files_of(&dir).map_err(|e| e.to_string()))?;
let digest = package::package_digest(&dir, &files).map_err(|e| e.to_string())?;
let signature = match (a.value("key"), a.value("publisher")) {
(Some(key), Some(publisher)) => {
if !manifest.id.starts_with(&format!("{publisher}.")) {
return Err(format!("publisher {publisher:?} may only sign plugins named {publisher}.*"));
}
Some(read_key(key)?.sign(publisher, &manifest.id, &manifest.version, &digest))
}
(None, None) => None,
_ => return Err("--key and --publisher go together".into()),
};
for lic in ["LICENSE", "LICENSE.md", "LICENSE.txt", "NOTICE", "NOTICE.md", "COPYING", "README.md"] {
if dir.join(lic).is_file() {
files.push(PathBuf::from(lic));
}
}
let out = a.value("out").map(PathBuf::from).unwrap_or_else(|| {
PathBuf::from(format!("{}-{}.{}", manifest.id, manifest.version, package::PACKAGE_EXTENSION))
});
package::pack(&dir, &files, &out, signature.as_ref())?;
let size = std::fs::metadata(&out).map(|m| m.len()).unwrap_or(0);
let signed = signature.as_ref().map_or("unsigned".to_owned(), |s| format!("signed by {}", s.publisher));
println!("-> {} ({}, {signed})", out.display(), human_size(size));
Ok(())
}
fn cmd_index(args: &[String]) -> Result<(), String> {
let a = Args::parse(args, &[], &["publisher", "name", "key", "out"])?;
let dir = canonical(&a.dir()?)?;
let publisher = a.value("publisher").ok_or("--publisher is required")?;
let name = a.value("name").unwrap_or(publisher);
let key = read_key(a.value("key").ok_or("--key is required")?)?;
let out = a.value("out").map(PathBuf::from).unwrap_or_else(|| dir.join("index.json"));
// Revocations and other publishers of an existing index are kept.
let mut index = match std::fs::read_to_string(&out) {
Ok(text) => Index::parse(&text)?,
Err(_) => Index { format: package::INDEX_FORMAT, ..Index::default() },
};
// Earlier keys of the publisher stay listed (packages they signed remain valid until revoked).
let current = key.public_base64();
let earlier: Vec<KeyEntry> = index
.publishers
.iter()
.filter(|p| p.id == publisher)
.flat_map(|p| p.keys.iter().filter(|k| k.key != current).cloned())
.collect();
index.publishers.retain(|p| p.id != publisher);
let keys: Vec<KeyEntry> = std::iter::once(KeyEntry { key: current, revoked: false }).chain(earlier).collect();
index.publishers.push(package::Publisher { id: publisher.into(), name: name.into(), keys });
index.plugins.clear();
let mut packages: Vec<PathBuf> = std::fs::read_dir(&dir)
.map_err(|e| e.to_string())?
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|e| e == package::PACKAGE_EXTENSION))
.collect();
packages.sort();
for path in packages {
let scratch = std::env::temp_dir().join(format!("openidea-plugin-index-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&scratch);
let unpacked = package::unpack(&path, &scratch)?;
let entry = (|| {
let m = Manifest::load(&scratch)?;
let files = with_scratch_host(|h| h.package_files_of(&scratch).map_err(|e| e.to_string()))?;
let digest = package::package_digest(&scratch, &files).map_err(|e| e.to_string())?;
let sig = unpacked.signature.as_ref().ok_or_else(|| format!("{}: not signed", path.display()))?;
sig.check(&m.id, &m.version, &digest).map_err(|e| format!("{}: {e}", path.display()))?;
Ok::<_, String>(IndexEntry {
id: m.id.clone(),
name: m.name.clone(),
version: m.version.clone(),
description: m.description.clone(),
authors: m.authors.clone(),
publisher: sig.publisher.clone(),
download: path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(),
digest,
capabilities: m.capabilities.all().map(|c| c.to_string()).collect(),
})
})();
let _ = std::fs::remove_dir_all(&scratch);
let entry = entry?;
println!(" {} {}", entry.id, entry.version);
index.plugins.push(entry);
}
let json = serde_json::to_string_pretty(&index).map_err(|e| e.to_string())?;
std::fs::write(&out, json).map_err(|e| format!("{}: {e}", out.display()))?;
println!("-> {} ({} plugins)", out.display(), index.plugins.len());
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn names() {
assert_eq!(slugify("My Cool_Plugin"), "my-cool-plugin");
assert_eq!(slugify("acme.Tools"), "acme.tools");
assert_eq!(title_case("material-icons"), "Material Icons");
assert_eq!(
relative(Path::new("/a/b/plugins/x"), Path::new("/a/b/sdk/rust/p")).unwrap(),
PathBuf::from("../../sdk/rust/p")
);
assert_eq!(relative(Path::new("/x/y"), Path::new("/z")), None);
}
#[test]
fn every_template_is_a_valid_package() {
for t in templates::Template::ALL {
let dir =
std::env::temp_dir().join(format!("openidea-plugin-template-{}-{}", t.label(), std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let vars = templates::Vars { id: "acme.sample", name: "Sample", sdk: Some("../sdk") };
for (rel, content) in templates::render(t, &vars) {
let path = dir.join(rel);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, content).unwrap();
}
let m = Manifest::load(&dir).unwrap_or_else(|e| panic!("{}: {e}", t.label()));
assert_eq!(m.id, "acme.sample");
if m.wasm.is_none() {
check(&dir).unwrap_or_else(|e| panic!("{}: {e}", t.label()));
}
std::fs::remove_dir_all(&dir).unwrap();
}
}
}
@@ -0,0 +1,278 @@
//! The files of `openidea-plugin new`. Placeholders: `__ID__` (plugin id),
//! `__SLUG__` (last id segment), `__NAME__`, `__CRATE__`, `__SDK__`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Template {
Command,
Theme,
IconTheme,
Language,
}
impl Template {
pub const ALL: [Template; 4] = [Self::Command, Self::Theme, Self::IconTheme, Self::Language];
pub fn parse(s: &str) -> Result<Self, String> {
Self::ALL
.into_iter()
.find(|t| t.label() == s)
.ok_or_else(|| format!("unknown template {s:?} (command, theme, icon-theme, language)"))
}
pub fn label(self) -> &'static str {
match self {
Self::Command => "command",
Self::Theme => "theme",
Self::IconTheme => "icon-theme",
Self::Language => "language",
}
}
}
pub struct Vars<'a> {
pub id: &'a str,
pub name: &'a str,
pub sdk: Option<&'a str>,
}
/// `(relative path, contents)` of every file of the template.
pub fn render(template: Template, vars: &Vars) -> Vec<(String, String)> {
let slug = vars.id.rsplit('.').next().unwrap_or(vars.id).to_owned();
let crate_name = format!("openidea-plugin-{}", vars.id.replace('.', "-"));
let fill = |s: &str| {
s.replace("__ID__", vars.id)
.replace("__SLUG__", &slug)
.replace("__NAME__", vars.name)
.replace("__CRATE__", &crate_name)
.replace("__SDK__", vars.sdk.unwrap_or(""))
// The theme files come from the Harbor Night plugin.
.replace("harbor-night", &slug)
.replace("Harbor Night", vars.name)
};
let files: Vec<(String, &str)> = match template {
Template::Command => vec![
("plugin.toml".into(), COMMAND_MANIFEST),
("Cargo.toml".into(), COMMAND_CARGO),
("src/lib.rs".into(), COMMAND_LIB),
(".gitignore".into(), "/target\n"),
("README.md".into(), COMMAND_README),
],
Template::Theme => vec![
("plugin.toml".into(), THEME_MANIFEST),
(format!("themes/{slug}.theme.json"), THEME_UI),
(format!("themes/{slug}.scheme.json"), THEME_SCHEME),
],
Template::IconTheme => vec![
("plugin.toml".into(), ICON_MANIFEST),
("icons/theme.json".into(), ICON_THEME),
("icons/file.svg".into(), ICON_FILE),
("icons/folder.svg".into(), ICON_FOLDER),
("icons/folder-open.svg".into(), ICON_FOLDER_OPEN),
("icons/markdown.svg".into(), ICON_MARKDOWN),
],
Template::Language => {
vec![("plugin.toml".into(), LANGUAGE_MANIFEST), (format!("languages/{slug}.toml"), LANGUAGE_DEF)]
}
};
files.into_iter().map(|(path, content)| (path, fill(content))).collect()
}
const COMMAND_MANIFEST: &str = r#"id = "__ID__"
name = "__NAME__"
version = "0.1.0"
description = "Adds two sample commands."
authors = []
engines.openidea = "^1.0"
# Built by `openidea-plugin build`. The code is loaded the first time one of
# its commands runs; the commands below show in Find Action before that.
wasm = "dist/plugin.wasm"
# What the code may do. The user approves these when installing.
[capabilities]
required = ["editor.read"]
[[contributes.commands]]
id = "say-hello"
title = "__NAME__: Say Hello"
category = "__NAME__"
[[contributes.commands]]
id = "line-count"
title = "__NAME__: Line Count of Active File"
category = "__NAME__"
"#;
const COMMAND_CARGO: &str = r#"[package]
name = "__CRATE__"
version = "0.1.0"
edition = "2024"
publish = false
# A plugin builds on its own (target wasm32-wasip2), outside any IDE workspace.
[workspace]
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
openidea-plugin = { path = "__SDK__" }
[profile.release]
opt-level = "s"
lto = true
strip = true
codegen-units = 1
panic = "abort"
"#;
const COMMAND_LIB: &str = r#"//! __NAME__: an OpenIDEA plugin.
use openidea_plugin::{Invocation, NotificationLevel, Plugin, host, register_plugin};
#[derive(Default)]
pub struct Main {
greetings: u32,
}
impl Plugin for Main {
fn run_command(&mut self, id: &str, ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, String> {
match id {
"say-hello" => {
// Instance state lasts until the plugin is idle for a while;
// keep what must survive in `host::storage`.
self.greetings += 1;
let body = format!("Hello #{} from __NAME__!", self.greetings);
host::notify(NotificationLevel::Info, "__NAME__", &body);
Ok(None)
}
"line-count" => {
let editor = ctx.editor().ok_or("no file is open")?;
let text = editor.document()?.text;
Ok(Some(format!("{} lines in {}", text.lines().count(), editor.path())))
}
_ => Err(format!("unknown command {id}")),
}
}
}
register_plugin!(Main);
#[cfg(test)]
mod tests {
use super::*;
use openidea_plugin::mock::MockHost;
#[test]
fn says_hello() {
let host = MockHost::install();
let mut plugin = Main::default();
plugin.run_command("say-hello", &host.invocation(), None).unwrap();
plugin.run_command("say-hello", &host.invocation(), None).unwrap();
assert_eq!(host.notifications()[1].body, "Hello #2 from __NAME__!");
}
#[test]
fn counts_lines() {
let host = MockHost::install();
host.open_editor("/project/src/main.rs", "rust", "fn main() {\n}\n", &[]);
let out = Main::default().run_command("line-count", &host.invocation(), None).unwrap();
assert_eq!(out.as_deref(), Some("2 lines in /project/src/main.rs"));
}
}
"#;
const COMMAND_README: &str = r#"# __NAME__
An OpenIDEA plugin.
```sh
cargo test # logic, against the SDK's mock host
openidea-plugin build # → dist/plugin.wasm, validated like an install
openidea-plugin link # load this folder in the IDE (Settings › Plugins)
```
`plugin.toml` declares the commands, so they appear in Find Action before the
code is loaded, and the capabilities the code needs. `src/lib.rs` implements
them with the `openidea-plugin` SDK.
"#;
const THEME_MANIFEST: &str = r#"id = "__ID__"
name = "__NAME__"
version = "0.1.0"
description = "A dark theme."
authors = []
engines.openidea = "^1.0"
# One entry per theme. `editor-scheme` is optional: its tokens are merged over
# the theme's (IntelliJ's .theme.json + .icls pair). Reference: docs/plugins/themes.md.
[[contributes.themes]]
path = "themes/__SLUG__.theme.json"
editor-scheme = "themes/__SLUG__.scheme.json"
"#;
const THEME_UI: &str = include_str!("../../../../../plugins/official/harbor-night/themes/harbor-night.theme.json");
const THEME_SCHEME: &str = include_str!("../../../../../plugins/official/harbor-night/themes/harbor-night.scheme.json");
const ICON_MANIFEST: &str = r#"id = "__ID__"
name = "__NAME__"
version = "0.1.0"
description = "File and folder icons."
authors = []
engines.openidea = "^1.0"
# Apply it in Settings › Plugins › __NAME__. SVGs are sanitised on install:
# no scripts, event handlers, external references or entities.
[[contributes.iconThemes]]
id = "__SLUG__"
label = "__NAME__"
path = "icons/theme.json"
"#;
const ICON_THEME: &str = r#"{
"iconDefinitions": {
"file": "icons/file.svg",
"folder": "icons/folder.svg",
"folder-open": "icons/folder-open.svg",
"markdown": "icons/markdown.svg"
},
"file": "file",
"folder": "folder",
"folderExpanded": "folder-open",
"fileExtensions": { "md": "markdown", "markdown": "markdown" },
"fileNames": { "readme": "markdown" },
"folderNames": {}
}
"#;
const ICON_FILE: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#90a4ae" d="M4 1.5h5.5L13 5v9.5H4z"/><path fill="#cfd8dc" d="M9.5 1.5V5H13z"/></svg>
"##;
const ICON_FOLDER: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#90a4ae" d="M1.5 3h5l1.5 1.5h6.5v9h-13z"/></svg>
"##;
const ICON_FOLDER_OPEN: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#90a4ae" d="M1.5 3h5l1.5 1.5h6v2h-10l-2 7h-.5z"/><path fill="#b0bec5" d="M4 7.5h11.5l-2 6H2z"/></svg>
"##;
const ICON_MARKDOWN: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="3.5" width="14" height="9" rx="1.5" fill="#42a5f5"/><path fill="#fff" d="M3 10.5v-5h1.5L6 7.5l1.5-2H9v5H7.5V8L6 10 4.5 8v2.5zm8 0L9.5 8h1V5.5H12V8h1z"/></svg>
"##;
const LANGUAGE_MANIFEST: &str = r#"id = "__ID__"
name = "__NAME__"
version = "0.1.0"
description = "Language support for .__SLUG__ files: detection, comments and indentation."
authors = []
engines.openidea = "^1.0"
# Same format as languages/<id>.toml in the IDE repository.
[[contributes.languages]]
path = "languages/__SLUG__.toml"
"#;
const LANGUAGE_DEF: &str = r##"id = "__SLUG__"
name = "__NAME__"
extensions = ["__SLUG__"]
line-comment = "#"
indent = { tab-size = 2, insert-spaces = true }
# Borrow an existing CodeMirror mode for instant highlighting.
ui-mode = "Properties files"
"##;
+31 -1
View File
@@ -1,6 +1,8 @@
//! `openidea-server --stdio`: the IDE backend for remote development, serving
//! one desktop client over stdin / stdout (started by the client over SSH).
use ide_host::plugins::remote::{RemotePluginLanguages, SYNC_COMMAND};
use ide_host::plugins::remote_host::RemotePlugins;
use ide_host::rpc::{self, Dispatcher};
use ide_host::{Host, HostConfig};
use ide_language::LanguageRegistry;
@@ -51,10 +53,15 @@ async fn run(base: PathBuf) -> i32 {
return 1;
}
};
// The desktop's plugin languages (`plugin_sync_languages`); programs pinned on this machine.
let plugin_languages = RemotePluginLanguages::new(Some(&data_dir));
languages.attach_plugins(plugin_languages.store().clone());
let peer = Arc::new(OnceLock::new());
let sink_peer = peer.clone();
let plugins_data = data_dir.clone();
let host = Arc::new(Host::new(HostConfig {
config_dir,
local_data_dir: data_dir.clone(),
data_dir,
languages,
syntax: Arc::default(),
@@ -63,8 +70,31 @@ async fn run(base: PathBuf) -> i32 {
runtime: tokio::runtime::Handle::current(),
project_environment: true,
}));
// The desktop's code plugins run here, next to the project (`plugin_sync_packages`).
let plugins = match RemotePlugins::start(&plugins_data, &host, peer.clone()) {
Ok(p) => Some(p),
Err(e) => {
tracing::warn!("plugins are unavailable: {e}");
None
}
};
tracing::info!("openidea-server {} ready", env!("CARGO_PKG_VERSION"));
rpc::serve(host.clone(), Dispatcher::standard(), peer, tokio::io::stdin(), tokio::io::stdout()).await;
let mut dispatcher = Dispatcher::standard();
if let Some(plugins) = &plugins {
plugins.register(&mut dispatcher);
}
dispatcher.add(SYNC_COMMAND, move |c| {
let remote = plugin_languages.clone();
async move {
let report = remote.sync(c.arg("languages")?);
c.host.plugin_languages_changed();
Ok(report)
}
});
rpc::serve(host.clone(), dispatcher, peer, tokio::io::stdin(), tokio::io::stdout()).await;
if let Some(plugins) = &plugins {
plugins.host().shutdown();
}
// `process::exit` skips destructors: nothing started for the client may outlive it.
if tokio::time::timeout(std::time::Duration::from_secs(10), host.shutdown()).await.is_err() {
tracing::warn!("language servers did not shut down in time");
+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-"))
);
}
}
+134 -46
View File
@@ -2,16 +2,48 @@
use crate::Host;
use ide_core::event::NotifyLevel;
use ide_core::settings::PrettierSettings;
use ide_core::settings::{ExternalFormatter, LanguagesSettings};
use ide_core::{DocId, IdeError, IdeEvent, IdeResult, Range, TextEdit};
use ide_language::FormatterConfig;
use ide_language::LanguageConfig;
use ide_lsp::types::FormatOptions;
use ide_tools::{Tools, formatter};
use std::path::Path;
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)
const OXFMT_CONFIGS: [&str; 4] = [".oxfmtrc.json", ".oxfmtrc.jsonc", "oxfmt.config.ts", "oxfmt.config.mts"];
/// The external formatter of a file, for a language with an external formatter.
/// `forced` names the tool (Reformat with Prettier/oxfmt). Otherwise oxfmt
/// applies when it is enabled for the language and either an oxfmt
/// configuration is found above the file or oxfmt is the selected provider;
/// else Prettier, when it is enabled. Turning Prettier off for a language
/// leaves it to the language server, not to oxfmt.
fn external_tool(
forced: Option<ExternalFormatter>,
language: Option<&LanguageConfig>,
file: &Path,
settings: &LanguagesSettings,
) -> IdeResult<Option<ExternalFormatter>> {
use ExternalFormatter::{Oxfmt, Prettier};
let Some(language) = language.filter(|l| l.formatter.as_ref().is_some_and(|f| f.tool == "prettier")) else {
return match forced {
Some(tool) => Err(IdeError::invalid(format!(
"{} cannot format {} files",
formatter::display_name(tool.name()),
language.map_or("these", |l| l.name.as_str())
))),
None => Ok(None),
};
};
if forced.is_some() {
return Ok(forced);
}
let enabled = |tool| settings.formatter(tool).languages.iter().any(|l| l == language.id.as_str());
let oxfmt_config = || OXFMT_CONFIGS.iter().any(|name| formatter::find_up(file, Path::new(name)).is_some());
Ok(if enabled(Oxfmt) && (settings.oxfmt.prefer || oxfmt_config()) {
Some(Oxfmt)
} else {
enabled(Prettier).then_some(Prettier)
})
}
pub async fn format(
@@ -24,36 +56,43 @@ pub async fn format(
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) {
let mut selected = external_tool(options.tool, language.as_ref(), &snap.path, &settings.languages)?;
if let Some(tool) = selected
&& options.tool.is_none()
&& !settings.languages.formatter(tool).prefer
&& project.lsp.can_format(doc).await?
{
selected = None;
}
let Some(selected) = selected else {
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 {
};
let tool_settings = settings.languages.formatter(selected);
if options.on_save && !tool_settings.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 custom = ide_sdk::apply::setting_path(&project.root, &prettier.path);
let tools = Tools::new(&host.data_dir);
let whole_selection = range.is_some() && !config.range;
let config = language.and_then(|l| l.formatter).expect("external_tool selects languages with a formatter");
let tool = selected.name();
let name = formatter::display_name(tool);
let custom = ide_sdk::apply::setting_path(&project.root, &tool_settings.path);
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(&config.tool, &snap.path, custom.as_deref())?;
command.current_dir(snap.path.parent().ok_or_else(|| IdeError::invalid("Prettier: file has no directory"))?);
command.args(formatter::arguments(&config, &snap.path, options.tab_size, options.insert_spaces, offsets));
let formatted = formatter::run(command, &snap.text, &Default::default())?;
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
@@ -69,39 +108,88 @@ pub async fn format(
if edits.is_ok() && whole_selection {
project.sink.emit(IdeEvent::Notify {
level: NotifyLevel::Info,
title: "Prettier".into(),
body: Some(
"Prettier does not support selection formatting for this language; formatted the whole file".into(),
),
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("prettier"))
edits.map_err(|e| e.with_tool(tool))
}
#[cfg(test)]
mod tests {
use super::*;
use ExternalFormatter::{Oxfmt, Prettier};
use ide_core::LanguageId;
use ide_language::LanguageRegistry;
fn tool(
forced: Option<ExternalFormatter>,
language: &str,
file: &Path,
settings: &LanguagesSettings,
) -> IdeResult<Option<ExternalFormatter>> {
let language = LanguageRegistry::load(None).unwrap().get(&LanguageId::new(language));
external_tool(forced, language.as_ref(), file, settings)
}
#[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
);
}
fn default_settings_give_every_external_formatter_language_to_prettier() {
let settings = LanguagesSettings::default();
let file = Path::new("/project/file");
for lang in LanguageRegistry::load(None).unwrap().all() {
let configured = lang.formatter.as_ref().is_some_and(|f| f.tool == "prettier");
let selected = external_tool(None, Some(&lang), file, &settings).unwrap();
assert_eq!(selected, configured.then_some(Prettier), "{}", 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));
assert_eq!(external_tool(None, None, file, &settings).unwrap(), None);
}
#[test]
fn oxfmt_config_selects_oxfmt_for_its_enabled_languages() {
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"));
let mut settings = LanguagesSettings::default();
assert_eq!(tool(None, "typescript", &web, &settings).unwrap(), Some(Prettier));
for config in OXFMT_CONFIGS {
std::fs::write(root.join("web").join(config), "{}").unwrap();
assert_eq!(tool(None, "typescript", &web, &settings).unwrap(), Some(Oxfmt), "{config}");
assert_eq!(tool(None, "typescript", &api, &settings).unwrap(), Some(Prettier), "{config}");
std::fs::remove_file(root.join("web").join(config)).unwrap();
}
std::fs::write(root.join("web/.oxfmtrc.json"), "{}").unwrap();
settings.oxfmt.languages.retain(|l| l != "markdown");
assert_eq!(tool(None, "markdown", &web, &settings).unwrap(), Some(Prettier));
assert_eq!(tool(None, "typescript", &web, &settings).unwrap(), Some(Oxfmt));
}
#[test]
fn oxfmt_provider_selects_oxfmt_without_a_config_and_prettier_off_leaves_the_language_server() {
let file = Path::new("/project/src/app.ts");
let mut settings = LanguagesSettings::default();
settings.oxfmt.prefer = true;
assert_eq!(tool(None, "typescript", file, &settings).unwrap(), Some(Oxfmt));
settings.oxfmt.prefer = false;
settings.prettier.languages.clear();
assert_eq!(tool(None, "typescript", file, &settings).unwrap(), None);
settings.oxfmt.prefer = true;
settings.oxfmt.languages.clear();
assert_eq!(tool(None, "typescript", file, &settings).unwrap(), None);
}
#[test]
fn forced_tool_runs_that_formatter_or_reports_why_not() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join(".oxfmtrc.json"), "{}").unwrap();
let file = dir.path().join("app.ts");
let mut settings = LanguagesSettings::default();
settings.oxfmt.languages.clear();
assert_eq!(tool(Some(Prettier), "typescript", &file, &settings).unwrap(), Some(Prettier));
assert_eq!(tool(Some(Oxfmt), "typescript", &file, &settings).unwrap(), Some(Oxfmt));
let rust = tool(Some(Oxfmt), "rust", &dir.path().join("main.rs"), &settings).unwrap_err();
assert_eq!(rust.message, "oxfmt cannot format Rust files");
}
}
+67 -6
View File
@@ -1,14 +1,16 @@
//! The IDE backend without a UI toolkit: open projects per window, terminals,
//! processes, settings and the remote services. The desktop shell (`src-tauri`)
//! wraps one [`Host`] and adds windows, menus and plugins; `openidea-server`
//! wraps one [`Host`] and adds windows and menus; `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;
pub mod plugins;
mod project;
mod proxy;
mod requests;
@@ -33,7 +35,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>>>,
@@ -49,10 +54,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).
@@ -76,8 +85,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);
@@ -86,8 +104,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(),
@@ -96,6 +117,7 @@ impl Host {
syntax,
config_dir,
data_dir,
local_data_dir,
sink_for,
owner,
runtime,
@@ -112,6 +134,17 @@ impl Host {
self.syntax.get_or_init(|| Arc::new(Syntax::new())).clone()
}
/// Plugin languages or the servers they may start changed (they are part
/// of [`Self::languages`] and every project's fork): open documents are
/// detected again and language servers follow their new configuration.
pub fn plugin_languages_changed(&self) {
for project in self.projects() {
project.documents.redetect_languages();
let lsp = project.lsp.clone();
self.runtime.spawn(async move { lsp.reconfigure().await });
}
}
/// Every open project (all windows).
pub fn projects(&self) -> Vec<Arc<Project>> {
self.projects.read().values().cloned().collect()
@@ -153,14 +186,24 @@ impl Host {
if let Some(bridge) = &project.kotlin_bridge {
bridge.stop();
}
project.documents.clear_workspace_edit_history();
project.agents.shutdown();
project.notebooks.stop_admission();
// 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())),
);
let lsp = project.lsp.clone();
self.runtime.spawn(async move { lsp.shutdown_all().await });
}
}
@@ -170,6 +213,8 @@ impl Host {
&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();
@@ -187,6 +232,12 @@ impl Host {
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() {
@@ -208,9 +259,18 @@ impl Host {
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.
/// For a process that exits without dropping the host (the remote server).
pub async fn shutdown(&self) {
@@ -222,6 +282,7 @@ impl Host {
if let Some(bridge) = &project.kotlin_bridge {
bridge.stop();
}
project.documents.clear_workspace_edit_history();
project.notebooks.stop_admission();
project.agents.shutdown();
}
+19 -3
View File
@@ -23,9 +23,16 @@ pub fn providers(host: &Host, window: &str) -> IdeResult<Vec<AgentProvider>> {
Ok(providers)
}
/// Overrides built-in agent `id`'s command with `command` (its agent file keeps the
/// built-in's driver and arguments), or returns it to auto-detection with `None`.
pub fn set_command(host: &Host, window: &str, id: String, command: Option<String>) -> IdeResult<()> {
host.require_trust(window)?;
host.project(window)?.agents.set_builtin_command(&id, command)
}
/// 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 +70,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
@@ -179,6 +194,7 @@ pub async fn base_text(host: &Host, window: &str, task: String, path: IpcPath) -
pub(crate) fn register(d: &mut Dispatcher) {
d.add("agent_subscribe", |c| async move { subscribe(&c.host, &c.window, c.channel("onEvent")?) });
d.add("agent_providers", |c| async move { providers(&c.host, &c.window) });
d.add("agent_set_command", |c| async move { set_command(&c.host, &c.window, c.arg("id")?, c.arg("command")?) });
d.add("agent_registry_list", |c| async move { registry_list(&c.host, &c.window, c.arg("request")?).await });
d.add("agent_registry_install", |c| async move {
registry_install(&c.host, &c.window, c.arg("id")?, c.arg("request")?, c.sender("onEvent")?).await
+68 -20
View File
@@ -74,6 +74,18 @@ pub fn load(root: &Path, cache: &Path, settings: &Settings) -> BuildModel {
model
}
/// `model` building with the Maven profiles of the last sync (`-P`), as Maven runs from
/// the tool window do.
fn with_profiles(mut model: BuildModel, cache: &Path) -> BuildModel {
let profiles = ide_build::maven_profiles(cache);
if let (Some(m), false) = (&mut model.maven, profiles.is_empty()) {
for p in &mut m.projects {
p.options.push(format!("-P{}", profiles.join(",")));
}
}
model
}
/// Maven and Gradle projects of the window's project, from the build files plus the last sync.
pub async fn build_model(host: &Host, window: &str) -> IdeResult<BuildModel> {
host.require_trust(window)?;
@@ -98,6 +110,12 @@ pub async fn build_sync(
let project = host.project(window)?;
let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective());
let bridge_profiles = profiles.clone();
// What the Java language server and Build Project resolve the project with from now on.
let profiles_changed = tool == BuildTool::Maven
&& ide_build::save_maven_profiles(&cache, &profiles).unwrap_or_else(|e| {
tracing::warn!("Maven profiles not saved: {e}");
false
});
let token = host.begin_project_request(window, request);
let result = tokio::task::spawn_blocking(move || {
let current = load(&root, &cache, &settings);
@@ -132,23 +150,50 @@ pub async fn build_sync(
.map_err(join_err);
host.end_request(request);
let model = result??;
if tool == BuildTool::Maven
&& let Some(maven) = &model.maven
{
// 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).
if let Some(bridge) = &project.kotlin_bridge {
bridge.configure_maven(offline, bridge_profiles);
}
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}");
}
});
if tool == BuildTool::Gradle {
// The sync may report project folders outside the project (`includeFlat`): index and list them.
project.refresh_index_scope();
project.sink.emit(ide_core::IdeEvent::ProjectRootsChanged { root: project.root.clone().into() });
let p = project.clone();
tokio::task::spawn_blocking(move || p.refresh_repositories());
}
if tool == BuildTool::Maven
&& model.maven.is_some()
&& let Some(bridge) = &project.kotlin_bridge
{
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();
// With a module of a reactor unlinked, jdtls imports the other modules one by one and
// not the reactor: naming the reactor here would import it again, all modules included.
let imported = match tool {
BuildTool::Maven => crate::project::jdtls_maven_import(&project.root, &project.settings.effective()).1,
BuildTool::Gradle => None,
};
let uris = match imported {
Some(files) => files.into_iter().filter(|f| f.ends_with("/pom.xml")).collect(),
None => build_files.iter().map(|f| ide_lsp::path_to_uri_string(&project.root.join(f))).collect(),
};
tokio::spawn(async move {
if profiles_changed {
// jdtls takes the profiles from its Maven settings (see `ide_sdk::maven_profiles`).
lsp.reconfigure().await;
}
if let Err(e) = lsp.java_update_project_configs(&project.root, uris).await {
tracing::debug!("java/projectConfigurationsUpdate: {e}");
}
});
Ok(model)
}
@@ -203,8 +248,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) => {}
@@ -248,7 +295,8 @@ pub async fn build_run(
for member in workspace.projects.iter().filter(|p| file.as_ref().is_none_or(|f| f.starts_with(p))) {
let settings = ide_workspace::SettingsStore::load(&config_dir, Some(member)).effective();
let member_cache = cache.join(crate::project::project_key(member));
let plan = compile::plan(member, &load(member, &member_cache, &settings), kind, file.as_deref())?;
let model = with_profiles(load(member, &member_cache, &settings), &member_cache);
let plan = compile::plan(member, &model, kind, file.as_deref())?;
match &mut combined {
Some(combined) => combined.steps.extend(plan.steps),
None => combined = Some(plan),
@@ -258,7 +306,7 @@ pub async fn build_run(
plan.title = workspace.name;
plan
} else {
compile::plan(&root, &load(&root, &cache, &settings), kind, file.as_deref())?
compile::plan(&root, &with_profiles(load(&root, &cache, &settings), &cache), kind, file.as_deref())?
};
let result = compile::run(&root, &plan, &token, &|e| {
let _ = on_event.send(e);
+422
View File
@@ -0,0 +1,422 @@
//! Database commands shared by the desktop shell's IPC commands and the
//! remote server ([`crate::rpc`]). `db_export_file` stays in the desktop
//! shell: its path comes from a local save dialog, so the export must land
//! on the user's machine, not the side the project runs on.
use crate::Host;
use crate::rpc::Dispatcher;
use ide_core::{IdeError, IdeResult, IpcPath, RequestId};
use ide_db::split::SqlStatement;
use ide_db::{
CompletionModel, DataSource, Databases, DbObjectKind, DbRowEdit, HistoryEntry, PlanNode, QueryResult,
SchemaObjects, SortKey, TableDetails, TableInfo, TablePage,
};
use serde::{Deserialize, Serialize};
use specta::Type;
/// One statement of a console run.
#[derive(Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
#[serde(rename = "DbStatementOutcome")]
pub struct StatementOutcome {
pub statement: SqlStatement,
pub result: Option<QueryResult>,
pub error: Option<String>,
}
pub(crate) fn register(d: &mut Dispatcher) {
d.add("db_sources", |c| async move { sources(&c.host, &c.window) });
d.add("db_save_sources", |c| async move {
let sources = c.arg("sources")?;
save_sources(&c.host, &c.window, sources).await
});
d.add("db_set_password", |c| async move {
let (id, password, save) =
(c.arg::<String>("id")?, c.arg::<Option<String>>("password")?, c.arg::<bool>("save")?);
set_password(&c.host, &c.window, &id, password, save).await
});
d.add("db_test", |c| async move {
let (source, password, ssh_secret) = (
c.arg::<DataSource>("source")?,
c.arg::<Option<String>>("password")?,
c.arg::<Option<String>>("sshSecret")?,
);
test(&c.host, &c.window, source, password, ssh_secret).await
});
d.add("db_set_ssh_secret", |c| async move {
let (id, secret, save) = (c.arg::<String>("id")?, c.arg::<Option<String>>("secret")?, c.arg::<bool>("save")?);
set_ssh_secret(&c.host, &c.window, &id, secret, save).await
});
d.add("db_trust_ssh_host", |c| async move {
let id = c.arg::<String>("id")?;
trust_ssh_host(&c.host, &c.window, &id).await
});
d.add("db_connected", |c| async move { connected(&c.host, &c.window) });
d.add("db_connect", |c| async move {
let id = c.arg::<String>("id")?;
connect(&c.host, &c.window, &id).await
});
d.add("db_set_manual_commit", |c| async move {
let (id, manual) = (c.arg::<String>("id")?, c.arg::<bool>("manual")?);
set_manual_commit(&c.host, &c.window, &id, manual).await
});
d.add("db_finish_transaction", |c| async move {
let (id, commit) = (c.arg::<String>("id")?, c.arg::<bool>("commit")?);
finish_transaction(&c.host, &c.window, &id, commit).await
});
d.add("db_disconnect", |c| async move {
let id = c.arg::<String>("id")?;
disconnect(&c.host, &c.window, &id).await
});
d.add("db_databases", |c| async move {
let id = c.arg::<String>("id")?;
databases(&c.host, &c.window, &id).await
});
d.add("db_schemas", |c| async move {
let id = c.arg::<String>("id")?;
schemas(&c.host, &c.window, &id).await
});
d.add("db_tables", |c| async move {
let (id, schema) = (c.arg::<String>("id")?, c.arg::<String>("schema")?);
tables(&c.host, &c.window, &id, &schema).await
});
d.add("db_table_details", |c| async move {
let (id, schema, table) = (c.arg::<String>("id")?, c.arg::<String>("schema")?, c.arg::<String>("table")?);
table_details(&c.host, &c.window, &id, &schema, &table).await
});
d.add("db_ddl", |c| async move {
let (id, schema, table) = (c.arg::<String>("id")?, c.arg::<String>("schema")?, c.arg::<String>("table")?);
ddl(&c.host, &c.window, &id, &schema, &table).await
});
d.add("db_schema_objects", |c| async move {
let (id, schema) = (c.arg::<String>("id")?, c.arg::<String>("schema")?);
schema_objects(&c.host, &c.window, &id, &schema).await
});
d.add("db_object_ddl", |c| async move {
let (id, schema, kind, name) = (
c.arg::<String>("id")?,
c.arg::<String>("schema")?,
c.arg::<DbObjectKind>("kind")?,
c.arg::<String>("name")?,
);
object_ddl(&c.host, &c.window, &id, &schema, kind, &name).await
});
d.add("db_explain", |c| async move {
let (request, id, statement) =
(c.arg::<RequestId>("request")?, c.arg::<String>("id")?, c.arg::<String>("statement")?);
explain(&c.host, &c.window, request, &id, &statement).await
});
d.add("db_history", |c| async move {
let id = c.arg::<String>("id")?;
history(&c.host, &c.window, &id).await
});
d.add("db_remove_history", |c| async move {
let (id, texts) = (c.arg::<String>("id")?, c.arg::<Option<Vec<String>>>("texts")?);
remove_history(&c.host, &c.window, &id, texts).await
});
d.add("db_completion", |c| async move {
let id = c.arg::<String>("id")?;
completion(&c.host, &c.window, &id).await
});
d.add("db_statements", |c| async move {
let (id, text, from, to) =
(c.arg::<String>("id")?, c.arg::<String>("text")?, c.arg::<u32>("from")?, c.arg::<u32>("to")?);
statements(&c.host, &c.window, &id, &text, from, to)
});
d.add("db_execute", |c| async move {
let (request, id, statements, max_rows) = (
c.arg::<RequestId>("request")?,
c.arg::<String>("id")?,
c.arg::<Vec<SqlStatement>>("statements")?,
c.arg::<u32>("maxRows")?,
);
execute(&c.host, &c.window, request, &id, statements, max_rows).await
});
d.add("db_table_page", |c| async move {
let (request, id, schema, table) = (
c.arg::<RequestId>("request")?,
c.arg::<String>("id")?,
c.arg::<String>("schema")?,
c.arg::<String>("table")?,
);
let (filter, sort, offset, limit) = (
c.arg::<Option<String>>("filter")?,
c.arg::<Vec<SortKey>>("sort")?,
c.arg::<u32>("offset")?,
c.arg::<u32>("limit")?,
);
table_page(&c.host, &c.window, request, &id, &schema, &table, filter, sort, offset, limit).await
});
d.add("db_apply_edits", |c| async move {
let (id, schema, table, edits) = (
c.arg::<String>("id")?,
c.arg::<String>("schema")?,
c.arg::<String>("table")?,
c.arg::<Vec<DbRowEdit>>("edits")?,
);
apply_edits(&c.host, &c.window, &id, &schema, &table, edits).await
});
d.add("db_console_path", |c| async move { console_path(&c.host, &c.arg::<String>("id")?) });
}
/// The data sources configured for the project (the tree's roots).
pub fn sources(host: &Host, window: &str) -> IdeResult<Vec<DataSource>> {
host.project(window)?.db.sources()
}
/// Replaces the project's data sources with `sources`.
pub async fn save_sources(host: &Host, window: &str, sources: Vec<DataSource>) -> IdeResult<()> {
let db = host.project(window)?.db.clone();
db.save_sources(sources).await
}
/// Remembers the password of data source `id` for the session, and in the OS
/// saved passwords when `save` (`None` forgets it).
pub async fn set_password(host: &Host, window: &str, id: &str, password: Option<String>, save: bool) -> IdeResult<()> {
let db = host.project(window)?.db.clone();
db.set_password(id, password, save).await
}
/// Test Connection: connects with unsaved settings and returns the server version.
pub async fn test(
host: &Host,
window: &str,
source: DataSource,
password: Option<String>,
ssh_secret: Option<String>,
) -> IdeResult<String> {
let db = host.project(window)?.db.clone();
db.test(&source, password, ssh_secret).await
}
/// Remembers the SSH password / key passphrase of a data source (see `set_password`).
pub async fn set_ssh_secret(host: &Host, window: &str, id: &str, secret: Option<String>, save: bool) -> IdeResult<()> {
let db = host.project(window)?.db.clone();
db.set_ssh_secret(id, secret, save).await
}
/// Trusts the SSH host key that made the last connection of `id` fail with
/// "Unknown SSH host key" (records it in `~/.ssh/known_hosts`).
pub async fn trust_ssh_host(host: &Host, window: &str, id: &str) -> IdeResult<()> {
let db = host.project(window)?.db.clone();
db.trust_ssh_host(id).await
}
/// The data sources with an open connection.
pub fn connected(host: &Host, window: &str) -> IdeResult<Vec<String>> {
Ok(host.project(window)?.db.connected())
}
/// Connects (fails with `permissionDenied` when the password is missing or wrong).
pub async fn connect(host: &Host, window: &str, id: &str) -> IdeResult<()> {
let db = host.project(window)?.db.clone();
db.pool(id).await.map(|_| ())
}
/// Switches a data source between auto-commit and manual commit (leaving
/// manual mode rolls back what was not committed).
pub async fn set_manual_commit(host: &Host, window: &str, id: &str, manual: bool) -> IdeResult<()> {
let db = host.project(window)?.db.clone();
db.set_manual_commit(id, manual).await;
Ok(())
}
/// Commits or rolls back the open transaction of a manual-commit data source.
pub async fn finish_transaction(host: &Host, window: &str, id: &str, commit: bool) -> IdeResult<()> {
let db = host.project(window)?.db.clone();
db.finish_transaction(id, commit).await
}
/// Closes the connection of data source `id`.
pub async fn disconnect(host: &Host, window: &str, id: &str) -> IdeResult<()> {
let db = host.project(window)?.db.clone();
db.disconnect(id).await;
Ok(())
}
/// Databases of the server of data source `id` (PostgreSQL); connect to one as `<id>:<database>`.
pub async fn databases(host: &Host, window: &str, id: &str) -> IdeResult<Databases> {
let db = host.project(window)?.db.clone();
db.databases(id).await
}
/// The schemas of data source `id`.
pub async fn schemas(host: &Host, window: &str, id: &str) -> IdeResult<Vec<String>> {
let db = host.project(window)?.db.clone();
db.schemas(id).await
}
/// The tables and views of `schema`.
pub async fn tables(host: &Host, window: &str, id: &str, schema: &str) -> IdeResult<Vec<TableInfo>> {
let db = host.project(window)?.db.clone();
db.tables(id, schema).await
}
/// Columns, keys and indexes of `table`.
pub async fn table_details(host: &Host, window: &str, id: &str, schema: &str, table: &str) -> IdeResult<TableDetails> {
let db = host.project(window)?.db.clone();
db.table_details(id, schema, table).await
}
/// The DDL of table or view `table` (Go to DDL, Generate DDL).
pub async fn ddl(host: &Host, window: &str, id: &str, schema: &str, table: &str) -> IdeResult<String> {
let db = host.project(window)?.db.clone();
db.ddl(id, schema, table).await
}
/// The routines and sequences of `schema` (the tree's `routines` / `sequences`).
pub async fn schema_objects(host: &Host, window: &str, id: &str, schema: &str) -> IdeResult<SchemaObjects> {
let db = host.project(window)?.db.clone();
db.schema_objects(id, schema).await
}
/// The definition of a routine, trigger or sequence (Go to DDL, Quick Documentation).
pub async fn object_ddl(
host: &Host,
window: &str,
id: &str,
schema: &str,
kind: DbObjectKind,
name: &str,
) -> IdeResult<String> {
let db = host.project(window)?.db.clone();
db.object_ddl(id, schema, kind, name).await
}
/// Explain Plan: the execution plan of `statement` on `id`, without running it.
pub async fn explain(
host: &Host,
window: &str,
request: RequestId,
id: &str,
statement: &str,
) -> IdeResult<Vec<PlanNode>> {
let db = host.project(window)?.db.clone();
let cancel = host.begin_request(request);
let result = db.explain(id, statement, &cancel).await;
host.end_request(request);
result
}
/// The statements data source `id` ran, newest first (Browse Query History).
pub async fn history(host: &Host, window: &str, id: &str) -> IdeResult<Vec<HistoryEntry>> {
let db = host.project(window)?.db.clone();
db.history(id).await
}
/// Removes statements from the query history of `id` (`None`: all of them).
pub async fn remove_history(host: &Host, window: &str, id: &str, texts: Option<Vec<String>>) -> IdeResult<()> {
let db = host.project(window)?.db.clone();
db.remove_history(id, texts).await
}
/// Tables, columns and foreign keys for SQL completion.
pub async fn completion(host: &Host, window: &str, id: &str) -> IdeResult<CompletionModel> {
let db = host.project(window)?.db.clone();
db.completion(id).await
}
/// Statements a console runs: the selection's (`from < to`) or the one at the caret (`from`).
pub fn statements(host: &Host, window: &str, id: &str, text: &str, from: u32, to: u32) -> IdeResult<Vec<SqlStatement>> {
let driver = host.project(window)?.db.source(id)?.driver;
Ok(if from < to {
ide_db::split::statements_in(text, from, to, driver)
} else {
ide_db::split::statement_at(text, from, driver).into_iter().collect()
})
}
/// Runs `statements` in order on data source `id`, stopping at the first
/// error. Cancel with `cancel_request(request)`.
pub async fn execute(
host: &Host,
window: &str,
request: RequestId,
id: &str,
statements: Vec<SqlStatement>,
max_rows: u32,
) -> IdeResult<Vec<StatementOutcome>> {
let db = host.project(window)?.db.clone();
let cancel = host.begin_request(request);
let mut out = Vec::new();
for statement in statements {
match db.execute(id, &statement.text, max_rows.max(1) as usize, &cancel).await {
Ok(result) => out.push(StatementOutcome { statement, result: Some(result), error: None }),
Err(e) if e.kind == ide_core::error::ErrorKind::Cancelled => {
host.end_request(request);
return Err(e);
}
Err(e) if e.kind == ide_core::error::ErrorKind::PermissionDenied && out.is_empty() => {
// Not connected for lack of a password: the UI asks and retries.
host.end_request(request);
return Err(e);
}
Err(e) => {
out.push(StatementOutcome { statement, result: None, error: Some(e.message) });
break;
}
}
}
host.end_request(request);
Ok(out)
}
/// One page of the rows of `table` (the table editor).
#[allow(clippy::too_many_arguments)]
pub async fn table_page(
host: &Host,
window: &str,
request: RequestId,
id: &str,
schema: &str,
table: &str,
filter: Option<String>,
sort: Vec<SortKey>,
offset: u32,
limit: u32,
) -> IdeResult<TablePage> {
let db = host.project(window)?.db.clone();
let cancel = host.begin_request(request);
let result =
db.table_page(id, schema, table, filter.as_deref(), &sort, offset.into(), limit.max(1).into(), &cancel).await;
host.end_request(request);
result
}
/// Submits the table editor's changes in one transaction; returns the rows changed.
pub async fn apply_edits(
host: &Host,
window: &str,
id: &str,
schema: &str,
table: &str,
edits: Vec<DbRowEdit>,
) -> IdeResult<f64> {
let db = host.project(window)?.db.clone();
Ok(db.apply_edits(id, schema, table, &edits).await? as f64)
}
/// The query console file of data source `id` (`<dataDir>/consoles/db/<id>/console.sql`),
/// created on first use. Consoles are files of the project, so a remote
/// window resolves it on the host the project runs on.
pub fn console_path(host: &Host, id: &str) -> IdeResult<IpcPath> {
let (source, database) = ide_db::split_target(id);
if source.is_empty() || !source.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') {
return Err(IdeError::invalid("invalid data source id"));
}
// One console per database: `<id>` for the default one, `<id>.<database>` for the others.
let name = match database {
Some(db) => {
let safe: String =
db.chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '_' }).collect();
format!("{source}.{safe}")
}
None => source.to_owned(),
};
let dir = host.data_dir.join("consoles").join("db").join(&name);
std::fs::create_dir_all(&dir)?;
let file = dir.join("console.sql");
if !file.exists() {
std::fs::write(&file, "")?;
}
Ok(file.into())
}
+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("local"),
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");
}
}
+7 -8
View File
@@ -1,6 +1,6 @@
use crate::Host;
use ide_core::{CancellationToken, IdeEvent, IdeResult, IpcPath, RequestId, SharedSink, TaskId};
use ide_remote::{DeployedPath, DeploymentCoverage, SyncEntry, SyncStep, TransferReport};
use ide_remote::{DeployedPath, DeploymentCoverage, DeploymentRoot, SyncEntry, SyncStep, TransferReport};
use ide_ssh::{FsConnection, RemoteEntry};
use std::path::PathBuf;
@@ -134,13 +134,12 @@ pub async fn deploy_info(
state.remote.deployed(&project.settings.effective(), &project.root, server.as_deref(), &path).await
}
/// Remote Host tool window: the server root of `server`, resolved on the host.
pub async fn deploy_server_root(window: &str, state: &Host, server: String) -> IdeResult<String> {
let settings = match state.project(window) {
Ok(p) => p.settings.effective(),
Err(_) => state.global_settings.effective(),
};
state.remote.server_root(&settings, &server).await
/// Remote Host tool window: the resolved server root and project mappings.
pub async fn deploy_server_root(window: &str, state: &Host, server: String) -> IdeResult<DeploymentRoot> {
let project = state.project(window).ok();
let settings =
project.as_ref().map(|p| p.settings.effective()).unwrap_or_else(|| state.global_settings.effective());
state.remote.server_info(&settings, project.as_ref().map(|p| p.root.as_path()), &server).await
}
/// Entries of a folder on the selected filesystem (`~`: its home directory).
+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 |_| {
+31 -25
View File
@@ -56,14 +56,16 @@ pub async fn copy_merge(
if overwrite {
project.history.record_deletion(&to, DELETE_SNAPSHOT_LIMIT);
}
ide_vfs::copy_merge(
&from,
&to,
overwrite,
Some(&project.root),
&project.exclude_globs(),
(&source_root, &destination_root),
)
project.with_disk_sync(|| {
ide_vfs::copy_merge(
&from,
&to,
overwrite,
Some(&project.root),
&project.exclude_globs(),
(&source_root, &destination_root),
)
})
})
.await
}
@@ -71,24 +73,28 @@ pub async fn copy_merge(
/// 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
+74 -6
View File
@@ -59,9 +59,9 @@ async fn resolve_launch(
.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, cancel).await?;
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 _external_build = external_build;
@@ -86,7 +86,7 @@ async fn resolve_launch(
};
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);
}
}
@@ -96,7 +96,13 @@ async fn resolve_launch(
let project_jdk = ide_sdk::apply::project_jdk(&project.root, &settings);
let from_jdtls = info.java_home.as_deref().map(ide_sdk::java::java_executable).filter(|p| p.is_file());
let java = project_jdk.map(|j| ide_sdk::java::java_executable(&j.home)).or(from_jdtls).or_else(|| {
let release = ide_sdk::java::project_java_version(&module);
// The build model's level includes what the module inherits from its parent POMs.
let release = model
.maven
.as_ref()
.and_then(|m| m.projects.iter().find(|p| project.root.join(&p.dir) == module))
.and_then(|p| p.java_version)
.or_else(|| ide_sdk::java::project_java_version(&module));
let jdks = ide_sdk::java::installed_jdks();
info.hints.extend(ide_sdk::java::missing_jdk_message(&jdks, release));
ide_sdk::java::pick_jdk(&jdks, release).map(|j| ide_sdk::java::java_executable(&j.home))
@@ -131,15 +137,44 @@ 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| p != original.as_ref());
info.class_paths.retain(|p| !same_entry(p, &original, windows));
let mut modular = false;
for path in &mut info.module_paths {
if path == original.as_ref() {
if same_entry(path, &original, windows) {
*path = dest.to_string_lossy().into_owned();
modular = true;
}
@@ -185,6 +220,39 @@ pub(crate) fn register(d: &mut Dispatcher) {
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();
+40 -8
View File
@@ -1,7 +1,7 @@
//! Bounded, read-only JVM presentation for visible files, also served remotely.
use crate::Host;
use ide_core::{CancellationToken, IdeError, IdeResult, LanguageId};
use ide_syntax::{JvmPresentation, Snapshot};
use ide_syntax::{JvmPresentation, Snapshot, StructureService, jvm_type_header};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::{
@@ -38,6 +38,27 @@ fn prefix(path: &PathBuf) -> std::io::Result<String> {
Ok(String::from_utf8_lossy(&bytes).into_owned())
}
/// The presentation of the file at `path` from its first [`PREFIX_BYTES`].
fn read_presentation(
structure: &StructureService,
path: &PathBuf,
language: &LanguageId,
) -> IdeResult<JvmPresentation> {
let source = prefix(path)?;
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or_default();
let present =
|source: &str| structure.jvm_presentation(&Snapshot::text(language, source), stem, &CancellationToken::new());
let presentation = present(&source)?;
// Cut off inside its body, a large file doesn't parse as a declaration: its header does.
if presentation.kind.is_none()
&& source.len() as u64 >= PREFIX_BYTES
&& let Some(header) = jvm_type_header(&source)
{
return Ok(present(&format!("{header}}}"))?);
}
Ok(presentation)
}
pub async fn jvm_file_presentations(
host: &Host,
window: &str,
@@ -67,13 +88,7 @@ pub async fn jvm_file_presentations(
let presentation = if let Some(hit) = hit {
hit.presentation
} else {
let Ok(source) = prefix(&path) else { continue };
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or_default();
let presentation = project.structure.jvm_presentation(
&Snapshot::text(&language, &source),
stem,
&CancellationToken::new(),
)?;
let Ok(presentation) = read_presentation(&project.structure, &path, &language) else { continue };
let mut cache = cache.lock().unwrap();
if cache.len() >= MAX_CACHE {
cache.clear();
@@ -99,4 +114,21 @@ mod tests {
assert_eq!(prefix(&path).unwrap().len(), PREFIX_BYTES as usize);
std::fs::remove_file(path).unwrap();
}
#[test]
fn large_files_cut_off_in_their_body_keep_their_kind() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("Foo.java");
let mut text = String::from("package a;\n\n@Service\npublic class Foo implements IFoo {\n");
while text.len() < PREFIX_BYTES as usize + 1024 {
// The prefix ends inside one of these comments.
text.push_str(" /**\n * Does something.\n */\n public void m() { call(\"x\"); }\n");
}
std::fs::write(&path, &text).unwrap();
let syntax = std::sync::Arc::new(ide_language::Syntax::new());
let structure = StructureService::new(std::sync::Arc::new(ide_language::SyntaxCache::new(syntax)));
let java = LanguageId::new("java");
let presentation = read_presentation(&structure, &path, &java).unwrap();
assert_eq!(presentation.kind, Some(ide_syntax::JvmTypeKind::Class));
}
}
+316 -30
View File
@@ -1,8 +1,34 @@
use super::doc::blocking;
use crate::Host;
use crate::apply_workspace_edit as apply_edit;
use crate::plugins::{ProviderKind, editor_providers_for as editor_providers};
use crate::rpc::Dispatcher;
use ide_core::{DocId, IdeResult, Location, Pos, Range, RequestId, ServerId, TextEdit, WorkspaceEdit};
use ide_lsp::types::*;
use ide_plugin::ProviderDocument;
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,
@@ -14,9 +40,99 @@ pub async fn lsp_completion(
) -> IdeResult<CompletionList> {
let lsp = host.project(window)?.lsp.clone();
let cancel = host.begin_request(request);
let result = lsp.completion(doc, pos, trigger, cancel).await;
let result = lsp.completion(doc, pos, trigger.clone(), cancel).await;
host.end_request(request);
result
// Plugin providers (`[[contributes.providers]]`) add theirs.
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::Completion) else { return result };
let items = plugin_call(move || plugins.provide_completion(&d, pos, trigger.as_deref())).await.unwrap_or_default();
if items.is_empty() {
return result;
}
let mut list = result.unwrap_or(CompletionList { is_incomplete: false, items: Vec::new() });
list.items.extend(items.into_iter().map(|(_, item)| plugin_completion(item)));
Ok(list)
}
/// The plugin host and the document, when some plugin provides `kind`.
fn plugin_providers(
host: &Host,
window: &str,
doc: DocId,
kind: ProviderKind,
) -> Option<(std::sync::Arc<crate::plugins::PluginHost>, ProviderDocument)> {
let plugins = editor_providers(kind)?;
let snap = host.project(window).ok()?.documents.snapshot(doc).ok()?;
let d = ProviderDocument {
path: snap.path.0,
language: snap.language.0,
version: u64::from(snap.version),
text: snap.text,
};
Some((plugins, d))
}
/// Plugin calls block (the plugin's actor thread does the work).
async fn plugin_call<T: Send + 'static>(f: impl FnOnce() -> T + Send + 'static) -> Option<T> {
tokio::task::spawn_blocking(f).await.ok()
}
/// Items from plugins carry no server (resolve and commands don't apply).
const PLUGIN_SERVER: ServerId = ServerId(0);
/// A plugin's `completion-item.kind` (wit/providers.wit lists them).
fn plugin_completion_kind(kind: &str) -> Option<CompletionKind> {
Some(match kind {
"text" => CompletionKind::Text,
"method" => CompletionKind::Method,
"function" => CompletionKind::Function,
"constructor" => CompletionKind::Constructor,
"field" => CompletionKind::Field,
"variable" => CompletionKind::Variable,
"class" => CompletionKind::Class,
"interface" => CompletionKind::Interface,
"module" => CompletionKind::Module,
"property" => CompletionKind::Property,
"unit" => CompletionKind::Unit,
"value" => CompletionKind::Value,
"enum" => CompletionKind::Enum,
"keyword" => CompletionKind::Keyword,
"snippet" => CompletionKind::Snippet,
"color" => CompletionKind::Color,
"file" => CompletionKind::File,
"reference" => CompletionKind::Reference,
"folder" => CompletionKind::Folder,
"enum-member" => CompletionKind::EnumMember,
"constant" => CompletionKind::Constant,
"struct" => CompletionKind::Struct,
"event" => CompletionKind::Event,
"operator" => CompletionKind::Operator,
"type-parameter" => CompletionKind::TypeParameter,
_ => return None,
})
}
fn plugin_completion(item: ide_plugin::CompletionItem) -> CompletionItem {
let kind = item.kind.as_deref().and_then(plugin_completion_kind);
CompletionItem {
label: item.label,
label_detail: None,
detail: item.detail,
kind,
documentation: item.documentation.map(|value| Markup { kind: "markdown".into(), value }),
sort_text: item.sort_text,
filter_text: None,
preselect: false,
deprecated: false,
insert_text: item.insert_text,
is_snippet: false,
replace_range: item.replace,
replace_range_tab: None,
additional_edits: Vec::new(),
commit_characters: Vec::new(),
command: None,
data: None,
server: PLUGIN_SERVER,
}
}
/// Inline (ghost text) suggestions at `pos`; `automatic` when triggered by typing.
@@ -30,9 +146,26 @@ pub async fn lsp_inline_completion(
) -> IdeResult<Vec<InlineCompletionItem>> {
let lsp = host.project(window)?.lsp.clone();
let cancel = host.begin_request(request);
let result = lsp.inline_completion(doc, pos, automatic, cancel).await;
let result = lsp.inline_completion(doc, pos, automatic, cancel.clone()).await;
// Plugins: cancelled (cooperatively) when the editor cancels the request.
let plugin = match plugin_providers(host, window, doc, ProviderKind::InlineCompletion) {
Some((plugins, d)) if !matches!(&result, Ok(items) if !items.is_empty()) => {
let cancel = cancel.clone();
plugin_call(move || plugins.provide_inline_completion(&d, pos, &cancel)).await.flatten()
}
_ => None,
};
host.end_request(request);
result
match plugin {
Some((_, s)) => Ok(vec![InlineCompletionItem {
insert_text: s.insert_text,
range: s.replace,
filter_text: None,
command: None,
server: PLUGIN_SERVER,
}]),
None => result,
}
}
pub async fn lsp_resolve_completion(
@@ -63,7 +196,40 @@ pub async fn lsp_signature_help(host: &Host, window: &str, doc: DocId, pos: Pos)
}
pub async fn lsp_goto(host: &Host, window: &str, doc: DocId, pos: Pos, kind: GotoKind) -> IdeResult<Vec<Location>> {
host.project(window)?.lsp.goto(doc, pos, kind).await
let result = host.project(window)?.lsp.goto(doc, pos, kind).await;
if kind != GotoKind::Definition {
return result;
}
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::Definition) else { return result };
let found = plugin_call(move || plugins.provide_definition(&d, pos)).await.unwrap_or_default();
if found.is_empty() {
return result;
}
let mut out = result.unwrap_or_default();
out.extend(found.into_iter().map(|l| Location { path: l.path.into(), range: l.range, uri: None }));
Ok(out)
}
/// Gutter markers from plugins (`line-markers` providers) for `doc`.
pub async fn plugin_line_markers(
host: &Host,
window: &str,
doc: DocId,
) -> IdeResult<Vec<crate::plugins::LineMarkerInfo>> {
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::LineMarkers) else {
return Ok(Vec::new());
};
let markers = plugin_call(move || plugins.provide_line_markers(&d)).await.unwrap_or_default();
Ok(markers
.into_iter()
.map(|(plugin_id, m)| crate::plugins::LineMarkerInfo {
plugin_id,
line: m.line,
icon: m.icon,
tooltip: m.tooltip,
target: m.target.map(|l| Location { path: l.path.into(), range: l.range, uri: None }),
})
.collect())
}
/// Implementations of the symbols at `positions`, one list per position (the
@@ -138,7 +304,48 @@ 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 result = project.lsp.code_actions(doc, range, only.clone()).await;
let plugin_actions = match plugin_providers(host, window, doc, ProviderKind::CodeActions) {
Some((plugins, d)) => plugin_call(move || plugins.provide_code_actions(&d, range)).await.unwrap_or_default(),
None => Vec::new(),
};
let plugin_actions: Vec<CodeAction> = plugin_actions
.into_iter()
.map(|(_, a)| plugin_code_action(a))
.filter(|a| {
only.is_empty()
|| a.kind.as_ref().is_some_and(|k| only.iter().any(|o| k == o || k.starts_with(&format!("{o}."))))
})
.collect();
let actions = if plugin_actions.is_empty() {
result?
} else {
let mut out = result.unwrap_or_default();
out.extend(plugin_actions);
out
};
prepare_actions(project, doc, version, actions).await
}
fn plugin_code_action(a: ide_plugin::CodeAction) -> CodeAction {
let files = a
.edits
.into_iter()
.map(|(path, edits)| ide_core::FileEdit::Edit { path: path.into(), edits, version: None })
.collect();
CodeAction {
title: a.title,
kind: a.kind,
is_preferred: a.preferred,
disabled_reason: None,
edit: Some(WorkspaceEdit { files, preconditions: Vec::new() }),
command: None,
data: None,
server: PLUGIN_SERVER,
}
}
/// Cancellable code actions with the kinds the servers advertise (Refactor This, refactoring availability).
@@ -150,11 +357,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).
@@ -167,19 +378,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?;
@@ -200,11 +420,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(
@@ -279,21 +512,43 @@ pub async fn lsp_restart(host: &Host, window: &str, server: ServerId) -> IdeResu
}
/// 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
@@ -441,7 +696,7 @@ 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,
@@ -465,7 +720,38 @@ pub(crate) fn register(d: &mut Dispatcher) {
)
.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
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_completion_kind_the_plugin_api_documents_is_understood() {
let wit = include_str!("../../../../wit/providers.wit");
let doc: String = wit
.lines()
.map(|l| l.trim().trim_start_matches("///").trim())
.skip_while(|l| !l.starts_with("Icon and sort group:"))
.take_while(|l| !l.starts_with("kind:"))
.collect::<Vec<_>>()
.join(" ");
let list = doc.strip_prefix("Icon and sort group:").unwrap().split(". Unknown").next().unwrap();
let list = list.replace(" or ", ", ");
let kinds: Vec<&str> = list.split(',').map(str::trim).filter(|k| !k.is_empty()).collect();
assert_eq!(kinds.len(), 25, "{kinds:?}");
for k in kinds {
assert!(plugin_completion_kind(k).is_some(), "{k:?}");
}
assert_eq!(plugin_completion_kind("nonsense"), None);
}
}
+5
View File
@@ -7,7 +7,9 @@ pub mod analysis;
pub mod build;
pub mod bytes;
pub mod coverage;
pub mod db;
pub mod debug;
pub mod dependencies;
pub mod deploy;
pub mod doc;
pub mod env;
@@ -21,6 +23,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 +72,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);
@@ -83,6 +87,7 @@ pub fn register_all(d: &mut crate::rpc::Dispatcher) {
run::register(d);
env::register(d);
deploy::register(d);
db::register(d);
}
impl Host {
+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);
}
}
+12 -1
View File
@@ -62,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> {
+44
View File
@@ -4,6 +4,24 @@ use crate::Host;
use crate::rpc::Dispatcher;
use ide_core::{IdeError, IdeResult};
use ide_run::RunConfig;
use ide_run::import::{RunImport, RunImportSource};
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};
@@ -88,6 +106,25 @@ pub fn run_save_templates(host: &Host, window: &str, templates: Vec<RunConfig>)
ide_run::save_templates(&root, &templates)
}
/// Project files run configurations can be imported from (`.vscode/launch.json`).
pub fn run_import_sources(host: &Host, window: &str) -> IdeResult<Vec<RunImportSource>> {
let root = host.project(window)?.root.clone();
Ok(ide_run::import::available(&root))
}
/// Configurations read from `source`, not saved; names already in `existing` (the
/// configurations being edited) are skipped.
pub fn run_import_configs(
host: &Host,
window: &str,
source: RunImportSource,
existing: Vec<RunConfig>,
) -> IdeResult<RunImport> {
host.require_trust(window)?;
let root = host.project(window)?.root.clone();
ide_run::import_configs(&root, source, &existing)
}
/// The environment runs inherit when "Include system environment variables" is on.
pub fn run_system_env(_host: &Host, _window: &str) -> std::collections::BTreeMap<String, String> {
ide_run::system_env()
@@ -206,6 +243,9 @@ 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
});
@@ -220,6 +260,10 @@ pub(crate) fn register(d: &mut Dispatcher) {
});
d.add("run_templates", |c| async move { run_templates(&c.host, &c.window) });
d.add("run_save_templates", |c| async move { run_save_templates(&c.host, &c.window, c.arg("templates")?) });
d.add("run_import_sources", |c| async move { run_import_sources(&c.host, &c.window) });
d.add("run_import_configs", |c| async move {
run_import_configs(&c.host, &c.window, c.arg("source")?, c.arg("existing")?)
});
d.add("run_system_env", |c| async move { Ok(run_system_env(&c.host, &c.window)) });
d.add("run_gradle_jvm_args_script", |c| async move {
run_gradle_jvm_args_script(&c.host, &c.window, c.arg("jvmArgs")?).await
+2 -1
View File
@@ -122,6 +122,7 @@ pub async fn sdk_python_pip(
on_output: Channel<String>,
) -> IdeResult<()> {
host.require_trust(window)?;
let project_root = host.project(window).ok().map(|p| p.root.clone());
let cancel = host.begin_project_request(window, request);
let result = tokio::task::spawn_blocking(move || {
let send = |line: String| {
@@ -130,7 +131,7 @@ pub async fn sdk_python_pip(
if uninstall {
ide_sdk::python::uninstall_package(&python, &requirement, &cancel, &send)
} else {
ide_sdk::python::install_package(&python, &requirement, upgrade, &cancel, &send)
ide_sdk::python::install_package(&python, &requirement, upgrade, project_root.as_deref(), &cancel, &send)
}
})
.await
+48 -1
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,
@@ -25,7 +40,8 @@ pub fn spawn(
.ok()
.filter(|p| p.is_trusted())
.and_then(|p| ide_sdk::apply::terminal_activation(&p.root, &p.settings.effective(), &shell));
let info = host.terminals.spawn(options, host.window_sink(window), Box::new(move |chunk| on_output(chunk)))?;
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"));
}
@@ -47,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());
}
}
+4 -4
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()),
@@ -104,7 +104,7 @@ pub async fn jdk_download_releases(
vendor: String,
request: RequestId,
) -> IdeResult<JdkReleases> {
let tools = Tools::new(&host.data_dir);
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:
@@ -130,7 +130,7 @@ pub async fn jdk_download(
request: RequestId,
on_event: Channel<InstallEvent>,
) -> IdeResult<Sdk> {
let tools = Tools::new(&host.data_dir);
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 || {
+84 -30
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))
})
})
@@ -552,12 +552,13 @@ pub async fn vcs_clone(
request: RequestId,
url: String,
dest: PathBuf,
depth: Option<u32>,
on_output: Channel<String>,
) -> IdeResult<()> {
apply_git_executable(&host.global_settings.effective());
let cancel = host.begin_request(request);
let result = tokio::task::spawn_blocking(move || {
ide_vcs::clone(&url, &dest, &cancel, &|line| {
ide_vcs::clone(&url, &dest, depth, &cancel, &|line| {
let _ = on_output.send(line);
})
})
@@ -729,7 +730,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 +750,12 @@ 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> {
@@ -846,7 +858,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.
@@ -854,16 +866,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
@@ -878,7 +897,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())
})
})
@@ -902,12 +922,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> {
@@ -922,13 +960,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
}
@@ -1026,7 +1071,16 @@ pub(crate) fn register(d: &mut Dispatcher) {
|c| async move { vcs_stage_hunks(&c.host, &c.window, c.arg("path")?, c.arg("hunks")?).await },
);
d.add("vcs_clone", |c| async move {
vcs_clone(&c.host, &c.window, c.arg("request")?, c.arg("url")?, c.arg("dest")?, c.sender("onOutput")?).await
vcs_clone(
&c.host,
&c.window,
c.arg("request")?,
c.arg("url")?,
c.arg("dest")?,
c.arg("depth")?,
c.sender("onOutput")?,
)
.await
});
d.add("vcs_init", |c| async move { vcs_init(&c.host, &c.window, c.arg("dir")?).await });
d.add("vcs_git_version", |c| async move { vcs_git_version(&c.host, &c.window, c.arg("path")?).await });
@@ -1107,7 +1161,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) });
Loaded 100 of 1107 files, more files were not shown because too many files have changed in this diff. Show more