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

...
Автор SHA1 Повідомлення Дата
l0ng-ai b5e49adf47 test(agents): find the working indicator in the tray below the conversation 2026-11-10 23:40:47 +08:00
l0ng-ai 55a4a0604e Merge remote-tracking branch 'origin/main' into fix/agent-stream-scroll 2026-11-10 23:40:31 +08:00
l0ng-ai 828db7bd13 fix(hierarchy): let the hierarchy tab be closed (#2552)
The Hierarchy tool window's content tab had no close button, so a type or
call hierarchy stayed until another one replaced it. The tab is closable
now; closing it clears the tree and hides the tool window, which has no
other content.

Refs #2471
2026-11-10 23:32:55 +08:00
l0ng-ai ef1421012a fix(refactor): type a chained call by its last call in Introduce Variable (#2551)
When the code has errors (e.g. a statement without its semicolon), Introduce
Variable falls back to a syntax-based declaration and types it from the hover
of the call the expression ends in. That call was found with a greedy regex,
so in `demo.getList().get(0)` the argument list swallowed `().get(0)` and
the hover of `getList` typed the variable as `List<String>`. The trailing
call is now found by matching its parentheses.

Fixes #2509
2026-11-10 23:30:56 +08:00
l0ng-ai 96e12d8d9f fix(kotlin): colour annotation names as annotations (#2549)
The JetBrains Kotlin LSP reports an annotation usage as a constructor call
(`method`), so the name after `@` took the Function call colour while the
`@` had Metadata. Names of annotation usages, including use-site targets
(`@field:Name`) and nested names (`@Outer.Inner`), now get the annotation
class; labels such as `return@forEach` and `this@Outer` are left alone.

Fixes #2543
2026-11-10 23:27:17 +08:00
l0ng-ai dc99fc83d2 fix(debugger): keep the selected tab when a step finishes (#2548)
Every pause brought Threads & Variables to the front, so stepping from the Console tab switched away from the output after each Step Over. Only a stop at a breakpoint, exception or pause request does that now; finishing a step keeps the tab the user is on.

Refs #2494
2026-11-10 23:22:45 +08:00
l0ng-ai ce7eb155b0 fix(http): read environment files saved with a byte order mark (#2547)
Editors on Windows such as Notepad save UTF-8 with a BOM, which serde_json rejects, so such an http-client.env.json offered no environments.

Refs #2435
2026-11-10 23:20:58 +08:00
devтаdevadapter 6788447d3a fix(security): run credential store access off the async runtime (#2459)
On Linux, saving a password (e.g. a new data source with Save password ticked) left the dialog on Connecting…: the Secret Service client runs its blocking calls on a tokio runtime of its own, and from a Tauri command thread the nested block_on panicked with "Cannot start a runtime from within a runtime"; keyring's poisoned LazyLock then made every later password access panic until restart. SecretFile, used for database, SSH, git and proxy passwords, now runs store access on a plain thread when called from async code.

Co-authored-by: devadapter <[email protected]>
2026-11-10 23:19:24 +08:00
zaojiaociтаzaojiaoci 2ad642cdc9 fix(plugins): keep pinned program paths out of the verbatim form (#2012)
std::fs::canonicalize returns \\?\-prefixed paths on Windows, so a process grant's pinned program path was stored in that form. Canonicalize the program's directory with dunce, which keeps the usual form.

Fixes #2001

Co-authored-by: zaojiaoci <[email protected]>
2026-11-10 23:19:14 +08:00
Clarence a60293929a fix(ui): the main menu button follows the tool window stripe (#2538)
On Windows and Linux the main menu button's size, slot and the frameless bar's left padding were hardcoded, so the button sat 6px right of the stripe icons below it and stayed 30px in compact mode. It now reads the stripe tokens and uses the 20px glyph of the other title bar tool buttons.

Co-authored-by: Clarence404 <[email protected]>
2026-11-10 23:16:39 +08:00
Ernstjan FreriksтаEjaYF 284a287a0f fix(ui): keep dialogs below the macOS window buttons (#2523)
With the overlay title bar, macOS draws the window buttons over the page, so a tall dialog (e.g. Push) or one dragged to the top edge had its title bar covered. The dialog layer now keeps a top margin of at least the title bar height on macOS, and dragging stops there. Windows and Linux are unchanged.

Co-authored-by: EjaYF <[email protected]>
2026-11-10 23:16:32 +08:00
Muuтаmuutot a58e3a45f0 fix(vcs): offer both ignore destinations in the changes view (#2499)
Add to .gitignore in the changes view is now a submenu with .gitignore and .git/info/exclude, as in the project view's Git menu, and the file the rule was written to opens in both cases.

Co-authored-by: muutot <[email protected]>
2026-11-10 23:16:24 +08:00
Moler1995 ca9cc64ee8 fix(plugins): keep line-marker gutter icons inside their column (#2481)
Line-marker buttons reuse the indexed-marker button class, whose theme sized every icon to 16px; in the 14px line-marker column, which clips to its width, every icon lost a pixel column on both sides. The 16px size now applies only inside the indexed-marker gutter.

Fixes #2479

{
  "message": "Not Found",
  "documentation_url": "https://docs.github.com/rest",
  "status": "404"
}
2026-11-10 23:16:16 +08:00
xtr-hubтаxtr-hub 8b32df9773 fix(archive): handle multibyte ZIP paths on Windows (#2516)
zip 9.1.3's reserved-device-name check sliced entry names at byte offsets, so a WAR or tool archive with a multi-byte file name (e.g. `ZC机柜.jpg`) panicked with "end byte index is not a char boundary" on Windows. Raise zip to 9.2.3, which checks on character boundaries; this covers Tomcat WAR deployment and ZIP/VSIX tool extraction, the two enclosed_name callers.

Fixes #2456

Co-authored-by: xtr-hub <[email protected]>
2026-11-10 23:14:04 +08:00
l0ng-ai 636b2e1572 fix(vcs): handle repositories nested below a non-Git project root (#2546)
- Project view: the Git submenu was gated on the project root being a
  repository, so it never appeared when the repositories are found later
  below a non-Git root. It now also follows the VCS availability that the
  Commit tool window uses.
- Commit: when only one repository had included changes, every staged but
  excluded file of every repository was passed to it, and the commit failed
  with "<path> is outside the repository". Paths that belong to another known
  repository are now left to that repository.

Fixes #2517
Fixes #2518
2026-11-10 23:09:35 +08:00
l0ng-ai 834a54c69b fix(boot): keep starting after an unhandled rejection (#2545)
The startup failure screen treated every unhandled promise rejection before
the workbench was ready as fatal. A rejected background request (WebKit
reports it as "TypeError: Load failed") then stopped startup on machines
where the app had started fine before. Such a rejection is now remembered and
only reported if startup also times out.

Errors without a stack (WebKit's network TypeErrors) were serialised with
JSON.stringify and showed as "{}"; they now show their name and message.

Fixes #2445
2026-11-10 23:03:52 +08:00
arookieofc 9cb8aed512 fix(run): retry the port-file read in stop_tree tests (#2541) 2026-11-10 23:01:28 +08:00
arookieofc 4779d84571 feat(mybatis): navigate and complete @ResultMap ids (#2461) (#2533)
* feat(mybatis): navigate and complete @ResultMap ids (#2461)

@ResultMap on a method jumps to @Results(id) in the same file, else to
the XML <resultMap>; resultMap= on XML statements jumps to <resultMap>;
all three get gutter markers and @ResultMap( completes ids (annotation
ids plus XML ids, merged). Unterminated strings now end at the newline
and interface-level ; resyncs paren tracking, so typing half-written
annotations no longer swallows the rest of the file.

* chore(mybatis): retrigger CI after retargeting to main
2026-11-10 22:49:39 +08:00
silencezhangтаsilencezhang7 31464cebea feat(agents): add OMP as a built-in agent (#2458)
OMP (Oh My Pi) ships its own ACP server (`omp acp`), so it runs through the same ACP pipeline as the other built-in agents. Adds it to the built-in providers with its logo, so an installed `omp` shows up in the agent picker without a custom agent definition.

Co-authored-by: silencezhang7 <[email protected]>
2026-11-10 22:36:50 +08:00
arookieofc 3bf921dfe4 feat(mybatis): navigate and complete #{} in annotation SQL (#2461) (#2530)
* fix(java): 重启后外部改动不增量编译导致 NoClassDefFoundError (#2527)

* feat(scheme): support a selection foreground colour (#2446)

Settings › Editor › Color Scheme › General › Editor gains a
Selection foreground attribute next to Selection background, wired
to a new --fg-editor-selection token. The editor paints selected
text with it (.cm-openidea-selected, including the diff viewer);
unset it keeps the previous rendering, with syntax colours showing
through (token default: inherit). .icls import/export round trips
it via SELECTION_FOREGROUND.

* test(themes): palette themes inherit selection foreground from base

Solarized/One keep the base default (preserve syntax colours on
selection); record that intent in INHERITED_FROM_BASE.

* fix(java): re-stamp mtime-preserving external edits before launch builds

Launch fingerprints detect external edits via content hash, but the Maven
rebuild that triggers is timestamp-incremental: sources whose mtime did not
advance are skipped, so the rebuild compiles nothing and the stale snapshot
is re-recorded as fresh. After an IDE restart the launch then reuses those
stale classes (NoClassDefFoundError).

Session::build now diffs the stored fingerprint against the fresh one,
re-stamps (bumps mtime on) sources whose content changed while
(length, mtime) stayed the same, and re-scans before building, so the
rebuild actually recompiles them and the record written afterwards matches.

Related to #2476

* feat(mybatis): navigate and complete #{} in annotation SQL (#2461)

Annotation @Select/@Insert/@Update/@Delete SQL strings now resolve
Definition, and complete parameter names after #{. Works without
any XML (pure-annotation mappers). Also fixes parse_java splitting
statements on braces/semicolons inside string literals.

* feat(mybatis): silence clippy type-complexity with LitSpan alias
2026-11-10 22:18:04 +08:00
l0ng-ai 646530312d fix(libraries): find Maven jars in the local repository set by ~/.m2/settings.xml (#2540)
External Libraries and published version catalogs fell back to ~/.m2/repository when Settings › Maven configured no local repository, ignoring <localRepository> in ~/.m2/settings.xml, so only jars that also existed under ~/.m2/repository were listed. The default lookup now lives in ide_sdk::apply and is shared with Lombok agent detection. Sibling: Gradle user home (#2466).
2026-11-10 21:58:33 +08:00
l0ng-ai 50c2435b17 docs(readme): drop the portable zip note and link contributing (#2539) 2026-11-10 21:57:31 +08:00
Zhu Minhaoтаzmh69695328 51c256db97 fix(libraries): read dependency jars from the configured Gradle home (#2466)
External Libraries looked up Gradle dependency jars only under $GRADLE_USER_HOME or ~/.gradle, ignoring Settings › Gradle › user home, so projects whose Gradle cache lives elsewhere listed no Gradle dependencies. The scan now resolves the home like published_version_catalog: the setting first, then the environment / default home.

Co-authored-by: zmh69695328 <[email protected]>
2026-11-10 21:56:34 +08:00
l0ng-ai ba8a7555cd docs(readme): remove the Contributors section (#2537) 2026-11-10 21:54:53 +08:00
l0ng-ai ca602586d1 docs(readme): link the documentation site (#2536) 2026-11-10 21:53:42 +08:00
NealimeKennaтаNealimeKenna 72b32c9faa fix(agents): let F7 move on to the next changed file in an agent task's diff (#2534)
Co-authored-by: NealimeKenna <[email protected]>
2026-11-10 21:52:10 +08:00
arookieofc 6908c0f1b8 fix(java): recompile external edits that keep their mtime after a restart (#2527)
Launch fingerprints detect external edits via content hash, but the Maven
rebuild that triggers is timestamp-incremental: sources whose mtime did not
advance are skipped, so the rebuild compiles nothing and the stale snapshot
is re-recorded as fresh. After an IDE restart the launch then reuses those
stale classes (NoClassDefFoundError).

Session::build now diffs the stored fingerprint against the fresh one,
re-stamps (bumps mtime on) sources whose content changed while
(length, mtime) stayed the same, and re-scans before building, so the
rebuild actually recompiles them and the record written afterwards matches.

Related to #2476
2026-11-10 21:35:12 +08:00
l0ng-ai 19fca27359 fix(i18n): translate data-held labels and unify zh terms (#2529) 2026-11-10 21:06:33 +08:00
l0ng-ai dbdf610ffa docs: add the Mintlify documentation site (#2528)
Closes #1543
2026-11-10 21:06:30 +08:00
l0ng-ai 9e46d61dc0 feat(agents): incremental streaming, folded turns, approval tray and one Settings › Agents page (#2511)
* feat(agents): stream reply text as appends and repaint only changed blocks

The backend sends only the text added to a streaming message (AgentEvent::Append)
instead of the whole entry every flush. Replies render per top-level Markdown
block, keeping the DOM of blocks that didn't change, and streaming text is
revealed at a steady pace.

* feat(agents): fold finished turns and summarize work by what it did

A finished turn's work before its reply folds into one line under its message
("Reasoning · Ran 1 command · Read 4 files · 12s"), runs of tool calls say what
they did instead of how many there were, and reasoning stays open and bounded
while it streams. New Settings › Agents values choose turn folding, smooth text,
reasoning and tool call display, tool output expansion, the send key and what
sending does while the agent works; per-agent defaults are stored for new tasks.

* feat(agents): ask approvals and show progress above the composer

An approval the agent waits for is asked above the composer, answered with
1, 2, 3… when the focus isn't in a text field, and recorded in the
conversation once answered. Beside it: the plan's progress, the task's changed
files (opening the changes), running subagents and background work, queued
messages, and a line saying what the agent does now and for how long.

* feat(agents): one Settings › Agents page for every agent

Agents get their own settings group. Its page lists every agent this machine
runs, built in or installed from the ACP Registry, with what each needs
(update, not found, custom) beside the selected one's status, sign-in, the
settings its new tasks start with, and how it launches. Add Agent… opens the
ACP Registry in a dialog; Remove and Update act on registry agents in place.
Chat and MCP Server move under it, and Chat gains the sending and conversation
options.

* test(e2e): follow approvals to the tray and work into turn folds

* fix(agents): lay out agent defaults in rows and name reasoning in work summaries

* fix(settings): keep ⌘Enter as written in every locale

* feat(agents): show the ACP Registry as a list beside the selected agent

Add Agent lists registry agents compactly (name, and Installed or Update) and
shows the selected one's version, runtime, authors, description, links and
Install / Update / Remove beside the list, instead of a card per agent with
badges and a button on every row.

* fix(agents): open Add Agent at a fixed size with the list scrolling inside

* feat(agents): list every agent in one searchable list, installed first

Registry agents that can be installed now sit under the installed ones
instead of behind Add Agent. The selected agent's page puts Install,
Update and Remove next to its name and lays out Sign In, New Tasks and
Launch as titled sections with label rows.

* feat(settings): make Agents a group of Coding Agents, Chat and MCP Server

* feat(agents): show each task's agent icon and move chat placement to the options menu

Task rows and session tabs show the icon of the agent the task runs;
rows mark only tasks that are still working or need attention, since
their group already says the rest are ready. Chat in Tool Window and
Chat in Editor move from the header into the tool window's options
menu as a checked pair.

* fix(agents): put actions under a turn's final reply only, and keep selections clean

Interim messages of a turn no longer carry a time and action row, and
the final reply gets one once the turn ends. Code in replies uses a
tint of the text colour instead of an opaque fill, so a selection no
longer leaves light boxes around it, and diagram labels are skipped
when selecting across a reply.

* feat(toolwindows): let a window keep its options out of the title

A tool window can set plainTitle so its own options stay in the "⋮"
menu and its title gets no "▾". The Agents window uses it.

* fix(agents): line up the settings form of an agent

The selects of New Tasks share one width, and the label column is as
wide in every section, so the controls of New Tasks and Launch start
at the same place.

* fix(agents): call copying a task into a new one Fork everywhere

The task menu, the user message and the reply offered the same
operation as Copy to a New Task, Copy to a New Task From Here and
Branch Conversation from Here. They now read Fork Task and Fork from
Here.

* fix(agents): label the fork buttons and menu item just Fork

* fix(agents): label the task's terminal entry just Terminal

* fix(agents): fold the session header into the status line and quiet the chat chrome

- The task's terminal and changes buttons move to the end of the status line above
  the composer; the header row that repeated the tab's title and status is gone.
- The status line leaves out the model when the composer's picker shows it.
- Your messages show their actions and send time beside "You" on hover instead of
  in a row under them, so turns sit closer together.
- Session tabs show a dot only while something waits for you: an unread reply, a
  question or an error. A spinner still marks a running task.
- The composer's attach buttons are plain toolbar icons, the image one monochrome;
  in a narrow composer the model button drops the effort so the model stays readable.

* fix(agents): answer approvals only from the focused session and never cut a turn short to steer

Number keys answer an approval only while the focus is in its session.
With "Sends it into the turn", a message to an agent that can't take it
mid-turn (or a turn waiting for input) is queued instead of cancelling
the turn. A task waiting for you shows a dot instead of a spinner, and a
turn that ran into an error before its reply stays unfolded.

* fix(agents): keep text that streams while a conversation loads

Entry events that arrive while a conversation loads are applied on top
of the loaded copy instead of being lost to it, so a long reply no
longer reloads the whole conversation over and over. A reply that
streams on as appends marks its task unread even when its conversation
isn't open. The backend keeps only the length and end of the text it
sent, rather than a copy it compares in full on every flush.

* fix(agents): render HTML blocks whole and leave drawn diagrams alone while a reply streams

A reply with an HTML block (such as <details> around Markdown) renders
as one piece, so the block wraps what it should. While a reply streams,
only the text after its finished blocks is cut again, and a diagram
already drawn stays in place with its zoom and pan.

* fix(settings): read agent setting values it doesn't know as their defaults

A hand-edited agent setting with an unknown value, or a non-text value
in agent defaults, no longer makes the whole settings file be ignored.

* fix(agents): drop Open from a task's context menu

Clicking a task already opens it.
2026-11-10 19:50:46 +08:00
Deng Liu dba85e8716 perf(vcs): speed up git log indexing, commit details, and blame commit reveal (#2483) 2026-11-10 18:58:09 +08:00
arookieofc c25459317b feat(scheme): support a selection foreground colour (#2464)
* feat(scheme): support a selection foreground colour (#2446)

Settings › Editor › Color Scheme › General › Editor gains a
Selection foreground attribute next to Selection background, wired
to a new --fg-editor-selection token. The editor paints selected
text with it (.cm-openidea-selected, including the diff viewer);
unset it keeps the previous rendering, with syntax colours showing
through (token default: inherit). .icls import/export round trips
it via SELECTION_FOREGROUND.

* test(themes): palette themes inherit selection foreground from base

Solarized/One keep the base default (preserve syntax colours on
selection); record that intent in INHERITED_FROM_BASE.
2026-11-10 18:32:39 +08:00
arookieofc 0d8ad0370a feat(mybatis): navigate namespace, resultMap and sql/include refs (#2461) (#2473)
Mapper XML gains Go to Definition and gutter markers for
<mapper namespace> (the interface), resultMap=/extends= (resultMap
ids, namespace-qualified or same-file) and <include refid=>
(<sql> ids, with refid completion). Links resolve even when the
interface is missing; unresolvable refs link nowhere.

This covers the XML-side navigation of #2461; annotation SQL,
OGNL, type aliases and Java type refs remain open.
2026-11-10 18:23:33 +08:00
arookieofc 3e3088c525 fix(maven): recognize POMs with non-UTF-8 declared encodings (#2495)
Issue #2439's project declares encoding="GBK"; its pom.xml is GBK bytes,
so read_to_string failed and the project was silently skipped (Maven tool
window showed 'No maven project', reload/add did nothing) while CLI Maven
reads it fine. Decode XML files via BOM, the encoding declaration, UTF-8,
then a chardetng guess; use it for POM, parent-chain, source-root and
settings.xml reads.

Refs #2439
2026-11-10 18:12:57 +08:00
arookieofc 331987fc17 fix(vcs): dedupe recent date filters by label (#2454)
* fix(vcs): dedupe recent date filters by label (#2443)

The remembered date preset was compared by JSON, but its rolling
after/before timestamps are recomputed on every selection, so each
switch of the date filter appended a look-alike duplicate that
localStorage kept across restarts. Compare date values (and whole
snapshots) by label instead, and dedupe on read so already-corrupted
state heals itself.

* chore: fix README formatting (pre-existing oxfmt violation blocking CI)
2026-11-10 17:46:10 +08:00
arookieofc e23073c1d4 feat(vcs): show worktree diff in the Compare tool window with Jump to Source and Swap Sides (#2486)
Branch and tag menus' Show Diff with Working Tree opened a dialog (or a
separate comparison window under Tauri). It now shows the worktree diff in
a sidebar Compare tool window instead, with Jump to Source opening the
selected file in the editor and Swap Sides exchanging the two diff sides.

Closes #2442
2026-11-10 17:46:00 +08:00
arookieofc 1699306eaa fix(vcs): make side-by-side diff divider draggable (#2441)
* fix(vcs): make side-by-side diff divider draggable

Allow dragging the middle band between unaligned panes to resize
left/right share (10-90%), with keyboard arrows, double-click
reset and localStorage persistence. Fixes #2437.

* test(vcs): cover diff divider drag, keys and reset

* chore: fix README formatting (pre-existing oxfmt violation blocking CI)
2026-11-10 17:43:29 +08:00
arookieofc c01a538ea4 fix(db): connect to StarRocks via a MySQL-compatible preset (#2463)
* fix(db): connect to StarRocks via a MySQL-compatible preset (#2452)

StarRocks speaks the MySQL wire protocol but rejects the non-constant
SET sql_mode=(SELECT ...) handshake sqlx sends by default. Add a
StarRocks data source preset (MySQL driver, port 9030) that turns off
pipes_as_concat/no_engine_substitution, and recognize the starrocks URL
scheme.

* test(i18n): allowlist StarRocks product name in text catalog
2026-11-10 17:43:18 +08:00
arookieofc 7cc0d5769d fix(boot): ignore ResizeObserver loop also when delivered as an Error (#2474)
* fix(boot): ignore ResizeObserver loop also when delivered as an Error (#2468)

WebKit (Tauri on Linux) can report the benign loop notification with
an Error object attached, which bypassed the bare-message guard and
showed a fatal startup dialog. Match on the engine-generated message
instead; no genuine startup failure carries these strings.

* chore: fix README formatting for oxfmt check
2026-11-10 17:43:08 +08:00
arookieofc 9828f18300 fix(vcs): let Git Log ref labels size to content (#2450)
* fix(vcs): let Git Log ref labels size to content (#2447)

Remove the unconditional 180px per-label cap and let the refs row use
the full subject cell when space allows, shrinking with ellipsis (full
name kept in the title tooltip) only when space truly runs out.

* chore: fix README formatting (pre-existing oxfmt violation blocking CI)
2026-11-10 17:30:16 +08:00
arookieofc c580dd7c69 fix(editor): make Ctrl+wheel font zoom apply to all open editors (#2488)
Ctrl/Cmd+wheel (and the Increase/Decrease Font Size actions) only resized
the focused editor. They now step every open editor to the same value, and
newly opened editors inherit the shared zoom; Reset Font Size resets it.

Closes #2480
2026-11-10 17:30:06 +08:00
arookieofc da3db5cbd6 fix(build): scope Build Artifacts to the active file's module (#2491)
* fix(build): scope Build Artifacts to the active file's module

Build Artifacts always packaged the whole reactor (all 57 modules in the
reporter's project) because the frontend passed a null file. Route it
through moduleTarget like Build Module does, so Maven runs
'-pl <module> -am package' and Gradle runs '<module>:assemble'; with no
active file it still falls back to the whole project. Backend already
supported module-scoped artifacts for Maven and Gradle, no Rust change.

Refs #2476

* fix(build): translate the module-scoped Build Artifacts description

The new action description needs a zh catalog entry (the only complete
locale) and the old text must go from every catalog (the stale-entry
check covers all locales); otherwise textCatalog.test.ts fails.

Refs #2476
2026-11-10 17:29:54 +08:00
arookieofc d1bef52041 fix(keymap): keep Shift+F6 as a rename fallback in the Eclipse preset (#2498)
Alt+Shift+R is swallowed by overlays that hook the Alt+R family (NVIDIA
performance overlay, Xbox Game Bar), leaving Eclipse-preset users with no
way to trigger refactor/project/db rename. Shift+F6 (IntelliJ's rename,
free in this preset) now works as an alternative entry point.

Refs #1876
2026-11-10 17:29:45 +08:00
l0ng-ai 1d4c0e1cd4 docs: remove docs/ and keep plugin docs in plugins/ (#2503)
* docs: remove unreferenced user guides

Drop docs/command-line.md, databases.md, java.md, unity-external-editor.md
and docs/media/debug.jpg. Nothing in the repository links to them.
Plugin, theme and README assets under docs/ stay.

* docs: keep only plugin docs under docs/

Remove docs/themes.md and docs/data-directories.md. Move the upstream
Atom One source revisions into THIRD_PARTY_LICENSES.md, drop the links
to the removed page, and move the README's WeChat QR code to
.github/assets/.

* docs: move plugin docs next to plugins/README.md

docs/plugins/themes.md and indexing.md move to plugins/. Update the
theme doc generator, its test, the plugin template, AGENTS.md and every
link to the new paths.
2026-11-10 16:16:32 +08:00
l0ng-ai dd48f79265 fix(agents): preserve upward scrolling during streaming replies 2026-11-10 15:59:13 +08:00
l0ng-ai 2b80c75317 refactor(plugins): run only programs in plugin run targets (#2490)
Plugin run targets keep running programs, tests and package scripts inside
the target. Language servers and debuggers run on the host again:

- Remove the Language server targets settings page, the project-only
  `languageServerTargets` setting and the PHP / Node.js "Language server:
  run in target" option.
- Remove debugging through plugin targets; Debug on a plugin target is
  refused again. PHP Remote Debug and Python remote configurations with a
  path mapping debug programs in containers.
- Remove LSP / DAP message path mapping, read-only container documents and
  the `ide-core` path-mapping module that only these used.
- Run-target validation (API 1.5, unreleased) no longer has a `debug`
  operation, and debug start no longer takes a request id.
2026-11-10 15:27:31 +08:00
Vadim Lazukтаl0ng-ai 565ada6b86 feat(dap): debug C/C++ with GDB's native DAP interpreter (#2412)
* feat(dap): debug C/C++ with GDB's native DAP interpreter

Fixes #2338

Native debugging was LLDB-only (lldb-dap, CodeLLDB), so toolchains and
embedded setups that ship GDB (pretty printers, .gdbinit, gdbserver
targets) had no debugger in the IDE. Adds a built-in "gdb" adapter:
`gdb -i dap` over stdio, with an install hint. GDB's DAP interpreter
shipped in GDB 14, so a launch with an older GDB is refused with the
found and the required version in the message (prepare_gdb, a probe
like debugpy's). C/C++ run configurations pick the debugger in the
Debug adapter control - GDB is an explicit choice there, the default
stays lldb-dap. The c-app fixture (C and C++ entry points) is debugged
with GDB in the language smoke; real_adapters gains a gdb case that
skips itself without a GDB 14+ on PATH.

* fix(dap): start GDB 15 sessions with the launch after the configuration

GDB 15 starts the program inside its launch request, so the launch-first
order every other adapter uses races the breakpoint upload: the run can
be past the breakpoints before setBreakpoints lands, and configurationDone
is refused with notStopped while the program is off. GDB 14 runs the
program at configurationDone and 16+ defers the whole launch there, so
both are fine with launch-first; 15.x is the one version that needs the
launch to go out after configurationDone. The order now follows a
gdb --version probe (only for the gdb adapter); a fake adapter speaking
GDB 15's wire behavior pins the ordering and the notStopped refusal.

* fix(dap): harden GDB startup and expression evaluation

---------

Co-authored-by: l0ng-ai <[email protected]>
2026-11-10 13:49:14 +08:00
l0ng-ai 876faa7ba2 fix(ci): make main's format check and platform-specific tests pass (#2477)
README.md had trailing blank lines oxfmt rejects. On macOS a plugin workbench test compared the pinned program's canonical /private/var path with the raw /var temp path. On Windows the IntelliJ settings test set a modification time through a read-only handle, and the JAR import test expected forward slashes where $PROJECT_DIR$ in program parameters expands to the native root. A remote plugin run test also read its output file before the run had written it; both waits there now wait for the complete content.
2026-11-10 13:40:26 +08:00
Vadim LazukтаInf1mum 11594437b8 feat(plugins): ask at first connect to a host the manifest does not list (net:ask) (#2417)
Plugins that declare net:ask can reach an HTTPS or loopback host their manifest does not list after the user allows it: Allow once, Always allow (remembered in the grant ledger, revocable in Settings › Plugins) or Deny. Unattended calls never prompt and are denied until the host is allowed. In remote development the dialog offers Allow for this session instead; the approval stays in memory on the server and ends with the connection, so no grant exists that the desktop cannot list or revoke.

Closes #2165

Co-authored-by: Inf1mum <[email protected]>
2026-11-10 12:31:45 +08:00
l0ng-ai bfe99c06e2 ci: build nightly versions twice a day (#2136)
Adds a nightly channel. Twice a day nightly.yml builds the newest main commit that passed CI and publishes it as a prerelease (OpenIDEA Nightly, ai.l0ng.openidea.nightly, own data and update manifest), or stops when nothing is newer than the last nightly. Platform builds move from release.yml into the reusable release-build.yml so both channels build the same way.

Versions are one patch above main with a timestamp suffix; each build gets its own v…-nightly.… tag (remote development downloads the server by version) and the last 14 are kept; a rolling nightly release holds latest.json. No MSI or RPM for nightlies. Nightly builds skip the CNB mirror manifest, and their automatic update prompts (startup and periodic) appear at most once a day; a manual check always answers.

Workflow permissions default to contents: read with write granted per job, Apple signing secrets reach only macOS builds, stable release.yml rejects nightly tags, tag pruning only matches -nightly. tags, and nightly runs share one concurrency group.
2026-11-10 12:30:17 +08:00
lleviathe f50d3099ce test(smoke): smoke-test PHP on a php-app fixture (#2189)
PHP had no regression coverage. Adds a php-app fixture (Composer + Pest) with language, run and debug smoke cases, and a php entry in the Language smoke workflow: PHP 8.5 with Xdebug via setup-php, Intelephense pinned like the other servers. Other languages' selectors are unchanged.

Co-authored-by: lleviathe <[email protected]>
2026-11-10 12:10:51 +08:00
lleviathe 00573f448f fix(dev): keep the dev identity of a moved checkout (#2078)
build.rs read CARGO_MANIFEST_DIR through env!(), which is fixed when the build script is compiled. Cargo reruns that binary after a checkout moves without recompiling it, so the script panicked when the old path was gone, or kept the old path's dev identity when something still existed there. The script now reads CARGO_MANIFEST_DIR at run time; the Windows manifest path for the linker follows the move too.

Co-authored-by: lleviathe <[email protected]>
2026-11-10 12:02:01 +08:00
l0ng-ai 573bac6b7a fix(plugins): confine resolved write destinations (#2436) 2026-11-10 11:55:52 +08:00
Shohruh Ismoilovтаl0ng-ai 5e7304a750 fix(go): resolve Delve targets with the selected compiler (#1997)
* fix(go): resolve Delve targets with the selected compiler

* fix(go): honor Windows compiler environment casing

* fix(deps): bundle patched SQLite for RUSTSEC-2026-0459

---------

Co-authored-by: l0ng-ai <[email protected]>
2026-11-10 10:47:40 +08:00
arookieofc 5d86688130 feat(agents): show running/done status on session and terminal tabs (#2233) (#2274)
An agent task's editor tab now shows a spinner while the task is busy and a
status dot once it finishes, failing or limited shown in danger. Terminal
tabs show the same: a spinner while a command runs (or a one-shot program
session is still starting) and an exited / failed dot afterwards.

The editor kind contribution gains an optional trailing(input) callback
rendered in the tab's trailing slot, next to the pin icon; the agents
feature uses it for a TaskStatusMark reading the task status out of the
agents store. The store bumps the tab-title revision on status changes
too, not only on renames, so the mark follows live.

Closes #2233
2026-11-10 10:45:26 +08:00
l0ng-ai 97ff542cdd fix(editor): prevent duplicate Linux clipboard commands (#2392) 2026-11-10 10:03:35 +08:00
Deng Liuтаdengliu 8cd121ed62 feat(docker): Compose detection, banner, gutter run and Services grouping (#2429)
Only compose.yml / docker-compose.yml were recognised as Compose files, so custom-named or content-only Compose YAML had no Docker icon, no gutter run markers and no Compose support, and running a docker-compose configuration from the gutter added a duplicate top-level Docker Compose group in Services. Compose files are now detected by filename (*compose*.yml/yaml) and by a bounded content scan that excludes Kubernetes, OpenAPI, GitHub Actions, Helm values and mkdocs files; content-detected files show a banner to confirm or reject, and the choice is stored in project settings and follows IDE renames. Detected files get the Docker icon in the tree and tabs, run markers on services: (whole stack) and on each service, and docker runs stay under the Docker node in Services.

Co-authored-by: dengliu <[email protected]>
2026-11-10 09:20:33 +08:00
Deng Liuтаdengliu 8971febef7 chore(plugins): remove official excalidraw plugin in favor of dengliu.excalidraw (#2391)
The Excalidraw editor now lives in its own repository as the community plugin dengliu.excalidraw (listed in l0ng-ai/openidea-plugins, 0.2.0 adds .excalidraw.svg support and the official icons), so the example copy under plugins/official is removed. Its host test coverage for web-view editors, templates and file icons moves to the remaining examples, and plugins/README.md points to the community plugin. Installed copies of the old example keep working until uninstalled; installing dengliu.excalidraw from the marketplace replaces it.

Co-authored-by: dengliu <[email protected]>
2026-11-10 08:44:34 +08:00
l0ng-ai e152cc4ad9 fix(project-tree): reveal library classes in registered archives (#2432)
Project and folder roots could intercept archive entries and block valid
library candidates. Match entries only to their registered archive roots,
comparing Windows paths in their normalised form. Resolve Gradle sources
jars to registered binary jars in sibling hash directories under the same
artifact version.

Follow-up to #2378
2026-11-10 08:21:28 +08:00
l0ng-ai ad0b9e478e fix(mcp): report window focus changes immediately (#2431)
Continuous typing delayed window focus reports and could route project
notifications to the previously focused window. Report focus and blur
immediately, cancel pending snapshots, and keep editor updates debounced.
Add regression tests for both focus transitions during continuous editing.

Follow-up to #2375
2026-11-10 08:21:22 +08:00
l0ng-ai 8131eaae5b fix(data): use isolated write probes and correct migration guidance (#2430)
The fixed probe path could delete existing files, follow symlinks, and race
between launches. Use an exclusively created temporary file instead.
Limit migration instructions to rebuildable local entries so user data stays
in place.

Follow-up to #2373
2026-11-10 08:21:16 +08:00
l0ng-ai 099d614516 Update README.md 2026-11-10 07:43:04 +08:00
Elmir Abkerimov b08a9bedf9 fix(editor): tighter text insets and full-row selection painting (#2420)
Text started 6px from the gutter and 4px below the top, and a multi-line selection left native-selection slivers in the inset, stopped before the right edge, filled first/last rows only to glyph height and only partly covered folded rows; the caret row lost its highlight under a selection and sticky lines showed none. CodeMirror's RectangleMarker.forRange sizes edge rows to the glyphs and stops at the line padding, and WebKit paints the gaps with the global ::selection colour. The selection layer now grows markers to row boxes, extends selected lines to the right edge, fills rows the selection runs past (folded included) and hides native selection inside .cm-content; one LINE_INSET (2.5px) drives line padding, indent guides, wrap indent, right margin, code lenses, sticky lines, Distraction Free and the VCS viewer panes; no top padding. The caret row stays highlighted under a selection, sticky lines paint their part of it (keeping gaps between disjoint ranges), and selection geometry is measured in batched read/write phases, reused while scrolling and skipped for empty selections.

Co-authored-by: w4terlaw <[email protected]>
2026-11-10 06:56:09 +08:00
l0ng-ai 0b6bf83b9e feat(plugins): validate run target launches (#2427)
Closes #2408

Plugin run targets were static argv wrappers: a plugin couldn't refuse a built-in Run/Test/Debug launch through its target (for example when its container project is stopped), so the wrapper started and could start the environment as a side effect. API 1.5 adds an optional run-target-validation export: after trust, ownership, process-grant and pin checks the host calls validate with the operation, bare program name and working directory; an error refuses the launch with the plugin's (prefixed, bounded) message and keeps the selected target. Plugins without the export behave as before. Calls wake reclaimed instances, use the host call timeout, are cancelled with the launch, and fail closed on trap/timeout; for remote projects validation runs on the server. SDK helpers, plugins/README.md and fixtures updated.
2026-11-10 06:48:36 +08:00
l0ng-ai 6c51e3307e fix(agents): handle conversation loading failures (#2426) 2026-11-10 06:40:08 +08:00
l0ng-ai 8961f6dbf4 fix(app): keep used settings profiles in a scrollable picker (#2425) 2026-11-10 06:14:49 +08:00
Vadim LazukтаInf1mum 329f0b1330 feat(database): boolean and enum cell editors with Set NULL / Set DEFAULT (#2415)
Refs #2331

Every grid cell edited in the same plain text box whatever its SQL type. Boolean columns (boolean, tinyint(1), bit) now show a checkbox on editable rows that writes the literal the driver renders, Space toggles it, and the editor offers true / false / NULL (NULL only for nullable columns). Enum columns with a known value list choose from it, keeping an unknown current value selectable. Set NULL and Set DEFAULT are cell actions (db.setCellNull / db.setCellDefault, ⌘⌫ / ⇧⌘⌫ on macOS) over the selected range; Set DEFAULT drops the pending value on added rows and writes literal defaults on existing ones, and is disabled for expression defaults. Works in the table editor and console results alongside the JSON value editor. Remaining in #2331: date/time editors, numeric/date validation, PostgreSQL enum introspection, FK lookup, DEFAULT keyword for expression defaults on existing rows.

Co-authored-by: Inf1mum <[email protected]>
2026-11-10 06:11:49 +08:00
l0ng-ai 71251d1746 feat(plugins): add per-command run target workdir arguments (#2424)
Closes #2407

A run-target command override replaced the wrapper's args but always appended the target's shared workdir-args, so an override like `composer -> [composer]` for DDEV produced `ddev composer --dir=... -- install` and fed exec flags to Composer. API 1.5 adds register-with-overrides, whose command overrides carry optional workdir-args: none inherits the shared list, an empty list omits it, and a list replaces it with the same {cwd} mapping and argv/path bounds. API 1.4 registrations map onto it unchanged; trust, process grants, pinning, remote execution and debugging go through the same path. SDK, plugins/README.md and fixtures updated.
2026-11-10 05:59:16 +08:00
FeriVOQ 05527ade4a fix(astro): resolve TypeScript SDKs and install astro-ls (#713)
astro-ls ships no TypeScript and refused to start unless the project had typescript in its own node_modules, because languages/astro.toml hard-coded tsdk = node_modules/typescript/lib; hoisted monorepo installs and projects without TypeScript failed with an opaque server crash. The tsdk now resolves from an explicit setting, then the project or a hoisted workspace install, then the server's own TypeScript, using the TypeScript helpers now shared with the Vue server. astro-ls is a managed tool installed through the tool registry with a pinned TypeScript, and a missing Node.js or TypeScript produces one actionable message instead of a crash loop.

Co-authored-by: ferivoq <[email protected]>
2026-11-10 05:53:01 +08:00
l0ng-ai 49fc9e9257 fix(project): refresh ignored state when rules change (#2423)
Editing .git/info/exclude, a .gitignore or a .ignore reloaded the backend's ignore rules, but the directories whose rules changed were dropped before anything downstream heard about them, so the project tree, the file index and open searches kept the old ignored state until a manual reload. The fsChanged event now carries those directories: the project tree reloads the affected loaded folders once, the index rebuilds its path filter and rescans only those subtrees, and Find in Files / Search Everywhere / VCS status refresh. Only create, modify, remove and rename of rule files count, so a refresh can't start another reload.
2026-11-10 05:48:02 +08:00
Vadim LazukтаInf1mum cc53be62d0 fix(run,debug): Stop ends the whole launched process tree (#2416)
Fixes #2339

Stop only reached the direct child (or walked the tree from a pid that no longer named the launch once a launcher exited), so a JVM or server started by a launcher survived and kept its port. Windows runs and debug launches now join a job object that Stop terminates (taskkill /T /F as fallback), and a stopped run reports its exit only once the job is empty; on Unix, debug adapters lead their own process group so a forced stop kills the whole launch. Attach sessions never kill the target. Stop no longer blocks the command thread, and a second Stop on Unix no longer deadlocks on the pid lock.

Co-authored-by: Inf1mum <[email protected]>
2026-11-10 05:24:52 +08:00
Vadim LazukтаInf1mum 6c3e5ae469 feat(database): JSON-aware value editor for json cells (#2410)
Refs #2333

Editing json / jsonb / MySQL JSON cells showed one long plain text line, and invalid JSON only failed on Submit. The Value panel and the grid's ⇧Enter window now open the IDE's code editor for JSON cells, pretty-printed (big integers and duplicate keys preserved, large values formatted off the main thread) with highlighting, folding and find; validation as you type blocks Apply only for JSON columns; Reformat; Apply of an unchanged value creates no pending edit, and drafts survive opening the window. XML and bytea modes from #2333 remain.

Co-authored-by: Inf1mum <[email protected]>
2026-11-10 05:22:56 +08:00
l0ng-ai 64dad9c520 feat(vcs): Compare with Revision and Show Current Revision for files (#2422)
Fixes #2261

The Git context menus (editor, editor tab, project view) lacked two file actions. Compare with Revision… opens a searchable chooser of the file's commits (hash, date, author, subject) and diffs the chosen revision against the working copy; Show Current Revision blames the caret line and selects that commit in the Git log. Both are enabled for tracked files only, through a new per-action enabled() check alongside the when clause. Revealing a commit in the log now also works when the log opens for the first time and its initial load replaces the request.
2026-11-10 05:16:22 +08:00
l0ng-ai ccc6bd9751 feat(java): download sources from decompiled library views (#2421)
Fixes #2381

Opening a Maven library class without a sources jar showed decompiled code with no way to get the real source. The decompiled view's banner now offers Download Sources, which resolves the artifact's -sources.jar into the local repository through the project's Maven launcher and repositories (maven-dependency-plugin get, non-transitive), with progress, cancellation and errors in the banner, then reopens the class at the same declaration; Choose Sources… attaches a jar through the existing library source archives setting. Declaration matching after reopening now skips calls and comments. Gradle downloads are not covered yet.
2026-11-10 05:15:04 +08:00
l0ng-ai 6b70e1ea08 fix(editor): separate callable completion results from parameters (#2419)
Refs #2326

For callable completion items gopls sends the parameters and the result in labelDetails.detail ("() error") and the whole signature in detail ("func() error"), so a method row read "Add() error" with "func() error" beside it. Only the balanced parameter list (with nested brackets and type parameters) now stays beside the name, and when detail just repeats that signature as func(...), the type column shows the result type instead ("Add()" | "error"). TS, rust-analyzer, JDT LS, pyright and clangd payloads keep their current rendering.
2026-11-10 05:06:20 +08:00
Deng Liuтаdengliu cbae278355 feat(markdown): highlight search and selected word occurrences in preview (#2413)
⌘F in a Markdown file only decorated matches in the editor, so the rendered preview (Split and Preview Only) showed none, and selecting a word in the preview didn't highlight its other occurrences. The Markdown viewer now publishes the active Find query and current match, and the preview draws matches, the current match and selected-word occurrences with the CSS Custom Highlight API in the editor scheme's colours, scrolling the current match into view; a selection in the preview seeds Find, and closing Find in Preview Only returns focus to the preview. Matches are indexed once per render, not rescanned per keystroke.

Co-authored-by: dengliu <[email protected]>
2026-11-10 04:52:13 +08:00
Ernstjan FreriksтаEjaYF b4e2d4493b fix(run): keep the Run/Debug Configurations toolbar visible with a long list (#2384)
With many configurations the dialog body itself scrolled, taking the list toolbar (+ − copy …) out of view. The dialog body is now a flex column whose panes fill it: the configuration list and the form scroll on their own, the toolbar and footer stay visible, and the body's height is a preferred size that shrinks with the window or when the dialog is resized.

Co-authored-by: EjaYF <[email protected]>
2026-11-10 04:27:36 +08:00
ntbang0901 bc048e2413 feat(app): import settings straight from an installed JetBrains IDE (#2380)
Refs #656, #776

Import Settings only took an exported settings.zip, so moving from a JetBrains IDE meant exporting first. File › Manage IDE Settings › Import Settings from JetBrains IDE… lists the settings folders of the installed JetBrains IDEs and Android Studio (newest version per product, reusing the IntelliJ theme importer's discovery) and reads the chosen one through the same mapping as an archive: read_jetbrains_settings accepts a folder as well as a zip, lists options/, colors/, keymaps/, codestyles/, templates/ and inspection/, reads only the mapped files with the archive's size limit, and does not follow symbolic links out of the folder. What is imported is unchanged (colour schemes, editor font and a few editor and general options).

Co-authored-by: ntbang0901 <[email protected]>
2026-11-10 04:15:26 +08:00
Muuтаmuutot 86dc82534e feat(python): highlight docstring tags and keywords by markup format (#2369)
Python docstrings were highlighted as one flat token, so :param x:, .. note::, @param, Google Args: and NumPy Parameters sections looked like the prose around them. Adds an editor.docstringFormat setting (plain, rest, epytext, google, numpy; default rest) on Languages & Frameworks › Python Interpreter, and the Python highlighter now marks tags and keywords inside docstrings only (first statement of a module, class or function; not other or byte strings), with new Docstring › Tag and Docstring › Keyword colour-scheme attributes wired through the colour scheme, ICLS import, IntelliJ theme import and editor theme tokens.

Co-authored-by: muutot <[email protected]>
2026-11-10 04:14:53 +08:00
l0ng-ai bdb8ca5549 fix(editor): match quick fixes to their diagnostics (#2411)
Refs #2248

With overlapping diagnostics, the problem hover and Apply Quick Fix (⌥⇧↵) picked the first quick fix for the range, so in go.mod "can be upgraded" offered "Remove dependency", the fix of the "not used" diagnostic on the same text. LSP code actions name the diagnostics they fix, but that list was dropped when converting them. It is now carried to the frontend in IDE coordinates (and the original server diagnostics are passed back on codeAction/resolve), and each problem's fix is chosen among the actions tagged with it; servers that tag nothing keep the range-based choice. Auto-import and imports-on-paste use the same filter.
2026-11-10 03:54:35 +08:00
Deng Liuтаdengliu 2841a5a406 feat(vcs,ssh): resolve ~/.ssh/config Host aliases for git remotes (#2399)
A remote that uses an SSH Host alias (git@github-personal:owner/repo.git with Host github-personal → HostName github.com) was not recognised by the GitHub integration (repository detection, tracked branch, Git Log CI checks), which saw the alias instead of the host. vcs_remotes now also reports Remote.resolvedUrl, resolved from ~/.ssh/config and its Includes without running anything (Match exec, ProxyCommand and LocalCommand are never executed; unreadable or malformed config falls back to the literal URL; the parsed config is cached), with ssh.github.com / altssh.gitlab.com normalised. Remote.url stays the literal value shown in Manage Remotes, and the plugin vcs.remotes call keeps returning the configured URL.

Co-authored-by: dengliu <[email protected]>
2026-11-10 03:53:00 +08:00
Shohruh Ismoilovтаshohruh-genius 231c47b5cd fix(vcs): stop hover flicker while scrolling the Git log (#2358)
Wheel-scrolling the Git log under a stationary pointer flashed row hover backgrounds, graph edge/node highlights and subject tooltips, because recycled virtual rows fire enter events as they pass under the pointer. Scrolling now pauses hover feedback on the list until the pointer actually moves; row hover, graph highlights and tooltips resume then.

Co-authored-by: shohruh-genius <[email protected]>
2026-11-10 03:48:31 +08:00
l0ng-ai 7f88d3d723 fix(settings): consistent form columns, font page metrics and sidebar width (#2388)
Settings forms now share one label/field column layout, the Editor › Font page uses consistent field sizes, and the sidebar width is resizable from the keyboard and remembered. The Settings window's minimum size grows to 900×700 so pages no longer squeeze below their layout; fitting a window over its parent now keeps the comparison and deployment windows' own minimum sizes instead of the Settings one.
2026-11-10 03:46:22 +08:00
HandsomeXi 38b56e724e feat(vcs): select the branch name and offer to unset its upstream when renaming (#2398)
Rename Branch opened with the old name unselected, so typing appended to it. Prefilled prompt fields now select their value on first focus (also New Branch's suggestion, rename shelf, Compare With's path). A renamed branch keeps tracking the old remote branch, so when it has an upstream the dialog offers Unset upstream branch (off by default), run after git branch -m in the same operation; if only the unset fails, the error says the rename succeeded.

Co-authored-by: HandsomeXi <[email protected]>
2026-11-10 03:45:53 +08:00
l0ng-ai 98cef2aa48 feat(run): JAR Application run configurations and their import (#2409)
Fixes #2393

Importing run configurations from .run/ and .idea/ skipped every JarApplication entry because there was no equivalent type. Adds a JAR Application run configuration (path to JAR, VM options, program arguments, JRE, working directory, environment) that runs java [VM options] -jar <jar> [args] and debugs over JDWP like the Application type, and maps JarApplication on import (JAR_PATH, VM/program parameters, working directory, alternative JRE path, envs).
2026-11-10 03:28:30 +08:00
l0ng-ai 718e07c831 fix(console): 16px toolbar icons and a two-row find bar in narrow panels (#2387)
Run and Debug console toolbars used 13/14px icon overrides, which made the controls too small; they now use the standard 16px icons, with a Scroll to End icon. The shared single-row console find bar overflowed narrow panels (Close ended up past the right edge); at 360px and below it now wraps to two rows, across Run, Debug, Services and stack traces.
2026-11-10 03:25:53 +08:00
l0ng-ai 4392ffacf6 fix(ui): retain dialog focus and copy text through the native clipboard (#2403)
Refs #2381

- Dialogs: an editor opened behind a dialog (e.g. the new file's tab behind "Add File to Git") could take keyboard focus after the dialog opened, so Enter/Escape went to the editor. Dialogs now keep focus while they are the top layer.
- Copy Reference failed with NotAllowedError: the WebView clipboard requires user activation, which native menu actions and async work (resolving the reference) outlive. Text copies now go through a native clipboard_write_text command (NSPasteboard / Win32 / GTK), and every navigator.clipboard.writeText caller uses the shared helper. The command is allowed in restricted (untrusted) projects like the other clipboard commands.
2026-11-10 03:25:21 +08:00
HandsomeXi 6b4be5a15b fix(vcs): keep the caret visible when navigating between differences (#2395)
Next/Previous Difference (toolbar arrows, F7 / Shift+F7) moved the selection without focusing the editor, and CodeMirror draws the caret only in a focused editor, so the caret at the reached difference was invisible. The diff and merge editors now focus the pane they navigate in and centre the difference; with synchronized scrolling, a follower pane's delayed layout scroll no longer pulls the navigation source back (also under reduced motion).

Co-authored-by: HandsomeXi <[email protected]>
2026-11-10 03:23:27 +08:00
ntbang0901 84cfac9c17 fix(editor): keep IME keys away from every text field's key handlers (#2364)
The Enter that commits an input-method candidate (keyCode 229 / isComposing) reached text-field handlers as a plain Enter, so Find in Files, Run Anything and several popups acted on it. Only handlers that called isImeKey themselves were safe. One capture-phase listener now stops IME keys from reaching any <input>/<textarea> handler, without cancelling the default action; xterm's textarea and CodeMirror's contenteditable keep their own IME handling.

Co-authored-by: ntbang0901 <[email protected]>
2026-11-10 03:14:20 +08:00
SunZhiQiang 51abfe9481 fix(agents): redraw mermaid diagrams when the theme changes (#2354)
Mermaid diagrams in agent Markdown are rendered in the active theme's colours but were drawn only once, so switching theme left them in the old palette. The Markdown view now subscribes to theme changes and redraws its diagrams.

Co-authored-by: panda-student <[email protected]>
2026-11-10 03:14:15 +08:00
l0ng-ai aee86444ec fix(vfs): ignore access events before watcher processing (#2406)
Fixes #2396

The watcher reacted to every notify event kind. On inotify, reloading .git/info/exclude (or scanning a directory) opens files, which emits Access events, which triggered another rule reload and resync, looping forever. Access events are now dropped once, before queueing and in batch_detailed, so reads never schedule reloads, resyncs or change events; writes still arrive as Modify events. Covered for ignore files, Git metadata and normal files.
2026-11-10 03:10:08 +08:00
l0ng-ai 2d4e91546b fix(boot): ignore ResizeObserver loop notifications (#2405)
Fixes #2397

The startup guard in public/boot.js treated every window error with a message as fatal, including the benign "ResizeObserver loop completed with undelivered notifications" (and "loop limit exceeded") that a layout during startup can raise without an error object. These are now ignored; real errors, rejections, script load failures and the 30 s timeout still show the failure page.
2026-11-10 03:06:51 +08:00
l0ng-ai 22a56bfc36 feat(editor): expose existing Git context menu actions (#2404)
Refs #2261

The editor and editor-tab Git submenu listed only part of the available Git actions. It now also offers Merge, Rebase, New Branch, New Tag, Reset HEAD, Stash/Unstash, a GitHub submenu, Manage Remotes and Clone, with labels and enablement from the action registry (all repository actions disabled outside Git).
2026-11-10 03:02:24 +08:00
l0ng-ai e3f41a2284 fix(editor): separate completion names from field types (#2402)
Fixes #2326

gopls sends a field's type in labelDetails.detail (" int"), which was appended to the name ("Capint"). Only a parameter signature suffix of a callable item stays beside the name now; any other label detail goes to the right-hand type column. Covered with gopls, Java, TS, Python and Rust payload shapes.
2026-11-10 03:01:00 +08:00
l0ng-ai 9f16141e6e test(vcs): use document-start shortcut in large diff checks (#2401)
The four large scattered-edit diff tests pressed ⌘↑ to reach the document start before F7. Since #2317 ⌘↑ opens the Navigation Bar and takes focus, so F7 never reached the viewer. Use ⌘Home and assert the right pane keeps focus. Product code unchanged.
2026-11-10 03:00:15 +08:00
l0ng-ai 0df19ce81e fix(ui): keep Git and diff toolbar icons at 16px (#2372)
Compact toolbar buttons shrank their icons too, so Git, diff and merge actions looked smaller than standard toolbar icons. Standard toolbar icons now stay 16px regardless of button size, keeping the 14px revert icons in Merge and Agents; the diff viewer uses dedicated side-by-side/unified and apply-arrow icons (Apache-2.0 expui assets), and divider positioning is scoped to its band SVG so arrows keep separate click areas while #2346's metric-based centring still holds. Verified on macOS across Git toolbars, Commit, diff (fonts, zoom, themes), merge and agent revert icons.
2026-11-10 02:36:37 +08:00
LU XIANGRUIтаwildcatDownstairs 358808833c feat(editor): reStructuredText highlighting and section outline (#2376)
.rst files open as reStructuredText instead of plain text: highlighting for section titles, directives, comments, roles, inline literals, emphasis/strong, links, substitutions, footnotes, field lists and literal blocks after :: and code-block; section titles in the Structure view, File Structure popup and breadcrumbs, with levels assigned per adornment style in order of first use as docutils does (overline+underline distinct from underline only); ⌘/ toggles '.. ' comments without turning '..text' into one. Refs #1681.

Co-authored-by: wildcatDownstairs <[email protected]>
2026-11-10 02:27:42 +08:00
l0ng-ai d132f1adf5 fix(terminal): keep session and search controls visible (#2379)
In a narrow Terminal tool window the search field's options and match count pushed the navigation and close buttons out of view (24px overflow), and session tab controls were misaligned. Search now lays out on a control-width grid (match count and navigation on their own row when narrow) and session controls align with the tool window header. Verified on macOS with tabs, new sessions, splits, search, light/dark and narrow panes.
2026-11-10 02:27:14 +08:00
ntbang0901 0b70ede663 fix(editor): show a completion's type, not its whole signature, in the right column (#2367)
Servers that send both labelDetails.description (the type) and detail (the whole signature), such as JDT.LS, showed the name and parameters twice: the right-hand column repeated the full signature. The LSP conversion now carries labelDetails.description through, including the resolve round trip, and the right column prefers it; a blank description falls back to the previous text and servers without labelDetails render as before. Refs #2326.

Co-authored-by: ntbang0901 <[email protected]>
2026-11-10 02:25:40 +08:00
Bruno Belloni 2738bd0ccc perf(terminal): add a full-screen TUI scenario (#2251)
The perf suite had no terminal scenario, so nothing guarded the terminal row cache (#2239) against regressions. The new terminalTui scenario streams a full-screen TUI into the terminal at 10 fps (every row rewritten, only the spinner row changed, the title updated, as agent CLIs do) while typing in an editor, through a hidden action registered only in dev and perf builds. rowsRebuiltPerTuiFrame counts row elements rebuilt per frame independently of MutationObserver batching and fails when no terminal output arrives; budget 3 (about 1.4 with the cache, the screen height without it).

Co-authored-by: brunobelloni <[email protected]>
2026-11-10 02:05:33 +08:00
Muuтаmuutot 632b21ac46 feat(workbench): add the Navigation Bar with a top / status bar / hidden position choice (#2317)
Adds the Navigation Bar — project › directories › file › symbol for the active editor, each segment opening a popup of its children — and a choice of where it lives: in the status bar (default, where it was before), above the editor, or hidden, from View › Appearance › Navigation Bar or Settings › Appearance. The choice is stored as appearance.navigationBar; the old showNavigationBar boolean migrates at load time. The row stays put while tabs change, follows the Project view's selection until the editor takes focus again, and Jump to Navigation Bar works in every placement (a popup bar when hidden), with keyboard navigation of segments and popups.

Co-authored-by: muutot <[email protected]>
2026-11-10 02:03:39 +08:00
LzSkylineтаlzskyline c1a6079ee1 feat(app): close the last window instead of returning to Welcome (#2321)
Two settings under Appearance & Behavior › System Settings, both off by default so current behaviour is unchanged: "Close the window instead of showing the Welcome screen" closes the last window rather than replacing it with Welcome (on Windows/Linux the app exits; on macOS it keeps running in the Dock, reopening shows Welcome and Quit exits), and "Close all editor tabs when the window closes" drops that window's saved editor session. Cancelling the close writes the tabs back; a missing value keeps the old behaviour.

Co-authored-by: lzskyline <[email protected]>
2026-11-10 01:53:14 +08:00
SunZhiQiang 9797295282 feat(agents): page multiple questions with tabs and a stable height (#2297)
When an agent asked several questions at once (AskUserQuestion), the card stacked them in one scrolling block, so a narrow composer showed only part of the prompt. The card now shows one question at a time behind tabs, with Previous / Next paging, a 1 / N count and a mark on answered questions; all panels stay mounted in one grid cell so the card keeps the tallest question's height. A single question renders as before. Tabs reuse the shared tab component with keyboard focus handling, long text wraps, and paging stays visible below long questions.

Co-authored-by: panda-student <[email protected]>
2026-11-10 01:46:12 +08:00
ntbang0901 d881737fb3 fix(editor): share go-to-definition requests per word and let the latest gesture win (#2344)
Ctrl-hover, Ctrl+click, Ctrl+B and Quick Definition on one word each sent their own lspGoto, and on jdtls identical requests run in parallel at full cost. They now share one in-flight or recent request per editor, document version, server generation, word and kind (definition, declaration, type definition); edits, empty answers and errors are never reused, and a newer go-to gesture in an editor supersedes older ones, so a late answer no longer navigates, retries or shows a hint. The syntax fallback still answers when no server does. Hover then click: 2 requests → 1; three rapid clicks: 3 → 1. Server-side cancellation of lspGoto is a follow-up. Refs #2314.

Co-authored-by: ntbang0901 <[email protected]>
2026-11-10 01:45:31 +08:00
ntbang0901 16ba1ba3f5 fix(project-tree): keep remote paths away from the local file manager (#2340)
In an SSH or dev-container window, Reveal in Finder, Open In › Finder, Select In and Navigate › File Path passed the server's path to this desktop's file manager, which failed with "No such file or directory" or could open an unrelated local file with the same path. One check, canRevealInFileManager, now decides whether a path belongs to this desktop (plus WSL paths on Windows through \\wsl.localhost); menus hide the item in other remote windows, the action and File Path warn instead, and Create Patch no longer offers to show a patch saved on the server. Log, config folder and local recent projects still reveal from a remote window. Refs #1670.

Co-authored-by: ntbang0901 <[email protected]>
2026-11-10 01:41:26 +08:00
ntbang0901 50f541fa64 fix(vcs): centre the diff viewer's revert controls (#2346)
The diff viewer's gutter arrows and the inline Revert button sat off-centre on their change blocks: they used fixed offsets that did not follow the editor's line height or the gutter width. They are now positioned from the editor's metrics and spacing tokens, centred independently of width. Covers modified, multi-line, deletion-only and insertion-only blocks, paired and merge accept controls, font sizes and zoom.

Co-authored-by: ntbang0901 <[email protected]>
2026-11-10 01:24:23 +08:00
LzSkylineтаlzskyline 27401ad438 fix(markdown): stop inline Mermaid diagrams rendering black (#2322)
On a dark theme Mermaid diagrams painted near-black: node fills and borders came from the editor surface and chrome border, class-diagram labels used nodeBorder, and with darkMode Mermaid darkened every derived series colour by 75%, turning pies black. Fills now use a lifted surface, strokes and labels use the foreground, series colours are passed in explicitly, Gantt task labels stay readable against their bars, and diagrams use the classic look (the neo look hard-codes a black start state). Applies to the Markdown preview and agent chat, which share the theme helper; covered for flowchart, sequence, class, state, pie and Gantt in light and dark.

Co-authored-by: lzskyline <[email protected]>
2026-11-10 01:19:12 +08:00
Ilya Lтаmushketer888 3ae6bddcc0 feat(arduino): add sketch code intelligence (#2177)
.ino sketches get C++ highlighting and code intelligence through Arduino Language Server: completion, signature help, hover, definition navigation and diagnostics. The IDE finds arduino-cli, clangd and the language server installed by Arduino IDE 2 (or paths from languages.arduino settings), reads the board FQBN from sketch.yaml or settings, roots the server at the sketch, and works around the server's capability quirks. Build, upload and serial monitor are left to a plugin.

Co-authored-by: mushketer888 <[email protected]>
2026-11-10 01:16:21 +08:00
ntbang0901 b6cb416036 fix(workbench): keep empty editor hints aligned when an action is unbound (#2345)
The empty editor's hint grid places cells automatically in two columns; a row whose action had no shortcut left out its key cell, so every later label moved into the shortcut column. Rows with an action now always render the key cell, empty when unbound. Covered over every keymap preset and user overrides.

Co-authored-by: ntbang0901 <[email protected]>
2026-11-10 00:58:36 +08:00
l0ng-ai 4126822be8 fix(window): preserve fullscreen when adding project tabs (#2389)
Adding a project to a native macOS tab group anchored the group on the newly opened window, so AppKit took that window's normal frame and the group left full screen (black margins, stacked title bar and tab strip). Tab sync now anchors on the group's currently selected window and only selects the new tab. Verified on macOS with the system tab preference set to always and manual, in full screen and windowed: open, switch, close, merge and detach tabs. Fixes #2224.
2026-11-10 00:53:59 +08:00
Qingтаqtvbwfn 29af5994e3 feat(syntax): support structure and local navigation in inline scripts (#1593)
Without a language server, inline <script> blocks of HTML containers (vue, html, svelte, jsp) had no File Structure entries, breadcrumbs or Go to Declaration. The structure service now nests each script block's TS/JS declarations under its script entry, with region-local UTF-16 positions mapped to document coordinates, and the declaration fallback answers from the block grammar's tree inside a script. Language server answers still take precedence. Covers multiple and uppercase script blocks, CRLF, multi-byte text before a script, memo reuse per document version and parser errors.

Co-authored-by: qtvbwfn <[email protected]>
2026-11-10 00:43:33 +08:00
l0ng-ai 226ac26954 fix(run): resolve stack frames from all exact source names (#2313)
Stack-trace navigation only searched the first 50 fuzzy file matches, so in a project with many files of the same name the project source could be skipped in favour of a library or decompiled class. Frames now look up every indexed file with the exact source basename (new find_files_named) and then apply the existing package-path and declaration checks. Covers Java, Kotlin (including sources outside their package directory), Scala and Groovy; older remote servers fall back to their full fuzzy list. Refs #1660.
2026-11-10 00:39:59 +08:00
l0ng-ai 658c38a193 fix(app): keep manual update check answers visible (#2385)
A manual update check could end without a visible answer: balloons were hidden whenever a saved layout listed Notifications as open, even when that tool window was not actually shown (welcome screen, hidden panel), and marking the list read dropped unlogged balloons. Balloons now defer only to an effectively visible Notifications tool window, and only listed entries are marked read. Covers update available, up to date and error in project, welcome and light-edit windows. Fixes #2285.
2026-11-10 00:16:38 +08:00
ntbang0901 20be9ff646 fix(settings): stop a theme picked earlier staying previewed in the Settings window (#2343)
The theme row only re-previewed a staged theme that differed from the saved one, and only when the list closed, so re-picking the saved theme quickly or pressing Reset left the earlier pick painted in the Settings window. It now shows its staged theme, or the saved one, whenever that changes and whenever the list closes.

Co-authored-by: ntbang0901 <[email protected]>
2026-11-10 00:03:00 +08:00
LU XIANGRUIтаLU XIANGRUI 394e9919c7 feat(project-view): reveal library classes opened from jars in External Libraries (#2378)
- Map jdt://, jar: and decompiled class URIs to <archive>!/<entry> paths inside the library archive
- revealPath handles them in one place, so Select Opened File and autoscroll both work

Co-authored-by: LU XIANGRUI <[email protected]>
2026-11-09 23:41:30 +08:00
l0ng-ai e839e5dfb7 fix(terminal): preserve protocol replies during IME commits (#2312)
The IME duplicate-commit gate dropped every emission after the first one in a commit, including xterm's device/cursor replies and mouse reports, so TUIs stopped getting answers after typing through an IME. The gate now only collapses text; ESC-prefixed output always reaches the PTY. Refs #2160.
2026-11-09 23:38:34 +08:00
LU XIANGRUIтаLU XIANGRUI ff17c3753f feat(plugins): notifications targeted at one project (API 1.5) (#2375)
- Add ui.notify-project: it shows only in that project's window (the most recently focused one if there are several) and works without a calling window
- Button commands run in the window showing the notification; a closed project returns not-found, with nothing shown and no fallback to another window
- Bump the plugin API to 1.5.0 (wit/, SDK copy, ENGINE_VERSION, API_VERSION) and rebuild all components
- Add Project::notify and its mock to the SDK; update plugins/README.md

Co-authored-by: LU XIANGRUI <[email protected]>
2026-11-09 23:20:38 +08:00
LU XIANGRUIтаLU XIANGRUI 5f970d97d5 feat(data): move rebuildable local data with OPENIDEA_LOCAL_DATA_DIR (#2373)
- The local data directory (tools, language servers, index caches) is resolved in one place, ide_host::data_dirs::local_data_dir
- The variable must be an absolute path; a missing directory is created, and an unwritable one exits with an error instead of falling back to the system default
- Config and log directories still follow the system
- Add docs/data-directories.md

Co-authored-by: LU XIANGRUI <[email protected]>
2026-11-09 23:01:11 +08:00
ntbang0901таNguyenBang 16bf9b53a2 feat(plugins): show a running state on a plugin command's toolbar button (#2371)
A plugin command that takes seconds (an LLM call that fills the commit message) gave no
sign at the button that the click was taken, and a second click started a second run.

Actions may now set `indicatesRunning`: the action runner counts their in-flight runs
and an IconButton bound to such an action shows a spinner, sets aria-busy and ignores
clicks until the run settles. Plugin commands set it, so every toolbar slot that draws
them (editor.title, mainToolbar, commit.message) is covered without a plugin API change
and without features importing each other. The button stays focusable (aria-disabled,
not disabled). Spinner moves to its own module so Button can use it without a cycle
with Feedback, which re-exports it.

Refs #2254

Co-authored-by: NguyenBang <[email protected]>
2026-11-09 22:43:53 +08:00
LU XIANGRUIтаLU XIANGRUI 68dd88993b feat(database): submit and revert pending console result edits from the keyboard (#2370)
- The console result grid reuses the table editor's Submit / Revert actions and shortcuts, active only while the grid has focus and pending edits
- ⌘↩ while editing a cell confirms that cell before submitting (the table editor does the same)
- Submit / Revert button tooltips show their shortcuts; the result grid supports Go to Column
- ⌘↩ still executes the statement in the SQL editor

Closes #2332

Co-authored-by: LU XIANGRUI <[email protected]>
2026-11-09 22:39:29 +08:00
l0ng-ai b50219b6fa build: use thin LTO for release builds (#2366) 2026-11-09 22:04:59 +08:00
LU XIANGRUIтаLU XIANGRUI 7090a8db56 test(markdown): cover Mermaid flowchart nodes and sequence control blocks against solid black (#2363)
- Render the flowchart from the issue verbatim and assert its nodes and subgraph are not pure black
- Add an assertion for the sequence diagram loop control block (.labelBox); the #2288 test only covered cluster

Co-authored-by: LU XIANGRUI <[email protected]>
2026-11-09 21:54:41 +08:00
l0ng-ai 37c2a6048e chore: v0.1.40 2026-11-09 21:05:39 +08:00
l0ng-ai d71434a0cb fix(vim): keep shadowed leader built-ins across rc reloads (#2361) 2026-11-09 21:04:29 +08:00
l0ng-ai 53f837b704 test(e2e): reach the end of the diff with Meta+End
Meta+ArrowDown now jumps to source in the diff viewer, so the scattered
edits test never left the first difference.
2026-11-09 20:53:05 +08:00
l0ng-ai 7c80b68787 fix(vim): only shadow leader keys, not operators a mapping starts with (#2360) 2026-11-09 20:50:59 +08:00
Shohruh Ismoilov 44d871caee fix(deps): bundle patched SQLite for RUSTSEC-2026-0459 (#2351)
Update libsqlite3-sys to 0.39.1 (SQLite 3.54.1), which needs SQLx at its unreleased rev 10462fa: SQLx 0.9.0 on crates.io caps libsqlite3-sys below 0.38. The rev also brings the 48 other commits on SQLx's main since 0.9.0. Cargo deny allows this one Git source and requires revision pins.
2026-11-09 20:30:07 +08:00
arookieofc 89f96edc07 fix(vim): let mapleader shadow built-in keys like <Space> and , (#2337) (#2355)
* fix(vim): let mapleader shadow built-in keys like <Space> and , (#2337)

codemirror-vim prefers a full match over a partial one, so a user mapping
that starts with <Space> or , never fires: the built-in <Space>->l and
,->repeatLastCharacterSearch commands run first and clear the key buffer.

While an rc mapping begins with a key a built-in uses, unmap the built-in;
restore it on the next applyRcCommands pass so mapclear()'s user/default
boundary stays honest (Vim.unmap does not update defaultKeymapLength).

* fix(vim): pass the context arg Vim.unmap's typings require (#2337)
2026-11-09 20:27:38 +08:00
l0ng-ai fd79a5c55e fix(editor): keep the page from scrolling with the editor (#2356)
CodeMirror's tooltip container in `body` carries the editor's theme classes,
so the themes' `height: 100%` gave it a full window of height. Scrolling to a
position the editor had already reached then moved the page itself, leaving
the title bar cut off and an empty band at the bottom.

Set the height on `.cm-editor` only, and clip the body so nothing outside
`#root` can make the page scrollable.
2026-11-09 20:22:12 +08:00
arookieofc bb8c9fa0b4 fix(vcs): remember the diff window's size between openings (#2328) (#2353)
The modal-commit diff dialog always reopened at its default 1100x720.
Pass the persisted size as the dialog's initialSize and save it when a
resize ends, like the Find in Files popup already does.
2026-11-09 20:14:28 +08:00
ntbang0901таNguyenBang 039ec09122 feat(vcs): pick each repository's remote and target branch in the multi-repository Push dialog (#2342)
Co-authored-by: NguyenBang <[email protected]>
2026-11-09 20:10:09 +08:00
ntbang0901таNguyenBang d02aae2280 feat(keymap): bind ⌘↓ to Jump to Source in the diff viewer on macOS (#2341)
IDEA's Diff.OpenInEditor copies EditSource's shortcuts, which the Mac OS X 10.5+ keymap binds to F4 and ⌘↓. The IntelliJ (macOS) preset only had F4 for diff.jumpToSource, which macOS often takes for itself. The binding is scoped by diffEditorFocus, so ⌘↓ still moves to the document end in other editors.

Closes #2292

Co-authored-by: NguyenBang <[email protected]>
2026-11-09 20:08:01 +08:00
NealimeKennaтаNealimeKenna e44a3af310 fix(agents): attach pasted clipboard images on Linux (#2336)
WebKitGTK hands the composer an empty clipboardData when the clipboard
holds only an image, as after a GNOME screenshot, and refuses
navigator.clipboard.read(). Ctrl+V then attached nothing. A paste that
carries no data now reads the image through the GTK clipboard with the
new clipboard_image command.

Co-authored-by: NealimeKenna <[email protected]>
2026-11-09 19:59:30 +08:00
l0ng-ai ff2eb95b01 fix(lsp): respect Dart's own filesystem watcher (#2350) 2026-11-09 19:58:10 +08:00
l0ng-ai b2b881e075 fix(debug): wait for Windows debuggee trees to exit (#2349) 2026-11-09 19:58:00 +08:00
l0ng-ai 90d32f7883 fix(lsp): wait through planned server restarts (#2348) 2026-11-09 19:57:51 +08:00
arookieofc f72e3a9a8a fix(fonts): prefer installed JetBrains Mono/Inter over bundled variable webfonts (#2311)
* fix(fonts): prefer installed JetBrains Mono/Inter over bundled variable webfonts

When the user has JetBrains Mono or Inter installed, the system outline
fonts carry TrueType hinting bytecode that the bundled fontsource variable
webfonts lack, so the installed face renders crisper at editor sizes and
matches IntelliJ IDEA. Keep the variable faces as fallbacks so machines
without a system install keep rendering (#26).

Fixes #2305

* test(e2e): expect installed Inter before bundled Inter Variable (#2305)

* test(e2e): match computed-style serialization in the Inter font assertion (#2305)

* chore: trigger CI (no runs created for 950cb4b4)
2026-11-09 19:43:48 +08:00
l0ng-ai 1ae8572021 fix(project): keep attachments in the current project (#2320)
Attach adds a trusted folder to the current project without reopening it or moving its root. Attachments live in the primary project's private .openidea/workspace.json, and detaching the last one restores a plain project.

ProjectInfo.name stays the primary project's name; displayName supplies window titles, project tabs and Recent Projects. Attach is no longer a remembered opening policy, and File > Attach Project is available when the policy is not Ask. Attached folders must be trusted, and Restricted Mode names any attached folder that blocks trust. Explicit workspaces keep their behaviour and still cannot be created in or inside a linked project.
2026-11-09 19:23:05 +08:00
l0ng-ai 023c3f36c2 feat(app): check for updates while the IDE stays open (#2329)
* feat(app): check for updates periodically

* test(app): cover update commands in Restricted Mode
2026-11-09 19:13:47 +08:00
l0ng-ai 88a1e4161e Update README.md 2026-11-09 18:44:37 +08:00
l0ng-ai 0b1f723b9a fix(build): link missing tool and JDK errors to recovery (#2335) 2026-11-09 18:15:24 +08:00
l0ng-ai e2f22de824 fix(tools): install with the selected project SDK (#2334) 2026-11-09 18:13:57 +08:00
arookieofc 24d3173a55 fix(editor): parent CodeMirror tooltips to the body so panels can't cover them (#2310) (#2327) 2026-11-09 18:12:18 +08:00
l0ng-ai 799391b301 fix(debugger): report obsolete methods as a completed hot swap (#2330) 2026-11-09 18:07:12 +08:00
Budi Santosoтаboenkkk 8ddf979cfe feat(db): separate procedures and functions into their own schema folders (#2270)
The database tree lists routines in one `routines` folder. A Separate Procedures and Functions option in the Database tool window's menu (off by default, remembered across sessions) lists `procedures` and `functions` folders instead, each holding only routines of that kind, with the same overload-aware keys and object filter. Expanded folders carry over when the option is switched either way.

Co-authored-by: boenkkk <[email protected]>
2026-11-09 18:05:59 +08:00
Qing f3f9fdb1ad fix(run): drop the leftover vue kind from the category table (#2302)
The vue run-configuration kind never shipped upstream — detection is gone
and no type registers it. Its KIND_CATEGORIES row (and the test table row)
are dead data; the plugin-era dev-server story is covered by plain npm
configurations with stable ids.
2026-11-09 18:01:46 +08:00
arookieofc 687471c19e fix(database): table editor focus when the tab opens targets the editor host (#2307) (#2318)
The editor area focuses its own [data-editor-focus] host when a table tab
opens; dbTableEditorFocus only set when focus landed inside
[data-db-table-editor], so Go to Column / Submit / Revert stayed disabled
until a cell was clicked. Treat host focus that wraps a table editor as
table-editor focus too.
2026-11-09 17:58:29 +08:00
yoo hyeong chanтаarookieofc a46d873f6c feat(java): share JDT LS across builds in a project window (#2272)
* feat(java): share JDT LS across builds in a project window

* fix(java): wait for JDT restart before launch requests

* fix(host): give relay final replies ten seconds of EOF grace

relay::tests::final_responses_keep_flowing_after_input_eof flakes on
Windows CI: the target writes one byte per second for seven seconds
after input EOF, but the relay only waits five idle seconds, so a
slow first byte makes the relay return with nothing received.

Raise EOF_GRACE to ten seconds and update the companion test's
server wait and elapsed bound to match.

Refs #2272 (Windows CI failure)

---------

Co-authored-by: arookieofc <[email protected]>
2026-11-09 17:53:59 +08:00
Emanyzwwwтаemanyzwww 5d3432a000 fix(welcome): name the platform's file manager, not Finder (#2265)
On Windows and Linux several places still named macOS's Finder: the recent-project row menu (Show in Finder), Settings › Version Control › Confirmation › When patch is created, the question after a patch is saved, and the reveal error. The wording was an English literal at each call site, one place sniffed the user agent, and the catalogs only had Finder's text. Shared helpers in src/platform/fileManager.ts now name Finder / Explorer / Files from the platform info, and the project view's Reveal action, the editor tab menu, the project tree menu, the welcome page and the patch flow use them, with whole-sentence catalog entries so each locale can translate them.

Co-authored-by: emanyzwww <[email protected]>
2026-11-09 17:51:48 +08:00
ppxb 16ebb6fde8 feat(run): import JetBrains Go Application and Go Test configurations (#2309) 2026-11-09 17:49:59 +08:00
l0ng-ai 0e673c206a fix(editor): keep the saved layout when a session restore meets open tabs (#2325)
A session restore that lands after tabs were already opened (a file launched into LightEdit while the window was still restoring, or a file opened from the CLI into a project window during startup) appended every restored tab to the current pane: saved splits collapsed into one pane, restored pinned tabs landed after unpinned ones, a pane could hold several preview tabs, and the tab limit was skipped. The restore now starts from the saved state and reopens the live tabs into it through the normal tab rules (pin order, one preview per pane, tab limit without evicting the live tabs), keeping their slots and line/column targets; the launched tab ends up active and focused in the saved active pane. An empty window still takes the saved layout as is.

Refs #2229
2026-11-09 17:42:23 +08:00
arookieofc 2680ba8463 fix(agents): notify subscribers when a session report changes the cached provider config (#2308)
* fix(java): pass m2e an effective settings file carrying the configured local repository (#1739)

m2e resolves the local repository from the Maven settings XML, never from
-Dmaven.repo.local, so JDTLS imports kept downloading into ~/.m2/repository
even when the IDE's own mvn runs honored the configured path. When a local
repository is configured but the user settings lack <localRepository>, derive
an effective settings file under <root>/.openidea/ and pass it as
java.configuration.maven.userSettings. Settings that already declare
<localRepository> are passed through untouched.

* fix(agents): notify subscribers when a session report changes the cached provider config (#1490)

Session reports (session/new, session/load, turn updates) and CLI discovery
funnel through Shared::remember_inner, which cached new model lists but never
emitted AgentEvent::Providers. The composer therefore kept showing a stale
model list until the next task's busy-to-idle refresh. Emit Providers when the
cache actually changes; unchanged reports stay silent, and the cache lock is
released before invoking the sink. fetch_settings' unconditional emit is now
covered by remember_discovered and removed.
2026-11-09 17:39:15 +08:00
arookieofc 660587a6fe fix(java): list JDT .classpath libraries and the JDK for manifest-less projects (#2300) (#2304)
* fix(java): list JDT .classpath libraries and the JDK for manifest-less projects (#2300)

External Libraries was empty for a pure-Java project (only .classpath /
.idea/libraries, no pom.xml or build.gradle):

- jdk_groups returned early without a build manifest, so even a
  configured JDK never appeared. It now also treats .classpath, .project,
  .idea, a configured JDK and declared source roots as Java markers.
- The JDT model was never a source of libraries. classpath_groups parses
  each module's .classpath kind="lib" entries (sourcepath preferred when
  it exists) into a per-module group, discovered through the same module
  scan the other ecosystems use.

Adds quick-xml to ide-sdk (already used by ide-run / ide-host) and the
.classpath file to the External Libraries refresh triggers.

* chore: regenerate bindings for classpath library kind
2026-11-09 17:32:07 +08:00
l0ng-ai 2955cdcf9c fix(editor): restore an evicted tab's exact scroll offset (#2324) 2026-11-09 17:26:35 +08:00
l0ng-ai 5a48beab07 feat(dotnet): add workspace, NuGet and test workflows (#2303)
* feat(run): show .NET test results and rerun failed methods

* feat(dotnet): add workspace, NuGet and editor test workflows

* fix(dotnet): discard cancelled queries and scan test targets linearly
2026-11-09 17:17:27 +08:00
Zhu Minhaoтаzmh69695328 dfa610beb0 fix(gradle): read dependencies from scripts a build applies (#2277)
Dependencies declared in a script pulled in with `apply from:` never reached External Libraries, and `"g🅰️${v}"` versions were dropped because statement splitting at `{` cut them in half. The scan now follows `apply from` recursively (Groovy and Kotlin DSL; `$rootDir`, `$projectDir`, `rootProject.projectDir`; relative paths from the project directory, then beside the applying script; URLs and missing files skipped; cycles and aliases cut by canonical path, bounded at 256 scripts and 32 levels). It fills `ext`/`extra` values (`ext.x`, `ext { }`, `ext["x"]`, `extra["x"]`, `val x by extra(...)`) into `${...}` versions with gradle.properties taking precedence, and counts applied scripts as build inputs, so editing one invalidates the cached model and refreshes the Build tool window and External Libraries.

Co-authored-by: zmh69695328 <[email protected]>
2026-11-09 17:05:20 +08:00
ovra e86134ce83 feat(vcs): soft-wrap long lines in the diff viewer (#2286)
The diff viewer never soft-wrapped, so a long changed line ran off the right edge. Diff panes now follow Settings › Editor › General › Soft-wrap files for the file, and the viewer's settings menu has a Use Soft Wraps checkbox that overrides it for all diffs and is remembered with the other viewer options. View › Active Editor › Soft-Wrap (editor.toggleSoftWrap) also toggles it when a diff pane has focus. Toggling reconfigures both panes in place, keeping the selection and the scroll position, including aligned split diffs.

Closes #2263

Co-authored-by: atlas-u <[email protected]>
2026-11-09 17:03:21 +08:00
Deng Liuтаl0ng-ai 4bc08a1084 fix(windows): open loose external files in the focused project window (#923)
* fix(windows): route loose external files to active project window

* fix(windows): open loose files in the focused window only

Loose files join the focused window when it holds a local project or
LightEdit. A Welcome or remote window, or a project window in the
background, keeps the previous routing: the LightEdit window, else a
free window, else a new one.

---------

Co-authored-by: l0ng-ai <[email protected]>
2026-11-09 16:27:45 +08:00
arookieofc cb2f249db5 fix(java): pass m2e an effective settings file carrying the configured local repository (#1739) (#2301)
m2e resolves the local repository from the Maven settings XML, never from
-Dmaven.repo.local, so JDTLS imports kept downloading into ~/.m2/repository
even when the IDE's own mvn runs honored the configured path. When a local
repository is configured but the user settings lack <localRepository>, derive
an effective settings file under <root>/.openidea/ and pass it as
java.configuration.maven.userSettings. Settings that already declare
<localRepository> are passed through untouched.
2026-11-09 16:16:38 +08:00
Hacbit 2dd57d095f feat(unity): open scripts from external editor arguments (#2296)
* feat(app): support Unity external editor navigation

* docs(unity): explain manual external editor setup
2026-11-09 16:03:37 +08:00
Clarence 5ab5c8c677 fix(run): read JetBrains path options written as file: URLs (#2287)
IDEA writes path options as URLs (`WORKING_DIRECTORY = file://$PROJECT_DIR$`). The run configuration importer expanded the macros but never stripped the `file:` scheme, so on Windows `file://C:/project` became the drive-relative `C:project` and was joined onto the project directory, and imported configurations failed to launch. Every path-valued option the importer reads (working directories, SDK home, script and settings files, pytest targets, env files including EnvFile entries, package.json, package manager, node interpreter, config file, test directory/file, JS file, interpreter path, path arguments) now goes through one conversion: drive letters in the authority position, `localhost` as a local authority, UNC for a real host, and IDEA's unencoded paths kept verbatim. Env-file lists no longer split at a URL's scheme colon on Unix.

Co-authored-by: Clarence404 <[email protected]>
2026-11-09 15:48:31 +08:00
Deng Liuтаdengliu 14aa612038 fix(markdown): keep Mermaid styles scoped after rewriting diagram ids (#2288)
reid() made cached Mermaid diagrams' ids unique by suffixing every id, but only rewrote references followed by `"` or `)`. The root svg id changed while the selectors in the SVG's own <style> (`#id{`, `#id .cluster rect`, `#id>`…) did not, so none of Mermaid's rules matched and clusters, nodes and edges rendered solid black; the suffix also broke selectors that match marker ids by suffix. Ids now get a per-application prefix, rewritten consistently in id, href/xlink:href, aria-labelledby/describedby, url(#…) in presentation attributes (quoted or not) and the ID selectors of <style> rules, leaving declarations (hex colours), labels and unknown references untouched; an id that is a prefix of another (`-1` vs `-10`) stays distinct.

Co-authored-by: dengliu <[email protected]>
2026-11-09 15:29:11 +08:00
l0ng-ai ab5f71b442 fix(installer): preserve legacy Windows installation scope (#2225) 2026-11-09 15:26:21 +08:00
Deng Liu c5124b809d feat(docker): add Services dashboards, layers, processes, view filters and log editor tabs (#2246) 2026-11-09 15:24:52 +08:00
ppxbтаppxb a206ac0ca5 fix(vim): accept special-key names in any case in rc mappings (#2291)
codemirror-vim matches special keys only in their exact canonical spelling, so rc mappings written for Vim/IdeaVim such as `inoremap jk <esc>` never fired. The rc parser now canonicalizes `<...>` key names on both sides of a mapping: any case and the usual aliases (Esc, CR/Enter/Return, BS, Del, Ins, Space, Tab, Bar, Bslash, arrows, Home/End, PageUp/PageDown, F1-F12), modifier prefixes in any case and order (C-/S-/A-/M-, D- as Command), `<lt>` kept literal on the right-hand side, Ex mappings ending in <Enter>/<Return>, and leader values containing `$&`. Unknown tokens are left untouched.

Closes #2281

Co-authored-by: ppxb <[email protected]>
2026-11-09 15:23:41 +08:00
arookieofc b12fabd472 fix(lightedit): merge the saved session with launched tabs instead of replacing them (#2229) (#2290) 2026-11-09 15:22:18 +08:00
e77ee39047 fix(editor): keep the corner overlay from covering line ends (#2289)
The inspection widget and the Review Mode group float in `.cm-editor`,
outside `.cm-scroller`: they neither scroll with the text nor count in
`scrollWidth`, so at full horizontal scroll the last characters of a line
landed under them.

Reserve the corner's measured width in the lines' right padding, next to
the room the fixed padding already keeps for the overview lane.

Co-authored-by: yhqian <[email protected]>
Co-authored-by: Claude Code <[email protected]>
2026-11-09 15:20:49 +08:00
l0ng-ai 1a008fd17e perf(java): reuse usage search results and skip covered folders when widening (#2278)
Find Usages and usage counts searched again on every request: about two
seconds each for a common type such as String.

- The Java usages extension (1.12.0) keeps answers by search target until a
  Java element, the classpath or an unsaved edit changes, and requests for
  the same target share one search.
- Widening checks up to 5000 candidates against the server's analysis scope
  before applying the 300-file limit, so names mentioned in many covered
  files no longer report "too many files".
- Candidate scans skip the source folders the server covers fully
  (openidea.workspace.coveredRoots) instead of reading every file.
- Commands a server registers after initializing are added to its commands.
2026-11-09 14:44:48 +08:00
ppxb d2603facdf feat(tomcat): skip the before-launch build when the WAR is up to date (#2276)
* feat(tomcat): skip the before-launch build when the WAR is up to date

* fix(i18n): remove obsolete Tomcat WAR build messages from catalogs
2026-11-09 14:42:47 +08:00
l0ng-ai 89c7ae7d4c perf(java): count usage lenses in the project sources only (#2244)
* perf(java): count usage lenses in the project sources only

Usage lenses asked JDT LS for a full references search per declaration,
which also scans every dependency jar and the JDK. In a library class such
as java.lang.String that kept JDT LS busy for tens of seconds per pass,
and failed counts were asked again on every scroll.

- Scoped Java searches without libraries pass the "sources" scope.
- Lenses count declarations near the viewport (10 lines instead of 60).
- Incomplete counts are remembered per document version and asked again
  once the server settles, instead of on every pass.

* perf(java): turn off the language server's own reference lenses

JDT LS shows a references lens above each declaration by default and the
editor resolved them all at once, a references search per declaration on
every open and edit. The editor's usage lenses already count usages.

* style(editor): keep the usage lens's unrelated lines as they were
2026-11-09 14:34:05 +08:00
misswell 89b178f9aa feat(run): a Find entry on every console, not just the Run toolbar (#1764)
Console search exists (⌘F, the magnifier in a Run tab's toolbar), but a console without a
toolbar of its own — a stack-trace tab, say — offered no visible way in: the shortcut only
worked once the output had focus, and nothing said so.

Every uncontrolled ConsoleView now shows a small Find button over the output's top-right
corner, opening the same find bar (query, match count, Enter/Shift+Enter, case and regex
options). Consoles whose find bar a toolbar drives keep exactly one entry: the toolbar's.

How it was verified:
- pnpm vitest run src/features/run/findBar.test.tsx: 9 passed, including a new test that
  the button opens the find bar and focuses its input.
- pnpm typecheck.
2026-11-09 14:29:23 +08:00
Bruno BelloniтаClaude Opus 5.5 c5c5930a75 perf(terminal): skip DOM work for rows whose markup did not change (#2239)
xterm's DOM renderer rebuilds every dirty row with replaceChildren even when
the new spans are identical. Full-screen TUIs (Grok, Claude Code) redraw the
whole screen on each spinner tick, so WebKitGTK restyles, lays out and paints
all rows several times a second and keystrokes queue behind it.

Install a row cache on xterm 6's DomRenderer: rows are still built, but a row
whose markup matches what is already shown is left in place. clear() and link
underlines rebuild rows outside renderRows, so they reset the cache. The
install is a no-op when xterm's renderer no longer has the expected shape.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-09 14:23:32 +08:00
l0ng-ai 40be4e4c17 fix(agents): leave a conversation's end for scrolls no layout caused (#2283)
Following the end snapped back any scroll away from it that didn't follow a
wheel, touch, key or pointer press. A scroll without one, like dragging the
scrollbar thumb where no pointer event reaches the page or a programmatic
jump, was undone. The offset WebKit moves back on its own comes with a change
of the content height while rows lay out; only such a scroll keeps following
now, any other leaves the end.
2026-11-09 14:19:47 +08:00
l0ng-ai 502cc348ba perf(editor): keep recently used editor views across tab switches (#2282)
Switching between file tabs destroyed the hidden tab's CodeMirror view and
built a new one on return: every switch re-ran the view's plugins, re-measured
the height map (the scrollbar jumped) and re-requested inlay hints, usages and
diagnostics, which popped in and shifted the text up to a second later.

The eight most recently hidden views are now kept detached and re-attached on
return, with their decorations, height map and plugin state. Edits while hidden
go to the kept view; plugins that skip work while their view is detached catch
up when it is shown again (`viewShown`).

Switching also forces fewer layouts: the Switcher no longer gives focus back to
the editor left behind when it opens another tab, the tab strip only measures
the active tab when it isn't known to be in view, and focus moving between
editors no longer drops the old editor's DOM caret.

The tabs perf scenario counts views rebuilt when switching back (budget 0).
2026-11-09 14:16:21 +08:00
arookieofc eb72b78abb fix(sdk): list conda environments from the conda on PATH (#2240) (#2273)
Python SDK discovery only scanned ~/anaconda3-style install roots and
<root>/envs — an Anaconda installed off the home dir (D:\soft\anaconda3,
ProgramData) hid every environment from the Conda Environment picker even
though 'conda env list' saw them all.

Now: the conda binary on PATH reveals its install root (<root>/Scripts or
bin → prefix), 'conda env list' adds --prefix envs kept outside envs/ and
envs_dirs configured in .condarc, and the env probe looks for python.exe at
the env root on Windows — where conda puts it — instead of only under
Scripts/ (virtualenv layout).
2026-11-09 13:32:28 +08:00
l0ng-ai 542584abb8 feat(debug): debug programs in a plugin run target (#2264) 2026-11-09 12:52:41 +08:00
arookieofc 0ccc1e687d fix(run): pin the JDWP port for Maven test forks (#2215) (#2268)
A Surefire/Failsafe fork started with suspend=y never reaches the console
with its "Listening for transport" line: surefire buffers the fork's
output until the tests run, and the tests wait for the debugger. The IDE
found no port, never attached, and Stop left an orphan debug session.

Pick a free port up front (new debug_free_port IPC), pass it to the
-Dmaven.surefire.debug/-Dmaven.failsafe.debug agent as
address=127.0.0.1:<port>, and attach right away — java-debug retries the
connect while the fork starts, so no output line is needed. A duplicated
line that does arrive later is deduplicated by port.
2026-11-09 12:50:40 +08:00
l0ng-ai 92b527ab0e fix(agents): keep following a conversation's end unless the user scrolls away (#2269)
Only user scrolling (wheel, touch, keys, the scrollbar, Find) leaves the end of an agent conversation; offsets WebKit moves back on its own while rows lay out no longer stop it following and painting its history.
2026-11-09 12:27:49 +08:00
l0ng-ai 0e81c6aeb1 feat(lsp): run language servers in a plugin run target (#2250)
* feat(lsp): run language servers in a plugin run target

* test(settings): list the language server targets page
2026-11-09 12:22:21 +08:00
l0ng-ai f8e15a7980 fix(plugins): give test selection the command budget (#2258)
* fix(plugins): give test selection the command budget

* test(plugins): bound test selection timeouts by the command budget
2026-11-09 12:21:32 +08:00
ppxb d17f6755f3 fix(dap): avoid free-port race when starting debug adapters (#2262) 2026-11-09 12:10:31 +08:00
l0ng-ai f1c10f4d84 perf(search): switch editors in the frame the Switcher closes (#2267)
The Switcher popup unmounts synchronously and gives focus back, then the selection opens in the same task, so closing and switching paint in one frame instead of two.
2026-11-09 12:05:04 +08:00
l0ng-ai 54d35d8a67 feat(lsp): language servers inside plugin-injected ranges (#2235)
* feat(lsp): language servers inside plugin-injected ranges

* chore(plugins): rebuild bundled plugins for the updated injections docs
2026-11-09 11:53:07 +08:00
arookieofc b6b09eb968 fix(delve): retry removing __debug_bin leftovers on Windows (#2257)
A just-killed dlv can still hold this session's binary while the previous
run's lock is already released, so one remove_file attempt only ever
deleted the older leftover. Retry with a short back-off so the sweep
outlasts the handle release.
2026-11-09 11:47:26 +08:00
l0ng-ai 36fae164a8 perf: add a scenario for switching between and scrolling long agent conversations (#2259)
Adds the agentTabs perf scenario: two 2,100-entry agent conversations switched with Ctrl+Tab and scrolled, with budgets on DOM size, rows skipping rendering off screen, switch latency, scroll jumps and frame pacing.
2026-11-09 11:36:35 +08:00
arookieofc 8c21a1e088 fix(run): scroll the config list and form independently in the editor dialog (#2255)
.editBody had a min-height but no cap, so a tall form grew it past the
viewport and the dialog body scrolled both panes together. Cap it below
the dialog's own body bound and let each pane's overflow take over.
2026-11-09 11:36:21 +08:00
l0ng-ai ecba8aff68 docs(readme): update the WeChat group QR code 2026-11-09 11:33:51 +08:00
arookieofc c90d3a7a8e fix(java): run jdtls on a new-enough JDK behind Scoop shims and project JAVA_HOME (#2230)
* fix(java): run jdtls on a new-enough JDK behind Scoop shims and project JAVA_HOME

Two ways jdtls ended up on a JDK too old for its bundles (#2227):

- jdtls_min_java resolved the command's own path to derive the install
  root, but a Scoop shim (shims\jdtls.exe) hides the release layout, so
  the bundles' real requirement fell back to JDTLS_MIN_JAVA (21) while a
  1.64 install needs JavaSE 25. Extract the resolve/follow-shim flow
  from launch_directly into jdtls::script_for and share it.
- configure_jdtls only wrote JAVA_HOME when the config had none, so a
  project JAVA_HOME (JDK 8 for compilation) won over the chosen
  server_jdk. Probe the configured value: a home too old or incomplete
  for the server is replaced by server_jdk or any usable JDK; an
  unresolvable explicit value stays (the user's call).

* test(java): compare JDK homes against canonical paths

jdk_at reports the canonicalized home (macOS /var -> /private/var,
Windows 8.3 short names), which configure_jdtls now writes to JAVA_HOME.
Have test_jdk canonicalize too, and assert the runtimes/JAVA_HOME values
against the Jdk.home fields instead of raw fixtures.path() joins, which
also differ on Windows when the join arg uses forward slashes.

* test(jdtls): compare JAVA_HOME against the canonical jdk home

test_jdk now canonicalizes home, so the duplicate-versions assertion must
compare against server.home rather than the raw tempdir join, which on
Windows carries 8.3 short names.
2026-11-09 11:08:28 +08:00
l0ng-ai 115d789091 perf(agents): make switching to a long conversation tab cheap (#2252)
Folded conversation sections render their content only when opened (or while Find in Conversation is open), and off-screen rows skip rendering once laid out, so showing a kept-alive agent tab no longer restyles the whole history.
2026-11-09 11:05:53 +08:00
Kamal ElFadlтаelfadl fb92a82101 feat(debugger): support clickable file and URL links in debug console (#2245)
Co-authored-by: elfadl <[email protected]>
2026-11-09 10:56:44 +08:00
arookieofc 7829bee55c fix(vcs): edit the full commit message, not only the subject (#2242)
A paged Git log only carries each commit's summary line; the Edit Commit
Message dialog therefore opened with the body missing, and OK rewrote the
commit to the subject alone.

Load the full message through loadLogDetails when the commit's
messageTruncated flag is set, the same path Copy Commit Message already
uses, so the dialog edits subject and body together.

Fixes #2238
2026-11-09 10:48:39 +08:00
l0ng-ai 13b590f2c7 feat(plugins): let test frameworks select tests to rerun (#2228)
* feat(plugins): let test frameworks select tests to rerun

* test(smoke): await Rerun Failed Tests
2026-11-09 10:08:51 +08:00
l0ng-ai 8d5504b376 fix(usages): keep the scope summary row from taking half of the Find Usages panel 2026-11-09 10:07:02 +08:00
l0ng-ai 60b19b2ffb Revert "fix(agents): stop defaulting new tasks to bypass" (#2234)
Reverts #2232: new agent tasks default to Bypass again.
2026-11-09 09:55:17 +08:00
l0ng-ai 73b3a99699 fix(agents): stop defaulting new tasks to bypass (#2232)
New agent tasks defaulted to Bypass whenever the provider offered it, whatever the user had chosen. A task now starts with the settings shown in the composer: the provider's reported mode, or Ask for Cursor. An explicit choice is kept, including Bypass, and running tasks keep their own mode. Cached provider settings could hold a Bypass written automatically with no record of its source, so a cached bypass is dropped on load, read and save while the option stays offered.

Fixes #740
2026-11-09 09:46:57 +08:00
arookieofc 95843ea12d fix(vue): highlight SCSS in <style lang="scss"> blocks (#2219) (#2221)
The Vue editor mode (`@codemirror/lang-vue` over the stock HTML base)
parses every `<style>` block as plain CSS, so SCSS — nested rules,
$-variables, and so on — rendered uncoloured or mis-highlighted.

Register a Vue mode of our own (overriding the language-data entry) that
gives the HTML base a nested Sass grammar for `<style>` whose `lang`
is `scss` or `sass`; blocks without a dialect keep CSS.
2026-11-09 09:23:17 +08:00
l0ng-ai 3dac5b8bdc fix(plugins): report Problems to plugins in remote projects (#2223) 2026-11-09 09:18:11 +08:00
l0ng-ai fd8b4ff14c fix(terminal): resume pasted input as soon as the PTY accepts more (#2220) 2026-11-09 09:17:30 +08:00
f70eb13f75 fix(process): don't spawn taskkill when the child started nothing (#2056)
`kill_tree` and `kill_tree_of` run `taskkill /T /F /PID` on Windows even when the child has no descendants, which is the common case for a program a plugin started, read from and closed. `taskkill` is a process spawn — ~0.7 s here, measured on this machine — and it runs inside host calls that are inside a plugin call's budget: dropping eight process handles at once overran a 5 s budget, so the plugin was trapped for something the host did.

When `descendants(pid)` is empty the child itself is all there is to kill, and both callers already do that (`kill_tree` follows with `child.kill()` + `child.wait()`, `kill_tree_of` leaves the child to its owner), so the spawn buys nothing. Skip it.

Verified:

- `cargo test -p ide-core` → 77 passed.
- `cargo test -p ide-host --test plugins` → 95 passed, including `a_plugin_keeps_a_process_running_behind_a_handle`, which fails on unmodified `main` on Windows (`plugin "misbehave" timed out after 5s` at its `childdrop` step, which drops eight handles) and now passes in 9.7 s instead of 20 s.
- clippy `--all-targets -D warnings` and `cargo fmt --check` clean; no component or WIT change, so nothing to rebuild.

Co-authored-by: chao <[email protected]>
Co-authored-by: l0ng-ai <[email protected]>
2026-11-09 09:14:41 +08:00
l0ng-ai 337bf4b1ca fix(terminal): configure ConPTY compatibility on Windows (#2180) 2026-11-09 09:12:20 +08:00
l0ng-ai 047cac209b feat(plugins): plugin API 1.4 (#2207)
Plugin API 1.4: grants for unencrypted HTTP, local sockets, IDE commands and owned database sources; window status items, output channels, content tabs, editor selections, decorations, search, Problems and VCS reads; runs, keyed terminals with exit events, plugin run targets, dynamic run configurations and test parsers; language features, injections, snippets and menu locations; plugin MCP tools on the built-in server; four official API examples. Released 1.3 plugins keep working.
2026-11-09 08:40:40 +08:00
SunZhiQiang c5647a5934 feat(agents): show the model list and effort in one popover (#2213)
Choosing an agent model took two popovers, and the list cut a name on one row while showing it in full on another. The chip's popover now shows the effort slider above a searchable model list. Names with a description keep their full width, and the description is cut instead. The list is extracted from `Select` as `SelectListbox`; `Select` keeps its previews across popup open and close.

Co-authored-by: panda-student <[email protected]>
2026-11-09 08:39:44 +08:00
SunZhiQiang 1f2d86f793 feat(agents): load Claude's models and effort before the first task (#2212)
New Agent Task showed no model, effort or mode for Claude until an agent had run a task. The `initialize` request that already loads commands also returns those options, so they are now remembered and shown, with one extra `get_settings` request to read the configured effort. No prompt or session starts, a failed lookup is not repeated, and a session's own report always wins, in memory and on disk.

Co-authored-by: panda-student <[email protected]>
2026-11-09 08:34:46 +08:00
Lee Dong Geon 998b478de3 ci(plugins): skip the scheduled plugin index refresh on forks (#2211) 2026-11-09 08:16:56 +08:00
1412 змінених файлів з 99738 додано та 10658 видалено

No files matched your search

Бінарний файл не відображається.

Після

Ширина:  |  Висота:  |  Розмір: 86 KiB

+1 -1
Переглянути файл
@@ -103,7 +103,7 @@ jobs:
if: runner.os == 'Linux'
run: git diff --exit-code src/bindings.ts
# The remote development server ships as a static Linux binary (release.yml):
# The remote development server ships as a static Linux binary (release-build.yml):
# keep that build working.
server:
name: Remote server (linux musl)
+17 -2
Переглянути файл
@@ -47,6 +47,7 @@ env:
JDTLS: 1.63.0-202610191322
KOTLIN_LSP: 263.7084.0
GRADLE: 8.14.3
INTELEPHENSE: 1.18.5
jobs:
smoke:
@@ -54,8 +55,9 @@ jobs:
strategy:
fail-fast: false
matrix:
language: [typescript, python, rust, go, java, kotlin]
runs-on: ubuntu-22.04
language: [typescript, python, rust, go, java, kotlin, php, c, cpp]
# C/C++ debug with GDB, whose DAP interpreter needs 14+: 22.04 ships 12.1, 24.04 ships 15.
runs-on: ${{ (matrix.language == 'c' || matrix.language == 'cpp') && 'ubuntu-24.04' || 'ubuntu-22.04' }}
timeout-minutes: 40
steps:
- uses: actions/checkout@v4
@@ -74,6 +76,12 @@ jobs:
- if: matrix.language == 'typescript'
run: npm install --global "@vtsls/language-server@$VTSLS" "typescript@$TYPESCRIPT"
# The smoke builds the c-app fixture and debugs it with GDB's own DAP interpreter.
- if: matrix.language == 'c' || matrix.language == 'cpp'
run: |
sudo apt-get update
sudo apt-get install -y gdb
- if: matrix.language == 'python'
uses: actions/setup-python@v5
with: { python-version: "3.12" }
@@ -108,6 +116,13 @@ jobs:
ln -s "$RUNNER_TEMP/kotlin-lsp/kotlin-lsp.sh" "$RUNNER_TEMP/bin/kotlin-lsp"
echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH"
# Xdebug for the debug smoke; Composer installs the fixture's Pest for the run smoke.
- if: matrix.language == 'php'
uses: shivammathur/setup-php@v2
with: { php-version: "8.5", coverage: xdebug, tools: composer }
- if: matrix.language == 'php'
run: npm install --global "intelephense@$INTELEPHENSE"
- run: cargo test --locked -p ide-host --test language_smoke --no-run
- run: cargo test --locked -p ide-host --test language_smoke -- --ignored --exact ${{ matrix.language }}
- if: matrix.language == 'java'
+119
Переглянути файл
@@ -0,0 +1,119 @@
name: Nightly
# Twice a day, builds the newest main commit that passed CI as a prerelease tagged with its own
# version, so remote development finds the server of that version. The `nightly` release holds
# the manifest the nightly app updates from; its tag marks the commit last built, and a run with
# nothing newer stops there. Only the latest builds are kept.
on:
schedule:
- cron: "17 2,14 * * *"
workflow_dispatch:
inputs:
force:
description: "Build even if nothing is newer than the last nightly"
type: boolean
default: false
permissions:
contents: read
concurrency:
group: nightly
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
jobs:
prepare:
permissions:
actions: read
contents: write
if: github.repository == 'l0ng-ai/openidea'
runs-on: ubuntu-latest
outputs:
sha: ${{ steps.commit.outputs.sha }}
tag: ${{ steps.release.outputs.tag }}
release_id: ${{ steps.release.outputs.id }}
steps:
- id: commit
env:
FORCE: ${{ inputs.force }}
run: |
sha="$(gh run list --workflow ci.yml --branch main --event push --status success --limit 1 \
--json headSha --jq '.[0].headSha // ""')"
if [ -z "$sha" ]; then
echo "::notice::no main commit has passed CI"
exit 0
fi
# No `nightly` tag yet (the first run): compare fails and the build goes on.
status="$(gh api "repos/$GH_REPO/compare/nightly...$sha" --jq .status 2>/dev/null || echo ahead)"
if [ "$status" != ahead ] && [ "$FORCE" != true ]; then
echo "::notice::$sha is not newer than the last nightly"
exit 0
fi
echo "sha=$sha" >> "$GITHUB_OUTPUT"
- uses: actions/checkout@v5
if: steps.commit.outputs.sha != ''
with: { ref: "${{ steps.commit.outputs.sha }}" }
- uses: actions/setup-node@v5
if: steps.commit.outputs.sha != ''
with: { node-version: 22 }
- uses: dtolnay/rust-toolchain@stable
if: steps.commit.outputs.sha != ''
- id: release
if: steps.commit.outputs.sha != ''
env:
SHA: ${{ steps.commit.outputs.sha }}
run: |
version="$(node scripts/nightly-prepare.ts "$(date -u +%Y%m%d%H%M)")"
tag="v$version"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git commit -am "chore: $tag"
git tag "$tag"
git push origin "$tag"
id="$(gh api -X POST "repos/$GH_REPO/releases" -f tag_name="$tag" -f name="OpenIDEA Nightly $version" \
-F draft=true -F prerelease=true -f body="Built from $SHA." --jq .id)"
echo "tag=$tag" >> "$GITHUB_OUTPUT"
echo "id=$id" >> "$GITHUB_OUTPUT"
binaries:
permissions:
contents: write
needs: prepare
if: needs.prepare.outputs.tag != ''
uses: ./.github/workflows/release-build.yml
with:
tag: ${{ needs.prepare.outputs.tag }}
release_id: ${{ needs.prepare.outputs.release_id }}
secrets: inherit
publish:
permissions:
contents: write
needs: [prepare, binaries]
runs-on: ubuntu-latest
env:
TAG: ${{ needs.prepare.outputs.tag }}
SHA: ${{ needs.prepare.outputs.sha }}
KEEP: 14
steps:
- name: Publish the build
run: gh release edit "$TAG" --tag "$TAG" --draft=false
- name: Point the updater at it
run: |
gh release download "$TAG" -p latest.json -D "$RUNNER_TEMP"
gh release view nightly >/dev/null 2>&1 ||
gh release create nightly --prerelease --latest=false --target "$SHA" --title "OpenIDEA Nightly" \
--notes "The update manifest of the nightly builds, and the commit last built."
gh release upload nightly "$RUNNER_TEMP/latest.json" --clobber
gh api -X PATCH "repos/$GH_REPO/git/refs/tags/nightly" -f sha="$SHA" -F force=true --silent
# Failed builds' drafts go too, once they are this old.
- name: Delete old builds
run: |
gh release list --limit 200 --json tagName,createdAt --jq \
'[.[] | select(.tagName | contains("-nightly."))] | sort_by(.createdAt) | reverse | .[env.KEEP | tonumber:][].tagName' |
while read -r tag; do
gh release delete "$tag" --cleanup-tag --yes
done
+2
Переглянути файл
@@ -22,6 +22,8 @@ env:
jobs:
merge:
# The schedule is for this repository's release; forks can still run it by hand.
if: github.event_name != 'schedule' || github.repository == 'l0ng-ai/openidea'
runs-on: ubuntu-latest
steps:
- name: Merge the community index
+262
Переглянути файл
@@ -0,0 +1,262 @@
name: Release build
# Builds a tag's desktop apps, remote servers and updater manifest into its draft release.
# Stable releases (release.yml) and nightly builds (nightly.yml) both run it.
on:
workflow_call:
inputs:
tag: { type: string, required: true }
release_id: { type: string, required: true }
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
TAG: ${{ inputs.tag }}
jobs:
plugin-keys:
permissions: {}
runs-on: ubuntu-latest
outputs:
keys: ${{ steps.keys.outputs.keys }}
steps:
# The official plugins' public key (from PLUGIN_SIGNING_KEY, the seed
# `openidea-plugin keygen` writes), built into the app: their packages
# must carry its signature whatever the marketplace index lists. Without
# the secret the app takes the index's word, as development builds do.
- id: keys
env:
SIGNING_KEY: ${{ secrets.PLUGIN_SIGNING_KEY }}
run: |
if [ -n "$SIGNING_KEY" ]; then
seed="$(printf %s "$SIGNING_KEY" | base64 -d | od -An -tx1 | tr -d ' \n')"
key="$(printf '302e020100300506032b657004220420%s' "$seed" | xxd -r -p |
openssl pkey -inform DER -pubout -outform DER | tail -c 32 | base64)"
echo "keys=$key" >> "$GITHUB_OUTPUT"
fi
build:
permissions:
contents: write
needs: plugin-keys
name: Build (${{ matrix.name }})
env:
OPENIDEA_OFFICIAL_PLUGIN_KEYS: ${{ needs.plugin-keys.outputs.keys }}
strategy:
fail-fast: false
matrix:
include:
- name: macOS arm64
os: macos-latest
target: aarch64-apple-darwin
- name: macOS x64
os: macos-latest
target: x86_64-apple-darwin
- name: Linux x64
os: ubuntu-22.04
target: x86_64-unknown-linux-gnu
- name: Linux arm64
os: ubuntu-22.04-arm
target: aarch64-unknown-linux-gnu
- name: Windows arm64
os: windows-11-arm
target: aarch64-pc-windows-msvc
- name: Windows x64
os: windows-latest
target: x86_64-pc-windows-msvc
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v5
with: { ref: "${{ env.TAG }}" }
- uses: pnpm/action-setup@v5
with: { version: 11 }
- uses: actions/setup-node@v5
with: { node-version: 22, cache: pnpm }
- uses: dtolnay/rust-toolchain@stable
with: { targets: "${{ matrix.target }}" }
- uses: Swatinem/rust-cache@v2
with: { key: "${{ matrix.target }}" }
- name: Linux deps
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libxdo-dev libssl-dev patchelf
- run: pnpm install --frozen-lockfile
# Signing is optional: only secrets that are set get exported, so forks and
# unconfigured repos produce unsigned builds instead of failing.
- name: Signing env
shell: bash
env:
APPLE_CERTIFICATE: ${{ runner.os == 'macOS' && secrets.APPLE_CERTIFICATE || '' }}
APPLE_CERTIFICATE_PASSWORD: ${{ runner.os == 'macOS' && secrets.APPLE_CERTIFICATE_PASSWORD || '' }}
APPLE_SIGNING_IDENTITY: ${{ runner.os == 'macOS' && secrets.APPLE_SIGNING_IDENTITY || '' }}
APPLE_ID: ${{ runner.os == 'macOS' && secrets.APPLE_ID || '' }}
APPLE_PASSWORD: ${{ runner.os == 'macOS' && secrets.APPLE_PASSWORD || '' }}
APPLE_TEAM_ID: ${{ runner.os == 'macOS' && secrets.APPLE_TEAM_ID || '' }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: |
for v in APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_SIGNING_IDENTITY APPLE_ID \
APPLE_PASSWORD APPLE_TEAM_ID TAURI_SIGNING_PRIVATE_KEY TAURI_SIGNING_PRIVATE_KEY_PASSWORD; do
[ -n "${!v}" ] && { echo "$v<<__EOF__"; echo "${!v}"; echo "__EOF__"; } >> "$GITHUB_ENV"
done
true
- uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
releaseId: ${{ inputs.release_id }}
tagName: ${{ env.TAG }}
args: --target ${{ matrix.target }}
# The parallel builds would race on one latest.json; `updater-manifest` writes it once.
uploadUpdaterJson: false
# tauri-action notarizes the .app but not the DMG around it; notarize and
# staple the DMG too, then replace the uploaded asset.
- name: Notarize DMG
if: runner.os == 'macOS' && env.APPLE_ID != ''
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
for dmg in target/${{ matrix.target }}/release/bundle/dmg/*.dmg; do
xcrun notarytool submit "$dmg" --apple-id "$APPLE_ID" --password "$APPLE_PASSWORD" \
--team-id "$APPLE_TEAM_ID" --wait
xcrun stapler staple "$dmg"
# Release assets have no spaces: GitHub stored the first upload with dots.
asset="$RUNNER_TEMP/$(basename "$dmg" | tr ' ' .)"
cp "$dmg" "$asset"
gh release upload "$TAG" "$asset" --clobber
done
# Portable Windows builds (#1857): on Windows Tauri embeds the
# frontend in the exe and WebView2 is a system component, so zipping
# the raw exe gives an unpack-and-run alternative to the installers.
- name: Portable zip
if: runner.os == 'Windows'
shell: pwsh
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
$target = "${{ matrix.target }}"
$arch = if ($target -like "x86_64*") { "x64" } else { "arm64" }
$version = $env:TAG -replace '^v', ''
$asset = "OpenIDEA_${version}_windows_${arch}_portable.zip"
$dir = "target/$target/release"
$files = @("$dir/openidea.exe")
if (Test-Path "$dir/WebView2Loader.dll") {
$files += "$dir/WebView2Loader.dll"
}
Compress-Archive -Path $files -DestinationPath $asset -Force
$hash = (Get-FileHash $asset -Algorithm SHA256).Hash.ToLower()
"$hash $asset" | Out-File -Encoding ascii "$asset.sha256"
gh release upload $env:TAG $asset "$asset.sha256" --clobber
# The remote development server, per host platform it can run on. The desktop
# app downloads the one for the host (ide-tools `server`) and installs it over SSH.
# Linux builds are static (musl), so they run whatever the host's glibc.
server:
permissions:
contents: write
name: Server (${{ matrix.os }}-${{ matrix.arch }})
strategy:
fail-fast: false
matrix:
include:
- { runner: ubuntu-22.04, target: x86_64-unknown-linux-musl, os: linux, arch: x86_64 }
- { runner: ubuntu-24.04-arm, target: aarch64-unknown-linux-musl, os: linux, arch: aarch64 }
- { runner: macos-latest, target: aarch64-apple-darwin, os: macos, arch: aarch64 }
- { runner: macos-latest, target: x86_64-apple-darwin, os: macos, arch: x86_64 }
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v5
with: { ref: "${{ env.TAG }}" }
- uses: dtolnay/rust-toolchain@stable
with: { targets: "${{ matrix.target }}" }
- uses: Swatinem/rust-cache@v2
with: { key: "server-${{ matrix.target }}" }
- name: musl toolchain
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y musl-tools
- name: Build
run: |
# musl-tools only ships `musl-gcc`; cc-rs would look for `<arch>-linux-musl-gcc`.
if [ "$RUNNER_OS" = Linux ]; then export CC_${TARGET//-/_}=musl-gcc; fi
cargo build --release --locked -p ide-host --bin openidea-server --target "$TARGET"
env:
TARGET: ${{ matrix.target }}
- name: Upload
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ASSET: openidea-server-${{ matrix.os }}-${{ matrix.arch }}.gz
run: |
gzip -9 -c target/${{ matrix.target }}/release/openidea-server > "$ASSET"
if command -v sha256sum >/dev/null; then sha256sum "$ASSET"; else shasum -a 256 "$ASSET"; fi > "$ASSET.sha256"
gh release upload "$TAG" "$ASSET" "$ASSET.sha256" --clobber
# The updater manifest (latest.json), built once from the signed bundles every build uploaded.
# Fails when a platform is missing, so a release never ships a manifest without it.
updater-manifest:
permissions:
contents: write
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v8
env:
RELEASE_ID: ${{ inputs.release_id }}
with:
script: |
const { owner, repo } = context.repo;
const release_id = Number(process.env.RELEASE_ID);
const tag = process.env.TAG;
const assets = await github.paginate(github.rest.repos.listReleaseAssets, { owner, repo, release_id });
const sigs = assets.filter((a) => a.name.endsWith(".sig"));
if (sigs.length === 0) {
core.notice("no signed bundles (TAURI_SIGNING_PRIVATE_KEY unset): skipping latest.json");
return;
}
// Bundle suffix → updater platform keys, as tauri-action names them.
// Nightly versions don't fit MSI or RPM, so those builds offer NSIS on Windows.
const nightly = tag.includes("-nightly.");
const windows = (arch, msi, nsis) => nightly
? [[nsis, [`windows-${arch}`, `windows-${arch}-nsis`]]]
: [[msi, [`windows-${arch}`, `windows-${arch}-msi`]], [nsis, [`windows-${arch}-nsis`]]];
const kinds = [
[/_aarch64\.AppImage$/, ["linux-aarch64", "linux-aarch64-appimage"]],
[/_arm64\.deb$/, ["linux-aarch64-deb"]],
...(nightly ? [] : [[/\.aarch64\.rpm$/, ["linux-aarch64-rpm"]]]),
[/_amd64\.AppImage$/, ["linux-x86_64", "linux-x86_64-appimage"]],
[/_amd64\.deb$/, ["linux-x86_64-deb"]],
...(nightly ? [] : [[/\.x86_64\.rpm$/, ["linux-x86_64-rpm"]]]),
[/_aarch64\.app\.tar\.gz$/, ["darwin-aarch64", "darwin-aarch64-app"]],
[/_x64\.app\.tar\.gz$/, ["darwin-x86_64", "darwin-x86_64-app"]],
...windows("aarch64", /_arm64_en-US\.msi$/, /_arm64-setup\.exe$/),
...windows("x86_64", /_x64_en-US\.msi$/, /_x64-setup\.exe$/),
];
const platforms = {};
for (const sig of sigs) {
const bundle = sig.name.slice(0, -".sig".length);
const kind = kinds.find(([re]) => re.test(bundle));
if (!kind) continue;
const { data } = await github.rest.repos.getReleaseAsset({
owner, repo, asset_id: sig.id, headers: { accept: "application/octet-stream" },
});
const signature = Buffer.from(data).toString("utf8").trim();
const url = `https://github.com/${owner}/${repo}/releases/download/${tag}/${encodeURIComponent(bundle)}`;
for (const key of kind[1]) platforms[key] = { signature, url };
}
const missing = kinds.flatMap(([, keys]) => keys).filter((k) => !platforms[k]);
if (missing.length) {
core.setFailed(`latest.json would miss ${missing.join(", ")}`);
return;
}
const manifest = { version: tag.replace(/^v/, ""), notes: "", pub_date: new Date().toISOString(), platforms };
const old = assets.find((a) => a.name === "latest.json");
if (old) await github.rest.repos.deleteReleaseAsset({ owner, repo, asset_id: old.id });
await github.rest.repos.uploadReleaseAsset({
owner, repo, release_id, name: "latest.json",
data: JSON.stringify(manifest, null, 2),
headers: { "content-type": "application/json" },
});
core.info(`latest.json: ${Object.keys(platforms).sort().join(", ")}`);
+24 -229
Переглянути файл
@@ -1,7 +1,7 @@
name: Release
on:
push:
tags: ["v*"]
tags: ["v*", "!v*-nightly.*"]
workflow_dispatch:
inputs:
tag:
@@ -9,7 +9,7 @@ on:
required: true
permissions:
contents: write
contents: read
concurrency:
group: release-${{ github.event.inputs.tag || github.ref_name }}
@@ -23,10 +23,12 @@ env:
jobs:
# Creates (or reuses) one draft release that every platform build uploads into.
create-release:
if: ${{ !contains(github.event.inputs.tag || github.ref_name, '-nightly.') }}
permissions:
contents: write
runs-on: ubuntu-latest
outputs:
release_id: ${{ steps.release.outputs.id }}
official_plugin_keys: ${{ steps.plugin-keys.outputs.keys }}
steps:
- uses: actions/checkout@v5
with: { ref: "${{ env.TAG }}" }
@@ -47,20 +49,6 @@ jobs:
echo "::error::plugin API $(api wit) ships without a snapshot ($snapshot is $(api "$snapshot")): copy wit/*.wit to $snapshot/"
exit 1
fi
# The official plugins' public key (from PLUGIN_SIGNING_KEY, the seed
# `openidea-plugin keygen` writes), built into the app: their packages
# must carry its signature whatever the marketplace index lists. Without
# the secret the app takes the index's word, as development builds do.
- id: plugin-keys
env:
SIGNING_KEY: ${{ secrets.PLUGIN_SIGNING_KEY }}
run: |
if [ -n "$SIGNING_KEY" ]; then
seed="$(printf %s "$SIGNING_KEY" | base64 -d | od -An -tx1 | tr -d ' \n')"
key="$(printf '302e020100300506032b657004220420%s' "$seed" | xxd -r -p |
openssl pkey -inform DER -pubout -outform DER | tail -c 32 | base64)"
echo "keys=$key" >> "$GITHUB_OUTPUT"
fi
- id: release
uses: actions/github-script@v8
with:
@@ -74,161 +62,26 @@ jobs:
core.setOutput("id", existing.id);
return;
}
// Nightly builds are prereleases: the notes span stable release to stable release.
const previous = releases.find((r) => !r.draft && !r.prerelease);
const { data: notes } = await github.rest.repos.generateReleaseNotes({
owner, repo, tag_name: tag, previous_tag_name: previous?.tag_name,
});
const { data } = await github.rest.repos.createRelease({
owner, repo, tag_name: tag, name: `OpenIDEA ${tag}`,
draft: true, prerelease: tag.includes("-"), generate_release_notes: true,
draft: true, prerelease: tag.includes("-"), body: notes.body,
});
core.setOutput("id", data.id);
build:
binaries:
permissions:
contents: write
needs: create-release
name: Build (${{ matrix.name }})
env:
OPENIDEA_OFFICIAL_PLUGIN_KEYS: ${{ needs.create-release.outputs.official_plugin_keys }}
strategy:
fail-fast: false
matrix:
include:
- name: macOS arm64
os: macos-latest
target: aarch64-apple-darwin
- name: macOS x64
os: macos-latest
target: x86_64-apple-darwin
- name: Linux x64
os: ubuntu-22.04
target: x86_64-unknown-linux-gnu
- name: Linux arm64
os: ubuntu-22.04-arm
target: aarch64-unknown-linux-gnu
- name: Windows arm64
os: windows-11-arm
target: aarch64-pc-windows-msvc
- name: Windows x64
os: windows-latest
target: x86_64-pc-windows-msvc
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v5
with: { ref: "${{ env.TAG }}" }
- uses: pnpm/action-setup@v5
with: { version: 11 }
- uses: actions/setup-node@v5
with: { node-version: 22, cache: pnpm }
- uses: dtolnay/rust-toolchain@stable
with: { targets: "${{ matrix.target }}" }
- uses: Swatinem/rust-cache@v2
with: { key: "${{ matrix.target }}" }
- name: Linux deps
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libxdo-dev libssl-dev patchelf
- run: pnpm install --frozen-lockfile
# Signing is optional: only secrets that are set get exported, so forks and
# unconfigured repos produce unsigned builds instead of failing.
- name: Signing env
shell: bash
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: |
for v in APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_SIGNING_IDENTITY APPLE_ID \
APPLE_PASSWORD APPLE_TEAM_ID TAURI_SIGNING_PRIVATE_KEY TAURI_SIGNING_PRIVATE_KEY_PASSWORD; do
[ -n "${!v}" ] && { echo "$v<<__EOF__"; echo "${!v}"; echo "__EOF__"; } >> "$GITHUB_ENV"
done
true
- uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
releaseId: ${{ needs.create-release.outputs.release_id }}
tagName: ${{ env.TAG }}
args: --target ${{ matrix.target }}
# The parallel builds would race on one latest.json; `updater-manifest` writes it once.
uploadUpdaterJson: false
# tauri-action notarizes the .app but not the DMG around it; notarize and
# staple the DMG too, then replace the uploaded asset.
- name: Notarize DMG
if: runner.os == 'macOS' && env.APPLE_ID != ''
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
for dmg in target/${{ matrix.target }}/release/bundle/dmg/*.dmg; do
xcrun notarytool submit "$dmg" --apple-id "$APPLE_ID" --password "$APPLE_PASSWORD" \
--team-id "$APPLE_TEAM_ID" --wait
xcrun stapler staple "$dmg"
gh release upload "$TAG" "$dmg" --clobber
done
# Portable Windows builds (#1857): on Windows Tauri embeds the
# frontend in the exe and WebView2 is a system component, so zipping
# the raw exe gives an unpack-and-run alternative to the installers.
- name: Portable zip
if: runner.os == 'Windows'
shell: pwsh
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
$target = "${{ matrix.target }}"
$arch = if ($target -like "x86_64*") { "x64" } else { "arm64" }
$version = $env:TAG -replace '^v', ''
$asset = "OpenIDEA_${version}_windows_${arch}_portable.zip"
$dir = "target/$target/release"
$files = @("$dir/openidea.exe")
if (Test-Path "$dir/WebView2Loader.dll") {
$files += "$dir/WebView2Loader.dll"
}
Compress-Archive -Path $files -DestinationPath $asset -Force
$hash = (Get-FileHash $asset -Algorithm SHA256).Hash.ToLower()
"$hash $asset" | Out-File -Encoding ascii "$asset.sha256"
gh release upload $env:TAG $asset "$asset.sha256" --clobber
# The remote development server, per host platform it can run on. The desktop
# app downloads the one for the host (ide-tools `server`) and installs it over SSH.
# Linux builds are static (musl), so they run whatever the host's glibc.
server:
needs: create-release
name: Server (${{ matrix.os }}-${{ matrix.arch }})
strategy:
fail-fast: false
matrix:
include:
- { runner: ubuntu-22.04, target: x86_64-unknown-linux-musl, os: linux, arch: x86_64 }
- { runner: ubuntu-24.04-arm, target: aarch64-unknown-linux-musl, os: linux, arch: aarch64 }
- { runner: macos-latest, target: aarch64-apple-darwin, os: macos, arch: aarch64 }
- { runner: macos-latest, target: x86_64-apple-darwin, os: macos, arch: x86_64 }
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v5
with: { ref: "${{ env.TAG }}" }
- uses: dtolnay/rust-toolchain@stable
with: { targets: "${{ matrix.target }}" }
- uses: Swatinem/rust-cache@v2
with: { key: "server-${{ matrix.target }}" }
- name: musl toolchain
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y musl-tools
- name: Build
run: |
# musl-tools only ships `musl-gcc`; cc-rs would look for `<arch>-linux-musl-gcc`.
if [ "$RUNNER_OS" = Linux ]; then export CC_${TARGET//-/_}=musl-gcc; fi
cargo build --release --locked -p ide-host --bin openidea-server --target "$TARGET"
env:
TARGET: ${{ matrix.target }}
- name: Upload
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ASSET: openidea-server-${{ matrix.os }}-${{ matrix.arch }}.gz
run: |
gzip -9 -c target/${{ matrix.target }}/release/openidea-server > "$ASSET"
if command -v sha256sum >/dev/null; then sha256sum "$ASSET"; else shasum -a 256 "$ASSET"; fi > "$ASSET.sha256"
gh release upload "$TAG" "$ASSET" "$ASSET.sha256" --clobber
uses: ./.github/workflows/release-build.yml
with:
tag: ${{ github.event.inputs.tag || github.ref_name }}
release_id: ${{ needs.create-release.outputs.release_id }}
secrets: inherit
# The official plugins, signed, and the marketplace index released apps read
# (releases/latest/download/plugins-index.json). They are not in the
@@ -240,6 +93,8 @@ jobs:
# openidea-plugin tool for Linux x64, which that workflow and the registry's
# checks run.
plugins:
permissions:
contents: write
needs: create-release
name: Official plugins
runs-on: ubuntu-22.04
@@ -303,70 +158,10 @@ jobs:
gh release upload "$TAG" "$out"/*.oiplugin "$out/plugins-index.json" \
"$out/openidea-plugin-linux-x86_64.gz" --clobber
# The updater manifest (latest.json), built once from the signed bundles every build uploaded.
# Fails when a platform is missing, so a release never ships a manifest without it.
updater-manifest:
needs: [create-release, build]
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v8
env:
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
with:
script: |
const { owner, repo } = context.repo;
const release_id = Number(process.env.RELEASE_ID);
const tag = process.env.TAG;
const assets = await github.paginate(github.rest.repos.listReleaseAssets, { owner, repo, release_id });
const sigs = assets.filter((a) => a.name.endsWith(".sig"));
if (sigs.length === 0) {
core.notice("no signed bundles (TAURI_SIGNING_PRIVATE_KEY unset): skipping latest.json");
return;
}
// Bundle suffix → updater platform keys, as tauri-action names them.
const kinds = [
[/_aarch64\.AppImage$/, ["linux-aarch64", "linux-aarch64-appimage"]],
[/_arm64\.deb$/, ["linux-aarch64-deb"]],
[/\.aarch64\.rpm$/, ["linux-aarch64-rpm"]],
[/_amd64\.AppImage$/, ["linux-x86_64", "linux-x86_64-appimage"]],
[/_amd64\.deb$/, ["linux-x86_64-deb"]],
[/\.x86_64\.rpm$/, ["linux-x86_64-rpm"]],
[/_aarch64\.app\.tar\.gz$/, ["darwin-aarch64", "darwin-aarch64-app"]],
[/_x64\.app\.tar\.gz$/, ["darwin-x86_64", "darwin-x86_64-app"]],
[/_arm64_en-US\.msi$/, ["windows-aarch64", "windows-aarch64-msi"]],
[/_arm64-setup\.exe$/, ["windows-aarch64-nsis"]],
[/_x64_en-US\.msi$/, ["windows-x86_64", "windows-x86_64-msi"]],
[/_x64-setup\.exe$/, ["windows-x86_64-nsis"]],
];
const platforms = {};
for (const sig of sigs) {
const bundle = sig.name.slice(0, -".sig".length);
const kind = kinds.find(([re]) => re.test(bundle));
if (!kind) continue;
const { data } = await github.rest.repos.getReleaseAsset({
owner, repo, asset_id: sig.id, headers: { accept: "application/octet-stream" },
});
const signature = Buffer.from(data).toString("utf8").trim();
const url = `https://github.com/${owner}/${repo}/releases/download/${tag}/${encodeURIComponent(bundle)}`;
for (const key of kind[1]) platforms[key] = { signature, url };
}
const missing = kinds.flatMap(([, keys]) => keys).filter((k) => !platforms[k]);
if (missing.length) {
core.setFailed(`latest.json would miss ${missing.join(", ")}`);
return;
}
const manifest = { version: tag.replace(/^v/, ""), notes: "", pub_date: new Date().toISOString(), platforms };
const old = assets.find((a) => a.name === "latest.json");
if (old) await github.rest.repos.deleteReleaseAsset({ owner, repo, asset_id: old.id });
await github.rest.repos.uploadReleaseAsset({
owner, repo, release_id, name: "latest.json",
data: JSON.stringify(manifest, null, 2),
headers: { "content-type": "application/json" },
});
core.info(`latest.json: ${Object.keys(platforms).sort().join(", ")}`);
publish:
needs: [create-release, build, server, plugins, updater-manifest]
permissions:
contents: write
needs: [create-release, binaries, plugins]
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v8
+1 -1
Переглянути файл
@@ -12,7 +12,7 @@
"src/editor/core/cm/angular/parser*.ts",
"src/editor/core/cm/jsp/parser*.ts",
"src/editor/core/cm/astro/parser*.ts",
"docs/plugins/themes.md",
"plugins/themes.md",
"pnpm-lock.yaml",
"**/*.toml",
"fixtures",
+3 -1
Переглянути файл
@@ -21,11 +21,13 @@ Visual style: dark, low-contrast greys, one cool accent
- `crates/ide-host` — the `openidea-server` binary for remote development and the
`openidea-plugin` CLI (plugin starter templates live in its `templates.rs`).
- `wit/` — plugin interface definitions; `plugins/` — sample and bundled plugins.
Plugin docs: `plugins/README.md`, `docs/plugins/themes.md`.
Plugin docs: `plugins/README.md`, `plugins/themes.md`.
- `java/` — JDT LS extension bundles (`java-usages`, `kotlin-bridge`) and the patched
Lombok agent, embedded into the app by `ide-sdk` / `ide-tools`.
- `e2e/` — Playwright UI tests (`pnpm e2e`). `fixtures/` — sample projects per language
for manual and automated testing.
- `docs/` — the Mintlify documentation site (`docs.json` navigation, `en/` and `zh/` pages);
preview with `npx mint dev` in `docs/`.
## Rules
Створено
+218 -62
Переглянути файл
@@ -60,6 +60,20 @@ dependencies = [
"zeroize",
]
[[package]]
name = "ahash"
version = "0.8.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
dependencies = [
"cfg-if",
"getrandom 0.3.4",
"once_cell",
"serde",
"version_check",
"zerocopy",
]
[[package]]
name = "aho-corasick"
version = "1.1.5"
@@ -616,6 +630,12 @@ dependencies = [
"cipher",
]
[[package]]
name = "borrow-or-share"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c"
[[package]]
name = "borsh"
version = "1.8.1"
@@ -690,6 +710,12 @@ dependencies = [
"allocator-api2",
]
[[package]]
name = "bytecount"
version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e"
[[package]]
name = "bytemuck"
version = "1.25.2"
@@ -1496,7 +1522,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a"
dependencies = [
"quote",
"syn 3.0.9",
"syn 2.0.119",
]
[[package]]
@@ -2030,6 +2056,15 @@ dependencies = [
"zeroize",
]
[[package]]
name = "email_address"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
dependencies = [
"serde",
]
[[package]]
name = "embed-resource"
version = "3.0.12"
@@ -2187,6 +2222,17 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "fancy-regex"
version = "0.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f"
dependencies = [
"bit-set 0.8.0",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "faster-hex"
version = "0.10.1"
@@ -2287,6 +2333,17 @@ dependencies = [
"bitflags 1.3.2",
]
[[package]]
name = "fluent-uri"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1918b65d96df47d3591bed19c5cca17e3fa5d0707318e4b5ef2eae01764df7e5"
dependencies = [
"borrow-or-share",
"ref-cast",
"serde",
]
[[package]]
name = "flume"
version = "0.12.0"
@@ -2346,6 +2403,16 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "fraction"
version = "0.15.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e076045bb43dac435333ed5f04caf35c7463631d0dae2deb2638d94dd0a5b872"
dependencies = [
"lazy_static",
"num",
]
[[package]]
name = "fs-set-times"
version = "0.20.3"
@@ -4113,7 +4180,7 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "ide-agent"
version = "0.1.39"
version = "0.1.40"
dependencies = [
"dunce",
"encoding_rs",
@@ -4137,7 +4204,7 @@ dependencies = [
[[package]]
name = "ide-analysis"
version = "0.1.39"
version = "0.1.40"
dependencies = [
"ast-grep-core",
"ide-core",
@@ -4151,7 +4218,7 @@ dependencies = [
[[package]]
name = "ide-android"
version = "0.1.39"
version = "0.1.40"
dependencies = [
"ide-core",
"serde",
@@ -4162,11 +4229,13 @@ dependencies = [
[[package]]
name = "ide-build"
version = "0.1.39"
version = "0.1.40"
dependencies = [
"blake3",
"chardetng",
"dirs 6.0.0",
"dunce",
"encoding_rs",
"ide-core",
"serde",
"serde_json",
@@ -4178,7 +4247,7 @@ dependencies = [
[[package]]
name = "ide-cli"
version = "0.1.39"
version = "0.1.40"
dependencies = [
"dirs 6.0.0",
"serde",
@@ -4192,8 +4261,9 @@ dependencies = [
[[package]]
name = "ide-clipboard"
version = "0.1.39"
version = "0.1.40"
dependencies = [
"gtk",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
@@ -4201,7 +4271,7 @@ dependencies = [
[[package]]
name = "ide-core"
version = "0.1.39"
version = "0.1.40"
dependencies = [
"dunce",
"encoding_rs",
@@ -4218,7 +4288,7 @@ dependencies = [
[[package]]
name = "ide-coverage"
version = "0.1.39"
version = "0.1.40"
dependencies = [
"ide-core",
"ide-run",
@@ -4232,7 +4302,7 @@ dependencies = [
[[package]]
name = "ide-dap"
version = "0.1.39"
version = "0.1.40"
dependencies = [
"futures",
"ide-core",
@@ -4253,10 +4323,12 @@ dependencies = [
[[package]]
name = "ide-db"
version = "0.1.39"
version = "0.1.40"
dependencies = [
"bb8",
"blake3",
"dirs 6.0.0",
"dunce",
"futures",
"ide-core",
"ide-http",
@@ -4274,13 +4346,14 @@ dependencies = [
"tokio",
"tokio-util",
"toml 0.9.12+spec-1.1.0",
"toml_edit 0.25.17+spec-1.1.0",
"tracing",
"uuid",
]
[[package]]
name = "ide-docker"
version = "0.1.39"
version = "0.1.40"
dependencies = [
"ide-core",
"serde",
@@ -4293,7 +4366,7 @@ dependencies = [
[[package]]
name = "ide-extension"
version = "0.1.39"
version = "0.1.40"
dependencies = [
"base64 0.22.1",
"blake3",
@@ -4313,7 +4386,7 @@ dependencies = [
[[package]]
name = "ide-fonts"
version = "0.1.39"
version = "0.1.40"
dependencies = [
"objc2-core-foundation",
"objc2-core-text",
@@ -4325,7 +4398,7 @@ dependencies = [
[[package]]
name = "ide-github"
version = "0.1.39"
version = "0.1.40"
dependencies = [
"ide-core",
"ide-vcs",
@@ -4338,7 +4411,7 @@ dependencies = [
[[package]]
name = "ide-host"
version = "0.1.39"
version = "0.1.40"
dependencies = [
"base64 0.22.1",
"blake3",
@@ -4380,6 +4453,7 @@ dependencies = [
"ide-vfs",
"ide-workspace",
"ignore",
"jsonschema",
"parking_lot",
"quick-xml 0.42.0",
"regex-lite",
@@ -4403,7 +4477,7 @@ dependencies = [
[[package]]
name = "ide-http"
version = "0.1.39"
version = "0.1.40"
dependencies = [
"ide-core",
"parking_lot",
@@ -4421,7 +4495,7 @@ dependencies = [
[[package]]
name = "ide-index"
version = "0.1.39"
version = "0.1.40"
dependencies = [
"blake3",
"criterion",
@@ -4443,7 +4517,7 @@ dependencies = [
[[package]]
name = "ide-jsonrpc"
version = "0.1.39"
version = "0.1.40"
dependencies = [
"encoding_rs",
"futures",
@@ -4460,7 +4534,7 @@ dependencies = [
[[package]]
name = "ide-language"
version = "0.1.39"
version = "0.1.40"
dependencies = [
"blake3",
"criterion",
@@ -4516,7 +4590,7 @@ dependencies = [
[[package]]
name = "ide-lsp"
version = "0.1.39"
version = "0.1.40"
dependencies = [
"dunce",
"futures",
@@ -4542,7 +4616,7 @@ dependencies = [
[[package]]
name = "ide-mcp"
version = "0.1.39"
version = "0.1.40"
dependencies = [
"bytes",
"dunce",
@@ -4561,7 +4635,7 @@ dependencies = [
[[package]]
name = "ide-notebook"
version = "0.1.39"
version = "0.1.40"
dependencies = [
"futures",
"ide-core",
@@ -4584,9 +4658,10 @@ dependencies = [
[[package]]
name = "ide-plugin"
version = "0.1.39"
version = "0.1.40"
dependencies = [
"blake3",
"dunce",
"ide-core",
"ide-extension",
"parking_lot",
@@ -4598,6 +4673,7 @@ dependencies = [
"thiserror 2.0.21",
"tokio",
"tracing",
"url",
"wasmtime",
"wasmtime-wasi",
"wat",
@@ -4605,7 +4681,7 @@ dependencies = [
[[package]]
name = "ide-profiler"
version = "0.1.39"
version = "0.1.40"
dependencies = [
"ide-core",
"ide-run",
@@ -4618,8 +4694,9 @@ dependencies = [
[[package]]
name = "ide-remote"
version = "0.1.39"
version = "0.1.40"
dependencies = [
"dunce",
"globset",
"ide-core",
"ide-run",
@@ -4636,7 +4713,7 @@ dependencies = [
[[package]]
name = "ide-run"
version = "0.1.39"
version = "0.1.40"
dependencies = [
"dunce",
"encoding_rs",
@@ -4660,7 +4737,7 @@ dependencies = [
[[package]]
name = "ide-sdk"
version = "0.1.39"
version = "0.1.40"
dependencies = [
"dunce",
"ide-core",
@@ -4668,6 +4745,7 @@ dependencies = [
"ide-run",
"ide-vfs",
"ignore",
"quick-xml 0.42.0",
"serde",
"serde_json",
"specta",
@@ -4679,7 +4757,7 @@ dependencies = [
[[package]]
name = "ide-search"
version = "0.1.39"
version = "0.1.40"
dependencies = [
"criterion",
"globset",
@@ -4699,7 +4777,7 @@ dependencies = [
[[package]]
name = "ide-spell"
version = "0.1.39"
version = "0.1.40"
dependencies = [
"ide-core",
"parking_lot",
@@ -4712,7 +4790,7 @@ dependencies = [
[[package]]
name = "ide-ssh"
version = "0.1.39"
version = "0.1.40"
dependencies = [
"dirs 6.0.0",
"flate2",
@@ -4733,7 +4811,7 @@ dependencies = [
[[package]]
name = "ide-syntax"
version = "0.1.39"
version = "0.1.40"
dependencies = [
"ide-core",
"ide-language",
@@ -4748,7 +4826,7 @@ dependencies = [
[[package]]
name = "ide-terminal"
version = "0.1.39"
version = "0.1.40"
dependencies = [
"ide-core",
"libc",
@@ -4761,7 +4839,7 @@ dependencies = [
[[package]]
name = "ide-text"
version = "0.1.39"
version = "0.1.40"
dependencies = [
"criterion",
"ide-core",
@@ -4774,7 +4852,7 @@ dependencies = [
[[package]]
name = "ide-tools"
version = "0.1.39"
version = "0.1.40"
dependencies = [
"flate2",
"hex",
@@ -4795,7 +4873,7 @@ dependencies = [
[[package]]
name = "ide-vcs"
version = "0.1.39"
version = "0.1.40"
dependencies = [
"blake3",
"dunce",
@@ -4816,7 +4894,7 @@ dependencies = [
[[package]]
name = "ide-vfs"
version = "0.1.39"
version = "0.1.40"
dependencies = [
"chardetng",
"dunce",
@@ -4840,7 +4918,7 @@ dependencies = [
[[package]]
name = "ide-web"
version = "0.1.39"
version = "0.1.40"
dependencies = [
"ide-core",
"open",
@@ -4856,7 +4934,7 @@ dependencies = [
[[package]]
name = "ide-workspace"
version = "0.1.39"
version = "0.1.40"
dependencies = [
"blake3",
"dunce",
@@ -5287,6 +5365,32 @@ dependencies = [
"serde_json",
]
[[package]]
name = "jsonschema"
version = "0.33.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d46662859bc5f60a145b75f4632fbadc84e829e45df6c5de74cfc8e05acb96b5"
dependencies = [
"ahash",
"base64 0.22.1",
"bytecount",
"email_address",
"fancy-regex",
"fraction",
"idna",
"itoa",
"num-cmp",
"num-traits",
"once_cell",
"percent-encoding",
"referencing",
"regex",
"regex-syntax",
"serde",
"serde_json",
"uuid-simd",
]
[[package]]
name = "jupyter-protocol"
version = "2.0.3"
@@ -5477,9 +5581,9 @@ dependencies = [
[[package]]
name = "libsqlite3-sys"
version = "0.37.0"
version = "0.39.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1"
checksum = "14a5f01b87b92705d07b433c06f96cbc764637d81a0bffb5734ce17cf0f9a2b3"
dependencies = [
"cc",
"pkg-config",
@@ -5520,7 +5624,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53353550a17c04ac46c585feb189c2db82154fc84b79c7a66c96c2c644f66071"
dependencies = [
"bitflags 1.3.2",
"fluent-uri",
"fluent-uri 0.1.4",
"serde",
"serde_json",
"serde_repr",
@@ -5862,6 +5966,12 @@ dependencies = [
"rand_core 0.10.1",
]
[[package]]
name = "num-cmp"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa"
[[package]]
name = "num-complex"
version = "0.4.6"
@@ -6253,6 +6363,15 @@ dependencies = [
"objc2-foundation 0.2.2",
]
[[package]]
name = "objc2-system-configuration"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396"
dependencies = [
"objc2-core-foundation",
]
[[package]]
name = "objc2-ui-kit"
version = "0.2.2"
@@ -6402,7 +6521,7 @@ dependencies = [
[[package]]
name = "openidea"
version = "0.1.39"
version = "0.1.40"
dependencies = [
"base64 0.22.1",
"blake3",
@@ -6539,6 +6658,12 @@ dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "outref"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e"
[[package]]
name = "p256"
version = "0.14.0"
@@ -7306,6 +7431,20 @@ dependencies = [
"syn 3.0.9",
]
[[package]]
name = "referencing"
version = "0.33.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e9c261f7ce75418b3beadfb3f0eb1299fe8eb9640deba45ffa2cb783098697d"
dependencies = [
"ahash",
"fluent-uri 0.3.2",
"once_cell",
"parking_lot",
"percent-encoding",
"serde_json",
]
[[package]]
name = "regalloc2"
version = "0.15.2"
@@ -8499,8 +8638,7 @@ checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620"
[[package]]
name = "sqlx"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "378620ccc25c62c89d8be1c819e76a88d59bdcc3304733330788948e619bfd71"
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
dependencies = [
"sqlx-core",
"sqlx-macros",
@@ -8512,8 +8650,7 @@ dependencies = [
[[package]]
name = "sqlx-core"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb"
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
dependencies = [
"base64 0.22.1",
"bytes",
@@ -8535,7 +8672,7 @@ dependencies = [
"rustls",
"serde",
"serde_json",
"sha2 0.10.9",
"sha2 0.11.1",
"smallvec",
"thiserror 2.0.21",
"tokio",
@@ -8548,8 +8685,7 @@ dependencies = [
[[package]]
name = "sqlx-macros"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd2b84f2bc39a5705ef27ec785a11c934a41bbd4a24941e257927cddc26b60bf"
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
dependencies = [
"proc-macro2",
"quote",
@@ -8561,8 +8697,7 @@ dependencies = [
[[package]]
name = "sqlx-macros-core"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb8d96de5fdc85a5c4ec813432b523ec637e80ba98f046555f75f7908ddac7c3"
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
dependencies = [
"cfg-if",
"dotenvy",
@@ -8573,7 +8708,7 @@ dependencies = [
"quote",
"serde",
"serde_json",
"sha2 0.10.9",
"sha2 0.11.1",
"sqlx-core",
"sqlx-mysql",
"sqlx-postgres",
@@ -8586,8 +8721,7 @@ dependencies = [
[[package]]
name = "sqlx-mysql"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90b8020fe17c5f2c245bfa2505d7ef59c5604839527c740266ad2214acebea27"
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
dependencies = [
"bitflags 2.13.2",
"byteorder",
@@ -8612,8 +8746,7 @@ dependencies = [
[[package]]
name = "sqlx-postgres"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e"
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
dependencies = [
"atoi",
"base64 0.22.1",
@@ -8647,8 +8780,7 @@ dependencies = [
[[package]]
name = "sqlx-sqlite"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c"
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
dependencies = [
"atoi",
"flume",
@@ -9413,7 +9545,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.3",
"getrandom 0.3.4",
"once_cell",
"rustix",
"windows-sys 0.61.2",
@@ -10496,6 +10628,17 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "uuid-simd"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8"
dependencies = [
"outref",
"uuid",
"vsimd",
]
[[package]]
name = "valuable"
version = "0.1.1"
@@ -10520,6 +10663,12 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "vsimd"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64"
[[package]]
name = "vswhom"
version = "0.1.0"
@@ -11162,6 +11311,12 @@ name = "whoami"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c"
dependencies = [
"libc",
"libredox",
"objc2-system-configuration",
"web-sys",
]
[[package]]
name = "win_uds"
@@ -11997,14 +12152,15 @@ dependencies = [
[[package]]
name = "zip"
version = "9.1.3"
version = "9.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1156b39d142a34b999c2310a50455e0cb3760bf7efad0122f796d813bee26ccb"
checksum = "00fb4744e923442ecadfd3f85f51a5d24e03bf43d53813b81ebb4c91d02baa62"
dependencies = [
"crc32fast",
"flate2",
"getrandom 0.4.3",
"indexmap 2.14.2",
"libc",
"memchr",
"nix 0.31.3",
"typed-path",
+7 -4
Переглянути файл
@@ -5,7 +5,7 @@ members = ["crates/*", "src-tauri"]
exclude = ["sdk", "plugins"]
[workspace.package]
version = "0.1.39"
version = "0.1.40"
edition = "2024"
rust-version = "1.96"
license = "Apache-2.0"
@@ -82,7 +82,7 @@ trash = "5"
blake3 = "1"
dunce = "1"
dirs = "6"
zip = { version = "9", default-features = false, features = ["deflate"] }
zip = { version = "9.2.3", default-features = false, features = ["deflate"] }
# search, index, language
grep-searcher = "0.1"
@@ -103,7 +103,8 @@ portable-pty = "0.9"
reqwest = { version = "0.13", default-features = false, features = ["rustls-no-provider", "http2", "charset", "socks", "gzip", "deflate", "brotli"] }
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] }
rustls-platform-verifier = "0.7"
sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio", "tls-rustls-ring-webpki", "sqlite", "postgres", "mysql"] }
# Until SQLx releases its libsqlite3-sys 0.39 update (RUSTSEC-2026-0459).
sqlx = { version = "0.9", git = "https://github.com/transact-rs/sqlx", rev = "10462fa4414c6c7f1ed1d39bb31678e3b726878c", default-features = false, features = ["runtime-tokio", "tls-rustls-ring-webpki", "sqlite", "postgres", "mysql"] }
tiberius = { version = "0.13", default-features = false, features = ["tds73", "rustls", "sql-browser-tokio"] }
oracle = { version = "0.6", default-features = false }
bb8 = "0.9"
@@ -131,7 +132,9 @@ debug = false
[profile.release]
codegen-units = 1
lto = true
# Thin LTO: fat LTO's whole-program link outgrew the 16 GB release runners
# (Linux arm64 was killed mid-link from v0.1.39 on).
lto = "thin"
opt-level = 3
# Unwind, so a panic fails one task or command instead of quitting the IDE.
panic = "unwind"
+6 -10
Переглянути файл
@@ -12,6 +12,8 @@
<p align="center">
<a href="https://github.com/l0ng-ai/openidea/releases/latest"><b>Download</b></a>
&nbsp;·&nbsp;
<a href="https://openidea.sh">Docs</a>
&nbsp;·&nbsp;
<a href="#benchmark">Benchmark</a>
&nbsp;·&nbsp;
<a href="#plugins">Plugins</a>
@@ -25,9 +27,7 @@ OpenIDEA brings the IntelliJ IDEA way of working — its keymap, its tool window
its search-everything habits — to a native app with a Rust core.
Editor, code insight, debugger, Git, terminal, database and HTTP clients,
for 60+ languages. Builds for macOS, Windows and Linux. On Windows, if you'd
rather skip the installer, each release also ships a portable zip — unpack it
and run `openidea.exe`.
for 60+ languages. Builds for macOS, Windows and Linux.
## Benchmark
@@ -56,7 +56,7 @@ themes, file icons and languages. They run as sandboxed WebAssembly components;
themes, icon themes and languages need no code at all.
Install them from Settings › Plugins, or start your own with
`openidea-plugin new` — see [Writing OpenIDEA plugins](plugins/README.md) and
`openidea-plugin new` — see [Writing OpenIDEA plugins](https://openidea.sh/en/plugins) and
the [official plugins](plugins/official). The plugin API is still a preview.
## Community
@@ -64,13 +64,9 @@ the [official plugins](plugins/official). The plugin API is still a preview.
- [Discord](https://discord.gg/2Zy4yn7gP) — questions, feedback and release news.
- WeChat group — scan to join:
<img src="docs/assets/wechat-group.png" alt="WeChat group QR code" width="200">
<img src=".github/assets/wechat-group.png" alt="WeChat group QR code" width="200">
### Contributors
<a href="https://github.com/l0ng-ai/openidea/graphs/contributors">
<img src="https://contrib.rocks/image?repo=l0ng-ai/openidea&max=100" alt="OpenIDEA contributors">
</a>
- Contributing: see [CONTRIBUTING.md](CONTRIBUTING.md) and the [contributor docs](https://openidea.sh/en/contribute/setup).
## License
+6 -2
Переглянути файл
@@ -262,7 +262,7 @@ END OF TERMS AND CONDITIONS
The Solarized palettes in `src/styles/tokens.css` are adapted from
[Ethan Schoonover’s Solarized](https://github.com/altercation/solarized/tree/62f656a02f93c5190a8753159e34b385588d5ff3).
See [theme sources and mapping](docs/themes.md). MIT license:
MIT license:
```text
Copyright (c) 2011 Ethan Schoonover
@@ -290,7 +290,11 @@ THE SOFTWARE.
The One palettes and semantic syntax mapping in `src/styles/tokens.css` are
adapted from GitHub’s Atom One syntax and UI themes.
Source revisions are listed in [theme sources and mapping](docs/themes.md).
Source revisions:
[one-light-syntax d845790](https://github.com/atom/one-light-syntax/tree/d84579027410c576086dfca14d934c4bd74b0438),
[one-dark-syntax 9c96f44](https://github.com/atom/one-dark-syntax/tree/9c96f4454362267ac45322063e193ccf9d2debb1),
[one-light-ui a27bce3](https://github.com/atom/one-light-ui/tree/a27bce35088ecd323e44f5c2510bcf2d4e9efcf8),
[one-dark-ui 18c2143](https://github.com/atom/one-dark-ui/tree/18c2143d41e17b337fff6dc7024562c4d999be17).
Both syntax themes use the following MIT notice:
```text
+145 -37
Переглянути файл
@@ -100,7 +100,7 @@ struct Shared {
providers: RwLock<Vec<provider::ProviderSpec>>,
/// Last probe of each provider.
probes: Mutex<HashMap<String, AgentProbe>>,
/// One pre-session command lookup per provider, including failed attempts.
/// One pre-session lookup of commands and settings per provider, including failed attempts.
command_fetches: Mutex<HashMap<String, tokio::sync::watch::Receiver<bool>>>,
/// [`IDLE_RELEASE`], in milliseconds (shorter in tests).
idle_release_ms: AtomicU64,
@@ -153,7 +153,7 @@ impl Shared {
cache.get(provider).and_then(|k| k.commands.get(&self.root_key())).cloned().unwrap_or_default()
}
async fn fetch_commands(self: &Arc<Self>, spec: provider::ProviderSpec) {
async fn fetch_settings(self: &Arc<Self>, spec: provider::ProviderSpec) {
let mut ready = {
let mut fetches = self.command_fetches.lock();
fetches
@@ -163,14 +163,16 @@ impl Shared {
let shared = self.clone();
// Keep the lookup alive if a composer unmounts while it is waiting.
self.runtime.spawn(async move {
if let Ok(commands) =
native::claude::commands(spec.clone(), shared.paths.project_root.clone()).await
if let Ok((commands, options)) =
native::claude::discover(spec.clone(), shared.paths.project_root.clone()).await
{
let commands = config::commands_of(&json!({ "availableCommands": commands }), &spec.id);
let options = config::options_of(&json!({ "configOptions": options }));
let _ = tokio::task::spawn_blocking(move || {
if shared.known_commands(&spec.id).is_empty() {
shared.remember(&spec.id, None, Some(&commands), None);
}
// Notifies subscribers itself when the discovery
// changed the cached config; unchanged results
// stay silent.
shared.remember_discovered(&spec.id, &options, &commands);
})
.await;
}
@@ -187,6 +189,10 @@ impl Shared {
}
}
fn has_config(&self, provider: &str) -> bool {
self.known.lock().get(provider).is_some_and(|k| !k.config.is_empty())
}
fn root_key(&self) -> String {
self.paths.project_root.to_string_lossy().into_owned()
}
@@ -199,41 +205,63 @@ impl Shared {
commands: Option<&[AgentCommand]>,
model: Option<&str>,
) {
let snapshot = {
let mut cache = self.known.lock();
let known = cache.entry(provider.to_owned()).or_default();
let mut changed = false;
if let Some(c) = config {
if let Some(model) = model {
let levels: Vec<_> =
c.iter().filter(|o| o.category.as_deref() == Some("thought_level")).cloned().collect();
if known.thought_levels.get(model) != Some(&levels) {
known.thought_levels.insert(model.to_owned(), levels);
changed = true;
}
}
if !c.is_empty() && known.config != c {
known.config = c.to_vec();
self.remember_inner(provider, config, commands, model, false);
}
fn remember_discovered(&self, provider: &str, config: &[AgentConfigOption], commands: &[AgentCommand]) {
self.remember_inner(provider, Some(config), Some(commands), model_of(config).as_deref(), true);
}
fn remember_inner(
&self,
provider: &str,
config: Option<&[AgentConfigOption]>,
commands: Option<&[AgentCommand]>,
model: Option<&str>,
discovered: bool,
) {
// Keep the update and its disk write ordered; a session report wins over discovery.
let mut cache = self.known.lock();
let known = cache.entry(provider.to_owned()).or_default();
let mut changed = false;
if let Some(c) = config.filter(|_| !discovered || known.config.is_empty()) {
if let Some(model) = model {
let levels: Vec<_> =
c.iter().filter(|o| o.category.as_deref() == Some("thought_level")).cloned().collect();
if known.thought_levels.get(model) != Some(&levels) {
known.thought_levels.insert(model.to_owned(), levels);
changed = true;
}
}
let root = self.root_key();
if let Some(c) =
commands.filter(|c| !c.is_empty() && known.commands.get(&root).map(Vec::as_slice) != Some(c))
{
known.commands.insert(root, c.to_vec());
if !c.is_empty() && known.config != c {
known.config = c.to_vec();
changed = true;
}
if !changed {
return;
}
serde_json::to_vec_pretty(&*cache)
};
if let Ok(bytes) = snapshot
}
let root = self.root_key();
if let Some(c) = commands.filter(|c| {
!c.is_empty()
&& (!discovered || known.commands.get(&root).is_none_or(Vec::is_empty))
&& known.commands.get(&root).map(Vec::as_slice) != Some(c)
}) {
known.commands.insert(root, c.to_vec());
changed = true;
}
if !changed {
return;
}
if let Ok(bytes) = serde_json::to_vec_pretty(&*cache)
&& let Err(e) = write_file(&self.paths.models_file, &bytes)
{
tracing::warn!("saving agent settings: {e}");
}
// The cached provider config actually changed (a session report, discovery,
// or a turn update): tell subscribers to re-list, so a changed model list
// reaches the composer without waiting for the next task (#1490). Unchanged
// reports stay silent, so routine session traffic emits nothing. The cache
// lock is released first; the sink never runs under it.
drop(cache);
self.emit(AgentEvent::Providers);
}
}
@@ -317,6 +345,8 @@ struct Task {
/// Settings already sent this session (`(option, value)`), so a refusal is not retried.
config_sent: Mutex<HashSet<(String, String)>>,
dirty: Mutex<BTreeSet<(Session, usize)>>,
/// How much of each session's streaming message was last sent, so the next flush sends the rest.
streamed: Mutex<HashMap<Session, Sent>>,
flush_scheduled: AtomicBool,
stderr: Mutex<VecDeque<String>>,
/// Set by `delete`; held while saving.
@@ -458,10 +488,10 @@ impl AgentManager {
// merged without duplicating what `availableCommands` or the disk offer.
let mut offered = offered;
if task.is_none()
&& offered.is_empty()
&& (offered.is_empty() || !self.shared.has_config(&provider))
&& let Some(spec) = self.shared.spec(&provider).filter(|s| s.driver == provider::Driver::Claude)
{
self.shared.fetch_commands(spec).await;
self.shared.fetch_settings(spec).await;
offered = self.shared.known_commands(&provider);
}
for &(name, description) in builtin_commands(&provider) {
@@ -824,6 +854,7 @@ impl AgentManager {
s.task.updated_ms = now_ms();
first
};
task.streamed.lock().clear();
task.persist();
self.shared.emit(AgentEvent::Truncated { task: id.to_owned(), length: 0 });
task.emit_task();
@@ -849,6 +880,7 @@ impl AgentManager {
s.task.updated_ms = now_ms();
first
};
task.streamed.lock().clear();
task.persist();
self.shared.emit(AgentEvent::Truncated { task: id.to_owned(), length: cut as u32 });
task.emit_task();
@@ -1101,6 +1133,7 @@ impl Task {
idle_epoch: AtomicU64::new(0),
config_sent: Mutex::new(HashSet::new()),
dirty: Mutex::new(BTreeSet::new()),
streamed: Mutex::new(HashMap::new()),
flush_scheduled: AtomicBool::new(false),
stderr: Mutex::new(VecDeque::new()),
deleted: Mutex::new(false),
@@ -1222,11 +1255,12 @@ impl Task {
let indices = std::mem::take(&mut *self.dirty.lock());
let events: Vec<AgentEvent> = {
let s = self.state.lock();
let mut streamed = self.streamed.lock();
indices
.into_iter()
.filter_map(|(subagent, i)| {
let entry = s.conversation.timeline(&subagent)?.entries.get(i)?.clone();
Some(AgentEvent::Entry { task: self.id.clone(), subagent, index: i as u32, entry })
let entry = s.conversation.timeline(&subagent)?.entries.get(i)?;
Some(entry_event(&self.id, &mut streamed, subagent, i, entry))
})
.collect()
};
@@ -2288,3 +2322,77 @@ fn stdio_mcp_servers(executable: Option<&std::path::Path>, supported: bool, root
let command = ide_mcp::stdio_command(executable, root);
vec![json!({ "name": "OpenIDEA", "command": command[0], "args": command[1..], "env": [] })]
}
/// What was sent of a streaming message: its first `bytes` (`utf16` UTF-16 units), which
/// ended with `tail`. A message only grows while it streams, so its tail tells a message
/// that went on from one that was replaced without comparing (or keeping) all of it.
struct Sent {
index: usize,
bytes: usize,
utf16: usize,
tail: String,
}
const SENT_TAIL: usize = 64;
impl Sent {
fn new(index: usize, text: &str) -> Self {
Self::after(index, text, 0, 0)
}
/// `text` as sent, of which the first `bytes` (`utf16` units) were sent before.
fn after(index: usize, text: &str, bytes: usize, utf16: usize) -> Self {
let mut start = text.len().saturating_sub(SENT_TAIL);
while !text.is_char_boundary(start) {
start -= 1;
}
let utf16 = utf16 + text[bytes..].encode_utf16().count();
Self { index, bytes: text.len(), utf16, tail: text[start..].to_owned() }
}
/// Whether `text` at `index` is this message, gone on.
fn extended_by(&self, index: usize, text: &str) -> bool {
self.index == index
&& text.len() > self.bytes
&& text.is_char_boundary(self.bytes)
&& text[..self.bytes].ends_with(&self.tail)
}
}
/// The event that brings the UI's copy of entry `i` up to date: only the new text when
/// it extends the message last sent for the session, else the whole entry.
fn entry_event(
task: &str,
streamed: &mut HashMap<Session, Sent>,
subagent: Session,
i: usize,
entry: &AgentEntry,
) -> AgentEvent {
let text = match entry {
AgentEntry::Agent { text } | AgentEntry::Thought { text } => Some(text),
_ => None,
};
let Some(text) = text else {
if streamed.get(&subagent).is_some_and(|sent| sent.index == i) {
streamed.remove(&subagent);
}
return AgentEvent::Entry { task: task.to_owned(), subagent, index: i as u32, entry: entry.clone() };
};
match streamed.get(&subagent).filter(|sent| sent.extended_by(i, text)) {
Some(sent) => {
let (bytes, from) = (sent.bytes, sent.utf16);
streamed.insert(subagent.clone(), Sent::after(i, text, bytes, from));
AgentEvent::Append {
task: task.to_owned(),
subagent,
index: i as u32,
from: from as u32,
text: text[bytes..].to_owned(),
}
}
None => {
streamed.insert(subagent.clone(), Sent::new(i, text));
AgentEvent::Entry { task: task.to_owned(), subagent, index: i as u32, entry: entry.clone() }
}
}
}
+9
Переглянути файл
@@ -518,6 +518,15 @@ pub enum AgentEvent {
index: u32,
entry: AgentEntry,
},
/// Text was appended to entry `index` (an agent message or a thought): it read the first
/// `from` UTF-16 units of its text when the previous event for it was sent.
Append {
task: String,
subagent: Option<String>,
index: u32,
from: u32,
text: String,
},
/// An agent reported the usage limits of its account (shared by every task of `provider`).
Limits {
provider: String,
+43 -18
Переглянути файл
@@ -69,8 +69,9 @@ pub(crate) fn connect(
Ok(super::serve(shim, client))
}
/// Discover commands without a prompt, ACP session, or pending session file.
pub(crate) async fn commands(spec: ProviderSpec, cwd: PathBuf) -> Result<Vec<Value>, String> {
/// Discover the slash commands and the model, mode and effort options without a prompt,
/// ACP session, or pending session file.
pub(crate) async fn discover(spec: ProviderSpec, cwd: PathBuf) -> Result<(Vec<Value>, Value), String> {
tokio::time::timeout(Duration::from_secs(5), async move {
let spec = tokio::task::spawn_blocking(move || (spec.resolve_for_launch(&cwd), cwd));
let (spec, cwd) = spec.await.map_err(|e| e.to_string())?;
@@ -79,28 +80,52 @@ pub(crate) async fn commands(spec: ProviderSpec, cwd: PathBuf) -> Result<Vec<Val
let launch = Launch { program: spec.command, args, cwd, env: spec.env };
let mut io = cli::Process.launch(&launch, Arc::new(|_| {})).map_err(|e| e.to_string())?;
let _stop = io.kill.clone().drop_guard();
let request = json!({ "type": "control_request", "request_id": "commands",
"request": { "subtype": "initialize", "perTaskStopAffordance": true } });
io.stdin.write_all(format!("{request}\n").as_bytes()).await.map_err(|e| e.to_string())?;
io.stdin.flush().await.map_err(|e| e.to_string())?;
let mut lines = BufReader::new(io.stdout).lines();
while let Some(line) = lines.next_line().await.map_err(|e| e.to_string())? {
let Ok(msg) = serde_json::from_str::<Value>(&line) else { continue };
if msg["type"] == "control_response" && msg["response"]["request_id"] == "commands" {
let response = &msg["response"];
return if response["subtype"] == "success" {
Ok(Settings::from_cli(&response["response"], &Value::Null).commands)
} else {
Err("Claude Code did not initialize".into())
};
}
}
Err("Claude Code exited".into())
let init = control(
&mut io.stdin,
&mut lines,
"commands",
json!({ "subtype": "initialize", "perTaskStopAffordance": true }),
)
.await?
.ok_or("Claude Code did not initialize")?;
// The effort and model the user configured; without them the options show the defaults.
let applied = tokio::time::timeout(
Duration::from_secs(2),
control(&mut io.stdin, &mut lines, "settings", json!({ "subtype": "get_settings" })),
)
.await
.ok()
.and_then(Result::ok)
.flatten()
.unwrap_or_default();
let settings = Settings::from_cli(&init, &applied);
Ok((settings.commands.clone(), settings.options()))
})
.await
.map_err(|_| "Claude Code command discovery timed out".to_owned())?
}
/// Sends one control request and waits for its response; `None` when the CLI answered with an error.
async fn control(
stdin: &mut (impl AsyncWriteExt + Unpin),
lines: &mut tokio::io::Lines<impl AsyncBufReadExt + Unpin>,
id: &str,
request: Value,
) -> Result<Option<Value>, String> {
let line = json!({ "type": "control_request", "request_id": id, "request": request });
stdin.write_all(format!("{line}\n").as_bytes()).await.map_err(|e| e.to_string())?;
stdin.flush().await.map_err(|e| e.to_string())?;
while let Some(line) = lines.next_line().await.map_err(|e| e.to_string())? {
let Ok(msg) = serde_json::from_str::<Value>(&line) else { continue };
if msg["type"] == "control_response" && msg["response"]["request_id"] == id {
let response = &msg["response"];
return Ok((response["subtype"] == "success").then(|| response["response"].clone()));
}
}
Err("Claude Code exited".into())
}
struct Config {
pub program: String,
/// Extra arguments (a custom agent's), before the session's own.
+1
Переглянути файл
@@ -64,6 +64,7 @@ const BUILTIN: &[(&str, &str, Driver, &[&str], &str)] = &[
("auggie", "Auggie", Driver::Acp, &["auggie", "--acp"], "Run `auggie login` in a terminal."),
("kiro", "Kiro", Driver::Acp, &["kiro-cli", "acp"], "Run `kiro-cli login` in a terminal."),
("vibe", "Mistral Vibe", Driver::Acp, &["vibe-acp"], "Run `vibe` in a terminal and sign in."),
("omp", "OMP", Driver::Acp, &["omp", "acp"], "Run `omp` in a terminal and sign in."),
];
fn builtins() -> Vec<ProviderSpec> {
+220 -10
Переглянути файл
@@ -366,12 +366,41 @@ fn manager_at(dir: &Path, models_file: &Path, agent: Arc<FakeAgent>) -> AgentMan
)
}
#[tokio::test(flavor = "multi_thread")]
async fn saved_conversation_reads_do_not_start_or_load_the_agent() {
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent::default());
let mgr = manager(tmp.path(), agent.clone());
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("Review the saved conversation"),
provider: "claude".into(),
config: Vec::new(),
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
mgr.shutdown();
let restored_agent = Arc::new(FakeAgent::default());
let restored = manager(tmp.path(), restored_agent.clone());
assert!(!restored.entries(&task.id, None).unwrap().is_empty());
assert!(restored_agent.calls.lock().is_empty());
assert!(restored.entries("missing-task", None).is_err());
assert!(restored.entries(&task.id, Some("missing-subagent".into())).is_err());
restored.task(&task.id).unwrap().state.lock().conversation = Conversation::default();
assert!(restored.entries(&task.id, None).unwrap().is_empty());
restored.shutdown();
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread")]
async fn claude_commands_before_session_are_fetched_once_and_saved() {
use std::os::unix::fs::PermissionsExt;
for outcome in ["success", "error", "exit", "timeout", "missing"] {
for outcome in ["success", "session_report", "empty", "settings_timeout", "error", "exit", "timeout", "missing"] {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let script = root.join("fake-claude");
@@ -380,6 +409,7 @@ async fn claude_commands_before_session_are_fetched_once_and_saved() {
r#"#!/usr/bin/env python3
import json, os, pathlib, sys, time
root = pathlib.Path.cwd()
(root / 'pid').write_text(str(os.getpid()))
with (root / 'launches').open('a') as f:
f.write(json.dumps(sys.argv[1:]) + '\n')
request = json.loads(sys.stdin.readline())
@@ -391,16 +421,34 @@ if outcome == 'exit':
if outcome == 'timeout':
time.sleep(60)
response = {'subtype': outcome, 'request_id': request['request_id']}
if outcome == 'success':
if outcome in ('success', 'session_report', 'empty', 'settings_timeout'):
response['subtype'] = 'success'
response['response'] = {'commands': [
{'name': 'compact', 'description': 'Free up context', 'argumentHint': '[instructions]'},
{'name': 'clear'}, {'name': 'model'}, {'name': 'context'}]}
{'name': 'clear'}, {'name': 'model'}, {'name': 'context'}],
'models': [
{'value': 'default', 'displayName': 'Default (recommended)', 'description': 'Use the default model',
'supportsEffort': True, 'supportedEffortLevels': ['low', 'high']},
{'value': 'custom-opus', 'displayName': 'custom-opus', 'description': 'Custom Opus model'}]}
if outcome == 'empty':
response['response'] = {}
else:
response['error'] = 'Authentication required'
if outcome == 'session_report':
(root / 'initialized').touch()
while not (root / 'continue').exists():
time.sleep(0.01)
print(json.dumps({'type': 'control_response', 'response': response}), flush=True)
for line in sys.stdin:
with (root / 'requests').open('a') as f:
f.write(line)
request = json.loads(line)
if request['request']['subtype'] == 'get_settings':
if outcome == 'settings_timeout':
time.sleep(60)
reply = {'subtype': 'success', 'request_id': request['request_id'],
'response': {'applied': {'effort': 'high'}, 'effective': {}}}
print(json.dumps({'type': 'control_response', 'response': reply}), flush=True)
"#,
)
.unwrap();
@@ -417,19 +465,59 @@ for line in sys.stdin:
}
spec.env = vec![("OUTCOME".into(), outcome.into())];
}
let (first, concurrent) = tokio::join!(mgr.commands("claude", None), mgr.commands("claude", None));
let concurrent = async {
if outcome == "session_report" {
tokio::time::timeout(Duration::from_secs(2), async {
while !root.join("initialized").exists() {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.unwrap();
let reported = config::options_of(&json!({ "configOptions": [
{ "id": "model", "category": "model", "type": "select", "currentValue": "default",
"options": [{ "value": "default" }, { "value": "custom-opus" }] },
{ "id": "effort", "category": "thought_level", "type": "select", "currentValue": "low",
"options": [{ "value": "default" }, { "value": "low" }, { "value": "high" }] }
] }));
mgr.shared.remember("claude", Some(&reported), None, Some("default"));
std::fs::write(root.join("continue"), "").unwrap();
}
mgr.commands("claude", None).await
};
let (first, concurrent) = tokio::join!(mgr.commands("claude", None), concurrent);
let first = first.unwrap();
assert_eq!(first, concurrent.unwrap());
assert_eq!(first, mgr.commands("claude", None).await.unwrap());
let names: Vec<_> = first.iter().map(|c| c.name.as_str()).collect();
if outcome == "success" {
if matches!(outcome, "success" | "session_report" | "settings_timeout") {
assert_eq!(names, ["compact", "clear", "model", "context", "triage"]);
assert_eq!(first[0].hint.as_deref(), Some("[instructions]"));
let restored = manager(root, Arc::new(FakeAgent::default()));
assert_eq!(first, restored.commands("claude", None).await.unwrap());
// The same handshake lists the models, so the composer has them before any task.
for mgr in [&mgr, &restored] {
let claude = mgr.providers().into_iter().find(|p| p.id == "claude").unwrap();
let model = claude.config.iter().find(|o| o.category.as_deref() == Some("model")).unwrap();
assert_eq!(model.current.as_deref(), Some("default"));
let values: Vec<_> = model.values.iter().map(|v| v.value.as_str()).collect();
assert_eq!(values, ["default", "custom-opus"]);
let effort =
claude.thought_levels["default"].iter().find(|o| o.category.as_deref() == Some("thought_level"));
assert_eq!(
effort.unwrap().current.as_deref(),
Some(match outcome {
"settings_timeout" => "default",
"session_report" => "low",
_ => "high",
})
);
}
} else {
assert_eq!(names, ["triage"], "{outcome}");
assert!(!root.join("models.json").exists());
assert_eq!(root.join("models.json").exists(), outcome == "empty");
let claude = mgr.providers().into_iter().find(|p| p.id == "claude").unwrap();
assert!(!claude.config.iter().any(|o| o.category.as_deref() == Some("model")), "{outcome}");
}
assert!(mgr.tasks.read().is_empty());
assert!(!root.join("store").exists());
@@ -439,10 +527,25 @@ for line in sys.stdin:
let args: Vec<String> = serde_json::from_str(launches.trim()).unwrap();
assert!(args.windows(2).any(|a| a == ["--input-format", "stream-json"]));
let requests = std::fs::read_to_string(root.join("requests")).unwrap();
assert_eq!(requests.lines().count(), 1, "only initialize, no prompt or other control request");
let request: Value = serde_json::from_str(requests.trim()).unwrap();
assert_eq!(request["type"], "control_request");
assert_eq!(request["request"]["subtype"], "initialize");
let subtypes: Vec<String> = requests
.lines()
.map(|l| serde_json::from_str::<Value>(l).unwrap()["request"]["subtype"].as_str().unwrap().to_owned())
.collect();
// The handshake and the settings read: no prompt or other control request.
let expected: &[&str] = if matches!(outcome, "success" | "session_report" | "empty" | "settings_timeout") {
&["initialize", "get_settings"]
} else {
&["initialize"]
};
assert_eq!(subtypes, expected, "{outcome}");
let pid = std::fs::read_to_string(root.join("pid")).unwrap();
tokio::time::timeout(Duration::from_secs(2), async {
while std::process::Command::new("kill").args(["-0", pid.trim()]).output().unwrap().status.success() {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("discovery process must exit");
}
}
}
@@ -2072,6 +2175,49 @@ async fn reload_providers_updates_registry_install_clears_stale_probes_and_notif
assert!(probe.error.is_none());
}
/// A session report that changes the cached provider config notifies subscribers,
/// so the composer re-lists models without waiting for the next task (#1490).
/// Reports that change nothing stay silent.
#[tokio::test(flavor = "multi_thread")]
async fn remembered_config_changes_notify_subscribers_once() {
let tmp = tempfile::tempdir().unwrap();
let mgr = manager(tmp.path(), Arc::new(FakeAgent::default()));
let events = Arc::new(Mutex::new(Vec::new()));
let sink = events.clone();
mgr.subscribe(Arc::new(move |e| sink.lock().push(e)));
events.lock().clear();
let reported = config::options_of(&json!({ "configOptions": [
{ "id": "model", "category": "model", "type": "select", "currentValue": "default",
"options": [{ "value": "default" }, { "value": "custom-opus" }] },
] }));
mgr.shared.remember("claude", Some(&reported), None, Some("default"));
assert!(
events.lock().iter().any(|ev| matches!(ev, AgentEvent::Providers)),
"a first report changing the cache notifies subscribers"
);
events.lock().clear();
// The same report again: the cache is unchanged, so subscribers hear nothing.
mgr.shared.remember("claude", Some(&reported), None, Some("default"));
assert!(events.lock().is_empty(), "an unchanged report must stay silent");
// A changed model list notifies again, and providers() serves the new list.
let updated = config::options_of(&json!({ "configOptions": [
{ "id": "model", "category": "model", "type": "select", "currentValue": "default",
"options": [{ "value": "default" }, { "value": "custom-opus" }, { "value": "custom-sonnet" }] },
] }));
mgr.shared.remember("claude", Some(&updated), None, Some("default"));
assert!(
events.lock().iter().any(|ev| matches!(ev, AgentEvent::Providers)),
"a changed model list notifies subscribers"
);
let claude = mgr.providers().into_iter().find(|p| p.id == "claude").unwrap();
let model = claude.config.iter().find(|o| o.category.as_deref() == Some("model")).unwrap();
let values: Vec<_> = model.values.iter().map(|v| v.value.as_str()).collect();
assert_eq!(values, ["default", "custom-opus", "custom-sonnet"]);
}
#[tokio::test(flavor = "multi_thread")]
async fn idle_agents_stop_and_resume_their_session_on_the_next_message() {
let new = || NewAgentTask {
@@ -2358,3 +2504,67 @@ fn mcp_servers_follow_server_state_and_agent_capabilities() {
"args":["--mcp-stdio","--project","/project"],"env":[]})]
);
}
#[tokio::test(flavor = "multi_thread")]
async fn session_settings_win_over_discovery_in_memory_and_on_disk() {
for discovery_first in [false, true] {
let tmp = tempfile::tempdir().unwrap();
let mgr = manager(tmp.path(), Arc::new(FakeAgent::default()));
let options = |model: &str, effort: &str| {
config::options_of(&json!({ "configOptions": [
{ "id": "model", "category": "model", "type": "select", "currentValue": model,
"options": [{ "value": "saved" }, { "value": "discovered" }] },
{ "id": "effort", "category": "thought_level", "type": "select", "currentValue": effort,
"options": [{ "value": "low" }, { "value": "high" }] }
] }))
};
let saved = options("saved", "low");
let discovered = options("discovered", "high");
if discovery_first {
mgr.shared.remember_discovered("claude", &discovered, &[]);
}
mgr.shared.remember("claude", Some(&saved), None, Some("saved"));
mgr.shared.remember_discovered("claude", &discovered, &[]);
let restored = manager(tmp.path(), Arc::new(FakeAgent::default()));
for mgr in [&mgr, &restored] {
assert_eq!(model_of(&mgr.shared.known_config("claude")).as_deref(), Some("saved"));
assert_eq!(mgr.shared.known.lock()["claude"].thought_levels["saved"], saved[1..]);
}
}
}
#[test]
fn streamed_text_is_sent_as_appends() {
let mut streamed = HashMap::new();
let agent = |t: &str| AgentEntry::Agent { text: t.into() };
let first = entry_event("t", &mut streamed, None, 3, &agent("Hé"));
assert!(matches!(first, AgentEvent::Entry { index: 3, .. }), "the first sight of a message is whole");
let next = entry_event("t", &mut streamed, None, 3, &agent("Hé 👋 there"));
assert!(
matches!(&next, AgentEvent::Append { subagent: None, index: 3, from: 2, text, .. } if text == " 👋 there"),
"{next:?}"
);
let sub = entry_event("t", &mut streamed, Some("s".into()), 3, &agent("Hé 👋 there!"));
assert!(matches!(sub, AgentEvent::Entry { .. }), "each session streams on its own");
let after = entry_event("t", &mut streamed, None, 3, &agent("Hé 👋 there."));
assert!(
matches!(&after, AgentEvent::Append { from: 11, text, .. } if text == "."),
"from counts UTF-16 units: {after:?}"
);
let replaced = entry_event("t", &mut streamed, None, 3, &agent("Other"));
assert!(matches!(replaced, AgentEvent::Entry { .. }), "text that doesn't extend the sent text is sent whole");
let thought = entry_event("t", &mut streamed, None, 4, &AgentEntry::Thought { text: "hmm…".into() });
assert!(matches!(thought, AgentEvent::Entry { index: 4, .. }), "a new message starts whole");
let tool = AgentEntry::Error { message: "x".into() };
entry_event("t", &mut streamed, None, 4, &tool);
let again = entry_event("t", &mut streamed, None, 4, &AgentEntry::Thought { text: "hmm… ok".into() });
assert!(matches!(again, AgentEvent::Entry { .. }), "an entry replaced by another kind forgets its text");
let long = "é".repeat(5000);
entry_event("t", &mut streamed, None, 5, &agent(&long));
assert!(streamed[&None].tail.len() <= 64, "only the end of what was sent is kept");
let on = entry_event("t", &mut streamed, None, 5, &agent(&format!("{long}!")));
assert!(matches!(&on, AgentEvent::Append { from: 5000, text, .. } if text == "!"), "{on:?}");
let rewritten = entry_event("t", &mut streamed, None, 5, &agent(&format!("{}x!??", "é".repeat(4999))));
assert!(matches!(rewritten, AgentEvent::Entry { .. }), "a rewritten end is sent whole");
}
+2
Переглянути файл
@@ -7,7 +7,9 @@ license.workspace = true
[dependencies]
blake3.workspace = true
chardetng.workspace = true
dunce.workspace = true
encoding_rs.workspace = true
ide-core.workspace = true
zip.workspace = true
serde.workspace = true
+78
Переглянути файл
@@ -184,6 +184,30 @@ pub fn launch_fingerprint(
Ok(Some(LaunchFingerprint { version: 2, parameters, files }))
}
impl LaunchFingerprint {
/// Relative paths whose content changed while `(length, mtime)` stayed the same
/// compared to `previous` (timestamp-preserving external edits, coarse-granularity
/// filesystems). Timestamp-incremental builders (Maven, the Kotlin plugin) would
/// skip these sources as "unchanged", so the rebuild the fingerprint triggers
/// compiles nothing and the stale outputs get re-recorded as fresh. Callers
/// re-stamp the returned paths before building. Inputs without a content hash
/// (directories, files outside the project) are never reported.
pub fn mtime_preserved_edits(&self, current: &Self) -> Vec<PathBuf> {
let mut edits = Vec::new();
for (path, current_stamp) in &current.files {
let Some(current_stamp) = current_stamp else { continue };
let Some(previous_stamp) = self.files.get(path).and_then(|stamp| stamp.as_ref()) else { continue };
let (len, mtime, Some(current_hash)) = current_stamp else { continue };
let (prev_len, prev_mtime, Some(prev_hash)) = previous_stamp else { continue };
if current_hash != prev_hash && len == prev_len && mtime == prev_mtime {
edits.push(path.clone());
}
}
edits.sort();
edits
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -282,6 +306,60 @@ mod tests {
assert_ne!(before, after, "a same-length, same-mtime content change must invalidate the fingerprint");
}
#[test]
fn mtime_preserved_edits_only_reports_lying_timestamps() {
fn stamp(len: u64, hash: &str) -> Option<Stamp> {
Some((len, SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(42), Some(hash.into())))
}
fn prints(
previous: BTreeMap<PathBuf, Option<Stamp>>,
current: BTreeMap<PathBuf, Option<Stamp>>,
) -> Vec<PathBuf> {
let previous = LaunchFingerprint { version: 2, parameters: String::new(), files: previous };
let current = LaunchFingerprint { version: 2, parameters: String::new(), files: current };
previous.mtime_preserved_edits(&current)
}
// Same length and mtime, different content: the builder would skip it.
assert_eq!(
prints([("a.java".into(), stamp(5, "old"))].into(), [("a.java".into(), stamp(5, "new"))].into()),
vec![PathBuf::from("a.java")]
);
// A normal edit bumps the mtime: the builder already sees it.
let mut touched = stamp(5, "new").unwrap();
touched.1 += std::time::Duration::from_secs(1);
assert!(
prints([("a.java".into(), stamp(5, "old"))].into(), [("a.java".into(), Some(touched))].into()).is_empty()
);
// Added, deleted, and hash-less inputs (directories, files outside the
// project) carry no comparable content: never reported.
assert!(
prints(
[("gone.java".into(), stamp(5, "old")), ("dir".into(), None)].into(),
[("new.java".into(), stamp(5, "new")), ("dir".into(), None)].into()
)
.is_empty()
);
}
#[test]
fn lying_source_is_found_against_a_real_fingerprint() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let model = fixture(root);
let source = root.join("lib/src/main/kotlin/Channel.kt");
let before = fingerprint(root, &model).unwrap();
let mtime = fs::metadata(&source).unwrap().modified().unwrap();
// Same 5-byte length, different content, preserved mtime.
write(root, "lib/src/main/kotlin/Channel.kt", "valve");
fs::File::options().write(true).open(&source).unwrap().set_modified(mtime).unwrap();
let after = fingerprint(root, &model).unwrap();
assert_eq!(before.mtime_preserved_edits(&after), [PathBuf::from("lib/src/main/kotlin/Channel.kt")]);
// A normal edit (mtime advances) needs no repair.
write(root, "lib/src/main/kotlin/Channel.kt", "valve!");
let normal = fingerprint(root, &model).unwrap();
assert!(before.mtime_preserved_edits(&normal).is_empty());
}
#[test]
fn profile_options_jdk_goal_and_wrapper_invalidate_without_using_class_outputs() {
let dir = tempfile::tempdir().unwrap();
+641 -9
Переглянути файл
@@ -109,11 +109,11 @@ fn strip_comments(text: &str) -> String {
/// Quoted string literals in `s`.
fn literals(s: &str) -> Vec<String> {
let mut out = Vec::new();
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c == '"' || c == '\'' {
out.push(chars.by_ref().take_while(|&n| n != c).collect());
}
let mut rest = s;
while let Some(start) = rest.find(['"', '\'']) {
let Some((value, used)) = literal(&rest[start..]) else { break };
out.push(value);
rest = &rest[start + used..];
}
out
}
@@ -565,11 +565,12 @@ fn is_dependency_configuration(name: &str) -> bool {
/// `(group, artifact, version)` of the external dependencies a build script declares:
/// `implementation 'g:a:v'`, `api("g:a:v:classifier@jar")`, and the map notation
/// `implementation group: 'g', name: 'a', version: 'v'` (Groovy) / `group = "g", …` (Kotlin).
/// Versions with interpolations, ranges and catalog aliases (`libs.x`) are skipped.
pub fn script_dependencies(text: &str) -> Vec<(String, String, String)> {
/// Versions written as `${gsonVersion}` are filled in from `properties` (`ext` values, see
/// [`script_properties`]); ranges, unknown names and catalog aliases (`libs.x`) are skipped.
pub fn script_dependencies(text: &str, properties: &BTreeMap<String, String>) -> Vec<(String, String, String)> {
let text = strip_comments(text);
let mut out = Vec::new();
for line in text.lines().flat_map(|l| l.split(['{', ';'])) {
for line in text.lines().flat_map(statements) {
let line = line.trim_start();
let name: String = line.chars().take_while(|c| c.is_ascii_alphanumeric() || *c == '_').collect();
if name.is_empty() || !is_dependency_configuration(&name) {
@@ -591,6 +592,7 @@ pub fn script_dependencies(text: &str) -> Vec<(String, String, String)> {
}),
};
if let Some((g, a, v)) = coordinate {
let v = interpolate(&v, properties);
let plain = |s: &str| {
!s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | '+'))
};
@@ -602,11 +604,354 @@ pub fn script_dependencies(text: &str) -> Vec<(String, String, String)> {
out
}
/// `line` split at `{` and `;` outside quoted strings, so a `${version}` stays in one piece.
fn statements(line: &str) -> Vec<&str> {
let mut out = Vec::new();
let mut start = 0;
let mut index = 0;
while index < line.len() {
let c = line[index..].chars().next().unwrap();
if c == '"' || c == '\'' {
index += literal(&line[index..]).map_or(line.len() - index, |(_, used)| used);
continue;
}
if matches!(c, '{' | '}' | ';' | '\n') {
out.push(&line[start..index]);
start = index + c.len_utf8();
}
index += c.len_utf8();
}
out.push(&line[start..]);
out
}
/// `apply from: 'x.gradle'` (Groovy) and `apply(from = "x.gradle.kts")` (Kotlin DSL)
/// targets, as written in the script.
pub fn applied_scripts(text: &str) -> Vec<String> {
let text = strip_comments(text);
let mut out = Vec::new();
for (index, word) in code_words(&text) {
if word != "apply" || !starts_word(&text, index) {
continue;
}
let mut rest = text[index + "apply".len()..].trim_start();
if let Some(inner) = rest.strip_prefix('(') {
rest = inner.trim_start();
}
let Some(rest) = rest.strip_prefix("from") else { continue };
let Some(rest) = rest.trim_start().strip_prefix(':').or_else(|| rest.trim_start().strip_prefix('=')) else {
continue;
};
let mut rest = rest.trim_start();
if let Some(inner) = rest.strip_prefix("file") {
let inner = inner.trim_start();
if let Some(inner) = inner.strip_prefix('(') {
rest = inner.trim_start();
}
}
if let Some((path, _)) = literal(rest) {
out.push(path);
}
}
out
}
/// Literal extra properties declared by Groovy's `ext` or Kotlin's `extra`.
pub fn script_properties(text: &str) -> BTreeMap<String, String> {
let text = strip_comments(text);
let mut out = BTreeMap::new();
for (index, word) in code_words(&text) {
if !starts_word(&text, index) {
continue;
}
let rest = text[index + word.len()..].trim_start();
if word == "val" || word == "var" {
let (name, rest) = identifier(rest);
let Some(rest) = rest.trim_start().strip_prefix("by").map(str::trim_start) else { continue };
let Some(rest) = rest.strip_prefix("extra").map(str::trim_start) else { continue };
if let Some(rest) = rest.strip_prefix('(')
&& let Some((value, used)) = literal(rest.trim_start())
&& rest.trim_start()[used..].trim_start().starts_with(')')
&& !name.is_empty()
{
out.insert(name.to_owned(), value);
}
continue;
}
if word != "ext" && word != "extra" {
continue;
}
if let Some(block) = rest.strip_prefix('{') {
if let Some(end) = matching_brace(block) {
out.extend(assignments(&block[..end]));
}
} else if let Some(rest) = rest.strip_prefix('.') {
let (name, rest) = identifier(rest);
if let Some(value) = assigned_literal(rest)
&& !name.is_empty()
{
out.insert(name.to_owned(), value);
}
} else if let Some(rest) = rest.strip_prefix('[')
&& let Some((name, used)) = literal(rest.trim_start())
&& let Some(rest) = rest.trim_start()[used..].trim_start().strip_prefix(']')
&& let Some(value) = assigned_literal(rest)
{
out.insert(name, value);
}
}
out
}
pub(crate) fn file_properties(build_dir: &Path) -> BTreeMap<String, String> {
let text = std::fs::read_to_string(build_dir.join("gradle.properties")).unwrap_or_default();
text.lines()
.filter_map(|line| {
let line = line.trim();
if line.starts_with(['#', '!']) {
return None;
}
let separator = line.find(|c: char| c == '=' || c == ':' || c.is_whitespace())?;
let (key, rest) = line.split_at(separator);
let value = rest.trim_start().trim_start_matches(['=', ':']).trim_start();
(!key.is_empty()).then(|| (key.to_owned(), value.to_owned()))
})
.collect()
}
/// The build script of a project and, recursively, the scripts it applies with `apply from`,
/// read from disk: absolute path and text. Targets that do not exist are skipped.
pub fn scripts_of(root: &Path, project_dir: &Path, build_dir: &Path, build_file: &Path) -> Vec<(PathBuf, String)> {
const MAX_SCRIPTS: usize = 256;
const MAX_DEPTH: usize = 32;
let mut out = Vec::new();
let mut seen = std::collections::HashSet::new();
let first = normalize(&root.join(build_file));
let Ok(identity) = dunce::canonicalize(&first) else { return out };
seen.insert(identity);
let mut stack = vec![(first, 0)];
while let Some((path, depth)) = stack.pop() {
let Ok(text) = std::fs::read_to_string(&path) else { continue };
if depth < MAX_DEPTH {
let mut targets = Vec::new();
for spec in applied_scripts(&text) {
if seen.len() >= MAX_SCRIPTS {
break;
}
if let Some(target) = resolve_script(&spec, &path, project_dir, build_dir)
&& let Ok(identity) = dunce::canonicalize(&target)
&& seen.insert(identity)
{
targets.push((target, depth + 1));
}
}
stack.extend(targets.into_iter().rev());
}
out.push((path, text));
}
out
}
/// Relative targets are project-scoped, with a lenient fallback beside the applying script.
fn resolve_script(spec: &str, script: &Path, project_dir: &Path, build_dir: &Path) -> Option<PathBuf> {
let spec = spec.trim();
if spec.contains("://") {
return None;
}
let properties = BTreeMap::from([
("rootDir".into(), build_dir.to_string_lossy().into_owned()),
("rootProject.projectDir".into(), build_dir.to_string_lossy().into_owned()),
("projectDir".into(), project_dir.to_string_lossy().into_owned()),
]);
let spec = interpolate(spec, &properties);
if spec.contains('$') {
return None;
}
let path = Path::new(&spec);
if path.is_absolute() {
return path.is_file().then(|| normalize(path));
}
let project_path = normalize(&project_dir.join(path));
if project_path.is_file() {
return Some(project_path);
}
let path = normalize(&script.parent()?.join(path));
path.is_file().then_some(path)
}
pub(crate) fn project_build_dir(root: &Path, model: &GradleModel, project: &GradleProject) -> PathBuf {
let dir = project.build.as_ref().and_then(|build| model.projects.iter().find(|p| &p.path == build));
root.join(dir.map_or(&model.dir, |p| &p.dir))
}
/// `2.8.9` for `${gsonVersion}` / `$gsonVersion` / `${ext.gsonVersion}`; unknown names and
/// expressions are left as written, so ranges and catalogs stay skipped.
fn interpolate(value: &str, properties: &BTreeMap<String, String>) -> String {
if !value.contains('$') {
return value.to_owned();
}
let mut out = String::with_capacity(value.len());
let mut chars = value.chars().peekable();
while let Some(c) = chars.next() {
if c != '$' {
out.push(c);
continue;
}
let braced = chars.peek() == Some(&'{');
if braced {
chars.next();
}
let mut name = String::new();
if braced {
for n in chars.by_ref() {
if n == '}' {
break;
}
name.push(n);
}
} else {
while let Some(&n) = chars.peek() {
if n.is_alphanumeric() || n == '_' || n == '.' {
name.push(n);
chars.next();
} else {
break;
}
}
}
let key = name.strip_prefix("ext.").or_else(|| name.strip_prefix("extra.")).unwrap_or(&name);
let bracket = name.strip_prefix("ext[").or_else(|| name.strip_prefix("extra[")).and_then(|rest| {
let (key, used) = literal(rest.trim_start())?;
(rest.trim_start()[used..].trim() == "]").then_some(key)
});
match properties.get(bracket.as_deref().unwrap_or(key)) {
Some(v) if !name.is_empty() => out.push_str(v),
_ => {
out.push('$');
if braced {
out.push('{');
}
out.push_str(&name);
if braced {
out.push('}');
}
}
}
}
out
}
/// Whether `index` starts a whole word in `text` (not `reapply`, `my.apply`).
fn starts_word(text: &str, index: usize) -> bool {
text[..index].chars().next_back().is_none_or(|c| !(c.is_alphanumeric() || c == '_' || c == '.'))
}
/// Identifiers outside quoted strings, with their byte positions.
fn code_words(text: &str) -> Vec<(usize, &str)> {
let mut out = Vec::new();
let mut index = 0;
while index < text.len() {
let c = text[index..].chars().next().unwrap();
if c == '"' || c == '\'' {
index += literal(&text[index..]).map_or(text.len() - index, |(_, used)| used);
} else if c.is_alphabetic() || c == '_' {
let (word, _) = identifier(&text[index..]);
out.push((index, word));
index += word.len();
} else {
index += c.len_utf8();
}
}
out
}
fn identifier(text: &str) -> (&str, &str) {
let end = text.find(|c: char| !(c.is_alphanumeric() || c == '_')).unwrap_or(text.len());
text.split_at(end)
}
fn assigned_literal(text: &str) -> Option<String> {
let rest = text.trim_start().strip_prefix('=')?.trim_start();
let (value, used) = literal(rest)?;
// Do not turn a computed value (`'1.' + suffix`) into a concrete version.
let tail = rest[used..].trim_start_matches([' ', '\t', '\r']);
(tail.is_empty() || tail.starts_with(['\n', ';', '}'])).then_some(value)
}
/// The quoted string at the start of `s`, and how many bytes it spans.
fn literal(s: &str) -> Option<(String, usize)> {
let quote = s.chars().next().filter(|c| *c == '"' || *c == '\'')?;
let rest = &s[quote.len_utf8()..];
let mut index = 0;
while index < rest.len() {
let c = rest[index..].chars().next()?;
if c == quote {
return Some((rest[..index].to_owned(), quote.len_utf8() + index + c.len_utf8()));
}
if c == '\\' {
index += c.len_utf8();
index += rest[index..].chars().next()?.len_utf8();
} else if quote == '"' && rest[index..].starts_with("${") {
index += 2;
index += matching_brace(&rest[index..])? + 1;
} else {
index += c.len_utf8();
}
}
None
}
/// Byte index of the `}` closing a `{` that was already consumed, ignoring quoted text.
fn matching_brace(text: &str) -> Option<usize> {
let mut depth = 1usize;
let mut index = 0;
while index < text.len() {
let c = text[index..].chars().next()?;
if c == '"' || c == '\'' {
index += literal(&text[index..])?.1;
continue;
}
if c == '{' {
depth += 1;
} else if c == '}' {
depth -= 1;
if depth == 0 {
return Some(index);
}
}
index += c.len_utf8();
}
None
}
/// `name = 'value'` pairs in `text`.
fn assignments(text: &str) -> Vec<(String, String)> {
statements(text)
.into_iter()
.filter_map(|statement| {
let (name, rest) = identifier(statement.trim_start());
let value = assigned_literal(rest)?;
(!name.is_empty()).then(|| (name.to_owned(), value))
})
.collect()
}
/// Every settings and build script of the model (for change detection).
pub fn build_files(root: &Path, model: &GradleModel) -> Vec<PathBuf> {
let dir = root.join(&model.dir);
let mut out: Vec<PathBuf> = SETTINGS_FILES.iter().map(|f| dir.join(f)).filter(|p| p.is_file()).collect();
out.extend(model.projects.iter().filter_map(|p| p.build_file.as_ref().map(|f| root.join(f))));
// Scripts a build script applies are build files too: editing one can change the model.
for project in &model.projects {
let Some(file) = project.build_file.as_deref() else { continue };
let build_dir = project_build_dir(root, model, project);
let properties = build_dir.join("gradle.properties");
if properties.is_file() {
out.push(properties);
}
let applied = scripts_of(root, &root.join(&project.dir), &build_dir, Path::new(file));
out.extend(applied.into_iter().skip(1).map(|(path, _)| path));
}
// The settings of each nested build (its root project's folder).
for p in model.projects.iter().filter(|p| p.build.as_deref() == Some(p.path.as_str())) {
let nested = root.join(&p.dir);
@@ -624,6 +969,8 @@ pub fn build_files(root: &Path, model: &GradleModel) -> Vec<PathBuf> {
catalogs.sort();
out.extend(catalogs);
}
out.sort();
out.dedup();
out
}
@@ -808,7 +1155,7 @@ dependencies {
classpath 'not:a:library'
}
"#;
let deps = script_dependencies(script);
let deps = script_dependencies(script, &BTreeMap::new());
let names: Vec<String> = deps.iter().map(|(g, a, v)| format!("{g}:{a}:{v}")).collect();
assert_eq!(
names,
@@ -822,6 +1169,291 @@ dependencies {
);
}
#[test]
fn applied_scripts_read_groovy_and_kotlin_forms() {
let script = r#"
apply plugin: 'java'
apply from: 'gradle/dependencies.gradle'
apply from: file('gradle/extra.gradle')
apply(from = "gradle/kotlin.gradle.kts")
apply(from = file("gradle/kotlin-file.gradle.kts"))
// apply from: 'commented.gradle'
reapply from: 'not-a-call.gradle'
"#;
assert_eq!(
applied_scripts(script),
[
"gradle/dependencies.gradle",
"gradle/extra.gradle",
"gradle/kotlin.gradle.kts",
"gradle/kotlin-file.gradle.kts"
]
);
}
#[test]
fn ext_values_fill_in_dependency_versions() {
let script = r#"
ext {
gsonVersion = '2.8.9'
junit = "4.13.2"
}
ext.logback = '1.4.14'
dependencies {
implementation "com.google.code.gson:gson:${gsonVersion}"
testImplementation "junit:junit:$junit"
implementation "ch.qos.logback:logback-classic:${ext.logback}"
implementation 'org.plain:literal:1.0'
implementation "org.x:unknown:${missing}"
}
"#;
let names: Vec<String> = script_dependencies(script, &script_properties(script))
.into_iter()
.map(|(g, a, v)| format!("{g}:{a}:{v}"))
.collect();
assert_eq!(
names,
[
"com.google.code.gson:gson:2.8.9",
"junit:junit:4.13.2",
"ch.qos.logback:logback-classic:1.4.14",
"org.plain:literal:1.0",
]
);
}
#[test]
fn extra_properties_support_brackets_and_kotlin_delegates() {
for declaration in [
"ext.x = '1.2'",
"ext { x = '1.2' }",
"ext[\"x\"] = '1.2'",
"extra[\"x\"] = \"1.2\"",
"val x by extra(\"1.2\")",
] {
let script = format!("{declaration}\ndependencies {{ implementation \"g:a:${{x}}\" }}");
assert_eq!(
script_dependencies(&script, &script_properties(&script)),
[("g".into(), "a".into(), "1.2".into())],
"{declaration}"
);
}
}
#[test]
fn parsers_ignore_apply_and_ext_inside_strings() {
let script = r#"
println "apply from: 'fake.gradle'"
println "ext.x = 'bogus'"
ext.real = '1'
"#;
assert!(applied_scripts(script).is_empty());
assert_eq!(script_properties(script), BTreeMap::from([("real".into(), "1".into())]));
}
#[test]
fn dependency_statements_preserve_interpolation_and_escaped_quotes() {
let script = r#"dependencies { implementation "g:a:${v}"; api(group = "g", name = "b", version = "${v}-RC1") }; dependencies { runtimeOnly 'g:c:2' }"#;
let properties = BTreeMap::from([("v".into(), "1.2".into())]);
assert_eq!(
script_dependencies(script, &properties),
[
("g".into(), "a".into(), "1.2".into()),
("g".into(), "b".into(), "1.2-RC1".into()),
("g".into(), "c".into(), "2".into())
]
);
assert_eq!(
statements(r#"println "escaped\"; {still quoted}"; implementation 'g:a:1'"#),
[r#"println "escaped\"; {still quoted}""#, " implementation 'g:a:1'"]
);
}
#[test]
fn dependency_versions_skip_unresolved_names_and_expressions() {
let script = r#"dependencies {
implementation "g:missing:${unknown}"
implementation "g:expression:${1 + 2}"
implementation "g:cyclic:${cycle}"
implementation 'g:range:[1,2)'
implementation 'g:dynamic:1.+'
}"#;
let properties = BTreeMap::from([("cycle".into(), "${cycle}".into())]);
assert!(script_dependencies(script, &properties).is_empty());
}
#[test]
fn extra_property_references_support_qualified_and_bracket_access() {
let script = r#"dependencies {
implementation "g:a:${ext.x}"
implementation "g:b:${extra.x}"
implementation "g:c:${ext['x']}"
implementation "g:d:${extra["x"]}"
}"#;
let properties = BTreeMap::from([("x".into(), "1".into())]);
assert_eq!(script_dependencies(script, &properties).len(), 4);
}
#[test]
fn applied_scripts_are_read_recursively_and_survive_cycles() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("gradle")).unwrap();
std::fs::write(root.join("build.gradle"), "apply from: 'gradle/deps.gradle'\n").unwrap();
std::fs::write(root.join("gradle/deps.gradle"), "apply from: '$rootDir/gradle/more.gradle'\n").unwrap();
std::fs::write(root.join("gradle/more.gradle"), "apply from: 'deps.gradle'\n").unwrap();
let scripts: Vec<String> = scripts_of(root, root, root, Path::new("build.gradle"))
.into_iter()
.map(|(path, _)| path.strip_prefix(root).unwrap().to_string_lossy().replace('\\', "/"))
.collect();
assert_eq!(scripts, ["build.gradle", "gradle/deps.gradle", "gradle/more.gradle"]);
assert_eq!(build_files(root, &load(root).unwrap()).len(), 3);
}
#[test]
fn applied_paths_prefer_the_project_directory() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("gradle")).unwrap();
std::fs::write(root.join("build.gradle"), "apply from: 'gradle/deps.gradle'").unwrap();
std::fs::write(root.join("gradle/deps.gradle"), "apply from: 'versions.gradle'").unwrap();
std::fs::write(root.join("versions.gradle"), "right").unwrap();
std::fs::write(root.join("gradle/versions.gradle"), "wrong").unwrap();
let scripts = scripts_of(root, root, root, Path::new("build.gradle"));
assert_eq!(scripts.len(), 3);
assert_eq!(scripts[2].1, "right");
}
#[test]
fn nested_applied_paths_are_relative_to_the_project_directory() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("gradle")).unwrap();
std::fs::write(root.join("build.gradle"), "apply from: 'gradle/a.gradle'").unwrap();
std::fs::write(root.join("gradle/a.gradle"), "apply from: 'gradle/b.gradle'").unwrap();
std::fs::write(root.join("gradle/b.gradle"), "").unwrap();
let scripts = scripts_of(root, root, root, Path::new("build.gradle"));
assert_eq!(scripts.len(), 3);
assert_eq!(scripts[2].0, root.join("gradle/b.gradle"));
}
#[test]
fn applied_paths_fall_back_to_the_applying_script_directory() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("gradle")).unwrap();
std::fs::write(root.join("build.gradle"), "apply from: 'gradle/a.gradle'").unwrap();
std::fs::write(root.join("gradle/a.gradle"), "apply from: 'c.gradle'").unwrap();
std::fs::write(root.join("gradle/c.gradle"), "").unwrap();
let scripts = scripts_of(root, root, root, Path::new("build.gradle"));
assert_eq!(scripts.len(), 3);
assert_eq!(scripts[2].0, root.join("gradle/c.gradle"));
}
#[test]
fn applied_paths_resolve_gradle_directory_variables() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let project = root.join("app");
let script = project.join("gradle/deps.gradle");
std::fs::create_dir_all(script.parent().unwrap()).unwrap();
std::fs::write(root.join("shared.gradle"), "").unwrap();
std::fs::write(project.join("local.gradle"), "").unwrap();
for prefix in ["$rootDir", "${rootDir}", "$rootProject.projectDir", "${rootProject.projectDir}"] {
assert_eq!(
resolve_script(&format!("{prefix}/shared.gradle"), &script, &project, root),
Some(root.join("shared.gradle")),
"{prefix}"
);
}
for prefix in ["$projectDir", "${projectDir}"] {
assert_eq!(
resolve_script(&format!("{prefix}/local.gradle"), &script, &project, root),
Some(project.join("local.gradle")),
"{prefix}"
);
}
assert_eq!(
resolve_script(root.join("shared.gradle").to_str().unwrap(), &script, &project, root),
Some(root.join("shared.gradle"))
);
}
#[test]
fn applied_scripts_skip_urls_and_missing_files() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
// Even a local path resembling a URL must not be read.
if !cfg!(windows) {
for scheme in ["http", "https"] {
let local = root.join(format!("{scheme}:/example.com/deps.gradle"));
std::fs::create_dir_all(local.parent().unwrap()).unwrap();
std::fs::write(local, "implementation 'g:remote:1'").unwrap();
}
}
std::fs::write(root.join("build.gradle"), "apply from: 'http://example.com/deps.gradle'\napply(from = \"https://example.com/deps.gradle\")\napply from: 'missing.gradle'").unwrap();
assert_eq!(scripts_of(root, root, root, Path::new("build.gradle")).len(), 1);
}
#[test]
fn applied_scripts_deduplicate_self_application_and_path_aliases() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("gradle")).unwrap();
std::fs::write(
root.join("build.gradle"),
"apply from: 'build.gradle'\napply from: 'gradle/../build.gradle'\napply from: 'gradle/a.gradle'",
)
.unwrap();
std::fs::write(root.join("gradle/a.gradle"), "apply from: '../build.gradle'").unwrap();
assert_eq!(scripts_of(root, root, root, Path::new("build.gradle")).len(), 2);
}
#[cfg(unix)]
#[test]
fn applied_scripts_deduplicate_symlink_cycles() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(root.join("build.gradle"), "apply from: 'alias/build.gradle'").unwrap();
std::os::unix::fs::symlink(root, root.join("alias")).unwrap();
assert_eq!(scripts_of(root, root, root, Path::new("build.gradle")).len(), 1);
}
#[test]
fn applied_script_traversal_bounds_depth_and_total_files() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
for i in 0..300 {
std::fs::write(root.join(format!("{i}.gradle")), format!("apply from: '{}.gradle'", i + 1)).unwrap();
}
let chain = scripts_of(root, root, root, Path::new("0.gradle"));
assert_eq!(chain.len(), 33);
let fanout: String = (0..300).map(|i| format!("apply from: '{i}.gradle'\n")).collect();
std::fs::write(root.join("build.gradle"), fanout).unwrap();
assert_eq!(scripts_of(root, root, root, Path::new("build.gradle")).len(), 256);
}
#[test]
fn applied_scripts_use_their_composite_build_root() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("included/app")).unwrap();
std::fs::write(root.join("build.gradle"), "").unwrap();
std::fs::write(root.join("included/build.gradle"), "").unwrap();
std::fs::write(root.join("included/app/build.gradle"), "apply from: \"${rootDir}/deps.gradle\"").unwrap();
std::fs::write(root.join("included/deps.gradle"), "implementation 'g:included:1'").unwrap();
let mut model = load(root).unwrap();
for (path, dir) in [(":included", "included"), (":included:app", "included/app")] {
let mut project = project_at(root, path.into(), path.into(), &root.join(dir));
project.build = Some(":included".into());
model.projects.push(project);
}
assert!(build_files(root, &model).contains(&root.join("included/deps.gradle")));
let model = crate::BuildModel { maven: None, gradle: vec![model] };
assert_eq!(crate::dependency_coordinates(root, &model)[0].artifact_id, "included");
}
use super::*;
#[test]
+57 -6
Переглянути файл
@@ -147,7 +147,8 @@ pub struct DependencyCoordinate {
/// The external libraries of `model`, sorted and deduplicated: Maven dependencies
/// (with the transitive ones the last sync read), except the build's own modules,
/// `pom` imports and `system` scope; dependencies declared in the Gradle build scripts.
/// `pom` imports and `system` scope; dependencies declared in the Gradle build scripts,
/// including the scripts they pull in with `apply from`.
pub fn dependency_coordinates(root: &Path, model: &BuildModel) -> Vec<DependencyCoordinate> {
let mut out = Vec::new();
if let Some(m) = &model.maven {
@@ -177,11 +178,26 @@ pub fn dependency_coordinates(root: &Path, model: &BuildModel) -> Vec<Dependency
}
}
for g in &model.gradle {
for script in g.projects.iter().filter_map(|p| p.build_file.as_ref()) {
let Ok(text) = std::fs::read_to_string(root.join(script)) else { continue };
out.extend(gradle::script_dependencies(&text).into_iter().map(|(group_id, artifact_id, version)| {
DependencyCoordinate { gradle: true, group_id, artifact_id, version }
}));
for p in &g.projects {
let Some(build_file) = p.build_file.as_deref() else { continue };
let build_dir = gradle::project_build_dir(root, g, p);
let scripts = gradle::scripts_of(root, &root.join(&p.dir), &build_dir, Path::new(build_file));
// The script's own `ext` values win over the scripts it applies.
let mut properties = std::collections::BTreeMap::new();
for (_, text) in scripts.iter().rev() {
properties.extend(gradle::script_properties(text));
}
properties.extend(gradle::file_properties(&build_dir));
for (_, text) in &scripts {
out.extend(gradle::script_dependencies(text, &properties).into_iter().map(
|(group_id, artifact_id, version)| DependencyCoordinate {
gradle: true,
group_id,
artifact_id,
version,
},
));
}
}
}
out.sort();
@@ -312,4 +328,39 @@ mod tests {
.collect();
assert_eq!(names, ["M org.b:y:2", "M org.c:z:3", "G org.a:x:1.0"]);
}
#[test]
fn dependency_coordinates_read_scripts_brought_in_by_apply_from() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("gradle")).unwrap();
std::fs::write(root.join("build.gradle"), "apply from: 'gradle/dependencies.gradle'\n").unwrap();
std::fs::write(
root.join("gradle/dependencies.gradle"),
"ext { gson = '2.8.9' }\ndependencies { implementation \"com.google.code.gson:gson:${gson}\" }\n",
)
.unwrap();
let model = BuildModel { maven: None, gradle: gradle::load(root).into_iter().collect() };
let names: Vec<String> = dependency_coordinates(root, &model)
.into_iter()
.map(|c| format!("{}:{}:{}", c.group_id, c.artifact_id, c.version))
.collect();
assert_eq!(names, ["com.google.code.gson:gson:2.8.9"]);
}
#[test]
fn gradle_properties_override_script_extra_values() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(root.join("build.gradle"), "ext.v = 'wrong'\napply from: 'deps.gradle'").unwrap();
std::fs::write(
root.join("deps.gradle"),
"ext.v = 'alsoWrong'\nimplementation \"g:a:${v}\"\nimplementation \"g:b:${onlyInProperties}\"",
)
.unwrap();
std::fs::write(root.join("gradle.properties"), "# versions\nv = 1.2\nonlyInProperties: 2.3\n").unwrap();
let model = BuildModel { maven: None, gradle: gradle::load(root).into_iter().collect() };
let versions: Vec<_> = dependency_coordinates(root, &model).into_iter().map(|c| c.version).collect();
assert_eq!(versions, ["1.2", "2.3"]);
}
}
+25 -5
Переглянути файл
@@ -16,6 +16,8 @@ use std::io::Read;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
pub mod sources;
/// Phases shown under "Lifecycle" (IDEA's default list).
pub const LIFECYCLE: &[&str] =
&["clean", "validate", "compile", "test", "package", "verify", "install", "site", "deploy"];
@@ -497,7 +499,7 @@ pub fn user_settings_file(root: &Path, settings: &ide_core::settings::MavenSetti
/// IDEA lists them): checked by default when `<activeProfiles>` names them or they are
/// `activeByDefault`.
pub fn settings_profiles(settings: &Path) -> Vec<MavenProfile> {
let Some(root) = std::fs::read_to_string(settings).ok().and_then(|text| xml::parse(&text)) else {
let Some(root) = xml::read_text(settings).as_deref().and_then(xml::parse) else {
return Vec::new();
};
let active: Vec<String> =
@@ -517,7 +519,7 @@ pub fn settings_profiles(settings: &Path) -> Vec<MavenProfile> {
/// `<localRepository>` of a settings file, when it names one: the repository a build
/// with `-s <file>` resolves into, which is not necessarily the user's default.
pub fn local_repository_of(settings: &Path) -> Option<PathBuf> {
let text = std::fs::read_to_string(settings).ok()?;
let text = xml::read_text(settings)?;
let root = xml::parse(&text)?;
let repo = root.text_of("localRepository")?.trim().to_owned();
if repo.is_empty() {
@@ -543,7 +545,7 @@ impl Resolver {
fn pom(&mut self, path: &Path) -> Option<RawPom> {
self.poms
.entry(path.to_path_buf())
.or_insert_with(|| std::fs::read_to_string(path).ok().and_then(|t| parse_pom(&t)))
.or_insert_with(|| xml::read_text(path).as_deref().and_then(parse_pom))
.clone()
}
@@ -734,7 +736,7 @@ pub(crate) fn launch_inputs(
return None;
}
out.push(path.clone());
let text = std::fs::read_to_string(&path).ok()?;
let text = xml::read_text(&path)?;
roots(&xml::parse(&text)?, "", &dir, &mut out)?;
let raw = resolver.pom(&path)?;
let Some(parent) = raw.parent else { break };
@@ -1638,7 +1640,7 @@ fn parent_chain(path: &Path, text: &str, local_repo: Option<PathBuf>) -> Vec<(Pa
let Some(parent) = pom.and_then(|p| p.parent) else { return chain };
let Some(pp) = resolver.parent_path(&dir, &parent) else { return chain };
let pp = dunce::canonicalize(&pp).unwrap_or(pp);
let Ok(next) = std::fs::read_to_string(&pp) else { return chain };
let Some(next) = xml::read_text(&pp) else { return chain };
pom = resolver.pom(&pp);
dir = pp.parent().unwrap_or(Path::new("")).to_path_buf();
chain.push((pp, next));
@@ -2295,6 +2297,24 @@ mod tests {
assert!(!is_maven_project(&root.join("c")));
}
#[test]
fn non_utf8_pom_with_declared_encoding_is_a_build() {
// Issue #2439: a GBK `pom.xml` is not valid UTF-8; it must still load
// instead of vanishing as "No maven project".
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let (bytes, _, _) = encoding_rs::GBK.encode(
"<?xml version=\"1.0\" encoding=\"GBK\"?>\n<project><groupId>g</groupId>\
<artifactId>应用</artifactId><version>1</version><name>演示</name></project>",
);
std::fs::write(root.join("pom.xml"), bytes.as_ref()).unwrap();
let model = load_with_repo(root, None);
assert_eq!(model.projects.len(), 1);
assert_eq!(model.projects[0].artifact_id, "应用");
assert_eq!(model.projects[0].display_name, "演示");
assert!(is_maven_project(root));
}
#[test]
fn aggregate_pom_in_src_is_a_build() {
// Issue #1001: `src/pom.xml` aggregating modules under `src/` is recognized,
+124
Переглянути файл
@@ -0,0 +1,124 @@
//! On-demand sources resolution using the project's Maven launcher and repositories.
use super::MavenProject;
use crate::process;
use ide_core::{CancellationToken, IdeError, IdeResult};
use std::path::{Component, Path, PathBuf};
#[derive(Debug, PartialEq, Eq)]
pub struct Artifact {
pub group: String,
pub name: String,
pub version: String,
pub sources: PathBuf,
}
/// Resolve only jars inside the selected local repository, including custom repositories.
pub fn artifact(jar: &Path, repository: &Path) -> Option<Artifact> {
let relative = jar.strip_prefix(repository).ok()?;
let parts: Vec<&str> = relative
.components()
.map(|c| match c {
Component::Normal(s) => s.to_str(),
_ => None,
})
.collect::<Option<_>>()?;
if parts.len() < 4 {
return None;
}
let n = parts.len();
let (name, version, file) = (parts[n - 3], parts[n - 2], parts[n - 1]);
let valid = |s: &str| {
!s.is_empty() && s != "." && s != ".." && s.chars().all(|c| c.is_ascii_alphanumeric() || "_.-".contains(c))
};
if !parts[..n - 1].iter().all(|s| valid(s)) || file != format!("{name}-{version}.jar") {
return None;
}
Some(Artifact {
group: parts[..n - 3].join("."),
name: name.into(),
version: version.into(),
sources: jar.with_file_name(format!("{name}-{version}-sources.jar")),
})
}
pub fn download(
root: &Path,
project: &MavenProject,
artifact: &Artifact,
repository: &Path,
cancel: &CancellationToken,
) -> IdeResult<Option<PathBuf>> {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
if artifact.sources.is_file() {
return Ok(Some(artifact.sources.clone()));
}
let mut args = vec!["-B".into(), "-N".into(), "-Dstyle.color=never".into()];
args.extend(project.options.clone());
args.extend([
format!("-Dmaven.repo.local={}", repository.display()),
"org.apache.maven.plugins:maven-dependency-plugin:3.8.1:get".into(),
format!("-Dartifact={}:{}:{}:jar:sources", artifact.group, artifact.name, artifact.version),
"-Dtransitive=false".into(),
]);
let output = process::run(&project.command, &args, &root.join(&project.dir), &project.env, cancel, |_, _| {})?;
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
if artifact.sources.is_file() {
return Ok(Some(artifact.sources.clone()));
}
let coordinate = format!("{}:{}:jar:sources:{}", artifact.group, artifact.name, artifact.version);
if output.success
|| output.text.lines().any(|line| {
line.contains(&coordinate) && (line.contains("Could not find artifact") || line.contains("was not found"))
})
{
return Ok(None);
}
Err(IdeError::unavailable(
output.text.lines().rev().take(20).collect::<Vec<_>>().into_iter().rev().collect::<Vec<_>>().join("\n"),
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolves_sources_coordinates_from_maven_layouts() {
for repository in ["/home/me/.m2/repository", "/tmp/project repo", "C:/Users/me/.m2/repository"] {
for (group, name, version) in [
("org/apache/commons", "commons-lang3", "3.17.0"),
("com/example", "example-lib", "1.0-SNAPSHOT"),
("single", "lib", "1.0.0-rc.1"),
] {
let repository = Path::new(repository);
let dir = repository.join(group).join(name).join(version);
let found = artifact(&dir.join(format!("{name}-{version}.jar")), repository).unwrap();
assert_eq!(found.group, group.replace('/', "."));
assert_eq!(found.name, name);
assert_eq!(found.version, version);
assert_eq!(found.sources, dir.join(format!("{name}-{version}-sources.jar")));
}
}
}
#[test]
fn rejects_non_artifacts_and_paths_outside_the_repository() {
let repository = Path::new("/m2/repository");
for path in [
"/m2/repository-other/g/a/1/a-1.jar",
"/m2/repository/g/a/1/a-1-sources.jar",
"/m2/repository/g/a/1/other.jar",
"/m2/repository/g/a/../a-1.jar",
"/m2/repository/g/a/1/a-1.pom",
"/m2/repository/g/a-1.jar",
"/gradle/caches/modules-2/files-2.1/g/a/1/hash/a-1.jar",
] {
assert_eq!(artifact(Path::new(path), repository), None, "{path}");
}
}
}
+34
Переглянути файл
@@ -818,6 +818,40 @@ mod tests {
assert!(m.stale, "gradle.properties is watched");
}
#[test]
fn applied_script_edits_invalidate_the_cached_gradle_model() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("project");
let cache_dir = tmp.path().join("cache");
std::fs::create_dir_all(root.join("gradle")).unwrap();
std::fs::write(root.join("build.gradle"), "apply from: 'gradle/dependencies.gradle'").unwrap();
let applied = root.join("gradle/dependencies.gradle");
std::fs::write(&applied, "implementation 'g:a:1'").unwrap();
let model = gradle::load(&root).unwrap();
let cache = GradleCache {
hash: hash_gradle_files(&gradle::build_files(&root, &model)),
projects: vec![ReportedProject {
path: ":".into(),
dir: root.to_string_lossy().into_owned(),
..Default::default()
}],
..Default::default()
};
write_cache(&cache_dir, GRADLE_CACHE, &cache).unwrap();
let stale = || {
let mut model = gradle::load(&root).unwrap();
apply_gradle_cache(&root, &cache_dir, &mut model);
model.stale
};
assert!(!stale());
std::fs::write(&applied, "// comment\nimplementation 'g:a:1'\n").unwrap();
assert!(!stale());
std::fs::write(&applied, "implementation 'g:a:2'").unwrap();
assert!(stale());
std::fs::remove_file(&applied).unwrap();
assert!(stale());
}
#[test]
fn sync_reads_verbose_trees_with_a_pinned_plugin_unless_offline() {
let online = maven_sync_goals(Path::new("e.xml"), false);
+66
Переглянути файл
@@ -3,6 +3,57 @@
//! skipped. Namespace prefixes are dropped (`<x:project>` → `project`).
//! Elements keep their byte offsets in the source, for edits ([`crate::pom_edit`]).
use encoding_rs::Encoding;
use std::path::Path;
/// Reads an XML file, honoring its `encoding="…"` declaration: a GBK POM
/// (issue #2439) is not valid UTF-8, and `read_to_string` would drop the
/// whole project as "No maven project" while CLI Maven reads it fine.
pub fn read_text(path: &Path) -> Option<String> {
std::fs::read(path).ok().map(|bytes| decode_bytes(&bytes))
}
/// Decodes XML bytes: BOM first, then the `encoding` declaration (ASCII in
/// every single-byte encoding, so the head scans before decoding), then
/// UTF-8, then a chardetng guess.
pub fn decode_bytes(bytes: &[u8]) -> String {
if let Some((enc, len)) = Encoding::for_bom(bytes) {
let (text, _) = enc.decode_without_bom_handling(&bytes[len..]);
return text.into_owned();
}
if let Some(enc) = declared_encoding(bytes) {
let (text, _) = enc.decode_without_bom_handling(bytes);
return text.into_owned();
}
if let Ok(text) = std::str::from_utf8(bytes) {
return text.to_owned();
}
let mut detector = chardetng::EncodingDetector::new();
detector.feed(bytes, true);
let (text, _) = detector.guess(None, true).decode_without_bom_handling(bytes);
text.into_owned()
}
fn declared_encoding(bytes: &[u8]) -> Option<&'static Encoding> {
let head = std::str::from_utf8(bytes.get(..bytes.len().min(1024))?).ok()?;
let mut rest = head.split("?>").next().unwrap_or(head);
while let Some(i) = rest.find("encoding") {
rest = &rest[i + "encoding".len()..];
let Some(value) = rest.trim_start().strip_prefix('=') else { continue };
let value = value.trim_start();
let (&quote, after) = value.as_bytes().split_first()?;
if quote != b'"' && quote != b'\'' {
continue;
}
let after = std::str::from_utf8(after).ok()?;
let end = after.find(quote as char)?;
if let Some(enc) = Encoding::for_label(after[..end].trim().as_bytes()) {
return Some(enc);
}
}
None
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Element {
pub name: String,
@@ -210,4 +261,19 @@ mod tests {
assert!(parse("").is_none());
assert!(parse("just text").is_none());
}
#[test]
fn decodes_the_declared_encoding() {
// Issue #2439: a GBK POM is not valid UTF-8; decoding must honor the declaration.
let (bytes, _, _) =
encoding_rs::GBK.encode("<?xml version=\"1.0\" encoding=\"GBK\"?>\n<project><name>中文</name></project>");
let text = decode_bytes(&bytes);
assert_eq!(parse(&text).unwrap().text_of("name").as_deref(), Some("中文"));
// An unknown label falls back to UTF-8 rather than mis-decoding.
let text = decode_bytes(b"<?xml version=\"1.0\" encoding=\"X-UNKNOWN\"?>\n<project><name>ok</name></project>");
assert_eq!(parse(&text).unwrap().text_of("name").as_deref(), Some("ok"));
// No declaration: plain UTF-8 passes through untouched.
let text = decode_bytes("<project><name>plain</name></project>".as_bytes());
assert_eq!(parse(&text).unwrap().text_of("name").as_deref(), Some("plain"));
}
}
+3
Переглянути файл
@@ -11,3 +11,6 @@ license.workspace = true
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"] }
[target.'cfg(target_os = "linux")'.dependencies]
gtk = "0.18"
+60 -1
Переглянути файл
@@ -21,6 +21,12 @@
use std::path::PathBuf;
/// Replaces every clipboard flavor with plain text. `owner` is the calling window
/// handle on Windows, ignored elsewhere. Call on the main thread on macOS and Linux.
pub fn set_text(text: &str, owner: usize) -> std::io::Result<()> {
imp::set_text(text, owner)
}
/// Size of `DROPFILES`: `pFiles`, `pt` (two `LONG`s), `fNC`, `fWide`.
const HEADER: usize = 20;
@@ -165,8 +171,12 @@ mod imp {
/// Opens the clipboard as its owner: the list is written in full, so no
/// owning window is needed.
fn open() -> bool {
open_for(ptr::null_mut())
}
fn open_for(owner: Handle) -> bool {
for _ in 0..OPEN_ATTEMPTS {
if unsafe { OpenClipboard(ptr::null_mut()) } != 0 {
if unsafe { OpenClipboard(owner) } != 0 {
return true;
}
std::thread::sleep(OPEN_RETRY);
@@ -230,6 +240,29 @@ mod imp {
}
}
pub fn set_text(text: &str, owner: usize) -> std::io::Result<()> {
let (memory, _) = global_text(text);
if memory.is_null() {
return Err(std::io::Error::last_os_error());
}
// SAFETY: ownership transfers only on success; otherwise we free the allocation.
unsafe {
if !open_for(owner as Handle) {
GlobalFree(memory);
return Err(std::io::Error::last_os_error());
}
let taken = EmptyClipboard() != 0 && !SetClipboardData(CF_UNICODETEXT, memory).is_null();
let error = std::io::Error::last_os_error();
CloseClipboard();
if taken {
Ok(())
} else {
GlobalFree(memory);
Err(error)
}
}
}
pub fn set_files(paths: &[PathBuf]) -> bool {
let bytes = encode(paths);
if bytes.len() <= HEADER {
@@ -340,6 +373,19 @@ mod imp {
})
}
pub fn set_text(text: &str, _owner: usize) -> std::io::Result<()> {
debug_assert!(MainThreadMarker::new().is_some(), "NSPasteboard access requires the main thread");
autoreleasepool(|_| {
let board = NSPasteboard::generalPasteboard();
board.clearContents();
if board.setString_forType(&NSString::from_str(text), unsafe { NSPasteboardTypeString }) {
Ok(())
} else {
Err(std::io::Error::other("Cannot write clipboard text"))
}
})
}
pub fn set_files(paths: &[PathBuf]) -> bool {
debug_assert!(MainThreadMarker::new().is_some(), "NSPasteboard access requires the main thread");
autoreleasepool(|_| {
@@ -370,6 +416,19 @@ mod imp {
mod imp {
use std::path::PathBuf;
pub fn set_text(text: &str, _owner: usize) -> std::io::Result<()> {
#[cfg(target_os = "linux")]
{
gtk::Clipboard::get(&gtk::gdk::SELECTION_CLIPBOARD).set_text(text);
Ok(())
}
#[cfg(not(target_os = "linux"))]
{
let _ = text;
Err(std::io::Error::other("Clipboard text is unavailable on this platform"))
}
}
pub fn has_files() -> bool {
false
}
+1 -1
Переглянути файл
@@ -22,4 +22,4 @@ tempfile.workspace = true
libc = "0.2"
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Win32_Globalization", "Win32_Security", "Win32_System_Registry", "Win32_Foundation", "Win32_System_Diagnostics_ToolHelp", "Win32_System_Threading", "Win32_System_IO"] }
windows-sys = { version = "0.61", features = ["Wdk_System_SystemServices", "Win32_Foundation", "Win32_Globalization", "Win32_Security", "Win32_System_Diagnostics_ToolHelp", "Win32_System_IO", "Win32_System_JobObjects", "Win32_System_Registry", "Win32_System_SystemInformation", "Win32_System_Threading"] }
+12
Переглянути файл
@@ -20,6 +20,8 @@ pub enum McpUiRequest {
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
pub enum IdeEvent {
/// Project data source settings changed; reload the Database tree.
DbSourcesChanged,
McpRequest {
id: u32,
request: McpUiRequest,
@@ -40,6 +42,10 @@ pub enum IdeEvent {
created: Vec<crate::IpcPath>,
modified: Vec<crate::IpcPath>,
removed: Vec<crate::IpcPath>,
/// Subtrees whose ignore rules changed, after the matchers were rebuilt.
#[serde(default)]
#[specta(optional)]
ignore_rules: Vec<crate::IpcPath>,
},
/// Excluded or ignored folders whose contents changed; they stay out of `FsChanged`.
ExcludedDirsChanged {
@@ -146,6 +152,12 @@ pub enum IdeEvent {
SettingsChanged {
key: String,
},
UpdateStatusChanged {
status: crate::updates::UpdateStatus,
},
UpdateAvailable {
update: crate::updates::UpdateInfo,
},
/// The project's source / test / resource / excluded roots changed (build files
/// changed or folders were marked); the UI re-reads them with `project_roots`.
ProjectRootsChanged {
+128 -10
Переглянути файл
@@ -4,10 +4,12 @@ use std::collections::VecDeque;
use std::io::{self, Write};
use std::sync::{Arc, Condvar, Mutex};
use std::thread::JoinHandle;
#[cfg(any(not(unix), test))]
use std::time::Duration;
const MAX_BYTES: usize = 4 * 1024 * 1024;
const MAX_WRITES: usize = 1024;
#[cfg(any(not(unix), test))]
const RETRY: Duration = Duration::from_millis(4);
#[derive(Default)]
@@ -28,10 +30,11 @@ pub struct InputWriter {
}
impl InputWriter {
/// Unix writers must first be made nonblocking with `nonblocking`.
/// On Unix, pass the readiness hook returned by `nonblocking` for this writer.
pub fn new(
name: String,
mut writer: Box<dyn Write + Send>,
#[cfg(unix)] mut wait_writable: impl FnMut() -> io::Result<()> + Send + 'static,
on_error: impl FnOnce(io::Error) + Send + 'static,
) -> io::Result<Self> {
let shared = Arc::new((Mutex::new(State::default()), Condvar::new()));
@@ -74,11 +77,19 @@ impl InputWriter {
Ok(()) => {}
Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
Err(e) if e.kind() == io::ErrorKind::WouldBlock => {
let state = lock.lock().unwrap();
if state.aborted {
return Ok(());
#[cfg(unix)]
match wait_writable() {
Err(e) if e.kind() == io::ErrorKind::Interrupted => {}
result => result?,
}
#[cfg(not(unix))]
{
let state = lock.lock().unwrap();
if state.aborted {
return Ok(());
}
drop(wake.wait_timeout(state, RETRY).unwrap());
}
drop(wake.wait_timeout(state, RETRY).unwrap());
}
Err(e) => return Err(e),
}
@@ -168,14 +179,46 @@ impl Drop for InputWriter {
}
}
/// PTY clones share file status flags, so readers must also handle WouldBlock.
/// Make a writer nonblocking and return its readiness hook. PTY clones share file
/// status flags, so readers must also handle WouldBlock.
#[cfg(unix)]
pub fn nonblocking(fd: std::os::fd::RawFd) -> io::Result<()> {
pub fn nonblocking(fd: std::os::fd::RawFd) -> io::Result<impl FnMut() -> io::Result<()> + Send> {
let wait = readiness(fd, libc::POLLOUT)?;
let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) };
if flags == -1 || unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) } == -1 {
return Err(io::Error::last_os_error());
}
Ok(())
Ok(wait)
}
/// Wait for output from a nonblocking PTY without delaying the child's next read.
#[cfg(unix)]
pub fn readable(fd: std::os::fd::RawFd) -> io::Result<impl FnMut() -> io::Result<()> + Send> {
readiness(fd, libc::POLLIN)
}
#[cfg(unix)]
fn readiness(fd: std::os::fd::RawFd, events: libc::c_short) -> io::Result<impl FnMut() -> io::Result<()> + Send> {
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
// Own a duplicate so the hook remains valid if the caller closes its fd.
let duplicate = unsafe { libc::fcntl(fd, libc::F_DUPFD_CLOEXEC, 0) };
if duplicate == -1 {
return Err(io::Error::last_os_error());
}
// SAFETY: fcntl returned a new, owned descriptor.
let fd = unsafe { OwnedFd::from_raw_fd(duplicate) };
Ok(move || {
let mut pollfd = libc::pollfd { fd: fd.as_raw_fd(), events, revents: 0 };
// Readiness wakes immediately; the timeout only bounds abort latency.
if unsafe { libc::poll(&mut pollfd, 1, 50) } == -1 {
return Err(io::Error::last_os_error());
}
if pollfd.revents & libc::POLLNVAL != 0 {
return Err(io::Error::from_raw_os_error(libc::EBADF));
}
Ok(())
})
}
#[cfg(test)]
@@ -221,6 +264,11 @@ mod tests {
let writer = InputWriter::new(
"test-input".into(),
Box::new(SlowWriter { ready: ready.clone(), output: output.clone(), dropped: tx }),
#[cfg(unix)]
|| {
std::thread::sleep(RETRY);
Ok(())
},
|e| panic!("{e}"),
)
.unwrap();
@@ -243,6 +291,11 @@ mod tests {
let writer = InputWriter::new(
"test-input".into(),
Box::new(SlowWriter { ready: ready.clone(), output: output.clone(), dropped: tx }),
#[cfg(unix)]
|| {
std::thread::sleep(RETRY);
Ok(())
},
|e| panic!("{e}"),
)
.unwrap();
@@ -256,6 +309,26 @@ mod tests {
assert_eq!(*output.lock().unwrap(), format!("{paste}tail").as_bytes());
}
#[cfg(unix)]
#[test]
fn readiness_errors_are_reported_and_reject_following_writes() {
let (dropped, _rx) = mpsc::channel();
let (errors, error) = mpsc::channel();
let writer = InputWriter::new(
"test-error-input".into(),
Box::new(SlowWriter { ready: Arc::new(AtomicBool::new(false)), output: Arc::default(), dropped }),
|| Err(io::Error::from_raw_os_error(libc::EBADF)),
move |e| errors.send(e.raw_os_error()).unwrap(),
)
.unwrap();
writer.write(b"input").unwrap();
assert_eq!(error.recv_timeout(Duration::from_secs(1)).unwrap(), Some(libc::EBADF));
assert_eq!(writer.write(b"later").unwrap_err().kind(), io::ErrorKind::BrokenPipe);
let state = writer.shared.0.lock().unwrap();
assert!(state.queue.is_empty());
assert_eq!(state.bytes, 0);
}
#[test]
fn tiny_writes_are_also_bounded() {
let shared = Arc::new((Mutex::new(State::default()), Condvar::new()));
@@ -266,14 +339,59 @@ mod tests {
assert_eq!(writer.write(b"x").unwrap_err().kind(), io::ErrorKind::WouldBlock);
}
#[cfg(unix)]
#[test]
fn writable_input_resumes_without_timer_delays() {
use std::io::Read;
use std::os::fd::AsRawFd;
use std::os::unix::net::UnixStream;
let (pipe, mut peer) = UnixStream::pair().unwrap();
let capacity: libc::c_int = 1024;
// Keep the OS buffer small enough that a timer per buffer takes seconds.
assert_eq!(
unsafe {
libc::setsockopt(
pipe.as_raw_fd(),
libc::SOL_SOCKET,
libc::SO_SNDBUF,
(&capacity as *const libc::c_int).cast(),
std::mem::size_of_val(&capacity) as libc::socklen_t,
)
},
0
);
peer.set_read_timeout(Some(Duration::from_secs(3))).unwrap();
let wait_writable = nonblocking(pipe.as_raw_fd()).unwrap();
let writer =
InputWriter::new("test-ready-input".into(), Box::new(pipe), wait_writable, |e| panic!("{e}")).unwrap();
let bytes = vec![b'x'; MAX_BYTES];
writer.write(&bytes).unwrap();
writer.finish();
// Start with a full buffer, then make the fd writable from another thread.
std::thread::sleep(Duration::from_millis(50));
let (tx, rx) = mpsc::channel();
let started = Instant::now();
let reader = std::thread::spawn(move || {
let mut output = Vec::new();
peer.read_to_end(&mut output).unwrap();
tx.send(output).unwrap();
});
let output = rx.recv_timeout(Duration::from_secs(1)).expect("input resumes as soon as the fd is writable");
assert_eq!(output, bytes);
reader.join().unwrap();
eprintln!("4 MiB input drained in {:?}", started.elapsed());
}
#[cfg(unix)]
#[test]
fn close_interrupts_a_full_os_pipe_while_the_peer_stays_open() {
use std::os::fd::AsRawFd;
use std::os::unix::net::UnixStream;
let (pipe, _peer) = UnixStream::pair().unwrap();
nonblocking(pipe.as_raw_fd()).unwrap();
let mut writer = InputWriter::new("test-pipe-input".into(), Box::new(pipe), |e| panic!("{e}")).unwrap();
let wait_writable = nonblocking(pipe.as_raw_fd()).unwrap();
let mut writer =
InputWriter::new("test-pipe-input".into(), Box::new(pipe), wait_writable, |e| panic!("{e}")).unwrap();
writer.write(&vec![b'x'; MAX_BYTES]).unwrap();
std::thread::sleep(Duration::from_millis(50));
assert!(!writer.worker.as_ref().unwrap().is_finished());
+2
Переглянути файл
@@ -16,6 +16,7 @@ pub mod ipc_path;
pub mod java_runtime;
pub mod jsonc;
pub mod output_encoding;
pub mod platform;
pub mod process;
pub mod project_dir;
pub mod project_roots;
@@ -25,6 +26,7 @@ pub mod settings;
pub mod task;
pub mod text;
pub mod tool_path;
pub mod updates;
pub mod version_managers;
pub mod workflow_settings;
pub mod wsl;
+29
Переглянути файл
@@ -0,0 +1,29 @@
/// The Windows OS build used by ConPTY; unavailable on other platforms or on query failure.
#[cfg(windows)]
pub fn windows_build_number() -> Option<u32> {
use windows_sys::Wdk::System::SystemServices::RtlGetVersion;
use windows_sys::Win32::System::SystemInformation::OSVERSIONINFOW;
let mut info =
OSVERSIONINFOW { dwOSVersionInfoSize: std::mem::size_of::<OSVERSIONINFOW>() as u32, ..Default::default() };
// SAFETY: `info` is initialized and declares the size of the writable structure.
let status = unsafe { RtlGetVersion(&mut info) };
(status >= 0 && info.dwBuildNumber > 0).then_some(info.dwBuildNumber)
}
#[cfg(not(windows))]
pub fn windows_build_number() -> Option<u32> {
None
}
#[cfg(test)]
mod tests {
#[test]
fn windows_build_is_platform_specific() {
let build = super::windows_build_number();
#[cfg(windows)]
assert!(build.is_some_and(|build| build > 0));
#[cfg(not(windows))]
assert_eq!(build, None);
}
}
+277 -3
Переглянути файл
@@ -104,12 +104,23 @@ fn signal_group(child: &Child, signal: libc::c_int) {
#[cfg(windows)]
const TREE_EXIT_WAIT: Duration = Duration::from_secs(5);
#[cfg(windows)]
pub use windows_job::Job;
#[cfg(windows)]
pub use windows_tree::ProcessTree;
/// `taskkill /T /F` from `pid`, then waits (up to [`TREE_EXIT_WAIT`]) until what the child
/// started is gone: termination is asynchronous, and until a process is gone the files it
/// holds open can't be opened or deleted. The child itself is left to its owner to reap.
#[cfg(windows)]
fn taskkill_tree_of(pid: u32) {
let started = windows_tree::descendants(pid);
// When the child started nothing, killing it alone is the whole job:
// `taskkill` would be a process spawn (measured at ~0.7 s here) for the
// same outcome, and the callers kill the child themselves.
if started.is_empty() {
return;
}
let mut taskkill = Command::new("taskkill");
prepare(&mut taskkill)
.args(["/T", "/F", "/PID", &pid.to_string()])
@@ -130,13 +141,48 @@ mod windows_tree {
CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW, TH32CS_SNAPPROCESS,
};
use windows_sys::Win32::System::Threading::{
GetProcessTimes, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_SYNCHRONIZE, WaitForSingleObject,
GetProcessTimes, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_SYNCHRONIZE, PROCESS_TERMINATE,
TerminateProcess, WaitForSingleObject,
};
/// Owned handles to a program and its current descendants, retained even if its launcher exits.
pub struct ProcessTree {
processes: Vec<OwnedHandle>,
}
impl ProcessTree {
pub fn capture(pid: u32) -> Self {
let Some(root) = open_with(pid, PROCESS_TERMINATE) else { return Self { processes: Vec::new() } };
let children = descendants_with(pid, PROCESS_TERMINATE);
Self { processes: std::iter::once(root).chain(children).collect() }
}
pub fn is_running(&self) -> bool {
self.processes.iter().any(|process| {
// SAFETY: an owned process handle opened with SYNCHRONIZE access.
unsafe {
WaitForSingleObject(process.as_raw_handle(), 0) == windows_sys::Win32::Foundation::WAIT_TIMEOUT
}
})
}
/// Requests termination children first; poll `is_running` until termination completes.
pub fn terminate(&self) {
for process in self.processes.iter().rev() {
// SAFETY: an owned process handle opened with TERMINATE access, never a reused pid.
unsafe { TerminateProcess(process.as_raw_handle(), 1) };
}
}
}
/// Every process `root` started, directly or not, opened so it can be waited for. A
/// parent's id is reused once it is gone, so a process counts only if it started after
/// its parent.
pub(super) fn descendants(root: u32) -> Vec<OwnedHandle> {
descendants_with(root, 0)
}
fn descendants_with(root: u32, access: u32) -> Vec<OwnedHandle> {
let Some(root_started) = open(root).as_ref().and_then(started) else { return Vec::new() };
let processes = processes();
let mut seen = HashSet::from([root]);
@@ -147,7 +193,7 @@ mod windows_tree {
if ppid != parent || !seen.insert(pid) {
continue;
}
let Some(handle) = open(pid) else { continue };
let Some(handle) = open_with(pid, access) else { continue };
if let Some(at) = started(&handle).filter(|&at| at >= parent_started) {
parents.push((pid, at));
found.push(handle);
@@ -187,8 +233,12 @@ mod windows_tree {
}
fn open(pid: u32) -> Option<OwnedHandle> {
open_with(pid, 0)
}
fn open_with(pid: u32, access: u32) -> Option<OwnedHandle> {
// SAFETY: plain call; a non-null result is a process handle owned by the caller.
let handle = unsafe { OpenProcess(PROCESS_SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
let handle = unsafe { OpenProcess(PROCESS_SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION | access, 0, pid) };
// SAFETY: as above.
(!handle.is_null()).then(|| unsafe { OwnedHandle::from_raw_handle(handle) })
}
@@ -203,6 +253,167 @@ mod windows_tree {
}
}
/// A kernel job object over one launched process tree. Everything a launch starts joins
/// the job by inheritance, so [`Job::terminate`] ends the whole tree — also processes
/// started after the launch's snapshot was taken, orphans of a launcher that already
/// exited (where a `taskkill /T /F` from the tracked pid no longer finds them), and
/// processes born in the window a process-enumeration kill would miss. Spawns assign
/// their root right after it starts; when the assignment fails (an enclosing job that
/// disallows nesting, an already-exited pid), callers fall back to `taskkill /T /F`.
#[cfg(windows)]
mod windows_job {
use std::mem::size_of;
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle, RawHandle};
use windows_sys::Win32::Foundation::{ERROR_MORE_DATA, INVALID_HANDLE_VALUE};
use windows_sys::Win32::System::JobObjects::{
AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
JOBOBJECT_BASIC_LIMIT_INFORMATION, JOBOBJECT_BASIC_PROCESS_ID_LIST, JOBOBJECT_EXTENDED_LIMIT_INFORMATION,
JobObjectBasicProcessIdList, JobObjectExtendedLimitInformation, QueryInformationJobObject,
SetInformationJobObject, TerminateJobObject,
};
use windows_sys::Win32::System::Threading::{IO_COUNTERS, OpenProcess, PROCESS_SET_QUOTA, PROCESS_TERMINATE};
/// A job object holding one launch's processes. The handle is kept open for the
/// launch's lifetime: `KILL_ON_JOB_CLOSE` ends anything still in the job when the
/// launch record (a run entry, a debug session) is finally dropped.
pub struct Job(OwnedHandle);
impl Job {
/// Takes the just-started `child` into a new job, so everything it starts is in
/// there too. `None` when the job could not be made or the process rejected the
/// assignment (callers fall back to `taskkill /T /F` from the child's pid).
pub fn for_child(child: &std::process::Child) -> Option<Job> {
// SAFETY: the child's own handle is a live process handle.
Self::from_handle(child.as_raw_handle())
}
/// [`Job::for_child`] for a process known by its id (a PTY child, a follow-up run,
/// a `tokio` child). `None` when the id no longer names an openable process — a
/// caller that holds the child cannot have had it reaped, so this is a dead or
/// already-gone launch.
pub fn for_pid(pid: u32) -> Option<Job> {
// SAFETY: plain call; a non-null result is a process handle owned by the caller.
// Assignment needs `SET_QUOTA | TERMINATE` on the process.
let handle = unsafe { OpenProcess(PROCESS_SET_QUOTA | PROCESS_TERMINATE, 0, pid) };
if handle.is_null() {
return None;
}
// SAFETY: the handle was just opened and is owned here.
let handle = unsafe { OwnedHandle::from_raw_handle(handle) };
Self::from_handle(handle.as_raw_handle())
}
fn from_handle(handle: RawHandle) -> Option<Job> {
// SAFETY: plain calls; a non-null result is a job handle owned by the caller.
let job = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) };
if job.is_null() || job == INVALID_HANDLE_VALUE {
return None;
}
// SAFETY: the job handle is owned here and `limits` is sized as the API requires.
let job = unsafe { OwnedHandle::from_raw_handle(job) };
let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION {
BasicLimitInformation: JOBOBJECT_BASIC_LIMIT_INFORMATION {
PerProcessUserTimeLimit: 0,
PerJobUserTimeLimit: 0,
LimitFlags: JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
MinimumWorkingSetSize: 0,
MaximumWorkingSetSize: 0,
ActiveProcessLimit: 0,
Affinity: 0,
PriorityClass: 0,
SchedulingClass: 0,
},
IoInfo: IO_COUNTERS {
ReadOperationCount: 0,
WriteOperationCount: 0,
OtherOperationCount: 0,
ReadTransferCount: 0,
WriteTransferCount: 0,
OtherTransferCount: 0,
},
ProcessMemoryLimit: 0,
JobMemoryLimit: 0,
PeakProcessMemoryUsed: 0,
PeakJobMemoryUsed: 0,
};
// SAFETY: the job handle is owned here and `limits` is sized as the API requires.
let ok = unsafe {
SetInformationJobObject(
job.as_raw_handle(),
JobObjectExtendedLimitInformation,
&mut limits as *mut _ as *const _,
size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
)
};
if ok == 0 {
return None;
}
// SAFETY: the process handle belongs to the just-spawned child and outlives
// this call; the job handle is owned here.
let ok = unsafe { AssignProcessToJobObject(job.as_raw_handle(), handle) };
if ok == 0 {
return None;
}
Some(Job(job))
}
/// Adds another just-started root of the same launch to the job (a Go build's
/// follow-up run, a JVM a debug session starts itself). `false` when it did not
/// take; the process then relies on its own kill.
pub fn assign_pid(&self, pid: u32) -> bool {
// SAFETY: plain call; the handle is opened only to assign it, and the job
// handle is owned and valid.
let handle = unsafe { OpenProcess(PROCESS_SET_QUOTA | PROCESS_TERMINATE, 0, pid) };
if handle.is_null() {
return false;
}
// SAFETY: both handles are valid for the duration of the call.
let ok = unsafe { AssignProcessToJobObject(self.0.as_raw_handle(), handle) };
// SAFETY: the process handle was opened above and is owned here.
unsafe { drop(OwnedHandle::from_raw_handle(handle)) };
ok != 0
}
/// Terminates every process in the job. `false` when the call failed (the job
/// handle is invalid): callers then fall back to `taskkill /T /F`. Termination is
/// asynchronous; poll [`Job::is_running`] (bounded by [`TREE_EXIT_WAIT`]) until
/// the tree is gone.
pub fn terminate(&self) -> bool {
// SAFETY: a job handle owned here.
unsafe { TerminateJobObject(self.0.as_raw_handle(), 1) != 0 }
}
/// Whether any process of the launch is still in the job. When the job's state
/// cannot be read, reports gone: waits on this must not wedge, and a kill via
/// [`Job::terminate`] does not depend on the answer.
pub fn is_running(&self) -> bool {
// A list of one is the common case; `ERROR_MORE_DATA` says the real list is
// longer, which means there are processes.
let mut list = JOBOBJECT_BASIC_PROCESS_ID_LIST {
NumberOfAssignedProcesses: 0,
NumberOfProcessIdsInList: 0,
ProcessIdList: [0],
};
let mut returned = 0u32;
// SAFETY: the job handle is owned here and `list` is sized as the API requires.
let ok = unsafe {
QueryInformationJobObject(
self.0.as_raw_handle(),
JobObjectBasicProcessIdList,
&mut list as *mut _ as *mut _,
size_of::<JOBOBJECT_BASIC_PROCESS_ID_LIST>() as u32,
&mut returned,
)
};
if ok != 0 {
list.NumberOfProcessIdsInList > 0
} else {
std::io::Error::last_os_error().raw_os_error() == Some(ERROR_MORE_DATA as i32)
}
}
}
}
/// Gives `cmd` the host's environment back when the IDE runs from an AppImage. `AppRun` and
/// its hooks point `LD_LIBRARY_PATH`, `PATH`, `XDG_DATA_DIRS`, `GIO_MODULE_DIR`, `GTK_PATH`,
/// `GST_PLUGIN_*`, ... into the mounted image for the IDE itself; a host program that
@@ -908,3 +1119,66 @@ mod kill_tests {
assert!(started.elapsed() < Duration::from_secs(5));
}
}
#[cfg(all(test, windows))]
mod job_tests {
use super::*;
use std::os::windows::process::CommandExt;
use std::process::Stdio;
use std::time::Instant;
/// `cmd` that keeps a `ping` grandchild for a minute, like a launcher that started an
/// application: the job must reach both.
fn launch_tree() -> (std::process::Child, Job, ProcessTree) {
let mut cmd = Command::new("cmd");
cmd.args(["/D", "/S", "/C", "ping -n 60 127.0.0.1 >NUL"]).stdin(Stdio::null()).creation_flags(CREATE_NO_WINDOW);
let child = cmd.spawn().unwrap();
let job = Job::for_child(&child).expect("the job takes the launch");
let tree = ProcessTree::capture(child.id());
assert!(tree.is_running(), "cmd started a grandchild the job inherits");
(child, job, tree)
}
/// Termination is asynchronous: the job reports its processes gone while their
/// handles still wait, so wait on the handles (the reused-pid-proof oracle).
fn assert_gone(tree: &ProcessTree) {
let until = Instant::now() + Duration::from_secs(10);
while tree.is_running() {
assert!(Instant::now() < until, "the tree survived the job");
std::thread::sleep(Duration::from_millis(20));
}
}
#[test]
fn job_terminates_the_tree_the_root_started() {
let (mut child, job, tree) = launch_tree();
assert!(job.is_running());
assert!(job.terminate(), "TerminateJobObject succeeds");
// The captured tree's handles survive the root's death, so the wait below is the
// grandchild's, not a reused pid's.
assert_gone(&tree);
assert!(!job.is_running());
let _ = child.wait();
}
#[test]
fn a_later_spawned_root_joins_the_job() {
let (mut first, job, _) = launch_tree();
let mut cmd = Command::new("cmd");
cmd.args(["/D", "/S", "/C", "ping -n 60 127.0.0.1 >NUL"]).stdin(Stdio::null()).creation_flags(CREATE_NO_WINDOW);
let mut second = cmd.spawn().unwrap();
let second_tree = ProcessTree::capture(second.id());
assert!(job.assign_pid(second.id()), "the follow-up run joins the job");
assert!(job.terminate());
assert_gone(&second_tree);
let _ = first.wait();
let _ = second.wait();
}
#[test]
fn a_dead_pid_makes_no_job() {
// An id of a process this test cannot open (or that does not exist) gives no job,
// which is the callers' cue for the `taskkill /T /F` fallback.
assert!(Job::for_pid(u32::MAX).is_none());
}
}
+24
Переглянути файл
@@ -191,6 +191,30 @@ pub struct RunTarget {
pub image_user: bool,
}
/// A plugin-owned argv wrapper for project runs (API 1.4).
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct PluginRunTarget {
pub id: String,
pub name: String,
pub program: String,
pub args: Vec<String>,
pub workdir_args: Vec<String>,
pub container_root: Option<String>,
pub commands: Vec<PluginCommandOverride>,
}
/// Per-command argv prefix and optional replacement working-directory arguments.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct PluginCommandOverride {
pub name: String,
pub args: Vec<String>,
/// None inherits the target's list; Some([]) suppresses it.
#[serde(default)]
pub workdir_args: Option<Vec<String>>,
}
#[cfg(test)]
mod deployment_tests {
use super::*;
+249 -5
Переглянути файл
@@ -59,16 +59,16 @@ pub struct Settings {
/// Build, Execution, Deployment › Run Targets.
pub run_targets: Vec<RunTarget>,
pub cmake: CMakeSettings,
/// Tools › Agents › Chat.
/// Settings › Agents.
pub agents: AgentSettings,
/// Tools › MCP Server (global only).
/// Agents › MCP Server (global only).
pub mcp_server: McpServerSettings,
/// Plugin settings (`[contributes.settings]`): plugin id → its values by key.
#[specta(type = BTreeMap<String, BTreeMap<String, specta_typescript::Unknown>>)]
pub plugins: BTreeMap<String, serde_json::Map<String, serde_json::Value>>,
}
/// What the meters in an agent task's header show.
/// Settings › Agents: what the conversation shows and how messages are sent.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct AgentSettings {
@@ -80,15 +80,125 @@ pub struct AgentSettings {
pub show_usage_limits: bool,
/// What the session has cost so far, as the agent reports it.
pub show_cost: bool,
/// A finished turn's work folds into one line under its message, leaving the reply.
pub fold_turns: bool,
/// Streaming text is revealed at a steady pace rather than chunk by chunk.
pub smooth_text: bool,
#[serde(deserialize_with = "or_default")]
#[specta(type = ThinkingDisplay)]
pub thinking: ThinkingDisplay,
#[serde(deserialize_with = "or_default")]
#[specta(type = ToolCallDisplay)]
pub tool_calls: ToolCallDisplay,
/// A tool call's output (a command's, a diff) is shown without opening it.
pub expand_tool_output: bool,
#[serde(deserialize_with = "or_default")]
#[specta(type = SendKey)]
pub send_with: SendKey,
/// What sending does while the agent works: queue the message for after the turn, or
/// send it into the turn.
#[serde(deserialize_with = "or_default")]
#[specta(type = WhileRunning)]
pub while_running: WhileRunning,
/// Settings new tasks of an agent start with: agent id → option id → value.
#[serde(deserialize_with = "agent_defaults")]
#[specta(type = BTreeMap<String, BTreeMap<String, String>>)]
pub defaults: BTreeMap<String, BTreeMap<String, String>>,
}
impl Default for AgentSettings {
fn default() -> Self {
Self { show_context_usage: true, show_work_time: true, show_usage_limits: true, show_cost: false }
Self {
show_context_usage: true,
show_work_time: true,
show_usage_limits: true,
show_cost: false,
fold_turns: true,
smooth_text: true,
thinking: ThinkingDisplay::Auto,
tool_calls: ToolCallDisplay::Grouped,
expand_tool_output: false,
send_with: SendKey::Enter,
while_running: WhileRunning::Queue,
defaults: BTreeMap::new(),
}
}
}
/// Tools › MCP Server: agents use the IDE's built-in tools.
/// A hand-edited value these settings don't know reads as the default, rather than
/// failing the whole settings file.
fn or_default<'de, D, T>(d: D) -> Result<T, D::Error>
where
D: serde::Deserializer<'de>,
T: serde::de::DeserializeOwned + Default,
{
Ok(serde_json::from_value(serde_json::Value::deserialize(d)?).unwrap_or_default())
}
/// [`AgentSettings::defaults`], keeping only the text values of each agent's table.
fn agent_defaults<'de, D>(d: D) -> Result<BTreeMap<String, BTreeMap<String, String>>, D::Error>
where
D: serde::Deserializer<'de>,
{
let serde_json::Value::Object(agents) = serde_json::Value::deserialize(d)? else { return Ok(BTreeMap::new()) };
Ok(agents
.into_iter()
.filter_map(|(agent, options)| match options {
serde_json::Value::Object(options) => Some((
agent,
options
.into_iter()
.filter_map(|(k, v)| match v {
serde_json::Value::String(v) => Some((k, v)),
_ => None,
})
.collect(),
)),
_ => None,
})
.collect())
}
/// How the agent's reasoning shows.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum ThinkingDisplay {
/// Open while it streams, folded once it ends.
#[default]
Auto,
Expanded,
Collapsed,
}
/// How tool calls between messages show.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum ToolCallDisplay {
/// A run of calls is one line saying what they did ("Read 4 files · Edited 2 files").
#[default]
Grouped,
/// Each call on its own line.
Each,
}
/// The key that sends a message; the other one starts a new line.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum SendKey {
#[default]
Enter,
ModEnter,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum WhileRunning {
#[default]
Queue,
Steer,
}
/// Agents › MCP Server: agents use the IDE's built-in tools.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct McpServerSettings {
@@ -266,6 +376,10 @@ pub struct CustomKeymap {
pub struct GeneralSettings {
pub reopen_last_project: bool,
pub confirm_exit: bool,
/// When on, the last window closes instead of showing the Welcome screen.
pub close_without_welcome: bool,
/// When on, closing a window drops its saved editor tabs, so they are not restored next time.
pub close_tabs_with_window: bool,
/// Where Open… puts a project while one is open: `ask` | `new` | `same` | `attach`.
pub open_project_in: String,
/// Initial folder of the Open / Clone dialogs; empty = the OS default.
@@ -281,6 +395,7 @@ pub struct GeneralSettings {
pub project_name: String,
/// Where updates come from: `auto` (the CNB mirror for the Chinese UI, else GitHub) | `github` | `cnb`.
pub update_source: String,
pub auto_check_updates: bool,
}
impl Default for GeneralSettings {
@@ -288,6 +403,8 @@ impl Default for GeneralSettings {
Self {
reopen_last_project: true,
confirm_exit: true,
close_without_welcome: false,
close_tabs_with_window: false,
open_project_in: "ask".into(),
default_project_directory: String::new(),
save_on_idle: false,
@@ -296,6 +413,7 @@ impl Default for GeneralSettings {
power_save_mode: false,
project_name: String::new(),
update_source: "auto".into(),
auto_check_updates: true,
}
}
}
@@ -350,6 +468,21 @@ impl Default for NotificationGroupSettings {
}
}
/// Where the Navigation Bar (the file path breadcrumbs) is shown. Mirrors
/// IDEA's `View | Appearance | Navigation Bar` (Top / Status Bar / Don't show).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum NavigationBarPosition {
/// Above the editor, below the main menu / toolbar.
Top,
/// In the footer, next to the status bar widgets — the default, matching
/// where the bar lived before the three-state choice existed.
#[default]
StatusBar,
/// Not shown at all.
Hidden,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct AppearanceSettings {
@@ -398,6 +531,9 @@ pub struct AppearanceSettings {
/// View › Appearance › Toolbar; hidden leaves IDEA's compact window header.
pub show_main_toolbar: bool,
pub show_status_bar: bool,
/// View › Appearance › Navigation Bar: the file path breadcrumbs, shown in
/// the status bar by default, above the editor, or not at all.
pub navigation_bar: NavigationBarPosition,
/// Status bar widgets shown (`true`) or hidden (`false`) by the user
/// (status bar context menu); widgets not listed use their default.
pub status_bar_widgets: BTreeMap<String, bool>,
@@ -470,6 +606,7 @@ impl Default for AppearanceSettings {
widescreen_tool_windows: false,
show_main_toolbar: true,
show_status_bar: true,
navigation_bar: NavigationBarPosition::StatusBar,
status_bar_widgets: BTreeMap::new(),
main_toolbar: None,
}
@@ -507,6 +644,9 @@ pub struct EditorSettings {
/// Color Scheme › Language Defaults › Semantic highlighting: local variables and
/// parameters get a colour derived from their name (IDEA's rainbow identifiers).
pub semantic_highlighting: bool,
/// Docstring markup format whose tags and keywords the Python editor highlights
/// (`plain`, `rest`, `epytext`, `google`, `numpy`).
pub docstring_format: String,
/// Color Scheme › Language Defaults › Rainbow brackets: `()`, `[]`, `{}` coloured by depth.
pub rainbow_brackets: bool,
pub ligatures: bool,
@@ -551,6 +691,7 @@ impl Default for EditorSettings {
color_overrides: BTreeMap::new(),
custom_schemes: BTreeMap::new(),
semantic_highlighting: false,
docstring_format: "rest".into(),
rainbow_brackets: false,
ligatures: false,
fallback_font: String::new(),
@@ -595,6 +736,15 @@ pub struct FileSettings {
pub file_types: BTreeMap<String, FileTypeAssociation>,
/// Override File Type: file (project-relative and `/`-separated, or absolute) → language id.
pub file_type_overrides: BTreeMap<String, String>,
/// Compose detection choices, keyed by project-relative file path.
pub compose_file_overrides: BTreeMap<String, ComposeFileOverride>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum ComposeFileOverride {
Kept,
Excluded,
}
/// One language's file name and hashbang patterns on top of its definition.
@@ -622,6 +772,7 @@ impl Default for FileSettings {
local_history_max_mb: 512,
file_types: BTreeMap::new(),
file_type_overrides: BTreeMap::new(),
compose_file_overrides: BTreeMap::new(),
}
}
}
@@ -629,6 +780,8 @@ impl Default for FileSettings {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct LanguagesSettings {
/// Explicit plugin formatter, by language id. Empty entries use the normal formatter policy.
pub plugin_formatters: BTreeMap<String, String>,
pub python: PythonSettings,
pub node: NodeSettings,
pub php: PhpSettings,
@@ -641,11 +794,13 @@ pub struct LanguagesSettings {
pub java: JavaSettings,
pub ruby: RubySettings,
pub markdown: MarkdownSettings,
pub arduino: ArduinoSettings,
}
impl Default for LanguagesSettings {
fn default() -> Self {
Self {
plugin_formatters: BTreeMap::new(),
python: PythonSettings::default(),
node: NodeSettings::default(),
php: PhpSettings::default(),
@@ -661,10 +816,22 @@ impl Default for LanguagesSettings {
java: JavaSettings::default(),
ruby: RubySettings::default(),
markdown: MarkdownSettings::default(),
arduino: ArduinoSettings::default(),
}
}
}
/// Arduino tool discovery paths and default board FQBN, configured in `settings.toml`.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct ArduinoSettings {
pub cli: String,
pub clangd: String,
pub language_server: String,
pub cli_config: String,
pub fqbn: String,
}
/// Languages & Frameworks › Markdown. Like IDEA's, it is application-level only.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
@@ -767,6 +934,8 @@ impl Default for PythonSettings {
pub struct PhpSettings {
/// `php` CLI executable; empty = the one on `PATH`.
pub interpreter: String,
/// Run PHP/Composer or Node/package-manager commands in this target; empty = project default, `local` = local.
pub run_target: String,
/// PHP version the language server checks the code against (`7.4`); empty = from
/// `composer.json` (`config.platform.php`, else the lowest version `require.php` allows).
pub language_level: String,
@@ -794,6 +963,8 @@ pub struct RubySettings {
pub struct NodeSettings {
/// `node` executable; empty = the one on `PATH`.
pub interpreter: String,
/// Run PHP/Composer or Node/package-manager commands in this target; empty = project default, `local` = local.
pub run_target: String,
/// `npm` | `pnpm` | `yarn`; empty = detect from the lock file / `packageManager`.
pub package_manager: String,
}
@@ -878,6 +1049,8 @@ pub struct JavaSettings {
/// Extra JVM options for the Java language server (jdtls), space-separated
/// (VS Code's `java.jdt.ls.vmargs`).
pub jdtls_vm_args: String,
/// Share one JDT LS across builds in an ordinary project window; other windows remain separate.
pub jdtls_shared: bool,
/// Build Tools › Maven.
pub maven: MavenSettings,
/// Build Tools › Gradle.
@@ -1161,6 +1334,26 @@ impl Settings {
mod tests {
use super::*;
#[test]
fn window_close_settings_default_off_and_round_trip() {
for old in [r#"{}"#, r#"{"general":{"confirmExit":false}}"#] {
let settings: Settings = serde_json::from_str(old).unwrap();
assert!(!settings.general.close_without_welcome);
assert!(!settings.general.close_tabs_with_window);
}
for close_window in [false, true] {
for close_tabs in [false, true] {
let mut settings = Settings::default();
settings.general.close_without_welcome = close_window;
settings.general.close_tabs_with_window = close_tabs;
let json = serde_json::to_value(&settings).unwrap();
assert_eq!(json["general"]["closeWithoutWelcome"], close_window);
assert_eq!(json["general"]["closeTabsWithWindow"], close_tabs);
assert_eq!(serde_json::from_value::<Settings>(json).unwrap().general, settings.general);
}
}
}
#[test]
fn cmake_profile_argument_arrays_round_trip() {
let mut settings = Settings::default();
@@ -1223,6 +1416,21 @@ mod tests {
}
}
#[test]
fn docstring_format_defaults_and_round_trips() {
let old: Settings = serde_json::from_str(r#"{"editor":{"semanticHighlighting":true}}"#).unwrap();
assert_eq!(old.editor.docstring_format, "rest");
assert_eq!(Settings::default().editor.docstring_format, "rest");
for format in ["plain", "rest", "epytext", "google", "numpy"] {
let mut settings = Settings::default();
settings.editor.docstring_format = format.into();
let json = serde_json::to_value(&settings).unwrap();
assert_eq!(json["editor"]["docstringFormat"], format);
let restored: Settings = serde_json::from_value(json).unwrap();
assert_eq!(restored.editor.docstring_format, format);
}
}
#[test]
fn tool_window_badges_default_on_and_round_trip() {
let old: Settings = serde_json::from_str(r#"{"appearance":{"showToolWindowNames":true}}"#).unwrap();
@@ -1252,6 +1460,22 @@ mod tests {
assert_eq!(restored.agents, settings.agents);
}
#[test]
fn hand_edited_agent_values_it_does_not_know_keep_the_rest() {
let settings: Settings = serde_json::from_str(
r#"{"editor":{"fontSize":17},"agents":{"thinking":"open","sendWith":"modEnter",
"defaults":{"claude":{"model":"opus","effort":3},"codex":"x"}}}"#,
)
.unwrap();
assert_eq!(settings.editor.font_size, 17.0);
assert_eq!(settings.agents.thinking, ThinkingDisplay::Auto);
assert_eq!(settings.agents.send_with, SendKey::ModEnter);
assert_eq!(
settings.agents.defaults,
BTreeMap::from([("claude".into(), BTreeMap::from([("model".into(), "opus".into())]))])
);
}
#[test]
fn main_menu_style_defaults_and_round_trips() {
let old: Settings = serde_json::from_str(r#"{"appearance":{"compactMode":true}}"#).unwrap();
@@ -1459,6 +1683,26 @@ mod maven_linkage_tests {
}
}
#[cfg(test)]
mod java_settings_tests {
use super::*;
#[test]
fn shared_jdtls_defaults_off_and_round_trips() {
for old in [r#"{}"#, r#"{"jdk":"/jdk"}"#] {
let java: JavaSettings = serde_json::from_str(old).unwrap();
assert!(!java.jdtls_shared);
}
for shared in [false, true] {
let java = JavaSettings { jdtls_shared: shared, ..JavaSettings::default() };
let json = serde_json::to_value(&java).unwrap();
assert_eq!(json["jdtlsShared"], serde_json::json!(shared));
assert_eq!(serde_json::from_value::<JavaSettings>(json).unwrap(), java);
}
assert!(serde_json::from_str::<JavaSettings>(r#"{"jdtlsShared":"true"}"#).is_err());
}
}
#[cfg(test)]
mod gradle_settings_tests {
use super::*;
+151
Переглянути файл
@@ -0,0 +1,151 @@
use serde::{Deserialize, Serialize};
use specta::Type;
use std::collections::HashSet;
use std::time::{Duration, Instant};
pub const CHECK_INTERVAL: Duration = Duration::from_secs(4 * 60 * 60);
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum UpdateSource {
#[default]
Github,
Cnb,
}
impl UpdateSource {
pub fn resolve(preference: &str, locale: &str, system_locale: &str) -> Self {
match preference {
"github" => return Self::Github,
"cnb" => return Self::Cnb,
_ => {}
}
let locale = locale.trim();
let locale = if matches!(locale, "" | "auto" | "system") { system_locale } else { locale };
if locale.split(['-', '_']).next().is_some_and(|base| base.eq_ignore_ascii_case("zh")) {
Self::Cnb
} else {
Self::Github
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct UpdateInfo {
pub version: String,
pub current_version: String,
pub notes: Option<String>,
pub date: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct UpdateCheck {
pub enabled: bool,
pub update: Option<UpdateInfo>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct UpdateStatus {
/// Unix milliseconds of the last successful check in this process.
pub last_checked: Option<f64>,
pub checking: bool,
}
/// One schedule and announcement history for the desktop process, independent of its windows.
#[derive(Default)]
pub struct UpdatePolicy {
attempted: Option<Instant>,
pub status: UpdateStatus,
announced: HashSet<String>,
}
impl UpdatePolicy {
pub fn due_in(&self, now: Instant) -> Duration {
self.attempted.map_or(Duration::ZERO, |at| CHECK_INTERVAL.saturating_sub(now.saturating_duration_since(at)))
}
pub fn begin(&mut self, now: Instant) {
self.attempted = Some(now);
self.status.checking = true;
}
pub fn finish(&mut self, checked_at: Option<f64>) {
self.status.checking = false;
if let Some(at) = checked_at {
self.status.last_checked = Some(at);
}
}
pub fn announced(&self, version: &str) -> bool {
self.announced.contains(version)
}
/// Manual checks always show their result, but also count as an announcement.
pub fn announce(&mut self, version: &str) -> bool {
self.announced.insert(version.to_owned())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn automatic_and_manual_checks_resolve_the_same_preferred_source() {
for (preference, locale, system, expected) in [
("auto", "zh-CN", "en-US", UpdateSource::Cnb),
("auto", " ZH_CN ", "en-US", UpdateSource::Cnb),
("auto", "system", "zh-CN", UpdateSource::Cnb),
("auto", "auto", "zh-TW", UpdateSource::Cnb),
("auto", "", "zh", UpdateSource::Cnb),
("auto", "en", "zh-CN", UpdateSource::Github),
("github", "zh", "zh-CN", UpdateSource::Github),
("cnb", "en", "en-US", UpdateSource::Cnb),
] {
assert_eq!(UpdateSource::resolve(preference, locale, system), expected);
}
}
#[test]
fn checks_at_startup_and_every_four_hours_even_after_failure() {
let mut policy = UpdatePolicy::default();
let now = Instant::now();
assert_eq!(policy.due_in(now), Duration::ZERO);
policy.begin(now);
assert!(policy.status.checking);
policy.finish(None);
assert_eq!(policy.status.last_checked, None);
assert_eq!(policy.due_in(now + CHECK_INTERVAL - Duration::from_secs(1)), Duration::from_secs(1));
assert_eq!(policy.due_in(now + CHECK_INTERVAL), Duration::ZERO);
policy.begin(now + CHECK_INTERVAL);
policy.finish(Some(1234.0));
assert_eq!(policy.status.last_checked, Some(1234.0));
assert!(!policy.status.checking);
assert_eq!(policy.due_in(now + CHECK_INTERVAL), CHECK_INTERVAL);
}
#[test]
fn manual_checks_reset_the_schedule_without_losing_last_success_on_failure() {
let mut policy = UpdatePolicy::default();
let now = Instant::now();
policy.begin(now);
policy.finish(Some(1000.0));
policy.begin(now + Duration::from_secs(60));
policy.finish(None);
assert_eq!(policy.status.last_checked, Some(1000.0));
assert_eq!(policy.due_in(now + CHECK_INTERVAL), Duration::from_secs(60));
}
#[test]
fn announcements_are_per_version_across_windows_sources_and_manual_checks() {
let mut policy = UpdatePolicy::default();
assert!(policy.announce("0.1.40"));
assert!(!policy.announce("0.1.40"));
assert!(policy.announce("0.1.41"));
assert!(!policy.announce("0.1.40"));
assert!(UpdatePolicy::default().announce("0.1.40"));
}
}
+9 -1
Переглянути файл
@@ -272,6 +272,8 @@ impl Default for PythonDebuggerSettings {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct RunSettings {
/// Target for configurations without an explicit target. Empty = local.
pub default_target: String,
pub confirm_rerun: bool,
/// Ask before closing a Run tab whose process is still running.
pub confirm_terminate: bool,
@@ -282,7 +284,13 @@ pub struct RunSettings {
impl Default for RunSettings {
fn default() -> Self {
Self { confirm_rerun: true, confirm_terminate: true, show_on_start: true, termination_timeout_ms: 3000 }
Self {
default_target: String::new(),
confirm_rerun: true,
confirm_terminate: true,
show_on_start: true,
termination_timeout_ms: 3000,
}
}
}
+342 -82
Переглянути файл
@@ -101,6 +101,14 @@ fn builtin(adapter: &str) -> Option<DebuggerConfig> {
"lldb-dap" => {
cfg("lldb-dap", &[], "stdio", "Install LLVM's lldb-dap (brew install llvm; on macOS `xcrun -f lldb-dap`)")
}
// GDB's own DAP interpreter, stdio transport. The DAP support it needs shipped in GDB 14
// (`prepare_gdb` refuses older ones with the required version in the message).
"gdb" => cfg(
"gdb",
&["-i", "dap"],
"stdio",
"Install GDB 14 or newer (apt install gdb, dnf install gdb, brew install gdb)",
),
"rdbg" => cfg("rdbg", &["-O", "--port", PORT_PLACEHOLDER, "-c"], "tcp", "gem install debug"),
// A jdtls bundle: the session is started through jdtls and reached via `debugServer`.
"java-debug" => cfg(
@@ -127,6 +135,7 @@ pub fn default_type(adapter: &str) -> Option<&'static str> {
"delve" => "go",
"netcoredbg" => "coreclr",
"lldb-dap" => "lldb-dap",
"gdb" => "gdb",
"java-debug" => "java",
_ => return None,
})
@@ -166,6 +175,11 @@ pub fn parse_address(addr: &str) -> Option<(String, u16)> {
}
/// A port that was free a moment ago on the loopback interface.
///
/// Inherently racy: another process can bind the port between the listener being
/// dropped here and the adapter binding it. Callers that hand the port to a child
/// retry on a bind conflict (see [`ProcessLauncher`]), or — better — let the child
/// pick `:0` and report the actual port back (as the Kotlin JDWP debuggee does).
pub fn free_port() -> std::io::Result<u16> {
let listener = std::net::TcpListener::bind(("127.0.0.1", 0))?;
Ok(listener.local_addr()?.port())
@@ -327,6 +341,89 @@ pub async fn prepare_debugpy(spec: &mut AdapterSpec) -> IdeResult<()> {
)))
}
/// GDB's DAP interpreter (`-i dap`) shipped in GDB 14; older ones exit with
/// "Interpreter `dap' unrecognized".
const GDB_MIN_MAJOR: u32 = 14;
/// `(major, minor)` of the first version number in the output of `gdb --version`
/// (`GNU gdb (GDB) 14.2`, `GNU gdb (Ubuntu 12.1-0ubuntu1~22.04.2) 12.1`, `GNU gdb
/// (GDB) 15.0.50.20240403-0ubuntu1`). `None` when no standalone version number is there.
pub fn parse_gdb_version(output: &str) -> Option<(u32, u32)> {
let line = output.lines().next()?;
// Distribution names can contain versions, e.g. `(GDB; SUSE Linux Enterprise 15) 14.2`.
let line = line.rsplit_once(')').map_or(line, |(_, version)| version);
let b = line.as_bytes();
let mut i = 0;
while i < b.len() {
if !b[i].is_ascii_digit() {
i += 1;
continue;
}
let start = i;
while i < b.len() && b[i].is_ascii_digit() {
i += 1;
}
let major: u32 = line[start..i].parse().ok()?;
let mut minor = 0;
if i + 1 < b.len() && b[i] == b'.' && b[i + 1].is_ascii_digit() {
i += 1;
let ms = i;
while i < b.len() && b[i].is_ascii_digit() {
i += 1;
}
minor = line[ms..i].parse().unwrap_or(0);
}
// A version stands alone: `mingw32-gdb13.exe`'s embedded digits are part of a word.
if start > 0 && (b[start - 1].is_ascii_alphabetic() || b[start - 1] == b'_') {
continue;
}
return Some((major, minor));
}
None
}
/// Probe the already resolved and verified executable with its launch environment.
async fn gdb_version(binary: &Path, spec: &AdapterSpec) -> IdeResult<Option<(u32, u32)>> {
let mut probe = Command::new(binary);
if let Some(path) = adapter_path(&spec.env) {
probe.env("PATH", path);
}
ide_core::process::prepare(probe.as_std_mut());
let probe = probe
.arg("--version")
.current_dir(&spec.cwd)
.envs(spec.env.iter().cloned())
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.kill_on_drop(true)
.output();
let Ok(Ok(out)) = tokio::time::timeout(Duration::from_secs(15), probe).await else {
return Ok(None);
};
Ok(parse_gdb_version(&String::from_utf8_lossy(&out.stdout)))
}
/// Refuse old GDB versions and report whether launch must follow configurationDone.
/// GDB 15 starts the inferior in launch; 14 and 16+ start it at configurationDone.
async fn prepare_gdb(spec: &AdapterSpec, binary: &Path) -> IdeResult<bool> {
let Some((major, minor)) = gdb_version(binary, spec).await? else {
return Ok(false);
};
if major >= GDB_MIN_MAJOR {
return Ok(major == 15);
}
let mut msg = format!(
"debugging with GDB needs GDB {GDB_MIN_MAJOR} or newer (found {major}.{minor}): \
this one has no DAP interpreter (`gdb -i dap`)"
);
if let Some(hint) = &spec.install_hint {
msg.push_str(". ");
msg.push_str(hint);
}
Err(IdeError::unavailable(msg))
}
/// An adapter that is a plugin of another program (java-debug, a jdtls bundle) rather
/// than a program: it has no process of its own and is reached through `debugServer`.
pub fn is_plugin_bundle(command: &str) -> bool {
@@ -360,6 +457,8 @@ pub type BoxWriter = Box<dyn AsyncWrite + Unpin + Send>;
/// A connected adapter: byte streams plus the process (if we spawned one).
pub struct AdapterIo {
/// GDB 15 starts running inside `launch`; configure breakpoints before sending it.
pub launch_after_configuration: bool,
pub reader: BoxReader,
pub writer: BoxWriter,
pub child: Option<Child>,
@@ -394,6 +493,9 @@ const CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
const EMBEDDED_START_TIMEOUT: Duration = Duration::from_secs(120);
/// Probe of a `connect` address before starting the command.
const PROBE_TIMEOUT: Duration = Duration::from_millis(500);
/// Attempts at a fresh port for a TCP adapter: the one `free_port` hands out can be
/// claimed by another process before the adapter binds it, so a new one usually wins.
const PORT_ATTEMPTS: u32 = 3;
impl AdapterLauncher for ProcessLauncher {
fn launch(&self, mut spec: AdapterSpec, reuse_port: Option<u16>) -> LaunchFuture {
@@ -408,6 +510,7 @@ impl AdapterLauncher for ProcessLauncher {
let stream = connect_with_retries(host, port, None, timeout).await?;
let (r, w) = stream.into_split();
return Ok(AdapterIo {
launch_after_configuration: false,
reader: Box::new(r),
writer: Box::new(w),
child: None,
@@ -431,6 +534,7 @@ impl AdapterLauncher for ProcessLauncher {
let _ = stream.set_nodelay(true);
let (r, w) = stream.into_split();
return Ok(AdapterIo {
launch_after_configuration: false,
reader: Box::new(r),
writer: Box::new(w),
child: None,
@@ -459,95 +563,152 @@ impl AdapterLauncher for ProcessLauncher {
.ok_or_else(|| missing(&spec))?
}
};
let port = match spec.transport {
// An embedded adapter started without `${port}` listens on its usual address.
Transport::Tcp => match &embedded {
Some((_, port)) if !spec.args.iter().any(|a| a.contains(PORT_PLACEHOLDER)) => Some(*port),
_ => Some(free_port()?),
},
Transport::Stdio => None,
};
let args = substitute_root(&spec.args, &spec.cwd);
let args = match port {
Some(port) => substitute_port(&args, port),
None => args,
};
let mut cmd = Command::new(&binary);
if let Some(path) = adapter_path(&spec.env) {
cmd.env("PATH", path);
}
ide_core::process::prepare(cmd.as_std_mut());
if spec.adapter == "rdbg" {
ide_core::process::own_group(cmd.as_std_mut());
}
cmd.args(&args)
.envs(spec.env.iter().map(|(k, v)| (k, v)))
.current_dir(&spec.cwd)
.stdin(if port.is_some() { Stdio::null() } else { Stdio::piped() })
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
let mut child =
cmd.spawn().map_err(|e| IdeError::unavailable(format!("failed to start {}: {e}", binary.display())))?;
let (program_sink, program_output) = if SHARES_STDIO.contains(&spec.adapter.as_str()) {
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
(Some(tx), Some(rx))
} else {
(None, None)
};
if let Some(err) = child.stderr.take() {
pump_lines(err, spec.adapter.clone(), stderr.clone(), program_sink.clone().map(|tx| (tx, "stderr")));
}
match port {
None => {
let stdin = child.stdin.take().ok_or_else(|| IdeError::internal("adapter stdin unavailable"))?;
let stdout = child.stdout.take().ok_or_else(|| IdeError::internal("adapter stdout unavailable"))?;
Ok(AdapterIo {
reader: Box::new(stdout),
writer: Box::new(stdin),
child: Some(child),
port: None,
stderr,
program_output,
})
let launch_after_configuration =
if spec.adapter == "gdb" { prepare_gdb(&spec, &binary).await? } else { false };
let base_args = substitute_root(&spec.args, &spec.cwd);
match spec.transport {
Transport::Stdio => {
let mut io = spawn_adapter(&spec, &binary, &base_args, None, CONNECT_TIMEOUT).await?;
io.launch_after_configuration = launch_after_configuration;
Ok(io)
}
Some(port) => {
if let Some(out) = child.stdout.take() {
pump_lines(out, spec.adapter.clone(), stderr.clone(), program_sink.map(|tx| (tx, "stdout")));
}
let timeout = if embedded.is_some() { EMBEDDED_START_TIMEOUT } else { CONNECT_TIMEOUT };
let stream = match connect_with_retries(LOOPBACK, port, Some(&mut child), timeout).await {
Ok(s) => s,
Err(e) => {
if spec.adapter == "rdbg"
&& let Some(pid) = child.id()
{
ide_core::process::kill_tree_of(pid);
}
let _ = child.start_kill();
let tail = stderr.lock().join("\n");
return Err(if tail.is_empty() {
e
} else {
IdeError::unavailable(format!("{}\n{tail}", e.message))
});
}
Transport::Tcp => {
// An embedded adapter started without `${port}` listens on its usual
// address; every other TCP adapter gets a port chosen here so it can be
// retried if another process takes it before the adapter binds.
let fixed = match &embedded {
Some((_, port)) if !spec.args.iter().any(|a| a.contains(PORT_PLACEHOLDER)) => Some(*port),
_ => None,
};
let (r, w) = stream.into_split();
Ok(AdapterIo {
reader: Box::new(r),
writer: Box::new(w),
child: Some(child),
port: Some(port),
stderr,
program_output,
})
let start_timeout = if embedded.is_some() { EMBEDDED_START_TIMEOUT } else { CONNECT_TIMEOUT };
for attempt in 1..=PORT_ATTEMPTS {
let port = match fixed {
Some(port) => port,
None => free_port()?,
};
let args = substitute_port(&base_args, port);
match spawn_adapter(&spec, &binary, &args, Some(port), start_timeout).await {
Ok(mut io) => {
io.launch_after_configuration = launch_after_configuration;
return Ok(io);
}
Err(e) => {
if fixed.is_none() && attempt < PORT_ATTEMPTS && is_port_race(&e) {
tracing::warn!(
target: "ide_dap::adapter",
"debug adapter failed to start on port {port}: {}; retrying",
e.message
);
continue;
}
return Err(e);
}
}
}
unreachable!("the retry loop returns on its last attempt")
}
}
})
}
}
/// Starts `binary` with `args`, then connects to it when `port` is set (a TCP adapter)
/// or hands back its stdio when it is not. The child is stopped if it never accepts.
async fn spawn_adapter(
spec: &AdapterSpec,
binary: &Path,
args: &[String],
port: Option<u16>,
start_timeout: Duration,
) -> IdeResult<AdapterIo> {
let stderr = Arc::new(parking_lot::Mutex::new(Vec::new()));
let mut cmd = Command::new(binary);
if let Some(path) = adapter_path(&spec.env) {
cmd.env("PATH", path);
}
ide_core::process::prepare(cmd.as_std_mut());
// Every adapter leads its own process group: the program an adapter launches itself
// (java-debug's JVM) joins the group, so a forced stop reaches the whole tree with
// one `kill(-pgid)`. On Windows the same coverage comes from the session's job object.
ide_core::process::own_group(cmd.as_std_mut());
cmd.args(args)
.envs(spec.env.iter().map(|(k, v)| (k, v)))
.current_dir(&spec.cwd)
.stdin(if port.is_some() { Stdio::null() } else { Stdio::piped() })
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
let mut child =
cmd.spawn().map_err(|e| IdeError::unavailable(format!("failed to start {}: {e}", binary.display())))?;
let (program_sink, program_output) = if SHARES_STDIO.contains(&spec.adapter.as_str()) {
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
(Some(tx), Some(rx))
} else {
(None, None)
};
if let Some(err) = child.stderr.take() {
pump_lines(err, spec.adapter.clone(), stderr.clone(), program_sink.clone().map(|tx| (tx, "stderr")));
}
match port {
None => {
let stdin = child.stdin.take().ok_or_else(|| IdeError::internal("adapter stdin unavailable"))?;
let stdout = child.stdout.take().ok_or_else(|| IdeError::internal("adapter stdout unavailable"))?;
Ok(AdapterIo {
launch_after_configuration: false,
reader: Box::new(stdout),
writer: Box::new(stdin),
child: Some(child),
port: None,
stderr,
program_output,
})
}
Some(port) => {
if let Some(out) = child.stdout.take() {
pump_lines(out, spec.adapter.clone(), stderr.clone(), program_sink.map(|tx| (tx, "stdout")));
}
let stream = match connect_with_retries(LOOPBACK, port, Some(&mut child), start_timeout).await {
Ok(s) => s,
Err(e) => {
if spec.adapter == "rdbg"
&& let Some(pid) = child.id()
{
ide_core::process::kill_tree_of(pid);
}
let _ = child.start_kill();
let tail = stderr.lock().join("\n");
return Err(if tail.is_empty() {
e
} else {
IdeError::unavailable(format!("{}\n{tail}", e.message))
});
}
};
let (r, w) = stream.into_split();
Ok(AdapterIo {
launch_after_configuration: false,
reader: Box::new(r),
writer: Box::new(w),
child: Some(child),
port: Some(port),
stderr,
program_output,
})
}
}
}
/// A failed adapter start that another process grabbing the free port can explain
/// (POSIX and Windows "address already in use" texts, or the adapter exiting because
/// it could not bind). Anything else is a real start failure and is not retried.
fn is_port_race(err: &IdeError) -> bool {
let message = err.message.to_ascii_lowercase();
message.contains("address already in use")
|| message.contains("addrinuse")
|| message.contains("only one usage of each socket address")
|| message.contains("exited before accepting connections")
}
fn pump_lines<R: AsyncRead + Unpin + Send + 'static>(
stream: R,
name: String,
@@ -683,15 +844,102 @@ mod tests {
#[test]
fn builtins_cover_common_adapters() {
for name in ["codelldb", "debugpy", "js-debug", "delve", "netcoredbg", "java-debug"] {
for name in ["codelldb", "debugpy", "js-debug", "delve", "netcoredbg", "java-debug", "gdb"] {
let cfg = builtin(name).unwrap();
assert!(cfg.install_hint.is_some());
}
let delve = resolve(&builtin("delve").unwrap(), PathBuf::from("/"));
assert_eq!(delve.transport, Transport::Tcp);
// GDB's own DAP interpreter over stdio, with the required version in its hint.
let gdb = builtin("gdb").unwrap();
assert_eq!(gdb.command, "gdb");
assert_eq!(gdb.args, ["-i", "dap"]);
assert_eq!(gdb.transport, "stdio");
assert!(gdb.install_hint.as_deref().is_some_and(|h| h.contains("14")), "{gdb:?}");
assert_eq!(default_type("gdb"), Some("gdb"));
assert!(builtin("nope").is_none());
}
/// The first version number of `gdb --version`'s first line, however the distro decorates it.
#[test]
fn parses_gdb_versions() {
for (out, want) in [
("GNU gdb (GDB) 14.2\nCopyright (C) 2023 Free Software Foundation", Some((14, 2))),
("GNU gdb (GDB) 15.0.50.20240403-0ubuntu1\n", Some((15, 0))),
("GNU gdb (Ubuntu 12.1-0ubuntu1~22.04.2) 12.1", Some((12, 1))),
("GNU gdb (Debian 13.1-3) 13.1", Some((13, 1))),
("GNU gdb (GDB) Fedora Linux 16.3-1.fc41", Some((16, 3))),
("GNU gdb (GDB; SUSE Linux Enterprise 15) 14.2", Some((14, 2))),
("GNU gdb (GDB; SUSE Linux Enterprise 15) 11.1", Some((11, 1))),
("mingw32-gdb13.exe (GDB) 13.2", Some((13, 2))),
("GNU gdb 14", Some((14, 0))),
("no version here", None),
("", None),
] {
assert_eq!(parse_gdb_version(out), want, "{out}");
}
}
#[tokio::test]
async fn gdb_probe_never_executes_a_changed_pinned_program() {
let dir = tempfile::tempdir().unwrap();
let fake = dir.path().join(if cfg!(windows) { "gdb.cmd" } else { "gdb" });
std::fs::write(&fake, "approved").unwrap();
let pin = ProgramPin::approve(&fake).unwrap();
#[cfg(windows)]
std::fs::write(&fake, "@echo off\r\necho ran>probe-ran\r\necho GNU gdb (GDB) 15.1\r\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::write(&fake, "#!/bin/sh\ntouch probe-ran\necho 'GNU gdb (GDB) 15.1'\n").unwrap();
std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let mut spec = resolve(&builtin("gdb").unwrap(), dir.path().to_owned());
spec.command = fake.to_string_lossy().into_owned();
spec.pinned = Some(pin);
let error = ProcessLauncher.launch(spec, None).await.err().expect("changed program is refused");
assert_eq!(error.kind, ide_core::error::ErrorKind::PermissionDenied);
assert!(!dir.path().join("probe-ran").exists(), "the version probe executed an unapproved program");
}
#[tokio::test]
async fn gdb_probe_uses_the_verified_program_and_launch_environment_once() {
use tokio::io::AsyncReadExt;
let dir = tempfile::tempdir().unwrap();
let fake = dir.path().join(if cfg!(windows) { "gdb.cmd" } else { "gdb" });
#[cfg(windows)]
std::fs::write(&fake, "@echo off\r\nif \"%~1\"==\"--version\" (\r\n echo probe>>probes\r\n echo GNU gdb ^(GDB^) %GDB_TEST_VERSION%\r\n) else (\r\n echo adapter\r\n)\r\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::write(&fake, "#!/bin/sh\nif [ \"$1\" = --version ]; then\n echo probe >> probes\n echo \"GNU gdb (GDB) $GDB_TEST_VERSION\"\nelse\n echo adapter\nfi\n").unwrap();
std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let pin = ProgramPin::approve(&fake).unwrap();
for (version, deferred) in [("12.1", false), ("14.2", false), ("15.1", true), ("16.3", false)] {
let mut spec = resolve(&builtin("gdb").unwrap(), dir.path().to_owned());
// Only the approved path is executable; the symbolic command must never be probed.
spec.command = "definitely-not-a-real-binary-openidea".into();
spec.pinned = Some(pin.clone());
spec.env.push(("GDB_TEST_VERSION".into(), version.into()));
let result = ProcessLauncher.launch(spec, None).await;
if version == "12.1" {
let error = result.err().expect("GDB 12 has no DAP interpreter");
assert_eq!(error.kind, ide_core::error::ErrorKind::Unavailable);
assert!(error.message.contains("GDB 14 or newer"), "{}", error.message);
assert!(error.message.contains("12.1"), "{}", error.message);
} else {
let mut io = result.unwrap();
assert_eq!(io.launch_after_configuration, deferred, "{version}");
let mut output = String::new();
io.reader.read_to_string(&mut output).await.unwrap();
assert_eq!(output.trim(), "adapter");
io.child.as_mut().unwrap().wait().await.unwrap();
}
}
assert_eq!(std::fs::read_to_string(dir.path().join("probes")).unwrap().lines().count(), 4);
}
#[test]
fn free_port_is_nonzero() {
assert_ne!(free_port().unwrap(), 0);
@@ -775,4 +1023,16 @@ mod tests {
assert_eq!(err.kind, ide_core::error::ErrorKind::Unavailable);
assert!(err.message.contains("runs inside the Java language server"), "{}", err.message);
}
#[test]
fn port_conflicts_are_recognised_for_retry() {
let in_use = IdeError::unavailable(
"debug adapter exited before accepting connections (exit status: 1)\nbind: address already in use",
);
assert!(is_port_race(&in_use));
assert!(is_port_race(&IdeError::unavailable(
"Only one usage of each socket address (protocol/network address/port) is normally permitted."
)));
assert!(!is_port_race(&IdeError::unavailable("failed to start codelldb: file not found")));
}
}
+70 -18
Переглянути файл
@@ -607,11 +607,22 @@ fn remove_delve_binaries(cwd: &Path) {
|| s == ".exe"
|| (s.starts_with(".exe") && s[".exe".len()..].chars().all(|c| c.is_ascii_digit()))
});
if leftover
&& entry.file_type().is_ok_and(|t| t.is_file())
&& let Err(e) = std::fs::remove_file(entry.path())
{
tracing::debug!("could not remove Delve leftover {}: {e}", entry.path().display());
if !leftover || entry.file_type().is_ok_and(|t| !t.is_file()) {
continue;
}
// Windows: a just-killed dlv / debuggee can still hold this session's
// binary while the previous one's lock is already gone, so one try
// leaves exactly the current leftover behind. Retry on a short back-off
// to outlast the handle release; a truly busy file is retried into the
// next run's sweep anyway.
for attempt in 0..5 {
match std::fs::remove_file(entry.path()) {
Ok(()) => break,
Err(e) if attempt == 4 => {
tracing::debug!("could not remove Delve leftover {}: {e}", entry.path().display());
}
Err(_) => std::thread::sleep(std::time::Duration::from_millis(50 * (attempt as u64 + 1))),
}
}
}
}
@@ -707,6 +718,10 @@ impl DebugManager {
{
spec.env.push(("JAVA_HOME".into(), home));
}
// Delve looks up Go before applying the debuggee's launch environment.
if config.adapter == "delve" {
spec.env.extend(config.env.iter().map(|(key, value)| (key.clone(), value.clone())));
}
if config.adapter == "rdbg" {
rdbg_command_line(&mut spec, &config, &cwd)?;
}
@@ -739,6 +754,18 @@ impl DebugManager {
if let Some(server) = &debug_server {
spec.server_host = server.host.clone();
}
if config.adapter == "gdb" && config.request == "launch" && debug_server.is_none() {
// GDB's DAP env replaces the entire inferior environment. Ordinary run overrides
// instead belong on the adapter process, which the inferior inherits. Explicit
// launch JSON keeps GDB's replacement semantics.
let explicit_env = config.extra_json.as_deref().is_some_and(|extra| {
serde_json::from_str::<Value>(extra).is_ok_and(|extra| extra.get("env").is_some())
});
if !explicit_env {
spec.env.extend(config.env.iter().map(|(k, v)| (k.clone(), v.clone())));
arguments.as_object_mut().unwrap().remove("env");
}
}
if let Some(cargo) = arguments.as_object_mut().and_then(|o| o.remove("cargo")) {
let cargo_args: Vec<String> = cargo
.get("args")
@@ -848,6 +875,7 @@ impl DebugManager {
return Err(IdeError::invalid(format!("request must be `launch` or `attach`, got `{request}`")));
}
let io = self.launcher.launch(spec.clone(), reuse_port).await?;
let launch_after_configuration = io.launch_after_configuration && request == "launch";
let stderr = io.stderr.clone();
let (conn, incoming) = {
let stderr = stderr.clone();
@@ -885,7 +913,7 @@ impl DebugManager {
let configured = tokio::select! {
biased;
_ = session.startup_cancel.cancelled() => return Err(IdeError::cancelled()),
result = self.configure(&session, arguments) => result,
result = self.configure(&session, arguments, launch_after_configuration) => result,
};
match configured {
Ok(()) => Ok(id),
@@ -912,7 +940,7 @@ impl DebugManager {
}
}
async fn configure(&self, s: &Arc<Session>, mut arguments: Value) -> IdeResult<()> {
async fn configure(&self, s: &Arc<Session>, mut arguments: Value, launch_in_config: bool) -> IdeResult<()> {
if s.adapter == kotlin::ADAPTER {
let project = arguments
.get("projectRoot")
@@ -978,16 +1006,28 @@ impl DebugManager {
// Launch/attach may wait for a slow runtime or a remote target. Let the adapter report
// failure; Stop cancels configuration and a closed connection fails the pending reply.
// Many adapters answer only after `configurationDone`, so don't wait for the reply yet.
let launch = s.conn.send_request(request, arguments)?;
let mut launch = Box::pin(async move { launch.await.unwrap_or(Err(DapError::Closed)) });
// GDB 15 is the exception: its `launch` starts the program itself (14 runs at
// `configurationDone`, 16+ defers the launch there), so its launch waits below.
let mut launch = if launch_in_config {
None
} else {
let reply = s.conn.send_request(request, arguments.clone())?;
Some(Box::pin(async move { reply.await.unwrap_or(Err(DapError::Closed)) }))
};
let mut initialized = s.initialized.subscribe();
let mut launch_result = None;
let got_initialized = tokio::select! {
r = initialized.wait_for(|v| *v) => { r.map_err(|_| DapError::Closed)?; true }
r = &mut launch => {
r?;
launch_result = Some(());
false
let got_initialized = match launch.as_mut() {
Some(launch) => tokio::select! {
r = initialized.wait_for(|v| *v) => { r.map_err(|_| DapError::Closed)?; true }
r = launch => {
r?;
launch_result = Some(());
false
}
},
None => {
initialized.wait_for(|v| *v).await.map_err(|_| DapError::Closed)?;
true
}
};
if !got_initialized {
@@ -1045,8 +1085,17 @@ impl DebugManager {
}
}
s.configured.store(true, Ordering::Release);
if launch_result.is_none() {
launch.await?;
match launch.as_mut() {
Some(launch) => {
if launch_result.is_none() {
launch.await?;
}
}
None => {
// GDB 15: the breakpoints are in, so its `run` cannot miss them.
let reply = s.conn.send_request(request, arguments)?;
reply.await.unwrap_or(Err(DapError::Closed))?;
}
}
if let Some(relay) = s.kotlin_relay.lock().as_mut() {
relay.release();
@@ -1066,6 +1115,9 @@ impl DebugManager {
/// Ends a session: `terminate` (when asked and supported) → `disconnect` → kill.
pub(crate) async fn shutdown(&self, s: &Arc<Session>, terminate: bool) {
if terminate {
crate::session::remember_debuggee(s).await;
}
// java-debug runs the program itself and pipes its stdout over DAP. A `disconnect`
// ends that DAP session, so every line the program writes while shutting down is lost.
// Ask the program to exit ourselves and keep the adapter attached until it has drained
@@ -1093,7 +1145,7 @@ impl DebugManager {
if own_terminate && !graceful {
// Signal before anything else: waiting on a request the adapter may not answer
// would delay the shutdown the console is supposed to show.
crate::session::signal_debuggee(s, false);
crate::session::signal_debuggee(s, false).await;
}
if graceful {
// java-debug sends `terminated` only after it has drained the program's console
+220 -29
Переглянути файл
@@ -45,6 +45,13 @@ pub(crate) struct Session {
pub debuggees: Mutex<Vec<Child>>,
/// The terminal a `runInTerminal` program runs in: typed input goes to it.
pub program: Mutex<Option<ProgramTerminal>>,
/// The job object over the adapter and everything it started: the program an adapter
/// like java-debug launches is its child, so a forced stop (and the session's
/// cleanup) ends the whole tree even when the adapter exited first and the reported
/// pid no longer names it. `None` when the assignment failed; the kill then falls
/// back to the captured process tree.
#[cfg(windows)]
pub job: Mutex<Option<ide_core::process::Job>>,
/// Set once `configurationDone` was sent; breakpoint updates go out live after that.
pub configured: AtomicBool,
pub initialized: watch::Sender<bool>,
@@ -59,6 +66,8 @@ pub(crate) struct Session {
/// adapter may report the launcher shell, whose child is the program).
#[cfg_attr(not(unix), allow(dead_code))]
pub stop_pids: parking_lot::Mutex<Vec<i32>>,
#[cfg(windows)]
pub stop_tree: Mutex<Option<ide_core::process::ProcessTree>>,
/// Breakpoints replaced for run to cursor / force stepping, restored on the next stop.
pub pending_restore: Mutex<PendingRestore>,
/// Adapter breakpoint id → (file, index in the stored list).
@@ -89,6 +98,10 @@ impl Session {
port: Option<u16>,
child: Option<Child>,
) -> Self {
// Before `child` moves into the session: the adapter is the root of the job, so
// everything it starts (java-debug's JVM) is in there by inheritance.
#[cfg(windows)]
let job = child.as_ref().and_then(tokio::process::Child::id).and_then(ide_core::process::Job::for_pid);
Self {
id,
owner,
@@ -103,6 +116,8 @@ impl Session {
adapter_process: Mutex::new(child),
debuggees: Mutex::new(Vec::new()),
program: Mutex::new(None),
#[cfg(windows)]
job: Mutex::new(job),
configured: AtomicBool::new(false),
initialized: watch::channel(false).0,
startup_cancel: ide_core::CancellationToken::new(),
@@ -111,6 +126,8 @@ impl Session {
stop_force: AtomicBool::new(false),
debuggee_pid: AtomicI64::new(0),
stop_pids: parking_lot::Mutex::new(Vec::new()),
#[cfg(windows)]
stop_tree: Mutex::new(None),
pending_restore: Mutex::default(),
breakpoint_ids: Mutex::new(HashMap::new()),
resolved_paths: Mutex::new(HashMap::new()),
@@ -278,6 +295,24 @@ impl Session {
/// Kills the adapter and every debuggee we started.
pub fn kill_processes(&self) {
self.kotlin_relay.lock().take();
#[cfg(unix)]
if self.request == "launch" {
for group in launch_groups(self) {
ide_core::process::kill_tree_of(group as u32);
}
}
// The job first: it ends the adapter and everything it started (the program
// java-debug launched is its child), members included that the stop's captured
// tree never saw. An empty job is unaffected, and whatever survives this session
// dies when the handle closes (`KILL_ON_JOB_CLOSE`).
#[cfg(windows)]
if let Some(job) = self.job.lock().as_ref() {
job.terminate();
}
#[cfg(windows)]
if let Some(tree) = self.stop_tree.lock().as_ref() {
tree.terminate();
}
if let Some(mut c) = self.adapter_process.lock().take() {
if self.adapter == "rdbg"
&& let Some(pid) = c.id()
@@ -291,6 +326,9 @@ impl Session {
}
if let Some(mut program) = self.program.lock().take() {
let _ = program.child.kill();
let _ = std::thread::Builder::new().name("openidea-debug-reap".into()).spawn(move || {
let _ = program.child.wait();
});
}
}
}
@@ -298,6 +336,21 @@ impl Session {
/// How long Stop waits for a launched program to exit before reporting the session ended.
pub(crate) const DEBUGGEE_EXIT_WAIT: Duration = Duration::from_secs(10);
pub(crate) async fn remember_debuggee(s: &Arc<Session>) {
#[cfg(windows)]
{
let Ok(pid) = u32::try_from(s.debuggee_pid.load(Ordering::Acquire)) else { return };
if s.request != "launch" || pid == 0 || s.stop_tree.lock().is_some() {
return;
}
if let Ok(tree) = tokio::task::spawn_blocking(move || ide_core::process::ProcessTree::capture(pid)).await {
s.stop_tree.lock().get_or_insert(tree);
}
}
#[cfg(not(windows))]
let _ = s;
}
/// The program and everything it started: an adapter may report the launcher shell, whose
/// child is the debugged program, so stopping only the reported pid leaves the program alive.
#[cfg(unix)]
@@ -327,18 +380,36 @@ fn process_tree(pid: i32) -> Vec<i32> {
/// Asks the program itself to exit (`force` = SIGKILL) without ending the adapter session:
/// its output keeps flowing over DAP while it shuts down. The first call remembers the pid
/// tree, because the reported pid may die before its program does.
pub(crate) fn signal_debuggee(s: &Arc<Session>, force: bool) {
let pid = s.debuggee_pid.load(Ordering::Acquire);
if pid <= 0 {
pub(crate) async fn signal_debuggee(s: &Arc<Session>, force: bool) {
if s.request != "launch" {
return;
}
#[cfg(not(windows))]
let pid = s.debuggee_pid.load(Ordering::Acquire);
#[cfg(unix)]
{
let mut known = s.stop_pids.lock();
if known.is_empty() {
*known = process_tree(pid as i32);
let needs_tree = pid > 0 && s.stop_pids.lock().is_empty();
if needs_tree {
let tree = tokio::task::spawn_blocking(move || process_tree(pid as i32)).await.unwrap_or_default();
*s.stop_pids.lock() = tree;
}
let known = s.stop_pids.lock();
let signal = if force { libc::SIGKILL } else { libc::SIGTERM };
if force {
// A forced stop kills the launch: every root the session spawned leads its own
// process group (`own_group` at spawn), so one signal reaches the program and
// everything it started, even processes born after the snapshot below was
// taken — the adapter's program is in the adapter's group. The session's own
// JVM roots (Kotlin) and `runInTerminal` programs lead groups of their own.
// The roots are unreaped until the session drops them, so their ids (and the
// groups named by them) cannot have been reused yet.
for group in launch_groups(s) {
// SAFETY: plain syscalls on the processes this session started.
if unsafe { libc::kill(-group, libc::SIGKILL) } != 0 {
unsafe { libc::kill(group, libc::SIGKILL) };
}
}
}
// Children first: the shell that started the program would otherwise reparent it.
for p in known.iter().rev() {
// SAFETY: plain syscall on the processes this session started.
@@ -349,17 +420,53 @@ pub(crate) fn signal_debuggee(s: &Arc<Session>, force: bool) {
}
#[cfg(windows)]
{
let mut cmd = std::process::Command::new("taskkill");
cmd.args(["/PID", &pid.to_string(), "/T"]);
// Console programs have no external graceful signal; the adapter gets its chance
// first. The job ends the program and everything it started, members included
// that the captured tree never saw.
if force {
cmd.arg("/F");
if let Some(job) = s.job.lock().as_ref() {
job.terminate();
}
// A separately spawned program may have rejected assignment to the adapter's job.
if let Some(tree) = s.stop_tree.lock().as_ref() {
tree.terminate();
}
}
let _ = cmd.status();
}
#[cfg(not(any(unix, windows)))]
let _ = (pid, force);
}
/// The process groups the session spawned roots for: the adapter's (the program it
/// launched is in it), the JVMs the session started itself, the `runInTerminal` program.
#[cfg(unix)]
fn launch_groups(s: &Session) -> Vec<libc::pid_t> {
let mut groups = Vec::new();
let mut push = |id: Option<u32>| {
if let Ok(pid) = libc::pid_t::try_from(id.unwrap_or(0)) {
groups.push(pid);
}
};
push(s.adapter_process.lock().as_ref().and_then(tokio::process::Child::id));
for child in s.debuggees.lock().iter() {
push(child.id());
}
push(s.program.lock().as_ref().and_then(|p| p.child.process_id()));
groups.retain(|g| *g > 0);
groups
}
/// Whether the launch is still running: the stop's captured tree when there is one,
/// otherwise the session's job (the adapter and everything it started).
#[cfg(windows)]
fn launch_running(s: &Arc<Session>) -> bool {
if let Some(tree) = s.stop_tree.lock().as_ref() {
tree.is_running()
} else {
s.job.lock().as_ref().is_some_and(|job| job.is_running())
}
}
/// Waits until the program a `launch` session started is gone; a second Stop kills it.
pub(crate) async fn wait_for_debuggee(s: &Arc<Session>, terminate: bool) {
if !terminate || s.request != "launch" {
@@ -373,25 +480,39 @@ pub(crate) async fn wait_for_debuggee(s: &Arc<Session>, terminate: bool) {
#[cfg(unix)]
{
use std::time::Instant;
let targets = {
let known = s.stop_pids.lock();
if known.is_empty() { process_tree(pid as i32) } else { known.clone() }
};
let mut targets = s.stop_pids.lock().clone();
if targets.is_empty() {
targets = tokio::task::spawn_blocking(move || process_tree(pid as i32)).await.unwrap_or_default();
}
let alive = || targets.iter().any(|p| unsafe { libc::kill(*p, 0) } == 0);
let deadline = Instant::now() + DEBUGGEE_EXIT_WAIT;
while alive() {
if s.stop_force.swap(false, Ordering::AcqRel) {
signal_debuggee(s, true);
signal_debuggee(s, true).await;
}
if Instant::now() >= deadline {
// The program ignored the terminate: do not leave it running.
signal_debuggee(s, true);
signal_debuggee(s, true).await;
break;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
#[cfg(not(unix))]
#[cfg(windows)]
{
remember_debuggee(s).await;
let deadline = std::time::Instant::now() + DEBUGGEE_EXIT_WAIT;
// The captured tree is the oracle when it was captured; otherwise the job (every
// process the launch started) is, so a failed capture can no longer end the wait
// while the program still holds its port.
while launch_running(s) {
if s.stop_force.swap(false, Ordering::AcqRel) || std::time::Instant::now() >= deadline {
signal_debuggee(s, true).await;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
#[cfg(not(any(unix, windows)))]
{
let _ = pid;
}
@@ -630,9 +751,15 @@ pub(crate) fn run_in_terminal(s: &Arc<Session>, args: &Value) -> Result<Value, S
.openpty(PtySize { rows: 24, cols: 200, pixel_width: 0, pixel_height: 0 })
.map_err(|e| format!("cannot open a terminal for {program}: {e}"))?;
#[cfg(unix)]
if let Some(fd) = pair.master.as_raw_fd() {
ide_core::input::nonblocking(fd).map_err(|e| format!("terminal of {program}: {e}"))?;
}
let wait_writable = ide_core::input::nonblocking(
pair.master.as_raw_fd().ok_or_else(|| format!("terminal of {program}: PTY has no file descriptor"))?,
)
.map_err(|e| format!("terminal of {program}: {e}"))?;
#[cfg(unix)]
let wait_readable = ide_core::input::readable(
pair.master.as_raw_fd().ok_or_else(|| format!("terminal of {program}: PTY has no file descriptor"))?,
)
.map_err(|e| format!("terminal of {program}: {e}"))?;
let mut cmd = CommandBuilder::new(program);
// CodeLLDB's launcher clears the terminal first; the console keeps the session's earlier output.
cmd.args(rest.iter().filter(|a| *a != "--clear-screen"));
@@ -659,19 +786,38 @@ pub(crate) fn run_in_terminal(s: &Arc<Session>, args: &Value) -> Result<Value, S
}
}
let mut child = pair.slave.spawn_command(cmd).map_err(|e| format!("failed to start {program}: {e}"))?;
// The program joins the session's job, so Stop reaches what it starts (its children),
// though only the program itself is the session's child.
#[cfg(windows)]
if let Some(pid) = child.process_id()
&& let Some(job) = s.job.lock().as_ref()
{
job.assign_pid(pid);
}
drop(pair.slave);
let reader = pair.master.try_clone_reader().map_err(|e| format!("terminal of {program}: {e}"))?;
let writer = pair.master.take_writer().map_err(|e| format!("terminal of {program}: {e}"))?;
let writer = ide_core::input::InputWriter::new(format!("openidea-debug-input-{}", s.id.0), writer, |e| {
tracing::warn!("debug input: {e}");
})
let writer = ide_core::input::InputWriter::new(
format!("openidea-debug-input-{}", s.id.0),
writer,
#[cfg(unix)]
wait_writable,
|e| {
tracing::warn!("debug input: {e}");
},
)
.inspect_err(|_| {
let _ = child.kill();
let _ = child.wait();
})
.map_err(|e| format!("terminal input worker: {e}"))?;
let pid = child.process_id();
forward_terminal_output(s, reader);
forward_terminal_output(
s,
reader,
#[cfg(unix)]
wait_readable,
);
if let Some(mut previous) = s.program.lock().replace(ProgramTerminal { writer, child, _master: pair.master }) {
let _ = previous.child.kill();
}
@@ -680,7 +826,11 @@ pub(crate) fn run_in_terminal(s: &Arc<Session>, args: &Value) -> Result<Value, S
}
/// The program's terminal output, as stdout lines (`\r\n` as `\n`; colours are kept).
fn forward_terminal_output(s: &Session, mut reader: Box<dyn std::io::Read + Send>) {
fn forward_terminal_output(
s: &Session,
mut reader: Box<dyn std::io::Read + Send>,
#[cfg(unix)] mut wait_readable: impl FnMut() -> std::io::Result<()> + Send + 'static,
) {
let session = s.id;
let sink = s.owner.sink.clone();
let _ = std::thread::Builder::new().name(format!("openidea-debuggee-{}", session.0)).spawn(move || {
@@ -692,6 +842,14 @@ fn forward_terminal_output(s: &Session, mut reader: Box<dyn std::io::Read + Send
Ok(n) => pending.extend_from_slice(&buf[..n]),
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
#[cfg(unix)]
if let Err(e) = wait_readable()
&& e.kind() != std::io::ErrorKind::Interrupted
{
tracing::warn!(%session, "debug terminal read: {e}");
break;
}
#[cfg(not(unix))]
std::thread::sleep(std::time::Duration::from_millis(4));
continue;
}
@@ -762,7 +920,6 @@ fn forward_output<R: AsyncRead + Unpin + Send + 'static>(s: &Session, mut stream
/// Launch with the IDE's resolved runtime and classpath; the adapter only attaches.
pub(crate) async fn start_kotlin_debuggee(s: &Arc<Session>, args: &mut Value) -> ide_core::IdeResult<()> {
use tokio::io::{AsyncBufReadExt, BufReader};
let port = crate::adapter::free_port()?;
let java = args.get("javaExec").and_then(Value::as_str).unwrap_or("java");
let strings = |key: &str| -> Vec<String> {
args.get(key)
@@ -775,7 +932,9 @@ pub(crate) async fn start_kotlin_debuggee(s: &Arc<Session>, args: &mut Value) ->
let mut cmd = tokio::process::Command::new(java);
ide_core::process::prepare(cmd.as_std_mut());
cmd.args(strings("vmArguments"))
.arg(format!("-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=127.0.0.1:{port}"))
// Let the JVM pick its own port (`:0`) and report it below: pre-picking a free
// port here races with other processes that may take it before the JVM binds.
.arg("-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=127.0.0.1:0")
.arg("-cp")
.arg(classpath);
let modules = strings("modulePaths");
@@ -802,20 +961,33 @@ pub(crate) async fn start_kotlin_debuggee(s: &Arc<Session>, args: &mut Value) ->
}
}
}
// A root the session spawns itself: its own process group on Unix (`kill(-pgid)` in a
// forced stop), the session's job on Windows, so a stop reaches what it forks.
ide_core::process::own_group(cmd.as_std_mut());
let mut child = cmd.spawn()?;
#[cfg(windows)]
if let Some(pid) = child.id()
&& let Some(job) = s.job.lock().as_ref()
{
job.assign_pid(pid);
}
if let Some(err) = child.stderr.take() {
forward_output(s, err, "stderr");
}
let mut stdout =
BufReader::new(child.stdout.take().ok_or_else(|| ide_core::IdeError::internal("Missing JVM stdout"))?);
tokio::time::timeout(Duration::from_secs(15), async {
// The JVM chose the port (`:0` above) and reports it here; reading it back removes
// the free-port race window entirely.
let port = tokio::time::timeout(Duration::from_secs(15), async {
loop {
let mut line = String::new();
if stdout.read_line(&mut line).await? == 0 {
return Err(ide_core::IdeError::unavailable("JVM exited before starting its debug listener"));
}
if line.contains("Listening for transport dt_socket") {
return Ok(());
return parse_jdwp_port(&line).ok_or_else(|| {
ide_core::IdeError::unavailable(format!("JVM reported an unreadable debug port: {}", line.trim()))
});
}
s.emit(DebugEvent::Output { category: "stdout".into(), output: line });
}
@@ -829,3 +1001,22 @@ pub(crate) async fn start_kotlin_debuggee(s: &Arc<Session>, args: &mut Value) ->
args["timeout"] = json!(10000);
Ok(())
}
/// The port a JVM chose, from `Listening for transport dt_socket at address: <port>`
/// (some JVMs print `host:port` or `[::1]:port`, so take what follows the last colon).
fn parse_jdwp_port(line: &str) -> Option<u16> {
line.rsplit_once("address:")?.1.trim().rsplit(':').next()?.trim().parse().ok()
}
#[cfg(test)]
mod tests {
use super::parse_jdwp_port;
#[test]
fn reads_the_port_the_jvm_chose() {
assert_eq!(parse_jdwp_port("Listening for transport dt_socket at address: 51234"), Some(51234));
assert_eq!(parse_jdwp_port("Listening for transport dt_socket at address: 127.0.0.1:51234"), Some(51234));
assert_eq!(parse_jdwp_port("Listening for transport dt_socket at address: [::1]:51234"), Some(51234));
assert_eq!(parse_jdwp_port("unrelated output"), None);
}
}
+274 -18
Переглянути файл
@@ -14,6 +14,93 @@ use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};
type Log = Arc<Mutex<Vec<Value>>>;
#[cfg(windows)]
#[test]
#[ignore = "child process for Windows stop tests"]
fn windows_debuggee_process() {
if std::env::var_os("OPENIDEA_TEST_DEBUGGEE").is_none() {
return;
}
if std::env::var("OPENIDEA_TEST_DEBUGGEE").unwrap() == "wrapper" {
let mut command = std::process::Command::new(std::env::current_exe().unwrap());
let mut child = ide_core::process::prepare(&mut command)
.args(["--exact", "tests::windows_debuggee_process", "--ignored", "--nocapture"])
.env("OPENIDEA_TEST_DEBUGGEE", "leaf")
.spawn()
.unwrap();
let _ = child.wait();
return;
}
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
println!("LISTENING {}", listener.local_addr().unwrap().port());
std::thread::sleep(Duration::from_secs(60));
}
#[cfg(windows)]
#[tokio::test]
async fn windows_stop_releases_the_debuggees_port_before_returning() {
use std::io::BufRead;
use std::sync::atomic::Ordering;
struct Child(std::process::Child, Option<ide_core::process::ProcessTree>);
impl Drop for Child {
fn drop(&mut self) {
if let Some(tree) = &self.1 {
tree.terminate();
}
let _ = self.0.kill();
let _ = self.0.wait();
}
}
for (second_stop, wrapper, supports_terminate) in
[(false, false, false), (true, false, false), (true, true, false), (true, true, true)]
{
let mut command = std::process::Command::new(std::env::current_exe().unwrap());
ide_core::process::prepare(&mut command)
.args(["--exact", "tests::windows_debuggee_process", "--ignored", "--nocapture"])
.env("OPENIDEA_TEST_DEBUGGEE", if wrapper { "wrapper" } else { "leaf" })
.stdout(std::process::Stdio::piped());
let mut child = Child(command.spawn().unwrap(), None);
let port: u16 = std::io::BufReader::new(child.0.stdout.take().unwrap())
.lines()
.map(Result::unwrap)
.find_map(|line| line.split("LISTENING ").nth(1).and_then(|p| p.parse().ok()))
.unwrap();
let pid = child.0.id();
child.1 = Some(ide_core::process::ProcessTree::capture(pid));
let h = harness(Script { debuggee_pid: Some(pid), supports_terminate, ..Default::default() });
let id = h.start(config()).await.unwrap();
let session = h.manager.session(id).unwrap();
while session.debuggee_pid.load(Ordering::Acquire) != i64::from(pid) {
tokio::time::sleep(Duration::from_millis(10)).await;
}
let stop = tokio::spawn({
let manager = h.manager.clone();
async move { manager.stop(id).await }
});
let deadline = std::time::Instant::now() + Duration::from_secs(5);
while session.stop_tree.lock().is_none() && !stop.is_finished() {
assert!(std::time::Instant::now() < deadline, "Stop did not capture the program tree");
tokio::time::sleep(Duration::from_millis(10)).await;
}
assert!(!stop.is_finished(), "Stop must wait for the live program");
if wrapper {
assert!(session.stop_tree.lock().is_some());
child.0.kill().unwrap();
child.0.wait().unwrap();
assert!(std::net::TcpListener::bind(("127.0.0.1", port)).is_err(), "the child still owns its port");
}
if second_stop {
h.manager.stop(id).await.unwrap();
}
let limit = if second_stop { 10 } else { 25 };
tokio::time::timeout(Duration::from_secs(limit), stop).await.unwrap().unwrap().unwrap();
assert!(child.0.try_wait().unwrap().is_some(), "Stop returned with the program alive");
let _listener = std::net::TcpListener::bind(("127.0.0.1", port)).expect("Stop returned with its port occupied");
}
}
#[derive(Clone)]
struct Script {
/// Send a `runInTerminal` reverse request when `launch` arrives.
@@ -39,6 +126,10 @@ struct Script {
pending_startup: Option<bool>,
startup_error: Option<&'static str>,
close_during_startup: bool,
/// GDB 15 semantics: `initialized` right after `initialize`, the program starts at
/// `launch`, and a `configurationDone` sent while it runs is refused with `notStopped`.
gdb15_starts_at_launch: bool,
launch_after_configuration: bool,
}
impl Default for Script {
@@ -58,6 +149,8 @@ impl Default for Script {
pending_startup: None,
startup_error: None,
close_during_startup: false,
gdb15_starts_at_launch: false,
launch_after_configuration: false,
}
}
}
@@ -72,10 +165,12 @@ impl AdapterLauncher for FakeLauncher {
let (client, server) = tokio::io::duplex(1 << 16);
let log = self.log.clone();
let script = self.script.clone();
let launch_after_configuration = script.launch_after_configuration;
tokio::spawn(fake_adapter(server, log, script));
let (r, w) = tokio::io::split(client);
Box::pin(async move {
Ok(AdapterIo {
launch_after_configuration,
reader: Box::new(r),
writer: Box::new(w),
child: None,
@@ -93,6 +188,7 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
let mut seq = 1000;
let mut pending_launch: Option<i64> = None;
let mut held: Vec<Value> = Vec::new();
let mut running = false;
macro_rules! send {
($v:expr) => {{
let mut v: Value = $v;
@@ -113,24 +209,37 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
continue;
}
match msg["command"].as_str().unwrap_or_default() {
"initialize" => ok!(
msg,
json!({
"supportsConfigurationDoneRequest": true,
"supportsCompletionsRequest": true,
"supportsTerminateRequest": script.supports_terminate,
"supportsSetVariable": true,
"supportsFunctionBreakpoints": true,
"supportsDataBreakpoints": true,
"supportsStepInTargetsRequest": true,
"supportsRestartFrame": true,
"exceptionBreakpointFilters": [
{"filter": "raised", "label": "Raised"},
{"filter": "uncaught", "label": "Uncaught", "default": true, "description": "Uncaught exceptions"},
],
})
),
"initialize" => {
ok!(
msg,
json!({
"supportsConfigurationDoneRequest": true,
"supportsCompletionsRequest": true,
"supportsTerminateRequest": script.supports_terminate,
"supportsSetVariable": true,
"supportsFunctionBreakpoints": true,
"supportsDataBreakpoints": true,
"supportsStepInTargetsRequest": true,
"supportsRestartFrame": true,
"exceptionBreakpointFilters": [
{"filter": "raised", "label": "Raised"},
{"filter": "uncaught", "label": "Uncaught", "default": true, "description": "Uncaught exceptions"},
],
})
);
if script.gdb15_starts_at_launch {
// GDB 15 announces itself before it sees `launch` (which a client that
// defers it sends late).
send!(json!({"type": "event", "event": "initialized"}));
}
}
"launch" | "attach" => {
if script.gdb15_starts_at_launch {
// GDB 15 starts the program here and answers `launch` right away.
running = true;
ok!(msg, Value::Null);
continue;
}
if script.close_during_startup {
return;
}
@@ -202,6 +311,14 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
ok!(msg, json!({"value": format!("{}!", msg["arguments"]["value"].as_str().unwrap()), "type": "int"}))
}
"configurationDone" => {
if script.gdb15_starts_at_launch && running {
// GDB 15 answers `configurationDone` with `notStopped` while the program
// it started at `launch` is still running.
send!(
json!({"type": "response", "request_seq": msg["seq"], "command": "configurationDone", "success": false, "message": "notStopped"})
);
continue;
}
if !script.ignore_configuration_done {
ok!(msg, Value::Null);
}
@@ -423,6 +540,60 @@ async fn stop_cancels_pending_launch_and_attach() {
}
}
/// GDB 15 must receive breakpoints and configurationDone before launch.
#[tokio::test(start_paused = true)]
async fn gdb_15_launch_waits_for_the_configuration() {
for deferred in [false, true] {
let h =
harness(Script { gdb15_starts_at_launch: true, launch_after_configuration: deferred, ..Script::default() });
let result = h.start(LaunchConfig { adapter: "gdb".into(), ..config() }).await;
if deferred {
let id = result.expect("GDB 15 defers its launch past the configuration");
let commands = h.commands();
let pos = |c: &str| commands.iter().position(|x| x == c).unwrap();
assert!(pos("launch") > pos("configurationDone"), "{commands:?}");
h.manager.stop(id).await.unwrap();
} else {
let error = result.unwrap_err();
assert!(error.message.contains("configurationDone failed: notStopped"), "{}", error.message);
}
}
}
#[tokio::test(start_paused = true)]
async fn gdb_attach_keeps_the_normal_configuration_order() {
let h = harness(Script { launch_after_configuration: true, ..Script::default() });
let id = h.start(LaunchConfig { adapter: "gdb".into(), request: "attach".into(), ..config() }).await.unwrap();
let commands = h.commands();
let pos = |c: &str| commands.iter().position(|x| x == c).unwrap();
assert!(pos("attach") < pos("configurationDone"), "{commands:?}");
h.manager.stop(id).await.unwrap();
}
#[tokio::test(start_paused = true)]
async fn gdb_run_environment_overrides_preserve_inheritance() {
for explicit in [None, Some(r#"{"env":{"ONLY":"explicit"}}"#)] {
let h = harness(Script::default());
let id = h
.start(LaunchConfig { adapter: "gdb".into(), extra_json: explicit.map(str::to_owned), ..config() })
.await
.unwrap();
let session = h.manager.session(id).unwrap();
{
let log = h.log.lock();
let args = &log.iter().find(|m| m["command"] == "launch").unwrap()["arguments"];
if explicit.is_some() {
assert_eq!(args["env"], json!({"ONLY": "explicit"}));
assert!(session.spec.env.is_empty());
} else {
assert!(args.get("env").is_none(), "GDB must inherit its process environment");
assert!(session.spec.env.contains(&("A".into(), "1".into())));
}
}
h.manager.stop(id).await.unwrap();
}
}
#[tokio::test(start_paused = true)]
async fn startup_still_reports_adapter_failure_and_connection_closure() {
for request in ["launch", "attach"] {
@@ -430,7 +601,6 @@ async fn startup_still_reports_adapter_failure_and_connection_closure() {
let error = h.start(LaunchConfig { request: request.into(), ..config() }).await.unwrap_err();
assert_eq!(error.message, format!("{request} failed: interpreter could not start"));
assert!(h.manager.sessions(None).is_empty());
let h = harness(Script { close_during_startup: true, ..Script::default() });
let error = h.start(LaunchConfig { request: request.into(), ..config() }).await.unwrap_err();
assert!(error.message.contains("debug adapter `debugpy` exited during startup"), "{error}");
@@ -953,6 +1123,39 @@ async fn connects_to_running_debug_server_over_tcp() {
assert!(events.lock().iter().any(|e| matches!(e, IdeEvent::Debug { event: DebugEvent::Started { .. }, .. })));
}
#[tokio::test]
async fn delve_adapter_inherits_the_selected_go_environment() {
struct CaptureLauncher {
inner: FakeLauncher,
env: Arc<Mutex<Vec<(String, String)>>>,
}
impl AdapterLauncher for CaptureLauncher {
fn launch(&self, spec: AdapterSpec, reuse: Option<u16>) -> LaunchFuture {
*self.env.lock() = spec.env.clone();
self.inner.launch(spec, reuse)
}
}
let env = Arc::new(Mutex::new(Vec::new()));
let launcher =
CaptureLauncher { inner: FakeLauncher { log: Arc::default(), script: Script::default() }, env: env.clone() };
let manager = DebugManager::with_launcher(Arc::new(LanguageRegistry::from_configs(Vec::new())), Arc::new(launcher));
let (owner, _) = recording_owner("main");
let config = LaunchConfig {
name: "Go".into(),
adapter: "delve".into(),
request: "launch".into(),
program: Some("/project".into()),
args: vec![],
cwd: None,
env: BTreeMap::from([("PATH".into(), "/chosen/go/bin".into()), ("GOROOT".into(), "/chosen/go".into())]),
extra_json: Some(json!({"mode":"debug"}).to_string()),
};
let id = manager.start(config, owner).await.unwrap();
assert!(env.lock().contains(&("PATH".into(), "/chosen/go/bin".into())));
assert!(env.lock().contains(&("GOROOT".into(), "/chosen/go".into())));
manager.stop(id).await.unwrap();
}
/// Go Remote / Python remote: the adapter runs on another machine (`dlv --headless`,
/// `debugpy --listen`); `debugServer: "host:port"` connects there instead of spawning one.
#[tokio::test]
@@ -1841,6 +2044,7 @@ async fn rdbg_drains_test_output_after_dap_closes_before_killing_the_process() {
});
let (reader, writer) = tokio::io::split(client);
Ok(AdapterIo {
launch_after_configuration: false,
reader: Box::new(reader),
writer: Box::new(writer),
child: Some(child),
@@ -1875,3 +2079,55 @@ async fn rdbg_drains_test_output_after_dap_closes_before_killing_the_process() {
IdeEvent::Debug { event: DebugEvent::Output { output, .. }, .. } if output == "test summary\n"
)));
}
#[cfg(unix)]
#[tokio::test]
async fn cleanup_ends_the_adapter_tree_without_a_process_event() {
use tokio::io::AsyncBufReadExt;
let h = harness(Script::default());
let id = h.start(config()).await.unwrap();
let session = h.manager.session(id).unwrap();
let mut command = tokio::process::Command::new("python3");
ide_core::process::own_group(command.as_std_mut());
command
.args([
"-u",
"-c",
"import subprocess,sys,time; subprocess.Popen([sys.executable, '-u', '-c', \
\"import socket,time; s=socket.socket(); s.bind(('127.0.0.1',0)); s.listen(); \
print(s.getsockname()[1],flush=True); time.sleep(60)\"]); time.sleep(60)",
])
.stdout(std::process::Stdio::piped());
let mut child = command.spawn().unwrap();
let mut lines = BufReader::new(child.stdout.take().unwrap()).lines();
let port: u16 = lines.next_line().await.unwrap().unwrap().parse().unwrap();
assert!(std::net::TcpListener::bind(("127.0.0.1", port)).is_err(), "the descendant holds the port");
let pid = child.id().unwrap();
*session.adapter_process.lock() = Some(child);
session.kill_processes();
let deadline = Instant::now() + Duration::from_secs(3);
while std::net::TcpListener::bind(("127.0.0.1", port)).is_err() {
if Instant::now() >= deadline {
ide_core::process::kill_tree_of(pid);
panic!("adapter cleanup left its descendant holding the port");
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
h.manager.stop(id).await.unwrap();
}
#[cfg(unix)]
#[tokio::test]
async fn stop_detaches_from_an_attached_program() {
let mut child = std::process::Command::new("sleep").arg("60").spawn().unwrap();
let h = harness(Script { debuggee_pid: Some(child.id()), ..Default::default() });
let id = h.start(LaunchConfig { request: "attach".into(), ..config() }).await.unwrap();
h.manager.stop(id).await.unwrap();
let alive = child.try_wait().unwrap().is_none();
let _ = child.kill();
let _ = child.wait();
assert!(alive, "Stop killed the attached program");
assert!(!h.commands().contains(&"terminate".to_owned()));
assert_eq!(h.request("disconnect")["arguments"]["terminateDebuggee"], false);
}
+20
Переглянути файл
@@ -151,6 +151,16 @@ fn cases(dir: &Path) -> Vec<Case> {
field: None,
config: launch("lldb-dap", &c.join("main"), &c, serde_json::json!({"env": ["OPENIDEA_FIXTURE=1"]})),
},
// GDB's own DAP interpreter needs GDB 14+; older ones are skipped by `installed`.
Case {
adapter: "gdb",
source: c.join("main.c"),
loop_line: 13,
after_loop: 15,
function: "parse",
field: None,
config: launch("gdb", &c.join("main"), &c, serde_json::json!({})),
},
]);
out
}
@@ -230,6 +240,16 @@ fn installed(adapter: &str) -> bool {
"js-debug" => "js-debug-adapter",
"delve" => "dlv",
"java-debug" => return true,
// Only a GDB with the DAP interpreter (14+) counts as installed.
"gdb" => {
return std::process::Command::new("gdb").arg("--version").output().is_ok_and(|out| {
String::from_utf8_lossy(&out.stdout)
.lines()
.next()
.and_then(adapter::parse_gdb_version)
.is_some_and(|(major, _)| major >= 14)
});
}
other => other,
};
adapter::resolve_binary(cmd, Path::new("/")).is_some()
+3
Переглянути файл
@@ -7,10 +7,13 @@ license.workspace = true
[dependencies]
ide-core.workspace = true
dunce.workspace = true
serde.workspace = true
serde_json = { workspace = true, features = ["raw_value"] }
specta.workspace = true
toml.workspace = true
toml_edit.workspace = true
blake3.workspace = true
tokio.workspace = true
tracing.workspace = true
parking_lot.workspace = true
+18
Переглянути файл
@@ -594,6 +594,13 @@ fn is_identifier(name: &str) -> bool {
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.')
}
/// sqlx's handshake flags for `sql_mode`: with either enabled it sends
/// `SET sql_mode=(SELECT CONCAT(...))`, a non-constant subquery that MySQL-protocol
/// servers like StarRocks reject (#2452), so the StarRocks preset turns both off.
fn mysql_mode_flags(vendor: &str) -> (bool, bool) {
if vendor == "starrocks" { (false, false) } else { (true, true) }
}
/// A MySQL `SET` value: numbers and keywords as they are, anything else quoted.
fn mysql_value(v: &str) -> String {
let keyword = ["ON", "OFF", "TRUE", "FALSE", "DEFAULT"].iter().any(|k| k.eq_ignore_ascii_case(v));
@@ -839,6 +846,8 @@ impl Pool {
if let Some(c) = &setup.collation {
opts = opts.collation(c);
}
let (pipes_as_concat, no_engine_substitution) = mysql_mode_flags(&source.vendor);
opts = opts.pipes_as_concat(pipes_as_concat).no_engine_substitution(no_engine_substitution);
if let Some(n) = setup.statement_cache {
opts = opts.statement_cache_capacity(n);
}
@@ -1465,6 +1474,15 @@ mod tests {
assert!(Setup::of(&DataSource { properties: vec![prop("connect_timeout", "soon")], ..s }).is_err());
}
#[test]
fn starrocks_disables_the_non_constant_sql_mode_handshake() {
// MySQL and its other wire-compatible presets keep sqlx's defaults.
for vendor in ["", "mysql", "mariadb", "tidb"] {
assert_eq!(mysql_mode_flags(vendor), (true, true), "{vendor}");
}
assert_eq!(mysql_mode_flags("starrocks"), (false, false));
}
#[test]
fn sqlite_properties_become_pragmas() {
let s = DataSource { properties: vec![prop("cache_size", "-2000")], ..Default::default() };
+26 -14
Переглянути файл
@@ -7,6 +7,7 @@ pub mod conn;
pub mod history;
mod oracle;
pub mod plan;
pub mod plugin;
pub mod secrets;
pub mod source;
pub mod split;
@@ -91,7 +92,7 @@ pub struct SequenceInfo {
pub data_type: String,
}
/// A schema's routines and sequences (the tree's `routines` / `sequences` folders).
/// A schema's routines (procedures and functions) and sequences.
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
#[serde(rename = "DbSchemaObjects")]
@@ -381,7 +382,9 @@ impl DbManager {
/// Where the saved passwords of data source `id` live: app-wide for a global one.
fn saved_for(&self, id: &str) -> &SecretStore {
match &self.global {
Some((_, store)) if self.global_sources().iter().any(|s| s.id == id) => store,
Some((_, store)) if self.sources().is_ok_and(|sources| sources.iter().any(|s| s.id == id && s.global)) => {
store
}
_ => &self.saved_passwords,
}
}
@@ -412,19 +415,28 @@ impl DbManager {
/// Saves the data source list; connections of removed or changed sources are closed.
/// Global ones (`global`) go to the app-wide list, the others to the project's.
pub async fn save_sources(&self, sources: Vec<DataSource>) -> IdeResult<()> {
let old = self.sources().unwrap_or_default();
let (global, project): (Vec<_>, Vec<_>) = sources.iter().cloned().partition(|s| s.global);
match &self.global {
// Rewritten only when it changes: other projects' windows edit the same file.
Some((dir, _)) => {
if global != old.iter().filter(|s| s.global).cloned().collect::<Vec<_>>() {
source::save_global(dir, &global)?;
}
}
None if !global.is_empty() => return Err(IdeError::invalid("global data sources are not available here")),
None => {}
if sources.iter().any(|s| s.global && s.plugin_owner.is_some()) {
return Err(IdeError::invalid("plugin-owned data sources must stay project-scoped"));
}
source::save(&self.root, &project)?;
let old = {
let _guard = plugin::EDIT.lock();
let old = self.sources().unwrap_or_default();
let (global, project): (Vec<_>, Vec<_>) = sources.iter().cloned().partition(|s| s.global);
match &self.global {
// Rewritten only when it changes: other projects' windows edit the same file.
Some((dir, _)) => {
if global != old.iter().filter(|s| s.global).cloned().collect::<Vec<_>>() {
source::save_global(dir, &global)?;
}
}
None if !global.is_empty() => {
return Err(IdeError::invalid("global data sources are not available here"));
}
None => {}
}
source::save(&self.root, &project)?;
old
};
for o in old {
match sources.iter().find(|s| s.id == o.id) {
Some(n) if *n == o => {}
+271
Переглянути файл
@@ -0,0 +1,271 @@
//! Ownership checks and surgical updates of plugin-created project sources.
use crate::{DataSource, DbManager, source};
use ide_core::{IdeError, IdeResult, project_dir};
use std::path::Path;
// All DbManagers of the same root share this file. Never hold this across an await.
pub(crate) static EDIT: parking_lot::Mutex<()> = parking_lot::Mutex::new(());
/// Host-owned connection id, unambiguous and safe for console paths.
pub fn source_id(plugin: &str, id: &str) -> String {
format!("plugin-{}", blake3::hash(format!("{plugin}\0{id}").as_bytes()).to_hex())
}
fn owned(source: &DataSource, plugin: &str, id: &str) -> bool {
source.id == source_id(plugin, id)
&& source.plugin_owner.as_deref() == Some(plugin)
&& source.plugin_source_id.as_deref() == Some(id)
&& !source.global
}
/// Edits just matching array tables, retaining unrelated entries, comments and formatting.
fn update(root: &Path, remove: impl Fn(&DataSource) -> bool, add: Option<&DataSource>) -> IdeResult<Vec<DataSource>> {
let path = project_dir::path(root, "data-sources.toml");
if let Some(ancestor) = path.ancestors().find(|p| p.exists())
&& root.exists()
&& !dunce::canonicalize(ancestor)?.starts_with(dunce::canonicalize(root)?)
{
return Err(IdeError::invalid("data sources file is outside the project"));
}
let text = match std::fs::read_to_string(&path) {
Ok(text) => text,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(e) => return Err(e.into()),
};
let old = source::load(root)?;
let doc = toml_edit::Document::parse(text.as_str()).map_err(|e| IdeError::invalid(format!("data sources: {e}")))?;
let removed: Vec<_> = old.iter().filter(|s| remove(s)).cloned().collect();
fn line_span(text: &str, span: std::ops::Range<usize>, out: &mut Vec<std::ops::Range<usize>>) {
let start = text[..span.start].rfind('\n').map_or(0, |n| n + 1);
let mut end = text[span.end..].find('\n').map_or(text.len(), |n| span.end + n + 1);
while end < text.len() {
let next = text[end..].find('\n').map_or(text.len(), |n| end + n + 1);
if !text[end..next].trim().is_empty() {
break;
}
end = next;
}
out.push(start..end);
}
fn spans(table: &toml_edit::Table, text: &str, out: &mut Vec<std::ops::Range<usize>>) {
if !table.is_implicit()
&& let Some(span) = table.span()
{
line_span(text, span, out);
}
for (_, item) in table.iter() {
if let Some(span) = item.as_value().and_then(toml_edit::Value::span) {
line_span(text, span, out);
}
if let Some(table) = item.as_table() {
spans(table, text, out);
}
if let Some(tables) = item.as_array_of_tables() {
for table in tables.iter() {
spans(table, text, out);
}
}
}
}
if doc.get("data-source").is_some_and(|i| i.as_array_of_tables().is_none()) {
return Err(IdeError::invalid("plugin sources require [[data-source]] tables"));
}
let mut ranges = vec![];
if let Some(tables) = doc.get("data-source").and_then(toml_edit::Item::as_array_of_tables) {
for (table, source) in tables.iter().zip(&old) {
if remove(source) {
spans(table, &text, &mut ranges);
}
}
}
ranges.sort_by_key(|r| r.start);
let mut merged: Vec<std::ops::Range<usize>> = vec![];
for range in ranges {
if let Some(last) = merged.last_mut()
&& range.start <= last.end
{
last.end = last.end.max(range.end);
continue;
}
merged.push(range);
}
let mut output = text.clone();
for range in merged.into_iter().rev() {
output.replace_range(range, "");
}
if let Some(add) = add {
let mut value = toml::Value::try_from(add).map_err(|e| IdeError::internal(e.to_string()))?;
value.as_table_mut().unwrap().remove("global");
let value =
toml::Value::Table(toml::Table::from_iter([("data-source".into(), toml::Value::Array(vec![value]))]));
if !output.is_empty() && !output.ends_with('\n') {
output.push('\n');
}
output.push_str(&toml::to_string_pretty(&value).map_err(|e| IdeError::internal(e.to_string()))?);
}
if !removed.is_empty() || add.is_some() {
let dir = path.parent().unwrap();
std::fs::create_dir_all(dir)?;
let tmp = dir.join(format!(".data-sources-{}.tmp", uuid::Uuid::new_v4()));
std::fs::write(&tmp, output)?;
if let Err(e) = std::fs::rename(&tmp, &path) {
let _ = std::fs::remove_file(&tmp);
return Err(e.into());
}
}
Ok(removed)
}
/// Removes only this plugin's sources, including when their project is closed at uninstall.
pub fn remove_all(root: &Path, plugin: &str) -> IdeResult<Vec<DataSource>> {
let _guard = EDIT.lock();
update(root, |s| s.plugin_owner.as_deref() == Some(plugin), None)
}
impl DbManager {
/// Lists private source ids without exposing any connection settings.
pub fn plugin_sources(&self, plugin: &str) -> IdeResult<Vec<String>> {
Ok(source::load(&self.root)?
.into_iter()
.filter(|s| s.plugin_owner.as_deref() == Some(plugin))
.filter_map(|s| s.plugin_source_id)
.collect())
}
/// Checks the stored owner, even if a user-created source uses the host's id format.
pub fn plugin_source(&self, plugin: &str, id: &str) -> IdeResult<DataSource> {
source::load(&self.root)?
.into_iter()
.find(|s| owned(s, plugin, id))
.ok_or_else(|| IdeError::not_found("no owned data source with this id"))
}
/// Replaces only an owned source and disconnects its old pools.
pub async fn upsert_plugin_source(&self, plugin: &str, id: &str, mut source: DataSource) -> IdeResult<()> {
source.id = source_id(plugin, id);
source.plugin_owner = Some(plugin.into());
source.plugin_source_id = Some(id.into());
source.global = false;
{
let _guard = EDIT.lock();
let all = self.sources()?;
if all.iter().any(|s| s.id == source.id && !owned(s, plugin, id)) {
return Err(IdeError::not_found("no owned data source with this id"));
}
if !all.iter().any(|s| owned(s, plugin, id)) && self.plugin_sources(plugin)?.len() >= 64 {
return Err(IdeError::invalid("at most 64 sources per plugin and project"));
}
update(&self.root, |s| owned(s, plugin, id), Some(&source))?;
}
self.disconnect_source(&source.id).await;
Ok(())
}
/// Deletes an owned source and all its saved/session credentials.
pub async fn remove_plugin_source(&self, plugin: &str, id: &str) -> IdeResult<()> {
let source = {
let _guard = EDIT.lock();
let source = self.plugin_source(plugin, id)?;
update(&self.root, |s| owned(s, plugin, id), None)?;
source
};
self.forget_plugin_source(&source.id).await;
Ok(())
}
/// Completes cleanup after removal from the file.
pub async fn forget_plugin_source(&self, id: &str) {
for key in [id.to_owned(), crate::secrets::ssh_key(id)] {
self.session_passwords.lock().remove(&key);
self.saved_passwords.delete(&key);
}
self.disconnect_source(id).await;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{DbDriver, secrets::SecretStore};
#[tokio::test]
async fn ownership_preserves_unrelated_bytes_and_cleans_credentials() {
ide_ssh::secrets::testing::use_memory_store();
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("project");
std::fs::create_dir_all(root.join(".openidea")).unwrap();
let original = "# user comment\n[[data-source]] # keep\nid = 'user'\nname='Mine'\ndriver='postgres'\n\n[data-source.options]\ntimeZone = 'UTC' # retain\n";
let file = root.join(".openidea/data-sources.toml");
std::fs::write(&file, original).unwrap();
let secrets = tmp.path().join("passwords.json");
let db = DbManager::new(
root.clone(),
secrets.clone(),
tmp.path().join("history.json"),
std::sync::Arc::new(ide_ssh::SshManager::new(tmp.path().join("ssh.json"))),
);
let settings =
DataSource { name: "Plugin".into(), driver: DbDriver::Postgres, save_password: true, ..Default::default() };
db.upsert_plugin_source("a", "local", settings.clone()).await.unwrap();
assert!(std::fs::read_to_string(&file).unwrap().starts_with(original));
db.upsert_plugin_source("b", "local", settings.clone()).await.unwrap();
assert_eq!(db.plugin_sources("a").unwrap(), ["local"]);
assert!(db.plugin_source("a", "user").is_err());
assert!(db.remove_plugin_source("a", "user").await.is_err());
let source = db.plugin_source("a", "local").unwrap();
db.set_password(&source.id, Some("top-secret".into()), true).await.unwrap();
let store = SecretStore::new(secrets, &root);
assert_eq!(store.get(&source.id).as_deref(), Some("top-secret"));
db.upsert_plugin_source("a", "local", DataSource { name: "Updated".into(), ..settings }).await.unwrap();
assert_eq!(store.get(&source.id).as_deref(), Some("top-secret"));
db.remove_plugin_source("a", "local").await.unwrap();
assert!(store.get(&source.id).is_none());
assert_eq!(db.plugin_sources("b").unwrap(), ["local"]);
remove_all(&root, "b").unwrap();
assert_eq!(std::fs::read_to_string(&file).unwrap(), original);
}
#[tokio::test]
async fn project_owned_credentials_do_not_replace_a_shadowed_global_sources_password() {
ide_ssh::secrets::testing::use_memory_store();
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("project");
let global = tmp.path().join("global");
let passwords = tmp.path().join("passwords.json");
let db = DbManager::new(
root.clone(),
passwords.clone(),
tmp.path().join("history.json"),
std::sync::Arc::new(ide_ssh::SshManager::new(tmp.path().join("ssh.json"))),
)
.with_global_sources(global.clone());
db.upsert_plugin_source(
"plugin",
"local",
DataSource { name: "Owned".into(), save_password: true, ..Default::default() },
)
.await
.unwrap();
let owned = db.plugin_source("plugin", "local").unwrap();
source::save_global(
&global,
&[DataSource { id: owned.id.clone(), name: "Global user source".into(), ..Default::default() }],
)
.unwrap();
let global_store = SecretStore::new(passwords.clone(), &global);
global_store.set(&owned.id, "user-secret").unwrap();
db.set_password(&owned.id, Some("plugin-secret".into()), true).await.unwrap();
assert_eq!(SecretStore::new(passwords, &root).get(&owned.id).as_deref(), Some("plugin-secret"));
assert_eq!(global_store.get(&owned.id).as_deref(), Some("user-secret"));
db.remove_plugin_source("plugin", "local").await.unwrap();
assert_eq!(global_store.get(&owned.id).as_deref(), Some("user-secret"));
}
#[tokio::test]
async fn a_user_source_cannot_be_claimed_by_predicting_its_id() {
let tmp = tempfile::tempdir().unwrap();
let db = DbManager::new(
tmp.path().join("project"),
tmp.path().join("passwords.json"),
tmp.path().join("history.json"),
std::sync::Arc::new(ide_ssh::SshManager::new(tmp.path().join("ssh.json"))),
);
let source = DataSource { id: source_id("plugin", "x"), name: "User".into(), ..Default::default() };
db.save_sources(vec![source.clone()]).await.unwrap();
assert!(db.upsert_plugin_source("plugin", "x", source.clone()).await.is_err());
assert_eq!(db.sources().unwrap(), [source]);
}
}
+10 -1
Переглянути файл
@@ -190,6 +190,12 @@ pub struct DataSource {
/// Stored app-wide and listed in every project. Implied by the file it is read from,
/// so not written to it.
pub global: bool,
/// Plugin id owning this source; absent for user-managed sources.
#[serde(skip_serializing_if = "Option::is_none")]
pub plugin_owner: Option<String>,
/// Id private to the owner, never used as a connection target.
#[serde(skip_serializing_if = "Option::is_none")]
pub plugin_source_id: Option<String>,
}
impl Default for DataSource {
@@ -219,6 +225,8 @@ impl Default for DataSource {
introspection: DbIntrospection::default(),
properties: Vec::new(),
global: false,
plugin_owner: None,
plugin_source_id: None,
}
}
}
@@ -262,7 +270,7 @@ pub fn scheme_driver(scheme: &str) -> Option<DbDriver> {
match scheme.to_ascii_lowercase().as_str() {
"postgres" | "postgresql" | "pgsql" | "redshift" | "cockroachdb" | "cockroach" | "yugabytedb"
| "timescaledb" | "greenplum" => Some(DbDriver::Postgres),
"mysql" | "mariadb" | "tidb" | "singlestore" | "memsql" | "oceanbase" => Some(DbDriver::Mysql),
"mysql" | "mariadb" | "tidb" | "singlestore" | "memsql" | "oceanbase" | "starrocks" => Some(DbDriver::Mysql),
"sqlserver" | "mssql" | "mssqlserver" => Some(DbDriver::SqlServer),
"sqlite" | "sqlite3" => Some(DbDriver::Sqlite),
"clickhouse" | "http" | "https" => Some(DbDriver::Clickhouse),
@@ -551,6 +559,7 @@ mod tests {
assert_eq!((u.host.as_str(), u.port, u.user.as_str()), ("::1", None, "root"));
assert_eq!(parse_url("jdbc:redshift://r:5439/dev").unwrap().driver, Some(DbDriver::Postgres));
assert_eq!(parse_url("tidb://h:4000").unwrap().driver, Some(DbDriver::Mysql));
assert_eq!(parse_url("starrocks://h:9030").unwrap().driver, Some(DbDriver::Mysql));
assert_eq!(parse_url("sqlite:/tmp/a.db").unwrap().file.as_deref(), Some("/tmp/a.db"));
let u = parse_url("oracle://scott:[email protected]:1521/ORCLPDB1").unwrap();
assert_eq!(u.driver, Some(DbDriver::Oracle));
+26
Переглянути файл
@@ -253,6 +253,32 @@ pub fn inspect(ep: &DockerEndpoint, object: DockerObject, id: &str) -> IdeResult
}
}
fn check_id(id: &str) -> IdeResult<&str> {
let id = id.trim();
if id.is_empty() || id.starts_with('-') { Err(IdeError::invalid("invalid Docker identifier")) } else { Ok(id) }
}
/// `docker image history --no-trunc --format '{{json .}}'` of one image.
pub fn image_history(ep: &DockerEndpoint, id: &str) -> IdeResult<Vec<DockerImageLayer>> {
let id = check_id(id)?;
let out = run(ep, &["image", "history", "--no-trunc", "--format", "{{json .}}", id], QUICK)?;
Ok(parse_image_history(&out))
}
/// `docker top <id>` of a running container.
pub fn container_top(ep: &DockerEndpoint, id: &str) -> IdeResult<DockerProcessList> {
let id = check_id(id)?;
let out = run(ep, &["top", id], QUICK)?;
Ok(parse_container_top(&out))
}
/// `docker stats --no-stream --no-trunc --format '{{json .}}' <id>` of a running container.
pub fn container_stats(ep: &DockerEndpoint, id: &str) -> IdeResult<DockerContainerStats> {
let id = check_id(id)?;
let out = run(ep, &["stats", "--no-stream", "--no-trunc", "--format", "{{json .}}", id], QUICK)?;
parse_container_stats(&out).ok_or_else(|| IdeError::not_found("no container stats returned"))
}
/// Streams a container's logs (`tail` last lines, then follows while `follow`)
/// until it ends or `cancel` fires.
pub fn logs(
+181 -19
Переглянути файл
@@ -41,6 +41,15 @@ pub struct DockerContainer {
pub compose_working_dir: Option<String>,
/// `com.docker.compose.project.config_files` label (comma-separated in docker).
pub compose_files: Vec<String>,
/// Mounted volume names or host paths reported by `docker ps` (`Mounts`).
#[serde(default)]
pub mounts: Vec<String>,
/// Connected network names reported by `docker ps` (`Networks`).
#[serde(default)]
pub networks: Vec<String>,
/// User and tool labels assigned to the container.
#[serde(default)]
pub labels: BTreeMap<String, String>,
}
impl DockerContainer {
@@ -80,6 +89,10 @@ pub struct DockerVolume {
pub driver: String,
pub mountpoint: String,
pub compose_project: Option<String>,
#[serde(default)]
pub scope: String,
#[serde(default)]
pub labels: BTreeMap<String, String>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
@@ -90,6 +103,46 @@ pub struct DockerNetwork {
pub driver: String,
pub scope: String,
pub compose_project: Option<String>,
#[serde(default)]
pub created_at: String,
#[serde(default)]
pub ipv6: bool,
#[serde(default)]
pub internal: bool,
#[serde(default)]
pub labels: BTreeMap<String, String>,
}
/// One layer in `docker image history --no-trunc --format '{{json .}}'`.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct DockerImageLayer {
pub id: String,
pub created_by: String,
pub size: String,
pub created_since: String,
pub created_at: String,
pub comment: String,
}
/// Process table returned by `docker top <container>`.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct DockerProcessList {
pub titles: Vec<String>,
pub processes: Vec<Vec<String>>,
}
/// Live resource metrics returned by `docker stats --no-stream --no-trunc --format '{{json .}}'`.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct DockerContainerStats {
pub cpu_perc: String,
pub mem_usage: String,
pub mem_perc: String,
pub net_io: String,
pub block_io: String,
pub pids: String,
}
/// Everything the Services tree shows, fetched in one go.
@@ -186,17 +239,20 @@ struct RawContainer {
ports: String,
created_at: String,
labels: String,
mounts: String,
networks: String,
}
fn split_csv(raw: &str) -> Vec<String> {
raw.split(',').map(str::trim).filter(|s| !s.is_empty()).map(str::to_owned).collect()
}
/// `docker ps -a --no-trunc --format '{{json .}}'`.
pub fn parse_containers(text: &str) -> Vec<DockerContainer> {
let mut out: Vec<DockerContainer> = lines::<RawContainer>(text)
.map(|r| {
let mut labels = parse_labels(&r.labels);
let files = labels
.remove(COMPOSE_FILES)
.map(|f| f.split(',').map(str::trim).filter(|s| !s.is_empty()).map(str::to_owned).collect())
.unwrap_or_default();
let all_labels = parse_labels(&r.labels);
let files = all_labels.get(COMPOSE_FILES).map(|f| split_csv(f)).unwrap_or_default();
// Older docker versions derive `State` from nothing: fall back on the status text.
let state = if r.state.is_empty() {
if r.status.starts_with("Up") { "running".into() } else { "exited".into() }
@@ -212,10 +268,13 @@ pub fn parse_containers(text: &str) -> Vec<DockerContainer> {
status: r.status,
ports: r.ports,
created_at: r.created_at,
compose_project: labels.remove(COMPOSE_PROJECT).filter(|s| !s.is_empty()),
compose_service: labels.remove(COMPOSE_SERVICE).filter(|s| !s.is_empty()),
compose_working_dir: labels.remove(COMPOSE_DIR).filter(|s| !s.is_empty()),
compose_project: all_labels.get(COMPOSE_PROJECT).filter(|s| !s.is_empty()).cloned(),
compose_service: all_labels.get(COMPOSE_SERVICE).filter(|s| !s.is_empty()).cloned(),
compose_working_dir: all_labels.get(COMPOSE_DIR).filter(|s| !s.is_empty()).cloned(),
compose_files: files,
mounts: split_csv(&r.mounts),
networks: split_csv(&r.networks),
labels: all_labels,
}
})
.collect();
@@ -258,17 +317,23 @@ struct RawVolume {
name: String,
driver: String,
mountpoint: String,
scope: String,
labels: String,
}
/// `docker volume ls --format '{{json .}}'`.
pub fn parse_volumes(text: &str) -> Vec<DockerVolume> {
let mut out: Vec<DockerVolume> = lines::<RawVolume>(text)
.map(|r| DockerVolume {
compose_project: parse_labels(&r.labels).remove(COMPOSE_PROJECT).filter(|s| !s.is_empty()),
name: r.name,
driver: r.driver,
mountpoint: r.mountpoint,
.map(|r| {
let labels = parse_labels(&r.labels);
DockerVolume {
compose_project: labels.get(COMPOSE_PROJECT).filter(|s| !s.is_empty()).cloned(),
name: r.name,
driver: r.driver,
mountpoint: r.mountpoint,
scope: r.scope,
labels,
}
})
.collect();
out.sort_by(|a, b| a.name.cmp(&b.name));
@@ -283,24 +348,121 @@ struct RawNetwork {
name: String,
driver: String,
scope: String,
created_at: String,
#[serde(rename = "IPv6")]
ipv6: String,
internal: String,
labels: String,
}
/// `docker network ls --no-trunc --format '{{json .}}'`.
pub fn parse_networks(text: &str) -> Vec<DockerNetwork> {
let mut out: Vec<DockerNetwork> = lines::<RawNetwork>(text)
.map(|r| DockerNetwork {
compose_project: parse_labels(&r.labels).remove(COMPOSE_PROJECT).filter(|s| !s.is_empty()),
id: r.id,
name: r.name,
driver: r.driver,
scope: r.scope,
.map(|r| {
let labels = parse_labels(&r.labels);
DockerNetwork {
compose_project: labels.get(COMPOSE_PROJECT).filter(|s| !s.is_empty()).cloned(),
id: r.id,
name: r.name,
driver: r.driver,
scope: r.scope,
created_at: r.created_at,
ipv6: r.ipv6.eq_ignore_ascii_case("true"),
internal: r.internal.eq_ignore_ascii_case("true"),
labels,
}
})
.collect();
out.sort_by(|a, b| a.name.cmp(&b.name));
out
}
#[derive(Deserialize, Default)]
#[serde(rename_all = "PascalCase", default)]
struct RawImageLayer {
#[serde(rename = "ID")]
id: String,
created_by: String,
size: String,
created_since: String,
created_at: String,
comment: String,
}
/// `docker image history --no-trunc --format '{{json .}}'`.
pub fn parse_image_history(text: &str) -> Vec<DockerImageLayer> {
lines::<RawImageLayer>(text)
.map(|r| DockerImageLayer {
id: r.id,
created_by: r.created_by,
size: r.size,
created_since: r.created_since,
created_at: r.created_at,
comment: r.comment,
})
.collect()
}
/// `docker top <container>` tabular output.
pub fn parse_container_top(text: &str) -> DockerProcessList {
let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty());
let Some(header) = lines.next() else {
return DockerProcessList::default();
};
let titles: Vec<String> = header.split_whitespace().map(str::to_owned).collect();
if titles.is_empty() {
return DockerProcessList::default();
}
let cols = titles.len();
let processes = lines
.map(|line| {
let mut row = Vec::with_capacity(cols);
let mut rest = line.trim_start();
for _ in 0..cols.saturating_sub(1) {
match rest.split_once(char::is_whitespace) {
Some((field, tail)) => {
row.push(field.to_owned());
rest = tail.trim_start();
}
None => break,
}
}
if !rest.is_empty() || row.len() < cols {
row.push(rest.to_owned());
}
row
})
.collect();
DockerProcessList { titles, processes }
}
#[derive(Deserialize, Default)]
#[serde(rename_all = "PascalCase", default)]
struct RawStats {
#[serde(rename = "CPUPerc")]
cpu_perc: String,
mem_usage: String,
mem_perc: String,
#[serde(rename = "NetIO")]
net_io: String,
#[serde(rename = "BlockIO")]
block_io: String,
#[serde(rename = "PIDs")]
pids: String,
}
/// `docker stats --no-stream --no-trunc --format '{{json .}}'`.
pub fn parse_container_stats(text: &str) -> Option<DockerContainerStats> {
lines::<RawStats>(text).next().map(|r| DockerContainerStats {
cpu_perc: r.cpu_perc,
mem_usage: r.mem_usage,
mem_perc: r.mem_perc,
net_io: r.net_io,
block_io: r.block_io,
pids: r.pids,
})
}
#[derive(Deserialize, Default)]
#[serde(default)]
struct RawVersion {
+44 -1
Переглянути файл
@@ -1,6 +1,9 @@
//! Parsing of recorded `docker … --format '{{json .}}'` output.
use ide_docker::{parse_containers, parse_images, parse_networks, parse_volumes};
use ide_docker::{
parse_container_stats, parse_container_top, parse_containers, parse_image_history, parse_images, parse_networks,
parse_volumes,
};
const PS: &str = include_str!("fixtures/ps.jsonl");
const IMAGES: &str = include_str!("fixtures/images.jsonl");
@@ -22,11 +25,16 @@ fn containers_with_compose_labels() {
assert_eq!(db.compose_working_dir.as_deref(), Some("/work/shop"));
assert_eq!(db.compose_files, ["/work/shop/compose.yaml", "/work/shop/compose.override.yaml"]);
assert_eq!(db.ports, "0.0.0.0:5432->5432/tcp");
assert_eq!(db.mounts, ["shop_pgdata"]);
assert_eq!(db.networks, ["shop_default"]);
assert_eq!(db.labels.get("com.docker.compose.project").map(String::as_str), Some("shop"));
let web = &c[1];
assert_eq!(web.name, "web", "leading slash of legacy names is dropped");
assert!(!web.running());
assert_eq!(web.compose_project, None);
assert!(web.mounts.is_empty());
assert_eq!(web.networks, ["bridge"]);
}
#[test]
@@ -44,9 +52,44 @@ fn volumes_and_networks() {
assert_eq!(v.iter().map(|v| v.name.as_str()).collect::<Vec<_>>(), ["0a1b", "shop_pgdata"]);
assert_eq!(v[1].compose_project.as_deref(), Some("shop"));
assert_eq!(v[1].mountpoint, "/var/lib/docker/volumes/shop_pgdata/_data");
assert_eq!(v[1].scope, "local");
assert_eq!(v[1].labels.get("com.docker.compose.volume").map(String::as_str), Some("pgdata"));
let n = parse_networks(NETWORKS);
assert_eq!(n.iter().map(|n| n.name.as_str()).collect::<Vec<_>>(), ["bridge", "host", "shop_default"]);
assert_eq!(n[2].compose_project.as_deref(), Some("shop"));
assert_eq!(n[1].driver, "host");
assert!(!n[2].ipv6);
assert!(!n[2].internal);
assert_eq!(n[2].labels.get("com.docker.compose.network").map(String::as_str), Some("default"));
}
#[test]
fn image_history_top_and_stats() {
let history = [
r#"{"Comment":"buildkit.dockerfile.v0","CreatedAt":"2026-10-01 09:00:00 +0000 UTC","CreatedBy":"CMD [\"postgres\"]","CreatedSince":"5 weeks ago","ID":"sha256:1234567890ab","Size":"0B"}"#,
r#"{"Comment":"","CreatedAt":"2026-10-01 08:59:00 +0000 UTC","CreatedBy":"/bin/sh -c #(nop) COPY file:abc in /usr/local/bin/ ","CreatedSince":"5 weeks ago","ID":"<missing>","Size":"42.5MB"}"#,
]
.join("\n");
let layers = parse_image_history(&history);
assert_eq!(layers.len(), 2);
assert_eq!(layers[0].created_by, "CMD [\"postgres\"]");
assert_eq!(layers[1].size, "42.5MB");
let top = "UID PID PPID C STIME TTY TIME CMD\nroot 1234 1200 0 09:12 ? 00:00:01 postgres -c config_file=/etc/postgresql.conf\n";
let parsed_top = parse_container_top(top);
assert_eq!(parsed_top.titles, ["UID", "PID", "PPID", "C", "STIME", "TTY", "TIME", "CMD"]);
assert_eq!(parsed_top.processes.len(), 1);
assert_eq!(parsed_top.processes[0][0], "root");
assert_eq!(parsed_top.processes[0][1], "1234");
assert_eq!(parsed_top.processes[0][7], "postgres -c config_file=/etc/postgresql.conf");
let stats_line = r#"{"BlockIO":"1.2MB / 0B","CPUPerc":"1.25%","Container":"shop-db-1","ID":"4f2b8c1d9e0a","MemPerc":"2.50%","MemUsage":"50MiB / 2GiB","Name":"shop-db-1","NetIO":"12kB / 8kB","PIDs":"14"}"#;
let stats = parse_container_stats(stats_line).expect("stats parsed");
assert_eq!(stats.cpu_perc, "1.25%");
assert_eq!(stats.mem_usage, "50MiB / 2GiB");
assert_eq!(stats.mem_perc, "2.50%");
assert_eq!(stats.net_io, "12kB / 8kB");
assert_eq!(stats.block_io, "1.2MB / 0B");
assert_eq!(stats.pids, "14");
}
+1
Переглянути файл
@@ -18,6 +18,7 @@ semver = "1"
blake3.workspace = true
zip.workspace = true
base64.workspace = true
dunce.workspace = true
ed25519-dalek = "3"
[dev-dependencies]
+216 -5
Переглянути файл
@@ -20,6 +20,10 @@ pub enum Capability {
EditorWrite,
/// Create and replace files inside the project root.
ProjectWrite,
/// Run arbitrary IDE actions and other plugins' commands.
IdeCommands,
/// Manage only its own project database sources and write their credentials.
DatabaseSources,
VcsRead,
VcsWrite,
/// The plugin's own key-value store and data directory.
@@ -32,8 +36,16 @@ pub enum Capability {
InputEvents,
/// The system clipboard, from the plugin's web views.
Clipboard,
/// HTTP(S) to one host (`*.example.com` matches subdomains).
/// HTTPS (and loopback HTTP) to one host (`*.example.com` matches subdomains).
Net(String),
/// Ask the user when the first request goes to an HTTPS (or loopback) host
/// the manifest does not list; each answer applies to one host. Plain
/// HTTP elsewhere always needs `net.http:<host>`.
NetAsk,
/// Unencrypted HTTP to one host.
NetHttp(String),
/// A local socket or named pipe on the project machine.
Ipc(String),
/// Run one external program. Full code execution: never fine-grained.
Process(String),
/// Read files outside the project under one path.
@@ -60,12 +72,15 @@ impl Capability {
Self::ProjectRead | Self::EditorRead | Self::Storage | Self::Events | Self::VcsRead => Risk::Low,
Self::EditorWrite
| Self::ProjectWrite
| Self::DatabaseSources
| Self::VcsWrite
| Self::Net(_)
| Self::NetAsk
| Self::NetHttp(_)
| Self::Secrets
| Self::InputEvents
| Self::Clipboard => Risk::Medium,
Self::Process(_) | Self::FsRead(_) | Self::FsWrite(_) => Risk::High,
Self::IdeCommands | Self::Ipc(_) | Self::Process(_) | Self::FsRead(_) | Self::FsWrite(_) => Risk::High,
}
}
@@ -82,6 +97,8 @@ impl Capability {
Self::EditorRead => "Read the focused editor's file and selection".into(),
Self::EditorWrite => "Change text in the editor".into(),
Self::ProjectWrite => "Create and change files in the open project".into(),
Self::DatabaseSources => "Manage its own database sources and store their passwords in trusted projects".into(),
Self::IdeCommands => "Run any IDE action or other plugins' commands as if you chose them, including running programs, committing and pushing".into(),
Self::VcsRead => "Read version control state".into(),
Self::VcsWrite => "Change version control state (commit, fetch, checkout)".into(),
Self::Storage => "Store its own data".into(),
@@ -92,6 +109,16 @@ impl Capability {
"Copy to the clipboard from its commands, and read and write it from its web views".into()
}
Self::Net(host) => format!("Connect to {host} (from the machine the project is on)"),
Self::NetAsk => {
"Ask to connect to a host that is not on the list the first time it tries; you allow or refuse each host, and can revoke the allowed ones"
.into()
}
Self::NetHttp(host) => {
format!("Connect to {host} over unencrypted HTTP (from the machine the project is on)")
}
Self::Ipc(path) => {
format!("Connect to local socket {path} — equivalent to running arbitrary code on the project machine")
}
Self::Process(bin) => format!("Run {bin} — equivalent to running arbitrary code on this machine"),
Self::FsRead(path) => format!("Read files under {path}, outside the project"),
Self::FsWrite(path) => format!("Change files under {path}, outside the project"),
@@ -100,8 +127,14 @@ impl Capability {
/// Whether `self`, granted, also covers `wanted` (`net:*.x.com` covers `net:a.x.com`).
pub fn covers(&self, wanted: &Capability) -> bool {
if self == wanted {
return true;
}
match (self, wanted) {
(Self::Net(pattern), Self::Net(host)) => host_matches(pattern, host),
(Self::Ipc(pattern), Self::Ipc(path)) => ipc_matches(pattern, path),
(Self::Net(pattern), Self::Net(host)) | (Self::NetHttp(pattern), Self::NetHttp(host)) => {
host_matches(pattern, host)
}
(Self::FsRead(base), Self::FsRead(path)) | (Self::FsWrite(base), Self::FsWrite(path)) => {
std::path::Path::new(path).starts_with(std::path::Path::new(base))
}
@@ -120,6 +153,75 @@ pub fn host_matches(pattern: &str, host: &str) -> bool {
}
}
/// Validates a socket path; a grant may use `*` in its last segment only.
pub fn validate_ipc_name(name: &str, pattern: bool) -> Result<(), String> {
let pipe = name.starts_with(r"\\.\pipe\");
let unix = name.starts_with('/')
|| name.starts_with("~/")
|| name.starts_with("$TMPDIR/")
|| name.starts_with("$XDG_RUNTIME_DIR/");
let parts: Vec<_> = name.strip_prefix(r"\\.\pipe\").unwrap_or(name).split(if pipe { '\\' } else { '/' }).collect();
if !(pipe || unix)
|| name.contains(['\0', '?'])
|| name.len() > 4096
|| parts.last().is_none_or(|p| p.is_empty())
|| parts.iter().any(|p| matches!(*p, "." | ".."))
|| parts[..parts.len().saturating_sub(1)].iter().any(|p| p.contains('*'))
|| (!pattern && name.contains('*'))
{
return Err(
"ipc: use an absolute socket path or \\\\.\\pipe\\name; only the last grant segment may contain '*'".into(),
);
}
Ok(())
}
/// Expands the three supported host-local prefixes; no shell is involved.
pub fn expand_ipc_name(name: &str) -> Result<String, String> {
validate_ipc_name(name, true)?;
for (prefix, key) in [("~/", "HOME"), ("$TMPDIR/", "TMPDIR"), ("$XDG_RUNTIME_DIR/", "XDG_RUNTIME_DIR")] {
if let Some(rest) = name.strip_prefix(prefix) {
let base = std::env::var(key).map_err(|_| format!("{key} is not set"))?;
let expanded = format!("{}/{rest}", base.trim_end_matches('/'));
validate_ipc_name(&expanded, true)?;
return Ok(expanded);
}
}
Ok(name.into())
}
fn ipc_matches(pattern: &str, name: &str) -> bool {
let (Ok(pattern), Ok(name)) = (expand_ipc_name(pattern), expand_ipc_name(name)) else { return false };
if validate_ipc_name(&name, false).is_err() {
return false;
}
let separator = if pattern.starts_with(r"\\.\pipe\") { '\\' } else { '/' };
let (Some((parent, pattern)), Some((other, name))) = (pattern.rsplit_once(separator), name.rsplit_once(separator))
else {
return false;
};
if parent != other {
return false;
}
let mut rest = name;
let mut pieces = pattern.split('*');
let first = pieces.next().unwrap_or_default();
let Some(tail) = rest.strip_prefix(first) else { return false };
rest = tail;
let pieces: Vec<_> = pieces.collect();
if pieces.is_empty() {
return rest.is_empty();
}
for (i, piece) in pieces.iter().enumerate() {
if i + 1 == pieces.len() {
return rest.ends_with(piece);
}
let Some(at) = rest.find(piece) else { return false };
rest = &rest[at + piece.len()..];
}
true
}
impl fmt::Display for Capability {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
@@ -127,6 +229,8 @@ impl fmt::Display for Capability {
Self::EditorRead => f.write_str("editor.read"),
Self::EditorWrite => f.write_str("editor.write"),
Self::ProjectWrite => f.write_str("project.write"),
Self::IdeCommands => f.write_str("ide.commands"),
Self::DatabaseSources => f.write_str("database.sources"),
Self::VcsRead => f.write_str("vcs.read"),
Self::VcsWrite => f.write_str("vcs.write"),
Self::Storage => f.write_str("storage"),
@@ -135,6 +239,9 @@ impl fmt::Display for Capability {
Self::InputEvents => f.write_str("events.input"),
Self::Clipboard => f.write_str("clipboard"),
Self::Net(h) => write!(f, "net:{h}"),
Self::NetAsk => f.write_str("net:ask"),
Self::NetHttp(h) => write!(f, "net.http:{h}"),
Self::Ipc(p) => write!(f, "ipc:{p}"),
Self::Process(b) => write!(f, "process:{b}"),
Self::FsRead(p) => write!(f, "fs.read:{p}"),
Self::FsWrite(p) => write!(f, "fs.write:{p}"),
@@ -153,7 +260,10 @@ impl FromStr for Capability {
return Err(format!("capability {s:?} needs a value after ':'"));
}
return match kind {
"net" => {
"net" | "net.http" => {
if kind == "net" && arg == "ask" {
return Ok(Self::NetAsk);
}
let host = arg.strip_prefix("*.").unwrap_or(arg);
let valid = !host.is_empty()
&& host.len() <= 253
@@ -162,7 +272,19 @@ impl FromStr for Capability {
&& label.len() <= 63
&& label.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')
});
if valid { Ok(Self::Net(arg.to_ascii_lowercase())) } else { Err(format!("invalid host in {s:?}")) }
if valid {
Ok(if kind == "net" {
Self::Net(arg.to_ascii_lowercase())
} else {
Self::NetHttp(arg.to_ascii_lowercase())
})
} else {
Err(format!("invalid host in {s:?}"))
}
}
"ipc" => {
validate_ipc_name(arg, true)?;
Ok(Self::Ipc(arg.to_owned()))
}
"process" => {
if arg.contains(['/', '\\']) && !std::path::Path::new(arg).is_absolute() {
@@ -184,6 +306,8 @@ impl FromStr for Capability {
"editor.read" | "editor-read" => Self::EditorRead,
"editor.write" | "editor-write" => Self::EditorWrite,
"project.write" => Self::ProjectWrite,
"ide.commands" => Self::IdeCommands,
"database.sources" => Self::DatabaseSources,
"vcs.read" => Self::VcsRead,
"vcs.write" => Self::VcsWrite,
"storage" => Self::Storage,
@@ -292,3 +416,90 @@ mod tests {
assert!(!Capability::FsRead("/etc".into()).covers(&Capability::FsRead("/etcx".into())));
}
}
#[cfg(test)]
mod api14_tests {
use super::*;
#[test]
fn http_grants_do_not_imply_https_and_socket_patterns_stay_in_one_directory() {
let http: Capability = "net.http:*.example.com".parse().unwrap();
assert_eq!(http.to_string(), "net.http:*.example.com");
assert_eq!(http.risk(), Risk::Medium);
assert!(http.describe().contains("unencrypted"));
assert!(http.covers(&"net.http:a.example.com".parse().unwrap()));
assert!(!http.covers(&"net:a.example.com".parse().unwrap()));
assert!(!"net:*.example.com".parse::<Capability>().unwrap().covers(&"net.http:a.example.com".parse().unwrap()));
let socket: Capability = "ipc:/tmp/discord-ipc-*-socket".parse().unwrap();
assert_eq!(socket.risk(), Risk::High);
assert!(socket.covers(&Capability::Ipc("/tmp/discord-ipc-42-socket".into())));
assert!(!socket.covers(&Capability::Ipc("/tmp/discord-ipc-dir/evil-socket".into())));
assert!(!socket.covers(&Capability::Ipc("/tmp/../tmp/discord-ipc-42-socket".into())));
assert_eq!("ipc:$TMPDIR/discord-ipc-*".parse::<Capability>().unwrap().to_string(), "ipc:$TMPDIR/discord-ipc-*");
assert!(
r"ipc:\\.\pipe\discord-*"
.parse::<Capability>()
.unwrap()
.covers(&Capability::Ipc(r"\\.\pipe\discord-0".into()))
);
for invalid in
["ipc:relative", "ipc:/tmp/*/socket", "ipc:/tmp/../socket", "ipc:/tmp/", "ipc:/tmp/socket?", "net.http:a b"]
{
assert!(invalid.parse::<Capability>().is_err(), "{invalid}");
}
}
}
#[cfg(test)]
mod command_tests {
use super::*;
#[test]
fn ide_commands_is_explicit_and_high_risk() {
let cap: Capability = "ide.commands".parse().unwrap();
assert_eq!(cap, Capability::IdeCommands);
assert_eq!(cap.to_string(), "ide.commands");
assert_eq!(cap.risk(), Risk::High);
assert!(!cap.auto_migrates());
assert!(!Capability::VcsWrite.covers(&cap));
assert!(!Capability::Process("git".into()).covers(&cap));
}
}
#[cfg(test)]
mod net_ask_tests {
use super::*;
#[test]
fn net_ask_is_its_own_medium_risk_grant_covering_no_host() {
let cap: Capability = "net:ask".parse().unwrap();
assert_eq!(cap, Capability::NetAsk);
assert_eq!(cap.to_string(), "net:ask");
assert_eq!(cap.risk(), Risk::Medium);
assert!(!cap.auto_migrates());
// It asks; it does not itself connect anywhere or pre-approve a host.
assert!(!cap.covers(&"net:api.example.com".parse().unwrap()));
assert!(!cap.covers(&Capability::NetHttp("example.com".into())));
assert!(!"net:api.example.com".parse::<Capability>().unwrap().covers(&cap));
assert_ne!("net:ask.com".parse::<Capability>().unwrap(), Capability::NetAsk);
assert!("net:ask".parse::<Capability>().unwrap().describe().contains("host"));
}
}
#[cfg(test)]
mod database_tests {
use super::*;
#[test]
fn database_sources_is_a_medium_risk_exact_grant_requiring_api4() {
let cap: Capability = "database.sources".parse().unwrap();
assert_eq!(cap, Capability::DatabaseSources);
assert_eq!(cap.to_string(), "database.sources");
assert_eq!(cap.risk(), Risk::Medium);
assert!(!cap.auto_migrates());
assert!(!Capability::ProjectRead.covers(&cap));
assert!(!Capability::ProjectWrite.covers(&cap));
for (api, valid) in [("^1.3", false), ("^1.4", true)] {
let text = format!(
"id='db-test'\nname='DB'\nversion='1.0.0'\nengines.openidea='{api}'\n[capabilities]\nrequired=['database.sources']"
);
assert_eq!(crate::Manifest::parse(&text).is_ok(), valid);
}
}
}
+151 -2
Переглянути файл
@@ -53,12 +53,21 @@ pub struct Grant {
/// Granted capabilities (`project.read`, `net:x`, …).
#[serde(default)]
pub granted: BTreeSet<String>,
/// Exact hosts approved through `net:ask`, separate from manifest grants.
#[serde(default)]
pub net_hosts: BTreeSet<String>,
/// Optional capabilities the user declined (not asked again until re-confirmed).
#[serde(default)]
pub declined: BTreeSet<String>,
/// `process:<program>` → the program confirmed for it.
#[serde(default)]
pub binaries: BTreeMap<String, BinaryGrant>,
/// Project root → process grant → the executable approved for that project.
#[serde(default)]
pub project_binaries: BTreeMap<PathBuf, BTreeMap<String, BinaryGrant>>,
/// Explicit global executable choices, retained for the next re-approval.
#[serde(default)]
pub program_choices: BTreeMap<String, PathBuf>,
/// Migrated from 0.1: capabilities not granted yet are asked for on first
/// use instead of blocking the plugin from loading.
#[serde(default)]
@@ -195,6 +204,9 @@ impl Ledger {
let Some(grant) = self.grants.get_mut(&req.subject) else {
return Decision::NeedsConfirmation { new: req.required.clone(), first_time: true };
};
if !req.required.iter().chain(&req.optional).any(|c| c == &Capability::NetAsk) {
grant.net_hosts.clear();
}
if !grant.confirmed {
return Decision::NeedsConfirmation { new: req.required.clone(), first_time: true };
}
@@ -219,6 +231,9 @@ impl Ledger {
binaries: BTreeMap<String, BinaryGrant>,
) {
let grant = self.grants.entry(req.subject.clone()).or_default();
if !req.required.iter().chain(&req.optional).any(|c| c == &Capability::NetAsk) {
grant.net_hosts.clear();
}
grant.plugin_id = req.plugin_id.to_owned();
grant.version = req.version.to_owned();
grant.package_hash = req.package_hash.to_owned();
@@ -246,13 +261,35 @@ impl Ledger {
grant.declined.remove(&s);
grant.granted.insert(s)
} else {
if let Capability::Net(host) = cap {
return grant.net_hosts.remove(host) | grant.granted.remove(&s);
}
if cap == &Capability::NetAsk {
grant.net_hosts.clear();
}
if let Capability::Process(_) = cap {
grant.binaries.remove(&s);
for binaries in grant.project_binaries.values_mut() {
binaries.remove(&s);
}
}
grant.granted.remove(&s)
}
}
/// Remembers one exact host without granting a manifest capability.
pub fn remember_net_host(&mut self, subject: &Subject, host: &str) -> bool {
let Some(grant) = self.grants.get_mut(subject) else { return false };
if !grant.confirmed || !grant.has(&Capability::NetAsk) || host.contains('*') {
return false;
}
grant.net_hosts.insert(host.to_owned())
}
pub fn forget_net_host(&mut self, subject: &Subject, host: &str) -> bool {
self.grants.get_mut(subject).is_some_and(|g| g.net_hosts.remove(host))
}
/// Records the program approved for a `process:` grant.
pub fn set_binary(&mut self, subject: &Subject, cap: &Capability, binary: BinaryGrant) {
if let Some(grant) = self.grants.get_mut(subject) {
@@ -260,6 +297,58 @@ impl Ledger {
}
}
/// The user's explicit global executable choice, if any.
pub fn program_choice(&self, subject: &Subject, cap: &Capability) -> Option<&Path> {
self.grants.get(subject)?.program_choices.get(&cap.to_string()).map(PathBuf::as_path)
}
/// Remembers or resets an explicit global executable choice after confirmation.
pub fn set_program_choice(&mut self, subject: &Subject, cap: &Capability, path: Option<&Path>) {
if let Some(grant) = self.grants.get_mut(subject) {
if let Some(path) = path {
grant.program_choices.insert(cap.to_string(), path.into());
} else {
grant.program_choices.remove(&cap.to_string());
}
}
}
/// Updates a project-specific program choice after user confirmation.
pub fn set_project_binary(
&mut self,
subject: &Subject,
cap: &Capability,
root: &Path,
binary: Option<BinaryGrant>,
) {
if let Some(grant) = self.grants.get_mut(subject) {
if let Some(binary) = binary {
grant.project_binaries.entry(root.into()).or_default().insert(cap.to_string(), binary);
} else if let Some(binaries) = grant.project_binaries.get_mut(root) {
binaries.remove(&cap.to_string());
}
}
}
/// The project override when present, otherwise the global approved program.
pub fn check_binary_in_project(
&self,
subject: &Subject,
cap: &Capability,
root: Option<&Path>,
) -> Result<PathBuf, String> {
let grant = self.grants.get(subject).ok_or_else(|| format!("{cap} was not granted"))?;
if !grant.has(cap) {
return Err(format!("{cap} was not granted"));
}
if let Some(bin) = root.and_then(|root| grant.project_binaries.get(root)).and_then(|b| b.get(&cap.to_string()))
{
return match file_hash(&bin.path) {
Ok(hash) if hash == bin.hash => Ok(bin.path.clone()),
_ => Err(format!("{} changed or is missing; confirm {cap} again", bin.path.display())),
};
}
self.check_binary(subject, cap)
}
/// Checks a `process:` grant at call time: the program confirmed for it,
/// unchanged since. Errors name what to do.
pub fn check_binary(&self, subject: &Subject, cap: &Capability) -> Result<PathBuf, String> {
@@ -306,6 +395,16 @@ pub fn by_risk(caps: &[Capability]) -> Vec<(Risk, Vec<Capability>)> {
groups.into_iter().map(|(r, v)| (r.0, v)).collect()
}
/// Pins an explicitly selected executable, retaining a multicall shim's filename.
pub fn approve_program(path: &Path) -> Result<BinaryGrant, String> {
if !path.is_absolute() || !is_executable(path) {
return Err("choose an absolute executable file".into());
}
let path = canonical_dir(path);
let hash = file_hash(&path).map_err(|e| e.to_string())?;
Ok(BinaryGrant { path, hash })
}
/// Resolves a `process:` program to an absolute path once, at grant time:
/// absolute names are taken as is, others are looked up in `path_var`.
/// The directory is canonicalized but the file name kept as found: `cargo`,
@@ -342,11 +441,13 @@ pub fn resolve_program(name: &str, path_var: Option<&OsStr>) -> Result<PathBuf,
/// `p` with its parent canonicalized but the file name as found: PATH
/// entries like `/var` → `/private/var` (macOS) or `~` links collapse to
/// their real directory, while a linked program (`cargo` → rustup) keeps
/// its link name so the shim still dispatches on argv[0].
/// its link name so the shim still dispatches on argv[0]. `dunce` keeps the
/// path in its usual form on Windows: a `\\?\`-prefixed pin would never
/// match the path the program was actually started with.
fn canonical_dir(p: &Path) -> PathBuf {
let Some(dir) = p.parent() else { return p.to_path_buf() };
let Some(name) = p.file_name() else { return p.to_path_buf() };
std::fs::canonicalize(dir).map(|d| d.join(name)).unwrap_or_else(|_| p.to_path_buf())
dunce::canonicalize(dir).map(|d| d.join(name)).unwrap_or_else(|_| p.to_path_buf())
}
fn is_executable(p: &Path) -> bool {
@@ -399,6 +500,20 @@ mod tests {
list.iter().map(|s| s.parse().unwrap()).collect()
}
#[test]
#[cfg(windows)]
fn pinned_program_path_has_no_verbatim_prefix() {
// std::fs::canonicalize returns \\?\-prefixed paths on Windows; a pin
// stored in that form would never match the plain path the program is
// started with. dunce keeps the usual form.
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("cargo.exe"), b"").unwrap();
let pinned = resolve_program("cargo.exe", Some(dir.path().as_os_str())).unwrap();
let shown = pinned.to_string_lossy();
assert!(!shown.starts_with(r"\\?\"), "verbatim prefix leaked into the pin: {shown}");
assert!(pinned.is_absolute());
}
fn req<'a>(version: &'a str, hash: &'a str, required: &[&str], optional: &[&str]) -> Request<'a> {
Request {
subject: Subject::Installed("p".into()),
@@ -439,6 +554,40 @@ mod tests {
assert!(matches!(l.decide(&r3), Decision::NeedsConfirmation { .. }));
}
#[test]
fn net_ask_hosts_persist_revoke_and_drop_with_the_capability() {
let tmp = tempfile::tempdir().unwrap();
let mut l = Ledger::load(tmp.path());
let r = req("1.0.0", "h", &["net:ask"], &[]);
l.confirm(&r, &[], BTreeMap::new());
let host = "api.example.com";
assert!(l.remember_net_host(&r.subject, host));
assert!(!l.remember_net_host(&r.subject, "*.example.com"));
assert!(!l.get(&r.subject).unwrap().has(&Capability::Net(host.into())));
l.save().unwrap();
l = Ledger::load(tmp.path());
assert!(l.get(&r.subject).unwrap().net_hosts.contains(host));
l.confirm(&r, &[], BTreeMap::new());
assert!(l.get(&r.subject).unwrap().net_hosts.contains(host));
assert!(l.set(&r.subject, &Capability::Net(host.into()), false));
assert!(l.get(&r.subject).unwrap().net_hosts.is_empty());
for confirm in [false, true] {
l.remember_net_host(&r.subject, host);
// A newly declared host must not inherit an ask grant after net:ask is removed.
let update = req("2.0.0", "h2", &["project.read", "net:api.example.com"], &[]);
if confirm {
l.confirm(&update, &[], BTreeMap::new());
} else {
assert!(matches!(l.decide(&update), Decision::NeedsConfirmation { .. }));
}
assert!(l.get(&r.subject).unwrap().net_hosts.is_empty());
}
l.remember_net_host(&r.subject, host);
l.set(&r.subject, &Capability::NetAsk, false);
assert!(l.get(&r.subject).unwrap().net_hosts.is_empty());
}
#[test]
fn migration_grants_low_risk_only() {
let tmp = tempfile::tempdir().unwrap();
+1 -1
Переглянути файл
@@ -24,7 +24,7 @@ mod registry;
mod ui;
mod when;
pub use capability::{Capability, CapabilityInfo, Risk, host_matches};
pub use capability::{Capability, CapabilityInfo, Risk, expand_ipc_name, host_matches, validate_ipc_name};
pub use index::ContributionIndex;
pub use manifest::{
ActivationEvent, CATEGORIES, Capabilities, ENGINE_VERSION, EVENT_KINDS, LEGACY_API, MANIFEST_FILE, MAX_CATEGORIES,
+90 -3
Переглянути файл
@@ -32,7 +32,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.3.0";
pub const ENGINE_VERSION: &str = "1.5.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.
@@ -144,8 +144,20 @@ impl ActivationEvent {
/// Workbench event kinds (`on-event:<kind>`, WIT `events.event-kind`).
/// `scheduled` comes from `on-schedule` instead.
pub const EVENT_KINDS: &[&str] =
&["file-opened", "file-saved", "files-changed", "editor-focused", "action-performed", "vcs-changed"];
pub const EVENT_KINDS: &[&str] = &[
"run-exited",
"terminal-exited",
"content-tab-action",
"file-closed",
"settings-changed",
"input-mode-changed",
"file-opened",
"file-saved",
"files-changed",
"editor-focused",
"action-performed",
"vcs-changed",
];
impl std::fmt::Display for ActivationEvent {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
@@ -317,9 +329,18 @@ impl Manifest {
check_relative(icon, "icon")?;
}
for cap in self.capabilities.required.iter().chain(&self.capabilities.optional) {
if matches!(
cap,
Capability::IdeCommands | Capability::DatabaseSources | Capability::NetHttp(_) | Capability::Ipc(_)
) {
self.needs_api(&format!("capability `{cap}`"), 4)?;
}
if matches!(cap, Capability::ProjectWrite | Capability::FsWrite(_)) {
self.needs_api(&format!("capability `{cap}`"), 3)?;
}
if matches!(cap, Capability::NetAsk) {
self.needs_api(&format!("capability `{cap}`"), 5)?;
}
}
if let Some(dark) = &self.icon_dark {
if self.icon.is_none() {
@@ -332,6 +353,10 @@ impl Manifest {
}
let mut seen = BTreeSet::new();
for event in &self.activation {
if matches!(event, ActivationEvent::Event(kind) if matches!(kind.as_str(), "file-closed" | "settings-changed" | "input-mode-changed" | "run-exited" | "terminal-exited" | "content-tab-action"))
{
self.needs_api(&format!("`{event}`"), 4)?;
}
if matches!(event, ActivationEvent::Event(kind) if kind == "files-changed") {
self.needs_api("`on-event:files-changed`", 3)?;
}
@@ -655,3 +680,65 @@ mod tests {
assert!(!engine_compatible("0.10", true));
}
}
#[cfg(test)]
mod api14_tests {
use super::*;
#[test]
fn new_capabilities_and_activations_require_api_1_4() {
for declaration in [
"[capabilities]\nrequired = [\"net.http:example.com\"]",
"[capabilities]\noptional = [\"ipc:$TMPDIR/test-*\"]",
"activation = [\"on-event:file-closed\"]",
"activation = [\"on-event:settings-changed\"]",
"activation = [\"on-event:input-mode-changed\"]",
"activation = [\"on-event:run-exited\"]",
"activation = [\"on-event:terminal-exited\"]",
"activation = [\"on-event:content-tab-action\"]",
] {
for engine in ["^1.0", "^1.3", "^1.4"] {
let manifest = format!(
"id = 'test'\nname = 'Test'\nversion = '0.1.0'\nwasm = 'plugin.wasm'\nengines.openidea = '{engine}'\n{declaration}\n"
);
let parsed = Manifest::parse(&manifest);
if engine == "^1.4" {
parsed.unwrap();
} else {
assert!(parsed.unwrap_err().contains("new in plugin API 1.4"));
}
}
}
}
}
#[cfg(test)]
mod commands_api_tests {
use super::*;
#[test]
fn ide_commands_requires_api_1_4() {
let manifest = "id = \"test\"\nname = \"Test\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.3\"\n[capabilities]\nrequired = [\"ide.commands\"]\n";
assert!(Manifest::parse(manifest).unwrap_err().contains("1.4"));
assert!(Manifest::parse(&manifest.replace("^1.3", "^1.4")).is_ok());
}
}
#[cfg(test)]
mod net_ask_api_tests {
use super::*;
#[test]
fn net_ask_requires_api_1_5() {
for declaration in ["[capabilities]\nrequired = [\"net:ask\"]", "[capabilities]\noptional = [\"net:ask\"]"] {
for engine in ["^1.4", "^1.5"] {
let manifest = format!(
"id = 'test'\nname = 'Test'\nversion = '0.1.0'\nwasm = 'plugin.wasm'\nengines.openidea = '{engine}'\n{declaration}\n"
);
let parsed = Manifest::parse(&manifest);
if engine == "^1.5" {
parsed.unwrap();
} else {
assert!(parsed.unwrap_err().contains("new in plugin API 1.5"), "{engine}");
}
}
}
}
}
+2 -2
Переглянути файл
@@ -627,8 +627,8 @@ mod tests {
#[test]
fn finds_remote_by_repo() {
let remotes = vec![
ide_vcs::Remote { name: "origin".into(), url: "[email protected]:me/App.git".into() },
ide_vcs::Remote { name: "upstream".into(), url: "https://github.com/org/app/".into() },
ide_vcs::Remote { name: "origin".into(), url: "[email protected]:me/App.git".into(), resolved_url: None },
ide_vcs::Remote { name: "upstream".into(), url: "https://github.com/org/app/".into(), resolved_url: None },
];
assert_eq!(remote_for(&remotes, "me/app").map(|r| r.name.as_str()), Some("origin"));
assert_eq!(remote_for(&remotes, "org/app").map(|r| r.name.as_str()), Some("upstream"));
+1
Переглянути файл
@@ -52,6 +52,7 @@ ide-tools = { path = "../ide-tools" }
ide-docker = { path = "../ide-docker" }
semver = "1"
serde.workspace = true
jsonschema = { version = "0.33", default-features = false }
serde_json = { workspace = true, features = ["raw_value"] }
toml.workspace = true
specta.workspace = true
+3 -1
Переглянути файл
@@ -368,7 +368,9 @@ fn check(dir: &Path) -> Result<(), String> {
let manifest = Manifest::load(&dir)?;
println!("{} {} — {}", preview.id, preview.version, preview.name);
if manifest.legacy {
println!(" note: plugin.toml uses the 0.1 format (`openidea-api`); see plugins/README.md to move to 0.2");
println!(
" note: plugin.toml uses the 0.1 format (`openidea-api`); its code no longer runs: rebuild it against the 1.x SDK and declare `engines.openidea` (see plugins/README.md)"
);
}
if let Some(wasm) = &manifest.wasm {
let engine = PluginEngine::new()?;
+1 -1
Переглянути файл
@@ -208,7 +208,7 @@ authors = []
engines.openidea = "^1.0"
# One entry per theme. `editor-scheme` is optional: its tokens are merged over
# the theme's (IntelliJ's .theme.json + .icls pair). Reference: docs/plugins/themes.md.
# the theme's (IntelliJ's .theme.json + .icls pair). Reference: plugins/themes.md.
[[contributes.themes]]
path = "themes/__SLUG__.theme.json"
editor-scheme = "themes/__SLUG__.scheme.json"
+124
Переглянути файл
@@ -1,9 +1,37 @@
//! Storage policy and best-effort migration of rebuildable desktop data.
use std::ffi::OsString;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
/// Moves the rebuildable local data (tools, language servers, indexes, caches);
/// configuration and roaming data keep following the OS.
pub const LOCAL_DATA_ENV: &str = "OPENIDEA_LOCAL_DATA_DIR";
/// `$OPENIDEA_LOCAL_DATA_DIR/<identifier>` when set, else the OS local data directory
/// (Tauri's `app_local_data_dir`). The identifier keeps release and per-worktree
/// debug builds apart under one root. A set but unusable path is an error, never a
/// silent fallback to the OS location.
pub fn local_data_dir(identifier: &str) -> Result<PathBuf, String> {
resolve_local(std::env::var_os(LOCAL_DATA_ENV), dirs::data_local_dir(), identifier)
}
fn resolve_local(var: Option<OsString>, os: Option<PathBuf>, identifier: &str) -> Result<PathBuf, String> {
let Some(root) = var.filter(|v| !v.is_empty()).map(PathBuf::from) else {
return os.map(|dir| dir.join(identifier)).ok_or_else(|| "cannot find the local data directory".into());
};
// The working directory depends on how the IDE was launched.
if !root.is_absolute() {
return Err(format!("{LOCAL_DATA_ENV} must be an absolute path: {}", root.display()));
}
let dir = root.join(identifier);
fs::create_dir_all(&dir)
.and_then(|()| tempfile::NamedTempFile::new_in(&dir).map(drop))
.map_err(|error| format!("{LOCAL_DATA_ENV}: cannot write to {}: {error}", dir.display()))?;
Ok(dir)
}
/// Unknown entries and recovery/user content deliberately stay in roaming data.
pub fn is_local(entry: &Path) -> bool {
["tools", "language-server-runtimes", "index", "cache", "lsp", "notebook-runtime", "server", "logs"]
@@ -276,6 +304,102 @@ fn relative_wrapper(text: &str, source: &Path) -> String {
mod tests {
use super::*;
#[test]
fn local_dir_follows_the_os_unless_the_variable_is_set() {
let os = Some(PathBuf::from("/os-local"));
for var in [None, Some(OsString::new())] {
assert_eq!(
resolve_local(var, os.clone(), "ai.l0ng.openidea").unwrap(),
Path::new("/os-local/ai.l0ng.openidea")
);
}
assert!(resolve_local(None, None, "id").is_err());
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("missing/nested");
let dir = resolve_local(Some(root.clone().into()), os.clone(), "ai.l0ng.openidea").unwrap();
assert_eq!(dir, root.join("ai.l0ng.openidea"));
assert!(dir.is_dir());
assert_eq!(fs::read_dir(&dir).unwrap().count(), 0);
// Debug worktrees have their own identifiers and stay apart under one root.
assert_ne!(resolve_local(Some(root.clone().into()), None, "dev-a").unwrap(), dir);
}
#[test]
fn local_dir_preserves_an_existing_probe_file() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("id");
fs::create_dir(&dir).unwrap();
let probe = dir.join(".openidea-write-check");
fs::write(&probe, "keep").unwrap();
assert_eq!(resolve_local(Some(tmp.path().into()), None, "id").unwrap(), dir);
assert_eq!(fs::read_to_string(probe).unwrap(), "keep");
assert_eq!(fs::read_dir(dir).unwrap().count(), 1);
}
#[cfg(unix)]
#[test]
fn local_dir_preserves_an_existing_probe_symlink_and_its_target() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("id");
fs::create_dir(&dir).unwrap();
let target = tmp.path().join("target");
fs::write(&target, "keep").unwrap();
let probe = dir.join(".openidea-write-check");
std::os::unix::fs::symlink(&target, &probe).unwrap();
assert_eq!(resolve_local(Some(tmp.path().into()), None, "id").unwrap(), dir);
assert_eq!(fs::read_to_string(target).unwrap(), "keep");
assert!(fs::symlink_metadata(probe).unwrap().file_type().is_symlink());
assert_eq!(fs::read_dir(dir).unwrap().count(), 1);
}
#[test]
fn local_dir_allows_concurrent_probes_for_the_same_identifier() {
let tmp = tempfile::tempdir().unwrap();
let barrier = std::sync::Barrier::new(8);
std::thread::scope(|scope| {
for _ in 0..8 {
let barrier = &barrier;
let root = tmp.path();
scope.spawn(move || {
barrier.wait();
for _ in 0..16 {
assert_eq!(resolve_local(Some(root.into()), None, "id").unwrap(), root.join("id"));
}
});
}
});
assert_eq!(fs::read_dir(tmp.path().join("id")).unwrap().count(), 0);
}
#[test]
fn local_dir_rejects_relative_and_unwritable_paths() {
for relative in ["local", "./local", "../local"] {
let error = resolve_local(Some(relative.into()), Some("/os".into()), "id").unwrap_err();
assert!(error.contains(LOCAL_DATA_ENV) && error.contains("absolute"), "{error}");
}
let tmp = tempfile::tempdir().unwrap();
let blocker = tmp.path().join("file");
fs::write(&blocker, "").unwrap();
let error = resolve_local(Some(blocker.into()), Some("/os".into()), "id").unwrap_err();
assert!(error.contains("cannot write"), "{error}");
}
#[cfg(unix)]
#[test]
fn local_dir_rejects_an_existing_read_only_directory() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("id");
fs::create_dir(&dir).unwrap();
fs::set_permissions(&dir, fs::Permissions::from_mode(0o555)).unwrap();
// Root ignores permission bits.
if fs::write(dir.join("probe"), "").is_err() {
assert!(resolve_local(Some(tmp.path().into()), None, "id").unwrap_err().contains("cannot write"));
}
fs::set_permissions(&dir, fs::Permissions::from_mode(0o755)).unwrap();
}
#[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";
+32
Переглянути файл
@@ -130,6 +130,38 @@ pub async fn format(
let snap = project.documents.snapshot(doc)?;
let settings = project.settings.effective();
let language = project.languages.get(&snap.language);
if options.tool.is_none()
&& let Some(id) = settings.languages.plugin_formatters.get(snap.language.as_str()).filter(|s| !s.is_empty())
{
if options.on_save && !settings.editor.format_on_save {
return Ok(Vec::new());
}
if snap.read_only {
return Err(IdeError::invalid("The file is read-only"));
}
let plugins = crate::plugins::editor_plugin_host()
.ok_or_else(|| IdeError::not_found("The selected plugin formatter is unavailable"))?;
let d = ide_plugin::ProviderDocument {
path: snap.path.0,
language: snap.language.0,
version: u64::from(snap.version),
text: snap.text,
};
let id = id.clone();
return tokio::task::spawn_blocking(move || {
plugins.language_format(
&id,
&d,
range,
ide_plugin::language_features::FormatOptions {
tab_size: options.tab_size,
insert_spaces: options.insert_spaces,
},
)
})
.await
.map_err(|e| IdeError::internal(e.to_string()))?;
}
// A committed PHP-CS-Fixer configuration takes over Reformat Code for `.php` files.
if options.tool.is_none()
&& snap.path.extension().is_some_and(|e| e == "php")
+30
Переглянути файл
@@ -55,6 +55,8 @@ pub struct HostConfig {
}
pub struct Host {
pub(crate) mcp_executable: RwLock<Option<PathBuf>>,
plugin_host: RwLock<std::sync::Weak<plugins::PluginHost>>,
pub mcp_reports: mcp::Reports,
pub mcp_requests: mcp::Requests,
pub trust: trust::TrustStore,
@@ -99,6 +101,14 @@ pub struct Host {
const EXIT_SHUTDOWN_WAIT: std::time::Duration = std::time::Duration::from_secs(10);
impl Host {
/// Windows whose current project has this root.
pub(crate) fn windows_for_root(&self, root: &std::path::Path) -> Vec<String> {
let mut windows: Vec<_> =
self.projects.read().iter().filter(|(_, p)| p.root == root).map(|(w, _)| w.clone()).collect();
windows.sort();
windows
}
pub fn new(config: HostConfig) -> Self {
let HostConfig {
config_dir,
@@ -115,6 +125,8 @@ impl Host {
let ssh = Arc::new(ide_ssh::SshManager::new(data_dir.join("ssh-passwords.json")));
ssh.set_configs(global_settings.effective().ssh.configs);
let host = Self {
plugin_host: RwLock::default(),
mcp_executable: RwLock::default(),
mcp_reports: Default::default(),
mcp_requests: Default::default(),
trust: trust::TrustStore::load(&config_dir, &data_dir),
@@ -166,6 +178,19 @@ impl Host {
}
}
/// Associates the plugin runtime that executes this host's project operations.
/// Publishes only the executable of the running built-in MCP service.
pub fn set_mcp_executable(&self, executable: Option<PathBuf>) {
*self.mcp_executable.write() = executable;
}
pub fn set_plugin_host(&self, plugins: &Arc<plugins::PluginHost>) {
*self.plugin_host.write() = Arc::downgrade(plugins);
}
pub(crate) fn plugin_host(&self) -> Option<Arc<plugins::PluginHost>> {
self.plugin_host.read().upgrade()
}
/// Every open project (all windows).
pub fn projects(&self) -> Vec<Arc<Project>> {
self.projects.read().values().cloned().collect()
@@ -231,6 +256,11 @@ impl Host {
self.cancel_request(request);
}
let project = self.projects.write().remove(window)?;
if self.windows_for_root(&project.root).is_empty()
&& let Some(plugins) = self.plugin_host()
{
plugins.withdraw_project_run_targets(&project.root);
}
project.files_events_active.store(false, std::sync::atomic::Ordering::Release);
project.plugin_diagnostics.close();
if let Some(plugins) = plugins::editor_plugin_host() {
+56 -1
Переглянути файл
@@ -75,7 +75,11 @@ impl ToolRegistry for Registry {
}
fn tools(&self, root: &Path) -> Result<Vec<ide_mcp::Tool>, String> {
let (_, project) = self.target(root)?;
Ok(tools().into_iter().map(|t| t.metadata).filter(|t| allowed(t, project.is_trusted())).collect())
let mut all: Vec<_> = tools().into_iter().map(|t| t.metadata).collect();
if let Some(plugins) = self.0.plugin_host() {
all.extend(plugins.mcp_tools(root).into_iter().map(|(_, _, tool)| tool));
}
Ok(all.into_iter().filter(|t| allowed(t, project.is_trusted())).collect())
}
fn call(
&self,
@@ -95,6 +99,12 @@ impl ToolRegistry for Registry {
client: &str,
) -> Result<String, ToolCallError> {
let (window, project) = self.target(root).map_err(ToolCallError::Failed)?;
if let Some(plugins) = self.0.plugin_host()
&& let Some((plugin, tool, metadata)) =
plugins.mcp_tools(root).into_iter().find(|(_, _, t)| t.name == name && allowed(t, project.is_trusted()))
{
return self.call_plugin(window, project, plugins, plugin, tool, metadata, arguments, cancel, client);
}
let tool = tools()
.into_iter()
.find(|t| t.metadata.name == name && allowed(&t.metadata, project.is_trusted()))
@@ -129,6 +139,51 @@ impl Host {
}
}
impl Registry {
#[allow(clippy::too_many_arguments)]
fn call_plugin(
&self,
window: String,
project: Arc<crate::Project>,
plugins: Arc<crate::plugins::PluginHost>,
plugin: String,
tool: String,
metadata: ide_mcp::Tool,
arguments: &str,
cancel: CancellationToken,
client: &str,
) -> Result<String, ToolCallError> {
let run = || -> ide_core::IdeResult<String> {
let args = serde_json::from_str(arguments).map_err(|e| IdeError::invalid(e.to_string()))?;
let call = tool::Call {
host: self.0.clone(),
project: project.clone(),
window: window.clone(),
args,
cancel: cancel.clone(),
client: client.into(),
tool: metadata.name,
};
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
if metadata.destructive {
self.0.runtime.block_on(call.confirm(&metadata.description, None))?;
}
if !metadata.read_only {
project.require_trust()?;
}
let invocation = ide_plugin::Invocation {
client: Some(window),
workspace_root: Some(project.root.clone()),
..Default::default()
};
plugins.call_mcp_tool(&plugin, &tool, arguments, invocation, &cancel)
};
run().map_err(|e| ToolCallError::Failed(e.message))
}
}
#[cfg(test)]
mod tests {
#[test]
+35 -3
Переглянути файл
@@ -28,6 +28,8 @@ pub struct Reports(RwLock<State>);
struct State {
windows: HashMap<String, Window>,
focused: Option<String>,
/// Windows in the order they were last focused, the most recent last.
focus_order: Vec<String>,
remote: HashMap<String, PathBuf>,
libraries: HashMap<String, Vec<PathBuf>>,
}
@@ -46,8 +48,16 @@ impl Reports {
state.windows.entry(window.into()).or_default().focused = editor;
if focused {
state.focused = Some(window.into());
state.focus_order.retain(|w| w != window);
state.focus_order.push(window.into());
}
}
/// Sorts `windows` by when they were last focused, the most recent first; windows
/// never focused keep their order after them.
pub(crate) fn most_recently_focused_first(&self, windows: &mut [String]) {
let order = &self.0.read().focus_order;
windows.sort_by_key(|w| std::cmp::Reverse(order.iter().position(|o| o == w)));
}
pub fn report_problems(&self, window: &str, reset: bool, files: Vec<FileProblems>) {
let mut state = self.0.write();
let problems = &mut state.windows.entry(window.into()).or_default().problems;
@@ -68,6 +78,7 @@ impl Reports {
state.windows.remove(window);
state.remote.remove(window);
state.libraries.remove(window);
state.focus_order.retain(|w| w != window);
if state.focused.as_deref() == Some(window) {
state.focused = None;
}
@@ -91,7 +102,7 @@ impl Reports {
pub(super) fn focused_window(&self) -> Option<String> {
self.0.read().focused.clone()
}
pub(super) fn editors(&self, windows: &[String]) -> Vec<PathBuf> {
pub(crate) fn editors(&self, windows: &[String]) -> Vec<PathBuf> {
let state = self.0.read();
let mut paths = Vec::new();
for window in windows.iter().filter_map(|w| state.windows.get(w)) {
@@ -103,10 +114,10 @@ impl Reports {
}
paths
}
pub(super) fn focused(&self, window: &str) -> Option<FocusedEditor> {
pub(crate) fn focused(&self, window: &str) -> Option<FocusedEditor> {
self.0.read().windows.get(window).and_then(|w| w.focused.clone())
}
pub(super) fn problems(&self, windows: &[String]) -> Vec<FileProblems> {
pub(crate) fn problems(&self, windows: &[String]) -> Vec<FileProblems> {
let state = self.0.read();
let mut files: BTreeMap<PathBuf, Vec<Diagnostic>> = BTreeMap::new();
for window in windows.iter().filter_map(|w| state.windows.get(w)) {
@@ -122,3 +133,24 @@ impl Reports {
files.into_iter().map(|(path, items)| FileProblems { path: path.into(), items }).collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn windows_sort_by_their_last_focus() {
let reports = Reports::default();
for window in ["a", "b", "a", "c"] {
reports.set_focused_editor(window, None, true);
}
reports.set_focused_editor("b", None, false);
let mut windows: Vec<String> = ["a", "b", "c", "never"].map(String::from).into();
reports.most_recently_focused_first(&mut windows);
assert_eq!(windows, ["c", "a", "b", "never"]);
reports.forget_window("c");
let mut windows: Vec<String> = ["never", "c", "b", "a"].map(String::from).into();
reports.most_recently_focused_first(&mut windows);
assert_eq!(windows, ["a", "b", "never", "c"]);
}
}
+2 -1
Переглянути файл
@@ -552,7 +552,8 @@ pub async fn build_run(
let mut combined: Option<compile::BuildPlan> = None;
for member in workspace.projects.iter().filter(|p| file.as_ref().is_none_or(|f| f.starts_with(p))) {
let settings = ide_workspace::SettingsStore::load(&config_dir, Some(member)).effective();
let member_cache = cache.join(crate::project::project_key(member));
let member_cache =
if member == &root { cache.clone() } else { cache.join(crate::project::project_key(member)) };
let model = with_profiles(load(member, &member_cache, &settings), &member_cache);
let plan = prepare_plan(
compile::plan(member, &model, kind, file.as_deref(), settings.cmake.profiles.as_deref())?,
+35 -4
Переглянути файл
@@ -12,13 +12,18 @@ pub(super) fn scopes(project: &Project, config: &Path) -> IdeResult<Vec<Scope>>
}
fn scopes_at(root: &Path, cache: &Path, config: &Path, settings: Settings) -> IdeResult<Vec<Scope>> {
let window_root = root;
Ok(match ide_workspace::workspace::Workspace::load(root)? {
Some(workspace) => workspace
.projects
.into_iter()
.filter(|p| p.is_dir())
.map(|root| Scope {
cache: cache.join(crate::project::project_key(&root)),
cache: if root == window_root {
cache.to_owned()
} else {
cache.join(crate::project::project_key(&root))
},
settings: ide_workspace::SettingsStore::load(config, Some(&root)).effective(),
root,
})
@@ -36,10 +41,12 @@ pub(super) fn load_scopes(root: &Path, scopes: &[Scope]) -> BuildModel {
let mut combined = BuildModel::default();
for scope in scopes {
let mut model = load(&scope.root, &scope.cache, &scope.settings);
if scope.root == root {
if scopes.len() == 1 && scope.root == root {
return model;
}
if let Some(maven) = &mut model.maven {
if scope.root != root
&& let Some(maven) = &mut model.maven
{
let owner = ide_core::ipc_path::to_ipc(&scope.root);
for p in &mut maven.projects {
p.project_root = Some(owner.clone());
@@ -73,7 +80,7 @@ pub(super) fn load_scopes(root: &Path, scopes: &[Scope]) -> BuildModel {
}
}
}
for g in &mut model.gradle {
for g in model.gradle.iter_mut().filter(|_| scope.root != root) {
g.dir = absolute(&scope.root, &g.dir);
for p in &mut g.projects {
p.dir = absolute(&scope.root, &p.dir);
@@ -145,6 +152,30 @@ pub(super) fn update_files(root: &Path, scopes: &[Scope], model: &BuildModel, to
mod tests {
use super::*;
#[test]
fn attached_primary_keeps_its_cache_and_combines_build_models() {
let tmp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(tmp.path()).unwrap();
let root = base.join("primary");
let other = base.join("other");
for path in [&root, &other] {
std::fs::create_dir(path).unwrap();
std::fs::write(path.join("pom.xml"), "<project><artifactId>app</artifactId></project>").unwrap();
}
let cache = base.join("cache");
ide_build::save_maven_profiles(&cache, &["kept".into()]).unwrap();
ide_workspace::workspace::Workspace::attach(&root, "Primary", vec![root.clone(), other.clone()]).unwrap();
let scopes = scopes_at(&root, &cache, &base.join("config"), Settings::default()).unwrap();
assert_eq!(scopes[0].cache, cache);
assert_eq!(ide_build::maven_profiles(&scopes[0].cache), ["kept"]);
assert_eq!(scopes[1].cache, cache.join(crate::project::project_key(&other)));
let model = load_scopes(&root, &scopes);
let projects = model.maven.unwrap().projects;
assert_eq!(projects.len(), 2);
assert_eq!(projects[0].pom, "pom.xml");
assert_eq!(projects[1].pom, absolute(&other, "pom.xml"));
}
#[test]
fn workspace_models_use_member_settings_paths_and_caches() {
let temp = tempfile::tempdir().unwrap();
+6 -1
Переглянути файл
@@ -169,7 +169,12 @@ pub fn sources(host: &Host, window: &str) -> IdeResult<Vec<DataSource>> {
/// Replaces the project's data sources with `sources`.
pub async fn save_sources(host: &Host, window: &str, sources: Vec<DataSource>) -> IdeResult<()> {
let db = host.project(window)?.db.clone();
db.save_sources(sources).await
db.save_sources(sources).await?;
let root = host.project(window)?.root.clone();
for project in host.projects().into_iter().filter(|p| p.root == root) {
project.sink.emit(ide_core::IdeEvent::DbSourcesChanged);
}
Ok(())
}
/// Remembers the password of data source `id` for the session, and in the OS
+10
Переглянути файл
@@ -28,6 +28,7 @@ pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> Ide
let settings = settings.clone();
config = host
.blocking(move || {
ide_sdk::go::prepare_debug_compiler(&mut config.env, &config.extra_json)?;
ide_sdk::go::configure_environment(&root, &settings, &mut config.env);
ide_sdk::go::apply_to_debug(
&mut config.env,
@@ -351,6 +352,14 @@ pub async fn debug_attachable_processes(_host: &Host, _window: &str) -> IdeResul
.map_err(|e| ide_core::IdeError::internal(format!("listing processes failed: {e}")))?
}
/// A loopback port that is free right now, for a debug agent the launch line starts
/// (`address=127.0.0.1:<port>`) — used when its "Listening for transport" line never reaches
/// the console (a Surefire fork holds it until the tests run, which `suspend=y` prevents).
pub async fn debug_free_port(host: &Host, window: &str) -> IdeResult<u16> {
host.require_trust(window)?;
ide_dap::adapter::free_port().map_err(ide_core::IdeError::from)
}
/// Remote JVM Debug in listen mode: waits (cancellable through `request`) for the remote
/// JVM to connect to `host:port`; returns the loopback port java-debug attaches to.
pub async fn debug_listen_for_jvm(
@@ -492,6 +501,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
.await
});
d.add("debug_attachable_processes", |c| async move { debug_attachable_processes(&c.host, &c.window).await });
d.add("debug_free_port", |c| async move { debug_free_port(&c.host, &c.window).await });
d.add("debug_listen_for_jvm", |c| async move {
debug_listen_for_jvm(&c.host, &c.window, c.arg("request")?, c.arg("host")?, c.arg("port")?).await
});
+1 -1
Переглянути файл
@@ -82,7 +82,7 @@ fn go_source(project: &crate::Project, config_dir: &Path, path: &Path) -> IdeRes
workspace
.projects
.into_iter()
.filter(|root| path.starts_with(root))
.filter(|root| **root != project.root && path.starts_with(root))
.max_by_key(|root| root.components().count())
});
let settings = match &member {
+50 -1
Переглянути файл
@@ -52,6 +52,20 @@ pub fn doc_set_line_ending(host: &Host, window: &str, doc: DocId, line_ending: L
pub async fn doc_outline(host: &Host, window: &str, doc: DocId) -> IdeResult<Vec<OutlineItem>> {
let project = host.project(window)?;
let lsp = project.lsp.document_symbols(doc).await.ok().flatten();
let lsp = if lsp.as_ref().is_none_or(|v| v.is_empty()) {
if let Some((plugins, d)) =
crate::ops::lsp::plugin_providers(host, window, doc, crate::plugins::ProviderKind::DocumentSymbols)
{
let items =
crate::ops::lsp::plugin_call(move || plugins.language_document_symbols(&d)).await.unwrap_or_default();
if items.is_empty() { lsp } else { Some(crate::plugins::language_features::outline(items)) }
} else {
lsp
}
} else {
lsp
};
let lsp = lsp.filter(|v| !v.is_empty());
blocking(move || {
let snap = match project.documents.text_snapshot(doc) {
Ok(snap) => snap,
@@ -80,7 +94,27 @@ pub async fn doc_folding(host: &Host, window: &str, doc: DocId) -> IdeResult<Vec
if snap.large {
return Ok(Vec::new());
}
Ok(project.syntax.folding(doc, &snap.language, snap.version, &snap.text))
let mut folds = project.syntax.folding(doc, &snap.language, snap.version, &snap.text);
if let Some(plugins) = crate::plugins::editor_providers_for(crate::plugins::ProviderKind::Folding) {
let d = ide_plugin::ProviderDocument {
path: project.documents.identity(doc)?.0,
language: snap.language.0,
version: u64::from(snap.version),
text: snap.text,
};
folds.extend(plugins.language_folding(&d).into_iter().map(|f| FoldRange {
start_line: f.start_line,
end_line: f.end_line,
kind: match f.kind.as_str() {
"comment" => ide_language::FoldKind::Comment,
"imports" => ide_language::FoldKind::Imports,
_ => ide_language::FoldKind::Region,
},
}));
folds.sort_by_key(|f| (f.start_line, f.end_line));
folds.dedup_by_key(|f| (f.start_line, f.end_line));
}
Ok(folds)
})
.await
}
@@ -105,6 +139,21 @@ pub async fn doc_selection_ranges(host: &Host, window: &str, doc: DocId, offset:
let project = host.project(window)?;
blocking(move || {
let snap = project.documents.text_snapshot(doc)?;
if !snap.large
&& let Some(plugins) = crate::plugins::editor_providers_for(crate::plugins::ProviderKind::SelectionRanges)
{
let at = ide_text::Document::new(&snap.text).offset_to_pos(offset);
let d = ide_plugin::ProviderDocument {
path: project.documents.identity(doc)?.0,
language: snap.language.0.clone(),
version: u64::from(snap.version),
text: snap.text.clone(),
};
let ranges = plugins.language_selection_ranges(&d, at);
if !ranges.is_empty() {
return Ok(ranges);
}
}
let language = if snap.large { ide_core::LanguageId::plain_text() } else { snap.language };
Ok(project.syntax.selection_ranges(doc, &language, snap.version, &snap.text, offset))
})
+76 -1
Переглянути файл
@@ -88,8 +88,28 @@ impl Session {
Ok(None)
}
};
let before = fingerprint()?;
let mut before = fingerprint()?;
let previous = fs::read(&record).ok().and_then(|bytes| serde_json::from_slice::<Outputs>(&bytes).ok());
// Timestamp-incremental builders (Maven, the Kotlin plugin) skip sources whose
// mtime did not advance. An external edit that preserves both length and mtime
// invalidates the fingerprint but would still compile to nothing below, and the
// stale outputs would be re-recorded as fresh. Re-stamp those sources first so
// the rebuild sees them; then re-scan so the record written afterwards matches.
let restamp: Vec<PathBuf> = match (before.as_ref(), previous.as_ref().and_then(|o| o.fingerprint.as_ref())) {
(Some(current), Some(recorded)) => recorded.mtime_preserved_edits(current),
_ => Vec::new(),
};
if !restamp.is_empty() {
let now = std::time::SystemTime::now();
for rel in &restamp {
let path = root.join(rel);
if let Err(error) = fs::File::options().write(true).open(&path).and_then(|file| file.set_modified(now))
{
tracing::debug!(path = %path.display(), %error, "Cannot re-stamp externally edited source");
}
}
before = fingerprint()?;
}
if let Some(outputs) = previous.as_ref()
&& policy != BuildPolicy::Rebuild
&& outputs.reusable(&before, &snapshots)
@@ -234,6 +254,22 @@ mod tests {
ide_build::load(root, &root.join("cache"), None)
}
/// Like [`fixture`], but the build script skips sources whose mtime did not advance,
/// mirroring Maven's (and the Kotlin plugin's) timestamp-incremental compilation.
fn fixture_timestamp_incremental(root: &Path) -> ide_build::BuildModel {
fs::write(
root.join("pom.xml"),
r#"<project><groupId>demo</groupId><artifactId>app</artifactId><version>1</version></project>"#,
)
.unwrap();
let script = root.join("mvnw");
fs::write(&script, "#!/bin/sh\necho build \"$@\" >> calls\nif [ -f src/main/kotlin/broken ]; then exit 1; fi\nmkdir -p target/classes\nif [ ! -f target/classes/Main.class ] || [ src/main/kotlin/Main.kt -nt target/classes/Main.class ]; then\ncp src/main/kotlin/Main.kt target/classes/Main.class\nfi\n").unwrap();
fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
fs::create_dir_all(root.join("src/main/kotlin")).unwrap();
fs::write(root.join("src/main/kotlin/Main.kt"), "first").unwrap();
ide_build::load(root, &root.join("cache"), None)
}
#[test]
fn rebuild_bypasses_snapshots_and_error_continuation_retains_previous_classes() {
let dir = tempfile::tempdir().unwrap();
@@ -357,6 +393,45 @@ mod tests {
assert_eq!(fs::read_to_string(rebuilt.snapshot.join("0/Main.class")).unwrap(), "other");
}
#[test]
fn restart_after_mtime_preserving_external_edit_actually_recompiles() {
// The content hash triggers a rebuild for a same-length, same-mtime external
// edit, but a timestamp-incremental builder (real Maven) would still compile
// nothing and the stale snapshot would be re-recorded as fresh. Dropping the
// session simulates the IDE restart from the report.
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let model = fixture_timestamp_incremental(root);
let cache = root.join("cache");
let cancel = CancellationToken::new();
let mut session = Session::new(cache.clone());
let first = session.build(root, &model, root, BuildOptions::default(), None, &cancel).unwrap();
assert_eq!(fs::read_to_string(first.snapshot.join("0/Main.class")).unwrap(), "first");
// Pin the old output's mtime so the `-nt` check below is deterministic even on
// coarse-granularity filesystems.
fs::File::options()
.write(true)
.open(root.join("target/classes/Main.class"))
.unwrap()
.set_modified(std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(42))
.unwrap();
drop(session);
let source = root.join("src/main/kotlin/Main.kt");
let mtime = fs::metadata(&source).unwrap().modified().unwrap();
fs::write(&source, "other").unwrap(); // 5 bytes, same length as "first"
fs::File::options().write(true).open(&source).unwrap().set_modified(mtime).unwrap();
let mut session = Session::new(cache.clone());
let rebuilt = session.build(root, &model, root, BuildOptions::default(), None, &cancel).unwrap();
assert_ne!(rebuilt.snapshot, first.snapshot);
assert_eq!(
fs::read_to_string(rebuilt.snapshot.join("0/Main.class")).unwrap(),
"other",
"the re-stamped source must actually recompile instead of re-recording stale outputs"
);
}
#[test]
fn session_drop_removes_superseded_snapshots_and_preserves_records_across_reopen() {
let dir = tempfile::tempdir().unwrap();
+514 -36
Переглянути файл
@@ -4,11 +4,39 @@ use crate::apply_workspace_edit as apply_edit;
use crate::plugins::{ProviderKind, editor_providers_for as editor_providers};
use crate::rpc::Dispatcher;
use ide_core::error::ErrorKind;
use ide_core::{DocId, IdeResult, Location, Pos, Range, RequestId, ServerId, TextEdit, WorkspaceEdit};
use ide_core::{DocId, IdeError, IdeResult, Location, Pos, Range, RequestId, ServerId, TextEdit, WorkspaceEdit};
use ide_lsp::types::*;
use ide_plugin::ProviderDocument;
use std::path::Path;
/// Select an embedded server only for point requests; formatting and rename keep the host.
async fn position_document(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeResult<DocId> {
let lsp = host.project(window)?.lsp.clone();
lsp.refresh_injections(doc).await?;
Ok(lsp.injected_document(doc, pos))
}
async fn begin_position_request(
host: &Host,
window: &str,
request: RequestId,
doc: DocId,
pos: Pos,
) -> IdeResult<(DocId, ide_core::CancellationToken)> {
let cancel = host.begin_request(request);
let target = tokio::select! {
result = position_document(host, window, doc, pos) => result,
_ = cancel.cancelled() => Err(ide_core::IdeError::cancelled()),
};
match target {
Ok(target) => Ok((target, cancel)),
Err(e) => {
host.end_request(request);
Err(e)
}
}
}
async fn prepare_actions(
project: std::sync::Arc<crate::Project>,
doc: DocId,
@@ -41,8 +69,8 @@ pub async fn lsp_completion(
trigger: Option<String>,
) -> IdeResult<CompletionList> {
let lsp = host.project(window)?.lsp.clone();
let cancel = host.begin_request(request);
let result = lsp.completion(doc, pos, trigger.clone(), cancel).await;
let (target, cancel) = begin_position_request(host, window, request, doc, pos).await?;
let result = lsp.completion(target, pos, trigger.clone(), cancel).await;
host.end_request(request);
// Plugin providers (`[[contributes.providers]]`) add theirs.
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::Completion) else { return result };
@@ -56,7 +84,7 @@ pub async fn lsp_completion(
}
/// The plugin host and the document, when some plugin provides `kind`.
fn plugin_providers(
pub(crate) fn plugin_providers(
host: &Host,
window: &str,
doc: DocId,
@@ -74,10 +102,48 @@ fn plugin_providers(
}
/// Plugin calls block (the plugin's actor thread does the work).
async fn plugin_call<T: Send + 'static>(f: impl FnOnce() -> T + Send + 'static) -> Option<T> {
pub(crate) async fn plugin_call<T: Send + 'static>(f: impl FnOnce() -> T + Send + 'static) -> Option<T> {
tokio::task::spawn_blocking(f).await.ok()
}
fn provider_server<T: Default>(server: IdeResult<T>) -> IdeResult<T> {
match server {
Ok(v) => Ok(v),
Err(e) if matches!(e.kind, ErrorKind::NotFound | ErrorKind::Unavailable) => Ok(T::default()),
Err(e) => Err(e),
}
}
fn merge_language_locations(mut server: Vec<Location>, plugin: Vec<ide_plugin::Location>) -> Vec<Location> {
let mut seen: std::collections::HashSet<_> = server.iter().cloned().collect();
for l in plugin {
let loc = Location::file(l.path, l.range);
if seen.insert(loc.clone()) {
server.push(loc);
}
}
server
}
fn merge_language_highlights(
mut server: Vec<Highlight>,
plugin: Vec<ide_plugin::language_features::Highlight>,
) -> Vec<Highlight> {
let mut seen: std::collections::HashSet<_> = server.iter().map(|h| h.range).collect();
for h in plugin {
if seen.insert(h.range) {
server.push(Highlight {
range: h.range,
kind: match h.kind.as_str() {
"read" => HighlightKind::Read,
"write" => HighlightKind::Write,
_ => HighlightKind::Text,
},
});
}
}
server
}
/// Items from plugins carry no server: commands don't apply, and a code
/// action is resolved by the plugin named in its `data` ([`PluginActionRef`]).
const PLUGIN_SERVER: ServerId = ServerId(0);
@@ -131,6 +197,7 @@ fn plugin_completion(item: ide_plugin::CompletionItem) -> CompletionItem {
CompletionItem {
label: item.label,
label_detail: None,
label_description: None,
detail: item.detail,
kind,
documentation: item.documentation.map(|value| Markup { kind: "markdown".into(), value }),
@@ -200,14 +267,14 @@ pub async fn lsp_hover(
pos: Pos,
) -> IdeResult<Option<Hover>> {
let lsp = host.project(window)?.lsp.clone();
let cancel = host.begin_request(request);
let (target, cancel) = begin_position_request(host, window, request, doc, pos).await?;
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::Hover) else {
let result = lsp.hover(doc, pos, cancel).await;
let result = lsp.hover(target, pos, cancel).await;
host.end_request(request);
return result;
};
let (result, hovers) =
tokio::join!(lsp.hover(doc, pos, cancel), plugin_call(move || plugins.provide_hover(&d, pos)));
tokio::join!(lsp.hover(target, pos, cancel), plugin_call(move || plugins.provide_hover(&d, pos)));
host.end_request(request);
let hovers = hovers.unwrap_or_default();
if hovers.is_empty() {
@@ -222,11 +289,28 @@ pub async fn lsp_hover(
}
pub async fn lsp_signature_help(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeResult<Option<SignatureHelp>> {
host.project(window)?.lsp.signature_help(doc, pos).await
let target = position_document(host, window, doc, pos).await?;
let server = host.project(window)?.lsp.signature_help(target, pos).await;
if matches!(&server,Ok(Some(h)) if !h.signatures.is_empty()) {
return server;
}
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::SignatureHelp) else {
return server;
};
let help = plugin_call(move || plugins.language_signature_help(&d, pos)).await.flatten();
match help {
Some(h) => {
let _ = provider_server(server)?;
Ok(Some(crate::plugins::language_features::signature_help(h)))
}
None => server,
}
}
pub async fn lsp_goto(host: &Host, window: &str, doc: DocId, pos: Pos, kind: GotoKind) -> IdeResult<Vec<Location>> {
if kind == GotoKind::Definition
let target = position_document(host, window, doc, pos).await?;
if target == doc
&& kind == GotoKind::Definition
&& let Some(plugins) = crate::plugins::editor_plugin_host()
{
let project = host.project(window)?;
@@ -237,7 +321,24 @@ pub async fn lsp_goto(host: &Host, window: &str, doc: DocId, pos: Pos, kind: Got
return Ok(found.targets);
}
}
let result = host.project(window)?.lsp.goto(doc, pos, kind).await;
let result = host.project(window)?.lsp.goto(target, pos, kind).await;
if matches!(kind, GotoKind::Implementation | GotoKind::TypeDefinition) {
let k =
if kind == GotoKind::Implementation { ProviderKind::Implementation } else { ProviderKind::TypeDefinition };
let Some((plugins, d)) = plugin_providers(host, window, doc, k) else {
return result;
};
let locations = plugin_call(move || {
if kind == GotoKind::Implementation {
plugins.language_implementation(&d, pos)
} else {
plugins.language_type_definition(&d, pos)
}
})
.await
.unwrap_or_default();
return Ok(merge_language_locations(provider_server(result)?, locations));
}
if kind != GotoKind::Definition {
return result;
}
@@ -355,9 +456,61 @@ pub async fn lsp_implementations(
positions: Vec<Pos>,
) -> IdeResult<Vec<Vec<Location>>> {
let lsp = host.project(window)?.lsp.clone();
let cancel = host.begin_request(request);
let result = lsp.implementations(doc, &positions, cancel).await;
let (_, cancel) =
begin_position_request(host, window, request, doc, positions.first().copied().unwrap_or_default()).await?;
let mut groups = std::collections::BTreeMap::<DocId, Vec<(usize, Pos)>>::new();
for (index, pos) in positions.iter().copied().enumerate() {
groups.entry(lsp.injected_document(doc, pos)).or_default().push((index, pos));
}
if groups.is_empty() {
groups.insert(doc, Vec::new());
}
let results = futures::future::join_all(groups.into_iter().map(|(target, entries)| {
let (lsp, cancel) = (lsp.clone(), cancel.clone());
async move {
let positions: Vec<_> = entries.iter().map(|(_, p)| *p).collect();
(entries, lsp.implementations(target, &positions, cancel).await)
}
}))
.await;
let mut out = vec![Vec::new(); positions.len()];
let mut error = None;
let mut succeeded = false;
for (entries, result) in results {
match result {
Ok(found) => {
succeeded = true;
for ((index, _), locations) in entries.into_iter().zip(found) {
out[index] = locations;
}
}
Err(e) => {
error.get_or_insert(e);
}
}
}
let result = if cancel.is_cancelled() {
Err(ide_core::IdeError::cancelled())
} else if succeeded {
Ok(out)
} else {
Err(error.expect("at least one request"))
};
host.end_request(request);
if let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::Implementation) {
let count = positions.len();
let found = plugin_call(move || {
positions.into_iter().map(|pos| plugins.language_implementation(&d, pos)).collect::<Vec<_>>()
})
.await
.unwrap_or_default();
let mut out = provider_server(result)?;
out.resize_with(count, Vec::new);
for (items, found) in out.iter_mut().zip(found) {
*items = merge_language_locations(std::mem::take(items), found);
}
return Ok(out);
}
result
}
@@ -373,13 +526,17 @@ pub async fn lsp_goto_implementation(
on_partial: super::Channel<Vec<Location>>,
) -> IdeResult<Vec<Location>> {
let lsp = host.project(window)?.lsp.clone();
let cancel = host.begin_request(request);
let (target, cancel) = begin_position_request(host, window, request, doc, pos).await?;
let result = lsp
.goto_implementation(doc, pos, cancel, &|locs| {
.goto_implementation(target, pos, cancel, &|locs| {
let _ = on_partial.send(locs);
})
.await;
host.end_request(request);
if let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::Implementation) {
let found = plugin_call(move || plugins.language_implementation(&d, pos)).await.unwrap_or_default();
return Ok(merge_language_locations(provider_server(result)?, found));
}
result
}
@@ -392,6 +549,82 @@ pub async fn lsp_virtual_document(host: &Host, window: &str, uri: String) -> Ide
host.project(window)?.lsp.virtual_document(&uri).await
}
/// Whether this class belongs to the project's Maven local repository.
pub async fn lsp_can_download_sources(host: &Host, window: &str, uri: String) -> IdeResult<bool> {
let project = host.project(window)?;
if !project.is_trusted() {
return Ok(false);
}
let Some(jar) = ide_lsp::LspManager::library_archive(&uri) else { return Ok(false) };
host.blocking(move || {
let settings = project.settings.effective();
let repository = ide_sdk::apply::maven_local_repository(&project.root, &settings)
.or_else(ide_build::maven::default_local_repo);
Ok(repository.is_some_and(|repo| ide_build::maven::sources::artifact(&jar, &repo).is_some())
&& ide_build::maven::has_projects(&project.root, &settings.languages.java.maven))
})
.await
}
/// Download with the project's Maven configuration, or attach an archive selected by the user.
pub async fn lsp_library_sources(
host: &Host,
window: &str,
request: RequestId,
uri: String,
pos: Pos,
sources: Option<std::path::PathBuf>,
) -> IdeResult<Option<Location>> {
host.require_trust(window)?;
let project = host.project(window)?;
let cancel = host.begin_project_request(window, request);
let result = async {
let sources = match sources {
Some(sources) => Some(sources),
None => {
let jar = ide_lsp::LspManager::library_archive(&uri)
.ok_or_else(|| IdeError::invalid("not a library class"))?;
let project = project.clone();
let cancel = cancel.clone();
host.blocking(move || {
let settings = project.settings.effective();
let repository = ide_sdk::apply::maven_local_repository(&project.root, &settings)
.or_else(ide_build::maven::default_local_repo)
.ok_or_else(|| IdeError::unavailable("Maven local repository is unavailable"))?;
let artifact = ide_build::maven::sources::artifact(&jar, &repository)
.ok_or_else(|| IdeError::invalid("not an artifact in the Maven local repository"))?;
let model = super::build::load(&project.root, &project.build_cache_dir(), &settings);
let maven = model.maven.ok_or_else(|| IdeError::unavailable("no Maven project"))?;
let owns = |p: &&ide_build::maven::MavenProject| {
p.dependencies.iter().any(|d| d.group_id == artifact.group && d.artifact_id == artifact.name)
};
let mut owner = maven
.projects
.iter()
.find(owns)
.or_else(|| maven.projects.first())
.cloned()
.ok_or_else(|| IdeError::unavailable("no Maven project"))?;
let profiles =
settings.languages.java.maven.profiles(&ide_build::maven_profiles(&project.build_cache_dir()));
if !profiles.is_empty() {
owner.options.push(format!("-P{}", profiles.join(",")));
}
ide_build::maven::sources::download(&project.root, &owner, &artifact, &repository, &cancel)
})
.await?
}
};
match sources {
Some(sources) => project.lsp.library_source_location(&uri, sources, pos, &cancel).await,
None => Ok(None),
}
}
.await;
host.end_request(request);
result
}
/// Opens compiled classes through JDT, with project source taking priority on disk.
pub async fn lsp_resolve_class_file(host: &Host, window: &str, path: std::path::PathBuf) -> IdeResult<String> {
let archive = ide_vfs::split_archive_path(&path);
@@ -400,8 +633,8 @@ pub async fn lsp_resolve_class_file(host: &Host, window: &str, path: std::path::
/// References of the symbol at `pos`. `project_wide` also searches project files
/// outside the language server's analysis scope; bulk scans over many symbols
/// leave it off. Java, `project_wide`: without `libraries`, the dependencies and the JDK
/// are not searched; with it, those found in the sources arrive on `on_partial` before
/// leave it off. Java: without `libraries`, the dependencies and the JDK are not
/// searched; with it and `project_wide`, those found in the sources arrive on `on_partial` before
/// the slower search for the rest, and the result has them all. Cancel with
/// `cancel_request(request)`.
#[allow(clippy::too_many_arguments)]
@@ -418,9 +651,9 @@ pub async fn lsp_references(
) -> IdeResult<References> {
let project = host.project(window)?;
let lsp = project.lsp.clone();
let cancel = host.begin_request(request);
let (target, cancel) = begin_position_request(host, window, request, doc, pos).await?;
let result = async {
let symbol = indexed_symbol(host, window, doc, pos).await?;
let symbol = if target == doc { indexed_symbol(host, window, doc, pos).await? } else { None };
if let Some(symbol) = &symbol {
let project = project.clone();
let snapshot = symbol.clone();
@@ -430,7 +663,7 @@ pub async fn lsp_references(
}
let result = lsp
.references_streaming(
doc,
target,
pos,
include_declaration,
project_wide,
@@ -505,10 +738,11 @@ fn validate_indexed_symbol(
let stale = || {
ide_core::IdeError::new(ErrorKind::OutOfSync, "indexed document changed; wait for indexing and try again")
};
if ide_plugin::resolve_in_root(&project.root, &file.path.to_string_lossy()).ok().as_ref() != Some(&file.path) {
let path = dunce::simplified(&file.path);
if ide_plugin::resolve_in_root(&project.root, &path.to_string_lossy()).ok().as_deref() != Some(path) {
return Err(stale());
}
let text = if let Some(doc) = project.documents.by_path(&file.path) {
let text = if let Some(doc) = project.documents.by_path(path) {
let snapshot = project.documents.text_snapshot(doc)?;
if snapshot.large || file.version.is_some_and(|v| v != snapshot.version) {
return Err(stale());
@@ -517,7 +751,7 @@ fn validate_indexed_symbol(
} else {
let loaded = project
.documents
.load_file_bounded(&file.path, crate::plugins::navigation::MAX_DOCUMENT_BYTES)
.load_file_bounded(path, crate::plugins::navigation::MAX_DOCUMENT_BYTES)
.map_err(|_| stale())?;
if loaded.binary || loaded.large {
return Err(stale());
@@ -555,7 +789,13 @@ fn merge_indexed_references(mut server: References, symbol: &crate::plugins::nav
}
pub async fn lsp_document_highlights(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeResult<Vec<Highlight>> {
host.project(window)?.lsp.document_highlights(doc, pos).await
let target = position_document(host, window, doc, pos).await?;
let server = host.project(window)?.lsp.document_highlights(target, pos).await;
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::DocumentHighlights) else {
return server;
};
let items = plugin_call(move || plugins.language_document_highlights(&d, pos)).await.unwrap_or_default();
Ok(merge_language_highlights(provider_server(server)?, items))
}
pub async fn lsp_workspace_symbols(
@@ -581,7 +821,9 @@ pub async fn lsp_code_actions(
let project = host.project(window)?;
let documents = project.documents.clone();
let version = blocking(move || documents.version(doc)).await?;
let result = project.lsp.code_actions(doc, range, only.clone()).await;
let target = position_document(host, window, doc, range.start).await?;
let range = project.lsp.injected_request_range(target, range);
let result = project.lsp.code_actions(target, range, only.clone()).await;
let plugin_actions = match plugin_providers(host, window, doc, ProviderKind::CodeActions) {
Some((plugins, d)) => plugin_call(move || plugins.provide_code_actions(&d, range)).await.unwrap_or_default(),
None => Vec::new(),
@@ -597,7 +839,7 @@ pub async fn lsp_code_actions(
let actions = if plugin_actions.is_empty() {
result?
} else {
let mut out = result.unwrap_or_default();
let mut out = provider_server(result)?;
out.extend(plugin_actions);
out
};
@@ -621,6 +863,8 @@ fn plugin_code_action(plugin: &str, root: &Path, a: ide_plugin::CodeAction) -> C
.and_then(|r| serde_json::to_value(r).ok());
CodeAction {
title: a.title,
diagnostics: Vec::new(),
original_diagnostics: None,
kind: a.kind,
is_preferred: a.preferred,
disabled_reason,
@@ -642,12 +886,13 @@ fn plugin_file_edits(
use ide_plugin::FileOperation;
let confine = !a.operations.is_empty() || a.open.is_some();
let confined = |path: &Path| ide_plugin::resolve_new_in_root(root, &path.to_string_lossy());
let writable = |path: &Path| ide_plugin::resolve_writable_in_root(root, &path.to_string_lossy());
let mut files = Vec::new();
let mut created_with_text = Vec::new();
for operation in &a.operations {
match operation {
FileOperation::Create { path, text, overwrite } => {
let path = confined(path)?;
let path = writable(path)?;
files.push(FileEdit::Create { path: path.clone().into(), overwrite: *overwrite });
if !text.is_empty() {
let start = Range::point(Pos::new(0, 0));
@@ -668,7 +913,7 @@ fn plugin_file_edits(
}
}
for (path, edits) in &a.edits {
let path = if confine { confined(path)? } else { path.clone() };
let path = if confine { writable(path)? } else { path.clone() };
if created_with_text.contains(&path) {
return Err("put the text of a created file in its `create`, not in an edit".into());
}
@@ -718,8 +963,9 @@ pub async fn lsp_code_action_query(
let project = host.project(window)?;
let documents = project.documents.clone();
let version = blocking(move || documents.version(doc)).await?;
let cancel = host.begin_request(request);
let result = project.lsp.code_action_query(doc, range, only, cancel).await;
let (target, cancel) = begin_position_request(host, window, request, doc, range.start).await?;
let range = project.lsp.injected_request_range(target, range);
let result = project.lsp.code_action_query(target, range, only, cancel).await;
host.end_request(request);
let mut result = result?;
result.actions = prepare_actions(project, doc, version, result.actions).await?;
@@ -1105,12 +1351,52 @@ pub async fn lsp_semantic_tokens(host: &Host, window: &str, doc: DocId) -> IdeRe
}
pub async fn lsp_code_lens(host: &Host, window: &str, doc: DocId) -> IdeResult<Vec<CodeLens>> {
host.project(window)?.lsp.code_lens(doc).await
let server = host.project(window)?.lsp.code_lens(doc).await;
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::CodeLenses) else {
return server;
};
let items = plugin_call(move || plugins.language_code_lenses(&d)).await.unwrap_or_default();
let mut out = provider_server(server)?;
out.extend(items.into_iter().map(|(plugin, i)| CodeLens {
range: i.range,
command: Some(Command {
title: i.title,
command: format!("openidea.plugin-language:{plugin}:{}", i.command),
arguments: vec![serde_json::json!(serde_json::to_string(&i.argument).unwrap_or_default())],
}),
}));
Ok(out)
}
/// Links in a document (`href`s, Markdown links, `#include`s…) from its language servers.
pub async fn lsp_document_links(host: &Host, window: &str, doc: DocId) -> IdeResult<Vec<DocumentLink>> {
host.project(window)?.lsp.document_links(doc).await
let server = host.project(window)?.lsp.document_links(doc).await;
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::DocumentLinks) else {
return server;
};
let root = host.project(window)?.root.clone();
let items = plugin_call(move || plugins.language_document_links(&d)).await.unwrap_or_default();
let mut out = provider_server(server)?;
out.extend(items.into_iter().filter_map(|i| {
let url = i.target.starts_with("https://") || i.target.starts_with("http://");
if !url && ide_core::is_virtual_uri(&i.target) {
return None;
}
Some(DocumentLink {
range: i.range,
path: (!url).then(|| {
{
let path = std::path::PathBuf::from(&i.target);
if path.is_absolute() { path } else { root.join(path) }
}
.into()
}),
url: url.then_some(i.target),
fragment: None,
tooltip: None,
})
}));
Ok(out)
}
pub async fn lsp_call_hierarchy(
@@ -1200,6 +1486,34 @@ pub async fn redo_workspace_edit(host: &Host, window: &str, id: u32) -> IdeResul
/// Runs a server command (`workspace/executeCommand`), e.g. a completion item's
/// follow-up command. Edits the server requests arrive via `workspace/applyEdit`.
pub async fn lsp_execute_command(host: &Host, window: &str, doc: DocId, command: Command) -> IdeResult<()> {
if let Some(rest) = command.command.strip_prefix("openidea.plugin-language:") {
let (plugin, command_id) =
rest.split_once(':').ok_or_else(|| ide_core::IdeError::invalid("Invalid plugin command"))?;
let plugins = crate::plugins::editor_plugin_host()
.ok_or_else(|| ide_core::IdeError::not_found("Plugin host unavailable"))?;
let argument = command
.arguments
.first()
.and_then(|a| a.as_str())
.and_then(|s| serde_json::from_str::<Option<String>>(s).ok())
.flatten();
let project = host.project(window)?;
let snap = project.documents.snapshot(doc)?;
let invocation = ide_plugin::Invocation {
client: Some(window.into()),
workspace_root: Some(project.root.clone()),
editor: Some(ide_plugin::EditorContext {
path: snap.path.0.clone(),
language: snap.language.0,
selections: vec![],
}),
selected_paths: vec![snap.path.0],
..Default::default()
};
let (plugin, command_id) = (plugin.to_owned(), command_id.to_owned());
return blocking(move || plugins.run_language_command(&plugin, &command_id, argument.as_deref(), invocation))
.await;
}
host.project(window)?.lsp.execute_command(doc, command).await
}
@@ -1262,7 +1576,88 @@ pub async fn lsp_execute_code_action(
}
/// Commands of this module the remote server answers.
/// Versioned plugin visuals, refreshed after the editor synchronizes its buffer.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct PluginLanguageVisuals {
pub version: u64,
pub injections: Vec<ide_plugin::language_features::Injection>,
pub colors: Vec<(String, ide_plugin::language_features::ColorInfo)>,
pub gutter_actions: Vec<(String, ide_plugin::language_features::GutterAction)>,
}
pub async fn plugin_language_visuals(host: &Host, window: &str, doc: DocId) -> IdeResult<PluginLanguageVisuals> {
let project = host.project(window)?;
let snap = project.documents.snapshot(doc)?;
let generation = project.lsp.injection_generation();
blocking(move || {
let d = ProviderDocument {
path: snap.path.0,
language: snap.language.0,
version: u64::from(snap.version),
text: snap.text,
};
let mut out =
PluginLanguageVisuals { version: d.version, injections: vec![], colors: vec![], gutter_actions: vec![] };
if d.text.len() <= crate::plugins::navigation::MAX_DOCUMENT_BYTES
&& let Some(plugins) = crate::plugins::editor_plugin_host()
{
out.injections = plugins.provide_injections(&d);
out.colors = plugins.language_colors(&d);
out.gutter_actions = plugins.language_gutter_actions(&d);
}
project.lsp.set_injections_at(
doc,
d.version as u32,
out.injections
.iter()
.map(|i| ide_lsp::InjectedRange {
range: i.range,
language: ide_core::LanguageId::new(i.language.clone()),
})
.collect(),
generation,
);
Ok(out)
})
.await
}
pub async fn plugin_color_presentations(
host: &Host,
window: &str,
doc: DocId,
plugin: String,
color: ide_plugin::language_features::ColorInfo,
) -> IdeResult<Vec<String>> {
let snap = host.project(window)?.documents.snapshot(doc)?;
blocking(move || {
let d = ProviderDocument {
path: snap.path.0,
language: snap.language.0,
version: u64::from(snap.version),
text: snap.text,
};
if !crate::plugins::language_features::valid_range(&d, color.range) {
return Err(ide_core::IdeError::invalid("Invalid color range"));
}
Ok(crate::plugins::editor_plugin_host()
.map(|p| p.language_color_presentations(&plugin, &d, color))
.unwrap_or_default())
})
.await
}
pub fn plugin_formatters() -> Vec<crate::plugins::language_features::FormatterChoice> {
crate::plugins::editor_plugin_host().map(|p| p.formatter_choices()).unwrap_or_default()
}
pub(crate) fn register(d: &mut Dispatcher) {
d.add(
"plugin_language_visuals",
|c| async move { plugin_language_visuals(&c.host, &c.window, c.arg("doc")?).await },
);
d.add("plugin_color_presentations", |c| async move {
plugin_color_presentations(&c.host, &c.window, c.arg("doc")?, c.arg("plugin")?, c.arg("color")?).await
});
d.add("plugin_formatters", |_c| async move { Ok(plugin_formatters()) });
d.add("lsp_completion", |c| async move {
lsp_completion(&c.host, &c.window, c.arg("request")?, c.arg("doc")?, c.arg("pos")?, c.arg("trigger")?).await
});
@@ -1295,6 +1690,14 @@ pub(crate) fn register(d: &mut Dispatcher) {
|c| async move { lsp_resolve_class_file(&c.host, &c.window, c.arg("path")?).await },
);
d.add("lsp_virtual_document", |c| async move { lsp_virtual_document(&c.host, &c.window, c.arg("uri")?).await });
d.add(
"lsp_can_download_sources",
|c| async move { lsp_can_download_sources(&c.host, &c.window, c.arg("uri")?).await },
);
d.add("lsp_library_sources", |c| async move {
lsp_library_sources(&c.host, &c.window, c.arg("request")?, c.arg("uri")?, c.arg("pos")?, c.arg("sources")?)
.await
});
d.add("lsp_references", |c| async move {
let on_partial = c.sender("onPartial")?;
lsp_references(
@@ -1408,6 +1811,32 @@ pub(crate) fn register(d: &mut Dispatcher) {
#[cfg(test)]
mod tests {
#[test]
fn language_features_merge_without_overwriting_lsp_ranges_or_masking_errors() {
use super::*;
let range = Range::new(Pos::default(), Pos::new(0, 1));
let existing = Location::file("/p/a".into(), range);
let merged = merge_language_locations(
vec![existing.clone()],
vec![
ide_plugin::Location { path: "/p/a".into(), range },
ide_plugin::Location { path: "/p/b".into(), range },
],
);
assert_eq!(merged.len(), 2);
assert_eq!(merged[0], existing);
let merged = merge_language_highlights(
vec![Highlight { range, kind: HighlightKind::Read }],
vec![ide_plugin::language_features::Highlight { range, kind: "write".into() }],
);
assert_eq!(merged.len(), 1);
assert_eq!(merged[0].kind, HighlightKind::Read);
assert!(
provider_server::<Vec<Location>>(Err(ide_core::IdeError::unavailable("no server"))).unwrap().is_empty()
);
assert!(provider_server::<Vec<Location>>(Err(ide_core::IdeError::cancelled())).is_err());
}
use super::*;
#[test]
@@ -1430,11 +1859,13 @@ mod tests {
use crate::plugins::navigation::{IndexedAnchors, IndexedSymbol};
use ide_core::{FileEdit, LanguageId};
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().canonicalize().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
let shared = root.join("shared.idx");
let native = root.join("native.txt");
std::fs::write(&shared, "old old").unwrap();
std::fs::write(&native, "old").unwrap();
// Exercise legacy verbatim index paths alongside ordinary native URI paths.
let shared = std::fs::canonicalize(shared).unwrap();
let range = Range::new(Pos::new(0, 0), Pos::new(0, 3));
let other = Range::new(Pos::new(0, 4), Pos::new(0, 7));
let symbol = IndexedSymbol {
@@ -1446,8 +1877,6 @@ mod tests {
ranges: vec![range, other],
}],
};
// Native file URIs have ordinary Windows paths; index snapshots retain
// canonical verbatim paths. Both identify the same file and edit batch.
let server_shared = dunce::simplified(&shared).to_path_buf();
let server_native = dunce::simplified(&native).to_path_buf();
#[cfg(windows)]
@@ -1764,6 +2193,55 @@ mod tests {
assert!(without_generated_helpers(Vec::new()).is_empty());
}
#[cfg(unix)]
#[test]
fn plugins_file_actions_confine_content_writes_and_preserve_link_entries() {
use ide_core::FileEdit;
use ide_plugin::{CodeAction, FileOperation};
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("proj");
std::fs::create_dir(&root).unwrap();
let root = dunce::canonicalize(root).unwrap();
let outside = tmp.path().join("outside.txt");
std::fs::write(&outside, "unchanged").unwrap();
std::fs::write(root.join("target.txt"), "inside").unwrap();
for (name, target) in [
("outside", outside.clone()),
("dangling", tmp.path().join("missing.txt")),
("inside", root.join("target.txt")),
] {
std::os::unix::fs::symlink(target, root.join(name)).unwrap();
}
let action = |operations, edits| CodeAction {
title: "Update files".into(),
kind: None,
preferred: false,
edits,
operations,
open: Some(root.join("target.txt")),
data: None,
file_action: true,
};
for name in ["outside", "dangling"] {
let create = FileOperation::Create { path: root.join(name), text: "text".into(), overwrite: true };
assert!(plugin_file_edits(&root, &action(vec![create], Vec::new())).is_err());
let edits = vec![(root.join(name), vec![TextEdit { range: Range::default(), new_text: "text".into() }])];
assert!(plugin_file_edits(&root, &action(Vec::new(), edits)).is_err());
}
let create = FileOperation::Create { path: root.join("inside"), text: "text".into(), overwrite: true };
let (files, _) = plugin_file_edits(&root, &action(vec![create], Vec::new())).unwrap();
assert!(matches!(&files[0], FileEdit::Create { path, .. } if **path == root.join("target.txt")));
let operations = vec![
FileOperation::Rename { from: root.join("outside"), to: root.join("dangling") },
FileOperation::Delete { path: root.join("outside") },
];
let (files, _) = plugin_file_edits(&root, &action(operations, Vec::new())).unwrap();
assert!(matches!(&files[0], FileEdit::Rename { from, to }
if **from == root.join("outside") && **to == root.join("dangling")));
assert!(matches!(&files[1], FileEdit::Delete { path, .. } if **path == root.join("outside")));
assert_eq!(std::fs::read_to_string(outside).unwrap(), "unchanged");
}
#[test]
fn a_plugin_action_maps_its_file_operations_before_its_edits_and_stays_in_the_project() {
use ide_core::FileEdit;
@@ -1771,7 +2249,7 @@ mod tests {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("proj");
std::fs::create_dir_all(&root).unwrap();
let root = root.canonicalize().unwrap();
let root = dunce::canonicalize(&root).unwrap();
let action = |operations: Vec<FileOperation>| ide_plugin::CodeAction {
title: "Create view".into(),
kind: Some("quickfix".into()),
+6
Переглянути файл
@@ -41,6 +41,11 @@ pub fn find_files(host: &Host, window: &str, query: String, limit: u32) -> IdeRe
Ok(host.project(window)?.index.find_files(&query, limit as usize))
}
pub async fn find_files_named(host: &Host, window: &str, name: String) -> IdeResult<Vec<IpcPath>> {
let project = host.project(window)?;
blocking(move || Ok(project.index.files_named(&name).into_iter().map(Into::into).collect())).await
}
pub fn find_dirs(host: &Host, window: &str, query: String, limit: u32) -> IdeResult<Vec<FileMatch>> {
Ok(host.project(window)?.index.find_dirs(&query, limit as usize))
}
@@ -167,6 +172,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("index_status", |c| async move { index_status(&c.host, &c.window) });
d.add("index_file_extensions", |c| async move { index_file_extensions(&c.host, &c.window) });
d.add("find_files", |c| async move { find_files(&c.host, &c.window, c.arg("query")?, c.arg("limit")?) });
d.add("find_files_named", |c| async move { find_files_named(&c.host, &c.window, c.arg("name")?).await });
d.add("find_dirs", |c| async move { find_dirs(&c.host, &c.window, c.arg("query")?, c.arg("limit")?) });
d.add("find_symbols", |c| async move {
find_symbols(&c.host, &c.window, c.arg("query")?, c.arg("filter")?, c.arg("limit")?)
+61 -20
Переглянути файл
@@ -13,6 +13,8 @@ pub struct ProjectInfo {
pub trusted: bool,
pub root: ide_core::IpcPath,
pub name: String,
#[serde(default)]
pub display_name: String,
pub is_git: bool,
pub settings: Settings,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -24,17 +26,23 @@ pub struct ProjectInfo {
pub fn info(project: &Project) -> ProjectInfo {
let settings = project.settings.effective();
let custom = settings.general.project_name.trim();
let primary = if custom.is_empty() {
project.root.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default()
} else {
custom.to_owned()
};
let workspace = ide_workspace::workspace::Workspace::load(&project.root).ok().flatten();
ProjectInfo {
trusted: project.is_trusted(),
name: if custom.is_empty() {
project.root.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default()
} else {
custom.to_owned()
},
display_name: workspace
.as_ref()
.map(|w| w.display_name(&project.root, &primary))
.unwrap_or_else(|| primary.clone()),
name: workspace.as_ref().filter(|w| w.is_container(&project.root)).map(|w| w.name.clone()).unwrap_or(primary),
is_git: !project.repos.read().is_empty(),
settings,
root: project.root.clone().into(),
workspace: ide_workspace::workspace::Workspace::load(&project.root).ok().flatten(),
workspace,
unity: ide_workspace::unity::detect(&project.root),
}
}
@@ -63,38 +71,71 @@ pub fn roots(host: &Host, window: &str) -> IdeResult<ide_workspace::ProjectRoots
pub fn save_workspace(host: &Host, window: &str, name: &str, paths: Vec<std::path::PathBuf>) -> IdeResult<ProjectInfo> {
let project = host.project(window)?;
ide_workspace::workspace::Workspace::save(&project.root, name, paths, false)?;
let previous = ide_workspace::workspace::Workspace::load(&project.root)?
.ok_or_else(|| ide_core::IdeError::invalid("This folder is not a workspace"))?;
for path in paths.iter().filter(|p| !previous.projects.contains(p)) {
require_attachment_trust(host, path)?;
}
if !previous.is_container(&project.root) && paths.first() != Some(&project.root) {
return Err(ide_core::IdeError::invalid("The primary project cannot be detached"));
}
if !previous.is_container(&project.root) && paths == [project.root.clone()] {
std::fs::remove_file(project.root.join(ide_workspace::workspace::FILE))?;
} else {
let workspace = ide_workspace::workspace::Workspace::save(&project.root, name, paths, false)?;
keep_attachment_private(&project.root, &workspace)?;
}
refresh_workspace(&project);
Ok(info(&project))
}
fn require_attachment_trust(host: &Host, path: &Path) -> IdeResult<()> {
if !host.trust.trusted(path)? {
return Err(ide_core::IdeError::invalid("Trust the project before attaching it"));
}
Ok(())
}
fn keep_attachment_private(root: &Path, workspace: &ide_workspace::workspace::Workspace) -> IdeResult<()> {
if !workspace.is_container(root) {
ide_vcs::changelists::ensure_ignored(&root.join(".openidea"), &["/workspace.json"])?;
}
Ok(())
}
fn refresh_workspace(project: &Project) {
project.settings.reload();
project.refresh_index_scope();
project.refresh_repositories();
project.sink.emit(ide_core::IdeEvent::ProjectRootsChanged { root: project.root.clone().into() });
Ok(info(&project))
}
/// Links a folder, or prepares an automatic workspace for the caller to open in place.
/// Attaches a trusted folder without replacing the current project.
pub fn attach_workspace(host: &Host, window: &str, path: &Path) -> IdeResult<ide_core::IpcPath> {
let project = host.project(window)?;
let path = dunce::canonicalize(path)?;
if project.light {
return Err(ide_core::IdeError::invalid("Open a project before attaching a folder"));
}
if path == project.root {
return Ok(project.root.clone().into());
}
require_attachment_trust(host, &path)?;
if let Some(mut workspace) = ide_workspace::workspace::Workspace::load(&project.root)? {
if !workspace.projects.contains(&path) {
workspace.projects.push(path);
save_workspace(host, window, &workspace.name, workspace.projects)?;
}
return Ok(project.root.clone().into());
} else {
let workspace = ide_workspace::workspace::Workspace::attach(
&project.root,
&info(&project).name,
vec![project.root.clone(), path],
)?;
keep_attachment_private(&project.root, &workspace)?;
refresh_workspace(&project);
}
if path == project.root {
return Ok(project.root.clone().into());
}
Ok(ide_workspace::workspace::Workspace::create_for_project(
&host.data_dir,
&project.root,
&info(&project).name,
&path,
)?
.into())
Ok(project.root.clone().into())
}
/// What [`set_settings`] changed; the caller tells the windows.
+109 -16
Переглянути файл
@@ -9,6 +9,9 @@ use std::time::Duration;
pub fn configs(host: &Host, window: &str) -> IdeResult<Vec<RunConfig>> {
host.require_trust(window)?;
let project = host.project(window)?;
if let Some(plugins) = host.plugin_host() {
plugins.refresh_run_configurations(&project.root);
}
project_configs(&project.root, &project.settings.effective(), &host.config_dir)
}
@@ -21,7 +24,7 @@ pub(super) fn project_configs(
let root_suppressed = ide_run::load_suppressed_config_ids(root)?;
let mut all = detected_and_saved(root, settings, &user, &root_suppressed);
if let Some(workspace) = ide_workspace::workspace::Workspace::load(root)? {
for member in workspace.projects.into_iter().filter(|p| p.is_dir()) {
for member in workspace.projects.into_iter().filter(|p| p != root && p.is_dir()) {
let settings = ide_workspace::SettingsStore::load(config_dir, Some(&member)).effective();
let saved = ide_run::load_user_configs(&member)?;
let suppressed = ide_run::load_suppressed_config_ids(&member)?;
@@ -142,20 +145,21 @@ fn settings_for(
config: &mut RunConfig,
) -> IdeResult<(std::path::PathBuf, ide_core::settings::Settings)> {
let workspace = ide_workspace::workspace::Workspace::load(&project.root)?;
if workspace.is_some() {
let owner = ide_run::scope::owner(&project.root, config);
let member = workspace.as_ref().and_then(|workspace| {
workspace
.projects
.iter()
.filter(|root| **root != project.root && owner.starts_with(root))
.max_by_key(|root| root.components().count())
.cloned()
});
if workspace.as_ref().is_some_and(|w| w.is_container(&project.root)) || member.is_some() {
if config.extras.module.is_none() && !matches!(config.kind.as_str(), "application" | "spring-boot") {
config.extras.module = config.cwd.as_deref().map(ide_core::ipc_path::to_ipc);
}
ide_run::scope::rebase(config, &project.root, None);
}
let member = workspace.and_then(|workspace| {
let owner = ide_run::scope::owner(&project.root, config);
workspace
.projects
.into_iter()
.filter(|root| owner.starts_with(root))
.max_by_key(|root| root.components().count())
});
Ok(match member {
Some(root) => {
let settings = ide_workspace::SettingsStore::load(&host.config_dir, Some(&root)).effective();
@@ -199,8 +203,66 @@ pub fn apply_settings(host: &Host, window: &str, configs: Vec<RunConfig>) -> Ide
}
pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit<OutputChunk>) -> IdeResult<RunInfo> {
start_request(host, window, config, on_output, None).await
}
pub async fn start_request(
host: &Host,
window: &str,
config: RunConfig,
on_output: Emit<OutputChunk>,
request: Option<ide_core::RequestId>,
) -> IdeResult<RunInfo> {
let cancel = request.map(|id| host.begin_request(id)).unwrap_or_default();
let operation = config.extras.params.get("plugin.operation").cloned();
let result = start_inner(host, window, config, on_output, &cancel).await;
if let Some(id) = request {
host.end_request(id);
}
if let (Ok(info), Some(operation), Some(plugins)) = (&result, operation, host.plugin_host()) {
plugins.run_operation_started(window, &operation, info.id);
}
result
}
async fn start_inner(
host: &Host,
window: &str,
mut config: RunConfig,
on_output: Emit<OutputChunk>,
cancel: &ide_core::CancellationToken,
) -> IdeResult<RunInfo> {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
host.require_trust(window)?;
let project = host.project(window)?;
if config.extras.target.is_none() {
config.extras.target = ide_sdk::apply::run_target(&config, &project.settings.effective()).map(str::to_owned);
}
if config.detected
&& config.id.starts_with(ide_run::EXTERNAL_PREFIX)
&& let Some(plugins) = host.plugin_host()
{
let provider = plugins.run_detection();
let binary =
provider.launch(&config).map_err(|e| IdeError::new(ide_core::error::ErrorKind::PermissionDenied, e))?;
config.command = if config.extras.target.as_ref().is_some_and(|t| !t.is_empty()) {
provider
.detect(&project.root)
.into_iter()
.find(|c| c.id == config.id)
.ok_or_else(|| IdeError::not_found("plugin configuration no longer available"))?
.command
} else {
binary.to_string_lossy().into_owned()
};
config.detected = false;
}
let mut sink = project.sink.clone();
if let Some(plugins) = host.plugin_host() {
sink = plugins.run_operation_sink(window, &mut config, sink)?;
}
if config.command.is_empty() {
return Err(IdeError::invalid("run configuration has no command"));
}
@@ -210,6 +272,38 @@ pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit
}
if let Some(id) = config.extras.target.as_deref().filter(|t| !t.is_empty()) {
let settings = project.settings.effective();
if id.starts_with("plugin-target:") {
let plugins =
host.plugin_host().ok_or_else(|| IdeError::not_found("plugin run target is no longer available"))?;
let root = project.root.clone();
let id = id.to_owned();
let token = cancel.clone();
let config = host
.blocking(move || {
let operation = if config.is_test {
ide_plugin::RunTargetOperation::Test
} else {
ide_plugin::RunTargetOperation::Run
};
plugins.prepare_run_target_launch(&root, &id, &config, operation, &token)
})
.await?;
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
project.require_trust()?;
return host.processes.start(
&project.root,
&config,
&StartOptions {
encoding: Some(settings.console.encoding).filter(|e| !e.trim().is_empty()),
python_paths: Vec::new(),
ruby_reporter: None,
},
sink,
Box::new(move |c| on_output(c)),
);
}
let target = ide_remote::service::run_target(&settings, id)?;
if target.kind != RunTargetKind::Ssh {
// A local run of the docker CLI: the command is resolved in the container, and
@@ -227,15 +321,12 @@ pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit
&project.root,
&config,
&options,
project.sink.clone(),
sink.clone(),
Box::new(move |c| on_output(c)),
);
}
let out = on_output.clone();
return host
.remote
.run(&settings, &project.root, &config, project.sink.clone(), Box::new(move |c| out(c)))
.await;
return host.remote.run(&settings, &project.root, &config, sink.clone(), Box::new(move |c| out(c))).await;
}
let mut config = config;
let (root, settings) = settings_for(host, &project, &mut config)?;
@@ -286,7 +377,6 @@ pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit
ruby_reporter: ruby_reporter_dir(host, &config),
};
project.require_trust()?;
let mut sink = project.sink.clone();
if config.kind == "go" && matches!(config.args.first().map(String::as_str), Some("mod" | "work")) {
let previous = sink;
let root = project.root.clone();
@@ -317,6 +407,9 @@ fn ruby_reporter_dir(host: &Host, config: &RunConfig) -> Option<std::path::PathB
/// Stops a run (interrupt, then kill after Settings › Run's termination timeout).
pub fn stop(host: &Host, window: &str, id: RunId) -> IdeResult<()> {
if let Some(plugins) = host.plugin_host() {
plugins.run_operation_stopped(id);
}
let settings = match host.project(window) {
Ok(p) => p.settings.effective(),
Err(_) => host.global_settings.effective(),
+7 -6
Переглянути файл
@@ -218,12 +218,13 @@ pub async fn run_tomcat_build(host: &Host, window: &str, config: RunConfig) -> I
.filter(|s| !s.is_empty())
.or_else(|| artifacts.first().map(|a| &a.id))
.ok_or_else(|| IdeError::invalid("Choose a WAR artifact"))?;
let mut plan = ide_build::web::build(
&project.root,
&model,
id,
&ide_run::tomcat::effective_base_dir(&project.root, &project.cache_dir, &config)?,
)?;
let base = ide_run::tomcat::effective_base_dir(&project.root, &project.cache_dir, &config)?;
// "Build before launch" is skipped when the built WAR is already newer than the
// module's sources, so a repeated run does not rebuild (IDEA's up-to-date check).
if ide_run::tomcat::artifact_is_current(&project.root, &base, &config) {
return Ok(None);
}
let mut plan = ide_build::web::build(&project.root, &model, id, &base)?;
plan.env.extend(config.env.clone());
let mut build = RunConfig {
id: format!("tomcat-build:{}", config.id),
+66 -57
Переглянути файл
@@ -34,67 +34,12 @@ pub async fn start(
// Excluded roots (Project Structure marks and detected `target`, `node_modules`, …) and
// ignored names are never searched.
query.exclude.extend(project.search_excludes());
let roots = project.roots();
let set = match query.scope {
ide_search::SearchScope::FileSet { set } => Some(set),
_ => None,
};
let cancel = host.begin_request(request);
let documents = project.documents.clone();
let root = project.root.clone();
let workspace = ide_workspace::workspace::Workspace::load(&root)?.is_some();
// The whole project also covers the Project Structure content roots.
let content_roots = match query.scope {
ide_search::SearchScope::Project
| ide_search::SearchScope::FileSet { .. }
| ide_search::SearchScope::Pattern { .. } => project.content_roots(),
_ => Vec::new(),
};
let include_root = !workspace
|| matches!(query.scope, ide_search::SearchScope::Directory { .. } | ide_search::SearchScope::Files { .. });
let languages = project.languages.clone();
let syntax = host.syntax();
let events = on_event.clone();
let syntax = host.syntax();
let summary = host
.blocking(move || {
let filter = |rel: &str| match set {
Some(ProjectFileSet::Production) => !roots.is_test(rel),
Some(ProjectFileSet::Test) => roots.is_test(rel),
Some(ProjectFileSet::All) | None => true,
};
// Comments and string literals for the search context filter.
let literals = |path: &Path, text: &str| {
let language = languages.detect(path, text.lines().next());
let found = syntax.literals(&language, text)?;
Some(found.into_iter().map(literal_range).collect())
};
let mut total = SearchSummary::default();
for dir in std::iter::once(&root).filter(|_| include_root).chain(&content_roots) {
let s = ide_search::search_in_context(
dir,
&query,
Some(&filter),
Some(&literals),
&|p| documents.search_text_of(p),
&cancel,
&|mut file| {
if dir != &root {
let name = dir.file_name().unwrap_or_default().to_string_lossy();
file.rel_path = format!("{name}/{}", file.rel_path);
}
events(SearchEvent::File { file });
},
)?;
total.files += s.files;
total.matches += s.matches;
total.elapsed_ms += s.elapsed_ms;
total.limit_hit |= s.limit_hit;
total.cancelled |= s.cancelled;
if total.limit_hit || total.cancelled {
break;
}
}
Ok(total)
search_project(&project, &syntax, &query, &cancel, &|file| events(SearchEvent::File { file }))
})
.await;
host.end_request(request);
@@ -102,5 +47,69 @@ pub async fn start(
Ok(())
}
/// Shared Find in Files engine: project excludes are added by the caller.
pub(crate) fn search_project(
project: &crate::project::Project,
syntax: &ide_language::Syntax,
query: &SearchQuery,
cancel: &ide_core::CancellationToken,
emit: &(dyn Fn(FileMatches) + Sync),
) -> IdeResult<SearchSummary> {
let root = &project.root;
let roots = project.roots();
let set = match query.scope {
ide_search::SearchScope::FileSet { set } => Some(set),
_ => None,
};
let workspace = ide_workspace::workspace::Workspace::load(root)?.is_some_and(|w| w.is_container(root));
let include_root = !workspace
|| matches!(query.scope, ide_search::SearchScope::Directory { .. } | ide_search::SearchScope::Files { .. });
let content_roots = match query.scope {
ide_search::SearchScope::Project
| ide_search::SearchScope::FileSet { .. }
| ide_search::SearchScope::Pattern { .. } => project.content_roots(),
_ => Vec::new(),
};
let filter = |rel: &str| match set {
Some(ProjectFileSet::Production) => !roots.is_test(rel),
Some(ProjectFileSet::Test) => roots.is_test(rel),
_ => true,
};
let literals = |path: &Path, text: &str| {
let language = project.languages.detect(path, text.lines().next());
Some(syntax.literals(&language, text)?.into_iter().map(literal_range).collect())
};
let mut total = SearchSummary::default();
for dir in std::iter::once(root).filter(|_| include_root).chain(&content_roots) {
let mut remaining = query.clone();
let max = if query.max_matches == 0 { 10_000 } else { query.max_matches };
remaining.max_matches = max.saturating_sub(total.matches).max(1);
let summary = ide_search::search_in_context(
dir,
&remaining,
Some(&filter),
Some(&literals),
&|p| project.documents.search_text_of(p),
cancel,
&|mut file| {
if dir != root {
let name = dir.file_name().unwrap_or_default().to_string_lossy();
file.rel_path = format!("{name}/{}", file.rel_path);
}
emit(file);
},
)?;
total.files += summary.files;
total.matches += summary.matches;
total.elapsed_ms += summary.elapsed_ms;
total.limit_hit |= summary.limit_hit || total.matches >= max;
total.cancelled |= summary.cancelled;
if total.limit_hit || total.cancelled {
break;
}
}
Ok(total)
}
mod commands;
pub use commands::*;
+2
Переглянути файл
@@ -24,8 +24,10 @@ fn tools(host: &Host, window: &str) -> IdeResult<Tools> {
.collect();
// Godot is usually an app bundle / Flatpak outside the search path.
let found = ide_sdk::godot::find(None).map(|p| (ide_sdk::godot::COMMAND.to_owned(), p));
let runtime = ide_sdk::apply::tool_install_environment(&root, &settings);
let work_dir = (!root.as_os_str().is_empty()).then_some(root);
Ok(Tools::new(&data)
.with_runtime(runtime.commands, runtime.env)
.with_custom_paths(custom)
.with_found_paths(found.into_iter().collect())
.with_work_dir(work_dir))
+17 -4
Переглянути файл
@@ -426,7 +426,8 @@ pub async fn vcs_log_details_batch(
let cancel = host.begin_project_read_request(window, request);
let result = blocking(move || {
project.with_log_repo_at(repo.as_deref(), |r| {
revisions.iter().map(|id| r.log_details_cancellable(id, &cancel)).collect()
r.enable_log_index(&project.cache);
r.log_details_batch_cancellable(&revisions, &cancel)
})
})
.await;
@@ -673,10 +674,11 @@ pub async fn vcs_rename_branch(
repo: Option<PathBuf>,
from: String,
to: String,
unset_upstream: bool,
) -> IdeResult<()> {
host.require_trust(window)?;
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.rename_branch(&from, &to))).await
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.rename_branch(&from, &to, unset_upstream))).await
}
pub async fn vcs_merge(host: &Host, window: &str, repo: Option<PathBuf>, branch: String) -> IdeResult<()> {
@@ -995,7 +997,17 @@ pub async fn vcs_rebase_with(
pub async fn vcs_remotes(host: &Host, window: &str, repo: Option<PathBuf>) -> IdeResult<Vec<Remote>> {
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.remotes())).await
blocking(move || {
let mut remotes = project.with_log_repo_at(repo.as_deref(), |r| r.remotes())?;
for r in &mut remotes {
let resolved = ide_ssh::resolve_ssh_remote_url(&r.url);
if resolved != r.url {
r.resolved_url = Some(resolved);
}
}
Ok(remotes)
})
.await
}
pub async fn vcs_edit_remote(host: &Host, window: &str, repo: Option<PathBuf>, edit: RemoteEdit) -> IdeResult<()> {
@@ -1379,7 +1391,8 @@ pub(crate) fn register(d: &mut Dispatcher) {
vcs_restore_branch(&c.host, &c.window, c.arg("repo")?, c.arg("deleted")?).await
});
d.add("vcs_rename_branch", |c| async move {
vcs_rename_branch(&c.host, &c.window, c.arg("repo")?, c.arg("from")?, c.arg("to")?).await
vcs_rename_branch(&c.host, &c.window, c.arg("repo")?, c.arg("from")?, c.arg("to")?, c.arg("unset_upstream")?)
.await
});
d.add("vcs_merge", |c| async move { vcs_merge(&c.host, &c.window, c.arg("repo")?, c.arg("branch")?).await });
d.add("vcs_rebase", |c| async move { vcs_rebase(&c.host, &c.window, c.arg("repo")?, c.arg("onto")?).await });
File diff suppressed because it is too large. Load diff
+205
Переглянути файл
@@ -0,0 +1,205 @@
//! One-shot OAuth redirects, always bound on the desktop's IPv4 loopback.
use ide_core::CancellationToken;
use ide_plugin::HostError;
use parking_lot::Mutex;
use std::collections::HashMap;
use std::io::{Read, Write};
use std::net::{Ipv4Addr, TcpListener, TcpStream};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};
pub(super) trait Service: Send + Sync {
fn begin(&self, plugin: &str, window: &str, path: &str) -> Result<(u64, String), HostError>;
fn wait(
&self,
plugin: &str,
id: u64,
timeout: Duration,
cancel: &CancellationToken,
) -> Result<Option<String>, HostError>;
fn close(&self, plugin: &str, id: u64);
fn clear(&self, plugin: &str);
fn forget_window(&self, window: &str);
}
struct Entry {
window: String,
path: String,
listener: Mutex<Option<TcpListener>>,
cancel: CancellationToken,
}
#[derive(Default)]
pub(super) struct Loopbacks {
next: AtomicU64,
entries: Mutex<HashMap<(String, u64), Arc<Entry>>>,
}
fn failed(e: impl std::fmt::Display) -> HostError {
HostError::Failed(e.to_string())
}
fn respond(stream: &mut TcpStream, ok: bool) {
let body = if ok { "You can close this tab." } else { "Not found." };
let status = if ok { "200 OK" } else { "404 Not Found" };
let _ = write!(
stream,
"HTTP/1.1 {status}\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\nCache-Control: no-store\r\n\r\n{body}",
body.len()
);
}
impl Loopbacks {
fn remove_where(&self, predicate: impl Fn(&str, &Entry) -> bool) {
self.entries.lock().retain(|(p, _), entry| {
if !predicate(p, entry) {
return true;
}
entry.cancel.cancel();
entry.listener.lock().take();
false
});
}
}
impl Service for Loopbacks {
fn begin(&self, plugin: &str, window: &str, path: &str) -> Result<(u64, String), HostError> {
let path = path.trim_start_matches('/');
if path.len() > 1024
|| path.bytes().any(|b| !(b.is_ascii_alphanumeric() || b"/-._~".contains(&b)))
|| path.split('/').any(|s| matches!(s, "." | ".."))
{
return Err(HostError::Invalid("loopback path must be a URL path without query or fragment".into()));
}
let mut entries = self.entries.lock();
if entries.keys().filter(|(p, _)| p == plugin).count() >= 2 {
return Err(HostError::Invalid("at most two open loopbacks per plugin".into()));
}
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).map_err(failed)?;
listener.set_nonblocking(true).map_err(failed)?;
let uri = format!("http://127.0.0.1:{}/{path}", listener.local_addr().map_err(failed)?.port());
let id = self.next.fetch_add(1, Ordering::Relaxed);
entries.insert(
(plugin.into(), id),
Arc::new(Entry {
window: window.into(),
path: format!("/{path}"),
listener: Mutex::new(Some(listener)),
cancel: CancellationToken::new(),
}),
);
Ok((id, uri))
}
fn wait(
&self,
plugin: &str,
id: u64,
timeout: Duration,
cancel: &CancellationToken,
) -> Result<Option<String>, HostError> {
let entry = self
.entries
.lock()
.get(&(plugin.into(), id))
.cloned()
.ok_or_else(|| HostError::NotFound("loopback".into()))?;
let listener =
entry.listener.lock().take().ok_or_else(|| HostError::Invalid("loopback already awaited".into()))?;
let deadline = Instant::now() + timeout;
let result = (|| loop {
if cancel.is_cancelled() || entry.cancel.is_cancelled() {
return Err(HostError::Cancelled);
}
match listener.accept() {
Ok((mut stream, address)) => {
if !address.ip().is_loopback() {
continue;
}
stream.set_nonblocking(true).map_err(failed)?;
let mut head = Vec::new();
loop {
if cancel.is_cancelled() || entry.cancel.is_cancelled() {
return Err(HostError::Cancelled);
}
if Instant::now() >= deadline {
return Ok(None);
}
let mut buf = [0; 1024];
match stream.read(&mut buf) {
Ok(0) => break,
Ok(n) => {
head.extend_from_slice(&buf[..n]);
if head.windows(4).any(|w| w == b"\r\n\r\n") || head.len() > 8192 {
break;
}
}
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
std::thread::sleep(Duration::from_millis(5))
}
Err(e) => return Err(failed(e)),
}
}
let line = std::str::from_utf8(&head).unwrap_or_default().lines().next().unwrap_or_default();
let target = line.strip_prefix("GET ").and_then(|s| s.split_once(' ')).map(|(target, _)| target);
let query = target.and_then(|target| {
let (path, query) = target.split_once('?').unwrap_or((target, ""));
(path == entry.path && head.len() <= 8192).then(|| query.to_owned())
});
stream.set_nonblocking(false).map_err(failed)?;
stream.set_write_timeout(Some(Duration::from_millis(100))).map_err(failed)?;
respond(&mut stream, query.is_some());
if query.is_some() {
return Ok(query);
}
}
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {}
Err(e) => return Err(failed(e)),
}
if Instant::now() >= deadline {
return Ok(None);
}
std::thread::sleep(Duration::from_millis(5));
})();
self.close(plugin, id);
result
}
fn close(&self, plugin: &str, id: u64) {
let entry = self.entries.lock().remove(&(plugin.into(), id));
if let Some(entry) = entry {
entry.cancel.cancel();
entry.listener.lock().take();
}
}
fn clear(&self, plugin: &str) {
self.remove_where(|p, _| p == plugin || p.starts_with("remote:") && p.ends_with(&format!(":{plugin}")));
}
fn forget_window(&self, window: &str) {
self.remove_where(|_, entry| entry.window == window);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn one_shot_raw_query_limits_timeout_and_cleanup() {
let service = Arc::new(Loopbacks::default());
let (id, uri) = service.begin("a", "main", "oauth/callback").unwrap();
assert!(uri.starts_with("http://127.0.0.1:"));
let (other, _) = service.begin("a", "main", "").unwrap();
assert!(service.begin("a", "main", "third").is_err());
assert_eq!(service.wait("a", other, Duration::ZERO, &CancellationToken::new()).unwrap(), None);
let host = uri.strip_prefix("http://").unwrap().split('/').next().unwrap().to_owned();
let worker = std::thread::spawn(move || {
let mut stream = TcpStream::connect(host).unwrap();
stream.write_all(b"GET /oauth/callback?code=a%2Bb&state=x HTTP/1.1\r\nHost: localhost\r\n\r\n").unwrap();
let mut answer = String::new();
stream.read_to_string(&mut answer).unwrap();
assert!(answer.contains("You can close this tab"));
});
assert_eq!(
service.wait("a", id, Duration::from_secs(2), &CancellationToken::new()).unwrap(),
Some("code=a%2Bb&state=x".into())
);
worker.join().unwrap();
assert!(service.wait("a", id, Duration::ZERO, &CancellationToken::new()).is_err());
let (id, _) = service.begin("a", "main", "callback").unwrap();
service.forget_window("main");
assert!(service.wait("a", id, Duration::ZERO, &CancellationToken::new()).is_err());
}
}
+225
Переглянути файл
@@ -0,0 +1,225 @@
//! Database ownership stays on the project host; credentials never pass back to guests.
use super::api::PluginApi;
use super::{PluginHost, WindowRequest};
use ide_core::{CancellationToken, IdeError, IdeEvent, IdeResult};
use ide_db::{DataSource, DbDriver, DbProperty};
use ide_plugin::{HostError, PluginDataSource};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
#[derive(serde::Serialize, serde::Deserialize)]
struct OwnedProject {
root: PathBuf,
sources: std::collections::BTreeSet<String>,
}
pub(super) fn settings(source: PluginDataSource) -> Result<DataSource, HostError> {
let bad = || HostError::Invalid("invalid non-secret database source settings".into());
if source.id.is_empty()
|| source.id.len() > 128
|| source.name.trim().is_empty()
|| source.name.len() > 256
|| source.id.chars().chain(source.name.chars()).any(char::is_control)
|| source.options.len() > 128
|| [&source.host, &source.database, &source.user, &source.url]
.into_iter()
.flatten()
.any(|v| v.len() > 16_384 || v.contains('\0'))
{
return Err(bad());
}
let driver = match source.driver.as_str() {
"sqlite" => DbDriver::Sqlite,
"postgres" => DbDriver::Postgres,
"mysql" => DbDriver::Mysql,
"sqlServer" => DbDriver::SqlServer,
"clickhouse" => DbDriver::Clickhouse,
"oracle" => DbDriver::Oracle,
_ => return Err(bad()),
};
let secret = |key: &str| {
let key = key.to_ascii_lowercase();
["password", "passwd", "pwd", "token", "secret", "credential"].iter().any(|s| key.contains(s))
};
if source.options.iter().any(|(k, v)| {
k.is_empty() || k.len() > 256 || v.len() > 16_384 || k.contains('\0') || v.contains('\0') || secret(k)
}) {
return Err(bad());
}
let mut out = DataSource {
name: source.name,
driver,
host: source.host.unwrap_or_else(|| "localhost".into()),
port: source.port,
database: source.database.unwrap_or_default(),
user: source.user.unwrap_or_default(),
save_password: true,
properties: source.options.into_iter().map(|(name, value)| DbProperty { name, value }).collect(),
..Default::default()
};
if driver == DbDriver::Sqlite {
out.file = Some(out.database.clone().into());
}
if let Some(url) = source.url.filter(|u| !u.is_empty()) {
if url.contains(';') {
return Err(bad());
}
let parsed = ide_db::source::parse_url(&url).map_err(|_| bad())?;
if parsed.password.is_some() || parsed.params.iter().any(|(k, _)| secret(k)) {
return Err(bad());
}
// Case-insensitive URL credential keys must not bypass the parser's lowercase password key.
if let Some((_, query)) = url.split_once('?') {
for part in query.split('&') {
let key = part.split('=').next().unwrap_or_default();
// Parsing a synthetic URL decodes percent-encoded parameter names as well.
let probe = ide_db::source::parse_url(&format!("postgres://host/db?{key}=x")).map_err(|_| bad())?;
if probe.password.is_some() || probe.params.iter().any(|(k, _)| secret(k)) {
return Err(bad());
}
}
}
out.url_only = true;
out.url = url;
out.resolved().map_err(|_| bad())?;
}
Ok(out)
}
impl PluginApi {
pub(super) fn database_host(&self, root: &Path) -> Result<(Arc<crate::Host>, Arc<ide_db::DbManager>), HostError> {
let workspace = self.shared.workspace().ok_or(HostError::Cancelled)?;
if !workspace.projects().iter().any(|r| r == root) {
return Err(HostError::NotFound("project is not open".into()));
}
if !workspace.trusted(root) {
return Err(HostError::Denied("project is not trusted".into()));
}
ide_plugin::resolve_new_in_root(root, ".openidea/data-sources.toml").map_err(HostError::Invalid)?;
let host = workspace
.database_host()
.ok_or_else(|| HostError::Invalid("Database subsystem is unavailable on this host".into()))?;
let db = host.projects().into_iter().find(|p| p.root == root).ok_or(HostError::Cancelled)?.db.clone();
Ok((host, db))
}
pub(super) fn remember_database_project(&self, root: &Path, id: &str) -> Result<(), HostError> {
let file = self.shared.data_dir.join(".database-projects").join(format!("{}.json", self.pkg.id()));
let mut projects: Vec<OwnedProject> = match std::fs::read(&file) {
Ok(bytes) => serde_json::from_slice(&bytes).map_err(|e| HostError::Failed(e.to_string()))?,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => vec![],
Err(e) => return Err(HostError::Failed(e.to_string())),
};
if !projects.iter().any(|p| p.root == root) {
projects.push(OwnedProject { root: root.into(), sources: Default::default() });
}
let project = projects.iter_mut().find(|p| p.root == root).unwrap();
if project.sources.insert(id.into()) {
std::fs::create_dir_all(file.parent().unwrap()).map_err(|e| HostError::Failed(e.to_string()))?;
ide_vfs::write_atomic(&file, &serde_json::to_vec(&projects).unwrap()).map_err(error)?;
}
Ok(())
}
pub(super) fn connect_database(
&self,
root: &Path,
client: &str,
id: &str,
cancel: &CancellationToken,
timeout: Duration,
) -> Result<(), HostError> {
let (_, db) = self.database_host(root)?;
if self.shared.project_of(client).as_deref() != Some(root) {
return Err(HostError::Invalid("requires this project's invoking window".into()));
}
let source = db.plugin_source(self.pkg.id(), id).map_err(error)?;
self.window_request(client, WindowRequest::DatabaseConnect { id: source.id }, cancel, timeout).map(|_| ())
}
}
pub(super) fn changed(host: &crate::Host, root: &Path) {
for project in host.projects().into_iter().filter(|p| p.root == root) {
project.sink.emit(IdeEvent::DbSourcesChanged);
}
}
impl PluginHost {
pub(super) fn remove_database_sources(&self, plugin: &str) -> IdeResult<()> {
let file = self.shared.data_dir.join(".database-projects").join(format!("{plugin}.json"));
let projects: Vec<OwnedProject> = match std::fs::read(&file) {
Ok(bytes) => serde_json::from_slice(&bytes).map_err(|e| IdeError::invalid(e.to_string()))?,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(e) => return Err(e.into()),
};
let host = self
.shared
.workspace()
.and_then(|w| w.database_host())
.ok_or_else(|| IdeError::unavailable("Database subsystem is unavailable on this host"))?;
for project in projects {
let root = project.root;
let removed = ide_db::plugin::remove_all(&root, plugin)?;
let store = ide_db::secrets::SecretStore::new(host.data_dir.join("db-passwords.json"), &root);
let ids: std::collections::BTreeSet<_> = project
.sources
.iter()
.map(|id| ide_db::plugin::source_id(plugin, id))
.chain(removed.into_iter().map(|source| source.id))
.collect();
for id in ids {
store.delete(&id);
store.delete(&ide_db::secrets::ssh_key(&id));
for project in host.projects().into_iter().filter(|p| p.root == root) {
host.runtime.block_on(project.db.forget_plugin_source(&id));
}
}
changed(&host, &root);
}
std::fs::remove_file(file)?;
Ok(())
}
}
pub(super) fn error(e: IdeError) -> HostError {
use ide_core::error::ErrorKind;
match e.kind {
ErrorKind::NotFound => HostError::NotFound(e.message),
ErrorKind::InvalidInput => HostError::Invalid(e.message),
ErrorKind::PermissionDenied => HostError::Denied(e.message),
_ => HostError::Failed(e.message),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn source() -> PluginDataSource {
PluginDataSource { id: "local".into(), name: "Local".into(), driver: "postgres".into(), ..Default::default() }
}
#[test]
fn rejects_secrets_without_echoing_them_and_documents_every_driver_id() {
for driver in ["sqlite", "postgres", "mysql", "sqlServer", "clickhouse", "oracle"] {
assert!(settings(PluginDataSource { driver: driver.into(), ..source() }).is_ok(), "{driver}");
}
for url in [
"postgres://user:top-secret@host/db",
"postgres://host/db?password=top-secret",
"postgres://host/db?PASSWORD=top-secret",
"postgres://host/db?to%6ben=top-secret",
] {
let error = settings(PluginDataSource { url: Some(url.into()), ..source() }).unwrap_err();
assert!(!error.to_string().contains("top-secret"));
}
for key in ["password", "accessToken", "client_secret", "PWD"] {
assert!(
settings(PluginDataSource { options: vec![(key.into(), "top-secret".into())], ..source() }).is_err()
);
}
let source = settings(PluginDataSource {
url: Some("postgres://reader@host:5433/db?sslmode=require".into()),
options: vec![("application_name".into(), "Example".into())],
..source()
})
.unwrap();
assert_eq!(source.resolved().unwrap().host, "host");
}
}
+25 -2
Переглянути файл
@@ -79,7 +79,9 @@ impl Diagnostics {
doc.cancel.cancel();
doc.cancel = CancellationToken::new();
let Some(host) = editor_plugin_host() else { return };
if host.providers.plugins_for(ProviderKind::Diagnostics, &doc.snapshot.language).is_empty() {
if host.providers.plugins_for(ProviderKind::Diagnostics, &doc.snapshot.language).is_empty()
&& !host.providers.any(ProviderKind::Injections)
{
return;
}
// Register only once per project incarnation; no project is retained by the host.
@@ -113,6 +115,7 @@ impl Diagnostics {
snapshot.text = current.text;
drop(documents);
let injections = host.providers.injection_generations(&snapshot.language);
let generations = host.providers.diagnostic_generations(&snapshot.language);
let results = if snapshot.text.len() <= super::navigation::MAX_DOCUMENT_BYTES {
host.provide_project_diagnostics(Some(&root), &snapshot, &cancel)
@@ -124,6 +127,11 @@ impl Diagnostics {
if state.closed || cancel.is_cancelled() {
return;
}
if injections != host.providers.injection_generations(&snapshot.language)
|| injections.keys().any(|id| lifecycle_generations.get(id) != state.generations.get(id))
{
return;
}
let matching: HashSet<_> = host
.diagnostic_plugins(Some(&root), &snapshot.language)
.into_iter()
@@ -224,6 +232,13 @@ pub(super) type ProjectState = (Weak<Mutex<State>>, SharedSink);
impl PluginHost {
pub(super) fn clear_provider_diagnostics(&self, plugin: &str) {
// Clone before taking a project's state lock (edits register in the other order).
let injector = self.get(plugin).is_ok_and(|p| {
p.manifest().declarations("providers").iter().any(|d| {
d.get("kinds")
.and_then(|v| v.as_array())
.is_some_and(|v| v.iter().any(|k| k.as_str() == Some("injections")))
})
});
let projects = self.diagnostic_projects.lock().clone();
for (state, sink) in projects {
if let Some(state) = state.upgrade() {
@@ -231,7 +246,15 @@ impl PluginHost {
// A completed answer can still be waiting for another provider.
*state.generations.entry(plugin.to_owned()).or_default() += 1;
for doc in state.documents.values_mut() {
if doc.sources.remove(plugin) {
if injector {
for source in doc.sources.drain() {
sink.emit(IdeEvent::Diagnostics {
path: doc.snapshot.path.clone().into(),
source: format!("plugin-provider:{source}"),
items: vec![],
});
}
} else if doc.sources.remove(plugin) {
sink.emit(IdeEvent::Diagnostics {
path: doc.snapshot.path.clone().into(),
source: format!("plugin-provider:{plugin}"),
+1 -1
Переглянути файл
@@ -281,7 +281,7 @@ mod tests {
#[test]
fn validates_entire_rooted_batch_with_utf16_positions() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().canonicalize().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
let file = root.join("routes.rb");
std::fs::write(&file, "😀x\n").unwrap();
let good = endpoint(file.clone());
+568
Переглянути файл
@@ -0,0 +1,568 @@
//! Window-owned plugin operations. Instance reclamation does not end them.
use super::api::{PluginApi, PromptEntry, Waiter};
use super::{CommandAnswer, EventQueue, PluginEvent, PluginHost, WindowRequest};
use ide_core::{CancellationToken, IdeError, IdeResult};
use ide_plugin::{ContentTab, ContentTabReply, HostError, WorkbenchEvent};
use std::sync::Arc;
use std::sync::atomic::Ordering;
use std::time::{Duration, Instant};
pub(super) struct Operation {
pub plugin: String,
pub client: String,
pub kind: OperationKind,
pub root: Option<std::path::PathBuf>,
pub started: bool,
pub closed: bool,
pub run: Option<ide_core::RunId>,
pub terminal: Option<(std::sync::Weak<crate::Host>, ide_core::TerminalId)>,
}
pub(super) enum OperationKind {
Run { config: String, configuration: String, program: String },
Terminal { key: String, command: ide_plugin::ProcessCommand },
}
pub(super) fn label(value: &str, max: usize) -> Result<(), HostError> {
if value.is_empty() || value.encode_utf16().count() > max || value.chars().any(char::is_control) {
return Err(HostError::Invalid(format!("expected 1-{max} characters without controls")));
}
Ok(())
}
fn body(value: &str) -> Result<(), HostError> {
if value.encode_utf16().count() > 1 << 20 || value.contains('\0') {
return Err(HostError::Invalid("content too large or contains NUL".into()));
}
Ok(())
}
pub(super) fn validate_tab(tab: &ContentTab) -> Result<(), HostError> {
label(&tab.key, 128)?;
label(&tab.title, 256)?;
body(&tab.content)?;
if let Some(name) = &tab.file_name {
label(name, 256)?;
if name.contains(['/', '\\']) {
return Err(HostError::Invalid("file-name cannot contain separators".into()));
}
}
if let Some(language) = &tab.language {
label(language, 64)?;
}
if tab.actions.len() > 16 || tab.actions.iter().filter(|a| a.save).count() > 1 {
return Err(HostError::Invalid("at most 16 actions and one Save action".into()));
}
let mut ids = std::collections::BTreeSet::new();
for action in &tab.actions {
label(&action.id, 64)?;
label(&action.label, 128)?;
if !ids.insert(&action.id) {
return Err(HostError::Invalid("duplicate action id".into()));
}
if let Some(icon) = &action.icon {
label(icon, 128)?;
}
}
Ok(())
}
pub(super) fn validate_reply(reply: &ContentTabReply) -> Result<(), HostError> {
if let Some(content) = &reply.content {
body(content)?;
}
if reply.status.as_ref().is_some_and(|s| s.len() > 512) || reply.error.as_ref().is_some_and(|s| s.len() > 1024) {
return Err(HostError::Invalid("content status or error too long".into()));
}
if let Some(diff) = &reply.diff {
body(&diff.left)?;
body(&diff.right)?;
for value in [&diff.left_label, &diff.right_label].into_iter().flatten() {
label(value, 128)?;
}
if let Some(title) = &diff.title {
label(title, 256)?;
}
}
Ok(())
}
impl PluginApi {
pub(super) fn new_operation(&self, client: &str, kind: OperationKind) -> Result<String, HostError> {
let closed = self.shared.closed_clients.lock();
let blocked = self.shared.prompts_blocked.lock();
if closed.contains(client) || blocked.contains(self.pkg.id()) {
return Err(HostError::Cancelled);
}
let mut operations = self.shared.operations.lock();
if operations.values().filter(|o| o.plugin == self.pkg.id()).count() >= 32 {
return Err(HostError::Invalid("at most 32 active operations".into()));
}
let id = format!("plugin-op-{}", self.shared.next_prompt.fetch_add(1, Ordering::Relaxed));
operations.insert(
id.clone(),
Operation {
plugin: self.pkg.id().into(),
client: client.into(),
kind,
root: self.shared.project_of(client),
started: false,
closed: false,
terminal: None,
run: None,
},
);
Ok(id)
}
pub(super) fn window_request(
&self,
client: &str,
request: WindowRequest,
cancel: &CancellationToken,
timeout: Duration,
) -> Result<Option<String>, HostError> {
let id = self.shared.next_prompt.fetch_add(1, Ordering::Relaxed);
let (tx, rx) = std::sync::mpsc::sync_channel(1);
{
let closed = self.shared.closed_clients.lock();
let blocked = self.shared.prompts_blocked.lock();
if closed.contains(client) || blocked.contains(self.pkg.id()) {
return Err(HostError::Cancelled);
}
self.shared.prompts.lock().insert(
id,
PromptEntry { plugin: self.pkg.id().into(), client: client.into(), answer: Waiter::Command(tx) },
);
}
self.shared.sink.emit(
Some(client),
PluginEvent::WindowRequest { plugin_id: self.pkg.id().into(), request_id: id.to_string(), request },
);
let deadline = Instant::now() + timeout;
loop {
match rx.recv_timeout(Duration::from_millis(20)) {
Ok(CommandAnswer::Value(value)) => return Ok(value),
Ok(CommandAnswer::NotFound) => return Err(HostError::NotFound("window service unavailable".into())),
Ok(CommandAnswer::Failed(error)) => return Err(HostError::Failed(error)),
Err(std::sync::mpsc::RecvTimeoutError::Timeout)
if !cancel.is_cancelled() && Instant::now() < deadline => {}
Err(_) => {
self.shared.prompts.lock().remove(&id);
return Err(HostError::Cancelled);
}
}
}
}
}
impl PluginHost {
/// The launcher alone may finish its operation; duplicate and foreign reports are ignored.
pub fn finish_operation(&self, client: &str, id: &str, status: Option<i32>, stopped: bool) -> bool {
let operation = {
let mut operations = self.shared.operations.lock();
if !operations.get(id).is_some_and(|op| op.client == client) {
return false;
}
operations.remove(id).unwrap()
};
let event = match operation.kind {
OperationKind::Run { config, .. } => {
WorkbenchEvent::RunExited(ide_plugin::RunExited { id: id.into(), config, status, killed: stopped })
}
OperationKind::Terminal { key, .. } => WorkbenchEvent::TerminalExited(ide_plugin::TerminalExited {
id: id.into(),
key,
status,
closed: stopped,
}),
};
self.post_owned_event(&operation.plugin, client, event);
true
}
/// Content actions bypass subscriptions, just like completions of owned operations.
pub fn content_tab_action(
&self,
client: &str,
plugin: &str,
action: ide_plugin::ContentTabAction,
) -> IdeResult<()> {
let tabs = self.shared.content_tabs.lock();
let tab = tabs
.get(&(plugin.into(), client.into(), action.key.clone()))
.ok_or_else(|| IdeError::not_found("unknown content tab"))?;
if !tab.actions.iter().any(|a| a.id == action.action) {
return Err(IdeError::invalid("unknown content action"));
}
body(&action.content).map_err(|e| IdeError::invalid(e.to_string()))?;
label(&action.request_id, 128).map_err(|e| IdeError::invalid(e.to_string()))?;
drop(tabs);
let mut pending = self.shared.content_requests.lock();
pending.retain(|_, (_, created)| created.elapsed() < Duration::from_secs(15));
if pending.keys().filter(|(p, w, _)| p == plugin && w == client).count() >= 32 {
return Err(IdeError::invalid("too many pending content actions"));
}
pending.insert((plugin.into(), client.into(), action.request_id.clone()), (action.key.clone(), Instant::now()));
drop(pending);
self.post_owned_event(plugin, client, WorkbenchEvent::ContentTabAction(action));
Ok(())
}
fn post_owned_event(&self, plugin: &str, client: &str, event: WorkbenchEvent) {
if self.shared.client_closed(client) {
return;
}
self.queues
.lock()
.entry((plugin.into(), Some(client.into())))
.or_insert_with(|| EventQueue { since: Instant::now(), events: Vec::new(), files_source: None })
.events
.push(event);
}
}
/// Dynamic configurations opt in with marker files, just like static detectors.
pub(super) fn configurations_declaration(
pkg: &ide_extension::Package,
value: toml::Value,
) -> Result<ide_extension::UiDecl, String> {
#[derive(serde::Deserialize, serde::Serialize)]
#[serde(deny_unknown_fields)]
struct Decl {
files: Vec<String>,
}
pkg.manifest.needs_api("contributes.runConfigurations", 4)?;
let decl: Decl = ide_extension::decode("runConfigurations", value)?;
if pkg.manifest.wasm.is_none() || decl.files.is_empty() || decl.files.len() > 16 {
return Err("runConfigurations needs code and 1-16 marker files".into());
}
for file in &decl.files {
ide_extension::check_relative(std::path::Path::new(file), "files")?;
}
Ok(ide_extension::UiDecl::new(
serde_json::to_string(&decl.files).unwrap(),
"Run configurations",
serde_json::to_value(decl).unwrap(),
))
}
impl PluginHost {
/// Refreshes configuration exports on the same listing path as static detectors.
pub fn refresh_run_configurations(&self, root: &std::path::Path) {
if !self.shared.workspace().is_some_and(|w| w.trusted(root)) {
return;
}
let owners: std::collections::BTreeSet<_> = self
.ui
.items("runConfigurations", None)
.into_iter()
.filter(|d| {
d.decl["files"]
.as_array()
.is_some_and(|files| files.iter().filter_map(|f| f.as_str()).any(|f| root.join(f).is_file()))
})
.map(|d| d.plugin_id)
.collect();
self.shared.run_configurations.write().retain(|(p, r), _| r != root || owners.contains(p));
for plugin in owners {
let project = root.to_path_buf();
let invocation = ide_plugin::Invocation { workspace_root: Some(project.clone()), ..Default::default() };
let result = self.code_call(&plugin, invocation, "run-configurations", |rt, ctx| {
rt.run_configurations(
ctx.workspace_root.unwrap().to_string_lossy().into_owned(),
Duration::from_secs(2),
)
});
let Ok(items) = result else { continue };
if let Err(error) = validate_configurations(root, &items) {
tracing::warn!(%plugin, %error, "invalid run configurations");
continue;
}
self.shared.run_configurations.write().insert((plugin, project), items);
}
}
/// Resolves only an owned plugin test configuration in the client's trusted project.
fn test_configuration(
&self,
client: &str,
config: &str,
) -> IdeResult<Option<(std::path::PathBuf, String, String)>> {
let root = self.shared.project_of(client).ok_or_else(|| IdeError::not_found("no project"))?;
if !self.shared.workspace().is_some_and(|w| w.trusted(&root)) {
return Err(IdeError::invalid("project is not trusted"));
}
let Some(rest) = config.strip_prefix(ide_run::EXTERNAL_PREFIX) else { return Ok(None) };
let Some((owner, id)) = rest.split_once(':') else { return Ok(None) };
let configuration = self
.shared
.workspace()
.ok_or_else(IdeError::cancelled)?
.run_configuration(client, owner, id)
.map_err(|e| IdeError::invalid(e.to_string()))?;
if !configuration.is_test || configuration.extras.params.get("framework").map(String::as_str) != Some("plugin")
{
return Ok(None);
}
Ok(Some((root, owner.to_owned(), id.to_owned())))
}
/// Derives replacement argv for a user action, under the command budget.
pub fn select_tests(&self, client: &str, config: &str, tests: Vec<Vec<String>>) -> IdeResult<Option<Vec<String>>> {
if tests.is_empty()
|| tests.len() > 10000
|| tests.iter().any(|p| p.is_empty() || p.len() > 128)
|| serde_json::to_vec(&tests).is_ok_and(|b| b.len() > 4 << 20)
{
return Ok(None);
}
let Some((root, owner, id)) = self.test_configuration(client, config)? else { return Ok(None) };
let plugin = self.get(&owner)?;
let (actor, activated) = self.ensure_active(&plugin, None)?;
if activated {
self.changed();
}
let invocation = ide_plugin::Invocation { workspace_root: Some(root), ..Default::default() };
// A user action, not the edit path: allow the command budget (a cold instance may be compiling).
let timeout = self.limits.timeout;
let started = Instant::now();
let result = actor.call(Some(timeout), move |rt| rt.select_tests(id, tests, invocation, timeout));
let args = self.finish_provider_call(&plugin, &actor, started, result).flatten();
Ok(args.filter(|args| args.len() <= 10000 && args.iter().map(String::len).sum::<usize>() <= 4 << 20))
}
/// Parses a bounded batch under the editor-provider soft/hard budgets.
pub fn parse_test_results(
&self,
client: &str,
config: &str,
chunk: String,
) -> IdeResult<Option<Vec<ide_plugin::PluginTestEvent>>> {
if chunk.len() > 64 << 10 {
return Err(IdeError::invalid("test output batch too large"));
}
let Some((root, owner, id)) = self.test_configuration(client, config)? else { return Ok(None) };
let plugin = self.get(&owner)?;
let (actor, activated) = self.ensure_active(&plugin, None)?;
if activated {
self.changed();
}
let invocation = ide_plugin::Invocation { workspace_root: Some(root.clone()), ..Default::default() };
let (id, hard) = (id.to_owned(), self.limits.edit_hard);
let started = Instant::now();
let result =
actor.call(Some(self.limits.edit_soft), move |rt| rt.parse_test_results(id, chunk, invocation, hard));
let mut events = self.finish_provider_call(&plugin, &actor, started, result);
if let Some(events) = &mut events {
if serde_json::to_vec(&*events).is_ok_and(|b| b.len() > 4 << 20) {
return Ok(None);
}
for event in events {
let location = match event {
ide_plugin::PluginTestEvent::SuiteStarted(v) | ide_plugin::PluginTestEvent::TestStarted(v) => {
v.location.as_mut()
}
ide_plugin::PluginTestEvent::Failed(v) => v.location.as_mut(),
_ => None,
};
if let Some(location) = location {
let Ok(path) = ide_plugin::resolve_new_in_root(&root, &location.path.to_string_lossy()) else {
return Ok(None);
};
if location.range.end < location.range.start {
return Ok(None);
}
location.path = path;
}
}
}
Ok(events)
}
}
fn validate_configurations(
root: &std::path::Path,
items: &[ide_plugin::PluginRunConfiguration],
) -> Result<(), HostError> {
let mut ids = std::collections::BTreeSet::new();
if items.len() > 1000 || serde_json::to_vec(items).is_ok_and(|b| b.len() > 4 << 20) {
return Err(HostError::Invalid("too many run configurations".into()));
}
for c in items {
ide_extension::validate_item_id(&c.id, "run configuration id").map_err(HostError::Invalid)?;
label(&c.name, 128)?;
if !ids.insert(&c.id)
|| c.program.is_empty()
|| c.program.len() > 256
|| c.program.contains(char::is_whitespace)
|| c.args.len() > 256
|| c.args.iter().any(|a| a.len() > 4096 || a.contains('\0'))
{
return Err(HostError::Invalid("invalid run configuration".into()));
}
if let Some(cwd) = &c.cwd {
ide_plugin::resolve_new_in_root(root, cwd).map_err(HostError::Invalid)?;
}
}
Ok(())
}
impl PluginHost {
/// Resolves and rechecks the pin immediately before starting a keyed terminal's PTY.
pub fn spawn_terminal_session(
self: &std::sync::Arc<Self>,
host: &std::sync::Arc<crate::Host>,
client: &str,
operation: &str,
cols: u16,
rows: u16,
output: crate::ops::Emit<String>,
) -> IdeResult<ide_terminal::TerminalInfo> {
let (plugin, command) = {
let mut operations = self.shared.operations.lock();
let op = operations
.get_mut(operation)
.filter(|op| op.client == client)
.ok_or_else(|| IdeError::not_found("unknown terminal operation"))?;
if op.started || op.closed || op.root != self.shared.project_of(client) {
return Err(IdeError::invalid("terminal operation already started or project changed"));
}
let OperationKind::Terminal { command, .. } = &op.kind else {
return Err(IdeError::invalid("not a terminal operation"));
};
op.started = true;
(op.plugin.clone(), command.clone())
};
let options = super::web::WebPtyCommand {
program: command.program,
args: command.args,
env: command.env.into_iter().collect(),
cols,
rows,
};
let weak = Arc::downgrade(self);
let (id, window) = (operation.to_owned(), client.to_owned());
let completion = Arc::new(move |status| {
if let Some(plugins) = weak.upgrade() {
let closed = plugins.shared.operations.lock().get(&id).is_some_and(|op| op.closed);
plugins.finish_operation(&window, &id, status, closed);
}
});
let result = self.spawn_plugin_pty(host, &plugin, client, output, Some(completion), || {
self.pty_options(&plugin, client, options)
});
if let Ok(info) = &result {
let mut operations = self.shared.operations.lock();
if let Some(op) = operations.get_mut(operation) {
op.terminal = Some((Arc::downgrade(host), info.id));
if op.closed {
let _ = host.terminals.kill(info.id);
}
}
}
if result.is_err()
&& let Some(op) = self.shared.operations.lock().get_mut(operation)
{
op.started = false;
}
result
}
}
impl PluginHost {
/// Closing a tab marks the operation before killing its PTY. The real exit emits the event.
pub fn close_terminal_session(&self, client: &str, id: &str) -> IdeResult<()> {
let terminal = {
let mut operations = self.shared.operations.lock();
let Some(op) = operations
.get_mut(id)
.filter(|op| op.client == client && matches!(op.kind, OperationKind::Terminal { .. }))
else {
return Ok(());
};
op.closed = true;
op.terminal.clone()
};
if let Some((host, id)) = terminal
&& let Some(host) = host.upgrade()
{
host.terminals.kill(id)?;
}
Ok(())
}
}
impl PluginHost {
/// Rechecks the owned configuration's program before Debug and every IDE rerun.
pub fn prepare_debug_run(&self, client: &str, mut config: ide_run::RunConfig) -> IdeResult<ide_run::RunConfig> {
let Some((plugin, id)) = config.id.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()))?;
if owned.debug_adapter.is_none() {
return Err(IdeError::invalid("this configuration has no debugger"));
}
if config.extras.target.is_none() {
config.extras.target = owned.extras.target.clone();
}
if config.extras.target.as_ref().is_some_and(|t| !t.is_empty()) {
return Err(IdeError::invalid("debugging this target is not supported"));
}
config.command = self
.program_for_project(plugin, &owned.command, self.shared.project_of(client).as_deref())?
.to_string_lossy()
.into_owned();
config.debug_adapter = owned.debug_adapter;
Ok(config)
}
}
/// Internal launch metadata; it is ignored after the original operation finishes.
pub(super) const RUN_OPERATION: &str = "plugin.operation";
impl PluginHost {
pub(crate) fn run_operation_sink(
self: &Arc<Self>,
client: &str,
config: &mut ide_run::RunConfig,
previous: Arc<dyn ide_core::EventSink>,
) -> IdeResult<Arc<dyn ide_core::EventSink>> {
let Some(id) = config.extras.params.get(RUN_OPERATION).cloned() else { return Ok(previous) };
let origin = self.shared.operations.lock().get(&id).and_then(|op| {
if op.client != client {
return None;
}
let OperationKind::Run { configuration, program, .. } = &op.kind else { return None };
(configuration == &config.id).then(|| (op.plugin.clone(), op.root.clone(), program.clone()))
});
let Some((plugin, root, program)) = origin else { return Ok(previous) };
let binary = self.program_for_project(&plugin, &program, root.as_deref())?;
config.command = if config.extras.target.as_ref().is_some_and(|t| !t.is_empty()) {
program
} else {
binary.to_string_lossy().into_owned()
};
config.detected = false;
let (weak, client) = (Arc::downgrade(self), client.to_owned());
Ok(Arc::new(move |event: ide_core::IdeEvent| {
if let ide_core::IdeEvent::RunExited { code, .. } = &event
&& let Some(plugins) = weak.upgrade()
{
let stopped = plugins.shared.operations.lock().get(&id).is_some_and(|op| op.closed);
plugins.finish_operation(&client, &id, *code, stopped);
}
previous.emit(event);
}))
}
pub(crate) fn run_operation_started(&self, client: &str, operation: &str, run: ide_core::RunId) {
if let Some(op) = self
.shared
.operations
.lock()
.get_mut(operation)
.filter(|op| op.client == client && matches!(op.kind, OperationKind::Run { .. }))
{
op.run = Some(run);
}
}
pub(crate) fn run_operation_stopped(&self, run: ide_core::RunId) {
for op in self.shared.operations.lock().values_mut().filter(|op| op.run == Some(run)) {
op.closed = true;
}
}
}
+1
Переглянути файл
@@ -113,6 +113,7 @@ pub async fn plugin_navigation_index(
&project.root,
crate::plugins::navigation::Snapshot { path: &path, version, text: &text, symbols: &symbols, scope },
Invocation {
command_chain: Vec::new(),
client: Some(client.clone()),
workspace_root: Some(project.root.clone()),
editor: None,
+158
Переглянути файл
@@ -0,0 +1,158 @@
//! Embedded documents preserve the host's UTF-16 coordinates.
use super::{PluginHost, ProviderKind};
use ide_core::{Pos, Range};
use ide_plugin::{ProviderDocument, language_features::Injection};
use std::sync::Arc;
/// Allowed output ranges for one virtual document; the host document is unrestricted.
pub(super) struct Bounds(Option<Vec<Range>>);
impl Bounds {
pub fn unrestricted() -> Self {
Self(None)
}
pub fn contains(&self, range: Range) -> bool {
self.0.as_ref().is_none_or(|rs| rs.iter().any(|r| r.start <= range.start && range.end <= r.end))
}
pub fn lines(&self, start: u32, end: u32) -> bool {
self.0.as_ref().is_none_or(|rs| rs.iter().any(|r| r.start.line <= start && end <= r.end.line))
}
}
impl PluginHost {
pub fn provide_injections(&self, doc: &ProviderDocument) -> Vec<Injection> {
if !self.providers.any(ProviderKind::Injections) {
return vec![];
}
if doc.text.len() > super::navigation::MAX_DOCUMENT_BYTES {
return Vec::new();
}
let text = ide_text::Document::new(&doc.text);
let d = Arc::new(doc.clone());
let mut items: Vec<_> = self
.document_calls(ProviderKind::Injections, doc, move |rt, t| rt.provide_injections(&d, t))
.into_iter()
.flat_map(|(_, v)| v)
.filter(|i| {
!i.range.is_empty()
&& super::language_features::valid_range_in(&text, i.range)
&& i.language != doc.language
&& !i.language.is_empty()
&& i.language.len() <= 64
&& i.language.chars().all(|c| c.is_ascii_alphanumeric() || "-_.+#".contains(c))
})
.collect();
items.sort_by_key(|i| i.range);
let mut end = Pos::default();
items.retain(|i| {
if i.range.start < end {
return false;
}
end = i.range.end;
true
});
items.truncate(1000);
items
}
pub(super) fn feature_documents(&self, doc: &ProviderDocument, at: Option<Pos>) -> Vec<(ProviderDocument, Bounds)> {
let injections = self.provide_injections(doc);
let mut languages = Vec::new();
for i in &injections {
if at.is_some_and(|p| p < i.range.start || p >= i.range.end) || languages.contains(&i.language) {
continue;
}
languages.push(i.language.clone());
if languages.len() == 32 {
break;
}
}
let mut docs = Vec::new();
for language in languages {
let ranges = injections.iter().filter(|i| i.language == language).map(|i| i.range).collect();
let text = virtual_text(doc, &injections, &language);
docs.push((ProviderDocument { language, text, ..doc.clone() }, Bounds(Some(ranges))));
}
docs.push((doc.clone(), Bounds(None)));
docs
}
}
pub(crate) fn virtual_text(doc: &ProviderDocument, ranges: &[Injection], language: &str) -> String {
let ranges: Vec<_> = ranges.iter().filter(|i| i.language == language).map(|i| i.range).collect();
let mut index = 0;
let mut at = Pos::default();
let mut text = String::with_capacity(doc.text.len());
for c in doc.text.chars() {
while index < ranges.len() && ranges[index].end <= at {
index += 1;
}
let inside = ranges.get(index).is_some_and(|r| r.start <= at && at < r.end);
if inside || c == '\n' || c == '\r' {
text.push(c);
} else {
for _ in 0..c.len_utf16() {
text.push(' ');
}
}
if c == '\n' {
at.line += 1;
at.col = 0;
} else {
at.col += c.len_utf16() as u32;
}
}
text
}
/// Shared by local and remote projects; all plugin work runs on the host.
static LSP_PROJECTS: parking_lot::Mutex<Vec<std::sync::Weak<ide_lsp::LspManager>>> =
parking_lot::Mutex::new(Vec::new());
pub(super) fn providers_changed() {
let projects = {
let mut projects = LSP_PROJECTS.lock();
projects.retain(|p| p.strong_count() > 0);
projects.iter().filter_map(std::sync::Weak::upgrade).collect::<Vec<_>>()
};
for project in projects {
project.injection_providers_changed();
}
}
pub(crate) fn configure_lsp(lsp: &Arc<ide_lsp::LspManager>) {
LSP_PROJECTS.lock().push(Arc::downgrade(lsp));
lsp.set_injection_provider(Arc::new(|path, language, version, text| {
let Some(plugins) = super::editor_plugin_host() else { return Vec::new() };
plugins
.provide_injections(&ProviderDocument {
path: path.to_path_buf(),
language: language.0.clone(),
version: version.into(),
text: text.to_owned(),
})
.into_iter()
.map(|i| ide_lsp::InjectedRange { range: i.range, language: ide_core::LanguageId::new(i.language) })
.collect()
}));
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn virtual_documents_preserve_utf16_columns_and_newlines() {
let d = ProviderDocument {
path: "/p/a".into(),
language: "host".into(),
version: 7,
text: "😀 sql\n outside\n sql".into(),
};
let ranges = vec![
Injection { range: Range::new(Pos::new(0, 3), Pos::new(0, 6)), language: "sql".into() },
Injection { range: Range::new(Pos::new(2, 1), Pos::new(2, 4)), language: "sql".into() },
];
let text = virtual_text(&d, &ranges, "sql");
assert_eq!(text, " sql\n \n sql");
assert_eq!(text.encode_utf16().count(), d.text.encode_utf16().count());
}
}
+195 -8
Переглянути файл
@@ -78,6 +78,25 @@ pub fn read_source(path: &Path) -> IdeResult<IntellijThemeSource> {
/// The theme plugins of the JetBrains IDEs (and Android Studio) of this user,
/// the themes their IDEs are set to first.
pub fn installed_themes() -> Vec<InstalledIntellijTheme> {
find_installed(&latest_product_versions(local_ide_dirs()))
}
/// The settings folder of a JetBrains IDE (or Android Studio) of this user.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct InstalledIdeSettings {
/// The IDE and its version: `IntelliJ IDEA 2025.2`.
pub ide: String,
/// What Import Settings reads.
pub path: IpcPath,
}
/// The settings folders of the JetBrains IDEs of this user, most recently used first.
pub fn installed_ide_settings() -> Vec<InstalledIdeSettings> {
settings_folders(&local_ide_dirs())
}
fn local_ide_dirs() -> Vec<IdeDir> {
let Some(home) = dirs::home_dir() else { return Vec::new() };
let layout = if cfg!(target_os = "macos") {
Layout::Mac
@@ -86,7 +105,65 @@ pub fn installed_themes() -> Vec<InstalledIntellijTheme> {
} else {
Layout::Xdg
};
find_installed(&ide_dirs(layout, &home, |key| std::env::var_os(key)))
ide_dirs(layout, &home, |key| std::env::var_os(key))
}
fn settings_folders(ides: &[IdeDir]) -> Vec<InstalledIdeSettings> {
let mut used: Vec<_> =
ides.iter().filter_map(|ide| settings_modified(&ide.config).map(|time| (ide, time))).collect();
used.sort_by(|(a, at), (b, bt)| {
bt.cmp(at).then_with(|| a.label.cmp(&b.label)).then_with(|| a.config.cmp(&b.config))
});
used.into_iter()
.map(|(ide, _)| InstalledIdeSettings { ide: ide.label.clone(), path: ide.config.clone().into() })
.collect()
}
fn settings_modified(config: &Path) -> Option<std::time::SystemTime> {
let options = config.join("options");
// Match the folder reader: never count links or walk below the settings directories.
if !config.symlink_metadata().ok()?.is_dir() || !options.symlink_metadata().ok()?.is_dir() {
return None;
}
let mut modified = None;
let mut customised = false;
for entry in std::fs::read_dir(&options).ok()?.flatten() {
let path = entry.path();
if !entry.file_type().is_ok_and(|t| t.is_file())
|| !path.extension().is_some_and(|e| e.eq_ignore_ascii_case("xml"))
{
continue;
}
let Ok(meta) = entry.metadata() else { continue };
modified = modified.max(meta.modified().ok());
// Startup bookkeeping can exist before the user has changed any settings.
let name = entry.file_name();
customised |= meta.len() > 0
&& !matches!(
name.to_str(),
Some(
"other.xml"
| "nonRoamable.xml"
| "early-access-registry.xml"
| "applicationInfo.xml"
| "updates.xml"
| "usage.statistics.xml"
)
);
}
customised |= ["keymaps", "codestyles", "colors", "templates", "inspection"].iter().any(|sub| {
let dir = config.join(sub);
dir.symlink_metadata().is_ok_and(|m| m.is_dir())
&& std::fs::read_dir(dir).into_iter().flatten().flatten().any(|entry| {
entry.file_type().is_ok_and(|t| t.is_file())
&& entry.metadata().is_ok_and(|m| m.len() > 0)
&& entry
.path()
.extension()
.is_some_and(|e| e.eq_ignore_ascii_case("xml") || e.eq_ignore_ascii_case("icls"))
})
});
customised.then(|| modified.or_else(|| options.metadata().ok()?.modified().ok()).unwrap_or(std::time::UNIX_EPOCH))
}
fn read(path: &Path) -> Result<IntellijThemeSource, String> {
@@ -422,8 +499,7 @@ struct IdeDir {
plugins: PathBuf,
}
/// The settings folders of JetBrains IDEs and Android Studio (2020.1 and later), newest first, one
/// per product: its newest version's (an upgrade leaves the older ones behind).
/// The settings folders of JetBrains IDEs and Android Studio (2020.1 and later), newest version first.
fn ide_dirs(layout: Layout, home: &Path, env: impl Fn(&str) -> Option<OsString>) -> Vec<IdeDir> {
let var = |key: &str| env(key).filter(|v| !v.is_empty()).map(PathBuf::from);
let (config_root, data_root) = match layout {
@@ -463,11 +539,16 @@ fn ide_dirs(layout: Layout, home: &Path, env: impl Fn(&str) -> Option<OsString>)
}
}
out.sort_by(|a, b| b.version.cmp(&a.version).then_with(|| a.label.cmp(&b.label)));
let mut products = std::collections::HashSet::new();
out.retain(|ide| products.insert(ide.product.clone()));
out
}
/// Theme discovery only uses the newest version of each product.
fn latest_product_versions(mut ides: Vec<IdeDir>) -> Vec<IdeDir> {
let mut products = std::collections::HashSet::new();
ides.retain(|ide| products.insert(ide.product.clone()));
ides
}
/// `IntelliJIdea2025.2` → (`IntelliJIdea`, (2025, 2), `2025.2`).
fn product_dir(name: &str) -> Option<(&str, (u32, u32), &str)> {
let at = name.find(|c: char| c.is_ascii_digit())?;
@@ -787,15 +868,16 @@ mod tests {
};
let mac = mk(home.join("Library/Application Support/JetBrains/IntelliJIdea2025.2"));
mk(home.join("Library/Application Support/JetBrains/Toolbox"));
// An upgrade leaves the older version's folder behind: only the newest of a product counts.
// Settings import keeps every version; theme discovery uses only the newest.
mk(home.join("Library/Application Support/JetBrains/IntelliJIdea2024.3"));
mk(home.join("Library/Application Support/JetBrains/IntelliJIdea2025.10"));
let dirs = ide_dirs(Layout::Mac, home, |_| None);
assert_eq!(dirs.len(), 1);
assert_eq!(dirs.len(), 3);
assert_eq!(dirs[0].label, "IntelliJ IDEA 2025.10");
assert_eq!(latest_product_versions(dirs).len(), 1);
std::fs::remove_dir(home.join("Library/Application Support/JetBrains/IntelliJIdea2025.10")).unwrap();
let dirs = ide_dirs(Layout::Mac, home, |_| None);
assert_eq!(dirs.len(), 1);
assert_eq!(dirs.len(), 2);
assert_eq!(dirs[0].label, "IntelliJ IDEA 2025.2");
assert_eq!((dirs[0].config.clone(), dirs[0].plugins.clone()), (mac.clone(), mac.join("plugins")));
@@ -881,4 +963,109 @@ mod tests {
assert_eq!(found, [theme("shallow-id", "Shallow", false, true), theme("deep-sea-id", "Deep Sea", true, false)]);
assert!(find_installed(&[]).is_empty());
}
#[test]
fn unused_newer_versions_do_not_hide_settings() {
let tmp = tempfile::tempdir().unwrap();
for (layout, root) in [
(Layout::Mac, "Library/Application Support"),
(Layout::Windows, "AppData/Roaming"),
(Layout::Xdg, ".config"),
] {
let root = tmp.path().join(root).join("JetBrains");
let used = root.join("IntelliJIdea2024.3");
let fresh = root.join("IntelliJIdea2025.2");
write(&used.join("options/editor.xml"), b"<application><component name=\"EditorSettings\"/></application>");
std::fs::create_dir_all(&fresh).unwrap();
let expected = vec![InstalledIdeSettings { ide: "IntelliJ IDEA 2024.3".into(), path: used.into() }];
assert_eq!(settings_folders(&ide_dirs(layout, tmp.path(), |_| None)), expected);
write(&fresh.join("options/other.xml"), b"<application/>");
write(&fresh.join("options/early-access-registry.xml"), b"<application/>");
assert_eq!(settings_folders(&ide_dirs(layout, tmp.path(), |_| None)), expected);
}
}
#[test]
fn settings_candidates_keep_used_versions_and_sort_by_options_modification() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("Library/Application Support/JetBrains");
let put = |product: &str, file: &str, seconds| {
let path = root.join(product).join(file);
write(&path, b"<application/>");
std::fs::File::options()
.write(true)
.open(path)
.unwrap()
.set_modified(std::time::UNIX_EPOCH + std::time::Duration::from_secs(seconds))
.unwrap();
};
put("IntelliJIdea2025.2", "options/editor.xml", 100);
put("IntelliJIdea2024.3", "options/editor.xml", 200);
put("IntelliJIdea2024.3", "options/editor-font.xml", 400);
put("PyCharm2025.1", "options/editor.xml", 300);
// Directory times and unrelated files do not change options-file ordering.
write(&root.join("IntelliJIdea2025.2/options/README.txt"), b"recent");
let dirs = ide_dirs(Layout::Mac, tmp.path(), |_| None);
let found = settings_folders(&dirs);
assert_eq!(
found.iter().map(|s| s.ide.as_str()).collect::<Vec<_>>(),
["IntelliJ IDEA 2024.3", "PyCharm 2025.1", "IntelliJ IDEA 2025.2"]
);
assert_eq!(found[0].path, root.join("IntelliJIdea2024.3"));
assert_eq!(latest_product_versions(dirs).len(), 2);
}
#[test]
fn skips_empty_and_startup_only_settings_but_keeps_custom_schemes() {
let tmp = tempfile::tempdir().unwrap();
let config = tmp.path();
assert!(settings_modified(config).is_none());
std::fs::create_dir(config.join("options")).unwrap();
assert!(settings_modified(config).is_none());
for file in ["other", "nonRoamable", "early-access-registry", "applicationInfo", "updates", "usage.statistics"]
{
write(
&config.join(format!("options/{file}.xml")),
b"<application><component name=\"Startup\"/></application>",
);
}
write(&config.join("options/editor.xml"), b"");
write(&config.join("options/nested/editor.xml"), b"<application/>");
for sub in ["keymaps", "codestyles", "colors", "templates", "inspection"] {
std::fs::create_dir(config.join(sub)).unwrap();
write(&config.join(sub).join("README.txt"), b"not settings");
}
assert!(settings_modified(config).is_none());
for (sub, file) in [
("keymaps", "custom.xml"),
("codestyles", "custom.xml"),
("colors", "custom.icls"),
("templates", "custom.xml"),
("inspection", "custom.xml"),
] {
let path = config.join(sub).join(file);
write(&path, b"<custom/>");
assert!(settings_modified(config).is_some(), "{sub} is a user setting");
std::fs::remove_file(path).unwrap();
}
}
#[test]
fn lists_the_settings_folders_of_ides_that_have_been_run() {
let tmp = tempfile::tempdir().unwrap();
let ide = |label: &str, product: &str, version: (u32, u32)| {
let config = tmp.path().join(format!("{product}{}.{}", version.0, version.1));
IdeDir { label: label.into(), product: product.into(), version, config: config.clone(), plugins: config }
};
let used = ide("IntelliJ IDEA 2025.2", "IntelliJIdea", (2025, 2));
let fresh = ide("PyCharm 2025.1", "PyCharm", (2025, 1));
let used_config = used.config.clone();
write(&used_config.join("options/editor.xml"), b"<application/>");
std::fs::create_dir_all(&fresh.config).unwrap();
assert_eq!(
settings_folders(&[used, fresh]),
[InstalledIdeSettings { ide: "IntelliJ IDEA 2025.2".into(), path: used_config.into() }]
);
assert!(settings_folders(&[]).is_empty());
}
}
+174
Переглянути файл
@@ -0,0 +1,174 @@
//! Plugin-owned local byte streams, closed on resource drop and plugin shutdown.
use super::api::net_runtime;
use ide_core::CancellationToken;
use ide_plugin::HostError;
use parking_lot::Mutex;
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
trait Stream: AsyncRead + AsyncWrite + Unpin + Send {}
impl<T: AsyncRead + AsyncWrite + Unpin + Send> Stream for T {}
struct Connection {
stream: tokio::sync::Mutex<Box<dyn Stream>>,
closed: CancellationToken,
}
impl Drop for Connection {
fn drop(&mut self) {
self.closed.cancel();
}
}
#[derive(Default)]
pub(super) struct Connections {
next: AtomicU64,
entries: Mutex<HashMap<(String, u64), Arc<Connection>>>,
}
fn failed(e: impl std::fmt::Display) -> HostError {
HostError::Failed(e.to_string())
}
impl Connections {
pub fn connect(
&self,
plugin: &str,
name: &str,
cancel: &CancellationToken,
timeout: Duration,
) -> Result<u64, HostError> {
let check = |entries: &HashMap<(String, u64), Arc<Connection>>| {
if entries.keys().filter(|(p, _)| p == plugin).count() >= 8 {
Err(HostError::Invalid("at most eight IPC connections per plugin".into()))
} else {
Ok(())
}
};
check(&self.entries.lock())?;
let stream: Box<dyn Stream> = net_runtime().block_on(async {
let connect = async {
#[cfg(unix)]
{ Ok::<Box<dyn Stream>, HostError>(Box::new(tokio::net::UnixStream::connect(name).await.map_err(failed)?)) }
#[cfg(windows)]
{ Ok::<Box<dyn Stream>, HostError>(Box::new(tokio::net::windows::named_pipe::ClientOptions::new().open(name).map_err(failed)?)) }
#[cfg(not(any(unix, windows)))]
{ let _ = name; Err(HostError::Failed("local IPC unavailable".into())) }
};
tokio::select! {
r = tokio::time::timeout(timeout, connect) => r.map_err(|_| HostError::Failed("IPC connection timed out".into()))?,
() = cancel.cancelled() => Err(HostError::Cancelled),
}
})?;
let mut entries = self.entries.lock();
check(&entries)?;
if cancel.is_cancelled() {
return Err(HostError::Cancelled);
}
let id = self.next.fetch_add(1, Ordering::Relaxed);
entries.insert(
(plugin.into(), id),
Arc::new(Connection { stream: tokio::sync::Mutex::new(stream), closed: CancellationToken::new() }),
);
Ok(id)
}
fn get(&self, plugin: &str, id: u64) -> Result<Arc<Connection>, HostError> {
self.entries
.lock()
.get(&(plugin.into(), id))
.cloned()
.ok_or_else(|| HostError::NotFound("IPC connection".into()))
}
pub fn write(
&self,
plugin: &str,
id: u64,
bytes: Vec<u8>,
cancel: &CancellationToken,
timeout: Duration,
) -> Result<(), HostError> {
let entry = self.get(plugin, id)?;
net_runtime().block_on(async {
tokio::select! {
r = tokio::time::timeout(timeout, async { entry.stream.lock().await.write_all(&bytes).await.map_err(failed) }) => r.map_err(|_| HostError::Failed("IPC write timed out".into()))?,
() = cancel.cancelled() => Err(HostError::Cancelled),
() = entry.closed.cancelled() => Err(HostError::Cancelled),
}
})
}
pub fn read(
&self,
plugin: &str,
id: u64,
max: usize,
timeout: Duration,
cancel: &CancellationToken,
) -> Result<Vec<u8>, HostError> {
let entry = self.get(plugin, id)?;
net_runtime().block_on(async {
let read = async {
let mut bytes = vec![0; max.min(1 << 20)];
let count = entry.stream.lock().await.read(&mut bytes).await.map_err(failed)?;
bytes.truncate(count);
Ok(bytes)
};
tokio::select! {
biased;
() = cancel.cancelled() => Err(HostError::Cancelled),
() = entry.closed.cancelled() => Err(HostError::Cancelled),
r = read => r,
() = tokio::time::sleep(timeout) => Ok(Vec::new()),
}
})
}
pub fn close(&self, plugin: &str, id: u64) {
if let Some(entry) = self.entries.lock().remove(&(plugin.into(), id)) {
entry.closed.cancel();
}
}
pub fn clear(&self, plugin: &str) {
self.entries.lock().retain(|(p, _), entry| {
if p != plugin {
return true;
}
entry.closed.cancel();
false
});
}
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
use std::io::{Read, Write};
use std::os::unix::net::UnixListener;
#[test]
fn echo_limit_and_shutdown() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("echo.sock");
let listener = UnixListener::bind(&path).unwrap();
let worker = std::thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let mut bytes = [0; 4];
stream.read_exact(&mut bytes).unwrap();
stream.write_all(&bytes).unwrap();
// Accept the remaining seven connections before shutdown.
for _ in 1..8 {
listener.accept().unwrap();
}
});
let pool = Connections::default();
let token = CancellationToken::new();
let timeout = Duration::from_secs(2);
let id = pool.connect("a", path.to_str().unwrap(), &token, timeout).unwrap();
pool.write("a", id, b"echo".to_vec(), &token, timeout).unwrap();
assert_eq!(pool.read("a", id, 4, timeout, &token).unwrap(), b"echo");
for _ in 1..8 {
pool.connect("a", path.to_str().unwrap(), &token, timeout).unwrap();
}
assert!(matches!(pool.connect("a", path.to_str().unwrap(), &token, timeout), Err(HostError::Invalid(_))));
assert!(pool.read("b", id, 4, timeout, &token).is_err());
pool.clear("a");
assert!(pool.read("a", id, 4, timeout, &token).is_err());
worker.join().unwrap();
}
}
+462
Переглянути файл
@@ -0,0 +1,462 @@
//! API 1.4 language features. Results are bounded and checked against the snapshot.
use super::{PluginHost, ProviderKind};
use ide_core::{IdeResult, Pos, Range};
use ide_plugin::{ProviderDocument, language_features::*};
use std::sync::Arc;
pub(crate) fn valid_range(doc: &ProviderDocument, range: Range) -> bool {
valid_range_in(&ide_text::Document::new(&doc.text), range)
}
pub(super) fn valid_range_in(text: &ide_text::Document, range: Range) -> bool {
if range.start > range.end {
return false;
}
[range.start, range.end].iter().all(|p| text.offset_to_pos(text.pos_to_offset(*p)) == *p)
}
impl PluginHost {
pub fn language_format(
&self,
id: &str,
doc: &ProviderDocument,
range: Option<Range>,
options: FormatOptions,
) -> IdeResult<Vec<ide_core::TextEdit>> {
let candidates = if let Some(r) = range {
self.feature_documents(doc, Some(r.start))
} else {
vec![(doc.clone(), super::injections::Bounds::unrestricted())]
};
let (target, bounds) = candidates
.into_iter()
.find(|(d, b)| {
range.is_none_or(|r| b.contains(r))
&& self
.provider_plugins(ProviderKind::Formatting, &d.language)
.iter()
.any(|p| p.id() == id && p.dev_root.as_ref().is_none_or(|r| d.path.starts_with(r)))
})
.ok_or_else(|| ide_core::IdeError::not_found("The selected plugin formatter is unavailable"))?;
let text = ide_text::Document::new(&target.text);
let d = Arc::new(target);
let edits = self.code_call(id, ide_plugin::Invocation::default(), "formatting", move |rt, _| {
rt.language_format(&d, range, options.clone(), std::time::Duration::from_secs(5))
})?;
if edits.iter().any(|e| !valid_range_in(&text, e.range) || !bounds.contains(e.range)) {
return Err(ide_core::IdeError::invalid("Plugin formatter returned an invalid range"));
}
let mut ranges: Vec<_> = edits.iter().map(|e| e.range).collect();
ranges.sort();
if ranges.windows(2).any(|p| p[0].end > p[1].start) {
return Err(ide_core::IdeError::invalid("Plugin formatter returned overlapping edits"));
}
Ok(edits)
}
pub fn language_signature_help(&self, doc: &ProviderDocument, at: Pos) -> Option<SignatureHelp> {
for (d, _bounds) in self.feature_documents(doc, Some(at)) {
let snapshot = d.clone();
let d = Arc::new(d);
let results = self.document_calls(ProviderKind::SignatureHelp, &snapshot, move |rt, t| {
rt.language_signature_help(&d, at, t)
});
for (_, v) in results {
if let Some(v) = v.filter(|h| !h.signatures.is_empty()) {
return Some(v);
}
}
}
None
}
pub fn language_document_highlights(&self, doc: &ProviderDocument, at: Pos) -> Vec<Highlight> {
let mut out = Vec::new();
for (d, bounds) in self.feature_documents(doc, Some(at)) {
let snapshot = d.clone();
let text = ide_text::Document::new(&snapshot.text);
let d = Arc::new(d);
let results = self.document_calls(ProviderKind::DocumentHighlights, &snapshot, move |rt, t| {
rt.language_document_highlights(&d, at, t)
});
for (p, items) in results {
for i in items {
let _ = &p;
if valid_range_in(&text, i.range) && bounds.contains(i.range) {
out.push(i);
}
}
}
}
out
}
pub fn language_document_links(&self, doc: &ProviderDocument) -> Vec<DocumentLink> {
let mut out = Vec::new();
for (d, bounds) in self.feature_documents(doc, None) {
let snapshot = d.clone();
let text = ide_text::Document::new(&snapshot.text);
let d = Arc::new(d);
let results = self
.document_calls(ProviderKind::DocumentLinks, &snapshot, move |rt, t| rt.language_document_links(&d, t));
for (p, items) in results {
for i in items {
let _ = &p;
if valid_range_in(&text, i.range) && bounds.contains(i.range) {
out.push(i);
}
}
}
}
out
}
pub fn language_code_lenses(&self, doc: &ProviderDocument) -> Vec<(String, CodeLens)> {
let mut out = Vec::new();
for (d, bounds) in self.feature_documents(doc, None) {
let snapshot = d.clone();
let text = ide_text::Document::new(&snapshot.text);
let d = Arc::new(d);
let results =
self.document_calls(ProviderKind::CodeLenses, &snapshot, move |rt, t| rt.language_code_lenses(&d, t));
for (p, items) in results {
let commands: std::collections::HashSet<_> =
self.ui.items(super::COMMANDS, Some(p.id())).into_iter().map(|c| c.key).collect();
for i in items {
if valid_range_in(&text, i.range) && bounds.contains(i.range) && commands.contains(&i.command) {
out.push((p.id().to_owned(), i));
}
}
}
}
out
}
pub fn language_selection_ranges(&self, doc: &ProviderDocument, at: Pos) -> Vec<Range> {
let mut out = Vec::new();
for (d, bounds) in self.feature_documents(doc, Some(at)) {
let snapshot = d.clone();
let text = ide_text::Document::new(&snapshot.text);
let d = Arc::new(d);
let results = self.document_calls(ProviderKind::SelectionRanges, &snapshot, move |rt, t| {
rt.language_selection_ranges(&d, at, t)
});
for (p, items) in results {
for i in items {
let _ = &p;
if valid_range_in(&text, i) && i.start <= at && at <= i.end && bounds.contains(i) {
out.push(i);
}
}
}
}
out.sort_by(|a, b| b.start.cmp(&a.start).then(a.end.cmp(&b.end)));
out.dedup();
let mut end = None;
out.retain(|r| {
let ok = end.is_none_or(|prev: Range| r.start <= prev.start && prev.end <= r.end);
if ok {
end = Some(*r);
}
ok
});
out
}
pub fn language_folding(&self, doc: &ProviderDocument) -> Vec<FoldRange> {
let mut out = Vec::new();
for (d, bounds) in self.feature_documents(doc, None) {
let lines = d.text.split('\n').count() as u32;
let snapshot = d.clone();
let d = Arc::new(d);
let results =
self.document_calls(ProviderKind::Folding, &snapshot, move |rt, t| rt.language_folding(&d, t));
for (p, items) in results {
for i in items {
let _ = &p;
if i.start_line < i.end_line && i.end_line < lines && bounds.lines(i.start_line, i.end_line) {
out.push(i);
}
}
}
}
out
}
pub fn language_colors(&self, doc: &ProviderDocument) -> Vec<(String, ColorInfo)> {
let mut out = Vec::new();
for (d, bounds) in self.feature_documents(doc, None) {
let snapshot = d.clone();
let text = ide_text::Document::new(&snapshot.text);
let d = Arc::new(d);
let results = self.document_calls(ProviderKind::Colors, &snapshot, move |rt, t| rt.language_colors(&d, t));
for (p, items) in results {
for i in items {
if valid_range_in(&text, i.range)
&& bounds.contains(i.range)
&& [i.red, i.green, i.blue, i.alpha].iter().all(|c| c.is_finite() && (0.0..=1.0).contains(c))
{
out.push((p.id().to_owned(), i));
}
}
}
}
out
}
pub fn language_color_presentations(&self, id: &str, doc: &ProviderDocument, color: ColorInfo) -> Vec<String> {
for (d, bounds) in self.feature_documents(doc, Some(color.range.start)) {
if !bounds.contains(color.range)
|| !self.provider_plugins(ProviderKind::Colors, &d.language).iter().any(|p| p.id() == id)
{
continue;
}
let snapshot = d.clone();
let d = Arc::new(d);
let color = color.clone();
let result = self.document_calls_for(ProviderKind::Colors, &snapshot, Some(id), move |rt, t| {
rt.language_color_presentations(&d, color.clone(), t)
});
if let Some((_, items)) = result.into_iter().find(|(p, _)| p.id() == id) {
return items.into_iter().take(100).collect();
}
}
Vec::new()
}
pub fn language_document_symbols(&self, doc: &ProviderDocument) -> Vec<DocumentSymbol> {
let mut out = Vec::new();
for (d, bounds) in self.feature_documents(doc, None) {
let snapshot = d.clone();
let text = ide_text::Document::new(&snapshot.text);
let d = Arc::new(d);
let results = self.document_calls(ProviderKind::DocumentSymbols, &snapshot, move |rt, t| {
rt.language_document_symbols(&d, t)
});
for (_, items) in results {
let mut remap = std::collections::HashMap::new();
for (index, mut i) in items.into_iter().enumerate() {
if !valid_range_in(&text, i.range)
|| !valid_range_in(&text, i.selection_range)
|| i.selection_range.start < i.range.start
|| i.selection_range.end > i.range.end
|| !bounds.contains(i.range)
{
continue;
}
i.parent = i.parent.and_then(|p| remap.get(&(p as usize)).copied());
remap.insert(index, out.len() as u32);
out.push(i);
}
}
}
out
}
pub fn language_implementation(&self, doc: &ProviderDocument, at: Pos) -> Vec<ide_plugin::Location> {
let mut out = Vec::new();
for (d, _bounds) in self.feature_documents(doc, Some(at)) {
let snapshot = d.clone();
let d = Arc::new(d);
let results = self.document_calls(ProviderKind::Implementation, &snapshot, move |rt, t| {
rt.language_implementation(&d, at, t)
});
for (p, items) in results {
for i in items {
let _ = &p;
out.push(i);
}
}
}
out
}
pub fn language_type_definition(&self, doc: &ProviderDocument, at: Pos) -> Vec<ide_plugin::Location> {
let mut out = Vec::new();
for (d, _bounds) in self.feature_documents(doc, Some(at)) {
let snapshot = d.clone();
let d = Arc::new(d);
let results = self.document_calls(ProviderKind::TypeDefinition, &snapshot, move |rt, t| {
rt.language_type_definition(&d, at, t)
});
for (p, items) in results {
for i in items {
let _ = &p;
out.push(i);
}
}
}
out
}
pub fn language_gutter_actions(&self, doc: &ProviderDocument) -> Vec<(String, GutterAction)> {
let mut out = Vec::new();
for (d, bounds) in self.feature_documents(doc, None) {
let lines = d.text.split('\n').count() as u32;
let snapshot = d.clone();
let d = Arc::new(d);
let results = self
.document_calls(ProviderKind::GutterActions, &snapshot, move |rt, t| rt.language_gutter_actions(&d, t));
for (p, items) in results {
let commands: std::collections::HashSet<_> =
self.ui.items(super::COMMANDS, Some(p.id())).into_iter().map(|c| c.key).collect();
for i in items {
if i.line < lines && bounds.lines(i.line, i.line) && commands.contains(&i.command) {
out.push((p.id().to_owned(), i));
}
}
}
}
out
}
}
impl PluginHost {
pub(crate) fn run_language_command(
&self,
plugin: &str,
command: &str,
argument: Option<&str>,
invocation: ide_plugin::Invocation,
) -> IdeResult<()> {
if !self.ui.items(super::COMMANDS, Some(plugin)).iter().any(|c| c.key == command) {
return Err(ide_core::IdeError::not_found("The plugin did not declare this command"));
}
self.run_command(plugin, command, argument, invocation).map(|_| ())
}
}
pub(crate) fn signature_help(h: SignatureHelp) -> ide_lsp::types::SignatureHelp {
let signatures: Vec<_> = h
.signatures
.into_iter()
.take(100)
.map(|s| {
let len = s.label.encode_utf16().count() as u32;
let parameters = s
.parameters
.into_iter()
.take(100)
.map(|p| match p.label {
ParameterLabel::Offsets((a, b)) => (a.min(len), b.max(a).min(len)),
ParameterLabel::Text(t) => s
.label
.find(&t)
.map(|a| {
let start = s.label[..a].encode_utf16().count() as u32;
(start, start + t.encode_utf16().count() as u32)
})
.unwrap_or((0, 0)),
})
.collect();
ide_lsp::types::Signature {
label: s.label,
documentation: s.documentation.map(|value| ide_lsp::types::Markup { kind: "markdown".into(), value }),
parameters,
}
})
.collect();
let active_signature = h.active_signature.min(signatures.len().saturating_sub(1) as u32);
let active_parameter =
h.active_parameter
.min(signatures.get(active_signature as usize).map(|s| s.parameters.len().saturating_sub(1)).unwrap_or(0)
as u32);
ide_lsp::types::SignatureHelp { signatures, active_signature, active_parameter }
}
pub(crate) fn outline(items: Vec<DocumentSymbol>) -> Vec<ide_core::OutlineItem> {
// A parent must precede its child, ruling out cycles and unbounded recursion.
let mut depths = vec![0usize; items.len()];
let mut parents = vec![None; items.len()];
for (idx, item) in items.iter().enumerate() {
if let Some(parent) = item.parent.map(|p| p as usize).filter(|p| {
*p < idx
&& depths[*p] < 63
&& items[*p].range.start <= item.range.start
&& item.range.end <= items[*p].range.end
}) {
parents[idx] = Some(parent);
depths[idx] = depths[parent] + 1;
}
}
let mut nodes: Vec<_> = items
.into_iter()
.map(|i| {
Some(ide_core::OutlineItem {
name: i.name,
detail: Some(i.detail).filter(|s| !s.is_empty()),
kind: serde_json::from_value(serde_json::Value::String(symbol_kind_name(&i.kind)))
.unwrap_or(ide_core::SymbolKind::Unknown),
range: i.range,
selection_range: i.selection_range,
children: vec![],
visibility: None,
})
})
.collect();
let mut roots = Vec::new();
for idx in (0..nodes.len()).rev() {
let mut node = nodes[idx].take().expect("once");
node.children.reverse();
if let Some(parent) = parents[idx] {
nodes[parent].as_mut().expect("parent precedes child").children.push(node);
} else {
roots.push(node);
}
}
roots.reverse();
roots
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct FormatterChoice {
pub id: String,
pub name: String,
pub languages: Vec<String>,
}
impl PluginHost {
pub fn formatter_choices(&self) -> Vec<FormatterChoice> {
let mut out = std::collections::BTreeMap::<String, FormatterChoice>::new();
for (id, languages) in self.providers.formatter_declarations() {
let Ok(p) = self.get(&id) else {
continue;
};
if !matches!(p.meta.lock().status, super::PluginStatus::Active | super::PluginStatus::Inactive) {
continue;
}
out.entry(id.clone())
.or_insert_with(|| FormatterChoice { id, name: p.pkg.manifest.name.clone(), languages: vec![] })
.languages
.extend(languages);
}
out.into_values()
.map(|mut c| {
c.languages.sort();
c.languages.dedup();
c
})
.collect()
}
}
fn symbol_kind_name(kind: &str) -> String {
let mut parts = kind.split('-');
let mut out = parts.next().unwrap_or_default().to_owned();
for part in parts {
let mut chars = part.chars();
if let Some(c) = chars.next() {
out.extend(c.to_uppercase());
out.extend(chars);
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn outline_uses_parent_order_containment_and_string_kinds() {
let range = Range::new(Pos::new(0, 0), Pos::new(0, 10));
let item = |name: &str, parent| DocumentSymbol {
name: name.into(),
detail: String::new(),
kind: "enum-member".into(),
range,
selection_range: range,
parent,
};
let out =
outline(vec![item("root", None), item("child", Some(0)), item("future", Some(3)), item("other", Some(2))]);
assert_eq!(out[0].children[0].kind, ide_core::SymbolKind::EnumMember);
assert_eq!(out[1].name, "future");
assert_eq!(out[1].children[0].name, "other");
}
}
+176
Переглянути файл
@@ -0,0 +1,176 @@
//! Validated MCP declarations and dispatch through the ordinary plugin actor.
use super::{PluginHost, PluginStatus};
use ide_core::{IdeError, IdeResult};
use ide_extension::{Package, UiDecl, decode};
use ide_plugin::Invocation;
use serde::Deserialize;
pub(super) const POINT: &str = "mcpTools";
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct Declaration {
name: String,
description: String,
input_schema: std::path::PathBuf,
read_only: bool,
destructive: bool,
}
fn tool_name(plugin: &str, name: &str) -> String {
let plugin: String = plugin
.chars()
.map(|c| if c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' { c } else { '_' })
.collect();
format!("{plugin}__{name}")
}
pub(super) fn declaration(pkg: &Package, value: toml::Value) -> Result<UiDecl, String> {
pkg.manifest.needs_api("contributes.mcpTools", 4)?;
if pkg.manifest.wasm.is_none() {
return Err("mcpTools requires wasm".into());
}
let d: Declaration = decode(POINT, value)?;
if d.name.is_empty()
|| d.name.len() > 40
|| !d.name.bytes().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'_')
|| tool_name(pkg.id(), &d.name).len() > 64
|| d.description.trim().is_empty()
|| d.description.len() > 4096
{
return Err(
"mcpTools: name must match [a-z0-9_]{1,40}, namespaced name at most 64 bytes, description 1–4096 bytes"
.into(),
);
}
let text = pkg.read_text(&d.input_schema)?;
if text.len() > 256 << 10 {
return Err("mcpTools: schema exceeds 256 KiB".into());
}
let schema: serde_json::Value = serde_json::from_str(&text).map_err(|e| format!("mcpTools: {e}"))?;
if schema.get("type").and_then(serde_json::Value::as_str) != Some("object") {
return Err("mcpTools: inputSchema must describe an object".into());
}
// No HTTP or filesystem resolver features: schemas must be self-contained.
jsonschema::validator_for(&schema).map_err(|e| format!("mcpTools: invalid JSON Schema: {e}"))?;
let mut out = UiDecl::new(
d.name.clone(),
d.description.clone(),
serde_json::json!({
"name": d.name, "description": d.description, "inputSchema": schema, "readOnly": d.read_only, "destructive": d.destructive,
}),
);
out.files.push(d.input_schema);
Ok(out)
}
impl PluginHost {
/// Live declarations only, refreshed on every list/call. Ambiguous normalized names fail closed.
pub fn mcp_tools(&self, root: &std::path::Path) -> Vec<(String, String, ide_mcp::Tool)> {
let mut tools = vec![];
for item in self.ui.items(POINT, None) {
let Ok(plugin) = self.get(&item.plugin_id) else { continue };
if !matches!(plugin.status(), PluginStatus::Active | PluginStatus::Inactive)
|| plugin.dev_root.as_deref().is_some_and(|r| r != root)
{
continue;
}
let d = &item.decl;
let Some(name) = d["name"].as_str() else { continue };
tools.push((
plugin.id().to_owned(),
name.to_owned(),
ide_mcp::Tool {
name: tool_name(plugin.id(), name),
description: format!(
"{} (plugin: {} [{}])",
d["description"].as_str().unwrap_or_default(),
plugin.manifest().name,
plugin.id()
),
input_schema: d["inputSchema"].clone(),
output_schema: None,
read_only: d["readOnly"].as_bool().unwrap_or(false),
destructive: d["destructive"].as_bool().unwrap_or(true),
},
));
}
let mut counts = std::collections::HashMap::new();
for (_, _, tool) in &tools {
*counts.entry(tool.name.clone()).or_insert(0) += 1;
}
tools.retain(|(_, _, tool)| counts[&tool.name] == 1);
tools
}
pub fn call_mcp_tool(
&self,
plugin: &str,
tool: &str,
arguments: &str,
invocation: Invocation,
cancel: &ide_core::CancellationToken,
) -> IdeResult<String> {
if arguments.len() > 1 << 20 {
return Err(IdeError::invalid("MCP arguments exceed 1 MiB"));
}
let root =
invocation.workspace_root.as_ref().ok_or_else(|| IdeError::invalid("MCP requires a project window"))?;
let (_, _, declared) = self
.mcp_tools(std::path::Path::new(root))
.into_iter()
.find(|(p, n, _)| p == plugin && n == tool)
.ok_or_else(|| IdeError::not_found("plugin MCP tool is no longer available"))?;
let args: serde_json::Value = serde_json::from_str(arguments).map_err(|e| IdeError::invalid(e.to_string()))?;
let validator =
jsonschema::validator_for(&declared.input_schema).map_err(|e| IdeError::invalid(e.to_string()))?;
if !validator.is_valid(&args) {
return Err(IdeError::invalid("MCP arguments do not match inputSchema"));
}
let (tool, arguments) = (tool.to_owned(), arguments.to_owned());
self.code_call_cancellable(plugin, invocation, "calling the MCP tool", Some(cancel), move |rt, inv| {
rt.mcp_call(&tool, &arguments, inv)
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validates_names_schema_files_versions_and_schema_keywords_at_install() {
let tmp = tempfile::tempdir().unwrap();
let pkg = |api: &str| {
Package::new(
ide_extension::Manifest::parse(&format!(
"id='test.tools'\nname='Tools'\nversion='1.0.0'\nengines.openidea='{api}'\nwasm='plugin.wasm'"
))
.unwrap(),
tmp.path().into(),
)
};
let decl = |name: &str, path: &str| {
toml::from_str::<toml::Value>(&format!(
"name='{name}'\ndescription='Tool'\ninputSchema='{path}'\nreadOnly=true\ndestructive=false"
))
.unwrap()
};
std::fs::write(
tmp.path().join("args.json"),
r##"{"type":"object","$defs":{"text":{"type":"string"}},"properties":{"text":{"$ref":"#/$defs/text"}}}"##,
)
.unwrap();
assert_eq!(declaration(&pkg("^1.4"), decl("echo", "args.json")).unwrap().files.len(), 1);
assert!(declaration(&pkg("^1.3"), decl("echo", "args.json")).is_err());
for name in ["", "Bad", "bad-name", &"x".repeat(41)] {
assert!(declaration(&pkg("^1.4"), decl(name, "args.json")).is_err());
}
assert!(declaration(&pkg("^1.4"), decl("echo", "../args.json")).is_err());
for schema in [
r#"{"type":"nonsense"}"#,
r#"{"type":"object","required":42}"#,
r#"{"type":"object","properties":{"x":{"$ref":"file:///private/secret"}}}"#,
r#"{"type":"object","properties":{"x":{"$ref":"https://example.invalid/schema"}}}"#,
] {
std::fs::write(tmp.path().join("args.json"), schema).unwrap();
assert!(declaration(&pkg("^1.4"), decl("echo", "args.json")).is_err(), "{schema}");
}
assert_eq!(tool_name("a.b-c", "echo"), "a_b_c__echo");
}
}
+264 -16
Переглянути файл
@@ -16,10 +16,13 @@
//! projects) and folders linked with `openidea-plugin link` (`links.json`).
mod api;
mod auth;
pub mod commit;
mod database;
pub(crate) mod diagnostics;
pub mod endpoints;
mod event_paths;
mod execution;
mod files_changed;
mod hover;
mod icon_extensions;
@@ -27,17 +30,23 @@ mod icon_patterns;
mod icon_settings;
mod icons;
pub mod index_ops;
pub(crate) mod injections;
mod inlay_hints;
mod install;
pub mod intellij;
mod ipc;
pub mod language_features;
mod market;
mod mcp;
pub mod navigation;
mod points;
mod providers;
pub mod remote;
pub mod remote_host;
mod run_detectors;
pub mod run_targets;
mod semantic_tokens;
mod snippets;
mod theme;
mod types;
pub mod ui_points;
@@ -134,6 +143,8 @@ struct Meta {
retry_at: Option<Instant>,
/// The component exports `syntax-provider` (known once activated).
has_syntax: bool,
/// Known before activation can publish target registrations.
has_run_target_validation: bool,
/// Next `on-schedule` activation.
next_schedule: Option<Instant>,
/// The trusted publisher of a signed installed package.
@@ -155,6 +166,7 @@ impl Meta {
failures: 0,
retry_at: None,
has_syntax: false,
has_run_target_validation: false,
publisher: None,
next_schedule: None,
}
@@ -368,7 +380,7 @@ pub struct PluginHost {
diagnostic_projects: Mutex<Vec<diagnostics::ProjectState>>,
/// Validated UI declarations of installed packages (start-up reuses them).
index: Arc<ContributionIndex>,
ledger: Mutex<Ledger>,
ledger: Arc<Mutex<Ledger>>,
plugins: RwLock<BTreeMap<String, Arc<LoadedPlugin>>>,
state: Mutex<PersistedState>,
/// What every plugin's [`api::PluginApi`] shares: sink, registry,
@@ -410,6 +422,11 @@ struct PendingUpdate {
}
impl PluginHost {
/// Current decoration snapshots for windows opening after a plugin published them.
pub fn decoration_sets(&self) -> std::collections::BTreeMap<String, Vec<DecorationSet>> {
self.shared.decoration_sets()
}
/// Creates the host; call [`load_all`](Self::load_all) to load installed plugins.
pub fn new(plugins_dir: PathBuf, sink: Arc<dyn PluginEventSink>, limits: Limits) -> IdeResult<Self> {
std::fs::create_dir_all(&plugins_dir)?;
@@ -435,7 +452,9 @@ impl PluginHost {
languages.set_process_policy(Arc::new(move |plugin, cap| {
policy_pins.read().get(&(plugin.to_owned(), cap.to_string())).cloned()
}));
let shared = api::Shared::new(sink.clone(), plugins_dir.join(".data"), pins.clone(), ui.clone());
let ledger = Arc::new(Mutex::new(Ledger::load(&plugins_dir)));
let shared =
api::Shared::new(sink.clone(), plugins_dir.join(".data"), pins.clone(), ui.clone(), ledger.clone());
let official_keys = market::built_in_official_keys();
let market = RwLock::new(Self::load_market_index(&plugins_dir, &official_keys));
let host = Self {
@@ -446,7 +465,7 @@ impl PluginHost {
updates: Mutex::default(),
project_policy: RwLock::new(Arc::new(|_| false)),
engine: PluginEngine::new().map_err(IdeError::internal)?,
ledger: Mutex::new(Ledger::load(&plugins_dir)),
ledger,
plugins_dir,
sink,
limits,
@@ -653,6 +672,7 @@ impl PluginHost {
/// Unloads the project plugins of `root` (the project became untrusted or closed).
pub fn restrict_project(&self, root: &Path) {
self.shared.withdraw_run_targets(None, Some(root));
let _guard = self.lifecycle.lock();
let ids: Vec<_> = self
.plugins
@@ -788,6 +808,11 @@ impl PluginHost {
Ok(self.ui.assets_with_prefix(id, prefix).into_iter().map(|(k, v)| (k, v.to_string())).collect())
}
/// Keeps hosts allowed through `net:ask` in memory for this host's lifetime.
pub fn set_net_hosts_session_only(&self) {
self.shared.net_hosts_session_only.store(true, std::sync::atomic::Ordering::Relaxed);
}
/// The UI language windows run in, as `locale()` reports it to plugin code.
pub fn set_ui_locale(&self, tag: &str) {
*self.shared.locale.write() = tag.to_owned();
@@ -1061,26 +1086,48 @@ impl PluginHost {
self.info_of(id)
}
/// Grants or revokes one capability (Settings › Plugins › Permissions).
/// Revokes one exact host remembered through `net:ask`.
pub fn revoke_net_host(&self, id: &str, host: &str) -> IdeResult<PluginInfo> {
let _guard = self.lifecycle.lock();
let plugin = self.get(id)?;
self.check_not_uninstalling(&plugin)?;
{
let mut ledger = self.ledger.lock();
if !ledger.forget_net_host(&plugin.subject(), host) {
return Err(IdeError::invalid("host is not remembered for this plugin"));
}
self.save_ledger(&ledger);
}
self.stop(&plugin);
if !self.state.lock().disabled.contains(id) {
self.start(&plugin);
}
self.changed();
self.info_of(id)
}
/// Grants or revokes one declared capability (Settings › Plugins › Permissions).
/// Revoking a required one stops the plugin until consent is given again.
pub fn set_capability(&self, id: &str, capability: &str, granted: bool) -> IdeResult<PluginInfo> {
let _guard = self.lifecycle.lock();
let plugin = self.get(id)?;
self.check_not_uninstalling(&plugin)?;
let cap: Capability = capability.parse().map_err(IdeError::invalid)?;
if !plugin.manifest().declares(&cap) && !plugin.meta.lock().declared_extra.contains(&cap) {
return Err(IdeError::invalid(format!("plugin {id:?} does not declare {cap}")));
}
{
let mut ledger = self.ledger.lock();
let subject = plugin.subject();
if !plugin.manifest().declares(&cap) && !plugin.meta.lock().declared_extra.contains(&cap) {
return Err(IdeError::invalid(format!("plugin {id:?} does not declare {cap}")));
}
if ledger.get(&subject).is_none() {
return Err(IdeError::invalid(format!("plugin {id:?} has not been confirmed yet")));
}
ledger.set(&subject, &cap, granted);
if granted
&& let Capability::Process(program) = &cap
&& let Some(bin) = self.resolve_binary(program)
&& let Some(bin) = ledger
.program_choice(&subject, &cap)
.map_or_else(|| self.resolve_binary(program), |path| ledger::approve_program(path).ok())
{
ledger.set_binary(&subject, &cap, bin);
}
@@ -1097,14 +1144,74 @@ impl PluginHost {
/// The program a `process:` grant allows, checked against the file
/// approved for it. Errors ask the user to confirm again.
pub fn program_for(&self, id: &str, program: &str) -> IdeResult<PathBuf> {
self.program_for_project(id, program, None)
}
/// Resolves the approved executable for the invoking project, verifying its pinned content.
pub fn program_for_project(&self, id: &str, program: &str, root: Option<&Path>) -> IdeResult<PathBuf> {
let plugin = self.get(id)?;
let cap = Capability::Process(program.to_owned());
self.ledger
.lock()
.check_binary(&plugin.subject(), &cap)
.check_binary_in_project(&plugin.subject(), &Capability::Process(program.into()), root)
.map_err(|e| IdeError::new(ide_core::error::ErrorKind::PermissionDenied, e))
}
/// Records a user-approved executable choice; removing it revalidates the fallback or revokes the grant.
pub fn choose_program(
&self,
id: &str,
capability: &str,
root: Option<&Path>,
path: Option<&Path>,
) -> IdeResult<PluginInfo> {
let _guard = self.lifecycle.lock();
let plugin = self.get(id)?;
self.check_not_uninstalling(&plugin)?;
let cap: Capability = capability.parse().map_err(IdeError::invalid)?;
let Capability::Process(program) = &cap else {
return Err(IdeError::invalid("expected a process capability"));
};
if !plugin.manifest().declares(&cap) && !plugin.meta.lock().declared_extra.contains(&cap) {
return Err(IdeError::invalid("undeclared process capability"));
}
let binary = if let Some(path) = path {
Some(ledger::approve_program(path).map_err(IdeError::invalid)?)
} else if root.is_none() {
self.resolve_binary(program)
} else {
None
};
{
let mut ledger = self.ledger.lock();
let subject = plugin.subject();
if ledger.get(&subject).is_none() {
return Err(IdeError::invalid("confirm this plugin's permissions first"));
}
if let Some(root) = root {
ledger.set_project_binary(&subject, &cap, root, binary);
if path.is_some() {
ledger.set(&subject, &cap, true);
} else if ledger.check_binary(&subject, &cap).is_err() {
ledger.set(&subject, &cap, false);
}
} else if let Some(binary) = binary {
ledger.set_program_choice(&subject, &cap, path);
ledger.set(&subject, &cap, true);
ledger.set_binary(&subject, &cap, binary);
} else {
ledger.set_program_choice(&subject, &cap, None);
ledger.set(&subject, &cap, false);
}
self.save_ledger(&ledger);
}
self.stop(&plugin);
if !self.state.lock().disabled.contains(id) {
self.start(&plugin);
}
self.changed();
self.info_of(id)
}
fn check_uninstall_pending(&self, id: &str) -> IdeResult<()> {
if install::uninstall_pending(&self.plugins_dir, id)? {
return Err(IdeError::unavailable(std::io::Error::from(std::io::ErrorKind::ResourceBusy).to_string()));
@@ -1151,6 +1258,7 @@ impl PluginHost {
}
self.stop(&plugin);
let cleanup = || -> std::io::Result<()> {
self.remove_database_sources(id).map_err(|e| std::io::Error::other(e.message))?;
install::check_cleanup_parent(&self.plugins_dir, &self.shared.data_dir.join(id))?;
install::check_cleanup_parent(&self.plugins_dir, plugin.dir())?;
install::check_not_symlink(&self.shared.data_dir.join(id))?;
@@ -1343,6 +1451,31 @@ impl PluginHost {
what: &str,
f: impl Fn(&mut Runtime, Invocation) -> Result<R, CallError> + Clone + Send + 'static,
) -> IdeResult<R> {
self.code_call_cancellable(id, invocation, what, None, f)
}
fn code_call_cancellable<R: Send + 'static>(
&self,
id: &str,
invocation: Invocation,
what: &str,
cancel: Option<&ide_core::CancellationToken>,
f: impl Fn(&mut Runtime, Invocation) -> Result<R, CallError> + Clone + Send + 'static,
) -> IdeResult<R> {
self.code_call_with_budget(id, invocation, what, cancel, None, f)
}
fn code_call_with_budget<R: Send + 'static>(
&self,
id: &str,
invocation: Invocation,
what: &str,
cancel: Option<&ide_core::CancellationToken>,
budget: Option<Duration>,
f: impl Fn(&mut Runtime, Invocation) -> Result<R, CallError> + Clone + Send + 'static,
) -> IdeResult<R> {
let deadline = budget.map(|budget| Instant::now() + budget);
let fallback = ide_core::CancellationToken::new();
let plugin = self.get(id)?;
let client = invocation.client.clone();
{
@@ -1383,7 +1516,29 @@ impl PluginHost {
}
let started = Instant::now();
let (call, inv) = (f.clone(), invocation.clone());
let result = actor.call(None, move |rt| call(rt, inv));
let result = match (deadline, cancel) {
(Some(deadline), cancel) => actor.call_bounded(
cancel.unwrap_or(&fallback),
Duration::from_millis(50),
deadline.saturating_duration_since(Instant::now()),
move |rt| call(rt, inv),
),
(None, Some(cancel)) => {
actor.call_cancellable(cancel, Duration::from_millis(50), move |rt| call(rt, inv))
}
(None, None) => actor.call(None, move |rt| call(rt, inv)),
};
if deadline.is_some() && matches!(result, Err(ActorError::SoftTimeout)) {
self.reap(&plugin, &actor, client.as_deref());
return Err(IdeError::new(
ide_core::error::ErrorKind::TimedOut,
format!("plugin {id:?} timed out while {what}"),
));
}
if matches!(result, Err(ActorError::Cancelled)) {
self.reap(&plugin, &actor, client.as_deref());
return Err(IdeError::cancelled());
}
if !matches!(result, Err(ActorError::Stopped | ActorError::SoftTimeout | ActorError::Cancelled)) {
let crashed = matches!(result, Err(ActorError::Call(CallError::Trap(_))));
// Cancelling or a missing grant is not the plugin failing.
@@ -1457,7 +1612,12 @@ impl PluginHost {
/// Plugins' `runDetectors`, for [`ide_run::set_external_detection`].
pub fn run_detection(&self) -> Arc<dyn ide_run::ExternalDetection> {
Arc::new(run_detectors::PluginRunDetection { store: self.run_detectors.clone(), pins: self.pins.clone() })
Arc::new(run_detectors::PluginRunDetection {
store: self.run_detectors.clone(),
dynamic: self.shared.run_configurations.clone(),
pins: self.pins.clone(),
project_pins: self.shared.project_pins.clone(),
})
}
/// The syntax of plugin languages, for [`ide_language::set_external_syntax`].
@@ -1513,6 +1673,10 @@ impl PluginHost {
}
/// Plugin context keys in effect for window `client`: global keys overlaid with its own.
pub fn status_snapshot(&self, client: &str) -> Vec<PluginStatusItem> {
self.shared.status_snapshot(client)
}
pub fn context_keys(&self, client: Option<&str>) -> Vec<PluginContextKey> {
self.shared.context_keys(client)
}
@@ -1543,6 +1707,10 @@ impl PluginHost {
/// A window answered a plugin's `CommitRequested`; false when the call no
/// longer waits.
pub fn answer_command(&self, client: &str, request: &str, answer: CommandAnswer) -> bool {
request.parse().is_ok_and(|id| self.shared.answer_command(client, id, answer))
}
pub fn answer_commit(&self, request_id: &str, answer: CommitAnswer) -> bool {
request_id.parse().is_ok_and(|id| self.shared.answer_commit(id, answer))
}
@@ -1632,6 +1800,30 @@ impl PluginHost {
/// Queues workbench events from window `client` for the plugins that want
/// them; [`flush_events`](Self::flush_events) delivers them in batches.
pub fn post_window_events(&self, client: &str, events: Vec<WorkbenchEvent>, editor: Option<PluginEditor>) {
let mut editors = self.shared.event_editors.write();
if let Some(editor) = editor {
editors.insert(client.into(), editor.context());
} else {
editors.remove(client);
}
drop(editors);
self.post_events(
Some(client),
events
.into_iter()
.filter(|e| {
!matches!(
e,
WorkbenchEvent::RunExited(_)
| WorkbenchEvent::TerminalExited(_)
| WorkbenchEvent::ContentTabAction(_)
)
})
.collect(),
);
}
pub fn post_events(&self, client: Option<&str>, events: Vec<WorkbenchEvent>) {
self.post_events_from(client, events, None);
}
@@ -1661,7 +1853,13 @@ impl PluginHost {
.as_ref()
.is_none_or(|root| client.and_then(|c| self.shared.project_of(c)).as_ref() == Some(root))
})
.cloned()
.filter_map(|e| match e {
WorkbenchEvent::SettingsChanged(_) => {
let keys = self.shared.changed_settings(&p.pkg, client);
(!keys.is_empty()).then_some(WorkbenchEvent::SettingsChanged(keys))
}
_ => Some(e.clone()),
})
.collect();
let wanted = Self::filter_path_events(&self.shared, &p, client, &wanted, &subs);
if wanted.is_empty() {
@@ -1682,7 +1880,15 @@ impl PluginHost {
let excess = queue.events.len().saturating_sub(MAX_QUEUED_EVENTS);
let mut remaining = excess;
queue.events.retain(|event| {
if remaining > 0 && !matches!(event, WorkbenchEvent::FilesChanged(_)) {
if remaining > 0
&& !matches!(
event,
WorkbenchEvent::FilesChanged(_)
| WorkbenchEvent::RunExited(_)
| WorkbenchEvent::TerminalExited(_)
| WorkbenchEvent::ContentTabAction(_)
)
{
remaining -= 1;
false
} else {
@@ -1809,9 +2015,10 @@ impl PluginHost {
let invocation = Invocation {
workspace_root: client.as_deref().and_then(|c| self.shared.project_of(c)),
client: client.clone(),
editor: None,
editor: client.as_deref().and_then(|c| self.shared.event_editors.read().get(c).cloned()),
selected_paths: Vec::new(),
global_settings: false,
command_chain: Vec::new(),
};
let kinds: BTreeSet<EventKind> = queue.events.iter().map(WorkbenchEvent::kind).collect();
let filesystem = kinds.contains(&EventKind::FilesChanged);
@@ -1823,6 +2030,9 @@ impl PluginHost {
if batches.running.as_ref() == Some(&kinds)
&& !kinds.contains(&EventKind::Scheduled)
&& !kinds.contains(&EventKind::FilesChanged)
&& !kinds.iter().any(|k| {
matches!(k, EventKind::RunExited | EventKind::TerminalExited | EventKind::ContentTabAction)
})
{
actor.cancel_soft();
}
@@ -2354,6 +2564,7 @@ impl PluginHost {
plugin_id: plugin.id().to_owned(),
version: plugin.manifest().version.clone(),
granted,
subject: plugin.subject(),
host: api,
limits: self.limits,
};
@@ -2372,6 +2583,7 @@ impl PluginHost {
return Err(IdeError::internal(format!("plugin {:?} {e}", plugin.id())));
}
};
plugin.meta.lock().has_run_target_validation = rt.has_run_target_validation();
let crash = match rt.activate() {
Ok(_) => None,
Err(CallError::Trap(e)) => Some(format!("activation {e}")),
@@ -2599,8 +2811,21 @@ impl PluginHost {
/// pick up a change on their next lookup.
fn refresh_pins(&self, ledger: &Ledger) {
let mut pins = HashMap::new();
let mut project_pins = HashMap::new();
for plugin in self.plugins.read().values() {
let subject = plugin.subject();
if let Some(grant) = ledger.get(&subject) {
for (root, binaries) in &grant.project_binaries {
for (cap, bin) in binaries {
if grant.granted.contains(cap) {
project_pins.insert(
(plugin.id().to_owned(), cap.clone(), root.clone()),
ProgramPin { path: bin.path.clone(), hash: bin.hash.clone() },
);
}
}
}
}
let declared =
plugin.manifest().capabilities.all().cloned().chain(plugin.meta.lock().declared_extra.clone());
for cap in declared.filter(|c| matches!(c, Capability::Process(_))) {
@@ -2612,6 +2837,7 @@ impl PluginHost {
}
}
}
*self.shared.project_pins.write() = project_pins;
let mut current = self.pins.write();
if *current != pins {
*current = pins;
@@ -2665,12 +2891,26 @@ impl PluginHost {
fn record_consent(&self, request: &Request<'_>, consent: &Consent) {
let accepted: Vec<Capability> = request.optional.iter().filter(|c| consent.accepts(c)).cloned().collect();
let choices: BTreeMap<_, _> = {
let ledger = self.ledger.lock();
request
.required
.iter()
.chain(&accepted)
.filter_map(|cap| {
ledger.program_choice(&request.subject, cap).map(|p| (cap.to_string(), p.to_path_buf()))
})
.collect()
};
let binaries: BTreeMap<String, BinaryGrant> = request
.required
.iter()
.chain(&accepted)
.filter_map(|c| match c {
Capability::Process(program) => self.resolve_binary(program).map(|b| (c.to_string(), b)),
Capability::Process(program) => choices
.get(&c.to_string())
.map_or_else(|| self.resolve_binary(program), |path| ledger::approve_program(path).ok())
.map(|b| (c.to_string(), b)),
_ => None,
})
.collect();
@@ -2702,6 +2942,13 @@ impl PluginHost {
capabilities.push(CapabilityInfo::new(extra, true, granted(extra)));
}
}
// Hosts remembered through `net:ask` are grants of their own: listed
// separately so the user can revoke them one by one.
let net_hosts = grant
.as_ref()
.filter(|g| g.confirmed && g.has(&Capability::NetAsk) && m.declares(&Capability::NetAsk))
.map(|g| g.net_hosts.iter().cloned().collect())
.unwrap_or_default();
PluginInfo {
id: m.id.clone(),
name: m.name.clone(),
@@ -2718,6 +2965,7 @@ impl PluginHost {
error: meta.error.clone(),
has_code: m.wasm.is_some(),
capabilities,
net_hosts,
contributions: self.registry.contributions(&m.id),
activation: m.activation.iter().map(ToString::to_string).collect(),
warnings: meta.warnings.clone(),
+2 -1
Переглянути файл
@@ -11,7 +11,7 @@ use std::collections::{BTreeMap, HashMap};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Weak};
pub const MAX_DOCUMENT_BYTES: usize = 2 << 20;
pub const MAX_DOCUMENT_BYTES: usize = ide_lsp::MAX_INJECTED_DOCUMENT_BYTES;
const MAX_FILES: usize = 20_000;
const MAX_EDGES: usize = 4096;
@@ -197,6 +197,7 @@ impl PluginHost {
/// navigation_at. Call off the UI thread, in small cancellable batches.
pub fn index_navigation(&self, root: &Path, snapshot: Snapshot<'_>, invocation: Invocation) -> IdeResult<()> {
let Snapshot { path, version, text, symbols, scope } = snapshot;
let path = dunce::simplified(path);
if !(self.project_policy.read())(root) {
return Err(IdeError::unavailable("project is not trusted"));
}
+24 -2
Переглянути файл
@@ -20,15 +20,23 @@ pub struct CommandDecl {
pub id: String,
pub title: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub category: Option<String>,
/// Enablement, evaluated by the client against context keys (`editorLang == rust`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub when: Option<String>,
}
fn command(_pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
fn command(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
let c: CommandDecl = decode(COMMANDS, decl)?;
validate_item_id(&c.id, "command id")?;
if let Some(icon) = &c.icon {
pkg.manifest.needs_api("contributes.commands.icon", 4)?;
if icon.is_empty() || icon.len() > 256 {
return Err("command icon: expected 1–256 bytes".into());
}
}
if c.title.trim().is_empty() {
return Err(format!("command {:?} has an empty title", c.id));
}
@@ -40,7 +48,14 @@ fn command(_pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
}
let c = CommandDecl { category: c.category.filter(|x| !x.trim().is_empty()), ..c };
let json = serde_json::to_value(&c).map_err(|e| e.to_string())?;
Ok(UiDecl::new(c.id.clone(), c.title, json))
let mut out = UiDecl::new(c.id.clone(), c.title, json);
if let Some(icon) = c.icon.filter(|s| s.ends_with(".svg")) {
let path = std::path::PathBuf::from(icon);
let svg = super::icons::read_icon(pkg, &path, super::icons::MAX_SVG)?;
out.files.push(path);
out.assets.push((format!("commandIcon/{}", c.id), svg));
}
Ok(out)
}
/// Registers every first-party extension point.
@@ -60,6 +75,13 @@ pub fn register_builtin_points(
registry.register(Arc::new(super::providers::ProvidersPoint::new(providers.clone())))?;
registry.register(Arc::new(UiPoint::new(super::providers::SECRETS_POINT, ui.clone(), super::providers::secret)))?;
registry.register(Arc::new(UiPoint::new(super::endpoints::POINT, ui.clone(), super::endpoints::declaration)))?;
registry.register(Arc::new(UiPoint::new(
"runConfigurations",
ui.clone(),
super::execution::configurations_declaration,
)))?;
registry.register(Arc::new(UiPoint::new("snippets", ui.clone(), super::snippets::declaration)))?;
registry.register(Arc::new(UiPoint::new(super::mcp::POINT, ui.clone(), super::mcp::declaration)))?;
use super::ui_points as p;
registry.register(Arc::new(UiPoint::new(p::MENUS, ui.clone(), p::menu)))?;
registry.register(Arc::new(UiPoint::new(p::KEYMAPS, ui.clone(), p::keymap)))?;
+169 -20
Переглянути файл
@@ -42,6 +42,19 @@ pub enum ProviderKind {
Hover,
InlayHints,
Diagnostics,
Formatting,
SignatureHelp,
DocumentHighlights,
DocumentLinks,
CodeLenses,
SelectionRanges,
Folding,
Colors,
DocumentSymbols,
Implementation,
TypeDefinition,
GutterActions,
Injections,
}
#[derive(Clone, Debug, Deserialize)]
@@ -63,16 +76,29 @@ pub struct Providers {
impl Providers {
fn add(self: &Arc<Self>, plugin: String, decl: Decl) -> Disposable {
let injections = decl.kinds.contains(&ProviderKind::Injections);
let seq = self.seq.fetch_add(1, Ordering::Relaxed);
self.items.write().push((seq, plugin, decl.kinds, decl.languages));
if injections {
self.injections_changed();
}
let weak = Arc::downgrade(self);
Disposable::new(move || {
if let Some(this) = weak.upgrade() {
this.items.write().retain(|(s, ..)| *s != seq);
if injections {
this.injections_changed();
}
}
})
}
fn injections_changed(self: &Arc<Self>) {
if editor_plugin_host().is_some_and(|host| Arc::ptr_eq(&host.providers, self)) {
super::injections::providers_changed();
}
}
/// Plugins providing `kind` for `language`, in declaration order.
pub fn plugins_for(&self, kind: ProviderKind, language: &str) -> Vec<String> {
self.matching::<Vec<_>>(kind, language).into_iter().map(|(plugin, _)| plugin).collect()
@@ -94,9 +120,30 @@ impl Providers {
}
pub(super) fn diagnostic_generations(&self, language: &str) -> HashMap<String, u64> {
self.matching(ProviderKind::Diagnostics, language)
if self.any(ProviderKind::Injections) {
let mut out = HashMap::new();
for (seq, plugin, kinds, _) in self.items.read().iter() {
if kinds.contains(&ProviderKind::Diagnostics) {
out.entry(plugin.clone()).or_insert(*seq);
}
}
out
} else {
self.matching(ProviderKind::Diagnostics, language)
}
}
pub(super) fn injection_generations(&self, language: &str) -> HashMap<String, u64> {
self.matching(ProviderKind::Injections, language)
}
pub(super) fn formatter_declarations(&self) -> Vec<(String, Vec<String>)> {
self.items
.read()
.iter()
.filter(|(_, _, k, _)| k.contains(&ProviderKind::Formatting))
.map(|(_, p, _, l)| (p.clone(), l.clone()))
.collect()
}
/// Whether any plugin provides `kind` (for any language).
pub fn any(&self, kind: ProviderKind) -> bool {
self.items.read().iter().any(|(_, _, kinds, _)| kinds.contains(&kind))
@@ -142,6 +189,26 @@ impl ExtensionPoint for ProvidersPoint {
if d.kinds.contains(&ProviderKind::InlayHints) {
pkg.manifest.needs_api("contributes.providers: inlay-hints", 3)?;
}
if d.kinds.iter().any(|k| {
matches!(
k,
ProviderKind::Formatting
| ProviderKind::SignatureHelp
| ProviderKind::DocumentHighlights
| ProviderKind::DocumentLinks
| ProviderKind::CodeLenses
| ProviderKind::SelectionRanges
| ProviderKind::Folding
| ProviderKind::Colors
| ProviderKind::DocumentSymbols
| ProviderKind::Implementation
| ProviderKind::TypeDefinition
| ProviderKind::GutterActions
| ProviderKind::Injections
)
}) {
pkg.manifest.needs_api("contributes.providers: language features and injections", 4)?;
}
for l in &d.languages {
if l != "*"
&& (l.is_empty()
@@ -206,12 +273,13 @@ static EDITOR_PROVIDERS: RwLock<Option<Weak<PluginHost>>> = RwLock::new(None);
/// definition and inline completion (`None` removes them).
pub fn set_editor_providers(host: Option<&Arc<PluginHost>>) {
*EDITOR_PROVIDERS.write() = host.map(Arc::downgrade);
super::injections::providers_changed();
}
/// The plugin host whose providers take part, when any declares `kind`.
pub(crate) fn editor_providers(kind: ProviderKind) -> Option<Arc<PluginHost>> {
let host = editor_plugin_host()?;
host.providers.any(kind).then_some(host)
(host.providers.any(kind) || host.providers.any(ProviderKind::Injections)).then_some(host)
}
pub(crate) fn editor_plugin_host() -> Option<Arc<PluginHost>> {
@@ -236,15 +304,46 @@ impl PluginHost {
/// declares `kind` for `language`, in plugin order, leaving out those that
/// failed or were late. Each plugin runs on its own thread, so the calls
/// run at the same time: the wait is the slowest plugin's, not the sum.
fn edit_calls<R: Send + 'static>(
pub(super) fn edit_calls<R: Send + 'static>(
&self,
kind: ProviderKind,
language: &str,
f: impl FnOnce(&mut Runtime, Duration) -> Result<R, CallError> + Clone + Send + 'static,
) -> Vec<(Arc<LoadedPlugin>, R)> {
self.edit_calls_plugins(self.provider_plugins(kind, language), f)
}
pub(super) fn document_calls<R: Send + 'static>(
&self,
kind: ProviderKind,
doc: &ProviderDocument,
f: impl FnOnce(&mut Runtime, Duration) -> Result<R, CallError> + Clone + Send + 'static,
) -> Vec<(Arc<LoadedPlugin>, R)> {
self.document_calls_for(kind, doc, None, f)
}
pub(super) fn document_calls_for<R: Send + 'static>(
&self,
kind: ProviderKind,
doc: &ProviderDocument,
owner: Option<&str>,
f: impl FnOnce(&mut Runtime, Duration) -> Result<R, CallError> + Clone + Send + 'static,
) -> Vec<(Arc<LoadedPlugin>, R)> {
let plugins = self
.provider_plugins(kind, &doc.language)
.into_iter()
.filter(|p| {
p.dev_root.as_ref().is_none_or(|r| doc.path.starts_with(r)) && owner.is_none_or(|id| p.id() == id)
})
.collect();
self.edit_calls_plugins(plugins, f)
}
fn edit_calls_plugins<R: Send + 'static>(
&self,
plugins: Vec<Arc<LoadedPlugin>>,
f: impl FnOnce(&mut Runtime, Duration) -> Result<R, CallError> + Clone + Send + 'static,
) -> Vec<(Arc<LoadedPlugin>, R)> {
let (soft, hard) = (self.limits.edit_soft, self.limits.edit_hard);
let queued: Vec<_> = self
.provider_plugins(kind, language)
let queued: Vec<_> = plugins
.into_iter()
.filter_map(|plugin| {
let (actor, activated) = self.ensure_active(&plugin, None).ok()?;
@@ -303,13 +402,24 @@ impl PluginHost {
at: ide_core::Pos,
trigger: Option<&str>,
) -> Vec<(String, CompletionItem)> {
let (d, t) = (Arc::new(doc.clone()), trigger.map(str::to_owned));
self.edit_calls(ProviderKind::Completion, &doc.language, move |rt, timeout| {
rt.provide_completion(&d, at, t.as_deref(), timeout)
})
.into_iter()
.flat_map(|(p, items)| items.into_iter().map(move |i| (p.id().to_owned(), i)))
.collect()
self.feature_documents(doc, Some(at))
.into_iter()
.flat_map(|(doc, bounds)| {
let (d, t) = (Arc::new(doc.clone()), trigger.map(str::to_owned));
self.document_calls(ProviderKind::Completion, &doc, move |rt, timeout| {
rt.provide_completion(&d, at, t.as_deref(), timeout)
})
.into_iter()
.flat_map(|(p, items)| items.into_iter().map(move |i| (p.id().to_owned(), i)))
.filter_map(move |(p, mut i)| {
if i.replace.is_some_and(|r| !bounds.contains(r)) {
return None;
}
i.additional_edits.retain(|e| bounds.contains(e.range));
Some((p, i))
})
})
.collect()
}
pub fn provide_code_actions(
@@ -391,10 +501,20 @@ impl PluginHost {
project: Option<&std::path::Path>,
language: &str,
) -> Vec<Arc<LoadedPlugin>> {
self.provider_plugins(ProviderKind::Diagnostics, language)
.into_iter()
.filter(|p| p.dev_root.is_none() || p.dev_root.as_deref() == project)
.collect()
let plugins = if self.providers.any(ProviderKind::Injections) {
self.providers
.diagnostic_generations(language)
.keys()
.filter_map(|id| self.get(id).ok())
.filter(|p| {
let m = p.meta.lock();
matches!(m.status, PluginStatus::Active | PluginStatus::Inactive) && m.pending.is_empty()
})
.collect()
} else {
self.provider_plugins(ProviderKind::Diagnostics, language)
};
plugins.into_iter().filter(|p| p.dev_root.is_none() || p.dev_root.as_deref() == project).collect()
}
pub(super) fn provide_project_diagnostics(
@@ -402,11 +522,35 @@ impl PluginHost {
project: Option<&std::path::Path>,
doc: &ProviderDocument,
cancel: &CancellationToken,
) -> Vec<(String, Vec<ide_core::Diagnostic>)> {
let mut out = std::collections::BTreeMap::<String, Vec<ide_core::Diagnostic>>::new();
for (d, bounds) in self.feature_documents(doc, None) {
if cancel.is_cancelled() {
break;
}
for (id, items) in self.provide_document_diagnostics(project, &d, cancel) {
out.entry(id).or_default().extend(items.into_iter().filter(|i| bounds.contains(i.range)));
}
}
out.into_iter()
.map(|(id, mut items)| {
items.truncate(5000);
(id, items)
})
.collect()
}
fn provide_document_diagnostics(
&self,
project: Option<&std::path::Path>,
doc: &ProviderDocument,
cancel: &CancellationToken,
) -> Vec<(String, Vec<ide_core::Diagnostic>)> {
std::thread::scope(|scope| {
let calls: Vec<_> = self
.diagnostic_plugins(project, &doc.language)
.provider_plugins(ProviderKind::Diagnostics, &doc.language)
.into_iter()
.filter(|p| p.dev_root.is_none() || p.dev_root.as_deref() == project)
.map(|p| {
scope.spawn(move || {
if cancel.is_cancelled() {
@@ -432,10 +576,15 @@ impl PluginHost {
}
pub fn provide_hover(&self, doc: &ProviderDocument, at: ide_core::Pos) -> Vec<PluginHover> {
let d = Arc::new(doc.clone());
self.edit_calls(ProviderKind::Hover, &doc.language, move |rt, timeout| rt.provide_hover(&d, at, timeout))
self.feature_documents(doc, Some(at))
.into_iter()
.filter_map(|(_, hover)| hover)
.flat_map(|(doc, bounds)| {
let d = Arc::new(doc.clone());
self.document_calls(ProviderKind::Hover, &doc, move |rt, timeout| rt.provide_hover(&d, at, timeout))
.into_iter()
.filter_map(|(_, h)| h)
.filter(move |h| h.range.is_none_or(|r| bounds.contains(r)))
})
.collect()
}
+332 -18
Переглянути файл
@@ -22,9 +22,7 @@
//! keychain and are asked for with a `plugin_secret` request
//! ([`SecretRequest`]) when a request header needs one (rule 4).
use super::{
Consent, HostWorkspace, PluginEditor, PluginEvent, PluginHost, PluginSource, PluginStatusItem, SecretStore,
};
use super::{Consent, HostWorkspace, PluginEditor, PluginEvent, PluginHost, PluginSource, SecretStore};
use crate::rpc::Dispatcher;
use base64::Engine as _;
use ide_core::{IdeError, IdeResult};
@@ -45,6 +43,32 @@ pub const SYNC_PACKAGES: &str = "plugin_sync_packages";
pub const EVENT_NOTIFICATION: &str = "plugin";
/// Server → client request ([`SecretRequest`] → `Option<String>`).
pub const SECRET_REQUEST: &str = "plugin_secret";
/// Mutations of declared secrets, kept on the desktop.
pub const SECRET_WRITE: &str = "plugin_secret_write";
/// OAuth listeners bound on the desktop, never the project server.
pub const AUTH_REQUEST: &str = "plugin_auth";
#[derive(Clone, Serialize, Deserialize)]
pub struct SecretWrite {
pub plugin: String,
pub name: String,
pub value: Option<String>,
}
/// A remote plugin's operation on a desktop OAuth listener.
#[derive(Clone, Serialize, Deserialize)]
pub struct AuthRequest {
pub plugin: String,
pub operation: AuthOperation,
}
#[derive(Clone, Serialize, Deserialize)]
pub enum AuthOperation {
Begin { path: String },
Wait { id: u64, timeout_ms: u32 },
Close { id: u64 },
Clear,
}
/// Under the server's data directory.
pub const PLUGINS_DIR: &str = "remote-plugins";
const INCOMING_DIR: &str = "remote-plugins.incoming";
@@ -61,6 +85,13 @@ pub struct RemotePackage {
pub digest: String,
/// Capabilities the user granted on the desktop.
pub granted: Vec<String>,
/// Disabled packages remain installed so their persistent project data survives.
#[serde(default = "enabled_by_default")]
pub enabled: bool,
}
fn enabled_by_default() -> bool {
true
}
/// One file of a package (`path` relative to the package, `data` base64).
@@ -107,8 +138,18 @@ pub fn package_digest(dir: &Path, files: &[PathBuf]) -> IdeResult<String> {
pub fn forwarded(event: &PluginEvent) -> bool {
matches!(
event,
PluginEvent::Notification { .. }
PluginEvent::RunTargetsChanged
| PluginEvent::WindowRequest { .. }
| PluginEvent::ContentTabReply { .. }
| PluginEvent::DecorationsChanged { .. }
| PluginEvent::EditorOpen { .. }
| PluginEvent::EditorRanges { .. }
| PluginEvent::Notification { .. }
| PluginEvent::StatusItemChanged { .. }
| PluginEvent::WindowStatusItemChanged { .. }
| PluginEvent::ExecuteCommand { .. }
| PluginEvent::OutputChanged { .. }
| PluginEvent::RevealOutput { .. }
| PluginEvent::ContextChanged { .. }
| PluginEvent::TaskStarted { .. }
| PluginEvent::TaskProgress { .. }
@@ -144,12 +185,16 @@ impl PluginHost {
plugins
.iter()
.filter(|p| p.dev_root.is_none() && !p.invalid && p.manifest().wasm.is_some())
.filter(|p| matches!(p.status(), super::PluginStatus::Active | super::PluginStatus::Inactive))
.filter_map(|p| {
let files = self.package_files(p).ok()?;
let digest = package_digest(p.dir(), &files).ok()?;
let granted = p.meta.lock().granted.iter().map(ToString::to_string).collect();
Some(RemotePackage { id: p.id().to_owned(), digest, granted })
Some(RemotePackage {
id: p.id().to_owned(),
digest,
granted,
enabled: matches!(p.status(), super::PluginStatus::Active | super::PluginStatus::Inactive),
})
})
.collect()
}
@@ -218,8 +263,19 @@ impl SecretStore for ClientSecrets {
}
}
fn set(&self, _: &str, _: &str, _: Option<&str>) -> Result<(), String> {
Err("secrets are stored on the desktop".into())
fn set(&self, plugin: &str, name: &str, value: Option<&str>) -> Result<(), String> {
let peer = self.peer.get().ok_or("desktop disconnected")?.clone();
let request = SecretWrite { plugin: plugin.into(), name: name.into(), value: value.map(str::to_owned) };
let ask = async {
tokio::time::timeout(SECRET_TIMEOUT, peer.request::<_, Result<(), String>>(SECRET_WRITE, request, None))
.await
};
let answer = if tokio::runtime::Handle::try_current().is_ok() {
tokio::task::block_in_place(|| self.runtime.block_on(ask))
} else {
self.runtime.block_on(ask)
};
answer.map_err(|_| "desktop timed out".to_owned())?.map_err(|e| e.to_string())?
}
}
@@ -236,8 +292,11 @@ impl RemotePlugins {
}
};
let host = Arc::new(PluginHost::new(data_dir.join(PLUGINS_DIR), Arc::new(sink), Limits::default())?);
host.set_net_hosts_session_only();
projects.set_plugin_host(&host);
host.set_workspace(Some(Arc::new(HostWorkspace(Arc::downgrade(projects)))));
host.set_secret_store(Arc::new(ClientSecrets { peer, runtime: projects.runtime.clone() }));
host.set_secret_store(Arc::new(ClientSecrets { peer: peer.clone(), runtime: projects.runtime.clone() }));
*host.shared.loopbacks.write() = Arc::new(ClientLoopbacks { peer, runtime: projects.runtime.clone() });
let trust = Arc::downgrade(projects);
host.set_project_policy(Arc::new(move |root| {
trust.upgrade().is_some_and(|h| h.trust.trusted(root).unwrap_or(false))
@@ -285,6 +344,14 @@ impl RemotePlugins {
report.failed.push(format!("{}: invalid plugin id", pkg.id));
continue;
}
if !pkg.enabled {
if self.host.info_of(&pkg.id).is_ok()
&& let Err(e) = self.host.set_enabled(&pkg.id, false)
{
report.failed.push(format!("{}: {e}", pkg.id));
}
continue;
}
// The desktop asked the user, change of signer included.
let consent = Consent { optional: pkg.granted.clone(), signer_change: true };
if self.current_digest(&pkg.id).as_deref() == Some(pkg.digest.as_str()) {
@@ -320,6 +387,9 @@ impl RemotePlugins {
if have != want || info.status == super::PluginStatus::NeedsConsent {
self.host.grant(&pkg.id, consent)?;
}
if info.status == super::PluginStatus::Disabled {
self.host.set_enabled(&pkg.id, true)?;
}
Ok(())
}
@@ -380,6 +450,7 @@ impl RemotePlugins {
let paths: Option<Vec<PathBuf>> = c.arg("paths")?;
let settings_scope: Option<SettingsScope> = c.arg("settingsScope")?;
let invocation = Invocation {
command_chain: c.arg::<Option<Vec<String>>>("commandChain")?.unwrap_or_default(),
client: Some(c.window.clone()),
workspace_root: c.host.project(&c.window).ok().map(|p| p.root.clone()),
editor: editor.map(PluginEditor::context),
@@ -398,10 +469,69 @@ impl RemotePlugins {
}
});
let h = host.clone();
d.add("plugin_select_tests", move |c| {
let h = h.clone();
async move {
let host = alive(&h)?;
let config = c.arg::<String>("config")?;
let tests = c.arg("tests")?;
blocking(move || host.select_tests(&c.window, &config, tests)).await
}
});
let h = host.clone();
d.add("plugin_parse_test_results", move |c| {
let h = h.clone();
async move {
let host = alive(&h)?;
let config = c.arg::<String>("config")?;
let chunk = c.arg("chunk")?;
blocking(move || host.parse_test_results(&c.window, &config, chunk)).await
}
});
let h = host.clone();
d.add("plugin_terminal_session_close", move |c| {
let h = h.clone();
async move { alive(&h)?.close_terminal_session(&c.window, &c.arg::<String>("operation")?) }
});
let h = host.clone();
d.add("plugin_terminal_session_spawn", move |c| {
let h = h.clone();
async move {
let plugins = alive(&h)?;
let operation: String = c.arg("operation")?;
let cols = c.arg("cols")?;
let rows = c.arg("rows")?;
let output = c.channel("onOutput")?;
blocking(move || plugins.spawn_terminal_session(&c.host, &c.window, &operation, cols, rows, output))
.await
}
});
let h = host.clone();
d.add("plugin_prepare_debug_run", move |c| {
let h = h.clone();
async move {
let plugins = alive(&h)?;
let config = c.arg("config")?;
blocking(move || plugins.prepare_debug_run(&c.window, config)).await
}
});
let h = host.clone();
d.add("plugin_finish_operation", move |c| {
let h = h.clone();
async move {
Ok(alive(&h)?.finish_operation(&c.window, &c.arg::<String>("id")?, c.arg("status")?, c.arg("stopped")?))
}
});
let h = host.clone();
d.add("plugin_content_tab_action", move |c| {
let h = h.clone();
async move { alive(&h)?.content_tab_action(&c.window, &c.arg::<String>("pluginId")?, c.arg("action")?) }
});
let h = host.clone();
d.add("plugin_post_events", move |c| {
let h = h.clone();
async move {
alive(&h)?.post_events(Some(&c.window), c.arg("events")?);
alive(&h)?.post_window_events(&c.window, c.arg("events")?, c.arg("editor")?);
Ok(())
}
});
@@ -411,15 +541,9 @@ impl RemotePlugins {
async move { Ok(alive(&h)?.event_kinds().into_iter().collect::<Vec<_>>()) }
});
let h = host.clone();
d.add("plugin_status_items", move |_| {
d.add("plugin_status_items", move |c| {
let h = h.clone();
async move {
Ok(alive(&h)?
.status_items()
.into_iter()
.map(|(plugin_id, id, item)| PluginStatusItem { plugin_id, id, item })
.collect::<Vec<_>>())
}
async move { Ok(alive(&h)?.status_snapshot(&c.window)) }
});
let h = host.clone();
d.add("plugin_context_keys", move |c| {
@@ -437,6 +561,43 @@ impl RemotePlugins {
async move { Ok(alive(&h)?.answer_prompt(&c.arg::<String>("promptId")?, c.arg("answer")?)) }
});
let h = host.clone();
d.add("plugin_choose_program", move |c| {
let h = h.clone();
async move {
let host = alive(&h)?;
let id: String = c.arg("pluginId")?;
let cap: String = c.arg("capability")?;
let scope: SettingsScope = c.arg("scope")?;
let path: Option<PathBuf> = c.arg("path")?;
let root = match scope {
SettingsScope::Global => None,
SettingsScope::Project => Some(c.host.project(&c.window)?.root.clone()),
};
blocking(move || host.choose_program(&id, &cap, root.as_deref(), path.as_deref())).await
}
});
let h = host.clone();
d.add("plugin_program", move |c| {
let h = h.clone();
async move {
let host = alive(&h)?;
let id: String = c.arg("pluginId")?;
let program: String = c.arg("program")?;
let scope: SettingsScope = c.arg("scope")?;
let root = match scope {
SettingsScope::Global => None,
SettingsScope::Project => Some(c.host.project(&c.window)?.root.clone()),
};
blocking(move || host.program_for_project(&id, &program, root.as_deref()).map(ide_core::IpcPath::from))
.await
}
});
let h = host.clone();
d.add("plugin_answer_command", move |c| {
let h = h.clone();
async move { Ok(alive(&h)?.answer_command(&c.window, &c.arg::<String>("requestId")?, c.arg("answer")?)) }
});
let h = host.clone();
d.add("plugin_answer_commit", move |c| {
let h = h.clone();
async move { Ok(alive(&h)?.answer_commit(&c.arg::<String>("requestId")?, c.arg("answer")?)) }
@@ -456,6 +617,27 @@ impl RemotePlugins {
.await
}
});
d.add("mcp_set_open_editors", |c| async move {
let paths: Vec<ide_core::IpcPath> = c.arg("paths")?;
c.host.project(&c.window)?;
c.host.mcp_reports.set_open_editors(&c.window, paths.into_iter().map(PathBuf::from).collect());
Ok(())
});
d.add("mcp_set_focused_editor", |c| async move {
c.host.project(&c.window)?;
c.host.mcp_reports.set_focused_editor(&c.window, c.arg("editor")?, c.arg("focused")?);
Ok(())
});
d.add("mcp_report_problems", |c| async move {
c.host.project(&c.window)?;
c.host.mcp_reports.report_problems(&c.window, c.arg("reset")?, c.arg("files")?);
Ok(())
});
let h = host.clone();
d.add("plugin_decoration_sets", move |_c| {
let h = h.clone();
async move { Ok(alive(&h)?.decoration_sets()) }
});
let h = host.clone();
d.add("plugin_set_active_file", move |c| {
let h = h.clone();
@@ -577,6 +759,21 @@ impl RemotePlugins {
let h = h.clone();
async move { super::index_ops::plugin_endpoints(&c.host, alive(&h)?, &c.window, c.arg("pluginId")?).await }
});
let h = host.clone();
d.add("plugin_run_targets", move |c| {
let h = h.clone();
async move { super::run_targets::list(&c.host, alive(&h)?.as_ref(), &c.window) }
});
let h = host.clone();
d.add("plugin_answer_run_target", move |c| {
let h = h.clone();
async move {
let plugins = alive(&h)?;
let id: String = c.arg("id")?;
let use_target = c.arg("useTarget")?;
blocking(move || super::run_targets::answer(&c.host, &plugins, &c.window, &id, use_target)).await
}
});
d.add("plugin_line_markers", |c| async move {
crate::ops::lsp::plugin_line_markers(&c.host, &c.window, c.arg("doc")?).await
});
@@ -621,6 +818,123 @@ fn safe_relative(path: &str) -> IdeResult<PathBuf> {
Ok(p.to_path_buf())
}
struct ClientLoopbacks {
peer: Arc<OnceLock<Peer>>,
runtime: tokio::runtime::Handle,
}
impl ClientLoopbacks {
fn request(
&self,
plugin: &str,
operation: AuthOperation,
cancel: Option<ide_core::CancellationToken>,
) -> Result<serde_json::Value, ide_plugin::HostError> {
let peer = self.peer.get().ok_or(ide_plugin::HostError::Cancelled)?.clone();
let timeout = match &operation {
AuthOperation::Wait { timeout_ms, .. } => Duration::from_millis(u64::from(*timeout_ms)) + SECRET_TIMEOUT,
_ => SECRET_TIMEOUT,
};
let request = AuthRequest { plugin: plugin.into(), operation };
let ask = async {
tokio::time::timeout(
timeout,
peer.request::<_, Result<serde_json::Value, ide_plugin::HostError>>(AUTH_REQUEST, request, cancel),
)
.await
.map_err(|_| ide_plugin::HostError::Failed("desktop OAuth request timed out".into()))?
.map_err(|e| ide_plugin::HostError::Failed(e.to_string()))?
};
if tokio::runtime::Handle::try_current().is_ok() {
tokio::task::block_in_place(|| self.runtime.block_on(ask))
} else {
self.runtime.block_on(ask)
}
}
}
impl super::auth::Service for ClientLoopbacks {
fn begin(&self, plugin: &str, _window: &str, path: &str) -> Result<(u64, String), ide_plugin::HostError> {
serde_json::from_value(self.request(plugin, AuthOperation::Begin { path: path.into() }, None)?)
.map_err(|e| ide_plugin::HostError::Failed(e.to_string()))
}
fn wait(
&self,
plugin: &str,
id: u64,
timeout: Duration,
cancel: &ide_core::CancellationToken,
) -> Result<Option<String>, ide_plugin::HostError> {
let result = self.request(
plugin,
AuthOperation::Wait { id, timeout_ms: timeout.as_millis().min(u128::from(u32::MAX)) as u32 },
Some(cancel.clone()),
);
if cancel.is_cancelled() {
self.close(plugin, id);
return Err(ide_plugin::HostError::Cancelled);
}
serde_json::from_value(result?).map_err(|e| ide_plugin::HostError::Failed(e.to_string()))
}
fn close(&self, plugin: &str, id: u64) {
let _ = self.request(plugin, AuthOperation::Close { id }, None);
}
fn clear(&self, plugin: &str) {
let _ = self.request(plugin, AuthOperation::Clear, None);
}
fn forget_window(&self, _: &str) {}
}
impl PluginHost {
/// A remote instance may mutate only declared secrets with a current grant.
pub fn remote_secret_write(&self, request: SecretWrite) -> Result<(), String> {
let plugin = self.get(&request.plugin).map_err(|e| e.message)?;
if !plugin.meta.lock().granted.iter().any(|g| g.covers(&ide_extension::Capability::Secrets)) {
return Err("secrets capability not granted".into());
}
if !self.ui.items(super::providers::SECRETS_POINT, Some(plugin.id())).iter().any(|s| s.key == request.name) {
return Err("undeclared secret".into());
}
if request.value.as_ref().is_some_and(|v| v.len() > 64 << 10) {
return Err("secret value too long".into());
}
self.shared.secrets.read().set(plugin.id(), &request.name, request.value.as_deref())
}
/// Serves a connected remote instance using this connection's actual window.
pub fn remote_auth(&self, window: &str, request: AuthRequest) -> Result<serde_json::Value, ide_plugin::HostError> {
if matches!(&request.operation, AuthOperation::Begin { .. } | AuthOperation::Wait { .. }) {
self.get(&request.plugin).map_err(|e| ide_plugin::HostError::NotFound(e.message))?;
}
let owner = format!("remote:{window}:{}", request.plugin);
let service = self.shared.loopbacks.read().clone();
let result = match request.operation {
AuthOperation::Begin { path } => {
if self.shared.client_closed(window) {
return Err(ide_plugin::HostError::Cancelled);
}
serde_json::to_value(service.begin(&owner, window, &path)?)
}
AuthOperation::Wait { id, timeout_ms } => serde_json::to_value(service.wait(
&owner,
id,
Duration::from_millis(u64::from(timeout_ms)),
&ide_core::CancellationToken::new(),
)?),
AuthOperation::Close { id } => {
service.close(&owner, id);
Ok(serde_json::Value::Null)
}
AuthOperation::Clear => {
service.clear(&owner);
Ok(serde_json::Value::Null)
}
};
result.map_err(|e| ide_plugin::HostError::Failed(e.to_string()))
}
/// A remote transport closed; abandon its browser listeners.
pub fn forget_remote_loopbacks(&self, window: &str) {
self.shared.loopbacks.read().forget_window(window);
}
}
#[cfg(test)]
mod tests {
use super::*;
+76 -9
Переглянути файл
@@ -42,6 +42,9 @@ pub struct Detector {
pub args: Vec<String>,
#[serde(default)]
pub test: bool,
/// Optional output parser, including the API 1.4 plugin export.
#[serde(default)]
pub framework: Option<String>,
}
/// Detectors of enabled plugins.
@@ -85,6 +88,12 @@ impl ExtensionPoint for RunDetectorsPoint {
fn prepare(&self, pkg: &Package, decl: toml::Value) -> Result<Prepared, String> {
let d: Detector = decode(POINT_ID, decl)?;
if d.framework.is_some() {
pkg.manifest.needs_api("runDetectors.framework", 4)?;
}
if d.framework.as_ref().is_some_and(|v| v.is_empty() || v.len() > 64) {
return Err("invalid test framework".into());
}
validate_item_id(&d.id, "run detector id")?;
if d.name.trim().is_empty() || d.name.len() > 128 {
return Err(format!("run detector {:?}: `name` must be 1–128 bytes", d.id));
@@ -110,48 +119,106 @@ impl ExtensionPoint for RunDetectorsPoint {
}
type Pins = Arc<RwLock<HashMap<(String, String), ProgramPin>>>;
pub(super) type DynamicConfigurations =
Arc<RwLock<std::collections::BTreeMap<(String, PathBuf), Vec<ide_plugin::PluginRunConfiguration>>>>;
/// [`ExternalDetection`] over the detectors and the approved programs.
pub struct PluginRunDetection {
pub(crate) store: Arc<RunDetectors>,
pub(crate) pins: Pins,
pub(crate) dynamic: DynamicConfigurations,
pub(crate) project_pins: super::api::ProjectPins,
}
fn config_id(plugin: &str, detector: &str) -> String {
pub(super) fn config_id(plugin: &str, detector: &str) -> String {
format!("{EXTERNAL_PREFIX}{plugin}:{detector}")
}
impl PluginRunDetection {
fn pin(&self, plugin: &str, d: &Detector) -> Option<ProgramPin> {
fn pin(&self, plugin: &str, d: &Detector, root: Option<&Path>) -> Option<ProgramPin> {
let cap = Capability::Process(d.command.trim().to_owned());
self.pins.read().get(&(plugin.to_owned(), cap.to_string())).cloned()
root.and_then(|root| self.project_pins.read().get(&(plugin.into(), cap.to_string(), root.into())).cloned())
.or_else(|| self.pins.read().get(&(plugin.to_owned(), cap.to_string())).cloned())
}
}
impl ExternalDetection for PluginRunDetection {
fn detect(&self, root: &Path) -> Vec<RunConfig> {
let items = self.store.items.read().clone();
items
let mut configs: Vec<_> = items
.into_iter()
.filter(|(_, plugin, d)| self.pin(plugin, d).is_some() && d.files.iter().any(|f| root.join(f).exists()))
.filter(|(_, plugin, d)| {
self.pin(plugin, d, Some(root)).is_some() && d.files.iter().any(|f| root.join(f).exists())
})
.map(|(_, plugin, d)| RunConfig {
id: config_id(&plugin, &d.id),
name: d.name.clone(),
kind: "plugin".into(),
command: d.command.trim().to_owned(),
args: d.args.clone(),
cwd: None,
cwd: Some(root.to_path_buf().into()),
env: Default::default(),
detected: true,
is_test: d.test,
debug_adapter: None,
allow_parallel: false,
extras: Default::default(),
extras: ide_run::RunExtras {
params: d.framework.clone().map(|f| [("framework".into(), f)].into()).unwrap_or_default(),
..Default::default()
},
})
.collect()
.collect();
for ((plugin, project), entries) in self.dynamic.read().iter().filter(|((_, project), _)| project == root) {
for c in entries {
let cap = Capability::Process(c.program.clone());
if !self.project_pins.read().contains_key(&(plugin.clone(), cap.to_string(), project.clone()))
&& !self.pins.read().contains_key(&(plugin.clone(), cap.to_string()))
{
continue;
}
let mut config = RunConfig {
id: String::new(),
name: c.name.clone(),
kind: "plugin".into(),
command: c.program.clone(),
args: c.args.clone(),
cwd: Some(root.to_path_buf().into()),
env: Default::default(),
detected: true,
is_test: c.test,
debug_adapter: None,
allow_parallel: false,
extras: Default::default(),
};
config.id = config_id(plugin, &c.id);
config.cwd = Some(c.cwd.as_ref().map(|p| root.join(p)).unwrap_or_else(|| root.to_path_buf()).into());
if let Some(framework) = &c.framework {
config.extras.params.insert("framework".into(), framework.clone());
}
configs.push(config);
}
}
configs
}
fn launch(&self, config: &RunConfig) -> Result<PathBuf, String> {
let dynamic = self.dynamic.read();
if let Some(((plugin, root), c)) =
dynamic.iter().flat_map(|(k, entries)| entries.iter().map(move |c| (k, c))).find(|((plugin, root), c)| {
config_id(plugin, &c.id) == config.id && config.cwd.as_ref().is_some_and(|cwd| cwd.starts_with(root))
})
{
let cap = Capability::Process(c.program.clone()).to_string();
let pin = self
.project_pins
.read()
.get(&(plugin.clone(), cap.clone(), root.clone()))
.cloned()
.or_else(|| self.pins.read().get(&(plugin.clone(), cap)).cloned())
.ok_or_else(|| "run program is no longer granted".to_owned())?;
return pin.verify().map(Path::to_path_buf);
}
drop(dynamic);
let items = self.store.items.read();
let (plugin, d) = items
.iter()
@@ -159,7 +226,7 @@ impl ExternalDetection for PluginRunDetection {
.map(|(_, p, d)| (p.clone(), d.clone()))
.ok_or_else(|| "the plugin that detected this configuration is no longer enabled".to_owned())?;
drop(items);
let pin = self.pin(&plugin, &d).ok_or_else(|| {
let pin = self.pin(&plugin, &d, config.cwd.as_ref().map(|p| p.as_ref())).ok_or_else(|| {
format!("allow process:{} for plugin {plugin} in Settings › Plugins to run this configuration", d.command)
})?;
pin.verify().map(Path::to_path_buf)
+196
Переглянути файл
@@ -0,0 +1,196 @@
//! Project-owned registrations, retained across idle instance reclamation.
use super::api::{PluginApi, Shared};
use super::{PluginEvent, PluginHost};
use ide_core::{IdeError, IdeResult};
use ide_plugin::{HostError, PluginRunTarget};
use std::path::{Path, PathBuf};
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct RegisteredRunTarget {
/// Opaque selector id, namespaced by the host.
pub id: String,
pub plugin_id: String,
pub plugin_name: String,
pub target: PluginRunTarget,
pub prompt: bool,
}
pub(super) type Targets = std::collections::BTreeMap<(PathBuf, String), RegisteredRunTarget>;
fn selector(plugin: &str, id: &str) -> String {
format!("plugin-target:{}:{plugin}{id}", plugin.len())
}
impl Shared {
fn target_answer_file(&self, root: &Path, id: &str) -> PathBuf {
let key = format!("{}\0{id}", root.display());
self.data_dir.join(".run-target-answers").join(blake3::hash(key.as_bytes()).to_hex().as_str())
}
pub(super) fn withdraw_run_targets(&self, plugin: Option<&str>, root: Option<&Path>) {
let mut targets = self.run_targets.lock();
let before = targets.len();
targets.retain(|(r, _), t| !(plugin.is_none_or(|p| p == t.plugin_id) && root.is_none_or(|path| path == r)));
if before != targets.len() {
drop(targets);
self.sink.emit(None, PluginEvent::RunTargetsChanged);
}
}
}
impl PluginApi {
pub(super) fn register_target(&self, root: &Path, target: PluginRunTarget) -> Result<(), HostError> {
ide_remote::plugin_target::validate(&target).map_err(|e| HostError::Invalid(e.to_string()))?;
let workspace = self.shared.workspace().ok_or(HostError::Cancelled)?;
if !workspace.projects().iter().any(|r| r == root) {
return Err(HostError::NotFound("project is not open".into()));
}
if !workspace.trusted(root) {
return Err(HostError::Denied("project is not trusted".into()));
}
self.approved_program_in_project(&target.program, Some(root))?;
let blocked = self.shared.prompts_blocked.lock();
if blocked.contains(self.pkg.id()) {
return Err(HostError::Cancelled);
}
let id = selector(self.pkg.id(), &target.id);
let mut targets = self.shared.run_targets.lock();
if !targets.contains_key(&(root.into(), id.clone()))
&& targets.iter().filter(|((r, _), t)| r == root && t.plugin_id == self.pkg.id()).count() >= 32
{
return Err(HostError::Invalid("at most 32 targets per plugin and project".into()));
}
let prompt = !self.shared.target_answer_file(root, &id).exists();
targets.insert(
(root.into(), id.clone()),
RegisteredRunTarget {
id,
plugin_id: self.pkg.id().into(),
plugin_name: self.pkg.manifest.name.clone(),
target,
prompt,
},
);
drop(targets);
drop(blocked);
self.shared.sink.emit(None, PluginEvent::RunTargetsChanged);
Ok(())
}
pub(super) fn unregister_target(&self, root: &Path, id: &str) {
if self.shared.run_targets.lock().remove(&(root.into(), selector(self.pkg.id(), id))).is_some() {
self.shared.sink.emit(None, PluginEvent::RunTargetsChanged);
}
}
}
impl PluginHost {
/// Drops registrations when their project closes or loses trust.
pub fn withdraw_project_run_targets(&self, root: &Path) {
self.shared.withdraw_run_targets(None, Some(root));
}
pub fn run_targets(&self, root: &Path) -> Vec<RegisteredRunTarget> {
self.shared.run_targets.lock().iter().filter(|((r, _), _)| r == root).map(|(_, t)| t.clone()).collect()
}
/// Prepares a user launch, including the optional plugin validation gate.
pub fn prepare_run_target_launch(
&self,
root: &Path,
id: &str,
config: &ide_run::RunConfig,
operation: ide_plugin::RunTargetOperation,
cancel: &ide_core::CancellationToken,
) -> IdeResult<ide_run::RunConfig> {
if !self.shared.workspace().is_some_and(|w| w.trusted(root)) {
return Err(IdeError::new(ide_core::error::ErrorKind::PermissionDenied, "project is not trusted"));
}
let target = self
.shared
.run_targets
.lock()
.get(&(root.into(), id.into()))
.cloned()
.ok_or_else(|| IdeError::not_found("plugin run target is no longer available"))?;
let program = self.program_for_project(&target.plugin_id, &target.target.program, Some(root))?;
let wrapped = ide_remote::plugin_target::prepare(root, &target.target, &program, config)?;
if self.get(&target.plugin_id)?.meta.lock().has_run_target_validation {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let info = ide_plugin::RunTargetLaunch {
operation,
program: ide_run::wrap::program_name(&config.command),
cwd: wrapped.cwd.as_ref().unwrap().to_string_lossy().into_owned(),
};
let project_root = root.to_path_buf();
let invocation =
ide_plugin::Invocation { workspace_root: Some(project_root.clone()), ..Default::default() };
let (target_id, timeout) = (target.target.id.clone(), self.limits.timeout);
let result = self.code_call_with_budget(
&target.plugin_id,
invocation,
"validating the run target",
Some(cancel),
Some(timeout),
move |rt, _| {
rt.validate_run_target(
project_root.to_string_lossy().into_owned(),
target_id.clone(),
info.clone(),
timeout,
)
},
);
match result {
Ok(Ok(())) => {}
Ok(Err(message)) => return Err(IdeError::invalid(format!("{}: {message}", target.plugin_name))),
Err(error) if error.kind == ide_core::error::ErrorKind::Cancelled => return Err(error),
Err(error) => return Err(IdeError::unavailable(format!("{}: {error}", target.plugin_name))),
}
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
self.program_for_project(&target.plugin_id, &target.target.program, Some(root))?;
}
Ok(wrapped)
}
}
pub fn list(host: &crate::Host, plugins: &PluginHost, window: &str) -> IdeResult<Vec<RegisteredRunTarget>> {
Ok(plugins.run_targets(&host.project(window)?.root))
}
/// The first answer wins across the project's windows. Only Use changes settings.
pub fn answer(
host: &crate::Host,
plugins: &PluginHost,
window: &str,
id: &str,
use_target: bool,
) -> IdeResult<ide_core::settings::Settings> {
host.require_trust(window)?;
let project = host.project(window)?;
let mut targets = plugins.shared.run_targets.lock();
let target = targets
.get_mut(&(project.root.clone(), id.into()))
.ok_or_else(|| IdeError::not_found("plugin run target is no longer available"))?;
let file = plugins.shared.target_answer_file(&project.root, id);
if file.exists() {
target.prompt = false;
return Ok(project.settings.effective());
}
if use_target {
plugins.program_for_project(&target.plugin_id, &target.target.program, Some(&project.root))?;
let scope = ide_workspace::SettingsScope::Project;
for key in ["run.defaultTarget", "languages.php.runTarget", "languages.node.runTarget"] {
project.settings.set(scope, key, serde_json::json!(id))?;
for window in host.windows_for_root(&project.root) {
if let Ok(open) = host.project(&window) {
open.settings.reload();
open.sink.emit(ide_core::IdeEvent::SettingsChanged { key: key.into() });
}
}
}
}
std::fs::create_dir_all(file.parent().unwrap())?;
std::fs::write(file, if use_target { "use" } else { "not-now" })?;
target.prompt = false;
drop(targets);
plugins.shared.sink.emit(None, PluginEvent::RunTargetsChanged);
Ok(project.settings.effective())
}
+106
Переглянути файл
@@ -0,0 +1,106 @@
//! Named JSON snippets, retained as read-only UI declarations.
use ide_extension::{Package, UiDecl, decode};
use serde::Deserialize;
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Decl {
language: String,
path: std::path::PathBuf,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum Lines {
One(String),
Many(Vec<String>),
}
impl Lines {
fn values(self) -> Vec<String> {
match self {
Self::One(s) => vec![s],
Self::Many(v) => v,
}
}
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Snippet {
prefix: Lines,
body: Lines,
description: Option<String>,
}
pub(super) fn declaration(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
pkg.manifest.needs_api("contributes.snippets", 4)?;
let d: Decl = decode("snippets", decl)?;
if d.language.is_empty()
|| d.language.len() > 64
|| !d.language.chars().all(|c| c.is_ascii_alphanumeric() || "-_.+#".contains(c))
{
return Err("snippets: invalid language id".into());
}
let text = pkg.read_text(&d.path)?;
if text.len() > 1024 * 1024 {
return Err("snippets: file exceeds 1 MiB".into());
}
let snippets: std::collections::BTreeMap<String, Snippet> =
serde_json::from_str(&text).map_err(|e| format!("snippets: {e}"))?;
if snippets.len() > 1000 {
return Err("snippets: at most 1000 entries per file".into());
}
let mut entries = vec![];
for (name, s) in snippets {
let prefixes = s.prefix.values();
let body = s.body.values().join("\n");
if name.is_empty()
|| name.len() > 256
|| prefixes.is_empty()
|| prefixes.len() > 32
|| prefixes.iter().any(|p| p.is_empty() || p.len() > 256)
|| body.len() > 65536
|| s.description.as_ref().is_some_and(|s| s.len() > 4096)
{
return Err(format!("snippets: invalid entry {name:?}"));
}
entries.push(serde_json::json!({"name":name,"prefixes":prefixes,"body":body,"description":s.description.unwrap_or_default()}));
}
let key = d.path.to_string_lossy().into_owned();
let mut out = UiDecl::new(
key.clone(),
d.language.clone(),
serde_json::json!({"source":key,"language":d.language,"entries":entries}),
);
out.files.push(d.path);
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validates_every_snippet_at_install_and_requires_api4() {
let dir = tempfile::tempdir().unwrap();
let manifest = |api| {
ide_extension::Manifest::parse(&format!(
"id='test'\nname='Test'\nversion='1.0.0'\nengines.openidea='{api}'"
))
.unwrap()
};
let decl: toml::Value = toml::from_str("language='sql'\npath='snippets.json'").unwrap();
std::fs::write(
dir.path().join("snippets.json"),
r#"{"Select":{"prefix":["sel","select"],"body":["SELECT ${1:column}","FROM $1$0"],"description":"Query"}}"#,
)
.unwrap();
let pkg = Package::new(manifest("^1.4"), dir.path().into());
let out = declaration(&pkg, decl.clone()).unwrap();
assert_eq!(out.json["entries"][0]["body"], "SELECT ${1:column}\nFROM $1$0");
assert!(declaration(&Package::new(manifest("^1.3"), dir.path().into()), decl.clone()).is_err());
for text in [
r#"{"x":{"prefix":[],"body":"x"}}"#,
r#"{"x":{"prefix":"x","body":42}}"#,
r#"{"x":{"prefix":"x","body":"x","extra":1}}"#,
] {
std::fs::write(dir.path().join("snippets.json"), text).unwrap();
assert!(declaration(&pkg, decl.clone()).is_err());
}
}
}
Loaded 100 of 1412 files, more files were not shown because too many files have changed in this diff. Show more