Порівняти коміти

...
Автор SHA1 Повідомлення Дата
l0ng-ai 7c9ff885dd fix(debugger): let a Python program read input while it is debugged
debugpy launched the program with console "internalConsole", which gives it no
standard input: input() raised EOFError right after printing its prompt. The
program now runs on the pseudo-terminal the debugger already provides for
runInTerminal, so what is typed in the debug console reaches it, as in a Run.
Its output reaches the console from that terminal, so debugpy no longer
captures it a second time (redirectOutput).
2026-11-14 21:33:53 +08:00
l0ng-ai 4e90d9fd5c fix: keep a JAR application's derived Windows java.exe off saved configurations; fix three Windows-only tests (#3368)
Saving a JAR Application configuration on Windows stored the project JDK's full ...\bin\java.exe instead of the generated java, so a later Project JDK change no longer reached it: undoing a derived command only tried the file name. The name without .exe is tried first now.

Windows-only test failures on main: the plugin fuel test's 3_500 budget did not cover activation on Windows (6_000 still proves the per-report refill, which patient needs about 9_000 without); the class-folder test built JDT's memento from a native path; the download test passed its missing remote path with backslashes.
2026-11-14 21:26:01 +08:00
pravdomil ffc2f75d3b fix(markdown): restore the Preview only scroll position when going Back after clicking a link to another Markdown file (#3363)
In Preview only, navigation history recorded the hidden editor's caret, and the preview's caret-follow effect then scrolled to that line over the offset the preview had restored. History now records the line the preview shows, the current entry is refreshed before opening another file, and the preview keeps its restored offset when the caret is already on the line it shows.

Co-authored-by: pravdomil <[email protected]>
2026-11-14 21:25:18 +08:00
Akbar Husain 19587af8b1 fix(vcs): Rollback acts on the checked changes when the selection has none (#3330)
Toolbar Rollback in the Commit window resolved its targets from the tree selection only, so with changes checked but an unversioned file selected it reported "No changes to roll back". The selection is used while it holds rollbackable changes; otherwise the checked changes are.

Co-authored-by: akbar1214 <[email protected]>
2026-11-14 21:25:12 +08:00
akshathayadav378-boopтаakshathayadav378-boop b2c22ccec6 fix(ui): keep a file drag from selecting the text it passes over (#3358)
Dragging a file from the Project view selected the editor text the pointer passed over (#3334). WebKit keeps contenteditable text selectable whatever its user-select, and starts a selection there during a mouse drag even when the press selected nothing. beginDrag() now clears the selection and marks every pointer drag (startPointerDrag, the Project view's own file drag, tool window stripe buttons): no selection, and inputs, textareas and contenteditable out of hit testing (drop targets are their containers).

Co-authored-by: akshathayadav378-boop <[email protected]>
2026-11-14 21:24:20 +08:00
LunarFellerтаLunarFeller f795aa3232 fix(agents): ignore task replies from previous projects (#3356)
Starting or forking a task in project A and switching to B before the reply arrived let A's late reply insert its task into B and replace B's New Agent Task page. startTask and forkTask now check the connection generation they started under before handling the reply or error, and again after Home closes, covering reconnects and A -> B -> A.

Co-authored-by: LunarFeller <[email protected]>
2026-11-14 21:13:23 +08:00
Shohruh Ismoilovтаshohruh-genius 9239b99f3a test(settings): follow the grouped Language Servers page (#3360)
The Language Servers page now groups tools by language (#3272). The search, settings and settingsPaneLayout specs still looked for the old single tool list, and the details pane directly under the body, so they failed on main.

Co-authored-by: shohruh-genius <[email protected]>
2026-11-14 21:10:05 +08:00
l0ng-ai c5be2998ec chore: v0.1.45 2026-11-14 19:38:04 +08:00
l0ng-ai b83df2dbda fix(agents): keep the session status line readable in a narrow panel
The status line under a session wraps what doesn't fit to the next line
instead of shrinking every fact to a letter or two; the changes chip
starts that line and the meters end it. The current-file hint keeps to
one line, and references in the composer get a remove button like the
attached blocks.
2026-11-14 19:24:55 +08:00
l0ng-ai 9f7a35f170 fix(boot): allow 60 seconds for startup before showing the failure page
A cold start can take longer than 30 seconds before the workbench is up
(the first load of a development build, a slow machine), and the failure
page then stays over a window that did start.
2026-11-14 19:24:54 +08:00
l0ng-ai 52b3e14a8c fix(ui): translate template names, keep stash messages readable, retitle tool windows on a language switch (#3361)
Built-in template names are translated where the New menu, popup, dialog and actions show them (they stay identifiers); the Stash list gives its message the room, keeping the time whole and hiding the branch in a narrow list; an open tool window's header follows a language switch.
2026-11-14 19:04:08 +08:00
l0ng-ai 934e4ca0e7 test(database): keep the SSH tunnel test's command off Tauri's invoke (#3357)
The SSH tunnel test mocked call but not the command it receives, so commands.dbTestTunnel ran Tauri's invoke in jsdom and its unhandled rejection failed the Frontend job on main. The test stubs dbTest/dbTestTunnel now.
2026-11-14 18:52:31 +08:00
l0ng-ai a2809bda80 feat(database): search the tree from a field over the toolbar
Typing in the Database tree opens a search field over the toolbar's
first buttons, with a caret and a toggle that shows only the matching
objects; matched text is highlighted in the tree. The always-visible
"Filter objects" box in the toolbar is removed along with its state
and the loading it triggered.
2026-11-14 18:49:30 +08:00
l0ng-ai 0fd8389b33 fix: close gaps in the result submit, busy close and browser navigation fixes (#3352)
Pending result edits live only in the shared per-result store, so a submit finishing while its pane is unmounted clears the remounted pane even if the refresh fails; busy tabs are checked again after discards, skipping already-confirmed ones; the reused debug page's target is made canonical in the IDE instead of using the page's URL global.
2026-11-14 18:33:59 +08:00
l0ng-ai 19c0ae07f2 fix: close gaps in the LSP URI, index snapshot and MCP restart fixes (#3349)
Encode a diagnostic's codeDescription.href; track a linked worktree's shared config and exclude file in the index snapshot; wait for the stopped MCP server's port at any address, since 127.0.0.1 and 0.0.0.0 on one port overlap.
2026-11-14 18:31:45 +08:00
l0ng-ai 5a5b53d86b fix(vcs): stash the whole working tree again (#3348)
#3314 passed --literal-pathspecs on every stash push; a whole-tree stash with untracked files or keep-index makes git pass itself the pathspec :/, which then failed or left untracked files behind. Literal pathspecs are used only for chosen files. The integration tests' stash_push calls are updated to the new signature (they broke main's build).
2026-11-14 18:31:07 +08:00
l0ng-ai ac7a81db1c fix(agents): keep the new-task composer still when switching agents
The notes under the composer (agent version, first-task hint, warnings)
change from 0 to 2 lines as the agent is switched and probed. The home
view centres its content, so each change moved the composer. The notes
now sit in a zero-height container that doesn't take part in centring;
the docked layout keeps them in flow.
2026-11-14 18:27:14 +08:00
l0ng-ai 9681e1a118 fix(settings): select the page a search names over pages that mention it (#3345)
Settings search selected the first page in tree order matching anywhere (Plugins → Notifications, TypeScript → Keymap). A matching page whose title is the query, or starts with it, now wins.
2026-11-14 18:19:20 +08:00
l0ng-ai 293972baaa fix(mcp): wait only for the stopped server's own port on a restart (#3341)
With the fixed MCP port taken by another program the server runs on a free port, yet every restart polled the taken port for two seconds, blocking the window. A restart now waits only for the port the stopped server held.
2026-11-14 18:02:19 +08:00
l0ng-ai b173a53004 fix(index): check the ignore files above the project before a warm start (#3340)
The startup snapshot (#3286) did not record the ignore files above the project root that the walker reads, nor the enclosing repository's exclude file and config, so changing them while the project was closed kept a stale file list. They are now part of the snapshot's external inputs.
2026-11-14 17:58:25 +08:00
l0ng-ai f6e54f581b fix(debugger): send a reused browser page to each launch's address, keep multi-line watches whole (#3339)
A reused debug browser compared a new launch's URL with the one it was started on, so A → B → A left the page on B; the page now compares its own address. Live watches split the watch list on newlines, evaluating a multi-line watch line by line; the list is serialised as JSON now.
2026-11-14 17:55:35 +08:00
l0ng-ai 276a30849c fix(database): keep a result's submission from repeating after switching tabs (#3337)
The submit-in-flight state of a result pane was component-local, so switching result tabs during a submit re-enabled Submit on the restored edits (#3114) and could insert a row twice. It is kept per result now, and a successful submit drops the kept edits directly.
2026-11-14 17:53:03 +08:00
l0ng-ai 63abedaadd fix(editor): ask before ending a busy tab's session ahead of discarding changes (#3336)
Bulk closes discarded the documents answered with Discard before asking whether to end busy tabs' sessions (#3177), so Cancel there lost the changes. The busy-tab question is now asked before any discard.
2026-11-14 17:51:20 +08:00
l0ng-ai c22a7fa112 fix(lsp): encode only the URI fields of a server's messages (#3335)
#3234 percent-encoded every string starting with file:, so formatting edits and completions whose text begins with file: (YAML, .env) had spaces and newlines escaped. Only URI fields and workspace-edit changes keys are encoded now.
2026-11-14 17:49:21 +08:00
l0ng-ai a46f187428 fix(menu): give every menu item a catalog label (#3328)
22 menu items (Open File…, Remote Development…, Deployment and SSH items, Navigation Bar, Update Application, project-tab window items) had no menu.* catalog key and stayed English in translated menus. They get en/zh labels, and a test checks every menu action and submenu has both.
2026-11-14 16:57:33 +08:00
l0ng-ai ff826279f2 feat(menu): group the File menu's creation items under a New submenu (#3327)
File now starts with New › Project…, Workspace…, File…, Scratch File, followed by the open items, the workspace items and the project items in separated groups. The workspace items get menu.* catalog labels (they showed in English in other languages). Labels of the moved items are shortened in every bundled locale; docs naming these paths are updated.

Closes #3305
2026-11-14 16:55:16 +08:00
l0ng-ai 759fb3278d test(i18n): allow the tsserver memory sizes to read the same in every locale (#3326)
The tsserver memory limit options from #3325 are numbers with a unit whose Chinese text equals the English; list them as same-in-every-language so i18n.test.ts passes on main again.
2026-11-14 16:51:45 +08:00
Muuтаmuutot f7d19e1711 feat(vcs): list the incoming and outgoing commits (#3309)
The title-bar branch widget showed incoming/outgoing counts with no way to see the commits. Clicking a marker, or Incoming and Outgoing Commits… in the Branches popup, now opens a popup listing them (hash, subject, author, relative date) from the existing vcsLog ranges branch..upstream / upstream..branch. A row opens the commit's diff, Open in Git Log shows it in the log, and the keyboard moves and opens rows. A single-direction popup doesn't repeat its title as a section header. Empty states cover no upstream, up to date and an empty range. Docs and both text catalogs are updated.

Co-authored-by: muutot <[email protected]>
2026-11-14 16:47:27 +08:00
Qingтаqtvbwfn 97d202ade0 fix(vue): keep tsserver from crash-looping at its heap limit (#3325)
In Vue hybrid mode one tsserver program holds every .vue file's virtual TypeScript, and vtsls' default 3072 MB heap ended in a GC death spiral and restart loop. Hybrid mode now raises the heap to 8192 MB, runs without the separate syntax server, and turns off automatic type acquisition when a tsconfig/jsconfig is present. When tsserver dies at its heap limit, its running progress tasks are ended and the loading state is cleared, so the restarted instance reports fresh instead of leaving zombie tasks and held-back answers.

A new TypeScript setting, tsserver memory limit (0 = auto), sets maxTsServerMemory for every vtsls project; auto keeps 8192 MB in hybrid mode and the server default elsewhere.

Co-authored-by: qtvbwfn <[email protected]>
2026-11-14 16:46:07 +08:00
ntbang0901 f0f15366e4 feat(debugger): Network tab for a debugged page (#3315)
A page session gets a Network tab in the Debug tool window: the page's requests with status, method, type, size and time, filtered by URL, resource kind and My code only (a request sent through a library is attributed to the project code that called it). A request shows its headers, response body (text, cut at 1 MB) and initiator; Break on Next Request turns it into a request breakpoint, and Reload Page loads the page again. js-debug's enableNetworking is asked once the page's launch is answered; a js-debug that doesn't report requests says so in the tab.

Co-authored-by: ntbang0901 <[email protected]>
2026-11-14 16:37:23 +08:00
ntbang0901 fecbf8ed14 feat(debugger): evaluate watches while a JavaScript program runs (#3318)
js-debug evaluates without suspending, so with Settings › Debugger › Evaluate watches while the program runs (off by default) the watches of a running Node.js program or page are evaluated once a second. Nothing is evaluated while the Debug tool window is hidden or the session is paused or ended, and a slow round isn't joined by another. Other debuggers keep showing "Not suspended".

Co-authored-by: ntbang0901 <[email protected]>
2026-11-14 16:35:01 +08:00
Muuтаmuutot 2d00f09393 feat(vcs): let annotate skip the commits in blame.ignoreRevsFile (#3310)
Annotate passes the file blame.ignoreRevsFile names to git blame (resolved from the worktree root, read from the config snapshot so Restricted Mode starts no program); a configured file missing from disk is cleared with --no-ignore-revs-file. The annotation context menu gets Ignore This Commit, which appends the commit to the list git reads: the configured file, else .git-blame-ignore-revs at the repository root, which the local config then names. Existing lines and line endings are kept and a listed commit isn't repeated.

Co-authored-by: muutot <[email protected]>
2026-11-14 16:24:18 +08:00
Muuтаmuutot f154bf8c83 feat(vcs): stash only the selected files (#3314)
The changes tree context menu gets Stash Selected Files…, using the Stash dialog. git stash push gets the selected paths (with --literal-pathspecs), grouped per repository like Shelve, so a multi-repository selection stashes each repository's part; a path outside the repository is rejected.

Co-authored-by: muutot <[email protected]>
2026-11-14 16:22:07 +08:00
Mansur Akhmadiтаmansur-akhmadi d3720c0e11 feat(vcs): choose the repository in the Pull dialog when the project has several (#3312)
With two or more Git repositories, the Pull dialog shows a Repository combo before the remote. Picking one reloads its remotes, branches and branch info, and Pull runs in that repository; the choice stays local to the dialog.

Closes #3311

Co-authored-by: mansur-akhmadi <[email protected]>
2026-11-14 16:21:49 +08:00
l0ng-ai ec862b3668 test(libraries): index the refreshed node's children safely (#3324)
The refresh test from #3317 failed pnpm typecheck under noUncheckedIndexedAccess; it now uses optional access at each index.
2026-11-14 16:14:27 +08:00
l0ng-ai 735830d642 fix(remote): trust SSH host keys and passwords on the host that connects (#3323)
In a remote window (SSH, WSL, dev container) deployments, data source tests and run targets
connect from the server, but "Trust Host Key and Retry", a password entered for an SSH
configuration and the SSH settings page's checks went to the desktop's SSH manager. The server
then still didn't know the key ("no host key to trust" on the desktop), so a configuration
that worked in a local window could never connect from the remote one. The SSH configuration
commands are now served by the remote host too, like db_trust_ssh_host already was.

Fixes #3313
2026-11-14 16:09:39 +08:00
LunarFellerтаLunarFeller 65c0413dbb fix(agents): preserve drafts edited while sending (#3321)
Send and Start cleared the composer when the agent acknowledged the prompt, wiping text or attachments added while waiting. The acknowledgement now clears the composer only if it is still the submitted revision in the same project connection.

Co-authored-by: LunarFeller <[email protected]>
2026-11-14 16:08:25 +08:00
l0ng-ai 14bb7bcc6f fix(database): say that Oracle servers older than 12c are not supported (#3322)
The thin Oracle driver refuses servers older than 12c during the handshake with a generic "connections to this database server are not supported". That error now says the server is older than 12c and that OpenIDEA connects to 12c and later.

Part of #3301
2026-11-14 16:07:01 +08:00
l0ng-ai 8113d101ef fix(remote): finish a remote command whose channel stays open after it exits (#3319)
Session::exec returned only when the server closed the channel, so a remote find whose channel outlived the command (a background child holding stdout) hung sync before run on "Comparing project files" and the folder-by-folder fallback never ran. exec now drains output for a quiet period after the exit status and closes the channel, as spawn already did; the tree listing also stops when the run is cancelled.

Fixes #3299
2026-11-14 15:53:54 +08:00
l0ng-ai 5f7e05adc4 fix(libraries): keep External Libraries while a refresh is under way (#3317)
Partial streamed results (even an empty batch) replaced the whole External Libraries node, and the old node was removed before the new one was added, so the node vanished during every refresh. Partial results now keep the ecosystems that haven't reported yet and the node is replaced in place. When cargo metadata --offline fails because an edited manifest names crates not downloaded yet, the last graph for that workspace stays instead of an empty Cargo.lock fallback.
2026-11-14 15:46:08 +08:00
l0ng-ai f2b85eca4c fix(debugger): open the Debug window on Console, and on the view last picked (#3316)
The Debug window switched to Console only when it was open while the session was starting; a window opened later mounted on Threads & Variables, and a remount forgot the user's choice. It now opens on Console until a view is picked, then on the view last picked; a breakpoint or exception still brings Threads & Variables forward.
2026-11-14 15:46:03 +08:00
LunarFellerтаLunarFeller e41cf32a94 fix(http-client): preserve numeric environment values in requests (#3176)
Numeric values in http-client.env.json and http-client.private.env.json were parsed into serde_json numbers and printed back, which rounds large integers and long decimals (123456789012345678901234567890 became 1.2345678901234568e+29). Variables are now read as raw JSON tokens, so the URL, headers and body get the number exactly as written; strings, booleans, nulls and nested objects behave as before.

Refs #3105.

Co-authored-by: LunarFeller <[email protected]>
2026-11-14 15:32:01 +08:00
ntbang0901 0cd19a8c0f feat(debugger): break on DOM events and requests in a debugged page (#3306)
The Breakpoints dialog gets a JavaScript Events and Requests section: stop when a debugged page handles a mouse, keyboard, form, page or timer event, or sends a request whose URL contains a given text. They are kept per project and sent through js-debug's setCustomBreakpoints to every page session, live ones at once and later ones when they start. A page's requests wait until js-debug has answered its launch, since requests sent while it sets the session up are acknowledged and dropped. A stop on one of these says what it was for above the frames, and js-debug's Caught Exceptions filter explains that it is how errors a framework catches are stopped at. A new page session whose id was used before (after a backend restart) also gets the breakpoints.

Co-authored-by: ntbang0901 <[email protected]>
2026-11-14 15:26:07 +08:00
ntbang0901 fb76a6dab7 feat(debugger): open the page of a debugged dev server, and debug a URL from a console (#3302)
Debugging a Node.js program that prints a local address serving an HTML page can open that page in a browser debug session (Settings › Build, Execution, Deployment › Debugger › JavaScript, off by default). Ctrl/Cmd+Shift-clicking a URL in a console debugs it in the browser. The new web_serves_page command only asks loopback addresses, without a proxy, follows redirects only within this machine and times out after 10 s.

Co-authored-by: ntbang0901 <[email protected]>
2026-11-14 15:24:33 +08:00
LunarFellerтаLunarFeller f4502ffffe fix(database): keep terminating averages exact (#3303)
Selection and chart means stopped after 12 fractional digits even when the division terminates, which could also change an integer mean (1220703130000 instead of 1220703125000). The shared decimal division now continues until the remainder is zero when the reduced denominator has only factors 2 and 5; repeating quotients keep their rounding.

Co-authored-by: LunarFeller <[email protected]>
2026-11-14 15:10:35 +08:00
Akbar Husain 2430c892ba fix(editor): keep inlay hints and lenses while a language server is busy (#3307)
Inlay hints and Code Vision rows were replaced with whatever a language server answered while indexing or restarting, often nothing, so the text reflowed and jumped. Shown hints, server lenses and usages lenses are now kept while a server is starting or busy (and when a usages count is still computing), and refreshed once the server settles; a server that stays down still takes its decorations with it.

Co-authored-by: akbar1214 <[email protected]>
2026-11-14 15:09:43 +08:00
Muuтаmuutot 8876d04cb8 docs(vcs): creating a repository in an open project is supported (#3308)
The VCS guide said there is no git init action for an open project and listed it as unsupported. Git › Create Git Repository… runs git init in a chosen folder and registers the repository, so the guide now describes it next to Clone… and drops the unsupported entry.

Co-authored-by: muutot <[email protected]>
2026-11-14 15:07:54 +08:00
l0ng-ai eeb20f467c feat(plugins): list installed and marketplace plugins together, in foldable groups (#3300)
Settings › Plugins drops its Available / Installed tabs. Search and the category filter move to the top bar, and one list shows the installed plugins (plus linked and project plugins), then the suggested, featured and all marketplace plugins, examples and local packages; each group header folds it. The details follow the entry clicked last, falling back to the other side's first entry when nothing is listed on that side.
2026-11-14 13:58:16 +08:00
zhzhsxтаzhzhsx 4ccff88ccf fix(editor): align completion labels with the word being completed (#3297)
CodeMirror anchors the completion popup at the start of the word being completed, but each row starts with a kind icon, so labels sat one icon column to the right of the text. The popup is offset left by the row's leading inset (border, row margin, padding, icon and gap), kept in custom properties that the row rules use too, so the labels line up with the word.

Co-authored-by: zhzhsx <[email protected]>
2026-11-14 13:50:26 +08:00
ntbang0901 fdf29989ec feat(debugger): debug JavaScript that runs in the browser (#3294)
Code running in a browser could not be debugged: js-debug was only started for Node.js and launch.json entries of type chrome / msedge were skipped on import. A JavaScript Debug run configuration (URL, Chrome or Edge, web root) now opens the page with js-debug attached, so breakpoints in the page's sources are hit through source maps with the usual stepping, variables, evaluate and console. Each project gets its own browser profile under the IDE's local data, a project has one debugged browser of a kind (starting again reuses it), launch.json chrome / msedge launches import as this configuration, child sessions named about:blank take their parent's name, and Vue, Svelte, Astro and HTML files take breakpoints. Not yet available in remote projects.

Co-authored-by: ntbang0901 <[email protected]>
2026-11-14 13:49:21 +08:00
l0ng-ai 3c3b6a6fd1 fix(run): translate the message for a preferred JavaScript runtime that is not installed (#3298)
The message added in #3291 for a preferred runtime that is not installed was a plain template string, so it was never translated and textCatalog.test.ts failed on main. It goes through tx now, with the Chinese text in the catalog.
2026-11-14 13:43:35 +08:00
ntbang0901 49bb6b2d76 fix(debugger): keep watches per project (#3293)
Watches were one list shared by every project, so a watch added while debugging one project showed up, and usually failed to evaluate, in all the others. Each project now keeps its own list; a project without one starts from the old shared list once, since that list doesn't record which project each watch came from.

Co-authored-by: ntbang0901 <[email protected]>
2026-11-14 13:40:10 +08:00
zhzhsxтаzhzhsx 632e564d58 fix(editor): let Tab step out of the parentheses a completion inserted (#3292)
With "Jump outside closing bracket/quote with Tab" on, Tab only stepped over a closing character that typing had inserted; after accepting a call completion such as `order.withPrice()` it indented inside the parentheses. Completion transactions now register the pair at the tail of the inserted text, including call snippets whose caret moves inside in a later selection-only transaction.

Co-authored-by: zhzhsx <[email protected]>
2026-11-14 13:39:32 +08:00
zhzhsxтаzhzhsx 08333bcff8 fix(terminal): hide the tool window or refocus when a session ends (#3290)
With "Close session when it ends" on, a session that exited on its own closed its tab but left an empty Terminal tool window open, and the pane menu's Close Session did the same. Both paths now follow the tab close button: the tool window hides when no tabs remain; otherwise the active session takes the caret, which for a shell exiting on its own happens only when a terminal had the caret, so a background exit doesn't pull focus out of the editor.

Co-authored-by: zhzhsx <[email protected]>
2026-11-14 12:53:37 +08:00
l0ng-ai ae542b3044 feat(javascript): choose the preferred runtime on a JavaScript Runtime settings page (#3291)
The Node.js settings page becomes JavaScript Runtime with a Preferred runtime choice: Auto (the nearest deno.json / bun.lock, else Node.js, as before), Node.js, Bun or Deno. It decides what runs JavaScript and TypeScript files from the gutter and the editor's Run action, files outside the project included; a chosen runtime that is not installed is reported instead of falling back to Node.js. The package manager sits beside it, the Node interpreter and run target under a Node.js section. New setting languages.node.runtime.

Closes #3233
2026-11-14 12:48:05 +08:00
l0ng-ai 4cb98ff162 feat(tools): organize Language Servers settings by language (#3272)
Settings › Language Servers listed every tool in one flat list. The page now lists languages, with their file-type icon and how many tools each is missing; the selected language's tools are grouped into language servers, formatters, debuggers and plugins, each a small table with status, version and an inline Install link, and the selected tool's settings open under its table. Interchangeable servers get one "Language server for …" picker above them, and a Ready / Partly set up / Not set up label with an Install Missing button covers what the language needs. Update checks for installed tools show as an Update tag in the table and an Update button with the available version in the tool's details.
2026-11-14 12:37:05 +08:00
Akbar Husain cc8ccea888 perf(index): load an unchanged project's index from a startup snapshot (#3286)
Reopening a project re-walked every file, canonicalized each path and opened redb before results were available. The index now writes a flat columnar startup snapshot (paths, symbol text, file metadata, symbols, directory mtimes and the ignore inputs) and, on open, validates it against the disk: every directory's mtime, every file's mtime and size, the per-directory and external ignore files and the filter settings. Any difference falls back to the full walk, so the result is the same as before; an unchanged project is hydrated in bulk without walking the tree, and redb opens lazily. Watcher updates refresh the snapshot after reconciling the touched directories. A corrupt snapshot is rejected before any offset is used.

Warm reopen measured about 10x faster (20k files: 204 ms to 21 ms; 100k files: 967 ms to 96 ms); the first index is about 10% slower because it writes the snapshot.

Co-authored-by: akbar1214 <[email protected]>
2026-11-14 12:31:19 +08:00
l0ng-ai 75344c21ef fix(build): open Maven compile errors on Windows (#3288)
Maven prints Windows source paths as /C:/dev/p/A.java. Joined against the
working directory that became the drive-relative C:dev\p\A.java, so clicking a
build message failed with os error 3. Drop the slash before the drive letter
when parsing Maven paths, as file URIs already did.
2026-11-14 12:13:14 +08:00
Clarence 91ac7f417c fix(mybatis): find a mapper's XML by its file name before searching, and keep a found one (#3282)
In large projects a mapper interface often showed no gutter arrows to its XML: the Java-to-XML lookup searched every XML file for the namespace, which ran past the editor's deadline on a cold start and lost its cache when the instance was interrupted. The lookup now asks the project index for the XML named like the interface and confirms its namespace, falls back to the namespace search only when that finds nothing, and keeps a found path while the file still maps the namespace (only misses expire). Version 0.4.2; component rebuilt from source.

Co-authored-by: Clarence404 <[email protected]>
2026-11-14 11:52:52 +08:00
zhzhsxтаzhzhsx 2fd43aa29f fix(editor): keep the completion hint below the suggestion list (#3283)
CodeMirror rebuilds the completion list by removing the old <ul> and appending a new one, so the advertiser bar (insert/replace hint and Sort by name), which was only added when missing, ended up above the list after the first refresh. An existing bar is now moved back behind the list.

Co-authored-by: zhzhsx <[email protected]>
2026-11-14 11:51:40 +08:00
Muuтаmuutot 5b7d258adb feat(vcs): register a repository moved into an open project (#3269)
A repository folder moved or restored into an open project stayed invisible until the project was reopened: the open scan had already run, and the folder brings no `.git` event of its own where the watcher reports only the created directory. The watcher's rescan, which already ran when a `.git` / `.git/HEAD` appeared, now also runs when a created path is itself a repository root, so the repository is registered the same way a `git init` inside the project is. The project-open scan and the rescan share `discover_repositories`.

Co-authored-by: muutot <[email protected]>
2026-11-14 11:50:39 +08:00
l0ng-ai fd1cebdf98 fix(window): lay out project window tabs evenly with centred titles (#3285)
Project tabs of a merged window group now share the row equally with the project name centred; the close button sits on the left and shows on hover or keyboard focus, and the selected tab is marked by its background instead of an accent underline. The folder icon is gone. Reordering, the context menu and keyboard navigation are unchanged.

Closes #3266
2026-11-14 11:48:44 +08:00
l0ng-ai c9362247d0 ci: run the Maven launch classpath smoke for the runner JDK (#3273)
maven_launch_classpath_uses_the_runner_jdk_classifier (#2929) is an ignored project smoke test that CI never ran: add it to the java job's project_smoke step.
2026-11-14 11:33:04 +08:00
Muuтаmuutot 8ab9c688ac fix(vcs): make the line-separator check read bytes, and drop the unused check input (#3281)
The CRLF commit check compared the editor's text, which the host LF-normalises (open buffers and `text_of` alike), so it never fired. It now reads the file's bytes through fs_read_range, carrying the last byte across ranges so a CRLF that straddles two is found, and skips files fs_probe classifies as binary. The commit check context drops the `files` list no check read; checks get the paths that still exist.

Co-authored-by: muutot <[email protected]>
2026-11-14 11:23:40 +08:00
Deng Liuтаdengliu ba0b3d9c7d fix(completion): clear feature backoff on plugin updates and explicit completion (#3277)
Explicit completion (Ctrl+Space) now bypasses the per-document completion backoff, and a successful completion clears it. Plugin enable/disable or UI changes clear all backoffs, so a newly installed completion provider is asked again. On the host, an empty plugin result only counts as an answer when a completion or injection plugin actually covers the document; otherwise the request stays Unavailable and falls through to the language server.

Co-authored-by: dengliu <[email protected]>
2026-11-14 11:21:43 +08:00
Muuтаmuutot 96f8ee27dd fix(vcs): read the scoped status through git again, with the pathspec spelled right (#3280)
The scoped status read after a watcher batch handed gitoxide's in-process pathspec the paths as the platform spells them. On Windows a backslash path matched nothing, silently, so the merged answer dropped the file from the change list until a full refresh; a path that is the repository root became an empty pattern and failed the read. The scoped read is git status with the pathspec again, with /-separated patterns and the root meaning the full status, and Restricted Mode answers with the full gitoxide status since it starts no program.

Co-authored-by: muutot <[email protected]>
2026-11-14 11:19:10 +08:00
l0ng-ai 5529db667c feat(database): test a data source's SSH tunnel on its own (#3279)
The SSH/SSL tab gets Test SSH Tunnel: it logs in to the SSH host (shared SSH configuration or the settings typed in the dialog) and checks that the SSH host reaches the database host and port, without the database password or login. Success shows user@host:port and the database host:port reached; failures name the step (connecting, authenticating, reaching the database). An unknown host key offers Trust Host and Retry. The in-process SSH test server now forwards direct-tcpip channels.
2026-11-14 11:17:18 +08:00
l0ng-ai 9c55c28d5b feat(run): run, test and debug Zig projects from build.zig (#3278)
A build.zig project gets a configuration per build step (zig build, zig build run, zig build test and the project's own steps) and one per executable it declares, which runs zig build first and then the installed binary, debuggable with CodeLLDB. The build script is parsed, never run, to find them.

Zig files get gutter markers: pub fn main runs the executable built from the file (or zig run for a file on its own), a test block runs zig test <file> --test-filter <name>, a file with tests only runs them all. Debugging a single file or test builds it into a binary first (build-exe, test --test-no-exec) and launches that.

Adds fixtures/zig-app (Zig 0.17). Part of #3260.
2026-11-14 11:04:44 +08:00
l0ng-ai 009773c0d4 fix(debugger): debug Kotlin with kotlin-lsp without asking for kotlin-debug-adapter (#3274)
Since #3172, debugging a Maven / Gradle run of a module with Kotlin sources stopped before the
build and asked to install kotlin-debug-adapter whenever it was missing, although the backend
debugs Kotlin with kotlin-lsp's own debugger when kotlin-lsp is installed and only falls back
to kotlin-debug-adapter when it can't. The check now offers kotlin-debug-adapter only when
neither is installed.
2026-11-14 11:03:32 +08:00
l0ng-ai ba0f4329ea fix(vcs): refresh the diff navigator's handlers before paint (#3275)
DiffEditor's registered navigator refreshed its handlers in a passive effect, so a shortcut pressed right after the toolbar changed (Jump to Source once the working copy is found) ran the previous handler and did nothing. The handlers are refreshed in a layout effect now. This was also why the diffToolbar F4 / Cmd+Down test failed about one run in eight.
2026-11-14 10:50:31 +08:00
Deng Liuтаdengliu 4d88d44616 feat(language): add a Helm Template language for chart templates (#3261)
Files in a Helm chart's templates folder (yaml, yml, tpl, txt under a Chart.yaml / Chart.yml root) and *.gotmpl files are detected as Helm Template instead of YAML or plain text. They contain Go text/template + Sprig actions that are not valid YAML until rendered, so the YAML language server, formatter and tree-sitter syntax check no longer attach to them and flood the editor with errors.

A CodeMirror stream mode highlights YAML outside {{ … }} and keywords, variables, dot paths, functions and comments (including {{- /* … */ -}}) inside. .tpl outside a chart keeps its current type, since Smarty, Terraform and other engines use it too.

Co-authored-by: dengliu <[email protected]>
2026-11-14 10:46:56 +08:00
Muuтаmuutot 5e5d804b89 feat(vcs): refresh the working tree status incrementally (#3267)
A watcher batch used to re-read the whole working tree status, so a save in a large repository walked every file. The batch's paths now go to a scoped read (vcs_status_paths, gitoxide's status with a pathspec; git status with a pathspec as the fallback) and are merged into the loaded status: a path that is no longer changed drops out, a removed directory drops what is below it, new entries land in path order.

The full status still answers when the scoped one could be wrong: batches over 200 paths, a status that is not loaded yet, ignore-rule changes, a failed read, and batches that can split a rename. Git pairs a deletion with an addition across the whole index, so a pathspec read of a staged rename's target reports it as added and drops the source; batches that touch a rename, read one, or read an addition or deletion while the other side exists elsewhere take the full status. Restricted Mode keeps starting no program: the scoped read is in-process.

Co-authored-by: muutot <[email protected]>
2026-11-14 10:44:11 +08:00
Muuтаmuutot 83b9744a36 feat(vcs): annotate the file as of an earlier revision (#3265)
The annotation gutter's menu gains IDEA's annotate family:

- Annotate Previous Revision opens the file at the first parent of the annotated line's commit next to the commit's version, with the previous side annotated; on a file's first commit it says there is no previous revision.
- Annotate at Revision… opens the file's revision chooser and shows the picked revision annotated next to the working copy, following renames through historyPath.

One diff viewer provider annotates the left pane once per diff, so annotations the user closes stay closed when the panes rebuild. The diff opens at its first change, since the annotated line's number belongs to the working copy.

Co-authored-by: muutot <[email protected]>
2026-11-14 10:38:39 +08:00
l0ng-ai 4ab8959bac ci: run the Kotlin smoke against kotlin-lsp 271.2085.0 (#3270)
263.7084.0 is a JetBrains pre-release build that has expired: it now exits at startup ("This build of kotlin-server has expired"), so Language smoke (kotlin) failed on every branch.
2026-11-14 10:35:44 +08:00
GuoYuтаguoyucode d715643ff2 feat(mcp): a listen address for the IDE MCP server, 127.0.0.1 by default (#3146)
Agents › IDE MCP Server gets a Listen address setting (mcpServer.address): 127.0.0.1 by default, or another address of this computer (LAN / VPN) or 0.0.0.0 / :: so agents on other computers can connect. Requests must still carry the bearer token, and the Host check still refuses host names other than localhost (DNS rebinding). An address that isn't an IP address, or isn't on this computer, stops the server with an error naming it. Clients on this computer keep connecting through loopback; the discovery file records the address. The settings hint and docs warn that anyone who can reach the address and has the token can use the tools.

Co-authored-by: guoyucode <[email protected]>
2026-11-14 10:32:24 +08:00
Muuтаmuutot a5214cae38 feat(vcs): make before-commit checks a registry (#3268)
Before-commit checks register through one extension point (CommitCheck, registerCommitCheck, getCommitChecks), ordered by order then id. Each check reports a severity: an error holds the commit (the next click is Commit Anyway), a warning is shown without stopping it. Every built-in check reports error, so the Commit view, messages and the Commit Anyway flow are unchanged. The before-commit modifications (reformat, optimize imports, cleanup) run as one step ahead of the checks, so the analysis, TODO and CRLF checks read the rewritten files. A check that throws is reported instead of breaking the commit.

Co-authored-by: muutot <[email protected]>
2026-11-14 10:31:25 +08:00
Vadim LazukтаInf1mum 54dd3a167f fix(database,http-client,graphql): keep exact numeric text in aggregates, charts and JSON paths (#3175)
Large or very precise numbers were rounded through JavaScript doubles in database result aggregates (sum / average / min / max) and charts, in HTTP response formatting and in GraphQL variables and responses. Aggregates and charts now compute on exact decimal text, and a shared lossless JSON parser/serialiser (src/platform/losslessJson.ts) keeps number literals as written for HTTP and GraphQL.

Refs #3105

Co-authored-by: Inf1mum <[email protected]>
2026-11-14 10:28:39 +08:00
l0ng-ai 0938a507da fix(macos): require macOS 13, the first release whose system engine runs the app (#3271)
On macOS 11 and 12 other apps keep using the system's built-in WebKit (Safari 14.1 / 15.x) even after Safari is updated, so the Safari 16 interface could not start there. The bundle now declares macOS 13 as its minimum, the install docs say macOS 13, and the startup failure page no longer suggests updating Safari.

Fixes #2008
2026-11-14 10:23:17 +08:00
Daniel AdámekтаDanielADK a05a40e0a3 fix(editor): end a <<- heredoc at its tab-indented delimiter in shell highlighting (#3259)
The stock CodeMirror shell mode ends a heredoc body only on a line equal to the delimiter and drops the `-` of `<<-`, so a tab-indented closing delimiter highlighted the rest of the file as heredoc text. shellInterpolation now tracks heredocs itself: it reads the delimiter as bash does (quotes removed, ends at `|`, `;`, `>`…), strips leading tabs for `<<-`, queues several heredocs on one line, starts bodies on the next line, and leaves `<<` in `$(( ))` and `<<<` here-strings as operators.

Co-authored-by: DanielADK <[email protected]>
2026-11-14 09:08:41 +08:00
acaoтаamphetamlne 3b978721e3 test(plugins): exercise remote diagnostics through the host snapshot (#3254)
The remote plugin API test still filled the UI Problems reports after #3103 moved plugin diagnostics to the host-owned snapshot, so it failed on main with an empty result. It now publishes through the project's recording event sink and checks the snapshot's per-file / per-producer semantics, project close and RPC disconnect.

Co-authored-by: amphetamlne <[email protected]>
2026-11-14 09:07:46 +08:00
l0ng-ai 9793857024 fix(plugins): rebuild the diagnostics test fixture after formatting its source (#3264)
#3263 ran cargo fmt on the fixture's src/lib.rs, which changes its source fingerprint, so plugins/build.sh --check reported dist/plugin.wasm as stale.
2026-11-14 09:05:35 +08:00
l0ng-ai 8fb3b7b084 fix(vcs): keep F7 working in the diff it opens for the next file (#3263)
F7 / Shift+F7 past the last difference opened the neighbouring file with focus on the editor tab's host, so the diff's keymap no longer applied; it had only worked through a stale navigator registration that #3137 removed. The diff now forwards the host's focus to its focused pane, as the image and hex viewers do. Also fixes the htmlFrame test's race with its listener effect and formats the diagnostics plugin fixture, so main's Frontend and Plugins jobs pass.
2026-11-14 09:01:07 +08:00
Deng Liuтаdengliu 22fe1702be feat(plugins): dynamic file and folder icons by path (API 1.8) (#3257)
Static [[contributes.fileIcons]] entries match file names only, so a plugin could not tell a Helm chart folder, its templates or a Kustomize overlay from any other YAML file or folder. API 1.8 adds decorations.set-file-icon and the batched set-file-icons (up to 10,000 paths per set, requires project.read): a plugin assigns one of its declared file icons to an exact file or plain-folder path, shown in the project tree and editor tabs after the user's own file type icons. Under engines.openidea = "^1.8" a fileIcons entry may omit patterns, for icons used only this way. Both WIT copies, the SDK (host functions and mock), the plugins README and the bundled components are updated.

Co-authored-by: dengliu <[email protected]>
2026-11-14 07:20:33 +08:00
János Kranczlerтаjanoskranczler ad434af78c feat(agents): import sessions from other apps from the Agents tool window (#3255)
Importing Claude Code / Codex sessions started outside OpenIDEA was only reachable from the history popup of a docked session and imported one row at a time. A new Import Sessions from Other Apps… action is listed in the Agents tool window's options menu and in the empty task list. It opens a chooser that rescans on open, filters by title or folder, selects several sessions (modifier-click, Shift-click, Cmd/Ctrl-A, Select All) and imports them in one go: a single session opens as before, several are added to the list without opening tabs, failures are reported together, and Cancel stops the batch after the import in flight.

The set of sessions being imported now lives in the store, so neither the chooser nor the docked popup can import a session twice, and late scan results after a project switch are dropped.

Co-authored-by: janoskranczler <[email protected]>
2026-11-14 05:56:28 +08:00
l0ng-ai 6d103df5cf feat(run): show what each Java test printed, for Maven and Gradle (#3256)
Selecting a test in the Run tool window's test tree shows only that test's
output, and a class shows its own output followed by its tests'.

- Maven: Surefire's console names failures but not what passed tests printed.
  The JUnit XML reports carry each test case's <system-out>/<system-err>;
  they are now read into a separate `stdout` field and shown for every test,
  before the failure details. Failures already reported on the console get
  the printed lines in front of their stack trace.
- Gradle: output of a class's own setup and teardown (@BeforeAll, @AfterAll)
  arrived under the class descriptor and showed up as an extra child named
  after the class. The init script now sends it without a test name and it
  belongs to the class node.
2026-11-14 05:51:58 +08:00
LunarFellerтаLunarFeller e2d089a0c4 fix(agents): preserve prompt text during native image paste (#3250)
When the clipboard event carried no data (WebKitGTK pasting a screenshot), the composer started reading the image from the native clipboard but returned false, so CodeMirror still handled the paste and replaced the selected prompt text with an empty string. The handler now prevents the default paste and reports it as handled; the image is still attached when the native read returns one, and the prompt and selection stay as they were in every other outcome. The clipboard fixtures in the tests now implement getData so they go through CodeMirror's own paste path. This is the only native-clipboard paste fallback in the frontend.

Co-authored-by: LunarFeller <[email protected]>
2026-11-14 05:41:38 +08:00
l0ng-ai 47fa0e6f79 feat(typescript): run TypeScript 7's own language server when it is the selected TypeScript (#3252)
TypeScript 7 has no tsserver or JavaScript API; its platform package's native executable serves LSP itself. When the TypeScript setting selects one (the project's or a chosen directory), vtsls / typescript-language-server launch that executable with --lsp --stdio instead. The project's TypeScript 7 is listed as "TypeScript 7.x (native)".
2026-11-14 04:39:59 +08:00
l0ng-ai 921f1f2b2d fix(lsp): run a server chosen from a Node.js installation on that Node (#3251)
A server Path pointing at a script in a Node.js installation's bin (nvm's bin/tsc, bin/npx) ran through #!/usr/bin/env node on the project's Node.js interpreter or the first node on PATH; TypeScript 7's extensionless ESM tsc crashed on Node 20.9. That installation's bin now comes first on the server's PATH.
2026-11-14 04:32:29 +08:00
l0ng-ai 88f73630a6 feat(tools): offer updates for the language servers and debuggers the IDE installed (#3247)
Settings › Languages & Frameworks › Language Servers looks for newer versions of the tools in the IDE's tools folder once per session and from its refresh button: release downloads resolve their latest release as an install does, npm packages ask npm view through the user's registry. A tool with a newer version gets an Update tag, the available version in its details and an Update button that installs it.
2026-11-14 03:38:51 +08:00
l0ng-ai 504ffac10a test(rpc): check handler argument names against the client bindings (#3244)
Remote command handlers read arguments by name; a name the desktop client doesn't send reads as null (#3231). The test checks every c.arg / c.sender / c.channel name of each .add("command", …) registration in crates/ide-host/src against that command's arguments in src/bindings.ts.
2026-11-14 03:21:40 +08:00
l0ng-ai 5ee558d1a7 fix(database): keep expanded nodes loaded after editing a data source (#3243)
saveSource forgot the source's cached metadata before writing the new settings, so the following refresh found nothing loaded and reloaded only the first level; expanded schemas and tables stayed on Loading. saveSource now captures the loaded levels before forgetting them and passes them to refresh.
2026-11-14 03:16:32 +08:00
LunarFellerтаLunarFeller c09fb7191a fix(database): ignore stale metadata after deactivation (#3237)
A metadata reply that arrived after its data source was deactivated, deleted, edited or after a project switch could bring back the Connected indicator and the cleared schemas, tables, columns or completion, and an obsolete chain could issue further requests. Requests now carry project / source / target lifetimes (requests.ts); forget() starts a new lifetime, and late replies, introspection steps and follow-up requests of an ended lifetime are dropped. Completed writes keep their result but no longer revive an old connection.

Co-authored-by: LunarFeller <[email protected]>
2026-11-14 03:11:39 +08:00
János Kranczlerтаjanoskranczler 7ee80ed736 fix(database): tint the whole tree row of a coloured data source (#3240)
The data source colour was applied to the node's content span, so the tint started after the indent and left gaps between rows. The tint now goes on the tree row itself through Tree's rowClassName, covering the indent, chevron and full row height for the source and every node under it; selected and hovered rows keep their own colours.

Co-authored-by: janoskranczler <[email protected]>
2026-11-14 03:05:14 +08:00
Lucas 353768a15f fix(plugins): refuse linked and control paths in background document edits (#3236)
editor.apply-edits-at applies edits that may come from another machine (a collaboration session), so it no longer uses the ordinary read resolver. The path must be project-relative, every component is checked with symlink_metadata from the canonical root, and symbolic links, junctions and other reparse points are refused, as are VCS and IDE control directories (.git, .hg, .svn, .idea, .openidea, .vscode, including Windows alternate spellings). The WIT docs describe the rule in both wit/ and the SDK copy.

Co-authored-by: SorPuti <[email protected]>
2026-11-14 02:59:48 +08:00
Mr0Bread 5e969f0bad fix(vcs): rename a branch in remote windows (#3232)
Rename Branch always failed in remote development windows with `argument unset_upstream: invalid type: null, expected a boolean`: the remote JSON-RPC handler read the argument as `unset_upstream` while the client sends `unsetUpstream`. A sweep of every RPC handler's argument names against the client bindings found no other mismatch.

Closes #3231

Co-authored-by: Mr0Bread <[email protected]>
2026-11-14 02:56:14 +08:00
Vadim LazukтаInf1mum 6f60f55a1d fix(editor): recount the usages code lenses when the project's documents change (#3235)
The usages code lens cached each count for as long as its own document's text stood still, so edits to the call sites in other files never reached it and a count taken while the project's indexes were still building stayed on display; the lens could disagree with Find Usages (4 vs 5). Counts are now dropped when another document is edited or closed, the backend patches a document or applies a workspace edit, files change on disk, or a plugin index batch completes, and a tab shown again recounts. The query itself is unchanged.

Fixes #3217

Co-authored-by: Inf1mum <[email protected]>
2026-11-14 02:54:41 +08:00
l0ng-ai 8e5ac1dc01 feat(run): detect rebar3 and Mix projects and run escripts (#3239)
Erlang and Elixir projects had no run configurations. A rebar.config now gives rebar3 shell, rebar3 eunit and, with *_SUITE.erl suites (also in umbrella apps), rebar3 ct; a rebar3 script in the project is used over the PATH. A mix.exs gives iex -S mix (iex -S mix phx.server for Phoenix) and mix test. An .escript file runs with escript. The configurations show the Erlang and Elixir file icons.

Closes #3208
2026-11-14 02:49:16 +08:00
l0ng-ai 4fcf4555a1 fix(navigation): show usages on a declaration a plugin's index links elsewhere (#3238)
A plugin's document index can link a declaration's name elsewhere: MyBatis Duo links a mapper method to its XML statement. Go to Declaration followed such a link at once, in the editor and in the host, so ⌘B on the method jumped to the XML instead of showing its usages as it does on any declaration (and did for plugins that answer through a definition provider). When the syntax tree puts the caret on a declaration's name, the link is no longer followed directly: the language server's answer comes along and the usages show, the plugin's target among them. Elsewhere plugin links are followed as before, without waiting for the language server.

Fixes #3207
2026-11-14 02:43:33 +08:00
l0ng-ai 40494f2920 fix(lsp): accept file URIs a language server leaves unencoded (#3234)
jdtls on Windows sends a project path as is in its URIs (file:///H:/项目/…). lsp-types rejects such a URI, so the whole response failed: Find Usages, Go to Declaration, references, implementations and call hierarchy fell back to a text search for any project under a non-ASCII folder. Every message from a language server now has the characters URIs don't allow (non-ASCII, spaces, brackets…) percent-encoded in its file: URIs, string values and object keys (a workspace edit's changes) alike, before it is read.

Fixes #3205
2026-11-14 02:29:59 +08:00
l0ng-ai c016007a2d fix(ui): give .slint files their own icon (#3230)
Slint files showed the generic file icon, and `icons.test.tsx` (every detected language file has a specific icon) failed on main.
2026-11-14 02:18:56 +08:00
Mr0Bread 69ce1ab13d fix(editor): keep editor actions off a tool window's speed search (#3227)
Text and editor actions also run while a popup is open, so Find Action can run them on the editor. A tool window's speed search opens a popup layer too while focus stays in the tool window, so ⌘⌫ in the Project view's speed search ran Delete Line on the editor behind it. The popup branch of TEXT_WHEN, the Find occurrence actions and EDITOR_WHEN (navigation, LSP, code insight and template actions) now also require that no tool window has focus; Find Action and menus render outside tool windows and keep working.

Co-authored-by: Mr0Bread <[email protected]>
2026-11-14 02:15:19 +08:00
Mr0Bread fcc8583f36 fix(ui): ⌘⌫ erases a tree's speed search query (#3226)
While a tree's speed search has a query, the keymap dispatcher ran ⌫ shortcuts before the tree saw the key: in the Database view ⌘⌫ opened Drop or Remove Data Source. The shared Tree now claims ⌫ with any modifier while a query is active and edits the query like a text field: ⌘⌫ erases it, ⌥⌫ / Ctrl+⌫ erase the last word. Without a query the view's shortcuts run as before.

Co-authored-by: Mr0Bread <[email protected]>
2026-11-14 02:15:07 +08:00
acaoтаamphetamlne b368f21eb6 perf(search): prune explicitly excluded subtrees (#3221)
Search and scope enumeration walked into directories that an exclusion mask covers entirely and dropped their files one by one. A directory is now skipped before its children are read when an exclusion with a trailing `/**` (e.g. `**/node_modules/**`) covers it; every file below such a directory is already excluded by the same mask, so results are unchanged. Other masks keep filtering individual files.

Co-authored-by: amphetamlne <[email protected]>
2026-11-14 02:13:05 +08:00
l0ng-ai 929866bbc5 fix(tools): honour Cancel until a downloaded tool replaces the installed one (#3228)
After unpacking, the IDE removed the previous install, moved the new one into place and cleared the quarantine flag without checking for Cancel or reporting progress, so the install log kept saying Extracting… and Cancel did nothing; on a slower disk, deleting a large tool such as kotlin-lsp (about 1 GB) took seconds. Cancel is now checked right before the swap, as JDK downloads already do; the quarantine flag is cleared before it, and the previous install is moved aside and deleted in the background.

Refs #2925
2026-11-14 02:12:33 +08:00
Mr0Bread 3c781296d6 feat(database): Select Opened File in the Database tool window header (#3223)
Adds Select Opened File (db.selectOpened) to the Database tool window header and Find Action. For a table editor it selects the table or view, loading and expanding the path to it; for a SQL console or SQL file it selects the schema the console runs in (or its database / data source when the schema isn't loaded) without connecting.

The reveal path shared with Go to Declaration now selects the nearest shown ancestor when the target row isn't shown (filtered out or dropped), and keeps focus in the tree instead of the filter field.

Co-authored-by: Mr0Bread <[email protected]>
2026-11-14 01:55:52 +08:00
l0ng-ai 4aed449dc0 fix(editor): keep the usage highlight class names out of the text catalog check (#3225)
The usage highlight's class for plain occurrences sat under a `text` key, which the text catalog check reads as UI text, so `textCatalog.test.ts` failed on main. The class names are now built from one constant.
2026-11-14 01:51:25 +08:00
Akbar Husain 55431f3965 perf(startup): batch action registration (#3222)
registerActions() registered actions one at a time, and each registerAction() cloned the whole action map, so registering a feature's actions at start-up was quadratic. A batch now adds every source and updates the store once; replacement, disposal and localisation are unchanged.

Co-authored-by: akbar1214 <[email protected]>
2026-11-14 01:50:31 +08:00
l0ng-ai d4b14a36c7 test(plugins): focus an absolute path in the git-toolbox schedule test (#3224)
Editor focus events are routed by the focused file's project, so a relative path is dropped before it reaches the plugin and the test never woke Git Toolbox. Editors always report absolute paths; the test now does too.
2026-11-14 01:46:28 +08:00
rohitkumardandaтаrohitdanda d2c565b19d fix(plugins): harden the official plugins against crafted input (#3219)
Several official plugins trusted crafted input: paths outside the project were accepted where only project files were meant, some loops and replacements had no upper bound, and CSV Add Row wrapped its row index on wasm32.

- ai-completion: only treat a real OpenAI host as OpenAI
- checkstyle, license-header, mybatis: keep resolved paths under the project root
- license-header: keep the generated header on one line where one is expected
- csv-editor: Add Row appends on wasm32 instead of overwriting the header
- regex-tester: cap the replaced output
- solidity: linearisation stops on cyclic inheritance
- mybatis: bound the mappers and statements it indexes
- string-tools, graphviz: guard the remaining crafted-input cases
- Rebuilt the changed components from source.

Co-authored-by: rohitdanda <[email protected]>
2026-11-14 01:42:03 +08:00
zhangdagouтаPLZY 01a60ef164 fix(editor): make usage highlighting follow the caret at once and paint multi-line usages as a block (#3220)
Usage highlighting at the caret waited a fixed 300 ms after every caret move, so clicking around lagged. A click now asks at once and keyboard moves wait 100 ms; with the document unchanged, a caret landing on usages found recently shows them immediately while they are refreshed. A highlight over several lines (a return statement's exit point) is painted as a block: from its start to the right edge, whole lines between, and the last line up to its end.

Co-authored-by: PLZY <[email protected]>
2026-11-14 01:33:42 +08:00
zhangdagouтаPLZY b0b768d25e feat(editor): highlight the caret's indent guide and select a block by double-clicking its brace (#3213)
The indent guide in the caret's column, within the block the caret line belongs to, takes the selected indent guide colour, and guides sit slightly left of their column so a caret there is to the right of the line. With the caret at a brace of a multi-line block, that block's guide takes the matched-brace colour. A double-click that puts the caret next to { or } (also past the end of a line ending in {) selects the block, both braces included; a double-click on a word still selects the word.

Co-authored-by: PLZY <[email protected]>
2026-11-14 01:30:56 +08:00
l0ng-ai 3240bfc423 fix(vim): draw the Normal-mode block cursor solid, with the character in the background colour (#3218)
The focused block cursor was a 60% caret-coloured fill. On dark themes the caret is light
grey, so it read as a slightly brighter character and the cursor was easy to lose. It is now a
solid block in the caret colour with the character under it in the editor background colour,
as IdeaVim's block caret; the unfocused 1px outline and partial blocks are unchanged.

Fixes #3197
2026-11-14 01:27:32 +08:00
Mansur Akhmadiтаmansur-akhmadi 15182507cf fix(vcs): confirm force pushes in the multi-repository Push dialog (#3215)
With Force (with lease) ticked, the multi-repository Push dialog force pushed every picked repository without the Force Push confirmation the single-repository dialog shows. Each forced target is now confirmed first, once when several repositories push to the same target; protected targets are never forced and not asked about, "Don't warn about this target" is respected, and declining pushes nothing and keeps the dialog open.

Closes #3214

Co-authored-by: mansur-akhmadi <[email protected]>
2026-11-14 01:24:29 +08:00
zhangdagouтаPLZY 38bd8fbd16 fix(vcs): let the Commit diff go on past the last change into unversioned files (#3211)
A diff opened from the Commit tool window (double-click / Enter, and the preview that follows the selection) stopped at the last file of its group, so F7 never reached the new files under Unversioned Files. Its chain now covers every file of the tree in tree order, as the Commit diff preview does; Show Diff (Ctrl+D) still keeps to the file's group.

Co-authored-by: PLZY <[email protected]>
2026-11-14 01:22:54 +08:00
l0ng-ai 2859ec9106 feat(templates): New ▸ C# Class with the project's namespace (#3216)
Class, Interface, Struct, Enum and Record. The namespace is the nearest .csproj's
RootNamespace (else its file name) plus the folders below it. Projects on C# 10 or later
(.NET 6+, or LangVersion 10/latest) get a file-scoped namespace; .NET Framework, Unity,
.NET Standard and .NET Core projects get a namespace block.

Fixes #3190
2026-11-14 01:17:26 +08:00
l0ng-ai a4b62ad27f fix(dev): apply the worktree's Vite URL when dev:app is given a project path (#3212)
`pnpm dev:app <path>` appended `--config {devUrl}` after the path, and `tauri dev`
hands the first positional argument and everything after it to the app. The config was
dropped: tauri ran `pnpm dev` on port 1420 as well, and a full reload loaded that server
instead of the worktree's, failing with "Could not load startup script". The config now goes
before the first positional argument.
2026-11-14 01:10:20 +08:00
Akbar Husain 1392e824bb perf(index): use compact query tables for faster file and symbol search (#3209)
Index queries (find_files, find_dirs, find_symbols) walked FileEntry values and per-file Box<str> / Box<[Sym]> allocations on every keystroke. They now scan compact tables kept in sync under the IndexData write lock: packed file rows and interned path/symbol arenas, a flat symbol table, precomputed masks, an ASCII fast path, a per-file union of symbol masks to skip files, and O(1) recents lookup. Ranking, scores, match indices, recency boosts, cancellation and incremental updates are unchanged.

Criterion (index_100k), main vs this change: find_files 45-60% faster (zzzz flat), find_dirs 20-48% faster (a flat), find_symbols 50-61% faster except OCo (flat).

Co-authored-by: akbar1214 <[email protected]>
2026-11-14 01:08:30 +08:00
zhangdagouтаPLZY 6073f35eb6 feat(debugger): import classes the frame's file doesn't import in Java Evaluate (#3204)
Evaluating an expression that uses a class the breakpoint's file doesn't import (`JSON.toJSONString(u)`) failed with "JSON cannot be resolved". java-debug only takes the expression's text, so a class it reports as unresolved is looked up through workspace symbols and written fully qualified: one accessible class with the name is imported at once, several are offered to choose from. Choosing a class in completion inserts the short name and imports it. Imports last for the debug session and apply to Watches and the Debug Console too.

Constructor calls (`new Foo()`) are qualified as well, and private nested classes (java.util.Arrays.ArrayList) are not offered.

Co-authored-by: PLZY <[email protected]>
2026-11-14 00:58:46 +08:00
zhangdagouтаPLZY b02964962b fix(vcs): show the time of older commits in the Git Log (#3206)
With pretty dates on, commits older than yesterday showed only the date; they now show date and time (numeric date, e.g. 2026/11/11 22:43), and the same format is used with pretty dates off. Today, Yesterday and N min ago are unchanged. The date column's default width grows from 120 to 140 px so a 12-hour date and time ("11/05/2026 02:30 PM", "Yesterday 09:07 AM") is no longer cut off.

Co-authored-by: PLZY <[email protected]>
2026-11-14 00:52:49 +08:00
l0ng-ai 9ca8097f3c fix(lsp): offer an update when a language server build has expired (#3210)
JetBrains preview builds of kotlin-lsp stop starting once their date passes (exit code 7). The server was restarted three times and a manual restart only reported the same failure. A server that reports an expired build is no longer restarted, and the tools feature offers the latest release (Update for the IDE's copy, Install Latest for one on PATH, the Language Servers page for a chosen executable).

Fixes #2925
2026-11-14 00:45:51 +08:00
l0ng-ai 1b08d5de09 feat(diff): Ctrl/Cmd-click navigation and quick docs in the working-copy pane (#3203)
A diff pane showing a file's open document (working copy, Compare Files) gets that document's hover / quick docs and Ctrl/Cmd-click navigation; the target opens in an editor tab and the diff stays. Editor contributions gain diffExtensions and document attachments expose their context.
2026-11-14 00:20:57 +08:00
Alexanderтаformiat 8faedce175 feat(vcs): show favorite branches first in the Git Log branch filter (#3201)
The Git Log Branch filter now lists HEAD, then the favorite branches (local and remote, by name, marked with a star), then Local and Remote as collapsed groups that open with a click, Enter or Right and close with Left. A search still lists every match under Local and Remote. Nested branches are indented with the tree indent.

Co-authored-by: formiat <[email protected]>
2026-11-14 00:11:16 +08:00
Muuтаmuutot c8bdfa8edd feat(plugins): a host-owned diagnostics snapshot and line-end annotation order (#3103)
Plugins read problems from a host-owned snapshot that records every Diagnostics event per file and producer, instead of the UI-reported Problems reports, so what a plugin sees no longer depends on window lifetime or the invoking window. Adds the problems-changed workbench event (plugin API 1.8.0, gated at needs_api 8), editor focus events for files of other windows, severity style previews in plugin settings, and lays plugin annotations on the line-end chain before code-vision hints and inline blame. Components and fixtures are rebuilt for API 1.8.0.

Co-authored-by: muutot <[email protected]>
2026-11-14 00:07:03 +08:00
Mamaev Igor 4d22632609 fix(gutter): keep plugin gutter buttons while editing (#3196)
Plugin gutter actions (Run buttons from language plugins) disappeared on every edit and came back after the 300 ms refresh: the actions field was emptied on any document change, a provider change cleared everything at once, and a failed refresh removed them. The buttons now map through edits, a provider change only schedules a refresh, failures keep the last buttons, and the gutter column is reconfigured only when it appears or goes away.

Co-authored-by: pxc1984 <[email protected]>
2026-11-13 23:56:02 +08:00
Muuтаmuutot 7f61b7b8ab fix(themes): give the bundled theme the new typo and deprecated tokens (#3199)
#3154 added --wave-typo and --fg-deprecated to tokens.css but not to the bundled Harbor Night scheme, which must set every editor token, and plugins/themes.md was stale. Adds both with the dark values from tokens.css and regenerates the theme docs.

Co-authored-by: muutot <[email protected]>
2026-11-13 23:52:32 +08:00
l0ng-ai abe30914b5 fix(remote): never reuse an older openidea-server after an update (#3202)
A server binary at <data dir>/server/<os>-<arch>/openidea-server was preferred over this version's download and never checked, so after an update remote windows kept installing the old server and new commands failed with -32601. The server now comes from OPENIDEA_SERVER_BINARY, the one next to the app, or this version's download (server/<version>/<os>-<arch>/); the unversioned location is an offline fallback only. A command missing on a server of another version names both versions.
2026-11-13 23:51:33 +08:00
Alexanderтаformiat 1ae3edd5c8 fix(vcs): keep Git Branches' expanded groups and folders across restarts (#3182)
Git Branches kept the groups and folders the user expanded or collapsed only for the session. The toggled keys, per popup kind, are now stored per project root in localStorage and loaded when the popup opens; untouched keys keep their defaults.

Co-authored-by: formiat <[email protected]>
2026-11-13 23:33:16 +08:00
Alexanderтаformiat 52b8cf5e70 fix(vcs): close Git Log toolbar dropdowns on a second click (#3193)
A second click on a Git Log toolbar button reopened its menu (or left the Branch popup open): the menus opened as the global context menu at a point, which took the press on the button for an outside click. They now open as a Menu anchored to the button that toggles on the same button, covering the filter bar, Graph/View Options and the commit details menus; the Branch popup toggles too.

Co-authored-by: formiat <[email protected]>
2026-11-13 23:32:30 +08:00
l0ng-ai 258ba189c6 feat(appearance): background image for the project window (#3194)
Settings › Appearance › Background Image picks an image shown through the project window, with opacity (default 15%), fill (scale, original size, tile) and anchor. Drawn over the workbench under dialogs and popups without taking clicks; the settings row previews the image and shows why a path cannot be loaded.
2026-11-13 23:19:17 +08:00
l0ng-ai f3d0417f4c feat(languages): Slint with the slint-lsp language server (#3191)
Adds a Slint language definition (.slint) served by slint-lsp, installable from the slint-ui/slint GitHub releases (macOS universal, Linux and Windows x64/arm64).
2026-11-13 23:09:26 +08:00
l0ng-ai 291d1324c5 style(agents): borderless task search that sits in the list like a tree row 2026-11-13 22:44:34 +08:00
l0ng-ai a6e137a6ae feat(terminal): move a terminal tab to the editor area (#3187)
A Terminal tool window tab can move into the editor area with its session still running (Move to Editor). Restored editor terminal tabs start a login shell where the session was. The New CLI launcher page, saved CLIs and the cli.new action are removed.
2026-11-13 22:44:17 +08:00
l0ng-ai 89f00a29f9 feat(agents): reference chips, quote blocks and a richer completion in the composer (#3091)
The agent composer is now a CodeMirror editor: the text stays plain and is what's sent, while @ file references are drawn as chips (icon, name, lines) that move and delete as one piece, open on click and preview on hover; Backspace after a chip opens it as text. Quotes show as blocks, and sent messages get the same chips and quote blocks. The @ completion lists open editors first, highlights matches and shows the folder; the focused editor's file is offered above the text, a pasted project path becomes a reference, and dropping files shows a hint. Lines copied from an editor paste as a reference, terminal output as a terminal block, and code blocks show as cards. The reference trimming regex avoids lookbehind for WebKit before 16.4.
2026-11-13 22:29:59 +08:00
l0ng-ai 834f9e172c test(i18n): allow CLI to stay untranslated (#3186)
The New CLI tab added "CLI" to the text catalogs; like Shell and other abbreviations it reads the same in every locale, so the catalog test lists it with them.
2026-11-13 22:27:11 +08:00
l0ng-ai 45badba915 feat(agents): generated titles for Claude Code and Codex tasks (#3133)
Claude Code run over stdio never generates a session title, and codex app-server leaves thread naming to its clients, so both kept the first line of their prompt as their title. After a turn without a title, the Claude shim sends the generate_session_title control request (persist: true) with the last 1000 characters of the prompts and replies; the Codex shim asks thread/sample for a title after the first completed turn of an unnamed top-level thread and stores it with thread/name/set. Without a title the next turn tries again, and a task renamed in the IDE keeps its name. Imported Codex sessions take their name from ~/.codex/session_index.jsonl.
2026-11-13 22:25:10 +08:00
l0ng-ai 71728a473a ci(release): keep the Rust build cache when a release build fails (#3125)
A release build that fails after compiling (for example when notarization is rejected) saved no Rust cache, so a rerun compiled the whole app again. The rust-cache steps of the desktop and server builds now set cache-on-failure.
2026-11-13 22:23:12 +08:00
l0ng-ai 2acd5cf479 fix(java): import a Gradle build on a JDK its Gradle runs on when no version is declared (#3181)
Without a Project JDK and without a Java version the build declares, the jdtls Gradle import ran on jdtls's own runtime, which an older Gradle cannot run on, so every non-JDK dependency stayed unresolved. When the Gradle version is known, the import now uses the newest installed JDK that Gradle supports; a Project JDK, Gradle JVM or declared toolchain still wins.

Fixes #2681
2026-11-13 22:17:50 +08:00
Vadim LazukтаInf1mum 85f4ffb67c feat(terminal): CLI sessions as editor-area tabs (#3177)
Full-size agent CLIs (Claude Code, Codex, aider…) open as editor-area tabs next to file tabs. Each tab runs one saved command in the user's login shell in the project directory and reuses the terminal session (PTY lifecycle, resize, IME, links, theme, font zoom); tabs keep their session while hidden. A New CLI tab (action cli.new, also in the Terminal tool window's more menu) lists saved CLIs, adds new ones and opens a plain shell. CLI tabs are restored with the editor session showing a Start button and never run on their own. A new optional, generic EditorKindContribution.isBusy hook makes closing a running CLI ask first and keeps busy tabs out of tab-limit eviction.

Fixes #2202

Co-authored-by: Inf1mum <[email protected]>
2026-11-13 22:12:54 +08:00
Muuтаmuutot 4348dab26e fix(settings): write settings off the main thread (#3153)
On Windows a WebView2 IPC callback reaches settings_set with about 750 KiB of the main thread's 1 MiB stack already used, while validating the merged Settings needs about 250 KiB in a debug build (measured by bisecting a worker's stack size; independent of the settings file), so applying a setting could overflow the stack. The write now runs on a worker thread that the command joins, keeping writes serialized as before.

Co-authored-by: muutot <[email protected]>
2026-11-13 22:07:12 +08:00
l0ng-ai 38d4dd8c16 feat(deployment): enter an FTP server's password in its connection settings (#3179)
FTP and FTPS deployment servers had no password field: the password was only asked for after a failed Test Connection. The Connection tab now has Password and Save password below User name, staged like a setting and written on Apply (saved, or for this session); clearing Save password forgets a saved password, and a saved one shows as "Password already stored". Test Connection logs in with a typed, not yet applied password.

Fixes #3151
2026-11-13 21:57:57 +08:00
Vadim LazukтаInf1mum 58af3c55a5 fix(git-toolbox): show the focused file's branch in workspace windows (#3159)
In a workspace window the plugin asked vcs_status of the window's project, the workspace container, which is usually not a git repository, so the branch item disappeared and Fetch Now fetched nothing. Git Toolbox now resolves the editor-focused file to the deepest open project containing it and shows that repository, falling back to the window's project, the one shown last, then the first open one. Fetch Now fetches only the repository the status bar shows. Covered by plugin unit tests and a host e2e test with a two-member workspace.

Fixes #2556

Co-authored-by: Inf1mum <[email protected]>
2026-11-13 21:46:42 +08:00
Muuтаmuutot a8643366c8 fix(vcs): keep the inline blame at the line start, off the caret path, and its tooltip to itself (#3167)
The inline blame label's gap was a margin, so a caret at the line end was drawn after the label, and a blank line pushed both label and caret past column 0. The gap is now padding (clipped from hit-testing past any code-vision hint), blank lines get no gap, and the commit tooltip opens only when the pointer is on the label itself, not on another line-end widget at the same offset. The label sits after plugin line-end messages (side 2) and is no longer truncated.

Co-authored-by: muutot <[email protected]>
2026-11-13 21:43:57 +08:00
rohitkumardandaтаrohitdanda 22685815cc fix(agents): keep the user's MCP server values in the credential store (#3002)
Environment variable and header values of the user's MCP servers move from the user settings.toml into the credential store (AgentSecrets, as for agent environment values): on start-up, on settings import/export, and when the MCP page saves. They are filled back in when the page lists servers and when a session starts. A project's own servers keep their values in the project's .openidea/settings.toml, where the project shares them.

Co-authored-by: rohitdanda <[email protected]>
2026-11-13 21:28:11 +08:00
Vadim LazukтаInf1mum e6b88b2701 feat(editor): support linked editing for matching HTML tags (#2993)
Editing the name in an opening or closing HTML tag renames the matching tag of the same element as you type, from the @lezer/html syntax tree, in one undoable transaction. Covers HTML, Vue, Angular templates, JSP, Django and Astro (ERB is token-based and stays out). Smart Keys gains Simultaneous <tag></tag> editing (on by default). Review fix: the Gutter Icons bookmarks group keeps its real gutter class.

Co-authored-by: Inf1mum <[email protected]>
2026-11-13 21:21:12 +08:00
l0ng-ai c076dedefc feat(agents): search the task list (#3174)
A search field above the Agents task list (and the session history popup) filters tasks as you type: fuzzy on the title with the matches highlighted, word starts on the project folder and branch. It searches every section, collapsed Archived included, hides sections without a match; Up/Down move the selection, Enter opens, Escape clears.
2026-11-13 21:14:41 +08:00
l0ng-ai 3764d963d7 feat(editor): show the interfaces a Go type or method implements in the gutter (#3173)
Go types satisfy interfaces implicitly, so the gutter only showed the interface side. Named types and receiver methods now get an implements marker from gopls's implementation answer (the interfaces / interface methods they satisfy). Named non-struct types no longer show the interfaces they satisfy as subclasses. Go method targets are named after their receiver and Go interface/struct declarations count as enclosing types.
2026-11-13 21:10:36 +08:00
l0ng-ai 4d2bcbbc26 fix(debugger): offer the Kotlin debugger before starting a Maven or Gradle debug run (#3172)
Debugging a test in a module with Kotlin sources started Maven / Gradle with a JDWP agent and only found the missing kotlin-debug-adapter when attaching: the Run window flashed, the empty Debug window replaced it and the build was stopped. debugWithAgent now asks the debugger (debug.jvmAttachReady) first; when the adapter is missing the build doesn't start and Install and Debug installs it and debugs again.
2026-11-13 21:03:00 +08:00
l0ng-ai 56d9c8908c fix(status bar): close Background Tasks when its last task ends (#3171)
Cancelling the only task from the Background Tasks popup unmounted the status bar widget it was anchored to, leaving the popup in the top-left corner reading "No background tasks". It now closes when the task list empties, and anchors bottom-right when there are no tasks.
2026-11-13 21:02:19 +08:00
l0ng-ai 0bd0c4e03b fix(toolwindows): don't reopen Run, Debug and Find with the project (#3170)
Run and Debug sessions and Find / Find Usages results don't survive closing the project, but a layout saved with one of these windows open brought it back empty on reopen ("Not debugging"). Tool windows can declare sessionContent; their visibility is left out of the saved layout and dropped from older layouts.
2026-11-13 21:01:43 +08:00
l0ng-ai 209fc796ce feat(debugger): attach to .NET processes and list processes on Windows (#3169)
Attach to Process lists .NET apps (dotnet host or apphost with <name>.runtimeconfig.json) under ".NET (netcoredbg)" and attaches by process id. On Windows the process list is now implemented (ToolHelp + image path + command line with limited query access), so the action no longer fails as unsupported. On macOS the executable path comes from proc_pidpath, since ps comm kept relative launch paths and hid apphosts and Go binaries started as ./bin/app.
2026-11-13 20:50:10 +08:00
l0ng-ai e7e89e46c2 docs: Chinese README, sponsor section only in the Chinese one (#3168) 2026-11-13 20:46:18 +08:00
l0ng-ai c87ab87417 feat(vcs): create a Git repository for a project that has none (#3166)
Git › Create Git Repository… asks for the folder (the project root by default), runs git init and rescans for repositories, so the branch widget, Commit tool window and file statuses appear right away. The title bar's Version Control menu for projects without a repository lists it first.
2026-11-13 20:39:03 +08:00
l0ng-ai a9ad1f1bbb fix(agents): stop Claude Code processes when their connection closes (#3163)
Closing a task's connection (idle release, archive, delete) stopped its claude -p processes through a weak reference to the in-process shim, which the closing connection could already have dropped; the processes then ran until the app quit. The close watcher now holds the shim, and a session dropped without being stopped stops its process. Rollback and fork, which start a stopped agent without a turn, now schedule the same idle release.
2026-11-13 20:35:41 +08:00
l0ng-ai 4c8caca6a5 feat(editor): open the blame annotations from Code Vision's author hint (#3161)
Clicking a declaration's Code Vision author hint now runs Annotate with Git Blame for that editor and leaves the annotations open when already shown.
2026-11-13 20:25:47 +08:00
l0ng-ai 2ec2e75d40 fix(vcs): run the merge editor's buttons on a mouse click on macOS (#3160)
WebKit doesn't focus a clicked button, so focus moved to the editor area outside the merge editor and cleared the focused merge editor; Apply, Cancel, Accept Yours/Theirs and the toolbar actions then did nothing on a mouse click. The merge editor root is now focusable (tabindex -1) so a click inside it keeps focus there.
2026-11-13 20:20:50 +08:00
Muuтаmuutot 04d5d9bd58 feat(color scheme): edit text style for errors and warnings (#3154)
Errors and Warnings attributes (Error, Warning, Typo, Unused symbol, Deprecated symbol) now offer bold, italic and effect choices like syntax tokens, written to --wave-*/--fg-* variables the editor reads; Typo and Deprecated symbol are listed and previewed. Deprecated symbol keeps the text colour and draws only its effect; an effect set to none draws none. Custom and imported schemes keep the font style and effect of token-less attributes (Errors and Warnings, Git), and the preview shows them.

Co-authored-by: muutot <[email protected]>
2026-11-13 20:15:01 +08:00
l0ng-ai 9a415e6875 fix(project-tree): list a re-installed SNAPSHOT's entries after reloading the project (#3158)
A SNAPSHOT installed again replaces its jar in place, which no file watcher reports, so expanded folders inside it under External Libraries kept the old entries. The project view now re-reads its loaded folders inside archives when project roots change (a build tool sync).
2026-11-13 20:04:17 +08:00
l0ng-ai 6f83f8cad0 fix(java): show a re-installed SNAPSHOT's classes after reloading the project (#3157)
A SNAPSHOT installed again replaces its jar in place, so class URIs and jdtls' class handles stay the same; the backend's virtual document cache, the editor's cache and jdtls' decompiler cache all kept the old contents. Cached library classes now record their archive's modification time and are read again once it changes; after a build tool sync jdtls refreshes external archives, closes the changed roots and drops its decompiled sources (openidea.java.refreshArchives, java-usages 1.14.0), and open classes whose jar changed are replaced. The editor drops cached virtual documents when project roots change.
2026-11-13 20:01:02 +08:00
l0ng-ai b971c8b391 fix(ui): keep a right-click in an open menu from opening the toolbar menu (#3152)
Right-clicking an entry of the in-app main menu (Windows and Linux), such as a project under File › Open Recent, opened the title bar's toolbar menu (Project Widget, VCS Widget, …): the menu is rendered in a popup but is a child of the title bar in the component tree, so the contextmenu event reached the title bar's handler. A menu now consumes right-clicks itself, which covers every menu shown above another component's context menu.
2026-11-13 19:33:30 +08:00
l0ng-ai c333990404 fix(debugger): show JVM values as {Type@id} summary and map entries as key -> value (#3150)
The Variables view showed the Java debugger's text under its type: args = {String[]} String[0]@9, map = {HashMap} HashMap@10 size=1, and a map entry as 0 = {HashMap$Node} HashMap$Node@31 "a":"b". A JVM object now reads {String[0]@9} [] and {HashMap@10} size = 1, a map entry as "a" -> "b" without its index, and a String without its type. Inline values and the value tooltip use the same text (args: [], map: size = 1). Values from other debuggers keep their text and type.
2026-11-13 19:24:18 +08:00
l0ng-ai 70b953c77a feat(deployment): server groups for uploading to several servers at once (#3149)
Deployment settings can hold server groups (+ › Server Group): a name and a set of the configured servers. Upload to… lists a group whenever one of its servers covers the selection and uploads to each of its servers in turn. Each server reports its result or error in its own notification, and a failure on one server doesn't stop the rest. Removing a server drops it from its groups. Upload to Default Server, download, sync and delete keep working on single servers.
2026-11-13 19:18:33 +08:00
l0ng-ai cb18b88531 test(i18n): allow "Python {short}" to read the same in every locale (#3147)
The conda Python version picker labels its entries "Python 3.12", which every locale writes like English.
2026-11-13 19:12:07 +08:00
Mr0Bread 401d7a4105 fix(database): Tab goes from a result tab to the grid's first cell (#3130)
Tab on a database result tab stepped through every toolbar control (7 to 11 stops) before reaching the grid, and the grid then had no active cell. Now:
- a new WAI-ARIA Toolbar (src/ui/Toolbar.tsx) makes the result, plan, Output and pager toolbars one Tab stop with roving focus on arrows/Home/End;
- Tabs gets an optional focusContent prop, called on plain Tab, so Tab from a result tab focuses the grid (or plan tree / Output log);
- the grid selects its first cell when focused from the keyboard, Tab / Shift+Tab move between cells row by row and leave the grid past either end, and Tab in the cell editor commits and moves on.

Co-authored-by: Mr0Bread <[email protected]>
2026-11-13 19:05:47 +08:00
Mr0Bread 46bd1f6fb8 fix(ui): accent the selected tab only in the part that has focus (#3124)
The editor, tool windows and database results painted their selected tab with the same accent, so it wasn't clear which part had focus. A focus-scope tracker now marks the editor pane, each tool window and the results view that contain the focused element; outside the focused scope the selected tab uses the existing --bg-tab-selected-inactive / --border-tab-selected-inactive tokens. Scopes are opt-in, so tab strips in dialogs and popups stay accented, and focus moving into a popup, menu or dialog keeps the current marks. IntelliJ theme import maps the inactive underlined tab colours to the same tokens.

Co-authored-by: Mr0Bread <[email protected]>
2026-11-13 19:05:26 +08:00
Mr0Bread 85f755591b feat(database): Expand All in the Database tool window header (#3140)
The Database tool window header had Collapse All but no Expand All. Add Expand All (db.expandAll) next to it, also reachable from Find Action. It opens every node whose children are already loaded and never connects to a data source or runs a query, so unconnected sources and tables without loaded columns stay closed.

Co-authored-by: Mr0Bread <[email protected]>
2026-11-13 19:05:14 +08:00
Shohruh Ismoilovтаshohruh-genius 06b7513210 fix(vcs): keep Jump to Source current in focused diffs (#3137)
Pressing Cmd+Down or F4 in a focused revision diff did nothing when the working file was found after focus entered the pane: the keyboard dispatcher kept the navigator object registered before that lookup, while the diff replaced it. The diff now keeps the registered navigator and refreshes its handlers in place, so Jump to Source opens the working file at the mapped caret.

Co-authored-by: shohruh-genius <[email protected]>
2026-11-13 19:04:56 +08:00
l0ng-ai 09aa03bb86 fix(debugger): keep the frames | variables split, put the views after the toolbar, badge live sessions (#3144)
The divider between frames and variables is now remembered across debug starts; the Debug tool window's toolbar leads with the session actions, followed by the Threads & Variables / Console / Memory tabs; and the Debug stripe button shows how many debug sessions are live, as Run does.

Refs #3112
2026-11-13 18:58:27 +08:00
l0ng-ai 317988cfa7 fix(python): refresh the interpreter page after recreating an environment, pick a conda env's Python version (#3143)
Creating an environment at the path the project interpreter already pointed to left the Python Interpreter page saying it does not exist: the package list and the validity check reloaded only when the path changed. Both now reload after an environment is created. A new conda environment now asks for a Python version instead of a base interpreter chosen among existing environments, which read as copying one; the note says it starts with only Python.

Refs #3109
2026-11-13 18:58:22 +08:00
l0ng-ai 7db2e98269 fix(project-view): offer Git and Find Usages in workspace members outside the workspace folder (#3142)
The Project view's context menu counted a node as part of the project only inside the window's root, which in a workspace is the workspace folder. Members outside it lost the Git submenu and Find Usages, although their repositories are discovered and their changes listed. Those entries now accept nodes inside the root or any workspace member; Mark Directory as stays limited to the root, whose project structure it edits, and Scratches stay outside.

Fixes #3111
2026-11-13 18:55:42 +08:00
l0ng-ai fdc8bc2d4c fix(editor): leave reference counts to Code Vision's usages (#3139)
csharp-ls adds a "3 Reference(s)" lens above each declaration, next to Code Vision's own "3 usages". It duplicated the count, ignored the Code Vision settings, and was a second set of lens rows replaced after every edit. Language-server lenses that are only a reference count are now left out; Settings › Editor › Inlay Hints › Code Vision › Usages governs the count.

Refs #3107
2026-11-13 18:41:34 +08:00
l0ng-ai 497d4766d7 fix(vcs): lay out a merge commit's file side by side, each pane scrolling (#3138)
Opening a file of a merge commit from the Git log stacked its three panes in a column the editor could not scroll: only the first parent's text showed, without the changes, and neither the wheel nor the arrow keys moved it. The panes now sit side by side under a label row, each scrolls and they stay in step.

Fixes #3108
2026-11-13 18:38:56 +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
Muuтаmuutot 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
langxpтаkradme 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
GuoYuтаguoyucode 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
acaoтаamphetamlne 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
LunarFellerтаLunarFeller 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
LunarFellerтаLunarFeller 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
rohitkumardandaтаrohitdanda 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
GuoYuтаguoyucode 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
GuoYuтаguoyucode 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
GuoYuтаguoyucode 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
GuoYuтаguoyucode 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
l0ng-ai 28829ad756 style(vcs): format inline blame (#3051) 2026-11-13 11:22:44 +08:00
l0ng-ai d6c562049f test(e2e): load the app modules of the Branches resize spec through variables (#3050)
knip resolved the literal Vite paths in page.evaluate as imports and failed on them; the other specs pass the module path through a variable.
2026-11-13 11:21:04 +08:00
l0ng-ai 100075166c test(database): let FSEvents deliver the startup events before checking ignored files (#3048)
On macOS, FSEvents can report the creation of .openidea/ made just before the watch started after the watch is running, so ignores_other_files counted one change and failed on CI. The test now waits for those events and compares against the count after them.
2026-11-13 11:18:40 +08:00
l0ng-ai d5332a2197 feat(ui): show Go files with a gopher icon (#3047)
Go files get the gopher icon, as GoLand shows them, instead of a "GO" letter tile; go.mod and go.work keep theirs.
2026-11-13 11:16:32 +08:00
arookieofcтаarookieofc b0d20d7997 feat(vcs): Backend trait for the Changes-view surface (#2958)
Adds an ide-vcs Backend trait for what the Commit / Local Changes view, diff viewer, annotate and log need from a repository (status, file_at / file_mode_at, diffs, line hunks, blame, block history, log, commit files, revision diffs, stage / unstage / discard, commit, pending changes), with Repo implementing it by forwarding. Git-only concepts (branches, remotes, stash, rebase, worktrees) stay on Repo, reached through as_git.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Closes #3014

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

Fixes #2968

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

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

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

Fixes #2969
2026-11-13 08:11:38 +08:00
arookieofc 4c15fe5a23 test(e2e): take the first 'Plain PHP documentation' token (#2994)
The locator resolves to two leaf spans — the comment tokenizes as
multiple spans — so the strict-mode expect flakes. Every other
multi-match token in the spec already uses .first().
2026-11-13 03:33:18 +08:00
arookieofc 8f6a4b5b39 style: format the plugin settings test (#2980 merged with a format slip) (#2992)
oxfmt --check fails on main, failing the Frontend job for every PR.
2026-11-13 02:30:22 +08:00
1091 змінених файлів з 53595 додано та 5402 видалено

No files matched your search

+1 -1
Переглянути файл
@@ -7,7 +7,7 @@ BASEDPYRIGHT=1.40.2
RUFF=0.17.3
GOPLS=v0.24.0
JDTLS=1.63.0-202610191322
KOTLIN_LSP=263.7084.0
KOTLIN_LSP=271.2085.0
GRADLE=8.14.3
INTELEPHENSE=1.18.5
# The JDK projects compile against (JAVA_HOME): library navigation opens its sources.
+1 -1
Переглянути файл
@@ -119,7 +119,7 @@ jobs:
- run: cargo test --locked -p ide-host --test language_smoke --no-run
- run: cargo test --locked -p ide-host --test language_smoke -- --ignored --exact ${{ matrix.language }}
- if: matrix.language == 'java'
run: cargo test --locked -p ide-host --test project_smoke -- --ignored --exact maven_sync jdk
run: cargo test --locked -p ide-host --test project_smoke -- --ignored --exact maven_sync jdk maven_launch_classpath_uses_the_runner_jdk_classifier
- if: matrix.language == 'kotlin'
run: cargo test --locked -p ide-host --test project_smoke -- --ignored --exact gradle_sync
- if: matrix.language == 'python'
+1 -1
Переглянути файл
@@ -47,7 +47,7 @@ jobs:
exit 0
fi
# No `nightly` tag yet (the first run): compare fails and the build goes on.
status="$(gh api "repos/$GH_REPO/compare/nightly...$sha" --jq .status 2>/dev/null || echo ahead)"
status="$(gh api "repos/$GH_REPO/compare/nightly...$sha" --jq .status 2>/dev/null)" || status=ahead
if [ "$status" != ahead ] && [ "$FORCE" != true ]; then
echo "::notice::$sha is not newer than the last nightly"
exit 0
+11 -2
Переглянути файл
@@ -78,7 +78,7 @@ jobs:
- uses: dtolnay/rust-toolchain@stable
with: { targets: "${{ matrix.target }}" }
- uses: Swatinem/rust-cache@v2
with: { key: "${{ matrix.target }}" }
with: { key: "${{ matrix.target }}", cache-on-failure: true }
- name: Linux deps
if: runner.os == 'Linux'
run: |
@@ -151,6 +151,13 @@ jobs:
$hash = (Get-FileHash $asset -Algorithm SHA256).Hash.ToLower()
"$hash $asset" | Out-File -Encoding ascii "$asset.sha256"
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
# app downloads the one for the host (ide-tools `server`) and installs it over SSH.
@@ -174,7 +181,7 @@ jobs:
- uses: dtolnay/rust-toolchain@stable
with: { targets: "${{ matrix.target }}" }
- uses: Swatinem/rust-cache@v2
with: { key: "server-${{ matrix.target }}" }
with: { key: "server-${{ matrix.target }}", cache-on-failure: true }
- name: musl toolchain
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y musl-tools
@@ -233,6 +240,8 @@ jobs:
[/_x64\.app\.tar\.gz$/, ["darwin-x86_64", "darwin-x86_64-app"]],
...windows("aarch64", /_arm64_en-US\.msi$/, /_arm64-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 = {};
for (const sig of sigs) {
Створено
+52 -51
Переглянути файл
@@ -4144,7 +4144,7 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "ide-agent"
version = "0.1.43"
version = "0.1.45"
dependencies = [
"dunce",
"encoding_rs",
@@ -4168,7 +4168,7 @@ dependencies = [
[[package]]
name = "ide-analysis"
version = "0.1.43"
version = "0.1.45"
dependencies = [
"ast-grep-core",
"ide-core",
@@ -4182,7 +4182,7 @@ dependencies = [
[[package]]
name = "ide-android"
version = "0.1.43"
version = "0.1.45"
dependencies = [
"ide-core",
"serde",
@@ -4193,7 +4193,7 @@ dependencies = [
[[package]]
name = "ide-build"
version = "0.1.43"
version = "0.1.45"
dependencies = [
"blake3",
"chardetng",
@@ -4211,7 +4211,7 @@ dependencies = [
[[package]]
name = "ide-cli"
version = "0.1.43"
version = "0.1.45"
dependencies = [
"dirs 6.0.0",
"serde",
@@ -4225,7 +4225,7 @@ dependencies = [
[[package]]
name = "ide-clipboard"
version = "0.1.43"
version = "0.1.45"
dependencies = [
"gtk",
"objc2 0.6.5",
@@ -4235,7 +4235,7 @@ dependencies = [
[[package]]
name = "ide-core"
version = "0.1.43"
version = "0.1.45"
dependencies = [
"dunce",
"encoding_rs",
@@ -4252,7 +4252,7 @@ dependencies = [
[[package]]
name = "ide-coverage"
version = "0.1.43"
version = "0.1.45"
dependencies = [
"ide-core",
"ide-run",
@@ -4266,7 +4266,7 @@ dependencies = [
[[package]]
name = "ide-dap"
version = "0.1.43"
version = "0.1.45"
dependencies = [
"futures",
"ide-core",
@@ -4288,11 +4288,12 @@ dependencies = [
"tokio",
"tracing",
"tracing-subscriber",
"windows-sys 0.61.2",
]
[[package]]
name = "ide-db"
version = "0.1.43"
version = "0.1.45"
dependencies = [
"base64 0.22.1",
"bb8",
@@ -4328,7 +4329,7 @@ dependencies = [
[[package]]
name = "ide-docker"
version = "0.1.43"
version = "0.1.45"
dependencies = [
"ide-core",
"serde",
@@ -4341,7 +4342,7 @@ dependencies = [
[[package]]
name = "ide-extension"
version = "0.1.43"
version = "0.1.45"
dependencies = [
"base64 0.22.1",
"blake3",
@@ -4362,7 +4363,7 @@ dependencies = [
[[package]]
name = "ide-fonts"
version = "0.1.43"
version = "0.1.45"
dependencies = [
"objc2-core-foundation",
"objc2-core-text",
@@ -4374,7 +4375,7 @@ dependencies = [
[[package]]
name = "ide-github"
version = "0.1.43"
version = "0.1.45"
dependencies = [
"ide-core",
"ide-vcs",
@@ -4387,7 +4388,7 @@ dependencies = [
[[package]]
name = "ide-heap"
version = "0.1.43"
version = "0.1.45"
dependencies = [
"criterion",
"ide-core",
@@ -4405,7 +4406,7 @@ dependencies = [
[[package]]
name = "ide-host"
version = "0.1.43"
version = "0.1.45"
dependencies = [
"base64 0.22.1",
"blake3",
@@ -4476,7 +4477,7 @@ dependencies = [
[[package]]
name = "ide-http"
version = "0.1.43"
version = "0.1.45"
dependencies = [
"ide-core",
"ide-vfs",
@@ -4495,7 +4496,7 @@ dependencies = [
[[package]]
name = "ide-index"
version = "0.1.43"
version = "0.1.45"
dependencies = [
"blake3",
"criterion",
@@ -4517,7 +4518,7 @@ dependencies = [
[[package]]
name = "ide-jdwp"
version = "0.1.43"
version = "0.1.45"
dependencies = [
"ide-jvm-inspect",
"proptest",
@@ -4529,7 +4530,7 @@ dependencies = [
[[package]]
name = "ide-jsonrpc"
version = "0.1.43"
version = "0.1.45"
dependencies = [
"encoding_rs",
"futures",
@@ -4546,7 +4547,7 @@ dependencies = [
[[package]]
name = "ide-jvm-attach"
version = "0.1.43"
version = "0.1.45"
dependencies = [
"libc",
"tempfile",
@@ -4555,7 +4556,7 @@ dependencies = [
[[package]]
name = "ide-jvm-inspect"
version = "0.1.43"
version = "0.1.45"
dependencies = [
"futures",
"serde",
@@ -4566,7 +4567,7 @@ dependencies = [
[[package]]
name = "ide-language"
version = "0.1.43"
version = "0.1.45"
dependencies = [
"blake3",
"criterion",
@@ -4623,7 +4624,7 @@ dependencies = [
[[package]]
name = "ide-lsp"
version = "0.1.43"
version = "0.1.45"
dependencies = [
"dunce",
"encoding_rs",
@@ -4651,7 +4652,7 @@ dependencies = [
[[package]]
name = "ide-mcp"
version = "0.1.43"
version = "0.1.45"
dependencies = [
"bytes",
"dunce",
@@ -4670,7 +4671,7 @@ dependencies = [
[[package]]
name = "ide-notebook"
version = "0.1.43"
version = "0.1.45"
dependencies = [
"futures",
"ide-core",
@@ -4693,7 +4694,7 @@ dependencies = [
[[package]]
name = "ide-plugin"
version = "0.1.43"
version = "0.1.45"
dependencies = [
"blake3",
"dunce",
@@ -4716,7 +4717,7 @@ dependencies = [
[[package]]
name = "ide-profiler"
version = "0.1.43"
version = "0.1.45"
dependencies = [
"ide-core",
"ide-jvm-attach",
@@ -4730,7 +4731,7 @@ dependencies = [
[[package]]
name = "ide-remote"
version = "0.1.43"
version = "0.1.45"
dependencies = [
"dunce",
"futures",
@@ -4750,7 +4751,7 @@ dependencies = [
[[package]]
name = "ide-run"
version = "0.1.43"
version = "0.1.45"
dependencies = [
"dunce",
"encoding_rs",
@@ -4774,7 +4775,7 @@ dependencies = [
[[package]]
name = "ide-scaffold"
version = "0.1.43"
version = "0.1.45"
dependencies = [
"ide-core",
"ide-tools",
@@ -4790,7 +4791,7 @@ dependencies = [
[[package]]
name = "ide-sdk"
version = "0.1.43"
version = "0.1.45"
dependencies = [
"dunce",
"ide-core",
@@ -4811,7 +4812,7 @@ dependencies = [
[[package]]
name = "ide-search"
version = "0.1.43"
version = "0.1.45"
dependencies = [
"criterion",
"encoding_rs",
@@ -4832,7 +4833,7 @@ dependencies = [
[[package]]
name = "ide-spell"
version = "0.1.43"
version = "0.1.45"
dependencies = [
"ide-core",
"parking_lot",
@@ -4845,7 +4846,7 @@ dependencies = [
[[package]]
name = "ide-ssh"
version = "0.1.43"
version = "0.1.45"
dependencies = [
"dirs 6.0.0",
"flate2",
@@ -4870,7 +4871,7 @@ dependencies = [
[[package]]
name = "ide-syntax"
version = "0.1.43"
version = "0.1.45"
dependencies = [
"ide-core",
"ide-language",
@@ -4885,7 +4886,7 @@ dependencies = [
[[package]]
name = "ide-terminal"
version = "0.1.43"
version = "0.1.45"
dependencies = [
"ide-core",
"libc",
@@ -4898,7 +4899,7 @@ dependencies = [
[[package]]
name = "ide-text"
version = "0.1.43"
version = "0.1.45"
dependencies = [
"criterion",
"ide-core",
@@ -4911,7 +4912,7 @@ dependencies = [
[[package]]
name = "ide-tools"
version = "0.1.43"
version = "0.1.45"
dependencies = [
"flate2",
"hex",
@@ -4932,7 +4933,7 @@ dependencies = [
[[package]]
name = "ide-vcs"
version = "0.1.43"
version = "0.1.45"
dependencies = [
"blake3",
"dunce",
@@ -4953,7 +4954,7 @@ dependencies = [
[[package]]
name = "ide-vfs"
version = "0.1.43"
version = "0.1.45"
dependencies = [
"chardetng",
"dunce",
@@ -4977,7 +4978,7 @@ dependencies = [
[[package]]
name = "ide-web"
version = "0.1.43"
version = "0.1.45"
dependencies = [
"dirs 6.0.0",
"ide-core",
@@ -4993,7 +4994,7 @@ dependencies = [
[[package]]
name = "ide-workspace"
version = "0.1.43"
version = "0.1.45"
dependencies = [
"blake3",
"dunce",
@@ -6594,7 +6595,7 @@ dependencies = [
[[package]]
name = "openidea"
version = "0.1.43"
version = "0.1.45"
dependencies = [
"base64 0.22.1",
"blake3",
@@ -8727,7 +8728,7 @@ checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620"
[[package]]
name = "sqlx"
version = "0.9.0"
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
source = "git+https://github.com/l0ng-ai/sqlx?rev=9894985a1babc7e6bc0437da4fd43e3143184073#9894985a1babc7e6bc0437da4fd43e3143184073"
dependencies = [
"sqlx-core",
"sqlx-macros",
@@ -8739,7 +8740,7 @@ dependencies = [
[[package]]
name = "sqlx-core"
version = "0.9.0"
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
source = "git+https://github.com/l0ng-ai/sqlx?rev=9894985a1babc7e6bc0437da4fd43e3143184073#9894985a1babc7e6bc0437da4fd43e3143184073"
dependencies = [
"base64 0.22.1",
"bytes",
@@ -8774,7 +8775,7 @@ dependencies = [
[[package]]
name = "sqlx-macros"
version = "0.9.0"
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
source = "git+https://github.com/l0ng-ai/sqlx?rev=9894985a1babc7e6bc0437da4fd43e3143184073#9894985a1babc7e6bc0437da4fd43e3143184073"
dependencies = [
"proc-macro2",
"quote",
@@ -8786,7 +8787,7 @@ dependencies = [
[[package]]
name = "sqlx-macros-core"
version = "0.9.0"
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
source = "git+https://github.com/l0ng-ai/sqlx?rev=9894985a1babc7e6bc0437da4fd43e3143184073#9894985a1babc7e6bc0437da4fd43e3143184073"
dependencies = [
"cfg-if",
"dotenvy",
@@ -8810,7 +8811,7 @@ dependencies = [
[[package]]
name = "sqlx-mysql"
version = "0.9.0"
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
source = "git+https://github.com/l0ng-ai/sqlx?rev=9894985a1babc7e6bc0437da4fd43e3143184073#9894985a1babc7e6bc0437da4fd43e3143184073"
dependencies = [
"bitflags 2.13.2",
"byteorder",
@@ -8835,7 +8836,7 @@ dependencies = [
[[package]]
name = "sqlx-postgres"
version = "0.9.0"
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
source = "git+https://github.com/l0ng-ai/sqlx?rev=9894985a1babc7e6bc0437da4fd43e3143184073#9894985a1babc7e6bc0437da4fd43e3143184073"
dependencies = [
"atoi",
"base64 0.22.1",
@@ -8869,7 +8870,7 @@ dependencies = [
[[package]]
name = "sqlx-sqlite"
version = "0.9.0"
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
source = "git+https://github.com/l0ng-ai/sqlx?rev=9894985a1babc7e6bc0437da4fd43e3143184073#9894985a1babc7e6bc0437da4fd43e3143184073"
dependencies = [
"atoi",
"flume",
+2 -2
Переглянути файл
@@ -5,7 +5,7 @@ members = ["crates/*", "src-tauri"]
exclude = ["sdk", "plugins"]
[workspace.package]
version = "0.1.43"
version = "0.1.45"
edition = "2024"
rust-version = "1.96"
license = "Apache-2.0"
@@ -111,7 +111,7 @@ reqwest = { version = "0.13", default-features = false, features = ["rustls-no-p
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] }
rustls-platform-verifier = "0.7"
# Until SQLx releases its libsqlite3-sys 0.39 update (RUSTSEC-2026-0459).
sqlx = { version = "0.9", git = "https://github.com/transact-rs/sqlx", rev = "10462fa4414c6c7f1ed1d39bb31678e3b726878c", default-features = false, features = ["runtime-tokio", "tls-rustls-ring-webpki", "sqlite", "postgres", "mysql"] }
sqlx = { version = "0.9", git = "https://github.com/l0ng-ai/sqlx", rev = "9894985a1babc7e6bc0437da4fd43e3143184073", default-features = false, features = ["runtime-tokio", "tls-rustls-ring-webpki", "sqlite", "postgres", "mysql"] }
tiberius = { version = "0.13", default-features = false, features = ["tds73", "rustls", "sql-browser-tokio"] }
# Pure-Rust thin Oracle driver; pinned to the beta until it stabilizes.
oracledb = "=26.0.0-beta.5"
+4 -13
Переглянути файл
@@ -9,6 +9,10 @@
The IDE you know, rebuilt in Rust.
</p>
<p align="center">
English · <a href="README.zh-CN.md">简体中文</a>
</p>
<p align="center">
<a href="https://github.com/l0ng-ai/openidea/releases/latest"><b>Download</b></a>
&nbsp;·&nbsp;
@@ -19,8 +23,6 @@
<a href="#plugins">Plugins</a>
&nbsp;·&nbsp;
<a href="#community">Community</a>
&nbsp;·&nbsp;
<a href="#sponsor">Sponsor</a>
</p>
https://github.com/user-attachments/assets/234dd54c-1255-4b3a-b35c-533d78d737c0
@@ -70,17 +72,6 @@ the [official plugins](plugins/official). The plugin API is still a preview.
- Contributing: see [CONTRIBUTING.md](CONTRIBUTING.md) and the [contributor docs](https://openidea.sh/en/contribute/setup).
## Sponsor
If OpenIDEA saves you time, you can support its development with Alipay or WeChat Pay.
<table>
<tr>
<td align="center"><img src=".github/assets/sponsor-alipay.png" alt="Alipay QR code" width="200"><br>Alipay</td>
<td align="center"><img src=".github/assets/sponsor-wechat.png" alt="WeChat Pay QR code" width="200"><br>WeChat Pay</td>
</tr>
</table>
## License
[Apache 2.0](LICENSE). Bundled third-party assets are listed in [`THIRD_PARTY_LICENSES.md`](THIRD_PARTY_LICENSES.md).
+88
Переглянути файл
@@ -0,0 +1,88 @@
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="src-tauri/icons/source/openidea-wordmark-dark.svg">
<img src="src-tauri/icons/source/openidea-wordmark.svg" alt="OpenIDEA" height="56">
</picture>
</p>
<p align="center">
你熟悉的 IDE,用 Rust 重新打造。
</p>
<p align="center">
<a href="README.md">English</a> · 简体中文
</p>
<p align="center">
<a href="https://github.com/l0ng-ai/openidea/releases/latest"><b>下载</b></a>
&nbsp;·&nbsp;
<a href="https://openidea.sh/zh">文档</a>
&nbsp;·&nbsp;
<a href="#性能对比">性能对比</a>
&nbsp;·&nbsp;
<a href="#插件">插件</a>
&nbsp;·&nbsp;
<a href="#社区">社区</a>
&nbsp;·&nbsp;
<a href="#赞助">赞助</a>
</p>
https://github.com/user-attachments/assets/234dd54c-1255-4b3a-b35c-533d78d737c0
OpenIDEA 把 IntelliJ IDEA 的使用方式(快捷键、工具窗口、随处搜索的习惯)带到一个以 Rust
为核心的原生桌面应用里。
编辑器、代码洞察、调试器、Git、终端、数据库客户端和 HTTP 客户端,支持 60 多种语言。
提供 macOS、Windows 和 Linux 版本。
## 性能对比
在缓存已预热的情况下打开 [freqtrade](https://github.com/freqtrade/freqtrade)(Python,约 770 个文件),
取 5 次运行的中位数:
| | OpenIDEA | IntelliJ IDEA 2026.2.3 |
| --------------------- | -------------- | ---------------------- |
| 显示项目窗口 | **0.9 s** | 3.1 s |
| 就绪(CPU 平稳) | **1.1 s** | 13.2 s |
| 内存(USS,所有进程) | **169 MB** | 2011 MB |
| 就绪前消耗的 CPU 时间 | **2.4 s** | 47.9 s |
| 空闲 CPU | 单核的 **4 %** | 单核的 7 % |
| 退出 | **0.3 s** | 5.9 s |
测试使用 `main` 分支的 OpenIDEA release 构建,以及默认设置和插件的 IDEA,环境为 Windows 11、
32 核 AMD、62 GB 内存。IDEA 对 Python 的分析更深入,所以两边的“就绪”并不代表完成了同样多的工作。
两者都以相同方式从外部测量:可以用 [`perf/compare`](perf/compare) 在自己的机器和项目上复现。
## 插件
插件可以添加操作、补全、快速修复、工具窗口、自定义编辑器、主题、文件图标和语言。插件以沙箱化的
WebAssembly 组件运行;主题、图标主题和语言无需编写代码。
在“设置 › 插件”中安装,或用 `openidea-plugin new` 创建自己的插件:参见
[编写 OpenIDEA 插件](https://openidea.sh/zh/plugins)和[官方插件](plugins/official)。插件 API 目前仍是预览版。
## 社区
- [Discord](https://discord.gg/2Zy4yn7gP):提问、反馈和版本发布消息。
- 微信群,扫码加入:
<img src=".github/assets/wechat-group.png" alt="微信群二维码" width="200">
- 参与贡献:参见 [CONTRIBUTING.md](CONTRIBUTING.md) 和[贡献者文档](https://openidea.sh/zh/contribute/setup)。
## 赞助
如果 OpenIDEA 帮你节省了时间,可以通过支付宝或微信支付支持它的开发。
<table>
<tr>
<td align="center"><img src=".github/assets/sponsor-alipay.png" alt="支付宝收款码" width="200"><br>支付宝</td>
<td align="center"><img src=".github/assets/sponsor-wechat.png" alt="微信支付收款码" width="200"><br>微信支付</td>
</tr>
</table>
## 许可证
[Apache 2.0](LICENSE)。随附的第三方资源列在 [`THIRD_PARTY_LICENSES.md`](THIRD_PARTY_LICENSES.md) 中。
<sub>与 JetBrains 无关联。IntelliJ IDEA 是 JetBrains s.r.o. 的商标。</sub>
+1
Переглянути файл
@@ -107,6 +107,7 @@ fn manager(dir: &Path, agent: Arc<FakeV2Agent>) -> AgentManager {
models_file: dir.join("models.json"),
custom_agents_dir: None,
home_dir: None,
env_secrets: None,
},
tokio::runtime::Handle::current(),
connector,
+1
Переглянути файл
@@ -154,6 +154,7 @@ async fn discovery_reads_effort_and_fast_from_session_new_then_stops_the_agent()
models_file: tmp.path().join("models.json"),
custom_agents_dir: None,
home_dir: None,
env_secrets: None,
},
tokio::runtime::Handle::current(),
connector,
+62 -3
Переглянути файл
@@ -42,9 +42,38 @@ pub(crate) fn bypass_approvals(config: &[AgentConfigOption]) -> bool {
let selected = |category: &str| {
config.iter().find(|o| o.category.as_deref() == Some(category)).and_then(|o| o.current.as_deref())
};
selected("mode").is_some_and(is_bypass_mode)
|| selected("permissions").is_some_and(|v| matches!(v.to_ascii_lowercase().as_str(), "on" | "true"))
|| selected("approval").is_some_and(|v| v.eq_ignore_ascii_case("bypass"))
["mode", "permissions", "approval"]
.into_iter()
.any(|category| selected(category).zip(bypass_value(category)).is_some_and(|(value, bypass)| bypass(value)))
}
/// For an option of `category` that can approve everything, what tells its approving values.
fn bypass_value(category: &str) -> Option<fn(&str) -> bool> {
match category {
"mode" => Some(is_bypass_mode),
"permissions" => Some(|v| matches!(v.to_ascii_lowercase().as_str(), "on" | "true")),
"approval" => Some(|v| v.eq_ignore_ascii_case("bypass")),
_ => None,
}
}
/// Moves options that approve everything without asking to a value that asks: the agent's
/// default or ask mode, else its first one that asks.
pub(crate) fn ask_for_approvals(config: &mut [AgentConfigOption]) {
for option in config {
let Some(bypass) = option.category.as_deref().and_then(bypass_value) else { continue };
if option.current.as_deref().is_some_and(|c| !bypass(c)) {
continue;
}
let asks = |v: &&AgentConfigValue| !bypass(&v.value);
let value = ["default", "ask"]
.iter()
.find_map(|preferred| option.values.iter().filter(asks).find(|v| v.value == *preferred))
.or_else(|| option.values.iter().find(asks));
if let Some(value) = value {
option.current = Some(value.value.clone());
}
}
}
/// The mode that approves everything without asking (`bypassPermissions`, `yolo`, `full-access`…), if the agent has one.
@@ -341,6 +370,36 @@ mod tests {
assert!(bypass_approvals(&url_mode));
}
#[test]
fn asking_for_approvals_keeps_a_mode_that_asks_and_leaves_one_that_does_not() {
let mode = |current: &str, values: &[&str]| {
options_of(&json!({"modes": {"currentModeId": current,
"availableModes": values.iter().map(|v| json!({"id": v})).collect::<Vec<_>>()}}))
};
let codex = ["read-only", "ask", "auto", "full-access"];
for (current, expected) in [("auto", "auto"), ("read-only", "read-only"), ("full-access", "ask")] {
let mut c = mode(current, &codex);
ask_for_approvals(&mut c);
assert_eq!(c[0].current.as_deref(), Some(expected), "{current}");
assert!(!bypass_approvals(&c));
}
let mut claude = mode("bypassPermissions", &["acceptEdits", "default", "plan", "bypassPermissions"]);
ask_for_approvals(&mut claude);
assert_eq!(claude[0].current.as_deref(), Some("default"));
let mut other = mode("yolo", &["yolo", "code", "architect"]);
ask_for_approvals(&mut other);
assert_eq!(other[0].current.as_deref(), Some("code"));
let mut c = options_of(&json!({"configOptions": [
{"id": "allow_all", "category": "permissions", "type": "select", "currentValue": "on",
"options": [{"value": "on"}, {"value": "off"}]},
{"id": "approval", "category": "approval", "type": "select", "currentValue": "bypass",
"options": [{"value": "bypass"}, {"value": "ask"}]}
]}));
ask_for_approvals(&mut c);
assert_eq!(ids(&c), [("allow_all", Some("off")), ("approval", Some("ask"))]);
}
#[test]
fn effort_and_fast_categories_are_inferred_from_ids() {
let c = options_of(&json!({
+43 -3
Переглянути файл
@@ -3,11 +3,13 @@
//!
//! Only the native providers keep transcripts on disk in a readable form: Claude Code
//! (`<config>/projects/*/<id>.jsonl`, see `native::claude::history`) and Codex
//! (`<home>/.codex/sessions/**`, rollout files with a `session_meta` line).
//! (`<home>/.codex/sessions/**`, rollout files with a `session_meta` line, named in
//! `<home>/.codex/session_index.jsonl`).
use super::native::claude::history;
use serde::Serialize;
use specta::Type;
use std::collections::HashMap;
use std::io::BufRead;
use std::path::{Path, PathBuf};
@@ -206,11 +208,39 @@ fn rollout_files(dir: &Path, depth: usize, out: &mut Vec<PathBuf>) {
}
}
/// Codex sessions of the project at `root`: `<home>/.codex/sessions/**`.
/// Thread names Codex keeps in `session_index.jsonl`, by thread id. A rename adds a line,
/// so the last one wins.
fn codex_names(codex_home: &Path) -> HashMap<String, String> {
let Ok(file) = std::fs::File::open(codex_home.join("session_index.jsonl")) else { return HashMap::new() };
let mut names = HashMap::new();
for line in std::io::BufReader::new(file).lines() {
let Ok(line) = line else { break };
let Ok(v) = serde_json::from_str::<serde_json::Value>(&line) else { continue };
let id = v.get("id").and_then(serde_json::Value::as_str);
let name = v.get("thread_name").and_then(serde_json::Value::as_str).map(str::trim).filter(|n| !n.is_empty());
if let (Some(id), Some(name)) = (id, name) {
names.insert(id.to_owned(), name.to_owned());
}
}
names
}
/// Codex sessions of the project at `root`: `<home>/.codex/sessions/**`, titled by their
/// thread names, else by their first prompts.
pub fn codex_sessions(codex_home: &Path, root: &Path) -> Vec<ExternalSession> {
let mut files = Vec::new();
rollout_files(&codex_home.join("sessions"), 4, &mut files);
files.iter().filter_map(|p| codex_file(p)).filter(|s| in_project(s.cwd.as_deref(), root)).collect()
let mut sessions: Vec<ExternalSession> =
files.iter().filter_map(|p| codex_file(p)).filter(|s| in_project(s.cwd.as_deref(), root)).collect();
if !sessions.is_empty() {
let names = codex_names(codex_home);
for s in &mut sessions {
if let Some(name) = names.get(&s.session_id) {
s.title = short(name);
}
}
}
sessions
}
#[cfg(test)]
@@ -282,6 +312,16 @@ mod tests {
assert_eq!((s.provider.as_str(), s.session_id.as_str()), ("codex", "thread-1"));
assert_eq!(s.title, "add tests");
assert_eq!(s.cwd.as_deref(), Some("/work/app"));
write(
&tmp.path().join("session_index.jsonl"),
&[
json!({"id": "thread-1", "thread_name": "Add tests", "updated_at": "t1"}),
json!({"id": "thread-2", "thread_name": "Other", "updated_at": "t1"}),
json!({"id": "thread-1", "thread_name": "Add parser tests", "updated_at": "t2"}),
],
);
assert_eq!(codex_sessions(tmp.path(), Path::new("/work"))[0].title, "Add parser tests");
}
#[test]
+65 -14
Переглянути файл
@@ -43,6 +43,9 @@ pub type AgentSink = Arc<dyn Fn(AgentEvent) + Send + Sync>;
/// Returns the active command execution security policy for the project.
pub type SecurityHook = Arc<dyn Fn() -> ide_core::settings::AgentSecuritySettings + Send + Sync>;
/// Returns the user's choice of the approvals new tasks start with.
pub type NewTaskApprovalsHook = Arc<dyn Fn() -> ide_core::settings::NewTaskApprovals + Send + Sync>;
/// Entry changes are batched for this long before they are sent.
const FLUSH_DELAY: Duration = Duration::from_millis(40);
/// Updates that arrive this late after `session/load` returns are still replayed history.
@@ -89,6 +92,18 @@ pub struct AgentPaths {
pub custom_agents_dir: Option<PathBuf>,
/// The user's home: its `.claude` holds their own skills and commands.
pub home_dir: Option<PathBuf>,
/// Where the values of the agent files' environment variables are kept; without it they stay in the files.
pub env_secrets: Option<Arc<dyn EnvSecrets>>,
}
/// Keeps the values of agent files' environment variables (`agent/<id>/env/<name>`) out of
/// the files, which keep only the names; the app keeps them in the OS credential store.
pub trait EnvSecrets: Send + Sync {
fn get(&self, key: &str) -> Option<String>;
fn set(&self, key: &str, value: &str) -> IdeResult<()>;
fn delete(&self, key: &str);
/// The keys that start with `prefix`.
fn keys(&self, prefix: &str) -> Vec<String>;
}
/// Starts the ACP server of a provider in a directory, speaking to `client`.
@@ -107,6 +122,7 @@ struct Shared {
connector: Connector,
sink: RwLock<Option<AgentSink>>,
security: RwLock<Option<SecurityHook>>,
new_task_approvals: RwLock<Option<NewTaskApprovalsHook>>,
known: Mutex<HashMap<String, Known>>,
providers: RwLock<Vec<provider::ProviderSpec>>,
/// Last probe of each provider.
@@ -136,6 +152,10 @@ impl Shared {
self.providers.read().iter().find(|p| p.id == id).cloned()
}
fn env_secrets(&self) -> Option<&dyn EnvSecrets> {
self.paths.env_secrets.as_deref()
}
fn emit(&self, event: AgentEvent) {
let sink = self.sink.read().clone();
if let Some(sink) = sink {
@@ -155,6 +175,11 @@ impl Shared {
// Model-specific options are offered separately, never as provider-wide defaults.
config.retain(|o| o.category.as_deref() != Some("thought_level"));
config::add_client_options(provider, &mut config);
let hook = self.new_task_approvals.read().clone();
if hook.is_some_and(|ask| ask() == ide_core::settings::NewTaskApprovals::Ask) {
config::ask_for_approvals(&mut config);
return config;
}
if let Some((o, v)) = config::bypass_mode(&config).map(|(o, v)| (o.id.clone(), v.value.clone()))
&& let Some(o) = config.iter_mut().find(|c| c.id == o)
{
@@ -446,13 +471,14 @@ impl AgentManager {
fn with_connector(paths: AgentPaths, runtime: tokio::runtime::Handle, connector: Connector) -> Self {
let known =
std::fs::read(&paths.models_file).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default();
let providers = RwLock::new(provider::load(paths.custom_agents_dir.as_deref()));
let providers = RwLock::new(provider::load(paths.custom_agents_dir.as_deref(), paths.env_secrets.as_deref()));
let shared = Arc::new(Shared {
paths,
runtime,
connector,
sink: RwLock::new(None),
security: RwLock::new(None),
new_task_approvals: RwLock::new(None),
known: Mutex::new(known),
providers,
probes: Mutex::new(HashMap::new()),
@@ -501,9 +527,14 @@ impl AgentManager {
*self.shared.security.write() = Some(hook);
}
/// Sets the callback that reads which approvals new tasks start with.
pub fn set_new_task_approvals_hook(&self, hook: NewTaskApprovalsHook) {
*self.shared.new_task_approvals.write() = Some(hook);
}
/// The agents (custom ones re-read), whether each is installed, and what they offered last time.
pub fn providers(&self) -> Vec<AgentProvider> {
let specs = provider::load(self.shared.paths.custom_agents_dir.as_deref());
let specs = provider::load(self.shared.paths.custom_agents_dir.as_deref(), self.shared.env_secrets());
let in_use: std::collections::HashSet<String> =
self.tasks.read().values().map(|t| t.state.lock().task.provider.clone()).collect();
let list = specs
@@ -537,7 +568,7 @@ impl AgentManager {
let Some(dir) = self.shared.paths.custom_agents_dir.clone() else {
return Err(IdeError::unavailable("there is no custom agents directory"));
};
provider::set_builtin_command(&dir, id, command.as_deref())
provider::set_builtin_command(&dir, self.shared.env_secrets(), id, command.as_deref())
}
/// Sets the environment variables built-in `id` starts with; empty clears them.
@@ -545,7 +576,7 @@ impl AgentManager {
let Some(dir) = self.shared.paths.custom_agents_dir.clone() else {
return Err(IdeError::unavailable("there is no custom agents directory"));
};
provider::set_builtin_env(&dir, id, env)
provider::set_builtin_env(&dir, self.shared.env_secrets(), id, env)
}
/// Saves a new custom ACP agent through its agent file.
@@ -553,7 +584,7 @@ impl AgentManager {
let Some(dir) = self.shared.paths.custom_agents_dir.clone() else {
return Err(IdeError::unavailable("there is no custom agents directory"));
};
provider::save_custom(&dir, &input)
provider::save_custom(&dir, self.shared.env_secrets(), &input)
}
/// Removes a custom agent's file (registry installs go through `registry_remove`).
@@ -561,7 +592,7 @@ impl AgentManager {
let Some(dir) = self.shared.paths.custom_agents_dir.clone() else {
return Err(IdeError::unavailable("there is no custom agents directory"));
};
provider::remove_custom(&dir, id)
provider::remove_custom(&dir, self.shared.env_secrets(), id)
}
/// Imports a JetBrains `acp.json` file's `agent_servers` entries as custom agents.
@@ -569,12 +600,12 @@ impl AgentManager {
let Some(dir) = self.shared.paths.custom_agents_dir.clone() else {
return Err(IdeError::unavailable("there is no custom agents directory"));
};
provider::import_acp(&dir, text)
provider::import_acp(&dir, self.shared.env_secrets(), text)
}
/// Re-reads configured/installed agents, drops cached probes, and notifies subscribers.
pub fn reload_providers(&self) {
let specs = provider::load(self.shared.paths.custom_agents_dir.as_deref());
let specs = provider::load(self.shared.paths.custom_agents_dir.as_deref(), self.shared.env_secrets());
*self.shared.providers.write() = specs;
self.shared.probes.lock().clear();
self.shared.emit(AgentEvent::Providers);
@@ -1066,6 +1097,8 @@ impl AgentManager {
title_renamed: s.title_renamed,
}
};
// Copying may have started the source's agent: it stops again once idle.
source.release_when_idle();
let task = Task::restore(file, self.shared.clone());
self.tasks.write().insert(task.id.clone(), task.clone());
task.persist();
@@ -1135,6 +1168,8 @@ impl AgentManager {
task.persist();
self.shared.emit(AgentEvent::Truncated { task: id.to_owned(), length: cut as u32 });
task.emit_task();
// Undoing may have started the agent: it stops again once idle.
task.release_when_idle();
Ok(first)
}
@@ -2266,15 +2301,19 @@ impl Task {
.iter()
.map(|question| question.answer_key.clone().unwrap_or_else(|| question.question.clone()))
.collect();
let command = security::extract_command(params);
// What the card names as the command: not for a request that grants more than running it.
let shown = command.as_deref().filter(|_| !security::widens_access(params)).map(shown_command);
if let Some(hook) = self.shared.security.read().clone()
&& let Some(command) = security::extract_command(params)
&& let Some(command) = command.as_deref()
{
match security::evaluate_command(&command, &hook()) {
match security::evaluate_command(command, &hook()) {
security::PolicyDecision::Blocked(pattern) => {
let reject = options.iter().find(|o| o.kind.starts_with("reject")).cloned();
let _ = self.push_entry(AgentEntry::Permission {
id,
title,
command: shown.clone(),
options,
questions: Vec::new(),
chosen: Some(format!("Blocked by policy ({pattern})")),
@@ -2284,7 +2323,7 @@ impl Task {
None => json!({ "outcome": { "outcome": "cancelled" } }),
};
}
security::PolicyDecision::Allowed => {
security::PolicyDecision::Allowed if !security::widens_access(params) => {
if let Some(allow) = options
.iter()
.find(|o| o.kind == "allow_once")
@@ -2294,6 +2333,7 @@ impl Task {
let _ = self.push_entry(AgentEntry::Permission {
id,
title,
command: shown.clone(),
options,
questions: Vec::new(),
chosen: Some(format!("{} (whitelisted)", allow.name)),
@@ -2301,12 +2341,18 @@ impl Task {
return json!({ "outcome": { "outcome": "selected", "optionId": allow.id } });
}
}
security::PolicyDecision::AskUser => {}
security::PolicyDecision::Allowed | security::PolicyDecision::AskUser => {}
}
}
let (tx, rx) = std::sync::mpsc::channel();
let entry =
self.push_entry(AgentEntry::Permission { id, title, options: options.clone(), questions, chosen: None });
let entry = self.push_entry(AgentEntry::Permission {
id,
title,
command: shown,
options: options.clone(),
questions,
chosen: None,
});
self.permissions.lock().insert(id, tx);
self.set_status(AgentStatus::NeedsInput, None);
self.approve_pending();
@@ -2468,6 +2514,11 @@ fn permission_title(params: &Value) -> String {
title.unwrap_or("The agent asks for permission").to_owned()
}
/// A command as a permission card shows it: the script of a `<shell> -c <script>` line.
fn shown_command(command: &str) -> String {
shell_script(command).unwrap_or_else(|| command.to_owned())
}
/// The script of a `<shell> -c <quoted script>` command line, when the whole line is exactly that.
pub(crate) fn shell_script(command: &str) -> Option<String> {
let (shell, rest) = command.split_once(' ')?;
+4
Переглянути файл
@@ -384,6 +384,10 @@ pub enum AgentEntry {
Permission {
id: u32,
title: String,
/// The shell command the request asks to run, from its structured input; `None` for
/// any other request, whatever its title says.
#[serde(default)]
command: Option<String>,
options: Vec<AgentPermissionOption>,
/// Set when the request asks `AskUserQuestion`'s questions; answered with a JSON object
/// of question to answer instead of an option.
+59
Переглянути файл
@@ -158,6 +158,42 @@ pub(crate) fn title(entries: &[Value]) -> Option<String> {
.map(str::to_owned)
}
/// Claude Code titles a session from the last 1000 characters of its text.
const TITLE_SOURCE_CHARS: usize = 1000;
/// Below this Claude Code gives no title without asking the model.
const TITLE_SOURCE_MIN_CHARS: usize = 10;
/// The conversation's own text to title the session after: the prompts and the replies,
/// without the files attached to the prompts. `None` while there is too little.
pub(super) fn title_source(entries: &[Value]) -> Option<String> {
let mut text = String::new();
for e in chain(entries, None) {
if !(is_turn_start(e) || kind(e) == "assistant") {
continue;
}
let blocks = match e.pointer("/message/content") {
Some(Value::String(s)) => vec![s.as_str()],
Some(Value::Array(blocks)) => blocks
.iter()
.filter(|b| b.get("type").and_then(Value::as_str) == Some("text"))
.filter_map(|b| b.get("text").and_then(Value::as_str))
.filter(|t| !t.starts_with("\n<context ref="))
.collect(),
_ => Vec::new(),
};
for block in blocks {
text.push_str(block);
text.push('\n');
}
}
let text = text.trim();
let len = text.chars().count();
if len < TITLE_SOURCE_MIN_CHARS {
return None;
}
Some(text.chars().skip(len.saturating_sub(TITLE_SOURCE_CHARS)).collect())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -219,6 +255,29 @@ mod tests {
assert_eq!(cut(&c, 1), Ok(None));
}
#[test]
fn titles_come_from_prompts_and_replies() {
let mut prompt = user("u1", None, "Fix the login form");
prompt["message"]["content"]
.as_array_mut()
.unwrap()
.push(json!({"type": "text", "text": "\n<context ref=\"file:///p/a.rs\">\nfn a() {}\n</context>"}));
let entries = vec![
prompt,
assistant(
"a1",
"u1",
json!([{"type": "thinking", "thinking": "hm"}, {"type": "text", "text": "Done."},
{"type": "tool_use", "id": "t1", "name": "Bash", "input": {}}]),
),
json!({"type": "user", "uuid": "r1", "parentUuid": "a1", "message": {"content": [{"type": "tool_result", "tool_use_id": "t1", "content": "out"}]}}),
];
assert_eq!(title_source(&entries).as_deref(), Some("Fix the login form\nDone."));
assert_eq!(title_source(&[user("u1", None, "hi")]), None);
let long = "x".repeat(TITLE_SOURCE_CHARS + 5);
assert_eq!(title_source(&[user("u1", None, &long)]).map(|t| t.chars().count()), Some(TITLE_SOURCE_CHARS));
}
#[test]
fn project_directory_names() {
assert_eq!(escape(Path::new("/private/tmp/a.b/c_d")), "-private-tmp-a-b-c-d");
+57 -10
Переглянути файл
@@ -191,6 +191,8 @@ struct State {
limit_resets_ms: Option<f64>,
terminal_only: Vec<String>,
titled: bool,
/// A title is being read or generated.
titling: bool,
}
struct Turn {
@@ -365,6 +367,7 @@ impl Shim {
limit_resets_ms: None,
terminal_only: Vec::new(),
titled: false,
titling: false,
}),
cwd,
starting: tokio::sync::Mutex::new(()),
@@ -583,11 +586,12 @@ impl Shim {
impl super::Shim for Shim {
fn attach(&self, client: Peer) {
let _ = self.client.set(client.clone());
// The client went away: so do the processes.
let weak = self.me.clone();
// The client went away: so do the processes. Held until then: the connection drops
// its handler (this shim) as it closes, and the sessions would go without stopping.
let me = self.me.upgrade();
tokio::spawn(async move {
client.closed().await;
if let Some(me) = weak.upgrade() {
if let Some(me) = me {
me.closed.store(true, Ordering::Release);
let sessions: Vec<Arc<Session>> = me.sessions.lock().drain().map(|(_, s)| s).collect();
for s in sessions {
@@ -598,6 +602,15 @@ impl super::Shim for Shim {
}
}
impl Drop for Session {
/// A session dropped without being stopped (the shim went away) takes its process along.
fn drop(&mut self) {
if let Some(p) = self.st.get_mut().proc.take() {
p.kill.cancel();
}
}
}
impl Session {
fn shim(&self) -> Option<Arc<Shim>> {
self.shim.upgrade()
@@ -1048,7 +1061,8 @@ impl Session {
} else if matches!(st.start, Start::New { .. }) {
st.start = Start::Resume { id: self.id.clone(), at: None };
}
read_title = !st.titled;
read_title = !st.titled && !st.titling;
st.titling |= read_title;
if let Some(turn) = st.turn.as_ref()
&& belongs(turn, &msg)
{
@@ -1073,13 +1087,29 @@ impl Session {
}
}
/// Sends the title Claude Code gave the session, once it has one.
/// Sends the session's title once it has one: the one in its transcript, or else one
/// Claude Code is asked for, as it titles no session run the way the Agent SDK runs it.
/// Without a title the next turn tries again.
fn read_title(self: &Arc<Self>) {
let me = self.clone();
tokio::task::spawn_blocking(move || {
let Some(title) = chain_title(&me) else { return };
me.st.lock().titled = true;
if let Some(shim) = me.shim() {
tokio::spawn(async move {
let reader = me.clone();
let entries = tokio::task::spawn_blocking(move || transcript_entries(&reader)).await.ok().flatten();
let mut title = entries.as_deref().and_then(history::title);
if title.is_none()
&& let Some(source) = entries.as_deref().and_then(history::title_source)
{
title = me.generate_title(source).await;
}
{
let mut st = me.st.lock();
st.titling = false;
if title.is_none() {
return;
}
st.titled = true;
}
if let (Some(title), Some(shim)) = (title, me.shim()) {
shim.notify(vec![Update {
session: me.id.clone(),
update: json!({ "sessionUpdate": "session_info_update", "title": title }),
@@ -1088,6 +1118,19 @@ impl Session {
});
}
/// A title Claude Code generates from `source`, also written to the transcript.
async fn generate_title(&self, source: String) -> Option<String> {
let proc = self.st.lock().proc.clone()?;
let request = json!({ "subtype": "generate_session_title", "description": source, "persist": true });
match proc.control(request).await {
Ok(r) => r.get("title").and_then(Value::as_str).map(str::trim).filter(|t| !t.is_empty()).map(str::to_owned),
Err(e) => {
tracing::debug!("generating a claude session title: {e}");
None
}
}
}
async fn on_cli_request(self: &Arc<Self>, proc: &Arc<Proc>, msg: Value) {
let id = msg.get("request_id").and_then(Value::as_str).unwrap_or_default().to_owned();
let request = msg.get("request").cloned().unwrap_or_default();
@@ -1417,9 +1460,13 @@ fn strings(v: Option<&Value>) -> Vec<String> {
/// The title in a session's transcript (or that of the session it continues).
fn chain_title(session: &Session) -> Option<String> {
history::title(&transcript_entries(session)?)
}
fn transcript_entries(session: &Session) -> Option<Vec<Value>> {
let shim = session.shim()?;
let path = shim.transcript(&session.cwd, &session.id).filter(|p| p.exists())?;
history::title(&history::read(&path).ok()?)
history::read(&path).ok()
}
fn start_json(start: &Start) -> Value {
+76 -4
Переглянути файл
@@ -311,6 +311,8 @@ struct Replay {
sent: Arc<Mutex<Vec<Vec<Value>>>>,
launched: Mutex<Vec<Vec<String>>>,
errors: Arc<Mutex<Vec<String>>>,
/// Each process's kill token.
kills: Mutex<Vec<CancellationToken>>,
}
impl Cli for Replay {
@@ -331,6 +333,7 @@ impl Cli for Replay {
let (shim_in, cli_in) = tokio::io::duplex(1 << 20);
let (mut cli_out, shim_out) = tokio::io::duplex(1 << 20);
let kill = CancellationToken::new();
self.kills.lock().push(kill.clone());
let (sent, errors, token) = (self.sent.clone(), self.errors.clone(), kill.clone());
sent.lock().push(Vec::new());
tokio::spawn(async move {
@@ -421,14 +424,18 @@ impl Replayed {
}
}
async fn replay(name: &str) -> Replayed {
let processes = load_fixture(name);
let cli = Arc::new(Replay {
fn replay_cli(processes: Vec<Vec<Frame>>) -> Arc<Replay> {
Arc::new(Replay {
processes: Mutex::new(processes.into()),
sent: Arc::default(),
launched: Mutex::default(),
errors: Arc::default(),
});
kills: Mutex::default(),
})
}
async fn replay(name: &str) -> Replayed {
let cli = replay_cli(load_fixture(name));
let (config, cwd) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
let cwd_path = std::fs::canonicalize(cwd.path()).unwrap();
let h =
@@ -461,6 +468,69 @@ async fn replay_simple() {
assert_eq!(user["message"]["content"][0]["text"], "Reply with exactly: hello there");
}
/// The app closing the connection (an idle task released, archived or deleted) stops the
/// CLI processes of its sessions, idle ones included.
#[tokio::test(flavor = "multi_thread")]
async fn closing_the_connection_stops_the_processes() {
let r = replay("simple").await;
assert!(r.cli.kills.lock().iter().all(|k| !k.is_cancelled()));
r.h.client.kill();
let kills = r.cli.kills.lock().clone();
tokio::time::timeout(Duration::from_secs(5), async {
for k in kills {
k.cancelled().await;
}
})
.await
.expect("every process was stopped");
}
#[tokio::test(flavor = "multi_thread")]
async fn sessions_without_a_title_get_one_generated() {
let mut processes = load_fixture("simple");
processes[0].extend([
Frame::Shim(json!({"type": "control_request", "request_id": "req_3",
"request": {"subtype": "generate_session_title"}})),
Frame::Cli(json!({"type": "control_response",
"response": {"subtype": "success", "request_id": "req_3", "response": {"title": "Say hello"}}})),
]);
let cli = replay_cli(processes);
let (config, cwd) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
let cwd_path = std::fs::canonicalize(cwd.path()).unwrap();
let h = Harness::connect(
cli.clone(),
config.path().into(),
config.path().join("pending"),
cwd_path.clone(),
Vec::new(),
)
.await;
let session = h.new_session().await;
// The transcript Claude Code writes, without the title it gives sessions in a terminal.
let dir = config.path().join("projects").join(history::escape(&cwd_path));
std::fs::create_dir_all(&dir).unwrap();
let prompt = json!({"type": "user", "uuid": "u1", "parentUuid": null,
"message": {"role": "user", "content": "Reply with exactly: hello there"}});
std::fs::write(dir.join(format!("{session}.jsonl")), format!("{prompt}\n")).unwrap();
h.prompt(&session, "Reply with exactly: hello there").await.unwrap();
let mut titles = Vec::new();
for _ in 0..100 {
titles = h.updates(&session, "session_info_update");
if !titles.is_empty() {
break;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
assert!(cli.errors.lock().is_empty(), "{:?}", cli.errors.lock());
assert_eq!(titles.iter().map(|t| t["title"].clone()).collect::<Vec<_>>(), [json!("Say hello")]);
let sent = cli.sent.lock()[0].clone();
let request =
sent.iter().find(|m| m.pointer("/request/subtype") == Some(&json!("generate_session_title"))).unwrap();
assert_eq!(request["request"]["description"], "Reply with exactly: hello there");
assert_eq!(request["request"]["persist"], true);
}
#[tokio::test(flavor = "multi_thread")]
async fn replay_multi_turn() {
let r = replay("multi_turn").await;
@@ -727,6 +797,7 @@ async fn a_stop_ahead_of_its_prompt_cancels_it() {
sent: Arc::default(),
launched: Mutex::default(),
errors: Arc::default(),
kills: Mutex::default(),
});
let (dir, cwd) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
let h = Harness::connect(cli.clone(), dir.path().into(), dir.path().join("p"), cwd.path().into(), Vec::new()).await;
@@ -770,6 +841,7 @@ async fn a_failed_turn_reports_its_failure() {
sent: Arc::default(),
launched: Mutex::default(),
errors: Arc::default(),
kills: Mutex::default(),
});
let (dir, cwd) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
let h =
+51 -11
Переглянути файл
@@ -28,6 +28,9 @@ const OUTPUT_EVERY: Duration = Duration::from_millis(200);
const EARLY_CANCEL: Duration = Duration::from_secs(1);
/// How long the client's connection outlives codex.
const EXIT_GRACE: Duration = Duration::from_millis(100);
/// What a thread's name is asked for with, after its first turn.
const NAME_PROMPT: &str = "Name this conversation for a list of tasks: a short title of at most six words, in the \
language the user writes in. Reply with the title only, without quotes or a closing period.";
pub(crate) fn connect(
spec: &ProviderSpec,
@@ -126,6 +129,10 @@ struct Thread {
early_cancel: Option<Instant>,
/// The latest agent message (a subagent's answer).
answer: String,
/// The thread has a name.
named: bool,
/// A name is being asked for.
naming: bool,
}
struct PendingPrompt {
@@ -329,6 +336,7 @@ impl CodexShim {
t.parent = None;
t.settings = Some(settings);
t.cwd = cwd;
t.named = thread.get("name").and_then(Value::as_str).is_some_and(|n| !n.trim().is_empty());
(id, options)
}
@@ -625,8 +633,9 @@ impl CodexShim {
Some(translate::plan(params))
}),
"thread/tokenUsage/updated" => self.with_thread(thread, |_, _| translate::usage(params)),
"thread/name/updated" => self.with_thread(thread, |_, _| {
"thread/name/updated" => self.with_thread(thread, |t, _| {
let title = params.get("threadName").and_then(Value::as_str)?;
t.named = true;
Some(json!({ "sessionUpdate": "session_info_update", "title": title }))
}),
"thread/started" => self.thread_started(params.get("thread").unwrap_or(&Value::Null)),
@@ -812,9 +821,10 @@ impl CodexShim {
}
}
fn turn_completed(&self, thread: &str, turn: &Value) {
fn turn_completed(self: &Arc<Self>, thread: &str, turn: &Value) {
let id = turn.get("id").and_then(Value::as_str).unwrap_or_default();
let (parent, answer) = {
let completed = turn.get("status").and_then(Value::as_str) == Some("completed");
let (parent, answer, name) = {
let mut state = self.state.lock();
let Some(t) = state.threads.get_mut(thread) else { return };
if t.turn.as_deref().is_none_or(|r| r == id) {
@@ -830,8 +840,15 @@ impl CodexShim {
let _ = p.done.send((turn.clone(), error.clone()));
}
let answer = std::mem::take(&mut t.answer);
(t.parent.clone(), (answer, error))
let name =
completed && !id.is_empty() && t.parent.is_none() && t.settings.is_some() && !t.named && !t.naming;
t.naming |= name;
(t.parent.clone(), (answer, error), name)
};
if name {
let (shim, thread, turn) = (self.clone(), thread.to_owned(), id.to_owned());
self.runtime.spawn(async move { shim.name_thread(&thread, &turn).await });
}
let Some(parent) = parent else { return };
let (answer, error) = answer;
let mut state_of = match turn.get("status").and_then(Value::as_str) {
@@ -853,6 +870,28 @@ impl CodexShim {
}
}
/// Names a thread from what it holds after `turn`, as codex leaves naming to its
/// clients; `thread/name/updated` brings the name back. Unnamed, the next turn tries again.
async fn name_thread(&self, thread: &str, turn: &str) {
let sample = json!({ "threadId": thread, "expectedTurnId": turn, "prompt": NAME_PROMPT, "effort": "low" });
let named = match self.call("thread/sample", sample).await {
Ok(r) => match r.get("text").and_then(Value::as_str).and_then(translate::thread_name) {
Some(name) => {
self.call("thread/name/set", json!({ "threadId": thread, "name": name })).await.map(|_| ())
}
None => Err(error(codes::INTERNAL_ERROR, "codex gave no name")),
},
Err(e) => Err(e),
};
if let Err(e) = &named {
tracing::debug!("naming codex thread {thread}: {}", e.message);
}
if let Some(t) = self.state.lock().threads.get_mut(thread) {
t.naming = false;
t.named |= named.is_ok();
}
}
/// Requests from codex: approvals and questions, asked as ACP permission requests.
fn on_codex_request(&self, method: &str, params: &Value) -> Result<Value, (i64, String)> {
let thread = params.get("threadId").and_then(Value::as_str).unwrap_or_default().to_owned();
@@ -869,13 +908,14 @@ impl CodexShim {
"item/commandExecution/requestApproval" => {
let choices = translate::decisions(params, true);
let title = translate::command_approval_title(params, item.as_ref());
let call = tool_call_of(
&item_id,
&title,
"execute",
reason,
Some(json!({ "command": params.get("command"), "cwd": params.get("cwd") })),
);
let mut input = json!({
"command": translate::approval_command(params, item.as_ref()),
"cwd": params.get("cwd"),
});
if let Some(network) = params.get("networkApprovalContext").filter(|c| !c.is_null()) {
input["networkApprovalContext"] = network.clone();
}
let call = tool_call_of(&item_id, &title, "execute", reason, Some(input));
Ok(self.ask(&thread, call, &choices, decline))
}
"item/fileChange/requestApproval" => {
+50 -5
Переглянути файл
@@ -535,8 +535,10 @@ async fn play<R: AsyncRead + Unpin, W: AsyncWrite + Unpin>(
.unwrap()
.unwrap_or_else(|| panic!("the shim closed before {frame}"));
let actual: Value = serde_json::from_str(&line).unwrap();
// Older recordings predate skill discovery; play an unsupported server.
if actual["method"] == "skills/list" && frame["method"] != "skills/list" {
// Older recordings predate skill discovery and thread naming; play an
// unsupported server.
let method = actual["method"].as_str().unwrap_or_default();
if matches!(method, "skills/list" | "thread/sample") && frame["method"] != method {
let reply = json!({ "id": actual["id"], "error": { "code": -32601, "message": "unsupported" } });
write.write_all(format!("{reply}\n").as_bytes()).await.unwrap();
continue;
@@ -574,7 +576,14 @@ async fn play<R: AsyncRead + Unpin, W: AsyncWrite + Unpin>(
/// Runs `scenario` against its transcript; returns the ACP results, the client and
/// the frames the shim sent.
async fn replay(scenario: Scenario) -> (Results, Arc<Client>, Vec<Value>, Value) {
let t = load(scenario.file());
let (results, client, sent, meta, _codex) = replay_transcript(scenario, load(scenario.file())).await;
(results, client, sent, meta)
}
/// Both connections of a replay, which stay open while it is held.
type CodexEnd = Box<dyn std::any::Any>;
async fn replay_transcript(scenario: Scenario, t: Transcript) -> (Results, Arc<Client>, Vec<Value>, Value, CodexEnd) {
let (ours, theirs) = tokio::io::duplex(1 << 20);
let (tr, tw) = tokio::io::split(theirs);
let client = Arc::new(Client::default());
@@ -590,8 +599,8 @@ async fn replay(scenario: Scenario) -> (Results, Arc<Client>, Vec<Value>, Value)
.await
.expect("the scenario finished");
// Codex stays up until the scenario is over.
let (sent, _read, _write) = player.await.unwrap();
(results, client, sent, meta)
let (sent, read, write) = player.await.unwrap();
(results, client, sent, meta, Box::new((acp, read, write)))
}
fn sent<'a>(frames: &'a [Value], method: &str) -> Vec<&'a Value> {
@@ -801,6 +810,42 @@ async fn a_simple_turn_streams_reasoning_text_and_usage() {
assert_eq!(info["title"], "Say hi");
}
#[tokio::test(flavor = "multi_thread")]
async fn an_unnamed_thread_is_named_after_its_first_turn() {
let mut t = load(Scenario::Simple.file());
t.frames.retain(|(_, f)| f["method"] != "thread/name/updated");
let thread = "01a18300-0000-7000-8000-000000000001";
t.frames.extend([
(true, json!({"id": 90, "method": "thread/sample"})),
(false, json!({"id": 90, "result": {"responseId": "r", "sourceTurnId": thread, "text": "\"Say hi.\"\n"}})),
(true, json!({"id": 91, "method": "thread/name/set"})),
(false, json!({"id": 91, "result": {}})),
(false, json!({"method": "thread/name/updated", "params": {"threadId": thread, "threadName": "Say hi"}})),
]);
let (results, client, frames, _, _codex) = replay_transcript(Scenario::Simple, t).await;
let session = session_of(&results, "new");
let sample = &sent(&frames, "thread/sample")[0]["params"];
assert_eq!((sample["threadId"].as_str(), sample["expectedTurnId"].as_str()), (Some(thread), Some(thread)));
assert_eq!(sent(&frames, "thread/name/set")[0]["params"], json!({"threadId": thread, "name": "Say hi"}));
let mut info = Vec::new();
for _ in 0..100 {
info = client.of(&session).into_iter().filter(|u| u["sessionUpdate"] == "session_info_update").collect();
if !info.is_empty() {
break;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
assert_eq!(info.iter().map(|u| u["title"].clone()).collect::<Vec<_>>(), [json!("Say hi")]);
}
#[test]
fn thread_names_are_the_first_line_of_the_reply() {
assert_eq!(thread_name(" \n“修复登录表单。”\nmore").as_deref(), Some("修复登录表单"));
assert_eq!(thread_name("**Fix the login form.**").as_deref(), Some("Fix the login form"));
assert_eq!(thread_name(" \n\"\" "), None);
assert_eq!(thread_name(&"x".repeat(100)).map(|n| n.chars().count()), Some(80));
}
#[tokio::test(flavor = "multi_thread")]
async fn settings_apply_to_the_next_turn() {
let (results, client, frames, _) = replay(Scenario::MultiTurn).await;
+22 -8
Переглянути файл
@@ -612,6 +612,16 @@ pub(super) fn usage(params: &Value) -> Option<Value> {
Some(json!({ "sessionUpdate": "usage_update", "used": used, "size": size }))
}
/// A thread name from the model's reply to the naming prompt: its first line, without
/// quotes or a closing period. `None` when nothing is left.
pub(super) fn thread_name(reply: &str) -> Option<String> {
const MAX_NAME: usize = 80;
let line = reply.lines().map(str::trim).find(|l| !l.is_empty())?;
let line = line.trim_matches(|c: char| matches!(c, '"' | '\'' | '`' | '“' | '”' | '「' | '」' | '*'));
let line = line.trim_end_matches(['.', '。']).trim();
(!line.is_empty()).then(|| line.chars().take(MAX_NAME).collect())
}
/// How a failed turn is reported.
#[derive(Debug, PartialEq)]
pub(super) enum Failure {
@@ -757,19 +767,23 @@ pub(super) fn answer_of(choices: &[Choice], outcome: &Value, decline: Value) ->
choices.iter().find(|c| selected && c.option["optionId"].as_str() == chosen).map_or(decline, |c| c.answer.clone())
}
/// The command a command approval is about: the request's own, else its item's.
pub(super) fn approval_command<'a>(params: &'a Value, item: Option<&'a Value>) -> Option<&'a str> {
// Approval payloads can describe a different command from the item's earlier summary.
let item = item.filter(|i| i.get("id") == params.get("itemId"));
params
.get("command")
.and_then(Value::as_str)
.filter(|c| !c.is_empty())
.or_else(|| item.and_then(|i| i.get("command")).and_then(Value::as_str).filter(|c| !c.is_empty()))
}
/// The ACP title of a command approval.
pub(super) fn command_approval_title(params: &Value, item: Option<&Value>) -> String {
if let Some(host) = params.pointer("/networkApprovalContext/host").and_then(Value::as_str) {
return format!("Allow network access to {host}");
}
// Approval payloads can describe a different command from the item's earlier summary.
let item = item.filter(|i| i.get("id") == params.get("itemId"));
let command = params
.get("command")
.and_then(Value::as_str)
.filter(|c| !c.is_empty())
.or_else(|| item.and_then(|i| i.get("command")).and_then(Value::as_str).filter(|c| !c.is_empty()));
match command {
match approval_command(params, item) {
Some(command) => format!("Run {}", crate::shell_script(command).unwrap_or_else(|| command.to_owned())),
None => "Run a command".to_owned(),
}
+219 -68
Переглянути файл
@@ -2,7 +2,7 @@
//! `<config>/agents/<id>.toml` (written by hand, or by installing one from the ACP
//! Registry, see `registry`).
use crate::{AcpImportOutcome, AgentCommandReadOnlyReason, AgentProvider, AgentSource, CustomAgentInput};
use crate::{AcpImportOutcome, AgentCommandReadOnlyReason, AgentProvider, AgentSource, CustomAgentInput, EnvSecrets};
use ide_core::{IdeError, IdeResult};
use serde::{Deserialize, Serialize};
use serde_json::Value;
@@ -231,23 +231,40 @@ pub(crate) fn describe(spec: &ProviderSpec) -> AgentProvider {
/// Overrides built-in `id`'s command through its agent file, preserving other
/// customizations. `None` restores the built-in command and removes a redundant file.
pub(crate) fn set_builtin_command(custom_dir: &Path, id: &str, command: Option<&str>) -> IdeResult<()> {
update_builtin(custom_dir, id, |agent, builtin| agent.command = command.unwrap_or(&builtin.command).to_owned())
pub(crate) fn set_builtin_command(
custom_dir: &Path,
secrets: Option<&dyn EnvSecrets>,
id: &str,
command: Option<&str>,
) -> IdeResult<()> {
update_builtin(custom_dir, secrets, id, |agent, builtin| {
agent.command = command.unwrap_or(&builtin.command).to_owned()
})
}
/// The environment built-in `id` starts with, through its agent file (empty clears it).
pub(crate) fn set_builtin_env(custom_dir: &Path, id: &str, env: BTreeMap<String, String>) -> IdeResult<()> {
update_builtin(custom_dir, id, |agent, _| agent.env = env)
pub(crate) fn set_builtin_env(
custom_dir: &Path,
secrets: Option<&dyn EnvSecrets>,
id: &str,
env: BTreeMap<String, String>,
) -> IdeResult<()> {
update_builtin(custom_dir, secrets, id, |agent, _| agent.env = env)
}
/// Changes built-in `id`'s agent file; a file left with only the built-in's values is removed.
fn update_builtin(custom_dir: &Path, id: &str, change: impl FnOnce(&mut CustomAgent, &ProviderSpec)) -> IdeResult<()> {
fn update_builtin(
custom_dir: &Path,
secrets: Option<&dyn EnvSecrets>,
id: &str,
change: impl FnOnce(&mut CustomAgent, &ProviderSpec),
) -> IdeResult<()> {
let Some(builtin) = builtin_spec(id) else {
return Err(IdeError::invalid(format!("{id} is not a built-in agent")));
};
let file = custom_dir.join(format!("{id}.toml"));
let mut agent = if file.try_exists()? {
CustomAgent::read(&file).map_err(IdeError::invalid)?
CustomAgent::read_with(&file, id, secrets).map_err(IdeError::invalid)?
} else {
CustomAgent {
name: Some(builtin.name.clone()),
@@ -272,16 +289,20 @@ fn update_builtin(custom_dir: &Path, id: &str, change: impl FnOnce(&mut CustomAg
Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e.into()),
_ => {}
}
forget_env(secrets, id, &[]);
return Ok(());
}
agent.write(&file)?;
Ok(())
agent.write_with(&file, id, secrets)
}
/// Saves a new custom agent; refuses built-in and ACP Registry ids, taken ids
/// and an empty command. The id is also the agent file's name, so it must be
/// a safe file stem.
pub(crate) fn save_custom(custom_dir: &Path, input: &CustomAgentInput) -> IdeResult<()> {
pub(crate) fn save_custom(
custom_dir: &Path,
secrets: Option<&dyn EnvSecrets>,
input: &CustomAgentInput,
) -> IdeResult<()> {
let id = validated_custom_id(&input.id)?;
if builtin_spec(&id).is_some() {
return Err(IdeError::invalid(format!("{id} is a built-in agent; change its command instead")));
@@ -306,12 +327,11 @@ pub(crate) fn save_custom(custom_dir: &Path, input: &CustomAgentInput) -> IdeRes
env: input.env.clone(),
registry: None,
}
.write(&file)?;
Ok(())
.write_with(&file, &id, secrets)
}
/// Removes a custom agent's file; refuses built-in, ACP Registry and missing ids.
pub(crate) fn remove_custom(custom_dir: &Path, id: &str) -> IdeResult<()> {
pub(crate) fn remove_custom(custom_dir: &Path, secrets: Option<&dyn EnvSecrets>, id: &str) -> IdeResult<()> {
let id = validated_custom_id(id)?;
let id = id.as_str();
if builtin_spec(id).is_some() {
@@ -325,14 +345,21 @@ pub(crate) fn remove_custom(custom_dir: &Path, id: &str) -> IdeResult<()> {
}
match std::fs::remove_file(&file) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e.into()),
_ => Ok(()),
_ => {
forget_env(secrets, id, &[]);
Ok(())
}
}
}
/// Imports a JetBrains `acp.json` file's `agent_servers` entries as custom
/// agents, one outcome per entry; entries that collide, lack a command or
/// have no usable name are skipped with a reason.
pub(crate) fn import_acp(custom_dir: &Path, text: &str) -> IdeResult<Vec<AcpImportOutcome>> {
pub(crate) fn import_acp(
custom_dir: &Path,
secrets: Option<&dyn EnvSecrets>,
text: &str,
) -> IdeResult<Vec<AcpImportOutcome>> {
#[derive(Deserialize)]
struct AcpJson {
#[serde(default)]
@@ -370,7 +397,7 @@ pub(crate) fn import_acp(custom_dir: &Path, text: &str) -> IdeResult<Vec<AcpImpo
env: server.env,
registry: None,
}
.write(&custom_dir.join(format!("{id}.toml")))?;
.write_with(&custom_dir.join(format!("{id}.toml")), &id, secrets)?;
outcome.saved = true;
}
outcomes.push(outcome);
@@ -400,7 +427,7 @@ fn slugify_id(name: &str) -> String {
}
/// An agent file, `<config>/agents/<id>.toml`.
#[derive(Debug, PartialEq, Serialize, Deserialize)]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct CustomAgent {
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -447,11 +474,67 @@ impl CustomAgent {
std::fs::write(&tmp, text)?;
std::fs::rename(&tmp, path)
}
/// Reads agent `id`'s file with its environment values filled in from `secrets`; values
/// still written in the file (by hand, by an older version) move there first. A registry
/// install keeps the environment its registry entry gives it in the file.
pub(crate) fn read_with(path: &Path, id: &str, secrets: Option<&dyn EnvSecrets>) -> Result<Self, String> {
let mut agent = Self::read(path)?;
let Some(secrets) = secrets.filter(|_| agent.registry.is_none()) else {
return Ok(agent);
};
let written = agent.env.values().any(|v| !v.is_empty());
for (name, value) in agent.env.iter_mut() {
if value.is_empty()
&& let Some(saved) = secrets.get(&env_key(id, name))
{
*value = saved;
}
}
if written && let Err(e) = agent.write_with(path, id, Some(secrets)) {
tracing::warn!("agent {id}: could not move environment values out of {}: {}", path.display(), e.message);
}
Ok(agent)
}
/// Writes agent `id`'s file with the environment's names only, its values saved in `secrets`.
pub(crate) fn write_with(&self, path: &Path, id: &str, secrets: Option<&dyn EnvSecrets>) -> IdeResult<()> {
let Some(secrets) = secrets.filter(|_| self.registry.is_none()) else {
return Ok(self.write(path)?);
};
let mut keys = Vec::with_capacity(self.env.len());
for (name, value) in &self.env {
let key = env_key(id, name);
if value.is_empty() {
secrets.delete(&key);
} else if secrets.get(&key).as_deref() != Some(value.as_str()) {
secrets.set(&key, value)?;
}
keys.push(key);
}
forget_env(Some(secrets), id, &keys);
let names = self.env.keys().map(|name| (name.clone(), String::new())).collect();
Ok(Self { env: names, ..self.clone() }.write(path)?)
}
}
fn env_key(id: &str, name: &str) -> String {
format!("agent/{id}/env/{}", name.replace('%', "%25").replace('/', "%2F"))
}
/// Forgets agent `id`'s saved environment values, except `keep`.
fn forget_env(secrets: Option<&dyn EnvSecrets>, id: &str, keep: &[String]) {
let Some(secrets) = secrets else { return };
for key in secrets.keys(&format!("agent/{id}/env/")) {
if !keep.contains(&key) {
secrets.delete(&key);
}
}
}
/// Built-in agents, then the `*.toml` agents of `custom_dir`; a custom agent with a
/// built-in id replaces it.
pub(crate) fn load(custom_dir: Option<&Path>) -> Vec<ProviderSpec> {
pub(crate) fn load(custom_dir: Option<&Path>, secrets: Option<&dyn EnvSecrets>) -> Vec<ProviderSpec> {
let mut specs = builtins();
let mut files: Vec<PathBuf> = custom_dir
.and_then(|d| std::fs::read_dir(d).ok())
@@ -464,7 +547,7 @@ pub(crate) fn load(custom_dir: Option<&Path>) -> Vec<ProviderSpec> {
files.sort();
for path in files {
let Some(id) = path.file_stem().and_then(|s| s.to_str()).map(str::to_owned) else { continue };
let agent = match CustomAgent::read(&path) {
let agent = match CustomAgent::read_with(&path, &id, secrets) {
Ok(a) => a,
Err(e) => {
tracing::warn!("skipping agent {}: {e}", path.display());
@@ -534,7 +617,7 @@ mod tests {
#[test]
fn qoder_global_and_cn_editions_are_both_built_in() {
let info = |id: &str| describe(&load(None).into_iter().find(|s| s.id == id).unwrap());
let info = |id: &str| describe(&load(None, None).into_iter().find(|s| s.id == id).unwrap());
let (global, cn) = (info("qoder"), info("qodercn"));
assert_eq!((global.name.as_str(), global.command.as_str()), ("Qoder", "qodercli --acp"));
assert_eq!((cn.name.as_str(), cn.command.as_str()), ("Qoder CN", "qoderclicn --acp"));
@@ -544,7 +627,7 @@ mod tests {
#[test]
fn qodercn_is_available_without_a_file_and_preserves_existing_customizations() {
let dir = tempfile::tempdir().unwrap();
let qodercn = load(None).into_iter().find(|s| s.id == "qodercn").unwrap();
let qodercn = load(None, None).into_iter().find(|s| s.id == "qodercn").unwrap();
assert_eq!(qodercn.driver, Driver::Acp);
assert!(!is_unlisted(&qodercn));
let info = describe(&qodercn);
@@ -559,13 +642,13 @@ mod tests {
"command = \"/custom/qoderclicn\"\nargs = [\"--acp\"]\n[env]\nK = \"v\"\n",
)
.unwrap();
let specs = load(Some(dir.path()));
let specs = load(Some(dir.path()), None);
assert_eq!(specs.iter().filter(|s| s.id == "qodercn").count(), 1);
let custom = specs.iter().find(|s| s.id == "qodercn").unwrap();
assert_eq!(custom.command, "/custom/qoderclicn");
assert_eq!(custom.env, [("K".into(), "v".into())]);
set_builtin_command(dir.path(), "qodercn", None).unwrap();
let reset = load(Some(dir.path())).into_iter().find(|s| s.id == "qodercn").unwrap();
set_builtin_command(dir.path(), None, "qodercn", None).unwrap();
let reset = load(Some(dir.path()), None).into_iter().find(|s| s.id == "qodercn").unwrap();
assert_eq!(reset.command, "qoderclicn");
assert_eq!(reset.args, ["--acp"]);
assert_eq!(reset.env, custom.env);
@@ -680,7 +763,7 @@ mod tests {
#[test]
fn launch_metadata_distinguishes_defaults_overrides_and_read_only_agents() {
let dir = tempfile::tempdir().unwrap();
let list = || load(Some(dir.path())).iter().map(describe).collect::<Vec<_>>();
let list = || load(Some(dir.path()), None).iter().map(describe).collect::<Vec<_>>();
let defaults = list();
let codex = defaults.iter().find(|p| p.id == "codex").unwrap();
assert_eq!(codex.program, "codex");
@@ -700,14 +783,14 @@ mod tests {
p.id.ends_with("-acp").then_some(AgentCommandReadOnlyReason::PackageRunner)
);
}
set_builtin_command(dir.path(), "codex", Some("/path with spaces/codex")).unwrap();
set_builtin_command(dir.path(), None, "codex", Some("/path with spaces/codex")).unwrap();
let overridden = list().into_iter().find(|p| p.id == "codex").unwrap();
assert_eq!(overridden.program, "/path with spaces/codex");
assert_eq!(overridden.args, codex.args);
assert_eq!(overridden.default_program, codex.default_program);
assert!(overridden.command_overridden && overridden.command_editable);
assert_eq!(overridden.source, AgentSource::Builtin);
set_builtin_command(dir.path(), "codex", Some("codex")).unwrap();
set_builtin_command(dir.path(), None, "codex", Some("codex")).unwrap();
assert!(!list().iter().find(|p| p.id == "codex").unwrap().command_overridden);
std::fs::write(
dir.path().join("gemini.toml"),
@@ -732,7 +815,7 @@ mod tests {
write("mine.toml", "name = \"Mine\"\ncommand = \"/opt/mine\"\nargs = [\"acp\"]\n[env]\nK = \"v\"\n");
write("gemini.toml", "command = \"gemini\"\nargs = [\"--acp\"]\n");
write("broken.toml", "nope = 1\n");
let specs = load(Some(dir.path()));
let specs = load(Some(dir.path()), None);
let mine = specs.iter().find(|s| s.id == "mine").unwrap();
assert_eq!((mine.name.as_str(), mine.command.as_str()), ("Mine", "/opt/mine"));
assert_eq!(mine.env, vec![("K".to_owned(), "v".to_owned())]);
@@ -748,34 +831,35 @@ mod tests {
fn set_builtin_env_writes_and_clears_the_environment() {
let dir = tempfile::tempdir().unwrap();
let env = BTreeMap::from([("CLAUDE_CODE_ENTRYPOINT".to_owned(), "openidea".to_owned())]);
set_builtin_env(dir.path(), "claude", env.clone()).unwrap();
let claude = load(Some(dir.path())).into_iter().find(|s| s.id == "claude").unwrap();
set_builtin_env(dir.path(), None, "claude", env.clone()).unwrap();
let claude = load(Some(dir.path()), None).into_iter().find(|s| s.id == "claude").unwrap();
assert_eq!(claude.env, vec![("CLAUDE_CODE_ENTRYPOINT".to_owned(), "openidea".to_owned())]);
assert_eq!(describe(&claude).env, env);
// The command override and the environment are kept independently.
set_builtin_command(dir.path(), "claude", Some("/opt/claude")).unwrap();
set_builtin_command(dir.path(), "claude", None).unwrap();
set_builtin_command(dir.path(), None, "claude", Some("/opt/claude")).unwrap();
set_builtin_command(dir.path(), None, "claude", None).unwrap();
assert!(dir.path().join("claude.toml").is_file());
set_builtin_env(dir.path(), "claude", BTreeMap::new()).unwrap();
set_builtin_env(dir.path(), None, "claude", BTreeMap::new()).unwrap();
assert!(!dir.path().join("claude.toml").exists());
set_builtin_env(dir.path(), "not-a-builtin", env).unwrap_err();
set_builtin_env(dir.path(), None, "not-a-builtin", env).unwrap_err();
}
#[test]
fn acp_adapters_of_native_agents_are_unlisted_until_customized() {
let dir = tempfile::tempdir().unwrap();
let unlisted = |dir: &Path| load(Some(dir)).into_iter().filter(is_unlisted).map(|s| s.id).collect::<Vec<_>>();
let unlisted =
|dir: &Path| load(Some(dir), None).into_iter().filter(is_unlisted).map(|s| s.id).collect::<Vec<_>>();
assert_eq!(unlisted(dir.path()), ["claude-acp", "codex-acp"]);
set_builtin_env(dir.path(), "codex-acp", BTreeMap::from([("A".to_owned(), "1".to_owned())])).unwrap();
set_builtin_env(dir.path(), None, "codex-acp", BTreeMap::from([("A".to_owned(), "1".to_owned())])).unwrap();
assert_eq!(unlisted(dir.path()), ["claude-acp"]);
}
#[test]
fn set_builtin_command_overrides_and_clears_a_builtin() {
let dir = tempfile::tempdir().unwrap();
set_builtin_command(dir.path(), "not-a-builtin", Some("/x")).unwrap_err();
set_builtin_command(dir.path(), "claude", Some("/opt/claude")).unwrap();
let claude = load(Some(dir.path())).into_iter().find(|s| s.id == "claude").unwrap();
set_builtin_command(dir.path(), None, "not-a-builtin", Some("/x")).unwrap_err();
set_builtin_command(dir.path(), None, "claude", Some("/opt/claude")).unwrap();
let claude = load(Some(dir.path()), None).into_iter().find(|s| s.id == "claude").unwrap();
assert_eq!((claude.command.as_str(), claude.driver), ("/opt/claude", Driver::Claude));
assert!(claude.args.is_empty());
// A registry install is neither overwritten nor removed by a command override.
@@ -790,14 +874,14 @@ mod tests {
}
.write(&path)
.unwrap();
set_builtin_command(dir.path(), "codex", Some("/x")).unwrap_err();
set_builtin_command(dir.path(), "codex", None).unwrap_err();
set_builtin_command(dir.path(), None, "codex", Some("/x")).unwrap_err();
set_builtin_command(dir.path(), None, "codex", None).unwrap_err();
assert!(path.exists());
// Clearing (twice) returns the built-in to auto-detection.
set_builtin_command(dir.path(), "claude", None).unwrap();
set_builtin_command(dir.path(), "claude", None).unwrap();
set_builtin_command(dir.path(), None, "claude", None).unwrap();
set_builtin_command(dir.path(), None, "claude", None).unwrap();
assert!(!dir.path().join("claude.toml").exists());
assert_eq!(load(Some(dir.path())).iter().find(|s| s.id == "claude").unwrap().command, "claude");
assert_eq!(load(Some(dir.path()), None).iter().find(|s| s.id == "claude").unwrap().command, "claude");
}
#[test]
@@ -825,11 +909,11 @@ mod tests {
}
agent.write(&path).unwrap();
set_builtin_command(dir.path(), "claude", Some("/new/claude")).unwrap();
set_builtin_command(dir.path(), None, "claude", Some("/new/claude")).unwrap();
agent.command = "/new/claude".into();
assert_eq!(CustomAgent::read(&path).unwrap(), agent, "{field}");
set_builtin_command(dir.path(), "claude", None).unwrap();
set_builtin_command(dir.path(), None, "claude", None).unwrap();
agent.command = "claude".into();
assert_eq!(CustomAgent::read(&path).unwrap(), agent, "{field}");
}
@@ -845,11 +929,11 @@ mod tests {
)
.unwrap();
set_builtin_command(dir.path(), "codex", None).unwrap();
set_builtin_command(dir.path(), None, "codex", None).unwrap();
assert!(!path.exists());
assert_eq!(
load(Some(dir.path())).into_iter().find(|s| s.id == "codex").unwrap(),
load(Some(dir.path()), None).into_iter().find(|s| s.id == "codex").unwrap(),
builtin_spec("codex").unwrap()
);
}
@@ -870,7 +954,7 @@ mod tests {
let text = std::fs::read_to_string(&path).unwrap();
assert!(text.contains("[registry]") && !text.contains("driver"), "{text}");
assert_eq!(CustomAgent::read(&path).unwrap(), agent);
let spec = load(Some(path.parent().unwrap())).into_iter().find(|s| s.id == "amp-acp").unwrap();
let spec = load(Some(path.parent().unwrap()), None).into_iter().find(|s| s.id == "amp-acp").unwrap();
assert_eq!(spec.source, AgentSource::Registry { version: "0.10.0".into(), update: None });
assert!(toml::from_str::<CustomAgent>("command = \"x\"\n[registry]\nid = \"x\"\n").is_err());
}
@@ -888,50 +972,117 @@ mod tests {
#[test]
fn custom_agents_save_list_and_remove() {
let dir = tempfile::tempdir().unwrap();
save_custom(dir.path(), &custom_input("mine")).unwrap();
let mine = load(Some(dir.path())).into_iter().find(|s| s.id == "mine").unwrap();
save_custom(dir.path(), None, &custom_input("mine")).unwrap();
let mine = load(Some(dir.path()), None).into_iter().find(|s| s.id == "mine").unwrap();
assert_eq!((mine.name.as_str(), mine.command.as_str()), ("Mine", "/opt/mine"));
assert_eq!(mine.env, vec![("K".to_owned(), "v".to_owned())]);
assert_eq!(mine.source, AgentSource::Custom);
remove_custom(dir.path(), "mine").unwrap();
assert!(!load(Some(dir.path())).iter().any(|s| s.id == "mine"));
remove_custom(dir.path(), "mine").unwrap_err();
remove_custom(dir.path(), None, "mine").unwrap();
assert!(!load(Some(dir.path()), None).iter().any(|s| s.id == "mine"));
remove_custom(dir.path(), None, "mine").unwrap_err();
}
#[test]
fn custom_agents_refuse_builtin_registry_and_bad_ids() {
let dir = tempfile::tempdir().unwrap();
save_custom(dir.path(), &custom_input("claude")).unwrap_err();
save_custom(dir.path(), &custom_input("../evil")).unwrap_err();
save_custom(dir.path(), &custom_input("")).unwrap_err();
save_custom(dir.path(), None, &custom_input("claude")).unwrap_err();
save_custom(dir.path(), None, &custom_input("../evil")).unwrap_err();
save_custom(dir.path(), None, &custom_input("")).unwrap_err();
let mut empty_command = custom_input("mine");
empty_command.command = " ".into();
save_custom(dir.path(), &empty_command).unwrap_err();
save_custom(dir.path(), &custom_input("mine")).unwrap();
save_custom(dir.path(), &custom_input("mine")).unwrap_err();
remove_custom(dir.path(), "claude").unwrap_err();
save_custom(dir.path(), None, &empty_command).unwrap_err();
save_custom(dir.path(), None, &custom_input("mine")).unwrap();
save_custom(dir.path(), None, &custom_input("mine")).unwrap_err();
remove_custom(dir.path(), None, "claude").unwrap_err();
// An id that isn't a plain file stem never reaches outside the agents directory.
let outside = dir.path().join("outside.toml");
std::fs::write(&outside, "command = \"/bin/sh\"\n").unwrap();
let agents = dir.path().join("agents");
std::fs::create_dir(&agents).unwrap();
remove_custom(&agents, "../outside").unwrap_err();
remove_custom(&agents, None, "../outside").unwrap_err();
assert!(outside.exists());
std::fs::write(
dir.path().join("amp-acp.toml"),
"command = \"/registry/amp\"\n[registry]\nid = \"amp-acp\"\nversion = \"1.0\"\n",
)
.unwrap();
save_custom(dir.path(), &custom_input("amp-acp")).unwrap_err();
remove_custom(dir.path(), "amp-acp").unwrap_err();
save_custom(dir.path(), None, &custom_input("amp-acp")).unwrap_err();
remove_custom(dir.path(), None, "amp-acp").unwrap_err();
}
#[derive(Default)]
struct MemorySecrets(parking_lot::Mutex<BTreeMap<String, String>>);
impl EnvSecrets for MemorySecrets {
fn get(&self, key: &str) -> Option<String> {
self.0.lock().get(key).cloned()
}
fn set(&self, key: &str, value: &str) -> IdeResult<()> {
self.0.lock().insert(key.to_owned(), value.to_owned());
Ok(())
}
fn delete(&self, key: &str) {
self.0.lock().remove(key);
}
fn keys(&self, prefix: &str) -> Vec<String> {
self.0.lock().keys().filter(|k| k.starts_with(prefix)).cloned().collect()
}
}
#[test]
fn environment_values_go_to_the_secrets_and_the_files_keep_the_names() {
let dir = tempfile::tempdir().unwrap();
let secrets = MemorySecrets::default();
let s = Some(&secrets as &dyn EnvSecrets);
let env = BTreeMap::from([("API_KEY".to_owned(), "v-91f".to_owned()), ("A/B".to_owned(), String::new())]);
set_builtin_env(dir.path(), s, "claude", env.clone()).unwrap();
save_custom(dir.path(), s, &CustomAgentInput { env: env.clone(), ..custom_input("mine") }).unwrap();
for id in ["claude", "mine"] {
let text = std::fs::read_to_string(dir.path().join(format!("{id}.toml"))).unwrap();
assert!(text.contains("API_KEY") && !text.contains("v-91f"), "{text}");
let spec = load(Some(dir.path()), s).into_iter().find(|p| p.id == id).unwrap();
assert_eq!(describe(&spec).env, env);
}
assert_eq!(secrets.keys("agent/"), ["agent/claude/env/API_KEY", "agent/mine/env/API_KEY"]);
set_builtin_command(dir.path(), s, "claude", Some("/opt/claude")).unwrap();
assert_eq!(secrets.get("agent/claude/env/API_KEY").as_deref(), Some("v-91f"));
set_builtin_env(dir.path(), s, "claude", BTreeMap::from([("OTHER".to_owned(), "x".to_owned())])).unwrap();
assert_eq!(secrets.keys("agent/claude/"), ["agent/claude/env/OTHER"]);
set_builtin_command(dir.path(), s, "claude", None).unwrap();
set_builtin_env(dir.path(), s, "claude", BTreeMap::new()).unwrap();
remove_custom(dir.path(), s, "mine").unwrap();
assert_eq!(secrets.keys("agent/"), Vec::<String>::new());
}
#[test]
fn values_written_in_agent_files_move_to_the_secrets_unless_installed_from_the_registry() {
let dir = tempfile::tempdir().unwrap();
let secrets = MemorySecrets::default();
let s = Some(&secrets as &dyn EnvSecrets);
std::fs::write(dir.path().join("mine.toml"), "command = \"/opt/mine\"\n[env]\nK = \"v-91f\"\nS = \"\"\n")
.unwrap();
secrets.set("agent/mine/env/S", "saved").unwrap();
let registry =
"command = \"/registry/amp\"\n[env]\nR = \"1\"\n[registry]\nid = \"amp-acp\"\nversion = \"1.0\"\n";
std::fs::write(dir.path().join("amp-acp.toml"), registry).unwrap();
let specs = load(Some(dir.path()), s);
assert_eq!(
specs.iter().find(|p| p.id == "mine").unwrap().env,
[("K".to_owned(), "v-91f".to_owned()), ("S".to_owned(), "saved".to_owned())]
);
assert!(!std::fs::read_to_string(dir.path().join("mine.toml")).unwrap().contains("v-91f"));
assert_eq!(secrets.keys("agent/"), ["agent/mine/env/K", "agent/mine/env/S"]);
assert_eq!(std::fs::read_to_string(dir.path().join("amp-acp.toml")).unwrap(), registry);
}
#[test]
fn acp_json_imports_servers_and_skips_collisions() {
let dir = tempfile::tempdir().unwrap();
save_custom(dir.path(), &custom_input("taken")).unwrap();
save_custom(dir.path(), None, &custom_input("taken")).unwrap();
let outcomes = import_acp(
dir.path(),
None,
r#"{"agent_servers": {
"My Agent!": {"command": "/opt/agent", "args": ["--acp"], "env": {"K": "v"}},
"taken": {"command": "/opt/taken"},
@@ -944,10 +1095,10 @@ mod tests {
let saved: Vec<&str> = outcomes.iter().filter(|o| o.saved).map(|o| o.id.as_str()).collect();
assert_eq!(saved, ["my-agent"]);
assert!(outcomes.iter().filter(|o| !o.saved).all(|o| !o.reason.is_empty()));
let mine = load(Some(dir.path())).into_iter().find(|s| s.id == "my-agent").unwrap();
let mine = load(Some(dir.path()), None).into_iter().find(|s| s.id == "my-agent").unwrap();
assert_eq!((mine.name.as_str(), mine.command.as_str()), ("My Agent!", "/opt/agent"));
assert_eq!(mine.source, AgentSource::Custom);
import_acp(dir.path(), "not json").unwrap_err();
assert!(import_acp(dir.path(), "{}").unwrap().is_empty());
import_acp(dir.path(), None, "not json").unwrap_err();
assert!(import_acp(dir.path(), None, "{}").unwrap().is_empty());
}
}
+4 -4
Переглянути файл
@@ -826,7 +826,7 @@ mod tests {
assert!(!shown.iter().any(|a| a.id == "cline"));
write_atomic(&p.cache_file, INDEX.as_bytes()).unwrap();
let mut providers: Vec<AgentProvider> = provider::load(Some(&p.agents_dir))
let mut providers: Vec<AgentProvider> = provider::load(Some(&p.agents_dir), None)
.into_iter()
.filter(|s| s.id == "amp-acp")
.map(|s| provider::describe(&s))
@@ -918,7 +918,7 @@ mod tests {
assert_eq!(agent.command, v1.to_string_lossy());
assert_eq!((agent.args.as_slice(), agent.env["A"].as_str()), (&["acp".to_owned()][..], "1"));
assert_eq!(agent.registry, Some(RegistryRef { id: "demo".into(), version: "1.0.0".into() }));
let spec = provider::load(Some(&p.agents_dir)).into_iter().find(|s| s.id == "demo").unwrap();
let spec = provider::load(Some(&p.agents_dir), None).into_iter().find(|s| s.id == "demo").unwrap();
assert_eq!(spec.source, AgentSource::Registry { version: "1.0.0".into(), update: None });
assert!(provider::installed(&spec.command));
@@ -991,7 +991,7 @@ mod tests {
assert_eq!((shown[0].installed.as_deref(), shown[0].update), (Some("1.0.104"), true));
write_atomic(&p.cache_file, index.as_bytes()).unwrap();
let spec = provider::load(Some(&p.agents_dir)).into_iter().find(|s| s.id == "copilot").unwrap();
let spec = provider::load(Some(&p.agents_dir), None).into_iter().find(|s| s.id == "copilot").unwrap();
assert_eq!(spec.name, "GitHub Copilot");
assert_eq!(spec.login_hint, "Run `copilot` in a terminal and sign in.");
assert_eq!(spec.source, AgentSource::Registry { version: "1.0.104".into(), update: None });
@@ -1004,7 +1004,7 @@ mod tests {
);
remove(&p, "github-copilot-cli").unwrap();
let fallback = provider::load(Some(&p.agents_dir)).into_iter().find(|s| s.id == "copilot").unwrap();
let fallback = provider::load(Some(&p.agents_dir), None).into_iter().find(|s| s.id == "copilot").unwrap();
assert_eq!(fallback.source, AgentSource::Builtin);
}
}
+1
Переглянути файл
@@ -24,6 +24,7 @@ fn paths(dir: &Path) -> AgentPaths {
models_file: dir.join("models.json"),
custom_agents_dir: None,
home_dir: None,
env_secrets: None,
}
}
+135 -22
Переглянути файл
@@ -13,29 +13,23 @@ pub enum PolicyDecision {
AskUser,
}
/// Extracts the shell command string from an ACP `session/request_permission` payload
/// when the request is for executing a command.
/// The shell command an ACP `session/request_permission` asks to run: only a tool call of kind
/// `execute`, and only its structured `rawInput` command. A title is free text the agent
/// writes, so it never names the command.
pub fn extract_command(params: &Value) -> Option<String> {
let tool = params.get("toolCall").or_else(|| params.pointer("/subject/toolCall"))?;
if tool.get("kind").and_then(Value::as_str) != Some("execute") {
return None;
}
let raw_cmd = tool.pointer("/rawInput/command").or_else(|| tool.pointer("/rawInput/cmd"));
if let Some(cmd) = raw_cmd.and_then(command_from_value) {
return Some(cmd);
}
let kind = tool.get("kind").and_then(Value::as_str).unwrap_or_default();
let name = tool.get("name").and_then(Value::as_str).unwrap_or_default();
let title =
tool.get("title").and_then(Value::as_str).or_else(|| params.get("title").and_then(Value::as_str))?.trim();
if let Some(cmd) = title.strip_prefix("Run ").map(str::trim).filter(|s| !s.is_empty()) {
return Some(cmd.to_owned());
}
if (kind == "execute" || matches!(name, "Bash" | "PowerShell" | "Execute" | "Run" | "terminal"))
&& !title.is_empty()
&& title != "Terminal"
&& title != "Run a command"
{
return Some(title.to_owned());
}
None
raw_cmd.and_then(command_from_value)
}
/// Whether allowing the request grants more than running its command, such as network access
/// for it: the command policy may block such a request but never approves it.
pub fn widens_access(params: &Value) -> bool {
let tool = params.get("toolCall").or_else(|| params.pointer("/subject/toolCall"));
tool.and_then(|t| t.pointer("/rawInput/networkApprovalContext")).is_some_and(|c| !c.is_null())
}
fn command_from_value(val: &Value) -> Option<String> {
@@ -298,6 +292,41 @@ fn split_subcommands(command: &str) -> Vec<&str> {
out
}
/// Whether every shell an agent may run `command` in (a POSIX shell, PowerShell, cmd.exe) reads
/// it as the one command the policy sees. Outside double quotes no separator or redirection may
/// remain after splitting (cmd.exe takes single quotes literally); `\` may only stand in a
/// path (it escapes only in POSIX shells); no `^` may escape a quote (cmd.exe); no `%` (cmd.exe
/// expands it before parsing); no carriage return; no typographic quotes (PowerShell ends
/// strings at them).
fn reads_alike_in_every_shell(command: &str) -> bool {
let chars: Vec<char> = command.chars().collect();
let mut in_double = false;
for (i, &c) in chars.iter().enumerate() {
let next = chars.get(i + 1).copied();
match c {
'"' => in_double = !in_double,
'\\' if !next.is_none_or(|n| n.is_ascii_alphanumeric() || matches!(n, '\\' | '.' | '_' | '-')) => {
return false;
}
'^' if matches!(next, Some('"' | '\'' | '\n' | '\r')) => return false,
'%' | '\r' | '\u{2018}'..='\u{201f}' => return false,
';' | '&' | '|' | '<' | '>' | '\n' if !in_double => return false,
_ => {}
}
}
true
}
/// A command as blocked patterns are compared with it: lower case, single spaces, and the
/// program without its folder or `.exe` (`/bin/rm` and `RM.EXE` are `rm`).
fn normalized_for_blocking(command: &str) -> String {
let mut words = command.split_whitespace().map(str::to_lowercase);
let Some(program) = words.next() else { return String::new() };
let program = program.rsplit(['/', '\\']).next().unwrap_or(&program);
let program = program.strip_suffix(".exe").unwrap_or(program);
std::iter::once(program.to_owned()).chain(words).collect::<Vec<_>>().join(" ")
}
/// Evaluates `command` against `settings`:
/// 1. Blacklist takes precedence: if the full command or any sub-command matches a
/// `blocked_commands` entry, returns [`PolicyDecision::Blocked`].
@@ -312,12 +341,18 @@ pub fn evaluate_command(command: &str, settings: &AgentSecuritySettings) -> Poli
}
let subcommands = split_subcommands(cmd);
let normalized: Vec<String> =
std::iter::once(cmd).chain(subcommands.iter().copied()).map(normalized_for_blocking).collect();
for pat in &settings.blocked_commands {
let pat = pat.trim();
if pat.is_empty() {
continue;
}
if matches_command(pat, cmd) || subcommands.iter().any(|sub| matches_command(pat, sub)) {
let normalized_pat = normalized_for_blocking(pat);
if matches_command(pat, cmd)
|| subcommands.iter().any(|sub| matches_command(pat, sub))
|| normalized.iter().any(|c| matches_command(&normalized_pat, c))
{
return PolicyDecision::Blocked(pat.to_owned());
}
}
@@ -328,7 +363,7 @@ pub fn evaluate_command(command: &str, settings: &AgentSecuritySettings) -> Poli
let all_allowed = subcommands.iter().all(|sub| {
let s = sub.trim();
if s.is_empty() || has_unsafe_shell_syntax(s) {
if s.is_empty() || has_unsafe_shell_syntax(s) || !reads_alike_in_every_shell(s) {
return false;
}
settings.allowed_commands.iter().any(|entry| matches_allow_entry(entry, s))
@@ -455,6 +490,58 @@ mod tests {
assert_eq!(evaluate_command("git status && rm -rf /", &settings), PolicyDecision::Blocked("rm -rf *".into()));
}
/// The command may run in PowerShell or cmd.exe as well as a POSIX shell: what any of them
/// would split or expand differently from the policy's reading is never auto-approved.
#[test]
fn what_another_shell_reads_as_two_commands_is_not_auto_approved() {
let settings = AgentSecuritySettings {
allowed_commands: vec!["git status".into(), "git commit".into()],
blocked_commands: vec![],
};
for cmd in [
// `\` escapes only in POSIX shells.
"git status \\; calc.exe",
"git status \\& calc.exe",
"git status \\| calc.exe",
"git status \\\\\\; calc.exe",
"git status \"\\\" ; calc.exe ; \\\"\"",
// cmd.exe: single quotes don't quote, `^` escapes a quote, `%` expands first.
"git status '&' calc.exe",
"git status ';' calc.exe",
"git status '>' out.txt",
"git status ^\" & calc.exe ^\"",
"git status %COMSPEC%",
// PowerShell also closes a string at a typographic quote.
"git status \"a \u{201d} ; calc.exe ; \u{201c} b\"",
"git status \u{2018}x\u{2019}",
// A carriage return ends the command line too.
"git status\r calc.exe",
] {
assert_eq!(evaluate_command(cmd, &settings), PolicyDecision::AskUser, "{cmd:?}");
}
// Text every shell reads alike stays approvable.
for cmd in [
"git status -s",
"git status && git status",
"git commit -m \"fix <bug> & (issue); done\"",
"git status src\\main.rs C:\\work\\repo",
"git status HEAD^",
] {
assert_eq!(evaluate_command(cmd, &settings), PolicyDecision::Allowed, "{cmd:?}");
}
}
#[test]
fn blocked_patterns_ignore_case_spacing_and_the_programs_folder() {
let settings = AgentSecuritySettings::default();
for cmd in
["rm -rf /", "RM -RF /", "/bin/rm -rf /", "Rm -Rf /tmp/x", "C:\\Windows\\System32\\shutdown.exe /s"]
{
assert!(matches!(evaluate_command(cmd, &settings), PolicyDecision::Blocked(_)), "{cmd:?}");
}
assert_eq!(evaluate_command("git status", &settings), PolicyDecision::AskUser);
}
#[test]
fn extracts_command_from_v1_v2_and_codex_payloads() {
let claude = json!({
@@ -484,4 +571,30 @@ mod tests {
});
assert_eq!(extract_command(&write), None);
}
/// Only a tool call of kind `execute` runs a command, and its command is in `rawInput`:
/// a title says what the agent wants it to say.
#[test]
fn a_request_is_a_command_only_by_its_kind_and_input() {
let escalation = json!({ "sessionId": "t", "toolCall": {
"toolCallId": "i1", "title": "Run git status", "kind": "other", "status": "pending",
"rawInput": { "fileSystem": { "write": ["C:\\"] }, "network": { "enabled": true } } },
"options": [ { "optionId": "turn", "name": "Allow for this turn", "kind": "allow_once" },
{ "optionId": "decline", "name": "Decline", "kind": "reject_once" } ] });
assert_eq!(extract_command(&escalation), None);
let titled_only = json!({ "toolCall": { "title": "Run git status", "kind": "execute" } });
assert_eq!(extract_command(&titled_only), None);
let named_only = json!({ "toolCall": { "title": "git status", "name": "Bash" } });
assert_eq!(extract_command(&named_only), None);
let mislabelled = json!({ "toolCall": { "title": "Run git status", "kind": "edit",
"rawInput": { "command": "git status" } } });
assert_eq!(extract_command(&mislabelled), None);
let network = json!({ "toolCall": { "title": "Allow network access to example.com", "kind": "execute",
"rawInput": { "command": "curl https://example.com", "networkApprovalContext": { "host": "example.com" } } } });
assert_eq!(extract_command(&network).as_deref(), Some("curl https://example.com"));
assert!(widens_access(&network));
let plain = json!({ "toolCall": { "title": "Run git status", "kind": "execute",
"rawInput": { "command": "git status" } } });
assert!(!widens_access(&plain));
}
}
+86 -2
Переглянути файл
@@ -264,6 +264,23 @@ impl Handler for FakeAgent {
self.say(&session, &format!("late chose {chosen}"));
return Ok(json!({"stopReason": "end_turn"}));
}
if text == "escalate" {
// A request for wider access, titled with text the model wrote.
let peer = self.latest();
let answer: Value = tokio::runtime::Handle::current()
.block_on(peer.request(
"session/request_permission",
json!({"sessionId": session, "toolCall": {"toolCallId": "e1", "title": "Run cargo test",
"kind": "other", "rawInput": {"network": {"enabled": true}}},
"options": [{"optionId": "yes", "name": "Allow for this turn", "kind": "allow_once"},
{"optionId": "no", "name": "Decline", "kind": "reject_once"}]}),
None,
))
.unwrap();
let chosen = answer.pointer("/outcome/optionId").and_then(Value::as_str).unwrap_or("none");
self.say(&session, &format!("escalation chose {chosen}"));
return Ok(json!({"stopReason": "end_turn"}));
}
if text == "ask" {
let peer = self.latest();
let answer: Value = tokio::runtime::Handle::current()
@@ -344,7 +361,8 @@ impl Handler for FakeAgent {
let answer: Value = tokio::runtime::Handle::current()
.block_on(peer.request(
"session/request_permission",
json!({"sessionId": session, "toolCall": {"toolCallId": "t1", "title": "Run cargo test"},
json!({"sessionId": session, "toolCall": {"toolCallId": "t1", "title": "Run cargo test",
"kind": "execute", "rawInput": {"command": "cargo test"}},
"options": [{"optionId": "yes", "name": "Allow", "kind": "allow_once"},
{"optionId": "no", "name": "Reject", "kind": "reject_once"}]}),
None,
@@ -366,7 +384,8 @@ fn manager(dir: &Path, agent: Arc<FakeAgent>) -> AgentManager {
fn manager_at(dir: &Path, models_file: &Path, agent: Arc<FakeAgent>) -> AgentManager {
// These tests speak to FakeAgent, never an installed CLI, including after a provider reload.
let agents = dir.join("agents");
provider::set_builtin_command(&agents, "claude", Some(&dir.join("missing-claude").to_string_lossy())).unwrap();
provider::set_builtin_command(&agents, None, "claude", Some(&dir.join("missing-claude").to_string_lossy()))
.unwrap();
let connector: Connector = Arc::new(move |_spec, _cwd, client, _stderr| {
let (a, b) = duplex(1 << 20);
let (ar, aw) = tokio::io::split(a);
@@ -385,6 +404,7 @@ fn manager_at(dir: &Path, models_file: &Path, agent: Arc<FakeAgent>) -> AgentMan
// The project is the boundary of the skill walk: nothing above it (such as the
// user's profile, when temp lives under it) leaks into the test.
home_dir: Some(dir.to_path_buf()),
env_secrets: None,
},
tokio::runtime::Handle::current(),
connector,
@@ -1410,6 +1430,7 @@ async fn real_claude_task_in_a_worktree() {
models_file: tmp.path().join("models.json"),
custom_agents_dir: None,
home_dir: None,
env_secrets: None,
},
tokio::runtime::Handle::current(),
);
@@ -2243,6 +2264,7 @@ async fn reload_providers_updates_registry_install_clears_stale_probes_and_notif
models_file: tmp.path().join("models.json"),
custom_agents_dir: Some(agents_dir.clone()),
home_dir: None,
env_secrets: None,
},
tokio::runtime::Handle::current(),
connector,
@@ -2369,6 +2391,27 @@ async fn idle_agents_stop_and_resume_their_session_on_the_next_message() {
assert!(live(&mgr, &task.id));
mgr.shutdown();
// Undoing turns or copying the conversation starts a stopped agent: it stops again too.
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent::default());
let mgr = manager(tmp.path(), agent.clone());
mgr.set_idle_release(Duration::from_millis(200));
let task = mgr.create(new()).await.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
mgr.prompt(&task.id, text("again"), false).await.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
tokio::time::sleep(Duration::from_millis(600)).await;
assert!(!live(&mgr, &task.id));
mgr.rollback(&task.id, 1).await.unwrap();
assert!(live(&mgr, &task.id));
tokio::time::sleep(Duration::from_millis(600)).await;
assert!(!live(&mgr, &task.id), "an agent started to undo turns stops too");
mgr.fork(&task.id, None).await.unwrap();
assert!(live(&mgr, &task.id));
tokio::time::sleep(Duration::from_millis(600)).await;
assert!(!live(&mgr, &task.id), "an agent started to copy the conversation stops too");
mgr.shutdown();
// An agent that can't reload its session keeps running: stopping it would lose the conversation.
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent { no_load: true, ..FakeAgent::default() });
@@ -2553,6 +2596,7 @@ async fn delete_running_agent(no_load: bool) {
models_file: root.join("models.json"),
custom_agents_dir: None,
home_dir: None,
env_secrets: None,
},
tokio::runtime::Handle::current(),
connector,
@@ -3187,3 +3231,43 @@ async fn security_policy_auto_approves_whitelist_and_rejects_blacklist() {
"{blocked_entries:?}"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn security_policy_never_auto_approves_a_request_that_only_reads_like_a_command() {
use ide_core::settings::AgentSecuritySettings;
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent::default());
let mgr = manager(tmp.path(), agent);
mgr.set_security_hook(Arc::new(|| AgentSecuritySettings {
allowed_commands: vec!["cargo test".into()],
blocked_commands: vec![],
}));
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("escalate"),
provider: "claude".into(),
config: vec![],
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
let entries = mgr.entries(&task.id, None).unwrap();
let pending = entries
.iter()
.find_map(|e| match e {
AgentEntry::Permission { id, chosen: None, .. } => Some(*id),
_ => None,
})
.unwrap_or_else(|| panic!("the request was answered without the user: {entries:?}"));
mgr.respond(&task.id, pending, Some("no".into())).unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
let entries = mgr.entries(&task.id, None).unwrap();
assert!(
entries.iter().any(|e| matches!(e, AgentEntry::Agent { text } if text == "escalation chose no")),
"{entries:?}"
);
}
+1
Переглянути файл
@@ -438,6 +438,7 @@ mod tests {
t.push(AgentEntry::Permission {
id: 1,
title: "Run?".into(),
command: None,
options: vec![],
questions: vec![],
chosen: None,
+226 -24
Переглянути файл
@@ -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> {
let mut out = Vec::with_capacity(bytes.len());
for (i, &b) in bytes.iter().enumerate() {
@@ -358,6 +367,38 @@ impl TaskBase {
}
}
/// One side of an Accept (the task's worktree or the checkout): where its files really are, and
/// git's own folders for it.
struct Confined {
root: PathBuf,
metadata: Vec<PathBuf>,
}
impl Confined {
fn new(root: &Path, repo: Option<&Repo>) -> IdeResult<Self> {
let metadata = match repo {
Some(repo) => repo.metadata_dirs()?.iter().map(|d| ide_core::real_path::resolve_real(d)).collect(),
None => Vec::new(),
};
Ok(Self { root: dunce::canonicalize(root)?, metadata })
}
/// Whether `rel` may be read or written here: it doesn't name git's metadata under any
/// spelling (case, trailing dots or spaces, stream suffixes, short names), stays in the tree
/// once links are followed, and isn't a link itself.
fn allows(&self, rel: &str) -> bool {
use ide_core::real_path::{names_git_dir, reaches_git_dir, resolve_real};
let path = self.root.join(rel);
if names_git_dir(Path::new(rel)) || path.symlink_metadata().is_ok_and(|m| m.file_type().is_symlink()) {
return false;
}
let real = resolve_real(&path);
real.starts_with(&self.root)
&& !reaches_git_dir(&self.root, &real)
&& !self.metadata.iter().any(|dir| real.starts_with(dir))
}
}
/// Copies the task's `changes` (against `base`) from the worktree `cwd` into the
/// local checkout `root`, uncommitted: the user reviews and commits them with the
/// VCS tools. Files the checkout changed itself are left alone and reported, so
@@ -370,28 +411,40 @@ pub(crate) fn accept(
) -> IdeResult<crate::AgentAcceptOutcome> {
let repo = repo_at(cwd)?;
// Change paths are repository-relative, and the task's project may be a folder inside it.
let local_root = discover(root)?.map_or_else(|| root.to_path_buf(), |r| r.root().to_path_buf());
let local = discover(root)?;
let local_root = local.as_ref().map_or_else(|| root.to_path_buf(), |r| r.root().to_path_buf());
// Paths come from what the agent left in its worktree: neither side may lead into git's own
// files or out through a link.
let task_side = Confined::new(repo.root(), Some(&repo))?;
let checkout_side = Confined::new(&local_root, local.as_ref())?;
let copyable = |rel: &str| task_side.allows(rel) && checkout_side.allows(rel);
let renamed_from: BTreeMap<String, String> =
repo.diff_with_worktree(base)?.into_iter().filter_map(|f| Some((f.path, f.old_path?))).collect();
// Brings one path of the checkout to the task's version, unless the checkout changed it itself.
let apply = |rel: &str| -> IdeResult<bool> {
let local = local_root.join(rel);
// The task's version of one path of the checkout (`Some(None)`: absent there), or `None` when
// the checkout changed it itself.
let target = |rel: &str| -> IdeResult<Option<Option<Vec<u8>>>> {
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 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 {
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`).
let unchanged = match (&local_bytes, &base_bytes) {
(Some(now), Some(at_base)) => now == at_base || crlf_to_lf(now) == *at_base,
(now, at_base) => now == at_base,
};
if !unchanged {
return Ok(false);
Ok(unchanged.then_some(current))
};
// 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) => {
if let Some(parent) = local.parent() {
std::fs::create_dir_all(parent)?;
@@ -403,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)
};
let mut accepted = Vec::with_capacity(changes.len());
@@ -411,13 +470,26 @@ pub(crate) fn accept(
path: path.to_owned(),
reason: "the checkout has changes of its own to it; accept it by hand".to_owned(),
};
let refuse = |path: &str| crate::AgentAcceptSkipped {
path: path.to_owned(),
reason: "it leads into git's own files or through a link, so it is not copied".to_owned(),
};
for change in changes {
let old = renamed_from.get(&change.rel_path);
if !copyable(&change.rel_path) {
skipped.push(refuse(&change.rel_path));
continue;
}
if let Some(old) = old.filter(|old| !copyable(old)) {
skipped.push(refuse(old));
continue;
}
// A case-only rename (`a.txt` → `A.txt`). Where the disk resolves both
// spellings to one file it is renamed in place and `apply` then refreshes
// the content. On a case-sensitive disk the two names are different files:
// the old one's unchanged-ness (it is the file the checkout had) decides —
// then the task's version lands at the new name and the old one goes.
let old = renamed_from.get(&change.rel_path);
// both are checked before either changes — the old one must be the file the
// 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 =
old.is_some_and(|o| o != &change.rel_path && o.to_lowercase() == change.rel_path.to_lowercase());
if case_only {
@@ -426,20 +498,18 @@ pub(crate) fn accept(
if same_file(&old_path, &new_path) {
std::fs::rename(&old_path, &new_path)?;
} else {
if !apply(old)? {
let Some(old_target) = target(old)? else {
skipped.push(skip(old));
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
// the new name); now write that version under the new name. `apply`
// 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)?;
}
write(&change.rel_path, task_bytes)?;
write(old, old_target)?;
accepted.push(change.rel_path.clone());
continue;
}
@@ -910,6 +980,138 @@ mod tests {
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)]
#[test]
fn accept_does_not_write_into_the_checkouts_git_folder_through_its_short_name() {
let (_tmp, repo, created) = accept_repo();
if !repo.join("GIT~1").join("HEAD").is_file() {
eprintln!("8.3 short names are disabled on this volume; nothing to check");
return;
}
// Written in the worktree under the name the checkout's `.git` folder also has.
let wt = &created.path;
std::fs::rename(wt.join(".git"), wt.join("gitfile")).unwrap();
std::fs::create_dir_all(wt.join("GIT~1").join("hooks")).unwrap();
std::fs::write(wt.join("GIT~1").join("hooks").join("pre-commit"), "#!/bin/sh\nexit 0\n").unwrap();
std::fs::rename(wt.join("gitfile"), wt.join(".git")).unwrap();
std::fs::write(wt.join("a.txt"), "one\nTWO\n").unwrap();
let changes = listed(wt, &created.base_commit);
assert!(changes.iter().any(|c| c.rel_path == "GIT~1/hooks/pre-commit"), "control: git lists it");
let outcome = accept(&repo, wt, &created.base_commit, &changes).unwrap();
assert!(!repo.join(".git").join("hooks").join("pre-commit").exists(), "{outcome:?}");
assert_eq!(outcome.accepted, ["a.txt"]);
assert_eq!(outcome.skipped.iter().map(|s| s.path.as_str()).collect::<Vec<_>>(), ["GIT~1/hooks/pre-commit"]);
}
#[test]
fn accept_refuses_paths_that_lead_into_git_metadata_under_any_spelling() {
let (tmp, repo, _created) = accept_repo();
let checkout = Confined::new(&repo, Repo::discover(&repo).unwrap().as_ref()).unwrap();
for rel in [
".git/hooks/pre-commit",
".GIT/hooks/pre-commit",
".git./hooks/pre-commit",
".git /hooks/pre-commit",
".git::$INDEX_ALLOCATION/hooks/pre-commit",
"sub/.git/config",
"../outside.txt",
] {
assert!(!checkout.allows(rel), "{rel}");
}
assert!(checkout.allows("src/new.txt"));
assert!(checkout.allows(".github/workflows/ci.yml"));
// git's folder under another name inside the tree (`--separate-git-dir`).
let separate = tmp.path().join("separate");
std::fs::create_dir(&separate).unwrap();
git(&separate, &["init", "-q", "--separate-git-dir", "meta", "-b", "main"]);
let side = Confined::new(&separate, Repo::discover(&separate).unwrap().as_ref()).unwrap();
assert!(!side.allows("meta/hooks/pre-commit"));
assert!(side.allows("metadata.txt"));
}
/// A link the task left in its worktree, or a linked folder in the checkout: neither is followed.
#[test]
fn accept_does_not_follow_links_on_either_side() {
let (tmp, repo, created) = accept_repo();
let outside = tmp.path().join("outside");
std::fs::create_dir(&outside).unwrap();
std::fs::write(outside.join("secret.txt"), "secret\n").unwrap();
// A folder link in the checkout (a junction on Windows, which needs no privilege).
#[cfg(unix)]
std::os::unix::fs::symlink(&outside, repo.join("linked")).unwrap();
#[cfg(windows)]
assert!(
std::process::Command::new("cmd")
.args(["/c", "mklink", "/J"])
.arg(repo.join("linked"))
.arg(&outside)
.output()
.unwrap()
.status
.success()
);
std::fs::create_dir(created.path.join("linked")).unwrap();
std::fs::write(created.path.join("linked/new.txt"), "x\n").unwrap();
std::fs::write(created.path.join("a.txt"), "one\nTWO\n").unwrap();
// A file link in the worktree, where the platform lets this user create one.
#[cfg(unix)]
let file_link = std::os::unix::fs::symlink(outside.join("secret.txt"), created.path.join("copied.txt"));
#[cfg(windows)]
let file_link = std::os::windows::fs::symlink_file(outside.join("secret.txt"), created.path.join("copied.txt"));
if let Err(e) = &file_link {
eprintln!("no file link ({e}); checking the folder link only");
}
let changes: Vec<_> = listed(&created.path, &created.base_commit);
let outcome = accept(&repo, &created.path, &created.base_commit, &changes).unwrap();
assert!(!outside.join("new.txt").exists(), "{outcome:?}");
assert!(!repo.join("copied.txt").exists(), "{outcome:?}");
assert!(outcome.accepted.contains(&"a.txt".to_owned()));
let refused: Vec<_> = outcome.skipped.iter().map(|s| s.path.as_str()).collect();
if file_link.is_ok() {
assert_eq!(refused, ["copied.txt", "linked/new.txt"]);
} else {
assert_eq!(refused, ["linked/new.txt"]);
}
}
#[test]
fn accept_copies_new_modified_and_deleted_files_into_the_checkout() {
let (_tmp, repo, created) = accept_repo();
+2 -1
Переглянути файл
@@ -12,7 +12,8 @@ mod up_to_date;
pub use fingerprint::{LaunchFingerprint, launch_fingerprint, menu_fingerprint};
pub use launch_resources::process_launch_resources;
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};
+23 -4
Переглянути файл
@@ -144,6 +144,13 @@ fn bracket_pos(s: &str) -> Option<(u32, Option<u32>, &str)> {
Some((line, col, rest.trim_start()))
}
/// `/C:/p/A.java` → `C:/p/A.java`: Maven and file URIs put a `/` before the drive letter on Windows.
/// Joined as is, it would become the drive-relative `C:p/A.java`.
fn drive_path(p: &str) -> &str {
let b = p.as_bytes();
if b.len() > 2 && b[0] == b'/' && b[1].is_ascii_alphabetic() && b[2] == b':' { &p[1..] } else { p }
}
/// `file:///p/A.kt` → `/p/A.kt` (percent-decoded).
fn from_file_uri(s: &str) -> String {
let Some(rest) = s.strip_prefix("file://") else { return s.to_owned() };
@@ -162,9 +169,7 @@ fn from_file_uri(s: &str) -> String {
out.push(bytes[i]);
i += 1;
}
let path = String::from_utf8_lossy(&out).into_owned();
// `file:///C:/x` on Windows.
if path.len() > 2 && path.as_bytes()[0] == b'/' && path.as_bytes()[2] == b':' { path[1..].to_owned() } else { path }
drive_path(&String::from_utf8_lossy(&out)).to_owned()
}
/// Kotlin: `file:///p/A.kt:12:5 msg` or `/p/A.kt: (12, 5): msg`, after the `e: ` / `w: ` prefix.
@@ -222,7 +227,7 @@ fn maven_line(line: &str) -> Option<RawMessage> {
&& looks_like_source_path(path)
&& let Some((ln, col, msg)) = bracket_pos(&format!("[{pos}"))
{
return Some(RawMessage::new(severity, msg, ColUnit::Char).at(path, ln, col));
return Some(RawMessage::new(severity, msg, ColUnit::Char).at(drive_path(path), ln, col));
}
if let Some(m) = kotlin_line(rest) {
return Some(m);
@@ -456,6 +461,20 @@ mod tests {
assert_eq!(out[1].unit, ColUnit::Char);
}
#[test]
fn maven_windows_paths_drop_the_slash_before_the_drive() {
let out = parse(
Format::Maven,
"[ERROR] /C:/dev/p/src/main/java/a/A.java:[18,41] package a.b does not exist\n\
[ERROR] C:\\dev\\p\\B.java:[3,8] cannot find symbol\n\
[ERROR] file:///C:/dev/p/K.kt:4:9 Unresolved reference 'x'.\n",
);
assert_eq!(out.len(), 3, "{out:?}");
assert_eq!(pos(&out[0]), (Severity::Error, "C:/dev/p/src/main/java/a/A.java", 18, Some(41)));
assert_eq!(pos(&out[1]), (Severity::Error, "C:\\dev\\p\\B.java", 3, Some(8)));
assert_eq!(pos(&out[2]), (Severity::Error, "C:/dev/p/K.kt", 4, Some(9)));
}
#[test]
fn maven_detail_lines_with_level_prefix_and_kotlin() {
let out = parse(
+210 -3
Переглянути файл
@@ -4,12 +4,21 @@
//! walks the whole reactor; compiling one file only needs that file's module
//! outputs plus its dependency classpath, which is much faster to assemble.
use super::*;
use serde::{Deserialize, Serialize};
use std::fs;
use std::process::Command as ProcCommand;
/// Output file of the `dependency:build-classpath` probe, relative to each module.
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.
pub struct SingleFileTarget {
/// 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 })
}
/// 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`.
pub fn find_javac(env: &BTreeMap<String, String>) -> String {
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,
/// so `-am` stays cheap). Entries are platform-separated (`;` on Windows).
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();
// 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"] };
@@ -110,7 +223,14 @@ pub fn single_file_classpath(root: &Path, model: &BuildModel, target: &SingleFil
args.extend(["-pl", target.pl.as_str(), "-am"]);
}
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 status = ProcCommand::new(&target.top.command)
.args(&args)
@@ -119,8 +239,10 @@ pub fn single_file_classpath(root: &Path, model: &BuildModel, target: &SingleFil
.output()
.map_err(|e| IdeError::internal(format!("cannot resolve the compile classpath: {e}")))?;
if !status.status.success() {
let stdout = String::from_utf8_lossy(&status.stdout);
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");
return Err(IdeError::internal(format!("cannot resolve the compile classpath:\n{tail}")));
}
@@ -305,7 +427,7 @@ mod tests {
let script = root.join("mvnw");
fs::write(
&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();
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/b.jar"), "{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");
}
}
+210 -24
Переглянути файл
@@ -16,6 +16,7 @@ use std::io::Read;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
pub mod completion;
pub mod sources;
/// Lets the IDE's own Maven runs (Reload, Build) use what the local repository already
@@ -299,7 +300,7 @@ fn read_plugin(e: &Element) -> Option<RawPlugin> {
})
}
fn read_properties(e: Option<&Element>) -> Vec<(String, String)> {
pub(crate) fn read_properties(e: Option<&Element>) -> Vec<(String, String)> {
e.map(|p| p.children.iter().map(|c| (c.name.clone(), c.text.trim().to_owned())).collect()).unwrap_or_default()
}
@@ -711,18 +712,27 @@ pub(crate) fn launch_inputs(
// Profile activation may depend on the presence of an otherwise unrelated file.
let activation = parent == "file" && matches!(el.name.as_str(), "exists" | "missing");
if input || activation {
// Other properties can be overridden by -D, profiles or settings.
// Without Maven's effective roots, conservatively disable reuse for them.
let value = el
.text
.trim()
.replace("${project.basedir}", &dir.to_string_lossy())
.replace("${basedir}", &dir.to_string_lossy());
if value.contains("${") {
return None;
}
if !value.is_empty() {
out.push(dir.join(value));
// A source root under Maven's build directory is generated by the build itself.
// Skip it without resolving the property: `<build><directory>` may be custom, and
// hashing its rewritten files would make every launch look changed.
if !(input && el.text.contains("${project.build.directory}")) {
// Other properties can be overridden by -D, profiles or settings. Without
// Maven's effective model, leave them unresolved to conservatively disable reuse.
let value = el
.text
.trim()
.replace("${project.basedir}", &dir.to_string_lossy())
.replace("${basedir}", &dir.to_string_lossy());
if value.contains("${") {
return None;
}
if !value.is_empty() {
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 {
@@ -1679,6 +1689,15 @@ pub enum PomGotoKind {
ManagedDependency,
/// A `<pluginManagement>` entry providing the plugin's version.
ManagedPlugin,
/// The POM of the artifact itself: a project module's, else the local repository's.
ArtifactPom,
}
/// A Maven project of the workspace, for going to a dependency on it.
pub struct ModulePom {
pub group_id: String,
pub artifact_id: String,
pub pom: PathBuf,
}
/// Byte offset of `pos` (0-based line / UTF-16 column), clamped to `text`.
@@ -1729,7 +1748,7 @@ fn property_ref_at(text: &str, at: usize) -> Option<(&str, usize, usize)> {
}
/// The elements containing `at`, from the root down to the innermost one.
fn element_path(root: &Element, at: usize) -> Vec<&Element> {
pub(crate) fn element_path(root: &Element, at: usize) -> Vec<&Element> {
let mut path = vec![root];
loop {
let Some(next) = path.last().unwrap().children.iter().find(|c| c.start <= at && at < c.end) else {
@@ -1758,6 +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
/// resolvable declaration here.
pub fn goto_declaration(text: &str, path: &Path, pos: Pos, local_repo: Option<PathBuf>) -> Option<PomGoto> {
goto_declaration_in(text, path, pos, local_repo, Vec::new)
}
/// [`goto_declaration`], where a `<parent>` and a dependency or plugin whose version no
/// management entry provides (or a management entry itself) go to the artifact's own
/// POM: the project module among `modules` with its coordinates, else the local
/// repository's POM of its version.
pub fn goto_declaration_in(
text: &str,
path: &Path,
pos: Pos,
local_repo: Option<PathBuf>,
modules: impl FnOnce() -> Vec<ModulePom>,
) -> Option<PomGoto> {
let root = xml::parse(text).filter(|e| e.name == "project")?;
let at = offset_at(text, pos);
if let Some((name, _, _)) = property_ref_at(text, at)
@@ -1768,15 +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
// from a management entry of this POM or an ancestor's.
let elements = element_path(&root, at);
let e = elements.iter().rev().find(|e| e.name == "dependency" || e.name == "plugin")?;
if elements.iter().any(|a| a.name == "dependencyManagement" || a.name == "pluginManagement") {
return None; // Management entries are declarations; there is nothing to go to.
if elements.get(1).is_some_and(|e| e.name == "parent") {
let chain = parent_chain(path, text, local_repo);
let (pom, parent_text) = chain.get(1)?;
let label = elements[1].text_of("artifactId").unwrap_or_default();
return Some(pom_goto(pom.clone(), parent_text, label));
}
let kind = match e.name.as_str() {
"dependency" => PomGotoKind::ManagedDependency,
_ => PomGotoKind::ManagedPlugin,
};
managed_target(text, path, e, kind, local_repo)
let e = elements.iter().rev().find(|e| e.name == "dependency" || e.name == "plugin")?;
// A management entry declares the version: it goes to the artifact (a BOM import, say).
if !elements.iter().any(|a| a.name == "dependencyManagement" || a.name == "pluginManagement") {
let kind = match e.name.as_str() {
"dependency" => PomGotoKind::ManagedDependency,
_ => PomGotoKind::ManagedPlugin,
};
if let Some(found) = managed_target(text, path, e, kind, local_repo.clone()) {
return Some(found);
}
}
artifact_target(text, path, e, local_repo, modules)
}
/// The artifact's own POM for a dependency or plugin element `e`.
fn artifact_target(
text: &str,
path: &Path,
e: &Element,
local_repo: Option<PathBuf>,
modules: impl FnOnce() -> Vec<ModulePom>,
) -> Option<PomGoto> {
let mut resolver = Resolver::new(local_repo.clone(), &[]);
let chain = parent_chain(path, text, local_repo);
let props = properties_at(&mut resolver, &chain, 0);
let (group, artifact) = coordinates(e, &props)?;
let label = format!("{group}:{artifact}");
if let Some(module) = modules().into_iter().find(|m| m.group_id == group && m.artifact_id == artifact) {
let module_text = xml::read_text(&module.pom)?;
return Some(pom_goto(module.pom, &module_text, label));
}
let version = interpolate(&e.text_of("version")?, &props);
let pom = resolver.repo_pom(&group, &artifact, &version)?;
let pom_text = xml::read_text(&pom)?;
Some(pom_goto(pom, &pom_text, label))
}
/// The POM at `path` as a target, at its `<artifactId>`.
fn pom_goto(path: PathBuf, text: &str, label: String) -> PomGoto {
let range = xml::parse(text)
.filter(|e| e.name == "project")
.and_then(|root| root.child("artifactId").map(|a| range_of(text, a)))
.unwrap_or_default();
PomGoto { path, range, kind: PomGotoKind::ArtifactPom, label }
}
/// The parent POMs of the POM in `text` at `path`, child-first (its `<parent>` refs,
@@ -2075,6 +2149,52 @@ mod tests {
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) {
let p = dir.join(rel);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
@@ -3086,9 +3206,9 @@ mod tests {
let text = std::fs::read_to_string(&path).unwrap();
// The property is declared nowhere along the chain.
assert_eq!(goto_declaration(&text, &path, at(&text, "${missing.key}"), None), None);
// The dependency names its own version: it is the declaration.
// The dependency names its own version and no local repository has its POM.
assert_eq!(goto_declaration(&text, &path, at(&text, "<artifactId>other-lib"), None), None);
// A management entry is a declaration itself, not a reference.
// A management entry declares the version; its artifact's POM is not at hand either.
let parent_path = tmp.path().join("pom.xml");
let parent_text = std::fs::read_to_string(&parent_path).unwrap();
assert_eq!(
@@ -3100,6 +3220,72 @@ mod tests {
assert_eq!(goto_declaration("<project></project>", &path, Pos::new(0, 3), None), None);
}
#[test]
fn artifacts_go_to_their_own_pom_in_the_project_or_the_local_repository() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("repo");
let bom_dir = repo.join("com/alibaba/cloud/spring-cloud-alibaba-dependencies/2.2.6.RELEASE");
std::fs::create_dir_all(&bom_dir).unwrap();
let bom = "<project>\n <groupId>com.alibaba.cloud</groupId>\n <artifactId>spring-cloud-alibaba-dependencies</artifactId>\n</project>";
std::fs::write(bom_dir.join("spring-cloud-alibaba-dependencies-2.2.6.RELEASE.pom"), bom).unwrap();
let parent = format!(
r#"{PARENT_HEADER}<properties><alibaba.version>2.2.6.RELEASE</alibaba.version></properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-alibaba-dependencies</artifactId>
<version>${{alibaba.version}}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
</project>"#
);
let child = format!(
r#"{CHILD_HEADER}<dependencies>
<dependency>
<groupId>com.example</groupId>
<artifactId>core</artifactId>
<version>1.0</version>
</dependency>
</dependencies>
</project>"#
);
write_parent_child(tmp.path(), &parent, &child);
let core = tmp.path().join("core/pom.xml");
std::fs::create_dir_all(core.parent().unwrap()).unwrap();
std::fs::write(
&core,
"<project>\n <groupId>com.example</groupId>\n <artifactId>core</artifactId>\n</project>",
)
.unwrap();
let modules =
|| vec![ModulePom { group_id: "com.example".into(), artifact_id: "core".into(), pom: core.clone() }];
// A BOM import in <dependencyManagement> opens the BOM from the local repository.
let parent_path = tmp.path().join("pom.xml");
let parent_text = std::fs::read_to_string(&parent_path).unwrap();
let at_bom = at(&parent_text, "spring-cloud-alibaba-dependencies<");
let goto = goto_declaration_in(&parent_text, &parent_path, at_bom, Some(repo.clone()), modules).unwrap();
assert_eq!(goto.path, bom_dir.join("spring-cloud-alibaba-dependencies-2.2.6.RELEASE.pom"));
assert_eq!(goto.kind, PomGotoKind::ArtifactPom);
assert_eq!(goto.range.start, pos_at(bom, bom.find("<artifactId>").unwrap()));
// A dependency on a project module opens the module's POM.
let path = tmp.path().join("app/pom.xml");
let text = std::fs::read_to_string(&path).unwrap();
let goto =
goto_declaration_in(&text, &path, at(&text, "core</artifactId>"), Some(repo.clone()), modules).unwrap();
assert_eq!((goto.path.as_path(), goto.label.as_str()), (core.as_path(), "com.example:core"));
// The <parent> section opens the parent POM.
let goto = goto_declaration_in(&text, &path, at(&text, "parent</artifactId>"), Some(repo), modules).unwrap();
assert_eq!(goto.path, canon(&parent_path));
assert_eq!(goto.kind, PomGotoKind::ArtifactPom);
}
#[test]
fn a_property_reference_after_non_ascii_text_is_still_found() {
let text = format!(
+694
Переглянути файл
@@ -0,0 +1,694 @@
//! Completion of Maven coordinates in a POM: inside a `<dependency>`, the
//! `<groupId>`, `<artifactId>` and `<version>` values are completed from the
//! local repository layout (`<repo>/<group path>/<artifactId>/<version>/…`).
//! The Maven Central search endpoint tops the lists up remotely; the host op
//! fetches those (cached) and merges them behind the local ones, so this side
//! stays pure text and directory logic and tests build their own repositories.
use super::xml::{self, Element};
use super::{element_path, interpolate, read_properties};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::cmp::Ordering;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
/// The coordinate element a POM completion fills.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum PomCompletionKind {
/// The dependency's `<groupId>`.
GroupId,
/// The dependency's `<artifactId>`.
ArtifactId,
/// The dependency's `<version>`.
Version,
}
/// One suggestion: the coordinate as inserted, and an optional note shown in
/// the lookup (the owning group of an artifactId, its newest installed version).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct PomCompletionOption {
/// The text the completion inserts (`org.example.lib`, `example-lib`, `1.2.0`).
pub label: String,
pub detail: Option<String>,
}
/// The completion offered at a caret of a POM: the replaced value span (UTF-16
/// offsets) and the suggestions, local ones first.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct PomCompletion {
/// UTF-16 offset where the completed text starts: the value's first character.
pub from: u32,
/// UTF-16 offset where the replaced value ends: past the caret when the caret
/// sits inside an existing value, which is then replaced whole.
pub to: u32,
/// Which coordinate element the caret is in.
pub kind: PomCompletionKind,
/// The suggestions, local repository first.
pub options: Vec<PomCompletionOption>,
}
/// Where the caret sits in a `<dependency>` coordinate element: what is being
/// completed, the typed prefix, and the sibling coordinates the rest need.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PomCompletionTarget {
/// UTF-16 offset of the value's first character (the caret in an empty value).
pub from: u32,
/// UTF-16 offset past the value's last character (the caret in an empty value).
pub to: u32,
/// The value typed so far: the text between `from` and the caret, trimmed.
pub typed: String,
pub kind: PomCompletionKind,
/// The interpolated `<groupId>` sibling, when completing the artifactId and
/// the group is written out plain (no unresolved `${property}`).
pub group: Option<String>,
/// The interpolated `<artifactId>` sibling, when completing the version.
pub artifact: Option<String>,
}
/// A coordinate element's text, with the POM's own `${properties}` replaced;
/// `None` when the element is missing or a property stays unresolved (the
/// coordinates behind it are not known without the parent chain, which the
/// completion does not follow).
fn coordinate(e: Option<&Element>, props: &HashMap<String, String>) -> Option<String> {
let text = interpolate(e?.text.trim(), props);
(!text.is_empty() && !text.contains("${")).then_some(text)
}
fn own_properties(root: &Element) -> HashMap<String, String> {
read_properties(root.child("properties")).into_iter().collect()
}
/// UTF-16 offset -> byte offset, clamped to `text`.
fn utf16_to_byte(text: &str, offset: u32) -> usize {
let mut seen = 0u32;
for (byte, c) in text.char_indices() {
if seen >= offset {
return byte;
}
seen += c.len_utf16() as u32;
}
text.len()
}
/// Byte offset -> UTF-16 offset.
fn utf16_at(text: &str, byte: usize) -> u32 {
text[..byte].encode_utf16().count() as u32
}
/// Byte offset just past the start tag's `>` of the element at `start`.
fn start_tag_end(text: &str, start: usize) -> Option<usize> {
let bytes = text.as_bytes();
let mut i = start.checked_add(1)?;
let mut quote = None;
while i < bytes.len() {
match (quote, bytes[i]) {
(Some(q), b) if b == q => quote = None,
(Some(_), _) => {}
(None, b'"' | b'\'') => quote = Some(bytes[i]),
(None, b'>') => return Some(i + 1),
_ => {}
}
i += 1;
}
None
}
/// What to complete at the caret (`offset`, UTF-16) of the POM `text`: the
/// `<groupId>` / `<artifactId>` / `<version>` element of a `<dependency>` the
/// caret sits in the value of. `None` everywhere else — other elements, carets
/// in an end tag or a `${property}` reference, dependencies without the
/// `<groupId>` (and, for versions, the `<artifactId>`) their completions need.
pub fn completion_target(text: &str, offset: u32) -> Option<PomCompletionTarget> {
let root = xml::parse(text).filter(|e| e.name == "project")?;
let at = utf16_to_byte(text, offset);
// A caret at the very end of the input sits in the element still being
// typed, whose range reaches to the input's end; step back one character
// so `element_path` sees it (the caret's own offset stays `at`).
let probe = if at == text.len() && at > 0 {
let mut p = at - 1;
while p > 0 && !text.is_char_boundary(p) {
p -= 1;
}
p
} else {
at
};
let elements = element_path(&root, probe);
let e = *elements.last()?;
let parent = elements.get(elements.len().checked_sub(2)?)?;
let kind = match e.name.as_str() {
"groupId" => PomCompletionKind::GroupId,
"artifactId" => PomCompletionKind::ArtifactId,
"version" => PomCompletionKind::Version,
_ => return None,
};
if parent.name != "dependency" || at > e.inner_end {
return None;
}
let inner_start = start_tag_end(text, e.start)?;
let value = &text[inner_start..e.inner_end.max(inner_start)];
if value.contains("${") {
return None; // Property references are goto-declaration territory, not completion.
}
let lead = value.len() - value.trim_start().len();
let trail = value.len() - value.trim_end().len();
let (mut from, mut to) = (inner_start + lead, e.inner_end.max(inner_start) - trail);
if from > to {
// A value of whitespace only: complete at the caret.
from = at.min(to);
to = to.max(from);
}
from = from.min(at);
to = to.max(from);
let props = own_properties(&root);
let group = (kind != PomCompletionKind::GroupId).then(|| coordinate(parent.child("groupId"), &props)).flatten();
let artifact =
(kind == PomCompletionKind::Version).then(|| coordinate(parent.child("artifactId"), &props)).flatten();
let typed = text[from..at].trim().to_owned();
Some(PomCompletionTarget { from: utf16_at(text, from), to: utf16_at(text, to), typed, kind, group, artifact })
}
/// The suggestions the local repository at `repo` offers for `target`: group
/// ids installed under it, the artifacts of the target's group, or the versions
/// of the target's artifact. Every scan is bounded (see `SCAN_BUDGET`) and
/// reads only directory listings of `repo` — nothing outside it, no jar
/// contents.
pub fn local_options(repo: &Path, target: &PomCompletionTarget) -> Vec<PomCompletionOption> {
let mut budget = SCAN_BUDGET;
match target.kind {
// `org.spr` completes the `spr` part under `org/`, `org.` the next
// segment under `org/`; every typed segment is validated so POM text
// cannot walk out of the repository.
PomCompletionKind::GroupId => {
let segments: Vec<&str> = target.typed.split('.').collect();
let (partial, base) = segments.split_last().unwrap_or((&"", &[]));
if base.iter().all(|s| segment(s)) {
let dir = base.iter().fold(repo.to_path_buf(), |p, s| p.join(s));
scan_groups(&dir, &base.join("."), partial, &mut budget)
} else {
Vec::new()
}
}
PomCompletionKind::ArtifactId => target
.group
.as_deref()
.and_then(|g| repo_dir(repo, &[g]))
.map_or_else(Vec::new, |dir| scan_artifacts(&dir, &target.typed, &mut budget)),
PomCompletionKind::Version => target
.group
.as_deref()
.zip(target.artifact.as_deref())
.and_then(|(g, a)| repo_dir(repo, &[g, a]))
.map_or_else(Vec::new, |dir| scan_versions(&dir, &target.typed, &mut budget)),
}
}
/// Directory listings one completion may read: the scans are interactive, so
/// they stop before they could be felt, and a huge repository simply yields
/// fewer suggestions (its shallowest first — the walk is breadth-preferring).
const SCAN_BUDGET: u32 = 2048;
/// Suggestion caps per kind: group ids and artifact ids fill a lookup page,
/// versions list an artifact's releases.
const MAX_GROUP_OPTIONS: usize = 60;
const MAX_ARTIFACT_OPTIONS: usize = 60;
const MAX_VERSION_OPTIONS: usize = 100;
/// The cap the merged list (local plus Maven Central's) is truncated to.
pub const MAX_OPTIONS: usize = 100;
/// A directory entry whose name passes the repository layout's alphabet
/// (mirrors `sources::artifact`'s): letters, digits, `._-`. Path separators
/// and `..` never pass, so a POM's `${…}`-free text cannot traverse out of
/// the repository.
fn segment(name: &str) -> bool {
!name.is_empty()
&& name != "."
&& name != ".."
&& name.chars().all(|c| c.is_ascii_alphanumeric() || "._-".contains(c))
}
/// The subdirectories of `dir` with repository-layout names, spending `budget`
/// on the listing; `None` when the budget ran out before `dir` was listed.
fn subdirs(dir: &Path, budget: &mut u32) -> Option<Vec<String>> {
if *budget == 0 {
return None;
}
*budget -= 1;
let Ok(entries) = std::fs::read_dir(dir) else {
return Some(Vec::new());
};
let mut names = Vec::new();
for entry in entries.flatten() {
// The budget bounds file-type checks too: a plain listing answers.
if entry.file_type().map(|t| t.is_dir()).unwrap_or(false)
&& let Ok(name) = entry.file_name().into_string()
&& segment(&name)
{
names.push(name);
}
}
Some(names)
}
/// Whether `dir` holds files — a version directory does (jar, pom, the
/// tracking files), a group path segment never does.
fn holds_files(dir: &Path, budget: &mut u32) -> bool {
if *budget == 0 {
return false;
}
*budget -= 1;
let Ok(entries) = std::fs::read_dir(dir) else {
return false;
};
entries.flatten().any(|e| e.file_type().map(|t| t.is_file()).unwrap_or(false))
}
/// Whether the version directory `dir` holds the version: its `.pom` or a `.jar`. A failed
/// download leaves only `*.lastUpdated` tracking files behind, for a version that may not
/// even exist (one typed while the POM was being edited).
fn installed(dir: &Path, budget: &mut u32) -> bool {
if *budget == 0 {
return false;
}
*budget -= 1;
let Ok(entries) = std::fs::read_dir(dir) else {
return false;
};
entries.flatten().any(|e| {
e.file_type().map(|t| t.is_file()).unwrap_or(false)
&& e.file_name().to_str().is_some_and(|n| n.ends_with(".pom") || n.ends_with(".jar"))
})
}
/// Whether `dir` is an artifact directory: it holds versions of one artifact,
/// recognizable by a `maven-metadata*.xml` beside them or a child directory
/// holding files. A group segment's children are directories without files.
fn artifact_dir(dir: &Path, budget: &mut u32) -> bool {
if *budget == 0 {
return false;
}
*budget -= 1;
let Ok(entries) = std::fs::read_dir(dir) else {
return false;
};
for entry in entries.flatten() {
let Ok(name) = entry.file_name().into_string() else { continue };
if name.starts_with("maven-metadata") {
return true;
}
}
let Some(children) = subdirs(dir, budget) else { return false };
children.iter().any(|c| holds_files(&dir.join(c), budget))
}
/// Whether `dir` is a group directory (holds artifact directories) and whether
/// it also holds deeper group segments worth descending into.
fn group_dir(dir: &Path, budget: &mut u32) -> Option<(bool, bool)> {
let children = subdirs(dir, budget)?;
let (mut group, mut descend) = (false, false);
for child in &children {
if artifact_dir(&dir.join(child), budget) {
group = true;
} else {
descend = true;
}
}
Some((group, descend))
}
/// The group ids installed under `dir` (which the typed prefix `prefix` —
/// "" for the repository root — already names), completing the partial last
/// segment `partial`, shallower groups first.
fn scan_groups(dir: &Path, prefix: &str, partial: &str, budget: &mut u32) -> Vec<PomCompletionOption> {
let mut out = Vec::new();
scan_groups_into(dir, prefix, partial, &mut out, budget);
out.sort_by(|a, b| a.label.cmp(&b.label));
out
}
fn scan_groups_into(dir: &Path, prefix: &str, partial: &str, out: &mut Vec<PomCompletionOption>, budget: &mut u32) {
if out.len() >= MAX_GROUP_OPTIONS {
return;
}
let Some(children) = subdirs(dir, budget) else { return };
for name in children {
if !name.starts_with(partial) {
continue;
}
let path = dir.join(&name);
let label = if prefix.is_empty() { name.clone() } else { format!("{prefix}.{name}") };
let Some((group, descend)) = group_dir(&path, budget) else { return };
if group {
out.push(PomCompletionOption { label: label.clone(), detail: None });
if out.len() >= MAX_GROUP_OPTIONS {
return;
}
}
// Deeper groups (org.example under org.example's own parent) follow the
// shallower ones, so a truncated scan of a huge repository still offers
// every first-level segment.
if descend {
scan_groups_into(&path, &label, "", out, budget);
}
}
}
/// The artifact ids installed under the group directory `dir`, completing the
/// typed prefix, each with its newest installed version as the detail.
fn scan_artifacts(dir: &Path, typed: &str, budget: &mut u32) -> Vec<PomCompletionOption> {
let Some(children) = subdirs(dir, budget) else { return Vec::new() };
let mut out = Vec::new();
for name in children {
if !name.starts_with(typed) || !artifact_dir(&dir.join(&name), budget) {
continue;
}
let detail = newest_version(&dir.join(&name), budget);
out.push(PomCompletionOption { label: name, detail });
}
out.sort_by(|a, b| a.label.cmp(&b.label));
out.truncate(MAX_ARTIFACT_OPTIONS);
out
}
/// The newest version directory of the artifact at `dir`, by Maven's ordering.
fn newest_version(dir: &Path, budget: &mut u32) -> Option<String> {
let children = subdirs(dir, budget)?;
children.into_iter().filter(|name| installed(&dir.join(name), budget)).max_by(|a, b| compare_versions(a, b))
}
/// The versions installed of the artifact at `dir`, completing the typed
/// prefix, newest first.
fn scan_versions(dir: &Path, typed: &str, budget: &mut u32) -> Vec<PomCompletionOption> {
let Some(children) = subdirs(dir, budget) else { return Vec::new() };
let mut out: Vec<_> = children
.into_iter()
.filter(|name| name.starts_with(typed) && installed(&dir.join(name), budget))
.map(|name| PomCompletionOption { label: name, detail: None })
.collect();
out.sort_by(|a, b| compare_versions(&b.label, &a.label));
out.truncate(MAX_VERSION_OPTIONS);
out
}
/// The repository directory of `coordinates` (a group id, or a group and an artifact
/// id): the group's dots are directories, an artifact id is one directory whatever dots
/// 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> {
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)))
}
/// Maven-ish version order, best-effort (a full `ComparableVersion` is out of
/// proportion for ordering a lookup): numeric runs compare numerically, letter
/// runs by their qualifier rank (`alpha < beta < m < rc < snapshot < release <
/// sp`, unknown ones with releases lexically); missing parts rank as zero.
fn compare_versions(a: &str, b: &str) -> Ordering {
fn tokens(v: &str) -> Vec<String> {
v.split(|c: char| !c.is_ascii_alphanumeric()).filter(|t| !t.is_empty()).map(str::to_ascii_lowercase).collect()
}
fn rank(t: &str) -> Option<u8> {
match t {
"a" | "alpha" => Some(0),
"b" | "beta" => Some(1),
"m" | "milestone" => Some(2),
"rc" | "cr" => Some(3),
"snapshot" => Some(4),
"" | "ga" | "final" => Some(5),
"sp" => Some(6),
_ => None,
}
}
let (a, b) = (tokens(a), tokens(b));
for i in 0..a.len().max(b.len()) {
let (x, y) = (a.get(i).map(String::as_str), b.get(i).map(String::as_str));
let (xn, yn) = (x.and_then(|s| s.parse::<u64>().ok()), y.and_then(|s| s.parse::<u64>().ok()));
let order = match (xn, yn) {
// Numbers compare numerically; a number outranks a qualifier.
(Some(xn), Some(yn)) => xn.cmp(&yn),
(Some(_), None) if y.is_some() => return Ordering::Greater,
(None, Some(_)) if x.is_some() => return Ordering::Less,
_ => {
let xr = x.and_then(rank).unwrap_or(5);
let yr = y.and_then(rank).unwrap_or(5);
if xr != yr { xr.cmp(&yr) } else { x.unwrap_or("").cmp(y.unwrap_or("")) }
}
};
if order != Ordering::Equal {
return order;
}
}
Ordering::Equal
}
#[cfg(test)]
mod tests {
use super::*;
/// A small local repository: every file is laid out as Maven installs it,
/// so the scans recognize group, artifact and version directories.
fn repo(dir: &Path) -> PathBuf {
let repo = dir.join("repository");
for file in [
"org/apache/maven/maven-core/4.0.0-rc-5/maven-core-4.0.0-rc-5.jar",
"org/apache/maven/plugins/maven-compiler-plugin/3.8.1/maven-compiler-plugin-3.8.1.jar",
"org/example/lib/1.0/lib-1.0.jar",
"org/example/lib/1.9.1/lib-1.9.1.jar",
"org/example/lib/1.10/lib-1.10.jar",
"org/example/lib/2.0-beta-1/lib-2.0-beta-1.jar",
"org/example/lib/2.0/lib-2.0.pom",
"net/nu/netty/4.1.100/netty-4.1.100.jar",
"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.
"org/example/lib/2/lib-2.jar.lastUpdated",
"org/example/lib/3.0/lib-3.0.pom.lastUpdated",
] {
let path = repo.join(file);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, b"").unwrap();
}
repo
}
fn target(text: &str, needle: &str) -> Option<PomCompletionTarget> {
completion_target(text, (text.find(needle).unwrap() + needle.len()) as u32)
}
#[test]
fn completes_each_coordinate_element_of_a_dependency() {
let text = "<project><dependencies><dependency>\
<groupId>org.example</groupId><artifactId>lib</artifactId><version>1.0</version>\
</dependency></dependencies></project>";
let t = target(text, "<groupId>org.example").unwrap();
assert_eq!(t.kind, PomCompletionKind::GroupId);
assert_eq!(t.typed, "org.example");
assert_eq!((t.from, t.to), (44, 55));
assert!(t.group.is_none() && t.artifact.is_none());
let t = target(text, "<artifactId>lib").unwrap();
assert_eq!(t.kind, PomCompletionKind::ArtifactId);
assert_eq!(t.typed, "lib");
assert_eq!(t.group.as_deref(), Some("org.example"));
assert!(t.artifact.is_none());
let t = target(text, "<version>1.0").unwrap();
assert_eq!(t.kind, PomCompletionKind::Version);
assert_eq!(t.typed, "1.0");
assert_eq!(t.group.as_deref(), Some("org.example"));
assert_eq!(t.artifact.as_deref(), Some("lib"));
}
#[test]
fn completes_while_the_element_is_being_typed_and_the_value_is_empty() {
let empty = "<project><dependencies><dependency><groupId></groupId></dependency></dependencies></project>";
let t = target(empty, "<groupId>").unwrap();
assert_eq!(t.kind, PomCompletionKind::GroupId);
assert_eq!(t.typed, "");
assert_eq!(t.from, t.to);
let open = "<project><dependencies><dependency><artifactId>li";
let t = target(open, "li").unwrap();
assert_eq!(t.kind, PomCompletionKind::ArtifactId);
assert_eq!(t.typed, "li");
// The value under construction ends with the input and is replaced whole.
assert_eq!(t.to, open.len() as u32);
}
#[test]
fn completes_only_dependency_coordinates() {
// The same elements outside a <dependency> get nothing: a <plugin> is
// completed by the repository, not coordinates.
let plugin =
"<project><build><plugins><plugin><groupId>org.apache.maven</groupId></plugin></plugins></build></project>";
assert_eq!(target(plugin, "<groupId>org.apache.maven"), None);
// A caret in the end tag, or on a `${property}` value, is not a completion.
let text =
"<project><dependencies><dependency><groupId>org.example</groupId></dependency></dependencies></project>";
assert_eq!(target(text, "</groupId>"), None);
let property = "<project><dependencies><dependency><groupId>${example.group}</groupId></dependency></dependencies></project>";
assert_eq!(target(property, "${example.group}"), None);
// A dependency's groupId is required before its artifacts can be listed.
let no_group =
"<project><dependencies><dependency><artifactId>lib</artifactId></dependency></dependencies></project>";
let t = target(no_group, "<artifactId>lib").unwrap();
assert!(t.group.is_none());
}
#[test]
fn interpolates_the_poms_own_properties_for_siblings() {
let text = "<project>\
<properties><example.group>org.example</example.group><example.version>2.0</example.version></properties>\
<dependencies><dependency><groupId>${example.group}</groupId><artifactId>lib</artifactId><version>${example.version}</version></dependency></dependencies>\
</project>";
// The groupId the caret is typing stays a completion; the siblings resolve.
let t = target(text, "<artifactId>lib").unwrap();
assert_eq!(t.group.as_deref(), Some("org.example"));
// A `${property}` under the caret itself gets nothing to complete.
assert_eq!(target(text, "<version>${example.version}"), None);
}
#[test]
fn local_scan_lists_installed_groups_shallowest_first() {
let tmp = tempfile::tempdir().unwrap();
let repo = repo(tmp.path());
let options = |typed: &str| {
local_options(
&repo,
&PomCompletionTarget {
from: 0,
to: 0,
typed: typed.into(),
kind: PomCompletionKind::GroupId,
group: None,
artifact: None,
},
)
.into_iter()
.map(|o| o.label)
.collect::<Vec<_>>()
};
// Only directories holding artifacts are group ids: `net` and `org`
// alone never receive an install, their deeper levels do.
let org = ["org.apache.maven", "org.apache.maven.plugins", "org.eclipse.platform", "org.example"];
assert_eq!(options(""), [&["net.nu"][..], &org].concat());
assert_eq!(options("org"), org);
assert_eq!(options("org."), org);
assert_eq!(options("org.spr"), Vec::<String>::new());
assert_eq!(options("net."), ["net.nu"]);
}
#[test]
fn local_scan_lists_artifacts_of_the_group_with_their_newest_version() {
let tmp = tempfile::tempdir().unwrap();
let repo = repo(tmp.path());
let options = |typed: &str| {
local_options(
&repo,
&PomCompletionTarget {
from: 0,
to: 0,
typed: typed.into(),
kind: PomCompletionKind::ArtifactId,
group: Some("org.example".into()),
artifact: None,
},
)
.into_iter()
.map(|o| (o.label, o.detail))
.collect::<Vec<_>>()
};
assert_eq!(options(""), [("lib".to_owned(), Some("2.0".to_owned()))]);
assert_eq!(options("net"), Vec::<(String, Option<String>)>::new());
}
#[test]
fn local_scan_lists_versions_of_the_artifact_newest_first() {
let tmp = tempfile::tempdir().unwrap();
let repo = repo(tmp.path());
let options = |typed: &str| {
local_options(
&repo,
&PomCompletionTarget {
from: 0,
to: 0,
typed: typed.into(),
kind: PomCompletionKind::Version,
group: Some("org.example".into()),
artifact: Some("lib".into()),
},
)
.into_iter()
.map(|o| o.label)
.collect::<Vec<_>>()
};
assert_eq!(options(""), ["2.0", "2.0-beta-1", "1.10", "1.9.1", "1.0"]);
assert_eq!(options("1."), ["1.10", "1.9.1", "1.0"]);
// 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]
fn a_poms_text_cannot_walk_out_of_the_repository() {
let tmp = tempfile::tempdir().unwrap();
let repo = repo(tmp.path());
for group in ["../../etc", "..", "org/../.."] {
let options = local_options(
&repo,
&PomCompletionTarget {
from: 0,
to: 0,
typed: "".into(),
kind: PomCompletionKind::ArtifactId,
group: Some(group.into()),
artifact: None,
},
);
assert!(options.is_empty(), "{group}");
}
}
#[test]
fn the_scan_stops_at_the_option_cap() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("repository");
for i in 0..MAX_GROUP_OPTIONS + 10 {
let group = repo.join(format!("com/example/g{i}"));
std::fs::create_dir_all(group.join("art/1.0")).unwrap();
std::fs::write(group.join("art/1.0/art-1.0.jar"), b"").unwrap();
}
let options = local_options(
&repo,
&PomCompletionTarget {
from: 0,
to: 0,
typed: "".into(),
kind: PomCompletionKind::GroupId,
group: None,
artifact: None,
},
);
assert_eq!(options.len(), MAX_GROUP_OPTIONS);
}
#[test]
fn maven_version_order() {
let mut versions = ["2.0", "1.0", "2.0-beta-1", "2.0-alpha-2", "1.10", "1.9.1", "4.0.0-rc-5", "4.0.0-rc-13"];
versions.sort_by(|a, b| compare_versions(a, b));
assert_eq!(versions, ["1.0", "1.9.1", "1.10", "2.0-alpha-2", "2.0-beta-1", "2.0", "4.0.0-rc-5", "4.0.0-rc-13"]);
}
}
+9
Переглянути файл
@@ -270,6 +270,13 @@ fn run(
/// The first error lines of a failed build, for the tool window.
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
.lines()
.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";
assert_eq!(error_summary(text), "Failed to execute goal");
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]
+15
Переглянути файл
@@ -27,6 +27,13 @@ pub fn set_text(text: &str, owner: usize) -> std::io::Result<()> {
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`.
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<()> {
debug_assert!(MainThreadMarker::new().is_some(), "NSPasteboard access requires the main thread");
autoreleasepool(|_| {
+6
Переглянути файл
@@ -67,6 +67,8 @@ pub struct EditorAppearanceSettings {
/// Smooth caret movement curve: `snappy` | `gliding`.
pub caret_movement_style: String,
pub line_numbers: bool,
/// Gutter numbering: `absolute` | `relative` | `hybrid` (absolute on the caret line, distances elsewhere).
pub line_number_mode: String,
pub method_separators: bool,
/// Sub-options of `editor.showWhitespace`.
pub whitespace_leading: bool,
@@ -96,6 +98,7 @@ impl Default for EditorAppearanceSettings {
smooth_caret_movement: false,
caret_movement_style: "snappy".into(),
line_numbers: true,
line_number_mode: "absolute".into(),
method_separators: false,
whitespace_leading: true,
whitespace_inner: true,
@@ -267,6 +270,8 @@ pub struct SmartKeysSettings {
pub pair_quotes: bool,
pub surround_selection: bool,
pub tab_out_of_pair: bool,
/// Simultaneous `<tag></tag>` editing: renaming a tag renames its matching tag.
pub simultaneous_tag_editing: bool,
/// Reformat on paste: `none` | `indentBlock` | `indentEachLine`.
pub reformat_on_paste: String,
pub escape_on_paste: bool,
@@ -286,6 +291,7 @@ impl Default for SmartKeysSettings {
pair_quotes: true,
surround_selection: true,
tab_out_of_pair: true,
simultaneous_tag_editing: true,
reformat_on_paste: "indentEachLine".into(),
escape_on_paste: true,
smart_indent: true,
+58
Переглянути файл
@@ -8,13 +8,18 @@
use crate::{DebugSessionId, Diagnostic, DocId, RunId, ServerId, TaskId, TerminalId, TextChange};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::collections::BTreeMap;
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)]
#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
pub enum McpUiRequest {
Confirm { client: String, tool: String, subject: String, project: String, detail: Option<String> },
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)]
@@ -72,6 +77,9 @@ pub enum IdeEvent {
WorkspaceEditApplied {
id: u32,
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
/// Type), or its UI mode (a framework's root marker appeared or went).
@@ -283,6 +291,56 @@ pub enum DebugEvent {
JvmBackend {
state: JvmBackendState,
},
/// A step in the life of one request of a debugged page.
JsNetwork {
event: JsNetworkEvent,
},
/// The adapter does not report the requests of this page: there will be no `JsNetwork`.
JsNetworkUnavailable,
}
/// Which step of a request a [`JsNetworkEvent`] reports.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum JsNetworkKind {
Request,
Response,
Finished,
Failed,
}
/// The script frame that made a request, where the browser ran it (0-based line and column).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct JsInitiator {
pub function: String,
pub url: String,
pub line: u32,
pub column: u32,
}
/// What a debugged page's browser reports about a request, reduced to what the Network view
/// shows. Events of one request share `request_id`; each kind fills its own fields.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct JsNetworkEvent {
pub kind: JsNetworkKind,
pub request_id: String,
/// The browser's monotonic clock, comparable between the events of a page.
pub timestamp_ms: u64,
/// Request and response.
pub url: Option<String>,
pub method: Option<String>,
/// `Fetch`, `XHR`, `Script`, `Document`, …
pub resource_type: Option<String>,
pub status: Option<u16>,
pub mime_type: Option<String>,
/// The request's headers, or the response's.
pub headers: BTreeMap<String, String>,
/// Bytes received over the network, once finished.
pub encoded_length: Option<u64>,
pub error_text: Option<String>,
pub initiator: Option<JsInitiator>,
}
/// The backend that reads a debugged JVM's objects (instances, fields, referrers).
+1
Переглянути файл
@@ -21,6 +21,7 @@ pub mod process;
pub mod project_dir;
pub mod project_roots;
pub mod proxy_settings;
pub mod real_path;
pub mod remote_settings;
pub mod settings;
pub mod task;
+52 -1
Переглянути файл
@@ -76,7 +76,8 @@ fn decode_tool_output_with_code_page(bytes: &[u8], code_page: u32) -> String {
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 {
65001 => encoding_rs::UTF_8,
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 FILE_ENCODING: &str = "file.encoding";
const TOOL_OPTIONS: &str = "JAVA_TOOL_OPTIONS";
+53
Переглянути файл
@@ -0,0 +1,53 @@
//! Where a path really lands, and whether it reaches git's own metadata, for writes that must
//! stay inside a project and out of its `.git`.
use std::path::{Path, PathBuf};
/// `joined` after `..`/symlink resolution as far as the file system allows:
/// the canonical path when it exists, else the canonicalized deepest existing
/// ancestor with the still-missing tail resolved textually — so the location
/// of a missing path is still judged where it would really land.
pub fn resolve_real(joined: &Path) -> PathBuf {
let mut tail = Vec::new();
let mut candidate = joined.to_path_buf();
let mut real = loop {
match dunce::canonicalize(&candidate) {
Ok(real) => break real,
Err(_) => {
let Some(last) = candidate.components().next_back() else {
return joined.to_path_buf();
};
tail.push(last.as_os_str().to_owned());
if !candidate.pop() {
return joined.to_path_buf();
}
}
}
};
for part in tail.iter().rev() {
if part == ".." {
real.pop();
} else if part != "." {
real.push(part);
}
}
real
}
/// Whether a path (relative to the project root) goes through git's own `.git` folder or file.
/// git runs what it finds in `.git` (hooks, `gpg.program`, filters), so a content write must
/// never reach it. Names are normalized for trailing dots and spaces and NTFS stream suffixes;
/// the caller also checks the short-name-resolved path via [`resolve_real`].
pub fn names_git_dir(rel: &Path) -> bool {
rel.components().any(|part| {
let name = part.as_os_str().to_string_lossy();
let name = name.split(':').next().unwrap_or_default().trim_end_matches(['.', ' ']);
name.eq_ignore_ascii_case(".git")
})
}
/// A resolved write destination under `root` that reaches git's own `.git` metadata, judged by
/// the name given and by the name the file system resolves it to.
pub fn reaches_git_dir(root: &Path, real: &Path) -> bool {
real.strip_prefix(root).is_ok_and(names_git_dir) || resolve_real(real).strip_prefix(root).is_ok_and(names_git_dir)
}
+13
Переглянути файл
@@ -77,6 +77,8 @@ pub enum AutoUpload {
#[serde(default, rename_all = "camelCase")]
pub struct DeploymentSettings {
pub servers: Vec<DeploymentServer>,
/// Server groups: Upload to… on a group uploads to each of its servers.
pub groups: Vec<DeploymentGroup>,
/// Server used by Upload / Download / Sync and automatic upload; empty = none.
pub default_server: String,
pub auto_upload: AutoUpload,
@@ -92,6 +94,7 @@ impl Default for DeploymentSettings {
fn default() -> Self {
Self {
servers: Vec::new(),
groups: Vec::new(),
default_server: String::new(),
auto_upload: AutoUpload::Never,
delete_remote: false,
@@ -103,6 +106,16 @@ impl Default for DeploymentSettings {
}
}
/// Named set of deployment servers that files are uploaded to together.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct DeploymentGroup {
pub id: String,
pub name: String,
/// [`DeploymentServer::id`]s, in upload order.
pub servers: Vec<String>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum DeploymentType {
+103 -4
Переглянути файл
@@ -102,6 +102,11 @@ pub struct AgentSettings {
#[serde(deserialize_with = "or_default")]
#[specta(type = WhileRunning)]
pub while_running: WhileRunning,
/// Whether new tasks start in the agent's mode that approves everything (Claude Code's
/// bypass permissions, Codex's full access) or in one that asks. User settings only.
#[serde(deserialize_with = "or_default")]
#[specta(type = NewTaskApprovals)]
pub new_task_approvals: NewTaskApprovals,
/// Settings new tasks of an agent start with: agent id → option id → value.
#[serde(deserialize_with = "agent_defaults")]
#[specta(type = BTreeMap<String, BTreeMap<String, String>>)]
@@ -127,6 +132,7 @@ impl Default for AgentSettings {
expand_tool_output: false,
send_with: SendKey::Enter,
while_running: WhileRunning::Queue,
new_task_approvals: NewTaskApprovals::Bypass,
defaults: BTreeMap::new(),
mcp_servers: BTreeMap::new(),
}
@@ -270,15 +276,61 @@ pub enum WhileRunning {
Steer,
}
/// The approvals new agent tasks start with.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum NewTaskApprovals {
/// The agent's mode that approves everything without asking, when it has one.
#[default]
Bypass,
/// A mode in which the agent asks before edits and commands.
Ask,
}
/// Agents › MCP Server: agents use the IDE's built-in tools.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct McpServerSettings {
/// Serve the tools on 127.0.0.1 while the IDE runs.
/// Serve the tools on [`Self::address`] while the IDE runs.
pub enabled: bool,
/// 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.
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,
/// The IP address to listen on: `127.0.0.1` (this computer only, the default), another
/// address of this computer, or `0.0.0.0` / `::` for every address.
pub address: String,
/// 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;
/// The default of [`McpServerSettings::address`]: this computer only.
pub const DEFAULT_MCP_ADDRESS: &str = "127.0.0.1";
impl Default for McpServerSettings {
fn default() -> Self {
Self {
enabled: false,
run_without_confirmation: false,
agent_tab_limit: 1,
agent_archive_limit: 999,
address: DEFAULT_MCP_ADDRESS.to_owned(),
fixed_port: true,
port: DEFAULT_MCP_PORT,
fixed_token: false,
}
}
}
/// Absent profiles retain automatic discovery for existing projects.
@@ -627,6 +679,28 @@ pub struct AppearanceSettings {
pub status_bar_widgets: BTreeMap<String, bool>,
/// Customize Main Toolbar; `None` = the default toolbar.
pub main_toolbar: Option<MainToolbarSettings>,
/// Appearance › Background Image: an image shown through the IDE window.
pub background_image: BackgroundImageSettings,
}
/// Appearance › Background Image. An empty `path` shows no image.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct BackgroundImageSettings {
pub path: String,
/// Percent, 0–100.
pub opacity: u8,
/// `scale` (cover the window) | `plain` (original size) | `tile`.
pub fill: String,
/// Where the image is anchored: `center`, `top-left`, `top`, `top-right`, `left`, `right`,
/// `bottom-left`, `bottom`, `bottom-right`.
pub anchor: String,
}
impl Default for BackgroundImageSettings {
fn default() -> Self {
Self { path: String::new(), opacity: 15, fill: "scale".into(), anchor: "center".into() }
}
}
/// Main toolbar entries per side, in order: title widget ids (`project`,
@@ -697,6 +771,7 @@ impl Default for AppearanceSettings {
navigation_bar: NavigationBarPosition::StatusBar,
status_bar_widgets: BTreeMap::new(),
main_toolbar: None,
background_image: BackgroundImageSettings::default(),
}
}
}
@@ -1021,11 +1096,28 @@ pub struct PythonSettings {
pub interpreter: String,
/// Activate the interpreter's virtualenv in new terminal sessions.
pub activate_virtualenv: bool,
/// Interpreters the user added to Settings › Python › All Interpreters (executable paths).
/// Detection contributes the rest; `hidden_interpreters` removes some of them.
pub interpreters: Vec<String>,
/// Interpreters the user removed from that list, as executable paths: detection does
/// not list them again.
pub hidden_interpreters: Vec<String>,
/// Hide interpreters that belong to another project (a virtualenv inside its folder).
pub hide_other_project_interpreters: bool,
/// Show each interpreter's path under its name on the interpreters page.
pub show_interpreter_paths: bool,
}
impl Default for PythonSettings {
fn default() -> Self {
Self { interpreter: String::new(), activate_virtualenv: true }
Self {
interpreter: String::new(),
activate_virtualenv: true,
interpreters: Vec::new(),
hidden_interpreters: Vec::new(),
hide_other_project_interpreters: false,
show_interpreter_paths: true,
}
}
}
@@ -1067,6 +1159,9 @@ pub struct NodeSettings {
pub run_target: String,
/// `npm` | `pnpm` | `yarn`; empty = detect from the lock file / `packageManager`.
pub package_manager: String,
/// Runtime for running JavaScript / TypeScript files: `node` | `bun` | `deno`; empty = from the
/// project (`deno.json` → Deno, `bun.lock` → Bun, else Node.js).
pub runtime: String,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
@@ -1263,11 +1358,15 @@ pub struct TypeScriptSettings {
/// `bundled` (the server's own TypeScript), `workspace` (`node_modules/typescript`),
/// or a path to a TypeScript `lib` directory.
pub version: String,
/// tsserver heap limit in MB. `0` = automatic: 8192 when vtsls hosts the Vue
/// plugin (hybrid mode; a Vue project's program alone needs ~3 GiB, and the
/// vtsls default 3072 dies in a GC death spiral), else unset (vtsls default).
pub tsserver_max_memory: u32,
}
impl Default for TypeScriptSettings {
fn default() -> Self {
Self { version: "bundled".into() }
Self { version: "bundled".into(), tsserver_max_memory: 0 }
}
}
+2 -5
Переглянути файл
@@ -61,16 +61,13 @@ pub fn find_command(command: &str, search: &OsStr, root: &Path) -> Option<PathBu
fn is_executable(path: &Path) -> bool {
let Ok(metadata) = path.metadata() else { return false };
if !metadata.is_file() {
return false;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
metadata.permissions().mode() & 0o111 != 0
metadata.is_file() && metadata.permissions().mode() & 0o111 != 0
}
#[cfg(not(unix))]
true
metadata.is_file()
}
/// [`search_path`]'s directories, in order and once each. Only absolute ones: a
+6
Переглянути файл
@@ -266,12 +266,16 @@ pub struct DebuggerSettings {
pub inline_values: bool,
/// Data Views: list variables by name instead of the adapter's order.
pub sort_values: bool,
/// Data Views: evaluate watches while the program runs, where the debugger can (JavaScript).
pub live_watches: bool,
pub value_tooltip: bool,
/// While suspended, selecting an expression shows its value.
pub value_tooltip_on_selection: bool,
pub value_tooltip_delay: u32,
/// Stepping: do not step into library code (debugpy `justMyCode`, js-debug `skipFiles`).
pub skip_library_code: bool,
/// JavaScript: debugging a dev server (`vite`, `next dev`) also opens its page in a browser session.
pub debug_server_page: bool,
pub python: PythonDebuggerSettings,
}
@@ -285,10 +289,12 @@ impl Default for DebuggerSettings {
run_to_cursor_inlay: true,
inline_values: true,
sort_values: false,
live_watches: false,
value_tooltip: true,
value_tooltip_on_selection: true,
value_tooltip_delay: 700,
skip_library_code: true,
debug_server_page: false,
python: PythonDebuggerSettings::default(),
}
}
+3
Переглянути файл
@@ -28,6 +28,9 @@ tracing.workspace = true
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Wdk_System_Threading", "Win32_Foundation", "Win32_System_Diagnostics_ToolHelp", "Win32_System_Threading"] }
[dev-dependencies]
tracing-subscriber.workspace = true
tokio = { workspace = true, features = ["net", "rt-multi-thread", "macros", "test-util"] }
+72
Переглянути файл
@@ -9,6 +9,7 @@
use ide_core::process::executable_names;
use ide_core::{IdeError, IdeResult};
use ide_language::{DebuggerConfig, LanguageRegistry, ProgramPin};
use serde_json::Value;
use std::future::Future;
use std::path::{Path, PathBuf};
use std::pin::Pin;
@@ -212,6 +213,50 @@ pub fn tools_root() -> Option<PathBuf> {
TOOLS_ROOT.read().clone()
}
/// The browser js-debug launches for `ty`, or `None` when the launch starts no browser.
fn launched_browser(ty: &str) -> Option<&'static str> {
match ty {
"pwa-chrome" | "chrome" => Some("chrome"),
"pwa-msedge" | "msedge" => Some("msedge"),
_ => None,
}
}
/// Gives a browser launch that names no `userDataDir` a profile of `project`'s own in the
/// IDE's local data (`cache/browser-profiles`), so sign-ins and site data are still there in
/// the next session. js-debug's own default is one temporary profile for every project.
pub(crate) fn set_browser_profile(arguments: &mut Value, project: &Path) {
if let Some(local_data) = tools_root().and_then(|tools| tools.parent().map(Path::to_owned)) {
set_browser_profile_in(arguments, project, &local_data);
}
}
fn set_browser_profile_in(arguments: &mut Value, project: &Path, local_data: &Path) {
let Some(obj) = arguments.as_object_mut() else { return };
let browser = obj.get("type").and_then(Value::as_str).and_then(launched_browser);
let launch = obj.get("request").and_then(Value::as_str) == Some("launch");
let (Some(browser), true, false) = (browser, launch, obj.contains_key("userDataDir")) else { return };
let dir = local_data.join("cache").join("browser-profiles").join(profile_name(project)).join(browser);
obj.insert("userDataDir".into(), dir.to_string_lossy().into_owned().into());
}
/// `<folder name>-<hash of the path>`: readable, and distinct for projects whose folders share a name.
fn profile_name(project: &Path) -> String {
// FNV-1a: the name has to stay the same across toolchains, which `DefaultHasher` doesn't promise.
let hash = project
.to_string_lossy()
.bytes()
.fold(0xcbf2_9ce4_8422_2325_u64, |h, b| (h ^ u64::from(b)).wrapping_mul(0x0000_0100_0000_01b3));
let name: String = project
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default()
.chars()
.map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' { c } else { '_' })
.collect();
format!("{name}-{hash:016x}")
}
/// Executable directories of managed installs, read at lookup time so tools installed
/// after startup are found without a restart: `<tool>/bin`, `<tool>/venv/bin`, npm's `.bin`.
fn managed_dirs(root: &Path) -> Vec<PathBuf> {
@@ -1040,6 +1085,33 @@ mod tests {
assert!(err.message.contains("runs inside the Java language server"), "{}", err.message);
}
#[test]
fn a_browser_launch_gets_a_profile_of_its_project() {
let local = Path::new("/data/local");
let profile = |args: serde_json::Value, project: &str| {
let mut args = args;
set_browser_profile_in(&mut args, Path::new(project), local);
args.get("userDataDir").cloned()
};
let chrome = serde_json::json!({ "type": "pwa-chrome", "request": "launch" });
let dir = profile(chrome.clone(), "/work/shop").unwrap();
let dir = Path::new(dir.as_str().unwrap());
assert!(dir.starts_with("/data/local/cache/browser-profiles"), "{dir:?}");
assert!(dir.ends_with("chrome"), "{dir:?}");
let name = dir.parent().unwrap().file_name().unwrap().to_string_lossy().into_owned();
assert!(name.starts_with("shop-") && name.len() == "shop-".len() + 16, "{name}");
// The same project again: the same profile. Another folder of that name, or Edge: another one.
assert_eq!(profile(chrome.clone(), "/work/shop").unwrap(), dir.to_string_lossy().as_ref());
assert_ne!(profile(chrome.clone(), "/other/shop").unwrap(), dir.to_string_lossy().as_ref());
let edge = profile(serde_json::json!({ "type": "pwa-msedge", "request": "launch" }), "/work/shop").unwrap();
assert!(Path::new(edge.as_str().unwrap()).ends_with("msedge"), "{edge}");
// A chosen profile (or the default one, `false`) is kept; Node and attach requests get none.
let own = serde_json::json!({ "type": "pwa-chrome", "request": "launch", "userDataDir": false });
assert_eq!(profile(own, "/work/shop"), Some(false.into()));
assert_eq!(profile(serde_json::json!({ "type": "pwa-node", "request": "launch" }), "/work/shop"), None);
assert_eq!(profile(serde_json::json!({ "type": "pwa-chrome", "request": "attach" }), "/work/shop"), None);
}
#[test]
fn port_conflicts_are_recognised_for_retry() {
let in_use = IdeError::unavailable(
+195 -12
Переглянути файл
@@ -1,6 +1,7 @@
//! Attach to Process: the current user's processes, grouped by the debugger that can
//! attach to them (IDEA's `XAttachDebuggerProvider`s): JVMs with a JDWP agent, Node,
//! Python (debugpy injection), Go binaries (Delve) and anything else natively (LLDB).
//! Python (debugpy injection), .NET (netcoredbg), Go binaries (Delve) and anything else
//! natively (LLDB).
use crate::adapter;
use ide_core::{IdeError, IdeResult};
@@ -14,6 +15,7 @@ pub enum AttachKind {
Java,
Node,
Python,
Dotnet,
Go,
Native,
}
@@ -95,13 +97,29 @@ fn file_name(path: &str) -> &str {
path.rsplit(['/', '\\']).next().unwrap_or(path)
}
/// A .NET app: the `dotnet` host, or an apphost next to its `<name>.runtimeconfig.json`.
pub fn is_dotnet(exe: &str) -> bool {
let path = Path::new(exe);
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else { return false };
if stem == "dotnet" {
return true;
}
path.is_absolute() && path.with_file_name(format!("{stem}.runtimeconfig.json")).is_file()
}
fn is_python(name: &str) -> bool {
let n = name.strip_suffix(".exe").unwrap_or(name);
n == "Python" || n.strip_prefix("python").is_some_and(|v| v.chars().all(|c| c.is_ascii_digit() || c == '.'))
}
/// Classifies a process; `is_go` is asked only for processes no interpreter claims.
pub fn classify(p: &RawProcess, is_go: impl Fn(&str) -> bool, native_adapter: &str) -> Option<AttachableProcess> {
/// Classifies a process; `is_dotnet` and `is_go` are asked only for processes no
/// interpreter claims.
pub fn classify(
p: &RawProcess,
is_dotnet: impl Fn(&str) -> bool,
is_go: impl Fn(&str) -> bool,
native_adapter: &str,
) -> Option<AttachableProcess> {
let name = file_name(&p.exe).to_owned();
let make = |kind, adapter: &str, addr: Option<(Option<String>, u16)>| {
let (host, port) = match addr {
@@ -129,6 +147,9 @@ pub fn classify(p: &RawProcess, is_go: impl Fn(&str) -> bool, native_adapter: &s
if is_python(&name) {
return make(AttachKind::Python, "debugpy", debugpy_listen(&p.args));
}
if is_dotnet(&p.exe) {
return make(AttachKind::Dotnet, "netcoredbg", None);
}
if is_go(&p.exe) {
return make(AttachKind::Go, "delve", None);
}
@@ -143,7 +164,7 @@ const GO_SCAN_LIMIT: u64 = 96 << 20;
pub fn is_go_binary(exe: &str) -> bool {
use std::io::Read;
let skip = ["/System/", "/usr/", "/bin/", "/sbin/", "/Library/Apple/", "/Applications/"];
if skip.iter().any(|p| exe.starts_with(p)) || !Path::new(exe).is_absolute() {
if skip.iter().any(|p| exe.starts_with(p)) || is_windows_system(exe) || !Path::new(exe).is_absolute() {
return false;
}
let Ok(file) = std::fs::File::open(exe) else { return false };
@@ -165,6 +186,17 @@ pub fn is_go_binary(exe: &str) -> bool {
}
}
/// Windows' own and installed programs (`C:\Windows\…`, `C:\Program Files…`), like the
/// system locations skipped on Unix.
fn is_windows_system(exe: &str) -> bool {
let lower = exe.to_ascii_lowercase();
lower.get(1..).is_some_and(|rest| {
rest.starts_with(":\\windows\\")
|| rest.starts_with(":\\program files\\")
|| rest.starts_with(":\\program files (x86)\\")
})
}
/// The current user's processes (without this one).
#[cfg(unix)]
pub fn list_processes() -> IdeResult<Vec<RawProcess>> {
@@ -181,7 +213,7 @@ pub fn list_processes() -> IdeResult<Vec<RawProcess>> {
let (pid, rest) = line.split_once(char::is_whitespace)?;
Some((pid.parse().ok()?, rest.trim().to_owned()))
};
// `comm` is the executable path on macOS; on Linux it is truncated, so read /proc.
// `comm` is the executable path as started on macOS; on Linux it is truncated, so read /proc.
let exes: HashMap<u32, String> =
if cfg!(target_os = "linux") { HashMap::new() } else { ps("pid=,comm=")?.lines().filter_map(split).collect() };
let me = std::process::id();
@@ -190,9 +222,8 @@ pub fn list_processes() -> IdeResult<Vec<RawProcess>> {
.filter_map(split)
.filter(|(pid, _)| *pid != me)
.map(|(pid, args)| {
let exe = exes
.get(&pid)
.cloned()
let exe = exe_path(pid)
.or_else(|| exes.get(&pid).cloned())
.or_else(|| std::fs::read_link(format!("/proc/{pid}/exe")).ok().map(|p| p.display().to_string()))
.unwrap_or_else(|| args.split_whitespace().next().unwrap_or_default().to_owned());
RawProcess { pid, exe, args }
@@ -200,9 +231,134 @@ pub fn list_processes() -> IdeResult<Vec<RawProcess>> {
.collect())
}
#[cfg(not(unix))]
/// The processes this user may open (without this one): system and other users'
/// processes refuse `PROCESS_QUERY_LIMITED_INFORMATION` and are left out.
#[cfg(windows)]
pub fn list_processes() -> IdeResult<Vec<RawProcess>> {
Err(IdeError::unavailable("Attach to Process is not supported on this platform yet"))
windows_processes::list()
}
#[cfg(windows)]
mod windows_processes {
use super::RawProcess;
use ide_core::{IdeError, IdeResult};
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle};
use windows_sys::Wdk::System::Threading::{NtQueryInformationProcess, ProcessCommandLineInformation};
use windows_sys::Win32::Foundation::{INVALID_HANDLE_VALUE, UNICODE_STRING};
use windows_sys::Win32::System::Diagnostics::ToolHelp::{
CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW, TH32CS_SNAPPROCESS,
};
use windows_sys::Win32::System::Threading::{
OpenProcess, PROCESS_NAME_WIN32, PROCESS_QUERY_LIMITED_INFORMATION, QueryFullProcessImageNameW,
};
pub fn list() -> IdeResult<Vec<RawProcess>> {
let me = std::process::id();
Ok(pids()?
.into_iter()
.filter(|&pid| pid != 0 && pid != me)
.filter_map(|pid| {
// SAFETY: plain call; a non-null result is a process handle owned here.
let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
if handle.is_null() {
return None;
}
// SAFETY: as above.
let process = unsafe { OwnedHandle::from_raw_handle(handle) };
let exe = image_path(&process)?;
let args = command_line(&process).unwrap_or_else(|| exe.clone());
Some(RawProcess { pid, exe, args })
})
.collect())
}
fn pids() -> IdeResult<Vec<u32>> {
let mut out = Vec::new();
// SAFETY: a snapshot handle owned here and an entry sized as the API requires.
unsafe {
let snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
if snapshot.is_null() || snapshot == INVALID_HANDLE_VALUE {
return Err(IdeError::unavailable(format!(
"cannot list processes: {}",
std::io::Error::last_os_error()
)));
}
let snapshot = OwnedHandle::from_raw_handle(snapshot);
let mut entry = PROCESSENTRY32W { dwSize: size_of::<PROCESSENTRY32W>() as u32, ..Default::default() };
let mut more = Process32FirstW(snapshot.as_raw_handle(), &mut entry);
while more != 0 {
out.push(entry.th32ProcessID);
more = Process32NextW(snapshot.as_raw_handle(), &mut entry);
}
}
Ok(out)
}
fn image_path(process: &OwnedHandle) -> Option<String> {
let mut buf = vec![0u16; 32 * 1024];
let mut len = buf.len() as u32;
// SAFETY: a live process handle with query access; `len` is the buffer's capacity.
let ok = unsafe {
QueryFullProcessImageNameW(process.as_raw_handle(), PROCESS_NAME_WIN32, buf.as_mut_ptr(), &mut len)
};
(ok != 0).then(|| String::from_utf16_lossy(&buf[..len as usize]))
}
/// The command line (Windows 8.1+), read with the same limited query access.
fn command_line(process: &OwnedHandle) -> Option<String> {
let mut size = 0u32;
// SAFETY: a size query with no buffer; it fails and reports the size it needs.
unsafe {
NtQueryInformationProcess(
process.as_raw_handle(),
ProcessCommandLineInformation,
std::ptr::null_mut(),
0,
&mut size,
)
};
if (size as usize) < size_of::<UNICODE_STRING>() {
return None;
}
// u64 elements keep the UNICODE_STRING header aligned.
let mut buf = vec![0u64; (size as usize).div_ceil(8)];
// SAFETY: a buffer of at least `size` bytes.
let status = unsafe {
NtQueryInformationProcess(
process.as_raw_handle(),
ProcessCommandLineInformation,
buf.as_mut_ptr().cast(),
size,
&mut size,
)
};
if status < 0 {
return None;
}
// SAFETY: on success the buffer starts with a UNICODE_STRING whose text lies inside it.
let text = unsafe {
let s = &*buf.as_ptr().cast::<UNICODE_STRING>();
if s.Buffer.is_null() {
return None;
}
std::slice::from_raw_parts(s.Buffer, usize::from(s.Length) / 2)
};
Some(String::from_utf16_lossy(text))
}
}
/// The full executable path: `comm` keeps a relative path a process was started with.
#[cfg(target_os = "macos")]
fn exe_path(pid: u32) -> Option<String> {
let mut buf = vec![0u8; libc::PROC_PIDPATHINFO_MAXSIZE as usize];
// SAFETY: the buffer holds `PROC_PIDPATHINFO_MAXSIZE` bytes, as the call requires.
let n = unsafe { libc::proc_pidpath(i32::try_from(pid).ok()?, buf.as_mut_ptr().cast(), buf.len() as u32) };
(n > 0).then(|| String::from_utf8_lossy(&buf[..n as usize]).into_owned())
}
#[cfg(all(unix, not(target_os = "macos")))]
fn exe_path(_pid: u32) -> Option<String> {
None
}
/// Node's default inspector port, which `SIGUSR1` opens.
@@ -244,7 +400,7 @@ pub fn attachable_processes() -> IdeResult<Vec<AttachableProcess>> {
let cwd = std::env::temp_dir();
let native = if adapter::resolve_binary("codelldb", &cwd).is_some() { "codelldb" } else { "lldb-dap" };
let mut out: Vec<AttachableProcess> =
list_processes()?.iter().filter_map(|p| classify(p, is_go_binary, native)).collect();
list_processes()?.iter().filter_map(|p| classify(p, is_dotnet, is_go_binary, native)).collect();
out.sort_by(|a, b| a.kind.cmp(&b.kind).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())));
Ok(out)
}
@@ -258,7 +414,8 @@ mod tests {
}
fn kind(exe: &str, args: &str) -> Option<(AttachKind, Option<u16>)> {
classify(&raw(exe, args), |e| e.ends_with("/gosrv"), "codelldb").map(|p| (p.kind, p.port))
classify(&raw(exe, args), |e| e.ends_with("/Api"), |e| e.ends_with("/gosrv"), "codelldb")
.map(|p| (p.kind, p.port))
}
#[test]
@@ -292,11 +449,37 @@ mod tests {
kind("/Library/Frameworks/Python.framework/Versions/3.12/Resources/Python.app/Contents/MacOS/Python", "x"),
Some((AttachKind::Python, None))
);
assert_eq!(kind("/home/me/Api", "./Api"), Some((AttachKind::Dotnet, None)));
assert_eq!(kind("/home/me/gosrv", "./gosrv"), Some((AttachKind::Go, None)));
assert_eq!(kind("/home/me/a.out", "./a.out"), Some((AttachKind::Native, None)));
assert!(!is_python("pythonista"));
}
#[test]
fn detects_dotnet_hosts_and_apphosts() {
let dir = std::env::temp_dir().join(format!("openidea-attach-dotnet-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let app = dir.join("Api");
std::fs::write(&app, b"").unwrap();
std::fs::write(dir.join("Api.runtimeconfig.json"), b"{}").unwrap();
let tool = dir.join("tool");
std::fs::write(&tool, b"").unwrap();
assert!(is_dotnet(app.to_str().unwrap()));
assert!(!is_dotnet(tool.to_str().unwrap()));
assert!(is_dotnet("/usr/local/share/dotnet/dotnet"));
assert!(is_dotnet("dotnet"));
assert!(!is_dotnet("Api"));
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn skips_windows_system_programs() {
assert!(is_windows_system(r"C:\Windows\System32\svchost.exe"));
assert!(is_windows_system(r"c:\program files (x86)\App\app.exe"));
assert!(!is_windows_system(r"D:\src\srv\srv.exe"));
assert!(!is_windows_system("/usr/bin/true"));
}
#[test]
fn detects_go_binaries_by_buildinfo() {
let dir = std::env::temp_dir().join(format!("openidea-attach-{}", std::process::id()));
+283
Переглянути файл
@@ -0,0 +1,283 @@
//! Requests of vscode-js-debug's own, beyond the Debug Adapter Protocol: what a page in a
//! browser offers a debugger besides stepping (breakpoints on DOM events and requests).
//! Each is a typed method; a js-debug build that lacks one answers with an error, which is
//! reported as the feature being unavailable.
use crate::session::Session;
use crate::{DebugManager, DebugSessionId, REQUEST_TIMEOUT};
use ide_core::event::{JsInitiator, JsNetworkEvent, JsNetworkKind};
use ide_core::{IdeError, IdeResult};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use specta::Type;
use std::collections::BTreeMap;
pub(crate) const ADAPTER: &str = "js-debug";
/// Whether `session` debugs a page: the child js-debug starts for a browser launch or attach.
/// The launch's own session only owns the browser.
pub(crate) fn is_page(session: &Session) -> bool {
session.adapter == ADAPTER
&& session.parent.is_some()
&& matches!(session.launch_type.get().map(String::as_str), Some("pwa-chrome" | "pwa-msedge"))
}
/// Waits until the adapter answered the `launch` of `session`. js-debug sets a page's session
/// up between `configurationDone` and that answer; a request of its own that arrives then is
/// answered and has no effect.
async fn launched(session: &Session) -> bool {
let mut launched = session.launched.subscribe();
let answered = tokio::time::timeout(REQUEST_TIMEOUT, launched.wait_for(|answered| *answered)).await;
matches!(answered, Ok(Ok(_)))
}
impl DebugManager {
/// One of js-debug's requests for pages, sent to the page `s` once its session has started.
async fn js_page_request(&self, s: DebugSessionId, command: &str, arguments: Value) -> IdeResult<Value> {
let session = self.session(s)?;
if !is_page(&session) {
return Err(IdeError::unavailable("this debug session is not a page in a browser"));
}
if !launched(&session).await {
return Err(IdeError::unavailable("the page's debug session did not finish starting"));
}
session
.conn
.request_timeout(command, arguments, REQUEST_TIMEOUT)
.await
.map_err(|e| IdeError::unavailable(format!("the JavaScript debugger refused `{command}`: {e}")))
}
/// Breakpoints on events (`listener:click`, `instrumentation:setTimeout`) and on requests
/// whose URL contains one of `xhr`. The lists replace the page's previous ones.
pub async fn js_set_custom_breakpoints(
&self,
s: DebugSessionId,
ids: Vec<String>,
xhr: Vec<String>,
) -> IdeResult<()> {
self.js_page_request(s, "setCustomBreakpoints", json!({ "ids": ids, "xhr": xhr })).await?;
Ok(())
}
}
/// The browser events js-debug is asked to pass on (`enableNetworking`'s `mirrorEvents`).
pub(crate) const NETWORK_EVENTS: [&str; 4] =
["requestWillBeSent", "responseReceived", "loadingFinished", "loadingFailed"];
/// Response bodies shown are cut at this many bytes.
pub const RESPONSE_BODY_LIMIT: usize = 1_048_576;
/// The body of a response, as text or as the browser's base64 for binary content.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct JsResponseBody {
pub body: String,
pub base64_encoded: bool,
/// The body was longer than [`RESPONSE_BODY_LIMIT`] and is cut there.
pub truncated: bool,
}
fn text(v: &Value, key: &str) -> Option<String> {
v.get(key).and_then(Value::as_str).map(str::to_owned)
}
fn headers(v: &Value) -> BTreeMap<String, String> {
v.get("headers")
.and_then(Value::as_object)
.map(|h| h.iter().filter_map(|(k, v)| Some((k.clone(), v.as_str()?.to_owned()))).collect())
.unwrap_or_default()
}
/// The frame a request is attributed to: the innermost one outside `node_modules`, looked
/// for through the stacks that scheduled this one (an `await`, a timer), since a request sent
/// through a library has the library innermost. A stack of library frames only gives its first.
fn initiator(data: &Value) -> Option<JsInitiator> {
let stack = data.pointer("/initiator/stack")?;
let frames = |stack: &Value| stack.get("callFrames").and_then(Value::as_array).cloned().unwrap_or_default();
let stacks = std::iter::successors(Some(stack), |stack| stack.get("parent")).take(32);
let all: Vec<Value> = stacks.flat_map(frames).collect();
let own = |frame: &&Value| {
frame.get("url").and_then(Value::as_str).is_some_and(|url| !url.is_empty() && !url.contains("/node_modules/"))
};
let frame = all.iter().find(own).or(all.first())?;
let number = |key: &str| frame.get(key).and_then(Value::as_u64).and_then(|n| u32::try_from(n).ok()).unwrap_or(0);
Some(JsInitiator {
function: text(frame, "functionName").unwrap_or_default(),
url: text(frame, "url")?,
line: number("lineNumber"),
column: number("columnNumber"),
})
}
/// js-debug's `networkEvent` (`{event, data}`, `data` being the browser's own parameters of
/// that event) as a [`JsNetworkEvent`]; `None` for events the Network view has no use for.
pub(crate) fn network_event(body: &Value) -> Option<JsNetworkEvent> {
let data = body.get("data")?;
let kind = match body.get("event").and_then(Value::as_str)? {
"requestWillBeSent" => JsNetworkKind::Request,
"responseReceived" => JsNetworkKind::Response,
"loadingFinished" => JsNetworkKind::Finished,
"loadingFailed" => JsNetworkKind::Failed,
_ => return None,
};
let part = match kind {
JsNetworkKind::Request => data.get("request"),
JsNetworkKind::Response => data.get("response"),
_ => None,
};
let part = part.unwrap_or(&Value::Null);
Some(JsNetworkEvent {
kind,
request_id: text(data, "requestId")?,
// Seconds with a fraction.
timestamp_ms: (data.get("timestamp").and_then(Value::as_f64).unwrap_or(0.0).max(0.0) * 1000.0).round() as u64,
url: text(part, "url"),
method: text(part, "method"),
resource_type: text(data, "type"),
status: part.get("status").and_then(Value::as_u64).and_then(|s| u16::try_from(s).ok()),
mime_type: text(part, "mimeType"),
headers: headers(part),
encoded_length: data.get("encodedDataLength").and_then(Value::as_f64).map(|n| n.max(0.0) as u64),
error_text: text(data, "errorText"),
initiator: if kind == JsNetworkKind::Request { initiator(data) } else { None },
})
}
/// `networkCall getResponseBody`'s answer, cut at `limit` bytes on a character boundary.
fn response_body(answer: &Value, limit: usize) -> JsResponseBody {
let mut body = text(answer, "body").unwrap_or_default();
let truncated = body.len() > limit;
if truncated {
let mut end = limit;
while !body.is_char_boundary(end) {
end -= 1;
}
body.truncate(end);
}
JsResponseBody {
body,
base64_encoded: answer.get("base64Encoded").and_then(Value::as_bool).unwrap_or(false),
truncated,
}
}
/// The arguments of `enableNetworking`, which has a page report its requests
/// ([`ide_core::event::DebugEvent::JsNetwork`]). A page session sends it as it starts.
pub(crate) fn networking_arguments() -> Value {
json!({ "mirrorEvents": NETWORK_EVENTS })
}
impl DebugManager {
/// The body of the response to `request_id`, while the browser still has it.
pub async fn js_response_body(&self, s: DebugSessionId, request_id: String) -> IdeResult<JsResponseBody> {
let call = json!({ "method": "getResponseBody", "params": { "requestId": request_id } });
let answer = self.js_page_request(s, "networkCall", call).await?;
Ok(response_body(&answer, RESPONSE_BODY_LIMIT))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn event(kind: &str, data: Value) -> Option<ide_core::event::JsNetworkEvent> {
network_event(&json!({ "event": kind, "data": data }))
}
#[test]
fn a_request_carries_what_was_asked_and_who_asked() {
let e = event(
"requestWillBeSent",
json!({
"requestId": "76695.31", "timestamp": 611807.5, "type": "Fetch",
"request": { "method": "GET", "url": "http://127.0.0.1:5195/src/main.ts", "headers": { "Accept": "*/*", "X-N": 1 } },
"initiator": { "type": "script", "stack": { "callFrames": [
{ "functionName": "load", "url": "http://127.0.0.1:5195/src/App.vue", "lineNumber": 9, "columnNumber": 2 }
] } }
}),
)
.unwrap();
assert_eq!(e.kind, JsNetworkKind::Request);
assert_eq!((e.request_id.as_str(), e.timestamp_ms), ("76695.31", 611_807_500));
assert_eq!(e.url.as_deref(), Some("http://127.0.0.1:5195/src/main.ts"));
assert_eq!((e.method.as_deref(), e.resource_type.as_deref()), (Some("GET"), Some("Fetch")));
// Only text headers are kept.
assert_eq!(e.headers.len(), 1);
assert_eq!(e.headers["Accept"], "*/*");
let from = e.initiator.unwrap();
assert_eq!((from.function.as_str(), from.line, from.column), ("load", 9, 2));
assert_eq!(from.url, "http://127.0.0.1:5195/src/App.vue");
}
#[test]
fn a_request_made_through_a_library_is_asked_by_the_code_that_called_the_library() {
let frame = |function: &str, url: &str| json!({ "functionName": function, "url": url, "lineNumber": 1, "columnNumber": 0 });
let lib = "http://localhost:5173/node_modules/.vite/deps/http-client.js";
let asked_by = |stack: Value| {
let data = json!({ "requestId": "1", "timestamp": 1.0, "request": { "method": "GET", "url": "http://localhost/api" },
"initiator": { "type": "script", "stack": stack } });
event("requestWillBeSent", data).unwrap().initiator.unwrap().function
};
// The innermost frame is the library's; the project's caller is further out.
let direct = json!({ "callFrames": [frame("dispatch", lib), frame("request", lib), frame("loadItems", "http://localhost:5173/src/api.ts")] });
assert_eq!(asked_by(direct), "loadItems");
// Across an `await`, the caller is in the stack that scheduled this one.
let scheduled = json!({ "callFrames": [frame("dispatch", lib)],
"parent": { "callFrames": [frame("then", lib)], "parent": { "callFrames": [frame("onMounted", "http://localhost:5173/src/App.vue")] } } });
assert_eq!(asked_by(scheduled), "onMounted");
// A library on its own: the frame that sent the request.
assert_eq!(asked_by(json!({ "callFrames": [frame("dispatch", lib), frame("poll", lib)] })), "dispatch");
}
#[test]
fn a_response_a_finish_and_a_failure_carry_their_outcome() {
let response = event(
"responseReceived",
json!({ "requestId": "7", "timestamp": 2.0, "type": "Script", "response": {
"url": "http://localhost/a.js", "status": 200, "mimeType": "text/javascript",
"headers": { "Content-Type": "text/javascript" }
} }),
)
.unwrap();
assert_eq!(response.kind, JsNetworkKind::Response);
assert_eq!((response.status, response.mime_type.as_deref()), (Some(200), Some("text/javascript")));
assert_eq!(response.headers["Content-Type"], "text/javascript");
let finished =
event("loadingFinished", json!({ "requestId": "7", "timestamp": 2.5, "encodedDataLength": 1234 })).unwrap();
assert_eq!((finished.kind, finished.encoded_length), (JsNetworkKind::Finished, Some(1234)));
let failed =
event("loadingFailed", json!({ "requestId": "8", "timestamp": 3.0, "errorText": "net::ERR_FAILED" }))
.unwrap();
assert_eq!((failed.kind, failed.error_text.as_deref()), (JsNetworkKind::Failed, Some("net::ERR_FAILED")));
}
#[test]
fn events_that_name_no_request_or_are_not_mirrored_are_dropped() {
assert!(event("requestWillBeSent", json!({ "timestamp": 1.0 })).is_none());
assert!(event("dataReceived", json!({ "requestId": "7", "timestamp": 1.0 })).is_none());
assert!(network_event(&json!({ "data": { "requestId": "7" } })).is_none());
// A request made by the page's parser has no script behind it.
let parser = event(
"requestWillBeSent",
json!({ "requestId": "9", "timestamp": 1.0, "request": { "method": "GET", "url": "http://localhost/" },
"initiator": { "type": "parser" } }),
)
.unwrap();
assert!(parser.initiator.is_none());
}
#[test]
fn a_response_body_is_cut_at_the_limit_on_a_character() {
let whole = response_body(&json!({ "body": "héllo", "base64Encoded": false }), 100);
assert_eq!((whole.body.as_str(), whole.base64_encoded, whole.truncated), ("héllo", false, false));
// `é` is two bytes: a cut inside it backs off to the character before.
let cut = response_body(&json!({ "body": "héllo" }), 2);
assert_eq!((cut.body.as_str(), cut.truncated), ("h", true));
let binary = response_body(&json!({ "body": "AAEC", "base64Encoded": true }), 100);
assert!(binary.base64_encoded && !binary.truncated);
assert_eq!(response_body(&json!({}), 100).body, "");
}
}
+44 -2
Переглянути файл
@@ -13,6 +13,7 @@ pub mod attach;
mod cargo;
pub mod connection;
pub mod jdwp;
mod js_debug;
mod jvm_memory;
pub mod kotlin;
pub mod kotlin_lsp;
@@ -23,6 +24,7 @@ use connection::{Connection, DapError};
use futures::future::join_all;
use ide_core::{DebugSessionId, IdeError, IdeResult, SharedSink};
use ide_language::LanguageRegistry;
pub use js_debug::JsResponseBody;
pub use jvm_memory::*;
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
@@ -136,6 +138,11 @@ pub struct DebugCapabilities {
pub jvm_instances: bool,
/// The session can be asked for a heap dump ([`DebugManager::jvm_heap_dump`]).
pub jvm_heap: bool,
/// The session debugs a page in a browser: js-debug's requests for pages apply
/// ([`DebugManager::js_set_custom_breakpoints`]).
pub js_page: bool,
/// The adapter evaluates expressions while the program runs, without a stack frame.
pub live_evaluate: bool,
}
/// Result of [`DebugManager::redefine_classes`].
@@ -393,6 +400,16 @@ fn no_session(s: DebugSessionId) -> IdeError {
IdeError::not_found(format!("no debug session {}", s.0))
}
/// The name of a child session: the adapter's, or the parent's when it gives none. js-debug
/// names a browser page's session after the page the browser starts on, before the URL
/// loads (`about:blank`, or nothing in a new profile), which says nothing about what is debugged.
fn child_name(configuration: &Value, parent: &str) -> String {
match configuration.get("name").and_then(Value::as_str) {
Some(name) if !name.trim().is_empty() && name != "about:blank" => name.to_owned(),
_ => parent.to_owned(),
}
}
/// Builds the `launch`/`attach` arguments: our fields first, then `extra_json` merged on top.
pub fn launch_arguments(config: &LaunchConfig) -> IdeResult<Value> {
// VS Code puts `type`, `request` and `name` in the arguments; js-debug picks launch
@@ -580,6 +597,8 @@ pub(crate) fn capabilities_from(caps: &Value) -> DebugCapabilities {
jvm_memory: false,
jvm_instances: false,
jvm_heap: false,
js_page: false,
live_evaluate: false,
}
}
@@ -589,6 +608,8 @@ pub(crate) fn session_capabilities(session: &Session) -> DebugCapabilities {
caps.jvm_memory = JVM_MEMORY_ADAPTERS.contains(&session.adapter.as_str());
caps.jvm_instances = session.jvm_inspector.lock().is_some();
caps.jvm_heap = JVM_MEMORY_ADAPTERS.contains(&session.adapter.as_str());
caps.js_page = js_debug::is_page(session);
caps.live_evaluate = session.adapter == js_debug::ADAPTER;
caps
}
@@ -744,6 +765,9 @@ impl DebugManager {
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into()));
let dbg = adapter::find_config(&self.languages, &config.adapter)
.ok_or_else(|| IdeError::unavailable(format!("unknown debug adapter `{}`", config.adapter)))?;
if config.adapter == "js-debug" {
adapter::set_browser_profile(&mut arguments, &cwd);
}
let mut spec = adapter::resolve(&dbg, cwd.clone());
if config.adapter == kotlin::ADAPTER
&& let Some(home) = arguments
@@ -872,8 +896,7 @@ impl DebugManager {
request: String,
configuration: Value,
) -> IdeResult<()> {
let name =
configuration.get("name").and_then(Value::as_str).map(str::to_owned).unwrap_or_else(|| parent.name.clone());
let name = child_name(&configuration, &parent.name);
let mut configuration = configuration;
if let Some(obj) = configuration.as_object_mut() {
obj.entry("request").or_insert_with(|| request.clone().into());
@@ -921,6 +944,9 @@ impl DebugManager {
.flatten();
let session =
Arc::new(Session::new(id, owner, name, adapter_name, parent, request, conn, spec, io.port, io.child));
if let Some(launch_type) = arguments.get("type").and_then(Value::as_str) {
let _ = session.launch_type.set(launch_type.to_owned());
}
// rdbg execs the debuggee in its own process and sends no DAP process event.
if let Some(pid) = rdbg_pid {
session.debuggee_pid.store(i64::from(pid), Ordering::Release);
@@ -1155,6 +1181,22 @@ impl DebugManager {
reply.await.unwrap_or(Err(DapError::Closed))?;
}
}
s.launched.send_replace(true);
// A page reports its requests from here on. This is the earliest js-debug takes the
// request (before its answer to `launch` the page has no adapter and the request is
// never answered), and the page is already loading: its document and first scripts
// are not in the list.
if js_debug::is_page(s) {
let answer = s.conn.send_request("enableNetworking", js_debug::networking_arguments())?;
let session = s.clone();
// Not waited for here: the page runs on. A refusal is told to the Network view,
// which would otherwise wait for requests that never come.
tokio::spawn(async move {
if matches!(answer.await, Ok(Err(_))) {
session.emit(ide_core::event::DebugEvent::JsNetworkUnavailable);
}
});
}
if let Some(link) = s.jdwp_link.lock().as_ref() {
link.release();
}
+13
Переглянути файл
@@ -57,6 +57,8 @@ pub(crate) struct Session {
pub job: Mutex<Option<ide_core::process::Job>>,
/// Set once `configurationDone` was sent; breakpoint updates go out live after that.
pub configured: AtomicBool,
/// Set once the adapter answered `launch` / `attach`, after the configuration.
pub launched: watch::Sender<bool>,
pub initialized: watch::Sender<bool>,
pub startup_cancel: ide_core::CancellationToken,
pub output_drained: watch::Sender<bool>,
@@ -89,6 +91,9 @@ pub(crate) struct Session {
progress: Mutex<HashMap<String, TaskId>>,
/// Relay between a Kotlin adapter and its JVM. Dropping it closes both sockets.
pub jdwp_link: parking_lot::Mutex<Option<ide_jdwp::JdwpLink>>,
/// The `type` of the launch or attach request (js-debug's `pwa-node`, `pwa-chrome`); a child
/// session's comes from the configuration the adapter started it with.
pub launch_type: std::sync::OnceLock<String>,
pub jvm_inspector: parking_lot::Mutex<Option<Arc<dyn ide_jvm_inspect::JvmInspector>>>,
/// One inspector request per session at a time.
pub inspect_lock: tokio::sync::Mutex<()>,
@@ -230,6 +235,7 @@ impl Session {
#[cfg(windows)]
job: Mutex::new(job),
configured: AtomicBool::new(false),
launched: watch::channel(false).0,
initialized: watch::channel(false).0,
startup_cancel: ide_core::CancellationToken::new(),
output_drained: watch::channel(true).0,
@@ -248,6 +254,7 @@ impl Session {
resolved_paths: Mutex::new(HashMap::new()),
progress: Mutex::new(HashMap::new()),
jdwp_link: parking_lot::Mutex::new(None),
launch_type: std::sync::OnceLock::new(),
jvm_inspector: parking_lot::Mutex::new(None),
inspect_lock: tokio::sync::Mutex::default(),
ended: ide_core::CancellationToken::new(),
@@ -832,6 +839,12 @@ async fn on_event(mgr: &Arc<DebugManager>, s: &Arc<Session>, event: &str, mut bo
.map(|a| a.iter().filter_map(Value::as_str).map(str::to_owned).collect())
.unwrap_or_default(),
}),
// js-debug, after `enableNetworking`.
"networkEvent" => {
if let Some(event) = crate::js_debug::network_event(&body) {
s.emit(DebugEvent::JsNetwork { event });
}
}
_ => tracing::trace!("dap: ignored event {event}"),
}
}
+212 -5
Переглянути файл
@@ -146,6 +146,10 @@ struct Script {
delay_initialize: Option<Duration>,
initialized_delay: Duration,
launch_delay: Duration,
/// Answer a child session's `launch` this long after its `configurationDone`, reading
/// requests meanwhile. Like js-debug, one of `accept` that arrives in between is answered
/// and has no effect: the log gets a `dropped` entry for it.
child_launch_delay: Duration,
/// Leave startup pending before `initialized` (false) or after configuration (true).
pending_startup: Option<bool>,
startup_error: Option<&'static str>,
@@ -154,8 +158,15 @@ struct Script {
/// `launch`, and a `configurationDone` sent while it runs is refused with `notStopped`.
gdb15_starts_at_launch: bool,
launch_after_configuration: bool,
/// Requests of the adapter's own (js-debug's `setCustomBreakpoints`…) answered with an empty body.
accept: &'static [&'static str],
/// Requests of the adapter's own answered with a body and followed by events.
custom: Option<fn(&Value) -> Option<CustomReply>>,
}
/// The answer to a request of the adapter's own: the response body, then events (name, body).
type CustomReply = (Value, Vec<(&'static str, Value)>);
impl Default for Script {
fn default() -> Self {
Self {
@@ -181,11 +192,14 @@ impl Default for Script {
delay_initialize: None,
initialized_delay: Duration::ZERO,
launch_delay: Duration::ZERO,
child_launch_delay: Duration::ZERO,
pending_startup: None,
startup_error: None,
close_during_startup: false,
gdb15_starts_at_launch: false,
launch_after_configuration: false,
accept: &[],
custom: None,
}
}
}
@@ -218,10 +232,15 @@ impl AdapterLauncher for FakeLauncher {
}
async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream: S, log: Log, script: Script) {
let (r, mut w) = tokio::io::split(stream);
let (r, w) = tokio::io::split(stream);
// Shared with the task that answers a child's `launch` late (`child_launch_delay`).
let w = Arc::new(tokio::sync::Mutex::new(w));
let mut r = BufReader::new(r);
let mut seq = 1000;
let mut pending_launch: Option<i64> = None;
let mut child = false;
// Between `configurationDone` and the answer to `launch`.
let starting = Arc::new(std::sync::atomic::AtomicBool::new(false));
let mut held: Vec<Value> = Vec::new();
let mut stopped = false;
let mut running = false;
@@ -230,7 +249,7 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
let mut v: Value = $v;
seq += 1;
v["seq"] = seq.into();
framing::write_message(&mut w, &serde_json::to_vec(&v).unwrap()).await.unwrap();
framing::write_message(&mut *w.lock().await, &serde_json::to_vec(&v).unwrap()).await.unwrap();
}};
}
macro_rules! ok {
@@ -292,6 +311,7 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
continue;
}
pending_launch = msg["seq"].as_i64();
child = msg["arguments"]["isChild"] == true;
if let Some(args) = &script.run_in_terminal {
send!(
json!({"type": "request", "command": "runInTerminal", "arguments": {"kind": "integrated", "args": args, "cwd": std::env::temp_dir()}})
@@ -299,7 +319,8 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
}
if script.start_debugging && msg["arguments"]["isChild"] != true {
send!(
json!({"type": "request", "command": "startDebugging", "arguments": {"request": "launch", "configuration": {"name": "child", "isChild": true}}})
// Like js-debug, the child's configuration repeats the launch's `type`.
json!({"type": "request", "command": "startDebugging", "arguments": {"request": "launch", "configuration": {"name": "child", "isChild": true, "type": msg["arguments"]["type"]}}})
);
}
if script.pending_startup != Some(false) {
@@ -378,7 +399,21 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
}
tokio::time::sleep(script.launch_delay).await;
if let Some(l) = pending_launch.take() {
send!(json!({"type": "response", "request_seq": l, "command": "launch", "success": true}));
let answer = json!({"type": "response", "request_seq": l, "command": "launch", "success": true});
if child && !script.child_launch_delay.is_zero() {
starting.store(true, Ordering::Release);
let (w, starting, delay) = (w.clone(), starting.clone(), script.child_launch_delay);
tokio::spawn(async move {
tokio::time::sleep(delay).await;
let mut answer = answer;
answer["seq"] = 999_999.into();
let _ = framing::write_message(&mut *w.lock().await, &serde_json::to_vec(&answer).unwrap())
.await;
starting.store(false, Ordering::Release);
});
} else {
send!(answer);
}
}
if let Some(pid) = script.debuggee_pid {
let event = if script.java_process_id {
@@ -474,9 +509,25 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
}
"disconnect" => {
ok!(msg, Value::Null);
let _ = w.shutdown().await;
let _ = w.lock().await.shutdown().await;
break;
}
other if script.accept.contains(&other) => {
if starting.load(Ordering::Acquire) {
log.lock().push(json!({"type": "dropped", "command": other}));
}
ok!(msg, json!({}))
}
other if script.custom.and_then(|custom| custom(&msg)).is_some() => {
if starting.load(Ordering::Acquire) {
log.lock().push(json!({"type": "dropped", "command": other}));
}
let (body, events) = script.custom.and_then(|custom| custom(&msg)).unwrap();
ok!(msg, body);
for (event, body) in events {
send!(json!({"type": "event", "event": event, "body": body}));
}
}
other => send!(
json!({"type": "response", "request_seq": msg["seq"], "command": other, "success": false, "message": "unsupported"})
),
@@ -905,6 +956,162 @@ async fn blocked_program_input_does_not_hold_up_shutdown() {
assert!(h.manager.send_input(session, "later").is_err());
}
fn js_config(launch_type: &str) -> LaunchConfig {
LaunchConfig {
adapter: "js-debug".into(),
program: None,
args: Vec::new(),
extra_json: Some(format!(r#"{{"type": "{launch_type}"}}"#)),
..config()
}
}
/// The session the adapter started under `parent`.
async fn child_of(h: &Harness, parent: DebugSessionId) -> DebugSessionId {
for _ in 0..200 {
if let Some(child) = h.manager.sessions(Some("main")).into_iter().find(|s| s.parent == Some(parent)) {
return child.id;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
panic!("no child session of {parent:?}");
}
#[tokio::test]
async fn a_browser_page_session_has_the_page_capability() {
let h = harness(Script { start_debugging: true, ..Script::default() });
let browser = h.start(js_config("pwa-chrome")).await.unwrap();
let page = h.manager.capabilities(child_of(&h, browser).await).unwrap();
assert!(page.js_page && page.live_evaluate);
assert!(!h.manager.capabilities(browser).unwrap().js_page, "the launch itself is not a page");
let node = h.start(js_config("pwa-node")).await.unwrap();
let process = h.manager.capabilities(child_of(&h, node).await).unwrap();
assert!(!process.js_page && process.live_evaluate);
let python = h.start(config()).await.unwrap();
let script = h.manager.capabilities(child_of(&h, python).await).unwrap();
assert!(!script.js_page && !script.live_evaluate);
}
#[tokio::test]
async fn custom_breakpoints_are_sent_to_a_page_and_refused_elsewhere() {
let h = harness(Script { start_debugging: true, accept: &["setCustomBreakpoints"], ..Script::default() });
let browser = h.start(js_config("pwa-chrome")).await.unwrap();
let page = child_of(&h, browser).await;
h.manager.js_set_custom_breakpoints(page, vec!["listener:click".into()], vec!["api/".into()]).await.unwrap();
assert_eq!(requests(&h, "setCustomBreakpoints"), [json!({"ids": ["listener:click"], "xhr": ["api/"]})]);
let not_a_page = h.manager.js_set_custom_breakpoints(browser, Vec::new(), Vec::new()).await.unwrap_err();
assert_eq!(not_a_page.kind, ide_core::error::ErrorKind::Unavailable);
// A js-debug build without the request.
let old = harness(Script { start_debugging: true, ..Script::default() });
let browser = old.start(js_config("pwa-chrome")).await.unwrap();
let page = child_of(&old, browser).await;
let refused = old.manager.js_set_custom_breakpoints(page, Vec::new(), Vec::new()).await.unwrap_err();
assert_eq!(refused.kind, ide_core::error::ErrorKind::Unavailable);
}
/// js-debug answers a request of its own that reaches a page after `configurationDone` and
/// before the page's `launch` is answered, and then does nothing with it.
#[tokio::test]
async fn a_page_gets_its_requests_once_its_session_has_started() {
let h = harness(Script {
start_debugging: true,
accept: &["setCustomBreakpoints"],
child_launch_delay: Duration::from_millis(150),
..Script::default()
});
let browser = h.start(js_config("pwa-chrome")).await.unwrap();
let page = child_of(&h, browser).await;
// Past the page's `configurationDone`, with its `launch` still unanswered.
tokio::time::sleep(Duration::from_millis(50)).await;
h.manager.js_set_custom_breakpoints(page, vec!["listener:click".into()], Vec::new()).await.unwrap();
assert_eq!(requests(&h, "setCustomBreakpoints"), [json!({"ids": ["listener:click"], "xhr": []})]);
let dropped = h.log.lock().iter().filter(|m| m["type"] == "dropped").count();
assert_eq!(dropped, 0, "the request reached the page before it took requests");
}
/// js-debug's network requests: `enableNetworking` is followed by one mirrored browser event.
fn network_adapter(msg: &Value) -> Option<CustomReply> {
match msg["command"].as_str()? {
"enableNetworking" => Some((
json!({}),
vec![(
"networkEvent",
json!({ "event": "requestWillBeSent", "data": {
"requestId": "7", "timestamp": 1.5, "type": "Fetch",
"request": { "method": "GET", "url": "http://localhost:5173/api/items", "headers": {} }
} }),
)],
)),
"networkCall" => Some((json!({ "body": "é".repeat(600_000), "base64Encoded": false }), Vec::new())),
_ => None,
}
}
#[tokio::test]
async fn a_page_reports_its_requests_and_their_bodies() {
let h = harness(Script {
start_debugging: true,
custom: Some(network_adapter),
child_launch_delay: Duration::from_millis(100),
..Script::default()
});
let browser = h.start(js_config("pwa-chrome")).await.unwrap();
let page = child_of(&h, browser).await;
// Recording is asked for by the session itself, as soon as js-debug takes the request:
// once it has answered the page's `launch` (before that, the request is never answered).
let DebugEvent::JsNetwork { event } = h.wait_event(|e| matches!(e, DebugEvent::JsNetwork { .. })).await else {
unreachable!()
};
assert_eq!(
requests(&h, "enableNetworking"),
[json!({ "mirrorEvents": ["requestWillBeSent", "responseReceived", "loadingFinished", "loadingFailed"] })]
);
let dropped = h.log.lock().iter().filter(|m| m["type"] == "dropped").count();
assert_eq!(dropped, 0, "recording was asked for before the page took requests");
assert_eq!((event.request_id.as_str(), event.timestamp_ms), ("7", 1500));
assert_eq!(event.url.as_deref(), Some("http://localhost:5173/api/items"));
let body = h.manager.js_response_body(page, "7".into()).await.unwrap();
assert_eq!(requests(&h, "networkCall"), [json!({ "method": "getResponseBody", "params": { "requestId": "7" } })]);
// 1.2 MB of two-byte characters: cut at the limit, on a character.
assert!(body.truncated && !body.base64_encoded);
assert_eq!(body.body.len(), 1_048_576);
assert!(body.body.chars().all(|c| c == 'é'));
assert_eq!(
h.manager.js_response_body(browser, "7".into()).await.unwrap_err().kind,
ide_core::error::ErrorKind::Unavailable
);
// A Node.js program is not a page: nothing is asked of it.
let node = h.start(js_config("pwa-node")).await.unwrap();
child_of(&h, node).await;
h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await;
assert_eq!(requests(&h, "enableNetworking").len(), 1);
}
#[tokio::test]
async fn a_page_whose_requests_the_adapter_does_not_report_says_so() {
// A js-debug build without `enableNetworking`.
let h = harness(Script { start_debugging: true, ..Script::default() });
let browser = h.start(js_config("pwa-chrome")).await.unwrap();
child_of(&h, browser).await;
h.wait_event(|e| matches!(e, DebugEvent::JsNetworkUnavailable)).await;
}
#[test]
fn a_child_session_is_named_by_the_adapter_unless_it_is_a_blank_page() {
assert_eq!(child_name(&json!({ "name": "server.js [4120]" }), "Server"), "server.js [4120]");
assert_eq!(child_name(&json!({ "name": "about:blank" }), "Shop in Chrome"), "Shop in Chrome");
assert_eq!(child_name(&json!({ "name": "" }), "Shop in Chrome"), "Shop in Chrome");
assert_eq!(child_name(&json!({}), "Shop in Chrome"), "Shop in Chrome");
}
#[test]
fn launch_arguments_merge_and_defaults() {
let mut c = config();
+11
Переглянути файл
@@ -557,6 +557,17 @@ async fn attach_to_processes() {
extra: |pid, _| serde_json::json!({"pid": pid}),
},
];
// A .NET app built into `dotnet/bin` (`<OutDir>bin</OutDir>`), attached by its apphost.
let dotnet_app = dir.join("dotnet/bin").join(if cfg!(windows) { "Server.exe" } else { "Server" });
if dotnet_app.is_file() {
cases.push(AttachCase {
kind: attach::AttachKind::Dotnet,
command: vec![s(&dotnet_app)],
source: dir.join("dotnet/Program.cs"),
line: 1,
extra: |pid, _| serde_json::json!({"processId": pid, "justMyCode": true}),
});
}
if java_server.is_some() {
cases.insert(
0,
+1 -1
Переглянути файл
@@ -37,6 +37,6 @@ zeroize = "1"
open = "5"
[dev-dependencies]
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "net"] }
tempfile.workspace = true
ide-ssh = { workspace = true, features = ["testing"] }
+16 -3
Переглянути файл
@@ -241,6 +241,12 @@ pub enum Pool {
Snowflake(crate::snowflake::SnowflakePool),
}
/// Some servers and proxies (e.g. Aliyun RDS for MySQL 5.6) advertise `CLIENT_DEPRECATE_EOF` but
/// still end result sets with legacy EOF packets, which sqlx then fails to read as OK packets.
fn legacy_eof(e: &sqlx::Error) -> bool {
matches!(e, sqlx::Error::Protocol(msg) if msg.starts_with("OK_Packet too short"))
}
pub(crate) fn db_err(e: sqlx::Error) -> IdeError {
match &e {
sqlx::Error::Database(d) => {
@@ -747,7 +753,7 @@ impl Pool {
return crate::oracle::OraclePool::connect(source, password, &setup).await.map(Pool::Oracle);
}
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 init = Arc::new(setup.statements);
@@ -873,9 +879,16 @@ impl Pool {
if !tz.is_empty() {
opts = opts.timezone(Some(tz.to_owned()));
}
let manager = MysqlManager { options: opts, init: init.clone(), timeout };
let mut manager = MysqlManager { options: opts, init: init.clone(), timeout };
// Open once directly so authentication failures retain their database error.
let first = manager.connect().await.map_err(db_err)?;
let first = match manager.connect().await {
Err(e) if legacy_eof(&e) => {
manager.options = manager.options.clone().deprecate_eof(false);
manager.connect().await
}
first => first,
}
.map_err(db_err)?;
let pool = Bb8Pool::builder().max_size(MAX).connection_timeout(timeout).build_unchecked(manager);
let _ = pool.add(first);
Ok(Pool::Mysql(ServerPool { inner: pool, closed: AtomicBool::new(false) }))
+58 -21
Переглянути файл
@@ -324,6 +324,17 @@ fn group_foreign_keys(rows: Vec<Vec<Option<String>>>, tables: &[CompletionTable]
/// Connection target: a data source id (its configured database), or
/// `<id>:<database>` for another database of the same server. Data source ids
/// never contain `:`.
/// Snowflake is HTTPS to a named host: a tunnel's local end cannot pass its certificate check.
fn tunneled(driver: DbDriver) -> bool {
!matches!(driver, DbDriver::Sqlite | DbDriver::Snowflake)
}
/// The database host and port the SSH host forwards to.
fn tunnel_target(source: &DataSource) -> (&str, u16) {
let port = source.port.or(source.driver.default_port()).unwrap_or_default();
(if source.host.trim().is_empty() { "localhost" } else { source.host.trim() }, port)
}
pub fn split_target(target: &str) -> (&str, Option<&str>) {
match target.split_once(':') {
Some((id, db)) if !db.is_empty() => (id, Some(db)),
@@ -655,9 +666,7 @@ impl DbManager {
ssh_secret: Option<String>,
shared: bool,
) -> IdeResult<(DataSource, Option<Arc<Tunnel>>)> {
// Snowflake is HTTPS to a named host: a tunnel's local end cannot pass its certificate check.
let tunneled = !matches!(source.driver, DbDriver::Sqlite | DbDriver::Snowflake);
let Some(ssh) = source.ssh.as_ref().filter(|s| s.enabled && tunneled) else {
let Some(ssh) = source.ssh.as_ref().filter(|s| s.enabled && tunneled(source.driver)) else {
return Ok((source.clone(), None));
};
let local = |t: &Tunnel| DataSource { host: "127.0.0.1".into(), port: Some(t.port()), ..source.clone() };
@@ -668,8 +677,25 @@ impl DbManager {
return Ok((local(&t), Some(t)));
}
}
let session = self.ssh_session(source, ssh, ssh_secret).await?;
let (target_host, target_port) = tunnel_target(source);
let t = Arc::new(Tunnel::open(session, target_host, target_port).await?);
if shared {
self.tunnels.lock().insert(source.id.clone(), t.clone());
}
Ok((local(&t), Some(t)))
}
/// The SSH session the tunnel of `source` goes through: its shared SSH configuration's, or one
/// opened with the settings typed in the data source.
async fn ssh_session(
&self,
source: &DataSource,
ssh: &DbSsh,
ssh_secret: Option<String>,
) -> IdeResult<Arc<ide_ssh::Session>> {
let config = ssh.config.trim();
let session = if config.is_empty() {
Ok(if config.is_empty() {
let secret = match ssh_secret {
Some(s) => Some(s),
None => self.secret(source, secrets::ssh_key(&source.id), ssh.save_secret).await,
@@ -688,14 +714,22 @@ impl DbManager {
self.ssh.set_secret(config, Some(s), save);
}
self.ssh.session(config).await?
};
let target_port = source.port.or(source.driver.default_port()).unwrap_or_default();
let target_host = if source.host.trim().is_empty() { "localhost" } else { source.host.trim() };
let t = Arc::new(Tunnel::open(session, target_host, target_port).await?);
if shared {
self.tunnels.lock().insert(source.id.clone(), t.clone());
}
Ok((local(&t), Some(t)))
})
}
/// Data Source › SSH/SSL › Test SSH Tunnel: logs in to the SSH host and checks that it reaches
/// the database host and port, without logging in to the database.
pub async fn test_tunnel(&self, source: &DataSource, ssh_secret: Option<String>) -> IdeResult<String> {
let source = &source.resolved()?;
let ssh = source
.ssh
.as_ref()
.filter(|s| s.enabled && tunneled(source.driver))
.ok_or_else(|| IdeError::invalid("This data source does not use an SSH tunnel"))?;
let session = self.ssh_session(source, ssh, ssh_secret).await?;
let (host, port) = tunnel_target(source);
drop(Tunnel::open(session.clone(), host, port).await?);
Ok(format!("{} reaches {host}:{port}", session.label()))
}
/// Connects with `source` as given (not necessarily saved): Test Connection.
@@ -1491,18 +1525,21 @@ impl DbManager {
let Some(session) = session else { return Ok(()) };
let mut held = session.lock().await;
let Some(mut conn) = held.take() else { return Ok(()) };
let result = async {
conn.command(if commit { "COMMIT" } else { "ROLLBACK" }).await?;
conn.restore_default().await
}
.await;
match result {
Ok(()) => Ok(()),
Err(e) => {
if let Err(e) = conn.command(if commit { "COMMIT" } else { "ROLLBACK" }).await {
// Discarding a Snowflake connection doesn't end its session, which the data source's
// other targets share: the transaction stays open, so it stays held to be ended again.
if matches!(conn, conn::Held::Snowflake(..)) {
*held = Some(conn);
} else {
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)]
+35
Переглянути файл
@@ -117,6 +117,14 @@ pub(crate) fn oracle_err(e: &oracledb::Error) -> IdeError {
// config first. Treat it as a logon failure, so the UI prompts for one.
let kind = match e.kind() {
oracledb::ErrorKind::NoCredentials => ErrorKind::PermissionDenied,
// The thin driver speaks the protocol of Oracle Database 12c and later only.
oracledb::ErrorKind::ServerVersionNotSupported => {
return IdeError::new(
ErrorKind::Unavailable,
"This Oracle Database is older than 12c. OpenIDEA connects to Oracle Database 12c and later; \
11g and earlier are not supported.",
);
}
_ => ErrorKind::InvalidInput,
};
return IdeError::new(kind, e.to_string());
@@ -571,6 +579,33 @@ mod tests {
assert_eq!(connect_string_with(&s("PROD=MYDB"), false).unwrap(), "PROD=MYDB");
}
#[test]
fn a_server_older_than_12c_is_named() {
use std::io::{Read, Write};
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let server = std::thread::spawn(move || {
let (mut socket, _) = listener.accept().unwrap();
let mut connect = [0u8; 1024];
let _ = socket.read(&mut connect);
// A TNS ACCEPT packet with protocol version 314, as Oracle Database 11g answers.
let mut accept = vec![0u8; 32];
accept[1] = 32;
accept[4] = 2;
accept[8..10].copy_from_slice(&314u16.to_be_bytes());
let _ = socket.write_all(&accept);
});
let config = oracledb::Config::default()
.set_credentials("scott", "tiger")
.set_connect_string(&format!("//127.0.0.1:{port}/ORCL"))
.unwrap()
.set_transport_connect_timeout(Some(std::time::Duration::from_secs(5)));
let err = oracle_err(&oracledb::connect(config).err().expect("an 11g server is refused"));
assert_eq!(err.kind, ErrorKind::Unavailable, "{err:?}");
assert!(err.message.contains("older than 12c"), "{}", err.message);
server.join().unwrap();
}
#[test]
fn a_tns_alias_resolves_when_tnsnames_exists() {
let alias = DataSource {
+94 -24
Переглянути файл
@@ -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>>>> =
LazyLock::new(|| parking_lot::Mutex::new(HashMap::new()));
@@ -106,6 +107,15 @@ struct Lane {
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 METADATA: usize = 1;
@@ -251,7 +261,12 @@ fn gunzip(bytes: Vec<u8>) -> IdeResult<Vec<u8>> {
}
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 endpoint = account::endpoint(source)?;
let user = source.user.trim();
@@ -302,9 +317,15 @@ impl SnowflakePool {
lane: CONSOLE,
};
// 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(
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(),
settings.auth,
secret.unwrap_or_default(),
@@ -636,13 +657,18 @@ impl SnowflakePool {
() = self.closed.cancelled() => return Err(closed()),
turn = self.lane().turn.lock() => turn,
};
// A `USE` needs no schema to run in, and may be what makes the schema reachable.
if first_word(sql) != "USE" {
self.enter(schema, cancel).await?;
}
// A `USE` needs no schema to run in, and may be what makes the schema reachable. Ending a
// transaction needs none either, and must not fail because the schema is gone meanwhile.
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.
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 {
*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() = None;
match self.statement(&wanted, 0, 0, cancel).await {
Ok(_) => {}
// The session's role may not use this database or schema (after `USE ROLE`, or a
// schema that is gone). The statement still runs, in the session's own context:
// it may be the `USE` that leads back, or name its objects in full. Asked again
// once a statement moves the session.
Err(refused) if refused.kind == ErrorKind::InvalidInput => {
tracing::warn!("Snowflake: the session did not enter its database and schema");
}
Err(error) => return Err(error),
// Refused (the session's role may not use this database or schema): the statement
// does not run, since the session is still wherever it was, which may be another
// target's database. Tried again with the next statement.
if let Err(error) = self.statement(&wanted, 0, 0, Lost::Enter(None), cancel).await {
return Err(IdeError {
message: format!("{wanted} failed, so the statement was not run: {}", error.message),
..error
});
}
*self.lane().context.lock() = Some(wanted);
}
@@ -680,11 +704,11 @@ impl SnowflakePool {
let _turn = self.lane().turn.lock().await;
let cancel = CancellationToken::new();
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 mut total = 0;
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 {
return Err(IdeError::invalid(format!(
"{sql}: expected to change 1 row, changed {changed}; nothing was saved"
@@ -697,22 +721,24 @@ impl SnowflakePool {
.await;
match changes {
Ok(total) => {
self.statement("COMMIT", 0, 0, &cancel).await?;
self.statement("COMMIT", 0, 0, Lost::Fail, &cancel).await?;
Ok(total)
}
Err(error) => {
let _ = self.statement("ROLLBACK", 0, 0, &cancel).await;
let _ = self.statement("ROLLBACK", 0, 0, Lost::Fail, &cancel).await;
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(
&self,
sql: &str,
skip: usize,
max_rows: usize,
lost: Lost<'_>,
cancel: &CancellationToken,
) -> IdeResult<QueryResult> {
let started = Instant::now();
@@ -743,7 +769,24 @@ impl SnowflakePool {
Ok(Outcome::Done(result)) => return Ok(*result),
Ok(Outcome::Expired { gone }) if !renewed => {
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 { .. }) => {
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(
&self,
sql: &str,
+106 -16
Переглянути файл
@@ -407,13 +407,64 @@ async fn a_lost_session_signs_in_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 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();
pool.execute("select 1", 5, &CancellationToken::new()).await.unwrap();
pool.close().await;
let seen = server.await.unwrap();
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]
@@ -714,7 +765,7 @@ async fn a_console_schema_is_switched_with_its_statement_and_only_when_needed()
}
#[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 = || {
json!({ "success": true, "data": { "rowtype": [{ "name": "status", "type": "text" }],
"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 (listener, port) = bind().await;
// use role | the schema is refused once, the statement runs | the next one does not ask again |
// a role that is not granted | use role back, the schema is entered again
// use role | the schema is refused, the statement is not sent | asked again, refused again |
// use role back, the schema is entered, the statement runs
let replies = vec![
ok(LOGIN_OK),
ok(status()),
ok(refused),
ok(one()),
ok(one()),
ok(refused),
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 cancel = CancellationToken::new();
pool.execute("use role auditor", 0, &cancel).await.unwrap();
pool.execute("select 1", 1, &cancel).await.unwrap();
pool.execute("select 2", 1, &cancel).await.unwrap();
let missing = pool.execute("use role nobody", 0, &cancel).await.unwrap_err();
assert!(missing.message.contains("002043") && missing.message.contains("granted"), "{}", missing.message);
let error = pool.execute("delete from t", 1, &cancel).await.unwrap_err();
assert!(
error.message.contains("USE SCHEMA")
&& 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("select 3", 1, &cancel).await.unwrap();
pool.close().await;
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\"";
assert_eq!(
sent,
["use role auditor", schema, "select 1", "select 2", "use role nobody", "use role analyst", schema, "select 3"]
);
assert_eq!(sent, ["use role auditor", schema, schema, "use role analyst", schema, "select 3"]);
}
#[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());
}
#[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]
async fn metadata_runs_on_its_own_session_beside_a_busy_console() {
let (listener, port) = bind().await;
+4 -1
Переглянути файл
@@ -143,9 +143,12 @@ mod tests {
std::fs::create_dir_all(root.join(".openidea")).unwrap();
let (count, on_change) = counting();
let _watch = SourcesWatch::start(&root, None, on_change).unwrap();
// FSEvents can deliver the creation of `.openidea/` above after the watch started.
std::thread::sleep(Duration::from_secs(1));
let seen = count.load(Ordering::SeqCst);
std::fs::write(root.join(".openidea/settings.toml"), "").unwrap();
std::fs::write(root.join("main.py"), "").unwrap();
std::thread::sleep(SETTLE * 3);
assert_eq!(count.load(Ordering::SeqCst), 0);
assert_eq!(count.load(Ordering::SeqCst), seen);
}
}
+48
Переглянути файл
@@ -183,3 +183,51 @@ async fn a_reused_physical_connection_restores_the_configured_database() -> IdeR
assert_eq!(pool.execute("SELECT CONNECTION_ID()", 1, &c).await?.rows, backend);
Ok(())
}
/// Forwards to the test server, adding `CLIENT_DEPRECATE_EOF` to the capabilities its greeting
/// advertises, like proxies that set the flag but leave a 5.x server sending legacy EOF packets.
async fn deprecate_eof_proxy(host: String, port: u16) -> u16 {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let local = listener.local_addr().unwrap().port();
tokio::spawn(async move {
while let Ok((mut client, _)) = listener.accept().await {
let mut server = tokio::net::TcpStream::connect((host.as_str(), port)).await.unwrap();
tokio::spawn(async move {
let mut header = [0u8; 4];
server.read_exact(&mut header).await.unwrap();
let len = u32::from_le_bytes([header[0], header[1], header[2], 0]) as usize;
let mut greeting = vec![0u8; len];
server.read_exact(&mut greeting).await.unwrap();
// protocol version, NUL-terminated server version, connection id, 8 bytes of
// auth data, filler, lower capabilities, charset, status, upper capabilities.
let version_end = 1 + greeting[1..].iter().position(|&b| b == 0).unwrap();
let upper = version_end + 1 + 4 + 8 + 1 + 2 + 1 + 2;
greeting[upper + 1] |= 0x01; // bit 24 of the capability flags
client.write_all(&header).await.unwrap();
client.write_all(&greeting).await.unwrap();
let _ = tokio::io::copy_bidirectional(&mut client, &mut server).await;
});
}
});
local
}
#[tokio::test]
async fn servers_that_advertise_deprecate_eof_but_send_legacy_eof_packets() -> IdeResult<()> {
use ide_db::Pool;
let Some((host, port, user, password)) = server() else { return Ok(()) };
let source = DataSource {
driver: DbDriver::Mysql,
host: "127.0.0.1".into(),
port: Some(deprecate_eof_proxy(host, port).await),
user,
..Default::default()
};
let pool = Pool::connect(&source, Some(&password), None).await?;
assert_eq!(pool.rows("SELECT 1 + 1", 1).await?, vec![vec![Some("2".into())]]);
let c = CancellationToken::new();
let r = pool.execute("SELECT 'a' UNION ALL SELECT 'b'", 10, &c).await?;
assert_eq!(r.rows, vec![vec![Some("a".into())], vec![Some("b".into())]]);
Ok(())
}
+96
Переглянути файл
@@ -0,0 +1,96 @@
//! Data Source › SSH/SSL › Test SSH Tunnel against an in-process SSH server: the SSH login and
//! the SSH host reaching the database host and port, with no database login.
use ide_core::error::ErrorKind;
use ide_db::{DataSource, DbDriver, DbManager, DbSsh, DbSshAuth};
use ide_ssh::{SshFiles, SshManager};
use std::sync::Arc;
use tokio::net::TcpListener;
struct Env {
_tmp: tempfile::TempDir,
db: DbManager,
ssh_port: u16,
}
async fn env() -> Env {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().to_path_buf();
let ssh_port = ide_ssh::testing::sshd(root.join("server"), Arc::new(|cmd| panic!("unexpected exec: {cmd}"))).await;
let ssh = Arc::new(SshManager::with_files(
root.join("ssh-secrets.json"),
SshFiles { known_hosts: root.join("known_hosts"), config: None },
));
let db = DbManager::new(root.clone(), root.join("passwords.json"), root.join("history.json"), ssh);
Env { _tmp: tmp, db, ssh_port }
}
impl Env {
/// A PostgreSQL source on `127.0.0.1:<db_port>` through the test server as `dev`.
fn source(&self, db_port: u16) -> DataSource {
DataSource {
id: "pg".into(),
name: "pg".into(),
driver: DbDriver::Postgres,
host: "127.0.0.1".into(),
port: Some(db_port),
user: "app".into(),
ssh: Some(DbSsh {
enabled: true,
host: "127.0.0.1".into(),
port: Some(self.ssh_port),
user: "dev".into(),
auth: DbSshAuth::Password,
..Default::default()
}),
..Default::default()
}
}
}
/// A port something listens on (and never speaks a database protocol).
async fn listening() -> (TcpListener, u16) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
(listener, port)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn the_tunnel_is_tested_without_a_database_login() {
let env = env().await;
let (_db, db_port) = listening().await;
let source = env.source(db_port);
// First contact: the host key has to be trusted, as with Test Connection.
let unknown = env.db.test_tunnel(&source, Some("secret".into())).await.unwrap_err();
assert!(unknown.message.starts_with("Unknown SSH host key"), "{unknown:?}");
env.db.trust_ssh_host("pg").await.unwrap();
let ok = env.db.test_tunnel(&source, Some("secret".into())).await.unwrap();
assert_eq!(ok, format!("[email protected]:{} reaches 127.0.0.1:{db_port}", env.ssh_port));
let wrong = env.db.test_tunnel(&source, Some("nope".into())).await.unwrap_err();
assert!(wrong.message.contains("SSH authentication failed"), "{}", wrong.message);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_database_port_the_ssh_host_cannot_reach_is_named() {
let env = env().await;
let (listener, closed) = listening().await;
drop(listener);
let source = env.source(closed);
let _ = env.db.test_tunnel(&source, Some("secret".into())).await;
env.db.trust_ssh_host("pg").await.unwrap();
let err = env.db.test_tunnel(&source, Some("secret".into())).await.unwrap_err();
assert!(err.message.contains(&format!("cannot reach 127.0.0.1:{closed}")), "{}", err.message);
}
#[tokio::test]
async fn a_source_without_a_tunnel_has_nothing_to_test() {
let env = env().await;
let mut source = env.source(5432);
source.ssh.as_mut().unwrap().enabled = false;
let err = env.db.test_tunnel(&source, None).await.unwrap_err();
assert_eq!(err.kind, ErrorKind::InvalidInput, "{err:?}");
}
+2 -5
Переглянути файл
@@ -458,16 +458,13 @@ fn canonical_dir(p: &Path) -> PathBuf {
fn is_executable(p: &Path) -> bool {
let Ok(meta) = std::fs::metadata(p) else { return false };
if !meta.is_file() {
return false;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
meta.permissions().mode() & 0o111 != 0
meta.is_file() && meta.permissions().mode() & 0o111 != 0
}
#[cfg(not(unix))]
true
meta.is_file()
}
/// blake3 of a file, hex.
+7 -1
Переглянути файл
@@ -34,7 +34,7 @@ use std::path::{Component, Path, PathBuf};
pub const MANIFEST_FILE: &str = "plugin.toml";
/// The plugin API (engine) version this host implements.
pub const ENGINE_VERSION: &str = "1.6.0";
pub const ENGINE_VERSION: &str = "1.8.0";
/// The 0.1 API, still accepted for contribution-only packages and 0.1 components.
pub const LEGACY_API: &str = "0.1";
/// Marketplace categories, in the order they are shown.
@@ -166,6 +166,7 @@ pub const EVENT_KINDS: &[&str] = &[
"editor-focused",
"action-performed",
"vcs-changed",
"problems-changed",
];
impl std::fmt::Display for ActivationEvent {
@@ -372,6 +373,11 @@ impl Manifest {
{
self.needs_api(&format!("`{event}`"), 6)?;
}
// Added after 1.7.0 shipped: a 1.7 host rejects the manifest, so the gate is the minor
// that carries it.
if matches!(event, ActivationEvent::Event(kind) if kind == "problems-changed") {
self.needs_api(&format!("`{event}`"), 8)?;
}
if matches!(event, ActivationEvent::Event(kind) if matches!(kind.as_str(), "file-closed" | "settings-changed" | "input-mode-changed" | "run-exited" | "terminal-exited" | "content-tab-action"))
{
self.needs_api(&format!("`{event}`"), 4)?;
+175
Переглянути файл
@@ -0,0 +1,175 @@
//! Values coding agents are configured with that may be secrets (MCP servers' environment
//! variables and HTTP headers, agents' environment), kept with the saved passwords in the OS
//! credential store; the files that configure them keep only the names. Without a credential
//! store (a headless Linux host) a value is kept in an owner-only file of the user's data
//! instead, never in a project's files, and the user is told.
use ide_core::event::NotifyLevel;
use ide_core::{IdeEvent, IdeResult, SharedSink};
use ide_ssh::secrets::SecretFile;
use parking_lot::Mutex;
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
pub struct AgentSecrets {
store: SecretFile,
/// Values the credential store refused, by key.
local: PathBuf,
lock: Mutex<()>,
}
/// Where [`AgentSecrets::set`] kept a value.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Kept {
Store,
LocalFile,
}
impl AgentSecrets {
pub fn new(data_dir: &Path) -> Self {
Self::with_store(
SecretFile::new(data_dir.join("agent-secrets.json")),
data_dir.join("agent-secrets.local.json"),
)
}
pub fn with_store(store: SecretFile, local: PathBuf) -> Self {
Self { store, local, lock: Mutex::new(()) }
}
pub fn get(&self, key: &str) -> Option<String> {
self.store.get(key).or_else(|| self.read_local().remove(key))
}
pub fn set(&self, key: &str, value: &str) -> IdeResult<Kept> {
match self.store.set(key, value) {
Ok(()) => {
self.edit_local(|local| local.remove(key).is_some())?;
Ok(Kept::Store)
}
Err(e) => {
tracing::warn!("agent settings: {key} is kept in {}: {}", self.local.display(), e.message);
self.edit_local(|local| local.insert(key.to_owned(), value.to_owned()).as_deref() != Some(value))?;
Ok(Kept::LocalFile)
}
}
}
pub fn delete(&self, key: &str) {
self.store.delete(key);
if let Err(e) = self.edit_local(|local| local.remove(key).is_some()) {
tracing::warn!("agent settings: could not forget {key}: {}", e.message);
}
}
/// The keys that start with `prefix`, sorted.
pub fn keys(&self, prefix: &str) -> Vec<String> {
let keys: BTreeSet<String> = self.store.keys().into_iter().chain(self.read_local().into_keys()).collect();
keys.into_iter().filter(|k| k.starts_with(prefix)).collect()
}
/// Tells the window that values went to the owner-only file rather than the credential store.
pub fn notify_kept_locally(&self, sink: &SharedSink) {
sink.emit(IdeEvent::Notify {
level: NotifyLevel::Warning,
title: "No credential store".into(),
body: Some(format!(
"Values of agent and MCP server settings are kept in {}, readable only by you.",
self.local.display()
)),
});
}
fn read_local(&self) -> BTreeMap<String, String> {
match std::fs::read(&self.local) {
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|e| {
tracing::warn!("agent settings: {}: {e}", self.local.display());
BTreeMap::new()
}),
Err(_) => BTreeMap::new(),
}
}
fn edit_local(&self, edit: impl FnOnce(&mut BTreeMap<String, String>) -> bool) -> IdeResult<()> {
let _g = self.lock.lock();
let mut local = self.read_local();
if !edit(&mut local) {
return Ok(());
}
if local.is_empty() {
return match std::fs::remove_file(&self.local) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e.into()),
_ => Ok(()),
};
}
if let Some(dir) = self.local.parent() {
std::fs::create_dir_all(dir)?;
}
let bytes = serde_json::to_vec_pretty(&local).map_err(|e| ide_core::IdeError::internal(e.to_string()))?;
ide_vfs::write_atomic_private(&self.local, &bytes)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use ide_ssh::secrets::testing::{files_containing, use_memory_store};
use ide_ssh::secrets::{StoreError, StoreResult, Vault};
use std::sync::Arc;
/// A machine without a credential store (a headless Linux host).
struct NoStore;
impl Vault for NoStore {
fn available(&self) -> bool {
false
}
fn get(&self, _: &str, _: &str) -> StoreResult<String> {
Err(StoreError::NoDefaultStore)
}
fn set(&self, _: &str, _: &str, _: &str) -> StoreResult<()> {
Err(StoreError::NoDefaultStore)
}
fn delete(&self, _: &str, _: &str) -> StoreResult<()> {
Err(StoreError::NoDefaultStore)
}
}
#[test]
fn values_go_to_the_credential_store() {
use_memory_store();
let tmp = tempfile::tempdir().unwrap();
let secrets = AgentSecrets::new(tmp.path());
assert_eq!(secrets.set("mcp/user/a/env/TOKEN", "value-7c1e").unwrap(), Kept::Store);
assert_eq!(files_containing(tmp.path(), "value-7c1e"), Vec::<PathBuf>::new());
assert_eq!(AgentSecrets::new(tmp.path()).get("mcp/user/a/env/TOKEN").as_deref(), Some("value-7c1e"));
assert_eq!(secrets.keys("mcp/user/"), ["mcp/user/a/env/TOKEN"]);
secrets.delete("mcp/user/a/env/TOKEN");
assert_eq!(secrets.get("mcp/user/a/env/TOKEN"), None);
}
#[test]
fn without_a_credential_store_values_stay_in_an_owner_only_file() {
let tmp = tempfile::tempdir().unwrap();
let local = tmp.path().join("agent-secrets.local.json");
let store = SecretFile::with_vault(
tmp.path().join("agent-secrets.json"),
|v| serde_json::from_value(v).ok(),
Arc::new(NoStore),
);
let secrets = AgentSecrets::with_store(store, local.clone());
assert_eq!(secrets.set("mcp/user/a/env/TOKEN", "value-7c1e").unwrap(), Kept::LocalFile);
assert_eq!(files_containing(tmp.path(), "value-7c1e"), std::slice::from_ref(&local));
assert_eq!(secrets.get("mcp/user/a/env/TOKEN").as_deref(), Some("value-7c1e"));
assert_eq!(secrets.keys("mcp/"), ["mcp/user/a/env/TOKEN"]);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(std::fs::metadata(&local).unwrap().permissions().mode() & 0o777, 0o600);
}
secrets.delete("mcp/user/a/env/TOKEN");
assert_eq!(secrets.get("mcp/user/a/env/TOKEN"), None);
assert!(!local.exists());
}
}
+3 -3
Переглянути файл
@@ -426,9 +426,9 @@ fn plugins_dir() -> Result<PathBuf, String> {
if let Some(dir) = std::env::var_os("OPENIDEA_PLUGINS_DIR") {
return Ok(PathBuf::from(dir));
}
dirs::data_dir()
.map(|d| d.join("ai.l0ng.openidea").join("plugins"))
.ok_or_else(|| "cannot find the app data directory; set OPENIDEA_PLUGINS_DIR".into())
ide_host::data_dirs::data_dir("ai.l0ng.openidea")
.map(|d| d.join("plugins"))
.map_err(|error| format!("{error}; set OPENIDEA_PLUGINS_DIR"))
}
#[derive(Default, serde::Serialize, serde::Deserialize)]
+66 -4
Переглянути файл
@@ -5,9 +5,52 @@ use std::fs;
use std::io;
use std::path::{Path, PathBuf};
/// Moves the rebuildable local data (tools, language servers, indexes, caches);
/// configuration and roaming data keep following the OS.
/// Moves the rebuildable local data (tools, language servers, indexes, caches).
pub const LOCAL_DATA_ENV: &str = "OPENIDEA_LOCAL_DATA_DIR";
/// Moves the configuration (settings, recent projects, language definitions, window state).
pub const CONFIG_ENV: &str = "OPENIDEA_CONFIG_DIR";
/// Moves the user data (plugins, scratches, Local History, trusted projects).
pub const DATA_ENV: &str = "OPENIDEA_DATA_DIR";
/// Moves the logs.
pub const LOG_ENV: &str = "OPENIDEA_LOG_DIR";
/// `$OPENIDEA_CONFIG_DIR/<identifier>` when set, else the OS configuration directory
/// (Tauri's `app_config_dir`). Like the local data, the identifier keeps release and
/// per-worktree debug builds apart under one root; a relative path is an error.
pub fn config_dir(identifier: &str) -> Result<PathBuf, String> {
resolve_env(CONFIG_ENV, std::env::var_os(CONFIG_ENV), dirs::config_dir(), identifier)
}
/// `$OPENIDEA_DATA_DIR/<identifier>` when set, else the OS data directory (Tauri's `app_data_dir`).
pub fn data_dir(identifier: &str) -> Result<PathBuf, String> {
resolve_env(DATA_ENV, std::env::var_os(DATA_ENV), dirs::data_dir(), identifier)
}
/// `$OPENIDEA_LOG_DIR/<identifier>` when set, else `~/Library/Logs/<identifier>` on macOS and
/// `<local data>/<identifier>/logs` elsewhere (Tauri's `app_log_dir`).
pub fn log_dir(identifier: &str) -> Result<PathBuf, String> {
let os = if cfg!(target_os = "macos") {
dirs::home_dir().map(|home| home.join("Library/Logs").join(identifier))
} else {
dirs::data_local_dir().map(|dir| dir.join(identifier).join("logs"))
};
match std::env::var_os(LOG_ENV).filter(|v| !v.is_empty()) {
Some(var) => resolve_env(LOG_ENV, Some(var), None, identifier),
None => os.ok_or_else(|| "cannot find the log directory".into()),
}
}
/// The directory under `$<env>` when set (absolute, not created here), else under the OS directory.
fn resolve_env(env: &str, var: Option<OsString>, os: Option<PathBuf>, identifier: &str) -> Result<PathBuf, String> {
let Some(root) = var.filter(|v| !v.is_empty()).map(PathBuf::from) else {
return os.map(|dir| dir.join(identifier)).ok_or_else(|| format!("cannot find the directory {env} replaces"));
};
// The working directory depends on how the IDE was launched.
if !root.is_absolute() {
return Err(format!("{env} must be an absolute path: {}", root.display()));
}
Ok(root.join(identifier))
}
/// A custom local-data root chosen in Settings › System Settings › Caches,
/// stored in the configuration directory. The environment variable still wins.
@@ -51,11 +94,11 @@ fn startup_resolve(
/// The file holding the custom local-data root, if the platform exposes its
/// configuration directory. Both startup and Settings use this one path.
pub fn override_path(identifier: &str) -> Option<PathBuf> {
dirs::config_dir().map(|dir| dir.join(identifier).join(OVERRIDE_FILE))
config_dir(identifier).ok().map(|dir| dir.join(OVERRIDE_FILE))
}
fn migration_path(identifier: &str) -> Option<PathBuf> {
dirs::config_dir().map(|dir| dir.join(identifier).join(MIGRATION_FILE))
config_dir(identifier).ok().map(|dir| dir.join(MIGRATION_FILE))
}
/// The custom root stored by Settings, if any (validated when it is used).
@@ -493,6 +536,25 @@ fn relative_wrapper(text: &str, source: &Path) -> String {
mod tests {
use super::*;
#[test]
fn config_data_and_log_dirs_follow_the_os_unless_their_variable_is_set() {
let os = Some(PathBuf::from("/os"));
for env in [CONFIG_ENV, DATA_ENV, LOG_ENV] {
for var in [None, Some(OsString::new())] {
assert_eq!(resolve_env(env, var, os.clone(), "id").unwrap(), PathBuf::from("/os/id"));
}
let root = std::env::temp_dir().join("openidea-moved");
assert_eq!(resolve_env(env, Some(root.clone().into()), os.clone(), "id").unwrap(), root.join("id"));
// Release and per-worktree debug builds stay apart under one root.
assert_ne!(
resolve_env(env, Some(root.clone().into()), None, "dev-a").unwrap(),
resolve_env(env, Some(root.clone().into()), None, "dev-b").unwrap()
);
let error = resolve_env(env, Some("relative/dir".into()), os.clone(), "id").unwrap_err();
assert!(error.contains(env) && error.contains("absolute"), "{error}");
}
}
#[test]
fn local_dir_follows_the_os_unless_the_variable_is_set() {
let os = Some(PathBuf::from("/os-local"));
+78
Переглянути файл
@@ -0,0 +1,78 @@
//! Host-owned diagnostics snapshot: every `Diagnostics` event lands here, whoever
//! emitted it and whatever window it was addressed to. Plugins read this instead of
//! the UI-reported Problems reports, so what a plugin sees no longer depends on
//! window lifetime, the invoking window's project, or the report loop's liveness.
use ide_core::{Diagnostic, EventSink, IdeEvent, SharedSink};
use parking_lot::RwLock;
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::Arc;
#[derive(Default)]
pub struct DiagnosticsStore(RwLock<BTreeMap<PathBuf, BTreeMap<String, Vec<Diagnostic>>>>);
impl DiagnosticsStore {
/// Full replacement for one (file, producer), like the event itself.
pub fn replace(&self, path: &PathBuf, source: &str, items: Vec<Diagnostic>) {
let mut map = self.0.write();
let sources = map.entry(path.clone()).or_default();
if items.is_empty() {
sources.remove(source);
if sources.is_empty() {
map.remove(path);
}
} else {
sources.insert(source.to_owned(), items);
}
}
/// Every file's diagnostics as one entry per producer.
pub fn problems(&self) -> Vec<(PathBuf, String, Vec<Diagnostic>)> {
let map = self.0.read();
map.iter()
.flat_map(|(path, sources)| {
sources.iter().map(move |(source, items)| (path.clone(), source.clone(), items.clone()))
})
.collect()
}
pub fn file(&self, path: &PathBuf) -> BTreeMap<String, Vec<Diagnostic>> {
self.0.read().get(path).cloned().unwrap_or_default()
}
/// Drops the files under `root` (their project closed; the producers will re-report
/// on open). Returns the number of files removed.
pub fn forget_root(&self, root: &PathBuf) -> usize {
let mut map = self.0.write();
let before = map.len();
map.retain(|path, _| !path.starts_with(root));
before - map.len()
}
}
/// Records `Diagnostics` events, then delivers them as usual.
pub struct RecordingSink {
pub inner: SharedSink,
pub store: Arc<DiagnosticsStore>,
}
impl EventSink for RecordingSink {
fn emit(&self, event: IdeEvent) {
if let IdeEvent::Diagnostics { path, source, items } = &event {
self.store.replace(&path.0.clone(), source, items.clone());
}
self.inner.emit(event);
}
}
/// A sink whose diagnostics are recorded; the store outlives every project.
pub type RecordSinkFactory = Box<dyn Fn(&str) -> SharedSink + Send + Sync>;
/// Wraps a sink factory so every sink it creates records diagnostics.
pub fn recording_sink_factory(store: Arc<DiagnosticsStore>, inner: crate::SinkFactory) -> crate::SinkFactory {
Box::new(move |window| {
let inner = inner(window);
Arc::new(RecordingSink { inner, store: store.clone() }) as SharedSink
})
}
+22
Переглянути файл
@@ -3,7 +3,9 @@
//! wraps one [`Host`] and adds windows and menus; `openidea-server`
//! runs one on a remote machine and serves it over JSON-RPC ([`rpc`]).
pub mod agent_secrets;
pub mod data_dirs;
pub mod diagnostics_store;
pub mod environment;
mod formatter;
mod java_runtime;
@@ -57,9 +59,15 @@ pub struct HostConfig {
pub struct Host {
pub(crate) mcp_executable: RwLock<Option<PathBuf>>,
plugin_host: RwLock<std::sync::Weak<plugins::PluginHost>>,
/// Every `Diagnostics` event the host ever emitted, per file and producer:
/// the snapshot plugins read (`problems`), independent of windows and reports.
pub diagnostics: Arc<diagnostics_store::DiagnosticsStore>,
pub mcp_reports: mcp::Reports,
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(crate) mcp_approvals: Arc<trust::McpServerApprovals>,
pub config_dir: PathBuf,
/// Persistent user state, including credentials and recovery history.
pub data_dir: PathBuf,
@@ -91,6 +99,8 @@ pub struct Host {
proxy_password: ide_ssh::secrets::SecretFile,
/// The proxy password when it could not be saved (no credential store).
proxy_session_password: Mutex<Option<String>>,
/// Values of agents' and MCP servers' environment variables and headers (see `agent_secrets.rs`).
pub agent_secrets: Arc<agent_secrets::AgentSecrets>,
pub(crate) sink_for: SinkFactory,
owner: fn(&str) -> &str,
pub runtime: tokio::runtime::Handle,
@@ -129,17 +139,24 @@ impl Host {
runtime,
project_environment,
} = config;
// Every sink the host hands out records diagnostics into the shared store first.
let diagnostics: Arc<diagnostics_store::DiagnosticsStore> = Arc::default();
let sink_for: SinkFactory = diagnostics_store::recording_sink_factory(diagnostics.clone(), sink_for);
let global_settings = SettingsStore::load(&config_dir, None);
let ssh = Arc::new(ide_ssh::SshManager::new(data_dir.join("ssh-passwords.json")));
ssh.set_configs(global_settings.effective().ssh.configs);
let host = Self {
plugin_host: RwLock::default(),
mcp_executable: RwLock::default(),
diagnostics,
mcp_reports: Default::default(),
mcp_requests: Default::default(),
mcp_agent_runs: Default::default(),
trust: trust::TrustStore::load(&config_dir, &data_dir),
mcp_approvals: Arc::new(trust::McpServerApprovals::load(&data_dir)),
proxy_password: ide_ssh::secrets::SecretFile::new(data_dir.join("proxy-password.json")),
proxy_session_password: Mutex::default(),
agent_secrets: Arc::new(agent_secrets::AgentSecrets::new(&data_dir)),
remote: Arc::new(ide_remote::RemoteService::new(ssh.clone(), data_dir.join("remote"))),
ssh,
environment: environment::Environment::new(project_environment, local_data_dir.clone()),
@@ -171,6 +188,7 @@ impl Host {
open_locks: Default::default(),
};
host.apply_proxy();
ops::agent::move_user_mcp_values(&host);
host
}
@@ -263,6 +281,7 @@ impl Host {
fn begin_close(&self, window: &str) -> Option<std::thread::JoinHandle<()>> {
self.mcp_reports.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));
for request in self.project_requests.write().remove(self.owner_window(window)).unwrap_or_default() {
self.cancel_request(request);
@@ -277,6 +296,9 @@ impl Host {
}
project.files_events_active.store(false, std::sync::atomic::Ordering::Release);
project.plugin_diagnostics.close();
// The producers will re-report when a project reopens; until then the snapshot
// must not answer for files whose project is gone.
self.diagnostics.forget_root(&project.root);
if let Some(plugins) = plugins::editor_plugin_host() {
plugins.invalidate_client_navigation(&project.root, Some(window), None);
plugins.set_active_file(window, None);
+176
Переглянути файл
@@ -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"
);
}
}
+14 -13
Переглянути файл
@@ -87,7 +87,7 @@ async fn create_or_replace(call: &Call, path: PathBuf, text: Option<String>, ove
return Ok(());
}
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).
@@ -112,16 +112,17 @@ fn whole_range(text: &str) -> Range {
/// Whether two paths name the same file (language servers report canonical paths; on a
/// case-insensitive file system `a.txt` and `A.txt` are one file).
fn same_file(a: &Path, b: &Path) -> bool {
if a == b || matches!((dunce::canonicalize(a), dunce::canonicalize(b)), (Ok(x), Ok(y)) if x == y) {
return true;
}
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
if let (Ok(x), Ok(y)) = (std::fs::metadata(a), std::fs::metadata(b)) {
return x.dev() == y.dev() && x.ino() == y.ino();
}
}
a == b || matches!((dunce::canonicalize(a), dunce::canonicalize(b)), (Ok(x), Ok(y)) if x == y) || same_inode(a, b)
}
#[cfg(unix)]
fn same_inode(a: &Path, b: &Path) -> bool {
use std::os::unix::fs::MetadataExt;
matches!((std::fs::metadata(a), std::fs::metadata(b)), (Ok(x), Ok(y)) if x.dev() == y.dev() && x.ino() == y.ino())
}
#[cfg(not(unix))]
fn same_inode(_: &Path, _: &Path) -> bool {
false
}
@@ -316,7 +317,7 @@ async fn apply_op(call: &Call, op: FileOp, notes: &mut Vec<String>) -> IdeResult
preconditions: Vec::new(),
};
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 } => {
// A plain diff's other names count when its `+++` path does not exist.
@@ -399,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() });
}
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 {
notes.push(format!("{shown}: patched the unsaved editor buffer"));
}
+1 -1
Переглянути файл
@@ -385,7 +385,7 @@ targetIndex, or narrow the call with a contextSnippet that shows the one you mea
_ => None,
});
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 {
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));
+1 -1
Переглянути файл
@@ -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!({
"severity": d.severity, "description": d.message,
"lineContent": text.lines().nth(d.range.start.line as usize).unwrap_or_default(),
+34 -9
Переглянути файл
@@ -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.
Can read text sources inside jar/zip archives; compiled class files are not decompiled.
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.",
|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 limit = positive(&call, "limit", DEFAULT_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?;
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))
@@ -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
/// and the last halves of the budget with a `…N lines truncated…` marker between them.
/// Lines `start..` of `text` as `L<n>: <line>`, at most `max_lines` of them. A `window` is
/// 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.
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 count = lines.len();
if start > count {
@@ -86,6 +93,15 @@ fn render_slice(text: &str, start: usize, max_lines: usize) -> IdeResult<String>
if available <= max_lines {
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 tail = max_lines - head;
let head_end = start + head - 1;
@@ -245,7 +261,7 @@ mod tests {
use super::*;
fn slice(text: &str, start: usize, max: usize) -> String {
render_slice(text, start, max).unwrap()
render_slice(text, start, max, false).unwrap()
}
#[test]
@@ -254,18 +270,27 @@ mod tests {
assert_eq!(slice("a\r\nb\n", 1, 10), "L1: a\nL2: b\nL3: ");
assert_eq!(slice("", 1, 10), "L1: ");
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("a\nb", 3, 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, true).unwrap_err().message.contains("offset exceeds file length"));
}
#[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");
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, 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]
fn cuts_long_lines_at_utf16_units() {
let long = "x".repeat(2100);
+4 -1
Переглянути файл
@@ -1,8 +1,9 @@
//! Built-in IDE tools. Transport and discovery live in `ide-mcp`.
mod check;
mod edit;
mod editors;
mod requests;
mod run;
pub(crate) mod run;
mod vcs;
pub use requests::Requests;
mod files;
@@ -16,6 +17,7 @@ use crate::Host;
use ide_core::{CancellationToken, IdeError};
use ide_mcp::{ToolCallError, ToolRegistry};
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::sync::Arc;
@@ -30,6 +32,7 @@ fn tools() -> Vec<tool::Tool> {
let mut tools = files::tools();
tools.extend(search::tools());
tools.extend(editors::tools());
tools.push(check::check_files());
tools.push(symbols::get_symbol_info());
tools.push(symbols::find_references());
tools.extend(edit::tools());
File diff suppressed because it is too large. Load diff
+418
Переглянути файл
@@ -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
Переглянути файл
@@ -15,7 +15,6 @@ use std::sync::Arc;
use std::time::Instant;
const DEFAULT_TIMEOUT_MS: usize = 60_000;
const MAX_TIMEOUT_MS: usize = 3_600_000;
const MAX_PROBLEMS: usize = 300;
/// Console lines attached to a failure whose messages were not parsed.
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 \
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 \
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 {
let files: Vec<String> =
call.str_list("filesToRebuild")?.into_iter().filter(|f| !f.trim().is_empty()).collect();
let rebuild = call.flag("rebuild", false)? && files.is_empty();
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 dirs: Vec<PathBuf> = Vec::new();
for f in &files {
+1 -3
Переглянути файл
@@ -19,8 +19,6 @@ use std::sync::Arc;
use std::time::{Duration, Instant};
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.
const MAX_PROCESS_OUTPUT_LENGTH: usize = 20_000;
/// 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)?;
// 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 configs = load_configs(&call).await?;
let mut config = match &target {
+14 -5
Переглянути файл
@@ -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 configs;
mod output;
@@ -11,15 +13,22 @@ use ide_core::IdeResult;
use std::time::Duration;
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.
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")? {
None => Duration::from_millis(default as u64),
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
Переглянути файл
@@ -434,10 +434,23 @@ fn cwd_of(root: &Path, c: &RunConfig) -> String {
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> {
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()) }
};
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::"]);
}
#[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]
fn cargo_test_args_get_one_separator() {
let root = Path::new("/p");
+131 -2
Переглянути файл
@@ -38,6 +38,14 @@ const LINE_WINDOW: usize = 500;
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.";
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> {
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.
Use this tool for fast text search with match coordinates.
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.
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::optional("paths", ParamType::StringList, PATHS_DESCRIPTION))
.param(Param::optional("limit", ParamType::Integer, LIMIT_DESCRIPTION))
.param(Param::optional("context", ParamType::String, CONTEXT_DESCRIPTION))
.output(result_schema())
}
@@ -66,6 +77,8 @@ fn search_regex() -> Tool {
"Searches for regex matches within project files.
Use this tool when you need regex search with match coordinates.
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.
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::optional("paths", ParamType::StringList, PATHS_DESCRIPTION))
.param(Param::optional("limit", ParamType::Integer, LIMIT_DESCRIPTION))
.param(Param::optional("context", ParamType::String, CONTEXT_DESCRIPTION))
.output(result_schema())
}
@@ -84,6 +98,7 @@ fn search_symbol() -> Tool {
"Searches for symbols (classes, methods, fields).
Use this tool for semantic lookup by identifier fragments.
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.
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.",
))
.param(Param::optional("limit", ParamType::Integer, LIMIT_DESCRIPTION))
.param(Param::optional("context", ParamType::String, CONTEXT_DESCRIPTION))
.output(result_schema())
}
@@ -145,6 +161,13 @@ struct SearchItem {
name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
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 {
@@ -182,7 +205,9 @@ fn result_schema() -> Value {
"type": "object",
"properties": {
"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"],
} },
@@ -207,6 +232,87 @@ fn query(call: &Call, empty: &str) -> IdeResult<String> {
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 ----------------------------------------------------------------
/// The `paths` argument: project-relative globs, `!` excludes.
@@ -416,6 +522,7 @@ struct TextHit {
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 limit = limit(&call, DEFAULT_LIMIT)?;
let around = context(&call)?;
let scope = PathScope::new(call.root(), &call.str_list("paths")?)?;
// Globs Find in Files cannot take are applied afterwards, so the engine must not
// 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;
hits.truncate(limit);
extend_spanning(&call, &query, &mut hits, spanning).await;
let items = hits
let mut items: Vec<SearchItem> = hits
.into_iter()
.map(|h| SearchItem {
file_path: h.file_path,
@@ -505,6 +612,9 @@ async fn text_search(call: Call, is_regex: bool) -> IdeResult<Output> {
..Default::default()
})
.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);
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 q = query(&call, "Search query is empty")?;
let limit = limit(&call, SYMBOL_SEARCH_DEFAULT_LIMIT)?;
let around = context(&call)?;
let scope = PathScope::new(call.root(), &call.str_list("paths")?)?;
let external = call.flag("include_external", false)?;
let host = call.host();
@@ -775,6 +886,13 @@ async fn symbol_search(call: Call) -> IdeResult<Output> {
}
let more = items.len() > limit || timed_out;
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 partial_result_reason = if indexing {
Some(INDEXING_PARTIAL_RESULT_REASON)
@@ -869,6 +987,17 @@ mod tests {
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]
fn path_scopes() {
let (_dir, root) = root();
+187 -12
Переглянути файл
@@ -2,6 +2,7 @@
//! window they run on the host, next to the project they work on.
use super::{Channel, Emit};
use crate::Host;
use crate::agent_secrets::{AgentSecrets, Kept};
use crate::rpc::Dispatcher;
use ide_agent::registry::{AgentRegistry, RegistryPaths};
use ide_agent::{
@@ -342,8 +343,77 @@ pub async fn accept_changes(
type McpServerMap = std::collections::BTreeMap<String, ide_core::settings::AgentMcpServer>;
/// Where the values of the user's MCP servers are kept in [`AgentSecrets`]. A project's own
/// servers keep their values in the project's file, where the project shares them.
const USER_MCP_VALUES: &str = "mcp/user/";
/// Every environment variable and header value of `map`, by its key in [`AgentSecrets`].
fn mcp_values(map: &mut McpServerMap) -> Vec<(String, &mut String)> {
let part = |s: &str| s.replace('%', "%25").replace('/', "%2F");
let mut values = Vec::new();
for (server, cfg) in map.iter_mut() {
for (kind, entries) in [("env", &mut cfg.env), ("header", &mut cfg.headers)] {
for (name, value) in entries.iter_mut() {
values.push((format!("{USER_MCP_VALUES}{}/{kind}/{}", part(server), part(name)), value));
}
}
}
values
}
fn has_mcp_values(map: &McpServerMap) -> bool {
map.values().any(|s| s.env.values().chain(s.headers.values()).any(|v| !v.is_empty()))
}
/// Fills in the saved values. A value written in the settings file wins: an empty one stands for the saved one.
fn fill_mcp_values(secrets: &AgentSecrets, map: &mut McpServerMap) {
for (key, value) in mcp_values(map) {
if value.is_empty()
&& let Some(saved) = secrets.get(&key)
{
*value = saved;
}
}
}
/// Saves the values of `map` and empties them in it; values of servers and names no
/// longer in it are forgotten.
fn take_mcp_values(secrets: &AgentSecrets, map: &mut McpServerMap) -> IdeResult<Kept> {
let mut kept = Kept::Store;
let mut current = HashSet::new();
for (key, value) in mcp_values(map) {
if value.is_empty() {
secrets.delete(&key);
} else if secrets.get(&key).as_deref() != Some(value.as_str()) && secrets.set(&key, value)? == Kept::LocalFile {
kept = Kept::LocalFile;
}
value.clear();
current.insert(key);
}
for key in secrets.keys(USER_MCP_VALUES) {
if !current.contains(&key) {
secrets.delete(&key);
}
}
Ok(kept)
}
/// Values written into the user settings (by hand, by an older version, by an import) move to [`AgentSecrets`].
pub fn move_user_mcp_values(host: &Host) {
let mut user = host.global_settings.effective().agents.mcp_servers;
if !has_mcp_values(&user) {
return;
}
fill_mcp_values(&host.agent_secrets, &mut user);
if let Err(e) = persist_mcp_map(host, None, McpScope::User, &user) {
tracing::warn!("MCP servers: could not move values out of the user settings: {}", e.message);
}
}
fn mcp_maps(host: &Host, window: &str) -> (McpServerMap, McpServerMap, Option<std::sync::Arc<crate::Project>>) {
let user = host.global_settings.effective().agents.mcp_servers;
move_user_mcp_values(host);
let mut user = host.global_settings.effective().agents.mcp_servers;
fill_mcp_values(&host.agent_secrets, &mut user);
let project = host.project(window).ok().filter(|p| !p.light);
let workspace = project
.as_ref()
@@ -358,10 +428,15 @@ fn persist_mcp_map(
scope: McpScope,
map: &McpServerMap,
) -> IdeResult<()> {
let mut map = map.clone();
let kept = match scope {
McpScope::User => take_mcp_values(&host.agent_secrets, &mut map)?,
McpScope::Workspace => Kept::Store,
};
let value = if map.is_empty() {
serde_json::Value::Null
} else {
serde_json::to_value(map).map_err(|e| IdeError::invalid(e.to_string()))?
serde_json::to_value(&map).map_err(|e| IdeError::invalid(e.to_string()))?
};
let event = IdeEvent::SettingsChanged { key: "agents.mcpServers".into() };
match scope {
@@ -370,6 +445,9 @@ fn persist_mcp_map(
for p in host.projects() {
p.settings.reload();
p.sink.emit(event.clone());
if kept == Kept::LocalFile {
host.agent_secrets.notify_kept_locally(&p.sink);
}
}
}
McpScope::Workspace => {
@@ -381,7 +459,7 @@ fn persist_mcp_map(
Ok(())
}
fn update_mcp_maps<F>(host: &Host, window: &str, f: F) -> IdeResult<Vec<McpServer>>
fn update_mcp_maps<F>(host: &Host, window: &str, approve: Option<&str>, f: F) -> IdeResult<Vec<McpServer>>
where
F: FnOnce(&mut McpServerMap, &mut McpServerMap, Option<&std::path::Path>) -> Result<(), String>,
{
@@ -390,43 +468,85 @@ where
let prev_workspace = workspace.clone();
let project_root = project.as_ref().map(|p| p.root.as_path());
f(&mut user, &mut workspace, project_root).map_err(IdeError::invalid)?;
if let (Some(name), Some(p)) = (approve, &project)
&& let Some(server) = workspace.get(name)
{
host.mcp_approvals.approve(&p.root, name, server)?;
}
if workspace != prev_workspace {
persist_mcp_map(host, project.as_ref(), McpScope::Workspace, &workspace)?;
}
if user != prev_user {
persist_mcp_map(host, project.as_ref(), McpScope::User, &user)?;
}
Ok(ide_agent::mcp::servers_from_settings(&user, &workspace))
Ok(listed(host, project.as_ref(), &user, workspace))
}
/// Workspace servers the user hasn't turned on in Settings are listed as off: agents don't start them.
fn listed(
host: &Host,
project: Option<&std::sync::Arc<crate::Project>>,
user: &McpServerMap,
workspace: McpServerMap,
) -> Vec<McpServer> {
let workspace = match project {
Some(p) => host.mcp_approvals.apply(&p.root, workspace),
None => workspace,
};
ide_agent::mcp::servers_from_settings(user, &workspace)
}
/// The MCP servers agent sessions of the project at `root` get: the user's, values filled in, and
/// the project's the user turned on.
pub(crate) fn session_mcp_servers(
settings: &ide_workspace::SettingsStore,
approvals: &crate::trust::McpServerApprovals,
secrets: &AgentSecrets,
root: &std::path::Path,
) -> Vec<McpServer> {
use ide_workspace::settings::SettingsScope;
let mut user = settings.layer(SettingsScope::Global).agents.mcp_servers;
fill_mcp_values(secrets, &mut user);
let workspace = approvals.apply(root, settings.layer(SettingsScope::Project).agents.mcp_servers);
ide_agent::mcp::servers_from_settings(&user, &workspace)
}
/// Lists configured MCP servers across User (`settings.toml`) and Workspace (`.openidea/settings.toml`) scopes.
pub fn mcp_list(host: &Host, window: &str) -> IdeResult<Vec<McpServer>> {
let (user, workspace, _) = mcp_maps(host, window);
Ok(ide_agent::mcp::servers_from_settings(&user, &workspace))
let (user, workspace, project) = mcp_maps(host, window);
Ok(listed(host, project.as_ref(), &user, workspace))
}
/// Creates or updates a User or Workspace MCP server and returns the updated list.
/// Creates or updates a User or Workspace MCP server and returns the updated list. A Workspace
/// server saved here is one the user turned on.
pub fn mcp_save(host: &Host, window: &str, input: McpServerInput) -> IdeResult<Vec<McpServer>> {
update_mcp_maps(host, window, |user, workspace, project_root| {
let approve = (input.scope == McpScope::Workspace).then(|| input.name.trim().to_owned());
update_mcp_maps(host, window, approve.as_deref(), |user, workspace, project_root| {
ide_agent::mcp::apply_save(user, workspace, project_root.is_some(), input)
})
}
/// Enables or disables an MCP server and returns the updated list.
pub fn mcp_set_enabled(host: &Host, window: &str, id: String, enabled: bool) -> IdeResult<Vec<McpServer>> {
update_mcp_maps(host, window, |user, workspace, _| {
let approve = ide_agent::mcp::parse_server_id(&id)
.filter(|(scope, _)| enabled && *scope == McpScope::Workspace)
.map(|(_, name)| name.to_owned());
update_mcp_maps(host, window, approve.as_deref(), |user, workspace, _| {
ide_agent::mcp::apply_set_enabled(user, workspace, &id, enabled).map(|_| ())
})
}
/// Removes a User or Workspace MCP server and returns the updated list.
pub fn mcp_remove(host: &Host, window: &str, id: String) -> IdeResult<Vec<McpServer>> {
update_mcp_maps(host, window, |user, workspace, _| ide_agent::mcp::apply_remove(user, workspace, &id).map(|_| ()))
update_mcp_maps(host, window, None, |user, workspace, _| {
ide_agent::mcp::apply_remove(user, workspace, &id).map(|_| ())
})
}
/// Copies MCP servers from `.mcp.json` and `.vscode/mcp.json` into the workspace settings once.
/// Copies MCP servers from `.mcp.json` and `.vscode/mcp.json` into the workspace settings once;
/// they stay off until the user turns them on.
pub fn mcp_import(host: &Host, window: &str) -> IdeResult<Vec<McpServer>> {
update_mcp_maps(host, window, |_, workspace, project_root| {
update_mcp_maps(host, window, None, |_, workspace, project_root| {
let root = project_root.ok_or_else(|| "Open a project to import workspace MCP servers".to_string())?;
ide_agent::mcp::apply_import(workspace, root);
Ok(())
@@ -550,4 +670,59 @@ mod tests {
assert!(validate_project_root(&root, Some(&rejected)).is_err(), "{rejected:?}");
}
}
#[test]
fn sessions_get_the_users_servers_and_the_projects_turned_on_ones() {
ide_ssh::secrets::testing::use_memory_store();
let tmp = tempfile::tempdir().unwrap();
let (config, root) = (tmp.path().join("config"), tmp.path().join("project"));
std::fs::create_dir_all(&config).unwrap();
std::fs::create_dir_all(root.join(".openidea")).unwrap();
std::fs::write(config.join("settings.toml"), "[agents.mcpServers.shared]\ncommand = \"user-cmd\"\n").unwrap();
std::fs::write(
root.join(".openidea/settings.toml"),
"[agents.mcpServers.shared]\ncommand = \"project-cmd\"\n\n[agents.mcpServers.extra]\ncommand = \"extra\"\n",
)
.unwrap();
let settings = ide_workspace::SettingsStore::load(&config, Some(&root));
let approvals = crate::trust::McpServerApprovals::load(&tmp.path().join("data"));
let secrets = AgentSecrets::new(&tmp.path().join("data"));
let on = |servers: Vec<McpServer>| -> Vec<(McpScope, String, String)> {
servers
.into_iter()
.filter(|s| s.enabled)
.map(|s| (s.scope, s.name, s.command.unwrap_or_default()))
.collect()
};
assert_eq!(
on(session_mcp_servers(&settings, &approvals, &secrets, &root)),
[(McpScope::User, "shared".to_owned(), "user-cmd".to_owned())]
);
// A user server's saved values reach the session; the file keeps only the names.
std::fs::write(
config.join("settings.toml"),
"[agents.mcpServers.keyed]\ncommand = \"k\"\nenv = { TOKEN = \"\" }\n",
)
.unwrap();
secrets.set("mcp/user/keyed/env/TOKEN", "saved-user").unwrap();
settings.reload();
let keyed = session_mcp_servers(&settings, &approvals, &secrets, &root);
let keyed = keyed.iter().find(|s| s.name == "keyed").unwrap();
assert_eq!(keyed.env[0].value, "saved-user");
std::fs::write(config.join("settings.toml"), "[agents.mcpServers.shared]\ncommand = \"user-cmd\"\n").unwrap();
settings.reload();
let extra = settings.layer(ide_workspace::settings::SettingsScope::Project).agents.mcp_servers["extra"].clone();
approvals.approve(&root, "extra", &extra).unwrap();
assert!(on(session_mcp_servers(&settings, &approvals, &secrets, &root)).contains(&(
McpScope::Workspace,
"extra".to_owned(),
"extra".to_owned()
)));
// Remembered across restarts, and only for this project.
let reloaded = crate::trust::McpServerApprovals::load(&tmp.path().join("data"));
assert!(reloaded.approved(&root, "extra", &extra));
assert!(!reloaded.approved(&tmp.path().join("other"), "extra", &extra));
}
}
+295 -4
Переглянути файл
@@ -2,17 +2,20 @@
//! Build › Build Project (compiling with the project's build system).
mod workspace;
use super::dependencies::{MavenCentralArtifacts, MavenCentralGroups, MavenCentralVersions};
use crate::ops::Channel;
use crate::rpc::Dispatcher;
use crate::{Host, Project, apply_workspace_edit};
use ide_build::compile::{self, BuildEvent, BuildKind, BuildResult};
use ide_build::maven::PomGoto;
use ide_build::maven::completion::{PomCompletion, PomCompletionOption};
use ide_build::pom_edit::{ArtifactKey, Exclusion};
use ide_build::{BuildModel, BuildTool, SyncOptions, SyncProgress};
use ide_core::settings::Settings;
use ide_core::{FileEdit, IdeError, IdeEvent, IdeResult, Location, Pos, Range, RequestId, WorkspaceEdit};
use ide_core::{DocId, FileEdit, IdeError, IdeEvent, IdeResult, Location, Pos, Range, RequestId, WorkspaceEdit};
use std::path::Path;
use std::sync::Arc;
fn join_err(e: tokio::task::JoinError) -> IdeError {
IdeError::internal(e.to_string())
@@ -311,10 +314,11 @@ pub async fn build_sync(
if tool == BuildTool::Gradle {
// The sync may report project folders outside the project (`includeFlat`): index and list them.
project.refresh_index_scope();
project.sink.emit(ide_core::IdeEvent::ProjectRootsChanged { root: project.root.clone().into() });
let p = project.clone();
tokio::task::spawn_blocking(move || p.refresh_repositories());
}
// 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
&& model.maven.is_some()
&& let Some(bridge) = &project.kotlin_bridge
@@ -327,21 +331,61 @@ pub async fn build_sync(
// holds for Gradle: it reuses its cached classpath on restart, so a
// dependency added to `build.gradle` stays unresolved until told to sync.
let lsp = project.lsp.clone();
let documents = project.documents.clone();
tokio::spawn(async move {
if profiles_changed {
// jdtls takes the profiles from its Maven settings (see `ide_sdk::maven_profiles`).
lsp.reconfigure().await;
}
let first = updates.first().cloned();
for file in updates {
let uri = ide_lsp::path_to_uri_string(&file);
if let Err(e) = lsp.java_update_project_configs(&file, vec![uri]).await {
tracing::debug!("java/projectConfigurationsUpdate: {e}");
}
}
let changed = {
let lsp = lsp.clone();
tokio::task::spawn_blocking(move || lsp.changed_library_archives()).await.unwrap_or_default()
};
if let Some(file) = first
&& let Err(e) = lsp.java_refresh_archives(&file, &changed).await
{
tracing::debug!("openidea.java.refreshArchives: {e}");
}
refresh_library_classes(&lsp, &documents).await;
});
Ok(model)
}
/// Open library classes whose jar was replaced in place (a SNAPSHOT installed
/// again keeps its path) show the new contents, like a reopened one.
async fn refresh_library_classes(lsp: &Arc<ide_lsp::LspManager>, documents: &Arc<ide_workspace::Documents>) {
let changed = {
let (lsp, documents) = (lsp.clone(), documents.clone());
tokio::task::spawn_blocking(move || {
documents
.open_documents()
.into_iter()
.filter_map(|doc| Some((doc, documents.virtual_uri(doc).ok().flatten()?)))
.filter(|(_, uri)| lsp.library_class_changed(uri))
.collect::<Vec<_>>()
})
.await
.unwrap_or_default()
};
for (doc, uri) in changed {
match lsp.virtual_document(&uri).await {
Ok(vd) => {
if let Err(e) = documents.replace_virtual(doc, &vd.text, &vd.encoding, vd.has_bom) {
tracing::debug!("refreshing {uri}: {e}");
}
}
Err(e) => tracing::debug!("refreshing {uri}: {e}"),
}
}
}
/// The POM declaring `dependency` for the Maven module `pom` (project-relative): the
/// module's own, else its nearest parent in the project; its path, text and declaration.
fn declaring_pom(
@@ -453,7 +497,8 @@ pub async fn maven_dependency_declaration(
/// absolute for a parent POM opened from the local repository): the definition of the
/// `${property}` at the caret, or the `<dependencyManagement>` / `<pluginManagement>`
/// entry providing the dependency's / plugin's version, along the parent chain (into
/// the local repository when a parent is not in the project). `None` when nothing
/// the local repository when a parent is not in the project), else the artifact's own
/// POM (a project module's, or the local repository's). `None` when nothing
/// resolvable is under the caret.
pub async fn maven_goto_declaration(host: &Host, window: &str, pom: String, pos: Pos) -> IdeResult<Option<PomGoto>> {
let project = host.project(window)?;
@@ -464,12 +509,107 @@ pub async fn maven_goto_declaration(host: &Host, window: &str, pom: String, pos:
let settings = project.settings.effective();
let repo = ide_sdk::apply::maven_local_repository(&project.root, &settings)
.or_else(ide_build::maven::default_local_repo);
Ok(ide_build::maven::goto_declaration(&text, &path, pos, repo))
let modules = || {
let model = load(&project.root, &project.build_cache_dir(), &settings).maven.unwrap_or_default();
model
.projects
.into_iter()
.map(|p| ide_build::maven::ModulePom {
group_id: p.group_id,
artifact_id: p.artifact_id,
pom: project.root.join(&p.pom),
})
.collect()
};
Ok(ide_build::maven::goto_declaration_in(&text, &path, pos, repo, modules))
})
.await
.map_err(join_err)?
}
/// How long completion waits for Maven Central before answering from the local repository.
const CENTRAL_WAIT: std::time::Duration = std::time::Duration::from_secs(1);
/// Completion of the `<dependency>` coordinate the caret (`offset`, UTF-16) sits
/// in the value of in the POM `doc`: the coordinates installed in the local
/// repository (Settings › Maven's, else `~/.m2/repository`) — group ids,
/// the chosen group's artifacts, the chosen artifact's versions — topped up
/// with Maven Central's (the search endpoint's popular coordinates, the
/// published version metadata), which the shared version cache keeps for the
/// session. `None` when the caret is not in a `<dependency>` coordinate element.
pub async fn maven_dependency_completion(
host: &Host,
window: &str,
doc: DocId,
offset: u32,
request: RequestId,
) -> IdeResult<Option<PomCompletion>> {
use ide_build::maven::completion::{PomCompletionKind, local_options};
let project = host.project(window)?;
let cache = host.dependency_versions.clone();
let cancel = host.begin_project_read_request(window, request);
let result = host
.blocking(move || {
let snap = project.documents.snapshot(doc)?;
if snap.large || snap.read_only || !snap.path.file_name().is_some_and(|name| name == "pom.xml") {
return Ok(None);
}
let Some(target) = ide_build::maven::completion::completion_target(&snap.text, offset) else {
return Ok(None);
};
let settings = project.settings.effective();
let repo = ide_sdk::apply::maven_local_repository(&project.root, &settings)
.or_else(ide_build::maven::default_local_repo);
let mut options = repo.map_or_else(Vec::new, |repo| local_options(&repo, &target));
// Central's answers are cached for the session (the shared version cache).
// The local repository answers at once: Central gets `CENTRAL_WAIT`, then the
// lookup finishes in the background and fills the cache for the next request.
// Offline, behind a proxy or rate-limited, a failed fetch never fails completion.
let remote = {
let (cache, target) = (cache.clone(), target.clone());
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let detached = ide_core::CancellationToken::new();
let found = match target.kind {
PomCompletionKind::GroupId if !target.typed.is_empty() => {
cache.versions(&MavenCentralGroups, &target.typed, &detached).unwrap_or_default()
}
PomCompletionKind::ArtifactId => match &target.group {
Some(group) => cache
.versions(&MavenCentralArtifacts { group: group.clone() }, &target.typed, &detached)
.unwrap_or_default(),
None => Vec::new(),
},
PomCompletionKind::Version => match (&target.group, &target.artifact) {
(Some(group), Some(artifact)) => cache
.versions(&MavenCentralVersions, &format!("{group}:{artifact}"), &detached)
.unwrap_or_default(),
_ => Vec::new(),
},
_ => Vec::new(), // A group id from nothing: the search endpoint has no such query.
};
let _ = tx.send(found);
});
rx.recv_timeout(CENTRAL_WAIT).unwrap_or_default()
};
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let known: std::collections::HashSet<String> = options.iter().map(|o| o.label.clone()).collect();
options.extend(
remote
.into_iter()
.filter(|label| !known.contains(label))
.map(|label| PomCompletionOption { label, detail: None }),
);
options.truncate(ide_build::maven::completion::MAX_OPTIONS);
Ok(Some(PomCompletion { from: target.from, to: target.to, kind: target.kind, options }))
})
.await;
host.end_request(request);
result
}
/// Adds an `<exclusion>` of `exclusion` to the declaration of the direct dependency
/// `dependency` of the Maven module `pom`, through the open document when there is one
/// (saved). Returns the declaration; nothing changes when it already excludes it.
@@ -711,6 +851,9 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("maven_goto_declaration", |c| async move {
maven_goto_declaration(&c.host, &c.window, c.arg("pom")?, c.arg("pos")?).await
});
d.add("maven_dependency_completion", |c| async move {
maven_dependency_completion(&c.host, &c.window, c.arg("doc")?, c.arg("offset")?, c.arg("request")?).await
});
d.add("maven_exclude_dependency", |c| async move {
maven_exclude_dependency(&c.host, &c.window, c.arg("pom")?, c.arg("dependency")?, c.arg("exclusion")?).await
});
@@ -883,6 +1026,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]
fn model_for_sync_tasks_and_compile_uses_gradle_settings() {
let tmp = tempfile::tempdir().unwrap();
@@ -909,3 +1084,119 @@ mod build_tool_settings_tests {
assert_eq!(model.env["GRADLE_USER_HOME"], root.join("user-home").to_string_lossy());
}
}
#[cfg(test)]
mod maven_completion_tests {
use super::*;
use crate::ops::dependencies::hashed_key;
use ide_build::maven::completion::PomCompletionKind;
use ide_core::CancellationToken;
use ide_workspace::dependencies::DependencyVersionSource;
/// Cache answers the test plants before completing, so the op's Central
/// lookups hit the session cache and the network is never reached.
struct FakeSource(&'static str, Vec<&'static str>);
impl DependencyVersionSource for FakeSource {
fn id(&self) -> &'static str {
self.0
}
fn cache_key(&self, package: &str) -> Option<String> {
Some(hashed_key(self.0, package))
}
fn fetch(&self, _: &str, _: &CancellationToken) -> IdeResult<Vec<String>> {
Ok(self.1.iter().map(|s| (*s).to_owned()).collect())
}
}
const GROUPS: &str = "maven-central-groups";
const ARTIFACTS: &str = "maven-central-artifacts";
const VERSIONS: &str = "maven-central-versions";
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn completes_dependency_coordinates_from_the_local_repository_then_central() {
let temp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(temp.path()).unwrap();
let (root, repo) = (base.join("project"), base.join("repository"));
std::fs::create_dir_all(&root).unwrap();
// Settings › Maven's local repository points at the fixture, so the
// test machine's own ~/.m2 is never read.
std::fs::create_dir_all(base.join("config")).unwrap();
std::fs::write(
base.join("config/settings.toml"),
format!("[languages.java.maven]\nlocalRepository = \"{}\"\n", repo.to_string_lossy().replace('\\', "/")),
)
.unwrap();
for file in [
"org/apache/maven/maven-core/4.0.0-rc-5/maven-core-4.0.0-rc-5.jar",
"org/example/lib/2.0/lib-2.0.jar",
"org/lib/1.0/lib-1.0.jar",
] {
let path = repo.join(file);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, b"").unwrap();
}
let text = "<project>\n <dependencies>\n <dependency>\n <groupId>org</groupId>\n <artifactId>lib</artifactId>\n <version>1.0</version>\n </dependency>\n <dependency>\n <groupId>org.example</groupId>\n <artifactId></artifactId>\n </dependency>\n <dependency>\n <groupId>org.example</groupId>\n <artifactId>lib</artifactId>\n <version></version>\n </dependency>\n </dependencies>\n</project>\n";
let path = root.join("pom.xml");
std::fs::write(&path, text).unwrap();
let host = Host::new(crate::HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("local"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(|_| {})),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
let project = host.open_project("main", &root).unwrap();
let info = project.documents.open(&path).unwrap();
let token = CancellationToken::new();
host.dependency_versions.versions(&FakeSource(GROUPS, vec!["org.zzz"]), "org", &token).unwrap();
host.dependency_versions.versions(&FakeSource(ARTIFACTS, vec!["api"]), "org.example\u{1}", &token).unwrap();
host.dependency_versions.versions(&FakeSource(VERSIONS, vec!["9.9"]), "org.example:lib", &token).unwrap();
let at = |needle: &str| (text.find(needle).unwrap() + needle.len()) as u32;
let next = std::sync::atomic::AtomicU32::new(500);
let complete = |offset: u32| {
maven_dependency_completion(
&host,
"main",
info.id,
offset,
RequestId(next.fetch_add(1, std::sync::atomic::Ordering::Relaxed)),
)
};
fn labels(c: &PomCompletion) -> Vec<&str> {
c.options.iter().map(|o| o.label.as_str()).collect()
}
let group =
complete(text.find("<groupId>org").unwrap() as u32 + "<groupId>org".len() as u32).await.unwrap().unwrap();
assert_eq!(group.kind, PomCompletionKind::GroupId);
// The installed groups first, Central's cached suggestions behind them.
assert_eq!(labels(&group), ["org", "org.apache.maven", "org.example", "org.zzz"]);
// Nothing outside a dependency's coordinates.
let outside = complete(at("<dependencies>")).await;
assert!(matches!(outside, Ok(None)), "{outside:?}");
let empty_artifact = text.find("<artifactId></artifactId>").unwrap() as u32 + "<artifactId>".len() as u32;
let artifact = complete(empty_artifact).await;
let artifact = match artifact {
Ok(Some(a)) => a,
other => panic!("artifact completion: {other:?}"),
};
assert_eq!(artifact.kind, PomCompletionKind::ArtifactId);
let installed = artifact.options.first().unwrap();
assert_eq!((installed.label.as_str(), installed.detail.as_deref()), ("lib", Some("2.0")));
assert_eq!(labels(&artifact), ["lib", "api"]);
let empty_version = text.find("<version></version>").unwrap() as u32 + "<version>".len() as u32;
let version = complete(empty_version).await.unwrap().unwrap();
assert_eq!(version.kind, PomCompletionKind::Version);
assert_eq!(labels(&version), ["2.0", "9.9"]);
host.close_project("main");
}
}
+15
Переглянути файл
@@ -42,6 +42,10 @@ pub(crate) fn register(d: &mut Dispatcher) {
);
test(&c.host, &c.window, source, password, ssh_secret).await
});
d.add("db_test_tunnel", |c| async move {
let (source, ssh_secret) = (c.arg::<DataSource>("source")?, c.arg::<Option<String>>("sshSecret")?);
test_tunnel(&c.host, &c.window, source, ssh_secret).await
});
d.add("db_set_ssh_secret", |c| async move {
let (id, secret, save) = (c.arg::<String>("id")?, c.arg::<Option<String>>("secret")?, c.arg::<bool>("save")?);
set_ssh_secret(&c.host, &c.window, &id, secret, save).await
@@ -212,6 +216,17 @@ pub async fn test(
db.test(&source, password, ssh_secret).await
}
/// Checks the SSH tunnel of `source` alone: SSH login and the SSH host reaching the database.
pub async fn test_tunnel(
host: &Host,
window: &str,
source: DataSource,
ssh_secret: Option<String>,
) -> IdeResult<String> {
let db = host.project(window)?.db.clone();
db.test_tunnel(&source, ssh_secret).await
}
/// Remembers the SSH password / key passphrase of a data source (see `set_password`).
pub async fn set_ssh_secret(host: &Host, window: &str, id: &str, secret: Option<String>, save: bool) -> IdeResult<()> {
let db = host.project(window)?.db.clone();
+25
Переглянути файл
@@ -372,6 +372,25 @@ pub async fn debug_restart_frame(host: &Host, _window: &str, session: DebugSessi
host.debugger.restart_frame(session, frame_id).await
}
pub async fn debug_js_custom_breakpoints(
host: &Host,
_window: &str,
session: DebugSessionId,
ids: Vec<String>,
xhr: Vec<String>,
) -> IdeResult<()> {
host.debugger.js_set_custom_breakpoints(session, ids, xhr).await
}
pub async fn debug_js_response_body(
host: &Host,
_window: &str,
session: DebugSessionId,
request_id: String,
) -> IdeResult<ide_dap::JsResponseBody> {
host.debugger.js_response_body(session, request_id).await
}
pub fn debug_capabilities(host: &Host, _window: &str, session: DebugSessionId) -> IdeResult<DebugCapabilities> {
host.debugger.capabilities(session)
}
@@ -588,6 +607,12 @@ pub(crate) fn register(d: &mut Dispatcher) {
debug_restart_frame(&c.host, &c.window, c.arg("session")?, c.arg("frameId")?).await
});
d.add("debug_capabilities", |c| async move { debug_capabilities(&c.host, &c.window, c.arg("session")?) });
d.add("debug_js_response_body", |c| async move {
debug_js_response_body(&c.host, &c.window, c.arg("session")?, c.arg("requestId")?).await
});
d.add("debug_js_custom_breakpoints", |c| async move {
debug_js_custom_breakpoints(&c.host, &c.window, c.arg("session")?, c.arg("ids")?, c.arg("xhr")?).await
});
d.add("debug_set_function_breakpoints", |c| async move {
debug_set_function_breakpoints(&c.host, &c.window, c.arg("breakpoints")?).await
});
+74 -1
Переглянути файл
@@ -1,7 +1,7 @@
//! Repository version lookups for manifest inspections and completion.
use crate::{Host, rpc::Dispatcher};
use ide_core::{CancellationToken, DocId, IdeError, IdeResult, RequestId};
use ide_workspace::dependencies::{DependencyVersionSource, DependencyVersions, cargo, go, npm};
use ide_workspace::dependencies::{DependencyVersionSource, DependencyVersions, cargo, go, maven, npm};
use std::collections::BTreeMap;
use std::hash::{DefaultHasher, Hash, Hasher};
use std::path::{Path, PathBuf};
@@ -25,6 +25,79 @@ impl DependencyVersionSource for CratesIo {
}
}
/// A cache key for a `package` that is not a filesystem-safe name (a Maven
/// coordinate with dots, a query): hashed under the source's id.
pub(crate) fn hashed_key(id: &str, package: &str) -> String {
let mut hash = DefaultHasher::new();
package.hash(&mut hash);
format!("{id}-{:016x}", hash.finish())
}
/// Maven Central's group-id suggestions: the search endpoint's groups matching
/// a typed prefix, most recently updated first.
pub(crate) struct MavenCentralGroups;
impl DependencyVersionSource for MavenCentralGroups {
fn id(&self) -> &'static str {
"maven-central-groups"
}
fn cache_key(&self, prefix: &str) -> Option<String> {
Some(hashed_key(self.id(), prefix))
}
fn fetch(&self, prefix: &str, cancel: &CancellationToken) -> IdeResult<Vec<String>> {
let url = maven::groups_url(prefix).ok_or_else(|| IdeError::internal("invalid group prefix"))?;
let search_path = std::env::var_os("PATH").unwrap_or_default();
let text =
ide_tools::Http::new(&search_path)?.https_only().get_text_limited(&url, Some(1024 * 1024), cancel)?;
maven::parse_search_groups(&text).ok_or_else(|| IdeError::internal("invalid search response"))
}
}
/// The artifacts of one group matching a typed prefix, from the same endpoint.
pub(crate) struct MavenCentralArtifacts {
pub group: String,
}
impl DependencyVersionSource for MavenCentralArtifacts {
fn id(&self) -> &'static str {
"maven-central-artifacts"
}
fn cache_key(&self, prefix: &str) -> Option<String> {
Some(hashed_key(self.id(), &format!("{}\u{1}{prefix}", self.group)))
}
fn fetch(&self, prefix: &str, cancel: &CancellationToken) -> IdeResult<Vec<String>> {
let url =
maven::artifacts_url(&self.group, prefix).ok_or_else(|| IdeError::internal("invalid artifact prefix"))?;
let search_path = std::env::var_os("PATH").unwrap_or_default();
let text =
ide_tools::Http::new(&search_path)?.https_only().get_text_limited(&url, Some(1024 * 1024), cancel)?;
maven::parse_search_artifacts(&text).ok_or_else(|| IdeError::internal("invalid search response"))
}
}
/// Every version published of one `group:artifact` on Central, newest first.
pub(crate) struct MavenCentralVersions;
impl DependencyVersionSource for MavenCentralVersions {
fn id(&self) -> &'static str {
"maven-central-versions"
}
fn cache_key(&self, coordinates: &str) -> Option<String> {
Some(hashed_key(self.id(), coordinates))
}
fn fetch(&self, coordinates: &str, cancel: &CancellationToken) -> IdeResult<Vec<String>> {
let (group, artifact) = coordinates.split_once(':').ok_or_else(|| IdeError::internal("invalid coordinates"))?;
let url = maven::metadata_url(group, artifact).ok_or_else(|| IdeError::internal("invalid coordinates"))?;
let search_path = std::env::var_os("PATH").unwrap_or_default();
let text =
ide_tools::Http::new(&search_path)?.https_only().get_text_limited(&url, Some(2 * 1024 * 1024), cancel)?;
Ok(maven::parse_metadata_versions(&text))
}
}
struct NpmRegistry;
impl DependencyVersionSource for NpmRegistry {
fn id(&self) -> &'static str {
+9 -3
Переглянути файл
@@ -188,8 +188,14 @@ pub async fn remote_download_into(
r
}
pub async fn deploy_test_connection(state: &Host, connection: FsConnection, path: String) -> IdeResult<()> {
state.remote.test_connection(&connection, &path).await
/// `password`: an FTP password typed in the settings but not applied yet.
pub async fn deploy_test_connection(
state: &Host,
connection: FsConnection,
path: String,
password: Option<String>,
) -> IdeResult<()> {
state.remote.test_connection_with(&connection, &path, password).await
}
pub(crate) fn register(d: &mut crate::rpc::Dispatcher) {
@@ -231,6 +237,6 @@ pub(crate) fn register(d: &mut crate::rpc::Dispatcher) {
remote_rename(&c.host, c.arg("connection")?, c.arg("from")?, c.arg("to")?).await
});
d.add("deploy_test_connection", |c| async move {
deploy_test_connection(&c.host, c.arg("connection")?, c.arg("path")?).await
deploy_test_connection(&c.host, c.arg("connection")?, c.arg("path")?, c.arg("password")?).await
});
}
+53 -10
Переглянути файл
@@ -114,14 +114,28 @@ pub fn create_file(host: &Host, window: &str, path: PathBuf) -> IdeResult<()> {
if empty && !bom.is_empty() {
std::fs::write(&path, bom)?;
}
// A checkout backend opens the file for add; Git's hook is a no-op.
if let Err(e) = p.with_backend_of(&path, |r| r.files_added(std::slice::from_ref(&path))) {
tracing::debug!("vcs files_added for {}: {e}", path.display());
}
Ok(())
}
Err(_) => ide_vfs::create_file(&path),
}
}
pub async fn create_file_bytes(host: &Host, path: PathBuf, data: Vec<u8>) -> IdeResult<()> {
host.blocking(move || ide_vfs::create_file_bytes(&path, &data)).await
pub async fn create_file_bytes(host: &Host, window: &str, path: PathBuf, data: Vec<u8>) -> IdeResult<()> {
let project = host.project(window).ok();
host.blocking(move || {
ide_vfs::create_file_bytes(&path, &data)?;
if let Some(p) = project
&& let Err(e) = p.with_backend_of(&path, |r| r.files_added(std::slice::from_ref(&path)))
{
tracing::debug!("vcs files_added for {}: {e}", path.display());
}
Ok(())
})
.await
}
/// Toggle Read-only Attribute: sets the writable bit (and the open document's
@@ -135,25 +149,54 @@ pub fn set_writable(host: &Host, window: &str, path: PathBuf, writable: bool) ->
/// Moves to the trash; Local History keeps the contents so deleted files can be restored.
pub async fn delete(host: &Host, window: &str, path: PathBuf) -> IdeResult<()> {
let history = host.project(window).ok().map(|p| p.history.clone());
let project = host.project(window).ok();
let history = project.as_ref().map(|p| p.history.clone());
host.blocking(move || {
let paths = ide_workspace::file_ops::deletion_paths(&path)?;
if let Some(h) = history {
for p in paths {
h.record_deletion(&p, DELETE_SNAPSHOT_LIMIT);
for p in &paths {
h.record_deletion(p, DELETE_SNAPSHOT_LIMIT);
}
}
ide_workspace::file_ops::delete(&path)
ide_workspace::file_ops::delete(&path)?;
// A checkout backend opens the paths for delete; Git's hook is a no-op.
if let Some(p) = project {
let _ = p.with_backend_of(&path, |r| r.files_deleted(&paths));
}
Ok(())
})
.await
}
pub async fn copy(host: &Host, from: PathBuf, to: PathBuf) -> IdeResult<()> {
host.blocking(move || ide_workspace::file_ops::copy(&from, &to)).await
pub async fn copy(host: &Host, window: &str, from: PathBuf, to: PathBuf) -> IdeResult<()> {
let project = host.project(window).ok();
host.blocking(move || {
ide_workspace::file_ops::copy(&from, &to)?;
// A copy is an add at the destination; Git's hook is a no-op.
if let Some(p) = project
&& let Err(e) = p.with_backend_of(&to, |r| r.files_added(std::slice::from_ref(&to)))
{
tracing::debug!("vcs files_added for {}: {e}", to.display());
}
Ok(())
})
.await
}
pub async fn rename(host: &Host, from: PathBuf, to: PathBuf) -> IdeResult<()> {
host.blocking(move || ide_workspace::file_ops::rename(&from, &to)).await
pub async fn rename(host: &Host, window: &str, from: PathBuf, to: PathBuf) -> IdeResult<()> {
let project = host.project(window).ok();
host.blocking(move || {
ide_workspace::file_ops::rename(&from, &to)?;
// A checkout backend records the rename so history follows the file;
// Git's hook is a no-op.
if let Some(p) = project
&& let Err(e) = p.with_backend_of(&to, |r| r.file_moved(&from, &to))
{
tracing::debug!("vcs file_moved {} → {}: {e}", from.display(), to.display());
}
Ok(())
})
.await
}
/// Reads a text file without opening it as a document; prefers the unsaved buffer.
+166 -30
Переглянути файл
@@ -48,7 +48,7 @@ async fn resolve_launch(
let build = policy != BuildPolicy::NoBuild;
let full = policy == BuildPolicy::Rebuild;
let build_kind = if full { compile::BuildKind::Rebuild } else { compile::BuildKind::Build };
let (model, external, delegated, prefer_maven) = {
let (model, external, mut delegated, prefer_maven, is_maven) = {
let project = project.clone();
let settings = settings.clone();
let module = module.clone();
@@ -68,30 +68,47 @@ async fn resolve_launch(
prefer_maven,
);
let external = non_java || delegated;
(model, external, delegated, prefer_maven)
(model, external, delegated, prefer_maven, is_maven)
})
.await
.map_err(|e| IdeError::internal(e.to_string()))?
};
let external_build = if delegated { Some(project.lsp.java_external_build(&module, cancel).await?) } else { None };
let mut external_build =
if delegated { Some(project.lsp.java_external_build(&module, cancel).await?) } else { None };
let mut info = project.lsp.java_launch(&module, test, build && !delegated, full, delegated, cancel).await?;
if info.errors.iter().any(|e| processor_recreated_a_file(&e.message)) {
if build && !delegated && is_maven {
// JDT runs an annotation processor again over sources it generated in the same build
// (MapStruct over MapStruct-Plus's mappers) and fails on the file it already wrote;
// javac doesn't, so Maven builds the module.
tracing::info!("Annotation processing failed in JDT; building with Maven");
delegated = true;
external_build = Some(project.lsp.java_external_build(&module, cancel).await?);
info = project.lsp.java_launch(&module, test, false, false, true, cancel).await?;
}
if delegated {
// Maven runs the annotation processors itself; JDT's failure to rerun one is no compile error.
drop_processor_recreate_errors(&mut info);
}
}
let lsp_ms = started.elapsed().as_millis();
let data_dir = host.local_data_dir.clone();
let cancel = cancel.clone();
tokio::task::spawn_blocking(move || {
let _external_build = external_build;
let local_repo = ide_sdk::apply::maven_local_repository(&project.root, &settings);
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(
&project.root,
&model,
&module,
cache::BuildOptions { test, policy },
ide_sdk::apply::maven_local_repository(&project.root, &settings),
cache::BuildOptions { test, policy, rebuild_on_jdt_errors },
local_repo.clone(),
&cancel,
)?;
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
} else if external {
let built = compile::build_for_launch(&project.root, &model, &module, test, build_kind, &cancel)?;
@@ -107,10 +124,22 @@ async fn resolve_launch(
Vec::new()
};
let mut class_paths: Vec<String> = built.iter().map(|p| p.to_string_lossy().into_owned()).collect();
for p in std::mem::take(&mut info.class_paths) {
if !class_paths.iter().any(|c| same_entry(c, &p, cfg!(windows))) {
class_paths.push(p);
}
if delegated {
// m2e runs inside jdtls on the server JDK and can resolve a different
// 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;
apply_library_classpath(&mut info, &project.root, &settings.project_structure.libraries, test);
@@ -175,21 +204,18 @@ async fn resolve_launch(
/// 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
/// compiler, so a launch JDT reports errors for recompiles everything.
fn delegated_policy(policy: BuildPolicy, prefer_maven: bool, jdt_errors: u32) -> BuildPolicy {
if prefer_maven && jdt_errors > 0 && matches!(policy, BuildPolicy::Automatic | BuildPolicy::Make) {
BuildPolicy::Rebuild
} else {
policy
}
fn rebuild_on_jdt_errors(policy: BuildPolicy, prefer_maven: bool, jdt_errors: u32) -> bool {
prefer_maven && jdt_errors > 0 && matches!(policy, BuildPolicy::Automatic | BuildPolicy::Make)
}
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.hints.push(failure);
JavaBuildStatus::Failed
} else if clean {
// A clean external build recompiled every source; JDT's diagnostics still describe
// the previous output (and reject sources javac accepts, e.g. a package mismatch).
} else if authoritative {
// A clean rebuild or a fingerprint-verified snapshot is authoritative. JDT diagnostics
// 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.error_count = 0;
JavaBuildStatus::Succeeded
@@ -270,6 +296,44 @@ fn same_entry(a: &str, b: &str, windows: bool) -> bool {
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> {
use_snapshot_on(info, built, snapshot, cfg!(windows))
}
@@ -338,20 +402,92 @@ pub(crate) fn register(d: &mut Dispatcher) {
});
}
/// An annotation processor's error for writing a file this build already wrote (JDT's
/// `IdeFilerImpl`: "Source file already created", javac's Filer: "Attempt to recreate a file").
fn processor_recreated_a_file(message: &str) -> bool {
message.contains("FilerException")
&& (message.contains("Source file already created") || message.contains("Attempt to recreate a file"))
}
fn drop_processor_recreate_errors(info: &mut JavaLaunchInfo) {
let before = info.errors.len();
info.errors.retain(|e| !processor_recreated_a_file(&e.message));
info.error_count = info.error_count.saturating_sub((before - info.errors.len()) as u32);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn recognises_a_processor_recreating_its_output() {
assert!(processor_recreated_a_file(
"Internal error in the mapping processor: java.lang.RuntimeException: \
javax.annotation.processing.FilerException: Source file already created: /m/target/generated-sources/annotations/a/AMapper__4Impl.java"
));
assert!(processor_recreated_a_file(
"javax.annotation.processing.FilerException: Attempt to recreate a file for type a.B"
));
assert!(!processor_recreated_a_file("Source file already created"));
assert!(!processor_recreated_a_file("cannot find symbol"));
}
#[test]
fn a_delegated_launch_ignores_jdt_processor_recreate_errors() {
let error = |message: &str| ide_lsp::jdtls::JavaCompileError {
path: "src/main/java/a/A.java".into(),
line: 1,
column: 1,
message: message.into(),
};
let mut info = JavaLaunchInfo {
error_count: 2,
errors: vec![
error(
"javax.annotation.processing.FilerException: Source file already created: /m/AMapper__1Impl.java",
),
error("cannot find symbol"),
],
..Default::default()
};
drop_processor_recreate_errors(&mut info);
assert_eq!(info.error_count, 1);
assert_eq!(info.errors.len(), 1);
assert_eq!(info.errors[0].message, "cannot find symbol");
}
#[test]
fn delegated_launches_with_jdt_errors_recompile_with_maven() {
assert_eq!(delegated_policy(BuildPolicy::Make, true, 1), BuildPolicy::Rebuild);
assert_eq!(delegated_policy(BuildPolicy::Automatic, true, 2), BuildPolicy::Rebuild);
assert_eq!(delegated_policy(BuildPolicy::Make, true, 0), BuildPolicy::Make);
assert_eq!(delegated_policy(BuildPolicy::Make, false, 1), BuildPolicy::Make);
assert_eq!(
delegated_policy(BuildPolicy::MakeAndContinueOnErrors, true, 1),
BuildPolicy::MakeAndContinueOnErrors
);
assert!(rebuild_on_jdt_errors(BuildPolicy::Make, true, 1));
assert!(rebuild_on_jdt_errors(BuildPolicy::Automatic, true, 2));
assert!(!rebuild_on_jdt_errors(BuildPolicy::Make, true, 0));
assert!(!rebuild_on_jdt_errors(BuildPolicy::Make, false, 1));
assert!(!rebuild_on_jdt_errors(BuildPolicy::MakeAndContinueOnErrors, true, 1));
}
#[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]
@@ -395,7 +531,7 @@ mod tests {
}
#[test]
fn clean_external_build_clears_stale_jdt_errors() {
fn authoritative_external_result_clears_stale_jdt_errors() {
let mut info = JavaLaunchInfo {
error_count: 7,
errors: vec![ide_lsp::jdtls::JavaCompileError {
+71 -4
Переглянути файл
@@ -14,6 +14,10 @@ pub(super) struct Outputs {
pub originals: Vec<PathBuf>,
#[serde(skip)]
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 {
@@ -42,6 +46,9 @@ pub(super) fn has_record(root: &Path, cache: &Path, module: &Path) -> bool {
pub(super) struct BuildOptions {
pub test: bool,
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.
@@ -75,7 +82,7 @@ impl Session {
cancel: &CancellationToken,
) -> IdeResult<Outputs> {
self.initialize();
let BuildOptions { test, policy } = options;
let BuildOptions { test, policy, rebuild_on_jdt_errors } = options;
let cache = &self.cache;
let record =
record_path(root, cache, module, test).ok_or_else(|| IdeError::internal("launch outside project"))?;
@@ -115,8 +122,14 @@ impl Session {
&& outputs.reusable(&before, &snapshots)
{
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
// source still delegates to Maven to clean its old classes on the next launch.
fs::create_dir_all(record.parent().unwrap())?;
@@ -156,7 +169,13 @@ impl Session {
if cancel.is_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 {
outputs.fingerprint = before;
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]
fn successful_snapshots_reuse_across_close_and_rebuild_for_changes_missing_outputs_or_failure() {
let dir = tempfile::tempdir().unwrap();
@@ -483,7 +544,13 @@ mod tests {
fs::write(snapshots.join("launch-file"), "keep").unwrap();
let record = cache.join("java-launch/records/deep/module/test.json");
fs::create_dir_all(record.parent().unwrap()).unwrap();
let outputs = Outputs { fingerprint: None, snapshot: retained.clone(), originals: Vec::new(), 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();
let mut session = Session::new(cache);
session.initialize();
+81 -14
Переглянути файл
@@ -74,9 +74,15 @@ pub async fn lsp_completion(
host.end_request(request);
// Plugin providers (`[[contributes.providers]]`) add theirs.
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::Completion) else { return result };
let items = plugin_call(move || plugins.provide_completion(&d, pos, trigger.as_deref())).await.unwrap_or_default();
let (covered, items) = plugin_call(move || plugins.provide_completion_for_editor(&d, pos, trigger.as_deref()))
.await
.unwrap_or_default();
if items.is_empty() {
return Ok(result.unwrap_or(CompletionList { is_incomplete: true, items: Vec::new() }));
return if covered {
Ok(result.unwrap_or(CompletionList { is_incomplete: true, items: Vec::new() }))
} else {
result
};
}
let mut list = result.unwrap_or(CompletionList { is_incomplete: false, items: Vec::new() });
list.items.extend(items.into_iter().map(|(_, item)| plugin_completion(item)));
@@ -309,9 +315,13 @@ pub async fn lsp_signature_help(host: &Host, window: &str, doc: DocId, pos: Pos)
pub async fn lsp_goto(host: &Host, window: &str, doc: DocId, pos: Pos, kind: GotoKind) -> IdeResult<Vec<Location>> {
let target = position_document(host, window, doc, pos).await?;
// ⌘B on a declaration shows its usages, also when a plugin's index links the name elsewhere
// (a MyBatis mapper method to its XML statement): the server's answer comes along for the
// editor to tell.
if target == doc
&& kind == GotoKind::Definition
&& let Some(plugins) = crate::plugins::editor_plugin_host()
&& !on_declaration_name(host, window, doc, pos).await
{
let project = host.project(window)?;
// A notebook cell is a language-server document only: no plugin navigates in it.
@@ -352,6 +362,30 @@ pub async fn lsp_goto(host: &Host, window: &str, doc: DocId, pos: Pos, kind: Got
definition_targets(result, found, in_string)
}
/// Whether the syntax tree puts the identifier at `pos` as a declaration's name (`int f()`'s `f`).
async fn on_declaration_name(host: &Host, window: &str, doc: DocId, pos: Pos) -> bool {
let Ok(project) = host.project(window) else { return false };
blocking(move || {
let snap = project.documents.text_snapshot(doc)?;
if snap.large {
return Ok(false);
}
let s = ide_syntax::Snapshot::doc(doc, snap.version, &snap.language, &snap.text);
let cancel = ide_core::CancellationToken::new();
let Ok(Some(at)) = project.structure.node_at(&s, Range::new(pos, pos), &cancel) else { return Ok(false) };
Ok(is_declaration_name(&at))
})
.await
.unwrap_or(false)
}
fn is_declaration_name(at: &ide_syntax::NodeAt) -> bool {
at.node.field.as_deref() == Some("name")
&& at.ancestors.first().is_some_and(|parent| {
["declaration", "definition", "declarator"].iter().any(|kind| parent.kind.ends_with(kind))
})
}
async fn in_string_literal(host: &Host, window: &str, doc: DocId, pos: Pos) -> bool {
let Ok(project) = host.project(window) else { return false };
blocking(move || {
@@ -1483,23 +1517,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<()> {
let project = host.project(window)?;
blocking(move || {
// Multi-file refactorings leave a Local History point to go back to.
let paths: Vec<&std::path::Path> = edit
.files
.iter()
.filter_map(|f| match f {
ide_core::FileEdit::Edit { path, .. } => Some(&**path),
_ => None,
})
.collect();
if edit.files.len() > 1 {
project.record_before_refactoring(&paths, "Apply changes");
}
record_before_multi_file_edit(&project, &edit);
apply_edit(&project.documents, &edit)
})
.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> {
let project = host.project(window)?;
blocking(move || {
@@ -2121,6 +2170,24 @@ mod tests {
assert_eq!(paths(definition_targets(Ok(vec![helper, guess]), Vec::new(), false)), ["/p/app/helpers.php"]);
}
#[test]
fn declaration_names_are_told_apart_from_uses() {
let syntax = std::sync::Arc::new(ide_language::Syntax::new());
let structure = ide_syntax::StructureService::new(std::sync::Arc::new(ide_language::SyntaxCache::new(syntax)));
let java = ide_core::LanguageId::new("java");
let text = "interface ShareMapper {\n int batchCancel(List<Long> ids);\n}\nclass S { int f(ShareMapper m) { return m.batchCancel(null); } }\n";
let snap = ide_syntax::Snapshot::text(&java, text);
let cancel = ide_core::CancellationToken::new();
let at = |line, col| {
let pos = Pos::new(line, col);
is_declaration_name(&structure.node_at(&snap, Range::new(pos, pos), &cancel).unwrap().unwrap())
};
assert!(at(1, 10), "the mapper method's name");
assert!(at(0, 12), "the interface's name");
assert!(!at(3, 45), "a call of the method");
assert!(!at(1, 4), "its return type");
}
#[test]
fn positions_inside_string_literals_are_told_apart() {
let syntax = std::sync::Arc::new(ide_language::Syntax::new());
+2
Переглянути файл
@@ -31,6 +31,7 @@ pub mod scaffold;
pub mod sdk;
pub mod search;
pub mod spell;
mod ssh;
pub mod syntax;
pub mod term;
pub mod tools;
@@ -95,6 +96,7 @@ pub fn register_all(d: &mut crate::rpc::Dispatcher) {
deploy::register(d);
docker::register(d);
db::register(d);
ssh::register(d);
}
impl Host {
+20 -1
Переглянути файл
@@ -234,7 +234,19 @@ pub async fn start_request(
) -> IdeResult<RunInfo> {
let cancel = request.map(|id| host.begin_request(id)).unwrap_or_default();
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 {
host.end_request(id);
}
@@ -249,6 +261,7 @@ async fn start_inner(
window: &str,
mut config: RunConfig,
on_output: Emit<OutputChunk>,
agent: Option<&crate::mcp::run::agent::Launch>,
cancel: &ide_core::CancellationToken,
) -> IdeResult<RunInfo> {
if cancel.is_cancelled() {
@@ -282,6 +295,9 @@ async fn start_inner(
if let Some(plugins) = host.plugin_host() {
sink = plugins.run_operation_sink(window, &mut config, sink)?;
}
if let Some(agent) = agent {
sink = agent.wrap_sink(sink);
}
if config.command.is_empty() {
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() {
plugins.run_operation_stopped(id);
}
host.mcp_agent_runs.stopping(id);
let settings = match host.project(window) {
Ok(p) => p.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)
}
/// 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<()> {
host.mcp_agent_runs.user_input(id, data);
if host.remote.runs.contains(id) {
return host.remote.runs.write_stdin(id, data);
}
+110
Переглянути файл
@@ -7,6 +7,8 @@ use ide_core::{IdeError, IdeResult, RequestId};
use ide_sdk::python::PythonPackage;
use ide_sdk::{Sdk, SdkKind};
use std::path::PathBuf;
use std::sync::Mutex;
use std::sync::atomic::{AtomicUsize, Ordering};
fn join_err(e: impl std::fmt::Display) -> IdeError {
IdeError::internal(e.to_string())
@@ -20,6 +22,14 @@ pub struct LocatedTools {
pub compile_dir: ide_core::IpcPath,
}
/// An interpreter another project uses: the project root and the SDK itself.
#[derive(Clone, Debug, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct ProjectInterpreter {
pub project: ide_core::IpcPath,
pub sdk: Sdk,
}
/// Installed interpreters / toolchains of `kind`; project-local ones first.
pub async fn sdk_detect(host: &Host, window: &str, kind: SdkKind) -> IdeResult<Vec<Sdk>> {
host.require_trust(window)?;
@@ -45,6 +55,53 @@ pub async fn sdk_detect(host: &Host, window: &str, kind: SdkKind) -> IdeResult<V
.map_err(join_err)
}
/// The Python interpreters of the projects opened recently (newest first), each with the
/// project it belongs to. The open project is skipped: [`sdk_detect`] lists its own.
pub async fn sdk_python_project_interpreters(host: &Host, window: &str) -> IdeResult<Vec<ProjectInterpreter>> {
host.require_trust(window)?;
let current = host.project(window).ok().map(|p| p.root.clone());
let config = host.config_dir.clone();
// 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 || {
// Each project resolves its configured interpreter, else its own virtualenv. The
// probes run `python`, so keep them off the async runtime and bound how many run at
// once: the recent list can hold ~30 projects, and each probe starts a process.
const PROBES: usize = 4;
let results = Mutex::new(Vec::with_capacity(roots.len()));
let next = AtomicUsize::new(0);
std::thread::scope(|scope| {
for _ in 0..roots.len().min(PROBES) {
scope.spawn(|| {
loop {
let index = next.fetch_add(1, Ordering::Relaxed);
let Some(root) = roots.get(index) else { break };
let settings = ide_workspace::SettingsStore::load(&config, Some(root)).effective();
let Some(python) = ide_sdk::apply::python_interpreter(root, &settings) else { continue };
let Some(sdk) = ide_sdk::python_interpreter_sdk(&python, Some(root)) else { continue };
results
.lock()
.unwrap()
.push((index, ProjectInterpreter { project: root.as_path().into(), sdk }));
}
});
}
});
let mut found = results.into_inner().unwrap();
found.sort_by_key(|(index, _)| *index);
found.into_iter().map(|(_, interpreter)| interpreter).collect()
})
.await
.map_err(join_err)
}
/// An existing Python executable or virtualenv folder as an SDK; `None` if Python cannot run.
pub async fn sdk_python_interpreter(host: &Host, window: &str, path: PathBuf) -> IdeResult<Option<Sdk>> {
host.require_trust(window)?;
@@ -310,6 +367,10 @@ pub async fn sdk_python_pip(
/// Commands of this module the remote server answers.
pub(crate) fn register(d: &mut Dispatcher) {
d.add("sdk_detect", |c| async move { sdk_detect(&c.host, &c.window, c.arg("kind")?).await });
d.add(
"sdk_python_project_interpreters",
|c| async move { sdk_python_project_interpreters(&c.host, &c.window).await },
);
d.add(
"sdk_python_interpreter",
|c| async move { sdk_python_interpreter(&c.host, &c.window, c.arg("path")?).await },
@@ -425,4 +486,53 @@ mod tests {
assert!(sdk_python_interpreter(&host, "main", root.join("README.md")).await.unwrap().is_none());
host.close_project("main");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn another_projects_interpreter_is_listed_with_its_project() {
let temp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(temp.path()).unwrap();
let root = base.join("project");
let other = base.join("shop");
std::fs::create_dir_all(other.join(".openidea")).unwrap();
std::fs::create_dir_all(&root).unwrap();
let python = fake_venv(&other, ".venv");
std::fs::write(
other.join(".openidea/settings.toml"),
"[languages.python]\ninterpreter = \".venv/bin/python\"\n",
)
.unwrap();
// The projects the user opened before this one.
let config = base.join("config");
std::fs::create_dir_all(&config).unwrap();
let recent = serde_json::json!([
{ "path": other.to_str().unwrap(), "name": "shop", "openedAtMs": 2.0, "branch": null },
{ "path": root.to_str().unwrap(), "name": "project", "openedAtMs": 1.0, "branch": null },
]);
std::fs::write(config.join("recent.json"), serde_json::to_vec(&recent).unwrap()).unwrap();
let host = Host::new(HostConfig {
config_dir: config,
data_dir: base.join("data"),
local_data_dir: base.join("local"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(|_| {})),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
host.trust.decide(&root, true, false).unwrap();
host.open_project("main", &root).unwrap();
// 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();
assert_eq!(list.len(), 1, "{list:?}");
assert_eq!(&*list[0].project, other.as_path());
assert_eq!(&*list[0].sdk.path, python.as_path());
assert_eq!(list[0].sdk.name, "Python 3.12 (shop)");
host.close_project("main");
}
}
+28 -4
Переглянути файл
@@ -47,14 +47,17 @@ pub async fn spell_check(
let syntax = host.syntax();
let cancel = host.begin_request(request);
let result = blocking(move || {
let path = project.documents.snapshot(doc)?.path.0.clone();
let snap = project.documents.text_snapshot(doc)?;
if snap.large {
return Ok(Vec::new());
return Ok((path, Vec::new()));
}
let regions = if PROSE.contains(&snap.language.as_str()) {
ide_spell::prose_regions(&snap.text)
} else {
let Some(found) = syntax.spelling_regions(&snap.language, &snap.text) else { return Ok(Vec::new()) };
let Some(found) = syntax.spelling_regions(&snap.language, &snap.text) else {
return Ok((path, Vec::new()));
};
found
.into_iter()
.map(|r| Region {
@@ -72,11 +75,32 @@ pub async fn spell_check(
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
speller(&project).check(&snap.text, &regions, &|| cancel.is_cancelled()).ok_or_else(IdeError::cancelled)
let typos =
speller(&project).check(&snap.text, &regions, &|| cancel.is_cancelled()).ok_or_else(IdeError::cancelled)?;
Ok((path, typos))
})
.await;
host.end_request(request);
result
// Record the typos in the host's diagnostics snapshot so line-end annotations
// see them without waiting for a UI report; the editor still draws from its
// own call above, unchanged.
if let Ok((path, typos)) = &result {
let items: Vec<ide_core::Diagnostic> = typos
.iter()
.map(|t| ide_core::Diagnostic {
range: t.range,
severity: ide_core::Severity::Info,
message: format!("Typo: In word '{}'", t.word),
source: Some("Spelling".into()),
code: None,
unnecessary: false,
deprecated: false,
server: None,
})
.collect();
host.diagnostics.replace(path, "Spelling", items);
}
result.map(|(_, typos)| typos)
}
/// Replacements for a misspelt `word`, best first (Typo › Change to…).
+29
Переглянути файл
@@ -0,0 +1,29 @@
//! SSH configuration commands shared by the desktop shell's IPC commands and the remote
//! server ([`crate::rpc`]). A remote window connects from the server (deployment, database
//! tunnels, run targets, SSH sessions), so the host keys it trusts and the passwords it is
//! given belong to the server's [`ide_ssh::SshManager`], not the desktop's.
use crate::rpc::Dispatcher;
use ide_core::remote_settings::SshConfiguration;
pub(crate) fn register(d: &mut Dispatcher) {
d.add("ssh_test", |c| async move {
let (config, secret) = (c.arg::<SshConfiguration>("config")?, c.arg::<Option<String>>("secret")?);
c.host.ssh.test(&config, secret).await
});
d.add("ssh_set_secret", |c| async move {
let (id, secret, save) = (c.arg::<String>("id")?, c.arg::<Option<String>>("secret")?, c.arg::<bool>("save")?);
c.host.ssh.set_secret(&id, secret, save);
Ok(())
});
d.add("ssh_password_saved", |c| async move { Ok(c.host.ssh.password_saved(&c.arg::<String>("key")?)) });
d.add("ssh_needs_secret", |c| async move {
let ssh = &c.host.ssh;
Ok(ssh.needs_secret(&ssh.config(&c.arg::<String>("id")?)?))
});
d.add("ssh_trust_host", |c| async move { c.host.ssh.trust_host(&c.arg::<String>("id")?).await });
d.add("ssh_connected", |c| async move { Ok(c.host.ssh.connected()) });
d.add("ssh_disconnect", |c| async move {
c.host.ssh.disconnect(&c.arg::<String>("id")?);
Ok(())
});
}
+12 -1
Переглянути файл
@@ -6,7 +6,7 @@ use crate::rpc::Dispatcher;
use ide_core::{IdeError, IdeResult, RequestId};
use ide_sdk::Sdk;
use ide_tools::jdk::{JdkReleases, JdkVendor};
use ide_tools::{InstallEvent, ToolInfo, Tools};
use ide_tools::{InstallEvent, ToolInfo, ToolUpdate, Tools};
use std::path::PathBuf;
/// The installer, reporting the executables chosen in the window's settings.
@@ -89,6 +89,16 @@ pub async fn tools_install(
Ok(info)
}
/// Newer versions of the tools the IDE installed (release downloads and npm packages).
/// Cancel with `cancel_request(request)`.
pub async fn tools_updates(host: &Host, window: &str, request: RequestId) -> IdeResult<Vec<ToolUpdate>> {
let tools = tools(host, window)?;
let cancel = host.begin_request(request);
let result = tokio::task::spawn_blocking(move || tools.updates(&cancel)).await.map_err(join_err);
host.end_request(request);
result?
}
/// Removes a tool the IDE installed.
pub async fn tools_uninstall(host: &Host, window: &str, name: String) -> IdeResult<ToolInfo> {
let tools = tools(host, window)?;
@@ -185,6 +195,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
tools_install(&c.host, &c.window, c.arg("name")?, c.arg("request")?, c.sender("onEvent")?).await
});
d.add("tools_uninstall", |c| async move { tools_uninstall(&c.host, &c.window, c.arg("name")?).await });
d.add("tools_updates", |c| async move { tools_updates(&c.host, &c.window, c.arg("request")?).await });
d.add("jdk_download_vendors", |c| async move { Ok(jdk_download_vendors(&c.host, &c.window)) });
d.add("jdk_download_releases", |c| async move {
jdk_download_releases(&c.host, &c.window, c.arg("vendor")?, c.arg("request")?).await
+21
Переглянути файл
@@ -22,6 +22,27 @@ pub async fn status(host: &Host, window: &str) -> IdeResult<Vec<FileStatus>> {
.await
}
/// Changes of `paths` only: the dirty-scope refresh after a watcher batch. `paths` that belong
/// to no repository are ignored, and an empty list is the full status.
pub async fn status_paths(host: &Host, window: &str, paths: Vec<std::path::PathBuf>) -> IdeResult<Vec<FileStatus>> {
let project = host.project(window)?;
host.blocking(move || {
let settings = project.settings.effective();
if project.is_trusted() {
apply_git_executable(&settings);
}
let repos = project.repos();
if repos.is_empty() {
return Err(ide_core::IdeError::unavailable("project is not a git repository"));
}
for r in repos.all() {
r.set_ignored_names(settings.ignored_names());
}
repos.status_paths(&paths)
})
.await
}
mod commands;
pub use commands::*;
+60 -19
Переглянути файл
@@ -138,6 +138,11 @@ impl LogAccess {
.ok_or_else(|| ide_core::IdeError::unavailable("external Git log is not open"))?
.with_log_repo_at(at, f)
}
/// `with_log_repo_at` through the backend-agnostic Changes-view surface.
fn with_log_backend_at<T>(&self, at: Option<&Path>, f: impl FnOnce(&dyn Backend) -> IdeResult<T>) -> IdeResult<T> {
self.with_log_repo_at(at, |r| f(r as &dyn Backend))
}
}
/// A repository of the project (IDEA's VCS root).
@@ -280,6 +285,16 @@ fn per_repo(
Ok(())
}
/// `per_repo` through the backend-agnostic Changes-view surface.
fn per_backend(
project: &Project,
paths: &[PathBuf],
recorded: Option<&str>,
f: impl Fn(&dyn Backend, &[PathBuf]) -> IdeResult<()>,
) -> IdeResult<()> {
per_repo(project, paths, recorded, |r, paths| f(r as &dyn Backend, paths))
}
/// A shelf is one patch for one owning repository. Validate the whole selection before reverting files.
fn shelf_repository(project: &Project, paths: &[PathBuf]) -> IdeResult<PathBuf> {
let repos = project.repos();
@@ -348,7 +363,7 @@ pub async fn vcs_line_hunks(host: &Host, window: &str, path: PathBuf) -> IdeResu
let project = host.project(window)?;
blocking(move || {
let text = project.text_of(&path)?;
project.with_repo_of(&path, |r| r.line_hunks(&path, &text, false, project.vcs_encoding(&path, None)?))
project.with_backend_of(&path, |r| r.line_hunks(&path, &text, false, project.vcs_encoding(&path, None)?))
})
.await
}
@@ -360,7 +375,7 @@ pub async fn vcs_file_at(host: &Host, window: &str, path: PathBuf, rev: Option<S
});
blocking(move || {
if let Some(project) = project {
return project.with_log_repo_of(&path, |r| {
return project.with_log_backend_of(&path, |r| {
r.file_at(&path, rev.as_deref(), project.vcs_encoding(&path, rev.as_deref())?)
});
}
@@ -383,7 +398,7 @@ pub async fn vcs_file_mode(host: &Host, window: &str, path: PathBuf, rev: Option
});
blocking(move || {
if let Some(project) = project {
return project.with_log_repo_of(&path, |r| r.file_mode_at(&path, rev.as_deref()));
return project.with_log_backend_of(&path, |r| r.file_mode_at(&path, rev.as_deref()));
}
external
.ok_or_else(|| ide_core::IdeError::unavailable("external Git log is not open"))?
@@ -403,7 +418,7 @@ pub async fn vcs_blame(
// An explicit text wins (merge view annotates its side panes, not the worktree).
blocking(move || {
let text = text.or_else(|| project.documents.text_of(&path));
project.with_repo_of(&path, |r| {
project.with_backend_of(&path, |r| {
r.blame(&path, text.as_deref(), revision.as_deref(), project.vcs_encoding(&path, revision.as_deref())?)
})
})
@@ -424,7 +439,7 @@ pub async fn vcs_block_history(
let cancel = host.begin_request(request);
let result = blocking(move || {
let text = text.or_else(|| project.documents.text_of(&path));
project.with_repo_of(&path, |r| {
project.with_backend_of(&path, |r| {
r.block_history(&path, text.as_deref(), lines, project.vcs_encoding(&path, None)?, &cancel)
})
})
@@ -441,7 +456,7 @@ pub async fn vcs_log(host: &Host, window: &str, repo: Option<PathBuf>, query: Lo
let result = blocking(move || {
project.with_log_repo_at(repo.as_deref(), |r| {
r.enable_log_index(&project.cache);
r.log_cancellable(&query, &cancel)
(r as &dyn Backend).log(&query, &cancel)
})
})
.await;
@@ -540,7 +555,7 @@ pub async fn vcs_commit_files(
commit: String,
) -> IdeResult<Vec<ChangedFile>> {
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.commit_files(&commit))).await
blocking(move || project.with_log_backend_at(repo.as_deref(), |r| r.commit_files(&commit))).await
}
/// Combined changes of several commits, `commits` oldest first.
@@ -551,7 +566,7 @@ pub async fn vcs_commits_changes(
commits: Vec<String>,
) -> IdeResult<Vec<RevisionChange>> {
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.commits_changes(&commits))).await
blocking(move || project.with_log_backend_at(repo.as_deref(), |r| r.commits_changes(&commits))).await
}
pub async fn vcs_branches(host: &Host, window: &str, repo: Option<PathBuf>) -> IdeResult<Vec<Branch>> {
@@ -567,7 +582,22 @@ pub async fn vcs_stashes(host: &Host, window: &str, repo: Option<PathBuf>) -> Id
pub async fn vcs_stage(host: &Host, window: &str, paths: Vec<PathBuf>) -> IdeResult<()> {
host.require_trust(window)?;
let project = host.project(window)?;
blocking(move || per_repo(&project, &paths, None, |r, paths| r.stage(paths))).await
blocking(move || per_backend(&project, &paths, None, |r, paths| r.stage(paths))).await
}
/// Adds `commit` to annotate's ignore list in `path`'s repository (the file `blame.ignoreRevsFile`
/// names, else `.git-blame-ignore-revs`, which the local config then names).
pub async fn vcs_ignore_blame_revision(host: &Host, window: &str, path: PathBuf, commit: String) -> IdeResult<()> {
host.require_trust(window)?;
let project = host.project(window)?;
blocking(move || {
let result = project.with_disk_sync(|| project.with_repo_of(&path, |r| r.ignore_blame_revision(&commit)));
if result.is_ok() {
project.sink.emit(ide_core::IdeEvent::VcsChanged { root: project.root.clone().into() });
}
result
})
.await
}
/// Project-host operation (also registered with the remote dispatcher).
@@ -602,7 +632,7 @@ pub async fn vcs_add_to_ignore(
pub async fn vcs_unstage(host: &Host, window: &str, paths: Vec<PathBuf>) -> IdeResult<()> {
host.require_trust(window)?;
let project = host.project(window)?;
blocking(move || per_repo(&project, &paths, None, |r, paths| r.unstage(paths))).await
blocking(move || per_backend(&project, &paths, None, |r, paths| r.unstage(paths))).await
}
pub async fn vcs_stage_patch(host: &Host, window: &str, patch: String) -> IdeResult<()> {
@@ -614,7 +644,7 @@ pub async fn vcs_stage_patch(host: &Host, window: &str, patch: String) -> IdeRes
pub async fn vcs_discard(host: &Host, window: &str, paths: Vec<PathBuf>) -> IdeResult<()> {
host.require_trust(window)?;
let project = host.project(window)?;
blocking(move || per_repo(&project, &paths, Some("Rollback"), |r, paths| r.discard(paths))).await
blocking(move || per_backend(&project, &paths, Some("Rollback"), |r, paths| r.discard(paths))).await
}
pub async fn vcs_commit(
@@ -628,8 +658,8 @@ pub async fn vcs_commit(
// Commit hooks may run for a long time.
let project = host.project(window)?;
blocking(move || {
project.with_repo_recorded(repo.as_deref(), None, |r| {
r.commit_with(&message, &options, &|path| project.vcs_encoding(path, None))
project.with_backend_recorded(repo.as_deref(), None, |r| {
r.commit(&message, &options, &|path| project.vcs_encoding(path, None))
})
})
.await
@@ -777,12 +807,19 @@ pub async fn vcs_stash_push(
message: Option<String>,
include_untracked: bool,
keep_index: bool,
paths: Option<Vec<PathBuf>>,
) -> IdeResult<()> {
host.require_trust(window)?;
let project = host.project(window)?;
let paths = paths.unwrap_or_default();
blocking(move || {
project.with_repo_recorded(repo.as_deref(), Some("Git stash"), |r| {
r.stash_push(message.as_deref(), include_untracked, keep_index)
if paths.is_empty() {
return project.with_repo_recorded(repo.as_deref(), Some("Git stash"), |r| {
r.stash_push(message.as_deref(), include_untracked, keep_index, &[])
});
}
per_repo(&project, &paths, Some("Git stash"), |r, paths| {
r.stash_push(message.as_deref(), include_untracked, keep_index, paths)
})
})
.await
@@ -886,7 +923,7 @@ pub async fn vcs_conflict_versions(host: &Host, window: &str, path: PathBuf) ->
pub async fn vcs_diff_text(host: &Host, window: &str, path: PathBuf, staged: bool) -> IdeResult<String> {
let project = host.project(window)?;
blocking(move || project.with_repo_of(&path, |r| r.diff_text(&path, staged, project.vcs_encoding(&path, None)?)))
blocking(move || project.with_backend_of(&path, |r| r.diff_text(&path, staged, project.vcs_encoding(&path, None)?)))
.await
}
@@ -1201,7 +1238,7 @@ pub async fn vcs_diff_with_worktree(
rev: String,
) -> IdeResult<Vec<ChangedFile>> {
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.diff_with_worktree(&rev))).await
blocking(move || project.with_log_backend_at(repo.as_deref(), |r| r.diff_with_worktree(&rev))).await
}
/// Files that differ between two revisions (the "N files updated" of Update Project).
@@ -1213,7 +1250,7 @@ pub async fn vcs_diff_revisions(
to: String,
) -> IdeResult<Vec<ChangedFile>> {
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.diff_revisions(&from, &to))).await
blocking(move || project.with_log_backend_at(repo.as_deref(), |r| r.diff_revisions(&from, &to))).await
}
/// Added and deleted lines between two revisions. `paths` limits the diff; empty diffs every file.
@@ -1447,6 +1484,9 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("vcs_add_to_ignore", |c| async move {
vcs_add_to_ignore(&c.host, &c.window, c.arg("paths")?, c.arg("destination")?).await
});
d.add("vcs_ignore_blame_revision", |c| async move {
vcs_ignore_blame_revision(&c.host, &c.window, c.arg("path")?, c.arg("commit")?).await
});
d.add("vcs_unstage", |c| async move { vcs_unstage(&c.host, &c.window, c.arg("paths")?).await });
d.add("vcs_stage_patch", |c| async move { vcs_stage_patch(&c.host, &c.window, c.arg("patch")?).await });
d.add("vcs_discard", |c| async move { vcs_discard(&c.host, &c.window, c.arg("paths")?).await });
@@ -1492,7 +1532,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
vcs_restore_branch(&c.host, &c.window, c.arg("repo")?, c.arg("deleted")?).await
});
d.add("vcs_rename_branch", |c| async move {
vcs_rename_branch(&c.host, &c.window, c.arg("repo")?, c.arg("from")?, c.arg("to")?, c.arg("unset_upstream")?)
vcs_rename_branch(&c.host, &c.window, c.arg("repo")?, c.arg("from")?, c.arg("to")?, c.arg("unsetUpstream")?)
.await
});
d.add("vcs_merge", |c| async move { vcs_merge(&c.host, &c.window, c.arg("repo")?, c.arg("branch")?).await });
@@ -1505,6 +1545,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
c.arg("message")?,
c.arg("includeUntracked")?,
c.arg("keepIndex")?,
c.arg("paths")?,
)
.await
});
+96
Переглянути файл
@@ -2,10 +2,106 @@
use crate::Host;
use ide_core::IdeResult;
use ide_core::proxy_settings::{ProxyMode, ProxySettings};
use std::path::Path;
use std::time::Duration;
/// Whether `url` names this machine (`localhost` or a loopback address).
fn is_loopback(url: &reqwest::Url) -> bool {
let host = url.host_str().unwrap_or_default().trim_start_matches('[').trim_end_matches(']');
host.eq_ignore_ascii_case("localhost") || host.parse::<std::net::IpAddr>().is_ok_and(|ip| ip.is_loopback())
}
fn is_html(content_type: &str) -> bool {
let kind = content_type.split(';').next().unwrap_or_default().trim();
kind.eq_ignore_ascii_case("text/html") || kind.eq_ignore_ascii_case("application/xhtml+xml")
}
/// A client that reaches this machine only: no proxy, and a redirect is followed while it
/// stays here (a dev server sending `/` to `/app/`).
fn loopback_client() -> IdeResult<reqwest::Client> {
let direct = ProxySettings { mode: ProxyMode::None, ..ProxySettings::default() };
let stay = reqwest::redirect::Policy::custom(|attempt| {
if is_loopback(attempt.url()) && attempt.previous().len() < 5 { attempt.follow() } else { attempt.stop() }
});
ide_http::client::build_with_redirect(&direct, stay)
}
/// Whether `url`, a server on this machine, answers a browser with an HTML document: a page
/// to open, as a dev server has, and not an API endpoint. Other hosts are never asked.
pub async fn serves_page(url: &str) -> bool {
let Ok(url) = reqwest::Url::parse(url) else { return false };
if !matches!(url.scheme(), "http" | "https") || !is_loopback(&url) {
return false;
}
let Ok(client) = loopback_client() else { return false };
// A dev server may compile the page on this first request.
let request = client.get(url).header(reqwest::header::ACCEPT, "text/html").timeout(Duration::from_secs(10));
let Ok(response) = request.send().await else { return false };
let html = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.is_some_and(is_html);
response.status().is_success() && html
}
/// The URL serving `file`, under the window's project when it holds the file.
pub async fn url(host: &Host, window: &str, file: &Path) -> IdeResult<String> {
let root = host.project(window).ok().map(|p| p.root.clone());
host.web.url_for(root.as_deref(), file).await
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
/// A server on a free port that answers every request with `status` and `header`. It
/// listens on every interface, so that a test can reach it under a name that is not loopback.
async fn answering(status: &'static str, header: String) -> String {
let listener = tokio::net::TcpListener::bind("0.0.0.0:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
tokio::spawn(async move {
while let Ok((mut socket, _)) = listener.accept().await {
let mut request = [0u8; 2048];
let _ = socket.read(&mut request).await;
let response =
format!("HTTP/1.1 {status}\r\n{header}\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok");
let _ = socket.write_all(response.as_bytes()).await;
}
});
format!("http://127.0.0.1:{port}/")
}
async fn server(status: &'static str, content_type: &'static str) -> String {
answering(status, format!("Content-Type: {content_type}")).await
}
async fn redirecting_to(location: &str) -> String {
answering("302 Found", format!("Location: {location}")).await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_redirect_is_followed_on_this_machine_only() {
let page = server("200 OK", "text/html").await;
assert!(serves_page(&redirecting_to(&page).await).await);
// The same page under an address that is not loopback: it is not asked.
let elsewhere = page.replace("127.0.0.1", "0.0.0.0");
assert!(!serves_page(&redirecting_to(&elsewhere).await).await);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_page_is_an_html_answer_from_this_machine() {
assert!(serves_page(&server("200 OK", "text/html; charset=utf-8").await).await);
assert!(serves_page(&server("200 OK", "Text/HTML").await.replace("127.0.0.1", "localhost")).await);
// An API, an error page and a port nothing listens on are not pages.
assert!(!serves_page(&server("200 OK", "application/json").await).await);
assert!(!serves_page(&server("404 Not Found", "text/html").await).await);
assert!(!serves_page("http://127.0.0.1:1/").await);
// Other machines are not asked at all.
assert!(!serves_page("http://example.com/").await);
assert!(!serves_page("ftp://localhost/").await);
assert!(!serves_page("not a url").await);
}
}
+235 -34
Переглянути файл
@@ -179,7 +179,7 @@ impl SecretStore for FileSecretStore {
}
/// Largest response body a plugin receives.
const MAX_RESPONSE: usize = 8 << 20;
const MAX_RESPONSE: usize = 16 << 20;
/// Largest stdout / stderr kept from a process.
const MAX_OUTPUT: usize = 16 << 20;
/// Most processes one plugin keeps running at once.
@@ -1276,12 +1276,13 @@ pub(super) fn commit_draft(
};
// Both callers have checked that the window's project is trusted.
r.set_execution_allowed(true);
let pending = r.pending_changes(&repo.options, budget, &|path| Ok(workspace.vcs_encoding(path))).map_err(
|e| match e.kind {
let backend = &r as &dyn ide_vcs::Backend;
let pending = backend
.pending_changes(&repo.options, budget, &|path| Ok(workspace.vcs_encoding(path)))
.map_err(|e| match e.kind {
ide_core::error::ErrorKind::InvalidInput => HostError::Invalid(e.message),
_ => HostError::Failed(e.message),
},
)?;
})?;
let root = to_ipc(r.root());
for c in pending {
budget -= c.diff.as_ref().map_or(0, String::len).min(budget);
@@ -1530,6 +1531,19 @@ impl HostApi for PluginApi {
}
Ok(())
}
fn open_diff(
&self,
client: &str,
diff: ide_plugin::ContentDiff,
file_name: Option<String>,
) -> Result<(), HostError> {
super::execution::validate_diff(&diff)?;
if let Some(name) = &file_name {
super::execution::label(name, 256)?;
}
self.shared.sink.emit(Some(client), PluginEvent::OpenDiff { plugin_id: self.id().into(), diff, file_name });
Ok(())
}
fn reply_content_tab(
&self,
client: &str,
@@ -1628,6 +1642,9 @@ impl HostApi for PluginApi {
) -> Result<(), HostError> {
self.set_decorations(path, set, Vec::new(), item, true)
}
fn file_icon_decorations(&self, set: &str, items: Vec<(PathBuf, String)>) -> Result<(), HostError> {
self.set_file_icon_decorations(set, items)
}
fn vcs_log(
&self,
root: &Path,
@@ -2267,7 +2284,7 @@ impl HostApi for PluginApi {
let mut response = response;
while let Some(chunk) = response.chunk().await.map_err(|e| HostError::Failed(e.to_string()))? {
if body.len() + chunk.len() > MAX_RESPONSE {
return Err(HostError::Failed("response larger than 8 MiB".into()));
return Err(HostError::Failed(format!("response larger than {} MiB", MAX_RESPONSE >> 20)));
}
body.extend_from_slice(&chunk);
}
@@ -3162,26 +3179,17 @@ impl PluginWorkspace for HostWorkspace {
Ok(out)
}
fn problems(&self, client: &str, path: Option<&Path>) -> Result<Vec<ide_plugin::FileProblems>, HostError> {
// The host records every `Diagnostics` event it emits (language servers,
// builds, spelling, plugin providers), so this sees a file's problems
// whatever window opened it — not just the ones its UI reported.
let host = self.0.upgrade().ok_or(HostError::Cancelled)?;
let root = host.project(client).map_err(failed)?.root.clone();
let windows = host.windows_for_root(&root);
host.project(client).map_err(failed)?;
let mut out = Vec::new();
for file in host.mcp_reports.problems(&windows) {
if path.is_some_and(|p| p != file.path.0) {
for (file, source, diagnostics) in host.diagnostics.problems() {
if path.is_some_and(|p| p != file) {
continue;
}
if ide_plugin::resolve_new_in_root(&root, &to_ipc(&file.path.0)).is_err() {
continue;
}
let mut sources: BTreeMap<String, Vec<ide_core::Diagnostic>> = BTreeMap::new();
for d in file.items {
sources.entry(d.server.clone().or_else(|| d.source.clone()).unwrap_or_default()).or_default().push(d);
}
out.extend(sources.into_iter().map(|(source, diagnostics)| ide_plugin::FileProblems {
path: to_ipc(&file.path.0),
source,
diagnostics,
}));
out.push(ide_plugin::FileProblems { path: to_ipc(&file), source, diagnostics });
}
Ok(out)
}
@@ -3307,6 +3315,8 @@ impl PluginWorkspace for HostWorkspace {
path: &Path,
items: Vec<ide_core::Diagnostic>,
) -> Result<(), HostError> {
// Any open project may publish problems for any file: annotations follow the
// file, not the window (an editor can show a file outside every project root).
let projects = HostWorkspace::projects(self);
let project = projects
.iter()
@@ -3318,7 +3328,8 @@ impl PluginWorkspace for HostWorkspace {
.iter()
.find(|p| crate::project::workspace_member_roots(&p.root).iter().any(|m| path.starts_with(m)))
})
.ok_or_else(|| HostError::Invalid(format!("{} is not in an open project", path.display())))?;
.or_else(|| projects.first())
.ok_or_else(|| HostError::NotFound("no project is open".into()))?;
project.sink.emit(ide_core::IdeEvent::Diagnostics {
path: path.to_path_buf().into(),
source: format!("plugin:{source}"),
@@ -3949,6 +3960,72 @@ mod tests {
worker.join().unwrap();
}
#[test]
fn fetch_accepts_responses_up_to_16_mib_and_rejects_larger_bodies() {
fn serve_bytes(total: usize) -> (u16, std::thread::JoinHandle<()>) {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let handle = std::thread::spawn(move || {
use std::io::{Read, Write};
let (mut stream, _) = listener.accept().unwrap();
let mut buf = [0; 4096];
let _ = stream.read(&mut buf);
let header = format!("HTTP/1.1 200 OK\r\nContent-Length: {total}\r\nConnection: close\r\n\r\n");
if stream.write_all(header.as_bytes()).is_err() {
return;
}
let chunk = [b'x'; 64 << 10];
let mut sent = 0;
while sent < total {
let n = (total - sent).min(chunk.len());
if stream.write_all(&chunk[..n]).is_err() {
break;
}
sent += n;
}
});
(port, handle)
}
let dir = tempfile::tempdir().unwrap();
let api = api(dir.path(), Arc::default());
let (ok_port, ok_worker) = serve_bytes(10 << 20);
let resp = api
.fetch(
HttpRequest {
method: "GET".into(),
url: format!("http://127.0.0.1:{ok_port}/"),
headers: Vec::new(),
body: None,
timeout: Duration::from_secs(10),
grants: vec![Capability::Net("127.0.0.1".into())],
},
&CancellationToken::new(),
)
.expect("10 MiB response within 16 MiB limit succeeds");
assert_eq!(resp.status, 200);
assert_eq!(resp.body.len(), 10 << 20);
ok_worker.join().unwrap();
let (big_port, big_worker) = serve_bytes(MAX_RESPONSE + (1 << 20));
let err = api
.fetch(
HttpRequest {
method: "GET".into(),
url: format!("http://127.0.0.1:{big_port}/"),
headers: Vec::new(),
body: None,
timeout: Duration::from_secs(10),
grants: vec![Capability::Net("127.0.0.1".into())],
},
&CancellationToken::new(),
)
.expect_err("response exceeding 16 MiB fails explicitly");
assert!(matches!(&err, HostError::Failed(msg) if msg.contains("response larger than 16 MiB")), "{err:?}");
big_worker.join().unwrap();
}
#[test]
fn output_ring_keeps_utf8_and_is_withdrawn_on_stop() {
let dir = tempfile::tempdir().unwrap();
@@ -4045,25 +4122,30 @@ 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) {
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()));
}
if let Some(icon) = file.as_ref().and_then(|f| f.icon.as_ref()) {
ide_extension::validate_item_id(icon, "file icon id").map_err(HostError::Invalid)?;
}
items.retain(|d| decoration_style(&d.style));
if items.iter().any(|d| d.range.start > d.range.end) {
return Err(HostError::Invalid("reversed decoration range".into()));
}
let replacement = if let Some(path) = path {
let workspace = self.shared.workspace().ok_or_else(|| HostError::NotFound("no project open".into()))?;
let (root, real) = workspace
.projects()
.into_iter()
.filter_map(|root| ide_plugin::resolve_new_in_root(&root, &to_ipc(path)).ok().map(|real| (root, real)))
.max_by_key(|(root, _)| root.components().count())
.ok_or_else(|| HostError::Invalid("path outside open projects".into()))?;
// The decoration follows the file, not the window's project: editors render
// whatever they have open, including files outside every project root. The
// set's root stays the path's own (deepest open) project for tree filtering.
let root = self.decoration_root(path);
Some(super::DecorationSet {
root: root.into(),
path: real.into(),
path: ide_core::IpcPath(path.to_path_buf()),
set_id: set.into(),
tree,
items,
file: file.filter(|f| decoration_style(&f.style)),
file: file.filter(|f| f.note.is_some() || f.icon.is_some() || decoration_style(&f.style)),
})
} else {
None
@@ -4091,6 +4173,60 @@ impl PluginApi {
self.shared.sink.emit(None, PluginEvent::DecorationsChanged { plugin_id: self.id().into(), sets });
Ok(())
}
fn decoration_root(&self, path: &Path) -> PathBuf {
self.shared
.workspace()
.map(|w| {
w.projects()
.into_iter()
.filter(|root| path.starts_with(root))
.max_by_key(|root| root.components().count())
})
.unwrap_or_default()
.unwrap_or_else(|| path.parent().map(Path::to_path_buf).unwrap_or_else(|| path.to_path_buf()))
}
fn set_file_icon_decorations(&self, set: &str, items: Vec<(PathBuf, String)>) -> Result<(), HostError> {
if set.is_empty() || set.len() > 128 {
return Err(HostError::Invalid("set id must be 1-128 bytes".into()));
}
if items.len() > 10_000 {
return Err(HostError::Invalid("at most 10000 file icons per set".into()));
}
let mut by_path = BTreeMap::new();
for (path, icon) in items {
ide_extension::validate_item_id(&icon, "file icon id").map_err(HostError::Invalid)?;
by_path.insert(path, icon);
}
let mut all = self.shared.decorations.lock();
let mut sets = all.get(self.id()).cloned().unwrap_or_default();
sets.retain(|s| !(s.tree && s.set_id == set));
for (path, icon) in by_path {
let root = self.decoration_root(&path);
sets.push(super::DecorationSet {
root: root.into(),
path: ide_core::IpcPath(path),
set_id: set.into(),
tree: true,
items: Vec::new(),
file: Some(ide_plugin::FileDecoration {
badge: None,
style: String::new(),
tooltip: None,
note: None,
icon: Some(icon),
}),
});
}
let names: BTreeSet<_> = sets.iter().filter(|s| s.tree).map(|s| &s.set_id).collect();
if names.len() > 50 || serde_json::to_vec(&sets).map_err(failed)?.len() > 4 << 20 {
return Err(HostError::Invalid("decoration sets exceed plugin limits".into()));
}
all.insert(self.id().into(), sets.clone());
self.shared.sink.emit(None, PluginEvent::DecorationsChanged { plugin_id: self.id().into(), sets });
Ok(())
}
}
fn decoration_style(style: &str) -> bool {
matches!(
@@ -4103,6 +4239,8 @@ fn decoration_style(style: &str) -> bool {
| "warning"
| "error"
| "info"
| "typo"
| "weak"
| "underline"
| "strikethrough"
| "muted"
@@ -4223,8 +4361,11 @@ mod workbench_tests {
};
api.decorations(Some(&path), "set", vec![item("highlight"), item("unknown")]).unwrap();
assert_eq!(api.shared.decoration_sets()["test"][0].items.len(), 1);
assert!(api.decorations(Some(&root.join("../outside.txt")), "set", vec![]).is_err());
// Decorations follow the file: a path outside the open project is accepted too.
api.decorations(Some(&root.join("../outside.txt")), "set", vec![]).unwrap();
assert_eq!(api.shared.decoration_sets()["test"].len(), 2);
api.decorations(None, "set", vec![]).unwrap();
assert!(api.shared.decoration_sets()["test"].is_empty());
for i in 0..50 {
api.decorations(Some(&path), &format!("s{i}"), vec![]).unwrap();
}
@@ -4235,13 +4376,73 @@ mod workbench_tests {
api.file_decorations(
Some(&path),
"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,
icon: None,
})
)
.is_err()
);
let note = |text: &str| ide_plugin::FileDecoration {
badge: None,
style: String::new(),
tooltip: Some("why".into()),
note: Some(text.into()),
icon: None,
};
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.file_icon_decorations(
"icons",
vec![(path.clone(), "helm-chart".into()), (root.join("chart"), "helm-dir".into())],
)
.unwrap();
let icons = api.shared.decoration_sets()["test"]
.iter()
.filter(|s| s.tree && s.set_id == "icons")
.filter_map(|s| s.file.as_ref().and_then(|f| f.icon.clone()))
.collect::<Vec<_>>();
assert_eq!(icons.len(), 2);
assert!(api.file_icon_decorations("icons", vec![(path.clone(), "BAD ID".into())]).is_err());
api.shared.clear_plugin("test", true);
assert!(api.shared.decoration_sets().is_empty());
}
#[test]
fn decoration_styles_keep_the_severity_palette_the_editor_renders() {
// Inspection Lens colours typos and weak warnings with their own styles; the
// host must keep them, not silently drop them the way an unknown style is dropped.
for style in [
"highlight",
"search",
"added",
"modified",
"removed",
"warning",
"error",
"info",
"typo",
"weak",
"underline",
"strikethrough",
"muted",
] {
assert!(decoration_style(style), "{style} should be a known style");
}
assert!(!decoration_style("unknown"));
}
fn git(root: &Path, args: &[&str]) {
let out = StdCommand::new("git").current_dir(root).args(args).output().unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
Loaded 100 of 1091 files, more files were not shown because too many files have changed in this diff. Show more