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

..
Автор SHA1 Повідомлення Дата
l0ng-ai 3715649aaf fix(services): list the SSH configurations in the Docker connection dialog
The SSH configuration picker reads the settings window's staged list by
default, which is empty outside the settings window, so the Docker
connection dialog offered only None and a configuration picked from a URL
showed as missing. The dialog now passes the live list. The None choice
gets its Chinese text.
2026-11-15 23:47:30 +08:00
l0ng-ai 937008a6fe fix(lsp): highlight a library class the Kotlin server shows as Java as Java (#3565)
A virtual .class document has no language by its name, so it took its server's first language: the Kotlin language server's Java stubs of jar classes were highlighted as Kotlin and their Java keywords stayed uncoloured. A .class stub whose package or import line ends with ';' is now Java (virtual_doc::is_java_class_stub).
2026-11-15 23:40:00 +08:00
l0ng-ai dc3d2050cb fix(editor): colour this and super as keywords in Java, JavaScript, TypeScript and C++ (#3564)
The Lezer grammars tag `this` as `self`, which the colour scheme paints with Python's self-parameter colour, and @lezer/javascript tags `super` as an atom. JAVA_TAGS (also used by JSP), JS_TAGS and a new C++ entry restyle them as keywords. Python's `self` is unchanged.
2026-11-15 23:37:06 +08:00
Mr0Bread 732baf8f1e fix(styles): use defined tokens where CSS read --bg-subtle, --bg-toolbar and --fg-primary (#3550)
Three custom properties were read without a fallback and declared nowhere, so they resolved to their initial values: no background on the Working Status and Command Permissions rows, none on the console Find button (its icon sat over console text), and an inherited hover colour. They now use --bg-hover, --bg-elev and --fg-strong; a dead var(--bg) fallback in Run.module.css is dropped. A test lists any var(--x) without a fallback whose --x is declared nowhere.

Co-authored-by: Mr0Bread <[email protected]>
2026-11-15 23:31:12 +08:00
Mansur Akhmadiтаmansur-akhmadi 9b6e5a5a62 feat(vcs): copy a file's link on its Git remote from Copy Path/Reference… (#3553)
Copy Path/Reference… gains a <hosting> Link item (GitHub Link, GitLab Link, Bitbucket Link) that copies the page Open In › <hosting> opens; in the editor the link covers the selected lines (#L2-L3 / #L2-3 / #lines-2:3). A copyPath menu id lets features add items to the chooser, so editor/core and project-tree stay free of Git code.

Closes #3552

Co-authored-by: mansur-akhmadi <[email protected]>
2026-11-15 23:29:54 +08:00
l0ng-ai 8c2c701517 feat(keymap): list Cut, Copy, Paste, Undo, Redo and Select All in Settings › Keymap (#3563)
The Edit actions are hidden from Search Everywhere, and the Keymap page skipped hidden actions with no preset shortcut, so they could not get a second key (Ctrl+Shift+C / V / X for Vim users). A keymap flag on an action keeps it listed; the settings window's action list (describeActions) carries it too.
2026-11-15 23:25:59 +08:00
l0ng-ai aeb689c632 test(app): the command-line diff opens with Swap Sides (#3557)
#3540 made the command-line diff swappable; the launch tests in windows.test.ts still expected the diff data without the flag.
2026-11-15 23:17:53 +08:00
l0ng-ai 80c25be389 fix(keymap): list the data editor actions in the VS Code preset (#3556)
The VS Code preset must name every IntelliJ action id; #3551 added the db.* data editor actions to the IntelliJ presets only, failing keymap.test.ts on main.
2026-11-15 23:16:27 +08:00
Sameh Elhawaryтаsamehelhawary acea7e6235 feat(php): Phoxite as a selectable PHP language server (#3541)
languages/php.toml gains a phoxite entry (phoxite-lsp over stdio), a fallback listed after phpactor; Intelephense stays the default and users pick Phoxite under Language Servers. ide-tools installs it from the samehelhawary/phoxite GitHub releases (a binary per platform with a .sha256 beside it). A server entry can now name the servers it replaces: those stay away while it serves the file (Phoxite replaces laravel-lsp, which it covers), including when the replacing fallback takes over after a server gives up.

Co-authored-by: samehelhawary <[email protected]>
2026-11-15 23:14:32 +08:00
l0ng-ai 25197b3215 fix(vcs): show a committed directory symlink's target in revision diffs (#3554)
Revision content was decoded with the working file's encoding, read whenever the path existed. For a symlink to a folder that path is a directory, so loading failed and the Git Log diff showed '<path> is a directory'. The working file is now consulted only when it is a regular file; otherwise the encoding is detected from the revision's blob.

Closes #3528
2026-11-15 23:11:50 +08:00
Vadim LazukтаInf1mum 55786a7030 fix(vcs): the Git Log rename label names the original directory (#3546)
The changed-file label trimmed a rename's old path to its base name, so a move between directories looked like a rename in place. A shared renameFrom() now says 'renamed from <name>' within one directory and 'moved from <old path relative to the new directory>' otherwise, as IDEA does; Git Log and the GitHub create / pull request change trees use it.

Closes #3529

Co-authored-by: Inf1mum <[email protected]>
2026-11-15 23:09:13 +08:00
Vadim LazukтаInf1mum b7adbbb17c fix(project): mark symlinks with a link arrow in the tree (#3547)
Directory and file symlinks used the same icons as real folders and files. The Project view now overlays IDEA's link arrow (expui nodes/symlink) on every entry the listing reports as a symlink; the model already carried the flag, so no extra stat calls.

Closes #3527

Co-authored-by: Inf1mum <[email protected]>
2026-11-15 23:06:39 +08:00
l0ng-ai d1290c76ea feat(database): data editor row and toolbar commands as actions (#3551)
Reload Page, First / Previous / Next / Last Page, Add Row, Export Data, Delete Rows, Restore Rows, Edit Cell and the cell copy commands were menu items or toolbar buttons only. They are now db.* actions supplied by the table editor and the console result grid through their focus handle, so they appear in Find Action and take Keymap shortcuts; menus and buttons run the same actions. DataGrip's defaults bind Reload Page, Previous / Next Page and Add Row, and Delete Rows on Windows and Linux.

Closes #3506
2026-11-15 23:03:35 +08:00
l0ng-ai 0febd3e170 fix(editor): Sort Lines orders by character code (#3548)
Sort Lines used a locale-aware collator, which interleaves upper- and lowercase letters. It now compares UTF-16 code units, the order IDEA's Sort Lines uses (String.compareTo) and LC_ALL=C sort gives.

Closes #3516
2026-11-15 22:47:11 +08:00
Akbar Husain acee0d5edb feat(libraries): show the Zig standard library and build.zig.zon packages in External Libraries (#3526)
A zig detector joins the External Libraries pipeline: the standard library from zig env (struct-literal and JSON output), and the fetched build.zig.zon dependencies from the global package cache, as p/<hash> folders (Zig <= 0.14) or p/<hash>.tar.gz blobs (Zig >= 0.15) browsed read-only through the archive VFS, which now reads gzip'd tars. Nothing is downloaded; path dependencies stay in the project tree. New setting languages.zig.zig (directory of zig, empty = PATH).

Co-authored-by: akbar1214 <[email protected]>
2026-11-15 22:45:18 +08:00
Akbar Husain a02a594010 perf(text): byte-parallel scans in ide-text, ide-syntax and ide-language (#3539)
Document open/diff and UTF-16 position mapping decoded whole files char by char. common_ends compares bytes and backs off to char boundaries, utf16_len classifies bytes with an ASCII fast path, LF-only text skips line-ending normalisation, and memchr_newlines uses memchr. Outputs are unchanged; text/open_1mb -60%, diff_two_edits_1mb -38%.

Co-authored-by: akbar1214 <[email protected]>
2026-11-15 22:38:51 +08:00
PONK 6bb4a6375a docs: add Turkish README translation (#3519)
Adds README.tr.md and a Türkçe link in the README language switcher.

Co-authored-by: lprvy <[email protected]>
2026-11-15 22:37:30 +08:00
Mr0Bread f15c2c2b5e perf(startup): load DiffEditor, the Run console and the file-revision chooser on demand (#3544)
DiffWindow, the Services detail pane and Compare with Revision imported these statically, which kept their chunks in the startup set despite lazy imports elsewhere. They now load on demand; initial JS drops by about 34 KB gzip.

Co-authored-by: Mr0Bread <[email protected]>
2026-11-15 22:35:26 +08:00
Mr0Bread a83a1a54e5 fix(agents): refresh stale agent versions after CLI updates (#3538)
The New Task page reused a probe cached for the whole session, so an updated CLI kept showing its old version. The selected agent is probed again (initialize only, no session) when the page is shown or the window regains focus, without overlapping probes.

Co-authored-by: Mr0Bread <[email protected]>
2026-11-15 22:35:21 +08:00
Mr0Bread 0d499f3af6 fix(agents): render Nerd Font icons in agent task titles (#3523)
No font in the UI stack had private-use icons, so OMP task titles showed a missing-glyph box. The UI stack now ends with --font-icons, the first installed Nerd Font (proportional faces first), resolved once when the window is idle. The terminal's Nerd Font list moved to platform/fonts unchanged.

Co-authored-by: Mr0Bread <[email protected]>
2026-11-15 22:35:17 +08:00
zhzhsxтаzhzhsx e06c890ca4 fix(editor): show completion documentation beside the list (#3522)
The completion documentation was drawn inside the lookup popup, which clipped it or let it cover the options. positionInfo now places it beside the list on the side with more room, top edges aligned, with capped width and height.

Co-authored-by: zhzhsx <[email protected]>
2026-11-15 22:35:12 +08:00
Muuтаmuutot 6295cc2237 fix(vcs): keep inline blame on its line when soft wraps are on (#3518)
The line-end blame was an in-flow nowrap inline-block, so with soft wrap a label that did not fit was folded onto a new visual row and stretched the line. It is now a zero-width anchor with an absolutely positioned label, like code-vision hints and plugin line-end messages; the inner box is the hover and click target.

Co-authored-by: muutot <[email protected]>
2026-11-15 22:35:07 +08:00
Mamaev Igor 8349938481 fix(vim): enable Vim in notebook cells (#3515)
Notebook cell editors did not include the embedded editor contributions, so Vim (an embedded contribution) never attached to them. CellEditor now adds embeddedEditorExtensions like the other embedded editors.

Co-authored-by: pxc1984 <[email protected]>
2026-11-15 22:35:03 +08:00
Alexanderтаformiat cb2c4345d1 fix(vcs): a branch whose remote branch is gone tracks nothing (#3533)
upstream_of returned the configured upstream name even when its tracking ref had been deleted and pruned, so Git Branches still showed origin/<name>, Update stayed enabled and incoming commits were polled against a missing ref. It now reports an upstream only while the tracking ref exists; branches() and branch_info() share it.

Closes #3530

Co-authored-by: formiat <[email protected]>
2026-11-15 22:25:02 +08:00
l0ng-ai 22f409ae76 feat(diff): external diff tool and Show Diff for Lines (#3543)
- Settings › Tools › External Diff Tools: a program and its parameters
  (%1 the left file, %2 the right file, %3 the title). Diff viewers then
  offer Show Diff in External Tool (toolbar button and action). Saved
  files are passed as they are (saved first); revisions and texts go to
  temporary files named after the file. The program is always read from
  the global settings, never a project's, and runs on this machine.
- Show Diff for Lines (vcs.showDiffForLines): with the caret on changed
  lines in the editor, opens the file's diff at that line.
2026-11-15 22:18:58 +08:00
l0ng-ai b2dc59eb0f feat(diff): blank diff window, Swap Sides, and Search In Changes (#3540)
- Open Blank Diff Window: a new diff tab with two empty, editable sides;
  the editor's selection fills the left one. Text typed into either side
  survives viewer changes (mode, highlighting, Swap Sides).
- Swap Sides: a toolbar button and action for diffs whose sides the user
  picked (Compare With…, Compare Files, Compare with Clipboard, the
  command-line diff and blank diffs). Diffs of revisions keep their order.
- Search In Changes: a find bar filter in diffs that limits matches to the
  changed ranges of the searched side. It is per diff and applies to
  finding only; the replace bar searches the whole side.
2026-11-15 21:59:59 +08:00
Mr0Bread f5641f9c6f feat(agents): say what the agent is doing in the working status, with opt-in playful words (#3459)
The row under a working conversation showed a constant Working…. It now follows the turn's latest entry: Thinking…, Writing…, Reading…, Editing…, Running a command…, Running a subagent… and so on, with Working… for the rest.

Settings › Agents › Working Status adds playful words per activity, off by default. When on, they rotate from the start or after a configurable quiet time (0–600 s). Each word stays 8 s, or at least 3 s once the activity changes. Screen readers keep hearing the plain status. Users can replace or clear each activity's words.

Co-authored-by: Mr0Bread <[email protected]>
2026-11-15 21:55:09 +08:00
l0ng-ai dcd4f150bd fix(kotlin): stop notifying about every Kotlin compiler error in Maven projects (#3521)
The Kotlin-to-Java bridge popped an error notification for each new failure message, so mid-edit Kotlin with autosave produced a balloon per save for errors its editor already shows. Compiler errors in project sources now only update the Problems entry on pom.xml; a build that fails without one (Maven, a goal that didn't run, the bridge bundle or JDT snapshot) still notifies once per message.
2026-11-15 19:26:00 +08:00
l0ng-ai 8caeb1305f fix(editor): indent Kotlin, Scala, Dart and Groovy brackets opened after a string (#3520)
The string-interpolation wrapper let the stock clike tokenizer consume an empty string on a new stream at column 0 whenever a literal opened; clike read that as a new line and reset the line's indentation to 0, so a bracket opened later on the same line indented its contents from column 0. Enter and paste with Reformat on Paste put the following lines at the wrong column. The empty string is now tokenized at the literal's own column, which fixes all dialects served by the wrapper (Kotlin, Scala, Dart, Groovy; C# shares the code path).
2026-11-15 19:21:48 +08:00
l0ng-ai 7ec0a4fed2 feat(diff): Esc closes a diff tab; switch panes with ⌃⇧⇥; move a diff window back to a tab (#3517)
Close Diff Editor: Esc in a diff editor tab closes it once the find bar, a selection, completion or a popup don't take it; panels, dialogs and diff windows keep their own Esc. Select Opposite Diff Pane (⌃⇧⇥ in the IntelliJ keymaps) moves focus to the other side of a side-by-side diff and wins over the Switcher while a diff is focused. A diff window's Show Diff in Editor Tab saves its edits, opens the diff as a tab in its project window and closes the window.
2026-11-15 18:42:16 +08:00
l0ng-ai 733b6cff1d feat(run): pin Run and Debug tabs; a new run replaces one finished, unpinned tab (#3514)
Run and Debug tabs can be pinned (context menu, Pin Active Tab, or the pin in place of the close button). A new run or debug session takes the place of one finished, unpinned tab: the same configuration's, else the selected one, else the first; Run used to add a tab per configuration, so finished tabs piled up. Rerun on a pinned tab restarts it in place and keeps it pinned. Settings > Run/Debug gets "Make configurations pinned by default".

Siblings: Run, Debug, Find and Usages tabs share one context menu (Close Tab, Close All Tabs, Close Other Tabs, Pin/Unpin Tab); Find and Usages show a pinned tab with the pin in place of its close button.
2026-11-15 18:29:15 +08:00
l0ng-ai b0860ed1e2 fix(debugger): a new debug session replaces one finished tab, not all of them (#3512)
Starting a session dropped every finished Debug tab, so ended sessions of other configurations disappeared with it. The new session now takes the place of a single finished tab: the same-named one, else the selected one, else the first; other finished tabs stay. A prepared start replaces it when its build begins and doesn't replace another when the adapter starts.
2026-11-15 18:01:31 +08:00
l0ng-ai 48a13cb8fa fix(terminal): paste paths of files dragged from the Project view (#3511)
The terminal took file paths only from OS drops (Finder / Explorer). A
file or folder dragged from the Project view onto the terminal did nothing.
The terminal is now a drop target for Project view drags too and pastes the
paths at the prompt, shell-quoted, without executing; it also works in a
remote window, where the paths are the remote project's.

Fixes #3503
2026-11-15 17:28:05 +08:00
l0ng-ai c9488df786 fix(appearance): red-green vision setting also adjusts editor colours (#3510)
Adjust colors for red-green vision deficiency only recoloured VCS file
status, change markers and success, so in a file without changes it had no
visible effect. With the bundled dark and light schemes it now also uses the
schemes' red-green variants for syntax (keywords, strings, numbers, fields,
functions, annotations, TODO), errors (orange instead of red), warnings,
identifier-under-caret and search highlights, breakpoint lines and coverage.
Other colour themes and plugin themes keep their own editor colours.

Fixes #3500
2026-11-15 17:28:01 +08:00
l0ng-ai 4427ec2c07 fix(ui): restore hover backgrounds on the main menu and Review Mode buttons (#3509)
The main menu button (Windows and Linux title bar) and the Review Mode comment navigation buttons are plain buttons, so #2990's IconButton hover did not reach them. They paint --bg-toolbar-hover again; the main menu button keeps it while its menu is open. Gutter run markers, diff arrows and the database banner close button keep their icon-only hover.
2026-11-15 17:26:02 +08:00
Mr0Bread cf71a61cdf fix(ui): restore toolbar icon hover feedback (#2990)
Toolbar and tool window icon buttons (IconButton) paint --bg-toolbar-hover across their hit target again, so hovering shows the clickable area. Disabled buttons stay transparent; pressed, selected, focus ring and icon colours are unchanged. The hover colours match the toolbar tokens already used for the pressed state in Islands Light and Dark.

Co-authored-by: Mr0Bread <[email protected]>
2026-11-15 17:23:13 +08:00
l0ng-ai f9af0e7bff fix(editor): offer to make a read-only file writable when editing it (#3508)
A file with the read-only attribute set (often kept when copying files in
Explorer) opened read-only, and typing into it did nothing beyond the
banner. Typing, pasting or dropping into such a file now asks to make it
writable (the same question as Toggle Read-only Attribute) and clears the
attribute on confirm. Binary, too-large and library documents still refuse
edits without asking.

Fixes #3479
2026-11-15 17:15:50 +08:00
Akbar Husain 6449b68bb0 feat(debug): debug Zig under CodeLLDB (#3392)
Zig programs now debug under CodeLLDB with sourceLanguages: [] so expressions aren't evaluated as Rust; the context menu of a closed .zig file reads its content; rerunning failed tests of a zig build test step derives a standalone zig test from the module root detect/zig.rs records per step (zig build forwards no args to the test binary). Adds the zig-app fixture's failing test step and run/debug smoke cases. The Language smoke CI job for zig is left for a separate change.

Part of #3260

Co-authored-by: akbar1214 <[email protected]>
2026-11-15 17:13:12 +08:00
Mr0Bread 5d2d814610 fix(trust): list Restricted Mode commands by name; snapshot every command's trust class (#3394)
Restricted Mode allowed commands by 19 name prefixes, so any new command matching a prefix was allowed without review. It now lists each allowed command by exact name; a new command is denied until it is listed. src-tauri/tests/commands.snap records every command's trust class, remote placement, bindings presence and sync/async mode, regenerated by the export_bindings test and checked for drift in CI next to src/bindings.ts. The unused bytes_ prefix is gone; doc_open_untitled, doc_save_as and doc_python_facts, added on main since, stay allowed by name.

Co-authored-by: Mr0Bread <[email protected]>
2026-11-15 17:11:59 +08:00
l0ng-ai 3f18a8a274 fix(project-view): paste a clipboard image as img.png (#3507)
Pasting into the Project view only handled files, so a screenshot on the
clipboard did nothing. With no files to paste, Paste now saves the clipboard
image in the target folder as img.png (img_1.png, img_2.png ... when taken)
and selects it; Paste is enabled while the clipboard holds an image.

The image is read natively: public.png, or public.tiff converted, on macOS;
the registered PNG format, or CF_DIB (24/32-bit) encoded as PNG, on Windows;
GTK on Linux as before.

Fixes #3486
2026-11-15 17:10:11 +08:00
l0ng-ai f8a8ecb662 fix(tools): name a tool relative to its Windows wrapper so a non-ASCII user directory works (#3376)
Windows tool wrappers held the tool's absolute path as UTF-8, which cmd.exe reads in the console code page, so a non-ASCII user directory broke every language server and debugger. Wrappers now name paths inside the tool directory via %~dp0 (plain ASCII), and installed wrappers are rewritten at startup with the existing relocate_shims; GEM_HOME lines are covered too.

Fixes #3338
2026-11-15 17:06:56 +08:00
Deng Liuтаdengliu e2b1a2d732 fix(markdown): scale Mermaid diagrams, SVGs and images with preview font zoom (#3481)
Editor font zoom scaled the Markdown preview's text but left Mermaid diagrams, embedded SVGs and raster images at 1x: Mermaid writes a pixel max-width on its svg and images were only capped at 100%. The preview now publishes --markdown-zoom-scale and records each diagram's and image's intrinsic width (SVG viewBox/width/height, data-URL SVG headers, naturalWidth), sizing them as min(base, 100%) * scale so they follow zoom in/out/reset while staying clamped to the pane at 1x. Agent chat diagrams keep scale 1; Mermaid full-screen and PNG export share the same size helper.

Co-authored-by: dengliu <[email protected]>
2026-11-15 16:57:09 +08:00
Akbar Husain db5a306450 fix(vcs): handle checking out a branch held by another worktree (#3484)
Checking out a branch that another worktree has checked out fell through to the generic failure path with git's raw stderr. Checkout now returns CheckedOutInWorktree before touching the repository (no Smart Checkout stash), and the Worktree Conflict dialog offers Checkout Anyway (--ignore-other-worktrees), Open Existing Worktree, or Cancel. Git's pre-2.42 wording is recognised too, and overwriting such a branch via New Branch names the worktree instead of quoting git.

Co-authored-by: akbar1214 <[email protected]>
2026-11-15 16:54:55 +08:00
Deng Liuтаdengliu bad9429e79 fix(agents): skip unselectable chrome and closed <details> in Find highlights (#3485)
Find in Conversation matched text in user-select:none chrome (summaries, roles, times), which WebKit paints over the next selectable row, and painted matches inside closed <details> that still report client rects. Those subtrees are left out of the matches, and matches in closed <details> count but are only highlighted once stepped into (which opens them).

Co-authored-by: dengliu <[email protected]>
2026-11-15 16:48:37 +08:00
Deng Liuтаdengliu c942374f8a fix(agents): apply Fit conversation to the window width live without restarting (#3483)
Off-screen conversation rows kept their old intrinsic heights when the column switched between 900px and full width (the stream's width doesn't change), so they are re-measured when the setting changes; Agents › Chat display preferences are client settings, so remote windows no longer overwrite them with the server's.

Co-authored-by: dengliu <[email protected]>
2026-11-15 16:48:07 +08:00
Mansur Akhmadiтаmansur-akhmadi 611b27b974 feat(run): Run with Fuzzing for Go fuzz targets (#3497)
A Go Fuzz function's gutter menu and the editor's More Run/Debug get GoLand's "Run '<name>' with Fuzzing": its go test configuration with -fuzz (anchored pattern, saved -run/-bench/-fuzz dropped) as a separate temporary configuration; refused for ./... or several packages, which go test -fuzz rejects.

Co-authored-by: mansur-akhmadi <[email protected]>
2026-11-15 16:47:16 +08:00
Mansur Akhmadiтаmansur-akhmadi 71f948cc9f feat(run): Previous / Next Failed Test in the test results toolbar (#3502)
The test tree registers an occurrence navigator for the Run tool window (failed leaves), so ⌘⌥↑ / ⌘⌥↓ step through failures and open their source; toolbar buttons run the same actions and, as IDEA's FailedTestsNavigator, stop at the first / last failure. Merged with main's Expand All / Collapse All (toolbar: expand, collapse, previous, next).

Co-authored-by: mansur-akhmadi <[email protected]>
2026-11-15 16:46:15 +08:00
Mansur Akhmadiтаmansur-akhmadi 79a750362a feat(run): Expand All / Collapse All in the test results toolbar (#3499)
Expand All opens every suite (filtered ones too); Collapse All leaves the top-level suites, as IDEA's test tree does. Bound to ⌘= / ⌘- (IntelliJ) while the test tree has focus, numpad keys in the Eclipse presets.

Co-authored-by: mansur-akhmadi <[email protected]>
2026-11-15 16:44:26 +08:00
Mansur Akhmadiтаmansur-akhmadi f1c0d098ad feat(vcs): Group By › Repository toggle in the Commit tool window (#3490)
With several repositories, View Options › Group By has a Repository toggle (on by default, persisted) shared by the Commit tool window, Local Changes and the Commit dialog; directory nodes now carry their absolute path so repositories outside the project folder group correctly.

Co-authored-by: mansur-akhmadi <[email protected]>
2026-11-15 16:42:47 +08:00
Mansur Akhmadiтаmansur-akhmadi d9c1a9ff22 feat(vcs): view and drop stashes in the Unstash dialog (#3495)
Show Changes opens Changes Between for stash@{n}^..stash@{n} in the dialog's repository; Drop Stash drops the selected stash after confirmation and reloads the list.

Co-authored-by: mansur-akhmadi <[email protected]>
2026-11-15 16:42:26 +08:00
Mansur Akhmadiтаmansur-akhmadi e86011fc87 feat(vcs): choose the repository in the Stash dialog when the project has several (#3493)
With several repositories the Stash dialog shows the repository combo, starts on the given repository and shows that repository's branch; Stash Selected Files keeps stashing each file in its own repository.

Co-authored-by: mansur-akhmadi <[email protected]>
2026-11-15 16:42:19 +08:00
Mansur Akhmadiтаmansur-akhmadi f80dd0aa49 feat(vcs): choose the repository in the Tag dialog when the project has several (#3488)
With several repositories the Tag dialog shows the repository combo (as Pull, Merge and Rebase do); tags are checked and created in the picked repository, ignoring late answers for the previous pick.

Co-authored-by: mansur-akhmadi <[email protected]>
2026-11-15 16:42:12 +08:00
zhzhsxтаzhzhsx 832c397e0b fix(editor): hide the hover documentation while typing (#3491)
The hover tooltip stayed open and moved with the text while typing, covering the completion lookup. It now closes when the document or the selection changes, as Quick Documentation on hover does in IDEA.

Co-authored-by: zhzhsx <[email protected]>
2026-11-15 16:40:27 +08:00
l0ng-ai 0f91aac0d4 fix(completion): leave items their server inserts to the server (#3505)
kotlin-lsp items carry an empty text and a command that inserts them through a workspace edit. We added call parentheses to method items anyway (refreshName()(), an override's body followed by ()) and left the caret in front of the server's text. Items with an empty text get no parentheses, and the caret moves past the server's edit when it lands at the caret.
2026-11-15 16:38:37 +08:00
l0ng-ai d4b2ade24d fix(toolwindows): a window that loses its last tab takes its stripe button with it (#3504)
Hierarchy, Run and Debug hid when their last tab closed but kept their stripe button; Find and Find Usages stayed open and empty. Like IDEA (hide on empty content; Hierarchy unavailable without content), they now hide and drop their button until new content opens them again. Terminal keeps its button.
2026-11-15 16:35:49 +08:00
l0ng-ai e5f642f1d1 fix(agents): list only the files a local task changed while it worked (#3480)
A task in the project's own checkout listed every file changed since it started, the user's edits between turns and other tasks' work included, and rolled them back with it. Each turn snapshots the checkout's changed files as it starts and ends; files whose state moved are recorded (and persisted) as the task's, and its changes, grouped changes and rollback keep to them. Imported sessions start with none; worktree tasks and earlier saved tasks keep listing every change.
2026-11-15 11:43:13 +08:00
Deng Liuтаdengliu b8a06863d9 fix(terminal): lift overhanging fallback glyphs and prefer full-width Nerd Fonts (#3475)
Symbols that fall back to a wider font (U+23F2/U+23F1 via STIX Two Math on macOS, drawn with negative letter-spacing) and non-PUA Nerd Font symbols were clipped by the next cell's background: only Private Use Area icons were lifted. Spans measured wider than their cell are lifted too, the class survives link-hover redraws, the rule is shared with embedded terminals, and full-width Nerd Font faces come before their Mono variants in the fallback list.

Co-authored-by: dengliu <[email protected]>
2026-11-15 11:36:14 +08:00
GuoYuтаguoyucode b5d86b4532 feat(mcp): listing tools return a page, so agents can afford to call them (#3471)
Tools that list things (get_run_configurations, git_status, list_runs, get_project_problems, find_references, list_directory_tree, search_*) take limit/offset and report total (where cheap), more and nextOffset; list_runs filters by state, get_run_configurations by query, get_project_problems lists errors only by default. Detected run configurations no longer list the IDE's own environment. Searches stop offering a nextOffset past the 5000 results they can find.

Co-authored-by: guoyucode <[email protected]>
2026-11-15 11:32:01 +08:00
Akbar Husain d8814d18b0 perf(keymap): serve the effective binding table from cache (#3474)
The dispatcher rebuilt the merged, normalized binding table on every keystroke (~0.28 ms with the default keymap). It is now cached against the identity of the store's preset, overrides and extensions, which every writer replaces wholesale.

Co-authored-by: akbar1214 <[email protected]>
2026-11-15 11:25:37 +08:00
l0ng-ai 7bd115bc97 fix(debugger): replace a finished Debug tab when a prepared start begins (#3478)
A start that builds first added its tab right away but dropped finished sessions only once the adapter started, so the previous run's terminated tab stayed beside the new one during the build. startPreparedSession now drops finished sessions before adding its tab.
2026-11-15 11:15:14 +08:00
l0ng-ai 74e66a5dae feat(plugins): environment and debugger for plugin run configurations (API 1.9) (#3476)
Plugin run configurations could not set environment variables, and Debug
only worked when a source file in the arguments let the IDE infer a
debugger, so a plugin could run `func start` for an Azure Functions app but
not debug the worker it starts.

API 1.9 adds the optional `launch-configurations` export, preferred over
`run-configurations` when a component has both. Each configuration can add
environment variables (only names the IDE's environment doesn't already
have, like `process` commands) and name a debugger: launch the program
under a language's debugger, or run it and attach to the first process of
an Attach to Process kind that the run starts. Only the run's own
descendants are candidates; the IDE waits up to a minute while the run
lasts and otherwise leaves it running without the debugger. Remote projects
resolve the process on the server.

The SDK gets `LaunchConfigurations` / `launch_configurations`, the README
and docs describe the export, and the bundled components and fixtures are
rebuilt for 1.9.
2026-11-15 11:01:12 +08:00
l0ng-ai 6c301a7b6b feat(run): fold the chooser's other configurations under All Configurations (#3473)
Once some configurations are pinned or recent (and there are five or more), the run configuration chooser folds the rest under a collapsed "All Configurations N" row, kept per project; a search still finds them. Each row gets a ⋮ button (→ on the keyboard) with Run, Debug, Edit…, Pin / Unpin and Delete. Current File now follows the configurations.

Closes #3074
2026-11-15 10:34:53 +08:00
l0ng-ai d171a946fe fix(editor): Spring, endpoint, npm, Docker and plugin gutter icons join the shared icon area (#3472)
Following #3452, the remaining line-marker kinds kept a gutter column each (a Spring controller showed its mapping icons in a second column beside the run icon). Scripts and Docker/Compose targets join the run icons in the shared icon area (hidden with them), Spring mappings and endpoints follow, then plugin line markers, gutter actions and indexed mappings (now 14px like the others). Presses go to the pressed icon's source.
2026-11-15 10:31:42 +08:00
l0ng-ai 6ae167efc9 test(lsp): wait for merged injection diagnostics instead of reading them at once (#3470)
The SQL server's launch fails with the test launcher and reports its status from a runtime thread; while that thread drains the server's event queue it delivers the diagnostics the test emits, so reading the sink straight after emitting could find no host event (24/200 failures alone). The test now waits for the expected host diagnostics at each step (0/300).
2026-11-15 09:53:29 +08:00
Deng Liuтаdengliu 8432bf9997 feat(agents): add "Fit conversation to the window width" chat setting (#3455)
Settings › Agents › Chat › Conversation gets an opt-in toggle (agents.fitWindowWidth, off by default). When on, the conversation stream, composer, question card, user message bubbles and the New Task page drop their max-width caps and follow the pane width; when off, the readable 900 px column stays.

Co-authored-by: dengliu <[email protected]>
2026-11-15 09:50:13 +08:00
Deng Liuтаdengliu 59fba1a65d refactor(agents): hide redundant Terminal actions for local project-root tasks (#3460)
A task running in the local checkout at the project root opens the same folder as the IDE's own terminal, so its status-bar Terminal button and task-menu Terminal entry are hidden there. Worktree tasks and imported sessions in another folder keep both.

Co-authored-by: dengliu <[email protected]>
2026-11-15 09:45:47 +08:00
Mr0Bread 3bfd3134c0 feat(agents): add a shortcut to cycle reasoning effort (#3454)
Cycle Agent Effort (Option+T / Alt+T in every built-in keymap) cycles the current model's advertised effort levels while focus is in New Task or a writable task conversation, through the existing config setter; the message, caret and focus are kept and Send stays available.

Co-authored-by: Mr0Bread <[email protected]>
2026-11-15 08:59:01 +08:00
l0ng-ai 573adbf9ab fix(lsp): open dependency classes without sources decompiled by jdtls (#3469)
Go to declaration from Kotlin into a dependency class with no cached sources jar opened the Kotlin LSP's signature-only decompile output. When jdtls is running, the jar: location is resolved to the same class in jdtls' Java model (matching archive and package), decompiled with method bodies and navigable; the Kotlin output stays the fallback.

Refs #3447
2026-11-15 08:53:33 +08:00
Muuтаmuutot d2381096fb fix(editor): re-render inheritance markers when files change (#3468)
A Python file added or removed invalidated the cached inheritance graph and markers, but nothing redrew the open documents, so a tooltip kept naming a deleted class. The file-change handler now refreshes the live marker plugins, as a settling server already did.

Co-authored-by: muutot <[email protected]>
2026-11-15 08:46:50 +08:00
Muuтаmuutot 2c70c12bf2 fix(analysis): refresh Python inspections when their profile changes (#3466)
The inheritance inspections read the profile when they run, but only an edit, a file change or a settling server ran them, so turning one on or off in Settings looked inert until the file was touched. They now subscribe to profile changes, like the server diagnostics already do.

Co-authored-by: muutot <[email protected]>
2026-11-15 08:46:45 +08:00
l0ng-ai 3ef7fee3e2 fix(menu): keep New first in the File menu, with Untitled File inside it (#3467)
#3427 put New Untitled File above the New submenu, which broke the menu test
that the File menu starts with New (as in IntelliJ IDEA). It now sits in New
next to Scratch File, labelled Untitled File.
2026-11-15 08:45:04 +08:00
Sergei Melnikov deec6d05a0 fix(vcs): show the Push dialog's repositories, unpushed commits, files and diff as IntelliJ IDEA does (#3461)
Push… from one repository's branch menu lists only that repository, and a branch without upstream lists the commits no remote branch has instead of its last 50. Push all lists every repository (up-to-date ones greyed) and checks those with commits. Commits and changed files sit side by side at a fixed height, so selecting a row no longer resizes the dialog and swallows the first checkbox click; the commit details and file tree are split by a draggable divider, and a selected file's diff opens full-width below both panes with a close button and a draggable height.

Co-authored-by: pdasilem <[email protected]>
2026-11-15 08:42:50 +08:00
Muuтаmuutot 4a98ce544b fix(editor): resolve an imported Python name to its declaration, not the import (#3462)
basedpyright and pyrefly analyse one root, so for a name declared in an attached dependency project they answer the file's own import statement. When the declaration request's only answer is an import line of this Python file, the name's from-import binding (aliases included) names the module; nav_resolve_module finds it, attached projects included, and the project index's declaration there becomes the target (or the module file when the index has none). A caret on the import itself keeps its existing answer.

Co-authored-by: muutot <[email protected]>
2026-11-15 08:30:14 +08:00
SunZhiQiang 396a8d4b26 feat(agents): temporary side conversations from a selection (#3411)
Selecting text in an agent conversation shows a toolbar with Reply (quotes it into the composer) and Start Side Conversation, which opens a temporary, read-only conversation beside the task. It carries the source conversation (newest first within a 100k-character budget, long tool outputs trimmed) as the context of its first message, stays out of the Agents list and the store, and is discarded with its context when its view closes. Its session is pushed to a mode that asks before changing things, and edit / delete / move / execute requests are denied; an agent without such a mode is refused.

Co-authored-by: panda-student <[email protected]>
2026-11-15 08:27:35 +08:00
l0ng-aiтаpanda-student 2889b301b8 fix(ide-workspace): declare GetProcessTimes consistently (#3465)
process.rs and memory.rs declared the same kernel32 GetProcessTimes with different out-pointer types, which Windows clippy rejects (clashing_extern_declarations). Both now read the FILETIMEs as u64.

Co-authored-by: panda-student <[email protected]>
2026-11-15 08:24:15 +08:00
l0ng-ai e26e1d1d7d fix(themes): add the shell command token to the theme template and docs (#3464)
#3458 added --syn-shell-command without setting it in the Harbor Night
scheme (the template new themes start from) or regenerating plugins/themes.md,
so scripts/theme/theme.test.ts failed. Imported IntelliJ schemes map
BASH.EXTERNAL_COMMAND to it.
2026-11-15 08:23:20 +08:00
l0ng-ai 0af3bd00e7 fix(editor): highlight shell commands, function names and local from the grammar (#3458)
The legacy shell stream mode works from fixed word lists: local, declare and
return were plain text, and so were function definitions and any command
outside its ~70 builtins. The bundled tree-sitter-bash grammar now draws its
highlights over the stream mode, as Kotlin's does: declaration keywords,
function definition names, command names and variables. Command names get
their own colour (--syn-shell-command, Color Scheme › Shell Script ›
Command), blue in light and orange in dark schemes, falling back to the
function-call colour in colour themes.
2026-11-15 05:10:11 +08:00
SunZhiQiang 951e81e065 feat(editor): untitled buffers and Save As with a file type (#3427)
File › New Untitled File opens an editable untitled buffer (Untitled-N), also from the welcome page (New File, plus Open File beside Open), the LightEdit empty-editor hints, and Meta+N in the VS Code keymap. Saving asks for a path in a Save As dialog whose type dropdown lists every registered language; the backend re-keys the buffer under the operations lock, keeps its undo history and re-detects the language. Untitled buffers belong to the window and follow it into and out of projects; closing a dirty one asks to save it. A LightEdit window with only untitled buffers offers Open Project via the folder picker.

Closes #3426

Co-authored-by: panda-student <[email protected]>
2026-11-15 04:32:12 +08:00
l0ng-ai 023c0a6ee6 fix(debugger): silence the unix-only runInTerminal pid on Windows (#3456)
ProgramTerminal's pid and process_id are read only by the unix process-group
cleanup, so Windows clippy failed with dead-code warnings.
2026-11-15 04:11:35 +08:00
rohitkumardandaтаrohitdanda c5481e8fe4 fix(lsp): language servers that give up, start slowly or send bad data (#3449)
Several causes behind servers that ended up absent or stuck:
- Fallback servers now take over when the preferred one gives up (missing, not launchable, crashed for good, expired), not only when it is missing, and hand back after a manual restart.
- Crash-budget uptime counts from ready, not from launch; the crash message carries why initialize failed (timeout or the server's error).
- Restart works while a server is still starting and no longer holds the reconfigure lock while it waits; a server dropped during its crash backoff no longer comes back.
- Progress of a still-starting server is watched.
- One malformed diagnostic no longer drops the file's other problems; DocumentLink.target URIs are escaped.
- Semantic tokens, inlay hints and code lens are asked again on ContentModified (staleRequestSupport).
- rustup toolchain lookup uses the platform executable suffix and USERPROFILE, and probes in the project root with a timeout.
- Server output lines are capped at 8 KiB.
- Go to Declaration from Java to a Kotlin class declared in a differently named file finds the .kt source.
- The kotlin-lsp install hint names the Language Servers settings page.

Co-authored-by: rohitdanda <[email protected]>
2026-11-15 04:10:44 +08:00
Deng Liuтаdengliu dfacb45575 fix(agents): sanitize unsupported ambient GitHub tokens for Copilot CLI (#3443)
Copilot CLI rejects a classic PAT (ghp_…) in GITHUB_TOKEN / GH_TOKEN / COPILOT_GITHUB_TOKEN outright instead of falling back to its stored login, and a stale or narrow-scope token from the login shell made model discovery fail with "Not authenticated". Classic PATs inherited from the shell are now cleared for copilot; when discovery still fails with an authentication error and such a token is inherited, it retries once without the ambient tokens and keeps doing so for later launches. Tokens set in the agent's own environment settings are kept.

Co-authored-by: dengliu <[email protected]>
2026-11-15 04:07:51 +08:00
Mr0Bread 3363bb8a46 feat(agents): resize the task Changes pane by dragging its edge (#3451)
The Changes pane beside a task's conversation was fixed at 280 px. Its left edge is now a separator that drags (or moves with ←/→ when focused); the width applies to every task, is remembered across restarts, and always leaves the conversation 240 px (half of a narrower session). In the tool window the Changes view fills the window as before.

Co-authored-by: Mr0Bread <[email protected]>
2026-11-15 04:05:17 +08:00
Mr0Bread 39f34ec22f fix(agents): clicking the bottom of the message box puts the caret in it (#3450)
The composer's 44 px minimum height sat on CodeMirror's scroller, while the text inside stopped at 32 px (its min-height: 100% doesn't resolve against a min-height parent), so a click in the strip below focused the bare scroller and showed no caret. The minimum height now sits on .cm-content.

Co-authored-by: Mr0Bread <[email protected]>
2026-11-15 04:05:07 +08:00
Taylor BurkeтаTaylorBurke 34ae8acbf8 fix(window): lower the minimum window width to 400 px and let the UI fit it (#3440)
Tiling compositors ignore a client's minimum size, so a tile narrower than 800 px laid the webview out at 800 px and scaled it down. The minimum is now 400 px on every platform. At that width the welcome page header and action tiles wrap, the title bar's run and VCS widgets ellipsize instead of covering the toolbar, dialog footers wrap, and below 560 px the left and right tool windows collapse (their stripe buttons stay, the saved layout is unchanged, a wider window brings them back).

Closes #3046

Co-authored-by: TaylorBurke <[email protected]>
2026-11-15 04:02:55 +08:00
l0ng-ai 6bafcb2b5c fix(i18n): translate the memory popup's PID tooltip in Chinese (#3453)
The PID tooltips from #3437 kept the English text in zh.json, which the i18n catalog test rejects; they now read 进程号 {pid}.
2026-11-15 03:58:02 +08:00
l0ng-ai a6ed65e6d1 fix(editor): run icons, inheritance markers and the doc toggle share one gutter column (#3452)
Each kind of gutter icon had its own column, so a file with doc comments kept an empty column for the hover-only rendered-view toggle beside the inheritance markers, and run icons took a third. A shared icon area (iconAreaSource facet in core, exported from @/editor/api) now holds run icons, implements/overrides markers and the doc toggle in one column, as wide as the most icons on any line; empty areas take no width, sources can reserve an icon's width and handle their own presses. Hiding run icons drops them from the area.
2026-11-15 03:56:38 +08:00
l0ng-ai 009e61d27b fix(editor): clicking the author hint again closes the blame annotations (#3448)
The Code Vision author hint ran Annotate with Git Blame only while the annotations were hidden, so a second click left them open. It now runs vcs.annotate on every click, toggling the annotations like the action itself.
2026-11-15 03:46:11 +08:00
l0ng-ai a89c0dd229 feat(ui): small raster data-URI images as icons (#3446)
Icon draws a raster data: URI (PNG, JPEG, GIF or WebP, base64, at most 64 KiB) as an image of the icon's size, so plugin view items and buttons can show avatars and status badges the plugin fetched with its net grant; the UI never loads URLs for icons, and older hosts show the file icon. Documented in the views guide.
2026-11-15 03:33:29 +08:00
l0ng-ai ea644dcdb0 feat(plugins): diffs of up to 8 MiB per side from open-diff (#3445)
views.open-diff and content tab reply diffs accept 8 MiB per side (was 1 MiB). A larger side fails with invalid("diff too large: the <side> side is over 8 MiB") from the host call and "Diff too large" for a web view's open-diff message, so plugins can tell the size limit from a malformed request. WIT docs updated and plugin components rebuilt.
2026-11-15 03:32:29 +08:00
l0ng-ai 519c8ad8fc feat(plugins): Markdown preview for Markdown content tabs (#3442)
A Markdown content tab (language markdown, or a .md file name) gets the Markdown editor's layouts (Editor Only / Editor and Preview / Preview Only) and opens on the preview when read-only. The preview uses the editor's sanitized Markdown pipeline, highlights fenced code, follows reloaded content and opens web links in the browser. No API change; documented in the plugin README and views guide.
2026-11-15 03:30:52 +08:00
l0ng-ai 27fdbe71e5 fix(ui): leave language status widgets out of projects without the language (#3439)
The status bar context menu listed PHP Language Level and Python Interpreter in every project, although neither widget shows without PHP or Python files. Status widgets can now be marked unavailable for the open project (setStatusWidgetAvailable): neither rendered nor listed in the menus. PHP and Python track availability from activation (so hidden widgets are judged too) and re-check on project, index, file-set and settings changes.
2026-11-15 03:01:18 +08:00
l0ng-ai d5cd5e7a55 feat(perf): CPU, PID and End Process in the memory popup (#3437)
The Memory & Diagnostics popup now shows each row's CPU use since the previous measurement (percent of one core; proc_pid_rusage / /proc/<pid>/stat / GetProcessTimes via ide-workspace CpuSampler), the PID in the row tooltip, and an End Process button for language server, run and terminal rows. end_process_tree sends SIGTERM, then SIGKILL after a grace period (TerminateProcess on Windows), and refuses pids that don't descend from the backend; owners see the exit as usual (language servers restart, terminals close).
2026-11-15 02:55:37 +08:00
l0ng-ai c32a68c0b8 feat(ui): Project Structure toolbars and the type hierarchy root's package (#3436)
Project Structure: Modules gets an icon toolbar (+ menu with Add Module Overrides / Import Module from Existing Sources, -); Libraries gets + (JAR or Directory, Classes Folder), - and Copy. Type Hierarchy: the root type shows the server's detail (Java: its package) beside its name, through the additive lsp_prepare_type_hierarchy command.

Part of #3409
2026-11-15 02:29:04 +08:00
pravdomil 59cc84521f fix(project-tree): Autoscroll to Source no longer opens files in external apps (#3429)
Autoscroll to Source used the same EditorService.openFile as an explicit open, so files matching Files opened in associated applications (PDF, Office documents) were launched in the system app whenever the selection touched them. OpenOptions.external = false now leaves such files unopened; ExternalFileOpenHandler gains matches() so the editor can tell without launching anything. Explicit opens (double-click, Enter, Jump to Source) are unchanged.

Co-authored-by: pravdomil <[email protected]>
2026-11-15 02:15:18 +08:00
l0ng-ai 4faa37aaea fix(vcs): Pull, Merge and Rebase start on the repository of the context (#3435)
The context menus' Git > Pull... ran Update Project (every repository) while the rest of the submenu acts on the selection. It now opens the Pull dialog. Pull, Merge and Rebase start on the repository of the action's files, else the focused Project view selection, else the active editor's file, else the selected repository; Update Project still updates every repository.

Closes #3404
2026-11-15 02:13:34 +08:00
l0ng-ai aaf25d9f0d fix(vcs): keep the Project view branch suffix of workspace members current (#3434)
Only the project folder's watcher emitted VcsChanged on .git changes, so members outside the workspace folder kept a stale branch suffix after a checkout, fetch, pull or push. Member watchers now emit it too (same own-index-write filter), and the suffix is read again when a repository's HEAD moves or a fetch / pull / push finishes.

Closes #3405
2026-11-15 02:13:23 +08:00
l0ng-ai 913ad6616c feat(vcs): View Commits after Update Project opens an Update Info tab (#3432)
View Commits opened a modal dialog with one repository's commits. It now opens a closable Update Info tab in the Git tool window: the Log of every updated repository, each limited to the range the update brought in (LogFilters.repoRanges, sent per repository as the query's ranges), with the Log's filters, details and changed files; each update opens a tab of its own.

Closes #3403
2026-11-15 02:00:12 +08:00
l0ng-ai b80f0f9e39 fix(ui): collapsing a tree node also collapses everything expanded below it (#3430)
Collapsing a node kept its expanded descendants in the expanded set, so expanding it again brought the whole subtree back. The shared Tree now drops the node's loaded descendants when it collapses (chevron, Left, Enter, single-click expand), so every tree (Project view, Structure, Git, Debugger variables, ...) shows only direct children on the next expand; trees that persist their expanded set save the result.

Closes #3402
2026-11-15 01:48:20 +08:00
l0ng-ai 460027fd4b feat(layout): Restore Current Layout and Save Changes in Current Layout (#3421)
Restore Current Layout applies the active saved tool window layout again, or the default layout when none is active; Shift+F12 in the IntelliJ keymaps now runs it. Save Changes in Current Layout stores the current windows into the active layout. Both are in Window > Tool Window Layouts.

Closes #3399
2026-11-15 01:28:16 +08:00
Muuтаmuutot 33c5627bdb feat(analysis): Python inheritance inspections from the IDE graph (#3419)
Four Python inspections under a new Python category in Settings › Inspections, computed from the IDE's inheritance graph with facts the backend extracts from the syntax tree (doc_python_facts): circular inheritance (error), @override on a method no base declares (warning), abstract method not implemented (warning), redundant override (weak warning). A rule stays silent while a base on the lookup path is outside the project; object, ABC, Generic and Protocol count as known. A class that is itself abstract (own abstract methods, or ABC / Protocol as a base) isn't asked to implement inherited abstract methods.

Co-authored-by: muutot <[email protected]>
2026-11-15 01:20:26 +08:00
akshathayadav378-boopтаakshathayadav378-boop 3a22a2d0f0 fix(vcs): show cached inline blame without the 300 ms debounce (#3410)
A caret move in a document whose blame is already loaded shows the new line's inline blame at once (BlameCache keeps the resolved lines and peek(doc) returns them for the current document version); edits and first loads keep the 300 ms debounce, so typing starts at most one git blame per pause. This replaces the equivalent change merged in #3418.

Co-authored-by: akshathayadav378-boop <[email protected]>
2026-11-15 01:11:43 +08:00
l0ng-ai c169b8ab3c fix(vcs): show the current line's inline blame at once when the file is blamed (#3418)
A caret move within a document whose blame is loaded (or loading) now shows the new line's inline blame at once; edits and a document not blamed yet still wait 300 ms, so typing doesn't run git per keystroke.
2026-11-15 01:10:23 +08:00
l0ng-ai 0dbce5d09e feat(vcs): show the number of changed files in the Project view's branch suffix (#3416)
With "Show branch and incoming/outgoing commits in the Project view" on, a repository root with local changes also shows how many files changed (staged, unstaged or untracked; ignored aside), with a tooltip; a clean repository shows nothing extra. The count follows the working tree status, re-rendered from the branches already read without asking git again.
2026-11-15 01:08:47 +08:00
l0ng-ai 7fe6f5f58d feat(run): run icons on the profiles of launchSettings.json (#3415)
A .NET project's Properties/launchSettings.json shows a run icon on each Project profile; Run / Debug launch the project's configuration for that profile, or a temporary copy for a profile added since detection ran. The package.json scripts gutter is generalised into entryGutter for this.
2026-11-15 01:04:59 +08:00
l0ng-ai abf904553d feat(build): build .NET solutions and projects in the Build tool window (#3414)
Build Project / Build Module / Rebuild / Build Artifacts build a .NET solution or project (dotnet build, --no-incremental, dotnet publish), and MSBuild's canonical file(line,col): error CODE lines become Build messages linking to the source. A .NET run configuration's "Build .NET project" before-launch task builds its project in the Build tool window instead of a Run tab.
2026-11-15 01:00:28 +08:00
l0ng-ai d425388f1a fix(run): read .NET launch profiles from a launchSettings.json with a byte order mark (#3412)
Visual Studio saves launchSettings.json with a UTF-8 byte order mark, which serde_json rejects, so a project's launch profiles came out empty and a configuration naming one failed with "launch profile is missing". jsonc::strip drops a leading byte order mark, which also covers devcontainer.json, the .vscode/launch.json import, tsconfig.json and run detection.
2026-11-15 00:54:37 +08:00
Akbar Husain de123e2a21 perf(index): stream the cold scan instead of chunk joins (#3407)
The initial scan parsed files in 512-file chunks (rayon join, then insert and redb commit), so every join idled the pool on its slowest file and insert/persist serialized between chunks. The scan now parses the whole todo list in one rayon pass, serializes cache records inside the parse, and streams them over a bounded channel to the index thread, which inserts and persists in batches of 2048 alongside the parse. Records stay byte-identical; the scan's log line carries walk / classify / extract / insert / persist / durable laps.

Co-authored-by: akbar1214 <[email protected]>
2026-11-15 00:48:27 +08:00
Muuтаmuutot d19dbec56a feat(editor): Python inheritance in navigation from one shared graph (#3397)
Python's servers answer no type hierarchy, so the navigation built on it did nothing for Python. The inheritance graph moves out of the gutter into src/editor/navigation/inheritanceGraph.ts (one fetch per project, the name map and base resolver), and Type Hierarchy, Structure's Show Inherited, Super Method and Go to Implementation use it for Python, merged with whatever the server answers. The gutter keeps its per-file import reading.

The subclass map is built when a subtype lookup first needs it; the files whose imports decide a base are read in one batch, bases resolve in parallel, and an import whose name is no class of the project is not looked up.

Co-authored-by: muutot <[email protected]>
2026-11-15 00:41:25 +08:00
l0ng-ai 1a204b5f4b fix(lsp): show TypeScript 7 as the server when its own tsc --lsp runs (#3408)
A project on TypeScript 7 runs the compiler's native tsc --lsp in place of vtsls / typescript-language-server, but the status bar and the server popup still named the configured server. The settings hook gives such a server a display name, carried by ServerInfo and the languageServer event as an optional label; the server's name stays its identity.
2026-11-15 00:25:51 +08:00
l0ng-ai 3ec7daf845 fix(profiler): name js-debug as the recorder of a page's CPU profile (#3406)
A page's CPU profile is read like a Node.js run's .cpuprofile files, so its snapshot header said "node --cpu-prof". profiler.showCpuProfiles takes an optional profiler name; the debugger passes js-debug.
2026-11-15 00:16:52 +08:00
ntbang0901 112ac05052 feat(debugger): replace the responses a debugged page receives (#3329)
A page session can answer requests itself: rules (URL part → status, content type, body) are kept per project, edited from the Network tab (Overrides, Override Response… on a request), and applied through js-debug's CDP proxy with Fetch.enable / Fetch.fulfillRequest. Interception is switched off before the connection is dropped, unknown statuses get a reason phrase, cross-origin pages get CORS headers and preflight answers, and rules present at page start wait for the launch response.

The Network list marks a request overridden only when it was sent after the rule that answers it was saved or enabled.

Co-authored-by: ntbang0901 <[email protected]>
2026-11-15 00:15:09 +08:00
ntbang0901 10b7340e2a feat(debugger): CPU profile of a debugged page (#3320)
The More menu of a page session has Start / Stop CPU Profile (js-debug's startProfile / stopProfile); the recording opens in the Profiler tool window as a snapshot named after the session. The .cpuprofile reader prefers js-debug's $vscode.locations, so a frame opens the project's source at its line instead of the URL the browser ran.

Co-authored-by: ntbang0901 <[email protected]>
2026-11-15 00:01:16 +08:00
Andrea Basileтаandreabasile 6fa5b368e6 feat(python): a Docker or Compose run target as the project interpreter (#3364)
A Docker or Compose run target can be the project's Python interpreter (languages.python.interpreter = "target:<id>"). The IDE runs the target's Python once in a new container (entrypoint and service dependencies skipped), copies its sys.path folders outside the standard library and the project with docker cp, once per image, and gives them to basedpyright / Pyright as extraPaths with an empty local virtualenv as interpreter. Packages show read-only with a refresh that copies again; Python run configurations without a target of their own run on that target; the copy shows under External Libraries. Snapshots require a trusted project.

Co-authored-by: andreabasile <[email protected]>
2026-11-14 23:53:40 +08:00
Mr0Bread dd40273952 test(database): cover result submission lifecycle interleavings (#3346)
Covers paging staying disabled while a remounted result applies edits, and an apply finishing after its result tab closed without refreshing another tab. (The refresh-failure case is already covered on main.)

Co-authored-by: Mr0Bread <[email protected]>
2026-11-14 23:43:23 +08:00
l0ng-ai 21bc81c6da fix(ui): remember splitter positions in the Git and other tool windows (#3401)
None of the Git views gave SplitGroup a storageKey, so their splitters reset whenever the view was recreated. They, the Commit dialog and the Todo, Find, Problems, Usages, Build, HTTP Client and Dependency Analyzer splits now store their sizes. A view whose preview comes and goes keys sizes per panel set, and SplitGroup applies that set's sizes when the key changes, without remounting the view.
2026-11-14 23:42:02 +08:00
l0ng-ai 1dc1e3cca1 feat(debugger): Focus On Breakpoint toggle for the Threads & Variables tab (#3400)
A breakpoint hit always brought Threads & Variables to the front. Its tab's context menu now has Focus On Breakpoint (on by default, client setting debugger.variablesOnBreakpoint); turned off, a hit keeps the current tab, e.g. Console. Finishing a step already kept it.
2026-11-14 23:34:17 +08:00
l0ng-ai 4a82c22fdc fix(plugins): keep document indexer markers on their lines while typing (#3398)
Every edit emptied the indexed-marker gutter until the host re-indexed the file; the column has no fixed width, so the text shifted left and back on each keystroke. The markers are now mapped through the change (a deleted line drops its own), as line markers and bookmarks already do.
2026-11-14 23:27:33 +08:00
Mr0Bread 010e7ba6b4 feat(agents): add configurable task input focus shortcut (#3382)
Focus Agent Input (⌘L on macOS, Ctrl+L on Windows / Linux, in every built-in keymap) returns focus to the current writable task's message input from its conversation, in an editor tab or the tool window, keeping the draft, caret and selection. It applies only while focus is inside an agent task, so the keys keep their editor and terminal meaning elsewhere; the binding is under Settings › Keymap › Agents.

Co-authored-by: Mr0Bread <[email protected]>
2026-11-14 23:24:59 +08:00
Mr0Bread 9984e40b83 fix(agents): refresh available models before starting tasks (#3366)
New Task showed no models until an agent's first task. It now shows the cached catalog at once and discovers the agent's models without starting a task (no prompt, no project MCP servers): one shared lookup per provider and project, fresh for 15 minutes, with a Refresh models action and a 30-second cooldown. Catalogs persist per effective launch configuration (a digest, never the arguments or credentials); a selected model the latest catalog dropped blocks Start until another is chosen. Discovery requires a trusted project.

Co-authored-by: Mr0Bread <[email protected]>
2026-11-14 23:22:14 +08:00
Muuтаmuutot 36a6e6359d feat(editor): resolve an ambiguous Python base name through the file's imports (#3384)
A base name several modules declare was skipped, so a project with many Config classes got no marker for them. The subclass file's from-imports (aliases included) now pick the declaration; with no import naming it the base is still ignored. Imports are read only when a base may name a class of the open file, so a marker refresh does not read every subclass file of the project.

Co-authored-by: muutot <+[email protected]>
2026-11-14 23:14:53 +08:00
Muuтаmuutot 4f64326f89 fix(editor): keep close gestures when the close button is hidden (#3354)
EditorArea passed closable: showCloseButton, and closable gates every close gesture in Tabs, so Close button position: None made editor tabs unclosable by middle click, Shift+click and Delete. Tabs gets a visual-only showCloseButton that hides the button while the tab stays closable.

Co-authored-by: muutot <[email protected]>
2026-11-14 23:12:14 +08:00
akshathayadav378-boopтаakshathayadav378-boop b5c2d0dc5e fix(settings): let Project View branch decorations be bold and italic (#3387)
The six Project View branch attributes never set fontStyle, so Settings disabled Bold and Italic for them although the Project view's branch suffix already reads --git-<attr>-bold/-italic/-effect. Setting it enables the checkboxes and makes the overrides emit those variables.

Co-authored-by: akshathayadav378-boop <[email protected]>
2026-11-14 23:10:13 +08:00
l0ng-ai c9dca15972 fix(debugger): let a Node.js program read input while it is debugged (#3395)
js-debug launched the program with the internal console, so it had no standard input. Run it in the debugger's pseudo-terminal instead, and reap a runInTerminal program as soon as it exits: an unreaped zombie looked alive to js-debug's processId polling and kept the session open.
2026-11-14 23:08:36 +08:00
l0ng-ai 41b581eafd fix(github): recognise remotes cloned through ssh.github.com (#3391)
A clone over GitHub's SSH-over-HTTPS host (ssh://[email protected]:443/o/r.git or [email protected]:o/r.git) wasn't recognised as a GitHub repository. parseGitHubRemote now accepts ssh.github.com for SSH remotes; https://ssh.github.com stays rejected. Sibling of #3379.
2026-11-14 22:57:59 +08:00
akshathayadav378-boopтаakshathayadav378-boop 13e18af777 fix(vcs): open Bitbucket SSH clones on bitbucket.org (#3379)
For a Bitbucket Cloud repository cloned over SSH ([email protected]:team/repo.git), Open In used ssh.bitbucket.org as the web host and GitLab routes. remoteWebUrl now maps the services' SSH front hosts (ssh.bitbucket.org, ssh.github.com, altssh.gitlab.com) to their web hosts before building the URL.

Co-authored-by: akshathayadav378-boop <[email protected]>
2026-11-14 22:54:44 +08:00
charlie-morrisonтаcharlie-morrison 488e819b46 fix(vcs): keep the merge editor responsive on long files (#3381)
Opening a conflicted package-lock.json in the merge editor froze the app. The Your/Their panes diffed each side against the base with @codemirror/merge's default config, which gives up past ~16 KB of changed span and returns one chunk from the first change to the last, so each pane rendered the whole base as one deletion widget; syncMergeScroll ran an unbounded character diff on first scroll. Both now use the IDEA line comparison the diff viewer uses (ideaDiffConfig).

Co-authored-by: charlie-morrison <[email protected]>
2026-11-14 22:53:04 +08:00
l0ng-ai ad8df8fe00 feat(database): open several query consoles per data source (#3386)
New › Query Console always reopened the single console of a data source. It now creates another console each time (console, console_1, console_2…, as in DataGrip); Jump to Query Console opens the only console directly or lists them with New Query Console; Tools › Query Console keeps opening the default one. Backend: db_consoles(id, create).
2026-11-14 22:50:51 +08:00
l0ng-ai 19d6eab21a feat(http-client): find in the response body and open the files .http lines name (#3383)
The response pane had no find: ⌘F (or a Find button next to Show Raw) now opens the console find bar with matches marked in the body. ⌘-click / ⌘B on the file a .http line names (< body, > handler, >> redirect, <> saved response) opens it; saved responses resolve against .idea/httpRequests/ (where IntelliJ IDEA writes them), then the request file's folder.
2026-11-14 22:32:50 +08:00
Muuтаmuutot 5c0befeff0 feat(lsp): compute Python inheritance gutter markers from the IDE index (#3359)
Python's overrides / is-overridden gutter markers never appeared: basedpyright and pyrefly declare no typeHierarchyProvider, and a single-root server cannot see classes in attached projects. The IDE now extracts every class X(Base) with tree-sitter across the project (content roots included) and nav_inheritance exposes them; the editor merges this graph with the server's answer. A base name is trusted only when it names one class (unique in the project or declared in the same file). Class bases are cached per file and parsed again only when the file changes.

Co-authored-by: muutot <[email protected]>
2026-11-14 22:24:48 +08:00
Akbar Husain 07bd8ded5e feat(run): report Zig test runs in the test runner (#3355)
Zig test blocks had run markers but their runs never reached the test runner: no parser read the runners' output. A parser now reads zig test's per-test progress lines, error-return traces and summary, and zig build test's failure blocks and --summary all counts (added to those runs). Results are rooted at the module stem so gutter icons turn green/red per test; Rerun Failed and Run Selected build --test-filter configs for standalone zig test runs. test names are unescaped in run targets so the marker label, --test-filter and the runner's names agree.

Co-authored-by: akbar1214 <[email protected]>
2026-11-14 22:16:35 +08:00
Mr0Bread 4ab85d5f49 feat(editor): resize the vertical editor tab column by dragging its edge (#3365)
With tabs placed left or right, the column could not be resized and was never narrower than 140 px. It now fits its longest name up to a limit (260 px by default); dragging its inner edge, or ←/→ on the focused edge, changes the limit for every editor group and it is remembered. The editor keeps at least 60% of each group.

Co-authored-by: Mr0Bread <[email protected]>
2026-11-14 22:15:22 +08:00
Mansur Akhmadiтаmansur-akhmadi 3c91adfae2 feat(vcs): choose the repository in the Rebase dialog when the project has several (#3351)
Git > Rebase… always rebased the repository selected in the branch widget. With two or more repositories the dialog now has the repository combo: picking one reloads its branches and the label, clears the chosen upstream and --onto base, and Rebase runs in that repository.

Co-authored-by: mansur-akhmadi <[email protected]>
2026-11-14 22:12:34 +08:00
Mansur Akhmadiтаmansur-akhmadi 5cfa6614a1 feat(vcs): choose the repository in the Merge dialog when the project has several (#3332)
Git > Merge… always merged into the repository selected in the branch widget. With two or more repositories the dialog now has the repository combo (as in Pull): picking one reloads its branches and title, clears the chosen branch, and Merge runs in that repository.

Co-authored-by: mansur-akhmadi <[email protected]>
2026-11-14 22:12:04 +08:00
Shohruh Ismoilovтаshohruh-genius e875d97f7c fix(project): show relevant file templates in New menus (#3342)
The New menu offered every language template in every project. It now uses the detected type of the project owning the selected folder for language-specific templates (Rust, Python, Go, PHP, Java/Kotlin); generic and web templates (JavaScript, TypeScript) stay in every project, as in IntelliJ IDEA Ultimate, and From Template still lists the whole catalog. Tomcat Context Descriptor is offered only when the project has a Tomcat run configuration.

Co-authored-by: shohruh-genius <[email protected]>
2026-11-14 22:07:27 +08:00
Mr0Bread 31016ee684 fix(agents): add Copy Link Address to the context menu of links in readable text (#3371)
Right-clicking a link in Agent Task chat (and other readable panes using the shared Copy menu) only offered Copy, which copied the selected label. Links now also get Copy Link Address, as in browsers; Copy keeps copying the selection, and in-page anchors get no link item.

Co-authored-by: Mr0Bread <[email protected]>
2026-11-14 22:05:29 +08:00
l0ng-ai 6a93f1cce6 fix(debugger): show a JVM session's Memory tab from the start; pass lldb-dap's environment for cargo debugging (#3380)
The Memory tab appeared only once the debugger reported its capabilities, so a Java or Kotlin session's tab bar grew a tab after the program started; it is now shown from the start with a placeholder until the program runs. A cargo configuration debugged with lldb-dap passed CodeLLDB's sourceLanguages and an environment object; lldb-dap now gets KEY=VALUE strings like other native programs.
2026-11-14 22:01:34 +08:00
l0ng-ai 2b4e3c79f8 fix(debugger): let a Python program read input while it is debugged (#3374)
debugpy launched the program with console "internalConsole", which gives it no standard input, so input() raised EOFError. It now runs with console "integratedTerminal": debugpy starts it through runInTerminal on the pseudo-terminal the debugger already provides, and what is typed in the debug console reaches it. Output comes from that terminal, so redirectOutput is dropped (it doubled every line). Node under js-debug still lacks input; its terminal launch needs its own fix.
2026-11-14 21:34:21 +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
1220 змінених файлів з 62858 додано та 6013 видалено

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.
+3 -3
Переглянути файл
@@ -98,10 +98,10 @@ jobs:
# Test fixtures write LF files; keep Git from converting them on checkout.
- run: git config --global core.autocrlf false
- run: cargo nextest run --workspace --no-fail-fast
# nextest ran export_bindings, which rewrites src/bindings.ts.
- name: Bindings are up to date
# nextest ran export_bindings, which rewrites src/bindings.ts and the command snapshot.
- name: Bindings and command snapshot are up to date
if: runner.os == 'Linux'
run: git diff --exit-code src/bindings.ts
run: git diff --exit-code src/bindings.ts src-tauri/tests/commands.snap
# The remote development server ships as a static Linux binary (release-build.yml):
# keep that build working.
+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'
+2 -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: |
@@ -181,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
Створено
+54 -44
Переглянути файл
@@ -4144,8 +4144,9 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "ide-agent"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"blake3",
"dunce",
"encoding_rs",
"ide-core",
@@ -4168,7 +4169,7 @@ dependencies = [
[[package]]
name = "ide-analysis"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"ast-grep-core",
"ide-core",
@@ -4182,7 +4183,7 @@ dependencies = [
[[package]]
name = "ide-android"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"ide-core",
"serde",
@@ -4193,7 +4194,7 @@ dependencies = [
[[package]]
name = "ide-build"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"blake3",
"chardetng",
@@ -4211,7 +4212,7 @@ dependencies = [
[[package]]
name = "ide-cli"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"dirs 6.0.0",
"serde",
@@ -4225,17 +4226,18 @@ dependencies = [
[[package]]
name = "ide-clipboard"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"gtk",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"png 0.18.1",
]
[[package]]
name = "ide-core"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"dunce",
"encoding_rs",
@@ -4252,7 +4254,7 @@ dependencies = [
[[package]]
name = "ide-coverage"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"ide-core",
"ide-run",
@@ -4266,8 +4268,9 @@ dependencies = [
[[package]]
name = "ide-dap"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"base64 0.22.1",
"futures",
"ide-core",
"ide-heap",
@@ -4286,13 +4289,15 @@ dependencies = [
"specta",
"tempfile",
"tokio",
"tokio-tungstenite",
"tracing",
"tracing-subscriber",
"windows-sys 0.61.2",
]
[[package]]
name = "ide-db"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"base64 0.22.1",
"bb8",
@@ -4328,7 +4333,7 @@ dependencies = [
[[package]]
name = "ide-docker"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"ide-core",
"serde",
@@ -4341,7 +4346,7 @@ dependencies = [
[[package]]
name = "ide-extension"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"base64 0.22.1",
"blake3",
@@ -4362,7 +4367,7 @@ dependencies = [
[[package]]
name = "ide-fonts"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"objc2-core-foundation",
"objc2-core-text",
@@ -4374,7 +4379,7 @@ dependencies = [
[[package]]
name = "ide-github"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"ide-core",
"ide-vcs",
@@ -4387,7 +4392,7 @@ dependencies = [
[[package]]
name = "ide-heap"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"criterion",
"ide-core",
@@ -4405,7 +4410,7 @@ dependencies = [
[[package]]
name = "ide-host"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"base64 0.22.1",
"blake3",
@@ -4476,7 +4481,7 @@ dependencies = [
[[package]]
name = "ide-http"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"ide-core",
"ide-vfs",
@@ -4495,7 +4500,7 @@ dependencies = [
[[package]]
name = "ide-index"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"blake3",
"criterion",
@@ -4517,7 +4522,7 @@ dependencies = [
[[package]]
name = "ide-jdwp"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"ide-jvm-inspect",
"proptest",
@@ -4529,7 +4534,7 @@ dependencies = [
[[package]]
name = "ide-jsonrpc"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"encoding_rs",
"futures",
@@ -4546,7 +4551,7 @@ dependencies = [
[[package]]
name = "ide-jvm-attach"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"libc",
"tempfile",
@@ -4555,7 +4560,7 @@ dependencies = [
[[package]]
name = "ide-jvm-inspect"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"futures",
"serde",
@@ -4566,13 +4571,14 @@ dependencies = [
[[package]]
name = "ide-language"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"blake3",
"criterion",
"ide-core",
"ide-extension",
"ide-text",
"memchr",
"parking_lot",
"regex-lite",
"serde",
@@ -4623,7 +4629,7 @@ dependencies = [
[[package]]
name = "ide-lsp"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"dunce",
"encoding_rs",
@@ -4651,7 +4657,7 @@ dependencies = [
[[package]]
name = "ide-mcp"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"bytes",
"dunce",
@@ -4670,7 +4676,7 @@ dependencies = [
[[package]]
name = "ide-notebook"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"futures",
"ide-core",
@@ -4693,7 +4699,7 @@ dependencies = [
[[package]]
name = "ide-plugin"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"blake3",
"dunce",
@@ -4716,7 +4722,7 @@ dependencies = [
[[package]]
name = "ide-profiler"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"ide-core",
"ide-jvm-attach",
@@ -4730,7 +4736,7 @@ dependencies = [
[[package]]
name = "ide-remote"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"dunce",
"futures",
@@ -4750,7 +4756,7 @@ dependencies = [
[[package]]
name = "ide-run"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"dunce",
"encoding_rs",
@@ -4774,7 +4780,7 @@ dependencies = [
[[package]]
name = "ide-scaffold"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"ide-core",
"ide-tools",
@@ -4790,9 +4796,10 @@ dependencies = [
[[package]]
name = "ide-sdk"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"dunce",
"flate2",
"ide-core",
"ide-language",
"ide-run",
@@ -4802,6 +4809,7 @@ dependencies = [
"serde",
"serde_json",
"specta",
"tar",
"tempfile",
"toml 0.9.12+spec-1.1.0",
"tracing",
@@ -4811,7 +4819,7 @@ dependencies = [
[[package]]
name = "ide-search"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"criterion",
"encoding_rs",
@@ -4832,7 +4840,7 @@ dependencies = [
[[package]]
name = "ide-spell"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"ide-core",
"parking_lot",
@@ -4845,7 +4853,7 @@ dependencies = [
[[package]]
name = "ide-ssh"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"dirs 6.0.0",
"flate2",
@@ -4870,7 +4878,7 @@ dependencies = [
[[package]]
name = "ide-syntax"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"ide-core",
"ide-language",
@@ -4885,7 +4893,7 @@ dependencies = [
[[package]]
name = "ide-terminal"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"ide-core",
"libc",
@@ -4898,7 +4906,7 @@ dependencies = [
[[package]]
name = "ide-text"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"criterion",
"ide-core",
@@ -4911,7 +4919,7 @@ dependencies = [
[[package]]
name = "ide-tools"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"flate2",
"hex",
@@ -4932,7 +4940,7 @@ dependencies = [
[[package]]
name = "ide-vcs"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"blake3",
"dunce",
@@ -4953,11 +4961,12 @@ dependencies = [
[[package]]
name = "ide-vfs"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"chardetng",
"dunce",
"encoding_rs",
"flate2",
"gix",
"globset",
"ide-core",
@@ -4968,6 +4977,7 @@ dependencies = [
"serde",
"serde_json",
"specta",
"tar",
"tempfile",
"tracing",
"trash",
@@ -4977,7 +4987,7 @@ dependencies = [
[[package]]
name = "ide-web"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"dirs 6.0.0",
"ide-core",
@@ -4993,7 +5003,7 @@ dependencies = [
[[package]]
name = "ide-workspace"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"blake3",
"dunce",
@@ -6594,7 +6604,7 @@ dependencies = [
[[package]]
name = "openidea"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"base64 0.22.1",
"blake3",
+3 -1
Переглянути файл
@@ -5,7 +5,7 @@ members = ["crates/*", "src-tauri"]
exclude = ["sdk", "plugins"]
[workspace.package]
version = "0.1.44"
version = "0.1.45"
edition = "2024"
rust-version = "1.96"
license = "Apache-2.0"
@@ -90,6 +90,8 @@ dunce = "1"
dirs = "6"
memmap2 = "0.9"
zip = { version = "9.2.3", default-features = false, features = ["deflate"] }
flate2 = "1"
tar = "0.4"
# search, index, language
grep-searcher = "0.1"
+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> · <a href="README.tr.md">Türkçe</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).
+79
Переглянути файл
@@ -0,0 +1,79 @@
<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">
Bildiğin IDE, Rust ile yeniden yazıldı.
</p>
<p align="center">
<a href="README.md">English</a> · <a href="README.zh-CN.md">简体中文</a> · Türkçe
</p>
<p align="center">
<a href="https://github.com/l0ng-ai/openidea/releases/latest"><b>İndir</b></a>
&nbsp;·&nbsp;
<a href="https://openidea.sh">Belgeler</a>
&nbsp;·&nbsp;
<a href="#performans-karşılaştırması">Performans</a>
&nbsp;·&nbsp;
<a href="#eklentiler">Eklentiler</a>
&nbsp;·&nbsp;
<a href="#topluluk">Topluluk</a>
</p>
https://github.com/user-attachments/assets/234dd54c-1255-4b3a-b35c-533d78d737c0
OpenIDEA, IntelliJ IDEA'nın çalışma biçimini (kısayol tuşlarını, araç pencerelerini,
her şeyi arama alışkanlığını) çekirdeği Rust ile yazılmış yerel bir uygulamaya taşır.
Editör, kod analizi, hata ayıklayıcı (debugger), Git, terminal, veritabanı ve HTTP istemcileri;
60'tan fazla dil için. macOS, Windows ve Linux sürümleri var.
## Performans karşılaştırması
[freqtrade](https://github.com/freqtrade/freqtrade) projesini (Python, ~770 dosya)
önbellekler hazırken açma, 5 çalıştırmanın ortancası:
| | OpenIDEA | IntelliJ IDEA 2026.2.3 |
| ---------------------------------- | --------------------- | ---------------------- |
| Projeyle pencerenin açılması | **0,9 sn** | 3,1 sn |
| Hazır (CPU sakinleşti) | **1,1 sn** | 13,2 sn |
| Bellek (USS, tüm işlemler) | **169 MB** | 2011 MB |
| Hazır olana kadar CPU süresi | **2,4 sn** | 47,9 sn |
| Boştayken CPU | Bir çekirdeğin **%4**'ü | Bir çekirdeğin %7'si |
| Kapanma | **0,3 sn** | 5,9 sn |
`main` dalından derlenmiş bir OpenIDEA sürümü ve varsayılan ayar ve eklentilerle IDEA;
Windows 11, 32 çekirdekli AMD, 62 GB RAM.
IDEA, Python'u daha derinlemesine analiz eder; yani "hazır" iki tarafta aynı işi ifade etmez.
İkisi de dışarıdan, aynı yöntemle ölçüldü: kendi makinende ve projelerinde denemek için
[`perf/compare`](perf/compare) klasörüne bak.
## Eklentiler
Eklentiler; eylemler, kod tamamlama, hızlı düzeltmeler, araç pencereleri, özel editörler,
temalar, dosya simgeleri ve diller ekler. Korumalı (sandbox) WebAssembly bileşenleri olarak çalışırlar;
temalar, simge temaları ve diller için hiç kod yazmak gerekmez.
Eklentileri Ayarlar › Eklentiler (Settings › Plugins) bölümünden kur ya da
`openidea-plugin new` ile kendininkini başlat. Ayrıntılar için [OpenIDEA eklentisi yazmak](https://openidea.sh/en/plugins) sayfasına ve
[resmi eklentilere](plugins/official) bak. Eklenti API'si hâlâ önizleme aşamasında.
## Topluluk
- [Discord](https://discord.gg/2Zy4yn7gP): sorular, geri bildirim ve sürüm haberleri.
- WeChat grubu, katılmak için kodu okut:
<img src=".github/assets/wechat-group.png" alt="WeChat grubu QR kodu" width="200">
- Katkıda bulunmak için: [CONTRIBUTING.md](CONTRIBUTING.md) ve [katkıcı belgeleri](https://openidea.sh/en/contribute/setup).
## Lisans
[Apache 2.0](LICENSE). Pakete dahil üçüncü taraf varlıklar [`THIRD_PARTY_LICENSES.md`](THIRD_PARTY_LICENSES.md) dosyasında listelenir.
<sub>JetBrains ile bağlantılı değildir. IntelliJ IDEA, JetBrains s.r.o.'nun ticari markasıdır.</sub>
+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>
+4
Переглянути файл
@@ -7,6 +7,7 @@ license.workspace = true
[dependencies]
encoding_rs.workspace = true
blake3.workspace = true
ide-vfs.workspace = true
ide-core.workspace = true
ide-mcp.workspace = true
@@ -24,3 +25,6 @@ dunce.workspace = true
uuid.workspace = true
tempfile.workspace = true
which = "8"
[dev-dependencies]
tokio = { workspace = true, features = ["test-util"] }
+55 -28
Переглянути файл
@@ -3,6 +3,7 @@
//! `initialize` alone).
use super::{Conn, Dialect};
use crate::catalogs::Discovered;
use crate::config;
use crate::model::AgentConfigOption;
use crate::provider::ProviderSpec;
@@ -15,22 +16,41 @@ use std::time::Duration;
const DISCOVER_TIMEOUT: Duration = if cfg!(test) { Duration::from_secs(3) } else { Duration::from_secs(120) };
/// Opens `session/new`, reads `configOptions`, and records per-model `thought_level` choices.
pub(crate) async fn run(shared: &Arc<Shared>, spec: &ProviderSpec) -> Result<(), String> {
pub(crate) async fn run(shared: &Arc<Shared>, spec: &ProviderSpec) -> Result<Discovered, String> {
let prefer_stored = crate::provider::prefer_stored_copilot_auth();
match run_once(shared, spec, prefer_stored).await {
Err(err)
if !prefer_stored
&& crate::provider::is_copilot_provider(&spec.id)
&& crate::provider::has_unconfigured_copilot_ambient_token(&spec.env)
&& super::auth_required(0, &err) =>
{
let retry = run_once(shared, spec, true).await;
if retry.is_ok() {
crate::provider::mark_prefer_stored_copilot_auth();
}
retry
}
other => other,
}
}
async fn run_once(shared: &Arc<Shared>, spec: &ProviderSpec, strip_ambient: bool) -> Result<Discovered, String> {
let root = shared.paths.project_root.clone();
let launch = spec.clone();
let launch = tokio::task::spawn_blocking({
let root = root.clone();
move || launch.resolve_for_launch(&root)
move || launch.resolve_for_launch_with_copilot_env(&root, strip_ambient)
})
.await
.map_err(|e| e.to_string())?;
let cwd = dunce::canonicalize(&root).unwrap_or(root);
let stderr: Arc<Mutex<Vec<String>>> = Arc::default();
let lines = stderr.clone();
let on_stderr: Box<dyn Fn(String) + Send + Sync> = Box::new(move |l| lines.lock().push(l));
let client = Dialect::wrap(Arc::new(ide_jsonrpc::NoopHandler));
let updates = Arc::new(DiscoveryUpdates { provider: spec.id.clone(), commands: Mutex::new(None) });
let client = Dialect::wrap(updates.clone());
let on_stderr: Box<dyn Fn(String) + Send + Sync> = Box::new(|_| {});
let peer = (shared.connector)(&launch, &cwd, client.clone(), on_stderr)
.map_err(|e| format!("Couldn't start {} (`{}`): {e}", launch.name, launch.command))?;
let _stop = crate::catalogs::KillPeer(peer.clone());
let conn = Conn::new(peer.clone(), client);
let work = async {
conn.initialize(&client_info()).await.map_err(|e| e.to_string())?;
@@ -51,34 +71,41 @@ pub(crate) async fn run(shared: &Arc<Shared>, spec: &ProviderSpec) -> Result<(),
(Some(model), Some(effort)) => model.current.clone().map(|m| vec![(m, effort)]).unwrap_or_default(),
_ => Vec::new(),
};
let option_ids: Vec<_> = options.iter().map(|o| o.id.as_str()).collect();
tracing::info!(
"agent {}: settings discovery got {} config option(s) {:?}, {} model effort list(s)",
launch.id,
options.len(),
option_ids,
levels.len()
);
shared.remember_discovered(&launch.id, &options, &[], &levels);
Ok::<(), String>(())
let commands = updates
.commands
.lock()
.clone()
.or_else(|| new.get("availableCommands").map(|_| config::commands_of(&new, &launch.id)));
Ok::<_, String>(Discovered { config: options, commands, levels: levels.into_iter().collect() })
};
let result = tokio::time::timeout(DISCOVER_TIMEOUT, work).await;
peer.kill();
match result {
Ok(Ok(())) => Ok(()),
Ok(Err(e)) => Err(match stderr.lock().last() {
Some(line) => format!("{e}: {line}"),
None => e,
}),
Err(_) => {
let tail = stderr.lock().join("\n");
if tail.is_empty() {
Err("the agent did not answer in time".into())
} else {
Err(format!("the agent did not answer in time:\n{tail}"))
}
Ok(result) => result,
Err(_) => Err("the agent did not answer in time".into()),
}
}
#[derive(Default)]
struct DiscoveryUpdates {
provider: String,
commands: Mutex<Option<Vec<crate::AgentCommand>>>,
}
impl ide_jsonrpc::Handler for DiscoveryUpdates {
fn on_notification(&self, method: &str, params: Value) {
if method == "session/update"
&& let Some(update) = params.get("update")
&& update["sessionUpdate"] == "available_commands_update"
{
// Commands are protocol data, not stderr (which may contain credentials).
*self.commands.lock() = Some(config::commands_of(update, &self.provider));
}
}
fn on_request(&self, method: &str, _: Value) -> Result<Value, (i64, String)> {
Err((ide_jsonrpc::codes::METHOD_NOT_FOUND, format!("unsupported discovery request {method}")))
}
}
fn is_thought_level(o: &AgentConfigOption) -> bool {
+1
Переглянути файл
@@ -108,6 +108,7 @@ fn manager(dir: &Path, agent: Arc<FakeV2Agent>) -> AgentManager {
custom_agents_dir: None,
home_dir: None,
env_secrets: None,
catalogs: Arc::default(),
},
tokio::runtime::Handle::current(),
connector,
+1
Переглянути файл
@@ -155,6 +155,7 @@ async fn discovery_reads_effort_and_fast_from_session_new_then_stops_the_agent()
custom_agents_dir: None,
home_dir: None,
env_secrets: None,
catalogs: Arc::default(),
},
tokio::runtime::Handle::current(),
connector,
+427
Переглянути файл
@@ -0,0 +1,427 @@
//! Model availability for new tasks. Sessions own selections; discovery owns catalogs.
use crate::{
AgentCommand, AgentConfigOption, AgentEvent, AgentModelCatalog, Known, Shared, acp, config, native, provider,
};
use ide_core::{IdeError, IdeResult};
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashMap};
use std::path::PathBuf;
use std::sync::{Arc, Weak};
use std::time::Duration;
use tokio::sync::watch;
use tokio::time::Instant;
const FRESH_FOR: Duration = Duration::from_secs(15 * 60);
const COOLDOWN: Duration = Duration::from_secs(30);
type Store = Arc<Mutex<HashMap<String, Known>>>;
type Key = (PathBuf, PathBuf, String);
/// Backend-wide single-flight model lookups, scoped to project and effective launch configuration.
#[derive(Default)]
pub struct AgentCatalogs {
state: Mutex<State>,
#[cfg(test)]
clock_ms: std::sync::atomic::AtomicU64,
}
#[derive(Default)]
struct State {
stores: HashMap<PathBuf, Store>,
entries: HashMap<Key, Entry>,
subscribers: Vec<Weak<Shared>>,
generation: u64,
}
struct Entry {
// Launch credentials remain only in memory, never serialized or logged.
context: LaunchContext,
generation: u64,
completed: Option<Instant>,
successful: Option<Instant>,
retry_at_ms: Option<f64>,
error: Option<String>,
flight: Option<watch::Receiver<bool>>,
abort: Option<tokio::task::AbortHandle>,
catalog: Option<CatalogData>,
}
#[derive(Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CatalogData {
pub config: Vec<AgentConfigOption>,
pub thought_levels: BTreeMap<String, Vec<AgentConfigOption>>,
pub refreshed_at_ms: Option<f64>,
}
#[derive(Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct SavedCatalog {
/// An opaque cryptographic digest, never raw launch arguments or credentials.
fingerprint: String,
#[serde(flatten)]
catalog: CatalogData,
}
#[derive(PartialEq)]
struct LaunchContext {
spec: provider::ProviderSpec,
executable: PathBuf,
modified: Option<std::time::SystemTime>,
len: Option<u64>,
}
impl LaunchContext {
fn new(spec: provider::ProviderSpec, root: &std::path::Path) -> Self {
let resolved = spec.clone().resolve_for_launch(root);
let executable = dunce::canonicalize(&resolved.command).unwrap_or_else(|_| PathBuf::from(&resolved.command));
let metadata = std::fs::metadata(&executable).ok();
Self {
spec,
executable,
modified: metadata.as_ref().and_then(|m| m.modified().ok()),
len: metadata.as_ref().map(std::fs::Metadata::len),
}
}
fn fingerprint(&self) -> String {
let mut hash = blake3::Hasher::new();
fn field(hash: &mut blake3::Hasher, value: &[u8]) {
hash.update(&(value.len() as u64).to_le_bytes());
hash.update(value);
}
field(&mut hash, b"openidea-model-catalog-v1");
field(
&mut hash,
match self.spec.driver {
provider::Driver::Acp => b"acp",
provider::Driver::Codex => b"codex",
provider::Driver::Claude => b"claude",
},
);
field(&mut hash, self.spec.command.as_bytes());
hash.update(&(self.spec.args.len() as u64).to_le_bytes());
for arg in &self.spec.args {
field(&mut hash, arg.as_bytes());
}
hash.update(&(self.spec.env.len() as u64).to_le_bytes());
for (name, value) in &self.spec.env {
field(&mut hash, name.as_bytes());
field(&mut hash, value.as_bytes());
}
field(&mut hash, self.executable.to_string_lossy().as_bytes());
if let Some(modified) = self.modified.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) {
hash.update(&modified.as_nanos().to_le_bytes());
}
if let Some(len) = self.len {
hash.update(&len.to_le_bytes());
}
hash.finalize().to_hex().to_string()
}
}
pub(crate) struct Discovered {
pub config: Vec<AgentConfigOption>,
pub commands: Option<Vec<AgentCommand>>,
pub levels: BTreeMap<String, Vec<AgentConfigOption>>,
}
pub(crate) struct KillPeer(pub ide_jsonrpc::Peer);
impl Drop for KillPeer {
fn drop(&mut self) {
self.0.kill();
}
}
impl AgentCatalogs {
fn now(&self) -> Instant {
let now = Instant::now();
#[cfg(test)]
let now = now + Duration::from_millis(self.clock_ms.load(std::sync::atomic::Ordering::Relaxed));
now
}
fn epoch_ms(&self) -> f64 {
let now = crate::now_ms();
#[cfg(test)]
let now = now + self.clock_ms.load(std::sync::atomic::Ordering::Relaxed) as f64;
now
}
pub(crate) fn store(&self, path: &std::path::Path) -> Store {
self.state
.lock()
.stores
.entry(path.to_path_buf())
.or_insert_with(|| {
Arc::new(Mutex::new(
std::fs::read(path).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default(),
))
})
.clone()
}
pub(crate) fn subscribe(&self, shared: &Arc<Shared>) {
let mut state = self.state.lock();
state.subscribers.retain(|s| s.strong_count() > 0);
state.subscribers.push(Arc::downgrade(shared));
}
fn key(shared: &Shared, provider: &str) -> Key {
(shared.paths.models_file.clone(), shared.paths.project_root.clone(), provider.to_owned())
}
fn fallback(shared: &Shared, provider: &str) -> CatalogData {
let cache = shared.known.lock();
let Some(known) = cache.get(provider) else { return CatalogData::default() };
known.catalogs.get(&shared.root_key()).map(|saved| saved.catalog.clone()).unwrap_or_else(|| CatalogData {
config: known.config.clone(),
thought_levels: known.thought_levels.clone(),
refreshed_at_ms: None,
})
}
pub(crate) fn cached(&self, shared: &Shared, provider: &str) -> CatalogData {
self.state
.lock()
.entries
.get(&Self::key(shared, provider))
.and_then(|e| e.catalog.clone())
.unwrap_or_else(|| Self::fallback(shared, provider))
}
fn snapshot(&self, shared: &Shared, provider: &str, entry: &Entry) -> AgentModelCatalog {
let catalog = entry.catalog.clone().unwrap_or_else(|| Self::fallback(shared, provider));
AgentModelCatalog {
provider: provider.to_owned(),
config: shared.config_for_new_task(catalog.config, provider),
thought_levels: catalog.thought_levels,
refreshing: entry.flight.is_some(),
refreshed_at_ms: catalog.refreshed_at_ms,
retry_at_ms: entry
.retry_at_ms
.filter(|_| entry.completed.is_some_and(|at| self.now().duration_since(at) < COOLDOWN)),
error: entry.error.clone(),
}
}
/// Start or join a lookup. No queued refresh can outlive the request that asked for it.
pub(crate) fn request(
self: &Arc<Self>,
shared: &Arc<Shared>,
spec: provider::ProviderSpec,
force: bool,
) -> (AgentModelCatalog, Option<watch::Receiver<bool>>) {
let key = Self::key(shared, &spec.id);
let context = LaunchContext::new(spec.clone(), &shared.paths.project_root);
let now = self.now();
let mut state = self.state.lock();
let changed = state.entries.get(&key).is_none_or(|e| e.context != context);
if changed {
if let Some(abort) = state.entries.get_mut(&key).and_then(|entry| entry.abort.take()) {
abort.abort();
}
state.generation += 1;
let generation = state.generation;
let saved =
shared.known.lock().get(&spec.id).and_then(|known| known.catalogs.get(&shared.root_key())).cloned();
let matching = saved.as_ref().is_some_and(|saved| saved.fingerprint == context.fingerprint());
let age = saved
.as_ref()
.filter(|_| matching)
.and_then(|saved| saved.catalog.refreshed_at_ms)
.map(|at| self.epoch_ms() - at)
.filter(|age| *age >= 0.0 && *age < FRESH_FOR.as_millis() as f64)
.map(|age| Duration::from_secs_f64(age / 1000.0));
let successful = age.and_then(|age| now.checked_sub(age));
let completed = age.filter(|age| *age < COOLDOWN).and_then(|age| now.checked_sub(age));
let retry_at_ms = completed
.and_then(|_| saved.as_ref().and_then(|s| s.catalog.refreshed_at_ms))
.map(|at| at + COOLDOWN.as_millis() as f64);
let catalog = saved.map(|saved| {
let mut catalog = saved.catalog;
if !matching {
catalog.refreshed_at_ms = None;
}
catalog
});
state.entries.insert(
key.clone(),
Entry {
context,
generation,
completed,
successful,
retry_at_ms,
catalog,
error: None,
flight: None,
abort: None,
},
);
}
let entry = state.entries.get_mut(&key).expect("catalog entry");
let cooling = entry.completed.is_some_and(|at| now.duration_since(at) < COOLDOWN);
let fresh = entry.successful.is_some_and(|at| now.duration_since(at) < FRESH_FOR);
let mut start = None;
if entry.flight.is_none() && !cooling && (force || !fresh) {
let (done, ready) = watch::channel(false);
entry.flight = Some(ready);
entry.error = None;
entry.retry_at_ms = None;
start = Some((entry.generation, done));
}
let snapshot = self.snapshot(shared, &spec.id, entry);
let flight = entry.flight.clone();
drop(state);
if let Some((generation, done)) = start {
self.broadcast(shared, &snapshot);
let coordinator = self.clone();
let shared = shared.clone();
let flight_key = key.clone();
let task = shared.runtime.clone().spawn(async move {
let result = discover(&shared, spec).await;
coordinator.complete(&shared, flight_key, generation, result);
let _ = done.send(true);
});
// A subscriber can re-enter and change configuration during the start event.
let mut state = self.state.lock();
if let Some(entry) = state.entries.get_mut(&key).filter(|entry| entry.generation == generation) {
if entry.flight.is_some() {
entry.abort = Some(task.abort_handle());
}
} else {
task.abort();
}
}
(snapshot, flight)
}
fn complete(&self, shared: &Shared, key: Key, generation: u64, result: Result<Discovered, String>) {
let current = provider::load(shared.paths.custom_agents_dir.as_deref(), shared.env_secrets())
.into_iter()
.find(|spec| spec.id == key.2)
.map(|spec| LaunchContext::new(spec, &shared.paths.project_root));
let mut state = self.state.lock();
let Some(entry) = state.entries.get_mut(&key).filter(|e| e.generation == generation) else { return };
// Configuration may have changed without another refresh request arriving.
if current.as_ref() != Some(&entry.context) {
entry.flight = None;
entry.abort = None;
entry.completed = None;
entry.retry_at_ms = None;
entry.error = None;
let snapshot = self.snapshot(shared, &key.2, entry);
state.entries.remove(&key);
drop(state);
self.broadcast(shared, &snapshot);
return;
}
let now = self.now();
entry.completed = Some(now);
entry.retry_at_ms = Some(self.epoch_ms() + COOLDOWN.as_millis() as f64);
entry.flight = None;
entry.abort = None;
let success = match result {
Ok(found) => {
let mut cache = shared.known.lock();
let known = cache.entry(key.2.clone()).or_default();
let catalog = CatalogData {
config: found.config,
thought_levels: found.levels,
refreshed_at_ms: Some(self.epoch_ms()),
};
known.config = catalog.config.clone();
known.thought_levels = catalog.thought_levels.clone();
if let Some(commands) = found.commands {
known.commands.insert(shared.root_key(), commands);
}
known.authoritative = true;
known.refreshed_at_ms = catalog.refreshed_at_ms;
known.catalogs.insert(
shared.root_key(),
SavedCatalog { fingerprint: entry.context.fingerprint(), catalog: catalog.clone() },
);
entry.catalog = Some(catalog);
shared.persist_known(&cache);
entry.successful = Some(now);
entry.error = None;
true
}
Err(error) => {
entry.error = Some(error);
false
}
};
let snapshot = self.snapshot(shared, &key.2, entry);
drop(state);
self.broadcast(shared, &snapshot);
if success {
for subscriber in self.subscribers(shared) {
subscriber.emit(AgentEvent::Providers);
}
}
}
fn subscribers(&self, shared: &Shared) -> Vec<Arc<Shared>> {
self.state
.lock()
.subscribers
.iter()
.filter_map(Weak::upgrade)
.filter(|s| {
s.paths.models_file == shared.paths.models_file && s.paths.project_root == shared.paths.project_root
})
.collect()
}
fn broadcast(&self, shared: &Shared, catalog: &AgentModelCatalog) {
for subscriber in self.subscribers(shared) {
subscriber.emit(AgentEvent::ModelCatalog { catalog: catalog.clone() });
}
}
}
impl crate::AgentManager {
/// Return cached choices immediately and schedule discovery when eligible.
pub fn refresh_models(&self, provider: &str, force: bool) -> IdeResult<AgentModelCatalog> {
// Settings and environment secrets can change while this window is open.
*self.shared.providers.write() =
provider::load(self.shared.paths.custom_agents_dir.as_deref(), self.shared.env_secrets());
let spec = self.shared.spec(provider).ok_or_else(|| IdeError::invalid(format!("unknown agent {provider}")))?;
Ok(self.shared.paths.catalogs.request(&self.shared, spec, force).0)
}
}
async fn discover(shared: &Arc<Shared>, spec: provider::ProviderSpec) -> Result<Discovered, String> {
match spec.driver {
provider::Driver::Acp => acp::discover::run(shared, &spec).await,
provider::Driver::Codex => native::codex::discover(spec, shared.paths.project_root.clone()).await,
provider::Driver::Claude => {
let found = native::claude::discover(spec.clone(), shared.paths.project_root.clone()).await?;
Ok(Discovered {
commands: Some(config::commands_of(
&serde_json::json!({ "availableCommands": found.commands }),
&spec.id,
)),
config: config::options_of(&serde_json::json!({ "configOptions": found.options })),
levels: found
.efforts
.into_iter()
.map(|(model, option)| {
let levels = option
.map(|o| config::options_of(&serde_json::json!({ "configOptions": [o] })))
.unwrap_or_default();
(model, levels)
})
.collect(),
})
}
}
}
#[cfg(test)]
mod tests;
+501
Переглянути файл
@@ -0,0 +1,501 @@
use super::*;
use crate::{AgentManager, AgentPaths, Connector};
use ide_jsonrpc::{Framing, Peer};
use serde_json::{Value, json};
use std::path::Path;
use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::sync::{mpsc, oneshot};
const PROVIDER: &str = "catalog-test";
#[derive(Debug)]
struct Pending {
params: Value,
reply: oneshot::Sender<Result<Value, String>>,
}
struct Harness {
connector: Connector,
pending: mpsc::UnboundedReceiver<Pending>,
launches: Arc<AtomicUsize>,
live: Arc<AtomicUsize>,
}
struct Live(Arc<AtomicUsize>);
impl Drop for Live {
fn drop(&mut self) {
self.0.fetch_sub(1, Ordering::SeqCst);
}
}
impl Harness {
fn new() -> Self {
let (pending_tx, pending) = mpsc::unbounded_channel();
let launches = Arc::new(AtomicUsize::new(0));
let live = Arc::new(AtomicUsize::new(0));
let connector: Connector = Arc::new({
let launches = launches.clone();
let live = live.clone();
move |_, _, client, _| {
launches.fetch_add(1, Ordering::SeqCst);
live.fetch_add(1, Ordering::SeqCst);
let guard = Live(live.clone());
let pending = pending_tx.clone();
let (client_io, server_io) = tokio::io::duplex(1 << 16);
let (read, mut write) = tokio::io::split(server_io);
tokio::spawn(async move {
let _guard = guard;
let mut lines = BufReader::new(read).lines();
while let Some(line) = lines.next_line().await.unwrap() {
let request: Value = serde_json::from_str(&line).unwrap();
let id = &request["id"];
if id.is_null() {
continue;
}
let response = match request["method"].as_str().unwrap() {
"initialize" => json!({ "id": id, "jsonrpc": "2.0", "result": {
"protocolVersion": 1, "agentCapabilities": {}, "authMethods": [] } }),
"session/new" => {
let (reply, result) = oneshot::channel();
pending.send(Pending { params: request["params"].clone(), reply }).unwrap();
let result = tokio::select! {
result = result => result.unwrap(),
_ = lines.next_line() => break,
};
match result {
Ok(result) => json!({ "id": id, "jsonrpc": "2.0", "result": result }),
Err(error) => {
json!({ "id": id, "jsonrpc": "2.0", "error": { "code": -32000, "message": error } })
}
}
}
method => panic!("discovery must not call {method}"),
};
if write.write_all(format!("{response}\n").as_bytes()).await.is_err() {
break;
}
}
});
let (read, write) = tokio::io::split(client_io);
Ok(Peer::connect_framed(read, write, client, Framing::Lines))
}
});
Self { connector, pending, launches, live }
}
async fn next(&mut self) -> Pending {
tokio::time::timeout(Duration::from_secs(5), self.pending.recv()).await.unwrap().unwrap()
}
}
fn configure(root: &Path, version: &str) {
std::fs::create_dir_all(root.join("agents")).unwrap();
let executable = root.join("catalog-test-bin");
if !executable.exists() {
std::fs::write(&executable, "first executable").unwrap();
}
std::fs::write(
root.join("agents/catalog-test.toml"),
format!(
"name = \"Catalog Test\"\ncommand = {}\n[env]\nTOKEN = \"secret-{version}\"\n",
serde_json::to_string(&executable.to_string_lossy()).unwrap()
),
)
.unwrap();
}
fn make_manager(root: &Path, catalogs: Arc<AgentCatalogs>, harness: &Harness) -> AgentManager {
make_manager_at(root, &root.join("models.json"), catalogs, harness)
}
fn make_manager_at(root: &Path, models_file: &Path, catalogs: Arc<AgentCatalogs>, harness: &Harness) -> AgentManager {
AgentManager::with_connector(
AgentPaths {
project_root: root.to_path_buf(),
store_dir: root.join("store"),
worktrees_dir: root.join("worktrees"),
models_file: models_file.to_path_buf(),
catalogs,
custom_agents_dir: Some(root.join("agents")),
home_dir: Some(root.to_path_buf()),
env_secrets: None,
},
tokio::runtime::Handle::current(),
harness.connector.clone(),
)
}
fn events(manager: &AgentManager) -> mpsc::UnboundedReceiver<AgentEvent> {
let (tx, rx) = mpsc::unbounded_channel();
manager.subscribe(Arc::new(move |event| {
let _ = tx.send(event);
}));
rx
}
async fn completed(events: &mut mpsc::UnboundedReceiver<AgentEvent>) -> AgentModelCatalog {
tokio::time::timeout(Duration::from_secs(5), async {
loop {
if let AgentEvent::ModelCatalog { catalog } = events.recv().await.unwrap()
&& !catalog.refreshing
{
return catalog;
}
}
})
.await
.unwrap()
}
fn response(models: &[&str], effort: &[&str]) -> Value {
json!({ "sessionId": "discovery-only", "configOptions": [
{ "id": "model", "type": "select", "category": "model", "currentValue": models.first(),
"options": models.iter().map(|m| json!({ "value": m, "name": m })).collect::<Vec<_>>() },
{ "id": "effort", "type": "select", "category": "thought_level", "currentValue": effort.first(),
"options": effort.iter().map(|e| json!({ "value": e })).collect::<Vec<_>>() }
] })
}
fn model_values(catalog: &AgentModelCatalog) -> Vec<&str> {
catalog
.config
.iter()
.filter(|o| o.category.as_deref() == Some("model"))
.flat_map(|o| o.values.iter().map(|v| v.value.as_str()))
.collect()
}
async fn stopped(harness: &Harness) {
tokio::time::timeout(Duration::from_secs(5), async {
while harness.live.load(Ordering::SeqCst) != 0 {
tokio::task::yield_now().await;
}
})
.await
.unwrap();
}
fn advance(catalogs: &AgentCatalogs, seconds: u64) {
catalogs.clock_ms.fetch_add(seconds * 1000, Ordering::Relaxed);
}
#[tokio::test]
async fn successful_refresh_replaces_models_and_efforts_and_old_sessions_cannot_revert_it() {
let tmp = tempfile::tempdir().unwrap();
configure(tmp.path(), "one");
let catalogs = Arc::new(AgentCatalogs::default());
let mut harness = Harness::new();
let manager = make_manager(tmp.path(), catalogs.clone(), &harness);
let mut events = events(&manager);
let old = config::options_of(&response(&["old", "retained"], &["low", "high"]));
manager.shared.remember(PROVIDER, Some(&old), None, Some("old"));
let cached = manager.refresh_models(PROVIDER, false).unwrap();
assert!(cached.refreshing);
assert_eq!(model_values(&cached), ["old", "retained"]);
assert!(cached.refreshed_at_ms.is_none(), "session reports are not fresh discovery");
let pending = harness.next().await;
assert_eq!(pending.params["mcpServers"], json!([]));
pending.reply.send(Ok(response(&["retained", "new"], &["medium"]))).unwrap();
let fresh = completed(&mut events).await;
assert_eq!(model_values(&fresh), ["retained", "new"]);
assert_eq!(fresh.thought_levels.keys().map(String::as_str).collect::<Vec<_>>(), ["new", "retained"]);
assert_eq!(fresh.thought_levels["retained"][0].values[0].value, "medium");
assert!(fresh.refreshed_at_ms.is_some() && fresh.retry_at_ms.is_some());
manager.shared.remember(PROVIDER, Some(&old), None, Some("old"));
assert_eq!(manager.refresh_models(PROVIDER, false).unwrap().config, fresh.config);
let provider = manager.providers().into_iter().find(|p| p.id == PROVIDER).unwrap();
assert_eq!(provider.config, fresh.config);
let persisted = std::fs::read_to_string(tmp.path().join("models.json")).unwrap();
assert!(!persisted.contains("secret-one") && !persisted.contains("TOKEN"));
let restored = make_manager(tmp.path(), Arc::default(), &harness);
let restored_snapshot = restored.refresh_models(PROVIDER, false).unwrap();
assert!(!restored_snapshot.refreshing, "matching persisted launch context remains fresh");
assert_eq!(restored_snapshot.refreshed_at_ms, fresh.refreshed_at_ms);
let provider = restored.providers().into_iter().find(|p| p.id == PROVIDER).unwrap();
assert_eq!(provider.config, fresh.config);
restored.shared.remember(PROVIDER, Some(&old), None, Some("old"));
assert_eq!(restored.shared.known_config(PROVIDER), fresh.config);
assert_eq!(restored.shared.known.lock()[PROVIDER].refreshed_at_ms, fresh.refreshed_at_ms);
assert!(!tmp.path().join("store").exists(), "refresh never creates tasks");
}
#[tokio::test]
async fn legacy_cache_loads_without_claiming_authoritative_freshness() {
let tmp = tempfile::tempdir().unwrap();
configure(tmp.path(), "one");
let options = config::options_of(&response(&["legacy"], &["low"]));
std::fs::write(
tmp.path().join("models.json"),
serde_json::to_vec(&json!({
(PROVIDER): { "config": options, "thoughtLevels": {}, "projectCommands": {} }
}))
.unwrap(),
)
.unwrap();
let mut harness = Harness::new();
let manager = make_manager(tmp.path(), Arc::default(), &harness);
let mut events = events(&manager);
let cached = manager.refresh_models(PROVIDER, false).unwrap();
assert_eq!(model_values(&cached), ["legacy"]);
assert!(cached.refreshing && cached.refreshed_at_ms.is_none());
harness.next().await.reply.send(Err("Sign in required".into())).unwrap();
let failed = completed(&mut events).await;
assert_eq!(failed.config, cached.config);
assert!(failed.error.as_deref().unwrap().contains("Sign in required"));
}
#[tokio::test]
async fn concurrent_windows_commands_and_manual_requests_share_one_flight_and_cooldown() {
let tmp = tempfile::tempdir().unwrap();
configure(tmp.path(), "one");
let catalogs = Arc::new(AgentCatalogs::default());
let mut harness = Harness::new();
let first = Arc::new(make_manager(tmp.path(), catalogs.clone(), &harness));
let second = make_manager(tmp.path(), catalogs.clone(), &harness);
let mut first_events = events(&first);
let mut second_events = events(&second);
assert!(first.refresh_models(PROVIDER, false).unwrap().refreshing);
let pending = harness.next().await;
for _ in 0..10 {
assert!(second.refresh_models(PROVIDER, true).unwrap().refreshing);
assert!(first.refresh_models(PROVIDER, true).unwrap().refreshing);
}
let commands = tokio::spawn({
let first = first.clone();
async move { first.commands(PROVIDER, None).await }
});
tokio::task::yield_now().await;
assert_eq!(harness.launches.load(Ordering::SeqCst), 1);
pending.reply.send(Ok(response(&["one"], &["low"]))).unwrap();
let done = completed(&mut first_events).await;
assert_eq!(completed(&mut second_events).await, done);
commands.await.unwrap().unwrap();
for force in [false, true, false, true] {
assert!(!second.refresh_models(PROVIDER, force).unwrap().refreshing);
}
advance(&catalogs, 29);
assert!(!first.refresh_models(PROVIDER, true).unwrap().refreshing);
advance(&catalogs, 1);
assert!(!first.refresh_models(PROVIDER, false).unwrap().refreshing, "automatic refresh remains fresh");
assert!(first.refresh_models(PROVIDER, true).unwrap().refreshing);
harness.next().await.reply.send(Ok(response(&["two"], &["high"]))).unwrap();
let newer = completed(&mut first_events).await;
assert_eq!(model_values(&newer), ["two"]);
assert_eq!(completed(&mut second_events).await, newer);
advance(&catalogs, 15 * 60 - 1);
assert!(!second.refresh_models(PROVIDER, false).unwrap().refreshing);
advance(&catalogs, 1);
assert!(second.refresh_models(PROVIDER, false).unwrap().refreshing);
harness.next().await.reply.send(Ok(response(&["three"], &["low"]))).unwrap();
completed(&mut first_events).await;
assert_eq!(harness.launches.load(Ordering::SeqCst), 3);
}
#[tokio::test]
async fn failure_keeps_successful_choices_and_throttles_manual_and_auto_retries() {
let tmp = tempfile::tempdir().unwrap();
configure(tmp.path(), "one");
let catalogs = Arc::new(AgentCatalogs::default());
let mut harness = Harness::new();
let manager = make_manager(tmp.path(), catalogs.clone(), &harness);
let mut events = events(&manager);
manager.refresh_models(PROVIDER, true).unwrap();
harness.next().await.reply.send(Ok(response(&["cached"], &["low"]))).unwrap();
let success = completed(&mut events).await;
let disk = std::fs::read(tmp.path().join("models.json")).unwrap();
advance(&catalogs, 15 * 60);
manager.refresh_models(PROVIDER, false).unwrap();
harness.next().await.reply.send(Err("Authentication expired".into())).unwrap();
let failed = completed(&mut events).await;
assert_eq!(failed.config, success.config);
assert_eq!(failed.thought_levels, success.thought_levels);
assert_eq!(failed.refreshed_at_ms, success.refreshed_at_ms);
assert!(failed.error.as_deref().unwrap().contains("Authentication expired"));
assert_eq!(std::fs::read(tmp.path().join("models.json")).unwrap(), disk);
assert!(!manager.refresh_models(PROVIDER, false).unwrap().refreshing);
assert!(!manager.refresh_models(PROVIDER, true).unwrap().refreshing);
advance(&catalogs, 29);
assert!(!manager.refresh_models(PROVIDER, true).unwrap().refreshing);
advance(&catalogs, 1);
assert!(manager.refresh_models(PROVIDER, false).unwrap().refreshing);
harness.next().await.reply.send(Ok(response(&["recovered"], &["high"]))).unwrap();
let recovered = completed(&mut events).await;
assert_eq!(model_values(&recovered), ["recovered"]);
assert!(recovered.error.is_none());
}
#[tokio::test]
async fn timeout_cleans_up_and_enters_the_same_failure_cooldown() {
let tmp = tempfile::tempdir().unwrap();
configure(tmp.path(), "one");
let catalogs = Arc::new(AgentCatalogs::default());
let mut harness = Harness::new();
let manager = make_manager(tmp.path(), catalogs.clone(), &harness);
let mut events = events(&manager);
manager.refresh_models(PROVIDER, false).unwrap();
let pending = harness.next().await;
tokio::time::pause();
tokio::time::advance(Duration::from_secs(4)).await;
let failed = completed(&mut events).await;
assert!(failed.error.as_deref().unwrap().contains("time"));
assert!(failed.retry_at_ms.is_some());
assert!(!manager.refresh_models(PROVIDER, true).unwrap().refreshing);
assert!(!manager.refresh_models(PROVIDER, false).unwrap().refreshing);
tokio::time::resume();
stopped(&harness).await;
assert_eq!(harness.live.load(Ordering::SeqCst), 0, "timed-out connection must close");
assert!(pending.reply.is_closed());
advance(&catalogs, 30);
assert!(manager.refresh_models(PROVIDER, false).unwrap().refreshing);
harness.next().await.reply.send(Ok(response(&["retry"], &["low"]))).unwrap();
completed(&mut events).await;
}
#[tokio::test]
async fn changed_effective_config_supersedes_old_completion_without_queueing() {
let tmp = tempfile::tempdir().unwrap();
configure(tmp.path(), "one");
let catalogs = Arc::new(AgentCatalogs::default());
let mut harness = Harness::new();
let first = make_manager(tmp.path(), catalogs.clone(), &harness);
let second = make_manager(tmp.path(), catalogs.clone(), &harness);
let mut events = events(&second);
first.refresh_models(PROVIDER, false).unwrap();
let obsolete = harness.next().await;
configure(tmp.path(), "two");
assert!(second.refresh_models(PROVIDER, false).unwrap().refreshing);
let latest = harness.next().await;
latest.reply.send(Ok(response(&["latest"], &["high"]))).unwrap();
let fresh = completed(&mut events).await;
let _ = obsolete.reply.send(Ok(response(&["obsolete"], &["low"])));
stopped(&harness).await;
let shown = first.refresh_models(PROVIDER, false).unwrap();
assert_eq!(shown.config, fresh.config);
assert_eq!(shown.refreshed_at_ms, fresh.refreshed_at_ms);
assert!(!shown.refreshing);
assert_eq!(harness.launches.load(Ordering::SeqCst), 2);
assert!(!std::fs::read_to_string(tmp.path().join("models.json")).unwrap().contains("obsolete"));
// A changed configuration also invalidates successful freshness/cooldown.
configure(tmp.path(), "three");
assert!(first.refresh_models(PROVIDER, false).unwrap().refreshing);
harness.next().await.reply.send(Ok(response(&["new-config"], &["medium"]))).unwrap();
completed(&mut events).await;
}
#[tokio::test]
async fn invalid_provider_is_a_request_error_and_does_not_launch() {
let tmp = tempfile::tempdir().unwrap();
let harness = Harness::new();
let manager = make_manager(tmp.path(), Arc::default(), &harness);
assert!(manager.refresh_models("missing-provider", true).is_err());
assert_eq!(harness.launches.load(Ordering::SeqCst), 0);
}
#[tokio::test]
async fn replacing_the_executable_at_the_same_path_invalidates_freshness_and_obsolete_responses() {
let tmp = tempfile::tempdir().unwrap();
configure(tmp.path(), "unchanged-environment");
let catalogs = Arc::new(AgentCatalogs::default());
let mut harness = Harness::new();
let manager = make_manager(tmp.path(), catalogs, &harness);
let mut events = events(&manager);
manager.refresh_models(PROVIDER, false).unwrap();
let obsolete = harness.next().await;
std::fs::write(tmp.path().join("catalog-test-bin"), "replacement executable with different size").unwrap();
manager.refresh_models(PROVIDER, false).unwrap();
let latest = harness.next().await;
latest.reply.send(Ok(response(&["updated"], &["high"]))).unwrap();
let fresh = completed(&mut events).await;
let _ = obsolete.reply.send(Ok(response(&["obsolete"], &["low"])));
stopped(&harness).await;
assert_eq!(manager.refresh_models(PROVIDER, false).unwrap().config, fresh.config);
std::fs::write(tmp.path().join("catalog-test-bin"), "another executable").unwrap();
assert!(manager.refresh_models(PROVIDER, false).unwrap().refreshing);
harness.next().await.reply.send(Ok(response(&["new-install"], &["medium"]))).unwrap();
assert_eq!(model_values(&completed(&mut events).await), ["new-install"]);
assert_eq!(harness.launches.load(Ordering::SeqCst), 3);
}
#[tokio::test]
async fn project_catalogs_and_persisted_freshness_do_not_leak_between_contexts() {
let tmp = tempfile::tempdir().unwrap();
let a = tmp.path().join("project-a");
let b = tmp.path().join("project-b");
configure(&a, "account-a");
configure(&b, "account-b");
let models_file = tmp.path().join("shared-models.json");
let catalogs = Arc::new(AgentCatalogs::default());
let mut harness = Harness::new();
let first = make_manager_at(&a, &models_file, catalogs.clone(), &harness);
let second = make_manager_at(&b, &models_file, catalogs, &harness);
let mut first_events = events(&first);
let mut second_events = events(&second);
first.refresh_models(PROVIDER, false).unwrap();
harness.next().await.reply.send(Ok(response(&["account-a-model"], &["low"]))).unwrap();
let a_catalog = completed(&mut first_events).await;
second.refresh_models(PROVIDER, false).unwrap();
harness.next().await.reply.send(Ok(response(&["account-b-model"], &["high"]))).unwrap();
let b_catalog = completed(&mut second_events).await;
assert_eq!(first.refresh_models(PROVIDER, false).unwrap().config, a_catalog.config);
assert_eq!(second.refresh_models(PROVIDER, false).unwrap().config, b_catalog.config);
assert_eq!(first.providers().into_iter().find(|p| p.id == PROVIDER).unwrap().config, a_catalog.config);
for (root, expected) in [(&a, &a_catalog), (&b, &b_catalog)] {
let restored = make_manager_at(root, &models_file, Arc::default(), &harness);
let cached = restored.refresh_models(PROVIDER, false).unwrap();
assert!(!cached.refreshing, "matching persisted context should retain freshness");
assert_eq!(cached.config, expected.config);
assert_eq!(cached.refreshed_at_ms, expected.refreshed_at_ms);
}
assert_eq!(harness.launches.load(Ordering::SeqCst), 2);
configure(&a, "new-account");
let changed = make_manager_at(&a, &models_file, Arc::default(), &harness);
let mut events = events(&changed);
let stale = changed.refresh_models(PROVIDER, false).unwrap();
assert!(stale.refreshing, "a different launch environment cannot reuse persisted freshness");
assert!(stale.refreshed_at_ms.is_none());
assert_eq!(stale.config, a_catalog.config, "stale cached choices remain selectable");
harness.next().await.reply.send(Ok(response(&["new-account-model"], &["medium"]))).unwrap();
completed(&mut events).await;
let persisted = std::fs::read_to_string(models_file).unwrap();
assert!(!persisted.contains("secret-") && !persisted.contains("TOKEN"));
}
#[tokio::test]
async fn configuration_changed_without_another_request_also_discards_old_discovery() {
let tmp = tempfile::tempdir().unwrap();
configure(tmp.path(), "before");
let mut harness = Harness::new();
let manager = make_manager(tmp.path(), Arc::default(), &harness);
let mut events = events(&manager);
manager.refresh_models(PROVIDER, false).unwrap();
let obsolete = harness.next().await;
configure(tmp.path(), "after");
obsolete.reply.send(Ok(response(&["obsolete"], &["low"]))).unwrap();
let discarded = completed(&mut events).await;
assert!(model_values(&discarded).is_empty());
assert!(discarded.refreshed_at_ms.is_none());
assert!(!tmp.path().join("models.json").exists());
manager.refresh_models(PROVIDER, false).unwrap();
harness.next().await.reply.send(Ok(response(&["current"], &["high"]))).unwrap();
assert_eq!(model_values(&completed(&mut events).await), ["current"]);
}
#[tokio::test]
async fn successful_empty_catalog_removes_all_previous_models_and_efforts() {
let tmp = tempfile::tempdir().unwrap();
configure(tmp.path(), "one");
let catalogs = Arc::new(AgentCatalogs::default());
let mut harness = Harness::new();
let manager = make_manager(tmp.path(), catalogs.clone(), &harness);
let mut events = events(&manager);
manager.refresh_models(PROVIDER, true).unwrap();
harness.next().await.reply.send(Ok(response(&["removed"], &["low"]))).unwrap();
completed(&mut events).await;
advance(&catalogs, 30);
manager.refresh_models(PROVIDER, true).unwrap();
harness.next().await.reply.send(Ok(response(&[], &[]))).unwrap();
let empty = completed(&mut events).await;
assert!(model_values(&empty).is_empty() && empty.thought_levels.is_empty());
assert!(empty.refreshed_at_ms.is_some() && empty.error.is_none());
}
+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]
+365 -181
Переглянути файл
@@ -8,6 +8,7 @@
//! next prompt when the agent supports `session/load`.
mod acp;
mod catalogs;
mod config;
mod conversation;
mod external;
@@ -21,6 +22,7 @@ mod skills;
mod timeline;
mod worktree;
pub use catalogs::AgentCatalogs;
pub use external::ExternalSession;
pub use model::*;
@@ -88,6 +90,8 @@ pub struct AgentPaths {
pub worktrees_dir: PathBuf,
/// Settings each agent offered last time (shared by all projects), and its commands per project.
pub models_file: PathBuf,
/// Shared by every project/window on this backend.
pub catalogs: Arc<AgentCatalogs>,
/// User-defined agents, one `<id>.toml` each (`name`, `command`, `args`, `env`).
pub custom_agents_dir: Option<PathBuf>,
/// The user's home: its `.claude` holds their own skills and commands.
@@ -123,12 +127,10 @@ struct Shared {
sink: RwLock<Option<AgentSink>>,
security: RwLock<Option<SecurityHook>>,
new_task_approvals: RwLock<Option<NewTaskApprovalsHook>>,
known: Mutex<HashMap<String, Known>>,
known: Arc<Mutex<HashMap<String, Known>>>,
providers: RwLock<Vec<provider::ProviderSpec>>,
/// Last probe of each provider.
probes: Mutex<HashMap<String, AgentProbe>>,
/// One pre-session lookup of commands and settings per provider, including failed attempts.
command_fetches: Mutex<HashMap<String, tokio::sync::watch::Receiver<bool>>>,
/// [`IDLE_RELEASE`], in milliseconds (shorter in tests).
idle_release_ms: AtomicU64,
mcp_servers_hook: RwLock<Option<Arc<McpServersFn>>>,
@@ -145,6 +147,14 @@ struct Known {
/// By project root: skills and custom commands differ per project.
#[serde(default, rename = "projectCommands")]
commands: HashMap<String, Vec<AgentCommand>>,
/// Successful discovery, unlike a session report, owns availability.
#[serde(default)]
authoritative: bool,
#[serde(default, rename = "refreshedAtMs")]
refreshed_at_ms: Option<f64>,
/// Authoritative availability and freshness are scoped to project and launch context.
#[serde(default, rename = "projectCatalogs")]
catalogs: HashMap<String, catalogs::SavedCatalog>,
}
impl Shared {
@@ -171,7 +181,11 @@ impl Shared {
}
fn known_config(&self, provider: &str) -> Vec<AgentConfigOption> {
let mut config = self.known.lock().get(provider).map(|k| k.config.clone()).unwrap_or_default();
let config = self.paths.catalogs.cached(self, provider).config;
self.config_for_new_task(config, provider)
}
fn config_for_new_task(&self, mut config: Vec<AgentConfigOption>, provider: &str) -> Vec<AgentConfigOption> {
// 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);
@@ -197,80 +211,6 @@ impl Shared {
cache.get(provider).and_then(|k| k.commands.get(&self.root_key())).cloned().unwrap_or_default()
}
async fn fetch_settings(self: &Arc<Self>, spec: provider::ProviderSpec) {
let mut ready = {
let mut fetches = self.command_fetches.lock();
fetches
.entry(spec.id.clone())
.or_insert_with(|| {
let (done, ready) = tokio::sync::watch::channel(false);
let shared = self.clone();
// Keep the lookup alive if a composer unmounts while it is waiting.
self.runtime.spawn(async move {
tracing::info!("agent {}: settings discovery started", spec.id);
let discovered = if spec.driver == provider::Driver::Acp {
acp::discover::run(&shared, &spec).await.map(|_| None)
} else if spec.driver == provider::Driver::Claude {
native::claude::discover(spec.clone(), shared.paths.project_root.clone()).await.map(Some)
} else {
return;
};
if let Ok(Some(found)) = discovered {
let commands =
config::commands_of(&json!({ "availableCommands": found.commands }), &spec.id);
let options = config::options_of(&json!({ "configOptions": found.options }));
let levels: Vec<_> = found
.efforts
.into_iter()
.map(|(model, o)| {
let options = o.map(|o| config::options_of(&json!({ "configOptions": [o] })));
(model, options.unwrap_or_default())
})
.collect();
let _ = tokio::task::spawn_blocking(move || {
// Notifies subscribers itself when the discovery
// changed the cached config; unchanged results
// stay silent.
shared.remember_discovered(&spec.id, &options, &commands, &levels);
})
.await;
} else if let Err(e) = discovered {
tracing::info!("agent {}: settings discovery failed: {e}", spec.id);
}
let _ = done.send(true);
});
ready
})
.clone()
};
while !*ready.borrow_and_update() {
if ready.changed().await.is_err() {
break;
}
}
}
fn has_config(&self, provider: &str) -> bool {
self.known.lock().get(provider).is_some_and(|k| !k.config.is_empty())
}
/// Whether the thinking levels of every model the agent offers are known (#1686).
fn knows_all_levels(&self, provider: &str) -> bool {
let cache = self.known.lock();
let Some(known) = cache.get(provider) else { return false };
let models: Vec<&str> = known
.config
.iter()
.filter(|o| o.category.as_deref() == Some("model"))
.flat_map(|o| o.values.iter().map(|v| v.value.as_str()))
.collect();
// No advertised models yet: nothing to attach effort levels to.
if models.is_empty() {
return false;
}
models.iter().all(|id| known.thought_levels.contains_key(*id))
}
fn root_key(&self) -> String {
self.paths.project_root.to_string_lossy().into_owned()
}
@@ -283,78 +223,48 @@ impl Shared {
commands: Option<&[AgentCommand]>,
model: Option<&str>,
) {
self.remember_inner(provider, config, commands, model, &[], false);
}
/// `levels`: each model's thinking-level options, so a new task offers them for any model
/// it picks (#1686). A session's report for a model wins over these.
fn remember_discovered(
&self,
provider: &str,
config: &[AgentConfigOption],
commands: &[AgentCommand],
levels: &[(String, Vec<AgentConfigOption>)],
) {
self.remember_inner(provider, Some(config), Some(commands), model_of(config).as_deref(), levels, true);
}
fn remember_inner(
&self,
provider: &str,
config: Option<&[AgentConfigOption]>,
commands: Option<&[AgentCommand]>,
model: Option<&str>,
discovered_levels: &[(String, Vec<AgentConfigOption>)],
discovered: bool,
) {
// Keep the update and its disk write ordered; a session report wins over discovery.
let mut cache = self.known.lock();
let known = cache.entry(provider.to_owned()).or_default();
let mut changed = false;
for (model, levels) in discovered_levels {
if !known.thought_levels.contains_key(model) {
known.thought_levels.insert(model.clone(), levels.clone());
changed = true;
}
}
if let Some(c) = config.filter(|_| !discovered || known.config.is_empty()) {
// Running tasks retain their own reported settings. Once discovery has
// succeeded, a session's older list/defaults cannot change New Task choices.
if !known.authoritative
&& let Some(config) = config.filter(|c| !c.is_empty())
{
if let Some(model) = model {
let levels: Vec<_> =
c.iter().filter(|o| o.category.as_deref() == Some("thought_level")).cloned().collect();
config.iter().filter(|o| o.category.as_deref() == Some("thought_level")).cloned().collect();
if known.thought_levels.get(model) != Some(&levels) {
known.thought_levels.insert(model.to_owned(), levels);
changed = true;
}
}
if !c.is_empty() && known.config != c {
known.config = c.to_vec();
if known.config != config {
known.config = config.to_vec();
changed = true;
}
}
let root = self.root_key();
if let Some(c) = commands.filter(|c| {
!c.is_empty()
&& (!discovered || known.commands.get(&root).is_none_or(Vec::is_empty))
&& known.commands.get(&root).map(Vec::as_slice) != Some(c)
}) {
known.commands.insert(root, c.to_vec());
if let Some(commands) = commands.filter(|c| !c.is_empty())
&& known.commands.get(&self.root_key()).map(Vec::as_slice) != Some(commands)
{
known.commands.insert(self.root_key(), commands.to_vec());
changed = true;
}
if !changed {
return;
if changed {
self.persist_known(&cache);
}
if let Ok(bytes) = serde_json::to_vec_pretty(&*cache)
drop(cache);
if changed {
self.emit(AgentEvent::Providers);
}
}
fn persist_known(&self, cache: &HashMap<String, Known>) {
if let Ok(bytes) = serde_json::to_vec_pretty(cache)
&& let Err(e) = write_file(&self.paths.models_file, &bytes)
{
tracing::warn!("saving agent settings: {e}");
}
// The cached provider config actually changed (a session report, discovery,
// or a turn update): tell subscribers to re-list, so a changed model list
// reaches the composer without waiting for the next task (#1490). Unchanged
// reports stay silent, so routine session traffic emits nothing. The cache
// lock is released first; the sink never runs under it.
drop(cache);
self.emit(AgentEvent::Providers);
}
}
@@ -373,6 +283,11 @@ struct TaskFile {
/// The folder (in the task store) with copies of the `before` files, for rolling back.
#[serde(default, skip_serializing_if = "Option::is_none")]
saved: Option<String>,
/// In the project's own checkout, where the user and other tasks change files too: the
/// files that changed while one of this task's turns ran, the only ones its changes list.
/// `None` for a worktree task, and a task saved before these were recorded.
#[serde(default, skip_serializing_if = "Option::is_none")]
touched: Option<BTreeSet<String>>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
subagents: Vec<SavedSubagent>,
/// Turns to drop from the session once it is loaded (a fork of an earlier point).
@@ -383,6 +298,9 @@ struct TaskFile {
title_renamed: bool,
}
/// The changed files of a checkout, stamped.
type Snapshot = BTreeMap<String, worktree::Stamp>;
struct TaskState {
task: AgentTask,
session_id: Option<String>,
@@ -390,11 +308,18 @@ struct TaskState {
wanted: Vec<AgentConfigChoice>,
before: Arc<BTreeMap<String, worktree::Stamp>>,
saved: Option<String>,
/// See `TaskFile::touched`.
touched: Option<Arc<BTreeSet<String>>>,
pending_rollback: u32,
/// The user named the task; see `TaskFile::title_renamed`.
title_renamed: bool,
/// A turn is running or about to (prompts arriving now are queued).
busy: bool,
/// A temporary side conversation: lives in memory only, never persisted.
ephemeral: bool,
/// The conversation a temporary side conversation starts with, sent to the agent with
/// its first real message; the task's own entries hold only the questions.
ephemeral_context: Option<String>,
/// Background tasks the agent reported as running (`run_in_background` commands and
/// the like), as it sent them: unlike a turn they outlive it, so an idle release
/// waits for the last one. A (re)started process runs nothing yet.
@@ -469,8 +394,7 @@ 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 known = paths.catalogs.store(&paths.models_file);
let providers = RwLock::new(provider::load(paths.custom_agents_dir.as_deref(), paths.env_secrets.as_deref()));
let shared = Arc::new(Shared {
paths,
@@ -479,13 +403,13 @@ impl AgentManager {
sink: RwLock::new(None),
security: RwLock::new(None),
new_task_approvals: RwLock::new(None),
known: Mutex::new(known),
known,
providers,
probes: Mutex::new(HashMap::new()),
command_fetches: Mutex::new(HashMap::new()),
idle_release_ms: AtomicU64::new(IDLE_RELEASE.as_millis() as u64),
mcp_servers_hook: RwLock::new(None),
});
shared.paths.catalogs.subscribe(&shared);
let mut tasks = HashMap::new();
for file in std::fs::read_dir(&shared.paths.store_dir).into_iter().flatten().flatten() {
let path = file.path();
@@ -515,8 +439,14 @@ impl AgentManager {
}
/// Routes events to `sink` (replacing the previous subscriber); returns every task.
/// Ephemeral tasks belonged to the previous page session: a fresh one starts without them.
pub fn subscribe(&self, sink: AgentSink) -> Vec<AgentTask> {
*self.shared.sink.write() = Some(sink);
let stale: Vec<Arc<Task>> = self.tasks.read().values().filter(|t| t.state.lock().ephemeral).cloned().collect();
for task in &stale {
self.drop_in_memory(task);
self.shared.emit(AgentEvent::Removed { id: task.id.clone() });
}
let mut tasks: Vec<AgentTask> = self.tasks.read().values().map(|t| t.state.lock().task.clone()).collect();
tasks.sort_by(|a, b| b.updated_ms.total_cmp(&a.updated_ms));
tasks
@@ -542,9 +472,9 @@ impl AgentManager {
.filter(|p| !provider::is_unlisted(p) || in_use.contains(&p.id))
.map(|p| {
let mut info = provider::describe(p);
info.config = self.shared.known_config(&p.id);
info.thought_levels =
self.shared.known.lock().get(&p.id).map(|k| k.thought_levels.clone()).unwrap_or_default();
let catalog = self.shared.paths.catalogs.cached(&self.shared, &p.id);
info.config = self.shared.config_for_new_task(catalog.config, &p.id);
info.thought_levels = catalog.thought_levels;
info.commands = self.shared.known_commands(&p.id);
info
})
@@ -632,17 +562,20 @@ impl AgentManager {
// merged without duplicating what `availableCommands` or the disk offer.
let mut offered = offered;
if task.is_none()
&& (offered.is_empty() || !self.shared.has_config(&provider) || !self.shared.knows_all_levels(&provider))
&& let Some(spec) = self
.shared
.spec(&provider)
.filter(|s| matches!(s.driver, provider::Driver::Claude | provider::Driver::Acp))
&& let Some(spec) =
provider::load(self.shared.paths.custom_agents_dir.as_deref(), self.shared.env_secrets())
.into_iter()
.find(|spec| spec.id == provider)
{
let claude = spec.driver == provider::Driver::Claude;
self.shared.fetch_settings(spec).await;
if claude {
offered = self.shared.known_commands(&provider);
let (_, flight) = self.shared.paths.catalogs.request(&self.shared, spec, false);
if let Some(mut ready) = flight {
while !*ready.borrow_and_update() {
if ready.changed().await.is_err() {
break;
}
}
}
offered = self.shared.known_commands(&provider);
}
for &(name, description) in builtin_commands(&provider) {
if !offered.iter().any(|c| c.name == name) {
@@ -743,19 +676,18 @@ impl AgentManager {
retry: None,
background: Box::default(),
};
let file = TaskFile {
let handle = self.register(TaskFile {
task: task.clone(),
session_id: Some(session_id.to_owned()),
entries: Vec::new(),
wanted: Vec::new(),
before: BTreeMap::new(),
saved: None,
touched: Some(BTreeSet::new()),
subagents: Vec::new(),
pending_rollback: 0,
title_renamed: true,
};
let handle = Task::restore(file, self.shared.clone());
self.tasks.write().insert(task.id.clone(), handle.clone());
});
handle.emit_task();
handle.persist();
Ok(task)
@@ -793,25 +725,8 @@ impl AgentManager {
(root, None, base, commit, before)
}
};
// Until the session reports its own, show what the agent offered last time with the choices applied.
let mut config = self.shared.known_config(&spec.id);
for choice in &req.config {
if let Some(o) = config.iter_mut().find(|o| o.id == choice.id) {
o.current = Some(choice.value.clone());
}
}
if let Some(model) = model_of(&config)
&& let Some(levels) = self.shared.known.lock().get(&spec.id).and_then(|k| k.thought_levels.get(&model))
{
config.extend(levels.iter().cloned().map(|mut o| {
if let Some(choice) =
req.config.iter().find(|c| c.id == o.id && o.values.iter().any(|v| v.value == c.value))
{
o.current = Some(choice.value.clone());
}
o
}));
}
// The session's settings: what the agent offered last time, with the choices applied.
let config = self.merged_config(&spec.id, &req.config);
let now = now_ms();
let id = uuid::Uuid::new_v4().simple().to_string();
// Copies of the files the user had changed already, for rolling back the task's changes to them.
@@ -852,26 +767,128 @@ impl AgentManager {
retry: None,
background: Box::default(),
};
let file = TaskFile {
let handle = self.register(TaskFile {
task: task.clone(),
session_id: None,
entries: Vec::new(),
wanted: req.config,
before,
saved,
touched: (req.isolation == Isolation::Local).then(BTreeSet::new),
subagents: Vec::new(),
pending_rollback: 0,
title_renamed: false,
};
let handle = Task::restore(file, self.shared.clone());
});
handle.state.lock().busy = true;
self.tasks.write().insert(task.id.clone(), handle.clone());
handle.emit_task();
handle.persist();
self.shared.runtime.spawn(async move { handle.run(prompt).await });
Ok(task)
}
/// Builds the in-memory handle of a fresh task, registers it and announces it. The
/// caller decides what else its life starts with (a persisted file, a session, a run).
fn register(&self, file: TaskFile) -> Arc<Task> {
let handle = Task::restore(file, self.shared.clone());
self.tasks.write().insert(handle.id.clone(), handle.clone());
handle.emit_task();
handle
}
/// The session's settings: what the agent offered last time, with the request's
/// choices — and the chosen model's thought levels — applied.
fn merged_config(&self, provider: &str, req: &[AgentConfigChoice]) -> Vec<AgentConfigOption> {
let catalog = self.shared.paths.catalogs.cached(&self.shared, provider);
let mut levels = catalog.thought_levels;
let mut config = self.shared.config_for_new_task(catalog.config, provider);
for choice in req {
if let Some(o) = config.iter_mut().find(|o| o.id == choice.id) {
o.current = Some(choice.value.clone());
}
}
if let Some(model) = model_of(&config)
&& let Some(levels) = levels.remove(&model)
{
config.extend(levels.into_iter().map(|mut o| {
if let Some(choice) = req.iter().find(|c| c.id == o.id && o.values.iter().any(|v| v.value == c.value)) {
o.current = Some(choice.value.clone());
}
o
}));
}
config
}
/// Creates a temporary side conversation of `source`: an in-memory task that is never
/// persisted, whose session starts with its first real message (`prompt` only names
/// the title). `source`'s conversation rides along as the context of that message.
/// The task is read-only: its settings are pushed to a mode that asks before anything,
/// and requests that would change the project are denied regardless. Fails for an
/// agent that offers no way to ask.
pub async fn create_ephemeral(&self, req: NewAgentTask, source: &str) -> IdeResult<AgentTask> {
let spec = self
.shared
.spec(&req.provider)
.ok_or_else(|| IdeError::invalid(format!("unknown agent {}", req.provider)))?;
let prompt = clean(req.prompt).ok_or_else(|| IdeError::invalid("describe the topic first"))?;
let title = title_of(&prompt);
let mut config = self.merged_config(&spec.id, &req.config);
config::ask_for_approvals(&mut config);
if config::bypass_approvals(&config) {
return Err(IdeError::invalid(
"this agent has no mode that asks before changing things, so it can't run a side conversation",
));
}
let read_only = !config::bypass_approvals(&config);
let context = context_transcript(&self.task(source)?.state.lock().conversation.main.entries);
let root = self.shared.paths.project_root.clone();
let before = blocking(move || Ok(worktree::snapshot(&root).unwrap_or_default())).await?;
let now = now_ms();
let mut capabilities = self.shared.probes.lock().get(&spec.id).map(|p| p.capabilities).unwrap_or_default();
capabilities.read_only = read_only;
let task = AgentTask {
project_root: Some(IpcPath(self.shared.paths.project_root.clone())),
id: uuid::Uuid::new_v4().simple().to_string(),
title,
provider: spec.id.clone(),
model: model_of(&config),
status: AgentStatus::Ready,
isolation: Isolation::Local,
cwd: IpcPath(self.shared.paths.project_root.clone()),
branch: None,
base: None,
base_commit: None,
error: None,
created_ms: now,
updated_ms: now,
commands: self.shared.known_commands(&spec.id),
usage: None,
queue: Vec::new(),
capabilities,
resets_ms: None,
retry: None,
background: Box::default(),
config,
};
let handle = self.register(TaskFile {
task: task.clone(),
session_id: None,
entries: Vec::new(),
wanted: req.config,
before,
saved: None,
touched: None,
subagents: Vec::new(),
pending_rollback: 0,
title_renamed: false,
});
{
let mut s = handle.state.lock();
s.ephemeral = true;
s.ephemeral_context = Some(context);
}
Ok(task)
}
/// Sends a message. While the agent works it is queued, or with `now` the current turn
/// is cancelled and the message goes next.
pub async fn prompt(&self, id: &str, prompt: AgentPrompt, now: bool) -> IdeResult<()> {
@@ -1092,11 +1109,14 @@ impl AgentManager {
wanted: s.wanted.clone(),
before: (*s.before).clone(),
saved: s.saved.clone(),
touched: s.touched.as_deref().cloned(),
subagents: s.conversation.saved_subagents(),
pending_rollback: total - keep,
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();
@@ -1166,6 +1186,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)
}
@@ -1287,6 +1309,15 @@ impl AgentManager {
Ok(())
}
/// Removes a task from the manager's table and stops its agent. The disk (its file,
/// its worktree) is the caller's business.
fn drop_in_memory(&self, task: &Arc<Task>) {
task.kill();
self.tasks.write().remove(&task.id);
*task.deleted.lock() = true;
let _ = std::fs::remove_file(self.shared.paths.store_dir.join(format!("{}.json", task.id)));
}
/// Returns an archived task to the active list without starting the agent.
pub fn unarchive(&self, id: &str) -> IdeResult<()> {
let task = self.task(id)?;
@@ -1370,8 +1401,8 @@ impl AgentManager {
pub async fn changes(&self, id: &str) -> IdeResult<Vec<AgentChange>> {
let task = self.task(id)?;
let at = task.base();
let before = task.state.lock().before.clone();
blocking(move || at.changes(&before)).await
let (before, touched) = task.attribution();
blocking(move || Ok(worktree::made_by_task(at.changes(&before)?, touched.as_deref()))).await
}
/// The task's uncommitted changes and, for a worktree task, the commits made since it
@@ -1379,8 +1410,13 @@ impl AgentManager {
pub async fn grouped_changes(&self, id: &str) -> IdeResult<AgentChanges> {
let task = self.task(id)?;
let at = task.base();
let before = task.state.lock().before.clone();
blocking(move || at.grouped_changes(&before)).await
let (before, touched) = task.attribution();
blocking(move || {
let mut grouped = at.grouped_changes(&before)?;
grouped.uncommitted = worktree::made_by_task(grouped.uncommitted, touched.as_deref());
Ok(grouped)
})
.await
}
/// Content of `path` (in the task's directory) when the task started.
@@ -1406,15 +1442,16 @@ impl AgentManager {
return Err(IdeError::invalid("the agent is working: stop it before rolling back its changes"));
}
let at = task.base();
let (before, saved) = {
let (before, saved, touched) = {
let s = task.state.lock();
(s.before.clone(), s.saved.as_ref().map(|name| self.shared.paths.store_dir.join(name)))
(s.before.clone(), s.saved.as_ref().map(|name| self.shared.paths.store_dir.join(name)), s.touched.clone())
};
let restamped = blocking(move || {
let (changes, rev) = match view {
AgentChangesView::Group => (at.grouped_changes(&before)?.uncommitted, worktree::head_commit(at.cwd())?),
AgentChangesView::SinceStart => (at.changes(&before)?, at.rev()?),
};
let changes = worktree::made_by_task(changes, touched.as_deref());
let sources = match rev.as_deref() {
Some(rev) => worktree::rename_sources(at.cwd(), rev)?,
None => Default::default(),
@@ -1505,9 +1542,12 @@ impl Task {
wanted: file.wanted,
before: Arc::new(file.before),
saved: file.saved,
touched: file.touched.map(Arc::new),
pending_rollback: file.pending_rollback,
title_renamed: file.title_renamed,
busy: false,
ephemeral: false,
ephemeral_context: None,
background_tasks: Vec::new(),
}),
live: Mutex::new(None),
@@ -1529,6 +1569,41 @@ impl Task {
})
}
/// The files already changed when the task started, and the files its turns changed.
fn attribution(&self) -> (Arc<Snapshot>, Option<Arc<BTreeSet<String>>>) {
let s = self.state.lock();
(s.before.clone(), s.touched.clone())
}
/// The changed files of the checkout as a turn starts, when the task records what its turns change.
async fn checkout_before_turn(&self) -> Option<Snapshot> {
let cwd = {
let s = self.state.lock();
s.touched.as_ref()?;
s.task.cwd.0.clone()
};
blocking(move || worktree::snapshot(&cwd)).await.ok()
}
/// Records the files that changed in the checkout since `start`, as the turn ends, as the task's.
async fn record_turn_changes(&self, start: Option<Snapshot>) {
let Some(start) = start else { return };
let cwd = self.state.lock().task.cwd.0.clone();
let Ok(end) = blocking(move || worktree::snapshot(&cwd)).await else { return };
let changed = worktree::changed_between(&start, &end);
if changed.is_empty() {
return;
}
{
let mut s = self.state.lock();
let Some(touched) = &s.touched else { return };
let mut touched = (**touched).clone();
touched.extend(changed);
s.touched = Some(Arc::new(touched));
}
self.persist();
}
fn base(&self) -> worktree::TaskBase {
let s = self.state.lock();
let cwd = s.task.cwd.0.clone();
@@ -1609,6 +1684,9 @@ impl Task {
if *deleted {
return;
}
if self.state.lock().ephemeral {
return;
}
let bytes = {
let s = self.state.lock();
serde_json::to_vec(&TaskFile {
@@ -1618,6 +1696,7 @@ impl Task {
wanted: s.wanted.clone(),
before: (*s.before).clone(),
saved: s.saved.clone(),
touched: s.touched.as_deref().cloned(),
subagents: s.conversation.saved_subagents(),
pending_rollback: s.pending_rollback,
title_renamed: s.title_renamed,
@@ -1669,9 +1748,11 @@ impl Task {
/// the user stops one. The caller set `busy`.
async fn run(self: Arc<Self>, mut prompt: AgentPrompt) {
loop {
let start = self.checkout_before_turn().await;
let result = self.turn(&prompt).await;
self.cancel_pending.store(false, Ordering::Release);
tokio::time::sleep(TURN_GRACE).await;
self.record_turn_changes(start).await;
let changed = self.state.lock().conversation.end_turn(Some(now_ms()));
self.mark_dirty(changed);
let send_now = self.send_now.swap(false, Ordering::AcqRel);
@@ -1839,7 +1920,20 @@ impl Task {
if !prompt.images.is_empty() && !live.capabilities.images {
return Err(Failure::from("This agent can't read images.".to_owned()));
}
let content = self.content(&live, prompt).await;
// A temporary side conversation's context rides with its first real message; the
// conversation itself records only the question (and whatever comes after).
let prompt = {
let context = self.state.lock().ephemeral_context.take();
match context {
Some(context) => {
let mut owned = prompt.clone();
owned.text = format!("{context}\n\n---\n\n{}", owned.text);
owned
}
None => prompt.clone(),
}
};
let content = self.content(&live, &prompt).await;
let result: Result<Value, RpcError> =
live.peer.request("session/prompt", json!({ "sessionId": live.session, "prompt": content }), None).await;
match result {
@@ -2293,6 +2387,29 @@ impl Task {
.collect()
})
.unwrap_or_default();
// A side conversation is read-only: deny anything that would change the project
// (its task is deleted when its pane closes, leaving nothing to review or revert).
if self.state.lock().ephemeral
&& params
.pointer("/toolCall/kind")
.or_else(|| params.pointer("/subject/toolCall/kind"))
.and_then(Value::as_str)
.is_some_and(|kind| matches!(kind, "edit" | "delete" | "move" | "execute"))
{
let reject = options.iter().find(|o| o.kind.starts_with("reject")).cloned();
let _ = self.push_entry(AgentEntry::Permission {
id,
title,
command: None,
options,
questions: Vec::new(),
chosen: Some("Denied: side conversations are read-only".to_owned()),
});
return match reject {
Some(option) => json!({ "outcome": { "outcome": "selected", "optionId": option.id } }),
None => json!({ "outcome": { "outcome": "cancelled" } }),
};
}
let asked: Vec<String> = questions
.iter()
.map(|question| question.answer_key.clone().unwrap_or_else(|| question.question.clone()))
@@ -2490,6 +2607,72 @@ fn clean(mut prompt: AgentPrompt) -> Option<AgentPrompt> {
(!prompt.text.is_empty() || !prompt.images.is_empty() || !prompt.files.is_empty()).then_some(prompt)
}
/// The character budget of a side conversation's context: the newest entries win, the
/// oldest are dropped first. Leaves room for the question and the agent's answer.
const CONTEXT_BUDGET: usize = 100_000;
/// A long tool output trimmed to its first and last few lines, with the elided middle noted.
fn trimmed_output(output: &str) -> String {
const KEPT: usize = 4;
let lines: Vec<&str> = output.lines().collect();
if lines.len() <= KEPT * 2 + 1 {
return output.to_owned();
}
let omitted = lines.len() - KEPT * 2;
let mut out = lines[..KEPT].join("\n");
out.push_str(&format!("\n… {omitted} lines omitted …\n"));
out.push_str(&lines[lines.len() - KEPT..].join("\n"));
out
}
fn context_entry(e: &AgentEntry) -> Option<String> {
match e {
AgentEntry::User { text, .. } => Some(format!("User: {text}")),
AgentEntry::Agent { text, .. } if !text.trim().is_empty() => Some(format!("Agent: {text}")),
AgentEntry::Thought { text, .. } if !text.trim().is_empty() => Some(format!("Thought: {text}")),
AgentEntry::Tool { title, output, .. } => match output.as_deref() {
Some(output) if !output.trim().is_empty() => Some(format!("Tool: {title}\n{}", trimmed_output(output))),
_ => Some(format!("Tool: {title}")),
},
AgentEntry::Error { message, .. } => Some(format!("Error: {message}")),
_ => None,
}
}
/// The conversation as the context a temporary side conversation starts with: what was
/// said, what was done (tool calls with trimmed output), errors and the agent's reasoning,
/// oldest first, within [`CONTEXT_BUDGET`].
fn context_transcript(entries: &[AgentEntry]) -> String {
// Newest wins: walk back until the budget is spent, dropping the oldest entries first.
let mut lines: Vec<String> = Vec::new();
let mut used = 0usize;
for e in entries.iter().rev() {
let Some(line) = context_entry(e) else { continue };
if used + line.len() > CONTEXT_BUDGET {
if lines.is_empty() {
// Even the newest entry alone exceeds the budget: keep its head.
lines.push(line.chars().take(CONTEXT_BUDGET).collect());
}
break;
}
used += line.len();
lines.push(line);
}
lines.reverse();
let dropped = entries.iter().rev().filter_map(context_entry).count() - lines.len();
let mut context = String::from(
"You are answering a side question in a temporary conversation. The conversation so far is below, \
for context only; answer the new question after the marker, and do not continue the main task \
unless asked.\n\n<conversation>\n",
);
if dropped > 0 {
context.push_str(&format!("(The {dropped} oldest entries are omitted.)\n\n"));
}
context.push_str(&lines.join("\n\n"));
context.push_str("\n</conversation>");
context
}
fn permission_title(params: &Value) -> String {
let call = &params["toolCall"];
let title = call.get("title").and_then(Value::as_str).filter(|t| !t.is_empty());
@@ -2690,6 +2873,7 @@ fn capabilities_of(init: &acp::Negotiated) -> (AgentCapabilities, bool) {
rollback: meta("/openidea/rollback"),
steer: meta("/openidea/steer"),
background_tasks: meta("/openidea/backgroundTasks"),
read_only: false,
};
(capabilities, init.prompt("embeddedContext"))
}
+20
Переглянути файл
@@ -36,6 +36,9 @@ pub struct AgentCapabilities {
pub steer: bool,
/// It reports its background tasks and stops them one by one.
pub background_tasks: bool,
/// The task runs read-only: it asks before anything, and requests that would change
/// the project are denied (a temporary side conversation).
pub read_only: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
@@ -538,6 +541,19 @@ pub struct AgentChanges {
pub commits: Vec<AgentCommit>,
}
/// Authoritative choices for new tasks, independent of any running session's settings.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct AgentModelCatalog {
pub provider: String,
pub config: Vec<AgentConfigOption>,
pub thought_levels: BTreeMap<String, Vec<AgentConfigOption>>,
pub refreshing: bool,
pub refreshed_at_ms: Option<f64>,
pub retry_at_ms: Option<f64>,
pub error: Option<String>,
}
/// Streamed to the window that subscribed (`AgentManager::subscribe`).
#[derive(Clone, Debug, Serialize, Type)]
#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
@@ -575,6 +591,10 @@ pub enum AgentEvent {
provider: String,
limits: Vec<AgentLimit>,
},
/// A model lookup started or completed, including unsuccessful lookups.
ModelCatalog {
catalog: AgentModelCatalog,
},
/// The installed or configured agent providers changed.
Providers,
}
+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");
+59 -10
Переглянути файл
@@ -85,6 +85,8 @@ pub(crate) async fn discover(spec: ProviderSpec, cwd: PathBuf) -> Result<Discove
let (spec, cwd) = spec.await.map_err(|e| e.to_string())?;
let mut args = spec.args;
args.extend(Start::New { id: uuid::Uuid::new_v4().to_string() }.args());
// Metadata discovery must not launch project-configured MCP servers.
args.extend(["--strict-mcp-config".into(), "--mcp-config".into(), "{\"mcpServers\":{}}".into()]);
let launch = Launch { program: spec.command, args, cwd, env: spec.env };
let mut io = cli::Process.launch(&launch, Arc::new(|_| {})).map_err(|e| e.to_string())?;
let _stop = io.kill.clone().drop_guard();
@@ -191,6 +193,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 +369,7 @@ impl Shim {
limit_resets_ms: None,
terminal_only: Vec::new(),
titled: false,
titling: false,
}),
cwd,
starting: tokio::sync::Mutex::new(()),
@@ -583,11 +588,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 +604,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 +1063,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 +1089,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 +1120,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 +1462,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 =
+141 -41
Переглянути файл
@@ -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,
@@ -41,6 +44,92 @@ pub(crate) fn connect(
})
}
/// A model lookup uses app-server directly: no thread, task, prompt or project MCP.
pub(crate) async fn discover(
spec: ProviderSpec,
cwd: std::path::PathBuf,
) -> Result<crate::catalogs::Discovered, String> {
let spec = tokio::task::spawn_blocking({
let cwd = cwd.clone();
move || spec.resolve_for_launch(&cwd)
})
.await
.map_err(|e| e.to_string())?;
let peer = Peer::spawn_framed(
&spec.command,
&spec.args,
&cwd,
&spec.env,
Arc::new(ide_jsonrpc::NoopHandler),
Box::new(|_| {}),
Framing::Lines,
)
.map_err(|e| e.to_string())?;
let _stop = crate::catalogs::KillPeer(peer.clone());
tokio::time::timeout(Duration::from_secs(30), async {
let _: Value = peer.request("initialize", initialize_params(), None).await.map_err(|e| e.to_string())?;
peer.notify("initialized", Value::Null).map_err(|e| e.to_string())?;
let mut models = list_models(&peer).await.map_err(|e| e.message)?;
models.retain(|model| !model.hidden);
let mut settings = Settings::of(&json!({}), &models);
if settings.model.is_empty()
&& let Some(model) = models.first()
{
settings.model = model.id.clone();
settings.fit_effort(&models);
}
let options = translate::config_options(&models, &settings);
let config = crate::config::options_of(&json!({ "configOptions": options }));
let levels = models
.iter()
.map(|model| {
let mut settings = settings.clone();
settings.model = model.id.clone();
settings.fit_effort(&models);
let options = translate::config_options(&models, &settings);
let levels = crate::config::options_of(&json!({ "configOptions": options }))
.into_iter()
.filter(|o| o.category.as_deref() == Some("thought_level"))
.collect();
(model.id.clone(), levels)
})
.collect();
Ok(crate::catalogs::Discovered { config, commands: None, levels })
})
.await
.map_err(|_| "Codex model discovery timed out".to_owned())?
}
fn initialize_params() -> Value {
json!({
"clientInfo": { "name": "openidea", "title": "OpenIDEA", "version": env!("CARGO_PKG_VERSION") },
"capabilities": { "experimentalApi": true, "optOutNotificationMethods": OPT_OUT },
})
}
async fn list_models(peer: &Peer) -> Result<Vec<ModelInfo>, ResponseError> {
let mut all = Vec::new();
let mut cursor = Value::Null;
let mut seen = HashSet::new();
loop {
let page: Value = peer.request("model/list", json!({ "cursor": cursor }), None).await.map_err(from_codex)?;
let Some(data) = page.get("data").and_then(Value::as_array) else {
return Err(error(codes::INTERNAL_ERROR, "Codex returned an invalid model list"));
};
let models = translate::models_of(&page);
if models.len() != data.len() {
return Err(error(codes::INTERNAL_ERROR, "Codex returned an invalid model list"));
}
all.extend(models);
match page.get("nextCursor") {
None | Some(Value::Null) => return Ok(all),
Some(Value::String(next)) if next.is_empty() => return Ok(all),
Some(Value::String(next)) if seen.insert(next.clone()) => cursor = json!(next),
_ => return Err(error(codes::INTERNAL_ERROR, "Codex returned an invalid model-list cursor")),
}
}
}
/// Connects `client` to a shim driving the codex connection `connect_codex` opens (it
/// gets the handler for what codex sends). Each side's end closes the other.
fn attach(
@@ -126,6 +215,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 {
@@ -243,15 +336,7 @@ impl CodexShim {
}
async fn initialize(&self) -> Result<Value, ResponseError> {
let info = self
.call(
"initialize",
json!({
"clientInfo": { "name": "openidea", "title": "OpenIDEA", "version": env!("CARGO_PKG_VERSION") },
"capabilities": { "experimentalApi": true, "optOutNotificationMethods": OPT_OUT },
}),
)
.await?;
let info = self.call("initialize", initialize_params()).await?;
self.codex().notify("initialized", Value::Null).map_err(from_codex)?;
self.state.lock().initialized = true;
// `<client>/<codex version> (<platform>) …`
@@ -275,32 +360,15 @@ impl CodexShim {
}))
}
/// The models codex offers; empty when it can't list them.
async fn models(&self) -> Arc<Vec<ModelInfo>> {
/// Cache only complete, successful listings; a failed page must never become availability.
async fn models(&self) -> Result<Arc<Vec<ModelInfo>>, ResponseError> {
let mut cached = self.models.lock().await;
if let Some(m) = cached.as_ref() {
return m.clone();
if let Some(models) = cached.as_ref() {
return Ok(models.clone());
}
let mut all = Vec::new();
let mut cursor = Value::Null;
loop {
match self.call("model/list", json!({ "cursor": cursor })).await {
Ok(page) => {
all.extend(translate::models_of(&page));
match page.get("nextCursor").filter(|c| c.is_string()) {
Some(next) if *next != cursor => cursor = next.clone(),
_ => break,
}
}
Err(e) => {
tracing::warn!("codex: listing models failed: {}", e.message);
return Arc::new(all);
}
}
}
let models = Arc::new(all);
let models = Arc::new(list_models(self.codex()).await?);
*cached = Some(models.clone());
models
Ok(models)
}
async fn publish_skills(&self, session: &str) {
@@ -329,11 +397,12 @@ 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)
}
async fn new_session(&self, params: &Value) -> Result<Value, ResponseError> {
let models = self.models().await;
let models = self.models().await?;
let mut request = json!({ "cwd": params.get("cwd") });
if let Some(config) = translate::mcp_config(params) {
request["config"] = config;
@@ -350,7 +419,7 @@ impl CodexShim {
/// Resumes the thread and replays its history before answering.
async fn load_session(&self, params: &Value) -> Result<Value, ResponseError> {
let id = str_param(params, "sessionId")?;
let models = self.models().await;
let models = self.models().await?;
let mut request = json!({ "threadId": id, "cwd": params.get("cwd") });
if let Some(config) = translate::mcp_config(params) {
request["config"] = config;
@@ -368,7 +437,7 @@ impl CodexShim {
async fn fork_session(&self, params: &Value) -> Result<Value, ResponseError> {
let source = str_param(params, "sessionId")?;
let models = self.models().await;
let models = self.models().await?;
let settings = self.state.lock().threads.get(source).and_then(|t| t.settings.clone());
let mut request = json!({ "threadId": source, "cwd": params.get("cwd") });
if let Some(config) = translate::mcp_config(params) {
@@ -389,7 +458,7 @@ impl CodexShim {
let session = str_param(params, "sessionId")?;
let option = str_param(params, "configId")?;
let value = str_param(params, "value")?;
let models = self.models().await;
let models = self.models().await?;
let mut state = self.state.lock();
let settings = state
.threads
@@ -406,7 +475,7 @@ impl CodexShim {
if input.is_empty() {
return Err(invalid("empty prompt"));
}
let models = self.models().await;
let models = self.models().await?;
let (done, ended) = oneshot::channel();
let settings = {
let mut state = self.state.lock();
@@ -625,8 +694,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 +882,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 +901,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 +931,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();
+199 -5
Переглянути файл
@@ -11,6 +11,155 @@ use std::collections::VecDeque;
use std::path::PathBuf;
use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};
struct ModelPages {
pages: Mutex<VecDeque<Result<Value, (i64, String)>>>,
cursors: Mutex<Vec<Value>>,
}
impl Handler for ModelPages {
fn on_notification(&self, _: &str, _: Value) {}
fn on_request(&self, method: &str, params: Value) -> Result<Value, (i64, String)> {
assert_eq!(method, "model/list", "listing models must never start a thread or turn");
self.cursors.lock().push(params["cursor"].clone());
self.pages.lock().pop_front().expect("another expected page")
}
}
fn model_peer(pages: Arc<ModelPages>) -> (Peer, Peer) {
let (ours, theirs) = tokio::io::duplex(1 << 16);
let (read, write) = tokio::io::split(theirs);
let server = Peer::connect_framed(read, write, pages, Framing::Lines);
let (read, write) = tokio::io::split(ours);
(Peer::connect_framed(read, write, Arc::new(ide_jsonrpc::NoopHandler), Framing::Lines), server)
}
#[tokio::test]
async fn model_pagination_failure_does_not_cache_an_incomplete_catalog() {
let first = json!({ "data": [{ "id": "first" }], "nextCursor": "next" });
let pages = Arc::new(ModelPages {
pages: Mutex::new(VecDeque::from([
Ok(first.clone()),
Err((-32000, "authentication expired".into())),
Ok(first),
Ok(json!({ "data": [{ "id": "second" }], "nextCursor": null })),
])),
cursors: Mutex::default(),
});
let (peer, server) = model_peer(pages.clone());
let shim = CodexShim {
runtime: tokio::runtime::Handle::current(),
codex: OnceLock::from(peer.clone()),
client: OnceLock::new(),
state: Mutex::default(),
models: tokio::sync::Mutex::new(None),
skills: tokio::sync::Mutex::new(()),
};
assert_eq!(shim.models().await.unwrap_err().message, "authentication expired");
let models = shim.models().await.unwrap();
assert_eq!(models.iter().map(|m| m.id.as_str()).collect::<Vec<_>>(), ["first", "second"]);
assert_eq!(shim.models().await.unwrap().len(), 2);
assert_eq!(*pages.cursors.lock(), [Value::Null, json!("next"), Value::Null, json!("next")]);
peer.kill();
server.kill();
}
#[tokio::test]
async fn repeated_or_invalid_model_cursors_and_malformed_pages_are_failures() {
for pages in [
vec![json!({ "data": [], "nextCursor": "a" }), json!({ "data": [], "nextCursor": "a" })],
vec![
json!({ "data": [], "nextCursor": "a" }),
json!({ "data": [], "nextCursor": "b" }),
json!({ "data": [], "nextCursor": "a" }),
],
vec![json!({ "data": [], "nextCursor": 7 })],
vec![json!({ "nextCursor": null })],
vec![json!({ "data": [{}], "nextCursor": null })],
] {
let pages =
Arc::new(ModelPages { pages: Mutex::new(pages.into_iter().map(Ok).collect()), cursors: Mutex::default() });
let (peer, server) = model_peer(pages);
assert!(list_models(&peer).await.is_err());
peer.kill();
server.kill();
}
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread")]
async fn native_discovery_only_initializes_and_pages_models_then_stops_the_process() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let script = tmp.path().join("fake-codex");
std::fs::write(
&script,
r#"#!/usr/bin/env python3
import json, os, pathlib, sys
pathlib.Path('pid').write_text(str(os.getpid()))
for line in sys.stdin:
request = json.loads(line)
with pathlib.Path('requests').open('a') as f:
f.write(json.dumps(request) + '\n')
method = request['method']
if method == 'initialized':
continue
if method == 'initialize':
result = {}
elif method == 'model/list':
cursor = request['params']['cursor']
model = 'one' if cursor is None else 'two'
result = {'data': [{'id': model, 'isDefault': cursor is None,
'defaultReasoningEffort': 'low', 'supportedReasoningEfforts': [{'reasoningEffort': 'low'}]}],
'nextCursor': 'second-page' if cursor is None else None}
else:
raise RuntimeError('must not create a thread or prompt: ' + method)
print(json.dumps({'jsonrpc': '2.0', 'id': request['id'], 'result': result}), flush=True)
"#,
)
.unwrap();
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
let spec = ProviderSpec {
id: "codex".into(),
name: "Codex".into(),
driver: crate::provider::Driver::Codex,
command: script.to_string_lossy().into_owned(),
args: Vec::new(),
env: Vec::new(),
login_hint: String::new(),
source: crate::AgentSource::Builtin,
};
let found = discover(spec, tmp.path().to_path_buf()).await.unwrap();
let models: Vec<_> = found
.config
.iter()
.filter(|o| o.category.as_deref() == Some("model"))
.flat_map(|o| o.values.iter().map(|v| v.value.as_str()))
.collect();
assert_eq!(models, ["one", "two"]);
assert_eq!(found.levels.keys().map(String::as_str).collect::<Vec<_>>(), ["one", "two"]);
let requests = std::fs::read_to_string(tmp.path().join("requests")).unwrap();
let requests: Vec<Value> = requests.lines().map(|line| serde_json::from_str(line).unwrap()).collect();
assert_eq!(
requests.iter().map(|r| r["method"].as_str().unwrap()).collect::<Vec<_>>(),
["initialize", "initialized", "model/list", "model/list"]
);
assert_eq!(requests[2]["params"]["cursor"], Value::Null);
assert_eq!(requests[3]["params"]["cursor"], "second-page");
let pid = std::fs::read_to_string(tmp.path().join("pid")).unwrap();
tokio::time::timeout(Duration::from_secs(5), async {
loop {
let alive = std::process::Command::new("kill").args(["-0", pid.trim()]).output().unwrap().status.success();
if !alive {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("discovery must kill and reap its child");
}
// ---- Pure translations -------------------------------------------------------------
fn models() -> Vec<ModelInfo> {
@@ -535,8 +684,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 +725,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 +748,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 +959,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;
+10
Переглянути файл
@@ -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 {
+91 -1
Переглянути файл
@@ -120,10 +120,18 @@ impl ProviderSpec {
}
/// Resolve once before connecting, so authentication guidance names that same install.
pub(crate) fn resolve_for_launch(mut self, cwd: &Path) -> Self {
pub(crate) fn resolve_for_launch(self, cwd: &Path) -> Self {
let strip = prefer_stored_copilot_auth();
self.resolve_for_launch_with_copilot_env(cwd, strip)
}
pub(crate) fn resolve_for_launch_with_copilot_env(mut self, cwd: &Path, strip_ambient_tokens: bool) -> Self {
if self.driver != Driver::Acp {
self.command = crate::native::newest_install(&self.command);
}
if is_copilot_provider(&self.id) {
self.env = sanitized_copilot_env(&self.env, strip_ambient_tokens);
}
let program = ide_jsonrpc::resolve_spawn_program(&self.command, &self.env);
self.command =
if program.components().count() > 1 && !program.is_absolute() { cwd.join(program) } else { program }
@@ -133,6 +141,69 @@ impl ProviderSpec {
}
}
const AMBIENT_COPILOT_AUTH_VARS: [&str; 3] = ["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"];
static PREFER_STORED_COPILOT_AUTH: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
pub(crate) fn is_copilot_provider(id: &str) -> bool {
id == "copilot" || id.starts_with("copilot-")
}
pub(crate) fn prefer_stored_copilot_auth() -> bool {
PREFER_STORED_COPILOT_AUTH.load(std::sync::atomic::Ordering::Acquire)
}
pub(crate) fn mark_prefer_stored_copilot_auth() {
PREFER_STORED_COPILOT_AUTH.store(true, std::sync::atomic::Ordering::Release);
}
pub(crate) fn has_unconfigured_copilot_ambient_token(spec_env: &[(String, String)]) -> bool {
AMBIENT_COPILOT_AUTH_VARS.iter().any(|&key| {
!spec_env.iter().any(|(k, v)| k == key && !v.is_empty())
&& ambient_copilot_env_value(key).is_some_and(|v| !v.is_empty())
})
}
fn ambient_copilot_env_value(key: &str) -> Option<String> {
ide_core::process::shell_env()
.find(|(k, _)| *k == key)
.map(|(_, v)| v.to_string_lossy().into_owned())
.or_else(|| ide_core::process::host_var_os(key).map(|v| v.to_string_lossy().into_owned()))
}
/// Builds child environment overrides for `copilot`:
/// - Always clears ambient classic PATs (`ghp_…`), which Copilot CLI unconditionally rejects
/// (`"Classic PATs are not supported"`) instead of falling back to Keychain / `gh` login.
/// - When `strip_ambient_tokens` is true, clears unconfigured ambient `GITHUB_TOKEN` / `GH_TOKEN` /
/// `COPILOT_GITHUB_TOKEN` so Copilot uses its stored login (`~/.copilot` or `gh auth` keyring),
/// while preserving any token explicitly configured in OpenIDEA's agent `spec_env`.
pub(crate) fn sanitized_copilot_env(
spec_env: &[(String, String)],
strip_ambient_tokens: bool,
) -> Vec<(String, String)> {
sanitize_copilot_env_with(spec_env, strip_ambient_tokens, ambient_copilot_env_value)
}
pub(crate) fn sanitize_copilot_env_with(
spec_env: &[(String, String)],
strip_ambient_tokens: bool,
lookup: impl Fn(&str) -> Option<String>,
) -> Vec<(String, String)> {
let mut out = Vec::with_capacity(spec_env.len() + AMBIENT_COPILOT_AUTH_VARS.len());
for key in AMBIENT_COPILOT_AUTH_VARS {
let explicit = spec_env.iter().any(|(k, v)| k == key && !v.is_empty());
if explicit {
continue;
}
if let Some(val) = lookup(key).filter(|v| !v.is_empty())
&& (strip_ambient_tokens || val.trim().starts_with("ghp_"))
{
out.push((key.to_owned(), String::new()));
}
}
out.extend(spec_env.iter().cloned());
out
}
fn render_login_hint(spec: &ProviderSpec, program: &Path, on_path: Option<&Path>, windows: bool) -> String {
let Some(builtin) = builtin_spec(&spec.id) else { return spec.login_hint.clone() };
let prefix = format!("Run `{}", builtin.command);
@@ -1101,4 +1172,23 @@ mod tests {
import_acp(dir.path(), None, "not json").unwrap_err();
assert!(import_acp(dir.path(), None, "{}").unwrap().is_empty());
}
#[test]
fn sanitizes_classic_pats_and_ambient_tokens_while_preserving_explicit_agent_env() {
let lookup = |k: &str| match k {
"GITHUB_TOKEN" => Some("ghp_classic_pat_123".into()),
"GH_TOKEN" => Some("gho_oauth_token_456".into()),
_ => None,
};
assert_eq!(sanitize_copilot_env_with(&[], false, lookup), vec![("GITHUB_TOKEN".into(), String::new())]);
assert_eq!(
sanitize_copilot_env_with(&[], true, lookup),
vec![("GH_TOKEN".into(), String::new()), ("GITHUB_TOKEN".into(), String::new())]
);
let explicit = vec![("GH_TOKEN".into(), "github_pat_custom".into())];
assert_eq!(
sanitize_copilot_env_with(&explicit, true, lookup),
vec![("GITHUB_TOKEN".into(), String::new()), ("GH_TOKEN".into(), "github_pat_custom".into())]
);
}
}
+1
Переглянути файл
@@ -25,6 +25,7 @@ fn paths(dir: &Path) -> AgentPaths {
custom_agents_dir: None,
home_dir: None,
env_secrets: None,
catalogs: Arc::default(),
}
}
+242 -65
Переглянути файл
@@ -405,6 +405,7 @@ fn manager_at(dir: &Path, models_file: &Path, agent: Arc<FakeAgent>) -> AgentMan
// user's profile, when temp lives under it) leaks into the test.
home_dir: Some(dir.to_path_buf()),
env_secrets: None,
catalogs: Arc::default(),
},
tokio::runtime::Handle::current(),
connector,
@@ -442,7 +443,7 @@ async fn saved_conversation_reads_do_not_start_or_load_the_agent() {
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread")]
async fn claude_commands_before_session_are_fetched_once_and_saved() {
async fn claude_commands_share_catalog_discovery_and_persist_results() {
use std::os::unix::fs::PermissionsExt;
for outcome in ["success", "session_report", "empty", "settings_timeout", "error", "exit", "timeout", "missing"] {
@@ -502,14 +503,18 @@ for line in sys.stdin:
std::fs::write(root.join(".claude/skills/triage/SKILL.md"), "---\nname: triage\ndescription: Triage\n---\n")
.unwrap();
let mgr = manager(root, Arc::new(FakeAgent::default()));
{
let mut providers = mgr.shared.providers.write();
let spec = providers.iter_mut().find(|p| p.id == "claude").unwrap();
if outcome != "missing" {
spec.command = script.to_string_lossy().into_owned();
}
spec.env = vec![("OUTCOME".into(), outcome.into())];
if outcome != "missing" {
provider::set_builtin_command(&root.join("agents"), None, "claude", Some(&script.to_string_lossy()))
.unwrap();
}
provider::set_builtin_env(
&root.join("agents"),
None,
"claude",
BTreeMap::from([("OUTCOME".into(), outcome.into())]),
)
.unwrap();
mgr.reload_providers();
let concurrent = async {
if outcome == "session_report" {
tokio::time::timeout(Duration::from_secs(10), async {
@@ -553,7 +558,6 @@ for line in sys.stdin:
effort.unwrap().current.as_deref(),
Some(match outcome {
"settings_timeout" => "default",
"session_report" => "low",
_ => "high",
})
);
@@ -571,6 +575,8 @@ for line in sys.stdin:
assert_eq!(launches.lines().count(), 1, "{outcome}");
let args: Vec<String> = serde_json::from_str(launches.trim()).unwrap();
assert!(args.windows(2).any(|a| a == ["--input-format", "stream-json"]));
assert!(args.iter().any(|a| a == "--strict-mcp-config"));
assert!(args.windows(2).any(|a| a == ["--mcp-config", "{\"mcpServers\":{}}"]));
let requests = std::fs::read_to_string(root.join("requests")).unwrap();
let subtypes: Vec<String> = requests
.lines()
@@ -1431,6 +1437,7 @@ async fn real_claude_task_in_a_worktree() {
custom_agents_dir: None,
home_dir: None,
env_secrets: None,
catalogs: Arc::default(),
},
tokio::runtime::Handle::current(),
);
@@ -2204,6 +2211,7 @@ async fn task_project_root_controls_local_and_worktree_repositories() {
.unwrap();
mgr.respond(&task.id, permission, Some("yes".into())).unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
count_every_change(&mgr, &task.id);
assert_eq!(task.project_root, Some(IpcPath(member.clone())));
assert_eq!(task.base.as_deref(), Some("member-main"));
if isolation == Isolation::Local {
@@ -2265,6 +2273,7 @@ async fn reload_providers_updates_registry_install_clears_stale_probes_and_notif
custom_agents_dir: Some(agents_dir.clone()),
home_dir: None,
env_secrets: None,
catalogs: Arc::default(),
},
tokio::runtime::Handle::current(),
connector,
@@ -2391,6 +2400,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() });
@@ -2576,6 +2606,7 @@ async fn delete_running_agent(no_load: bool) {
custom_agents_dir: None,
home_dir: None,
env_secrets: None,
catalogs: Arc::default(),
},
tokio::runtime::Handle::current(),
connector,
@@ -2638,34 +2669,6 @@ fn mcp_servers_follow_server_state_and_agent_capabilities() {
);
}
#[tokio::test(flavor = "multi_thread")]
async fn session_settings_win_over_discovery_in_memory_and_on_disk() {
for discovery_first in [false, true] {
let tmp = tempfile::tempdir().unwrap();
let mgr = manager(tmp.path(), Arc::new(FakeAgent::default()));
let options = |model: &str, effort: &str| {
config::options_of(&json!({ "configOptions": [
{ "id": "model", "category": "model", "type": "select", "currentValue": model,
"options": [{ "value": "saved" }, { "value": "discovered" }] },
{ "id": "effort", "category": "thought_level", "type": "select", "currentValue": effort,
"options": [{ "value": "low" }, { "value": "high" }] }
] }))
};
let saved = options("saved", "low");
let discovered = options("discovered", "high");
if discovery_first {
mgr.shared.remember_discovered("claude", &discovered, &[], &[]);
}
mgr.shared.remember("claude", Some(&saved), None, Some("saved"));
mgr.shared.remember_discovered("claude", &discovered, &[], &[]);
let restored = manager(tmp.path(), Arc::new(FakeAgent::default()));
for mgr in [&mgr, &restored] {
assert_eq!(model_of(&mgr.shared.known_config("claude")).as_deref(), Some("saved"));
assert_eq!(mgr.shared.known.lock()["claude"].thought_levels["saved"], saved[1..]);
}
}
}
#[tokio::test(flavor = "multi_thread")]
async fn provider_reload_does_not_start_acp_discovery() {
let agent = Arc::new(FakeAgent::default());
@@ -2752,31 +2755,6 @@ async fn discovery_times_out_when_session_new_hangs() {
assert!(!cursor.config.iter().any(|o| o.category.as_deref() == Some("model")));
}
#[tokio::test(flavor = "multi_thread")]
async fn discovered_effort_levels_fill_models_no_session_has_reported() {
let tmp = tempfile::tempdir().unwrap();
let mgr = manager(tmp.path(), Arc::new(FakeAgent::default()));
let effort = |values: &[&str], current: &str| {
config::options_of(&json!({ "configOptions": [
{ "id": "effort", "category": "thought_level", "type": "select", "currentValue": current,
"options": values.iter().map(|v| json!({ "value": v })).collect::<Vec<_>>() }
] }))
};
let model = config::options_of(&json!({ "configOptions": [
{ "id": "model", "category": "model", "type": "select", "currentValue": "sonnet",
"options": [{ "value": "sonnet" }, { "value": "opus" }] }
] }));
// A session already reported sonnet's levels; discovery adds opus's.
let reported = [model.clone(), effort(&["low", "high"], "high")].concat();
mgr.shared.remember("claude", Some(&reported), None, Some("sonnet"));
let levels =
[("sonnet".to_owned(), effort(&["low"], "low")), ("opus".to_owned(), effort(&["low", "max"], "default"))];
mgr.shared.remember_discovered("claude", &model, &[], &levels);
let known = mgr.shared.known.lock();
assert_eq!(known["claude"].thought_levels["sonnet"], effort(&["low", "high"], "high"), "the session's report wins");
assert_eq!(known["claude"].thought_levels["opus"], effort(&["low", "max"], "default"));
}
#[test]
fn streamed_text_is_sent_as_appends() {
let mut streamed = HashMap::new();
@@ -2865,12 +2843,14 @@ async fn rolling_back_restores_the_start_of_the_task_and_keeps_the_users_own_edi
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
mgr.cancel(&task.id, None).unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
// What the agent did.
// What the agent did, while its turn ran.
write("mine.txt", "my edit\nand the agent's\n");
write("other.txt", "agent\n");
write("new.txt", "agent\n");
mgr.cancel(&task.id, None).unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
// The user's (or another task's) work between turns is not the task's.
write("later.txt", "mine\n");
async fn changed(mgr: &AgentManager, id: &str) -> Vec<String> {
mgr.changes(id).await.unwrap().into_iter().map(|c| c.rel_path).collect()
}
@@ -2886,6 +2866,7 @@ async fn rolling_back_restores_the_start_of_the_task_and_keeps_the_users_own_edi
mgr.rollback_changes(&task.id, None, AgentChangesView::SinceStart).await.unwrap();
assert_eq!(read("mine.txt"), "my edit\n", "the user's edit stays");
assert!(!dir.join("new.txt").exists());
assert_eq!(read("later.txt"), "mine\n", "not the task's to roll back");
assert!(changed(&mgr, &task.id).await.is_empty());
mgr.delete(&task.id, false).await.unwrap();
@@ -2970,10 +2951,17 @@ impl Checkout {
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
mgr.cancel(&task.id, None).unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
count_every_change(&mgr, &task.id);
(mgr, task)
}
}
/// Counts every change in the checkout as the task's, as for a task saved before tasks
/// recorded what their turns changed: for tests that change files outside a turn.
fn count_every_change(mgr: &AgentManager, id: &str) {
mgr.task(id).unwrap().state.lock().touched = None;
}
async fn changed(mgr: &AgentManager, id: &str) -> Vec<String> {
mgr.changes(id).await.unwrap().into_iter().map(|c| c.rel_path).collect()
}
@@ -3250,3 +3238,192 @@ async fn security_policy_never_auto_approves_a_request_that_only_reads_like_a_co
"{entries:?}"
);
}
/// A temporary side conversation is created ready, without starting its session and
/// without writing anything to the store; a fresh subscriber (a reloaded page) sweeps
/// it, and deleting it just drops it from memory.
#[tokio::test(flavor = "multi_thread")]
async fn ephemeral_tasks_stay_in_memory_only() {
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent::default());
let mgr = manager(tmp.path(), agent.clone());
let source = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("hello"),
provider: "claude".into(),
config: vec![],
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &source.id, AgentStatus::NeedsInput).await;
let calls_before = agent.calls.lock().len();
let task = mgr
.create_ephemeral(
NewAgentTask {
project_root: None,
prompt: text("What changed in this file?"),
provider: "claude".into(),
config: vec![],
isolation: Isolation::Local,
base: None,
},
&source.id,
)
.await
.unwrap();
// Registering it starts nothing: the session waits for its first real message.
assert_eq!(agent.calls.lock().len(), calls_before);
assert_eq!(mgr.task(&task.id).unwrap().state.lock().task.status, AgentStatus::Ready);
assert!(!tmp.path().join("store").join(format!("{}.json", task.id)).exists());
// Its first message starts the session like any task's, and still saves nothing: the
// conversation records the question, not the context that rode along to the agent.
// The fake agent's turn asks to change something, which a side conversation denies by
// itself, so the turn ends without waiting for the user.
mgr.prompt(&task.id, text("Why?"), false).await.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
let entries = mgr.entries(&task.id, None).unwrap();
let question = entries.iter().find_map(|e| match e {
AgentEntry::User { text, .. } => Some(text.clone()),
_ => None,
});
assert_eq!(question.as_deref(), Some("Why?"));
assert!(
entries.iter().any(|e| matches!(e, AgentEntry::Permission { chosen: Some(c), .. } if c.contains("read-only"))),
"its `cargo test` request was denied: {entries:?}"
);
assert!(!tmp.path().join("store").join(format!("{}.json", task.id)).exists());
// A fresh subscriber (a reloaded page) sweeps it: it never reaches the task list.
let events: Arc<Mutex<Vec<AgentEvent>>> = Arc::new(Mutex::new(Vec::new()));
let sink_events = events.clone();
let tasks = mgr.subscribe(Arc::new(move |e| sink_events.lock().push(e)));
assert!(!tasks.iter().any(|t| t.id == task.id));
assert!(mgr.task(&task.id).is_err());
assert!(events.lock().iter().any(|e| matches!(e, AgentEvent::Removed { id } if *id == task.id)));
mgr.shutdown();
}
/// A side conversation's context stays within its budget: long tool outputs are cut to
/// their head and tail, and entries that no longer fit are dropped oldest first.
#[test]
fn side_conversation_context_is_bounded() {
let long_output = (0..400).map(|i| format!("line {i}")).collect::<Vec<_>>().join(
"
",
);
// Each reply alone is well inside the budget; together they are past it.
let reply = |n: usize| AgentEntry::Agent { text: format!("reply {n} {}", "x".repeat(30_000)) };
let entries = vec![
AgentEntry::User {
text: "oldest question".into(),
attachments: vec![],
images: vec![],
sent_ms: None,
done_ms: None,
steered: false,
},
reply(1),
reply(2),
reply(3),
reply(4),
AgentEntry::Tool {
id: "t1".into(),
title: "grep".into(),
kind: "search".into(),
status: AgentToolStatus::Completed,
locations: vec![],
output: Some(long_output),
diffs: vec![],
},
AgentEntry::User {
text: "newest question".into(),
attachments: vec![],
images: vec![],
sent_ms: None,
done_ms: None,
steered: false,
},
];
let transcript = context_transcript(&entries);
assert!(transcript.contains("newest question"), "the newest entry is always kept");
assert!(transcript.contains("lines omitted"), "a long tool output is trimmed");
assert!(transcript.contains("line 0") && transcript.contains("line 399"), "its head and tail stay");
assert!(!transcript.contains("oldest question"), "entries past the budget are dropped oldest first");
assert!(transcript.contains("oldest entries are omitted"));
}
/// An ephemeral task denies a permission request that would change the project.
#[tokio::test(flavor = "multi_thread")]
async fn ephemeral_tasks_deny_write_permissions() {
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent::default());
let mgr = manager(tmp.path(), agent.clone());
let source = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("Something with a conversation to reuse"),
provider: "claude".into(),
config: vec![],
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &source.id, AgentStatus::NeedsInput).await;
let pending = mgr
.entries(&source.id, None)
.unwrap()
.iter()
.find_map(|e| match e {
AgentEntry::Permission { id, chosen: None, .. } => Some(*id),
_ => None,
})
.unwrap_or_else(|| panic!("no pending permission request"));
mgr.respond(&source.id, pending, Some("no".into())).unwrap();
wait_for(&mgr, &source.id, AgentStatus::Ready).await;
let task = mgr
.create_ephemeral(
NewAgentTask {
project_root: None,
prompt: text("What changed in this file?"),
provider: "claude".into(),
config: vec![],
isolation: Isolation::Local,
base: None,
},
&source.id,
)
.await
.unwrap();
assert!(mgr.task(&task.id).unwrap().state.lock().task.capabilities.read_only);
let answer = mgr
.task(&task.id)
.unwrap()
.ask_permission(&json!({
"sessionId": "c1",
"toolCall": {"toolCallId": "c1-t2", "title": "Edit main.rs", "kind": "edit"},
"options": [
{"optionId": "yes", "name": "Allow", "kind": "allow_once"},
{"optionId": "no", "name": "Reject", "kind": "reject_once"}
]
}))
.pointer("/outcome/optionId")
.and_then(Value::as_str)
.unwrap_or("none")
.to_owned();
assert_eq!(answer, "no", "the write request is denied");
let entries = mgr.entries(&task.id, None).unwrap();
assert!(
entries
.iter()
.any(|e| matches!(e, AgentEntry::Permission { chosen: Some(chosen), .. } if chosen.contains("read-only"))),
"{entries:?}"
);
mgr.shutdown();
}
+36 -1
Переглянути файл
@@ -3,7 +3,7 @@
use crate::model::{AgentChange, AgentChanges, AgentCommit};
use ide_core::{IdeError, IdeResult, IpcPath};
use ide_vcs::{ChangeKind, LogQuery, Repo};
use std::collections::BTreeMap;
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use std::time::{Duration, UNIX_EPOCH};
@@ -548,6 +548,25 @@ pub(crate) fn snapshot(cwd: &Path) -> IdeResult<BTreeMap<String, Stamp>> {
Ok(repo.status()?.into_iter().map(|s| (s.rel_path, stamp(&s.path))).collect())
}
/// The files whose state changed between two snapshots of a checkout: edited, new, deleted,
/// or back to as committed.
pub(crate) fn changed_between(start: &BTreeMap<String, Stamp>, end: &BTreeMap<String, Stamp>) -> Vec<String> {
let mut changed: Vec<String> =
end.iter().filter(|(path, stamp)| start.get(*path) != Some(*stamp)).map(|(path, _)| path.clone()).collect();
changed.extend(start.keys().filter(|path| !end.contains_key(*path)).cloned());
changed
}
/// The changes among `changes` that the task made, when it records the files its turns
/// changed (`None`: every change counts).
pub(crate) fn made_by_task(changes: Vec<AgentChange>, touched: Option<&BTreeSet<String>>) -> Vec<AgentChange> {
let Some(touched) = touched else { return changes };
changes
.into_iter()
.filter(|c| touched.contains(&c.rel_path) || c.old_path.as_ref().is_some_and(|old| touched.contains(old)))
.collect()
}
/// Drops the changes that were there before the task and haven't been touched since.
pub(crate) fn without_untouched(changes: Vec<AgentChange>, before: &BTreeMap<String, Stamp>) -> Vec<AgentChange> {
changes.into_iter().filter(|c| before.get(&c.rel_path).is_none_or(|s| *s != stamp(&c.path))).collect()
@@ -1184,4 +1203,20 @@ mod tests {
assert_eq!(std::fs::read_to_string(repo.join("a.txt")).unwrap(), "one\nMINE\n");
assert_eq!(std::fs::read_to_string(repo.join("gone.txt")).unwrap(), "bye\nbye\n");
}
#[test]
fn a_turn_changed_the_files_whose_state_moved() {
let start: BTreeMap<String, Stamp> =
[("edited", Some((1, 1))), ("same", Some((2, 2))), ("reverted", Some((3, 3)))]
.into_iter()
.map(|(p, s)| (p.to_owned(), s))
.collect();
let end: BTreeMap<String, Stamp> = [("edited", Some((1, 9))), ("same", Some((2, 2))), ("new", Some((4, 4)))]
.into_iter()
.map(|(p, s)| (p.to_owned(), s))
.collect();
let mut changed = changed_between(&start, &end);
changed.sort();
assert_eq!(changed, ["edited", "new", "reverted"]);
}
}
+51 -1
Переглянути файл
@@ -53,6 +53,8 @@ pub enum BuildSystem {
TypeScript,
#[serde(rename = "cmake")]
CMake,
#[serde(rename = "dotnet")]
Dotnet,
}
/// One command of a build.
@@ -165,6 +167,8 @@ fn detect_dir(dir: &Path, model: &BuildModel, root: &Path) -> Option<BuildSystem
|| has(dir, &["build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts"])
{
Some(BuildSystem::Gradle)
} else if dotnet_target(dir).is_some() {
Some(BuildSystem::Dotnet)
} else if has(dir, &["Cargo.toml"]) {
Some(BuildSystem::Cargo)
} else if has(dir, &["go.mod", "go.work"]) {
@@ -225,7 +229,7 @@ pub fn plan(
.or((!model.gradle.is_empty()).then_some(BuildSystem::Gradle))
.or_else(|| model.maven.as_ref().filter(|m| !m.projects.is_empty()).map(|_| BuildSystem::Maven))
.ok_or_else(|| {
IdeError::not_found("no supported build system (Maven, Gradle, Cargo, Go, TypeScript, CMake) found")
IdeError::not_found("no supported build system (Maven, Gradle, .NET, Cargo, Go, TypeScript, CMake) found")
})?;
let module = module_dir.as_deref().filter(|d| *d != root);
let title = match module {
@@ -244,6 +248,7 @@ pub fn plan(
BuildSystem::Go => go_steps(file.and_then(Path::parent).unwrap_or(&dir), kind)?,
BuildSystem::TypeScript => ts_steps(&dir, kind),
BuildSystem::CMake => cmake_steps(&dir, kind, cmake.and_then(<[_]>::first)),
BuildSystem::Dotnet => dotnet_steps(&dir, kind)?,
};
Ok(BuildPlan { system, title, steps })
}
@@ -451,6 +456,33 @@ fn has_build_script(dir: &Path) -> bool {
.is_some_and(|v| v.pointer("/scripts/build").is_some_and(serde_json::Value::is_string))
}
/// The solution (`.sln` / `.slnx`) or project (`.csproj` / `.fsproj` / `.vbproj`) file `dir`
/// builds: a solution first, then a project; the first by name when there are several.
fn dotnet_target(dir: &Path) -> Option<PathBuf> {
let mut files: Vec<PathBuf> =
std::fs::read_dir(dir).ok()?.flatten().map(|e| e.path()).filter(|p| p.is_file()).collect();
files.sort();
let ext = |p: &Path| p.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
let solution = files.iter().find(|p| matches!(ext(p).as_str(), "sln" | "slnx"));
solution.or_else(|| files.iter().find(|p| matches!(ext(p).as_str(), "csproj" | "fsproj" | "vbproj"))).cloned()
}
/// `dotnet build` of the directory's solution or project; Rebuild skips incremental builds,
/// Build Artifacts publishes. The summary that repeats every message is left out.
fn dotnet_steps(dir: &Path, kind: BuildKind) -> IdeResult<Vec<BuildStep>> {
let target = dotnet_target(dir).ok_or_else(|| IdeError::not_found("no .NET solution or project found"))?;
let target = target.file_name().map_or_else(String::new, |n| n.to_string_lossy().into_owned());
let verb = if kind == BuildKind::Artifacts { "publish" } else { "build" };
let mut step = BuildStep::new("dotnet", &[verb, &target, "-nologo", "-clp:NoSummary"], dir, Format::Msbuild);
if kind == BuildKind::Rebuild {
step.args.push("--no-incremental".into());
}
// The classic console logger prints the canonical `file(line,col): error` lines; the
// terminal logger (.NET 8+) only appears on a terminal, but rules it out for older SDKs too.
step.env.insert("MSBUILDTERMINALLOGGER".into(), "off".into());
Ok(vec![step])
}
fn ts_steps(dir: &Path, kind: BuildKind) -> Vec<BuildStep> {
let script = has_build_script(dir);
if script && (kind == BuildKind::Artifacts || !dir.join("tsconfig.json").is_file()) {
@@ -1141,6 +1173,24 @@ mod tests {
("pnpm", &["run".to_owned(), "build".to_owned()][..])
);
// .NET: the solution, else the project; a module build is the file's project. An ASP.NET
// project's package.json (its front end) doesn't make it a TypeScript build.
let net = tmp.path().join("net");
write(&net, "App.sln", "");
write(&net, "src/Api/Api.csproj", "<Project/>");
write(&net, "src/Api/package.json", "{}");
write(&net, "src/Api/Program.cs", "");
let p = plan(&net, &BuildModel::default(), BuildKind::Build, None, None).unwrap();
assert_eq!(p.system, BuildSystem::Dotnet);
assert_eq!(p.steps[0].args, ["build", "App.sln", "-nologo", "-clp:NoSummary"]);
assert_eq!(p.steps[0].env["MSBUILDTERMINALLOGGER"], "off");
let p = plan(&net, &BuildModel::default(), BuildKind::Rebuild, Some(&net.join("src/Api/Program.cs")), None)
.unwrap();
assert_eq!(p.steps[0].args, ["build", "Api.csproj", "-nologo", "-clp:NoSummary", "--no-incremental"]);
assert_eq!(p.steps[0].cwd, net.join("src/Api"));
let p = plan(&net, &BuildModel::default(), BuildKind::Artifacts, None, None).unwrap();
assert_eq!(p.steps[0].args[0], "publish");
let cm = tmp.path().join("cm");
write(&cm, "CMakeLists.txt", "project(app)");
let args = |p: &BuildPlan| p.steps.iter().map(|s| s.args.join(" ")).collect::<Vec<_>>();
+84 -4
Переглянути файл
@@ -25,6 +25,8 @@ pub(crate) enum Format {
Go,
/// GCC / Clang (through `cmake --build`): `src/a.cpp:12:5: error: msg`.
Gcc,
/// MSBuild (`dotnet build`): `C:\p\A.cs(12,5): error CS1002: msg [C:\p\App.csproj]`.
Msbuild,
}
/// What a printed column counts.
@@ -144,6 +146,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 +171,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 +229,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);
@@ -263,6 +270,43 @@ fn tsc_line(line: &str) -> Option<RawMessage> {
Some(m)
}
/// MSBuild's canonical form: `path(12,5): error CS1002: msg [project]`, `path(12,5,12,9): …`,
/// or `path : error NU1101: msg [project]` for a project-level message (no position).
/// The path may contain spaces (`C:\Users\Jane Doe\…`); "error" / "warning" are never localized.
fn msbuild_line(line: &str) -> Option<RawMessage> {
let line = line.trim();
// The project the message came from, appended by the console logger.
let line = match line.rfind(" [") {
Some(i) if line.ends_with(']') => &line[..i],
_ => line,
};
let (head, severity, rest) =
[(" error ", Severity::Error), (" warning ", Severity::Warning)].into_iter().find_map(|(word, severity)| {
let i = line.find(&format!(":{word}"))?;
Some((&line[..i], severity, &line[i + word.len() + 1..]))
})?;
let (code, msg) = rest.split_once(':')?;
let code = code.trim();
if code.is_empty() || code.contains(' ') {
return None;
}
let head = head.trim_end();
let (path, pos) = match head.strip_suffix(')').and_then(|h| h.rsplit_once('(')) {
Some((path, pos)) => (path, pos.split(',').map(num).collect::<Vec<_>>()),
None => (head, Vec::new()),
};
let at = |i: usize| pos.get(i).copied().flatten();
// `MSBUILD : error MSB1009` and the like name no file.
let file = path.rsplit(['/', '\\']).next().is_some_and(|n| n.contains('.'));
let mut m = RawMessage::new(severity, msg.trim(), ColUnit::Utf16);
if file {
m.path = Some(path.trim().to_owned());
(m.line, m.col, m.end_line, m.end_col) = (at(0), at(1), at(2), at(3));
}
m.code = Some(code.to_owned());
Some(m)
}
/// go: `./a.go:12:5: msg` (errors only; `go vet` style `a.go:12: msg` too).
fn go_line(line: &str) -> Option<RawMessage> {
let (path, ln, col, rest) = path_line_col(line)?;
@@ -399,6 +443,7 @@ impl OutputParser {
Format::Tsc => tsc_line(&line),
Format::Go => go_line(&line),
Format::Gcc => gcc_line(&line),
Format::Msbuild => msbuild_line(&line),
Format::Cargo => None,
};
if let Some(msg) = parsed {
@@ -456,6 +501,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(
@@ -532,6 +591,27 @@ mod tests {
assert!(plain.messages.is_empty() && plain.console.is_none());
}
#[test]
fn msbuild_canonical_messages() {
let out = parse(
Format::Msbuild,
" Determining projects to restore...\n\
C:\\Users\\Jane Doe\\App\\Program.cs(12,5): error CS1002: ; expected [C:\\Users\\Jane Doe\\App\\App.csproj]\n\
/p/App/Models/User.cs(3,8,3,12): warning CS0168: The variable 'e' is declared but never used [/p/App/App.csproj]\n\
/p/App/App.csproj : error NU1101: Unable to find package Foo. [/p/App/App.csproj]\n\
MSBUILD : error MSB1009: Project file does not exist.\n\
Build FAILED.\n",
);
assert_eq!(out.len(), 4, "{out:?}");
assert_eq!(pos(&out[0]), (Severity::Error, "C:\\Users\\Jane Doe\\App\\Program.cs", 12, Some(5)));
assert_eq!((out[0].code.as_deref(), out[0].text.as_str()), (Some("CS1002"), "; expected"));
assert_eq!(pos(&out[1]), (Severity::Warning, "/p/App/Models/User.cs", 3, Some(8)));
assert_eq!((out[1].end_line, out[1].end_col), (Some(3), Some(12)));
assert_eq!((out[2].path.as_deref(), out[2].line), (Some("/p/App/App.csproj"), None));
assert_eq!(out[2].text, "Unable to find package Foo.");
assert_eq!((out[3].path.as_deref(), out[3].code.as_deref()), (None, Some("MSB1009")));
}
#[test]
fn tsc_plain_and_pretty() {
let out = parse(
+9 -3
Переглянути файл
@@ -1,6 +1,6 @@
[package]
name = "ide-clipboard"
description = "The OS clipboard's file list (Explorer <-> project view): Windows CF_HDROP or macOS NSPasteboard file URLs, no window or message loop"
description = "The OS clipboard's file list (Explorer <-> project view) and pasted images: Windows CF_HDROP / PNG / CF_DIB or macOS NSPasteboard, no window or message loop"
version.workspace = true
edition.workspace = true
license.workspace = true
@@ -9,8 +9,14 @@ license.workspace = true
[target.'cfg(target_os = "macos")'.dependencies]
objc2 = { version = "0.6", default-features = false, features = ["std"] }
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSDictionary", "NSEnumerator", "NSObject", "NSString", "NSURL", "NSValue"] }
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSPasteboard"] }
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSData", "NSDictionary", "NSEnumerator", "NSObject", "NSString", "NSURL", "NSValue"] }
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSBitmapImageRep", "NSImageRep", "NSPasteboard"] }
[target.'cfg(windows)'.dependencies]
png = "0.18"
[target.'cfg(target_os = "linux")'.dependencies]
gtk = "0.18"
[dev-dependencies]
png = "0.18"
+222 -3
Переглянути файл
@@ -18,6 +18,10 @@
//!
//! Other platforms (`text/uri-list` on X11) are not wired up: [`files`] is
//! empty and [`set_files`] fails there.
//!
//! [`png`] reads an image (a screenshot) for the project view's Paste, which
//! saves it as a file: `public.png` or `public.tiff` on macOS, the registered
//! `PNG` format or `CF_DIB` on Windows. Linux reads it through GTK in the app.
use std::path::PathBuf;
@@ -34,6 +38,19 @@ pub fn text() -> Option<String> {
imp::text()
}
/// The clipboard's image as PNG bytes, None when it holds none. Call on the main
/// thread on macOS.
#[cfg(any(windows, target_os = "macos"))]
pub fn png() -> Option<Vec<u8>> {
imp::png()
}
/// Whether the clipboard holds an image, without reading it. Call on the main
/// thread on macOS and Linux.
pub fn has_image() -> bool {
imp::has_image()
}
/// Size of `DROPFILES`: `pFiles`, `pt` (two `LONG`s), `fNC`, `fWide`.
const HEADER: usize = 20;
@@ -134,9 +151,62 @@ pub fn decode(bytes: &[u8]) -> Vec<PathBuf> {
out
}
/// `BI_RGB` and `BI_BITFIELDS`: uncompressed pixels, the forms screenshots take.
#[cfg(any(windows, test))]
const BI_RGB: u32 = 0;
#[cfg(any(windows, test))]
const BI_BITFIELDS: u32 = 3;
/// A packed DIB (`BITMAPINFOHEADER` or a later header, then the pixels) as PNG.
/// Only 24- and 32-bit uncompressed bitmaps, which is what screenshots and
/// copied images are; None for anything else. A 32-bit bitmap whose alpha is
/// all zero (most writers leave it unset) is opaque.
#[cfg(any(windows, test))]
fn dib_to_png(dib: &[u8]) -> Option<Vec<u8>> {
let u32_at = |at: usize| dib.get(at..at + 4).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]));
let header = u32_at(0)? as usize;
let width = u32_at(4)? as i32;
let height = u32_at(8)? as i32;
let bits = u16::from_le_bytes([*dib.get(14)?, *dib.get(15)?]);
let compression = u32_at(16)?;
if header < 40 || width <= 0 || height == 0 || !matches!(bits, 24 | 32) {
return None;
}
if compression != BI_RGB && !(compression == BI_BITFIELDS && bits == 32) {
return None;
}
// A `BITMAPINFOHEADER` keeps the three bitfield masks after it; later
// headers hold them inside. Masks other than BGRA's are not supported.
let masks = if compression == BI_BITFIELDS && header == 40 { 12 } else { 0 };
let colors = u32_at(32)? as usize;
let (width, rows) = (width as usize, height.unsigned_abs() as usize);
let stride = (width * bits as usize).div_ceil(32) * 4;
let start = header + masks + colors * 4;
let pixels = dib.get(start..start.checked_add(stride.checked_mul(rows)?)?)?;
let step = bits as usize / 8;
let mut rgba = Vec::with_capacity(width * rows * 4);
for row in 0..rows {
// A positive height stores the bottom row first.
let source = if height > 0 { rows - 1 - row } else { row };
let line = &pixels[source * stride..source * stride + width * step];
for px in line.chunks_exact(step) {
rgba.extend_from_slice(&[px[2], px[1], px[0], if step == 4 { px[3] } else { 255 }]);
}
}
if step == 4 && rgba.as_chunks::<4>().0.iter().all(|px| px[3] == 0) {
rgba.as_chunks_mut::<4>().0.iter_mut().for_each(|px| px[3] = 255);
}
let mut out = Vec::new();
let mut encoder = png::Encoder::new(&mut out, width as u32, rows as u32);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
encoder.write_header().ok()?.write_image_data(&rgba).ok()?;
Some(out)
}
#[cfg(windows)]
mod imp {
use super::{HEADER, decode, encode, names_text};
use super::{HEADER, decode, dib_to_png, encode, names_text};
use std::ffi::c_void;
use std::path::PathBuf;
use std::ptr;
@@ -164,6 +234,59 @@ mod imp {
fn GetClipboardData(format: u32) -> Handle;
fn SetClipboardData(format: u32, memory: Handle) -> Handle;
fn IsClipboardFormatAvailable(format: u32) -> i32;
fn RegisterClipboardFormatW(name: *const u16) -> u32;
}
/// `CF_DIB`: Windows synthesizes it from `CF_BITMAP` and `CF_DIBV5`, so every
/// bitmap on the clipboard reads as one.
const CF_DIB: u32 = 8;
/// The `PNG` format browsers and the Snipping Tool add beside the bitmap; it
/// keeps transparency, so it is read first.
fn png_format() -> u32 {
let name: Vec<u16> = "PNG\0".encode_utf16().collect();
unsafe { RegisterClipboardFormatW(name.as_ptr()) }
}
pub fn has_image() -> bool {
let png = png_format();
(png != 0 && unsafe { IsClipboardFormatAvailable(png) } != 0)
|| unsafe { IsClipboardFormatAvailable(CF_DIB) } != 0
}
/// A copy of the clipboard's `format` block. Call with the clipboard open.
fn read_block(format: u32) -> Option<Vec<u8>> {
// SAFETY: the clipboard keeps the block alive while it is open, and
// `GlobalSize` bounds the bytes read from it.
unsafe {
let memory = GetClipboardData(format);
if memory.is_null() {
return None;
}
let data = GlobalLock(memory);
if data.is_null() {
return None;
}
let bytes = std::slice::from_raw_parts(data as *const u8, GlobalSize(memory)).to_vec();
GlobalUnlock(memory);
Some(bytes)
}
}
pub fn png() -> Option<Vec<u8>> {
if !open() {
return None;
}
let png = png_format();
let image = if png != 0 && unsafe { IsClipboardFormatAvailable(png) } != 0 {
read_block(png)
} else if unsafe { IsClipboardFormatAvailable(CF_DIB) } != 0 {
read_block(CF_DIB).and_then(|dib| dib_to_png(&dib))
} else {
None
};
unsafe { CloseClipboard() };
image
}
#[link(name = "kernel32")]
@@ -327,8 +450,8 @@ mod imp {
use objc2::runtime::{AnyClass, AnyObject, ProtocolObject};
use objc2::{ClassType, MainThreadMarker};
use objc2_app_kit::{
NSPasteboard, NSPasteboardReadingOptionKey, NSPasteboardTypeString, NSPasteboardURLReadingFileURLsOnlyKey,
NSPasteboardWriting,
NSBitmapImageFileType, NSBitmapImageRep, NSPasteboard, NSPasteboardReadingOptionKey, NSPasteboardTypePNG,
NSPasteboardTypeString, NSPasteboardTypeTIFF, NSPasteboardURLReadingFileURLsOnlyKey, NSPasteboardWriting,
};
use objc2_foundation::{NSArray, NSDictionary, NSNumber, NSString, NSURL};
use std::path::PathBuf;
@@ -380,6 +503,33 @@ mod imp {
})
}
pub fn has_image() -> bool {
debug_assert!(MainThreadMarker::new().is_some(), "NSPasteboard access requires the main thread");
autoreleasepool(|_| {
// SAFETY: the types are AppKit constants, valid for the process' lifetime.
let types = NSArray::from_slice(&[unsafe { NSPasteboardTypePNG }, unsafe { NSPasteboardTypeTIFF }]);
NSPasteboard::generalPasteboard().availableTypeFromArray(&types).is_some()
})
}
/// PNG as copied, else TIFF (what macOS screenshots and most apps write) converted.
pub fn png() -> Option<Vec<u8>> {
debug_assert!(MainThreadMarker::new().is_some(), "NSPasteboard access requires the main thread");
autoreleasepool(|_| {
let board = NSPasteboard::generalPasteboard();
// SAFETY: the types are AppKit constants, valid for the process' lifetime.
if let Some(png) = board.dataForType(unsafe { NSPasteboardTypePNG }) {
return Some(png.to_vec());
}
let tiff = board.dataForType(unsafe { NSPasteboardTypeTIFF })?;
let image = NSBitmapImageRep::imageRepWithData(&tiff)?;
// SAFETY: an empty property dictionary is valid for every file type.
let png =
unsafe { image.representationUsingType_properties(NSBitmapImageFileType::PNG, &NSDictionary::new()) }?;
Some(png.to_vec())
})
}
pub fn text() -> Option<String> {
debug_assert!(MainThreadMarker::new().is_some(), "NSPasteboard access requires the main thread");
autoreleasepool(|_| {
@@ -448,6 +598,17 @@ mod imp {
false
}
pub fn has_image() -> bool {
#[cfg(target_os = "linux")]
{
gtk::Clipboard::get(&gtk::gdk::SELECTION_CLIPBOARD).wait_is_image_available()
}
#[cfg(not(target_os = "linux"))]
{
false
}
}
pub fn files() -> std::io::Result<Vec<PathBuf>> {
Ok(Vec::new())
}
@@ -521,4 +682,62 @@ mod tests {
bytes.extend_from_slice(b"C:\\a\\b.txt\0C:\\c\0\0");
assert_eq!(decode(&bytes), paths(&["C:\\a\\b.txt", "C:\\c"]));
}
/// A `BITMAPINFOHEADER` DIB of `width` × `height` (positive: bottom-up).
fn dib(width: i32, height: i32, bits: u16, compression: u32, rows: &[&[u8]]) -> Vec<u8> {
let mut out = Vec::new();
out.extend_from_slice(&40u32.to_le_bytes());
out.extend_from_slice(&width.to_le_bytes());
out.extend_from_slice(&height.to_le_bytes());
out.extend_from_slice(&1u16.to_le_bytes());
out.extend_from_slice(&bits.to_le_bytes());
out.extend_from_slice(&compression.to_le_bytes());
out.extend_from_slice(&[0u8; 20]);
if compression == super::BI_BITFIELDS {
for mask in [0x00ff_0000u32, 0x0000_ff00, 0x0000_00ff] {
out.extend_from_slice(&mask.to_le_bytes());
}
}
for row in rows {
out.extend_from_slice(row);
}
out
}
fn decode_png(png: &[u8]) -> (u32, u32, Vec<u8>) {
let mut reader = png::Decoder::new(std::io::Cursor::new(png)).read_info().unwrap();
let mut buf = vec![0; reader.output_buffer_size().unwrap()];
let info = reader.next_frame(&mut buf).unwrap();
buf.truncate(info.buffer_size());
(info.width, info.height, buf)
}
#[test]
fn a_bottom_up_24_bit_bitmap_becomes_an_upright_opaque_png() {
// Rows are padded to 4 bytes; the first stored row is the bottom one.
let bottom: &[u8] = &[255, 0, 0, 0, 255, 0, 0, 0];
let top: &[u8] = &[0, 0, 255, 255, 255, 255, 0, 0];
let png = super::dib_to_png(&dib(2, 2, 24, super::BI_RGB, &[bottom, top])).unwrap();
let (w, h, rgba) = decode_png(&png);
assert_eq!((w, h), (2, 2));
assert_eq!(rgba, [255, 0, 0, 255, 255, 255, 255, 255, 0, 0, 255, 255, 0, 255, 0, 255]);
}
#[test]
fn a_32_bit_bitmap_keeps_its_alpha_unless_none_was_written() {
let row: &[u8] = &[10, 20, 30, 0, 40, 50, 60, 0];
let (_, _, rgba) = decode_png(&super::dib_to_png(&dib(2, -1, 32, super::BI_BITFIELDS, &[row])).unwrap());
assert_eq!(rgba, [30, 20, 10, 255, 60, 50, 40, 255]);
let row: &[u8] = &[10, 20, 30, 128, 40, 50, 60, 0];
let (_, _, rgba) = decode_png(&super::dib_to_png(&dib(2, -1, 32, super::BI_RGB, &[row])).unwrap());
assert_eq!(rgba, [30, 20, 10, 128, 60, 50, 40, 0]);
}
#[test]
fn unsupported_or_truncated_bitmaps_are_not_images() {
let row: &[u8] = &[1, 2, 3, 4];
assert!(super::dib_to_png(&dib(4, 1, 8, super::BI_RGB, &[row])).is_none());
assert!(super::dib_to_png(&dib(2, 2, 24, super::BI_RGB, &[row])).is_none());
assert!(super::dib_to_png(&[0; 10]).is_none());
}
}
+3
Переглянути файл
@@ -270,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,
@@ -289,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,
+54
Переглянути файл
@@ -118,6 +118,10 @@ pub enum IdeEvent {
LanguageServer {
server: ServerId,
name: String,
/// What the user sees as the server when another program serves in its place.
#[serde(default)]
#[specta(optional)]
label: Option<String>,
status: ServerStatus,
message: Option<String>,
},
@@ -291,6 +295,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).
+9 -1
Переглянути файл
@@ -1,8 +1,10 @@
//! JSON with comments (`devcontainer.json`, `deno.jsonc`, `tsconfig.json`): turned
//! into plain JSON for `serde_json`.
/// Strips `//` and `/* */` comments and trailing commas so JSONC parses as JSON.
/// Strips `//` and `/* */` comments, trailing commas and a leading byte order mark (Visual
/// Studio writes one into `launchSettings.json`) so JSONC parses as JSON.
pub fn strip(src: &str) -> String {
let src = src.strip_prefix('\u{feff}').unwrap_or(src);
let mut out = String::with_capacity(src.len());
let mut chars = src.chars().peekable();
let mut in_str = false;
@@ -87,4 +89,10 @@ mod tests {
assert_eq!(v["url"], "https://x.dev//y /* no */");
assert_eq!(v["q"], "say \"hi\",");
}
#[test]
fn drops_a_byte_order_mark() {
let v: serde_json::Value = serde_json::from_str(&strip("\u{feff}{ \"a\": 1 }")).unwrap();
assert_eq!(v["a"], 1);
}
}
+24
Переглянути файл
@@ -141,6 +141,30 @@ pub fn process_tree(pid: i32) -> Vec<i32> {
tree
}
/// Every process `pid` started, directly or not, parents before their children.
pub fn descendants(pid: u32) -> Vec<u32> {
#[cfg(unix)]
{
let Ok(root) = i32::try_from(pid) else { return Vec::new() };
process_tree(root).into_iter().skip(1).filter_map(|p| u32::try_from(p).ok()).collect()
}
#[cfg(windows)]
{
use std::os::windows::io::AsRawHandle;
windows_tree::descendants(pid)
.iter()
// SAFETY: a live process handle opened with query access.
.map(|h| unsafe { windows_sys::Win32::System::Threading::GetProcessId(h.as_raw_handle()) })
.filter(|&p| p != 0)
.collect()
}
#[cfg(not(any(unix, windows)))]
{
let _ = pid;
Vec::new()
}
}
/// [`kill_tree`] that first asks the group to exit: `SIGTERM`, then `grace`, then `SIGKILL`.
/// Git removes its `index.lock` on `SIGTERM`, where `SIGKILL` leaves it behind and the next
/// git command fails. Windows has no such signal to send from outside the child's console,
+93 -3
Переглянути файл
@@ -42,6 +42,8 @@ pub struct Settings {
pub terminal: TerminalSettings,
pub actions_on_save: ActionsOnSaveSettings,
pub external_tools: Vec<ExternalTool>,
/// Tools › Diff & Merge › External Diff Tools.
pub external_diff: ExternalDiffSettings,
/// Language servers / debug adapters at a user-chosen location, by the
/// command the language definition launches (`elixir-ls`, `codelldb`).
pub tools: BTreeMap<String, ToolSettings>,
@@ -86,6 +88,18 @@ pub struct AgentSettings {
pub fold_turns: bool,
/// Streaming text is revealed at a steady pace rather than chunk by chunk.
pub smooth_text: bool,
/// Conversations and the composer expand to the full width of the pane instead of staying in a centred column.
pub fit_window_width: bool,
/// While the agent works, playful words fill the pauses in what it reports.
pub playful_status: bool,
/// Seconds the agent reports nothing new before the playful words take over; 0 shows them
/// from the start.
pub playful_after_seconds: u32,
/// The user's playful words by activity (`working`, `thinking`, `reading`, …), replacing
/// the built-in ones of that activity; an empty list keeps its status factual.
#[serde(deserialize_with = "or_default")]
#[specta(type = BTreeMap<String, Vec<String>>)]
pub playful_words: BTreeMap<String, Vec<String>>,
#[serde(deserialize_with = "or_default")]
#[specta(type = ThinkingDisplay)]
pub thinking: ThinkingDisplay,
@@ -127,6 +141,10 @@ impl Default for AgentSettings {
show_cost: false,
fold_turns: true,
smooth_text: true,
fit_window_width: false,
playful_status: false,
playful_after_seconds: 0,
playful_words: BTreeMap::new(),
thinking: ThinkingDisplay::Auto,
tool_calls: ToolCallDisplay::Grouped,
expand_tool_output: false,
@@ -291,7 +309,7 @@ pub enum NewTaskApprovals {
#[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.
@@ -300,6 +318,9 @@ pub struct McpServerSettings {
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,
@@ -312,6 +333,8 @@ pub struct McpServerSettings {
/// 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 {
@@ -320,6 +343,7 @@ impl Default for McpServerSettings {
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,
@@ -460,6 +484,7 @@ impl Default for Settings {
debugger: DebuggerSettings::default(),
run: RunSettings::default(),
terminal: TerminalSettings::default(),
external_diff: ExternalDiffSettings::default(),
actions_on_save: ActionsOnSaveSettings::default(),
external_tools: Vec::new(),
tools: BTreeMap::new(),
@@ -673,6 +698,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`,
@@ -743,6 +790,7 @@ impl Default for AppearanceSettings {
navigation_bar: NavigationBarPosition::StatusBar,
status_bar_widgets: BTreeMap::new(),
main_toolbar: None,
background_image: BackgroundImageSettings::default(),
}
}
}
@@ -941,6 +989,7 @@ pub struct LanguagesSettings {
pub ruby: RubySettings,
pub markdown: MarkdownSettings,
pub arduino: ArduinoSettings,
pub zig: ZigSettings,
}
impl Default for LanguagesSettings {
@@ -963,6 +1012,7 @@ impl Default for LanguagesSettings {
ruby: RubySettings::default(),
markdown: MarkdownSettings::default(),
arduino: ArduinoSettings::default(),
zig: ZigSettings::default(),
}
}
}
@@ -1063,7 +1113,8 @@ impl Default for ExternalFormatterSettings {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct PythonSettings {
/// Project interpreter executable; empty = auto-detect (project venv, else `python3`).
/// Project interpreter executable; empty = auto-detect (project venv, else `python3`);
/// `target:<run-target-id>` = the Python of a Docker or Compose run target.
pub interpreter: String,
/// Activate the interpreter's virtualenv in new terminal sessions.
pub activate_virtualenv: bool,
@@ -1092,6 +1143,17 @@ impl Default for PythonSettings {
}
}
impl PythonSettings {
/// What `interpreter` starts with when the project interpreter is a run target's.
pub const TARGET_PREFIX: &str = "target:";
/// The run target whose Python is the project interpreter (`target:<id>`), if one is.
pub fn run_target(&self) -> Option<&str> {
let id = self.interpreter.trim().strip_prefix(Self::TARGET_PREFIX)?.trim();
(!id.is_empty()).then_some(id)
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct PhpSettings {
@@ -1130,6 +1192,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)]
@@ -1159,6 +1224,13 @@ impl Default for RustSettings {
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct ZigSettings {
/// Directory holding the `zig` executable; empty = the one on `PATH`.
pub zig: String,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct GoSettings {
@@ -1326,11 +1398,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 }
}
}
@@ -1506,6 +1582,16 @@ impl Settings {
mod tests {
use super::*;
#[test]
fn python_interpreter_may_name_a_run_target() {
let python = |interpreter: &str| PythonSettings { interpreter: interpreter.into(), ..Default::default() };
assert_eq!(python("target:web").run_target(), Some("web"));
assert_eq!(python(" target: web ").run_target(), Some("web"));
for local in ["", "target:", "/usr/bin/python3", ".venv/bin/python", "targets/python"] {
assert_eq!(python(local).run_target(), None, "{local}");
}
}
#[test]
fn removed_one_themes_read_as_islands_themes() {
let settings: Settings = serde_json::from_str(
@@ -1657,11 +1743,15 @@ mod tests {
let old: Settings = serde_json::from_str(r#"{"agents":{"showCost":true}}"#).unwrap();
assert!(old.agents.show_cost);
assert!(old.agents.show_context_usage && old.agents.show_work_time && old.agents.show_usage_limits);
assert!(!old.agents.fit_window_width);
assert!(!Settings::default().agents.show_cost);
assert!(!Settings::default().agents.fit_window_width);
let mut settings = Settings::default();
settings.agents.show_usage_limits = false;
settings.agents.fit_window_width = true;
let json = serde_json::to_value(&settings).unwrap();
assert_eq!(json["agents"]["showUsageLimits"], false);
assert_eq!(json["agents"]["fitWindowWidth"], true);
let restored: Settings = serde_json::from_value(json).unwrap();
assert_eq!(restored.agents, settings.agents);
}
+30
Переглянути файл
@@ -175,6 +175,24 @@ impl Default for VcsConfirmationSettings {
}
}
/// Tools › Diff & Merge › External Diff Tools: a program that shows two files' differences.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct ExternalDiffSettings {
/// Offer Show Diff in External Tool in diff viewers.
pub enabled: bool,
/// The program to start.
pub path: String,
/// Its arguments: `%1` the left file, `%2` the right file, `%3` the diff's title.
pub parameters: String,
}
impl Default for ExternalDiffSettings {
fn default() -> Self {
Self { enabled: false, path: String::new(), parameters: "%1 %2 %3".into() }
}
}
/// Version Control › Shelf.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
@@ -260,18 +278,24 @@ pub struct DebuggerSettings {
pub show_on_breakpoint: bool,
pub focus_app_on_breakpoint: bool,
pub hide_on_termination: bool,
/// A breakpoint or exception hit brings the Debug tool window's Threads & Variables tab to the front.
pub variables_on_breakpoint: bool,
pub breakpoints_any_file: bool,
/// While suspended, hovering the start of a line shows a Run to Cursor button.
pub run_to_cursor_inlay: bool,
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,
}
@@ -281,14 +305,17 @@ impl Default for DebuggerSettings {
show_on_breakpoint: true,
focus_app_on_breakpoint: false,
hide_on_termination: false,
variables_on_breakpoint: true,
breakpoints_any_file: false,
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(),
}
}
@@ -318,6 +345,8 @@ pub struct RunSettings {
/// Ask before closing a Run tab whose process is still running.
pub confirm_terminate: bool,
pub show_on_start: bool,
/// New Run/Debug tabs start pinned: a later run doesn't take their place once finished.
pub pin_tabs: bool,
/// Grace period between interrupt and kill when stopping a process.
pub termination_timeout_ms: u32,
}
@@ -329,6 +358,7 @@ impl Default for RunSettings {
confirm_rerun: true,
confirm_terminate: true,
show_on_start: true,
pin_tabs: false,
termination_timeout_ms: 3000,
}
}
+5
Переглянути файл
@@ -7,6 +7,8 @@ license.workspace = true
[dependencies]
futures.workspace = true
tokio-tungstenite.workspace = true
base64.workspace = true
tempfile.workspace = true
ide-core.workspace = true
ide-language.workspace = true
@@ -28,6 +30,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(
+235 -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,11 +400,29 @@ 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)
}
/// The Attach to Process candidates among the processes `root` started, parents first.
/// Blocking, like [`attachable_processes`].
pub fn attachable_descendants(root: u32) -> IdeResult<Vec<AttachableProcess>> {
let order = ide_core::process::descendants(root);
if order.is_empty() {
return Ok(Vec::new());
}
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(|p| order.contains(&p.pid))
.filter_map(|p| classify(p, is_dotnet, is_go_binary, native))
.collect();
out.sort_by_key(|p| order.iter().position(|&pid| pid == p.pid));
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -258,7 +432,30 @@ 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))
}
#[cfg(unix)]
#[test]
fn a_runs_candidates_are_the_processes_it_started() {
let mut child = std::process::Command::new("sh").args(["-c", "sleep 30; true"]).spawn().unwrap();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
let found = loop {
let found = attachable_descendants(child.id()).unwrap();
if !found.is_empty() || std::time::Instant::now() > deadline {
break found;
}
std::thread::sleep(std::time::Duration::from_millis(50));
};
for p in &found {
let _ = std::process::Command::new("kill").arg(p.pid.to_string()).status();
}
child.kill().unwrap();
child.wait().unwrap();
assert!(found.iter().all(|p| p.pid != child.id()), "{found:?}");
assert!(found.iter().any(|p| p.name == "sleep" && p.kind == AttachKind::Native), "{found:?}");
assert!(attachable_descendants(u32::MAX - 1).unwrap().is_empty());
}
#[test]
@@ -292,11 +489,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()));
+528
Переглянути файл
@@ -0,0 +1,528 @@
//! Replacing the responses a debugged page receives. js-debug hands out a WebSocket that
//! speaks the browser's own protocol (`requestCDPProxy`); over it the browser is told to hold
//! the requests whose URL matches a rule (`Fetch.enable`) and each held request is answered
//! here, with the rule's response or by letting it through.
//!
//! A held request stays held until it is answered, so interception is always switched off
//! (`Fetch.disable`) before the connection is given up: a page whose requests nobody answers hangs.
use crate::{DebugManager, DebugSessionId, REQUEST_TIMEOUT, js_debug};
use base64::Engine;
use futures::{SinkExt, StreamExt};
use ide_core::{IdeError, IdeResult};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use specta::Type;
use std::collections::HashMap;
use tokio::sync::{oneshot, watch};
use tokio_tungstenite::tungstenite::{Message, http::StatusCode};
/// A response given in place of the server's to every request whose URL contains `url_contains`.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct JsResponseRule {
pub url_contains: String,
pub status: u16,
pub content_type: String,
pub body: String,
}
/// The browser's URL pattern (`*` any text, `?` one character, `\` escapes) for "contains `part`".
pub(crate) fn url_pattern(part: &str) -> String {
let mut pattern = String::with_capacity(part.len() + 2);
pattern.push('*');
for c in part.chars() {
if matches!(c, '*' | '?' | '\\') {
pattern.push('\\');
}
pattern.push(c);
}
pattern.push('*');
pattern
}
pub(crate) fn rule_for<'a>(rules: &'a [JsResponseRule], url: &str) -> Option<&'a JsResponseRule> {
rules.iter().find(|rule| url.contains(&rule.url_contains))
}
/// What to tell the browser about a request it holds: the first matching rule's response, or
/// to send the request on. Every held request gets one of the two.
pub(crate) fn answer(held: &Value, rules: &[JsResponseRule]) -> (&'static str, Value) {
let request_id = held.get("requestId").and_then(Value::as_str).unwrap_or_default();
let url = held.pointer("/request/url").and_then(Value::as_str).unwrap_or_default();
let Some(rule) = rule_for(rules, url) else {
return ("Fetch.continueRequest", json!({ "requestId": request_id }));
};
let header = |name: &str, value: &str| json!({ "name": name, "value": value });
// A page on another origin only reads a response that names its origin. The server would
// say so; the response given in its place has to.
let mut cross_origin = Vec::new();
if let Some(origin) = request_header(held, "Origin") {
cross_origin.push(header("Access-Control-Allow-Origin", origin));
cross_origin.push(header("Access-Control-Allow-Credentials", "true"));
}
let method = held.pointer("/request/method").and_then(Value::as_str).unwrap_or_default();
// The browser's question before such a request: allowed here, the server may not exist.
if method == "OPTIONS"
&& let Some(asked) = request_header(held, "Access-Control-Request-Method")
{
cross_origin.push(header("Access-Control-Allow-Methods", asked));
if let Some(asked) = request_header(held, "Access-Control-Request-Headers") {
cross_origin.push(header("Access-Control-Allow-Headers", asked));
}
let allowed = json!({
"requestId": request_id,
"responseCode": 204,
"responsePhrase": phrase(204),
"responseHeaders": cross_origin,
});
return ("Fetch.fulfillRequest", allowed);
}
let mut headers = vec![header("Content-Type", &rule.content_type)];
headers.extend(cross_origin);
let replaced = json!({
"requestId": request_id,
"responseCode": rule.status,
"responsePhrase": phrase(rule.status),
"responseHeaders": headers,
"body": base64::engine::general_purpose::STANDARD.encode(rule.body.as_bytes()),
});
("Fetch.fulfillRequest", replaced)
}
/// A header of the held request; the browser reports names as the page wrote them.
fn request_header<'a>(held: &'a Value, name: &str) -> Option<&'a str> {
held.pointer("/request/headers")?.as_object()?.iter().find(|(key, _)| key.eq_ignore_ascii_case(name))?.1.as_str()
}
/// The browser refuses a response without a phrase when it has none of its own for the status.
fn phrase(status: u16) -> &'static str {
StatusCode::from_u16(status).ok().and_then(|status| status.canonical_reason()).unwrap_or("Unknown")
}
fn interception(rules: &[JsResponseRule]) -> Value {
let patterns: Vec<Value> = rules
.iter()
.map(|rule| json!({ "urlPattern": url_pattern(&rule.url_contains), "requestStage": "Request" }))
.collect();
json!({ "patterns": patterns })
}
/// Applies `rules` to the page behind js-debug's CDP proxy at `ws_url` until their sender is
/// dropped (the session is gone) or the proxy closes the connection. The browser holds
/// requests only while there are rules. `ready` hears whether the proxy reports held
/// requests at all; nothing is held before it has agreed to.
pub(crate) async fn serve(
ws_url: String,
mut rules: watch::Receiver<Vec<JsResponseRule>>,
ready: oneshot::Sender<Result<(), String>>,
) {
let socket = match tokio_tungstenite::connect_async(ws_url.as_str()).await {
Ok((socket, _)) => socket,
Err(e) => {
let _ = ready.send(Err(e.to_string()));
return;
}
};
let (mut write, mut read) = socket.split();
let mut last_id = 0u64;
let mut call = |method: &str, params: Value| {
last_id += 1;
(last_id, Message::text(json!({ "id": last_id, "method": method, "params": params }).to_string()))
};
let (subscription, subscribe) = call("JsDebug.subscribe", json!({ "events": ["Fetch.requestPaused"] }));
let subscribed = tokio::time::timeout(REQUEST_TIMEOUT, async {
write.send(subscribe).await.map_err(|e| e.to_string())?;
loop {
let reply = read.next().await.ok_or("the connection closed")?.map_err(|e| e.to_string())?;
let Some(reply) = reply.to_text().ok().and_then(|text| serde_json::from_str::<Value>(text).ok()) else {
continue;
};
if reply.get("id").and_then(Value::as_u64) != Some(subscription) {
continue;
}
break match reply.get("error") {
Some(error) => Err(error.get("message").and_then(Value::as_str).unwrap_or("refused").to_owned()),
None => Ok(()),
};
}
})
.await
.unwrap_or_else(|_| Err("no answer".to_owned()));
if let Err(why) = subscribed {
let _ = ready.send(Err(why));
let _ = write.close().await;
return;
}
let _ = ready.send(Ok(()));
let mut current = rules.borrow_and_update().clone();
let mut holding = !current.is_empty();
if holding && write.send(call("Fetch.enable", interception(&current)).1).await.is_err() {
return;
}
// The request each sent replacement is for, by command id, until the browser has taken it.
let mut replacing: HashMap<u64, String> = HashMap::new();
loop {
tokio::select! {
changed = rules.changed() => {
if changed.is_err() {
break;
}
current = rules.borrow_and_update().clone();
let (_, command) = match (current.is_empty(), holding) {
(true, false) => continue,
(true, true) => call("Fetch.disable", json!({})),
(false, _) => call("Fetch.enable", interception(&current)),
};
holding = !current.is_empty();
if write.send(command).await.is_err() {
return;
}
}
message = read.next() => {
// The proxy closed (the page or the session ended): nothing is held any more.
let Some(Ok(message)) = message else { return };
let Ok(text) = message.to_text() else { continue };
let Ok(message) = serde_json::from_str::<Value>(text) else { continue };
if let Some(id) = message.get("id").and_then(Value::as_u64) {
// The browser did not take the replacement: let the request through, or
// it stays held and the page waits for it.
if let Some(request_id) = replacing.remove(&id)
&& message.get("error").is_some()
{
let (_, go_on) = call("Fetch.continueRequest", json!({ "requestId": request_id }));
if write.send(go_on).await.is_err() {
return;
}
}
continue;
}
if message.get("method").and_then(Value::as_str) != Some("Fetch.requestPaused") {
continue;
}
let Some(held) = message.get("params") else { continue };
let Some(request_id) = held.get("requestId").and_then(Value::as_str) else { continue };
let (method, params) = answer(held, &current);
let (id, command) = call(method, params);
if method == "Fetch.fulfillRequest" {
replacing.insert(id, request_id.to_owned());
}
if write.send(command).await.is_err() {
return;
}
}
}
}
if holding {
let _ = write.send(call("Fetch.disable", json!({})).1).await;
}
let _ = write.close().await;
}
impl DebugManager {
/// Makes `rules` the page's response rules; none switches the replacing off. The first
/// rules of a session open the connection to js-debug's CDP proxy, later ones go over it.
/// An error means the rules are not applied.
pub async fn js_set_response_overrides(&self, s: DebugSessionId, rules: Vec<JsResponseRule>) -> IdeResult<()> {
let session = self.session(s)?;
if !js_debug::is_page(&session) {
return Err(IdeError::unavailable("this debug session is not a page in a browser"));
}
// Held to the end: two callers at once would each open a connection.
let mut applied = session.js_overrides.lock().await;
if applied.as_ref().is_some_and(|sender| sender.send(rules.clone()).is_ok()) {
return Ok(());
}
// No connection yet, or the proxy closed it.
applied.take();
if rules.is_empty() {
return Ok(());
}
let refused =
|why: String| IdeError::unavailable(format!("the JavaScript debugger has no browser connection: {why}"));
if !js_debug::launched(&session).await {
return Err(refused("the page's debug session did not finish starting".into()));
}
let proxy = session
.conn
.request_timeout("requestCDPProxy", json!({}), REQUEST_TIMEOUT)
.await
.map_err(|e| refused(e.to_string()))?;
let (Some(host), Some(port)) =
(proxy.get("host").and_then(Value::as_str), proxy.get("port").and_then(Value::as_u64))
else {
return Err(refused("no address".into()));
};
let path = proxy.get("path").and_then(Value::as_str).unwrap_or_default();
let (sender, receiver) = watch::channel(rules);
let (ready, is_ready) = oneshot::channel();
tokio::spawn(serve(format!("ws://{host}:{port}{path}"), receiver, ready));
match is_ready.await {
Ok(Ok(())) => {
*applied = Some(sender);
Ok(())
}
Ok(Err(why)) => Err(refused(why)),
Err(_) => Err(refused("no answer".into())),
}
}
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
use futures::{SinkExt, StreamExt};
use serde_json::json;
use tokio::sync::mpsc;
use tokio_tungstenite::tungstenite::Message;
pub(crate) fn rule(url_contains: &str, status: u16, body: &str) -> JsResponseRule {
JsResponseRule {
url_contains: url_contains.into(),
status,
content_type: "application/json".into(),
body: body.into(),
}
}
#[test]
fn a_part_of_a_url_becomes_a_pattern_that_matches_it_anywhere() {
assert_eq!(url_pattern("api/items"), "*api/items*");
// The pattern's own wildcards and escape character are taken literally.
assert_eq!(url_pattern(r"a*b?c\d"), r"*a\*b\?c\\d*");
}
#[test]
fn the_first_rule_whose_text_is_in_the_url_answers() {
let rules = [rule("api/items", 201, "{\"a\":\"é\"}"), rule("api", 500, "later")];
assert_eq!(rule_for(&rules, "http://h/api/items?x=1").map(|r| r.status), Some(201));
assert_eq!(rule_for(&rules, "http://h/api/users").map(|r| r.status), Some(500));
assert!(rule_for(&rules, "http://h/logo.png").is_none());
let (command, params) = answer(&held("7", "http://h/api/items")["params"], &rules);
assert_eq!(command, "Fetch.fulfillRequest");
assert_eq!(
params,
json!({
"requestId": "7",
"responseCode": 201,
"responsePhrase": "Created",
"responseHeaders": [{ "name": "Content-Type", "value": "application/json" }],
// base64 of the UTF-8 text `{"a":"é"}`
"body": "eyJhIjoiw6kifQ==",
})
);
// A request the browser held that no rule is for goes on to the network.
assert_eq!(
answer(&held("8", "http://h/logo.png")["params"], &rules),
("Fetch.continueRequest", json!({ "requestId": "8" }))
);
}
#[test]
fn a_status_the_browser_has_no_phrase_for_is_given_one() {
// The browser refuses a response whose status it cannot name.
let (_, params) = answer(&held("1", "http://h/api")["params"], &[rule("api", 419, "")]);
assert_eq!((&params["responseCode"], &params["responsePhrase"]), (&json!(419), &json!("Unknown")));
}
fn header<'a>(params: &'a Value, name: &str) -> Option<&'a str> {
params["responseHeaders"].as_array()?.iter().find(|h| h["name"] == name)?["value"].as_str()
}
#[test]
fn a_replaced_response_passes_the_pages_cross_origin_check() {
let rules = [rule("api/items", 200, "[]")];
let from_page = |method: &str, headers: Value| json!({ "requestId": "3", "request": { "url": "http://localhost:8080/api/items", "method": method, "headers": headers } });
// A page on another origin: the response names that origin, as the real server would.
let (_, params) = answer(&from_page("GET", json!({ "origin": "http://localhost:5173" })), &rules);
assert_eq!(header(&params, "Access-Control-Allow-Origin"), Some("http://localhost:5173"));
assert_eq!(header(&params, "Access-Control-Allow-Credentials"), Some("true"));
assert_eq!(params["body"], "W10=");
// The browser's question before such a request is answered too: the server may not exist.
let preflight = from_page(
"OPTIONS",
json!({
"Origin": "http://localhost:5173",
"Access-Control-Request-Method": "PUT",
"Access-Control-Request-Headers": "content-type,x-token",
}),
);
let (command, params) = answer(&preflight, &rules);
assert_eq!((command, &params["responseCode"]), ("Fetch.fulfillRequest", &json!(204)));
assert_eq!(header(&params, "Access-Control-Allow-Origin"), Some("http://localhost:5173"));
assert_eq!(header(&params, "Access-Control-Allow-Methods"), Some("PUT"));
assert_eq!(header(&params, "Access-Control-Allow-Headers"), Some("content-type,x-token"));
assert_eq!(params.get("body"), None);
// Same origin: nothing but the rule's own header.
let (_, params) = answer(&from_page("GET", json!({})), &rules);
assert_eq!(params["responseHeaders"], json!([{ "name": "Content-Type", "value": "application/json" }]));
}
/// A stand-in for js-debug's CDP proxy: what it receives, and a way to send it events.
pub(crate) async fn proxy() -> (String, mpsc::UnboundedReceiver<Value>, mpsc::UnboundedSender<Value>) {
proxy_refusing(|_| false).await
}
/// A proxy that answers the commands `refused` picks with an error, like the real one does
/// for what it or the browser does not accept.
pub(crate) async fn proxy_refusing(
refused: fn(&Value) -> bool,
) -> (String, mpsc::UnboundedReceiver<Value>, mpsc::UnboundedSender<Value>) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("ws://127.0.0.1:{}/session", listener.local_addr().unwrap().port());
let (received_tx, received) = mpsc::unbounded_channel();
let (events, mut events_rx) = mpsc::unbounded_channel::<Value>();
tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let (mut write, mut read) = tokio_tungstenite::accept_async(stream).await.unwrap().split();
loop {
tokio::select! {
message = read.next() => {
let Some(Ok(message)) = message else { break };
let Ok(text) = message.to_text() else { continue };
let Ok(request) = serde_json::from_str::<Value>(text) else { continue };
let reply = if refused(&request) {
json!({ "id": request["id"], "error": { "code": -32602, "message": "refused" } })
} else {
json!({ "id": request["id"], "result": {} })
};
let _ = write.send(Message::text(reply.to_string())).await;
let _ = received_tx.send(request);
}
Some(event) = events_rx.recv() => {
let _ = write.send(Message::text(event.to_string())).await;
}
}
}
});
(url, received, events)
}
pub(crate) async fn next(received: &mut mpsc::UnboundedReceiver<Value>) -> (String, Value) {
let request = tokio::time::timeout(std::time::Duration::from_secs(5), received.recv())
.await
.expect("the proxy got a request in time")
.expect("the connection is open");
(request["method"].as_str().unwrap().to_owned(), request["params"].clone())
}
/// The browser's event for a request it holds.
fn held(request_id: &str, url: &str) -> Value {
json!({ "method": "Fetch.requestPaused", "params": { "requestId": request_id, "request": { "url": url } } })
}
type Serving = (tokio::task::JoinHandle<()>, oneshot::Receiver<Result<(), String>>);
fn serving(url: String, rules: watch::Receiver<Vec<JsResponseRule>>) -> Serving {
let (ready, is_ready) = oneshot::channel();
(tokio::spawn(serve(url, rules, ready)), is_ready)
}
async fn ended(task: tokio::task::JoinHandle<()>) {
tokio::time::timeout(std::time::Duration::from_secs(5), task).await.unwrap().unwrap();
}
#[tokio::test]
async fn held_requests_are_answered_and_interception_ends_before_the_connection() {
let (url, mut received, events) = proxy().await;
let (rules, watched) = tokio::sync::watch::channel(vec![rule("api/items", 200, "[]")]);
let (serving, ready) = serving(url, watched);
assert_eq!(ready.await.unwrap(), Ok(()));
assert_eq!(
next(&mut received).await,
("JsDebug.subscribe".into(), json!({ "events": ["Fetch.requestPaused"] }))
);
assert_eq!(
next(&mut received).await,
(
"Fetch.enable".into(),
json!({ "patterns": [{ "urlPattern": "*api/items*", "requestStage": "Request" }] })
)
);
events.send(held("1", "http://h/api/items")).unwrap();
let (command, params) = next(&mut received).await;
assert_eq!(
(command.as_str(), &params["requestId"], &params["responseCode"]),
("Fetch.fulfillRequest", &json!("1"), &json!(200))
);
events.send(held("2", "http://h/other")).unwrap();
assert_eq!(next(&mut received).await, ("Fetch.continueRequest".into(), json!({ "requestId": "2" })));
// New rules replace the browser's patterns.
rules.send(vec![rule("api/users", 404, "{}")]).unwrap();
assert_eq!(
next(&mut received).await,
(
"Fetch.enable".into(),
json!({ "patterns": [{ "urlPattern": "*api/users*", "requestStage": "Request" }] })
)
);
// No rules left: the browser stops holding requests. The connection stays for the
// rules that come next, and goes with the session.
rules.send(Vec::new()).unwrap();
assert_eq!(next(&mut received).await.0, "Fetch.disable");
rules.send(vec![rule("api/items", 200, "[]")]).unwrap();
assert_eq!(next(&mut received).await.0, "Fetch.enable");
rules.send(Vec::new()).unwrap();
assert_eq!(next(&mut received).await.0, "Fetch.disable");
drop(rules);
ended(serving).await;
// Already off: not switched off a second time.
assert!(received.try_recv().is_err());
}
#[tokio::test]
async fn a_session_that_goes_away_switches_interception_off() {
let (url, mut received, _events) = proxy().await;
let (rules, watched) = tokio::sync::watch::channel(vec![rule("api", 200, "[]")]);
let (serving, _ready) = serving(url, watched);
assert_eq!(next(&mut received).await.0, "JsDebug.subscribe");
assert_eq!(next(&mut received).await.0, "Fetch.enable");
drop(rules);
assert_eq!(next(&mut received).await.0, "Fetch.disable");
ended(serving).await;
}
#[tokio::test]
async fn a_response_the_browser_refuses_lets_the_request_through() {
let (url, mut received, events) = proxy_refusing(|request| request["method"] == "Fetch.fulfillRequest").await;
let (_rules, watched) = tokio::sync::watch::channel(vec![rule("api", 200, "[]")]);
let (_serving, _ready) = serving(url, watched);
assert_eq!(next(&mut received).await.0, "JsDebug.subscribe");
assert_eq!(next(&mut received).await.0, "Fetch.enable");
events.send(held("9", "http://h/api")).unwrap();
assert_eq!(next(&mut received).await.0, "Fetch.fulfillRequest");
// Unanswered, the request would hang the page.
assert_eq!(next(&mut received).await, ("Fetch.continueRequest".into(), json!({ "requestId": "9" })));
}
#[tokio::test]
async fn a_proxy_that_does_not_report_held_requests_is_not_asked_to_hold_any() {
let (url, mut received, _events) = proxy_refusing(|request| request["method"] == "JsDebug.subscribe").await;
let (_rules, watched) = tokio::sync::watch::channel(vec![rule("api", 200, "[]")]);
let (serving, ready) = serving(url, watched);
assert_eq!(next(&mut received).await.0, "JsDebug.subscribe");
assert_eq!(ready.await.unwrap(), Err("refused".to_owned()));
ended(serving).await;
assert!(received.try_recv().is_err());
}
#[tokio::test]
async fn no_connection_to_the_proxy_is_reported() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("ws://127.0.0.1:{}/session", listener.local_addr().unwrap().port());
drop(listener);
let (_rules, watched) = tokio::sync::watch::channel(vec![rule("api", 200, "[]")]);
let (serving, ready) = serving(url, watched);
assert!(ready.await.unwrap().is_err());
ended(serving).await;
}
}
+323
Переглянути файл
@@ -0,0 +1,323 @@
//! 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;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU32, Ordering};
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.
pub(crate) 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(())
}
/// Starts recording the page's CPU profile; [`Self::js_stop_profile`] ends it.
pub async fn js_start_profile(&self, s: DebugSessionId) -> IdeResult<()> {
self.js_page_request(s, "startProfile", json!({ "type": "cpu" })).await?;
Ok(())
}
/// Ends the recording and returns a new directory holding it as `page.cpuprofile`
/// (what `ide-profiler` reads a directory of).
pub async fn js_stop_profile(&self, s: DebugSessionId) -> IdeResult<PathBuf> {
let session = self.session(s)?;
// js-debug names the file it records into when the recording starts.
let Some(recorded) = session.js_profile.lock().take() else {
return Err(IdeError::invalid("no CPU profile is being recorded"));
};
self.js_page_request(s, "stopProfile", json!({})).await?;
static HANDED_OVER: AtomicU32 = AtomicU32::new(0);
let dir = std::env::temp_dir().join(format!(
"openidea-js-profile-{}-{}-{}",
std::process::id(),
s.0,
HANDED_OVER.fetch_add(1, Ordering::Relaxed)
));
let copied = tokio::task::spawn_blocking({
let dir = dir.clone();
move || {
std::fs::create_dir_all(&dir)?;
std::fs::copy(&recorded, dir.join("page.cpuprofile"))?;
// The adapter's own copy is in the system's temporary directory: no use keeping two.
let _ = std::fs::remove_file(&recorded);
Ok::<_, std::io::Error>(())
}
})
.await
.map_err(|e| IdeError::internal(e.to_string()))?;
copied.map_err(|e| IdeError::unavailable(format!("the recorded CPU profile cannot be read: {e}")))?;
Ok(dir)
}
}
/// 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, "");
}
}
+46 -2
Переглянути файл
@@ -13,6 +13,8 @@ pub mod attach;
mod cargo;
pub mod connection;
pub mod jdwp;
mod js_cdp;
mod js_debug;
mod jvm_memory;
pub mod kotlin;
pub mod kotlin_lsp;
@@ -23,6 +25,8 @@ use connection::{Connection, DapError};
use futures::future::join_all;
use ide_core::{DebugSessionId, IdeError, IdeResult, SharedSink};
use ide_language::LanguageRegistry;
pub use js_cdp::JsResponseRule;
pub use js_debug::JsResponseBody;
pub use jvm_memory::*;
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
@@ -136,6 +140,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 +402,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 +599,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 +610,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 +767,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 +898,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 +946,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 +1183,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();
}
+85 -8
Переглянути файл
@@ -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,14 @@ 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>,
/// The response rules of a page once a connection applies them (`js_cdp::serve`).
/// Dropped with the session, which switches the browser's interception off.
pub js_overrides: tokio::sync::Mutex<Option<tokio::sync::watch::Sender<Vec<crate::JsResponseRule>>>>,
/// The file js-debug records the page's CPU profile into, while one is being recorded.
pub js_profile: parking_lot::Mutex<Option<std::path::PathBuf>>,
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 +240,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 +259,9 @@ 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(),
js_overrides: tokio::sync::Mutex::new(None),
js_profile: parking_lot::Mutex::new(None),
jvm_inspector: parking_lot::Mutex::new(None),
inspect_lock: tokio::sync::Mutex::default(),
ended: ide_core::CancellationToken::new(),
@@ -467,10 +481,7 @@ impl Session {
let _ = c.start_kill();
}
if let Some(mut program) = self.program.lock().take() {
let _ = program.child.kill();
let _ = std::thread::Builder::new().name("openidea-debug-reap".into()).spawn(move || {
let _ = program.child.wait();
});
program.kill();
}
}
}
@@ -567,7 +578,7 @@ fn launch_groups(s: &Session) -> Vec<libc::pid_t> {
for child in s.debuggees.lock().iter() {
push(child.id());
}
push(s.program.lock().as_ref().and_then(|p| p.child.process_id()));
push(s.program.lock().as_ref().and_then(ProgramTerminal::process_id));
groups.retain(|g| *g > 0);
groups
}
@@ -832,6 +843,16 @@ 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 });
}
}
// js-debug, after `startProfile`: where it records the profile.
"profileStarted" => {
*s.js_profile.lock() = str_("file").map(std::path::PathBuf::from);
}
_ => tracing::trace!("dap: ignored event {event}"),
}
}
@@ -861,7 +882,11 @@ fn on_reverse_request(mgr: &Arc<DebugManager>, s: &Arc<Session>, seq: i64, comma
/// A `runInTerminal` program and the pseudo-terminal it runs in.
pub(crate) struct ProgramTerminal {
writer: ide_core::input::InputWriter,
child: Box<dyn portable_pty::Child + Send + Sync>,
killer: Box<dyn portable_pty::ChildKiller + Send + Sync>,
#[cfg_attr(not(unix), allow(dead_code))]
pid: Option<u32>,
/// Set once the program has exited and been reaped.
exited: Arc<AtomicBool>,
_master: Box<dyn portable_pty::MasterPty + Send>,
}
@@ -869,6 +894,29 @@ impl ProgramTerminal {
pub(crate) fn write(&mut self, text: &str) -> std::io::Result<()> {
self.writer.write(text.as_bytes())
}
/// The program's process id while it runs.
#[cfg_attr(not(unix), allow(dead_code))]
pub(crate) fn process_id(&self) -> Option<u32> {
if self.exited.load(Ordering::Acquire) { None } else { self.pid }
}
pub(crate) fn kill(&mut self) {
// After the reap the pid may belong to another process.
if !self.exited.load(Ordering::Acquire) {
let _ = self.killer.kill();
}
}
}
/// Reaps the program as soon as it exits. Until then it stays a zombie that `kill(pid, 0)` still
/// finds: js-debug polls the `processId` it was given and keeps the session (and its terminal)
/// open while that process looks alive, and Stop would wait for it as for a running program.
fn reap_on_exit(mut child: Box<dyn portable_pty::Child + Send + Sync>, exited: Arc<AtomicBool>) {
let _ = std::thread::Builder::new().name("openidea-debug-reap".into()).spawn(move || {
let _ = child.wait();
exited.store(true, Ordering::Release);
});
}
/// `runInTerminal`: starts the debuggee on a pseudo-terminal, as an IDE terminal would (CodeLLDB
@@ -955,8 +1003,12 @@ pub(crate) fn run_in_terminal(s: &Arc<Session>, args: &Value) -> Result<Value, S
#[cfg(unix)]
wait_readable,
);
if let Some(mut previous) = s.program.lock().replace(ProgramTerminal { writer, child, _master: pair.master }) {
let _ = previous.child.kill();
let killer = child.clone_killer();
let exited = Arc::new(AtomicBool::new(false));
reap_on_exit(child, exited.clone());
let program = ProgramTerminal { writer, killer, pid, exited, _master: pair.master };
if let Some(mut previous) = s.program.lock().replace(program) {
previous.kill();
}
s.owner.sink.emit(IdeEvent::Debug { session: s.id, event: DebugEvent::ProgramInput });
Ok(json!({ "processId": pid }))
@@ -1325,4 +1377,29 @@ mod tests {
assert_eq!(parse_jdwp_port("Listening for transport dt_socket at address: [::1]:51234"), Some(51234));
assert_eq!(parse_jdwp_port("unrelated output"), None);
}
/// js-debug polls the `runInTerminal` pid with `kill(pid, 0)`; an unreaped zombie answers it as
/// a live process and keeps the session open after the program ends.
#[cfg(unix)]
#[test]
fn a_terminal_program_is_reaped_when_it_exits() {
use portable_pty::{CommandBuilder, PtySize, native_pty_system};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
let pair =
native_pty_system().openpty(PtySize { rows: 24, cols: 80, pixel_width: 0, pixel_height: 0 }).unwrap();
let mut cmd = CommandBuilder::new("/bin/sh");
cmd.args(["-c", "exit 0"]);
let child = pair.slave.spawn_command(cmd).unwrap();
let pid = child.process_id().unwrap() as libc::pid_t;
let exited = Arc::new(AtomicBool::new(false));
super::reap_on_exit(child, exited.clone());
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
while !exited.load(Ordering::Acquire) {
assert!(std::time::Instant::now() < deadline, "program was not reaped");
std::thread::sleep(std::time::Duration::from_millis(10));
}
// SAFETY: signal 0 only checks whether the process exists.
assert_eq!(unsafe { libc::kill(pid, 0) }, -1);
}
}
+348 -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,298 @@ 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;
}
/// Where the fake adapter says its CDP proxy listens (`requestCDPProxy`).
static CDP_PROXY_PORT: std::sync::atomic::AtomicU16 = std::sync::atomic::AtomicU16::new(0);
fn proxy_adapter(msg: &Value) -> Option<CustomReply> {
let port = CDP_PROXY_PORT.load(std::sync::atomic::Ordering::Relaxed);
(msg["command"] == "requestCDPProxy")
.then(|| (json!({ "host": "127.0.0.1", "port": port, "path": "/session" }), Vec::new()))
}
#[tokio::test]
async fn response_overrides_reach_the_page_through_the_adapters_proxy() {
use crate::js_cdp::tests::{next, proxy, rule};
let (url, mut received, _events) = proxy().await;
let port: u16 = url.trim_start_matches("ws://127.0.0.1:").trim_end_matches("/session").parse().unwrap();
CDP_PROXY_PORT.store(port, std::sync::atomic::Ordering::Relaxed);
let h = harness(Script {
start_debugging: true,
custom: Some(proxy_adapter),
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;
// Rules that are there as the page starts wait until its session takes requests.
tokio::time::sleep(Duration::from_millis(50)).await;
h.manager.js_set_response_overrides(page, vec![rule("api/items", 200, "[]")]).await.unwrap();
let dropped = h.log.lock().iter().filter(|m| m["type"] == "dropped").count();
assert_eq!(dropped, 0, "the proxy was asked for before the page took requests");
assert_eq!(next(&mut received).await.0, "JsDebug.subscribe");
assert_eq!(
next(&mut received).await,
("Fetch.enable".into(), json!({ "patterns": [{ "urlPattern": "*api/items*", "requestStage": "Request" }] }))
);
// Changed rules go over the connection that is open: the proxy is asked for once.
h.manager.js_set_response_overrides(page, vec![rule("api/users", 200, "[]")]).await.unwrap();
assert_eq!(next(&mut received).await.1["patterns"][0]["urlPattern"], "*api/users*");
assert_eq!(requests(&h, "requestCDPProxy").len(), 1);
h.manager.js_set_response_overrides(page, Vec::new()).await.unwrap();
assert_eq!(next(&mut received).await.0, "Fetch.disable");
// A rule switched back on.
h.manager.js_set_response_overrides(page, vec![rule("api/items", 200, "[]")]).await.unwrap();
assert_eq!(next(&mut received).await.0, "Fetch.enable");
assert_eq!(requests(&h, "requestCDPProxy").len(), 1);
let not_a_page = h.manager.js_set_response_overrides(browser, vec![rule("api", 200, "")]).await.unwrap_err();
assert_eq!(not_a_page.kind, ide_core::error::ErrorKind::Unavailable);
}
#[tokio::test]
async fn response_overrides_are_unavailable_without_the_adapters_proxy() {
use crate::js_cdp::tests::rule;
let h = harness(Script { start_debugging: true, ..Script::default() });
let browser = h.start(js_config("pwa-chrome")).await.unwrap();
let page = child_of(&h, browser).await;
let refused = h.manager.js_set_response_overrides(page, vec![rule("api", 200, "")]).await.unwrap_err();
assert_eq!(refused.kind, ide_core::error::ErrorKind::Unavailable);
// Nothing to switch off is not an error.
h.manager.js_set_response_overrides(page, Vec::new()).await.unwrap();
}
/// A port nothing listens on, which the fake adapter gives as its CDP proxy.
static DEAD_PROXY_PORT: std::sync::atomic::AtomicU16 = std::sync::atomic::AtomicU16::new(0);
fn dead_proxy_adapter(msg: &Value) -> Option<CustomReply> {
let port = DEAD_PROXY_PORT.load(std::sync::atomic::Ordering::Relaxed);
(msg["command"] == "requestCDPProxy")
.then(|| (json!({ "host": "127.0.0.1", "port": port, "path": "/session" }), Vec::new()))
}
#[tokio::test]
async fn response_overrides_that_cannot_reach_the_browser_are_an_error() {
use crate::js_cdp::tests::rule;
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
DEAD_PROXY_PORT.store(listener.local_addr().unwrap().port(), std::sync::atomic::Ordering::Relaxed);
drop(listener);
let h = harness(Script { start_debugging: true, custom: Some(dead_proxy_adapter), ..Script::default() });
let browser = h.start(js_config("pwa-chrome")).await.unwrap();
let page = child_of(&h, browser).await;
let unreachable = h.manager.js_set_response_overrides(page, vec![rule("api", 200, "")]).await.unwrap_err();
assert_eq!(unreachable.kind, ide_core::error::ErrorKind::Unavailable);
// The next rules try again instead of going to a connection that never was.
let again = h.manager.js_set_response_overrides(page, vec![rule("api", 200, "")]).await.unwrap_err();
assert_eq!(again.kind, ide_core::error::ErrorKind::Unavailable);
assert_eq!(requests(&h, "requestCDPProxy").len(), 2);
}
/// The file the fake adapter says it records a CPU profile into (`profileStarted`).
static PROFILE_FILE: parking_lot::Mutex<String> = parking_lot::Mutex::new(String::new());
fn profiling_adapter(msg: &Value) -> Option<CustomReply> {
match msg["command"].as_str()? {
"startProfile" => {
Some((json!({}), vec![("profileStarted", json!({ "file": *PROFILE_FILE.lock(), "type": "cpu" }))]))
}
"stopProfile" => Some((json!({}), vec![("profilerStateUpdate", json!({ "label": "", "running": false }))])),
_ => None,
}
}
#[tokio::test]
async fn a_pages_cpu_profile_is_handed_over_as_a_cpuprofile_file() {
let recorded = std::env::temp_dir().join(format!("openidea-dap-profile-{}", std::process::id()));
std::fs::write(&recorded, r#"{"nodes":[],"samples":[]}"#).unwrap();
*PROFILE_FILE.lock() = recorded.to_string_lossy().into_owned();
let h = harness(Script { start_debugging: true, custom: Some(profiling_adapter), ..Script::default() });
let browser = h.start(js_config("pwa-chrome")).await.unwrap();
let page = child_of(&h, browser).await;
// Nothing is being recorded yet.
let idle = h.manager.js_stop_profile(page).await.unwrap_err();
assert_eq!(idle.kind, ide_core::error::ErrorKind::InvalidInput);
h.manager.js_start_profile(page).await.unwrap();
assert_eq!(requests(&h, "startProfile"), [json!({ "type": "cpu" })]);
let dir = h.manager.js_stop_profile(page).await.unwrap();
assert_eq!(requests(&h, "stopProfile").len(), 1);
let files: Vec<_> = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name()).collect();
assert_eq!(files, ["page.cpuprofile"]);
assert_eq!(std::fs::read_to_string(dir.join("page.cpuprofile")).unwrap(), r#"{"nodes":[],"samples":[]}"#);
// One recording is handed over once.
assert_eq!(h.manager.js_stop_profile(page).await.unwrap_err().kind, ide_core::error::ErrorKind::InvalidInput);
// The adapter's file isn't there when it is asked for (the hand-over above took the first one).
assert!(!recorded.exists());
h.manager.js_start_profile(page).await.unwrap();
assert_eq!(h.manager.js_stop_profile(page).await.unwrap_err().kind, ide_core::error::ErrorKind::Unavailable);
assert_eq!(h.manager.js_start_profile(browser).await.unwrap_err().kind, ide_core::error::ErrorKind::Unavailable);
let _ = std::fs::remove_dir_all(dir);
}
#[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,
+46 -12
Переглянути файл
@@ -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.
+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 {
+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:?}");
}
+18
Переглянути файл
@@ -542,6 +542,24 @@ pub fn compose_config(ep: &DockerEndpoint, dir: &Path, files: &[String]) -> IdeR
if out.success { Ok(out.stdout) } else { Err(failure(&args, &out.stderr, &out.stdout)) }
}
/// Runs the docker CLI with `args` in `dir` and returns its standard output; a non-zero
/// exit is an error with docker's message.
pub fn cli(
ep: &DockerEndpoint,
dir: Option<&Path>,
args: &[String],
timeout: Duration,
cancel: Option<&CancellationToken>,
) -> IdeResult<String> {
let args: Vec<&str> = args.iter().map(String::as_str).collect();
let mut cmd = command(ep, &args)?;
if let Some(dir) = dir {
cmd.current_dir(dir);
}
let out = process::run(cmd, timeout, cancel)?;
if out.success { Ok(out.stdout) } else { Err(failure(&args, &out.stderr, &out.stdout)) }
}
#[cfg(test)]
mod local_tests {
use super::*;
+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.7.0";
pub const ENGINE_VERSION: &str = "1.9.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)?;
+30
Переглянути файл
@@ -325,6 +325,20 @@ pub fn migrate(data: &Path, local: &Path) {
if cfg!(target_os = "windows") && data != local {
migrate_entries(data, local);
}
if cfg!(target_os = "windows") {
relative_tool_shims(&local.join("tools"));
}
}
/// Wrappers written before they named their tool relative to themselves hold its absolute
/// path, which cmd.exe can't find through a non-ASCII user directory (#3338).
fn relative_tool_shims(tools: &Path) {
let Ok(entries) = fs::read_dir(tools) else { return };
for dir in entries.flatten().map(|e| e.path()).filter(|p| p.is_dir()) {
if let Err(error) = relocate_shims(&dir, &dir) {
tracing::warn!(%error, ?dir, "cannot make the tool's wrappers relative");
}
}
}
fn migrate_entries(data: &Path, local: &Path) {
@@ -651,6 +665,22 @@ mod tests {
fs::set_permissions(&dir, fs::Permissions::from_mode(0o755)).unwrap();
}
#[test]
fn installed_tool_wrappers_become_relative() {
let tmp = tempfile::tempdir().unwrap();
let tools = tmp.path().join("tools");
let tool = tools.join("taplo");
fs::create_dir_all(tool.join("bin")).unwrap();
let shim = tool.join("bin/taplo.cmd");
fs::write(&shim, format!("@echo off\r\n\"{}\\taplo.exe\" %*\r\n", tool.display())).unwrap();
// A wrapper the user re-saved in another encoding is left alone.
fs::create_dir_all(tools.join("other/bin")).unwrap();
fs::write(tools.join("other/bin/x.cmd"), b"@echo off\r\n\"C:\\\xd5\xc5\" %*\r\n").unwrap();
relative_tool_shims(&tools);
assert_eq!(fs::read_to_string(&shim).unwrap(), "@echo off\r\n\"%~dp0..\\taplo.exe\" %*\r\n");
relative_tool_shims(&tmp.path().join("missing"));
}
#[test]
fn wrappers_accept_mixed_separators_without_rewriting_neighbor_paths() {
let text = "@echo off\r\n\"C:\\old\\tools/demo\\payload.exe\" \"C:\\old\\tools/demo-other\\x\" %*\r\n";
+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
})
}
+38 -12
Переглянути файл
@@ -251,18 +251,12 @@ impl KotlinBridge {
}
}
other => {
let text = match other {
Ok(Ok(outcome)) => outcome
.messages
.iter()
.filter(|m| m.severity == Severity::Error)
.map(|m| m.text.as_str())
.collect::<Vec<_>>()
.join("\n"),
Ok(Err(error)) => error.message,
Err(error) => error.to_string(),
let (text, notify) = match other {
Ok(Ok(outcome)) => failed_build(&outcome),
Ok(Err(error)) => (error.message, true),
Err(error) => (error.to_string(), true),
};
self.failure(text, &mut diagnostics, &mut last_failure);
self.report(text, notify, &mut diagnostics, &mut last_failure);
remove_directory(directory).await;
}
}
@@ -276,6 +270,12 @@ impl KotlinBridge {
}
fn failure(&self, text: String, paths: &mut BTreeSet<PathBuf>, previous: &mut String) {
self.report(text, true, paths, previous);
}
/// Publishes a failed build in Problems (on `pom.xml`) and, when `notify`, as a
/// notification once per distinct message.
fn report(&self, text: String, notify: bool, paths: &mut BTreeSet<PathBuf>, previous: &mut String) {
let text = if text.is_empty() { "Kotlin compilation failed".to_owned() } else { text };
let text = format!("{text}\nJava temporarily uses the last successfully compiled Kotlin API.");
self.clear_diagnostics(paths);
@@ -295,7 +295,7 @@ impl KotlinBridge {
deprecated: false,
}],
});
if previous != &text {
if notify && previous != &text {
self.sink.emit(IdeEvent::Notify {
level: NotifyLevel::Error,
title: "Kotlin editor compilation failed".into(),
@@ -306,6 +306,15 @@ impl KotlinBridge {
}
}
/// The error text of a failed bridge build, and whether to notify about it. Compiler errors
/// in the user's own Kotlin already show in its editor and stay in Problems; only a build
/// that failed without one (Maven itself, a missing plugin) is worth a notification.
fn failed_build(outcome: &Outcome) -> (String, bool) {
let errors: Vec<_> = outcome.messages.iter().filter(|m| m.severity == Severity::Error).collect();
let text = errors.iter().map(|m| m.text.as_str()).collect::<Vec<_>>().join("\n");
(text, !errors.iter().any(|m| m.path.is_some()))
}
fn relevant_change(root: &Path, path: &Path, settings: &ide_core::settings::Settings) -> bool {
let input = path.extension().is_some_and(|e| e == "kt")
|| path.file_name().is_some_and(|n| n == "pom.xml" || n == "mvnw" || n == "mvnw.cmd")
@@ -383,6 +392,23 @@ mod tests {
assert!(!relevant_change(dir.path(), &dir.path().join("module/src/build/pkg"), &Default::default()));
}
#[test]
fn source_errors_stay_out_of_notifications() {
let message = |text: &str, path: Option<&str>| ide_build::compile::BuildMessage {
severity: Severity::Error,
text: text.into(),
path: path.map(|p| PathBuf::from(p).into()),
range: None,
code: None,
};
let outcome = |messages| Outcome { projects: Vec::new(), messages, success: false };
let (text, notify) = failed_build(&outcome(vec![message("No value passed", Some("/p/src/A.kt"))]));
assert_eq!((text.as_str(), notify), ("No value passed", false));
let (text, notify) = failed_build(&outcome(vec![message("Plugin not found", None)]));
assert_eq!((text.as_str(), notify), ("Plugin not found", true));
assert!(failed_build(&outcome(Vec::new())).1);
}
#[test]
fn extensionless_root_file_does_not_trigger() {
let dir = tempfile::tempdir().unwrap();
+14
Переглянути файл
@@ -5,6 +5,7 @@
pub mod agent_secrets;
pub mod data_dirs;
pub mod diagnostics_store;
pub mod environment;
mod formatter;
mod java_runtime;
@@ -58,10 +59,14 @@ 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(crate) agent_catalogs: Arc<ide_agent::AgentCatalogs>,
pub trust: trust::TrustStore,
pub(crate) mcp_approvals: Arc<trust::McpServerApprovals>,
pub config_dir: PathBuf,
@@ -135,15 +140,20 @@ 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(),
agent_catalogs: Arc::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")),
@@ -180,6 +190,7 @@ impl Host {
open_locks: Default::default(),
};
host.apply_proxy();
ops::agent::move_user_mcp_values(&host);
host
}
@@ -287,6 +298,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);
+29 -6
Переглянути файл
@@ -1,6 +1,10 @@
use super::page::{OFFSET, Page};
use super::tool::{Call, Output, Param, ParamType, Tool};
use ide_core::{Diagnostic, IdeResult, Severity};
use serde_json::{Value, json};
use serde_json::{Map, Value, json};
/// Problems `get_project_problems` lists by default.
const PROBLEMS_LIMIT: usize = 100;
pub fn tools() -> Vec<Tool> {
vec![
@@ -37,7 +41,10 @@ Lines and UTF-16 columns are 1-based.",
and inspections after profile filtering. This reflects what the Problems view shows, rather than a fresh analysis.
Lines and UTF-16 columns are 1-based.",
project_problems,
),
)
.param(Param::optional("errorsOnly", ParamType::Boolean, "Only errors (default) or warnings too"))
.param(Param::optional("limit", ParamType::Integer, "Problems to return at most (default 100)"))
.param(Param::optional("offset", ParamType::Integer, OFFSET)),
]
}
@@ -90,9 +97,25 @@ async fn file_problems(call: Call) -> IdeResult<Output> {
}
async fn project_problems(call: Call) -> IdeResult<Output> {
let page = Page::of(&call, PROBLEMS_LIMIT)?;
let errors_only = call.flag("errorsOnly", true)?;
let files = call.host.mcp_reports.problems(&call.host.mcp_windows(call.root()));
let files: Vec<_> = files.into_iter().map(|f| json!({
"filePath":call.display(&f.path.0), "problems": f.items.into_iter().map(|d| problem(d, "")).collect::<Vec<_>>()
})).collect();
Output::json(json!({"files":files}))
let problems: Vec<(String, Value)> = files
.into_iter()
.flat_map(|f| {
let path = call.display(&f.path.0);
let wanted = f.items.into_iter().filter(|d| !errors_only || d.severity == Severity::Error);
wanted.map(move |d| (path.clone(), problem(d, "")))
})
.collect();
let mut out = Map::new();
let mut files: Vec<Value> = Vec::new();
for (path, problem) in page.take(problems, &mut out) {
match files.last_mut() {
Some(file) if file["filePath"] == path.as_str() => file["problems"].as_array_mut().unwrap().push(problem),
_ => files.push(json!({ "filePath": path, "problems": [problem] })),
}
}
out.insert("files".into(), files.into());
Output::json(Value::Object(out))
}
+100 -6
Переглянути файл
@@ -2,6 +2,7 @@
// the list_directory_tree part of FileToolset.kt, src/com/intellij/mcpserver/util/fs.util.kt), Apache-2.0.
//! Reading files and listing directories.
use crate::mcp::page::{OFFSET, Page};
use crate::mcp::tool::{Call, Output, Param, ParamType, Tool};
use crate::mcp::util::{
self, RELATIVE_PATH_IN_PROJECT_DESCRIPTION, TIMED_OUT_DESCRIPTION, TIMEOUT_MILLISECONDS_DESCRIPTION,
@@ -17,6 +18,8 @@ const MAX_READ_LIMIT: i64 = 5000;
/// Longer lines are cut (minified files would flood the context), in UTF-16 units.
const MAX_LINE_LENGTH: usize = 2000;
const DEFAULT_TREE_DEPTH: i64 = 5;
/// Entries `list_directory_tree` lists by default.
const TREE_LIMIT: usize = 100;
pub fn tools() -> Vec<Tool> {
vec![read_file(), list_directory_tree()]
@@ -149,6 +152,12 @@ struct DirectoryTreeInfo {
errors: Vec<String>,
#[serde(skip_serializing_if = "std::ops::Not::not")]
listing_timed_out: bool,
/// Entries under the directory within `maxDepth`.
total: usize,
#[serde(skip_serializing_if = "std::ops::Not::not")]
more: bool,
#[serde(skip_serializing_if = "Option::is_none")]
next_offset: Option<usize>,
}
fn list_directory_tree() -> Tool {
@@ -156,10 +165,12 @@ fn list_directory_tree() -> Tool {
"list_directory_tree",
"Provides a tree representation of the specified directory in the pseudo graphic format like `tree` utility does.
Use this tool to explore the contents of a directory or the whole project.
You MUST prefer this tool over listing directories via command line utilities like `ls` or `dir`.",
You MUST prefer this tool over listing directories via command line utilities like `ls` or `dir`.
A page that starts inside a directory repeats the lines of the directories above it.",
|call| async move {
let directory = call.str("directoryPath")?;
let max_depth = call.opt_i64("maxDepth")?.unwrap_or(DEFAULT_TREE_DEPTH);
let page = Page::of(&call, TREE_LIMIT)?;
let timeout = call.opt_i64("timeout")?.unwrap_or(util::MEDIUM_TIMEOUT.as_millis() as i64);
let dir = call.resolve_readable(&directory).await?;
if !dir.exists() {
@@ -170,8 +181,16 @@ You MUST prefer this tool over listing directories via command line utilities li
}
let deadline = Instant::now() + Duration::from_millis(timeout.max(0) as u64);
let cancel = call.cancel.clone();
let mut tree =
TreeRender { out: String::new(), errors: Vec::new(), timed_out: false, deadline, cancel: cancel.clone() };
let mut tree = TreeRender {
out: String::new(),
errors: Vec::new(),
timed_out: false,
deadline,
cancel: cancel.clone(),
page,
seen: 0,
above: Vec::new(),
};
let tree = call
.host()
.blocking(move || {
@@ -187,11 +206,16 @@ You MUST prefer this tool over listing directories via command line utilities li
tree: tree.out,
errors: tree.errors,
listing_timed_out: tree.timed_out,
total: tree.seen,
more: tree.seen > page.end(),
next_offset: (tree.seen > page.end()).then_some(page.end()),
})
},
)
.param(Param::required("directoryPath", ParamType::String, RELATIVE_PATH_IN_PROJECT_DESCRIPTION))
.param(Param::optional("maxDepth", ParamType::Integer, "Maximum recursion depth"))
.param(Param::optional("limit", ParamType::Integer, "Entries to return at most (default 100)"))
.param(Param::optional("offset", ParamType::Integer, OFFSET))
.param(Param::optional("timeout", ParamType::Integer, TIMEOUT_MILLISECONDS_DESCRIPTION))
.output(json!({
"type": "object",
@@ -200,19 +224,28 @@ You MUST prefer this tool over listing directories via command line utilities li
"tree": { "type": "string" },
"errors": { "type": "array", "items": { "type": "string" } },
"listingTimedOut": { "type": "boolean", "description": TIMED_OUT_DESCRIPTION },
"total": { "type": "integer" },
"more": { "type": "boolean" },
"nextOffset": { "type": "integer" },
},
"required": ["traversedDirectory", "tree", "errors"],
"required": ["traversedDirectory", "tree", "errors", "total"],
}))
}
/// The `tree`-like listing: the directory's absolute path, then every entry (hidden and
/// ignored ones too) by lower-cased name, directories marked with `/`.
/// ignored ones too) by lower-cased name, directories marked with `/`; entries outside the
/// page are counted, not listed.
struct TreeRender {
out: String,
errors: Vec<String>,
timed_out: bool,
deadline: Instant,
cancel: CancellationToken,
page: Page,
/// Entries met so far.
seen: usize,
/// The lines of the directories above the entry, and whether this page shows them yet.
above: Vec<(String, bool)>,
}
impl TreeRender {
@@ -235,7 +268,13 @@ impl TreeRender {
path.file_name().unwrap_or_default().to_string_lossy().into_owned()
};
let is_dir = path.is_dir() && !path.is_symlink();
self.out.push_str(&format!("{indent}{prefix}{name}{}\n", if is_dir { "/" } else { "" }));
let line = format!("{indent}{prefix}{name}{}\n", if is_dir { "/" } else { "" });
let shown = if is_root {
self.out.push_str(&line);
true
} else {
self.entry(&line)
};
if !is_dir {
return;
}
@@ -250,9 +289,32 @@ impl TreeRender {
children.sort_by_cached_key(|p| (key(p).to_lowercase(), key(p)));
let child_indent = format!("{indent}{}", if is_last { " " } else { "│ " });
let n = children.len();
if !is_root {
self.above.push((line, shown));
}
for (i, child) in children.iter().enumerate() {
self.render(child, &child_indent, i + 1 == n, max_depth - 1, false);
}
if !is_root {
self.above.pop();
}
}
/// Counts an entry and lists it when on the page, after the directories above it.
fn entry(&mut self, line: &str) -> bool {
let n = self.seen;
self.seen += 1;
if n < self.page.offset || n >= self.page.end() {
return false;
}
for (above, shown) in &mut self.above {
if !*shown {
self.out.push_str(above);
*shown = true;
}
}
self.out.push_str(line);
true
}
}
@@ -302,4 +364,36 @@ mod tests {
let short = format!("{}😀", "x".repeat(1998));
assert_eq!(format_line(&short), short);
}
#[test]
fn a_tree_page_repeats_the_directories_above_it() {
let tmp = tempfile::tempdir().unwrap();
for file in ["a/x.txt", "a/y.txt", "b.txt"] {
let path = tmp.path().join(file);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, "").unwrap();
}
let render = |offset, limit| {
let mut tree = TreeRender {
out: String::new(),
errors: Vec::new(),
timed_out: false,
deadline: Instant::now() + Duration::from_secs(10),
cancel: CancellationToken::new(),
page: Page { offset, limit },
seen: 0,
above: Vec::new(),
};
tree.render(tmp.path(), "", true, 5, true);
let root = format!("{}/\n", tmp.path().display());
(tree.out.strip_prefix(&root).unwrap().to_owned(), tree.seen)
};
assert_eq!(render(0, 2), (" ├── a/\n │ ├── x.txt\n".to_owned(), 4));
assert_eq!(
render(2, 2).0,
" ├── a/\n │ └── y.txt\n └── b.txt\n",
"the page in `a/` repeats its line"
);
assert_eq!(render(0, 100).0, " ├── a/\n │ ├── x.txt\n │ └── y.txt\n └── b.txt\n");
}
}
+1
Переглянути файл
@@ -7,6 +7,7 @@ pub(crate) mod run;
mod vcs;
pub use requests::Requests;
mod files;
mod page;
mod reports;
mod search;
mod symbols;
+71
Переглянути файл
@@ -0,0 +1,71 @@
//! `limit` and `offset` of the tools that list things: a few items by default, the rest page by page.
use super::tool::Call;
use ide_core::{IdeError, IdeResult};
use serde_json::{Map, Value};
/// The most items one call returns.
pub const MAX_LIMIT: usize = 1000;
pub const OFFSET: &str = "Items to skip: `nextOffset` of the previous page";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Page {
pub offset: usize,
pub limit: usize,
}
impl Page {
/// The `limit` (`default` when absent, at most [`MAX_LIMIT`]) and `offset` (0) arguments.
pub fn of(call: &Call, default: usize) -> IdeResult<Self> {
let limit = match call.opt_i64("limit")? {
None => default,
Some(n) if n <= 0 => return Err(IdeError::invalid("limit must be > 0")),
Some(n) => (n as usize).min(MAX_LIMIT),
};
let offset = match call.opt_i64("offset")? {
None => 0,
Some(n) if n < 0 => return Err(IdeError::invalid("offset must be >= 0")),
Some(n) => n as usize,
};
Ok(Self { offset, limit })
}
/// Where the page ends: the items to find for it.
pub fn end(&self) -> usize {
self.offset.saturating_add(self.limit)
}
/// This page of all the `items`; `out` gets their `total`, and `more` and `nextOffset` when some follow.
pub fn take<T>(&self, items: Vec<T>, out: &mut Map<String, Value>) -> Vec<T> {
let total = items.len();
out.insert("total".into(), total.into());
self.mark(total > self.end(), out);
items.into_iter().skip(self.offset).take(self.limit).collect()
}
/// `more` and `nextOffset` when items follow the page.
pub fn mark(&self, more: bool, out: &mut Map<String, Value>) {
if more {
out.insert("more".into(), true.into());
out.insert("nextOffset".into(), self.end().into());
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pages_say_what_follows() {
let page = |offset, limit| Page { offset, limit };
let mut out = Map::new();
assert_eq!(page(0, 2).take(vec![1, 2, 3, 4, 5], &mut out), [1, 2]);
assert_eq!(Value::Object(out), serde_json::json!({ "total": 5, "more": true, "nextOffset": 2 }));
let mut out = Map::new();
assert_eq!(page(4, 2).take(vec![1, 2, 3, 4, 5], &mut out), [5]);
assert_eq!(Value::Object(out), serde_json::json!({ "total": 5 }), "the last page");
let mut out = Map::new();
assert!(page(9, 2).take(vec![1], &mut out).is_empty());
}
}
+80 -4
Переглянути файл
@@ -3,6 +3,7 @@
use super::agent::{AgentRun, INTERVENTION_ADVICE, TruncateMode, clean_output, tab_name, truncate_lines};
use super::output::RUN_PREVIEW_MAX_LENGTH;
use crate::mcp::page::{OFFSET, Page};
use crate::mcp::run::timeout;
use crate::mcp::tool::{Call, Output, Param, ParamType, Tool};
use ide_core::{IdeError, IdeResult};
@@ -35,6 +36,14 @@ and `nextOffset` are positions in the tab's raw output log; pass `nextOffset` as
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";
/// `list_runs`: tabs returned by default, and characters of each command, enough to tell them apart.
const LIST_LIMIT: usize = 10;
const LIST_COMMAND_CHARS: usize = 80;
const LIST_STATES: [(&str, &[&str]); 3] = [
("running", &["starting", "running", "stopping"]),
("idle", &["succeeded", "failed", "crashed", "stoppedByAgent", "stoppedByUser", "startFailed"]),
("error", &["failed", "crashed", "startFailed"]),
];
fn find(call: &Call) -> IdeResult<Arc<AgentRun>> {
let name = tab_name(&call.str("name")?)?;
@@ -283,13 +292,64 @@ for input) and `tabClosedAt` when the user closed the tab.{INTERVENTIONS}"
pub fn list_runs() -> Tool {
Tool::new(
"list_runs",
"Lists the agent tabs of the project (those started by `run_command`) with their status.",
"Lists the agent tabs of the project (those started by `run_command`), newest first: name, state, the \
start of the command, exit code and times. `get_run_status` gives a tab's whole 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 }))
let page = Page::of(&call, LIST_LIMIT)?;
let wanted = call.opt_str("state")?.filter(|s| s != "all");
let states = match wanted.as_deref() {
None => None,
Some(s) => Some(match LIST_STATES.iter().find(|(group, _)| *group == s) {
Some((_, states)) => states.to_vec(),
None if LIST_STATES[1].1.contains(&s) || LIST_STATES[0].1.contains(&s) => vec![s],
None => {
return Err(IdeError::invalid(format!(
"unknown state `{s}`: all, running, idle, error, or one of the states of get_run_status"
)));
}
}),
};
let runs: Vec<Value> = call
.host()
.mcp_agent_runs
.list(call.root())
.iter()
.rev()
.map(|r| r.status())
.filter(|s| {
states.as_ref().is_none_or(|states| s["state"].as_str().is_some_and(|v| states.contains(&v)))
})
.map(|s| Value::Object(brief(s)))
.collect();
let mut out = Map::new();
let runs = page.take(runs, &mut out);
out.insert("runs".into(), runs.into());
Output::json(Value::Object(out))
},
)
.param(Param::optional(
"state",
ParamType::String,
"all (default), running, idle (not running), error (failed, crashed, startFailed), or one state",
))
.param(Param::optional("limit", ParamType::Integer, "Tabs to return at most (default 10)"))
.param(Param::optional("offset", ParamType::Integer, OFFSET))
}
/// A tab in `list_runs`: what tells it apart and how it went, the command cut short.
fn brief(mut status: Map<String, Value>) -> Map<String, Value> {
let mut out = Map::new();
for key in ["name", "state", "command", "exitCode", "crash", "error", "startedAt", "endedAt", "tabClosedAt"] {
if let Some(v) = status.remove(key) {
out.insert(key.into(), v);
}
}
if let Some(Value::String(command)) = out.get_mut("command")
&& command.chars().count() > LIST_COMMAND_CHARS
{
*command = format!("{}…", command.chars().take(LIST_COMMAND_CHARS).collect::<String>());
}
out
}
/// What a key does to a command on pipes.
@@ -407,6 +467,22 @@ pub fn tools() -> Vec<Tool> {
mod tests {
use super::*;
#[test]
fn listed_tabs_are_brief_and_their_commands_cut_short() {
let status = serde_json::json!({
"name": "_agent_build", "state": "failed", "command": "x".repeat(100), "exitCode": 1,
"workingDirectory": "", "outputLength": 9, "startedBy": "agent",
});
let Value::Object(status) = status else { unreachable!() };
let brief = Value::Object(brief(status));
assert_eq!(brief["command"].as_str().unwrap(), format!("{}…", "x".repeat(LIST_COMMAND_CHARS)));
assert_eq!(
(&brief["name"], &brief["state"], &brief["exitCode"]),
(&"_agent_build".into(), &"failed".into(), &1.into())
);
assert!(brief.get("outputLength").is_none() && brief.get("workingDirectory").is_none());
}
#[test]
fn keys_are_what_pipes_can_take() {
assert_eq!(key(" enter ").unwrap(), Key::Text("\n"));
+25 -3
Переглянути файл
@@ -7,6 +7,7 @@ use super::output::OutputCollector;
use super::process::{quote, split_words};
use super::targets;
use crate::Project;
use crate::mcp::page::{OFFSET, Page};
use crate::mcp::run::timeout;
use crate::mcp::tool::{Call, Output, Param, ParamType, Tool};
use ide_build::compile::BuildKind;
@@ -19,6 +20,8 @@ use std::sync::Arc;
use std::time::{Duration, Instant};
const DEFAULT_TIMEOUT_MS: usize = 10_000;
/// Configurations `get_run_configurations` lists by default.
const CONFIGURATIONS_LIMIT: usize = 100;
/// 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.
@@ -50,7 +53,8 @@ fn describe_config(call: &Call, c: &RunConfig) -> Value {
if let Some(cwd) = c.cwd.as_deref() {
v["workingDirectory"] = call.display(&call.root().join(cwd)).into();
}
if !c.env.is_empty() {
// A detected configuration's environment is the IDE's own (its tools on `PATH`).
if !c.env.is_empty() && !c.detected {
v["environment"] = json!(c.env);
}
}
@@ -133,8 +137,23 @@ the whole command. Run points are found in Rust, JavaScript/TypeScript, Python a
.await?;
return Output::json(json!({ "filePath": file, "runPoints": points }));
}
let list: Vec<Value> = configs.iter().map(|c| describe_config(&call, c)).collect();
Output::json(json!({ "configurations": list }))
let page = Page::of(&call, CONFIGURATIONS_LIMIT)?;
let query = call.opt_str("query")?.map(|q| q.to_lowercase());
let list: Vec<Value> = configs
.iter()
.filter(|c| {
query.as_ref().is_none_or(|q| {
[c.name.as_str(), c.kind.as_str(), command_line(c).as_str()]
.iter()
.any(|s| s.to_lowercase().contains(q.as_str()))
})
})
.map(|c| describe_config(&call, c))
.collect();
let mut out = Map::new();
let list = page.take(list, &mut out);
out.insert("configurations".into(), list.into());
Output::json(Value::Object(out))
},
)
.write()
@@ -144,6 +163,9 @@ the whole command. Run points are found in Rust, JavaScript/TypeScript, Python a
"Optional file path relative to the project root. When provided, returns run points (executable entry points) \
in the file instead of project-wide run configurations.",
))
.param(Param::optional("query", ParamType::String, "Only configurations whose name, type or command contains it"))
.param(Param::optional("limit", ParamType::Integer, "Configurations to return at most (default 100)"))
.param(Param::optional("offset", ParamType::Integer, OFFSET))
}
/// What `execute_run_configuration` runs.
+36 -16
Переглянути файл
@@ -5,6 +5,7 @@
//! Coordinates are 1-based, end exclusive; columns count UTF-16 code units like
//! the editor (one per character except emoji and other astral characters).
use crate::mcp::page::{OFFSET, Page};
use crate::mcp::tool::{Call, Output, Param, ParamType, Tool};
use crate::mcp::util::{INDEXING_PARTIAL_RESULT_REASON, MEDIUM_TIMEOUT};
use crate::ops::search::SearchEvent;
@@ -21,8 +22,8 @@ use std::sync::Arc;
use std::time::{Duration, Instant};
pub const MAX_RESULTS_UPPER_BOUND: usize = 5000;
const DEFAULT_LIMIT: i64 = 1000;
pub const SYMBOL_SEARCH_DEFAULT_LIMIT: i64 = 50;
const DEFAULT_LIMIT: usize = 100;
pub const SYMBOL_SEARCH_DEFAULT_LIMIT: usize = 50;
pub const SEARCH_TIMEOUT_PARTIAL_RESULT_REASON: &str =
"The search timed out after 10 s. Narrow the query or pass paths.";
/// Symbols requested from the index per result when `paths` filters them afterwards.
@@ -37,7 +38,8 @@ 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 LIMIT_DESCRIPTION: &str = "Results to return at most (default 100)";
const SYMBOL_LIMIT_DESCRIPTION: &str = "Results to return at most (default 50)";
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.";
@@ -67,6 +69,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("offset", ParamType::Integer, OFFSET))
.param(Param::optional("context", ParamType::String, CONTEXT_DESCRIPTION))
.output(result_schema())
}
@@ -88,6 +91,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("offset", ParamType::Integer, OFFSET))
.param(Param::optional("context", ParamType::String, CONTEXT_DESCRIPTION))
.output(result_schema())
}
@@ -113,7 +117,8 @@ If you don't find a suitable result, try again with include_external=true to sea
"Whether to include SDK and library symbols. Disabled by default; if nothing suitable is found, try again \
with include_external=true.",
))
.param(Param::optional("limit", ParamType::Integer, LIMIT_DESCRIPTION))
.param(Param::optional("limit", ParamType::Integer, SYMBOL_LIMIT_DESCRIPTION))
.param(Param::optional("offset", ParamType::Integer, OFFSET))
.param(Param::optional("context", ParamType::String, CONTEXT_DESCRIPTION))
.output(result_schema())
}
@@ -138,6 +143,7 @@ Paths are optional additional glob filters relative to the project root.",
"Whether to include excluded/ignored files in results",
))
.param(Param::optional("limit", ParamType::Integer, LIMIT_DESCRIPTION))
.param(Param::optional("offset", ParamType::Integer, OFFSET))
.output(result_schema())
}
@@ -191,6 +197,9 @@ struct SearchResult {
items: Vec<SearchItem>,
#[serde(skip_serializing_if = "std::ops::Not::not")]
more: bool,
/// The `offset` of the next page, when `more`.
#[serde(skip_serializing_if = "Option::is_none")]
next_offset: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
partial_result_reason: Option<&'static str>,
}
@@ -212,16 +221,23 @@ fn result_schema() -> Value {
"required": ["filePath"],
} },
"more": { "type": "boolean" },
"nextOffset": int,
"partialResultReason": string,
},
})
}
fn limit(call: &Call, default: i64) -> IdeResult<usize> {
match call.opt_i64("limit")?.unwrap_or(default) {
n if n <= 0 => Err(IdeError::invalid("limit must be > 0")),
n => Ok((n as usize).min(MAX_RESULTS_UPPER_BOUND)),
}
/// The page of `call` and the results to find for it: up to its end.
fn page(call: &Call, default: usize) -> IdeResult<(Page, usize)> {
let page = Page::of(call, default)?;
Ok((page, page.end().min(MAX_RESULTS_UPPER_BOUND)))
}
/// Drops the results before the page, and the `nextOffset` when more follow. A search finds
/// [`MAX_RESULTS_UPPER_BOUND`] results at most, so no page starts past them.
fn skip<T>(page: Page, items: &mut Vec<T>, more: bool) -> Option<usize> {
items.drain(..page.offset.min(items.len()));
(more && page.end() < MAX_RESULTS_UPPER_BOUND).then_some(page.end())
}
fn query(call: &Call, empty: &str) -> IdeResult<String> {
@@ -521,7 +537,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 (page, limit) = page(&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
@@ -612,11 +628,12 @@ async fn text_search(call: Call, is_regex: bool) -> IdeResult<Output> {
..Default::default()
})
.collect();
let next_offset = skip(page, &mut items, more);
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 })
Output::json(SearchResult { items, more, next_offset, partial_result_reason })
}
/// The matches of `ranges` (byte ranges in `text`) that start on 0-based `line`, with their
@@ -721,7 +738,7 @@ async fn run_search(call: &Call, query: SearchQuery) -> IdeResult<(Vec<FileMatch
async fn file_search(call: Call) -> IdeResult<Output> {
let q = query(&call, "Glob pattern is empty")?;
let limit = limit(&call, DEFAULT_LIMIT)?;
let (page, limit) = page(&call, DEFAULT_LIMIT)?;
let scope = PathScope::new(call.root(), &call.str_list("paths")?)?;
let include_excluded = call.flag("includeExcluded", false)?;
let glob = GlobSetBuilder::new()
@@ -759,9 +776,11 @@ async fn file_search(call: Call) -> IdeResult<Output> {
Ok((found, more, walk.timed_out.get()))
})
.await?;
let items = paths.into_iter().map(|file_path| SearchItem { file_path, ..Default::default() }).collect();
let mut items: Vec<SearchItem> =
paths.into_iter().map(|file_path| SearchItem { file_path, ..Default::default() }).collect();
let next_offset = skip(page, &mut items, more);
let partial_result_reason = timed_out.then_some(SEARCH_TIMEOUT_PARTIAL_RESULT_REASON);
Output::json(SearchResult { items, more, partial_result_reason })
Output::json(SearchResult { items, more, next_offset, partial_result_reason })
}
struct Walk {
@@ -835,7 +854,7 @@ impl Walk {
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 (page, limit) = page(&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)?;
@@ -886,6 +905,7 @@ async fn symbol_search(call: Call) -> IdeResult<Output> {
}
let more = items.len() > limit || timed_out;
items.truncate(limit);
let next_offset = skip(page, &mut items, more);
// 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| {
@@ -899,7 +919,7 @@ async fn symbol_search(call: Call) -> IdeResult<Output> {
} else {
timed_out.then_some(SEARCH_TIMEOUT_PARTIAL_RESULT_REASON)
};
Output::json(SearchResult { items, more, partial_result_reason })
Output::json(SearchResult { items, more, next_offset, partial_result_reason })
}
/// The language servers' workspace symbols, and whether `budget` ran out first; empty
+15
Переглянути файл
@@ -210,6 +210,7 @@ fn leads_declaration(line: &str, line_comment: Option<&str>) -> bool {
pub fn find_references() -> Tool {
Tool::new("find_references", "Find references using the language service and the IDE's project index. Positions are 1-based UTF-16. Reports incomplete results when analysis is still running.", |call| async move {
let path = call.resolve_readable(&call.str("filePath")?).await?;
let page = super::page::Page::of(&call, REFERENCES_LIMIT)?;
let line = call.opt_i64("line")?.ok_or_else(|| IdeError::invalid("missing line"))?;
let column = call.opt_i64("column")?.ok_or_else(|| IdeError::invalid("missing column"))?;
let project = call.project.clone();
@@ -244,14 +245,28 @@ pub fn find_references() -> Tool {
}
}
positions(&mut value);
// `classified`, when there, goes with `locations` one to one.
if let serde_json::Value::Object(map) = &mut value {
let mut list = |key: &str| match map.remove(key) { Some(serde_json::Value::Array(items)) => items, _ => Vec::new() };
let (locations, classified) = (list("locations"), list("classified"));
let classified = page.take(classified, &mut serde_json::Map::new());
let locations = page.take(locations, map);
map.insert("locations".into(), locations.into());
map.insert("classified".into(), classified.into());
}
Output::json(value)
})
.param(Param::required("filePath", ParamType::String, "Path relative to the project root"))
.param(Param::required("line", ParamType::Integer, "1-based line"))
.param(Param::required("column", ParamType::Integer, "1-based UTF-16 column"))
.param(Param::optional("includeDeclaration", ParamType::Boolean, "Include declarations (default true)"))
.param(Param::optional("limit", ParamType::Integer, "References to return at most (default 100)"))
.param(Param::optional("offset", ParamType::Integer, super::page::OFFSET))
}
/// References `find_references` lists by default.
const REFERENCES_LIMIT: usize = 100;
fn mcp_position(text: &str, line: i64, column: i64) -> IdeResult<Pos> {
if line < 1 || line > u32::MAX as i64 || column < 1 || column > u32::MAX as i64 {
return Err(IdeError::invalid("line and column must be positive 1-based positions"));
+15 -11
Переглянути файл
@@ -2,12 +2,13 @@
// (mcpserver.vcs/src/com/intellij/mcpserver/toolsets/vcs/VcsToolset.kt), Apache-2.0.
//! Version control: repositories and git status.
use crate::mcp::page::{OFFSET, Page};
use crate::mcp::tool::{Call, Output, Param, ParamType, Tool};
use ide_core::{IdeError, IdeResult};
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
const STATUS_DEFAULT_LIMIT: i64 = 1000;
const STATUS_DEFAULT_LIMIT: usize = 100;
pub fn tools() -> Vec<Tool> {
vec![get_repositories(), git_status(), git_diff()]
@@ -254,11 +255,7 @@ By default all Git repositories are returned.",
|call| async move {
let include_untracked = call.flag("includeUntracked", true)?;
let include_ignored = call.flag("includeIgnored", false)?;
let limit = call.opt_i64("limit")?.unwrap_or(STATUS_DEFAULT_LIMIT);
if limit <= 0 {
return Err(IdeError::invalid("limit must be > 0"));
}
let limit = limit as usize;
let page = Page::of(&call, STATUS_DEFAULT_LIMIT)?;
let host = call.host();
let h = host.clone();
let w = call.window.clone();
@@ -305,7 +302,8 @@ By default all Git repositories are returned.",
let count = |f: fn(&StatusEntry) -> bool| entries.iter().filter(|e| f(e)).count();
let listed: Vec<Value> = entries
.iter()
.take(limit)
.skip(page.offset)
.take(page.limit)
.map(|e| {
let mut v = json!({
"pathRelativeToRepository": e.path.trim_end_matches('/'),
@@ -318,19 +316,24 @@ By default all Git repositories are returned.",
v
})
.collect();
repositories.push(json!({
let more = entries.len() > page.end();
let mut repository = json!({
"repositoryPathRelativeToProject": rel,
"currentBranch": branch,
"isClean": entries.is_empty(),
"totalEntries": entries.len(),
"hasMoreEntries": entries.len() > listed.len(),
"hasMoreEntries": more,
"stagedCount": count(StatusEntry::staged),
"unstagedCount": count(StatusEntry::unstaged),
"untrackedCount": count(StatusEntry::untracked),
"ignoredCount": count(StatusEntry::ignored),
"conflictedCount": count(StatusEntry::conflicted),
"entries": listed,
}));
});
if more {
repository["nextOffset"] = page.end().into();
}
repositories.push(repository);
}
Output::json(json!({ "repositories": repositories }))
},
@@ -342,7 +345,8 @@ By default all Git repositories are returned.",
))
.param(Param::optional("includeUntracked", ParamType::Boolean, "Whether to include untracked files"))
.param(Param::optional("includeIgnored", ParamType::Boolean, "Whether to include ignored files"))
.param(Param::optional("limit", ParamType::Integer, "Maximum number of entries returned per repository"))
.param(Param::optional("limit", ParamType::Integer, "Entries to return per repository at most (default 100)"))
.param(Param::optional("offset", ParamType::Integer, OFFSET))
}
fn git_diff() -> Tool {
+126 -9
Переглянути файл
@@ -2,12 +2,13 @@
//! 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::{
AcpImportOutcome, AgentAcceptOutcome, AgentChange, AgentChanges, AgentChangesView, AgentCommand, AgentEntry,
AgentEvent, AgentProbe, AgentPrompt, AgentProvider, AgentQueued, AgentTask, CustomAgentInput, ExternalSession,
McpScope, McpServer, McpServerInput, NewAgentTask,
AgentEvent, AgentModelCatalog, AgentProbe, AgentPrompt, AgentProvider, AgentQueued, AgentTask, CustomAgentInput,
ExternalSession, McpScope, McpServer, McpServerInput, NewAgentTask,
};
use ide_core::error::ErrorKind;
use ide_core::{IdeError, IdeEvent, IdeResult, IpcPath, RequestId, SharedSink, TaskId};
@@ -27,6 +28,12 @@ pub fn providers(host: &Host, window: &str) -> IdeResult<Vec<AgentProvider>> {
Ok(providers)
}
/// Refreshes the selected agent's model catalog without sending a prompt.
pub fn refresh_models(host: &Host, window: &str, provider: String, force: bool) -> IdeResult<AgentModelCatalog> {
host.require_trust(window)?;
host.project(window)?.agents.refresh_models(&provider, force)
}
/// Overrides built-in agent `id`'s command with `command` (its agent file keeps the
/// built-in's driver and arguments), or returns it to auto-detection with `None`.
pub fn set_command(host: &Host, window: &str, id: String, command: Option<String>) -> IdeResult<()> {
@@ -254,6 +261,18 @@ pub async fn fork(host: &Host, window: &str, task: String, turns: Option<u32>) -
host.project(window)?.agents.fork(&task, turns).await
}
/// Creates a temporary side conversation of `source`: lives in memory only, never
/// persisted, not listed.
pub async fn create_ephemeral(
host: &Host,
window: &str,
request: NewAgentTask,
source: String,
) -> IdeResult<AgentTask> {
host.require_trust(window)?;
host.project(window)?.agents.create_ephemeral(request, &source).await
}
/// Drops the task's turns after its first `turns`; returns the message of the first dropped one.
pub async fn rollback(host: &Host, window: &str, task: String, turns: u32) -> IdeResult<Option<AgentPrompt>> {
host.require_trust(window)?;
@@ -342,8 +361,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 +446,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 +463,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 => {
@@ -418,15 +514,17 @@ fn listed(
ide_agent::mcp::servers_from_settings(user, &workspace)
}
/// The MCP servers agent sessions of the project at `root` get: the user's, and the project's the
/// user turned on.
/// 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 user = settings.layer(SettingsScope::Global).agents.mcp_servers;
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)
}
@@ -477,6 +575,9 @@ pub fn mcp_import(host: &Host, window: &str) -> IdeResult<Vec<McpServer>> {
pub(crate) fn register(d: &mut Dispatcher) {
d.add("agent_subscribe", |c| async move { subscribe(&c.host, &c.window, c.channel("onEvent")?) });
d.add("agent_providers", |c| async move { providers(&c.host, &c.window) });
d.add("agent_refresh_models", |c| async move {
refresh_models(&c.host, &c.window, c.arg("provider")?, c.arg("force")?)
});
d.add("agent_set_command", |c| async move { set_command(&c.host, &c.window, c.arg("id")?, c.arg("command")?) });
d.add("agent_set_env", |c| async move { set_env(&c.host, &c.window, c.arg("id")?, c.arg("env")?) });
d.add("agent_registry_list", |c| async move { registry_list(&c.host, &c.window, c.arg("request")?).await });
@@ -593,6 +694,7 @@ mod tests {
#[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();
@@ -605,6 +707,7 @@ mod tests {
.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()
@@ -613,13 +716,27 @@ mod tests {
.collect()
};
assert_eq!(
on(session_mcp_servers(&settings, &approvals, &root)),
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, &root)).contains(&(
assert!(on(session_mcp_servers(&settings, &approvals, &secrets, &root)).contains(&(
McpScope::Workspace,
"extra".to_owned(),
"extra".to_owned()
+41 -1
Переглянути файл
@@ -15,6 +15,7 @@ use ide_build::{BuildModel, BuildTool, SyncOptions, SyncProgress};
use ide_core::settings::Settings;
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())
@@ -330,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(
@@ -1051,7 +1092,6 @@ mod maven_completion_tests {
use ide_build::maven::completion::PomCompletionKind;
use ide_core::CancellationToken;
use ide_workspace::dependencies::DependencyVersionSource;
use std::sync::Arc;
/// Cache answers the test plants before completing, so the op's Central
/// lookups hit the session cache and the network is never reached.
+60 -6
Переглянути файл
@@ -12,6 +12,7 @@ use ide_db::{
};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::path::PathBuf;
/// One statement of a console run.
#[derive(Clone, Serialize, Deserialize, Type)]
@@ -42,6 +43,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
@@ -175,6 +180,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
apply_edits(&c.host, &c.window, &id, &schema, &table, edits).await
});
d.add("db_console_path", |c| async move { console_path(&c.host, &c.arg::<String>("id")?) });
d.add("db_consoles", |c| async move { consoles(&c.host, &c.arg::<String>("id")?, c.arg("create")?) });
}
/// The data sources configured for the project (the tree's roots).
@@ -212,6 +218,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();
@@ -457,11 +474,52 @@ pub async fn apply_edits(
/// created on first use. Consoles are files of the project, so a remote
/// window resolves it on the host the project runs on.
pub fn console_path(host: &Host, id: &str) -> IdeResult<IpcPath> {
let file = console_dir(host, id)?.join("console.sql");
if !file.exists() {
std::fs::write(&file, "")?;
}
Ok(file.into())
}
/// The query consoles of data source `id`: the default `console.sql`, then
/// `console_1.sql`, `console_2.sql`… in order. `create` adds the next one first
/// (DataGrip's New › Query Console).
pub fn consoles(host: &Host, id: &str, create: bool) -> IdeResult<Vec<IpcPath>> {
let dir = console_dir(host, id)?;
let default = dir.join("console.sql");
let mut numbered: Vec<(u32, PathBuf)> = std::fs::read_dir(&dir)?
.filter_map(|e| {
let path = e.ok()?.path();
let n = path.file_name()?.to_str()?.strip_prefix("console_")?.strip_suffix(".sql")?.parse().ok()?;
Some((n, path))
})
.collect();
numbered.sort();
if create {
// The default console comes first; once it exists, the next number.
if !default.exists() {
std::fs::write(&default, "")?;
} else {
let n = numbered.last().map_or(1, |(n, _)| n + 1);
let file = dir.join(format!("console_{n}.sql"));
std::fs::write(&file, "")?;
numbered.push((n, file));
}
}
let mut out: Vec<IpcPath> = Vec::new();
if default.exists() {
out.push(default.into());
}
out.extend(numbered.into_iter().map(|(_, p)| p.into()));
Ok(out)
}
/// `<dataDir>/consoles/db/<id>`: `<id>` for the default database, `<id>.<database>` for the others.
fn console_dir(host: &Host, id: &str) -> IdeResult<PathBuf> {
let (source, database) = ide_db::split_target(id);
if source.is_empty() || !source.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') {
return Err(IdeError::invalid("invalid data source id"));
}
// One console per database: `<id>` for the default one, `<id>.<database>` for the others.
let name = match database {
Some(db) => {
let safe: String =
@@ -472,9 +530,5 @@ pub fn console_path(host: &Host, id: &str) -> IdeResult<IpcPath> {
};
let dir = host.data_dir.join("consoles").join("db").join(&name);
std::fs::create_dir_all(&dir)?;
let file = dir.join("console.sql");
if !file.exists() {
std::fs::write(&file, "")?;
}
Ok(file.into())
Ok(dir)
}
+57
Переглянути файл
@@ -372,6 +372,46 @@ 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_overrides(
host: &Host,
_window: &str,
session: DebugSessionId,
rules: Vec<ide_dap::JsResponseRule>,
) -> IdeResult<()> {
host.debugger.js_set_response_overrides(session, rules).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 async fn debug_js_start_profile(host: &Host, _window: &str, session: DebugSessionId) -> IdeResult<()> {
host.debugger.js_start_profile(session).await
}
pub async fn debug_js_stop_profile(
host: &Host,
_window: &str,
session: DebugSessionId,
) -> IdeResult<ide_core::IpcPath> {
host.debugger.js_stop_profile(session).await.map(Into::into)
}
pub fn debug_capabilities(host: &Host, _window: &str, session: DebugSessionId) -> IdeResult<DebugCapabilities> {
host.debugger.capabilities(session)
}
@@ -588,6 +628,23 @@ 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_overrides", |c| async move {
debug_js_response_overrides(&c.host, &c.window, c.arg("session")?, c.arg("rules")?).await
});
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_js_start_profile",
|c| async move { debug_js_start_profile(&c.host, &c.window, c.arg("session")?).await },
);
d.add(
"debug_js_stop_profile",
|c| async move { debug_js_stop_profile(&c.host, &c.window, c.arg("session")?).await },
);
d.add("debug_set_function_breakpoints", |c| async move {
debug_set_function_breakpoints(&c.host, &c.window, c.arg("breakpoints")?).await
});
+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
});
}
+22
Переглянути файл
@@ -21,6 +21,28 @@ pub fn close(host: &Host, window: &str, doc: DocId) -> IdeResult<()> {
Ok(())
}
/// Opens an editable untitled buffer (a new-file tab); it reaches the disk
/// through [`save_as`], which turns it into a regular file document. `name`
/// re-references an already minted buffer instead of creating one.
pub async fn open_untitled(host: &Host, window: &str, name: Option<String>) -> IdeResult<DocumentInfo> {
let project = host.project(window)?;
host.blocking(move || project.documents.open_untitled(name.as_deref())).await
}
/// Save As for an untitled buffer ([`Documents::save_as`]); LightEdit windows
/// start watching the saved file's folder.
pub async fn save_as(host: &Host, window: &str, doc: DocId, path: PathBuf) -> IdeResult<DocumentInfo> {
let project = host.project(window)?;
host.blocking(move || {
let info = project.documents.save_as(doc, &path)?;
if let Some(dir) = path.parent().filter(|_| project.light) {
project.watch_light_dir(dir);
}
Ok(info)
})
.await
}
/// Applies a CodeMirror change set; returns the new version (`outOfSync` when `base_version` is stale).
pub fn edit(host: &Host, window: &str, doc: DocId, base_version: u32, changes: ChangeSet) -> IdeResult<u32> {
host.project(window)?.documents.edit(doc, base_version, changes)
+14 -1
Переглянути файл
@@ -50,7 +50,10 @@ pub async fn doc_set_encoding(
reload: bool,
) -> IdeResult<DocumentEncoding> {
let project = host.project(window)?;
if reload && let Some(uri) = project.documents.virtual_uri(doc)? {
if reload
&& !project.documents.is_untitled(doc)?
&& let Some(uri) = project.documents.virtual_uri(doc)?
{
let vd = if uri.starts_with("git-revision:") {
crate::ops::vcs::revision_virtual_document(host, window, &uri, Some(&encoding)).await?
} else {
@@ -159,6 +162,15 @@ pub async fn doc_highlights(host: &Host, window: &str, doc: DocId) -> IdeResult<
.await
}
/// Python class facts the inheritance inspections read: base names as written, a method's
/// parameters, decorator semantics and its body shape. Tree-sitter answers; no LSP.
pub async fn doc_python_facts(host: &Host, window: &str, doc: DocId) -> IdeResult<ide_language::PythonFacts> {
let project = host.project(window)?;
let syntax = host.syntax();
let snap = project.documents.text_snapshot(doc)?;
blocking(move || Ok(syntax.python_facts(&snap.language, &snap.text))).await
}
/// Extend Selection (⌥↑): syntax ranges around `offset`, innermost first.
/// Large documents fall back to word / line / document ranges.
pub async fn doc_selection_ranges(host: &Host, window: &str, doc: DocId, offset: u32) -> IdeResult<Vec<Range>> {
@@ -208,6 +220,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("doc_outline", |c| async move { doc_outline(&c.host, &c.window, c.arg("doc")?).await });
d.add("doc_folding", |c| async move { doc_folding(&c.host, &c.window, c.arg("doc")?).await });
d.add("doc_highlights", |c| async move { doc_highlights(&c.host, &c.window, c.arg("doc")?).await });
d.add("doc_python_facts", |c| async move { doc_python_facts(&c.host, &c.window, c.arg("doc")?).await });
d.add("doc_selection_ranges", |c| async move {
doc_selection_ranges(&c.host, &c.window, c.arg("doc")?, c.arg("offset")?).await
});
+66 -2
Переглянути файл
@@ -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 || {
@@ -1438,6 +1472,15 @@ pub async fn lsp_type_hierarchy(
host.project(window)?.lsp.type_hierarchy(doc, pos, direction).await
}
pub async fn lsp_prepare_type_hierarchy(
host: &Host,
window: &str,
doc: DocId,
pos: Pos,
) -> IdeResult<Option<HierarchyItem>> {
host.project(window)?.lsp.prepare_type_hierarchy(doc, pos).await
}
pub fn lsp_servers(host: &Host, window: &str) -> IdeResult<Vec<ServerInfo>> {
Ok(host.project(window)?.lsp.servers())
}
@@ -1822,6 +1865,9 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("lsp_type_hierarchy", |c| async move {
lsp_type_hierarchy(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?, c.arg("direction")?).await
});
d.add("lsp_prepare_type_hierarchy", |c| async move {
lsp_prepare_type_hierarchy(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?).await
});
d.add("lsp_servers", |c| async move { lsp_servers(&c.host, &c.window) });
d.add("lsp_resend_diagnostics", |c| async move { lsp_resend_diagnostics(&c.host, &c.window) });
d.add("lsp_restart", |c| async move { lsp_restart(&c.host, &c.window, c.arg("server")?).await });
@@ -2136,6 +2182,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 {
+87 -1
Переглянути файл
@@ -2,7 +2,7 @@ use super::doc::blocking;
use crate::Host;
use crate::Project;
use crate::rpc::Dispatcher;
use ide_core::{DocId, IdeResult, IpcPath, Location, Pos};
use ide_core::{DocId, IdeResult, IpcPath, LanguageId, Location, Pos};
use ide_index::{ExtensionCount, FileMatch, IndexStatus, SymbolFilter, SymbolMatch};
use ide_language::{FileReference, WebReference};
use ide_workspace::file_refs::{self, PathCompletion};
@@ -22,6 +22,23 @@ pub struct WebGoto {
const MAX_WEB_TARGETS: usize = 200;
/// A Python class definition and the base names written in its source. The UI builds a
/// name → locations index from every class in the project (attached dependency roots
/// included) to compute ↑ overrides / ↓ overridden gutter markers without a language
/// server's type-hierarchy provider.
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct ClassInheritance {
/// Start of the class definition's selection range (0-based line, UTF-16 column).
pub path: IpcPath,
pub line: u32,
pub col: u32,
pub name: String,
/// Base names as written in source (bare name, e.g. `AllToAllTokenDispatcher`; a
/// qualified name keeps only its last segment).
pub bases: Vec<String>,
}
/// The project root and its resource roots (served at `/`), for file references.
fn reference_roots(project: &Project) -> (PathBuf, Vec<PathBuf>) {
let resources = project.roots().resource_dirs().iter().map(|d| project.root.join(d)).collect();
@@ -174,6 +191,74 @@ pub async fn nav_index_definitions(
.await
}
/// Every Python class in the project (attached dependency roots included) and its base
/// names. The index covers content roots, so this is the whole-project answer the UI uses
/// for inheritance gutter markers when no type-hierarchy provider exists.
pub async fn nav_inheritance(host: &Host, window: &str) -> IdeResult<Vec<ClassInheritance>> {
let project = host.project(window)?;
let syntax = host.syntax();
blocking(move || {
let python = LanguageId::new("python");
let files = project.index.files_with_extensions(&["py".into(), "pyi".into()], 20_000);
let mut cache = CLASS_BASES.lock();
let mut out = Vec::new();
for path in files {
// The open (possibly unsaved) buffer is keyed by its text, a file on disk by its
// size and modification time, so only changed files are parsed again.
let open = project.documents.text_of(&path);
let stamp = match &open {
Some(text) => Stamp::Text(text_hash(text)),
None => match std::fs::metadata(&path) {
Ok(m) => Stamp::Disk(m.len(), m.modified().ok()),
Err(_) => continue,
},
};
let classes = match cache.get(&path) {
Some((cached, classes)) if *cached == stamp => classes,
_ => {
let text = match open {
Some(text) => text,
None => match project.text_of(&path) {
Ok(text) => text,
Err(_) => continue,
},
};
let classes = syntax.class_bases(&python, &text);
&cache.entry(path.clone()).insert_entry((stamp, classes)).into_mut().1
}
};
out.extend(classes.iter().map(|class| ClassInheritance {
path: IpcPath::from(path.clone()),
line: class.line,
col: class.col,
name: class.name.clone(),
bases: class.bases.clone(),
}));
}
Ok(out)
})
.await
}
#[derive(Clone, PartialEq, Eq)]
enum Stamp {
Text(u64),
Disk(u64, Option<std::time::SystemTime>),
}
fn text_hash(text: &str) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
text.hash(&mut h);
h.finish()
}
type ClassBasesCache = std::collections::HashMap<PathBuf, (Stamp, Vec<ide_language::ClassBases>)>;
/// Class bases per Python file, reused while the file is unchanged.
static CLASS_BASES: std::sync::LazyLock<parking_lot::Mutex<ClassBasesCache>> =
std::sync::LazyLock::new(Default::default);
/// The existing file or directory a path written in `from` names (`href`, `url()`, a string literal).
pub async fn fs_resolve_reference(
host: &Host,
@@ -249,6 +334,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("nav_index_definitions", |c| async move {
nav_index_definitions(&c.host, &c.window, c.arg("name")?, c.arg("from")?).await
});
d.add("nav_inheritance", |c| async move { nav_inheritance(&c.host, &c.window).await });
d.add("fs_resolve_reference", |c| async move {
fs_resolve_reference(&c.host, &c.window, c.arg("from")?, c.arg("value")?).await
});
+6
Переглянути файл
@@ -239,6 +239,12 @@ pub fn set_settings(
let toolchain = ["languages", "tools", "languageServers", "projectStructure"]
.iter()
.any(|k| key == *k || key.starts_with(&format!("{k}.")));
// A run target chosen as the Python interpreter (or changed) needs a snapshot of its libraries.
if ["languages", "languages.python", "languages.python.interpreter", "runTargets"].contains(&key) {
for project in affected() {
crate::ops::sdk::container_python::ensure(host, &project);
}
}
// Code style only reaches servers as settings (e.g. jdtls' formatter profile); builds don't depend on it.
if code_style || toolchain {
for project in affected() {
+1 -1
Переглянути файл
@@ -508,7 +508,7 @@ pub fn list(host: &Host) -> Vec<RunInfo> {
}
mod commands;
mod container;
pub(crate) mod container;
pub use commands::*;
#[cfg(test)]
+26 -15
Переглянути файл
@@ -33,23 +33,11 @@ pub(super) async fn prepare(host: &Host, root: &Path, target: RunTarget, config:
let root = root.to_path_buf();
let data_dir = host.data_dir.clone();
host.blocking(move || {
if !ide_docker::endpoint_is_local(&endpoint)? {
return Err(IdeError::invalid(format!(
"Run target '{}' uses a Docker daemon on another machine: the project can only be mounted from a local one",
target.name
)));
}
require_local(&endpoint, &target)?;
let docker = ide_docker::docker_executable().ok_or_else(|| {
IdeError::unavailable("docker executable not found; install Docker and make sure it is on PATH")
})?;
let container_root = match target.container_root.trim() {
"" if target.kind == RunTargetKind::Compose => {
let json = ide_docker::compose_config(&endpoint, &root, &target.compose_files)?;
compose_container_root(&json, target.service.trim(), &root)?
}
"" => default_container_root(&root),
folder => folder.to_owned(),
};
let container_root = container_root(&endpoint, &root, &target)?;
let file_env = env_files(&root, &config)?;
let name = container_name(&config.id, &unique());
let debugging = if debug { Some(Debugging::prepare(&data_dir)?) } else { None };
@@ -75,6 +63,29 @@ pub(super) async fn prepare(host: &Host, root: &Path, target: RunTarget, config:
.await
}
/// The project can only be bind-mounted from a daemon on this machine.
pub(crate) fn require_local(endpoint: &DockerEndpoint, target: &RunTarget) -> IdeResult<()> {
if !ide_docker::endpoint_is_local(endpoint)? {
return Err(IdeError::invalid(format!(
"Run target '{}' uses a Docker daemon on another machine: the project can only be mounted from a local one",
target.name
)));
}
Ok(())
}
/// Where the project is in the target's containers.
pub(crate) fn container_root(endpoint: &DockerEndpoint, root: &Path, target: &RunTarget) -> IdeResult<String> {
Ok(match target.container_root.trim() {
"" if target.kind == RunTargetKind::Compose => {
let json = ide_docker::compose_config(endpoint, root, &target.compose_files)?;
compose_container_root(&json, target.service.trim(), root)?
}
"" => default_container_root(root),
folder => folder.to_owned(),
})
}
/// What a debug run mounts and publishes.
struct Debugging {
port: u16,
@@ -104,7 +115,7 @@ fn free_port() -> IdeResult<u16> {
}
/// The target's Docker connection (Services tool window), by id; none = the CLI's daemon.
fn endpoint(data_dir: &Path, target: &RunTarget) -> IdeResult<DockerEndpoint> {
pub(crate) fn endpoint(data_dir: &Path, target: &RunTarget) -> IdeResult<DockerEndpoint> {
let id = target.docker_connection.trim();
if id.is_empty() {
return Ok(DockerEndpoint::Default);
+12 -2
Переглянути файл
@@ -10,6 +10,9 @@ use std::path::PathBuf;
use std::sync::Mutex;
use std::sync::atomic::{AtomicUsize, Ordering};
pub(crate) mod container_python;
pub use container_python::{ContainerPython, sdk_container_python, sdk_container_python_refresh};
fn join_err(e: impl std::fmt::Display) -> IdeError {
IdeError::internal(e.to_string())
}
@@ -232,12 +235,17 @@ pub async fn sdk_project_libraries(
let project = host.project(window)?;
let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective());
let cancel = host.begin_project_request(window, request);
let container = container_python::library_group(host, &root, &settings);
let libraries = {
let (root, settings, cancel, on_groups) = (root.clone(), settings.clone(), cancel.clone(), on_groups.clone());
tokio::task::spawn_blocking(move || {
ide_sdk::libraries::project_libraries_stream(&root, &settings, &[], &cancel, &|groups| {
if let Some(group) = &container {
let _ = on_groups.send(vec![group.clone()]);
}
let groups = ide_sdk::libraries::project_libraries_stream(&root, &settings, &[], &cancel, &|groups| {
let _ = on_groups.send(groups);
})
});
container.into_iter().chain(groups).collect::<Vec<_>>()
})
};
let dependencies = {
@@ -377,6 +385,8 @@ pub(crate) fn register(d: &mut Dispatcher) {
);
d.add("sdk_python_effective", |c| async move { sdk_python_effective(&c.host, &c.window, c.arg("near")?) });
d.add("sdk_php_effective", |c| async move { sdk_php_effective(&c.host, &c.window) });
d.add("sdk_container_python", |c| async move { sdk_container_python(&c.host, &c.window) });
d.add("sdk_container_python_refresh", |c| async move { sdk_container_python_refresh(&c.host, &c.window).await });
d.add("sdk_php_xdebug", |c| async move { sdk_php_xdebug(&c.host, &c.window, c.arg("php")?, c.arg("cwd")?).await });
d.add(
"sdk_php_language_level",
+298
Переглянути файл
@@ -0,0 +1,298 @@
//! A Docker or Compose run target's Python as the project interpreter: snapshots of the
//! container's library folders for the language server (see `ide_sdk::container_python`).
//!
//! A snapshot runs the target's Python once in a new container (the image's entrypoint
//! and the service's dependencies skipped), then copies its `sys.path` folders out of
//! the stopped container with `docker cp`. Nothing is installed in the container.
use crate::Host;
use crate::project::Project;
use ide_core::event::NotifyLevel;
use ide_core::remote_settings::{RunTarget, RunTargetKind};
use ide_core::settings::Settings;
use ide_core::{IdeError, IdeEvent, IdeResult, IpcPath, TaskId};
use ide_sdk::container_python::{self as snapshots, Snapshot};
use ide_sdk::python::PythonPackage;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::{Arc, LazyLock, Mutex};
use std::time::Duration;
/// Building or pulling the image may come first.
const PROBE_TIMEOUT: Duration = Duration::from_secs(15 * 60);
const COPY_TIMEOUT: Duration = Duration::from_secs(10 * 60);
const QUICK: Duration = Duration::from_secs(30);
/// The project interpreter when it is a run target's.
#[derive(Clone, Debug, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct ContainerPython {
/// The run target's id.
pub target: String,
/// Its name; empty when no run target has the id.
pub name: String,
/// The Python version of the snapshot in use; `None`: no snapshot yet.
pub version: Option<String>,
/// The image the snapshot was copied from.
pub image: Option<String>,
/// The copied library folders on this machine.
pub folders: Vec<IpcPath>,
/// Packages of the snapshot (read-only).
pub packages: Vec<PythonPackage>,
/// A snapshot is being taken.
pub updating: bool,
}
/// Where snapshots are kept, and the Docker connections the targets refer to.
#[derive(Clone)]
struct Dirs {
data: PathBuf,
/// `<local data>/container-python/`: `<project>/<target>/<image>/` and `neutral/`.
cache: PathBuf,
}
impl Dirs {
fn of(host: &Host) -> Self {
Self::new(&host.data_dir, &host.local_data_dir)
}
fn new(data_dir: &Path, local_data_dir: &Path) -> Self {
Self { data: data_dir.to_path_buf(), cache: local_data_dir.join("container-python") }
}
fn target(&self, root: &Path, target: &str) -> PathBuf {
snapshots::target_dir(&self.cache.join(crate::project::project_key(root)), target)
}
}
/// Target folders with a snapshot being taken.
static UPDATING: LazyLock<Mutex<HashSet<PathBuf>>> = LazyLock::new(Default::default);
fn updating(dir: &Path) -> bool {
UPDATING.lock().unwrap_or_else(|e| e.into_inner()).contains(dir)
}
/// The snapshot in use for the project's interpreter, if it is a run target's.
fn current(dirs: &Dirs, root: &Path, settings: &Settings) -> Option<(PathBuf, Snapshot)> {
let target = settings.languages.python.run_target()?;
snapshots::current(&dirs.target(root, target))
}
/// Language server configuration: basedpyright / pyright see the container's libraries.
pub(crate) fn configure_server(
cfg: &mut ide_language::LanguageServerConfig,
local_data_dir: &Path,
root: &Path,
settings: &Settings,
) {
if settings.languages.python.run_target().is_none() {
return;
}
let current = current(&Dirs::new(local_data_dir, local_data_dir), root, settings);
snapshots::configure_server(cfg, current.as_ref().map(|(d, s)| (d.as_path(), s)));
}
/// External Libraries: the snapshot's folders, named after the run target.
pub(crate) fn library_group(host: &Host, root: &Path, settings: &Settings) -> Option<ide_sdk::libraries::LibraryGroup> {
let target = settings.languages.python.run_target()?;
let (dir, snapshot) = current(&Dirs::of(host), root, settings)?;
let name = settings.run_targets.iter().find(|t| t.id == target).map_or(target, |t| t.name.as_str());
snapshots::library_group(name, &dir, &snapshot)
}
/// The project interpreter when it is a run target's, with its snapshot.
pub fn sdk_container_python(host: &Host, window: &str) -> IdeResult<Option<ContainerPython>> {
host.require_trust(window)?;
let project = host.project(window)?;
Ok(describe(host, &project.root, &project.settings.effective()))
}
fn describe(host: &Host, root: &Path, settings: &Settings) -> Option<ContainerPython> {
let target = settings.languages.python.run_target()?;
let name = settings.run_targets.iter().find(|t| t.id == target).map(|t| t.name.clone()).unwrap_or_default();
let dir = Dirs::of(host).target(root, target);
let mut out = ContainerPython {
target: target.to_owned(),
name,
version: None,
image: None,
folders: Vec::new(),
packages: Vec::new(),
updating: updating(&dir),
};
if let Some((dir, snapshot)) = snapshots::current(&dir) {
out.folders = snapshots::local_folders(&dir, &snapshot).into_iter().map(Into::into).collect();
out.packages = snapshots::packages(&dir, &snapshot);
out.version = Some(snapshot.probe.version);
out.image = Some(snapshot.image);
}
Some(out)
}
/// Takes a new snapshot of the project interpreter's container (also of an image that
/// has one already), then the language servers pick it up.
pub async fn sdk_container_python_refresh(host: &Host, window: &str) -> IdeResult<Option<ContainerPython>> {
host.require_trust(window)?;
let project = host.project(window)?;
update(Dirs::of(host), &project, true).await?;
Ok(describe(host, &project.root, &project.settings.effective()))
}
/// Takes a snapshot when the project interpreter is a run target's and the current image
/// has none yet (a new choice, a rebuilt image), in the background. Errors are notified.
pub(crate) fn ensure(host: &Host, project: &Arc<Project>) {
if !project.is_trusted() || project.settings.effective().languages.python.run_target().is_none() {
return;
}
let (dirs, project) = (Dirs::of(host), project.clone());
host.runtime.spawn(async move {
if let Err(e) = update(dirs, &project, false).await
&& e.kind != ide_core::error::ErrorKind::Cancelled
{
project.sink.emit(IdeEvent::Notify {
level: NotifyLevel::Warning,
title: "Cannot copy the container's Python libraries".into(),
body: Some(e.message),
});
}
});
}
async fn update(dirs: Dirs, project: &Arc<Project>, force: bool) -> IdeResult<()> {
let settings = project.settings.effective();
let Some(id) = settings.languages.python.run_target() else { return Ok(()) };
let target = ide_remote::service::run_target(&settings, id)?;
if target.kind == RunTargetKind::Ssh {
return Err(IdeError::invalid(format!(
"Run target '{}' runs over SSH: only a Docker or Compose run target can be the project interpreter",
target.name
)));
}
let dir = dirs.target(&project.root, id);
if !UPDATING.lock().unwrap_or_else(|e| e.into_inner()).insert(dir.clone()) {
return Ok(());
}
let _done = Updating(dir.clone());
let endpoint = crate::ops::run::container::endpoint(&dirs.data, &target)?;
let task = TaskId::next();
let title = format!("Python libraries of {}", target.name);
let (root, sink) = (project.root.clone(), project.sink.clone());
let progress = move |message: &str, done: bool| {
sink.emit(IdeEvent::Task {
id: task,
title: title.clone(),
message: Some(message.to_owned()),
fraction: None,
done,
cancellable: false,
})
};
let report = progress.clone();
let result = tokio::task::spawn_blocking(move || {
take(&endpoint, &root, &target, &dir, &dirs.cache, force, &|message| report(message, false))
})
.await
.map_err(|e| IdeError::internal(e.to_string()))
.and_then(|r| r);
progress(if result.is_ok() { "Done" } else { "Failed" }, true);
if result? {
project.lsp.reconfigure().await;
}
Ok(())
}
struct Updating(PathBuf);
impl Drop for Updating {
fn drop(&mut self) {
UPDATING.lock().unwrap_or_else(|e| e.into_inner()).remove(&self.0);
}
}
/// Removes the probe container, also when copying failed.
struct Container<'a> {
endpoint: &'a ide_docker::DockerEndpoint,
name: String,
}
impl Drop for Container<'_> {
fn drop(&mut self) {
let args = ["rm", "--force", "--volumes", &self.name].map(String::from);
if let Err(e) = ide_docker::cli(self.endpoint, None, &args, QUICK, None) {
tracing::warn!("cannot remove container {}: {}", self.name, e.message);
}
}
}
/// Takes the snapshot in `target_dir` and makes it the one in use; `false` when the image
/// had one already and it is in use (unless `force`).
fn take(
endpoint: &ide_docker::DockerEndpoint,
root: &Path,
target: &RunTarget,
target_dir: &Path,
cache: &Path,
force: bool,
progress: &dyn Fn(&str),
) -> IdeResult<bool> {
crate::ops::run::container::require_local(endpoint, target)?;
let container_root = crate::ops::run::container::container_root(endpoint, root, target)?;
let name = format!("openidea-python-{}", unique());
let args = ide_remote::docker::python_probe_args(target, root, &name, snapshots::PROBE)?;
progress("Starting the container's Python");
let container = Container { endpoint, name };
let out = ide_docker::cli(endpoint, Some(root), &args, PROBE_TIMEOUT, None);
let probe = snapshots::parse_probe(&out?)?;
let inspect = ["inspect".into(), "--format".into(), "{{.Image}}".into(), container.name.clone()];
let image = ide_docker::cli(endpoint, None, &inspect, QUICK, None)?.trim().to_owned();
if image.is_empty() {
return Err(IdeError::internal(format!("docker inspect printed no image for {}", container.name)));
}
let dir = target_dir.join(snapshots::dir_name(&image));
let before = snapshots::current(target_dir).map(|(_, s)| s.image);
if !force && snapshots::read(&dir).is_some() {
snapshots::make_current(target_dir, &image)?;
return Ok(before.as_deref() != Some(image.as_str()));
}
let partial = target_dir.join(format!("{}.partial", snapshots::dir_name(&image)));
if partial.exists() {
std::fs::remove_dir_all(&partial)?;
}
std::fs::create_dir_all(&partial)?;
let mut folders = Vec::new();
for folder in snapshots::folders_to_copy(&probe, &container_root) {
progress(&format!("Copying {folder}"));
let local = snapshots::local_folder(&partial, &folder);
let Some(parent) = local.parent() else { continue };
std::fs::create_dir_all(parent)?;
// Into an existing folder: `docker cp` puts the source folder in it under its own name.
let args = ["cp".into(), format!("{}:{folder}", container.name), format!("{}/", parent.to_string_lossy())];
match ide_docker::cli(endpoint, None, &args, COPY_TIMEOUT, None) {
Ok(_) => folders.push(folder),
Err(e) => tracing::warn!("cannot copy {folder} from {}: {}", container.name, e.message),
}
}
drop(container);
progress("Preparing the language server's interpreter");
let snapshot = Snapshot {
neutral_python: snapshots::neutral_python(cache, &probe.version),
image: image.clone(),
probe,
folders,
};
snapshots::write(&partial, &snapshot)?;
if dir.exists() {
std::fs::remove_dir_all(&dir)?;
}
std::fs::rename(&partial, &dir)?;
snapshots::make_current(target_dir, &image)?;
Ok(true)
}
/// Distinguishes probe containers.
fn unique() -> String {
use std::sync::atomic::{AtomicU32, Ordering};
static COUNT: AtomicU32 = AtomicU32::new(0);
let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs());
format!("{:x}-{}-{}", secs & 0xff_ffff, std::process::id(), COUNT.fetch_add(1, Ordering::Relaxed))
}
+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::*;
+29 -3
Переглянути файл
@@ -585,6 +585,21 @@ pub async fn vcs_stage(host: &Host, window: &str, paths: Vec<PathBuf>) -> IdeRes
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).
/// Returns the ignore files that were updated, so the UI can show them.
pub async fn vcs_add_to_ignore(
@@ -792,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
@@ -1462,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 });
@@ -1507,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 });
@@ -1520,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);
}
}
+131 -29
Переглянути файл
@@ -1642,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,
@@ -3044,7 +3047,16 @@ impl PluginWorkspace for HostWorkspace {
&project.root,
&project.settings.effective(),
);
if config.debug_adapter.is_none() {
if config.debug_adapter.is_none()
&& let Some(language) = config.extras.params.get(super::run_detectors::DEBUG_LANGUAGE)
{
config.debug_adapter = project
.languages
.get(&ide_core::LanguageId::new(language.as_str()))
.and_then(|l| l.debugger)
.map(|d| d.adapter);
}
if config.debug_adapter.is_none() && !config.extras.params.contains_key(super::run_detectors::DEBUG_ATTACH) {
let cwd = config.cwd.as_ref().map(|p| p.as_ref()).unwrap_or(&project.root);
config.debug_adapter =
config.args.iter().map(|arg| cwd.join(arg)).filter(|path| path.is_file()).find_map(|path| {
@@ -3176,26 +3188,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)
}
@@ -3321,6 +3324,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()
@@ -3332,7 +3337,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}"),
@@ -4130,25 +4136,25 @@ impl PluginApi {
{
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| f.note.is_some() || decoration_style(&f.style)),
file: file.filter(|f| f.note.is_some() || f.icon.is_some() || decoration_style(&f.style)),
})
} else {
None
@@ -4176,6 +4182,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!(
@@ -4188,6 +4248,8 @@ fn decoration_style(style: &str) -> bool {
| "warning"
| "error"
| "info"
| "typo"
| "weak"
| "underline"
| "strikethrough"
| "muted"
@@ -4308,8 +4370,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();
}
@@ -4324,7 +4389,8 @@ mod workbench_tests {
badge: Some("abc".into()),
style: "info".into(),
tooltip: None,
note: None
note: None,
icon: None,
})
)
.is_err()
@@ -4334,6 +4400,7 @@ mod workbench_tests {
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| {
@@ -4347,9 +4414,44 @@ mod workbench_tests {
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));
+19 -9
Переглянути файл
@@ -129,6 +129,7 @@ impl PluginHost {
}
let path = match event {
WorkbenchEvent::FileOpened(path) | WorkbenchEvent::FileSaved(path) => Path::new(path),
WorkbenchEvent::EditorFocused(Some(path)) => Path::new(path),
WorkbenchEvent::DocumentChanged(change) => Path::new(&change.path),
_ => return Some(event.clone()),
};
@@ -136,16 +137,25 @@ impl PluginHost {
return None;
}
let workspace = workspace.as_ref()?;
let real = ide_plugin::resolve_real(path);
// The file's own project, not the invoking window's: annotations follow
// the file, so a plugin hears about a file another window has open.
let root = match client {
Some(client) => workspace.project_of(client),
None => {
let real = ide_plugin::resolve_real(path);
workspace
.projects()
.into_iter()
.filter(|r| real.starts_with(ide_plugin::resolve_real(r)))
.max_by_key(|r| r.components().count())
}
Some(client) => workspace
.project_of(client)
.filter(|r| real.starts_with(ide_plugin::resolve_real(r)))
.or_else(|| {
workspace
.projects()
.into_iter()
.filter(|r| real.starts_with(ide_plugin::resolve_real(r)))
.max_by_key(|r| r.components().count())
}),
None => workspace
.projects()
.into_iter()
.filter(|r| real.starts_with(ide_plugin::resolve_real(r)))
.max_by_key(|r| r.components().count()),
}?;
if plugin.dev_root.as_deref().is_some_and(|r| r != root) {
return None;
+192 -2
Переглянути файл
@@ -77,9 +77,22 @@ pub(super) fn validate_reply(reply: &ContentTabReply) -> Result<(), HostError> {
Ok(())
}
/// Largest side of a plugin diff, in UTF-16 code units (earlier hosts allowed 1 MiB).
pub(super) const MAX_DIFF_SIDE: usize = 8 << 20;
fn diff_side(value: &str, side: &str) -> Result<(), HostError> {
if value.contains('\0') {
return Err(HostError::Invalid(format!("the {side} side of the diff contains NUL")));
}
if value.encode_utf16().count() > MAX_DIFF_SIDE {
return Err(HostError::Invalid(format!("diff too large: the {side} side is over {} MiB", MAX_DIFF_SIDE >> 20)));
}
Ok(())
}
pub(super) fn validate_diff(diff: &ide_plugin::ContentDiff) -> Result<(), HostError> {
body(&diff.left)?;
body(&diff.right)?;
diff_side(&diff.left, "left")?;
diff_side(&diff.right, "right")?;
for value in [&diff.left_label, &diff.right_label].into_iter().flatten() {
label(value, 128)?;
}
@@ -402,6 +415,67 @@ fn validate_configurations(
if let Some(cwd) = &c.cwd {
ide_plugin::resolve_new_in_root(root, cwd).map_err(HostError::Invalid)?;
}
validate_env(&c.env)?;
validate_debugger(c.debugger.as_ref())?;
}
Ok(())
}
/// Like `process` commands, a configuration only adds variables: one the IDE's own
/// environment has (`PATH`, …) would change what the approved program runs.
fn validate_env(env: &[(String, String)]) -> Result<(), HostError> {
if env.len() > 64 {
return Err(HostError::Invalid("at most 64 environment variables".into()));
}
let mut names = std::collections::BTreeSet::new();
for (name, value) in env {
if name.is_empty() || name.len() > 256 || name.contains(['=', '\0']) {
return Err(HostError::Invalid(format!("{name:?} is not an environment variable name")));
}
if value.len() > 4096 || value.contains('\0') {
return Err(HostError::Invalid(format!("the value of {name:?} is too long or holds a NUL")));
}
let key = if cfg!(windows) { name.to_ascii_uppercase() } else { name.clone() };
if !names.insert(key) || inherited_env(name) {
return Err(HostError::Invalid(format!(
"environment variable {name:?} is repeated or already set; only new variables may be added"
)));
}
}
Ok(())
}
fn inherited_env(name: &str) -> bool {
std::env::vars_os().any(|(inherited, _)| {
if cfg!(windows) { inherited.to_string_lossy().eq_ignore_ascii_case(name) } else { inherited == name }
})
}
/// Attach to Process kinds a configuration may attach to.
pub(super) const ATTACH_KINDS: [&str; 5] = ["dotnet", "node", "python", "go", "native"];
fn validate_debugger(debugger: Option<&ide_plugin::PluginDebugger>) -> Result<(), HostError> {
match debugger {
Some(ide_plugin::PluginDebugger::Launch { language }) => {
if language.is_empty()
|| language.len() > 64
|| !language.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
{
return Err(HostError::Invalid(format!("{language:?} is not a language id")));
}
}
Some(ide_plugin::PluginDebugger::AttachChild { kind, command_contains }) => {
if !ATTACH_KINDS.contains(&kind.as_str()) {
return Err(HostError::Invalid(format!(
"attach-child kind {kind:?} is not one of {}",
ATTACH_KINDS.join(", ")
)));
}
if command_contains.as_ref().is_some_and(|t| t.is_empty() || t.len() > 256 || t.contains('\0')) {
return Err(HostError::Invalid("attach-child command-contains must be 1-256 bytes".into()));
}
}
None => {}
}
Ok(())
}
@@ -518,6 +592,52 @@ impl PluginHost {
}
}
impl PluginHost {
/// The process an `attach-child` configuration's debugger attaches to: the first Attach to
/// Process candidate of its kind that `run` (a run of this configuration) started, waited
/// for while the run lasts, up to a minute. Only the run's own descendants are candidates.
pub fn debug_attach_target(
&self,
host: &crate::Host,
client: &str,
config: &str,
run: ide_core::RunId,
) -> IdeResult<ide_dap::attach::AttachableProcess> {
use super::run_detectors::{DEBUG_ATTACH, DEBUG_ATTACH_CONTAINS};
let Some((plugin, id)) = config.strip_prefix(ide_run::EXTERNAL_PREFIX).and_then(|s| s.split_once(':')) else {
return Err(IdeError::invalid("not a plugin configuration"));
};
let workspace = self.shared.workspace().ok_or_else(IdeError::cancelled)?;
let owned = workspace.run_configuration(client, plugin, id).map_err(|e| IdeError::invalid(e.to_string()))?;
let kind = owned
.extras
.params
.get(DEBUG_ATTACH)
.filter(|k| ATTACH_KINDS.contains(&k.as_str()))
.ok_or_else(|| IdeError::invalid("this configuration does not attach to a process"))?
.clone();
let contains = owned.extras.params.get(DEBUG_ATTACH_CONTAINS).cloned();
let deadline = Instant::now() + Duration::from_secs(60);
loop {
let info = crate::ops::run::list(host).into_iter().find(|r| r.id == run && r.config_id == owned.id);
let Some(pid) = info.as_ref().filter(|r| r.running).and_then(|r| r.pid) else {
return Err(IdeError::not_found("the run ended before a process to debug started"));
};
let found = ide_dap::attach::attachable_descendants(pid)?.into_iter().find(|p| {
serde_json::to_value(p.kind).ok().and_then(|v| v.as_str().map(|k| k == kind)) == Some(true)
&& contains.as_ref().is_none_or(|t| p.command_line.contains(t.as_str()))
});
if let Some(process) = found {
return Ok(process);
}
if Instant::now() >= deadline {
return Err(IdeError::not_found(format!("no {kind} process started by the run within a minute")));
}
std::thread::sleep(Duration::from_millis(500));
}
}
}
/// Internal launch metadata; it is ignored after the original operation finishes.
pub(super) const RUN_OPERATION: &str = "plugin.operation";
impl PluginHost {
@@ -571,3 +691,73 @@ impl PluginHost {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn diff(left: String, right: String) -> ide_plugin::ContentDiff {
ide_plugin::ContentDiff { left, right, left_label: None, right_label: None, title: None }
}
#[test]
fn diff_sides_up_to_the_limit_open_and_larger_ones_say_why() {
assert!(validate_diff(&diff("x".repeat(5 << 20), "y".repeat(MAX_DIFF_SIDE))).is_ok());
let err = validate_diff(&diff(String::new(), "y".repeat(MAX_DIFF_SIDE + 1))).unwrap_err();
assert!(
matches!(&err, HostError::Invalid(m) if m == "diff too large: the right side is over 8 MiB"),
"{err:?}"
);
assert!(
matches!(validate_diff(&diff("a\0".into(), String::new())), Err(HostError::Invalid(m)) if m.contains("NUL"))
);
}
fn launch(
env: Vec<(&str, &str)>,
debugger: Option<ide_plugin::PluginDebugger>,
) -> ide_plugin::PluginRunConfiguration {
ide_plugin::PluginRunConfiguration {
id: "app".into(),
name: "App".into(),
program: "tool".into(),
env: env.into_iter().map(|(k, v)| (k.into(), v.into())).collect(),
debugger,
..Default::default()
}
}
#[test]
fn configurations_only_add_environment_variables() {
let root = std::env::temp_dir();
let ok = |c| validate_configurations(&root, &[c]).is_ok();
assert!(ok(launch(vec![("OPENIDEA_TEST_NEW_VARIABLE", "1")], None)));
let (inherited, _) = std::env::vars().next().expect("the test runs with an environment");
assert!(!ok(launch(vec![(inherited.as_str(), "x")], None)));
assert!(!ok(launch(vec![("A_B", "1"), ("A_B", "2")], None)));
for name in ["", "A=B", "A\0"] {
assert!(!ok(launch(vec![(name, "1")], None)), "{name:?}");
}
assert!(!ok(launch(vec![("OPENIDEA_TEST_NUL", "a\0")], None)));
let many: Vec<_> = (0..65).map(|i| (format!("OPENIDEA_TEST_{i}"), String::new())).collect();
let mut config = launch(vec![], None);
config.env = many;
assert!(!ok(config));
}
#[test]
fn configurations_name_a_language_or_an_attach_to_process_kind() {
use ide_plugin::PluginDebugger::{AttachChild, Launch};
let root = std::env::temp_dir();
let ok = |d| validate_configurations(&root, &[launch(vec![], Some(d))]).is_ok();
assert!(ok(Launch { language: "csharp".into() }));
assert!(!ok(Launch { language: String::new() }));
assert!(!ok(Launch { language: "c sharp".into() }));
for kind in ATTACH_KINDS {
assert!(ok(AttachChild { kind: kind.into(), command_contains: None }), "{kind}");
}
assert!(!ok(AttachChild { kind: "java".into(), command_contains: None }));
assert!(ok(AttachChild { kind: "dotnet".into(), command_contains: Some("Worker.dll".into()) }));
assert!(!ok(AttachChild { kind: "dotnet".into(), command_contains: Some(String::new()) }));
}
}
+25 -2
Переглянути файл
@@ -396,15 +396,37 @@ impl PluginHost {
}
}
pub(super) fn has_document_providers(&self, kind: ProviderKind, doc: &ProviderDocument) -> bool {
self.provider_plugins(kind, &doc.language)
.into_iter()
.any(|p| p.dev_root.as_ref().is_none_or(|r| doc.path.starts_with(r)))
}
pub fn provide_completion(
&self,
doc: &ProviderDocument,
at: ide_core::Pos,
trigger: Option<&str>,
) -> Vec<(String, CompletionItem)> {
self.feature_documents(doc, Some(at))
self.provide_completion_for_editor(doc, at, trigger).1
}
pub(crate) fn provide_completion_for_editor(
&self,
doc: &ProviderDocument,
at: ide_core::Pos,
trigger: Option<&str>,
) -> (bool, Vec<(String, CompletionItem)>) {
// An injection-capable outer document (e.g. SQL embedded in text) may complete at other
// positions even when `at` is outside an injection, so avoid marking document-wide backoff.
let mut covered = self.has_document_providers(ProviderKind::Injections, doc);
let items = self
.feature_documents(doc, Some(at))
.into_iter()
.flat_map(|(doc, bounds)| {
if self.has_document_providers(ProviderKind::Completion, &doc) {
covered = true;
}
let (d, t) = (Arc::new(doc.clone()), trigger.map(str::to_owned));
self.document_calls(ProviderKind::Completion, &doc, move |rt, timeout| {
rt.provide_completion(&d, at, t.as_deref(), timeout)
@@ -419,7 +441,8 @@ impl PluginHost {
Some((p, i))
})
})
.collect()
.collect();
(covered, items)
}
pub fn provide_code_actions(
+10
Переглянути файл
@@ -57,6 +57,7 @@ pub const COMMANDS: &[&str] = &[
"plugin_terminal_session_close",
"plugin_terminal_session_spawn",
"plugin_prepare_debug_run",
"plugin_debug_attach_target",
"plugin_finish_operation",
"plugin_content_tab_action",
"plugin_post_events",
@@ -572,6 +573,15 @@ impl RemotePlugins {
}
});
let h = host.clone();
d.add("plugin_debug_attach_target", move |c| {
let h = h.clone();
async move {
let plugins = alive(&h)?;
let (config, run) = (c.arg::<String>("config")?, c.arg("run")?);
blocking(move || plugins.debug_attach_target(&c.host, &c.window, &config, run)).await
}
});
let h = host.clone();
d.add("plugin_finish_operation", move |c| {
let h = h.clone();
async move {
+18
Переглянути файл
@@ -130,6 +130,11 @@ pub struct PluginRunDetection {
pub(crate) project_pins: super::api::ProjectPins,
}
/// `RunConfig.extras.params` keys carrying an API 1.9 configuration's debugger.
pub const DEBUG_LANGUAGE: &str = "debugLanguage";
pub const DEBUG_ATTACH: &str = "debugAttach";
pub const DEBUG_ATTACH_CONTAINS: &str = "debugAttachContains";
pub(super) fn config_id(plugin: &str, detector: &str) -> String {
format!("{EXTERNAL_PREFIX}{plugin}:{detector}")
}
@@ -195,6 +200,19 @@ impl ExternalDetection for PluginRunDetection {
if let Some(framework) = &c.framework {
config.extras.params.insert("framework".into(), framework.clone());
}
config.env = c.env.iter().cloned().collect();
match &c.debugger {
Some(ide_plugin::PluginDebugger::Launch { language }) => {
config.extras.params.insert(DEBUG_LANGUAGE.into(), language.clone());
}
Some(ide_plugin::PluginDebugger::AttachChild { kind, command_contains }) => {
config.extras.params.insert(DEBUG_ATTACH.into(), kind.clone());
if let Some(text) = command_contains {
config.extras.params.insert(DEBUG_ATTACH_CONTAINS.into(), text.clone());
}
}
None => {}
}
configs.push(config);
}
}
+46 -5
Переглянути файл
@@ -352,6 +352,15 @@ where
deserializer.deserialize_map(Properties)
}
/// A sample shown in a setting's own style (the Colour Scheme colours), e.g. the
/// message an Inspection Lens severity produces.
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
pub struct SettingExample {
pub text: String,
/// `error`, `warning`, `weak`, `typo`, `info` or `muted`: which sample style to draw.
pub style: String,
}
/// One setting of a plugin.
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -371,6 +380,8 @@ pub struct SettingSchema {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub group: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub example: Option<SettingExample>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub items: Option<Box<SettingSchema>>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub properties: Vec<SettingSchema>,
@@ -455,6 +466,20 @@ fn setting_at(key: &str, raw: &serde_json::value::RawValue, depth: u8) -> Result
Some(_) => Err(format!("setting {key:?}: `{name}` is a string up to {max} bytes")),
}
};
let example = match o.get("example") {
None => None,
Some(v) => {
let e: SettingExample = serde_json::from_value(v.clone())
.map_err(|e| format!("setting {key:?}: `example` is {{\"text\", \"style\"}}: {e}"))?;
if e.text.is_empty() || e.text.len() > 300 {
return Err(format!("setting {key:?}: `example.text` is 1–300 bytes"));
}
if e.style.len() > 32 || !e.style.chars().all(|c| c.is_ascii_lowercase()) {
return Err(format!("setting {key:?}: `example.style` is a lowercase style name"));
}
Some(e)
}
};
let s = SettingSchema {
key: key.to_owned(),
kind: kind.to_owned(),
@@ -464,6 +489,7 @@ fn setting_at(key: &str, raw: &serde_json::value::RawValue, depth: u8) -> Result
options,
multiline: o.get("multiline").and_then(Value::as_bool).unwrap_or(false),
group: text("group", MAX_TITLE)?,
example,
items,
properties,
};
@@ -717,6 +743,7 @@ pub(super) fn custom_editor(pkg: &Package, decl: toml::Value) -> Result<UiDecl,
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct FileIconDecl {
id: String,
#[serde(default)]
patterns: Vec<String>,
icon: PathBuf,
#[serde(default)]
@@ -728,13 +755,19 @@ pub fn file_icon_asset(id: &str, dark: bool) -> String {
format!("fileIcon/{id}/{}", if dark { "dark" } else { "light" })
}
/// `[[contributes.fileIcons]]`: an icon for the files matching `patterns`,
/// whatever icon theme is in use (the user's own file type icons still win).
/// `[[contributes.fileIcons]]`: an icon for the files matching `patterns` (or
/// assigned dynamically through `decorations.set-file-icon` when `patterns` is
/// omitted in API 1.8), whatever icon theme is in use (the user's own file type
/// icons still win).
pub(super) fn file_icon(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
let d: FileIconDecl = decode(FILE_ICONS, decl)?;
validate_item_id(&d.id, "file icon id")?;
let what = format!("file icon {:?}", d.id);
file_patterns(&d.patterns, &what)?;
if d.patterns.is_empty() {
pkg.manifest.needs_api("contributes.fileIcons without patterns", 8)?;
} else {
file_patterns(&d.patterns, &what)?;
}
let mut files = Vec::new();
let mut assets = Vec::new();
for (path, dark) in [(Some(&d.icon), false), (d.icon_dark.as_ref(), true)] {
@@ -743,8 +776,16 @@ pub(super) fn file_icon(pkg: &Package, decl: toml::Value) -> Result<UiDecl, Stri
assets.push((file_icon_asset(&d.id, dark), svg));
files.push(path.clone());
}
let json = serde_json::json!({ "id": d.id, "patterns": d.patterns, "dark": d.icon_dark.is_some() });
let mut out = UiDecl::new(d.id.clone(), d.patterns.join(", "), json);
let bytes = serde_json::to_vec(&assets).map_err(|e| e.to_string())?;
let revision = blake3::hash(&bytes).to_hex().to_string();
let json = serde_json::json!({
"id": d.id,
"patterns": d.patterns,
"dark": d.icon_dark.is_some(),
"revision": revision,
});
let label = if d.patterns.is_empty() { d.id.clone() } else { d.patterns.join(", ") };
let mut out = UiDecl::new(d.id.clone(), label, json);
out.files = files;
out.assets = assets;
Ok(out)
+72 -15
Переглянути файл
@@ -88,6 +88,9 @@ impl Host {
let root = dunce::canonicalize(root)?;
let opening = self.opening(window);
let _opening = opening.lock();
// Untitled buffers belong to the window, not a project: they follow it
// into the project's fresh document registry.
let carried = self.project(window).ok().map(|prev| prev.documents.untitled_buffers()).unwrap_or_default();
// Reject a damaged manifest before replacing the currently open project.
let is_workspace = ide_workspace::workspace::Workspace::load(&root)?.is_some();
// The window reloaded (or asked again) for the project it already has: keep
@@ -174,6 +177,7 @@ impl Host {
let root = member.as_ref().unwrap_or(window_root);
ide_sdk::apply::server_launch_overrides(cfg, &effective);
ide_sdk::apply::configure_server(cfg, root, server_root, &effective);
crate::ops::sdk::container_python::configure_server(cfg, &data_dir, root, &effective);
if cfg.name == "jdtls" {
// A workspace member keeps its build model under its own key (Build Project).
let cache = match &member {
@@ -447,6 +451,11 @@ impl Host {
.collect();
matches!(rest.as_slice(), [first, ..] if first == "worktrees" && rest.len() <= 2)
});
// A repository folder moved or restored into the project: no `.git` event is
// written inside it before the watcher sees the new directory, its creation is
// the signal. A `git clone` / `git init` inside the project already arrives
// through the `.git` paths above; this covers the whole tree being brought in.
git_dirs |= batch.created.iter().any(|p| p.join(".git").exists());
if git_dirs {
scan_repositories(&root, &repos, &settings, &sink, &build_cache);
}
@@ -615,6 +624,9 @@ impl Host {
}));
}
self.insert_project(window, project.clone());
for (key, text, dirty) in &carried {
let _ = project.documents.restore_untitled_buffer(key, text, *dirty);
}
project.lsp.prewarm_java_configuration();
if let Some(bridge) = &project.kotlin_bridge {
bridge.start(&self.runtime);
@@ -625,6 +637,7 @@ impl Host {
if let Some(cmd) = post_start {
self.environment.spawn_post_start(cmd, project.root.clone(), project.sink.clone());
}
crate::ops::sdk::container_python::ensure(self, &project);
Ok(project)
}
@@ -659,6 +672,7 @@ impl Host {
.join(project_key)
}),
models_file: self.data_dir.join("agents").join("models.json"),
catalogs: self.agent_catalogs.clone(),
custom_agents_dir: Some(self.config_dir.join("agents")),
home_dir: dirs::home_dir(),
env_secrets: Some(Arc::new(AgentEnvSecrets {
@@ -675,9 +689,10 @@ impl Host {
agents.set_new_task_approvals_hook(Arc::new(move || settings.effective().agents.new_task_approvals));
}
let approvals = self.mcp_approvals.clone();
let secrets = self.agent_secrets.clone();
let root = root.to_path_buf();
agents.set_mcp_servers_hook(Box::new(move || {
crate::ops::agent::session_mcp_servers(&settings, &approvals, &root)
crate::ops::agent::session_mcp_servers(&settings, &approvals, &secrets, &root)
}));
(db, http_history, agents)
}
@@ -694,12 +709,29 @@ impl Host {
return Err(IdeError::invalid(format!("{} is a folder", file.display())));
}
let dir = file.parent().map(Path::to_path_buf).ok_or_else(|| IdeError::invalid("no parent folder"))?;
let watch = dir.clone();
self.open_light_in(window, dir, Some(&watch))
}
/// LightEdit without a file (a welcome window's File › New File): rooted
/// at the user's home folder, watching nothing until a buffer is saved
/// into a real folder ([`Project::watch_light_dir`]).
pub fn open_light_empty(&self, window: &str) -> IdeResult<Arc<Project>> {
let dir = dirs::home_dir().unwrap_or_else(std::env::temp_dir);
self.open_light_in(window, dir, None)
}
fn open_light_in(&self, window: &str, dir: PathBuf, watch_dir: Option<&Path>) -> IdeResult<Arc<Project>> {
let opening = self.opening(window);
let _opening = opening.lock();
// Untitled buffers belong to the window: they follow it out of a project.
let carried = self.project(window).ok().map(|prev| prev.documents.untitled_buffers()).unwrap_or_default();
if let Ok(current) = self.project(window)
&& current.light
{
current.watch_light_dir(&dir);
if let Some(dir) = watch_dir {
current.watch_light_dir(dir);
}
return Ok(current);
}
self.close_project(window);
@@ -718,7 +750,16 @@ impl Host {
let syntax = Arc::new(SyntaxCache::new(self.syntax()));
documents.add_observer(Arc::new(SyntaxObserver(syntax.clone())));
documents.add_observer(Arc::new(HistoryObserver(history.clone())));
let watcher = light_watcher(&dir, &sink, &documents, &history)?;
let watcher = match watch_dir {
Some(dir) => light_watcher(dir, &sink, &documents, &history)?,
// A quiet private folder stands in until the first save brings a
// real folder in.
None => {
let idle = self.local_data_dir.join("light-edit");
std::fs::create_dir_all(&idle)?;
light_watcher(&idle, &sink, &documents, &history)?
}
};
let project_key = project_key(&dir);
let (db, http_history, agents) = self.project_services(&dir, &project_key, &settings, &sink);
let notebooks = crate::notebook::NotebookService::new(
@@ -764,8 +805,11 @@ impl Host {
light: true,
root: dir,
});
tracing::info!("window {window} edits {} without a project", file.display());
tracing::info!("window {window} edits {} without a project", project.root.display());
self.insert_project(window, project.clone());
for (key, text, dirty) in &carried {
let _ = project.documents.restore_untitled_buffer(key, text, *dirty);
}
Ok(project)
}
}
@@ -863,6 +907,20 @@ fn scan_repositories(
sink: &SharedSink,
build_cache: &Path,
) {
match discover_repositories(root, settings, build_cache) {
Ok(found) => {
if found.roots() != repos.read().roots() {
*repos.write() = found;
sink.emit(IdeEvent::VcsChanged { root: root.to_path_buf().into() });
}
}
Err(e) => tracing::warn!("looking for git repositories: {e}"),
}
}
/// The repository containing `root` and those nested below it or below a content root.
/// The project-open scan and the watcher's rescan share these skip rules.
fn discover_repositories(root: &Path, settings: &SettingsStore, build_cache: &Path) -> IdeResult<Repos> {
let ignored = settings.effective().ignored_names();
let skip = |p: &Path| {
p.file_name().is_some_and(|n| {
@@ -874,15 +932,7 @@ fn scan_repositories(
};
let mut roots = vec![root.to_path_buf()];
roots.extend(content_roots(&settings.effective(), root, build_cache));
match Repos::discover_roots(&roots, &skip) {
Ok(found) => {
if found.roots() != repos.read().roots() {
*repos.write() = found;
sink.emit(IdeEvent::VcsChanged { root: root.to_path_buf().into() });
}
}
Err(e) => tracing::warn!("looking for git repositories: {e}"),
}
Repos::discover_roots(&roots, &skip)
}
impl Project {
@@ -1225,7 +1275,9 @@ impl Project {
}
let defaults = self.documents.file_defaults();
let fallback = defaults.fallback_for(path);
if path.exists() {
// The working file only when it is a regular file: a path that is a directory there
// (a committed symlink to a folder, a file replaced by a folder) has only its blob.
if path.is_file() {
let loaded = ide_vfs::load_text_with_fallback(path, fallback)?;
return encoding_rs::Encoding::for_label(loaded.encoding.as_bytes())
.ok_or_else(|| ide_core::IdeError::invalid("unknown file encoding"));
@@ -1443,7 +1495,7 @@ fn content_root_watcher(
) -> Option<Watcher> {
let (kotlin_bridge, phpstan) = (kotlin_bridge.cloned(), phpstan.cloned());
let (index, documents, history, lsp) = (index.clone(), documents.clone(), history.clone(), lsp.clone());
let root = dir.to_path_buf();
let (root, events) = (dir.to_path_buf(), sink.clone());
Watcher::start(
dir,
excludes.to_vec(),
@@ -1465,6 +1517,11 @@ fn content_root_watcher(
}
documents.on_disk_changed(&[&changed[..], &ignored].concat());
record_external_changes(&history, &changed, &batch.removed);
// A member outside the project folder: its repository changes (commit, checkout,
// fetch, pull) reach the UI only through this watcher.
if batch.vcs.iter().any(|p| vcs_path_changed(p)) {
events.emit(IdeEvent::VcsChanged { root: root.clone().into() });
}
}),
)
.map_err(|e| tracing::warn!("content root watcher {}: {e}", dir.display()))
+9 -1
Переглянути файл
@@ -218,6 +218,13 @@ impl Dispatcher {
})
.add("fs_read_text", |c| async move { ops::fs::read_text(&c.host, &c.window, c.arg("path")?).await })
.add("doc_open", |c| async move { ops::doc::open(&c.host, &c.window, c.arg("path")?).await })
.add("doc_open_untitled", |c| async move {
ops::doc::open_untitled(&c.host, &c.window, c.arg::<Option<String>>("name")?).await
})
.add(
"doc_save_as",
|c| async move { ops::doc::save_as(&c.host, &c.window, c.arg("doc")?, c.arg("path")?).await },
)
.add("doc_close", |c| async move {
ops::doc::blocking(move || ops::doc::close(&c.host, &c.window, c.arg("doc")?)).await
})
@@ -270,7 +277,8 @@ impl Dispatcher {
.add("run_resize", |c| async move { ops::run::resize(&c.host, c.arg("id")?, c.arg("cols")?, c.arg("rows")?) })
.add("run_close_stdin", |c| async move { ops::run::close_stdin(&c.host, c.arg("id")?) })
.add("run_list", |c| async move { Ok(ops::run::list(&c.host)) })
.add("vcs_status", |c| async move { ops::vcs::status(&c.host, &c.window).await });
.add("vcs_status", |c| async move { ops::vcs::status(&c.host, &c.window).await })
.add("vcs_status_paths", |c| async move { ops::vcs::status_paths(&c.host, &c.window, c.arg("paths")?).await });
ops::register_all(&mut d);
d
}
+148 -30
Переглянути файл
@@ -289,45 +289,151 @@ impl crate::Host {
}
}
project.lsp.reconfigure().await;
crate::ops::sdk::container_python::ensure(self, &project);
project.sink.emit(ide_core::IdeEvent::ProjectTrustChanged { root: project.root.clone().into(), trusted });
}
Ok(())
}
}
/// Commands which only inspect/edit source or operate user-owned application state.
/// New execution commands are denied until deliberately classified here.
/// Commands allowed in Restricted Mode, by exact name: those which only inspect/edit source or
/// operate user-owned application state. A new command is denied until deliberately listed here.
pub fn restricted_command_allowed(cmd: &str) -> bool {
if [
"fs_",
"doc_",
"syntax_",
"search_",
"nav_",
"history_",
"spell_",
"analysis_",
"structural_",
"refactor_",
"bytes_",
"window_",
"window_group_",
"term_",
"settings_",
"project_trust_",
// New Project writes only where the user chose and runs no code of an open project.
"scaffold_",
"ssh_",
"git_credential",
]
.iter()
.any(|p| cmd.starts_with(p))
{
return true;
}
matches!(
cmd,
"app_info"
"fs_compare_directories"
| "fs_copy"
| "fs_copy_merge"
| "fs_create_dir"
| "fs_create_file"
| "fs_create_file_bytes"
| "fs_delete"
| "fs_entry_kind"
| "fs_open_with_system"
// The user's own diff program (global settings) on the diff's two sides.
| "diff_external_tool"
| "fs_probe"
| "fs_read_dir"
| "fs_read_image"
| "fs_read_range"
| "fs_read_text"
| "fs_rename"
| "fs_resolve_reference"
| "fs_set_writable"
| "doc_close"
| "doc_edit"
| "doc_folding"
| "doc_highlights"
| "doc_open"
| "doc_open_untitled"
| "doc_open_virtual"
| "doc_outline"
| "doc_python_facts"
| "doc_save"
| "doc_save_as"
| "doc_save_all"
| "doc_selection_ranges"
| "doc_set_bom"
| "doc_set_encoding"
| "doc_set_line_ending"
| "doc_snapshot"
| "doc_sync_from_disk"
| "doc_ui_mode"
| "syntax_errors"
| "syntax_expression_at"
| "syntax_identifiers"
| "syntax_identifiers_in_files"
| "syntax_locals"
| "syntax_node_at"
| "syntax_structural_ranges"
| "syntax_support"
| "search_start"
| "search_validate"
| "nav_index_definitions"
| "nav_inheritance"
| "nav_resolve_module"
| "history_content"
| "history_list"
| "history_list_dir"
| "history_put_label"
| "history_restore"
| "spell_check"
| "spell_suggest"
| "analysis_locate_duplicates"
| "structural_languages"
| "structural_search"
| "structural_validate"
| "refactor_can_undo_file_move"
| "refactor_file_move_edits"
| "refactor_file_move_text_edits"
| "refactor_move_files"
| "refactor_undo_file_move"
| "window_close_context"
| "window_complete_launch"
| "window_cycle"
| "window_flash_taskbar"
| "window_group_cancel_close"
| "window_group_close"
| "window_group_close_tabs"
| "window_group_current"
| "window_group_detach"
| "window_group_merge"
| "window_group_reorder"
| "window_group_restore"
| "window_group_select"
| "window_is_fullscreen"
| "window_new"
| "window_open_comparison"
| "window_open_deployment"
| "window_open_diff"
| "window_open_file_in_new_window"
| "window_open_files"
| "window_open_project"
| "window_open_projects"
| "window_open_remote"
| "window_open_settings"
| "window_set_titlebar_height"
| "window_start_drag"
| "window_take_launch"
| "window_toggle_fullscreen"
| "window_toggle_zoom"
| "term_available_shells"
| "term_default_shell"
| "term_is_busy"
| "term_kill"
| "term_resize"
| "term_spawn"
| "term_store_image"
| "term_write"
| "settings_export"
| "settings_get"
| "settings_get_global"
| "settings_import"
| "settings_list_jetbrains_ides"
| "settings_read_jetbrains"
| "settings_set"
| "project_trust_add_location"
| "project_trust_list"
| "project_trust_remove_location"
| "project_trust_set"
| "project_trust_status"
// New Project writes only where the user chose and runs no code of an open project.
| "scaffold_catalog"
| "scaffold_create"
| "scaffold_options"
| "scaffold_requirements"
| "ssh_askpass_answer"
| "ssh_connected"
| "ssh_disconnect"
| "ssh_needs_secret"
| "ssh_open_terminal"
| "ssh_password_saved"
| "ssh_set_secret"
| "ssh_test"
| "ssh_trust_host"
| "git_credential_forget"
| "git_credentials"
| "app_info"
| "app_build_info"
| "app_cli_install"
| "app_cli_uninstall"
@@ -341,6 +447,7 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
| "clear_caches"
| "clipboard_file_list"
| "clipboard_has_files"
| "clipboard_has_image"
| "clipboard_image"
| "clipboard_set_file_list"
| "clipboard_write_text"
@@ -357,6 +464,7 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
| "docker_set_registry_password"
| "docker_run_env"
| "db_console_path"
| "db_consoles"
| "db_export_file"
| "vcs_write_patch"
| "app_metrics"
@@ -399,6 +507,7 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
| "web_unresolved_references"
| "project_rename"
| "light_edit_open"
| "light_edit_open_untitled"
| "workspace_create"
| "workspace_discover_projects"
| "workspace_update"
@@ -475,6 +584,7 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
| "vcs_clone"
| "vcs_init"
| "vcs_status"
| "vcs_status_paths"
| "vcs_roots"
| "vcs_branch_info"
| "vcs_line_hunks"
@@ -568,6 +678,14 @@ mod tests {
assert!(super::restricted_command_allowed("clipboard_write_text"));
}
#[test]
fn restricted_mode_denies_new_commands_whatever_their_name() {
for cmd in ["fs_new_command", "term_new_command", "ssh_new_command", "settings_new_command", "git_credential_x"]
{
assert!(!super::restricted_command_allowed(cmd), "{cmd} is allowed without being listed");
}
}
#[test]
fn remote_project_decisions_are_kept_on_this_machine() {
let tmp = tempfile::tempdir().unwrap();
+98
Переглянути файл
@@ -0,0 +1,98 @@
//! Separate project windows share discovery and its backend-enforced cooldown.
#![cfg(unix)]
use ide_agent::AgentEvent;
use ide_core::NullSink;
use ide_host::{Host, HostConfig, ops};
use std::sync::Arc;
use std::time::Duration;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn windows_share_model_discovery_and_manual_refresh_cooldown() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("project");
let config = tmp.path().join("config");
std::fs::create_dir_all(&root).unwrap();
std::fs::create_dir_all(config.join("agents")).unwrap();
let script = tmp.path().join("catalog.sh");
std::fs::write(
&script,
r#"#!/bin/sh
printf 'launch\n' >> "${0%/*}/launches"
while IFS= read -r line; do
case "$line" in
*'"id":'*) rest=${line#*\"id\":}; id=${rest%%,*}; id=${id%%\}*} ;;
*) continue ;;
esac
case "$line" in
*'"method":"initialize"'*)
printf '{"jsonrpc":"2.0","id":%s,"result":{"protocolVersion":1,"agentCapabilities":{}}}\n' "$id" ;;
*'"method":"session/new"'*)
while [ ! -f "${0%/*}/release" ]; do sleep 0.01; done
printf '{"jsonrpc":"2.0","id":%s,"result":{"sessionId":"catalog","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"new-model","options":[{"value":"new-model","name":"New model"}]}]}}\n' "$id" ;;
esac
done
"#,
)
.unwrap();
std::fs::write(
config.join("agents/catalog-test.toml"),
format!(
"name = \"Catalog test\"\ncommand = \"/bin/sh\"\nargs = [{}]\n",
serde_json::to_string(&script).unwrap()
),
)
.unwrap();
let host = Host::new(HostConfig {
config_dir: config,
data_dir: tmp.path().join("data"),
local_data_dir: tmp.path().join("local"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
owner: |window| window,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
host.trust.decide(&root, true, false).unwrap();
host.open_project("first", &root).unwrap();
host.open_project("second", &root).unwrap();
let (send, mut events) = tokio::sync::mpsc::unbounded_channel();
for window in ["first", "second"] {
let send = send.clone();
host.project(window).unwrap().agents.subscribe(Arc::new(move |event| {
if let AgentEvent::ModelCatalog { catalog } = event {
let _ = send.send((window, catalog));
}
}));
}
for _ in 0..20 {
for window in ["first", "second"] {
let state = ops::agent::refresh_models(&host, window, "catalog-test".into(), true).unwrap();
assert!(state.refreshing);
}
}
std::fs::write(tmp.path().join("release"), "").unwrap();
let completed = tokio::time::timeout(Duration::from_secs(10), async {
let mut windows = std::collections::HashSet::new();
while windows.len() < 2 {
let (window, catalog) = events.recv().await.expect("catalog event stream closed");
if !catalog.refreshing && catalog.refreshed_at_ms.is_some() {
assert!(catalog.error.is_none(), "{:?}", catalog.error);
windows.insert(window);
}
}
})
.await;
assert!(completed.is_ok(), "both windows must receive the completed catalog");
for _ in 0..20 {
for window in ["first", "second"] {
let catalog = ops::agent::refresh_models(&host, window, "catalog-test".into(), true).unwrap();
assert!(!catalog.refreshing);
assert!(catalog.retry_at_ms.is_some());
let models = catalog.config.iter().find(|o| o.category.as_deref() == Some("model")).unwrap();
assert_eq!(models.values[0].value, "new-model");
}
}
assert_eq!(std::fs::read_to_string(tmp.path().join("launches")).unwrap(), "launch\n");
}
+174
Переглянути файл
@@ -0,0 +1,174 @@
//! A Compose run target as the Python interpreter, against a real daemon and the real
//! basedpyright. Ignored by default: `OPENIDEA_DOCKER_TEST_DIR` names an empty folder the
//! daemon can bind-mount (on this machine, or at the same path in a VM such as OrbStack's),
//! `docker` with the Compose plugin and `basedpyright-langserver` must be on `PATH`, and
//! this machine's Python must not have Django installed.
#![cfg(unix)]
use ide_core::IdeEvent;
use ide_host::{Host, HostConfig, ops};
use ide_run::{OutputChunk, RunConfig};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
const WINDOW: &str = "main";
type Events = Arc<Mutex<Vec<IdeEvent>>>;
fn host(base: &Path, events: &Events) -> Arc<Host> {
Arc::new(Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new({
let events = events.clone();
move |_| {
let events = events.clone();
Arc::new(move |e: IdeEvent| events.lock().unwrap().push(e))
}
}),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
}))
}
/// basedpyright's latest diagnostics of `path` once `done` accepts them.
async fn diagnostics(events: &Events, path: &Path, timeout: Duration, done: impl Fn(&[String]) -> bool) -> Vec<String> {
let deadline = Instant::now() + timeout;
loop {
let latest = events.lock().unwrap().iter().rev().find_map(|e| match e {
IdeEvent::Diagnostics { path: p, items, source } if **p == *path && source.contains("basedpyright") => {
Some(items.iter().map(|d| d.message.clone()).collect::<Vec<_>>())
}
_ => None,
});
if let Some(messages) = &latest
&& done(messages)
{
return messages.clone();
}
assert!(Instant::now() < deadline, "timed out; last diagnostics: {latest:?}");
tokio::time::sleep(Duration::from_millis(500)).await;
}
}
fn unresolved(messages: &[String]) -> bool {
messages.iter().any(|m| m.contains("\"django") && m.contains("could not be resolved"))
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "requires a Docker daemon, basedpyright and OPENIDEA_DOCKER_TEST_DIR"]
async fn compose_target_is_the_python_interpreter() {
let root = PathBuf::from(std::env::var("OPENIDEA_DOCKER_TEST_DIR").expect("OPENIDEA_DOCKER_TEST_DIR"));
let root = dunce::canonicalize(root).unwrap();
let files = [
("compose.yaml", "services:\n web:\n build: .\n volumes:\n - .:/app\n working_dir: /app\n"),
(
"Dockerfile",
"FROM python:3.12-slim\nCOPY requirements.txt /tmp/requirements.txt\n\
RUN pip install --no-cache-dir -r /tmp/requirements.txt\n",
),
("requirements.txt", "Django==5.1.3\n"),
(
"app/main.py",
"import django\nfrom django.http import HttpResponse\n\nprint(django.get_version(), HttpResponse)\n",
),
];
for (path, text) in files {
std::fs::create_dir_all(root.join(path).parent().unwrap()).unwrap();
std::fs::write(root.join(path), text).unwrap();
}
let main = root.join("app/main.py");
let state = tempfile::tempdir().unwrap();
let events: Events = Arc::default();
let host = host(state.path(), &events);
host.trust.decide(&root, true, false).unwrap();
host.open_project(WINDOW, &root).unwrap();
let set = |key: &str, value: serde_json::Value| {
ops::project::set_settings(
&host,
WINDOW,
ide_workspace::settings::SettingsScope::Project,
key,
&value.to_string(),
)
.unwrap();
};
set("runTargets", serde_json::json!([{ "kind": "compose", "id": "web", "name": "Compose web", "service": "web" }]));
// A local interpreter: Django is only in the image.
ops::doc::open(&host, WINDOW, main.clone()).await.unwrap();
let before = diagnostics(&events, &main, Duration::from_secs(120), unresolved).await;
eprintln!("local interpreter: {before:?}");
// The run target as the interpreter: a snapshot is taken in the background, then the
// server restarts with the copied site-packages as `extraPaths`.
set("languages.python.interpreter", serde_json::json!("target:web"));
let after = diagnostics(&events, &main, Duration::from_secs(900), |m| !unresolved(m)).await;
eprintln!("run target interpreter: {after:?}");
assert!(after.iter().all(|m| !m.contains("could not be resolved")), "{after:?}");
let info = ops::sdk::sdk_container_python(&host, WINDOW).unwrap().expect("a run target interpreter");
eprintln!("snapshot: version {:?}, image {:?}, folders {:?}", info.version, info.image, info.folders);
assert_eq!(info.name, "Compose web");
assert!(info.version.as_deref().is_some_and(|v| v.starts_with("3.12.")), "{info:?}");
assert!(info.packages.iter().any(|p| p.name == "Django" && p.version == "5.1.3"), "{:?}", info.packages);
let site = info.folders.iter().find(|f| f.0.ends_with("site-packages")).expect("site-packages copied");
assert!(site.0.join("django/__init__.py").is_file());
assert!(site.0.starts_with(state.path().join("data/container-python")), "{site:?}");
let servers = ops::lsp::lsp_servers(&host, WINDOW).unwrap();
eprintln!("servers: {:?}", servers.iter().map(|s| (&s.name, s.status)).collect::<Vec<_>>());
// Run: a Python configuration without its own target runs in the container.
let out = Arc::new(Mutex::new(String::new()));
let sink = out.clone();
let config = RunConfig {
id: "main".into(),
name: "main".into(),
kind: "python".into(),
command: "python3".into(),
args: vec!["app/main.py".into()],
cwd: None,
env: Default::default(),
detected: false,
is_test: false,
debug_adapter: None,
allow_parallel: false,
extras: Default::default(),
};
ops::run::start(
&host,
WINDOW,
config,
Arc::new(move |chunk| match chunk {
OutputChunk::Stdout { text } | OutputChunk::Stderr { text } | OutputChunk::System { text } => {
sink.lock().unwrap().push_str(&text)
}
}),
)
.await
.unwrap();
for _ in 0..1200 {
if out.lock().unwrap().contains("Process finished") {
break;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
let out = out.lock().unwrap().clone();
eprintln!("run output: {out}");
assert!(out.contains("5.1.3") && out.contains("exit code 0"), "{out}");
// Refresh takes a new snapshot of the same image; nothing is left running.
let refreshed = ops::sdk::sdk_container_python_refresh(&host, WINDOW).await.unwrap().unwrap();
assert_eq!(refreshed.image, info.image);
let probes = Command::new("docker")
.args(["ps", "--all", "--quiet", "--filter", "label=openidea.python-probe=true"])
.output()
.unwrap();
assert_eq!(String::from_utf8_lossy(&probes.stdout).trim(), "", "probe containers are removed");
Command::new("docker").args(["compose", "down", "--rmi", "local"]).current_dir(&root).status().unwrap();
}
+37
Переглянути файл
@@ -0,0 +1,37 @@
use ide_core::NullSink;
use ide_host::ops::db;
use ide_host::{Host, HostConfig};
use std::sync::Arc;
#[tokio::test]
async fn new_query_consoles_are_numbered_after_the_default_one() {
let tmp = tempfile::tempdir().unwrap();
let base = tmp.path();
let host = Arc::new(Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
}));
let names = |paths: Vec<ide_core::IpcPath>| -> Vec<String> {
paths.iter().map(|p| p.0.file_name().unwrap().to_string_lossy().into_owned()).collect()
};
assert!(db::consoles(&host, "pg", false).unwrap().is_empty());
// The first new console is the default one, then console_1, console_2…
assert_eq!(names(db::consoles(&host, "pg", true).unwrap()), ["console.sql"]);
assert_eq!(names(db::consoles(&host, "pg", true).unwrap()), ["console.sql", "console_1.sql"]);
let all = db::consoles(&host, "pg", true).unwrap();
assert_eq!(names(all.clone()), ["console.sql", "console_1.sql", "console_2.sql"]);
// Numbers sort numerically, and the default console path is the first one.
std::fs::write(all[0].0.with_file_name("console_10.sql"), "").unwrap();
assert_eq!(names(db::consoles(&host, "pg", false).unwrap())[3], "console_10.sql");
assert_eq!(db::console_path(&host, "pg").unwrap().0, all[0].0);
// Each database of a source has its own consoles.
assert!(db::consoles(&host, "pg:other", false).unwrap().is_empty());
assert!(db::consoles(&host, "../x", true).is_err());
}
+70
Переглянути файл
@@ -179,3 +179,73 @@ async fn light_notebooks_open_without_interpreter_detection_or_execution() {
assert!(!bin.join("python.probed").exists());
host.shutdown().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn untitled_buffers_reach_the_disk_through_save_as() {
let tmp = tempfile::tempdir().unwrap();
let tmp_root = dunce::canonicalize(tmp.path()).unwrap();
let dir = tmp_root.join("notes");
let host = host(&tmp_root.join("app"));
// A window with no file (a welcome screen's File › New File): rooted at the
// home folder, no watcher yet, doc commands work.
let empty = host.open_light_empty("main").unwrap();
assert!(empty.light);
let untitled = ops::doc::open_untitled(&host, "main", None).await.unwrap();
assert_eq!(untitled.path, std::path::PathBuf::from("untitled:Untitled-1"));
assert_eq!(untitled.language.as_str(), "plaintext");
let version = ops::doc::edit(&host, "main", untitled.id, untitled.version, untitled_edit("# notes\n")).unwrap();
assert!(ops::doc::save(&host, "main", untitled.id).await.is_ok(), "saving nowhere is a no-op");
assert!(host.project("main").unwrap().documents.is_dirty(untitled.id).unwrap());
// Save As: the buffer lands on the disk and turns into a file document.
let target = dir.join("deep/notes.md");
let saved = ops::doc::save_as(&host, "main", untitled.id, target.clone()).await.unwrap();
assert_eq!(saved.path, target);
assert_eq!(saved.language.as_str(), "markdown");
assert!(!saved.dirty);
// The platform's default line separator applies to new buffers.
assert_eq!(std::fs::read_to_string(&target).unwrap().replace("\r\n", "\n"), "# notes\n");
ops::doc::edit(&host, "main", untitled.id, version, untitled_edit("x\n")).unwrap();
ops::doc::save(&host, "main", untitled.id).await.unwrap();
assert_eq!(std::fs::read_to_string(&target).unwrap().replace("\r\n", "\n"), "x\n# notes\n");
// The saved file's folder is followed for external changes now.
host.shutdown().await;
}
fn untitled_edit(insert: &str) -> ChangeSet {
ChangeSet { changes: vec![TextChange { from: 0, to: 0, insert: insert.into() }] }
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn untitled_buffers_follow_the_window_into_a_project() {
let tmp = tempfile::tempdir().unwrap();
let tmp_root = dunce::canonicalize(tmp.path()).unwrap();
let host = host(&tmp_root.join("app"));
// A LightEdit window with an edited untitled buffer (and a clean one).
host.open_light_empty("main").unwrap();
let draft = ops::doc::open_untitled(&host, "main", None).await.unwrap();
ops::doc::edit(&host, "main", draft.id, draft.version, untitled_edit("{\"draft\": true}")).unwrap();
let clean = ops::doc::open_untitled(&host, "main", None).await.unwrap();
// The window switches to a project: the buffers follow, contents and dirty flags kept.
let project_root = tmp_root.join("proj");
std::fs::create_dir_all(&project_root).unwrap();
let project = host.open_project("main", &project_root).unwrap();
assert!(!project.light);
let carried =
ops::doc::open_untitled(&host, "main", Some(draft.path.to_string_lossy().into_owned())).await.unwrap();
assert_eq!(carried.path, draft.path, "same key, fresh registry: a new doc id carries the content");
let snap = ops::doc::snapshot(&host, "main", carried.id).await.unwrap();
assert_eq!(snap.text, "{\"draft\": true}");
assert!(host.project("main").unwrap().documents.is_dirty(carried.id).unwrap());
assert_eq!(snap.language.as_str(), "plaintext");
let clean_carried =
ops::doc::open_untitled(&host, "main", Some(clean.path.to_string_lossy().into_owned())).await.unwrap();
assert!(!host.project("main").unwrap().documents.is_dirty(clean_carried.id).unwrap());
host.shutdown().await;
}
Loaded 100 of 1220 files, more files were not shown because too many files have changed in this diff. Show more