Compare commits

...
Author SHA1 Message Date
l0ng-ai 9944d13314 fix(java): use the project host classpath separator 2026-10-31 00:55:24 +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
l0ng-ai 44f01b5ce6 chore: v0.1.25 2026-10-29 22:51:09 +08:00
Florent Vandflorentvdry 14d7fd0061 fix(keymap): match letter shortcuts by typed character on AZERTY (#291)
Letter shortcuts were matched by physical key (KeyboardEvent.code), so on AZERTY the key typing z (physical KeyW) dispatched Ctrl+W and closed the tab instead of undoing. Letters are now named by the character typed when Alt is not held; Alt combos keep the physical key since they may type a symbol (macOS Option, AltGr). The terminal's Ctrl+letter control codes use the typed letter too.

Co-authored-by: florentvdry <[email protected]>
2026-10-29 22:49:08 +08:00
l0ng-ai b9cfd54742 fix(editor): clear stale text focus when switching tabs 2026-10-29 22:42:19 +08:00
l0ng-ai f6a0152488 fix(dev): isolate Tauri development sessions per worktree 2026-10-29 22:35:28 +08:00
l0ng-ai 234703c7e9 fix(agents): restore Codex message editing and cancel repaint (#288)
* fix(agents): restore Codex message editing and cancel repaint

* fix(agents): recover edited draft when sending after rollback fails
2026-10-29 21:39:05 +08:00
l0ng-ai aac924517c fix(agents): preserve Shift selection and add bulk task actions (#283) 2026-10-29 20:53:02 +08:00
l0ng-ai b1347dd331 fix(lsp): keep background usage counts from delaying navigation (#282)
* fix(lsp): keep background usage counts from delaying navigation

* fix(lsp): retry incomplete counts and preserve newer hints
2026-10-29 20:49:01 +08:00
l0ng-ai fc2496c3f0 feat(agents): show unread task indicators 2026-10-29 20:48:02 +08:00
l0ng-ai 0f26b72d07 fix(agents): anchor composer completions below the caret (#278) 2026-10-29 20:37:25 +08:00
ad09a76212 feat(vcs): IntelliJ's diff algorithm and change colouring in the diff viewer (#242)
* feat(vcs): IDEA's diff algorithm and change colouring in the diff viewer

Port IntelliJ's text comparison to TypeScript (src/features/vcs/comparison):
Myers/patience LCS with unique-element discarding, ByLine (smart matching
that skips unimportant lines, chunk optimizer, second-step correction),
ByWord (word chunks, punctuation matching, whitespace policies, line block
splitting) and ComparisonManager's compareLinesInner / processBlocks. The
port runs IntelliJ's own LineComparisonUtilTest, WordComparisonUtilTest and
SplitComparisonUtilTest cases (459 runs), converted to ideaCases.json.

MergeView chunks now come straight from these line fragments, with the
inner word fragments as chunk changes, instead of @codemirror/merge's
presentable diff. Revert and >> / << replace lines as
DiffUtil.applyModification does.

Colour changes as IDEA does: modified (blue), inserted (green) and deleted
(grey) by chunk type, a lighter line background for chunks whose words were
compared, each word fragment in its own type at full line height, a thin
bar for a fragment empty on one side, and the colours carried into the
gutters and the divider bands. New --diff-modified / --diff-inserted /
--diff-deleted theme tokens, imported from .icls DIFF_* attributes.

Add a Playwright suite over a generated git repository of diff cases,
including 10,000-line files, in every viewer mode.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(vcs): preserve unified revert boundaries and bound large diffs

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: l0ng-ai <[email protected]>
2026-10-29 19:59:57 +08:00
Walter WangandWalt von Neumann 8568a70a05 feat(vcs): add shared and local Git ignore actions (#255)
Co-authored-by: Walt von Neumann <[email protected]>
2026-10-29 19:43:44 +08:00
JawandClaude Opus 5.5 a20207110e fix(vcs): follow the editor font settings in diff and merge viewers (#253)
The VCS viewers (diff, merge, local history) hard-coded the default mono
font, size and line height, so Settings > Editor > Font had no effect on
them. Read the editor font variables like the main editor does, and apply
fontExtension next to the caret, kept in step with the settings.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-29 19:43:40 +08:00
l0ng-ai 74b2d5f5d7 fix(vcs): prevent Git log refresh flicker and graph seams (#269) 2026-10-29 19:12:31 +08:00
l0ng-ai 297b15a7b1 fix(agents): expose Cursor approval controls (#265) 2026-10-29 19:12:27 +08:00
l0ng-ai 97144421d2 fix(agents): open local file links in Markdown messages (#263) 2026-10-29 19:12:23 +08:00
JawandClaude Opus 5.5 200ed6a528 fix(project): paste files copied in Finder on macOS (#250)
The OS clipboard's file list was only wired up for Windows (CF_HDROP), so on
macOS the project view never saw files copied in Finder: Paste stayed disabled
and ⌘V did nothing. Read and write the general NSPasteboard's file URLs
instead, so Finder's copies paste into the project view and the project view's
copies paste in Finder.

Closes #246

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-29 18:05:11 +08:00
l0ng-ai 4666c4b9a5 fix(settings): keep macOS settings visible across displays (#254) 2026-10-29 18:05:06 +08:00
JawandClaude Opus 5.5 ce43a7aa70 chore: add pre-commit hook with simple-git-hooks and lint-staged (#252)
Run oxlint --fix and oxfmt on staged files before each commit, and
rustfmt on staged Rust files. simple-git-hooks installs the hook from
its postinstall, allowed in pnpm-workspace.yaml.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-29 17:47:55 +08:00
l0ng-ai f911196f7d ci(release): Write latest.json once after every build (#176)
Each build's tauri-action rewrote the shared latest.json by deleting and re-uploading it, so
parallel macOS builds raced: one failed with 404 on the delete (skipping DMG notarization), or a
platform could silently drop out of the manifest. The builds no longer upload it; a new
updater-manifest job builds it once from the uploaded .sig files and fails if any platform is
missing. publish waits for it.

Claude-Session: https://claude.ai/code/session_01Dq6jQQn8ZhRfkkyj3PRutA
2026-10-29 17:47:47 +08:00
l0ng-ai 8dea5904f2 style(database): format SQL completion regex 2026-10-29 15:39:07 +08:00
l0ng-ai c7d32f9e23 fix(python): include project roots in run and debug imports
Python file launches only exposed the script directory, so imports of sibling project packages failed. Add content, source and test roots to the effective PYTHONPATH after environment resolution, preserving existing paths. Apply the shared behavior to Python/test process launches and debugpy launches while leaving attach and remote adapter paths unchanged.

Refs #216
2026-10-29 15:11:22 +08:00
Kartik 3f9512056d fix(editor): auto-indent blocks in languages without a CodeMirror mode
Languages without a CodeMirror grammar had no indentation service. Add a low-precedence fallback, GDScript smart Backspace and statement completion. Review fixes preserve literal contents, Elixir inline do expressions and paired-bracket Enter indentation; regression tests cover these sibling cases.

Co-authored-by: kartik-madhak <[email protected]>
2026-10-29 15:03:25 +08:00
l0ng-ai 1283af8a41 chore: v0.1.24 2026-10-29 15:03:04 +08:00
l0ng-ai d37b96002b fix: avoid lookbehind in application regexes for older WebKit
Application regex lookbehind prevented older WebKit from loading the startup module graph. Replace lookbehind with boundary checks across run/terminal links, SQL aliases, debugger targets and refactoring; preserve Unicode boundaries, UTF-16 offsets and empty-match progress. Add compatibility and regression tests.

Fixes #224
Refs #208
2026-10-29 15:01:01 +08:00
l0ng-ai 364bb5ef50 fix: resolve frontend and cross-platform CI failures 2026-10-29 14:45:42 +08:00
l0ng-ai 4d503ef976 feat(vcs): support multiple repositories in the Git tool window
The Git tool window followed one globally selected repository even when multiple roots were registered. Aggregate repository logs and branches, preserve repository identity in selections, history tabs, stashes, diffs and patch commands, and capture repository targets for delayed operations and dialogs. Validate tag names against the captured repository.

Fixes #195
2026-10-29 14:30:32 +08:00
胡翀andzzxzz12345 75b1704f78 feat(database): add grid range editing and SQL filters
Add rectangular database cell selection and TSV copy/paste, plus schema-aware SQL completion and highlighting in WHERE and ORDER BY fields. Preserve whitespace in highlighted SQL and match the input caret scroll width so the overlay remains aligned.

Co-authored-by: zzxzz12345 <[email protected]>
2026-10-29 14:21:14 +08:00
Kartik 06d78cf524 fix(lsp): isolate backoff for optional language features
Optional LSP failures shared one backoff with completion and other features. Give each feature its own per-document backoff and clear all feature backoffs when a server becomes ready.

Co-authored-by: kartik-madhak <[email protected]>
2026-10-29 14:14:00 +08:00
l0ng-ai fff2c7110c feat(keymap): Eclipse and Eclipse (macOS) keymap presets (#219)
Settings › Keymap offers Eclipse and Eclipse (macOS), built on the default presets with the bundled Eclipse keymap bindings for every action we have. Numpad keys are matched by physical key only when the active keymap binds them explicitly (other presets keep the + - * / aliases) and display as NumPad keys; undo/redo stay with the focused view.

Not verified on Windows or Linux machines.

Fixes #192

Claude-Session: https://claude.ai/code/session_01Dq6jQQn8ZhRfkkyj3PRutA
2026-10-29 12:10:33 +08:00
l0ng-ai 9fbd57ed15 feat(settings): Python interpreter widget in the status bar (#218)
The status bar shows the project's Python interpreter when the project has Python files or configuration, with a warning state when none is set. Its popup lists detected interpreters (paths shown for same-named ones) and switches immediately through the project settings, which restarts the Python language server and re-resolves run configurations; Add New Interpreter… and Interpreter Settings… open the Python Interpreter settings page.

Fixes #138

Claude-Session: https://claude.ai/code/session_01Dq6jQQn8ZhRfkkyj3PRutA
2026-10-29 12:08:27 +08:00
l0ng-ai aea0b33585 fix(settings): List every installed font family in the font pickers (#225)
The font pickers never read the installed fonts: they probed a hard-coded list of ~30 names with canvas width checks, so anything outside the list never appeared and the monospaced filter changed nothing. A new ide-fonts crate asks the platform font system for families and their fixed-pitch flag (CoreText on macOS, DirectWrite on Windows, fc-list on Linux/BSD), exposed as system_font_families (spawn_blocking, cached). Every font picker — editor font and fallback, terminal font and fallback, UI custom font — uses one FontSelect with prefix type-ahead, a real monospaced filter, and keeps an uninstalled selection visible.

Not verified on Windows or Linux machines.

Fixes #199

Claude-Session: https://claude.ai/code/session_01Dq6jQQn8ZhRfkkyj3PRutA
2026-10-29 12:05:46 +08:00
l0ng-ai e537dd43a0 feat(sdk): Detect mise and asdf toolchains and use the project's pinned versions (#227)
Toolchain detection only knew fixed install locations (SDKMAN, nvm, pyenv…), so per-version managers were invisible and project-pinned versions were ignored; mise also only reaches PATH through an interactive-shell hook. JDK, Node.js, Python, Go and Maven installs under mise (MISE_DATA_DIR / XDG) and asdf (ASDF_DATA_DIR) are now detected, pinned versions from mise.toml variants and .tool-versions are resolved and used when the SDK setting is Automatic, and the managers' shims/installs are added to PATH when the login shell didn't.

Fixes #207

Claude-Session: https://claude.ai/code/session_01Dq6jQQn8ZhRfkkyj3PRutA
2026-10-29 12:04:11 +08:00
l0ng-ai c821ab144c fix(run): Give forked and debugged JVMs the console encoding and render colours in the Debug console (#220)
Only the JVM the IDE launched directly was told the console's output encoding, so forked JVMs (spring-boot:run, Surefire, bootRun, Gradle workers), debugger-launched JVMs and Build-window builds wrote GBK on Windows while the console decoded UTF-8. Every JVM the IDE starts on Windows now gets the console encoding: java gets -Dfile.encoding alongside the std* properties, build tools and their forks get JAVA_TOOL_OPTIONS (std* only) with the IDE's own 'Picked up JAVA_TOOL_OPTIONS' line filtered, shell command lines are recognised by their first word, and java-debug launches get the matching vmArgs. The Debug console now shares the Run console's ANSI parser. Encoding helpers moved to ide-core::output_encoding.

Not verified on a Windows machine.

Fixes #180
Fixes #182

Claude-Session: https://claude.ai/code/session_01Dq6jQQn8ZhRfkkyj3PRutA
2026-10-29 11:58:19 +08:00
l0ng-ai 6767a5ff8c fix(db): Read MySQL catalog names as text, not hex (#212)
MySQL flags text columns with a binary collation as BINARY, so sqlx reports them as VARBINARY/BLOB and cell() formatted them as 0x… — including information_schema names (utf8mb3_bin under lower_case_table_names=0, or anything with character_set_results=binary). The hex schema name was then used in follow-up queries, which matched nothing. Metadata queries now always decode text as UTF-8; result grids show hex only for true binary data. Covers schemas, tables, columns, indexes, foreign keys, triggers, routines, DDL, completion and the table editor's _bin primary keys.

Fixes #196

Claude-Session: https://claude.ai/code/session_01Dq6jQQn8ZhRfkkyj3PRutA
2026-10-29 11:50:28 +08:00
l0ng-ai a233d85fcc feat(agents): Show when Claude Code retries a failed API request (#214)
When Claude Code loses its API connection or is refused, it retries on its own
for up to several minutes. The session showed only "Working…" meanwhile. It
now shows "API request failed, retrying in Ns (attempt 3/10)" with the error
in the tooltip, cleared by the agent's next output or the end of the turn.
2026-10-29 11:46:52 +08:00
l0ng-ai 4e675c1171 fix(java): Start jdtls with a bounded heap that returns memory to the OS (#210)
jdtls was launched with -Xms1G and no heap cap or GC choice, so the JVM grew to a quarter of physical RAM during import and kept it. Default VM args are now ParallelGC with GCTimeRatio/AdaptiveSizePolicyWeight, -Xmx2G -Xms100m, disabled memory-mapped zips and VM-install detection, prepended as --jvm-arg so both the direct and launcher-script paths get them. User JVM options replace the matching default (heap, -D/-XX by name, any -Xlog, any GC flag drops the whole GC group).

Fixes #193

Claude-Session: https://claude.ai/code/session_01Dq6jQQn8ZhRfkkyj3PRutA
2026-10-29 11:46:22 +08:00
l0ng-ai 8ebeaa2663 feat(run): Add VM options to Java run configurations (#206)
Adds Application and Spring Boot run configuration types with main class, VM options, program arguments, classpath and active profiles. Maven passes them through spring-boot.run.jvmArguments / profiles / argLine; Gradle through an init script.

Fixes #187

Claude-Session: https://claude.ai/code/session_01Dq6jQQn8ZhRfkkyj3PRutA
2026-10-29 11:39:20 +08:00
l0ng-ai ed7b0f88ef feat(agents): Dock the Agents tool window at the top of the right side by default (#209)
Closes #191

Claude-Session: https://claude.ai/code/session_01Dq6jQQn8ZhRfkkyj3PRutA
2026-10-29 11:13:12 +08:00
l0ng-aiandClaude 9d4e778c7b feat(settings): HTTP Proxy settings for the IDE's outgoing connections (#194)
* feat(settings): HTTP Proxy settings for the IDE's outgoing connections

Add Appearance & Behavior › System Settings › HTTP Proxy with three modes:
No proxy, Auto-detect proxy settings (the default; environment proxy as
before) and Manual proxy configuration (HTTP or SOCKS, host and port, a
comma-separated "No proxy for" list with `*` wildcards, optional proxy
authentication). "Check connection…" GETs a URL with the settings as edited
and reports the status or the error.

- ide-core: `ProxySettings` plus routing (no-proxy globs, loopback always
  direct, credentials percent-encoded), the current proxy for the process,
  curl arguments and child-process variables.
- ide-http: `client::build` / `client::shared` is the one HTTP client
  constructor; the HTTP Client requests and the update check go through it.
- ide-tools: curl downloads (tools, JDKs, debug adapters, agent registry,
  remote server) follow the setting.
- A manual proxy reaches child processes (language servers, debug adapters,
  terminals, git) as HTTP_PROXY / HTTPS_PROXY / ALL_PROXY / NO_PROXY.
- The proxy password is kept in the owner-only saved passwords file
  (`proxy-password.json` in the app data directory), not in settings.toml.

Co-Authored-By: Claude <[email protected]>

* fix(host): Allow the Unix-only post-start test helpers on Windows

Co-Authored-By: Claude <[email protected]>

---------

Co-authored-by: Claude <[email protected]>
2026-10-29 10:43:30 +08:00
l0ng-ai c8e3fe9d2e fix(lsp): Name the running server when it lacks a feature (#201)
ide-lsp returned the same `unavailable` error whether no server was running or a running server didn't implement the request, so the UI said no language server was available, and Invoke Inline Completion said no language server provides inline completion, while jdtls was running fine (it doesn't implement textDocument/inlineCompletion). When servers are running but none supports the feature, the error now names them ("jdtls doesn't support inline completion"), and every explicit action shares one message helper: Invoke Inline Completion, go to declaration / implementation / type declaration, quick documentation, parameter info, rename, reformat, intention actions, highlight usages, call / type hierarchy, find usages, safe delete and workspace symbols. With no server running it still says no language server is available. Background requests while typing stay silent.

Fixes #186

Claude-Session: https://claude.ai/code/session_01Dq6jQQn8ZhRfkkyj3PRutA
2026-10-29 10:35:37 +08:00
Jaw 4410da60a2 feat(vcs): IDEA-style synchronized scrolling for the side-by-side diff (#181)
The side-by-side diff gets two independent options. Synchronize Scrolling (toolbar lock, also in the settings menu, on by default) scrolls both panes together; Align Changes (settings menu, off by default) pads the panes so changes line up. With Align Changes off, the panes keep their own heights and a divider of curved bands joins each change, with arrows to apply it; scrolling one pane moves the other through the matching change (anchored at a third of the height), including caret moves and the wheel over the divider. Scroll mapping and the divider look up changes by binary search, so large files with thousands of changes scroll smoothly.

Claude-Session: https://claude.ai/code/session_01Dq6jQQn8ZhRfkkyj3PRutA
Co-authored-by: jaw52 <[email protected]>
2026-10-29 10:25:57 +08:00
713 changed files with 50868 additions and 3812 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
+60 -1
View File
@@ -117,6 +117,8 @@ jobs:
releaseId: ${{ needs.create-release.outputs.release_id }}
tagName: ${{ env.TAG }}
args: --target ${{ matrix.target }}
# The parallel builds would race on one latest.json; `updater-manifest` writes it once.
uploadUpdaterJson: false
# tauri-action notarizes the .app but not the DMG around it; notarize and
# staple the DMG too, then replace the uploaded asset.
- name: Notarize DMG
@@ -172,8 +174,65 @@ jobs:
if command -v sha256sum >/dev/null; then sha256sum "$ASSET"; else shasum -a 256 "$ASSET"; fi > "$ASSET.sha256"
gh release upload "$TAG" "$ASSET" "$ASSET.sha256" --clobber
# The updater manifest (latest.json), built once from the signed bundles every build uploaded.
# Fails when a platform is missing, so a release never ships a manifest without it.
updater-manifest:
needs: [create-release, build]
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v7
env:
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
with:
script: |
const { owner, repo } = context.repo;
const release_id = Number(process.env.RELEASE_ID);
const tag = process.env.TAG;
const assets = await github.paginate(github.rest.repos.listReleaseAssets, { owner, repo, release_id });
const sigs = assets.filter((a) => a.name.endsWith(".sig"));
if (sigs.length === 0) {
core.notice("no signed bundles (TAURI_SIGNING_PRIVATE_KEY unset): skipping latest.json");
return;
}
// Bundle suffix → updater platform keys, as tauri-action names them.
const kinds = [
[/_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"]],
[/_x64_en-US\.msi$/, ["windows-x86_64", "windows-x86_64-msi"]],
[/_x64-setup\.exe$/, ["windows-x86_64-nsis"]],
];
const platforms = {};
for (const sig of sigs) {
const bundle = sig.name.slice(0, -".sig".length);
const kind = kinds.find(([re]) => re.test(bundle));
if (!kind) continue;
const { data } = await github.rest.repos.getReleaseAsset({
owner, repo, asset_id: sig.id, headers: { accept: "application/octet-stream" },
});
const signature = Buffer.from(data).toString("utf8").trim();
const url = `https://github.com/${owner}/${repo}/releases/download/${tag}/${encodeURIComponent(bundle)}`;
for (const key of kind[1]) platforms[key] = { signature, url };
}
const missing = kinds.flatMap(([, keys]) => keys).filter((k) => !platforms[k]);
if (missing.length) {
core.setFailed(`latest.json would miss ${missing.join(", ")}`);
return;
}
const manifest = { version: tag.replace(/^v/, ""), notes: "", pub_date: new Date().toISOString(), platforms };
const old = assets.find((a) => a.name === "latest.json");
if (old) await github.rest.repos.deleteReleaseAsset({ owner, repo, asset_id: old.id });
await github.rest.repos.uploadReleaseAsset({
owner, repo, release_id, name: "latest.json",
data: JSON.stringify(manifest, null, 2),
headers: { "content-type": "application/json" },
});
core.info(`latest.json: ${Object.keys(platforms).sort().join(", ")}`);
publish:
needs: [create-release, build, server]
needs: [create-release, build, server, updater-manifest]
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v7
+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
+434 -143
View File
File diff suppressed because it is too large. Load diff
+10 -2
View File
@@ -3,7 +3,7 @@ resolver = "3"
members = ["crates/*", "src-tauri"]
[workspace.package]
version = "0.1.23"
version = "0.1.28"
edition = "2024"
rust-version = "1.96"
license = "Apache-2.0"
@@ -36,9 +36,11 @@ ide-web = { path = "crates/ide-web" }
ide-spell = { path = "crates/ide-spell" }
ide-agent = { path = "crates/ide-agent" }
ide-clipboard = { path = "crates/ide-clipboard" }
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"] }
@@ -74,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"
@@ -84,13 +87,18 @@ 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"
reqwest = { version = "0.13", default-features = false, features = ["rustls-no-provider", "http2", "charset", "gzip", "deflate", "brotli"] }
reqwest = { version = "0.13", default-features = false, features = ["rustls-no-provider", "http2", "charset", "socks", "gzip", "deflate", "brotli"] }
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] }
rustls-platform-verifier = "0.7"
sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio", "tls-rustls-ring-webpki", "sqlite", "postgres", "mysql"] }
tiberius = { version = "0.13", default-features = false, features = ["tds73", "rustls", "sql-browser-tokio"] }
bb8 = "0.9"
uuid = { version = "1", features = ["v4"] }
# testing
+11
View File
@@ -15,6 +15,8 @@
<a href="#benchmark">Benchmark</a>
&nbsp;·&nbsp;
<a href="#build">Build</a>
&nbsp;·&nbsp;
<a href="https://discord.gg/2Zy4yn7gP">Discord</a>
</p>
https://github.com/user-attachments/assets/234dd54c-1255-4b3a-b35c-533d78d737c0
@@ -54,6 +56,15 @@ pnpm tauri dev
Rust 1.96, Node 22, pnpm and the [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/).
Native notebook execution tests are opt-in and need two distinct Python environments
with `ipykernel` installed:
```sh
OPENIDEA_TEST_PYTHON=/first/env/bin/python \
OPENIDEA_TEST_PYTHON_SECOND=/second/env/bin/python \
cargo test -p ide-notebook --test kernel_runtime -- --ignored --test-threads=1
```
## License
[Apache 2.0](LICENSE). Bundled third-party assets are listed in [`THIRD_PARTY_LICENSES.md`](THIRD_PARTY_LICENSES.md).
+25
View File
@@ -28,6 +28,19 @@ icon set, copied unmodified (some renamed) from JetBrains' open-source repositor
At render time `src/ui/icons.tsx` may recolour monochrome icons to `currentColor`.
## JetBrains IntelliJ Platform diff algorithm
`src/features/vcs/comparison/**` is a TypeScript port of the text comparison of
[intellij-community](https://github.com/JetBrains/intellij-community) (Apache-2.0):
`platform/util/diff` (`com.intellij.util.diff`: Diff, Reindexer, MyersLCS,
PatienceIntLCS, UniqueLCS; `com.intellij.diff.comparison`: ByLineRt, ByWordRt,
ByCharRt, ChunkOptimizer, ChangeCorrector, LineFragmentSplitter, TrimUtil,
ComparisonUtil, iterables) and `platform/diff-impl` (ComparisonManagerImpl's
`compareLinesInner` and `processBlocks`). `__tests__/ideaCases.json` is converted
from its tests (`LineComparisonUtilTest`, `WordComparisonUtilTest`,
`SplitComparisonUtilTest`). Modified from the originals. Copyright 2000-2026
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,
Svelte, …). Those using the page-with-corner-badge layout (`goMod`, `goWork`,
@@ -98,6 +111,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() }],
+44 -1
View File
@@ -4,6 +4,49 @@
use crate::model::{AgentCommand, AgentConfigOption, AgentConfigValue, AgentConfigVia, AgentUsage};
use serde_json::{Value, json};
/// Cursor exposes execution modes over ACP, but leaves approval policy to its client.
pub(crate) const CURSOR_APPROVAL: &str = "openidea.cursor.approval";
pub(crate) fn add_client_options(provider: &str, config: &mut Vec<AgentConfigOption>) {
if provider != "cursor" || config.iter().any(|o| o.id == CURSOR_APPROVAL) {
return;
}
config.push(AgentConfigOption {
id: CURSOR_APPROVAL.into(),
name: "Approvals".into(),
description: Some("Tool approvals for this Cursor conversation".into()),
category: Some("approval".into()),
current: Some("ask".into()),
via: AgentConfigVia::Option,
values: vec![
AgentConfigValue {
value: "ask".into(),
name: "Ask for approval".into(),
description: Some("Show permission requests from Cursor".into()),
},
AgentConfigValue {
value: "bypass".into(),
name: "Bypass approvals".into(),
description: Some("Automatically approve tool requests in this conversation".into()),
},
],
});
}
pub(crate) fn bypass_approvals(provider: &str, config: &[AgentConfigOption]) -> bool {
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> {
@@ -64,7 +107,7 @@ pub(crate) fn parse_options(options: &Value) -> Vec<AgentConfigOption> {
pub(crate) fn merge_update(config: &mut Vec<AgentConfigOption>, options: &Value) {
let fresh = parse_options(options);
let covered = |o: &AgentConfigOption| fresh.iter().any(|f| f.category.is_some() && f.category == o.category);
config.retain(|o| o.via != AgentConfigVia::Option && !covered(o));
config.retain(|o| o.id == CURSOR_APPROVAL || (o.via != AgentConfigVia::Option && !covered(o)));
let legacy = std::mem::take(config);
config.extend(fresh);
config.extend(legacy);
+140 -11
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};
@@ -46,6 +47,8 @@ const STDERR_LINES: usize = 20;
/// Updates of a session's state rather than its conversation.
const SESSION_STATE: [&str; 5] =
["session_info_update", "config_option_update", "current_mode_update", "available_commands_update", "usage_update"];
/// A native agent's own update: it is about to retry a failed API request (see [`AgentRetry`]).
pub(crate) const RETRY_UPDATE: &str = "openidea_retry";
/// Referenced files up to this size are embedded in the prompt (when the agent takes it).
const MAX_EMBED: u64 = 256 * 1024;
/// How long a probe waits for the agent to answer `initialize`.
@@ -112,7 +115,17 @@ impl Shared {
}
fn known_config(&self, provider: &str) -> Vec<AgentConfigOption> {
self.known.lock().get(provider).map(|k| k.config.clone()).unwrap_or_default()
let mut config = self.known.lock().get(provider).map(|k| k.config.clone()).unwrap_or_default();
config::add_client_options(provider, &mut config);
if let Some((o, v)) = config::bypass_mode(&config).map(|(o, v)| (o.id.clone(), v.value.clone()))
&& let Some(o) = config.iter_mut().find(|c| c.id == o)
{
o.current = Some(v);
}
if let Some(o) = config.iter_mut().find(|o| o.id == config::CURSOR_APPROVAL) {
o.current = Some("bypass".into());
}
config
}
/// The commands the agent offered last time in this project.
@@ -264,6 +277,7 @@ impl AgentManager {
if matches!(f.task.status, AgentStatus::Running | AgentStatus::NeedsInput | AgentStatus::Starting) {
f.task.status = AgentStatus::Ready;
}
f.task.retry = None;
let task = Task::restore(f, shared.clone());
task.state.lock().conversation.settle(now_ms());
tasks.insert(task.id.clone(), task);
@@ -305,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) => {
@@ -316,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);
}
@@ -344,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?;
@@ -370,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(),
@@ -389,6 +419,7 @@ impl AgentManager {
queue: Vec::new(),
capabilities: self.shared.probes.lock().get(&spec.id).map(|p| p.capabilities).unwrap_or_default(),
resets_ms: None,
retry: None,
};
let file = TaskFile {
task: task.clone(),
@@ -512,7 +543,7 @@ impl AgentManager {
o.clone()
};
let live = task.live.lock().clone().filter(|l| !l.peer.is_closed());
if let Some(live) = live {
if let Some(live) = live.filter(|_| option != config::CURSOR_APPROVAL) {
let (method, mut params) = config::set_request(&target, &value);
params["sessionId"] = json!(live.session);
let reply: Value =
@@ -529,7 +560,12 @@ 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();
task.emit_task();
Ok(())
@@ -577,6 +613,7 @@ impl AgentManager {
usage: None,
queue: Vec::new(),
resets_ms: None,
retry: None,
..s.task.clone()
};
TaskFile {
@@ -736,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);
@@ -784,7 +844,8 @@ impl AgentManager {
impl Task {
fn restore(file: TaskFile, shared: Arc<Shared>) -> Arc<Self> {
let task = file.task;
let mut task = file.task;
config::add_client_options(&task.provider, &mut task.config);
let conversation = Conversation::restore(file.entries, file.subagents);
Arc::new(Self {
id: task.id.clone(),
@@ -830,9 +891,22 @@ impl Task {
self.shared.emit(AgentEvent::Task { task });
}
/// Shows (or clears) the agent's pending retry; emitted only when it changes.
fn set_retry(&self, retry: Option<AgentRetry>) {
{
let mut s = self.state.lock();
if s.task.retry == retry {
return;
}
s.task.retry = retry;
}
self.emit_task();
}
fn set_status(&self, status: AgentStatus, error: Option<String>) {
{
let mut s = self.state.lock();
s.task.retry = None;
s.task.status = status;
s.task.error = error;
s.task.updated_ms = now_ms();
@@ -991,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,
@@ -1011,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,
@@ -1156,10 +1232,16 @@ impl Task {
let s = self.state.lock();
(if known { reported } else { s.task.config.clone() }, s.wanted.clone())
};
self.shared.remember(&provider_id, Some(&options), None);
config::add_client_options(&provider_id, &mut options);
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 };
if o.id == config::CURSOR_APPROVAL {
if o.values.iter().any(|v| v.value == choice.value) {
options.iter_mut().find(|o| o.id == choice.id).unwrap().current = Some(choice.value.clone());
}
continue;
}
let already = known && o.current.as_deref() == Some(choice.value.as_str());
if already || !o.values.iter().any(|v| v.value == choice.value) {
continue;
@@ -1179,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());
@@ -1203,7 +1286,7 @@ impl Task {
s.wanted
.iter()
.filter_map(|c| {
let o = s.task.config.iter().find(|o| o.id == c.id)?;
let o = s.task.config.iter().find(|o| o.id == c.id && o.id != config::CURSOR_APPROVAL)?;
let due = o.current.as_deref() != Some(c.value.as_str())
&& o.values.iter().any(|v| v.value == c.value)
&& sent.insert((c.id.clone(), c.value.clone()));
@@ -1264,6 +1347,12 @@ impl Task {
}
return;
}
if kind == RETRY_UPDATE {
if !self.replaying.load(Ordering::Acquire) {
self.set_retry(retry_of(update, now_ms()));
}
return;
}
if kind == "session_info_update" {
// The agent's own title for the session replaces the first line of the prompt.
let title = update.get("title").and_then(Value::as_str).map(str::trim).filter(|t| !t.is_empty());
@@ -1332,6 +1421,8 @@ impl Task {
}
fn apply(self: &Arc<Self>, session: Option<&str>, kind: &str, update: &Value) {
// Output again: the retried request went through.
self.set_retry(None);
let applied = self.state.lock().conversation.apply(session, update, now_ms());
self.mark_dirty(applied);
// A subagent can start or finish after the turn that started it: saved right away.
@@ -1374,6 +1465,7 @@ impl Task {
let entry = self.push_entry(AgentEntry::Permission { id, title, options: options.clone(), chosen: None });
self.permissions.lock().insert(id, tx);
self.set_status(AgentStatus::NeedsInput, None);
self.approve_pending();
let answer = rx.recv().ok().flatten();
let name = answer
@@ -1397,6 +1489,22 @@ impl Task {
}
}
fn approve_pending(&self) {
let s = self.state.lock();
if self.cancel_pending.load(Ordering::Acquire) || !config::bypass_approvals(&s.task.provider, &s.task.config) {
return;
}
let mut pending = self.permissions.lock();
for entry in s.conversation.main.entries.iter().rev() {
if let AgentEntry::Permission { id, options, chosen: None, .. } = entry
&& let Some(option) = options.iter().find(|o| o.kind == "allow_once")
&& let Some(reply) = pending.remove(id)
{
let _ = reply.send(Some(option.id.clone()));
}
}
}
fn cancel_permissions(&self) {
for (_, reply) in self.permissions.lock().drain() {
let _ = reply.send(None);
@@ -1556,10 +1664,31 @@ fn is_zero(n: &u32) -> bool {
*n == 0
}
/// The retry a [`RETRY_UPDATE`] announces, timed from `now`.
fn retry_of(update: &Value, now: f64) -> Option<AgentRetry> {
let n = |k: &str| update.get(k).and_then(Value::as_u64);
Some(AgentRetry {
attempt: u32::try_from(n("attempt")?).unwrap_or(u32::MAX),
max_retries: n("maxRetries").and_then(|m| u32::try_from(m).ok()),
retry_at_ms: now + n("retryDelayMs").unwrap_or(0) as f64,
error: update.get("error").and_then(Value::as_str).filter(|e| !e.is_empty()).map(str::to_owned),
})
}
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())
}
+24
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`].
@@ -81,6 +84,21 @@ pub struct AgentTask {
/// When a [`AgentStatus::Limited`] task's limit resets (ms since the epoch), if the agent said.
#[serde(default)]
pub resets_ms: Option<f64>,
/// The agent is retrying a failed API request (connection lost, overloaded…); cleared
/// by its next output or any status change.
#[serde(default)]
pub retry: Option<AgentRetry>,
}
/// An API request the agent is about to retry.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct AgentRetry {
pub attempt: u32,
pub max_retries: Option<u32>,
/// When the next attempt starts (ms since the epoch).
pub retry_at_ms: f64,
pub error: Option<String>,
}
/// One value of a select-type session setting.
@@ -225,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>,
@@ -346,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…).
+1 -1
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)
}
@@ -917,6 +917,20 @@ fn pending_starts_round_trip() {
}
}
#[test]
fn api_retries_are_announced() {
let mut t = Translator::new("s", PathBuf::from("/w"), true);
let out = t.on_message(&json!({"type": "system", "subtype": "api_retry", "attempt": 3, "max_retries": 10,
"retry_delay_ms": 4000, "error_status": null, "error": "Connection error."}));
assert_eq!(out.len(), 1);
assert_eq!(out[0].session, "s");
assert_eq!(
out[0].update,
json!({"sessionUpdate": crate::RETRY_UPDATE, "attempt": 3, "maxRetries": 10, "retryDelayMs": 4000,
"error": "Connection error."})
);
}
#[test]
fn streamed_text_is_not_sent_twice() {
let mut t = Translator::new("s", PathBuf::from("/w"), true);
@@ -5,6 +5,7 @@
//! Agent tool use that launched them: their messages carry it as `parent_tool_use_id`.
use super::tools;
use crate::RETRY_UPDATE;
use serde_json::{Value, json};
use std::collections::{HashMap, VecDeque};
use std::path::PathBuf;
@@ -427,6 +428,23 @@ impl Translator {
}
Vec::new()
}
// Claude Code retries a failed API request on its own; without this the turn looks stuck.
Some("api_retry") => {
let n = |k: &str| msg.get(k).and_then(Value::as_u64);
let error = msg.get("error").and_then(|e| {
e.as_str().map(str::to_owned).or_else(|| e.get("message")?.as_str().map(str::to_owned))
});
vec![Update {
session: self.root.clone(),
update: json!({
"sessionUpdate": RETRY_UPDATE,
"attempt": n("attempt").unwrap_or(1),
"maxRetries": n("max_retries"),
"retryDelayMs": n("retry_delay_ms").unwrap_or(0),
"error": error,
}),
}]
}
Some("task_notification") if self.children.get(&tool_use).is_some_and(|c| c.background != Some(false)) => {
let stop = match msg.get("status").and_then(Value::as_str) {
Some("failed") => "error",
+45 -2
View File
@@ -199,7 +199,12 @@ fn invalid(message: impl Into<String>) -> ResponseError {
fn from_codex(e: RpcError) -> ResponseError {
match e {
RpcError::Server { code, message, data } => {
let code = if message.to_lowercase().contains("auth") { AUTH_REQUIRED } else { code };
// Protocol errors can list method names containing "auth"; they are not sign-in failures.
let protocol = matches!(
code,
codes::PARSE_ERROR | codes::INVALID_REQUEST | codes::METHOD_NOT_FOUND | codes::INVALID_PARAMS
);
let code = if !protocol && message.to_lowercase().contains("auth") { AUTH_REQUIRED } else { code };
ResponseError { code, message, data }
}
RpcError::Closed => error(codes::INTERNAL_ERROR, "Codex exited"),
@@ -426,10 +431,48 @@ impl CodexShim {
async fn rollback(&self, params: &Value) -> Result<Value, ResponseError> {
let session = str_param(params, "sessionId")?;
let turns = params.get("turns").and_then(Value::as_u64).filter(|n| *n > 0).ok_or_else(|| invalid("turns"))?;
self.call("thread/rollback", json!({ "threadId": session, "numTurns": turns })).await?;
match self.call("thread/rollback", json!({ "threadId": session, "numTurns": turns })).await {
Ok(_) => {}
Err(e)
if e.code == codes::METHOD_NOT_FOUND
|| (e.code == codes::INVALID_REQUEST
&& e.message.contains("unknown variant `thread/rollback`")) =>
{
self.revert(session, turns).await?;
}
Err(e) => return Err(e),
}
Ok(json!({}))
}
/// New Codex versions replace rollback counts with an exclusive turn id.
async fn revert(&self, session: &str, mut remaining: u64) -> Result<(), ResponseError> {
let mut cursor = Value::Null;
let mut seen = HashSet::new();
loop {
let page = self
.call(
"thread/turns/list",
json!({ "threadId": session, "sortDirection": "desc", "limit": remaining.min(100), "cursor": cursor }),
)
.await?;
let turns = page.get("data").and_then(Value::as_array).ok_or_else(|| invalid("Codex returned no turns"))?;
if remaining <= turns.len() as u64 {
let before = str_param(&turns[remaining as usize - 1], "id")?;
self.call("thread/revert", json!({ "threadId": session, "beforeTurnId": before })).await?;
return Ok(());
}
remaining -= turns.len() as u64;
let next = page.get("nextCursor").and_then(Value::as_str);
let Some(next) = next.filter(|next| seen.insert((*next).to_owned())) else {
return Err(invalid(
"Codex history has fewer turns than the conversation; reload the task before editing",
));
};
cursor = json!(next);
}
}
/// Stops following the session's thread (codex unloads it once nothing follows it).
async fn close(&self, params: &Value) -> Result<Value, ResponseError> {
let session = str_param(params, "sessionId")?;
@@ -593,6 +593,104 @@ async fn resume_replays_history_then_forks_and_rolls_back() {
assert_eq!(sent(&frames, "thread/unsubscribe")[0]["params"], json!({"threadId": thread}));
}
async fn rollback_replies(turns: u32, replies: Vec<(&str, Value)>) -> (Result<Value, RpcError>, Vec<Value>) {
let frames = replies
.into_iter()
.enumerate()
.flat_map(|(i, (method, mut reply))| {
let id = i + 1;
reply["id"] = json!(id);
[(true, json!({ "id": id, "method": method })), (false, reply)]
})
.collect();
let transcript = Transcript { meta: Value::Null, frames };
let (ours, theirs) = tokio::io::duplex(1 << 16);
let acp = attach(Arc::new(Client::default()), |events| {
let (read, write) = tokio::io::split(ours);
Ok(Peer::connect_framed(read, write, events, Framing::Lines))
})
.unwrap();
let (read, write) = tokio::io::split(theirs);
let player = tokio::spawn(async move { play(&transcript, read, write).await });
let result = request(&acp, "_openidea/rollback", json!({ "sessionId": "s", "turns": turns })).await;
let (sent, _read, _write) = player.await.unwrap();
acp.kill();
(result, sent)
}
fn removed_rollback() -> Value {
json!({ "error": { "code": -32600, "message": "Invalid request: unknown variant `thread/rollback`, expected one of `thread/revert`, `account/gatewayOAuth/read`, `getAuthStatus`" } })
}
#[tokio::test(flavor = "multi_thread")]
async fn removed_rollback_reverts_before_the_target_turn_across_pages() {
let (result, frames) = rollback_replies(
3,
vec![
("thread/rollback", removed_rollback()),
(
"thread/turns/list",
json!({ "result": { "data": [{ "id": "last" }, { "id": "middle" }], "nextCursor": "older" } }),
),
(
"thread/turns/list",
json!({ "result": { "data": [{ "id": "edited" }, { "id": "retained" }], "nextCursor": null } }),
),
("thread/revert", json!({ "result": { "thread": { "id": "s", "turns": [] } } })),
],
)
.await;
assert_eq!(result.unwrap(), json!({}));
let pages = sent(&frames, "thread/turns/list");
assert_eq!(pages[0]["params"], json!({ "threadId": "s", "sortDirection": "desc", "limit": 3, "cursor": null }));
assert_eq!(pages[1]["params"]["cursor"], "older");
assert_eq!(pages[1]["params"]["limit"], 1);
assert_eq!(sent(&frames, "thread/revert")[0]["params"], json!({ "threadId": "s", "beforeTurnId": "edited" }));
}
#[tokio::test(flavor = "multi_thread")]
async fn method_not_found_reverts_the_latest_turn() {
let (result, frames) = rollback_replies(
1,
vec![
("thread/rollback", json!({ "error": { "code": -32601, "message": "Method not found" } })),
("thread/turns/list", json!({ "result": { "data": [{ "id": "last" }], "nextCursor": null } })),
("thread/revert", json!({ "result": {} })),
],
)
.await;
assert_eq!(result.unwrap(), json!({}));
assert_eq!(sent(&frames, "thread/revert")[0]["params"]["beforeTurnId"], "last");
}
#[tokio::test(flavor = "multi_thread")]
async fn rollback_refusals_are_not_retried_as_revert() {
let (result, frames) = rollback_replies(
1,
vec![("thread/rollback", json!({ "error": { "code": -32600, "message": "a turn is still running" } }))],
)
.await;
assert!(result.unwrap_err().to_string().contains("a turn is still running"));
assert_eq!(frames.len(), 1);
}
#[tokio::test(flavor = "multi_thread")]
async fn incomplete_or_repeated_history_pages_do_not_revert_the_wrong_turn() {
for next in [Value::Null, json!("older")] {
let (result, frames) = rollback_replies(
3,
vec![
("thread/rollback", removed_rollback()),
("thread/turns/list", json!({ "result": { "data": [{ "id": "last" }], "nextCursor": "older" } })),
("thread/turns/list", json!({ "result": { "data": [], "nextCursor": next } })),
],
)
.await;
assert!(result.unwrap_err().to_string().contains("reload the task"));
assert!(sent(&frames, "thread/revert").is_empty());
}
}
#[tokio::test(flavor = "multi_thread")]
async fn steering_adds_to_the_running_turn() {
let (results, _client, frames, _) = replay(Scenario::Steer).await;
+71 -20
View File
@@ -21,18 +21,26 @@ 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();
newest_install_in(command, &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 +50,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 +64,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 +74,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 +92,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());
}
}
}
+1 -1
View File
@@ -335,7 +335,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() })
}
+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),
+234 -1
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.
@@ -709,3 +760,185 @@ async fn usage_limits_and_late_settings() {
let config = mgr.task(&task.id).unwrap().state.lock().task.config.clone();
assert_eq!(config.iter().find(|o| o.id == "effort").unwrap().current.as_deref(), Some("high"));
}
#[test]
fn retry_updates_become_a_countdown() {
let update =
json!({"sessionUpdate": RETRY_UPDATE, "attempt": 2, "maxRetries": 10, "retryDelayMs": 1500, "error": ""});
assert_eq!(
retry_of(&update, 1000.0),
Some(AgentRetry { attempt: 2, max_retries: Some(10), retry_at_ms: 2500.0, error: None })
);
assert_eq!(retry_of(&json!({"sessionUpdate": RETRY_UPDATE}), 0.0), None, "no attempt");
}
#[tokio::test(flavor = "multi_thread")]
async fn cursor_approval_switch_resolves_pending_and_survives_restart() {
let tmp = tempfile::tempdir().unwrap();
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(),
isolation: Isolation::Local,
base: None,
};
let task = mgr.create(new()).await.unwrap();
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
mgr.set_config(&task.id, config::CURSOR_APPROVAL, "bypass".into()).await.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
assert!(
mgr.entries(&task.id, None)
.unwrap()
.iter()
.any(|e| matches!(e, AgentEntry::Permission { chosen: Some(name), .. } if name == "Allow"))
);
// Agent config updates must not erase the client's approval setting.
mgr.prompt(&task.id, text("late option"), false).await.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
let t = mgr.task(&task.id).unwrap();
assert!(config::bypass_approvals("cursor", &t.state.lock().task.config));
t.kill();
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")));
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;
// 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();
}
#[tokio::test(flavor = "multi_thread")]
async fn cursor_approval_choice_applies_before_first_prompt() {
let tmp = tempfile::tempdir().unwrap();
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() }],
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
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"])
+3
View File
@@ -234,6 +234,9 @@ 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);
if !m.projects.iter().any(|p| p.dir == rel) {
return Err(IdeError::not_found("no linked Maven project found for module"));
}
let top = tops
.iter()
.filter(|t| t.dir.is_empty() || rel == t.dir || rel.starts_with(&format!("{}/", t.dir)))
+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]
+12
View File
@@ -11,6 +11,7 @@ pub mod maven;
pub mod pom_edit;
mod process;
mod sync;
pub mod web;
mod xml;
pub use gradle::{GradleModel, GradleProject, GradleTask};
@@ -39,8 +40,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
});
+148 -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();
@@ -935,9 +943,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 +963,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 +976,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 +1289,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 +1561,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);
}
}
+27 -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.
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(
@@ -45,15 +61,21 @@ pub(crate) fn run(
mut on_line: impl FnMut(Stream, &str),
) -> IdeResult<Output> {
tracing::info!("build: {program} {} (in {})", args.join(" "), cwd.display());
let mut command = Command::new(program);
command.args(args).current_dir(cwd).envs(env).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
// 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 program = program_path(program, env);
let mut command = Command::new(&program);
command.args(args).current_dir(cwd).envs(env).envs(encoding.env).stdin(Stdio::null());
command.stdout(Stdio::piped()).stderr(Stdio::piped());
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
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}")))?;
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 || {
@@ -64,7 +86,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;
@@ -86,6 +108,7 @@ pub(crate) fn run(
return Err(IdeError::cancelled());
}
match rx.recv_timeout(Duration::from_millis(100)) {
Ok((_, line)) if encoding.notice.as_ref() == Some(&line) => {}
Ok((stream, line)) => {
on_line(stream, &line);
text.push_str(&line);
+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]
+172
View File
@@ -0,0 +1,172 @@
//! WAR modules and build-tool-owned archive resolution (including custom output names).
use crate::BuildModel;
use ide_core::{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>,
}
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()]);
}
args.extend([
"-DskipTests".into(),
"package".into(),
"help:evaluate".into(),
"-Dexpression=project.artifact.file".into(),
format!("-Doutput={}", manifest.display()),
]);
return Ok(WebBuild { command: top.command.clone(), args, cwd: top.dir.clone(), env: p.env.clone() });
}
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 literal = |s: &str| format!("'{}'", s.replace('\\', "\\\\").replace('\'', "\\'"));
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({}).text = warTask.get().archiveFile.get().asFile.absolutePath }}
}}
}}
"#,
literal(path),
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(),
});
}
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();
assert!(plan.args.contains(&"-Dexpression=project.artifact.file".into()));
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"]);
}
#[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 }"));
}
}
+6 -1
View File
@@ -1,8 +1,13 @@
[package]
name = "ide-clipboard"
description = "The OS clipboard's file list (Explorer <-> project view): Windows CF_HDROP read/write, no window or message loop"
description = "The OS clipboard's file list (Explorer <-> project view): Windows CF_HDROP or macOS NSPasteboard file URLs, no window or message loop"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
[target.'cfg(target_os = "macos")'.dependencies]
objc2 = { version = "0.6", default-features = false, features = ["std"] }
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSDictionary", "NSEnumerator", "NSObject", "NSString", "NSURL", "NSValue"] }
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSPasteboard"] }
+78 -4
View File
@@ -7,9 +7,12 @@
//! in full rather than rendered on demand, so no window or message loop is
//! needed and the list survives after `CloseClipboard`.
//!
//! Other platforms keep file lists elsewhere (`public.file-url` on macOS,
//! `text/uri-list` on X11) and are not wired up: [`files`] is empty and
//! [`set_files`] fails there.
//! 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.
//!
//! Other platforms (`text/uri-list` on X11) are not wired up: [`files`] is
//! empty and [`set_files`] fails there.
use std::path::PathBuf;
@@ -218,7 +221,78 @@ mod imp {
}
}
#[cfg(not(windows))]
#[cfg(target_os = "macos")]
mod imp {
use objc2::ClassType;
use objc2::rc::{Retained, autoreleasepool};
use objc2::runtime::{AnyClass, AnyObject, ProtocolObject};
use objc2_app_kit::{
NSPasteboard, NSPasteboardReadingOptionKey, NSPasteboardURLReadingFileURLsOnlyKey, NSPasteboardWriting,
};
use objc2_foundation::{NSArray, NSDictionary, NSNumber, NSURL};
use std::path::PathBuf;
/// `NSURL` as the one class to read, limited to file URLs: a web link the
/// user copied is not a file to paste.
fn read_file_urls<R>(
f: impl FnOnce(&NSPasteboard, &NSArray<AnyClass>, &NSDictionary<NSPasteboardReadingOptionKey, AnyObject>) -> R,
) -> R {
autoreleasepool(|_| {
let board = NSPasteboard::generalPasteboard();
let classes = NSArray::from_slice(&[NSURL::class()]);
let yes = NSNumber::new_bool(true);
// SAFETY: the key is an AppKit constant, valid for the process' lifetime.
let key = unsafe { NSPasteboardURLReadingFileURLsOnlyKey };
let options = NSDictionary::from_slices(&[key], &[yes.as_ref() as &AnyObject]);
f(&board, &classes, &options)
})
}
pub fn has_files() -> bool {
// SAFETY: the class list holds `NSURL`, which reads from pasteboards,
// and the option takes an `NSNumber`.
read_file_urls(|board, classes, options| unsafe {
board.canReadObjectForClasses_options(classes, Some(options))
})
}
pub fn files() -> 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();
};
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()
})
}
pub fn set_files(paths: &[PathBuf]) -> bool {
autoreleasepool(|_| {
// Folders get a directory URL (ending in `/`), as Finder writes them.
let urls: Vec<Retained<NSURL>> = paths
.iter()
.filter_map(|p| if p.is_dir() { NSURL::from_directory_path(p) } else { NSURL::from_file_path(p) })
.collect();
if urls.is_empty() {
return false;
}
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.
board.clearContents();
board.writeObjects(&NSArray::from_slice(&writers))
})
}
}
#[cfg(not(any(windows, target_os = "macos")))]
mod imp {
use std::path::PathBuf;
+7
View File
@@ -7,9 +7,16 @@ license.workspace = true
[dependencies]
dunce.workspace = true
encoding_rs.workspace = true
serde.workspace = true
serde_json.workspace = true
specta.workspace = true
specta-typescript.workspace = true
thiserror.workspace = true
tokio-util.workspace = true
[dev-dependencies]
tempfile.workspace = true
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Win32_Globalization"] }
+17 -1
View File
@@ -13,6 +13,8 @@ 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,
/// Files or directories changed on disk (after debouncing, ignore rules applied).
FsChanged {
created: Vec<crate::IpcPath>,
@@ -32,10 +34,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 +117,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.
+4
View File
@@ -12,12 +12,16 @@ pub mod ignored;
pub mod inspection_settings;
pub mod ipc_path;
pub mod jsonc;
pub mod output_encoding;
pub mod process;
pub mod project_dir;
pub mod proxy_settings;
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;
pub use error::{IdeError, IdeResult};
+489
View File
@@ -0,0 +1,489 @@
//! Makes the output of JVM and Python processes match the encoding the IDE decodes.
//!
//! On Windows both write piped output in the ANSI code page (GBK on a Chinese
//! system: `System.out.println`, `javac` errors, Maven / Gradle logs, `print`), while
//! the console decodes the configured encoding (UTF-8 by default). They are told
//! which one to use:
//! - `java`: `stdout.encoding` / `stderr.encoding` (JDK 19+) and their `sun.*`
//! forerunners (JDK 8–18) on the command line, plus `file.encoding=UTF-8` for a
//! UTF-8 console, the default charset of JDK 18+, which logging frameworks' console
//! appenders write in on older JDKs too.
//! - Build tools and launcher scripts (Maven, Gradle, sbt, …): the same `std*.encoding`
//! properties in `JAVA_TOOL_OPTIONS`, the one variable every JVM reads, so the JVMs
//! they fork get them too (`spring-boot:run`, Surefire, `bootRun`, test workers).
//! Each JVM then reports "Picked up JAVA_TOOL_OPTIONS: …" on stderr (which a build tool
//! may copy to its stdout); that line is hidden again ([`NoticeFilter`]). `file.encoding` is left alone there: it would change
//! how a build reads sources that declare no encoding.
//! - Python: `PYTHONIOENCODING`.
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";
/// Build tools and launcher scripts that run on (and may fork) JVMs.
const JVM_TOOLS: &[&str] =
&["mvn", "mvnw", "mvnd", "gradle", "gradlew", "sbt", "ant", "kotlin", "kotlinc", "groovy", "scala", "jbang"];
/// What a launch needs: `-D` options to put first on a `java` command line,
/// environment variables to set, and the notice line its JVMs print because of them.
#[derive(Debug, Default, PartialEq, Eq)]
pub struct EncodingFix {
pub java_args: Vec<String>,
pub env: Vec<(String, String)>,
/// "Picked up JAVA_TOOL_OPTIONS: …" for a variable set here alone, to drop from the output.
pub notice: Option<String>,
}
pub fn is_utf8(encoding: &str) -> bool {
matches!(encoding.trim().to_ascii_lowercase().replace('_', "-").as_str(), "utf-8" | "utf8")
}
/// File name of `command` without a Windows executable extension: `C:\jdk\bin\java.exe` → `java`.
fn program_name(command: &str) -> String {
let name = command.rsplit(['/', '\\']).next().unwrap_or(command).to_ascii_lowercase();
for ext in [".exe", ".cmd", ".bat"] {
if let Some(stem) = name.strip_suffix(ext) {
return stem.to_owned();
}
}
name
}
fn sets(text: &str, properties: &[&str]) -> bool {
properties.iter().any(|p| text.contains(&format!("-D{p}=")))
}
fn options(properties: &[&str], encoding: &str) -> Vec<String> {
properties.iter().map(|p| format!("-D{p}={encoding}")).collect()
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Kind {
Java,
JvmTool,
Python,
Other,
}
fn kind(program: &str) -> Kind {
let name = program_name(program);
if name == "java" || name == "javaw" {
Kind::Java
} else if JVM_TOOLS.contains(&name.as_str()) {
Kind::JvmTool
} else if is_python(&name) {
Kind::Python
} else {
Kind::Other
}
}
/// The options for a launch of `program` (`args`, environment `vars` over the
/// `inherited` one) whose output is decoded as `encoding`; nothing when the
/// process already writes it (UTF-8 off Windows) or the user chose the options.
pub fn fix(
program: &str,
args: &[String],
vars: &BTreeMap<String, String>,
inherited: impl Fn(&str) -> Option<String>,
encoding: &str,
windows: bool,
) -> EncodingFix {
fix_kind(kind(program), Some(args), vars, inherited, encoding, windows)
}
/// [`fix`] for a command line run by the shell (`cmd /C "mvn spring-boot:run"`), whose
/// arguments cannot be changed: its first word decides, and only variables are set.
pub fn fix_command_line(
line: &str,
vars: &BTreeMap<String, String>,
inherited: impl Fn(&str) -> Option<String>,
encoding: &str,
windows: bool,
) -> EncodingFix {
let line = line.trim_start();
let first = match line.chars().next() {
Some(q @ ('"' | '\'')) => line[1..].split(q).next(),
_ => line.split(char::is_whitespace).next(),
}
.unwrap_or_default();
fix_kind(kind(first), None, vars, inherited, encoding, windows)
}
fn fix_kind(
kind: Kind,
args: Option<&[String]>,
vars: &BTreeMap<String, String>,
inherited: impl Fn(&str) -> Option<String>,
encoding: &str,
windows: bool,
) -> EncodingFix {
let encoding = encoding.trim();
let mut out = EncodingFix::default();
if encoding.is_empty() || (!windows && is_utf8(encoding)) {
return out;
}
let current = |key: &str| vars.get(key).cloned().or_else(|| inherited(key)).unwrap_or_default();
let tool_options = current(TOOL_OPTIONS);
match (kind, args) {
(Kind::Java, Some(args)) => {
let java_options = format!("{} {tool_options}", current("JDK_JAVA_OPTIONS"));
let chosen =
|properties: &[&str]| args.iter().any(|a| sets(a, properties)) || sets(&java_options, properties);
if !chosen(STD_PROPERTIES) {
out.java_args = options(STD_PROPERTIES, encoding);
}
if is_utf8(encoding) && !chosen(&[FILE_ENCODING]) {
out.java_args.push(format!("-D{FILE_ENCODING}=UTF-8"));
}
}
(Kind::Java | Kind::JvmTool, _) => {
if !sets(&tool_options, STD_PROPERTIES) {
let added = options(STD_PROPERTIES, encoding).join(" ");
let value = match tool_options.trim() {
"" => {
out.notice = Some(format!("Picked up {TOOL_OPTIONS}: {added}"));
added
}
own => format!("{own} {added}"),
};
out.env.push((TOOL_OPTIONS.to_owned(), value));
}
}
(Kind::Python, _) if current("PYTHONIOENCODING").trim().is_empty() => {
out.env.push(("PYTHONIOENCODING".to_owned(), encoding.to_owned()));
}
_ => {}
}
out
}
/// `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> {
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) {
return None;
}
let added = options(STD_PROPERTIES, encoding).join(" ");
Some(match vm_args.trim() {
"" => added,
own => format!("{own} {added}"),
})
}
/// `python`, `python3`, `python3.12`, `py`, and the runners that start one.
fn is_python(name: &str) -> bool {
name == "python"
|| name == "py"
|| name.strip_prefix("python").is_some_and(|v| v.chars().all(|c| c.is_ascii_digit() || c == '.'))
|| matches!(name, "uv" | "poetry" | "pdm" | "pytest" | "pipenv")
}
/// Drops whole lines equal to `notice` from a stream of decoded text (the
/// [`EncodingFix::notice`] each JVM prints), passing everything else through.
/// The start of a line is held back only while it can still become the notice.
#[derive(Debug)]
pub struct NoticeFilter {
notice: String,
held: String,
at_line_start: bool,
}
impl NoticeFilter {
pub fn new(notice: String) -> Self {
Self { notice, held: String::new(), at_line_start: true }
}
pub fn filter(&mut self, text: &str, out: &mut String) {
for piece in text.split_inclusive('\n') {
if !self.at_line_start && self.held.is_empty() {
out.push_str(piece);
self.at_line_start = piece.ends_with('\n');
continue;
}
self.held.push_str(piece);
if self.held.ends_with('\n') {
if self.held.trim_end_matches(['\n', '\r']) != self.notice {
out.push_str(&self.held);
}
self.held.clear();
self.at_line_start = true;
} else if !self.could_be_notice() {
out.push_str(&self.held);
self.held.clear();
self.at_line_start = false;
}
}
}
/// The held text is the start of the notice, or all of it before a `\r\n`.
fn could_be_notice(&self) -> bool {
match self.held.strip_suffix('\r') {
Some(line) => line == self.notice,
None => self.notice.starts_with(self.held.as_str()),
}
}
/// The held-back start of a line, at the end of the stream.
pub fn finish(&mut self) -> String {
std::mem::take(&mut self.held)
}
}
#[cfg(test)]
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",
"-Dsun.stdout.encoding=UTF-8",
"-Dsun.stderr.encoding=UTF-8",
];
const TOOL_UTF8: &str =
"-Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8 -Dsun.stdout.encoding=UTF-8 -Dsun.stderr.encoding=UTF-8";
fn vars(vars: &[(&str, &str)]) -> BTreeMap<String, String> {
vars.iter().map(|(k, v)| ((*k).to_owned(), (*v).to_owned())).collect()
}
fn run(program: &str, args: &[&str], env: &[(&str, &str)], encoding: &str, windows: bool) -> EncodingFix {
let args: Vec<String> = args.iter().map(|a| (*a).to_owned()).collect();
fix(program, &args, &vars(env), |_| None, encoding, windows)
}
fn tool_env(f: &EncodingFix) -> Option<&str> {
f.env.iter().find(|(k, _)| k == TOOL_OPTIONS).map(|(_, v)| v.as_str())
}
#[test]
fn java_launches_write_the_console_encoding_on_windows() {
let mut expected: Vec<&str> = STD_UTF8.to_vec();
expected.push("-Dfile.encoding=UTF-8");
// A source-file launch through the Oracle `javapath` shim, and an Application / Spring Boot launch.
let java = "C:/Program Files/Common Files/Oracle/Java/javapath/java.exe";
for program in [java, "java", "javaw.exe", r"C:\jdk\bin\java.exe", "JAVA.EXE"] {
for args in [&["Main.java"][..], &["-cp", "a;b", "org.dromara.DromaraApplication"]] {
let f = run(program, args, &[], "UTF-8", true);
assert_eq!(f.java_args, expected, "{program}");
assert!(f.env.is_empty() && f.notice.is_none());
}
}
// Another console encoding: the streams follow it, the default charset stays the platform's.
assert_eq!(
run("java", &["-cp", "x", "a.Main"], &[], "GBK", true).java_args,
[
"-Dstdout.encoding=GBK",
"-Dstderr.encoding=GBK",
"-Dsun.stdout.encoding=GBK",
"-Dsun.stderr.encoding=GBK"
]
);
}
#[test]
fn nothing_changes_where_output_already_matches() {
for (program, args) in [("java", vec!["Main.java"]), ("mvn", vec!["package"]), ("python3", vec!["a.py"])] {
assert_eq!(run(program, &args, &[], "UTF-8", false), EncodingFix::default(), "{program}");
assert_eq!(run(program, &args, &[], "utf8", false), EncodingFix::default(), "{program}");
assert_eq!(run(program, &args, &[], "", true), EncodingFix::default(), "{program}");
}
for program in ["node", "cargo", "go", "npm.cmd", "cmd.exe", "javac-wrapper"] {
assert_eq!(run(program, &[], &[], "UTF-8", true), EncodingFix::default(), "{program}");
}
// A console set to another encoding elsewhere still gets it.
assert_eq!(run("java", &["Main"], &[], "GBK", false).java_args.len(), 4);
}
#[test]
fn options_chosen_by_the_user_win() {
let f = run("java", &["-Dstdout.encoding=GBK", "Main"], &[], "UTF-8", true);
assert_eq!(f.java_args, ["-Dfile.encoding=UTF-8"]);
let f = run("java", &["-Dfile.encoding=GBK", "Main"], &[], "UTF-8", true);
assert_eq!(f.java_args, STD_UTF8);
for key in ["JDK_JAVA_OPTIONS", TOOL_OPTIONS] {
let f = run("java", &["Main"], &[(key, "-Dsun.stdout.encoding=GBK -Dfile.encoding=GBK")], "UTF-8", true);
assert!(f.java_args.is_empty(), "{key}");
}
assert!(run("mvn.cmd", &[], &[(TOOL_OPTIONS, "-Dstdout.encoding=GBK")], "UTF-8", true).env.is_empty());
assert!(run("python.exe", &[], &[("PYTHONIOENCODING", "gbk")], "UTF-8", true).env.is_empty());
}
/// Maven's `spring-boot:run` and Surefire, Gradle's `bootRun` and test workers fork JVMs
/// that never see `MAVEN_OPTS` / `GRADLE_OPTS`: the options go where every JVM reads them.
#[test]
fn build_tools_reach_the_jvms_they_fork() {
for program in [
"mvn.cmd",
"mvnw.cmd",
r"C:\maven\bin\mvn.cmd",
"mvnd.cmd",
"gradlew.bat",
"gradle.bat",
"sbt.bat",
"kotlinc.bat",
] {
let f = run(program, &["spring-boot:run"], &[], "UTF-8", true);
assert_eq!(tool_env(&f), Some(TOOL_UTF8), "{program}");
assert_eq!(f.notice.as_deref(), Some(format!("Picked up JAVA_TOOL_OPTIONS: {TOOL_UTF8}").as_str()));
assert!(f.java_args.is_empty());
assert!(!tool_env(&f).unwrap().contains("file.encoding"), "a build's source encoding is left alone");
}
// Existing options are kept, from the configuration or the inherited environment; the
// JVMs then print the user's own variable, which stays visible.
let f = run("mvn.cmd", &[], &[(TOOL_OPTIONS, "-javaagent:a.jar")], "UTF-8", true);
assert_eq!(tool_env(&f), Some(format!("-javaagent:a.jar {TOOL_UTF8}").as_str()));
assert!(f.notice.is_none());
let inherited =
fix("gradlew.bat", &[], &BTreeMap::new(), |k| (k == TOOL_OPTIONS).then(|| "-Xss4m".into()), "UTF-8", true);
assert_eq!(tool_env(&inherited), Some(format!("-Xss4m {TOOL_UTF8}").as_str()));
for program in ["python.exe", "python3", "python3.12", "py.exe", "uv.exe", "poetry"] {
assert_eq!(
run(program, &[], &[], "UTF-8", true).env,
[("PYTHONIOENCODING".to_owned(), "UTF-8".to_owned())],
"{program}"
);
}
assert_eq!(run("python3", &[], &[], "GBK", false).env, [("PYTHONIOENCODING".to_owned(), "GBK".to_owned())]);
}
#[test]
fn shell_command_lines_get_variables_from_their_first_word() {
let line = |l: &str| fix_command_line(l, &BTreeMap::new(), |_| None, "UTF-8", true);
for l in [
"mvn spring-boot:run -pl ruoyi-admin",
"\"C:\\Program Files\\Maven\\bin\\mvn.cmd\" test",
"java -jar app.jar",
] {
let f = line(l);
assert_eq!(tool_env(&f), Some(TOOL_UTF8), "{l}");
assert!(f.java_args.is_empty());
}
assert_eq!(line("python manage.py runserver").env[0].0, "PYTHONIOENCODING");
assert_eq!(line("npm run dev && echo done"), EncodingFix::default());
assert_eq!(fix_command_line("mvn test", &BTreeMap::new(), |_| None, "UTF-8", false), EncodingFix::default());
}
#[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());
}
fn filtered(chunks: &[&str]) -> String {
let mut f = NoticeFilter::new(format!("Picked up JAVA_TOOL_OPTIONS: {TOOL_UTF8}"));
let mut out = String::new();
for c in chunks {
f.filter(c, &mut out);
}
out.push_str(&f.finish());
out
}
#[test]
fn notice_lines_are_dropped_wherever_the_chunks_split() {
let notice = format!("Picked up JAVA_TOOL_OPTIONS: {TOOL_UTF8}");
let text =
format!("{notice}\r\nerror: one\n{notice}\nPicked up nothing\nPicked up JAVA_TOOL_OPTIONS: -Dx\nlast");
let expected = "error: one\nPicked up nothing\nPicked up JAVA_TOOL_OPTIONS: -Dx\nlast";
assert_eq!(filtered(&[&text]), expected);
// Every split point, also inside the notice and between `\r` and `\n`.
for i in 1..text.len() {
let (a, b) = text.split_at(i);
assert_eq!(filtered(&[a, b]), expected, "split at {i}");
}
// A line that only starts like it is not held: text mid-line passes through at once.
let mut f = NoticeFilter::new(notice.clone());
let mut out = String::new();
f.filter("Pick a number: ", &mut out);
assert_eq!(out, "Pick a number: ");
f.filter("Picked", &mut out);
assert_eq!(out, "Pick a number: Picked");
// A prefix at the end of the stream comes out on finish.
assert_eq!(filtered(&["Picked up"]), "Picked up");
}
}
+45 -2
View File
@@ -30,7 +30,8 @@ pub fn prepare(cmd: &mut Command) -> &mut Command {
/// `GST_PLUGIN_*`, ... into the mounted image for the IDE itself; a host program that
/// inherits them loads the image's (older) libraries and plugins next to its own, e.g.
/// `curl: symbol lookup error ... nghttp2_...`. The IDE process keeps its environment
/// (WebKit and GTK still need it). Then applies the [`set_env_overlay`] variables.
/// (WebKit and GTK still need it). Then applies the [`set_env_overlay`] variables and
/// those of a manual HTTP proxy ([`crate::proxy_settings::ProxySettings::child_env`]).
/// Variables the call site has already set are kept. A no-op outside an AppImage
/// without an overlay.
pub fn host_env(cmd: &mut Command) -> &mut Command {
@@ -89,12 +90,16 @@ fn apply_changes(
vars
}
/// The AppImage fixes, then the overlay (which wins for a name both change); one entry per name.
/// The AppImage fixes, the overlay, then the manual proxy's (a later one wins); one entry per name.
fn child_env_changes() -> Vec<(OsString, Option<OsString>)> {
let mut changes = appimage_fixes();
let overlay = env_overlay();
changes.retain(|(k, _)| !overlay.iter().any(|(o, _)| o == k));
changes.extend(overlay.iter().cloned());
// A manual HTTP proxy reaches tools that read `HTTP_PROXY` (language servers, git, curl).
let proxy = crate::proxy_settings::proxy().child_env();
changes.retain(|(k, _)| !proxy.iter().any(|(p, _)| p == k));
changes.extend(proxy);
changes
}
@@ -213,6 +218,25 @@ 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()))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -358,4 +382,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);
}
}
+365
View File
@@ -0,0 +1,365 @@
//! Appearance & Behavior › System Settings › HTTP Proxy, and the proxy the IDE's own
//! connections use: [`set_proxy`] makes a configuration current, every outgoing
//! request asks [`proxy`] (the HTTP client, `curl` downloads, the updater), and with a
//! manual proxy child processes get `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY`.
use serde::{Deserialize, Serialize};
use specta::Type;
use std::ffi::OsString;
use std::sync::{Arc, RwLock};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum ProxyMode {
/// Every connection goes direct.
None,
/// The system's proxy: `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` of the environment.
#[default]
Auto,
/// The proxy configured here.
Manual,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum ProxyKind {
#[default]
Http,
Socks,
}
impl ProxyKind {
pub fn default_port(self) -> u16 {
match self {
ProxyKind::Http => 80,
ProxyKind::Socks => 1080,
}
}
fn scheme(self) -> &'static str {
match self {
ProxyKind::Http => "http",
// Host names resolve on the proxy.
ProxyKind::Socks => "socks5h",
}
}
}
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct ProxySettings {
pub mode: ProxyMode,
pub kind: ProxyKind,
pub host: String,
pub port: u16,
/// Hosts reached directly, comma-separated; `*` matches any characters (`*.example.com`, `192.168.*`).
pub no_proxy: String,
/// Send `login` / `password` to the proxy.
pub authenticate: bool,
pub login: String,
/// Kept in the saved passwords rather than `settings.toml`; filled in when settings are read.
pub password: String,
}
impl Default for ProxySettings {
fn default() -> Self {
Self {
mode: ProxyMode::Auto,
kind: ProxyKind::Http,
host: String::new(),
port: ProxyKind::Http.default_port(),
no_proxy: String::new(),
authenticate: false,
login: String::new(),
password: String::new(),
}
}
}
impl std::fmt::Debug for ProxySettings {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ProxySettings")
.field("mode", &self.mode)
.field("kind", &self.kind)
.field("host", &self.host)
.field("port", &self.port)
.field("no_proxy", &self.no_proxy)
.field("authenticate", &self.authenticate)
.field("login", &self.login)
.field("password", &if self.password.is_empty() { "" } else { "***" })
.finish()
}
}
/// How a connection to some host is made.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProxyRoute {
Direct,
/// Whatever the environment says (the HTTP library's / `curl`'s own proxy lookup).
System,
/// Through this proxy URL, credentials included (`http://user:pass@proxy:3128`).
Via(String),
}
/// Loopback hosts never go through the manual proxy.
const LOOPBACK: [&str; 3] = ["localhost", "127.0.0.1", "::1"];
impl ProxySettings {
/// A manual proxy with a host: what [`Self::route`] sends connections through.
pub fn manual(&self) -> bool {
self.mode == ProxyMode::Manual && !self.host.trim().is_empty()
}
/// What is wrong with the manual configuration, if anything.
pub fn problem(&self) -> Option<String> {
if self.mode != ProxyMode::Manual {
return None;
}
let host = self.host.trim();
if host.is_empty() {
return Some("Proxy host is empty".into());
}
if host.contains(['/', '@', ' ']) {
return Some(format!("Invalid proxy host: {host}"));
}
if self.port == 0 {
return Some("Proxy port must be between 1 and 65535".into());
}
if self.authenticate && self.login.trim().is_empty() {
return Some("Proxy login is empty".into());
}
None
}
/// Whether `host` is reached directly: loopback, or a "No proxy for" entry.
pub fn bypasses(&self, host: &str) -> bool {
let host = host.trim().trim_start_matches('[').trim_end_matches(']').to_ascii_lowercase();
if LOOPBACK.contains(&host.as_str()) {
return true;
}
self.no_proxy_entries().any(|pattern| glob_match(&pattern, &host))
}
fn no_proxy_entries(&self) -> impl Iterator<Item = String> + '_ {
self.no_proxy.split([',', ';', '\n']).map(|e| e.trim().to_ascii_lowercase()).filter(|e| !e.is_empty())
}
/// The manual proxy as a URL, with the credentials when it authenticates.
pub fn proxy_url(&self) -> Option<String> {
if !self.manual() {
return None;
}
let host = self.host.trim();
let host = if host.contains(':') && !host.starts_with('[') { format!("[{host}]") } else { host.to_owned() };
let auth = if self.authenticate && !self.login.is_empty() {
format!("{}:{}@", encode_userinfo(&self.login), encode_userinfo(&self.password))
} else {
String::new()
};
Some(format!("{}://{auth}{host}:{}", self.kind.scheme(), self.port))
}
/// How to connect to `host` (`None`: not known yet, e.g. the request has no URL).
pub fn route(&self, host: Option<&str>) -> ProxyRoute {
match self.mode {
ProxyMode::None => ProxyRoute::Direct,
ProxyMode::Auto => ProxyRoute::System,
ProxyMode::Manual => match self.proxy_url() {
Some(_) if host.is_some_and(|h| self.bypasses(h)) => ProxyRoute::Direct,
Some(url) => ProxyRoute::Via(url),
None => ProxyRoute::Direct,
},
}
}
/// [`Self::route`] for the host of `url` (`https://user@host:443/path`).
pub fn route_for_url(&self, url: &str) -> ProxyRoute {
self.route(url_host(url).as_deref())
}
/// Arguments that make a `curl` download of `url` follow this configuration. The
/// manual proxy itself comes from the environment (see [`Self::child_env`]), which
/// keeps the credentials out of the command line.
pub fn curl_args(&self, url: &str) -> Vec<&'static str> {
match self.route_for_url(url) {
ProxyRoute::Direct => vec!["--noproxy", "*"],
ProxyRoute::System => Vec::new(),
// Only this configuration decides what bypasses the proxy.
ProxyRoute::Via(_) => vec!["--noproxy", ""],
}
}
/// Variables child processes get with a manual proxy (`HTTP_PROXY`, `HTTPS_PROXY`,
/// `ALL_PROXY`, `NO_PROXY` and their lowercase forms); none otherwise.
pub fn child_env(&self) -> Vec<(OsString, Option<OsString>)> {
let Some(url) = self.proxy_url() else { return Vec::new() };
let mut no_proxy: Vec<String> = LOOPBACK.iter().map(|h| (*h).to_owned()).collect();
// `*.example.com` is `.example.com` to curl, Go, Python and Node.
no_proxy.extend(self.no_proxy_entries().map(|e| e.strip_prefix('*').map(str::to_owned).unwrap_or(e)));
no_proxy.dedup();
let no_proxy = no_proxy.join(",");
let mut env = Vec::new();
for name in ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY"] {
env.push((OsString::from(name), Some(OsString::from(&url))));
env.push((OsString::from(name.to_ascii_lowercase()), Some(OsString::from(&url))));
}
env.push((OsString::from("NO_PROXY"), Some(OsString::from(&no_proxy))));
env.push((OsString::from("no_proxy"), Some(OsString::from(no_proxy))));
env
}
}
/// Case-insensitive host glob (`pattern` is lowercase): `*` matches any run of characters.
fn glob_match(pattern: &str, host: &str) -> bool {
let (p, h) = (pattern.as_bytes(), host.as_bytes());
let (mut pi, mut hi) = (0, 0);
let mut star: Option<(usize, usize)> = None;
while hi < h.len() {
if pi < p.len() && p[pi] == b'*' {
star = Some((pi, hi));
pi += 1;
} else if pi < p.len() && p[pi] == h[hi] {
pi += 1;
hi += 1;
} else if let Some((sp, sh)) = star {
pi = sp + 1;
hi = sh + 1;
star = Some((sp, sh + 1));
} else {
return false;
}
}
p[pi..].iter().all(|&c| c == b'*')
}
fn encode_userinfo(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
out.push(b as char);
} else {
out.push_str(&format!("%{b:02X}"));
}
}
out
}
/// The host of `url`, without brackets, userinfo or port.
fn url_host(url: &str) -> Option<String> {
let rest = url.split_once("://").map_or(url, |(_, r)| r);
let authority = rest.split(['/', '?', '#']).next()?;
let authority = authority.rsplit_once('@').map_or(authority, |(_, a)| a);
let host =
if let Some(v6) = authority.strip_prefix('[') { v6.split(']').next()? } else { authority.split(':').next()? };
(!host.is_empty()).then(|| host.to_ascii_lowercase())
}
static CURRENT: RwLock<Option<Arc<ProxySettings>>> = RwLock::new(None);
/// Makes `settings` (password filled in) the proxy of every later connection and child process.
pub fn set_proxy(settings: ProxySettings) {
*CURRENT.write().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(settings));
}
/// The current proxy configuration (the default, auto-detect, until [`set_proxy`]).
pub fn proxy() -> Arc<ProxySettings> {
CURRENT.read().unwrap_or_else(|e| e.into_inner()).clone().unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
fn manual(host: &str, port: u16) -> ProxySettings {
ProxySettings { mode: ProxyMode::Manual, host: host.into(), port, ..Default::default() }
}
#[test]
fn auto_is_the_default_and_defers_to_the_system() {
let p = ProxySettings::default();
assert_eq!(p.mode, ProxyMode::Auto);
assert_eq!(p.route_for_url("https://example.com/"), ProxyRoute::System);
assert!(p.curl_args("https://example.com/").is_empty());
assert!(p.child_env().is_empty());
assert_eq!(p.problem(), None);
}
#[test]
fn no_proxy_goes_direct() {
let p = ProxySettings { mode: ProxyMode::None, ..manual("proxy", 3128) };
assert_eq!(p.route_for_url("https://example.com/"), ProxyRoute::Direct);
assert_eq!(p.curl_args("https://example.com/"), ["--noproxy", "*"]);
assert!(p.child_env().is_empty());
}
#[test]
fn manual_http_and_socks_urls() {
let p = manual("proxy.corp", 3128);
assert_eq!(p.route_for_url("https://example.com/x"), ProxyRoute::Via("http://proxy.corp:3128".into()));
assert_eq!(p.curl_args("https://example.com/x"), ["--noproxy", ""]);
let socks = ProxySettings { kind: ProxyKind::Socks, ..manual("10.0.0.1", 1080) };
assert_eq!(socks.proxy_url().as_deref(), Some("socks5h://10.0.0.1:1080"));
assert_eq!(manual("::1", 8080).proxy_url().as_deref(), Some("http://[::1]:8080"));
// Manual without a host connects directly.
assert_eq!(manual(" ", 80).route(Some("example.com")), ProxyRoute::Direct);
assert!(manual(" ", 80).problem().is_some());
assert!(manual("proxy", 0).problem().is_some());
assert!(manual("http://proxy", 80).problem().is_some());
}
#[test]
fn credentials_are_percent_encoded() {
let p = ProxySettings {
authenticate: true,
login: "dom\\me".into(),
password: "p@ss:w/rd".into(),
..manual("proxy", 3128)
};
assert_eq!(p.proxy_url().as_deref(), Some("http://dom%5Cme:p%40ss%3Aw%2Frd@proxy:3128"));
// Not sent without "Proxy authentication".
let off = ProxySettings { authenticate: false, ..p.clone() };
assert_eq!(off.proxy_url().as_deref(), Some("http://proxy:3128"));
assert!(ProxySettings { login: " ".into(), ..p.clone() }.problem().is_some());
assert!(!format!("{p:?}").contains("p@ss"));
}
#[test]
fn no_proxy_for_list_matches_globs_and_loopback() {
let p = ProxySettings { no_proxy: "*.example.com, 192.168.*,Intranet".into(), ..manual("proxy", 3128) };
assert!(p.bypasses("api.example.com"));
assert!(p.bypasses("a.b.EXAMPLE.com"));
assert!(!p.bypasses("example.com"));
assert!(!p.bypasses("example.com.evil.org"));
assert!(p.bypasses("192.168.1.20"));
assert!(p.bypasses("intranet"));
assert!(!p.bypasses("intranet2"));
assert!(p.bypasses("localhost") && p.bypasses("127.0.0.1") && p.bypasses("[::1]"));
assert_eq!(p.route_for_url("https://[email protected]:8443/v1"), ProxyRoute::Direct);
assert_eq!(p.curl_args("http://192.168.0.5/"), ["--noproxy", "*"]);
assert!(matches!(p.route_for_url("https://github.com"), ProxyRoute::Via(_)));
}
#[test]
fn child_processes_get_the_manual_proxy() {
let p = ProxySettings {
no_proxy: "*.corp, 10.*".into(),
authenticate: true,
login: "me".into(),
password: "pw".into(),
..manual("proxy", 3128)
};
let env = p.child_env();
let get = |k: &str| env.iter().find(|(n, _)| n == k).and_then(|(_, v)| v.clone()).unwrap();
assert_eq!(get("HTTPS_PROXY"), "http://me:pw@proxy:3128");
assert_eq!(get("http_proxy"), "http://me:pw@proxy:3128");
assert_eq!(get("NO_PROXY"), "localhost,127.0.0.1,::1,.corp,10.*");
}
#[test]
fn url_hosts() {
assert_eq!(url_host("https://Example.com:443/a?b").as_deref(), Some("example.com"));
assert_eq!(url_host("http://u:p@[::1]:80/").as_deref(), Some("::1"));
assert_eq!(url_host("example.com/x").as_deref(), Some("example.com"));
assert_eq!(url_host("file:///tmp/x"), None);
}
}
+68 -1
View File
@@ -7,6 +7,7 @@ use std::collections::BTreeMap;
pub use crate::editor_settings::*;
pub use crate::inspection_settings::*;
pub use crate::proxy_settings::{ProxyKind, ProxyMode, ProxySettings};
pub use crate::remote_settings::*;
pub use crate::workflow_settings::*;
@@ -25,6 +26,8 @@ pub struct Settings {
/// keymap's own overrides. `keymap` may name one of these ids.
pub custom_keymaps: BTreeMap<String, CustomKeymap>,
pub notifications: NotificationSettings,
/// System Settings › HTTP Proxy (global only).
pub proxy: ProxySettings,
/// Interpreters / toolchains per language (Python, Node.js, Rust, Go, TypeScript).
pub languages: LanguagesSettings,
/// Project Structure: source / test / resource / excluded folders (project-relative).
@@ -134,6 +137,7 @@ impl Default for Settings {
keymap_overrides: BTreeMap::new(),
custom_keymaps: BTreeMap::new(),
notifications: NotificationSettings::default(),
proxy: ProxySettings::default(),
languages: LanguagesSettings::default(),
project_structure: ProjectStructureSettings::default(),
vcs: VcsSettings::default(),
@@ -174,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,
@@ -559,12 +563,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`.
@@ -687,3 +716,41 @@ 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);
}
}
}
+193
View File
@@ -0,0 +1,193 @@
//! 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();
let dirs = search_dirs(host_var_os("PATH").as_deref(), shell_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>,
home: Option<&Path>,
shims: &[PathBuf],
) -> Vec<PathBuf> {
let mut dirs: Vec<PathBuf> = process_path.into_iter().chain(shell_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",
".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"];
/// 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")),
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"));
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, Some(Path::new("/home/a:b")), &[]);
assert!(dirs.contains(&PathBuf::from("/usr/bin")) && !dirs.iter().any(|d| d.starts_with("/home")));
}
#[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"]);
}
}
+241
View File
@@ -0,0 +1,241 @@
//! Version managers that keep one install per tool version (mise, asdf): where their
//! installs and shims live. SDK detection lists the installs; child processes get the
//! shims on `PATH` when the manager's shell hook didn't run (an app started from the Dock,
//! `mise activate` only in an interactive rc file).
use std::ffi::{OsStr, OsString};
use std::path::{Path, PathBuf};
pub const MISE: &str = "mise";
pub const ASDF: &str = "asdf";
/// A toolchain the version managers install.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Tool {
Java,
Node,
Python,
Go,
Maven,
Gradle,
}
impl Tool {
/// The tool's names in mise and asdf plugins (`nodejs`, `golang` are asdf's).
pub fn names(self) -> &'static [&'static str] {
match self {
Tool::Java => &["java"],
Tool::Node => &["node", "nodejs"],
Tool::Python => &["python"],
Tool::Go => &["go", "golang"],
Tool::Maven => &["maven"],
Tool::Gradle => &["gradle"],
}
}
}
/// One version manager's data directory (`installs/`, `shims/`).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Manager {
pub name: &'static str,
pub dir: PathBuf,
}
impl Manager {
pub fn shims(&self) -> PathBuf {
self.dir.join("shims")
}
/// `installs/<tool>/<version>` directories of `tool`, sorted. The alias links mise
/// adds next to an install (`21 -> ./21.0.2`, `latest`) are left out; a link to an
/// install elsewhere (`mise link`) is kept.
pub fn installs(&self, tool: Tool) -> Vec<PathBuf> {
let mut out = Vec::new();
for name in tool.names() {
let Ok(entries) = std::fs::read_dir(self.dir.join("installs").join(name)) else { continue };
for e in entries.flatten() {
let path = e.path();
let alias = e.file_type().is_ok_and(|t| t.is_symlink())
&& std::fs::read_link(&path).is_ok_and(|t| t.is_relative() && t.components().count() <= 2);
if !alias && path.is_dir() {
out.push(path);
}
}
}
out.sort();
out
}
/// The install of `tool` named `version` exactly (alias links included).
pub fn install_named(&self, tool: Tool, version: &str) -> Option<PathBuf> {
if version.is_empty() || version.contains(['/', '\\']) || version.starts_with('.') {
return None;
}
tool.names().iter().map(|n| self.dir.join("installs").join(n).join(version)).find(|p| p.is_dir())
}
}
/// mise's and asdf's data directories that exist, as this process's environment (and the
/// login shell's) set them.
pub fn managers() -> Vec<Manager> {
let lookup = |k: &str| {
crate::process::host_var_os(k)
.or_else(|| crate::process::login_env()?.iter().find(|(n, _)| n == k).map(|(_, v)| v.clone()))
};
let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).map(PathBuf::from);
managers_with(&lookup, home.as_deref()).into_iter().filter(|m| m.dir.is_dir()).collect()
}
/// mise: `MISE_DATA_DIR`, else `$XDG_DATA_HOME/mise`, else `~/.local/share/mise`
/// (`%LOCALAPPDATA%\mise` on Windows). asdf: `ASDF_DATA_DIR`, else `~/.asdf`.
pub fn managers_with(env: &dyn Fn(&str) -> Option<OsString>, home: Option<&Path>) -> Vec<Manager> {
let var = |k: &str| env(k).filter(|v| !v.is_empty()).map(PathBuf::from);
let mise = var("MISE_DATA_DIR")
.or_else(|| var("XDG_DATA_HOME").map(|d| d.join("mise")))
.or_else(|| if cfg!(windows) { var("LOCALAPPDATA").map(|d| d.join("mise")) } else { None })
.or_else(|| home.map(|h| h.join(".local/share/mise")));
let asdf = var("ASDF_DATA_DIR").or_else(|| home.map(|h| h.join(".asdf")));
let mut out = Vec::new();
out.extend(mise.map(|dir| Manager { name: MISE, dir }));
out.extend(asdf.map(|dir| Manager { name: ASDF, dir }));
out
}
/// Every install of `tool` with the manager it came from, mise's first.
pub fn installs(tool: Tool) -> Vec<(PathBuf, &'static str)> {
managers().iter().flat_map(|m| m.installs(tool).into_iter().map(|p| (p, m.name))).collect()
}
/// The manager whose installs `path` lies in.
pub fn manager_of(path: &Path) -> Option<&'static str> {
manager_in(&managers(), path)
}
pub fn manager_in(managers: &[Manager], path: &Path) -> Option<&'static str> {
managers.iter().find_map(|m| {
let installs = m.dir.join("installs");
let real = dunce::canonicalize(&installs).ok();
(path.starts_with(&installs) || real.is_some_and(|r| path.starts_with(r))).then_some(m.name)
})
}
/// Whether `path` is a version manager's shim (it picks the version by the working directory).
pub fn is_shim(path: &Path) -> bool {
path.parent().is_some_and(is_shims_dir)
}
/// Whether `dir` is a version manager's `shims` folder.
pub fn is_shims_dir(dir: &Path) -> bool {
managers().iter().any(|m| m.shims() == dir)
}
/// `path` (a `PATH` value) with the shims of a manager it doesn't reach yet: mise's in
/// front (a mise shim runs the next tool on `PATH` when no version is set), asdf's at the
/// end (an asdf shim fails then). A `PATH` that has a manager's shims or installs already
/// (its shell hook ran) is left as it is for that manager.
pub fn path_with_shims(managers: &[Manager], path: &OsStr) -> OsString {
let mut dirs: Vec<PathBuf> = if path.is_empty() { Vec::new() } else { std::env::split_paths(path).collect() };
let before = dirs.len();
for m in managers {
let shims = m.shims();
let installs = m.dir.join("installs");
if !shims.is_dir() || dirs.iter().any(|d| d == &shims || d.starts_with(&installs)) {
continue;
}
if m.name == MISE {
dirs.insert(0, shims);
} else {
dirs.push(shims);
}
}
if dirs.len() == before {
return path.to_owned();
}
std::env::join_paths(dirs).unwrap_or_else(|_| path.to_owned())
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
fn mkdir(p: &Path) {
std::fs::create_dir_all(p).unwrap();
}
#[test]
fn data_dirs_follow_the_environment() {
let home = Path::new("/home/me");
let none = |_: &str| None;
let dirs: Vec<_> = managers_with(&none, Some(home)).into_iter().map(|m| (m.name, m.dir)).collect();
if !cfg!(windows) {
assert_eq!(dirs[0], (MISE, home.join(".local/share/mise")));
}
assert_eq!(dirs[1], (ASDF, home.join(".asdf")));
let xdg = |k: &str| (k == "XDG_DATA_HOME").then(|| OsString::from("/xdg"));
assert_eq!(managers_with(&xdg, Some(home))[0].dir, Path::new("/xdg/mise"));
let custom = |k: &str| match k {
"MISE_DATA_DIR" => Some(OsString::from("/opt/mise")),
"XDG_DATA_HOME" => Some(OsString::from("/xdg")),
"ASDF_DATA_DIR" => Some(OsString::from("/opt/asdf")),
_ => None,
};
let dirs: Vec<_> = managers_with(&custom, Some(home)).into_iter().map(|m| m.dir).collect();
assert_eq!(dirs, [PathBuf::from("/opt/mise"), PathBuf::from("/opt/asdf")]);
}
#[cfg(unix)]
#[test]
fn installs_skip_alias_links() {
let tmp = tempfile::tempdir().unwrap();
let mise = Manager { name: MISE, dir: tmp.path().join("mise") };
let node = mise.dir.join("installs/node");
mkdir(&node.join("20.11.0"));
mkdir(&node.join("22.1.0"));
std::os::unix::fs::symlink("./22.1.0", node.join("22")).unwrap();
std::os::unix::fs::symlink("./22.1.0", node.join("latest")).unwrap();
let elsewhere = tmp.path().join("opt-node");
mkdir(&elsewhere);
std::os::unix::fs::symlink(&elsewhere, node.join("linked")).unwrap();
std::fs::write(node.join(".mise.backend.toml"), "").unwrap();
let names: Vec<_> =
mise.installs(Tool::Node).iter().map(|p| p.file_name().unwrap().to_string_lossy().into_owned()).collect();
assert_eq!(names, ["20.11.0", "22.1.0", "linked"]);
assert_eq!(mise.install_named(Tool::Node, "22"), Some(node.join("22")));
assert_eq!(mise.install_named(Tool::Node, "18"), None);
assert_eq!(mise.install_named(Tool::Node, "../node"), None);
// asdf's plugin names.
let asdf = Manager { name: ASDF, dir: tmp.path().join("asdf") };
mkdir(&asdf.dir.join("installs/golang/1.22.1"));
assert_eq!(asdf.installs(Tool::Go), [asdf.dir.join("installs/golang/1.22.1")]);
assert_eq!(manager_in(&[mise.clone(), asdf.clone()], &node.join("20.11.0/bin/node")), Some(MISE));
assert_eq!(manager_in(&[mise, asdf], tmp.path()), None);
}
#[cfg(unix)]
#[test]
fn shims_join_a_path_the_hook_did_not_set() {
let tmp = tempfile::tempdir().unwrap();
let mise = Manager { name: MISE, dir: tmp.path().join("mise") };
let asdf = Manager { name: ASDF, dir: tmp.path().join("asdf") };
let managers = [mise.clone(), asdf.clone()];
let path = OsString::from("/usr/bin:/bin");
assert_eq!(path_with_shims(&managers, &path), path, "no shims installed");
mkdir(&mise.shims());
mkdir(&asdf.shims());
let got = path_with_shims(&managers, &path);
let want = format!("{}:/usr/bin:/bin:{}", mise.shims().display(), asdf.shims().display());
assert_eq!(got, OsString::from(want));
assert_eq!(path_with_shims(&managers, &got), got, "applied once");
// `mise activate` put an install on PATH: mise is active, only asdf's shims join.
let active = format!("{}:/usr/bin", mise.dir.join("installs/node/22.1.0/bin").display());
let got = path_with_shims(&managers, OsStr::new(&active));
assert_eq!(got, OsString::from(format!("{active}:{}", asdf.shims().display())));
}
}
+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
+6
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",
+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);
}
}
}
+75 -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;
}
@@ -722,7 +732,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 +771,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 +826,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 +849,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 +864,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 +923,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 +941,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(())
}
+127 -3
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 {
@@ -130,7 +132,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 +143,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 +897,7 @@ fn godot_registry(port: u16, command: &str, args: &[&str]) -> Arc<LanguageRegist
block_comment: None,
indent: None,
ui_mode: None,
framework_ui_modes: Vec::new(),
tree_sitter: None,
language_servers: vec![],
debugger: Some(ide_language::DebuggerConfig {
@@ -1015,3 +1020,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
+486 -34
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");
@@ -88,10 +270,46 @@ fn hex(bytes: &[u8]) -> String {
s
}
/// How [`cell`] shows a value whose bytes are not text.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Cells {
/// Result grids: binary values as `0x…` hex.
Display,
/// Catalog queries: names and definitions, so bytes are read as UTF-8 whatever the
/// column's type (MySQL flags `utf8mb3_bin` catalog columns BINARY, and sends every
/// column as binary under `character_set_results = binary`).
Text,
}
/// Whether a value of type `ty` is binary data (see [`binary_type`]).
fn is_binary<DB: sqlx::Database>(ty: &DB::TypeInfo) -> bool
where
String: sqlx::Type<DB>,
{
binary_type(ty.name(), <String as sqlx::Type<DB>>::compatible(ty))
}
/// A BLOB / BINARY type that the driver does not read as text: MySQL flags text in a
/// binary collation (`utf8mb4_bin`) BINARY too, and only the `binary` collation is bytes.
fn binary_type(name: &str, text: bool) -> bool {
let name = name.to_ascii_uppercase();
(name.contains("BLOB") || name.contains("BINARY")) && !text
}
/// Bytes as text: UTF-8, else hex (grids) or with invalid sequences replaced (catalog names).
fn bytes_text(bytes: Vec<u8>, mode: Cells) -> String {
match (String::from_utf8(bytes), mode) {
(Ok(s), _) => s,
(Err(e), Cells::Display) => hex(e.as_bytes()),
(Err(e), Cells::Text) => String::from_utf8_lossy(e.as_bytes()).into_owned(),
}
}
/// The text of column `i` of `row`.
fn cell<R: Row>(row: &R, i: usize) -> Option<String>
fn cell<R: Row>(row: &R, i: usize, mode: Cells) -> Option<String>
where
usize: ColumnIndex<R>,
String: sqlx::Type<R::Database>,
for<'r> String: Decode<'r, R::Database>,
for<'r> Vec<u8>: Decode<'r, R::Database>,
{
@@ -99,18 +317,14 @@ where
if raw.is_null() {
return None;
}
let ty = raw.type_info().name().to_ascii_uppercase();
if ty.contains("BLOB") || ty.contains("BINARY") {
let raw = row.try_get_raw(i).ok()?;
if mode == Cells::Display && is_binary::<R::Database>(&raw.type_info()) {
return <Vec<u8> as Decode<R::Database>>::decode(raw).ok().map(|b| hex(&b));
}
match <String as Decode<R::Database>>::decode(raw) {
Ok(s) => Some(s),
Err(_) => {
let raw = row.try_get_raw(i).ok()?;
<Vec<u8> as Decode<R::Database>>::decode(raw)
.ok()
.map(|b| String::from_utf8(b).unwrap_or_else(|e| hex(e.as_bytes())))
<Vec<u8> as Decode<R::Database>>::decode(raw).ok().map(|b| bytes_text(b, mode))
}
}
}
@@ -122,9 +336,10 @@ fn result_columns<R: Row>(row: &R) -> Vec<ResultColumn> {
.collect()
}
/// Streams the results of `$sql` on executor `$exec`, keeping at most `$max` rows.
/// Streams the results of `$sql` on executor `$exec`, keeping at most `$max` rows
/// with their cells read as `$mode`.
macro_rules! fetch {
($exec:expr, $sql:expr, $max:expr) => {{
($exec:expr, $sql:expr, $max:expr, $mode:expr) => {{
use futures::StreamExt;
let mut stream = sqlx::raw_sql(sqlx::AssertSqlSafe($sql.to_owned())).fetch_many($exec);
let mut columns: Vec<ResultColumn> = Vec::new();
@@ -145,7 +360,7 @@ macro_rules! fetch {
has_more = true;
break;
}
rows.push((0..row.len()).map(|i| cell(&row, i)).collect());
rows.push((0..row.len()).map(|i| cell(&row, i, $mode)).collect());
}
Err(e) => {
failure = Some(db_err(e));
@@ -164,10 +379,41 @@ 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) => {{
let fetched: IdeResult<Fetched> = fetch!($conn, $sql, 1);
let fetched: IdeResult<Fetched> = fetch!($conn, $sql, 1, Cells::Text);
fetched
.ok()
.and_then(|f| f.1.into_iter().next())
@@ -280,6 +526,7 @@ impl Pool {
Pool::Sqlite(_) => DbDriver::Sqlite,
Pool::Postgres(_) => DbDriver::Postgres,
Pool::Mysql(_) => DbDriver::Mysql,
Pool::SqlServer(_) => DbDriver::SqlServer,
}
}
@@ -420,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.
/// 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));
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)
}
@@ -452,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)
}),
}
}
}
@@ -476,6 +789,7 @@ pub enum Held {
Sqlite(PoolConnection<Sqlite>),
Postgres(PoolConnection<Postgres>),
Mysql(PoolConnection<MySql>),
SqlServer(Box<bb8::PooledConnection<'static, MssqlConnectionManager>>),
}
impl Held {
@@ -484,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
@@ -509,6 +830,9 @@ impl Held {
Held::Mysql(c) => {
let _ = c.detach().close().await;
}
Held::SqlServer(mut c) => {
c.0.take();
}
}
}
@@ -530,7 +854,7 @@ impl Held {
None => None,
};
let r = tokio::select! {
r = async { fetch!(&mut **$conn, sql, max_rows) } => r,
r = async { fetch!(&mut **$conn, sql, max_rows, Cells::Display) } => r,
() = cancel.cancelled() => Err(IdeError::cancelled()),
};
if cancel.is_cancelled() {
@@ -556,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;
@@ -575,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",
}
}
@@ -591,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() }
}
@@ -649,4 +1070,35 @@ mod tests {
let s = DataSource { properties: vec![prop("cache_size", "-2000")], ..Default::default() };
assert_eq!(Setup::of(&s).unwrap().params, [("cache_size".to_owned(), "-2000".to_owned())]);
}
#[test]
fn only_bytes_typed_values_are_binary() {
// (type name as the driver reports it, the driver reads it as text, binary)
let cases = [
("VARBINARY", false, true),
("BLOB", false, true),
("LONGBLOB", false, true),
("BINARY", false, true),
// MySQL catalog columns (`utf8mb3_bin`) and `utf8mb4_bin` text: flagged BINARY.
("VARBINARY", true, false),
("BLOB", true, false),
("VARCHAR", true, false),
("TEXT", true, false),
("BYTEA", false, false),
("INT", false, false),
];
for (name, text, binary) in cases {
assert_eq!(binary_type(name, text), binary, "{name} text={text}");
}
}
#[test]
fn catalog_bytes_are_names_and_grid_bytes_are_hex() {
// `oi_数据` as MySQL sends it under `character_set_results = binary`.
let name = "oi_数据".as_bytes().to_vec();
assert_eq!(bytes_text(name.clone(), Cells::Text), "oi_数据");
assert_eq!(bytes_text(name, Cells::Display), "oi_数据");
assert_eq!(bytes_text(vec![0xCA, 0xFE], Cells::Display), "0xCAFE");
assert_eq!(bytes_text(vec![b'a', 0xFF], Cells::Text), "a\u{FFFD}");
}
}
+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;
+87
View File
@@ -0,0 +1,87 @@
//! Introspection against a real MySQL / MariaDB server, skipped unless
//! `OPENIDEA_TEST_MYSQL=host:port:user:password` names a throwaway server.
use ide_core::{CancellationToken, IdeResult};
use ide_db::{DataSource, DbDriver, DbManager, DbOptions};
const SCHEMAS: [&str; 2] = ["oi_bin_meta", "oi_数据"];
fn server() -> Option<(String, u16, String, String)> {
let v = std::env::var("OPENIDEA_TEST_MYSQL").ok()?;
let mut it = v.splitn(4, ':');
Some((it.next()?.into(), it.next()?.parse().ok()?, it.next()?.into(), it.next().unwrap_or("").into()))
}
async fn manager(tmp: &tempfile::TempDir, startup_script: &str) -> Option<DbManager> {
let (host, port, user, password) = server()?;
let root = tmp.path().to_path_buf();
let ssh = std::sync::Arc::new(ide_ssh::SshManager::new(root.join("ssh-passwords.json")));
let db = DbManager::new(root.clone(), root.join("passwords.json"), root.join("history.json"), ssh);
db.save_sources(vec![DataSource {
id: "m".into(),
name: "mysql".into(),
driver: DbDriver::Mysql,
host,
port: Some(port),
user,
options: DbOptions { startup_script: startup_script.into(), ..Default::default() },
..Default::default()
}])
.await
.unwrap();
db.set_password("m", Some(password), false).await.unwrap();
Some(db)
}
async fn create_schemas(db: &DbManager) {
let c = CancellationToken::new();
for s in SCHEMAS {
for stmt in [
format!("DROP DATABASE IF EXISTS `{s}`"),
format!("CREATE DATABASE `{s}` CHARACTER SET utf8mb4 COLLATE utf8mb4_bin"),
format!(
"CREATE TABLE `{s}`.`用户` (id INT PRIMARY KEY, name VARCHAR(20) COLLATE utf8mb4_bin, raw VARBINARY(4))"
),
format!("CREATE INDEX `名字` ON `{s}`.`用户` (name)"),
format!("INSERT INTO `{s}`.`用户` VALUES (1, 'ann', 0xCAFE)"),
] {
db.execute("m", &stmt, 10, &c).await.unwrap();
}
}
}
async fn assert_metadata_is_text(db: &DbManager) -> IdeResult<()> {
let schemas = db.schemas("m").await?;
for s in SCHEMAS {
assert!(schemas.iter().any(|n| n == s), "{s} missing from {schemas:?}");
let tables = db.tables("m", s).await?;
assert_eq!(tables.iter().map(|t| t.name.as_str()).collect::<Vec<_>>(), ["用户"], "tables of {s}");
let d = db.table_details("m", s, "用户").await?;
let cols: Vec<_> = d.columns.iter().map(|c| c.name.as_str()).collect();
assert_eq!(cols, ["id", "name", "raw"]);
assert!(d.indexes.iter().any(|i| i.name == "名字"), "{:?}", d.indexes);
assert!(db.ddl("m", s, "用户").await?.contains("CREATE TABLE `用户`"));
}
let completion = db.completion("m").await?;
let json = serde_json::to_string(&completion).unwrap();
assert!(json.contains("oi_数据") && json.contains("用户"), "{json}");
Ok(())
}
// One test: both phases share the schemas they create.
#[tokio::test]
async fn metadata_names_are_text_whatever_the_column_collation() -> IdeResult<()> {
let tmp = tempfile::tempdir().unwrap();
let Some(db) = manager(&tmp, "").await else { return Ok(()) };
create_schemas(&db).await;
// MySQL 8 sends `utf8mb3_bin` catalog names flagged BINARY (lower_case_table_names = 0).
assert_metadata_is_text(&db).await?;
// `_bin` text is text in the grid; true binary stays hex.
let r = db.execute("m", "SELECT name, raw FROM `oi_数据`.`用户`", 10, &CancellationToken::new()).await?;
assert_eq!(r.rows, vec![vec![Some("ann".to_owned()), Some("0xCAFE".to_owned())]]);
// Every text column arrives with the `binary` collation.
let tmp = tempfile::tempdir().unwrap();
let raw = manager(&tmp, "SET character_set_results = binary").await.unwrap();
assert_metadata_is_text(&raw).await
}
+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));
}
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "ide-fonts"
description = "Font families installed on this machine, for the font pickers: CoreText, DirectWrite or fontconfig"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
serde.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-text = { version = "0.3", default-features = false, features = ["std", "CTFontCollection", "CTFontDescriptor", "CTFontTraits"] }
[target.'cfg(windows)'.dependencies]
windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite"] }
+306
View File
@@ -0,0 +1,306 @@
//! 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
//! macOS, DirectWrite on Windows, fontconfig (`fc-list`) on Linux and the BSDs.
//! 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::{Arc, Mutex};
/// One installed family. `monospaced` is the font system's own flag (fixed
/// pitch; on Linux also fontconfig's dual-width CJK monospace).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct FontFamily {
pub name: String,
pub monospaced: bool,
}
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
/// family: trimmed, without empty or hidden (`.`-prefixed system UI) names,
/// monospaced when any face is, sorted case-insensitively.
pub fn normalize(faces: impl IntoIterator<Item = (String, bool)>) -> Vec<FontFamily> {
let mut by_key: BTreeMap<(String, String), bool> = BTreeMap::new();
for (name, mono) in faces {
let name = name.trim();
if name.is_empty() || name.starts_with('.') {
continue;
}
*by_key.entry((name.to_lowercase(), name.to_owned())).or_default() |= mono;
}
let mut out: Vec<FontFamily> = Vec::with_capacity(by_key.len());
for ((key, name), monospaced) in by_key {
// Same family under two casings (one font file each): keep the first.
if let Some(last) = out.last_mut()
&& last.name.to_lowercase() == key
{
last.monospaced |= monospaced;
continue;
}
out.push(FontFamily { name, monospaced });
}
out
}
/// Parses `fc-list -f '%{family[0]}\t%{spacing}\n'`: one line per face, the
/// spacing empty (proportional) or fontconfig's number or name for it.
pub fn parse_fc_list(output: &str) -> Vec<(String, bool)> {
output
.lines()
.filter_map(|line| {
let (family, spacing) = line.split_once('\t').unwrap_or((line, ""));
let family = family.replace("\\-", "-");
let mono = match spacing.trim() {
"dual" | "mono" | "charcell" => true,
s => s.parse::<u32>().is_ok_and(|n| n >= 90),
};
(!family.trim().is_empty()).then_some((family, mono))
})
.collect()
}
#[cfg(target_os = "macos")]
mod imp {
use objc2_core_foundation::{CFArray, CFDictionary, CFNumber, CFString, CFType};
use objc2_core_text::{
CTFontCollection, CTFontDescriptor, CTFontSymbolicTraits, kCTFontFamilyNameAttribute, kCTFontSymbolicTrait,
kCTFontTraitsAttribute,
};
/// Every face CoreText makes available to this process, as (family, fixed pitch).
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 {
let collection = CTFontCollection::from_available_fonts(None);
let Some(descriptors) = collection.matching_font_descriptors() else {
return Vec::new();
};
let descriptors: &CFArray<CTFontDescriptor> = descriptors.cast_unchecked();
descriptors
.iter()
.filter_map(|d| {
let name = d.attribute(kCTFontFamilyNameAttribute)?.downcast::<CFString>().ok()?.to_string();
let traits = d
.attribute(kCTFontTraitsAttribute)
.and_then(|t| t.downcast::<CFDictionary>().ok())
.and_then(|t| t.cast_unchecked::<CFString, CFType>().get(kCTFontSymbolicTrait))
.and_then(|n| n.downcast::<CFNumber>().ok())
.and_then(|n| n.as_i64())
.unwrap_or(0);
Some((name, traits as u32 & CTFontSymbolicTraits::TraitMonoSpace.0 != 0))
})
.collect()
}
}
}
#[cfg(windows)]
mod imp {
use windows::Win32::Graphics::DirectWrite::{
DWRITE_FACTORY_TYPE_SHARED, DWRITE_FONT_STRETCH_NORMAL, DWRITE_FONT_STYLE_NORMAL, DWRITE_FONT_WEIGHT_NORMAL,
DWriteCreateFactory, IDWriteFactory, IDWriteFont1, IDWriteLocalizedStrings,
};
use windows::core::{BOOL, Interface, w};
/// 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(refresh: bool) -> Vec<(String, bool)> {
// SAFETY: plain DirectWrite COM calls on interfaces we own.
unsafe { read(refresh) }.unwrap_or_default()
}
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, refresh) }?;
let Some(collection) = collection else {
return Ok(Vec::new());
};
let mut out = Vec::new();
for i in 0..unsafe { collection.GetFontFamilyCount() } {
let Ok(family) = (unsafe { collection.GetFontFamily(i) }) else {
continue;
};
let Some(name) = unsafe { family.GetFamilyNames() }.ok().and_then(|n| unsafe { english(&n) }) else {
continue;
};
let mono = unsafe {
family.GetFirstMatchingFont(
DWRITE_FONT_WEIGHT_NORMAL,
DWRITE_FONT_STRETCH_NORMAL,
DWRITE_FONT_STYLE_NORMAL,
)
}
.ok()
.and_then(|f| f.cast::<IDWriteFont1>().ok())
.is_some_and(|f| unsafe { f.IsMonospacedFont() }.as_bool());
out.push((name, mono));
}
Ok(out)
}
unsafe fn english(names: &IDWriteLocalizedStrings) -> Option<String> {
let mut index = 0u32;
let mut exists = BOOL(0);
unsafe { names.FindLocaleName(w!("en-us"), &mut index, &mut exists) }.ok()?;
if !exists.as_bool() {
index = 0;
}
let len = unsafe { names.GetStringLength(index) }.ok()? as usize;
let mut buf = vec![0u16; len + 1];
unsafe { names.GetString(index, &mut buf) }.ok()?;
Some(String::from_utf16_lossy(&buf[..len]))
}
}
#[cfg(all(unix, not(target_os = "macos")))]
mod imp {
use std::process::{Command, Stdio};
/// fontconfig's view, which WebKitGTK resolves names with. Empty when
/// `fc-list` isn't installed.
pub(crate) fn list(_refresh: bool) -> Vec<(String, bool)> {
Command::new("fc-list")
.args(["-f", "%{family[0]}\t%{spacing}\n"])
.stdin(Stdio::null())
.stderr(Stdio::null())
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| super::parse_fc_list(&String::from_utf8_lossy(&o.stdout)))
.unwrap_or_default()
}
}
#[cfg(not(any(unix, windows)))]
mod imp {
pub(crate) fn list(_refresh: bool) -> Vec<(String, bool)> {
Vec::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
fn fam(name: &str, monospaced: bool) -> FontFamily {
FontFamily { name: name.into(), monospaced }
}
#[test]
fn normalize_merges_faces_sorts_and_drops_hidden() {
let faces = [
("Menlo", true),
("Arial", false),
("Menlo", true),
(".SF NS Mono", true),
(" ", false),
("arial", false),
("PingFang SC", false),
("Courier New", false),
("Courier New", true),
("andale mono", true),
]
.map(|(n, m)| (n.to_owned(), m));
assert_eq!(
normalize(faces),
vec![
fam("andale mono", true),
fam("Arial", false),
fam("Courier New", true),
fam("Menlo", true),
fam("PingFang SC", false),
]
);
}
#[test]
fn fc_list_spacing_marks_monospace() {
let out = "DejaVu Sans Mono\t100\nDejaVu Sans\t\nNoto Sans Mono CJK SC\t90\nTerminus\t110\nCantarell\nFira\\-Code\tmono\n\t100\n";
assert_eq!(
parse_fc_list(out),
[
("DejaVu Sans Mono", true),
("DejaVu Sans", false),
("Noto Sans Mono CJK SC", true),
("Terminus", true),
("Cantarell", false),
("Fira-Code", true),
]
.map(|(n, m)| (n.to_owned(), m))
);
}
#[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(false);
assert!(list.len() > 20, "only {} families", list.len());
assert!(list.iter().any(|f| f.monospaced));
assert!(list.iter().any(|f| !f.monospaced));
let keys: Vec<String> = list.iter().map(|f| f.name.to_lowercase()).collect();
assert!(keys.windows(2).all(|w| w[0] < w[1]));
#[cfg(target_os = "macos")]
{
let menlo = list.iter().find(|f| f.name == "Menlo").expect("Menlo ships with macOS");
assert!(menlo.monospaced);
assert!(list.iter().any(|f| f.name == "Helvetica Neue" && !f.monospaced));
}
assert_eq!(&*families(true), &*list);
}
}
+5 -1
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
[dev-dependencies]
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "io-util"] }
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "io-util", "test-util"] }
tempfile.workspace = true
+6 -1
View File
@@ -281,7 +281,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;
}
@@ -395,6 +396,8 @@ mod tests {
assert_eq!(changed_names(&before, &after), ["B", "C", "D"]);
}
// Used by the Unix-only post-start tests.
#[cfg_attr(not(unix), allow(dead_code))]
#[derive(Default)]
struct Events(Mutex<Vec<IdeEvent>>);
impl EventSink for Events {
@@ -403,6 +406,7 @@ mod tests {
}
}
#[cfg_attr(not(unix), allow(dead_code))]
fn run(cmd: LifecycleCommand, root: &Path) -> Vec<IdeEvent> {
let events = Arc::new(Events::default());
let sink: SharedSink = events.clone();
@@ -410,6 +414,7 @@ mod tests {
events.0.lock().clone()
}
#[cfg_attr(not(unix), allow(dead_code))]
fn failure(events: &[IdeEvent]) -> Option<String> {
events.iter().find_map(|e| match e {
IdeEvent::Notify { level: NotifyLevel::Error, body, .. } => body.clone(),
+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());
}
}
+63 -6
View File
@@ -4,8 +4,11 @@
//! runs one on a remote machine and serves it over JSON-RPC ([`rpc`]).
pub mod environment;
mod java_runtime;
pub mod notebook;
pub mod ops;
mod project;
mod proxy;
mod requests;
pub mod rpc;
pub mod shell_env;
@@ -56,6 +59,8 @@ pub struct Host {
pub remote: Arc<ide_remote::RemoteService>,
/// What child processes get beyond this process's environment (Reload Environment, `remoteEnv`).
pub environment: environment::Environment,
/// The HTTP proxy's saved password (see `proxy.rs`).
proxy_password: ide_ssh::secrets::SecretFile,
pub(crate) sink_for: SinkFactory,
owner: fn(&str) -> &str,
pub runtime: tokio::runtime::Handle,
@@ -71,7 +76,8 @@ impl Host {
let global_settings = SettingsStore::load(&config_dir, None);
let ssh = Arc::new(ide_ssh::SshManager::new(data_dir.join("ssh-passwords.json")));
ssh.set_configs(global_settings.effective().ssh.configs);
Self {
let host = Self {
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,
environment: environment::Environment::new(project_environment, data_dir.clone()),
@@ -89,7 +95,9 @@ impl Host {
runtime,
projects: RwLock::default(),
requests: Default::default(),
}
};
host.apply_proxy();
host
}
/// The tree-sitter grammars (blocks only if start-up warm-up is still running).
@@ -126,26 +134,75 @@ 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();
}
project.agents.shutdown();
project.notebooks.stop_admission();
self.shutdown_notebooks_blocking(vec![project.notebooks.clone()]);
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_notebooks_blocking(&self, notebooks: Vec<Arc<notebook::NotebookService>>) {
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 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) {
self.shutdown_notebooks_blocking(self.projects().iter().map(|project| project.notebooks.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 {
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 {
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");
}
}
+796
View File
@@ -0,0 +1,796 @@
//! 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 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();
}
}
+53 -2
View File
@@ -87,8 +87,14 @@ 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> {
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.
@@ -140,6 +146,10 @@ 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.project(window)?.agents.delete(&task, remove_worktree).await
}
@@ -183,6 +193,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 +202,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:?}");
}
}
}
+77 -6
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
@@ -107,6 +113,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 +123,27 @@ 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).
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
@@ -203,6 +226,7 @@ pub async fn build_run(
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 config_dir = host.config_dir.clone();
let token = host.begin_request(request);
let result = tokio::task::spawn_blocking(move || {
for path in clear {
@@ -212,7 +236,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 +301,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());
}
}
+111 -2
View File
@@ -1,3 +1,5 @@
mod output;
use crate::Host;
use crate::rpc::Dispatcher;
use ide_core::{DebugSessionId, IdeResult};
@@ -6,18 +8,82 @@ 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> {
let mut config = match_java_debug_encoding(config, cfg!(windows));
if config.adapter == "debugpy"
&& config.request == "launch"
&& let Ok(project) = host.project(window)
{
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 == 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
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
/// (see [`ide_core::output_encoding::java_debug_vm_args`]).
fn match_java_debug_encoding(mut config: LaunchConfig, windows: bool) -> LaunchConfig {
if config.adapter != "java-debug" || config.request != "launch" {
return config;
}
let Some(serde_json::Value::Object(mut extra)) =
config.extra_json.as_deref().and_then(|e| serde_json::from_str(e).ok())
else {
return config;
};
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) {
extra.insert("vmArgs".into(), vm_args.into());
config.extra_json = Some(serde_json::Value::Object(extra).to_string());
}
config
}
pub async fn debug_set_breakpoints(
@@ -355,3 +421,46 @@ pub(crate) fn register(d: &mut Dispatcher) {
|c| async move { debug_redefine_classes(&c.host, &c.window, c.arg("session")?).await },
);
}
#[cfg(test)]
mod tests {
use super::*;
fn launch(adapter: &str, request: &str, extra: serde_json::Value) -> LaunchConfig {
LaunchConfig {
name: "DromaraApplication".into(),
adapter: adapter.into(),
request: request.into(),
program: None,
args: vec![],
cwd: None,
env: Default::default(),
extra_json: Some(extra.to_string()),
}
}
fn vm_args(config: &LaunchConfig) -> serde_json::Value {
serde_json::from_str::<serde_json::Value>(config.extra_json.as_deref().unwrap()).unwrap()["vmArgs"].clone()
}
#[test]
fn java_debug_launches_on_windows_write_utf8() {
let extra =
serde_json::json!({"mainClass": "org.dromara.DromaraApplication", "vmArgs": "-Xmx1g", "debugServer": 1});
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"
);
assert_eq!(
serde_json::from_str::<serde_json::Value>(fixed.extra_json.as_deref().unwrap()).unwrap()["mainClass"],
"org.dromara.DromaraApplication"
);
// Attaching, other adapters and other platforms are left alone.
for (adapter, request, windows) in
[("java-debug", "attach", true), ("debugpy", "launch", true), ("java-debug", "launch", false)]
{
assert_eq!(vm_args(&match_java_debug_encoding(launch(adapter, request, extra.clone()), windows)), "-Xmx1g");
}
}
}
+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);
}
}
+4 -2
View File
@@ -28,12 +28,14 @@ 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
}
pub async fn snapshot(host: &Host, window: &str, doc: DocId) -> IdeResult<DocumentInfo> {
+8
View File
@@ -22,6 +22,13 @@ 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)
@@ -108,6 +115,7 @@ 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")?)
});
+27 -5
View File
@@ -4,10 +4,11 @@ use crate::rpc::Dispatcher;
use super::build::load;
use ide_build::compile;
use ide_core::{CancellationToken, IdeError, IdeResult};
use ide_core::{CancellationToken, IdeError, IdeResult, RequestId};
use ide_lsp::jdtls::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,22 +18,39 @@ pub async fn java_resolve_launch(
module: PathBuf,
test: bool,
build: bool,
request: RequestId,
) -> IdeResult<JavaLaunchInfo> {
let cancel = host.begin_request(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 project = host.project(window)?;
let module = project.root.join(module);
let settings = project.settings.effective();
// Let jdtls finish its build before the external compiler writes Kotlin/Scala classes.
let mut info = project.lsp.java_launch(&module, test, build, cancel).await?;
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 cancel = cancel.clone();
tokio::task::spawn_blocking(move || {
compile::build_for_launch(&root, &load(&root, &cache, &settings), &module, test, &CancellationToken::new())
compile::build_for_launch(&root, &load(&root, &cache, &settings), &module, test, &cancel)
})
.await
.map_err(|e| IdeError::internal(e.to_string()))??
} else {
Vec::new()
};
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) {
@@ -64,6 +82,9 @@ pub async fn java_resolve_launch(
.map_err(|e| IdeError::internal(e.to_string()))?;
info.hints.extend(lombok.problem());
}
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
Ok(info)
}
@@ -82,13 +103,14 @@ pub async fn java_build_workspace(
) -> IdeResult<ide_lsp::jdtls::JavaBuildStatus> {
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 });
+23 -6
View File
@@ -186,8 +186,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(
@@ -220,11 +231,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(
@@ -335,7 +351,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 +364,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
+3
View File
@@ -15,7 +15,9 @@ pub mod history;
pub mod java;
pub mod lsp;
pub mod nav;
pub mod notebook;
pub mod project;
mod python;
pub mod refactor;
pub mod run;
pub mod sdk;
@@ -59,6 +61,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);
+155
View File
@@ -0,0 +1,155 @@
//! 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> {
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> {
let project = host.project(window)?;
let cancel = host.begin_request(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
});
}
+55 -5
View File
@@ -14,6 +14,8 @@ pub struct ProjectInfo {
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 {
@@ -28,6 +30,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(),
}
}
@@ -43,16 +46,52 @@ pub fn current(host: &Host, window: &str) -> Option<ProjectInfo> {
/// Effective settings for the window's project, or global settings without one.
pub fn settings(host: &Host, window: &str) -> Settings {
match host.project(window) {
host.with_proxy_password(match host.project(window) {
Ok(p) => p.settings.effective(),
Err(_) => host.global_settings.effective(),
}
})
}
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,
@@ -64,7 +103,8 @@ pub struct SettingsApplied {
/// Sets `key` to `value` (JSON text, e.g. `14` or `"dark"`) for the window's
/// project or globally, and applies it to the open projects: file defaults,
/// file types, language servers, index scope and SSH configurations.
/// file types, language servers, index scope, SSH configurations and the HTTP proxy
/// (whose password goes to the saved passwords, not the settings file).
pub fn set_settings(
host: &Host,
window: &str,
@@ -77,6 +117,11 @@ pub fn set_settings(
let current = host.project(window).ok();
// A LightEdit window has no project layer: its values are global.
let project_scope = matches!(scope, SettingsScope::Project) && current.as_ref().is_some_and(|p| !p.light);
let is_proxy = key == "proxy" || key.starts_with("proxy.");
let Some(value) = host.take_proxy_password(key, value)? else {
host.apply_proxy();
return Ok(SettingsApplied { settings: settings(host, window), project_scope, roots_changed: None });
};
let settings = match &current {
Some(p) if project_scope => p.settings.set(scope, key, value)?,
Some(p) => {
@@ -113,6 +158,9 @@ pub fn set_settings(
if key == "ssh" || key.starts_with("ssh.") {
host.ssh.set_configs(host.global_settings.effective().ssh.configs);
}
if is_proxy {
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}."))) {
for project in affected() {
@@ -120,7 +168,9 @@ pub fn set_settings(
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.") {
@@ -132,5 +182,5 @@ pub fn set_settings(
});
}
let roots_changed = current.as_ref().filter(|_| roots_changed && project_scope).map(|p| p.root.clone().into());
Ok(SettingsApplied { settings, project_scope, roots_changed })
Ok(SettingsApplied { settings: host.with_proxy_password(settings), project_scope, roots_changed })
}
+103
View File
@@ -0,0 +1,103 @@
use crate::project::Project;
use ide_core::IdeResult;
use ide_dap::LaunchConfig;
use ide_workspace::roots::RootKind;
use std::collections::BTreeMap;
use std::path::PathBuf;
pub(super) fn paths(project: &Project) -> Vec<PathBuf> {
let mut paths = vec![project.root.clone()];
paths.extend(project.content_roots());
for root in project.roots().roots {
if matches!(root.kind, RootKind::Source | RootKind::Test) {
let path = project.root.join(root.path);
if !paths.contains(&path) {
paths.push(path);
}
}
}
paths
}
pub(super) fn prepare_debug(config: &mut LaunchConfig, paths: &[PathBuf]) -> IdeResult<()> {
// Adapter-specific env takes precedence over the common launch fields in DAP.
let mut extra: Option<serde_json::Value> = config.extra_json.as_deref().and_then(|s| serde_json::from_str(s).ok());
// A custom adapter may run on another host; local paths are not portable.
if extra.as_ref().is_some_and(|e| e.get("debugServer").is_some()) {
return Ok(());
}
if let Some(env) = extra.as_mut().and_then(|e| e.get_mut("env")) {
if env.is_null() {
*env = serde_json::json!({});
}
if !env.is_object() {
return Ok(());
}
let mut vars = BTreeMap::new();
if let Some(value) = env.get("PYTHONPATH").and_then(|v| v.as_str()) {
vars.insert("PYTHONPATH".into(), value.to_owned());
}
ide_run::python_path::extend(&mut vars, paths, env.get("PYTHONPATH").is_none())?;
env["PYTHONPATH"] = serde_json::json!(vars["PYTHONPATH"]);
config.extra_json = extra.map(|e| e.to_string());
} else {
ide_run::python_path::extend(&mut config.env, paths, true)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn debug_preserves_remote_paths_and_handles_null_env() {
let root = std::env::temp_dir().join("project");
let mut config = LaunchConfig {
name: "Python".into(),
adapter: "debugpy".into(),
request: "launch".into(),
program: None,
args: vec![],
cwd: None,
env: BTreeMap::new(),
extra_json: Some(serde_json::json!({"env": null, "debugServer": "remote:5678"}).to_string()),
};
let before = config.extra_json.clone();
prepare_debug(&mut config, std::slice::from_ref(&root)).unwrap();
assert_eq!(config.extra_json, before);
assert!(config.env.is_empty());
config.extra_json = Some(serde_json::json!({"env": null}).to_string());
prepare_debug(&mut config, std::slice::from_ref(&root)).unwrap();
let value: serde_json::Value = serde_json::from_str(config.extra_json.as_deref().unwrap()).unwrap();
let paths = std::env::split_paths(value["env"]["PYTHONPATH"].as_str().unwrap()).collect::<Vec<_>>();
assert_eq!(paths[0], root);
}
#[test]
fn debug_uses_the_effective_environment_field() {
for extra in [false, true] {
let root = std::env::temp_dir().join("project");
let vendor = root.join("vendor");
let vars = BTreeMap::from([("PYTHONPATH".into(), vendor.to_string_lossy().into_owned())]);
let mut config = LaunchConfig {
name: "Python".into(),
adapter: "debugpy".into(),
request: "launch".into(),
program: Some("app/main.py".into()),
args: vec![],
cwd: None,
env: vars.clone(),
extra_json: extra.then(|| serde_json::json!({"env": vars}).to_string()),
};
prepare_debug(&mut config, std::slice::from_ref(&root)).unwrap();
let vars: BTreeMap<String, String> = if extra {
let value: serde_json::Value = serde_json::from_str(config.extra_json.as_deref().unwrap()).unwrap();
serde_json::from_value(value["env"].clone()).unwrap()
} else {
config.env
};
assert_eq!(std::env::split_paths(&vars["PYTHONPATH"]).collect::<Vec<_>>(), [root, vendor]);
}
}
}
+83 -10
View File
@@ -9,16 +9,59 @@ pub fn configs(host: &Host, window: &str) -> IdeResult<Vec<RunConfig>> {
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 {
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> {
@@ -27,6 +70,9 @@ pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit
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,15 +81,42 @@ 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.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()))??;
}
let encoding = settings.console.encoding;
let options = StartOptions { encoding: Some(encoding).filter(|e| !e.trim().is_empty()) };
let options = StartOptions {
encoding: Some(encoding).filter(|e| !e.trim().is_empty()),
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)))
}
+94
View File
@@ -21,6 +21,18 @@ pub fn run_system_env(_host: &Host, _window: &str) -> std::collections::BTreeMap
ide_run::system_env()
}
/// Writes the Gradle init script that adds `jvm_args` to the JVMs the build forks
/// (`bootRun`, `run`, tests) and returns its path, for `--init-script`.
pub async fn run_gradle_jvm_args_script(_host: &Host, _window: &str, jvm_args: Vec<String>) -> IdeResult<String> {
tokio::task::spawn_blocking(move || {
ide_run::gradle_jvm::write_init_script(&ide_run::gradle_jvm::default_dir(), &jvm_args)
.map(|p| p.to_string_lossy().into_owned())
.map_err(|e| IdeError::internal(format!("cannot write the Gradle init script: {e}")))
})
.await
.map_err(|e| IdeError::internal(e.to_string()))?
}
/// Results from the JUnit XML reports (Surefire, Failsafe, Gradle) of the module at
/// `module` (relative to the project root) written since `since_ms` (Unix epoch, ms).
pub async fn run_test_reports(
@@ -37,11 +49,93 @@ 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> {
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>> {
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>> {
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 plan = ide_build::web::build(
&project.root,
&model,
id,
&ide_run::tomcat::base_dir(&project.cache_dir, &config.id),
)?;
let mut env = plan.env;
env.extend(config.env.clone());
Ok(Some(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,
args: plan.args,
cwd: Some(std::path::PathBuf::from(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_tomcat_update(host: &Host, window: &str, config: RunConfig, mode: String) -> IdeResult<()> {
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_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)) });
d.add("run_gradle_jvm_args_script", |c| async move {
run_gradle_jvm_args_script(&c.host, &c.window, c.arg("jvmArgs")?).await
});
d.add("run_test_reports", |c| async move {
run_test_reports(&c.host, &c.window, c.arg("module")?, c.arg("sinceMs")?).await
});
+14 -5
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,7 +69,7 @@ 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,
@@ -74,7 +77,13 @@ pub async fn start(
Some(&literals),
&|p| documents.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;
+114 -32
View File
@@ -45,13 +45,16 @@ pub enum RemoteOp {
branch: String,
},
/// Pushes local `branch` (only up to `revision` when set) to its upstream, or to
/// remote branch `target` of the same remote.
/// remote branch `target`; `remote` overrides the upstream's remote (Push dialog's
/// remote picker).
PushBranch {
branch: String,
revision: Option<String>,
force_with_lease: bool,
#[serde(default)]
target: Option<String>,
#[serde(default)]
remote: Option<String>,
},
/// Fast-forwards local `branch` (not checked out) to its upstream.
UpdateBranch {
@@ -187,18 +190,33 @@ pub async fn vcs_branch_info(host: &Host, window: &str, repo: Option<PathBuf>) -
pub fn vcs_line_hunks(host: &Host, window: &str, path: PathBuf) -> IdeResult<Vec<LineHunk>> {
let project = host.project(window)?;
let text = project.text_of(&path)?;
project.with_repo_of(&path, |r| r.line_hunks(&path, &text, false))
project.with_repo_of(&path, |r| r.line_hunks(&path, &text, false, project.vcs_encoding(&path, None)?))
}
pub async fn vcs_file_at(host: &Host, window: &str, path: PathBuf, rev: Option<String>) -> IdeResult<Option<String>> {
let project = host.project(window)?;
blocking(move || project.with_repo_of(&path, |r| r.file_at(&path, rev.as_deref()))).await
blocking(move || {
project.with_repo_of(&path, |r| r.file_at(&path, rev.as_deref(), project.vcs_encoding(&path, rev.as_deref())?))
})
.await
}
pub async fn vcs_blame(host: &Host, window: &str, path: PathBuf) -> IdeResult<Vec<BlameLine>> {
pub async fn vcs_blame(
host: &Host,
window: &str,
path: PathBuf,
text: Option<String>,
revision: Option<String>,
) -> IdeResult<Vec<BlameLine>> {
let project = host.project(window)?;
let text = project.documents.text_of(&path);
blocking(move || project.with_repo_of(&path, |r| r.blame(&path, text.as_deref()))).await
// An explicit text wins (merge view annotates its side panes, not the worktree).
let text = text.or_else(|| project.documents.text_of(&path));
blocking(move || {
project.with_repo_of(&path, |r| {
r.blame(&path, text.as_deref(), revision.as_deref(), project.vcs_encoding(&path, revision.as_deref())?)
})
})
.await
}
pub async fn vcs_log(host: &Host, window: &str, repo: Option<PathBuf>, query: LogQuery) -> IdeResult<Vec<Commit>> {
@@ -242,6 +260,34 @@ pub async fn vcs_stage(host: &Host, window: &str, paths: Vec<PathBuf>) -> IdeRes
blocking(move || per_repo(&project, &paths, None, |r, paths| r.stage(paths))).await
}
/// Project-host operation (also registered with the remote dispatcher).
/// Returns the ignore files that were updated, so the UI can show them.
pub async fn vcs_add_to_ignore(
host: &Host,
window: &str,
paths: Vec<PathBuf>,
destination: IgnoreDestination,
) -> IdeResult<Vec<String>> {
let project = host.project(window)?;
blocking(move || {
for path in &paths {
if path == &project.root || !path.starts_with(&project.root) {
return Err(ide_core::IdeError::invalid("Ignore selection is outside the project"));
}
}
apply_git_executable(&project.settings.effective());
let result = ide_vcs::add_to_ignore(&paths, destination);
// Exclude may live outside a worktree's watched directory. Refresh filters and UI explicitly.
project.watcher.reload_ignore_rules();
for (_, watcher) in project.content_watchers.lock().iter() {
watcher.reload_ignore_rules();
}
project.sink.emit(ide_core::IdeEvent::VcsChanged { root: project.root.clone().into() });
result.map(|files| files.into_iter().map(|p| p.to_string_lossy().into_owned()).collect())
})
.await
}
pub async fn vcs_unstage(host: &Host, window: &str, paths: Vec<PathBuf>) -> IdeResult<()> {
let project = host.project(window)?;
blocking(move || per_repo(&project, &paths, None, |r, paths| r.unstage(paths))).await
@@ -249,7 +295,7 @@ pub async fn vcs_unstage(host: &Host, window: &str, paths: Vec<PathBuf>) -> IdeR
pub async fn vcs_stage_patch(host: &Host, window: &str, patch: String) -> IdeResult<()> {
let project = host.project(window)?;
blocking(move || project.with_repo(|r| r.stage_patch(&patch))).await
blocking(move || project.with_repo(|r| r.stage_patch(&patch, &|path| project.vcs_encoding(path, Some(":0"))))).await
}
pub async fn vcs_discard(host: &Host, window: &str, paths: Vec<PathBuf>) -> IdeResult<()> {
@@ -266,7 +312,12 @@ pub async fn vcs_commit(
) -> IdeResult<String> {
// Commit hooks may run for a long time.
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.commit_with(&message, &options))).await
blocking(move || {
project.with_repo_at(repo.as_deref(), |r| {
r.commit_with(&message, &options, &|path| project.vcs_encoding(path, None))
})
})
.await
}
pub async fn vcs_checkout(
@@ -416,12 +467,17 @@ pub async fn vcs_remote(
r.push_with_tags(force_with_lease, set_upstream, tags, &cancel, &out)
}
RemoteOp::DeleteBranch { branch } => r.delete_remote_branch(&branch, &cancel, &out),
RemoteOp::PushBranch { branch, revision, force_with_lease, target } => match target {
Some(target) => {
r.push_branch_to(&branch, revision.as_deref(), &target, force_with_lease, &cancel, &out)
}
None => r.push_branch(&branch, revision.as_deref(), force_with_lease, &cancel, &out),
},
RemoteOp::PushBranch { branch, revision, force_with_lease, target, remote } => r.push_branch_to(
&branch,
PushBranchOptions {
revision: revision.as_deref(),
target: target.as_deref(),
remote: remote.as_deref(),
force_with_lease,
},
&cancel,
&out,
),
RemoteOp::UpdateBranch { branch } => r.update_branch(&branch, &cancel, &out),
RemoteOp::PullFrom { remote, branches, rebase, options } => {
r.pull_from(&remote, &branches, rebase, &options, &cancel, &out)
@@ -437,12 +493,14 @@ pub async fn vcs_remote(
/// The user's side is `ours` (sides swapped during a rebase, as in IDEA).
pub async fn vcs_conflict_versions(host: &Host, window: &str, path: PathBuf) -> IdeResult<ide_vcs::MergeVersions> {
let project = host.project(window)?;
blocking(move || project.with_repo_of(&path, |r| r.merge_versions(&path))).await
blocking(move || project.with_repo_of(&path, |r| r.merge_versions(&path, project.vcs_encoding(&path, Some(":2"))?)))
.await
}
pub async fn vcs_diff_text(host: &Host, window: &str, path: PathBuf, staged: bool) -> IdeResult<String> {
let project = host.project(window)?;
blocking(move || project.with_repo_of(&path, |r| r.diff_text(&path, staged))).await
blocking(move || project.with_repo_of(&path, |r| r.diff_text(&path, staged, project.vcs_encoding(&path, None)?)))
.await
}
/// Line hunks between two arbitrary texts (diff viewer, commit dialog).
@@ -454,13 +512,11 @@ pub fn vcs_diff_hunks(_host: &Host, _window: &str, old_text: String, new_text: S
/// Indices refer to `vcs_diff_hunks(index_text, worktree_text)`.
pub fn vcs_stage_hunks(host: &Host, window: &str, path: PathBuf, hunks: Vec<u32>) -> IdeResult<()> {
let project = host.project(window)?;
let current = ide_vfs::load_text(&path)?.text;
let encoding = project.vcs_encoding(&path, Some(":0"))?;
let current = ide_vfs::load_text_as(&path, encoding.name())?.text;
project.with_repo_of(&path, |r| {
let index_text = r.file_at(&path, Some(":0"))?.unwrap_or_default();
let rel = path.strip_prefix(r.root()).unwrap_or(&path).to_string_lossy().replace('\\', "/");
let selected: Vec<usize> = hunks.iter().map(|&h| h as usize).collect();
let patch = ide_vcs::build_partial_patch(&rel, &index_text, &current, &selected);
if patch.is_empty() { Ok(()) } else { r.stage_patch(&patch) }
r.stage_hunks(&path, &current, &selected, encoding)
})
}
@@ -680,13 +736,17 @@ pub async fn vcs_diff_revisions(
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.diff_revisions(&from, &to))).await
}
pub async fn vcs_create_patch(host: &Host, window: &str, source: PatchSource) -> IdeResult<String> {
pub async fn vcs_create_patch(
host: &Host,
window: &str,
repo: Option<PathBuf>,
source: PatchSource,
) -> IdeResult<String> {
let project = host.project(window)?;
blocking(move || {
project.with_repo(|r| match source {
project.with_repo_at(repo.as_deref(), |r| match source {
PatchSource::Changes { paths, reverse } => {
let paths: Vec<PathBuf> = paths.into_iter().map(Into::into).collect();
in_primary_repo(&project, &paths)?;
r.create_patch(&paths, reverse)
}
PatchSource::Commits { commits, reverse } => r.create_commits_patch(&commits, reverse),
@@ -695,14 +755,24 @@ pub async fn vcs_create_patch(host: &Host, window: &str, source: PatchSource) ->
.await
}
pub async fn vcs_patch_preview(host: &Host, window: &str, patch: String) -> IdeResult<Vec<PatchFile>> {
pub async fn vcs_patch_preview(
host: &Host,
window: &str,
repo: Option<PathBuf>,
patch: String,
) -> IdeResult<Vec<PatchFile>> {
let project = host.project(window)?;
blocking(move || project.with_repo(|r| r.patch_preview(&patch))).await
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.patch_preview(&patch))).await
}
pub async fn vcs_apply_patch(host: &Host, window: &str, patch: String) -> IdeResult<ApplyOutcome> {
pub async fn vcs_apply_patch(
host: &Host,
window: &str,
repo: Option<PathBuf>,
patch: String,
) -> IdeResult<ApplyOutcome> {
let project = host.project(window)?;
blocking(move || project.with_repo(|r| r.apply_patch(&patch))).await
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.apply_patch(&patch))).await
}
/// Shelve Changes: saves the changes of `paths` as a patch on the shelf and reverts them.
@@ -775,7 +845,9 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("vcs_branch_info", |c| async move { vcs_branch_info(&c.host, &c.window, c.arg("repo")?).await });
d.add("vcs_line_hunks", |c| async move { vcs_line_hunks(&c.host, &c.window, c.arg("path")?) });
d.add("vcs_file_at", |c| async move { vcs_file_at(&c.host, &c.window, c.arg("path")?, c.arg("rev")?).await });
d.add("vcs_blame", |c| async move { vcs_blame(&c.host, &c.window, c.arg("path")?).await });
d.add("vcs_blame", |c| async move {
vcs_blame(&c.host, &c.window, c.arg("path")?, c.arg("text")?, c.arg("revision")?).await
});
d.add("vcs_log", |c| async move { vcs_log(&c.host, &c.window, c.arg("repo")?, c.arg("query")?).await });
d.add("vcs_commit_files", |c| async move {
vcs_commit_files(&c.host, &c.window, c.arg("repo")?, c.arg("commit")?).await
@@ -786,6 +858,9 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("vcs_branches", |c| async move { vcs_branches(&c.host, &c.window, c.arg("repo")?).await });
d.add("vcs_stashes", |c| async move { vcs_stashes(&c.host, &c.window, c.arg("repo")?).await });
d.add("vcs_stage", |c| async move { vcs_stage(&c.host, &c.window, c.arg("paths")?).await });
d.add("vcs_add_to_ignore", |c| async move {
vcs_add_to_ignore(&c.host, &c.window, c.arg("paths")?, c.arg("destination")?).await
});
d.add("vcs_unstage", |c| async move { vcs_unstage(&c.host, &c.window, c.arg("paths")?).await });
d.add("vcs_stage_patch", |c| async move { vcs_stage_patch(&c.host, &c.window, c.arg("patch")?).await });
d.add("vcs_discard", |c| async move { vcs_discard(&c.host, &c.window, c.arg("paths")?).await });
@@ -901,9 +976,16 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("vcs_diff_revisions", |c| async move {
vcs_diff_revisions(&c.host, &c.window, c.arg("repo")?, c.arg("from")?, c.arg("to")?).await
});
d.add("vcs_create_patch", |c| async move { vcs_create_patch(&c.host, &c.window, c.arg("source")?).await });
d.add("vcs_patch_preview", |c| async move { vcs_patch_preview(&c.host, &c.window, c.arg("patch")?).await });
d.add("vcs_apply_patch", |c| async move { vcs_apply_patch(&c.host, &c.window, c.arg("patch")?).await });
d.add("vcs_create_patch", |c| async move {
vcs_create_patch(&c.host, &c.window, c.arg("repo")?, c.arg("source")?).await
});
d.add("vcs_patch_preview", |c| async move {
vcs_patch_preview(&c.host, &c.window, c.arg("repo")?, c.arg("patch")?).await
});
d.add(
"vcs_apply_patch",
|c| async move { vcs_apply_patch(&c.host, &c.window, c.arg("repo")?, c.arg("patch")?).await },
);
d.add("vcs_shelve", |c| async move { vcs_shelve(&c.host, &c.window, c.arg("name")?, c.arg("paths")?).await });
d.add("vcs_shelves", |c| async move { vcs_shelves(&c.host, &c.window) });
d.add("vcs_unshelve", |c| async move { vcs_unshelve(&c.host, &c.window, c.arg("id")?, c.arg("remove")?).await });
+244 -39
View File
@@ -2,7 +2,7 @@
//! watchers and the per-project services, opened by [`crate::Host::open_project`].
use crate::Host;
use ide_core::{CancellationToken, IdeError, IdeEvent, IdeResult, SharedSink};
use ide_core::{CancellationToken, IdeError, IdeEvent, IdeResult, LanguageId, SharedSink};
use ide_index::ProjectIndex;
use ide_language::{FileTypes, LanguageRegistry, SyntaxCache};
use ide_lsp::LspManager;
@@ -46,6 +46,8 @@ pub struct Project {
pub http_history: Arc<ide_http::History>,
/// Coding agent tasks (Agents tool window).
pub agents: Arc<ide_agent::AgentManager>,
/// Native notebooks and owned kernels, scoped to this project incarnation.
pub notebooks: Arc<crate::notebook::NotebookService>,
pub(crate) watcher: Watcher,
/// Watchers of the Project Structure content roots outside the project folder.
pub(crate) content_watchers: Mutex<Vec<(PathBuf, Watcher)>>,
@@ -65,6 +67,8 @@ impl Host {
lap = std::time::Instant::now();
};
let root = dunce::canonicalize(root)?;
// Reject a damaged manifest before replacing the currently open project.
ide_workspace::workspace::Workspace::load(&root)?;
// The window reloaded (or asked again) for the project it already has: keep
// it, with its warm index, language servers and unsaved documents.
if let Ok(current) = self.project(window)
@@ -79,20 +83,14 @@ impl Host {
let post_start = self.environment.project_opened(&root, &sink);
let settings = SettingsStore::load(&self.config_dir, Some(&root));
let detected_roots = Arc::new(RwLock::new(ide_workspace::roots::detect(&root)));
let excludes = exclude_globs(&settings.effective(), &detected_roots.read());
let excludes = exclude_globs(&root, &settings.effective(), &detected_roots.read());
let extra_roots = content_roots(&settings.effective(), &root);
mark("roots");
// The project's own registry: File Types associations may be project-scoped.
let languages = Arc::new(self.languages.fork());
languages.set_file_types(FileTypes::from_settings(&settings.effective().files, Some(&root)));
let documents = {
let languages = languages.clone();
Arc::new(Documents::new(
sink.clone(),
Box::new(move |p: &Path, first: Option<&str>| languages.detect(p, first)),
))
};
let documents = documents(&sink, &languages, Some(&root));
documents.set_file_defaults(ide_workspace::FileDefaults::from_settings(&settings.effective(), Some(&root)));
let history = Arc::new(LocalHistory::open(&self.data_dir.join("history"), &root)?);
{
@@ -108,7 +106,7 @@ impl Host {
let index = ProjectIndex::open(
&root,
&self.data_dir.join("index").join(&project_key),
extra_roots.clone(),
index_roots(&root, extra_roots.clone()),
excludes.clone(),
languages.clone(),
self.syntax(),
@@ -116,31 +114,40 @@ impl Host {
)?;
mark("index");
let lsp = LspManager::new(root.clone(), languages.clone(), sink.clone());
lsp.set_content_roots(extra_roots.clone());
let settings = Arc::new(settings);
{
let settings = settings.clone();
let root = root.clone();
let data_dir = self.data_dir.clone();
let config_dir = self.config_dir.clone();
lsp.set_config_hook(Box::new(move |cfg, server_root| {
let effective = settings.effective();
let member = ide_workspace::workspace::Workspace::load(&root).ok().flatten().and_then(|w| {
w.projects.into_iter().filter(|p| server_root.starts_with(p)).max_by_key(|p| p.components().count())
});
let effective = member
.as_ref()
.map(|p| SettingsStore::load(&config_dir, Some(p)).effective())
.unwrap_or_else(|| settings.effective());
let root = member.as_ref().unwrap_or(&root);
ide_sdk::apply::server_launch_overrides(cfg, &effective);
ide_sdk::apply::configure_server(cfg, &root, server_root, &effective);
ide_sdk::apply::configure_server(cfg, root, server_root, &effective);
ide_sdk::java::cap_java_home(cfg, &ide_sdk::java::installed_jdks());
if cfg.name == "jdtls" {
let bundles: Vec<PathBuf> =
ide_tools::managed_bundle(&data_dir, "java-debug").into_iter().collect();
let min_java = ide_sdk::java::jdtls_min_java(cfg);
let jdks = ide_sdk::java::jdtls_jdks(min_java);
let jdks = ide_sdk::java::jdtls_jdks(min_java, &data_dir);
let lombok = ide_sdk::lombok::lombok_agent(
server_root,
ide_sdk::lombok::agent_in_args(cfg.args.iter().map(String::as_str)),
ide_tools::managed_bundle(&data_dir, "lombok"),
ide_sdk::apply::maven_local_repository(&root, &effective),
ide_sdk::apply::maven_local_repository(root, &effective),
);
if let Some(problem) = lombok.problem() {
tracing::warn!("{problem}");
}
let server_jdk = ide_sdk::apply::jdtls_jdk(&root, &effective).unwrap_or_else(|e| {
let server_jdk = ide_sdk::apply::jdtls_jdk(root, &effective).unwrap_or_else(|e| {
cfg.launch_error = Some(e);
None
});
@@ -149,7 +156,7 @@ impl Host {
server_root,
&data_dir,
&jdks,
ide_sdk::apply::project_jdk(&root, &effective).as_ref(),
ide_sdk::apply::project_jdk(root, &effective).as_ref(),
server_jdk.as_ref(),
min_java,
&bundles,
@@ -158,6 +165,27 @@ impl Host {
ide_sdk::jdtls::launch_directly(cfg);
}
ide_sdk::apply::server_protocol_overrides(cfg, &effective);
let excluded_poms = unlinked_maven_poms(root, &effective);
ide_sdk::apply::configure_maven_import(cfg, root, &excluded_poms);
}));
}
{
let settings = settings.clone();
let root = root.clone();
let data_dir = self.data_dir.clone();
let sink = sink.clone();
lsp.set_prepare_hook(Box::new(move |cfg, _, cancel| {
let effective = settings.effective();
if cfg.name == "jdtls" {
crate::java_runtime::prepare(
cfg,
&data_dir,
!effective.languages.java.jdtls_jdk.trim().is_empty()
|| ide_sdk::apply::jdtls_jdk(&root, &effective).is_err(),
&sink,
cancel,
);
}
}));
}
{
@@ -188,6 +216,7 @@ impl Host {
let settings = settings.clone();
let index = index.clone();
let documents = documents.clone();
let languages = languages.clone();
let sink = sink.clone();
let root = root.clone();
let detected_roots = detected_roots.clone();
@@ -203,6 +232,12 @@ impl Host {
index.update(&changed, &batch.removed);
// Atomic saves by external tools surface as `created`.
documents.on_disk_changed(&changed);
// A framework's root marker (`angular.json`) created or deleted: its
// templates gain or lose their mode. Markers in `node_modules` only
// count here when the folder isn't excluded (it is by default).
if batch.created.iter().chain(&batch.removed).any(|p| languages.is_framework_marker(p)) {
documents.redetect_languages();
}
record_external_changes(&history, &changed, &batch.removed);
if !batch.removed.is_empty() {
delete_deployed(
@@ -221,7 +256,10 @@ impl Host {
if *detected_roots.read() != fresh {
// Detected excluded folders (`target`, `build`) leave the index too.
let effective = settings.effective();
index.set_scope(content_roots(&effective, &root), exclude_globs(&effective, &fresh));
index.set_scope(
index_roots(&root, content_roots(&effective, &root)),
exclude_globs(&root, &effective, &fresh),
);
*detected_roots.write() = fresh;
sink.emit(IdeEvent::ProjectRootsChanged { root: root.clone().into() });
}
@@ -229,7 +267,7 @@ impl Host {
// A repository was cloned / initialised / removed below the project: its
// `.git/HEAD` (the watcher reports nothing else of `.git`) appeared or went.
let known = repos.read().roots();
let git_dirs = changed.iter().chain(&batch.removed).any(|p| {
let git_dirs = changed.iter().chain(&batch.removed).chain(&batch.vcs).any(|p| {
let Some(dir) = p.ends_with(".git/HEAD").then(|| p.parent().and_then(Path::parent)).flatten()
else {
return false;
@@ -239,7 +277,10 @@ impl Host {
if git_dirs {
scan_repositories(&root, &repos, &settings, &sink);
}
sink.emit(IdeEvent::VcsChanged { root: root.clone().into() });
let vcs_moved = batch.vcs.iter().any(|p| vcs_path_changed(p));
if git_dirs || vcs_moved {
sink.emit(IdeEvent::VcsChanged { root: root.clone().into() });
}
}),
)?
};
@@ -282,6 +323,10 @@ impl Host {
tracing::info!("opened {} in window {window} in {} ms {laps:?}", root.display(), started.elapsed().as_millis());
let (db, http_history, agents) = self.project_services(&root, &project_key);
let notebooks = crate::notebook::NotebookService::new(
self.data_dir.join("notebook-runtime").join(uuid::Uuid::new_v4().to_string()),
sink.clone(),
);
ide_agent::registry::maintain(crate::ops::agent::registry_paths(self));
let project = Arc::new(Project {
root,
@@ -301,6 +346,7 @@ impl Host {
db,
http_history,
agents,
notebooks,
watcher,
content_watchers: Mutex::new(content_watchers),
_scratch_watcher: scratch_watcher,
@@ -375,13 +421,7 @@ impl Host {
let effective = settings.effective();
let languages = Arc::new(self.languages.fork());
languages.set_file_types(FileTypes::from_settings(&effective.files, None));
let documents = {
let languages = languages.clone();
Arc::new(Documents::new(
sink.clone(),
Box::new(move |p: &Path, first: Option<&str>| languages.detect(p, first)),
))
};
let documents = documents(&sink, &languages, None);
documents.set_file_defaults(ide_workspace::FileDefaults::from_settings(&effective, None));
let history = Arc::new(LocalHistory::open(&self.data_dir.join("history"), &dir)?);
let index = ProjectIndex::unindexed(&dir, languages.clone(), self.syntax(), sink.clone())?;
@@ -393,6 +433,10 @@ impl Host {
let watcher = light_watcher(&dir, &sink, &documents, &history)?;
let project_key = project_key(&dir);
let (db, http_history, agents) = self.project_services(&dir, &project_key);
let notebooks = crate::notebook::NotebookService::new(
self.data_dir.join("notebook-runtime").join(uuid::Uuid::new_v4().to_string()),
sink.clone(),
);
let project = Arc::new(Project {
sink,
languages,
@@ -410,6 +454,7 @@ impl Host {
db,
http_history,
agents,
notebooks,
watcher,
content_watchers: Mutex::new(Vec::new()),
_scratch_watcher: None,
@@ -440,6 +485,18 @@ impl Project {
}
}
/// The documents of a project whose languages and UI modes `languages` detects,
/// looking for frameworks' root markers up to `root` (anywhere for loose files).
fn documents(sink: &SharedSink, languages: &Arc<LanguageRegistry>, root: Option<&Path>) -> Arc<Documents> {
let (detect, modes, root) = (languages.clone(), languages.clone(), root.map(Path::to_path_buf));
Arc::new(
Documents::new(sink.clone(), Box::new(move |p: &Path, first: Option<&str>| detect.detect(p, first)))
.with_ui_mode_detection(Box::new(move |p: &Path, lang: &LanguageId| {
modes.framework_ui_mode(lang, p, root.as_deref())
})),
)
}
fn light_watcher(
dir: &Path,
sink: &SharedSink,
@@ -465,7 +522,9 @@ fn light_watcher(
fn scan_repositories(root: &Path, repos: &RwLock<Repos>, settings: &SettingsStore, sink: &SharedSink) {
let ignored = settings.effective().ignored_names();
let skip = |p: &Path| p.file_name().is_some_and(|n| ignored.is_ignored_name(&n.to_string_lossy()));
match Repos::discover(root, &skip) {
let mut roots = vec![root.to_path_buf()];
roots.extend(content_roots(&settings.effective(), root));
match Repos::discover_roots(&roots, &skip) {
Ok(found) => {
if found.roots() != repos.read().roots() {
*repos.write() = found;
@@ -477,6 +536,10 @@ fn scan_repositories(root: &Path, repos: &RwLock<Repos>, settings: &SettingsStor
}
impl Project {
pub fn refresh_repositories(&self) {
scan_repositories(&self.root, &self.repos, &self.settings, &self.sink);
}
/// The project's git repositories.
pub fn repos(&self) -> Repos {
self.repos.read().clone()
@@ -567,7 +630,7 @@ impl Project {
/// Effective source / test / resource / excluded roots (detected + Project Structure marks).
pub fn roots(&self) -> ProjectRoots {
ProjectRoots::compute(self.detected_roots.read().clone(), &self.settings.effective().project_structure)
linked_roots(&self.root, &self.settings.effective(), &self.detected_roots.read())
}
/// Where the Maven / Gradle tool windows cache what the build tools reported.
@@ -602,17 +665,23 @@ impl Project {
q: &ide_lsp::CandidateQuery,
cancel: &CancellationToken,
) -> ide_lsp::CandidateFiles {
if !q.root.starts_with(&self.root) {
let root = self
.content_roots()
.into_iter()
.chain(std::iter::once(self.root.clone()))
.filter(|root| q.root.starts_with(root))
.max_by_key(|root| root.components().count());
let Some(root) = root else {
return ide_lsp::CandidateFiles::default();
}
};
let query = ide_search::WordQuery {
word: q.word.clone(),
dir: q.root.clone(),
exclude: self.search_excludes(),
limit: q.limit,
};
let filter = |rel: &str| q.languages.contains(&self.languages.detect(&self.root.join(rel), None));
match ide_search::files_with_word(&self.root, &query, Some(&filter), &|p| self.documents.text_of(p), cancel) {
let filter = |rel: &str| q.languages.contains(&self.languages.detect(&root.join(rel), None));
match ide_search::files_with_word(&root, &query, Some(&filter), &|p| self.documents.text_of(p), cancel) {
Ok(found) => ide_lsp::CandidateFiles { files: found.files, limit_hit: found.limit_hit },
Err(e) => {
tracing::debug!("reference candidates: {}", e.message);
@@ -623,7 +692,7 @@ impl Project {
/// `files.exclude`, the ignored names and the effective excluded roots (user-marked and detected).
pub fn exclude_globs(&self) -> Vec<String> {
exclude_globs(&self.settings.effective(), &self.detected_roots.read())
exclude_globs(&self.root, &self.settings.effective(), &self.detected_roots.read())
}
/// Project Structure content roots besides the project folder (existing directories).
@@ -649,7 +718,8 @@ impl Project {
return;
}
let (roots, excludes) = (self.content_roots(), self.exclude_globs());
self.index.set_scope(roots.clone(), excludes.clone());
self.lsp.set_content_roots(roots.clone());
self.index.set_scope(index_roots(&self.root, roots.clone()), excludes.clone());
self.watcher.set_excludes(&excludes);
let mut watchers = self.content_watchers.lock();
watchers.retain(|(p, _)| roots.contains(p));
@@ -668,11 +738,29 @@ impl Project {
}
}
/// Use the open document's selected encoding, or the same detection/settings as opening it.
pub fn vcs_encoding(&self, path: &Path, rev: Option<&str>) -> IdeResult<&'static encoding_rs::Encoding> {
if let Some(id) = self.documents.by_path(path) {
let info = self.documents.snapshot(id)?;
return encoding_rs::Encoding::for_label(info.encoding.as_bytes())
.ok_or_else(|| ide_core::IdeError::invalid("unknown document encoding"));
}
let defaults = self.documents.file_defaults();
let fallback = defaults.fallback_for(path);
if path.exists() {
let loaded = ide_vfs::load_text_with_fallback(path, fallback)?;
return encoding_rs::Encoding::for_label(loaded.encoding.as_bytes())
.ok_or_else(|| ide_core::IdeError::invalid("unknown file encoding"));
}
let bytes = self.with_repo_of(path, |r| r.file_bytes_at(path, rev))?.unwrap_or_default();
Ok(ide_vfs::detect_encoding(&bytes, fallback))
}
/// Current text of `path`: the open buffer when available, else the file on disk.
pub fn text_of(&self, path: &Path) -> IdeResult<String> {
match self.documents.text_of(path) {
Some(text) => Ok(text),
None => Ok(ide_vfs::load_text(path)?.text),
None => Ok(ide_vfs::load_text_with_fallback(path, self.documents.file_defaults().fallback_for(path))?.text),
}
}
}
@@ -735,8 +823,16 @@ pub fn history_retention(settings: &ide_core::settings::Settings) -> ide_workspa
}
}
/// Project Structure content roots outside `root` (existing absolute directories).
/// A workspace uses its root only for state, never as indexed content.
fn index_roots(root: &Path, folders: Vec<PathBuf>) -> ide_index::IndexRoots {
ide_index::IndexRoots { include_root: !root.join(ide_workspace::workspace::FILE).is_file(), folders }
}
/// Workspace members in manifest order, or extra Project Structure directories.
fn content_roots(settings: &ide_core::settings::Settings, root: &Path) -> Vec<PathBuf> {
if let Ok(Some(workspace)) = ide_workspace::workspace::Workspace::load(root) {
return workspace.projects;
}
let mut out: Vec<PathBuf> = Vec::new();
for r in &settings.project_structure.content_roots {
let p = PathBuf::from(r.trim());
@@ -773,13 +869,27 @@ fn content_root_watcher(
.ok()
}
fn exclude_globs(settings: &ide_core::settings::Settings, detected: &[SourceFolder]) -> Vec<String> {
pub(crate) fn unlinked_maven_poms(root: &Path, settings: &ide_core::settings::Settings) -> Vec<PathBuf> {
ide_build::maven::UnlinkedProjects::load(root, &settings.languages.java.maven).poms().map(PathBuf::from).collect()
}
fn linked_roots(root: &Path, settings: &ide_core::settings::Settings, detected: &[SourceFolder]) -> ProjectRoots {
let unlinked = ide_build::maven::UnlinkedProjects::load(root, &settings.languages.java.maven);
let detected = detected
.iter()
.filter(|folder| !folder.detected_from.as_ref().is_some_and(|pom| unlinked.contains(pom)))
.cloned()
.collect();
ProjectRoots::compute(detected, &settings.project_structure)
}
fn exclude_globs(root: &Path, settings: &ide_core::settings::Settings, detected: &[SourceFolder]) -> Vec<String> {
let mut globs = settings.file_excludes();
globs.extend(ProjectRoots::compute(detected.to_vec(), &settings.project_structure).excluded_globs());
globs.extend(linked_roots(root, settings, detected).excluded_globs());
globs
}
fn project_key(root: &Path) -> String {
pub(crate) fn project_key(root: &Path) -> String {
// Stable FNV-1a directory name per project for caches.
let s = root.to_string_lossy();
let mut h: u64 = 0xcbf29ce484222325;
@@ -906,3 +1016,98 @@ pub fn apply_git_executable(settings: &ide_core::settings::Settings) {
let exe = settings.vcs.git.executable.trim();
ide_vcs::set_git_executable((!exe.is_empty()).then(|| Path::new(exe)));
}
#[cfg(test)]
mod maven_linkage_tests {
#[test]
fn unlinked_roots_remain_plain_files() {
let tmp = tempfile::tempdir().unwrap();
for name in ["a", "b"] {
std::fs::create_dir_all(tmp.path().join(name).join("src/main/java")).unwrap();
std::fs::write(tmp.path().join(name).join("pom.xml"), "<project/>").unwrap();
}
std::fs::create_dir_all(tmp.path().join("a/child/src/main/java")).unwrap();
std::fs::write(tmp.path().join("a/pom.xml"), "<project><modules><module>child</module></modules></project>")
.unwrap();
std::fs::write(tmp.path().join("a/child/pom.xml"), "<project/>").unwrap();
let detected = ide_workspace::roots::detect(tmp.path());
let mut settings = ide_core::settings::Settings::default();
settings.languages.java.maven.unlinked_projects.push("a/pom.xml".into());
let excluded_poms = super::unlinked_maven_poms(tmp.path(), &settings);
assert_eq!(excluded_poms, vec![std::path::PathBuf::from("a/child/pom.xml"), "a/pom.xml".into()]);
let configs = ide_run::detect_excluding(tmp.path(), &excluded_poms);
assert!(configs.iter().any(|c| c.kind == "maven"));
assert!(
configs
.iter()
.filter(|c| c.kind == "maven")
.all(|c| { c.cwd.as_ref().is_some_and(|dir| dir.as_ref() == std::path::Path::new("b")) })
);
let mut server = serde_json::from_value::<ide_language::LanguageServerConfig>(
serde_json::json!({"name": "jdtls", "command": "jdtls"}),
)
.unwrap();
ide_sdk::apply::configure_maven_import(&mut server, tmp.path(), &excluded_poms);
let options = serde_json::to_value(server.initialization_options).unwrap();
let exclusions = options.pointer("/settings/java/import/exclusions").unwrap().as_array().unwrap();
assert!(exclusions.contains(&serde_json::json!(tmp.path().join("a").to_string_lossy().replace('\\', "/"))));
assert!(
exclusions.contains(&serde_json::json!(tmp.path().join("a/child").to_string_lossy().replace('\\', "/")))
);
let roots = super::linked_roots(tmp.path(), &settings, &detected);
assert!(roots.roots.iter().any(|r| r.path == "b/src/main/java"));
assert!(!roots.roots.iter().any(|r| r.path.starts_with("a/")));
assert!(tmp.path().join("a/pom.xml").is_file());
let model = crate::ops::build::load(tmp.path(), &tmp.path().join("cache"), &settings);
assert_eq!(model.maven.unwrap().projects.len(), 1);
settings.languages.java.maven.unlinked_projects.push("b/pom.xml".into());
let model = crate::ops::build::load(tmp.path(), &tmp.path().join("cache"), &settings);
assert!(model.maven.unwrap().projects.is_empty());
}
}
fn vcs_path_changed(path: &Path) -> bool {
!path.ends_with(".git/index") || !ide_vcs::is_own_index_write(path)
}
#[cfg(test)]
mod index_event_tests {
use super::vcs_path_changed;
#[test]
fn status_write_is_suppressed_but_external_index_and_refs_refresh() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
let git = |args: &[&str]| {
let output = std::process::Command::new("git").current_dir(dir).args(args).output().unwrap();
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
};
git(&["init", "-q"]);
let file = dir.join("file.txt");
std::fs::write(&file, "original\n").unwrap();
git(&["add", "file.txt"]);
// Same contents, different stat data: status must refresh the index.
std::fs::File::options()
.write(true)
.open(&file)
.unwrap()
.set_modified(std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1))
.unwrap();
let index = dir.join(".git/index");
let before = std::fs::read(&index).unwrap();
let repo = ide_vcs::Repo::discover(dir).unwrap().unwrap();
repo.status().unwrap();
assert_ne!(before, std::fs::read(&index).unwrap(), "status must actually write the index");
assert!(!vcs_path_changed(&index));
assert!(!vcs_path_changed(&index), "duplicate notifications for the same identity");
for path in [".git/HEAD", ".git/refs/heads/main", ".git/packed-refs"] {
assert!(vcs_path_changed(&dir.join(path)));
}
std::fs::write(&file, "external staged change\n").unwrap();
git(&["add", "file.txt"]);
assert!(vcs_path_changed(&index));
assert!(vcs_path_changed(&index));
std::fs::remove_file(&index).unwrap();
assert!(vcs_path_changed(&index));
}
}
+50
View File
@@ -0,0 +1,50 @@
//! System Settings › HTTP Proxy on the host: the global settings made current for
//! connections and child processes, the password kept in the saved passwords
//! (owner-only `proxy-password.json`) instead of `settings.toml`.
use crate::Host;
use ide_core::IdeResult;
use ide_core::settings::Settings;
const PASSWORD_KEY: &str = "httpProxy";
impl Host {
/// `settings` with the saved proxy password filled in, as the Settings dialog edits it.
pub fn with_proxy_password(&self, mut settings: Settings) -> Settings {
// A password typed into settings.toml by hand counts too, unless one was saved.
if let Some(password) = self.proxy_password.get(PASSWORD_KEY) {
settings.proxy.password = password;
}
settings
}
/// Makes the global proxy settings the proxy of every later connection and child process.
pub fn apply_proxy(&self) {
let settings = self.with_proxy_password(self.global_settings.effective());
if let Some(problem) = settings.proxy.problem() {
tracing::warn!("HTTP proxy: {problem}");
}
ide_core::proxy_settings::set_proxy(settings.proxy);
}
/// Takes the proxy password out of a settings write of `key` and saves it.
/// Returns the value left to write, `None` when nothing is.
pub(crate) fn take_proxy_password(
&self,
key: &str,
mut value: serde_json::Value,
) -> IdeResult<Option<serde_json::Value>> {
let password = match key {
"proxy.password" => Some(value.take()),
"proxy" => value.as_object_mut().and_then(|o| o.remove("password")),
_ => return Ok(Some(value)),
};
if let Some(password) = password {
match password.as_str().filter(|p| !p.is_empty()) {
Some(p) => self.proxy_password.set(PASSWORD_KEY, p)?,
None => self.proxy_password.delete(PASSWORD_KEY),
}
}
Ok((key != "proxy.password").then_some(value))
}
}
+6 -1
View File
@@ -200,7 +200,12 @@ impl Dispatcher {
.add("term_available_shells", |_| async move { Ok(ide_terminal::available_shells()) })
.add("run_configs", |c| async move { ops::run::configs(&c.host, &c.window) })
.add("run_save_configs", |c| async move {
ops::run::save_configs(&c.host, &c.window, &c.arg::<Vec<ide_run::RunConfig>>("configs")?)
ops::run::save_configs(
&c.host,
&c.window,
&c.arg::<Vec<ide_run::RunConfig>>("configs")?,
&c.arg::<Option<Vec<String>>>("removedDetectedIds")?.unwrap_or_default(),
)
})
.add("run_system_env", |_| async move { Ok(ide_run::system_env()) })
.add("run_start", |c| async move {
+74
View File
@@ -0,0 +1,74 @@
//! System Settings › HTTP Proxy: applied on write, the password kept out of settings.toml.
use ide_core::NullSink;
use ide_core::proxy_settings::{ProxyMode, ProxyRoute, proxy};
use ide_host::{Host, HostConfig, ops};
use ide_workspace::SettingsScope;
use std::sync::Arc;
fn host(base: &std::path::Path) -> Arc<Host> {
Arc::new(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(|_| Arc::new(NullSink)),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
}))
}
fn set(host: &Host, key: &str, value: &str) -> ide_core::settings::Settings {
ops::project::set_settings(host, "main", SettingsScope::Global, key, value).unwrap().settings
}
fn settings_files(base: &std::path::Path) -> String {
let mut text = String::new();
for entry in walk(&base.join("config")) {
text.push_str(&std::fs::read_to_string(entry).unwrap_or_default());
}
text
}
fn walk(dir: &std::path::Path) -> Vec<std::path::PathBuf> {
let mut out = Vec::new();
for entry in std::fs::read_dir(dir).into_iter().flatten().flatten() {
let path = entry.path();
if path.is_dir() { out.extend(walk(&path)) } else { out.push(path) }
}
out
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn manual_proxy_applies_and_the_password_is_saved_apart() {
let tmp = tempfile::tempdir().unwrap();
let host = host(tmp.path());
assert_eq!(proxy().mode, ProxyMode::Auto);
set(&host, "proxy.mode", "\"manual\"");
set(&host, "proxy.host", "\"proxy.corp\"");
set(&host, "proxy.port", "3128");
set(&host, "proxy.authenticate", "true");
set(&host, "proxy.login", "\"me\"");
let settings = set(&host, "proxy.password", "\"s3cret\"");
assert_eq!(settings.proxy.password, "s3cret", "the dialog sees the saved password");
assert_eq!(
proxy().route_for_url("https://example.com/"),
ProxyRoute::Via("http://me:[email protected]:3128".into())
);
assert!(!settings_files(tmp.path()).contains("s3cret"), "no password in settings.toml");
let saved = std::fs::read_to_string(tmp.path().join("data/proxy-password.json")).unwrap();
assert!(saved.contains("s3cret"));
assert_eq!(ops::project::settings(&host, "main").proxy.password, "s3cret");
// A whole-table write keeps the password out of the file too; an empty one forgets it.
let settings = set(&host, "proxy", r#"{"mode":"none","host":"proxy.corp","password":""}"#);
assert_eq!(settings.proxy.password, "");
assert!(!settings_files(tmp.path()).contains("password"));
assert_eq!(proxy().route_for_url("https://example.com/"), ProxyRoute::Direct);
set(&host, "proxy", r#"{"mode":"auto"}"#);
assert_eq!(proxy().mode, ProxyMode::Auto);
}
+7 -1
View File
@@ -109,7 +109,13 @@ fn server_environment_from_login_shell_and_devcontainer() {
let started = project.join("started.txt");
wait_for("postStartCommand", || std::fs::read_to_string(&started).is_ok_and(|s| s.ends_with('\n')));
assert_eq!(std::fs::read_to_string(&started).unwrap(), "two-demo\n");
wait_for("the task to end", || events.0.lock().iter().any(|e| matches!(e, IdeEvent::Task { done: true, .. })));
wait_for("postStartCommand to end", || {
events
.0
.lock()
.iter()
.any(|e| matches!(e, IdeEvent::Task { title, done: true, .. } if title == "postStartCommand"))
});
assert!(events.0.lock().iter().any(
|e| matches!(e, IdeEvent::Task { message: Some(m), title, .. } if m == "post-start-output" && title == "postStartCommand")
));
+20
View File
@@ -108,6 +108,26 @@ async fn serves_projects_files_documents_search_and_runs() {
invoke(&peer, "vcs_init", json!({ "dir": project })).await.unwrap();
until("git repository", || project.join(".git").is_dir()).await;
// Real project-host ignore writes travel through the remote command dispatcher.
assert!(Dispatcher::standard().commands().contains(&"vcs_add_to_ignore"));
let ignore_root = dunce::canonicalize(&project).unwrap();
for (name, destination) in [("shared.tmp", "gitignore"), ("private.tmp", "localExclude")] {
let path = ignore_root.join(name);
std::fs::write(&path, "temporary").unwrap();
invoke(&peer, "vcs_add_to_ignore", json!({ "paths": [path], "destination": destination })).await.unwrap();
let entries = invoke(&peer, "fs_read_dir", json!({ "path": ignore_root })).await.unwrap();
let entry = entries.as_array().unwrap().iter().find(|e| e["name"] == name).unwrap();
assert_eq!(entry["ignored"], true);
assert_eq!(entry["excluded"], false);
}
assert!(std::fs::read_to_string(project.join(".gitignore")).unwrap().contains("/shared.tmp"));
assert!(std::fs::read_to_string(project.join(".git/info/exclude")).unwrap().contains("/private.tmp"));
let outside = tmp.path().join("outside.tmp");
std::fs::write(&outside, "keep").unwrap();
assert!(
invoke(&peer, "vcs_add_to_ignore", json!({ "paths": [outside], "destination": "gitignore" })).await.is_err()
);
// Unknown commands and bad arguments come back as IdeErrors.
let e = invoke(&peer, "window_minimize", json!({})).await.unwrap_err();
assert!(e.message.contains("not available remotely"), "{e:?}");
+79
View File
@@ -0,0 +1,79 @@
use ide_core::NullSink;
use ide_host::{Host, HostConfig, ops};
use std::sync::Arc;
fn host(base: &std::path::Path) -> Arc<Host> {
Arc::new(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(|_| Arc::new(NullSink)),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
}))
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn removed_detections_stay_removed_and_overrides_restore() {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
std::fs::write(root.join("package.json"), r#"{"scripts":{"dev":"node app.js","test":"node test.js"}}"#).unwrap();
let host = host(&root.join("app"));
host.open_project("main", &root).unwrap();
let detected = ops::run::configs(&host, "main").unwrap();
assert!(detected.len() >= 2);
assert!(detected.iter().all(|c| c.detected));
let id = detected[0].id.clone();
ops::run::save_configs(&host, "main", &[], std::slice::from_ref(&id)).unwrap();
let remaining = ops::run::configs(&host, "main").unwrap();
assert!(!remaining.iter().any(|c| c.id == id));
assert_eq!(remaining.len(), detected.len() - 1);
// Reopening a project reads suppression from disk.
host.open_project("again", &root).unwrap();
assert!(!ops::run::configs(&host, "again").unwrap().iter().any(|c| c.id == id));
let mut saved = detected[0].clone();
saved.detected = false;
ops::run::save_configs(&host, "main", &[saved], &[]).unwrap();
assert!(ide_run::load_suppressed_config_ids(&root).unwrap().is_empty());
assert!(!ops::run::configs(&host, "main").unwrap().iter().find(|c| c.id == id).unwrap().detected);
ops::run::save_configs(&host, "main", &[], &[]).unwrap();
assert!(ops::run::configs(&host, "main").unwrap().iter().find(|c| c.id == id).unwrap().detected);
// An override may coexist with suppression (e.g. a shared file added elsewhere).
ops::run::save_configs(&host, "main", &[], std::slice::from_ref(&id)).unwrap();
std::fs::write(
ide_run::user_configs_path(&root),
format!("[[configurations]]\nid = {id:?}\nname = \"Override\"\ncommand = \"node\"\n"),
)
.unwrap();
assert!(ops::run::configs(&host, "main").unwrap().iter().any(|c| c.id == id && !c.detected));
ops::run::save_configs(&host, "main", &[], &[]).unwrap();
assert!(!ops::run::configs(&host, "main").unwrap().iter().any(|c| c.id == id));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn gradle_defaults_are_not_saved_as_explicit_run_choices() {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
std::fs::write(root.join("build.gradle"), "plugins { id 'java' }").unwrap();
let host = host(&root.join("app"));
host.open_project("main", &root).unwrap();
ops::project::set_settings(
&host,
"main",
ide_workspace::settings::SettingsScope::Project,
"languages.java.gradle.jvm",
"\"/configured-jdk\"",
)
.unwrap();
let configs = ops::run::configs(&host, "main").unwrap();
let mut gradle = configs.into_iter().find(|c| c.kind == "gradle").unwrap();
assert!(!gradle.env.contains_key("JAVA_HOME"));
gradle.detected = false;
gradle.env.insert("JAVA_HOME".into(), "/explicit-jdk".into());
ops::run::save_configs(&host, "main", &[gradle.clone()], &[]).unwrap();
let saved = ops::run::configs(&host, "main").unwrap().into_iter().find(|c| c.id == gradle.id).unwrap();
assert_eq!(saved.env["JAVA_HOME"], "/explicit-jdk");
assert_eq!(saved.command, gradle.command);
}
+111
View File
@@ -0,0 +1,111 @@
use ide_core::{ChangeSet, NullSink, TextChange};
use ide_host::{Host, HostConfig, ops::vcs};
use std::{path::Path, process::Command, sync::Arc, time::Duration};
fn git(root: &Path, args: &[&str]) {
let out = Command::new("git").current_dir(root).args(args).output().unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn gbk_document_gutter_diff_stage_and_rollback_share_encoding() {
let temp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(temp.path()).unwrap();
let root = base.join("project");
std::fs::create_dir(&root).unwrap();
git(&root, &["init", "-b", "main"]);
git(&root, &["config", "user.name", "Test"]);
git(&root, &["config", "user.email", "[email protected]"]);
git(&root, &["config", "commit.gpgsign", "false"]);
git(&root, &["config", "core.autocrlf", "false"]);
let path = root.join("Example.java");
let old = "// 中文注释\nclass Example {\n // 保持不变\n int value = 1;\n}\n";
let original = ide_vfs::encode(old, "gbk", false).unwrap();
std::fs::write(&path, &original).unwrap();
std::fs::write(root.join("Utf8.java"), old).unwrap();
git(&root, &["add", "."]);
git(&root, &["commit", "-m", "GBK Java"]);
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(|_| Arc::new(NullSink)),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
let project = host.open_project("main", &root).unwrap();
for _ in 0..500 {
if !project.repos().is_empty() {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
let mut defaults = project.documents.file_defaults();
defaults.project_fallback = "gbk".into();
project.documents.set_file_defaults(defaults);
assert_eq!(project.text_of(&path).unwrap(), old);
assert_eq!(vcs::vcs_file_at(&host, "main", path.clone(), None).await.unwrap().as_deref(), Some(old));
let doc = project.documents.open(&path).unwrap();
assert_eq!(doc.encoding, "gbk");
// A live document's explicit selection wins over later project defaults.
let mut defaults = project.documents.file_defaults();
defaults.project_fallback = "windows-1252".into();
project.documents.set_file_defaults(defaults);
assert!(vcs::vcs_line_hunks(&host, "main", path.clone()).unwrap().is_empty());
let from = old.find("value = 1").unwrap() + "value = ".len();
let offset = old[..from].encode_utf16().count() as u32;
project
.documents
.edit(
doc.id,
doc.version,
ChangeSet { changes: vec![TextChange { from: offset, to: offset + 1, insert: "2".into() }] },
)
.unwrap();
let hunks = vcs::vcs_line_hunks(&host, "main", path.clone()).unwrap();
assert_eq!(hunks.len(), 1);
assert_eq!((hunks[0].start, hunks[0].lines), (3, 1));
assert_eq!(hunks[0].old_text, " int value = 1;\n");
project.documents.save(doc.id).unwrap();
vcs::vcs_stage_hunks(&host, "main", path.clone(), vec![0]).unwrap();
let repo = ide_vcs::Repo::discover(&root).unwrap().unwrap();
assert_eq!(
repo.file_bytes_at(&path, Some(":0")).unwrap().unwrap(),
ide_vfs::encode(&old.replace("value = 1", "value = 2"), "gbk", false).unwrap()
);
repo.unstage(std::slice::from_ref(&path)).unwrap();
let snapshot = project.documents.snapshot(doc.id).unwrap();
let line_start =
snapshot.text.split_inclusive('\n').take(3).map(|s| s.encode_utf16().count()).sum::<usize>() as u32;
project
.documents
.edit(
doc.id,
snapshot.version,
ChangeSet {
changes: vec![TextChange {
from: line_start,
to: line_start + " int value = 2;\n".encode_utf16().count() as u32,
insert: hunks[0].old_text.clone(),
}],
},
)
.unwrap();
project.documents.save(doc.id).unwrap();
assert_eq!(std::fs::read(&path).unwrap(), original);
assert!(vcs::vcs_line_hunks(&host, "main", path.clone()).unwrap().is_empty());
assert!(vcs::vcs_blame(&host, "main", path.clone(), None, None).await.unwrap().iter().all(|l| l.author == "Test"));
// Missing working files still use configured encoding for historical content.
project.documents.close(doc.id);
let mut defaults = project.documents.file_defaults();
defaults.project_fallback = "gbk".into();
project.documents.set_file_defaults(defaults);
std::fs::remove_file(&path).unwrap();
assert_eq!(vcs::vcs_file_at(&host, "main", path, None).await.unwrap().as_deref(), Some(old));
let utf8 = root.join("Utf8.java");
std::fs::remove_file(&utf8).unwrap();
assert_eq!(vcs::vcs_file_at(&host, "main", utf8, None).await.unwrap().as_deref(), Some(old));
host.close_project("main");
}
+65
View File
@@ -0,0 +1,65 @@
//! Repository-scoped patch operations must never fall back to the primary root.
use ide_core::NullSink;
use ide_host::{Host, HostConfig, ops::vcs};
use std::{path::Path, process::Command, sync::Arc, time::Duration};
fn git(root: &Path, args: &[&str]) -> String {
let output = Command::new("git").current_dir(root).args(args).output().unwrap();
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
String::from_utf8(output.stdout).unwrap().trim().to_owned()
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn patches_read_and_write_the_requested_repository() {
let temp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(temp.path()).unwrap();
let project_root = base.join("project");
let roots = [project_root.join("front"), project_root.join("back")];
let mut revisions = Vec::new();
for (i, root) in roots.iter().enumerate() {
std::fs::create_dir_all(root).unwrap();
git(root, &["init", "-b", "main"]);
git(root, &["config", "user.name", "Test"]);
git(root, &["config", "user.email", "[email protected]"]);
std::fs::write(root.join("same.txt"), format!("root {i}\n")).unwrap();
git(root, &["add", "."]);
git(root, &["commit", "-m", "initial"]);
std::fs::write(root.join("same.txt"), format!("changed {i}\n")).unwrap();
git(root, &["commit", "-am", "change"]);
revisions.push(git(root, &["rev-parse", "HEAD"]));
}
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(|_| Arc::new(NullSink)),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
let project = host.open_project("main", &project_root).unwrap();
for _ in 0..500 {
if project.repos().all().len() == 2 {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
assert_eq!(project.repos().all().len(), 2);
for (i, root) in roots.iter().enumerate() {
let patch = vcs::vcs_create_patch(
&host,
"main",
Some(root.clone()),
vcs::PatchSource::Commits { commits: vec![revisions[i].clone()], reverse: true },
)
.await
.unwrap();
assert!(patch.contains(&format!("-changed {i}")));
assert_eq!(vcs::vcs_patch_preview(&host, "main", Some(root.clone()), patch.clone()).await.unwrap().len(), 1);
vcs::vcs_apply_patch(&host, "main", Some(root.clone()), patch).await.unwrap();
assert_eq!(std::fs::read_to_string(root.join("same.txt")).unwrap(), format!("root {i}\n"));
}
assert!(vcs::vcs_apply_patch(&host, "main", Some(base.join("outside")), String::new()).await.is_err());
host.close_project("main");
}
+114
View File
@@ -0,0 +1,114 @@
use ide_core::{ChangeSet, NullSink, RequestId, TextChange};
use ide_host::{Host, HostConfig, ops};
use ide_workspace::workspace::Workspace;
use std::{
sync::{Arc, Mutex},
time::Duration,
};
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn linked_projects_share_navigation_but_keep_files_repositories_and_run_directories() {
let temp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(temp.path()).unwrap();
let roots = vec![base.join("frontend"), base.join("backend")];
for (index, root) in roots.iter().enumerate() {
std::fs::create_dir(root).unwrap();
assert!(std::process::Command::new("git").args(["init", "-q"]).current_dir(root).status().unwrap().success());
std::fs::write(root.join("same.txt"), format!("shared needle {index}\n")).unwrap();
std::fs::write(root.join("package.json"), r#"{"scripts":{"test":"echo test"}}"#).unwrap();
}
let root = base.join("workspace");
Workspace::save(&root, "Full stack", vec![roots[0].clone()], true).unwrap();
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(|_| Arc::new(NullSink)),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
std::fs::write(root.join("internal-needle.txt"), "needle").unwrap();
let project = host.open_project("main", &root).unwrap();
assert_eq!(ops::project::info(&project).name, "Full stack");
assert_eq!(ops::project::attach_workspace(&host, "main", &roots[1]).unwrap().as_ref(), root.as_path());
ops::project::attach_workspace(&host, "main", &roots[1]).unwrap();
assert_eq!(project.content_roots(), roots);
assert!(project.index.wait_idle(Duration::from_secs(10)));
assert!(project.index.find_files("internal-needle", 100).is_empty());
assert!(project.index.find_files("workspace.json", 100).is_empty());
assert_eq!(project.index.find_files("same.txt", 100).len(), 2);
for _ in 0..500 {
if project.repos().all().len() == 2 {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
assert_eq!(project.repos().all().len(), 2);
let configs = ops::run::configs(&host, "main").unwrap();
for member in &roots {
assert!(configs.iter().any(|c| c.cwd.as_ref().is_some_and(|cwd| &**cwd == member)));
}
let mut ids = configs.iter().map(|c| c.id.clone()).collect::<Vec<_>>();
ids.sort();
ids.dedup();
assert_eq!(ids.len(), configs.len());
let a = ops::doc::open(&host, "main", roots[0].join("same.txt")).await.unwrap();
let b = ops::doc::open(&host, "main", roots[1].join("same.txt")).await.unwrap();
assert_ne!(a.id, b.id);
ops::doc::edit(
&host,
"main",
a.id,
a.version,
ChangeSet { changes: vec![TextChange { from: 0, to: 0, insert: "unsaved ".into() }] },
)
.unwrap();
let hits = Arc::new(Mutex::new(Vec::new()));
let out = hits.clone();
let query = serde_json::from_value(serde_json::json!({"pattern":"needle", "isRegex":false, "caseSensitive":false,"wholeWord":false,"include":[],"exclude":[],"scope":{"kind":"project"},"maxMatches":100})).unwrap();
ops::search::start(
&host,
"main",
RequestId::next(),
query,
Arc::new(move |event| {
if let ops::search::SearchEvent::File { file } = event {
out.lock().unwrap().push(file);
}
}),
)
.await
.unwrap();
assert_eq!(hits.lock().unwrap().len(), 2);
let found = hits.lock().unwrap().iter().map(|f| f.rel_path.clone()).collect::<Vec<_>>();
assert!(found.contains(&"frontend/same.txt".to_owned()));
assert!(found.contains(&"backend/same.txt".to_owned()));
let updated = ops::project::save_workspace(&host, "main", "Frontend", vec![roots[0].clone()]).unwrap();
assert_eq!(updated.workspace.unwrap().projects, vec![roots[0].clone()]);
assert_eq!(project.content_roots(), vec![roots[0].clone()]);
assert!(project.documents.text_of(&roots[0].join("same.txt")).unwrap().starts_with("unsaved"));
assert!(roots[1].join("same.txt").exists());
assert!(!roots[1].join(".openidea").exists());
let invalid = base.join("invalid");
std::fs::create_dir_all(invalid.join(".openidea")).unwrap();
std::fs::write(invalid.join(ide_workspace::workspace::FILE), "broken").unwrap();
assert!(host.open_project("main", &invalid).is_err());
assert!(Arc::ptr_eq(&host.project("main").unwrap(), &project));
host.open_project("main", &roots[0]).unwrap();
let automatic = ops::project::attach_workspace(&host, "main", &roots[1]).unwrap();
assert_eq!(host.project("main").unwrap().root, roots[0]);
let automatic = host.open_project("main", &automatic).unwrap();
assert_eq!(automatic.content_roots(), roots);
assert_eq!(ops::project::info(&automatic).name, "frontend");
assert!(automatic.root.starts_with(base.join("data/workspaces")));
assert!(automatic.index.wait_idle(Duration::from_secs(10)));
assert!(automatic.index.find_files("workspace.json", 100).is_empty());
let updated = ops::project::save_workspace(&host, "main", "frontend", vec![roots[1].clone()]).unwrap();
assert_eq!(updated.workspace.unwrap().projects, vec![roots[1].clone()]);
assert_eq!(automatic.root, updated.root.as_ref());
assert!(automatic.index.wait_idle(Duration::from_secs(10)));
assert_eq!(automatic.index.find_files("same.txt", 100).len(), 1);
host.close_project("main");
}
+187
View File
@@ -0,0 +1,187 @@
//! The one place the backend builds an HTTP client: TLS with the platform's
//! certificates, timeouts, and the proxy of System Settings › HTTP Proxy.
use ide_core::proxy_settings::{ProxyMode, ProxyRoute, ProxySettings};
use ide_core::{CancellationToken, IdeError, IdeResult};
use parking_lot::Mutex;
use std::sync::Arc;
use std::time::Duration;
/// `builder` routed as `proxy` says: direct, the environment's proxy (reqwest's
/// default), or the manual proxy except for the "No proxy for" hosts.
pub fn with_proxy(builder: reqwest::ClientBuilder, proxy: &ProxySettings) -> reqwest::ClientBuilder {
match proxy.mode {
ProxyMode::Auto => builder,
ProxyMode::None => builder.no_proxy(),
ProxyMode::Manual => {
let proxy = proxy.clone();
// A custom proxy also turns off the environment's.
builder.proxy(reqwest::Proxy::custom(move |url| match proxy.route(url.host_str()) {
ProxyRoute::Via(via) => Some(via),
ProxyRoute::Direct | ProxyRoute::System => None,
}))
}
}
}
/// A client for `proxy`.
pub fn build(proxy: &ProxySettings) -> IdeResult<reqwest::Client> {
use rustls_platform_verifier::BuilderVerifierExt;
let fail = |e: String| IdeError::internal(format!("HTTP client: {e}"));
let provider = Arc::new(rustls::crypto::ring::default_provider());
let tls = rustls::ClientConfig::builder_with_provider(provider)
.with_safe_default_protocol_versions()
.map_err(|e| fail(e.to_string()))?
.with_platform_verifier()
.map_err(|e| fail(e.to_string()))?
.with_no_client_auth();
let builder = reqwest::Client::builder()
.tls_backend_preconfigured(tls)
.connect_timeout(Duration::from_secs(30))
.user_agent(concat!("OpenIDEA-HttpClient/", env!("CARGO_PKG_VERSION")));
with_proxy(builder, proxy).build().map_err(|e| fail(e.to_string()))
}
/// The client for the current proxy settings, rebuilt when they change.
pub fn shared() -> IdeResult<reqwest::Client> {
static CLIENT: Mutex<Option<(Arc<ProxySettings>, reqwest::Client)>> = Mutex::new(None);
let proxy = ide_core::proxy_settings::proxy();
let mut cached = CLIENT.lock();
if let Some((settings, client)) = cached.as_ref()
&& **settings == *proxy
{
return Ok(client.clone());
}
let client = build(&proxy)?;
*cached = Some((proxy, client.clone()));
Ok(client)
}
/// HTTP Proxy › Check connection: GETs `url` through `proxy` (the settings as
/// edited, not yet applied). Any response but `407` counts as connected.
pub async fn check_connection(proxy: &ProxySettings, url: &str, cancel: &CancellationToken) -> IdeResult<String> {
if let Some(problem) = proxy.problem() {
return Err(IdeError::invalid(problem));
}
let url = url.trim();
let url = if url.contains("://") { url.to_owned() } else { format!("http://{url}") };
let client = build(proxy)?;
let request = client.get(&url).timeout(Duration::from_secs(30));
let response = tokio::select! {
r = request.send() => r.map_err(|e| IdeError::new(ide_core::error::ErrorKind::Io, describe(&e)))?,
() = cancel.cancelled() => return Err(IdeError::cancelled()),
};
let status = response.status();
if status == reqwest::StatusCode::PROXY_AUTHENTICATION_REQUIRED {
return Err(IdeError::new(ide_core::error::ErrorKind::PermissionDenied, "Proxy authentication required (407)"));
}
Ok(format!("{} {}", status.as_u16(), status.canonical_reason().unwrap_or("")).trim_end().to_owned())
}
/// reqwest errors with their causes ("error sending request: … connection refused").
pub(crate) fn describe(e: &reqwest::Error) -> String {
use std::error::Error;
let mut msg = e.to_string();
let mut source = e.source();
while let Some(s) = source {
let s_msg = s.to_string();
if !msg.contains(&s_msg) {
msg.push_str(": ");
msg.push_str(&s_msg);
}
source = s.source();
}
msg
}
#[cfg(test)]
mod tests {
use super::*;
use ide_core::proxy_settings::ProxyKind;
use std::io::{BufRead, BufReader, Write};
use std::net::TcpListener;
/// A one-shot HTTP server on loopback: answers `200` and hands back the request head.
fn server() -> (u16, std::thread::JoinHandle<String>) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let handle = std::thread::spawn(move || {
let (stream, _) = listener.accept().unwrap();
let mut reader = BufReader::new(stream.try_clone().unwrap());
let mut head = String::new();
loop {
let mut line = String::new();
if reader.read_line(&mut line).unwrap() == 0 || line == "\r\n" {
break;
}
head.push_str(&line);
}
let mut stream = stream;
stream.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok").unwrap();
head
});
(port, handle)
}
fn manual(port: u16) -> ProxySettings {
ProxySettings { mode: ProxyMode::Manual, host: "127.0.0.1".into(), port, ..Default::default() }
}
async fn get(proxy: &ProxySettings, url: &str) -> IdeResult<String> {
check_connection(proxy, url, &CancellationToken::new()).await
}
#[tokio::test]
async fn builds_for_every_mode() {
for mode in [ProxyMode::None, ProxyMode::Auto, ProxyMode::Manual] {
for kind in [ProxyKind::Http, ProxyKind::Socks] {
let p = ProxySettings { mode, kind, host: "proxy".into(), port: 3128, ..Default::default() };
assert!(build(&p).is_ok(), "{mode:?} {kind:?}");
}
}
}
#[tokio::test]
async fn manual_proxy_receives_the_request_with_credentials() {
let (port, proxy_server) = server();
let proxy = ProxySettings { authenticate: true, login: "me".into(), password: "s3cret".into(), ..manual(port) };
assert_eq!(get(&proxy, "http://example.invalid/ping").await.unwrap(), "200 OK");
let head = proxy_server.join().unwrap();
assert!(head.starts_with("GET http://example.invalid/ping HTTP/1.1"), "{head}");
// base64("me:s3cret")
assert!(head.to_ascii_lowercase().contains("proxy-authorization: basic bwu6cznjcmv0"), "{head}");
}
#[tokio::test]
async fn manual_proxy_without_authentication_sends_no_credentials() {
let (port, proxy_server) = server();
let proxy = ProxySettings { login: "me".into(), password: "x".into(), ..manual(port) };
get(&proxy, "http://example.invalid/").await.unwrap();
assert!(!proxy_server.join().unwrap().to_ascii_lowercase().contains("proxy-authorization"));
}
#[tokio::test]
async fn no_proxy_hosts_and_no_proxy_mode_connect_directly() {
// Nothing listens on the proxy port: a request through it would fail.
let dead = TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
let (port, target) = server();
let listed = ProxySettings { no_proxy: "example.com, 127.0.*".into(), ..manual(dead) };
assert!(listed.bypasses("127.0.0.1"));
get(&listed, &format!("http://127.0.0.1:{port}/direct")).await.unwrap();
assert!(target.join().unwrap().starts_with("GET /direct HTTP/1.1"));
let (port, target) = server();
let none = ProxySettings { mode: ProxyMode::None, ..manual(dead) };
get(&none, &format!("http://127.0.0.1:{port}/none")).await.unwrap();
assert!(target.join().unwrap().starts_with("GET /none HTTP/1.1"));
}
#[tokio::test]
async fn check_reports_errors() {
let dead = TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
let e = get(&manual(dead), "http://example.invalid/").await.unwrap_err();
assert!(!e.message.is_empty());
let e = get(&manual(0), "http://example.invalid/").await.unwrap_err();
assert!(e.message.contains("port"), "{}", e.message);
}
}
+2 -41
View File
@@ -1,5 +1,6 @@
//! Preparing (substitution, body includes) and executing requests.
use crate::client::describe;
use crate::env::{Environments, Resolver};
use crate::parse::{BodyPart, RawRequest};
use ide_core::{CancellationToken, IdeError, IdeResult};
@@ -7,7 +8,6 @@ use serde::{Deserialize, Serialize};
use specta::Type;
use std::collections::BTreeSet;
use std::path::Path;
use std::sync::{Arc, OnceLock};
use std::time::Instant;
/// Bodies above this are cut (the full size is still reported).
@@ -98,29 +98,6 @@ pub fn prepare(
})
}
fn client() -> IdeResult<reqwest::Client> {
static CLIENT: OnceLock<Result<reqwest::Client, String>> = OnceLock::new();
CLIENT
.get_or_init(|| {
use rustls_platform_verifier::BuilderVerifierExt;
let provider = Arc::new(rustls::crypto::ring::default_provider());
let tls = rustls::ClientConfig::builder_with_provider(provider)
.with_safe_default_protocol_versions()
.map_err(|e| e.to_string())?
.with_platform_verifier()
.map_err(|e| e.to_string())?
.with_no_client_auth();
reqwest::Client::builder()
.tls_backend_preconfigured(tls)
.connect_timeout(std::time::Duration::from_secs(30))
.user_agent(concat!("OpenIDEA-HttpClient/", env!("CARGO_PKG_VERSION")))
.build()
.map_err(|e| e.to_string())
})
.clone()
.map_err(|e| IdeError::internal(format!("HTTP client: {e}")))
}
fn looks_textual(content_type: Option<&str>, bytes: &[u8]) -> bool {
if let Some(ct) = content_type {
let ct = ct.to_ascii_lowercase();
@@ -148,7 +125,7 @@ fn looks_textual(content_type: Option<&str>, bytes: &[u8]) -> bool {
/// Sends `req`; cancelled when `cancel` fires.
pub async fn execute(req: &PreparedRequest, cancel: &CancellationToken) -> IdeResult<HttpResponse> {
let client = client()?;
let client = crate::client::shared()?;
let method = reqwest::Method::from_bytes(req.method.as_bytes())
.map_err(|_| IdeError::invalid(format!("invalid method {}", req.method)))?;
let mut builder = client.request(method, &req.url);
@@ -205,22 +182,6 @@ pub async fn execute(req: &PreparedRequest, cancel: &CancellationToken) -> IdeRe
})
}
/// reqwest errors with their causes ("error sending request: … connection refused").
fn describe(e: &reqwest::Error) -> String {
use std::error::Error;
let mut msg = e.to_string();
let mut source = e.source();
while let Some(s) = source {
let s_msg = s.to_string();
if !msg.contains(&s_msg) {
msg.push_str(": ");
msg.push_str(&s_msg);
}
source = s.source();
}
msg
}
#[cfg(test)]
mod tests {
use super::*;
+3 -1
View File
@@ -2,8 +2,10 @@
//!
//! [`parse`] splits a file into requests, [`env`] resolves `{{variables}}` from
//! `http-client(.private).env.json`, [`exec`] sends a request (cancellable,
//! timed) and [`history`] keeps the responses.
//! timed) and [`history`] keeps the responses. [`client`] builds every HTTP client
//! of the backend, with the proxy of System Settings › HTTP Proxy.
pub mod client;
pub mod env;
pub mod exec;
pub mod history;
+21 -14
View File
@@ -15,6 +15,7 @@ const ALWAYS_SKIP: &[&str] = &[".git", ".hg", ".svn", ".jj"];
pub(crate) struct PathFilter {
root: PathBuf,
include_root: bool,
/// Content roots outside `root`, indexed as `<folder name>/<path>`.
extra: Vec<ExtraRoot>,
excludes: GlobSet,
@@ -22,7 +23,7 @@ pub(crate) struct PathFilter {
/// applies nested ignore files itself).
gitignore: Gitignore,
/// What the filter was built from: `(extra roots, exclude globs)`.
config: (Vec<PathBuf>, Vec<String>),
config: (crate::IndexRoots, Vec<String>),
}
struct ExtraRoot {
@@ -68,7 +69,7 @@ pub(crate) fn mtime_of(meta: &std::fs::Metadata) -> u64 {
}
impl PathFilter {
pub(crate) fn new(root: &Path, extra_roots: &[PathBuf], exclude_globs: &[String]) -> Self {
pub(crate) fn new(root: &Path, extra_roots: &crate::IndexRoots, exclude_globs: &[String]) -> Self {
let mut set = GlobSetBuilder::new();
for raw in exclude_globs {
let p = raw.trim().trim_end_matches('/');
@@ -92,10 +93,9 @@ impl PathFilter {
}
let excludes = set.build().unwrap_or_else(|_| GlobSet::empty());
let mut extra: Vec<ExtraRoot> = Vec::new();
for r in extra_roots {
for r in &extra_roots.folders {
let path = dunce::canonicalize(r).unwrap_or_else(|_| r.clone());
let nested = path.starts_with(root)
|| root.starts_with(&path)
let nested = (extra_roots.include_root && (path.starts_with(root) || root.starts_with(&path)))
|| extra.iter().any(|e| path.starts_with(&e.path) || e.path.starts_with(&path));
if nested || !path.is_dir() {
continue;
@@ -105,16 +105,17 @@ impl PathFilter {
}
Self {
root: root.to_owned(),
include_root: extra_roots.include_root,
extra,
excludes,
gitignore: root_ignores(root),
config: (extra_roots.to_vec(), exclude_globs.to_vec()),
config: (extra_roots.clone(), exclude_globs.to_vec()),
}
}
/// Whether the filter was built from these roots and globs.
pub(crate) fn same_config(&self, extra_roots: &[PathBuf], excludes: &[String]) -> bool {
self.config.0 == extra_roots && self.config.1 == excludes
pub(crate) fn same_config(&self, extra_roots: &crate::IndexRoots, excludes: &[String]) -> bool {
&self.config.0 == extra_roots && self.config.1 == excludes
}
pub(crate) fn root(&self) -> &Path {
@@ -123,12 +124,14 @@ impl PathFilter {
/// The project root, then the extra content roots.
pub(crate) fn roots(&self) -> impl Iterator<Item = &Path> {
std::iter::once(self.root.as_path()).chain(self.extra.iter().map(|e| e.path.as_path()))
std::iter::once(self.root.as_path())
.filter(|_| self.include_root)
.chain(self.extra.iter().map(|e| e.path.as_path()))
}
/// The root containing `path`, its display prefix (`None` for the project root) and ignore rules.
fn base_of(&self, path: &Path) -> Option<(&Path, Option<&str>, &Gitignore)> {
if path.starts_with(&self.root) {
if self.include_root && path.starts_with(&self.root) {
return Some((&self.root, None, &self.gitignore));
}
self.extra
@@ -159,6 +162,9 @@ impl PathFilter {
/// Key of `path` in the persistent store: only files below the project root
/// are persisted (extra content roots are rescanned when the project opens).
pub(crate) fn store_key(&self, path: &Path) -> Option<String> {
if !self.include_root {
return None;
}
let rel = rel_string(path.strip_prefix(&self.root).ok()?);
(!rel.is_empty()).then_some(rel)
}
@@ -290,7 +296,8 @@ mod tests {
touch(&root, f);
}
std::fs::write(root.join(".gitignore"), "build/\n").unwrap();
let filter = PathFilter::new(&root, &[], &["**/node_modules".into(), "target".into(), "docs/**".into()]);
let filter =
PathFilter::new(&root, &vec![].into(), &["**/node_modules".into(), "target".into(), "docs/**".into()]);
let mut found: Vec<String> =
filter.walk(&root, &AtomicBool::new(false)).iter().filter_map(|f| filter.rel(&f.path)).collect();
found.sort();
@@ -313,7 +320,7 @@ mod tests {
touch(&base, f);
}
std::fs::write(shared.join(".gitignore"), "out/\n").unwrap();
let filter = PathFilter::new(&root, &[shared.clone(), root.join("src")], &["target".into()]);
let filter = PathFilter::new(&root, &vec![shared.clone(), root.join("src")].into(), &["target".into()]);
assert_eq!(filter.roots().count(), 2, "roots inside the project are not extra roots");
let mut found: Vec<String> = filter
.roots()
@@ -327,7 +334,7 @@ mod tests {
assert!(!filter.is_excluded(&shared.join("src/new.rs"), false));
assert!(filter.is_excluded(&shared.join("target/y.rs"), false));
assert!(filter.is_excluded(&shared.join("out/y.rs"), false));
assert!(filter.same_config(&[shared.clone(), root.join("src")], &["target".into()]));
assert!(!filter.same_config(&[shared], &["target".into()]));
assert!(filter.same_config(&vec![shared.clone(), root.join("src")].into(), &["target".into()]));
assert!(!filter.same_config(&vec![shared].into(), &["target".into()]));
}
}
+26 -5
View File
@@ -70,6 +70,19 @@ pub struct ProjectIndex {
root: PathBuf,
}
/// Folders indexed alongside the internal state root.
#[derive(Clone, PartialEq)]
pub struct IndexRoots {
pub include_root: bool,
pub folders: Vec<PathBuf>,
}
impl From<Vec<PathBuf>> for IndexRoots {
fn from(folders: Vec<PathBuf>) -> Self {
Self { include_root: true, folders }
}
}
impl ProjectIndex {
/// Opens (or creates) the persistent index at `cache_dir` and starts a
/// background full scan that skips files whose content hash is unchanged.
@@ -77,7 +90,7 @@ impl ProjectIndex {
pub fn open(
root: &Path,
cache_dir: &Path,
content_roots: Vec<PathBuf>,
content_roots: impl Into<IndexRoots>,
excludes: Vec<String>,
languages: Arc<LanguageRegistry>,
syntax: Arc<Syntax>,
@@ -88,7 +101,7 @@ impl ProjectIndex {
return Err(ide_core::IdeError::not_found(format!("{} is not a directory", root.display())));
}
let store = store::Store::open(cache_dir, &root, &syntax.fingerprint());
let filter = filter::PathFilter::new(&root, &content_roots, &excludes);
let filter = filter::PathFilter::new(&root, &content_roots.into(), &excludes);
let shared = Arc::new(Shared::new(filter, languages, syntax, sink, store));
Self::start(shared, root, true)
}
@@ -102,7 +115,7 @@ impl ProjectIndex {
sink: SharedSink,
) -> IdeResult<Arc<Self>> {
let root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_owned());
let filter = filter::PathFilter::new(&root, &[], &[]);
let filter = filter::PathFilter::new(&root, &Vec::new().into(), &[]);
let shared = Arc::new(Shared::new(filter, languages, syntax, sink, None));
Self::start(shared, root, false)
}
@@ -121,7 +134,8 @@ impl ProjectIndex {
/// Changes what is indexed besides the root (content roots) and the exclude
/// globs, rescanning in the background (unchanged files are not re-read).
/// Returns whether anything changed.
pub fn set_scope(&self, content_roots: Vec<PathBuf>, excludes: Vec<String>) -> bool {
pub fn set_scope(&self, content_roots: impl Into<IndexRoots>, excludes: Vec<String>) -> bool {
let content_roots = content_roots.into();
if self.shared.filter().same_config(&content_roots, &excludes) {
return false;
}
@@ -222,7 +236,14 @@ impl ProjectIndex {
/// the IDE was closed) are left out. Virtual documents are always listed.
pub fn recent_files(&self) -> Vec<PathBuf> {
let recent = self.shared.recent.lock().clone();
recent.into_iter().filter(|p| p.to_str().is_some_and(ide_core::text::is_virtual_uri) || p.exists()).collect()
let filter = self.shared.filter();
recent
.into_iter()
.filter(|p| {
p.to_str().is_some_and(ide_core::text::is_virtual_uri)
|| (p.exists() && (!p.starts_with(&self.root) || filter.contains(p)))
})
.collect()
}
/// Blocks until the initial scan and every queued update has been applied
+35
View File
@@ -508,3 +508,38 @@ fn unindexed_index_never_scans_or_persists() {
assert!(!fx.cache.exists());
assert!(!events.lock().iter().any(|e| matches!(e, IdeEvent::Task { .. })), "no indexing task");
}
#[test]
fn workspace_scope_excludes_state_from_scans_updates_and_cached_results() {
let fx = Fixture::new(&[(".openidea/workspace.json", "{}"), ("internal.rs", "fn hidden() {}")]);
// Simulate a cache produced before the settings root was excluded.
let old = fx.open();
old.mark_recent(&fx.root.join("internal.rs"));
assert!(old.wait_idle(WAIT));
drop(old);
let member = fx.root.join("member");
std::fs::create_dir_all(&member).unwrap();
std::fs::write(member.join("visible.rs"), "fn visible() {}").unwrap();
let idx = ProjectIndex::open(
&fx.root,
&fx.cache,
crate::IndexRoots { include_root: false, folders: vec![member.clone()] },
Vec::new(),
Arc::new(LanguageRegistry::load(None).unwrap()),
Arc::new(Syntax::new()),
Arc::new(NullSink),
)
.unwrap();
assert!(idx.wait_idle(WAIT));
assert!(idx.find_files("internal", 100).is_empty());
assert!(idx.find_files("workspace.json", 100).is_empty());
assert!(idx.recent_files().is_empty());
assert_eq!(idx.find_files("visible", 100).len(), 1);
idx.update(&[fx.root.join("internal.rs"), fx.root.join(".openidea/workspace.json")], &[]);
assert!(idx.wait_idle(WAIT));
assert!(symbols(&idx, "hidden", SymbolFilter::All).is_empty());
assert_eq!(symbols(&idx, "visible", SymbolFilter::All), ["visible"]);
idx.set_scope(crate::IndexRoots { include_root: false, folders: Vec::new() }, Vec::new());
assert!(idx.wait_idle(WAIT));
assert!(idx.find_files("", 100).is_empty());
}
Loaded 100 of 713 files, more files were not shown because too many files have changed in this diff. Show more