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

...
Автор SHA1 Повідомлення Дата
l0ng-ai a738209cbe fix(editor): Sort Lines orders by character code
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:46:55 +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
453 змінених файлів з 13342 додано та 1376 видалено

No files matched your search

+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.
Створено
+6
Переглянути файл
@@ -4232,6 +4232,7 @@ dependencies = [
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"png 0.18.1",
]
[[package]]
@@ -4577,6 +4578,7 @@ dependencies = [
"ide-core",
"ide-extension",
"ide-text",
"memchr",
"parking_lot",
"regex-lite",
"serde",
@@ -4797,6 +4799,7 @@ name = "ide-sdk"
version = "0.1.45"
dependencies = [
"dunce",
"flate2",
"ide-core",
"ide-language",
"ide-run",
@@ -4806,6 +4809,7 @@ dependencies = [
"serde",
"serde_json",
"specta",
"tar",
"tempfile",
"toml 0.9.12+spec-1.1.0",
"tracing",
@@ -4962,6 +4966,7 @@ dependencies = [
"chardetng",
"dunce",
"encoding_rs",
"flate2",
"gix",
"globset",
"ide-core",
@@ -4972,6 +4977,7 @@ dependencies = [
"serde",
"serde_json",
"specta",
"tar",
"tempfile",
"tracing",
"trash",
+2
Переглянути файл
@@ -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"
+1 -1
Переглянути файл
@@ -10,7 +10,7 @@
</p>
<p align="center">
English · <a href="README.zh-CN.md">简体中文</a>
English · <a href="README.zh-CN.md">简体中文</a> · <a href="README.tr.md">Türkçe</a>
</p>
<p align="center">
+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>
+303 -37
Переглянути файл
@@ -283,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).
@@ -293,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>,
@@ -300,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.
@@ -424,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
@@ -655,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)
@@ -705,27 +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 catalog = self.shared.paths.catalogs.cached(&self.shared, &spec.id);
let mut levels = catalog.thought_levels;
let mut config = self.shared.config_for_new_task(catalog.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) = levels.remove(&model)
{
config.extend(levels.into_iter().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.
@@ -766,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<()> {
@@ -1006,6 +1109,7 @@ 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,
@@ -1205,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)?;
@@ -1288,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
@@ -1297,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.
@@ -1324,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(),
@@ -1423,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),
@@ -1447,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();
@@ -1527,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 {
@@ -1536,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,
@@ -1587,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);
@@ -1757,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 {
@@ -2211,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()))
@@ -2408,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());
@@ -2608,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"))
}
+3
Переглянути файл
@@ -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)]
+203 -3
Переглянути файл
@@ -2211,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 {
@@ -2842,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()
}
@@ -2863,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();
@@ -2947,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()
}
@@ -3227,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"]);
}
}
+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());
}
}
+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,
+32
Переглянути файл
@@ -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,
@@ -466,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(),
@@ -970,6 +989,7 @@ pub struct LanguagesSettings {
pub ruby: RubySettings,
pub markdown: MarkdownSettings,
pub arduino: ArduinoSettings,
pub zig: ZigSettings,
}
impl Default for LanguagesSettings {
@@ -992,6 +1012,7 @@ impl Default for LanguagesSettings {
ruby: RubySettings::default(),
markdown: MarkdownSettings::default(),
arduino: ArduinoSettings::default(),
zig: ZigSettings::default(),
}
}
}
@@ -1203,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 {
@@ -1715,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);
}
+21
Переглянути файл
@@ -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")]
@@ -327,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,
}
@@ -338,6 +358,7 @@ impl Default for RunSettings {
confirm_rerun: true,
confirm_terminate: true,
show_on_start: true,
pin_tabs: false,
termination_timeout_ms: 3000,
}
}
+40
Переглянути файл
@@ -405,6 +405,24 @@ pub fn attachable_processes() -> IdeResult<Vec<AttachableProcess>> {
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::*;
@@ -418,6 +436,28 @@ mod tests {
.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]
fn parses_jdwp_agents() {
let a = "java -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005 -jar app.jar";
+1 -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.8.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.
+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";
+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();
+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 {
+12
Переглянути файл
@@ -261,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)?;
+10 -1
Переглянути файл
@@ -3047,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| {
+155
Переглянути файл
@@ -415,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(())
}
@@ -531,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 {
@@ -605,4 +712,52 @@ mod tests {
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()) }));
}
}
+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);
}
}
+144 -30
Переглянути файл
@@ -296,39 +296,144 @@ impl crate::Host {
}
}
/// 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"
@@ -342,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"
@@ -572,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();
+68 -1
Переглянути файл
@@ -110,6 +110,11 @@ async fn reads_unsaved_text_and_searches_the_overlay_with_a_limit() {
let found = h.json("search_text", json!({"q":"needle","limit":2})).await;
assert_eq!(found["items"].as_array().unwrap().len(), 2);
assert_eq!(found["more"], true);
assert_eq!(found["nextOffset"], 2);
// The next page goes on where this one ended.
let every = h.json("search_text", json!({"q":"needle","limit":20})).await["items"].as_array().unwrap().clone();
let next = h.json("search_text", json!({"q":"needle","limit":2,"offset":2})).await;
assert_eq!(next["items"].as_array().unwrap()[..], every[2..every.len().min(4)]);
let all = h.json("search_text", json!({"q":"fresh","limit":20})).await;
assert_eq!(all["items"][0]["filePath"], "src/a.txt");
// The matched line comes with the result (unsaved text included), and lines around it on request.
@@ -162,7 +167,9 @@ async fn window_reports_supply_editor_selection_and_problems_then_disappear_on_c
assert_eq!(focused["selection"]["startColumn"], 3);
let problems = h.json("get_file_problems", json!({"filePath":"src/a.txt","errorsOnly":false})).await;
assert_eq!(problems["errors"][0]["description"], "reported warning");
assert_eq!(h.json("get_project_problems", json!({})).await["files"].as_array().unwrap().len(), 1);
assert_eq!(h.json("get_project_problems", json!({})).await["files"], json!([]), "errors only by default");
let all = h.json("get_project_problems", json!({"errorsOnly":false})).await;
assert_eq!((all["files"].as_array().unwrap().len(), &all["total"]), (1, &json!(1)));
h.host.close_project("main");
h.host.open_project("main", &h.root).unwrap();
assert_eq!(h.json("get_all_open_file_paths", json!({})).await["openFiles"], json!([]));
@@ -287,6 +294,41 @@ async fn execution_requires_a_window_and_denial_never_runs_a_build() {
assert!(error.contains("window closed"), "{error}");
}
#[tokio::test(flavor = "multi_thread")]
async fn run_configurations_are_listed_a_page_at_a_time_and_filtered() {
let h = Harness::new();
h.trust().await;
let saved = |name: &str, command: &str, env: &[(&str, &str)]| ide_run::RunConfig {
id: format!("user:{name}"),
name: name.into(),
kind: "shell".into(),
command: command.into(),
args: Vec::new(),
cwd: None,
env: env.iter().map(|(k, v)| ((*k).to_owned(), (*v).to_owned())).collect(),
detected: false,
is_test: false,
debug_adapter: None,
allow_parallel: false,
extras: Default::default(),
};
let configs =
[saved("build", "tsc", &[("MODE", "ci")]), saved("lint", "oxlint .", &[]), saved("test", "vitest", &[])];
ide_run::save_user_configs(&h.root, &configs).unwrap();
let all = h.json("get_run_configurations", json!({})).await;
let total = all["total"].as_u64().unwrap();
assert!(total >= 3, "{all}");
// A saved configuration's own environment is listed.
let build = all["configurations"].as_array().unwrap().iter().find(|c| c["name"] == "build").unwrap();
assert_eq!(build["environment"], json!({ "MODE": "ci" }));
let first = h.json("get_run_configurations", json!({"limit": 1})).await;
assert_eq!((first["configurations"].as_array().unwrap().len(), &first["nextOffset"]), (1, &json!(1)));
let lint = h.json("get_run_configurations", json!({"query": "OXLINT"})).await;
let found = lint["configurations"].as_array().unwrap();
assert!(!found.is_empty() && (found.len() as u64) < total, "{lint}");
assert!(found.iter().all(|c| c.to_string().to_lowercase().contains("oxlint")), "{lint}");
}
#[tokio::test(flavor = "multi_thread")]
async fn git_status_and_diff_on_a_temporary_repository() {
let h = Harness::new();
@@ -308,6 +350,18 @@ async fn git_status_and_diff_on_a_temporary_repository() {
std::fs::write(h.root.join("src/a.txt"), "changed\n").unwrap();
let status = h.json("git_status", json!({"repositoryPathRelativeToProject":"."})).await;
assert_eq!(status["repositories"][0]["unstagedCount"], 1);
// Two entries, one per page.
std::fs::write(h.root.join("src/new.txt"), "new\n").unwrap();
let page =
|offset: u64| h.json("git_status", json!({"repositoryPathRelativeToProject":".","limit":1,"offset":offset}));
let first = page(0).await["repositories"][0].clone();
assert_eq!(
(&first["totalEntries"], &first["hasMoreEntries"], &first["nextOffset"]),
(&json!(2), &json!(true), &json!(1))
);
let second = page(1).await["repositories"][0].clone();
assert_ne!(second["entries"][0], first["entries"][0]);
assert!(second.get("nextOffset").is_none(), "{second}");
assert!(h.call("git_diff", json!({"filePath":"src/a.txt"})).await.contains("+changed"));
assert!(h.call("git_diff", json!({})).await.contains("src/a.txt"));
assert!(h.call("git_diff", json!({"staged":true})).await.is_empty());
@@ -610,6 +664,19 @@ async fn agent_commands_run_in_named_tabs_and_report_what_the_user_did() {
assert!(from_start["output"].as_str().unwrap().contains("hello"), "one log across the tab's commands");
let runs = h.json("list_runs", json!({})).await;
assert_eq!(runs["runs"].as_array().unwrap().len(), 2, "{runs}");
// Newest first, brief, filtered by state and paged.
let newest = h.json("list_runs", json!({"limit": 1})).await;
assert_eq!(
(&newest["runs"][0]["name"], &newest["total"], &newest["nextOffset"]),
(&json!("_agent_server"), &json!(2), &json!(1))
);
assert!(newest["runs"][0].get("outputLength").is_none(), "{newest}");
assert_eq!(h.json("list_runs", json!({"state": "idle"})).await["total"], 2);
assert_eq!(h.json("list_runs", json!({"state": "running"})).await["total"], 0);
assert_eq!(h.json("list_runs", json!({"state": "stoppedByAgent"})).await["total"], 2);
assert_eq!(h.json("list_runs", json!({"state": "error"})).await["total"], 0);
let unknown = h.error("list_runs", json!({"state": "done"})).await;
assert!(unknown.contains("unknown state `done`"), "{unknown}");
let missing = h.error("get_run_status", json!({"name": "nothing"})).await;
assert!(missing.contains("no agent tab named `_agent_nothing`"), "{missing}");
+7 -1
Переглянути файл
@@ -6450,7 +6450,13 @@ fn api14_stage3_configurations_test_results_and_content_actions_use_real_exports
let configs = f.host.run_detection().detect(&f.project);
assert!(configs.iter().any(|c| c.id == "plugin:execution:dynamic"
&& c.is_test
&& c.extras.params.get("framework").map(String::as_str) == Some("plugin")));
&& c.extras.params.get("framework").map(String::as_str) == Some("plugin")
&& c.env.get("OPENIDEA_EXECUTION_FIXTURE").map(String::as_str) == Some("1")
&& c.extras.params.get("debugLanguage").map(String::as_str) == Some("python")));
let worker = configs.iter().find(|c| c.id == "plugin:execution:worker").unwrap();
assert_eq!(worker.extras.params.get("debugAttach").map(String::as_str), Some("dotnet"));
assert_eq!(worker.extras.params.get("debugAttachContains").map(String::as_str), Some("Worker.dll"));
assert!(worker.env.is_empty() && !worker.is_test);
assert_eq!(
f.host.parse_test_results("main", "plugin:execution:dynamic", "batch".into()).unwrap().unwrap().len(),
3
+1
Переглянути файл
@@ -46,6 +46,7 @@ async fn application_commands_work_in_restricted_projects_and_without_a_project(
"clear_caches",
"clipboard_file_list",
"clipboard_has_files",
"clipboard_has_image",
"clipboard_image",
"clipboard_set_file_list",
"clipboard_write_text",
+19
Переглянути файл
@@ -207,3 +207,22 @@ async fn change_commit_branch_stash_and_merge_a_conflict() {
assert!(f.status().await.is_empty());
assert_eq!(std::fs::read_to_string(&lib_path).unwrap(), resolved);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn checkout_reports_a_branch_held_by_another_worktree() {
let f = Flow::new("rust-app").await;
let linked = f._tmp.path().join("linked");
git(&f.root, &["worktree", "add", "-q", "-b", "topic", linked.to_str().unwrap()]);
let outcome = ops::vcs::vcs_checkout(&f.host, WINDOW, None, "topic".into(), false, None).await.unwrap();
assert_eq!(
outcome,
ide_vcs::CheckoutOutcome::CheckedOutInWorktree {
branch: "topic".into(),
path: dunce::canonicalize(&linked).unwrap()
}
);
// Nothing changed: the project is still on main.
let branches = ops::vcs::vcs_branches(&f.host, WINDOW, None).await.unwrap();
assert!(branches.iter().any(|b| b.name == "main" && b.current), "{branches:?}");
}
+1
Переглянути файл
@@ -16,6 +16,7 @@ toml.workspace = true
tree-sitter.workspace = true
parking_lot.workspace = true
tracing.workspace = true
memchr = "2"
regex-lite = "0.1"
blake3.workspace = true
tree-sitter-language = "0.1"
+7 -2
Переглянути файл
@@ -79,11 +79,16 @@ impl<'a> LineIndex<'a> {
}
fn memchr_newlines(bytes: &[u8]) -> impl Iterator<Item = usize> + '_ {
bytes.iter().enumerate().filter(|&(_, &b)| b == b'\n').map(|(i, _)| i + 1)
memchr::memchr_iter(b'\n', bytes).map(|i| i + 1)
}
pub(crate) fn utf16_len(s: &str) -> u32 {
if s.is_ascii() { s.len() as u32 } else { s.chars().map(|c| c.len_utf16() as u32).sum() }
if s.is_ascii() {
return s.len() as u32;
}
// One unit per char (each starts with a non-continuation byte), two for
// 4-byte chars; byte classification vectorizes, unlike decoding `chars()`.
s.as_bytes().iter().map(|&b| u32::from((b & 0xC0) != 0x80) + u32::from(b >= 0xF0)).sum()
}
/// Byte offset of `pos` (line, UTF-16 column) in `text`; None past the last line.
+18 -16
Переглянути файл
@@ -715,33 +715,35 @@ mod tests {
server.set_diagnostics(a.path.clone(), vec![diag(2, 5, "a"), diag(0, 1, "outside"), diag(4, 6, "crosses")]);
let items = server.set_diagnostics(b.path.clone(), vec![diag(7, 10, "b")]);
mgr.emit_diagnostics(&server, b.path, items, Encoding::Utf16);
let latest = || {
events
.lock()
.iter()
.rev()
.find_map(|e| match e {
// The SQL server's failed launch reports its status from a runtime thread; while that
// thread drains the server's event queue, events emitted here reach the sink from it.
let latest = |want: &dyn Fn(&[ide_core::Diagnostic]) -> bool| {
let deadline = std::time::Instant::now() + Duration::from_secs(5);
loop {
let items = events.lock().iter().rev().find_map(|e| match e {
ide_core::IdeEvent::Diagnostics { path: p, source, items }
if p.0 == path && source == &server.name =>
{
Some(items.clone())
}
_ => None,
})
.unwrap()
});
match items {
Some(items) if want(&items) => return items,
_ if std::time::Instant::now() > deadline => panic!("latest host diagnostics: {items:?}"),
_ => std::thread::sleep(Duration::from_millis(5)),
}
}
};
assert_eq!(latest().iter().map(|d| d.message.as_str()).collect::<Vec<_>>().len(), 3);
let messages = |items: &[ide_core::Diagnostic]| items.iter().map(|d| d.message.clone()).collect::<Vec<_>>();
let merged = latest(&|items| items.len() == 3);
assert!(
latest()
.iter()
.filter(|d| d.message != "native")
.all(|d| d.source.as_deref() == Some(server.name.as_str()))
merged.iter().filter(|d| d.message != "native").all(|d| d.source.as_deref() == Some(server.name.as_str()))
);
mgr.set_injections(host, 1, vec![injection(7, 10)]);
assert_eq!(latest().iter().map(|d| d.message.clone()).collect::<Vec<_>>(), ["native", "b"]);
latest(&|items| messages(items) == ["native", "b"]);
mgr.set_injections(host, 1, vec![]);
assert_eq!(latest()[0].message, "native");
assert_eq!(latest().len(), 1);
latest(&|items| messages(items) == ["native"]);
assert!(
events.lock().iter().all(|e| !matches!(e, ide_core::IdeEvent::Diagnostics { path, .. } if reserved(path)))
);
+33
Переглянути файл
@@ -1767,6 +1767,38 @@ impl LspManager {
}
}
/// Library classes in `jar:` targets that have no cached sources jar: the official Kotlin
/// LSP decompiles them to signatures only (`/* compiled code */`). When jdtls is running,
/// the same class from its Java model instead, decompiled with method bodies and navigable.
/// Its documents belong to jdtls ([`Self::virtual_document`] records it).
async fn jdt_library_classes(&self, snap: &DocSnap, pos: Pos, locs: Vec<Location>) -> Vec<Location> {
let jar_class = |l: &Location| l.uri.as_deref().is_some_and(|u| u.starts_with("jar:") && u.ends_with(".class"));
if !locs.iter().any(jar_class)
|| !self.servers.lock().iter().any(|s| s.name == jdtls::SERVER && s.running().is_some())
{
return locs;
}
let name = library_source::identifier_at(snap.text.line(pos.line as usize), pos.col).map(str::to_owned);
let mut out = Vec::with_capacity(locs.len());
for loc in locs {
let entry = loc.uri.as_deref().filter(|_| jar_class(&loc)).and_then(library_source::parse);
let Some(entry) = entry else {
out.push(loc);
continue;
};
let Ok(uri) = self.java_class_file(Path::new(""), Some((entry.jar, entry.entry))).await else {
out.push(loc);
continue;
};
let range = match (&name, self.virtual_document(&uri).await) {
(Some(name), Ok(doc)) => library_source::declaration_range(&doc.text, name),
_ => Range::default(),
};
out.push(Location { path: uri.clone().into(), range, uri: Some(uri) });
}
out
}
async fn goto_in(
&self,
snap: &DocSnap,
@@ -1808,6 +1840,7 @@ impl LspManager {
let locs = library_sources(snap, &self.project_root, pos, locs).await;
self.note_virtual(&t.server, locs.iter().map(|l| l.uri.as_deref()));
self.note_arduino_sources(&t.server, &locs);
let locs = self.jdt_library_classes(snap, pos, locs).await;
let locs = merge_locations(first, locs);
if !locs.is_empty() {
return Ok(locs);
+96 -28
Переглянути файл
@@ -296,37 +296,48 @@ fn harness_configured(
servers: &[(&str, Value)],
adjust: impl Fn(&mut LanguageServerConfig),
) -> Harness {
harness_languages(&[(lang_id, ext, servers)], adjust)
}
/// A language as `(id, extension, servers)`.
type MockLanguage<'a> = (&'a str, &'a str, &'a [(&'a str, Value)]);
/// Several languages; documents open as the first.
fn harness_languages(languages: &[MockLanguage<'_>], adjust: impl Fn(&mut LanguageServerConfig)) -> Harness {
let dir = tempfile::tempdir().unwrap();
// Edits and references report canonical paths.
let root = dunce::canonicalize(dir.path()).unwrap();
let lang = LanguageConfig {
id: LanguageId::new(lang_id),
name: "Mock".into(),
extensions: vec![ext.into()],
filenames: vec![],
first_line: None,
line_comment: None,
block_comment: None,
indent: None,
ui_mode: None,
framework_ui_modes: Vec::new(),
framework_files: Vec::new(),
formatter: None,
scratch_template: None,
tree_sitter: None,
language_servers: servers
.iter()
.map(|(n, _)| {
let mut cfg = server_cfg(n);
adjust(&mut cfg);
cfg
})
.collect(),
debugger: None,
};
let registry = Arc::new(LanguageRegistry::from_configs(vec![lang]));
let configs = languages
.iter()
.map(|(lang_id, ext, servers)| LanguageConfig {
id: LanguageId::new(*lang_id),
name: "Mock".into(),
extensions: vec![ext.to_string()],
filenames: vec![],
first_line: None,
line_comment: None,
block_comment: None,
indent: None,
ui_mode: None,
framework_ui_modes: Vec::new(),
framework_files: Vec::new(),
formatter: None,
scratch_template: None,
tree_sitter: None,
language_servers: servers
.iter()
.map(|(n, _)| {
let mut cfg = server_cfg(n);
adjust(&mut cfg);
cfg
})
.collect(),
debugger: None,
})
.collect();
let registry = Arc::new(LanguageRegistry::from_configs(configs));
let launcher = Arc::new(MockLauncher::default());
for (name, caps) in servers {
for (name, caps) in languages.iter().flat_map(|(_, _, servers)| servers.iter()) {
*launcher.state(name).caps.lock() = caps.clone();
}
let events = Arc::new(Mutex::new(Vec::new()));
@@ -335,7 +346,7 @@ fn harness_configured(
Arc::new(move |e: IdeEvent| events.lock().push(e))
};
let mgr = LspManager::with_launcher(root.clone(), registry, sink, launcher.clone());
Harness { _dir: dir, root, mgr, launcher, events, lang: LanguageId::new(lang_id) }
Harness { _dir: dir, root, mgr, launcher, events, lang: LanguageId::new(languages[0].0) }
}
impl Harness {
@@ -2946,6 +2957,63 @@ async fn library_classes_in_jars_open_as_their_source_or_decompiled() {
assert_eq!(asked[0], json!({ "command": "decompile", "arguments": [thing] }));
}
#[tokio::test(flavor = "multi_thread")]
async fn library_classes_without_sources_open_decompiled_by_a_running_jdtls() {
use std::io::Write;
let repo = tempfile::tempdir().unwrap();
let jar = dunce::canonicalize(repo.path()).unwrap().join("acme/lib/1.0/lib-1.0.jar");
std::fs::create_dir_all(jar.parent().unwrap()).unwrap();
let mut zip = zip::ZipWriter::new(std::fs::File::create(&jar).unwrap());
zip.start_file("acme/Thing.class", zip::write::SimpleFileOptions::default()).unwrap();
zip.write_all(b"\0").unwrap();
zip.finish().unwrap();
let class = format!("jar://{}!/acme/Thing.class", uri_of(&jar).trim_start_matches("file://"));
let escaped = jar.to_string_lossy().replace('/', "%5C/");
let jdt = format!("jdt://contents/lib-1.0.jar/acme/Thing.class?=app/{escaped}%3Cacme%28Thing.class");
let decompiled =
"package acme;\n\npublic class Thing {\n public void run() {\n System.out.println();\n }\n}\n";
let mut kotlin = incremental_caps();
kotlin["executeCommandProvider"] = json!({ "commands": ["decompile"] });
let mut java = incremental_caps();
java["workspaceSymbolProvider"] = json!(true);
let h =
harness_languages(&[("kotlin", "kt", &[("kotlin-lsp", kotlin)]), ("java", "java", &[("jdtls", java)])], |_| {});
let (doc, _) = h.open("Main.kt", "val thing = Thing()\n");
h.wait_status("kotlin-lsp", ServerStatus::Ready).await;
let k = h.launcher.state("kotlin-lsp");
k.respond(
"textDocument/definition",
json!({ "uri": class, "range": {"start":{"line":0,"character":6},"end":{"line":0,"character":11}} }),
);
let r = json!({"start":{"line":2,"character":13},"end":{"line":2,"character":18}});
// jdtls not running: the Kotlin server's own target and decompiler.
let defs = h.mgr.goto(doc, Pos::new(0, 14), GotoKind::Definition).await.unwrap();
assert_eq!(defs[0].uri.as_deref(), Some(class.as_str()));
let path = h.root.join("Sample.java");
std::fs::write(&path, "class Sample {}").unwrap();
h.mgr.did_open(DocId::next(), &path, &LanguageId::new("java"), 1, "class Sample {}");
h.wait_status("jdtls", ServerStatus::Ready).await;
let j = h.launcher.state("jdtls");
j.respond(
"workspace/symbol",
json!([{ "name": "Thing", "kind": 5, "containerName": "acme", "location": { "uri": jdt, "range": r } }]),
);
j.respond("java/classFileContents", json!(decompiled));
let defs = h.mgr.goto(doc, Pos::new(0, 14), GotoKind::Definition).await.unwrap();
assert_eq!(
defs,
vec![ide_core::Location { path: jdt.clone().into(), range: rng(2, 13, 2, 18), uri: Some(jdt.clone()) }]
);
assert_eq!(j.requested("workspace/symbol")[0]["query"], "acme.Thing");
let vd = h.mgr.virtual_document(&jdt).await.unwrap();
assert_eq!(vd.text, decompiled);
assert!(k.requested("workspace/executeCommand").is_empty(), "the Kotlin stub is not asked for");
}
#[tokio::test(flavor = "multi_thread")]
async fn alternative_java_server_uses_lsp_without_jdt_commands() {
let h = harness_for("java", "java", &[("kmp-lsp", incremental_caps())], &[]);
+18
Переглянути файл
@@ -61,6 +61,24 @@ pub struct PluginRunConfiguration {
pub cwd: Option<String>,
pub test: bool,
pub framework: Option<String>,
/// API 1.9: variables added to the run's environment.
#[serde(default)]
pub env: Vec<(String, String)>,
/// API 1.9: how Debug starts the configuration.
#[serde(default)]
pub debugger: Option<PluginDebugger>,
}
/// How Debug starts a plugin configuration (API 1.9 `launch-configurations.debugger`).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase", tag = "type")]
pub enum PluginDebugger {
/// `program` under the debugger of this language id.
#[serde(rename_all = "camelCase")]
Launch { language: String },
/// Run, then attach to the first matching process the run starts.
#[serde(rename_all = "camelCase")]
AttachChild { kind: String, command_contains: Option<String> },
}
/// RunExited data exchanged with the IDE.
+62 -23
Переглянути файл
@@ -36,6 +36,7 @@ use wasmtime::{Config, Engine, Store, StoreLimits, StoreLimitsBuilder, UpdateDea
use wasmtime_wasi::p2::pipe::MemoryOutputPipe;
use wasmtime_wasi::{WasiCtx, WasiCtxView, WasiView};
use crate::wit_host::launch_configurations_bindings::exports::openidea::plugin::launch_configurations as wit_launch;
use crate::wit_host::run_configurations_bindings::exports::openidea::plugin::run_configurations as wit_configs;
use crate::wit_host::test_results_bindings::exports::openidea::plugin::test_results as wit_tests;
use bindings::openidea::plugin::events as wit_events;
@@ -55,7 +56,7 @@ use views_bindings::exports::openidea::plugin::view_provider as wit_view;
use views_bindings::openidea::plugin::view_types as wit_vt;
/// The API version of the world above, `major.minor` of [`host_api`].
pub const API_VERSION: &str = "1.8";
pub const API_VERSION: &str = "1.9";
const EPOCH_TICK: Duration = Duration::from_millis(10);
pub(crate) const MAX_TEXT: usize = 1 << 20;
pub(crate) const MAX_TITLE: usize = 256;
@@ -1008,6 +1009,7 @@ struct Exports {
inlay: Option<wit_inlay::Guest>,
endpoints: Option<wit_endpoints::Guest>,
run_configurations: Option<wit_configs::Guest>,
launch_configurations: Option<wit_launch::Guest>,
test_results: Option<wit_tests::Guest>,
mcp: Option<crate::wit_host::mcp_bindings::exports::openidea::plugin::mcp_provider::Guest>,
}
@@ -1147,6 +1149,7 @@ impl Runtime {
let inlay_indices = wit_inlay::GuestIndices::new(&pre).ok();
let endpoint_indices = wit_endpoints::GuestIndices::new(&pre).ok();
let configs_indices = wit_configs::GuestIndices::new(&pre).ok();
let launch_indices = wit_launch::GuestIndices::new(&pre).ok();
let tests_indices = wit_tests::GuestIndices::new(&pre).ok();
let validation_indices = wit_rtv::GuestIndices::new(&pre).ok();
let lf_indices = wit_lf::GuestIndices::new(&pre).ok();
@@ -1157,6 +1160,8 @@ impl Runtime {
let mcp = mcp_indices.map(|i| i.load(&mut store, &instance)).transpose().map_err(api_hint)?;
let run_configurations =
configs_indices.map(|i| i.load(&mut store, &instance)).transpose().map_err(api_hint)?;
let launch_configurations =
launch_indices.map(|i| i.load(&mut store, &instance)).transpose().map_err(api_hint)?;
let test_results = tests_indices.map(|i| i.load(&mut store, &instance)).transpose().map_err(api_hint)?;
let language_features = lf_indices.map(|i| i.load(&mut store, &instance)).transpose().map_err(api_hint)?;
let injections = inj_indices.map(|i| i.load(&mut store, &instance)).transpose().map_err(api_hint)?;
@@ -1230,6 +1235,7 @@ impl Runtime {
inlay,
endpoints,
run_configurations,
launch_configurations,
test_results,
mcp,
file_actions,
@@ -1269,34 +1275,67 @@ impl Runtime {
.map(|r| r.map_err(|message| clip(message, 4096)))
}
/// Complete configurations from the optional API 1.4 export.
/// Complete configurations from the optional API 1.9 export, else the API 1.4 one.
pub fn run_configurations(
&mut self,
root: String,
timeout: Duration,
) -> Result<Vec<crate::PluginRunConfiguration>, CallError> {
if self.exports.run_configurations.is_none() {
return Err(CallError::missing_export("run-configurations"));
}
let invocation = Invocation { workspace_root: Some(root.clone().into()), ..Default::default() };
let items = self.call(invocation, timeout, |e, store| {
e.run_configurations.as_ref().unwrap().call_configurations(store, &root)
})?;
if items.len() > 1000 {
return Err(CallError::Guest(GuestError::Invalid("too many run configurations".into())));
}
Ok(items
.into_iter()
.map(|c| crate::PluginRunConfiguration {
id: c.id,
name: c.name,
program: c.program,
args: c.args,
cwd: c.cwd,
test: c.test,
framework: c.framework,
})
.collect())
let items: Vec<crate::PluginRunConfiguration> = if self.exports.launch_configurations.is_some() {
let items = self.call(invocation, timeout, |e, store| {
e.launch_configurations.as_ref().unwrap().call_configurations(store, &root)
})?;
if items.len() > 1000 {
return Err(CallError::Guest(GuestError::Invalid("too many run configurations".into())));
}
items
.into_iter()
.map(|c| crate::PluginRunConfiguration {
id: c.id,
name: c.name,
program: c.program,
args: c.args,
cwd: c.cwd,
test: c.test,
framework: c.framework,
env: c.env,
debugger: match c.debugger {
Some(wit_launch::Debugger::Launch(language)) => {
Some(crate::PluginDebugger::Launch { language })
}
Some(wit_launch::Debugger::AttachChild(a)) => Some(crate::PluginDebugger::AttachChild {
kind: a.kind,
command_contains: a.command_contains,
}),
Some(wit_launch::Debugger::Other(_)) | None => None,
},
})
.collect()
} else if self.exports.run_configurations.is_some() {
let items = self.call(invocation, timeout, |e, store| {
e.run_configurations.as_ref().unwrap().call_configurations(store, &root)
})?;
if items.len() > 1000 {
return Err(CallError::Guest(GuestError::Invalid("too many run configurations".into())));
}
items
.into_iter()
.map(|c| crate::PluginRunConfiguration {
id: c.id,
name: c.name,
program: c.program,
args: c.args,
cwd: c.cwd,
test: c.test,
framework: c.framework,
..Default::default()
})
.collect()
} else {
return Err(CallError::missing_export("run-configurations"));
};
Ok(items)
}
/// Selects tests under the same budget as the test parser.
pub fn select_tests(
+5
Переглянути файл
@@ -2254,6 +2254,11 @@ impl bindings::openidea::plugin::run::Host for StoreData {
pub(crate) mod run_configurations_bindings {
wasmtime::component::bindgen!({ world: "configurations-provider", path: "../../wit" });
}
pub(crate) mod launch_configurations_bindings {
wasmtime::component::bindgen!({ world: "launch-configurations-provider", path: "../../wit",
with: { "openidea:plugin/types": super::bindings::openidea::plugin::types },
});
}
pub(crate) mod test_results_bindings {
wasmtime::component::bindgen!({ world: "test-results-provider", path: "../../wit",
with: { "openidea:plugin/types": super::bindings::openidea::plugin::types },
+5 -5
Переглянути файл
@@ -91,15 +91,15 @@ fn the_sdk_carries_the_hosts_wit() {
#[test]
fn the_version_check_runs_compatible_components_and_explains_the_rest() {
let host = v("1.8.0");
for ok in ["1.0.0", "1.3.0", "1.4.7", "1.5.0", "1.6.0", "1.7.0", "1.8.0", "1.0.0-rc.1"] {
let host = v("1.9.0");
for ok in ["1.0.0", "1.3.0", "1.4.7", "1.5.0", "1.6.0", "1.7.0", "1.8.0", "1.9.0", "1.0.0-rc.1"] {
assert_eq!(check_api(&v(ok), &host), Ok(()), "{ok}");
}
let newer = check_api(&v("1.9.0"), &host).unwrap_err();
assert!(newer.contains("1.9") && newer.contains("update OpenIDEA"), "{newer}");
let newer = check_api(&v("1.10.0"), &host).unwrap_err();
assert!(newer.contains("1.10") && newer.contains("update OpenIDEA"), "{newer}");
assert!(check_api(&v("2.0.0"), &host).unwrap_err().contains("update OpenIDEA"));
let older = check_api(&v("0.2.0"), &host).unwrap_err();
assert!(older.contains("rebuild it with the 1.8 SDK"), "{older}");
assert!(older.contains("rebuild it with the 1.9 SDK"), "{older}");
// 0.x: every minor is its own API.
assert_eq!(check_api(&v("0.2.0"), &v("0.2.5")), Ok(()));
assert!(check_api(&v("0.1.0"), &v("0.2.0")).is_err());
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
0b77e7a414ec3b0dd1f41e5658b981f059dd842743dbe2f06e5d428f5b9570f9
a3d2511f30277245a8cb9673c4d8a948a8a025b8b9149d5015bed5137a98ccdd
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
6f7d6527837c8e5c86c377479cceef5116decd61666b28f36dd11332e36e9934
dad3edad99ad85234aa612bbc1f3a66e50361189e54038b91674703815f049cc
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
c1ac86a5b4d45b55013e6e00dcb83deda33b759ecc366c3fdf57739bdd22cbff
918af7d4d8bc302621b9b68070a56930813b1fc2ba235d89de4e56c53eaba5bd
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
e4d8d8f5f5849b912b2c0ebee6b91d60796c3070929d7698323e8ce4f200a38a
7a140c2255a3d251a5ab9079bec953cccc4408727b067b49da47cf8fb27d8cb8
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
e2e2fc44ad9cae9cf26a5bf706768d3786502f23db6994dc29695deb249ed73f
ef41934bd0b67f76a17ef321b44c172496314c9fea6b46b4cd2f1d0f4b96907d
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
61e1f0c43bbfddc35dcf40685169eb273777ddca13da534626e1699b47e8eac3
c6dc1ae00d9f3d936f4bf5ff110915050cb503ac06982bdc48e967deac84a913
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
2ea1c1693db8638db996c9c47597f0c1635ef0a9c7dfb33685c515b7af95e6a6
25a782556dd6bbf8ed1ad7b50b9b55685f074d80220af08f3bd9cb12cf07f74e
+1 -1
Переглянути файл
@@ -1,7 +1,7 @@
id = "execution"
name = "Plugin execution tests"
version = "0.1.0"
engines.openidea = "^1.5"
engines.openidea = "^1.9"
wasm = "dist/plugin.wasm"
activation = ["on-command:*"]
[capabilities]
+32 -12
Переглянути файл
@@ -114,17 +114,37 @@ impl Plugin for Execution {
host::storage::set("events", &serde_json::to_string(&received).unwrap()).unwrap();
}
}
impl RunConfigurations for Execution {
fn configurations(&mut self, _: &str) -> Vec<PluginRunConfiguration> {
vec![PluginRunConfiguration {
id: "dynamic".into(),
name: "Dynamic tests".into(),
program: "execution-tool".into(),
args: vec!["test".into()],
test: true,
framework: Some("plugin".into()),
..Default::default()
}]
impl LaunchConfigurations for Execution {
fn launch_configurations(&mut self, _: &str) -> Vec<PluginLaunchConfiguration> {
vec![
PluginLaunchConfiguration {
config: PluginRunConfiguration {
id: "dynamic".into(),
name: "Dynamic tests".into(),
program: "execution-tool".into(),
args: vec!["test".into()],
test: true,
framework: Some("plugin".into()),
..Default::default()
},
env: vec![("OPENIDEA_EXECUTION_FIXTURE".into(), "1".into())],
debugger: Some(Debugger::Launch { language: "python".into() }),
},
PluginLaunchConfiguration {
config: PluginRunConfiguration {
id: "worker".into(),
name: "Worker".into(),
program: "execution-tool".into(),
args: vec!["start".into()],
..Default::default()
},
env: Vec::new(),
debugger: Some(Debugger::AttachChild {
kind: "dotnet".into(),
command_contains: Some("Worker.dll".into()),
}),
},
]
}
}
impl TestResults for Execution {
@@ -181,4 +201,4 @@ impl RunTargetValidation for Execution {
}
}
}
register_plugin!(Execution, run_configurations, test_results, run_target_validation);
register_plugin!(Execution, launch_configurations, test_results, run_target_validation);
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
3c788f6092b229484a5d94fd0e6f4651afc88dd0ab1b22b067d84614a6a0c85e
f58b5f68d56611c3d15f69ff8e73e6e65d84e55d14c9bae00ed50b346460adfa
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
18273599cc459833d6a30185a5f0ba127cdb4e22bc054a973571e530eb3a85ca
749bff2d051984edf65590494ef803225dfccd03d032de140974d8e984683ac1
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
c9fafba362fa0745d7ee1685efe5582d432510003c04c98100eddd557bd1e2cf
50c609ebf387b400a01ea28e6ccf0f3de2ff7a5eedbb47c0a4bc40d05e3aaade
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
4121009913709edeca8c5cb52d88dfcc2b16867bc06859768f73c17cf9c456a2
c2350d0277636285b182d1484d01d706c3116ef5e28917ddeb24ca78bac251ba
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
b55bd202a06f5643c4f70abb6fb473fbe53af657f4e21a6d13581a23a726e085
3aaa2a7655189a1e4da55a86fb30fdfde29a5b6ad65de53a78b18df30eeb97f3
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
11a12228df67f9b94bd52375b5dddb2d955f7e5f88d7f9ea0a430b1db128f6ca
975ead22afb4c5bc5c79799b76c1d3a683b572b906e3bade94944319920f7f84
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
553ce6c57052f8ff2ea2526e88762d5a0aee2489cda192fc7ab708766078ccf7
b849aeca8e5d6b6ede216e372cfc019bac08774006e11ed81d272f67770adc17
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
7b43f8a2a06d7b2952f8d46727a4cce524fb2bdecee1035dad9c767261d276d0
79505f2677e5f19374a3ed46cccdc1ed8d3092a3c43e3154e8cfd73063533251
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
e994c69fda5eef94ae85d6dac5aec3d04fb6811c35c446c9d33236a449bd1c6c
f7f43d46ebd545cc4c52d859c4307d08bb95b3187a4b947704e37c27cf0f4fa0
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
c37f6a7ea9018ca636d0fd486a9d91cd397158615410de9b837ca9b8f9f593b7
491e8e713e51045ebef81c4f07ed5475ed41688e88ac47ee26d13f2bc5c53b49
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
1b298dfa5c1f2163d609ef0ccc3d42fafe78e69326002effe53abc4b82974749
63b2f6007782c9d3498c86505a6c107b70f09d90678c79cf16c859ecbf349e70
+3 -3
Переглянути файл
@@ -5,9 +5,9 @@ wit_bindgen::generate!({
inline: "
package fixture:misbehave;
world misbehave {
include openidea:plugin/plugin-syntax@1.8.0;
export openidea:plugin/providers@1.8.0;
export openidea:plugin/file-actions@1.8.0;
include openidea:plugin/plugin-syntax@1.9.0;
export openidea:plugin/providers@1.9.0;
export openidea:plugin/file-actions@1.9.0;
}
",
path: "../../../../../wit",
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
1e11a95e0909d18308d21a9a8448f8b2e427d240d5d8cd13d8ddbe6ba5009237
3d1f2451dee8bba3cc874f497bcea909fa9bd58ae605dc1d74b4298b4b11ed94
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
d5cd77130cc97cffdbc742f04e3e1196f14160107b636806c6ef3c9b40724e84
26a2b15b9354fa0ba2bf5934114bfdd20f7906c4a67497d013146cc6b3fc9643
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
a0f74430b0a7d9ff48524d7aae4f8a0bd0fc60511e2a951dde9991b9bf7d15a9
c9ef2cf7b6e8f3204f8ee382d7a381a5190b1e812e11d51bd1262c3d429a315f
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
65508f6e25bd0efc85820ae7d0f9037b58c778fa61e8498c5b9a5055cc38f6fc
b07cc854c1302fd5bfd29fb3037732c7439efde606fc57295b2c0f94602a1077
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
c86ed41d68c132f72eb8b5794f31b975492e5ad42e41b7823d1bf28ba118a262
12c4004f118070d0f37125ac32639ab9f332d59cc4585e74a8af61e3e6c5c1e8
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
3da1a09906d803234016b3f9c30a88b9b40bf3187fc73840ff60a2e05f504b48
7517da988ab8cacaa3abf7645274a84e364de582ea9edf60a63926b1442d1581
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
39e7a805120ce6433750c32b1d670b3caba990f7b15aea866a29c23b01cba708
6e045ee3fcaa655bae773774e48e3eeb9d97ae9d8db1f52bf76c38459dcecb3f
Бінарний файл не відображається.
Loaded 100 of 453 files, more files were not shown because too many files have changed in this diff. Show more