Compare commits

...
Author SHA1 Message Date
l0ng-ai 4a667b2cd2 fix(vcs): lay out a merge commit's file side by side, each pane scrolling
Opening a file of a merge commit from the Git log stacked its three panes (parent 1, merge result, parent 2) in a column the editor could not scroll: only the first parent's text showed, without the changes, and the wheel and arrow keys did not move it. The panes now sit side by side under a label row, each one scrolls and they stay in step.

Fixes #3108
2026-11-13 18:38:28 +08:00
l0ng-ai 7e11d0729b fix(macos): keep full screen when Escape goes unhandled (#3134)
Escape the page leaves unhandled reaches the window as cancelOperation:, and NSWindow leaves full screen on it, so pressing Escape once more after closing a popup dropped the IDE out of full screen. The IDE's windows now answer cancelOperation: with nothing; View › Appearance › Toggle Full Screen (⌃⌘F) leaves full screen. A page-level preventDefault was not used because several windows close on an otherwise unhandled Escape.

Fixes #3104
2026-11-13 18:33:53 +08:00
l0ng-ai 2e78c42fac perf(java): find implementations in the sources through the index (#3132)
Go to Implementation on project code built a region type hierarchy over every source folder, which resolves every type in the region whatever the target; in a large multi-module project it took seconds. It now runs the operation IType.newTypeHierarchy uses, scoped to the source folders and the bridged Kotlin output, so the index narrows the subtypes to files that name a supertype. Results are unchanged, local and anonymous classes included (~230 ms -> ~7 ms on a 4,000-class test project).

Refs #3101
2026-11-13 18:27:46 +08:00
l0ng-ai 1d0449d430 fix(refactor): introduce a variable directly when the expression occurs once (#3128)
With a language server that offers both a single-occurrence and an all-occurrences Introduce Variable action, the "Multiple occurrences found" chooser opened even when the selected expression occurred only once. It now applies the single-occurrence action directly then, as the built-in path already did.
2026-11-13 18:05:20 +08:00
l0ng-ai 73f7fa0016 feat(editor): start a new line in a /* */ comment with a leading asterisk (#3127)
Enter on the first line of a /* */ or /** */ comment, or on a line that starts with *, starts the new line with * aligned under the comment's asterisks; before the closing */ the comment is closed on its own line. Strings and // comments are left alone.

Fixes #3099
2026-11-13 18:03:37 +08:00
l0ng-ai f3dfb384c9 fix(lsp): check again for a missing language server from the status popup (#3126)
A language server reported as not installed had its restart button disabled in the Language Servers popup, so one installed outside the IDE afterwards stayed "Not installed" until the project was reopened. The popup now offers "Check for <name> again" next to Install; restarting a missing server resolves its executable again and starts it when found, and says "<name> is still not installed" otherwise.

Refs #2925
2026-11-13 18:02:59 +08:00
Muuandmuutot b53cfe8cc2 feat(color-scheme): Git page for inline blame and project view branch colors (#3090)
Adds an Editor › Color Scheme › Git page with attributes for the editor inline blame and the Project View branch decorations (changed, HEAD tags, local, master/main with and without remote, tracking), with a live preview. The attribute panel now disables the channels an attribute doesn't use and labels the effect selector "Effect style".

Co-authored-by: muutot <[email protected]>
2026-11-13 17:43:02 +08:00
Mr0Bread 7d146ef1b7 feat(database): reuse a console's result tabs on each run, pin tabs to keep them (#3113)
Running a console added a new Result N tab per run. As in IntelliJ, a run now replaces that console's unpinned result tabs (in place of the first of them); a run without result sets keeps them and shows Output. Pin Tab (tab menu or the result toolbar) keeps a result: its close button becomes Unpin and close gestures spare it; Close All but Pinned closes the rest. Explain Plan replaces only an unpinned plan tab, and the 12-tab limit never evicts pinned or just-created tabs. The shared Tabs component gets an opt-in pinned item flag.

Co-authored-by: Mr0Bread <[email protected]>
2026-11-13 17:37:59 +08:00
Mr0Bread 4bde38ef9d fix(database): use the Database tab's table icon for result tabs and the Table view (#3115)
Result tabs and the result toolbar's Table button drew the db-table glyph while the Services Database tab above them shows the dataTables icon; as in IntelliJ all three now use the same icon. Table nodes in the Database tree and Edit Data keep db-table.

Co-authored-by: Mr0Bread <[email protected]>
2026-11-13 17:36:45 +08:00
Mr0Bread c6212293e1 fix(database): close the active result tab, not the Services window, on Close Tab (#3118)
Close Tab (⌘W) with focus in the database results hid the whole Services window: toolwindow.closeContent only closes tabs of strips registered with the window's content-tab registry, which was provided only in tool window header rows. ToolWindowContentTabs registers tab strips rendered in a window's content; the result tabs use it, so Close Tab closes the selected result, and with Output selected the window hides as before.

Co-authored-by: Mr0Bread <[email protected]>
2026-11-13 17:34:42 +08:00
l0ng-ai b8ba1256d2 fix(terminal): keep Nerd Font icons wider than their cell above the next cell's background (#3121)
Nerd Font / Powerline icons (Private Use Area characters) are often drawn wider than one cell. xterm's DOM renderer paints each span's background in order, so the next prompt segment's background covered the icon's right part and powerline prompts showed half icons. Spans holding such characters are now marked in the DOM row hook and lifted over the following spans.

Fixes #3094
2026-11-13 17:30:43 +08:00
langxpandkradme 6fc6bf598c fix(java): use and cache the Maven runner launch classpath (#2929)
Delegated Java launches took their dependency classpath from jdtls, whose m2e import runs on the server JDK and can activate other JDK-activated Maven profiles or classifiers than the module build (a reactor switching between jdk8 and jdk21 artifacts). The launch now asks the Maven process and runner JDK that built the module for its runtime / test classpath (compile + dependency:build-classpath with -am, no package), cached by the launch fingerprint (POMs, sources, JDK, settings, profiles, environment). A failed probe falls back to the jdtls classpath with a hint in the console; cancellation still cancels. A fingerprint-verified snapshot is reused before JDT errors escalate a launch to Rebuild, and generated sources under the build directory no longer change the fingerprint after each compile.

Co-authored-by: kradme <[email protected]>
2026-11-13 17:27:38 +08:00
l0ng-ai 95235220b5 fix(lsp): escape unescaped Unicode and spaces in file URIs from any server (#3117)
lsp-types rejects a URI with raw non-ASCII characters or spaces, so one such file URI made a whole response fail to deserialize ("data did not match any variant of untagged enum DocumentChanges"). Java servers build some URIs with java.net.URI.toString(), which keeps non-ASCII characters, e.g. jdtls in Introduce Variable's edit for a project under a Chinese path. The csharp-ls normalisation now applies to file: URIs from every server, in responses, publishDiagnostics and workspace/applyEdit, including WorkspaceEdit.changes keys; opaque data and other schemes pass through unchanged.

Refs #3089
2026-11-13 17:24:17 +08:00
GuoYuandguoyucode 620fa247ef feat(mcp): fixed port and kept token, focus only for confirmations, no refactoring notice for agent edits (#3062)
The IDE MCP server took a new port and token in every run, so configured HTTP clients and port forwards broke at each restart; every MCP request brought the window to the front; and an agent's edits were announced as refactorings.

- Settings › Agents › IDE MCP Server: Use a fixed port (on by default, 26371) with a fallback to a free port when it is in use or reserved, reported in Status; Keep the token (off by default) stores the bearer token in the app data, readable by this user only, with Regenerate Token. Changing either restarts the server. Access control is otherwise unchanged (127.0.0.1, bearer token, origin and host checks).
- Only confirmations, which wait for the user, raise the window.
- Agent edits stay undoable (Local History point for multi-file edits) but are not announced as refactorings.

Co-authored-by: guoyucode <[email protected]>
2026-11-13 17:19:02 +08:00
l0ng-ai 52e5a0c4af fix(vcs): leave a new branch from a remote branch with another name untracked (#3063)
Git's autoSetupMerge made the remote start point the upstream of a branch created from it whatever its name, so a branch 'feature' created from 'origin/main' proposed and pushed to 'main'. New Branch and New Worktree now create such a branch with --no-track, so the Push dialog targets its own name and the first push sets that upstream; a remote branch checked out under the same name is still tracked.
2026-11-13 17:11:46 +08:00
l0ng-ai 4acb265b90 fix(gradle): run the unpacked wrapper distribution when gradle-wrapper.jar is missing (#3116)
A *.jar rule in .gitignore often leaves gradle/wrapper/gradle-wrapper.jar out of the repository, and gradlew then fails with only "could not find or load main class org.gradle.wrapper.GradleWrapperMain". When the jar is missing, the distribution named by gradle-wrapper.properties runs from the Gradle user home's wrapper/dists if the wrapper already unpacked it; otherwise the wrapper runs as before and the failure summary names the missing jar and the fixes.

Fixes #3086
2026-11-13 17:10:15 +08:00
Mr0Bread b284c37588 feat(database): add default result tab navigation shortcuts (#3100)
Select Next / Previous Tab for database results get default shortcuts in every keymap preset, the same keys as editor-tab navigation (IntelliJ macOS ⇧⌘] / ⇧⌘[ and ⌃→ / ⌃←, IntelliJ Windows/Linux Alt+→ / Alt+←, VS Code and Eclipse their editor-tab keys). They apply while focus is in Results (tracked with focusin so it survives a replaced grid or tab) and move focus to the destination tab; elsewhere the keys keep switching editor tabs.

Co-authored-by: Mr0Bread <[email protected]>
2026-11-13 17:07:09 +08:00
l0ng-ai 771575057e fix(database): keep unsubmitted result edits while another result tab is shown (#3114)
The result pane is keyed by tab and remounts when another result tab is selected, so its pending cell edits, added and deleted rows were dropped on every switch (mouse, menu or keyboard). Pending edits are now kept by the result rows they were made on; a re-run or another page still starts over.
2026-11-13 17:03:51 +08:00
acaoandamphetamlne 563d2a75d4 perf(vcs): reuse the displayed graph for parent navigation (#3049)
Go to Parent rebuilt the displayed log graph for every candidate commit on every paging check (604 graph builds for a 300-commit log with the parent already loaded). Each check now builds one snapshot and uses it for both the parent adjacency and the candidates, recomputing after each loaded page so collapsed / linearized relationships and repository-qualified ids stay right. Paging stays uncapped, so deep parents are still found.

Co-authored-by: amphetamlne <[email protected]>
2026-11-13 17:01:29 +08:00
LunarFellerandLunarFeller 3a64bba254 fix(database): preserve numeric precision in JSON exports (#3102)
The JSON extractor converted numeric cells through JavaScript Number before JSON.stringify, rounding BIGINTs and high-precision decimals in the preview, Copy as JSON, the dialog clipboard and file export. Numeric cells are now written as normalized decimal text (sign, leading zeros and an empty fraction normalized, digits and exponent kept); non-finite or non-decimal text stays a string as before.

Refs #3105

Co-authored-by: LunarFeller <[email protected]>
2026-11-13 16:58:16 +08:00
LunarFellerandLunarFeller f4b4a0fa72 fix(database): preserve precision when sorting numeric cells (#3075)
Result sorting, filter value order and selection Min/Max compared numeric cells through JavaScript Number, so BIGINTs beyond 2^53 and high-precision decimals that round to the same double kept the wrong order. The comparator still uses the float difference when it is non-zero (rounding is monotonic) and compares the decimal digits and exponent order for rounded ties.

Refs #3105

Co-authored-by: LunarFeller <[email protected]>
2026-11-13 16:57:45 +08:00
Bake Yam 50bd134b65 feat(templates): built-in Jupyter Notebook file template (#3084)
New ▸ Jupyter Notebook creates a valid nbformat 4 notebook with a Python 3 kernelspec, either empty or starting with a Markdown heading named after the file. It uses the .ipynb file icon and is listed under Settings › Editor › File and Code Templates like the other built-ins.

Fixes #3082

Co-authored-by: Renjiankang <[email protected]>
2026-11-13 16:56:21 +08:00
Bake Yam 8840a1e58a fix(templates): write the file template body before opening the file (#3083)
Creating a file from a template whose extension opens in a non-text editor (an .ipynb notebook) left the file empty: the body was only ever inserted into a CodeMirror view, which never appears for such editors, so the wait timed out after ~4 s with "The template content could not be inserted."

Now the editor kind is probed before the file is created (its acquisitions released); a file a provided editor opens gets the rendered body written on create. Text templates keep inserting into the view (caret at $END$, reformat, saved with the editor's encoding).

Fixes #3081

Co-authored-by: Renjiankang <[email protected]>
2026-11-13 16:55:16 +08:00
l0ng-ai bf36cc0801 chore: v0.1.44 2026-11-13 15:46:55 +08:00
l0ng-ai 78c98d289f fix: Snowflake transactions and lost sessions, accept never writes over unreadable files (#3098)
Snowflake: COMMIT/ROLLBACK end a transaction without switching to the console's schema, and a failed one stays held to be ended again; after a lost session is signed in to again, the target's USE runs before the statement is repeated (a table-editor transaction is not repeated); the session key includes the project. Agent accept: a checkout file that exists but can't be read is skipped as the checkout's own instead of written over.
2026-11-13 15:46:25 +08:00
l0ng-ai 30b0fcb8fc fix(mcp): a late diagnostics publication for an older text version isn't fresh (#3097)
A publishDiagnostics tagged with a version older than the document's is still shown but no longer counts as a fresh answer for check_files.
2026-11-13 15:43:44 +08:00
l0ng-ai 586399deda fix(database): Snowflake runs nothing in another database and keeps sessions per data source (#3096)
A refused USE for a statement's database or schema no longer lets the statement run in the session's previous context (possibly another database of the data source) and is retried with the next statement. Sessions are keyed by data source as well as connection settings, so two data sources with the same credentials no longer share a Snowflake session and its transactions.
2026-11-13 15:33:09 +08:00
l0ng-ai e6e89860e6 fix(agent): don't overwrite a file at a case-only rename's new name on accept (#3095)
On a case-sensitive disk, accepting an a.txt -> A.txt rename removed a.txt and wrote A.txt unchecked, overwriting a file the user had created there. Both names are now checked before either changes (old: the checkout's file; new: absent or already the task's), and the new name is written before the old one is removed.
2026-11-13 15:32:53 +08:00
l0ng-ai d890c41352 fix: trusted interpreters only, plugin diffs off the disk, fresh check_files diagnostics, dotted Maven artifact ids (#3093)
- Python: All Interpreters ran the interpreter of every recent project, including ones last opened in Restricted Mode; only projects trusted in their own right are probed.
- Plugins: views.open-diff's file name became a path the diff viewer probed on disk; plugin diffs get a virtual plugin-diff: path and virtual paths aren't probed.
- MCP: check_files took a publication from before an already-applied outside edit as fresh; the LSP document records when its servers last got new text.
- Maven: version completion split dotted artifact ids into directories; only the group id is split.
2026-11-13 15:29:24 +08:00
l0ng-ai c942b87abb fix(editor): translate the code vision usage counts (#3088)
The usages hint above declarations was always English; it now goes through the text catalog, with Chinese entries.
2026-11-13 14:54:48 +08:00
l0ng-ai cdecb5916c fix(plugins): run the scripts of plugin html views in release builds (#3087)
A view's html body was loaded as a srcdoc frame, which inherits the workbench CSP; its script-src 'self' blocks inline scripts, so in every build that serves the CSP html views rendered but ran no script. In the app the body is now written into a page of the openidea-plugin scheme (.frame) whose response carries only the frame's own policy, sandboxed as before. Web views already load from the scheme; notebook HTML output never runs scripts.
2026-11-13 14:44:26 +08:00
Clarence 835c44615b fix(vcs): Branches popup search matches words, not scattered letters (#3076)
The Branches popup filtered with fuzzyMatch, whose last fallback accepts any subsequence, so `test` listed names like `sprint-1.1-device-reset`. It now uses wordMatch, as Search Everywhere and the Keymap page do: a substring, or fragments at word / camel-hump starts. Repository names in a multi-repository project keep the loose match. The search field takes the bar's free width, so the clear, Fetch and Options buttons sit at the right edge.

Co-authored-by: Clarence404 <[email protected]>
2026-11-13 14:19:19 +08:00
l0ng-ai df79c80aa8 feat(plugins): notes after file names in the project tree (API 1.7) (#3085)
Tree decorations could carry only a two-character badge and a tooltip. decorations.set-file-note (API 1.7) shows one line of muted text (at most 200 characters, optional tooltip) after a file or folder name in the project tree, replacing that tree set's item for the file and sharing the tree-set limits. The SDK wraps it as host::set_file_note with a mock; checked-in components rebuilt.
2026-11-13 14:18:04 +08:00
l0ng-ai 73df48db7c fix(vue): resolve the @ alias in Vue projects without Vue CLI (#3080)
Only projects depending on @vue/cli-service had their webpack aliases passed to tsserver, so @/ imports resolved to nothing in Vue CLI 2, custom webpack or Vite projects without jsconfig.json. Those now get their own webpack configuration's aliases (function / list / ES module exports handled), else the @ -> src alias Vue templates set up.
2026-11-13 14:01:38 +08:00
l0ng-ai 6143011880 fix(http-client): take the server port from the active Spring profiles' files (#3079)
Generated requests read server.port and the context path only from application.properties / application.yml, so a port in application-dev.yml under spring.profiles.active=dev was ignored. The active profiles' files are now read over the base ones, the last profile winning.
2026-11-13 13:55:41 +08:00
l0ng-ai d1872c07ce feat(http-client): request bodies from DTOs in other modules, header completion (#3078)
The @RequestBody type of a Spring mapping was looked up only beside the controller or in its source root, so a DTO in another Maven module produced {}. Types not found there are looked up through the project index in the imported (or same / wildcard-imported) package, and superclass fields are included. Header names and common header values complete in .http request blocks. Requests opened from an endpoint use the module's server address instead of a fixed localhost:8080.
2026-11-13 13:54:32 +08:00
l0ng-ai cd79293fd7 fix(libraries): keep External Libraries in a stable order and reload after a build sync (#3077)
External Libraries groups were listed in detection order, which differed between the streamed and final results; they are now sorted SDKs first, then libraries by name. A Maven sync that changed dependency versions without touching a manifest left old versions listed; every successful build sync now emits projectRootsChanged (Gradle already did) and External Libraries reloads on it.
2026-11-13 13:48:12 +08:00
rohitkumardandaandrohitdanda 86f7b747a5 fix(security): stop other pages from embedding the app's own pages (#2998)
The app's CSP now includes `frame-ancestors 'none'`, so no page (for example a project's HTML in the built-in preview) can embed the IDE's own pages in a frame. Notebook HTML output, plugin views (srcdoc frames) and the built-in HTML preview still render in a debug bundle that serves the CSP.

Co-authored-by: rohitdanda <[email protected]>
2026-11-13 13:46:25 +08:00
l0ng-ai 2e5e9acd9f fix(ui): keep Select All in a reading pane out of the composer (#3073)
⌘A in an agent conversation selected the pane from its own start to its own end; WebKit paints a selection ending on the pane's boundary up to the next selectable text after it, so the composer looked selected too. Select All now runs from the pane's first to its last selectable text, like a clamped drag selection already does.
2026-11-13 13:43:14 +08:00
caojcm b5e9e27dad fix(updater): update portable Windows installs in place (#2846)
A portable Windows build (the raw exe from the portable ZIP, which has no bundle type) updated through the installer, which installed the app elsewhere instead of updating the folder it was unpacked in.

- Updater: an unbundled Windows build asks for the `windows-<arch>-portable` target. Its update is the signed portable ZIP (verified by the updater's download), unpacked into a staging folder next to the exe; only `openidea.exe` and `WebView2Loader.dll` are accepted. Each file is replaced with a backup, rolled back if any replacement fails, and the backups are removed on the next start.
- Release: the portable ZIP is signed with the updater key and `latest.json` lists `windows-x86_64-portable` and `windows-aarch64-portable`.

Co-authored-by: caojcm <[email protected]>
2026-11-13 13:34:56 +08:00
GuoYuandguoyucode ff1bfeea9d feat(mcp): check_files for fresh diagnostics, exact read_file ranges, search results with the matched line and context (#3061)
- check_files syncs each file from disk, tells its language servers it was saved (so on-save checks such as rust-analyzer's cargo check run), waits until every server answered for the current text and went idle, and reports per file whether the problems are complete (checked / incomplete / serverUnavailable / noLanguageServer / untrusted / failed) with each server's status and reason.
- read_file with offset or limit returns exactly that range plus where to read on; the default read of a whole file keeps its head and tail.
- search_text / search_regex / search_symbol results carry the matched line (long lines cut around the match) and, with `context`, the lines before and after it.
- Merged main: check_files uses the run tools' uncapped timeout.

Co-authored-by: guoyucode <[email protected]>
2026-11-13 13:29:43 +08:00
GuoYuandguoyucode 14ee1a97f2 feat(mcp): agent commands in Run tool window tabs, with streamed progress and no timeout cap (#2997)
The MCP server let agents read and edit code but not run a command and follow it, so they ran commands in their own shell, out of the user's sight. New tools run_command, wait_for_run, read_run_output, get_run_status, send_run_input, stop_run and list_runs run each command in a Run tool window tab _agent_<name>: the user sees it and can type into it, rerun, stop or close it, and results then carry interventions and advice so the agent stops and asks. The same name reuses its tab; a running one is refused. Every start asks the user unless tools run without confirmation. Commands run on pipes, with maxLinesCount / truncateMode, offsets to read on, and pattern waits. Finished agent tabs beyond a limit (default 1) move to an Archived (n) menu.

A call still running after a second is answered on an event stream with a progress notification every 15 s, so clients that drop a response not started within a minute keep waiting; the run tools (build_project and execute_run_configuration included) no longer cap timeout. A call is cancelled when its client goes away; the stdio bridge relays streamed answers.

Co-authored-by: guoyucode <[email protected]>
2026-11-13 13:23:56 +08:00
l0ng-ai 916c4243bf fix(agents): shorten the command permissions copy (#3072)
Settings › Agents › Command Execution Policy becomes Command Permissions, its description is split into short sentences, the columns drop the (Whitelist) / (Blacklist) suffixes, and the allowed-command placeholder no longer suggests a trailing `*` that allow entries ignore. Chinese: 命令权限, and Coding Agents reads 编程智能体 like its parent group.
2026-11-13 13:18:05 +08:00
l0ng-ai 9c63f174e4 fix(keymap): list the Git log's Show Diff in the VS Code preset (#3071)
#3065 bound vcs.log.changes.showDiff in the IntelliJ keymaps, and the VS Code
preset must name every IntelliJ action id (keymap.test.ts). VS Code has no Show
Diff key, so it gets none, like vcs.showDiff.
2026-11-13 13:13:44 +08:00
GuoYuandguoyucode b463252bc5 fix(run): decode Windows programs' piped output in any system language (#3060)
Commands run on pipes (Run configurations without terminal emulation, and agent commands) showed mojibake for Windows' own programs: ping, dir, cmd's messages and PowerShell's native output write a pipe in the console (OEM) code page, wsl.exe and cmd /u write UTF-16LE, while a UTF-8 console decoded every byte as UTF-8.

On Windows, a UTF-8 console now decodes piped output line by line: a UTF-8 line stays UTF-8, any other line is decoded in the console code page (encoding_rs for GBK, Big5, Shift_JIS, EUC-KR, 866, 874 and the 125x pages; Windows for the single-byte OEM pages it lacks), and a read with NUL bytes is UTF-16LE. ASCII passes through at once; the rest of a line is held until it shows its encoding, and a held prompt is shown after a batch interval without more output. An explicit console encoding and terminal emulation are unchanged; other platforms keep the plain UTF-8 decoder.

Co-authored-by: guoyucode <[email protected]>
2026-11-13 13:06:11 +08:00
GuoYuandguoyucode 2abdac4fdb fix(mcp): a run point in a Cargo workspace member runs that member's tests (#3059)
execute_run_configuration with a run point in a Cargo workspace member ran the first member's tests: every member's `cargo test -p <member>` configuration runs from the workspace root, so all were equally close to the file and the first won. A Cargo configuration now covers its member's directory (extras.module) when the closest configuration is picked; its working directory stays the workspace root.

Co-authored-by: guoyucode <[email protected]>
2026-11-13 13:04:35 +08:00
l0ng-ai 721f4144c1 fix(vcs): keep a commit's details shown while a refresh reloads them (#3070)
The Git log's commit details (containing branches, signature, refs) were keyed on
the repository's refresh version and dropped as soon as it changed, so each
refresh blanked the section until the reload finished. With something writing to
the repository's .git often, the section blinked continuously.

useCommitDetails now keeps the last details of the same commit until the reloaded
ones arrive; another commit still starts empty.
2026-11-13 13:01:09 +08:00
l0ng-ai 55057b8958 feat(vcs): open a file on its Git remote from Open In (#3069)
Open In in the project view and the editor (context menu and tab) now lists the
repository's hosting service, GitHub, GitLab, Bitbucket or Gitee, named after the
remote. It opens the file at the caret line, or the directory, on the remote's web
UI, at the current branch's upstream when it has one and at the HEAD commit
otherwise. The remote is the upstream's, else origin. Hosts other than the known
services are treated as self-managed GitLab and shown by host name.

The remote URL of each repository is read when the repositories or the upstream
change, so the menu is built without waiting for Git.
2026-11-13 12:57:02 +08:00
l0ng-ai 8abc8e7fbf fix(clipboard): read the clipboard natively for Paste from menus (#3068)
A Paste chosen from a menu (the terminal's context menu, Edit › Paste, Paste as
Plain Text and other paste buttons) has no paste event to read, so it used
navigator.clipboard.readText(), and the terminal tried execCommand("paste") first.
On Linux, WebKitGTK gives neither the text, so the terminal's context-menu Paste
did nothing while middle-click paste worked.

A new clipboard_text command reads the clipboard's text natively, as
clipboard_write_text already writes it: GTK on Linux, NSPasteboard on macOS, and
none on Windows, where the WebView's clipboard is still used.
readClipboardPlainText() wraps it with that fallback, and every menu paste reads
through it. On Linux the terminal pastes the native text before trying
execCommand; an image-only clipboard keeps the existing paths.
2026-11-13 12:52:26 +08:00
l0ng-ai 68dd949df3 fix(terminal): keep the first shifted character a macOS IME commits directly (#3067)
With WeChat or Pinyin on macOS, Shift+/ commits `?` with an insertText that
arrives before the character's keydown, while only Shift is down. xterm's input
gate was already armed by the Shift keydown and dropped the event, so the first
press produced nothing and the key had to be pressed twice.

The macOS input handler now also delivers an insertText that arrives while only
modifiers are down since the last keyup. Shifted keys xterm sends itself are
unchanged: their keydown comes first.
2026-11-13 12:41:17 +08:00
l0ng-ai b98bddbbab fix(vcs): fill the diff dialog with the editors (#3066)
The front diff dialog (the modal commit interface's Show Diff) left the area below a
short diff in the dialog's background: its body wrapper used flex: 1 inside the
dialog body, which is not a flex container, so it only took the content's height.
It now fills the body, and the editors' background reaches the footer.
2026-11-13 12:33:15 +08:00
l0ng-ai 21b05ab73a fix(vcs): show the commit's diff on ⌘D in the Git Log's changed files (#3065)
⌘D / Ctrl+D in the Git Log's changed files ran the global Show Diff, which diffs
the selected file's working copy against HEAD, and the context menu's Show Diff had
no shortcut. The changed files now set a vcsLogChangesFocus context; the log's Show
Diff is bound to the Show Diff key while it is set, so the menu shows it and the key
opens the commit's diff of the selected files, and the global Show Diff steps aside.
2026-11-13 12:31:56 +08:00
l0ng-ai 7d7de0b966 fix(python): exclude virtual environments from detected roots whatever their name (#3058)
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:38 +08:00
Clarence 830f4aa802 perf(editor): don't rescan rendered doc comments on every keystroke (#3052)
With Render Documentation Comments on, every keystroke rescanned all comment nodes and rebuilt every rendered comment, so typing in a large file cost about 10 ms per keystroke against about 1 ms with rendering off.

A keystroke (input.type / delete) that touches no comment's lines now only maps the rendered comments and their decorations through the change and marks the view stale; the file is scanned again 300 ms after typing and parsing pause, by the scanner that already does this while rendering is off (renamed deferredScanner). Paste, drop, undo/redo, formatting, reloads, keystrokes on a comment's lines, toggles and relabels still rebuild at once, and parser progress rebuilds at once unless a scan is already waiting, so files still open with visible comments rendered.

Co-authored-by: Clarence404 <[email protected]>
2026-11-13 12:18:34 +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
431 changed files with 10845 additions and 1190 deletions

No files matched your search

+9
View File
@@ -151,6 +151,13 @@ jobs:
$hash = (Get-FileHash $asset -Algorithm SHA256).Hash.ToLower() $hash = (Get-FileHash $asset -Algorithm SHA256).Hash.ToLower()
"$hash $asset" | Out-File -Encoding ascii "$asset.sha256" "$hash $asset" | Out-File -Encoding ascii "$asset.sha256"
gh release upload $env:TAG $asset "$asset.sha256" --clobber gh release upload $env:TAG $asset "$asset.sha256" --clobber
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
if ($env:TAURI_SIGNING_PRIVATE_KEY) {
pnpm exec tauri signer sign --app-version $version $asset
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
gh release upload $env:TAG "$asset.sig" --clobber
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
}
# The remote development server, per host platform it can run on. The desktop # The remote development server, per host platform it can run on. The desktop
# app downloads the one for the host (ide-tools `server`) and installs it over SSH. # app downloads the one for the host (ide-tools `server`) and installs it over SSH.
@@ -233,6 +240,8 @@ jobs:
[/_x64\.app\.tar\.gz$/, ["darwin-x86_64", "darwin-x86_64-app"]], [/_x64\.app\.tar\.gz$/, ["darwin-x86_64", "darwin-x86_64-app"]],
...windows("aarch64", /_arm64_en-US\.msi$/, /_arm64-setup\.exe$/), ...windows("aarch64", /_arm64_en-US\.msi$/, /_arm64-setup\.exe$/),
...windows("x86_64", /_x64_en-US\.msi$/, /_x64-setup\.exe$/), ...windows("x86_64", /_x64_en-US\.msi$/, /_x64-setup\.exe$/),
[/_windows_arm64_portable\.zip$/, ["windows-aarch64-portable"]],
[/_windows_x64_portable\.zip$/, ["windows-x86_64-portable"]],
]; ];
const platforms = {}; const platforms = {};
for (const sig of sigs) { for (const sig of sigs) {
Generated
+44 -44
View File
@@ -4144,7 +4144,7 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]] [[package]]
name = "ide-agent" name = "ide-agent"
version = "0.1.43" version = "0.1.44"
dependencies = [ dependencies = [
"dunce", "dunce",
"encoding_rs", "encoding_rs",
@@ -4168,7 +4168,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-analysis" name = "ide-analysis"
version = "0.1.43" version = "0.1.44"
dependencies = [ dependencies = [
"ast-grep-core", "ast-grep-core",
"ide-core", "ide-core",
@@ -4182,7 +4182,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-android" name = "ide-android"
version = "0.1.43" version = "0.1.44"
dependencies = [ dependencies = [
"ide-core", "ide-core",
"serde", "serde",
@@ -4193,7 +4193,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-build" name = "ide-build"
version = "0.1.43" version = "0.1.44"
dependencies = [ dependencies = [
"blake3", "blake3",
"chardetng", "chardetng",
@@ -4211,7 +4211,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-cli" name = "ide-cli"
version = "0.1.43" version = "0.1.44"
dependencies = [ dependencies = [
"dirs 6.0.0", "dirs 6.0.0",
"serde", "serde",
@@ -4225,7 +4225,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-clipboard" name = "ide-clipboard"
version = "0.1.43" version = "0.1.44"
dependencies = [ dependencies = [
"gtk", "gtk",
"objc2 0.6.5", "objc2 0.6.5",
@@ -4235,7 +4235,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-core" name = "ide-core"
version = "0.1.43" version = "0.1.44"
dependencies = [ dependencies = [
"dunce", "dunce",
"encoding_rs", "encoding_rs",
@@ -4252,7 +4252,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-coverage" name = "ide-coverage"
version = "0.1.43" version = "0.1.44"
dependencies = [ dependencies = [
"ide-core", "ide-core",
"ide-run", "ide-run",
@@ -4266,7 +4266,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-dap" name = "ide-dap"
version = "0.1.43" version = "0.1.44"
dependencies = [ dependencies = [
"futures", "futures",
"ide-core", "ide-core",
@@ -4292,7 +4292,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-db" name = "ide-db"
version = "0.1.43" version = "0.1.44"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"bb8", "bb8",
@@ -4328,7 +4328,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-docker" name = "ide-docker"
version = "0.1.43" version = "0.1.44"
dependencies = [ dependencies = [
"ide-core", "ide-core",
"serde", "serde",
@@ -4341,7 +4341,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-extension" name = "ide-extension"
version = "0.1.43" version = "0.1.44"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"blake3", "blake3",
@@ -4362,7 +4362,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-fonts" name = "ide-fonts"
version = "0.1.43" version = "0.1.44"
dependencies = [ dependencies = [
"objc2-core-foundation", "objc2-core-foundation",
"objc2-core-text", "objc2-core-text",
@@ -4374,7 +4374,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-github" name = "ide-github"
version = "0.1.43" version = "0.1.44"
dependencies = [ dependencies = [
"ide-core", "ide-core",
"ide-vcs", "ide-vcs",
@@ -4387,7 +4387,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-heap" name = "ide-heap"
version = "0.1.43" version = "0.1.44"
dependencies = [ dependencies = [
"criterion", "criterion",
"ide-core", "ide-core",
@@ -4405,7 +4405,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-host" name = "ide-host"
version = "0.1.43" version = "0.1.44"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"blake3", "blake3",
@@ -4476,7 +4476,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-http" name = "ide-http"
version = "0.1.43" version = "0.1.44"
dependencies = [ dependencies = [
"ide-core", "ide-core",
"ide-vfs", "ide-vfs",
@@ -4495,7 +4495,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-index" name = "ide-index"
version = "0.1.43" version = "0.1.44"
dependencies = [ dependencies = [
"blake3", "blake3",
"criterion", "criterion",
@@ -4517,7 +4517,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-jdwp" name = "ide-jdwp"
version = "0.1.43" version = "0.1.44"
dependencies = [ dependencies = [
"ide-jvm-inspect", "ide-jvm-inspect",
"proptest", "proptest",
@@ -4529,7 +4529,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-jsonrpc" name = "ide-jsonrpc"
version = "0.1.43" version = "0.1.44"
dependencies = [ dependencies = [
"encoding_rs", "encoding_rs",
"futures", "futures",
@@ -4546,7 +4546,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-jvm-attach" name = "ide-jvm-attach"
version = "0.1.43" version = "0.1.44"
dependencies = [ dependencies = [
"libc", "libc",
"tempfile", "tempfile",
@@ -4555,7 +4555,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-jvm-inspect" name = "ide-jvm-inspect"
version = "0.1.43" version = "0.1.44"
dependencies = [ dependencies = [
"futures", "futures",
"serde", "serde",
@@ -4566,7 +4566,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-language" name = "ide-language"
version = "0.1.43" version = "0.1.44"
dependencies = [ dependencies = [
"blake3", "blake3",
"criterion", "criterion",
@@ -4623,7 +4623,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-lsp" name = "ide-lsp"
version = "0.1.43" version = "0.1.44"
dependencies = [ dependencies = [
"dunce", "dunce",
"encoding_rs", "encoding_rs",
@@ -4651,7 +4651,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-mcp" name = "ide-mcp"
version = "0.1.43" version = "0.1.44"
dependencies = [ dependencies = [
"bytes", "bytes",
"dunce", "dunce",
@@ -4670,7 +4670,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-notebook" name = "ide-notebook"
version = "0.1.43" version = "0.1.44"
dependencies = [ dependencies = [
"futures", "futures",
"ide-core", "ide-core",
@@ -4693,7 +4693,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-plugin" name = "ide-plugin"
version = "0.1.43" version = "0.1.44"
dependencies = [ dependencies = [
"blake3", "blake3",
"dunce", "dunce",
@@ -4716,7 +4716,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-profiler" name = "ide-profiler"
version = "0.1.43" version = "0.1.44"
dependencies = [ dependencies = [
"ide-core", "ide-core",
"ide-jvm-attach", "ide-jvm-attach",
@@ -4730,7 +4730,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-remote" name = "ide-remote"
version = "0.1.43" version = "0.1.44"
dependencies = [ dependencies = [
"dunce", "dunce",
"futures", "futures",
@@ -4750,7 +4750,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-run" name = "ide-run"
version = "0.1.43" version = "0.1.44"
dependencies = [ dependencies = [
"dunce", "dunce",
"encoding_rs", "encoding_rs",
@@ -4774,7 +4774,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-scaffold" name = "ide-scaffold"
version = "0.1.43" version = "0.1.44"
dependencies = [ dependencies = [
"ide-core", "ide-core",
"ide-tools", "ide-tools",
@@ -4790,7 +4790,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-sdk" name = "ide-sdk"
version = "0.1.43" version = "0.1.44"
dependencies = [ dependencies = [
"dunce", "dunce",
"ide-core", "ide-core",
@@ -4811,7 +4811,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-search" name = "ide-search"
version = "0.1.43" version = "0.1.44"
dependencies = [ dependencies = [
"criterion", "criterion",
"encoding_rs", "encoding_rs",
@@ -4832,7 +4832,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-spell" name = "ide-spell"
version = "0.1.43" version = "0.1.44"
dependencies = [ dependencies = [
"ide-core", "ide-core",
"parking_lot", "parking_lot",
@@ -4845,7 +4845,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-ssh" name = "ide-ssh"
version = "0.1.43" version = "0.1.44"
dependencies = [ dependencies = [
"dirs 6.0.0", "dirs 6.0.0",
"flate2", "flate2",
@@ -4870,7 +4870,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-syntax" name = "ide-syntax"
version = "0.1.43" version = "0.1.44"
dependencies = [ dependencies = [
"ide-core", "ide-core",
"ide-language", "ide-language",
@@ -4885,7 +4885,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-terminal" name = "ide-terminal"
version = "0.1.43" version = "0.1.44"
dependencies = [ dependencies = [
"ide-core", "ide-core",
"libc", "libc",
@@ -4898,7 +4898,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-text" name = "ide-text"
version = "0.1.43" version = "0.1.44"
dependencies = [ dependencies = [
"criterion", "criterion",
"ide-core", "ide-core",
@@ -4911,7 +4911,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-tools" name = "ide-tools"
version = "0.1.43" version = "0.1.44"
dependencies = [ dependencies = [
"flate2", "flate2",
"hex", "hex",
@@ -4932,7 +4932,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-vcs" name = "ide-vcs"
version = "0.1.43" version = "0.1.44"
dependencies = [ dependencies = [
"blake3", "blake3",
"dunce", "dunce",
@@ -4953,7 +4953,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-vfs" name = "ide-vfs"
version = "0.1.43" version = "0.1.44"
dependencies = [ dependencies = [
"chardetng", "chardetng",
"dunce", "dunce",
@@ -4977,7 +4977,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-web" name = "ide-web"
version = "0.1.43" version = "0.1.44"
dependencies = [ dependencies = [
"dirs 6.0.0", "dirs 6.0.0",
"ide-core", "ide-core",
@@ -4993,7 +4993,7 @@ dependencies = [
[[package]] [[package]]
name = "ide-workspace" name = "ide-workspace"
version = "0.1.43" version = "0.1.44"
dependencies = [ dependencies = [
"blake3", "blake3",
"dunce", "dunce",
@@ -6594,7 +6594,7 @@ dependencies = [
[[package]] [[package]]
name = "openidea" name = "openidea"
version = "0.1.43" version = "0.1.44"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"blake3", "blake3",
+1 -1
View File
@@ -5,7 +5,7 @@ members = ["crates/*", "src-tauri"]
exclude = ["sdk", "plugins"] exclude = ["sdk", "plugins"]
[workspace.package] [workspace.package]
version = "0.1.43" version = "0.1.44"
edition = "2024" edition = "2024"
rust-version = "1.96" rust-version = "1.96"
license = "Apache-2.0" license = "Apache-2.0"
+77 -22
View File
@@ -225,6 +225,15 @@ fn same_file(a: &Path, b: &Path) -> bool {
} }
} }
/// The bytes of `path` (`Some(None)`: no file there), or `None` when it is there but can't be read.
fn existing(path: &Path) -> Option<Option<Vec<u8>>> {
match std::fs::read(path) {
Ok(bytes) => Some(Some(bytes)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Some(None),
Err(_) => None,
}
}
fn crlf_to_lf(bytes: &[u8]) -> Vec<u8> { fn crlf_to_lf(bytes: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(bytes.len()); let mut out = Vec::with_capacity(bytes.len());
for (i, &b) in bytes.iter().enumerate() { for (i, &b) in bytes.iter().enumerate() {
@@ -411,25 +420,31 @@ pub(crate) fn accept(
let copyable = |rel: &str| task_side.allows(rel) && checkout_side.allows(rel); let copyable = |rel: &str| task_side.allows(rel) && checkout_side.allows(rel);
let renamed_from: BTreeMap<String, String> = let renamed_from: BTreeMap<String, String> =
repo.diff_with_worktree(base)?.into_iter().filter_map(|f| Some((f.path, f.old_path?))).collect(); 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. // The task's version of one path of the checkout (`Some(None)`: absent there), or `None` when
let apply = |rel: &str| -> IdeResult<bool> { // the checkout changed it itself.
let local = local_root.join(rel); let target = |rel: &str| -> IdeResult<Option<Option<Vec<u8>>>> {
let in_task = repo.root().join(rel); let in_task = repo.root().join(rel);
let base_bytes = repo.file_bytes_at(&in_task, Some(base))?;
let current = std::fs::read(&in_task).ok(); let current = std::fs::read(&in_task).ok();
let local_bytes = std::fs::read(&local).ok(); // A file there that can't be read is the checkout's own: never written over.
let Some(local_bytes) = existing(&local_root.join(rel)) else { return Ok(None) };
if local_bytes == current { if local_bytes == current {
return Ok(true); return Ok(Some(current));
} }
let base_bytes = repo.file_bytes_at(&in_task, Some(base))?;
// The checkout may hold the committed LF text with CRLF line ends (`core.autocrlf`). // The checkout may hold the committed LF text with CRLF line ends (`core.autocrlf`).
let unchanged = match (&local_bytes, &base_bytes) { let unchanged = match (&local_bytes, &base_bytes) {
(Some(now), Some(at_base)) => now == at_base || crlf_to_lf(now) == *at_base, (Some(now), Some(at_base)) => now == at_base || crlf_to_lf(now) == *at_base,
(now, at_base) => now == at_base, (now, at_base) => now == at_base,
}; };
if !unchanged { Ok(unchanged.then_some(current))
return Ok(false); };
// Puts `bytes` (`None`: nothing) at `rel` of the checkout.
let write = |rel: &str, bytes: Option<Vec<u8>>| -> IdeResult<()> {
let local = local_root.join(rel);
if std::fs::read(&local).ok() == bytes {
return Ok(());
} }
match current { match bytes {
Some(bytes) => { Some(bytes) => {
if let Some(parent) = local.parent() { if let Some(parent) = local.parent() {
std::fs::create_dir_all(parent)?; std::fs::create_dir_all(parent)?;
@@ -441,6 +456,12 @@ pub(crate) fn accept(
_ => {} _ => {}
}, },
} }
Ok(())
};
// Brings one path of the checkout to the task's version, unless the checkout changed it itself.
let apply = |rel: &str| -> IdeResult<bool> {
let Some(bytes) = target(rel)? else { return Ok(false) };
write(rel, bytes)?;
Ok(true) Ok(true)
}; };
let mut accepted = Vec::with_capacity(changes.len()); let mut accepted = Vec::with_capacity(changes.len());
@@ -466,8 +487,9 @@ pub(crate) fn accept(
// A case-only rename (`a.txt` → `A.txt`). Where the disk resolves both // 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 // 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 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 — // both are checked before either changes — the old one must be the file the
// then the task's version lands at the new name and the old one goes. // checkout had, the new one absent or already the task's — then the task's
// version lands at the new name before the old one goes.
let case_only = let case_only =
old.is_some_and(|o| o != &change.rel_path && o.to_lowercase() == change.rel_path.to_lowercase()); old.is_some_and(|o| o != &change.rel_path && o.to_lowercase() == change.rel_path.to_lowercase());
if case_only { if case_only {
@@ -476,20 +498,18 @@ pub(crate) fn accept(
if same_file(&old_path, &new_path) { if same_file(&old_path, &new_path) {
std::fs::rename(&old_path, &new_path)?; std::fs::rename(&old_path, &new_path)?;
} else { } else {
if !apply(old)? { let Some(old_target) = target(old)? else {
skipped.push(skip(old)); skipped.push(skip(old));
continue; continue;
};
let task_bytes = std::fs::read(repo.root().join(&change.rel_path)).ok();
let at_new = existing(&new_path);
if at_new.as_ref().is_none_or(|at_new| at_new.is_some() && *at_new != task_bytes) {
skipped.push(skip(&change.rel_path));
continue;
} }
// `apply(old)` removed the old file (the task's tree has it only at write(&change.rel_path, task_bytes)?;
// the new name); now write that version under the new name. `apply` write(old, old_target)?;
// can't do it — it reads the new name's own base/absence — so the
// write is open-coded from the same task bytes `apply` would use.
if let Ok(bytes) = std::fs::read(repo.root().join(&change.rel_path)) {
if let Some(parent) = new_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&new_path, bytes)?;
}
accepted.push(change.rel_path.clone()); accepted.push(change.rel_path.clone());
continue; continue;
} }
@@ -960,6 +980,41 @@ mod tests {
assert_eq!(names, ["A.txt"]); assert_eq!(names, ["A.txt"]);
} }
#[test]
fn a_case_only_rename_keeps_a_file_the_checkout_has_at_the_new_name() {
let (_tmp, repo, created) = accept_repo();
std::fs::write(repo.join("A.txt"), "mine\n").unwrap();
if std::fs::read_to_string(repo.join("a.txt")).unwrap() == "mine\n" {
eprintln!("this disk doesn't tell a.txt from A.txt; nothing to check");
return;
}
git(&created.path, &["mv", "a.txt", "A.txt"]);
let changes = listed(&created.path, &created.base_commit);
let outcome = accept(&repo, &created.path, &created.base_commit, &changes).unwrap();
assert_eq!(outcome.skipped.iter().map(|s| s.path.as_str()).collect::<Vec<_>>(), ["A.txt"]);
assert_eq!(std::fs::read_to_string(repo.join("A.txt")).unwrap(), "mine\n");
assert_eq!(std::fs::read_to_string(repo.join("a.txt")).unwrap(), "one\ntwo\n");
}
#[cfg(unix)]
#[test]
fn accept_leaves_a_checkout_file_it_cannot_read() {
use std::os::unix::fs::PermissionsExt;
let (_tmp, repo, created) = accept_repo();
std::fs::write(created.path.join("new.txt"), "task\n").unwrap();
std::fs::write(repo.join("new.txt"), "mine\n").unwrap();
std::fs::set_permissions(repo.join("new.txt"), std::fs::Permissions::from_mode(0o200)).unwrap();
if std::fs::read(repo.join("new.txt")).is_ok() {
eprintln!("running as a user that reads any file; nothing to check");
return;
}
let changes = listed(&created.path, &created.base_commit);
let outcome = accept(&repo, &created.path, &created.base_commit, &changes).unwrap();
std::fs::set_permissions(repo.join("new.txt"), std::fs::Permissions::from_mode(0o644)).unwrap();
assert_eq!(outcome.skipped.iter().map(|s| s.path.as_str()).collect::<Vec<_>>(), ["new.txt"]);
assert_eq!(std::fs::read_to_string(repo.join("new.txt")).unwrap(), "mine\n");
}
#[cfg(windows)] #[cfg(windows)]
#[test] #[test]
fn accept_does_not_write_into_the_checkouts_git_folder_through_its_short_name() { fn accept_does_not_write_into_the_checkouts_git_folder_through_its_short_name() {
+2 -1
View File
@@ -12,7 +12,8 @@ mod up_to_date;
pub use fingerprint::{LaunchFingerprint, launch_fingerprint, menu_fingerprint}; pub use fingerprint::{LaunchFingerprint, launch_fingerprint, menu_fingerprint};
pub use launch_resources::process_launch_resources; pub use launch_resources::process_launch_resources;
pub use single::{ pub use single::{
SingleFileTarget, compile_file_plan, find_javac, javac_step, single_file_classpath, single_file_target, SingleFileTarget, compile_file_plan, find_javac, javac_step, module_classpath, single_file_classpath,
single_file_target,
}; };
pub use up_to_date::{menu_up_to_date, record_menu_build}; pub use up_to_date::{menu_up_to_date, record_menu_build};
+210 -3
View File
@@ -4,12 +4,21 @@
//! walks the whole reactor; compiling one file only needs that file's module //! walks the whole reactor; compiling one file only needs that file's module
//! outputs plus its dependency classpath, which is much faster to assemble. //! outputs plus its dependency classpath, which is much faster to assemble.
use super::*; use super::*;
use serde::{Deserialize, Serialize};
use std::fs; use std::fs;
use std::process::Command as ProcCommand; use std::process::Command as ProcCommand;
/// Output file of the `dependency:build-classpath` probe, relative to each module. /// Output file of the `dependency:build-classpath` probe, relative to each module.
const CP_FILE: &str = "target/ide-compile-cp.txt"; const CP_FILE: &str = "target/ide-compile-cp.txt";
/// The resolved dependency classpath for one module and scope, tied to the same
/// inputs as its launch build so POM, JDK, settings and profile changes invalidate it.
#[derive(Debug, Deserialize, Serialize)]
struct CachedClasspath {
fingerprint: LaunchFingerprint,
classpath: String,
}
/// A `.java` source and the module build that compiles it. /// A `.java` source and the module build that compiles it.
pub struct SingleFileTarget { pub struct SingleFileTarget {
/// Absolute module directory. /// Absolute module directory.
@@ -64,6 +73,28 @@ pub fn single_file_target(root: &Path, model: &BuildModel, file: &Path) -> IdeRe
Ok(SingleFileTarget { module, test, out_dir, top: top.clone(), pl, java_version: project.java_version }) Ok(SingleFileTarget { module, test, out_dir, top: top.clone(), pl, java_version: project.java_version })
} }
/// The Maven reactor target for a launch module whose source file is not known yet.
fn module_target(root: &Path, model: &BuildModel, module: &Path, test: bool) -> IdeResult<SingleFileTarget> {
let Some(m) = &model.maven else {
return Err(IdeError::not_found("no linked Maven project found for module"));
};
let module = if module.is_absolute() { module.to_path_buf() } else { root.join(module) };
let project = m
.projects
.iter()
.find(|p| root.join(&p.dir) == module)
.ok_or_else(|| IdeError::not_found("no linked Maven project found for module"))?;
if project.packaging == "pom" {
return Err(IdeError::not_found("the launch module is a Maven aggregator without sources"));
}
let top = maven_reactor(m, &project.dir)
.ok_or_else(|| IdeError::not_found("no linked Maven reactor found for module"))?;
let top_dir = subdir(root, &top.dir);
let pl = module.strip_prefix(&top_dir).map(|r| r.to_string_lossy().replace('\\', "/")).unwrap_or_default();
let out_dir = module.join("target").join(if test { "test-classes" } else { "classes" });
Ok(SingleFileTarget { module, test, out_dir, top: top.clone(), pl, java_version: project.java_version })
}
/// `javac` for the module: its `JAVA_HOME`, else `PATH`. /// `javac` for the module: its `JAVA_HOME`, else `PATH`.
pub fn find_javac(env: &BTreeMap<String, String>) -> String { pub fn find_javac(env: &BTreeMap<String, String>) -> String {
let home = env.get("JAVA_HOME").map(Path::new); let home = env.get("JAVA_HOME").map(Path::new);
@@ -78,6 +109,88 @@ pub fn find_javac(env: &BTreeMap<String, String>) -> String {
/// `mvn dependency:build-classpath` (which resolves without compiling anything, /// `mvn dependency:build-classpath` (which resolves without compiling anything,
/// so `-am` stays cheap). Entries are platform-separated (`;` on Windows). /// so `-am` stays cheap). Entries are platform-separated (`;` on Windows).
pub fn single_file_classpath(root: &Path, model: &BuildModel, target: &SingleFileTarget) -> IdeResult<String> { pub fn single_file_classpath(root: &Path, model: &BuildModel, target: &SingleFileTarget) -> IdeResult<String> {
classpath(root, model, target, "test", false)
}
/// Runtime (or test) dependency classpath for a launch module.
///
/// The Java language server runs on its own JDK, so its Maven model can select a
/// different JDK profile and dependency classifier than the project launch. Use the
/// same Maven process that builds the module instead.
pub fn module_classpath(
root: &Path,
model: &BuildModel,
module: &Path,
test: bool,
local_repo: Option<PathBuf>,
cache: &Path,
cancel: &ide_core::CancellationToken,
) -> IdeResult<String> {
let target = module_target(root, model, module, test)?;
let fingerprint = launch_fingerprint(root, model, &target.module, test, local_repo, cancel)?;
let cache_path = classpath_cache_path(root, cache, &target.module, test);
if let (Some(fingerprint), Some(path)) = (&fingerprint, &cache_path)
&& let Ok(bytes) = fs::read(path)
&& let Ok(cached) = serde_json::from_slice::<CachedClasspath>(&bytes)
&& cached.fingerprint == *fingerprint
&& classpath_entries_exist(root, &cached.classpath)
{
return Ok(cached.classpath);
}
let classpath = classpath(root, model, &target, if test { "test" } else { "runtime" }, true)?;
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
if let (Some(fingerprint), Some(path)) = (fingerprint, cache_path) {
let cached = CachedClasspath { fingerprint, classpath: classpath.clone() };
if let Err(error) = cache_classpath(&path, &cached) {
tracing::debug!(path = %path.display(), %error, "Cannot cache the Java launch classpath");
}
}
Ok(classpath)
}
/// One cache file per module and scope. Mirroring the module path keeps nested
/// modules and their different dependency scopes from overwriting each other.
fn classpath_cache_path(root: &Path, cache: &Path, module: &Path, test: bool) -> Option<PathBuf> {
let relative = module.strip_prefix(root).ok()?;
let mut path = cache.join("java-launch/classpaths");
path.push(if relative.as_os_str().is_empty() { Path::new("_root") } else { relative });
path.push(if test { "test.json" } else { "runtime.json" });
Some(path)
}
/// Best-effort cache persistence. A failed write only costs one later Maven
/// probe, so the launch still succeeds even when the cache directory is unusable.
fn cache_classpath(path: &Path, cached: &CachedClasspath) -> IdeResult<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(path, serde_json::to_vec(cached).map_err(|e| IdeError::internal(e.to_string()))?)?;
Ok(())
}
/// A cache entry stays valid while every file exists. Maven also emits reactor
/// output directories for modules with no classes, which may legitimately never be
/// created; the launch snapshot already supplies the module's classes in that case.
fn classpath_entries_exist(root: &Path, classpath: &str) -> bool {
// Temp directories on macOS are commonly reached through `/var`, while Maven
// reports the canonical `/private/var` path. Compare against both spellings.
let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
std::env::split_paths(classpath).all(|entry| {
entry.exists()
|| ((entry.starts_with(root) || entry.starts_with(&canonical_root))
&& (entry.ends_with(Path::new("target/classes")) || entry.ends_with(Path::new("target/test-classes"))))
})
}
fn classpath(
root: &Path,
model: &BuildModel,
target: &SingleFileTarget,
scope: &str,
compile_first: bool,
) -> IdeResult<String> {
let mut entries: Vec<String> = Vec::new(); let mut entries: Vec<String> = Vec::new();
// The module's own classes first: the file almost always uses classes next to it. // The module's own classes first: the file almost always uses classes next to it.
let own: &[&str] = if target.test { &["target/test-classes", "target/classes"] } else { &["target/classes"] }; let own: &[&str] = if target.test { &["target/test-classes", "target/classes"] } else { &["target/classes"] };
@@ -110,7 +223,14 @@ pub fn single_file_classpath(root: &Path, model: &BuildModel, target: &SingleFil
args.extend(["-pl", target.pl.as_str(), "-am"]); args.extend(["-pl", target.pl.as_str(), "-am"]);
} }
let output_arg = format!("-Dmdep.outputFile={probe}"); let output_arg = format!("-Dmdep.outputFile={probe}");
args.extend(["dependency:build-classpath", output_arg.as_str(), "-Dmdep.includeScope=test", "-q"]); let scope_arg = format!("-Dmdep.includeScope={scope}");
// A launch build may only have reached `compile`. Running `compile` in this
// same Maven session lets the dependency plugin resolve Reactor siblings from
// their target/classes without paying for package-phase plugins.
if compile_first {
args.push("compile");
}
args.extend(["dependency:build-classpath", output_arg.as_str(), scope_arg.as_str(), "-q"]);
let top_dir = subdir(root, &target.top.dir); let top_dir = subdir(root, &target.top.dir);
let status = ProcCommand::new(&target.top.command) let status = ProcCommand::new(&target.top.command)
.args(&args) .args(&args)
@@ -119,8 +239,10 @@ pub fn single_file_classpath(root: &Path, model: &BuildModel, target: &SingleFil
.output() .output()
.map_err(|e| IdeError::internal(format!("cannot resolve the compile classpath: {e}")))?; .map_err(|e| IdeError::internal(format!("cannot resolve the compile classpath: {e}")))?;
if !status.status.success() { if !status.status.success() {
let stdout = String::from_utf8_lossy(&status.stdout);
let stderr = String::from_utf8_lossy(&status.stderr); let stderr = String::from_utf8_lossy(&status.stderr);
let tail: Vec<&str> = stderr.lines().filter(|l| !l.trim().is_empty()).collect(); // Maven reports reactor/dependency failures on stdout, not only stderr.
let tail: Vec<&str> = stdout.lines().chain(stderr.lines()).filter(|l| !l.trim().is_empty()).collect();
let tail = tail.iter().rev().take(5).rev().cloned().collect::<Vec<_>>().join("\n"); let tail = tail.iter().rev().take(5).rev().cloned().collect::<Vec<_>>().join("\n");
return Err(IdeError::internal(format!("cannot resolve the compile classpath:\n{tail}"))); return Err(IdeError::internal(format!("cannot resolve the compile classpath:\n{tail}")));
} }
@@ -305,7 +427,7 @@ mod tests {
let script = root.join("mvnw"); let script = root.join("mvnw");
fs::write( fs::write(
&script, &script,
"#!/bin/sh\npl='.'\nwhile [ $# -gt 0 ]; do\nif [ \"$1\" = \"-pl\" ]; then pl=\"$2\"; shift 2; else shift; fi\ndone\nmkdir -p \"$pl/target\"\nprintf '/repo/a.jar:/repo/b.jar' > \"$pl/target/ide-compile-cp.txt\"\n", "#!/bin/sh\nprintf '%s\\n' \"$@\" > args.txt\npl='.'\nwhile [ $# -gt 0 ]; do\nif [ \"$1\" = \"-pl\" ]; then pl=\"$2\"; shift 2; else shift; fi\ndone\nmkdir -p \"$pl/target\"\nprintf '/repo/a.jar:/repo/b.jar' > \"$pl/target/ide-compile-cp.txt\"\n",
) )
.unwrap(); .unwrap();
fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap(); fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
@@ -327,5 +449,90 @@ mod tests {
assert!(cp.contains("/repo/a.jar"), "{cp}"); assert!(cp.contains("/repo/a.jar"), "{cp}");
assert!(cp.contains("/repo/b.jar"), "{cp}"); assert!(cp.contains("/repo/b.jar"), "{cp}");
assert!(cp.split(separator).count() >= 3, "{cp}"); assert!(cp.split(separator).count() >= 3, "{cp}");
let args = fs::read_to_string(root.join("args.txt")).unwrap();
assert!(!args.lines().any(|a| a == "compile" || a == "package"), "{args}");
}
/// A launch uses runtime dependencies, while tests additionally see test-scope ones.
#[cfg(unix)]
#[test]
fn launch_module_classpath_uses_the_matching_dependency_scope() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fixture(root);
let script = root.join("mvnw");
fs::write(
&script,
"#!/bin/sh\nprintf '%s\\n' \"$@\" > args.txt\npl='.'\nwhile [ $# -gt 0 ]; do\nif [ \"$1\" = \"-pl\" ]; then pl=\"$2\"; shift 2; else shift; fi\ndone\nmkdir -p \"$pl/target\"\nprintf '/repo/runtime.jar:/repo/test.jar' > \"$pl/target/ide-compile-cp.txt\"\n",
)
.unwrap();
fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
let model = crate::load(root, &root.join("cache"), None);
let cache = root.join("cache");
let runtime =
module_classpath(root, &model, &root.join("app"), false, None, &cache, &CancellationToken::new()).unwrap();
assert!(runtime.contains("/repo/runtime.jar"), "{runtime}");
let args = fs::read_to_string(root.join("args.txt")).unwrap();
assert!(args.lines().any(|a| a == "compile"), "{args}");
assert!(!args.lines().any(|a| a == "package"), "{args}");
assert!(args.lines().any(|a| a == "-Dmdep.includeScope=runtime"), "{args}");
let test =
module_classpath(root, &model, &root.join("app"), true, None, &cache, &CancellationToken::new()).unwrap();
assert!(test.contains("/repo/test.jar"), "{test}");
let args = fs::read_to_string(root.join("args.txt")).unwrap();
assert!(args.lines().any(|a| a == "-Dmdep.includeScope=test"), "{args}");
}
/// The launch classpath follows the Maven runner JDK and is reused until that
/// build input changes; the probe does not run packaging plugins.
#[cfg(unix)]
#[test]
fn launch_classpath_cache_follows_the_runner_jdk() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fixture(root);
let script = root.join("mvnw");
// `dependency:build-classpath` reports reactor outputs as absolute paths. Keep
// the fake probe faithful so the cache's existence check sees the output.
fs::write(
&script,
"#!/bin/sh\nprintf x >> calls.txt\npl='.'\nwhile [ $# -gt 0 ]; do\nif [ \"$1\" = \"-pl\" ]; then pl=\"$2\"; shift 2; else shift; fi\ndone\nmkdir -p \"$pl/target/classes\" \"$JAVA_HOME/classpath\"\n: > \"$JAVA_HOME/classpath/jdk.jar\"\nprintf '%s/classpath/jdk.jar:%s/%s/target/classes:%s/empty/target/classes' \"$JAVA_HOME\" \"$(pwd)\" \"$pl\" \"$(pwd)\" > \"$pl/target/ide-compile-cp.txt\"\n",
)
.unwrap();
fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
let mut model = crate::load(root, &root.join("cache"), None);
for (name, version) in [("jdk21", "21"), ("jdk8", "8")] {
let home = root.join(name);
fs::create_dir_all(home.join("bin")).unwrap();
fs::write(home.join("release"), format!("JAVA_VERSION=\"{version}\"\n")).unwrap();
fs::write(home.join("bin/java"), "").unwrap();
}
let cache = root.join("classpath-cache");
let cancel = CancellationToken::new();
let jdk21 = root.join("jdk21");
for project in &mut model.maven.as_mut().unwrap().projects {
project.env.insert("JAVA_HOME".into(), jdk21.to_string_lossy().into_owned());
}
let first = module_classpath(root, &model, &root.join("app"), false, None, &cache, &cancel).unwrap();
assert!(first.contains("jdk21/classpath/jdk.jar"), "{first}");
assert!(!first.contains("jdk8/classpath/jdk.jar"), "{first}");
let second = module_classpath(root, &model, &root.join("app"), false, None, &cache, &cancel).unwrap();
assert_eq!(second, first);
assert_eq!(fs::read_to_string(root.join("calls.txt")).unwrap(), "x");
let jdk8 = root.join("jdk8");
for project in &mut model.maven.as_mut().unwrap().projects {
project.env.insert("JAVA_HOME".into(), jdk8.to_string_lossy().into_owned());
}
let switched = module_classpath(root, &model, &root.join("app"), false, None, &cache, &cancel).unwrap();
assert!(switched.contains("jdk8/classpath/jdk.jar"), "{switched}");
assert!(!switched.contains("jdk21/classpath/jdk.jar"), "{switched}");
assert_eq!(fs::read_to_string(root.join("calls.txt")).unwrap(), "xx");
} }
} }
+207 -22
View File
@@ -712,18 +712,27 @@ pub(crate) fn launch_inputs(
// Profile activation may depend on the presence of an otherwise unrelated file. // Profile activation may depend on the presence of an otherwise unrelated file.
let activation = parent == "file" && matches!(el.name.as_str(), "exists" | "missing"); let activation = parent == "file" && matches!(el.name.as_str(), "exists" | "missing");
if input || activation { if input || activation {
// Other properties can be overridden by -D, profiles or settings. // A source root under Maven's build directory is generated by the build itself.
// Without Maven's effective roots, conservatively disable reuse for them. // Skip it without resolving the property: `<build><directory>` may be custom, and
let value = el // hashing its rewritten files would make every launch look changed.
.text if !(input && el.text.contains("${project.build.directory}")) {
.trim() // Other properties can be overridden by -D, profiles or settings. Without
.replace("${project.basedir}", &dir.to_string_lossy()) // Maven's effective model, leave them unresolved to conservatively disable reuse.
.replace("${basedir}", &dir.to_string_lossy()); let value = el
if value.contains("${") { .text
return None; .trim()
} .replace("${project.basedir}", &dir.to_string_lossy())
if !value.is_empty() { .replace("${basedir}", &dir.to_string_lossy());
out.push(dir.join(value)); if value.contains("${") {
return None;
}
if !value.is_empty() {
let path = dir.join(value);
// Conventional generated sources under target are also build outputs.
if !(input && path.starts_with(dir.join("target"))) {
out.push(path);
}
}
} }
} }
for child in &el.children { for child in &el.children {
@@ -1680,6 +1689,15 @@ pub enum PomGotoKind {
ManagedDependency, ManagedDependency,
/// A `<pluginManagement>` entry providing the plugin's version. /// A `<pluginManagement>` entry providing the plugin's version.
ManagedPlugin, 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`. /// Byte offset of `pos` (0-based line / UTF-16 column), clamped to `text`.
@@ -1759,6 +1777,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 /// the local repository when it is not in the project). `None` when there is no
/// resolvable declaration here. /// resolvable declaration here.
pub fn goto_declaration(text: &str, path: &Path, pos: Pos, local_repo: Option<PathBuf>) -> Option<PomGoto> { 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 root = xml::parse(text).filter(|e| e.name == "project")?;
let at = offset_at(text, pos); let at = offset_at(text, pos);
if let Some((name, _, _)) = property_ref_at(text, at) if let Some((name, _, _)) = property_ref_at(text, at)
@@ -1769,15 +1801,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 // A `<dependency>` / `<plugin>` in a declaration position: its version may come
// from a management entry of this POM or an ancestor's. // from a management entry of this POM or an ancestor's.
let elements = element_path(&root, at); let elements = element_path(&root, at);
let e = elements.iter().rev().find(|e| e.name == "dependency" || e.name == "plugin")?; if elements.get(1).is_some_and(|e| e.name == "parent") {
if elements.iter().any(|a| a.name == "dependencyManagement" || a.name == "pluginManagement") { let chain = parent_chain(path, text, local_repo);
return None; // Management entries are declarations; there is nothing to go to. 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() { let e = elements.iter().rev().find(|e| e.name == "dependency" || e.name == "plugin")?;
"dependency" => PomGotoKind::ManagedDependency, // A management entry declares the version: it goes to the artifact (a BOM import, say).
_ => PomGotoKind::ManagedPlugin, if !elements.iter().any(|a| a.name == "dependencyManagement" || a.name == "pluginManagement") {
}; let kind = match e.name.as_str() {
managed_target(text, path, e, kind, local_repo) "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, /// The parent POMs of the POM in `text` at `path`, child-first (its `<parent>` refs,
@@ -2076,6 +2149,52 @@ mod tests {
assert_eq!(known.goals, ["compile", "help", "testCompile"], "the common plugins keep their table"); assert_eq!(known.goals, ["compile", "help", "testCompile"], "the common plugins keep their table");
} }
#[test]
fn launch_inputs_skip_generated_sources_under_the_project_build_directory() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(
root,
"pom.xml",
"<project><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version>\
<packaging>pom</packaging><modules><module>app</module></modules></project>",
);
write(
root,
"app/pom.xml",
r#"<project><parent><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version></parent>
<artifactId>app</artifactId><build><plugins><plugin>
<groupId>org.codehaus.mojo</groupId><artifactId>build-helper-maven-plugin</artifactId>
<executions><execution><configuration><sources>
<source>${project.build.directory}/generated-sources/java</source>
</sources></configuration></execution></executions>
</plugin></plugins></build></project>"#,
);
let model = load_with_repo(root, None);
let projects: Vec<&MavenProject> = model.projects.iter().collect();
let inputs = launch_inputs(root, &projects, None).expect("portable launch inputs");
assert!(!inputs.contains(&root.join("app/target/generated-sources/java")), "{inputs:?}");
assert!(inputs.contains(&root.join("app/src")), "conventional sources stay fingerprinted");
}
#[test]
fn launch_inputs_disable_reuse_for_file_activation_on_the_build_directory() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(
root,
"pom.xml",
r#"<project><groupId>demo</groupId><artifactId>app</artifactId><version>1</version>
<build><directory>${project.basedir}/custom-build</directory></build>
<profiles><profile><id>generated</id><activation><file>
<exists>${project.build.directory}/enable-profile</exists>
</file></activation></profile></profiles></project>"#,
);
let model = load_with_repo(root, None);
let projects: Vec<&MavenProject> = model.projects.iter().collect();
assert!(launch_inputs(root, &projects, None).is_none());
}
fn write(dir: &Path, rel: &str, text: &str) { fn write(dir: &Path, rel: &str, text: &str) {
let p = dir.join(rel); let p = dir.join(rel);
std::fs::create_dir_all(p.parent().unwrap()).unwrap(); std::fs::create_dir_all(p.parent().unwrap()).unwrap();
@@ -3087,9 +3206,9 @@ mod tests {
let text = std::fs::read_to_string(&path).unwrap(); let text = std::fs::read_to_string(&path).unwrap();
// The property is declared nowhere along the chain. // The property is declared nowhere along the chain.
assert_eq!(goto_declaration(&text, &path, at(&text, "${missing.key}"), None), None); 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); 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_path = tmp.path().join("pom.xml");
let parent_text = std::fs::read_to_string(&parent_path).unwrap(); let parent_text = std::fs::read_to_string(&parent_path).unwrap();
assert_eq!( assert_eq!(
@@ -3101,6 +3220,72 @@ mod tests {
assert_eq!(goto_declaration("<project></project>", &path, Pos::new(0, 3), None), None); 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] #[test]
fn a_property_reference_after_non_ascii_text_is_still_found() { fn a_property_reference_after_non_ascii_text_is_still_found() {
let text = format!( let text = format!(
+24 -7
View File
@@ -393,11 +393,13 @@ fn scan_versions(dir: &Path, typed: &str, budget: &mut u32) -> Vec<PomCompletion
out out
} }
/// The repository directory of the `/`-separated `coordinates` (a group id, or /// The repository directory of `coordinates` (a group id, or a group and an artifact
/// a group and an artifact id), each segment validated against the layout's /// id): the group's dots are directories, an artifact id is one directory whatever dots
/// alphabet so POM text cannot walk outside `repo`. /// it has (`org/eclipse/platform/org.eclipse.core.runtime`). Each segment is validated
/// against the layout's alphabet so POM text cannot walk outside `repo`.
fn repo_dir(repo: &Path, coordinates: &[&str]) -> Option<PathBuf> { fn repo_dir(repo: &Path, coordinates: &[&str]) -> Option<PathBuf> {
let segments: Vec<&str> = coordinates.iter().flat_map(|c| c.split('.')).collect(); let (group, artifact) = coordinates.split_first()?;
let segments: Vec<&str> = group.split('.').chain(artifact.iter().copied()).collect();
segments.iter().all(|s| segment(s)).then(|| segments.iter().fold(repo.to_path_buf(), |p, s| p.join(s))) segments.iter().all(|s| segment(s)).then(|| segments.iter().fold(repo.to_path_buf(), |p, s| p.join(s)))
} }
@@ -460,6 +462,7 @@ mod tests {
"org/example/lib/2.0-beta-1/lib-2.0-beta-1.jar", "org/example/lib/2.0-beta-1/lib-2.0-beta-1.jar",
"org/example/lib/2.0/lib-2.0.pom", "org/example/lib/2.0/lib-2.0.pom",
"net/nu/netty/4.1.100/netty-4.1.100.jar", "net/nu/netty/4.1.100/netty-4.1.100.jar",
"org/eclipse/platform/org.eclipse.core.runtime/3.31.0/org.eclipse.core.runtime-3.31.0.jar",
// Failed downloads (a version typed while editing): tracking files only, not installed. // Failed downloads (a version typed while editing): tracking files only, not installed.
"org/example/lib/2/lib-2.jar.lastUpdated", "org/example/lib/2/lib-2.jar.lastUpdated",
"org/example/lib/3.0/lib-3.0.pom.lastUpdated", "org/example/lib/3.0/lib-3.0.pom.lastUpdated",
@@ -570,9 +573,10 @@ mod tests {
}; };
// Only directories holding artifacts are group ids: `net` and `org` // Only directories holding artifacts are group ids: `net` and `org`
// alone never receive an install, their deeper levels do. // alone never receive an install, their deeper levels do.
assert_eq!(options(""), ["net.nu", "org.apache.maven", "org.apache.maven.plugins", "org.example"]); let org = ["org.apache.maven", "org.apache.maven.plugins", "org.eclipse.platform", "org.example"];
assert_eq!(options("org"), ["org.apache.maven", "org.apache.maven.plugins", "org.example"]); assert_eq!(options(""), [&["net.nu"][..], &org].concat());
assert_eq!(options("org."), ["org.apache.maven", "org.apache.maven.plugins", "org.example"]); assert_eq!(options("org"), org);
assert_eq!(options("org."), org);
assert_eq!(options("org.spr"), Vec::<String>::new()); assert_eq!(options("org.spr"), Vec::<String>::new());
assert_eq!(options("net."), ["net.nu"]); assert_eq!(options("net."), ["net.nu"]);
} }
@@ -623,6 +627,19 @@ mod tests {
}; };
assert_eq!(options(""), ["2.0", "2.0-beta-1", "1.10", "1.9.1", "1.0"]); 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"]); assert_eq!(options("1."), ["1.10", "1.9.1", "1.0"]);
// An artifact id with dots is one directory.
let dotted = local_options(
&repo,
&PomCompletionTarget {
from: 0,
to: 0,
typed: "".into(),
kind: PomCompletionKind::Version,
group: Some("org.eclipse.platform".into()),
artifact: Some("org.eclipse.core.runtime".into()),
},
);
assert_eq!(dotted.into_iter().map(|o| o.label).collect::<Vec<_>>(), ["3.31.0"]);
} }
#[test] #[test]
+9
View File
@@ -270,6 +270,13 @@ fn run(
/// The first error lines of a failed build, for the tool window. /// The first error lines of a failed build, for the tool window.
pub(crate) fn error_summary(text: &str) -> String { pub(crate) fn error_summary(text: &str) -> String {
// The JVM's "could not find or load main class" (localized) when `gradlew` has no jar.
if text.contains("org.gradle.wrapper.GradleWrapperMain") {
return "gradle/wrapper/gradle-wrapper.jar is missing (often excluded by a `*.jar` rule in .gitignore). \
Restore it, run `gradle wrapper` with an installed Gradle, or choose Distribution: Local installation \
in Settings › Build, Execution, Deployment › Gradle."
.to_owned();
}
let lines: Vec<&str> = text let lines: Vec<&str> = text
.lines() .lines()
.filter(|l| { .filter(|l| {
@@ -909,6 +916,8 @@ mod tests {
let text = "[INFO] x\n[ERROR] Failed to execute goal\n[ERROR] \n[ERROR] Re-run Maven using -X\n"; let text = "[INFO] x\n[ERROR] Failed to execute goal\n[ERROR] \n[ERROR] Re-run Maven using -X\n";
assert_eq!(error_summary(text), "Failed to execute goal"); assert_eq!(error_summary(text), "Failed to execute goal");
assert_eq!(error_summary("one\nlast line\n\n"), "last line"); assert_eq!(error_summary("one\nlast line\n\n"), "last line");
let missing_jar = "错误: 找不到或无法加载主类 org.gradle.wrapper.GradleWrapperMain\n";
assert!(error_summary(missing_jar).starts_with("gradle/wrapper/gradle-wrapper.jar is missing"));
} }
#[test] #[test]
+15
View File
@@ -27,6 +27,13 @@ pub fn set_text(text: &str, owner: usize) -> std::io::Result<()> {
imp::set_text(text, owner) imp::set_text(text, owner)
} }
/// The clipboard's plain text, None when it holds none. macOS only (main thread);
/// Linux reads it asynchronously through GTK in the app, Windows through the WebView.
#[cfg(target_os = "macos")]
pub fn text() -> Option<String> {
imp::text()
}
/// Size of `DROPFILES`: `pFiles`, `pt` (two `LONG`s), `fNC`, `fWide`. /// Size of `DROPFILES`: `pFiles`, `pt` (two `LONG`s), `fNC`, `fWide`.
const HEADER: usize = 20; const HEADER: usize = 20;
@@ -373,6 +380,14 @@ mod imp {
}) })
} }
pub fn text() -> Option<String> {
debug_assert!(MainThreadMarker::new().is_some(), "NSPasteboard access requires the main thread");
autoreleasepool(|_| {
// SAFETY: the type is an AppKit constant, valid for the process' lifetime.
NSPasteboard::generalPasteboard().stringForType(unsafe { NSPasteboardTypeString }).map(|s| s.to_string())
})
}
pub fn set_text(text: &str, _owner: usize) -> std::io::Result<()> { pub fn set_text(text: &str, _owner: usize) -> std::io::Result<()> {
debug_assert!(MainThreadMarker::new().is_some(), "NSPasteboard access requires the main thread"); debug_assert!(MainThreadMarker::new().is_some(), "NSPasteboard access requires the main thread");
autoreleasepool(|_| { autoreleasepool(|_| {
+8
View File
@@ -8,13 +8,18 @@
use crate::{DebugSessionId, Diagnostic, DocId, RunId, ServerId, TaskId, TerminalId, TextChange}; use crate::{DebugSessionId, Diagnostic, DocId, RunId, ServerId, TaskId, TerminalId, TextChange};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use specta::Type; use specta::Type;
use std::collections::BTreeMap;
use std::sync::Arc; use std::sync::Arc;
/// What a built-in MCP tool asks the project window for. `AgentRun` runs `command` (a shell
/// line) in the Run tool window's agent tab `name`, on pipes, answered once the process
/// started or failed to.
#[derive(Clone, Debug, Serialize, Deserialize, Type)] #[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")] #[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
pub enum McpUiRequest { pub enum McpUiRequest {
Confirm { client: String, tool: String, subject: String, project: String, detail: Option<String> }, Confirm { client: String, tool: String, subject: String, project: String, detail: Option<String> },
OpenFile { path: crate::IpcPath, line: Option<u32>, column: Option<u32> }, OpenFile { path: crate::IpcPath, line: Option<u32>, column: Option<u32> },
AgentRun { name: String, command: String, cwd: crate::IpcPath, env: BTreeMap<String, String> },
} }
#[derive(Clone, Debug, Serialize, Deserialize, Type)] #[derive(Clone, Debug, Serialize, Deserialize, Type)]
@@ -72,6 +77,9 @@ pub enum IdeEvent {
WorkspaceEditApplied { WorkspaceEditApplied {
id: u32, id: u32,
paths: Vec<crate::IpcPath>, paths: Vec<crate::IpcPath>,
/// Made by an agent through the MCP server's tools: undoable like a refactoring, but
/// not announced as one.
by_agent: bool,
}, },
/// An open document's language changed (Settings › File Types, Override File /// An open document's language changed (Settings › File Types, Override File
/// Type), or its UI mode (a framework's root marker appeared or went). /// Type), or its UI mode (a framework's root marker appeared or went).
+52 -1
View File
@@ -76,7 +76,8 @@ fn decode_tool_output_with_code_page(bytes: &[u8], code_page: u32) -> String {
text text
} }
fn encoding_for_code_page(cp: u32) -> Option<&'static encoding_rs::Encoding> { /// The `encoding_rs` encoding of a Windows code page, when it has one.
pub fn encoding_for_code_page(cp: u32) -> Option<&'static encoding_rs::Encoding> {
Some(match cp { Some(match cp {
65001 => encoding_rs::UTF_8, 65001 => encoding_rs::UTF_8,
936 => encoding_rs::GBK, 936 => encoding_rs::GBK,
@@ -101,6 +102,56 @@ fn encoding_for_code_page(cp: u32) -> Option<&'static encoding_rs::Encoding> {
}) })
} }
/// The code page Windows console programs write to a pipe in: the OEM code page (GBK on a
/// Chinese system, Shift_JIS on a Japanese one, EUC-KR on a Korean one, 437 / 850 / 866…
/// elsewhere). `None` when it is UTF-8 (Windows' "Use Unicode UTF-8" option) or off Windows.
pub fn console_code_page() -> Option<u32> {
#[cfg(windows)]
{
// SAFETY: GetOEMCP is an argument-less query with no preconditions.
let cp = unsafe { windows_sys::Win32::Globalization::GetOEMCP() };
(cp != 65001).then_some(cp)
}
#[cfg(not(windows))]
{
None
}
}
/// Whether every character of code page `cp` is one byte, so text in it can be decoded
/// piece by piece.
#[cfg(windows)]
pub fn is_single_byte_code_page(cp: u32) -> bool {
use windows_sys::Win32::Globalization::{CPINFO, GetCPInfo};
// SAFETY: CPINFO is plain data, which GetCPInfo fills in.
unsafe {
let mut info: CPINFO = std::mem::zeroed();
GetCPInfo(cp, &mut info) != 0 && info.MaxCharSize == 1
}
}
/// `bytes` in code page `cp`, decoded by Windows (for the code pages `encoding_rs` lacks,
/// like the OEM pages 437, 850 or 852).
#[cfg(windows)]
pub fn decode_code_page(bytes: &[u8], cp: u32) -> String {
use windows_sys::Win32::Globalization::MultiByteToWideChar;
let Ok(len) = i32::try_from(bytes.len()) else {
return String::from_utf8_lossy(bytes).into_owned();
};
if len == 0 {
return String::new();
}
// SAFETY: `bytes` holds `len` bytes; no output buffer asks for the length needed.
let needed = unsafe { MultiByteToWideChar(cp, 0, bytes.as_ptr(), len, std::ptr::null_mut(), 0) };
if needed <= 0 {
return String::from_utf8_lossy(bytes).into_owned();
}
let mut wide = vec![0u16; needed as usize];
// SAFETY: `wide` holds `needed` units.
let written = unsafe { MultiByteToWideChar(cp, 0, bytes.as_ptr(), len, wide.as_mut_ptr(), needed) };
String::from_utf16_lossy(&wide[..written.max(0) as usize])
}
const STD_PROPERTIES: &[&str] = &["stdout.encoding", "stderr.encoding", "sun.stdout.encoding", "sun.stderr.encoding"]; const STD_PROPERTIES: &[&str] = &["stdout.encoding", "stderr.encoding", "sun.stdout.encoding", "sun.stderr.encoding"];
const FILE_ENCODING: &str = "file.encoding"; const FILE_ENCODING: &str = "file.encoding";
const TOOL_OPTIONS: &str = "JAVA_TOOL_OPTIONS"; const TOOL_OPTIONS: &str = "JAVA_TOOL_OPTIONS";
+30 -1
View File
@@ -288,7 +288,7 @@ pub enum NewTaskApprovals {
} }
/// Agents › MCP Server: agents use the IDE's built-in tools. /// Agents › MCP Server: agents use the IDE's built-in tools.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")] #[serde(default, rename_all = "camelCase")]
pub struct McpServerSettings { pub struct McpServerSettings {
/// Serve the tools on 127.0.0.1 while the IDE runs. /// Serve the tools on 127.0.0.1 while the IDE runs.
@@ -296,6 +296,35 @@ pub struct McpServerSettings {
/// Run the tools that change the project or start processes without asking first /// Run the tools that change the project or start processes without asking first
/// (IDEA's brave mode). Read from the IDE's own settings only, never a project's. /// (IDEA's brave mode). Read from the IDE's own settings only, never a project's.
pub run_without_confirmation: bool, pub run_without_confirmation: bool,
/// Finished agent tabs (`run_command`) the Run tool window shows; older ones are archived.
pub agent_tab_limit: u32,
/// Archived agent tabs kept; older ones are closed.
pub agent_archive_limit: u32,
/// Listen on [`Self::port`] in every run, rather than a free port each time, so HTTP clients and
/// port forwards keep working after a restart.
pub fixed_port: bool,
/// The port of [`Self::fixed_port`].
pub port: u16,
/// Keep the bearer token across runs (in the app data, readable by this user only) rather than
/// make a new one each run.
pub fixed_token: bool,
}
/// The default of [`McpServerSettings::port`].
pub const DEFAULT_MCP_PORT: u16 = 26371;
impl Default for McpServerSettings {
fn default() -> Self {
Self {
enabled: false,
run_without_confirmation: false,
agent_tab_limit: 1,
agent_archive_limit: 999,
fixed_port: true,
port: DEFAULT_MCP_PORT,
fixed_token: false,
}
}
} }
/// Absent profiles retain automatic discovery for existing projects. /// Absent profiles retain automatic discovery for existing projects.
+1 -1
View File
@@ -753,7 +753,7 @@ impl Pool {
return crate::oracle::OraclePool::connect(source, password, &setup).await.map(Pool::Oracle); return crate::oracle::OraclePool::connect(source, password, &setup).await.map(Pool::Oracle);
} }
if source.driver == DbDriver::Snowflake { if source.driver == DbDriver::Snowflake {
return crate::snowflake::SnowflakePool::connect(source, password, setup).await.map(Pool::Snowflake); return crate::snowflake::SnowflakePool::connect(source, password, setup, root).await.map(Pool::Snowflake);
} }
let timeout = setup.connect_timeout.unwrap_or(Duration::from_secs(30)); let timeout = setup.connect_timeout.unwrap_or(Duration::from_secs(30));
let init = Arc::new(setup.statements); let init = Arc::new(setup.statements);
+12 -9
View File
@@ -1491,18 +1491,21 @@ impl DbManager {
let Some(session) = session else { return Ok(()) }; let Some(session) = session else { return Ok(()) };
let mut held = session.lock().await; let mut held = session.lock().await;
let Some(mut conn) = held.take() else { return Ok(()) }; let Some(mut conn) = held.take() else { return Ok(()) };
let result = async { if let Err(e) = conn.command(if commit { "COMMIT" } else { "ROLLBACK" }).await {
conn.command(if commit { "COMMIT" } else { "ROLLBACK" }).await?; // Discarding a Snowflake connection doesn't end its session, which the data source's
conn.restore_default().await // other targets share: the transaction stays open, so it stays held to be ended again.
} if matches!(conn, conn::Held::Snowflake(..)) {
.await; *held = Some(conn);
match result { } else {
Ok(()) => Ok(()),
Err(e) => {
conn.discard().await; conn.discard().await;
Err(e)
} }
return Err(e);
} }
if let Err(e) = conn.restore_default().await {
conn.discard().await;
return Err(e);
}
Ok(())
} }
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
+94 -24
View File
@@ -78,7 +78,8 @@ pub(crate) fn sso_seed(key: &str, token: &str) {
} }
} }
/// Live sessions by their connection settings, so targets opened with the same settings share one. /// Live sessions by data source and connection settings, so the targets of one data source (its
/// databases, the tree, consoles) opened with the same settings share one.
static SESSIONS: LazyLock<parking_lot::Mutex<HashMap<String, Weak<State>>>> = static SESSIONS: LazyLock<parking_lot::Mutex<HashMap<String, Weak<State>>>> =
LazyLock::new(|| parking_lot::Mutex::new(HashMap::new())); LazyLock::new(|| parking_lot::Mutex::new(HashMap::new()));
@@ -106,6 +107,15 @@ struct Lane {
context: parking_lot::Mutex<Option<String>>, context: parking_lot::Mutex<Option<String>>,
} }
/// What [`SnowflakePool::statement`] does when the session is gone and it signs in again.
#[derive(Clone, Copy)]
enum Lost<'a> {
/// Runs the statement again on the new session, after this `USE` when there is one.
Enter(Option<&'a str>),
/// Fails: the statement was part of a transaction that ended with the session.
Fail,
}
const CONSOLE: usize = 0; const CONSOLE: usize = 0;
const METADATA: usize = 1; const METADATA: usize = 1;
@@ -251,7 +261,12 @@ fn gunzip(bytes: Vec<u8>) -> IdeResult<Vec<u8>> {
} }
impl SnowflakePool { impl SnowflakePool {
pub(crate) async fn connect(source: &DataSource, secret: Option<&str>, setup: Setup) -> IdeResult<Self> { pub(crate) async fn connect(
source: &DataSource,
secret: Option<&str>,
setup: Setup,
root: Option<&std::path::Path>,
) -> IdeResult<Self> {
let settings = source.snowflake(); let settings = source.snowflake();
let endpoint = account::endpoint(source)?; let endpoint = account::endpoint(source)?;
let user = source.user.trim(); let user = source.user.trim();
@@ -302,9 +317,15 @@ impl SnowflakePool {
lane: CONSOLE, lane: CONSOLE,
}; };
// Everything that makes a session what it is, the secret included (hashed: the key is only compared). // Everything that makes a session what it is, the secret included (hashed: the key is only compared).
// The data source and its project too: its databases share a session, but another data source
// with the same settings, or the same one in another project (a plugin's source has the same id
// in every project), gets its own: its statements and transactions are not this one's. A browser
// sign-in is still shared through the cached ID token.
let key = blake3::hash( let key = blake3::hash(
format!( format!(
"{}\0{user}\0{:?}\0{}\0{}\0{}\0{}\0{:?}\0{:?}", "{:?}\0{}\0{}\0{user}\0{:?}\0{}\0{}\0{}\0{}\0{:?}\0{:?}",
root,
source.id,
api.address(), api.address(),
settings.auth, settings.auth,
secret.unwrap_or_default(), secret.unwrap_or_default(),
@@ -636,13 +657,18 @@ impl SnowflakePool {
() = self.closed.cancelled() => return Err(closed()), () = self.closed.cancelled() => return Err(closed()),
turn = self.lane().turn.lock() => turn, turn = self.lane().turn.lock() => turn,
}; };
// A `USE` needs no schema to run in, and may be what makes the schema reachable. // A `USE` needs no schema to run in, and may be what makes the schema reachable. Ending a
if first_word(sql) != "USE" { // transaction needs none either, and must not fail because the schema is gone meanwhile.
self.enter(schema, cancel).await?; let context = match first_word(sql).as_str() {
} "USE" | "COMMIT" | "ROLLBACK" => None,
_ => {
self.enter(schema, cancel).await?;
self.context(schema)
}
};
// These can move the session to another database or schema. // These can move the session to another database or schema.
let moves = matches!(first_word(sql).as_str(), "USE" | "CALL" | "EXECUTE"); let moves = matches!(first_word(sql).as_str(), "USE" | "CALL" | "EXECUTE");
let result = self.statement(sql, skip, max_rows, cancel).await; let result = self.statement(sql, skip, max_rows, Lost::Enter(context.as_deref()), cancel).await;
if moves { if moves {
*self.lane().context.lock() = None; *self.lane().context.lock() = None;
} }
@@ -655,16 +681,14 @@ impl SnowflakePool {
&& self.lane().context.lock().as_deref() != Some(wanted.as_str()) && self.lane().context.lock().as_deref() != Some(wanted.as_str())
{ {
*self.lane().context.lock() = None; *self.lane().context.lock() = None;
match self.statement(&wanted, 0, 0, cancel).await { // Refused (the session's role may not use this database or schema): the statement
Ok(_) => {} // does not run, since the session is still wherever it was, which may be another
// The session's role may not use this database or schema (after `USE ROLE`, or a // target's database. Tried again with the next statement.
// schema that is gone). The statement still runs, in the session's own context: if let Err(error) = self.statement(&wanted, 0, 0, Lost::Enter(None), cancel).await {
// it may be the `USE` that leads back, or name its objects in full. Asked again return Err(IdeError {
// once a statement moves the session. message: format!("{wanted} failed, so the statement was not run: {}", error.message),
Err(refused) if refused.kind == ErrorKind::InvalidInput => { ..error
tracing::warn!("Snowflake: the session did not enter its database and schema"); });
}
Err(error) => return Err(error),
} }
*self.lane().context.lock() = Some(wanted); *self.lane().context.lock() = Some(wanted);
} }
@@ -680,11 +704,11 @@ impl SnowflakePool {
let _turn = self.lane().turn.lock().await; let _turn = self.lane().turn.lock().await;
let cancel = CancellationToken::new(); let cancel = CancellationToken::new();
self.enter(None, &cancel).await?; self.enter(None, &cancel).await?;
self.statement("BEGIN", 0, 0, &cancel).await?; self.statement("BEGIN", 0, 0, Lost::Enter(self.context(None).as_deref()), &cancel).await?;
let changes = async { let changes = async {
let mut total = 0; let mut total = 0;
for (sql, expect_one) in statements { for (sql, expect_one) in statements {
let changed = self.statement(sql, 0, 0, &cancel).await?.affected.unwrap_or(0.0) as u64; let changed = self.statement(sql, 0, 0, Lost::Fail, &cancel).await?.affected.unwrap_or(0.0) as u64;
if *expect_one && changed != 1 { if *expect_one && changed != 1 {
return Err(IdeError::invalid(format!( return Err(IdeError::invalid(format!(
"{sql}: expected to change 1 row, changed {changed}; nothing was saved" "{sql}: expected to change 1 row, changed {changed}; nothing was saved"
@@ -697,22 +721,24 @@ impl SnowflakePool {
.await; .await;
match changes { match changes {
Ok(total) => { Ok(total) => {
self.statement("COMMIT", 0, 0, &cancel).await?; self.statement("COMMIT", 0, 0, Lost::Fail, &cancel).await?;
Ok(total) Ok(total)
} }
Err(error) => { Err(error) => {
let _ = self.statement("ROLLBACK", 0, 0, &cancel).await; let _ = self.statement("ROLLBACK", 0, 0, Lost::Fail, &cancel).await;
Err(error) Err(error)
} }
} }
} }
/// One statement on the session; the caller holds the session's turn. /// One statement on the session; the caller holds the session's turn. `lost`: what to do when
/// the session is gone and a new one is signed in to.
async fn statement( async fn statement(
&self, &self,
sql: &str, sql: &str,
skip: usize, skip: usize,
max_rows: usize, max_rows: usize,
lost: Lost<'_>,
cancel: &CancellationToken, cancel: &CancellationToken,
) -> IdeResult<QueryResult> { ) -> IdeResult<QueryResult> {
let started = Instant::now(); let started = Instant::now();
@@ -743,7 +769,24 @@ impl SnowflakePool {
Ok(Outcome::Done(result)) => return Ok(*result), Ok(Outcome::Done(result)) => return Ok(*result),
Ok(Outcome::Expired { gone }) if !renewed => { Ok(Outcome::Expired { gone }) if !renewed => {
renewed = true; renewed = true;
tokens = if gone { self.login().await? } else { self.renew(&tokens).await? }; if !gone {
tokens = self.renew(&tokens).await?;
continue;
}
if let Lost::Fail = lost {
// The transaction ended with the session: nothing of it was saved.
let _ = self.login().await;
return Err(IdeError::new(
ErrorKind::Io,
"Snowflake: the session was lost during the transaction; nothing was saved",
));
}
// The new session is under the login's database and schema, which may be another
// target's: back to this one's before the statement runs again.
tokens = self.login().await?;
if let Lost::Enter(Some(wanted)) = lost {
self.reenter(wanted, &tokens, cancel).await?;
}
} }
Ok(Outcome::Expired { .. }) => { Ok(Outcome::Expired { .. }) => {
return Err(IdeError::new(ErrorKind::Io, "Snowflake: the session expired; reconnect")); return Err(IdeError::new(ErrorKind::Io, "Snowflake: the session expired; reconnect"));
@@ -753,6 +796,33 @@ impl SnowflakePool {
} }
} }
/// [`enter`](Self::enter) on a session just signed in to (its `tokens`), within [`statement`](Self::statement).
async fn reenter(&self, wanted: &str, tokens: &Tokens, cancel: &CancellationToken) -> IdeResult<()> {
let request_id = uuid();
let outcome = tokio::select! {
biased;
() = cancel.cancelled() => return Err(IdeError::cancelled()),
() = self.closed.cancelled() => return Err(closed()),
outcome = self.run(wanted, 0, 0, tokens, &request_id, Instant::now()) => outcome,
};
match outcome {
Ok(Outcome::Done(_)) => {
*self.lane().context.lock() = Some(wanted.to_owned());
Ok(())
}
Ok(Outcome::Expired { .. }) => {
Err(IdeError::new(ErrorKind::Io, "Snowflake: the session expired; reconnect"))
}
Err(error) => {
let error = self.shared.api.scrub.clean(error);
Err(IdeError {
message: format!("{wanted} failed, so the statement was not run: {}", error.message),
..error
})
}
}
}
async fn run( async fn run(
&self, &self,
sql: &str, sql: &str,
+106 -16
View File
@@ -407,13 +407,64 @@ async fn a_lost_session_signs_in_again() {
let (listener, port) = bind().await; let (listener, port) = bind().await;
let gone = r#"{"success":false,"code":"390114","message":"Authentication token has expired. The user must authenticate again."}"#; let gone = r#"{"success":false,"code":"390114","message":"Authentication token has expired. The user must authenticate again."}"#;
let again = r#"{"success":true,"data":{"token":"session-token-3","masterToken":"master-token-3"}}"#; let again = r#"{"success":true,"data":{"token":"session-token-3","masterToken":"master-token-3"}}"#;
let server = serve(listener, vec![ok(LOGIN_OK), ok(gone), ok(again), ok(one()), ok(CLOSED_OK)]); let status = json!({ "success": true, "data": { "rowtype": [{ "name": "status", "type": "text" }],
"rowset": [["Statement executed successfully."]], "queryResultFormat": "json", "statementTypeId": 0x5000 } })
.to_string();
let server = serve(listener, vec![ok(LOGIN_OK), ok(gone), ok(again), ok(status), ok(one()), ok(CLOSED_OK)]);
let pool = Pool::connect(&source(port), None, None).await.unwrap(); let pool = Pool::connect(&source(port), None, None).await.unwrap();
pool.execute("select 1", 5, &CancellationToken::new()).await.unwrap(); pool.execute("select 1", 5, &CancellationToken::new()).await.unwrap();
pool.close().await; pool.close().await;
let seen = server.await.unwrap(); let seen = server.await.unwrap();
assert!(seen[2].line().starts_with("POST /session/v1/login-request?"), "{}", seen[2].line()); assert!(seen[2].line().starts_with("POST /session/v1/login-request?"), "{}", seen[2].line());
assert_eq!(seen[3].header("authorization"), Some("Snowflake Token=\"session-token-3\"")); // The new session enters the target's schema before the statement runs again.
assert_eq!(seen[3].json()["sqlText"], "USE SCHEMA \"ANALYTICS\".\"PUBLIC\"");
assert_eq!(seen[4].json()["sqlText"], "select 1");
assert_eq!(seen[4].header("authorization"), Some("Snowflake Token=\"session-token-3\""));
}
#[tokio::test]
async fn a_lost_session_runs_the_statement_in_its_own_database_again() {
let (listener, port) = bind().await;
let gone = r#"{"success":false,"code":"390114","message":"Authentication token has expired. The user must authenticate again."}"#;
let again = r#"{"success":true,"data":{"token":"session-token-3","masterToken":"master-token-3"}}"#;
let status = || {
json!({ "success": true, "data": { "rowtype": [{ "name": "status", "type": "text" }],
"rowset": [["Statement executed successfully."]], "queryResultFormat": "json", "statementTypeId": 0x5000 } })
.to_string()
};
// Raw Data entered, the statement finds the session gone, a new login (under ANALYTICS),
// Raw Data entered again, the statement.
let replies = vec![ok(LOGIN_OK), ok(status()), ok(gone), ok(again), ok(status()), ok(one()), ok(CLOSED_OK)];
let server = serve(listener, replies);
let first = Pool::connect(&source(port), None, None).await.unwrap();
let mut other = source(port);
other.database = "Raw Data".into();
other.options.default_schema = String::new();
let second = Pool::connect(&other, None, None).await.unwrap();
second.execute("delete from t", 1, &CancellationToken::new()).await.unwrap();
first.close().await;
second.close().await;
let sent: Vec<String> =
server.await.unwrap()[1..6].iter().map(|r| r.json()["sqlText"].as_str().unwrap_or("").to_owned()).collect();
let raw = "USE DATABASE \"Raw Data\"";
assert_eq!(sent, [raw, "delete from t", "", raw, "delete from t"]);
}
#[tokio::test]
async fn a_transaction_ends_even_when_its_schema_is_gone() {
let (listener, port) = bind().await;
let status = json!({ "success": true, "data": { "rowtype": [{ "name": "status", "type": "text" }],
"rowset": [["Statement executed successfully."]], "queryResultFormat": "json", "statementTypeId": 0x5000 } })
.to_string();
let server = serve(listener, vec![ok(LOGIN_OK), ok(status), ok(CLOSED_OK)]);
let pool = Pool::connect(&source(port), None, None).await.unwrap();
// The console's schema was dropped since it was picked: ending the transaction doesn't switch to it.
let mut console = crate::conn::Held::acquire(&pool).await.unwrap();
console.apply_context(Some("Dropped")).await.unwrap();
console.execute(&pool, "ROLLBACK", 0, &CancellationToken::new()).await.unwrap();
pool.close().await;
let seen = server.await.unwrap();
assert_eq!(seen[1].json()["sqlText"], "ROLLBACK");
} }
#[tokio::test] #[tokio::test]
@@ -714,7 +765,7 @@ async fn a_console_schema_is_switched_with_its_statement_and_only_when_needed()
} }
#[tokio::test] #[tokio::test]
async fn a_role_that_may_not_use_the_schema_still_runs_statements() { async fn a_role_that_may_not_use_the_schema_runs_nothing_elsewhere() {
let status = || { let status = || {
json!({ "success": true, "data": { "rowtype": [{ "name": "status", "type": "text" }], json!({ "success": true, "data": { "rowtype": [{ "name": "status", "type": "text" }],
"rowset": [["Statement executed successfully."]], "queryResultFormat": "json", "statementTypeId": 0x5000 } }) "rowset": [["Statement executed successfully."]], "queryResultFormat": "json", "statementTypeId": 0x5000 } })
@@ -722,14 +773,12 @@ async fn a_role_that_may_not_use_the_schema_still_runs_statements() {
}; };
let refused = r#"{"success":false,"code":"002043","message":"SQL compilation error:\nObject does not exist, or operation cannot be performed."}"#; let refused = r#"{"success":false,"code":"002043","message":"SQL compilation error:\nObject does not exist, or operation cannot be performed."}"#;
let (listener, port) = bind().await; let (listener, port) = bind().await;
// use role | the schema is refused once, the statement runs | the next one does not ask again | // use role | the schema is refused, the statement is not sent | asked again, refused again |
// a role that is not granted | use role back, the schema is entered again // use role back, the schema is entered, the statement runs
let replies = vec![ let replies = vec![
ok(LOGIN_OK), ok(LOGIN_OK),
ok(status()), ok(status()),
ok(refused), ok(refused),
ok(one()),
ok(one()),
ok(refused), ok(refused),
ok(status()), ok(status()),
ok(status()), ok(status()),
@@ -740,20 +789,22 @@ async fn a_role_that_may_not_use_the_schema_still_runs_statements() {
let pool = Pool::connect(&source(port), None, None).await.unwrap(); let pool = Pool::connect(&source(port), None, None).await.unwrap();
let cancel = CancellationToken::new(); let cancel = CancellationToken::new();
pool.execute("use role auditor", 0, &cancel).await.unwrap(); pool.execute("use role auditor", 0, &cancel).await.unwrap();
pool.execute("select 1", 1, &cancel).await.unwrap(); let error = pool.execute("delete from t", 1, &cancel).await.unwrap_err();
pool.execute("select 2", 1, &cancel).await.unwrap(); assert!(
let missing = pool.execute("use role nobody", 0, &cancel).await.unwrap_err(); error.message.contains("USE SCHEMA")
assert!(missing.message.contains("002043") && missing.message.contains("granted"), "{}", missing.message); && error.message.contains("was not run")
&& error.message.contains("002043"),
"{}",
error.message
);
assert!(pool.execute("select 2", 1, &cancel).await.is_err());
pool.execute("use role analyst", 0, &cancel).await.unwrap(); pool.execute("use role analyst", 0, &cancel).await.unwrap();
pool.execute("select 3", 1, &cancel).await.unwrap(); pool.execute("select 3", 1, &cancel).await.unwrap();
pool.close().await; pool.close().await;
let sent: Vec<String> = let sent: Vec<String> =
server.await.unwrap()[1..9].iter().map(|r| r.json()["sqlText"].as_str().unwrap().to_owned()).collect(); server.await.unwrap()[1..7].iter().map(|r| r.json()["sqlText"].as_str().unwrap().to_owned()).collect();
let schema = "USE SCHEMA \"ANALYTICS\".\"PUBLIC\""; let schema = "USE SCHEMA \"ANALYTICS\".\"PUBLIC\"";
assert_eq!( assert_eq!(sent, ["use role auditor", schema, schema, "use role analyst", schema, "select 3"]);
sent,
["use role auditor", schema, "select 1", "select 2", "use role nobody", "use role analyst", schema, "select 3"]
);
} }
#[tokio::test] #[tokio::test]
@@ -785,6 +836,45 @@ async fn databases_of_one_data_source_share_a_login() {
assert!(seen[4].line().starts_with("POST /session?delete=true"), "{}", seen[4].line()); assert!(seen[4].line().starts_with("POST /session?delete=true"), "{}", seen[4].line());
} }
#[tokio::test]
async fn data_sources_with_the_same_settings_have_their_own_sessions() {
let (listener, port) = bind().await;
let second = r#"{"success":true,"data":{"token":"session-token-2","masterToken":"master-token-2"}}"#;
let server = serve(listener, vec![ok(LOGIN_OK), ok(second), ok(one()), ok(one()), ok(CLOSED_OK), ok(CLOSED_OK)]);
let first = Pool::connect(&source(port), None, None).await.unwrap();
let mut copy = source(port);
copy.id = format!("{}-copy", copy.id);
let other = Pool::connect(&copy, None, None).await.unwrap();
let cancel = CancellationToken::new();
first.execute("select 1", 1, &cancel).await.unwrap();
other.execute("select 2", 1, &cancel).await.unwrap();
first.close().await;
other.close().await;
let seen = server.await.unwrap();
assert!(seen[1].line().starts_with("POST /session/v1/login-request?"), "{}", seen[1].line());
assert_eq!(seen[2].header("authorization"), Some("Snowflake Token=\"session-token-1\""));
assert_eq!(seen[3].header("authorization"), Some("Snowflake Token=\"session-token-2\""));
}
#[tokio::test]
async fn one_data_source_in_two_projects_has_a_session_in_each() {
let (listener, port) = bind().await;
let second = r#"{"success":true,"data":{"token":"session-token-2","masterToken":"master-token-2"}}"#;
let server = serve(listener, vec![ok(LOGIN_OK), ok(second), ok(one()), ok(one()), ok(CLOSED_OK), ok(CLOSED_OK)]);
// A plugin's data source has the same id in every project.
let first = Pool::connect(&source(port), None, Some(std::path::Path::new("/projects/a"))).await.unwrap();
let other = Pool::connect(&source(port), None, Some(std::path::Path::new("/projects/b"))).await.unwrap();
let cancel = CancellationToken::new();
first.execute("select 1", 1, &cancel).await.unwrap();
other.execute("select 2", 1, &cancel).await.unwrap();
first.close().await;
other.close().await;
let seen = server.await.unwrap();
assert!(seen[1].line().starts_with("POST /session/v1/login-request?"), "{}", seen[1].line());
assert_eq!(seen[2].header("authorization"), Some("Snowflake Token=\"session-token-1\""));
assert_eq!(seen[3].header("authorization"), Some("Snowflake Token=\"session-token-2\""));
}
#[tokio::test] #[tokio::test]
async fn metadata_runs_on_its_own_session_beside_a_busy_console() { async fn metadata_runs_on_its_own_session_beside_a_busy_console() {
let (listener, port) = bind().await; let (listener, port) = bind().await;
+1 -1
View File
@@ -34,7 +34,7 @@ use std::path::{Component, Path, PathBuf};
pub const MANIFEST_FILE: &str = "plugin.toml"; pub const MANIFEST_FILE: &str = "plugin.toml";
/// The plugin API (engine) version this host implements. /// 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. /// The 0.1 API, still accepted for contribution-only packages and 0.1 components.
pub const LEGACY_API: &str = "0.1"; pub const LEGACY_API: &str = "0.1";
/// Marketplace categories, in the order they are shown. /// Marketplace categories, in the order they are shown.
+4
View File
@@ -60,6 +60,8 @@ pub struct Host {
plugin_host: RwLock<std::sync::Weak<plugins::PluginHost>>, plugin_host: RwLock<std::sync::Weak<plugins::PluginHost>>,
pub mcp_reports: mcp::Reports, pub mcp_reports: mcp::Reports,
pub mcp_requests: mcp::Requests, pub mcp_requests: mcp::Requests,
/// Commands agents run in the Run tool window (`run_command`).
pub mcp_agent_runs: mcp::AgentRuns,
pub trust: trust::TrustStore, pub trust: trust::TrustStore,
pub(crate) mcp_approvals: Arc<trust::McpServerApprovals>, pub(crate) mcp_approvals: Arc<trust::McpServerApprovals>,
pub config_dir: PathBuf, pub config_dir: PathBuf,
@@ -141,6 +143,7 @@ impl Host {
mcp_executable: RwLock::default(), mcp_executable: RwLock::default(),
mcp_reports: Default::default(), mcp_reports: Default::default(),
mcp_requests: Default::default(), mcp_requests: Default::default(),
mcp_agent_runs: Default::default(),
trust: trust::TrustStore::load(&config_dir, &data_dir), trust: trust::TrustStore::load(&config_dir, &data_dir),
mcp_approvals: Arc::new(trust::McpServerApprovals::load(&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_password: ide_ssh::secrets::SecretFile::new(data_dir.join("proxy-password.json")),
@@ -269,6 +272,7 @@ impl Host {
fn begin_close(&self, window: &str) -> Option<std::thread::JoinHandle<()>> { fn begin_close(&self, window: &str) -> Option<std::thread::JoinHandle<()>> {
self.mcp_reports.forget_window(window); self.mcp_reports.forget_window(window);
self.mcp_requests.forget_window(window); self.mcp_requests.forget_window(window);
self.mcp_agent_runs.forget_window(window);
self.external_logs.write().remove(self.owner_window(window)); self.external_logs.write().remove(self.owner_window(window));
for request in self.project_requests.write().remove(self.owner_window(window)).unwrap_or_default() { for request in self.project_requests.write().remove(self.owner_window(window)).unwrap_or_default() {
self.cancel_request(request); self.cancel_request(request);
+176
View File
@@ -0,0 +1,176 @@
//! `check_files`: fresh diagnostics from the language servers right after an edit, and why a
//! file could not be checked (no server for it, not installed, crashed, misconfigured…).
use super::editors::problem;
use crate::mcp::run::timeout;
use crate::mcp::tool::{Call, Output, Param, ParamType, Tool};
use ide_core::{IdeError, IdeResult, Severity};
use ide_lsp::DocumentCheck;
use serde_json::{Value, json};
use std::path::PathBuf;
use std::time::{Duration, Instant};
const DEFAULT_TIMEOUT_MS: usize = 30_000;
const MAX_FILES: usize = 20;
pub fn check_files() -> Tool {
Tool::new(
"check_files",
&format!(
"Checks files with the language servers now and returns their problems: call it after changing files \
(with any tool, inside or outside the IDE). Each file is synced from disk and saved to its servers, so checks that \
run on save (rust-analyzer's cargo check) run too; then the call waits until every server answered for the current \
text and finished the work it started, up to `timeout` milliseconds (default {DEFAULT_TIMEOUT_MS}) for all files. \
Unlike get_file_problems, this is a fresh analysis, before the inspection profile's filtering.
Per file, `status` says what the problems are worth:
- checked: every server answered; `problems` is complete (empty means no problems).
- incomplete: some server was still analysing when the wait ended, or could not check; call again or raise `timeout`.
- serverUnavailable: no server could check. `servers` gives each one's `status` (missing: not installed; \
misconfigured; crashed; starting: did not finish starting) and `message`; tell the user, since the IDE has to be \
fixed (Settings › Languages & Frameworks › Language Servers), and check another way meanwhile (a build or a compiler).
- noLanguageServer: no language server is configured for this kind of file.
- untrusted: language servers don't run in an untrusted project.
- failed: the file could not be opened (`error`).
`unsavedInEditor` is true when the file has unsaved changes in the IDE: the problems are for the editor's text, not \
the file on disk. Lines and UTF-16 columns are 1-based."
),
|call| async move {
let raw = call.str_list("filePaths")?;
if raw.is_empty() {
return Err(IdeError::invalid("filePaths must name at least one file"));
}
if raw.len() > MAX_FILES {
return Err(IdeError::invalid(format!("at most {MAX_FILES} files at a time")));
}
let errors_only = call.flag("errorsOnly", true)?;
let limit = timeout(&call, DEFAULT_TIMEOUT_MS)?;
let mut paths = Vec::new();
for p in raw {
paths.push(call.resolve_readable(&p).await?);
}
let checks = paths.into_iter().map(|path| check_one(&call, path, limit, errors_only));
let files = futures::future::join_all(checks).await;
Output::json(json!({ "files": files }))
},
)
.param(Param::required("filePaths", ParamType::StringList, "Files to check, relative to the project root"))
.param(Param::optional(
"errorsOnly",
ParamType::Boolean,
"Whether to return only errors (default) or warnings and hints too",
))
.param(Param::optional("timeout", ParamType::Integer, "Milliseconds to wait for the servers at most"))
}
async fn check_one(call: &Call, path: PathBuf, limit: Duration, errors_only: bool) -> Value {
let mut out = json!({ "filePath": call.display(&path) });
if !call.project().is_trusted() {
out["status"] = "untrusted".into();
return out;
}
match check(call, &path, limit).await {
Ok((check, dirty, text)) => {
let servers: Vec<Value> = check
.servers
.iter()
.map(|s| {
let mut v = json!({ "name": s.name, "status": s.status, "checked": s.checked });
if let Some(m) = &s.message {
v["message"] = m.clone().into();
}
if s.incomplete {
v["incomplete"] = true.into();
}
if let Some(e) = &s.error {
v["error"] = e.clone().into();
}
v
})
.collect();
out["status"] = file_status(&check).into();
out["problems"] = check
.diagnostics
.into_iter()
.filter(|d| !errors_only || d.severity == Severity::Error)
.map(|d| problem(d, &text))
.collect::<Vec<_>>()
.into();
out["servers"] = servers.into();
if dirty {
out["unsavedInEditor"] = true.into();
}
}
Err(e) => {
out["status"] = "failed".into();
out["error"] = e.message.into();
}
}
out
}
fn file_status(check: &DocumentCheck) -> &'static str {
if check.servers.is_empty() {
"noLanguageServer"
} else if !check.servers.iter().any(|s| s.checked) {
"serverUnavailable"
} else if check.servers.iter().all(|s| s.checked && !s.incomplete) {
"checked"
} else {
"incomplete"
}
}
/// The check of `path`, whether it has unsaved changes in the IDE, and its text.
async fn check(call: &Call, path: &std::path::Path, limit: Duration) -> IdeResult<(DocumentCheck, bool, String)> {
let project = call.project().clone();
let docs = project.documents.clone();
let p = path.to_owned();
let at = Instant::now();
// Synced now: the file watcher's reload of a change made outside the IDE may not have run.
let (doc, changed, dirty) = call
.host()
.blocking(move || {
let before = docs.by_path(&p).and_then(|d| docs.version(d).ok().map(|v| (d, v)));
docs.on_disk_changed(std::slice::from_ref(&p));
let doc = docs.open(&p)?.id;
let changed = before.is_none_or(|(d, v)| d != doc || docs.version(d).is_ok_and(|now| now != v));
Ok((doc, changed, docs.is_dirty(doc).unwrap_or(false)))
})
.await?;
let result = project.lsp.check_document(doc, changed.then_some(at), limit, &call.cancel).await;
let text = project.documents.text_snapshot(doc).map(|t| t.text).unwrap_or_default();
let docs = project.documents.clone();
let _ = call
.host()
.blocking(move || {
docs.close(doc);
Ok(())
})
.await;
Ok((result?, dirty, text))
}
#[cfg(test)]
mod tests {
use super::*;
use ide_core::event::ServerStatus;
use ide_lsp::CheckedServer;
fn server(status: ServerStatus, checked: bool, incomplete: bool) -> CheckedServer {
CheckedServer { name: "s".into(), status, message: None, checked, incomplete, error: None }
}
#[test]
fn statuses_say_whether_the_problems_are_complete() {
let check = |servers| file_status(&DocumentCheck { diagnostics: Vec::new(), servers });
assert_eq!(check(vec![]), "noLanguageServer");
assert_eq!(check(vec![server(ServerStatus::Missing, false, false)]), "serverUnavailable");
assert_eq!(check(vec![server(ServerStatus::Ready, true, false)]), "checked");
assert_eq!(check(vec![server(ServerStatus::Busy, true, true)]), "incomplete");
assert_eq!(
check(vec![server(ServerStatus::Ready, true, false), server(ServerStatus::Crashed, false, false)]),
"incomplete"
);
}
}
+3 -3
View File
@@ -87,7 +87,7 @@ async fn create_or_replace(call: &Call, path: PathBuf, text: Option<String>, ove
return Ok(()); return Ok(());
} }
let edit = crate::ops::lsp::prepare_workspace_edit(&call.host, call.window(), edit).await?; let edit = crate::ops::lsp::prepare_workspace_edit(&call.host, call.window(), edit).await?;
crate::ops::lsp::apply_workspace_edit(&call.host, call.window(), edit).await crate::ops::lsp::apply_agent_workspace_edit(&call.host, call.window(), edit).await
} }
/// The open document of `path`, or a transient one (`true`: close it after use). /// The open document of `path`, or a transient one (`true`: close it after use).
@@ -317,7 +317,7 @@ async fn apply_op(call: &Call, op: FileOp, notes: &mut Vec<String>) -> IdeResult
preconditions: Vec::new(), preconditions: Vec::new(),
}; };
let edit = crate::ops::lsp::prepare_workspace_edit(&host, call.window(), edit).await?; let edit = crate::ops::lsp::prepare_workspace_edit(&host, call.window(), edit).await?;
crate::ops::lsp::apply_workspace_edit(&host, call.window(), edit).await crate::ops::lsp::apply_agent_workspace_edit(&host, call.window(), edit).await
} }
FileOp::Update { path: first, move_to, hunks, or } => { FileOp::Update { path: first, move_to, hunks, or } => {
// A plain diff's other names count when its `+++` path does not exist. // A plain diff's other names count when its `+++` path does not exist.
@@ -400,7 +400,7 @@ async fn apply_op(call: &Call, op: FileOp, notes: &mut Vec<String>) -> IdeResult
edit.files.push(FileEdit::Rename { from: path.into(), to: to.into() }); edit.files.push(FileEdit::Rename { from: path.into(), to: to.into() });
} }
let edit = crate::ops::lsp::prepare_workspace_edit(&host, call.window(), edit).await?; let edit = crate::ops::lsp::prepare_workspace_edit(&host, call.window(), edit).await?;
crate::ops::lsp::apply_workspace_edit(&host, call.window(), edit).await?; crate::ops::lsp::apply_agent_workspace_edit(&host, call.window(), edit).await?;
if dirty { if dirty {
notes.push(format!("{shown}: patched the unsaved editor buffer")); notes.push(format!("{shown}: patched the unsaved editor buffer"));
} }
+1 -1
View File
@@ -385,7 +385,7 @@ targetIndex, or narrow the call with a contextSnippet that shows the one you mea
_ => None, _ => None,
}); });
let (h, w) = (host.clone(), window.to_owned()); let (h, w) = (host.clone(), window.to_owned());
let written = crate::ops::lsp::apply_workspace_edit(&h, &w, edit).await; let written = crate::ops::lsp::apply_agent_workspace_edit(&h, &w, edit).await;
if let Err(e) = written { if let Err(e) = written {
let hint = format!("The rename stopped while it wrote. {} Read the files again before you retry.", e.message); let hint = format!("The rename stopped while it wrote. {} Read the files again before you retry.", e.message);
return Ok(RenameResult::failed("write_failed", hint).with_symbol(symbol)); return Ok(RenameResult::failed("write_failed", hint).with_symbol(symbol));
+1 -1
View File
@@ -65,7 +65,7 @@ async fn current_file(call: Call) -> IdeResult<Output> {
}) })
} }
fn problem(d: Diagnostic, text: &str) -> Value { pub(super) fn problem(d: Diagnostic, text: &str) -> Value {
json!({ json!({
"severity": d.severity, "description": d.message, "severity": d.severity, "description": d.message,
"lineContent": text.lines().nth(d.range.start.line as usize).unwrap_or_default(), "lineContent": text.lines().nth(d.range.start.line as usize).unwrap_or_default(),
+34 -9
View File
@@ -30,9 +30,14 @@ fn read_file() -> Tool {
"Reads a file in the project directory or from any project dependency or other project source root. "Reads a file in the project directory or from any project dependency or other project source root.
Can read text sources inside jar/zip archives; compiled class files are not decompiled. Can read text sources inside jar/zip archives; compiled class files are not decompiled.
Returns numbered lines (1-indexed) as text. Returns numbered lines (1-indexed) as text.
By default, reads up to 2000 lines starting from the beginning of the file. By default, reads up to 2000 lines starting from the beginning of the file; a longer file keeps its first and last
lines with a `…N lines truncated…` marker between them.
With `offset` or `limit`, returns exactly lines `offset` to `offset + limit - 1` (fewer at the end of the file),
followed by `…N more lines; read on with offset=M…` when the file goes on.
The maximum accepted limit is 5000 lines.", The maximum accepted limit is 5000 lines.",
|call| async move { |call| async move {
// An explicit range is read as asked; the default read of a whole file keeps its head and tail.
let window = call.opt_i64("offset")?.is_some() || call.opt_i64("limit")?.is_some();
let offset = positive(&call, "offset", 1)?; let offset = positive(&call, "offset", 1)?;
let limit = positive(&call, "limit", DEFAULT_READ_LIMIT)?; let limit = positive(&call, "limit", DEFAULT_READ_LIMIT)?;
if limit > MAX_READ_LIMIT { if limit > MAX_READ_LIMIT {
@@ -50,7 +55,7 @@ The maximum accepted limit is 5000 lines.",
))); )));
} }
let text = crate::ops::fs::read_text(&call.host(), call.window(), path).await?; let text = crate::ops::fs::read_text(&call.host(), call.window(), path).await?;
Ok(Output::text(render_slice(&text, offset as usize, limit as usize)?)) Ok(Output::text(render_slice(&text, offset as usize, limit as usize, window)?))
}, },
) )
.param(Param::required("file_path", ParamType::String, util::FILE_PATH_DESCRIPTION)) .param(Param::required("file_path", ParamType::String, util::FILE_PATH_DESCRIPTION))
@@ -70,10 +75,12 @@ fn positive(call: &Call, name: &str, default: i64) -> IdeResult<i64> {
} }
} }
/// Lines `start..` of `text` as `L<n>: <line>`; when more than `max_lines` remain, the first /// Lines `start..` of `text` as `L<n>: <line>`, at most `max_lines` of them. A `window` is
/// and the last halves of the budget with a `…N lines truncated…` marker between them. /// exactly the lines from `start` on, then how many lines remain; otherwise, when more than
/// `max_lines` remain, the first and the last halves of the budget with a `…N lines truncated…`
/// marker between them.
/// Lines are the editor's: a trailing line break starts an empty last line. /// Lines are the editor's: a trailing line break starts an empty last line.
fn render_slice(text: &str, start: usize, max_lines: usize) -> IdeResult<String> { fn render_slice(text: &str, start: usize, max_lines: usize, window: bool) -> IdeResult<String> {
let lines: Vec<&str> = text.split('\n').map(|l| l.strip_suffix('\r').unwrap_or(l)).collect(); let lines: Vec<&str> = text.split('\n').map(|l| l.strip_suffix('\r').unwrap_or(l)).collect();
let count = lines.len(); let count = lines.len();
if start > count { if start > count {
@@ -86,6 +93,15 @@ fn render_slice(text: &str, start: usize, max_lines: usize) -> IdeResult<String>
if available <= max_lines { if available <= max_lines {
return Ok(range(start, count)); return Ok(range(start, count));
} }
if window {
let end = start + max_lines - 1;
return Ok(format!(
"{}\n\u{2026}{} more lines; read on with offset={}\u{2026}",
range(start, end),
count - end,
end + 1
));
}
let head = max_lines / 2; let head = max_lines / 2;
let tail = max_lines - head; let tail = max_lines - head;
let head_end = start + head - 1; let head_end = start + head - 1;
@@ -245,7 +261,7 @@ mod tests {
use super::*; use super::*;
fn slice(text: &str, start: usize, max: usize) -> String { fn slice(text: &str, start: usize, max: usize) -> String {
render_slice(text, start, max).unwrap() render_slice(text, start, max, false).unwrap()
} }
#[test] #[test]
@@ -254,18 +270,27 @@ mod tests {
assert_eq!(slice("a\r\nb\n", 1, 10), "L1: a\nL2: b\nL3: "); assert_eq!(slice("a\r\nb\n", 1, 10), "L1: a\nL2: b\nL3: ");
assert_eq!(slice("", 1, 10), "L1: "); assert_eq!(slice("", 1, 10), "L1: ");
assert_eq!(slice("a\nb\nc", 2, 10), "L2: b\nL3: c"); assert_eq!(slice("a\nb\nc", 2, 10), "L2: b\nL3: c");
assert!(render_slice("", 2, 10).unwrap_err().message.contains("offset exceeds file length")); assert!(render_slice("", 2, 10, true).unwrap_err().message.contains("offset exceeds file length"));
assert!(render_slice("a\nb", 3, 10).unwrap_err().message.contains("offset exceeds file length")); assert!(render_slice("a\nb", 3, 10, true).unwrap_err().message.contains("offset exceeds file length"));
} }
#[test] #[test]
fn keeps_head_and_tail_of_long_slices() { fn keeps_head_and_tail_of_a_long_default_read() {
let text = (1..=11).map(|n| format!("line {n}")).collect::<Vec<_>>().join("\n"); let text = (1..=11).map(|n| format!("line {n}")).collect::<Vec<_>>().join("\n");
assert_eq!(slice(&text, 1, 4), "L1: line 1\nL2: line 2\n…7 lines truncated…\nL10: line 10\nL11: line 11"); assert_eq!(slice(&text, 1, 4), "L1: line 1\nL2: line 2\n…7 lines truncated…\nL10: line 10\nL11: line 11");
assert_eq!(slice(&text, 3, 4), "L3: line 3\nL4: line 4\n…5 lines truncated…\nL10: line 10\nL11: line 11"); assert_eq!(slice(&text, 3, 4), "L3: line 3\nL4: line 4\n…5 lines truncated…\nL10: line 10\nL11: line 11");
assert_eq!(slice(&text, 1, 1), "…10 lines truncated…\nL11: line 11"); assert_eq!(slice(&text, 1, 1), "…10 lines truncated…\nL11: line 11");
} }
#[test]
fn reads_an_explicit_range_exactly_and_says_where_to_read_on() {
let text = (1..=11).map(|n| format!("line {n}")).collect::<Vec<_>>().join("\n");
let window = |start, max| render_slice(&text, start, max, true).unwrap();
assert_eq!(window(5, 3), "L5: line 5\nL6: line 6\nL7: line 7\n…4 more lines; read on with offset=8…");
assert_eq!(window(1, 1), "L1: line 1\n…10 more lines; read on with offset=2…");
assert_eq!(window(10, 3), "L10: line 10\nL11: line 11", "the end of the file needs no marker");
}
#[test] #[test]
fn cuts_long_lines_at_utf16_units() { fn cuts_long_lines_at_utf16_units() {
let long = "x".repeat(2100); let long = "x".repeat(2100);
+4 -1
View File
@@ -1,8 +1,9 @@
//! Built-in IDE tools. Transport and discovery live in `ide-mcp`. //! Built-in IDE tools. Transport and discovery live in `ide-mcp`.
mod check;
mod edit; mod edit;
mod editors; mod editors;
mod requests; mod requests;
mod run; pub(crate) mod run;
mod vcs; mod vcs;
pub use requests::Requests; pub use requests::Requests;
mod files; mod files;
@@ -16,6 +17,7 @@ use crate::Host;
use ide_core::{CancellationToken, IdeError}; use ide_core::{CancellationToken, IdeError};
use ide_mcp::{ToolCallError, ToolRegistry}; use ide_mcp::{ToolCallError, ToolRegistry};
pub use reports::{FileProblems, FocusedEditor, Reports}; pub use reports::{FileProblems, FocusedEditor, Reports};
pub use run::agent::{AGENT_PARAM, AgentRun, AgentRuns, NAME_PREFIX, SHELL_PARAM};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
@@ -30,6 +32,7 @@ fn tools() -> Vec<tool::Tool> {
let mut tools = files::tools(); let mut tools = files::tools();
tools.extend(search::tools()); tools.extend(search::tools());
tools.extend(editors::tools()); tools.extend(editors::tools());
tools.push(check::check_files());
tools.push(symbols::get_symbol_info()); tools.push(symbols::get_symbol_info());
tools.push(symbols::find_references()); tools.push(symbols::find_references());
tools.extend(edit::tools()); tools.extend(edit::tools());
File diff suppressed because it is too large. Load diff
+418
View File
@@ -0,0 +1,418 @@
//! `run_command` and the tools that follow its tab up: `wait_for_run`, `read_run_output`,
//! `get_run_status`, `send_run_input`, `stop_run` and `list_runs` (see [`super::agent`]).
use super::agent::{AgentRun, INTERVENTION_ADVICE, TruncateMode, clean_output, tab_name, truncate_lines};
use super::output::RUN_PREVIEW_MAX_LENGTH;
use crate::mcp::run::timeout;
use crate::mcp::tool::{Call, Output, Param, ParamType, Tool};
use ide_core::{IdeError, IdeResult};
use serde_json::{Map, Value};
use std::sync::Arc;
const RUN_TIMEOUT_MS: usize = 10_000;
const WAIT_TIMEOUT_MS: usize = 30_000;
const STOP_TIMEOUT_MS: usize = 10_000;
/// Lines of output returned at most, by default (as JetBrains' `execute_terminal_command`).
const DEFAULT_MAX_LINES: usize = 2000;
/// Bytes of the log read for one result at most.
const READ_CAP: usize = 1 << 20;
/// Lines of output quoted when a name is still running.
const BUSY_TAIL_LINES: usize = 20;
const NAME: &str = "Tab name. OpenIDEA shows the tab as `_agent_<name>`; passing the prefixed name works too.";
const SEQUENCE: &str = "\n\nUsing the same name for every command of one task reuses its tab: a name whose command \
has finished starts the next command in the same tab. A name whose command is still running is refused (with that \
command and its last output); stop it with `stop_run` first or use another name. Nothing is stopped implicitly.";
const INTERVENTIONS: &str = "\n\nThe user sees every tab and can type into it, rerun or stop its command, or close \
it. Results then carry `interventions` (for example `User input: …` or `The user reran this command at …`) and \
`advice`: unless the user asked otherwise, stop what you are doing, tell the user you received the intervention and \
wait for their confirmation before you continue.";
const OUTPUT: &str = "\n\nThe command runs on pipes, not a terminal, so programs print plain lines (no progress \
bars). `output` is that text cleaned of escape sequences, at most `maxLinesCount` lines (default 2000): beyond it, \
`truncateMode` START (default) keeps the end, END the start, MIDDLE both ends, NONE everything, with a \
`<<<...content truncated...>>>` line where lines were left out and `truncatedLines` saying how many. `outputStart` \
and `nextOffset` are positions in the tab's raw output log; pass `nextOffset` as `offset` to read on.";
const MAX_LINES: &str = "Lines of output to return at most (default 2000)";
const TRUNCATE_MODE: &str = "Which lines to leave out beyond maxLinesCount: START (default, keeps the end), END \
(keeps the start), MIDDLE (keeps both ends) or NONE";
fn find(call: &Call) -> IdeResult<Arc<AgentRun>> {
let name = tab_name(&call.str("name")?)?;
call.host()
.mcp_agent_runs
.get(call.root(), &name)
.ok_or_else(|| IdeError::not_found(format!("no agent tab named `{name}` (see list_runs)")))
}
/// `status` of `run`, with interventions not told yet.
fn result(run: &AgentRun, extra: Map<String, Value>) -> IdeResult<Output> {
let mut out = run.status();
out.extend(extra);
let interventions = run.take_interventions();
if !interventions.is_empty() {
out.insert("interventions".into(), interventions.into());
out.insert("advice".into(), INTERVENTION_ADVICE.into());
}
Output::json(Value::Object(out))
}
/// The output from `from` as the agent reads it: cleaned, then cut to `maxLinesCount` lines
/// as `truncateMode` says.
fn output_view(call: &Call, run: &AgentRun, from: u64) -> IdeResult<Map<String, Value>> {
let max_lines = match call.opt_i64("maxLinesCount")? {
Some(n) if n < 1 => return Err(IdeError::invalid("maxLinesCount must be > 0")),
Some(n) => n as usize,
None => DEFAULT_MAX_LINES,
};
let mode = match call.opt_str("truncateMode")? {
Some(name) => TruncateMode::parse(&name)
.ok_or_else(|| IdeError::invalid("truncateMode must be START, MIDDLE, END or NONE"))?,
None => TruncateMode::Start,
};
let len = run.log_len();
let from = from.min(len);
// A long output is read from the end that stays.
let start = if mode == TruncateMode::Start && len - from > READ_CAP as u64 { len - READ_CAP as u64 } else { from };
let (raw, start, end) = run.read(start, READ_CAP)?;
let (text, truncated) = truncate_lines(&clean_output(&raw), max_lines, mode);
let mut out = Map::new();
out.insert("output".into(), text.into());
if truncated > 0 {
out.insert("truncatedLines".into(), truncated.into());
}
out.insert("outputStart".into(), start.into());
out.insert("nextOffset".into(), end.into());
Ok(out)
}
fn deadline(call: &Call, default: usize) -> IdeResult<tokio::time::Instant> {
Ok(tokio::time::Instant::now() + timeout(call, default)?)
}
fn pattern(call: &Call) -> IdeResult<Option<regex_lite::Regex>> {
call.opt_str("pattern")?
.filter(|p| !p.is_empty())
.map(|p| regex_lite::Regex::new(&p).map_err(|e| IdeError::invalid(format!("invalid pattern: {e}"))))
.transpose()
}
fn offset(call: &Call, name: &str) -> IdeResult<Option<u64>> {
match call.opt_i64(name)? {
Some(n) if n < 0 => Err(IdeError::invalid(format!("{name} must not be negative"))),
n => Ok(n.map(|n| n as u64)),
}
}
fn busy(run: &AgentRun) -> IdeError {
let tail = run.tail(run.process_offset(), BUSY_TAIL_LINES, RUN_PREVIEW_MAX_LENGTH).map(|t| t.0).unwrap_or_default();
IdeError::invalid(format!(
"`{}` is still running `{}`. Stop it with stop_run first, or use another name.\nLast output:\n{}",
run.name,
run.command(),
clean_output(&tail).trim_end()
))
}
pub fn run_command() -> Tool {
Tool::new(
"run_command",
&format!(
"Runs a shell command line in a new or reused tab of OpenIDEA's Run tool window, with the shell of \
Settings › Tools › Terminal. The user is asked first (unless the IDE runs tools without \
confirmation). Waits up to `timeout` milliseconds (default {RUN_TIMEOUT_MS}) for the command to finish; a command \
still running then keeps running, and `wait_for_run`, `read_run_output`, `get_run_status`, `send_run_input` and \
`stop_run` follow it up by name. Returns the tab's status (`state`: starting, running, stopping, succeeded, failed, \
crashed, stoppedByAgent, stoppedByUser or startFailed; `exitCode`; `crash` with the signal or exception of a crashed \
process) and its output so far.{OUTPUT}{SEQUENCE}{INTERVENTIONS}"
),
|call| async move {
let name = tab_name(&call.str("name")?)?;
let command = call.str("command")?;
if command.trim().is_empty() {
return Err(IdeError::invalid("command must not be empty"));
}
let cwd = match call.opt_str("workingDirectory")?.filter(|d| !d.trim().is_empty()) {
Some(dir) => {
let path = call.resolve_inside(&dir)?;
if !path.is_dir() {
return Err(IdeError::not_found(format!("workingDirectory {} is not a directory", dir)));
}
path
}
None => call.root().to_path_buf(),
};
let env = call.args.get("envs").filter(|v| !v.is_null()).map(|_| call.str_map("envs")).transpose()?;
let deadline = deadline(&call, RUN_TIMEOUT_MS)?;
let host = call.host();
let runs = &host.mcp_agent_runs;
if let Some(run) = runs.get(call.root(), &name).filter(|r| r.running()) {
return Err(busy(&run));
}
let shown_cwd = call.display(&cwd);
let mut detail = format!(
"{command}\n\nTab: {name}\nWorking directory: {}",
if shown_cwd.is_empty() { ".".to_owned() } else { shown_cwd.clone() }
);
for (key, value) in env.iter().flatten() {
detail.push_str(&format!("\n{key}={value}"));
}
call.confirm(&name, Some(detail)).await?;
let run = runs.prepare(call.root(), call.window(), &name, &command, &shown_cwd).map_err(|r| busy(&r))?;
let request = ide_core::event::McpUiRequest::AgentRun {
name: name.clone(),
command: command.clone(),
cwd: cwd.into(),
env: env.unwrap_or_default().into_iter().collect(),
};
if let Err(e) = call.ask(request).await {
let error = run.start_error();
runs.abandon(&run);
return Err(match error {
Some(error) => IdeError::invalid(format!("`{name}` did not start: {error}")),
None => e,
});
}
if let Some(error) = run.start_error() {
return Err(IdeError::invalid(format!("`{name}` did not start: {error}")));
}
let from = run.process_offset();
let end = run.wait(deadline, None, from, &call.cancel).await?;
let mut extra = output_view(&call, &run, from)?;
extra.insert("waitEnded".into(), end.as_str().into());
result(&run, extra)
},
)
.write()
.param(Param::required("name", ParamType::String, NAME))
.param(Param::required(
"command",
ParamType::String,
"Shell command line, e.g. `npm test -- --watch=false` or `cargo build && ./target/debug/app`",
))
.param(Param::optional(
"workingDirectory",
ParamType::String,
"Directory inside the project to run in, relative to the project root; default: the project root",
))
.param(Param::optional("envs", ParamType::StringMap, "Environment variables added for this command"))
.param(Param::optional(
"timeout",
ParamType::Integer,
"Milliseconds to wait for the command to finish before returning (0 returns once it started); the command \
keeps running after it",
))
.param(Param::optional("maxLinesCount", ParamType::Integer, MAX_LINES))
.param(Param::optional("truncateMode", ParamType::String, TRUNCATE_MODE))
}
pub fn wait_for_run() -> Tool {
Tool::new(
"wait_for_run",
&format!(
"Waits until the command of an agent tab finishes, its output matches `pattern`, the user intervenes, or \
`timeout` milliseconds pass (default {WAIT_TIMEOUT_MS}). Cancelling the wait leaves the command running. Returns \
`waitEnded` (exited, matched, userIntervened or timedOut), the tab's status and the output since `offset`. Use \
`pattern` to wait for a server to be ready, e.g. `Listening on|Started .* in`.{OUTPUT}{INTERVENTIONS}"
),
|call| async move {
let run = find(&call)?;
let deadline = deadline(&call, WAIT_TIMEOUT_MS)?;
let pattern = pattern(&call)?;
let from = offset(&call, "offset")?.unwrap_or_else(|| run.process_offset());
let end = run.wait(deadline, pattern.as_ref(), from, &call.cancel).await?;
let mut extra = output_view(&call, &run, from)?;
extra.insert("waitEnded".into(), end.as_str().into());
result(&run, extra)
},
)
.param(Param::required("name", ParamType::String, NAME))
.param(Param::optional("timeout", ParamType::Integer, "Milliseconds to wait at most"))
.param(Param::optional(
"pattern",
ParamType::String,
"Regular expression to wait for in the output after `offset` (as the command wrote it)",
))
.param(Param::optional(
"offset",
ParamType::Integer,
"Where in the tab's output log to start: a `nextOffset` from an earlier result. Default: where the current \
command's output starts",
))
.param(Param::optional("maxLinesCount", ParamType::Integer, MAX_LINES))
.param(Param::optional("truncateMode", ParamType::String, TRUNCATE_MODE))
}
pub fn read_run_output() -> Tool {
Tool::new(
"read_run_output",
&format!(
"Reads the output log of an agent tab, which goes on across the commands and reruns of the tab: from \
`offset` (a `nextOffset` from an earlier result), or from its start. Also returns the tab's status.\
{OUTPUT}{INTERVENTIONS}"
),
|call| async move {
let run = find(&call)?;
let from = offset(&call, "offset")?.unwrap_or(0);
let extra = output_view(&call, &run, from)?;
result(&run, extra)
},
)
.param(Param::required("name", ParamType::String, NAME))
.param(Param::optional("offset", ParamType::Integer, "Where in the log to read from"))
.param(Param::optional("maxLinesCount", ParamType::Integer, MAX_LINES))
.param(Param::optional("truncateMode", ParamType::String, TRUNCATE_MODE))
}
pub fn get_run_status() -> Tool {
Tool::new(
"get_run_status",
&format!(
"Returns the status of an agent tab: its command, `state` (starting, running, stopping, succeeded, \
failed, crashed, stoppedByAgent, stoppedByUser or startFailed), `startedBy` (agent, or user when the user reran \
it), times, `exitCode`, `crash`, `outputLength`, `lastOutputAt` (a running command silent for long may be waiting \
for input) and `tabClosedAt` when the user closed the tab.{INTERVENTIONS}"
),
|call| async move {
let run = find(&call)?;
result(&run, Map::new())
},
)
.param(Param::required("name", ParamType::String, NAME))
}
pub fn list_runs() -> Tool {
Tool::new(
"list_runs",
"Lists the agent tabs of the project (those started by `run_command`) with their status.",
|call| async move {
let runs: Vec<Value> =
call.host().mcp_agent_runs.list(call.root()).iter().map(|r| Value::Object(r.status())).collect();
Output::json(serde_json::json!({ "runs": runs }))
},
)
}
/// What a key does to a command on pipes.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Key {
/// Writes this text to its input.
Text(&'static str),
/// Ctrl+C: stops it, as `stop_run` does.
Interrupt,
/// Ctrl+D: ends its input.
EndOfInput,
}
fn key(name: &str) -> IdeResult<Key> {
Ok(match name.trim().to_ascii_lowercase().replace(' ', "").as_str() {
"enter" | "return" => Key::Text("\n"),
"tab" => Key::Text("\t"),
"ctrl+c" => Key::Interrupt,
"ctrl+d" => Key::EndOfInput,
"escape" | "esc" | "backspace" | "ctrl+z" | "ctrl+\\" | "ctrl+l" | "up" | "down" | "left" | "right" => {
return Err(IdeError::invalid(format!(
"`{name}` is a terminal key, and the command runs on pipes, not a terminal: send text and Enter, \
Ctrl+C to stop it or Ctrl+D to end its input"
)));
}
_ => return Err(IdeError::invalid(format!("unknown key `{name}`"))),
})
}
pub fn send_run_input() -> Tool {
Tool::new(
"send_run_input",
"Writes to the input of the running command of an agent tab, which runs on pipes: `text` as it is, then \
`keys` in order: Enter (a line break), Tab, Ctrl+C (stops the command, as `stop_run` does) and Ctrl+D (ends its \
input). Terminal keys (arrows, Escape, Backspace, Ctrl+Z…) are refused. To answer a prompt, pass the answer as \
`text` and `keys: [\"Enter\"]`. The user sees the command's output in the tab. Returns the tab's status.",
|call| async move {
let run = find(&call)?;
let text = call.opt_str("text")?.unwrap_or_default();
let keys = call.str_list("keys")?.iter().map(|k| key(k)).collect::<IdeResult<Vec<_>>>()?;
if text.is_empty() && keys.is_empty() {
return Err(IdeError::invalid("pass `text`, `keys` or both"));
}
let id = run.live_process().ok_or_else(|| IdeError::invalid(format!("`{}` is not running", run.name)))?;
call.project().require_trust()?;
let host = call.host();
let mut data = text;
for key in keys {
match key {
Key::Text(text) => data.push_str(text),
Key::EndOfInput | Key::Interrupt => {
if !data.is_empty() {
host.processes.write_stdin(id, &std::mem::take(&mut data))?;
}
if key == Key::EndOfInput {
crate::ops::run::close_stdin(&host, id)?;
} else {
run.agent_stopping();
crate::ops::run::stop(&host, &run.window(), id)?;
break;
}
}
}
}
if !data.is_empty() {
host.processes.write_stdin(id, &data)?;
}
result(&run, Map::new())
},
)
.write()
.param(Param::required("name", ParamType::String, NAME))
.param(Param::optional("text", ParamType::String, "Text to type"))
.param(Param::optional("keys", ParamType::StringList, "Keys to press after `text`"))
}
pub fn stop_run() -> Tool {
Tool::new(
"stop_run",
&format!(
"Stops the running command of an agent tab, the way the Run tool window's Stop does: it asks the \
process tree to exit and, with `force`, kills it. Waits up to `timeout` milliseconds (default {STOP_TIMEOUT_MS}) \
for it to end and returns the tab's status (`stoppedByAgent` once it ended). A process that ignores the request \
keeps `stopping`: call again with `force`. The tab stays open with its output."
),
|call| async move {
let run = find(&call)?;
let id = run.live_process().ok_or_else(|| IdeError::invalid(format!("`{}` is not running", run.name)))?;
let force = call.flag("force", false)?;
let deadline = deadline(&call, STOP_TIMEOUT_MS)?;
let host = call.host();
run.agent_stopping();
crate::ops::run::stop(&host, &run.window(), id)?;
if force {
// A second Stop kills the tree.
crate::ops::run::stop(&host, &run.window(), id)?;
}
let end = run.wait(deadline, None, run.log_len(), &call.cancel).await?;
let mut extra = Map::new();
extra.insert("waitEnded".into(), end.as_str().into());
result(&run, extra)
},
)
.write()
.param(Param::required("name", ParamType::String, NAME))
.param(Param::optional("force", ParamType::Boolean, "Kill the process tree instead of asking it to exit"))
.param(Param::optional("timeout", ParamType::Integer, "Milliseconds to wait for the command to end"))
}
pub fn tools() -> Vec<Tool> {
vec![run_command(), wait_for_run(), read_run_output(), get_run_status(), send_run_input(), stop_run(), list_runs()]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn keys_are_what_pipes_can_take() {
assert_eq!(key(" enter ").unwrap(), Key::Text("\n"));
assert_eq!(key("Ctrl+C").unwrap(), Key::Interrupt);
assert_eq!(key("ctrl+d").unwrap(), Key::EndOfInput);
assert!(key("Up").unwrap_err().message.contains("runs on pipes"));
assert!(key("F13").unwrap_err().message.contains("unknown key"));
}
}
+2 -3
View File
@@ -15,7 +15,6 @@ use std::sync::Arc;
use std::time::Instant; use std::time::Instant;
const DEFAULT_TIMEOUT_MS: usize = 60_000; const DEFAULT_TIMEOUT_MS: usize = 60_000;
const MAX_TIMEOUT_MS: usize = 3_600_000;
const MAX_PROBLEMS: usize = 300; const MAX_PROBLEMS: usize = 300;
/// Console lines attached to a failure whose messages were not parsed. /// Console lines attached to a failure whose messages were not parsed.
const CONSOLE_LINES: usize = 200; const CONSOLE_LINES: usize = 200;
@@ -84,13 +83,13 @@ You have to use this tool after performing edits to validate if the edits are va
In OpenIDEA the project's build system builds it (Maven, Gradle, Cargo, Go or TypeScript); with `filesToRebuild`, the \ In OpenIDEA the project's build system builds it (Maven, Gradle, Cargo, Go or TypeScript); with `filesToRebuild`, the \
modules containing those files. Asks the user first for every call. Problems have the project-relative \ modules containing those files. Asks the user first for every call. Problems have the project-relative \
`file` and 1-based `line` / `column`; at most 300 problems are listed, and `isSuccess` is false whenever one of them \ `file` and 1-based `line` / `column`; at most 300 problems are listed, and `isSuccess` is false whenever one of them \
is an ERROR. On timeout (at most 1 hour; 0 times out at once) the build is stopped.", is an ERROR. On timeout (0 times out at once) the build is stopped.",
|call| async move { |call| async move {
let files: Vec<String> = let files: Vec<String> =
call.str_list("filesToRebuild")?.into_iter().filter(|f| !f.trim().is_empty()).collect(); call.str_list("filesToRebuild")?.into_iter().filter(|f| !f.trim().is_empty()).collect();
let rebuild = call.flag("rebuild", false)? && files.is_empty(); let rebuild = call.flag("rebuild", false)? && files.is_empty();
let kind = if rebuild { BuildKind::Rebuild } else { BuildKind::Build }; let kind = if rebuild { BuildKind::Rebuild } else { BuildKind::Build };
let limit = timeout(&call, DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS)?; let limit = timeout(&call, DEFAULT_TIMEOUT_MS)?;
let mut targets: Vec<Option<PathBuf>> = Vec::new(); let mut targets: Vec<Option<PathBuf>> = Vec::new();
let mut dirs: Vec<PathBuf> = Vec::new(); let mut dirs: Vec<PathBuf> = Vec::new();
for f in &files { for f in &files {
+1 -3
View File
@@ -19,8 +19,6 @@ use std::sync::Arc;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
const DEFAULT_TIMEOUT_MS: usize = 10_000; const DEFAULT_TIMEOUT_MS: usize = 10_000;
/// No limit in JetBrains; the process keeps running after the timeout anyway.
const MAX_TIMEOUT_MS: usize = 1_800_000;
/// Output of a failed before-launch process kept for the error message. /// Output of a failed before-launch process kept for the error message.
const MAX_PROCESS_OUTPUT_LENGTH: usize = 20_000; const MAX_PROCESS_OUTPUT_LENGTH: usize = 20_000;
/// A before-launch build gets this long. /// A before-launch build gets this long.
@@ -541,7 +539,7 @@ tool window; output files are private and deleted after a day.",
)?; )?;
let wait = call.flag("waitForExit", true)?; let wait = call.flag("waitForExit", true)?;
// As JetBrains: `timeout` is ignored without waiting, and 0 returns without waiting. // As JetBrains: `timeout` is ignored without waiting, and 0 returns without waiting.
let limit = if wait { Some(timeout(&call, DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS)?) } else { None }; let limit = if wait { Some(timeout(&call, DEFAULT_TIMEOUT_MS)?) } else { None };
let envs = call.args.get("envs").filter(|v| !v.is_null()).map(|_| call.str_map("envs")).transpose()?; let envs = call.args.get("envs").filter(|v| !v.is_null()).map(|_| call.str_map("envs")).transpose()?;
let configs = load_configs(&call).await?; let configs = load_configs(&call).await?;
let mut config = match &target { let mut config = match &target {
+14 -5
View File
@@ -1,5 +1,7 @@
//! Run configurations and builds; every call asks the user first. //! Run configurations, builds and agent commands; every start asks the user first.
pub(crate) mod agent;
mod agent_tools;
mod build; mod build;
mod configs; mod configs;
mod output; mod output;
@@ -11,15 +13,22 @@ use ide_core::IdeResult;
use std::time::Duration; use std::time::Duration;
pub fn tools() -> Vec<Tool> { pub fn tools() -> Vec<Tool> {
vec![configs::get_run_configurations(), configs::execute_run_configuration(), build::build_project()] let mut tools =
vec![configs::get_run_configurations(), configs::execute_run_configuration(), build::build_project()];
tools.extend(agent_tools::tools());
tools
} }
/// The `timeout` argument: `default` when absent, at most `max` milliseconds. As in JetBrains /// Longest wait: far enough to be no limit, near enough not to overflow a deadline.
const FOREVER_MS: u64 = 365 * 24 * 60 * 60 * 1000;
/// The `timeout` argument in milliseconds, `default` when absent: the agent decides how long
/// to wait (long waits are answered on an event stream, see `ide_mcp`). As in JetBrains
/// (`withTimeoutOrNull`), zero or a negative value times out at once. /// (`withTimeoutOrNull`), zero or a negative value times out at once.
fn timeout(call: &Call, default: usize, max: usize) -> IdeResult<Duration> { pub(crate) fn timeout(call: &Call, default: usize) -> IdeResult<Duration> {
Ok(match call.opt_i64("timeout")? { Ok(match call.opt_i64("timeout")? {
None => Duration::from_millis(default as u64), None => Duration::from_millis(default as u64),
Some(n) if n <= 0 => Duration::ZERO, Some(n) if n <= 0 => Duration::ZERO,
Some(n) => Duration::from_millis((n as u64).min(max as u64)), Some(n) => Duration::from_millis((n as u64).min(FOREVER_MS)),
}) })
} }
+42 -2
View File
@@ -434,10 +434,23 @@ fn cwd_of(root: &Path, c: &RunConfig) -> String {
cwd.trim_end_matches('/').to_owned() cwd.trim_end_matches('/').to_owned()
} }
/// Configs whose working directory most specifically contains `rel` (ties in their original order). /// The directory a config covers: a Cargo workspace member's own directory (its `cargo test -p`
/// runs from the workspace root), else its working directory.
fn scope_of(root: &Path, c: &RunConfig) -> String {
match c.extras.module.as_deref() {
Some(module) if c.kind == "cargo" => {
let module = module.replace('\\', "/");
let module = module.strip_prefix("./").unwrap_or(&module);
if module == "." { String::new() } else { module.trim_end_matches('/').to_owned() }
}
_ => cwd_of(root, c),
}
}
/// Configs whose scope ([`scope_of`]) most specifically contains `rel` (ties in their original order).
fn all_closest<'a>(root: &Path, configs: &[&'a RunConfig], rel: &str) -> Vec<&'a RunConfig> { fn all_closest<'a>(root: &Path, configs: &[&'a RunConfig], rel: &str) -> Vec<&'a RunConfig> {
let score = |c: &RunConfig| -> Option<usize> { let score = |c: &RunConfig| -> Option<usize> {
let cwd = cwd_of(root, c); let cwd = scope_of(root, c);
if cwd.is_empty() { Some(0) } else { (rel == cwd || rel.starts_with(&format!("{cwd}/"))).then_some(cwd.len()) } if cwd.is_empty() { Some(0) } else { (rel == cwd || rel.starts_with(&format!("{cwd}/"))).then_some(cwd.len()) }
}; };
let scored: Vec<(&RunConfig, usize)> = configs.iter().filter_map(|c| score(c).map(|s| (*c, s))).collect(); let scored: Vec<(&RunConfig, usize)> = configs.iter().filter_map(|c| score(c).map(|s| (*c, s))).collect();
@@ -922,6 +935,33 @@ mod tests {
assert_eq!(c.args, ["test", "--", "math::tests::"]); assert_eq!(c.args, ["test", "--", "math::tests::"]);
} }
#[test]
fn a_workspace_member_runs_its_own_tests() {
let root = Path::new("/p");
let member = |pkg: &str, dir: &str| {
let mut c = blank(
format!("cargo:test:{pkg}"),
"test",
"cargo",
"cargo".into(),
vec!["test".into(), "-p".into(), pkg.into()],
);
c.is_test = true;
c.extras.module = Some(dir.into());
c
};
let mut workspace = blank("cargo:test".into(), "test", "cargo", "cargo".into(), vec!["test".into()]);
workspace.is_test = true;
let configs = vec![member("ide-agent", "crates/ide-agent"), member("ide-mcp", "crates/ide-mcp"), workspace];
let src = "#[cfg(test)]\nmod tests {\n #[test]\n fn adds() {}\n}\n";
let rel = "crates/ide-mcp/src/stdio.rs";
let ts = detect(src, rel);
let c = target_config(root, &ts[1], rel, &configs, src).unwrap();
assert_eq!(c.args, ["test", "-p", "ide-mcp", "--", "stdio::tests::adds", "--exact"]);
let c = target_config(root, &ts[1], "src/lib.rs", &configs, src).unwrap();
assert!(!c.args.iter().any(|a| a == "-p"), "outside every member: the workspace's tests, {:?}", c.args);
}
#[test] #[test]
fn cargo_test_args_get_one_separator() { fn cargo_test_args_get_one_separator() {
let root = Path::new("/p"); let root = Path::new("/p");
+131 -2
View File
@@ -38,6 +38,14 @@ const LINE_WINDOW: usize = 500;
const PATHS_DESCRIPTION: &str = "Optional list of project-relative glob patterns to filter results. Supports '!' \ const PATHS_DESCRIPTION: &str = "Optional list of project-relative glob patterns to filter results. Supports '!' \
excludes. Trailing '/' expands to '**'. Patterns without '/' are treated as '**/pattern'. Empty strings are ignored."; excludes. Trailing '/' expands to '**'. Patterns without '/' are treated as '**/pattern'. Empty strings are ignored.";
const LIMIT_DESCRIPTION: &str = "Maximum number of results to return"; const LIMIT_DESCRIPTION: &str = "Maximum number of results to return";
const CONTEXT_DESCRIPTION: &str = "Lines around each match to return besides the matched line (`lineText`): \"+N\" the \
N lines after it (`contextAfter`), \"-N\" the N lines before it (`contextBefore`), \"N\" (or the number N) N lines on \
each side. At most 50. Default: the matched line only.";
/// A matched line longer than this (UTF-16 units) is cut to the match and [`SNIPPET_AROUND`] units
/// on each side of it; a longer context line to its start.
const MAX_LINE_TEXT: usize = 300;
const SNIPPET_AROUND: usize = 100;
const MAX_CONTEXT_LINES: u32 = 50;
pub fn tools() -> Vec<Tool> { pub fn tools() -> Vec<Tool> {
vec![search_text(), search_regex(), search_symbol(), search_file()] vec![search_text(), search_regex(), search_symbol(), search_file()]
@@ -49,6 +57,8 @@ fn search_text() -> Tool {
"Searches for a text substring within project files. "Searches for a text substring within project files.
Use this tool for fast text search with match coordinates. Use this tool for fast text search with match coordinates.
Results include match coordinates when available (1-based line/column, end exclusive). Results include match coordinates when available (1-based line/column, end exclusive).
Each result has the matched line (`lineText`; a long line cut to the match and 100 characters on each side, marked
with …), and with `context` the lines around it.
Paths are glob patterns relative to the project root. Paths are glob patterns relative to the project root.
Examples: [\"src/**\", \"!**/test/**\"], [\"**/*.kt\"], [\"foo/\"].", Examples: [\"src/**\", \"!**/test/**\"], [\"**/*.kt\"], [\"foo/\"].",
@@ -57,6 +67,7 @@ Examples: [\"src/**\", \"!**/test/**\"], [\"**/*.kt\"], [\"foo/\"].",
.param(Param::required("q", ParamType::String, "Text to search for")) .param(Param::required("q", ParamType::String, "Text to search for"))
.param(Param::optional("paths", ParamType::StringList, PATHS_DESCRIPTION)) .param(Param::optional("paths", ParamType::StringList, PATHS_DESCRIPTION))
.param(Param::optional("limit", ParamType::Integer, LIMIT_DESCRIPTION)) .param(Param::optional("limit", ParamType::Integer, LIMIT_DESCRIPTION))
.param(Param::optional("context", ParamType::String, CONTEXT_DESCRIPTION))
.output(result_schema()) .output(result_schema())
} }
@@ -66,6 +77,8 @@ fn search_regex() -> Tool {
"Searches for regex matches within project files. "Searches for regex matches within project files.
Use this tool when you need regex search with match coordinates. Use this tool when you need regex search with match coordinates.
Results include match coordinates when available (1-based line/column, end exclusive). Results include match coordinates when available (1-based line/column, end exclusive).
Each result has the matched line (`lineText`; a long line cut to the match and 100 characters on each side, marked
with …), and with `context` the lines around it.
The pattern uses Rust regex syntax and is case-sensitive; prefix `(?i)` to ignore case. The pattern uses Rust regex syntax and is case-sensitive; prefix `(?i)` to ignore case.
Paths are glob patterns relative to the project root. Paths are glob patterns relative to the project root.
@@ -75,6 +88,7 @@ Examples: [\"src/**\", \"!**/test/**\"], [\"**/*.kt\"], [\"foo/\"].",
.param(Param::required("q", ParamType::String, "Regex pattern to search for")) .param(Param::required("q", ParamType::String, "Regex pattern to search for"))
.param(Param::optional("paths", ParamType::StringList, PATHS_DESCRIPTION)) .param(Param::optional("paths", ParamType::StringList, PATHS_DESCRIPTION))
.param(Param::optional("limit", ParamType::Integer, LIMIT_DESCRIPTION)) .param(Param::optional("limit", ParamType::Integer, LIMIT_DESCRIPTION))
.param(Param::optional("context", ParamType::String, CONTEXT_DESCRIPTION))
.output(result_schema()) .output(result_schema())
} }
@@ -84,6 +98,7 @@ fn search_symbol() -> Tool {
"Searches for symbols (classes, methods, fields). "Searches for symbols (classes, methods, fields).
Use this tool for semantic lookup by identifier fragments. Use this tool for semantic lookup by identifier fragments.
Results include match coordinates when available (1-based line/column, end exclusive). Results include match coordinates when available (1-based line/column, end exclusive).
Project results have their line (`lineText`), and with `context` the lines around it.
Paths are glob patterns relative to the project root. Paths are glob patterns relative to the project root.
By default this searches project symbols only. By default this searches project symbols only.
@@ -99,6 +114,7 @@ If you don't find a suitable result, try again with include_external=true to sea
with include_external=true.", with include_external=true.",
)) ))
.param(Param::optional("limit", ParamType::Integer, LIMIT_DESCRIPTION)) .param(Param::optional("limit", ParamType::Integer, LIMIT_DESCRIPTION))
.param(Param::optional("context", ParamType::String, CONTEXT_DESCRIPTION))
.output(result_schema()) .output(result_schema())
} }
@@ -145,6 +161,13 @@ struct SearchItem {
name: Option<String>, name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
container: Option<String>, container: Option<String>,
/// The text of `start_line` (cut when long).
#[serde(skip_serializing_if = "Option::is_none")]
line_text: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
context_before: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
context_after: Vec<String>,
} }
impl SearchItem { impl SearchItem {
@@ -182,7 +205,9 @@ fn result_schema() -> Value {
"type": "object", "type": "object",
"properties": { "properties": {
"filePath": string, "startLine": int, "startColumn": int, "endLine": int, "endColumn": int, "filePath": string, "startLine": int, "startColumn": int, "endLine": int, "endColumn": int,
"name": string, "container": string, "name": string, "container": string, "lineText": string,
"contextBefore": { "type": "array", "items": string },
"contextAfter": { "type": "array", "items": string },
}, },
"required": ["filePath"], "required": ["filePath"],
} }, } },
@@ -207,6 +232,87 @@ fn query(call: &Call, empty: &str) -> IdeResult<String> {
Ok(q) Ok(q)
} }
// --- matched lines -------------------------------------------------------------
/// The `context` argument as (lines before, lines after).
fn context(call: &Call) -> IdeResult<(u32, u32)> {
let invalid = || IdeError::invalid("context must be \"+N\", \"-N\" or N (lines after, before, or on each side)");
let (sign, n) = match call.args.get("context") {
None | Some(Value::Null) => return Ok((0, 0)),
Some(Value::Number(n)) => (None, n.as_u64().ok_or_else(invalid)?),
Some(Value::String(s)) => {
let s = s.trim();
match s.strip_prefix('+').map(|r| ('+', r)).or_else(|| s.strip_prefix('-').map(|r| ('-', r))) {
Some((sign, rest)) => (Some(sign), rest.trim().parse().map_err(|_| invalid())?),
None => (None, s.parse().map_err(|_| invalid())?),
}
}
Some(_) => return Err(invalid()),
};
let n = (n.min(MAX_CONTEXT_LINES as u64)) as u32;
Ok(match sign {
Some('+') => (0, n),
Some(_) => (n, 0),
None => (n, n),
})
}
/// `line`, or when longer than [`MAX_LINE_TEXT`] UTF-16 units, the part from [`SNIPPET_AROUND`]
/// units before 0-based UTF-16 column `start` to as many after `end`, with `…` where it was cut.
fn snippet(line: &str, start: usize, end: usize) -> String {
let units: usize = line.chars().map(char::len_utf16).sum();
if units <= MAX_LINE_TEXT {
return line.to_owned();
}
let from = start.saturating_sub(SNIPPET_AROUND);
let to = end.max(start).saturating_add(SNIPPET_AROUND).min(units);
let (mut out, mut at) = (String::new(), 0);
for c in line.chars() {
if at >= from && at < to {
out.push(c);
}
at += c.len_utf16();
}
let before = if from > 0 { "\u{2026}" } else { "" };
let after = if to < units { "\u{2026}" } else { "" };
format!("{before}{out}{after}")
}
/// A context line: its first [`MAX_LINE_TEXT`] UTF-16 units.
fn context_line(line: &str) -> String {
snippet(line, 0, MAX_LINE_TEXT.saturating_sub(SNIPPET_AROUND))
}
/// Fills `line_text` and the context of `items`, whose files are `absolute(item)` (none: left
/// as they are); each file is read once, with unsaved editor text.
async fn fill_lines(
call: &Call,
items: &mut [SearchItem],
absolute: impl Fn(&SearchItem) -> Option<PathBuf>,
(before, after): (u32, u32),
) {
let host = call.host();
let mut current: Option<(PathBuf, Option<Vec<String>>)> = None;
for item in items {
let (Some(path), Some(line)) = (absolute(item), item.start_line) else { continue };
if current.as_ref().is_none_or(|(p, _)| p != &path) {
let text = crate::ops::fs::read_text(&host, call.window(), path.clone()).await.ok();
let lines = text.map(|t| t.split('\n').map(|l| l.strip_suffix('\r').unwrap_or(l).to_owned()).collect());
current = Some((path, lines));
}
let Some((_, Some(lines))) = &current else { continue };
let index = line as usize - 1;
let Some(text) = lines.get(index) else { continue };
let start = item.start_column.unwrap_or(1) as usize - 1;
let end = if item.end_line == Some(line) { item.end_column.map_or(start, |c| c as usize - 1) } else { start };
item.line_text = Some(snippet(text, start, end));
let first = index.saturating_sub(before as usize);
item.context_before = lines[first..index].iter().map(|l| context_line(l)).collect();
let last = (index + after as usize).min(lines.len() - 1);
item.context_after = lines[index + 1..=last].iter().map(|l| context_line(l)).collect();
}
}
// --- path globs ---------------------------------------------------------------- // --- path globs ----------------------------------------------------------------
/// The `paths` argument: project-relative globs, `!` excludes. /// The `paths` argument: project-relative globs, `!` excludes.
@@ -416,6 +522,7 @@ struct TextHit {
async fn text_search(call: Call, is_regex: bool) -> IdeResult<Output> { async fn text_search(call: Call, is_regex: bool) -> IdeResult<Output> {
let pattern = query(&call, if is_regex { "Regex pattern is empty" } else { "Search text is empty" })?; let pattern = query(&call, if is_regex { "Regex pattern is empty" } else { "Search text is empty" })?;
let limit = limit(&call, DEFAULT_LIMIT)?; let limit = limit(&call, DEFAULT_LIMIT)?;
let around = context(&call)?;
let scope = PathScope::new(call.root(), &call.str_list("paths")?)?; let scope = PathScope::new(call.root(), &call.str_list("paths")?)?;
// Globs Find in Files cannot take are applied afterwards, so the engine must not // Globs Find in Files cannot take are applied afterwards, so the engine must not
// stop at `limit` matches the filter may drop. // stop at `limit` matches the filter may drop.
@@ -494,7 +601,7 @@ async fn text_search(call: Call, is_regex: bool) -> IdeResult<Output> {
let more = hits.len() > limit || summary.limit_hit || timed_out; let more = hits.len() > limit || summary.limit_hit || timed_out;
hits.truncate(limit); hits.truncate(limit);
extend_spanning(&call, &query, &mut hits, spanning).await; extend_spanning(&call, &query, &mut hits, spanning).await;
let items = hits let mut items: Vec<SearchItem> = hits
.into_iter() .into_iter()
.map(|h| SearchItem { .map(|h| SearchItem {
file_path: h.file_path, file_path: h.file_path,
@@ -505,6 +612,9 @@ async fn text_search(call: Call, is_regex: bool) -> IdeResult<Output> {
..Default::default() ..Default::default()
}) })
.collect(); .collect();
let absolute: std::collections::HashMap<&str, &Path> =
files.iter().map(|(path, file)| (path.as_str(), file.path.0.as_path())).collect();
fill_lines(&call, &mut items, |item| absolute.get(item.file_path.as_str()).map(|p| p.to_path_buf()), around).await;
let partial_result_reason = timed_out.then_some(SEARCH_TIMEOUT_PARTIAL_RESULT_REASON); let partial_result_reason = timed_out.then_some(SEARCH_TIMEOUT_PARTIAL_RESULT_REASON);
Output::json(SearchResult { items, more, partial_result_reason }) Output::json(SearchResult { items, more, partial_result_reason })
} }
@@ -726,6 +836,7 @@ async fn symbol_search(call: Call) -> IdeResult<Output> {
let started = Instant::now(); let started = Instant::now();
let q = query(&call, "Search query is empty")?; let q = query(&call, "Search query is empty")?;
let limit = limit(&call, SYMBOL_SEARCH_DEFAULT_LIMIT)?; let limit = limit(&call, SYMBOL_SEARCH_DEFAULT_LIMIT)?;
let around = context(&call)?;
let scope = PathScope::new(call.root(), &call.str_list("paths")?)?; let scope = PathScope::new(call.root(), &call.str_list("paths")?)?;
let external = call.flag("include_external", false)?; let external = call.flag("include_external", false)?;
let host = call.host(); let host = call.host();
@@ -775,6 +886,13 @@ async fn symbol_search(call: Call) -> IdeResult<Output> {
} }
let more = items.len() > limit || timed_out; let more = items.len() > limit || timed_out;
items.truncate(limit); items.truncate(limit);
// Project files (relative paths, or absolute ones of content roots); library URIs are left out.
let root = call.root().to_path_buf();
let absolute = |item: &SearchItem| {
let path = Path::new(&item.file_path);
(!item.file_path.contains("://")).then(|| if path.is_absolute() { path.to_path_buf() } else { root.join(path) })
};
fill_lines(&call, &mut items, absolute, around).await;
let indexing = indexing_before || project.index.status().indexing; let indexing = indexing_before || project.index.status().indexing;
let partial_result_reason = if indexing { let partial_result_reason = if indexing {
Some(INDEXING_PARTIAL_RESULT_REASON) Some(INDEXING_PARTIAL_RESULT_REASON)
@@ -869,6 +987,17 @@ mod tests {
assert_eq!(byte_at("ab", 5), None); assert_eq!(byte_at("ab", 5), None);
} }
#[test]
fn long_lines_are_cut_around_the_match() {
assert_eq!(snippet("short line", 0, 5), "short line");
let line = format!("{}needle{}", "a".repeat(400), "b".repeat(400));
let cut = snippet(&line, 400, 406);
assert_eq!(cut, format!("\u{2026}{}needle{}\u{2026}", "a".repeat(100), "b".repeat(100)));
let start = snippet(&line, 0, 0);
assert!(start.starts_with('a') && start.ends_with('\u{2026}') && start.chars().count() == 101);
assert_eq!(context_line(&"x".repeat(1000)).chars().count(), MAX_LINE_TEXT + 1);
}
#[test] #[test]
fn path_scopes() { fn path_scopes() {
let (_dir, root) = root(); let (_dir, root) = root();
+49 -3
View File
@@ -313,10 +313,11 @@ pub async fn build_sync(
if tool == BuildTool::Gradle { if tool == BuildTool::Gradle {
// The sync may report project folders outside the project (`includeFlat`): index and list them. // The sync may report project folders outside the project (`includeFlat`): index and list them.
project.refresh_index_scope(); project.refresh_index_scope();
project.sink.emit(ide_core::IdeEvent::ProjectRootsChanged { root: project.root.clone().into() });
let p = project.clone(); let p = project.clone();
tokio::task::spawn_blocking(move || p.refresh_repositories()); tokio::task::spawn_blocking(move || p.refresh_repositories());
} }
// New source folders and dependency versions: the Project view and External Libraries reload.
project.sink.emit(ide_core::IdeEvent::ProjectRootsChanged { root: project.root.clone().into() });
if tool == BuildTool::Maven if tool == BuildTool::Maven
&& model.maven.is_some() && model.maven.is_some()
&& let Some(bridge) = &project.kotlin_bridge && let Some(bridge) = &project.kotlin_bridge
@@ -455,7 +456,8 @@ pub async fn maven_dependency_declaration(
/// absolute for a parent POM opened from the local repository): the definition of the /// absolute for a parent POM opened from the local repository): the definition of the
/// `${property}` at the caret, or the `<dependencyManagement>` / `<pluginManagement>` /// `${property}` at the caret, or the `<dependencyManagement>` / `<pluginManagement>`
/// entry providing the dependency's / plugin's version, along the parent chain (into /// 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. /// resolvable is under the caret.
pub async fn maven_goto_declaration(host: &Host, window: &str, pom: String, pos: Pos) -> IdeResult<Option<PomGoto>> { pub async fn maven_goto_declaration(host: &Host, window: &str, pom: String, pos: Pos) -> IdeResult<Option<PomGoto>> {
let project = host.project(window)?; let project = host.project(window)?;
@@ -466,7 +468,19 @@ pub async fn maven_goto_declaration(host: &Host, window: &str, pom: String, pos:
let settings = project.settings.effective(); let settings = project.settings.effective();
let repo = ide_sdk::apply::maven_local_repository(&project.root, &settings) let repo = ide_sdk::apply::maven_local_repository(&project.root, &settings)
.or_else(ide_build::maven::default_local_repo); .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 .await
.map_err(join_err)? .map_err(join_err)?
@@ -971,6 +985,38 @@ mod build_tool_settings_tests {
} }
} }
/// Changing Project JDK updates the next Maven build without reopening the project.
#[test]
fn changing_project_jdk_updates_the_next_maven_build() {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
std::fs::write(
root.join("pom.xml"),
r#"<project><modelVersion>4.0.0</modelVersion><groupId>test</groupId><artifactId>app</artifactId><version>1</version></project>"#,
)
.unwrap();
for (name, version) in [("jdk8", "1.8"), ("jdk21", "21")] {
let home = root.join(name);
std::fs::create_dir_all(home.join("bin")).unwrap();
std::fs::write(home.join("bin").join(if cfg!(windows) { "java.exe" } else { "java" }), "").unwrap();
std::fs::write(home.join("release"), format!("JAVA_VERSION=\"{version}\"\n")).unwrap();
}
let mut settings = Settings::default();
settings.languages.java.maven.runner_jre.clear();
for name in ["jdk8", "jdk21"] {
settings.languages.java.jdk = name.into();
let expected = root.join(name);
let model = load(&root, &root.join("cache"), &settings);
assert_eq!(model.maven.as_ref().unwrap().projects[0].env["JAVA_HOME"], expected.to_string_lossy());
let plan = compile::plan(&root, &model, BuildKind::Build, None, None).unwrap();
assert!(!plan.steps.is_empty());
for step in plan.steps {
assert_eq!(step.env["JAVA_HOME"], expected.to_string_lossy());
}
}
}
#[test] #[test]
fn model_for_sync_tasks_and_compile_uses_gradle_settings() { fn model_for_sync_tasks_and_compile_uses_gradle_settings() {
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
+97 -27
View File
@@ -96,18 +96,19 @@ async fn resolve_launch(
let cancel = cancel.clone(); let cancel = cancel.clone();
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
let _external_build = external_build; let _external_build = external_build;
let local_repo = ide_sdk::apply::maven_local_repository(&project.root, &settings);
let built = if delegated { let built = if delegated {
let policy = delegated_policy(policy, prefer_maven, info.error_count); let rebuild_on_jdt_errors = rebuild_on_jdt_errors(policy, prefer_maven, info.error_count);
let outputs = project.java_launch_snapshots.lock().build( let outputs = project.java_launch_snapshots.lock().build(
&project.root, &project.root,
&model, &model,
&module, &module,
cache::BuildOptions { test, policy }, cache::BuildOptions { test, policy, rebuild_on_jdt_errors },
ide_sdk::apply::maven_local_repository(&project.root, &settings), local_repo.clone(),
&cancel, &cancel,
)?; )?;
let copies = use_snapshot(&mut info, &outputs.originals, &outputs.snapshot); let copies = use_snapshot(&mut info, &outputs.originals, &outputs.snapshot);
apply_external_build_result(&mut info, outputs.failure, policy == BuildPolicy::Rebuild); apply_external_build_result(&mut info, outputs.failure, outputs.authoritative);
copies copies
} else if external { } else if external {
let built = compile::build_for_launch(&project.root, &model, &module, test, build_kind, &cancel)?; let built = compile::build_for_launch(&project.root, &model, &module, test, build_kind, &cancel)?;
@@ -123,10 +124,22 @@ async fn resolve_launch(
Vec::new() Vec::new()
}; };
let mut class_paths: Vec<String> = built.iter().map(|p| p.to_string_lossy().into_owned()).collect(); let mut class_paths: Vec<String> = built.iter().map(|p| p.to_string_lossy().into_owned()).collect();
for p in std::mem::take(&mut info.class_paths) { if delegated {
if !class_paths.iter().any(|c| same_entry(c, &p, cfg!(windows))) { // m2e runs inside jdtls on the server JDK and can resolve a different
class_paths.push(p); // Maven profile/classifier than the module build. Ask the same Maven
} // process that built the module for its runtime/test dependencies.
let maven_cp = compile::module_classpath(
&project.root,
&model,
&module,
test,
local_repo,
&project.build_cache_dir(),
&cancel,
);
merge_maven_launch_classpath(&mut class_paths, &mut info, maven_cp)?;
} else {
append_unique_class_paths(&mut class_paths, std::mem::take(&mut info.class_paths));
} }
info.class_paths = class_paths; info.class_paths = class_paths;
apply_library_classpath(&mut info, &project.root, &settings.project_structure.libraries, test); apply_library_classpath(&mut info, &project.root, &settings.project_structure.libraries, test);
@@ -191,21 +204,18 @@ async fn resolve_launch(
/// With Settings › Maven › Delegate IDE build/run actions to Maven, javac's verdict replaces /// With Settings › Maven › Delegate IDE build/run actions to Maven, javac's verdict replaces
/// JDT's. Classes JDT wrote for sources with errors look up to date to Maven's incremental /// JDT's. Classes JDT wrote for sources with errors look up to date to Maven's incremental
/// compiler, so a launch JDT reports errors for recompiles everything. /// compiler, so a launch JDT reports errors for recompiles everything.
fn delegated_policy(policy: BuildPolicy, prefer_maven: bool, jdt_errors: u32) -> BuildPolicy { fn rebuild_on_jdt_errors(policy: BuildPolicy, prefer_maven: bool, jdt_errors: u32) -> bool {
if prefer_maven && jdt_errors > 0 && matches!(policy, BuildPolicy::Automatic | BuildPolicy::Make) { prefer_maven && jdt_errors > 0 && matches!(policy, BuildPolicy::Automatic | BuildPolicy::Make)
BuildPolicy::Rebuild
} else {
policy
}
} }
fn apply_external_build_result(info: &mut JavaLaunchInfo, failure: Option<String>, clean: bool) { fn apply_external_build_result(info: &mut JavaLaunchInfo, failure: Option<String>, authoritative: bool) {
info.build = Some(if let Some(failure) = failure { info.build = Some(if let Some(failure) = failure {
info.hints.push(failure); info.hints.push(failure);
JavaBuildStatus::Failed JavaBuildStatus::Failed
} else if clean { } else if authoritative {
// A clean external build recompiled every source; JDT's diagnostics still describe // A clean rebuild or a fingerprint-verified snapshot is authoritative. JDT diagnostics
// the previous output (and reject sources javac accepts, e.g. a package mismatch). // may describe an older output or its own classpath, so they must not mark this launch
// as failed after Maven has produced usable classes.
info.errors.clear(); info.errors.clear();
info.error_count = 0; info.error_count = 0;
JavaBuildStatus::Succeeded JavaBuildStatus::Succeeded
@@ -286,6 +296,44 @@ fn same_entry(a: &str, b: &str, windows: bool) -> bool {
a == b || entry_key(a, windows) == entry_key(b, windows) a == b || entry_key(a, windows) == entry_key(b, windows)
} }
/// Merge the Maven launch classpath without turning a recoverable probe failure into a
/// failed launch. Cancellation still propagates; ordinary failures keep jdtls' classpath
/// and leave the reason in the run console.
fn merge_maven_launch_classpath(
class_paths: &mut Vec<String>,
info: &mut JavaLaunchInfo,
classpath: IdeResult<String>,
) -> IdeResult<()> {
match classpath {
Ok(classpath) => {
append_unique_class_paths(
class_paths,
std::env::split_paths(&classpath).map(|path| path.to_string_lossy().into_owned()),
);
Ok(())
}
Err(error) if error.kind == ErrorKind::Cancelled => Err(error),
Err(error) => {
info.hints.push(format!(
"Maven launch classpath resolution failed; using the Java language server classpath instead:\n{}",
error.message
));
append_unique_class_paths(class_paths, std::mem::take(&mut info.class_paths));
Ok(())
}
}
}
/// Append entries without changing their precedence; `same_entry` also catches
/// Windows case-insensitive duplicates that would otherwise lengthen the command.
fn append_unique_class_paths(class_paths: &mut Vec<String>, entries: impl IntoIterator<Item = String>) {
for entry in entries {
if !class_paths.iter().any(|existing| same_entry(existing, &entry, cfg!(windows))) {
class_paths.push(entry);
}
}
}
fn use_snapshot(info: &mut JavaLaunchInfo, built: &[PathBuf], snapshot: &std::path::Path) -> Vec<PathBuf> { fn use_snapshot(info: &mut JavaLaunchInfo, built: &[PathBuf], snapshot: &std::path::Path) -> Vec<PathBuf> {
use_snapshot_on(info, built, snapshot, cfg!(windows)) use_snapshot_on(info, built, snapshot, cfg!(windows))
} }
@@ -410,14 +458,36 @@ mod tests {
#[test] #[test]
fn delegated_launches_with_jdt_errors_recompile_with_maven() { fn delegated_launches_with_jdt_errors_recompile_with_maven() {
assert_eq!(delegated_policy(BuildPolicy::Make, true, 1), BuildPolicy::Rebuild); assert!(rebuild_on_jdt_errors(BuildPolicy::Make, true, 1));
assert_eq!(delegated_policy(BuildPolicy::Automatic, true, 2), BuildPolicy::Rebuild); assert!(rebuild_on_jdt_errors(BuildPolicy::Automatic, true, 2));
assert_eq!(delegated_policy(BuildPolicy::Make, true, 0), BuildPolicy::Make); assert!(!rebuild_on_jdt_errors(BuildPolicy::Make, true, 0));
assert_eq!(delegated_policy(BuildPolicy::Make, false, 1), BuildPolicy::Make); assert!(!rebuild_on_jdt_errors(BuildPolicy::Make, false, 1));
assert_eq!( assert!(!rebuild_on_jdt_errors(BuildPolicy::MakeAndContinueOnErrors, true, 1));
delegated_policy(BuildPolicy::MakeAndContinueOnErrors, true, 1), }
BuildPolicy::MakeAndContinueOnErrors
); #[test]
fn maven_classpath_probe_failure_falls_back_to_jdtls_classpaths() {
let maven = std::env::join_paths(["maven.jar", "snapshot.jar"]).unwrap().to_string_lossy().into_owned();
let mut class_paths = vec!["snapshot.jar".to_owned()];
let mut info =
JavaLaunchInfo { class_paths: vec!["snapshot.jar".into(), "jdtls.jar".into()], ..Default::default() };
merge_maven_launch_classpath(&mut class_paths, &mut info, Ok(maven)).unwrap();
assert_eq!(class_paths, ["snapshot.jar", "maven.jar"]);
merge_maven_launch_classpath(&mut class_paths, &mut info, Err(IdeError::internal("offline dependency")))
.unwrap();
assert_eq!(class_paths, ["snapshot.jar", "maven.jar", "jdtls.jar"]);
assert!(info.hints[0].contains("offline dependency"), "{:?}", info.hints);
}
#[test]
fn cancelled_maven_classpath_probe_propagates_without_fallback() {
let mut class_paths = vec!["snapshot.jar".to_owned()];
let mut info = JavaLaunchInfo { class_paths: vec!["jdtls.jar".into()], ..Default::default() };
let error = merge_maven_launch_classpath(&mut class_paths, &mut info, Err(IdeError::cancelled())).unwrap_err();
assert_eq!(error.kind, ErrorKind::Cancelled);
assert_eq!(class_paths, ["snapshot.jar"]);
assert!(info.hints.is_empty());
} }
#[test] #[test]
@@ -461,7 +531,7 @@ mod tests {
} }
#[test] #[test]
fn clean_external_build_clears_stale_jdt_errors() { fn authoritative_external_result_clears_stale_jdt_errors() {
let mut info = JavaLaunchInfo { let mut info = JavaLaunchInfo {
error_count: 7, error_count: 7,
errors: vec![ide_lsp::jdtls::JavaCompileError { errors: vec![ide_lsp::jdtls::JavaCompileError {
+71 -4
View File
@@ -14,6 +14,10 @@ pub(super) struct Outputs {
pub originals: Vec<PathBuf>, pub originals: Vec<PathBuf>,
#[serde(skip)] #[serde(skip)]
pub failure: Option<String>, pub failure: Option<String>,
/// Whether this result replaces jdtls diagnostics with a clean Maven build or a
/// verified snapshot. It is runtime-only because reuse is determined per launch.
#[serde(skip)]
pub authoritative: bool,
} }
impl Outputs { impl Outputs {
@@ -42,6 +46,9 @@ pub(super) fn has_record(root: &Path, cache: &Path, module: &Path) -> bool {
pub(super) struct BuildOptions { pub(super) struct BuildOptions {
pub test: bool, pub test: bool,
pub policy: BuildPolicy, pub policy: BuildPolicy,
/// A JDT error makes a cache miss rebuild, but it must not prevent an unchanged
/// snapshot from being reused on the next launch.
pub rebuild_on_jdt_errors: bool,
} }
/// Owned by Project: running JVMs may still load superseded outputs until it drops. /// Owned by Project: running JVMs may still load superseded outputs until it drops.
@@ -75,7 +82,7 @@ impl Session {
cancel: &CancellationToken, cancel: &CancellationToken,
) -> IdeResult<Outputs> { ) -> IdeResult<Outputs> {
self.initialize(); self.initialize();
let BuildOptions { test, policy } = options; let BuildOptions { test, policy, rebuild_on_jdt_errors } = options;
let cache = &self.cache; let cache = &self.cache;
let record = let record =
record_path(root, cache, module, test).ok_or_else(|| IdeError::internal("launch outside project"))?; record_path(root, cache, module, test).ok_or_else(|| IdeError::internal("launch outside project"))?;
@@ -115,8 +122,14 @@ impl Session {
&& outputs.reusable(&before, &snapshots) && outputs.reusable(&before, &snapshots)
{ {
tracing::info!(module = %module.display(), test, "Reusing Maven launch snapshot"); tracing::info!(module = %module.display(), test, "Reusing Maven launch snapshot");
return Ok(previous.unwrap()); let mut outputs = previous.unwrap();
outputs.authoritative = true;
return Ok(outputs);
} }
// JDT can report errors from its own classpath while the last Maven snapshot is
// still valid. Reuse that snapshot first; only a cache miss needs the expensive
// clean rebuild that replaces JDT's error-stub classes.
let policy = if rebuild_on_jdt_errors { BuildPolicy::Rebuild } else { policy };
// Retain a non-reusable record even on failure, so removing the final mixed // Retain a non-reusable record even on failure, so removing the final mixed
// source still delegates to Maven to clean its old classes on the next launch. // source still delegates to Maven to clean its old classes on the next launch.
fs::create_dir_all(record.parent().unwrap())?; fs::create_dir_all(record.parent().unwrap())?;
@@ -156,7 +169,13 @@ impl Session {
if cancel.is_cancelled() { if cancel.is_cancelled() {
return Err(IdeError::cancelled()); return Err(IdeError::cancelled());
} }
let mut outputs = Outputs { fingerprint: None, snapshot, originals, failure: built.failure }; let mut outputs = Outputs {
fingerprint: None,
snapshot,
originals,
failure: built.failure,
authoritative: policy == BuildPolicy::Rebuild,
};
if outputs.failure.is_none() && before.is_some() && before == after { if outputs.failure.is_none() && before.is_some() && before == after {
outputs.fingerprint = before; outputs.fingerprint = before;
fs::create_dir_all(record.parent().unwrap())?; fs::create_dir_all(record.parent().unwrap())?;
@@ -334,6 +353,48 @@ mod tests {
); );
} }
#[test]
fn jdt_errors_reuse_an_unchanged_snapshot_and_rebuild_only_on_a_cache_miss() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let model = fixture(root);
let cancel = CancellationToken::new();
let mut session = Session::new(root.join("cache"));
let first = session.build(root, &model, root, BuildOptions::default(), None, &cancel).unwrap();
assert!(!first.authoritative);
let reused = session
.build(
root,
&model,
root,
BuildOptions { rebuild_on_jdt_errors: true, ..Default::default() },
None,
&cancel,
)
.unwrap();
assert_eq!(reused.snapshot, first.snapshot);
assert!(reused.authoritative);
assert_eq!(fs::read_to_string(root.join("calls")).unwrap().lines().count(), 1);
fs::write(root.join("src/main/kotlin/Main.kt"), "updated").unwrap();
let rebuilt = session
.build(
root,
&model,
root,
BuildOptions { rebuild_on_jdt_errors: true, ..Default::default() },
None,
&cancel,
)
.unwrap();
assert_ne!(rebuilt.snapshot, first.snapshot);
assert!(rebuilt.authoritative);
let calls = fs::read_to_string(root.join("calls")).unwrap();
assert_eq!(calls.lines().count(), 2);
assert!(calls.lines().last().unwrap().contains("clean"));
}
#[test] #[test]
fn successful_snapshots_reuse_across_close_and_rebuild_for_changes_missing_outputs_or_failure() { fn successful_snapshots_reuse_across_close_and_rebuild_for_changes_missing_outputs_or_failure() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
@@ -483,7 +544,13 @@ mod tests {
fs::write(snapshots.join("launch-file"), "keep").unwrap(); fs::write(snapshots.join("launch-file"), "keep").unwrap();
let record = cache.join("java-launch/records/deep/module/test.json"); let record = cache.join("java-launch/records/deep/module/test.json");
fs::create_dir_all(record.parent().unwrap()).unwrap(); fs::create_dir_all(record.parent().unwrap()).unwrap();
let outputs = Outputs { fingerprint: None, snapshot: retained.clone(), originals: Vec::new(), failure: None }; let outputs = Outputs {
fingerprint: None,
snapshot: retained.clone(),
originals: Vec::new(),
failure: None,
authoritative: false,
};
fs::write(record, serde_json::to_vec(&outputs).unwrap()).unwrap(); fs::write(record, serde_json::to_vec(&outputs).unwrap()).unwrap();
let mut session = Session::new(cache); let mut session = Session::new(cache);
session.initialize(); session.initialize();
+27 -12
View File
@@ -1483,23 +1483,38 @@ pub async fn lsp_restart_named(host: &Host, name: &str) -> IdeResult<()> {
pub async fn apply_workspace_edit(host: &Host, window: &str, edit: WorkspaceEdit) -> IdeResult<()> { pub async fn apply_workspace_edit(host: &Host, window: &str, edit: WorkspaceEdit) -> IdeResult<()> {
let project = host.project(window)?; let project = host.project(window)?;
blocking(move || { blocking(move || {
// Multi-file refactorings leave a Local History point to go back to. record_before_multi_file_edit(&project, &edit);
let paths: Vec<&std::path::Path> = edit
.files
.iter()
.filter_map(|f| match f {
ide_core::FileEdit::Edit { path, .. } => Some(&**path),
_ => None,
})
.collect();
if edit.files.len() > 1 {
project.record_before_refactoring(&paths, "Apply changes");
}
apply_edit(&project.documents, &edit) apply_edit(&project.documents, &edit)
}) })
.await .await
} }
/// [`apply_workspace_edit`] for the MCP server's tools: undoable the same way, but the UI does
/// not announce it as a refactoring.
pub async fn apply_agent_workspace_edit(host: &Host, window: &str, edit: WorkspaceEdit) -> IdeResult<()> {
let project = host.project(window)?;
blocking(move || {
record_before_multi_file_edit(&project, &edit);
project.documents.apply_agent_workspace_edit(&edit)
})
.await
}
/// Multi-file edits leave a Local History point to go back to.
fn record_before_multi_file_edit(project: &crate::Project, edit: &WorkspaceEdit) {
let paths: Vec<&std::path::Path> = edit
.files
.iter()
.filter_map(|f| match f {
ide_core::FileEdit::Edit { path, .. } => Some(&**path),
_ => None,
})
.collect();
if edit.files.len() > 1 {
project.record_before_refactoring(&paths, "Apply changes");
}
}
pub async fn prepare_workspace_edit(host: &Host, window: &str, mut edit: WorkspaceEdit) -> IdeResult<WorkspaceEdit> { pub async fn prepare_workspace_edit(host: &Host, window: &str, mut edit: WorkspaceEdit) -> IdeResult<WorkspaceEdit> {
let project = host.project(window)?; let project = host.project(window)?;
blocking(move || { blocking(move || {
+20 -1
View File
@@ -234,7 +234,19 @@ pub async fn start_request(
) -> IdeResult<RunInfo> { ) -> IdeResult<RunInfo> {
let cancel = request.map(|id| host.begin_request(id)).unwrap_or_default(); let cancel = request.map(|id| host.begin_request(id)).unwrap_or_default();
let operation = config.extras.params.get("plugin.operation").cloned(); let operation = config.extras.params.get("plugin.operation").cloned();
let result = start_inner(host, window, config, on_output, &cancel).await; let mut config = config;
let agent = match host.project(window) {
Ok(project) => host.mcp_agent_runs.launch(&project.root, window, &mut config),
Err(_) => None,
};
let on_output = match &agent {
Some(agent) => agent.wrap_output(on_output),
None => on_output,
};
let result = start_inner(host, window, config, on_output, agent.as_ref(), &cancel).await;
if let Some(agent) = &agent {
agent.started(&result);
}
if let Some(id) = request { if let Some(id) = request {
host.end_request(id); host.end_request(id);
} }
@@ -249,6 +261,7 @@ async fn start_inner(
window: &str, window: &str,
mut config: RunConfig, mut config: RunConfig,
on_output: Emit<OutputChunk>, on_output: Emit<OutputChunk>,
agent: Option<&crate::mcp::run::agent::Launch>,
cancel: &ide_core::CancellationToken, cancel: &ide_core::CancellationToken,
) -> IdeResult<RunInfo> { ) -> IdeResult<RunInfo> {
if cancel.is_cancelled() { if cancel.is_cancelled() {
@@ -282,6 +295,9 @@ async fn start_inner(
if let Some(plugins) = host.plugin_host() { if let Some(plugins) = host.plugin_host() {
sink = plugins.run_operation_sink(window, &mut config, sink)?; sink = plugins.run_operation_sink(window, &mut config, sink)?;
} }
if let Some(agent) = agent {
sink = agent.wrap_sink(sink);
}
if config.command.is_empty() { if config.command.is_empty() {
return Err(IdeError::invalid("run configuration has no command")); return Err(IdeError::invalid("run configuration has no command"));
} }
@@ -450,6 +466,7 @@ pub fn stop(host: &Host, window: &str, id: RunId) -> IdeResult<()> {
if let Some(plugins) = host.plugin_host() { if let Some(plugins) = host.plugin_host() {
plugins.run_operation_stopped(id); plugins.run_operation_stopped(id);
} }
host.mcp_agent_runs.stopping(id);
let settings = match host.project(window) { let settings = match host.project(window) {
Ok(p) => p.settings.effective(), Ok(p) => p.settings.effective(),
Err(_) => host.global_settings.effective(), Err(_) => host.global_settings.effective(),
@@ -461,7 +478,9 @@ pub fn stop(host: &Host, window: &str, id: RunId) -> IdeResult<()> {
host.processes.stop_with_grace(id, grace) host.processes.stop_with_grace(id, grace)
} }
/// Input the user typed into a run (the agent's goes to the process directly).
pub fn stdin(host: &Host, id: RunId, data: &str) -> IdeResult<()> { pub fn stdin(host: &Host, id: RunId, data: &str) -> IdeResult<()> {
host.mcp_agent_runs.user_input(id, data);
if host.remote.runs.contains(id) { if host.remote.runs.contains(id) {
return host.remote.runs.write_stdin(id, data); return host.remote.runs.write_stdin(id, data);
} }
+13 -6
View File
@@ -61,13 +61,16 @@ pub async fn sdk_python_project_interpreters(host: &Host, window: &str) -> IdeRe
host.require_trust(window)?; host.require_trust(window)?;
let current = host.project(window).ok().map(|p| p.root.clone()); let current = host.project(window).ok().map(|p| p.root.clone());
let config = host.config_dir.clone(); let config = host.config_dir.clone();
// Probing runs the project's interpreter (and its settings may name any program): only
// projects trusted in their own right, not ones last opened in Restricted Mode.
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()))
.filter(|root| host.trust.window_trusted(root).unwrap_or(false))
.collect();
tokio::task::spawn_blocking(move || { 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 // 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 // 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. // once: the recent list can hold ~30 projects, and each probe starts a process.
@@ -521,6 +524,10 @@ mod tests {
host.trust.decide(&root, true, false).unwrap(); host.trust.decide(&root, true, false).unwrap();
host.open_project("main", &root).unwrap(); host.open_project("main", &root).unwrap();
// A project last opened in Restricted Mode: its interpreter is never run.
host.trust.decide(&other, false, false).unwrap();
assert!(sdk_python_project_interpreters(&host, "main").await.unwrap().is_empty());
host.trust.decide(&other, true, false).unwrap();
let list = sdk_python_project_interpreters(&host, "main").await.unwrap(); let list = sdk_python_project_interpreters(&host, "main").await.unwrap();
assert_eq!(list.len(), 1, "{list:?}"); assert_eq!(list.len(), 1, "{list:?}");
assert_eq!(&*list[0].project, other.as_path()); assert_eq!(&*list[0].project, other.as_path());
+43 -2
View File
@@ -1531,6 +1531,19 @@ impl HostApi for PluginApi {
} }
Ok(()) 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( fn reply_content_tab(
&self, &self,
client: &str, client: &str,
@@ -4112,6 +4125,11 @@ impl PluginApi {
if items.len() > 10_000 || file.as_ref().and_then(|f| f.badge.as_ref()).is_some_and(|b| b.chars().count() > 2) { if items.len() > 10_000 || file.as_ref().and_then(|f| f.badge.as_ref()).is_some_and(|b| b.chars().count() > 2) {
return Err(HostError::Invalid("decoration limit exceeded".into())); return Err(HostError::Invalid("decoration limit exceeded".into()));
} }
if let Some(note) = file.as_ref().and_then(|f| f.note.as_ref())
&& (note.trim().is_empty() || note.chars().count() > 200 || note.contains(['\n', '\r']))
{
return Err(HostError::Invalid("a file note is one non-empty line of at most 200 characters".into()));
}
items.retain(|d| decoration_style(&d.style)); items.retain(|d| decoration_style(&d.style));
if items.iter().any(|d| d.range.start > d.range.end) { if items.iter().any(|d| d.range.start > d.range.end) {
return Err(HostError::Invalid("reversed decoration range".into())); return Err(HostError::Invalid("reversed decoration range".into()));
@@ -4130,7 +4148,7 @@ impl PluginApi {
set_id: set.into(), set_id: set.into(),
tree, tree,
items, items,
file: file.filter(|f| decoration_style(&f.style)), file: file.filter(|f| f.note.is_some() || decoration_style(&f.style)),
}) })
} else { } else {
None None
@@ -4302,10 +4320,33 @@ mod workbench_tests {
api.file_decorations( api.file_decorations(
Some(&path), Some(&path),
"tree", "tree",
Some(ide_plugin::FileDecoration { badge: Some("abc".into()), style: "info".into(), tooltip: None }) Some(ide_plugin::FileDecoration {
badge: Some("abc".into()),
style: "info".into(),
tooltip: None,
note: None
})
) )
.is_err() .is_err()
); );
let note = |text: &str| ide_plugin::FileDecoration {
badge: None,
style: String::new(),
tooltip: Some("why".into()),
note: Some(text.into()),
};
api.file_decorations(Some(&path), "notes", Some(note("entry point"))).unwrap();
let notes = |api: &PluginApi| {
api.shared.decoration_sets()["test"]
.iter()
.filter(|s| s.tree && s.set_id == "notes")
.map(|s| s.file.as_ref().and_then(|f| f.note.clone()))
.collect::<Vec<_>>()
};
assert_eq!(notes(&api), [Some("entry point".to_string())], "kept without a style");
for bad in ["", "two\nlines", &"x".repeat(201)] {
assert!(api.file_decorations(Some(&path), "notes", Some(note(bad))).is_err(), "{bad:?}");
}
api.shared.clear_plugin("test", true); api.shared.clear_plugin("test", true);
assert!(api.shared.decoration_sets().is_empty()); assert!(api.shared.decoration_sets().is_empty());
} }
+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())); return Err(HostError::Invalid("content status or error too long".into()));
} }
if let Some(diff) = &reply.diff { if let Some(diff) = &reply.diff {
body(&diff.left)?; validate_diff(diff)?;
body(&diff.right)?; }
for value in [&diff.left_label, &diff.right_label].into_iter().flatten() { Ok(())
label(value, 128)?; }
}
if let Some(title) = &diff.title { pub(super) fn validate_diff(diff: &ide_plugin::ContentDiff) -> Result<(), HostError> {
label(title, 256)?; 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(()) Ok(())
} }
@@ -188,6 +188,7 @@ pub fn forwarded(event: &PluginEvent) -> bool {
PluginEvent::RunTargetsChanged PluginEvent::RunTargetsChanged
| PluginEvent::WindowRequest { .. } | PluginEvent::WindowRequest { .. }
| PluginEvent::ContentTabReply { .. } | PluginEvent::ContentTabReply { .. }
| PluginEvent::OpenDiff { .. }
| PluginEvent::DecorationsChanged { .. } | PluginEvent::DecorationsChanged { .. }
| PluginEvent::EditorOpen { .. } | PluginEvent::EditorOpen { .. }
| PluginEvent::EditorRanges { .. } | PluginEvent::EditorRanges { .. }
+3
View File
@@ -218,6 +218,9 @@ pub enum PluginEvent {
/// Reply to a content action in this window. /// Reply to a content action in this window.
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
ContentTabReply { plugin_id: String, request_id: String, reply: ide_plugin::ContentTabReply }, 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`. /// Plugins, their status or their contributions changed: re-fetch `plugin_list`.
CommandsChanged, CommandsChanged,
+178 -1
View File
@@ -112,6 +112,16 @@ async fn reads_unsaved_text_and_searches_the_overlay_with_a_limit() {
assert_eq!(found["more"], true); assert_eq!(found["more"], true);
let all = h.json("search_text", json!({"q":"fresh","limit":20})).await; let all = h.json("search_text", json!({"q":"fresh","limit":20})).await;
assert_eq!(all["items"][0]["filePath"], "src/a.txt"); assert_eq!(all["items"][0]["filePath"], "src/a.txt");
// The matched line comes with the result (unsaved text included), and lines around it on request.
assert_eq!(all["items"][0]["lineText"], "fresh needle text");
assert!(all["items"][0].get("contextAfter").is_none());
let after = h.json("search_text", json!({"q":"fresh","context":"+1"})).await;
assert_eq!(after["items"][0]["contextAfter"], json!(["needle needle"]));
let before = h.json("search_regex", json!({"q":"needle needle","context":"-1"})).await;
assert_eq!(before["items"][0]["lineText"], "needle needle");
assert_eq!(before["items"][0]["contextBefore"], json!(["fresh needle text"]));
let bad = h.error("search_text", json!({"q":"fresh","context":"around"})).await;
assert!(bad.contains("context must be"), "{bad}");
let all = h.json("search_text", json!({"q":"needle","limit":20})).await; let all = h.json("search_text", json!({"q":"needle","limit":20})).await;
assert!(all["items"].as_array().unwrap().iter().all(|v| !v["filePath"].as_str().unwrap().contains("ignored"))); assert!(all["items"].as_array().unwrap().iter().all(|v| !v["filePath"].as_str().unwrap().contains("ignored")));
let files = h.json("search_file", json!({"q":"*.txt","paths":["src/**"]})).await; let files = h.json("search_file", json!({"q":"*.txt","paths":["src/**"]})).await;
@@ -221,7 +231,11 @@ async fn mutations_preserve_unsaved_text_and_reject_metadata_and_escapes() {
.iter() .iter()
.rev() .rev()
.find_map(|event| match event { .find_map(|event| match event {
IdeEvent::WorkspaceEditApplied { id, .. } => Some(*id), // Undoable like a refactoring, but marked as the agent's, so it isn't announced as one.
IdeEvent::WorkspaceEditApplied { id, by_agent, .. } => {
assert!(*by_agent, "an MCP edit is the agent's");
Some(*id)
}
_ => None, _ => None,
}) })
.unwrap(); .unwrap();
@@ -453,3 +467,166 @@ async fn brave_mode_runs_tools_without_asking_only_from_the_global_settings() {
assert!(!format!("{result:?}").contains("denied"), "{result:?}"); assert!(!format!("{result:?}").contains("denied"), "{result:?}");
assert!(!h.events.lock().unwrap().iter().any(|e| matches!(e, IdeEvent::McpRequest { .. })), "nothing asked"); assert!(!h.events.lock().unwrap().iter().any(|e| matches!(e, IdeEvent::McpRequest { .. })), "nothing asked");
} }
#[tokio::test(flavor = "multi_thread")]
async fn check_files_says_why_a_file_could_not_be_checked() {
let h = Harness::new();
let untrusted = h.json("check_files", json!({"filePaths": ["src/a.txt"]})).await;
assert_eq!(untrusted["files"][0]["status"], "untrusted", "{untrusted}");
h.trust().await;
let files = h.json("check_files", json!({"filePaths": ["src/a.txt", "src/gone.txt"], "timeout": 5000})).await;
assert_eq!(files["files"][0]["filePath"], "src/a.txt");
assert_eq!(files["files"][0]["status"], "noLanguageServer", "{files}");
assert_eq!(files["files"][0]["problems"], json!([]));
assert_eq!(files["files"][1]["status"], "failed", "{files}");
assert!(files["files"][1]["error"].is_string());
// No cap of the tool's own: a very long timeout is taken as given, without overflowing.
let long = h.json("check_files", json!({"filePaths": ["src/a.txt"], "timeout": 9_000_000_000_000_i64})).await;
assert_eq!(long["files"][0]["status"], "noLanguageServer", "{long}");
let none = h.error("check_files", json!({"filePaths": []})).await;
assert!(none.contains("at least one file"), "{none}");
}
/// Answers `run_command`'s window requests the way the Run tool window does: starts the
/// agent tab's configuration (shell from the default) and says whether it started. Returns
/// the configurations it started, for reruns.
#[cfg(unix)]
fn run_tool_window(h: &Harness) -> Arc<std::sync::Mutex<Vec<ide_run::RunConfig>>> {
let started = Arc::new(std::sync::Mutex::new(Vec::new()));
let (host, events, out) = (h.host.clone(), h.events.clone(), started.clone());
tokio::spawn(async move {
let mut handled = std::collections::HashSet::new();
loop {
let request = events.lock().unwrap().iter().find_map(|e| match e {
IdeEvent::McpRequest {
id,
request: ide_core::event::McpUiRequest::AgentRun { name, command, cwd, env },
} if !handled.contains(id) => Some((*id, name.clone(), command.clone(), cwd.clone(), env.clone())),
_ => None,
});
let Some((id, name, command, cwd, env)) = request else {
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
continue;
};
handled.insert(id);
let mut config = ide_run::RunConfig {
id: format!("mcp-agent:{name}"),
name: name.clone(),
kind: "shell".into(),
command,
args: Vec::new(),
cwd: Some(cwd),
env,
detected: false,
is_test: false,
debug_adapter: None,
allow_parallel: true,
extras: Default::default(),
};
config.extras.params.insert(ide_host::mcp::AGENT_PARAM.into(), name);
config.extras.target = Some(String::new());
out.lock().unwrap().push(config.clone());
let result = ide_host::ops::run::start(&host, "main", config, Arc::new(|_| {})).await;
let error = result.err().map(|e| e.message);
let _ = host.mcp_requests.answer("main", id, error.is_none(), error);
}
});
started
}
#[cfg(unix)]
fn agent_process(h: &Harness, name: &str) -> ide_core::RunId {
let id = format!("mcp-agent:{name}");
h.host.processes.list().into_iter().filter(|r| r.config_id == id && r.running).map(|r| r.id).max().unwrap()
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread")]
async fn agent_commands_run_in_named_tabs_and_report_what_the_user_did() {
let h = Harness::new();
h.trust().await;
h.host.mcp_requests.set_ready("main", true);
h.host
.global_settings
.set(ide_workspace::SettingsScope::Global, "mcpServer.runWithoutConfirmation", json!(true))
.unwrap();
let started = run_tool_window(&h);
// A command that finishes within the wait, in a tab named with the prefix.
let done = h.json("run_command", json!({"name": "check", "command": "echo hel''lo; exit 3"})).await;
assert_eq!(done["name"], "_agent_check", "{done}");
assert_eq!(done["state"], "failed", "{done}");
assert_eq!(done["exitCode"], 3);
assert_eq!(done["startedBy"], "agent");
assert!(done["output"].as_str().unwrap().contains("hello"), "{done}");
assert!(done.get("interventions").is_none());
// The finished name runs the next command; a running one is refused, never stopped.
let running =
h.json("run_command", json!({"name": "_agent_check", "command": "echo ready; sleep 30", "timeout": 0})).await;
assert_eq!(running["state"], "running", "{running}");
let matched = h.json("wait_for_run", json!({"name": "check", "pattern": "(?m)^ready", "timeout": 10000})).await;
assert_eq!(matched["waitEnded"], "matched", "{matched}");
let refused = h.error("run_command", json!({"name": "check", "command": "true"})).await;
assert!(refused.contains("still running `echo ready; sleep 30`"), "{refused}");
assert!(refused.contains("Last output:\nready"), "the refusal quotes the last output: {refused}");
// What the user types (a line from the Run console's input) ends a wait at once, with advice for the agent.
let process = agent_process(&h, "_agent_check");
ide_host::ops::run::stdin(&h.host, process, "yes\n").unwrap();
let typed = h.json("wait_for_run", json!({"name": "check", "timeout": 10000})).await;
assert_eq!(typed["waitEnded"], "userIntervened", "{typed}");
assert_eq!(typed["interventions"][0]["message"], "User input: yes");
assert!(typed["advice"].as_str().unwrap().contains("Do not stop the process"));
let input = h.json("send_run_input", json!({"name": "check", "text": "abc", "keys": ["Enter"]})).await;
assert!(input.get("interventions").is_none(), "the agent's own input is no intervention: {input}");
let stopped = h.json("stop_run", json!({"name": "check", "force": true, "timeout": 10000})).await;
assert_eq!(stopped["state"], "stoppedByAgent", "{stopped}");
assert!(stopped.get("interventions").is_none());
// The user's Stop, rerun and closing the tab are reported with their time.
h.json("run_command", json!({"name": "server", "command": "sleep 30", "timeout": 0})).await;
ide_host::ops::run::stop(&h.host, "main", agent_process(&h, "_agent_server")).unwrap();
let stop = h.json("wait_for_run", json!({"name": "server", "timeout": 10000})).await;
assert!(stop["interventions"][0]["message"].as_str().unwrap().starts_with("The user stopped this command at"));
let ended = h.json("wait_for_run", json!({"name": "server", "timeout": 10000})).await;
assert_eq!(ended["state"], "stoppedByUser", "{ended}");
let config = started.lock().unwrap().last().cloned().unwrap();
ide_host::ops::run::start(&h.host, "main", config, Arc::new(|_| {})).await.unwrap();
let status = h.json("get_run_status", json!({"name": "server"})).await;
assert_eq!(status["startedBy"], "user", "{status}");
assert!(status["interventions"][0]["message"].as_str().unwrap().starts_with("The user reran this command at"));
h.json("stop_run", json!({"name": "server", "force": true, "timeout": 10000})).await;
h.host.mcp_agent_runs.closed(&h.root, "_agent_server");
let closed = h.json("get_run_status", json!({"name": "server"})).await;
assert!(closed["tabClosedAt"].is_string(), "{closed}");
assert!(closed["interventions"][0]["message"].as_str().unwrap().starts_with("The user closed this tab at"));
let log = h.json("read_run_output", json!({"name": "check", "maxLinesCount": 20})).await;
assert!(log["output"].as_str().unwrap().contains("ready"), "{log}");
let from_start = h.json("read_run_output", json!({"name": "check", "offset": 0})).await;
assert!(from_start["output"].as_str().unwrap().contains("hello"), "one log across the tab's commands");
let runs = h.json("list_runs", json!({})).await;
assert_eq!(runs["runs"].as_array().unwrap().len(), 2, "{runs}");
let missing = h.error("get_run_status", json!({"name": "nothing"})).await;
assert!(missing.contains("no agent tab named `_agent_nothing`"), "{missing}");
// Pipes: plain lines, cleaned of escapes and cut to maxLinesCount (the end stays by default).
let printf = "printf '\\033[31mone\\033[0m\\ntwo\\nthree\\n'";
let lines =
h.json("run_command", json!({"name": "lines", "command": printf, "maxLinesCount": 2, "timeout": 10000})).await;
assert_eq!(lines["output"], "<<<...content truncated...>>>\ntwo\nthree\n", "{lines}");
assert_eq!(lines["truncatedLines"], 1);
let head = h.json("read_run_output", json!({"name": "lines", "maxLinesCount": 1, "truncateMode": "END"})).await;
assert_eq!(head["output"], "one\n<<<...content truncated...>>>\n", "{head}");
// Ctrl+C stops the command as stop_run does; terminal keys have no meaning on pipes.
h.json("run_command", json!({"name": "interrupt", "command": "sleep 30", "timeout": 0})).await;
let key = h.error("send_run_input", json!({"name": "interrupt", "keys": ["Up"]})).await;
assert!(key.contains("runs on pipes"), "{key}");
h.json("send_run_input", json!({"name": "interrupt", "keys": ["Ctrl+C"]})).await;
let interrupted = h.json("wait_for_run", json!({"name": "interrupt", "timeout": 10000})).await;
assert_eq!(interrupted["state"], "stoppedByAgent", "{interrupted}");
}
+20
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()); 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] #[test]
fn api14_fixture_exports_test_selection() { fn api14_fixture_exports_test_selection() {
let f = fixture(Limits::default()); let f = fixture(Limits::default());
+71
View File
@@ -252,6 +252,77 @@ async fn maven_sync() {
assert!(!junit.children.is_empty(), "{junit:?}"); assert!(!junit.children.is_empty(), "{junit:?}");
} }
/// The launch classpath must come from the runner JDK, not the JDK that happened
/// to load the language-server model: JDK-activated profiles can select a
/// different dependency classifier. Use real Maven so this covers the scenario
/// rather than only the probe arguments.
#[test]
#[ignore = "project smoke: needs Maven and a JDK"]
fn maven_launch_classpath_uses_the_runner_jdk_classifier() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let java_home = PathBuf::from(std::env::var("JAVA_HOME").expect("JAVA_HOME"));
let release = std::fs::read_to_string(java_home.join("release")).unwrap();
let version = release
.lines()
.find_map(|line| line.strip_prefix("JAVA_VERSION=\""))
.and_then(|line| line.strip_suffix('"'))
.expect("JAVA_VERSION in release");
let major = version.strip_prefix("1.").unwrap_or(version).split(['.', '_']).next().unwrap().parse::<u32>().unwrap();
let jar = java_home.join(if cfg!(windows) { "bin/jar.exe" } else { "bin/jar" });
let jars = root.join("libs");
let staging = root.join("empty");
std::fs::create_dir_all(&jars).unwrap();
std::fs::create_dir_all(&staging).unwrap();
for name in ["api-jdk8.jar", "api-jdk9.jar"] {
let status = std::process::Command::new(&jar)
.args(["cf"])
.arg(jars.join(name))
.arg(".")
.current_dir(&staging)
.status()
.unwrap();
assert!(status.success());
}
std::fs::write(
root.join("pom.xml"),
r#"<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<groupId>demo</groupId><artifactId>jdk-classifier</artifactId><version>1</version>
<profiles>
<profile><id>jdk8</id><activation><jdk>1.8</jdk></activation><dependencies>
<dependency><groupId>demo</groupId><artifactId>api</artifactId><version>1</version><classifier>jdk8</classifier>
<scope>system</scope><systemPath>${project.basedir}/libs/api-jdk8.jar</systemPath></dependency>
</dependencies></profile>
<profile><id>jdk9</id><activation><jdk>[9,)</jdk></activation><dependencies>
<dependency><groupId>demo</groupId><artifactId>api</artifactId><version>1</version><classifier>jdk9</classifier>
<scope>system</scope><systemPath>${project.basedir}/libs/api-jdk9.jar</systemPath></dependency>
</dependencies></profile>
</profiles>
</project>"#,
)
.unwrap();
let cache = root.join("cache");
let mut model = ide_build::load(root, &cache, None);
for project in &mut model.maven.as_mut().unwrap().projects {
project.env.insert("JAVA_HOME".into(), java_home.to_string_lossy().into_owned());
}
let classpath = ide_build::compile::module_classpath(
root,
&model,
root,
false,
None,
&root.join("classpath-cache"),
&ide_core::CancellationToken::new(),
)
.unwrap();
let expected = if major <= 8 { "api-jdk8.jar" } else { "api-jdk9.jar" };
let other = if major <= 8 { "api-jdk9.jar" } else { "api-jdk8.jar" };
assert!(classpath.contains(expected), "{classpath}");
assert!(!classpath.contains(other), "{classpath}");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)] #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "project smoke: needs Gradle and a JDK"] #[ignore = "project smoke: needs Gradle and a JDK"]
async fn gradle_sync() { async fn gradle_sync() {
+17 -6
View File
@@ -223,6 +223,8 @@ impl ClientHandler {
if self.server.running_gen(self.generation).is_some_and(|run| !run.caps.read().restriction.diagnostics()) { if self.server.running_gen(self.generation).is_some_and(|run| !run.caps.read().restriction.diagnostics()) {
return; return;
} }
let mut params = params;
crate::virtual_doc::normalize_uris(&mut params);
let params: lsp::PublishDiagnosticsParams = match serde_json::from_value(params) { let params: lsp::PublishDiagnosticsParams = match serde_json::from_value(params) {
Ok(p) => p, Ok(p) => p,
Err(e) => { Err(e) => {
@@ -250,7 +252,14 @@ impl ClientHandler {
{ {
return; return;
} }
let shown = self.server.set_diagnostics(path.clone(), params.diagnostics); // A late answer for an older version of the text is shown, but isn't fresh (`check_files`).
let obsolete =
params.version.is_some_and(|v| mgr.docs.lock().values().any(|d| d.path == path && d.version > v));
let shown = if obsolete {
self.server.set_obsolete_diagnostics(path.clone(), params.diagnostics)
} else {
self.server.set_diagnostics(path.clone(), params.diagnostics)
};
mgr.emit_diagnostics(&self.server, path, shown, self.encoding()); mgr.emit_diagnostics(&self.server, path, shown, self.encoding());
} }
@@ -315,11 +324,13 @@ impl ClientHandler {
} }
fn apply_edit(&self, mgr: &LspManager, params: Value) -> Result<Value, (i64, String)> { fn apply_edit(&self, mgr: &LspManager, params: Value) -> Result<Value, (i64, String)> {
let edit: lsp::WorkspaceEdit = params let edit: lsp::WorkspaceEdit =
.get("edit") params.get("edit").cloned().ok_or_else(|| (codes::INVALID_PARAMS, "missing edit".to_owned())).and_then(
.cloned() |mut e| {
.ok_or_else(|| (codes::INVALID_PARAMS, "missing edit".to_owned())) crate::virtual_doc::normalize_uris(&mut e);
.and_then(|e| serde_json::from_value(e).map_err(|e| (codes::INVALID_PARAMS, e.to_string())))?; serde_json::from_value(e).map_err(|e| (codes::INVALID_PARAMS, e.to_string()))
},
)?;
// Convert before calling the hook: it re-enters document sync. // Convert before calling the hook: it re-enters document sync.
let edit = convert::workspace_edit(&mgr.conv(self.encoding()), edit); let edit = convert::workspace_edit(&mgr.conv(self.encoding()), edit);
let applied = match mgr.apply_edit_hook() { let applied = match mgr.apply_edit_hook() {
+119
View File
@@ -22,6 +22,9 @@ use std::time::{Duration, Instant};
/// How long Inspect Code waits for a server that is still starting (jdtls, /// How long Inspect Code waits for a server that is still starting (jdtls,
/// rust-analyzer on a large workspace). /// rust-analyzer on a large workspace).
const INSPECT_START_WAIT: Duration = Duration::from_secs(60); const INSPECT_START_WAIT: Duration = Duration::from_secs(60);
/// Quiet period after a publish before a checked file counts as done (follow-up publishes:
/// syntax first, semantics later).
const CHECK_SETTLE: Duration = Duration::from_millis(500);
#[derive(Clone, Copy, Debug)] #[derive(Clone, Copy, Debug)]
pub struct InspectOptions { pub struct InspectOptions {
@@ -48,6 +51,29 @@ pub trait InspectHost: Send + Sync + 'static {
fn close(&self, doc: DocId); fn close(&self, doc: DocId);
} }
/// One language server's part in a [`DocumentCheck`].
#[derive(Clone, Debug)]
pub struct CheckedServer {
pub name: String,
/// Its state when the check ended: not `Ready`/`Busy` means it could not check.
pub status: ide_core::event::ServerStatus,
/// Why it isn't running (not installed, misconfigured, crashed), as the status bar says.
pub message: Option<String>,
/// Its diagnostics are in the result.
pub checked: bool,
/// It was still analysing (or had not published for this change) when the wait ended.
pub incomplete: bool,
/// Asking it failed.
pub error: Option<String>,
}
/// Fresh diagnostics of a document, with every language server that serves it.
#[derive(Clone, Debug, Default)]
pub struct DocumentCheck {
pub diagnostics: Vec<Diagnostic>,
pub servers: Vec<CheckedServer>,
}
/// What became of one file. /// What became of one file.
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub enum FileInspection { pub enum FileInspection {
@@ -198,6 +224,99 @@ impl LspManager {
} }
} }
/// Fresh diagnostics of the open document `doc`, whose text the caller just synced
/// (`changed`: at that instant, so diagnostics published before it are stale). Tells the
/// servers the document was saved, since checks that run on save (rust-analyzer's
/// `cargo check`) don't run for a change made outside the IDE, then waits for each server
/// to be ready, to answer for this text and to finish the background work it started, up
/// to `timeout`. Every server serving the document is reported, so a caller can tell "no
/// problems" from "nothing could check".
pub async fn check_document(
&self,
doc: DocId,
changed: Option<Instant>,
timeout: Duration,
cancel: &CancellationToken,
) -> IdeResult<DocumentCheck> {
let snap = self.snapshot(doc)?;
// The text may have changed before this call synced it (the file watcher reloaded it):
// only diagnostics published after the servers got the current text are fresh.
let changed = Some(changed.map_or(snap.changed, |c| c.max(snap.changed)));
let deadline = tokio::time::Instant::now() + timeout;
let left = || deadline.saturating_duration_since(tokio::time::Instant::now());
let ready = tokio::select! {
r = futures::future::join_all(snap.servers.iter().map(|s| s.wait_ready(left()))) => r,
() = cancel.cancelled() => return Err(IdeError::cancelled()),
};
// Once the servers have the document: a save sent while one starts would not reach it.
self.did_save(doc, snap.text.as_str());
let per_server = snap.servers.iter().zip(ready).map(|(server, run)| async {
let mut report = CheckedServer {
name: server.name.clone(),
status: server.status(),
message: server.status_message(),
checked: false,
incomplete: false,
error: None,
};
let Some(run) = run else { return Some((report, Vec::new())) };
let t = Target { server: server.clone(), run };
if !t.caps().restriction.diagnostics() {
return None;
}
let pull = t.caps().has("diagnosticProvider");
let mut since = changed;
let raw = loop {
// A pull asks for this text; a push server is waited on to publish for it.
let raw = if pull {
server.wait_idle(left(), cancel).await;
self.pull_diagnostics(&t, &snap.uri, cancel, left()).await
} else {
let opts = InspectOptions { concurrency: 1, timeout: left(), settle: CHECK_SETTLE };
wait_for_publish(server, &snap.path, since, opts, cancel).await
};
// The save may have started work (a `cargo check`) that publishes when it ends.
if raw.is_err() || pull || !server.is_busy() || left().is_zero() {
break raw;
}
if !server.wait_idle(left(), cancel).await {
break raw.map(|_| server.diagnostics_for(&snap.path));
}
// Idle: its last publish is final once quiet.
since = None;
};
report.status = server.status();
match raw {
Ok(items) => {
report.checked = true;
let fresh =
pull || changed.is_none_or(|c| server.last_published(&snap.path).is_some_and(|p| p >= c));
report.incomplete = !fresh || server.is_busy();
let conv = self.conv(t.enc());
let items = items
.into_iter()
.map(|d| convert::diagnostic(&conv, &snap.path, d, &t.server.name))
.collect::<Vec<_>>();
Some((report, items))
}
Err(e) => {
report.error = Some(e.message);
Some((report, Vec::new()))
}
}
});
let results = tokio::select! {
r = futures::future::join_all(per_server) => r,
() = cancel.cancelled() => return Err(IdeError::cancelled()),
};
let mut out = DocumentCheck::default();
for (report, items) in results.into_iter().flatten() {
out.servers.push(report);
out.diagnostics.extend(items);
}
Ok(out)
}
async fn pull_diagnostics( async fn pull_diagnostics(
&self, &self,
t: &Target, t: &Target,
+11 -8
View File
@@ -40,7 +40,7 @@ mod vue_navigation;
mod vue_refs; mod vue_refs;
pub use injections::{InjectedRange, InjectionProvider, MAX_INJECTED_DOCUMENT_BYTES}; pub use injections::{InjectedRange, InjectionProvider, MAX_INJECTED_DOCUMENT_BYTES};
pub use inspect::{FileInspection, InspectHost, InspectOptions}; pub use inspect::{CheckedServer, DocumentCheck, FileInspection, InspectHost, InspectOptions};
pub use jdtls::jvm_inspect::JdtJvmInspector; pub use jdtls::jvm_inspect::JdtJvmInspector;
pub use kotlin_lsp::{KotlinDebugServer, KotlinDebugUnavailable}; pub use kotlin_lsp::{KotlinDebugServer, KotlinDebugUnavailable};
pub use launch::{LaunchError, Launcher, ProcessLauncher}; pub use launch::{LaunchError, Launcher, ProcessLauncher};
@@ -120,6 +120,8 @@ struct DocState {
compose_detected: bool, compose_detected: bool,
/// The notebook a code cell belongs to: synced through `notebookDocument/*`, not as a file. /// The notebook a code cell belongs to: synced through `notebookDocument/*`, not as a file.
notebook: Option<PathBuf>, notebook: Option<PathBuf>,
/// When the servers last got new text (open or change): diagnostics published before are stale.
changed: std::time::Instant,
} }
impl DocState { impl DocState {
@@ -158,6 +160,8 @@ struct DocSnap {
language: LanguageId, language: LanguageId,
text: Arc<DocText>, text: Arc<DocText>,
servers: Vec<Arc<Server>>, servers: Vec<Arc<Server>>,
/// [`DocState::changed`].
changed: std::time::Instant,
} }
impl DocSnap { impl DocSnap {
@@ -1170,6 +1174,7 @@ impl LspManager {
servers: servers.iter().map(|s| Attached { server: s.clone(), opened_gen: None }).collect(), servers: servers.iter().map(|s| Attached { server: s.clone(), opened_gen: None }).collect(),
compose_detected, compose_detected,
notebook: None, notebook: None,
changed: std::time::Instant::now(),
}; };
let compose_added = { let compose_added = {
let mut docs = self.docs.lock(); let mut docs = self.docs.lock();
@@ -1217,6 +1222,7 @@ impl LspManager {
let mut docs = self.docs.lock(); let mut docs = self.docs.lock();
let Some(d) = docs.get_mut(&doc) else { return }; let Some(d) = docs.get_mut(&doc) else { return };
d.version = version as i32; d.version = version as i32;
d.changed = std::time::Instant::now();
// Servers that have this document open on their current instance. // Servers that have this document open on their current instance.
let targets: Vec<(Arc<Server>, Running, SyncKind, Encoding)> = d let targets: Vec<(Arc<Server>, Running, SyncKind, Encoding)> = d
@@ -1392,6 +1398,7 @@ impl LspManager {
language: d.language.clone(), language: d.language.clone(),
text: d.text.clone(), text: d.text.clone(),
servers, servers,
changed: d.changed,
}) })
} }
@@ -1433,13 +1440,9 @@ impl LspManager {
let params = serde_json::to_value(params).map_err(|e| IdeError::internal(e.to_string()))?; let params = serde_json::to_value(params).map_err(|e| IdeError::internal(e.to_string()))?;
let stamp = self.injected_request_stamp(&params); let stamp = self.injected_request_stamp(&params);
let fut = async { let fut = async {
if t.server.name == "csharp-ls" { let mut value = t.run.peer.request::<Value, Value>(method, params, cancel.cloned()).await?;
let mut value = t.run.peer.request::<Value, Value>(method, params, cancel.cloned()).await?; virtual_doc::normalize_uris(&mut value);
virtual_doc::normalize_csharp_uris(&mut value); serde_json::from_value(value).map_err(|e| RpcError::Protocol(format!("{method}: {e}")))
serde_json::from_value(value).map_err(|e| RpcError::Protocol(format!("{method}: {e}")))
} else {
t.run.peer.request::<Value, R>(method, params, cancel.cloned()).await
}
}; };
let result = match tokio::time::timeout(timeout, fut).await { let result = match tokio::time::timeout(timeout, fut).await {
Ok(r) => r.map_err(|e| rpc_err(&t.server.name, method, e)), Ok(r) => r.map_err(|e| rpc_err(&t.server.name, method, e)),
+3
View File
@@ -213,6 +213,7 @@ impl LspManager {
{ {
if d.text.as_str() != cell.source { if d.text.as_str() != cell.source {
Arc::make_mut(&mut d.text).set(&cell.source); Arc::make_mut(&mut d.text).set(&cell.source);
d.changed = std::time::Instant::now();
} }
d.version += 1; d.version += 1;
state.cells.push((cell.id.clone(), doc)); state.cells.push((cell.id.clone(), doc));
@@ -234,6 +235,7 @@ impl LspManager {
servers: state.servers.iter().map(|s| Attached { server: s.clone(), opened_gen: None }).collect(), servers: state.servers.iter().map(|s| Attached { server: s.clone(), opened_gen: None }).collect(),
compose_detected: false, compose_detected: false,
notebook: Some(path.to_owned()), notebook: Some(path.to_owned()),
changed: std::time::Instant::now(),
}, },
); );
state.cells.push((cell.id.clone(), doc)); state.cells.push((cell.id.clone(), doc));
@@ -259,6 +261,7 @@ impl LspManager {
} }
Arc::make_mut(&mut d.text).set(&cell.source); Arc::make_mut(&mut d.text).set(&cell.source);
d.version += 1; d.version += 1;
d.changed = std::time::Instant::now();
content.push(json!({ content.push(json!({
"document": { "uri": d.uri, "version": d.version }, "document": { "uri": d.uri, "version": d.version },
"changes": [{ "text": cell.source }], "changes": [{ "text": cell.source }],
+32
View File
@@ -658,6 +658,24 @@ impl Server {
} }
} }
/// Waits until no `$/progress` task is active, background work included (jdtls' builds,
/// rust-analyzer's `cargo check`), unlike [`Server::wait_indexed`]; false on timeout or
/// cancellation.
pub async fn wait_idle(&self, timeout: Duration, cancel: &CancellationToken) -> bool {
let deadline = tokio::time::Instant::now() + timeout;
loop {
let notified = self.phase_notify.notified();
if !self.is_busy() || self.running().is_none() {
return !self.is_busy();
}
tokio::select! {
() = notified => {}
() = tokio::time::sleep_until(deadline) => return !self.is_busy(),
() = cancel.cancelled() => return false,
}
}
}
// ---- transiently opened files ---- // ---- transiently opened files ----
/// Registers a holder of the transient file `path` on instance `generation`; /// Registers a holder of the transient file `path` on instance `generation`;
@@ -752,6 +770,20 @@ impl Server {
self.combine(path) self.combine(path)
} }
/// [`Server::set_diagnostics`] for a publication tagged with a document version older than
/// the text the server has now: shown, but not an answer for the current text, so
/// [`Server::last_published`] keeps its time.
pub fn set_obsolete_diagnostics(&self, path: PathBuf, items: Vec<lsp::Diagnostic>) -> Vec<lsp::Diagnostic> {
let before = self.last_published(&path);
let shown = self.set_diagnostics(path.clone(), items);
let mut published = self.published.lock();
match before {
Some(at) => published.insert(path, at),
None => published.remove(&path),
};
shown
}
/// Records a pulled report for `path` (`None` items: unchanged since `result_id`'s /// Records a pulled report for `path` (`None` items: unchanged since `result_id`'s
/// predecessor); returns what the file now shows. /// predecessor); returns what the file now shows.
pub fn set_pulled( pub fn set_pulled(
+58 -10
View File
@@ -18,23 +18,36 @@ const URI_ESCAPES: &AsciiSet = &CONTROLS
.add(b'|') .add(b'|')
.add(b'}'); .add(b'}');
/// csharp-ls uses .NET Uri.ToString(), which can leave spaces and Unicode unescaped. /// Servers can leave spaces and Unicode unescaped in document URIs: csharp-ls uses .NET
/// Normalize only document URI fields, before lsp-types' strict URI deserialization. /// `Uri.ToString()`, and Java servers build some URIs with `java.net.URI.toString()` (which keeps
pub(crate) fn normalize_csharp_uris(value: &mut serde_json::Value) { /// non-ASCII characters), e.g. jdtls in a refactoring's edit for a project under a Chinese path.
/// Escape them in document URI fields (and `WorkspaceEdit.changes` keys) before lsp-types' strict
/// URI deserialization, which otherwise rejects the whole response.
pub(crate) fn normalize_uris(value: &mut serde_json::Value) {
match value { match value {
serde_json::Value::Array(values) => values.iter_mut().for_each(normalize_csharp_uris), serde_json::Value::Array(values) => values.iter_mut().for_each(normalize_uris),
serde_json::Value::Object(fields) => { serde_json::Value::Object(fields) => {
for (key, value) in fields { for (key, value) in fields.iter_mut() {
// Opaque payloads must survive resolve/execute round trips unchanged. // Opaque payloads must survive resolve/execute round trips unchanged.
if matches!(key.as_str(), "data" | "arguments") { if matches!(key.as_str(), "data" | "arguments") {
continue; continue;
} }
if matches!(key.as_str(), "uri" | "targetUri") if matches!(key.as_str(), "uri" | "targetUri" | "oldUri" | "newUri")
&& let Some(uri) = value.as_str().filter(|s| s.starts_with("csharp:")) && let Some(uri) = value.as_str().and_then(escaped_uri)
{ {
*value = utf8_percent_encode(uri, URI_ESCAPES).to_string().into(); *value = uri.into();
} else if key == "changes"
&& let serde_json::Value::Object(changes) = value
{
*changes = std::mem::take(changes)
.into_iter()
.map(|(uri, mut edits)| {
normalize_uris(&mut edits);
(escaped_uri(&uri).unwrap_or(uri), edits)
})
.collect();
} else { } else {
normalize_csharp_uris(value); normalize_uris(value);
} }
} }
} }
@@ -42,6 +55,16 @@ pub(crate) fn normalize_csharp_uris(value: &mut serde_json::Value) {
} }
} }
/// `uri` with the characters a URI can't hold escaped; `None` when nothing changes or it is
/// not a document URI this applies to (other schemes are passed back to servers as given).
fn escaped_uri(uri: &str) -> Option<String> {
if !(uri.starts_with("file:") || uri.starts_with("csharp:")) {
return None;
}
let escaped = utf8_percent_encode(uri, URI_ESCAPES).to_string();
(escaped != uri).then_some(escaped)
}
#[derive(serde::Deserialize)] #[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub(crate) struct CSharpMetadata { pub(crate) struct CSharpMetadata {
@@ -202,7 +225,7 @@ mod tests {
{ "data": { "uri": uri }, "command": { "arguments": [{ "targetUri": uri }] } } { "data": { "uri": uri }, "command": { "arguments": [{ "targetUri": uri }] } }
]); ]);
let original = response.clone(); let original = response.clone();
normalize_csharp_uris(&mut response); normalize_uris(&mut response);
assert_eq!(response[0]["uri"], encoded); assert_eq!(response[0]["uri"], encoded);
assert_eq!(response[0]["source"], uri); assert_eq!(response[0]["source"], uri);
assert_eq!(response[1]["location"]["targetUri"], encoded); assert_eq!(response[1]["location"]["targetUri"], encoded);
@@ -210,6 +233,31 @@ mod tests {
assert!(encoded.parse::<lsp_types::Uri>().is_ok()); assert!(encoded.parse::<lsp_types::Uri>().is_ok());
} }
/// #3089: jdtls's Introduce Variable edit for a project under a Chinese path names the file
/// with the characters unescaped, and the resolved code action failed to deserialize.
#[test]
fn file_uris_with_unicode_and_spaces_deserialize_in_workspace_edits() {
let uri = "file:///C:/项目/my app/src/A.java";
let encoded = "file:///C:/%E9%A1%B9%E7%9B%AE/my%20app/src/A.java";
let edit = serde_json::json!([{ "range": { "start": { "line": 0, "character": 0 }, "end": { "line": 0, "character": 0 } }, "newText": "x" }]);
let mut action = serde_json::json!({
"title": "Extract to local variable",
"edit": {
"documentChanges": [{ "textDocument": { "uri": uri, "version": 3 }, "edits": edit }],
"changes": { (uri): edit },
},
"data": { "uri": uri },
});
assert!(serde_json::from_value::<lsp_types::CodeAction>(action.clone()).is_err());
normalize_uris(&mut action);
let action: lsp_types::CodeAction = serde_json::from_value(action.clone()).unwrap();
let edit = action.edit.unwrap();
let Some(lsp_types::DocumentChanges::Edits(edits)) = edit.document_changes else { panic!() };
assert_eq!(edits[0].text_document.uri.as_str(), encoded);
assert_eq!(edit.changes.unwrap().keys().next().unwrap().as_str(), encoded);
assert_eq!(action.data.unwrap()["uri"], uri, "opaque data is passed back unchanged");
}
#[test] #[test]
fn jdt_titles() { fn jdt_titles() {
assert_eq!(title(JDT, true), "SpringApplication.class"); assert_eq!(title(JDT, true), "SpringApplication.class");
+70
View File
@@ -2178,6 +2178,76 @@ async fn missing_server_reports_once() {
assert!(h.mgr.semantic_tokens(doc).await.unwrap().is_none()); assert!(h.mgr.semantic_tokens(doc).await.unwrap().is_none());
} }
#[tokio::test(flavor = "multi_thread")]
async fn checking_a_document_saves_it_and_waits_for_fresh_diagnostics() {
let h = harness(&[("mock", incremental_caps())]);
let s = h.launcher.state("mock");
let (doc, path) = h.open("a.mk", "bad\n");
h.wait_status("mock", ServerStatus::Ready).await;
// The server answers the save with a publish, as an on-save check (`cargo check`) does.
let (server, uri) = (s.clone(), uri_of(&path));
let publisher = tokio::spawn(async move {
eventually(|| !server.notified("textDocument/didSave").is_empty()).await;
let diag = json!({ "range": {"start":{"line":0,"character":0},"end":{"line":0,"character":3}},
"severity": 1, "message": "on save" });
server.peer().notify("textDocument/publishDiagnostics", json!({ "uri": uri, "diagnostics": [diag] })).unwrap();
});
let cancel = CancellationToken::new();
let changed = Some(std::time::Instant::now());
let check = h.mgr.check_document(doc, changed, Duration::from_secs(20), &cancel).await.unwrap();
publisher.await.unwrap();
assert_eq!(check.diagnostics.iter().map(|d| d.message.as_str()).collect::<Vec<_>>(), ["on save"]);
assert!(check.servers[0].checked && !check.servers[0].incomplete, "{:?}", check.servers);
// A change the server never answered: what it has is reported as not fresh.
let changed = Some(std::time::Instant::now());
let stale = h.mgr.check_document(doc, changed, Duration::from_millis(800), &cancel).await.unwrap();
assert!(stale.servers[0].checked && stale.servers[0].incomplete, "{:?}", stale.servers);
assert_eq!(stale.diagnostics.len(), 1);
// The text changed before the call (the file watcher reloaded it), so the caller saw no
// change of its own: the old publication is still not fresh.
h.mgr.did_change(doc, 2, &[change(rng(0, 0, 0, 3), "good")], &|| "good\n".to_owned());
let reloaded = h.mgr.check_document(doc, None, Duration::from_millis(800), &cancel).await.unwrap();
assert!(reloaded.servers[0].incomplete, "{:?}", reloaded.servers);
// A late publication tagged with the old version is no answer for the current text.
s.peer()
.notify("textDocument/publishDiagnostics", json!({ "uri": uri_of(&path), "version": 1, "diagnostics": [] }))
.unwrap();
let late = h.mgr.check_document(doc, None, Duration::from_millis(800), &cancel).await.unwrap();
assert!(late.servers[0].incomplete, "{:?}", late.servers);
// One for the current version is.
s.peer()
.notify("textDocument/publishDiagnostics", json!({ "uri": uri_of(&path), "version": 2, "diagnostics": [] }))
.unwrap();
let fresh = h.mgr.check_document(doc, None, Duration::from_secs(5), &cancel).await.unwrap();
assert!(fresh.servers[0].checked && !fresh.servers[0].incomplete, "{:?}", fresh.servers);
}
#[tokio::test(flavor = "multi_thread")]
async fn checking_a_document_reports_servers_that_cannot_check() {
let cancel = CancellationToken::new();
let h = harness(&[("ghost", json!({}))]);
h.launcher.state("ghost").missing.store(true, Ordering::SeqCst);
let (doc, _) = h.open("a.mk", "x");
h.wait_status("ghost", ServerStatus::Missing).await;
let check = h.mgr.check_document(doc, None, Duration::from_secs(5), &cancel).await.unwrap();
assert!(check.diagnostics.is_empty());
let s = &check.servers[0];
assert_eq!((s.name.as_str(), s.status, s.checked), ("ghost", ServerStatus::Missing, false));
assert_eq!(s.message.as_deref(), Some("install ghost"));
let h = harness(&[("mock", incremental_caps())]);
h.mgr.set_config_hook(Box::new(|cfg, _| cfg.launch_error = Some("needs JDK 21".into())));
let (doc, _) = h.open("a.mk", "x");
h.wait_status("mock", ServerStatus::Misconfigured).await;
let check = h.mgr.check_document(doc, None, Duration::from_secs(5), &cancel).await.unwrap();
assert_eq!(check.servers[0].status, ServerStatus::Misconfigured);
assert!(check.servers[0].message.as_deref().is_some_and(|m| m.contains("needs JDK 21")), "{:?}", check.servers);
assert!(!check.servers[0].checked);
}
#[tokio::test(flavor = "multi_thread")] #[tokio::test(flavor = "multi_thread")]
async fn launch_error_fails_without_starting_the_server() { async fn launch_error_fails_without_starting_the_server() {
let h = harness(&[("mock", incremental_caps())]); let h = harness(&[("mock", incremental_caps())]);
+56 -9
View File
@@ -1,6 +1,7 @@
//! `<app-data>/mcp/server.json`: where the running IDE's server listens and //! `<app-data>/mcp/server.json`: where the running IDE's server listens and
//! its token, for the stdio bridge. Readable only by the user; removed when //! its token, for the stdio bridge. Readable only by the user; removed when
//! the server stops. //! the server stops. `<app-data>/mcp/token` keeps the token across runs when
//! the user chose a fixed token.
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::io; use std::io;
@@ -14,6 +15,28 @@ pub fn discovery_path(app_data: &Path) -> PathBuf {
app_data.join(DISCOVERY_FILE) app_data.join(DISCOVERY_FILE)
} }
/// The kept token, relative to the app data directory.
pub const TOKEN_FILE: &str = "mcp/token";
/// The kept token of the IDE whose app data directory is `app_data`.
pub fn token_path(app_data: &Path) -> PathBuf {
app_data.join(TOKEN_FILE)
}
/// The token kept at `path`; a new one is made and kept there when there is none (or the
/// file doesn't hold one). Readable only by the user.
pub fn kept_token(path: &Path) -> io::Result<String> {
if let Ok(text) = std::fs::read_to_string(path) {
let token = text.trim();
if token.len() == 64 && token.bytes().all(|b| b.is_ascii_hexdigit()) {
return Ok(token.to_owned());
}
}
let token = crate::server::new_token()?;
write_atomic(path, token.as_bytes())?;
Ok(token)
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Discovery { pub struct Discovery {
pub port: u16, pub port: u16,
@@ -32,15 +55,8 @@ impl Discovery {
/// Writes the file atomically, readable only by the user. /// Writes the file atomically, readable only by the user.
pub fn write(&self, path: &Path) -> io::Result<()> { pub fn write(&self, path: &Path) -> io::Result<()> {
let dir = path.parent().ok_or_else(|| io::Error::other("no parent directory"))?;
std::fs::create_dir_all(dir)?;
let tmp = path.with_extension(format!("tmp{}", std::process::id()));
let bytes = serde_json::to_vec_pretty(self).map_err(io::Error::other)?; let bytes = serde_json::to_vec_pretty(self).map_err(io::Error::other)?;
let written = write_private(&tmp, &bytes).and_then(|()| std::fs::rename(&tmp, path)); write_atomic(path, &bytes)
if written.is_err() {
let _ = std::fs::remove_file(&tmp);
}
written
} }
/// Removes the file at `path` if it still describes this process's server. /// Removes the file at `path` if it still describes this process's server.
@@ -51,6 +67,18 @@ impl Discovery {
} }
} }
/// Writes `bytes` to `path` atomically, readable only by the user.
fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
let dir = path.parent().ok_or_else(|| io::Error::other("no parent directory"))?;
std::fs::create_dir_all(dir)?;
let tmp = path.with_extension(format!("tmp{}", std::process::id()));
let written = write_private(&tmp, bytes).and_then(|()| std::fs::rename(&tmp, path));
if written.is_err() {
let _ = std::fs::remove_file(&tmp);
}
written
}
#[cfg(unix)] #[cfg(unix)]
fn write_private(path: &Path, bytes: &[u8]) -> io::Result<()> { fn write_private(path: &Path, bytes: &[u8]) -> io::Result<()> {
use std::io::Write; use std::io::Write;
@@ -91,4 +119,23 @@ mod tests {
Discovery::remove_if_ours(&path); Discovery::remove_if_ours(&path);
assert!(!path.exists()); assert!(!path.exists());
} }
#[test]
fn keeps_one_token_until_its_file_is_removed() {
let tmp = tempfile::tempdir().unwrap();
let path = token_path(tmp.path());
let first = kept_token(&path).unwrap();
assert_eq!(first.len(), 64);
assert_eq!(kept_token(&path).unwrap(), first, "the same token on the next run");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600);
}
std::fs::write(&path, "not a token").unwrap();
let replaced = kept_token(&path).unwrap();
assert_ne!(replaced, first);
std::fs::remove_file(&path).unwrap();
assert_ne!(kept_token(&path).unwrap(), replaced, "a removed token is made anew");
}
} }
+2 -2
View File
@@ -22,8 +22,8 @@ mod protocol;
mod server; mod server;
pub mod stdio; pub mod stdio;
pub use discovery::{DISCOVERY_FILE, Discovery, discovery_path}; pub use discovery::{DISCOVERY_FILE, Discovery, TOKEN_FILE, discovery_path, kept_token, token_path};
pub use server::McpServer; pub use server::{McpServer, ServerOptions};
pub use tokio_util::sync::CancellationToken; pub use tokio_util::sync::CancellationToken;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
+292 -25
View File
@@ -3,17 +3,39 @@
use crate::discovery::Discovery; use crate::discovery::Discovery;
use crate::{CancellationToken, PROJECT_HEADER, ToolRegistry, protocol}; use crate::{CancellationToken, PROJECT_HEADER, ToolRegistry, protocol};
use bytes::Bytes; use bytes::Bytes;
use http_body_util::{BodyExt, Full, Limited}; use http_body_util::{BodyExt, Either, Full, Limited};
use hyper::body::Incoming; use hyper::body::{Frame, Incoming};
use hyper::header::{self, HeaderMap, HeaderValue}; use hyper::header::{self, HeaderMap, HeaderValue};
use hyper::{Method, Request, Response, StatusCode}; use hyper::{Method, Request, Response, StatusCode};
use serde_json::{Value, json};
use std::convert::Infallible; use std::convert::Infallible;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::pin::Pin;
use std::sync::Arc; use std::sync::Arc;
use std::task::{Context, Poll};
use std::time::Duration;
use tokio::sync::watch; use tokio::sync::watch;
use tokio::task::{JoinError, JoinHandle};
use tokio_util::sync::DropGuard;
/// Largest request body. /// Largest request body.
const MAX_BODY: usize = 8 << 20; const MAX_BODY: usize = 8 << 20;
/// A call still running after this long is answered on an event stream (when the client
/// takes one), so that it can take as long as it needs: clients give up on a POST whose
/// response has not started (Claude Code after a minute).
#[cfg(not(test))]
const STREAM_AFTER: Duration = Duration::from_secs(1);
#[cfg(test)]
const STREAM_AFTER: Duration = Duration::from_millis(100);
/// How often a streamed call reports that it is still running: progress notifications reset
/// the client's idle timeout (Claude Code's is five minutes).
#[cfg(not(test))]
const HEARTBEAT: Duration = Duration::from_secs(15);
#[cfg(test)]
const HEARTBEAT: Duration = Duration::from_millis(100);
type Handled = Result<Option<Value>, Value>;
type Body = Either<Full<Bytes>, EventStream>;
struct Shared { struct Shared {
registry: Arc<dyn ToolRegistry>, registry: Arc<dyn ToolRegistry>,
@@ -27,12 +49,25 @@ struct Shared {
/// removes its discovery file. /// removes its discovery file.
pub struct McpServer { pub struct McpServer {
port: u16, port: u16,
/// Why the port asked for in [`ServerOptions::port`] was not used.
port_unavailable: Option<String>,
token: String, token: String,
discovery: Option<PathBuf>, discovery: Option<PathBuf>,
shutdown: watch::Sender<bool>, shutdown: watch::Sender<bool>,
cancel: CancellationToken, cancel: CancellationToken,
} }
/// How [`McpServer::start_with`] listens.
#[derive(Clone, Debug, Default)]
pub struct ServerOptions {
/// Listen on `127.0.0.1:<port>`. When it can't be bound (in use, or reserved by the
/// system), a free port is taken instead and [`McpServer::port_unavailable`] says why.
/// `None`: a free port.
pub port: Option<u16>,
/// The bearer token; `None`: a new one.
pub token: Option<String>,
}
impl McpServer { impl McpServer {
/// Starts listening on `127.0.0.1:<random port>` with a new token, on /// Starts listening on `127.0.0.1:<random port>` with a new token, on
/// `runtime`, and writes the discovery file at `discovery` if given. /// `runtime`, and writes the discovery file at `discovery` if given.
@@ -41,10 +76,32 @@ impl McpServer {
discovery: Option<PathBuf>, discovery: Option<PathBuf>,
runtime: &tokio::runtime::Handle, runtime: &tokio::runtime::Handle,
) -> std::io::Result<Self> { ) -> std::io::Result<Self> {
let listener = std::net::TcpListener::bind(("127.0.0.1", 0))?; Self::start_with(registry, discovery, runtime, ServerOptions::default())
}
/// [`Self::start`] on the port and with the token of `options`.
pub fn start_with(
registry: Arc<dyn ToolRegistry>,
discovery: Option<PathBuf>,
runtime: &tokio::runtime::Handle,
options: ServerOptions,
) -> std::io::Result<Self> {
let (listener, port_unavailable) = match options.port {
Some(port) => match std::net::TcpListener::bind(("127.0.0.1", port)) {
Ok(listener) => (listener, None),
Err(e) => {
tracing::warn!("MCP server: port {port} unavailable ({e}); taking a free one");
(std::net::TcpListener::bind(("127.0.0.1", 0))?, Some(format!("port {port}: {e}")))
}
},
None => (std::net::TcpListener::bind(("127.0.0.1", 0))?, None),
};
listener.set_nonblocking(true)?; listener.set_nonblocking(true)?;
let port = listener.local_addr()?.port(); let port = listener.local_addr()?.port();
let token = new_token()?; let token = match options.token {
Some(token) => token,
None => new_token()?,
};
let listener = { let listener = {
let _entered = runtime.enter(); let _entered = runtime.enter();
tokio::net::TcpListener::from_std(listener)? tokio::net::TcpListener::from_std(listener)?
@@ -69,13 +126,18 @@ impl McpServer {
}); });
runtime.spawn(accept(listener, shared, stopped)); runtime.spawn(accept(listener, shared, stopped));
tracing::info!("MCP server on 127.0.0.1:{port}"); tracing::info!("MCP server on 127.0.0.1:{port}");
Ok(Self { port, token, discovery, shutdown, cancel }) Ok(Self { port, port_unavailable, token, discovery, shutdown, cancel })
} }
pub fn port(&self) -> u16 { pub fn port(&self) -> u16 {
self.port self.port
} }
/// Why the port of [`ServerOptions::port`] is not the one listened on.
pub fn port_unavailable(&self) -> Option<&str> {
self.port_unavailable.as_deref()
}
/// The bearer token of this run. /// The bearer token of this run.
pub fn token(&self) -> &str { pub fn token(&self) -> &str {
&self.token &self.token
@@ -140,15 +202,11 @@ async fn accept(listener: tokio::net::TcpListener, shared: Arc<Shared>, mut stop
} }
} }
async fn serve( async fn serve(req: Request<Incoming>, shared: Arc<Shared>, scope: String) -> Result<Response<Body>, Infallible> {
req: Request<Incoming>,
shared: Arc<Shared>,
scope: String,
) -> Result<Response<Full<Bytes>>, Infallible> {
Ok(respond(req, &shared, scope).await) Ok(respond(req, &shared, scope).await)
} }
type Reply = Response<Full<Bytes>>; type Reply = Response<Body>;
async fn respond(req: Request<Incoming>, shared: &Arc<Shared>, scope: String) -> Reply { async fn respond(req: Request<Incoming>, shared: &Arc<Shared>, scope: String) -> Reply {
if req.uri().path() != "/mcp" { if req.uri().path() != "/mcp" {
@@ -163,7 +221,7 @@ async fn respond(req: Request<Incoming>, shared: &Arc<Shared>, scope: String) ->
return response; return response;
} }
if req.method() != Method::POST { if req.method() != Method::POST {
// No server-sent event streams: every answer is in the POST's response. // No stream of server-initiated messages: every answer is in the POST's response.
let mut response = plain(StatusCode::METHOD_NOT_ALLOWED, "only POST"); let mut response = plain(StatusCode::METHOD_NOT_ALLOWED, "only POST");
response.headers_mut().insert(header::ALLOW, HeaderValue::from_static("POST")); response.headers_mut().insert(header::ALLOW, HeaderValue::from_static("POST"));
return response; return response;
@@ -175,25 +233,41 @@ async fn respond(req: Request<Incoming>, shared: &Arc<Shared>, scope: String) ->
} }
let scope = req.headers().get("mcp-session-id").and_then(|v| v.to_str().ok()).unwrap_or(&scope).to_owned(); let scope = req.headers().get("mcp-session-id").and_then(|v| v.to_str().ok()).unwrap_or(&scope).to_owned();
let project = project_of(&req); let project = project_of(&req);
let streams =
req.headers().get_all(header::ACCEPT).iter().any(|v| v.to_str().is_ok_and(|v| v.contains("text/event-stream")));
let body = match Limited::new(req.into_body(), MAX_BODY).collect().await { let body = match Limited::new(req.into_body(), MAX_BODY).collect().await {
Ok(body) => body.to_bytes(), Ok(body) => body.to_bytes(),
Err(e) => return plain(StatusCode::PAYLOAD_TOO_LARGE, &format!("request body: {e}")), Err(e) => return plain(StatusCode::PAYLOAD_TOO_LARGE, &format!("request body: {e}")),
}; };
let initializing = serde_json::from_slice::<serde_json::Value>(&body) let message = serde_json::from_slice::<Value>(&body).ok();
.is_ok_and(|m| m.get("method").and_then(|v| v.as_str()) == Some("initialize")); let initializing = message.as_ref().is_some_and(|m| m.get("method").and_then(|v| v.as_str()) == Some("initialize"));
let id = message.as_ref().and_then(|m| m.get("id")).cloned().unwrap_or(Value::Null);
let progress_token = message.as_ref().and_then(|m| m.pointer("/params/_meta/progressToken")).cloned();
let response_scope = scope.clone(); let response_scope = scope.clone();
let registry = shared.registry.clone(); let registry = shared.registry.clone();
let shared = shared.clone(); let shared = shared.clone();
let cancel = shared.cancel.child_token(); let cancel = shared.cancel.child_token();
let _guard = cancel.clone().drop_guard(); // Cancels the call when the client goes away (the response is dropped).
let handled = tokio::task::spawn_blocking(move || { let guard = cancel.clone().drop_guard();
let mut handled = tokio::task::spawn_blocking(move || {
protocol::handle_body(registry.as_ref(), project.as_deref(), &body, &shared.requests, &scope, cancel) protocol::handle_body(registry.as_ref(), project.as_deref(), &body, &shared.requests, &scope, cancel)
}) });
.await; if streams && !initializing {
match tokio::time::timeout(STREAM_AFTER, &mut handled).await {
Ok(done) => return answer(done, false, &response_scope),
Err(_) => return event_stream(handled, id, progress_token, guard),
}
}
let done = handled.await;
drop(guard);
answer(done, initializing, &response_scope)
}
fn answer(handled: Result<Handled, JoinError>, initializing: bool, scope: &str) -> Reply {
match handled { match handled {
Ok(Ok(Some(reply))) => { Ok(Ok(Some(reply))) => {
let mut response = json(StatusCode::OK, &reply); let mut response = json(StatusCode::OK, &reply);
if initializing && let Ok(value) = HeaderValue::from_str(&response_scope) { if initializing && let Ok(value) = HeaderValue::from_str(scope) {
response.headers_mut().insert("mcp-session-id", value); response.headers_mut().insert("mcp-session-id", value);
} }
response response
@@ -204,6 +278,71 @@ async fn respond(req: Request<Incoming>, shared: &Arc<Shared>, scope: String) ->
} }
} }
/// Answers a call that is still running on an event stream: a progress notification every
/// [`HEARTBEAT`] while it runs (a comment when the client gave no progress token), then its
/// answer. The call is cancelled as soon as the client goes away.
fn event_stream(mut handled: JoinHandle<Handled>, id: Value, token: Option<Value>, guard: DropGuard) -> Reply {
let (tx, rx) = tokio::sync::mpsc::channel::<Bytes>(4);
tokio::spawn(async move {
let _guard = guard;
let started = tokio::time::Instant::now();
let mut beat = tokio::time::interval_at(started + HEARTBEAT, HEARTBEAT);
loop {
tokio::select! {
done = &mut handled => {
let reply = match done {
Ok(Ok(Some(reply)) | Err(reply)) => reply,
Ok(Ok(None)) => return,
Err(e) => protocol::error_response(id, -32603, &format!("the request failed: {e}")),
};
let _ = tx.send(event(&reply)).await;
return;
}
_ = beat.tick() => {
let elapsed = started.elapsed();
let still_running = match &token {
Some(token) => event(&json!({
"jsonrpc": "2.0",
"method": "notifications/progress",
"params": {
"progressToken": token,
"progress": elapsed.as_secs_f64(),
"message": format!("running for {}s", elapsed.as_secs()),
},
})),
None => Bytes::from_static(b": running\n\n"),
};
if tx.send(still_running).await.is_err() {
return;
}
}
_ = tx.closed() => return,
}
}
});
let mut response = Response::new(Either::Right(EventStream(rx)));
response.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static("text/event-stream"));
response.headers_mut().insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
response
}
/// One server-sent event carrying a JSON-RPC message.
fn event(message: &Value) -> Bytes {
Bytes::from(format!("event: message\ndata: {message}\n\n"))
}
/// A `text/event-stream` body, written by [`event_stream`]'s task.
pub(crate) struct EventStream(tokio::sync::mpsc::Receiver<Bytes>);
impl hyper::body::Body for EventStream {
type Data = Bytes;
type Error = Infallible;
fn poll_frame(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Result<Frame<Bytes>, Infallible>>> {
self.get_mut().0.poll_recv(cx).map(|data| data.map(|data| Ok(Frame::data(data))))
}
}
/// No `Origin` other than this server's own, and a loopback `Host`: a web /// No `Origin` other than this server's own, and a loopback `Host`: a web
/// page (DNS rebinding) can't reach the tools even before the token check. /// page (DNS rebinding) can't reach the tools even before the token check.
fn local_request(headers: &HeaderMap, port: u16) -> bool { fn local_request(headers: &HeaderMap, port: u16) -> bool {
@@ -242,22 +381,22 @@ fn project_of(req: &Request<Incoming>) -> Option<String> {
}) })
} }
fn plain(status: StatusCode, text: &str) -> Response<Full<Bytes>> { fn plain(status: StatusCode, text: &str) -> Reply {
let mut response = Response::new(Full::new(Bytes::from(text.to_owned()))); let mut response = Response::new(Either::Left(Full::new(Bytes::from(text.to_owned()))));
*response.status_mut() = status; *response.status_mut() = status;
response.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static("text/plain; charset=utf-8")); response.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static("text/plain; charset=utf-8"));
response response
} }
fn json(status: StatusCode, value: &serde_json::Value) -> Response<Full<Bytes>> { fn json(status: StatusCode, value: &Value) -> Reply {
let mut response = Response::new(Full::new(Bytes::from(value.to_string()))); let mut response = Response::new(Either::Left(Full::new(Bytes::from(value.to_string()))));
*response.status_mut() = status; *response.status_mut() = status;
response.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static("application/json")); response.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static("application/json"));
response response
} }
fn empty(status: StatusCode) -> Response<Full<Bytes>> { fn empty(status: StatusCode) -> Reply {
let mut response = Response::new(Full::new(Bytes::new())); let mut response = Response::new(Either::Left(Full::new(Bytes::new())));
*response.status_mut() = status; *response.status_mut() = status;
response response
} }
@@ -265,6 +404,134 @@ fn empty(status: StatusCode) -> Response<Full<Bytes>> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use std::sync::atomic::{AtomicBool, Ordering};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
/// `slow` sleeps `ms` milliseconds; `wait` runs until it is cancelled, which it records.
struct Slow(Arc<AtomicBool>);
impl ToolRegistry for Slow {
fn projects(&self) -> Vec<PathBuf> {
vec![PathBuf::from("/project")]
}
fn tools(&self, _: &Path) -> Result<Vec<crate::Tool>, String> {
Ok(Vec::new())
}
fn call(
&self,
_: &Path,
name: &str,
arguments: &str,
cancel: CancellationToken,
) -> Result<String, crate::ToolCallError> {
if name == "wait" {
while !cancel.is_cancelled() {
std::thread::sleep(Duration::from_millis(10));
}
self.0.store(true, Ordering::SeqCst);
return Err(crate::ToolCallError::Failed("cancelled".into()));
}
let ms = serde_json::from_str::<Value>(arguments).unwrap()["ms"].as_u64().unwrap();
std::thread::sleep(Duration::from_millis(ms));
Ok("done".into())
}
}
fn start() -> (McpServer, Arc<AtomicBool>) {
let cancelled = Arc::new(AtomicBool::new(false));
let registry = Arc::new(Slow(cancelled.clone()));
(McpServer::start(registry, None, &tokio::runtime::Handle::current()).unwrap(), cancelled)
}
fn call(name: &str, ms: u64, token: Option<&str>) -> Value {
let mut params = json!({ "name": name, "arguments": { "ms": ms } });
if let Some(token) = token {
params["_meta"] = json!({ "progressToken": token });
}
json!({ "jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": params })
}
async fn send(server: &McpServer, accept: &str, body: &Value) -> tokio::net::TcpStream {
let body = body.to_string();
let port = server.port();
let mut stream = tokio::net::TcpStream::connect(("127.0.0.1", port)).await.unwrap();
let request = format!(
"POST /mcp HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nAuthorization: Bearer {}\r\nContent-Type: application/json\r\nAccept: {accept}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
server.token(),
body.len()
);
stream.write_all(request.as_bytes()).await.unwrap();
stream
}
/// The whole response, lowercased headers and the body as sent (chunked or not).
async fn exchange(server: &McpServer, accept: &str, body: &Value) -> (String, String) {
let mut out = String::new();
send(server, accept, body).await.read_to_string(&mut out).await.unwrap();
let (head, body) = out.split_once("\r\n\r\n").unwrap();
(head.to_ascii_lowercase(), body.to_owned())
}
fn events(body: &str) -> Vec<Value> {
body.lines().filter_map(|l| l.strip_prefix("data: ")).map(|d| serde_json::from_str(d).unwrap()).collect()
}
const BOTH: &str = "application/json, text/event-stream";
#[tokio::test(flavor = "multi_thread")]
async fn long_calls_are_answered_on_an_event_stream_that_reports_progress() {
let (server, _) = start();
let (head, body) = exchange(&server, BOTH, &call("slow", 0, Some("p"))).await;
assert!(head.contains("content-type: application/json"), "{head}");
assert_eq!(serde_json::from_str::<Value>(&body).unwrap()["result"]["content"][0]["text"], "done");
let (head, body) = exchange(&server, BOTH, &call("slow", 450, Some("p"))).await;
assert!(head.contains("content-type: text/event-stream"), "{head}");
let messages = events(&body);
let (answer, progress) = messages.split_last().unwrap();
assert_eq!(answer["id"], 3);
assert_eq!(answer["result"]["content"][0]["text"], "done");
assert!(progress.len() >= 2, "{body}");
for pair in progress.windows(2) {
assert!(pair[0]["params"]["progress"].as_f64() < pair[1]["params"]["progress"].as_f64(), "{body}");
}
for p in progress {
assert_eq!((&p["method"], &p["params"]["progressToken"]), (&json!("notifications/progress"), &json!("p")));
}
// Without a progress token, comments keep the stream going.
let (_, body) = exchange(&server, BOTH, &call("slow", 300, None)).await;
assert!(body.contains(": running"), "{body}");
assert_eq!(events(&body).len(), 1, "{body}");
// A client that takes only JSON waits for it.
let (head, body) = exchange(&server, "application/json", &call("slow", 300, Some("p"))).await;
assert!(head.contains("content-type: application/json"), "{head}");
assert_eq!(serde_json::from_str::<Value>(&body).unwrap()["id"], 3);
}
#[tokio::test(flavor = "multi_thread")]
async fn a_streamed_call_is_cancelled_when_the_client_goes_away() {
let (server, cancelled) = start();
let mut stream = send(&server, BOTH, &call("wait", 0, Some("p"))).await;
let mut head = Vec::new();
while !String::from_utf8_lossy(&head).contains("\r\n\r\n") {
let mut buf = [0u8; 1024];
let n = stream.read(&mut buf).await.unwrap();
assert!(n > 0, "the stream ended early");
head.extend_from_slice(&buf[..n]);
}
assert!(String::from_utf8_lossy(&head).to_ascii_lowercase().contains("text/event-stream"));
drop(stream);
tokio::time::timeout(Duration::from_secs(10), async {
while !cancelled.load(Ordering::SeqCst) {
tokio::time::sleep(Duration::from_millis(20)).await;
}
})
.await
.expect("the call was cancelled");
}
#[test] #[test]
fn only_loopback_hosts_and_origins_of_this_server_pass() { fn only_loopback_hosts_and_origins_of_this_server_pass() {
+106 -14
View File
@@ -35,7 +35,8 @@ pub fn run(discovery: &Path, project: Option<PathBuf>) -> i32 {
} }
/// Relays each line of `input` (a JSON-RPC message or batch) to the IDE and /// Relays each line of `input` (a JSON-RPC message or batch) to the IDE and
/// writes its answer, if any, as one line to `output`. /// writes what it answers, if anything, one message per line to `output`: the
/// answer, and before it the progress notifications of a long call.
pub async fn bridge<R, W>(discovery: &Path, project: &Path, input: R, mut output: W) -> std::io::Result<()> pub async fn bridge<R, W>(discovery: &Path, project: &Path, input: R, mut output: W) -> std::io::Result<()>
where where
R: AsyncBufRead + Unpin, R: AsyncBufRead + Unpin,
@@ -44,23 +45,37 @@ where
let scope = crate::server::new_token()?; let scope = crate::server::new_token()?;
let mut lines = input.lines(); let mut lines = input.lines();
let mut pending = tokio::task::JoinSet::new(); let mut pending = tokio::task::JoinSet::new();
let (sent, mut received) = tokio::sync::mpsc::unbounded_channel::<Value>();
let mut eof = false; let mut eof = false;
loop { loop {
if eof && pending.is_empty() && received.is_empty() {
break;
}
let reply = tokio::select! { let reply = tokio::select! {
line = lines.next_line(), if !eof => { line = lines.next_line(), if !eof => {
let Some(line) = line? else { eof = true; continue }; let Some(line) = line? else { eof = true; continue };
if line.trim().is_empty() { continue; } if line.trim().is_empty() { continue; }
match serde_json::from_str::<Value>(&line) { match serde_json::from_str::<Value>(&line) {
Ok(message) => { Ok(message) => {
let (discovery, project, scope) = (discovery.to_owned(), project.to_owned(), scope.clone()); let (discovery, project, scope, sent) =
pending.spawn(async move { relay(&discovery, &project, &message, &scope).await }); (discovery.to_owned(), project.to_owned(), scope.clone(), sent.clone());
pending.spawn(async move {
if let Some(reply) = relay(&discovery, &project, &message, &scope, &sent).await {
let _ = sent.send(reply);
}
});
continue; continue;
} }
Err(e) => Some(error_response(Value::Null, -32700, &format!("not JSON: {e}"))), Err(e) => Some(error_response(Value::Null, -32700, &format!("not JSON: {e}"))),
} }
} }
reply = pending.join_next(), if !pending.is_empty() => match reply { Some(reply) => reply.map_err(std::io::Error::other)?, None => None }, Some(message) = received.recv() => Some(message),
else => break, done = pending.join_next(), if !pending.is_empty() => {
if let Some(Err(e)) = done {
return Err(std::io::Error::other(e));
}
continue;
}
}; };
if let Some(reply) = reply { if let Some(reply) = reply {
output.write_all(reply.to_string().as_bytes()).await?; output.write_all(reply.to_string().as_bytes()).await?;
@@ -72,15 +87,24 @@ where
} }
/// The IDE's answer to `message`, or an error response for each request in /// The IDE's answer to `message`, or an error response for each request in
/// it when the IDE can't be reached. /// it when the IDE can't be reached. An answer on an event stream goes to `sent`
async fn relay(discovery: &Path, project: &Path, message: &Value, scope: &str) -> Option<Value> { /// with the messages before it.
let failure = match post(discovery, project, message, scope).await { async fn relay(
Ok((StatusCode::ACCEPTED, _)) => return None, discovery: &Path,
Ok((_, body)) if !body.is_empty() => match serde_json::from_slice::<Value>(&body) { project: &Path,
message: &Value,
scope: &str,
sent: &tokio::sync::mpsc::UnboundedSender<Value>,
) -> Option<Value> {
let failure = match post(discovery, project, message, scope, sent).await {
Ok(Answer::Streamed { answered: true }) => return None,
Ok(Answer::Streamed { answered: false }) => "OpenIDEA's answer ended early".to_owned(),
Ok(Answer::Body(StatusCode::ACCEPTED, _)) => return None,
Ok(Answer::Body(_, body)) if !body.is_empty() => match serde_json::from_slice::<Value>(&body) {
Ok(reply) => return Some(reply), Ok(reply) => return Some(reply),
Err(_) => String::from_utf8_lossy(&body).trim().to_owned(), Err(_) => String::from_utf8_lossy(&body).trim().to_owned(),
}, },
Ok((status, _)) => format!("OpenIDEA answered {status}"), Ok(Answer::Body(status, _)) => format!("OpenIDEA answered {status}"),
Err(e) => e, Err(e) => e,
}; };
let errors: Vec<Value> = requests(message).into_iter().map(|id| error_response(id, NOT_OPEN, &failure)).collect(); let errors: Vec<Value> = requests(message).into_iter().map(|id| error_response(id, NOT_OPEN, &failure)).collect();
@@ -100,9 +124,23 @@ fn requests(message: &Value) -> Vec<Value> {
} }
} }
enum Answer {
Body(StatusCode, Bytes),
/// The messages of an event stream went to the channel; `answered`: one was a response.
Streamed {
answered: bool,
},
}
/// POSTs `message` to the server in the discovery file; a message for the /// POSTs `message` to the server in the discovery file; a message for the
/// user when that fails. /// user when that fails.
async fn post(discovery: &Path, project: &Path, message: &Value, scope: &str) -> Result<(StatusCode, Bytes), String> { async fn post(
discovery: &Path,
project: &Path,
message: &Value,
scope: &str,
sent: &tokio::sync::mpsc::UnboundedSender<Value>,
) -> Result<Answer, String> {
let not_running = || { let not_running = || {
format!( format!(
"OpenIDEA's MCP server is not running. Start OpenIDEA, open {} in it, and turn on Settings › Agents › MCP Server", "OpenIDEA's MCP server is not running. Start OpenIDEA, open {} in it, and turn on Settings › Agents › MCP Server",
@@ -125,6 +163,60 @@ async fn post(discovery: &Path, project: &Path, message: &Value, scope: &str) ->
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
let response = sender.send_request(request).await.map_err(|e| format!("cannot reach OpenIDEA: {e}"))?; let response = sender.send_request(request).await.map_err(|e| format!("cannot reach OpenIDEA: {e}"))?;
let status = response.status(); let status = response.status();
let body = response.into_body().collect().await.map_err(|e| format!("OpenIDEA's answer: {e}"))?.to_bytes(); let streamed = response
Ok((status, body)) .headers()
.get(header::CONTENT_TYPE)
.is_some_and(|v| v.to_str().is_ok_and(|v| v.starts_with("text/event-stream")));
let mut body = response.into_body();
if !streamed {
let body = body.collect().await.map_err(|e| format!("OpenIDEA's answer: {e}"))?.to_bytes();
return Ok(Answer::Body(status, body));
}
let (mut buffer, mut answered) = (Vec::new(), false);
while let Some(frame) = body.frame().await {
let frame = frame.map_err(|e| format!("OpenIDEA's answer: {e}"))?;
let Ok(data) = frame.into_data() else { continue };
buffer.extend_from_slice(&data);
for data in take_events(&mut buffer) {
if let Ok(message) = serde_json::from_str::<Value>(&data) {
answered |= message.get("method").is_none();
let _ = sent.send(message);
}
}
}
Ok(Answer::Streamed { answered })
}
/// The `data` of each complete server-sent event at the start of `buffer`, which keeps
/// the rest. Comments and other fields are skipped.
fn take_events(buffer: &mut Vec<u8>) -> Vec<String> {
let mut events = Vec::new();
while let Some(end) = buffer.windows(2).position(|w| w == b"\n\n") {
let block: Vec<u8> = buffer.drain(..end + 2).collect();
let block = String::from_utf8_lossy(&block).replace("\r\n", "\n");
let data: Vec<&str> = block
.lines()
.filter_map(|line| line.strip_prefix("data:"))
.map(|d| d.strip_prefix(' ').unwrap_or(d))
.collect();
if !data.is_empty() {
events.push(data.join("\n"));
}
}
events
}
#[cfg(test)]
mod tests {
use super::take_events;
#[test]
fn events_are_taken_whole_and_comments_skipped() {
let mut buffer = b": running\n\nevent: message\ndata: {\"a\":1}\n\nevent: message\ndata: {\"b\"".to_vec();
assert_eq!(take_events(&mut buffer), ["{\"a\":1}"]);
assert_eq!(buffer, b"event: message\ndata: {\"b\"");
buffer.extend_from_slice(b":2}\n\n");
assert_eq!(take_events(&mut buffer), ["{\"b\":2}"]);
assert!(buffer.is_empty());
}
} }
+46 -1
View File
@@ -1,7 +1,7 @@
//! The server over real HTTP, and the stdio bridge in front of it, with a //! The server over real HTTP, and the stdio bridge in front of it, with a
//! registry standing in for the IDE host. //! registry standing in for the IDE host.
use ide_mcp::{Discovery, McpServer, Tool, ToolCallError, ToolRegistry, discovery_path, stdio}; use ide_mcp::{Discovery, McpServer, ServerOptions, Tool, ToolCallError, ToolRegistry, discovery_path, stdio};
use serde_json::{Value, json}; use serde_json::{Value, json};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
@@ -39,6 +39,10 @@ impl ToolRegistry for Registry {
match name { match name {
"echo" if args["text"] == "fail" => Err(ToolCallError::Failed("it failed".into())), "echo" if args["text"] == "fail" => Err(ToolCallError::Failed("it failed".into())),
"echo" => Ok(format!("{} in {}", args["text"].as_str().unwrap_or(""), project.display())), "echo" => Ok(format!("{} in {}", args["text"].as_str().unwrap_or(""), project.display())),
"sleep" => {
std::thread::sleep(std::time::Duration::from_millis(args["ms"].as_u64().unwrap()));
Ok("slept".into())
}
_ => Err(ToolCallError::UnknownTool(format!("no tool {name:?}"))), _ => Err(ToolCallError::UnknownTool(format!("no tool {name:?}"))),
} }
} }
@@ -252,6 +256,20 @@ async fn the_stdio_bridge_relays_lines_and_explains_a_missing_ide() {
assert!(reply["error"]["message"].as_str().unwrap().contains("MCP server is not running"), "{reply}"); assert!(reply["error"]["message"].as_str().unwrap().contains("MCP server is not running"), "{reply}");
} }
#[tokio::test(flavor = "multi_thread")]
async fn the_stdio_bridge_relays_a_long_call_answered_on_an_event_stream() {
let s = setup();
let call = json!({ "jsonrpc": "2.0", "id": 4, "method": "tools/call",
"params": { "name": "sleep", "arguments": { "ms": 1500 }, "_meta": { "progressToken": 4 } } });
let mut output = Vec::new();
stdio::bridge(&s.discovery, &s.project, format!("{call}\n").as_bytes(), &mut output).await.unwrap();
let replies: Vec<Value> =
String::from_utf8(output).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect();
let answer = replies.last().unwrap();
assert_eq!(answer["id"], 4, "{replies:?}");
assert_eq!(answer["result"]["content"][0]["text"], "slept");
}
#[tokio::test(flavor = "multi_thread")] #[tokio::test(flavor = "multi_thread")]
async fn routing_an_argument_overrides_the_header_and_ambiguity_lists_projects() { async fn routing_an_argument_overrides_the_header_and_ambiguity_lists_projects() {
struct Many(Vec<PathBuf>); struct Many(Vec<PathBuf>);
@@ -348,3 +366,30 @@ async fn a_cancellation_that_arrives_before_the_call_is_remembered() {
let (_, body) = post(&s, "/mcp", &headers, &call).await; let (_, body) = post(&s, "/mcp", &headers, &call).await;
assert_eq!(serde_json::from_str::<Value>(&body).unwrap()["result"]["isError"], true); assert_eq!(serde_json::from_str::<Value>(&body).unwrap()["result"]["isError"], true);
} }
#[tokio::test(flavor = "multi_thread")]
async fn a_fixed_port_and_token_are_used_and_a_taken_port_falls_back_to_a_free_one() {
let mut s = setup();
s.server.take().unwrap().stop();
let free = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap().local_addr().unwrap().port();
let token = "ab".repeat(32);
let options = ServerOptions { port: Some(free), token: Some(token.clone()) };
let registry = Arc::new(Registry { project: s.project.clone() });
let runtime = tokio::runtime::Handle::current();
s.server =
Some(McpServer::start_with(registry.clone(), Some(s.discovery.clone()), &runtime, options.clone()).unwrap());
assert_eq!((s.server().port(), s.server().token()), (free, token.as_str()));
assert!(s.server().port_unavailable().is_none());
assert_eq!(Discovery::read(&s.discovery).unwrap().port, free);
let list = rpc(&s, "tools/list", json!({})).await;
assert_eq!(list["result"]["tools"][0]["name"], "echo");
// The port is taken (by the first server): a second one listens elsewhere and says why.
let second = McpServer::start_with(registry, None, &runtime, options).unwrap();
assert_ne!(second.port(), free);
assert!(
second.port_unavailable().is_some_and(|why| why.contains(&free.to_string())),
"{:?}",
second.port_unavailable()
);
}
+6 -1
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; use views_bindings::openidea::plugin::view_types as wit_vt;
/// The API version of the world above, `major.minor` of [`host_api`]. /// 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); const EPOCH_TICK: Duration = Duration::from_millis(10);
pub(crate) const MAX_TEXT: usize = 1 << 20; pub(crate) const MAX_TEXT: usize = 1 << 20;
pub(crate) const MAX_TITLE: usize = 256; pub(crate) const MAX_TITLE: usize = 256;
@@ -170,6 +170,11 @@ pub trait HostApi: Send + Sync {
let _ = (client, tab, cancel, timeout); let _ = (client, tab, cancel, timeout);
Err(HostError::Failed("content tabs unavailable".into())) 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. /// Answers a content action in the invoking window.
fn reply_content_tab(&self, client: &str, request: &str, reply: crate::ContentTabReply) -> Result<(), HostError> { fn reply_content_tab(&self, client: &str, request: &str, reply: crate::ContentTabReply) -> Result<(), HostError> {
let _ = (client, request, reply); let _ = (client, request, reply);
+3
View File
@@ -922,6 +922,9 @@ pub struct FileDecoration {
pub badge: Option<String>, pub badge: Option<String>,
pub style: String, pub style: String,
pub tooltip: Option<String>, pub tooltip: Option<String>,
/// Text shown after the name (API 1.7 `set-file-note`).
#[serde(default)]
pub note: Option<String>,
} }
/// Commit metadata, date in Unix seconds. /// Commit metadata, date in Unix seconds.
#[derive(Clone, Debug, Serialize, Deserialize, Type)] #[derive(Clone, Debug, Serialize, Deserialize, Type)]
+18 -1
View File
@@ -1770,6 +1770,12 @@ impl views::Host for StoreData {
) )
.map_err(to_wit)) .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<()> { fn reply_content_tab(&mut self, request_id: String, reply: views::ContentTabReply) -> Fallible<()> {
if let Err(e) = self.require_window() { if let Err(e) = self.require_window() {
return Ok(Err(e)); return Ok(Err(e));
@@ -2491,7 +2497,7 @@ impl bindings::openidea::plugin::decorations::Host for StoreData {
.file_decorations( .file_decorations(
Some(&real), Some(&real),
&set_id, &set_id,
Some(crate::FileDecoration { badge: item.badge, style: item.style, tooltip: item.tooltip }), Some(crate::FileDecoration { badge: item.badge, style: item.style, tooltip: item.tooltip, note: None }),
) )
.map_err(to_wit)) .map_err(to_wit))
} }
@@ -2501,6 +2507,17 @@ impl bindings::openidea::plugin::decorations::Host for StoreData {
} }
Ok(self.host.file_decorations(None, &set_id, None).map_err(to_wit)) Ok(self.host.file_decorations(None, &set_id, None).map_err(to_wit))
} }
fn set_file_note(&mut self, path: String, set_id: String, note: String, tooltip: Option<String>) -> Fallible<()> {
if let Err(e) = self.require(Capability::ProjectRead) {
return Ok(Err(e));
}
let real = match self.decoration_path(&path) {
Ok(real) => real,
Err(e) => return Ok(Err(e)),
};
let item = crate::FileDecoration { badge: None, style: String::new(), tooltip, note: Some(note) };
Ok(self.host.file_decorations(Some(&real), &set_id, Some(item)).map_err(to_wit))
}
} }
pub(crate) mod language_features_bindings { pub(crate) mod language_features_bindings {
+5 -5
View File
@@ -91,15 +91,15 @@ fn the_sdk_carries_the_hosts_wit() {
#[test] #[test]
fn the_version_check_runs_compatible_components_and_explains_the_rest() { fn the_version_check_runs_compatible_components_and_explains_the_rest() {
let host = v("1.6.0"); 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.0.0-rc.1"] { 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}"); assert_eq!(check_api(&v(ok), &host), Ok(()), "{ok}");
} }
let newer = check_api(&v("1.7.0"), &host).unwrap_err(); let newer = check_api(&v("1.8.0"), &host).unwrap_err();
assert!(newer.contains("1.7") && newer.contains("update OpenIDEA"), "{newer}"); assert!(newer.contains("1.8") && newer.contains("update OpenIDEA"), "{newer}");
assert!(check_api(&v("2.0.0"), &host).unwrap_err().contains("update OpenIDEA")); assert!(check_api(&v("2.0.0"), &host).unwrap_err().contains("update OpenIDEA"));
let older = check_api(&v("0.2.0"), &host).unwrap_err(); 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. // 0.x: every minor is its own API.
assert_eq!(check_api(&v("0.2.0"), &v("0.2.5")), Ok(())); 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()); assert!(check_api(&v("0.1.0"), &v("0.2.0")).is_err());
Binary file not shown.
@@ -1 +1 @@
005d9da29eb7d713a97ff872051e408f653030434d837835843298553c9e12e0 d6c855f1f91aeb02eec5d6c8a07be4eb64bae0704619f44cb67f3b832daf01ee
Binary file not shown.
@@ -1 +1 @@
6359ef4271f38374889412bd926d9aa735f59a8ce047ffcb06c47976444196cf 51d00702c42c7167083caea70cf2df022e512bcaf89af8addf526421ff45a15f
Binary file not shown.
@@ -1 +1 @@
6f560f3d0d7737150d2dfa09c3b801bd3b4c3a52cef9893ed8fc045809fe33e1 40d2fd76ba96b0c93ea0a034c6749709ddc00fc119046e653a50b180a9c4718d
Binary file not shown.
@@ -1 +1 @@
4024af2d55d7fe4e8c456efd7e0c497ade4b2c5959e9108ff218f4a669e920f4 08822287f3649412340f9dbbe2f0ea5a5cd2f543a3c242c5d0a29c422c4c5367
Binary file not shown.
@@ -1 +1 @@
4ca977f53a18e9e8f869d34e027a15abece9e97b9331e23d4f71af721b104611 43eba23cc14cf3c74bfc934099cd58df20f07ee5df96cc10100b90016c544a7a
Binary file not shown.
@@ -1 +1 @@
3d12a00f81e0db1b0902c2ddebfd6454a056f15b97add80b22a0333722568dc8 520d9a95328dc772bbb913a2e5e22af8243c95ca45744cbb65b25747653d2a34
Binary file not shown.
@@ -1 +1 @@
95d89e411216f50d0b34bee110c6bfc9b8fbcb2ffaae2e912c3609f7bf678b2c acd3d0ee3f886c6a271ce3139200ce5bdb46e40f9c68e495c5ff9b4db98a88ac
@@ -40,6 +40,10 @@ title = "write"
id = "content" id = "content"
title = "content" title = "content"
[[contributes.commands]]
id = "diff"
title = "diff"
[[contributes.commands]] [[contributes.commands]]
id = "events" id = "events"
title = "events" title = "events"
+12
View File
@@ -78,6 +78,18 @@ impl Plugin for Execution {
})?; })?;
Ok(None) 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"), "events" => host::storage::get("events"),
"validation" => host::storage::get("validation"), "validation" => host::storage::get("validation"),
_ => Err(Error::NotFound(id.into())), _ => Err(Error::NotFound(id.into())),
Binary file not shown.
@@ -1 +1 @@
dd4b6c5153892a4f3facb3f23cb1d92da39a3977985b8abf4eaa9558decfa4a9 0cf3bc2197f0bd5339fe3c2199bd973e68b3637b8c5ae07902e62659160275e3
Binary file not shown.
@@ -1 +1 @@
325bc15cd28933d9687bc3c316008f921f48a4a69d7528c6b367fa0ff22489b8 17f837c8d6f58800728e00060afe68b2a093085d816d809c9e67065fdd165352
Binary file not shown.
@@ -1 +1 @@
abe706392e04ef5a14f9feab88e0c0d6a9e96bee7de771b61deeb65e7d9e0a7c 2cca80220fab6764a8e1f51db8ed2f55b603000c8d0dbf5913a441876b7857ba
Binary file not shown.
@@ -1 +1 @@
fb3677946021b30d226e2e3d422c352645c2065c93057c0a645246520860fc90 54211437002ad83a9d3d642f2dca5f37cadd89d7d77ddc7de545db87b99fb8b7
Binary file not shown.
@@ -1 +1 @@
a01852ead9004ac1558028da19a1ae96ccd035775484089fd39f14263082daf1 4326d9cdefe71f4bb9aab043ccef883abbd13145b1579352b97c66889f514c68
Binary file not shown.
@@ -1 +1 @@
bf00050b7bb9911680a94fd36aaeb83fc96d94030e8ceef51d1723c3950253f9 2469023f4f29b9abdaa27c9d96774229c4dd002366421dc508926c3fed1fba1b
Binary file not shown.
@@ -1 +1 @@
d00a07d9d51e7ca117cec59070d2812d4d1efb2730b60a6f171c8fd12903d055 233461ab52a12e27fa600e108e83cab1b930b3e61812e0c048c99c1e18c6d646
Binary file not shown.
@@ -1 +1 @@
0aa0ee56e8bacd4d218bafecdec7f4b6249381fad6e31f8b2ee89fd88b16d710 bfc654523da608bbc008ea6a15a0cfd0b129ffab70088bf6b610786b63b5352e
Binary file not shown.
@@ -1 +1 @@
e06955818969808e6695fba9178727239e343bc4542efbf56262dd407175fd8a c8536be072d27aa89f5e92b5b96e38ed65b7f06c35c05ecbd4a151218203c5f7
@@ -1 +1 @@
7af67673471d49f97aa2cc131b5deddfc78ee7fc203901eba12f4970a5b13f75 7c0c1f5035e12c794d48cb75a8fc259d11dd99ba3188030fe1d9956720ee6ad5
Binary file not shown.
@@ -1 +1 @@
e81d2454a61977ab69dc8b5c3c617627c559c4e340337a3d43309daeb9b1c12d 3cf5fd6ab0ce909b806230e20a7fa46264f85937018cfa68865ec0eeb92d4b72
+3 -3
View File
@@ -5,9 +5,9 @@ wit_bindgen::generate!({
inline: " inline: "
package fixture:misbehave; package fixture:misbehave;
world misbehave { world misbehave {
include openidea:plugin/plugin-syntax@1.6.0; include openidea:plugin/plugin-syntax@1.7.0;
export openidea:plugin/providers@1.6.0; export openidea:plugin/providers@1.7.0;
export openidea:plugin/file-actions@1.6.0; export openidea:plugin/file-actions@1.7.0;
} }
", ",
path: "../../../../../wit", path: "../../../../../wit",
Loaded 100 of 431 files, more files were not shown because too many files have changed in this diff. Show more