Compare commits

...
Author SHA1 Message Date
l0ng-ai aaeaec798c Merge remote-tracking branch 'origin/main' into HEAD 2026-11-01 18:59:17 +08:00
l0ng-ai 49f9c57bcc docs: remove the README build section 2026-11-01 18:46:40 +08:00
l0ng-ai 07609ef9c6 chore: v0.1.33 2026-11-01 18:35:25 +08:00
l0ng-ai bf2427bd3a feat(java): selectable Java formatting profiles (#680)
Java formatting comes from jdtls' Eclipse formatter, whose defaults differ from the IntelliJ IDEA code style. Settings › Editor › Code Style › Java now offers a formatting profile: Default (unchanged), IntelliJ IDEA, or a custom Eclipse formatter XML (absolute or project-relative path).

The IntelliJ IDEA profile is a bundled Eclipse formatter XML that follows IDEA's default Java code style item by item (indents, aligned multi-line parameters, annotation placement, else/catch/finally on the same line, array initializers, lambdas, blank lines, comments not reflowed). Manual line breaks are kept; lines longer than 120 columns wrap where necessary, which differs from IDEA's default of not wrapping. The profile reaches a running jdtls through didChangeConfiguration without a restart; an unreadable custom file shows a warning and leaves code intelligence running. Code style changes reconfigure language servers without scheduling Kotlin bridge builds.

Also drops two catalog entries whose texts are no longer used.

Fixes #649
2026-11-01 18:26:24 +08:00
fuqiuluo 041e72106b fix(linux): restore window geometry after GTK mapping (#619)
On Linux the window-state plugin restored the saved size before GTK's decorations and output scale had settled, so the saved physical inner size went through the creation-time CSD/scale conversion and drifted on every restart (52 px per axis under Mutter, the remembered size collapsing on other setups), and maximize transitions overwrote the remembered normal size.

Linux now builds project and settings windows hidden and restores them after the first configure, keeping the normal size apart from maximize, fullscreen and minimize, protecting it during transitions, and snapshotting each window before close instead of querying GTK during exit. The state file format is unchanged; macOS and Windows keep the plugin as before.

Verified under Mutter and Weston: three restarts at 1234×678, three maximized restarts followed by unmaximize, the settings window, several project windows across a full exit, and window groups all restore exactly. macOS restore is unchanged. Not run: GNOME on real hardware, mixed-DPI monitors, X11.

Fixes #594
Fixes #636
2026-11-01 18:05:05 +08:00
l0ng-ai aa2c24c648 fix(agents): make tool window sessions usable in narrow layouts 2026-11-01 18:00:54 +08:00
arookieofcanda-rookie-of-C-language 3a87c697b2 fix(ide-tools): move run_input above the tests module (#677)
clippy::items_after_test_module failed the workspace clippy run because run_input was defined after the first test module in crates/ide-tools/src/process.rs. Move it above; no code changes.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-01 17:48:38 +08:00
l0ng-ai 87bf89d688 feat(debugger): add paused editor evaluation entry points (#676)
While a debug session is paused, Alt/Option+click on a variable or expression runs Quick Evaluate at the clicked position (the selection when the click falls inside it) and shows the value next to it; outside a paused session Alt+click keeps its editor behaviour. The editor context menu offers Evaluate Expression… seeded with the selection or the expression at the caret while paused.

Mouse shortcuts now pass the clicked editor and position to the action, and the binding is only consumed when its action is enabled.

Fixes #664
2026-11-01 17:19:00 +08:00
l0ng-ai a3fb14fa86 fix(java): align debug JVM encoding with Run 2026-11-01 17:10:34 +08:00
l0ng-ai f6d7facd82 fix(window): stop macOS traffic lights jittering and fix local open from remote windows (#641)
AppKit puts the title bar container and window buttons back at their defaults on every live resize step, title change and fullscreen exit. They were corrected from window notifications after that layout had been drawn, so the buttons bounced between positions, or stayed at the defaults when no notification followed. The frames are now corrected in a main run loop observer ordered just before the Core Animation commit, project windows are created hidden on macOS so the buttons are placed before the first frame, and repeated title bar height updates are skipped.

Choosing This Window for a local project in a remote window was routed to the remote host or skipped when the path matched the remote root. open_project and light_edit_open now run on the local backend and leave remote mode once they succeed, project trust for these opens is checked locally, and a failed open cancels the pending close.
2026-11-01 16:58:49 +08:00
l0ng-ai ec5193f827 feat(format): external formatting with managed Prettier (#671)
Reformat Code only asked the language server for formatting, so files whose server has no formatting capability (Markdown with marksman, and others depending on the server) could not be formatted.

Languages can now declare an external formatter. Prettier is the first: a managed tool installed on demand, or the project's own node_modules/prettier, an explicit path, or PATH. It runs on the backend copy of the document with the file's path so Prettier resolves the project's config, overrides and ignore files, and its result is applied as one undoable edit. Language servers that format a file keep doing so unless Prettier is preferred in Settings › Languages › Prettier; formatting on save is opt-in. Parsers without range support format the whole file and say so.

Errors carry structured tool data, so a missing Prettier reuses the shared install prompt and Prettier diagnostics or timeouts show as non-sticky notifications.

Fixes #639
2026-11-01 16:54:31 +08:00
l0ng-ai 9bb44cbeb3 feat(java): resolve compiled Kotlin APIs in Maven Java editors (#669)
Java files in mixed Maven projects could not resolve Kotlin types from the same module or upstream reactor modules, because Kotlin compiler output never reached JDT's Java model.

A per-project bridge compiles saved Kotlin sources with the project's own Maven setup into private staging output (a bundled Maven extension redirects outputs and records each module's Kotlin compile result) and mounts the Kotlin classes as marked libraries through a bundled JDT command, leaving m2e/JRE containers and other entries untouched. The last successful snapshot stays mounted while rebuilding; a new one is published when every Kotlin target compiles, even if javac fails, so Java callers of a renamed or removed Kotlin API show their errors in the editor. Kotlin compile failures keep the previous API and are reported under the kotlin-bridge Problems source. Removing the last Kotlin file withdraws the libraries. Changes under build outputs and excluded directories never trigger a rebuild.

Part of #428
2026-11-01 16:33:44 +08:00
arookieofcanda-rookie-of-C-language 8d38aa4a9c feat(editor): navigate to definition on a plain middle-button click (#666)
The editor's mousedown handler ignored every button except the left one, so a middle click fell through to the webview's autoscroll. A plain middle click now moves the caret to the clicked symbol and runs go-to-definition, suppressing autoscroll; a keymap shortcut bound to Button2 still takes precedence, and modifier + middle combinations stay unbound.

Fixes #663

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-01 16:17:18 +08:00
arookieofcanda-rookie-of-C-language 683f8d8826 test(jdtls): compare resolved source paths as paths, not strings (#665)
java_class_file joins the package path with std::path, so on Windows the result uses backslashes while the test's expectation kept the literal forward slash from "p/Outer.java". Compare as Path so separator style does not matter.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-01 16:16:14 +08:00
l0ng-ai f19eacc336 fix(java): skip unchanged Maven launch builds (#667)
Run/Debug of a Maven module with Kotlin/Groovy/Scala sources rebuilt the module and its reactor dependencies with Maven before every launch, even when nothing had changed.

Each successful launch build now records a fingerprint of its inputs (sources and resource roots of the dependency closure, module and parent POMs, imported BOMs, wrapper/.mvn/settings/toolchains, JDK, command line and environment) together with an immutable snapshot of its outputs in the project cache. A later launch with the same fingerprint reuses the snapshot without running Maven; Run and Debug share it, application and test launches are kept apart. Inputs that cannot be resolved reliably (dynamic roots overridden by profiles or -D) always rebuild. A failed, cancelled or concurrently edited build never publishes a reusable record.

Superseded snapshots stay available while JVMs of the current session may still load classes and are removed when the project closes; snapshots left by earlier sessions and no longer referenced are cleaned on the first launch.

Fixes #530
2026-11-01 16:08:16 +08:00
l0ng-ai d3e63d8ff0 feat(search): select and open multiple search results (#670)
Search Everywhere and Recent Files lists now support multi-selection: Shift+Up/Down and Shift+click extend a range from the anchor, Cmd/Ctrl+click toggles a row, and Enter opens every selected file, symbol or text result in list order, leaving focus on the lead row. Actions in a mixed selection are skipped; a single selection behaves as before. Selection resets when the query or tab changes.

The row-selection model used by Find in Files moves to src/ui so the shared List can opt into modifier selection.

Fixes #647
2026-11-01 16:05:16 +08:00
arookieofcanda-rookie-of-C-language 5c0a5239fa feat(appearance): fallback font for the custom UI font (#660)
Adds a UI fallback font under Settings › Appearance › Use custom font, so glyphs missing from the chosen UI font (e.g. CJK in Menlo) render in a font the user picks instead of the system default. The fallback joins the UI font stack used by menus, the project tree and dialogs, and resolves macOS user fonts through the shared font loader.

Also keeps the custom font controls wrapping together at narrow widths and covers save/clear/disable, stack composition and settings round-trip in tests.

Fixes #654

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-01 15:53:24 +08:00
arookieofcanda-rookie-of-C-language f8d6f9df77 fix(editor): Shift+F6 renames the file when no language server covers it (#653)
In files no language server covers (Markdown, .properties, plain text), Shift+F6 only showed a transient "no running language server supports rename" hint at the caret, so the default rename shortcut looked broken.

Root cause: when prepare_rename reported no server and the syntax fallback declined, startRename fell through to the error hint. It now hands off to project.rename, which opens the file rename dialog for the active file; a running server that lacks rename keeps its hint.

Verified on macOS: Shift+F6 in .md and .properties editors renames the file, symbol rename in TypeScript is unchanged, and Shift+F6 in the project view still renames the selected file.

Fixes #651

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-01 15:45:35 +08:00
l0ng-ai 0c54266d03 fix(java): build mixed-language Maven dependencies before Java launch (#659)
Run/Debug of a Java-only module that depends on a Kotlin/Groovy/Scala module in the same reactor launched with stale classes or failed with Unresolved compilation problem, and a successful Maven build was still reported with the old JDT compile errors.

Root cause: the pre-launch external build only checked the launch module's own sources, and Maven shared target/classes with JDT autobuild.

Fix: walk the launch module's reactor dependency closure (transitive, test scope aware) for non-Java sources; when found, run Maven from the reactor root (-pl <module> -am clean compile, test-compile for test launches) and block the launch on failure or cancel with the Maven errors in the console. JDT autobuild is paused while Maven runs and restored afterwards, and the launch uses a per-launch snapshot of the closure's output so JDT cannot overwrite it. A successful Maven build no longer shows the stale JDT warning. Pure Java projects and Gradle keep their previous path; the debugger adapter choice still follows the launch module.

Verified on macOS with single- and multi-module Maven projects (new Kotlin values in Run and Debug, Kotlin error blocks the launch, cancel, pure Java does not invoke Maven). Editor type resolution for Kotlin in Java is handled separately.

Part of #428
2026-11-01 15:18:53 +08:00
l0ng-ai 1716f3e062 feat(diff): compare directories and synchronize entries (#661)
Two folders can now be compared from the project view (Compare Directories with two folders selected, or Compare With… on one folder, also in Find Action). The result tree marks entries as only on left, only on right, contents differ or identical, with a name filter and a show-identical toggle; double-click opens the existing diff editor, and files or whole subdirectories can be copied to either side (overwrite and delete ask first, extra files in the target are kept). Comparison runs in the background with progress and cancel, honours the project's exclusions, and works for remote projects through the same file commands.

The comparison tree and diff preview are shared with the Git folder/revision comparison.

Verified on macOS (subdirectories, Chinese names, binary files, copy both ways, delete, cancel on large files); Windows and Linux not run.

Fixes #520
2026-11-01 15:14:13 +08:00
l0ng-ai 482241ebb9 feat(java): open class files as decompiled sources with breakpoints (#657)
Opening a .class file showed binary content. Class files from library jars, project output directories, navigation, Go to Class and debugger frames now all go through one route to JDT class documents:

- Library classes open the attached source, or the FernFlower decompilation when there is none, read-only and marked as decompiled. java-debug's decompiled-source support is enabled, so breakpoints set in decompiled code map to the original lines and hit, and stepping into such classes lands on the right line.
- Output-directory classes (target/classes, build/classes) open their project source when it exists; otherwise the decompiled text is shown read-only with breakpoints disabled, because jdt.ls returns no line mapping for classes outside the Java model.
- The preferred content provider id is corrected to fernflowerContentProvider (attached sources still win), and the decompiled marker recognises the current FernFlower header.

Verified on macOS with a Maven project: library class from Libraries and Go to Class, breakpoint hit and step into decompiled code, output class with and without source. Not run on Windows, Linux or Gradle projects.

Fixes #394
2026-11-01 15:04:28 +08:00
l0ng-ai 7c72411dd7 fix(trust): allow application-level commands in restricted mode (#648)
Help › About showed no version, build or runtime in untrusted project windows and on the welcome screen.

Root cause: the restricted-mode allowlist added with project trust missed app_build_info and other application-level commands, so they were rejected wherever the window had no trusted project; the frontend then cached the failed build info for the whole session.

Fix: classify all registered commands and allow the 24 that only read application information or change the user's own application state (build info, log location, update checks, cache invalidation, file clipboard, JDK download, Docker settings, database export paths, patch export, system environment); JDK download now uses the application tool directory instead of project tool settings. Execution commands (run, build, debug, language servers, tool install from project settings) stay blocked. Build info is retried after a failure instead of being cached. Verified on macOS in an untrusted project and on the welcome screen; Windows not run.

Related to #519
2026-11-01 13:45:25 +08:00
l0ng-ai 376501a5fe fix(properties): keep existing Unicode escapes when saving with native-to-ascii (#650)
With Transparent native-to-ascii conversion on, pasting or typing existing \uXXXX escapes into a .properties file and saving wrote them back as \u005CuXXXX, so the file on disk changed meaning.

Root cause: the shared properties encoder escaped the backslash of any valid ASCII Unicode escape. It now writes ASCII escapes as they are and only converts non-ASCII characters; a literal backslash right before a non-ASCII character is still protected. The same encoder serves every write path, so normal/auto save, find and replace in open and closed files, workspace edits, Local History restore and partial Git stage/commit are all covered.

Verified on macOS following the reporter's steps (paste escapes, save, reload externally); Windows and Linux not run.

Fixes #519
2026-11-01 13:42:28 +08:00
l0ng-ai 21925b7f4b fix(terminal): activate virtualenvs using the resolved terminal shell (#646)
New terminals typed the virtualenv activation command for the wrong shell: on Windows the default terminal is PowerShell, but the activation line was chosen from $COMSPEC (cmd), so PowerShell just echoed "...\activate.bat" and the project interpreter was not activated.

Root cause: terminal_activation guessed the shell from $SHELL / $COMSPEC instead of the shell the PTY actually starts. Same class on macOS/Linux when the inherited $SHELL differs from the login shell the terminal uses, and for a blank configured shell.

Fix: the host resolves the shell once with the terminal's own resolve_shell and uses it both to start the PTY and to pick the activation command; terminal_activation takes the resolved shell and no longer reads the environment. Tests cover PowerShell/cmd paths, blank settings and a host-level run with an unrelated $SHELL. Verified in the app on macOS (default zsh and configured bash); not run on Windows.

Fixes #631
2026-11-01 12:40:03 +08:00
l0ng-ai 1e898367a5 fix(fonts): render unavailable macOS user fonts through binary IPC (#643)
On macOS versions where WKWebView refuses user-installed fonts by family name (fonts in ~/Library/Fonts or /Library/Fonts), the editor, terminal, Markdown and UI fell back to the default font and Nerd Font icons showed as boxes; only a webfont loaded from the file's bytes renders.

Root cause: the shared font resolver only ever referred to fonts by local family name, so every consumer depended on the WebView resolving user fonts by name. A URL-based webfont is blocked by the existing font-src policy, which stays unchanged.

Fix: the shared resolver probes whether a family renders by name (on a document-attached canvas; a detached canvas falls back even when the font works) and only when it does not, fetches the enumerated user font files over a raw-bytes IPC (user fonts only, looked up by family, no paths accepted) and registers them with the FontFace API under a stable alias. Only the faces nearest regular/bold and their italics are loaded, once per family. Covers editor and editor fallback, diff/merge views, terminal font, fallback and built-in Nerd fallback (re-measured after loading), embedded terminal, settings preview, custom UI font, Markdown and Mermaid. A debug-only environment switch reproduces the WebView restriction for testing.

Verified on macOS 26 with the restriction simulated (icons boxes before, correct after) and without it (no font bytes transferred, no regression). Not run on the reporter's macOS version, Windows or Linux; other platforms keep the previous behaviour.

Fixes #592
2026-11-01 12:23:08 +08:00
l0ng-ai 7a58d9f253 fix(java): bundle Lombok agent with Equinox config lookup fix (#642)
lombok.config was ignored in Java builds (e.g. lombok.accessors.chain=true produced void setters), and jdt.ls wrote those classes into target/classes, breaking later Maven builds.

Root cause: Lombok calls getModuleClassLoader(boolean), which newer Equinox removed; the exception is swallowed, Lombok resolves the wrong source path and finds no lombok.config at all, so every config key falls back to its default. Affects every Java project using lombok.config.

Fix: ship Lombok 1.18.48 with the upstream fix projectlombok/lombok#4059 (lombok-1.18.48-openidea.1, embedded in the app, no download) as the jdt.ls agent; the versioned path also resets the jdt.ls workspace so stale output is rebuilt. A user-configured agent still takes precedence. Return to Maven Central once an official release includes the fix (source and checksum in plugins/lombok/README.md).

Verified on macOS: 5 clean builds with chained setters, identical output to official 1.18.48 without lombok.config, the app rebuilds already-poisoned target/classes, and Run works. Windows and Linux not run.

Fixes #565
2026-11-01 12:08:44 +08:00
arookieofcanda-rookie-of-C-language ad80e903e3 feat(settings): add "Show tool window badges" (#637)
Counts on the tool window stripe icons (changed files on Commit, problems, agent tasks needing input, running processes) can be turned off in Settings › Appearance & Behavior › Appearance › Tool Windows. On by default; the setting is stored in appearance settings and survives restarts.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-01 11:38:26 +08:00
arookieofcanda-rookie-of-C-language 9847fd2c01 feat(agents): dock a session in the Agents tool window (#588)
Agent sessions can be opened in the Agents tool window beside the editor (Open in Tool Window / Open in Editor in the task menus, or by dragging between the editor and the tool window); opening in an editor tab stays the default.

Fixes in review:
- A docked conversation kept the previous task's subagent view after switching tasks: the session's local state is now keyed by task.
- Moving a conversation between the editor and the tool window lost its scroll position: position and follow-output are kept per conversation (task or subagent).
- In a short bottom tool window the message area shrank to a few pixels: wide windows place the task list beside the conversation, narrow ones cap the list height.
- Drag and drop and list selection now go through the agents.dockTask / undockTask / openTask actions.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-01 11:31:53 +08:00
arookieofcanda-rookie-of-C-language fa26ba842c fix(tests): update code action tests for the interactive apply path (#638)
applyCodeAction now resolves the picked action and executes it (lspResolveCodeAction → lspExecuteCodeAction) instead of calling lspApplyCodeAction, but refactorThis.test.ts and runKind.test.ts still mocked and asserted the old command, so main's frontend tests failed (4 tests). The mocks now follow the new commands; safeDelete.test.ts's negative assertion still holds.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-01 11:16:44 +08:00
l0ng-ai 16033c481b Revert "fix(editor): pause expensive decorations during fast scroll bursts (#401)" (#635)
This reverts commit c938d712b5.
2026-11-01 10:30:09 +08:00
l0ng-ai 2c3eabb786 feat(go): support interactive Generate actions (#632)
Go's test generation and interface stubs were reachable only through context actions, because Generate filtered their titles, and implementing an interface on a plain struct wasn't available since the client didn't support gopls's interactive commands.

Generate and the editor's context menu now list `Add test for ...`, `Declare missing methods ...` and `Add methods to ... implement ...`, and a generated test file opens in the editor. For gopls only, the client advertises `interactiveResolve` and runs its `command/resolve` handshake: a prompt titled from the server asks for the interface, shows validation errors in place, and Cancel writes nothing. Requests are cancellable and other servers and existing commands are unchanged.

Fixes #620
2026-11-01 10:27:36 +08:00
arookieofcandl0ng-ai c938d712b5 fix(editor): pause expensive decorations during fast scroll bursts (#401)
* fix(editor): pause expensive decorations during fast scroll bursts

Add a scrollThrottle plugin that flags the editor with a .cm-scrolling
class while the wheel is moving. While the flag is up the sticky-scroll
header and indent guides are hidden via theme CSS, and their view
plugins skip per-viewport re-measures/rebuilds; a trailing scrollSettled
effect re-runs them once the wheel rests for 140ms. This keeps fast
mouse-wheel scrolling from painting rows of stale decorations over
freshly rendered code.

Fixes l0ng-ai/openidea#375

* fix(editor): keep decorations painted during scroll bursts

The scroll throttle hid the sticky header and indent guides entirely while
scrolling, which flickered on every burst - and the sticky header is exactly
what you want to see while scrolling. Drop the display/background hiding from
the theme and keep the previous paint instead: the plugins skip re-measuring
during a burst and catch up once on the trailing scrollSettled update.

* fix(editor): throttle the first scroll event and queued sticky measurements

---------

Co-authored-by: l0ng-ai <[email protected]>
2026-11-01 10:09:47 +08:00
l0ng-ai 7cf4f7948d fix(run): pass plain Windows paths to Tomcat (#608) 2026-11-01 10:09:40 +08:00
l0ng-ai b281447da8 fix(search): mark outdated find results and block stale replacements (#630)
While a new query was debouncing or validating, Find and Replace in Files kept showing the previous query's rows, count and highlights, and Replace stayed enabled. Replace, Replace All in This File and Replace All then sent the new pattern with the old positions, so `order` changed to `ord` could rewrite `order` as `Xer`.

From the moment query, flags, mask, scope or context change, old rows are dimmed and lose their highlights, replacement preview and count until the new search's first results arrive. All replace buttons and keyboard paths are blocked meanwhile, and a pending Replace All confirmation rechecks that its results are still current. Opening a retained row still works.

Refs #623
2026-11-01 09:55:43 +08:00
arookieofcanda-rookie-of-C-language 1e913c2334 fix(languages): highlight .svelte files via the HTML ui mode (#627)
`.svelte` files got highlighting only from semantic tokens, because the language had no `ui-mode` and no CodeMirror language loaded. It now uses the HTML mode, which parses `<script lang="ts">` as TypeScript and `<style>` as CSS. Svelte blocks and markup expressions stay plain text.

Closes #626

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-01 09:51:22 +08:00
l0ng-ai 52cb592692 fix(clipboard): read the macOS pasteboard on the main thread (#629)
Clipboard file-list reads and writes ran on background threads, while AppKit and WebKit use the general pasteboard on the main thread. Its type cache isn't safe against that, so the app crashed with a segfault or heap corruption in `-[NSPasteboard _updateTypeCacheIfNeeded]`. Rechecking the clipboard on window focus made this frequent, sometimes within seconds of opening a project.

On macOS these commands now run on the main thread and await the result without blocking a command thread; other platforms are unchanged. `ide-clipboard` documents and debug-asserts the requirement. A concurrent read stress test that crashed main within a second completed about 650,000 reads over four minutes with no crash.
2026-11-01 09:44:41 +08:00
l0ng-ai bbaf2c89a0 fix(search): keep Find and Replace in Files footer actions inside the dialog (#628)
The Replace in Files footer has more actions than fit beside its keyboard hint, so buttons shrank below their labels, overlapped, and ran past the dialog edge. Buttons and the Preview checkbox now keep their size, the hint truncates first, and the actions wrap to a second row aligned right. The results and preview areas shrink when the footer wraps or the window is short. Find in Files shares the footer.
2026-11-01 09:29:42 +08:00
Ning YuanLin 6456f0e404 feat(java): add read/write filters for Java field usages (#569)
Find Usages mixed reads and writes of a Java field, including Lombok getter and setter calls, because jdtls has no read/write classification in its references. The usages popup and Find window now have Read and Write toggles. Read-write accesses stay visible in either mode and unclassified results are kept.

Classification comes from a jdtls extension in `plugins/java-usages`, embedded as a jar and installed beside the other bundles. The checked-in jar was rebuilt from source with ECJ and matches byte for byte. Any extension failure falls back to standard references, so a jdtls upgrade can lose the filter but not Find Usages; rebuilding the jar on an upgrade is manual and described in the plugin README. Older installed jars are removed. Other languages keep their plain result count.

Co-authored-by: NingYuanLin <[email protected]>
2026-11-01 09:09:15 +08:00
l0ng-ai c3d569d3e3 feat(run): update Spring Boot resources and application policies (#603)
Running Spring Boot main-class configurations had no Update action, and incremental Java builds did not publish edited resources to the running JVM. They now share the Update action, toolbar and build runner with Tomcat: resources go through the module's Maven or Gradle build and are published to the resolved runtime classpath, including Gradle's separate jdt.ls output directory. Class updates use scoped HotSwap when available and explain the restart requirement otherwise.

Both configuration types get "On 'Update' action" and "On frame deactivation" policies. A missing policy keeps the chooser and does no deactivation update. Focus tracking debounces external changes and prevents overlapping updates.

Verified on macOS with Maven, Gradle, Tomcat 10.1 and HotSwap. Windows and Linux focus detection were not run. On Linux a native GTK dialog can count as deactivation when no webview window is focused.

Fixes #580
2026-11-01 08:35:18 +08:00
l0ng-ai 0458424f9c fix(project): expire stale file clipboard after OS replacement (#598)
Project Paste could reuse an earlier file copy after another app replaced the OS clipboard, and Cut files stayed dimmed. The tree now tracks the hand-off to the OS clipboard and rechecks it on window focus and just before Paste. A local Copy or Cut is cleared once the OS holds a different or empty file list; failed writes and clipboard read errors keep it. The native adapters now distinguish read failures from empty lists.

In remote windows, Copy and Cut write newline-joined server paths as plain text, tree Paste keeps the server source, and a later OS file list replaces the remote copy and imports those client files. A plain-text replacement stays undetectable there without a clipboard read that may prompt.

Verified on macOS, locally and against a remote host. The Windows clipboard code was not run.
2026-11-01 08:32:33 +08:00
arookieofcanda-rookie-of-C-language 4395975664 fix(terminal): paste dropped file paths at the prompt instead of opening them (#605)
Dropping a file on a terminal fell through to the window-wide drop, which opened it in the editor or asked to open a folder as a project. It now pastes the shell-quoted paths at the prompt, separated by spaces and with no newline, so nothing runs until the user presses Enter.

Each terminal, including the Run console, accepts only drops inside its own host. Windows paths use double quotes. Remote windows keep the existing drop, since a local path means nothing on the remote host. Verified on macOS with a simulated drop; a native Finder drag and Windows quoting were not run.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-01 08:30:50 +08:00
arookieofcanda-rookie-of-C-language 0bf597c2fd fix(search): make the search popups draggable (#625)
Find in Files, Replace in Files and Search Everywhere have no window title bar, so they could not be moved off the editor underneath. Dialogs can now be dragged from any element marked `data-dialog-drag`: the headers of those search popups, the title of any titled popup, and the existing window title bars and alert bodies, which now use the same mark. Controls inside a handle never start a drag.

Also waits for a review thread inlay in a test where it can mount a tick later.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-01 08:13:15 +08:00
arookieofcanda-rookie-of-C-language 95e79116d1 feat(project-tree): show a file's members below it (#624)
Finding a declaration in a file meant opening the Structure view. A new Project view option, Show Members, lets files expand into their declarations, including nested ones, and clicking or activating a member opens the file at that symbol. It is off by default.

Outlines load when a file expands, including expansion restored on reopening a project or by turning the option on later. They refresh on save and on changes on disk, with stale in-flight results discarded. Files with no outline lose their chevron. File operations, drag and the context menu skip member rows. Also removes a duplicate Sort Folders First menu entry.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-01 07:31:26 +08:00
arookieofcanda-rookie-of-C-language cde74394d7 fix(welcome): space the landing actions evenly (#612)
The landing's three actions were flex items that hugged their labels, so the gap between icons followed the text length. They are now equal-width grid columns, which spaces the icons evenly in every locale.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-01 05:19:57 +08:00
arookieofcanda-rookie-of-C-language 9ca65b4df6 fix(ui): use dedicated icons for Rust, Cargo and ignore files (#616)
`.rs`, `Cargo.toml` and `Cargo.lock` used custom badges or the generic lock, and `.gitignore` used the git logo. They now have dedicated Rust, Cargo and Cargo lock icons with dark variants, and `.gitignore` and `.gitignore_global` share the ignored-file icon with the other ignore files. The icons' source and MIT licence are recorded in `THIRD_PARTY_LICENSES.md`.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-01 05:15:12 +08:00
l0ng-ai 2841f97145 fix(agents): use icon-only composer actions (#613)
The composers' primary actions were text buttons. Start, Send and Queue, plus Send in edit-and-resend, now share an icon-only submit button that keeps the translated name as its accessible label and tooltip, and shows a spinner while the action is pending. Stop is unchanged.
2026-11-01 04:18:32 +08:00
l0ng-ai 0be97c4323 fix(java): apply unmanaged project source roots (#611)
Source and Test roots marked in Project Structure never reached jdtls, so projects without Maven, Gradle or Eclipse metadata relied on jdtls's inference. That inference could treat a package directory as the source root, which gave wrong-package and unresolved-import errors in the editor and in Run.

Marked roots now go to jdtls as `java.project.sourcePaths`. Unmarked projects use `src/main/java` and `src/test/java`, or `src`; other layouts keep the inference. Changing the roots restarts only jdtls, with a separate workspace per source model, because a cached invisible project does not pick up new folders. Maven, Gradle and Eclipse projects keep their own model. Ant layouts count as unmanaged, since jdtls has no Ant importer.

Settings sent to jdtls are now merged the same way for initialization, configuration requests and change notifications. A Run with no editor open finds a trigger file inside the source roots so the project still imports.
2026-11-01 03:49:40 +08:00
l0ng-ai 0411a5b08a fix(settings): show SSH password and passphrase inputs (#609)
The SSH configuration form only asked for a password after a failed Test Connection, so choosing Password showed nothing but a "Save password" checkbox. It now shows a Password row, or a Passphrase row for key pairs.

The typed secret is staged with the other settings changes: it is written on Apply / OK and discarded on Cancel or Reset, and Test Connection uses it without storing it. An emptied field leaves the stored secret alone. Deployment, run target and data source forms have their own secret inputs and are unchanged.
2026-11-01 03:33:25 +08:00
l0ng-ai f48cd85d0b fix(editor): support custom folding in block comments (#606)
Native folding matched comment node names case-sensitively and stripped delimiters character by character, so XML comments were skipped and the `<` in editor-fold tags was consumed. The frontend hardcoded its own comment prefixes. Both now strip the configured comment tokens from the language definitions, so region and editor-fold markers work in block comment languages (XML, HTML, Vue, SVG, CSS and others), and Navigate > Custom Folding lists them. Languages without a bundled grammar get a fallback for own-line markers. Rust and frontend tests share one fixture over 22 language configurations.

Fixes #583
2026-11-01 02:28:07 +08:00
l0ng-ai 9e35cfc064 feat(vcs): compare folder selections with revisions across repositories (#602)
Compare with Branch or Revision only handled one file, so reviewing a folder meant opening each file. Folders, the project root and multiple selections now open a changes tree against the chosen revision, with per-file diffs on demand. A single file keeps its direct diff.

Each path resolves against its own repository through the root registry. In a multi-repo workspace the picker offers shared revisions such as "main in 3 repositories" as well as per-repository ones, results are grouped by repository, and content outside any repository or a repository missing the revision is stated in the result. Listing is cancellable and off the UI thread, includes worktree and untracked changes, and a rename leaving the selection shows as a delete. Show Diff on folders reuses the same tree.

Verified on macOS, and against a remote host over the server protocol. A remote window, Windows and Linux were not run.

Fixes #567
2026-11-01 01:43:15 +08:00
l0ng-ai 0a71f6be88 feat(editor): paste copied files as Markdown links (#599)
Pasting files copied in the project view into a Markdown document now inserts `[](relative/path)`, one link per line, encoded the same way as Markdown images. Other editors and terminals keep inserting the raw path, copied images keep their ![](...) route, and remote windows keep raw paths because the clipboard holds client paths. A context-menu Paste that the native command rejects falls back to reading the clipboard.

Fixes #573
2026-11-01 01:41:58 +08:00
l0ng-ai b3c3150489 fix(remote): run commands on the main thread after authorization (#600)
Authorization for a remote window resumes on a Tokio worker, so the generated handler ran synchronous commands there, including ones reaching AppKit such as the clipboard check, while local windows run them on the main thread. The handler is now dispatched back to the main thread after authorization, and the request is rejected if dispatch fails.

Two app aborts seen in remote windows unwound through this task. The abort was not reproduced and its throw site is unconfirmed. Logging confirmed the thread change. An audit found no remote-only blocking path in the synchronous commands that move to the main thread.
2026-11-01 01:31:01 +08:00
l0ng-ai 1d937d52e5 fix(i18n): remove stale remote import entries from the zh catalog (#590)
The remote import change left two zh entries whose source text is not in the scanned source, which failed textCatalog.test.ts on main.
2026-11-01 00:16:00 +08:00
l0ng-ai 5f57ce962e fix(project): import local files into remote project trees (#587)
Pasting or dropping OS files into a remote project tree sent a client-local source path to the server, so nothing was copied. Local-source imports are now carried separately from project copies and uploaded over the session SFTP channel: streamed in bounded chunks, created exclusively, cancellable across all selected paths under one task, with partial files removed on failure. Symlinks and Unix file modes are preserved, and special files are skipped and reported. Project-internal paste keeps working when the OS clipboard is briefly unavailable.

Native drop positions were assumed to be physical pixels, which shifted the target far from the pointer on Retina displays and with IDE zoom. A shared helper converts them to CSS coordinates for the project tree, the editor and Markdown drop handling, and the agent composer. Composer image drops are now read on the client in remote windows.

Verified on macOS against a live SSH host. Windows and Linux were not run: the Windows conversion is unchanged, the Linux one changed. Copy out from a remote tree to the local machine still needs a download workflow.
2026-11-01 00:14:56 +08:00
arookieofcanda-rookie-of-C-language 95b45c095d fix(project): paste copied files into editors and terminals as paths (#576)
Copying files in the project view wrote only the file list to the OS clipboard, so pasting into an editor did nothing. The copy now also writes the newline-separated paths as text, which other applications can paste.

WKWebView hides that text when file URLs are present and delivers a paste event with only Files, so the IDE editor and terminal still received nothing. A shared helper now reads the OS file list when a paste has files but no text, and inserts the paths. It covers Cmd+V and context-menu Paste in the editor, the terminal and embedded terminals. Markdown image paste runs first and keeps its behaviour.

Verified on macOS. The Windows clipboard change was tested by the contributor at the clipboard level only.

Refs #573

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-01 00:13:18 +08:00
l0ng-ai 9dc736de09 fix(java): explain recovery for unimported launch modules (#585)
Java launch preparation forwarded jdtls project-lookup failures unchanged, so a Maven module missing from the parent build or a wrongly inferred module showed a raw jdtls error in every Run and Debug entry point. Launch preparation now names the selected module and explains how to recover: wait for import, check the run configuration and the parent build, or open the folder as a project.

Covers the "does not belong to any Java project", "references non-existing project" and "not a Java project" responses. Cancellation, missing server, crash, timeout and build errors keep their existing handling. Import membership and module inference are unchanged.

Refs #197
2026-10-31 23:56:19 +08:00
arookieofcanda-rookie-of-C-language 369c63979d fix(project): keep the tree viewport when expanding shifts rows above (#579)
The tree followed the lead row by index, so expanding or collapsing a folder above the selection shifted the index and scrolled back to the selected row. It now scrolls when the lead changes. A lead whose row has not loaded yet is kept pending and scrolled to once when the row appears, so preselected rows and async loads still come into view.

Covers keyboard navigation, speed search, Select Opened File and autoscroll from source, which all still scroll to the new lead.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-10-31 23:44:34 +08:00
iGinkandiGink 347e3525d2 feat(editor): close editor tabs with Shift+click (#581)
Add Shift+left-click as a way to close editor tabs. The tab strip gets a closeOnShiftClick option, enabled for the editor area only, so tool window tabs such as Run and Terminal still select on Shift+click. Keyboard activation with Shift held still selects, non-closable tabs still select, and Shift+click does not start a tab drag.

Co-authored-by: iGink <[email protected]>
2026-10-31 23:38:44 +08:00
l0ng-ai 5280c9e584 fix(agent): report Claude Code turns that fail instead of ending them as ready (#582)
Prompts are sent with the origin `human` and their results echo it, but
`belongs` treated any result with an origin as another turn's. The result
was ignored and the turn ended on `command_lifecycle`, which dropped the
failure the turn had recorded. A signed-out Claude Code showed an empty
reply and a Ready task, with no error.

Only origins other than `human` now mark a result as foreign, so failures,
usage limits and the `max_tokens` and `refusal` stop reasons reach the task.
A turn ended by `command_lifecycle` also keeps its recorded failure.
2026-10-31 23:14:37 +08:00
l0ng-ai 67cad01ba7 chore: v0.1.32 2026-10-31 22:38:36 +08:00
l0ng-ai ad92b1577f fix(window): align macOS traffic lights with IDE zoom (#574)
IDE zoom changed the toolbar height while the macOS window buttons stayed at a fixed vertical position. The buttons are now centred in the title bar using its measured height and the zoom, and repositioned after AppKit layout updates. Zooming out keeps enough room for them so they do not cover the project icon, and compact mode follows the same alignment.

Windows align from the saved zoom and compact setting when they are built, so the buttons do not move once the page loads. The height uses the zoom the webview applies, so an out-of-range setting cannot misplace them.
2026-10-31 22:38:16 +08:00
l0ng-ai 06109aae95 chore: v0.1.31 2026-10-31 22:26:48 +08:00
arookieofcanda-rookie-of-C-language b2d1188049 fix(terminal): keep focus in the terminal after its context menu closes (#566)
Right-clicking the terminal did not focus it, and closing any context menu sent focus to the editor. Popup recorded the element to restore in an effect, after Menu had already focused its first item, so it restored the menu item; focus then fell to the body and the workbench fallback focused the editor.

Popup now records the focused element while rendering, and the terminal takes focus when its context menu opens. Enter after the menu closes goes to the shell. This covers every context menu: focus returns to what held it before the menu opened.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-10-31 21:43:20 +08:00
l0ng-ai 5329ddc469 chore: v0.1.30 2026-10-31 21:32:28 +08:00
l0ng-ai 2faa64fb68 test(project): limit Unix-only trust test imports to Unix (#571)
The trust integration tests that use these imports only compile on Unix, so they were unused on Windows and failed cargo clippy -D warnings.
2026-10-31 21:23:16 +08:00
l0ng-ai d6f6525ca5 fix(scratch): add visible language search field 2026-10-31 21:21:50 +08:00
l0ng-ai 2c9a6470ff feat(project): add project trust and restricted execution mode (#517)
Projects now open with a user-owned trust decision. The prompt offers Preview in Restricted Mode, Don't Open and Trust Project, with an optional trusted parent folder; preview is the default focus so Enter never grants trust.

Restricted projects keep browsing, editing, saving, search and highlighting. Project-controlled execution is denied by default in the backend: run, debug, build, tools, language servers, plugins and Git commands that could honour repository configuration. Trusting a project enables those paths without reopening it, and decisions can be revoked in Settings.

Terminals stay available when the user opens one, without project shell settings, environment or virtualenv activation. A shell prompt that runs git in the folder can still honour the repository's core.fsmonitor.

Fixes #462
2026-10-31 20:58:45 +08:00
l0ng-ai cf51b57924 Add Windows ARM64 and Linux ARM64 desktop releases (#516)
* build: verify native Windows and Linux ARM64 bundles

* fix: select native ARM64 tools before Windows x64 fallback

* build: register verified ARM64 updater bundles and remove verification workflow

* fix(agent): prefer npx and uvx over emulated x64 binaries on Windows ARM64
2026-10-31 20:47:50 +08:00
l0ng-ai e7740e4032 feat(workbench): add merged main menu style (#561)
* feat(workbench): add merged main menu style

* fix(workbench): restore focus on a second Alt and keep closed menus closed
2026-10-31 20:47:32 +08:00
l0ng-ai bdadecdf91 fix(settings): keep the settings window inside the work area on Windows and Linux (#558)
* fix(settings): keep the settings window inside the work area on Windows and Linux

* fix(settings): subtract the window frame when shrinking to the work area
2026-10-31 20:47:27 +08:00
l0ng-ai 178fc9eaba feat(editor): move preview layout controls into the tab strip (#554)
Markdown layout controls occupied a permanent bordered popup over the document.

- Move them into the editor tab strip with layout icons. When tabs are hidden, including Distraction Free Mode, show one floating toolbar that appears on mouse activity and fades after inactivity.
- Use the same floating toolbar for the HTML browser controls.
- Keep keyboard access: hold the toolbar while focused, return focus on Escape, and respect reduced motion. Add theme tokens with their import, template and reference entries.
2026-10-31 20:24:31 +08:00
l0ng-ai 17e3cd2b39 fix(editor): synchronize external file changes on focus (#564)
Open files only reloaded from file watcher events, so a change the watcher skipped stayed stale in its tab: excluded and git-ignored folders, files outside the project, and missed events.

- Re-check open documents against disk when the window gains focus, off the UI thread and debounced. Clean documents reload, dirty ones keep the existing warning, and unchanged ones cost one metadata call.
- Refresh the project tree and Git status on the same focus event.
- Add "Synchronize external changes when switching to the IDE window" under System Settings, on by default.

Fixes #563
2026-10-31 20:12:00 +08:00
l0ng-ai 4796265b5c style(debugger): format the Java launch preparation (#555)
Apply the formatter to src/features/debugger/java.ts, which failed the format check.
2026-10-31 15:33:35 +08:00
wangxgaandwangxga 1e9cf1c447 fix(services): show debug session output and state (#540)
A direct debug launch kept its output only in Debug, while Services showed an idle entry or an earlier Run.

- Show the debug execution's console and running, suspended or finished state in Services, reusing the debugger's bounded console through the Run/Debug bridge. Console output does not rebuild the Services tree.
- Keep Maven and Gradle attached launches in their Run console, pairing Run and debug sessions by configuration and process.
- Stop an owning Run and wait for its process to exit before detaching its debuggers, so no new JDWP listener is attached again.
- Point configuration Stop and Rerun at the displayed execution, and group Stop at every live execution.

Fixes #533

Co-authored-by: wangxga <[email protected]>
2026-10-31 15:11:52 +08:00
l0ng-ai 7610fc3f80 fix(java): show debugger startup progress and support cancellation (#546)
Java debug preparation ran before a session existed, so the toolbar and console stayed idle until jdtls had finished, and background console output waited for an animation frame.

- Create the Debug session as soon as the action starts, with progress in its console and a Stop that cancels preparation.
- Share launch preparation between Run and Debug, and move JDK and Lombok detection off the async runtime.
- Flush background console output on a timer as well as on animation frames, so a hidden window still receives it.
- Log how long each launch phase takes.

Refs #530
2026-10-31 15:02:39 +08:00
l0ng-ai a2405c083a fix(java): treat jdtls access restrictions as warnings (#545)
jdtls compiler defaults and Maven's generated project settings classify access restrictions as errors, so on JDK 8 the launch check reported compile errors for code that javac accepts.

- Configure forbidden and discouraged references as warnings through cached preferences and the jdtls settings API, for metadata that jdtls keeps in its own workspace. An existing project `.settings` directory is never touched.
- Real type errors are still reported before launch.

Fixes #536
2026-10-31 14:56:43 +08:00
l0ng-ai ee2585e7e9 fix(deployment): filter servers by selected path coverage (#544)
The server choosers listed every configured server without checking mapping coverage or exclusions, so choosing the wrong one silently skipped the files.

- Add a shared eligibility check, available through desktop IPC and host RPC, that never contacts a server.
- Filter Upload to, Download from, Compare and Delete choosers by it. A selection lists a server if it covers at least one path, and the rest are reported as skipped.
- Explain why the default server cannot take a selection, by file name, instead of doing nothing. Apply the same exclusion rules when a transfer runs.

Fixes #538
2026-10-31 14:53:48 +08:00
l0ng-ai 999eb86408 test(lsp): compare mock server results against canonical paths (#553)
Edits and references report canonical paths, while the mock server fixture used the temporary directory as given. On macOS and Windows those differ.
2026-10-31 14:46:48 +08:00
l0ng-ai 8f85f35ebe test(go): run the suites without a Go toolchain and on Windows (#550)
Keep the program output test out of the Unix-only attribute it landed under,
skip the Go process tests when no toolchain is installed, and list Go variable
names and placeholder values as untranslated text instead of identical
translations.
2026-10-31 14:31:29 +08:00
l0ng-ai 1e8551ad0b fix(cpp): run CMake targets and rename through file aliases once (#539)
C/C++ files in a CMake project ran as standalone compiler commands, so multi-source programs failed to link. Single-file debugging selected CodeLLDB although the language uses lldb-dap. File aliases such as /tmp and /private/tmp could apply one rename edit twice. CMake command names were highlighted as variable definitions.

- Add CMake Configure, Build and CTest configurations, and read the File API codemodel after configuring to discover executable targets and their sources. Context runs select the owning target and build it first. Configuring only runs before the first build, since a configured build directory regenerates by itself.
- Debug C/C++ files and CMake targets with lldb-dap, and map CMake's recorded source root to its canonical path so breakpoints resolve.
- Coalesce file aliases and duplicate edits before preview and application, deduplicate references, and avoid reopening an open document through an alias.
- Replace the CMake syntax mode with command, argument keyword, predefined variable, boolean, number, string, comment and expansion highlighting.
2026-10-31 14:28:27 +08:00
l0ng-ai 27ed0515e8 feat(editor): support transparent native-to-ascii conversion (#537)
Properties documents passed through charset decoding alone, leaving Unicode escapes in the shared document text.

- Add a default-off "Transparent native-to-ascii conversion" setting under the properties encoding and a stable action. Changing it reloads clean properties buffers in place; dirty buffers keep their codec.
- Decode eligible escapes and surrogate pairs when constructing documents, and save non-ASCII as uppercase escapes in pure ASCII. Backslash pairs, malformed escapes, unpaired surrogates and line terminator escapes stay literal, so an edit cannot split a property across lines.
- Use the same codec for search, closed-file readers, Git diffs, staging and partial commits. Clean saves and undone edits preserve the original bytes.

Fixes #519
2026-10-31 14:20:59 +08:00
l0ng-ai ac360b6143 feat(go): add core Go development workflows (#524)
Go settings, execution, debugging and test reporting were disconnected, leaving Go projects without editable build/test fields and workspace maintenance actions.

- Add project build constraints that update gopls in place, and notices on excluded files.
- Add editable Go Build and Go Test configurations with directory, package and file targets, separate tool and program arguments, modules, environment, output selection and build-only execution, and pass the same settings to Delve.
- Decode Go JSON test output into the results view, with subtests, failures, skips and rerun failed. Add gutter targets for benchmarks and generate directives.
- Add module environment and vendoring settings, read go.work members, and offer tidy, download, sync and use from file menus.
- Add file and package generate actions, and format and organize imports through gopls on Save and Reformat.
- Show what a Go program prints in the debug console: Delve's debuggee shares its stdio, which no DAP event carries.
- Keep the default build output out of version control, and keep configurations produced by an earlier run out of the gutter choices.

Fixes #507
Fixes #237
2026-10-31 13:56:06 +08:00
l0ng-aianda-rookie-of-C-language 4418f49c35 test(vcs): keep the shelf path fixture legal on Windows (#541)
Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-10-31 13:23:20 +08:00
l0ng-ai c5cb217c7c fix(search): scope Find in Files to the focused folder (#534)
Find in Files from the keyboard ignored the selected folder and reused the saved scope; only the context menu passed a directory.

Focused trees now publish the selected folder, or a file's parent, as an action context, and Find and Replace in Files use it when no explicit directory is given. This covers the Project view, Commit and Changes, Bookmarks and Scratches. Group rows and editor focus keep the previous behaviour.

Fixes #526
2026-10-31 12:43:42 +08:00
xRookieFightandxRookieFight a553dafa7a feat(window): use the frameless title bar on Linux (#279)
Linux windows used the GTK menu bar and the system title bar, unlike the frameless main toolbar on Windows.

Linux project and Settings windows now turn off decorations, install no GTK menu bar and draw the main menu and caption buttons in the title bar.

Co-authored-by: xRookieFight <[email protected]>
2026-10-31 12:43:01 +08:00
itninganditning cbdc177bbc feat(run): search the console output (#528)
The Run console had no search, so finding a line meant scrolling long output by hand.

Cmd/Ctrl+F, a toolbar button and the context menu open a find bar over the whole buffer with match case, words and regex, a match counter, next and previous navigation, and highlighting that keeps output colours. It expands a fold hiding the current match, and works for test output and stack trace consoles too.

Co-authored-by: itning <[email protected]>
2026-10-31 12:38:54 +08:00
l0ng-ai c0f8557f05 feat(vcs): show shelved changes and act on single files (#531)
The shelf could only unshelve or delete a whole changelist, and could not show what it contained: file selection lost its scope and shelf data was not connected to the diff and patch workflows.

The context menu now has Show Diff, Show Diff in a New Tab, Compare with Local, Create Patch, Copy as Patch to Clipboard and Import Patches, alongside Unshelve, Rename and Delete. Actions on a file node apply to that file only, including unshelve and delete. Diffs use the base saved with the shelf, and quoted, added, deleted and binary files are handled.

Fixes #522
2026-10-31 12:17:41 +08:00
l0ng-ai d4b354732c feat(find): open in-file results in the Find window (#527)
The in-file Find bar had no way to see all matches at once.

Its new More menu adds Open Results in Find Window (Alt+F7 while the bar has focus), which lists the current query's matches as an Occurrences tab in the Find tool window, using the open editor text and the case, word, regex and selection options. The menu also has the multiple-cursor occurrence actions and a Scroll to Results During Typing toggle.

Fixes #523
2026-10-31 12:09:18 +08:00
l0ng-ai 53bebe4836 fix(tomcat): resolve the WAR artifact of the selected Maven module (#529)
A Tomcat run with a WAR from a nested Maven module failed with "The built WAR artifact does not exist": the artifact path query was evaluated for the reactor parent, not the selected module.

The build now resolves the selected POM's effective identity first, using the same options and environment as the build, and asks for that project's artifact. Custom outputs, .mvn settings and root modules are covered, Gradle WAR manifests are written as UTF-8, and Tomcat deployment errors now name the checked path.

Fixes #515
2026-10-31 12:08:27 +08:00
l0ng-ai 290abd303e feat(deployment): deploy to a local or mounted folder (#521)
Deployment only supported SFTP, so a web root on the same machine or on a mounted share still needed an SSH server.

Servers now have a type: SFTP or Local or mounted folder. A local server has a folder and optional web URL and shares upload on save, upload and download, delete, compare and sync, excludes, mappings and the Remote Host view with SFTP through one backend filesystem. Copies never read the whole file into memory, a file is never copied onto itself, and Test Connection checks the folder exists and is writable. Existing servers stay SFTP.

Fixes #467
2026-10-31 12:01:54 +08:00
l0ng-ai bde19959b9 feat(settings): import and export editor color schemes (#518)
The Color Scheme page only offered built-in schemes, so a scheme from another IDE could not be used.

Import reads .icls files into named custom schemes in user settings, reports applied and skipped values, and rejects malformed files. Export writes the current scheme, replacing an existing file, and custom schemes can be deleted. Colours are validated before reaching a stylesheet, and project settings cannot define schemes. The Appearance selector and Quick Switch list custom schemes too.

Fixes #513
2026-10-31 11:41:39 +08:00
l0ng-ai 92cbdd0cb8 test(host): open the file for writing before setting its time (#525)
Setting a file's modified time needs a writable handle on Windows, so the JVM icon
cache check in the RPC test failed there.
2026-10-31 11:29:26 +08:00
l0ng-ai 8fe09eba7f test(debugger): pin the module in the Java separator tests (#514)
Java launch now resolves the module of a main class before starting, so these tests
reached the unmocked file search. They only cover path separators, so set the module.
2026-10-31 10:44:36 +08:00
l0ng-ai 9d670e6262 feat(project-tree): enrich JVM project view icons (#512)
Project rows only showed generic folder and filename icons, so JVM modules, packages and declarations looked alike.

Rows now show Maven and Gradle project and module icons, packages, Spring configuration and Boot application classes, runnable classes, test classes, Java and Kotlin declaration types, and generated, compiled and javadoc output folders. Editor tabs, Goto File and Recent Files use the same resolver. Types come from a bounded syntax parse of visible files, served locally and over remote RPC.

Spring stereotypes keep their ordinary type icons, and .class files stay binary.

Fixes #347
2026-10-31 10:40:24 +08:00
arookieofcandl0ng-ai 504d8592d2 fix(tools): include the Windows user PATH agents and tools are resolved on (#499)
* fix(tools): include the Windows user PATH agents and tools are resolved on

On Windows the login-shell environment is never read (it is a Unix-only path),
so a process started before the user PATH was edited — or one that simply does
not inherit it — resolves agent executables and language-server tools on the
machine PATH alone. A tool installed for the user (npm -g, \dotnet tool
install -g\, a vendor installer) is then reported as not on PATH.

Read the user's own PATH from HKCU\\Environment (expanding REG_EXPAND_SZ
entries) and append its directories after the inherited ones in both the tool
search path and the native agent lookup, so user installs resolve without
shadowing the inherited order.

Fixes #330.

* fix(tools): use div_ceil for the registry buffer size

* fix(tools): add the Windows user PATH to the process PATH at startup

Lookups alone left agents reported as not installed, and programs started from
user PATH entries could not find their own dependencies.

---------

Co-authored-by: l0ng-ai <[email protected]>
2026-10-31 10:15:04 +08:00
l0ng-ai 4f1feebbd0 fix(tools): install rust-analyzer without rustup (#490)
* fix(tools): install rust-analyzer without rustup

* fix(tools): reinstall a managed rust-analyzer with the installer that made it

Once rustup appeared, Reinstall ran rustup while the Open VSX copy stayed in use.
2026-10-31 10:14:55 +08:00
l0ng-ai 4a620122e8 fix(java): use the project host classpath separator (#492) 2026-10-31 10:00:01 +08:00
l0ng-ai f9a7c68897 fix(i18n): translate the Git log filter button
Adds the missing Chinese text for the More Filters button, which the text catalog test requires.
2026-10-31 09:38:18 +08:00
l0ng-ai 28baa4a57a fix(app): show update progress in its notification
The welcome screen has no status bar, so clicking Install and Restart there showed nothing until the download ended. The notification now stays with the download progress and Cancel, and a cancelled update says so.

Fixes #491
2026-10-31 09:37:46 +08:00
l0ng-ai a5fb30e04f chore: v0.1.29 2026-10-31 09:25:01 +08:00
l0ng-ai f2b8db548f fix(vcs): fit the Git log toolbar and rows to narrow panes
- Filters that do not fit the toolbar move behind a chevron that lists
  them, so the action buttons on the right stay visible.
- Commits on the current branch are tinted unless the log is filtered to
  that branch, instead of dimming the others only when other branches
  happen to be loaded.
- The HEAD commit is drawn as a ring around a dot.
- A wide graph no longer paints the subject and references over the
  author column; references shrink down to their icon.
- Scrollbar thumbs keep a minimum length on long lists.
2026-10-31 09:20:36 +08:00
l0ng-ai 89475f4fed feat(github): show relative dates in pull requests
Pull request lists, timelines, commits and replies show how long ago
something happened ("just now", "8 hours ago", "yesterday", "3 days ago")
and the date after 30 days ("on Oct 12"). Hovering a timeline date shows
the full date and time. With pretty formatting off, the plain date and time
are shown.
2026-10-31 09:07:22 +08:00
l0ng-ai 2bc5e8d0c8 feat(vcs): list Merge and Rebase in the Project view Git menu
Adds Merge… and Rebase… after Fetch in the Git submenu of the Project view context menu.
2026-10-31 08:56:37 +08:00
l0ng-ai 79e4233c56 fix(vcs): push to an upstream with another name
A plain git push refuses under push.default=simple when the tracked branch is named differently from the local one. Push now names the tracked ref explicitly in that case, which covers the Push dialog and Commit and Push.
2026-10-31 08:56:02 +08:00
l0ng-ai df6ed5ccba feat(editor): paste and drop images into Markdown files (#489)
Pasting or dropping an image into a Markdown editor saves it beside the document in `assets/` and inserts a URL-encoded relative link as one undo step, in local and remote projects. Existing names get numbered suffixes; the folder and clipboard name pattern are in Settings › Editor › General. Non-Markdown editors, text paste and other dropped files keep their behavior.

Fixes #308
2026-10-31 08:48:10 +08:00
l0ng-ai f592ff5b05 fix(build): show the Build tool window when explicit builds start (#487)
Explicit builds now show the Build tool window without taking focus, so progress is visible from the start. Before launch builds still leave it closed unless they fail.

Fixes #451
2026-10-31 08:31:10 +08:00
l0ng-ai c0c9553550 feat(github): show every open pull request by default (#506)
The list opened filtered to pull requests assigned to the user, which is empty on most repositories. A search already used in the project is still restored.
2026-10-31 08:04:13 +08:00
imacross 059fd8a69b feat(github): pull request tool window, timeline and review flow (#477)
Replaces the Pull Requests tool window with a full review workflow on top of gh's authentication.

- List with search, quick filters and paging; a tab per pull request and a New Pull Request tab.
- Details: commit picker, changes tree with viewed state, CI and reviewer status, merge, close and reopen.
- Timeline editor: comments, review threads, reactions and suggested changes.
- Review in the diff viewer and, for the checked-out branch, in the editor (review mode).
- New generic vcs extension points: registerDiffViewerProvider, lineInlays, reviewGutter, branch widget extensions; new vcs_merge_base and gh_api commands.
- CSP: img-src allows https://avatars.githubusercontent.com for avatars.

Fixed while testing against a real repository: remotes behind an SSH host alias, mergeability stuck on UNKNOWN, Apply Locally staying disabled after a checkout, relative links navigating the app's page, and review inlays wider than the pane.

Co-authored-by: imacross <[email protected]>
2026-10-31 07:50:43 +08:00
arookieofcanda-rookie-of-C-language 696377acc2 fix(lsp): publish a failed start's status before waking waiters (#505)
A request waiting for a language server woke when the start failed or the instance closed, then read the status while it still said `Starting`, so its error could be `jdtls is not ready (Starting)` instead of the crash. The status is now published first, for launch failures, crashes and giving up. A close during shutdown also wakes waiters, so they do not wait out their timeout. This fixes the crash test that was failing on `main`.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-10-31 05:48:59 +08:00
Ning YuanLin 52834088fe fix(tree): preserve horizontal scroll when expanding unrelated nodes (#504)
The Project view kept its widest row only until the rows changed, so expanding an unrelated folder while the wide row was out of the virtual window shrank the scroll range and moved the view back to the left edge. Row widths are now kept by row key, and a width goes away only when its row is removed or collapsed, or is measured narrower.

Co-authored-by: NingYuanLin <[email protected]>
2026-10-31 05:20:39 +08:00
arookieofcanda-rookie-of-C-language c3d29dc842 fix(sdk): list every Node on the search path, not only the first one (#502)
Detection stopped at the first `node` on the search path. Over SSH the minimal `PATH` puts `/usr/bin` ahead of `~/.local/bin`, so a Node installed there was never listed and refreshing could not help. Every `node` on the search path is now listed, and installs keep nvm, Volta or fnm as their source.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-10-31 04:22:58 +08:00
l0ng-ai 8dc1ee062f fix(java): show why jdtls crashed when a run needs it
A run that needed a crashed jdtls reported only "jdtls is not ready (Crashed)",
although the server's exit code and last output were already recorded for its
status. The run console and notification now show them.
2026-10-31 03:49:20 +08:00
l0ng-ai fcc36d67e7 fix(project-tree): refresh expanded excluded folders when files change
The watcher drops changes inside excluded and ignored folders so the index and
version control don't react to build output. That also kept an expanded `target`
from showing files a build added or removed. Those folders are now reported in
their own event, which only the Project view handles, and only for folders it has
loaded. Changes to existing files aren't reported, since the listing stays the same.
2026-10-31 03:21:59 +08:00
arookieofcanda-rookie-of-C-language 00108a669e fix(tools): find global .NET tools such as fsautocomplete (#497)
Global .NET tools install to `~/.dotnet/tools`, which the tool search did not look at, so an fsautocomplete installed the way its hint suggests was reported as missing unless that directory was on `PATH`. The directory is now searched after `PATH` and before the system directories.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-10-31 03:07:12 +08:00
l0ng-ai c08b0f543e feat(vim): read other files through source in the rc file
`source ~/.vimrc` was reported as unsupported, so mappings kept in a shared vimrc had no effect. `source` now reads that file in place by the same rules as the rc file, sharing the leader. Lines it cannot use are listed with the file they came from, and nothing in a sourced file is executed. Unreadable files and files that source themselves are reported.
2026-10-31 03:06:29 +08:00
l0ng-ai 7f9dda50b6 fix(fsharp): load the project when fsautocomplete starts
fsautocomplete waits for the client to load the project through its own
`fsharp/workspaceLoad` request, which the IDE never sent. The server showed as
running, but F# files got no completion or diagnostics. Turning on
`AutomaticWorkspaceInit` makes it load the project itself.
2026-10-31 02:53:00 +08:00
arookieofcanda-rookie-of-C-language 98c8c55b9e fix(project-tree): scroll horizontally when rows are wider than the pane (#495)
Project view rows were locked to the pane width, so deeply nested files and long names were clipped with no way to read the rest. Rows now size to their content and the view scrolls horizontally.

Rows are virtualised, so the scroll range keeps the widest row until the rows change. Otherwise scrolling past a wide row would remove the scrollbar and reset the horizontal position. Other trees opt out and keep clipping long labels.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-10-31 01:55:33 +08:00
l0ng-ai 8fabf054ef fix(lsp): offer auto-imports from unopened files in projects without a tsconfig (#496)
Without a tsconfig.json or jsconfig.json, tsserver's inferred project holds only
open files and what they import, so completion never offered exports from other
files. Register the project's sources with tsserver as an external project,
using the options an inferred project assumes, so no file is written to disk.

Registration needs `typescript.tsserverRequest`. Projects with a configuration,
including one in a subfolder, stay as they were, and so do trees over 10,000
sources or 16 MiB of JavaScript, since tsserver disables its language service
past 20 MiB. Created and deleted files update the project, and unopened files
stay out of Problems.
2026-10-31 01:49:00 +08:00
wangxgaandwangxga a3cd99db1f feat(services): reorder services within a run configuration type (#345)
Services followed configuration detection order, so frequently used services could not be kept together. Services can now be dragged before or after a sibling, with an insertion indicator and edge scrolling, or moved with Move Up/Down from the toolbar and context menu.

The order is stored per project location and type, separately from configuration definitions. New configurations append, windows stay in sync, and a failed save keeps the order for the session with a warning. Sorting in Tree is opt-in, so other trees are unchanged.

Co-authored-by: wangxga <[email protected]>
2026-10-31 01:28:41 +08:00
l0ng-ai 0660f3c020 fix(java): resolve application classpaths independently of working directory (#493)
Run and Debug passed the configuration working directory to java_resolve_launch as the module, so changing the working directory also changed which jdtls project supplied the classpath and failed with "references non-existing project". Java application configurations now store their module at detection and creation, keep it when the working directory changes, and clear it when the main class changes. Older saved configurations resolve it from the main class source file. The Kotlin debugger check also uses the module directory.

Fixes #435
2026-10-31 00:59:22 +08:00
arookieofcanda-rookie-of-C-language 2754cf327f feat(editor): drag an editor tab out to a standalone window (#470)
Dragging an editor tab past the window edge, or choosing Open in New Window from its menu, now opens the file in its own window and closes the tab here. A text file is saved first and a custom document runs its Save / Do not Save / Cancel guard once; if saving or opening fails, the tab stays. The new window is forced even when the project is already open elsewhere, and a file outside the project opens as LightEdit.

Dragging out tool windows is not covered. Refs #454

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-10-31 00:57:53 +08:00
DIMAS FEBRIYANTO 986b0ade58 feat(agents): group model picker by provider and improve search (#486)
Agents can report model ids with nested provider paths, which made long rows that lost their provider context. The model picker now shows the short name under a provider heading with the remaining path as detail. Searchable Select matches several tokens across label, detail and hidden keywords, with fuzzy matching, and keeps a heading while one of its items matches.

Select now has an explicit section option, separate from disabled, so a heading can never be selected and a disabled option is not rendered as a heading. Existing section rows in provider options and Find in Files scopes use it. Keyboard navigation skips headings in filtered lists.

Co-authored-by: dimassfeb-09 <[email protected]>
2026-10-31 00:55:29 +08:00
arookieofcanda-rookie-of-C-language 7aad01b035 fix(python): open the interpreter popup without a stale visibility flag (#485)
The Python interpreter widget published its shown flag from an effect that ran after the button rendered. A click landing in that window read it as false and opened Settings instead of the menu, which also made pythonStatus.test.tsx fail intermittently. The widget now keeps its resolved status in the store, and the render and the action read the same state.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-10-31 00:30:12 +08:00
Mykhailo Smolinskyiandnntndfrk 2613e61ebf feat(angular): highlight directive microsyntax and fold control-flow blocks (#458)
Angular templates left parts of their syntax uncoloured or misparsed, and control-flow blocks could not be folded. Binding names such as (keydown.escape) and [style.width.%] now take one colour and parse; structural directive values are parsed as microsyntax rather than JavaScript statements; block keywords are keywords only where one can appear; ?. is styled like . in templates and in JS and TS files; @if, @for, @switch, @defer and related blocks fold from { to } with the closing brace left visible.

Co-authored-by: nntndfrk <[email protected]>
2026-10-31 00:22:26 +08:00
l0ng-ai 971760f858 fix(debugger): allow cancelling Java launch preparation (#484)
Java debugging resolved the launch and built the workspace before a debug session existed, so during a jdtls build of up to 20 minutes there was no way to stop. A starting notification with Stop now appears before preparation begins, using the same pattern as waiting for a remote JVM. Stop cancels the resolve request, and a late response neither starts a session nor reports an error. Real resolve failures report as before.
2026-10-31 00:08:16 +08:00
l0ng-ai ad5f827673 fix(vcs): retry safe commands on brief index lock conflicts (#483)
The stat-refresh write-back in status holds .git/index.lock for a moment, so an IDE branch switch landing then failed with "Unable to create index.lock". Non-streaming git commands now retry every 50 ms for up to two seconds, only on the exact lock failure and only for switch, add, rm and reset, which take the lock before changing anything. Commit, update-index, merge, rebase, stash and path checkout or restore are left out because a lock error there can follow partial work. A lock that persists returns the original error and is never removed.
2026-10-31 00:05:15 +08:00
arookieofcanda-rookie-of-C-language dd8c8b8993 feat(run): searchable multi-add member picker for Compound configurations (#452)
Adding members to a Compound configuration went through a plain dropdown that closed after each pick and could not be searched. It is now a searchable picker that stays open, so several configurations can be added in a row; Enter adds the highlighted row, and Esc or an outside click closes it. Member rows are left-aligned because the label, not the icon, takes the free space. Members already added, the compound itself and candidates that would close a cycle stay excluded.

Closes #329

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-10-30 23:59:46 +08:00
arookieofcanda-rookie-of-C-language 35ba957893 fix(vcs): rollback, stage and unstage from a changelist's header menu (#480)
The context menu of a change list's header offered only commit, list management, shelve and patch actions, so rolling back or staging a whole list meant selecting every entry first. Rollback, Stage and Unstage now act on exactly that list's files, with the same enablement as file and directory nodes: Stage is disabled when nothing is unstaged, Unstage when nothing is staged.

Closes #473

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-10-30 23:48:49 +08:00
arookieofcanda-rookie-of-C-language e272131823 fix(keymap): list the project tab actions in the VS Code presets (#482)
#448 added window.nextTab and window.previousTab to the IntelliJ presets only, which failed the check that the VS Code presets carry every action id. Both are now listed unbound, the same way as window.next and window.previous.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-10-30 23:43:33 +08:00
arookieofcanda-rookie-of-C-language d5fe771eed fix(lsp): allow long jdtls workspace builds and make Java launch resolution cancellable (#449)
java/buildWorkspace builds the whole imported jdtls workspace, and its 5-minute cap failed launches in large multi-module projects. The budget is now 20 minutes, and the timeout error names it so a real stall can be told apart.

Waiting that long needs a way out, so java_resolve_launch now takes a RequestId and is cancellable locally and over remote. The token reaches the jdtls ready and import waits, the requests (sending $/cancelRequest), and the external build. Stopping a run session during this phase cancels silently and does not launch. Starting a debug session passes a request id but has no stop path yet.

Closes #373

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-10-30 23:02:20 +08:00
arookieofcanda-rookie-of-C-language 14c7821cf4 fix(vcs): stop status write-back from restoring a pre-switch index (#476)
A status run refreshes stat info in the index and writes the result back, a read-modify-write with no check that the file changed in between. A branch switch rewriting thousands of files gives a watcher-triggered status run time to land its stale snapshot over the new index, leaving the old branch staged: phantom MM/AD entries, empty diffs, and commits of the wrong tree.

The index identity is now captured before status reads the index, and the write-back is skipped if it no longer matches. This also covers git add, commit and stash run from a terminal. A false positive only drops one stat refresh.

Closes #471

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-10-30 22:56:29 +08:00
arookieofcanda-rookie-of-C-language 73dd9fdf65 fix(vcs): chunk path arguments so large rollbacks fit the Windows command line (#468)
Rollback, stage, unstage, clean, reset and shelve passed every path to one git invocation, so a few thousand files exceeded the 32,767-character CreateProcess limit and failed with os error 206 before git ran. Path-taking commands now run in batches sized to the platform limit, with room reserved for the quotes Windows adds around arguments containing spaces. Commit and stash are not chunked, since splitting them would change their result.

Covers the sibling call sites in patch.rs and shelf.rs. Not verified on a real Windows machine.

Closes #457

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-10-30 22:55:12 +08:00
arookieofcanda-rookie-of-C-language 091c25351b feat(keymap): bind Ctrl+Alt+click to Go to Implementation on Windows and Linux (#469)
The Windows/Linux keymap only bound the Ctrl+Alt+B stroke to navigate.implementation, so the click gesture that #405 made remappable did nothing there. Adds Ctrl+Alt+Button1 alongside the existing macOS Meta+Alt+Button1 binding; other Alt-click gestures are unaffected because column mode and multi-caret need different modifiers.

Closes #365

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-10-30 22:51:49 +08:00
l0ng-ai 81fe384f4f chore: v0.1.28 2026-10-30 22:49:07 +08:00
l0ng-ai 01defdefe4 feat: multi-project workspaces and project window groups (#448)
Workspaces open several local project folders in one IDE session (File › New / Open / Manage Workspace…, or Attach when opening a project, which creates a workspace automatically for a plain project window). Member folders are the Project view roots and take part in indexing, Find in Files, Git, language servers, run configurations and builds; agent tasks choose which member they work in. Window › Merge All Project Windows groups project windows into one window with project tabs (switch, reorder, close, move to a new window), restored on reopen.

Closes #241
Closes #198
Closes #286
2026-10-30 22:45:42 +08:00
l0ng-ai 5bab140968 fix(lsp): report incomplete rename and caller searches, make them cancellable (#472)
Rename and incoming calls open the project files that mention the name, like
Find Usages, but dropped the reason a search could miss usages and could not be
cancelled. A rename past the 300-file limit changed part of the usages without
saying so.

- Rename and call hierarchy return why the result may be incomplete and take a
  request id for cancel_request.
- A rename that may have missed usages always opens the preview, with a warning.
- The Callers tree shows the note above the tree or on the expanded node.
- Candidates too large or not readable as text are reported as skipped.
- A newer rename or hierarchy query cancels the one it replaces.
2026-10-30 22:08:10 +08:00
l0ng-ai 33d8d87ca2 feat(welcome): add Remote Development to the empty welcome page (#460)
The empty-state landing offered only Open and Clone Repository; the
Remote Development action was reachable only once recent projects existed.
2026-10-30 21:06:55 +08:00
huaqiang yang 669595e045 feat(keymap): support modified left-click shortcuts (#405)
The mouse shortcut recorder accepted only side buttons, so a modified left click such as Option+Command+click couldn't be assigned. Left clicks with Ctrl, Alt or Command now record and dispatch as mouse shortcuts (plain and Shift-only clicks, middle and right clicks keep their meaning; unbound combinations behave as before). In editors the caret moves to the clicked position before the action runs, so navigation targets the symbol under the pointer. The macOS keymap binds Option+Command+click to Go to Implementation(s).

Co-authored-by: huaqiangyang <[email protected]>
2026-10-30 20:52:27 +08:00
l0ng-ai 9c31a27e30 feat(agents): task actions in the context menu, Changes toggle at the header's end (#456) 2026-10-30 20:48:16 +08:00
arookieofcanda-rookie-of-C-language f265e636e9 feat(agents): show the Changes pane only when it's useful, toggled from the task header (#421)
The Changes pane took a fixed column next to every agent conversation, including tasks that changed nothing. It is now shown while the task has changes (so it appears when the agent first edits a file) and toggled from a Changes button with the file count in the task header; once toggled, the choice is kept per task.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-10-30 20:25:16 +08:00
l0ng-ai 47bd21e315 feat(agents): show images in sent messages (#439)
Images sent to an agent were dropped from the conversation: the user message kept only text and file chips. User entries now record their images and show them as thumbnails that open full size; composer thumbnails open the same preview. Editing a sent message keeps its images (removable) and rerunning from it sends them again.
2026-10-30 19:48:55 +08:00
l0ng-ai a5b7c154e1 feat(viewers): smooth zoom and drag-to-pan for images and Mermaid diagrams (#431)
The image viewer zoomed in fixed steps around the top-left and scrolled to move. It now zooms smoothly around the pointer with Cmd/Ctrl+wheel or pinch, pans with drag or the wheel, and the zoom actions keep the view centred. Mermaid diagrams in the Markdown preview zoom the same way with Cmd/Ctrl+wheel, pan by dragging once zoomed in, and reset on double-click; plain wheel still scrolls the preview.
2026-10-30 19:46:57 +08:00
l0ng-ai b2c183dbd4 fix(php): document language constructs and clarify rename failures (#354)
PHP language constructs such as empty and isset had no completion documentation, hover or quick documentation because the language server reports them as plain keywords. They now get built-in documentation in completion, hover, quick documentation and Go to Declaration (which shows the docs instead of failing), without matching strings, comments, methods or other languages. A refused syntax-based rename now states why the language server couldn't rename first.

Closes #340
Refs #221
2026-10-30 19:46:08 +08:00
l0ng-ai bc70ec7028 feat(agents): new tasks default to the bypass permission mode (#444)
New agent tasks start in the agent's mode that approves everything without asking (bypassPermissions, yolo, full-access…) or with approvals bypassed, unless the New Task page sets something else.
2026-10-30 19:44:43 +08:00
l0ng-ai 195356c4b1 fix(agents): keep text selection inside the conversation (#437)
A drag that started in the conversation could extend the selection into neighbouring panes, and one dragged up from another pane spilled into the conversation. While a drag starts in the conversation everything outside it is unselectable, and a selection crossing the conversation's edge is clamped to it from either direction.
2026-10-30 19:44:28 +08:00
arookieofcanda-rookie-of-C-language 67e3c60db8 feat(vcs): blame each merge side pane on the branch it belongs to (#391)
The merge view blamed both side panes against HEAD, so lines that only exist on the merged branch showed as Not Committed Yet. vcs_blame takes an optional revision (validated, passed to git blame --contents -); the merge view blames Yours on HEAD and Theirs on MERGE_HEAD (swapped during a rebase).

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-10-30 19:42:05 +08:00
arookieofcanda-rookie-of-C-language f87d153caf feat(vcs): resizable Push dialog with collapsible repository groups (#407)
The Push dialog had a fixed size and listed every repository's outgoing commits flat. Dialog gains a resizable option (corner grip, clamped to the viewport, numeric width as the minimum), both Push dialogs use it with the commit list filling the body, and the multi-repository Push dialog shows each repository as a collapsible group (clicking the row still toggles its checkbox).

Fixes #397

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-10-30 19:37:56 +08:00
arookieofcanda-rookie-of-C-language 552a43a0a1 fix(tools): join an install already in progress instead of rejecting it (#420)
Settings opens in its own window with its own state, so reinstalling a tool from a reopened Settings while an earlier install was still running failed with 'already being installed' and showed no progress or Cancel. A second request for the same tool now joins the running install: it receives its live log and result, and cancelling from either request stops it. JDK downloads and uninstalls keep failing fast when busy.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-10-30 19:36:32 +08:00
arookieofcanda-rookie-of-C-language 2b1add64db fix(maven): refresh jdtls project configurations after a Maven reload (#446)
Reload All Maven Projects re-synced only the IDE's build model; jdtls kept its old import, so artifacts that became resolvable meanwhile (mvn install into ~/.m2) stayed missing until its workspace was cleared. After a successful Maven sync, jdtls now receives java/projectConfigurationsUpdate for every project's pom.xml (only if it is already running; failures are logged and don't fail the reload).

Fixes #436

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-10-30 19:33:24 +08:00
arookieofcanda-rookie-of-C-language d277fc1522 fix(services): keep non-Spring-Boot run configurations in the Services tree (#443)
Services listed run configurations only while their session was alive (or when their whole type was added), so after a restart only Spring Boot configurations were back. Configurations that ran are now remembered per project and listed under their type, dimmed until they run again; ids of deleted configurations are ignored.

Fixes #388

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-10-30 19:29:35 +08:00
arookieofcanda-rookie-of-C-language b983e3d0b2 fix(vfs): report excluded and ignored directories themselves; keep lane marks aligned while scrolling (#412)
The file watcher dropped events for a directory that matched an exclude or .gitignore rule together with its contents, so a target/ or node_modules/ created by a build never appeared in the Project view. The directory's own create/remove now passes through (it shows as excluded/ignored) while its contents stay filtered. Scrollbar lane marks also recompute on viewport changes, since unmeasured line heights shift as more of the document is measured.

Refs #383

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-10-30 19:28:35 +08:00
arookieofcanda-rookie-of-C-language ce981e4fd6 fix(workbench): resize handles for the frameless Windows window (#414)
The frameless Windows window has no native resize borders, so it couldn't be resized from its edges. Invisible hit areas along the edges and corners start a native resize drag (minimum size and snapping handled by the system); they're omitted while the window is maximized or full screen. Not verified on a real Windows machine.

Refs #410

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-10-30 19:27:16 +08:00
arookieofcanda-rookie-of-C-language 19b96b60a4 feat(vcs): offer Compare With… in the editor and tab menus, and outside the desktop app (#433)
Compare With… was only in the Project view menu and needed the OS file dialog. The editor context menu (with Compare with Clipboard) and the editor tab menu now offer it too, and outside the desktop app (remote development, browser) a browser file picker supplies the file, compared as text.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-10-30 19:25:41 +08:00
l0ng-ai 9295a8f78a fix(keymap): drop the duplicate run.updateApplication entry in the VS Code macOS keymap 2026-10-30 19:25:10 +08:00
arookieofcanda-rookie-of-C-language f507f55d16 fix(maven): resolve bare launchers on the IDE search path (#422)
Builds and run configurations started a bare mvn / gradle through the app's own PATH, which can be minimal when the app isn't launched from a shell, so launchers installed by mise, asdf or Homebrew weren't found. Bare names now resolve through the configuration's PATH override or the IDE search path (login shell PATH plus version-manager shims) on every platform, for build processes and run configurations alike.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-10-30 19:23:31 +08:00
arookieofcanda-rookie-of-C-language 86cf348310 feat(terminal): add a Scroll to End toggle to the terminal pane (#395)
Adds a Scroll to End button to the terminal pane (shown on hover/focus). It reflects whether the viewport follows new output, turns off when you scroll up and back on at the bottom, and clicking it jumps to the end.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-10-30 19:23:22 +08:00
CS-Bob a1294598a5 fix(vcs): sync Git Log file tree with diff navigation (#445)
Moving between files in a commit's diff (F7 / next file) left the Git Log changed-files tree on the previous file. The tree now follows the active diff when it belongs to this commit, expanding and revealing the file without taking focus or reopening the diff.

Co-authored-by: CS-Bob <[email protected]>
2026-10-30 19:20:48 +08:00
arookieofcanda-rookie-of-C-language 6669df8313 feat(maven): Maven settings entry in the tool window (#430)
Adds a Maven Settings… action (toolbar button in the Maven tool window, Find Action) that opens Settings › Build, Execution, Deployment › Maven.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-10-30 19:20:27 +08:00
arookieofcanda-rookie-of-C-language 83eb1ce2ba fix(maven): list profiles inherited from parent POMs (#425)
A module can activate its parents' profiles with -P, but the Maven model listed only the profiles declared in the module's own POM. Profiles are now collected from the POM and its ancestors (with their activeByDefault state).

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-10-30 19:20:06 +08:00
arookieofcanda-rookie-of-C-language 6356e85671 fix(agent): run tasks in folders that are not git repositories (#402)
Agent tasks failed in projects without git because change tracking assumed a repository. Outside a repository there are no changes to compare; worktree isolation is hidden and the task runs in the local checkout, and asking for a worktree there returns a clear error.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-10-30 19:17:02 +08:00
arookieofcanda-rookie-of-C-language ed6092a4e9 fix(run): keep console output flowing in background tabs (#400)
Run console output is flushed on requestAnimationFrame, which stops while the page is hidden, so output from a run in a hidden window piled up and replayed when it was shown again. Pending flushes now move to a 100 ms timeout while hidden and back to rAF when visible.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-10-30 19:16:42 +08:00
arookieofcanda-rookie-of-C-language 12f676e0c0 fix(vcs): keep diff line backgrounds translucent so selection shows through (#416)
Opaque diff line and word backgrounds painted over CodeMirror's selection layer, so selected text in a diff was invisible. Text-area diff colours are now translucent mixes of the same tokens (gutters stay opaque), and the left pane's changed text uses the deleted colour.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-10-30 19:14:26 +08:00
arookieofcanda-rookie-of-C-language 1ee22dbf35 fix(vcs): support SHA-256 object-format git repositories (#442)
Repositories created with --object-format=sha256 failed to open because gix was built with SHA-1 only; ide-vcs and ide-vfs now enable gix's sha256 feature. Also adds the missing run.updateApplication entry to the VS Code macOS keymap.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-10-30 19:14:17 +08:00
arookieofcanda-rookie-of-C-language 7ba75c814c fix(maven): resolve the configured Maven launcher for saved goal configurations (#424)
Goal configurations saved from the Maven tool window kept the literal mvn / mvn.cmd, which could not start when Maven lives in a configured home or a version manager's shims. Saved configurations with a bare mvn launcher now take the configured Maven; custom paths and the wrapper are left alone.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-10-30 19:04:06 +08:00
arookieofcanda-rookie-of-C-language ae110f73be fix(maven): keep the fresh POM parse when the sync cache is outdated (#427)
When the build files changed since the last sync, the cached effective-POM plugins and resolved dependencies replaced what the POMs declare now, so a just-added dependency never appeared until Reload. The cache is now applied only when the build files are unchanged; a non-verbose cache still marks the model stale without discarding its data.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-10-30 19:01:06 +08:00
arookieofcanda-rookie-of-C-language c3252d43c3 fix(vcs): hide tracked changes of ignored names from Git status (#408)
Names in Settings › Ignored files and folders were hidden only when untracked; their tracked or staged changes still showed in the change list. They are now filtered too, except conflicts, so a merge can't stall on a hidden file.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-10-30 19:00:42 +08:00
DIMAS FEBRIYANTO 924446279b fix(agents): let collapsed Thinking blocks toggle on WebKit (#393)
content-visibility: auto on <details> kept collapsed Thinking blocks from expanding in WebKit; details elements are now excluded from the off-screen layout skip.

Co-authored-by: dimassfeb-09 <[email protected]>
2026-10-30 19:00:34 +08:00
l0ng-ai f88b5d6f8c fix(debugger): bound the Debug console by real lines and coalesce output events (#429)
Follow-up to #385. A single output event can carry thousands of lines, which became one console entry and escaped the line cap; output is now split at real line breaks (partial lines still merge across events) so the 50,000-line bound applies to physical lines. The host also coalesces debug output per session for up to 50 ms / 64 KB before it reaches desktop or remote IPC, flushing before any other session event so stops and exits never overtake earlier output.

Verified on macOS with a Java program printing ~480 lines/s for over a minute: the console stays responsive, follows the tail, and pause/step work.

Fixes #377
2026-10-30 18:38:31 +08:00
arookieofcanda-rookie-of-C-language ea53e4f6fa fix(debugger): cap and virtualise the Debug console, batch output events (#385)
Each DAP output event copied the whole console array and re-rendered every buffered line, so a debuggee writing continuously to stdout froze the window after tens of thousands of lines. Output is now queued and applied once per animation frame (merged per session and category), the buffer is capped (50,000 entries, plus the console buffer-size setting), and the Debug console renders only the visible rows.

Refs #377

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-10-30 18:18:01 +08:00
l0ng-ai 379a52d6e7 feat(debugger): Kotlin breakpoints through the Kotlin Debug Adapter (#434)
Kotlin line breakpoints never verified through java-debug because jdtls cannot resolve classes for .kt files. Kotlin Application configurations and JDWP attach for modules with Kotlin sources now use the official Kotlin Debug Adapter (installable from the debugger prompt, running on the language-server JDK); pure Java keeps java-debug.

The adapter resolves classes from source paths, so sources whose directories don't match their package are mirrored package-aligned outside the project (symlinks, copies where links fail) and paths are mapped back for breakpoints and stack frames; files that can't be mirrored are skipped rather than failing the session. Kotlin applications start with JDWP suspend=y and a session-owned JDWP relay holds the adapter's early resume until attach completes, so breakpoints are installed before the program runs.

Limitations (docs/kotlin-debugging.md): @file:JvmName facades are not resolved; package/source-root changes need a new session. Not verified on a real Windows machine.

Fixes #374
2026-10-30 18:15:42 +08:00
l0ng-ai 92b3ae508c feat(run): Tomcat local run configuration (#392)
Adds Tomcat Server › Local run configurations for WAR applications: installation validation and version detection, Maven/Gradle WAR artifact discovery and build before launch, WAR file or exploded-directory deployment, context path and HTTP port, project JDK or a chosen JRE, VM options, environment variables and opening the browser.

Each configuration runs with its own CATALINA_BASE in the project cache (HTTP, shutdown and local JMX ports configured there); Stop requests a clean shutdown before the usual process timeout. Debug attaches the Java debugger over JDWP. Update Application (Cmd+F10) updates resources, classes and resources with HotSwap, redeploys or restarts the server. Configurations are grouped under Tomcat Server in the configuration tree and Services.

Not verified on a real Windows machine; command construction is unit tested.

Fixes #205
2026-10-30 18:06:11 +08:00
l0ng-ai d837cb1acb fix(console): Ctrl+C copies selected output in consoles and terminals (#381)
On Windows and Linux, Ctrl+C in the Run/Debug consoles and other DOM output views did nothing because no handler copied the selection. Each output view (Run and Debug consoles, Build output and message detail, Git console, database output, Docker logs) now copies its own selection on Ctrl+C / Ctrl+Insert (Cmd+C on macOS) through a shared helper, leaving views with their own copy handling (database grid, Problems) untouched. In terminals, Ctrl+C copies a selection on Windows and Linux and otherwise interrupts; on macOS it always interrupts. The console menu shows the host's copy shortcut.

Not verified on a real Windows machine.

Fixes #372
2026-10-30 17:53:31 +08:00
l0ng-ai 932ac379bc feat(editor): highlight string interpolation in Kotlin, Scala, Dart, Groovy, C# and shell (#389)
The CodeMirror stream modes for these languages consumed string templates as one string token. A shared wrapper now splits template delimiters ($name, ${...}, C# {...}) from literal text and tokenizes the embedded code with the language's own mode, keeping nested braces, strings, escapes and multiline state; shell parameter, command and arithmetic expansions get the same treatment. Delimiters use the existing special-brace scope. JS/TS, Python, PHP, Swift and Ruby already tokenized embedded code and are unchanged.

Fixes #379
2026-10-30 17:49:54 +08:00
l0ng-ai fd5908e224 feat(services): multi-selection and batch actions (#386)
The Services tree dropped the shared Tree's selection set and resolved actions for the lead row only. It now keeps the full selection (Ctrl/Cmd-click, Shift-click ranges, Shift+arrows); toolbar and context-menu actions common to the selection run on every selected service, skipping disabled targets and descendants of a selected group. Entries that open a dialog are disabled for a multi-selection.

Fixes #376
2026-10-30 17:48:35 +08:00
l0ng-ai e017154c75 fix(debugger): Java source paths on Windows and breakpoints before session start (#387)
java-debug treated forward-slash Windows drive paths as URIs, so breakpoints never verified; source paths are now sent with native separators and mapped back for stack frames and breakpoint events. Debug sessions also flush every file's pending breakpoint updates before starting, so configurationDone never runs with an empty breakpoint snapshot (all adapters, launch and attach).

Not verified on a real Windows machine; the path conversion is covered by unit tests.

Fixes #380
Refs #374
2026-10-30 17:46:58 +08:00
arookieofcanda-rookie-of-C-language 3655961642 feat(vcs): git UX fixes for history, remotes, changelists, branch diff and merge view (#369)
- Git › Show History is offered for folders, multi-selection and the project root, not just single files.
- The Push dialog lets you pick any configured remote; pushing to another remote keeps the upstream's branch name and shows outgoing commits against that remote's branch.
- The Changes toolbar gains New Changelist (changelists already supported new/edit/delete/set active/move).
- Compare with Current gains Show Diff, opening the whole-project diff between the branch and HEAD.
- Merge view: F7 / Shift+F7 navigate conflicts, the three panes scroll together, and each side pane can be annotated with Git Blame of its own content.
- Windows-only test fixes in ide-lsp and ide-sdk.

Fixes #343
Fixes #310
Fixes #358
Fixes #348
Fixes #320
Refs #317

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-10-30 15:17:11 +08:00
l0ng-ai 545fb93744 feat(editor): expand Replace from the Find bar with a chevron (#378)
The in-editor Find bar gains a chevron at its left that expands and collapses the Replace row (Replace, Replace All, Exclude), replacing the separate Replace icon button. Cmd/Ctrl+F opens the bar collapsed and Cmd/Ctrl+R expanded; the search text, options and focus are kept when toggling. The chevron reports aria-expanded.

Fixes #362
2026-10-30 15:11:35 +08:00
GuoYuandguoyucode d9d1caa476 feat(vcs): add Add to .gitignore to the commit changes menu (#371)
Unversioned files and folders in the Commit tool window's changes menu offer Add to .gitignore / Add to .git/info/exclude, the same action as the Project view's Git submenu. vcs_add_to_ignore returns the ignore files it changed and the IDE opens the nearest one after writing. A selected symlink or Windows junction is ignored by name without a trailing slash; symlinked ancestors are still refused, and already-ignored paths are left unchanged.

Co-authored-by: guoyucode <[email protected]>
2026-10-30 15:09:55 +08:00
kevinh 9d1fe17894 fix(http-client): find the generated request scratch on Windows (#356)
Generate Request in HTTP Client (and Generate Requests for Controller, and the gutter icon) left the scratch file empty on Windows with "Cannot open file …\ai.l0ng.openidea/scratches/generated-requests.http": the scratch path joined the native app data directory (backslashes) with a /-separated suffix, so the opened editor could not be found by its path. Scratch paths now normalise the app data directory to the UI's /-separated form through a shared ipcPath helper, used by scratches, the HTTP client and Spring request generation.

Co-authored-by: kevinh458 <[email protected]>

未在 Windows 实机验证。
2026-10-30 15:09:50 +08:00
l0ng-ai 9575500dd6 fix(java): provision an isolated runtime for the Java language server (#370)
Recent Eclipse JDT LS releases need Java 25 to run, so projects on JDK 21 had no Java language support until the user downloaded a newer JDK by hand. When JDT LS is about to start, no installed JDK is new enough, and no language-server JDK is set explicitly, the IDE now downloads the Temurin JRE of the required version (about 50 MB, falling back to the JDK image where no JRE build exists) with a cancellable background task, then starts the server on it. The runtime lives in its own folder under the app data directory, outside the roots scanned for project JDKs, so the project JDK, run configurations and JDT LS's project runtimes are unchanged. One download runs at a time across windows and is reused; a failed attempt falls back to the existing manual Download JDK action and is not retried until the next start.

Fixes #258
Refs #271

未在 Windows / Linux 实机验证。
2026-10-30 14:38:45 +08:00
l0ng-ai 0329678124 fix(release): preserve unicode-normalization dependency version 2026-10-30 14:18:38 +08:00
l0ng-ai 2924c0f8e5 chore: v0.1.27 2026-10-30 14:16:20 +08:00
l0ng-ai 082420b5bb test(agent): finish pending permission turns before runtime teardown (#364)
Three ide-agent tests occasionally hung forever (about 1 in 40 runs under parallel load). Their fake agent answers a turn by requesting permission from a blocking thread and waits for the reply; the tests ended while that request was pending, and dropping the Tokio runtime waits for its blocking pool, which never finished. The tests now cancel the pending turn and wait for the task to settle before shutting down. Test-only: in the app, shutdown kills agent processes rather than waiting on them.
2026-10-30 14:01:52 +08:00
l0ng-ai 9a5b44bebb fix(agents): restore archived tasks for continued conversation (#349)
Archived agent tasks could be opened but never returned to the active list, so their message composer stayed disabled. Unarchive is now available in the task header, in the archived-task notice above the composer, and in the task context menu (including mixed bulk selections). Restoring a task moves it back to the active list, keeps its conversation and worktree, and reconnects the existing agent session on the next message; the backend persists and broadcasts the restored status and refuses restoration while a stopped turn is still settling.
2026-10-30 13:55:26 +08:00
l0ng-ai 2203938491 fix(i18n): translate every action title (#360)
Action titles passed to action factory helpers (editorAction and similar) were missed by the text-catalog check, so 63 of them, including Go to Declaration or Usages, showed in English and could not be found by Chinese text in Settings › Keymap. They now have Chinese entries, and a test checks that every literal action title, including factory arguments, has one (product names such as Vim stay as they are).

Refs #353
2026-10-30 13:46:15 +08:00
l0ng-ai 503bbb2f02 fix(tools): install TypeScript 6 without a shell-unsafe version range (#359)
Fixes #341
2026-10-30 13:37:58 +08:00
arookieofcanda-rookie-of-C-language ab6fca01c4 fix(vcs): break the .git/index self-refresh loop that froze the UI (#339)
With a project open, the app became slow and then unresponsive after 30-60 minutes. Git status writes refreshed stat data back to .git/index; the watcher reported that write as a file change and a VCS change; the UI scheduled another status ~250 ms later, which wrote the index again. With any stat-dirty file this never settled: a full status, IPC traffic and decoration rebuilds in an endless loop.

Watcher batches now carry .git internals separately (FsBatch.vcs), so they no longer appear as worktree changes and no fsChanged is sent for .git-only batches. Status records the identity (size, mtime) of the index it writes, and the host skips VcsChanged only for that write; external index changes (git add / reset / stash from a terminal) and HEAD / refs / packed-refs changes still refresh as before. The UI's fsChanged handler also ignores .git-only batches.

Fixes #311

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-10-30 13:34:18 +08:00
HePengZhou 0a27d02f4d fix(terminal): stop fcitx5 commits from being sent twice (#336)
With WebKitGTK and fcitx5, committed IME text reached the terminal twice: the IME commits without firing compositionstart, so xterm's composition bookkeeping never advanced and every commit was emitted both as the committed text and as a stale textarea diff. An IME commit gate now lets only the first emission of a commit through (with a 250 ms safety timeout) and empties the hidden textarea once the commit ends. It engages only for input that looks like a composition (keyCode 229 / composition input events) and stands down for IMEs that report compositionstart (macOS, IBus, Windows), leaving xterm's own handling unchanged there.

Co-authored-by: Hepengzhou <[email protected]>

未在 Linux(fcitx5)实机验证。
2026-10-30 13:22:12 +08:00
DIMAS FEBRIYANTO 97b8928e4f feat(ui): search in the agent model dropdown (#352)
Select gains an optional search box that filters its options; the agent model pickers use it, so long model lists can be filtered instead of scrolled. Keyboard navigation and type-ahead work on the filtered list, and the popup keeps its width while filtering.

Co-authored-by: dimassfeb-09 <[email protected]>
2026-10-30 13:21:15 +08:00
DIMAS FEBRIYANTO 53fd3683e2 fix(agent): keep the selected model when starting a new chat (#351)
After choosing a model and starting a new chat, the picker still showed that model but the session fell back to the agent's default. The remembered configuration was recorded from the agent's reported defaults before the user's choices were applied, and changing an option during a session did not update it. A new session now remembers its options after applying the user's choices, and changing an option updates the remembered configuration.

Co-authored-by: dimassfeb-09 <[email protected]>
2026-10-30 13:21:11 +08:00
arookieofcanda-rookie-of-C-language 27bef6956e fix(tools): validate zip symlink entries on every platform (#350)
Zip symlink entries were only validated on Unix: on Windows they fell through to the regular-file path, so a link escaping the destination was written as a file instead of being rejected (and the escape test failed on Windows CI). The symlink branch now validates on every platform; Unix creates the in-archive link, while Windows skips it, since creating symlinks needs a privilege most users lack.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-10-30 13:18:55 +08:00
l0ng-ai 3563f70810 fix(settings): keep the settings window on screen on Windows and Linux secondary displays (#357)
Fitting the settings window to the monitor work area left it invisible on a
Windows secondary display. The fitting now applies on macOS only; Windows and
Linux centre the window over its owner as before.

Fixes #325
2026-10-30 13:16:44 +08:00
Mykhailo Smolinskyiandnntndfrk 7a46774fda fix(angular): find ngserver installed through version managers (#260)
The Angular language server (ngserver) was not found when Node and global npm packages came from a version manager (nvm, fnm, volta, asdf, mise) whose bin directories are not on the GUI app's PATH. Tool lookup now searches those directories too through a shared ide-core tool_path module used by the LSP launcher, SDK queries and tool detection; explicit relative commands resolve against the project root, empty PATH entries are ignored, and a non-executable file earlier on PATH no longer shadows the real command.

The process query test that flaked under load now uses a generous deadline for the success case, with the timeout covered by a separate test of a child that never exits.

Co-authored-by: nntndfrk <[email protected]>
2026-10-30 12:23:25 +08:00
arookieofcanda-rookie-of-C-language 2ad979af66 feat(agents): complete OpenCode's /compact builtin (#257)
OpenCode handles /compact through its ACP session but does not list it in availableCommands, so the composer never offered it. Agent builtins the agent implements without advertising are now merged into slash-command completion without duplicating listed commands; the table holds only commands verified against the agent (OpenCode /compact). Claude Code already advertises /compact. Executable lookup keeps using the shared PATHEXT-aware resolution on main, with a test that the newest install across PATH entries is chosen.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-10-30 12:22:04 +08:00
l0ng-ai f5a95dcc21 fix(agents): keep text selection within the conversation (#346) 2026-10-30 11:59:34 +08:00
l0ng-aiandCursor 1ed6f7241b docs: link Discord from the README
Co-authored-by: Cursor <[email protected]>
2026-10-30 11:48:05 +08:00
l0ng-ai 0d880b9d35 feat(agents): complete typed paths outside the project after @ (#332)
In the agent composer, an @ query starting with ../, ./ or / lists the contents of the directory typed so far (through path_completions) instead of searching the project file index, so files outside the project can be referenced. Picking a folder inserts @dir/ and keeps completing inside it; picking a file inserts @rel, resolved to an absolute path when sending. Typed paths resolve against Windows project roots as well, keeping the drive.
2026-10-30 11:40:32 +08:00
l0ng-ai 95068f5885 fix(vcs): refine log ref labels, row height and favorite star (#335)
Git Log ref labels use an outlined tag glyph in the ref colour instead of a solid flag; log rows are 26px with wider graph lanes and larger nodes. With Highlight › Current Branch on, commits outside HEAD are dimmed instead of the current branch being tinted. Excluded folders in the project tree get a tinted row background (Tree gains an optional rowClassName). The favorite star is a rounder shape, and the filled one has no separate outline stroke.
2026-10-30 11:40:28 +08:00
KairosMakkiandKairosMakki 15d926a5d8 ci: add a cargo-deny job and run CI on pull requests to main (#305)
CI gains a Cargo deny job (advisories, licenses, bans, sources) using the repository's deny.toml, and its pull_request trigger is limited to pull requests targeting main.

Co-authored-by: KairosMakki <[email protected]>
2026-10-30 11:37:34 +08:00
l0ng-ai 7da58637c2 fix(deps): upgrade crates with security advisories (#338)
cargo deny check now passes, with a deny.toml at the repository root (advisories, licenses, bans, sources).

- zip 2.4.2 → 9 as one workspace dependency (RUSTSEC-2026-0367/0368/0369). Our own extraction is hardened too: tar/zip entries cannot escape the destination, symlinks are allowed only when they resolve inside it, entries cannot traverse a symlink, and setuid/setgid/sticky bits are dropped (also after system tar).
- tiberius 0.12 → 0.13 with rustls 0.23, which brings the fixed rustls / rustls-webpki (RUSTSEC-2026-0098/0099/0104/0346/0349/0382/0383). The bb8-tiberius adapter is replaced by a small bb8 connection manager in ide-db that keeps its single-redirect handling.
- crypto-bigint / ctutils updated (RUSTSEC-2026-0385/0387).
- rsa RUSTSEC-2023-0071 (no fixed release) is ignored with its reason: RSA is used only for MySQL password encryption and SSH signatures, never to decrypt attacker-controlled ciphertext.
- Unmaintained transitive crates (paste, proc-macro-error, rustls-pemfile, zbus/zvariant) remain warnings. MPL-2.0 is allowed (file-level copyleft, dependencies used unmodified).

未在真实 SQL Server 及 Windows / Linux 上验证解压。
2026-10-30 11:36:51 +08:00
l0ng-ai b1be3c6a09 feat(tools): pick among interchangeable language servers for every language (#313)
A language server and the fallback servers after it in languages/*.toml now share one picker in the Language Servers details pane, for every language rather than Python only: Language server for <languages>: Automatic, each server (with its status) or None, with the Automatic order in the hint. Servers outside such a group keep their on/off checkbox. ToolInfo.alternatives lists each server's group in preference order; a regular server after a fallback starts no group with it. The Python-only picker at the top of the page is removed, and names drop a language suffix the label already states. Storage is unchanged (each server's enabled flag, global or project scope), so existing settings carry over.
2026-10-30 10:56:00 +08:00
l0ng-ai 5792e7d9b6 fix(editor): size gutter icons consistently (#322)
The plain run glyph renders at 12px and the other gutter icons (run-all, run status badges, inheritance markers, HTTP request markers) at 14px through a shared gutterIconMarkup, so they look the same size. Run, Docker, HTTP, Spring HTTP and inheritance markers use it, and marker boxes are 14px to match the gutter column instead of overflowing it at 16px.
2026-10-30 10:55:55 +08:00
l0ng-ai ab7cc02561 fix(i18n): drop translations of removed run widget labels 2026-10-30 10:55:15 +08:00
l0ng-ai a7b5758f5a fix(run): target the selected configuration's run or debug session from the toolbar (#326)
With several sessions running, the toolbar's Rerun and Stop could act on a different configuration than the selected one: when the selected configuration had no matching run session, the widget fell back to the most recently started session of another configuration; and debug sessions did not record the configuration they were started from, so a configuration being debugged never matched and a running npm session took over the buttons.

The widget, Run (Shift+F10) and Stop (Cmd+F2) now target the selected configuration's session, whether it was started with Run or Debug (the most recent when both exist); Rerun of a debug session reruns it in debug. When the selected configuration is not running, the widget offers Run / Debug for it, and Stop remains available for the other running sessions (with a chooser when several are running). Debug sessions keep their originating run configuration. In a focused Run or Debug tool window, Stop still stops the selected tab's process.

Fixes #318
2026-10-30 10:51:37 +08:00
arookieofcanda-rookie-of-C-language e85ffa2ae8 feat(git): remember HTTPS credentials in the app secret store (#321)
Git no longer asks for the HTTPS user name and password on every fetch, pull and push. The password prompt offers "Remember for this host"; the credential is saved in the app's secret store (next to the saved SSH passwords, never in git's global configuration) only after the git command that used it succeeds, and a stored credential the server rejects is forgotten so the next command asks again. Settings › Version Control › Git lists saved credentials and forgets them.

Each git child gets its own id, passed to the askpass helper, so concurrent commands never save or forget each other's credentials. ide-vcs reports command completion through an observer installed at start-up instead of depending on ide-ssh. Credentials are keyed by scheme, host and port, and only git's authentication errors (Authentication failed, HTTP 401/403 responses, invalid username or password) forget a stored credential.

Fixes #272

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-10-30 10:48:29 +08:00
l0ng-ai 2c10b63f76 fix(settings): fit the settings window within the monitor work area (#323)
The Settings window opened at its remembered or default size even when that did not fit the current display, so on a 1920x1080 screen (or one with a taskbar or scaling) its bottom, including OK / Cancel / Apply, was cut off. Before showing, the window is now sized and positioned within the parent's monitor work area, in that monitor's logical coordinates and including the native frame; a size remembered on a larger display is clamped too, and the minimum size yields to a smaller work area. If the window system cannot report bounds, the window still opens unchanged.

Fixes #307

未在 Windows / Linux 实机验证。
2026-10-30 10:42:30 +08:00
l0ng-ai 44d0f40b13 fix(completion): replace prefixes typed after cached requests (#324)
Accepting an LSP completion after typing more characters than the request saw (`System.out.` requested, then `Print` typed while the cached list stayed open) inserted the item after the typed text: `System.out.Printprint()`. The cached edit range started exactly at the request caret and was mapped forward past the newly typed characters. The range start now stays at the request caret, so the typed prefix is replaced, whatever its casing. Enter (insert) and Tab (replace) keep their behaviour for the text after the caret; this applies to every language server.

Fixes #316
2026-10-30 10:40:41 +08:00
l0ng-ai f28d8da3a1 test(gradle): build expected launcher paths from path segments
The expected paths joined "<dir>/bin" as one segment, which mixes separators on Windows.
2026-10-30 10:06:24 +08:00
l0ng-ai 822a93abac chore: v0.1.26 2026-10-30 09:25:13 +08:00
l0ng-ai 08956b615e test(maven): compare JDTLS exclusions with forward-slash paths
Exclusions are written with forward slashes so they work as Java globs on Windows; the tests compared them with native paths and failed there.
2026-10-30 09:19:33 +08:00
l0ng-ai 6c1eba9d82 feat(gradle): add Gradle settings with Gradle JVM and distribution (#303)
Adds Settings › Build Tools › Gradle: Gradle JVM (defaults to the Project JDK), distribution (wrapper or local installation with its home) and Gradle user home, stored in the project or global settings.

Sync and Gradle tool-window tasks use the configured JVM, launcher and GRADLE_USER_HOME. Run configurations keep their own choices: one that uses the wrapper runs gradlew, one that runs plain `gradle` resolves it from the local installation, then the pinned toolchain, then PATH; an explicit JAVA_HOME in the configuration wins over Gradle JVM. Derived defaults are applied at launch and not saved into run configurations. New Gradle configurations start with the wrapper off when the project uses a local installation.

Fixes #232
2026-10-30 09:03:19 +08:00
l0ng-ai cdb6cc7c0d fix(vcs): decode git blobs in the file's encoding (#301)
Git blobs were always decoded as UTF-8, so files in GBK or other legacy encodings showed every line as changed in the gutter, diff, blame and commit views, and staging/committing could re-encode them wrongly.

Blob text is now decoded and encoded with the file's encoding: the open document's encoding, else detection on the working file with the project default as fallback, else detection on the blob. Covers line hunks, file-at-revision, blame, stage patch/hunks, commit, conflict versions, diff text, and the agent worktree's file_at, partial commit and patch helpers. Text that cannot be represented in the target encoding is reported as an error instead of being written lossily.

Fixes #243

未在 Windows 实机验证。
2026-10-30 09:00:21 +08:00
l0ng-ai 181a2ea03e fix(run): recognise Windows launcher names in run configuration forms (#300)
Run configurations whose command is a Windows launcher (mvn.cmd, MVN.CMD, a full C:\\…\\mvn.cmd path, gradle.bat, pytest.exe, java.exe, godot.exe, npm.cmd) opened in the generic command fallback because the Maven/Gradle, npm, pytest, Godot and Application/Spring Boot forms compared executable names exactly. A shared commandName() (basename, either slash, .exe/.cmd/.bat/.com stripped case-insensitively) now matches them, and the original launcher text is kept so the command line round-trips unchanged.

Refs #244
2026-10-30 08:52:00 +08:00
l0ng-ai d44e2414ca fix(build): decode tool output in the Windows code page (#299)
On Chinese Windows the Maven tool window showed sync errors as mojibake: tool output was decoded as UTF-8, but while JVMs are told to write UTF-8, cmd.exe, batch launchers (mvn.cmd, gradlew.bat) and native tools write their diagnostics in the ANSI code page (GBK). ide_core::output_encoding::decode_tool_output keeps valid UTF-8 and decodes other lines in the system code page (GetACP). It is used wherever OpenIDEA shows output of tools it launches itself: Maven/Gradle sync and tasks, tool installers, SDK probes, Docker, coverage, environment setup, Cargo stderr for debugging, agent version probes and notebook kernel stderr. Run consoles and protocol streams keep their own encodings. 未在 Windows 实机验证。

Refs #262
2026-10-30 08:18:53 +08:00
l0ng-ai 3bb994062d fix(remote): keep keymap and UI preferences local in remote windows (#298)
In a remote-development window every settings read went to the server, so the keymap (preset, overrides, custom keymaps) and other UI preferences came from the server's global settings — defaults on a fresh host — and customised shortcuts were lost. Client-scoped settings are now declared in one place (src/platform/clientSettings.ts: keymap, appearance, notifications, editor typography and display, terminal typography and interaction, and UI-only general/console/debugger/run/VCS preferences). In remote windows they are overlaid from the local global settings on every settings response (settings_get, settings_set, project snapshots, connect), and writes to them go to the local backend only. Project and tooling settings still come from the server.

Not verified against a real SSH host (none available here); covered by routing and overlay tests.

Fixes #239
2026-10-30 08:17:33 +08:00
l0ng-ai 2ea47873cf feat(maven): unlink and re-add Maven projects (#297)
As in IntelliJ IDEA, a root Maven project can be unlinked from the Maven tool window's context menu (Unlink Maven Projects, with confirmation) and linked again with the toolbar's Add Maven Projects (pick a pom.xml). Unlinked root POMs are stored in the project's Maven settings (unlinkedProjects). ide-host computes the unlinked roots and their modules once with ide-build and applies the same exclusion to the build model, Maven sync, project source roots, detected run configurations and the Java language server import (java.import.exclusions). The project's files stay visible in the Project view as plain files.

Refs #271
2026-10-30 08:12:49 +08:00
l0ng-ai c55fd9b86e fix(run): allow removing detected run configurations (#296)
Detected run configurations (Spring Boot and Java applications, npm scripts, Maven/Gradle tasks, pytest, Cargo, Go, …) could not be removed: the − button was disabled because a removed detection would be found again. As in IntelliJ IDEA, they can now be removed and stay removed: the removed id is stored in the workspace-local run.local.toml (suppressed-ids) and detection skips it; saving a configuration with that id again lifts the suppression. Applies to the Run/Debug Configurations − button, the Delete key in its tree, and Delete in the run configuration chooser. Removing a saved override still restores the detected configuration; temporary configurations are unchanged.

Refs #271
2026-10-30 07:42:00 +08:00
Mykhailo Smolinskyiandnntndfrk 922bbf493a feat(angular): highlight Angular templates with control-flow blocks (#284)
HTML files in Angular projects were highlighted as plain HTML. Languages can now declare framework UI modes with root markers (angular.json, node_modules/@angular/core); files under such a root get the framework's editor mode, re-detected when a marker appears or disappears, and the root-marker lookup is shared with language server roots. The Angular Template mode (vendored @codemirror/lang-angular, MIT) adds @if/@for/@switch/@defer/@let blocks, key and animation binding names, and colours {{ }} delimiters.

Lazily loaded editor modes are now loaded before a document's first editor opens, so a file never renders as plain text first (this also left Angular templates uncoloured).

Co-authored-by: nntndfrk <[email protected]>
2026-10-30 07:39:31 +08:00
Jepmen c0626b2728 perf(terminal): spawn the shell off the main thread (#295)
term_spawn was a synchronous Tauri command, so it ran on the main thread; starting the shell forks the IDE process, which takes tens of milliseconds with a large resident set and froze the UI each time a terminal opened. It is now async like term_is_busy, so the fork runs off the main thread. The frontend already awaited it.

Co-authored-by: brandondurandpro <[email protected]>
2026-10-30 07:33:00 +08:00
Dimas Victoranddvictor357 0d368ba947 feat(notebook): add native Jupyter notebook editing and execution (#268)
Opens .ipynb files in a native notebook editor, in a project or LightEdit.

- crates/ide-notebook: lossless nbformat documents (unchanged notebooks save byte-for-byte, unknown fields kept, external changes detected) and owned local kernels over ZMQ via kernelspec — the selected Python environment only needs ipykernel, no Jupyter server.
- ide-host owns notebook documents per project incarnation and admits one execution batch at a time; outputs stream over IPC channels and kernel status is an IdeEvent.
- src/features/notebook: virtualized Python/Markdown cells, insert/delete/move/change type, Run Cell / Run All (Shift+Enter), interrupt, restart, interpreter selection from ide-sdk discovery, shared split views. Raw/unknown cells stay read-only.
- Custom editor kinds take part in dirty state, save/save all, undo/redo and Save/Don't Save/Cancel; closing editors from other features awaits that decision.
- Outputs: text, tracebacks, PNG/JPEG and sanitized SVG inline; HTML sanitized in a sandboxed iframe without allow-same-origin; JavaScript output is shown as inert source.
- Explicit launches (opening a notebook from the OS) win over session restoration.

Closes #131

Co-authored-by: dvictor357 <[email protected]>
2026-10-30 07:26:43 +08:00
DIMAS FEBRIYANTO 3a1e47d872 feat(db): add Microsoft SQL Server driver support (#276)
The database tool window can connect to Microsoft SQL Server (tiberius over rustls with a bb8 pool): browse schema objects, edit table data, run queries and view plans; SQL Server dialect, object dialogs and icons in the UI.

Values are rendered by column type: exact decimal/numeric, money with four decimals, tinyint, uniqueidentifier, date/time/datetime/datetime2/smalldatetime/datetimeoffset in ISO form, xml and binary. Multi-statement edits roll back on any failure and discard the connection rather than returning it to the pool mid-transaction; connect-time errors (login, database) are reported; result pagination reports more rows; cancelling a query stops reading and drops the stream. The sqlx drivers keep the shared with_pool! path.

Co-authored-by: dimassfeb-09 <[email protected]>
2026-10-29 23:50:19 +08:00
Jaw d1f84ef364 perf(editor): stop re-rendering breadcrumbs on every caret move (#256)
The status-bar breadcrumbs, the code breadcrumbs and the closed Navigation Bar popup subscribed to the whole editor status, which is replaced on every keystroke and caret move, so ~30 crumb buttons re-rendered per key. They now select the symbol chain at the caret with a shallow comparison (re-rendering only when it changes), and the popup subscribes to the caret only while open. Typing 38 characters: 1386 → 152 component renders.

Co-authored-by: jaw52 <[email protected]>
2026-10-29 23:41:03 +08:00
Walter Wangandbghtyu 00693334f7 feat(settings): add editable font search and refresh (#251)
The font pickers (editor, fallback, UI, terminal) become editable comboboxes: type to search installed families or enter any family name, and Refresh re-reads the system fonts. ide-fonts' cached family list is now refreshable (DirectWrite re-queries the system collection with checkForUpdates; CoreText and fontconfig are read anew), exposed through system_font_families(refresh). Before the first native result the pickers fall back to Canvas detection of common families; saved names and the last catalog are kept when a refresh fails. Fonts are enumerated on the local desktop in remote-development windows too.

Co-authored-by: bghtyu <[email protected]>
2026-10-29 23:35:33 +08:00
l0ng-ai 285c7187fb feat(languages): add Windows batch file support (#294)
.bat and .cmd files were plain text: no highlighting, no comment toggling and no Run. Batch is now a built-in language (languages/bat.toml, line comment @REM as in VS Code) with a CodeMirror stream mode for keywords, labels, %VAR% / %%i / !VAR! variables, strings and :: / REM comments. Comment toggling always separates a word-like token (REM) from the code. On Windows hosts the editor and project-view Run use cmd.exe /c <file> in the file's directory; other hosts don't offer it. 未在 Windows 实机验证运行。

Fixes #236
2026-10-29 23:34:22 +08:00
arookieofcanda-rookie-of-C-language 1602e4b530 feat(run): pytest run configuration type (#274)
Add Configuration had no pytest template although detected and implicit pytest configurations already used the pytest kind. A pytest type now offers Command, Runner arguments, Target and pytest options, and opens existing pytest configurations in that form: pytest itself, `uv/poetry run pytest`, `python -m pytest` (also behind an interpreter prefix such as `uv run python -m pytest`), venv `bin/pytest` and Windows `Scripts\\pytest.exe`. Fields are translated.

Fixes #266

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-10-29 23:25:07 +08:00
Jaw 5217f8570b chore: add knip and remove the dead code it reports (#247)
Adds knip (pnpm knip, knip.ts covering src, scripts, perf and e2e) and runs it in the CI frontend job. Removes what it reported: the unused codemirror and @types/dompurify dependencies, exports and test hooks used nowhere (AppLogo, Tooltip, barrel re-exports, duplicate default exports of lazily loaded settings pages), the unused iterateAll helper, and the zh entry for a deleted notification. rustfmt (used by lint-staged) is ignored as a toolchain binary.

Co-authored-by: jaw52 <[email protected]>
2026-10-29 23:21:56 +08:00
l0ng-ai c3b3534f08 fix(settings): keep staged object settings stable across renders (#293)
Settings › HTTP Proxy crashed ("Maximum update depth exceeded") when switching to No proxy or Manual. useSetting selected stagedValue() inside the zustand selector; for an object key ("proxy") whose child keys are staged separately ("proxy.mode", "proxy.host"), stagedValue builds a new merged object on every call, so the snapshot was never stable. useSetting now reuses useStagedValue, which selects base and staged separately and memoises the merge — covering every useSetting on an object-valued key. No other selector calls stagedValue.

Fixes #285
2026-10-29 23:13:27 +08:00
l0ng-ai aeec0e6759 fix(keymap): match remaining letter shortcuts by typed character (#292)
#291 named letter keys by the typed character (e.key) when Alt is not held, so AZERTY Ctrl+Z is no longer Ctrl+W. Hard-coded handlers still compared the physical KeyboardEvent.code. A shared letterOf(e) in platform/keymap.ts now backs strokeOf and those handlers:

- terminal Ctrl+Shift+C / V / T (copy, paste, new tab) and Ctrl+letter control codes
- Run/Debug Configurations Ctrl/Cmd+N
- browser shortcut guards (blocked reload/print etc.)

Alt combos keep the physical key (Option / AltGr type symbols).
2026-10-29 23:05:26 +08:00
youngtseup d1a597898d fix(windows): use the app icon for the NSIS installer and uninstaller (#275)
The NSIS installer and uninstaller showed the default NSIS icon because tauri.windows.conf.json set no installerIcon/uninstallerIcon; both now use icons/icon.ico (merged into the base bundle config as a JSON merge patch).

Co-authored-by: youngtseup <[email protected]>
2026-10-29 23:03:25 +08:00
arookieofcanda-rookie-of-C-language 94d209c99c fix(agents): resolve agent commands through PATH x PATHEXT on Windows (#273)
On Windows std/tokio Command only tries .exe for a bare program name, so agents installed as npm shims (codex.cmd, claude.cmd) failed with "program not found". Bare names are now resolved through PATH x PATHEXT before spawning.

- ide-core::process gains find_executable / find_executable_in (moved from ide-run::which, which re-exports them).
- ide-jsonrpc Peer::spawn_framed resolves against the child's PATH (caller env, login shell, then ours): covers ACP agents and the native Codex driver.
- ide-agent newest_install builds candidates with executable_names, so the native Claude driver finds .cmd shims and multi-install selection works on Windows.
- ide-agent replay recorder resolves the same way.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-10-29 23:00:01 +08:00
1233 changed files with 98494 additions and 7289 deletions

No files matched your search

+12
View File
@@ -3,6 +3,8 @@ on:
push:
branches: [main]
pull_request:
branches: [main]
types: [opened, synchronize, reopened]
workflow_dispatch:
concurrency:
@@ -69,6 +71,15 @@ jobs:
- run: cargo build --locked -p ide-host --bin openidea-server --target x86_64-unknown-linux-musl
- run: target/x86_64-unknown-linux-musl/debug/openidea-server --version
deny:
name: Cargo deny
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: taiki-e/install-action@cargo-deny
- run: cargo deny check
frontend:
name: Frontend
runs-on: ubuntu-22.04
@@ -82,6 +93,7 @@ jobs:
- run: pnpm typecheck
- run: pnpm lint
- run: pnpm format:check
- run: pnpm knip
- run: pnpm test
- run: pnpm build
+11
View File
@@ -70,6 +70,12 @@ jobs:
- 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
@@ -196,11 +202,16 @@ jobs:
}
// 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"]],
];
+1
View File
@@ -9,6 +9,7 @@
"dist",
"target",
"src/bindings.ts",
"src/editor/core/cm/angular/parser*.ts",
"docs/plugins/themes.md",
"pnpm-lock.yaml",
"**/*.toml",
+1
View File
@@ -13,6 +13,7 @@
"src-tauri",
"crates",
"src/bindings.ts",
"src/editor/core/cm/angular/parser*.ts",
"fixtures",
"plugins",
"test-results",
Generated
+428 -144
View File
File diff suppressed because it is too large. Load diff
+8 -1
View File
@@ -3,7 +3,7 @@ resolver = "3"
members = ["crates/*", "src-tauri"]
[workspace.package]
version = "0.1.25"
version = "0.1.33"
edition = "2024"
rust-version = "1.96"
license = "Apache-2.0"
@@ -40,6 +40,7 @@ ide-fonts = { path = "crates/ide-fonts" }
ide-ssh = { path = "crates/ide-ssh" }
ide-remote = { path = "crates/ide-remote" }
ide-host = { path = "crates/ide-host" }
ide-notebook = { path = "crates/ide-notebook" }
# serialization & IPC typing
serde = { version = "1", features = ["derive"] }
@@ -75,6 +76,7 @@ trash = "5"
blake3 = "1"
dunce = "1"
dirs = "6"
zip = { version = "9", default-features = false, features = ["deflate"] }
# search, index, language
grep-searcher = "0.1"
@@ -85,6 +87,9 @@ redb = "4"
tree-sitter = "0.25"
# protocols & integrations
jupyter-zmq-client = { version = "=2.0.0", default-features = false, features = ["tokio-runtime", "ring"] }
jupyter-protocol = "=2.0.3"
zeromq = { version = "0.6", default-features = false, features = ["tokio-runtime", "tcp-transport"] }
lsp-types = "0.97"
gix = { version = "0.89", default-features = false }
portable-pty = "0.9"
@@ -92,6 +97,8 @@ reqwest = { version = "0.13", default-features = false, features = ["rustls-no-p
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] }
rustls-platform-verifier = "0.7"
sqlx = { version = "0.9", 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"] }
bb8 = "0.9"
uuid = { version = "1", features = ["v4"] }
# testing
+1 -10
View File
@@ -14,7 +14,7 @@
&nbsp;·&nbsp;
<a href="#benchmark">Benchmark</a>
&nbsp;·&nbsp;
<a href="#build">Build</a>
<a href="https://discord.gg/2Zy4yn7gP">Discord</a>
</p>
https://github.com/user-attachments/assets/234dd54c-1255-4b3a-b35c-533d78d737c0
@@ -45,15 +45,6 @@ IDEA analyses Python more deeply, so "ready" doesn't mean the same work on
both sides. Both are measured from the outside, the same way: see
[`perf/compare`](perf/compare) to run it on your machine and projects.
## Build
```sh
pnpm install
pnpm tauri dev
```
Rust 1.96, Node 22, pnpm and the [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/).
## License
[Apache 2.0](LICENSE). Bundled third-party assets are listed in [`THIRD_PARTY_LICENSES.md`](THIRD_PARTY_LICENSES.md).
+63 -3
View File
@@ -1,5 +1,13 @@
# Third-party licenses
## Project Lombok
`plugins/lombok/lombok-1.18.48-openidea.1.jar` contains Project Lombok v1.18.48 with the Equinox
classloader fix from [projectlombok/lombok#4059](https://github.com/projectlombok/lombok/pull/4059).
Copyright (C) 2009-2021 The Project Lombok Authors. MIT license and bundled component
notices are retained in [plugins/lombok/LICENSE](plugins/lombok/LICENSE) and inside the jar.
Source tag, patch commits and build instructions: [plugins/lombok/README.md](plugins/lombok/README.md).
## JetBrains IntelliJ Platform icons
`src/ui/expui/**` contains SVG icons from the IntelliJ Platform "new UI" (expui)
@@ -12,12 +20,13 @@ icon set, copied unmodified (some renamed) from JetBrains' open-source repositor
`python/python-requirements` (`fileTypes/requirements`); `python/pipenv`
(`fileTypes/pipenv`); `python/python-pyproject` (`expui/toml.svg` →
`fileTypes/pyprojectToml`); `plugins/kotlin/base` (`fileTypes/kotlin`,
`kotlinScript`, `kotlinGradleScript`); `java/openapi` (`fileTypes/maven`);
`kotlinScript`, `kotlinGradleScript`, `icons/expui/objectKotlin` → `kotlin/objectKotlin`); `java/openapi` (`fileTypes/maven`);
`plugins/markdown` (`fileTypes/markdown`); `plugins/toml` (`fileTypes/toml`);
`plugins/terminal` (`toolwindows/terminal`); `plugins/maven`
(`images/expui/toolwindow/maven` → `toolwindows/maven`, `images/expui/build/maven*`
→ `maven/`); `plugins/gradle` (`icons/expui/gradle` → `toolwindows/gradle`,
`icons/expui/gradleLoadChanges` → `build/gradleLoadChanges`);
`icons/expui/gradleLoadChanges` → `build/gradleLoadChanges`,
`icons/expui/gradle`, `icons/gradleSubproject` → `gradle/`);
`platform/external-system-impl` (`icons/newui/task` → `externalSystem/task`).
Copyright 2000-2026 JetBrains s.r.o. and contributors. Apache License 2.0 (below).
- [intellij-plugins](https://github.com/JetBrains/intellij-plugins) (each plugin
@@ -26,6 +35,20 @@ icon set, copied unmodified (some renamed) from JetBrains' open-source repositor
`fileTypes/hashiCorp`, Apache-2.0); `makefile` (`expui/makefile` →
`fileTypes/makefile`, MIT, Copyright (c) 2016 Victor Kropp — full text below).
The read/write usage filter assets (`actions/showReadAccess{,_dark}.svg` and
`actions/showWriteAccess{,_dark}.svg`) are copied unmodified from
`platform/icons/src/expui/actions/` at intellij-community commit
[`e992ff4750b4c36afe2caadfa8b191a8f36fb7a2`](https://github.com/JetBrains/intellij-community/tree/e992ff4750b4c36afe2caadfa8b191a8f36fb7a2/platform/icons/src/expui/actions).
Their original copyright headers and Apache-2.0 licensing are retained.
- [intellij-rust](https://github.com/intellij-rust/intellij-rust) (MIT):
`src/main/resources/icons/expui/rustFile{,_dark}.svg` →
`src/ui/expui/fileTypes/rust{,_dark}.svg`;
`src/main/resources/icons/expui/cargo{,_dark}.svg` and `cargoLock{,_dark}.svg` →
`src/ui/expui/fileTypes/cargo{,_dark}.svg` and `cargoLock{,_dark}.svg`.
Copyright (c) 2015 Aleksey Kladov, Evgeny Kurbatsky, Alexey Kudinkin and contributors;
Copyright (c) 2016 JetBrains. MIT licence, full text below.
At render time `src/ui/icons.tsx` may recolour monochrome icons to `currentColor`.
## JetBrains IntelliJ Platform diff algorithm
@@ -42,7 +65,7 @@ from its tests (`LineComparisonUtilTest`, `WordComparisonUtilTest`,
JetBrains s.r.o. and contributors. Apache License 2.0 (below).
`src/ui/openidea/**` holds OpenIDEA's own file-type icons drawn in the expui style for
languages whose JetBrains icons are not open source (Go, Rust, Ruby, Dart, Lua, Zig,
languages whose JetBrains icons are not open source (Go, Ruby, Dart, Lua, Zig,
Svelte, …). Those using the page-with-corner-badge layout (`goMod`, `goWork`,
`dotenv`, `readme`, `lock`, `license`) reuse the page outline of the Apache-2.0
expui `fileTypes/h.svg`; that outline is modified from the original by JetBrains.
@@ -53,6 +76,31 @@ The lettering in `src-tauri/icons/source/openidea-wordmark*.svg` is outlined fro
Inter Tight (Copyright 2022 The Inter Project Authors, SIL Open Font License 1.1,
https://github.com/rsms/inter-tight).
### MIT License (Rust and Cargo icons)
The MIT License (MIT)
Copyright (c) 2015 Aleksey Kladov, Evgeny Kurbatsky, Alexey Kudinkin and contributors
Copyright (c) 2016 JetBrains
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
### MIT License (makefile icon)
Copyright (c) 2016 Victor Kropp
@@ -111,6 +159,18 @@ shell and css dictionaries of [cspell-dicts](https://github.com/streetsidesoftwa
(October 2026): lowercased, filtered and merged by OpenIDEA. Copyright Street Side Software;
MIT licence, full text in `crates/ide-spell/src/dict/LICENSE-cspell-dicts`.
## CodeMirror Angular template language
`src/editor/core/cm/angular/` (`angular.grammar`, `tokens.ts`, `index.ts` and the
`parser.ts` generated from them) is derived from
[@codemirror/lang-angular](https://github.com/codemirror/lang-angular) 0.1.4
(commit `ecc5038f`). Copyright (C) 2018-2021 by Marijn Haverbeke and others; MIT
licence, full text in `src/editor/core/cm/angular/LICENSE`. Modifications by
OpenIDEA: control-flow blocks (`@if`, `@for`, `@switch`, `@defer`, ...) and `@let`
declarations, key and animation binding names (`(keydown.enter)`, `[@fade]`,
`(@fade.done)`), runs of text that entities split (`a && b`) parsed as one, and one
token for `{` and `}`.
## nvim-treesitter queries
`crates/ide-syntax/queries/<grammar>/locals.scm` are derived from the `locals.scm`
+2
View File
@@ -6,6 +6,8 @@ edition.workspace = true
license.workspace = true
[dependencies]
encoding_rs.workspace = true
ide-vfs.workspace = true
ide-core.workspace = true
ide-jsonrpc.workspace = true
ide-vcs.workspace = true
+1
View File
@@ -147,6 +147,7 @@ async fn a_task_runs_on_a_v2_agent_and_resumes_its_session() {
let mgr = manager(tmp.path(), agent.clone());
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("Use the TodoWrite tool"),
provider: "claude-acp".into(),
config: vec![AgentConfigChoice { id: "model".into(), value: "haiku".into() }],
+10
View File
@@ -37,6 +37,16 @@ pub(crate) fn bypass_approvals(provider: &str, config: &[AgentConfigOption]) ->
provider == "cursor" && config.iter().any(|o| o.id == CURSOR_APPROVAL && o.current.as_deref() == Some("bypass"))
}
/// The mode that approves everything without asking (`bypassPermissions`, `yolo`, `full-access`…), if the agent has one.
pub(crate) fn bypass_mode(options: &[AgentConfigOption]) -> Option<(&AgentConfigOption, &AgentConfigValue)> {
let option = options.iter().find(|o| o.category.as_deref() == Some("mode"))?;
let value = option.values.iter().find(|v| {
let m: String = v.value.to_lowercase().chars().filter(char::is_ascii_lowercase).collect();
m.contains("bypass") || m == "yolo" || m.contains("fullaccess") || m == "dangerous"
})?;
Some((option, value))
}
/// The settings in a `session/new` or `session/load` result: its select `configOptions`,
/// plus the older `modes` / `models` fields for what those don't cover.
pub(crate) fn options_of(result: &Value) -> Vec<AgentConfigOption> {
+69 -9
View File
@@ -23,6 +23,7 @@ pub use model::*;
use conversation::{Conversation, SavedSubagent, Session};
use ide_core::{IdeError, IdeResult, IpcPath};
use ide_jsonrpc::{Framing, Handler, Peer, RpcError, codes};
use ide_vcs::Repo;
use parking_lot::{Mutex, RwLock};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
@@ -116,9 +117,13 @@ impl Shared {
fn known_config(&self, provider: &str) -> Vec<AgentConfigOption> {
let mut config = self.known.lock().get(provider).map(|k| k.config.clone()).unwrap_or_default();
config::add_client_options(provider, &mut config);
// Approval bypass belongs to the conversation, never to another new task.
if let Some((o, v)) = config::bypass_mode(&config).map(|(o, v)| (o.id.clone(), v.value.clone()))
&& let Some(o) = config.iter_mut().find(|c| c.id == o)
{
o.current = Some(v);
}
if let Some(o) = config.iter_mut().find(|o| o.id == config::CURSOR_APPROVAL) {
o.current = Some("ask".into());
o.current = Some("bypass".into());
}
config
}
@@ -314,8 +319,8 @@ impl AgentManager {
}
/// The slash commands to complete for `provider`, in `task`'s directory or (without one)
/// in the project: what the agent offered last time there, plus, for Claude Code, the
/// skills and custom commands on disk it has not listed.
/// in the project: what the agent offered last time there, unadvertised builtins,
/// and, for Claude Code, skills and custom commands on disk it has not listed.
pub async fn commands(&self, provider: &str, task: Option<&str>) -> IdeResult<Vec<AgentCommand>> {
let (provider, offered, cwd) = match task {
Some(id) => {
@@ -325,6 +330,14 @@ impl AgentManager {
}
None => (provider.to_owned(), self.shared.known_commands(provider), self.shared.paths.project_root.clone()),
};
// Commands the agent implements itself and never lists (its CLI builtins):
// merged without duplicating what `availableCommands` or the disk offer.
let mut offered = offered;
for &(name, description) in builtin_commands(&provider) {
if !offered.iter().any(|c| c.name == name) {
offered.push(AgentCommand { name: name.to_owned(), description: description.to_owned(), hint: None });
}
}
if provider != "claude" && provider != "claude-acp" {
return Ok(offered);
}
@@ -353,9 +366,16 @@ impl AgentManager {
.ok_or_else(|| IdeError::invalid(format!("unknown agent {}", req.provider)))?;
let prompt = clean(req.prompt).ok_or_else(|| IdeError::invalid("describe the task first"))?;
let title = title_of(&prompt);
let root = self.shared.paths.project_root.clone();
let root = req.project_root.clone().map(|p| p.0).unwrap_or_else(|| self.shared.paths.project_root.clone());
let project_root = Some(IpcPath(root.clone()));
let (cwd, branch, base, base_commit, before) = match req.isolation {
Isolation::Worktree => {
// The UI offers worktrees for git projects only; state it when it isn't one.
if Repo::discover(&root)?.is_none() {
return Err(IdeError::invalid(
"worktrees need a git repository: run the task in the local checkout",
));
}
let dir = self.shared.paths.worktrees_dir.clone();
let (t, b) = (title.clone(), req.base.clone());
let c = blocking(move || worktree::create(&root, &dir, &t, b.as_deref())).await?;
@@ -379,6 +399,7 @@ impl AgentManager {
}
let now = now_ms();
let task = AgentTask {
project_root,
id: uuid::Uuid::new_v4().simple().to_string(),
title,
provider: spec.id.clone(),
@@ -539,6 +560,10 @@ impl AgentManager {
o.current = Some(value);
}
s.task.model = model_of(&s.task.config).or(s.task.model.take());
let provider = s.task.provider.clone();
let config = s.task.config.clone();
drop(s);
task.shared.remember(&provider, Some(&config), None);
}
task.approve_pending();
task.persist();
@@ -748,18 +773,41 @@ impl AgentManager {
Ok(())
}
/// Returns an archived task to the active list without starting the agent.
pub fn unarchive(&self, id: &str) -> IdeResult<()> {
let task = self.task(id)?;
{
let mut s = task.state.lock();
if s.task.status != AgentStatus::Archived {
return Ok(());
}
if s.busy {
return Err(IdeError::invalid("the task is still stopping"));
}
s.task.status = AgentStatus::Ready;
s.task.error = None;
s.task.updated_ms = now_ms();
}
task.persist();
task.emit_task();
Ok(())
}
/// Forgets the task; with `remove_worktree` also deletes its worktree (the branch stays).
pub async fn delete(&self, id: &str, remove_worktree: bool) -> IdeResult<()> {
let task = self.task(id)?;
task.kill();
let (isolation, cwd) = {
let (isolation, cwd, root) = {
let s = task.state.lock();
(s.task.isolation, s.task.cwd.0.clone())
(
s.task.isolation,
s.task.cwd.0.clone(),
s.task.project_root.clone().map(|p| p.0).unwrap_or_else(|| self.shared.paths.project_root.clone()),
)
};
// A copy of the task (a fork) may work in the same worktree.
let shared = self.tasks.read().values().any(|t| t.id != id && t.state.lock().task.cwd.0 == cwd);
if remove_worktree && !shared && isolation == Isolation::Worktree && cwd.exists() {
let root = self.shared.paths.project_root.clone();
blocking(move || worktree::remove(&root, &cwd)).await?;
}
self.tasks.write().remove(id);
@@ -1017,6 +1065,7 @@ impl Task {
self.push_entry(AgentEntry::User {
text: prompt.text,
attachments,
images: prompt.images,
sent_ms: Some(now_ms()),
done_ms: None,
steered: true,
@@ -1037,6 +1086,7 @@ impl Task {
self.push_entry(AgentEntry::User {
text: prompt.text.clone(),
attachments,
images: prompt.images.clone(),
sent_ms: Some(now_ms()),
done_ms: None,
steered: false,
@@ -1183,7 +1233,6 @@ impl Task {
(if known { reported } else { s.task.config.clone() }, s.wanted.clone())
};
config::add_client_options(&provider_id, &mut options);
self.shared.remember(&provider_id, Some(&options), None);
for choice in &wanted {
// An option the session doesn't offer yet is set once it does (`apply_late_choices`).
let Some(o) = options.iter().find(|o| o.id == choice.id).cloned() else { continue };
@@ -1212,6 +1261,7 @@ impl Task {
Err(e) => tracing::warn!("agent task {}: setting {} to {} failed: {e}", self.id, o.id, choice.value),
}
}
self.shared.remember(&provider_id, Some(&options), None);
{
let mut s = self.state.lock();
s.task.model = model_of(&options).or(s.task.model.take());
@@ -1629,6 +1679,16 @@ fn model_of(config: &[AgentConfigOption]) -> Option<String> {
config.iter().find(|o| o.category.as_deref() == Some("model")).and_then(|o| o.current.clone())
}
/// Commands the agent implements on its own and never lists in `availableCommands` —
/// its CLI builtins, passed through as prompt text (e.g. `/compact`). `(name, description)`.
fn builtin_commands(provider: &str) -> &'static [(&'static str, &'static str)] {
match provider {
// OpenCode ACP handles `/compact` via session.summarize but omits it from its command list.
"opencode" => &[("compact", "compact the conversation")],
_ => &[],
}
}
fn file_name(path: &Path) -> String {
path.file_name().map_or_else(|| path.to_string_lossy().into_owned(), |n| n.to_string_lossy().into_owned())
}
+9
View File
@@ -47,6 +47,9 @@ pub enum Isolation {
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct AgentTask {
/// Original project folder; absent in tasks saved before per-task roots.
#[serde(default)]
pub project_root: Option<IpcPath>,
pub id: String,
pub title: String,
/// [`AgentProvider::id`].
@@ -240,6 +243,9 @@ pub enum AgentEntry {
text: String,
/// Names of the files and images sent with it.
attachments: Vec<String>,
/// The images sent with it, shown in the conversation.
#[serde(default)]
images: Vec<AgentImage>,
/// When it was sent (ms since the epoch); `None` in tasks saved before this was kept.
#[serde(default)]
sent_ms: Option<f64>,
@@ -361,6 +367,9 @@ pub struct AgentProbe {
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct NewAgentTask {
/// Validated by the host; defaults to the window project root.
#[serde(default)]
pub project_root: Option<IpcPath>,
pub prompt: AgentPrompt,
pub provider: String,
/// Settings for the new session (model, mode…).
+8 -3
View File
@@ -296,7 +296,7 @@ impl Shim {
let mut cmd = std::process::Command::new(&self.config.program);
ide_core::process::prepare(&mut cmd);
let out = cmd.arg("--version").envs(ide_core::process::shell_env()).output().ok()?;
let text = String::from_utf8_lossy(&out.stdout);
let text = ide_core::output_encoding::decode_tool_output(&out.stdout);
text.split_whitespace().next().filter(|v| out.status.success() && !v.is_empty()).map(str::to_owned)
}
@@ -915,7 +915,11 @@ impl Session {
} else {
"end_turn"
};
finished = st.turn.take().map(|t| (t, TurnEnd::Stop(stop)));
finished = st.turn.take().map(|mut t| {
// Claude Code reports a turn that failed as cancelled here.
let failure = t.failure.take().filter(|_| !t.cancelled);
(t, failure.unwrap_or(TurnEnd::Stop(stop)))
});
}
}
"assistant" if msg.get("parent_tool_use_id").is_none_or(Value::is_null) => {
@@ -1139,7 +1143,8 @@ where
/// Whether `result` ends `turn`: it answers the turn's last message, or (stopped) any of
/// its messages, or names none (the CLI's own error before the turn began).
fn belongs(turn: &Turn, result: &Value) -> bool {
if result.get("origin").is_some_and(|o| !o.is_null()) {
// Prompts are sent with the origin `human`, and their results echo it.
if result.get("origin").is_some_and(|o| !o.is_null() && o.get("kind").and_then(Value::as_str) != Some("human")) {
return false;
}
let mut uuids = strings(result.get("user_message_uuids"));
@@ -738,6 +738,50 @@ async fn a_stop_ahead_of_its_prompt_cancels_it() {
assert!(cli.sent.lock()[0].iter().all(|m| m["type"] != "user"), "nothing was sent");
}
#[tokio::test(flavor = "multi_thread")]
async fn a_failed_turn_reports_its_failure() {
let text = "Failed to authenticate: OAuth session expired and could not be refreshed";
let lifecycle = |uuid: &str, state: &str| {
Frame::Cli(json!({ "type": "command_lifecycle", "command_uuid": uuid, "state": state }))
};
let result = |uuid: &str| {
Frame::Cli(json!({
"type": "result", "subtype": "success", "is_error": true, "result": text, "stop_reason": "stop_sequence",
"terminal_reason": "api_error", "origin": { "kind": "human" }, "user_message_uuids": [uuid],
}))
};
// As Claude Code sends it, and with the turn's end ahead of its result.
for lifecycle_first in [false, true] {
let mut process = load_fixture("simple").remove(0);
process.truncate(6);
let Some(Frame::Shim(prompt)) = process.last() else { panic!("the fixture's prompt") };
let uuid = prompt["uuid"].as_str().unwrap().to_owned();
process.push(Frame::Cli(json!({
"type": "assistant", "error": "authentication_failed", "parent_tool_use_id": null,
"message": { "id": "m", "model": "<synthetic>", "content": [{ "type": "text", "text": text }] },
})));
if lifecycle_first {
process.extend([lifecycle(&uuid, "cancelled"), result(&uuid)]);
} else {
process.extend([result(&uuid), lifecycle(&uuid, "cancelled")]);
}
let cli = Arc::new(Replay {
processes: Mutex::new(VecDeque::from([process])),
sent: Arc::default(),
launched: Mutex::default(),
errors: Arc::default(),
});
let (dir, cwd) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
let h =
Harness::connect(cli.clone(), dir.path().into(), dir.path().join("p"), cwd.path().into(), Vec::new()).await;
let s = h.new_session().await;
let Err(RpcError::Server { message, .. }) = h.prompt(&s, "hi").await else {
panic!("the turn should fail (lifecycle first: {lifecycle_first})");
};
assert_eq!(message, format!("Claude Code authentication failed: {text}"));
}
}
/// Fork and undo against the real CLI, across connections, in a symlinked directory
/// (macOS `/tmp`).
#[tokio::test(flavor = "multi_thread")]
@@ -829,6 +873,7 @@ fn which_result_ends_a_turn() {
assert!(!belongs(&t, &json!({"user_message_uuids": ["a"]})), "a steer superseded it");
assert!(belongs(&t, &json!({"subtype": "error_during_execution"})), "names no message");
assert!(!belongs(&t, &json!({"origin": {"kind": "task-notification"}})), "Claude Code's own turn");
assert!(belongs(&t, &json!({"origin": {"kind": "human"}, "user_message_uuids": ["b"]})), "the prompt's own result");
assert!(belongs(&turn(&["a", "b"], true), &json!({"user_message_uuids": ["a"]})));
}
+72 -20
View File
@@ -21,18 +21,27 @@ use std::sync::Arc;
/// The newest install of `command` on the `PATH` a child sees, by its `--version`.
/// Several installs are common (npm, Homebrew, the vendor's own installer), and the first
/// on `PATH` is often an old one that the service no longer serves the current models to.
/// A path, or a name found once (or not at all), is used as it is.
/// A path, or a name not found on `PATH`, is used as it is; a single match returns its
/// resolved path so Windows `PATHEXT` shims (`claude.cmd`, `codex.cmd`) start.
pub(crate) fn newest_install(command: &str) -> String {
if Path::new(command).components().count() > 1 {
let path = ide_core::process::host_var_os("PATH")
.or_else(|| ide_core::process::shell_env().find(|(k, _)| *k == "PATH").map(|(_, v)| v.to_owned()))
.unwrap_or_default();
// Windows: read afresh, so a tool the user installed since the IDE started is found too.
newest_install_in(command, &ide_core::process::with_user_path(&path))
}
fn newest_install_in(command: &str, search: &std::ffi::OsStr) -> String {
if Path::new(command).components().count() > 1 || Path::new(command).is_absolute() {
return command.to_owned();
}
let path = ide_core::process::host_var_os("PATH")
.or_else(|| ide_core::process::shell_env().find(|(k, _)| *k == "PATH").map(|(_, v)| v.to_owned()));
let pathext = ide_core::process::host_var_os("PATHEXT");
let names =
ide_core::process::executable_names(command, cfg!(windows), pathext.as_deref().and_then(|p| p.to_str()));
let mut seen = Vec::new();
let candidates: Vec<PathBuf> = path
.iter()
.flat_map(std::env::split_paths)
.map(|dir| dir.join(command))
let candidates: Vec<PathBuf> = std::env::split_paths(search)
.filter(|d| !d.as_os_str().is_empty())
.flat_map(|dir| names.iter().map(move |n| dir.join(n)))
.filter(|p| p.is_file())
.filter(|p| {
let real = dunce::canonicalize(p).unwrap_or_else(|_| p.clone());
@@ -42,7 +51,7 @@ pub(crate) fn newest_install(command: &str) -> String {
})
.collect();
if candidates.len() < 2 {
return command.to_owned();
return candidates.into_iter().next().map_or_else(|| command.to_owned(), |p| p.to_string_lossy().into_owned());
}
candidates
.into_iter()
@@ -56,7 +65,7 @@ fn version_of(program: &Path) -> Option<Vec<u64>> {
let mut cmd = std::process::Command::new(program);
ide_core::process::prepare(&mut cmd);
let out = cmd.arg("--version").envs(ide_core::process::shell_env()).output().ok()?;
parse_version(&String::from_utf8_lossy(&out.stdout))
parse_version(&ide_core::output_encoding::decode_tool_output(&out.stdout))
}
fn parse_version(text: &str) -> Option<Vec<u64>> {
@@ -66,16 +75,6 @@ fn parse_version(text: &str) -> Option<Vec<u64>> {
})
}
#[cfg(test)]
#[test]
fn versions_are_read_from_either_side_of_the_name() {
assert_eq!(parse_version("codex-cli 0.164.0\n"), Some(vec![0, 164, 0]));
assert_eq!(parse_version("2.1.312 (Claude Code)"), Some(vec![2, 1, 312]));
assert_eq!(parse_version("v1.2"), Some(vec![1, 2]));
assert_eq!(parse_version("no version here"), None);
assert!(parse_version("codex-cli 0.164.0") > parse_version("codex-cli 0.147.0"));
}
/// The agent side of an in-process ACP connection.
pub(crate) trait Shim: Handler {
/// Called once, before any message arrives: the connection to the client, for
@@ -94,3 +93,56 @@ pub(crate) fn serve<S: Shim>(shim: Arc<S>, client: Arc<dyn Handler>) -> Peer {
shim.attach(to_client);
Peer::connect_framed(or, ow, client, Framing::Lines)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn versions_are_read_from_either_side_of_the_name() {
assert_eq!(parse_version("codex-cli 0.164.0\n"), Some(vec![0, 164, 0]));
assert_eq!(parse_version("2.1.312 (Claude Code)"), Some(vec![2, 1, 312]));
assert_eq!(parse_version("v1.2"), Some(vec![1, 2]));
assert_eq!(parse_version("no version here"), None);
assert!(parse_version("codex-cli 0.164.0") > parse_version("codex-cli 0.147.0"));
}
#[test]
fn newest_install_resolves_single_candidate_and_keeps_missing_or_paths() {
let dir = tempfile::tempdir().unwrap();
let a = dir.path().join("a");
let b = dir.path().join("b");
std::fs::create_dir_all(&a).unwrap();
std::fs::create_dir_all(&b).unwrap();
std::fs::write(a.join("claude"), "#!/bin/sh\n").unwrap();
std::fs::write(b.join("claude.cmd"), "@echo off\n").unwrap();
let search = std::env::join_paths([&a, &b]).unwrap();
if cfg!(windows) {
assert_eq!(newest_install_in("claude", &search), b.join("claude.cmd").to_string_lossy());
} else {
assert_eq!(newest_install_in("claude", &search), a.join("claude").to_string_lossy());
}
assert_eq!(newest_install_in("nope", &search), "nope");
assert_eq!(newest_install_in("bin/claude", &search), "bin/claude");
}
#[cfg(unix)]
#[test]
fn newest_install_compares_versions_across_path_entries() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let old = dir.path().join("old");
let new = dir.path().join("new");
for (path, version) in [(&old, "2.9.0"), (&new, "2.10.0")] {
std::fs::create_dir_all(path).unwrap();
let executable = path.join("agent");
std::fs::write(&executable, format!("#!/bin/sh\necho {version}\n")).unwrap();
std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o755)).unwrap();
}
for paths in [[&old, &new], [&new, &old]] {
let search = std::env::join_paths(paths).unwrap();
assert_eq!(newest_install_in("agent", &search), new.join("agent").to_string_lossy());
}
}
}
+22 -1
View File
@@ -222,6 +222,13 @@ fn choose<'a>(entry: &'a Entry, platform: Platform, on_path: &dyn Fn(&str) -> bo
{
return Some(Choice::Uvx(p));
}
// Last resort: npx and uvx run natively, this one under OS emulation.
if platform.os == Os::Windows
&& platform.arch == Arch::Arm64
&& let Some(b) = d.binary.get("windows-x86_64")
{
return Some(Choice::Binary(b));
}
None
}
@@ -335,7 +342,7 @@ pub fn mark_updates(paths: &RegistryPaths, providers: &mut [AgentProvider]) {
/// HTTPS only, redirects included. Tests serve downloads over `file://`.
fn http() -> IdeResult<Http> {
let http = Http::new(&ide_tools::default_search_path())?;
let http = Http::new(&ide_core::tool_path::search_path())?;
Ok(if cfg!(test) { http } else { http.https_only() })
}
@@ -689,6 +696,20 @@ mod tests {
const LINUX: Platform = Platform { os: Os::Linux, arch: Arch::X64 };
const WINDOWS: Platform = Platform { os: Os::Windows, arch: Arch::X64 };
#[test]
fn windows_arm_binary_prefers_native_then_x64() {
let mut entry = parse(INDEX).unwrap().remove(0);
let arm = Platform { os: Os::Windows, arch: Arch::Arm64 };
assert!(matches!(choose(&entry, arm, &|_| false), Some(Choice::Binary(_))));
assert!(choose(&entry, Platform { os: Os::Linux, arch: Arch::Arm64 }, &|_| false).is_none());
entry.distribution.npx = Some(serde_json::from_str(r#"{"package":"amp-acp"}"#).unwrap());
assert!(matches!(choose(&entry, arm, &|_| true), Some(Choice::Npx(_))));
let mut native = entry.distribution.binary["windows-x86_64"].clone();
native.cmd = "native.exe".into();
entry.distribution.binary.insert("windows-aarch64".into(), native);
assert!(matches!(choose(&entry, arm, &|_| false), Some(Choice::Binary(b)) if b.cmd == "native.exe"));
}
const INDEX: &str = r#"{"version":"1.0.0","agents":[
{"id":"amp-acp","name":"Amp","version":"0.10.0","description":"Amp over ACP",
"repository":"https://github.com/x/amp-acp","authors":["x"],"license":"Apache-2.0",
+17 -1
View File
@@ -185,7 +185,23 @@ pub(crate) fn recorder(out: PathBuf) -> Connector {
let file = Arc::new(Mutex::new(String::new()));
let connections = Arc::new(Mutex::new(0u32));
Arc::new(move |spec, cwd, client, on_stderr| {
let mut cmd = tokio::process::Command::new(&spec.command);
let program = {
let path = Path::new(&spec.command);
if path.components().count() > 1 || path.is_absolute() {
path.to_path_buf()
} else {
let search = spec
.env
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case("PATH"))
.map(|(_, v)| std::ffi::OsString::from(v))
.or_else(|| ide_core::process::shell_env().find(|(k, _)| k == "PATH").map(|(_, v)| v))
.or_else(|| ide_core::process::host_var_os("PATH"))
.unwrap_or_default();
ide_core::process::find_executable(&spec.command, &search).unwrap_or_else(|| path.to_path_buf())
}
};
let mut cmd = tokio::process::Command::new(&program);
ide_core::process::prepare(cmd.as_std_mut());
let mut child = cmd
.args(&spec.args)
+1
View File
@@ -56,6 +56,7 @@ fn config(provider: &str) -> Vec<AgentConfigChoice> {
async fn create(mgr: &AgentManager, provider: &str, prompt: &str) -> String {
let req = NewAgentTask {
project_root: None,
prompt: text(prompt),
provider: provider.into(),
config: config(provider),
+164 -3
View File
@@ -210,10 +210,11 @@ async fn commands_are_remembered_per_project_and_merged_with_skills_on_disk() {
let names = |c: Vec<AgentCommand>| c.into_iter().map(|c| c.name).collect::<Vec<_>>();
let mgr_a = manager_at(&a, &models, Arc::new(FakeAgent::default()));
// Before any session: the skills on disk.
// Before any session: only the skills on disk.
assert_eq!(names(mgr_a.commands("claude", None).await.unwrap()), ["triage"]);
let task = mgr_a
.create(NewAgentTask {
project_root: None,
prompt: text("Fix the lexer"),
provider: "claude".into(),
config: Vec::new(),
@@ -227,6 +228,8 @@ async fn commands_are_remembered_per_project_and_merged_with_skills_on_disk() {
assert_eq!(names(mgr_a.commands("claude", Some(&task.id)).await.unwrap()), ["review", "triage"]);
// Other agents get only what they offered.
assert!(mgr_a.commands("codex", None).await.unwrap().is_empty());
mgr_a.cancel(&task.id, None).unwrap();
wait_for(&mgr_a, &task.id, AgentStatus::Ready).await;
mgr_a.shutdown();
// Another project shares the settings but not the commands.
@@ -239,6 +242,46 @@ async fn commands_are_remembered_per_project_and_merged_with_skills_on_disk() {
assert_eq!(names(mgr_a.providers().into_iter().find(|p| p.id == "claude").unwrap().commands), ["review"]);
}
#[tokio::test(flavor = "multi_thread")]
async fn builtin_commands_only_fill_verified_gaps_and_preserve_advertised_metadata() {
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent::default());
let mgr = manager_at(tmp.path(), &tmp.path().join("models.json"), agent);
for provider in ["claude", "claude-acp", "opencode2", "codex", "custom"] {
assert!(mgr.commands(provider, None).await.unwrap().is_empty(), "{provider}");
}
let commands = mgr.commands("opencode", None).await.unwrap();
assert_eq!(commands.iter().map(|c| c.name.as_str()).collect::<Vec<_>>(), ["compact"]);
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("Hello"),
provider: "opencode".into(),
config: Vec::new(),
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
let advertised = AgentCommand {
name: "compact".into(),
description: "Agent supplied description".into(),
hint: Some("instructions".into()),
};
mgr.task(&task.id).unwrap().state.lock().task.commands = vec![advertised.clone()];
// The task's provider wins over the caller's provider; metadata is not replaced.
let commands = mgr.commands("claude", Some(&task.id)).await.unwrap();
assert_eq!(commands.len(), 1);
assert_eq!(commands[0].name, advertised.name);
assert_eq!(commands[0].description, advertised.description);
assert_eq!(commands[0].hint, advertised.hint);
// Finish the in-process agent before Tokio stops driving its permission response.
mgr.cancel(&task.id, None).unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
mgr.shutdown();
}
async fn wait_for(mgr: &AgentManager, id: &str, status: AgentStatus) {
for _ in 0..500 {
let task = mgr.task(id).unwrap();
@@ -268,6 +311,7 @@ async fn a_task_runs_asks_for_permission_and_survives_a_restart() {
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("Fix the lexer\nmore detail"),
provider: "claude".into(),
config: vec![AgentConfigChoice { id: "model".into(), value: "small".into() }],
@@ -374,6 +418,7 @@ async fn messages_sent_while_busy_are_queued_and_send_now_interrupts() {
let mgr = manager(tmp.path(), agent.clone());
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("wait"),
provider: "claude".into(),
config: vec![],
@@ -428,6 +473,7 @@ async fn subagents_get_their_own_conversation() {
let mgr = manager(tmp.path(), agent.clone());
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("delegate"),
provider: "claude".into(),
config: vec![],
@@ -521,6 +567,7 @@ async fn cancelling_resolves_the_open_permission_and_delete_forgets_the_task() {
let mgr = manager(tmp.path(), Arc::new(FakeAgent::default()));
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("do it"),
provider: "codex".into(),
config: vec![],
@@ -542,6 +589,7 @@ async fn cancelling_resolves_the_open_permission_and_delete_forgets_the_task() {
assert!(!tmp.path().join("store").join(format!("{}.json", task.id)).exists());
assert!(
mgr.create(NewAgentTask {
project_root: None,
prompt: text(" "),
provider: "codex".into(),
config: vec![],
@@ -587,6 +635,7 @@ async fn real_claude_task_in_a_worktree() {
);
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("Create a file named hello.txt containing the single word hi. Do nothing else."),
provider: "claude".into(),
config: vec![AgentConfigChoice { id: "model".into(), value: "haiku".into() }],
@@ -635,6 +684,7 @@ async fn forks_rollbacks_and_steering() {
let agent = Arc::new(FakeAgent::default());
let mgr = manager(tmp.path(), agent.clone());
let new = |prompt: &str| NewAgentTask {
project_root: None,
prompt: text(prompt),
provider: "claude".into(),
config: Vec::new(),
@@ -685,6 +735,7 @@ async fn usage_limits_and_late_settings() {
let mgr = manager(tmp.path(), agent.clone());
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("limit"),
provider: "claude".into(),
// `effort` isn't offered until the session runs.
@@ -727,6 +778,7 @@ async fn cursor_approval_switch_resolves_pending_and_survives_restart() {
let agent = Arc::new(FakeAgent::default());
let mgr = manager(tmp.path(), agent.clone());
let new = || NewAgentTask {
project_root: None,
prompt: text("Fix"),
provider: "cursor".into(),
config: Vec::new(),
@@ -752,15 +804,17 @@ async fn cursor_approval_switch_resolves_pending_and_survives_restart() {
mgr.prompt(&task.id, text("Fix again"), false).await.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
assert!(!agent.calls.lock().iter().any(|c| c.contains("bypass")));
assert!(!config::bypass_approvals("cursor", &mgr.shared.known_config("cursor")));
assert!(config::bypass_approvals("cursor", &mgr.shared.known_config("cursor")));
mgr.set_config(&task.id, config::CURSOR_APPROVAL, "ask".into()).await.unwrap();
mgr.prompt(&task.id, text("Fix once more"), false).await.unwrap();
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
mgr.cancel(&task.id, None).unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
// An unrelated new conversation still asks, even after the cache was updated.
// A task created without choosing approvals still asks; the New Task page sends the default.
let other = mgr.create(new()).await.unwrap();
wait_for(&mgr, &other.id, AgentStatus::NeedsInput).await;
mgr.cancel(&other.id, None).unwrap();
wait_for(&mgr, &other.id, AgentStatus::Ready).await;
mgr.shutdown();
}
@@ -770,6 +824,7 @@ async fn cursor_approval_choice_applies_before_first_prompt() {
let mgr = manager(tmp.path(), Arc::new(FakeAgent::default()));
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("Fix"),
provider: "cursor".into(),
config: vec![AgentConfigChoice { id: config::CURSOR_APPROVAL.into(), value: "bypass".into() }],
@@ -781,3 +836,109 @@ async fn cursor_approval_choice_applies_before_first_prompt() {
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
mgr.shutdown();
}
#[tokio::test(flavor = "multi_thread")]
async fn archived_tasks_can_be_restored_and_continue_their_session() {
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("again"),
provider: "claude".into(),
config: vec![],
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
let entries = mgr.entries(&task.id, None).unwrap();
mgr.archive(&task.id).unwrap();
assert!(mgr.prompt(&task.id, text("again"), false).await.is_err());
mgr.unarchive(&task.id).unwrap();
assert_eq!(mgr.task(&task.id).unwrap().state.lock().task.status, AgentStatus::Ready);
assert!(mgr.task(&task.id).unwrap().live.lock().is_none());
assert_eq!(mgr.entries(&task.id, None).unwrap().len(), entries.len());
drop(mgr);
let mgr = manager(tmp.path(), agent.clone());
assert_eq!(mgr.task(&task.id).unwrap().state.lock().task.status, AgentStatus::Ready);
mgr.prompt(&task.id, text("again"), false).await.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
assert!(agent.calls.lock().iter().any(|call| call == "session/load"));
assert_eq!(mgr.entries(&task.id, None).unwrap().len(), entries.len() + 2);
mgr.unarchive(&task.id).unwrap();
}
#[tokio::test(flavor = "multi_thread")]
async fn task_project_root_controls_local_and_worktree_repositories() {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
let member = root.join("project-b");
std::fs::create_dir(&member).unwrap();
let git = |args: &[&str]| {
let out = std::process::Command::new("git")
.args(["-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false"])
.args(args)
.current_dir(&member)
.output()
.unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
String::from_utf8_lossy(&out.stdout).trim().to_owned()
};
git(&["init", "-q", "-b", "member-main"]);
std::fs::write(member.join("file.txt"), "original\n").unwrap();
git(&["add", "."]);
git(&["commit", "-qm", "initial"]);
let mgr = manager(&root, Arc::new(FakeAgent::default()));
for isolation in [Isolation::Local, Isolation::Worktree] {
let task = mgr
.create(NewAgentTask {
project_root: Some(IpcPath(member.clone())),
prompt: text("member task"),
provider: "claude".into(),
config: vec![],
isolation,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
let permission = mgr
.entries(&task.id, None)
.unwrap()
.iter()
.find_map(|e| match e {
AgentEntry::Permission { id, .. } => Some(*id),
_ => None,
})
.unwrap();
mgr.respond(&task.id, permission, Some("yes".into())).unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
assert_eq!(task.project_root, Some(IpcPath(member.clone())));
assert_eq!(task.base.as_deref(), Some("member-main"));
if isolation == Isolation::Local {
assert_eq!(task.cwd.0, member);
} else {
assert_ne!(task.cwd.0, member);
assert_eq!(std::fs::read_to_string(task.cwd.0.join("file.txt")).unwrap(), "original\n");
}
std::fs::write(task.cwd.0.join("file.txt"), "changed\n").unwrap();
let changes = mgr.changes(&task.id).await.unwrap();
assert_eq!(changes.len(), 1);
assert_eq!(changes[0].path.0, task.cwd.0.join("file.txt"));
assert_eq!(mgr.base_text(&task.id, changes[0].path.0.clone()).await.unwrap().as_deref(), Some("original\n"));
let saved = std::fs::read(root.join("store").join(format!("{}.json", task.id))).unwrap();
assert!(String::from_utf8(saved).unwrap().contains("projectRoot"));
let mut old = serde_json::to_value(&task).unwrap();
old.as_object_mut().unwrap().remove("projectRoot");
assert!(serde_json::from_value::<AgentTask>(old).unwrap().project_root.is_none());
mgr.delete(&task.id, true).await.unwrap();
if isolation == Isolation::Worktree {
assert!(!task.cwd.0.exists());
}
std::fs::write(member.join("file.txt"), "original\n").unwrap();
}
assert_eq!(git(&["worktree", "list", "--porcelain"]).matches("worktree ").count(), 1);
}
+1
View File
@@ -446,6 +446,7 @@ mod tests {
let user = |sent: f64| AgentEntry::User {
text: "q".into(),
attachments: vec![],
images: vec![],
sent_ms: Some(sent),
done_ms: None,
steered: false,
+22 -2
View File
@@ -53,8 +53,9 @@ pub(crate) fn remove(project_root: &Path, path: &Path) -> IdeResult<()> {
}
/// Files under `cwd` that differ from `base` (untracked files included), with line counts.
/// Outside a repository there is nothing to compare against: no changes.
pub(crate) fn changes(cwd: &Path, base: &str) -> IdeResult<Vec<AgentChange>> {
let repo = repo_at(cwd)?;
let Some(repo) = Repo::discover(cwd)? else { return Ok(Vec::new()) };
let root = repo.root().to_path_buf();
let stats = repo.numstat_with_worktree(base)?;
let stat = |rel: &str| stats.iter().find(|(p, ..)| p == rel).map_or((0, 0), |&(_, a, r)| (a, r));
@@ -96,7 +97,12 @@ pub(crate) fn without_untouched(changes: Vec<AgentChange>, before: &BTreeMap<Str
}
pub(crate) fn file_at(cwd: &Path, path: &Path, rev: &str) -> IdeResult<Option<String>> {
repo_at(cwd)?.file_at(path, Some(rev))
let Some(repo) = Repo::discover(cwd)? else { return Ok(None) };
let encoding = match ide_vfs::load_text(path) {
Ok(loaded) => encoding_rs::Encoding::for_label(loaded.encoding.as_bytes()).unwrap_or(encoding_rs::UTF_8),
Err(_) => ide_vfs::detect_encoding(&repo.file_bytes_at(path, Some(rev))?.unwrap_or_default(), None),
};
repo.file_at(path, Some(rev), encoding)
}
/// A short ASCII branch-safe name from the task title.
@@ -136,6 +142,20 @@ mod tests {
assert_eq!(unique_slug("x", |s| s == "x" || s == "x-2"), "x-3");
}
#[test]
fn outside_a_repository_there_is_nothing_to_compare() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("plain");
std::fs::create_dir(&dir).unwrap();
std::fs::write(dir.join("a.txt"), "one\n").unwrap();
assert!(changes(&dir, "HEAD").unwrap().is_empty());
assert!(snapshot(&dir).unwrap().is_empty());
assert_eq!(file_at(&dir, &dir.join("a.txt"), "HEAD").unwrap(), None);
assert!(create(&dir, &tmp.path().join("wt"), "Edit A", None).is_err());
assert!(remove(&dir, &dir).is_err());
}
fn git(dir: &Path, args: &[&str]) {
let ok = std::process::Command::new("git")
.args(["-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false"])
+218
View File
@@ -0,0 +1,218 @@
//! Resource processing by the project's build tool, then publishing to the live JVM's output directory.
use crate::BuildModel;
use ide_core::{IdeError, IdeResult};
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
pub struct ResourceBuild {
pub command: String,
pub args: Vec<String>,
pub cwd: String,
pub env: BTreeMap<String, String>,
pub kind: &'static str,
}
fn literal(value: &str) -> String {
format!("'{}'", value.replace('\\', "\\\\").replace('\'', "\\'"))
}
pub fn plan(root: &Path, model: &BuildModel, module: &Path, cache: &Path) -> IdeResult<ResourceBuild> {
fs::create_dir_all(cache)?;
let manifest = cache.join("resource-output.txt");
if manifest.exists() {
fs::remove_file(&manifest)?;
}
let module = dunce::canonicalize(root.join(module))?;
if let Some(p) = model.maven.as_ref().and_then(|m| m.projects.iter().find(|p| root.join(&p.dir) == module)) {
let mut args = p.options.clone();
args.extend([
"process-resources".into(),
"help:evaluate".into(),
"-Dexpression=project.build.outputDirectory".into(),
format!("-Doutput={}", manifest.display()),
]);
return Ok(ResourceBuild {
command: p.command.clone(),
args,
cwd: p.dir.clone(),
env: p.env.clone(),
kind: "maven",
});
}
if let Some(g) = &model.gradle
&& let Some(p) = g.projects.iter().find(|p| root.join(&p.dir) == module)
{
let script = cache.join("resources.init.gradle");
fs::write(
&script,
format!(
r#"gradle.projectsEvaluated {{
def p = gradle.rootProject.project({})
def resources = p.tasks.named('processResources')
p.tasks.register('openideaUpdateResources') {{
dependsOn resources
doLast {{ new File({}).setText(resources.get().destinationDir.absolutePath, 'UTF-8') }}
}}
}}
"#,
literal(&p.path),
literal(&manifest.to_string_lossy())
),
)?;
return Ok(ResourceBuild {
command: g.command.clone(),
args: vec![
"--init-script".into(),
script.to_string_lossy().into_owned(),
format!("{}:openideaUpdateResources", p.path.trim_end_matches(':')),
],
cwd: g.dir.clone(),
env: g.env.clone(),
kind: "gradle",
});
}
Err(IdeError::invalid("The Spring Boot module must belong to a linked Maven or Gradle build"))
}
pub fn publish(root: &Path, cache: &Path, class_paths: &[String], main_class: &str) -> IdeResult<()> {
let output = PathBuf::from(fs::read_to_string(cache.join("resource-output.txt"))?.trim());
if !output.is_absolute() || !output.is_dir() {
return Err(IdeError::invalid("The resource build did not report an output directory"));
}
let output = dunce::canonicalize(output)?;
let paths: Vec<_> = class_paths.iter().filter_map(|p| dunce::canonicalize(root.join(p)).ok()).collect();
// A separate resource directory is already on the runtime classpath (e.g. build/resources/main).
if paths.contains(&output) {
return Ok(());
}
// jdtls uses bin/main for Gradle, rather than Gradle's build/resources/main.
let class_file = main_class.split('.').collect::<PathBuf>().with_extension("class");
let destination = paths.iter().find(|p| p.join(&class_file).is_file()).ok_or_else(|| {
IdeError::invalid("Cannot find the running main class output directory; restart the application")
})?;
let previous: Vec<String> = fs::read(cache.join("published-resources.json"))
.ok()
.and_then(|bytes| serde_json::from_slice(&bytes).ok())
.unwrap_or_default();
let mut current = Vec::new();
copy_resources(&output, &output, destination, &mut current)?;
for rel in previous.iter().filter(|p| !current.contains(p)) {
let path = destination.join(rel);
if path.is_file() {
fs::remove_file(path)?;
}
}
fs::write(
cache.join("published-resources.json"),
serde_json::to_vec(&current).map_err(|e| IdeError::internal(e.to_string()))?,
)?;
Ok(())
}
fn copy_resources(root: &Path, from: &Path, to: &Path, copied: &mut Vec<String>) -> IdeResult<()> {
for entry in fs::read_dir(from)? {
let entry = entry?;
let path = entry.path();
let kind = entry.file_type()?;
if kind.is_symlink() {
continue;
}
if kind.is_dir() {
copy_resources(root, &path, to, copied)?;
} else if kind.is_file() && path.extension().is_none_or(|e| e != "class") {
let rel = path.strip_prefix(root).map_err(|e| IdeError::internal(e.to_string()))?;
let dest = to.join(rel);
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent)?;
}
fs::copy(&path, dest)?;
copied.push(rel.to_string_lossy().into_owned());
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resource_plans_use_the_selected_module_and_build_configuration() {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
fs::create_dir(root.join("app")).unwrap();
let mut model = BuildModel {
maven: Some(crate::MavenModel {
projects: vec![crate::MavenProject {
dir: "app".into(),
command: "/tools/mvnw".into(),
options: vec!["-Pdev".into()],
env: BTreeMap::from([("JAVA_HOME".into(), "/jdk".into())]),
..Default::default()
}],
..Default::default()
}),
..Default::default()
};
let cache = root.join("cache");
let maven = plan(&root, &model, Path::new("app"), &cache).unwrap();
assert_eq!(maven.command, "/tools/mvnw");
assert_eq!(maven.cwd, "app");
assert_eq!(maven.args[0..2], ["-Pdev", "process-resources"]);
assert!(maven.args.contains(&"-Dexpression=project.build.outputDirectory".into()));
assert_eq!(maven.env["JAVA_HOME"], "/jdk");
model.maven = None;
model.gradle = Some(crate::GradleModel {
command: "/tools/gradlew".into(),
projects: vec![crate::GradleProject { path: ":app".into(), dir: "app".into(), ..Default::default() }],
..Default::default()
});
let gradle = plan(&root, &model, Path::new("app"), &cache).unwrap();
assert_eq!(gradle.command, "/tools/gradlew");
assert_eq!(gradle.args.last().unwrap(), ":app:openideaUpdateResources");
let script = fs::read_to_string(cache.join("resources.init.gradle")).unwrap();
assert!(script.contains("processResources") && script.contains("destinationDir.absolutePath"));
assert!(!script.contains("build/resources/main")); // The task owns custom output locations.
}
#[test]
fn publishes_maven_and_gradle_resource_outputs_to_the_runtime_classpath() {
for layout in ["target/classes", "build/resources/main", "custom resource output"] {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let cache = root.join("cache");
let output = root.join(layout);
let runtime = if layout == "target/classes" { output.clone() } else { root.join("bin/main") };
fs::create_dir_all(output.join("static")).unwrap();
fs::create_dir_all(runtime.join("example")).unwrap();
fs::create_dir_all(&cache).unwrap();
fs::write(cache.join("resource-output.txt"), output.to_string_lossy().as_bytes()).unwrap();
fs::write(output.join("static/index.html"), "after").unwrap();
fs::write(output.join("unrelated.class"), "do not publish").unwrap();
fs::write(runtime.join("example/Application.class"), "running").unwrap();
publish(root, &cache, &[runtime.to_string_lossy().into_owned()], "example.Application").unwrap();
assert_eq!(fs::read_to_string(runtime.join("static/index.html")).unwrap(), "after");
assert_eq!(fs::read_to_string(runtime.join("example/Application.class")).unwrap(), "running");
if output != runtime {
assert!(!runtime.join("unrelated.class").exists());
fs::remove_file(output.join("static/index.html")).unwrap();
publish(root, &cache, &[runtime.to_string_lossy().into_owned()], "example.Application").unwrap();
assert!(!runtime.join("static/index.html").exists());
}
}
}
#[test]
fn leaves_separate_runtime_resource_directories_in_place_and_rejects_jar_only_runs() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let output = root.join("build/resources/main");
let cache = root.join("cache");
fs::create_dir_all(&output).unwrap();
fs::create_dir(&cache).unwrap();
fs::write(cache.join("resource-output.txt"), output.to_string_lossy().as_bytes()).unwrap();
publish(root, &cache, &[output.to_string_lossy().into_owned()], "a.Main").unwrap();
assert!(publish(root, &cache, &["app.jar".into()], "a.Main").is_err());
}
}
+321
View File
@@ -0,0 +1,321 @@
//! Metadata fingerprints for successful Maven launch builds.
use super::*;
use std::fs;
use std::hash::{Hash, Hasher};
use std::time::SystemTime;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct LaunchFingerprint {
version: u32,
parameters: String,
files: BTreeMap<PathBuf, Option<(u64, SystemTime)>>,
}
fn scan(
path: &Path,
files: &mut BTreeMap<PathBuf, Option<(u64, SystemTime)>>,
ancestors: &mut HashSet<PathBuf>,
cancel: &CancellationToken,
) -> IdeResult<()> {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let meta = match fs::metadata(path) {
Ok(meta) => meta,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
files.insert(path.to_path_buf(), None);
return Ok(());
}
Err(e) => return Err(e.into()),
};
if meta.is_dir() {
let canonical = dunce::canonicalize(path)?;
if !ancestors.insert(canonical.clone()) {
return Err(IdeError::internal("cyclic launch input directory"));
}
// Directory mtimes change on irrelevant JDT writes. File paths detect additions/deletions.
files.insert(path.to_path_buf(), None);
for entry in fs::read_dir(path)? {
scan(&entry?.path(), files, ancestors, cancel)?;
}
ancestors.remove(&canonical);
} else if meta.is_file() {
files.insert(path.to_path_buf(), Some((meta.len(), meta.modified()?)));
} else {
return Err(IdeError::internal("unsupported launch input file"));
}
Ok(())
}
/// `None` means an input cannot be resolved safely: build normally without reuse.
/// All filesystem work belongs on the caller's blocking thread.
pub fn launch_fingerprint(
root: &Path,
model: &BuildModel,
module: &Path,
test: bool,
local_repo: Option<PathBuf>,
cancel: &CancellationToken,
) -> IdeResult<Option<LaunchFingerprint>> {
let Some(m) = &model.maven else { return Ok(None) };
let projects = maven_launch_projects(root, m, module, test);
if projects.is_empty() {
return Ok(None);
}
let Some(mut inputs) = maven::launch_inputs(root, &projects, local_repo) else { return Ok(None) };
let plan = launch_plan(root, model, module, test)?;
let mut environment: BTreeMap<String, String> = std::env::vars()
.map(|(key, value)| (key.to_ascii_uppercase(), value))
.filter(|(key, _)| {
key.starts_with("MAVEN_")
|| key.starts_with("JAVA_")
|| key.starts_with("JDK_")
|| matches!(
key.as_str(),
"PATH" | "HOME" | "LANG" | "LC_ALL" | "_JAVA_OPTIONS" | "M2_HOME" | "MAVEN_HOME"
)
})
.collect();
// Explicit step values override the inherited environment just as they do in Command.
for step in &plan.steps {
environment.extend(step.env.iter().map(|(key, value)| (key.to_ascii_uppercase(), value.clone())));
for dir in step.cwd.ancestors() {
inputs.extend([dir.join(".mvn"), dir.join("mvnw"), dir.join("mvnw.cmd")]);
if dir == root {
break;
}
}
let program = process::program_path(&step.program, &step.env);
inputs.push(program.clone());
// Maven installed via a symlink still reads conf from its distribution.
if let Ok(program) = dunce::canonicalize(program)
&& let Some(home) = program.parent().and_then(Path::parent)
{
for conf in ["conf", "libexec/conf"] {
inputs.extend([home.join(conf).join("settings.xml"), home.join(conf).join("toolchains.xml")]);
}
}
if !environment.contains_key("JAVA_HOME") {
let java = process::program_path(if cfg!(windows) { "java.exe" } else { "java" }, &step.env);
inputs.push(java.clone());
if let Ok(java) = dunce::canonicalize(java)
&& let Some(home) = java.parent().and_then(Path::parent)
{
inputs.push(home.join("release"));
}
}
for (i, arg) in step.args.iter().enumerate() {
if matches!(arg.as_str(), "-s" | "--settings" | "-gs" | "--global-settings" | "-t" | "--toolchains")
&& let Some(file) = step.args.get(i + 1)
{
inputs.push(step.cwd.join(file));
}
for prefix in ["--settings=", "--global-settings=", "--toolchains="] {
if let Some(file) = arg.strip_prefix(prefix) {
inputs.push(step.cwd.join(file));
}
}
}
}
if let Some(home) = dirs::home_dir() {
inputs.extend([home.join(".m2/settings.xml"), home.join(".m2/toolchains.xml")]);
}
for key in ["MAVEN_HOME", "M2_HOME"] {
if let Some(home) = environment.get(key) {
inputs.extend([Path::new(home).join("conf/settings.xml"), Path::new(home).join("conf/toolchains.xml")]);
}
}
if let Some(home) = environment.get("JAVA_HOME") {
inputs.extend([
Path::new(home).join("release"),
Path::new(home).join("lib/modules"),
Path::new(home).join("lib/tools.jar"),
Path::new(home).join(if cfg!(windows) { "bin/java.exe" } else { "bin/java" }),
]);
}
let steps: Vec<_> =
plan.steps.iter().map(|s| (process::program_path(&s.program, &s.env), &s.args, &s.cwd, &s.env)).collect();
// Profiles and plugins can read arbitrary environment variables. Keep a signature
// of the inherited environment without persisting unrelated credentials.
let inherited: BTreeMap<_, _> = std::env::vars_os().collect();
let mut inherited_hash = std::hash::DefaultHasher::new();
inherited.hash(&mut inherited_hash);
let parameters =
serde_json::to_string(&(root, module, test, steps, environment, inherited_hash.finish(), projects))
.map_err(|e| IdeError::internal(e.to_string()))?;
let mut files = BTreeMap::new();
for path in inputs {
scan(&path, &mut files, &mut HashSet::new(), cancel)?;
}
// Store project inputs relative to the project, including each file's relative path.
let files = files.into_iter().map(|(p, stamp)| (p.strip_prefix(root).unwrap_or(&p).to_path_buf(), stamp)).collect();
Ok(Some(LaunchFingerprint { version: 1, parameters, files }))
}
#[cfg(test)]
mod tests {
use super::*;
fn write(root: &Path, rel: &str, value: &str) {
let path = root.join(rel);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, value).unwrap();
}
fn fixture(root: &Path) -> BuildModel {
write(
root,
"pom.xml",
r#"<project><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version>
<packaging>pom</packaging><modules><module>lib</module><module>app</module></modules></project>"#,
);
write(
root,
"lib/pom.xml",
r#"<project><parent><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version></parent>
<artifactId>lib</artifactId><build><sourceDirectory>code</sourceDirectory><resources><resource><directory>conf</directory></resource></resources></build></project>"#,
);
write(
root,
"app/pom.xml",
r#"<project><parent><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version></parent>
<artifactId>app</artifactId><dependencies><dependency><groupId>demo</groupId><artifactId>lib</artifactId><version>1</version></dependency></dependencies></project>"#,
);
write(root, "lib/src/main/kotlin/Channel.kt", "value");
write(root, "app/src/main/resources/app.properties", "key=value");
write(root, "lib/conf/custom.properties", "custom=value");
write(root, "lib/code/Custom.java", "class Custom {}");
BuildModel { maven: Some(maven::load(root)), ..Default::default() }
}
fn fingerprint(root: &Path, model: &BuildModel) -> Option<LaunchFingerprint> {
launch_fingerprint(root, model, &root.join("app"), false, None, &CancellationToken::new()).unwrap()
}
#[test]
fn unchanged_inputs_match_but_source_resource_pom_add_delete_and_mtime_invalidate() {
for rel in [
"lib/src/main/kotlin/Channel.kt",
"app/src/main/resources/app.properties",
"lib/conf/custom.properties",
"lib/code/Custom.java",
"app/pom.xml",
"lib/pom.xml",
"pom.xml",
".mvn/maven.config",
"lib/src/main/groovy/New.groovy",
"app/src/test/scala/Test.scala",
] {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let model = fixture(root);
let before = fingerprint(root, &model);
assert!(before.is_some());
assert_eq!(before, fingerprint(root, &model), "unchanged {rel}");
let serialized = serde_json::to_vec(&before).unwrap();
assert_eq!(before, serde_json::from_slice(&serialized).unwrap());
let value = if rel.ends_with("pom.xml") {
format!("{}<!-- changed -->", fs::read_to_string(root.join(rel)).unwrap())
} else {
"changed input".to_owned()
};
write(root, rel, &value);
let changed = fingerprint(root, &model);
assert_ne!(before, changed, "changed/added {rel}");
let file = fs::OpenOptions::new().write(true).open(root.join(rel)).unwrap();
file.set_modified(SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(42)).unwrap();
let touched = fingerprint(root, &model);
assert_ne!(changed, touched, "mtime-only {rel}");
fs::remove_file(root.join(rel)).unwrap();
assert_ne!(touched, fingerprint(root, &model), "deleted {rel}");
}
}
#[test]
fn profile_options_jdk_goal_and_wrapper_invalidate_without_using_class_outputs() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let mut model = fixture(root);
let before = fingerprint(root, &model);
write(root, "lib/target/classes/Channel.class", "JDT stub");
write(root, "app/target/classes/Main.class", "JDT stub");
assert_eq!(before, fingerprint(root, &model));
let mut previous = before.clone();
for option in ["-Pdev", "-Pprod", "-DskipTests", "-o"] {
model.maven.as_mut().unwrap().projects[0].options = vec![option.into()];
let current = fingerprint(root, &model);
assert_ne!(previous, current, "{option}");
previous = current;
}
model.maven.as_mut().unwrap().projects[0].options.clear();
model.maven.as_mut().unwrap().projects[0]
.env
.insert("JAVA_HOME".into(), root.join("jdk").to_string_lossy().into_owned());
let jdk = fingerprint(root, &model);
assert_ne!(before, jdk);
write(root, "jdk/release", "JAVA_VERSION=21");
assert_ne!(jdk, fingerprint(root, &model));
model.maven.as_mut().unwrap().projects[0].env.clear();
let test = launch_fingerprint(root, &model, &root.join("app"), true, None, &CancellationToken::new())
.unwrap()
.unwrap();
assert_ne!(before, Some(test));
write(root, "mvnw", "wrapper changed");
assert_ne!(before, fingerprint(root, &model));
}
#[test]
fn unresolved_roots_and_cancel_never_claim_up_to_date() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let model = fixture(root);
write(
root,
"lib/pom.xml",
"<project><artifactId>lib</artifactId><build><sourceDirectory>${unknown}/src</sourceDirectory></build></project>",
);
assert!(
launch_fingerprint(root, &model, &root.join("app"), false, None, &CancellationToken::new())
.unwrap()
.is_none()
);
let model = fixture(root);
let cancel = CancellationToken::new();
cancel.cancel();
assert!(launch_fingerprint(root, &model, &root.join("app"), false, None, &cancel).is_err());
}
#[test]
fn external_parent_and_selected_settings_files_invalidate() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("project");
let repo = dir.path().join("repository");
write(
&repo,
"demo/parent/1/parent-1.pom",
"<project><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version></project>",
);
write(
&root,
"pom.xml",
"<project><parent><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version><relativePath/></parent><artifactId>app</artifactId></project>",
);
let mut model = crate::load(&root, &root.join("cache"), Some(repo.clone()));
let settings = dir.path().join("custom-settings.xml");
fs::write(&settings, "<settings/>").unwrap();
model.maven.as_mut().unwrap().projects[0].options = vec!["-s".into(), settings.to_string_lossy().into_owned()];
let get =
|| launch_fingerprint(&root, &model, &root, false, Some(repo.clone()), &CancellationToken::new()).unwrap();
let before = get();
assert!(before.is_some());
write(
&repo,
"demo/parent/1/parent-1.pom",
"<project><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version><!-- changed --></project>",
);
let parent = get();
assert_ne!(before, parent);
fs::write(settings, "<settings><activeProfiles><activeProfile>dev</activeProfile></activeProfiles></settings>")
.unwrap();
assert_ne!(parent, get());
}
}
+236 -7
View File
@@ -3,8 +3,11 @@
//! background with cancellation and parses the compiler output into messages
//! for the Build tool window, the Problems view and the editor.
mod fingerprint;
mod parse;
pub use fingerprint::{LaunchFingerprint, launch_fingerprint};
use crate::process::{self, Stream};
use crate::{BuildModel, maven};
use ide_core::{CancellationToken, Diagnostic, IdeError, IdeResult, Pos, Range, Severity};
@@ -234,12 +237,10 @@ fn maven_steps(root: &Path, model: &BuildModel, kind: BuildKind, module: Option<
let Some(module) = module else { return Ok(tops.iter().map(|p| step(p, &[])).collect()) };
// The reactor root containing the module builds it with what it depends on (`-pl … -am`).
let rel = rel_str(root, module);
let top = tops
.iter()
.filter(|t| t.dir.is_empty() || rel == t.dir || rel.starts_with(&format!("{}/", t.dir)))
.max_by_key(|t| t.dir.len())
.copied()
.unwrap_or(tops[0]);
if !m.projects.iter().any(|p| p.dir == rel) {
return Err(IdeError::not_found("no linked Maven project found for module"));
}
let top = maven_reactor(m, &rel).ok_or_else(|| IdeError::not_found("no linked Maven project found for module"))?;
let top_dir = subdir(root, &top.dir);
if module == top_dir {
return Ok(vec![step(top, &[])]);
@@ -608,6 +609,90 @@ pub fn has_non_java_sources(module: &Path) -> bool {
walk(&module.join("src"), 12)
}
fn reactor_projects<'a>(m: &'a maven::MavenModel, top: &'a maven::MavenProject) -> Vec<&'a maven::MavenProject> {
let mut projects = Vec::new();
let mut pending = vec![top];
let mut seen = HashSet::new();
while let Some(p) = pending.pop() {
if !seen.insert(&p.pom) {
continue;
}
projects.push(p);
pending.extend(m.projects.iter().filter(|child| p.modules.contains(&child.pom)));
}
projects
}
fn maven_reactor<'a>(m: &'a maven::MavenModel, module: &str) -> Option<&'a maven::MavenProject> {
m.projects.iter().filter(|p| !p.aggregated).find(|top| reactor_projects(m, top).iter().any(|p| p.dir == module))
}
/// Launch module first, followed by its upstream modules in the same Maven reactor.
/// External artifacts and unrelated linked reactors are never rebuilt for this launch.
fn maven_launch_projects<'a>(
root: &Path,
m: &'a maven::MavenModel,
module: &Path,
test: bool,
) -> Vec<&'a maven::MavenProject> {
let rel = rel_str(root, module);
let Some(top) = maven_reactor(m, &rel) else { return Vec::new() };
let reactor = reactor_projects(m, top);
let Some(start) = reactor.iter().find(|p| p.dir == rel) else { return Vec::new() };
let mut pending = vec![*start];
let mut seen = HashSet::new();
let mut projects = Vec::new();
while let Some(p) = pending.pop() {
if !seen.insert(&p.pom) {
continue;
}
projects.push(p);
for dep in &p.dependencies {
if !dep.resolved() || (dep.scope == "test" && (!test || p.pom != start.pom)) {
continue;
}
if let Some(upstream) = reactor.iter().find(|u| {
u.group_id == dep.group_id
&& u.artifact_id == dep.artifact_id
&& dep.version.as_ref().is_none_or(|v| *v == u.version)
}) {
pending.push(*upstream);
}
}
}
projects
}
/// Whether a launch requires the external JVM compiler. Maven includes upstream
/// reactor dependencies; adapter selection remains a separate, module-local decision.
pub fn launch_has_non_java_sources(root: &Path, model: &BuildModel, module: &Path, test: bool) -> bool {
if let Some(m) = &model.maven {
let projects = maven_launch_projects(root, m, module, test);
if !projects.is_empty() {
return projects.iter().any(|p| has_non_java_sources(&subdir(root, &p.dir)));
}
}
has_non_java_sources(module)
}
fn launch_plan(root: &Path, model: &BuildModel, module: &Path, test: bool) -> IdeResult<BuildPlan> {
let kind = if model.maven.as_ref().is_some_and(|m| m.projects.iter().any(|p| subdir(root, &p.dir) == module)) {
// JDT may have left newer error stubs in Maven's output directories.
BuildKind::Rebuild
} else {
BuildKind::Build
};
let mut plan = plan(root, model, kind, Some(module))?;
if plan.system == BuildSystem::Maven && !test {
for step in &mut plan.steps {
if let Some(goal) = step.args.last_mut() {
*goal = "compile".into();
}
}
}
Ok(plan)
}
/// Compiles the module at `module` with its build system before a JVM launch and
/// returns the class directories it wrote, to put ahead of the jdtls classpath.
pub fn build_for_launch(
@@ -617,7 +702,7 @@ pub fn build_for_launch(
test: bool,
cancel: &CancellationToken,
) -> IdeResult<Vec<PathBuf>> {
let plan = plan(root, model, BuildKind::Build, Some(module))?;
let plan = launch_plan(root, model, module, test)?;
let result = run(root, &plan, cancel, &|_| {})?;
if !result.success {
let errors: Vec<String> = result
@@ -632,6 +717,14 @@ pub fn build_for_launch(
.collect();
return Err(IdeError::internal(format!("Build failed:\n{}", errors.join("\n"))));
}
if plan.system == BuildSystem::Maven
&& let Some(m) = &model.maven
{
return Ok(maven_launch_projects(root, m, module, test)
.iter()
.flat_map(|p| launch_class_dirs(plan.system, &subdir(root, &p.dir), test && subdir(root, &p.dir) == module))
.collect());
}
Ok(launch_class_dirs(plan.system, module, test))
}
@@ -818,6 +911,142 @@ mod tests {
}
}
fn reactor_model() -> BuildModel {
let project = |dir: &str, deps: &[&str]| maven::MavenProject {
pom: format!("{dir}/pom.xml"),
dir: dir.into(),
group_id: "probe".into(),
artifact_id: dir.into(),
version: "1".into(),
aggregated: true,
dependencies: deps
.iter()
.map(|d| maven::MavenDependency {
group_id: "probe".into(),
artifact_id: (*d).into(),
version: Some("1".into()),
scope: "compile".into(),
..Default::default()
})
.collect(),
..Default::default()
};
let mut projects = vec![
project("app", &["middle"]),
project("middle", &["lib"]),
project("lib", &["app"]),
project("unrelated", &[]),
];
projects.insert(
0,
maven::MavenProject {
pom: "pom.xml".into(),
artifact_id: "reactor".into(),
command: "mvnw".into(),
modules: projects.iter().map(|p| p.pom.clone()).collect(),
options: vec!["-s".into(), "settings.xml".into()],
env: BTreeMap::from([("JAVA_HOME".into(), "/jdk".into())]),
..Default::default()
},
);
BuildModel { maven: Some(maven::MavenModel { projects, ..Default::default() }), ..Default::default() }
}
#[test]
fn mixed_launch_detection_follows_only_reactor_dependencies() {
for extension in ["kt", "groovy", "scala"] {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let mut model = reactor_model();
write(root, "app/src/main/java/Application.java", "class Application {}");
write(root, &format!("unrelated/src/main/java/Other.{extension}"), "");
assert!(!launch_has_non_java_sources(root, &model, &root.join("app"), false));
write(root, &format!("lib/src/main/java/Channel.{extension}"), "");
assert!(launch_has_non_java_sources(root, &model, &root.join("app"), false));
assert_eq!(
maven_launch_projects(root, model.maven.as_ref().unwrap(), &root.join("app"), false).len(),
3,
"cycles terminate"
);
let m = model.maven.as_mut().unwrap();
m.projects[2].dependencies[0].scope = "test".into();
assert!(
!launch_has_non_java_sources(root, &model, &root.join("app"), true),
"upstream test dependencies are not transitive"
);
let m = model.maven.as_mut().unwrap();
m.projects[1].dependencies[0].scope = "test".into();
m.projects[2].dependencies[0].scope = "compile".into();
assert!(!launch_has_non_java_sources(root, &model, &root.join("app"), false));
assert!(launch_has_non_java_sources(root, &model, &root.join("app"), true));
model.maven.as_mut().unwrap().projects[2].dependencies[0].version = Some("external".into());
assert!(!launch_has_non_java_sources(root, &model, &root.join("app"), true));
model.maven.as_mut().unwrap().projects[2].dependencies[0].version = Some("1".into());
model.maven.as_mut().unwrap().projects[3].aggregated = false;
model.maven.as_mut().unwrap().projects[0].modules.retain(|p| p != "lib/pom.xml");
assert!(
!launch_has_non_java_sources(root, &model, &root.join("app"), true),
"another linked reactor is external"
);
}
}
#[test]
fn maven_launch_steps_use_reactor_settings_and_launch_scope() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(root, "app/pom.xml", "<project/>");
let model = reactor_model();
for (test, goal) in [(false, "compile"), (true, "test-compile")] {
let plan = launch_plan(root, &model, &root.join("app"), test).unwrap();
let step = &plan.steps[0];
assert_eq!(step.cwd, root);
assert_eq!(step.program, "mvnw");
assert_eq!(step.env["JAVA_HOME"], "/jdk");
assert_eq!(
step.args,
["-B", "-Dstyle.color=never", "-s", "settings.xml", "-pl", "app", "-am", "clean", goal]
);
}
}
#[cfg(unix)]
#[test]
fn external_launch_returns_upstream_outputs_and_blocks_failure_or_cancellation() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(root, "app/pom.xml", "<project/>");
write(
root,
"mvnw",
"#!/bin/sh\nmkdir -p app/target/classes middle/target/classes lib/target/classes app/target/test-classes\n",
);
let wrapper = root.join("mvnw");
std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).unwrap();
let mut model = reactor_model();
model.maven.as_mut().unwrap().projects[0].command = wrapper.to_string_lossy().into_owned();
let dirs = build_for_launch(root, &model, &root.join("app"), true, &CancellationToken::new()).unwrap();
assert_eq!(
dirs,
["app/target/test-classes", "app/target/classes", "middle/target/classes", "lib/target/classes"]
.map(|p| root.join(p))
);
write(
root,
"mvnw",
"#!/bin/sh\necho '[ERROR] Kotlin compilation failed: unresolved reference broken' >&2\nexit 1\n",
);
let error = build_for_launch(root, &model, &root.join("app"), false, &CancellationToken::new()).unwrap_err();
assert!(error.message.contains("Kotlin compilation failed"), "{}", error.message);
let cancel = CancellationToken::new();
cancel.cancel();
assert_eq!(
build_for_launch(root, &model, &root.join("app"), false, &cancel).unwrap_err().kind,
ide_core::error::ErrorKind::Cancelled
);
}
#[test]
fn non_java_launch_class_dirs() {
let tmp = tempfile::tempdir().unwrap();
+6
View File
@@ -399,6 +399,12 @@ pub fn parse_tasks(output: &str) -> Vec<(String, GradleTask)> {
out
}
/// Static fallback until Gradle has reported its tasks.
pub(crate) fn has_war_plugin(script: &str) -> bool {
let script = strip_comments(script);
script.split(|c: char| !c.is_alphanumeric() && c != '_').any(|word| word == "war")
}
#[cfg(test)]
mod tests {
#[test]
+475
View File
@@ -0,0 +1,475 @@
//! Private Maven builds for JDT's Kotlin binary libraries. Never use launch outputs.
use crate::BuildModel;
use crate::compile::{self, BuildEvent, BuildKind, BuildMessage};
use ide_core::{CancellationToken, IdeError, IdeResult};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Module {
key: String,
project_uri: String,
output: PathBuf,
test_output: Option<PathBuf>,
dependencies: Vec<Dependency>,
has_kotlin: bool,
kotlin_goals: Vec<KotlinGoal>,
}
#[derive(Clone, Debug, Deserialize)]
struct KotlinGoal {
execution: String,
status: String,
}
#[derive(Clone, Debug, Deserialize)]
struct Dependency {
key: String,
scope: String,
#[serde(rename = "type")]
kind: Option<String>,
classifier: Option<String>,
}
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
pub struct Library {
pub path: PathBuf,
pub test: bool,
}
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ProjectLibraries {
pub project_uri: String,
pub libraries: Vec<Library>,
}
pub struct Outcome {
pub projects: Vec<ProjectLibraries>,
pub messages: Vec<BuildMessage>,
pub success: bool,
}
/// Inspect sources, excluding generated/build/VCS trees and never following symlinks.
pub fn has_kotlin(root: &Path) -> bool {
let Ok(entries) = std::fs::read_dir(root) else { return false };
entries.flatten().any(|entry| {
let Ok(kind) = entry.file_type() else { return false };
if kind.is_file() {
entry.path().extension().is_some_and(|e| e == "kt")
} else {
kind.is_dir()
&& !matches!(
entry.file_name().to_str(),
Some("target" | "build" | "out" | "bin" | "node_modules" | ".git" | ".openidea")
)
&& has_kotlin(&entry.path())
}
})
}
/// Run the project's configured wrapper/settings/JDK and lifecycle, with an output
/// redirector loaded before Maven calculates its lifecycle execution plans.
pub fn build(
root: &Path,
model: &BuildModel,
bundle: &Path,
generation: &Path,
cancel: &CancellationToken,
) -> IdeResult<Outcome> {
std::fs::create_dir_all(generation)?;
let mut plan = compile::plan(root, model, BuildKind::Build, None)?;
if plan.system != compile::BuildSystem::Maven {
return Err(IdeError::invalid("Kotlin bridge requires a Maven build root"));
}
let mut manifests = Vec::new();
for (index, step) in plan.steps.iter_mut().enumerate() {
step.args.push("--fail-at-end".into());
let directory = generation.join(format!("reactor-{index}"));
std::fs::create_dir_all(&directory)?;
manifests.push(directory.join("modules.json"));
let mut extensions = vec![bundle.to_path_buf()];
step.args.retain(|arg| {
if let Some(value) = arg.strip_prefix("-Dmaven.ext.class.path=") {
extensions.extend(std::env::split_paths(value));
false
} else {
true
}
});
let extension_path = std::env::join_paths(extensions).map_err(|e| IdeError::invalid(e.to_string()))?;
step.args.push(format!("-Dmaven.ext.class.path={}", extension_path.to_string_lossy()));
step.args.push(format!("-Dopenidea.kotlin.staging={}", directory.join("staging").display()));
}
let mut messages = Vec::new();
// Independent linked reactors must still run after another reactor's javac fails.
for step in &plan.steps {
let mut reactor = plan.clone();
reactor.steps = vec![step.clone()];
let result = compile::run(root, &reactor, cancel, &|event| {
if let BuildEvent::Output { text, .. } = event {
tracing::debug!("kotlin bridge: {}", text.trim_end());
}
})?;
messages.extend(result.messages);
}
// javac diagnostics belong to JDT, including when a Kotlin target was skipped.
messages.retain(|message| {
message.path.as_ref().is_some_and(|path| PathBuf::from(path.clone()).extension().is_some_and(|e| e == "kt"))
});
let mut modules = Vec::new();
for manifest in manifests {
let parsed: Vec<Module> = serde_json::from_slice(&std::fs::read(manifest)?)
.map_err(|e| IdeError::internal(format!("Kotlin module manifest: {e}")))?;
modules.extend(parsed);
}
messages.extend(kotlin_failures(&modules));
if messages.iter().any(|message| message.severity == ide_core::Severity::Error) {
return Ok(Outcome { projects: Vec::new(), messages, success: false });
}
let pending = generation.join("libraries.pending");
let published = generation.join("libraries");
std::fs::create_dir_all(&pending)?;
let mut libraries = BTreeMap::new();
for (index, module) in modules.iter().enumerate() {
let mut outputs = ModuleLibraries::default();
for (source, target, name) in [
(Some(&module.output), &mut outputs.main, format!("main-{index}")),
(module.test_output.as_ref(), &mut outputs.test, format!("test-{index}")),
] {
let Some(source) = source else { continue };
if !source.starts_with(generation) {
return Err(IdeError::internal("Kotlin build output escaped staging"));
}
if copy_kotlin(source, &pending.join(&name), cancel)? {
*target = Some(published.join(name));
}
}
libraries.insert(module.key.clone(), outputs);
}
publish(&pending, &published, cancel)?;
Ok(Outcome { projects: consumers(&modules, &libraries), messages, success: true })
}
fn kotlin_failures(modules: &[Module]) -> Vec<BuildMessage> {
let mut messages = Vec::new();
for module in modules {
if !module.has_kotlin {
continue;
}
if module.kotlin_goals.is_empty() {
messages.push(kotlin_failure(module, "compile target was not executed"));
}
for goal in &module.kotlin_goals {
if goal.status != "success" {
messages.push(kotlin_failure(module, &format!("{} {}", goal.execution, goal.status)));
}
}
}
messages
}
fn kotlin_failure(module: &Module, reason: &str) -> BuildMessage {
BuildMessage {
severity: ide_core::Severity::Error,
text: format!("Kotlin compilation for {}: {reason}", module.key),
path: None,
range: None,
code: None,
}
}
fn publish(pending: &Path, published: &Path, cancel: &CancellationToken) -> IdeResult<()> {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
std::fs::rename(pending, published)?;
Ok(())
}
#[derive(Default)]
struct ModuleLibraries {
main: Option<PathBuf>,
test: Option<PathBuf>,
}
fn consumers(modules: &[Module], libraries: &BTreeMap<String, ModuleLibraries>) -> Vec<ProjectLibraries> {
modules
.iter()
.map(|consumer| {
let mut visible = Vec::<Library>::new();
let mut add = |path: &Option<PathBuf>, test: bool| {
if let Some(path) = path {
if let Some(previous) = visible.iter_mut().find(|entry| &entry.path == path) {
previous.test &= test;
} else {
visible.push(Library { path: path.clone(), test });
}
}
};
if let Some(own) = libraries.get(&consumer.key) {
add(&own.test, true);
add(&own.main, false);
}
// Maven's resolved artifacts already apply exclusions, mediation and optional
// propagation. Preserve their classpath order, after the module's own output.
for dependency in &consumer.dependencies {
let Some(output) = libraries.get(&dependency.key) else { continue };
let test = match dependency.scope.as_str() {
"compile" | "provided" | "system" | "" => false,
"test" | "runtime" => true,
_ => continue,
};
match (dependency.kind.as_deref(), dependency.classifier.as_deref()) {
(Some("test-jar"), None | Some("tests")) | (Some("jar"), Some("tests")) => add(&output.test, test),
(None | Some("jar"), None | Some("")) => add(&output.main, test),
_ => {}
}
}
ProjectLibraries { project_uri: consumer.project_uri.clone(), libraries: visible }
})
.collect()
}
fn copy_kotlin(source: &Path, destination: &Path, cancel: &CancellationToken) -> IdeResult<bool> {
let Ok(entries) = std::fs::read_dir(source) else { return Ok(false) };
let mut found = false;
for entry in entries {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let entry = entry?;
let kind = entry.file_type()?;
let path = entry.path();
let output = destination.join(entry.file_name());
if kind.is_dir() {
found |= copy_kotlin(&path, &output, cancel)?;
} else if kind.is_file() {
let bytes = std::fs::read(&path)?;
let class = path.extension().is_some_and(|e| e == "class");
let metadata = path.extension().is_some_and(|e| e == "kotlin_module");
if metadata || (class && kotlin_class(&bytes).unwrap_or(false)) {
std::fs::create_dir_all(destination)?;
std::fs::write(output, bytes)?;
found |= class;
}
}
}
Ok(found)
}
/// Read the actual class-level SourceFile attribute, rather than searching strings
/// (a Java constant containing "Something.kt" must never become a Kotlin library).
fn kotlin_class(bytes: &[u8]) -> Option<bool> {
struct Reader<'a> {
bytes: &'a [u8],
pos: usize,
}
impl<'a> Reader<'a> {
fn take(&mut self, count: usize) -> Option<&'a [u8]> {
let value = self.bytes.get(self.pos..self.pos.checked_add(count)?)?;
self.pos += count;
Some(value)
}
fn u2(&mut self) -> Option<usize> {
Some(u16::from_be_bytes(self.take(2)?.try_into().ok()?) as usize)
}
fn u4(&mut self) -> Option<usize> {
Some(u32::from_be_bytes(self.take(4)?.try_into().ok()?) as usize)
}
fn attributes(&mut self) -> Option<()> {
for _ in 0..self.u2()? {
self.u2()?;
let n = self.u4()?;
self.take(n)?;
}
Some(())
}
}
let mut reader = Reader { bytes, pos: 0 };
if reader.take(4)? != [0xca, 0xfe, 0xba, 0xbe] {
return None;
}
reader.take(4)?;
let count = reader.u2()?;
let mut utf8 = BTreeMap::new();
let mut index = 1;
while index < count {
match reader.take(1)?[0] {
1 => {
let n = reader.u2()?;
utf8.insert(index, reader.take(n)?);
}
3 | 4 | 9..=12 | 17 | 18 => {
reader.take(4)?;
}
5 | 6 => {
reader.take(8)?;
index += 1;
}
7 | 8 | 16 | 19 | 20 => {
reader.take(2)?;
}
15 => {
reader.take(3)?;
}
_ => return None,
}
index += 1;
}
reader.take(6)?;
let interfaces = reader.u2()?;
reader.take(interfaces.checked_mul(2)?)?;
for _ in 0..2 {
for _ in 0..reader.u2()? {
reader.take(6)?;
reader.attributes()?;
}
}
for _ in 0..reader.u2()? {
let name = reader.u2()?;
let length = reader.u4()?;
if utf8.get(&name).copied() == Some(b"SourceFile".as_slice()) && length == 2 {
let source = reader.u2()?;
return Some(utf8.get(&source)?.ends_with(b".kt"));
}
reader.take(length)?;
}
Some(false)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_kotlin_target_must_succeed_even_when_maven_fails_elsewhere() {
let mut modules: Vec<Module> = serde_json::from_str(r#"[
{"key":"lib","projectUri":"lib","output":"lib","dependencies":[],"hasKotlin":true,
"kotlinGoals":[{"execution":"main:compile","status":"success"},{"execution":"test:test-compile","status":"success"}]},
{"key":"java","projectUri":"java","output":"java","dependencies":[],"hasKotlin":false,"kotlinGoals":[]}
]"#).unwrap();
assert!(kotlin_failures(&modules).is_empty());
for status in ["failed", "skipped"] {
modules[0].kotlin_goals[1].status = status.into();
let failures = kotlin_failures(&modules);
assert_eq!(failures.len(), 1);
assert!(failures[0].text.contains(&format!("test:test-compile {status}")));
}
modules[0].kotlin_goals.clear();
assert_eq!(kotlin_failures(&modules).len(), 1);
modules[0].has_kotlin = false;
assert!(kotlin_failures(&modules).is_empty());
assert!(
serde_json::from_str::<Vec<Module>>(
r#"[{"key":"old","projectUri":"old","output":"old","dependencies":[]}]"#
)
.is_err()
);
}
#[test]
fn respects_mavens_resolved_closure_and_scopes() {
let json = r#"[
{"key":"zapp","projectUri":"app","output":"app", "hasKotlin":false,"kotlinGoals":[],"dependencies":[{"key":"mid","scope":"compile","optional":false},{"key":"lib","scope":"compile","optional":false}]},
{"key":"mid","projectUri":"mid","output":"mid", "hasKotlin":false,"kotlinGoals":[],"dependencies":[
{"key":"lib","scope":"compile","optional":false},
{"key":"optional","scope":"compile","optional":true},
{"key":"test","scope":"test","optional":false},
{"key":"provided","scope":"provided","optional":false}]},
{"key":"lib","projectUri":"lib","output":"lib","hasKotlin":false,"kotlinGoals":[],"dependencies":[]}
]"#;
let modules: Vec<Module> = serde_json::from_str(json).unwrap();
let libraries = ["zapp", "lib", "optional", "test", "provided"]
.into_iter()
.map(|key| (key.to_owned(), ModuleLibraries { main: Some(PathBuf::from(key)), test: None }))
.collect();
let result = consumers(&modules, &libraries);
assert_eq!(
result[0].libraries,
vec![Library { path: "zapp".into(), test: false }, Library { path: "lib".into(), test: false },]
);
assert_eq!(result[1].libraries.len(), 4);
assert!(result[1].libraries.iter().find(|l| l.path == Path::new("test")).unwrap().test);
}
#[test]
fn test_outputs_are_private_unless_maven_resolves_a_test_jar() {
let modules: Vec<Module> = serde_json::from_str(r#"[
{"key":"lib","projectUri":"lib","output":"lib","hasKotlin":false,"kotlinGoals":[],"dependencies":[]},
{"key":"app","projectUri":"app","output":"app","hasKotlin":false,"kotlinGoals":[],"dependencies":[{"key":"lib","scope":"compile","type":"jar","classifier":null}]},
{"key":"test-app","projectUri":"test-app","output":"app","hasKotlin":false,"kotlinGoals":[],"dependencies":[{"key":"lib","scope":"test","type":"test-jar","classifier":"tests"}]}
]"#).unwrap();
let libraries =
BTreeMap::from([("lib".into(), ModuleLibraries { main: Some("main".into()), test: Some("test".into()) })]);
let result = consumers(&modules, &libraries);
assert_eq!(
result[0].libraries,
vec![Library { path: "test".into(), test: true }, Library { path: "main".into(), test: false }]
);
assert_eq!(result[1].libraries, vec![Library { path: "main".into(), test: false }]);
assert_eq!(result[2].libraries, vec![Library { path: "test".into(), test: true }]);
}
#[test]
fn publishes_complete_directories_and_cancellation_keeps_them_private() {
let directory = tempfile::tempdir().unwrap();
let pending = directory.path().join("pending");
let final_path = directory.path().join("published");
std::fs::create_dir(&pending).unwrap();
std::fs::write(pending.join("Channel.class"), "complete").unwrap();
let cancelled = CancellationToken::new();
cancelled.cancel();
assert!(publish(&pending, &final_path, &cancelled).is_err());
assert!(!final_path.exists());
assert!(pending.exists());
publish(&pending, &final_path, &CancellationToken::new()).unwrap();
assert!(!pending.exists());
assert_eq!(std::fs::read(final_path.join("Channel.class")).unwrap(), b"complete");
}
#[test]
fn never_runs_another_build_system_with_maven_output_properties() {
let directory = tempfile::tempdir().unwrap();
std::fs::write(directory.path().join("Cargo.toml"), "").unwrap();
let error = build(
directory.path(),
&BuildModel::default(),
Path::new("unused.jar"),
&directory.path().join("private"),
&CancellationToken::new(),
)
.err()
.unwrap();
assert_eq!(error.message, "Kotlin bridge requires a Maven build root");
}
#[test]
fn does_not_find_deleted_sources_in_build_outputs() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("target/generated")).unwrap();
std::fs::write(dir.path().join("target/generated/Old.kt"), "").unwrap();
assert!(!has_kotlin(dir.path()));
std::fs::write(dir.path().join("New.kt"), "").unwrap();
assert!(has_kotlin(dir.path()));
}
#[test]
fn parses_sourcefile_and_rejects_truncated_classes() {
let mut class = vec![0xca, 0xfe, 0xba, 0xbe, 0, 0, 0, 52, 0, 3, 1, 0, 10];
class.extend_from_slice(b"SourceFile");
class.extend_from_slice(&[1, 0, 10]);
class.extend_from_slice(b"Channel.kt");
class.extend_from_slice(&[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 2, 0, 2]);
assert_eq!(kotlin_class(&class), Some(true));
for end in 0..class.len() {
assert!(kotlin_class(&class[..end]).is_none());
}
let offset = 26;
class[offset..offset + 10].copy_from_slice(b"Other.java");
assert_eq!(kotlin_class(&class), Some(false));
}
}
+14
View File
@@ -5,12 +5,15 @@
//! and merges what the last sync ([`sync_maven`] / [`sync_gradle`], which run
//! Maven / Gradle in the background) cached for the project.
pub mod application_update;
pub mod compile;
pub mod gradle;
pub mod kotlin_bridge;
pub mod maven;
pub mod pom_edit;
mod process;
mod sync;
pub mod web;
mod xml;
pub use gradle::{GradleModel, GradleProject, GradleTask};
@@ -39,8 +42,19 @@ pub struct BuildModel {
/// Static model of the builds under `root`, merged with the sync results cached in `cache_dir`.
/// Parent POMs are looked up in `maven_repo` (the configured local repository), else the default one.
pub fn load(root: &Path, cache_dir: &Path, maven_repo: Option<PathBuf>) -> BuildModel {
load_linked(root, cache_dir, maven_repo, &ide_core::settings::MavenSettings::default())
}
/// Loads only linked Maven reactors, retaining an empty model so projects can be re-added.
pub fn load_linked(
root: &Path,
cache_dir: &Path,
maven_repo: Option<PathBuf>,
settings: &ide_core::settings::MavenSettings,
) -> BuildModel {
let maven = maven::is_maven_project(root).then(|| {
let mut m = maven::load_with_repo(root, maven_repo.or_else(maven::default_local_repo));
maven::UnlinkedProjects::from_model(&m, settings).filter(&mut m);
sync::apply_maven_cache(root, cache_dir, &mut m);
m
});
+214 -4
View File
@@ -401,6 +401,9 @@ struct Inherited {
managed: Vec<RawDep>,
plugins: Vec<RawPlugin>,
managed_plugins: Vec<RawPlugin>,
/// `<id>`, `activeByDefault`: profiles of the POM and its ancestors —
/// Maven lets a child enable a parent's profiles with `-P`.
profiles: Vec<(String, bool)>,
}
fn merge_by<T: Clone>(base: &mut Vec<T>, own: &[T], key: impl Fn(&T) -> String) {
@@ -523,6 +526,11 @@ impl Resolver {
merge_by(&mut ctx.deps, &p.deps, RawDep::key);
merge_plugins(&mut ctx.plugins, &p.plugins);
}
merge_by(
&mut ctx.profiles,
&pom.profiles.iter().map(|p| (p.id.clone(), p.active_by_default)).collect::<Vec<_>>(),
|(id, _)| id.clone(),
);
// Managed dependencies: own entries win; imported BOMs fill the gaps.
let props = self.properties_for(&pom, &ctx, &dir);
let mut imported: Vec<RawDep> = Vec::new();
@@ -593,6 +601,72 @@ impl Resolver {
}
}
/// Files and source/resource trees consumed by a launch build. Unresolved custom
/// roots disable reuse rather than silently omitting an input.
pub(crate) fn launch_inputs(
root: &Path,
projects: &[&MavenProject],
local_repo: Option<PathBuf>,
) -> Option<Vec<PathBuf>> {
fn roots(el: &Element, parent: &str, dir: &Path, out: &mut Vec<PathBuf>) -> Option<()> {
let input =
matches!(el.name.as_str(), "sourceDirectory" | "testSourceDirectory" | "sourceDir" | "testSourceDir")
|| (matches!(parent, "sources" | "sourceDirs" | "testSources" | "testSourceDirs")
&& matches!(el.name.as_str(), "source" | "sourceDir" | "testSourceDir"))
|| (matches!(parent, "resource" | "testResource" | "source" | "testSource") && el.name == "directory")
|| (parent == "filters" && el.name == "filter");
// Profile activation may depend on the presence of an otherwise unrelated file.
let activation = parent == "file" && matches!(el.name.as_str(), "exists" | "missing");
if input || activation {
// Other properties can be overridden by -D, profiles or settings.
// Without Maven's effective roots, conservatively disable reuse for them.
let value = el
.text
.trim()
.replace("${project.basedir}", &dir.to_string_lossy())
.replace("${basedir}", &dir.to_string_lossy());
if value.contains("${") {
return None;
}
if !value.is_empty() {
out.push(dir.join(value));
}
}
for child in &el.children {
roots(child, &el.name, dir, out)?;
}
Some(())
}
let mut resolver = Resolver::new(local_repo.or_else(default_local_repo));
let mut out = Vec::new();
for project in projects {
let dir = root.join(&project.dir);
// Include all conventional language/resource roots, including nonstandard files under src.
out.push(dir.join("src"));
let pom = root.join(&project.pom);
resolver.context(&pom, 0);
let mut path = pom;
let mut seen = HashSet::new();
loop {
let canonical = dunce::canonicalize(&path).ok()?;
if !seen.insert(canonical) || seen.len() > MAX_PARENT_DEPTH {
return None;
}
out.push(path.clone());
let text = std::fs::read_to_string(&path).ok()?;
roots(&xml::parse(&text)?, "", &dir, &mut out)?;
let raw = resolver.pom(&path)?;
let Some(parent) = raw.parent else { break };
path = resolver.parent_path(path.parent()?, &parent)?;
}
}
// Also track imported BOMs read while resolving inheritance.
out.extend(resolver.poms.into_keys());
out.sort();
out.dedup();
Some(out)
}
// ---------------------------------------------------------------- plugins
/// `maven-clean-plugin` → `clean`, `spring-boot-maven-plugin` → `spring-boot`.
@@ -935,9 +1009,10 @@ pub(crate) fn load_with_repo(root: &Path, local_repo: Option<PathBuf>) -> MavenM
.or_else(|| pom.compiler_release.as_deref().map(i))
.and_then(|v| java_version(&v));
for p in &pom.profiles {
if !model.profiles.iter().any(|x| x.id == p.id) {
model.profiles.push(MavenProfile { id: p.id.clone(), active_by_default: p.active_by_default });
// The project can activate the profiles of its own POM and its ancestors'.
for (id, active) in &ctx.profiles {
if !model.profiles.iter().any(|x| x.id == *id) {
model.profiles.push(MavenProfile { id: id.clone(), active_by_default: *active });
}
}
let artifact_id = i(&pom.artifact_id);
@@ -954,7 +1029,7 @@ pub(crate) fn load_with_repo(root: &Path, local_repo: Option<PathBuf>) -> MavenM
aggregated,
plugins,
dependencies,
profiles: pom.profiles.iter().map(|p| p.id.clone()).collect(),
profiles: ctx.profiles.iter().map(|(id, _)| id.clone()).collect(),
java_version: jv,
command: wrapper(root, &dir),
options: Vec::new(),
@@ -967,6 +1042,67 @@ pub(crate) fn load_with_repo(root: &Path, local_repo: Option<PathBuf>) -> MavenM
model
}
/// Shared linkage decision for every Maven consumer, including modules outside a reactor directory.
#[derive(Default)]
pub struct UnlinkedProjects {
poms: std::collections::BTreeSet<String>,
}
impl UnlinkedProjects {
pub fn load(root: &Path, settings: &ide_core::settings::MavenSettings) -> Self {
if settings.unlinked_projects.is_empty() {
return Self::default();
}
Self::from_model(&load(root), settings)
}
pub fn from_model(model: &MavenModel, settings: &ide_core::settings::MavenSettings) -> Self {
let mut result = Self::default();
let mut pending = settings.unlinked_projects.clone();
while let Some(pom) = pending.pop() {
let pom = normalized_pom(&pom);
if !result.poms.insert(pom.clone()) {
continue;
}
if let Some(project) = model.projects.iter().find(|p| normalized_pom(&p.pom) == pom) {
pending.extend(project.modules.iter().cloned());
}
}
result
}
pub fn contains(&self, pom: &str) -> bool {
self.poms.contains(&normalized_pom(pom))
}
pub fn poms(&self) -> impl Iterator<Item = &str> {
self.poms.iter().map(String::as_str)
}
pub fn filter(&self, model: &mut MavenModel) {
model.projects.retain(|p| !self.contains(&p.pom));
for project in &mut model.projects {
project.modules.retain(|p| !self.contains(p));
}
model.profiles.retain(|p| model.projects.iter().any(|project| project.profiles.contains(&p.id)));
}
}
fn normalized_pom(pom: &str) -> String {
let pom = pom.replace('\\', "/");
let mut parts = Vec::new();
for part in pom.split('/') {
match part {
"" | "." => {}
".." if parts.last().is_some_and(|p| *p != "..") => {
parts.pop();
}
part => parts.push(part),
}
}
parts.join("/")
}
/// Every POM of the model (for change detection).
pub fn build_files(root: &Path, model: &MavenModel) -> Vec<PathBuf> {
model.projects.iter().map(|p| root.join(&p.pom)).collect()
@@ -1219,9 +1355,41 @@ mod tests {
let profiles: Vec<(&str, bool)> = model.profiles.iter().map(|p| (p.id.as_str(), p.active_by_default)).collect();
assert_eq!(profiles, [("dev", true), ("extra", false)]);
assert_eq!(parent.profiles, ["dev", "extra"]);
assert_eq!(core.profiles, ["dev", "extra"], "a module inherits its parent's profiles");
assert!(model.projects.iter().all(|p| p.command == if cfg!(windows) { "mvn.cmd" } else { "mvn" }));
}
#[test]
fn profiles_of_a_parent_pom_are_inherited() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(
root,
"pom.xml",
r#"<project><groupId>demo</groupId><artifactId>parent</artifactId><version>1.0</version>
<packaging>pom</packaging><modules><module>child</module></modules>
<profiles>
<profile><id>jdk8</id></profile>
<profile><id>jdk21</id><activation><activeByDefault>true</activeByDefault></activation></profile>
</profiles>
</project>"#,
);
write(
root,
"child/pom.xml",
r#"<project><parent><groupId>demo</groupId><artifactId>parent</artifactId><version>1.0</version></parent>
<artifactId>child</artifactId>
<profiles><profile><id>own</id></profile></profiles>
</project>"#,
);
let model = load_with_repo(root, None);
let child = &model.projects[1];
// `-Pjdk8` on the module activates the parent's profile, so it is listed.
assert_eq!(child.profiles, ["jdk8", "jdk21", "own"]);
let profiles: Vec<(&str, bool)> = model.profiles.iter().map(|p| (p.id.as_str(), p.active_by_default)).collect();
assert_eq!(profiles, [("jdk8", false), ("jdk21", true), ("own", false)]);
}
#[test]
fn parent_and_bom_from_the_local_repository_and_wrapper() {
let tmp = tempfile::tempdir().unwrap();
@@ -1459,3 +1627,45 @@ mod tests {
}
}
}
#[cfg(test)]
mod linkage_tests {
use super::*;
use ide_core::settings::MavenSettings;
#[test]
fn excludes_reactor_modules_and_profiles_but_keeps_other_roots() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
for (name, body) in [
(
"a",
"<modules><module>child</module><module>../shared</module></modules><profiles><profile><id>only-a</id></profile></profiles>",
),
("a/child", ""),
("shared", ""),
("b", ""),
] {
std::fs::create_dir_all(root.join(name)).unwrap();
std::fs::write(
root.join(name).join("pom.xml"),
format!("<project><artifactId>{name}</artifactId>{body}</project>"),
)
.unwrap();
}
let original = load_with_repo(root, None);
let settings = MavenSettings { unlinked_projects: vec!["./a/pom.xml".into()], ..Default::default() };
let unlinked = UnlinkedProjects::from_model(&original, &settings);
assert!(unlinked.contains("a/child/pom.xml"));
assert!(unlinked.contains("shared/pom.xml"));
assert!(unlinked.contains("a/../shared/pom.xml"));
assert!(!unlinked.contains("b/pom.xml"));
let mut model = original.clone();
unlinked.filter(&mut model);
assert_eq!(model.projects.iter().map(|p| p.pom.as_str()).collect::<Vec<_>>(), ["b/pom.xml"]);
assert!(model.profiles.is_empty());
let mut restored = original.clone();
UnlinkedProjects::from_model(&original, &MavenSettings::default()).filter(&mut restored);
assert_eq!(restored, original);
}
}
+37 -4
View File
@@ -34,6 +34,22 @@ fn kill_tree(child: &mut Child) {
let _ = child.wait();
}
/// `program` as spawnable: a bare name (`mvn`, `mvn.cmd`) resolves on the IDE's
/// search path — the login shell's `PATH` plus version-manager shims — since the
/// app's own `PATH` can be minimal and `Command` would not see `mvn` there.
pub(crate) fn program_path(program: &str, env: &BTreeMap<String, String>) -> std::path::PathBuf {
let path = std::path::Path::new(program);
if !path.is_relative() || path.components().count() > 1 {
return path.to_path_buf();
}
let search = env
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case("PATH"))
.map(|(_, v)| std::ffi::OsString::from(v))
.unwrap_or_else(ide_core::tool_path::search_path);
ide_core::process::find_executable(program, &search).unwrap_or_else(|| path.to_path_buf())
}
/// Runs `program args` in `cwd`, feeding each output line to `on_line`; kills
/// the process group and returns `cancelled` when `cancel` fires.
pub(crate) fn run(
@@ -42,14 +58,27 @@ pub(crate) fn run(
cwd: &Path,
env: &BTreeMap<String, String>,
cancel: &CancellationToken,
on_line: impl FnMut(Stream, &str),
) -> IdeResult<Output> {
run_with_env(program, args, cwd, env, false, cancel, on_line)
}
pub(crate) fn run_with_env(
program: &str,
args: &[String],
cwd: &Path,
env: &BTreeMap<String, String>,
clear_env: bool,
cancel: &CancellationToken,
mut on_line: impl FnMut(Stream, &str),
) -> IdeResult<Output> {
tracing::info!("build: {program} {} (in {})", args.join(" "), cwd.display());
// Output is read as UTF-8: JVMs on Windows are told to write it (see `ide_core::output_encoding`).
let encoding =
ide_core::output_encoding::fix(program, args, env, |k| std::env::var(k).ok(), "UTF-8", cfg!(windows));
let mut command = Command::new(program);
command.args(args).current_dir(cwd).envs(env).envs(encoding.env).stdin(Stdio::null());
let program = program_path(program, env);
let mut command = Command::new(&program);
command.args(args).current_dir(cwd).envs(env).envs(encoding.env.iter().map(|(k, v)| (k, v))).stdin(Stdio::null());
command.stdout(Stdio::piped()).stderr(Stdio::piped());
#[cfg(unix)]
{
@@ -57,7 +86,11 @@ pub(crate) fn run(
command.process_group(0);
}
ide_core::process::prepare(&mut command);
let mut child = command.spawn().map_err(|e| IdeError::unavailable(format!("cannot start {program}: {e}")))?;
if clear_env {
command.env_clear().envs(env).envs(encoding.env);
}
let mut child =
command.spawn().map_err(|e| IdeError::unavailable(format!("cannot start {}: {e}", program.display())))?;
let (tx, rx) = mpsc::channel::<(Stream, String)>();
let pump = |stream: Box<dyn Read + Send>, kind: Stream, tx: mpsc::Sender<(Stream, String)>| {
std::thread::spawn(move || {
@@ -68,7 +101,7 @@ pub(crate) fn run(
match reader.read_until(b'\n', &mut buf) {
Ok(0) | Err(_) => break,
Ok(_) => {
let line = String::from_utf8_lossy(&buf);
let line = ide_core::output_encoding::decode_tool_output(&buf);
let line = line.trim_end_matches(['\n', '\r']).to_owned();
if tx.send((kind, line)).is_err() {
break;
+51 -6
View File
@@ -23,6 +23,7 @@ pub struct SyncProgress {
#[derive(Clone, Debug, Default)]
pub struct SyncOptions {
pub maven_settings: ide_core::settings::MavenSettings,
/// `-o` / `--offline`.
pub offline: bool,
/// Maven profiles (`-P`), `!id` deactivates.
@@ -31,6 +32,8 @@ pub struct SyncOptions {
pub env: BTreeMap<String, String>,
/// Maven launcher replacing the project's `mvnw` / `mvn` (Settings › Maven home).
pub maven_command: Option<String>,
/// Resolved Gradle launcher from Settings.
pub gradle_command: Option<String>,
/// Maven options before the goals (`-s`, `-Dmaven.repo.local=`).
pub maven_options: Vec<String>,
}
@@ -118,6 +121,16 @@ fn now_ms() -> f64 {
/// Merges the cached effective build into `model`.
pub fn apply_maven_cache(root: &Path, cache_dir: &Path, model: &mut MavenModel) {
let Some(cache) = read_cache::<MavenCache>(cache_dir, MAVEN_CACHE) else { return };
model.synced_at = Some(cache.at_ms);
model.sync_error = cache.error;
let changed = cache.hash != hash_files(&maven::build_files(root, model));
model.stale = !cache.verbose || changed;
// A cache older than the current build files: its effective-POM plugins and
// resolved dependencies would hide what the POMs declare now (a just added
// dependency never appears). The static parse stays until Reload.
if changed {
return;
}
for p in &mut model.projects {
if let Some(c) = cache.projects.iter().find(|c| c.key == p.key()) {
if !c.plugins.is_empty() {
@@ -126,9 +139,6 @@ pub fn apply_maven_cache(root: &Path, cache_dir: &Path, model: &mut MavenModel)
p.dependencies = c.dependencies.clone();
}
}
model.synced_at = Some(cache.at_ms);
model.sync_error = cache.error;
model.stale = !cache.verbose || cache.hash != hash_files(&maven::build_files(root, model));
}
/// Merges the cached task list into `model`.
@@ -198,7 +208,8 @@ pub fn sync_maven(
cancel: &CancellationToken,
progress: &dyn Fn(SyncProgress),
) -> IdeResult<()> {
let model = maven::load(root);
let mut model = maven::load(root);
maven::UnlinkedProjects::from_model(&model, &options.maven_settings).filter(&mut model);
std::fs::create_dir_all(cache_dir)?;
let mut cache =
MavenCache { hash: hash_files(&maven::build_files(root, &model)), verbose: true, ..MavenCache::default() };
@@ -266,7 +277,14 @@ pub fn sync_gradle(
args.push("--offline".into());
}
progress(SyncProgress { message: "Loading Gradle tasks".into(), fraction: None });
let out = run(&model.command, &args, &root.join(&model.dir), &options.env, cancel, |_| {})?;
let out = run(
options.gradle_command.as_deref().unwrap_or(&model.command),
&args,
&root.join(&model.dir),
&options.env,
cancel,
|_| {},
)?;
let cache = GradleCache {
hash: hash_files(&gradle::build_files(root, &model)),
at_ms: now_ms(),
@@ -280,6 +298,29 @@ pub fn sync_gradle(
mod tests {
use super::*;
#[cfg(unix)]
#[test]
fn gradle_sync_uses_resolved_command_and_environment() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(root.join("build.gradle"), "").unwrap();
let launcher = root.join("configured-gradle");
std::fs::write(&launcher, "#!/bin/sh\nprintf '%s\\n' \"$JAVA_HOME\" \"$GRADLE_USER_HOME\" > actual-env\n")
.unwrap();
std::fs::set_permissions(&launcher, std::fs::Permissions::from_mode(0o755)).unwrap();
let options = SyncOptions {
gradle_command: Some(launcher.to_string_lossy().into_owned()),
env: BTreeMap::from([
("JAVA_HOME".into(), "/jdk21".into()),
("GRADLE_USER_HOME".into(), "/gradle-cache".into()),
]),
..Default::default()
};
sync_gradle(root, &root.join("cache"), &options, &CancellationToken::new(), &|_| {}).unwrap();
assert_eq!(std::fs::read_to_string(root.join("actual-env")).unwrap(), "/jdk21\n/gradle-cache\n");
}
#[test]
fn caches_merge_and_go_stale() {
let tmp = tempfile::tempdir().unwrap();
@@ -315,12 +356,16 @@ mod tests {
std::fs::write(
root.join("pom.xml"),
"<project><groupId>g</groupId><artifactId>a</artifactId><version>2</version></project>",
"<project><groupId>g</groupId><artifactId>a</artifactId><version>2</version>\
<dependencies><dependency><groupId>g</groupId><artifactId>fresh</artifactId><version>1</version></dependency></dependencies>\
</project>",
)
.unwrap();
let mut model = maven::load_with_repo(&root, None);
apply_maven_cache(&root, &cache_dir, &mut model);
assert!(model.stale);
// The POM changed: the stale effective dependencies must not hide the new one (#249).
assert_eq!(model.projects[0].dependencies[0].artifact_id, "fresh");
}
#[test]
+268
View File
@@ -0,0 +1,268 @@
//! WAR modules and build-tool-owned archive resolution (including custom output names).
use crate::BuildModel;
use ide_core::{CancellationToken, IdeError, IdeResult};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::{collections::BTreeMap, fs, path::Path};
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct WebArtifact {
pub id: String,
pub name: String,
pub module: String,
pub tool: String,
}
pub fn artifacts(root: &Path, model: &BuildModel) -> Vec<WebArtifact> {
let mut out = Vec::new();
if let Some(maven) = &model.maven {
for p in maven.projects.iter().filter(|p| p.packaging == "war") {
out.push(WebArtifact {
id: format!("maven:{}", p.pom),
name: p.artifact_id.clone(),
module: p.dir.clone(),
tool: "maven".into(),
});
}
}
if let Some(gradle) = &model.gradle {
for p in &gradle.projects {
let script = p.build_file.as_ref().and_then(|f| fs::read_to_string(root.join(f)).ok()).unwrap_or_default();
if p.tasks.iter().any(|t| t.name == "war") || crate::gradle::has_war_plugin(&script) {
out.push(WebArtifact {
id: format!("gradle:{}", p.path),
name: p.name.clone(),
module: p.dir.clone(),
tool: "gradle".into(),
});
}
}
}
out
}
pub struct WebBuild {
pub command: String,
pub args: Vec<String>,
pub cwd: String,
pub env: BTreeMap<String, String>,
maven_project: Option<(Vec<String>, std::path::PathBuf)>,
}
impl WebBuild {
/// Resolve the selected module's effective identity using the build's options and environment.
/// Runs off the UI thread, before launching the reactor build.
pub fn resolve_project(
&mut self,
root: &Path,
env: &BTreeMap<String, String>,
cancel: &CancellationToken,
) -> IdeResult<()> {
let Some((args, output)) = &self.maven_project else {
return Ok(());
};
if output.exists() {
fs::remove_file(output)?;
}
let cwd = root.join(&self.cwd).canonicalize()?;
let mut env = env.clone();
if !env.iter().any(|(k, v)| k.eq_ignore_ascii_case("MAVEN_BASEDIR") && !v.is_empty()) {
// -f changes Maven's .mvn search origin; keep the reactor's launcher settings.
let base = cwd.ancestors().find(|p| p.join(".mvn").is_dir()).unwrap_or(&cwd);
env.retain(|k, _| !k.eq_ignore_ascii_case("MAVEN_BASEDIR"));
env.insert("MAVEN_BASEDIR".into(), base.to_string_lossy().into_owned());
}
let result = crate::process::run_with_env(&self.command, args, &cwd, &env, true, cancel, |_, _| {})?;
if !result.success {
return Err(IdeError::invalid(format!("Cannot resolve Maven WAR artifact: {}", result.text.trim())));
}
let id = fs::read_to_string(output)?;
let parts: Vec<_> = id.trim().split(':').collect();
if parts.len() != 4 || parts[2] != "war" || parts.iter().any(|s| s.is_empty()) {
return Err(IdeError::invalid(format!("Cannot resolve Maven WAR artifact: {}", id.trim())));
}
let project = format!("{}:{}:{}", parts[0], parts[1], parts[3]);
self.args.push(format!("-Dexpression=session.projectMap({project}).artifact.file"));
self.maven_project = None;
Ok(())
}
}
fn groovy_literal(s: &str) -> String {
format!("'{}'", s.replace('\\', "\\\\").replace('\'', "\\'"))
}
pub fn build(root: &Path, model: &BuildModel, id: &str, cache: &Path) -> IdeResult<WebBuild> {
fs::create_dir_all(cache)?;
let manifest = cache.join("artifact-path.txt");
// A failed build must never silently reuse an older archive manifest.
if manifest.exists() {
fs::remove_file(&manifest)?;
}
fs::write(cache.join("artifact-id.txt"), id)?;
if let Some(pom) = id.strip_prefix("maven:") {
let p = model
.maven
.as_ref()
.and_then(|m| m.projects.iter().find(|p| p.pom == pom && p.packaging == "war"))
.ok_or_else(|| IdeError::invalid("Choose a Maven WAR module"))?;
let projects = &model.maven.as_ref().unwrap().projects;
let top = projects
.iter()
.filter(|t| {
!t.aggregated && (t.dir.is_empty() || p.dir == t.dir || p.dir.starts_with(&format!("{}/", t.dir)))
})
.max_by_key(|t| t.dir.len())
.unwrap_or(p);
let mut args = top.options.clone();
if top.pom != p.pom {
let module = root
.join(&p.dir)
.strip_prefix(root.join(&top.dir))
.map_err(|e| IdeError::invalid(e.to_string()))?
.to_string_lossy()
.replace('\\', "/");
args.extend(["-pl".into(), module, "-am".into()]);
}
// help:evaluate runs once, in the reactor's first project. Query the selected
// POM's effective identity first; profiles and -D options can change its GAV.
let output = cache.join("artifact-project.txt");
let mut probe = top.options.clone();
probe.extend([
"-f".into(),
root.join(&p.pom).to_string_lossy().into_owned(),
"help:evaluate".into(),
"-Dexpression=project.id".into(),
format!("-Doutput={}", output.display()),
]);
args.extend([
"-DskipTests".into(),
"package".into(),
"help:evaluate".into(),
format!("-Doutput={}", manifest.display()),
]);
return Ok(WebBuild {
command: top.command.clone(),
args,
cwd: top.dir.clone(),
env: p.env.clone(),
maven_project: Some((probe, output)),
});
}
if let Some(path) = id.strip_prefix("gradle:") {
let g = model.gradle.as_ref().ok_or_else(|| IdeError::invalid("Choose a Gradle WAR module"))?;
if !artifacts(root, model).iter().any(|a| a.id == id) {
return Err(IdeError::invalid("Choose a Gradle WAR module"));
}
let script = cache.join("war.init.gradle");
fs::write(
&script,
format!(
r#"gradle.projectsEvaluated {{
def p = gradle.rootProject.project({})
def warTask = p.tasks.named('war')
p.tasks.register('openideaTomcatArtifact') {{
dependsOn warTask
doLast {{ new File({}).setText(warTask.get().archiveFile.get().asFile.absolutePath, 'UTF-8') }}
}}
}}
"#,
groovy_literal(path),
groovy_literal(&manifest.to_string_lossy())
),
)?;
let task = format!("{}:openideaTomcatArtifact", path.trim_end_matches(':'));
return Ok(WebBuild {
command: g.command.clone(),
args: vec!["--init-script".into(), script.to_string_lossy().into_owned(), task],
cwd: g.dir.clone(),
env: g.env.clone(),
maven_project: None,
});
}
Err(IdeError::invalid("Choose a WAR artifact"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn maven_war_modules_use_resolved_artifact_file() {
let tmp = tempfile::tempdir().unwrap();
fs::write(tmp.path().join("pom.xml"), "<project><modelVersion>4.0.0</modelVersion><groupId>test</groupId><artifactId>web</artifactId><version>1</version><packaging>war</packaging><build><directory>custom</directory><finalName>renamed</finalName></build></project>").unwrap();
let model = crate::load(tmp.path(), &tmp.path().join("cache"), None);
let a = artifacts(tmp.path(), &model);
assert_eq!(a.len(), 1);
assert_eq!(a[0].name, "web");
let plan = build(tmp.path(), &model, &a[0].id, &tmp.path().join("base with spaces")).unwrap();
let (probe, _) = plan.maven_project.as_ref().unwrap();
assert!(probe.contains(&"-Dexpression=project.id".into()));
assert!(probe.contains(&tmp.path().join("pom.xml").to_string_lossy().into_owned()));
assert!(
plan.args.iter().any(|a| a.ends_with("base with spaces/artifact-path.txt")
|| a.ends_with("base with spaces\\artifact-path.txt"))
);
}
#[test]
fn maven_module_build_includes_reactor_dependencies() {
let tmp = tempfile::tempdir().unwrap();
fs::create_dir_all(tmp.path().join("web")).unwrap();
fs::write(tmp.path().join("pom.xml"), "<project><groupId>test</groupId><artifactId>parent</artifactId><version>1</version><packaging>pom</packaging><modules><module>web</module></modules></project>").unwrap();
fs::write(tmp.path().join("web/pom.xml"), "<project><parent><groupId>test</groupId><artifactId>parent</artifactId><version>1</version></parent><artifactId>web</artifactId><packaging>war</packaging></project>").unwrap();
let model = crate::load(tmp.path(), &tmp.path().join("cache"), None);
let plan = build(tmp.path(), &model, "maven:web/pom.xml", &tmp.path().join("base")).unwrap();
assert_eq!(plan.cwd, "");
assert_eq!(&plan.args[..3], &["-pl", "web", "-am"]);
assert!(
plan.maven_project
.as_ref()
.unwrap()
.0
.contains(&tmp.path().join("web/pom.xml").to_string_lossy().into_owned())
);
}
#[test]
fn gradle_war_uses_archive_provider_and_ignores_comments() {
let tmp = tempfile::tempdir().unwrap();
fs::write(
tmp.path().join("build.gradle.kts"),
"plugins { war }\ntasks.war { archiveFileName.set(\"custom.war\") }",
)
.unwrap();
let model = crate::load(tmp.path(), &tmp.path().join("cache"), None);
let a = artifacts(tmp.path(), &model);
assert_eq!(a.len(), 1);
let plan = build(tmp.path(), &model, &a[0].id, &tmp.path().join("base")).unwrap();
assert!(plan.args.contains(&":openideaTomcatArtifact".into()));
assert!(fs::read_to_string(tmp.path().join("base/war.init.gradle")).unwrap().contains("archiveFile.get()"));
assert!(!crate::gradle::has_war_plugin("// plugins { war }"));
}
#[test]
fn gradle_manifest_paths_preserve_windows_characters() {
for path in [r"C:\工作 区\Tomcat's cache\artifact-path.txt", r"\\server\share\artifact-path.txt"] {
let literal = groovy_literal(path);
assert_eq!(literal[1..literal.len() - 1].replace("\\'", "'").replace("\\\\", "\\"), path);
}
}
#[test]
fn gradle_root_and_nested_modules_write_utf8_archive_paths() {
for module in [":", ":apps:timesheet"] {
let tmp = tempfile::tempdir().unwrap();
let dir = if module == ":" { "" } else { "apps/timesheet" };
fs::create_dir_all(tmp.path().join(dir)).unwrap();
fs::write(tmp.path().join("settings.gradle"), "rootProject.name = 'timesheet'\ninclude 'apps:timesheet'")
.unwrap();
fs::write(tmp.path().join(dir).join("build.gradle"), "plugins { id 'war' }").unwrap();
let model = crate::load(tmp.path(), &tmp.path().join("cache"), None);
let cache = tmp.path().join("工作 cache");
let plan = build(tmp.path(), &model, &format!("gradle:{module}"), &cache).unwrap();
assert!(plan.args.contains(&format!("{}:openideaTomcatArtifact", module.trim_end_matches(':'))));
let script = fs::read_to_string(cache.join("war.init.gradle")).unwrap();
assert!(script.contains(&format!("rootProject.project('{}')", module)));
assert!(script.contains("archiveFile.get().asFile.absolutePath, 'UTF-8'"));
}
}
}
+84 -25
View File
@@ -1,6 +1,8 @@
//! The file list on the OS clipboard: what Explorer copies when the user
//! copies files, so the project view can paste them, and what the project view
//! writes so Explorer can paste the files it copied (IDEA does both).
//! writes so Explorer can paste the files it copied (IDEA does both). The
//! written list comes with the paths as text, so a paste into an editor
//! inserts them instead of doing nothing.
//!
//! Windows keeps it as `CF_HDROP`: a `DROPFILES` header and a double-NUL
//! terminated list of absolute paths, wide when `fWide` is set. The data is set
@@ -10,6 +12,9 @@
//! macOS keeps one `public.file-url` item per file on the general
//! `NSPasteboard`; `NSURL` reads and writes them, so Finder's copies paste here
//! and ours paste in Finder.
//! On macOS, [`files`], [`has_files`] and [`set_files`] must run on the main
//! thread: AppKit and WebKit also access the general pasteboard there, and its
//! mutable type cache cannot be protected by a mutex in this crate.
//!
//! Other platforms (`text/uri-list` on X11) are not wired up: [`files`] is
//! empty and [`set_files`] fails there.
@@ -20,7 +25,8 @@ use std::path::PathBuf;
const HEADER: usize = 20;
/// The absolute paths on the clipboard, empty when it holds no file list.
pub fn files() -> Vec<PathBuf> {
/// Read failures remain errors so callers can retain an internal copy.
pub fn files() -> std::io::Result<Vec<PathBuf>> {
imp::files()
}
@@ -29,9 +35,8 @@ pub fn has_files() -> bool {
imp::has_files()
}
/// Replaces the clipboard with `paths`. False when it is busy or holds nothing
/// to write; the clipboard is a shared resource, so callers treat this as
/// best-effort.
/// Replaces the clipboard with `paths`. True only when the file list was
/// accepted; writing just the accompanying text is not a successful hand-off.
pub fn set_files(paths: &[PathBuf]) -> bool {
if paths.is_empty() {
return false;
@@ -111,6 +116,8 @@ mod imp {
/// `CF_HDROP`, the clipboard format holding a file list.
const CF_HDROP: u32 = 15;
/// `CF_UNICODETEXT`, so an editor paste of copied files inserts the paths.
const CF_UNICODETEXT: u32 = 13;
/// An `HGLOBAL` or a `HANDLE`: both are opaque pointers here.
type Handle = *mut c_void;
@@ -156,25 +163,29 @@ mod imp {
(unsafe { IsClipboardFormatAvailable(CF_HDROP) }) != 0
}
pub fn files() -> Vec<PathBuf> {
if !has_files() || !open() {
return Vec::new();
pub fn files() -> std::io::Result<Vec<PathBuf>> {
if !open() {
return Err(std::io::Error::last_os_error());
}
if !has_files() {
unsafe { CloseClipboard() };
return Ok(Vec::new());
}
let memory = unsafe { GetClipboardData(CF_HDROP) };
let paths = if memory.is_null() {
Vec::new()
Err(std::io::Error::last_os_error())
} else {
// SAFETY: the clipboard keeps the block alive while it is open, and
// `GlobalSize` bounds the bytes read from it.
unsafe {
let data = GlobalLock(memory);
if data.is_null() {
Vec::new()
Err(std::io::Error::last_os_error())
} else {
let bytes = std::slice::from_raw_parts(data as *const u8, GlobalSize(memory));
let paths = decode(bytes);
GlobalUnlock(memory);
paths
Ok(paths)
}
}
};
@@ -182,12 +193,37 @@ mod imp {
paths
}
/// An `HGLOBAL` holding `text` as UTF-16 with a terminating NUL, and its
/// byte size. Null when the allocation failed.
fn global_text(text: &str) -> (Handle, usize) {
let mut units: Vec<u16> = text.encode_utf16().collect();
units.push(0);
let bytes = units.len() * 2;
unsafe {
let memory = GlobalAlloc(GMEM_MOVEABLE, bytes);
if memory.is_null() {
return (ptr::null_mut(), 0);
}
let data = GlobalLock(memory);
if data.is_null() {
GlobalFree(memory);
return (ptr::null_mut(), 0);
}
ptr::copy_nonoverlapping(units.as_ptr() as *const u8, data as *mut u8, bytes);
GlobalUnlock(memory);
(memory, bytes)
}
}
pub fn set_files(paths: &[PathBuf]) -> bool {
let bytes = encode(paths);
if bytes.len() <= HEADER {
return false;
}
// SAFETY: the block is allocated, filled, handed to the clipboard and
// The paths as text, newline-separated, so pasting into an editor
// inserts them (IDEA writes the list and the text together).
let text = paths.iter().map(|p| p.to_string_lossy().into_owned()).collect::<Vec<_>>().join("\r\n");
// SAFETY: the blocks are allocated, filled, handed to the clipboard and
// freed only on the paths where the clipboard did not take ownership.
unsafe {
let memory = GlobalAlloc(GMEM_MOVEABLE, bytes.len());
@@ -205,17 +241,26 @@ mod imp {
GlobalFree(memory);
return false;
}
// Replacing the list also drops the text the user copied before.
// Replacing the list also drops the text the user copied before;
// the paths' own text is set back with it.
if EmptyClipboard() == 0 {
CloseClipboard();
GlobalFree(memory);
return false;
}
let taken = !SetClipboardData(CF_HDROP, memory).is_null();
CloseClipboard();
if !taken {
GlobalFree(memory);
}
let (text_memory, _) = global_text(&text);
if !text_memory.is_null() {
// The text alone still pastes somewhere useful.
let text_taken = !SetClipboardData(CF_UNICODETEXT, text_memory).is_null();
if !text_taken {
GlobalFree(text_memory);
}
}
CloseClipboard();
taken
}
}
@@ -223,13 +268,14 @@ mod imp {
#[cfg(target_os = "macos")]
mod imp {
use objc2::ClassType;
use objc2::rc::{Retained, autoreleasepool};
use objc2::runtime::{AnyClass, AnyObject, ProtocolObject};
use objc2::{ClassType, MainThreadMarker};
use objc2_app_kit::{
NSPasteboard, NSPasteboardReadingOptionKey, NSPasteboardURLReadingFileURLsOnlyKey, NSPasteboardWriting,
NSPasteboard, NSPasteboardReadingOptionKey, NSPasteboardTypeString, NSPasteboardURLReadingFileURLsOnlyKey,
NSPasteboardWriting,
};
use objc2_foundation::{NSArray, NSDictionary, NSNumber, NSURL};
use objc2_foundation::{NSArray, NSDictionary, NSNumber, NSString, NSURL};
use std::path::PathBuf;
/// `NSURL` as the one class to read, limited to file URLs: a web link the
@@ -237,6 +283,7 @@ mod imp {
fn read_file_urls<R>(
f: impl FnOnce(&NSPasteboard, &NSArray<AnyClass>, &NSDictionary<NSPasteboardReadingOptionKey, AnyObject>) -> R,
) -> R {
debug_assert!(MainThreadMarker::new().is_some(), "NSPasteboard access requires the main thread");
autoreleasepool(|_| {
let board = NSPasteboard::generalPasteboard();
let classes = NSArray::from_slice(&[NSURL::class()]);
@@ -256,23 +303,28 @@ mod imp {
})
}
pub fn files() -> Vec<PathBuf> {
pub fn files() -> std::io::Result<Vec<PathBuf>> {
read_file_urls(|board, classes, options| {
// SAFETY: as in `has_files`; every object read for `NSURL` is one.
let Some(objects) = (unsafe { board.readObjectsForClasses_options(classes, Some(options)) }) else {
return Vec::new();
return if unsafe { board.canReadObjectForClasses_options(classes, Some(options)) } {
Err(std::io::Error::other("Cannot read clipboard files"))
} else {
Ok(Vec::new())
};
};
objects
Ok(objects
.iter()
.filter_map(|object| object.downcast::<NSURL>().ok())
.filter_map(|url| url.to_file_path())
// Finder ends a folder's URL in `/`; the project view names it without.
.map(|path| path.components().collect())
.collect()
.collect())
})
}
pub fn set_files(paths: &[PathBuf]) -> bool {
debug_assert!(MainThreadMarker::new().is_some(), "NSPasteboard access requires the main thread");
autoreleasepool(|_| {
// Folders get a directory URL (ending in `/`), as Finder writes them.
let urls: Vec<Retained<NSURL>> = paths
@@ -285,9 +337,16 @@ mod imp {
let writers: Vec<&ProtocolObject<dyn NSPasteboardWriting>> =
urls.iter().map(|url| ProtocolObject::from_ref(&**url)).collect();
let board = NSPasteboard::generalPasteboard();
// Replacing the list also drops the text the user copied before.
// Replacing the list also drops the text the user copied before;
// the paths' own text is set back with it, so an editor paste
// inserts them (IDEA writes the list and the text together).
board.clearContents();
board.writeObjects(&NSArray::from_slice(&writers))
let written = board.writeObjects(&NSArray::from_slice(&writers));
let text = NSString::from_str(
&paths.iter().map(|p| p.to_string_lossy().into_owned()).collect::<Vec<_>>().join("\n"),
);
board.setString_forType(&text, unsafe { NSPasteboardTypeString });
written
})
}
}
@@ -300,8 +359,8 @@ mod imp {
false
}
pub fn files() -> Vec<PathBuf> {
Vec::new()
pub fn files() -> std::io::Result<Vec<PathBuf>> {
Ok(Vec::new())
}
pub fn set_files(_paths: &[PathBuf]) -> bool {
+4
View File
@@ -7,6 +7,7 @@ license.workspace = true
[dependencies]
dunce.workspace = true
encoding_rs.workspace = true
serde.workspace = true
serde_json.workspace = true
specta.workspace = true
@@ -16,3 +17,6 @@ tokio-util.workspace = true
[dev-dependencies]
tempfile.workspace = true
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Win32_Globalization", "Win32_System_Registry", "Win32_Foundation"] }
+13
View File
@@ -12,6 +12,8 @@ use std::collections::BTreeMap;
pub struct EditorGeneralSettings {
/// Change font size with ⌘ + mouse wheel.
pub wheel_zoom: bool,
pub markdown_image_folder: String,
pub markdown_image_name_pattern: String,
/// Drag-and-drop of selected text inside the editor.
pub drag_and_drop: bool,
/// When the caret moves out of view: `minimize` scrolling | `center` the caret line.
@@ -35,6 +37,8 @@ impl Default for EditorGeneralSettings {
fn default() -> Self {
Self {
wheel_zoom: false,
markdown_image_folder: "assets".into(),
markdown_image_name_pattern: "image-{timestamp}".into(),
drag_and_drop: true,
caret_scroll: "minimize".into(),
soft_wrap_files: "*.md; *.txt; *.rst; *.adoc".into(),
@@ -367,6 +371,12 @@ pub struct TextAttributesOverride {
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct LanguageCodeStyle {
/// Java formatter: `default` | `intellij` | `custom` (other languages ignore it).
#[serde(skip_serializing_if = "Option::is_none")]
pub formatter: Option<String>,
/// Eclipse formatter XML; relative paths start at the project root.
#[serde(skip_serializing_if = "Option::is_none")]
pub formatter_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub use_tab: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -434,6 +444,8 @@ pub struct EncodingSettings {
pub project: String,
/// Fallback for `.properties` files.
pub properties: String,
/// Display properties Unicode escapes as characters and save non-ASCII as escapes.
pub native_to_ascii: bool,
/// Create UTF-8 files: `never` (no BOM) | `always` (with BOM) | `windows` (with BOM on Windows).
pub utf8_bom: String,
}
@@ -444,6 +456,7 @@ impl Default for EncodingSettings {
global: "utf-8".into(),
project: "utf-8".into(),
properties: "iso-8859-1".into(),
native_to_ascii: false,
utf8_bom: "never".into(),
}
}
+52 -1
View File
@@ -12,6 +12,15 @@ pub type IdeResult<T> = Result<T, IdeError>;
pub struct IdeError {
pub kind: ErrorKind,
pub message: String,
/// Structured detail for errors the UI acts on; `null` for plain errors.
#[specta(optional)]
pub data: Option<ErrorData>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum ErrorData {
Tool { tool: String, missing: bool },
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
@@ -24,6 +33,7 @@ pub enum ErrorKind {
/// Document version mismatch; the UI must resync the full text.
OutOfSync,
Cancelled,
TimedOut,
/// A required external tool (language server, git, debugger) is unavailable.
Unavailable,
Io,
@@ -33,7 +43,21 @@ pub enum ErrorKind {
impl IdeError {
pub fn new(kind: ErrorKind, message: impl Into<String>) -> Self {
Self { kind, message: message.into() }
Self { kind, message: message.into(), data: None }
}
pub fn with_tool(mut self, tool: impl Into<String>) -> Self {
if self.data.is_none() {
self.data = Some(ErrorData::Tool { tool: tool.into(), missing: false });
}
self
}
pub fn missing_tool(tool: impl Into<String>) -> Self {
let tool = tool.into();
Self {
kind: ErrorKind::NotFound,
message: format!("{tool} is not installed"),
data: Some(ErrorData::Tool { tool, missing: true }),
}
}
pub fn not_found(m: impl Into<String>) -> Self {
Self::new(ErrorKind::NotFound, m)
@@ -65,3 +89,30 @@ impl From<std::io::Error> for IdeError {
Self::new(kind, e.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tool_errors_round_trip_without_changing_diagnostics() {
let message = "[error] file.css: Unexpected token\n 1 | }\n";
let error = IdeError::invalid(message).with_tool("prettier");
let decoded: IdeError = serde_json::from_str(&serde_json::to_string(&error).unwrap()).unwrap();
assert_eq!(decoded.message, message);
assert_eq!(decoded.data, Some(ErrorData::Tool { tool: "prettier".into(), missing: false }));
let missing = IdeError::missing_tool("prettier").with_tool("prettier");
assert_eq!(missing.kind, ErrorKind::NotFound);
assert_eq!(missing.data, Some(ErrorData::Tool { tool: "prettier".into(), missing: true }));
}
#[test]
fn legacy_errors_need_no_data() {
let error: IdeError = serde_json::from_str(r#"{"kind":"unavailable","message":"no server"}"#).unwrap();
assert!(error.data.is_none());
assert_eq!(
serde_json::to_value(error).unwrap(),
serde_json::json!({"kind":"unavailable","message":"no server","data":null})
);
}
}
+25 -1
View File
@@ -13,12 +13,22 @@ use std::sync::Arc;
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
pub enum IdeEvent {
/// Project window membership or selection changed.
WindowGroupsChanged,
ProjectTrustChanged {
root: crate::IpcPath,
trusted: bool,
},
/// Files or directories changed on disk (after debouncing, ignore rules applied).
FsChanged {
created: Vec<crate::IpcPath>,
modified: Vec<crate::IpcPath>,
removed: Vec<crate::IpcPath>,
},
/// Excluded or ignored folders whose contents changed; they stay out of `FsChanged`.
ExcludedDirsChanged {
dirs: Vec<crate::IpcPath>,
},
/// The backend changed a document (reload from disk, formatting, refactoring).
/// The UI applies `changes` on top of `base_version`, yielding `version`.
DocPatched {
@@ -32,10 +42,13 @@ pub enum IdeEvent {
doc: DocId,
dirty: bool,
},
/// An open document's language changed (Settings › File Types, Override File Type).
/// An open document's language changed (Settings › File Types, Override File
/// Type), or its UI mode (a framework's root marker appeared or went).
DocLanguage {
doc: DocId,
language: crate::LanguageId,
/// See `DocumentInfo::ui_mode`.
ui_mode: Option<String>,
},
/// Full replacement of diagnostics for one file from one producer.
Diagnostics {
@@ -112,11 +125,22 @@ pub enum IdeEvent {
id: u64,
prompt: String,
kind: AskpassKind,
/// The dialog offers "Remember" for the answer (git HTTPS passwords).
remember: bool,
},
/// Prompt `id` was answered (in any window) or gave up.
AskpassDone {
id: u64,
},
/// The kernel of an open notebook changed state (`starting`, `idle`, `busy`,
/// `dead`); `message` says why it failed to start or died.
NotebookKernel {
/// Backend document incarnation; stale queued events cannot target a reopen.
id: String,
path: crate::IpcPath,
state: String,
message: Option<String>,
},
}
/// What an askpass prompt asks for.
+1
View File
@@ -20,6 +20,7 @@ pub mod remote_settings;
pub mod settings;
pub mod task;
pub mod text;
pub mod tool_path;
pub mod version_managers;
pub mod workflow_settings;
+136 -10
View File
@@ -18,6 +18,60 @@
use std::collections::BTreeMap;
/// Decodes complete lines or collected output from IDE-managed tools, preferring UTF-8.
/// Windows launcher/native diagnostics fall back to the system ANSI code page.
/// Callers must buffer incomplete lines so multibyte characters are never split.
/// Run consoles and protocol streams have their own encoding contracts.
pub fn decode_tool_output(bytes: &[u8]) -> String {
#[cfg(windows)]
// SAFETY: GetACP is an argument-less query with no preconditions.
let code_page = unsafe { windows_sys::Win32::Globalization::GetACP() };
#[cfg(not(windows))]
let code_page = 65001;
decode_tool_output_with_code_page(bytes, code_page)
}
fn decode_tool_output_with_code_page(bytes: &[u8], code_page: u32) -> String {
if let Ok(text) = std::str::from_utf8(bytes) {
return text.to_owned();
}
let encoding = encoding_for_code_page(code_page).unwrap_or(encoding_rs::UTF_8);
// Collected output can mix UTF-8 JVM lines with native launcher diagnostics.
let mut text = String::new();
for line in bytes.split_inclusive(|b| *b == b'\n') {
match std::str::from_utf8(line) {
Ok(line) => text.push_str(line),
Err(_) => text.push_str(&encoding.decode_without_bom_handling(line).0),
}
}
text
}
fn encoding_for_code_page(cp: u32) -> Option<&'static encoding_rs::Encoding> {
Some(match cp {
65001 => encoding_rs::UTF_8,
936 => encoding_rs::GBK,
54936 => encoding_rs::GB18030,
950 => encoding_rs::BIG5,
932 => encoding_rs::SHIFT_JIS,
949 => encoding_rs::EUC_KR,
866 => encoding_rs::IBM866,
874 => encoding_rs::WINDOWS_874,
1250 => encoding_rs::WINDOWS_1250,
1251 => encoding_rs::WINDOWS_1251,
1252 => encoding_rs::WINDOWS_1252,
1253 => encoding_rs::WINDOWS_1253,
1254 => encoding_rs::WINDOWS_1254,
1255 => encoding_rs::WINDOWS_1255,
1256 => encoding_rs::WINDOWS_1256,
1257 => encoding_rs::WINDOWS_1257,
1258 => encoding_rs::WINDOWS_1258,
20866 => encoding_rs::KOI8_R,
21866 => encoding_rs::KOI8_U,
_ => return None,
})
}
const STD_PROPERTIES: &[&str] = &["stdout.encoding", "stderr.encoding", "sun.stdout.encoding", "sun.stderr.encoding"];
const FILE_ENCODING: &str = "file.encoding";
const TOOL_OPTIONS: &str = "JAVA_TOOL_OPTIONS";
@@ -161,13 +215,21 @@ fn fix_kind(
}
/// `vmArgs` of a launch by java-debug, which decodes the JVM's output as `encoding`
/// (its launch attribute, UTF-8 when unset) and sets `file.encoding` itself.
pub fn java_debug_vm_args(vm_args: &str, encoding: Option<&str>, windows: bool) -> Option<String> {
/// (its launch attribute, UTF-8 when unset). java-debug sets `file.encoding` only
/// when that attribute is nonblank; use the same defaults and user overrides as [`fix`].
pub fn java_debug_vm_args(
vm_args: &str,
vars: &BTreeMap<String, String>,
inherited: impl Fn(&str) -> Option<String>,
encoding: Option<&str>,
windows: bool,
) -> Option<String> {
let encoding = encoding.map(str::trim).filter(|e| !e.is_empty()).unwrap_or("UTF-8");
if (!windows && is_utf8(encoding)) || sets(vm_args, STD_PROPERTIES) {
let added = fix_kind(Kind::Java, Some(&[vm_args.to_owned()]), vars, inherited, encoding, windows).java_args;
if added.is_empty() {
return None;
}
let added = options(STD_PROPERTIES, encoding).join(" ");
let added = added.join(" ");
Some(match vm_args.trim() {
"" => added,
own => format!("{own} {added}"),
@@ -237,6 +299,29 @@ impl NoticeFilter {
mod tests {
use super::*;
#[test]
fn tool_output_prefers_utf8() {
for cp in [936, 1252, 65001, 0] {
for text in ["", "plain ASCII\r\n", "中文 café 🦀\n"] {
assert_eq!(decode_tool_output_with_code_page(text.as_bytes(), cp), text);
}
}
}
#[test]
fn tool_output_decodes_native_diagnostics_and_mixed_lines() {
const GBK: &[u8] = b"\xcf\xb5\xcd\xb3\xd5\xd2\xb2\xbb\xb5\xbd\xd6\xb8\xb6\xa8\xb5\xc4\xc2\xb7\xbe\xb6\xa1\xa3";
assert_eq!(decode_tool_output_with_code_page(GBK, 936), "系统找不到指定的路径。");
let mut mixed = "JVM: 中文\r\n".as_bytes().to_vec();
mixed.extend_from_slice(GBK);
mixed.extend_from_slice(b"\r\nlast");
assert_eq!(decode_tool_output_with_code_page(&mixed, 936), "JVM: 中文\r\n系统找不到指定的路径。\r\nlast");
assert_eq!(decode_tool_output_with_code_page(b"caf\xe9", 1252), "café");
for cp in [65001, 0] {
assert_eq!(decode_tool_output_with_code_page(b"a\xffb", cp), "a�b");
}
}
const STD_UTF8: [&str; 4] = [
"-Dstdout.encoding=UTF-8",
"-Dstderr.encoding=UTF-8",
@@ -369,12 +454,53 @@ mod tests {
#[test]
fn java_debug_launches_write_what_java_debug_decodes() {
assert_eq!(java_debug_vm_args("", None, true).as_deref(), Some(TOOL_UTF8));
assert_eq!(java_debug_vm_args("-Xmx1g", Some(""), true), Some(format!("-Xmx1g {TOOL_UTF8}")));
assert_eq!(java_debug_vm_args("", Some("GBK"), true).unwrap().split(' ').next(), Some("-Dstdout.encoding=GBK"));
assert_eq!(java_debug_vm_args("-Dstdout.encoding=GBK", None, true), None);
assert_eq!(java_debug_vm_args("-Xmx1g", None, false), None);
assert!(java_debug_vm_args("", Some("GBK"), false).is_some());
let debug =
|args: &str, encoding, windows| java_debug_vm_args(args, &BTreeMap::new(), |_| None, encoding, windows);
for encoding in [None, Some(""), Some(" "), Some("UTF-8"), Some("utf8")] {
let streams = encoding.filter(|e| !e.trim().is_empty()).unwrap_or("UTF-8");
let utf8 = format!("{} -Dfile.encoding=UTF-8", options(STD_PROPERTIES, streams).join(" "));
assert_eq!(debug("", encoding, true), Some(utf8.clone()));
assert_eq!(debug("-Xmx1g", encoding, true), Some(format!("-Xmx1g {utf8}")));
assert_eq!(debug("-Xmx1g", encoding, false), None);
}
for windows in [true, false] {
assert_eq!(debug("", Some("GBK"), windows), Some(options(STD_PROPERTIES, "GBK").join(" ")));
}
}
#[test]
fn java_debug_preserves_user_options_independently() {
let debug = |args: &str| java_debug_vm_args(args, &BTreeMap::new(), |_| None, None, true);
assert_eq!(debug("-Dfile.encoding=GBK"), Some(format!("-Dfile.encoding=GBK {TOOL_UTF8}")));
for property in STD_PROPERTIES {
let args = format!("-D{property}=GBK");
assert_eq!(debug(&args), Some(format!("{args} -Dfile.encoding=UTF-8")));
assert_eq!(debug(&format!("{args} -Dfile.encoding=GBK")), None);
}
for key in ["JDK_JAVA_OPTIONS", TOOL_OPTIONS] {
for (own, expected) in [
("-Dfile.encoding=GBK", Some(TOOL_UTF8.to_owned())),
("-Dstdout.encoding=GBK", Some("-Dfile.encoding=UTF-8".to_owned())),
("-Dstdout.encoding=GBK -Dfile.encoding=GBK", None),
] {
assert_eq!(java_debug_vm_args("", &vars(&[(key, own)]), |_| None, None, true), expected);
assert_eq!(
java_debug_vm_args("", &BTreeMap::new(), |k| (k == key).then(|| own.to_owned()), None, true),
expected
);
}
// A launch environment value overrides the inherited one, as in Run.
assert_eq!(
java_debug_vm_args(
"",
&vars(&[(key, "")]),
|k| (k == key).then(|| "-Dfile.encoding=GBK".into()),
None,
true
),
Some(format!("{TOOL_UTF8} -Dfile.encoding=UTF-8"))
);
}
}
fn filtered(chunks: &[&str]) -> String {
+138
View File
@@ -218,10 +218,129 @@ pub fn executable_names(name: &str, windows: bool, pathext: Option<&str>) -> Vec
exts.iter().map(|e| format!("{name}{}", e.to_ascii_lowercase())).collect()
}
/// First `search` directory holding `name` (see [`executable_names`]).
pub fn find_executable_in(
name: &str,
search: &OsStr,
windows: bool,
pathext: Option<&str>,
) -> Option<std::path::PathBuf> {
let names = executable_names(name, windows, pathext);
std::env::split_paths(search)
.filter(|d| !d.as_os_str().is_empty())
.find_map(|dir| names.iter().map(|n| dir.join(n)).find(|p| p.is_file()))
}
/// [`find_executable_in`] for this platform (`PATHEXT` from the environment).
pub fn find_executable(name: &str, search: &OsStr) -> Option<std::path::PathBuf> {
let pathext = host_var_os("PATHEXT");
find_executable_in(name, search, cfg!(windows), pathext.as_deref().and_then(|p| p.to_str()))
}
/// The user's own `PATH` entries on Windows (`HKCU\Environment`), the part of the
/// merged `PATH` a process may not inherit — an IDE started before the variable was
/// edited, or as a different user, sees only the machine entries. `%VAR%` references
/// (`REG_EXPAND_SZ`) are expanded against the current environment. `None` elsewhere:
/// there a login shell already supplies the user's whole `PATH`.
#[cfg(windows)]
pub fn user_path() -> Option<OsString> {
use windows_sys::Win32::Foundation::ERROR_SUCCESS;
use windows_sys::Win32::System::Registry::{
HKEY, HKEY_CURRENT_USER, KEY_READ, RRF_RT_REG_EXPAND_SZ, RRF_RT_REG_SZ, RegCloseKey, RegGetValueW,
RegOpenKeyExW,
};
fn wide(s: &str) -> Vec<u16> {
s.encode_utf16().chain(std::iter::once(0)).collect()
}
unsafe {
let mut key: HKEY = std::ptr::null_mut();
if RegOpenKeyExW(HKEY_CURRENT_USER, wide("Environment").as_ptr(), 0, KEY_READ, &mut key) != ERROR_SUCCESS {
return None;
}
let read = || {
// Only `Path`; `REG_EXPAND_SZ` is accepted so `%USERPROFILE%\…` entries expand.
let mut kind = 0u32;
let mut len = 0u32;
let name = wide("Path");
let status = RegGetValueW(
key,
std::ptr::null(),
name.as_ptr(),
RRF_RT_REG_SZ | RRF_RT_REG_EXPAND_SZ,
&mut kind,
std::ptr::null_mut(),
&mut len,
);
if status != ERROR_SUCCESS || len == 0 {
return None;
}
let mut buf = vec![0u16; (len as usize).div_ceil(2)];
let status = RegGetValueW(
key,
std::ptr::null(),
name.as_ptr(),
RRF_RT_REG_SZ | RRF_RT_REG_EXPAND_SZ,
&mut kind,
buf.as_mut_ptr() as *mut _,
&mut len,
);
if status != ERROR_SUCCESS {
return None;
}
let text = String::from_utf16_lossy(&buf);
Some(text.trim_end_matches('\0').to_owned())
};
let value = read();
RegCloseKey(key);
value.filter(|v| !v.trim().is_empty()).map(OsString::from)
}
}
/// The user's own `PATH` entries; `None` outside Windows (see the Windows impl).
#[cfg(not(windows))]
pub fn user_path() -> Option<OsString> {
None
}
/// `path` followed by the [`user_path`] entries it lacks.
pub fn with_user_path(path: &OsStr) -> OsString {
user_path().map_or_else(|| path.to_owned(), |user| append_path_entries(path, &user))
}
fn append_path_entries(path: &OsStr, extra: &OsStr) -> OsString {
let mut dirs: Vec<std::path::PathBuf> = std::env::split_paths(path).filter(|d| !d.as_os_str().is_empty()).collect();
for dir in std::env::split_paths(extra).filter(|d| !d.as_os_str().is_empty()) {
if !dirs.contains(&dir) {
dirs.push(dir);
}
}
std::env::join_paths(dirs).unwrap_or_else(|_| path.to_owned())
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(windows)]
#[test]
fn user_path_reads_the_current_users_path() {
// This host has a user `Path` only when the test account set one; either way the call
// must not fail, and a present value is a non-empty `;`-joined list of directories.
if let Some(path) = user_path() {
let dirs: Vec<_> = std::env::split_paths(&path).collect();
assert!(!dirs.is_empty(), "user PATH should hold at least one directory");
}
}
#[test]
fn append_path_entries_keeps_the_order_and_adds_what_is_missing() {
let join = |dirs: &[&str]| std::env::join_paths(dirs).unwrap();
assert_eq!(append_path_entries(&join(&["a", "b"]), &join(&["b", "c"])), join(&["a", "b", "c"]));
assert_eq!(append_path_entries(OsStr::new(""), &join(&["c"])), join(&["c"]));
}
#[test]
fn shell_env_adds_only_what_the_process_lacks_and_the_overlay_wins() {
set_shell_env(vec![("OPENIDEA_SHELL_ONLY_VAR".into(), "1".into()), ("PATH".into(), "/from/shell".into())]);
@@ -363,4 +482,23 @@ mod tests {
prepare(&mut cmd);
assert_eq!(cmd.get_envs().find(|(k, _)| *k == "PATH").and_then(|(_, v)| v), Some(OsStr::new("/custom")));
}
#[test]
fn find_executable_finds_bare_names_on_the_given_path() {
let dir = tempfile::tempdir().unwrap();
let a = dir.path().join("a");
let b = dir.path().join("b");
std::fs::create_dir_all(&a).unwrap();
std::fs::create_dir_all(&b).unwrap();
std::fs::write(a.join("agent"), "#!/bin/sh\n").unwrap();
std::fs::write(b.join("agent.cmd"), "@echo off\n").unwrap();
let search = std::env::join_paths([&a, &b]).unwrap();
if cfg!(windows) {
// The `.cmd` shim wins over a POSIX script `Command` can't start.
assert_eq!(find_executable("agent", &search), Some(b.join("agent.cmd")));
} else {
assert_eq!(find_executable("agent", &search), Some(a.join("agent")));
}
assert_eq!(find_executable("nope", &search), None);
}
}
+30 -1
View File
@@ -103,10 +103,19 @@ impl Default for DeploymentSettings {
}
}
/// An SFTP server and how project folders map onto it.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum DeploymentType {
#[default]
Sftp,
Local,
}
/// A deployment filesystem and how project folders map onto it.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct DeploymentServer {
pub kind: DeploymentType,
pub id: String,
pub name: String,
/// [`SshConfiguration::id`].
@@ -148,3 +157,23 @@ pub struct RunTarget {
pub node: String,
pub java: String,
}
#[cfg(test)]
mod deployment_tests {
use super::*;
#[test]
fn deployment_type_defaults_and_round_trips() {
let legacy: DeploymentServer =
serde_json::from_str(r#"{"id":"existing","sshConfig":"ssh","rootPath":"/srv"}"#).unwrap();
assert_eq!(legacy.kind, DeploymentType::Sftp);
assert_eq!(legacy.ssh_config, "ssh");
for kind in [DeploymentType::Sftp, DeploymentType::Local] {
let server = DeploymentServer { kind, ..legacy.clone() };
assert_eq!(
serde_json::from_str::<DeploymentServer>(&serde_json::to_string(&server).unwrap()).unwrap(),
server
);
}
}
}
+211 -1
View File
@@ -178,7 +178,7 @@ pub struct CustomKeymap {
pub struct GeneralSettings {
pub reopen_last_project: bool,
pub confirm_exit: bool,
/// Where Open… puts a project while one is open: `ask` | `new` | `same`.
/// 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.
pub default_project_directory: String,
@@ -276,6 +276,8 @@ pub struct AppearanceSettings {
/// Apply `ui_font_family` / `ui_font_size` (otherwise the theme's).
pub use_custom_font: bool,
pub ui_font_family: String,
/// Font tried when `ui_font_family` lacks a glyph; empty = none.
pub ui_font_fallback: String,
pub contrast_scrollbars: bool,
/// Adjust colours for red-green vision deficiency.
pub red_green_vision: bool,
@@ -285,11 +287,14 @@ pub struct AppearanceSettings {
pub project_colors_in_toolbar: bool,
pub dnd_with_alt_only: bool,
pub menu_icons: bool,
/// `hamburger` | `merged`: main menu presentation in the frameless title bar.
pub main_menu_style: String,
/// `subpixel` | `greyscale` | `none`
pub ide_antialiasing: String,
pub editor_antialiasing: String,
pub show_tool_window_bars: bool,
pub show_tool_window_names: bool,
pub show_tool_window_badges: bool,
pub widescreen_tool_windows: bool,
/// Status bar widgets shown (`true`) or hidden (`false`) by the user
/// (status bar context menu); widgets not listed use their default.
@@ -323,6 +328,7 @@ impl Default for AppearanceSettings {
zoom: 100,
use_custom_font: false,
ui_font_family: "Inter".into(),
ui_font_fallback: String::new(),
contrast_scrollbars: false,
red_green_vision: false,
different_tool_window_background: false,
@@ -331,10 +337,12 @@ impl Default for AppearanceSettings {
project_colors_in_toolbar: true,
dnd_with_alt_only: false,
menu_icons: true,
main_menu_style: "hamburger".into(),
ide_antialiasing: "greyscale".into(),
editor_antialiasing: "greyscale".into(),
show_tool_window_bars: true,
show_tool_window_names: false,
show_tool_window_badges: true,
widescreen_tool_windows: false,
status_bar_widgets: BTreeMap::new(),
main_toolbar: None,
@@ -342,6 +350,15 @@ impl Default for AppearanceSettings {
}
}
/// Imported editor colour scheme on top of a built-in base.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct CustomColorScheme {
pub name: String,
pub parent: String,
pub attributes: BTreeMap<String, TextAttributesOverride>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct EditorSettings {
@@ -357,6 +374,8 @@ pub struct EditorSettings {
pub format_on_save: bool,
/// Editor colour scheme id; `theme-default` follows the IDE theme.
pub color_scheme: String,
/// Imported editor schemes, stored in user settings.
pub custom_schemes: BTreeMap<String, CustomColorScheme>,
/// Color Scheme edits: scheme id → attribute id → override.
pub color_overrides: BTreeMap<String, BTreeMap<String, TextAttributesOverride>>,
/// Color Scheme › Language Defaults › Semantic highlighting: local variables and
@@ -404,6 +423,7 @@ impl Default for EditorSettings {
format_on_save: false,
color_scheme: "theme-default".into(),
color_overrides: BTreeMap::new(),
custom_schemes: BTreeMap::new(),
semantic_highlighting: false,
rainbow_brackets: false,
ligatures: false,
@@ -434,6 +454,8 @@ impl Default for EditorSettings {
pub struct FileSettings {
/// Save on focus loss / tab switch, like IDEA.
pub auto_save: bool,
/// Re-check open files against disk when the IDE window gains focus.
pub sync_on_focus: bool,
pub exclude: Vec<String>,
/// Ignored Files and Folders: name patterns hidden everywhere; see [`crate::ignored`].
pub ignored_names: Vec<String>,
@@ -465,6 +487,7 @@ impl Default for FileSettings {
fn default() -> Self {
Self {
auto_save: true,
sync_on_focus: true,
exclude: vec!["**/.git".into(), "**/node_modules".into(), "**/target".into()],
ignored_names: crate::ignored::DEFAULT_IGNORED_NAMES.iter().map(|s| (*s).into()).collect(),
trim_trailing_whitespace: true,
@@ -485,9 +508,50 @@ pub struct LanguagesSettings {
pub rust: RustSettings,
pub go: GoSettings,
pub typescript: TypeScriptSettings,
pub prettier: PrettierSettings,
pub java: JavaSettings,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct PrettierSettings {
/// Empty path: project node_modules, managed installation, then PATH.
pub path: String,
pub languages: Vec<String>,
/// Prefer Prettier even when a language server offers formatting.
pub prefer: bool,
pub run_on_save: bool,
}
impl Default for PrettierSettings {
fn default() -> Self {
Self {
path: String::new(),
languages: [
"markdown",
"yaml",
"css",
"scss",
"less",
"html",
"graphql",
"javascript",
"javascriptreact",
"typescript",
"typescriptreact",
"json",
"jsonc",
"vue",
]
.into_iter()
.map(String::from)
.collect(),
prefer: false,
run_on_save: false,
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct PythonSettings {
@@ -546,6 +610,35 @@ pub struct GoSettings {
pub goroot: String,
/// Empty = `go env GOPATH`.
pub gopath: String,
pub build_tags: GoBuildTags,
pub modules: GoModules,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct GoModules {
pub env: BTreeMap<String, String>,
pub auto_vendor: bool,
}
impl Default for GoModules {
fn default() -> Self {
Self { env: BTreeMap::new(), auto_vendor: true }
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct GoBuildTags {
/// Empty values inherit the selected toolchain's defaults.
pub os: String,
pub arch: String,
pub version: String,
pub compiler: String,
/// Empty = automatic, "1" = enabled, "0" = disabled.
pub cgo: String,
pub custom: String,
pub experiments: String,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
@@ -563,12 +656,37 @@ pub struct JavaSettings {
pub jdtls_vm_args: String,
/// Build Tools › Maven.
pub maven: MavenSettings,
/// Build Tools › Gradle.
pub gradle: GradleSettings,
}
/// Build Tools › Gradle.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct GradleSettings {
/// Empty = Project JDK; otherwise a JDK home.
pub jvm: String,
pub distribution: GradleDistribution,
/// Used only for the local distribution.
pub home: String,
/// Empty = Gradle's default user home.
pub user_home: String,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum GradleDistribution {
#[default]
Wrapper,
Local,
}
/// Build Tools › Maven.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct MavenSettings {
/// Project-relative root POMs removed from the Maven project model.
pub unlinked_projects: Vec<String>,
/// Maven home (or its `mvn` executable); empty = the project's `mvnw`, else `mvn` on `PATH`.
pub home: String,
/// User `settings.xml` (`-s`); empty = `~/.m2/settings.xml`.
@@ -647,6 +765,44 @@ impl Settings {
mod tests {
use super::*;
#[test]
fn ui_font_fallback_defaults_empty_and_round_trips() {
let old: Settings = serde_json::from_str(r#"{"appearance":{"useCustomFont":true}}"#).unwrap();
assert!(old.appearance.ui_font_fallback.is_empty());
for family in ["", "Songti SC", "Maple Mono NF CN"] {
let mut settings = Settings::default();
settings.appearance.ui_font_fallback = family.into();
let json = serde_json::to_value(&settings).unwrap();
assert_eq!(json["appearance"]["uiFontFallback"], family);
let restored: Settings = serde_json::from_value(json).unwrap();
assert_eq!(restored.appearance.ui_font_fallback, family);
}
}
#[test]
fn tool_window_badges_default_on_and_round_trip() {
let old: Settings = serde_json::from_str(r#"{"appearance":{"showToolWindowNames":true}}"#).unwrap();
assert!(old.appearance.show_tool_window_badges);
assert!(Settings::default().appearance.show_tool_window_badges);
for enabled in [false, true] {
let mut settings = Settings::default();
settings.appearance.show_tool_window_badges = enabled;
let json = serde_json::to_value(&settings).unwrap();
assert_eq!(json["appearance"]["showToolWindowBadges"], enabled);
let restored: Settings = serde_json::from_value(json).unwrap();
assert_eq!(restored.appearance.show_tool_window_badges, enabled);
}
}
#[test]
fn main_menu_style_defaults_and_round_trips() {
let old: Settings = serde_json::from_str(r#"{"appearance":{"compactMode":true}}"#).unwrap();
assert_eq!(old.appearance.main_menu_style, "hamburger");
let merged: Settings = serde_json::from_str(r#"{"appearance":{"mainMenuStyle":"merged"}}"#).unwrap();
assert_eq!(merged.appearance.main_menu_style, "merged");
assert_eq!(serde_json::to_value(merged).unwrap()["appearance"]["mainMenuStyle"], "merged");
}
#[test]
fn editor_font_weights_default_to_regular_and_bold() {
let json = serde_json::to_value(Settings::default()).unwrap();
@@ -691,3 +847,57 @@ mod tests {
assert!(Settings::default().files.file_types.is_empty());
}
}
#[cfg(test)]
mod maven_linkage_tests {
use super::*;
#[test]
fn maven_linkage_defaults_and_round_trips() {
let old: MavenSettings = serde_json::from_str(r#"{"home":"/opt/maven"}"#).unwrap();
assert!(old.unlinked_projects.is_empty());
let settings =
MavenSettings { unlinked_projects: vec!["pom.xml".into(), "services/api/pom.xml".into()], ..old };
let encoded = serde_json::to_string(&settings).unwrap();
assert!(encoded.contains("unlinkedProjects"));
assert_eq!(serde_json::from_str::<MavenSettings>(&encoded).unwrap(), settings);
}
}
#[cfg(test)]
mod gradle_settings_tests {
use super::*;
#[test]
fn defaults_and_round_trip() {
let old: JavaSettings = serde_json::from_str(r#"{"jdk":"/jdk"}"#).unwrap();
assert_eq!(old.gradle, GradleSettings::default());
for distribution in [GradleDistribution::Wrapper, GradleDistribution::Local] {
let mut settings = Settings::default();
settings.languages.java.gradle = GradleSettings {
jvm: "/jdk21".into(),
distribution,
home: "/gradle".into(),
user_home: "/cache/gradle".into(),
};
let restored: Settings = serde_json::from_str(&serde_json::to_string(&settings).unwrap()).unwrap();
assert_eq!(restored.languages.java.gradle, settings.languages.java.gradle);
}
}
}
#[cfg(test)]
mod go_module_tests {
use super::*;
#[test]
fn old_go_settings_inherit_module_defaults() {
let old: GoSettings = serde_json::from_str(r#"{"goroot":"/go"}"#).unwrap();
assert!(old.modules.auto_vendor);
assert!(old.modules.env.is_empty());
let configured: GoSettings =
serde_json::from_str(r#"{"modules":{"autoVendor":false,"env":{"GOPRIVATE":"example.com/private"}}}"#)
.unwrap();
assert!(!configured.modules.auto_vendor);
assert_eq!(configured.modules.env["GOPRIVATE"], "example.com/private");
}
}
+69
View File
@@ -99,6 +99,44 @@ pub struct WorkspaceEdit {
pub files: Vec<FileEdit>,
}
impl WorkspaceEdit {
/// Coalesces aliases of the same file before applying edits against its original text.
/// Resource operations separate batches because later edits may use the new contents.
pub fn deduplicate(&mut self) {
let mut files = Vec::new();
let mut batch = std::collections::HashMap::<std::path::PathBuf, usize>::new();
let mut resolve_aliases = true;
for file in std::mem::take(&mut self.files) {
match file {
FileEdit::Edit { path, edits } => {
let path = if resolve_aliases {
dunce::canonicalize(&path).unwrap_or_else(|_| path.to_path_buf())
} else {
path.to_path_buf()
};
let index = *batch.entry(path.clone()).or_insert_with(|| {
files.push(FileEdit::Edit { path: path.into(), edits: Vec::new() });
files.len() - 1
});
let FileEdit::Edit { edits: own, .. } = &mut files[index] else { unreachable!() };
for edit in edits {
if !own.contains(&edit) {
own.push(edit);
}
}
}
op => {
batch.clear();
// Later paths refer to a filesystem state the resource operation has yet to create.
resolve_aliases = false;
files.push(op);
}
}
}
self.files = files;
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum FileEdit {
@@ -203,3 +241,34 @@ pub struct OutlineItem {
/// Fuzzy-match highlight: indices (in chars of the displayed string) that matched.
pub type MatchIndices = Vec<u32>;
#[cfg(all(test, unix))]
mod workspace_edit_tests {
use super::*;
#[test]
fn aliases_are_one_edit_but_resource_operations_preserve_order() {
let dir = tempfile::tempdir().unwrap();
let real = dir.path().join("main.cpp");
let alias = dir.path().join("alias.cpp");
std::fs::write(&real, "add()").unwrap();
std::os::unix::fs::symlink(&real, &alias).unwrap();
let change = TextEdit { range: Range::new(Pos::new(0, 0), Pos::new(0, 3)), new_text: "sum_values".into() };
let edit = |path: PathBuf| FileEdit::Edit { path: path.into(), edits: vec![change.clone()] };
let mut workspace = WorkspaceEdit {
files: vec![
edit(real.clone()),
edit(alias),
FileEdit::Create { path: dir.path().join("other").into(), overwrite: false },
edit(real),
],
};
workspace.deduplicate();
assert_eq!(workspace.files.len(), 3);
let FileEdit::Edit { edits, .. } = &workspace.files[0] else { panic!() };
assert_eq!(edits, &[change]);
let once = workspace.clone();
workspace.deduplicate();
assert_eq!(workspace, once);
}
}
+233
View File
@@ -0,0 +1,233 @@
//! Where the IDE looks up the executables it runs: language servers, the tools
//! their settings come from, installers. An app started from the desktop gets a
//! minimal `PATH`, and version managers set theirs up in shell rc files.
use crate::process::{host_var_os, login_env};
use crate::version_managers;
use std::collections::HashSet;
use std::ffi::{OsStr, OsString};
use std::path::{Path, PathBuf};
/// Where executables are looked up: this process's `PATH` (the login shell's in the
/// app), the login shell's `PATH` as last read, then per-user directories and mise's,
/// asdf's and pyenv's shims, for when the shell's setup didn't reach the app. Language server
/// launchers, their settings hooks and tool detection all look here, so a settings
/// hook finds the executable its server is launched from.
pub fn search_path() -> OsString {
let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).map(PathBuf::from);
let shell_path = login_env().and_then(|env| env.iter().find(|(k, _)| k == "PATH").map(|(_, v)| v.clone()));
let shims: Vec<PathBuf> = version_managers::managers().iter().map(|m| m.shims()).collect();
// The Windows user `PATH` may not reach a process started before it was set; append its
// entries after the inherited ones so missing user tools still resolve.
let user_path = crate::process::user_path();
let dirs = search_dirs(
host_var_os("PATH").as_deref(),
shell_path.as_deref(),
user_path.as_deref(),
home.as_deref(),
&shims,
);
std::env::join_paths(dirs).unwrap_or_default()
}
/// Resolves a command on the shared path, or an explicit path relative to `root`.
pub fn find_command(command: &str, search: &OsStr, root: &Path) -> Option<PathBuf> {
let path = Path::new(command);
let explicit = path.parent().is_some_and(|p| !p.as_os_str().is_empty());
let pathext = host_var_os("PATHEXT");
let names = crate::process::executable_names(command, cfg!(windows), pathext.as_deref().and_then(OsStr::to_str));
let dirs = if explicit {
vec![root.to_path_buf()]
} else {
std::env::split_paths(search).filter(|p| !p.as_os_str().is_empty()).map(|p| root.join(p)).collect()
};
dirs.into_iter().find_map(|dir| names.iter().map(|name| dir.join(name)).find(|p| is_executable(p)))
}
fn is_executable(path: &Path) -> bool {
let Ok(metadata) = path.metadata() else { return false };
if !metadata.is_file() {
return false;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
metadata.permissions().mode() & 0o111 != 0
}
#[cfg(not(unix))]
true
}
/// [`search_path`]'s directories, in order and once each. Only absolute ones: a
/// relative entry (`.`, `node_modules/.bin`) resolves against the project, which
/// could then supply the tools the IDE runs. One that can't be joined (a `:` in a
/// directory name) is left out rather than failing the whole path.
fn search_dirs(
process_path: Option<&OsStr>,
shell_path: Option<&OsStr>,
user_path: Option<&OsStr>,
home: Option<&Path>,
shims: &[PathBuf],
) -> Vec<PathBuf> {
let mut dirs: Vec<PathBuf> =
process_path.into_iter().chain(shell_path).chain(user_path).flat_map(std::env::split_paths).collect();
if let Some(home) = home {
dirs.extend(USER_DIRS.iter().map(|d| home.join(d)));
dirs.extend(nvm_bins(&home.join(".nvm/versions/node")));
}
// Version managers' shims after real tool directories: a shim exists for a command any
// installed version has, and fails when the selected one lacks it. Before the system's,
// as the managers' shell setup puts them.
dirs.extend(home.map(|h| h.join(".pyenv/shims")));
dirs.extend(shims.iter().cloned());
dirs.extend(SYSTEM_DIRS.iter().map(PathBuf::from));
let mut seen = HashSet::new();
dirs.retain(|d| d.is_absolute() && std::env::join_paths([d]).is_ok() && seen.insert(d.clone()));
dirs
}
/// Tool directories below the home directory.
const USER_DIRS: &[&str] = &[
".cargo/bin",
".local/bin",
"go/bin",
// `dotnet tool install --global` puts fsautocomplete, csharp-ls and friends here.
".dotnet/tools",
".bun/bin",
".deno/bin",
".npm-global/bin",
".volta/bin",
// Set up in shell rc files: fnm's default Node and pnpm's global bin (Linux, then macOS).
".local/share/fnm/aliases/default/bin",
"Library/Application Support/fnm/aliases/default/bin",
".fnm/aliases/default/bin",
".local/share/pnpm/bin",
".local/share/pnpm",
"Library/pnpm/bin",
"Library/pnpm",
];
const SYSTEM_DIRS: &[&str] = &["/opt/homebrew/bin", "/usr/local/go/bin", "/usr/local/bin", "/usr/bin", "/bin"];
/// Whether `dir` is one of the system's own bin directories, which [`search_path`] adds
/// whatever `PATH` holds.
pub fn is_system_dir(dir: &Path) -> bool {
SYSTEM_DIRS.iter().any(|d| Path::new(d) == dir)
}
/// The `bin` directories of nvm's Node versions in `dir`, newest first.
fn nvm_bins(dir: &Path) -> Vec<PathBuf> {
let mut versions: Vec<(Vec<u64>, PathBuf)> = std::fs::read_dir(dir)
.into_iter()
.flatten()
.flatten()
.map(|e| (version_key(&e.file_name().to_string_lossy()), e.path().join("bin")))
.collect();
versions.sort();
versions.into_iter().rev().map(|(_, bin)| bin).collect()
}
/// `v22.10.1` as `[22, 10, 1]`: v22 is newer than v9, v20.10 newer than v20.9.
fn version_key(name: &str) -> Vec<u64> {
name.trim_start_matches('v').split('.').map(|n| n.parse().unwrap_or(0)).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn commands_resolve_on_path_and_explicit_paths_under_root() {
let dir = tempfile::tempdir().unwrap();
let bin = dir.path().join("bin");
std::fs::create_dir(&bin).unwrap();
let name = if cfg!(windows) { "ngserver.cmd" } else { "ngserver" };
std::fs::write(bin.join(name), "").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(bin.join(name), std::fs::Permissions::from_mode(0o755)).unwrap();
}
let search = std::env::join_paths([&bin]).unwrap();
assert_eq!(find_command("ngserver", &search, dir.path()), Some(bin.join(name)));
assert_eq!(find_command("bin/ngserver", &search, dir.path()), Some(bin.join(name)));
assert_eq!(find_command(bin.join(name).to_str().unwrap(), &search, dir.path()), Some(bin.join(name)));
assert_eq!(find_command("absent", &search, dir.path()), None);
for command in ["bin/ngserver", bin.join(name).to_str().unwrap()] {
assert_eq!(find_command(command, OsStr::new(""), dir.path()), Some(bin.join(name)));
}
}
#[cfg(unix)]
#[test]
fn non_executable_files_do_not_shadow_commands() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let dirs = [tmp.path().join("first"), tmp.path().join("second")];
for (dir, mode) in dirs.iter().zip([0o644, 0o755]) {
std::fs::create_dir(dir).unwrap();
let file = dir.join("ngserver");
std::fs::write(&file, "#!/bin/sh\n").unwrap();
std::fs::set_permissions(file, std::fs::Permissions::from_mode(mode)).unwrap();
}
let path = std::env::join_paths(&dirs).unwrap();
assert_eq!(find_command("ngserver", &path, tmp.path()), Some(dirs[1].join("ngserver")));
assert_eq!(find_command("first/ngserver", &path, tmp.path()), None);
}
#[cfg(unix)]
#[test]
fn search_dirs_put_the_process_path_first_and_shims_after_real_tools() {
let shims = [PathBuf::from("/home/me/.local/share/mise/shims"), "/home/me/.asdf/shims".into()];
let dirs = search_dirs(
Some(OsStr::new("/usr/bin:.:node_modules/.bin:/opt/tools")),
// nushell prints `$PATH` as is.
Some(OsStr::new("$PATH:/opt/tools:/home/me/.asdf/shims")),
None,
Some(Path::new("/home/me")),
&shims,
);
let at = |d: &str| dirs.iter().position(|p| p == Path::new(d)).unwrap();
assert_eq!(dirs[..3], [PathBuf::from("/usr/bin"), "/opt/tools".into(), "/home/me/.asdf/shims".into()]);
assert!(at("/home/me/.npm-global/bin") < at("/home/me/.pyenv/shims"));
// Global .NET tools (fsautocomplete, csharp-ls) install here, off PATH.
assert!(at("/home/me/.dotnet/tools") < at("/usr/local/bin"));
assert!(at("/home/me/.local/share/mise/shims") < at("/usr/local/bin"));
assert!(dirs.iter().all(|d| d.is_absolute()));
let unique: HashSet<&PathBuf> = dirs.iter().collect();
assert_eq!(unique.len(), dirs.len());
// A `:` can't be in a `PATH` entry: those below such a home are left out.
let dirs = search_dirs(Some(OsStr::new("/usr/bin")), None, None, Some(Path::new("/home/a:b")), &[]);
assert!(dirs.contains(&PathBuf::from("/usr/bin")) && !dirs.iter().any(|d| d.starts_with("/home")));
}
#[test]
fn search_dirs_append_user_path_entries_once_after_the_inherited_path() {
let (system, dup, usertool) =
if cfg!(windows) { ("C:/system", "C:/dup", "C:/usertool") } else { ("/system", "/dup", "/usertool") };
let home = Path::new(if cfg!(windows) { "C:/home/me" } else { "/home/me" });
let join = |dirs: &[&str]| std::env::join_paths(dirs.iter().map(PathBuf::from)).unwrap();
let inherited = join(&[system, dup]);
let user = join(&[dup, usertool]);
let dirs = search_dirs(Some(inherited.as_os_str()), None, Some(user.as_os_str()), Some(home), &[]);
let at = |d: &str| dirs.iter().position(|p| p == Path::new(d)).unwrap();
// User entries follow the inherited PATH, de-duplicated, ahead of the fallbacks.
assert_eq!(dirs.iter().filter(|d| *d == Path::new(dup)).count(), 1);
assert!(at(dup) < at(usertool));
// And before this platform's home/system fallbacks.
let fallback = dirs.iter().position(|d| d == &home.join(USER_DIRS[0])).unwrap();
assert!(at(usertool) < fallback);
}
#[test]
fn nvm_versions_newest_first() {
let dir = std::env::temp_dir().join(format!("openidea-nvm-{}", std::process::id()));
for v in ["v9.11.2", "v20.9.0", "v22.1.0", "v20.10.0"] {
std::fs::create_dir_all(dir.join(v).join("bin")).unwrap();
}
let bins = nvm_bins(&dir);
std::fs::remove_dir_all(&dir).unwrap();
let names: Vec<_> = bins.iter().map(|b| b.parent().unwrap().file_name().unwrap().to_owned()).collect();
assert_eq!(names, ["v22.1.0", "v20.10.0", "v20.9.0", "v9.11.2"]);
}
}
+6 -2
View File
@@ -213,8 +213,12 @@ pub fn collect(spec: &CoverageReportSpec) -> IdeResult<CoverageSuite> {
.output()
.map_err(|e| IdeError::unavailable(format!("cannot run {program}: {e}")))?;
if !out.status.success() {
let text = String::from_utf8_lossy(&out.stderr).trim().to_owned();
let text = if text.is_empty() { String::from_utf8_lossy(&out.stdout).trim().to_owned() } else { text };
let text = ide_core::output_encoding::decode_tool_output(&out.stderr).trim().to_owned();
let text = if text.is_empty() {
ide_core::output_encoding::decode_tool_output(&out.stdout).trim().to_owned()
} else {
text
};
return Err(IdeError::not_found(format!("exporting the coverage data failed: {text}")));
}
let text = read_report(lcov)?;
+1
View File
@@ -6,6 +6,7 @@ edition.workspace = true
license.workspace = true
[dependencies]
tempfile.workspace = true
ide-core.workspace = true
ide-language.workspace = true
ide-jsonrpc.workspace = true
+45 -2
View File
@@ -63,6 +63,12 @@ fn builtin(adapter: &str) -> Option<DebuggerConfig> {
"tcp",
"Install the CodeLLDB adapter (https://github.com/vadimcn/codelldb/releases) and put `codelldb` on PATH",
),
"kotlin-debug-adapter" => cfg(
"kotlin-debug-adapter",
&[],
"stdio",
"Install the Kotlin Debug Adapter from Settings > Language Servers",
),
"debugpy" => cfg("python3", &["-m", "debugpy.adapter"], "stdio", "pip install debugpy"),
"js-debug" => cfg(
"js-debug-adapter",
@@ -342,8 +348,17 @@ pub struct AdapterIo {
pub port: Option<u16>,
/// Recent stderr lines of the adapter process, for error messages.
pub stderr: Arc<parking_lot::Mutex<Vec<String>>>,
/// (category, line) printed by the debuggee when it shares the adapter's stdio.
pub program_output: Option<ProgramOutput>,
}
pub type ProgramOutput = tokio::sync::mpsc::UnboundedReceiver<(&'static str, String)>;
type ProgramSink = tokio::sync::mpsc::UnboundedSender<(&'static str, String)>;
/// Delve's debuggee inherits its stdio; no DAP output event carries what the program prints.
const SHARES_STDIO: &str = "delve";
const DELVE_BANNER: &str = "DAP server listening at:";
pub type LaunchFuture = Pin<Box<dyn Future<Output = IdeResult<AdapterIo>> + Send>>;
/// Starts adapters. Tests substitute in-memory adapters.
@@ -379,6 +394,7 @@ impl AdapterLauncher for ProcessLauncher {
child: None,
port: Some(port),
stderr,
program_output: None,
});
}
let embedded = match spec.connect.as_deref().filter(|_| spec.transport == Transport::Tcp) {
@@ -401,6 +417,7 @@ impl AdapterLauncher for ProcessLauncher {
child: None,
port: Some(*port),
stderr,
program_output: None,
});
}
if spec.adapter == DEBUGPY_TOOL {
@@ -439,8 +456,14 @@ impl AdapterLauncher for ProcessLauncher {
.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 spec.adapter == SHARES_STDIO {
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());
pump_lines(err, spec.adapter.clone(), stderr.clone(), program_sink.clone().map(|tx| (tx, "stderr")));
}
match port {
None => {
@@ -452,11 +475,12 @@ impl AdapterLauncher for ProcessLauncher {
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());
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 {
@@ -478,6 +502,7 @@ impl AdapterLauncher for ProcessLauncher {
child: Some(child),
port: Some(port),
stderr,
program_output,
})
}
}
@@ -489,9 +514,15 @@ fn pump_lines<R: AsyncRead + Unpin + Send + 'static>(
stream: R,
name: String,
sink: Arc<parking_lot::Mutex<Vec<String>>>,
program: Option<(ProgramSink, &'static str)>,
) {
tokio::spawn(ide_jsonrpc::output::read_lines(stream, move |line| {
tracing::debug!(target: "ide_dap::adapter", "[{name}] {line}");
if let Some((tx, category)) = &program
&& !line.starts_with(DELVE_BANNER)
{
let _ = tx.send((category, format!("{line}\n")));
}
push_line(&sink, line);
}));
}
@@ -543,6 +574,18 @@ async fn connect_with_retries(
mod tests {
use super::*;
#[tokio::test]
async fn program_output_is_forwarded_without_the_adapter_banner() {
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let sink = Arc::default();
let text: &[u8] = b"DAP server listening at: 127.0.0.1:1\n[hello two words]\nno newline";
pump_lines(text, "delve".into(), Arc::clone(&sink), Some((tx, "stdout")));
assert_eq!(rx.recv().await, Some(("stdout", "[hello two words]\n".into())));
assert_eq!(rx.recv().await, Some(("stdout", "no newline\n".into())));
assert_eq!(rx.recv().await, None);
assert_eq!(sink.lock().len(), 3);
}
/// Managed tools win over `PATH`, and debugpy falls back to the private copy.
#[cfg(unix)]
#[tokio::test]
+4 -2
View File
@@ -70,8 +70,10 @@ pub async fn build(
let on_output = std::sync::Arc::new(on_output);
let err_out = on_output.clone();
let err_task = tokio::spawn(async move {
let mut lines = BufReader::new(stderr).lines();
while let Ok(Some(line)) = lines.next_line().await {
let mut lines = BufReader::new(stderr).split(b'\n');
while let Ok(Some(line)) = lines.next_segment().await {
let line = ide_core::output_encoding::decode_tool_output(&line);
let line = line.trim_end_matches('\r');
err_out(format!("{line}\n"));
}
});
+218
View File
@@ -0,0 +1,218 @@
//! Package-aligned sources for fwcd's path-based class resolver. Never write into a project.
use ide_core::{IdeError, IdeResult};
use serde_json::Value;
use std::{
collections::HashMap,
fs,
path::{Path, PathBuf},
};
pub const ADAPTER: &str = "kotlin-debug-adapter";
pub struct SourceMirror {
dir: tempfile::TempDir,
paths: HashMap<PathBuf, PathBuf>,
copies: Vec<(PathBuf, PathBuf)>,
source_dir: PathBuf,
roots: HashMap<PathBuf, usize>,
}
impl SourceMirror {
pub fn create(project: &Path) -> IdeResult<Self> {
let base = fs::canonicalize(std::env::temp_dir())?;
// Check before creating anything, including when TMPDIR points into a project.
if base.starts_with(fs::canonicalize(project)?) {
return Err(IdeError::invalid("Kotlin source mirror must be outside the project"));
}
let dir = tempfile::Builder::new().prefix("openidea-kotlin-").tempdir_in(base)?;
let source_dir = if project.join("src").is_dir() { project.join("src") } else { project.to_owned() };
let mut mirror = Self {
dir,
paths: HashMap::new(),
copies: Vec::new(),
source_dir: source_dir.clone(),
roots: HashMap::new(),
};
mirror.scan(&source_dir)?;
Ok(mirror)
}
pub fn root(&self) -> &Path {
self.dir.path()
}
fn scan(&mut self, dir: &Path) -> IdeResult<()> {
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
let kind = entry.file_type()?;
if kind.is_dir() {
if !matches!(
entry.file_name().to_str(),
Some(".git" | ".gradle" | "build" | "target" | "node_modules" | ".idea" | ".openidea")
) {
self.scan(&path)?;
}
} else if matches!(path.extension().and_then(|s| s.to_str()), Some("kt" | "java")) {
self.add(&path)?;
}
}
Ok(())
}
fn add(&mut self, real: &Path) -> IdeResult<()> {
if self.paths.contains_key(real) {
return Ok(());
}
let Ok(text) = fs::read_to_string(real) else {
tracing::debug!("Kotlin debug mirror: skipping unreadable {}", real.display());
return Ok(());
};
let language = if real.extension().is_some_and(|e| e == "java") { "java" } else { "kotlin" };
let relative = real.strip_prefix(&self.source_dir).unwrap_or(real);
let mut components = relative.components();
let source_set = components.next().map(|c| PathBuf::from(c.as_os_str())).unwrap_or_default();
let next_root = self.roots.len();
let index = *self.roots.entry(source_set).or_insert(next_root);
let mut mirror = self.root().join(format!("root{index}")).join("src/main").join(language);
for part in package(&text).split('.').filter(|s| !s.is_empty()) {
if part == ".." || part.contains(['/', '\\']) {
tracing::debug!("Kotlin debug mirror: skipping {} (invalid package)", real.display());
return Ok(());
}
mirror.push(part);
}
mirror.push(real.file_name().ok_or_else(|| IdeError::invalid("Source has no filename"))?);
// Same package and file name in one source set (e.g. main and test of a module):
// the first wins; breakpoints in the other stay unverified instead of failing the session.
if self.paths.values().any(|p| p == &mirror) {
tracing::debug!("Kotlin debug mirror: skipping ambiguous {}", real.display());
return Ok(());
}
fs::create_dir_all(mirror.parent().unwrap())?;
if link(real, &mirror).is_err() {
fs::copy(real, &mirror)?;
self.copies.push((real.to_owned(), mirror.clone()));
}
self.paths.insert(real.to_owned(), mirror);
Ok(())
}
pub fn adapter_path(&self, real: &Path) -> Option<PathBuf> {
self.paths.get(real).cloned()
}
pub fn client_path(&self, mirror: &Path) -> Option<PathBuf> {
self.paths.iter().find_map(|(real, p)| (p == mirror).then(|| real.clone()))
}
pub fn refresh(&mut self) -> IdeResult<()> {
for (real, mirror) in &self.copies {
if real.is_file() {
fs::copy(real, mirror)?;
}
}
self.scan(&self.source_dir.clone())
}
pub fn translate(&self, value: &mut Value) {
match value {
Value::Object(obj) => {
if let Some(path) = obj.get("path").and_then(Value::as_str).and_then(|p| self.client_path(Path::new(p)))
{
obj.insert("path".into(), Value::String(path.to_string_lossy().into_owned()));
}
for child in obj.values_mut() {
self.translate(child);
}
}
Value::Array(items) => {
for child in items {
self.translate(child);
}
}
_ => {}
}
}
}
#[cfg(unix)]
fn link(real: &Path, mirror: &Path) -> std::io::Result<()> {
std::os::unix::fs::symlink(real, mirror)
}
#[cfg(windows)]
fn link(real: &Path, mirror: &Path) -> std::io::Result<()> {
std::os::windows::fs::symlink_file(real, mirror)
}
// Remove comments before reading the package directive (including nested Kotlin block comments).
fn package(text: &str) -> String {
let mut clean = String::new();
let mut chars = text.chars().peekable();
let mut depth = 0;
while let Some(c) = chars.next() {
if c == '/' && chars.peek() == Some(&'*') {
chars.next();
depth += 1;
clean.push(' ');
} else if depth > 0 && c == '*' && chars.peek() == Some(&'/') {
chars.next();
depth -= 1;
} else if depth == 0 && c == '/' && chars.peek() == Some(&'/') {
for c in chars.by_ref() {
if c == '\n' {
clean.push('\n');
break;
}
}
} else if depth == 0 {
clean.push(c);
}
}
clean
.lines()
.find_map(|line| {
let rest = line.trim().strip_prefix("package")?;
if !rest.starts_with(char::is_whitespace) {
return None;
}
Some(rest.split(';').next().unwrap_or_default().trim().replace('`', ""))
})
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn external_mirror_round_trip_and_cleanup() {
let project = tempfile::tempdir().unwrap();
let real = project.path().join("Main.kt");
fs::write(&real, "/* package wrong */\npackage org.example\nfun main() {}\n").unwrap();
let mirror = SourceMirror::create(project.path()).unwrap();
let path = mirror.adapter_path(&real).unwrap();
assert!(!path.starts_with(project.path()));
assert!(path.ends_with("src/main/kotlin/org/example/Main.kt"));
assert_eq!(fs::read_to_string(&path).unwrap(), fs::read_to_string(&real).unwrap());
let mut payload =
serde_json::json!({"stackFrames": [{"source": {"path": path}, "line": 6}], "source": {"path": path}});
mirror.translate(&mut payload);
assert_eq!(payload["stackFrames"][0]["source"]["path"], real.to_string_lossy().as_ref());
assert_eq!(payload["source"]["path"], real.to_string_lossy().as_ref());
let root = mirror.root().to_owned();
drop(mirror);
assert!(!root.exists());
assert!(real.exists());
}
#[test]
fn copies_refresh_after_save() {
let project = tempfile::tempdir().unwrap();
let real = project.path().join("Main.java");
fs::write(&real, "package example;\nclass Main {}\n").unwrap();
let mut mirror = SourceMirror::create(project.path()).unwrap();
let path = mirror.adapter_path(&real).unwrap();
fs::remove_file(&path).unwrap();
fs::copy(&real, &path).unwrap();
mirror.copies.push((real.clone(), path.clone()));
fs::write(&real, "package example;\nclass Main { int i; }\n").unwrap();
mirror.refresh().unwrap();
assert_eq!(fs::read(&path).unwrap(), fs::read(&real).unwrap());
}
}
+166
View File
@@ -0,0 +1,166 @@
//! Keep a suspended JVM stopped until Kotlin's adapter has installed its listeners.
//! The official 0.4.4 adapter resumes VMStart while constructing its debuggee, before
//! installing breakpoint hooks. DAP attach completes only after those hooks exist.
use ide_core::IdeResult;
use std::{io, time::Duration};
use tokio::{
io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt},
net::{TcpListener, TcpStream},
sync::oneshot,
task::JoinHandle,
};
pub(crate) struct StartupRelay {
release: Option<oneshot::Sender<()>>,
task: JoinHandle<()>,
}
impl StartupRelay {
pub async fn start(host: String, port: u16) -> IdeResult<(Self, u16)> {
let listener = TcpListener::bind(("127.0.0.1", 0)).await?;
let local = listener.local_addr()?.port();
let (release, ready) = oneshot::channel();
let task = tokio::spawn(async move {
let connect = async {
let (adapter, _) = listener.accept().await?;
let jvm = TcpStream::connect((host.as_str(), port)).await?;
adapter.set_nodelay(true)?;
jvm.set_nodelay(true)?;
Ok::<_, io::Error>((adapter, jvm))
};
if let Ok(Ok((adapter, jvm))) = tokio::time::timeout(Duration::from_secs(60), connect).await {
let (mut ar, mut aw) = adapter.into_split();
let (mut jr, mut jw) = jvm.into_split();
let upstream = forward(&mut ar, &mut jw, ready);
let downstream = tokio::io::copy(&mut jr, &mut aw);
// Close both sockets as soon as either side disconnects.
tokio::select! {
r = upstream => if let Err(e) = r { tracing::debug!("Kotlin JDWP relay: {e}"); },
_ = downstream => {},
}
}
});
Ok((Self { release: Some(release), task }, local))
}
pub fn release(&mut self) {
if let Some(ready) = self.release.take() {
let _ = ready.send(());
}
}
}
impl Drop for StartupRelay {
fn drop(&mut self) {
self.task.abort();
}
}
async fn packet(reader: &mut (impl AsyncRead + Unpin)) -> io::Result<Vec<u8>> {
let mut header = [0; 11];
reader.read_exact(&mut header).await?;
let len = u32::from_be_bytes(header[..4].try_into().unwrap()) as usize;
if !(11..=16 * 1024 * 1024).contains(&len) {
return Err(io::Error::new(io::ErrorKind::InvalidData, "Invalid JDWP packet length"));
}
let mut packet = vec![0; len];
packet[..11].copy_from_slice(&header);
reader.read_exact(&mut packet[11..]).await?;
Ok(packet)
}
async fn forward(
reader: &mut (impl AsyncRead + Unpin),
writer: &mut (impl AsyncWrite + Unpin),
mut ready: oneshot::Receiver<()>,
) -> io::Result<()> {
let mut handshake = [0; 14];
reader.read_exact(&mut handshake).await?;
if &handshake != b"JDWP-Handshake" {
return Err(io::Error::new(io::ErrorKind::InvalidData, "Invalid JDWP handshake"));
}
writer.write_all(&handshake).await?;
let mut released = false;
let mut held: Vec<Vec<u8>> = Vec::new();
loop {
// Retain the in-flight read when attach completes, including partial packets.
let read = packet(reader);
tokio::pin!(read);
let packet = tokio::select! {
result = &mut read => result?,
result = &mut ready, if !released => {
result.map_err(|_| io::Error::new(io::ErrorKind::ConnectionAborted, "Kotlin attach cancelled"))?;
released = true;
for packet in held.drain(..) {
writer.write_all(&packet).await?;
}
read.await?
}
};
// JDWP VirtualMachine.Resume (1/9), ThreadReference.Resume (11/3).
// Other commands must proceed: attach itself queries threads and installs requests.
if !released && packet[8] == 0 && matches!((packet[9], packet[10]), (1, 9) | (11, 3)) {
if held.len() >= 1024 {
return Err(io::Error::new(io::ErrorKind::InvalidData, "Too many pending JDWP resumes"));
}
held.push(packet);
} else {
writer.write_all(&packet).await?;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn command(id: u8, set: u8, cmd: u8) -> Vec<u8> {
vec![0, 0, 0, 11, 0, 0, 0, id, 0, set, cmd]
}
#[tokio::test]
async fn holds_resumes_but_allows_setup_and_preserves_partial_reads() {
let (mut adapter, mut reader) = tokio::io::duplex(4096);
let (mut writer, mut jvm) = tokio::io::duplex(4096);
let (release, ready) = oneshot::channel();
let task = tokio::spawn(async move { forward(&mut reader, &mut writer, ready).await });
adapter.write_all(b"JDWP-Handshake").await.unwrap();
let mut handshake = [0; 14];
jvm.read_exact(&mut handshake).await.unwrap();
assert_eq!(&handshake, b"JDWP-Handshake");
let resume = command(1, 1, 9);
let thread_resume = command(2, 11, 3);
let setup = command(3, 1, 4);
adapter.write_all(&resume).await.unwrap();
adapter.write_all(&thread_resume).await.unwrap();
adapter.write_all(&setup).await.unwrap();
assert_eq!(packet(&mut jvm).await.unwrap(), setup);
let next = command(4, 1, 9);
adapter.write_all(&next[..6]).await.unwrap();
release.send(()).unwrap();
assert_eq!(packet(&mut jvm).await.unwrap(), resume);
assert_eq!(packet(&mut jvm).await.unwrap(), thread_resume);
adapter.write_all(&next[6..]).await.unwrap();
assert_eq!(packet(&mut jvm).await.unwrap(), next);
drop(adapter);
assert!(task.await.unwrap().is_err());
}
#[tokio::test]
async fn dropping_session_closes_relay() {
let (relay, port) = StartupRelay::start("127.0.0.1".into(), 1).await.unwrap();
let mut adapter = TcpStream::connect(("127.0.0.1", port)).await.unwrap();
drop(relay);
let result = tokio::time::timeout(Duration::from_secs(1), adapter.read_u8()).await.unwrap();
assert!(result.is_err());
}
#[tokio::test]
async fn rejects_invalid_packet_lengths() {
for len in [0_u32, 10, 16 * 1024 * 1024 + 1] {
let mut bytes = command(1, 1, 9);
bytes[..4].copy_from_slice(&len.to_be_bytes());
assert_eq!(packet(&mut bytes.as_slice()).await.unwrap_err().kind(), io::ErrorKind::InvalidData);
}
}
}
+83 -3
View File
@@ -13,6 +13,8 @@ pub mod attach;
mod cargo;
pub mod connection;
pub mod jdwp;
pub mod kotlin;
mod kotlin_jdwp;
mod session;
use adapter::{AdapterLauncher, AdapterSpec, ProcessLauncher};
@@ -563,6 +565,14 @@ impl DebugManager {
let dbg = adapter::find_config(&self.languages, &config.adapter)
.ok_or_else(|| IdeError::unavailable(format!("unknown debug adapter `{}`", config.adapter)))?;
let mut spec = adapter::resolve(&dbg, cwd.clone());
if config.adapter == kotlin::ADAPTER
&& let Some(home) = arguments
.as_object_mut()
.and_then(|o| o.remove("__adapterJavaHome"))
.and_then(|v| v.as_str().map(str::to_owned))
{
spec.env.push(("JAVA_HOME".into(), home));
}
if let Some(command) = custom_command(&spec.command) {
spec.command = command;
}
@@ -687,6 +697,14 @@ impl DebugManager {
};
let session =
Arc::new(Session::new(id, owner, name, adapter_name, parent, request, conn, spec, io.port, io.child));
if let Some(mut output) = io.program_output {
let session = session.clone();
tokio::spawn(async move {
while let Some((category, output)) = output.recv().await {
session.emit(ide_core::event::DebugEvent::Output { category: category.into(), output });
}
});
}
self.sessions.lock().insert(id, session.clone());
self.order.lock().push(id);
// Announced before the event loop runs, so it precedes every adapter event.
@@ -722,7 +740,19 @@ impl DebugManager {
}
}
async fn configure(&self, s: &Arc<Session>, arguments: Value) -> IdeResult<()> {
async fn configure(&self, s: &Arc<Session>, mut arguments: Value) -> IdeResult<()> {
if s.adapter == kotlin::ADAPTER {
let project = arguments
.get("projectRoot")
.and_then(Value::as_str)
.map(PathBuf::from)
.unwrap_or_else(|| s.spec.cwd.clone());
let mirror = tokio::task::spawn_blocking(move || kotlin::SourceMirror::create(&project))
.await
.map_err(|e| IdeError::internal(e.to_string()))??;
arguments["projectRoot"] = json!(mirror.root());
*s.kotlin_mirror.lock() = Some(mirror);
}
let caps = s
.conn
.request_timeout(
@@ -749,8 +779,32 @@ impl DebugManager {
.await?;
*s.caps.lock() = if caps.is_object() { caps } else { json!({}) };
// The Kotlin adapter ignores the resolved classpath and project runtime on launch.
let request = if s.adapter == kotlin::ADAPTER && s.request == "launch" {
session::start_kotlin_debuggee(s, &mut arguments).await?;
"attach"
} else {
&s.request
};
if s.adapter == kotlin::ADAPTER {
let host = arguments
.get("hostName")
.and_then(Value::as_str)
.ok_or_else(|| IdeError::invalid("Missing Kotlin JDWP host"))?
.to_owned();
let port = arguments
.get("port")
.and_then(Value::as_u64)
.and_then(|p| u16::try_from(p).ok())
.filter(|p| *p != 0)
.ok_or_else(|| IdeError::invalid("Invalid Kotlin JDWP port"))?;
let (relay, port) = kotlin_jdwp::StartupRelay::start(host, port).await?;
*s.kotlin_relay.lock() = Some(relay);
arguments["hostName"] = json!("127.0.0.1");
arguments["port"] = json!(port);
}
// Many adapters answer `launch` only after `configurationDone`, so don't wait for it yet.
let launch = s.conn.send_request(&s.request, arguments)?;
let launch = s.conn.send_request(request, arguments)?;
let mut launch = Box::pin(async move { launch.await.unwrap_or(Err(DapError::Closed)) });
let mut initialized = s.initialized.subscribe();
let mut launch_result = None;
@@ -780,6 +834,7 @@ impl DebugManager {
s.emit(ide_core::event::DebugEvent::BreakpointsChanged { path: path.into() });
}
}
Err(e) if s.adapter == kotlin::ADAPTER => return Err(e.into()),
Err(e) => tracing::debug!("setBreakpoints for {} failed: {e}", path.display()),
}
}
@@ -802,7 +857,11 @@ impl DebugManager {
let _ = self.send_exception_breakpoints(s).await;
}
if s.supports("supportsConfigurationDoneRequest") {
s.conn.request_timeout("configurationDone", Value::Null, REQUEST_TIMEOUT).await?;
if s.adapter == kotlin::ADAPTER {
drop(s.conn.send_request("configurationDone", json!({}))?);
} else {
s.conn.request_timeout("configurationDone", Value::Null, REQUEST_TIMEOUT).await?;
}
}
s.configured.store(true, Ordering::Release);
if launch_result.is_none() {
@@ -813,6 +872,9 @@ impl DebugManager {
Err(_) => return Err(DapError::Timeout(s.request.clone()).into()),
}
}
if let Some(relay) = s.kotlin_relay.lock().as_mut() {
relay.release();
}
Ok(())
}
@@ -869,6 +931,7 @@ impl DebugManager {
Ok(states) => {
result.get_or_insert(states);
}
Err(e) if s.adapter == kotlin::ADAPTER => return Err(e.into()),
Err(e) => tracing::debug!("setBreakpoints on session {} failed: {e}", s.id.0),
}
}
@@ -886,6 +949,23 @@ impl DebugManager {
Ok(states)
}
/// Refresh fallback copies after an editor save.
pub async fn refresh_source_mirrors(&self, owner: &str) -> IdeResult<()> {
let sessions: Vec<_> = self.sessions.lock().values().filter(|s| s.owner.key == owner).cloned().collect();
tokio::task::spawn_blocking(move || {
for s in sessions {
if let Some(mirror) = s.kotlin_mirror.lock().as_mut()
&& let Err(e) = mirror.refresh()
{
tracing::warn!("Could not refresh Kotlin debug sources: {e}");
}
}
Ok(())
})
.await
.map_err(|e| IdeError::internal(e.to_string()))?
}
/// Last known verification states of a file's breakpoints (after `BreakpointsChanged`).
pub fn breakpoint_states(&self, path: &Path) -> Vec<BreakpointState> {
self.states.lock().get(path).cloned().unwrap_or_default()
+109 -2
View File
@@ -54,6 +54,8 @@ pub(crate) struct Session {
/// Resolved path → the path as the IDE knows it, for adapters given resolved paths.
resolved_paths: Mutex<HashMap<PathBuf, PathBuf>>,
progress: Mutex<HashMap<String, TaskId>>,
pub kotlin_relay: Mutex<Option<crate::kotlin_jdwp::StartupRelay>>,
pub kotlin_mirror: Mutex<Option<crate::kotlin::SourceMirror>>,
}
/// Adapters that only match files by their resolved path: Godot maps a script to
@@ -95,6 +97,8 @@ impl Session {
breakpoint_ids: Mutex::new(HashMap::new()),
resolved_paths: Mutex::new(HashMap::new()),
progress: Mutex::new(HashMap::new()),
kotlin_relay: Mutex::new(None),
kotlin_mirror: Mutex::new(None),
}
}
@@ -104,6 +108,14 @@ impl Session {
/// `path` as sent to the adapter (resolved for [`RESOLVES_PATHS`] adapters).
pub fn adapter_path(&self, path: &Path) -> PathBuf {
if let Some(path) = self.kotlin_mirror.lock().as_ref().and_then(|m| m.adapter_path(path)) {
return path;
}
if self.adapter == "java-debug" {
let native = java_source_path(&path.to_string_lossy());
self.resolved_paths.lock().insert(native.clone(), path.to_path_buf());
return native;
}
if !RESOLVES_PATHS.contains(&self.adapter.as_str()) {
return path.to_path_buf();
}
@@ -118,7 +130,14 @@ impl Session {
/// A path from the adapter as the IDE knows it (undoes [`Session::adapter_path`]).
pub fn client_path(&self, path: &str) -> Option<PathBuf> {
self.resolved_paths.lock().get(Path::new(path)).cloned()
if let Some(real) = self.kotlin_mirror.lock().as_ref().and_then(|m| m.client_path(Path::new(path))) {
return Some(real);
}
let key = if self.adapter == "java-debug" { java_source_path(path) } else { PathBuf::from(path) };
self.resolved_paths.lock().get(&key).cloned().or_else(|| {
(self.adapter == "java-debug" && is_windows_source_path(path))
.then(|| PathBuf::from(path.replace('\\', "/")))
})
}
pub fn supports(&self, cap: &str) -> bool {
@@ -236,6 +255,7 @@ impl Session {
/// Kills the adapter and every debuggee we started.
pub fn kill_processes(&self) {
self.kotlin_relay.lock().take();
if let Some(mut c) = self.adapter_process.lock().take() {
let _ = c.start_kill();
}
@@ -245,6 +265,19 @@ impl Session {
}
}
// java-debug treats a forward-slash drive path as a URI (scheme "C").
// Keep DAP path mode, but use native Windows separators for Java source lookup.
fn is_windows_source_path(path: &str) -> bool {
let bytes = path.as_bytes();
(bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && matches!(bytes[2], b'/' | b'\\'))
|| path.starts_with("//")
|| path.starts_with(r"\\")
}
fn java_source_path(path: &str) -> PathBuf {
if is_windows_source_path(path) { PathBuf::from(path.replace('/', r"\")) } else { PathBuf::from(path) }
}
fn with_conditions(v: &mut Value, condition: Option<&str>, hit_condition: Option<&str>) {
if let Some(c) = condition.filter(|c| !c.trim().is_empty()) {
v["condition"] = c.into();
@@ -297,7 +330,10 @@ pub(crate) async fn event_loop(
}
}
async fn on_event(mgr: &Arc<DebugManager>, s: &Arc<Session>, event: &str, body: Value) {
async fn on_event(mgr: &Arc<DebugManager>, s: &Arc<Session>, event: &str, mut body: Value) {
if let Some(mirror) = s.kotlin_mirror.lock().as_ref() {
mirror.translate(&mut body);
}
let int = |k: &str| body.get(k).and_then(Value::as_i64);
let str_ = |k: &str| body.get(k).and_then(Value::as_str).map(str::to_owned);
match event {
@@ -486,3 +522,74 @@ 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)
.and_then(Value::as_array)
.map(|a| a.iter().filter_map(Value::as_str).map(str::to_owned).collect())
.unwrap_or_default()
};
let classpath =
std::env::join_paths(strings("classPaths")).map_err(|e| ide_core::IdeError::invalid(e.to_string()))?;
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}"))
.arg("-cp")
.arg(classpath);
let modules = strings("modulePaths");
if !modules.is_empty() {
cmd.arg("--module-path")
.arg(std::env::join_paths(modules).map_err(|e| ide_core::IdeError::invalid(e.to_string()))?);
}
cmd.arg(
args.get("mainClass")
.and_then(Value::as_str)
.ok_or_else(|| ide_core::IdeError::invalid("Missing Kotlin main class"))?,
)
.args(strings("programArgs"))
.current_dir(&s.spec.cwd)
.envs(ide_core::process::shell_env())
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
if let Some(env) = args.get("env").and_then(Value::as_object) {
for (k, v) in env {
if let Some(v) = v.as_str() {
cmd.env(k, v);
}
}
}
let mut child = cmd.spawn()?;
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 {
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(());
}
s.emit(DebugEvent::Output { category: "stdout".into(), output: line });
}
})
.await
.map_err(|_| ide_core::IdeError::unavailable("JVM did not start its debug listener"))??;
forward_output(s, stdout, "stdout");
s.debuggees.lock().push(child);
args["hostName"] = json!("127.0.0.1");
args["port"] = json!(port);
args["timeout"] = json!(10000);
Ok(())
}
+136 -4
View File
@@ -15,6 +15,8 @@ struct Script {
run_in_terminal: Option<Vec<String>>,
/// Send a `startDebugging` reverse request (child session) when a non-child `launch` arrives.
start_debugging: bool,
ignore_configuration_done: bool,
source_path: Option<String>,
}
struct FakeLauncher {
@@ -30,7 +32,14 @@ impl AdapterLauncher for FakeLauncher {
tokio::spawn(fake_adapter(server, log, script));
let (r, w) = tokio::io::split(client);
Box::pin(async move {
Ok(AdapterIo { reader: Box::new(r), writer: Box::new(w), child: None, port: None, stderr: Arc::default() })
Ok(AdapterIo {
reader: Box::new(r),
writer: Box::new(w),
child: None,
port: None,
stderr: Arc::default(),
program_output: None,
})
})
}
}
@@ -130,7 +139,9 @@ 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" => {
ok!(msg, Value::Null);
if !script.ignore_configuration_done {
ok!(msg, Value::Null);
}
if let Some(l) = pending_launch.take() {
send!(json!({"type": "response", "request_seq": l, "command": "launch", "success": true}));
}
@@ -139,14 +150,14 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
json!({"type": "event", "event": "stopped", "body": {"reason": "breakpoint", "threadId": 1, "allThreadsStopped": true}})
);
send!(
json!({"type": "event", "event": "breakpoint", "body": {"reason": "changed", "breakpoint": {"id": 1, "verified": false, "message": "moved", "line": 9}}})
json!({"type": "event", "event": "breakpoint", "body": {"reason": "changed", "breakpoint": {"id": 1, "verified": false, "message": "moved", "line": 9, "source": {"path": script.source_path}}}})
);
}
"threads" => ok!(msg, json!({"threads": [{"id": 1, "name": "MainThread"}]})),
"stackTrace" => ok!(
msg,
json!({"stackFrames": [
{"id": 10, "name": "main", "line": 5, "column": 3, "source": {"path": "/p/a.py"}},
{"id": 10, "name": "main", "line": 5, "column": 3, "source": {"path": script.source_path.as_deref().unwrap_or("/p/a.py")}},
{"id": 11, "name": "lib", "line": 1, "column": 1, "source": {"path": "/usr/lib/x.py", "presentationHint": "deemphasize"}},
]})
),
@@ -893,6 +904,8 @@ fn godot_registry(port: u16, command: &str, args: &[&str]) -> Arc<LanguageRegist
block_comment: None,
indent: None,
ui_mode: None,
framework_ui_modes: Vec::new(),
formatter: None,
tree_sitter: None,
language_servers: vec![],
debugger: Some(ide_language::DebuggerConfig {
@@ -1015,3 +1028,122 @@ fn root_and_address_placeholders() {
assert_eq!(adapter::parse_address("[::1]:6006"), Some(("::1".into(), 6006)));
assert_eq!(adapter::parse_address("6006"), None);
}
#[tokio::test]
async fn kotlin_configures_without_configuration_done_response() {
let h = harness(Script { ignore_configuration_done: true, ..Default::default() });
let project = tempfile::tempdir().unwrap();
let path = project.path().join("Main.kt");
std::fs::write(&path, "package org.example\nfun main() {}\n").unwrap();
h.manager.set_breakpoints(path.clone(), vec![bp(1)]).await.unwrap();
let mut config = config();
config.adapter = kotlin::ADAPTER.into();
config.request = "attach".into();
config.cwd = Some(project.path().to_owned().into());
config.extra_json = Some(json!({"projectRoot": project.path(), "hostName": "127.0.0.1", "port": 5005}).to_string());
let id = tokio::time::timeout(Duration::from_secs(2), h.start(config)).await.unwrap().unwrap();
let root = PathBuf::from(h.request("attach")["arguments"]["projectRoot"].as_str().unwrap());
assert!(!root.starts_with(project.path()));
let request = h.request("setBreakpoints");
assert_eq!(request["arguments"]["source"]["name"], "Main.kt");
let mapped = PathBuf::from(request["arguments"]["source"]["path"].as_str().unwrap());
assert!(mapped.starts_with(&root));
assert!(mapped.is_file());
assert_eq!(h.manager.session(id).unwrap().client_path(mapped.to_str().unwrap()), Some(path));
h.manager.stop(id).await.unwrap();
}
/// Requires a compiled no-delay reporter fixture (Main.kt, package org.example,
/// loop at line 5, println(i) at line 6), and the unmodified official adapter.
/// KOTLIN_PROBE_CONFIG is a LaunchConfig JSON file; KOTLIN_PROBE_ADAPTER is its executable.
#[tokio::test]
#[ignore = "requires official Kotlin adapter and compiled reporter fixture"]
async fn kotlin_official_release_ten_no_delay_runs() {
let config: LaunchConfig = serde_json::from_slice(
&std::fs::read(std::env::var("KOTLIN_PROBE_CONFIG").expect("KOTLIN_PROBE_CONFIG")).unwrap(),
)
.unwrap();
let adapter = std::env::var("KOTLIN_PROBE_ADAPTER").expect("KOTLIN_PROBE_ADAPTER");
let source = PathBuf::from(config.cwd.clone().unwrap()).join("src/main/kotlin/Main.kt");
for run in 1..=10 {
let (owner, events) = recording_owner("kotlin-probe");
let manager = DebugManager::new(Arc::new(LanguageRegistry::from_configs(Vec::new())));
let h = Harness { manager, owner, events, log: Arc::default() };
h.manager.set_breakpoints(source.clone(), vec![bp(5)]).await.unwrap();
let id = h.manager.start_with(config.clone(), h.owner.clone(), |_| Some(adapter.clone())).await.unwrap();
let stopped = h.wait_event(|e| matches!(e, DebugEvent::Stopped { reason, .. } if reason == "breakpoint")).await;
let DebugEvent::Stopped { thread_id, .. } = stopped else { unreachable!() };
let frames = h.manager.stack_trace(id, thread_id).await.unwrap();
assert_eq!(frames[0].line, 5);
assert_eq!(frames[0].path.as_deref(), Some(source.as_path()));
let scopes = h.manager.scopes(id, frames[0].id).await.unwrap();
let mut variables = Vec::new();
for scope in scopes {
variables.extend(h.manager.variables(id, scope.variables_reference).await.unwrap());
}
assert!(variables.iter().any(|v| v.name == "i" && v.value == "0"));
h.manager.step(id, thread_id, StepKind::Over).await.unwrap();
h.wait_event(|e| matches!(e, DebugEvent::Stopped { reason, .. } if reason == "step")).await;
let frames = h.manager.stack_trace(id, thread_id).await.unwrap();
assert_eq!(frames[0].line, 4);
h.manager.stop(id).await.unwrap();
println!("Run {run}/10: breakpoint Main.kt:6, i=0, step Main.kt:5; stopped");
}
}
#[tokio::test]
async fn java_windows_source_paths_for_launch_and_attach() {
for request in ["launch", "attach"] {
for (client, native, returned) in [
("C:/work/Main.java", r"C:\work\Main.java", r"C:\work\Main.java"),
("C:/work/Main.java", r"C:\work\Main.java", "C:/work/Main.java"),
(r"c:\work\Main.java", r"c:\work\Main.java", "c:/work/Main.java"),
(r"D:/my project\Main.java", r"D:\my project\Main.java", r"D:\my project\Main.java"),
("//server/share/Main.java", r"\\server\share\Main.java", "//server/share/Main.java"),
(r"\\server\share/Main.java", r"\\server\share\Main.java", "//server/share/Main.java"),
("/work/Main.java", "/work/Main.java", "/work/Main.java"),
] {
let h = harness(Script { source_path: Some(returned.into()), ..Script::default() });
let path = PathBuf::from(client);
h.manager.set_breakpoints(path.clone(), vec![bp(4)]).await.unwrap();
let mut cfg = config();
cfg.adapter = "java-debug".into();
cfg.request = request.into();
let id = h.start(cfg).await.unwrap();
assert_eq!(h.request("initialize")["arguments"]["pathFormat"], "path");
assert_eq!(h.request("setBreakpoints")["arguments"]["source"]["path"], native);
let commands = h.commands();
assert!(
commands.iter().position(|c| c == "setBreakpoints").unwrap()
< commands.iter().position(|c| c == "configurationDone").unwrap()
);
let frames = h.manager.stack_trace(id, 1).await.unwrap();
assert_eq!(frames[0].path.as_deref(), Some(path.as_path()));
h.wait_event(|e| {
matches!(e, DebugEvent::BreakpointsChanged { path: p } if p.as_ref() == path.as_path())
&& h.manager.breakpoint_states(&path)[0].message.as_deref() == Some("moved")
})
.await;
assert_eq!(h.manager.breakpoint_states(&path)[0].message.as_deref(), Some("moved"));
h.manager.stop(id).await.unwrap();
}
}
}
#[tokio::test]
async fn java_stack_frames_without_breakpoints_normalize_windows_paths() {
for (returned, expected) in [
(r"C:\work\Other.java", "C:/work/Other.java"),
(r"\\server\share\Other.java", "//server/share/Other.java"),
("C:/work/Other.java", "C:/work/Other.java"),
("/work/Other.java", "/work/Other.java"),
] {
let h = harness(Script { source_path: Some(returned.into()), ..Script::default() });
let mut cfg = config();
cfg.adapter = "java-debug".into();
let id = h.start(cfg).await.unwrap();
let frames = h.manager.stack_trace(id, 1).await.unwrap();
assert_eq!(frames[0].path.as_deref(), Some(PathBuf::from(expected).as_path()));
h.manager.stop(id).await.unwrap();
}
}
+3
View File
@@ -16,6 +16,9 @@ tracing.workspace = true
parking_lot.workspace = true
futures.workspace = true
sqlx.workspace = true
tiberius.workspace = true
bb8.workspace = true
tokio-util = { workspace = true, features = ["compat"] }
dirs.workspace = true
ide-ssh.workspace = true
+409 -21
View File
@@ -5,6 +5,9 @@
//! whatever its type.
use crate::source::{DataSource, DbDriver, DbSslMode};
use bb8::ManageConnection;
use bb8::Pool as Bb8Pool;
use futures::StreamExt;
use ide_core::{CancellationToken, IdeError, IdeResult, error::ErrorKind};
use serde::{Deserialize, Serialize};
use specta::Type;
@@ -44,10 +47,65 @@ pub struct QueryResult {
pub execution_ms: f64,
}
// Keep an empty slot when a connection is discarded after cancellation or rollback failure.
pub struct MssqlConnectionManager(tiberius::Config);
type MssqlClient = tiberius::Client<tokio_util::compat::Compat<tokio::net::TcpStream>>;
pub struct MssqlConnection(Option<MssqlClient>);
impl std::ops::Deref for MssqlConnection {
type Target = MssqlClient;
fn deref(&self) -> &Self::Target {
self.0.as_ref().expect("live SQL Server connection")
}
}
impl std::ops::DerefMut for MssqlConnection {
fn deref_mut(&mut self) -> &mut Self::Target {
self.0.as_mut().expect("live SQL Server connection")
}
}
impl ManageConnection for MssqlConnectionManager {
type Connection = MssqlConnection;
type Error = tiberius::error::Error;
async fn connect(&self) -> Result<Self::Connection, Self::Error> {
use tokio_util::compat::TokioAsyncWriteCompatExt;
let tcp = tokio::net::TcpStream::connect(self.0.get_addr()).await?;
tcp.set_nodelay(true)?;
let client = match MssqlClient::connect(self.0.clone(), tcp.compat_write()).await {
// Preserve the single redirect supported by the previous pool adapter.
Err(tiberius::error::Error::Routing { host, port }) => {
let mut config = self.0.clone();
config.host(host);
config.port(port);
let tcp = tokio::net::TcpStream::connect(config.get_addr()).await?;
tcp.set_nodelay(true)?;
MssqlClient::connect(config, tcp.compat_write()).await?
}
result => result?,
};
Ok(MssqlConnection(Some(client)))
}
async fn is_valid(&self, conn: &mut Self::Connection) -> Result<(), Self::Error> {
conn.simple_query("SELECT 1").await?.into_results().await?;
Ok(())
}
fn has_broken(&self, conn: &mut Self::Connection) -> bool {
conn.0.is_none()
}
}
pub enum Pool {
Sqlite(SqlitePool),
Postgres(PgPool),
Mysql(MySqlPool),
SqlServer(Bb8Pool<MssqlConnectionManager>),
}
/// Runs `$body` with `$p` bound to the concrete pool of each driver.
@@ -57,6 +115,7 @@ macro_rules! with_pool {
Pool::Sqlite($p) => $body,
Pool::Postgres($p) => $body,
Pool::Mysql($p) => $body,
Pool::SqlServer(_) => unreachable!(),
}
};
}
@@ -79,6 +138,129 @@ pub(crate) fn db_err(e: sqlx::Error) -> IdeError {
}
}
pub(crate) fn mssql_err(e: tiberius::error::Error) -> IdeError {
match &e {
tiberius::error::Error::Server(d) => {
let code = d.code();
// Wrong login: SQL Server 18456.
let auth = matches!(code, 18452 | 18456 | 18470);
IdeError::new(
if auth { ErrorKind::PermissionDenied } else { ErrorKind::InvalidInput },
format!("[{code}] {}", d.message()),
)
}
tiberius::error::Error::Io { .. } | tiberius::error::Error::Tls(_) => {
IdeError::new(ErrorKind::Io, e.to_string())
}
_ => IdeError::new(ErrorKind::Protocol, e.to_string()),
}
}
fn mssql_pool_err(e: bb8::RunError<tiberius::error::Error>) -> IdeError {
match e {
bb8::RunError::User(e) => mssql_err(e),
bb8::RunError::TimedOut => IdeError::new(ErrorKind::Io, "timed out connecting to the database"),
}
}
const DAYS_1900_01_01: i64 = 693_595;
/// Formats a proleptic Gregorian date given days since `0001-01-01`.
fn format_date(days: i64) -> String {
let z = days + 306;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let year = if m <= 2 { y + 1 } else { y };
format!("{year:04}-{m:02}-{d:02}")
}
fn format_hms(secs: u64, frac: u64, scale: u8) -> String {
let (h, m, s) = (secs / 3600, (secs / 60) % 60, secs % 60);
if scale == 0 || frac == 0 {
format!("{h:02}:{m:02}:{s:02}")
} else {
let frac = format!("{frac:0width$}", width = usize::from(scale));
format!("{h:02}:{m:02}:{s:02}.{}", frac.trim_end_matches('0'))
}
}
fn format_time(t: tiberius::time::Time) -> String {
let scale = t.scale().min(7);
let div = 10u64.pow(u32::from(scale));
format_hms(t.increments() / div, t.increments() % div, scale)
}
fn format_numeric(n: tiberius::numeric::Numeric) -> String {
let scale = u32::from(n.scale());
if scale == 0 {
return n.value().to_string();
}
let sign = if n.value() < 0 { "-" } else { "" };
let abs = n.value().unsigned_abs();
let div = 10u128.pow(scale);
format!("{sign}{}.{:0width$}", abs / div, abs % div, width = scale as usize)
}
fn tiberius_cell(cell: &tiberius::ColumnData<'_>, ty: tiberius::ColumnType) -> Option<String> {
use tiberius::ColumnData as C;
match cell {
C::U8(v) => v.map(|v| v.to_string()),
C::I16(v) => v.map(|v| v.to_string()),
C::I32(v) => v.map(|v| v.to_string()),
C::I64(v) => v.map(|v| v.to_string()),
C::F32(v) => v.map(|v| v.to_string()),
C::F64(v) if matches!(ty, tiberius::ColumnType::Money | tiberius::ColumnType::Money4) => {
// Beyond ~9e11, money precision is limited by the driver's f64 decoding.
v.map(|v| format!("{:.4}", v))
}
C::F64(v) => v.map(|v| v.to_string()),
C::Bit(v) => v.map(|v| v.to_string()),
C::String(v) => v.as_deref().map(str::to_owned),
C::Guid(v) => v.map(|v| v.to_string()),
C::Binary(v) => v.as_deref().map(hex),
C::Numeric(v) => v.map(format_numeric),
C::Xml(v) => v.as_deref().map(|x| x.as_ref().to_owned()),
C::Date(v) => v.map(|d| format_date(i64::from(d.days()))),
C::Time(v) => v.map(format_time),
C::DateTime(v) => v.map(|dt| {
let ms = (u64::from(dt.seconds_fragments()) * 1000 + 150) / 300;
format!("{} {}", format_date(DAYS_1900_01_01 + i64::from(dt.days())), format_hms(ms / 1000, ms % 1000, 3))
}),
C::SmallDateTime(v) => v.map(|dt| {
let secs = u64::from(dt.seconds_fragments()) * 60;
format!("{} {}", format_date(DAYS_1900_01_01 + i64::from(dt.days())), format_hms(secs, 0, 0))
}),
C::DateTime2(v) => {
v.map(|dt| format!("{} {}", format_date(i64::from(dt.date().days())), format_time(dt.time())))
}
C::DateTimeOffset(v) => v.map(|dto| {
let dt = dto.datetime2();
let t = dt.time();
let scale = t.scale().min(7);
let div = 10u64.pow(u32::from(scale));
let utc_secs = i64::from(dt.date().days()) * 86_400 + (t.increments() / div) as i64;
let local_secs = utc_secs + i64::from(dto.offset()) * 60;
let days = local_secs.div_euclid(86_400);
let secs = local_secs.rem_euclid(86_400) as u64;
let sign = if dto.offset() < 0 { '-' } else { '+' };
let off = dto.offset().unsigned_abs();
format!(
"{} {}{sign}{:02}:{:02}",
format_date(days),
format_hms(secs, t.increments() % div, scale),
off / 60,
off % 60
)
}),
}
}
fn hex(bytes: &[u8]) -> String {
let mut s = String::with_capacity(2 + bytes.len() * 2);
s.push_str("0x");
@@ -197,6 +379,37 @@ macro_rules! fetch {
/// Columns, rows, rows affected, more rows beyond the limit, time of the first answer.
type Fetched = (Vec<ResultColumn>, Vec<Vec<Option<String>>>, Option<u64>, bool, Option<Instant>);
async fn mssql_command(conn: &mut MssqlClient, sql: &str) -> IdeResult<()> {
// Transaction control must be a batch: sp_executesql rejects a changed transaction count.
conn.simple_query(sql).await.map_err(mssql_err)?.into_results().await.map_err(mssql_err)?;
Ok(())
}
async fn mssql_fetch(conn: &mut MssqlClient, sql: &str, max: usize) -> IdeResult<Fetched> {
let mut stream = conn.simple_query(sql).await.map_err(mssql_err)?;
let first = Some(Instant::now());
let columns = stream
.columns()
.await
.map_err(mssql_err)?
.unwrap_or_default()
.iter()
.map(|col| ResultColumn { name: col.name().to_owned(), type_name: format!("{:?}", col.column_type()) })
.collect();
let mut rows = Vec::new();
let mut has_more = false;
while let Some(item) = stream.next().await {
if let tiberius::QueryItem::Row(row) = item.map_err(mssql_err)? {
if rows.len() >= max {
has_more = true;
break;
}
rows.push(row.cells().map(|(col, cell)| tiberius_cell(cell, col.column_type())).collect());
}
}
Ok((columns, rows, None, has_more, first))
}
/// The server-side id of a connection (to cancel its statement from another one).
macro_rules! backend_id {
($conn:expr, $sql:expr) => {{
@@ -313,6 +526,7 @@ impl Pool {
Pool::Sqlite(_) => DbDriver::Sqlite,
Pool::Postgres(_) => DbDriver::Postgres,
Pool::Mysql(_) => DbDriver::Mysql,
Pool::SqlServer(_) => DbDriver::SqlServer,
}
}
@@ -453,20 +667,60 @@ impl Pool {
.map_err(db_err)?;
Ok(Pool::Mysql(pool))
}
DbDriver::SqlServer => {
use tiberius::Config;
let mut config = Config::new();
config.host(&source.host);
config.port(source.port.unwrap_or(1433));
if !source.user.is_empty() {
config.authentication(tiberius::AuthMethod::sql_server(
source.user.clone(),
password.unwrap_or_default().to_owned(),
));
}
if !source.database.is_empty() {
config.database(&source.database);
}
if matches!(source.ssl, DbSslMode::Disable | DbSslMode::Prefer) {
config.trust_cert();
}
let manager = MssqlConnectionManager(config);
let pool = bb8::Pool::builder()
.max_size(MAX)
.connection_timeout(timeout)
.build(manager)
.await
.map_err(mssql_err)?;
let mut conn = pool.get_owned().await.map_err(mssql_pool_err)?;
conn.query("SELECT 1", &[]).await.map_err(mssql_err)?.into_results().await.map_err(mssql_err)?;
Ok(Pool::SqlServer(pool))
}
}
}
pub async fn close(&self) {
with_pool!(self, p => p.close().await)
match self {
Pool::SqlServer(_) => {}
_ => with_pool!(self, p => p.close().await),
}
}
pub fn is_closed(&self) -> bool {
with_pool!(self, p => p.is_closed())
match self {
Pool::SqlServer(_) => false,
_ => with_pool!(self, p => p.is_closed()),
}
}
/// Runs `sql` (no row limit beyond `max`), for introspection: cells are text (see [`Cells::Text`]).
pub async fn rows(&self, sql: &str, max: usize) -> IdeResult<Vec<Vec<Option<String>>>> {
let fetched: IdeResult<Fetched> = with_pool!(self, p => fetch!(p, sql, max, Cells::Text));
let fetched: IdeResult<Fetched> = match self {
Pool::SqlServer(p) => {
let mut conn = p.get_owned().await.map_err(mssql_pool_err)?;
mssql_fetch(&mut conn, sql, max).await
}
_ => with_pool!(self, p => fetch!(p, sql, max, Cells::Text)),
};
Ok(fetched?.1)
}
@@ -485,21 +739,47 @@ impl Pool {
/// Runs `statements` in one transaction; each must change `expect_one`
/// row when flagged (table editor submits).
pub async fn transaction(&self, statements: &[(String, bool)]) -> IdeResult<u64> {
with_pool!(self, p => {
let mut tx = p.begin().await.map_err(db_err)?;
let mut total = 0;
for (sql, expect_one) in statements {
let done = sqlx::raw_sql(sqlx::AssertSqlSafe(sql.clone())).execute(&mut *tx).await.map_err(db_err)?;
let n = done.rows_affected();
// MySQL reports 0 for an UPDATE that leaves the row unchanged.
if *expect_one && (n > 1 || (n == 0 && !matches!(self, Pool::Mysql(_)))) {
return Err(IdeError::invalid(format!("{sql}: expected to change 1 row, changed {n}; nothing was saved")));
match self {
Pool::SqlServer(p) => {
let mut conn = p.get_owned().await.map_err(mssql_pool_err)?;
let result = async {
mssql_command(&mut conn, "BEGIN TRAN").await?;
let mut total = 0;
for (sql, expect_one) in statements {
let n = conn.execute(sql, &[]).await.map_err(mssql_err)?.total();
if *expect_one && n != 1 {
return Err(IdeError::invalid(format!(
"{sql}: expected to change 1 row, changed {n}; nothing was saved"
)));
}
total += n;
}
mssql_command(&mut conn, "COMMIT TRAN").await?;
Ok(total)
}
total += n;
.await;
if result.is_err() && mssql_command(&mut conn, "IF @@TRANCOUNT > 0 ROLLBACK TRAN").await.is_err() {
// A failed rollback must never return an open transaction to the pool.
conn.0.take();
}
result
}
tx.commit().await.map_err(db_err)?;
Ok(total)
})
_ => with_pool!(self, p => {
let mut tx = p.begin().await.map_err(db_err)?;
let mut total = 0;
for (sql, expect_one) in statements {
let done = sqlx::raw_sql(sqlx::AssertSqlSafe(sql.clone())).execute(&mut *tx).await.map_err(db_err)?;
let n = done.rows_affected();
// MySQL reports 0 for an UPDATE that leaves the row unchanged.
if *expect_one && (n > 1 || (n == 0 && !matches!(self, Pool::Mysql(_)))) {
return Err(IdeError::invalid(format!("{sql}: expected to change 1 row, changed {n}; nothing was saved")));
}
total += n;
}
tx.commit().await.map_err(db_err)?;
Ok(total)
}),
}
}
}
@@ -509,6 +789,7 @@ pub enum Held {
Sqlite(PoolConnection<Sqlite>),
Postgres(PoolConnection<Postgres>),
Mysql(PoolConnection<MySql>),
SqlServer(Box<bb8::PooledConnection<'static, MssqlConnectionManager>>),
}
impl Held {
@@ -517,18 +798,25 @@ impl Held {
Pool::Sqlite(p) => Held::Sqlite(p.acquire().await.map_err(db_err)?),
Pool::Postgres(p) => Held::Postgres(p.acquire().await.map_err(db_err)?),
Pool::Mysql(p) => Held::Mysql(p.acquire().await.map_err(db_err)?),
Pool::SqlServer(p) => Held::SqlServer(Box::new(p.get_owned().await.map_err(mssql_pool_err)?)),
})
}
/// Runs a statement without results (`BEGIN`, `COMMIT`, `ROLLBACK`).
pub async fn command(&mut self, sql: &str) -> IdeResult<()> {
let sql = sqlx::AssertSqlSafe(sql.to_owned());
match self {
Held::Sqlite(c) => sqlx::raw_sql(sql).execute(&mut **c).await.map(drop),
Held::Postgres(c) => sqlx::raw_sql(sql).execute(&mut **c).await.map(drop),
Held::Mysql(c) => sqlx::raw_sql(sql).execute(&mut **c).await.map(drop),
Held::SqlServer(c) => mssql_command(c, sql).await,
other => {
let sql = sqlx::AssertSqlSafe(sql.to_owned());
match other {
Held::Sqlite(c) => sqlx::raw_sql(sql).execute(&mut **c).await.map(drop),
Held::Postgres(c) => sqlx::raw_sql(sql).execute(&mut **c).await.map(drop),
Held::Mysql(c) => sqlx::raw_sql(sql).execute(&mut **c).await.map(drop),
Held::SqlServer(_) => unreachable!(),
}
.map_err(db_err)
}
}
.map_err(db_err)
}
/// Closes the connection instead of returning it to the pool (its statement was
@@ -542,6 +830,9 @@ impl Held {
Held::Mysql(c) => {
let _ = c.detach().close().await;
}
Held::SqlServer(mut c) => {
c.0.take();
}
}
}
@@ -589,6 +880,13 @@ impl Held {
run!(c, Some("SELECT pg_backend_pid()"), |b: String| format!("SELECT pg_cancel_backend({b})"))
}
Held::Mysql(c) => run!(c, Some("SELECT CONNECTION_ID()"), |b: String| format!("KILL QUERY {b}")),
Held::SqlServer(c) => {
// Tiberius has no attention/cancel API; stop reading and discard the connection.
tokio::select! {
r = mssql_fetch(c, sql, max_rows) => r?,
() = cancel.cancelled() => return Err(IdeError::cancelled()),
}
}
};
let (columns, rows, affected, has_more, first) = fetched;
let elapsed_ms = started.elapsed().as_secs_f64() * 1000.0;
@@ -608,6 +906,7 @@ impl Held {
pub fn begin_statement(driver: DbDriver) -> &'static str {
match driver {
DbDriver::Mysql => "START TRANSACTION",
DbDriver::SqlServer => "BEGIN TRAN",
DbDriver::Sqlite | DbDriver::Postgres => "BEGIN",
}
}
@@ -624,6 +923,95 @@ mod tests {
use super::*;
use crate::source::{DbOptions, DbProperty};
#[test]
fn discarded_sqlserver_connections_are_broken() {
let manager = MssqlConnectionManager(tiberius::Config::new());
assert!(manager.has_broken(&mut MssqlConnection(None)));
}
#[tokio::test]
async fn sqlserver_connect_reports_io_errors() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let mut config = tiberius::Config::new();
config.host("127.0.0.1");
config.port(listener.local_addr().unwrap().port());
drop(listener);
let result = MssqlConnectionManager(config).connect().await;
assert!(matches!(result, Err(tiberius::error::Error::Io { .. })));
}
#[test]
fn sqlserver_cells_preserve_values_and_nulls() {
use std::borrow::Cow;
use tiberius::time::{Date, DateTime, DateTime2, DateTimeOffset, SmallDateTime, Time};
use tiberius::{ColumnData as C, ColumnType as T, Uuid, numeric::Numeric};
let dt = DateTime2::new(Date::new(693_595), Time::new(1234567, 7));
let cases = [
(C::U8(Some(255)), "255"),
(C::I16(Some(-123)), "-123"),
(C::I32(Some(-456)), "-456"),
(C::I64(Some(i64::MAX)), "9223372036854775807"),
(C::F32(Some(1.25)), "1.25"),
(C::F64(Some(1.25)), "1.25"),
(C::Bit(Some(true)), "true"),
(C::String(Some(Cow::Borrowed("hello"))), "hello"),
(C::Binary(Some(Cow::Borrowed(&[0, 0xAB, 0xFF]))), "0x00ABFF"),
(
C::Guid(Some(Uuid::parse_str("ABCDEF01-2345-6789-ABCD-EF0123456789").unwrap())),
"abcdef01-2345-6789-abcd-ef0123456789",
),
(C::Numeric(Some(Numeric::new_with_scale(-123, 5))), "-0.00123"),
(C::Numeric(Some(Numeric::new_with_scale(12300, 4))), "1.2300"),
(
C::Numeric(Some(Numeric::new_with_scale(10i128.pow(38) - 1, 0))),
"99999999999999999999999999999999999999",
),
(C::Xml(Some(Cow::Owned(tiberius::xml::XmlData::new("<a/>")))), "<a/>"),
(C::Date(Some(Date::new(0))), "0001-01-01"),
(C::Date(Some(Date::new(3_652_058))), "9999-12-31"),
(C::Time(Some(Time::new(452961234567, 7))), "12:34:56.1234567"),
(C::DateTime(Some(DateTime::new(-1, 1))), "1899-12-31 00:00:00.003"),
(C::DateTime(Some(DateTime::new(0, 2))), "1900-01-01 00:00:00.007"),
(C::SmallDateTime(Some(SmallDateTime::new(0, 754))), "1900-01-01 12:34:00"),
(C::DateTime2(Some(dt)), "1900-01-01 00:00:00.1234567"),
(C::DateTimeOffset(Some(DateTimeOffset::new(dt, -60))), "1899-12-31 23:00:00.1234567-01:00"),
(C::DateTimeOffset(Some(DateTimeOffset::new(dt, 330))), "1900-01-01 05:30:00.1234567+05:30"),
];
for (cell, expected) in cases {
assert_eq!(tiberius_cell(&cell, T::Null).as_deref(), Some(expected), "{cell:?}");
}
for cell in [
C::U8(None),
C::I16(None),
C::I32(None),
C::I64(None),
C::F32(None),
C::F64(None),
C::Bit(None),
C::String(None),
C::Guid(None),
C::Binary(None),
C::Numeric(None),
C::Xml(None),
C::Date(None),
C::Time(None),
C::DateTime(None),
C::SmallDateTime(None),
C::DateTime2(None),
C::DateTimeOffset(None),
] {
assert_eq!(tiberius_cell(&cell, T::Null), None);
}
for ty in [T::Money, T::Money4] {
for (v, expected) in
[(0.0, "0.0000"), (1.2, "1.2000"), (-123.4567, "-123.4567"), (214748.3647, "214748.3647")]
{
assert_eq!(tiberius_cell(&C::F64(Some(v)), ty).as_deref(), Some(expected));
}
assert_eq!(tiberius_cell(&C::F64(None), ty), None);
}
}
fn prop(name: &str, value: &str) -> DbProperty {
DbProperty { name: name.into(), value: value.into() }
}
+8
View File
@@ -549,6 +549,7 @@ impl DbManager {
DbDriver::Sqlite => "SELECT 'SQLite ' || sqlite_version()",
DbDriver::Postgres => "SELECT version()",
DbDriver::Mysql => "SELECT CONCAT(@@version_comment, ' ', VERSION())",
DbDriver::SqlServer => "SELECT @@VERSION",
};
let v = pool.rows(version, 1).await.ok().and_then(|r| r.into_iter().next()?.into_iter().next()?);
pool.close().await;
@@ -821,6 +822,13 @@ impl DbManager {
let ddl = cells(pool.rows(&q, 1).await?, 1).into_iter().next();
ddl.map(|d| format!("{d};\n")).ok_or_else(missing)
}
DbDriver::SqlServer => {
let obj = format!("{}.{}", schema, name).replace('\'', "''");
let ddl = cells(pool.rows(&format!("SELECT OBJECT_DEFINITION(OBJECT_ID('{}'))", obj), 1).await?, 0)
.into_iter()
.next();
ddl.map(|d| format!("{d};\n")).ok_or_else(missing)
}
DbDriver::Postgres => {
let rel = pool.rows(&sql::postgres_relation(schema, name), 1).await?.into_iter().next();
let (oid, kind) = match rel.as_deref() {
+3
View File
@@ -36,6 +36,7 @@ pub fn explain_statement(driver: DbDriver, statement: &str) -> String {
DbDriver::Sqlite => format!("EXPLAIN QUERY PLAN {s}"),
DbDriver::Postgres => format!("EXPLAIN (FORMAT JSON) {s}"),
DbDriver::Mysql => format!("EXPLAIN {s}"),
DbDriver::SqlServer => format!("SET SHOWPLAN_TEXT ON; {s}"),
}
}
@@ -59,6 +60,7 @@ pub fn parse(driver: DbDriver, result: &QueryResult) -> IdeResult<Vec<PlanNode>>
let names: Vec<&str> = result.columns.iter().map(|c| c.name.as_str()).collect();
Ok(mysql(&names, &result.rows))
}
DbDriver::SqlServer => Ok(Vec::new()),
}
}
@@ -197,6 +199,7 @@ mod tests {
assert_eq!(explain_statement(DbDriver::Sqlite, "select 1;"), "EXPLAIN QUERY PLAN select 1");
assert_eq!(explain_statement(DbDriver::Postgres, " select 1 "), "EXPLAIN (FORMAT JSON) select 1");
assert_eq!(explain_statement(DbDriver::Mysql, "select 1"), "EXPLAIN select 1");
assert_eq!(explain_statement(DbDriver::SqlServer, "select 1"), "SET SHOWPLAN_TEXT ON; select 1");
}
#[test]
+12
View File
@@ -16,6 +16,8 @@ pub enum DbDriver {
Postgres,
/// MySQL and MariaDB.
Mysql,
/// Microsoft SQL Server.
SqlServer,
}
impl DbDriver {
@@ -24,6 +26,7 @@ impl DbDriver {
DbDriver::Sqlite => None,
DbDriver::Postgres => Some(5432),
DbDriver::Mysql => Some(3306),
DbDriver::SqlServer => Some(1433),
}
}
}
@@ -248,6 +251,7 @@ pub fn scheme_driver(scheme: &str) -> Option<DbDriver> {
"postgres" | "postgresql" | "pgsql" | "redshift" | "cockroachdb" | "cockroach" | "yugabytedb"
| "timescaledb" | "greenplum" => Some(DbDriver::Postgres),
"mysql" | "mariadb" | "tidb" | "singlestore" | "memsql" | "oceanbase" => Some(DbDriver::Mysql),
"sqlserver" | "mssql" | "mssqlserver" => Some(DbDriver::SqlServer),
"sqlite" | "sqlite3" => Some(DbDriver::Sqlite),
_ => None,
}
@@ -472,6 +476,14 @@ mod tests {
assert_eq!(load(tmp.path()).unwrap(), vec![s]);
}
#[test]
fn sqlserver_defaults() {
assert_eq!(DbDriver::SqlServer.default_port(), Some(1433));
assert_eq!(scheme_driver("sqlserver"), Some(DbDriver::SqlServer));
assert_eq!(scheme_driver("mssql"), Some(DbDriver::SqlServer));
assert_eq!(scheme_driver("mssqlserver"), Some(DbDriver::SqlServer));
}
#[test]
fn parses_urls() {
let u = parse_url("jdbc:postgresql://ann:s%[email protected]:5433/shop?sslmode=require&search_path=app").unwrap();
+105 -3
View File
@@ -8,6 +8,7 @@ use specta::Type;
pub fn quote_ident(driver: DbDriver, name: &str) -> String {
match driver {
DbDriver::Mysql => format!("`{}`", name.replace('`', "``")),
DbDriver::SqlServer => format!("[{}]", name.replace(']', "]]")),
_ => format!("\"{}\"", name.replace('"', "\"\"")),
}
}
@@ -52,6 +53,7 @@ pub fn schemas(driver: DbDriver, system: bool) -> &'static str {
WHERE schema_name NOT LIKE 'pg\\_toast%' AND schema_name NOT LIKE 'pg\\_temp%' ORDER BY 1"
}
DbDriver::Mysql if system => "SELECT schema_name FROM information_schema.schemata ORDER BY 1",
DbDriver::SqlServer if system => "SELECT schema_name FROM information_schema.schemata ORDER BY 1",
DbDriver::Postgres => {
"SELECT schema_name FROM information_schema.schemata \
WHERE schema_name NOT IN ('pg_catalog', 'information_schema') \
@@ -61,6 +63,10 @@ pub fn schemas(driver: DbDriver, system: bool) -> &'static str {
"SELECT schema_name FROM information_schema.schemata \
WHERE schema_name NOT IN ('information_schema', 'mysql', 'performance_schema', 'sys') ORDER BY 1"
}
DbDriver::SqlServer => {
"SELECT schema_name FROM information_schema.schemata \
WHERE schema_name NOT IN ('sys', 'information_schema', 'guest') ORDER BY 1"
}
}
}
@@ -83,6 +89,11 @@ pub fn tables(driver: DbDriver, schema: &str) -> String {
FROM information_schema.tables WHERE table_schema = {} ORDER BY 1",
quote_literal(driver, schema)
),
DbDriver::SqlServer => format!(
"SELECT table_name, CASE WHEN table_type LIKE '%VIEW%' THEN 'view' ELSE 'table' END \
FROM information_schema.tables WHERE table_schema = {} ORDER BY 1",
quote_literal(driver, schema)
),
}
}
@@ -110,6 +121,16 @@ pub fn columns(driver: DbDriver, schema: &str, table: &str) -> String {
FROM information_schema.columns WHERE table_schema = {s} AND table_name = {t} \
ORDER BY ordinal_position"
),
DbDriver::SqlServer => format!(
"SELECT column_name, data_type, is_nullable, column_default, \
CASE WHEN EXISTS (SELECT 1 FROM information_schema.table_constraints tc \
JOIN information_schema.key_column_usage k ON k.constraint_name = tc.constraint_name \
AND k.table_schema = tc.table_schema AND k.table_name = tc.table_name \
WHERE tc.constraint_type = 'PRIMARY KEY' AND tc.table_schema = {s} \
AND tc.table_name = {t} AND k.column_name = c.column_name) THEN 1 ELSE 0 END \
FROM information_schema.columns c WHERE c.table_schema = {s} AND c.table_name = {t} \
ORDER BY c.ordinal_position"
),
}
}
@@ -134,6 +155,13 @@ pub fn indexes(driver: DbDriver, schema: &str, table: &str) -> String {
FROM information_schema.statistics WHERE table_schema = {s} AND table_name = {t} \
ORDER BY index_name, seq_in_index"
),
DbDriver::SqlServer => format!(
"SELECT i.name, CASE WHEN i.is_unique = 1 THEN 1 ELSE 0 END, c.name \
FROM sys.indexes i JOIN sys.index_columns ic ON ic.object_id = i.object_id AND ic.index_id = i.index_id \
JOIN sys.columns c ON c.object_id = ic.object_id AND c.column_id = ic.column_id \
WHERE OBJECT_SCHEMA_NAME(i.object_id) = {s} AND OBJECT_NAME(i.object_id) = {t} AND i.name IS NOT NULL \
ORDER BY i.name, ic.key_ordinal"
),
}
}
@@ -171,6 +199,20 @@ pub fn completion_columns(driver: DbDriver) -> &'static str {
WHERE c.table_schema NOT IN ('information_schema', 'mysql', 'performance_schema', 'sys') \
ORDER BY c.table_schema, c.table_name, c.ordinal_position LIMIT 50000"
}
DbDriver::SqlServer => {
"SELECT c.table_schema, c.table_name, CASE WHEN t.table_type LIKE '%VIEW%' THEN 'view' ELSE 'table' END, \
c.column_name, c.data_type, c.is_nullable, \
CASE WHEN EXISTS (SELECT 1 FROM information_schema.table_constraints tc \
JOIN information_schema.key_column_usage k ON k.constraint_name = tc.constraint_name \
AND k.table_schema = tc.table_schema AND k.table_name = tc.table_name \
WHERE tc.constraint_type = 'PRIMARY KEY' AND tc.table_schema = c.table_schema \
AND tc.table_name = c.table_name AND k.column_name = c.column_name) THEN 1 ELSE 0 END, \
CASE WHEN c.column_default IS NOT NULL THEN 1 ELSE 0 END \
FROM information_schema.columns c JOIN information_schema.tables t \
ON t.table_schema = c.table_schema AND t.table_name = c.table_name \
WHERE c.table_schema NOT IN ('sys', 'information_schema') \
ORDER BY c.table_schema, c.table_name, c.ordinal_position"
}
}
}
@@ -202,6 +244,13 @@ pub fn foreign_keys(driver: DbDriver) -> &'static str {
AND table_schema NOT IN ('information_schema', 'mysql', 'performance_schema', 'sys') \
ORDER BY table_schema, table_name, constraint_name, ordinal_position LIMIT 20000"
}
DbDriver::SqlServer => {
"SELECT table_schema, table_name, constraint_name, column_name, \
referenced_table_schema, referenced_table_name, referenced_column_name \
FROM information_schema.key_column_usage WHERE referenced_table_name IS NOT NULL \
AND table_schema NOT IN ('sys', 'information_schema') \
ORDER BY table_schema, table_name, constraint_name, ordinal_position"
}
}
}
@@ -218,6 +267,9 @@ pub fn table_foreign_keys(driver: DbDriver, schema: &str, table: &str) -> String
DbDriver::Mysql => format!(
"SELECT table_schema, table_name, constraint_name, column_name, referenced_table_schema, referenced_table_name, referenced_column_name FROM information_schema.key_column_usage WHERE referenced_table_name IS NOT NULL AND table_schema = {s} AND table_name = {t} ORDER BY constraint_name, ordinal_position"
),
DbDriver::SqlServer => format!(
"SELECT table_schema, table_name, constraint_name, column_name, referenced_table_schema, referenced_table_name, referenced_column_name FROM information_schema.key_column_usage WHERE referenced_table_name IS NOT NULL AND table_schema = {s} AND table_name = {t} ORDER BY constraint_name, ordinal_position"
),
}
}
@@ -227,6 +279,7 @@ pub fn current_schema(driver: DbDriver) -> Option<&'static str> {
DbDriver::Sqlite => None,
DbDriver::Postgres => Some("SELECT current_schema()"),
DbDriver::Mysql => Some("SELECT DATABASE()"),
DbDriver::SqlServer => Some("SELECT DB_NAME()"),
}
}
@@ -250,8 +303,14 @@ pub fn table_page(
) -> String {
let mut sql = format!("SELECT * FROM {}", qualified(driver, schema, table));
if let Some(f) = filter.map(str::trim).filter(|f| !f.is_empty()) {
let f = f.strip_prefix("WHERE ").or_else(|| f.strip_prefix("where ")).unwrap_or(f);
sql.push_str(&format!(" WHERE {f}"));
let mut f = f.strip_prefix("WHERE ").or_else(|| f.strip_prefix("where ")).unwrap_or(f);
if let Some(i) = f.find(';') {
f = &f[..i];
}
f = f.trim();
if !f.is_empty() {
sql.push_str(&format!(" WHERE {f}"));
}
}
if !sort.is_empty() {
let keys: Vec<String> = sort
@@ -260,7 +319,14 @@ pub fn table_page(
.collect();
sql.push_str(&format!(" ORDER BY {}", keys.join(", ")));
}
sql.push_str(&format!(" LIMIT {} OFFSET {offset}", limit + 1));
if driver == DbDriver::SqlServer {
if sort.is_empty() {
sql.push_str(" ORDER BY (SELECT 1)");
}
sql.push_str(&format!(" OFFSET {offset} ROWS FETCH NEXT {} ROWS ONLY", limit + 1));
} else {
sql.push_str(&format!(" LIMIT {} OFFSET {offset}", limit + 1));
}
sql
}
@@ -400,6 +466,10 @@ pub fn triggers(driver: DbDriver, schema: &str, table: &str) -> String {
"SELECT trigger_name, lower(action_timing), lower(event_manipulation) FROM information_schema.triggers \
WHERE event_object_schema = {s} AND event_object_table = {t} ORDER BY 1"
),
DbDriver::SqlServer => format!(
"SELECT t.name, lower(te.type_desc), '' FROM sys.triggers t \
WHERE OBJECT_SCHEMA_NAME(t.parent_id) = {s} AND OBJECT_NAME(t.parent_id) = {t} ORDER BY 1"
),
}
}
@@ -438,6 +508,10 @@ pub fn routines(driver: DbDriver, schema: &str) -> Option<String> {
AND p.specific_name = r.specific_name AND p.ordinal_position > 0), r.dtd_identifier \
FROM information_schema.routines r WHERE r.routine_schema = {s} ORDER BY 1"
)),
DbDriver::SqlServer => Some(format!(
"SELECT r.routine_name, lower(r.routine_type), '', r.data_type \
FROM information_schema.routines r WHERE r.routine_schema = {s} ORDER BY 1"
)),
}
}
@@ -559,6 +633,10 @@ mod tests {
"SELECT * FROM \"public\".\"users\" WHERE age > 3 ORDER BY \"id\" DESC LIMIT 51 OFFSET 100"
);
assert_eq!(table_page(DbDriver::Mysql, "", "t", Some(" "), &[], 0, 10), "SELECT * FROM `t` LIMIT 11 OFFSET 0");
assert_eq!(
table_page(DbDriver::SqlServer, "dbo", "users", Some("id = 1;"), &[], 0, 500),
"SELECT * FROM [dbo].[users] WHERE id = 1 ORDER BY (SELECT 1) OFFSET 0 ROWS FETCH NEXT 501 ROWS ONLY"
);
}
#[test]
@@ -586,6 +664,30 @@ mod tests {
assert!(edit_statement(DbDriver::Postgres, "s", "t", &DbRowEdit::Delete { key: vec![] }).is_err());
}
#[test]
fn sqlserver_quoting_and_schema() {
assert_eq!(quote_ident(DbDriver::SqlServer, "a]b"), "[a]]b]");
assert_eq!(qualified(DbDriver::SqlServer, "dbo", "t"), "[dbo].[t]");
assert_eq!(qualified(DbDriver::SqlServer, "", "t"), "[t]");
assert_eq!(current_schema(DbDriver::SqlServer), Some("SELECT DB_NAME()"));
assert_eq!(crate::conn::begin_statement(DbDriver::SqlServer), "BEGIN TRAN");
assert_eq!(
schemas(DbDriver::SqlServer, true),
"SELECT schema_name FROM information_schema.schemata ORDER BY 1"
);
assert!(schemas(DbDriver::SqlServer, false).contains("NOT IN ('sys', 'information_schema', 'guest')"));
}
#[test]
fn sqlserver_introspection_queries() {
assert!(tables(DbDriver::SqlServer, "dbo").contains("information_schema.tables"));
assert!(columns(DbDriver::SqlServer, "dbo", "t").contains("information_schema.columns"));
assert!(indexes(DbDriver::SqlServer, "dbo", "t").contains("sys.indexes"));
assert!(foreign_keys(DbDriver::SqlServer).contains("referenced_table_name IS NOT NULL"));
assert!(table_foreign_keys(DbDriver::SqlServer, "dbo", "t").contains("table_schema = 'dbo'"));
assert!(completion_columns(DbDriver::SqlServer).contains("information_schema.columns"));
}
#[test]
fn sqlite_trigger_events() {
let ev = sqlite_trigger_event;
+97
View File
@@ -0,0 +1,97 @@
//! Live SQL Server checks, skipped unless OPENIDEA_TEST_SQLSERVER=host:port:user:password
//! names a throwaway server.
use ide_core::CancellationToken;
use ide_db::{DataSource, DbDriver, DbSslMode, Held, Pool};
use std::time::Duration;
#[tokio::test]
async fn sqlserver_values_paging_transactions_and_cancellation() {
let Ok(server) = std::env::var("OPENIDEA_TEST_SQLSERVER") else { return };
let mut parts = server.splitn(4, ':');
let source = DataSource {
driver: DbDriver::SqlServer,
host: parts.next().unwrap().into(),
port: Some(parts.next().unwrap().parse().unwrap()),
user: parts.next().unwrap().into(),
database: "tempdb".into(),
ssl: DbSslMode::Disable,
..Default::default()
};
let pool = Pool::connect(&source, parts.next(), None).await.unwrap();
let cancel = CancellationToken::new();
let mut held = Held::acquire(&pool).await.unwrap();
let table = format!("tempdb.dbo.oi_pr276_values_{}", std::process::id());
held.command(&format!("CREATE TABLE {table} (id int PRIMARY KEY, d decimal(38, 7), dt datetime2(7), guid uniqueidentifier, m money, tiny tinyint, x xml)")).await.unwrap();
held.command(&format!("INSERT INTO {table} VALUES (1, 12345678901234567890.1234567, '2024-02-29T12:34:56.1234567', 'abcdef01-2345-6789-abcd-ef0123456789', 12.3400, 255, '<a/>'), (2, NULL, NULL, NULL, NULL, NULL, NULL)")).await.unwrap();
let r = held.execute(&pool, &format!("SELECT * FROM {table} ORDER BY id"), 1, &cancel).await.unwrap();
assert!(r.has_more);
assert_eq!(
r.rows[0],
[
"1",
"12345678901234567890.1234567",
"2024-02-29 12:34:56.1234567",
"abcdef01-2345-6789-abcd-ef0123456789",
"12.3400",
"255",
"<a/>"
]
.map(|s| Some(s.to_owned()))
);
let r = held.execute(&pool, &format!("SELECT * FROM {table} WHERE id = 2"), 5, &cancel).await.unwrap();
assert!(!r.has_more);
assert!(r.rows[0][1..].iter().all(Option::is_none));
let r = held.execute(&pool, &format!("SELECT * FROM {table} WHERE id = 3"), 5, &cancel).await.unwrap();
assert_eq!(r.columns.len(), 7);
assert!(r.rows.is_empty());
let r = held.execute(&pool,
"SELECT CAST('2024-02-29T00:15:00.1234567+05:30' AS datetimeoffset(7)), CAST('2024-02-29' AS date), CAST('12:34:56.1234567' AS time(7)), CAST('2024-02-29T12:34:56.007' AS datetime), CAST('2024-02-29T12:34:00' AS smalldatetime), CAST(-12.3400 AS smallmoney)",
1, &cancel).await.unwrap();
assert_eq!(
r.rows[0],
[
"2024-02-29 00:15:00.1234567+05:30",
"2024-02-29",
"12:34:56.1234567",
"2024-02-29 12:34:56.007",
"2024-02-29 12:34:00",
"-12.3400"
]
.map(|s| Some(s.to_owned()))
);
held.command(&format!("DROP TABLE {table}")).await.unwrap();
drop(held);
// A unique permanent table lets transaction calls acquire any pooled connection.
let table = format!("tempdb.dbo.oi_pr276_{}", std::process::id());
pool.execute(&format!("CREATE TABLE {table} (id int PRIMARY KEY, v int)"), 1, &cancel).await.unwrap();
pool.transaction(&[(format!("INSERT INTO {table} VALUES (1, 10)"), true)]).await.unwrap();
for failing in [format!("INSERT INTO {table} VALUES (1, 20)"), format!("UPDATE {table} SET v = 30 WHERE id = 999")]
{
assert!(
pool.transaction(&[(format!("UPDATE {table} SET v = 99 WHERE id = 1"), true), (failing, true)])
.await
.is_err()
);
assert_eq!(pool.rows(&format!("SELECT v FROM {table}"), 1).await.unwrap()[0][0].as_deref(), Some("10"));
assert_eq!(pool.rows("SELECT @@TRANCOUNT", 1).await.unwrap()[0][0].as_deref(), Some("0"));
}
assert_eq!(pool.transaction(&[(format!("UPDATE {table} SET v = 11 WHERE id = 1"), true)]).await.unwrap(), 1);
pool.execute(&format!("DROP TABLE {table}"), 1, &cancel).await.unwrap();
let cancel = CancellationToken::new();
let stop = cancel.clone();
let task = tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(100)).await;
stop.cancel();
});
assert!(
tokio::time::timeout(Duration::from_secs(2), pool.execute("WAITFOR DELAY '00:00:10'; SELECT 1", 1, &cancel))
.await
.unwrap()
.is_err()
);
task.await.unwrap();
assert_eq!(pool.rows("SELECT 1", 1).await.unwrap()[0][0].as_deref(), Some("1"));
}
+2 -2
View File
@@ -54,7 +54,7 @@ fn read_all(mut pipe: impl Read + Send + 'static) -> std::thread::JoinHandle<Str
std::thread::spawn(move || {
let mut buf = Vec::new();
let _ = pipe.read_to_end(&mut buf);
String::from_utf8_lossy(&buf).into_owned()
ide_core::output_encoding::decode_tool_output(&buf)
})
}
@@ -115,7 +115,7 @@ fn spawn_reader(mut pipe: impl Read + Send + 'static, tx: mpsc::Sender<Event>, s
let mut pending: Vec<u8> = Vec::new();
let flush = |pending: &mut Vec<u8>| {
if !pending.is_empty() {
let line = String::from_utf8_lossy(pending).trim_end().to_string();
let line = ide_core::output_encoding::decode_tool_output(pending).trim_end().to_string();
pending.clear();
let _ = tx.send(Event::Line(stream, line));
}
+2 -1
View File
@@ -7,10 +7,11 @@ license.workspace = true
[dependencies]
serde.workspace = true
serde_json.workspace = true
specta.workspace = true
[target.'cfg(target_os = "macos")'.dependencies]
objc2-core-foundation = { version = "0.3", default-features = false, features = ["std", "CFArray", "CFDictionary", "CFNumber", "CFString"] }
objc2-core-foundation = { version = "0.3", default-features = false, features = ["std", "CFArray", "CFDictionary", "CFNumber", "CFString", "CFURL"] }
objc2-core-text = { version = "0.3", default-features = false, features = ["std", "CTFontCollection", "CTFontDescriptor", "CTFontTraits"] }
[target.'cfg(windows)'.dependencies]
+296 -19
View File
@@ -1,16 +1,16 @@
//! The font families installed on this machine, for the font pickers in
//! Settings (editor, typography fallback, terminal, UI font).
//!
//! Each platform asks the font system the WebView itself resolves CSS
//! `font-family` names against, so every listed name renders: CoreText on
//! Each platform asks the native font system for installed families: CoreText on
//! macOS, DirectWrite on Windows, fontconfig (`fc-list`) on Linux and the BSDs.
//! The list is read once per run and cached; the first read can take a few
//! hundred milliseconds, so callers run it off the UI and command threads.
//! The list is read on first use and cached until explicitly refreshed; the
//! query can take a few hundred milliseconds, so callers run it off the UI and
//! command threads.
use serde::{Deserialize, Serialize};
use specta::Type;
use std::collections::BTreeMap;
use std::sync::OnceLock;
use std::sync::{Arc, Mutex};
/// One installed family. `monospaced` is the font system's own flag (fixed
/// pitch; on Linux also fontconfig's dual-width CJK monospace).
@@ -21,10 +21,23 @@ pub struct FontFamily {
pub monospaced: bool,
}
/// The installed families, sorted case-insensitively, read on first use.
pub fn families() -> &'static [FontFamily] {
static CACHE: OnceLock<Vec<FontFamily>> = OnceLock::new();
CACHE.get_or_init(|| normalize(imp::list()))
type Cache = Mutex<Option<Arc<[FontFamily]>>>;
/// The installed families, sorted case-insensitively, read on first use and
/// re-queried when `refresh` is true.
pub fn families(refresh: bool) -> Arc<[FontFamily]> {
static CACHE: Cache = Mutex::new(None);
cached(&CACHE, refresh, imp::list)
}
fn cached(cache: &Cache, refresh: bool, list: impl FnOnce(bool) -> Vec<(String, bool)>) -> Arc<[FontFamily]> {
let mut guard = cache.lock().unwrap_or_else(|e| e.into_inner());
if !refresh && let Some(families) = guard.as_ref() {
return Arc::clone(families);
}
let families: Arc<[FontFamily]> = normalize(list(refresh)).into();
*guard = Some(Arc::clone(&families));
families
}
/// Merges the per-face records a font system reports into one entry per
@@ -70,16 +83,166 @@ pub fn parse_fc_list(output: &str) -> Vec<(String, bool)> {
.collect()
}
/// Binary font bundle: little-endian u32 JSON-header length, a face metadata
/// array, then raw font bytes. Paths never cross IPC; only CoreText-enumerated
/// files under user font directories can be read, selected by family name.
/// Includes only the nearest regular/bold faces and their optional italics.
pub fn user_font_data(family: &str) -> std::io::Result<Vec<u8>> {
#[cfg(target_os = "macos")]
{
let entries = imp::user_faces();
bundle_family(family, &entries)
}
#[cfg(not(target_os = "macos"))]
{
let _ = family;
Err(std::io::Error::new(std::io::ErrorKind::Unsupported, "User font loading is macOS only"))
}
}
#[cfg(any(target_os = "macos", test))]
#[derive(Debug)]
struct UserFace {
family: String,
path: std::path::PathBuf,
weight: u16,
italic: bool,
}
#[cfg(any(target_os = "macos", test))]
fn user_font_path(path: &std::path::Path, roots: &[std::path::PathBuf]) -> Option<std::path::PathBuf> {
let path = path.canonicalize().ok()?;
roots.iter().any(|root| path.starts_with(root)).then_some(path)
}
#[cfg(any(target_os = "macos", test))]
fn family_faces<'a>(family: &str, entries: &'a [UserFace]) -> Vec<&'a UserFace> {
let mut selected: Vec<&UserFace> = Vec::new();
for italic in [false, true] {
for weight in [400u16, 700] {
let closest = entries
.iter()
.filter(|e| e.family.eq_ignore_ascii_case(family.trim()) && e.italic == italic)
.min_by_key(|e| (e.weight.abs_diff(weight), e.weight, &e.path));
if let Some(face) = closest
&& !selected.iter().any(|f| std::ptr::eq(*f, face))
{
selected.push(face);
}
}
}
selected
}
#[cfg(any(target_os = "macos", test))]
fn bundle_family(family: &str, entries: &[UserFace]) -> std::io::Result<Vec<u8>> {
#[derive(Serialize)]
struct Face {
weight: u16,
style: &'static str,
offset: usize,
length: usize,
}
let mut faces = Vec::new();
let mut data = Vec::new();
let mut files = BTreeMap::new();
// Bound large CJK families to regular/bold and their nearest italic faces.
for entry in family_faces(family, entries) {
// Reject a file replaced by a symlink after enumeration.
if entry.path.canonicalize()? != entry.path {
return Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "Font file changed"));
}
let (offset, length) = if let Some(range) = files.get(&entry.path) {
*range
} else {
let bytes = std::fs::read(&entry.path)?;
let range = (data.len(), bytes.len());
data.extend(bytes);
files.insert(entry.path.clone(), range);
range
};
faces.push(Face {
weight: entry.weight,
style: if entry.italic { "italic" } else { "normal" },
offset,
length,
});
}
if faces.is_empty() {
return Err(std::io::Error::new(std::io::ErrorKind::NotFound, "No installed user font family"));
}
let header = serde_json::to_vec(&faces)?;
let mut out = (header.len() as u32).to_le_bytes().to_vec();
out.extend(header);
out.extend(data);
Ok(out)
}
#[cfg(target_os = "macos")]
mod imp {
use objc2_core_foundation::{CFArray, CFDictionary, CFNumber, CFString, CFType};
use objc2_core_foundation::{CFArray, CFDictionary, CFNumber, CFString, CFType, CFURL};
use objc2_core_text::{
CTFontCollection, CTFontDescriptor, CTFontSymbolicTraits, kCTFontFamilyNameAttribute, kCTFontSymbolicTrait,
kCTFontTraitsAttribute,
kCTFontTraitsAttribute, kCTFontURLAttribute, kCTFontWeightTrait,
};
pub(crate) fn user_faces() -> Vec<super::UserFace> {
let mut roots = vec![std::path::PathBuf::from("/Library/Fonts")];
if let Some(home) = std::env::var_os("HOME") {
roots.push(std::path::PathBuf::from(home).join("Library/Fonts"));
}
let roots: Vec<_> = roots.into_iter().filter_map(|r| r.canonicalize().ok()).collect();
// SAFETY: CoreText descriptors carry documented CFString, CFURL and trait dictionary values.
unsafe {
let collection = CTFontCollection::from_available_fonts(None);
let Some(descriptors) = collection.matching_font_descriptors() else { return Vec::new() };
let mut seen = std::collections::BTreeSet::new();
descriptors
.cast_unchecked::<CTFontDescriptor>()
.iter()
.filter_map(|d| {
let family = d.attribute(kCTFontFamilyNameAttribute)?.downcast::<CFString>().ok()?.to_string();
let path = d.attribute(kCTFontURLAttribute)?.downcast::<CFURL>().ok()?.to_file_path()?;
let path = super::user_font_path(&path, &roots)?;
let traits = d.attribute(kCTFontTraitsAttribute)?.downcast::<CFDictionary>().ok()?;
let traits = traits.cast_unchecked::<CFString, CFType>();
let symbolic = traits
.get(kCTFontSymbolicTrait)
.and_then(|n| n.downcast::<CFNumber>().ok())
.and_then(|n| n.as_i64())
.unwrap_or(0) as u32;
let weight = traits
.get(kCTFontWeightTrait)
.and_then(|n| n.downcast::<CFNumber>().ok())
.and_then(|n| n.as_f64())
.unwrap_or(0.0);
// CoreText's normalized weight values corresponding to CSS's nine standard weights.
let weight = [
(-0.8, 100),
(-0.6, 200),
(-0.4, 300),
(0.0, 400),
(0.23, 500),
(0.3, 600),
(0.4, 700),
(0.56, 800),
(0.62, 900),
]
.into_iter()
.min_by(|(a, _), (b, _)| (weight - a).abs().total_cmp(&(weight - b).abs()))?
.1;
let italic = symbolic & CTFontSymbolicTraits::TraitItalic.0 != 0;
if !seen.insert((family.clone(), path.clone(), weight, italic)) {
return None;
}
Some(super::UserFace { family, path, weight, italic })
})
.collect()
}
}
/// Every face CoreText makes available to this process, as (family, fixed pitch).
pub(crate) fn list() -> Vec<(String, bool)> {
pub(crate) fn list(_refresh: bool) -> Vec<(String, bool)> {
// SAFETY: CoreText's documented types: the matching descriptors array
// holds CTFontDescriptors and the traits dictionary maps CFString keys.
unsafe {
@@ -116,15 +279,15 @@ mod imp {
/// The system font collection's families, named in English when the
/// family has an English name (what CSS matches), else its first name.
pub(crate) fn list() -> Vec<(String, bool)> {
pub(crate) fn list(refresh: bool) -> Vec<(String, bool)> {
// SAFETY: plain DirectWrite COM calls on interfaces we own.
unsafe { read() }.unwrap_or_default()
unsafe { read(refresh) }.unwrap_or_default()
}
unsafe fn read() -> windows::core::Result<Vec<(String, bool)>> {
unsafe fn read(refresh: bool) -> windows::core::Result<Vec<(String, bool)>> {
let factory: IDWriteFactory = unsafe { DWriteCreateFactory(DWRITE_FACTORY_TYPE_SHARED) }?;
let mut collection = None;
unsafe { factory.GetSystemFontCollection(&mut collection, false) }?;
unsafe { factory.GetSystemFontCollection(&mut collection, refresh) }?;
let Some(collection) = collection else {
return Ok(Vec::new());
};
@@ -171,7 +334,7 @@ mod imp {
/// fontconfig's view, which WebKitGTK resolves names with. Empty when
/// `fc-list` isn't installed.
pub(crate) fn list() -> Vec<(String, bool)> {
pub(crate) fn list(_refresh: bool) -> Vec<(String, bool)> {
Command::new("fc-list")
.args(["-f", "%{family[0]}\t%{spacing}\n"])
.stdin(Stdio::null())
@@ -186,7 +349,7 @@ mod imp {
#[cfg(not(any(unix, windows)))]
mod imp {
pub(crate) fn list() -> Vec<(String, bool)> {
pub(crate) fn list(_refresh: bool) -> Vec<(String, bool)> {
Vec::new()
}
}
@@ -194,6 +357,93 @@ mod imp {
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
#[test]
fn selects_only_regular_bold_and_matching_italics_from_dense_families() {
let entries: Vec<_> = [false, true]
.into_iter()
.flat_map(|italic| {
(100..=900).step_by(100).map(move |weight| UserFace {
family: "CJK".into(),
path: format!("{weight}-{italic}.ttf").into(),
weight,
italic,
})
})
.collect();
let selected = family_faces(" cjk ", &entries);
assert_eq!(
selected.iter().map(|f| (f.weight, f.italic)).collect::<Vec<_>>(),
[(400, false), (700, false), (400, true), (700, true),]
);
assert!(family_faces("unknown", &entries).is_empty());
}
#[test]
fn selects_nearest_available_weights_once_with_optional_italics() {
let entries: Vec<_> = [(300, false), (500, false), (800, false), (500, true)]
.into_iter()
.map(|(weight, italic)| UserFace {
family: "Sparse".into(),
path: format!("{weight}-{italic}.ttf").into(),
weight,
italic,
})
.collect();
let selected = family_faces("Sparse", &entries);
assert_eq!(
selected.iter().map(|f| (f.weight, f.italic)).collect::<Vec<_>>(),
[(300, false), (800, false), (500, true),]
);
assert_eq!(family_faces("Sparse", &entries[..1]).len(), 1);
}
#[test]
fn bundle_only_reads_enumerated_family_faces() {
let root = std::env::current_dir().unwrap().join(format!("../../.font-bundle-test-{}", std::process::id()));
std::fs::create_dir_all(&root).unwrap();
let root = root.canonicalize().unwrap();
let regular = root.join("regular.ttf");
let bold = root.join("bold.ttf");
std::fs::write(&regular, [1, 2, 3]).unwrap();
std::fs::write(&bold, [4, 5]).unwrap();
let entries = [
UserFace { family: "Test Nerd Font".into(), path: regular.clone(), weight: 400, italic: false },
UserFace { family: "Test Nerd Font".into(), path: bold, weight: 700, italic: false },
// A non-selected face is never opened, even if its file has disappeared.
UserFace { family: "Test Nerd Font".into(), path: root.join("thin.ttf"), weight: 100, italic: false },
];
let result = bundle_family("test nerd font", &entries).unwrap();
let len = u32::from_le_bytes(result[..4].try_into().unwrap()) as usize;
let faces: serde_json::Value = serde_json::from_slice(&result[4..4 + len]).unwrap();
assert_eq!(faces[0]["weight"], 400);
assert_eq!(faces[1]["weight"], 700);
assert_eq!(faces.as_array().unwrap().len(), 2);
assert_eq!(&result[4 + len..], &[1, 2, 3, 4, 5]);
// Existing files are not inputs: neither paths nor unenumerated families grant access.
assert!(bundle_family(regular.to_str().unwrap(), &entries).is_err());
assert!(bundle_family("Unenumerated", &entries).is_err());
assert!(user_font_path(&regular, &[]).is_none());
assert!(user_font_path(&regular, std::slice::from_ref(&root)).is_some());
// Multiple selected descriptors in a collection still transfer the file only once.
let shared = [
UserFace { family: "Collection".into(), path: regular.clone(), weight: 400, italic: false },
UserFace { family: "Collection".into(), path: regular, weight: 700, italic: false },
];
let result = bundle_family("Collection", &shared).unwrap();
let len = u32::from_le_bytes(result[..4].try_into().unwrap()) as usize;
let faces: serde_json::Value = serde_json::from_slice(&result[4..4 + len]).unwrap();
assert_eq!(faces[0]["offset"], faces[1]["offset"]);
assert_eq!(&result[4 + len..], &[1, 2, 3]);
#[cfg(unix)]
{
let link = root.join("outside.ttf");
std::os::unix::fs::symlink(std::env::current_exe().unwrap(), &link).unwrap();
assert!(user_font_path(&link, std::slice::from_ref(&root)).is_none());
}
std::fs::remove_dir_all(root).unwrap();
}
fn fam(name: &str, monospaced: bool) -> FontFamily {
FontFamily { name: name.into(), monospaced }
@@ -243,12 +493,38 @@ mod tests {
);
}
#[test]
fn caches_until_refresh_requested() {
let cache = Mutex::new(None);
let calls = AtomicUsize::new(0);
let lister = |refresh: bool| {
let n = calls.fetch_add(1, Ordering::SeqCst);
vec![(format!("Font {n} (refresh={refresh})"), n.is_multiple_of(2))]
};
let first = cached(&cache, false, lister);
assert_eq!(&*first, &[fam("Font 0 (refresh=false)", true)]);
assert_eq!(calls.load(Ordering::SeqCst), 1);
let second = cached(&cache, false, lister);
assert!(Arc::ptr_eq(&first, &second));
assert_eq!(calls.load(Ordering::SeqCst), 1);
let refreshed = cached(&cache, true, lister);
assert_eq!(&*refreshed, &[fam("Font 1 (refresh=true)", false)]);
assert_eq!(calls.load(Ordering::SeqCst), 2);
let after_refresh = cached(&cache, false, lister);
assert!(Arc::ptr_eq(&refreshed, &after_refresh));
assert_eq!(calls.load(Ordering::SeqCst), 2);
}
/// The real font system: every desktop OS ships some fonts, at least one
/// of them monospaced, and the list comes back sorted without duplicates.
#[cfg(any(target_os = "macos", windows))]
#[test]
fn lists_installed_families() {
let list = families();
let list = families(false);
assert!(list.len() > 20, "only {} families", list.len());
assert!(list.iter().any(|f| f.monospaced));
assert!(list.iter().any(|f| !f.monospaced));
@@ -260,5 +536,6 @@ mod tests {
assert!(menlo.monospaced);
assert!(list.iter().any(|f| f.name == "Helvetica Neue" && !f.monospaced));
}
assert_eq!(&*families(true), &*list);
}
}
+48 -1
View File
@@ -107,7 +107,7 @@ pub fn availability(root: &Path) -> GhAvailability {
}
let (repo, login) = std::thread::scope(|s| {
let repo = s.spawn(|| {
let cmd = command(root, &["repo", "view", "--json", "nameWithOwner,url,defaultBranchRef"])?;
let cmd = command(root, &["repo", "view", "--json", REPO_FIELDS])?;
process::run(cmd, QUICK, None)
});
let login = s.spawn(|| run(root, &["api", "user", "--jq", ".login"]));
@@ -278,6 +278,53 @@ pub fn add_comment(root: &Path, number: u32, body: &str) -> IdeResult<()> {
run(root, &["pr", "comment", &n, "--body", body]).map(drop)
}
/// A GitHub API call through `gh api`: GraphQL (`endpoint` `graphql`, `body`
/// `{"query", "variables"}`) or REST (`repos/{owner}/{repo}/…`, gh fills in the
/// placeholders from the project's remote).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct GhApiRequest {
/// `GET`, `POST`, `PATCH`, `PUT`, `DELETE`.
pub method: String,
pub endpoint: String,
/// JSON request body, sent on stdin.
pub body: Option<String>,
/// Follow REST `Link` pages; the result is the array of all pages (`--slurp`).
pub paginate: bool,
}
const METHODS: &[&str] = &["GET", "POST", "PATCH", "PUT", "DELETE"];
/// `gh api` arguments for `req`.
pub fn api_args(req: &GhApiRequest) -> IdeResult<Vec<String>> {
let method = req.method.to_ascii_uppercase();
if !METHODS.contains(&method.as_str()) {
return Err(IdeError::invalid(format!("unsupported HTTP method {}", req.method)));
}
let endpoint = req.endpoint.trim();
if endpoint.is_empty() || endpoint.starts_with('-') || endpoint.contains("://") {
return Err(IdeError::invalid(format!("not a GitHub API endpoint: {endpoint}")));
}
let mut args: Vec<String> = vec!["api".into(), "--method".into(), method, endpoint.into()];
if req.body.is_some() {
args.push("--input".into());
args.push("-".into());
}
if req.paginate {
args.push("--paginate".into());
args.push("--slurp".into());
}
Ok(args)
}
/// Runs `req`; returns the response body (JSON text, empty for `204 No Content`).
pub fn api(root: &Path, req: &GhApiRequest) -> IdeResult<String> {
let args = api_args(req)?;
let refs: Vec<&str> = args.iter().map(String::as_str).collect();
let out = process::run_with_input(command(root, &refs)?, req.body.as_deref(), QUICK, None)?;
if out.success { Ok(out.stdout) } else { Err(failure(&out.stderr, &out.stdout)) }
}
/// Revisions to diff a pull request's files with, present in the local repository.
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
+24 -2
View File
@@ -34,6 +34,12 @@ pub struct GhRepo {
pub name_with_owner: String,
pub url: String,
pub default_branch: Option<String>,
/// Merge methods the repository allows (all of them when gh does not say).
pub merge_commit_allowed: bool,
pub squash_merge_allowed: bool,
pub rebase_merge_allowed: bool,
/// The signed-in user's permission: `ADMIN`, `MAINTAIN`, `WRITE`, `TRIAGE`, `READ`; empty when unknown.
pub viewer_permission: String,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
@@ -152,6 +158,8 @@ pub struct PullRequestDetails {
pub base_oid: String,
/// `MERGEABLE`, `CONFLICTING`, `UNKNOWN`.
pub mergeable: String,
/// GitHub's merge box state: `CLEAN`, `UNSTABLE`, `HAS_HOOKS`, `BLOCKED`, `BEHIND`, `DIRTY`, `DRAFT`, `UNKNOWN`.
pub merge_state_status: String,
pub is_cross_repository: bool,
pub merged_at: Option<String>,
pub closed_at: Option<String>,
@@ -242,7 +250,7 @@ impl RawSummary {
pub const LIST_FIELDS: &str = "number,title,author,state,isDraft,headRefName,baseRefName,createdAt,updatedAt,labels,reviewDecision,url,additions,deletions";
/// Fields requested from `gh pr view`.
pub const VIEW_FIELDS: &str = "number,title,author,state,isDraft,headRefName,baseRefName,createdAt,updatedAt,labels,reviewDecision,url,additions,deletions,body,headRefOid,baseRefOid,mergeable,isCrossRepository,mergedAt,closedAt,statusCheckRollup,reviewRequests,reviews,assignees,commits,files,comments";
pub const VIEW_FIELDS: &str = "number,title,author,state,isDraft,headRefName,baseRefName,createdAt,updatedAt,labels,reviewDecision,url,additions,deletions,body,headRefOid,baseRefOid,mergeable,mergeStateStatus,isCrossRepository,mergedAt,closedAt,statusCheckRollup,reviewRequests,reviews,assignees,commits,files,comments";
pub fn parse_list(json: &str) -> serde_json::Result<Vec<PullRequestSummary>> {
let raw: Vec<RawSummary> = serde_json::from_str(json)?;
@@ -348,6 +356,7 @@ struct RawDetails {
head_ref_oid: String,
base_ref_oid: String,
mergeable: String,
merge_state_status: String,
is_cross_repository: bool,
merged_at: Option<String>,
closed_at: Option<String>,
@@ -442,6 +451,7 @@ pub fn parse_view(json: &str) -> serde_json::Result<PullRequestDetails> {
head_oid: raw.head_ref_oid,
base_oid: raw.base_ref_oid,
mergeable: raw.mergeable,
merge_state_status: raw.merge_state_status,
is_cross_repository: raw.is_cross_repository,
merged_at: raw.merged_at.filter(|s| !s.is_empty()),
closed_at: raw.closed_at.filter(|s| !s.is_empty()),
@@ -534,6 +544,10 @@ struct RawRepo {
name_with_owner: String,
url: String,
default_branch_ref: Option<RawBranchRef>,
merge_commit_allowed: Option<bool>,
squash_merge_allowed: Option<bool>,
rebase_merge_allowed: Option<bool>,
viewer_permission: Option<String>,
}
#[derive(Deserialize, Default)]
@@ -542,13 +556,21 @@ struct RawBranchRef {
name: String,
}
/// `gh repo view --json nameWithOwner,url,defaultBranchRef`.
/// Fields requested from `gh repo view`.
pub const REPO_FIELDS: &str =
"nameWithOwner,url,defaultBranchRef,mergeCommitAllowed,squashMergeAllowed,rebaseMergeAllowed,viewerPermission";
/// `gh repo view --json` with [`REPO_FIELDS`].
pub fn parse_repo(json: &str) -> serde_json::Result<GhRepo> {
let raw: RawRepo = serde_json::from_str(json)?;
Ok(GhRepo {
name_with_owner: raw.name_with_owner,
url: raw.url,
default_branch: raw.default_branch_ref.map(|b| b.name).filter(|n| !n.is_empty()),
merge_commit_allowed: raw.merge_commit_allowed.unwrap_or(true),
squash_merge_allowed: raw.squash_merge_allowed.unwrap_or(true),
rebase_merge_allowed: raw.rebase_merge_allowed.unwrap_or(true),
viewer_permission: raw.viewer_permission.unwrap_or_default(),
})
}
+22 -1
View File
@@ -60,9 +60,30 @@ fn read_all(mut pipe: impl Read + Send + 'static) -> std::thread::JoinHandle<Str
/// Runs `cmd` to completion. Fails when it outlives `timeout` or `cancel` fires;
/// a non-zero exit is reported in [`Output::success`], not as an error.
pub(crate) fn run(mut cmd: Command, timeout: Duration, cancel: Option<&CancellationToken>) -> IdeResult<Output> {
pub(crate) fn run(cmd: Command, timeout: Duration, cancel: Option<&CancellationToken>) -> IdeResult<Output> {
run_with_input(cmd, None, timeout, cancel)
}
/// [`run`] with `input` written to the child's stdin (then closed).
pub(crate) fn run_with_input(
mut cmd: Command,
input: Option<&str>,
timeout: Duration,
cancel: Option<&CancellationToken>,
) -> IdeResult<Output> {
prepare(&mut cmd);
if input.is_some() {
cmd.stdin(Stdio::piped());
}
let mut child = spawn(&mut cmd)?;
if let (Some(input), Some(mut stdin)) = (input, child.stdin.take()) {
let input = input.to_owned();
// A writer thread: a large body must not block on a full pipe while gh is not reading yet.
std::thread::spawn(move || {
use std::io::Write;
let _ = stdin.write_all(input.as_bytes());
});
}
let out = child.stdout.take().map(read_all);
let err = child.stderr.take().map(read_all);
let deadline = Instant::now() + timeout;
+1 -1
View File
@@ -1 +1 @@
{"additions":42,"assignees":[],"author":{"id":"MDQ6VXNlcjM2NzI4OTMx","is_bot":false,"login":"babakks","name":"Babak K. Shandiz"},"baseRefName":"trunk","baseRefOid":"ae7b9715976c5508398b11dc8546e1279e10f6fc","body":"### Description\n\nThis PR moves gh to the tagged version of `cli/oauth`.","closedAt":"2026-10-21T12:29:58Z","comments":[{"id":"IC_1","author":{"login":"babakks"},"authorAssociation":"MEMBER","body":"Rebased on trunk.","createdAt":"2026-10-21T08:00:00Z","includesCreatedEdit":false,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/cli/cli/pull/14768#issuecomment-1","viewerDidAuthor":false}],"commits":[{"authoredDate":"2026-10-20T16:37:24Z","authors":[{"email":"[email protected]","id":"MDQ6VXNlcjM2NzI4OTMx","login":"babakks","name":"Babak K. Shandiz"}],"committedDate":"2026-10-21T12:06:51Z","messageBody":"cli/oauth v1.3.0 exposes the token expiry fields as time.Duration values.","messageHeadline":"chore(deps): bump github.com/cli/oauth to v1.3.0","oid":"fd1b1feb222dde885d00262b2c21b107c6c6e416"}],"createdAt":"2026-10-20T16:40:41Z","deletions":48,"files":[{"path":"go.mod","additions":1,"deletions":1,"changeType":"MODIFIED"},{"path":"pkg/cmd/status/status.go","additions":3,"deletions":2,"changeType":"MODIFIED"}],"headRefName":"babakks/refresh-token-bump-deps","headRefOid":"6ec1ba54542baca6cc5932582634104c83f36a28","isCrossRepository":false,"isDraft":false,"labels":[],"mergeable":"UNKNOWN","mergedAt":"2026-10-21T12:29:58Z","number":14768,"reviewDecision":"APPROVED","reviewRequests":[{"__typename":"User","login":"niik"}],"reviews":[{"id":"PRR_1","author":{"login":"copilot-pull-request-reviewer"},"authorAssociation":"CONTRIBUTOR","body":"","submittedAt":"2026-10-21T09:46:01Z","includesCreatedEdit":false,"reactionGroups":[],"state":"COMMENTED","commit":{"oid":"831b9343fa77c2f11f521a49fd01da2d2d007b8e"}},{"id":"PRR_2","author":{"login":"williammartin"},"authorAssociation":"MEMBER","body":"LGTM","submittedAt":"2026-10-21T12:20:00Z","includesCreatedEdit":false,"reactionGroups":[],"state":"APPROVED","commit":{"oid":"6ec1ba54542baca6cc5932582634104c83f36a28"}}],"state":"MERGED","statusCheckRollup":[{"__typename":"CheckRun","completedAt":"2026-10-21T12:09:03Z","conclusion":"SUCCESS","detailsUrl":"https://github.com/cli/cli/actions/runs/1/job/1","name":"build (ubuntu)","startedAt":"2026-10-21T12:07:01Z","status":"COMPLETED","workflowName":"Tests"},{"__typename":"CheckRun","completedAt":"0001-01-01T00:00:00Z","conclusion":"","detailsUrl":"https://github.com/cli/cli/actions/runs/1/job/2","name":"build (windows)","startedAt":"2026-10-21T12:07:01Z","status":"IN_PROGRESS","workflowName":"Tests"},{"__typename":"CheckRun","completedAt":"2026-10-21T12:08:00Z","conclusion":"FAILURE","detailsUrl":"https://github.com/cli/cli/actions/runs/1/job/3","name":"lint","startedAt":"2026-10-21T12:07:01Z","status":"COMPLETED","workflowName":"Lint"},{"__typename":"CheckRun","completedAt":"2026-10-21T12:08:00Z","conclusion":"SKIPPED","detailsUrl":"","name":"docs","startedAt":"2026-10-21T12:07:01Z","status":"COMPLETED","workflowName":""},{"__typename":"StatusContext","context":"ci/legacy","state":"PENDING","targetUrl":"https://ci.example.com/1","startedAt":"2026-10-21T12:07:01Z"}],"title":"Refreshable tokens: bump cli/oauth to v1.3.0 and fix `gh status` column width","updatedAt":"2026-10-21T12:30:00Z","url":"https://github.com/cli/cli/pull/14768"}
{"additions":42,"assignees":[],"author":{"id":"MDQ6VXNlcjM2NzI4OTMx","is_bot":false,"login":"babakks","name":"Babak K. Shandiz"},"baseRefName":"trunk","baseRefOid":"ae7b9715976c5508398b11dc8546e1279e10f6fc","body":"### Description\n\nThis PR moves gh to the tagged version of `cli/oauth`.","closedAt":"2026-10-21T12:29:58Z","comments":[{"id":"IC_1","author":{"login":"babakks"},"authorAssociation":"MEMBER","body":"Rebased on trunk.","createdAt":"2026-10-21T08:00:00Z","includesCreatedEdit":false,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/cli/cli/pull/14768#issuecomment-1","viewerDidAuthor":false}],"commits":[{"authoredDate":"2026-10-20T16:37:24Z","authors":[{"email":"[email protected]","id":"MDQ6VXNlcjM2NzI4OTMx","login":"babakks","name":"Babak K. Shandiz"}],"committedDate":"2026-10-21T12:06:51Z","messageBody":"cli/oauth v1.3.0 exposes the token expiry fields as time.Duration values.","messageHeadline":"chore(deps): bump github.com/cli/oauth to v1.3.0","oid":"fd1b1feb222dde885d00262b2c21b107c6c6e416"}],"createdAt":"2026-10-20T16:40:41Z","deletions":48,"files":[{"path":"go.mod","additions":1,"deletions":1,"changeType":"MODIFIED"},{"path":"pkg/cmd/status/status.go","additions":3,"deletions":2,"changeType":"MODIFIED"}],"headRefName":"babakks/refresh-token-bump-deps","headRefOid":"6ec1ba54542baca6cc5932582634104c83f36a28","isCrossRepository":false,"isDraft":false,"labels":[],"mergeable":"UNKNOWN","mergeStateStatus":"UNKNOWN","mergedAt":"2026-10-21T12:29:58Z","number":14768,"reviewDecision":"APPROVED","reviewRequests":[{"__typename":"User","login":"niik"}],"reviews":[{"id":"PRR_1","author":{"login":"copilot-pull-request-reviewer"},"authorAssociation":"CONTRIBUTOR","body":"","submittedAt":"2026-10-21T09:46:01Z","includesCreatedEdit":false,"reactionGroups":[],"state":"COMMENTED","commit":{"oid":"831b9343fa77c2f11f521a49fd01da2d2d007b8e"}},{"id":"PRR_2","author":{"login":"williammartin"},"authorAssociation":"MEMBER","body":"LGTM","submittedAt":"2026-10-21T12:20:00Z","includesCreatedEdit":false,"reactionGroups":[],"state":"APPROVED","commit":{"oid":"6ec1ba54542baca6cc5932582634104c83f36a28"}}],"state":"MERGED","statusCheckRollup":[{"__typename":"CheckRun","completedAt":"2026-10-21T12:09:03Z","conclusion":"SUCCESS","detailsUrl":"https://github.com/cli/cli/actions/runs/1/job/1","name":"build (ubuntu)","startedAt":"2026-10-21T12:07:01Z","status":"COMPLETED","workflowName":"Tests"},{"__typename":"CheckRun","completedAt":"0001-01-01T00:00:00Z","conclusion":"","detailsUrl":"https://github.com/cli/cli/actions/runs/1/job/2","name":"build (windows)","startedAt":"2026-10-21T12:07:01Z","status":"IN_PROGRESS","workflowName":"Tests"},{"__typename":"CheckRun","completedAt":"2026-10-21T12:08:00Z","conclusion":"FAILURE","detailsUrl":"https://github.com/cli/cli/actions/runs/1/job/3","name":"lint","startedAt":"2026-10-21T12:07:01Z","status":"COMPLETED","workflowName":"Lint"},{"__typename":"CheckRun","completedAt":"2026-10-21T12:08:00Z","conclusion":"SKIPPED","detailsUrl":"","name":"docs","startedAt":"2026-10-21T12:07:01Z","status":"COMPLETED","workflowName":""},{"__typename":"StatusContext","context":"ci/legacy","state":"PENDING","targetUrl":"https://ci.example.com/1","startedAt":"2026-10-21T12:07:01Z"}],"title":"Refreshable tokens: bump cli/oauth to v1.3.0 and fix `gh status` column width","updatedAt":"2026-10-21T12:30:00Z","url":"https://github.com/cli/cli/pull/14768"}
+32
View File
@@ -22,6 +22,7 @@ fn pr_view() {
assert_eq!(pr.summary.number, 14768);
assert_eq!(pr.summary.head_ref, "babakks/refresh-token-bump-deps");
assert_eq!(pr.head_oid, "6ec1ba54542baca6cc5932582634104c83f36a28");
assert_eq!(pr.merge_state_status, "UNKNOWN");
assert_eq!(pr.files.len(), 2);
assert_eq!(pr.files[0].change_type, "MODIFIED");
assert_eq!(pr.commits[0].headline, "chore(deps): bump github.com/cli/oauth to v1.3.0");
@@ -69,6 +70,16 @@ fn repo_view() {
.unwrap();
assert_eq!(r.name_with_owner, "l0ng-ai/openidea");
assert_eq!(r.default_branch.as_deref(), Some("main"));
assert!(r.merge_commit_allowed && r.squash_merge_allowed && r.rebase_merge_allowed, "unknown: allowed");
assert_eq!(r.viewer_permission, "");
let r = parse_repo(
r#"{"mergeCommitAllowed":true,"nameWithOwner":"o/r","url":"","rebaseMergeAllowed":false,"squashMergeAllowed":false,"viewerPermission":"WRITE"}"#,
)
.unwrap();
assert!(r.merge_commit_allowed);
assert!(!r.squash_merge_allowed && !r.rebase_merge_allowed);
assert_eq!(r.viewer_permission, "WRITE");
}
#[test]
@@ -87,3 +98,24 @@ fn list_arguments() {
assert!(joined.contains("--limit 500 --state merged --author @me"));
assert_eq!(args.last().map(String::as_str), Some("review-requested:@me fix login"));
}
#[test]
fn api_arguments() {
let req =
GhApiRequest { method: "post".into(), endpoint: "graphql".into(), body: Some("{}".into()), paginate: false };
assert_eq!(api_args(&req).unwrap(), ["api", "--method", "POST", "graphql", "--input", "-"]);
let req = GhApiRequest {
method: "GET".into(),
endpoint: "repos/{owner}/{repo}/labels".into(),
body: None,
paginate: true,
};
assert_eq!(
api_args(&req).unwrap(),
["api", "--method", "GET", "repos/{owner}/{repo}/labels", "--paginate", "--slurp"]
);
for bad in ["--hostname", "https://evil.example/x", ""] {
assert!(api_args(&GhApiRequest { endpoint: bad.into(), ..req.clone() }).is_err(), "{bad}");
}
assert!(api_args(&GhApiRequest { method: "TRACE".into(), ..req }).is_err());
}
+6 -2
View File
@@ -10,6 +10,7 @@ name = "openidea-server"
path = "src/bin/server.rs"
[dependencies]
encoding_rs.workspace = true
ide-core.workspace = true
ide-text.workspace = true
ide-vfs.workspace = true
@@ -34,17 +35,20 @@ ide-ssh.workspace = true
ide-remote.workspace = true
ide-jsonrpc.workspace = true
ide-agent.workspace = true
ide-notebook.workspace = true
ide-tools = { path = "../ide-tools" }
serde.workspace = true
serde_json.workspace = true
specta.workspace = true
specta-typescript.workspace = true
tokio = { workspace = true, features = ["io-std"] }
tracing.workspace = true
tracing-subscriber.workspace = true
parking_lot.workspace = true
dunce.workspace = true
dirs.workspace = true
uuid.workspace = true
tempfile.workspace = true
[dev-dependencies]
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "io-util"] }
tempfile.workspace = true
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "io-util", "test-util"] }
+13 -2
View File
@@ -44,6 +44,7 @@ struct State {
project: Changes,
/// Project roots whose `postStartCommand` ran in this process.
post_started: HashSet<PathBuf>,
post_commands: std::collections::HashMap<PathBuf, CancellationToken>,
}
pub struct Environment {
@@ -78,7 +79,12 @@ impl Environment {
pub(crate) fn project_closed(&self) {
if self.project_env {
self.state.lock().project.clear();
let mut state = self.state.lock();
for (_, token) in state.post_commands.drain() {
token.cancel();
}
state.project.clear();
drop(state);
self.publish();
}
}
@@ -144,6 +150,7 @@ impl Environment {
/// a failure is a notification.
pub(crate) fn spawn_post_start(&self, cmd: LifecycleCommand, root: PathBuf, sink: SharedSink) {
let cancel = self.cancel.child_token();
self.state.lock().post_commands.insert(root.clone(), cancel.clone());
let spawned = std::thread::Builder::new().name("openidea-post-start".into()).spawn(move || {
run_post_start(&cmd, &root, &sink, &cancel);
});
@@ -263,6 +270,9 @@ fn run_post_start(cmd: &LifecycleCommand, root: &Path, sink: &SharedSink, cancel
/// Runs `argv` in `root` with the children's environment, its stdout and stderr line by line
/// to `on_line`; `Err` says how it failed.
fn run_one(argv: &[String], root: &Path, cancel: &CancellationToken, on_line: &dyn Fn(String)) -> Result<(), String> {
if cancel.is_cancelled() {
return Err("cancelled".into());
}
let (program, args) = argv.split_first().ok_or("empty command")?;
let mut child = ide_core::process::prepare(&mut Command::new(program))
.args(args)
@@ -281,7 +291,8 @@ fn run_one(argv: &[String], root: &Path, cancel: &CancellationToken, on_line: &d
for pipe in pipes.into_iter().flatten() {
let tx = tx.clone();
std::thread::spawn(move || {
for line in BufReader::new(pipe).lines().map_while(Result::ok) {
for line in BufReader::new(pipe).split(b'\n').map_while(Result::ok) {
let line = ide_core::output_encoding::decode_tool_output(&line).trim_end_matches('\r').to_owned();
if tx.send(line).is_err() {
break;
}
+107
View File
@@ -0,0 +1,107 @@
//! Select the document's formatting provider before executing it on this host.
use crate::Host;
use ide_core::event::NotifyLevel;
use ide_core::settings::PrettierSettings;
use ide_core::{DocId, IdeError, IdeEvent, IdeResult, Range, TextEdit};
use ide_language::FormatterConfig;
use ide_lsp::types::FormatOptions;
use ide_tools::{Tools, formatter};
fn external_selected(config: Option<&FormatterConfig>, language: &str, settings: &PrettierSettings, lsp: bool) -> bool {
config.is_some_and(|c| c.tool == "prettier")
&& settings.languages.iter().any(|l| l == language)
&& (settings.prefer || !lsp)
}
pub async fn format(
host: &Host,
window: &str,
doc: DocId,
range: Option<Range>,
options: FormatOptions,
) -> IdeResult<Vec<TextEdit>> {
let project = host.project(window)?;
let snap = project.documents.snapshot(doc)?;
let settings = project.settings.effective();
let prettier = &settings.languages.prettier;
let language = project.languages.get(&snap.language);
let config = language.as_ref().and_then(|l| l.formatter.as_ref());
let candidate =
config.is_some_and(|c| c.tool == "prettier") && prettier.languages.iter().any(|l| l == snap.language.as_str());
let lsp = if candidate && !prettier.prefer { project.lsp.can_format(doc).await? } else { false };
if !external_selected(config, snap.language.as_str(), prettier, lsp) {
if options.on_save && !settings.editor.format_on_save {
return Ok(Vec::new());
}
return project.lsp.format(doc, range, options).await;
}
if options.on_save && !prettier.run_on_save {
return Ok(Vec::new());
}
host.require_trust(window)?;
if snap.read_only {
return Err(IdeError::invalid("The file is read-only"));
}
let config = config.unwrap().clone();
let custom = ide_sdk::apply::setting_path(&project.root, &prettier.path);
let tools = Tools::new(&host.data_dir);
let whole_selection = range.is_some() && !config.range;
let edits: IdeResult<Vec<TextEdit>> = tokio::task::spawn_blocking(move || {
let text = ide_text::Document::new(&snap.text);
let offsets = range.map(|r| (text.pos_to_offset(r.start), text.pos_to_offset(r.end)));
let mut command = tools.formatter_command(&config.tool, &snap.path, custom.as_deref())?;
command.current_dir(snap.path.parent().ok_or_else(|| IdeError::invalid("Prettier: file has no directory"))?);
command.args(formatter::arguments(&config, &snap.path, options.tab_size, options.insert_spaces, offsets));
let formatted = formatter::run(command, &snap.text, &Default::default())?;
Ok(text
.diff(&formatted)
.changes
.into_iter()
.map(|c| TextEdit {
range: Range::new(text.offset_to_pos(c.from), text.offset_to_pos(c.to)),
new_text: c.insert,
})
.collect())
})
.await
.map_err(|e| IdeError::internal(e.to_string()))?;
if edits.is_ok() && whole_selection {
project.sink.emit(IdeEvent::Notify {
level: NotifyLevel::Info,
title: "Prettier".into(),
body: Some(
"Prettier does not support selection formatting for this language; formatted the whole file".into(),
),
});
}
edits.map_err(|e| e.with_tool("prettier"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn language_enablement_and_preference_preserve_lsp_formatters() {
let languages = ide_language::LanguageRegistry::load(None).unwrap();
let mut settings = PrettierSettings::default();
for lang in languages.all() {
for lsp in [false, true] {
let configured = lang.formatter.as_ref().is_some_and(|f| f.tool == "prettier");
assert_eq!(
external_selected(lang.formatter.as_ref(), lang.id.as_str(), &settings, lsp),
configured && !lsp,
"{}",
lang.id
);
}
}
let ts = languages.get(&ide_core::LanguageId::new("typescript")).unwrap();
settings.prefer = true;
assert!(external_selected(ts.formatter.as_ref(), "typescript", &settings, true));
settings.languages.clear();
assert!(!external_selected(ts.formatter.as_ref(), "typescript", &settings, false));
assert!(!external_selected(None, "rust", &settings, false));
}
}
+183
View File
@@ -0,0 +1,183 @@
//! Automatic runtime provisioning belongs to the host, not SDK discovery.
use ide_core::{CancellationToken, IdeEvent, SharedSink, TaskId};
use ide_language::LanguageServerConfig;
use ide_sdk::java::{jdtls_jdks, jdtls_min_java, jdtls_runtime, language_server_runtimes_dir};
use parking_lot::{Condvar, Mutex};
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::{Arc, LazyLock, Weak};
use std::time::Duration;
#[derive(Default)]
struct State {
busy: bool,
sinks: Vec<Weak<dyn ide_core::EventSink>>,
attempted: HashSet<(PathBuf, u32)>,
}
#[derive(Default)]
struct Provisioner {
state: Mutex<State>,
changed: Condvar,
}
static PROVISIONER: LazyLock<Provisioner> = LazyLock::new(Provisioner::default);
fn needs_download(explicit: bool, suitable: bool, attempted: bool) -> bool {
!explicit && !suitable && !attempted
}
impl Provisioner {
fn subscribe(&self, sink: &SharedSink) {
let mut state = self.state.lock();
state.sinks.retain(|s| s.strong_count() > 0);
let weak = Arc::downgrade(sink);
if !state.sinks.iter().any(|s| s.ptr_eq(&weak)) {
state.sinks.push(weak);
}
}
fn emit(&self, event: IdeEvent) {
let sinks: Vec<_> = self.state.lock().sinks.iter().filter_map(Weak::upgrade).collect();
for sink in sinks {
sink.emit(event.clone());
}
}
fn run(&self, key: (PathBuf, u32), cancel: &CancellationToken, work: impl FnOnce()) {
let mut state = self.state.lock();
while state.busy && !cancel.is_cancelled() {
self.changed.wait_for(&mut state, Duration::from_millis(100));
}
if cancel.is_cancelled() || !state.attempted.insert(key) {
return;
}
state.busy = true;
drop(state);
// Even a panicking installer must release waiters and retain the attempt.
struct Flight<'a>(&'a Provisioner);
impl Drop for Flight<'_> {
fn drop(&mut self) {
self.0.state.lock().busy = false;
self.0.changed.notify_all();
}
}
let _flight = Flight(self);
work();
}
}
pub(crate) fn prepare(
cfg: &LanguageServerConfig,
data_dir: &Path,
explicit: bool,
sink: &SharedSink,
launch_cancel: &CancellationToken,
) {
let min_java = jdtls_min_java(cfg);
let jdks = jdtls_jdks(min_java, data_dir);
// A launch environment override is explicit too, even if it is invalid.
if !needs_download(explicit || cfg.env.contains_key("JAVA_HOME"), jdtls_runtime(&jdks, min_java).is_some(), false) {
return;
}
PROVISIONER.subscribe(sink);
PROVISIONER.run((data_dir.to_path_buf(), min_java), launch_cancel, || {
if jdtls_runtime(&jdtls_jdks(min_java, data_dir), min_java).is_some() {
return;
}
let id = TaskId::next();
let title = format!("Downloading Java {min_java} language server runtime");
// Closing one project must not cancel a download other projects await.
let cancel = CancellationToken::new();
let _handle = ide_core::task::on_cancel(id, {
let cancel = cancel.clone();
move || cancel.cancel()
});
let emit = |message, fraction, done| {
PROVISIONER.emit(IdeEvent::Task { id, title: title.clone(), message, fraction, done, cancellable: !done });
};
emit(Some("Eclipse Temurin".into()), None, false);
let result = ide_tools::Tools::new(data_dir).download_java_runtime(
min_java,
&language_server_runtimes_dir(data_dir),
&cancel,
&|event| match event {
ide_tools::InstallEvent::Line { text } => emit(Some(text), None, false),
ide_tools::InstallEvent::Progress { fraction, message } => {
emit(Some(message), fraction.map(|f| f as f32), false)
}
},
);
emit(result.as_ref().err().map(ToString::to_string), Some(1.0), true);
if let Err(error) = result {
// The configuration hook retains the existing manual download action.
tracing::warn!("automatic jdtls runtime download: {error}");
}
});
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
#[test]
fn download_decision() {
assert!(needs_download(false, false, false));
for (explicit, suitable, attempted) in [(true, false, false), (false, true, false), (false, false, true)] {
assert!(!needs_download(explicit, suitable, attempted));
}
}
#[test]
fn simultaneous_projects_share_one_attempt_and_failures_do_not_retry() {
let provisioner = Provisioner::default();
let calls = AtomicUsize::new(0);
let barrier = std::sync::Barrier::new(8);
std::thread::scope(|scope| {
for _ in 0..8 {
scope.spawn(|| {
barrier.wait();
provisioner.run((PathBuf::from("/data"), 25), &CancellationToken::new(), || {
calls.fetch_add(1, Ordering::SeqCst);
std::thread::sleep(Duration::from_millis(30));
});
});
}
});
provisioner.run((PathBuf::from("/data"), 25), &CancellationToken::new(), || panic!("retried"));
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
#[test]
fn different_versions_are_still_single_flight() {
let provisioner = Provisioner::default();
let active = AtomicUsize::new(0);
let calls = AtomicUsize::new(0);
std::thread::scope(|scope| {
for feature in [25, 26, 27] {
let provisioner = &provisioner;
let active = &active;
let calls = &calls;
scope.spawn(move || {
provisioner.run((PathBuf::from("/data"), feature), &CancellationToken::new(), || {
assert_eq!(active.fetch_add(1, Ordering::SeqCst), 0);
std::thread::sleep(Duration::from_millis(10));
calls.fetch_add(1, Ordering::SeqCst);
active.fetch_sub(1, Ordering::SeqCst);
});
});
}
});
assert_eq!(calls.load(Ordering::SeqCst), 3);
}
#[test]
fn cancelled_waiter_does_not_consume_an_attempt() {
let provisioner = Provisioner::default();
let cancel = CancellationToken::new();
cancel.cancel();
provisioner.run((PathBuf::from("/data"), 25), &cancel, || panic!("cancelled"));
assert!(provisioner.state.lock().attempted.is_empty());
}
}
+429
View File
@@ -0,0 +1,429 @@
//! Saved Kotlin -> isolated Maven output -> marked JDT libraries.
use ide_build::kotlin_bridge::{self, Outcome, ProjectLibraries};
use ide_core::event::NotifyLevel;
use ide_core::{CancellationToken, Diagnostic, IdeEvent, Pos, Range, Severity, SharedSink};
use ide_lsp::LspManager;
use ide_workspace::SettingsStore;
use ide_workspace::documents::DocumentObserver;
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
use std::time::Duration;
use tokio::sync::Notify;
const DELAY: Duration = Duration::from_millis(750);
const SOURCE: &str = "kotlin-bridge";
const COMMAND: &str = "openidea.kotlin.replace";
#[derive(Default)]
struct Revision {
generation: u64,
cancel: CancellationToken,
}
impl Revision {
fn change(&mut self) {
self.cancel.cancel();
self.generation += 1;
self.cancel = CancellationToken::new();
}
fn current(&self, generation: u64) -> bool {
self.generation == generation && !self.cancel.is_cancelled()
}
}
#[derive(Clone, Default, Serialize, Deserialize)]
struct MavenOptions {
offline: bool,
profiles: Vec<String>,
}
pub(crate) struct KotlinBridge {
root: PathBuf,
cache: PathBuf,
bundle: Option<PathBuf>,
settings: Arc<SettingsStore>,
lsp: Arc<LspManager>,
sink: SharedSink,
trusted: Arc<AtomicBool>,
lifetime: CancellationToken,
revision: Mutex<Revision>,
changed: Notify,
options: Mutex<MavenOptions>,
worker: Mutex<Option<tokio::task::JoinHandle<()>>>,
}
impl KotlinBridge {
pub(crate) fn new(
root: PathBuf,
cache: PathBuf,
data: &Path,
settings: Arc<SettingsStore>,
lsp: Arc<LspManager>,
sink: SharedSink,
trusted: Arc<AtomicBool>,
) -> Arc<Self> {
let options = std::fs::read(cache.join("kotlin-bridge-options.json"))
.ok()
.and_then(|bytes| serde_json::from_slice(&bytes).ok())
.unwrap_or_default();
Arc::new(Self {
root,
cache,
bundle: ide_sdk::java::kotlin_bridge_bundle(data),
settings,
lsp,
sink,
trusted,
lifetime: CancellationToken::new(),
revision: Mutex::new(Revision::default()),
changed: Notify::new(),
options: Mutex::new(options),
worker: Mutex::new(None),
})
}
pub(crate) fn start(self: &Arc<Self>, runtime: &tokio::runtime::Handle) {
let bridge = self.clone();
*self.worker.lock() = Some(runtime.spawn(async move { bridge.run().await }));
self.schedule();
}
pub(crate) fn configure_maven(&self, offline: bool, profiles: Vec<String>) {
let options = MavenOptions { offline, profiles };
if let Ok(bytes) = serde_json::to_vec(&options) {
let _ = std::fs::create_dir_all(&self.cache);
let _ = std::fs::write(self.cache.join("kotlin-bridge-options.json"), bytes);
}
*self.options.lock() = options;
self.schedule();
}
pub(crate) async fn shutdown(&self) {
self.stop();
let worker = self.worker.lock().take();
if let Some(worker) = worker {
let _ = worker.await;
}
}
pub(crate) fn schedule(&self) {
if self.lifetime.is_cancelled() {
return;
}
self.revision.lock().change();
self.changed.notify_one();
}
pub(crate) fn files_changed<'a>(&self, mut paths: impl Iterator<Item = &'a PathBuf>) {
let settings = self.settings.effective();
if paths.any(|path| relevant_change(&self.root, path, &settings)) {
self.schedule();
}
}
pub(crate) fn stop(&self) {
self.lifetime.cancel();
self.revision.lock().cancel.cancel();
}
async fn replace(&self, projects: &[ProjectLibraries], cancel: &CancellationToken) -> ide_core::IdeResult<()> {
let snapshot = serde_json::to_value(projects).map_err(|e| ide_core::IdeError::internal(e.to_string()))?;
let response = self.lsp.jdtls_command_cancellable(&self.root, COMMAND, vec![snapshot], cancel).await?;
if response.as_u64().is_none() {
return Err(ide_core::IdeError::unavailable(
"Kotlin bridge command did not acknowledge its library snapshot",
));
}
Ok(())
}
async fn run(self: Arc<Self>) {
let mut diagnostics = BTreeSet::new();
let mut last_failure = String::new();
let mut initialized = false;
// Also retain directories from unacknowledged replacements: JDT may have applied them.
let mut published = Vec::new();
loop {
tokio::select! {
_ = self.lifetime.cancelled() => return,
_ = self.changed.notified() => {},
}
let (generation, cancel) = {
let state = self.revision.lock();
(state.generation, state.cancel.clone())
};
if !self.trusted.load(Ordering::Acquire) {
continue;
}
if !debounce(&cancel, &self.lifetime).await {
continue;
}
let root = self.root.clone();
let cache = self.cache.clone();
let settings = self.settings.effective();
let has_sources = tokio::task::spawn_blocking(move || {
let model = crate::ops::build::load(&root, &cache, &settings);
let enabled =
model.maven.as_ref().is_some_and(|m| !m.projects.is_empty()) && kotlin_bridge::has_kotlin(&root);
(model, enabled)
})
.await;
let Ok((mut model, enabled)) = has_sources else { continue };
if !self.revision.lock().current(generation) {
continue;
}
if !enabled {
if self.cache.join("kotlin-bridge").exists()
&& let Err(error) = self.replace(&[], &cancel).await
{
if !cancel.is_cancelled() {
tracing::warn!("Kotlin library withdrawal: {error}");
}
continue;
}
for old in published.drain(..) {
remove_directory(old).await;
}
self.clear_diagnostics(&mut diagnostics);
last_failure.clear();
continue;
}
if let Some(maven) = &mut model.maven {
let options = self.options.lock();
for module in &mut maven.projects {
if options.offline {
module.options.push("-o".into());
}
if !options.profiles.is_empty() {
module.options.push(format!("-P{}", options.profiles.join(",")));
}
}
}
let Some(bundle) = self.bundle.clone() else {
self.failure("Kotlin bridge bundle is unavailable".into(), &mut diagnostics, &mut last_failure);
continue;
};
// Clear entries persisted by an earlier incarnation only before our first build.
if !initialized {
if let Err(error) = self.replace(&[], &cancel).await {
if !cancel.is_cancelled() {
self.failure(error.message, &mut diagnostics, &mut last_failure);
}
continue;
}
initialized = true;
}
let directory = self.cache.join(format!("kotlin-bridge/{}", uuid::Uuid::new_v4()));
let staging = directory.clone();
let root = self.root.clone();
let token = cancel.clone();
let result =
tokio::task::spawn_blocking(move || kotlin_bridge::build(&root, &model, &bundle, &staging, &token))
.await;
if !self.revision.lock().current(generation) || self.lifetime.is_cancelled() {
remove_directory(directory).await;
continue;
}
match result {
Ok(Ok(Outcome { success: true, projects, .. })) => {
match self.replace(&projects, &cancel).await {
Ok(()) => {
for old in published.drain(..) {
remove_directory(old).await;
}
published.push(directory);
self.clear_diagnostics(&mut diagnostics);
last_failure.clear();
}
Err(error) => {
// Keep both snapshots until an acknowledged replacement.
published.push(directory);
if !cancel.is_cancelled() {
self.failure(error.message, &mut diagnostics, &mut last_failure);
}
}
}
}
other => {
let text = match other {
Ok(Ok(outcome)) => outcome
.messages
.iter()
.filter(|m| m.severity == Severity::Error)
.map(|m| m.text.as_str())
.collect::<Vec<_>>()
.join("\n"),
Ok(Err(error)) => error.message,
Err(error) => error.to_string(),
};
self.failure(text, &mut diagnostics, &mut last_failure);
remove_directory(directory).await;
}
}
}
}
fn clear_diagnostics(&self, paths: &mut BTreeSet<PathBuf>) {
for path in std::mem::take(paths) {
self.sink.emit(IdeEvent::Diagnostics { path: path.into(), source: SOURCE.into(), items: Vec::new() });
}
}
fn failure(&self, text: String, paths: &mut BTreeSet<PathBuf>, previous: &mut String) {
let text = if text.is_empty() { "Kotlin compilation failed".to_owned() } else { text };
let text = format!("{text}\nJava temporarily uses the last successfully compiled Kotlin API.");
self.clear_diagnostics(paths);
let path = self.root.join("pom.xml");
paths.insert(path.clone());
self.sink.emit(IdeEvent::Diagnostics {
path: path.into(),
source: SOURCE.into(),
items: vec![Diagnostic {
range: Range { start: Pos { line: 0, col: 0 }, end: Pos { line: 0, col: 0 } },
severity: Severity::Error,
message: text.clone(),
source: Some(SOURCE.into()),
server: Some(SOURCE.into()),
code: None,
unnecessary: false,
deprecated: false,
}],
});
if previous != &text {
self.sink.emit(IdeEvent::Notify {
level: NotifyLevel::Error,
title: "Kotlin editor compilation failed".into(),
body: Some(text.clone()),
});
*previous = text;
}
}
}
fn relevant_change(root: &Path, path: &Path, settings: &ide_core::settings::Settings) -> bool {
let input = path.extension().is_some_and(|e| e == "kt")
|| path.file_name().is_some_and(|n| n == "pom.xml" || n == "mvnw" || n == "mvnw.cmd")
|| path.components().any(|c| c.as_os_str() == ".mvn");
let Ok(relative) = path.strip_prefix(root) else {
// Content roots outside the project only contribute Kotlin sources.
return path.extension().is_some_and(|e| e == "kt");
};
// Build outputs and generated sources (e.g. KSP's `.kt` under target/) never trigger,
// so a build cannot schedule itself.
if relative.components().any(|c| {
matches!(
c.as_os_str().to_str(),
Some("target" | "build" | "out" | "bin" | ".git" | "node_modules" | ".openidea")
)
}) || ide_vfs::ExcludeMatcher::new(&settings.exclude_globs()).is_excluded(relative)
{
return false;
}
if input {
return true;
}
if path.extension().is_some() {
return false;
}
// Works for deleted directories too; the module POM remains on disk.
path.ancestors().take_while(|ancestor| ancestor.starts_with(root)).any(|ancestor| {
ancestor.file_name().is_some_and(|name| name == "src")
&& ancestor.parent().is_some_and(|module| module.join("pom.xml").is_file())
})
}
async fn debounce(cancel: &CancellationToken, lifetime: &CancellationToken) -> bool {
tokio::select! {
_ = lifetime.cancelled() => false,
_ = cancel.cancelled() => false,
_ = tokio::time::sleep(DELAY) => true,
}
}
async fn remove_directory(path: PathBuf) {
let _ = tokio::task::spawn_blocking(move || std::fs::remove_dir_all(path)).await;
}
impl DocumentObserver for KotlinBridge {
fn saved(&self, _doc: ide_core::DocId, path: &Path, _text: &str) {
self.files_changed(std::iter::once(&path.to_path_buf()));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn target_package_directory_does_not_trigger() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("pom.xml"), "").unwrap();
assert!(!relevant_change(dir.path(), &dir.path().join("target/classes/probe"), &Default::default()));
let generated = dir.path().join("target/generated-sources/ksp/main/kotlin/Gen.kt");
assert!(!relevant_change(dir.path(), &generated, &Default::default()));
assert!(relevant_change(dir.path(), &dir.path().join("src/main/kotlin/A.kt"), &Default::default()));
}
#[test]
fn deleted_source_package_triggers_unless_excluded() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("module")).unwrap();
std::fs::write(dir.path().join("module/pom.xml"), "").unwrap();
let path = dir.path().join("module/src/main/kotlin/pkg");
let mut settings = ide_core::settings::Settings::default();
assert!(relevant_change(dir.path(), &path, &settings));
settings.project_structure.excluded.push("module/src/main/kotlin".into());
assert!(!relevant_change(dir.path(), &path, &settings));
assert!(!relevant_change(dir.path(), &dir.path().join("module/src/build/pkg"), &Default::default()));
}
#[test]
fn extensionless_root_file_does_not_trigger() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("pom.xml"), "").unwrap();
assert!(!relevant_change(dir.path(), &dir.path().join("LICENSE"), &Default::default()));
}
#[tokio::test(start_paused = true)]
async fn burst_saves_cancel_the_pending_debounce() {
let mut state = Revision::default();
state.change();
let token = state.cancel.clone();
let lifetime = CancellationToken::new();
let pending_lifetime = lifetime.clone();
let waiting = tokio::spawn(async move { debounce(&token, &pending_lifetime).await });
tokio::task::yield_now().await;
tokio::time::advance(DELAY - Duration::from_millis(1)).await;
assert!(!waiting.is_finished());
for _ in 0..20 {
state.change();
}
assert!(!waiting.await.unwrap());
let current = state.generation;
assert!(debounce(&state.cancel, &lifetime).await);
assert!(state.current(current));
lifetime.cancel();
assert!(!debounce(&state.cancel, &lifetime).await);
}
#[test]
fn superseded_and_cancelled_builds_cannot_publish() {
let mut state = Revision::default();
state.change();
let first = state.generation;
let token = state.cancel.clone();
state.change();
assert!(token.is_cancelled());
assert!(!state.current(first));
assert!(state.current(state.generation));
state.cancel.cancel();
assert!(!state.current(state.generation));
}
}
+104 -4
View File
@@ -4,12 +4,17 @@
//! runs one on a remote machine and serves it over JSON-RPC ([`rpc`]).
pub mod environment;
mod formatter;
mod java_runtime;
mod kotlin_bridge;
pub mod notebook;
pub mod ops;
mod project;
mod proxy;
mod requests;
pub mod rpc;
pub mod shell_env;
pub mod trust;
pub use project::{Project, apply_git_executable, apply_workspace_edit, history_retention};
@@ -42,6 +47,7 @@ pub struct HostConfig {
}
pub struct Host {
pub trust: trust::TrustStore,
pub config_dir: PathBuf,
pub data_dir: PathBuf,
pub languages: Arc<LanguageRegistry>,
@@ -65,6 +71,7 @@ pub struct Host {
/// One project per window, keyed by window label.
projects: RwLock<HashMap<String, Arc<Project>>>,
requests: requests::Requests,
project_requests: RwLock<HashMap<String, Vec<RequestId>>>,
}
impl Host {
@@ -75,6 +82,7 @@ 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 {
trust: trust::TrustStore::load(&config_dir, &data_dir),
proxy_password: ide_ssh::secrets::SecretFile::new(data_dir.join("proxy-password.json")),
remote: Arc::new(ide_remote::RemoteService::new(ssh.clone(), data_dir.join("remote"))),
ssh,
@@ -93,6 +101,7 @@ impl Host {
runtime,
projects: RwLock::default(),
requests: Default::default(),
project_requests: RwLock::default(),
};
host.apply_proxy();
host
@@ -117,6 +126,10 @@ impl Host {
.ok_or_else(|| IdeError::not_found("no project is open in this window"))
}
pub fn require_trust(&self, window: &str) -> IdeResult<()> {
self.project(window)?.require_trust()
}
/// The window that owns what `window` starts.
pub fn owner_window<'a>(&self, window: &'a str) -> &'a str {
(self.owner)(window)
@@ -132,26 +145,101 @@ impl Host {
}
pub fn close_project(&self, window: &str) {
if let Some(project) = self.projects.write().remove(window) {
let project = self.projects.write().remove(window);
if let Some(project) = project {
if !project.light {
self.environment.project_closed();
}
if let Some(bridge) = &project.kotlin_bridge {
bridge.stop();
}
project.agents.shutdown();
project.notebooks.stop_admission();
self.shutdown_project_workers_blocking(
vec![project.notebooks.clone()],
project.kotlin_bridge.iter().cloned().collect(),
);
let lsp = project.lsp.clone();
self.runtime.spawn(async move { lsp.shutdown_all().await });
}
}
/// Stops what this host started: language servers, agents, runs and terminals.
/// Synchronous project/window destruction must finish owned kernel cleanup,
/// not enqueue work which can be lost as the desktop runtime exits.
fn shutdown_project_workers_blocking(
&self,
notebooks: Vec<Arc<notebook::NotebookService>>,
bridges: Vec<Arc<kotlin_bridge::KotlinBridge>>,
) {
for bridge in &bridges {
bridge.stop();
}
for service in &notebooks {
service.stop_admission();
}
let runtime = self.runtime.clone();
let wait = move || {
let worker = std::thread::spawn(move || {
runtime.block_on(async move {
for bridge in bridges {
bridge.shutdown().await;
}
for service in notebooks {
service.shutdown().await;
}
});
});
if let Err(error) = worker.join() {
tracing::error!("notebook shutdown thread failed: {error:?}");
}
};
if tokio::runtime::Handle::try_current()
.is_ok_and(|handle| handle.runtime_flavor() == tokio::runtime::RuntimeFlavor::MultiThread)
{
tokio::task::block_in_place(wait);
} else {
wait();
}
}
/// Called before the desktop runtime exits, including update/restart exits.
pub fn shutdown_notebooks_on_exit(&self) {
let projects = self.projects();
self.shutdown_project_workers_blocking(
projects.iter().map(|project| project.notebooks.clone()).collect(),
projects.iter().filter_map(|project| project.kotlin_bridge.clone()).collect(),
);
}
/// Stops what this host started: notebook kernels, language servers, agents, runs and terminals.
/// For a process that exits without dropping the host (the remote server).
pub async fn shutdown(&self) {
self.environment.shutdown();
self.processes.kill_all();
self.terminals.kill_all();
let projects: Vec<_> = self.projects.write().drain().map(|(_, p)| p).collect();
for project in projects {
for project in &projects {
if let Some(bridge) = &project.kotlin_bridge {
bridge.stop();
}
project.notebooks.stop_admission();
project.agents.shutdown();
project.lsp.shutdown_all().await;
}
// Kernel cleanup owns its interrupt/termination/reaping deadlines. An
// outer LSP timeout must never abort it before owned children are reaped.
for project in &projects {
if let Some(bridge) = &project.kotlin_bridge {
bridge.shutdown().await;
}
project.notebooks.shutdown().await;
}
let stop_language_servers = async {
for project in &projects {
project.lsp.shutdown_all().await;
}
};
if tokio::time::timeout(std::time::Duration::from_secs(10), stop_language_servers).await.is_err() {
tracing::warn!("language servers did not shut down in time");
}
}
@@ -161,7 +249,19 @@ impl Host {
self.requests.begin(id)
}
pub fn begin_project_request(&self, window: &str, id: RequestId) -> CancellationToken {
let token = self.begin_request(id);
self.project_requests.write().entry(self.owner_window(window).to_owned()).or_default().push(id);
if self.project(window).is_ok_and(|p| !p.is_trusted()) {
token.cancel();
}
token
}
pub fn end_request(&self, id: RequestId) {
for ids in self.project_requests.write().values_mut() {
ids.retain(|entry| *entry != id);
}
self.requests.end(id);
}
+814
View File
@@ -0,0 +1,814 @@
//! Project-incarnation notebook ownership and whole-batch execution admission.
use crate::ops::Channel;
use ide_core::error::ErrorKind;
use ide_core::{CancellationToken, IdeError, IdeEvent, IdeResult, SharedSink};
use ide_notebook::kernel::{KernelManager, KernelOutput};
use ide_notebook::{NotebookDocument, NotebookEdit, NotebookSnapshot};
use parking_lot::Mutex;
use serde::Serialize;
use specta::Type;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{Mutex as AsyncMutex, watch};
#[derive(Clone, Debug, Serialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct NotebookOutputEvent {
pub id: String,
pub path: String,
pub cell: String,
pub msg_id: String,
pub kind: String,
#[specta(type = specta_typescript::Unknown)]
pub content: serde_json::Value,
pub version: u32,
pub dirty: bool,
pub changed_cells: Vec<String>,
}
#[derive(Default)]
struct Registry {
stopped: bool,
paths: HashMap<PathBuf, String>,
documents: HashMap<String, Arc<ManagedDocument>>,
}
struct ManagedDocument {
id: String,
path: PathBuf,
state: Mutex<DocumentState>,
control: AsyncMutex<()>,
}
struct DocumentState {
document: NotebookDocument,
generation: u64,
transitioning: bool,
closed: bool,
active: Option<Arc<Batch>>,
kernel_status: Option<(String, Option<String>)>,
}
struct Batch {
cancel: CancellationToken,
done: watch::Sender<bool>,
}
struct BatchGuard {
document: Arc<ManagedDocument>,
batch: Arc<Batch>,
}
pub(crate) struct ExecutionAdmission {
guard: BatchGuard,
generation: u64,
plan: Vec<(String, String)>,
}
impl Drop for BatchGuard {
fn drop(&mut self) {
let mut state = self.document.state.lock();
if state.active.as_ref().is_some_and(|active| Arc::ptr_eq(active, &self.batch)) {
if let Err(error) = state.document.set_execution_active(false) {
tracing::error!("notebook batch release: {error}");
}
state.active = None;
}
self.batch.done.send_replace(true);
}
}
pub struct NotebookService {
registry: Arc<Mutex<Registry>>,
kernels: Arc<KernelManager>,
sink: SharedSink,
}
impl NotebookService {
pub fn new(runtime_dir: PathBuf, sink: SharedSink) -> Arc<Self> {
let registry = Arc::new(Mutex::new(Registry::default()));
let status_registry = registry.clone();
let status_sink = sink.clone();
let kernels = KernelManager::new(
runtime_dir,
Arc::new(move |path, status, message| {
let document = {
let registry = status_registry.lock();
if registry.stopped {
return;
}
registry.paths.get(path).and_then(|id| registry.documents.get(id)).cloned()
};
if let Some(document) = document {
let deliver = {
let mut state = document.state.lock();
if state.closed {
return;
}
state.kernel_status = Some((status.as_str().to_owned(), message.clone()));
!state.transitioning
};
if deliver {
status_sink.emit(IdeEvent::NotebookKernel {
id: document.id.clone(),
path: path.to_path_buf().into(),
state: status.as_str().to_owned(),
message,
});
}
}
}),
);
Arc::new(Self { registry, kernels: Arc::new(kernels), sink })
}
pub fn open(&self, path: &Path) -> IdeResult<NotebookSnapshot> {
let path = dunce::canonicalize(path)?;
// Serialize first load with path insertion: split views share one incarnation.
let mut registry = self.registry.lock();
if registry.stopped {
return Err(IdeError::cancelled());
}
if let Some(id) = registry.paths.get(&path) {
return snapshot(&registry.documents[id]);
}
let document = NotebookDocument::load(&path)?;
let initial = document.snapshot();
let id = initial.id.clone();
registry.paths.insert(path.clone(), id.clone());
registry.documents.insert(
id.clone(),
Arc::new(ManagedDocument {
id,
path,
state: Mutex::new(DocumentState {
document,
generation: 0,
transitioning: false,
closed: false,
active: None,
kernel_status: None,
}),
control: AsyncMutex::new(()),
}),
);
Ok(initial)
}
fn resolve(&self, id: &str) -> IdeResult<Arc<ManagedDocument>> {
let registry = self.registry.lock();
if registry.stopped {
return Err(IdeError::not_found("notebook project has closed"));
}
registry.documents.get(id).cloned().ok_or_else(|| IdeError::not_found("notebook incarnation is closed"))
}
pub fn get(&self, id: &str) -> IdeResult<NotebookSnapshot> {
snapshot(self.resolve(id)?.as_ref())
}
pub fn edit(&self, id: &str, version: u32, edit: NotebookEdit) -> IdeResult<NotebookSnapshot> {
let document = self.resolve(id)?;
let mut state = document.state.lock();
mutable(&state)?;
state.document.edit(version, edit)
}
pub fn save(&self, id: &str) -> IdeResult<NotebookSnapshot> {
let document = self.resolve(id)?;
let mut state = document.state.lock();
mutable(&state)?;
state.document.save()
}
pub fn undo(&self, id: &str, redo: bool) -> IdeResult<NotebookSnapshot> {
let document = self.resolve(id)?;
let mut state = document.state.lock();
mutable(&state)?;
if state.active.is_some() {
return Err(IdeError::unavailable("stop notebook execution before undo or redo"));
}
if redo { state.document.redo() } else { state.document.undo() }
}
pub async fn interrupt(&self, id: &str) -> IdeResult<()> {
let document = self.resolve(id)?;
let batch = document.state.lock().active.clone();
if let Some(batch) = &batch {
batch.cancel.cancel();
}
// Draining makes prepareClose/save a stable checkpoint, including when
// Python catches KeyboardInterrupt or cancel races with kernel startup.
let interrupt = self.kernels.interrupt(&document.path).await;
self.drain(&document, batch).await?;
interrupt
}
async fn drain(&self, document: &ManagedDocument, batch: Option<Arc<Batch>>) -> IdeResult<()> {
let Some(batch) = batch else { return Ok(()) };
let mut done = batch.done.subscribe();
match tokio::time::timeout(Duration::from_secs(5), done.wait_for(|done| *done)).await {
Ok(Ok(_)) => return Ok(()),
Ok(Err(_)) => return Err(IdeError::internal("notebook execution completion channel closed")),
Err(_) => {}
}
// An uncooperative kernel must not outlive a close/reload authorization.
self.kernels.shutdown_kernel(&document.path).await?;
tokio::time::timeout(Duration::from_secs(5), done.wait_for(|done| *done))
.await
.map_err(|_| IdeError::unavailable("notebook execution did not stop after kernel shutdown"))?
.map_err(|_| IdeError::internal("notebook execution completion channel closed"))?;
Ok(())
}
pub async fn reload(&self, id: &str, restart: bool) -> IdeResult<NotebookSnapshot> {
let document = self.resolve(id)?;
let _control = document.control.lock().await;
let batch = invalidate(&document)?;
let result = async {
let _ = self.kernels.interrupt(&document.path).await;
self.drain(&document, batch).await?;
if document.state.lock().closed {
return Err(IdeError::cancelled());
}
if restart {
self.kernels.restart(&document.path).await?;
snapshot(&document)
} else {
self.kernels.shutdown_kernel(&document.path).await?;
document.state.lock().document.reload()
}
}
.await;
let status = {
let mut state = document.state.lock();
state.transitioning = false;
if state.closed { None } else { state.kernel_status.clone() }
};
if let Some((state, message)) = status {
self.sink.emit(IdeEvent::NotebookKernel {
id: document.id.clone(),
path: document.path.clone().into(),
state,
message,
});
}
result
}
/// Frontend disposal may arrive after project replacement has already retired this id.
pub async fn close(&self, id: &str) -> IdeResult<()> {
let document = match self.resolve(id) {
Ok(document) => document,
Err(error) if error.kind == ErrorKind::NotFound => return Ok(()),
Err(error) => return Err(error),
};
let _control = document.control.lock().await;
if document.state.lock().closed {
return Ok(());
}
let batch = invalidate(&document)?;
let result = async {
let _ = self.kernels.interrupt(&document.path).await;
self.drain(&document, batch).await?;
self.kernels.shutdown_kernel(&document.path).await
}
.await;
if let Err(error) = result {
document.state.lock().transitioning = false;
return Err(error);
}
document.state.lock().closed = true;
let mut registry = self.registry.lock();
registry.documents.remove(id);
registry.paths.remove(&document.path);
Ok(())
}
pub(crate) fn admit(
&self,
id: &str,
cells: Vec<String>,
cancel: CancellationToken,
) -> IdeResult<ExecutionAdmission> {
admit(self.resolve(id)?, cells, cancel)
}
pub(crate) async fn execute(
self: &Arc<Self>,
admission: ExecutionAdmission,
python: PathBuf,
on_output: Channel<NotebookOutputEvent>,
) -> IdeResult<NotebookSnapshot> {
let document = admission.guard.document.clone();
let service = self.clone();
// Keep admission and native execution owned if an IPC receiver disappears.
tokio::spawn(async move {
let ExecutionAdmission { guard, generation, plan } = admission;
let result =
service.run_batch(document.clone(), generation, plan, python, guard.batch.clone(), on_output).await;
drop(guard);
match result {
Ok(()) => snapshot(&document),
Err(error) => Err(error),
}
})
.await
.map_err(|error| IdeError::internal(error.to_string()))?
}
async fn run_batch(
&self,
document: Arc<ManagedDocument>,
generation: u64,
plan: Vec<(String, String)>,
python: PathBuf,
batch: Arc<Batch>,
on_output: Channel<NotebookOutputEvent>,
) -> IdeResult<()> {
for (cell, source) in plan {
{
let mut state = document.state.lock();
if batch.cancel.is_cancelled() || state.closed || state.generation != generation {
return Err(IdeError::cancelled());
}
// A user may delete a queued cell while an earlier one executes.
// Never resurrect it from the captured source or a late output.
match state.document.begin_execution(&cell) {
Ok(()) => {}
Err(error) if error.kind == ErrorKind::NotFound => continue,
Err(error) => return Err(error),
}
}
let output_error = Arc::new(Mutex::new(None));
let callback_document = document.clone();
let callback_batch = batch.clone();
let callback_error = output_error.clone();
let callback_cell = cell.clone();
let channel = on_output.clone();
let result = self
.kernels
.execute(
&document.path,
&python,
&source,
batch.cancel.clone(),
Arc::new(move |output: KernelOutput| {
let event = match commit_output(
&callback_document,
&callback_batch,
generation,
&callback_cell,
output,
) {
Ok(Some(event)) => event,
Ok(None) => return,
Err(error) => {
*callback_error.lock() = Some(error);
callback_batch.cancel.cancel();
return;
}
};
let _ = channel.send(event);
}),
)
.await;
if let Some(error) = output_error.lock().take() {
return Err(error);
}
let result = result?;
if batch.cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
if result.status != "ok" {
break;
}
}
let state = document.state.lock();
if state.closed || state.generation != generation || batch.cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
Ok(())
}
/// Synchronously revoke admission before handing cleanup to an async runtime.
pub fn stop_admission(&self) {
let mut registry = self.registry.lock();
registry.stopped = true;
for document in registry.documents.values() {
let mut state = document.state.lock();
state.closed = true;
state.generation += 1;
if let Some(batch) = &state.active {
batch.cancel.cancel();
}
}
}
pub async fn restrict(&self) {
let documents: Vec<_> = self.registry.lock().documents.values().cloned().collect();
for document in &documents {
let mut state = document.state.lock();
state.generation += 1;
if let Some(batch) = &state.active {
batch.cancel.cancel();
}
}
for document in documents {
let _ = self.kernels.shutdown_kernel(&document.path).await;
let batch = document.state.lock().active.clone();
if let Err(error) = self.drain(&document, batch).await {
tracing::warn!("notebook restriction: {error}");
}
}
}
pub async fn shutdown(&self) {
self.stop_admission();
self.kernels.shutdown().await;
let documents: Vec<_> = self.registry.lock().documents.values().cloned().collect();
for document in documents {
let batch = document.state.lock().active.clone();
if let Err(error) = self.drain(&document, batch).await {
tracing::error!("notebook cleanup: {error}");
}
}
let mut registry = self.registry.lock();
registry.paths.clear();
registry.documents.clear();
}
}
fn mutable(state: &DocumentState) -> IdeResult<()> {
if state.closed {
Err(IdeError::not_found("notebook incarnation is closed"))
} else if state.transitioning {
Err(IdeError::unavailable("notebook lifecycle operation is in progress"))
} else {
Ok(())
}
}
fn snapshot(document: &ManagedDocument) -> IdeResult<NotebookSnapshot> {
let state = document.state.lock();
if state.closed {
return Err(IdeError::not_found("notebook incarnation is closed"));
}
Ok(state.document.snapshot())
}
fn invalidate(document: &ManagedDocument) -> IdeResult<Option<Arc<Batch>>> {
let mut state = document.state.lock();
mutable(&state)?;
state.transitioning = true;
state.generation += 1;
if let Some(batch) = &state.active {
batch.cancel.cancel();
}
Ok(state.active.clone())
}
fn execution_plan(snapshot: &NotebookSnapshot, cells: Vec<String>) -> IdeResult<Vec<(String, String)>> {
let mut selected: HashSet<_> = cells.iter().map(String::as_str).collect();
if selected.is_empty() || selected.len() != cells.len() {
return Err(IdeError::invalid("select distinct code cells to execute"));
}
let mut plan = Vec::with_capacity(selected.len());
for cell in &snapshot.cells {
if selected.remove(cell.id.as_str()) {
if cell.cell_type != "code" {
return Err(IdeError::invalid("only code cells can execute"));
}
plan.push((cell.id.clone(), cell.source.clone()));
}
}
if !selected.is_empty() {
return Err(IdeError::not_found("an execution cell no longer exists"));
}
Ok(plan)
}
fn admit(
document: Arc<ManagedDocument>,
cells: Vec<String>,
cancel: CancellationToken,
) -> IdeResult<ExecutionAdmission> {
let (batch, generation, plan) = {
let mut state = document.state.lock();
mutable(&state)?;
if state.active.is_some() {
return Err(IdeError::unavailable("notebook execution batch is already running"));
}
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let plan = execution_plan(&state.document.snapshot(), cells)?;
state.document.set_execution_active(true)?;
let (done, _) = watch::channel(false);
let batch = Arc::new(Batch { cancel, done });
state.active = Some(batch.clone());
(batch, state.generation, plan)
};
Ok(ExecutionAdmission { guard: BatchGuard { document, batch }, generation, plan })
}
/// Commit under the short document lock; the caller delivers only after release.
fn commit_output(
document: &ManagedDocument,
batch: &Arc<Batch>,
generation: u64,
cell: &str,
output: KernelOutput,
) -> IdeResult<Option<NotebookOutputEvent>> {
let mut state = document.state.lock();
if state.closed
|| state.generation != generation
|| !state.active.as_ref().is_some_and(|active| Arc::ptr_eq(active, batch))
{
return Ok(None);
}
let changed_cells = match state.document.record_output(cell, &output) {
Ok(changed) => changed,
Err(error) if error.kind == ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(error),
};
Ok(Some(NotebookOutputEvent {
id: document.id.clone(),
path: document.path.to_string_lossy().into_owned(),
cell: cell.to_owned(),
msg_id: output.msg_id,
kind: output.kind,
content: output.content,
version: state.document.version(),
dirty: state.document.is_dirty(),
changed_cells,
}))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn fixture() -> (tempfile::TempDir, Arc<NotebookService>, NotebookSnapshot) {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("session.ipynb");
std::fs::write(&path, serde_json::to_vec(&json!({
"nbformat": 4, "nbformat_minor": 5, "metadata": {},
"cells": [
{"id":"first","cell_type":"code","metadata":{},"source":"x = 1","outputs":[],"execution_count":null},
{"id":"second","cell_type":"code","metadata":{},"source":"print(x)","outputs":[],"execution_count":null},
{"id":"prose","cell_type":"markdown","metadata":{},"source":"notes"}
]
})).unwrap()).unwrap();
let service = NotebookService::new(dir.path().join("kernels"), Arc::new(ide_core::NullSink));
let snapshot = service.open(&path).unwrap();
(dir, service, snapshot)
}
fn output(kind: &str, content: serde_json::Value) -> KernelOutput {
KernelOutput { kind: kind.into(), content, msg_id: "actual-parent-request".into() }
}
#[tokio::test]
async fn canonical_split_opens_share_incarnation_but_reopen_and_projects_do_not() {
let (dir, service, initial) = fixture();
let alternate = dir.path().join(".").join("session.ipynb");
assert_eq!(service.open(&alternate).unwrap().id, initial.id);
let other = NotebookService::new(dir.path().join("other-runtime"), Arc::new(ide_core::NullSink));
let independent = other.open(&alternate).unwrap();
assert_ne!(initial.id, independent.id);
assert_eq!(other.get(&initial.id).unwrap_err().kind, ErrorKind::NotFound);
service.close(&initial.id).await.unwrap();
let reopened = service.open(&alternate).unwrap();
assert_ne!(reopened.id, initial.id);
assert_eq!(service.get(&initial.id).unwrap_err().kind, ErrorKind::NotFound);
// A delayed old-incarnation close cannot dispose a new session at that path.
service.close(&initial.id).await.unwrap();
assert_eq!(service.get(&reopened.id).unwrap().id, reopened.id);
service.shutdown().await;
other.shutdown().await;
}
#[tokio::test(start_paused = true)]
async fn failed_close_keeps_the_incarnation_recoverable_until_a_retry_succeeds() {
let (dir, service, initial) = fixture();
// A batch that never drains models a kernel that ignores interrupt and shutdown.
let admission =
service.admit(&initial.id, vec![initial.cells[0].id.clone()], CancellationToken::new()).unwrap();
let error = service.close(&initial.id).await.unwrap_err();
assert_eq!(error.kind, ErrorKind::Unavailable);
assert!(admission.guard.batch.cancel.is_cancelled());
// The backend still owns the incarnation: a reopen shares it instead of
// starting a second kernel, and the failed close left it usable.
let reopened = service.open(&dir.path().join("session.ipynb")).unwrap();
assert_eq!(reopened.id, initial.id);
assert!(matches!(service.admit(&initial.id, vec![initial.cells[0].id.clone()], CancellationToken::new()),
Err(error) if error.kind == ErrorKind::Unavailable));
drop(admission);
service.close(&initial.id).await.unwrap();
assert_eq!(service.get(&initial.id).unwrap_err().kind, ErrorKind::NotFound);
assert_ne!(service.open(&dir.path().join("session.ipynb")).unwrap().id, initial.id);
service.shutdown().await;
}
#[test]
fn batch_admission_captures_document_order_and_retains_busy_until_drained() {
let (_dir, service, initial) = fixture();
let document = service.resolve(&initial.id).unwrap();
let cells = vec![initial.cells[1].id.clone(), initial.cells[0].id.clone()];
let admission = admit(document.clone(), cells.clone(), CancellationToken::new()).unwrap();
assert_eq!(
admission.plan,
vec![(initial.cells[0].id.clone(), "x = 1".into()), (initial.cells[1].id.clone(), "print(x)".into()),]
);
service
.edit(
&initial.id,
initial.version,
NotebookEdit::SetSource { cell: initial.cells[1].id.clone(), source: "print('new source')".into() },
)
.unwrap();
assert_eq!(admission.plan[1].1, "print(x)");
assert_eq!(service.undo(&initial.id, false).unwrap_err().kind, ErrorKind::Unavailable);
admission.guard.batch.cancel.cancel();
assert!(matches!(admit(document.clone(), cells.clone(), CancellationToken::new()), Err(error)
if error.kind == ErrorKind::Unavailable));
drop(admission);
let next = admit(document, cells, CancellationToken::new()).unwrap();
assert_eq!(next.plan[1].1, "print('new source')");
}
#[test]
fn output_commits_revision_and_cross_cell_display_updates_before_delivery() {
let (_dir, service, initial) = fixture();
let document = service.resolve(&initial.id).unwrap();
let cells: Vec<_> = initial.cells[..2].iter().map(|cell| cell.id.clone()).collect();
let admission = admit(document.clone(), cells.clone(), CancellationToken::new()).unwrap();
let event = commit_output(
&document,
&admission.guard.batch,
admission.generation,
&cells[0],
output(
"display_data",
json!({"data":{"text/plain":"before"},"metadata":{},"transient":{"display_id":"shared"}}),
),
)
.unwrap()
.unwrap();
let committed = service.get(&initial.id).unwrap();
assert_eq!(event.version, committed.version);
assert!(event.version > initial.version);
assert!(event.dirty);
assert_eq!(event.msg_id, "actual-parent-request");
assert_eq!(committed.cells[0].outputs[0]["data"]["text/plain"], "before");
let updated = commit_output(
&document,
&admission.guard.batch,
admission.generation,
&cells[1],
output(
"update_display_data",
json!({"data":{"text/plain":"after"},"metadata":{},"transient":{"display_id":"shared"}}),
),
)
.unwrap()
.unwrap();
assert!(updated.changed_cells.contains(&cells[0]));
let committed = service.get(&initial.id).unwrap();
assert_eq!(committed.cells[0].outputs[0]["data"]["text/plain"], "after");
assert!(committed.cells[0].outputs[0].get("transient").is_none());
assert_eq!(updated.version, committed.version);
}
#[test]
fn invalidated_or_completed_batches_cannot_commit_and_deleted_cells_stay_deleted() {
let (_dir, service, initial) = fixture();
let document = service.resolve(&initial.id).unwrap();
let cell = initial.cells[0].id.clone();
let admission = admit(document.clone(), vec![cell.clone()], CancellationToken::new()).unwrap();
let deleted =
service.edit(&initial.id, initial.version, NotebookEdit::DeleteCell { cell: cell.clone() }).unwrap();
assert!(
commit_output(
&document,
&admission.guard.batch,
admission.generation,
&cell,
output("stream", json!({"name":"stdout","text":"late"}))
)
.unwrap()
.is_none()
);
assert_eq!(service.get(&initial.id).unwrap().version, deleted.version);
let other = initial.cells[1].id.clone();
invalidate(&document).unwrap();
assert!(admission.guard.batch.cancel.is_cancelled());
assert!(
commit_output(
&document,
&admission.guard.batch,
admission.generation,
&other,
output("stream", json!({"name":"stdout","text":"old generation"}))
)
.unwrap()
.is_none()
);
let batch = admission.guard.batch.clone();
let generation = document.state.lock().generation;
drop(admission);
assert!(
commit_output(
&document,
&batch,
generation,
&other,
output("stream", json!({"name":"stdout","text":"post idle"}))
)
.unwrap()
.is_none()
);
assert_eq!(service.get(&initial.id).unwrap().version, deleted.version);
}
#[tokio::test]
async fn reload_preserves_incarnation_and_project_shutdown_revokes_all_admission() {
let (_dir, service, initial) = fixture();
let changed = service
.edit(
&initial.id,
initial.version,
NotebookEdit::SetSource { cell: initial.cells[0].id.clone(), source: "changed".into() },
)
.unwrap();
let reloaded = service.reload(&initial.id, false).await.unwrap();
assert_eq!(reloaded.id, initial.id);
assert!(reloaded.version > changed.version);
assert_eq!(reloaded.cells[0].source, "x = 1");
assert!(!reloaded.dirty);
let document = service.resolve(&initial.id).unwrap();
let admission = admit(document.clone(), vec![initial.cells[0].id.clone()], CancellationToken::new()).unwrap();
service.stop_admission();
assert!(admission.guard.batch.cancel.is_cancelled());
assert_eq!(service.get(&initial.id).unwrap_err().kind, ErrorKind::NotFound);
assert!(matches!(admit(document, vec![initial.cells[0].id.clone()], CancellationToken::new()), Err(error)
if error.kind == ErrorKind::NotFound));
drop(admission);
service.shutdown().await;
}
#[test]
fn invalid_batch_selection_does_not_claim_admission() {
let (_dir, service, initial) = fixture();
let document = service.resolve(&initial.id).unwrap();
for cells in [
vec![],
vec![initial.cells[0].id.clone(), initial.cells[0].id.clone()],
vec![initial.cells[2].id.clone()],
vec!["deleted".into()],
] {
assert!(admit(document.clone(), cells, CancellationToken::new()).is_err());
}
let cancelled = CancellationToken::new();
cancelled.cancel();
assert!(matches!(admit(document.clone(), vec![initial.cells[0].id.clone()], cancelled), Err(error)
if error.kind == ErrorKind::Cancelled));
let valid = admit(document, vec![initial.cells[0].id.clone()], CancellationToken::new()).unwrap();
assert_eq!(valid.plan[0].1, "x = 1");
}
#[tokio::test]
async fn interrupt_cancels_before_drain_and_does_not_release_a_live_batch() {
let (_dir, service, initial) = fixture();
service
.edit(
&initial.id,
initial.version,
NotebookEdit::SetSource { cell: initial.cells[0].id.clone(), source: "changed source".into() },
)
.unwrap();
let cancel = CancellationToken::new();
let admission = service.admit(&initial.id, vec![initial.cells[0].id.clone()], cancel.clone()).unwrap();
let interrupt_service = service.clone();
let id = initial.id.clone();
let interrupt = tokio::spawn(async move { interrupt_service.interrupt(&id).await });
tokio::time::timeout(Duration::from_secs(1), cancel.cancelled()).await.unwrap();
assert!(!interrupt.is_finished(), "interrupt must wait for owned execution to drain");
assert!(matches!(service.admit(&initial.id, vec![initial.cells[0].id.clone()], CancellationToken::new()),
Err(error) if error.kind == ErrorKind::Unavailable));
let mut done = admission.guard.batch.done.subscribe();
drop(admission);
assert!(*done.borrow_and_update());
tokio::time::timeout(Duration::from_secs(1), interrupt).await.unwrap().unwrap().unwrap();
let next = service.admit(&initial.id, vec![initial.cells[0].id.clone()], CancellationToken::new()).unwrap();
assert_eq!(next.plan[0].1, "changed source");
drop(next);
let undone = service.undo(&initial.id, false).unwrap();
assert_eq!(undone.cells[0].source, "x = 1");
service.close(&initial.id).await.unwrap();
}
}
+65 -2
View File
@@ -17,6 +17,7 @@ pub fn subscribe(host: &Host, window: &str, on_event: Emit<AgentEvent>) -> IdeRe
}
pub fn providers(host: &Host, window: &str) -> IdeResult<Vec<AgentProvider>> {
host.require_trust(window)?;
let mut providers = host.project(window)?.agents.providers();
ide_agent::registry::mark_updates(&registry_paths(host), &mut providers);
Ok(providers)
@@ -87,12 +88,20 @@ pub fn entries(host: &Host, window: &str, task: String, subagent: Option<String>
host.project(window)?.agents.entries(&task, subagent)
}
pub async fn create(host: &Host, window: &str, request: NewAgentTask) -> IdeResult<AgentTask> {
host.project(window)?.agents.create(request).await
pub async fn create(host: &Host, window: &str, mut request: NewAgentTask) -> IdeResult<AgentTask> {
host.require_trust(window)?;
let project = host.project(window)?;
let root = project.root.clone();
let requested = request.project_root.take();
request.project_root = Some(IpcPath(
host.blocking(move || validate_project_root(&root, requested.as_ref().map(|p| p.0.as_path()))).await?,
));
project.agents.create(request).await
}
/// Sends a message; while the agent works it is queued, or with `now` it interrupts the turn.
pub async fn prompt(host: &Host, window: &str, task: String, prompt: AgentPrompt, now: bool) -> IdeResult<()> {
host.require_trust(window)?;
host.project(window)?.agents.prompt(&task, prompt, now).await
}
@@ -103,26 +112,31 @@ pub fn unqueue(host: &Host, window: &str, task: String, queued: String) -> IdeRe
/// Sends a queued message now, interrupting the current turn.
pub fn send_queued_now(host: &Host, window: &str, task: String, queued: String) -> IdeResult<()> {
host.require_trust(window)?;
host.project(window)?.agents.send_queued_now(&task, &queued)
}
/// Changes a session setting (model, mode, thinking effort…).
pub async fn set_config(host: &Host, window: &str, task: String, option: String, value: String) -> IdeResult<()> {
host.require_trust(window)?;
host.project(window)?.agents.set_config(&task, &option, value).await
}
/// Copies the task's conversation into a new task, up to its first `turns` turns (all when `None`).
pub async fn fork(host: &Host, window: &str, task: String, turns: Option<u32>) -> IdeResult<AgentTask> {
host.require_trust(window)?;
host.project(window)?.agents.fork(&task, turns).await
}
/// Drops the task's turns after its first `turns`; returns the message of the first dropped one.
pub async fn rollback(host: &Host, window: &str, task: String, turns: u32) -> IdeResult<Option<AgentPrompt>> {
host.require_trust(window)?;
host.project(window)?.agents.rollback(&task, turns).await
}
/// What the agent reports about itself when it starts, without opening a session.
pub async fn probe(host: &Host, window: &str, provider: String, refresh: bool) -> IdeResult<AgentProbe> {
host.require_trust(window)?;
host.project(window)?.agents.probe(&provider, refresh).await
}
@@ -133,6 +147,7 @@ pub fn cancel(host: &Host, window: &str, task: String, subagent: Option<String>)
/// Answers a permission request; `option: None` cancels it.
pub fn respond(host: &Host, window: &str, task: String, permission: u32, option: Option<String>) -> IdeResult<()> {
host.require_trust(window)?;
host.project(window)?.agents.respond(&task, permission, option)
}
@@ -140,16 +155,23 @@ pub fn archive(host: &Host, window: &str, task: String) -> IdeResult<()> {
host.project(window)?.agents.archive(&task)
}
pub fn unarchive(host: &Host, window: &str, task: String) -> IdeResult<()> {
host.project(window)?.agents.unarchive(&task)
}
pub async fn delete(host: &Host, window: &str, task: String, remove_worktree: bool) -> IdeResult<()> {
host.require_trust(window)?;
host.project(window)?.agents.delete(&task, remove_worktree).await
}
pub async fn changes(host: &Host, window: &str, task: String) -> IdeResult<Vec<AgentChange>> {
host.require_trust(window)?;
host.project(window)?.agents.changes(&task).await
}
/// Content of `path` when the task started (left side of its diff).
pub async fn base_text(host: &Host, window: &str, task: String, path: IpcPath) -> IdeResult<Option<String>> {
host.require_trust(window)?;
host.project(window)?.agents.base_text(&task, path.into_path_buf()).await
}
@@ -183,6 +205,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("agent_respond", |c| async move {
respond(&c.host, &c.window, c.arg("task")?, c.arg("permission")?, c.arg("option")?)
});
d.add("agent_unarchive", |c| async move { unarchive(&c.host, &c.window, c.arg("task")?) });
d.add("agent_archive", |c| async move { archive(&c.host, &c.window, c.arg("task")?) });
d.add(
"agent_delete",
@@ -191,3 +214,43 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("agent_changes", |c| async move { changes(&c.host, &c.window, c.arg("task")?).await });
d.add("agent_base_text", |c| async move { base_text(&c.host, &c.window, c.arg("task")?, c.arg("path")?).await });
}
fn validate_project_root(root: &std::path::Path, requested: Option<&std::path::Path>) -> IdeResult<std::path::PathBuf> {
let Some(requested) = requested else {
return Ok(root.to_path_buf());
};
if !requested.is_absolute() {
return Err(IdeError::invalid("Choose an absolute project root"));
}
let requested = dunce::canonicalize(requested)?;
let window_root = dunce::canonicalize(root)?;
if requested == window_root
|| ide_workspace::workspace::Workspace::load(root)?.is_some_and(|w| w.projects.contains(&requested))
{
return Ok(requested);
}
Err(IdeError::invalid("Agent project must be the window project or a workspace member"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn agent_roots_are_exact_window_or_workspace_members() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("workspace");
let member = tmp.path().join("member");
let other = tmp.path().join("other");
for dir in [&root, &member, &other, &member.join("child")] {
std::fs::create_dir_all(dir).unwrap();
}
assert_eq!(validate_project_root(&root, None).unwrap(), root);
assert_eq!(validate_project_root(&root, Some(&root)).unwrap(), dunce::canonicalize(&root).unwrap());
assert!(validate_project_root(&root, Some(&member)).is_err());
ide_workspace::workspace::Workspace::save(&root, "Workspace", vec![member.clone()], true).unwrap();
assert_eq!(validate_project_root(&root, Some(&member)).unwrap(), dunce::canonicalize(&member).unwrap());
for rejected in [other, member.join("child"), member.join("../other"), "relative".into()] {
assert!(validate_project_root(&root, Some(&rejected)).is_err(), "{rejected:?}");
}
}
}
+4 -3
View File
@@ -39,12 +39,13 @@ fn load(project: &Project, languages: &LanguageRegistry, path: &Path) -> Option<
let snap = project.documents.text_snapshot(doc).ok()?;
return (!snap.large).then_some((snap.language, snap.text));
}
let loaded = ide_vfs::load_text(path).ok()?;
let loaded = project.documents.load_file(path).ok()?;
if loaded.binary || loaded.large || loaded.too_large {
return None;
}
let language = languages.detect(path, loaded.text.lines().next());
Some((language, loaded.text))
let text = ide_text::normalize_line_endings(&loaded.text).into_owned();
let language = languages.detect(path, text.lines().next());
Some((language, text))
}
// ---- Inspect Code ----
+86 -8
View File
@@ -27,7 +27,12 @@ fn java_env(project_jdk: Option<&ide_sdk::java::Jdk>, release: Option<u32>) -> B
/// The build model with the launch settings applied: the JDK (`JAVA_HOME`), and
/// the Maven home and options from Settings › Maven.
pub fn load(root: &Path, cache: &Path, settings: &Settings) -> BuildModel {
let mut model = ide_build::load(root, cache, ide_sdk::apply::maven_local_repository(root, settings));
let mut model = ide_build::load_linked(
root,
cache,
ide_sdk::apply::maven_local_repository(root, settings),
&settings.languages.java.maven,
);
let jdk = ide_sdk::apply::project_jdk(root, settings);
if let Some(m) = &mut model.maven {
let mvn = ide_sdk::apply::maven_command(root, settings);
@@ -41,7 +46,8 @@ pub fn load(root: &Path, cache: &Path, settings: &Settings) -> BuildModel {
}
}
if let Some(g) = &mut model.gradle {
g.env = java_env(jdk.as_ref(), g.java_version);
g.env = ide_sdk::apply::gradle_env(root, settings, java_env(jdk.as_ref(), g.java_version));
g.command = ide_sdk::apply::gradle_command(root, &root.join(&g.dir), settings).to_string_lossy().into_owned();
}
// A failed sync of a project no installed JDK can build says so first (with a Download JDK action).
let missing = |release: Option<u32>| match jdk {
@@ -62,7 +68,7 @@ pub fn load(root: &Path, cache: &Path, settings: &Settings) -> BuildModel {
if let Some(g) = &mut model.gradle
&& g.sync_error.is_some()
{
let hint = missing(g.java_version);
let hint = if settings.languages.java.gradle.jvm.trim().is_empty() { missing(g.java_version) } else { None };
explain(&mut g.sync_error, hint);
}
model
@@ -70,6 +76,7 @@ pub fn load(root: &Path, cache: &Path, settings: &Settings) -> BuildModel {
/// Maven and Gradle projects of the window's project, from the build files plus the last sync.
pub async fn build_model(host: &Host, window: &str) -> IdeResult<BuildModel> {
host.require_trust(window)?;
let project = host.project(window)?;
let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective());
tokio::task::spawn_blocking(move || load(&root, &cache, &settings)).await.map_err(join_err)
@@ -87,9 +94,11 @@ pub async fn build_sync(
profiles: Vec<String>,
on_progress: Channel<SyncProgress>,
) -> IdeResult<BuildModel> {
host.require_trust(window)?;
let project = host.project(window)?;
let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective());
let token = host.begin_request(request);
let bridge_profiles = profiles.clone();
let token = host.begin_project_request(window, request);
let result = tokio::task::spawn_blocking(move || {
let current = load(&root, &cache, &settings);
let env = match tool {
@@ -107,6 +116,8 @@ pub async fn build_sync(
env,
maven_command: ide_sdk::apply::maven_command(&root, &settings).map(|p| p.to_string_lossy().into_owned()),
maven_options: ide_sdk::apply::maven_options(&root, &settings),
maven_settings: settings.languages.java.maven.clone(),
gradle_command: current.gradle.as_ref().map(|g| g.command.clone()),
};
let progress = |p: SyncProgress| {
let _ = on_progress.send(p);
@@ -115,12 +126,30 @@ pub async fn build_sync(
BuildTool::Maven => ide_build::sync_maven(&root, &cache, &options, &token, &progress)?,
BuildTool::Gradle => ide_build::sync_gradle(&root, &cache, &options, &token, &progress)?,
}
Ok(load(&root, &cache, &settings))
Ok::<BuildModel, IdeError>(load(&root, &cache, &settings))
})
.await
.map_err(join_err);
host.end_request(request);
result?
let model = result??;
if tool == BuildTool::Maven
&& let Some(maven) = &model.maven
{
// jdtls re-imports on pom changes but misses artifacts added to ~/.m2
// meanwhile ("missing artifact" stays until Invalidate Caches); asking it
// to update the project configurations re-resolves them (#436).
if let Some(bridge) = &project.kotlin_bridge {
bridge.configure_maven(offline, bridge_profiles);
}
let lsp = project.lsp.clone();
let uris = maven.projects.iter().map(|p| ide_lsp::path_to_uri_string(&project.root.join(&p.pom))).collect();
tokio::spawn(async move {
if let Err(e) = lsp.java_update_project_configs(&project.root, uris).await {
tracing::debug!("java/projectConfigurationsUpdate: {e}");
}
});
}
Ok(model)
}
/// The POM declaring `dependency` for the Maven module `pom` (project-relative): the
@@ -200,10 +229,12 @@ pub async fn build_run(
clear: Vec<std::path::PathBuf>,
on_event: Channel<BuildEvent>,
) -> IdeResult<BuildResult> {
host.require_trust(window)?;
let project = host.project(window)?;
let (root, cache, sink) = (project.root.clone(), project.build_cache_dir(), project.sink.clone());
let settings = project.settings.effective();
let token = host.begin_request(request);
let config_dir = host.config_dir.clone();
let token = host.begin_project_request(window, request);
let result = tokio::task::spawn_blocking(move || {
for path in clear {
sink.emit(IdeEvent::Diagnostics {
@@ -212,7 +243,23 @@ pub async fn build_run(
items: Vec::new(),
});
}
let plan = compile::plan(&root, &load(&root, &cache, &settings), kind, file.as_deref())?;
let plan = if let Some(workspace) = ide_workspace::workspace::Workspace::load(&root)? {
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 plan = compile::plan(member, &load(member, &member_cache, &settings), kind, file.as_deref())?;
match &mut combined {
Some(combined) => combined.steps.extend(plan.steps),
None => combined = Some(plan),
}
}
let mut plan = combined.ok_or_else(|| IdeError::invalid("No workspace project to build"))?;
plan.title = workspace.name;
plan
} else {
compile::plan(&root, &load(&root, &cache, &settings), kind, file.as_deref())?
};
let result = compile::run(&root, &plan, &token, &|e| {
let _ = on_event.send(e);
})?;
@@ -261,3 +308,34 @@ pub(crate) fn register(d: &mut Dispatcher) {
.await
});
}
#[cfg(test)]
mod gradle_settings_tests {
use super::*;
#[test]
fn model_for_sync_tasks_and_compile_uses_gradle_settings() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(root.join("build.gradle"), "plugins { id 'java' }").unwrap();
let wrapper = root.join(if cfg!(windows) { "gradlew.bat" } else { "gradlew" });
std::fs::write(&wrapper, "").unwrap();
let mut settings = Settings::default();
let default = load(root, &root.join("cache"), &settings).gradle.unwrap();
assert_eq!(default.command, wrapper.to_string_lossy());
settings.languages.java.gradle.jvm = "jdk21".into();
settings.languages.java.gradle.user_home = "user-home".into();
settings.languages.java.gradle.distribution = ide_core::settings::GradleDistribution::Local;
settings.languages.java.gradle.home = "gradle-install".into();
let model = load(root, &root.join("cache"), &settings).gradle.unwrap();
assert_eq!(
model.command,
root.join("gradle-install")
.join("bin")
.join(if cfg!(windows) { "gradle.bat" } else { "gradle" })
.to_string_lossy()
);
assert_eq!(model.env["JAVA_HOME"], root.join("jdk21").to_string_lossy());
assert_eq!(model.env["GRADLE_USER_HOME"], root.join("user-home").to_string_lossy());
}
}
+3 -1
View File
@@ -16,6 +16,7 @@ pub async fn blocking<T: Send + 'static>(f: impl FnOnce() -> IdeResult<T> + Send
/// How to run `config` with coverage: the command to launch and where its report will be,
/// or which tool is missing.
pub async fn coverage_prepare(host: &Host, window: &str, config: RunConfig) -> IdeResult<CoveragePrepared> {
host.require_trust(window)?;
let project = host.project(window)?;
let (root, cache) = (project.root.clone(), project.cache_dir.join("coverage"));
blocking(move || {
@@ -26,7 +27,8 @@ pub async fn coverage_prepare(host: &Host, window: &str, config: RunConfig) -> I
}
/// Reads the report a coverage run left behind.
pub async fn coverage_collect(_host: &Host, _window: &str, report: CoverageReportSpec) -> IdeResult<CoverageSuite> {
pub async fn coverage_collect(host: &Host, window: &str, report: CoverageReportSpec) -> IdeResult<CoverageSuite> {
host.require_trust(window)?;
blocking(move || ide_coverage::collect(&report)).await
}
+78 -6
View File
@@ -1,3 +1,5 @@
mod output;
use crate::Host;
use crate::rpc::Dispatcher;
use ide_core::{DebugSessionId, IdeResult};
@@ -6,6 +8,7 @@ use std::path::PathBuf;
/// Starts a session owned by this window: its events (and those of child sessions) go here only.
pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> IdeResult<DebugSessionId> {
host.require_trust(window)?;
let mut config = match_java_debug_encoding(config, cfg!(windows));
if config.adapter == "debugpy"
&& config.request == "launch"
@@ -14,17 +17,70 @@ pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> Ide
super::python::prepare_debug(&mut config, &super::python::paths(&project))?;
}
let label = window;
let owner = SessionOwner::new(host.owner_window(label), host.window_sink(label));
let (sink, flush_output) = output::coalescing_sink(host.window_sink(label));
let owner = SessionOwner::new(host.owner_window(label), sink);
let settings = match host.project(label) {
Ok(project) => project.settings.effective(),
Err(_) => host.global_settings.effective(),
};
if config.adapter == "delve" && config.request == "launch" {
let root = host.project(window).map(|p| p.root.clone()).unwrap_or_default();
let settings = settings.clone();
config = host
.blocking(move || {
ide_sdk::go::debug_environment(&root, &settings, &mut config.env);
ide_sdk::go::apply_to_debug(
&mut config.env,
&mut config.extra_json,
&settings.languages.go.build_tags,
)?;
Ok(config)
})
.await?;
}
if config.adapter == ide_dap::kotlin::ADAPTER {
let data_dir = host.data_dir.clone();
let runtime_root = config.cwd.clone().map(PathBuf::from).unwrap_or_default();
let runtime_settings = settings.clone();
let runtime = host
.blocking(move || {
if let Some(jdk) = ide_sdk::apply::jdtls_jdk(&runtime_root, &runtime_settings)
.map_err(ide_core::IdeError::unavailable)?
{
if jdk.major >= 11 {
return Ok(jdk);
}
return Err(ide_core::IdeError::unavailable(
"Kotlin debugger needs JDK 11 or newer. Choose a newer language server JDK in Settings.",
));
}
let jdks = ide_sdk::java::jdtls_jdks(11, &data_dir);
ide_sdk::java::jdtls_runtime(&jdks, 11).cloned().ok_or_else(|| {
ide_core::IdeError::unavailable(
"Kotlin debugger needs JDK 11 or newer. Download or choose a language server JDK in Settings.",
)
})
})
.await?;
let mut extra: serde_json::Value = config
.extra_json
.as_deref()
.map(serde_json::from_str)
.transpose()
.map_err(|e| ide_core::IdeError::invalid(e.to_string()))?
.unwrap_or_else(|| serde_json::json!({}));
extra["__adapterJavaHome"] = serde_json::json!(runtime.home);
config.extra_json = Some(extra.to_string());
}
let root = config.cwd.clone().map(PathBuf::from).unwrap_or_default();
let custom = |command: &str| {
let path = ide_sdk::apply::tool_command(&root, &settings, command)?;
Some(path.to_string_lossy().into_owned())
};
host.debugger.start_with(config, owner, custom).await
host.require_trust(window)?;
let result = host.debugger.start_with(config, owner, custom).await;
flush_output();
result
}
/// A JVM launched by java-debug writes what java-debug decodes its output as
@@ -40,7 +96,13 @@ fn match_java_debug_encoding(mut config: LaunchConfig, windows: bool) -> LaunchC
};
let vm_args = extra.get("vmArgs").and_then(|v| v.as_str()).unwrap_or_default();
let encoding = extra.get("encoding").and_then(|v| v.as_str());
if let Some(vm_args) = ide_core::output_encoding::java_debug_vm_args(vm_args, encoding, windows) {
if let Some(vm_args) = ide_core::output_encoding::java_debug_vm_args(
vm_args,
&config.env,
|key| std::env::var(key).ok(),
encoding,
windows,
) {
extra.insert("vmArgs".into(), vm_args.into());
config.extra_json = Some(serde_json::Value::Object(extra).to_string());
}
@@ -253,11 +315,12 @@ pub async fn debug_attachable_processes(_host: &Host, _window: &str) -> IdeResul
/// JVM to connect to `host:port`; returns the loopback port java-debug attaches to.
pub async fn debug_listen_for_jvm(
host: &Host,
_window: &str,
window: &str,
request: ide_core::RequestId,
jvm_host: String,
port: u16,
) -> IdeResult<u16> {
host.require_trust(window)?;
let token = host.begin_request(request);
let result = jdwp::listen_for_jvm(&jvm_host, port, token).await;
host.end_request(request);
@@ -395,7 +458,7 @@ mod tests {
program: None,
args: vec![],
cwd: None,
env: Default::default(),
env: [("JDK_JAVA_OPTIONS".into(), "".into()), ("JAVA_TOOL_OPTIONS".into(), "".into())].into(),
extra_json: Some(extra.to_string()),
}
}
@@ -411,7 +474,7 @@ mod tests {
let fixed = match_java_debug_encoding(launch("java-debug", "launch", extra.clone()), true);
assert_eq!(
vm_args(&fixed),
"-Xmx1g -Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8 -Dsun.stdout.encoding=UTF-8 -Dsun.stderr.encoding=UTF-8"
"-Xmx1g -Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8 -Dsun.stdout.encoding=UTF-8 -Dsun.stderr.encoding=UTF-8 -Dfile.encoding=UTF-8"
);
assert_eq!(
serde_json::from_str::<serde_json::Value>(fixed.extra_json.as_deref().unwrap()).unwrap()["mainClass"],
@@ -424,4 +487,13 @@ mod tests {
assert_eq!(vm_args(&match_java_debug_encoding(launch(adapter, request, extra.clone()), windows)), "-Xmx1g");
}
}
#[test]
fn java_debug_launches_respect_environment_encoding() {
for key in ["JDK_JAVA_OPTIONS", "JAVA_TOOL_OPTIONS"] {
let mut config = launch("java-debug", "launch", serde_json::json!({"vmArgs": "-Dstdout.encoding=GBK"}));
config.env.insert(key.into(), "-Dfile.encoding=GBK".into());
assert_eq!(vm_args(&match_java_debug_encoding(config, true)), "-Dstdout.encoding=GBK");
}
}
}
+182
View File
@@ -0,0 +1,182 @@
use ide_core::event::DebugEvent;
use ide_core::{DebugSessionId, IdeEvent, SharedSink};
use parking_lot::Mutex;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
const WINDOW: Duration = Duration::from_millis(50);
const MAX_BYTES: usize = 64 * 1024;
#[derive(Default)]
struct Pending {
sessions: HashMap<DebugSessionId, (String, String)>,
scheduled: bool,
}
fn flush(pending: &mut Pending, sink: &SharedSink, session: DebugSessionId) {
if let Some((category, output)) = pending.sessions.remove(&session) {
sink.emit(IdeEvent::Debug { session, event: DebugEvent::Output { category, output } });
}
}
/// All debug output sources (including build output and child sessions) share this
/// bounded batch before reaching desktop or remote IPC. Control events flush
/// their session first, so a stop or exit cannot overtake preceding output.
pub(super) fn coalescing_sink(sink: SharedSink) -> (SharedSink, impl Fn()) {
let pending = Arc::new(Mutex::new(Pending::default()));
let flush_pending = {
let pending = pending.clone();
let sink = sink.clone();
move || {
let mut state = pending.lock();
for (session, (category, output)) in state.sessions.drain() {
sink.emit(IdeEvent::Debug { session, event: DebugEvent::Output { category, output } });
}
}
};
let runtime = tokio::runtime::Handle::current();
let output: SharedSink = Arc::new(move |event| {
let mut state = pending.lock();
match event {
IdeEvent::Debug { session, event: DebugEvent::Output { category, output } } => {
if output.is_empty() {
return;
}
if state.sessions.get(&session).is_some_and(|(c, _)| *c != category) {
flush(&mut state, &sink, session);
}
let mut rest = output.as_str();
while !rest.is_empty() {
let (_, text) = state.sessions.entry(session).or_insert_with(|| (category.clone(), String::new()));
let mut end = rest.len().min(MAX_BYTES - text.len());
while !rest.is_char_boundary(end) {
end -= 1;
}
text.push_str(&rest[..end]);
rest = &rest[end..];
if text.len() == MAX_BYTES || !rest.is_empty() {
flush(&mut state, &sink, session);
}
}
if !state.scheduled && !state.sessions.is_empty() {
state.scheduled = true;
let pending = pending.clone();
let sink = sink.clone();
runtime.spawn(async move {
tokio::time::sleep(WINDOW).await;
let mut state = pending.lock();
for (session, (category, output)) in state.sessions.drain() {
sink.emit(IdeEvent::Debug { session, event: DebugEvent::Output { category, output } });
}
state.scheduled = false;
});
}
}
IdeEvent::Debug { session, event } => {
flush(&mut state, &sink, session);
sink.emit(IdeEvent::Debug { session, event });
}
event => sink.emit(event),
}
});
(output, flush_pending)
}
#[cfg(test)]
mod tests {
use super::*;
fn setup() -> (SharedSink, Arc<Mutex<Vec<IdeEvent>>>) {
let events = Arc::new(Mutex::new(Vec::new()));
let saved = events.clone();
(coalescing_sink(Arc::new(move |event| saved.lock().push(event))).0, events)
}
fn output(sink: &SharedSink, session: u32, category: &str, output: &str) {
sink.emit(IdeEvent::Debug {
session: DebugSessionId(session),
event: DebugEvent::Output { category: category.into(), output: output.into() },
});
}
#[tokio::test(start_paused = true)]
async fn explicit_start_completion_flush_does_not_duplicate_output() {
let events = Arc::new(Mutex::new(Vec::new()));
let saved = events.clone();
let (sink, flush) = coalescing_sink(Arc::new(move |event| saved.lock().push(event)));
output(&sink, 1, "console", "build failed\n");
flush();
assert_eq!(events.lock().len(), 1);
tokio::task::yield_now().await;
tokio::time::advance(WINDOW).await;
tokio::task::yield_now().await;
assert_eq!(events.lock().len(), 1);
}
#[tokio::test(start_paused = true)]
async fn batches_per_session_and_flushes_idle_output() {
let (sink, events) = setup();
for _ in 0..500 {
output(&sink, 1, "stdout", "line\n");
}
output(&sink, 2, "stdout", "other\n");
assert!(events.lock().is_empty());
tokio::task::yield_now().await;
tokio::time::advance(WINDOW).await;
tokio::task::yield_now().await;
let events = events.lock();
assert_eq!(events.len(), 2);
assert!(events.iter().any(|event| matches!(event,
IdeEvent::Debug { session: DebugSessionId(1), event: DebugEvent::Output { output, .. } }
if output == &"line\n".repeat(500))));
}
#[tokio::test]
async fn categories_and_control_events_keep_order() {
let (sink, events) = setup();
output(&sink, 1, "stdout", "out");
output(&sink, 1, "stderr", "err");
sink.emit(IdeEvent::Debug { session: DebugSessionId(1), event: DebugEvent::Terminated });
let events = events.lock();
assert_eq!(events.len(), 3);
assert!(matches!(&events[0], IdeEvent::Debug { event: DebugEvent::Output { category, output }, .. }
if category == "stdout" && output == "out"));
assert!(matches!(&events[1], IdeEvent::Debug { event: DebugEvent::Output { category, output }, .. }
if category == "stderr" && output == "err"));
assert!(matches!(&events[2], IdeEvent::Debug { event: DebugEvent::Terminated, .. }));
}
#[tokio::test(start_paused = true)]
async fn terminating_one_session_preserves_other_pending_output() {
let (sink, events) = setup();
output(&sink, 1, "stdout", "first");
output(&sink, 2, "stdout", "second");
sink.emit(IdeEvent::Debug { session: DebugSessionId(1), event: DebugEvent::Terminated });
assert_eq!(events.lock().len(), 2);
tokio::task::yield_now().await;
tokio::time::advance(WINDOW).await;
tokio::task::yield_now().await;
let events = events.lock();
assert_eq!(events.len(), 3);
assert!(matches!(&events[2],
IdeEvent::Debug { session: DebugSessionId(2), event: DebugEvent::Output { output, .. } }
if output == "second"));
}
#[tokio::test]
async fn large_unicode_events_are_bounded_without_loss() {
let (sink, events) = setup();
let text = "日志\n".repeat(100_000);
output(&sink, 1, "stdout", &text);
sink.emit(IdeEvent::Debug { session: DebugSessionId(1), event: DebugEvent::Terminated });
let mut combined = String::new();
for event in events.lock().iter() {
if let IdeEvent::Debug { event: DebugEvent::Output { output, .. }, .. } = event {
assert!(output.len() <= MAX_BYTES);
combined.push_str(output);
}
}
assert_eq!(combined, text);
}
}
+207
View File
@@ -0,0 +1,207 @@
use crate::Host;
use ide_core::{CancellationToken, IdeEvent, IdeResult, IpcPath, RequestId, SharedSink, TaskId};
use ide_remote::{DeployedPath, DeploymentCoverage, SyncEntry, SyncStep, TransferReport};
use ide_ssh::{FsConnection, RemoteEntry};
use std::path::PathBuf;
/// Reports a transfer as a background task (status bar progress) whose Cancel fires `token`.
fn progress(
sink: SharedSink,
title: &str,
token: CancellationToken,
) -> (impl FnMut(&str, usize, usize) + Send, impl FnOnce()) {
let id = TaskId::next();
let cancel = token;
let handle = ide_core::task::on_cancel(id, move || cancel.cancel());
let title = title.to_owned();
let (s, t) = (sink.clone(), title.clone());
let report = move |path: &str, i: usize, n: usize| {
let fraction = if n == 0 { None } else { Some(i as f32 / n as f32) };
let message = Some(path.to_owned());
s.emit(IdeEvent::Task { id, title: t.clone(), message, fraction, done: false, cancellable: true });
};
let done = move || {
drop(handle);
sink.emit(IdeEvent::Task { id, title, message: None, fraction: Some(1.0), done: true, cancellable: false })
};
(report, done)
}
fn local(paths: Vec<IpcPath>) -> Vec<PathBuf> {
paths.into_iter().map(|p| p.to_path_buf()).collect()
}
/// Eligible paths per server, without contacting any deployment host.
pub async fn deploy_coverage(window: &str, state: &Host, paths: Vec<IpcPath>) -> IdeResult<Vec<DeploymentCoverage>> {
let project = state.project(window)?;
Ok(ide_remote::coverage(&project.settings.effective(), &project.root, &local(paths)))
}
/// Tools › Deployment › Upload to…: files and folders to `server` (default server when `None`).
pub async fn deploy_upload(
window: &str,
state: &Host,
paths: Vec<IpcPath>,
server: Option<String>,
request: RequestId,
) -> IdeResult<TransferReport> {
let project = state.project(window)?;
let token = state.begin_request(request);
let (mut report, done) = progress(project.sink.clone(), "Uploading", token.clone());
let r = state
.remote
.upload(&project.settings.effective(), &project.root, server.as_deref(), &local(paths), token, &mut report)
.await;
done();
state.end_request(request);
r
}
/// Download from…: the remote counterparts of files and folders.
pub async fn deploy_download(
window: &str,
state: &Host,
paths: Vec<IpcPath>,
server: Option<String>,
request: RequestId,
) -> IdeResult<TransferReport> {
let project = state.project(window)?;
let token = state.begin_request(request);
let (mut report, done) = progress(project.sink.clone(), "Downloading", token.clone());
let r = state
.remote
.download(&project.settings.effective(), &project.root, server.as_deref(), &local(paths), token, &mut report)
.await;
done();
state.end_request(request);
r
}
/// Deletes the remote counterparts of files and folders.
pub async fn deploy_delete_remote(
window: &str,
state: &Host,
paths: Vec<IpcPath>,
server: Option<String>,
) -> IdeResult<TransferReport> {
let project = state.project(window)?;
state.remote.delete_remote(&project.settings.effective(), &project.root, server.as_deref(), &local(paths)).await
}
/// Sync with Deployed: local and remote files under `path`, side by side.
pub async fn deploy_compare(
window: &str,
state: &Host,
path: IpcPath,
server: Option<String>,
request: RequestId,
) -> IdeResult<Vec<SyncEntry>> {
let project = state.project(window)?;
let token = state.begin_request(request);
let r = state.remote.compare(&project.settings.effective(), &project.root, server.as_deref(), &path, token).await;
state.end_request(request);
r
}
/// Applies the Sync with Deployed choices.
pub async fn deploy_sync(
window: &str,
state: &Host,
steps: Vec<SyncStep>,
server: Option<String>,
request: RequestId,
) -> IdeResult<TransferReport> {
let project = state.project(window)?;
let token = state.begin_request(request);
let (mut report, done) = progress(project.sink.clone(), "Synchronizing", token.clone());
let r = state
.remote
.sync(&project.settings.effective(), &project.root, server.as_deref(), &steps, token, &mut report)
.await;
done();
state.end_request(request);
r
}
/// Where `path` is deployed (remote path, web URL); `None` when not mapped.
pub async fn deploy_info(
window: &str,
state: &Host,
path: IpcPath,
server: Option<String>,
) -> IdeResult<Option<DeployedPath>> {
let project = state.project(window)?;
state.remote.deployed(&project.settings.effective(), &project.root, server.as_deref(), &path).await
}
/// Remote Host tool window: the server root of `server`, resolved on the host.
pub async fn deploy_server_root(window: &str, state: &Host, server: String) -> IdeResult<String> {
let settings = match state.project(window) {
Ok(p) => p.settings.effective(),
Err(_) => state.global_settings.effective(),
};
state.remote.server_root(&settings, &server).await
}
/// Entries of a folder on the selected filesystem (`~`: its home directory).
pub async fn remote_list(state: &Host, connection: FsConnection, path: String) -> IdeResult<Vec<RemoteEntry>> {
state.remote.list(&connection, &path).await
}
/// Opens a remote file for editing: a local copy, uploaded back on save.
pub async fn remote_open(state: &Host, connection: FsConnection, path: String) -> IdeResult<IpcPath> {
Ok(state.remote.open_remote(&connection, &path).await?.into())
}
/// The filesystem connection and path a cached editing copy stands for.
pub fn remote_of(state: &Host, path: IpcPath) -> Option<(FsConnection, String)> {
state.remote.remote_of(&path)
}
pub async fn remote_delete(state: &Host, connection: FsConnection, path: String) -> IdeResult<()> {
state.remote.remote_delete(&connection, &path).await
}
pub async fn remote_mkdir(state: &Host, connection: FsConnection, path: String) -> IdeResult<()> {
state.remote.remote_mkdir(&connection, &path).await
}
pub async fn remote_rename(state: &Host, connection: FsConnection, from: String, to: String) -> IdeResult<()> {
state.remote.remote_rename(&connection, &from, &to).await
}
pub async fn deploy_test_connection(state: &Host, connection: FsConnection, path: String) -> IdeResult<()> {
state.remote.test_connection(&connection, &path).await
}
pub(crate) fn register(d: &mut crate::rpc::Dispatcher) {
d.add("deploy_coverage", |c| async move { deploy_coverage(&c.window, &c.host, c.arg("paths")?).await });
d.add("deploy_upload", |c| async move {
deploy_upload(&c.window, &c.host, c.arg("paths")?, c.arg("server")?, c.arg("request")?).await
});
d.add("deploy_download", |c| async move {
deploy_download(&c.window, &c.host, c.arg("paths")?, c.arg("server")?, c.arg("request")?).await
});
d.add("deploy_delete_remote", |c| async move {
deploy_delete_remote(&c.window, &c.host, c.arg("paths")?, c.arg("server")?).await
});
d.add("deploy_compare", |c| async move {
deploy_compare(&c.window, &c.host, c.arg("path")?, c.arg("server")?, c.arg("request")?).await
});
d.add("deploy_sync", |c| async move {
deploy_sync(&c.window, &c.host, c.arg("steps")?, c.arg("server")?, c.arg("request")?).await
});
d.add("deploy_info", |c| async move { deploy_info(&c.window, &c.host, c.arg("path")?, c.arg("server")?).await });
d.add("deploy_server_root", |c| async move { deploy_server_root(&c.window, &c.host, c.arg("server")?).await });
d.add("remote_list", |c| async move { remote_list(&c.host, c.arg("connection")?, c.arg("path")?).await });
d.add("remote_open", |c| async move { remote_open(&c.host, c.arg("connection")?, c.arg("path")?).await });
d.add("remote_of", |c| async move { Ok(remote_of(&c.host, c.arg("path")?)) });
d.add("remote_delete", |c| async move { remote_delete(&c.host, c.arg("connection")?, c.arg("path")?).await });
d.add("remote_mkdir", |c| async move { remote_mkdir(&c.host, c.arg("connection")?, c.arg("path")?).await });
d.add("remote_rename", |c| async move {
remote_rename(&c.host, c.arg("connection")?, c.arg("from")?, c.arg("to")?).await
});
d.add("deploy_test_connection", |c| async move {
deploy_test_connection(&c.host, c.arg("connection")?, c.arg("path")?).await
});
}
+19 -2
View File
@@ -28,12 +28,26 @@ pub fn edit(host: &Host, window: &str, doc: DocId, base_version: u32, changes: C
pub async fn save(host: &Host, window: &str, doc: DocId) -> IdeResult<()> {
let project = host.project(window)?;
host.blocking(move || project.documents.save(doc)).await
host.blocking(move || project.documents.save(doc)).await?;
host.debugger.refresh_source_mirrors(host.owner_window(window)).await
}
pub async fn save_all(host: &Host, window: &str) -> IdeResult<()> {
let project = host.project(window)?;
host.blocking(move || project.documents.save_all()).await
host.blocking(move || project.documents.save_all()).await?;
host.debugger.refresh_source_mirrors(host.owner_window(window)).await
}
/// Recovers external changes missed by the project's file watchers.
pub async fn sync_from_disk(host: &Host, window: &str) -> IdeResult<()> {
let project = host.project(window)?;
host.blocking(move || {
if project.settings.effective().files.sync_on_focus {
project.documents.sync_from_disk();
}
Ok(())
})
.await
}
pub async fn snapshot(host: &Host, window: &str, doc: DocId) -> IdeResult<DocumentInfo> {
@@ -43,3 +57,6 @@ pub async fn snapshot(host: &Host, window: &str, doc: DocId) -> IdeResult<Docume
mod commands;
pub use commands::*;
#[cfg(test)]
mod tests;
+18 -3
View File
@@ -22,9 +22,23 @@ pub async fn doc_open_virtual(host: &Host, window: &str, uri: String) -> IdeResu
blocking(move || project.documents.open_virtual(&vd.uri, vd.language_id, &vd.text)).await
}
/// The CodeMirror mode `path` gets when opened (`DocumentInfo::ui_mode`),
/// without opening it: previews of closed files.
pub async fn doc_ui_mode(host: &Host, window: &str, path: PathBuf) -> IdeResult<Option<String>> {
let project = host.project(window)?;
blocking(move || Ok(project.documents.ui_mode_of(&path))).await
}
/// "Reload with Encoding" (`reload`) or "Convert to Encoding"; returns the new encoding label.
pub fn doc_set_encoding(host: &Host, window: &str, doc: DocId, encoding: String, reload: bool) -> IdeResult<String> {
host.project(window)?.documents.set_encoding(doc, &encoding, reload)
pub async fn doc_set_encoding(
host: &Host,
window: &str,
doc: DocId,
encoding: String,
reload: bool,
) -> IdeResult<String> {
let project = host.project(window)?;
host.blocking(move || project.documents.set_encoding(doc, &encoding, reload)).await
}
pub fn doc_set_line_ending(host: &Host, window: &str, doc: DocId, line_ending: LineEnding) -> IdeResult<()> {
@@ -108,8 +122,9 @@ pub fn history_content(host: &Host, window: &str, id: String) -> IdeResult<Strin
/// Commands of this module the remote server answers.
pub(crate) fn register(d: &mut Dispatcher) {
d.add("doc_open_virtual", |c| async move { doc_open_virtual(&c.host, &c.window, c.arg("uri")?).await });
d.add("doc_ui_mode", |c| async move { doc_ui_mode(&c.host, &c.window, c.arg("path")?).await });
d.add("doc_set_encoding", |c| async move {
doc_set_encoding(&c.host, &c.window, c.arg("doc")?, c.arg("encoding")?, c.arg("reload")?)
doc_set_encoding(&c.host, &c.window, c.arg("doc")?, c.arg("encoding")?, c.arg("reload")?).await
});
d.add("doc_set_line_ending", |c| async move {
doc_set_line_ending(&c.host, &c.window, c.arg("doc")?, c.arg("lineEnding")?)
+200
View File
@@ -0,0 +1,200 @@
use super::*;
use crate::{HostConfig, Project};
use ide_core::{IdeEvent, TextChange, event::NotifyLevel};
use parking_lot::Mutex;
use std::{path::Path, sync::Arc};
struct Fixture {
host: Host,
project: Arc<Project>,
events: Arc<Mutex<Vec<IdeEvent>>>,
path: PathBuf,
outside: PathBuf,
_temp: tempfile::TempDir,
}
impl Fixture {
fn new() -> Self {
let temp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(temp.path()).unwrap();
let root = base.join("project");
// Keep watcher delivery out of these tests: the op must recover it itself.
let excluded = root.join("node_modules");
std::fs::create_dir_all(&excluded).unwrap();
let path = excluded.join("file.txt");
let outside = base.join("outside.txt");
std::fs::write(&path, "original\n").unwrap();
std::fs::write(&outside, "outside\n").unwrap();
let events = Arc::new(Mutex::new(Vec::new()));
let captured = events.clone();
let host = Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(move |_| {
let events = captured.clone();
Arc::new(move |event| events.lock().push(event))
}),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
let project = host.open_project("main", &root).unwrap();
Self { _temp: temp, host, project, events, path, outside }
}
async fn open(&self, path: &Path) -> DocumentInfo {
open(&self.host, "main", path.to_path_buf()).await.unwrap()
}
async fn sync(&self) {
sync_from_disk(&self.host, "main").await.unwrap();
}
fn document_events(&self) -> Vec<IdeEvent> {
self.events
.lock()
.iter()
.filter(|e| matches!(e, IdeEvent::DocPatched { .. } | IdeEvent::DocDirty { .. } | IdeEvent::Notify { .. }))
.cloned()
.collect()
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn changed_clean_document_reloads_once() {
let f = Fixture::new();
let original = f.open(&f.path).await;
std::fs::write(&f.path, "externally changed\n").unwrap();
f.sync().await;
let loaded = f.project.documents.snapshot(original.id).unwrap();
assert_eq!(loaded.text, "externally changed\n");
assert!(!loaded.dirty);
assert!(loaded.version > original.version);
f.events.lock().clear();
f.sync().await;
assert_eq!(f.project.documents.snapshot(original.id).unwrap().version, loaded.version);
assert!(f.document_events().is_empty());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn unchanged_document_has_no_patch_or_event() {
let f = Fixture::new();
let original = f.open(&f.path).await;
f.events.lock().clear();
f.sync().await;
let loaded = f.project.documents.snapshot(original.id).unwrap();
assert_eq!(loaded.text, original.text);
assert_eq!(loaded.version, original.version);
assert!(f.document_events().is_empty());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn dirty_document_keeps_edits_and_warns_once() {
let f = Fixture::new();
let original = f.open(&f.path).await;
edit(
&f.host,
"main",
original.id,
original.version,
ChangeSet { changes: vec![TextChange { from: 0, to: 0, insert: "local ".into() }] },
)
.unwrap();
f.events.lock().clear();
std::fs::write(&f.path, "external replacement\n").unwrap();
f.sync().await;
let loaded = f.project.documents.snapshot(original.id).unwrap();
assert_eq!(loaded.text, "local original\n");
assert!(loaded.dirty);
assert!(matches!(f.document_events().as_slice(), [IdeEvent::Notify { level: NotifyLevel::Warning, .. }]));
f.events.lock().clear();
f.sync().await;
assert!(f.document_events().is_empty());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn deleted_document_keeps_its_buffer_and_other_documents_still_reload() {
let f = Fixture::new();
let original = f.open(&f.path).await;
let outside = f.open(&f.outside).await;
std::fs::remove_file(&f.path).unwrap();
std::fs::write(&f.outside, "outside changed after deletion\n").unwrap();
f.sync().await;
let loaded = f.project.documents.snapshot(original.id).unwrap();
assert_eq!(loaded.text, original.text);
assert_eq!(loaded.version, original.version);
assert_eq!(f.project.documents.snapshot(outside.id).unwrap().text, "outside changed after deletion\n");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn outside_project_document_reloads() {
let f = Fixture::new();
assert!(!f.outside.starts_with(&f.project.root));
let original = f.open(&f.outside).await;
std::fs::write(&f.outside, "outside changed externally\n").unwrap();
f.sync().await;
assert_eq!(f.project.documents.snapshot(original.id).unwrap().text, "outside changed externally\n");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn setting_defaults_on_and_disabled_sync_keeps_buffer() {
let f = Fixture::new();
assert!(f.project.settings.effective().files.sync_on_focus);
assert!(serde_json::from_value::<ide_core::settings::FileSettings>(serde_json::json!({})).unwrap().sync_on_focus);
let original = f.open(&f.outside).await;
f.project.settings.set(ide_workspace::settings::SettingsScope::Project, "files.syncOnFocus", false.into()).unwrap();
std::fs::write(&f.outside, "external change while disabled\n").unwrap();
f.sync().await;
assert_eq!(f.project.documents.snapshot(original.id).unwrap().version, original.version);
f.project.settings.set(ide_workspace::settings::SettingsScope::Project, "files.syncOnFocus", true.into()).unwrap();
f.sync().await;
assert_eq!(f.project.documents.snapshot(original.id).unwrap().text, "external change while disabled\n");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn properties_reload_preserves_native_to_ascii_decoding() {
let f = Fixture::new();
let path = f.outside.with_extension("properties");
std::fs::write(&path, "key=\\u4F60\n").unwrap();
let mut defaults = f.project.documents.file_defaults();
defaults.native_to_ascii = true;
f.project.documents.set_file_defaults(defaults);
let original = f.open(&path).await;
assert_eq!(original.text, "key=你\n");
std::fs::write(&path, "key=\\u4E16\\u754C\n").unwrap();
f.sync().await;
assert_eq!(f.project.documents.snapshot(original.id).unwrap().text, "key=世界\n");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn binary_and_virtual_documents_are_skipped() {
let f = Fixture::new();
let path = f.outside.with_extension("bin");
std::fs::write(&path, b"\0\0binary payload").unwrap();
let binary = f.open(&path).await;
assert!(binary.read_only);
let virtual_doc =
f.project.documents.open_virtual("test://virtual", ide_core::LanguageId::plain_text(), "virtual").unwrap();
std::fs::write(&path, "now ordinary text").unwrap();
f.events.lock().clear();
f.sync().await;
assert_eq!(f.project.documents.snapshot(binary.id).unwrap().version, binary.version);
assert_eq!(f.project.documents.snapshot(virtual_doc.id).unwrap().text, "virtual");
assert!(f.document_events().is_empty());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn synchronization_only_changes_the_calling_windows_documents() {
let f = Fixture::new();
let original = f.open(&f.outside).await;
let other_root = f.outside.parent().unwrap().join("other-project");
std::fs::create_dir(&other_root).unwrap();
let other = f.host.open_project("other", &other_root).unwrap();
let other_doc = open(&f.host, "other", f.outside.clone()).await.unwrap();
std::fs::write(&f.outside, "external change in shared file\n").unwrap();
f.sync().await;
assert_eq!(f.project.documents.snapshot(original.id).unwrap().text, "external change in shared file\n");
assert_eq!(other.documents.snapshot(other_doc.id).unwrap().text, other_doc.text);
}
+1 -1
View File
@@ -9,7 +9,7 @@ use std::sync::Arc;
/// Re-reads the login shell (and the window's project `remoteEnv` where it applies);
/// running terminals and processes keep their environment.
pub async fn reload(host: &Arc<Host>, window: &str) -> IdeResult<EnvReloaded> {
let root = host.project(window).ok().filter(|p| !p.light).map(|p| p.root.clone());
let root = host.project(window).ok().filter(|p| !p.light && p.is_trusted()).map(|p| p.root.clone());
let sink = host.window_sink(window);
let h = host.clone();
host.blocking(move || h.environment.reload(root.as_deref(), &sink)).await
+70 -1
View File
@@ -1,8 +1,73 @@
use super::Channel;
use crate::Host;
use ide_core::IdeResult;
use ide_core::{IdeResult, RequestId};
use ide_vfs::FileEntry;
use std::path::PathBuf;
pub async fn entry_kind(host: &Host, path: PathBuf) -> IdeResult<ide_vfs::EntryKind> {
host.blocking(move || {
let meta = std::fs::metadata(path)?;
Ok(if meta.is_dir() { ide_vfs::EntryKind::Dir } else { ide_vfs::EntryKind::File })
})
.await
}
pub async fn compare_directories(
host: &Host,
window: &str,
left: PathBuf,
right: PathBuf,
request: RequestId,
on_progress: Channel<ide_vfs::DirectoryCompareProgress>,
) -> IdeResult<Vec<ide_vfs::DirectoryCompareEntry>> {
let project = host.project(window)?;
let cancel = host.begin_request(request);
let result = host
.blocking(move || {
let last = std::cell::Cell::new(std::time::Instant::now() - std::time::Duration::from_secs(1));
ide_vfs::compare_directories(&left, &right, Some(&project.root), &project.exclude_globs(), &cancel, |p| {
// Bound IPC traffic even when hashing multi-gigabyte files.
if last.get().elapsed() >= std::time::Duration::from_millis(80) || p.total == Some(p.compared) {
let _ = on_progress.send(p);
last.set(std::time::Instant::now());
}
})
})
.await;
host.end_request(request);
result
}
pub async fn copy_merge(
host: &Host,
window: &str,
source_root: PathBuf,
destination_root: PathBuf,
path: PathBuf,
overwrite: bool,
) -> IdeResult<()> {
if path.as_os_str().is_empty() || path.components().any(|c| !matches!(c, std::path::Component::Normal(_))) {
return Err(ide_core::IdeError::invalid("expected a relative comparison entry path"));
}
let project = host.project(window)?;
host.blocking(move || {
let from = source_root.join(&path);
let to = destination_root.join(&path);
if overwrite {
project.history.record_deletion(&to, DELETE_SNAPSHOT_LIMIT);
}
ide_vfs::copy_merge(
&from,
&to,
overwrite,
Some(&project.root),
&project.exclude_globs(),
(&source_root, &destination_root),
)
})
.await
}
/// Files of a deleted folder snapshotted in Local History.
const DELETE_SNAPSHOT_LIMIT: usize = 2000;
@@ -49,6 +114,10 @@ pub fn create_file(host: &Host, window: &str, path: PathBuf) -> IdeResult<()> {
}
}
pub async fn create_file_bytes(host: &Host, path: PathBuf, data: Vec<u8>) -> IdeResult<()> {
host.blocking(move || ide_vfs::create_file_bytes(&path, &data)).await
}
/// Toggle Read-only Attribute: sets the writable bit (and the open document's
/// state). Returns whether the file is read-only now.
pub fn set_writable(host: &Host, window: &str, path: PathBuf, writable: bool) -> IdeResult<bool> {
+166 -42
View File
@@ -1,13 +1,16 @@
//! Java launch support: classpath / JDK resolution and the java-debug server (jdtls).
pub(crate) mod cache;
use crate::Host;
use crate::rpc::Dispatcher;
use super::build::load;
use ide_build::compile;
use ide_core::{CancellationToken, IdeError, IdeResult};
use ide_lsp::jdtls::JavaLaunchInfo;
use ide_core::{CancellationToken, IdeError, IdeResult, RequestId};
use ide_lsp::jdtls::{JavaBuildStatus, JavaLaunchInfo};
use std::path::PathBuf;
/// Cancellable with `cancel_request(request)`.
/// Builds the workspace (when `build`), then resolves the runtime (or test)
/// classpath and the JDK of the module at `module` (absolute, or relative to the project root).
/// A module with Kotlin / Groovy / Scala sources is built by its build system, whose class directories come first.
@@ -17,58 +20,140 @@ pub async fn java_resolve_launch(
module: PathBuf,
test: bool,
build: bool,
request: RequestId,
) -> IdeResult<JavaLaunchInfo> {
host.require_trust(window)?;
let cancel = host.begin_project_request(window, request);
let result = resolve_launch(host, window, module, test, build, &cancel).await;
host.end_request(request);
result
}
async fn resolve_launch(
host: &Host,
window: &str,
module: PathBuf,
test: bool,
build: bool,
cancel: &CancellationToken,
) -> IdeResult<JavaLaunchInfo> {
let started = std::time::Instant::now();
let project = host.project(window)?;
let module = project.root.join(module);
let settings = project.settings.effective();
let built = if build && compile::has_non_java_sources(&module) {
let (root, cache, module, settings) =
(project.root.clone(), project.build_cache_dir(), module.clone(), settings.clone());
let (model, external, delegated) = {
let project = project.clone();
let settings = settings.clone();
let module = module.clone();
tokio::task::spawn_blocking(move || {
compile::build_for_launch(&root, &load(&root, &cache, &settings), &module, test, &CancellationToken::new())
project.java_launch_snapshots.lock().initialize();
let model = load(&project.root, &project.build_cache_dir(), &settings);
let non_java = build && compile::launch_has_non_java_sources(&project.root, &model, &module, test);
let delegated = build
&& model.maven.as_ref().is_some_and(|m| m.projects.iter().any(|p| project.root.join(&p.dir) == module))
&& (non_java || cache::has_record(&project.root, &project.build_cache_dir(), &module));
let external = non_java || delegated;
(model, external, delegated)
})
.await
.map_err(|e| IdeError::internal(e.to_string()))??
} else {
Vec::new()
.map_err(|e| IdeError::internal(e.to_string()))?
};
let mut info = project.lsp.java_launch(&module, test, build).await?;
let mut class_paths: Vec<String> = built.iter().map(|p| p.to_string_lossy().into_owned()).collect();
for p in std::mem::take(&mut info.class_paths) {
if !class_paths.contains(&p) {
class_paths.push(p);
let external_build = if delegated { Some(project.lsp.java_external_build(&module, cancel).await?) } else { None };
let mut info = project.lsp.java_launch(&module, test, build && !delegated, cancel).await?;
let lsp_ms = started.elapsed().as_millis();
let data_dir = host.data_dir.clone();
let cancel = cancel.clone();
tokio::task::spawn_blocking(move || {
let _external_build = external_build;
let built = if delegated {
let outputs = project.java_launch_snapshots.lock().build(
&project.root,
&model,
&module,
test,
ide_sdk::apply::maven_local_repository(&project.root, &settings),
&cancel,
)?;
let copies = use_snapshot(&mut info, &outputs.originals, &outputs.snapshot);
info.build = Some(JavaBuildStatus::Succeeded);
info.errors.clear();
info.error_count = 0;
copies
} else if external {
compile::build_for_launch(&project.root, &model, &module, test, &cancel)?
} else {
Vec::new()
};
let mut class_paths: Vec<String> = built.iter().map(|p| p.to_string_lossy().into_owned()).collect();
for p in std::mem::take(&mut info.class_paths) {
if !class_paths.contains(&p) {
class_paths.push(p);
}
}
info.class_paths = class_paths;
let compile_ms = started.elapsed().as_millis() - lsp_ms;
// The project JDK, else the JDK jdtls compiles the module against, else one matching its Java version.
let project_jdk = ide_sdk::apply::project_jdk(&project.root, &settings);
let from_jdtls = info.java_home.as_deref().map(ide_sdk::java::java_executable).filter(|p| p.is_file());
let java = project_jdk.map(|j| ide_sdk::java::java_executable(&j.home)).or(from_jdtls).or_else(|| {
let release = ide_sdk::java::project_java_version(&module);
let jdks = ide_sdk::java::installed_jdks();
info.hints.extend(ide_sdk::java::missing_jdk_message(&jdks, release));
ide_sdk::java::pick_jdk(&jdks, release).map(|j| ide_sdk::java::java_executable(&j.home))
});
info.java_exec = Some(java.map(|p| p.to_string_lossy().into_owned()).unwrap_or_else(|| "java".to_owned()));
let exec = PathBuf::from(info.java_exec.as_deref().unwrap_or("java"));
let shorten =
ide_run::java_cmdline::shortening_for(&exec, &info.class_paths, &info.module_paths, cfg!(windows));
info.shorten_command_line = Some(shorten.as_str().to_owned());
if info.error_count > 0 {
let own = ide_sdk::lombok::agent_in_args(settings.languages.java.jdtls_vm_args.split_whitespace());
let managed = ide_tools::managed_bundle(&data_dir, "lombok");
let repo = ide_sdk::apply::maven_local_repository(&project.root, &settings);
let root = project.root.clone();
// Detection may read the project's sources.
let lombok = ide_sdk::lombok::lombok_agent(&root, own, managed, repo);
info.hints.extend(lombok.problem());
}
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
tracing::info!(
lsp_ms,
compile_ms,
jdk_ms = started.elapsed().as_millis() - lsp_ms - compile_ms,
total_ms = started.elapsed().as_millis(),
"Java launch resolved"
);
Ok(info)
})
.await
.map_err(|e| IdeError::internal(e.to_string()))?
}
fn use_snapshot(info: &mut JavaLaunchInfo, built: &[PathBuf], snapshot: &std::path::Path) -> Vec<PathBuf> {
let mut copies = Vec::new();
for (index, source) in built.iter().enumerate() {
let dest = snapshot.join(index.to_string());
let original = source.to_string_lossy();
info.class_paths.retain(|p| p != original.as_ref());
let mut modular = false;
for path in &mut info.module_paths {
if path == original.as_ref() {
*path = dest.to_string_lossy().into_owned();
modular = true;
}
}
if !modular {
copies.push(dest);
}
}
info.class_paths = class_paths;
// The project JDK, else the JDK jdtls compiles the module against, else one matching its Java version.
let project_jdk = ide_sdk::apply::project_jdk(&project.root, &settings);
let from_jdtls = info.java_home.as_deref().map(ide_sdk::java::java_executable).filter(|p| p.is_file());
let java = project_jdk.map(|j| ide_sdk::java::java_executable(&j.home)).or(from_jdtls).or_else(|| {
let release = ide_sdk::java::project_java_version(&module);
let jdks = ide_sdk::java::installed_jdks();
info.hints.extend(ide_sdk::java::missing_jdk_message(&jdks, release));
ide_sdk::java::pick_jdk(&jdks, release).map(|j| ide_sdk::java::java_executable(&j.home))
});
info.java_exec = Some(java.map(|p| p.to_string_lossy().into_owned()).unwrap_or_else(|| "java".to_owned()));
let exec = PathBuf::from(info.java_exec.as_deref().unwrap_or("java"));
let shorten = ide_run::java_cmdline::shortening_for(&exec, &info.class_paths, &info.module_paths, cfg!(windows));
info.shorten_command_line = Some(shorten.as_str().to_owned());
if info.error_count > 0 {
let own = ide_sdk::lombok::agent_in_args(settings.languages.java.jdtls_vm_args.split_whitespace());
let managed = ide_tools::managed_bundle(&host.data_dir, "lombok");
let repo = ide_sdk::apply::maven_local_repository(&project.root, &settings);
let root = project.root.clone();
// Detection may read the project's sources.
let lombok = tokio::task::spawn_blocking(move || ide_sdk::lombok::lombok_agent(&root, own, managed, repo))
.await
.map_err(|e| IdeError::internal(e.to_string()))?;
info.hints.extend(lombok.problem());
}
Ok(info)
copies
}
/// Port of java-debug's DAP server inside the jdtls serving `module` (started on demand).
pub async fn java_debug_server(host: &Host, window: &str, module: PathBuf) -> IdeResult<u16> {
host.require_trust(window)?;
let project = host.project(window)?;
let module = project.root.join(module);
project.lsp.java_debug_server(&module).await
@@ -80,16 +165,55 @@ pub async fn java_build_workspace(
window: &str,
module: PathBuf,
) -> IdeResult<ide_lsp::jdtls::JavaBuildStatus> {
host.require_trust(window)?;
let project = host.project(window)?;
let module = project.root.join(module);
project.lsp.java_build(&module).await
project.lsp.java_build(&module, &CancellationToken::new()).await
}
/// Commands of this module the remote server answers.
pub(crate) fn register(d: &mut Dispatcher) {
d.add("java_resolve_launch", |c| async move {
java_resolve_launch(&c.host, &c.window, c.arg("module")?, c.arg("test")?, c.arg("build")?).await
java_resolve_launch(&c.host, &c.window, c.arg("module")?, c.arg("test")?, c.arg("build")?, c.arg("request")?)
.await
});
d.add("java_debug_server", |c| async move { java_debug_server(&c.host, &c.window, c.arg("module")?).await });
d.add("java_build_workspace", |c| async move { java_build_workspace(&c.host, &c.window, c.arg("module")?).await });
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn launch_snapshots_survive_jdt_overwrites_and_replace_module_outputs() {
let root = tempfile::tempdir().unwrap();
let app = root.path().join("app/target/classes");
let lib = root.path().join("lib/target/classes");
for dir in [&app, &lib] {
std::fs::create_dir_all(dir).unwrap();
std::fs::write(dir.join("Channel.class"), "Maven bytecode").unwrap();
}
let mut info = JavaLaunchInfo {
class_paths: vec![app.to_string_lossy().into_owned(), "stdlib.jar".into()],
module_paths: vec![lib.to_string_lossy().into_owned()],
..Default::default()
};
let snapshot = tempfile::tempdir().unwrap();
for (i, source) in [&app, &lib].iter().enumerate() {
ide_vfs::copy(source, &snapshot.path().join(i.to_string())).unwrap();
}
let paths = use_snapshot(&mut info, &[app.clone(), lib.clone()], snapshot.path());
assert_eq!(paths, [snapshot.path().join("0")]);
assert_eq!(info.class_paths, ["stdlib.jar"]);
assert_eq!(info.module_paths, [snapshot.path().join("1").to_string_lossy().into_owned()]);
std::fs::write(app.join("Channel.class"), "JDT error stub").unwrap();
std::fs::remove_dir_all(lib).unwrap();
for index in ["0", "1"] {
assert_eq!(
std::fs::read_to_string(snapshot.path().join(index).join("Channel.class")).unwrap(),
"Maven bytecode"
);
}
}
}
+371
View File
@@ -0,0 +1,371 @@
//! Immutable Maven outputs retained in the project cache, including across close/reopen.
use ide_build::compile::{self, LaunchFingerprint};
use ide_core::{CancellationToken, IdeError, IdeResult};
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Serialize, Deserialize)]
pub(super) struct Outputs {
fingerprint: Option<LaunchFingerprint>,
pub snapshot: PathBuf,
pub originals: Vec<PathBuf>,
}
impl Outputs {
fn reusable(&self, fingerprint: &Option<LaunchFingerprint>, snapshots: &Path) -> bool {
fingerprint.is_some()
&& self.fingerprint == *fingerprint
&& self.snapshot.parent() == Some(snapshots)
&& !self.originals.is_empty()
&& (0..self.originals.len()).all(|i| self.snapshot.join(i.to_string()).is_dir())
}
}
fn record_path(root: &Path, cache: &Path, module: &Path, test: bool) -> Option<PathBuf> {
Some(cache.join("java-launch/records").join(module.strip_prefix(root).ok()?).join(if test {
"test.json"
} else {
"main.json"
}))
}
pub(super) fn has_record(root: &Path, cache: &Path, module: &Path) -> bool {
[false, true].into_iter().any(|test| record_path(root, cache, module, test).is_some_and(|record| record.is_file()))
}
/// Owned by Project: running JVMs may still load superseded outputs until it drops.
pub(crate) struct Session {
cache: PathBuf,
initialized: bool,
unreferenced: Vec<PathBuf>,
}
impl Session {
pub(crate) fn new(cache: PathBuf) -> Self {
Self { cache, initialized: false, unreferenced: Vec::new() }
}
pub(super) fn initialize(&mut self) {
if !self.initialized {
self.initialized = true;
if let Err(error) = sweep(&self.cache) {
tracing::warn!(%error, "Cannot clean previous Java launch snapshots");
}
}
}
pub(super) fn build(
&mut self,
root: &Path,
model: &ide_build::BuildModel,
module: &Path,
test: bool,
local_repo: Option<PathBuf>,
cancel: &CancellationToken,
) -> IdeResult<Outputs> {
self.initialize();
let cache = &self.cache;
let record =
record_path(root, cache, module, test).ok_or_else(|| IdeError::internal("launch outside project"))?;
let snapshots = cache.join("java-launch/snapshots");
let fingerprint = || match compile::launch_fingerprint(root, model, module, test, local_repo.clone(), cancel) {
Ok(value) => Ok(value),
Err(e) if cancel.is_cancelled() => Err(e),
Err(e) => {
tracing::debug!(error = %e, "Cannot fingerprint Maven launch; rebuilding");
Ok(None)
}
};
let before = fingerprint()?;
let previous = fs::read(&record).ok().and_then(|bytes| serde_json::from_slice::<Outputs>(&bytes).ok());
if let Some(outputs) = previous.as_ref()
&& outputs.reusable(&before, &snapshots)
{
tracing::info!(module = %module.display(), test, "Reusing Maven launch snapshot");
return Ok(previous.unwrap());
}
// Retain a non-reusable record even on failure, so removing the final mixed
// source still delegates to Maven to clean its old classes on the next launch.
fs::create_dir_all(record.parent().unwrap())?;
fs::write(&record, b"null")?;
if let Some(previous) = previous {
self.unreferenced.push(previous.snapshot);
}
let originals = compile::build_for_launch(root, model, module, test, cancel)?;
fs::create_dir_all(&snapshots)?;
let snapshot = tempfile::Builder::new().prefix("launch-").tempdir_in(&snapshots)?.keep();
// Retain every new snapshot even if copying or publishing its record fails.
self.unreferenced.push(snapshot.clone());
for (i, source) in originals.iter().enumerate() {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
ide_vfs::copy(source, &snapshot.join(i.to_string()))?;
}
let after = fingerprint()?;
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let mut outputs = Outputs { fingerprint: None, snapshot, originals };
if before.is_some() && before == after {
outputs.fingerprint = before;
fs::create_dir_all(record.parent().unwrap())?;
let mut file = tempfile::NamedTempFile::new_in(record.parent().unwrap())?;
serde_json::to_writer(file.as_file_mut(), &outputs).map_err(|e| IdeError::internal(e.to_string()))?;
file.persist(&record).map_err(|e| IdeError::from(e.error))?;
self.unreferenced.retain(|path| path != &outputs.snapshot);
}
Ok(outputs)
}
}
impl Drop for Session {
fn drop(&mut self) {
// Recheck all records: another module or launch mode may reference a path.
if let Ok(referenced) = referenced_snapshots(&self.cache) {
let snapshots = self.cache.join("java-launch/snapshots");
for path in &self.unreferenced {
remove_unreferenced(path, &snapshots, &referenced);
}
}
}
}
fn referenced_snapshots(cache: &Path) -> std::io::Result<HashSet<PathBuf>> {
let mut referenced = HashSet::new();
let records = cache.join("java-launch/records");
if !records.try_exists()? {
return Ok(referenced);
}
let mut pending = vec![records];
while let Some(dir) = pending.pop() {
for entry in fs::read_dir(dir)? {
let entry = entry?;
let kind = entry.file_type()?;
if kind.is_dir() {
pending.push(entry.path());
} else if kind.is_file() && entry.path().extension().is_some_and(|ext| ext == "json") {
let outputs: Option<Outputs> = serde_json::from_slice(&fs::read(entry.path())?)?;
if let Some(outputs) = outputs
&& let Ok(path) = fs::canonicalize(outputs.snapshot)
{
referenced.insert(path);
}
}
}
}
Ok(referenced)
}
fn remove_unreferenced(path: &Path, snapshots: &Path, referenced: &HashSet<PathBuf>) {
// Only real launch-* directories directly inside snapshots, never symlinks.
if path.parent() != Some(snapshots)
|| !path.file_name().is_some_and(|name| name.to_string_lossy().starts_with("launch-"))
|| !fs::symlink_metadata(path).is_ok_and(|meta| meta.file_type().is_dir())
{
return;
}
if let (Ok(path), Ok(snapshots)) = (fs::canonicalize(path), fs::canonicalize(snapshots))
&& path.parent() == Some(snapshots.as_path())
&& !referenced.contains(&path)
&& let Err(error) = fs::remove_dir_all(&path)
{
tracing::warn!(%error, path = %path.display(), "Cannot remove Java launch snapshot");
}
}
fn sweep(cache: &Path) -> std::io::Result<()> {
let referenced = referenced_snapshots(cache)?;
let snapshots = cache.join("java-launch/snapshots");
if snapshots.try_exists()? {
for entry in fs::read_dir(&snapshots)? {
remove_unreferenced(&entry?.path(), &snapshots, &referenced);
}
}
Ok(())
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
use std::os::unix::fs::PermissionsExt;
fn fixture(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\ncp src/main/kotlin/Main.kt target/classes/Main.class\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 successful_snapshots_reuse_across_close_and_rebuild_for_changes_missing_outputs_or_failure() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let model = fixture(root);
let cache = root.join("cache");
let cancel = CancellationToken::new();
let mut session = Session::new(cache.clone());
let mut build = || session.build(root, &model, root, false, None, &cancel);
let first = build().unwrap();
let snapshot = first.snapshot.clone();
drop(first);
// Dropping an Outputs value leaves the persisted snapshot reusable.
let second = build().unwrap();
assert_eq!(snapshot, second.snapshot);
assert_eq!(fs::read_to_string(root.join("calls")).unwrap().lines().count(), 1);
fs::write(root.join("target/classes/Main.class"), "JDT stub").unwrap();
assert_eq!(snapshot, build().unwrap().snapshot);
assert_eq!(fs::read_to_string(snapshot.join("0/Main.class")).unwrap(), "first");
fs::write(root.join("src/main/kotlin/Main.kt"), "updated").unwrap();
let updated = build().unwrap();
assert_ne!(snapshot, updated.snapshot);
assert_eq!(fs::read_to_string(updated.snapshot.join("0/Main.class")).unwrap(), "updated");
assert!(snapshot.join("0/Main.class").is_file(), "previous JVMs may still load it");
fs::remove_dir_all(updated.snapshot.join("0")).unwrap();
let rebuilt = build().unwrap();
assert_ne!(rebuilt.snapshot, updated.snapshot);
fs::write(root.join("src/main/kotlin/broken"), "broken").unwrap();
assert!(build().is_err());
assert_eq!(fs::read(cache.join("java-launch/records/main.json")).unwrap(), b"null");
fs::remove_file(root.join("src/main/kotlin/broken")).unwrap();
assert_ne!(rebuilt.snapshot, build().unwrap().snapshot, "a failed build invalidates success");
cancel.cancel();
assert!(build().is_err());
assert_eq!(fs::read_to_string(root.join("calls")).unwrap().lines().count(), 5);
}
#[test]
fn session_drop_removes_superseded_snapshots_and_preserves_records_across_reopen() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let model = fixture(root);
let cache = root.join("cache");
let cancel = CancellationToken::new();
let mut session = Session::new(cache.clone());
let first = session.build(root, &model, root, false, None, &cancel).unwrap();
fs::write(root.join("src/main/kotlin/Main.kt"), "updated").unwrap();
let second = session.build(root, &model, root, false, None, &cancel).unwrap();
assert!(first.snapshot.is_dir());
assert!(second.snapshot.is_dir());
// Another mode can keep a superseded snapshot alive.
let test_record = cache.join("java-launch/records/nested/test.json");
fs::create_dir_all(test_record.parent().unwrap()).unwrap();
fs::write(&test_record, serde_json::to_vec(&first).unwrap()).unwrap();
drop(session);
assert!(first.snapshot.is_dir());
assert!(second.snapshot.is_dir());
let mut session = Session::new(cache.clone());
let reused = session.build(root, &model, root, false, None, &cancel).unwrap();
assert_eq!(reused.snapshot, second.snapshot);
fs::write(root.join("src/main/kotlin/Main.kt"), "third version").unwrap();
let third = session.build(root, &model, root, false, None, &cancel).unwrap();
assert!(second.snapshot.is_dir());
drop(session);
assert!(!second.snapshot.exists());
assert!(first.snapshot.is_dir());
assert!(third.snapshot.is_dir());
assert_eq!(fs::read_to_string(root.join("calls")).unwrap().lines().count(), 3);
}
#[test]
fn initial_sweep_only_removes_unreferenced_direct_snapshot_directories() {
let dir = tempfile::tempdir().unwrap();
let cache = dir.path().join("cache");
let snapshots = cache.join("java-launch/snapshots");
let retained = snapshots.join("launch-retained");
let orphan = snapshots.join("launch-orphan");
let other = snapshots.join("other");
let nested = other.join("launch-nested");
let outside = dir.path().join("launch-outside");
for path in [&retained, &orphan, &nested, &outside] {
fs::create_dir_all(path).unwrap();
}
fs::write(outside.join("classes"), "keep").unwrap();
std::os::unix::fs::symlink(&outside, snapshots.join("launch-link")).unwrap();
fs::write(snapshots.join("launch-file"), "keep").unwrap();
let record = cache.join("java-launch/records/deep/module/test.json");
fs::create_dir_all(record.parent().unwrap()).unwrap();
let outputs = Outputs { fingerprint: None, snapshot: retained.clone(), originals: Vec::new() };
fs::write(record, serde_json::to_vec(&outputs).unwrap()).unwrap();
let mut session = Session::new(cache);
session.initialize();
assert!(!orphan.exists());
assert!(retained.is_dir());
assert!(nested.is_dir());
assert!(outside.join("classes").is_file());
assert!(snapshots.join("launch-link").is_symlink());
assert!(snapshots.join("launch-file").is_file());
// Initialization must not sweep snapshots still in use by this session.
fs::create_dir_all(&orphan).unwrap();
session.initialize();
assert!(orphan.is_dir());
session.unreferenced.extend([outside.clone(), snapshots.join("../../launch-outside"), nested]);
drop(session);
assert!(outside.join("classes").is_file());
assert!(other.join("launch-nested").is_dir());
}
#[test]
fn failed_record_publish_retains_snapshot_until_session_drop() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let model = fixture(root);
let script = root.join("mvnw");
let mut text = fs::read_to_string(&script).unwrap();
text.push_str("rm cache/java-launch/records/main.json\nmkdir cache/java-launch/records/main.json\n");
fs::write(script, text).unwrap();
let cache = root.join("cache");
let mut session = Session::new(cache.clone());
assert!(session.build(root, &model, root, false, None, &CancellationToken::new()).is_err());
assert_eq!(session.unreferenced.len(), 1);
let snapshot = session.unreferenced[0].clone();
assert!(snapshot.join("0/Main.class").is_file());
drop(session);
assert!(!snapshot.exists());
}
#[test]
fn edits_during_build_do_not_publish_a_success_record() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let model = fixture(root);
let script = root.join("mvnw");
let mut text = fs::read_to_string(&script).unwrap();
text.push_str("echo edited-during-build >> src/main/kotlin/Main.kt\n");
fs::write(script, text).unwrap();
let cache = root.join("cache");
let mut session = Session::new(cache.clone());
let outputs = session.build(root, &model, root, false, None, &CancellationToken::new()).unwrap();
assert!(outputs.fingerprint.is_none());
assert_eq!(fs::read(cache.join("java-launch/records/main.json")).unwrap(), b"null");
assert!(outputs.snapshot.join("0/Main.class").is_file());
drop(session);
assert!(!outputs.snapshot.exists());
}
#[test]
fn deleting_the_last_mixed_source_keeps_maven_delegation_after_failure() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let model = fixture(root);
let cache = root.join("cache");
let cancel = CancellationToken::new();
let mut session = Session::new(cache.clone());
let first = session.build(root, &model, root, false, None, &cancel).unwrap();
fs::remove_file(root.join("src/main/kotlin/Main.kt")).unwrap();
assert!(!compile::launch_has_non_java_sources(root, &model, root, false));
assert!(has_record(root, &cache, root));
assert!(session.build(root, &model, root, false, None, &cancel).is_err());
assert!(has_record(root, &cache, root));
fs::write(root.join("src/main/kotlin/Main.kt"), "restored").unwrap();
let restored = session.build(root, &model, root, false, None, &cancel).unwrap();
assert_ne!(first.snapshot, restored.snapshot);
}
}
+102
View File
@@ -0,0 +1,102 @@
//! Bounded, read-only JVM presentation for visible files, also served remotely.
use crate::Host;
use ide_core::{CancellationToken, IdeError, IdeResult, LanguageId};
use ide_syntax::{JvmPresentation, Snapshot};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::{
collections::HashMap,
fs::File,
io::Read,
path::PathBuf,
sync::{Mutex, OnceLock},
time::SystemTime,
};
pub const PREFIX_BYTES: u64 = 64 * 1024;
const MAX_BATCH: usize = 64;
const MAX_CACHE: usize = 4096;
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct JvmFilePresentation {
pub path: PathBuf,
pub presentation: JvmPresentation,
}
#[derive(Clone)]
struct Cached {
modified: SystemTime,
size: u64,
presentation: JvmPresentation,
}
fn prefix(path: &PathBuf) -> std::io::Result<String> {
let mut bytes = Vec::new();
File::open(path)?.take(PREFIX_BYTES).read_to_end(&mut bytes)?;
// A truncated final UTF-8 character must not invalidate the entire prefix.
Ok(String::from_utf8_lossy(&bytes).into_owned())
}
pub async fn jvm_file_presentations(
host: &Host,
window: &str,
paths: Vec<PathBuf>,
) -> IdeResult<Vec<JvmFilePresentation>> {
if paths.len() > MAX_BATCH {
return Err(IdeError::invalid("at most 64 JVM files per batch"));
}
let project = host.project(window)?;
host.blocking(move || {
static CACHE: OnceLock<Mutex<HashMap<PathBuf, Cached>>> = OnceLock::new();
let cache = CACHE.get_or_init(Default::default);
let mut results = Vec::new();
for path in paths {
let language = match path.extension().and_then(|e| e.to_str()) {
Some("java") => LanguageId::new("java"),
Some("kt") => LanguageId::new("kotlin"),
_ => continue,
};
let Ok(meta) = path.metadata() else { continue };
if !meta.is_file() {
continue;
}
let Ok(modified) = meta.modified() else { continue };
let hit =
cache.lock().unwrap().get(&path).filter(|c| c.modified == modified && c.size == meta.len()).cloned();
let presentation = if let Some(hit) = hit {
hit.presentation
} else {
let Ok(source) = prefix(&path) else { continue };
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or_default();
let presentation = project.structure.jvm_presentation(
&Snapshot::text(&language, &source),
stem,
&CancellationToken::new(),
)?;
let mut cache = cache.lock().unwrap();
if cache.len() >= MAX_CACHE {
cache.clear();
}
cache.insert(path.clone(), Cached { modified, size: meta.len(), presentation: presentation.clone() });
presentation
};
results.push(JvmFilePresentation { path, presentation });
}
Ok(results)
})
.await
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn huge_files_read_only_a_prefix() {
let path = std::env::temp_dir().join(format!("openidea-icon-prefix-{}.java", std::process::id()));
let file = File::create(&path).unwrap();
file.set_len(100 * 1024 * 1024).unwrap();
assert_eq!(prefix(&path).unwrap().len(), PREFIX_BYTES as usize);
std::fs::remove_file(path).unwrap();
}
}
+118 -10
View File
@@ -83,12 +83,18 @@ pub async fn lsp_implementations(
result
}
/// Contents of a virtual document (library class, decompiled code) that a
/// language server returned a non-`file:` URI for. Cached per project.
/// Contents of a read-only language-server document, including JDT library
/// classes and disk class files. Cached per project.
pub async fn lsp_virtual_document(host: &Host, window: &str, uri: String) -> IdeResult<VirtualDocument> {
host.project(window)?.lsp.virtual_document(&uri).await
}
/// 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);
host.project(window)?.lsp.java_class_file(&path, archive).await
}
/// 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. Cancel with `cancel_request(request)`.
@@ -186,8 +192,19 @@ pub async fn lsp_prepare_rename(host: &Host, window: &str, doc: DocId, pos: Pos)
}
/// Computes the rename edit without applying it, so the UI can preview conflicts.
pub async fn lsp_rename(host: &Host, window: &str, doc: DocId, pos: Pos, new_name: String) -> IdeResult<WorkspaceEdit> {
host.project(window)?.lsp.rename(doc, pos, new_name).await
pub async fn lsp_rename(
host: &Host,
window: &str,
request: RequestId,
doc: DocId,
pos: Pos,
new_name: String,
) -> IdeResult<Renamed> {
let lsp = host.project(window)?.lsp.clone();
let cancel = host.begin_request(request);
let result = lsp.rename(doc, pos, new_name, cancel).await;
host.end_request(request);
result
}
pub async fn lsp_format(
@@ -197,7 +214,7 @@ pub async fn lsp_format(
range: Option<Range>,
options: FormatOptions,
) -> IdeResult<Vec<TextEdit>> {
host.project(window)?.lsp.format(doc, range, options).await
crate::formatter::format(host, window, doc, range, options).await
}
pub async fn lsp_inlay_hints(host: &Host, window: &str, doc: DocId, range: Range) -> IdeResult<Vec<InlayHint>> {
@@ -220,11 +237,16 @@ pub async fn lsp_document_links(host: &Host, window: &str, doc: DocId) -> IdeRes
pub async fn lsp_call_hierarchy(
host: &Host,
window: &str,
request: RequestId,
doc: DocId,
pos: Pos,
direction: HierarchyDirection,
) -> IdeResult<Vec<HierarchyItem>> {
host.project(window)?.lsp.call_hierarchy(doc, pos, direction).await
) -> IdeResult<CallHierarchy> {
let lsp = host.project(window)?.lsp.clone();
let cancel = host.begin_request(request);
let result = lsp.call_hierarchy(doc, pos, direction, cancel).await;
host.end_request(request);
result
}
pub async fn lsp_type_hierarchy(
@@ -247,7 +269,13 @@ pub fn lsp_resend_diagnostics(host: &Host, window: &str) -> IdeResult<()> {
}
pub async fn lsp_restart(host: &Host, window: &str, server: ServerId) -> IdeResult<()> {
host.project(window)?.lsp.restart(server).await
let project = host.project(window)?;
let java = project.lsp.servers().iter().any(|s| s.id == server && s.name == "jdtls");
project.lsp.restart(server).await?;
if java && let Some(bridge) = &project.kotlin_bridge {
bridge.schedule();
}
Ok(())
}
/// Applies a (previewed) workspace edit, e.g. after rename confirmation.
@@ -274,6 +302,58 @@ pub async fn lsp_execute_command(host: &Host, window: &str, doc: DocId, command:
host.project(window)?.lsp.execute_command(doc, command).await
}
/// Resolves an interactive command without applying edits or executing it.
pub async fn lsp_resolve_command(
host: &Host,
window: &str,
request: RequestId,
doc: DocId,
server: ServerId,
command: Command,
answers: Vec<FormAnswer>,
) -> IdeResult<Option<CommandForm>> {
let lsp = host.project(window)?.lsp.clone();
let cancel = host.begin_request(request);
let result = lsp.resolve_command(doc, server, command, answers, cancel).await;
host.end_request(request);
result
}
/// Applies an explicitly invoked action after the interactive answers have been validated.
pub async fn lsp_execute_code_action(
host: &Host,
window: &str,
request: RequestId,
doc: DocId,
action: CodeAction,
answers: Vec<FormAnswer>,
) -> IdeResult<Vec<ide_core::IpcPath>> {
let project = host.project(window)?;
let cancel = host.begin_request(request);
let result = async {
if cancel.is_cancelled() {
return Err(ide_core::IdeError::cancelled());
}
if let Some(edit) = action.edit {
let documents = project.documents.clone();
tokio::task::spawn_blocking(move || apply_edit(&documents, &edit))
.await
.map_err(|e| ide_core::IdeError::internal(e.to_string()))??;
}
match action.command {
Some(command) => project
.lsp
.execute_action_command(doc, action.server, command, answers, cancel)
.await
.map(|paths| paths.into_iter().map(ide_core::IpcPath::from).collect()),
None => Ok(Vec::new()),
}
}
.await;
host.end_request(request);
result
}
/// Commands of this module the remote server answers.
pub(crate) fn register(d: &mut Dispatcher) {
d.add("lsp_completion", |c| async move {
@@ -299,6 +379,10 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("lsp_implementations", |c| async move {
lsp_implementations(&c.host, &c.window, c.arg("request")?, c.arg("doc")?, c.arg("positions")?).await
});
d.add(
"lsp_resolve_class_file",
|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_references", |c| async move {
lsp_references(
@@ -335,7 +419,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
lsp_prepare_rename(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?).await
});
d.add("lsp_rename", |c| async move {
lsp_rename(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?, c.arg("newName")?).await
lsp_rename(&c.host, &c.window, c.arg("request")?, c.arg("doc")?, c.arg("pos")?, c.arg("newName")?).await
});
d.add("lsp_format", |c| async move {
lsp_format(&c.host, &c.window, c.arg("doc")?, c.arg("range")?, c.arg("options")?).await
@@ -348,7 +432,8 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("lsp_code_lens", |c| async move { lsp_code_lens(&c.host, &c.window, c.arg("doc")?).await });
d.add("lsp_document_links", |c| async move { lsp_document_links(&c.host, &c.window, c.arg("doc")?).await });
d.add("lsp_call_hierarchy", |c| async move {
lsp_call_hierarchy(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?, c.arg("direction")?).await
lsp_call_hierarchy(&c.host, &c.window, c.arg("request")?, c.arg("doc")?, c.arg("pos")?, c.arg("direction")?)
.await
});
d.add("lsp_type_hierarchy", |c| async move {
lsp_type_hierarchy(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?, c.arg("direction")?).await
@@ -357,6 +442,29 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("lsp_resend_diagnostics", |c| async move { lsp_resend_diagnostics(&c.host, &c.window) });
d.add("lsp_restart", |c| async move { lsp_restart(&c.host, &c.window, c.arg("server")?).await });
d.add("apply_workspace_edit", |c| async move { apply_workspace_edit(&c.host, &c.window, c.arg("edit")?) });
d.add("lsp_resolve_command", |c| async move {
lsp_resolve_command(
&c.host,
&c.window,
c.arg("request")?,
c.arg("doc")?,
c.arg("server")?,
c.arg("command")?,
c.arg("answers")?,
)
.await
});
d.add("lsp_execute_code_action", |c| async move {
lsp_execute_code_action(
&c.host,
&c.window,
c.arg("request")?,
c.arg("doc")?,
c.arg("action")?,
c.arg("answers")?,
)
.await
});
d.add("lsp_execute_command", |c| async move {
lsp_execute_command(&c.host, &c.window, c.arg("doc")?, c.arg("command")?).await
});
+5
View File
@@ -8,13 +8,16 @@ pub mod build;
pub mod bytes;
pub mod coverage;
pub mod debug;
pub mod deploy;
pub mod doc;
pub mod env;
pub mod fs;
pub mod history;
pub mod java;
pub mod jvm_icons;
pub mod lsp;
pub mod nav;
pub mod notebook;
pub mod project;
mod python;
pub mod refactor;
@@ -60,6 +63,7 @@ impl<T> Clone for Channel<T> {
pub fn register_all(d: &mut crate::rpc::Dispatcher) {
agent::register(d);
lsp::register(d);
notebook::register(d);
nav::register(d);
refactor::register(d);
analysis::register(d);
@@ -78,6 +82,7 @@ pub fn register_all(d: &mut crate::rpc::Dispatcher) {
search::register(d);
run::register(d);
env::register(d);
deploy::register(d);
}
impl Host {
+157
View File
@@ -0,0 +1,157 @@
//! Native notebook operations shared by desktop IPC and the remote dispatcher.
use crate::Host;
pub use crate::notebook::NotebookOutputEvent;
use crate::ops::Channel;
use crate::rpc::Dispatcher;
use ide_core::{IdeError, IdeResult, RequestId};
use ide_notebook::{NotebookEdit, NotebookSnapshot};
use std::path::PathBuf;
pub async fn open(host: &Host, window: &str, path: String) -> IdeResult<NotebookSnapshot> {
let project = host.project(window)?;
let path = PathBuf::from(path);
let path = if path.is_absolute() { path } else { project.root.join(path) };
let notebooks = project.notebooks.clone();
host.blocking(move || notebooks.open(&path)).await
}
pub fn get(host: &Host, window: &str, id: String) -> IdeResult<NotebookSnapshot> {
host.project(window)?.notebooks.get(&id)
}
pub async fn edit(
host: &Host,
window: &str,
id: String,
version: u32,
edit: NotebookEdit,
) -> IdeResult<NotebookSnapshot> {
let notebooks = host.project(window)?.notebooks.clone();
host.blocking(move || notebooks.edit(&id, version, edit)).await
}
pub async fn save(host: &Host, window: &str, id: String) -> IdeResult<NotebookSnapshot> {
let notebooks = host.project(window)?.notebooks.clone();
host.blocking(move || notebooks.save(&id)).await
}
pub async fn undo(host: &Host, window: &str, id: String) -> IdeResult<NotebookSnapshot> {
let notebooks = host.project(window)?.notebooks.clone();
host.blocking(move || notebooks.undo(&id, false)).await
}
pub async fn redo(host: &Host, window: &str, id: String) -> IdeResult<NotebookSnapshot> {
let notebooks = host.project(window)?.notebooks.clone();
host.blocking(move || notebooks.undo(&id, true)).await
}
pub async fn reload(host: &Host, window: &str, id: String) -> IdeResult<NotebookSnapshot> {
let notebooks = host.project(window)?.notebooks.clone();
// An abandoned IPC future must not leave a lifecycle transition half applied.
tokio::spawn(async move { notebooks.reload(&id, false).await })
.await
.map_err(|error| IdeError::internal(error.to_string()))?
}
pub async fn restart(host: &Host, window: &str, id: String) -> IdeResult<NotebookSnapshot> {
host.require_trust(window)?;
let notebooks = host.project(window)?.notebooks.clone();
tokio::spawn(async move { notebooks.reload(&id, true).await })
.await
.map_err(|error| IdeError::internal(error.to_string()))?
}
pub async fn close(host: &Host, window: &str, id: String) -> IdeResult<()> {
let notebooks = host.project(window)?.notebooks.clone();
tokio::spawn(async move { notebooks.close(&id).await })
.await
.map_err(|error| IdeError::internal(error.to_string()))?
}
pub async fn interrupt(host: &Host, window: &str, id: String) -> IdeResult<()> {
let notebooks = host.project(window)?.notebooks.clone();
tokio::spawn(async move { notebooks.interrupt(&id).await })
.await
.map_err(|error| IdeError::internal(error.to_string()))?
}
#[allow(clippy::too_many_arguments)]
pub async fn execute(
host: &Host,
window: &str,
id: String,
cells: Vec<String>,
python: Option<String>,
request: RequestId,
on_output: Channel<NotebookOutputEvent>,
) -> IdeResult<NotebookSnapshot> {
host.require_trust(window)?;
let project = host.project(window)?;
let cancel = host.begin_project_request(window, request);
let _request = RequestGuard { host, request, cancel: cancel.clone() };
// Reserve the WHOLE batch and capture source before asynchronous discovery.
let admission = project.notebooks.admit(&id, cells, cancel.clone())?;
let selected = match python {
Some(path) if path.trim().is_empty() => return Err(IdeError::invalid("select a Python interpreter")),
Some(path) => PathBuf::from(path),
None => {
let root = project.root.clone();
let settings = project.settings.effective();
tokio::select! {
biased;
_ = cancel.cancelled() => return Err(IdeError::cancelled()),
result = host.blocking(move || {
ide_sdk::apply::python_interpreter(&root, &settings)
.or_else(|| ide_sdk::detect(ide_sdk::SdkKind::Python, Some(&root))
.into_iter().next().map(|sdk| sdk.path.into_path_buf()))
.ok_or_else(|| IdeError::unavailable("select a Python environment with ipykernel installed"))
}) => result?,
}
}
};
// Do not canonicalize this path: resolving a venv symlink to base Python
// changes sys.prefix and executes in the wrong environment.
let python = if selected.is_absolute() { selected } else { project.root.join(selected) };
project.notebooks.execute(admission, python, on_output).await
}
struct RequestGuard<'a> {
host: &'a Host,
request: RequestId,
cancel: ide_core::CancellationToken,
}
impl Drop for RequestGuard<'_> {
fn drop(&mut self) {
// If the invocation is dropped, the owned batch still receives cancellation.
self.cancel.cancel();
self.host.end_request(self.request);
}
}
pub(crate) fn register(d: &mut Dispatcher) {
d.add("notebook_open", |c| async move { open(&c.host, &c.window, c.arg("path")?).await });
d.add("notebook_get", |c| async move { get(&c.host, &c.window, c.arg("id")?) });
d.add("notebook_edit", |c| async move {
edit(&c.host, &c.window, c.arg("id")?, c.arg("version")?, c.arg("edit")?).await
});
d.add("notebook_save", |c| async move { save(&c.host, &c.window, c.arg("id")?).await });
d.add("notebook_reload", |c| async move { reload(&c.host, &c.window, c.arg("id")?).await });
d.add("notebook_undo", |c| async move { undo(&c.host, &c.window, c.arg("id")?).await });
d.add("notebook_redo", |c| async move { redo(&c.host, &c.window, c.arg("id")?).await });
d.add("notebook_close", |c| async move { close(&c.host, &c.window, c.arg("id")?).await });
d.add("notebook_interrupt", |c| async move { interrupt(&c.host, &c.window, c.arg("id")?).await });
d.add("notebook_restart", |c| async move { restart(&c.host, &c.window, c.arg("id")?).await });
d.add("notebook_execute", |c| async move {
execute(
&c.host,
&c.window,
c.arg("id")?,
c.arg("cells")?,
c.arg("python")?,
c.arg("request")?,
c.sender("onOutput")?,
)
.await
});
}
+61 -2
View File
@@ -10,16 +10,20 @@ use std::sync::Arc;
#[derive(Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct ProjectInfo {
pub trusted: bool,
pub root: ide_core::IpcPath,
pub name: String,
pub is_git: bool,
pub settings: Settings,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace: Option<ide_workspace::workspace::Workspace>,
}
pub fn info(project: &Project) -> ProjectInfo {
let settings = project.settings.effective();
let custom = settings.general.project_name.trim();
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 {
@@ -28,6 +32,7 @@ pub fn info(project: &Project) -> ProjectInfo {
is_git: !project.repos.read().is_empty(),
settings,
root: project.root.clone().into(),
workspace: ide_workspace::workspace::Workspace::load(&project.root).ok().flatten(),
}
}
@@ -53,6 +58,42 @@ pub fn roots(host: &Host, window: &str) -> IdeResult<ide_workspace::ProjectRoots
Ok(host.project(window)?.roots())
}
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)?;
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.
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 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());
}
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())
}
/// What [`set_settings`] changed; the caller tells the windows.
pub struct SettingsApplied {
pub settings: Settings,
@@ -107,6 +148,14 @@ pub fn set_settings(
}
}
let affected = || if project_scope { current.iter().cloned().collect() } else { host.projects() };
if key == "editor" || key == "editor.encodings" || key == "editor.encodings.nativeToAscii" {
let affected = affected();
host.runtime.spawn_blocking(move || {
for project in affected {
project.documents.reload_properties();
}
});
}
// File type associations re-detect open documents (off the command thread).
if key == "files" || key.starts_with("files.fileType") {
let affected = affected();
@@ -123,13 +172,23 @@ pub fn set_settings(
host.apply_proxy();
}
// A new interpreter / toolchain / server executable restarts the language servers it affects.
if ["languages", "tools", "languageServers"].iter().any(|k| key == *k || key.starts_with(&format!("{k}."))) {
let code_style = key == "editor" || key == "editor.codeStyle" || key.starts_with("editor.codeStyle.");
let toolchain = ["languages", "tools", "languageServers", "projectStructure"]
.iter()
.any(|k| key == *k || key.starts_with(&format!("{k}.")));
// Code style only reaches servers as settings (e.g. jdtls' formatter profile); builds don't depend on it.
if code_style || toolchain {
for project in affected() {
if toolchain && let Some(bridge) = &project.kotlin_bridge {
bridge.schedule();
}
let lsp = project.lsp.clone();
host.runtime.spawn(async move { lsp.reconfigure().await });
}
}
let roots_changed = key == "projectStructure" || key.starts_with("projectStructure.");
let roots_changed = key == "projectStructure"
|| key.starts_with("projectStructure.")
|| "languages.java.maven.unlinkedProjects".starts_with(key);
// Content roots and excludes (files.exclude, ignored names, excluded folders) reach the
// index and the watchers without reopening the project (the rescan runs in the background).
if roots_changed || key == "files" || key.starts_with("files.") {
+113 -11
View File
@@ -6,27 +6,80 @@ use std::time::Duration;
/// Detected configs followed by user-defined ones (user ids win on conflict).
pub fn configs(host: &Host, window: &str) -> IdeResult<Vec<RunConfig>> {
host.require_trust(window)?;
let project = host.project(window)?;
let root = project.root.clone();
let user = ide_run::load_user_configs(&root)?;
let mut all: Vec<RunConfig> =
ide_run::detect(&root).into_iter().filter(|d| !user.iter().any(|u| u.id == d.id)).collect();
all.extend(user);
// Project interpreter / package manager / toolchains from Languages & Frameworks.
ide_sdk::apply::apply_to_run_configs(&mut all, &root, &project.settings.effective());
let root_suppressed = ide_run::load_suppressed_config_ids(&root)?;
let settings = project.settings.effective();
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()) {
let settings = ide_workspace::SettingsStore::load(&host.config_dir, Some(&member)).effective();
let saved = ide_run::load_user_configs(&member)?;
let suppressed = ide_run::load_suppressed_config_ids(&member)?;
let configs = detected_and_saved(&member, &settings, &saved, &suppressed);
let key = crate::project::project_key(&member);
let name = member.file_name().unwrap_or_default().to_string_lossy();
for mut config in configs {
for task in &mut config.extras.before_launch {
if task.kind == "run" {
task.target = format!("workspace:{}:{}", key, task.target);
}
}
config.id = format!("workspace:{}:{}", key, config.id);
config.name = format!("{name} / {}", config.name);
config.cwd = Some(config.cwd.map(|p| member.join(&*p)).unwrap_or_else(|| member.clone()).into());
config.detected = true;
if !user.iter().any(|u| u.id == config.id) && !root_suppressed.contains(&config.id) {
all.push(config);
}
}
}
}
Ok(all)
}
pub fn save_configs(host: &Host, window: &str, configs: &[RunConfig]) -> IdeResult<()> {
ide_run::save_user_configs(&host.project(window)?.root, configs)
/// A project's detected configurations (minus removed ones and unlinked Maven projects), then
/// its saved ones; toolchains from Languages & Frameworks applied. Gradle choices stay as saved:
/// derived defaults must not be written back as explicit environment.
fn detected_and_saved(
root: &std::path::Path,
settings: &ide_core::settings::Settings,
saved: &[RunConfig],
suppressed: &[String],
) -> Vec<RunConfig> {
let excluded_poms = crate::project::unlinked_maven_poms(root, settings);
let mut all: Vec<RunConfig> = ide_run::detect_excluding(root, &excluded_poms)
.into_iter()
.filter(|d| !suppressed.contains(&d.id) && !saved.iter().any(|u| u.id == d.id))
.collect();
all.extend(saved.iter().cloned());
for config in all.iter_mut().filter(|c| c.kind != "gradle") {
ide_sdk::apply::apply_to_run_configs(std::slice::from_mut(config), root, settings);
}
all
}
pub fn save_configs(
host: &Host,
window: &str,
configs: &[RunConfig],
removed_detected_ids: &[String],
) -> IdeResult<()> {
ide_run::save_user_configs_with_removals(&host.project(window)?.root, configs, removed_detected_ids)
}
pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit<OutputChunk>) -> IdeResult<RunInfo> {
host.require_trust(window)?;
let project = host.project(window)?;
if config.command.is_empty() {
return Err(IdeError::invalid("run configuration has no command"));
}
// Settings › Run Targets: over SSH.
if config.kind == "tomcat" && config.extras.target.as_deref().is_some_and(|t| !t.is_empty()) {
return Err(IdeError::invalid("Tomcat Local requires the local machine"));
}
if config.extras.target.as_deref().is_some_and(|t| !t.is_empty()) {
let settings = project.settings.effective();
let out = on_output.clone();
@@ -35,19 +88,68 @@ pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit
.run(&settings, &project.root, &config, project.sink.clone(), Box::new(move |c| out(c)))
.await;
}
let settings = project.settings.effective();
let member = ide_workspace::workspace::Workspace::load(&project.root)?.and_then(|workspace| {
let cwd = config.cwd.as_deref()?;
workspace.projects.into_iter().filter(|root| cwd.starts_with(root)).max_by_key(|root| root.components().count())
});
let root = member.as_ref().unwrap_or(&project.root);
let settings = match &member {
Some(root) => ide_workspace::SettingsStore::load(&host.config_dir, Some(root)).effective(),
None => project.settings.effective(),
};
// Configurations made in the UI name bare tools (`godot`) that may live outside PATH.
let mut config = config;
ide_sdk::apply::resolve_run_command(&mut config, &project.root, &settings);
if matches!(config.kind.as_str(), "gradle" | "tomcat") {
ide_sdk::apply::apply_to_run_configs(std::slice::from_mut(&mut config), root, &settings);
} else {
ide_sdk::apply::resolve_run_command(&mut config, root, &settings);
}
if config.kind == "go" {
ide_sdk::apply::apply_to_run_configs(std::slice::from_mut(&mut config), root, &settings);
ide_sdk::go::apply_to_run(&mut config, &settings.languages.go.build_tags);
}
if config.command == ide_sdk::godot::COMMAND {
return Err(IdeError::unavailable(ide_sdk::godot::NOT_FOUND));
}
if config.kind == "tomcat" {
if host.processes.list().iter().any(|r| r.running && r.config_id == config.id) {
return Err(IdeError::invalid("This Tomcat configuration is already running"));
}
let (root, cache) = (root.clone(), project.cache_dir.clone());
config = tokio::task::spawn_blocking(move || {
ide_run::tomcat::prepare(&root, &cache, &mut config)?;
Ok::<_, IdeError>(config)
})
.await
.map_err(|e| IdeError::internal(e.to_string()))??;
}
if config.kind == "go" && config.extras.params.get("scope").is_some_and(|s| s == "file") {
let root = project.root.clone();
config = host
.blocking(move || {
ide_run::go::prepare_test_file(&root, &mut config)?;
Ok(config)
})
.await?;
}
let encoding = settings.console.encoding;
let options = StartOptions {
encoding: Some(encoding).filter(|e| !e.trim().is_empty()),
python_paths: super::python::paths(&project),
python_paths: if member.is_some() { vec![root.clone()] } else { super::python::paths(&project) },
};
host.processes.start(&project.root, &config, &options, project.sink.clone(), Box::new(move |c| on_output(c)))
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();
sink = std::sync::Arc::new(move |event: ide_core::IdeEvent| {
if matches!(&event, ide_core::IdeEvent::RunExited { .. }) {
ide_sdk::libraries::invalidate_go(&root);
}
previous.emit(event);
});
}
host.processes.start(&project.root, &config, &options, sink, Box::new(move |c| on_output(c)))
}
/// Stops a run (interrupt, then kill after Settings › Run's termination timeout).
+167
View File
@@ -5,6 +5,78 @@ use crate::rpc::Dispatcher;
use ide_core::{IdeError, IdeResult};
use ide_run::RunConfig;
fn application_cache(project: &crate::project::Project, id: &str) -> std::path::PathBuf {
use std::hash::{Hash, Hasher};
let mut hash = std::collections::hash_map::DefaultHasher::new();
id.hash(&mut hash);
project.cache_dir.join("application-update").join(format!("{:x}", hash.finish()))
}
pub async fn run_application_build(
host: &Host,
window: &str,
config: RunConfig,
module: std::path::PathBuf,
) -> IdeResult<RunConfig> {
host.require_trust(window)?;
if config.kind != "spring-boot" || config.extras.target.as_ref().is_some_and(|t| !t.is_empty()) {
return Err(IdeError::invalid("Application resource updates require a local Spring Boot configuration"));
}
let project = host.project(window)?;
tokio::task::spawn_blocking(move || {
let model = crate::ops::build::load(&project.root, &project.build_cache_dir(), &project.settings.effective());
let plan = ide_build::application_update::plan(
&project.root,
&model,
&module,
&application_cache(&project, &config.id),
)?;
let mut env = plan.env;
env.extend(config.env);
Ok(RunConfig {
id: format!("application-build:{}", config.id),
name: format!("Update {}", config.name),
kind: plan.kind.into(),
command: plan.command,
args: plan.args,
cwd: Some(project.root.join(plan.cwd).into()),
env,
detected: false,
is_test: false,
debug_adapter: None,
allow_parallel: false,
extras: ide_run::RunExtras {
include_system_env: config.extras.include_system_env,
env_files: config.extras.env_files,
..Default::default()
},
})
})
.await
.map_err(|e| IdeError::internal(e.to_string()))?
}
pub async fn run_application_publish(
host: &Host,
window: &str,
config: RunConfig,
class_paths: Vec<String>,
main_class: String,
) -> IdeResult<()> {
host.require_trust(window)?;
let project = host.project(window)?;
tokio::task::spawn_blocking(move || {
ide_build::application_update::publish(
&project.root,
&application_cache(&project, &config.id),
&class_paths,
&main_class,
)
})
.await
.map_err(|e| IdeError::internal(e.to_string()))?
}
/// Configuration templates: per-type defaults applied to new configurations.
pub fn run_templates(host: &Host, window: &str) -> IdeResult<Vec<RunConfig>> {
let root = host.project(window)?.root.clone();
@@ -49,8 +121,103 @@ pub async fn run_test_reports(
.map_err(|e| IdeError::internal(e.to_string()))
}
pub async fn run_tomcat_home(host: &Host, window: &str, home: String) -> IdeResult<ide_run::tomcat::TomcatHome> {
host.require_trust(window)?;
let root = host.project(window)?.root.clone();
tokio::task::spawn_blocking(move || ide_run::tomcat::validate_home(&root.join(home)))
.await
.map_err(|e| IdeError::internal(e.to_string()))?
}
pub async fn run_tomcat_artifacts(host: &Host, window: &str) -> IdeResult<Vec<ide_build::web::WebArtifact>> {
host.require_trust(window)?;
let project = host.project(window)?;
tokio::task::spawn_blocking(move || {
let model = crate::ops::build::load(&project.root, &project.build_cache_dir(), &project.settings.effective());
ide_build::web::artifacts(&project.root, &model)
})
.await
.map_err(|e| IdeError::internal(e.to_string()))
}
pub async fn run_tomcat_build(host: &Host, window: &str, config: RunConfig) -> IdeResult<Option<RunConfig>> {
host.require_trust(window)?;
if config.extras.params.get("deployment").is_some_and(|v| v != "artifact") {
return Ok(None);
}
let project = host.project(window)?;
tokio::task::spawn_blocking(move || {
let model = crate::ops::build::load(&project.root, &project.build_cache_dir(), &project.settings.effective());
let artifacts = ide_build::web::artifacts(&project.root, &model);
let id = config
.extras
.params
.get("artifact")
.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::base_dir(&project.cache_dir, &config.id),
)?;
plan.env.extend(config.env.clone());
let mut build = RunConfig {
id: format!("tomcat-build:{}", config.id),
name: format!("Build {}", config.name),
kind: if id.starts_with("maven:") { "maven" } else { "gradle" }.into(),
command: plan.command.clone(),
args: vec![],
cwd: Some(std::path::PathBuf::from(&plan.cwd).into()),
env: plan.env.clone(),
detected: false,
is_test: false,
debug_adapter: None,
allow_parallel: false,
extras: ide_run::RunExtras {
include_system_env: config.extras.include_system_env,
env_files: config.extras.env_files,
..Default::default()
},
};
plan.resolve_project(
&project.root,
&ide_run::resolved_env(&project.root, &build)?,
&ide_core::CancellationToken::new(),
)?;
build.args = plan.args;
Ok(Some(build))
})
.await
.map_err(|e| IdeError::internal(e.to_string()))?
}
pub async fn run_tomcat_update(host: &Host, window: &str, config: RunConfig, mode: String) -> IdeResult<()> {
host.require_trust(window)?;
let project = host.project(window)?;
if !host.processes.list().iter().any(|r| r.running && r.config_id == config.id) {
return Err(IdeError::invalid("Start this Tomcat configuration before updating it"));
}
tokio::task::spawn_blocking(move || ide_run::tomcat::update(&project.root, &project.cache_dir, &config, &mode))
.await
.map_err(|e| IdeError::internal(e.to_string()))?
}
/// Commands of this module the remote server answers.
pub(crate) fn register(d: &mut Dispatcher) {
d.add("run_application_build", |c| async move {
run_application_build(&c.host, &c.window, c.arg("config")?, c.arg("module")?).await
});
d.add("run_application_publish", |c| async move {
run_application_publish(&c.host, &c.window, c.arg("config")?, c.arg("classPaths")?, c.arg("mainClass")?).await
});
d.add("run_tomcat_home", |c| async move { run_tomcat_home(&c.host, &c.window, c.arg("home")?).await });
d.add("run_tomcat_artifacts", |c| async move { run_tomcat_artifacts(&c.host, &c.window).await });
d.add("run_tomcat_build", |c| async move { run_tomcat_build(&c.host, &c.window, c.arg("config")?).await });
d.add("run_tomcat_update", |c| async move {
run_tomcat_update(&c.host, &c.window, c.arg("config")?, c.arg("mode")?).await
});
d.add("run_templates", |c| async move { run_templates(&c.host, &c.window) });
d.add("run_save_templates", |c| async move { run_save_templates(&c.host, &c.window, c.arg("templates")?) });
d.add("run_system_env", |c| async move { Ok(run_system_env(&c.host, &c.window)) });
+15 -8
View File
@@ -22,12 +22,14 @@ pub struct LocatedTools {
/// Installed interpreters / toolchains of `kind`; project-local ones first.
pub async fn sdk_detect(host: &Host, window: &str, kind: SdkKind) -> IdeResult<Vec<Sdk>> {
host.require_trust(window)?;
let root = host.project(window).ok().map(|p| p.root.clone());
tokio::task::spawn_blocking(move || ide_sdk::detect(kind, root.as_deref())).await.map_err(join_err)
}
/// The interpreter the project uses: the configured one, else its virtualenv.
pub fn sdk_python_effective(host: &Host, window: &str) -> IdeResult<Option<ide_core::IpcPath>> {
host.require_trust(window)?;
let project = host.project(window)?;
Ok(ide_sdk::apply::python_interpreter(&project.root, &project.settings.effective()).map(Into::into))
}
@@ -35,6 +37,7 @@ pub fn sdk_python_effective(host: &Host, window: &str) -> IdeResult<Option<ide_c
/// SDK / library source folders shown under "External Libraries" in the Project view,
/// with the Maven / Gradle dependency jars of the build model.
pub async fn sdk_project_libraries(host: &Host, window: &str) -> IdeResult<Vec<ide_sdk::libraries::LibraryGroup>> {
host.require_trust(window)?;
let project = host.project(window)?;
let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective());
tokio::task::spawn_blocking(move || {
@@ -56,11 +59,12 @@ pub async fn sdk_project_libraries(host: &Host, window: &str) -> IdeResult<Vec<i
/// Looks up `names` (bare command names): `node_modules/.bin` above `near`, then the search path.
pub async fn sdk_locate_tools(
_host: &Host,
_window: &str,
host: &Host,
window: &str,
names: Vec<String>,
near: Option<PathBuf>,
) -> IdeResult<LocatedTools> {
host.require_trust(window)?;
tokio::task::spawn_blocking(move || LocatedTools {
tools: ide_sdk::locate::locate_tools(&names, near.as_deref()),
compile_dir: ide_sdk::locate::compile_dir().into(),
@@ -72,13 +76,14 @@ pub async fn sdk_locate_tools(
/// `base -m venv location`; output lines stream through `on_output`.
pub async fn sdk_python_create_venv(
host: &Host,
_window: &str,
window: &str,
base: PathBuf,
location: PathBuf,
request: RequestId,
on_output: Channel<String>,
) -> IdeResult<Sdk> {
let cancel = host.begin_request(request);
host.require_trust(window)?;
let cancel = host.begin_project_request(window, request);
let result = tokio::task::spawn_blocking(move || {
ide_sdk::python::create_venv(&base, &location, &cancel, &|line| {
let _ = on_output.send(line);
@@ -92,11 +97,12 @@ pub async fn sdk_python_create_venv(
pub async fn sdk_python_packages(
host: &Host,
_window: &str,
window: &str,
python: PathBuf,
request: RequestId,
) -> IdeResult<Vec<PythonPackage>> {
let cancel = host.begin_request(request);
host.require_trust(window)?;
let cancel = host.begin_project_request(window, request);
let result =
tokio::task::spawn_blocking(move || ide_sdk::python::list_packages(&python, &cancel)).await.map_err(join_err);
host.end_request(request);
@@ -107,7 +113,7 @@ pub async fn sdk_python_packages(
#[allow(clippy::too_many_arguments)]
pub async fn sdk_python_pip(
host: &Host,
_window: &str,
window: &str,
python: PathBuf,
requirement: String,
uninstall: bool,
@@ -115,7 +121,8 @@ pub async fn sdk_python_pip(
request: RequestId,
on_output: Channel<String>,
) -> IdeResult<()> {
let cancel = host.begin_request(request);
host.require_trust(window)?;
let cancel = host.begin_project_request(window, request);
let result = tokio::task::spawn_blocking(move || {
let send = |line: String| {
let _ = on_output.send(line);
+15 -6
View File
@@ -42,13 +42,16 @@ pub async fn start(
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 { set: ProjectFileSet::All } => {
project.content_roots()
}
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();
@@ -66,15 +69,21 @@ pub async fn start(
Some(found.into_iter().map(literal_range).collect())
};
let mut total = SearchSummary::default();
for dir in std::iter::once(&root).chain(&content_roots) {
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.text_of(p),
&|p| documents.search_text_of(p),
&cancel,
&|file| events(SearchEvent::File { file }),
&|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;
+1 -1
View File
@@ -124,7 +124,7 @@ pub async fn syntax_identifiers_in_files(
let snap = project.documents.text_snapshot(doc).ok()?;
return (!snap.large).then_some((snap.language, Some((doc, snap.version)), snap.text));
}
let text = ide_vfs::load_text(path).ok()?.text;
let text = project.text_of(path).ok()?;
let first_line = text.lines().next();
Some((languages.detect(path, first_line), None, text))
};
+19 -3
View File
@@ -3,12 +3,28 @@ use crate::Host;
use ide_core::{IdeResult, TerminalId};
use ide_terminal::{TerminalInfo, TerminalOptions};
pub fn spawn(host: &Host, window: &str, options: TerminalOptions, on_output: Emit<String>) -> IdeResult<TerminalInfo> {
// Python › "Activate virtualenv": typed into the new shell, like PyCharm.
pub fn spawn(
host: &Host,
window: &str,
mut options: TerminalOptions,
on_output: Emit<String>,
) -> IdeResult<TerminalInfo> {
if !host.project(window).is_ok_and(|p| p.is_trusted()) {
// Project terminal settings can otherwise start a command as soon as a tab restores.
let global = host.global_settings.effective().terminal;
options.shell = None;
options.args.clear();
options.env = global.env;
}
let (shell, args) = ide_terminal::resolve_shell(options.shell.as_deref(), &options.args);
options.shell = Some(shell.clone());
options.args = args;
// Python › "Activate virtualenv": typed into the same shell the PTY starts.
let activation = host
.project(window)
.ok()
.and_then(|p| ide_sdk::apply::terminal_activation(&p.root, &p.settings.effective(), options.shell.as_deref()));
.filter(|p| p.is_trusted())
.and_then(|p| ide_sdk::apply::terminal_activation(&p.root, &p.settings.effective(), &shell));
let info = host.terminals.spawn(options, host.window_sink(window), Box::new(move |chunk| on_output(chunk)))?;
if let Some(line) = activation {
let _ = host.terminals.write(info.id, &format!(" {line}\r"));
Loaded 100 of 1233 files, more files were not shown because too many files have changed in this diff. Show more