Compare commits

..
Author SHA1 Message Date
l0ng-ai ae14a61eec fix(python): exclude virtual environments from detected roots whatever their name
Root detection only skipped environments named `venv` / `.venv`. A venv with another
name in the project (e.g. `code10`) was walked into, and any package in its
site-packages that ships `pyproject.toml`, `setup.py` or `requirements.txt` became a
source root. Source roots go on PYTHONPATH for runs, so a package directory with an
`io` package shadowed the standard library and Python 3.10 failed at startup with
`init_sys_streams: module 'io' has no attribute 'open'`.

A directory with `pyvenv.cfg` (venv, virtualenv, uv) or `conda-meta` (conda) is now
an excluded root and not searched for modules; creating one re-runs detection.
Project discovery in the workspace folder picker skips such directories too, so
packages inside an environment are not offered as projects.
2026-11-13 12:21:07 +08:00
l0ng-ai c905f1428e fix(maven): go from a dependency, plugin or parent to its own POM (#3057)
Go to Declaration in pom.xml found nothing on a dependency that names its own
version, on a <dependencyManagement> entry (a BOM import, say) or on <parent>.
They now open the artifact's POM: the project module with those coordinates, else
the local repository's POM of that version (properties resolved). A dependency
whose version a management entry provides still goes to that entry.
2026-11-13 12:10:46 +08:00
l0ng-ai a8036ce6fa feat(plugins): open two texts in the diff viewer (API 1.7) (#3055)
Plugins could show their own content in an editor tab (open-content-tab) but
could open the diff viewer only as the reply to one of that tab's actions. A
review plugin (a merge request's files, say) had no way to show a file's
before and after.

views.open-diff opens two texts in the invoking window's diff viewer, with
optional labels and title and a file name that picks the syntax highlighting;
web views send { op: "open-diff", ... }. The SDK wraps it as host::open_diff
with a mock. The plugin API moves to 1.7.0 (1.6.0 is released) and the checked-in
components are rebuilt.
2026-11-13 12:02:36 +08:00
l0ng-ai 28829ad756 style(vcs): format inline blame (#3051) 2026-11-13 11:22:44 +08:00
l0ng-ai d6c562049f test(e2e): load the app modules of the Branches resize spec through variables (#3050)
knip resolved the literal Vite paths in page.evaluate as imports and failed on them; the other specs pass the module path through a variable.
2026-11-13 11:21:04 +08:00
l0ng-ai 100075166c test(database): let FSEvents deliver the startup events before checking ignored files (#3048)
On macOS, FSEvents can report the creation of .openidea/ made just before the watch started after the watch is running, so ignores_other_files counted one change and failed on CI. The test now waits for those events and compares against the count after them.
2026-11-13 11:18:40 +08:00
l0ng-ai d5332a2197 feat(ui): show Go files with a gopher icon (#3047)
Go files get the gopher icon, as GoLand shows them, instead of a "GO" letter tile; go.mod and go.work keep theirs.
2026-11-13 11:16:32 +08:00
arookieofcandarookieofc b0d20d7997 feat(vcs): Backend trait for the Changes-view surface (#2958)
Adds an ide-vcs Backend trait for what the Commit / Local Changes view, diff viewer, annotate and log need from a repository (status, file_at / file_mode_at, diffs, line hunks, blame, block history, log, commit files, revision diffs, stage / unstage / discard, commit, pending changes), with Repo implementing it by forwarding. Git-only concepts (branches, remotes, stash, rebase, worktrees) stay on Repo, reached through as_git.

The trait has default no-op hooks for IDE file operations (files_added, files_deleted, file_moved), which the host now calls after create, copy, delete and rename so a checkout-based backend can open files for add / delete / move. The Changes-surface call sites in ide-host go through &dyn Backend. No behaviour change for Git.

Co-authored-by: arookieofc <[email protected]>
2026-11-13 11:09:57 +08:00
rohitkumardandaandrohitdanda 8df59cf5a7 fix(remote): decide routing, trust and accepted events on the desktop (#2999)
In a remote window the desktop took several decisions about itself from the server: it routed commands by the server's `commands` reply (a command the server left out ran locally), authorized its own commands from the server's `project_current().trusted`, and showed or ran whatever events the server forwarded, including any workbench action named by a plugin `executeCommand` event.

- Routing: commands go by `rpc::client_commands()`, the list a server of this build serves, compiled into the desktop; a test builds the server's dispatcher and compares the two.
- Trust: commands that run on the desktop follow the desktop's own decision, stored in `remote-project-trust.json` by target and root (a dev container also follows its local folder). The trust prompt appears unless the desktop's and the server's decisions agree; the answer and Revoke Trust update both. New commands `remote_trust_status` / `remote_trust_set`.
- Events: server `event`s pass an exhaustive allow-list (MCP approvals, update notices and window-group events are dropped); plugin `executeCommand` may only name `plugin.<id>.<command>`; `openProject` is dropped.
- Paths: a project root that the desktop would read as a share or device path is refused at connect.
- A remote plugin's `openUrl` and `copyToClipboard` ask first, showing the full address or the text.

Co-authored-by: rohitdanda <[email protected]>
2026-11-13 11:05:12 +08:00
Binandffgenius 3c68d2ea7f fix(vcs): position commit history above message input (#3040)
Commit Message History opened below its button, over the message field, and the menu reserved an empty icon column. It now opens above the Amend bar, aligned with the message's left edge, and Menu takes iconColumn={false} to leave out the icon column for rows without icons.

Co-authored-by: ffgenius <+[email protected]>
2026-11-13 10:54:03 +08:00
l0ng-ai cfe1416dbd fix(index): keep the symbol index across releases that leave extraction unchanged (#3027)
The persistent symbol index was reset whenever Syntax::fingerprint() changed, and the fingerprint included CARGO_PKG_VERSION, so every release reparsed every file on first open.

The fingerprint is now a build-time hash of what decides extraction: ide-language's src/ and queries/, languages/*.toml, and the locked tree-sitter crates. The index stamp also carries a build-time hash of ide-index's own sources (ignored state, size limit, stored record), so a change there still resets the index without a manual SCHEMA bump.
2026-11-13 10:47:24 +08:00
l0ng-ai 44fc0a2ebc fix(java): build with Maven when JDT's annotation processing recreates a file (#3043)
With MapStruct-Plus, JDT runs MapStruct again over the mappers MapStruct-Plus
generated in the same build and fails with "FilerException: Source file already
created", so every run of the application stopped on that error. javac doesn't,
so a Maven module whose JDT build fails this way is now built with Maven, and a
launch already built with Maven ignores that JDT error instead of reporting the
build as failing.
2026-11-13 10:44:29 +08:00
l0ng-ai e52cb2e426 fix(agents): keep the conversation at its end after a send or an opened section (#2975)
After a send the composer shrank and the conversation view grew; the resulting scroll was taken for a user scroll, so following the end stopped and the reply arrived below the view. A change of the view height now counts as layout. Opening a folded section (reasoning, tool call, turn) keeps the view at the end when the end was in view, else scrolls the section into view.
2026-11-13 10:12:27 +08:00
l0ng-ai 519e90da9f fix(plugins): list the official marketplace in debug builds too (#2977)
Debug builds set no default marketplace index, so the Plugins page showed only the repository's local plugins and browsing, installs, update and revocation checks couldn't be exercised without entering the index URL. The official index is now the default in every build; local plugins keep their own group.
2026-11-13 10:12:18 +08:00
Deng Liuanddengliu 9e73877f5e fix(plugins): raise the plugin HTTP response body limit to 16 MiB (#3033)
PluginApi::fetch capped response bodies at 8 MiB after reqwest's transparent decompression, so compact gzip API responses (a Kubernetes Table listing of a large cluster) failed once decompressed. The limit is now 16 MiB, the same as process output and file reads, and the error message follows the constant. Plugin network docs updated.

Co-authored-by: dengliu <[email protected]>
2026-11-13 10:12:10 +08:00
l0ng-ai 94a8761cc9 feat(editor): add Fix ESLint Problems, and let ESLint fix-all fix every rule (#3038)
Fix ESLint Problems runs vscode-eslint's source.fixAll.eslint on the whole file; the editor context menu offers it for files a running ESLint server lints, and it can be bound in the keymap.

The ESLint server settings sent codeActionOnSave.rules = [], which vscode-eslint reads as "fix no rule", so its fix-all (also from Code Cleanup and Actions on Save) always returned an empty edit. The key is left out now, so every configured rule is fixed.
2026-11-13 10:07:15 +08:00
l0ng-ai e66513668a fix(run): run JAR Applications on the project JDK by default (#3037)
A JAR Application without a chosen JRE ran java from PATH even with a Project JDK set. Its default JRE is now the project JDK, applied with the other toolchains (an explicit JRE stays), and the resolved path is left out when saving so it follows later changes. The JRE field's default reads "Default (the project's JDK)".
2026-11-13 10:06:31 +08:00
SunZhiQiang 565bdf170f feat(agents): pin tasks and arrange them in groups (#3016)
Tasks in the Agents tool window can be pinned above every group, and put in groups of your own from the context menu or by dropping them on a group header. A group's menu renames or removes it; removing it returns its tasks to their status groups. Pinned tasks keep their group, so unpinning returns them to it. Groups and pins are kept per project in localStorage.

Co-authored-by: panda-student <[email protected]>
2026-11-13 10:04:58 +08:00
SunZhiQiang 5712a5373d feat(agents): show a task's project, checkout and branch as chips (#3020)
The session status line packed provider, project, checkout, branch and state into one string, while the New Task composer showed them as separate controls. Both now use one Chip primitive (also a chip variant of Select), so project, checkout and branch read as separate facts, and the project is named for a plain project too. The changed-files and commits buttons move to the right of the status line, above the context meter, time and terminal.

Co-authored-by: panda-student <[email protected]>
2026-11-13 10:04:50 +08:00
SunZhiQiang 2a981dd642 fix(agents): centre Jump to Latest over the conversation (#3022)
Jump to Latest sat against the panel's right edge, so in a wide editor tab it floated far from the centred conversation. It is now centred over the end of the stream, like the content. The conversation keeps its readable width.

Co-authored-by: panda-student <[email protected]>
2026-11-13 10:04:43 +08:00
SunZhiQiang 3580cbacd7 feat(agents): show the context window's size in the usage meter (#3021)
The composer's context meter read only "12%". It now reads "24k/200k (12%)", so the window's size is visible at a glance. The bar fills by the percentage rather than using the label as a CSS width, and the tooltip keeps the exact counts.

Co-authored-by: panda-student <[email protected]>
2026-11-13 10:04:36 +08:00
l0ng-ai f91b87d790 feat: move configuration, user data and logs with environment variables (#3035)
OPENIDEA_CONFIG_DIR, OPENIDEA_DATA_DIR and OPENIDEA_LOG_DIR take an absolute path, like OPENIDEA_LOCAL_DATA_DIR, and the IDE uses <path>/<identifier> under it. Every place that resolved these directories now goes through ide_host::data_dirs (app state, invalidate caches, window zoom and window state, the MCP stdio bridge, logging, the openidea-plugin CLI), and the frontend takes the user data directory from app_info instead of Tauri's appDataDir. A relative path for the configuration or user data shows an error and exits.

Closes #2814
2026-11-13 09:53:08 +08:00
Mr0Bread 74abf32704 feat(database): add a context menu to query result tabs (#3012)
Database result tabs get Close, Close Others, Close All, Rename Tab, Select Next Tab and Select Previous Tab from the right-click menu or Shift+F10. They are actions, so they can also be bound in the keymap. Close, Close Others and Rename act on the clicked tab without selecting it, which keeps its hover look while the menu is open (showContextMenu takes an optional owner element). Navigation starts from the active tab and wraps through Output. Output, consoles and connections are never closed, and a renamed Query Plan keeps its title when the plan is replaced.

Blocking prompts (promptInput, chooseItem, pickItems, message dialogs) now suspend workbench shortcuts while open, so typing in Rename Tab can't edit the query console behind it. In plain text fields, Backspace/Delete with a modifier stays native text deletion.

Co-authored-by: Mr0Bread <[email protected]>
2026-11-13 09:37:38 +08:00
Muuandmuutot 4b1370742f feat(editor): show the date of the last change in the code vision author hint (#2918)
The code vision author hint showed only the name (`Thomas +2`). It now adds the leading author's last change date in the same compact form as the blame gutter (the Date Formats pattern when the override is on, the system's short date otherwise), and the tooltip gives the full date. Blame without a timestamp keeps the old label.

The line-end ("right") hint is drawn from a zero-width anchor with an absolutely positioned inner box, so a soft-wrapped line can't push it to the next row or grow the line height. It uses the Inline hints colour and the editor size minus 1. Inline blame steps past a hint, including one that appears after the blame. A lens is rebuilt when its position changes in an open editor.

Co-authored-by: muutot <[email protected]>
2026-11-13 09:31:42 +08:00
l0ng-ai 6700f57f79 fix(database): connect to MySQL servers that advertise DEPRECATE_EOF but send EOF packets (#3030)
Some servers and proxies (Aliyun RDS for MySQL 5.6) advertise CLIENT_DEPRECATE_EOF but still end result sets with legacy EOF packets; sqlx requested every advertised capability, so every query failed with "OK_Packet too short". sqlx now comes from l0ng-ai/sqlx at a rev adding MySqlConnectOptions::deprecate_eof (proposed upstream as transact-rs/sqlx#4524), and opening a MySQL pool retries once without the capability when the first connection fails with that error.

Fixes #1053
2026-11-13 09:27:41 +08:00
Mamaev Igor 578219de3b feat(editor): add relative and hybrid line numbers (#3015)
Settings › Editor › General › Appearance › Show line numbers gets a mode: Absolute (default), Relative (distance from the caret line, 0 on it) or Hybrid (the caret line keeps its own number). The relative modes use a gutter that keeps the cm-lineNumbers class, so styling, sticky lines and line-number clicks treat it like the absolute one; it re-renders when the caret moves to another line and when lines are inserted or removed above it.

Closes #3014

Co-authored-by: pxc1984 <[email protected]>
2026-11-13 09:23:58 +08:00
Mr0Bread afcdf57eb6 fix(database): inset result tabs from the Services pane edges (#3013)
The database Results view uses the unpadded compact tab strip, so the selected Output tab touched the Services pane edge. The Results strip now has a --space-2 inline inset of its own; other compact strips are unchanged.

Co-authored-by: Mr0Bread <[email protected]>
2026-11-13 09:23:52 +08:00
Mr0Bread f145ea5d7b fix(database): select the neighbouring result when closing the active tab (#3017)
Closing the active database result selected the rightmost remaining result, jumping away from where the user was. It now selects the left neighbour, then the right one, and Output when none remain. Closing an inactive or missing result keeps the current selection.

Co-authored-by: Mr0Bread <[email protected]>
2026-11-13 09:22:27 +08:00
Alexanderandformiat 0c00f474e7 feat(vcs): resize Git Branches by its edges and corner (#3025)
Git Branches had a fixed size (380 px, 440 px with several repositories, at most 440 px of list), so long branch names were cut off and only about a dozen branches fit. Its right edge, bottom edge and bottom-right corner now resize it within a minimum size and the window. The popup keeps its anchored top-left corner, the branch list fills the new height, and the last size is kept in localStorage and used on the next open.

Popup gains resizable, minWidth, minHeight, initialSize and onResized, with the same meaning as on Dialog, so other anchored popups can opt in later. The e2e test measures the popup after its open animation.

Co-authored-by: formiat <[email protected]>
2026-11-13 09:20:02 +08:00
Gabriel Gondim 5cf01f682f fix(editor): keep server code lenses mapped across edits (#3011)
Language-server and plugin code lenses were cleared on every document change and only came back after the 400 ms refetch. Their block rows disappearing and reappearing changed the document height on each keystroke, so the viewport jumped while typing with servers that publish lenses (e.g. csharp-ls). Lens positions are now mapped through edits (MapMode.TrackDel), the same way usages lenses already are, until the next fetch replaces them.

Co-authored-by: GGVidal <[email protected]>
2026-11-13 09:14:36 +08:00
SunZhiQiang fec4eb34b9 fix(agents): keep worktree tasks refreshing on project VCS events (#2988)
#2979 matched vcsChanged events against the task's cwd. A worktree task's gitdir is <project>/.git/worktrees/<slug>, outside its checkout, so the watcher reports the project root when the agent commits there and the Changes pane stopped refreshing. Worktree tasks now match their project root; local tasks still match only their own checkout.

Co-authored-by: panda-student <[email protected]>
2026-11-13 09:13:58 +08:00
l0ng-ai 6454dbe169 fix(i18n): restore the commas between merged locale entries (#3029)
#3001 and #2987 each appended entries at the end of en.json and zh.json; merged together, the line between them lost its trailing comma, so neither file parsed.
2026-11-13 09:13:13 +08:00
Gabriel Gondim 8bdf623a28 fix(search): disable ligatures in Find results unless Enable ligatures is on (#3010)
Mono previews outside the editor (Find in Files results, Usages, ...) inherited the browser's default ligatures, so `!==` and `=>` rendered as ligatures while the editor, with `editor.ligatures` off by default, showed plain text. The root now disables ligatures and `data-ligatures`, set from Editor > Font > Enable ligatures, turns them back on for every mono surface, matching the editor.

Co-authored-by: GGVidal <[email protected]>
2026-11-13 09:13:03 +08:00
Muuandarookieofc bff6a12e2f feat(python): manage every interpreter from a Python settings folder (#2987)
* feat(python): manage every interpreter from a Python settings folder

Settings › Languages & Frameworks now groups Python settings in one folder: All Interpreters (new), Dependencies (the project interpreter and its packages), Docstrings (split out of the interpreter page) and Ruff. All Interpreters lists interpreters detected on the machine, the ones other recently opened projects use and the ones added there, with a search field, PyCharm's "hide interpreters of other projects" and "show interpreter paths" toggles, and + / − to add an interpreter or remove it from the list (hidden, never deleted; a removed project interpreter falls back to the project virtualenv).

Each interpreter's source has its own icon (virtualenv, conda, uv, Poetry, pyenv/mise/asdf, system) in the list, the Add Interpreter dialog, the interpreter combo and the status bar menu. The IDE-wide list and its view options are global settings that a project cannot override; removed interpreters also disappear from the interpreter combo and the status bar popup.

* fix(python): compare interpreter paths by their own case rules and cap probes

A POSIX interpreter path kept only its own case (Linux allows `/opt/Python` and
`/opt/python` side by side); only Windows drive/UNC paths are folded now, and the
status bar uses the same key as the list. A configured `~/…` interpreter is matched
against the absolute path the backend resolves it to, so the "Project interpreter"
badge and removing it in use keep working without the frontend knowing the home
directory.

`sdk_python_project_interpreters` probes at most four recent projects at a time
instead of one thread per project, so opening All Interpreters does not start ~30
pythons at once. The hidden-but-in-use interpreter stays selectable in the combo and
status popup (custom entry / current fallback); that is now documented on `exclude`.

---------

Co-authored-by: arookieofc <[email protected]>
2026-11-13 08:56:17 +08:00
rohitkumardandaandrohitdanda ee1f15eba7 feat(agents): choose whether new tasks start asking for approval (#3001)
Once an agent reports its modes, new tasks start in the mode that approves everything (Claude Code's bypassPermissions, Codex's full-access, which also turns off the sandbox), where the Command Execution Policy never applies. Settings › Agents › Chat › New tasks start (agents.newTaskApprovals: bypass or ask) chooses; the default keeps today's behaviour. With ask, new tasks start in a mode that asks: the agent's reported mode when it isn't a bypass mode, otherwise its default/ask mode, with allow-all permissions off and Cursor approvals set to ask. The setting is user-only (in the settings layer's USER_ONLY list): project files can't set it.

Co-authored-by: rohitdanda <[email protected]>
2026-11-13 08:55:46 +08:00
l0ng-ai 7ea4f36b6d fix(windows): don't show system error boxes for processes that fail to load (#2550)
Probing a broken Python install (for example one whose python3xx.dll is gone) blocked on a modal "System Error" dialog, because Windows shows critical-error boxes for any process that fails to load a DLL. The app now sets SEM_FAILCRITICALERRORS and SEM_NOOPENFILEERRORBOX at startup, keeping inherited bits, and children inherit the mode, so such tools exit with an error code instead. Covers interpreter detection, Run, Terminal and every other tool the desktop app starts.
2026-11-13 08:53:28 +08:00
Bruno Belloni ddfdbf1be6 fix(linux): keep the DMA-BUF renderer on Wayland with NVIDIA (#2179)
On Wayland with the NVIDIA driver WebKitGTK's DMA-BUF renderer fails with Error 71 unless something works around it. Turning the renderer off made WebKit paint on the CPU, so dragging a tool window stalled the UI for seconds. Setting __NV_DISABLE_EXPLICIT_SYNC=1 for the process avoids the protocol error and keeps GPU rendering. A user who already set either variable keeps their own setting; WEBKIT_DISABLE_DMABUF_RENDERER=1 still restores the old behaviour.

Co-authored-by: brunobelloni <[email protected]>
2026-11-13 08:49:21 +08:00
rohitkumardandaandrohitdanda da412f14be fix(agents): keep agent permissions out of project settings (#3000)
A project's .openidea/settings.toml could loosen what coding agents do without asking: auto-approve commands, empty the blocked list, pick the mode new tasks start in, and add MCP servers every agent session starts. Project settings can no longer set agentSecurity.allowedCommands or agents.defaults (user settings only, like mcpServer), and a project's agentSecurity.blockedCommands adds to the user's list instead of replacing it. Workspace MCP servers start only once the user has turned that exact definition on in Settings › Agents › External MCP Servers; servers saved there count as turned on, servers imported or changed in the file stay off until turned on. The Command Execution Policy page drops its Global / This project switch.

Co-authored-by: rohitdanda <[email protected]>
2026-11-13 08:48:40 +08:00
l0ng-ai 104986ead4 fix(ci): build the first nightly when no nightly tag exists yet (#2747)
Without a nightly tag the compare call returns 404 and its error output was appended to the status instead of replacing it, so the scheduled nightly never built. The fallback now replaces the output.
2026-11-13 08:48:14 +08:00
rohitkumardandaandrohitdanda fdeaa31b7d fix(agents): keep agents' environment values in the credential store (#3003)
Environment variables set for an agent (built-in, custom or imported from acp.json), typically API keys, were written as plain text into <config>/agents/<id>.toml. Their values now go to the OS credential store like the other saved passwords; the agent file keeps only the names. Values already in agent files move there the next time they are read. Without a credential store (a headless Linux host) a value is kept in an owner-only file in the user's data and the user is told.

Co-authored-by: rohitdanda <[email protected]>
2026-11-13 08:42:06 +08:00
rohitkumardandaandrohitdanda 4d5c640451 fix(plugins): judge sandbox paths from their text before opening them (#3007)
The plugin sandbox resolved a requested path, which on Windows opens it, before checking whether it was allowed, so a network path (UNC or verbatim UNC) or a pipe or device path (\\.\pipe, GLOBALROOT) was opened and connected to before being refused. Every resolver now judges the path from its text first: devices, pipes, NTFS streams and reserved device names are always refused on Windows, a network path is resolved only under the allowed root or grant, and local paths go on to the existing real-path checks. workspace.project-of uses the same check.

Co-authored-by: rohitdanda <[email protected]>
2026-11-13 08:38:47 +08:00
rohitkumardandaandrohitdanda 3e40295668 fix(agents): keep worktree Accept out of git's own files and links (#3004)
Accept in a worktree task copied every changed path into the local checkout without checking where it lands, so a path could reach the checkout's .git through a name that opens it (8.3 short name GIT~1, a case variant, trailing dots or spaces, an NTFS stream suffix) or go through a symlink or junction on either side. Accept now refuses, and lists as skipped, any path that names git metadata, leaves the tree once links are followed, lands in the repository's git dir or common dir, or is a link itself, on both the worktree and the checkout side. The .git-name and real-path helpers the plugin sandbox used move to ide_core::real_path so both share one implementation.

Co-authored-by: rohitdanda <[email protected]>
2026-11-13 08:36:29 +08:00
rohitkumardandaandrohitdanda b43db63ff3 fix(agents): judge permission requests by their kind and command, not their title (#3006)
The Command Execution Policy and the permission card treated any request titled `Run …` as a shell command, but the title is free text the agent writes: a Codex request for wider sandbox access titled `Run git status` was approved by an allow entry `git status`. Only a tool call of kind `execute` now counts as a command, and only its structured `rawInput.command` is evaluated and shown; other requests ask with their own title. A command approval that also grants network access can still be blocked but is never auto-approved. Permission entries carry the command, so the card no longer parses the title; the native Codex driver puts the resolved command and any network context into rawInput.

Co-authored-by: rohitdanda <[email protected]>
2026-11-13 08:33:12 +08:00
rohitkumardandaandrohitdanda 4dbb6920c8 fix(agents): auto-approve only commands every shell reads alike (#3005)
The Command Execution Policy split and checked commands with POSIX rules, but agents also run commands in PowerShell and cmd.exe, so `git status \; calc.exe` or `git status '&' calc.exe` passed as an allowed `git status` while the shell ran two commands. A sub-command is now auto-approved only when every shell reads it the same way (no `\\` outside a path, no separator or redirection outside double quotes, no `^` before a quote, no `%`, carriage return or typographic quote); otherwise the policy asks. Blocked patterns are also compared ignoring case, repeated spaces and the program's folder or .exe.

Co-authored-by: rohitdanda <[email protected]>
2026-11-13 08:31:35 +08:00
Vadim LazukandInf1mum 10603b953b feat(maven): complete groupId, artifactId and version of pom.xml dependencies (#2995)
Inside a <dependency>'s <groupId>, <artifactId> or <version> in pom.xml the editor suggests the coordinates installed in the local repository (Settings › Maven's, else ~/.m2/repository) and, behind them, Maven Central's: the search endpoint's groups and artifacts and the artifact's published versions, cached for the session.

The local repository answers at once; Central gets one second and otherwise finishes in the background for the next request, so an unreachable Central never holds completion. Suggestions keep their order (installed first, newest versions first), and a version counts as installed only with its .pom or a .jar, not the .lastUpdated files a failed download leaves.

Fixes #2968

Co-authored-by: Inf1mum <[email protected]>
2026-11-13 08:27:24 +08:00
rohitkumardandaandrohitdanda 59a0a86257 fix(ci): satisfy clippy 1.100's needless_bool in the Windows build (#3018)
Rust 1.100's clippy flags needless_bool when a #[cfg(unix)] block disappears on Windows and leaves an early `return false` followed by a literal. The three sites (ide-core tool_path, ide-extension ledger, ide-host mcp/edit) are rewritten without early returns; behaviour is unchanged.

Co-authored-by: rohitdanda <[email protected]>
2026-11-13 08:14:53 +08:00
l0ng-ai f05066b18f fix(java): open the Kotlin source for a Kotlin class used from Java (#3026)
Java code sees the project's Kotlin classes through class folders on JDT's classpath, so Go to Declaration on a Kotlin class from a Java file returned the folder's class and opened the decompiled view.

A goto result whose element handle names a class in a class folder (never a dependency jar or JDK module) now opens the project's source of that class: the .kt / .java file named after it (or after a FileKt facade, or the outer class) that declares its package, preferring the directory matching the package and skipping build output. The name under the caret selects the declaration in it.

Fixes #2969
2026-11-13 08:11:38 +08:00
arookieofc 4c15fe5a23 test(e2e): take the first 'Plain PHP documentation' token (#2994)
The locator resolves to two leaf spans — the comment tokenizes as
multiple spans — so the strict-mode expect flakes. Every other
multi-match token in the spec already uses .first().
2026-11-13 03:33:18 +08:00
arookieofc 8f6a4b5b39 style: format the plugin settings test (#2980 merged with a format slip) (#2992)
oxfmt --check fails on main, failing the Frontend job for every PR.
2026-11-13 02:30:22 +08:00
l0ng-ai 4f9dc0a56c fix(vue): go to the ref and its component's members from this.$refs (#2989)
Vue 2.7 types `$refs` as an index signature, so the TypeScript service sends `this.$refs.child` to that signature in vue.d.ts and finds nothing for `this.$refs.child.focusIt`.

Go to Declaration in a Vue SFC's script now reads the template: `this.$refs.<name>` goes to the `ref="<name>"` attribute, and a member read from a component's ref goes to that member in the component's options (methods, computed, props, data() / setup() return). The component comes from the default import its tag names; relative paths resolve directly, aliases through the language service, then `@/` as `src/`.

Fixes #2940
2026-11-12 23:37:26 +08:00
410 changed files with 11121 additions and 1241 deletions

No files matched your search

+1 -1
View File
@@ -47,7 +47,7 @@ jobs:
exit 0
fi
# No `nightly` tag yet (the first run): compare fails and the build goes on.
status="$(gh api "repos/$GH_REPO/compare/nightly...$sha" --jq .status 2>/dev/null || echo ahead)"
status="$(gh api "repos/$GH_REPO/compare/nightly...$sha" --jq .status 2>/dev/null)" || status=ahead
if [ "$status" != ahead ] && [ "$FORCE" != true ]; then
echo "::notice::$sha is not newer than the last nightly"
exit 0
Generated
+7 -7
View File
@@ -8727,7 +8727,7 @@ checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620"
[[package]]
name = "sqlx"
version = "0.9.0"
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
source = "git+https://github.com/l0ng-ai/sqlx?rev=9894985a1babc7e6bc0437da4fd43e3143184073#9894985a1babc7e6bc0437da4fd43e3143184073"
dependencies = [
"sqlx-core",
"sqlx-macros",
@@ -8739,7 +8739,7 @@ dependencies = [
[[package]]
name = "sqlx-core"
version = "0.9.0"
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
source = "git+https://github.com/l0ng-ai/sqlx?rev=9894985a1babc7e6bc0437da4fd43e3143184073#9894985a1babc7e6bc0437da4fd43e3143184073"
dependencies = [
"base64 0.22.1",
"bytes",
@@ -8774,7 +8774,7 @@ dependencies = [
[[package]]
name = "sqlx-macros"
version = "0.9.0"
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
source = "git+https://github.com/l0ng-ai/sqlx?rev=9894985a1babc7e6bc0437da4fd43e3143184073#9894985a1babc7e6bc0437da4fd43e3143184073"
dependencies = [
"proc-macro2",
"quote",
@@ -8786,7 +8786,7 @@ dependencies = [
[[package]]
name = "sqlx-macros-core"
version = "0.9.0"
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
source = "git+https://github.com/l0ng-ai/sqlx?rev=9894985a1babc7e6bc0437da4fd43e3143184073#9894985a1babc7e6bc0437da4fd43e3143184073"
dependencies = [
"cfg-if",
"dotenvy",
@@ -8810,7 +8810,7 @@ dependencies = [
[[package]]
name = "sqlx-mysql"
version = "0.9.0"
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
source = "git+https://github.com/l0ng-ai/sqlx?rev=9894985a1babc7e6bc0437da4fd43e3143184073#9894985a1babc7e6bc0437da4fd43e3143184073"
dependencies = [
"bitflags 2.13.2",
"byteorder",
@@ -8835,7 +8835,7 @@ dependencies = [
[[package]]
name = "sqlx-postgres"
version = "0.9.0"
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
source = "git+https://github.com/l0ng-ai/sqlx?rev=9894985a1babc7e6bc0437da4fd43e3143184073#9894985a1babc7e6bc0437da4fd43e3143184073"
dependencies = [
"atoi",
"base64 0.22.1",
@@ -8869,7 +8869,7 @@ dependencies = [
[[package]]
name = "sqlx-sqlite"
version = "0.9.0"
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
source = "git+https://github.com/l0ng-ai/sqlx?rev=9894985a1babc7e6bc0437da4fd43e3143184073#9894985a1babc7e6bc0437da4fd43e3143184073"
dependencies = [
"atoi",
"flume",
+1 -1
View File
@@ -111,7 +111,7 @@ reqwest = { version = "0.13", default-features = false, features = ["rustls-no-p
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] }
rustls-platform-verifier = "0.7"
# Until SQLx releases its libsqlite3-sys 0.39 update (RUSTSEC-2026-0459).
sqlx = { version = "0.9", git = "https://github.com/transact-rs/sqlx", rev = "10462fa4414c6c7f1ed1d39bb31678e3b726878c", default-features = false, features = ["runtime-tokio", "tls-rustls-ring-webpki", "sqlite", "postgres", "mysql"] }
sqlx = { version = "0.9", git = "https://github.com/l0ng-ai/sqlx", rev = "9894985a1babc7e6bc0437da4fd43e3143184073", default-features = false, features = ["runtime-tokio", "tls-rustls-ring-webpki", "sqlite", "postgres", "mysql"] }
tiberius = { version = "0.13", default-features = false, features = ["tds73", "rustls", "sql-browser-tokio"] }
# Pure-Rust thin Oracle driver; pinned to the beta until it stabilizes.
oracledb = "=26.0.0-beta.5"
+1
View File
@@ -107,6 +107,7 @@ fn manager(dir: &Path, agent: Arc<FakeV2Agent>) -> AgentManager {
models_file: dir.join("models.json"),
custom_agents_dir: None,
home_dir: None,
env_secrets: None,
},
tokio::runtime::Handle::current(),
connector,
+1
View File
@@ -154,6 +154,7 @@ async fn discovery_reads_effort_and_fast_from_session_new_then_stops_the_agent()
models_file: tmp.path().join("models.json"),
custom_agents_dir: None,
home_dir: None,
env_secrets: None,
},
tokio::runtime::Handle::current(),
connector,
+62 -3
View File
@@ -42,9 +42,38 @@ pub(crate) fn bypass_approvals(config: &[AgentConfigOption]) -> bool {
let selected = |category: &str| {
config.iter().find(|o| o.category.as_deref() == Some(category)).and_then(|o| o.current.as_deref())
};
selected("mode").is_some_and(is_bypass_mode)
|| selected("permissions").is_some_and(|v| matches!(v.to_ascii_lowercase().as_str(), "on" | "true"))
|| selected("approval").is_some_and(|v| v.eq_ignore_ascii_case("bypass"))
["mode", "permissions", "approval"]
.into_iter()
.any(|category| selected(category).zip(bypass_value(category)).is_some_and(|(value, bypass)| bypass(value)))
}
/// For an option of `category` that can approve everything, what tells its approving values.
fn bypass_value(category: &str) -> Option<fn(&str) -> bool> {
match category {
"mode" => Some(is_bypass_mode),
"permissions" => Some(|v| matches!(v.to_ascii_lowercase().as_str(), "on" | "true")),
"approval" => Some(|v| v.eq_ignore_ascii_case("bypass")),
_ => None,
}
}
/// Moves options that approve everything without asking to a value that asks: the agent's
/// default or ask mode, else its first one that asks.
pub(crate) fn ask_for_approvals(config: &mut [AgentConfigOption]) {
for option in config {
let Some(bypass) = option.category.as_deref().and_then(bypass_value) else { continue };
if option.current.as_deref().is_some_and(|c| !bypass(c)) {
continue;
}
let asks = |v: &&AgentConfigValue| !bypass(&v.value);
let value = ["default", "ask"]
.iter()
.find_map(|preferred| option.values.iter().filter(asks).find(|v| v.value == *preferred))
.or_else(|| option.values.iter().find(asks));
if let Some(value) = value {
option.current = Some(value.value.clone());
}
}
}
/// The mode that approves everything without asking (`bypassPermissions`, `yolo`, `full-access`…), if the agent has one.
@@ -341,6 +370,36 @@ mod tests {
assert!(bypass_approvals(&url_mode));
}
#[test]
fn asking_for_approvals_keeps_a_mode_that_asks_and_leaves_one_that_does_not() {
let mode = |current: &str, values: &[&str]| {
options_of(&json!({"modes": {"currentModeId": current,
"availableModes": values.iter().map(|v| json!({"id": v})).collect::<Vec<_>>()}}))
};
let codex = ["read-only", "ask", "auto", "full-access"];
for (current, expected) in [("auto", "auto"), ("read-only", "read-only"), ("full-access", "ask")] {
let mut c = mode(current, &codex);
ask_for_approvals(&mut c);
assert_eq!(c[0].current.as_deref(), Some(expected), "{current}");
assert!(!bypass_approvals(&c));
}
let mut claude = mode("bypassPermissions", &["acceptEdits", "default", "plan", "bypassPermissions"]);
ask_for_approvals(&mut claude);
assert_eq!(claude[0].current.as_deref(), Some("default"));
let mut other = mode("yolo", &["yolo", "code", "architect"]);
ask_for_approvals(&mut other);
assert_eq!(other[0].current.as_deref(), Some("code"));
let mut c = options_of(&json!({"configOptions": [
{"id": "allow_all", "category": "permissions", "type": "select", "currentValue": "on",
"options": [{"value": "on"}, {"value": "off"}]},
{"id": "approval", "category": "approval", "type": "select", "currentValue": "bypass",
"options": [{"value": "bypass"}, {"value": "ask"}]}
]}));
ask_for_approvals(&mut c);
assert_eq!(ids(&c), [("allow_all", Some("off")), ("approval", Some("ask"))]);
}
#[test]
fn effort_and_fast_categories_are_inferred_from_ids() {
let c = options_of(&json!({
+61 -14
View File
@@ -43,6 +43,9 @@ pub type AgentSink = Arc<dyn Fn(AgentEvent) + Send + Sync>;
/// Returns the active command execution security policy for the project.
pub type SecurityHook = Arc<dyn Fn() -> ide_core::settings::AgentSecuritySettings + Send + Sync>;
/// Returns the user's choice of the approvals new tasks start with.
pub type NewTaskApprovalsHook = Arc<dyn Fn() -> ide_core::settings::NewTaskApprovals + Send + Sync>;
/// Entry changes are batched for this long before they are sent.
const FLUSH_DELAY: Duration = Duration::from_millis(40);
/// Updates that arrive this late after `session/load` returns are still replayed history.
@@ -89,6 +92,18 @@ pub struct AgentPaths {
pub custom_agents_dir: Option<PathBuf>,
/// The user's home: its `.claude` holds their own skills and commands.
pub home_dir: Option<PathBuf>,
/// Where the values of the agent files' environment variables are kept; without it they stay in the files.
pub env_secrets: Option<Arc<dyn EnvSecrets>>,
}
/// Keeps the values of agent files' environment variables (`agent/<id>/env/<name>`) out of
/// the files, which keep only the names; the app keeps them in the OS credential store.
pub trait EnvSecrets: Send + Sync {
fn get(&self, key: &str) -> Option<String>;
fn set(&self, key: &str, value: &str) -> IdeResult<()>;
fn delete(&self, key: &str);
/// The keys that start with `prefix`.
fn keys(&self, prefix: &str) -> Vec<String>;
}
/// Starts the ACP server of a provider in a directory, speaking to `client`.
@@ -107,6 +122,7 @@ struct Shared {
connector: Connector,
sink: RwLock<Option<AgentSink>>,
security: RwLock<Option<SecurityHook>>,
new_task_approvals: RwLock<Option<NewTaskApprovalsHook>>,
known: Mutex<HashMap<String, Known>>,
providers: RwLock<Vec<provider::ProviderSpec>>,
/// Last probe of each provider.
@@ -136,6 +152,10 @@ impl Shared {
self.providers.read().iter().find(|p| p.id == id).cloned()
}
fn env_secrets(&self) -> Option<&dyn EnvSecrets> {
self.paths.env_secrets.as_deref()
}
fn emit(&self, event: AgentEvent) {
let sink = self.sink.read().clone();
if let Some(sink) = sink {
@@ -155,6 +175,11 @@ impl Shared {
// Model-specific options are offered separately, never as provider-wide defaults.
config.retain(|o| o.category.as_deref() != Some("thought_level"));
config::add_client_options(provider, &mut config);
let hook = self.new_task_approvals.read().clone();
if hook.is_some_and(|ask| ask() == ide_core::settings::NewTaskApprovals::Ask) {
config::ask_for_approvals(&mut config);
return config;
}
if let Some((o, v)) = config::bypass_mode(&config).map(|(o, v)| (o.id.clone(), v.value.clone()))
&& let Some(o) = config.iter_mut().find(|c| c.id == o)
{
@@ -446,13 +471,14 @@ impl AgentManager {
fn with_connector(paths: AgentPaths, runtime: tokio::runtime::Handle, connector: Connector) -> Self {
let known =
std::fs::read(&paths.models_file).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default();
let providers = RwLock::new(provider::load(paths.custom_agents_dir.as_deref()));
let providers = RwLock::new(provider::load(paths.custom_agents_dir.as_deref(), paths.env_secrets.as_deref()));
let shared = Arc::new(Shared {
paths,
runtime,
connector,
sink: RwLock::new(None),
security: RwLock::new(None),
new_task_approvals: RwLock::new(None),
known: Mutex::new(known),
providers,
probes: Mutex::new(HashMap::new()),
@@ -501,9 +527,14 @@ impl AgentManager {
*self.shared.security.write() = Some(hook);
}
/// Sets the callback that reads which approvals new tasks start with.
pub fn set_new_task_approvals_hook(&self, hook: NewTaskApprovalsHook) {
*self.shared.new_task_approvals.write() = Some(hook);
}
/// The agents (custom ones re-read), whether each is installed, and what they offered last time.
pub fn providers(&self) -> Vec<AgentProvider> {
let specs = provider::load(self.shared.paths.custom_agents_dir.as_deref());
let specs = provider::load(self.shared.paths.custom_agents_dir.as_deref(), self.shared.env_secrets());
let in_use: std::collections::HashSet<String> =
self.tasks.read().values().map(|t| t.state.lock().task.provider.clone()).collect();
let list = specs
@@ -537,7 +568,7 @@ impl AgentManager {
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())
provider::set_builtin_command(&dir, self.shared.env_secrets(), id, command.as_deref())
}
/// Sets the environment variables built-in `id` starts with; empty clears them.
@@ -545,7 +576,7 @@ impl AgentManager {
let Some(dir) = self.shared.paths.custom_agents_dir.clone() else {
return Err(IdeError::unavailable("there is no custom agents directory"));
};
provider::set_builtin_env(&dir, id, env)
provider::set_builtin_env(&dir, self.shared.env_secrets(), id, env)
}
/// Saves a new custom ACP agent through its agent file.
@@ -553,7 +584,7 @@ impl AgentManager {
let Some(dir) = self.shared.paths.custom_agents_dir.clone() else {
return Err(IdeError::unavailable("there is no custom agents directory"));
};
provider::save_custom(&dir, &input)
provider::save_custom(&dir, self.shared.env_secrets(), &input)
}
/// Removes a custom agent's file (registry installs go through `registry_remove`).
@@ -561,7 +592,7 @@ impl AgentManager {
let Some(dir) = self.shared.paths.custom_agents_dir.clone() else {
return Err(IdeError::unavailable("there is no custom agents directory"));
};
provider::remove_custom(&dir, id)
provider::remove_custom(&dir, self.shared.env_secrets(), id)
}
/// Imports a JetBrains `acp.json` file's `agent_servers` entries as custom agents.
@@ -569,12 +600,12 @@ impl AgentManager {
let Some(dir) = self.shared.paths.custom_agents_dir.clone() else {
return Err(IdeError::unavailable("there is no custom agents directory"));
};
provider::import_acp(&dir, text)
provider::import_acp(&dir, self.shared.env_secrets(), text)
}
/// 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());
let specs = provider::load(self.shared.paths.custom_agents_dir.as_deref(), self.shared.env_secrets());
*self.shared.providers.write() = specs;
self.shared.probes.lock().clear();
self.shared.emit(AgentEvent::Providers);
@@ -2266,15 +2297,19 @@ impl Task {
.iter()
.map(|question| question.answer_key.clone().unwrap_or_else(|| question.question.clone()))
.collect();
let command = security::extract_command(params);
// What the card names as the command: not for a request that grants more than running it.
let shown = command.as_deref().filter(|_| !security::widens_access(params)).map(shown_command);
if let Some(hook) = self.shared.security.read().clone()
&& let Some(command) = security::extract_command(params)
&& let Some(command) = command.as_deref()
{
match security::evaluate_command(&command, &hook()) {
match security::evaluate_command(command, &hook()) {
security::PolicyDecision::Blocked(pattern) => {
let reject = options.iter().find(|o| o.kind.starts_with("reject")).cloned();
let _ = self.push_entry(AgentEntry::Permission {
id,
title,
command: shown.clone(),
options,
questions: Vec::new(),
chosen: Some(format!("Blocked by policy ({pattern})")),
@@ -2284,7 +2319,7 @@ impl Task {
None => json!({ "outcome": { "outcome": "cancelled" } }),
};
}
security::PolicyDecision::Allowed => {
security::PolicyDecision::Allowed if !security::widens_access(params) => {
if let Some(allow) = options
.iter()
.find(|o| o.kind == "allow_once")
@@ -2294,6 +2329,7 @@ impl Task {
let _ = self.push_entry(AgentEntry::Permission {
id,
title,
command: shown.clone(),
options,
questions: Vec::new(),
chosen: Some(format!("{} (whitelisted)", allow.name)),
@@ -2301,12 +2337,18 @@ impl Task {
return json!({ "outcome": { "outcome": "selected", "optionId": allow.id } });
}
}
security::PolicyDecision::AskUser => {}
security::PolicyDecision::Allowed | security::PolicyDecision::AskUser => {}
}
}
let (tx, rx) = std::sync::mpsc::channel();
let entry =
self.push_entry(AgentEntry::Permission { id, title, options: options.clone(), questions, chosen: None });
let entry = self.push_entry(AgentEntry::Permission {
id,
title,
command: shown,
options: options.clone(),
questions,
chosen: None,
});
self.permissions.lock().insert(id, tx);
self.set_status(AgentStatus::NeedsInput, None);
self.approve_pending();
@@ -2468,6 +2510,11 @@ fn permission_title(params: &Value) -> String {
title.unwrap_or("The agent asks for permission").to_owned()
}
/// A command as a permission card shows it: the script of a `<shell> -c <script>` line.
fn shown_command(command: &str) -> String {
shell_script(command).unwrap_or_else(|| command.to_owned())
}
/// The script of a `<shell> -c <quoted script>` command line, when the whole line is exactly that.
pub(crate) fn shell_script(command: &str) -> Option<String> {
let (shell, rest) = command.split_once(' ')?;
+4
View File
@@ -384,6 +384,10 @@ pub enum AgentEntry {
Permission {
id: u32,
title: String,
/// The shell command the request asks to run, from its structured input; `None` for
/// any other request, whatever its title says.
#[serde(default)]
command: Option<String>,
options: Vec<AgentPermissionOption>,
/// Set when the request asks `AskUserQuestion`'s questions; answered with a JSON object
/// of question to answer instead of an option.
+8 -7
View File
@@ -869,13 +869,14 @@ impl CodexShim {
"item/commandExecution/requestApproval" => {
let choices = translate::decisions(params, true);
let title = translate::command_approval_title(params, item.as_ref());
let call = tool_call_of(
&item_id,
&title,
"execute",
reason,
Some(json!({ "command": params.get("command"), "cwd": params.get("cwd") })),
);
let mut input = json!({
"command": translate::approval_command(params, item.as_ref()),
"cwd": params.get("cwd"),
});
if let Some(network) = params.get("networkApprovalContext").filter(|c| !c.is_null()) {
input["networkApprovalContext"] = network.clone();
}
let call = tool_call_of(&item_id, &title, "execute", reason, Some(input));
Ok(self.ask(&thread, call, &choices, decline))
}
"item/fileChange/requestApproval" => {
+12 -8
View File
@@ -757,19 +757,23 @@ pub(super) fn answer_of(choices: &[Choice], outcome: &Value, decline: Value) ->
choices.iter().find(|c| selected && c.option["optionId"].as_str() == chosen).map_or(decline, |c| c.answer.clone())
}
/// The command a command approval is about: the request's own, else its item's.
pub(super) fn approval_command<'a>(params: &'a Value, item: Option<&'a Value>) -> Option<&'a str> {
// Approval payloads can describe a different command from the item's earlier summary.
let item = item.filter(|i| i.get("id") == params.get("itemId"));
params
.get("command")
.and_then(Value::as_str)
.filter(|c| !c.is_empty())
.or_else(|| item.and_then(|i| i.get("command")).and_then(Value::as_str).filter(|c| !c.is_empty()))
}
/// The ACP title of a command approval.
pub(super) fn command_approval_title(params: &Value, item: Option<&Value>) -> String {
if let Some(host) = params.pointer("/networkApprovalContext/host").and_then(Value::as_str) {
return format!("Allow network access to {host}");
}
// Approval payloads can describe a different command from the item's earlier summary.
let item = item.filter(|i| i.get("id") == params.get("itemId"));
let command = params
.get("command")
.and_then(Value::as_str)
.filter(|c| !c.is_empty())
.or_else(|| item.and_then(|i| i.get("command")).and_then(Value::as_str).filter(|c| !c.is_empty()));
match command {
match approval_command(params, item) {
Some(command) => format!("Run {}", crate::shell_script(command).unwrap_or_else(|| command.to_owned())),
None => "Run a command".to_owned(),
}
+219 -68
View File
@@ -2,7 +2,7 @@
//! `<config>/agents/<id>.toml` (written by hand, or by installing one from the ACP
//! Registry, see `registry`).
use crate::{AcpImportOutcome, AgentCommandReadOnlyReason, AgentProvider, AgentSource, CustomAgentInput};
use crate::{AcpImportOutcome, AgentCommandReadOnlyReason, AgentProvider, AgentSource, CustomAgentInput, EnvSecrets};
use ide_core::{IdeError, IdeResult};
use serde::{Deserialize, Serialize};
use serde_json::Value;
@@ -231,23 +231,40 @@ pub(crate) fn describe(spec: &ProviderSpec) -> AgentProvider {
/// 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<()> {
update_builtin(custom_dir, id, |agent, builtin| agent.command = command.unwrap_or(&builtin.command).to_owned())
pub(crate) fn set_builtin_command(
custom_dir: &Path,
secrets: Option<&dyn EnvSecrets>,
id: &str,
command: Option<&str>,
) -> IdeResult<()> {
update_builtin(custom_dir, secrets, id, |agent, builtin| {
agent.command = command.unwrap_or(&builtin.command).to_owned()
})
}
/// The environment built-in `id` starts with, through its agent file (empty clears it).
pub(crate) fn set_builtin_env(custom_dir: &Path, id: &str, env: BTreeMap<String, String>) -> IdeResult<()> {
update_builtin(custom_dir, id, |agent, _| agent.env = env)
pub(crate) fn set_builtin_env(
custom_dir: &Path,
secrets: Option<&dyn EnvSecrets>,
id: &str,
env: BTreeMap<String, String>,
) -> IdeResult<()> {
update_builtin(custom_dir, secrets, id, |agent, _| agent.env = env)
}
/// Changes built-in `id`'s agent file; a file left with only the built-in's values is removed.
fn update_builtin(custom_dir: &Path, id: &str, change: impl FnOnce(&mut CustomAgent, &ProviderSpec)) -> IdeResult<()> {
fn update_builtin(
custom_dir: &Path,
secrets: Option<&dyn EnvSecrets>,
id: &str,
change: impl FnOnce(&mut CustomAgent, &ProviderSpec),
) -> 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)?
CustomAgent::read_with(&file, id, secrets).map_err(IdeError::invalid)?
} else {
CustomAgent {
name: Some(builtin.name.clone()),
@@ -272,16 +289,20 @@ fn update_builtin(custom_dir: &Path, id: &str, change: impl FnOnce(&mut CustomAg
Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e.into()),
_ => {}
}
forget_env(secrets, id, &[]);
return Ok(());
}
agent.write(&file)?;
Ok(())
agent.write_with(&file, id, secrets)
}
/// Saves a new custom agent; refuses built-in and ACP Registry ids, taken ids
/// and an empty command. The id is also the agent file's name, so it must be
/// a safe file stem.
pub(crate) fn save_custom(custom_dir: &Path, input: &CustomAgentInput) -> IdeResult<()> {
pub(crate) fn save_custom(
custom_dir: &Path,
secrets: Option<&dyn EnvSecrets>,
input: &CustomAgentInput,
) -> IdeResult<()> {
let id = validated_custom_id(&input.id)?;
if builtin_spec(&id).is_some() {
return Err(IdeError::invalid(format!("{id} is a built-in agent; change its command instead")));
@@ -306,12 +327,11 @@ pub(crate) fn save_custom(custom_dir: &Path, input: &CustomAgentInput) -> IdeRes
env: input.env.clone(),
registry: None,
}
.write(&file)?;
Ok(())
.write_with(&file, &id, secrets)
}
/// Removes a custom agent's file; refuses built-in, ACP Registry and missing ids.
pub(crate) fn remove_custom(custom_dir: &Path, id: &str) -> IdeResult<()> {
pub(crate) fn remove_custom(custom_dir: &Path, secrets: Option<&dyn EnvSecrets>, id: &str) -> IdeResult<()> {
let id = validated_custom_id(id)?;
let id = id.as_str();
if builtin_spec(id).is_some() {
@@ -325,14 +345,21 @@ pub(crate) fn remove_custom(custom_dir: &Path, id: &str) -> IdeResult<()> {
}
match std::fs::remove_file(&file) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e.into()),
_ => Ok(()),
_ => {
forget_env(secrets, id, &[]);
Ok(())
}
}
}
/// Imports a JetBrains `acp.json` file's `agent_servers` entries as custom
/// agents, one outcome per entry; entries that collide, lack a command or
/// have no usable name are skipped with a reason.
pub(crate) fn import_acp(custom_dir: &Path, text: &str) -> IdeResult<Vec<AcpImportOutcome>> {
pub(crate) fn import_acp(
custom_dir: &Path,
secrets: Option<&dyn EnvSecrets>,
text: &str,
) -> IdeResult<Vec<AcpImportOutcome>> {
#[derive(Deserialize)]
struct AcpJson {
#[serde(default)]
@@ -370,7 +397,7 @@ pub(crate) fn import_acp(custom_dir: &Path, text: &str) -> IdeResult<Vec<AcpImpo
env: server.env,
registry: None,
}
.write(&custom_dir.join(format!("{id}.toml")))?;
.write_with(&custom_dir.join(format!("{id}.toml")), &id, secrets)?;
outcome.saved = true;
}
outcomes.push(outcome);
@@ -400,7 +427,7 @@ fn slugify_id(name: &str) -> String {
}
/// An agent file, `<config>/agents/<id>.toml`.
#[derive(Debug, PartialEq, Serialize, Deserialize)]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct CustomAgent {
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -447,11 +474,67 @@ impl CustomAgent {
std::fs::write(&tmp, text)?;
std::fs::rename(&tmp, path)
}
/// Reads agent `id`'s file with its environment values filled in from `secrets`; values
/// still written in the file (by hand, by an older version) move there first. A registry
/// install keeps the environment its registry entry gives it in the file.
pub(crate) fn read_with(path: &Path, id: &str, secrets: Option<&dyn EnvSecrets>) -> Result<Self, String> {
let mut agent = Self::read(path)?;
let Some(secrets) = secrets.filter(|_| agent.registry.is_none()) else {
return Ok(agent);
};
let written = agent.env.values().any(|v| !v.is_empty());
for (name, value) in agent.env.iter_mut() {
if value.is_empty()
&& let Some(saved) = secrets.get(&env_key(id, name))
{
*value = saved;
}
}
if written && let Err(e) = agent.write_with(path, id, Some(secrets)) {
tracing::warn!("agent {id}: could not move environment values out of {}: {}", path.display(), e.message);
}
Ok(agent)
}
/// Writes agent `id`'s file with the environment's names only, its values saved in `secrets`.
pub(crate) fn write_with(&self, path: &Path, id: &str, secrets: Option<&dyn EnvSecrets>) -> IdeResult<()> {
let Some(secrets) = secrets.filter(|_| self.registry.is_none()) else {
return Ok(self.write(path)?);
};
let mut keys = Vec::with_capacity(self.env.len());
for (name, value) in &self.env {
let key = env_key(id, name);
if value.is_empty() {
secrets.delete(&key);
} else if secrets.get(&key).as_deref() != Some(value.as_str()) {
secrets.set(&key, value)?;
}
keys.push(key);
}
forget_env(Some(secrets), id, &keys);
let names = self.env.keys().map(|name| (name.clone(), String::new())).collect();
Ok(Self { env: names, ..self.clone() }.write(path)?)
}
}
fn env_key(id: &str, name: &str) -> String {
format!("agent/{id}/env/{}", name.replace('%', "%25").replace('/', "%2F"))
}
/// Forgets agent `id`'s saved environment values, except `keep`.
fn forget_env(secrets: Option<&dyn EnvSecrets>, id: &str, keep: &[String]) {
let Some(secrets) = secrets else { return };
for key in secrets.keys(&format!("agent/{id}/env/")) {
if !keep.contains(&key) {
secrets.delete(&key);
}
}
}
/// Built-in agents, then the `*.toml` agents of `custom_dir`; a custom agent with a
/// built-in id replaces it.
pub(crate) fn load(custom_dir: Option<&Path>) -> Vec<ProviderSpec> {
pub(crate) fn load(custom_dir: Option<&Path>, secrets: Option<&dyn EnvSecrets>) -> Vec<ProviderSpec> {
let mut specs = builtins();
let mut files: Vec<PathBuf> = custom_dir
.and_then(|d| std::fs::read_dir(d).ok())
@@ -464,7 +547,7 @@ pub(crate) fn load(custom_dir: Option<&Path>) -> Vec<ProviderSpec> {
files.sort();
for path in files {
let Some(id) = path.file_stem().and_then(|s| s.to_str()).map(str::to_owned) else { continue };
let agent = match CustomAgent::read(&path) {
let agent = match CustomAgent::read_with(&path, &id, secrets) {
Ok(a) => a,
Err(e) => {
tracing::warn!("skipping agent {}: {e}", path.display());
@@ -534,7 +617,7 @@ mod tests {
#[test]
fn qoder_global_and_cn_editions_are_both_built_in() {
let info = |id: &str| describe(&load(None).into_iter().find(|s| s.id == id).unwrap());
let info = |id: &str| describe(&load(None, None).into_iter().find(|s| s.id == id).unwrap());
let (global, cn) = (info("qoder"), info("qodercn"));
assert_eq!((global.name.as_str(), global.command.as_str()), ("Qoder", "qodercli --acp"));
assert_eq!((cn.name.as_str(), cn.command.as_str()), ("Qoder CN", "qoderclicn --acp"));
@@ -544,7 +627,7 @@ mod tests {
#[test]
fn qodercn_is_available_without_a_file_and_preserves_existing_customizations() {
let dir = tempfile::tempdir().unwrap();
let qodercn = load(None).into_iter().find(|s| s.id == "qodercn").unwrap();
let qodercn = load(None, None).into_iter().find(|s| s.id == "qodercn").unwrap();
assert_eq!(qodercn.driver, Driver::Acp);
assert!(!is_unlisted(&qodercn));
let info = describe(&qodercn);
@@ -559,13 +642,13 @@ mod tests {
"command = \"/custom/qoderclicn\"\nargs = [\"--acp\"]\n[env]\nK = \"v\"\n",
)
.unwrap();
let specs = load(Some(dir.path()));
let specs = load(Some(dir.path()), None);
assert_eq!(specs.iter().filter(|s| s.id == "qodercn").count(), 1);
let custom = specs.iter().find(|s| s.id == "qodercn").unwrap();
assert_eq!(custom.command, "/custom/qoderclicn");
assert_eq!(custom.env, [("K".into(), "v".into())]);
set_builtin_command(dir.path(), "qodercn", None).unwrap();
let reset = load(Some(dir.path())).into_iter().find(|s| s.id == "qodercn").unwrap();
set_builtin_command(dir.path(), None, "qodercn", None).unwrap();
let reset = load(Some(dir.path()), None).into_iter().find(|s| s.id == "qodercn").unwrap();
assert_eq!(reset.command, "qoderclicn");
assert_eq!(reset.args, ["--acp"]);
assert_eq!(reset.env, custom.env);
@@ -680,7 +763,7 @@ mod tests {
#[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 list = || load(Some(dir.path()), None).iter().map(describe).collect::<Vec<_>>();
let defaults = list();
let codex = defaults.iter().find(|p| p.id == "codex").unwrap();
assert_eq!(codex.program, "codex");
@@ -700,14 +783,14 @@ mod tests {
p.id.ends_with("-acp").then_some(AgentCommandReadOnlyReason::PackageRunner)
);
}
set_builtin_command(dir.path(), "codex", Some("/path with spaces/codex")).unwrap();
set_builtin_command(dir.path(), None, "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();
set_builtin_command(dir.path(), None, "codex", Some("codex")).unwrap();
assert!(!list().iter().find(|p| p.id == "codex").unwrap().command_overridden);
std::fs::write(
dir.path().join("gemini.toml"),
@@ -732,7 +815,7 @@ mod tests {
write("mine.toml", "name = \"Mine\"\ncommand = \"/opt/mine\"\nargs = [\"acp\"]\n[env]\nK = \"v\"\n");
write("gemini.toml", "command = \"gemini\"\nargs = [\"--acp\"]\n");
write("broken.toml", "nope = 1\n");
let specs = load(Some(dir.path()));
let specs = load(Some(dir.path()), None);
let mine = specs.iter().find(|s| s.id == "mine").unwrap();
assert_eq!((mine.name.as_str(), mine.command.as_str()), ("Mine", "/opt/mine"));
assert_eq!(mine.env, vec![("K".to_owned(), "v".to_owned())]);
@@ -748,34 +831,35 @@ mod tests {
fn set_builtin_env_writes_and_clears_the_environment() {
let dir = tempfile::tempdir().unwrap();
let env = BTreeMap::from([("CLAUDE_CODE_ENTRYPOINT".to_owned(), "openidea".to_owned())]);
set_builtin_env(dir.path(), "claude", env.clone()).unwrap();
let claude = load(Some(dir.path())).into_iter().find(|s| s.id == "claude").unwrap();
set_builtin_env(dir.path(), None, "claude", env.clone()).unwrap();
let claude = load(Some(dir.path()), None).into_iter().find(|s| s.id == "claude").unwrap();
assert_eq!(claude.env, vec![("CLAUDE_CODE_ENTRYPOINT".to_owned(), "openidea".to_owned())]);
assert_eq!(describe(&claude).env, env);
// The command override and the environment are kept independently.
set_builtin_command(dir.path(), "claude", Some("/opt/claude")).unwrap();
set_builtin_command(dir.path(), "claude", None).unwrap();
set_builtin_command(dir.path(), None, "claude", Some("/opt/claude")).unwrap();
set_builtin_command(dir.path(), None, "claude", None).unwrap();
assert!(dir.path().join("claude.toml").is_file());
set_builtin_env(dir.path(), "claude", BTreeMap::new()).unwrap();
set_builtin_env(dir.path(), None, "claude", BTreeMap::new()).unwrap();
assert!(!dir.path().join("claude.toml").exists());
set_builtin_env(dir.path(), "not-a-builtin", env).unwrap_err();
set_builtin_env(dir.path(), None, "not-a-builtin", env).unwrap_err();
}
#[test]
fn acp_adapters_of_native_agents_are_unlisted_until_customized() {
let dir = tempfile::tempdir().unwrap();
let unlisted = |dir: &Path| load(Some(dir)).into_iter().filter(is_unlisted).map(|s| s.id).collect::<Vec<_>>();
let unlisted =
|dir: &Path| load(Some(dir), None).into_iter().filter(is_unlisted).map(|s| s.id).collect::<Vec<_>>();
assert_eq!(unlisted(dir.path()), ["claude-acp", "codex-acp"]);
set_builtin_env(dir.path(), "codex-acp", BTreeMap::from([("A".to_owned(), "1".to_owned())])).unwrap();
set_builtin_env(dir.path(), None, "codex-acp", BTreeMap::from([("A".to_owned(), "1".to_owned())])).unwrap();
assert_eq!(unlisted(dir.path()), ["claude-acp"]);
}
#[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();
set_builtin_command(dir.path(), None, "not-a-builtin", Some("/x")).unwrap_err();
set_builtin_command(dir.path(), None, "claude", Some("/opt/claude")).unwrap();
let claude = load(Some(dir.path()), None).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.
@@ -790,14 +874,14 @@ mod tests {
}
.write(&path)
.unwrap();
set_builtin_command(dir.path(), "codex", Some("/x")).unwrap_err();
set_builtin_command(dir.path(), "codex", None).unwrap_err();
set_builtin_command(dir.path(), None, "codex", Some("/x")).unwrap_err();
set_builtin_command(dir.path(), None, "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();
set_builtin_command(dir.path(), None, "claude", None).unwrap();
set_builtin_command(dir.path(), None, "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");
assert_eq!(load(Some(dir.path()), None).iter().find(|s| s.id == "claude").unwrap().command, "claude");
}
#[test]
@@ -825,11 +909,11 @@ mod tests {
}
agent.write(&path).unwrap();
set_builtin_command(dir.path(), "claude", Some("/new/claude")).unwrap();
set_builtin_command(dir.path(), None, "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();
set_builtin_command(dir.path(), None, "claude", None).unwrap();
agent.command = "claude".into();
assert_eq!(CustomAgent::read(&path).unwrap(), agent, "{field}");
}
@@ -845,11 +929,11 @@ mod tests {
)
.unwrap();
set_builtin_command(dir.path(), "codex", None).unwrap();
set_builtin_command(dir.path(), None, "codex", None).unwrap();
assert!(!path.exists());
assert_eq!(
load(Some(dir.path())).into_iter().find(|s| s.id == "codex").unwrap(),
load(Some(dir.path()), None).into_iter().find(|s| s.id == "codex").unwrap(),
builtin_spec("codex").unwrap()
);
}
@@ -870,7 +954,7 @@ mod tests {
let text = std::fs::read_to_string(&path).unwrap();
assert!(text.contains("[registry]") && !text.contains("driver"), "{text}");
assert_eq!(CustomAgent::read(&path).unwrap(), agent);
let spec = load(Some(path.parent().unwrap())).into_iter().find(|s| s.id == "amp-acp").unwrap();
let spec = load(Some(path.parent().unwrap()), None).into_iter().find(|s| s.id == "amp-acp").unwrap();
assert_eq!(spec.source, AgentSource::Registry { version: "0.10.0".into(), update: None });
assert!(toml::from_str::<CustomAgent>("command = \"x\"\n[registry]\nid = \"x\"\n").is_err());
}
@@ -888,50 +972,117 @@ mod tests {
#[test]
fn custom_agents_save_list_and_remove() {
let dir = tempfile::tempdir().unwrap();
save_custom(dir.path(), &custom_input("mine")).unwrap();
let mine = load(Some(dir.path())).into_iter().find(|s| s.id == "mine").unwrap();
save_custom(dir.path(), None, &custom_input("mine")).unwrap();
let mine = load(Some(dir.path()), None).into_iter().find(|s| s.id == "mine").unwrap();
assert_eq!((mine.name.as_str(), mine.command.as_str()), ("Mine", "/opt/mine"));
assert_eq!(mine.env, vec![("K".to_owned(), "v".to_owned())]);
assert_eq!(mine.source, AgentSource::Custom);
remove_custom(dir.path(), "mine").unwrap();
assert!(!load(Some(dir.path())).iter().any(|s| s.id == "mine"));
remove_custom(dir.path(), "mine").unwrap_err();
remove_custom(dir.path(), None, "mine").unwrap();
assert!(!load(Some(dir.path()), None).iter().any(|s| s.id == "mine"));
remove_custom(dir.path(), None, "mine").unwrap_err();
}
#[test]
fn custom_agents_refuse_builtin_registry_and_bad_ids() {
let dir = tempfile::tempdir().unwrap();
save_custom(dir.path(), &custom_input("claude")).unwrap_err();
save_custom(dir.path(), &custom_input("../evil")).unwrap_err();
save_custom(dir.path(), &custom_input("")).unwrap_err();
save_custom(dir.path(), None, &custom_input("claude")).unwrap_err();
save_custom(dir.path(), None, &custom_input("../evil")).unwrap_err();
save_custom(dir.path(), None, &custom_input("")).unwrap_err();
let mut empty_command = custom_input("mine");
empty_command.command = " ".into();
save_custom(dir.path(), &empty_command).unwrap_err();
save_custom(dir.path(), &custom_input("mine")).unwrap();
save_custom(dir.path(), &custom_input("mine")).unwrap_err();
remove_custom(dir.path(), "claude").unwrap_err();
save_custom(dir.path(), None, &empty_command).unwrap_err();
save_custom(dir.path(), None, &custom_input("mine")).unwrap();
save_custom(dir.path(), None, &custom_input("mine")).unwrap_err();
remove_custom(dir.path(), None, "claude").unwrap_err();
// An id that isn't a plain file stem never reaches outside the agents directory.
let outside = dir.path().join("outside.toml");
std::fs::write(&outside, "command = \"/bin/sh\"\n").unwrap();
let agents = dir.path().join("agents");
std::fs::create_dir(&agents).unwrap();
remove_custom(&agents, "../outside").unwrap_err();
remove_custom(&agents, None, "../outside").unwrap_err();
assert!(outside.exists());
std::fs::write(
dir.path().join("amp-acp.toml"),
"command = \"/registry/amp\"\n[registry]\nid = \"amp-acp\"\nversion = \"1.0\"\n",
)
.unwrap();
save_custom(dir.path(), &custom_input("amp-acp")).unwrap_err();
remove_custom(dir.path(), "amp-acp").unwrap_err();
save_custom(dir.path(), None, &custom_input("amp-acp")).unwrap_err();
remove_custom(dir.path(), None, "amp-acp").unwrap_err();
}
#[derive(Default)]
struct MemorySecrets(parking_lot::Mutex<BTreeMap<String, String>>);
impl EnvSecrets for MemorySecrets {
fn get(&self, key: &str) -> Option<String> {
self.0.lock().get(key).cloned()
}
fn set(&self, key: &str, value: &str) -> IdeResult<()> {
self.0.lock().insert(key.to_owned(), value.to_owned());
Ok(())
}
fn delete(&self, key: &str) {
self.0.lock().remove(key);
}
fn keys(&self, prefix: &str) -> Vec<String> {
self.0.lock().keys().filter(|k| k.starts_with(prefix)).cloned().collect()
}
}
#[test]
fn environment_values_go_to_the_secrets_and_the_files_keep_the_names() {
let dir = tempfile::tempdir().unwrap();
let secrets = MemorySecrets::default();
let s = Some(&secrets as &dyn EnvSecrets);
let env = BTreeMap::from([("API_KEY".to_owned(), "v-91f".to_owned()), ("A/B".to_owned(), String::new())]);
set_builtin_env(dir.path(), s, "claude", env.clone()).unwrap();
save_custom(dir.path(), s, &CustomAgentInput { env: env.clone(), ..custom_input("mine") }).unwrap();
for id in ["claude", "mine"] {
let text = std::fs::read_to_string(dir.path().join(format!("{id}.toml"))).unwrap();
assert!(text.contains("API_KEY") && !text.contains("v-91f"), "{text}");
let spec = load(Some(dir.path()), s).into_iter().find(|p| p.id == id).unwrap();
assert_eq!(describe(&spec).env, env);
}
assert_eq!(secrets.keys("agent/"), ["agent/claude/env/API_KEY", "agent/mine/env/API_KEY"]);
set_builtin_command(dir.path(), s, "claude", Some("/opt/claude")).unwrap();
assert_eq!(secrets.get("agent/claude/env/API_KEY").as_deref(), Some("v-91f"));
set_builtin_env(dir.path(), s, "claude", BTreeMap::from([("OTHER".to_owned(), "x".to_owned())])).unwrap();
assert_eq!(secrets.keys("agent/claude/"), ["agent/claude/env/OTHER"]);
set_builtin_command(dir.path(), s, "claude", None).unwrap();
set_builtin_env(dir.path(), s, "claude", BTreeMap::new()).unwrap();
remove_custom(dir.path(), s, "mine").unwrap();
assert_eq!(secrets.keys("agent/"), Vec::<String>::new());
}
#[test]
fn values_written_in_agent_files_move_to_the_secrets_unless_installed_from_the_registry() {
let dir = tempfile::tempdir().unwrap();
let secrets = MemorySecrets::default();
let s = Some(&secrets as &dyn EnvSecrets);
std::fs::write(dir.path().join("mine.toml"), "command = \"/opt/mine\"\n[env]\nK = \"v-91f\"\nS = \"\"\n")
.unwrap();
secrets.set("agent/mine/env/S", "saved").unwrap();
let registry =
"command = \"/registry/amp\"\n[env]\nR = \"1\"\n[registry]\nid = \"amp-acp\"\nversion = \"1.0\"\n";
std::fs::write(dir.path().join("amp-acp.toml"), registry).unwrap();
let specs = load(Some(dir.path()), s);
assert_eq!(
specs.iter().find(|p| p.id == "mine").unwrap().env,
[("K".to_owned(), "v-91f".to_owned()), ("S".to_owned(), "saved".to_owned())]
);
assert!(!std::fs::read_to_string(dir.path().join("mine.toml")).unwrap().contains("v-91f"));
assert_eq!(secrets.keys("agent/"), ["agent/mine/env/K", "agent/mine/env/S"]);
assert_eq!(std::fs::read_to_string(dir.path().join("amp-acp.toml")).unwrap(), registry);
}
#[test]
fn acp_json_imports_servers_and_skips_collisions() {
let dir = tempfile::tempdir().unwrap();
save_custom(dir.path(), &custom_input("taken")).unwrap();
save_custom(dir.path(), None, &custom_input("taken")).unwrap();
let outcomes = import_acp(
dir.path(),
None,
r#"{"agent_servers": {
"My Agent!": {"command": "/opt/agent", "args": ["--acp"], "env": {"K": "v"}},
"taken": {"command": "/opt/taken"},
@@ -944,10 +1095,10 @@ mod tests {
let saved: Vec<&str> = outcomes.iter().filter(|o| o.saved).map(|o| o.id.as_str()).collect();
assert_eq!(saved, ["my-agent"]);
assert!(outcomes.iter().filter(|o| !o.saved).all(|o| !o.reason.is_empty()));
let mine = load(Some(dir.path())).into_iter().find(|s| s.id == "my-agent").unwrap();
let mine = load(Some(dir.path()), None).into_iter().find(|s| s.id == "my-agent").unwrap();
assert_eq!((mine.name.as_str(), mine.command.as_str()), ("My Agent!", "/opt/agent"));
assert_eq!(mine.source, AgentSource::Custom);
import_acp(dir.path(), "not json").unwrap_err();
assert!(import_acp(dir.path(), "{}").unwrap().is_empty());
import_acp(dir.path(), None, "not json").unwrap_err();
assert!(import_acp(dir.path(), None, "{}").unwrap().is_empty());
}
}
+4 -4
View File
@@ -826,7 +826,7 @@ mod tests {
assert!(!shown.iter().any(|a| a.id == "cline"));
write_atomic(&p.cache_file, INDEX.as_bytes()).unwrap();
let mut providers: Vec<AgentProvider> = provider::load(Some(&p.agents_dir))
let mut providers: Vec<AgentProvider> = provider::load(Some(&p.agents_dir), None)
.into_iter()
.filter(|s| s.id == "amp-acp")
.map(|s| provider::describe(&s))
@@ -918,7 +918,7 @@ mod tests {
assert_eq!(agent.command, v1.to_string_lossy());
assert_eq!((agent.args.as_slice(), agent.env["A"].as_str()), (&["acp".to_owned()][..], "1"));
assert_eq!(agent.registry, Some(RegistryRef { id: "demo".into(), version: "1.0.0".into() }));
let spec = provider::load(Some(&p.agents_dir)).into_iter().find(|s| s.id == "demo").unwrap();
let spec = provider::load(Some(&p.agents_dir), None).into_iter().find(|s| s.id == "demo").unwrap();
assert_eq!(spec.source, AgentSource::Registry { version: "1.0.0".into(), update: None });
assert!(provider::installed(&spec.command));
@@ -991,7 +991,7 @@ mod tests {
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();
let spec = provider::load(Some(&p.agents_dir), None).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 });
@@ -1004,7 +1004,7 @@ mod tests {
);
remove(&p, "github-copilot-cli").unwrap();
let fallback = provider::load(Some(&p.agents_dir)).into_iter().find(|s| s.id == "copilot").unwrap();
let fallback = provider::load(Some(&p.agents_dir), None).into_iter().find(|s| s.id == "copilot").unwrap();
assert_eq!(fallback.source, AgentSource::Builtin);
}
}
+1
View File
@@ -24,6 +24,7 @@ fn paths(dir: &Path) -> AgentPaths {
models_file: dir.join("models.json"),
custom_agents_dir: None,
home_dir: None,
env_secrets: None,
}
}
+135 -22
View File
@@ -13,29 +13,23 @@ pub enum PolicyDecision {
AskUser,
}
/// Extracts the shell command string from an ACP `session/request_permission` payload
/// when the request is for executing a command.
/// The shell command an ACP `session/request_permission` asks to run: only a tool call of kind
/// `execute`, and only its structured `rawInput` command. A title is free text the agent
/// writes, so it never names the command.
pub fn extract_command(params: &Value) -> Option<String> {
let tool = params.get("toolCall").or_else(|| params.pointer("/subject/toolCall"))?;
if tool.get("kind").and_then(Value::as_str) != Some("execute") {
return None;
}
let raw_cmd = tool.pointer("/rawInput/command").or_else(|| tool.pointer("/rawInput/cmd"));
if let Some(cmd) = raw_cmd.and_then(command_from_value) {
return Some(cmd);
}
let kind = tool.get("kind").and_then(Value::as_str).unwrap_or_default();
let name = tool.get("name").and_then(Value::as_str).unwrap_or_default();
let title =
tool.get("title").and_then(Value::as_str).or_else(|| params.get("title").and_then(Value::as_str))?.trim();
if let Some(cmd) = title.strip_prefix("Run ").map(str::trim).filter(|s| !s.is_empty()) {
return Some(cmd.to_owned());
}
if (kind == "execute" || matches!(name, "Bash" | "PowerShell" | "Execute" | "Run" | "terminal"))
&& !title.is_empty()
&& title != "Terminal"
&& title != "Run a command"
{
return Some(title.to_owned());
}
None
raw_cmd.and_then(command_from_value)
}
/// Whether allowing the request grants more than running its command, such as network access
/// for it: the command policy may block such a request but never approves it.
pub fn widens_access(params: &Value) -> bool {
let tool = params.get("toolCall").or_else(|| params.pointer("/subject/toolCall"));
tool.and_then(|t| t.pointer("/rawInput/networkApprovalContext")).is_some_and(|c| !c.is_null())
}
fn command_from_value(val: &Value) -> Option<String> {
@@ -298,6 +292,41 @@ fn split_subcommands(command: &str) -> Vec<&str> {
out
}
/// Whether every shell an agent may run `command` in (a POSIX shell, PowerShell, cmd.exe) reads
/// it as the one command the policy sees. Outside double quotes no separator or redirection may
/// remain after splitting (cmd.exe takes single quotes literally); `\` may only stand in a
/// path (it escapes only in POSIX shells); no `^` may escape a quote (cmd.exe); no `%` (cmd.exe
/// expands it before parsing); no carriage return; no typographic quotes (PowerShell ends
/// strings at them).
fn reads_alike_in_every_shell(command: &str) -> bool {
let chars: Vec<char> = command.chars().collect();
let mut in_double = false;
for (i, &c) in chars.iter().enumerate() {
let next = chars.get(i + 1).copied();
match c {
'"' => in_double = !in_double,
'\\' if !next.is_none_or(|n| n.is_ascii_alphanumeric() || matches!(n, '\\' | '.' | '_' | '-')) => {
return false;
}
'^' if matches!(next, Some('"' | '\'' | '\n' | '\r')) => return false,
'%' | '\r' | '\u{2018}'..='\u{201f}' => return false,
';' | '&' | '|' | '<' | '>' | '\n' if !in_double => return false,
_ => {}
}
}
true
}
/// A command as blocked patterns are compared with it: lower case, single spaces, and the
/// program without its folder or `.exe` (`/bin/rm` and `RM.EXE` are `rm`).
fn normalized_for_blocking(command: &str) -> String {
let mut words = command.split_whitespace().map(str::to_lowercase);
let Some(program) = words.next() else { return String::new() };
let program = program.rsplit(['/', '\\']).next().unwrap_or(&program);
let program = program.strip_suffix(".exe").unwrap_or(program);
std::iter::once(program.to_owned()).chain(words).collect::<Vec<_>>().join(" ")
}
/// Evaluates `command` against `settings`:
/// 1. Blacklist takes precedence: if the full command or any sub-command matches a
/// `blocked_commands` entry, returns [`PolicyDecision::Blocked`].
@@ -312,12 +341,18 @@ pub fn evaluate_command(command: &str, settings: &AgentSecuritySettings) -> Poli
}
let subcommands = split_subcommands(cmd);
let normalized: Vec<String> =
std::iter::once(cmd).chain(subcommands.iter().copied()).map(normalized_for_blocking).collect();
for pat in &settings.blocked_commands {
let pat = pat.trim();
if pat.is_empty() {
continue;
}
if matches_command(pat, cmd) || subcommands.iter().any(|sub| matches_command(pat, sub)) {
let normalized_pat = normalized_for_blocking(pat);
if matches_command(pat, cmd)
|| subcommands.iter().any(|sub| matches_command(pat, sub))
|| normalized.iter().any(|c| matches_command(&normalized_pat, c))
{
return PolicyDecision::Blocked(pat.to_owned());
}
}
@@ -328,7 +363,7 @@ pub fn evaluate_command(command: &str, settings: &AgentSecuritySettings) -> Poli
let all_allowed = subcommands.iter().all(|sub| {
let s = sub.trim();
if s.is_empty() || has_unsafe_shell_syntax(s) {
if s.is_empty() || has_unsafe_shell_syntax(s) || !reads_alike_in_every_shell(s) {
return false;
}
settings.allowed_commands.iter().any(|entry| matches_allow_entry(entry, s))
@@ -455,6 +490,58 @@ mod tests {
assert_eq!(evaluate_command("git status && rm -rf /", &settings), PolicyDecision::Blocked("rm -rf *".into()));
}
/// The command may run in PowerShell or cmd.exe as well as a POSIX shell: what any of them
/// would split or expand differently from the policy's reading is never auto-approved.
#[test]
fn what_another_shell_reads_as_two_commands_is_not_auto_approved() {
let settings = AgentSecuritySettings {
allowed_commands: vec!["git status".into(), "git commit".into()],
blocked_commands: vec![],
};
for cmd in [
// `\` escapes only in POSIX shells.
"git status \\; calc.exe",
"git status \\& calc.exe",
"git status \\| calc.exe",
"git status \\\\\\; calc.exe",
"git status \"\\\" ; calc.exe ; \\\"\"",
// cmd.exe: single quotes don't quote, `^` escapes a quote, `%` expands first.
"git status '&' calc.exe",
"git status ';' calc.exe",
"git status '>' out.txt",
"git status ^\" & calc.exe ^\"",
"git status %COMSPEC%",
// PowerShell also closes a string at a typographic quote.
"git status \"a \u{201d} ; calc.exe ; \u{201c} b\"",
"git status \u{2018}x\u{2019}",
// A carriage return ends the command line too.
"git status\r calc.exe",
] {
assert_eq!(evaluate_command(cmd, &settings), PolicyDecision::AskUser, "{cmd:?}");
}
// Text every shell reads alike stays approvable.
for cmd in [
"git status -s",
"git status && git status",
"git commit -m \"fix <bug> & (issue); done\"",
"git status src\\main.rs C:\\work\\repo",
"git status HEAD^",
] {
assert_eq!(evaluate_command(cmd, &settings), PolicyDecision::Allowed, "{cmd:?}");
}
}
#[test]
fn blocked_patterns_ignore_case_spacing_and_the_programs_folder() {
let settings = AgentSecuritySettings::default();
for cmd in
["rm -rf /", "RM -RF /", "/bin/rm -rf /", "Rm -Rf /tmp/x", "C:\\Windows\\System32\\shutdown.exe /s"]
{
assert!(matches!(evaluate_command(cmd, &settings), PolicyDecision::Blocked(_)), "{cmd:?}");
}
assert_eq!(evaluate_command("git status", &settings), PolicyDecision::AskUser);
}
#[test]
fn extracts_command_from_v1_v2_and_codex_payloads() {
let claude = json!({
@@ -484,4 +571,30 @@ mod tests {
});
assert_eq!(extract_command(&write), None);
}
/// Only a tool call of kind `execute` runs a command, and its command is in `rawInput`:
/// a title says what the agent wants it to say.
#[test]
fn a_request_is_a_command_only_by_its_kind_and_input() {
let escalation = json!({ "sessionId": "t", "toolCall": {
"toolCallId": "i1", "title": "Run git status", "kind": "other", "status": "pending",
"rawInput": { "fileSystem": { "write": ["C:\\"] }, "network": { "enabled": true } } },
"options": [ { "optionId": "turn", "name": "Allow for this turn", "kind": "allow_once" },
{ "optionId": "decline", "name": "Decline", "kind": "reject_once" } ] });
assert_eq!(extract_command(&escalation), None);
let titled_only = json!({ "toolCall": { "title": "Run git status", "kind": "execute" } });
assert_eq!(extract_command(&titled_only), None);
let named_only = json!({ "toolCall": { "title": "git status", "name": "Bash" } });
assert_eq!(extract_command(&named_only), None);
let mislabelled = json!({ "toolCall": { "title": "Run git status", "kind": "edit",
"rawInput": { "command": "git status" } } });
assert_eq!(extract_command(&mislabelled), None);
let network = json!({ "toolCall": { "title": "Allow network access to example.com", "kind": "execute",
"rawInput": { "command": "curl https://example.com", "networkApprovalContext": { "host": "example.com" } } } });
assert_eq!(extract_command(&network).as_deref(), Some("curl https://example.com"));
assert!(widens_access(&network));
let plain = json!({ "toolCall": { "title": "Run git status", "kind": "execute",
"rawInput": { "command": "git status" } } });
assert!(!widens_access(&plain));
}
}
+65 -2
View File
@@ -264,6 +264,23 @@ impl Handler for FakeAgent {
self.say(&session, &format!("late chose {chosen}"));
return Ok(json!({"stopReason": "end_turn"}));
}
if text == "escalate" {
// A request for wider access, titled with text the model wrote.
let peer = self.latest();
let answer: Value = tokio::runtime::Handle::current()
.block_on(peer.request(
"session/request_permission",
json!({"sessionId": session, "toolCall": {"toolCallId": "e1", "title": "Run cargo test",
"kind": "other", "rawInput": {"network": {"enabled": true}}},
"options": [{"optionId": "yes", "name": "Allow for this turn", "kind": "allow_once"},
{"optionId": "no", "name": "Decline", "kind": "reject_once"}]}),
None,
))
.unwrap();
let chosen = answer.pointer("/outcome/optionId").and_then(Value::as_str).unwrap_or("none");
self.say(&session, &format!("escalation chose {chosen}"));
return Ok(json!({"stopReason": "end_turn"}));
}
if text == "ask" {
let peer = self.latest();
let answer: Value = tokio::runtime::Handle::current()
@@ -344,7 +361,8 @@ impl Handler for FakeAgent {
let answer: Value = tokio::runtime::Handle::current()
.block_on(peer.request(
"session/request_permission",
json!({"sessionId": session, "toolCall": {"toolCallId": "t1", "title": "Run cargo test"},
json!({"sessionId": session, "toolCall": {"toolCallId": "t1", "title": "Run cargo test",
"kind": "execute", "rawInput": {"command": "cargo test"}},
"options": [{"optionId": "yes", "name": "Allow", "kind": "allow_once"},
{"optionId": "no", "name": "Reject", "kind": "reject_once"}]}),
None,
@@ -366,7 +384,8 @@ fn manager(dir: &Path, agent: Arc<FakeAgent>) -> AgentManager {
fn manager_at(dir: &Path, models_file: &Path, agent: Arc<FakeAgent>) -> AgentManager {
// These tests speak to FakeAgent, never an installed CLI, including after a provider reload.
let agents = dir.join("agents");
provider::set_builtin_command(&agents, "claude", Some(&dir.join("missing-claude").to_string_lossy())).unwrap();
provider::set_builtin_command(&agents, None, "claude", Some(&dir.join("missing-claude").to_string_lossy()))
.unwrap();
let connector: Connector = Arc::new(move |_spec, _cwd, client, _stderr| {
let (a, b) = duplex(1 << 20);
let (ar, aw) = tokio::io::split(a);
@@ -385,6 +404,7 @@ fn manager_at(dir: &Path, models_file: &Path, agent: Arc<FakeAgent>) -> AgentMan
// The project is the boundary of the skill walk: nothing above it (such as the
// user's profile, when temp lives under it) leaks into the test.
home_dir: Some(dir.to_path_buf()),
env_secrets: None,
},
tokio::runtime::Handle::current(),
connector,
@@ -1410,6 +1430,7 @@ async fn real_claude_task_in_a_worktree() {
models_file: tmp.path().join("models.json"),
custom_agents_dir: None,
home_dir: None,
env_secrets: None,
},
tokio::runtime::Handle::current(),
);
@@ -2243,6 +2264,7 @@ async fn reload_providers_updates_registry_install_clears_stale_probes_and_notif
models_file: tmp.path().join("models.json"),
custom_agents_dir: Some(agents_dir.clone()),
home_dir: None,
env_secrets: None,
},
tokio::runtime::Handle::current(),
connector,
@@ -2553,6 +2575,7 @@ async fn delete_running_agent(no_load: bool) {
models_file: root.join("models.json"),
custom_agents_dir: None,
home_dir: None,
env_secrets: None,
},
tokio::runtime::Handle::current(),
connector,
@@ -3187,3 +3210,43 @@ async fn security_policy_auto_approves_whitelist_and_rejects_blacklist() {
"{blocked_entries:?}"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn security_policy_never_auto_approves_a_request_that_only_reads_like_a_command() {
use ide_core::settings::AgentSecuritySettings;
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent::default());
let mgr = manager(tmp.path(), agent);
mgr.set_security_hook(Arc::new(|| AgentSecuritySettings {
allowed_commands: vec!["cargo test".into()],
blocked_commands: vec![],
}));
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("escalate"),
provider: "claude".into(),
config: vec![],
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
let entries = mgr.entries(&task.id, None).unwrap();
let pending = entries
.iter()
.find_map(|e| match e {
AgentEntry::Permission { id, chosen: None, .. } => Some(*id),
_ => None,
})
.unwrap_or_else(|| panic!("the request was answered without the user: {entries:?}"));
mgr.respond(&task.id, pending, Some("no".into())).unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
let entries = mgr.entries(&task.id, None).unwrap();
assert!(
entries.iter().any(|e| matches!(e, AgentEntry::Agent { text } if text == "escalation chose no")),
"{entries:?}"
);
}
+1
View File
@@ -438,6 +438,7 @@ mod tests {
t.push(AgentEntry::Permission {
id: 1,
title: "Run?".into(),
command: None,
options: vec![],
questions: vec![],
chosen: None,
+149 -2
View File
@@ -358,6 +358,38 @@ impl TaskBase {
}
}
/// One side of an Accept (the task's worktree or the checkout): where its files really are, and
/// git's own folders for it.
struct Confined {
root: PathBuf,
metadata: Vec<PathBuf>,
}
impl Confined {
fn new(root: &Path, repo: Option<&Repo>) -> IdeResult<Self> {
let metadata = match repo {
Some(repo) => repo.metadata_dirs()?.iter().map(|d| ide_core::real_path::resolve_real(d)).collect(),
None => Vec::new(),
};
Ok(Self { root: dunce::canonicalize(root)?, metadata })
}
/// Whether `rel` may be read or written here: it doesn't name git's metadata under any
/// spelling (case, trailing dots or spaces, stream suffixes, short names), stays in the tree
/// once links are followed, and isn't a link itself.
fn allows(&self, rel: &str) -> bool {
use ide_core::real_path::{names_git_dir, reaches_git_dir, resolve_real};
let path = self.root.join(rel);
if names_git_dir(Path::new(rel)) || path.symlink_metadata().is_ok_and(|m| m.file_type().is_symlink()) {
return false;
}
let real = resolve_real(&path);
real.starts_with(&self.root)
&& !reaches_git_dir(&self.root, &real)
&& !self.metadata.iter().any(|dir| real.starts_with(dir))
}
}
/// Copies the task's `changes` (against `base`) from the worktree `cwd` into the
/// local checkout `root`, uncommitted: the user reviews and commits them with the
/// VCS tools. Files the checkout changed itself are left alone and reported, so
@@ -370,7 +402,13 @@ pub(crate) fn accept(
) -> IdeResult<crate::AgentAcceptOutcome> {
let repo = repo_at(cwd)?;
// Change paths are repository-relative, and the task's project may be a folder inside it.
let local_root = discover(root)?.map_or_else(|| root.to_path_buf(), |r| r.root().to_path_buf());
let local = discover(root)?;
let local_root = local.as_ref().map_or_else(|| root.to_path_buf(), |r| r.root().to_path_buf());
// Paths come from what the agent left in its worktree: neither side may lead into git's own
// files or out through a link.
let task_side = Confined::new(repo.root(), Some(&repo))?;
let checkout_side = Confined::new(&local_root, local.as_ref())?;
let copyable = |rel: &str| task_side.allows(rel) && checkout_side.allows(rel);
let renamed_from: BTreeMap<String, String> =
repo.diff_with_worktree(base)?.into_iter().filter_map(|f| Some((f.path, f.old_path?))).collect();
// Brings one path of the checkout to the task's version, unless the checkout changed it itself.
@@ -411,13 +449,25 @@ pub(crate) fn accept(
path: path.to_owned(),
reason: "the checkout has changes of its own to it; accept it by hand".to_owned(),
};
let refuse = |path: &str| crate::AgentAcceptSkipped {
path: path.to_owned(),
reason: "it leads into git's own files or through a link, so it is not copied".to_owned(),
};
for change in changes {
let old = renamed_from.get(&change.rel_path);
if !copyable(&change.rel_path) {
skipped.push(refuse(&change.rel_path));
continue;
}
if let Some(old) = old.filter(|old| !copyable(old)) {
skipped.push(refuse(old));
continue;
}
// A case-only rename (`a.txt` → `A.txt`). Where the disk resolves both
// spellings to one file it is renamed in place and `apply` then refreshes
// the content. On a case-sensitive disk the two names are different files:
// the old one's unchanged-ness (it is the file the checkout had) decides —
// then the task's version lands at the new name and the old one goes.
let old = renamed_from.get(&change.rel_path);
let case_only =
old.is_some_and(|o| o != &change.rel_path && o.to_lowercase() == change.rel_path.to_lowercase());
if case_only {
@@ -910,6 +960,103 @@ mod tests {
assert_eq!(names, ["A.txt"]);
}
#[cfg(windows)]
#[test]
fn accept_does_not_write_into_the_checkouts_git_folder_through_its_short_name() {
let (_tmp, repo, created) = accept_repo();
if !repo.join("GIT~1").join("HEAD").is_file() {
eprintln!("8.3 short names are disabled on this volume; nothing to check");
return;
}
// Written in the worktree under the name the checkout's `.git` folder also has.
let wt = &created.path;
std::fs::rename(wt.join(".git"), wt.join("gitfile")).unwrap();
std::fs::create_dir_all(wt.join("GIT~1").join("hooks")).unwrap();
std::fs::write(wt.join("GIT~1").join("hooks").join("pre-commit"), "#!/bin/sh\nexit 0\n").unwrap();
std::fs::rename(wt.join("gitfile"), wt.join(".git")).unwrap();
std::fs::write(wt.join("a.txt"), "one\nTWO\n").unwrap();
let changes = listed(wt, &created.base_commit);
assert!(changes.iter().any(|c| c.rel_path == "GIT~1/hooks/pre-commit"), "control: git lists it");
let outcome = accept(&repo, wt, &created.base_commit, &changes).unwrap();
assert!(!repo.join(".git").join("hooks").join("pre-commit").exists(), "{outcome:?}");
assert_eq!(outcome.accepted, ["a.txt"]);
assert_eq!(outcome.skipped.iter().map(|s| s.path.as_str()).collect::<Vec<_>>(), ["GIT~1/hooks/pre-commit"]);
}
#[test]
fn accept_refuses_paths_that_lead_into_git_metadata_under_any_spelling() {
let (tmp, repo, _created) = accept_repo();
let checkout = Confined::new(&repo, Repo::discover(&repo).unwrap().as_ref()).unwrap();
for rel in [
".git/hooks/pre-commit",
".GIT/hooks/pre-commit",
".git./hooks/pre-commit",
".git /hooks/pre-commit",
".git::$INDEX_ALLOCATION/hooks/pre-commit",
"sub/.git/config",
"../outside.txt",
] {
assert!(!checkout.allows(rel), "{rel}");
}
assert!(checkout.allows("src/new.txt"));
assert!(checkout.allows(".github/workflows/ci.yml"));
// git's folder under another name inside the tree (`--separate-git-dir`).
let separate = tmp.path().join("separate");
std::fs::create_dir(&separate).unwrap();
git(&separate, &["init", "-q", "--separate-git-dir", "meta", "-b", "main"]);
let side = Confined::new(&separate, Repo::discover(&separate).unwrap().as_ref()).unwrap();
assert!(!side.allows("meta/hooks/pre-commit"));
assert!(side.allows("metadata.txt"));
}
/// A link the task left in its worktree, or a linked folder in the checkout: neither is followed.
#[test]
fn accept_does_not_follow_links_on_either_side() {
let (tmp, repo, created) = accept_repo();
let outside = tmp.path().join("outside");
std::fs::create_dir(&outside).unwrap();
std::fs::write(outside.join("secret.txt"), "secret\n").unwrap();
// A folder link in the checkout (a junction on Windows, which needs no privilege).
#[cfg(unix)]
std::os::unix::fs::symlink(&outside, repo.join("linked")).unwrap();
#[cfg(windows)]
assert!(
std::process::Command::new("cmd")
.args(["/c", "mklink", "/J"])
.arg(repo.join("linked"))
.arg(&outside)
.output()
.unwrap()
.status
.success()
);
std::fs::create_dir(created.path.join("linked")).unwrap();
std::fs::write(created.path.join("linked/new.txt"), "x\n").unwrap();
std::fs::write(created.path.join("a.txt"), "one\nTWO\n").unwrap();
// A file link in the worktree, where the platform lets this user create one.
#[cfg(unix)]
let file_link = std::os::unix::fs::symlink(outside.join("secret.txt"), created.path.join("copied.txt"));
#[cfg(windows)]
let file_link = std::os::windows::fs::symlink_file(outside.join("secret.txt"), created.path.join("copied.txt"));
if let Err(e) = &file_link {
eprintln!("no file link ({e}); checking the folder link only");
}
let changes: Vec<_> = listed(&created.path, &created.base_commit);
let outcome = accept(&repo, &created.path, &created.base_commit, &changes).unwrap();
assert!(!outside.join("new.txt").exists(), "{outcome:?}");
assert!(!repo.join("copied.txt").exists(), "{outcome:?}");
assert!(outcome.accepted.contains(&"a.txt".to_owned()));
let refused: Vec<_> = outcome.skipped.iter().map(|s| s.path.as_str()).collect();
if file_link.is_ok() {
assert_eq!(refused, ["copied.txt", "linked/new.txt"]);
} else {
assert_eq!(refused, ["linked/new.txt"]);
}
}
#[test]
fn accept_copies_new_modified_and_deleted_files_into_the_checkout() {
let (_tmp, repo, created) = accept_repo();
+143 -12
View File
@@ -16,6 +16,7 @@ use std::io::Read;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
pub mod completion;
pub mod sources;
/// Lets the IDE's own Maven runs (Reload, Build) use what the local repository already
@@ -299,7 +300,7 @@ fn read_plugin(e: &Element) -> Option<RawPlugin> {
})
}
fn read_properties(e: Option<&Element>) -> Vec<(String, String)> {
pub(crate) fn read_properties(e: Option<&Element>) -> Vec<(String, String)> {
e.map(|p| p.children.iter().map(|c| (c.name.clone(), c.text.trim().to_owned())).collect()).unwrap_or_default()
}
@@ -1679,6 +1680,15 @@ pub enum PomGotoKind {
ManagedDependency,
/// A `<pluginManagement>` entry providing the plugin's version.
ManagedPlugin,
/// The POM of the artifact itself: a project module's, else the local repository's.
ArtifactPom,
}
/// A Maven project of the workspace, for going to a dependency on it.
pub struct ModulePom {
pub group_id: String,
pub artifact_id: String,
pub pom: PathBuf,
}
/// Byte offset of `pos` (0-based line / UTF-16 column), clamped to `text`.
@@ -1729,7 +1739,7 @@ fn property_ref_at(text: &str, at: usize) -> Option<(&str, usize, usize)> {
}
/// The elements containing `at`, from the root down to the innermost one.
fn element_path(root: &Element, at: usize) -> Vec<&Element> {
pub(crate) fn element_path(root: &Element, at: usize) -> Vec<&Element> {
let mut path = vec![root];
loop {
let Some(next) = path.last().unwrap().children.iter().find(|c| c.start <= at && at < c.end) else {
@@ -1758,6 +1768,20 @@ fn coordinates(e: &Element, props: &HashMap<String, String>) -> Option<(String,
/// the local repository when it is not in the project). `None` when there is no
/// resolvable declaration here.
pub fn goto_declaration(text: &str, path: &Path, pos: Pos, local_repo: Option<PathBuf>) -> Option<PomGoto> {
goto_declaration_in(text, path, pos, local_repo, Vec::new)
}
/// [`goto_declaration`], where a `<parent>` and a dependency or plugin whose version no
/// management entry provides (or a management entry itself) go to the artifact's own
/// POM: the project module among `modules` with its coordinates, else the local
/// repository's POM of its version.
pub fn goto_declaration_in(
text: &str,
path: &Path,
pos: Pos,
local_repo: Option<PathBuf>,
modules: impl FnOnce() -> Vec<ModulePom>,
) -> Option<PomGoto> {
let root = xml::parse(text).filter(|e| e.name == "project")?;
let at = offset_at(text, pos);
if let Some((name, _, _)) = property_ref_at(text, at)
@@ -1768,15 +1792,56 @@ pub fn goto_declaration(text: &str, path: &Path, pos: Pos, local_repo: Option<Pa
// A `<dependency>` / `<plugin>` in a declaration position: its version may come
// from a management entry of this POM or an ancestor's.
let elements = element_path(&root, at);
let e = elements.iter().rev().find(|e| e.name == "dependency" || e.name == "plugin")?;
if elements.iter().any(|a| a.name == "dependencyManagement" || a.name == "pluginManagement") {
return None; // Management entries are declarations; there is nothing to go to.
if elements.get(1).is_some_and(|e| e.name == "parent") {
let chain = parent_chain(path, text, local_repo);
let (pom, parent_text) = chain.get(1)?;
let label = elements[1].text_of("artifactId").unwrap_or_default();
return Some(pom_goto(pom.clone(), parent_text, label));
}
let kind = match e.name.as_str() {
"dependency" => PomGotoKind::ManagedDependency,
_ => PomGotoKind::ManagedPlugin,
};
managed_target(text, path, e, kind, local_repo)
let e = elements.iter().rev().find(|e| e.name == "dependency" || e.name == "plugin")?;
// A management entry declares the version: it goes to the artifact (a BOM import, say).
if !elements.iter().any(|a| a.name == "dependencyManagement" || a.name == "pluginManagement") {
let kind = match e.name.as_str() {
"dependency" => PomGotoKind::ManagedDependency,
_ => PomGotoKind::ManagedPlugin,
};
if let Some(found) = managed_target(text, path, e, kind, local_repo.clone()) {
return Some(found);
}
}
artifact_target(text, path, e, local_repo, modules)
}
/// The artifact's own POM for a dependency or plugin element `e`.
fn artifact_target(
text: &str,
path: &Path,
e: &Element,
local_repo: Option<PathBuf>,
modules: impl FnOnce() -> Vec<ModulePom>,
) -> Option<PomGoto> {
let mut resolver = Resolver::new(local_repo.clone(), &[]);
let chain = parent_chain(path, text, local_repo);
let props = properties_at(&mut resolver, &chain, 0);
let (group, artifact) = coordinates(e, &props)?;
let label = format!("{group}:{artifact}");
if let Some(module) = modules().into_iter().find(|m| m.group_id == group && m.artifact_id == artifact) {
let module_text = xml::read_text(&module.pom)?;
return Some(pom_goto(module.pom, &module_text, label));
}
let version = interpolate(&e.text_of("version")?, &props);
let pom = resolver.repo_pom(&group, &artifact, &version)?;
let pom_text = xml::read_text(&pom)?;
Some(pom_goto(pom, &pom_text, label))
}
/// The POM at `path` as a target, at its `<artifactId>`.
fn pom_goto(path: PathBuf, text: &str, label: String) -> PomGoto {
let range = xml::parse(text)
.filter(|e| e.name == "project")
.and_then(|root| root.child("artifactId").map(|a| range_of(text, a)))
.unwrap_or_default();
PomGoto { path, range, kind: PomGotoKind::ArtifactPom, label }
}
/// The parent POMs of the POM in `text` at `path`, child-first (its `<parent>` refs,
@@ -3086,9 +3151,9 @@ mod tests {
let text = std::fs::read_to_string(&path).unwrap();
// The property is declared nowhere along the chain.
assert_eq!(goto_declaration(&text, &path, at(&text, "${missing.key}"), None), None);
// The dependency names its own version: it is the declaration.
// The dependency names its own version and no local repository has its POM.
assert_eq!(goto_declaration(&text, &path, at(&text, "<artifactId>other-lib"), None), None);
// A management entry is a declaration itself, not a reference.
// A management entry declares the version; its artifact's POM is not at hand either.
let parent_path = tmp.path().join("pom.xml");
let parent_text = std::fs::read_to_string(&parent_path).unwrap();
assert_eq!(
@@ -3100,6 +3165,72 @@ mod tests {
assert_eq!(goto_declaration("<project></project>", &path, Pos::new(0, 3), None), None);
}
#[test]
fn artifacts_go_to_their_own_pom_in_the_project_or_the_local_repository() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("repo");
let bom_dir = repo.join("com/alibaba/cloud/spring-cloud-alibaba-dependencies/2.2.6.RELEASE");
std::fs::create_dir_all(&bom_dir).unwrap();
let bom = "<project>\n <groupId>com.alibaba.cloud</groupId>\n <artifactId>spring-cloud-alibaba-dependencies</artifactId>\n</project>";
std::fs::write(bom_dir.join("spring-cloud-alibaba-dependencies-2.2.6.RELEASE.pom"), bom).unwrap();
let parent = format!(
r#"{PARENT_HEADER}<properties><alibaba.version>2.2.6.RELEASE</alibaba.version></properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-alibaba-dependencies</artifactId>
<version>${{alibaba.version}}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
</project>"#
);
let child = format!(
r#"{CHILD_HEADER}<dependencies>
<dependency>
<groupId>com.example</groupId>
<artifactId>core</artifactId>
<version>1.0</version>
</dependency>
</dependencies>
</project>"#
);
write_parent_child(tmp.path(), &parent, &child);
let core = tmp.path().join("core/pom.xml");
std::fs::create_dir_all(core.parent().unwrap()).unwrap();
std::fs::write(
&core,
"<project>\n <groupId>com.example</groupId>\n <artifactId>core</artifactId>\n</project>",
)
.unwrap();
let modules =
|| vec![ModulePom { group_id: "com.example".into(), artifact_id: "core".into(), pom: core.clone() }];
// A BOM import in <dependencyManagement> opens the BOM from the local repository.
let parent_path = tmp.path().join("pom.xml");
let parent_text = std::fs::read_to_string(&parent_path).unwrap();
let at_bom = at(&parent_text, "spring-cloud-alibaba-dependencies<");
let goto = goto_declaration_in(&parent_text, &parent_path, at_bom, Some(repo.clone()), modules).unwrap();
assert_eq!(goto.path, bom_dir.join("spring-cloud-alibaba-dependencies-2.2.6.RELEASE.pom"));
assert_eq!(goto.kind, PomGotoKind::ArtifactPom);
assert_eq!(goto.range.start, pos_at(bom, bom.find("<artifactId>").unwrap()));
// A dependency on a project module opens the module's POM.
let path = tmp.path().join("app/pom.xml");
let text = std::fs::read_to_string(&path).unwrap();
let goto =
goto_declaration_in(&text, &path, at(&text, "core</artifactId>"), Some(repo.clone()), modules).unwrap();
assert_eq!((goto.path.as_path(), goto.label.as_str()), (core.as_path(), "com.example:core"));
// The <parent> section opens the parent POM.
let goto = goto_declaration_in(&text, &path, at(&text, "parent</artifactId>"), Some(repo), modules).unwrap();
assert_eq!(goto.path, canon(&parent_path));
assert_eq!(goto.kind, PomGotoKind::ArtifactPom);
}
#[test]
fn a_property_reference_after_non_ascii_text_is_still_found() {
let text = format!(
+677
View File
@@ -0,0 +1,677 @@
//! Completion of Maven coordinates in a POM: inside a `<dependency>`, the
//! `<groupId>`, `<artifactId>` and `<version>` values are completed from the
//! local repository layout (`<repo>/<group path>/<artifactId>/<version>/…`).
//! The Maven Central search endpoint tops the lists up remotely; the host op
//! fetches those (cached) and merges them behind the local ones, so this side
//! stays pure text and directory logic and tests build their own repositories.
use super::xml::{self, Element};
use super::{element_path, interpolate, read_properties};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::cmp::Ordering;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
/// The coordinate element a POM completion fills.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum PomCompletionKind {
/// The dependency's `<groupId>`.
GroupId,
/// The dependency's `<artifactId>`.
ArtifactId,
/// The dependency's `<version>`.
Version,
}
/// One suggestion: the coordinate as inserted, and an optional note shown in
/// the lookup (the owning group of an artifactId, its newest installed version).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct PomCompletionOption {
/// The text the completion inserts (`org.example.lib`, `example-lib`, `1.2.0`).
pub label: String,
pub detail: Option<String>,
}
/// The completion offered at a caret of a POM: the replaced value span (UTF-16
/// offsets) and the suggestions, local ones first.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct PomCompletion {
/// UTF-16 offset where the completed text starts: the value's first character.
pub from: u32,
/// UTF-16 offset where the replaced value ends: past the caret when the caret
/// sits inside an existing value, which is then replaced whole.
pub to: u32,
/// Which coordinate element the caret is in.
pub kind: PomCompletionKind,
/// The suggestions, local repository first.
pub options: Vec<PomCompletionOption>,
}
/// Where the caret sits in a `<dependency>` coordinate element: what is being
/// completed, the typed prefix, and the sibling coordinates the rest need.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PomCompletionTarget {
/// UTF-16 offset of the value's first character (the caret in an empty value).
pub from: u32,
/// UTF-16 offset past the value's last character (the caret in an empty value).
pub to: u32,
/// The value typed so far: the text between `from` and the caret, trimmed.
pub typed: String,
pub kind: PomCompletionKind,
/// The interpolated `<groupId>` sibling, when completing the artifactId and
/// the group is written out plain (no unresolved `${property}`).
pub group: Option<String>,
/// The interpolated `<artifactId>` sibling, when completing the version.
pub artifact: Option<String>,
}
/// A coordinate element's text, with the POM's own `${properties}` replaced;
/// `None` when the element is missing or a property stays unresolved (the
/// coordinates behind it are not known without the parent chain, which the
/// completion does not follow).
fn coordinate(e: Option<&Element>, props: &HashMap<String, String>) -> Option<String> {
let text = interpolate(e?.text.trim(), props);
(!text.is_empty() && !text.contains("${")).then_some(text)
}
fn own_properties(root: &Element) -> HashMap<String, String> {
read_properties(root.child("properties")).into_iter().collect()
}
/// UTF-16 offset -> byte offset, clamped to `text`.
fn utf16_to_byte(text: &str, offset: u32) -> usize {
let mut seen = 0u32;
for (byte, c) in text.char_indices() {
if seen >= offset {
return byte;
}
seen += c.len_utf16() as u32;
}
text.len()
}
/// Byte offset -> UTF-16 offset.
fn utf16_at(text: &str, byte: usize) -> u32 {
text[..byte].encode_utf16().count() as u32
}
/// Byte offset just past the start tag's `>` of the element at `start`.
fn start_tag_end(text: &str, start: usize) -> Option<usize> {
let bytes = text.as_bytes();
let mut i = start.checked_add(1)?;
let mut quote = None;
while i < bytes.len() {
match (quote, bytes[i]) {
(Some(q), b) if b == q => quote = None,
(Some(_), _) => {}
(None, b'"' | b'\'') => quote = Some(bytes[i]),
(None, b'>') => return Some(i + 1),
_ => {}
}
i += 1;
}
None
}
/// What to complete at the caret (`offset`, UTF-16) of the POM `text`: the
/// `<groupId>` / `<artifactId>` / `<version>` element of a `<dependency>` the
/// caret sits in the value of. `None` everywhere else — other elements, carets
/// in an end tag or a `${property}` reference, dependencies without the
/// `<groupId>` (and, for versions, the `<artifactId>`) their completions need.
pub fn completion_target(text: &str, offset: u32) -> Option<PomCompletionTarget> {
let root = xml::parse(text).filter(|e| e.name == "project")?;
let at = utf16_to_byte(text, offset);
// A caret at the very end of the input sits in the element still being
// typed, whose range reaches to the input's end; step back one character
// so `element_path` sees it (the caret's own offset stays `at`).
let probe = if at == text.len() && at > 0 {
let mut p = at - 1;
while p > 0 && !text.is_char_boundary(p) {
p -= 1;
}
p
} else {
at
};
let elements = element_path(&root, probe);
let e = *elements.last()?;
let parent = elements.get(elements.len().checked_sub(2)?)?;
let kind = match e.name.as_str() {
"groupId" => PomCompletionKind::GroupId,
"artifactId" => PomCompletionKind::ArtifactId,
"version" => PomCompletionKind::Version,
_ => return None,
};
if parent.name != "dependency" || at > e.inner_end {
return None;
}
let inner_start = start_tag_end(text, e.start)?;
let value = &text[inner_start..e.inner_end.max(inner_start)];
if value.contains("${") {
return None; // Property references are goto-declaration territory, not completion.
}
let lead = value.len() - value.trim_start().len();
let trail = value.len() - value.trim_end().len();
let (mut from, mut to) = (inner_start + lead, e.inner_end.max(inner_start) - trail);
if from > to {
// A value of whitespace only: complete at the caret.
from = at.min(to);
to = to.max(from);
}
from = from.min(at);
to = to.max(from);
let props = own_properties(&root);
let group = (kind != PomCompletionKind::GroupId).then(|| coordinate(parent.child("groupId"), &props)).flatten();
let artifact =
(kind == PomCompletionKind::Version).then(|| coordinate(parent.child("artifactId"), &props)).flatten();
let typed = text[from..at].trim().to_owned();
Some(PomCompletionTarget { from: utf16_at(text, from), to: utf16_at(text, to), typed, kind, group, artifact })
}
/// The suggestions the local repository at `repo` offers for `target`: group
/// ids installed under it, the artifacts of the target's group, or the versions
/// of the target's artifact. Every scan is bounded (see `SCAN_BUDGET`) and
/// reads only directory listings of `repo` — nothing outside it, no jar
/// contents.
pub fn local_options(repo: &Path, target: &PomCompletionTarget) -> Vec<PomCompletionOption> {
let mut budget = SCAN_BUDGET;
match target.kind {
// `org.spr` completes the `spr` part under `org/`, `org.` the next
// segment under `org/`; every typed segment is validated so POM text
// cannot walk out of the repository.
PomCompletionKind::GroupId => {
let segments: Vec<&str> = target.typed.split('.').collect();
let (partial, base) = segments.split_last().unwrap_or((&"", &[]));
if base.iter().all(|s| segment(s)) {
let dir = base.iter().fold(repo.to_path_buf(), |p, s| p.join(s));
scan_groups(&dir, &base.join("."), partial, &mut budget)
} else {
Vec::new()
}
}
PomCompletionKind::ArtifactId => target
.group
.as_deref()
.and_then(|g| repo_dir(repo, &[g]))
.map_or_else(Vec::new, |dir| scan_artifacts(&dir, &target.typed, &mut budget)),
PomCompletionKind::Version => target
.group
.as_deref()
.zip(target.artifact.as_deref())
.and_then(|(g, a)| repo_dir(repo, &[g, a]))
.map_or_else(Vec::new, |dir| scan_versions(&dir, &target.typed, &mut budget)),
}
}
/// Directory listings one completion may read: the scans are interactive, so
/// they stop before they could be felt, and a huge repository simply yields
/// fewer suggestions (its shallowest first — the walk is breadth-preferring).
const SCAN_BUDGET: u32 = 2048;
/// Suggestion caps per kind: group ids and artifact ids fill a lookup page,
/// versions list an artifact's releases.
const MAX_GROUP_OPTIONS: usize = 60;
const MAX_ARTIFACT_OPTIONS: usize = 60;
const MAX_VERSION_OPTIONS: usize = 100;
/// The cap the merged list (local plus Maven Central's) is truncated to.
pub const MAX_OPTIONS: usize = 100;
/// A directory entry whose name passes the repository layout's alphabet
/// (mirrors `sources::artifact`'s): letters, digits, `._-`. Path separators
/// and `..` never pass, so a POM's `${…}`-free text cannot traverse out of
/// the repository.
fn segment(name: &str) -> bool {
!name.is_empty()
&& name != "."
&& name != ".."
&& name.chars().all(|c| c.is_ascii_alphanumeric() || "._-".contains(c))
}
/// The subdirectories of `dir` with repository-layout names, spending `budget`
/// on the listing; `None` when the budget ran out before `dir` was listed.
fn subdirs(dir: &Path, budget: &mut u32) -> Option<Vec<String>> {
if *budget == 0 {
return None;
}
*budget -= 1;
let Ok(entries) = std::fs::read_dir(dir) else {
return Some(Vec::new());
};
let mut names = Vec::new();
for entry in entries.flatten() {
// The budget bounds file-type checks too: a plain listing answers.
if entry.file_type().map(|t| t.is_dir()).unwrap_or(false)
&& let Ok(name) = entry.file_name().into_string()
&& segment(&name)
{
names.push(name);
}
}
Some(names)
}
/// Whether `dir` holds files — a version directory does (jar, pom, the
/// tracking files), a group path segment never does.
fn holds_files(dir: &Path, budget: &mut u32) -> bool {
if *budget == 0 {
return false;
}
*budget -= 1;
let Ok(entries) = std::fs::read_dir(dir) else {
return false;
};
entries.flatten().any(|e| e.file_type().map(|t| t.is_file()).unwrap_or(false))
}
/// Whether the version directory `dir` holds the version: its `.pom` or a `.jar`. A failed
/// download leaves only `*.lastUpdated` tracking files behind, for a version that may not
/// even exist (one typed while the POM was being edited).
fn installed(dir: &Path, budget: &mut u32) -> bool {
if *budget == 0 {
return false;
}
*budget -= 1;
let Ok(entries) = std::fs::read_dir(dir) else {
return false;
};
entries.flatten().any(|e| {
e.file_type().map(|t| t.is_file()).unwrap_or(false)
&& e.file_name().to_str().is_some_and(|n| n.ends_with(".pom") || n.ends_with(".jar"))
})
}
/// Whether `dir` is an artifact directory: it holds versions of one artifact,
/// recognizable by a `maven-metadata*.xml` beside them or a child directory
/// holding files. A group segment's children are directories without files.
fn artifact_dir(dir: &Path, budget: &mut u32) -> bool {
if *budget == 0 {
return false;
}
*budget -= 1;
let Ok(entries) = std::fs::read_dir(dir) else {
return false;
};
for entry in entries.flatten() {
let Ok(name) = entry.file_name().into_string() else { continue };
if name.starts_with("maven-metadata") {
return true;
}
}
let Some(children) = subdirs(dir, budget) else { return false };
children.iter().any(|c| holds_files(&dir.join(c), budget))
}
/// Whether `dir` is a group directory (holds artifact directories) and whether
/// it also holds deeper group segments worth descending into.
fn group_dir(dir: &Path, budget: &mut u32) -> Option<(bool, bool)> {
let children = subdirs(dir, budget)?;
let (mut group, mut descend) = (false, false);
for child in &children {
if artifact_dir(&dir.join(child), budget) {
group = true;
} else {
descend = true;
}
}
Some((group, descend))
}
/// The group ids installed under `dir` (which the typed prefix `prefix` —
/// "" for the repository root — already names), completing the partial last
/// segment `partial`, shallower groups first.
fn scan_groups(dir: &Path, prefix: &str, partial: &str, budget: &mut u32) -> Vec<PomCompletionOption> {
let mut out = Vec::new();
scan_groups_into(dir, prefix, partial, &mut out, budget);
out.sort_by(|a, b| a.label.cmp(&b.label));
out
}
fn scan_groups_into(dir: &Path, prefix: &str, partial: &str, out: &mut Vec<PomCompletionOption>, budget: &mut u32) {
if out.len() >= MAX_GROUP_OPTIONS {
return;
}
let Some(children) = subdirs(dir, budget) else { return };
for name in children {
if !name.starts_with(partial) {
continue;
}
let path = dir.join(&name);
let label = if prefix.is_empty() { name.clone() } else { format!("{prefix}.{name}") };
let Some((group, descend)) = group_dir(&path, budget) else { return };
if group {
out.push(PomCompletionOption { label: label.clone(), detail: None });
if out.len() >= MAX_GROUP_OPTIONS {
return;
}
}
// Deeper groups (org.example under org.example's own parent) follow the
// shallower ones, so a truncated scan of a huge repository still offers
// every first-level segment.
if descend {
scan_groups_into(&path, &label, "", out, budget);
}
}
}
/// The artifact ids installed under the group directory `dir`, completing the
/// typed prefix, each with its newest installed version as the detail.
fn scan_artifacts(dir: &Path, typed: &str, budget: &mut u32) -> Vec<PomCompletionOption> {
let Some(children) = subdirs(dir, budget) else { return Vec::new() };
let mut out = Vec::new();
for name in children {
if !name.starts_with(typed) || !artifact_dir(&dir.join(&name), budget) {
continue;
}
let detail = newest_version(&dir.join(&name), budget);
out.push(PomCompletionOption { label: name, detail });
}
out.sort_by(|a, b| a.label.cmp(&b.label));
out.truncate(MAX_ARTIFACT_OPTIONS);
out
}
/// The newest version directory of the artifact at `dir`, by Maven's ordering.
fn newest_version(dir: &Path, budget: &mut u32) -> Option<String> {
let children = subdirs(dir, budget)?;
children.into_iter().filter(|name| installed(&dir.join(name), budget)).max_by(|a, b| compare_versions(a, b))
}
/// The versions installed of the artifact at `dir`, completing the typed
/// prefix, newest first.
fn scan_versions(dir: &Path, typed: &str, budget: &mut u32) -> Vec<PomCompletionOption> {
let Some(children) = subdirs(dir, budget) else { return Vec::new() };
let mut out: Vec<_> = children
.into_iter()
.filter(|name| name.starts_with(typed) && installed(&dir.join(name), budget))
.map(|name| PomCompletionOption { label: name, detail: None })
.collect();
out.sort_by(|a, b| compare_versions(&b.label, &a.label));
out.truncate(MAX_VERSION_OPTIONS);
out
}
/// The repository directory of the `/`-separated `coordinates` (a group id, or
/// a group and an artifact id), each segment validated against the layout's
/// alphabet so POM text cannot walk outside `repo`.
fn repo_dir(repo: &Path, coordinates: &[&str]) -> Option<PathBuf> {
let segments: Vec<&str> = coordinates.iter().flat_map(|c| c.split('.')).collect();
segments.iter().all(|s| segment(s)).then(|| segments.iter().fold(repo.to_path_buf(), |p, s| p.join(s)))
}
/// Maven-ish version order, best-effort (a full `ComparableVersion` is out of
/// proportion for ordering a lookup): numeric runs compare numerically, letter
/// runs by their qualifier rank (`alpha < beta < m < rc < snapshot < release <
/// sp`, unknown ones with releases lexically); missing parts rank as zero.
fn compare_versions(a: &str, b: &str) -> Ordering {
fn tokens(v: &str) -> Vec<String> {
v.split(|c: char| !c.is_ascii_alphanumeric()).filter(|t| !t.is_empty()).map(str::to_ascii_lowercase).collect()
}
fn rank(t: &str) -> Option<u8> {
match t {
"a" | "alpha" => Some(0),
"b" | "beta" => Some(1),
"m" | "milestone" => Some(2),
"rc" | "cr" => Some(3),
"snapshot" => Some(4),
"" | "ga" | "final" => Some(5),
"sp" => Some(6),
_ => None,
}
}
let (a, b) = (tokens(a), tokens(b));
for i in 0..a.len().max(b.len()) {
let (x, y) = (a.get(i).map(String::as_str), b.get(i).map(String::as_str));
let (xn, yn) = (x.and_then(|s| s.parse::<u64>().ok()), y.and_then(|s| s.parse::<u64>().ok()));
let order = match (xn, yn) {
// Numbers compare numerically; a number outranks a qualifier.
(Some(xn), Some(yn)) => xn.cmp(&yn),
(Some(_), None) if y.is_some() => return Ordering::Greater,
(None, Some(_)) if x.is_some() => return Ordering::Less,
_ => {
let xr = x.and_then(rank).unwrap_or(5);
let yr = y.and_then(rank).unwrap_or(5);
if xr != yr { xr.cmp(&yr) } else { x.unwrap_or("").cmp(y.unwrap_or("")) }
}
};
if order != Ordering::Equal {
return order;
}
}
Ordering::Equal
}
#[cfg(test)]
mod tests {
use super::*;
/// A small local repository: every file is laid out as Maven installs it,
/// so the scans recognize group, artifact and version directories.
fn repo(dir: &Path) -> PathBuf {
let repo = dir.join("repository");
for file in [
"org/apache/maven/maven-core/4.0.0-rc-5/maven-core-4.0.0-rc-5.jar",
"org/apache/maven/plugins/maven-compiler-plugin/3.8.1/maven-compiler-plugin-3.8.1.jar",
"org/example/lib/1.0/lib-1.0.jar",
"org/example/lib/1.9.1/lib-1.9.1.jar",
"org/example/lib/1.10/lib-1.10.jar",
"org/example/lib/2.0-beta-1/lib-2.0-beta-1.jar",
"org/example/lib/2.0/lib-2.0.pom",
"net/nu/netty/4.1.100/netty-4.1.100.jar",
// Failed downloads (a version typed while editing): tracking files only, not installed.
"org/example/lib/2/lib-2.jar.lastUpdated",
"org/example/lib/3.0/lib-3.0.pom.lastUpdated",
] {
let path = repo.join(file);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, b"").unwrap();
}
repo
}
fn target(text: &str, needle: &str) -> Option<PomCompletionTarget> {
completion_target(text, (text.find(needle).unwrap() + needle.len()) as u32)
}
#[test]
fn completes_each_coordinate_element_of_a_dependency() {
let text = "<project><dependencies><dependency>\
<groupId>org.example</groupId><artifactId>lib</artifactId><version>1.0</version>\
</dependency></dependencies></project>";
let t = target(text, "<groupId>org.example").unwrap();
assert_eq!(t.kind, PomCompletionKind::GroupId);
assert_eq!(t.typed, "org.example");
assert_eq!((t.from, t.to), (44, 55));
assert!(t.group.is_none() && t.artifact.is_none());
let t = target(text, "<artifactId>lib").unwrap();
assert_eq!(t.kind, PomCompletionKind::ArtifactId);
assert_eq!(t.typed, "lib");
assert_eq!(t.group.as_deref(), Some("org.example"));
assert!(t.artifact.is_none());
let t = target(text, "<version>1.0").unwrap();
assert_eq!(t.kind, PomCompletionKind::Version);
assert_eq!(t.typed, "1.0");
assert_eq!(t.group.as_deref(), Some("org.example"));
assert_eq!(t.artifact.as_deref(), Some("lib"));
}
#[test]
fn completes_while_the_element_is_being_typed_and_the_value_is_empty() {
let empty = "<project><dependencies><dependency><groupId></groupId></dependency></dependencies></project>";
let t = target(empty, "<groupId>").unwrap();
assert_eq!(t.kind, PomCompletionKind::GroupId);
assert_eq!(t.typed, "");
assert_eq!(t.from, t.to);
let open = "<project><dependencies><dependency><artifactId>li";
let t = target(open, "li").unwrap();
assert_eq!(t.kind, PomCompletionKind::ArtifactId);
assert_eq!(t.typed, "li");
// The value under construction ends with the input and is replaced whole.
assert_eq!(t.to, open.len() as u32);
}
#[test]
fn completes_only_dependency_coordinates() {
// The same elements outside a <dependency> get nothing: a <plugin> is
// completed by the repository, not coordinates.
let plugin =
"<project><build><plugins><plugin><groupId>org.apache.maven</groupId></plugin></plugins></build></project>";
assert_eq!(target(plugin, "<groupId>org.apache.maven"), None);
// A caret in the end tag, or on a `${property}` value, is not a completion.
let text =
"<project><dependencies><dependency><groupId>org.example</groupId></dependency></dependencies></project>";
assert_eq!(target(text, "</groupId>"), None);
let property = "<project><dependencies><dependency><groupId>${example.group}</groupId></dependency></dependencies></project>";
assert_eq!(target(property, "${example.group}"), None);
// A dependency's groupId is required before its artifacts can be listed.
let no_group =
"<project><dependencies><dependency><artifactId>lib</artifactId></dependency></dependencies></project>";
let t = target(no_group, "<artifactId>lib").unwrap();
assert!(t.group.is_none());
}
#[test]
fn interpolates_the_poms_own_properties_for_siblings() {
let text = "<project>\
<properties><example.group>org.example</example.group><example.version>2.0</example.version></properties>\
<dependencies><dependency><groupId>${example.group}</groupId><artifactId>lib</artifactId><version>${example.version}</version></dependency></dependencies>\
</project>";
// The groupId the caret is typing stays a completion; the siblings resolve.
let t = target(text, "<artifactId>lib").unwrap();
assert_eq!(t.group.as_deref(), Some("org.example"));
// A `${property}` under the caret itself gets nothing to complete.
assert_eq!(target(text, "<version>${example.version}"), None);
}
#[test]
fn local_scan_lists_installed_groups_shallowest_first() {
let tmp = tempfile::tempdir().unwrap();
let repo = repo(tmp.path());
let options = |typed: &str| {
local_options(
&repo,
&PomCompletionTarget {
from: 0,
to: 0,
typed: typed.into(),
kind: PomCompletionKind::GroupId,
group: None,
artifact: None,
},
)
.into_iter()
.map(|o| o.label)
.collect::<Vec<_>>()
};
// Only directories holding artifacts are group ids: `net` and `org`
// alone never receive an install, their deeper levels do.
assert_eq!(options(""), ["net.nu", "org.apache.maven", "org.apache.maven.plugins", "org.example"]);
assert_eq!(options("org"), ["org.apache.maven", "org.apache.maven.plugins", "org.example"]);
assert_eq!(options("org."), ["org.apache.maven", "org.apache.maven.plugins", "org.example"]);
assert_eq!(options("org.spr"), Vec::<String>::new());
assert_eq!(options("net."), ["net.nu"]);
}
#[test]
fn local_scan_lists_artifacts_of_the_group_with_their_newest_version() {
let tmp = tempfile::tempdir().unwrap();
let repo = repo(tmp.path());
let options = |typed: &str| {
local_options(
&repo,
&PomCompletionTarget {
from: 0,
to: 0,
typed: typed.into(),
kind: PomCompletionKind::ArtifactId,
group: Some("org.example".into()),
artifact: None,
},
)
.into_iter()
.map(|o| (o.label, o.detail))
.collect::<Vec<_>>()
};
assert_eq!(options(""), [("lib".to_owned(), Some("2.0".to_owned()))]);
assert_eq!(options("net"), Vec::<(String, Option<String>)>::new());
}
#[test]
fn local_scan_lists_versions_of_the_artifact_newest_first() {
let tmp = tempfile::tempdir().unwrap();
let repo = repo(tmp.path());
let options = |typed: &str| {
local_options(
&repo,
&PomCompletionTarget {
from: 0,
to: 0,
typed: typed.into(),
kind: PomCompletionKind::Version,
group: Some("org.example".into()),
artifact: Some("lib".into()),
},
)
.into_iter()
.map(|o| o.label)
.collect::<Vec<_>>()
};
assert_eq!(options(""), ["2.0", "2.0-beta-1", "1.10", "1.9.1", "1.0"]);
assert_eq!(options("1."), ["1.10", "1.9.1", "1.0"]);
}
#[test]
fn a_poms_text_cannot_walk_out_of_the_repository() {
let tmp = tempfile::tempdir().unwrap();
let repo = repo(tmp.path());
for group in ["../../etc", "..", "org/../.."] {
let options = local_options(
&repo,
&PomCompletionTarget {
from: 0,
to: 0,
typed: "".into(),
kind: PomCompletionKind::ArtifactId,
group: Some(group.into()),
artifact: None,
},
);
assert!(options.is_empty(), "{group}");
}
}
#[test]
fn the_scan_stops_at_the_option_cap() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("repository");
for i in 0..MAX_GROUP_OPTIONS + 10 {
let group = repo.join(format!("com/example/g{i}"));
std::fs::create_dir_all(group.join("art/1.0")).unwrap();
std::fs::write(group.join("art/1.0/art-1.0.jar"), b"").unwrap();
}
let options = local_options(
&repo,
&PomCompletionTarget {
from: 0,
to: 0,
typed: "".into(),
kind: PomCompletionKind::GroupId,
group: None,
artifact: None,
},
);
assert_eq!(options.len(), MAX_GROUP_OPTIONS);
}
#[test]
fn maven_version_order() {
let mut versions = ["2.0", "1.0", "2.0-beta-1", "2.0-alpha-2", "1.10", "1.9.1", "4.0.0-rc-5", "4.0.0-rc-13"];
versions.sort_by(|a, b| compare_versions(a, b));
assert_eq!(versions, ["1.0", "1.9.1", "1.10", "2.0-alpha-2", "2.0-beta-1", "2.0", "4.0.0-rc-5", "4.0.0-rc-13"]);
}
}
+3
View File
@@ -67,6 +67,8 @@ pub struct EditorAppearanceSettings {
/// Smooth caret movement curve: `snappy` | `gliding`.
pub caret_movement_style: String,
pub line_numbers: bool,
/// Gutter numbering: `absolute` | `relative` | `hybrid` (absolute on the caret line, distances elsewhere).
pub line_number_mode: String,
pub method_separators: bool,
/// Sub-options of `editor.showWhitespace`.
pub whitespace_leading: bool,
@@ -96,6 +98,7 @@ impl Default for EditorAppearanceSettings {
smooth_caret_movement: false,
caret_movement_style: "snappy".into(),
line_numbers: true,
line_number_mode: "absolute".into(),
method_separators: false,
whitespace_leading: true,
whitespace_inner: true,
+1
View File
@@ -21,6 +21,7 @@ pub mod process;
pub mod project_dir;
pub mod project_roots;
pub mod proxy_settings;
pub mod real_path;
pub mod remote_settings;
pub mod settings;
pub mod task;
+53
View File
@@ -0,0 +1,53 @@
//! Where a path really lands, and whether it reaches git's own metadata, for writes that must
//! stay inside a project and out of its `.git`.
use std::path::{Path, PathBuf};
/// `joined` after `..`/symlink resolution as far as the file system allows:
/// the canonical path when it exists, else the canonicalized deepest existing
/// ancestor with the still-missing tail resolved textually — so the location
/// of a missing path is still judged where it would really land.
pub fn resolve_real(joined: &Path) -> PathBuf {
let mut tail = Vec::new();
let mut candidate = joined.to_path_buf();
let mut real = loop {
match dunce::canonicalize(&candidate) {
Ok(real) => break real,
Err(_) => {
let Some(last) = candidate.components().next_back() else {
return joined.to_path_buf();
};
tail.push(last.as_os_str().to_owned());
if !candidate.pop() {
return joined.to_path_buf();
}
}
}
};
for part in tail.iter().rev() {
if part == ".." {
real.pop();
} else if part != "." {
real.push(part);
}
}
real
}
/// Whether a path (relative to the project root) goes through git's own `.git` folder or file.
/// git runs what it finds in `.git` (hooks, `gpg.program`, filters), so a content write must
/// never reach it. Names are normalized for trailing dots and spaces and NTFS stream suffixes;
/// the caller also checks the short-name-resolved path via [`resolve_real`].
pub fn names_git_dir(rel: &Path) -> bool {
rel.components().any(|part| {
let name = part.as_os_str().to_string_lossy();
let name = name.split(':').next().unwrap_or_default().trim_end_matches(['.', ' ']);
name.eq_ignore_ascii_case(".git")
})
}
/// A resolved write destination under `root` that reaches git's own `.git` metadata, judged by
/// the name given and by the name the file system resolves it to.
pub fn reaches_git_dir(root: &Path, real: &Path) -> bool {
real.strip_prefix(root).is_ok_and(names_git_dir) || resolve_real(real).strip_prefix(root).is_ok_and(names_git_dir)
}
+35 -1
View File
@@ -102,6 +102,11 @@ pub struct AgentSettings {
#[serde(deserialize_with = "or_default")]
#[specta(type = WhileRunning)]
pub while_running: WhileRunning,
/// Whether new tasks start in the agent's mode that approves everything (Claude Code's
/// bypass permissions, Codex's full access) or in one that asks. User settings only.
#[serde(deserialize_with = "or_default")]
#[specta(type = NewTaskApprovals)]
pub new_task_approvals: NewTaskApprovals,
/// Settings new tasks of an agent start with: agent id → option id → value.
#[serde(deserialize_with = "agent_defaults")]
#[specta(type = BTreeMap<String, BTreeMap<String, String>>)]
@@ -127,6 +132,7 @@ impl Default for AgentSettings {
expand_tool_output: false,
send_with: SendKey::Enter,
while_running: WhileRunning::Queue,
new_task_approvals: NewTaskApprovals::Bypass,
defaults: BTreeMap::new(),
mcp_servers: BTreeMap::new(),
}
@@ -270,6 +276,17 @@ pub enum WhileRunning {
Steer,
}
/// The approvals new agent tasks start with.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum NewTaskApprovals {
/// The agent's mode that approves everything without asking, when it has one.
#[default]
Bypass,
/// A mode in which the agent asks before edits and commands.
Ask,
}
/// Agents › MCP Server: agents use the IDE's built-in tools.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
@@ -1021,11 +1038,28 @@ pub struct PythonSettings {
pub interpreter: String,
/// Activate the interpreter's virtualenv in new terminal sessions.
pub activate_virtualenv: bool,
/// Interpreters the user added to Settings › Python › All Interpreters (executable paths).
/// Detection contributes the rest; `hidden_interpreters` removes some of them.
pub interpreters: Vec<String>,
/// Interpreters the user removed from that list, as executable paths: detection does
/// not list them again.
pub hidden_interpreters: Vec<String>,
/// Hide interpreters that belong to another project (a virtualenv inside its folder).
pub hide_other_project_interpreters: bool,
/// Show each interpreter's path under its name on the interpreters page.
pub show_interpreter_paths: bool,
}
impl Default for PythonSettings {
fn default() -> Self {
Self { interpreter: String::new(), activate_virtualenv: true }
Self {
interpreter: String::new(),
activate_virtualenv: true,
interpreters: Vec::new(),
hidden_interpreters: Vec::new(),
hide_other_project_interpreters: false,
show_interpreter_paths: true,
}
}
}
+2 -5
View File
@@ -61,16 +61,13 @@ pub fn find_command(command: &str, search: &OsStr, root: &Path) -> Option<PathBu
fn is_executable(path: &Path) -> bool {
let Ok(metadata) = path.metadata() else { return false };
if !metadata.is_file() {
return false;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
metadata.permissions().mode() & 0o111 != 0
metadata.is_file() && metadata.permissions().mode() & 0o111 != 0
}
#[cfg(not(unix))]
true
metadata.is_file()
}
/// [`search_path`]'s directories, in order and once each. Only absolute ones: a
+1 -1
View File
@@ -37,6 +37,6 @@ zeroize = "1"
open = "5"
[dev-dependencies]
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "net"] }
tempfile.workspace = true
ide-ssh = { workspace = true, features = ["testing"] }
+15 -2
View File
@@ -241,6 +241,12 @@ pub enum Pool {
Snowflake(crate::snowflake::SnowflakePool),
}
/// Some servers and proxies (e.g. Aliyun RDS for MySQL 5.6) advertise `CLIENT_DEPRECATE_EOF` but
/// still end result sets with legacy EOF packets, which sqlx then fails to read as OK packets.
fn legacy_eof(e: &sqlx::Error) -> bool {
matches!(e, sqlx::Error::Protocol(msg) if msg.starts_with("OK_Packet too short"))
}
pub(crate) fn db_err(e: sqlx::Error) -> IdeError {
match &e {
sqlx::Error::Database(d) => {
@@ -873,9 +879,16 @@ impl Pool {
if !tz.is_empty() {
opts = opts.timezone(Some(tz.to_owned()));
}
let manager = MysqlManager { options: opts, init: init.clone(), timeout };
let mut manager = MysqlManager { options: opts, init: init.clone(), timeout };
// Open once directly so authentication failures retain their database error.
let first = manager.connect().await.map_err(db_err)?;
let first = match manager.connect().await {
Err(e) if legacy_eof(&e) => {
manager.options = manager.options.clone().deprecate_eof(false);
manager.connect().await
}
first => first,
}
.map_err(db_err)?;
let pool = Bb8Pool::builder().max_size(MAX).connection_timeout(timeout).build_unchecked(manager);
let _ = pool.add(first);
Ok(Pool::Mysql(ServerPool { inner: pool, closed: AtomicBool::new(false) }))
+4 -1
View File
@@ -143,9 +143,12 @@ mod tests {
std::fs::create_dir_all(root.join(".openidea")).unwrap();
let (count, on_change) = counting();
let _watch = SourcesWatch::start(&root, None, on_change).unwrap();
// FSEvents can deliver the creation of `.openidea/` above after the watch started.
std::thread::sleep(Duration::from_secs(1));
let seen = count.load(Ordering::SeqCst);
std::fs::write(root.join(".openidea/settings.toml"), "").unwrap();
std::fs::write(root.join("main.py"), "").unwrap();
std::thread::sleep(SETTLE * 3);
assert_eq!(count.load(Ordering::SeqCst), 0);
assert_eq!(count.load(Ordering::SeqCst), seen);
}
}
+48
View File
@@ -183,3 +183,51 @@ async fn a_reused_physical_connection_restores_the_configured_database() -> IdeR
assert_eq!(pool.execute("SELECT CONNECTION_ID()", 1, &c).await?.rows, backend);
Ok(())
}
/// Forwards to the test server, adding `CLIENT_DEPRECATE_EOF` to the capabilities its greeting
/// advertises, like proxies that set the flag but leave a 5.x server sending legacy EOF packets.
async fn deprecate_eof_proxy(host: String, port: u16) -> u16 {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let local = listener.local_addr().unwrap().port();
tokio::spawn(async move {
while let Ok((mut client, _)) = listener.accept().await {
let mut server = tokio::net::TcpStream::connect((host.as_str(), port)).await.unwrap();
tokio::spawn(async move {
let mut header = [0u8; 4];
server.read_exact(&mut header).await.unwrap();
let len = u32::from_le_bytes([header[0], header[1], header[2], 0]) as usize;
let mut greeting = vec![0u8; len];
server.read_exact(&mut greeting).await.unwrap();
// protocol version, NUL-terminated server version, connection id, 8 bytes of
// auth data, filler, lower capabilities, charset, status, upper capabilities.
let version_end = 1 + greeting[1..].iter().position(|&b| b == 0).unwrap();
let upper = version_end + 1 + 4 + 8 + 1 + 2 + 1 + 2;
greeting[upper + 1] |= 0x01; // bit 24 of the capability flags
client.write_all(&header).await.unwrap();
client.write_all(&greeting).await.unwrap();
let _ = tokio::io::copy_bidirectional(&mut client, &mut server).await;
});
}
});
local
}
#[tokio::test]
async fn servers_that_advertise_deprecate_eof_but_send_legacy_eof_packets() -> IdeResult<()> {
use ide_db::Pool;
let Some((host, port, user, password)) = server() else { return Ok(()) };
let source = DataSource {
driver: DbDriver::Mysql,
host: "127.0.0.1".into(),
port: Some(deprecate_eof_proxy(host, port).await),
user,
..Default::default()
};
let pool = Pool::connect(&source, Some(&password), None).await?;
assert_eq!(pool.rows("SELECT 1 + 1", 1).await?, vec![vec![Some("2".into())]]);
let c = CancellationToken::new();
let r = pool.execute("SELECT 'a' UNION ALL SELECT 'b'", 10, &c).await?;
assert_eq!(r.rows, vec![vec![Some("a".into())], vec![Some("b".into())]]);
Ok(())
}
+2 -5
View File
@@ -458,16 +458,13 @@ fn canonical_dir(p: &Path) -> PathBuf {
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
meta.is_file() && meta.permissions().mode() & 0o111 != 0
}
#[cfg(not(unix))]
true
meta.is_file()
}
/// blake3 of a file, hex.
+1 -1
View File
@@ -34,7 +34,7 @@ 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.6.0";
pub const ENGINE_VERSION: &str = "1.7.0";
/// The 0.1 API, still accepted for contribution-only packages and 0.1 components.
pub const LEGACY_API: &str = "0.1";
/// Marketplace categories, in the order they are shown.
+175
View File
@@ -0,0 +1,175 @@
//! Values coding agents are configured with that may be secrets (MCP servers' environment
//! variables and HTTP headers, agents' environment), kept with the saved passwords in the OS
//! credential store; the files that configure them keep only the names. Without a credential
//! store (a headless Linux host) a value is kept in an owner-only file of the user's data
//! instead, never in a project's files, and the user is told.
use ide_core::event::NotifyLevel;
use ide_core::{IdeEvent, IdeResult, SharedSink};
use ide_ssh::secrets::SecretFile;
use parking_lot::Mutex;
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
pub struct AgentSecrets {
store: SecretFile,
/// Values the credential store refused, by key.
local: PathBuf,
lock: Mutex<()>,
}
/// Where [`AgentSecrets::set`] kept a value.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Kept {
Store,
LocalFile,
}
impl AgentSecrets {
pub fn new(data_dir: &Path) -> Self {
Self::with_store(
SecretFile::new(data_dir.join("agent-secrets.json")),
data_dir.join("agent-secrets.local.json"),
)
}
pub fn with_store(store: SecretFile, local: PathBuf) -> Self {
Self { store, local, lock: Mutex::new(()) }
}
pub fn get(&self, key: &str) -> Option<String> {
self.store.get(key).or_else(|| self.read_local().remove(key))
}
pub fn set(&self, key: &str, value: &str) -> IdeResult<Kept> {
match self.store.set(key, value) {
Ok(()) => {
self.edit_local(|local| local.remove(key).is_some())?;
Ok(Kept::Store)
}
Err(e) => {
tracing::warn!("agent settings: {key} is kept in {}: {}", self.local.display(), e.message);
self.edit_local(|local| local.insert(key.to_owned(), value.to_owned()).as_deref() != Some(value))?;
Ok(Kept::LocalFile)
}
}
}
pub fn delete(&self, key: &str) {
self.store.delete(key);
if let Err(e) = self.edit_local(|local| local.remove(key).is_some()) {
tracing::warn!("agent settings: could not forget {key}: {}", e.message);
}
}
/// The keys that start with `prefix`, sorted.
pub fn keys(&self, prefix: &str) -> Vec<String> {
let keys: BTreeSet<String> = self.store.keys().into_iter().chain(self.read_local().into_keys()).collect();
keys.into_iter().filter(|k| k.starts_with(prefix)).collect()
}
/// Tells the window that values went to the owner-only file rather than the credential store.
pub fn notify_kept_locally(&self, sink: &SharedSink) {
sink.emit(IdeEvent::Notify {
level: NotifyLevel::Warning,
title: "No credential store".into(),
body: Some(format!(
"Values of agent and MCP server settings are kept in {}, readable only by you.",
self.local.display()
)),
});
}
fn read_local(&self) -> BTreeMap<String, String> {
match std::fs::read(&self.local) {
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|e| {
tracing::warn!("agent settings: {}: {e}", self.local.display());
BTreeMap::new()
}),
Err(_) => BTreeMap::new(),
}
}
fn edit_local(&self, edit: impl FnOnce(&mut BTreeMap<String, String>) -> bool) -> IdeResult<()> {
let _g = self.lock.lock();
let mut local = self.read_local();
if !edit(&mut local) {
return Ok(());
}
if local.is_empty() {
return match std::fs::remove_file(&self.local) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e.into()),
_ => Ok(()),
};
}
if let Some(dir) = self.local.parent() {
std::fs::create_dir_all(dir)?;
}
let bytes = serde_json::to_vec_pretty(&local).map_err(|e| ide_core::IdeError::internal(e.to_string()))?;
ide_vfs::write_atomic_private(&self.local, &bytes)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use ide_ssh::secrets::testing::{files_containing, use_memory_store};
use ide_ssh::secrets::{StoreError, StoreResult, Vault};
use std::sync::Arc;
/// A machine without a credential store (a headless Linux host).
struct NoStore;
impl Vault for NoStore {
fn available(&self) -> bool {
false
}
fn get(&self, _: &str, _: &str) -> StoreResult<String> {
Err(StoreError::NoDefaultStore)
}
fn set(&self, _: &str, _: &str, _: &str) -> StoreResult<()> {
Err(StoreError::NoDefaultStore)
}
fn delete(&self, _: &str, _: &str) -> StoreResult<()> {
Err(StoreError::NoDefaultStore)
}
}
#[test]
fn values_go_to_the_credential_store() {
use_memory_store();
let tmp = tempfile::tempdir().unwrap();
let secrets = AgentSecrets::new(tmp.path());
assert_eq!(secrets.set("mcp/user/a/env/TOKEN", "value-7c1e").unwrap(), Kept::Store);
assert_eq!(files_containing(tmp.path(), "value-7c1e"), Vec::<PathBuf>::new());
assert_eq!(AgentSecrets::new(tmp.path()).get("mcp/user/a/env/TOKEN").as_deref(), Some("value-7c1e"));
assert_eq!(secrets.keys("mcp/user/"), ["mcp/user/a/env/TOKEN"]);
secrets.delete("mcp/user/a/env/TOKEN");
assert_eq!(secrets.get("mcp/user/a/env/TOKEN"), None);
}
#[test]
fn without_a_credential_store_values_stay_in_an_owner_only_file() {
let tmp = tempfile::tempdir().unwrap();
let local = tmp.path().join("agent-secrets.local.json");
let store = SecretFile::with_vault(
tmp.path().join("agent-secrets.json"),
|v| serde_json::from_value(v).ok(),
Arc::new(NoStore),
);
let secrets = AgentSecrets::with_store(store, local.clone());
assert_eq!(secrets.set("mcp/user/a/env/TOKEN", "value-7c1e").unwrap(), Kept::LocalFile);
assert_eq!(files_containing(tmp.path(), "value-7c1e"), std::slice::from_ref(&local));
assert_eq!(secrets.get("mcp/user/a/env/TOKEN").as_deref(), Some("value-7c1e"));
assert_eq!(secrets.keys("mcp/"), ["mcp/user/a/env/TOKEN"]);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(std::fs::metadata(&local).unwrap().permissions().mode() & 0o777, 0o600);
}
secrets.delete("mcp/user/a/env/TOKEN");
assert_eq!(secrets.get("mcp/user/a/env/TOKEN"), None);
assert!(!local.exists());
}
}
@@ -426,9 +426,9 @@ 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())
ide_host::data_dirs::data_dir("ai.l0ng.openidea")
.map(|d| d.join("plugins"))
.map_err(|error| format!("{error}; set OPENIDEA_PLUGINS_DIR"))
}
#[derive(Default, serde::Serialize, serde::Deserialize)]
+66 -4
View File
@@ -5,9 +5,52 @@ use std::fs;
use std::io;
use std::path::{Path, PathBuf};
/// Moves the rebuildable local data (tools, language servers, indexes, caches);
/// configuration and roaming data keep following the OS.
/// Moves the rebuildable local data (tools, language servers, indexes, caches).
pub const LOCAL_DATA_ENV: &str = "OPENIDEA_LOCAL_DATA_DIR";
/// Moves the configuration (settings, recent projects, language definitions, window state).
pub const CONFIG_ENV: &str = "OPENIDEA_CONFIG_DIR";
/// Moves the user data (plugins, scratches, Local History, trusted projects).
pub const DATA_ENV: &str = "OPENIDEA_DATA_DIR";
/// Moves the logs.
pub const LOG_ENV: &str = "OPENIDEA_LOG_DIR";
/// `$OPENIDEA_CONFIG_DIR/<identifier>` when set, else the OS configuration directory
/// (Tauri's `app_config_dir`). Like the local data, the identifier keeps release and
/// per-worktree debug builds apart under one root; a relative path is an error.
pub fn config_dir(identifier: &str) -> Result<PathBuf, String> {
resolve_env(CONFIG_ENV, std::env::var_os(CONFIG_ENV), dirs::config_dir(), identifier)
}
/// `$OPENIDEA_DATA_DIR/<identifier>` when set, else the OS data directory (Tauri's `app_data_dir`).
pub fn data_dir(identifier: &str) -> Result<PathBuf, String> {
resolve_env(DATA_ENV, std::env::var_os(DATA_ENV), dirs::data_dir(), identifier)
}
/// `$OPENIDEA_LOG_DIR/<identifier>` when set, else `~/Library/Logs/<identifier>` on macOS and
/// `<local data>/<identifier>/logs` elsewhere (Tauri's `app_log_dir`).
pub fn log_dir(identifier: &str) -> Result<PathBuf, String> {
let os = if cfg!(target_os = "macos") {
dirs::home_dir().map(|home| home.join("Library/Logs").join(identifier))
} else {
dirs::data_local_dir().map(|dir| dir.join(identifier).join("logs"))
};
match std::env::var_os(LOG_ENV).filter(|v| !v.is_empty()) {
Some(var) => resolve_env(LOG_ENV, Some(var), None, identifier),
None => os.ok_or_else(|| "cannot find the log directory".into()),
}
}
/// The directory under `$<env>` when set (absolute, not created here), else under the OS directory.
fn resolve_env(env: &str, var: Option<OsString>, os: Option<PathBuf>, identifier: &str) -> Result<PathBuf, String> {
let Some(root) = var.filter(|v| !v.is_empty()).map(PathBuf::from) else {
return os.map(|dir| dir.join(identifier)).ok_or_else(|| format!("cannot find the directory {env} replaces"));
};
// The working directory depends on how the IDE was launched.
if !root.is_absolute() {
return Err(format!("{env} must be an absolute path: {}", root.display()));
}
Ok(root.join(identifier))
}
/// A custom local-data root chosen in Settings › System Settings › Caches,
/// stored in the configuration directory. The environment variable still wins.
@@ -51,11 +94,11 @@ fn startup_resolve(
/// The file holding the custom local-data root, if the platform exposes its
/// configuration directory. Both startup and Settings use this one path.
pub fn override_path(identifier: &str) -> Option<PathBuf> {
dirs::config_dir().map(|dir| dir.join(identifier).join(OVERRIDE_FILE))
config_dir(identifier).ok().map(|dir| dir.join(OVERRIDE_FILE))
}
fn migration_path(identifier: &str) -> Option<PathBuf> {
dirs::config_dir().map(|dir| dir.join(identifier).join(MIGRATION_FILE))
config_dir(identifier).ok().map(|dir| dir.join(MIGRATION_FILE))
}
/// The custom root stored by Settings, if any (validated when it is used).
@@ -493,6 +536,25 @@ fn relative_wrapper(text: &str, source: &Path) -> String {
mod tests {
use super::*;
#[test]
fn config_data_and_log_dirs_follow_the_os_unless_their_variable_is_set() {
let os = Some(PathBuf::from("/os"));
for env in [CONFIG_ENV, DATA_ENV, LOG_ENV] {
for var in [None, Some(OsString::new())] {
assert_eq!(resolve_env(env, var, os.clone(), "id").unwrap(), PathBuf::from("/os/id"));
}
let root = std::env::temp_dir().join("openidea-moved");
assert_eq!(resolve_env(env, Some(root.clone().into()), os.clone(), "id").unwrap(), root.join("id"));
// Release and per-worktree debug builds stay apart under one root.
assert_ne!(
resolve_env(env, Some(root.clone().into()), None, "dev-a").unwrap(),
resolve_env(env, Some(root.clone().into()), None, "dev-b").unwrap()
);
let error = resolve_env(env, Some("relative/dir".into()), os.clone(), "id").unwrap_err();
assert!(error.contains(env) && error.contains("absolute"), "{error}");
}
}
#[test]
fn local_dir_follows_the_os_unless_the_variable_is_set() {
let os = Some(PathBuf::from("/os-local"));
+6
View File
@@ -3,6 +3,7 @@
//! 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 agent_secrets;
pub mod data_dirs;
pub mod environment;
mod formatter;
@@ -60,6 +61,7 @@ pub struct Host {
pub mcp_reports: mcp::Reports,
pub mcp_requests: mcp::Requests,
pub trust: trust::TrustStore,
pub(crate) mcp_approvals: Arc<trust::McpServerApprovals>,
pub config_dir: PathBuf,
/// Persistent user state, including credentials and recovery history.
pub data_dir: PathBuf,
@@ -91,6 +93,8 @@ pub struct Host {
proxy_password: ide_ssh::secrets::SecretFile,
/// The proxy password when it could not be saved (no credential store).
proxy_session_password: Mutex<Option<String>>,
/// Values of agents' and MCP servers' environment variables and headers (see `agent_secrets.rs`).
pub agent_secrets: Arc<agent_secrets::AgentSecrets>,
pub(crate) sink_for: SinkFactory,
owner: fn(&str) -> &str,
pub runtime: tokio::runtime::Handle,
@@ -138,8 +142,10 @@ impl Host {
mcp_reports: Default::default(),
mcp_requests: Default::default(),
trust: trust::TrustStore::load(&config_dir, &data_dir),
mcp_approvals: Arc::new(trust::McpServerApprovals::load(&data_dir)),
proxy_password: ide_ssh::secrets::SecretFile::new(data_dir.join("proxy-password.json")),
proxy_session_password: Mutex::default(),
agent_secrets: Arc::new(agent_secrets::AgentSecrets::new(&data_dir)),
remote: Arc::new(ide_remote::RemoteService::new(ssh.clone(), data_dir.join("remote"))),
ssh,
environment: environment::Environment::new(project_environment, local_data_dir.clone()),
+11 -10
View File
@@ -112,16 +112,17 @@ fn whole_range(text: &str) -> Range {
/// Whether two paths name the same file (language servers report canonical paths; on a
/// case-insensitive file system `a.txt` and `A.txt` are one file).
fn same_file(a: &Path, b: &Path) -> bool {
if a == b || matches!((dunce::canonicalize(a), dunce::canonicalize(b)), (Ok(x), Ok(y)) if x == y) {
return true;
}
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
if let (Ok(x), Ok(y)) = (std::fs::metadata(a), std::fs::metadata(b)) {
return x.dev() == y.dev() && x.ino() == y.ino();
}
}
a == b || matches!((dunce::canonicalize(a), dunce::canonicalize(b)), (Ok(x), Ok(y)) if x == y) || same_inode(a, b)
}
#[cfg(unix)]
fn same_inode(a: &Path, b: &Path) -> bool {
use std::os::unix::fs::MetadataExt;
matches!((std::fs::metadata(a), std::fs::metadata(b)), (Ok(x), Ok(y)) if x.dev() == y.dev() && x.ino() == y.ino())
}
#[cfg(not(unix))]
fn same_inode(_: &Path, _: &Path) -> bool {
false
}
+89 -10
View File
@@ -381,7 +381,7 @@ fn persist_mcp_map(
Ok(())
}
fn update_mcp_maps<F>(host: &Host, window: &str, f: F) -> IdeResult<Vec<McpServer>>
fn update_mcp_maps<F>(host: &Host, window: &str, approve: Option<&str>, f: F) -> IdeResult<Vec<McpServer>>
where
F: FnOnce(&mut McpServerMap, &mut McpServerMap, Option<&std::path::Path>) -> Result<(), String>,
{
@@ -390,43 +390,83 @@ where
let prev_workspace = workspace.clone();
let project_root = project.as_ref().map(|p| p.root.as_path());
f(&mut user, &mut workspace, project_root).map_err(IdeError::invalid)?;
if let (Some(name), Some(p)) = (approve, &project)
&& let Some(server) = workspace.get(name)
{
host.mcp_approvals.approve(&p.root, name, server)?;
}
if workspace != prev_workspace {
persist_mcp_map(host, project.as_ref(), McpScope::Workspace, &workspace)?;
}
if user != prev_user {
persist_mcp_map(host, project.as_ref(), McpScope::User, &user)?;
}
Ok(ide_agent::mcp::servers_from_settings(&user, &workspace))
Ok(listed(host, project.as_ref(), &user, workspace))
}
/// Workspace servers the user hasn't turned on in Settings are listed as off: agents don't start them.
fn listed(
host: &Host,
project: Option<&std::sync::Arc<crate::Project>>,
user: &McpServerMap,
workspace: McpServerMap,
) -> Vec<McpServer> {
let workspace = match project {
Some(p) => host.mcp_approvals.apply(&p.root, workspace),
None => workspace,
};
ide_agent::mcp::servers_from_settings(user, &workspace)
}
/// The MCP servers agent sessions of the project at `root` get: the user's, and the project's the
/// user turned on.
pub(crate) fn session_mcp_servers(
settings: &ide_workspace::SettingsStore,
approvals: &crate::trust::McpServerApprovals,
root: &std::path::Path,
) -> Vec<McpServer> {
use ide_workspace::settings::SettingsScope;
let user = settings.layer(SettingsScope::Global).agents.mcp_servers;
let workspace = approvals.apply(root, settings.layer(SettingsScope::Project).agents.mcp_servers);
ide_agent::mcp::servers_from_settings(&user, &workspace)
}
/// Lists configured MCP servers across User (`settings.toml`) and Workspace (`.openidea/settings.toml`) scopes.
pub fn mcp_list(host: &Host, window: &str) -> IdeResult<Vec<McpServer>> {
let (user, workspace, _) = mcp_maps(host, window);
Ok(ide_agent::mcp::servers_from_settings(&user, &workspace))
let (user, workspace, project) = mcp_maps(host, window);
Ok(listed(host, project.as_ref(), &user, workspace))
}
/// Creates or updates a User or Workspace MCP server and returns the updated list.
/// Creates or updates a User or Workspace MCP server and returns the updated list. A Workspace
/// server saved here is one the user turned on.
pub fn mcp_save(host: &Host, window: &str, input: McpServerInput) -> IdeResult<Vec<McpServer>> {
update_mcp_maps(host, window, |user, workspace, project_root| {
let approve = (input.scope == McpScope::Workspace).then(|| input.name.trim().to_owned());
update_mcp_maps(host, window, approve.as_deref(), |user, workspace, project_root| {
ide_agent::mcp::apply_save(user, workspace, project_root.is_some(), input)
})
}
/// Enables or disables an MCP server and returns the updated list.
pub fn mcp_set_enabled(host: &Host, window: &str, id: String, enabled: bool) -> IdeResult<Vec<McpServer>> {
update_mcp_maps(host, window, |user, workspace, _| {
let approve = ide_agent::mcp::parse_server_id(&id)
.filter(|(scope, _)| enabled && *scope == McpScope::Workspace)
.map(|(_, name)| name.to_owned());
update_mcp_maps(host, window, approve.as_deref(), |user, workspace, _| {
ide_agent::mcp::apply_set_enabled(user, workspace, &id, enabled).map(|_| ())
})
}
/// Removes a User or Workspace MCP server and returns the updated list.
pub fn mcp_remove(host: &Host, window: &str, id: String) -> IdeResult<Vec<McpServer>> {
update_mcp_maps(host, window, |user, workspace, _| ide_agent::mcp::apply_remove(user, workspace, &id).map(|_| ()))
update_mcp_maps(host, window, None, |user, workspace, _| {
ide_agent::mcp::apply_remove(user, workspace, &id).map(|_| ())
})
}
/// Copies MCP servers from `.mcp.json` and `.vscode/mcp.json` into the workspace settings once.
/// Copies MCP servers from `.mcp.json` and `.vscode/mcp.json` into the workspace settings once;
/// they stay off until the user turns them on.
pub fn mcp_import(host: &Host, window: &str) -> IdeResult<Vec<McpServer>> {
update_mcp_maps(host, window, |_, workspace, project_root| {
update_mcp_maps(host, window, None, |_, workspace, project_root| {
let root = project_root.ok_or_else(|| "Open a project to import workspace MCP servers".to_string())?;
ide_agent::mcp::apply_import(workspace, root);
Ok(())
@@ -550,4 +590,43 @@ mod tests {
assert!(validate_project_root(&root, Some(&rejected)).is_err(), "{rejected:?}");
}
}
#[test]
fn sessions_get_the_users_servers_and_the_projects_turned_on_ones() {
let tmp = tempfile::tempdir().unwrap();
let (config, root) = (tmp.path().join("config"), tmp.path().join("project"));
std::fs::create_dir_all(&config).unwrap();
std::fs::create_dir_all(root.join(".openidea")).unwrap();
std::fs::write(config.join("settings.toml"), "[agents.mcpServers.shared]\ncommand = \"user-cmd\"\n").unwrap();
std::fs::write(
root.join(".openidea/settings.toml"),
"[agents.mcpServers.shared]\ncommand = \"project-cmd\"\n\n[agents.mcpServers.extra]\ncommand = \"extra\"\n",
)
.unwrap();
let settings = ide_workspace::SettingsStore::load(&config, Some(&root));
let approvals = crate::trust::McpServerApprovals::load(&tmp.path().join("data"));
let on = |servers: Vec<McpServer>| -> Vec<(McpScope, String, String)> {
servers
.into_iter()
.filter(|s| s.enabled)
.map(|s| (s.scope, s.name, s.command.unwrap_or_default()))
.collect()
};
assert_eq!(
on(session_mcp_servers(&settings, &approvals, &root)),
[(McpScope::User, "shared".to_owned(), "user-cmd".to_owned())]
);
let extra = settings.layer(ide_workspace::settings::SettingsScope::Project).agents.mcp_servers["extra"].clone();
approvals.approve(&root, "extra", &extra).unwrap();
assert!(on(session_mcp_servers(&settings, &approvals, &root)).contains(&(
McpScope::Workspace,
"extra".to_owned(),
"extra".to_owned()
)));
// Remembered across restarts, and only for this project.
let reloaded = crate::trust::McpServerApprovals::load(&tmp.path().join("data"));
assert!(reloaded.approved(&root, "extra", &extra));
assert!(!reloaded.approved(&tmp.path().join("other"), "extra", &extra));
}
}
+221 -3
View File
@@ -2,16 +2,18 @@
//! Build › Build Project (compiling with the project's build system).
mod workspace;
use super::dependencies::{MavenCentralArtifacts, MavenCentralGroups, MavenCentralVersions};
use crate::ops::Channel;
use crate::rpc::Dispatcher;
use crate::{Host, Project, apply_workspace_edit};
use ide_build::compile::{self, BuildEvent, BuildKind, BuildResult};
use ide_build::maven::PomGoto;
use ide_build::maven::completion::{PomCompletion, PomCompletionOption};
use ide_build::pom_edit::{ArtifactKey, Exclusion};
use ide_build::{BuildModel, BuildTool, SyncOptions, SyncProgress};
use ide_core::settings::Settings;
use ide_core::{FileEdit, IdeError, IdeEvent, IdeResult, Location, Pos, Range, RequestId, WorkspaceEdit};
use ide_core::{DocId, FileEdit, IdeError, IdeEvent, IdeResult, Location, Pos, Range, RequestId, WorkspaceEdit};
use std::path::Path;
fn join_err(e: tokio::task::JoinError) -> IdeError {
@@ -453,7 +455,8 @@ pub async fn maven_dependency_declaration(
/// absolute for a parent POM opened from the local repository): the definition of the
/// `${property}` at the caret, or the `<dependencyManagement>` / `<pluginManagement>`
/// entry providing the dependency's / plugin's version, along the parent chain (into
/// the local repository when a parent is not in the project). `None` when nothing
/// the local repository when a parent is not in the project), else the artifact's own
/// POM (a project module's, or the local repository's). `None` when nothing
/// resolvable is under the caret.
pub async fn maven_goto_declaration(host: &Host, window: &str, pom: String, pos: Pos) -> IdeResult<Option<PomGoto>> {
let project = host.project(window)?;
@@ -464,12 +467,107 @@ pub async fn maven_goto_declaration(host: &Host, window: &str, pom: String, pos:
let settings = project.settings.effective();
let repo = ide_sdk::apply::maven_local_repository(&project.root, &settings)
.or_else(ide_build::maven::default_local_repo);
Ok(ide_build::maven::goto_declaration(&text, &path, pos, repo))
let modules = || {
let model = load(&project.root, &project.build_cache_dir(), &settings).maven.unwrap_or_default();
model
.projects
.into_iter()
.map(|p| ide_build::maven::ModulePom {
group_id: p.group_id,
artifact_id: p.artifact_id,
pom: project.root.join(&p.pom),
})
.collect()
};
Ok(ide_build::maven::goto_declaration_in(&text, &path, pos, repo, modules))
})
.await
.map_err(join_err)?
}
/// How long completion waits for Maven Central before answering from the local repository.
const CENTRAL_WAIT: std::time::Duration = std::time::Duration::from_secs(1);
/// Completion of the `<dependency>` coordinate the caret (`offset`, UTF-16) sits
/// in the value of in the POM `doc`: the coordinates installed in the local
/// repository (Settings › Maven's, else `~/.m2/repository`) — group ids,
/// the chosen group's artifacts, the chosen artifact's versions — topped up
/// with Maven Central's (the search endpoint's popular coordinates, the
/// published version metadata), which the shared version cache keeps for the
/// session. `None` when the caret is not in a `<dependency>` coordinate element.
pub async fn maven_dependency_completion(
host: &Host,
window: &str,
doc: DocId,
offset: u32,
request: RequestId,
) -> IdeResult<Option<PomCompletion>> {
use ide_build::maven::completion::{PomCompletionKind, local_options};
let project = host.project(window)?;
let cache = host.dependency_versions.clone();
let cancel = host.begin_project_read_request(window, request);
let result = host
.blocking(move || {
let snap = project.documents.snapshot(doc)?;
if snap.large || snap.read_only || !snap.path.file_name().is_some_and(|name| name == "pom.xml") {
return Ok(None);
}
let Some(target) = ide_build::maven::completion::completion_target(&snap.text, offset) else {
return Ok(None);
};
let settings = project.settings.effective();
let repo = ide_sdk::apply::maven_local_repository(&project.root, &settings)
.or_else(ide_build::maven::default_local_repo);
let mut options = repo.map_or_else(Vec::new, |repo| local_options(&repo, &target));
// Central's answers are cached for the session (the shared version cache).
// The local repository answers at once: Central gets `CENTRAL_WAIT`, then the
// lookup finishes in the background and fills the cache for the next request.
// Offline, behind a proxy or rate-limited, a failed fetch never fails completion.
let remote = {
let (cache, target) = (cache.clone(), target.clone());
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let detached = ide_core::CancellationToken::new();
let found = match target.kind {
PomCompletionKind::GroupId if !target.typed.is_empty() => {
cache.versions(&MavenCentralGroups, &target.typed, &detached).unwrap_or_default()
}
PomCompletionKind::ArtifactId => match &target.group {
Some(group) => cache
.versions(&MavenCentralArtifacts { group: group.clone() }, &target.typed, &detached)
.unwrap_or_default(),
None => Vec::new(),
},
PomCompletionKind::Version => match (&target.group, &target.artifact) {
(Some(group), Some(artifact)) => cache
.versions(&MavenCentralVersions, &format!("{group}:{artifact}"), &detached)
.unwrap_or_default(),
_ => Vec::new(),
},
_ => Vec::new(), // A group id from nothing: the search endpoint has no such query.
};
let _ = tx.send(found);
});
rx.recv_timeout(CENTRAL_WAIT).unwrap_or_default()
};
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let known: std::collections::HashSet<String> = options.iter().map(|o| o.label.clone()).collect();
options.extend(
remote
.into_iter()
.filter(|label| !known.contains(label))
.map(|label| PomCompletionOption { label, detail: None }),
);
options.truncate(ide_build::maven::completion::MAX_OPTIONS);
Ok(Some(PomCompletion { from: target.from, to: target.to, kind: target.kind, options }))
})
.await;
host.end_request(request);
result
}
/// Adds an `<exclusion>` of `exclusion` to the declaration of the direct dependency
/// `dependency` of the Maven module `pom`, through the open document when there is one
/// (saved). Returns the declaration; nothing changes when it already excludes it.
@@ -711,6 +809,9 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("maven_goto_declaration", |c| async move {
maven_goto_declaration(&c.host, &c.window, c.arg("pom")?, c.arg("pos")?).await
});
d.add("maven_dependency_completion", |c| async move {
maven_dependency_completion(&c.host, &c.window, c.arg("doc")?, c.arg("offset")?, c.arg("request")?).await
});
d.add("maven_exclude_dependency", |c| async move {
maven_exclude_dependency(&c.host, &c.window, c.arg("pom")?, c.arg("dependency")?, c.arg("exclusion")?).await
});
@@ -909,3 +1010,120 @@ mod build_tool_settings_tests {
assert_eq!(model.env["GRADLE_USER_HOME"], root.join("user-home").to_string_lossy());
}
}
#[cfg(test)]
mod maven_completion_tests {
use super::*;
use crate::ops::dependencies::hashed_key;
use ide_build::maven::completion::PomCompletionKind;
use ide_core::CancellationToken;
use ide_workspace::dependencies::DependencyVersionSource;
use std::sync::Arc;
/// Cache answers the test plants before completing, so the op's Central
/// lookups hit the session cache and the network is never reached.
struct FakeSource(&'static str, Vec<&'static str>);
impl DependencyVersionSource for FakeSource {
fn id(&self) -> &'static str {
self.0
}
fn cache_key(&self, package: &str) -> Option<String> {
Some(hashed_key(self.0, package))
}
fn fetch(&self, _: &str, _: &CancellationToken) -> IdeResult<Vec<String>> {
Ok(self.1.iter().map(|s| (*s).to_owned()).collect())
}
}
const GROUPS: &str = "maven-central-groups";
const ARTIFACTS: &str = "maven-central-artifacts";
const VERSIONS: &str = "maven-central-versions";
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn completes_dependency_coordinates_from_the_local_repository_then_central() {
let temp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(temp.path()).unwrap();
let (root, repo) = (base.join("project"), base.join("repository"));
std::fs::create_dir_all(&root).unwrap();
// Settings › Maven's local repository points at the fixture, so the
// test machine's own ~/.m2 is never read.
std::fs::create_dir_all(base.join("config")).unwrap();
std::fs::write(
base.join("config/settings.toml"),
format!("[languages.java.maven]\nlocalRepository = \"{}\"\n", repo.to_string_lossy().replace('\\', "/")),
)
.unwrap();
for file in [
"org/apache/maven/maven-core/4.0.0-rc-5/maven-core-4.0.0-rc-5.jar",
"org/example/lib/2.0/lib-2.0.jar",
"org/lib/1.0/lib-1.0.jar",
] {
let path = repo.join(file);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, b"").unwrap();
}
let text = "<project>\n <dependencies>\n <dependency>\n <groupId>org</groupId>\n <artifactId>lib</artifactId>\n <version>1.0</version>\n </dependency>\n <dependency>\n <groupId>org.example</groupId>\n <artifactId></artifactId>\n </dependency>\n <dependency>\n <groupId>org.example</groupId>\n <artifactId>lib</artifactId>\n <version></version>\n </dependency>\n </dependencies>\n</project>\n";
let path = root.join("pom.xml");
std::fs::write(&path, text).unwrap();
let host = Host::new(crate::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();
let token = CancellationToken::new();
host.dependency_versions.versions(&FakeSource(GROUPS, vec!["org.zzz"]), "org", &token).unwrap();
host.dependency_versions.versions(&FakeSource(ARTIFACTS, vec!["api"]), "org.example\u{1}", &token).unwrap();
host.dependency_versions.versions(&FakeSource(VERSIONS, vec!["9.9"]), "org.example:lib", &token).unwrap();
let at = |needle: &str| (text.find(needle).unwrap() + needle.len()) as u32;
let next = std::sync::atomic::AtomicU32::new(500);
let complete = |offset: u32| {
maven_dependency_completion(
&host,
"main",
info.id,
offset,
RequestId(next.fetch_add(1, std::sync::atomic::Ordering::Relaxed)),
)
};
fn labels(c: &PomCompletion) -> Vec<&str> {
c.options.iter().map(|o| o.label.as_str()).collect()
}
let group =
complete(text.find("<groupId>org").unwrap() as u32 + "<groupId>org".len() as u32).await.unwrap().unwrap();
assert_eq!(group.kind, PomCompletionKind::GroupId);
// The installed groups first, Central's cached suggestions behind them.
assert_eq!(labels(&group), ["org", "org.apache.maven", "org.example", "org.zzz"]);
// Nothing outside a dependency's coordinates.
let outside = complete(at("<dependencies>")).await;
assert!(matches!(outside, Ok(None)), "{outside:?}");
let empty_artifact = text.find("<artifactId></artifactId>").unwrap() as u32 + "<artifactId>".len() as u32;
let artifact = complete(empty_artifact).await;
let artifact = match artifact {
Ok(Some(a)) => a,
other => panic!("artifact completion: {other:?}"),
};
assert_eq!(artifact.kind, PomCompletionKind::ArtifactId);
let installed = artifact.options.first().unwrap();
assert_eq!((installed.label.as_str(), installed.detail.as_deref()), ("lib", Some("2.0")));
assert_eq!(labels(&artifact), ["lib", "api"]);
let empty_version = text.find("<version></version>").unwrap() as u32 + "<version>".len() as u32;
let version = complete(empty_version).await.unwrap().unwrap();
assert_eq!(version.kind, PomCompletionKind::Version);
assert_eq!(labels(&version), ["2.0", "9.9"]);
host.close_project("main");
}
}
+74 -1
View File
@@ -1,7 +1,7 @@
//! 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, go, npm};
use ide_workspace::dependencies::{DependencyVersionSource, DependencyVersions, cargo, go, maven, npm};
use std::collections::BTreeMap;
use std::hash::{DefaultHasher, Hash, Hasher};
use std::path::{Path, PathBuf};
@@ -25,6 +25,79 @@ impl DependencyVersionSource for CratesIo {
}
}
/// A cache key for a `package` that is not a filesystem-safe name (a Maven
/// coordinate with dots, a query): hashed under the source's id.
pub(crate) fn hashed_key(id: &str, package: &str) -> String {
let mut hash = DefaultHasher::new();
package.hash(&mut hash);
format!("{id}-{:016x}", hash.finish())
}
/// Maven Central's group-id suggestions: the search endpoint's groups matching
/// a typed prefix, most recently updated first.
pub(crate) struct MavenCentralGroups;
impl DependencyVersionSource for MavenCentralGroups {
fn id(&self) -> &'static str {
"maven-central-groups"
}
fn cache_key(&self, prefix: &str) -> Option<String> {
Some(hashed_key(self.id(), prefix))
}
fn fetch(&self, prefix: &str, cancel: &CancellationToken) -> IdeResult<Vec<String>> {
let url = maven::groups_url(prefix).ok_or_else(|| IdeError::internal("invalid group prefix"))?;
let search_path = std::env::var_os("PATH").unwrap_or_default();
let text =
ide_tools::Http::new(&search_path)?.https_only().get_text_limited(&url, Some(1024 * 1024), cancel)?;
maven::parse_search_groups(&text).ok_or_else(|| IdeError::internal("invalid search response"))
}
}
/// The artifacts of one group matching a typed prefix, from the same endpoint.
pub(crate) struct MavenCentralArtifacts {
pub group: String,
}
impl DependencyVersionSource for MavenCentralArtifacts {
fn id(&self) -> &'static str {
"maven-central-artifacts"
}
fn cache_key(&self, prefix: &str) -> Option<String> {
Some(hashed_key(self.id(), &format!("{}\u{1}{prefix}", self.group)))
}
fn fetch(&self, prefix: &str, cancel: &CancellationToken) -> IdeResult<Vec<String>> {
let url =
maven::artifacts_url(&self.group, prefix).ok_or_else(|| IdeError::internal("invalid artifact prefix"))?;
let search_path = std::env::var_os("PATH").unwrap_or_default();
let text =
ide_tools::Http::new(&search_path)?.https_only().get_text_limited(&url, Some(1024 * 1024), cancel)?;
maven::parse_search_artifacts(&text).ok_or_else(|| IdeError::internal("invalid search response"))
}
}
/// Every version published of one `group:artifact` on Central, newest first.
pub(crate) struct MavenCentralVersions;
impl DependencyVersionSource for MavenCentralVersions {
fn id(&self) -> &'static str {
"maven-central-versions"
}
fn cache_key(&self, coordinates: &str) -> Option<String> {
Some(hashed_key(self.id(), coordinates))
}
fn fetch(&self, coordinates: &str, cancel: &CancellationToken) -> IdeResult<Vec<String>> {
let (group, artifact) = coordinates.split_once(':').ok_or_else(|| IdeError::internal("invalid coordinates"))?;
let url = maven::metadata_url(group, artifact).ok_or_else(|| IdeError::internal("invalid coordinates"))?;
let search_path = std::env::var_os("PATH").unwrap_or_default();
let text =
ide_tools::Http::new(&search_path)?.https_only().get_text_limited(&url, Some(2 * 1024 * 1024), cancel)?;
Ok(maven::parse_metadata_versions(&text))
}
}
struct NpmRegistry;
impl DependencyVersionSource for NpmRegistry {
fn id(&self) -> &'static str {
+53 -10
View File
@@ -114,14 +114,28 @@ pub fn create_file(host: &Host, window: &str, path: PathBuf) -> IdeResult<()> {
if empty && !bom.is_empty() {
std::fs::write(&path, bom)?;
}
// A checkout backend opens the file for add; Git's hook is a no-op.
if let Err(e) = p.with_backend_of(&path, |r| r.files_added(std::slice::from_ref(&path))) {
tracing::debug!("vcs files_added for {}: {e}", path.display());
}
Ok(())
}
Err(_) => ide_vfs::create_file(&path),
}
}
pub async fn create_file_bytes(host: &Host, path: PathBuf, data: Vec<u8>) -> IdeResult<()> {
host.blocking(move || ide_vfs::create_file_bytes(&path, &data)).await
pub async fn create_file_bytes(host: &Host, window: &str, path: PathBuf, data: Vec<u8>) -> IdeResult<()> {
let project = host.project(window).ok();
host.blocking(move || {
ide_vfs::create_file_bytes(&path, &data)?;
if let Some(p) = project
&& let Err(e) = p.with_backend_of(&path, |r| r.files_added(std::slice::from_ref(&path)))
{
tracing::debug!("vcs files_added for {}: {e}", path.display());
}
Ok(())
})
.await
}
/// Toggle Read-only Attribute: sets the writable bit (and the open document's
@@ -135,25 +149,54 @@ pub fn set_writable(host: &Host, window: &str, path: PathBuf, writable: bool) ->
/// Moves to the trash; Local History keeps the contents so deleted files can be restored.
pub async fn delete(host: &Host, window: &str, path: PathBuf) -> IdeResult<()> {
let history = host.project(window).ok().map(|p| p.history.clone());
let project = host.project(window).ok();
let history = project.as_ref().map(|p| p.history.clone());
host.blocking(move || {
let paths = ide_workspace::file_ops::deletion_paths(&path)?;
if let Some(h) = history {
for p in paths {
h.record_deletion(&p, DELETE_SNAPSHOT_LIMIT);
for p in &paths {
h.record_deletion(p, DELETE_SNAPSHOT_LIMIT);
}
}
ide_workspace::file_ops::delete(&path)
ide_workspace::file_ops::delete(&path)?;
// A checkout backend opens the paths for delete; Git's hook is a no-op.
if let Some(p) = project {
let _ = p.with_backend_of(&path, |r| r.files_deleted(&paths));
}
Ok(())
})
.await
}
pub async fn copy(host: &Host, from: PathBuf, to: PathBuf) -> IdeResult<()> {
host.blocking(move || ide_workspace::file_ops::copy(&from, &to)).await
pub async fn copy(host: &Host, window: &str, from: PathBuf, to: PathBuf) -> IdeResult<()> {
let project = host.project(window).ok();
host.blocking(move || {
ide_workspace::file_ops::copy(&from, &to)?;
// A copy is an add at the destination; Git's hook is a no-op.
if let Some(p) = project
&& let Err(e) = p.with_backend_of(&to, |r| r.files_added(std::slice::from_ref(&to)))
{
tracing::debug!("vcs files_added for {}: {e}", to.display());
}
Ok(())
})
.await
}
pub async fn rename(host: &Host, from: PathBuf, to: PathBuf) -> IdeResult<()> {
host.blocking(move || ide_workspace::file_ops::rename(&from, &to)).await
pub async fn rename(host: &Host, window: &str, from: PathBuf, to: PathBuf) -> IdeResult<()> {
let project = host.project(window).ok();
host.blocking(move || {
ide_workspace::file_ops::rename(&from, &to)?;
// A checkout backend records the rename so history follows the file;
// Git's hook is a no-op.
if let Some(p) = project
&& let Err(e) = p.with_backend_of(&to, |r| r.file_moved(&from, &to))
{
tracing::debug!("vcs file_moved {} → {}: {e}", from.display(), to.display());
}
Ok(())
})
.await
}
/// Reads a text file without opening it as a document; prefers the unsaved buffer.
+69 -3
View File
@@ -48,7 +48,7 @@ async fn resolve_launch(
let build = policy != BuildPolicy::NoBuild;
let full = policy == BuildPolicy::Rebuild;
let build_kind = if full { compile::BuildKind::Rebuild } else { compile::BuildKind::Build };
let (model, external, delegated, prefer_maven) = {
let (model, external, mut delegated, prefer_maven, is_maven) = {
let project = project.clone();
let settings = settings.clone();
let module = module.clone();
@@ -68,13 +68,29 @@ async fn resolve_launch(
prefer_maven,
);
let external = non_java || delegated;
(model, external, delegated, prefer_maven)
(model, external, delegated, prefer_maven, is_maven)
})
.await
.map_err(|e| IdeError::internal(e.to_string()))?
};
let external_build = if delegated { Some(project.lsp.java_external_build(&module, cancel).await?) } else { None };
let mut 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, full, delegated, cancel).await?;
if info.errors.iter().any(|e| processor_recreated_a_file(&e.message)) {
if build && !delegated && is_maven {
// JDT runs an annotation processor again over sources it generated in the same build
// (MapStruct over MapStruct-Plus's mappers) and fails on the file it already wrote;
// javac doesn't, so Maven builds the module.
tracing::info!("Annotation processing failed in JDT; building with Maven");
delegated = true;
external_build = Some(project.lsp.java_external_build(&module, cancel).await?);
info = project.lsp.java_launch(&module, test, false, false, true, cancel).await?;
}
if delegated {
// Maven runs the annotation processors itself; JDT's failure to rerun one is no compile error.
drop_processor_recreate_errors(&mut info);
}
}
let lsp_ms = started.elapsed().as_millis();
let data_dir = host.local_data_dir.clone();
let cancel = cancel.clone();
@@ -338,10 +354,60 @@ pub(crate) fn register(d: &mut Dispatcher) {
});
}
/// An annotation processor's error for writing a file this build already wrote (JDT's
/// `IdeFilerImpl`: "Source file already created", javac's Filer: "Attempt to recreate a file").
fn processor_recreated_a_file(message: &str) -> bool {
message.contains("FilerException")
&& (message.contains("Source file already created") || message.contains("Attempt to recreate a file"))
}
fn drop_processor_recreate_errors(info: &mut JavaLaunchInfo) {
let before = info.errors.len();
info.errors.retain(|e| !processor_recreated_a_file(&e.message));
info.error_count = info.error_count.saturating_sub((before - info.errors.len()) as u32);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn recognises_a_processor_recreating_its_output() {
assert!(processor_recreated_a_file(
"Internal error in the mapping processor: java.lang.RuntimeException: \
javax.annotation.processing.FilerException: Source file already created: /m/target/generated-sources/annotations/a/AMapper__4Impl.java"
));
assert!(processor_recreated_a_file(
"javax.annotation.processing.FilerException: Attempt to recreate a file for type a.B"
));
assert!(!processor_recreated_a_file("Source file already created"));
assert!(!processor_recreated_a_file("cannot find symbol"));
}
#[test]
fn a_delegated_launch_ignores_jdt_processor_recreate_errors() {
let error = |message: &str| ide_lsp::jdtls::JavaCompileError {
path: "src/main/java/a/A.java".into(),
line: 1,
column: 1,
message: message.into(),
};
let mut info = JavaLaunchInfo {
error_count: 2,
errors: vec![
error(
"javax.annotation.processing.FilerException: Source file already created: /m/AMapper__1Impl.java",
),
error("cannot find symbol"),
],
..Default::default()
};
drop_processor_recreate_errors(&mut info);
assert_eq!(info.error_count, 1);
assert_eq!(info.errors.len(), 1);
assert_eq!(info.errors[0].message, "cannot find symbol");
}
#[test]
fn delegated_launches_with_jdt_errors_recompile_with_maven() {
assert_eq!(delegated_policy(BuildPolicy::Make, true, 1), BuildPolicy::Rebuild);
+103
View File
@@ -7,6 +7,8 @@ use ide_core::{IdeError, IdeResult, RequestId};
use ide_sdk::python::PythonPackage;
use ide_sdk::{Sdk, SdkKind};
use std::path::PathBuf;
use std::sync::Mutex;
use std::sync::atomic::{AtomicUsize, Ordering};
fn join_err(e: impl std::fmt::Display) -> IdeError {
IdeError::internal(e.to_string())
@@ -20,6 +22,14 @@ pub struct LocatedTools {
pub compile_dir: ide_core::IpcPath,
}
/// An interpreter another project uses: the project root and the SDK itself.
#[derive(Clone, Debug, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct ProjectInterpreter {
pub project: ide_core::IpcPath,
pub sdk: Sdk,
}
/// Installed interpreters / toolchains of `kind`; project-local ones first.
pub async fn sdk_detect(host: &Host, window: &str, kind: SdkKind) -> IdeResult<Vec<Sdk>> {
host.require_trust(window)?;
@@ -45,6 +55,50 @@ pub async fn sdk_detect(host: &Host, window: &str, kind: SdkKind) -> IdeResult<V
.map_err(join_err)
}
/// The Python interpreters of the projects opened recently (newest first), each with the
/// project it belongs to. The open project is skipped: [`sdk_detect`] lists its own.
pub async fn sdk_python_project_interpreters(host: &Host, window: &str) -> IdeResult<Vec<ProjectInterpreter>> {
host.require_trust(window)?;
let current = host.project(window).ok().map(|p| p.root.clone());
let config = host.config_dir.clone();
tokio::task::spawn_blocking(move || {
let roots: Vec<PathBuf> = ide_workspace::RecentProjects::load(&config)
.list()
.into_iter()
.map(|p| p.path.into_path_buf())
.filter(|root| current.as_deref() != Some(root.as_path()))
.collect();
// Each project resolves its configured interpreter, else its own virtualenv. The
// probes run `python`, so keep them off the async runtime and bound how many run at
// once: the recent list can hold ~30 projects, and each probe starts a process.
const PROBES: usize = 4;
let results = Mutex::new(Vec::with_capacity(roots.len()));
let next = AtomicUsize::new(0);
std::thread::scope(|scope| {
for _ in 0..roots.len().min(PROBES) {
scope.spawn(|| {
loop {
let index = next.fetch_add(1, Ordering::Relaxed);
let Some(root) = roots.get(index) else { break };
let settings = ide_workspace::SettingsStore::load(&config, Some(root)).effective();
let Some(python) = ide_sdk::apply::python_interpreter(root, &settings) else { continue };
let Some(sdk) = ide_sdk::python_interpreter_sdk(&python, Some(root)) else { continue };
results
.lock()
.unwrap()
.push((index, ProjectInterpreter { project: root.as_path().into(), sdk }));
}
});
}
});
let mut found = results.into_inner().unwrap();
found.sort_by_key(|(index, _)| *index);
found.into_iter().map(|(_, interpreter)| interpreter).collect()
})
.await
.map_err(join_err)
}
/// An existing Python executable or virtualenv folder as an SDK; `None` if Python cannot run.
pub async fn sdk_python_interpreter(host: &Host, window: &str, path: PathBuf) -> IdeResult<Option<Sdk>> {
host.require_trust(window)?;
@@ -310,6 +364,10 @@ pub async fn sdk_python_pip(
/// Commands of this module the remote server answers.
pub(crate) fn register(d: &mut Dispatcher) {
d.add("sdk_detect", |c| async move { sdk_detect(&c.host, &c.window, c.arg("kind")?).await });
d.add(
"sdk_python_project_interpreters",
|c| async move { sdk_python_project_interpreters(&c.host, &c.window).await },
);
d.add(
"sdk_python_interpreter",
|c| async move { sdk_python_interpreter(&c.host, &c.window, c.arg("path")?).await },
@@ -425,4 +483,49 @@ mod tests {
assert!(sdk_python_interpreter(&host, "main", root.join("README.md")).await.unwrap().is_none());
host.close_project("main");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn another_projects_interpreter_is_listed_with_its_project() {
let temp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(temp.path()).unwrap();
let root = base.join("project");
let other = base.join("shop");
std::fs::create_dir_all(other.join(".openidea")).unwrap();
std::fs::create_dir_all(&root).unwrap();
let python = fake_venv(&other, ".venv");
std::fs::write(
other.join(".openidea/settings.toml"),
"[languages.python]\ninterpreter = \".venv/bin/python\"\n",
)
.unwrap();
// The projects the user opened before this one.
let config = base.join("config");
std::fs::create_dir_all(&config).unwrap();
let recent = serde_json::json!([
{ "path": other.to_str().unwrap(), "name": "shop", "openedAtMs": 2.0, "branch": null },
{ "path": root.to_str().unwrap(), "name": "project", "openedAtMs": 1.0, "branch": null },
]);
std::fs::write(config.join("recent.json"), serde_json::to_vec(&recent).unwrap()).unwrap();
let host = Host::new(HostConfig {
config_dir: 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,
});
host.trust.decide(&root, true, false).unwrap();
host.open_project("main", &root).unwrap();
let list = sdk_python_project_interpreters(&host, "main").await.unwrap();
assert_eq!(list.len(), 1, "{list:?}");
assert_eq!(&*list[0].project, other.as_path());
assert_eq!(&*list[0].sdk.path, python.as_path());
assert_eq!(list[0].sdk.name, "Python 3.12 (shop)");
host.close_project("main");
}
}
+31 -16
View File
@@ -138,6 +138,11 @@ impl LogAccess {
.ok_or_else(|| ide_core::IdeError::unavailable("external Git log is not open"))?
.with_log_repo_at(at, f)
}
/// `with_log_repo_at` through the backend-agnostic Changes-view surface.
fn with_log_backend_at<T>(&self, at: Option<&Path>, f: impl FnOnce(&dyn Backend) -> IdeResult<T>) -> IdeResult<T> {
self.with_log_repo_at(at, |r| f(r as &dyn Backend))
}
}
/// A repository of the project (IDEA's VCS root).
@@ -280,6 +285,16 @@ fn per_repo(
Ok(())
}
/// `per_repo` through the backend-agnostic Changes-view surface.
fn per_backend(
project: &Project,
paths: &[PathBuf],
recorded: Option<&str>,
f: impl Fn(&dyn Backend, &[PathBuf]) -> IdeResult<()>,
) -> IdeResult<()> {
per_repo(project, paths, recorded, |r, paths| f(r as &dyn Backend, paths))
}
/// 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();
@@ -348,7 +363,7 @@ pub async fn vcs_line_hunks(host: &Host, window: &str, path: PathBuf) -> IdeResu
let project = host.project(window)?;
blocking(move || {
let text = project.text_of(&path)?;
project.with_repo_of(&path, |r| r.line_hunks(&path, &text, false, project.vcs_encoding(&path, None)?))
project.with_backend_of(&path, |r| r.line_hunks(&path, &text, false, project.vcs_encoding(&path, None)?))
})
.await
}
@@ -360,7 +375,7 @@ pub async fn vcs_file_at(host: &Host, window: &str, path: PathBuf, rev: Option<S
});
blocking(move || {
if let Some(project) = project {
return project.with_log_repo_of(&path, |r| {
return project.with_log_backend_of(&path, |r| {
r.file_at(&path, rev.as_deref(), project.vcs_encoding(&path, rev.as_deref())?)
});
}
@@ -383,7 +398,7 @@ pub async fn vcs_file_mode(host: &Host, window: &str, path: PathBuf, rev: Option
});
blocking(move || {
if let Some(project) = project {
return project.with_log_repo_of(&path, |r| r.file_mode_at(&path, rev.as_deref()));
return project.with_log_backend_of(&path, |r| r.file_mode_at(&path, rev.as_deref()));
}
external
.ok_or_else(|| ide_core::IdeError::unavailable("external Git log is not open"))?
@@ -403,7 +418,7 @@ pub async fn vcs_blame(
// An explicit text wins (merge view annotates its side panes, not the worktree).
blocking(move || {
let text = text.or_else(|| project.documents.text_of(&path));
project.with_repo_of(&path, |r| {
project.with_backend_of(&path, |r| {
r.blame(&path, text.as_deref(), revision.as_deref(), project.vcs_encoding(&path, revision.as_deref())?)
})
})
@@ -424,7 +439,7 @@ pub async fn vcs_block_history(
let cancel = host.begin_request(request);
let result = blocking(move || {
let text = text.or_else(|| project.documents.text_of(&path));
project.with_repo_of(&path, |r| {
project.with_backend_of(&path, |r| {
r.block_history(&path, text.as_deref(), lines, project.vcs_encoding(&path, None)?, &cancel)
})
})
@@ -441,7 +456,7 @@ pub async fn vcs_log(host: &Host, window: &str, repo: Option<PathBuf>, query: Lo
let result = blocking(move || {
project.with_log_repo_at(repo.as_deref(), |r| {
r.enable_log_index(&project.cache);
r.log_cancellable(&query, &cancel)
(r as &dyn Backend).log(&query, &cancel)
})
})
.await;
@@ -540,7 +555,7 @@ pub async fn vcs_commit_files(
commit: String,
) -> IdeResult<Vec<ChangedFile>> {
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.commit_files(&commit))).await
blocking(move || project.with_log_backend_at(repo.as_deref(), |r| r.commit_files(&commit))).await
}
/// Combined changes of several commits, `commits` oldest first.
@@ -551,7 +566,7 @@ pub async fn vcs_commits_changes(
commits: Vec<String>,
) -> IdeResult<Vec<RevisionChange>> {
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.commits_changes(&commits))).await
blocking(move || project.with_log_backend_at(repo.as_deref(), |r| r.commits_changes(&commits))).await
}
pub async fn vcs_branches(host: &Host, window: &str, repo: Option<PathBuf>) -> IdeResult<Vec<Branch>> {
@@ -567,7 +582,7 @@ pub async fn vcs_stashes(host: &Host, window: &str, repo: Option<PathBuf>) -> Id
pub async fn vcs_stage(host: &Host, window: &str, paths: Vec<PathBuf>) -> IdeResult<()> {
host.require_trust(window)?;
let project = host.project(window)?;
blocking(move || per_repo(&project, &paths, None, |r, paths| r.stage(paths))).await
blocking(move || per_backend(&project, &paths, None, |r, paths| r.stage(paths))).await
}
/// Project-host operation (also registered with the remote dispatcher).
@@ -602,7 +617,7 @@ pub async fn vcs_add_to_ignore(
pub async fn vcs_unstage(host: &Host, window: &str, paths: Vec<PathBuf>) -> IdeResult<()> {
host.require_trust(window)?;
let project = host.project(window)?;
blocking(move || per_repo(&project, &paths, None, |r, paths| r.unstage(paths))).await
blocking(move || per_backend(&project, &paths, None, |r, paths| r.unstage(paths))).await
}
pub async fn vcs_stage_patch(host: &Host, window: &str, patch: String) -> IdeResult<()> {
@@ -614,7 +629,7 @@ pub async fn vcs_stage_patch(host: &Host, window: &str, patch: String) -> IdeRes
pub async fn vcs_discard(host: &Host, window: &str, paths: Vec<PathBuf>) -> IdeResult<()> {
host.require_trust(window)?;
let project = host.project(window)?;
blocking(move || per_repo(&project, &paths, Some("Rollback"), |r, paths| r.discard(paths))).await
blocking(move || per_backend(&project, &paths, Some("Rollback"), |r, paths| r.discard(paths))).await
}
pub async fn vcs_commit(
@@ -628,8 +643,8 @@ pub async fn vcs_commit(
// Commit hooks may run for a long time.
let project = host.project(window)?;
blocking(move || {
project.with_repo_recorded(repo.as_deref(), None, |r| {
r.commit_with(&message, &options, &|path| project.vcs_encoding(path, None))
project.with_backend_recorded(repo.as_deref(), None, |r| {
r.commit(&message, &options, &|path| project.vcs_encoding(path, None))
})
})
.await
@@ -886,7 +901,7 @@ pub async fn vcs_conflict_versions(host: &Host, window: &str, path: PathBuf) ->
pub async fn vcs_diff_text(host: &Host, window: &str, path: PathBuf, staged: bool) -> IdeResult<String> {
let project = host.project(window)?;
blocking(move || project.with_repo_of(&path, |r| r.diff_text(&path, staged, project.vcs_encoding(&path, None)?)))
blocking(move || project.with_backend_of(&path, |r| r.diff_text(&path, staged, project.vcs_encoding(&path, None)?)))
.await
}
@@ -1201,7 +1216,7 @@ pub async fn vcs_diff_with_worktree(
rev: String,
) -> IdeResult<Vec<ChangedFile>> {
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.diff_with_worktree(&rev))).await
blocking(move || project.with_log_backend_at(repo.as_deref(), |r| r.diff_with_worktree(&rev))).await
}
/// Files that differ between two revisions (the "N files updated" of Update Project).
@@ -1213,7 +1228,7 @@ pub async fn vcs_diff_revisions(
to: String,
) -> IdeResult<Vec<ChangedFile>> {
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.diff_revisions(&from, &to))).await
blocking(move || project.with_log_backend_at(repo.as_deref(), |r| r.diff_revisions(&from, &to))).await
}
/// Added and deleted lines between two revisions. `paths` limits the diff; empty diffs every file.
+86 -6
View File
@@ -179,7 +179,7 @@ impl SecretStore for FileSecretStore {
}
/// Largest response body a plugin receives.
const MAX_RESPONSE: usize = 8 << 20;
const MAX_RESPONSE: usize = 16 << 20;
/// Largest stdout / stderr kept from a process.
const MAX_OUTPUT: usize = 16 << 20;
/// Most processes one plugin keeps running at once.
@@ -1276,12 +1276,13 @@ pub(super) fn commit_draft(
};
// Both callers have checked that the window's project is trusted.
r.set_execution_allowed(true);
let pending = r.pending_changes(&repo.options, budget, &|path| Ok(workspace.vcs_encoding(path))).map_err(
|e| match e.kind {
let backend = &r as &dyn ide_vcs::Backend;
let pending = backend
.pending_changes(&repo.options, budget, &|path| Ok(workspace.vcs_encoding(path)))
.map_err(|e| match e.kind {
ide_core::error::ErrorKind::InvalidInput => HostError::Invalid(e.message),
_ => HostError::Failed(e.message),
},
)?;
})?;
let root = to_ipc(r.root());
for c in pending {
budget -= c.diff.as_ref().map_or(0, String::len).min(budget);
@@ -1530,6 +1531,19 @@ impl HostApi for PluginApi {
}
Ok(())
}
fn open_diff(
&self,
client: &str,
diff: ide_plugin::ContentDiff,
file_name: Option<String>,
) -> Result<(), HostError> {
super::execution::validate_diff(&diff)?;
if let Some(name) = &file_name {
super::execution::label(name, 256)?;
}
self.shared.sink.emit(Some(client), PluginEvent::OpenDiff { plugin_id: self.id().into(), diff, file_name });
Ok(())
}
fn reply_content_tab(
&self,
client: &str,
@@ -2267,7 +2281,7 @@ impl HostApi for PluginApi {
let mut response = response;
while let Some(chunk) = response.chunk().await.map_err(|e| HostError::Failed(e.to_string()))? {
if body.len() + chunk.len() > MAX_RESPONSE {
return Err(HostError::Failed("response larger than 8 MiB".into()));
return Err(HostError::Failed(format!("response larger than {} MiB", MAX_RESPONSE >> 20)));
}
body.extend_from_slice(&chunk);
}
@@ -3949,6 +3963,72 @@ mod tests {
worker.join().unwrap();
}
#[test]
fn fetch_accepts_responses_up_to_16_mib_and_rejects_larger_bodies() {
fn serve_bytes(total: usize) -> (u16, std::thread::JoinHandle<()>) {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let handle = std::thread::spawn(move || {
use std::io::{Read, Write};
let (mut stream, _) = listener.accept().unwrap();
let mut buf = [0; 4096];
let _ = stream.read(&mut buf);
let header = format!("HTTP/1.1 200 OK\r\nContent-Length: {total}\r\nConnection: close\r\n\r\n");
if stream.write_all(header.as_bytes()).is_err() {
return;
}
let chunk = [b'x'; 64 << 10];
let mut sent = 0;
while sent < total {
let n = (total - sent).min(chunk.len());
if stream.write_all(&chunk[..n]).is_err() {
break;
}
sent += n;
}
});
(port, handle)
}
let dir = tempfile::tempdir().unwrap();
let api = api(dir.path(), Arc::default());
let (ok_port, ok_worker) = serve_bytes(10 << 20);
let resp = api
.fetch(
HttpRequest {
method: "GET".into(),
url: format!("http://127.0.0.1:{ok_port}/"),
headers: Vec::new(),
body: None,
timeout: Duration::from_secs(10),
grants: vec![Capability::Net("127.0.0.1".into())],
},
&CancellationToken::new(),
)
.expect("10 MiB response within 16 MiB limit succeeds");
assert_eq!(resp.status, 200);
assert_eq!(resp.body.len(), 10 << 20);
ok_worker.join().unwrap();
let (big_port, big_worker) = serve_bytes(MAX_RESPONSE + (1 << 20));
let err = api
.fetch(
HttpRequest {
method: "GET".into(),
url: format!("http://127.0.0.1:{big_port}/"),
headers: Vec::new(),
body: None,
timeout: Duration::from_secs(10),
grants: vec![Capability::Net("127.0.0.1".into())],
},
&CancellationToken::new(),
)
.expect_err("response exceeding 16 MiB fails explicitly");
assert!(matches!(&err, HostError::Failed(msg) if msg.contains("response larger than 16 MiB")), "{err:?}");
big_worker.join().unwrap();
}
#[test]
fn output_ring_keeps_utf8_and_is_withdrawn_on_stop() {
let dir = tempfile::tempdir().unwrap();
+13 -8
View File
@@ -72,14 +72,19 @@ pub(super) fn validate_reply(reply: &ContentTabReply) -> Result<(), HostError> {
return Err(HostError::Invalid("content status or error too long".into()));
}
if let Some(diff) = &reply.diff {
body(&diff.left)?;
body(&diff.right)?;
for value in [&diff.left_label, &diff.right_label].into_iter().flatten() {
label(value, 128)?;
}
if let Some(title) = &diff.title {
label(title, 256)?;
}
validate_diff(diff)?;
}
Ok(())
}
pub(super) fn validate_diff(diff: &ide_plugin::ContentDiff) -> Result<(), HostError> {
body(&diff.left)?;
body(&diff.right)?;
for value in [&diff.left_label, &diff.right_label].into_iter().flatten() {
label(value, 128)?;
}
if let Some(title) = &diff.title {
label(title, 256)?;
}
Ok(())
}
+95 -3
View File
@@ -47,6 +47,48 @@ pub const SECRET_REQUEST: &str = "plugin_secret";
pub const SECRET_WRITE: &str = "plugin_secret_write";
/// OAuth listeners bound on the desktop, never the project server.
pub const AUTH_REQUEST: &str = "plugin_auth";
/// What [`RemotePlugins::register`] adds to a server's commands.
pub const COMMANDS: &[&str] = &[
SYNC_PACKAGES,
"plugin_run_command",
"plugin_set_ui_locale",
"plugin_select_tests",
"plugin_parse_test_results",
"plugin_terminal_session_close",
"plugin_terminal_session_spawn",
"plugin_prepare_debug_run",
"plugin_finish_operation",
"plugin_content_tab_action",
"plugin_post_events",
"plugin_event_kinds",
"plugin_status_items",
"plugin_context_keys",
"plugin_cancel_task",
"plugin_answer_prompt",
"plugin_choose_program",
"plugin_program",
"plugin_answer_command",
"plugin_answer_commit",
"plugin_commit_checks",
"mcp_set_open_editors",
"mcp_set_focused_editor",
"mcp_report_problems",
"plugin_decoration_sets",
"plugin_set_active_file",
"plugin_view_render",
"plugin_view_event",
"plugin_service_detail",
"plugin_service_log",
"plugin_navigation_files",
"plugin_navigation_invalidate",
"plugin_navigation_index",
"plugin_navigation_goto",
"plugin_navigation_markers",
"plugin_endpoints",
"plugin_run_targets",
"plugin_answer_run_target",
"plugin_line_markers",
];
#[derive(Clone, Serialize, Deserialize)]
pub struct SecretWrite {
@@ -134,20 +176,25 @@ pub fn package_digest(dir: &Path, files: &[PathBuf]) -> IdeResult<String> {
}
/// Whether a plugin event means something to a remote window (the desktop
/// owns the plugin list, themes and consent).
/// owns the plugin list, themes and consent). The desktop checks it again on
/// arrival: a server may send anything, and only what acts on the remote project
/// passes. A command may only be another plugin command, which runs on the server.
pub fn forwarded(event: &PluginEvent) -> bool {
if let PluginEvent::ExecuteCommand { id, .. } = event {
return plugin_command(id);
}
matches!(
event,
PluginEvent::RunTargetsChanged
| PluginEvent::WindowRequest { .. }
| PluginEvent::ContentTabReply { .. }
| PluginEvent::OpenDiff { .. }
| PluginEvent::DecorationsChanged { .. }
| PluginEvent::EditorOpen { .. }
| PluginEvent::EditorRanges { .. }
| PluginEvent::Notification { .. }
| PluginEvent::StatusItemChanged { .. }
| PluginEvent::WindowStatusItemChanged { .. }
| PluginEvent::ExecuteCommand { .. }
| PluginEvent::OutputChanged { .. }
| PluginEvent::RevealOutput { .. }
| PluginEvent::ContextChanged { .. }
@@ -163,7 +210,6 @@ pub fn forwarded(event: &PluginEvent) -> bool {
| PluginEvent::OpenLogTab { .. }
| PluginEvent::CopyToClipboard { .. }
| PluginEvent::OpenUrl { .. }
| PluginEvent::OpenProject { .. }
| PluginEvent::PromptRequested { .. }
| PluginEvent::PromptClosed { .. }
| PluginEvent::PromptWaiting { .. }
@@ -173,6 +219,13 @@ pub fn forwarded(event: &PluginEvent) -> bool {
)
}
/// `plugin.<plugin id>.<command id>`, as the workbench names plugin commands.
fn plugin_command(id: &str) -> bool {
id.strip_prefix("plugin.")
.and_then(|rest| rest.split_once('.'))
.is_some_and(|(plugin, command)| !plugin.is_empty() && !command.is_empty() && !command.ends_with('.'))
}
// ---- desktop side -----------------------------------------------------------
impl PluginHost {
@@ -951,4 +1004,43 @@ mod tests {
assert!(valid_id("ai-completion"));
assert!(!valid_id("../x") && !valid_id(".data") && !valid_id(""));
}
fn execute(id: &str) -> PluginEvent {
PluginEvent::ExecuteCommand {
request_id: "1".into(),
id: id.into(),
arg: Some(r"\\host.example\share\pkg".into()),
chain: vec!["tools".into()],
}
}
#[test]
fn a_server_runs_only_plugin_commands_in_the_desktop_window() {
assert!(forwarded(&execute("plugin.tools.build")));
assert!(forwarded(&execute("plugin.acme.tools.build.all")));
for id in [
"plugins.installFromDisk",
"project.revokeTrust",
"project.removeTrustedLocation",
"ssh.startSession",
"file.openProjectPath",
"terminal.runCommand",
"plugin.",
"plugin.tools",
"plugin..build",
"plugin.tools.",
] {
assert!(!forwarded(&execute(id)), "{id}");
}
}
#[test]
fn a_server_does_not_open_desktop_folders() {
let open = PluginEvent::OpenProject {
plugin_id: "tools".into(),
path: r"\\host.example\share\project".into(),
new_window: true,
};
assert!(!forwarded(&open));
}
}
+3
View File
@@ -218,6 +218,9 @@ pub enum PluginEvent {
/// Reply to a content action in this window.
#[serde(rename_all = "camelCase")]
ContentTabReply { plugin_id: String, request_id: String, reply: ide_plugin::ContentTabReply },
/// Open two texts in this window's diff viewer.
#[serde(rename_all = "camelCase")]
OpenDiff { plugin_id: String, diff: ide_plugin::ContentDiff, file_name: Option<String> },
/// Plugins, their status or their contributions changed: re-fetch `plugin_list`.
CommandsChanged,
+87 -5
View File
@@ -8,7 +8,7 @@ use ide_language::{FileTypes, LanguageRegistry, SyntaxCache};
use ide_lsp::LspManager;
use ide_syntax::StructureService;
use ide_text::{AppliedChange, ByteEdit};
use ide_vcs::{Repo, Repos};
use ide_vcs::{Backend, Repo, Repos};
use ide_vfs::Watcher;
use ide_workspace::documents::DocumentObserver;
use ide_workspace::{DocumentInfo, Documents, FileMoves, LocalHistory, ProjectRoots, SettingsStore, SourceFolder};
@@ -541,7 +541,7 @@ impl Host {
mark("git");
tracing::info!("opened {} in window {window} in {} ms {laps:?}", root.display(), started.elapsed().as_millis());
let (db, http_history, agents) = self.project_services(&root, &project_key, &settings);
let (db, http_history, agents) = self.project_services(&root, &project_key, &settings, &sink);
let db_sources_watch = db_sources_watch(&db, &sink, &self.runtime);
let notebooks = crate::notebook::NotebookService::new(
self.local_data_dir.join("notebook-runtime").join(uuid::Uuid::new_v4().to_string()),
@@ -634,6 +634,7 @@ impl Host {
root: &Path,
project_key: &str,
settings: &Arc<SettingsStore>,
sink: &SharedSink,
) -> (Arc<ide_db::DbManager>, Arc<ide_http::History>, Arc<ide_agent::AgentManager>) {
let db = Arc::new(
ide_db::DbManager::new(
@@ -660,12 +661,23 @@ impl Host {
models_file: self.data_dir.join("agents").join("models.json"),
custom_agents_dir: Some(self.config_dir.join("agents")),
home_dir: dirs::home_dir(),
env_secrets: Some(Arc::new(AgentEnvSecrets {
secrets: self.agent_secrets.clone(),
sink: sink.clone(),
told: AtomicBool::new(false),
})),
},
self.runtime.clone(),
));
let settings = settings.clone();
{
let settings = settings.clone();
agents.set_new_task_approvals_hook(Arc::new(move || settings.effective().agents.new_task_approvals));
}
let approvals = self.mcp_approvals.clone();
let root = root.to_path_buf();
agents.set_mcp_servers_hook(Box::new(move || {
ide_agent::mcp::servers_from_settings(&Default::default(), &settings.effective().agents.mcp_servers)
crate::ops::agent::session_mcp_servers(&settings, &approvals, &root)
}));
(db, http_history, agents)
}
@@ -708,7 +720,7 @@ impl Host {
documents.add_observer(Arc::new(HistoryObserver(history.clone())));
let watcher = light_watcher(&dir, &sink, &documents, &history)?;
let project_key = project_key(&dir);
let (db, http_history, agents) = self.project_services(&dir, &project_key, &settings);
let (db, http_history, agents) = self.project_services(&dir, &project_key, &settings, &sink);
let notebooks = crate::notebook::NotebookService::new(
self.local_data_dir.join("notebook-runtime").join(uuid::Uuid::new_v4().to_string()),
sink.clone(),
@@ -959,6 +971,11 @@ impl Project {
}
}
/// `with_repo_of` through the backend-agnostic Changes-view surface.
pub fn with_backend_of<T>(&self, path: &Path, f: impl FnOnce(&dyn Backend) -> IdeResult<T>) -> IdeResult<T> {
self.with_repo_of(path, |r| f(r as &dyn Backend))
}
fn run_in<T>(&self, repo: &Repo, f: impl FnOnce(&Repo) -> IdeResult<T>) -> IdeResult<T> {
let settings = self.settings.effective();
if self.is_trusted() {
@@ -968,6 +985,40 @@ impl Project {
f(repo)
}
/// `with_repo_at` through the backend-agnostic Changes-view surface.
pub fn with_backend_at<T>(&self, at: Option<&Path>, f: impl FnOnce(&dyn Backend) -> IdeResult<T>) -> IdeResult<T> {
self.with_repo_at(at, |r| f(r as &dyn Backend))
}
/// `with_log_repo_at` through the backend-agnostic Changes-view surface.
pub fn with_log_backend_at<T>(
&self,
at: Option<&Path>,
f: impl FnOnce(&dyn Backend) -> IdeResult<T>,
) -> IdeResult<T> {
self.with_log_repo_at(at, |r| f(r as &dyn Backend))
}
/// `with_log_repo_of` through the backend-agnostic Changes-view surface.
pub fn with_log_backend_of<T>(&self, path: &Path, f: impl FnOnce(&dyn Backend) -> IdeResult<T>) -> IdeResult<T> {
self.with_log_repo_of(path, |r| f(r as &dyn Backend))
}
/// `with_repo` through the backend-agnostic Changes-view surface.
pub fn with_backend<T>(&self, f: impl FnOnce(&dyn Backend) -> IdeResult<T>) -> IdeResult<T> {
self.with_backend_at(None, f)
}
/// `with_repo_recorded` through the backend-agnostic Changes-view surface.
pub fn with_backend_recorded<T>(
&self,
at: Option<&Path>,
operation: Option<&str>,
f: impl FnOnce(&dyn Backend) -> IdeResult<T>,
) -> IdeResult<T> {
self.with_repo_recorded(at, operation, |r| f(r as &dyn Backend))
}
/// Runs `f` with the project's primary git repository, or fails when the project is not under git.
pub fn with_repo<T>(&self, f: impl FnOnce(&Repo) -> IdeResult<T>) -> IdeResult<T> {
self.with_repo_at(None, f)
@@ -992,7 +1043,7 @@ impl Project {
) -> IdeResult<T> {
let Some(name) = operation else { return self.with_disk_sync(|| self.with_repo_at(at, f)) };
self.history_label(&format!("Before {name}"));
if let Ok(changed) = self.with_repo_at(at, |r| r.status()) {
if let Ok(changed) = self.with_backend_at(at, |r| r.status()) {
for file in changed.iter().take(HISTORY_BATCH_LIMIT) {
if let Err(e) = self.history.record_file(&file.path, &format!("Before {name}"), false) {
tracing::debug!("local history: {e}");
@@ -1586,6 +1637,37 @@ pub(crate) fn project_key(root: &Path) -> String {
format!("{h:016x}")
}
/// The environment values of agent files in [`crate::agent_secrets::AgentSecrets`]; the
/// window is told (once) when they had to go to the owner-only file instead.
struct AgentEnvSecrets {
secrets: Arc<crate::agent_secrets::AgentSecrets>,
sink: SharedSink,
told: AtomicBool,
}
impl ide_agent::EnvSecrets for AgentEnvSecrets {
fn get(&self, key: &str) -> Option<String> {
self.secrets.get(key)
}
fn set(&self, key: &str, value: &str) -> IdeResult<()> {
if self.secrets.set(key, value)? == crate::agent_secrets::Kept::LocalFile
&& !self.told.swap(true, Ordering::Relaxed)
{
self.secrets.notify_kept_locally(&self.sink);
}
Ok(())
}
fn delete(&self, key: &str) {
self.secrets.delete(key);
}
fn keys(&self, prefix: &str) -> Vec<String> {
self.secrets.keys(prefix)
}
}
struct LspObserver(Arc<LspManager>);
impl DocumentObserver for LspObserver {
+106 -3
View File
@@ -185,15 +185,15 @@ impl Dispatcher {
.add("fs_entry_kind", |c| async move { ops::fs::entry_kind(&c.host, c.arg("path")?).await })
.add("fs_create_file", |c| async move { ops::fs::create_file(&c.host, &c.window, c.arg("path")?) })
.add("fs_create_file_bytes", |c| async move {
ops::fs::create_file_bytes(&c.host, c.arg("path")?, c.arg("data")?).await
ops::fs::create_file_bytes(&c.host, &c.window, c.arg("path")?, c.arg("data")?).await
})
.add("fs_create_dir", |c| async move { ide_vfs::create_dir(&c.arg::<PathBuf>("path")?) })
.add("fs_rename", |c| async move { ops::fs::rename(&c.host, c.arg("from")?, c.arg("to")?).await })
.add("fs_rename", |c| async move { ops::fs::rename(&c.host, &c.window, c.arg("from")?, c.arg("to")?).await })
.add("fs_set_writable", |c| async move {
ops::fs::set_writable(&c.host, &c.window, c.arg("path")?, c.arg("writable")?)
})
.add("fs_delete", |c| async move { ops::fs::delete(&c.host, &c.window, c.arg("path")?).await })
.add("fs_copy", |c| async move { ops::fs::copy(&c.host, c.arg("from")?, c.arg("to")?).await })
.add("fs_copy", |c| async move { ops::fs::copy(&c.host, &c.window, c.arg("from")?, c.arg("to")?).await })
.add("fs_copy_merge", |c| async move {
ops::fs::copy_merge(
&c.host,
@@ -298,6 +298,56 @@ pub fn event_sink(peer: Arc<OnceLock<Peer>>, window: Option<String>) -> ide_core
Arc::new(PeerSink { peer, window })
}
/// Whether a desktop shows an `event` notification of its server in the window. A server reports
/// on its project; what this machine answers or owns itself (MCP approvals, app updates, window
/// groups) never comes from it.
pub fn client_accepts(event: &IdeEvent) -> bool {
match event {
IdeEvent::McpRequest { .. }
| IdeEvent::McpRequestClosed { .. }
| IdeEvent::WindowGroupsChanged
| IdeEvent::WindowFullscreenChanged
| IdeEvent::UpdateStatusChanged { .. }
| IdeEvent::UpdateAvailable { .. } => false,
IdeEvent::DbSourcesChanged
| IdeEvent::ProjectTrustChanged { .. }
| IdeEvent::FsChanged { .. }
| IdeEvent::ExcludedDirsChanged { .. }
| IdeEvent::DocPatched { .. }
| IdeEvent::DocDirty { .. }
| IdeEvent::WorkspaceEditApplied { .. }
| IdeEvent::DocLanguage { .. }
| IdeEvent::Diagnostics { .. }
| IdeEvent::Task { .. }
| IdeEvent::IndexState { .. }
| IdeEvent::VcsChanged { .. }
| IdeEvent::LanguageServer { .. }
| IdeEvent::LanguageServerWarning { .. }
| IdeEvent::LanguageServerRemoved { .. }
| IdeEvent::LspRefresh { .. }
| IdeEvent::TerminalExited { .. }
| IdeEvent::RunExited { .. }
| IdeEvent::Debug { .. }
| IdeEvent::Notify { .. }
| IdeEvent::SettingsChanged { .. }
| IdeEvent::ProjectRootsChanged { .. }
| IdeEvent::Askpass { .. }
| IdeEvent::AskpassDone { .. }
| IdeEvent::NotebookKernel { .. } => true,
}
}
/// The commands a desktop routes to its server: what `openidea-server` of this build answers,
/// fixed here rather than taken from the server, so a server cannot move a command to this machine.
pub fn client_commands() -> Vec<&'static str> {
let mut out = Dispatcher::standard().commands();
out.extend(crate::plugins::remote_host::COMMANDS);
out.push(crate::plugins::remote::SYNC_COMMAND);
out.sort_unstable();
out.dedup();
out
}
struct PeerSink {
peer: Arc<OnceLock<Peer>>,
window: Option<String>,
@@ -364,3 +414,56 @@ pub fn error_from_reply(e: ide_jsonrpc::RpcError) -> IdeError {
other => other.into(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use ide_core::event::{AskpassKind, McpUiRequest, NotifyLevel};
#[test]
fn a_server_cannot_raise_desktop_prompts() {
let confirm = McpUiRequest::Confirm {
client: "agent".into(),
tool: "run".into(),
subject: "rm -rf".into(),
project: "/srv/app".into(),
detail: None,
};
let update = ide_core::updates::UpdateInfo {
version: "9.9.9".into(),
current_version: "0.1.0".into(),
notes: None,
date: None,
};
for event in [
IdeEvent::McpRequest { id: 1, request: confirm },
IdeEvent::McpRequestClosed { id: 1 },
IdeEvent::UpdateAvailable { update },
IdeEvent::UpdateStatusChanged {
status: ide_core::updates::UpdateStatus { last_checked: None, checking: true },
},
IdeEvent::WindowGroupsChanged,
IdeEvent::WindowFullscreenChanged,
] {
assert!(!client_accepts(&event), "{event:?}");
}
}
#[test]
fn a_server_reports_its_project_to_the_desktop() {
for event in [
IdeEvent::FsChanged {
created: vec!["/srv/app/a".into()],
modified: vec![],
removed: vec![],
ignore_rules: vec![],
},
IdeEvent::VcsChanged { root: "/srv/app".into() },
IdeEvent::Notify { level: NotifyLevel::Info, title: "Build".into(), body: None },
IdeEvent::Askpass { id: 1, prompt: "Password".into(), kind: AskpassKind::Secret, remember: false },
IdeEvent::ProjectTrustChanged { root: "/srv/app".into(), trusted: true },
] {
assert!(client_accepts(&event), "{event:?}");
}
}
}
+112 -2
View File
@@ -18,6 +18,15 @@ pub struct TrustStore {
state: Mutex<IdeResult<TrustDecisions>>,
/// Folders trusted for this process only, by the client that started it ([`TRUSTED_ROOT_ENV`]).
inherited: Mutex<Vec<PathBuf>>,
remote_file: PathBuf,
remote: Mutex<IdeResult<RemoteDecisions>>,
}
/// This machine's decisions about remote projects, by location (see [`TrustStore::remote_decision`]).
#[derive(Clone, Default, Serialize, Deserialize)]
struct RemoteDecisions {
trusted: Vec<String>,
restricted: Vec<String>,
}
/// Set by the desktop when it starts the server in a dev container of a folder the user trusts
@@ -40,7 +49,37 @@ impl TrustStore {
}
Err(e) => Err(e.into()),
};
Self { file, state: Mutex::new(state), inherited: Mutex::default() }
let remote_file = data.join("remote-project-trust.json");
let remote = match std::fs::read(&remote_file) {
Ok(bytes) => serde_json::from_slice(&bytes).map_err(|e| IdeError::invalid(e.to_string())),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(RemoteDecisions::default()),
Err(e) => Err(e.into()),
};
Self { file, state: Mutex::new(state), inherited: Mutex::default(), remote_file, remote: Mutex::new(remote) }
}
/// The decision made on this machine for the remote project at `location` (`None`: none yet).
/// Commands a remote window runs here follow it, not the server's own trust.
pub fn remote_decision(&self, location: &str) -> IdeResult<Option<bool>> {
let remote = self.remote.lock().clone()?;
Ok(if remote.restricted.iter().any(|l| l == location) {
Some(false)
} else if remote.trusted.iter().any(|l| l == location) {
Some(true)
} else {
None
})
}
pub fn decide_remote(&self, location: &str, trusted: bool) -> IdeResult<()> {
let mut state = self.remote.lock();
let mut next = state.clone()?;
next.trusted.retain(|l| l != location);
next.restricted.retain(|l| l != location);
if trusted { &mut next.trusted } else { &mut next.restricted }.push(location.to_owned());
persist(&self.remote_file, &next)?;
*state = Ok(next);
Ok(())
}
pub fn decisions(&self) -> IdeResult<TrustDecisions> {
@@ -133,7 +172,7 @@ impl TrustStore {
}
}
fn persist(file: &Path, state: &TrustDecisions) -> IdeResult<()> {
fn persist<T: Serialize>(file: &Path, state: &T) -> IdeResult<()> {
if let Some(parent) = file.parent() {
std::fs::create_dir_all(parent)?;
}
@@ -142,6 +181,61 @@ fn persist(file: &Path, state: &TrustDecisions) -> IdeResult<()> {
Ok(())
}
type McpServers = std::collections::BTreeMap<String, ide_core::settings::AgentMcpServer>;
/// Workspace MCP servers (a project's `agents.mcpServers`) the user turned on in Settings, by a
/// fingerprint of the project and the server's definition. Agents start a workspace server only
/// while its definition is one the user turned on, so a server added or changed in the project's
/// file stays off until then.
pub struct McpServerApprovals {
file: PathBuf,
approved: Mutex<std::collections::BTreeSet<String>>,
}
impl McpServerApprovals {
pub fn load(data: &Path) -> Self {
let file = data.join("mcp-server-approvals.json");
let approved = std::fs::read(&file).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default();
Self { file, approved: Mutex::new(approved) }
}
fn fingerprint(root: &Path, name: &str, server: &ide_core::settings::AgentMcpServer) -> String {
// Turning a server off and on again doesn't change what it runs.
let server = ide_core::settings::AgentMcpServer { enabled: true, ..server.clone() };
let bytes = serde_json::to_vec(&(root, name, server)).expect("MCP server serializes");
blake3::hash(&bytes).to_hex().to_string()
}
pub fn approved(&self, root: &Path, name: &str, server: &ide_core::settings::AgentMcpServer) -> bool {
self.approved.lock().contains(&Self::fingerprint(root, name, server))
}
pub fn approve(&self, root: &Path, name: &str, server: &ide_core::settings::AgentMcpServer) -> IdeResult<()> {
let fingerprint = Self::fingerprint(root, name, server);
let mut approved = self.approved.lock();
if approved.contains(&fingerprint) {
return Ok(());
}
let mut next = approved.clone();
next.insert(fingerprint);
if let Some(parent) = self.file.parent() {
std::fs::create_dir_all(parent)?;
}
let bytes = serde_json::to_vec_pretty(&next).map_err(|e| IdeError::internal(e.to_string()))?;
ide_vfs::write_atomic(&self.file, &bytes)?;
*approved = next;
Ok(())
}
/// `workspace` with the servers the user hasn't turned on switched off.
pub fn apply(&self, root: &Path, mut workspace: McpServers) -> McpServers {
for (name, server) in &mut workspace {
server.enabled &= self.approved(root, name, server);
}
workspace
}
}
impl crate::Host {
pub async fn set_project_trust(&self, path: &Path, trusted: bool, folder: bool) -> IdeResult<()> {
self.trust.decide(path, trusted, folder)?;
@@ -317,6 +411,8 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
| "remote_current"
| "remote_disconnect"
| "remote_connect"
| "remote_trust_status"
| "remote_trust_set"
| "remote_bootstrap_list"
| "wsl_distros"
| "wsl_resolve"
@@ -471,4 +567,18 @@ mod tests {
fn restricted_mode_allows_clipboard_write_text() {
assert!(super::restricted_command_allowed("clipboard_write_text"));
}
#[test]
fn remote_project_decisions_are_kept_on_this_machine() {
let tmp = tempfile::tempdir().unwrap();
let load = || super::TrustStore::load(&tmp.path().join("config"), &tmp.path().join("data"));
let store = load();
let here = r#"[{"kind":"ssh","config":"box"},"/srv/app"]"#;
assert_eq!(store.remote_decision(here).unwrap(), None);
store.decide_remote(here, true).unwrap();
assert_eq!(store.remote_decision(here).unwrap(), Some(true));
assert_eq!(store.remote_decision(r#"[{"kind":"ssh","config":"box"},"/srv/other"]"#).unwrap(), None);
store.decide_remote(here, false).unwrap();
assert_eq!(load().remote_decision(here).unwrap(), Some(false));
}
}
+57
View File
@@ -0,0 +1,57 @@
//! The mode new agent tasks start in.
use ide_core::NullSink;
use ide_host::{Host, HostConfig};
use serde_json::json;
use std::path::Path;
use std::sync::Arc;
/// Codex's modes as it reported them last time, in "ask".
fn remember_codex_modes(data: &Path) {
std::fs::create_dir_all(data.join("agents")).unwrap();
let modes = json!({ "codex": { "config": [ {
"id": "mode", "name": "Mode", "description": null, "category": "mode", "current": "ask",
"values": [ { "value": "read-only", "name": "Read Only", "description": null },
{ "value": "ask", "name": "Ask", "description": null },
{ "value": "full-access", "name": "Full Access", "description": null } ],
"via": "option" } ] } });
std::fs::write(data.join("agents/models.json"), modes.to_string()).unwrap();
}
fn codex_mode(host: &Host) -> Option<String> {
let providers = ide_host::ops::agent::providers(host, "main").unwrap();
let codex = providers.into_iter().find(|p| p.id == "codex").expect("codex is listed");
codex.config.into_iter().find(|o| o.id == "mode").and_then(|o| o.current)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn new_tasks_start_asking_for_approval_when_the_user_chose_so() {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap().join("project");
std::fs::create_dir_all(root.join(".openidea")).unwrap();
let (config, data) = (tmp.path().join("config"), tmp.path().join("data"));
remember_codex_modes(&data);
std::fs::create_dir_all(&config).unwrap();
std::fs::write(config.join("settings.toml"), "[agents]\nnewTaskApprovals = \"ask\"\n").unwrap();
// A project's own settings don't choose this.
std::fs::write(root.join(".openidea/settings.toml"), "[agents]\nnewTaskApprovals = \"bypass\"\n").unwrap();
let host = Arc::new(Host::new(HostConfig {
config_dir: config.clone(),
data_dir: data,
local_data_dir: tmp.path().join("local"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
}));
host.trust.decide(&root, true, false).unwrap();
host.open_project("main", &root).unwrap();
assert_eq!(codex_mode(&host).as_deref(), Some("ask"));
// The default keeps starting new tasks without approvals.
std::fs::remove_file(config.join("settings.toml")).unwrap();
host.project("main").unwrap().settings.reload();
assert_eq!(codex_mode(&host).as_deref(), Some("full-access"));
}
@@ -0,0 +1,86 @@
//! Values of agents' environment variables live in the credential store; agent files keep only their names.
use ide_core::NullSink;
use ide_host::{Host, HostConfig};
use ide_ssh::secrets::testing::{files_containing, use_memory_store};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
const VALUE: &str = "agent-env-value-5b2d81";
fn host(dir: &Path) -> Arc<Host> {
Arc::new(Host::new(HostConfig {
config_dir: dir.join("config"),
data_dir: dir.join("data"),
local_data_dir: dir.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
}))
}
fn open(host: &Host, tmp: &tempfile::TempDir) {
let root = dunce::canonicalize(tmp.path()).unwrap().join("project");
std::fs::create_dir_all(&root).unwrap();
host.trust.decide(&root, true, false).unwrap();
host.open_project("main", &root).unwrap();
}
fn env_of(host: &Host, id: &str) -> BTreeMap<String, String> {
let providers = ide_host::ops::agent::providers(host, "main").unwrap();
providers.into_iter().find(|p| p.id == id).unwrap_or_else(|| panic!("{id}")).env
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn agent_environment_values_stay_out_of_agent_files() {
use_memory_store();
let tmp = tempfile::tempdir().unwrap();
let host = host(tmp.path());
open(&host, &tmp);
let env = BTreeMap::from([("ANTHROPIC_API_KEY".to_owned(), VALUE.to_owned())]);
ide_host::ops::agent::set_env(&host, "main", "claude".into(), env.clone()).unwrap();
ide_host::ops::agent::save_custom_agent(
&host,
"main",
ide_agent::CustomAgentInput {
id: "mine".into(),
name: "Mine".into(),
command: "/opt/mine".into(),
args: vec![],
env: env.clone(),
},
)
.unwrap();
host.close_project("main");
assert_eq!(files_containing(tmp.path(), VALUE), Vec::<PathBuf>::new());
let host = self::host(tmp.path());
open(&host, &tmp);
assert_eq!(env_of(&host, "claude"), env);
assert_eq!(env_of(&host, "mine"), env);
ide_host::ops::agent::remove_custom_agent(&host, "main", "mine".into()).unwrap();
ide_host::ops::agent::set_env(&host, "main", "claude".into(), BTreeMap::new()).unwrap();
assert_eq!(host.agent_secrets.keys("agent/"), Vec::<String>::new());
host.close_project("main");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn values_in_agent_files_move_to_the_credential_store() {
use_memory_store();
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join("config/agents")).unwrap();
std::fs::write(
tmp.path().join("config/agents/mine.toml"),
format!("command = \"/opt/mine\"\n[env]\nTOKEN = \"{VALUE}\"\n"),
)
.unwrap();
let host = host(tmp.path());
open(&host, &tmp);
assert_eq!(files_containing(tmp.path(), VALUE), Vec::<PathBuf>::new());
assert_eq!(env_of(&host, "mine"), BTreeMap::from([("TOKEN".to_owned(), VALUE.to_owned())]));
host.close_project("main");
}
+77
View File
@@ -0,0 +1,77 @@
//! External MCP servers in Settings › Agents, as the host stores and lists them.
use ide_agent::{McpScope, McpServer, McpServerInput};
use ide_core::NullSink;
use ide_core::settings::McpTransport;
use ide_host::ops::agent::{mcp_import, mcp_list, mcp_save, mcp_set_enabled};
use ide_host::{Host, HostConfig};
use std::path::{Path, PathBuf};
use std::sync::Arc;
fn open(base: &Path) -> (Arc<Host>, PathBuf) {
let root = dunce::canonicalize(base).unwrap().join("project");
std::fs::create_dir_all(root.join(".openidea")).unwrap();
let host = Arc::new(Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
}));
host.trust.decide(&root, true, false).unwrap();
host.open_project("main", &root).unwrap();
(host, root)
}
fn enabled(list: &[McpServer], id: &str) -> bool {
list.iter().find(|s| s.id == id).unwrap_or_else(|| panic!("{id} not listed: {list:?}")).enabled
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn workspace_servers_from_the_project_file_stay_off_until_the_user_turns_them_on() {
let tmp = tempfile::tempdir().unwrap();
let (host, root) = open(tmp.path());
let settings_file = root.join(".openidea/settings.toml");
std::fs::write(&settings_file, "[agents.mcpServers.helper]\ncommand = \"node\"\nargs = [\"helper.js\"]\n").unwrap();
host.project("main").unwrap().settings.reload();
assert!(!enabled(&mcp_list(&host, "main").unwrap(), "workspace::helper"));
assert!(enabled(&mcp_set_enabled(&host, "main", "workspace::helper".into(), true).unwrap(), "workspace::helper"));
assert!(enabled(&mcp_list(&host, "main").unwrap(), "workspace::helper"));
// A changed definition is off again until turned back on.
std::fs::write(&settings_file, "[agents.mcpServers.helper]\ncommand = \"node\"\nargs = [\"other.js\"]\n").unwrap();
host.project("main").unwrap().settings.reload();
assert!(!enabled(&mcp_list(&host, "main").unwrap(), "workspace::helper"));
// What the user adds in Settings is on.
let saved = mcp_save(
&host,
"main",
McpServerInput {
id: None,
name: "mine".into(),
scope: McpScope::Workspace,
transport: McpTransport::Stdio,
enabled: true,
command: Some("uvx".into()),
args: vec!["mcp-server-git".into()],
env: vec![],
url: None,
headers: vec![],
agents: vec![],
},
)
.unwrap();
assert!(enabled(&saved, "workspace::mine"));
// Imported servers come from the project's own files: off until turned on.
std::fs::write(root.join(".mcp.json"), r#"{"mcpServers":{"imported":{"command":"uvx","args":["x"]}}}"#).unwrap();
let imported = mcp_import(&host, "main").unwrap();
assert!(!enabled(&imported, "workspace::imported"));
assert!(enabled(&imported, "workspace::mine"));
}
+25 -1
View File
@@ -6352,6 +6352,26 @@ fn api14_stage3_configurations_test_results_and_content_actions_use_real_exports
assert!(f.host.parse_test_results("main", "plugin:execution:dynamic", "trap".into()).unwrap().is_none());
}
#[cfg(unix)]
#[test]
fn a_plugin_command_opens_a_diff_in_its_window() {
let f = execution_fixture();
f.take_events();
f.run("execution", "diff", None).unwrap();
f.host.flush_events_at(Instant::now() + Duration::from_secs(3));
assert!(eventually(|| {
f.events.lock().iter().any(|(client, event)| {
client.as_deref() == Some("main")
&& matches!(event, PluginEvent::OpenDiff { plugin_id, diff, file_name }
if plugin_id == "execution"
&& diff.left == "before\n"
&& diff.right == "after\n"
&& diff.title.as_deref() == Some("Review")
&& file_name.as_deref() == Some("src/review.rs"))
})
}));
}
#[test]
fn api14_fixture_exports_test_selection() {
let f = fixture(Limits::default());
@@ -6682,7 +6702,11 @@ fn project_write_cannot_replace_the_git_file_of_a_linked_worktree() {
}
for rel in spellings {
let err = f.run("misbehave", "projwrite", Some(rel)).unwrap_err();
assert!(err.contains("git's own files"), "{rel}: {err}");
// On Windows a stream name is refused before the path is looked at at all; elsewhere a colon
// is an ordinary character and the `.git` rule applies.
let refused =
if cfg!(windows) && rel.contains(':') { "file streams are not allowed" } else { "git's own files" };
assert!(err.contains(refused), "{rel}: {err}");
assert_eq!(std::fs::read_to_string(&pointer).unwrap(), gitdir, "{rel}");
}
}
+26
View File
@@ -455,3 +455,29 @@ async fn properties_conversion_through_remote_dispatch_without_a_remote_host() {
invoke(&peer, "close_project", json!({})).await.unwrap();
peer.kill();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn the_desktop_routes_exactly_what_a_server_of_this_build_answers() {
let tmp = tempfile::tempdir().unwrap();
let base = tmp.path().join("server");
let peer_slot = Arc::new(OnceLock::new());
let host = Arc::new(Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(ide_core::NullSink)),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
}));
// As `openidea-server` assembles its commands.
let plugins = ide_host::plugins::remote_host::RemotePlugins::start(&base.join("data"), &host, peer_slot).unwrap();
let mut dispatcher = Dispatcher::standard();
plugins.register(&mut dispatcher);
dispatcher.add(ide_host::plugins::remote::SYNC_COMMAND, |_| async { Ok(()) });
assert_eq!(rpc::client_commands(), dispatcher.commands());
assert!(rpc::client_commands().contains(&"term_spawn"));
plugins.host().shutdown();
}
+2 -2
View File
@@ -83,10 +83,10 @@ async fn opens_unity_without_generated_solutions_and_excludes_caches_from_the_in
};
let original_metadata = "fileFormatVersion: 2\nguid: 11111111111111111111111111111111\n";
std::fs::write(meta(&original), original_metadata).unwrap();
ops::fs::rename(&host, original.clone(), renamed.clone()).await.unwrap();
ops::fs::rename(&host, "main", original.clone(), renamed.clone()).await.unwrap();
assert!(!original.exists() && !meta(&original).exists());
assert_eq!(std::fs::read_to_string(meta(&renamed)).unwrap(), original_metadata);
ops::fs::copy(&host, renamed.clone(), copied.clone()).await.unwrap();
ops::fs::copy(&host, "main", renamed.clone(), copied.clone()).await.unwrap();
assert!(copied.exists());
assert_ne!(std::fs::read_to_string(meta(&copied)).unwrap(), original_metadata);
assert_eq!(std::fs::read_to_string(meta(&renamed)).unwrap(), original_metadata);
+3
View File
@@ -21,6 +21,9 @@ ignore.workspace = true
globset.workspace = true
postcard = { version = "1.1", default-features = false, features = ["use-std"] }
[build-dependencies]
blake3.workspace = true
[dev-dependencies]
tempfile.workspace = true
criterion = { version = "0.5", default-features = false }
+33
View File
@@ -0,0 +1,33 @@
//! Hashes this crate's sources into `INDEX_SOURCE_HASH`: what is extracted and stored for a
//! file (ignored state, size limit, record layout) is decided here as well as in
//! `ide-language`, so the persistent index is rebuilt when either changes.
use std::path::{Path, PathBuf};
fn main() {
let src = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()).join("src");
println!("cargo:rerun-if-changed={}", src.display());
let mut files = Vec::new();
collect(&src, &mut files);
files.sort();
let mut h = blake3::Hasher::new();
for file in files {
h.update(file.strip_prefix(&src).unwrap_or(&file).to_string_lossy().replace('\\', "/").as_bytes());
h.update(b"\0");
h.update(&std::fs::read(&file).unwrap());
h.update(b"\0");
}
println!("cargo:rustc-env=INDEX_SOURCE_HASH={}", &h.finalize().to_hex()[..16]);
}
fn collect(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else { return };
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
collect(&path, out);
} else {
out.push(path);
}
}
}
+1 -1
View File
@@ -45,7 +45,7 @@ impl Store {
}
};
let store = Self { db: RwLock::new(db) };
let stamp = format!("{SCHEMA}\n{fingerprint}\n{}", root.display());
let stamp = format!("{SCHEMA}\n{}\n{fingerprint}\n{}", env!("INDEX_SOURCE_HASH"), root.display());
match store.meta("stamp") {
Ok(Some(v)) if v == stamp.as_bytes() => {}
_ => {
+3
View File
@@ -58,6 +58,9 @@ tree-sitter-graphql = "0.3.0"
tree-sitter-prisma-io = "1.6.0"
tree-sitter-gdscript = "6.1.0"
[build-dependencies]
blake3.workspace = true
[dev-dependencies]
criterion = { version = "0.5", default-features = false }
tempfile.workspace = true
+61
View File
@@ -0,0 +1,61 @@
//! Hashes everything that decides what `Syntax::tags` extracts (this crate's
//! sources, queries, language definitions and the locked tree-sitter crates)
//! into `SYNTAX_SOURCE_HASH`, so persistent caches survive releases that leave
//! extraction untouched.
use std::path::{Path, PathBuf};
fn main() {
let manifest = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap());
let workspace = manifest.join("../..");
let lock = workspace.join("Cargo.lock");
let inputs = [manifest.join("src"), manifest.join("queries"), workspace.join("languages")];
let mut h = blake3::Hasher::new();
for dir in &inputs {
println!("cargo:rerun-if-changed={}", dir.display());
let mut files = Vec::new();
collect(dir, &mut files);
files.sort();
for file in files {
let rel = file.strip_prefix(&workspace).unwrap_or(&file);
h.update(rel.to_string_lossy().replace('\\', "/").as_bytes());
h.update(b"\0");
h.update(&std::fs::read(&file).unwrap());
h.update(b"\0");
}
}
println!("cargo:rerun-if-changed={}", lock.display());
match std::fs::read_to_string(&lock) {
Ok(text) => h.update(tree_sitter_packages(&text).as_bytes()),
// Built outside the workspace: fall back to the crate version.
Err(_) => h.update(std::env::var("CARGO_PKG_VERSION").unwrap().as_bytes()),
};
println!("cargo:rustc-env=SYNTAX_SOURCE_HASH={}", &h.finalize().to_hex()[..16]);
}
fn collect(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else { return };
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
collect(&path, out);
} else {
out.push(path);
}
}
}
/// `name version checksum` of every locked `tree-sitter*` package.
fn tree_sitter_packages(lock: &str) -> String {
let mut out = String::new();
for package in lock.split("[[package]]") {
let field = |key: &str| {
package.lines().find_map(|l| l.strip_prefix(key)?.strip_prefix(" = ")).unwrap_or("").trim_matches('"')
};
if field("name").starts_with("tree-sitter") {
out.push_str(&format!("{} {} {}\n", field("name"), field("version"), field("checksum")));
}
}
out
}
+4 -22
View File
@@ -132,29 +132,11 @@ impl Syntax {
SPECS.iter().map(|s| s.name)
}
/// Changes whenever extraction output may change (grammar set, queries,
/// crate version); persistent caches of [`Syntax::tags`] key on it.
/// Changes whenever extraction output may change (this crate's sources,
/// queries, language definitions, tree-sitter crate versions; see
/// `build.rs`); persistent caches of [`Syntax::tags`] key on it.
pub fn fingerprint(&self) -> String {
static FP: OnceLock<String> = OnceLock::new();
FP.get_or_init(|| {
let mut h = blake3::Hasher::new();
h.update(env!("CARGO_PKG_VERSION").as_bytes());
// Bump when web definitions (`.class`, `#id`, `--var` tags) change.
h.update(b"web-definitions-1");
for s in SPECS {
h.update(s.name.as_bytes());
h.update(&Language::new(s.language).abi_version().to_le_bytes());
h.update(s.outline.as_bytes());
for list in [s.fold_nodes, s.fold_bodies, s.imports, s.constructors] {
for k in list {
h.update(k.as_bytes());
h.update(b"\0");
}
}
}
h.finalize().to_hex()[..16].to_owned()
})
.clone()
env!("SYNTAX_SOURCE_HASH").to_owned()
}
/// Whether a tree-sitter grammar is bundled for this language.
+22 -5
View File
@@ -378,15 +378,32 @@ fn read_jar_source(uri: &str, encoding: Option<&str>) -> Option<(String, ide_vfs
/// Library classes in `jar:` targets (the official Kotlin LSP) go to their source
/// in the artifact's sources jar when it is cached, at the declaration of the
/// name navigated from; [`LspManager::virtual_document`] serves it.
async fn library_sources(snap: &DocSnap, pos: Pos, locs: Vec<Location>) -> Vec<Location> {
if !locs.iter().any(|l| l.uri.as_deref().is_some_and(|u| u.starts_with("jar:") && u.ends_with(".class"))) {
async fn library_sources(snap: &DocSnap, root: &Path, pos: Pos, locs: Vec<Location>) -> Vec<Location> {
let jar_class = |u: &str| u.starts_with("jar:") && u.ends_with(".class");
// The path names the class's source file (`Db.kt`); the handle in the query, the class.
let jdt_class = |u: &str| u.starts_with("jdt://contents/") && u.contains(".class");
if !locs.iter().any(|l| l.uri.as_deref().is_some_and(|u| jar_class(u) || jdt_class(u))) {
return locs;
}
let name = library_source::identifier_at(snap.text.line(pos.line as usize), pos.col).map(str::to_owned);
let root = root.to_owned();
tokio::task::spawn_blocking(move || {
locs.into_iter()
.map(|loc| {
let Some(uri) = loc.uri.as_deref().filter(|u| u.starts_with("jar:") && u.ends_with(".class")) else {
// A class JDT finds in a project's class folder (Kotlin compiled for Java code):
// the project's source file, not the decompiled class.
if let Some(uri) = loc.uri.as_deref().filter(|u| jdt_class(u)) {
let source = library_source::folder_class(uri)
.and_then(|(package, class)| library_source::project_class_source(&root, &package, &class));
let Some(source) = source else { return loc };
let range = name
.as_deref()
.zip(std::fs::read_to_string(&source).ok())
.map(|(n, text)| library_source::declaration_range(&text, n))
.unwrap_or_default();
return Location { path: source.into(), range, uri: None };
}
let Some(uri) = loc.uri.as_deref().filter(|u| jar_class(u)) else {
return loc;
};
let Some((source, loaded)) = library_source::class_source(uri, None) else { return loc };
@@ -1683,7 +1700,7 @@ impl LspManager {
Ok(found) => {
let locs =
convert::goto_response(&self.conv(t.enc()), lsp::GotoDefinitionResponse::Array(found.locations));
let locs = library_sources(snap, pos, locs).await;
let locs = library_sources(snap, &self.project_root, pos, locs).await;
self.note_virtual(&t.server, locs.iter().map(|l| l.uri.as_deref()));
(locs, found.complete)
}
@@ -1732,7 +1749,7 @@ impl LspManager {
match r {
Ok(Some(r)) => {
let locs = convert::goto_response(&self.conv(t.enc()), r);
let locs = library_sources(snap, pos, locs).await;
let locs = library_sources(snap, &self.project_root, pos, locs).await;
self.note_virtual(&t.server, locs.iter().map(|l| l.uri.as_deref()));
self.note_arduino_sources(&t.server, &locs);
let locs = merge_locations(first, locs);
+112
View File
@@ -226,6 +226,66 @@ pub(crate) fn identifier_at(line: &str, col: u32) -> Option<&str> {
(!word.is_empty() && !word.starts_with(|c: char| c.is_ascii_digit())).then_some(word)
}
/// A class of a class folder on JDT's classpath, as `(package path, class name)`, read
/// from the element handle in the query:
/// `jdt://contents/<folder>/<pkg>/<File>?=<project>/<escaped folder>[=/<attribute>=/<value>]…<pkg(Name.class`
/// (`<File>` is the class's SourceFile, `Db.kt` for a Kotlin class). A folder is a project's
/// own build output (the Kotlin classes the IDE compiles for JDT among them), never a
/// dependency, which is a jar or a JDK module.
pub(crate) fn folder_class(uri: &str) -> Option<(String, String)> {
uri.strip_prefix("jdt://contents/")?;
let query = percent_decode_str(uri.split_once('?')?.1).decode_utf8().ok()?;
let (root, element) = query.split_once('/')?.1.split_once('<')?;
let folder = PathBuf::from(root.split("=/").next()?.replace('\\', ""));
let (package, file) = element.split_once('(')?;
let name = file.strip_suffix(".class")?;
if name.is_empty() || !folder.is_dir() {
return None;
}
Some((package.replace('.', "/"), name.to_owned()))
}
/// The project's source of `class` (`Name`, `Outer$Inner`, `FileKt`) in `package`
/// (`/`-separated): a `.kt` / `.java` file of that name declaring the package. Build
/// output and ignored files are skipped.
pub(crate) fn project_class_source(root: &Path, package: &str, class: &str) -> Option<PathBuf> {
let outer = class.split('$').next().unwrap_or(class);
let mut names = vec![format!("{outer}.kt"), format!("{outer}.java")];
if let Some(file) = outer.strip_suffix("Kt").filter(|f| !f.is_empty()) {
names.push(format!("{file}.kt"));
}
let package = package.replace('/', ".");
let mut found: Vec<PathBuf> = ignore::WalkBuilder::new(root)
.filter_entry(|e| {
let output = matches!(e.file_name().to_str(), Some("target" | "build" | "out" | "node_modules"));
// A package may be named `build`: only outside source trees is it output.
!output || e.path().components().any(|c| c.as_os_str() == "src")
})
.build()
.flatten()
.filter(|e| e.file_type().is_some_and(|t| t.is_file()))
.filter(|e| e.file_name().to_str().is_some_and(|n| names.iter().any(|name| name == n)))
.map(|e| e.into_path())
.filter(|p| std::fs::read_to_string(p).is_ok_and(|text| declared_package(&text) == package))
.collect();
// The directory matching the package first, then by name order.
found.sort_by_key(|p| {
let dir = p.parent().map(|d| d.to_string_lossy().replace('\\', "/")).unwrap_or_default();
let name = p.file_name().and_then(|n| n.to_str()).unwrap_or_default();
(!dir.ends_with(&format!("/{}", package.replace('.', "/"))), names.iter().position(|n| n == name))
});
found.into_iter().next()
}
/// The `package` a Kotlin or Java file declares; empty for the default package.
fn declared_package(text: &str) -> String {
text.lines()
.map(str::trim)
.find(|line| line.starts_with("package "))
.map(|line| line["package ".len()..].trim_end_matches(';').trim().replace('`', ""))
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
@@ -414,6 +474,58 @@ mod tests {
assert_eq!((reloaded.text.as_str(), reloaded.encoding.as_str()), (GBK_SAMPLE, "gbk"));
}
#[test]
fn maps_class_folder_classes_to_project_sources() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let output = root.join(".cache/kotlin/main-0");
std::fs::create_dir_all(output.join("test001")).unwrap();
// As jdtls writes it: the class's source file name in the path, the library's
// classpath attributes after the folder.
let escaped = output.to_string_lossy().replace('/', "\\/");
let handle = format!("=demo/{escaped}=/ai.openidea.kotlin=/true=/<test001(Db.class");
let uri = format!("jdt://contents/main-0/test001/Db.kt?{}", utf8_percent_encode(&handle, PATH));
assert_eq!(folder_class(&uri), Some(("test001".into(), "Db".into())));
let plain = format!(
"jdt://contents/main-0/test001/Db.class?{}",
utf8_percent_encode(&format!("=demo/{escaped}<test001(Db.class"), PATH)
);
assert_eq!(folder_class(&plain), Some(("test001".into(), "Db".into())));
let nested = format!(
"jdt://contents/main-0/a.b/Db.kt?{}",
utf8_percent_encode(&format!("=demo/{escaped}<a.b(Db$Companion.class"), PATH)
);
assert_eq!(folder_class(&nested), Some(("a/b".into(), "Db$Companion".into())));
let jar = "jdt://contents/a.jar/p/C.class?=p/%5C/m2%5C/a.jar%3Cp%28C.class";
assert_eq!(folder_class(jar), None, "a dependency jar");
assert_eq!(folder_class(&uri.replace("main-0", "gone")), None);
let write = |path: &str, text: &str| {
let path = root.join(path);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, text).unwrap();
};
write("src/main/kotlin/test001/Db.kt", "package test001\n\nclass Db\n");
write("src/main/kotlin/other/Db.kt", "package other\n\nclass Db\n");
write("target/generated/test001/Db.kt", "package test001\n\nclass Db\n");
write("src/main/kotlin/util/Strings.kt", "@file:JvmName(\"Strings\")\npackage util\n\nfun shout() = 1\n");
write("src/main/kotlin/com/x/build/Tool.kt", "package com.x.build\n\nclass Tool\n");
write("src/main/kotlin/flat/Elsewhere.kt", "");
write("src/main/kotlin/Loose.kt", "package flat\n\nobject Loose\n");
let source = |package: &str, class: &str| {
project_class_source(root, package, class).map(|p| p.strip_prefix(root).unwrap().to_owned())
};
assert_eq!(source("test001", "Db"), Some(PathBuf::from("src/main/kotlin/test001/Db.kt")));
assert_eq!(source("test001", "Db$Companion"), Some(PathBuf::from("src/main/kotlin/test001/Db.kt")));
assert_eq!(source("other", "Db"), Some(PathBuf::from("src/main/kotlin/other/Db.kt")));
assert_eq!(source("util", "StringsKt"), Some(PathBuf::from("src/main/kotlin/util/Strings.kt")));
assert_eq!(source("com/x/build", "Tool"), Some(PathBuf::from("src/main/kotlin/com/x/build/Tool.kt")));
assert_eq!(source("flat", "Loose"), Some(PathBuf::from("src/main/kotlin/Loose.kt")), "any directory");
assert_eq!(source("test001", "Missing"), None);
assert_eq!(declared_package("package a.b.c;\n"), "a.b.c");
assert_eq!(declared_package("class A"), "");
}
#[test]
fn locates_declarations_in_kotlin_and_java_sources() {
let at = |text: &str, name: &str| {
+1 -1
View File
@@ -23,5 +23,5 @@ wasmtime-wasi = { version = "49.0.4", default-features = false, features = ["p2"
[dev-dependencies]
tempfile.workspace = true
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "time"] }
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "time", "net"] }
wat = "1.258"
+7 -9
View File
@@ -55,7 +55,7 @@ use views_bindings::exports::openidea::plugin::view_provider as wit_view;
use views_bindings::openidea::plugin::view_types as wit_vt;
/// The API version of the world above, `major.minor` of [`host_api`].
pub const API_VERSION: &str = "1.6";
pub const API_VERSION: &str = "1.7";
const EPOCH_TICK: Duration = Duration::from_millis(10);
pub(crate) const MAX_TEXT: usize = 1 << 20;
pub(crate) const MAX_TITLE: usize = 256;
@@ -170,6 +170,11 @@ pub trait HostApi: Send + Sync {
let _ = (client, tab, cancel, timeout);
Err(HostError::Failed("content tabs unavailable".into()))
}
/// Opens a diff of two texts in the invoking window.
fn open_diff(&self, client: &str, diff: crate::ContentDiff, file_name: Option<String>) -> Result<(), HostError> {
let _ = (client, diff, file_name);
Err(HostError::Failed("diffs unavailable".into()))
}
/// Answers a content action in the invoking window.
fn reply_content_tab(&self, client: &str, request: &str, reply: crate::ContentTabReply) -> Result<(), HostError> {
let _ = (client, request, reply);
@@ -365,14 +370,7 @@ pub trait HostApi: Send + Sync {
}
/// The most specific open project containing a document (including a new file).
fn project_of(&self, path: &Path) -> Option<std::path::PathBuf> {
if !path.is_absolute() {
return None;
}
let real = crate::resolve_real(path);
self.projects()
.into_iter()
.filter(|root| real.starts_with(crate::resolve_real(root)))
.max_by_key(|root| root.components().count())
crate::sandbox::innermost_root(self.projects(), path)
}
/// Searches the current in-memory index of an open project.
+275 -64
View File
@@ -1,7 +1,9 @@
//! Capability checks for host functions that touch the file system.
use ide_core::real_path::reaches_git_dir;
pub use ide_core::real_path::resolve_real;
use ide_extension::Capability;
use std::path::{Path, PathBuf};
use std::path::{Component, Path, PathBuf, Prefix};
/// Largest file `read-file` returns.
/// The most entries `fs.read-dir` hands over: the same cap as `find-files`.
@@ -11,53 +13,114 @@ pub const MAX_READ_BYTES: u64 = 16 << 20;
/// The largest file s.write may write, in bytes.
pub const MAX_WRITE_BYTES: usize = 16 << 20;
/// The canonical `root` and the absolute form of `path` (absolute, or
/// relative to `root`) — before any symlink or existence check.
fn joined_path(root: &Path, path: &str) -> Result<(PathBuf, PathBuf), String> {
/// What a requested path is, judged from its text alone — before anything opens it. On
/// Windows even resolving a path opens it, and opening a network or pipe path connects to
/// that host or pipe, so every resolver asks this first and checks the real path after.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Lexical {
/// A local path, or a network path under one of the allowed bases.
Resolvable,
/// A network path outside every allowed base.
Outside,
/// A device, pipe or file stream, which is never a file a plugin may use.
Refused,
}
/// Judges `path` against `bases` (absolute paths a resolver allows) from the text alone.
fn judge(path: &Path, bases: &[&Path]) -> Lexical {
let Some((parts, remote)) = lexical_parts(path) else {
return Lexical::Refused;
};
let under =
|base: &&Path| base.is_absolute() && lexical_parts(base).is_some_and(|(base, _)| parts.starts_with(&base));
if remote && !bases.iter().any(under) { Lexical::Outside } else { Lexical::Resolvable }
}
/// `path` split into comparable parts, `.` and `..` applied, a verbatim drive or share
/// spelled like the plain one and, on Windows, case-folded; with whether it names a network
/// location. `None` for a device or pipe (`\\.\…`, `\\?\GLOBALROOT\…`, `NUL`, `COM1`) or an
/// NTFS stream (`a.txt:s`).
fn lexical_parts(path: &Path) -> Option<(Vec<String>, bool)> {
let mut parts = Vec::new();
let mut remote = false;
let mut anchored = 0;
for part in path.components() {
match part {
Component::Prefix(prefix) => {
parts.push(match prefix.kind() {
Prefix::Disk(drive) | Prefix::VerbatimDisk(drive) => format!("{}:", char::from(drive)),
Prefix::UNC(server, share) | Prefix::VerbatimUNC(server, share) => {
remote = true;
format!(r"\\{}\{}", server.to_string_lossy(), share.to_string_lossy())
}
Prefix::Verbatim(_) | Prefix::DeviceNS(_) => return None,
});
anchored = parts.len();
}
Component::RootDir => {
parts.push(std::path::MAIN_SEPARATOR_STR.to_owned());
anchored = parts.len();
}
Component::CurDir => {}
Component::ParentDir => {
if parts.len() > anchored {
parts.pop();
}
}
Component::Normal(name) => {
let name = name.to_string_lossy();
if cfg!(windows) && (name.contains(':') || names_device(&name)) {
return None;
}
parts.push(name.into_owned());
}
}
}
if cfg!(windows) {
parts.iter_mut().for_each(|part| *part = part.to_lowercase());
}
Some((parts, remote))
}
/// A name Windows opens as a device in every folder (`NUL`, `COM1`, `con.txt`): what comes
/// before the first `.`, trailing spaces ignored.
fn names_device(name: &str) -> bool {
let stem = name.split('.').next().unwrap_or_default().trim_end_matches(' ').to_ascii_uppercase();
match stem.as_str() {
"CON" | "PRN" | "AUX" | "NUL" | "CONIN$" | "CONOUT$" => true,
_ => stem.strip_prefix("COM").or_else(|| stem.strip_prefix("LPT")).is_some_and(|number| {
matches!(number, "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "¹" | "²" | "³")
}),
}
}
fn refused(path: &str) -> String {
format!("{path}: devices, pipes and file streams are not allowed")
}
/// The canonical `root` and the absolute form of `path` (absolute, or relative to `root`) —
/// before any symlink or existence check — when [`judge`] lets it be resolved inside `root`.
/// `Ok(None)` for a network path outside `root`.
fn joined_path(root: &Path, path: &str) -> Result<Option<(PathBuf, PathBuf)>, String> {
if path.is_empty() {
return Err("empty path".into());
}
let root = dunce::canonicalize(root).map_err(|e| format!("project root unavailable: {e}"))?;
let real_root = dunce::canonicalize(root).map_err(|e| format!("project root unavailable: {e}"))?;
let requested = Path::new(path);
let joined = if requested.is_absolute() { requested.to_path_buf() } else { root.join(requested) };
Ok((root, joined))
}
/// `joined` after `..`/symlink resolution as far as the file system allows:
/// the canonical path when it exists, else the canonicalized deepest existing
/// ancestor with the still-missing tail resolved textually — so the location
/// of a missing path is still judged where it would really land.
pub fn resolve_real(joined: &Path) -> PathBuf {
let mut tail = Vec::new();
let mut candidate = joined.to_path_buf();
let mut real = loop {
match dunce::canonicalize(&candidate) {
Ok(real) => break real,
Err(_) => {
let Some(last) = candidate.components().next_back() else {
return joined.to_path_buf();
};
tail.push(last.as_os_str().to_owned());
if !candidate.pop() {
return joined.to_path_buf();
}
}
}
};
for part in tail.iter().rev() {
if part == ".." {
real.pop();
} else if part != "." {
real.push(part);
}
let joined = if requested.is_absolute() { requested.to_path_buf() } else { real_root.join(requested) };
match judge(&joined, &[root, &real_root]) {
Lexical::Resolvable => Ok(Some((real_root, joined))),
Lexical::Outside => Ok(None),
Lexical::Refused => Err(refused(path)),
}
real
}
/// Resolves `path` (absolute, or relative to `root`) and checks that the real
/// path — after following `..` and symlinks — stays inside `root`.
pub fn resolve_in_root(root: &Path, path: &str) -> Result<PathBuf, String> {
let (root, joined) = joined_path(root, path)?;
let Some((root, joined)) = joined_path(root, path)? else {
return Err(format!("{path}: access outside the project root is not allowed"));
};
let real = dunce::canonicalize(&joined).map_err(|_| format!("{path}: no such file"))?;
if !real.starts_with(&root) {
return Err(format!("{path}: access outside the project root is not allowed"));
@@ -93,7 +156,9 @@ pub fn resolve_in_roots(roots: &[PathBuf], path: &str) -> Result<PathBuf, String
/// callers distinguish "outside" from "absent". Existing symlinks are
/// followed; a missing tail is judged at its would-be location.
pub fn inside_root(root: &Path, path: &str) -> Result<bool, String> {
let (root, joined) = joined_path(root, path)?;
let Some((root, joined)) = joined_path(root, path)? else {
return Ok(false);
};
Ok(resolve_real(&joined).starts_with(&root))
}
@@ -114,36 +179,40 @@ pub fn inside_roots(roots: &[PathBuf], path: &str) -> Result<bool, String> {
Ok(false)
}
/// The most specific of `roots` containing `path` (absolute, and possibly not existing yet),
/// judged where it really lands. A network path outside every root is not resolved at all.
pub(crate) fn innermost_root(roots: Vec<PathBuf>, path: &Path) -> Option<PathBuf> {
if !path.is_absolute() {
return None;
}
let roots: Vec<(PathBuf, PathBuf)> = roots
.into_iter()
.map(|root| {
let real = resolve_real(&root);
(root, real)
})
.collect();
let bases: Vec<&Path> = roots.iter().flat_map(|(root, real)| [root.as_path(), real.as_path()]).collect();
if judge(path, &bases) != Lexical::Resolvable {
return None;
}
let real = resolve_real(path);
roots
.into_iter()
.filter(|(_, root)| real.starts_with(root))
.map(|(root, _)| root)
.max_by_key(|root| root.components().count())
}
/// Like [`resolve_in_root`] for a path that may not exist yet (a file about to
/// be created) or that names a symlink itself (to delete or rename it): the
/// directory part is resolved through symlinks, the final name is kept as is,
/// and the result must stay inside `root`.
/// Whether a path (relative to the project root) goes through git's own `.git` folder or file.
/// git runs what it finds in `.git` (hooks, `gpg.program`, filters), so a content write must
/// never reach it. Names are normalized for trailing dots and spaces and NTFS stream suffixes;
/// the caller also checks the short-name-resolved path via [`resolve_real`].
pub fn names_git_dir(rel: &Path) -> bool {
rel.components().any(|part| {
let name = part.as_os_str().to_string_lossy();
let name = name.split(':').next().unwrap_or_default().trim_end_matches(['.', ' ']);
name.eq_ignore_ascii_case(".git")
})
}
/// A resolved write destination that reaches git's own `.git` metadata, judged by the name
/// given and by the name the file system resolves it to.
fn reaches_git_dir(root: &Path, real: &Path) -> bool {
real.strip_prefix(root).is_ok_and(names_git_dir) || resolve_real(real).strip_prefix(root).is_ok_and(names_git_dir)
}
pub fn resolve_new_in_root(root: &Path, path: &str) -> Result<PathBuf, String> {
let outside = || format!("{path}: access outside the project root is not allowed");
if path.is_empty() {
return Err("empty path".into());
}
let root = dunce::canonicalize(root).map_err(|e| format!("project root unavailable: {e}"))?;
let requested = Path::new(path);
let joined = if requested.is_absolute() { requested.to_path_buf() } else { root.join(requested) };
let Some((root, joined)) = joined_path(root, path)? else {
return Err(outside());
};
let mut lexical = PathBuf::new();
for part in joined.components() {
match part {
@@ -181,7 +250,9 @@ pub fn resolve_new_in_root(root: &Path, path: &str) -> Result<PathBuf, String> {
/// Resolves a content write's destination, including existing and dangling
/// symlinks in the final component. Entry operations use [`resolve_new_in_root`].
pub fn resolve_writable_in_root(root: &Path, path: &str) -> Result<PathBuf, String> {
let (root, joined) = joined_path(root, path)?;
let Some((root, joined)) = joined_path(root, path)? else {
return Err(format!("{path}: access outside the project root is not allowed"));
};
let real = resolve_write_target(&joined, 40).map_err(|e| format!("{path}: {e}"))?;
if !real.starts_with(&root) {
return Err(format!("{path}: access outside the project root is not allowed"));
@@ -346,6 +417,22 @@ fn resolve(granted: &[Capability], path: &str, wanted: Wanted) -> Result<PathBuf
if !asked.is_absolute() {
return Err(Refusal::Invalid(format!("{path}: use an absolute path (or ~/…)")));
}
let bases: Vec<PathBuf> = granted
.iter()
.filter_map(|grant| match (wanted, grant) {
(Wanted::Read, Capability::FsRead(base)) | (Wanted::Write, Capability::FsWrite(base)) => {
Some(expand_tilde(base))
}
_ => None,
})
.flat_map(|base| [dunce::canonicalize(&base).ok(), Some(base)])
.flatten()
.collect();
match judge(&asked, &bases.iter().map(PathBuf::as_path).collect::<Vec<_>>()) {
Lexical::Resolvable => {}
Lexical::Outside => return Err(Refusal::Denied(wanted.capability(strip_verbatim(&asked)).to_string())),
Lexical::Refused => return Err(Refusal::Invalid(refused(path))),
}
let real = match wanted {
Wanted::Read => dunce::canonicalize(&asked).map_err(|_| Refusal::Invalid(format!("{path}: no such file")))?,
Wanted::Write => dunce::canonicalize(&asked)
@@ -399,6 +486,7 @@ fn strip_verbatim(path: &Path) -> String {
#[cfg(test)]
mod tests {
use super::*;
use ide_core::real_path::names_git_dir;
#[cfg(windows)]
#[test]
@@ -894,4 +982,127 @@ mod tests {
);
assert_eq!(expand_tilde("relative/x"), PathBuf::from("relative/x"));
}
/// Resolving a path must not open what it names before the path is known to be allowed:
/// on Windows, opening a pipe or network path (even only to canonicalize it) connects to
/// that pipe or host. Streams and reserved device names are never project files.
#[cfg(windows)]
mod windows_paths {
use super::*;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use tokio::net::windows::named_pipe::ServerOptions;
/// Every spelling of the local pipe `name` that Windows opens: device, verbatim, object
/// manager and network (through the SMB redirector, as a remote host would be reached).
fn spellings(name: &str) -> [String; 6] {
[
format!(r"\\.\pipe\{name}"),
format!(r"//./pipe/{name}"),
format!(r"\\?\pipe\{name}"),
format!(r"\\?\GLOBALROOT\Device\NamedPipe\{name}"),
format!(r"\\127.0.0.1\pipe\{name}"),
format!(r"\\?\UNC\127.0.0.1\pipe\{name}"),
]
}
/// Each resolver, as `(name, accepts root path)`.
type Resolver = (&'static str, fn(&Path, &str) -> bool);
const RESOLVERS: [Resolver; 11] = [
("resolve_in_root", |root, path| resolve_in_root(root, path).is_ok()),
("resolve_in_roots", |root, path| resolve_in_roots(&[root.to_path_buf()], path).is_ok()),
("inside_root", |root, path| inside_root(root, path).unwrap_or(false)),
("inside_roots", |root, path| inside_roots(&[root.to_path_buf()], path).unwrap_or(false)),
("resolve_new_in_root", |root, path| resolve_new_in_root(root, path).is_ok()),
("resolve_new_in_roots", |root, path| resolve_new_in_roots(&[root.to_path_buf()], path).is_ok()),
("resolve_writable_in_root", |root, path| resolve_writable_in_root(root, path).is_ok()),
("resolve_writable_in_roots", |root, path| resolve_writable_in_roots(&[root.to_path_buf()], path).is_ok()),
("resolve_granted", |root, path| resolve_granted(&[grant_for(root)], path).is_ok()),
("resolve_writable", |root, path| {
resolve_writable(&[Capability::FsWrite(strip_verbatim(&dunce::canonicalize(root).unwrap()))], path)
.is_ok()
}),
("project_of", |root, path| innermost_root(vec![root.to_path_buf()], Path::new(path)).is_some()),
];
#[test]
fn pipe_and_network_paths_are_refused_without_being_opened() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("project");
std::fs::create_dir(&root).unwrap();
let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap();
let _context = runtime.enter();
let mut probes = Vec::new();
for (resolver, accepts) in RESOLVERS {
for spelling in 0..spellings("").len() {
let name = format!("openidea-sandbox-{}-{resolver}-{spelling}", std::process::id());
let server = ServerOptions::new().first_pipe_instance(true).create(format!(r"\\.\pipe\{name}"));
let server = server.unwrap();
let opened = Arc::new(AtomicBool::new(false));
let flag = opened.clone();
runtime.spawn(async move {
let _ = server.connect().await;
flag.store(true, Ordering::SeqCst);
});
let path = spellings(&name)[spelling].clone();
let accepted = accepts(&root, &path);
probes.push((resolver, path, accepted, opened));
}
}
runtime.block_on(tokio::time::sleep(std::time::Duration::from_millis(500)));
let failures: Vec<String> = probes
.iter()
.filter(|(_, _, accepted, opened)| *accepted || opened.load(Ordering::SeqCst))
.map(|(resolver, path, accepted, opened)| {
format!("{resolver}({path}): accepted={accepted} opened={}", opened.load(Ordering::SeqCst))
})
.collect();
assert!(failures.is_empty(), "{failures:#?}");
}
#[test]
fn streams_and_device_names_are_refused() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("project");
std::fs::create_dir(&root).unwrap();
std::fs::write(root.join("a.txt"), "text").unwrap();
let root = dunce::canonicalize(root).unwrap();
let mut accepted = Vec::new();
for rel in ["a.txt:hidden", "a.txt::$DATA", "NUL", "con.txt", "sub/COM1", "LPT1.log", "CONIN$"] {
for path in [rel.to_owned(), root.join(rel).to_string_lossy().into_owned()] {
for (resolver, accepts) in RESOLVERS {
if accepts(&root, &path) {
accepted.push(format!("{resolver}({path})"));
}
}
}
}
assert!(accepted.is_empty(), "{accepted:#?}");
}
#[test]
fn a_share_path_is_resolved_only_under_a_root_on_that_share() {
let root = Path::new(r"\\?\UNC\server\share\proj");
for path in
[r"\\server\share\proj\a.txt", r"\\SERVER\Share\Proj\src\..\a.txt", r"\\?\UNC\server\share\proj"]
{
assert_eq!(judge(Path::new(path), &[root]), Lexical::Resolvable, "{path}");
}
for path in [
r"\\server\other\a.txt",
r"\\elsewhere\share\proj\a.txt",
r"\\server\share\proj\..\..\other\a.txt",
r"\\server\share\project\a.txt",
] {
assert_eq!(judge(Path::new(path), &[root]), Lexical::Outside, "{path}");
assert_eq!(judge(Path::new(path), &[Path::new(r"C:\proj")]), Lexical::Outside, "{path}");
}
for path in [r"C:\proj\a.txt", r"\\?\C:\elsewhere\a.txt", r"D:\x"] {
assert_eq!(judge(Path::new(path), &[root]), Lexical::Resolvable, "{path}");
}
assert_eq!(judge(Path::new(r"\\server\share\proj\a.txt:s"), &[root]), Lexical::Refused);
assert!(!names_device("console.log") && !names_device("com10") && !names_device("aux_utils.rs"));
}
}
}
+6
View File
@@ -1770,6 +1770,12 @@ impl views::Host for StoreData {
)
.map_err(to_wit))
}
fn open_diff(&mut self, diff: views::ContentDiff, file_name: Option<String>) -> Fallible<()> {
if let Err(e) = self.require_window() {
return Ok(Err(e));
}
Ok(self.host.open_diff(self.invocation.client.as_deref().unwrap(), diff.into(), file_name).map_err(to_wit))
}
fn reply_content_tab(&mut self, request_id: String, reply: views::ContentTabReply) -> Fallible<()> {
if let Err(e) = self.require_window() {
return Ok(Err(e));
+5 -5
View File
@@ -91,15 +91,15 @@ fn the_sdk_carries_the_hosts_wit() {
#[test]
fn the_version_check_runs_compatible_components_and_explains_the_rest() {
let host = v("1.6.0");
for ok in ["1.0.0", "1.3.0", "1.4.7", "1.5.0", "1.6.0", "1.0.0-rc.1"] {
let host = v("1.7.0");
for ok in ["1.0.0", "1.3.0", "1.4.7", "1.5.0", "1.6.0", "1.7.0", "1.0.0-rc.1"] {
assert_eq!(check_api(&v(ok), &host), Ok(()), "{ok}");
}
let newer = check_api(&v("1.7.0"), &host).unwrap_err();
assert!(newer.contains("1.7") && newer.contains("update OpenIDEA"), "{newer}");
let newer = check_api(&v("1.8.0"), &host).unwrap_err();
assert!(newer.contains("1.8") && newer.contains("update OpenIDEA"), "{newer}");
assert!(check_api(&v("2.0.0"), &host).unwrap_err().contains("update OpenIDEA"));
let older = check_api(&v("0.2.0"), &host).unwrap_err();
assert!(older.contains("rebuild it with the 1.6 SDK"), "{older}");
assert!(older.contains("rebuild it with the 1.7 SDK"), "{older}");
// 0.x: every minor is its own API.
assert_eq!(check_api(&v("0.2.0"), &v("0.2.5")), Ok(()));
assert!(check_api(&v("0.1.0"), &v("0.2.0")).is_err());
Binary file not shown.
@@ -1 +1 @@
005d9da29eb7d713a97ff872051e408f653030434d837835843298553c9e12e0
3c7f35facd15ee8380ccb6475b7c051d500e38346b711e1805077becd0c75f75
Binary file not shown.
@@ -1 +1 @@
6359ef4271f38374889412bd926d9aa735f59a8ce047ffcb06c47976444196cf
3879822d975695c6258e95286b0e64c5ee1e3265876501efaf0e577b38f8222b
Binary file not shown.
@@ -1 +1 @@
6f560f3d0d7737150d2dfa09c3b801bd3b4c3a52cef9893ed8fc045809fe33e1
228ae888bf556ce5cedc7843eb20e92f221d9e1f640f3c6c595bed3fd6be6051
Binary file not shown.
@@ -1 +1 @@
4024af2d55d7fe4e8c456efd7e0c497ade4b2c5959e9108ff218f4a669e920f4
41de239b6f723020b38eb2b67f642b7c88cc88c79117c3072ba122633afc418c
Binary file not shown.
@@ -1 +1 @@
4ca977f53a18e9e8f869d34e027a15abece9e97b9331e23d4f71af721b104611
78c5ed3c756498c7619b46c3622ae7a59461c61c747b45caecd79b341ee654de
Binary file not shown.
@@ -1 +1 @@
3d12a00f81e0db1b0902c2ddebfd6454a056f15b97add80b22a0333722568dc8
ee848321e53cb181536e6158570dbb4ab02a6fd2a1a064b068803ac7da7f3448
Binary file not shown.
@@ -1 +1 @@
95d89e411216f50d0b34bee110c6bfc9b8fbcb2ffaae2e912c3609f7bf678b2c
b587de945875a98f1d4ca20fab7cf94fb4dc6d848136aa19ab0e0e5319155a80
@@ -40,6 +40,10 @@ title = "write"
id = "content"
title = "content"
[[contributes.commands]]
id = "diff"
title = "diff"
[[contributes.commands]]
id = "events"
title = "events"
+12
View File
@@ -78,6 +78,18 @@ impl Plugin for Execution {
})?;
Ok(None)
}
"diff" => {
host::open_diff(
&ContentDiff {
left: "before\n".into(),
right: "after\n".into(),
title: Some("Review".into()),
..Default::default()
},
Some("src/review.rs"),
)?;
Ok(None)
}
"events" => host::storage::get("events"),
"validation" => host::storage::get("validation"),
_ => Err(Error::NotFound(id.into())),
Binary file not shown.
@@ -1 +1 @@
dd4b6c5153892a4f3facb3f23cb1d92da39a3977985b8abf4eaa9558decfa4a9
1c8776dcae35d2d49dd6c1e32b0cdd354de1eb0f72a473245a2c47368b89ec30
Binary file not shown.
@@ -1 +1 @@
325bc15cd28933d9687bc3c316008f921f48a4a69d7528c6b367fa0ff22489b8
2a517cbffd6d3cdda37117d8ca8815978a09995923fc3d5aa5af7020afdac56a
Binary file not shown.
@@ -1 +1 @@
abe706392e04ef5a14f9feab88e0c0d6a9e96bee7de771b61deeb65e7d9e0a7c
dbd912b1f8488ecd446af07b5dcaef55d44bce57c1a55be5c0b0fb33e2697903
Binary file not shown.
@@ -1 +1 @@
fb3677946021b30d226e2e3d422c352645c2065c93057c0a645246520860fc90
efdc84cd2bedb3989b03d19b569d09958f26e502db65db4dd786eb3438a7c407
Binary file not shown.
@@ -1 +1 @@
a01852ead9004ac1558028da19a1ae96ccd035775484089fd39f14263082daf1
be6bc15ee42a5e21781ba54f548007351a46b669882ee4491da20ff556927a06
Binary file not shown.
@@ -1 +1 @@
bf00050b7bb9911680a94fd36aaeb83fc96d94030e8ceef51d1723c3950253f9
e5c3a3beda750dff27d7b823676d53f15b7c76b8ad539594cd913bd178d750da
Binary file not shown.
@@ -1 +1 @@
d00a07d9d51e7ca117cec59070d2812d4d1efb2730b60a6f171c8fd12903d055
322de3299f0413101c02358836e7df7e06e58cbb22c11897664564abbb8af090
Binary file not shown.
@@ -1 +1 @@
0aa0ee56e8bacd4d218bafecdec7f4b6249381fad6e31f8b2ee89fd88b16d710
d768ff6a4a466cc6f1d72499df3ccb4ae76872436b8eddff27f62aa156331a37
Loaded 100 of 410 files, more files were not shown because too many files have changed in this diff. Show more