Compare commits

...
Author SHA1 Message Date
thomasandClaude Sonnet 5.5 6087b8b8e8 fix(windows): match the native window theme to the app theme
The native outline and shadow followed the OS appearance, so a light app
theme in OS dark mode drew a dark border around project and settings windows.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 21:58:20 +08:00
l0ng-ai 2c9a6470ff feat(project): add project trust and restricted execution mode (#517)
Projects now open with a user-owned trust decision. The prompt offers Preview in Restricted Mode, Don't Open and Trust Project, with an optional trusted parent folder; preview is the default focus so Enter never grants trust.

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

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

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

* fix: select native ARM64 tools before Windows x64 fallback

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

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

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

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

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

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

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

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

Fixes #533

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Fixes #330.

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Dragging out tool windows is not covered. Refs #454

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

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

Co-authored-by: dimassfeb-09 <[email protected]>
2026-10-31 00:55:29 +08:00
643 changed files with 44470 additions and 3836 deletions

No files matched your search

+11
View File
@@ -70,6 +70,12 @@ jobs:
- name: Linux x64
os: ubuntu-22.04
target: x86_64-unknown-linux-gnu
- name: Linux arm64
os: ubuntu-22.04-arm
target: aarch64-unknown-linux-gnu
- name: Windows arm64
os: windows-11-arm
target: aarch64-pc-windows-msvc
- name: Windows x64
os: windows-latest
target: x86_64-pc-windows-msvc
@@ -196,11 +202,16 @@ jobs:
}
// Bundle suffix → updater platform keys, as tauri-action names them.
const kinds = [
[/_aarch64\.AppImage$/, ["linux-aarch64", "linux-aarch64-appimage"]],
[/_arm64\.deb$/, ["linux-aarch64-deb"]],
[/\.aarch64\.rpm$/, ["linux-aarch64-rpm"]],
[/_amd64\.AppImage$/, ["linux-x86_64", "linux-x86_64-appimage"]],
[/_amd64\.deb$/, ["linux-x86_64-deb"]],
[/\.x86_64\.rpm$/, ["linux-x86_64-rpm"]],
[/_aarch64\.app\.tar\.gz$/, ["darwin-aarch64", "darwin-aarch64-app"]],
[/_x64\.app\.tar\.gz$/, ["darwin-x86_64", "darwin-x86_64-app"]],
[/_arm64_en-US\.msi$/, ["windows-aarch64", "windows-aarch64-msi"]],
[/_arm64-setup\.exe$/, ["windows-aarch64-nsis"]],
[/_x64_en-US\.msi$/, ["windows-x86_64", "windows-x86_64-msi"]],
[/_x64-setup\.exe$/, ["windows-x86_64-nsis"]],
];
Generated
+39 -35
View File
@@ -4038,7 +4038,7 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "ide-agent"
version = "0.1.28"
version = "0.1.29"
dependencies = [
"dunce",
"encoding_rs",
@@ -4061,7 +4061,7 @@ dependencies = [
[[package]]
name = "ide-analysis"
version = "0.1.28"
version = "0.1.29"
dependencies = [
"ast-grep-core",
"ide-core",
@@ -4075,7 +4075,7 @@ dependencies = [
[[package]]
name = "ide-build"
version = "0.1.28"
version = "0.1.29"
dependencies = [
"dirs 6.0.0",
"dunce",
@@ -4090,7 +4090,7 @@ dependencies = [
[[package]]
name = "ide-clipboard"
version = "0.1.28"
version = "0.1.29"
dependencies = [
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
@@ -4099,7 +4099,7 @@ dependencies = [
[[package]]
name = "ide-core"
version = "0.1.28"
version = "0.1.29"
dependencies = [
"dunce",
"encoding_rs",
@@ -4115,7 +4115,7 @@ dependencies = [
[[package]]
name = "ide-coverage"
version = "0.1.28"
version = "0.1.29"
dependencies = [
"ide-core",
"ide-run",
@@ -4129,7 +4129,7 @@ dependencies = [
[[package]]
name = "ide-dap"
version = "0.1.28"
version = "0.1.29"
dependencies = [
"ide-core",
"ide-jsonrpc",
@@ -4147,7 +4147,7 @@ dependencies = [
[[package]]
name = "ide-db"
version = "0.1.28"
version = "0.1.29"
dependencies = [
"bb8",
"dirs 6.0.0",
@@ -4169,7 +4169,7 @@ dependencies = [
[[package]]
name = "ide-docker"
version = "0.1.28"
version = "0.1.29"
dependencies = [
"ide-core",
"libc",
@@ -4183,7 +4183,7 @@ dependencies = [
[[package]]
name = "ide-fonts"
version = "0.1.28"
version = "0.1.29"
dependencies = [
"objc2-core-foundation",
"objc2-core-text",
@@ -4194,7 +4194,7 @@ dependencies = [
[[package]]
name = "ide-github"
version = "0.1.28"
version = "0.1.29"
dependencies = [
"ide-core",
"ide-vcs",
@@ -4208,7 +4208,7 @@ dependencies = [
[[package]]
name = "ide-host"
version = "0.1.28"
version = "0.1.29"
dependencies = [
"dirs 6.0.0",
"dunce",
@@ -4253,7 +4253,7 @@ dependencies = [
[[package]]
name = "ide-http"
version = "0.1.28"
version = "0.1.29"
dependencies = [
"ide-core",
"parking_lot",
@@ -4271,7 +4271,7 @@ dependencies = [
[[package]]
name = "ide-index"
version = "0.1.28"
version = "0.1.29"
dependencies = [
"blake3",
"criterion",
@@ -4293,7 +4293,7 @@ dependencies = [
[[package]]
name = "ide-jsonrpc"
version = "0.1.28"
version = "0.1.29"
dependencies = [
"encoding_rs",
"futures",
@@ -4309,7 +4309,7 @@ dependencies = [
[[package]]
name = "ide-language"
version = "0.1.28"
version = "0.1.29"
dependencies = [
"blake3",
"criterion",
@@ -4364,14 +4364,16 @@ dependencies = [
[[package]]
name = "ide-lsp"
version = "0.1.28"
version = "0.1.29"
dependencies = [
"dunce",
"futures",
"globset",
"ide-core",
"ide-jsonrpc",
"ide-language",
"ide-text",
"ignore",
"lsp-types",
"parking_lot",
"percent-encoding",
@@ -4386,7 +4388,7 @@ dependencies = [
[[package]]
name = "ide-notebook"
version = "0.1.28"
version = "0.1.29"
dependencies = [
"futures",
"ide-core",
@@ -4409,7 +4411,7 @@ dependencies = [
[[package]]
name = "ide-plugin"
version = "0.1.28"
version = "0.1.29"
dependencies = [
"ide-core",
"ide-language",
@@ -4427,7 +4429,7 @@ dependencies = [
[[package]]
name = "ide-profiler"
version = "0.1.28"
version = "0.1.29"
dependencies = [
"ide-core",
"ide-run",
@@ -4440,7 +4442,7 @@ dependencies = [
[[package]]
name = "ide-remote"
version = "0.1.28"
version = "0.1.29"
dependencies = [
"globset",
"ide-core",
@@ -4457,9 +4459,11 @@ dependencies = [
[[package]]
name = "ide-run"
version = "0.1.28"
version = "0.1.29"
dependencies = [
"dunce",
"encoding_rs",
"ide-build",
"ide-core",
"libc",
"parking_lot",
@@ -4478,7 +4482,7 @@ dependencies = [
[[package]]
name = "ide-sdk"
version = "0.1.28"
version = "0.1.29"
dependencies = [
"dunce",
"ide-core",
@@ -4497,7 +4501,7 @@ dependencies = [
[[package]]
name = "ide-search"
version = "0.1.28"
version = "0.1.29"
dependencies = [
"criterion",
"globset",
@@ -4517,7 +4521,7 @@ dependencies = [
[[package]]
name = "ide-spell"
version = "0.1.28"
version = "0.1.29"
dependencies = [
"ide-core",
"parking_lot",
@@ -4530,7 +4534,7 @@ dependencies = [
[[package]]
name = "ide-ssh"
version = "0.1.28"
version = "0.1.29"
dependencies = [
"dirs 6.0.0",
"flate2",
@@ -4549,7 +4553,7 @@ dependencies = [
[[package]]
name = "ide-syntax"
version = "0.1.28"
version = "0.1.29"
dependencies = [
"ide-core",
"ide-language",
@@ -4564,7 +4568,7 @@ dependencies = [
[[package]]
name = "ide-terminal"
version = "0.1.28"
version = "0.1.29"
dependencies = [
"ide-core",
"libc",
@@ -4577,7 +4581,7 @@ dependencies = [
[[package]]
name = "ide-text"
version = "0.1.28"
version = "0.1.29"
dependencies = [
"criterion",
"ide-core",
@@ -4589,7 +4593,7 @@ dependencies = [
[[package]]
name = "ide-tools"
version = "0.1.28"
version = "0.1.29"
dependencies = [
"flate2",
"hex",
@@ -4610,7 +4614,7 @@ dependencies = [
[[package]]
name = "ide-vcs"
version = "0.1.28"
version = "0.1.29"
dependencies = [
"dunce",
"encoding_rs",
@@ -4629,7 +4633,7 @@ dependencies = [
[[package]]
name = "ide-vfs"
version = "0.1.28"
version = "0.1.29"
dependencies = [
"chardetng",
"dunce",
@@ -4651,7 +4655,7 @@ dependencies = [
[[package]]
name = "ide-web"
version = "0.1.28"
version = "0.1.29"
dependencies = [
"ide-core",
"open",
@@ -4667,7 +4671,7 @@ dependencies = [
[[package]]
name = "ide-workspace"
version = "0.1.28"
version = "0.1.29"
dependencies = [
"blake3",
"dunce",
@@ -6137,7 +6141,7 @@ dependencies = [
[[package]]
name = "openidea"
version = "0.1.28"
version = "0.1.29"
dependencies = [
"blake3",
"dirs 6.0.0",
+1 -1
View File
@@ -3,7 +3,7 @@ resolver = "3"
members = ["crates/*", "src-tauri"]
[workspace.package]
version = "0.1.28"
version = "0.1.29"
edition = "2024"
rust-version = "1.96"
license = "Apache-2.0"
+3 -2
View File
@@ -12,12 +12,13 @@ icon set, copied unmodified (some renamed) from JetBrains' open-source repositor
`python/python-requirements` (`fileTypes/requirements`); `python/pipenv`
(`fileTypes/pipenv`); `python/python-pyproject` (`expui/toml.svg` →
`fileTypes/pyprojectToml`); `plugins/kotlin/base` (`fileTypes/kotlin`,
`kotlinScript`, `kotlinGradleScript`); `java/openapi` (`fileTypes/maven`);
`kotlinScript`, `kotlinGradleScript`, `icons/expui/objectKotlin` → `kotlin/objectKotlin`); `java/openapi` (`fileTypes/maven`);
`plugins/markdown` (`fileTypes/markdown`); `plugins/toml` (`fileTypes/toml`);
`plugins/terminal` (`toolwindows/terminal`); `plugins/maven`
(`images/expui/toolwindow/maven` → `toolwindows/maven`, `images/expui/build/maven*`
→ `maven/`); `plugins/gradle` (`icons/expui/gradle` → `toolwindows/gradle`,
`icons/expui/gradleLoadChanges` → `build/gradleLoadChanges`);
`icons/expui/gradleLoadChanges` → `build/gradleLoadChanges`,
`icons/expui/gradle`, `icons/gradleSubproject` → `gradle/`);
`platform/external-system-impl` (`icons/newui/task` → `externalSystem/task`).
Copyright 2000-2026 JetBrains s.r.o. and contributors. Apache License 2.0 (below).
- [intellij-plugins](https://github.com/JetBrains/intellij-plugins) (each plugin
+2 -1
View File
@@ -27,7 +27,8 @@ pub(crate) fn newest_install(command: &str) -> String {
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)
// Windows: read afresh, so a tool the user installed since the IDE started is found too.
newest_install_in(command, &ide_core::process::with_user_path(&path))
}
fn newest_install_in(command: &str, search: &std::ffi::OsStr) -> String {
+21
View File
@@ -222,6 +222,13 @@ fn choose<'a>(entry: &'a Entry, platform: Platform, on_path: &dyn Fn(&str) -> bo
{
return Some(Choice::Uvx(p));
}
// Last resort: npx and uvx run natively, this one under OS emulation.
if platform.os == Os::Windows
&& platform.arch == Arch::Arm64
&& let Some(b) = d.binary.get("windows-x86_64")
{
return Some(Choice::Binary(b));
}
None
}
@@ -689,6 +696,20 @@ mod tests {
const LINUX: Platform = Platform { os: Os::Linux, arch: Arch::X64 };
const WINDOWS: Platform = Platform { os: Os::Windows, arch: Arch::X64 };
#[test]
fn windows_arm_binary_prefers_native_then_x64() {
let mut entry = parse(INDEX).unwrap().remove(0);
let arm = Platform { os: Os::Windows, arch: Arch::Arm64 };
assert!(matches!(choose(&entry, arm, &|_| false), Some(Choice::Binary(_))));
assert!(choose(&entry, Platform { os: Os::Linux, arch: Arch::Arm64 }, &|_| false).is_none());
entry.distribution.npx = Some(serde_json::from_str(r#"{"package":"amp-acp"}"#).unwrap());
assert!(matches!(choose(&entry, arm, &|_| true), Some(Choice::Npx(_))));
let mut native = entry.distribution.binary["windows-x86_64"].clone();
native.cmd = "native.exe".into();
entry.distribution.binary.insert("windows-aarch64".into(), native);
assert!(matches!(choose(&entry, arm, &|_| false), Some(Choice::Binary(b)) if b.cmd == "native.exe"));
}
const INDEX: &str = r#"{"version":"1.0.0","agents":[
{"id":"amp-acp","name":"Amp","version":"0.10.0","description":"Amp over ACP",
"repository":"https://github.com/x/amp-acp","authors":["x"],"license":"Apache-2.0",
+16 -1
View File
@@ -58,6 +58,18 @@ pub(crate) fn run(
cwd: &Path,
env: &BTreeMap<String, String>,
cancel: &CancellationToken,
on_line: impl FnMut(Stream, &str),
) -> IdeResult<Output> {
run_with_env(program, args, cwd, env, false, cancel, on_line)
}
pub(crate) fn run_with_env(
program: &str,
args: &[String],
cwd: &Path,
env: &BTreeMap<String, String>,
clear_env: bool,
cancel: &CancellationToken,
mut on_line: impl FnMut(Stream, &str),
) -> IdeResult<Output> {
tracing::info!("build: {program} {} (in {})", args.join(" "), cwd.display());
@@ -66,7 +78,7 @@ pub(crate) fn run(
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.args(args).current_dir(cwd).envs(env).envs(encoding.env.iter().map(|(k, v)| (k, v))).stdin(Stdio::null());
command.stdout(Stdio::piped()).stderr(Stdio::piped());
#[cfg(unix)]
{
@@ -74,6 +86,9 @@ pub(crate) fn run(
command.process_group(0);
}
ide_core::process::prepare(&mut command);
if clear_env {
command.env_clear().envs(env).envs(encoding.env);
}
let mut child =
command.spawn().map_err(|e| IdeError::unavailable(format!("cannot start {}: {e}", program.display())))?;
let (tx, rx) = mpsc::channel::<(Stream, String)>();
+104 -8
View File
@@ -1,6 +1,6 @@
//! WAR modules and build-tool-owned archive resolution (including custom output names).
use crate::BuildModel;
use ide_core::{IdeError, IdeResult};
use ide_core::{CancellationToken, IdeError, IdeResult};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::{collections::BTreeMap, fs, path::Path};
@@ -47,6 +47,50 @@ pub struct WebBuild {
pub args: Vec<String>,
pub cwd: String,
pub env: BTreeMap<String, String>,
maven_project: Option<(Vec<String>, std::path::PathBuf)>,
}
impl WebBuild {
/// Resolve the selected module's effective identity using the build's options and environment.
/// Runs off the UI thread, before launching the reactor build.
pub fn resolve_project(
&mut self,
root: &Path,
env: &BTreeMap<String, String>,
cancel: &CancellationToken,
) -> IdeResult<()> {
let Some((args, output)) = &self.maven_project else {
return Ok(());
};
if output.exists() {
fs::remove_file(output)?;
}
let cwd = root.join(&self.cwd).canonicalize()?;
let mut env = env.clone();
if !env.iter().any(|(k, v)| k.eq_ignore_ascii_case("MAVEN_BASEDIR") && !v.is_empty()) {
// -f changes Maven's .mvn search origin; keep the reactor's launcher settings.
let base = cwd.ancestors().find(|p| p.join(".mvn").is_dir()).unwrap_or(&cwd);
env.retain(|k, _| !k.eq_ignore_ascii_case("MAVEN_BASEDIR"));
env.insert("MAVEN_BASEDIR".into(), base.to_string_lossy().into_owned());
}
let result = crate::process::run_with_env(&self.command, args, &cwd, &env, true, cancel, |_, _| {})?;
if !result.success {
return Err(IdeError::invalid(format!("Cannot resolve Maven WAR artifact: {}", result.text.trim())));
}
let id = fs::read_to_string(output)?;
let parts: Vec<_> = id.trim().split(':').collect();
if parts.len() != 4 || parts[2] != "war" || parts.iter().any(|s| s.is_empty()) {
return Err(IdeError::invalid(format!("Cannot resolve Maven WAR artifact: {}", id.trim())));
}
let project = format!("{}:{}:{}", parts[0], parts[1], parts[3]);
self.args.push(format!("-Dexpression=session.projectMap({project}).artifact.file"));
self.maven_project = None;
Ok(())
}
}
fn groovy_literal(s: &str) -> String {
format!("'{}'", s.replace('\\', "\\\\").replace('\'', "\\'"))
}
pub fn build(root: &Path, model: &BuildModel, id: &str, cache: &Path) -> IdeResult<WebBuild> {
@@ -81,21 +125,36 @@ pub fn build(root: &Path, model: &BuildModel, id: &str, cache: &Path) -> IdeResu
.replace('\\', "/");
args.extend(["-pl".into(), module, "-am".into()]);
}
// help:evaluate runs once, in the reactor's first project. Query the selected
// POM's effective identity first; profiles and -D options can change its GAV.
let output = cache.join("artifact-project.txt");
let mut probe = top.options.clone();
probe.extend([
"-f".into(),
root.join(&p.pom).to_string_lossy().into_owned(),
"help:evaluate".into(),
"-Dexpression=project.id".into(),
format!("-Doutput={}", output.display()),
]);
args.extend([
"-DskipTests".into(),
"package".into(),
"help:evaluate".into(),
"-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() });
return Ok(WebBuild {
command: top.command.clone(),
args,
cwd: top.dir.clone(),
env: p.env.clone(),
maven_project: Some((probe, output)),
});
}
if let Some(path) = id.strip_prefix("gradle:") {
let g = model.gradle.as_ref().ok_or_else(|| IdeError::invalid("Choose a Gradle WAR module"))?;
if !artifacts(root, model).iter().any(|a| a.id == id) {
return Err(IdeError::invalid("Choose a Gradle WAR module"));
}
let literal = |s: &str| format!("'{}'", s.replace('\\', "\\\\").replace('\'', "\\'"));
let script = cache.join("war.init.gradle");
fs::write(
&script,
@@ -105,12 +164,12 @@ pub fn build(root: &Path, model: &BuildModel, id: &str, cache: &Path) -> IdeResu
def warTask = p.tasks.named('war')
p.tasks.register('openideaTomcatArtifact') {{
dependsOn warTask
doLast {{ new File({}).text = warTask.get().archiveFile.get().asFile.absolutePath }}
doLast {{ new File({}).setText(warTask.get().archiveFile.get().asFile.absolutePath, 'UTF-8') }}
}}
}}
"#,
literal(path),
literal(&manifest.to_string_lossy())
groovy_literal(path),
groovy_literal(&manifest.to_string_lossy())
),
)?;
let task = format!("{}:openideaTomcatArtifact", path.trim_end_matches(':'));
@@ -119,6 +178,7 @@ pub fn build(root: &Path, model: &BuildModel, id: &str, cache: &Path) -> IdeResu
args: vec!["--init-script".into(), script.to_string_lossy().into_owned(), task],
cwd: g.dir.clone(),
env: g.env.clone(),
maven_project: None,
});
}
Err(IdeError::invalid("Choose a WAR artifact"))
@@ -136,7 +196,9 @@ mod tests {
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()));
let (probe, _) = plan.maven_project.as_ref().unwrap();
assert!(probe.contains(&"-Dexpression=project.id".into()));
assert!(probe.contains(&tmp.path().join("pom.xml").to_string_lossy().into_owned()));
assert!(
plan.args.iter().any(|a| a.ends_with("base with spaces/artifact-path.txt")
|| a.ends_with("base with spaces\\artifact-path.txt"))
@@ -152,6 +214,13 @@ mod tests {
let plan = build(tmp.path(), &model, "maven:web/pom.xml", &tmp.path().join("base")).unwrap();
assert_eq!(plan.cwd, "");
assert_eq!(&plan.args[..3], &["-pl", "web", "-am"]);
assert!(
plan.maven_project
.as_ref()
.unwrap()
.0
.contains(&tmp.path().join("web/pom.xml").to_string_lossy().into_owned())
);
}
#[test]
fn gradle_war_uses_archive_provider_and_ignores_comments() {
@@ -169,4 +238,31 @@ mod tests {
assert!(fs::read_to_string(tmp.path().join("base/war.init.gradle")).unwrap().contains("archiveFile.get()"));
assert!(!crate::gradle::has_war_plugin("// plugins { war }"));
}
#[test]
fn gradle_manifest_paths_preserve_windows_characters() {
for path in [r"C:\工作 区\Tomcat's cache\artifact-path.txt", r"\\server\share\artifact-path.txt"] {
let literal = groovy_literal(path);
assert_eq!(literal[1..literal.len() - 1].replace("\\'", "'").replace("\\\\", "\\"), path);
}
}
#[test]
fn gradle_root_and_nested_modules_write_utf8_archive_paths() {
for module in [":", ":apps:timesheet"] {
let tmp = tempfile::tempdir().unwrap();
let dir = if module == ":" { "" } else { "apps/timesheet" };
fs::create_dir_all(tmp.path().join(dir)).unwrap();
fs::write(tmp.path().join("settings.gradle"), "rootProject.name = 'timesheet'\ninclude 'apps:timesheet'")
.unwrap();
fs::write(tmp.path().join(dir).join("build.gradle"), "plugins { id 'war' }").unwrap();
let model = crate::load(tmp.path(), &tmp.path().join("cache"), None);
let cache = tmp.path().join("工作 cache");
let plan = build(tmp.path(), &model, &format!("gradle:{module}"), &cache).unwrap();
assert!(plan.args.contains(&format!("{}:openideaTomcatArtifact", module.trim_end_matches(':'))));
let script = fs::read_to_string(cache.join("war.init.gradle")).unwrap();
assert!(script.contains(&format!("rootProject.project('{}')", module)));
assert!(script.contains("archiveFile.get().asFile.absolutePath, 'UTF-8'"));
}
}
}
+1 -1
View File
@@ -19,4 +19,4 @@ tokio-util.workspace = true
tempfile.workspace = true
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Win32_Globalization"] }
windows-sys = { version = "0.61", features = ["Win32_Globalization", "Win32_System_Registry", "Win32_Foundation"] }
+7
View File
@@ -12,6 +12,8 @@ use std::collections::BTreeMap;
pub struct EditorGeneralSettings {
/// Change font size with ⌘ + mouse wheel.
pub wheel_zoom: bool,
pub markdown_image_folder: String,
pub markdown_image_name_pattern: String,
/// Drag-and-drop of selected text inside the editor.
pub drag_and_drop: bool,
/// When the caret moves out of view: `minimize` scrolling | `center` the caret line.
@@ -35,6 +37,8 @@ impl Default for EditorGeneralSettings {
fn default() -> Self {
Self {
wheel_zoom: false,
markdown_image_folder: "assets".into(),
markdown_image_name_pattern: "image-{timestamp}".into(),
drag_and_drop: true,
caret_scroll: "minimize".into(),
soft_wrap_files: "*.md; *.txt; *.rst; *.adoc".into(),
@@ -434,6 +438,8 @@ pub struct EncodingSettings {
pub project: String,
/// Fallback for `.properties` files.
pub properties: String,
/// Display properties Unicode escapes as characters and save non-ASCII as escapes.
pub native_to_ascii: bool,
/// Create UTF-8 files: `never` (no BOM) | `always` (with BOM) | `windows` (with BOM on Windows).
pub utf8_bom: String,
}
@@ -444,6 +450,7 @@ impl Default for EncodingSettings {
global: "utf-8".into(),
project: "utf-8".into(),
properties: "iso-8859-1".into(),
native_to_ascii: false,
utf8_bom: "never".into(),
}
}
+8
View File
@@ -15,12 +15,20 @@ use std::sync::Arc;
pub enum IdeEvent {
/// Project window membership or selection changed.
WindowGroupsChanged,
ProjectTrustChanged {
root: crate::IpcPath,
trusted: bool,
},
/// Files or directories changed on disk (after debouncing, ignore rules applied).
FsChanged {
created: Vec<crate::IpcPath>,
modified: Vec<crate::IpcPath>,
removed: Vec<crate::IpcPath>,
},
/// Excluded or ignored folders whose contents changed; they stay out of `FsChanged`.
ExcludedDirsChanged {
dirs: Vec<crate::IpcPath>,
},
/// The backend changed a document (reload from disk, formatting, refactoring).
/// The UI applies `changes` on top of `base_version`, yielding `version`.
DocPatched {
+100
View File
@@ -237,10 +237,110 @@ pub fn find_executable(name: &str, search: &OsStr) -> Option<std::path::PathBuf>
find_executable_in(name, search, cfg!(windows), pathext.as_deref().and_then(|p| p.to_str()))
}
/// The user's own `PATH` entries on Windows (`HKCU\Environment`), the part of the
/// merged `PATH` a process may not inherit — an IDE started before the variable was
/// edited, or as a different user, sees only the machine entries. `%VAR%` references
/// (`REG_EXPAND_SZ`) are expanded against the current environment. `None` elsewhere:
/// there a login shell already supplies the user's whole `PATH`.
#[cfg(windows)]
pub fn user_path() -> Option<OsString> {
use windows_sys::Win32::Foundation::ERROR_SUCCESS;
use windows_sys::Win32::System::Registry::{
HKEY, HKEY_CURRENT_USER, KEY_READ, RRF_RT_REG_EXPAND_SZ, RRF_RT_REG_SZ, RegCloseKey, RegGetValueW,
RegOpenKeyExW,
};
fn wide(s: &str) -> Vec<u16> {
s.encode_utf16().chain(std::iter::once(0)).collect()
}
unsafe {
let mut key: HKEY = std::ptr::null_mut();
if RegOpenKeyExW(HKEY_CURRENT_USER, wide("Environment").as_ptr(), 0, KEY_READ, &mut key) != ERROR_SUCCESS {
return None;
}
let read = || {
// Only `Path`; `REG_EXPAND_SZ` is accepted so `%USERPROFILE%\…` entries expand.
let mut kind = 0u32;
let mut len = 0u32;
let name = wide("Path");
let status = RegGetValueW(
key,
std::ptr::null(),
name.as_ptr(),
RRF_RT_REG_SZ | RRF_RT_REG_EXPAND_SZ,
&mut kind,
std::ptr::null_mut(),
&mut len,
);
if status != ERROR_SUCCESS || len == 0 {
return None;
}
let mut buf = vec![0u16; (len as usize).div_ceil(2)];
let status = RegGetValueW(
key,
std::ptr::null(),
name.as_ptr(),
RRF_RT_REG_SZ | RRF_RT_REG_EXPAND_SZ,
&mut kind,
buf.as_mut_ptr() as *mut _,
&mut len,
);
if status != ERROR_SUCCESS {
return None;
}
let text = String::from_utf16_lossy(&buf);
Some(text.trim_end_matches('\0').to_owned())
};
let value = read();
RegCloseKey(key);
value.filter(|v| !v.trim().is_empty()).map(OsString::from)
}
}
/// The user's own `PATH` entries; `None` outside Windows (see the Windows impl).
#[cfg(not(windows))]
pub fn user_path() -> Option<OsString> {
None
}
/// `path` followed by the [`user_path`] entries it lacks.
pub fn with_user_path(path: &OsStr) -> OsString {
user_path().map_or_else(|| path.to_owned(), |user| append_path_entries(path, &user))
}
fn append_path_entries(path: &OsStr, extra: &OsStr) -> OsString {
let mut dirs: Vec<std::path::PathBuf> = std::env::split_paths(path).filter(|d| !d.as_os_str().is_empty()).collect();
for dir in std::env::split_paths(extra).filter(|d| !d.as_os_str().is_empty()) {
if !dirs.contains(&dir) {
dirs.push(dir);
}
}
std::env::join_paths(dirs).unwrap_or_else(|_| path.to_owned())
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(windows)]
#[test]
fn user_path_reads_the_current_users_path() {
// This host has a user `Path` only when the test account set one; either way the call
// must not fail, and a present value is a non-empty `;`-joined list of directories.
if let Some(path) = user_path() {
let dirs: Vec<_> = std::env::split_paths(&path).collect();
assert!(!dirs.is_empty(), "user PATH should hold at least one directory");
}
}
#[test]
fn append_path_entries_keeps_the_order_and_adds_what_is_missing() {
let join = |dirs: &[&str]| std::env::join_paths(dirs).unwrap();
assert_eq!(append_path_entries(&join(&["a", "b"]), &join(&["b", "c"])), join(&["a", "b", "c"]));
assert_eq!(append_path_entries(OsStr::new(""), &join(&["c"])), join(&["c"]));
}
#[test]
fn shell_env_adds_only_what_the_process_lacks_and_the_overlay_wins() {
set_shell_env(vec![("OPENIDEA_SHELL_ONLY_VAR".into(), "1".into()), ("PATH".into(), "/from/shell".into())]);
+30 -1
View File
@@ -103,10 +103,19 @@ impl Default for DeploymentSettings {
}
}
/// An SFTP server and how project folders map onto it.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum DeploymentType {
#[default]
Sftp,
Local,
}
/// A deployment filesystem and how project folders map onto it.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct DeploymentServer {
pub kind: DeploymentType,
pub id: String,
pub name: String,
/// [`SshConfiguration::id`].
@@ -148,3 +157,23 @@ pub struct RunTarget {
pub node: String,
pub java: String,
}
#[cfg(test)]
mod deployment_tests {
use super::*;
#[test]
fn deployment_type_defaults_and_round_trips() {
let legacy: DeploymentServer =
serde_json::from_str(r#"{"id":"existing","sshConfig":"ssh","rootPath":"/srv"}"#).unwrap();
assert_eq!(legacy.kind, DeploymentType::Sftp);
assert_eq!(legacy.ssh_config, "ssh");
for kind in [DeploymentType::Sftp, DeploymentType::Local] {
let server = DeploymentServer { kind, ..legacy.clone() };
assert_eq!(
serde_json::from_str::<DeploymentServer>(&serde_json::to_string(&server).unwrap()).unwrap(),
server
);
}
}
}
+72
View File
@@ -285,6 +285,8 @@ pub struct AppearanceSettings {
pub project_colors_in_toolbar: bool,
pub dnd_with_alt_only: bool,
pub menu_icons: bool,
/// `hamburger` | `merged`: main menu presentation in the frameless title bar.
pub main_menu_style: String,
/// `subpixel` | `greyscale` | `none`
pub ide_antialiasing: String,
pub editor_antialiasing: String,
@@ -331,6 +333,7 @@ impl Default for AppearanceSettings {
project_colors_in_toolbar: true,
dnd_with_alt_only: false,
menu_icons: true,
main_menu_style: "hamburger".into(),
ide_antialiasing: "greyscale".into(),
editor_antialiasing: "greyscale".into(),
show_tool_window_bars: true,
@@ -342,6 +345,15 @@ impl Default for AppearanceSettings {
}
}
/// Imported editor colour scheme on top of a built-in base.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct CustomColorScheme {
pub name: String,
pub parent: String,
pub attributes: BTreeMap<String, TextAttributesOverride>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct EditorSettings {
@@ -357,6 +369,8 @@ pub struct EditorSettings {
pub format_on_save: bool,
/// Editor colour scheme id; `theme-default` follows the IDE theme.
pub color_scheme: String,
/// Imported editor schemes, stored in user settings.
pub custom_schemes: BTreeMap<String, CustomColorScheme>,
/// Color Scheme edits: scheme id → attribute id → override.
pub color_overrides: BTreeMap<String, BTreeMap<String, TextAttributesOverride>>,
/// Color Scheme › Language Defaults › Semantic highlighting: local variables and
@@ -404,6 +418,7 @@ impl Default for EditorSettings {
format_on_save: false,
color_scheme: "theme-default".into(),
color_overrides: BTreeMap::new(),
custom_schemes: BTreeMap::new(),
semantic_highlighting: false,
rainbow_brackets: false,
ligatures: false,
@@ -434,6 +449,8 @@ impl Default for EditorSettings {
pub struct FileSettings {
/// Save on focus loss / tab switch, like IDEA.
pub auto_save: bool,
/// Re-check open files against disk when the IDE window gains focus.
pub sync_on_focus: bool,
pub exclude: Vec<String>,
/// Ignored Files and Folders: name patterns hidden everywhere; see [`crate::ignored`].
pub ignored_names: Vec<String>,
@@ -465,6 +482,7 @@ impl Default for FileSettings {
fn default() -> Self {
Self {
auto_save: true,
sync_on_focus: true,
exclude: vec!["**/.git".into(), "**/node_modules".into(), "**/target".into()],
ignored_names: crate::ignored::DEFAULT_IGNORED_NAMES.iter().map(|s| (*s).into()).collect(),
trim_trailing_whitespace: true,
@@ -546,6 +564,35 @@ pub struct GoSettings {
pub goroot: String,
/// Empty = `go env GOPATH`.
pub gopath: String,
pub build_tags: GoBuildTags,
pub modules: GoModules,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct GoModules {
pub env: BTreeMap<String, String>,
pub auto_vendor: bool,
}
impl Default for GoModules {
fn default() -> Self {
Self { env: BTreeMap::new(), auto_vendor: true }
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct GoBuildTags {
/// Empty values inherit the selected toolchain's defaults.
pub os: String,
pub arch: String,
pub version: String,
pub compiler: String,
/// Empty = automatic, "1" = enabled, "0" = disabled.
pub cgo: String,
pub custom: String,
pub experiments: String,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
@@ -672,6 +719,15 @@ impl Settings {
mod tests {
use super::*;
#[test]
fn main_menu_style_defaults_and_round_trips() {
let old: Settings = serde_json::from_str(r#"{"appearance":{"compactMode":true}}"#).unwrap();
assert_eq!(old.appearance.main_menu_style, "hamburger");
let merged: Settings = serde_json::from_str(r#"{"appearance":{"mainMenuStyle":"merged"}}"#).unwrap();
assert_eq!(merged.appearance.main_menu_style, "merged");
assert_eq!(serde_json::to_value(merged).unwrap()["appearance"]["mainMenuStyle"], "merged");
}
#[test]
fn editor_font_weights_default_to_regular_and_bold() {
let json = serde_json::to_value(Settings::default()).unwrap();
@@ -754,3 +810,19 @@ mod gradle_settings_tests {
}
}
}
#[cfg(test)]
mod go_module_tests {
use super::*;
#[test]
fn old_go_settings_inherit_module_defaults() {
let old: GoSettings = serde_json::from_str(r#"{"goroot":"/go"}"#).unwrap();
assert!(old.modules.auto_vendor);
assert!(old.modules.env.is_empty());
let configured: GoSettings =
serde_json::from_str(r#"{"modules":{"autoVendor":false,"env":{"GOPRIVATE":"example.com/private"}}}"#)
.unwrap();
assert!(!configured.modules.auto_vendor);
assert_eq!(configured.modules.env["GOPRIVATE"], "example.com/private");
}
}
+69
View File
@@ -99,6 +99,44 @@ pub struct WorkspaceEdit {
pub files: Vec<FileEdit>,
}
impl WorkspaceEdit {
/// Coalesces aliases of the same file before applying edits against its original text.
/// Resource operations separate batches because later edits may use the new contents.
pub fn deduplicate(&mut self) {
let mut files = Vec::new();
let mut batch = std::collections::HashMap::<std::path::PathBuf, usize>::new();
let mut resolve_aliases = true;
for file in std::mem::take(&mut self.files) {
match file {
FileEdit::Edit { path, edits } => {
let path = if resolve_aliases {
dunce::canonicalize(&path).unwrap_or_else(|_| path.to_path_buf())
} else {
path.to_path_buf()
};
let index = *batch.entry(path.clone()).or_insert_with(|| {
files.push(FileEdit::Edit { path: path.into(), edits: Vec::new() });
files.len() - 1
});
let FileEdit::Edit { edits: own, .. } = &mut files[index] else { unreachable!() };
for edit in edits {
if !own.contains(&edit) {
own.push(edit);
}
}
}
op => {
batch.clear();
// Later paths refer to a filesystem state the resource operation has yet to create.
resolve_aliases = false;
files.push(op);
}
}
}
self.files = files;
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum FileEdit {
@@ -203,3 +241,34 @@ pub struct OutlineItem {
/// Fuzzy-match highlight: indices (in chars of the displayed string) that matched.
pub type MatchIndices = Vec<u32>;
#[cfg(all(test, unix))]
mod workspace_edit_tests {
use super::*;
#[test]
fn aliases_are_one_edit_but_resource_operations_preserve_order() {
let dir = tempfile::tempdir().unwrap();
let real = dir.path().join("main.cpp");
let alias = dir.path().join("alias.cpp");
std::fs::write(&real, "add()").unwrap();
std::os::unix::fs::symlink(&real, &alias).unwrap();
let change = TextEdit { range: Range::new(Pos::new(0, 0), Pos::new(0, 3)), new_text: "sum_values".into() };
let edit = |path: PathBuf| FileEdit::Edit { path: path.into(), edits: vec![change.clone()] };
let mut workspace = WorkspaceEdit {
files: vec![
edit(real.clone()),
edit(alias),
FileEdit::Create { path: dir.path().join("other").into(), overwrite: false },
edit(real),
],
};
workspace.deduplicate();
assert_eq!(workspace.files.len(), 3);
let FileEdit::Edit { edits, .. } = &workspace.files[0] else { panic!() };
assert_eq!(edits, &[change]);
let once = workspace.clone();
workspace.deduplicate();
assert_eq!(workspace, once);
}
}
+43 -3
View File
@@ -17,7 +17,16 @@ 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);
// The Windows user `PATH` may not reach a process started before it was set; append its
// entries after the inherited ones so missing user tools still resolve.
let user_path = crate::process::user_path();
let dirs = search_dirs(
host_var_os("PATH").as_deref(),
shell_path.as_deref(),
user_path.as_deref(),
home.as_deref(),
&shims,
);
std::env::join_paths(dirs).unwrap_or_default()
}
@@ -56,10 +65,12 @@ fn is_executable(path: &Path) -> bool {
fn search_dirs(
process_path: Option<&OsStr>,
shell_path: Option<&OsStr>,
user_path: Option<&OsStr>,
home: Option<&Path>,
shims: &[PathBuf],
) -> Vec<PathBuf> {
let mut dirs: Vec<PathBuf> = process_path.into_iter().chain(shell_path).flat_map(std::env::split_paths).collect();
let mut dirs: Vec<PathBuf> =
process_path.into_iter().chain(shell_path).chain(user_path).flat_map(std::env::split_paths).collect();
if let Some(home) = home {
dirs.extend(USER_DIRS.iter().map(|d| home.join(d)));
dirs.extend(nvm_bins(&home.join(".nvm/versions/node")));
@@ -80,6 +91,8 @@ const USER_DIRS: &[&str] = &[
".cargo/bin",
".local/bin",
"go/bin",
// `dotnet tool install --global` puts fsautocomplete, csharp-ls and friends here.
".dotnet/tools",
".bun/bin",
".deno/bin",
".npm-global/bin",
@@ -96,6 +109,12 @@ const USER_DIRS: &[&str] = &[
const SYSTEM_DIRS: &[&str] = &["/opt/homebrew/bin", "/usr/local/go/bin", "/usr/local/bin", "/usr/bin", "/bin"];
/// Whether `dir` is one of the system's own bin directories, which [`search_path`] adds
/// whatever `PATH` holds.
pub fn is_system_dir(dir: &Path) -> bool {
SYSTEM_DIRS.iter().any(|d| Path::new(d) == dir)
}
/// The `bin` directories of nvm's Node versions in `dir`, newest first.
fn nvm_bins(dir: &Path) -> Vec<PathBuf> {
let mut versions: Vec<(Vec<u64>, PathBuf)> = std::fs::read_dir(dir)
@@ -164,21 +183,42 @@ mod tests {
Some(OsStr::new("/usr/bin:.:node_modules/.bin:/opt/tools")),
// nushell prints `$PATH` as is.
Some(OsStr::new("$PATH:/opt/tools:/home/me/.asdf/shims")),
None,
Some(Path::new("/home/me")),
&shims,
);
let at = |d: &str| dirs.iter().position(|p| p == Path::new(d)).unwrap();
assert_eq!(dirs[..3], [PathBuf::from("/usr/bin"), "/opt/tools".into(), "/home/me/.asdf/shims".into()]);
assert!(at("/home/me/.npm-global/bin") < at("/home/me/.pyenv/shims"));
// Global .NET tools (fsautocomplete, csharp-ls) install here, off PATH.
assert!(at("/home/me/.dotnet/tools") < at("/usr/local/bin"));
assert!(at("/home/me/.local/share/mise/shims") < at("/usr/local/bin"));
assert!(dirs.iter().all(|d| d.is_absolute()));
let unique: HashSet<&PathBuf> = dirs.iter().collect();
assert_eq!(unique.len(), dirs.len());
// A `:` can't be in a `PATH` entry: those below such a home are left out.
let dirs = search_dirs(Some(OsStr::new("/usr/bin")), None, Some(Path::new("/home/a:b")), &[]);
let dirs = search_dirs(Some(OsStr::new("/usr/bin")), None, None, Some(Path::new("/home/a:b")), &[]);
assert!(dirs.contains(&PathBuf::from("/usr/bin")) && !dirs.iter().any(|d| d.starts_with("/home")));
}
#[test]
fn search_dirs_append_user_path_entries_once_after_the_inherited_path() {
let (system, dup, usertool) =
if cfg!(windows) { ("C:/system", "C:/dup", "C:/usertool") } else { ("/system", "/dup", "/usertool") };
let home = Path::new(if cfg!(windows) { "C:/home/me" } else { "/home/me" });
let join = |dirs: &[&str]| std::env::join_paths(dirs.iter().map(PathBuf::from)).unwrap();
let inherited = join(&[system, dup]);
let user = join(&[dup, usertool]);
let dirs = search_dirs(Some(inherited.as_os_str()), None, Some(user.as_os_str()), Some(home), &[]);
let at = |d: &str| dirs.iter().position(|p| p == Path::new(d)).unwrap();
// User entries follow the inherited PATH, de-duplicated, ahead of the fallbacks.
assert_eq!(dirs.iter().filter(|d| *d == Path::new(dup)).count(), 1);
assert!(at(dup) < at(usertool));
// And before this platform's home/system fallbacks.
let fallback = dirs.iter().position(|d| d == &home.join(USER_DIRS[0])).unwrap();
assert!(at(usertool) < fallback);
}
#[test]
fn nvm_versions_newest_first() {
let dir = std::env::temp_dir().join(format!("openidea-nvm-{}", std::process::id()));
+39 -2
View File
@@ -348,8 +348,17 @@ pub struct AdapterIo {
pub port: Option<u16>,
/// Recent stderr lines of the adapter process, for error messages.
pub stderr: Arc<parking_lot::Mutex<Vec<String>>>,
/// (category, line) printed by the debuggee when it shares the adapter's stdio.
pub program_output: Option<ProgramOutput>,
}
pub type ProgramOutput = tokio::sync::mpsc::UnboundedReceiver<(&'static str, String)>;
type ProgramSink = tokio::sync::mpsc::UnboundedSender<(&'static str, String)>;
/// Delve's debuggee inherits its stdio; no DAP output event carries what the program prints.
const SHARES_STDIO: &str = "delve";
const DELVE_BANNER: &str = "DAP server listening at:";
pub type LaunchFuture = Pin<Box<dyn Future<Output = IdeResult<AdapterIo>> + Send>>;
/// Starts adapters. Tests substitute in-memory adapters.
@@ -385,6 +394,7 @@ impl AdapterLauncher for ProcessLauncher {
child: None,
port: Some(port),
stderr,
program_output: None,
});
}
let embedded = match spec.connect.as_deref().filter(|_| spec.transport == Transport::Tcp) {
@@ -407,6 +417,7 @@ impl AdapterLauncher for ProcessLauncher {
child: None,
port: Some(*port),
stderr,
program_output: None,
});
}
if spec.adapter == DEBUGPY_TOOL {
@@ -445,8 +456,14 @@ impl AdapterLauncher for ProcessLauncher {
.kill_on_drop(true);
let mut child =
cmd.spawn().map_err(|e| IdeError::unavailable(format!("failed to start {}: {e}", binary.display())))?;
let (program_sink, program_output) = if spec.adapter == SHARES_STDIO {
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
(Some(tx), Some(rx))
} else {
(None, None)
};
if let Some(err) = child.stderr.take() {
pump_lines(err, spec.adapter.clone(), stderr.clone());
pump_lines(err, spec.adapter.clone(), stderr.clone(), program_sink.clone().map(|tx| (tx, "stderr")));
}
match port {
None => {
@@ -458,11 +475,12 @@ impl AdapterLauncher for ProcessLauncher {
child: Some(child),
port: None,
stderr,
program_output,
})
}
Some(port) => {
if let Some(out) = child.stdout.take() {
pump_lines(out, spec.adapter.clone(), stderr.clone());
pump_lines(out, spec.adapter.clone(), stderr.clone(), program_sink.map(|tx| (tx, "stdout")));
}
let timeout = if embedded.is_some() { EMBEDDED_START_TIMEOUT } else { CONNECT_TIMEOUT };
let stream = match connect_with_retries(LOOPBACK, port, Some(&mut child), timeout).await {
@@ -484,6 +502,7 @@ impl AdapterLauncher for ProcessLauncher {
child: Some(child),
port: Some(port),
stderr,
program_output,
})
}
}
@@ -495,9 +514,15 @@ fn pump_lines<R: AsyncRead + Unpin + Send + 'static>(
stream: R,
name: String,
sink: Arc<parking_lot::Mutex<Vec<String>>>,
program: Option<(ProgramSink, &'static str)>,
) {
tokio::spawn(ide_jsonrpc::output::read_lines(stream, move |line| {
tracing::debug!(target: "ide_dap::adapter", "[{name}] {line}");
if let Some((tx, category)) = &program
&& !line.starts_with(DELVE_BANNER)
{
let _ = tx.send((category, format!("{line}\n")));
}
push_line(&sink, line);
}));
}
@@ -549,6 +574,18 @@ async fn connect_with_retries(
mod tests {
use super::*;
#[tokio::test]
async fn program_output_is_forwarded_without_the_adapter_banner() {
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let sink = Arc::default();
let text: &[u8] = b"DAP server listening at: 127.0.0.1:1\n[hello two words]\nno newline";
pump_lines(text, "delve".into(), Arc::clone(&sink), Some((tx, "stdout")));
assert_eq!(rx.recv().await, Some(("stdout", "[hello two words]\n".into())));
assert_eq!(rx.recv().await, Some(("stdout", "no newline\n".into())));
assert_eq!(rx.recv().await, None);
assert_eq!(sink.lock().len(), 3);
}
/// Managed tools win over `PATH`, and debugpy falls back to the private copy.
#[cfg(unix)]
#[tokio::test]
+8
View File
@@ -697,6 +697,14 @@ impl DebugManager {
};
let session =
Arc::new(Session::new(id, owner, name, adapter_name, parent, request, conn, spec, io.port, io.child));
if let Some(mut output) = io.program_output {
let session = session.clone();
tokio::spawn(async move {
while let Some((category, output)) = output.recv().await {
session.emit(ide_core::event::DebugEvent::Output { category: category.into(), output });
}
});
}
self.sessions.lock().insert(id, session.clone());
self.order.lock().push(id);
// Announced before the event loop runs, so it precedes every adapter event.
+8 -1
View File
@@ -32,7 +32,14 @@ impl AdapterLauncher for FakeLauncher {
tokio::spawn(fake_adapter(server, log, script));
let (r, w) = tokio::io::split(client);
Box::pin(async move {
Ok(AdapterIo { reader: Box::new(r), writer: Box::new(w), child: None, port: None, stderr: Arc::default() })
Ok(AdapterIo {
reader: Box::new(r),
writer: Box::new(w),
child: None,
port: None,
stderr: Arc::default(),
program_output: None,
})
})
}
}
+48 -1
View File
@@ -107,7 +107,7 @@ pub fn availability(root: &Path) -> GhAvailability {
}
let (repo, login) = std::thread::scope(|s| {
let repo = s.spawn(|| {
let cmd = command(root, &["repo", "view", "--json", "nameWithOwner,url,defaultBranchRef"])?;
let cmd = command(root, &["repo", "view", "--json", REPO_FIELDS])?;
process::run(cmd, QUICK, None)
});
let login = s.spawn(|| run(root, &["api", "user", "--jq", ".login"]));
@@ -278,6 +278,53 @@ pub fn add_comment(root: &Path, number: u32, body: &str) -> IdeResult<()> {
run(root, &["pr", "comment", &n, "--body", body]).map(drop)
}
/// A GitHub API call through `gh api`: GraphQL (`endpoint` `graphql`, `body`
/// `{"query", "variables"}`) or REST (`repos/{owner}/{repo}/…`, gh fills in the
/// placeholders from the project's remote).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct GhApiRequest {
/// `GET`, `POST`, `PATCH`, `PUT`, `DELETE`.
pub method: String,
pub endpoint: String,
/// JSON request body, sent on stdin.
pub body: Option<String>,
/// Follow REST `Link` pages; the result is the array of all pages (`--slurp`).
pub paginate: bool,
}
const METHODS: &[&str] = &["GET", "POST", "PATCH", "PUT", "DELETE"];
/// `gh api` arguments for `req`.
pub fn api_args(req: &GhApiRequest) -> IdeResult<Vec<String>> {
let method = req.method.to_ascii_uppercase();
if !METHODS.contains(&method.as_str()) {
return Err(IdeError::invalid(format!("unsupported HTTP method {}", req.method)));
}
let endpoint = req.endpoint.trim();
if endpoint.is_empty() || endpoint.starts_with('-') || endpoint.contains("://") {
return Err(IdeError::invalid(format!("not a GitHub API endpoint: {endpoint}")));
}
let mut args: Vec<String> = vec!["api".into(), "--method".into(), method, endpoint.into()];
if req.body.is_some() {
args.push("--input".into());
args.push("-".into());
}
if req.paginate {
args.push("--paginate".into());
args.push("--slurp".into());
}
Ok(args)
}
/// Runs `req`; returns the response body (JSON text, empty for `204 No Content`).
pub fn api(root: &Path, req: &GhApiRequest) -> IdeResult<String> {
let args = api_args(req)?;
let refs: Vec<&str> = args.iter().map(String::as_str).collect();
let out = process::run_with_input(command(root, &refs)?, req.body.as_deref(), QUICK, None)?;
if out.success { Ok(out.stdout) } else { Err(failure(&out.stderr, &out.stdout)) }
}
/// Revisions to diff a pull request's files with, present in the local repository.
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
+24 -2
View File
@@ -34,6 +34,12 @@ pub struct GhRepo {
pub name_with_owner: String,
pub url: String,
pub default_branch: Option<String>,
/// Merge methods the repository allows (all of them when gh does not say).
pub merge_commit_allowed: bool,
pub squash_merge_allowed: bool,
pub rebase_merge_allowed: bool,
/// The signed-in user's permission: `ADMIN`, `MAINTAIN`, `WRITE`, `TRIAGE`, `READ`; empty when unknown.
pub viewer_permission: String,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
@@ -152,6 +158,8 @@ pub struct PullRequestDetails {
pub base_oid: String,
/// `MERGEABLE`, `CONFLICTING`, `UNKNOWN`.
pub mergeable: String,
/// GitHub's merge box state: `CLEAN`, `UNSTABLE`, `HAS_HOOKS`, `BLOCKED`, `BEHIND`, `DIRTY`, `DRAFT`, `UNKNOWN`.
pub merge_state_status: String,
pub is_cross_repository: bool,
pub merged_at: Option<String>,
pub closed_at: Option<String>,
@@ -242,7 +250,7 @@ impl RawSummary {
pub const LIST_FIELDS: &str = "number,title,author,state,isDraft,headRefName,baseRefName,createdAt,updatedAt,labels,reviewDecision,url,additions,deletions";
/// Fields requested from `gh pr view`.
pub const VIEW_FIELDS: &str = "number,title,author,state,isDraft,headRefName,baseRefName,createdAt,updatedAt,labels,reviewDecision,url,additions,deletions,body,headRefOid,baseRefOid,mergeable,isCrossRepository,mergedAt,closedAt,statusCheckRollup,reviewRequests,reviews,assignees,commits,files,comments";
pub const VIEW_FIELDS: &str = "number,title,author,state,isDraft,headRefName,baseRefName,createdAt,updatedAt,labels,reviewDecision,url,additions,deletions,body,headRefOid,baseRefOid,mergeable,mergeStateStatus,isCrossRepository,mergedAt,closedAt,statusCheckRollup,reviewRequests,reviews,assignees,commits,files,comments";
pub fn parse_list(json: &str) -> serde_json::Result<Vec<PullRequestSummary>> {
let raw: Vec<RawSummary> = serde_json::from_str(json)?;
@@ -348,6 +356,7 @@ struct RawDetails {
head_ref_oid: String,
base_ref_oid: String,
mergeable: String,
merge_state_status: String,
is_cross_repository: bool,
merged_at: Option<String>,
closed_at: Option<String>,
@@ -442,6 +451,7 @@ pub fn parse_view(json: &str) -> serde_json::Result<PullRequestDetails> {
head_oid: raw.head_ref_oid,
base_oid: raw.base_ref_oid,
mergeable: raw.mergeable,
merge_state_status: raw.merge_state_status,
is_cross_repository: raw.is_cross_repository,
merged_at: raw.merged_at.filter(|s| !s.is_empty()),
closed_at: raw.closed_at.filter(|s| !s.is_empty()),
@@ -534,6 +544,10 @@ struct RawRepo {
name_with_owner: String,
url: String,
default_branch_ref: Option<RawBranchRef>,
merge_commit_allowed: Option<bool>,
squash_merge_allowed: Option<bool>,
rebase_merge_allowed: Option<bool>,
viewer_permission: Option<String>,
}
#[derive(Deserialize, Default)]
@@ -542,13 +556,21 @@ struct RawBranchRef {
name: String,
}
/// `gh repo view --json nameWithOwner,url,defaultBranchRef`.
/// Fields requested from `gh repo view`.
pub const REPO_FIELDS: &str =
"nameWithOwner,url,defaultBranchRef,mergeCommitAllowed,squashMergeAllowed,rebaseMergeAllowed,viewerPermission";
/// `gh repo view --json` with [`REPO_FIELDS`].
pub fn parse_repo(json: &str) -> serde_json::Result<GhRepo> {
let raw: RawRepo = serde_json::from_str(json)?;
Ok(GhRepo {
name_with_owner: raw.name_with_owner,
url: raw.url,
default_branch: raw.default_branch_ref.map(|b| b.name).filter(|n| !n.is_empty()),
merge_commit_allowed: raw.merge_commit_allowed.unwrap_or(true),
squash_merge_allowed: raw.squash_merge_allowed.unwrap_or(true),
rebase_merge_allowed: raw.rebase_merge_allowed.unwrap_or(true),
viewer_permission: raw.viewer_permission.unwrap_or_default(),
})
}
+22 -1
View File
@@ -60,9 +60,30 @@ fn read_all(mut pipe: impl Read + Send + 'static) -> std::thread::JoinHandle<Str
/// Runs `cmd` to completion. Fails when it outlives `timeout` or `cancel` fires;
/// a non-zero exit is reported in [`Output::success`], not as an error.
pub(crate) fn run(mut cmd: Command, timeout: Duration, cancel: Option<&CancellationToken>) -> IdeResult<Output> {
pub(crate) fn run(cmd: Command, timeout: Duration, cancel: Option<&CancellationToken>) -> IdeResult<Output> {
run_with_input(cmd, None, timeout, cancel)
}
/// [`run`] with `input` written to the child's stdin (then closed).
pub(crate) fn run_with_input(
mut cmd: Command,
input: Option<&str>,
timeout: Duration,
cancel: Option<&CancellationToken>,
) -> IdeResult<Output> {
prepare(&mut cmd);
if input.is_some() {
cmd.stdin(Stdio::piped());
}
let mut child = spawn(&mut cmd)?;
if let (Some(input), Some(mut stdin)) = (input, child.stdin.take()) {
let input = input.to_owned();
// A writer thread: a large body must not block on a full pipe while gh is not reading yet.
std::thread::spawn(move || {
use std::io::Write;
let _ = stdin.write_all(input.as_bytes());
});
}
let out = child.stdout.take().map(read_all);
let err = child.stderr.take().map(read_all);
let deadline = Instant::now() + timeout;
+1 -1
View File
@@ -1 +1 @@
{"additions":42,"assignees":[],"author":{"id":"MDQ6VXNlcjM2NzI4OTMx","is_bot":false,"login":"babakks","name":"Babak K. Shandiz"},"baseRefName":"trunk","baseRefOid":"ae7b9715976c5508398b11dc8546e1279e10f6fc","body":"### Description\n\nThis PR moves gh to the tagged version of `cli/oauth`.","closedAt":"2026-10-21T12:29:58Z","comments":[{"id":"IC_1","author":{"login":"babakks"},"authorAssociation":"MEMBER","body":"Rebased on trunk.","createdAt":"2026-10-21T08:00:00Z","includesCreatedEdit":false,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/cli/cli/pull/14768#issuecomment-1","viewerDidAuthor":false}],"commits":[{"authoredDate":"2026-10-20T16:37:24Z","authors":[{"email":"[email protected]","id":"MDQ6VXNlcjM2NzI4OTMx","login":"babakks","name":"Babak K. Shandiz"}],"committedDate":"2026-10-21T12:06:51Z","messageBody":"cli/oauth v1.3.0 exposes the token expiry fields as time.Duration values.","messageHeadline":"chore(deps): bump github.com/cli/oauth to v1.3.0","oid":"fd1b1feb222dde885d00262b2c21b107c6c6e416"}],"createdAt":"2026-10-20T16:40:41Z","deletions":48,"files":[{"path":"go.mod","additions":1,"deletions":1,"changeType":"MODIFIED"},{"path":"pkg/cmd/status/status.go","additions":3,"deletions":2,"changeType":"MODIFIED"}],"headRefName":"babakks/refresh-token-bump-deps","headRefOid":"6ec1ba54542baca6cc5932582634104c83f36a28","isCrossRepository":false,"isDraft":false,"labels":[],"mergeable":"UNKNOWN","mergedAt":"2026-10-21T12:29:58Z","number":14768,"reviewDecision":"APPROVED","reviewRequests":[{"__typename":"User","login":"niik"}],"reviews":[{"id":"PRR_1","author":{"login":"copilot-pull-request-reviewer"},"authorAssociation":"CONTRIBUTOR","body":"","submittedAt":"2026-10-21T09:46:01Z","includesCreatedEdit":false,"reactionGroups":[],"state":"COMMENTED","commit":{"oid":"831b9343fa77c2f11f521a49fd01da2d2d007b8e"}},{"id":"PRR_2","author":{"login":"williammartin"},"authorAssociation":"MEMBER","body":"LGTM","submittedAt":"2026-10-21T12:20:00Z","includesCreatedEdit":false,"reactionGroups":[],"state":"APPROVED","commit":{"oid":"6ec1ba54542baca6cc5932582634104c83f36a28"}}],"state":"MERGED","statusCheckRollup":[{"__typename":"CheckRun","completedAt":"2026-10-21T12:09:03Z","conclusion":"SUCCESS","detailsUrl":"https://github.com/cli/cli/actions/runs/1/job/1","name":"build (ubuntu)","startedAt":"2026-10-21T12:07:01Z","status":"COMPLETED","workflowName":"Tests"},{"__typename":"CheckRun","completedAt":"0001-01-01T00:00:00Z","conclusion":"","detailsUrl":"https://github.com/cli/cli/actions/runs/1/job/2","name":"build (windows)","startedAt":"2026-10-21T12:07:01Z","status":"IN_PROGRESS","workflowName":"Tests"},{"__typename":"CheckRun","completedAt":"2026-10-21T12:08:00Z","conclusion":"FAILURE","detailsUrl":"https://github.com/cli/cli/actions/runs/1/job/3","name":"lint","startedAt":"2026-10-21T12:07:01Z","status":"COMPLETED","workflowName":"Lint"},{"__typename":"CheckRun","completedAt":"2026-10-21T12:08:00Z","conclusion":"SKIPPED","detailsUrl":"","name":"docs","startedAt":"2026-10-21T12:07:01Z","status":"COMPLETED","workflowName":""},{"__typename":"StatusContext","context":"ci/legacy","state":"PENDING","targetUrl":"https://ci.example.com/1","startedAt":"2026-10-21T12:07:01Z"}],"title":"Refreshable tokens: bump cli/oauth to v1.3.0 and fix `gh status` column width","updatedAt":"2026-10-21T12:30:00Z","url":"https://github.com/cli/cli/pull/14768"}
{"additions":42,"assignees":[],"author":{"id":"MDQ6VXNlcjM2NzI4OTMx","is_bot":false,"login":"babakks","name":"Babak K. Shandiz"},"baseRefName":"trunk","baseRefOid":"ae7b9715976c5508398b11dc8546e1279e10f6fc","body":"### Description\n\nThis PR moves gh to the tagged version of `cli/oauth`.","closedAt":"2026-10-21T12:29:58Z","comments":[{"id":"IC_1","author":{"login":"babakks"},"authorAssociation":"MEMBER","body":"Rebased on trunk.","createdAt":"2026-10-21T08:00:00Z","includesCreatedEdit":false,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/cli/cli/pull/14768#issuecomment-1","viewerDidAuthor":false}],"commits":[{"authoredDate":"2026-10-20T16:37:24Z","authors":[{"email":"[email protected]","id":"MDQ6VXNlcjM2NzI4OTMx","login":"babakks","name":"Babak K. Shandiz"}],"committedDate":"2026-10-21T12:06:51Z","messageBody":"cli/oauth v1.3.0 exposes the token expiry fields as time.Duration values.","messageHeadline":"chore(deps): bump github.com/cli/oauth to v1.3.0","oid":"fd1b1feb222dde885d00262b2c21b107c6c6e416"}],"createdAt":"2026-10-20T16:40:41Z","deletions":48,"files":[{"path":"go.mod","additions":1,"deletions":1,"changeType":"MODIFIED"},{"path":"pkg/cmd/status/status.go","additions":3,"deletions":2,"changeType":"MODIFIED"}],"headRefName":"babakks/refresh-token-bump-deps","headRefOid":"6ec1ba54542baca6cc5932582634104c83f36a28","isCrossRepository":false,"isDraft":false,"labels":[],"mergeable":"UNKNOWN","mergeStateStatus":"UNKNOWN","mergedAt":"2026-10-21T12:29:58Z","number":14768,"reviewDecision":"APPROVED","reviewRequests":[{"__typename":"User","login":"niik"}],"reviews":[{"id":"PRR_1","author":{"login":"copilot-pull-request-reviewer"},"authorAssociation":"CONTRIBUTOR","body":"","submittedAt":"2026-10-21T09:46:01Z","includesCreatedEdit":false,"reactionGroups":[],"state":"COMMENTED","commit":{"oid":"831b9343fa77c2f11f521a49fd01da2d2d007b8e"}},{"id":"PRR_2","author":{"login":"williammartin"},"authorAssociation":"MEMBER","body":"LGTM","submittedAt":"2026-10-21T12:20:00Z","includesCreatedEdit":false,"reactionGroups":[],"state":"APPROVED","commit":{"oid":"6ec1ba54542baca6cc5932582634104c83f36a28"}}],"state":"MERGED","statusCheckRollup":[{"__typename":"CheckRun","completedAt":"2026-10-21T12:09:03Z","conclusion":"SUCCESS","detailsUrl":"https://github.com/cli/cli/actions/runs/1/job/1","name":"build (ubuntu)","startedAt":"2026-10-21T12:07:01Z","status":"COMPLETED","workflowName":"Tests"},{"__typename":"CheckRun","completedAt":"0001-01-01T00:00:00Z","conclusion":"","detailsUrl":"https://github.com/cli/cli/actions/runs/1/job/2","name":"build (windows)","startedAt":"2026-10-21T12:07:01Z","status":"IN_PROGRESS","workflowName":"Tests"},{"__typename":"CheckRun","completedAt":"2026-10-21T12:08:00Z","conclusion":"FAILURE","detailsUrl":"https://github.com/cli/cli/actions/runs/1/job/3","name":"lint","startedAt":"2026-10-21T12:07:01Z","status":"COMPLETED","workflowName":"Lint"},{"__typename":"CheckRun","completedAt":"2026-10-21T12:08:00Z","conclusion":"SKIPPED","detailsUrl":"","name":"docs","startedAt":"2026-10-21T12:07:01Z","status":"COMPLETED","workflowName":""},{"__typename":"StatusContext","context":"ci/legacy","state":"PENDING","targetUrl":"https://ci.example.com/1","startedAt":"2026-10-21T12:07:01Z"}],"title":"Refreshable tokens: bump cli/oauth to v1.3.0 and fix `gh status` column width","updatedAt":"2026-10-21T12:30:00Z","url":"https://github.com/cli/cli/pull/14768"}
+32
View File
@@ -22,6 +22,7 @@ fn pr_view() {
assert_eq!(pr.summary.number, 14768);
assert_eq!(pr.summary.head_ref, "babakks/refresh-token-bump-deps");
assert_eq!(pr.head_oid, "6ec1ba54542baca6cc5932582634104c83f36a28");
assert_eq!(pr.merge_state_status, "UNKNOWN");
assert_eq!(pr.files.len(), 2);
assert_eq!(pr.files[0].change_type, "MODIFIED");
assert_eq!(pr.commits[0].headline, "chore(deps): bump github.com/cli/oauth to v1.3.0");
@@ -69,6 +70,16 @@ fn repo_view() {
.unwrap();
assert_eq!(r.name_with_owner, "l0ng-ai/openidea");
assert_eq!(r.default_branch.as_deref(), Some("main"));
assert!(r.merge_commit_allowed && r.squash_merge_allowed && r.rebase_merge_allowed, "unknown: allowed");
assert_eq!(r.viewer_permission, "");
let r = parse_repo(
r#"{"mergeCommitAllowed":true,"nameWithOwner":"o/r","url":"","rebaseMergeAllowed":false,"squashMergeAllowed":false,"viewerPermission":"WRITE"}"#,
)
.unwrap();
assert!(r.merge_commit_allowed);
assert!(!r.squash_merge_allowed && !r.rebase_merge_allowed);
assert_eq!(r.viewer_permission, "WRITE");
}
#[test]
@@ -87,3 +98,24 @@ fn list_arguments() {
assert!(joined.contains("--limit 500 --state merged --author @me"));
assert_eq!(args.last().map(String::as_str), Some("review-requested:@me fix login"));
}
#[test]
fn api_arguments() {
let req =
GhApiRequest { method: "post".into(), endpoint: "graphql".into(), body: Some("{}".into()), paginate: false };
assert_eq!(api_args(&req).unwrap(), ["api", "--method", "POST", "graphql", "--input", "-"]);
let req = GhApiRequest {
method: "GET".into(),
endpoint: "repos/{owner}/{repo}/labels".into(),
body: None,
paginate: true,
};
assert_eq!(
api_args(&req).unwrap(),
["api", "--method", "GET", "repos/{owner}/{repo}/labels", "--paginate", "--slurp"]
);
for bad in ["--hostname", "https://evil.example/x", ""] {
assert!(api_args(&GhApiRequest { endpoint: bad.into(), ..req.clone() }).is_err(), "{bad}");
}
assert!(api_args(&GhApiRequest { method: "TRACE".into(), ..req }).is_err());
}
+11 -1
View File
@@ -44,6 +44,7 @@ struct State {
project: Changes,
/// Project roots whose `postStartCommand` ran in this process.
post_started: HashSet<PathBuf>,
post_commands: std::collections::HashMap<PathBuf, CancellationToken>,
}
pub struct Environment {
@@ -78,7 +79,12 @@ impl Environment {
pub(crate) fn project_closed(&self) {
if self.project_env {
self.state.lock().project.clear();
let mut state = self.state.lock();
for (_, token) in state.post_commands.drain() {
token.cancel();
}
state.project.clear();
drop(state);
self.publish();
}
}
@@ -144,6 +150,7 @@ impl Environment {
/// a failure is a notification.
pub(crate) fn spawn_post_start(&self, cmd: LifecycleCommand, root: PathBuf, sink: SharedSink) {
let cancel = self.cancel.child_token();
self.state.lock().post_commands.insert(root.clone(), cancel.clone());
let spawned = std::thread::Builder::new().name("openidea-post-start".into()).spawn(move || {
run_post_start(&cmd, &root, &sink, &cancel);
});
@@ -263,6 +270,9 @@ fn run_post_start(cmd: &LifecycleCommand, root: &Path, sink: &SharedSink, cancel
/// Runs `argv` in `root` with the children's environment, its stdout and stderr line by line
/// to `on_line`; `Err` says how it failed.
fn run_one(argv: &[String], root: &Path, cancel: &CancellationToken, on_line: &dyn Fn(String)) -> Result<(), String> {
if cancel.is_cancelled() {
return Err("cancelled".into());
}
let (program, args) = argv.split_first().ok_or("empty command")?;
let mut child = ide_core::process::prepare(&mut Command::new(program))
.args(args)
+21
View File
@@ -12,6 +12,7 @@ mod proxy;
mod requests;
pub mod rpc;
pub mod shell_env;
pub mod trust;
pub use project::{Project, apply_git_executable, apply_workspace_edit, history_retention};
@@ -44,6 +45,7 @@ pub struct HostConfig {
}
pub struct Host {
pub trust: trust::TrustStore,
pub config_dir: PathBuf,
pub data_dir: PathBuf,
pub languages: Arc<LanguageRegistry>,
@@ -67,6 +69,7 @@ pub struct Host {
/// One project per window, keyed by window label.
projects: RwLock<HashMap<String, Arc<Project>>>,
requests: requests::Requests,
project_requests: RwLock<HashMap<String, Vec<RequestId>>>,
}
impl Host {
@@ -77,6 +80,7 @@ impl Host {
let ssh = Arc::new(ide_ssh::SshManager::new(data_dir.join("ssh-passwords.json")));
ssh.set_configs(global_settings.effective().ssh.configs);
let host = Self {
trust: trust::TrustStore::load(&config_dir, &data_dir),
proxy_password: ide_ssh::secrets::SecretFile::new(data_dir.join("proxy-password.json")),
remote: Arc::new(ide_remote::RemoteService::new(ssh.clone(), data_dir.join("remote"))),
ssh,
@@ -95,6 +99,7 @@ impl Host {
runtime,
projects: RwLock::default(),
requests: Default::default(),
project_requests: RwLock::default(),
};
host.apply_proxy();
host
@@ -119,6 +124,10 @@ impl Host {
.ok_or_else(|| IdeError::not_found("no project is open in this window"))
}
pub fn require_trust(&self, window: &str) -> IdeResult<()> {
self.project(window)?.require_trust()
}
/// The window that owns what `window` starts.
pub fn owner_window<'a>(&self, window: &'a str) -> &'a str {
(self.owner)(window)
@@ -212,7 +221,19 @@ impl Host {
self.requests.begin(id)
}
pub fn begin_project_request(&self, window: &str, id: RequestId) -> CancellationToken {
let token = self.begin_request(id);
self.project_requests.write().entry(self.owner_window(window).to_owned()).or_default().push(id);
if self.project(window).is_ok_and(|p| !p.is_trusted()) {
token.cancel();
}
token
}
pub fn end_request(&self, id: RequestId) {
for ids in self.project_requests.write().values_mut() {
ids.retain(|entry| *entry != id);
}
self.requests.end(id);
}
+18
View File
@@ -407,6 +407,24 @@ impl NotebookService {
}
}
pub async fn restrict(&self) {
let documents: Vec<_> = self.registry.lock().documents.values().cloned().collect();
for document in &documents {
let mut state = document.state.lock();
state.generation += 1;
if let Some(batch) = &state.active {
batch.cancel.cancel();
}
}
for document in documents {
let _ = self.kernels.shutdown_kernel(&document.path).await;
let batch = document.state.lock().active.clone();
if let Err(error) = self.drain(&document, batch).await {
tracing::warn!("notebook restriction: {error}");
}
}
}
pub async fn shutdown(&self) {
self.stop_admission();
self.kernels.shutdown().await;
+12
View File
@@ -17,6 +17,7 @@ pub fn subscribe(host: &Host, window: &str, on_event: Emit<AgentEvent>) -> IdeRe
}
pub fn providers(host: &Host, window: &str) -> IdeResult<Vec<AgentProvider>> {
host.require_trust(window)?;
let mut providers = host.project(window)?.agents.providers();
ide_agent::registry::mark_updates(&registry_paths(host), &mut providers);
Ok(providers)
@@ -88,6 +89,7 @@ pub fn entries(host: &Host, window: &str, task: String, subagent: Option<String>
}
pub async fn create(host: &Host, window: &str, mut request: NewAgentTask) -> IdeResult<AgentTask> {
host.require_trust(window)?;
let project = host.project(window)?;
let root = project.root.clone();
let requested = request.project_root.take();
@@ -99,6 +101,7 @@ pub async fn create(host: &Host, window: &str, mut request: NewAgentTask) -> Ide
/// Sends a message; while the agent works it is queued, or with `now` it interrupts the turn.
pub async fn prompt(host: &Host, window: &str, task: String, prompt: AgentPrompt, now: bool) -> IdeResult<()> {
host.require_trust(window)?;
host.project(window)?.agents.prompt(&task, prompt, now).await
}
@@ -109,26 +112,31 @@ pub fn unqueue(host: &Host, window: &str, task: String, queued: String) -> IdeRe
/// Sends a queued message now, interrupting the current turn.
pub fn send_queued_now(host: &Host, window: &str, task: String, queued: String) -> IdeResult<()> {
host.require_trust(window)?;
host.project(window)?.agents.send_queued_now(&task, &queued)
}
/// Changes a session setting (model, mode, thinking effort…).
pub async fn set_config(host: &Host, window: &str, task: String, option: String, value: String) -> IdeResult<()> {
host.require_trust(window)?;
host.project(window)?.agents.set_config(&task, &option, value).await
}
/// Copies the task's conversation into a new task, up to its first `turns` turns (all when `None`).
pub async fn fork(host: &Host, window: &str, task: String, turns: Option<u32>) -> IdeResult<AgentTask> {
host.require_trust(window)?;
host.project(window)?.agents.fork(&task, turns).await
}
/// Drops the task's turns after its first `turns`; returns the message of the first dropped one.
pub async fn rollback(host: &Host, window: &str, task: String, turns: u32) -> IdeResult<Option<AgentPrompt>> {
host.require_trust(window)?;
host.project(window)?.agents.rollback(&task, turns).await
}
/// What the agent reports about itself when it starts, without opening a session.
pub async fn probe(host: &Host, window: &str, provider: String, refresh: bool) -> IdeResult<AgentProbe> {
host.require_trust(window)?;
host.project(window)?.agents.probe(&provider, refresh).await
}
@@ -139,6 +147,7 @@ pub fn cancel(host: &Host, window: &str, task: String, subagent: Option<String>)
/// Answers a permission request; `option: None` cancels it.
pub fn respond(host: &Host, window: &str, task: String, permission: u32, option: Option<String>) -> IdeResult<()> {
host.require_trust(window)?;
host.project(window)?.agents.respond(&task, permission, option)
}
@@ -151,15 +160,18 @@ pub fn unarchive(host: &Host, window: &str, task: String) -> IdeResult<()> {
}
pub async fn delete(host: &Host, window: &str, task: String, remove_worktree: bool) -> IdeResult<()> {
host.require_trust(window)?;
host.project(window)?.agents.delete(&task, remove_worktree).await
}
pub async fn changes(host: &Host, window: &str, task: String) -> IdeResult<Vec<AgentChange>> {
host.require_trust(window)?;
host.project(window)?.agents.changes(&task).await
}
/// Content of `path` when the task started (left side of its diff).
pub async fn base_text(host: &Host, window: &str, task: String, path: IpcPath) -> IdeResult<Option<String>> {
host.require_trust(window)?;
host.project(window)?.agents.base_text(&task, path.into_path_buf()).await
}
+4 -3
View File
@@ -39,12 +39,13 @@ fn load(project: &Project, languages: &LanguageRegistry, path: &Path) -> Option<
let snap = project.documents.text_snapshot(doc).ok()?;
return (!snap.large).then_some((snap.language, snap.text));
}
let loaded = ide_vfs::load_text(path).ok()?;
let loaded = project.documents.load_file(path).ok()?;
if loaded.binary || loaded.large || loaded.too_large {
return None;
}
let language = languages.detect(path, loaded.text.lines().next());
Some((language, loaded.text))
let text = ide_text::normalize_line_endings(&loaded.text).into_owned();
let language = languages.detect(path, text.lines().next());
Some((language, text))
}
// ---- Inspect Code ----
+5 -2
View File
@@ -76,6 +76,7 @@ pub fn load(root: &Path, cache: &Path, settings: &Settings) -> BuildModel {
/// Maven and Gradle projects of the window's project, from the build files plus the last sync.
pub async fn build_model(host: &Host, window: &str) -> IdeResult<BuildModel> {
host.require_trust(window)?;
let project = host.project(window)?;
let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective());
tokio::task::spawn_blocking(move || load(&root, &cache, &settings)).await.map_err(join_err)
@@ -93,9 +94,10 @@ pub async fn build_sync(
profiles: Vec<String>,
on_progress: Channel<SyncProgress>,
) -> IdeResult<BuildModel> {
host.require_trust(window)?;
let project = host.project(window)?;
let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective());
let token = host.begin_request(request);
let token = host.begin_project_request(window, request);
let result = tokio::task::spawn_blocking(move || {
let current = load(&root, &cache, &settings);
let env = match tool {
@@ -223,11 +225,12 @@ pub async fn build_run(
clear: Vec<std::path::PathBuf>,
on_event: Channel<BuildEvent>,
) -> IdeResult<BuildResult> {
host.require_trust(window)?;
let project = host.project(window)?;
let (root, cache, sink) = (project.root.clone(), project.build_cache_dir(), project.sink.clone());
let settings = project.settings.effective();
let config_dir = host.config_dir.clone();
let token = host.begin_request(request);
let token = host.begin_project_request(window, request);
let result = tokio::task::spawn_blocking(move || {
for path in clear {
sink.emit(IdeEvent::Diagnostics {
+3 -1
View File
@@ -16,6 +16,7 @@ pub async fn blocking<T: Send + 'static>(f: impl FnOnce() -> IdeResult<T> + Send
/// How to run `config` with coverage: the command to launch and where its report will be,
/// or which tool is missing.
pub async fn coverage_prepare(host: &Host, window: &str, config: RunConfig) -> IdeResult<CoveragePrepared> {
host.require_trust(window)?;
let project = host.project(window)?;
let (root, cache) = (project.root.clone(), project.cache_dir.join("coverage"));
blocking(move || {
@@ -26,7 +27,8 @@ pub async fn coverage_prepare(host: &Host, window: &str, config: RunConfig) -> I
}
/// Reads the report a coverage run left behind.
pub async fn coverage_collect(_host: &Host, _window: &str, report: CoverageReportSpec) -> IdeResult<CoverageSuite> {
pub async fn coverage_collect(host: &Host, window: &str, report: CoverageReportSpec) -> IdeResult<CoverageSuite> {
host.require_trust(window)?;
blocking(move || ide_coverage::collect(&report)).await
}
+19 -1
View File
@@ -8,6 +8,7 @@ use std::path::PathBuf;
/// Starts a session owned by this window: its events (and those of child sessions) go here only.
pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> IdeResult<DebugSessionId> {
host.require_trust(window)?;
let mut config = match_java_debug_encoding(config, cfg!(windows));
if config.adapter == "debugpy"
&& config.request == "launch"
@@ -22,6 +23,21 @@ pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> Ide
Ok(project) => project.settings.effective(),
Err(_) => host.global_settings.effective(),
};
if config.adapter == "delve" && config.request == "launch" {
let root = host.project(window).map(|p| p.root.clone()).unwrap_or_default();
let settings = settings.clone();
config = host
.blocking(move || {
ide_sdk::go::debug_environment(&root, &settings, &mut config.env);
ide_sdk::go::apply_to_debug(
&mut config.env,
&mut config.extra_json,
&settings.languages.go.build_tags,
)?;
Ok(config)
})
.await?;
}
if config.adapter == ide_dap::kotlin::ADAPTER {
let data_dir = host.data_dir.clone();
let runtime_root = config.cwd.clone().map(PathBuf::from).unwrap_or_default();
@@ -61,6 +77,7 @@ pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> Ide
let path = ide_sdk::apply::tool_command(&root, &settings, command)?;
Some(path.to_string_lossy().into_owned())
};
host.require_trust(window)?;
let result = host.debugger.start_with(config, owner, custom).await;
flush_output();
result
@@ -292,11 +309,12 @@ pub async fn debug_attachable_processes(_host: &Host, _window: &str) -> IdeResul
/// JVM to connect to `host:port`; returns the loopback port java-debug attaches to.
pub async fn debug_listen_for_jvm(
host: &Host,
_window: &str,
window: &str,
request: ide_core::RequestId,
jvm_host: String,
port: u16,
) -> IdeResult<u16> {
host.require_trust(window)?;
let token = host.begin_request(request);
let result = jdwp::listen_for_jvm(&jvm_host, port, token).await;
host.end_request(request);
+207
View File
@@ -0,0 +1,207 @@
use crate::Host;
use ide_core::{CancellationToken, IdeEvent, IdeResult, IpcPath, RequestId, SharedSink, TaskId};
use ide_remote::{DeployedPath, DeploymentCoverage, SyncEntry, SyncStep, TransferReport};
use ide_ssh::{FsConnection, RemoteEntry};
use std::path::PathBuf;
/// Reports a transfer as a background task (status bar progress) whose Cancel fires `token`.
fn progress(
sink: SharedSink,
title: &str,
token: CancellationToken,
) -> (impl FnMut(&str, usize, usize) + Send, impl FnOnce()) {
let id = TaskId::next();
let cancel = token;
let handle = ide_core::task::on_cancel(id, move || cancel.cancel());
let title = title.to_owned();
let (s, t) = (sink.clone(), title.clone());
let report = move |path: &str, i: usize, n: usize| {
let fraction = if n == 0 { None } else { Some(i as f32 / n as f32) };
let message = Some(path.to_owned());
s.emit(IdeEvent::Task { id, title: t.clone(), message, fraction, done: false, cancellable: true });
};
let done = move || {
drop(handle);
sink.emit(IdeEvent::Task { id, title, message: None, fraction: Some(1.0), done: true, cancellable: false })
};
(report, done)
}
fn local(paths: Vec<IpcPath>) -> Vec<PathBuf> {
paths.into_iter().map(|p| p.to_path_buf()).collect()
}
/// Eligible paths per server, without contacting any deployment host.
pub async fn deploy_coverage(window: &str, state: &Host, paths: Vec<IpcPath>) -> IdeResult<Vec<DeploymentCoverage>> {
let project = state.project(window)?;
Ok(ide_remote::coverage(&project.settings.effective(), &project.root, &local(paths)))
}
/// Tools › Deployment › Upload to…: files and folders to `server` (default server when `None`).
pub async fn deploy_upload(
window: &str,
state: &Host,
paths: Vec<IpcPath>,
server: Option<String>,
request: RequestId,
) -> IdeResult<TransferReport> {
let project = state.project(window)?;
let token = state.begin_request(request);
let (mut report, done) = progress(project.sink.clone(), "Uploading", token.clone());
let r = state
.remote
.upload(&project.settings.effective(), &project.root, server.as_deref(), &local(paths), token, &mut report)
.await;
done();
state.end_request(request);
r
}
/// Download from…: the remote counterparts of files and folders.
pub async fn deploy_download(
window: &str,
state: &Host,
paths: Vec<IpcPath>,
server: Option<String>,
request: RequestId,
) -> IdeResult<TransferReport> {
let project = state.project(window)?;
let token = state.begin_request(request);
let (mut report, done) = progress(project.sink.clone(), "Downloading", token.clone());
let r = state
.remote
.download(&project.settings.effective(), &project.root, server.as_deref(), &local(paths), token, &mut report)
.await;
done();
state.end_request(request);
r
}
/// Deletes the remote counterparts of files and folders.
pub async fn deploy_delete_remote(
window: &str,
state: &Host,
paths: Vec<IpcPath>,
server: Option<String>,
) -> IdeResult<TransferReport> {
let project = state.project(window)?;
state.remote.delete_remote(&project.settings.effective(), &project.root, server.as_deref(), &local(paths)).await
}
/// Sync with Deployed: local and remote files under `path`, side by side.
pub async fn deploy_compare(
window: &str,
state: &Host,
path: IpcPath,
server: Option<String>,
request: RequestId,
) -> IdeResult<Vec<SyncEntry>> {
let project = state.project(window)?;
let token = state.begin_request(request);
let r = state.remote.compare(&project.settings.effective(), &project.root, server.as_deref(), &path, token).await;
state.end_request(request);
r
}
/// Applies the Sync with Deployed choices.
pub async fn deploy_sync(
window: &str,
state: &Host,
steps: Vec<SyncStep>,
server: Option<String>,
request: RequestId,
) -> IdeResult<TransferReport> {
let project = state.project(window)?;
let token = state.begin_request(request);
let (mut report, done) = progress(project.sink.clone(), "Synchronizing", token.clone());
let r = state
.remote
.sync(&project.settings.effective(), &project.root, server.as_deref(), &steps, token, &mut report)
.await;
done();
state.end_request(request);
r
}
/// Where `path` is deployed (remote path, web URL); `None` when not mapped.
pub async fn deploy_info(
window: &str,
state: &Host,
path: IpcPath,
server: Option<String>,
) -> IdeResult<Option<DeployedPath>> {
let project = state.project(window)?;
state.remote.deployed(&project.settings.effective(), &project.root, server.as_deref(), &path).await
}
/// Remote Host tool window: the server root of `server`, resolved on the host.
pub async fn deploy_server_root(window: &str, state: &Host, server: String) -> IdeResult<String> {
let settings = match state.project(window) {
Ok(p) => p.settings.effective(),
Err(_) => state.global_settings.effective(),
};
state.remote.server_root(&settings, &server).await
}
/// Entries of a folder on the selected filesystem (`~`: its home directory).
pub async fn remote_list(state: &Host, connection: FsConnection, path: String) -> IdeResult<Vec<RemoteEntry>> {
state.remote.list(&connection, &path).await
}
/// Opens a remote file for editing: a local copy, uploaded back on save.
pub async fn remote_open(state: &Host, connection: FsConnection, path: String) -> IdeResult<IpcPath> {
Ok(state.remote.open_remote(&connection, &path).await?.into())
}
/// The filesystem connection and path a cached editing copy stands for.
pub fn remote_of(state: &Host, path: IpcPath) -> Option<(FsConnection, String)> {
state.remote.remote_of(&path)
}
pub async fn remote_delete(state: &Host, connection: FsConnection, path: String) -> IdeResult<()> {
state.remote.remote_delete(&connection, &path).await
}
pub async fn remote_mkdir(state: &Host, connection: FsConnection, path: String) -> IdeResult<()> {
state.remote.remote_mkdir(&connection, &path).await
}
pub async fn remote_rename(state: &Host, connection: FsConnection, from: String, to: String) -> IdeResult<()> {
state.remote.remote_rename(&connection, &from, &to).await
}
pub async fn deploy_test_connection(state: &Host, connection: FsConnection, path: String) -> IdeResult<()> {
state.remote.test_connection(&connection, &path).await
}
pub(crate) fn register(d: &mut crate::rpc::Dispatcher) {
d.add("deploy_coverage", |c| async move { deploy_coverage(&c.window, &c.host, c.arg("paths")?).await });
d.add("deploy_upload", |c| async move {
deploy_upload(&c.window, &c.host, c.arg("paths")?, c.arg("server")?, c.arg("request")?).await
});
d.add("deploy_download", |c| async move {
deploy_download(&c.window, &c.host, c.arg("paths")?, c.arg("server")?, c.arg("request")?).await
});
d.add("deploy_delete_remote", |c| async move {
deploy_delete_remote(&c.window, &c.host, c.arg("paths")?, c.arg("server")?).await
});
d.add("deploy_compare", |c| async move {
deploy_compare(&c.window, &c.host, c.arg("path")?, c.arg("server")?, c.arg("request")?).await
});
d.add("deploy_sync", |c| async move {
deploy_sync(&c.window, &c.host, c.arg("steps")?, c.arg("server")?, c.arg("request")?).await
});
d.add("deploy_info", |c| async move { deploy_info(&c.window, &c.host, c.arg("path")?, c.arg("server")?).await });
d.add("deploy_server_root", |c| async move { deploy_server_root(&c.window, &c.host, c.arg("server")?).await });
d.add("remote_list", |c| async move { remote_list(&c.host, c.arg("connection")?, c.arg("path")?).await });
d.add("remote_open", |c| async move { remote_open(&c.host, c.arg("connection")?, c.arg("path")?).await });
d.add("remote_of", |c| async move { Ok(remote_of(&c.host, c.arg("path")?)) });
d.add("remote_delete", |c| async move { remote_delete(&c.host, c.arg("connection")?, c.arg("path")?).await });
d.add("remote_mkdir", |c| async move { remote_mkdir(&c.host, c.arg("connection")?, c.arg("path")?).await });
d.add("remote_rename", |c| async move {
remote_rename(&c.host, c.arg("connection")?, c.arg("from")?, c.arg("to")?).await
});
d.add("deploy_test_connection", |c| async move {
deploy_test_connection(&c.host, c.arg("connection")?, c.arg("path")?).await
});
}
+15
View File
@@ -38,6 +38,18 @@ pub async fn save_all(host: &Host, window: &str) -> IdeResult<()> {
host.debugger.refresh_source_mirrors(host.owner_window(window)).await
}
/// Recovers external changes missed by the project's file watchers.
pub async fn sync_from_disk(host: &Host, window: &str) -> IdeResult<()> {
let project = host.project(window)?;
host.blocking(move || {
if project.settings.effective().files.sync_on_focus {
project.documents.sync_from_disk();
}
Ok(())
})
.await
}
pub async fn snapshot(host: &Host, window: &str, doc: DocId) -> IdeResult<DocumentInfo> {
let project = host.project(window)?;
host.blocking(move || project.documents.snapshot(doc)).await
@@ -45,3 +57,6 @@ pub async fn snapshot(host: &Host, window: &str, doc: DocId) -> IdeResult<Docume
mod commands;
pub use commands::*;
#[cfg(test)]
mod tests;
+10 -3
View File
@@ -30,8 +30,15 @@ pub async fn doc_ui_mode(host: &Host, window: &str, path: PathBuf) -> IdeResult<
}
/// "Reload with Encoding" (`reload`) or "Convert to Encoding"; returns the new encoding label.
pub fn doc_set_encoding(host: &Host, window: &str, doc: DocId, encoding: String, reload: bool) -> IdeResult<String> {
host.project(window)?.documents.set_encoding(doc, &encoding, reload)
pub async fn doc_set_encoding(
host: &Host,
window: &str,
doc: DocId,
encoding: String,
reload: bool,
) -> IdeResult<String> {
let project = host.project(window)?;
host.blocking(move || project.documents.set_encoding(doc, &encoding, reload)).await
}
pub fn doc_set_line_ending(host: &Host, window: &str, doc: DocId, line_ending: LineEnding) -> IdeResult<()> {
@@ -117,7 +124,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("doc_open_virtual", |c| async move { doc_open_virtual(&c.host, &c.window, c.arg("uri")?).await });
d.add("doc_ui_mode", |c| async move { doc_ui_mode(&c.host, &c.window, c.arg("path")?).await });
d.add("doc_set_encoding", |c| async move {
doc_set_encoding(&c.host, &c.window, c.arg("doc")?, c.arg("encoding")?, c.arg("reload")?)
doc_set_encoding(&c.host, &c.window, c.arg("doc")?, c.arg("encoding")?, c.arg("reload")?).await
});
d.add("doc_set_line_ending", |c| async move {
doc_set_line_ending(&c.host, &c.window, c.arg("doc")?, c.arg("lineEnding")?)
+200
View File
@@ -0,0 +1,200 @@
use super::*;
use crate::{HostConfig, Project};
use ide_core::{IdeEvent, TextChange, event::NotifyLevel};
use parking_lot::Mutex;
use std::{path::Path, sync::Arc};
struct Fixture {
host: Host,
project: Arc<Project>,
events: Arc<Mutex<Vec<IdeEvent>>>,
path: PathBuf,
outside: PathBuf,
_temp: tempfile::TempDir,
}
impl Fixture {
fn new() -> Self {
let temp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(temp.path()).unwrap();
let root = base.join("project");
// Keep watcher delivery out of these tests: the op must recover it itself.
let excluded = root.join("node_modules");
std::fs::create_dir_all(&excluded).unwrap();
let path = excluded.join("file.txt");
let outside = base.join("outside.txt");
std::fs::write(&path, "original\n").unwrap();
std::fs::write(&outside, "outside\n").unwrap();
let events = Arc::new(Mutex::new(Vec::new()));
let captured = events.clone();
let host = Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(move |_| {
let events = captured.clone();
Arc::new(move |event| events.lock().push(event))
}),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
let project = host.open_project("main", &root).unwrap();
Self { _temp: temp, host, project, events, path, outside }
}
async fn open(&self, path: &Path) -> DocumentInfo {
open(&self.host, "main", path.to_path_buf()).await.unwrap()
}
async fn sync(&self) {
sync_from_disk(&self.host, "main").await.unwrap();
}
fn document_events(&self) -> Vec<IdeEvent> {
self.events
.lock()
.iter()
.filter(|e| matches!(e, IdeEvent::DocPatched { .. } | IdeEvent::DocDirty { .. } | IdeEvent::Notify { .. }))
.cloned()
.collect()
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn changed_clean_document_reloads_once() {
let f = Fixture::new();
let original = f.open(&f.path).await;
std::fs::write(&f.path, "externally changed\n").unwrap();
f.sync().await;
let loaded = f.project.documents.snapshot(original.id).unwrap();
assert_eq!(loaded.text, "externally changed\n");
assert!(!loaded.dirty);
assert!(loaded.version > original.version);
f.events.lock().clear();
f.sync().await;
assert_eq!(f.project.documents.snapshot(original.id).unwrap().version, loaded.version);
assert!(f.document_events().is_empty());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn unchanged_document_has_no_patch_or_event() {
let f = Fixture::new();
let original = f.open(&f.path).await;
f.events.lock().clear();
f.sync().await;
let loaded = f.project.documents.snapshot(original.id).unwrap();
assert_eq!(loaded.text, original.text);
assert_eq!(loaded.version, original.version);
assert!(f.document_events().is_empty());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn dirty_document_keeps_edits_and_warns_once() {
let f = Fixture::new();
let original = f.open(&f.path).await;
edit(
&f.host,
"main",
original.id,
original.version,
ChangeSet { changes: vec![TextChange { from: 0, to: 0, insert: "local ".into() }] },
)
.unwrap();
f.events.lock().clear();
std::fs::write(&f.path, "external replacement\n").unwrap();
f.sync().await;
let loaded = f.project.documents.snapshot(original.id).unwrap();
assert_eq!(loaded.text, "local original\n");
assert!(loaded.dirty);
assert!(matches!(f.document_events().as_slice(), [IdeEvent::Notify { level: NotifyLevel::Warning, .. }]));
f.events.lock().clear();
f.sync().await;
assert!(f.document_events().is_empty());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn deleted_document_keeps_its_buffer_and_other_documents_still_reload() {
let f = Fixture::new();
let original = f.open(&f.path).await;
let outside = f.open(&f.outside).await;
std::fs::remove_file(&f.path).unwrap();
std::fs::write(&f.outside, "outside changed after deletion\n").unwrap();
f.sync().await;
let loaded = f.project.documents.snapshot(original.id).unwrap();
assert_eq!(loaded.text, original.text);
assert_eq!(loaded.version, original.version);
assert_eq!(f.project.documents.snapshot(outside.id).unwrap().text, "outside changed after deletion\n");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn outside_project_document_reloads() {
let f = Fixture::new();
assert!(!f.outside.starts_with(&f.project.root));
let original = f.open(&f.outside).await;
std::fs::write(&f.outside, "outside changed externally\n").unwrap();
f.sync().await;
assert_eq!(f.project.documents.snapshot(original.id).unwrap().text, "outside changed externally\n");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn setting_defaults_on_and_disabled_sync_keeps_buffer() {
let f = Fixture::new();
assert!(f.project.settings.effective().files.sync_on_focus);
assert!(serde_json::from_value::<ide_core::settings::FileSettings>(serde_json::json!({})).unwrap().sync_on_focus);
let original = f.open(&f.outside).await;
f.project.settings.set(ide_workspace::settings::SettingsScope::Project, "files.syncOnFocus", false.into()).unwrap();
std::fs::write(&f.outside, "external change while disabled\n").unwrap();
f.sync().await;
assert_eq!(f.project.documents.snapshot(original.id).unwrap().version, original.version);
f.project.settings.set(ide_workspace::settings::SettingsScope::Project, "files.syncOnFocus", true.into()).unwrap();
f.sync().await;
assert_eq!(f.project.documents.snapshot(original.id).unwrap().text, "external change while disabled\n");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn properties_reload_preserves_native_to_ascii_decoding() {
let f = Fixture::new();
let path = f.outside.with_extension("properties");
std::fs::write(&path, "key=\\u4F60\n").unwrap();
let mut defaults = f.project.documents.file_defaults();
defaults.native_to_ascii = true;
f.project.documents.set_file_defaults(defaults);
let original = f.open(&path).await;
assert_eq!(original.text, "key=你\n");
std::fs::write(&path, "key=\\u4E16\\u754C\n").unwrap();
f.sync().await;
assert_eq!(f.project.documents.snapshot(original.id).unwrap().text, "key=世界\n");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn binary_and_virtual_documents_are_skipped() {
let f = Fixture::new();
let path = f.outside.with_extension("bin");
std::fs::write(&path, b"\0\0binary payload").unwrap();
let binary = f.open(&path).await;
assert!(binary.read_only);
let virtual_doc =
f.project.documents.open_virtual("test://virtual", ide_core::LanguageId::plain_text(), "virtual").unwrap();
std::fs::write(&path, "now ordinary text").unwrap();
f.events.lock().clear();
f.sync().await;
assert_eq!(f.project.documents.snapshot(binary.id).unwrap().version, binary.version);
assert_eq!(f.project.documents.snapshot(virtual_doc.id).unwrap().text, "virtual");
assert!(f.document_events().is_empty());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn synchronization_only_changes_the_calling_windows_documents() {
let f = Fixture::new();
let original = f.open(&f.outside).await;
let other_root = f.outside.parent().unwrap().join("other-project");
std::fs::create_dir(&other_root).unwrap();
let other = f.host.open_project("other", &other_root).unwrap();
let other_doc = open(&f.host, "other", f.outside.clone()).await.unwrap();
std::fs::write(&f.outside, "external change in shared file\n").unwrap();
f.sync().await;
assert_eq!(f.project.documents.snapshot(original.id).unwrap().text, "external change in shared file\n");
assert_eq!(other.documents.snapshot(other_doc.id).unwrap().text, other_doc.text);
}
+1 -1
View File
@@ -9,7 +9,7 @@ use std::sync::Arc;
/// Re-reads the login shell (and the window's project `remoteEnv` where it applies);
/// running terminals and processes keep their environment.
pub async fn reload(host: &Arc<Host>, window: &str) -> IdeResult<EnvReloaded> {
let root = host.project(window).ok().filter(|p| !p.light).map(|p| p.root.clone());
let root = host.project(window).ok().filter(|p| !p.light && p.is_trusted()).map(|p| p.root.clone());
let sink = host.window_sink(window);
let h = host.clone();
host.blocking(move || h.environment.reload(root.as_deref(), &sink)).await
+4
View File
@@ -49,6 +49,10 @@ pub fn create_file(host: &Host, window: &str, path: PathBuf) -> IdeResult<()> {
}
}
pub async fn create_file_bytes(host: &Host, path: PathBuf, data: Vec<u8>) -> IdeResult<()> {
host.blocking(move || ide_vfs::create_file_bytes(&path, &data)).await
}
/// Toggle Read-only Attribute: sets the writable bit (and the open document's
/// state). Returns whether the file is read-only now.
pub fn set_writable(host: &Host, window: &str, path: PathBuf, writable: bool) -> IdeResult<bool> {
+64 -47
View File
@@ -20,7 +20,8 @@ pub async fn java_resolve_launch(
build: bool,
request: RequestId,
) -> IdeResult<JavaLaunchInfo> {
let cancel = host.begin_request(request);
host.require_trust(window)?;
let cancel = host.begin_project_request(window, request);
let result = resolve_launch(host, window, module, test, build, &cancel).await;
host.end_request(request);
result
@@ -34,62 +35,77 @@ async fn resolve_launch(
build: bool,
cancel: &CancellationToken,
) -> IdeResult<JavaLaunchInfo> {
let started = std::time::Instant::now();
let project = host.project(window)?;
let module = project.root.join(module);
let settings = project.settings.effective();
// Let 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, &cancel)
})
.await
.map_err(|e| IdeError::internal(e.to_string()))??
} else {
Vec::new()
};
let mut class_paths: Vec<String> = built.iter().map(|p| p.to_string_lossy().into_owned()).collect();
for p in std::mem::take(&mut info.class_paths) {
if !class_paths.contains(&p) {
class_paths.push(p);
let lsp_ms = started.elapsed().as_millis();
let data_dir = host.data_dir.clone();
let cancel = cancel.clone();
tokio::task::spawn_blocking(move || {
let built = if build && compile::has_non_java_sources(&module) {
compile::build_for_launch(
&project.root,
&load(&project.root, &project.build_cache_dir(), &settings),
&module,
test,
&cancel,
)?
} else {
Vec::new()
};
let mut class_paths: Vec<String> = built.iter().map(|p| p.to_string_lossy().into_owned()).collect();
for p in std::mem::take(&mut info.class_paths) {
if !class_paths.contains(&p) {
class_paths.push(p);
}
}
}
info.class_paths = class_paths;
// The project JDK, else the JDK jdtls compiles the module against, else one matching its Java version.
let project_jdk = ide_sdk::apply::project_jdk(&project.root, &settings);
let from_jdtls = info.java_home.as_deref().map(ide_sdk::java::java_executable).filter(|p| p.is_file());
let java = project_jdk.map(|j| ide_sdk::java::java_executable(&j.home)).or(from_jdtls).or_else(|| {
let release = ide_sdk::java::project_java_version(&module);
let jdks = ide_sdk::java::installed_jdks();
info.hints.extend(ide_sdk::java::missing_jdk_message(&jdks, release));
ide_sdk::java::pick_jdk(&jdks, release).map(|j| ide_sdk::java::java_executable(&j.home))
});
info.java_exec = Some(java.map(|p| p.to_string_lossy().into_owned()).unwrap_or_else(|| "java".to_owned()));
let exec = PathBuf::from(info.java_exec.as_deref().unwrap_or("java"));
let shorten = ide_run::java_cmdline::shortening_for(&exec, &info.class_paths, &info.module_paths, cfg!(windows));
info.shorten_command_line = Some(shorten.as_str().to_owned());
if info.error_count > 0 {
let own = ide_sdk::lombok::agent_in_args(settings.languages.java.jdtls_vm_args.split_whitespace());
let managed = ide_tools::managed_bundle(&host.data_dir, "lombok");
let repo = ide_sdk::apply::maven_local_repository(&project.root, &settings);
let root = project.root.clone();
// Detection may read the project's sources.
let lombok = tokio::task::spawn_blocking(move || ide_sdk::lombok::lombok_agent(&root, own, managed, repo))
.await
.map_err(|e| IdeError::internal(e.to_string()))?;
info.hints.extend(lombok.problem());
}
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
Ok(info)
info.class_paths = class_paths;
let compile_ms = started.elapsed().as_millis() - lsp_ms;
// The project JDK, else the JDK jdtls compiles the module against, else one matching its Java version.
let project_jdk = ide_sdk::apply::project_jdk(&project.root, &settings);
let from_jdtls = info.java_home.as_deref().map(ide_sdk::java::java_executable).filter(|p| p.is_file());
let java = project_jdk.map(|j| ide_sdk::java::java_executable(&j.home)).or(from_jdtls).or_else(|| {
let release = ide_sdk::java::project_java_version(&module);
let jdks = ide_sdk::java::installed_jdks();
info.hints.extend(ide_sdk::java::missing_jdk_message(&jdks, release));
ide_sdk::java::pick_jdk(&jdks, release).map(|j| ide_sdk::java::java_executable(&j.home))
});
info.java_exec = Some(java.map(|p| p.to_string_lossy().into_owned()).unwrap_or_else(|| "java".to_owned()));
let exec = PathBuf::from(info.java_exec.as_deref().unwrap_or("java"));
let shorten =
ide_run::java_cmdline::shortening_for(&exec, &info.class_paths, &info.module_paths, cfg!(windows));
info.shorten_command_line = Some(shorten.as_str().to_owned());
if info.error_count > 0 {
let own = ide_sdk::lombok::agent_in_args(settings.languages.java.jdtls_vm_args.split_whitespace());
let managed = ide_tools::managed_bundle(&data_dir, "lombok");
let repo = ide_sdk::apply::maven_local_repository(&project.root, &settings);
let root = project.root.clone();
// Detection may read the project's sources.
let lombok = ide_sdk::lombok::lombok_agent(&root, own, managed, repo);
info.hints.extend(lombok.problem());
}
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
tracing::info!(
lsp_ms,
compile_ms,
jdk_ms = started.elapsed().as_millis() - lsp_ms - compile_ms,
total_ms = started.elapsed().as_millis(),
"Java launch resolved"
);
Ok(info)
})
.await
.map_err(|e| IdeError::internal(e.to_string()))?
}
/// Port of java-debug's DAP server inside the jdtls serving `module` (started on demand).
pub async fn java_debug_server(host: &Host, window: &str, module: PathBuf) -> IdeResult<u16> {
host.require_trust(window)?;
let project = host.project(window)?;
let module = project.root.join(module);
project.lsp.java_debug_server(&module).await
@@ -101,6 +117,7 @@ pub async fn java_build_workspace(
window: &str,
module: PathBuf,
) -> IdeResult<ide_lsp::jdtls::JavaBuildStatus> {
host.require_trust(window)?;
let project = host.project(window)?;
let module = project.root.join(module);
project.lsp.java_build(&module, &CancellationToken::new()).await
+102
View File
@@ -0,0 +1,102 @@
//! Bounded, read-only JVM presentation for visible files, also served remotely.
use crate::Host;
use ide_core::{CancellationToken, IdeError, IdeResult, LanguageId};
use ide_syntax::{JvmPresentation, Snapshot};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::{
collections::HashMap,
fs::File,
io::Read,
path::PathBuf,
sync::{Mutex, OnceLock},
time::SystemTime,
};
pub const PREFIX_BYTES: u64 = 64 * 1024;
const MAX_BATCH: usize = 64;
const MAX_CACHE: usize = 4096;
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct JvmFilePresentation {
pub path: PathBuf,
pub presentation: JvmPresentation,
}
#[derive(Clone)]
struct Cached {
modified: SystemTime,
size: u64,
presentation: JvmPresentation,
}
fn prefix(path: &PathBuf) -> std::io::Result<String> {
let mut bytes = Vec::new();
File::open(path)?.take(PREFIX_BYTES).read_to_end(&mut bytes)?;
// A truncated final UTF-8 character must not invalidate the entire prefix.
Ok(String::from_utf8_lossy(&bytes).into_owned())
}
pub async fn jvm_file_presentations(
host: &Host,
window: &str,
paths: Vec<PathBuf>,
) -> IdeResult<Vec<JvmFilePresentation>> {
if paths.len() > MAX_BATCH {
return Err(IdeError::invalid("at most 64 JVM files per batch"));
}
let project = host.project(window)?;
host.blocking(move || {
static CACHE: OnceLock<Mutex<HashMap<PathBuf, Cached>>> = OnceLock::new();
let cache = CACHE.get_or_init(Default::default);
let mut results = Vec::new();
for path in paths {
let language = match path.extension().and_then(|e| e.to_str()) {
Some("java") => LanguageId::new("java"),
Some("kt") => LanguageId::new("kotlin"),
_ => continue,
};
let Ok(meta) = path.metadata() else { continue };
if !meta.is_file() {
continue;
}
let Ok(modified) = meta.modified() else { continue };
let hit =
cache.lock().unwrap().get(&path).filter(|c| c.modified == modified && c.size == meta.len()).cloned();
let presentation = if let Some(hit) = hit {
hit.presentation
} else {
let Ok(source) = prefix(&path) else { continue };
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or_default();
let presentation = project.structure.jvm_presentation(
&Snapshot::text(&language, &source),
stem,
&CancellationToken::new(),
)?;
let mut cache = cache.lock().unwrap();
if cache.len() >= MAX_CACHE {
cache.clear();
}
cache.insert(path.clone(), Cached { modified, size: meta.len(), presentation: presentation.clone() });
presentation
};
results.push(JvmFilePresentation { path, presentation });
}
Ok(results)
})
.await
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn huge_files_read_only_a_prefix() {
let path = std::env::temp_dir().join(format!("openidea-icon-prefix-{}.java", std::process::id()));
let file = File::create(&path).unwrap();
file.set_len(100 * 1024 * 1024).unwrap();
assert_eq!(prefix(&path).unwrap().len(), PREFIX_BYTES as usize);
std::fs::remove_file(path).unwrap();
}
}
+3
View File
@@ -8,11 +8,13 @@ pub mod build;
pub mod bytes;
pub mod coverage;
pub mod debug;
pub mod deploy;
pub mod doc;
pub mod env;
pub mod fs;
pub mod history;
pub mod java;
pub mod jvm_icons;
pub mod lsp;
pub mod nav;
pub mod notebook;
@@ -80,6 +82,7 @@ pub fn register_all(d: &mut crate::rpc::Dispatcher) {
search::register(d);
run::register(d);
env::register(d);
deploy::register(d);
}
impl Host {
+3 -1
View File
@@ -54,6 +54,7 @@ pub async fn reload(host: &Host, window: &str, id: String) -> IdeResult<Notebook
}
pub async fn restart(host: &Host, window: &str, id: String) -> IdeResult<NotebookSnapshot> {
host.require_trust(window)?;
let notebooks = host.project(window)?.notebooks.clone();
tokio::spawn(async move { notebooks.reload(&id, true).await })
.await
@@ -84,8 +85,9 @@ pub async fn execute(
request: RequestId,
on_output: Channel<NotebookOutputEvent>,
) -> IdeResult<NotebookSnapshot> {
host.require_trust(window)?;
let project = host.project(window)?;
let cancel = host.begin_request(request);
let cancel = host.begin_project_request(window, request);
let _request = RequestGuard { host, request, cancel: cancel.clone() };
// Reserve the WHOLE batch and capture source before asynchronous discovery.
let admission = project.notebooks.admit(&id, cells, cancel.clone())?;
+10
View File
@@ -10,6 +10,7 @@ use std::sync::Arc;
#[derive(Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct ProjectInfo {
pub trusted: bool,
pub root: ide_core::IpcPath,
pub name: String,
pub is_git: bool,
@@ -22,6 +23,7 @@ pub fn info(project: &Project) -> ProjectInfo {
let settings = project.settings.effective();
let custom = settings.general.project_name.trim();
ProjectInfo {
trusted: project.is_trusted(),
name: if custom.is_empty() {
project.root.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default()
} else {
@@ -146,6 +148,14 @@ pub fn set_settings(
}
}
let affected = || if project_scope { current.iter().cloned().collect() } else { host.projects() };
if key == "editor" || key == "editor.encodings" || key == "editor.encodings.nativeToAscii" {
let affected = affected();
host.runtime.spawn_blocking(move || {
for project in affected {
project.documents.reload_properties();
}
});
}
// File type associations re-detect open documents (off the command thread).
if key == "files" || key.starts_with("files.fileType") {
let affected = affected();
+33 -1
View File
@@ -6,6 +6,7 @@ use std::time::Duration;
/// Detected configs followed by user-defined ones (user ids win on conflict).
pub fn configs(host: &Host, window: &str) -> IdeResult<Vec<RunConfig>> {
host.require_trust(window)?;
let project = host.project(window)?;
let root = project.root.clone();
let user = ide_run::load_user_configs(&root)?;
@@ -21,6 +22,11 @@ pub fn configs(host: &Host, window: &str) -> IdeResult<Vec<RunConfig>> {
let key = crate::project::project_key(&member);
let name = member.file_name().unwrap_or_default().to_string_lossy();
for mut config in configs {
for task in &mut config.extras.before_launch {
if task.kind == "run" {
task.target = format!("workspace:{}:{}", key, task.target);
}
}
config.id = format!("workspace:{}:{}", key, config.id);
config.name = format!("{name} / {}", config.name);
config.cwd = Some(config.cwd.map(|p| member.join(&*p)).unwrap_or_else(|| member.clone()).into());
@@ -65,6 +71,7 @@ pub fn save_configs(
}
pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit<OutputChunk>) -> IdeResult<RunInfo> {
host.require_trust(window)?;
let project = host.project(window)?;
if config.command.is_empty() {
return Err(IdeError::invalid("run configuration has no command"));
@@ -97,6 +104,10 @@ pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit
} else {
ide_sdk::apply::resolve_run_command(&mut config, root, &settings);
}
if config.kind == "go" {
ide_sdk::apply::apply_to_run_configs(std::slice::from_mut(&mut config), root, &settings);
ide_sdk::go::apply_to_run(&mut config, &settings.languages.go.build_tags);
}
if config.command == ide_sdk::godot::COMMAND {
return Err(IdeError::unavailable(ide_sdk::godot::NOT_FOUND));
}
@@ -112,12 +123,33 @@ pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit
.await
.map_err(|e| IdeError::internal(e.to_string()))??;
}
if config.kind == "go" && config.extras.params.get("scope").is_some_and(|s| s == "file") {
let root = project.root.clone();
config = host
.blocking(move || {
ide_run::go::prepare_test_file(&root, &mut config)?;
Ok(config)
})
.await?;
}
let encoding = settings.console.encoding;
let options = StartOptions {
encoding: Some(encoding).filter(|e| !e.trim().is_empty()),
python_paths: if member.is_some() { vec![root.clone()] } else { super::python::paths(&project) },
};
host.processes.start(&project.root, &config, &options, project.sink.clone(), Box::new(move |c| on_output(c)))
project.require_trust()?;
let mut sink = project.sink.clone();
if config.kind == "go" && matches!(config.args.first().map(String::as_str), Some("mod" | "work")) {
let previous = sink;
let root = project.root.clone();
sink = std::sync::Arc::new(move |event: ide_core::IdeEvent| {
if matches!(&event, ide_core::IdeEvent::RunExited { .. }) {
ide_sdk::libraries::invalidate_go(&root);
}
previous.emit(event);
});
}
host.processes.start(&project.root, &config, &options, sink, Box::new(move |c| on_output(c)))
}
/// Stops a run (interrupt, then kill after Settings › Run's termination timeout).
+19 -9
View File
@@ -50,6 +50,7 @@ pub async fn run_test_reports(
}
pub async fn run_tomcat_home(host: &Host, window: &str, home: String) -> IdeResult<ide_run::tomcat::TomcatHome> {
host.require_trust(window)?;
let root = host.project(window)?.root.clone();
tokio::task::spawn_blocking(move || ide_run::tomcat::validate_home(&root.join(home)))
.await
@@ -57,6 +58,7 @@ pub async fn run_tomcat_home(host: &Host, window: &str, home: String) -> IdeResu
}
pub async fn run_tomcat_artifacts(host: &Host, window: &str) -> IdeResult<Vec<ide_build::web::WebArtifact>> {
host.require_trust(window)?;
let project = host.project(window)?;
tokio::task::spawn_blocking(move || {
let model = crate::ops::build::load(&project.root, &project.build_cache_dir(), &project.settings.effective());
@@ -67,6 +69,7 @@ pub async fn run_tomcat_artifacts(host: &Host, window: &str) -> IdeResult<Vec<id
}
pub async fn run_tomcat_build(host: &Host, window: &str, config: RunConfig) -> IdeResult<Option<RunConfig>> {
host.require_trust(window)?;
if config.extras.params.get("deployment").is_some_and(|v| v != "artifact") {
return Ok(None);
}
@@ -81,22 +84,21 @@ pub async fn run_tomcat_build(host: &Host, window: &str, config: RunConfig) -> I
.filter(|s| !s.is_empty())
.or_else(|| artifacts.first().map(|a| &a.id))
.ok_or_else(|| IdeError::invalid("Choose a WAR artifact"))?;
let plan = ide_build::web::build(
let mut 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 {
plan.env.extend(config.env.clone());
let mut build = RunConfig {
id: format!("tomcat-build:{}", config.id),
name: format!("Build {}", config.name),
kind: if id.starts_with("maven:") { "maven" } else { "gradle" }.into(),
command: plan.command,
args: plan.args,
cwd: Some(std::path::PathBuf::from(plan.cwd).into()),
env,
command: plan.command.clone(),
args: vec![],
cwd: Some(std::path::PathBuf::from(&plan.cwd).into()),
env: plan.env.clone(),
detected: false,
is_test: false,
debug_adapter: None,
@@ -106,13 +108,21 @@ pub async fn run_tomcat_build(host: &Host, window: &str, config: RunConfig) -> I
env_files: config.extras.env_files,
..Default::default()
},
}))
};
plan.resolve_project(
&project.root,
&ide_run::resolved_env(&project.root, &build)?,
&ide_core::CancellationToken::new(),
)?;
build.args = plan.args;
Ok(Some(build))
})
.await
.map_err(|e| IdeError::internal(e.to_string()))?
}
pub async fn run_tomcat_update(host: &Host, window: &str, config: RunConfig, mode: String) -> IdeResult<()> {
host.require_trust(window)?;
let project = host.project(window)?;
if !host.processes.list().iter().any(|r| r.running && r.config_id == config.id) {
return Err(IdeError::invalid("Start this Tomcat configuration before updating it"));
+15 -8
View File
@@ -22,12 +22,14 @@ pub struct LocatedTools {
/// Installed interpreters / toolchains of `kind`; project-local ones first.
pub async fn sdk_detect(host: &Host, window: &str, kind: SdkKind) -> IdeResult<Vec<Sdk>> {
host.require_trust(window)?;
let root = host.project(window).ok().map(|p| p.root.clone());
tokio::task::spawn_blocking(move || ide_sdk::detect(kind, root.as_deref())).await.map_err(join_err)
}
/// The interpreter the project uses: the configured one, else its virtualenv.
pub fn sdk_python_effective(host: &Host, window: &str) -> IdeResult<Option<ide_core::IpcPath>> {
host.require_trust(window)?;
let project = host.project(window)?;
Ok(ide_sdk::apply::python_interpreter(&project.root, &project.settings.effective()).map(Into::into))
}
@@ -35,6 +37,7 @@ pub fn sdk_python_effective(host: &Host, window: &str) -> IdeResult<Option<ide_c
/// SDK / library source folders shown under "External Libraries" in the Project view,
/// with the Maven / Gradle dependency jars of the build model.
pub async fn sdk_project_libraries(host: &Host, window: &str) -> IdeResult<Vec<ide_sdk::libraries::LibraryGroup>> {
host.require_trust(window)?;
let project = host.project(window)?;
let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective());
tokio::task::spawn_blocking(move || {
@@ -56,11 +59,12 @@ pub async fn sdk_project_libraries(host: &Host, window: &str) -> IdeResult<Vec<i
/// Looks up `names` (bare command names): `node_modules/.bin` above `near`, then the search path.
pub async fn sdk_locate_tools(
_host: &Host,
_window: &str,
host: &Host,
window: &str,
names: Vec<String>,
near: Option<PathBuf>,
) -> IdeResult<LocatedTools> {
host.require_trust(window)?;
tokio::task::spawn_blocking(move || LocatedTools {
tools: ide_sdk::locate::locate_tools(&names, near.as_deref()),
compile_dir: ide_sdk::locate::compile_dir().into(),
@@ -72,13 +76,14 @@ pub async fn sdk_locate_tools(
/// `base -m venv location`; output lines stream through `on_output`.
pub async fn sdk_python_create_venv(
host: &Host,
_window: &str,
window: &str,
base: PathBuf,
location: PathBuf,
request: RequestId,
on_output: Channel<String>,
) -> IdeResult<Sdk> {
let cancel = host.begin_request(request);
host.require_trust(window)?;
let cancel = host.begin_project_request(window, request);
let result = tokio::task::spawn_blocking(move || {
ide_sdk::python::create_venv(&base, &location, &cancel, &|line| {
let _ = on_output.send(line);
@@ -92,11 +97,12 @@ pub async fn sdk_python_create_venv(
pub async fn sdk_python_packages(
host: &Host,
_window: &str,
window: &str,
python: PathBuf,
request: RequestId,
) -> IdeResult<Vec<PythonPackage>> {
let cancel = host.begin_request(request);
host.require_trust(window)?;
let cancel = host.begin_project_request(window, request);
let result =
tokio::task::spawn_blocking(move || ide_sdk::python::list_packages(&python, &cancel)).await.map_err(join_err);
host.end_request(request);
@@ -107,7 +113,7 @@ pub async fn sdk_python_packages(
#[allow(clippy::too_many_arguments)]
pub async fn sdk_python_pip(
host: &Host,
_window: &str,
window: &str,
python: PathBuf,
requirement: String,
uninstall: bool,
@@ -115,7 +121,8 @@ pub async fn sdk_python_pip(
request: RequestId,
on_output: Channel<String>,
) -> IdeResult<()> {
let cancel = host.begin_request(request);
host.require_trust(window)?;
let cancel = host.begin_project_request(window, request);
let result = tokio::task::spawn_blocking(move || {
let send = |line: String| {
let _ = on_output.send(line);
+1 -1
View File
@@ -75,7 +75,7 @@ pub async fn start(
&query,
Some(&filter),
Some(&literals),
&|p| documents.text_of(p),
&|p| documents.search_text_of(p),
&cancel,
&|mut file| {
if dir != &root {
+1 -1
View File
@@ -124,7 +124,7 @@ pub async fn syntax_identifiers_in_files(
let snap = project.documents.text_snapshot(doc).ok()?;
return (!snap.large).then_some((snap.language, Some((doc, snap.version)), snap.text));
}
let text = ide_vfs::load_text(path).ok()?.text;
let text = project.text_of(path).ok()?;
let first_line = text.lines().next();
Some((languages.detect(path, first_line), None, text))
};
+17 -5
View File
@@ -3,12 +3,24 @@ use crate::Host;
use ide_core::{IdeResult, TerminalId};
use ide_terminal::{TerminalInfo, TerminalOptions};
pub fn spawn(host: &Host, window: &str, options: TerminalOptions, on_output: Emit<String>) -> IdeResult<TerminalInfo> {
pub fn spawn(
host: &Host,
window: &str,
mut options: TerminalOptions,
on_output: Emit<String>,
) -> IdeResult<TerminalInfo> {
if !host.project(window).is_ok_and(|p| p.is_trusted()) {
// Project terminal settings can otherwise start a command as soon as a tab restores.
let global = host.global_settings.effective().terminal;
options.shell = None;
options.args.clear();
options.env = global.env;
}
// Python › "Activate virtualenv": typed into the new shell, like PyCharm.
let activation = host
.project(window)
.ok()
.and_then(|p| ide_sdk::apply::terminal_activation(&p.root, &p.settings.effective(), options.shell.as_deref()));
let activation =
host.project(window).ok().filter(|p| p.is_trusted()).and_then(|p| {
ide_sdk::apply::terminal_activation(&p.root, &p.settings.effective(), options.shell.as_deref())
});
let info = host.terminals.spawn(options, host.window_sink(window), Box::new(move |chunk| on_output(chunk)))?;
if let Some(line) = activation {
let _ = host.terminals.write(info.id, &format!(" {line}\r"));
+1
View File
@@ -11,6 +11,7 @@ use std::path::PathBuf;
/// The installer, reporting the executables chosen in the window's settings.
fn tools(host: &Host, window: &str) -> IdeResult<Tools> {
host.require_trust(window)?;
let data = host.data_dir.clone();
let (settings, root) = match host.project(window) {
Ok(project) => (project.settings.effective(), project.root.clone()),
+3 -1
View File
@@ -7,7 +7,9 @@ pub async fn status(host: &Host, window: &str) -> IdeResult<Vec<FileStatus>> {
let project = host.project(window)?;
host.blocking(move || {
let settings = project.settings.effective();
apply_git_executable(&settings);
if project.is_trusted() {
apply_git_executable(&settings);
}
let repos = project.repos();
if repos.is_empty() {
return Err(ide_core::IdeError::unavailable("project is not a git repository"));
+101 -22
View File
@@ -187,10 +187,13 @@ pub async fn vcs_branch_info(host: &Host, window: &str, repo: Option<PathBuf>) -
}
/// Gutter change markers for a file, computed against its current (unsaved) text.
pub fn vcs_line_hunks(host: &Host, window: &str, path: PathBuf) -> IdeResult<Vec<LineHunk>> {
pub async 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.vcs_encoding(&path, None)?))
blocking(move || {
let text = project.text_of(&path)?;
project.with_repo_of(&path, |r| r.line_hunks(&path, &text, false, project.vcs_encoding(&path, None)?))
})
.await
}
pub async fn vcs_file_at(host: &Host, window: &str, path: PathBuf, rev: Option<String>) -> IdeResult<Option<String>> {
@@ -210,8 +213,8 @@ pub async fn vcs_blame(
) -> IdeResult<Vec<BlameLine>> {
let project = host.project(window)?;
// 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 || {
let text = text.or_else(|| project.documents.text_of(&path));
project.with_repo_of(&path, |r| {
r.blame(&path, text.as_deref(), revision.as_deref(), project.vcs_encoding(&path, revision.as_deref())?)
})
@@ -256,6 +259,7 @@ pub async fn vcs_stashes(host: &Host, window: &str, repo: Option<PathBuf>) -> Id
}
pub async fn vcs_stage(host: &Host, window: &str, paths: Vec<PathBuf>) -> IdeResult<()> {
host.require_trust(window)?;
let project = host.project(window)?;
blocking(move || per_repo(&project, &paths, None, |r, paths| r.stage(paths))).await
}
@@ -268,6 +272,7 @@ pub async fn vcs_add_to_ignore(
paths: Vec<PathBuf>,
destination: IgnoreDestination,
) -> IdeResult<Vec<String>> {
host.require_trust(window)?;
let project = host.project(window)?;
blocking(move || {
for path in &paths {
@@ -289,16 +294,19 @@ pub async fn vcs_add_to_ignore(
}
pub async fn vcs_unstage(host: &Host, window: &str, paths: Vec<PathBuf>) -> IdeResult<()> {
host.require_trust(window)?;
let project = host.project(window)?;
blocking(move || per_repo(&project, &paths, None, |r, paths| r.unstage(paths))).await
}
pub async fn vcs_stage_patch(host: &Host, window: &str, patch: String) -> IdeResult<()> {
host.require_trust(window)?;
let project = host.project(window)?;
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<()> {
host.require_trust(window)?;
let project = host.project(window)?;
blocking(move || per_repo(&project, &paths, Some("Rollback"), |r, paths| r.discard(paths))).await
}
@@ -310,6 +318,7 @@ pub async fn vcs_commit(
message: String,
options: CommitOptions,
) -> IdeResult<String> {
host.require_trust(window)?;
// Commit hooks may run for a long time.
let project = host.project(window)?;
blocking(move || {
@@ -328,6 +337,7 @@ pub async fn vcs_checkout(
create: bool,
options: Option<ide_vcs::CheckoutOptions>,
) -> IdeResult<ide_vcs::CheckoutOutcome> {
host.require_trust(window)?;
let project = host.project(window)?;
let options = options.unwrap_or_default();
blocking(move || {
@@ -346,6 +356,7 @@ pub async fn vcs_create_branch(
start: Option<String>,
checkout: bool,
) -> IdeResult<()> {
host.require_trust(window)?;
let project = host.project(window)?;
let recorded = checkout.then_some("Git checkout");
blocking(move || {
@@ -357,6 +368,7 @@ pub async fn vcs_create_branch(
/// Undo Commit: moves HEAD back to the commit's parent, keeping its changes staged.
/// Returns the undone commit's message.
pub async fn vcs_undo_commit(host: &Host, window: &str, repo: Option<PathBuf>, commit: String) -> IdeResult<String> {
host.require_trust(window)?;
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.undo_commit(&commit))).await
}
@@ -367,6 +379,7 @@ pub async fn vcs_delete_branch(
repo: Option<PathBuf>,
branch: String,
) -> IdeResult<ide_vcs::DeletedBranch> {
host.require_trust(window)?;
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.delete_branch_restorable(&branch))).await
}
@@ -378,6 +391,7 @@ pub async fn vcs_restore_branch(
repo: Option<PathBuf>,
deleted: ide_vcs::DeletedBranch,
) -> IdeResult<()> {
host.require_trust(window)?;
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.restore_branch(&deleted))).await
}
@@ -389,16 +403,19 @@ pub async fn vcs_rename_branch(
from: String,
to: String,
) -> IdeResult<()> {
host.require_trust(window)?;
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.rename_branch(&from, &to))).await
}
pub async fn vcs_merge(host: &Host, window: &str, repo: Option<PathBuf>, branch: String) -> IdeResult<()> {
host.require_trust(window)?;
let project = host.project(window)?;
blocking(move || project.with_repo_recorded(repo.as_deref(), Some("Git merge"), |r| r.merge(&branch))).await
}
pub async fn vcs_rebase(host: &Host, window: &str, repo: Option<PathBuf>, onto: String) -> IdeResult<()> {
host.require_trust(window)?;
let project = host.project(window)?;
blocking(move || project.with_repo_recorded(repo.as_deref(), Some("Git rebase"), |r| r.rebase(&onto))).await
}
@@ -411,6 +428,7 @@ pub async fn vcs_stash_push(
include_untracked: bool,
keep_index: bool,
) -> IdeResult<()> {
host.require_trust(window)?;
let project = host.project(window)?;
blocking(move || {
project.with_repo_recorded(repo.as_deref(), Some("Git stash"), |r| {
@@ -429,6 +447,7 @@ pub async fn vcs_stash_apply(
reinstate_index: bool,
branch: Option<String>,
) -> IdeResult<()> {
host.require_trust(window)?;
let project = host.project(window)?;
blocking(move || {
project.with_repo_recorded(repo.as_deref(), Some("Git unstash"), |r| match branch.as_deref() {
@@ -440,6 +459,7 @@ pub async fn vcs_stash_apply(
}
pub async fn vcs_stash_drop(host: &Host, window: &str, repo: Option<PathBuf>, index: u32) -> IdeResult<()> {
host.require_trust(window)?;
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.stash_drop(index))).await
}
@@ -453,6 +473,7 @@ pub async fn vcs_remote(
op: RemoteOp,
on_output: Channel<String>,
) -> IdeResult<()> {
host.require_trust(window)?;
let project = host.project(window)?;
let cancel = host.begin_request(request);
let result = tokio::task::spawn_blocking(move || {
@@ -510,14 +531,18 @@ pub fn vcs_diff_hunks(_host: &Host, _window: &str, old_text: String, new_text: S
/// Stages only the selected hunks of a file's working-tree changes.
/// 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<()> {
pub async fn vcs_stage_hunks(host: &Host, window: &str, path: PathBuf, hunks: Vec<u32>) -> IdeResult<()> {
host.require_trust(window)?;
let project = host.project(window)?;
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 selected: Vec<usize> = hunks.iter().map(|&h| h as usize).collect();
r.stage_hunks(&path, &current, &selected, encoding)
blocking(move || {
let encoding = project.vcs_encoding(&path, Some(":0"))?;
let current = project.documents.load_file_as(&path, encoding.charset.name())?.text;
project.with_repo_of(&path, |r| {
let selected: Vec<usize> = hunks.iter().map(|&h| h as usize).collect();
r.stage_hunks(&path, &current, &selected, encoding)
})
})
.await
}
/// Clones a repository (Get from VCS). Works without an open project.
@@ -550,7 +575,8 @@ pub async fn vcs_init(host: &Host, _window: &str, dir: PathBuf) -> IdeResult<()>
/// Settings › Git › Test: runs `git --version` on `path`, or on the auto-detected
/// executable when `path` is empty.
pub async fn vcs_git_version(_host: &Host, _window: &str, path: String) -> IdeResult<GitExecutableInfo> {
pub async fn vcs_git_version(host: &Host, window: &str, path: String) -> IdeResult<GitExecutableInfo> {
host.require_trust(window)?;
tokio::task::spawn_blocking(move || {
let explicit = path.trim();
let exe = if explicit.is_empty() {
@@ -592,6 +618,7 @@ pub async fn vcs_operation(
op: GitOperation,
on_output: Channel<String>,
) -> IdeResult<()> {
host.require_trust(window)?;
let project = host.project(window)?;
let cancel = host.begin_request(request);
let result = tokio::task::spawn_blocking(move || {
@@ -632,6 +659,7 @@ pub async fn vcs_create_tag(
message: Option<String>,
force: bool,
) -> IdeResult<()> {
host.require_trust(window)?;
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.create_tag(&name, &commit, message.as_deref(), force)))
.await
@@ -650,6 +678,7 @@ pub async fn vcs_merge_with(
branches: Vec<String>,
options: MergeOptions,
) -> IdeResult<()> {
host.require_trust(window)?;
let project = host.project(window)?;
blocking(move || {
project.with_repo_recorded(repo.as_deref(), Some("Git merge"), |r| r.merge_with(&branches, &options))
@@ -665,6 +694,7 @@ pub async fn vcs_rebase_with(
upstream: String,
options: RebaseOptions,
) -> IdeResult<()> {
host.require_trust(window)?;
let project = host.project(window)?;
blocking(move || {
project.with_repo_recorded(repo.as_deref(), Some("Git rebase"), |r| r.rebase_with(&upstream, &options))
@@ -678,6 +708,7 @@ pub async fn vcs_remotes(host: &Host, window: &str, repo: Option<PathBuf>) -> Id
}
pub async fn vcs_edit_remote(host: &Host, window: &str, repo: Option<PathBuf>, edit: RemoteEdit) -> IdeResult<()> {
host.require_trust(window)?;
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.edit_remote(&edit))).await
}
@@ -695,6 +726,7 @@ pub async fn vcs_add_worktree(
commitish: String,
new_branch: Option<String>,
) -> IdeResult<()> {
host.require_trust(window)?;
let project = host.project(window)?;
blocking(move || {
project.with_repo_at(repo.as_deref(), |r| r.add_worktree(&path, &commitish, new_branch.as_deref()))
@@ -709,6 +741,7 @@ pub async fn vcs_remove_worktree(
path: PathBuf,
force: bool,
) -> IdeResult<()> {
host.require_trust(window)?;
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.remove_worktree(&path, force))).await
}
@@ -736,6 +769,19 @@ pub async fn vcs_diff_revisions(
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.diff_revisions(&from, &to))).await
}
/// The best common ancestor of `a` and `b` (`git merge-base`), if any: the New Pull
/// Request tab's three-dot diff.
pub async fn vcs_merge_base(
host: &Host,
window: &str,
repo: Option<PathBuf>,
a: String,
b: String,
) -> IdeResult<Option<String>> {
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.merge_base(&a, &b))).await
}
pub async fn vcs_create_patch(
host: &Host,
window: &str,
@@ -771,12 +817,14 @@ pub async fn vcs_apply_patch(
repo: Option<PathBuf>,
patch: String,
) -> IdeResult<ApplyOutcome> {
host.require_trust(window)?;
let project = host.project(window)?;
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.
pub async fn vcs_shelve(host: &Host, window: &str, name: String, paths: Vec<PathBuf>) -> IdeResult<ShelvedChangeList> {
host.require_trust(window)?;
let project = host.project(window)?;
blocking(move || {
in_primary_repo(&project, &paths)?;
@@ -793,17 +841,26 @@ pub fn vcs_shelves(host: &Host, window: &str) -> IdeResult<Vec<ShelvedChangeList
/// Unshelve: applies the shelved patch (3-way merging; conflicts are left for the
/// merge UI); with `remove`, deletes it from the shelf.
pub async fn vcs_unshelve(host: &Host, window: &str, id: String, remove: bool) -> IdeResult<ApplyOutcome> {
pub async fn vcs_unshelve(
host: &Host,
window: &str,
id: String,
remove: bool,
path: Option<String>,
) -> IdeResult<ApplyOutcome> {
host.require_trust(window)?;
let project = host.project(window)?;
blocking(move || {
project.with_repo_recorded(None, Some("Unshelve"), |r| r.unshelve(&shelf_dir(&project.root), &id, remove))
project.with_repo_recorded(None, Some("Unshelve"), |r| {
ide_vcs::shelf::unshelve_path(r, &shelf_dir(&project.root), &id, remove, path.as_deref())
})
})
.await
}
pub fn vcs_shelf_delete(host: &Host, window: &str, id: String) -> IdeResult<()> {
pub fn vcs_shelf_delete(host: &Host, window: &str, id: String, path: Option<String>) -> IdeResult<()> {
let project = host.project(window)?;
ide_vcs::shelf::delete(&shelf_dir(&project.root), &id)
ide_vcs::shelf::delete_path(&shelf_dir(&project.root), &id, path.as_deref())
}
pub fn vcs_shelf_rename(host: &Host, window: &str, id: String, name: String) -> IdeResult<()> {
@@ -811,9 +868,19 @@ pub fn vcs_shelf_rename(host: &Host, window: &str, id: String, name: String) ->
ide_vcs::shelf::rename(&shelf_dir(&project.root), &id, &name)
}
pub fn vcs_shelf_patch(host: &Host, window: &str, id: String) -> IdeResult<String> {
pub fn vcs_shelf_patch(host: &Host, window: &str, id: String, path: Option<String>) -> IdeResult<String> {
let project = host.project(window)?;
ide_vcs::shelf::patch_of(&shelf_dir(&project.root), &id)
ide_vcs::shelf::selected_patch(&shelf_dir(&project.root), &id, path.as_deref())
}
pub async fn vcs_shelf_diff(host: &Host, window: &str, id: String) -> IdeResult<Vec<ide_vcs::shelf::ShelfDiffFile>> {
let project = host.project(window)?;
blocking(move || project.with_repo(|r| ide_vcs::shelf::diff_files(r, &shelf_dir(&project.root), &id))).await
}
pub async fn vcs_shelf_import(host: &Host, window: &str, name: String, patch: String) -> IdeResult<()> {
let project = host.project(window)?;
blocking(move || ide_vcs::shelf::import_patch(&shelf_dir(&project.root), &name, &patch)).await
}
pub fn vcs_changelists_load(host: &Host, window: &str) -> IdeResult<ChangelistState> {
@@ -843,7 +910,7 @@ pub async fn vcs_write_patch(_host: &Host, _window: &str, path: PathBuf, patch:
pub(crate) fn register(d: &mut Dispatcher) {
d.add("vcs_roots", |c| async move { vcs_roots(&c.host, &c.window) });
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_line_hunks", |c| async move { vcs_line_hunks(&c.host, &c.window, c.arg("path")?).await });
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")?, c.arg("text")?, c.arg("revision")?).await
@@ -927,7 +994,10 @@ pub(crate) fn register(d: &mut Dispatcher) {
"vcs_diff_hunks",
|c| async move { Ok(vcs_diff_hunks(&c.host, &c.window, c.arg("oldText")?, c.arg("newText")?)) },
);
d.add("vcs_stage_hunks", |c| async move { vcs_stage_hunks(&c.host, &c.window, c.arg("path")?, c.arg("hunks")?) });
d.add(
"vcs_stage_hunks",
|c| async move { vcs_stage_hunks(&c.host, &c.window, c.arg("path")?, c.arg("hunks")?).await },
);
d.add("vcs_clone", |c| async move {
vcs_clone(&c.host, &c.window, c.arg("request")?, c.arg("url")?, c.arg("dest")?, c.sender("onOutput")?).await
});
@@ -976,6 +1046,9 @@ 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_merge_base", |c| async move {
vcs_merge_base(&c.host, &c.window, c.arg("repo")?, c.arg("a")?, c.arg("b")?).await
});
d.add("vcs_create_patch", |c| async move {
vcs_create_patch(&c.host, &c.window, c.arg("repo")?, c.arg("source")?).await
});
@@ -988,10 +1061,16 @@ pub(crate) fn register(d: &mut Dispatcher) {
);
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 });
d.add("vcs_shelf_delete", |c| async move { vcs_shelf_delete(&c.host, &c.window, c.arg("id")?) });
d.add("vcs_unshelve", |c| async move {
vcs_unshelve(&c.host, &c.window, c.arg("id")?, c.arg("remove")?, c.arg("path")?).await
});
d.add("vcs_shelf_delete", |c| async move { vcs_shelf_delete(&c.host, &c.window, c.arg("id")?, c.arg("path")?) });
d.add("vcs_shelf_rename", |c| async move { vcs_shelf_rename(&c.host, &c.window, c.arg("id")?, c.arg("name")?) });
d.add("vcs_shelf_patch", |c| async move { vcs_shelf_patch(&c.host, &c.window, c.arg("id")?) });
d.add("vcs_shelf_diff", |c| async move { vcs_shelf_diff(&c.host, &c.window, c.arg("id")?).await });
d.add("vcs_shelf_import", |c| async move {
vcs_shelf_import(&c.host, &c.window, c.arg("name")?, c.arg("patch")?).await
});
d.add("vcs_shelf_patch", |c| async move { vcs_shelf_patch(&c.host, &c.window, c.arg("id")?, c.arg("path")?) });
d.add("vcs_changelists_load", |c| async move { vcs_changelists_load(&c.host, &c.window) });
d.add("vcs_changelists_save", |c| async move { vcs_changelists_save(&c.host, &c.window, c.arg("changelists")?) });
d.add(
+86 -7
View File
@@ -15,9 +15,11 @@ use ide_workspace::{DocumentInfo, Documents, FileMoves, LocalHistory, ProjectRoo
use parking_lot::{Mutex, RwLock};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
/// Everything bound to one open project folder.
pub struct Project {
pub(crate) trusted: Arc<AtomicBool>,
pub root: PathBuf,
/// Window-scoped event sink, for commands that emit project events.
pub sink: SharedSink,
@@ -80,7 +82,9 @@ impl Host {
self.close_project(window);
let sink: SharedSink = (self.sink_for)(window);
// Before anything the project starts: its children get the project's environment.
let post_start = self.environment.project_opened(&root, &sink);
let trusted = Arc::new(AtomicBool::new(self.trust.trusted(&root)?));
let post_start =
trusted.load(Ordering::Acquire).then(|| self.environment.project_opened(&root, &sink)).flatten();
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(&root, &settings.effective(), &detected_roots.read());
@@ -114,6 +118,7 @@ impl Host {
)?;
mark("index");
let lsp = LspManager::new(root.clone(), languages.clone(), sink.clone());
lsp.set_execution_allowed(trusted.load(Ordering::Acquire));
lsp.set_content_roots(extra_roots.clone());
let settings = Arc::new(settings);
{
@@ -208,9 +213,13 @@ impl Host {
root: root.clone(),
sink: sink.clone(),
runtime: self.runtime.clone(),
trusted: trusted.clone(),
}));
let repos = Arc::new(RwLock::new(Repos::enclosing(&root)?));
for repo in repos.read().all() {
repo.set_execution_allowed(trusted.load(Ordering::Acquire));
}
let watcher = {
let repos = repos.clone();
let settings = settings.clone();
@@ -223,6 +232,8 @@ impl Host {
let history = history.clone();
let remote = self.remote.clone();
let runtime = self.runtime.clone();
let lsp = lsp.clone();
let trusted = trusted.clone();
Watcher::start(
&root.clone(),
excludes.clone(),
@@ -230,6 +241,7 @@ impl Host {
Box::new(move |batch| {
let changed: Vec<PathBuf> = batch.created.iter().chain(&batch.modified).cloned().collect();
index.update(&changed, &batch.removed);
lsp.project_files_changed(batch.created.iter().chain(&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
@@ -239,7 +251,7 @@ impl Host {
documents.redetect_languages();
}
record_external_changes(&history, &changed, &batch.removed);
if !batch.removed.is_empty() {
if trusted.load(Ordering::Acquire) && !batch.removed.is_empty() {
delete_deployed(
&runtime,
remote.clone(),
@@ -329,6 +341,7 @@ impl Host {
);
ide_agent::registry::maintain(crate::ops::agent::registry_paths(self));
let project = Arc::new(Project {
trusted,
root,
sink,
languages,
@@ -358,6 +371,12 @@ impl Host {
weak.upgrade().map(|p| p.reference_candidates(q, cancel)).unwrap_or_default()
}));
}
{
let weak = Arc::downgrade(&project);
project.lsp.set_project_excludes_hook(Box::new(move || {
weak.upgrade().map(|p| p.exclude_globs()).unwrap_or_default()
}));
}
self.insert_project(window, project.clone());
if let Some(cmd) = post_start {
self.environment.spawn_post_start(cmd, project.root.clone(), project.sink.clone());
@@ -438,6 +457,7 @@ impl Host {
sink.clone(),
);
let project = Arc::new(Project {
trusted: Arc::new(AtomicBool::new(true)),
sink,
languages,
settings,
@@ -468,6 +488,18 @@ impl Host {
}
impl Project {
pub fn is_trusted(&self) -> bool {
self.trusted.load(Ordering::Acquire)
}
pub fn require_trust(&self) -> IdeResult<()> {
if self.is_trusted() {
Ok(())
} else {
Err(IdeError::unavailable("Project is in Restricted Mode. Trust the project to enable execution."))
}
}
/// LightEdit: follow external changes to the files of `dir` (its own entries only).
pub fn watch_light_dir(&self, dir: &Path) {
if !self.light {
@@ -542,7 +574,11 @@ impl Project {
/// The project's git repositories.
pub fn repos(&self) -> Repos {
self.repos.read().clone()
let repos = self.repos.read().clone();
for repo in repos.all() {
repo.set_execution_allowed(self.is_trusted());
}
repos
}
/// Runs `f` with the repository whose root is `at`, the project's primary repository
@@ -574,7 +610,9 @@ impl Project {
fn run_in<T>(&self, repo: &Repo, f: impl FnOnce(&Repo) -> IdeResult<T>) -> IdeResult<T> {
let settings = self.settings.effective();
apply_git_executable(&settings);
if self.is_trusted() {
apply_git_executable(&settings);
}
repo.set_ignored_names(settings.ignored_names());
f(repo)
}
@@ -681,7 +719,7 @@ impl Project {
limit: q.limit,
};
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) {
match ide_search::files_with_word(&root, &query, Some(&filter), &|p| self.documents.search_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);
@@ -739,7 +777,17 @@ 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> {
pub fn vcs_encoding(&self, path: &Path, rev: Option<&str>) -> IdeResult<ide_vcs::TextEncoding> {
let charset = self.file_encoding(path, rev)?;
let encoding = ide_vcs::TextEncoding::from(charset);
Ok(if self.documents.native_to_ascii_for(path) {
encoding.with_ascii_conversion(ide_vfs::decode_properties_escapes, ide_vfs::encode_properties_ascii)
} else {
encoding
})
}
fn file_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())
@@ -760,7 +808,7 @@ impl Project {
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_with_fallback(path, self.documents.file_defaults().fallback_for(path))?.text),
None => Ok(ide_text::normalize_line_endings(&self.documents.load_file(path)?.text).into_owned()),
}
}
}
@@ -768,6 +816,8 @@ impl Project {
/// Applies a multi-file edit through the document layer so open editors update live.
pub fn apply_workspace_edit(documents: &Documents, edit: &ide_core::WorkspaceEdit) -> IdeResult<()> {
use ide_core::FileEdit;
let mut edit = edit.clone();
edit.deduplicate();
for file in &edit.files {
match file {
FileEdit::Edit { path, edits } => {
@@ -961,6 +1011,7 @@ impl DocumentObserver for HistoryObserver {
/// Uploads saved files: remote files' local copies, and automatic upload to the default server.
struct DeployObserver {
trusted: Arc<AtomicBool>,
remote: Arc<ide_remote::RemoteService>,
settings: Arc<SettingsStore>,
root: PathBuf,
@@ -970,6 +1021,9 @@ struct DeployObserver {
impl DocumentObserver for DeployObserver {
fn saved(&self, _doc: ide_core::DocId, path: &Path, _text: &str) {
if !self.trusted.load(Ordering::Acquire) {
return;
}
let (remote, settings, root, sink, path) =
(self.remote.clone(), self.settings.clone(), self.root.clone(), self.sink.clone(), path.to_path_buf());
self.runtime.spawn(async move {
@@ -1111,3 +1165,28 @@ mod index_event_tests {
assert!(vcs_path_changed(&index));
}
}
#[cfg(all(test, unix))]
mod workspace_edit_tests {
use super::*;
use ide_core::{FileEdit, LanguageId, Pos, Range, TextEdit, WorkspaceEdit};
#[test]
fn rename_via_symlink_updates_the_open_document_once() {
let dir = tempfile::tempdir().unwrap();
let real = dir.path().join("main.cpp");
let alias = dir.path().join("alias.cpp");
std::fs::write(&real, "add();\n").unwrap();
std::os::unix::fs::symlink(&real, &alias).unwrap();
let documents = Documents::new(Arc::new(|_: IdeEvent| {}), Box::new(|_, _| LanguageId::new("cpp")));
let doc = documents.open(&alias).unwrap();
let changes = |path: PathBuf| FileEdit::Edit {
path: path.into(),
edits: vec![TextEdit { range: Range::new(Pos::new(0, 0), Pos::new(0, 3)), new_text: "sum_values".into() }],
};
apply_workspace_edit(&documents, &WorkspaceEdit { files: vec![changes(real.clone()), changes(alias)] })
.unwrap();
assert_eq!(std::fs::read_to_string(real).unwrap(), "sum_values();\n");
assert_eq!(documents.snapshot(doc.id).unwrap().text, "sum_values();\n");
}
}
+18 -1
View File
@@ -24,7 +24,7 @@ use std::path::PathBuf;
use std::pin::Pin;
use std::sync::{Arc, OnceLock};
pub const PROTOCOL_VERSION: u32 = 1;
pub const PROTOCOL_VERSION: u32 = 2;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -138,6 +138,15 @@ impl Dispatcher {
/// terminals, runs and VCS status.
pub fn standard() -> Self {
let mut d = Self::default();
d.add("project_trust_status", |c| async move { c.host.trust.status(&c.arg::<PathBuf>("path")?) })
.add("project_trust_list", |c| async move { c.host.trust.decisions() })
.add("project_trust_set", |c| async move {
c.host.set_project_trust(&c.arg::<PathBuf>("path")?, c.arg("trusted")?, c.arg("folder")?).await
})
.add("project_trust_remove_location", |c| async move {
c.host.trust.remove_location(&c.arg::<PathBuf>("path")?)?;
c.host.refresh_project_trust().await
});
d.add("open_project", |c| async move {
let path: PathBuf = c.arg("path")?;
let project = ops::project::open(&c.host, &c.window, &path).await?;
@@ -148,6 +157,9 @@ impl Dispatcher {
c.host.close_project(&c.window);
Ok(())
})
.add("jvm_file_presentations", |c| async move {
ops::jvm_icons::jvm_file_presentations(&c.host, &c.window, c.arg("paths")?).await
})
.add("project_roots", |c| async move { ops::project::roots(&c.host, &c.window) })
.add("settings_get", |c| async move { Ok(ops::project::settings(&c.host, &c.window)) })
.add("settings_set", |c| async move {
@@ -168,6 +180,9 @@ impl Dispatcher {
.add("cancel_task", |c| async move { Ok(ide_core::task::cancel(c.arg::<TaskId>("id")?)) })
.add("fs_read_dir", |c| async move { ops::fs::read_dir(&c.host, &c.window, c.arg("path")?) })
.add("fs_create_file", |c| async move { ops::fs::create_file(&c.host, &c.window, c.arg("path")?) })
.add("fs_create_file_bytes", |c| async move {
ops::fs::create_file_bytes(&c.host, c.arg("path")?, c.arg("data")?).await
})
.add("fs_create_dir", |c| async move { ide_vfs::create_dir(&c.arg::<PathBuf>("path")?) })
.add("fs_rename", |c| async move { ide_vfs::rename(&c.arg::<PathBuf>("from")?, &c.arg::<PathBuf>("to")?) })
.add("fs_set_writable", |c| async move {
@@ -183,6 +198,7 @@ impl Dispatcher {
})
.add("doc_save", |c| async move { ops::doc::save(&c.host, &c.window, c.arg("doc")?).await })
.add("doc_save_all", |c| async move { ops::doc::save_all(&c.host, &c.window).await })
.add("doc_sync_from_disk", |c| async move { ops::doc::sync_from_disk(&c.host, &c.window).await })
.add("doc_snapshot", |c| async move { ops::doc::snapshot(&c.host, &c.window, c.arg("doc")?).await })
.add("search_start", |c| async move {
let on_event = c.channel("onEvent")?;
@@ -284,6 +300,7 @@ impl Handler for ServerHandler {
let Some(handler) = self.dispatcher.handlers.get(call.cmd.as_str()).cloned() else {
return Err((codes::METHOD_NOT_FOUND, format!("command {} is not available remotely", call.cmd)));
};
self.host.authorize_command(&call.window, &call.cmd).map_err(error_reply)?;
let c = Call { host: self.host.clone(), window: call.window, args: call.args, peer: self.peer.clone() };
// Requests run on blocking-capable threads: wait for the command here.
self.host.runtime.block_on(handler(c)).map_err(error_reply)
+310
View File
@@ -0,0 +1,310 @@
//! User-owned project execution decisions. Project settings never grant trust.
use ide_core::{IdeError, IdeResult};
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use specta::Type;
use std::path::{Path, PathBuf};
#[derive(Clone, Default, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct TrustDecisions {
pub projects: Vec<PathBuf>,
pub locations: Vec<PathBuf>,
pub restricted: Vec<PathBuf>,
}
pub struct TrustStore {
file: PathBuf,
state: Mutex<IdeResult<TrustDecisions>>,
}
impl TrustStore {
pub fn load(config: &Path, data: &Path) -> Self {
let file = data.join("project-trust.json");
let state = match std::fs::read(&file) {
Ok(bytes) => serde_json::from_slice(&bytes).map_err(|e| IdeError::invalid(e.to_string())),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
let projects = ide_workspace::RecentProjects::load(config)
.paths()
.into_iter()
.filter_map(|p| dunce::canonicalize(p).ok())
.collect();
let migrated = TrustDecisions { projects, ..Default::default() };
persist(&file, &migrated).map(|()| migrated)
}
Err(e) => Err(e.into()),
};
Self { file, state: Mutex::new(state) }
}
pub fn decisions(&self) -> IdeResult<TrustDecisions> {
self.state.lock().clone()
}
pub fn trusted(&self, path: &Path) -> IdeResult<bool> {
let root = dunce::canonicalize(path)?;
let decisions = self.decisions()?;
Ok(!decisions.restricted.contains(&root)
&& (decisions.projects.contains(&root) || decisions.locations.iter().any(|p| root.starts_with(p))))
}
pub fn decide(&self, path: &Path, trusted: bool, folder: bool) -> IdeResult<()> {
let root = match dunce::canonicalize(path) {
Ok(root) => root,
Err(_) if !trusted && path.is_absolute() => path.to_path_buf(),
Err(error) => return Err(error.into()),
};
let mut state = self.state.lock();
let mut next = state.clone()?;
next.projects.retain(|p| p != &root);
next.restricted.retain(|p| p != &root);
if trusted {
next.projects.push(root.clone());
if folder && let Some(parent) = root.parent() {
let parent = parent.to_path_buf();
if !next.locations.contains(&parent) {
next.locations.push(parent);
}
}
} else {
next.restricted.push(root);
}
persist(&self.file, &next)?;
*state = Ok(next);
Ok(())
}
pub fn remove_location(&self, path: &Path) -> IdeResult<()> {
let mut state = self.state.lock();
let mut next = state.clone()?;
next.locations.retain(|p| p != path);
persist(&self.file, &next)?;
*state = Ok(next);
Ok(())
}
}
fn persist(file: &Path, state: &TrustDecisions) -> IdeResult<()> {
if let Some(parent) = file.parent() {
std::fs::create_dir_all(parent)?;
}
let bytes = serde_json::to_vec_pretty(state).map_err(|e| IdeError::internal(e.to_string()))?;
ide_vfs::write_atomic(file, &bytes)?;
Ok(())
}
impl crate::Host {
pub async fn set_project_trust(&self, path: &Path, trusted: bool, folder: bool) -> IdeResult<()> {
self.trust.decide(path, trusted, folder)?;
self.refresh_project_trust().await
}
pub async fn refresh_project_trust(&self) -> IdeResult<()> {
for project in self.projects().into_iter().filter(|p| !p.light) {
let trusted = self.trust.trusted(&project.root).unwrap_or(false);
let previous = project.trusted.swap(trusted, std::sync::atomic::Ordering::AcqRel);
if previous == trusted {
continue;
}
if !trusted {
project.lsp.set_execution_allowed(false);
}
project.repos();
if trusted {
if let Some(cmd) = self.environment.project_opened(&project.root, &project.sink) {
self.environment.spawn_post_start(cmd, project.root.clone(), project.sink.clone());
}
project.lsp.set_execution_allowed(true);
} else {
self.environment.project_closed();
project.agents.shutdown();
self.processes.kill_project(&project.root);
project.notebooks.restrict().await;
let windows: Vec<_> = self
.projects
.read()
.iter()
.filter(|(_, current)| std::sync::Arc::ptr_eq(current, &project))
.map(|(window, _)| window.clone())
.collect();
for window in windows {
let requests = self.project_requests.write().remove(&window).unwrap_or_default();
for request in requests {
self.cancel_request(request);
}
for session in self.debugger.sessions(Some(&window)) {
let _ = self.debugger.stop(session.id).await;
}
}
}
project.lsp.reconfigure().await;
project.sink.emit(ide_core::IdeEvent::ProjectTrustChanged { root: project.root.clone().into(), trusted });
}
Ok(())
}
}
/// Commands which only inspect/edit source or operate user-owned application state.
/// New execution commands are denied until deliberately classified here.
pub fn restricted_command_allowed(cmd: &str) -> bool {
if [
"fs_",
"doc_",
"syntax_",
"search_",
"nav_",
"history_",
"spell_",
"analysis_",
"structural_",
"refactor_",
"bytes_",
"window_",
"window_group_",
"term_",
"settings_",
"project_trust_",
"ssh_",
"git_credential",
]
.iter()
.any(|p| cmd.starts_with(p))
{
return true;
}
matches!(
cmd,
"app_info"
| "app_metrics"
| "app_quit"
| "app_cancel_quit"
| "app_restart"
| "languages"
| "menu_refresh"
| "menu_model"
| "menu_configure"
| "menu_set_disabled"
| "lsp_servers"
| "lsp_resend_diagnostics"
| "recent_projects"
| "remove_recent_project"
| "open_project"
| "close_project"
| "project_current"
| "project_roots"
| "index_status"
| "jvm_file_presentations"
| "deploy_coverage"
| "find_files"
| "find_symbols"
| "mark_recent"
| "remove_recent"
| "recent_files"
| "web_goto"
| "path_completions"
| "web_unresolved_references"
| "project_rename"
| "light_edit_open"
| "workspace_create"
| "workspace_update"
| "workspace_attach"
| "cancel_request"
| "cancel_task"
| "log_frontend"
| "env_reload"
| "remote_invoke"
| "remote_current"
| "remote_disconnect"
| "remote_connect"
| "remote_list"
| "remote_open"
| "remote_of"
| "web_browsers"
| "web_url"
| "web_open"
| "debug_attachable_processes"
| "agent_subscribe"
| "agent_entries"
| "agent_commands"
| "agent_unqueue"
| "agent_cancel"
| "agent_archive"
| "agent_unarchive"
| "agent_registry_list"
| "agent_registry_install"
| "agent_registry_remove"
| "coverage_import"
| "system_font_families"
| "plugin_subscribe"
| "plugin_list"
| "plugin_set_active_file"
| "plugin_set_theme"
| "plugin_set_theme_frame"
| "plugin_catalog"
| "notebook_open"
| "notebook_get"
| "notebook_edit"
| "notebook_save"
| "notebook_undo"
| "notebook_redo"
| "notebook_reload"
| "notebook_close"
| "notebook_interrupt"
| "run_stop"
| "run_list"
| "debug_stop"
| "debug_sessions"
| "vcs_clone"
| "vcs_init"
| "vcs_status"
| "vcs_roots"
| "vcs_branch_info"
| "vcs_line_hunks"
| "vcs_file_at"
| "vcs_blame"
| "vcs_log"
| "vcs_commit_files"
| "vcs_commits_changes"
| "vcs_branches"
| "vcs_stashes"
| "vcs_conflict_versions"
| "vcs_diff_text"
| "vcs_diff_hunks"
| "vcs_detected_git"
| "vcs_config_get"
| "vcs_tags"
| "vcs_remotes"
| "vcs_worktrees"
| "vcs_diff_with_worktree"
| "vcs_diff_revisions"
| "vcs_merge_base"
| "vcs_shelves"
| "vcs_shelf_patch"
| "vcs_changelists_load"
) || cmd.starts_with("plugin:")
}
impl crate::Host {
pub fn authorize_command(&self, window: &str, command: &str) -> IdeResult<()> {
if restricted_command_allowed(command) {
return Ok(());
}
self.project(window)?.require_trust()
}
}
#[derive(Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct TrustStatus {
pub root: ide_core::IpcPath,
pub trusted: bool,
pub decided: bool,
}
impl TrustStore {
pub fn status(&self, path: &Path) -> IdeResult<TrustStatus> {
let root = dunce::canonicalize(path)?;
let trusted = self.trusted(&root)?;
let state = self.decisions()?;
Ok(TrustStatus { decided: trusted || state.restricted.contains(&root), root: root.into(), trusted })
}
}
+185
View File
@@ -0,0 +1,185 @@
use ide_core::{ChangeSet, NullSink, RequestId, TextChange};
use ide_host::{
Host, HostConfig,
ops::{doc, search, vcs},
};
use ide_search::{SearchQuery, SearchScope};
use std::{
path::{Path, PathBuf},
process::Command,
sync::{Arc, Mutex},
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));
}
fn host(base: &Path) -> 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,
})
}
async fn find(host: &Host, paths: Vec<PathBuf>, pattern: &str) -> Vec<ide_search::FileMatches> {
let files = Arc::new(Mutex::new(Vec::new()));
let found = files.clone();
search::start(
host,
"main",
RequestId(519),
SearchQuery {
pattern: pattern.into(),
is_regex: false,
case_sensitive: true,
whole_word: false,
include: Vec::new(),
exclude: Vec::new(),
scope: SearchScope::Files { paths: paths.into_iter().map(Into::into).collect() },
max_matches: 0,
context: Default::default(),
},
Arc::new(move |event| {
if let search::SearchEvent::File { file } = event {
found.lock().unwrap().push(file);
}
}),
)
.await
.unwrap();
Arc::try_unwrap(files).unwrap().into_inner().unwrap()
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn properties_search_git_and_document_coordinates_agree() {
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("messages.properties");
let original = "hello=\\u4f60\\u597d\\uD83D\\uDE00target\nsecond=unchanged\nescaped=line\\u000Alogical\n";
std::fs::write(&path, original).unwrap();
let host = host(&base);
host.trust.decide(&root, true, false).unwrap();
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;
}
git(&root, &["add", "."]);
git(&root, &["commit", "-m", "Properties"]);
let mut defaults = project.documents.file_defaults();
defaults.native_to_ascii = true;
project.documents.set_file_defaults(defaults);
let displayed = "hello=你好😀target\nsecond=unchanged\nescaped=line\\u000Alogical\n";
assert_eq!(project.text_of(&path).unwrap(), displayed);
let closed = find(&host, vec![path.clone()], "target").await;
let closed_json = serde_json::to_value(&closed).unwrap();
let opened = doc::open(&host, "main", path.clone()).await.unwrap();
assert_eq!(opened.text, displayed);
assert_eq!(serde_json::to_value(find(&host, vec![path.clone()], "target").await).unwrap(), closed_json);
assert_eq!(closed[0].matches[0].ranges, vec![(10, 16)]);
assert_eq!(find(&host, vec![path.clone()], "你好").await[0].matches.len(), 1);
assert!(find(&host, vec![path.clone()], r"\u4f60").await.is_empty());
assert!(vcs::vcs_line_hunks(&host, "main", path.clone()).await.unwrap().is_empty());
assert_eq!(vcs::vcs_file_at(&host, "main", path.clone(), None).await.unwrap().as_deref(), Some(displayed));
assert!(vcs::vcs_blame(&host, "main", path.clone(), None, None).await.unwrap().iter().all(|l| l.author == "Test"));
doc::edit(
&host,
"main",
opened.id,
opened.version,
ChangeSet { changes: vec![TextChange { from: 10, to: 16, insert: "世界".into() }] },
)
.unwrap();
let hunks = vcs::vcs_line_hunks(&host, "main", path.clone()).await.unwrap();
assert_eq!(hunks.len(), 1);
assert_eq!(hunks[0].old_text, "hello=你好😀target\n");
doc::save(&host, "main", opened.id).await.unwrap();
let saved = std::fs::read(&path).unwrap();
assert!(saved.is_ascii());
assert!(String::from_utf8(saved.clone()).unwrap().contains(r"\u4F60\u597D\uD83D\uDE00\u4E16\u754C"));
vcs::vcs_stage_hunks(&host, "main", path.clone(), vec![0]).await.unwrap();
let repo = ide_vcs::Repo::discover(&root).unwrap().unwrap();
assert_eq!(repo.file_bytes_at(&path, Some(":0")).unwrap().unwrap(), saved);
repo.unstage(std::slice::from_ref(&path)).unwrap();
// Line staging must match the decoded base even when Git stores lowercase escapes.
let patch = ide_vcs::build_partial_patch("messages.properties", displayed, &project.text_of(&path).unwrap(), &[0]);
repo.stage_patch(&patch, &|p| project.vcs_encoding(p, Some(":0"))).unwrap();
assert_eq!(repo.file_bytes_at(&path, Some(":0")).unwrap().unwrap(), saved);
repo.unstage(std::slice::from_ref(&path)).unwrap();
let snap = project.documents.snapshot(opened.id).unwrap();
project.documents.replace_all(opened.id, displayed).unwrap();
doc::save(&host, "main", opened.id).await.unwrap();
assert!(project.documents.snapshot(opened.id).unwrap().version > snap.version);
assert!(vcs::vcs_line_hunks(&host, "main", path.clone()).await.unwrap().is_empty());
// Partial commits identify hunks in displayed line coordinates as well.
let partial_text = displayed.replace("target", "世界").replace("logical", "changed");
project.documents.replace_all(opened.id, &partial_text).unwrap();
doc::save(&host, "main", opened.id).await.unwrap();
let hunks = ide_vcs::diff_hunks(displayed, &partial_text);
assert_eq!(hunks.len(), 2);
let selected = &hunks[0];
let options = ide_vcs::CommitOptions {
partial: vec![ide_vcs::PartialCommitFile {
path: path.clone().into(),
hunks: vec![ide_vcs::HunkRange {
start: selected.start,
lines: selected.lines,
old_start: selected.old_start,
old_lines: selected.old_lines,
}],
}],
..Default::default()
};
repo.commit_with("Selected properties change", &options, &|p| project.vcs_encoding(p, None)).unwrap();
let head = vcs::vcs_file_at(&host, "main", path.clone(), None).await.unwrap().unwrap();
assert_eq!(head, displayed.replace("target", "世界"));
assert!(repo.file_bytes_at(&path, None).unwrap().unwrap().is_ascii());
let closed_path = root.join("closed.properties");
std::fs::write(&closed_path, r"value=\u4F60\u597D\uD83D\uDE00target").unwrap();
let query = SearchQuery {
pattern: "target".into(),
is_regex: false,
case_sensitive: true,
whole_word: false,
include: Vec::new(),
exclude: Vec::new(),
scope: SearchScope::Files { paths: vec![closed_path.clone().into()] },
max_matches: 0,
context: Default::default(),
};
search::replace_in_files(
&host,
"main",
RequestId(520),
query,
"世界".into(),
false,
vec![ide_search::ReplaceTarget {
path: closed_path.clone().into(),
only: Some(vec![ide_core::Pos { line: 0, col: 10 }]),
}],
ide_host::ops::Channel::new(|_| {}),
)
.await
.unwrap();
assert_eq!(std::fs::read_to_string(&closed_path).unwrap(), r"value=\u4F60\u597D\uD83D\uDE00\u4E16\u754C");
assert!(project.documents.by_path(&closed_path).is_none());
host.close_project("main");
}
+4
View File
@@ -100,6 +100,7 @@ fn server_environment_from_login_shell_and_devcontainer() {
"postStartCommand": ["/bin/sh", "-c", "echo \"$FROM_PROJECT\" >> started.txt; echo post-start-output"],
}"#,
);
host.trust.decide(&project, true, false).unwrap();
runtime.block_on(ide_host::ops::project::open(&host, "main", &project)).unwrap();
assert_eq!(child_var("FROM_PROJECT"), "two-demo");
assert!(child_var("PATH").starts_with("/project/bin:"), "{}", child_var("PATH"));
@@ -158,6 +159,7 @@ fn server_environment_from_login_shell_and_devcontainer() {
// Reopening the project doesn't run postStartCommand again (once per server start).
host.close_project("main");
assert_eq!(child_var("FROM_PROJECT"), "", "remoteEnv leaves with the project");
host.trust.decide(&project, true, false).unwrap();
runtime.block_on(ide_host::ops::project::open(&host, "main", &project)).unwrap();
std::thread::sleep(Duration::from_millis(500));
assert_eq!(std::fs::read_to_string(&started).unwrap(), "two-demo\n");
@@ -165,6 +167,7 @@ fn server_environment_from_login_shell_and_devcontainer() {
// A failing postStartCommand is a notification; the project opens regardless.
let failing = tmp.join("failing");
write(&failing.join(".devcontainer.json"), r#"{ "postStartCommand": "echo oops >&2; exit 7" }"#);
host.trust.decide(&failing, true, false).unwrap();
runtime.block_on(ide_host::ops::project::open(&host, "main", &failing)).unwrap();
wait_for("the failure notification", || {
events.0.lock().iter().any(|e| {
@@ -176,6 +179,7 @@ fn server_environment_from_login_shell_and_devcontainer() {
// A broken file is a warning, not an error.
let broken = tmp.join("broken");
write(&broken.join(".devcontainer.json"), "{ \"remoteEnv\": ");
host.trust.decide(&broken, true, false).unwrap();
runtime.block_on(ide_host::ops::project::open(&host, "main", &broken)).unwrap();
assert!(events.0.lock().iter().any(|e| matches!(e,
IdeEvent::Notify { level: NotifyLevel::Warning, title, .. } if title == "devcontainer.json was not applied")));
+173 -1
View File
@@ -70,17 +70,51 @@ async fn serves_projects_files_documents_search_and_runs() {
let client = Arc::new(Client::default());
let peer = Peer::connect(cr, cw, client.clone());
let info: ServerInfo = peer.request("initialize", json!({ "version": 1 }), None).await.unwrap();
let info: ServerInfo = peer.request("initialize", json!({ "version": rpc::PROTOCOL_VERSION }), None).await.unwrap();
assert_eq!(info.protocol, rpc::PROTOCOL_VERSION);
let opened = invoke(&peer, "open_project", json!({ "path": project })).await.unwrap();
assert_eq!(opened["name"], "demo");
assert_eq!(opened["trusted"], false);
invoke(&peer, "index_status", json!({})).await.unwrap();
assert_eq!(invoke(&peer, "lsp_servers", json!({})).await.unwrap(), json!([]));
invoke(&peer, "lsp_resend_diagnostics", json!({})).await.unwrap();
let files = invoke(&peer, "find_files", json!({"query": "main", "limit": 10})).await.unwrap();
assert!(files.is_array());
for command in ["build_sync", "run_start", "sdk_locate_tools", "notebook_execute", "debug_start"] {
let error = invoke(&peer, command, json!({})).await.unwrap_err();
assert!(error.message.contains("Restricted Mode"), "{command}: {error:?}");
}
let call = Invoke { window: "unknown".into(), cmd: "coverage_collect".into(), args: json!({}) };
assert!(peer.request::<_, Value>("invoke", call, None).await.is_err());
invoke(&peer, "project_trust_set", json!({"path": project, "trusted": true, "folder": false})).await.unwrap();
let current = invoke(&peer, "project_current", json!({})).await.unwrap();
assert_eq!(current["name"], "demo");
let entries = invoke(&peer, "fs_read_dir", json!({ "path": project })).await.unwrap();
assert!(entries.as_array().unwrap().iter().any(|e| e["name"] == "src"), "{entries}");
// JVM icons use the same read-only protocol as local windows.
let java = project.join("src/Foo.java");
let kotlin = project.join("src/Singleton.kt");
std::fs::write(&java, "abstract class Foo {}").unwrap();
std::fs::write(&kotlin, "object Singleton {}").unwrap();
let icons = invoke(&peer, "jvm_file_presentations", json!({ "paths": [java, kotlin] })).await.unwrap();
assert_eq!(icons[0]["presentation"]["kind"], "abstractClass");
assert_eq!(icons[1]["presentation"]["kind"], "object");
// Replacing a file invalidates its cached metadata, including same-size writes.
std::fs::write(&java, "interface Foo {} ").unwrap();
std::fs::OpenOptions::new()
.write(true)
.open(&java)
.unwrap()
.set_modified(std::time::SystemTime::UNIX_EPOCH)
.unwrap();
let icons = invoke(&peer, "jvm_file_presentations", json!({ "paths": [java] })).await.unwrap();
assert_eq!(icons[0]["presentation"]["kind"], "interface");
let oversized = invoke(&peer, "jvm_file_presentations", json!({ "paths": vec![java.clone(); 65] })).await;
assert!(oversized.is_err());
// Edit and save a document.
let file = project.join("src/main.py");
let doc = invoke(&peer, "doc_open", json!({ "path": file })).await.unwrap();
@@ -91,6 +125,14 @@ async fn serves_projects_files_documents_search_and_runs() {
invoke(&peer, "doc_save", json!({ "doc": id })).await.unwrap();
assert_eq!(std::fs::read_to_string(&file).unwrap(), "print('needle')\nprint('more')\n");
let outside = tmp.path().join("outside.txt");
std::fs::write(&outside, "original\n").unwrap();
let external = invoke(&peer, "doc_open", json!({ "path": outside })).await.unwrap();
std::fs::write(&outside, "external change\n").unwrap();
invoke(&peer, "doc_sync_from_disk", json!({})).await.unwrap();
let snapshot = invoke(&peer, "doc_snapshot", json!({ "doc": external["id"] })).await.unwrap();
assert_eq!(snapshot["text"], "external change\n");
// Commands shared with the desktop shell (camelCase argument names, as Tauri takes them).
invoke(&peer, "doc_set_line_ending", json!({ "doc": id, "lineEnding": "crLf" })).await.unwrap();
invoke(&peer, "doc_save", json!({ "doc": id })).await.unwrap();
@@ -170,3 +212,133 @@ impl EventType for EventMessage {
serde_json::to_value(&self.event).unwrap()["type"].as_str().unwrap_or_default().to_owned()
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn local_deployment_runs_on_the_project_host() {
let tmp = tempfile::tempdir().unwrap();
let project = tmp.path().join("project");
let target = tmp.path().join("mounted");
std::fs::create_dir(&project).unwrap();
std::fs::create_dir(&target).unwrap();
let project = dunce::canonicalize(project).unwrap();
let file = project.join("local.txt");
std::fs::write(&file, "host file").unwrap();
let slot = Arc::new(OnceLock::new());
let sink_slot = slot.clone();
let host = Arc::new(Host::new(HostConfig {
config_dir: tmp.path().join("config"),
data_dir: tmp.path().join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(move |w| rpc::event_sink(sink_slot.clone(), Some(w.to_owned()))),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
}));
let dispatcher = Dispatcher::standard();
for cmd in [
"deploy_coverage",
"deploy_upload",
"deploy_download",
"deploy_delete_remote",
"deploy_compare",
"deploy_sync",
"deploy_info",
"deploy_server_root",
"deploy_test_connection",
"remote_list",
"remote_open",
"remote_of",
"remote_delete",
"remote_mkdir",
"remote_rename",
] {
assert!(dispatcher.commands().contains(&cmd), "missing host command {cmd}");
}
let (client_io, server_io) = tokio::io::duplex(1 << 20);
let (sr, sw) = tokio::io::split(server_io);
tokio::spawn(rpc::serve(host, dispatcher, slot, sr, sw));
let (cr, cw) = tokio::io::split(client_io);
let peer = Peer::connect(cr, cw, Arc::new(Client::default()));
invoke(&peer, "project_trust_set", json!({"path": project, "trusted": true, "folder": false})).await.unwrap();
invoke(&peer, "open_project", json!({"path": project})).await.unwrap();
let deployment = json!({"servers": [{"id":"local", "kind":"local", "rootPath":target}], "defaultServer":"local"});
invoke(&peer, "settings_set", json!({"scope":"project", "key":"deployment", "value":deployment.to_string()}))
.await
.unwrap();
let coverage = invoke(&peer, "deploy_coverage", json!({"paths":[file]})).await.unwrap();
assert_eq!(coverage[0]["server"], "local");
assert_eq!(coverage[0]["paths"], json!([ide_core::IpcPath::from(&file)]));
invoke(&peer, "deploy_test_connection", json!({"connection":{"kind":"local"}, "path":target})).await.unwrap();
let report = invoke(&peer, "deploy_upload", json!({"paths":[file], "request":1001})).await.unwrap();
assert_eq!(report["files"], 1, "{report}");
assert_eq!(std::fs::read_to_string(target.join("local.txt")).unwrap(), "host file");
let entries = invoke(&peer, "remote_list", json!({"connection":{"kind":"local"}, "path":target})).await.unwrap();
assert_eq!(entries[0]["name"], "local.txt");
std::fs::write(target.join("local.txt"), "host download").unwrap();
let report = invoke(&peer, "deploy_download", json!({"paths":[file], "request":1002})).await.unwrap();
assert_eq!(report["files"], 1);
assert_eq!(std::fs::read_to_string(&file).unwrap(), "host download");
invoke(&peer, "deploy_delete_remote", json!({"paths":[file]})).await.unwrap();
assert!(!target.join("local.txt").exists());
invoke(&peer, "close_project", json!({})).await.unwrap();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn properties_conversion_through_remote_dispatch_without_a_remote_host() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("properties-project");
std::fs::create_dir(&root).unwrap();
let path = root.join("messages.properties");
std::fs::write(&path, r"value=\u4F60\u597D\uD83D\uDE00!").unwrap();
let peer_slot = Arc::new(OnceLock::new());
let host = Arc::new(Host::new(HostConfig {
config_dir: tmp.path().join("config"),
data_dir: tmp.path().join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(ide_core::NullSink)),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
}));
let (client_io, server_io) = tokio::io::duplex(1 << 20);
let (sr, sw) = tokio::io::split(server_io);
tokio::spawn(rpc::serve(host, Dispatcher::standard(), peer_slot, sr, sw));
let (cr, cw) = tokio::io::split(client_io);
let peer = Peer::connect(cr, cw, Arc::new(Client::default()));
invoke(&peer, "open_project", json!({"path":root})).await.unwrap();
let settings = invoke(&peer, "settings_get", json!({})).await.unwrap();
assert_eq!(settings["editor"]["encodings"]["nativeToAscii"], false);
let opened = invoke(&peer, "doc_open", json!({"path":path})).await.unwrap();
let doc = opened["id"].clone();
invoke(&peer, "settings_set", json!({"scope":"project", "key":"editor.encodings.nativeToAscii", "value":"true"}))
.await
.unwrap();
let mut snap = Value::Null;
for _ in 0..500 {
snap = invoke(&peer, "doc_snapshot", json!({"doc":doc})).await.unwrap();
if snap["text"] == "value=你好😀!" {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
assert_eq!(snap["text"], "value=你好😀!");
assert_eq!(snap["dirty"], false);
invoke(
&peer,
"doc_edit",
json!({"doc":doc, "baseVersion":snap["version"],
"changes":{"changes":[{"from":10,"to":11,"insert":"界"}]}}),
)
.await
.unwrap();
invoke(&peer, "doc_save", json!({"doc":doc})).await.unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), r"value=\u4F60\u597D\uD83D\uDE00\u754C");
invoke(&peer, "doc_set_encoding", json!({"doc":doc,"encoding":"utf-8","reload":true})).await.unwrap();
let reloaded = invoke(&peer, "doc_snapshot", json!({"doc":doc})).await.unwrap();
assert_eq!(reloaded["text"], "value=你好😀界");
assert_eq!(reloaded["dirty"], false);
invoke(&peer, "close_project", json!({})).await.unwrap();
peer.kill();
}
+23
View File
@@ -21,6 +21,7 @@ async fn removed_detections_stay_removed_and_overrides_restore() {
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.trust.decide(&root, true, false).unwrap();
host.open_project("main", &root).unwrap();
let detected = ops::run::configs(&host, "main").unwrap();
assert!(detected.len() >= 2);
@@ -58,6 +59,7 @@ async fn gradle_defaults_are_not_saved_as_explicit_run_choices() {
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.trust.decide(&root, true, false).unwrap();
host.open_project("main", &root).unwrap();
ops::project::set_settings(
&host,
@@ -77,3 +79,24 @@ async fn gradle_defaults_are_not_saved_as_explicit_run_choices() {
assert_eq!(saved.env["JAVA_HOME"], "/explicit-jdk");
assert_eq!(saved.command, gradle.command);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn workspace_cmake_before_launch_tasks_resolve_in_the_member() {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
let member = root.join("native");
std::fs::create_dir(&member).unwrap();
std::fs::write(member.join("CMakeLists.txt"), "project(demo)").unwrap();
let workspace = root.join("workspace");
ide_workspace::workspace::Workspace::save(&workspace, "Native", vec![member.clone()], true).unwrap();
let host = host(&root.join("app"));
host.trust.decide(&workspace, true, false).unwrap();
host.trust.decide(&member, true, false).unwrap();
host.open_project("main", &workspace).unwrap();
let configs = ops::run::configs(&host, "main").unwrap();
let build = configs.iter().find(|c| c.kind == "cmake" && c.args.first().is_some_and(|a| a == "--build")).unwrap();
assert_eq!(build.cwd.as_deref(), Some(member.as_path()));
for task in &build.extras.before_launch {
assert!(configs.iter().any(|c| c.id == task.target && c.cwd == build.cwd));
}
}
+244
View File
@@ -0,0 +1,244 @@
use ide_core::NullSink;
use ide_host::{Host, HostConfig, ops, trust::TrustStore};
use std::path::Path;
use std::sync::Arc;
use std::time::{Duration, Instant};
fn host(base: &Path, environment: bool) -> 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: environment,
}))
}
#[test]
fn persists_user_decisions_locations_revocation_and_one_time_migration() {
let tmp = tempfile::tempdir().unwrap();
let config = tmp.path().join("config");
let data = tmp.path().join("data");
let a = tmp.path().join("projects/a");
let b = tmp.path().join("projects/b");
std::fs::create_dir_all(a.join(".openidea")).unwrap();
std::fs::create_dir_all(b.join(".openidea")).unwrap();
std::fs::write(a.join(".openidea/project-trust.json"), r#"{"trusted":true}"#).unwrap();
std::fs::write(b.join(".openidea/project-trust.json"), r#"{"trusted":true}"#).unwrap();
let recent = ide_workspace::RecentProjects::load(&config);
recent.touch(&a, None).unwrap();
let trust = TrustStore::load(&config, &data);
assert!(trust.trusted(&a).unwrap());
assert!(!trust.status(&b).unwrap().decided);
recent.touch(&b, None).unwrap();
let trust = TrustStore::load(&config, &data);
assert!(!trust.trusted(&b).unwrap(), "migration is not repeated");
trust.decide(&a, true, true).unwrap();
assert!(trust.trusted(&b).unwrap());
trust.decide(&b, false, false).unwrap();
assert!(!trust.trusted(&b).unwrap(), "revocation overrides a trusted location");
let trust = TrustStore::load(&config, &data);
assert!(!trust.trusted(&b).unwrap());
trust.remove_location(&dunce::canonicalize(a.parent().unwrap()).unwrap()).unwrap();
assert!(trust.trusted(&a).unwrap());
trust.decide(&a, false, false).unwrap();
assert!(!TrustStore::load(&config, &data).trusted(&a).unwrap());
std::fs::write(data.join("project-trust.json"), "broken").unwrap();
assert!(TrustStore::load(&config, &data).trusted(&a).is_err(), "corruption fails closed");
}
#[cfg(unix)]
fn script(root: &Path, name: &str, marker: &str) {
use std::os::unix::fs::PermissionsExt;
let path = root.join(name);
std::fs::write(&path, format!("#!/bin/sh\nprintf executed > '{marker}'\n")).unwrap();
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap();
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn malicious_project_stays_editable_then_starts_in_place_after_trust() {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap().join("malicious");
std::fs::create_dir_all(root.join(".openidea")).unwrap();
script(&root, "hook", "hook.marker");
script(&root, "server", "lsp.marker");
script(&root, "gradlew", "build.marker");
std::fs::write(root.join("build.gradle"), "plugins { id 'java' }").unwrap();
std::fs::write(root.join("main.py"), "print('safe')\n").unwrap();
std::fs::write(root.join(".devcontainer.json"), r#"{"postStartCommand":["./hook"]}"#).unwrap();
std::fs::write(root.join(".openidea/settings.toml"), format!(
"[languageServers.trust-witness]\nlanguages = ['python']\ncommand = {:?}\n[languageServers.pyright]\nenabled = false\n", root.join("server").to_str().unwrap()
)).unwrap();
let host = host(&tmp.path().join("app"), true);
let project = host.open_project("main", &root).unwrap();
assert!(!project.is_trusted());
let doc = ops::doc::open(&host, "main", root.join("main.py")).await.unwrap();
ops::doc::save(&host, "main", doc.id).await.unwrap();
assert!(project.index.wait_idle(Duration::from_secs(10)));
assert!(project.index.status().files > 0);
let sync = || {
ops::build::build_sync(
&host,
"main",
ide_build::BuildTool::Gradle,
ide_core::RequestId(472),
false,
vec![],
ops::Channel::new(|_| {}),
)
};
assert!(sync().await.is_err(), "restricted build marker: {}", root.join("build.marker").exists());
assert!(
ops::sdk::sdk_locate_tools(&host, "missing-window", vec!["server".into()], Some(root.clone())).await.is_err()
);
for command in [
"run_start",
"debug_start",
"notebook_execute",
"coverage_collect",
"profiler_collect",
"docker_compose_op",
"agent_prompt",
"sdk_detect",
"build_sync",
] {
assert!(host.authorize_command("main", command).is_err(), "{command}");
assert!(host.authorize_command("missing-window", command).is_err(), "{command}");
}
tokio::time::sleep(Duration::from_millis(200)).await;
for marker in ["hook.marker", "lsp.marker", "build.marker"] {
assert!(!root.join(marker).exists(), "{marker}");
}
host.set_project_trust(&root, true, false).await.unwrap();
assert!(Arc::ptr_eq(&project, &host.project("main").unwrap()));
assert_eq!(project.documents.snapshot(doc.id).unwrap().text, doc.text);
sync().await.unwrap();
let deadline = Instant::now() + Duration::from_secs(10);
while !root.join("hook.marker").exists() || !root.join("lsp.marker").exists() {
assert!(Instant::now() < deadline, "trusted services must start");
tokio::time::sleep(Duration::from_millis(20)).await;
}
assert!(root.join("build.marker").exists());
host.set_project_trust(&root, false, false).await.unwrap();
assert!(!project.is_trusted());
assert!(sync().await.is_err());
ops::doc::save(&host, "main", doc.id).await.unwrap();
host.shutdown().await;
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn restricted_terminal_keeps_user_input_but_ignores_project_startup_commands() {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap().join("project");
std::fs::create_dir_all(&root).unwrap();
let host = host(&tmp.path().join("app"), false);
host.global_settings
.set(
ide_workspace::SettingsScope::Global,
"terminal.env",
serde_json::json!({"ZDOTDIR": root, "ENV": "/dev/null", "BASH_ENV": "/dev/null"}),
)
.unwrap();
host.open_project("main", &root).unwrap();
let terminal = ops::term::spawn(
&host,
"main",
ide_terminal::TerminalOptions {
cwd: root.clone().into(),
shell: Some("/bin/sh".into()),
args: vec!["-c".into(), "touch automatic.marker".into()],
env: Default::default(),
cols: 80,
rows: 24,
shell_integration: false,
},
Arc::new(|_| {}),
)
.unwrap();
host.terminals.write(terminal.id, "printf user > terminal.marker; exit\n").unwrap();
let deadline = Instant::now() + Duration::from_secs(10);
while !root.join("terminal.marker").exists() {
assert!(Instant::now() < deadline, "user terminal input must run");
tokio::time::sleep(Duration::from_millis(20)).await;
}
assert!(!root.join("automatic.marker").exists());
host.shutdown().await;
}
#[cfg(unix)]
#[test]
fn trusted_locations_use_canonical_directory_boundaries() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("trusted/project");
let other = tmp.path().join("trusted-other/project");
std::fs::create_dir_all(&root).unwrap();
std::fs::create_dir_all(&other).unwrap();
std::os::unix::fs::symlink(other.parent().unwrap(), root.parent().unwrap().join("escape")).unwrap();
let trust = TrustStore::load(&tmp.path().join("config"), &tmp.path().join("data"));
trust.decide(&root, true, true).unwrap();
assert!(!trust.trusted(&other).unwrap());
let alias = root.parent().unwrap().join("escape/project");
assert!(!trust.trusted(&alias).unwrap());
assert_eq!(trust.status(&alias).unwrap().root.as_ref(), dunce::canonicalize(&other).unwrap());
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn restricted_git_views_ignore_project_executables_and_mutations_cannot_run_hooks() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap().join("project");
std::fs::create_dir_all(root.join(".openidea")).unwrap();
let git = |args: &[&str]| {
let output = std::process::Command::new("git").current_dir(&root).args(args).output().unwrap();
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
};
git(&["init", "-q"]);
git(&["config", "user.name", "Test"]);
git(&["config", "user.email", "[email protected]"]);
git(&["config", "commit.gpgsign", "false"]);
git(&["config", "core.hooksPath", ".git/hooks"]);
let file = root.join("safe.txt");
std::fs::write(&file, "safe\n").unwrap();
script(&root, ".git/hooks/pre-commit", "git-hook.marker");
let executable = root.join(".openidea/git-witness");
std::fs::write(&executable, "#!/bin/sh\nprintf executed > git-executable.marker\nexec git \"$@\"\n").unwrap();
std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o755)).unwrap();
std::fs::write(
root.join(".openidea/settings.toml"),
format!("[vcs.git]\nexecutable = {:?}\n", executable.to_str().unwrap()),
)
.unwrap();
let host = host(&tmp.path().join("app"), false);
let project = host.open_project("main", &root).unwrap();
assert!(!project.with_repo(|repo| repo.status()).unwrap().is_empty());
assert!(ops::vcs::vcs_stage(&host, "main", vec![file.clone()]).await.is_err());
assert!(!root.join("git-hook.marker").exists());
assert!(!root.join("git-executable.marker").exists());
host.set_project_trust(&root, true, false).await.unwrap();
ops::vcs::vcs_stage(&host, "main", vec![file]).await.unwrap();
project.with_repo(|repo| repo.commit("trusted", false, false)).unwrap();
assert!(root.join("git-executable.marker").exists());
assert!(root.join("git-hook.marker").exists());
ide_vcs::set_git_executable(None);
host.shutdown().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn revocation_still_applies_when_a_project_folder_was_removed() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("project");
std::fs::create_dir(&root).unwrap();
let host = host(&tmp.path().join("app"), false);
host.trust.decide(&root, true, true).unwrap();
let project = host.open_project("main", &root).unwrap();
std::fs::remove_dir_all(&root).unwrap();
host.set_project_trust(&project.root, false, false).await.unwrap();
assert!(!project.is_trusted());
host.shutdown().await;
}
+36
View File
@@ -0,0 +1,36 @@
//! The configured Git binary is process-wide, so it has its own test binary.
#![cfg(unix)]
use ide_core::NullSink;
use ide_host::{Host, HostConfig, ops};
use std::os::unix::fs::PermissionsExt;
use std::sync::Arc;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn restricted_status_does_not_select_the_project_git_for_other_windows() {
let tmp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(tmp.path()).unwrap();
let 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,
}));
let root = base.join("malicious");
std::fs::create_dir_all(root.join(".openidea")).unwrap();
let marker = base.join("git.marker");
let git = root.join("git");
std::fs::write(&git, format!("#!/bin/sh\nprintf executed > '{}'\n", marker.display())).unwrap();
std::fs::set_permissions(&git, std::fs::Permissions::from_mode(0o755)).unwrap();
std::fs::write(root.join(".openidea/settings.toml"), format!("[vcs.git]\nexecutable = {:?}\n", git)).unwrap();
ide_vcs::init(&root).unwrap();
host.open_project("main", &root).unwrap();
ops::vcs::status(&host, "main").await.unwrap();
let _ = ide_vcs::git_version(None);
assert!(!marker.exists(), "another window's next Git command would run the project's binary");
}
+5 -4
View File
@@ -35,6 +35,7 @@ async fn gbk_document_gutter_diff_stage_and_rollback_share_encoding() {
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
host.trust.decide(&root, true, false).unwrap();
let project = host.open_project("main", &root).unwrap();
for _ in 0..500 {
if !project.repos().is_empty() {
@@ -53,7 +54,7 @@ async fn gbk_document_gutter_diff_stage_and_rollback_share_encoding() {
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());
assert!(vcs::vcs_line_hunks(&host, "main", path.clone()).await.unwrap().is_empty());
let from = old.find("value = 1").unwrap() + "value = ".len();
let offset = old[..from].encode_utf16().count() as u32;
project
@@ -64,12 +65,12 @@ async fn gbk_document_gutter_diff_stage_and_rollback_share_encoding() {
ChangeSet { changes: vec![TextChange { from: offset, to: offset + 1, insert: "2".into() }] },
)
.unwrap();
let hunks = vcs::vcs_line_hunks(&host, "main", path.clone()).unwrap();
let hunks = vcs::vcs_line_hunks(&host, "main", path.clone()).await.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();
vcs::vcs_stage_hunks(&host, "main", path.clone(), vec![0]).await.unwrap();
let repo = ide_vcs::Repo::discover(&root).unwrap().unwrap();
assert_eq!(
repo.file_bytes_at(&path, Some(":0")).unwrap().unwrap(),
@@ -95,7 +96,7 @@ async fn gbk_document_gutter_diff_stage_and_rollback_share_encoding() {
.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_line_hunks(&host, "main", path.clone()).await.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);
+1
View File
@@ -38,6 +38,7 @@ async fn patches_read_and_write_the_requested_repository() {
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
host.trust.decide(&project_root, true, false).unwrap();
let project = host.open_project("main", &project_root).unwrap();
for _ in 0..500 {
if project.repos().all().len() == 2 {
+1
View File
@@ -30,6 +30,7 @@ async fn linked_projects_share_navigation_but_keep_files_repositories_and_run_di
project_environment: false,
});
std::fs::write(root.join("internal-needle.txt"), "needle").unwrap();
host.trust.decide(&root, true, false).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());
+3
View File
@@ -120,6 +120,9 @@ pub struct LanguageServerConfig {
/// a JDK 21+); reported as the failure instead of launching it.
#[serde(skip)]
pub launch_error: Option<String>,
/// Register source files with tsserver when no project configuration exists.
#[serde(default)]
pub tsserver_project: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
+2
View File
@@ -7,6 +7,7 @@ license.workspace = true
[dependencies]
ide-core.workspace = true
dunce.workspace = true
ide-text.workspace = true
ide-language.workspace = true
ide-jsonrpc.workspace = true
@@ -19,6 +20,7 @@ parking_lot.workspace = true
tracing.workspace = true
futures.workspace = true
globset.workspace = true
ignore.workspace = true
toml.workspace = true
percent-encoding = "2"
+32 -1
View File
@@ -661,7 +661,9 @@ pub fn workspace_edit(conv: &Conv, e: lsp::WorkspaceEdit) -> WorkspaceEdit {
}
}
}
WorkspaceEdit { files }
let mut edit = WorkspaceEdit { files };
edit.deduplicate();
edit
}
/// Adds the changes of `extra`, another server's edit for the same request, to
@@ -710,6 +712,14 @@ pub fn goto_response(conv: &Conv, r: lsp::GotoDefinitionResponse) -> Vec<Locatio
}
pub fn dedup_locations(v: &mut Vec<Location>) {
let mut canonical = std::collections::HashMap::new();
for location in v.iter_mut().filter(|l| l.uri.is_none()) {
let path =
canonical.entry(location.path.clone()).or_insert_with(|| dunce::canonicalize(&location.path).ok()).clone();
if let Some(path) = path {
location.path = path.into();
}
}
let mut seen = std::collections::HashSet::new();
v.retain(|l| seen.insert(l.clone()));
}
@@ -1405,3 +1415,24 @@ mod tests {
assert!(document_link(&conv, here, link(None)).is_none());
}
}
#[cfg(all(test, unix))]
mod alias_tests {
use super::*;
#[test]
fn references_to_a_file_and_its_symlink_are_one_location() {
let dir = tempfile::tempdir().unwrap();
let real = dir.path().join("main.cpp");
let alias = dir.path().join("alias.cpp");
std::fs::write(&real, "add()").unwrap();
std::os::unix::fs::symlink(&real, &alias).unwrap();
let mut locations: Vec<Location> = [real.clone(), alias]
.into_iter()
.map(|p| Location { path: p.into(), range: Range::new(Pos::new(0, 0), Pos::new(0, 3)), uri: None })
.collect();
dedup_locations(&mut locations);
assert_eq!(locations.len(), 1);
assert_eq!(locations[0].path.as_ref(), dunce::canonicalize(real).unwrap());
}
}
+286
View File
@@ -0,0 +1,286 @@
//! Give tsserver a project for unopened sources, without creating a config file.
use crate::LspManager;
use crate::server::{Running, Server};
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::Ordering;
use std::time::Duration;
pub type ExcludesFn = dyn Fn() -> Vec<String> + Send + Sync;
const COMMAND: &str = "typescript.tsserverRequest";
const LIMIT: usize = 10_000;
const SCAN_LIMIT: usize = 100_000;
/// tsserver turns its language service off past 20 MiB of JavaScript in a project.
const SCRIPT_BYTES_LIMIT: u64 = 16 << 20;
const REFRESH_DELAY: Duration = Duration::from_millis(300);
const TIMEOUT: Duration = Duration::from_secs(15);
#[derive(Default)]
pub(crate) struct State {
generation: u64,
files: Vec<PathBuf>,
}
fn supported(server: &Server, run: &Running) -> bool {
server.config.tsserver_project
&& run
.caps
.read()
.raw
.pointer("/executeCommandProvider/commands")
.and_then(Value::as_array)
.is_some_and(|commands| commands.iter().any(|c| c.as_str() == Some(COMMAND)))
}
fn configured(dir: &Path) -> bool {
["tsconfig.json", "jsconfig.json"].iter().any(|name| dir.join(name).is_file())
}
fn is_script(ext: &str) -> bool {
matches!(ext, "js" | "jsx" | "mjs" | "cjs")
}
fn is_source(ext: &str) -> bool {
is_script(ext) || matches!(ext, "ts" | "tsx" | "mts" | "cts")
}
/// All-or-nothing at the caps: a partial project could displace an inferred
/// project while omitting some of its files. Configured trees stay with tsserver.
fn source_files(root: &Path, exclude_root: &Path, excludes: Vec<String>, limit: usize) -> Vec<PathBuf> {
if root.ancestors().any(configured) {
return Vec::new();
}
let mut ignores = ignore::gitignore::GitignoreBuilder::new(exclude_root);
for pattern in excludes {
if ignores.add_line(None, &pattern).is_err() {
return Vec::new();
}
}
let Ok(ignores) = ignores.build() else { return Vec::new() };
let mut walk = ignore::WalkBuilder::new(root);
walk.require_git(false).follow_links(false).filter_entry(move |entry| {
if entry.depth() == 0 {
return true;
}
let path = entry.path();
let name = entry.file_name().to_string_lossy();
!matches!(name.as_ref(), "node_modules" | "dist" | "build" | "out" | "coverage" | "target" | ".git")
&& !ignores.matched_path_or_any_parents(path, entry.file_type().is_some_and(|t| t.is_dir())).is_ignore()
});
let mut files = Vec::new();
let mut script_bytes = 0;
for (visited, entry) in walk.build().enumerate() {
// Do not claim a tree is config-less if we could not inspect it fully.
let Ok(entry) = entry else { return Vec::new() };
if visited >= SCAN_LIMIT {
return Vec::new();
}
if entry.file_type().is_some_and(|t| t.is_dir()) && configured(entry.path()) {
return Vec::new();
}
let ext = entry.path().extension().and_then(|s| s.to_str()).unwrap_or_default();
if entry.file_type().is_some_and(|t| t.is_file()) && is_source(ext) {
if is_script(ext) {
script_bytes += entry.metadata().map_or(0, |m| m.len());
if script_bytes > SCRIPT_BYTES_LIMIT {
tracing::debug!("external TypeScript project exceeds its script size; retaining inferred projects");
return Vec::new();
}
}
files.push(entry.into_path());
if files.len() > limit {
tracing::debug!("external TypeScript project exceeds {limit} files; retaining inferred projects");
return Vec::new();
}
}
}
files.sort();
files
}
fn project_name(server: &Server) -> PathBuf {
server.root.join(".openidea-external-project")
}
async fn request(run: &Running, command: &str, args: Value) -> bool {
let request = run.peer.request::<_, Value>(
"workspace/executeCommand",
json!({
"command": COMMAND,
"arguments": [command, args, { "isAsync": false, "expectsResult": true }]
}),
None,
);
match tokio::time::timeout(TIMEOUT, request).await {
Ok(Ok(result)) if result.get("success").and_then(Value::as_bool) == Some(true) => true,
result => {
tracing::debug!("tsserver {command}: {result:?}");
false
}
}
}
impl LspManager {
pub fn set_project_excludes_hook(&self, hook: Box<ExcludesFn>) {
*self.excludes_hook.write() = Some(Arc::from(hook));
}
pub(crate) async fn refresh_external_project(&self, server: &Server, run: &Running) {
if !supported(server, run) {
return;
}
let mut state = server.external_project.lock().await;
if state.generation != run.generation {
*state = State { generation: run.generation, files: Vec::new() };
server.external_diagnostics.store(false, Ordering::Release);
}
let root = server.root.clone();
let exclude_root = self.project_root_for(&root);
let hook = self.excludes_hook.read().clone();
let files = tokio::task::spawn_blocking(move || {
source_files(&root, &exclude_root, hook.map(|h| h()).unwrap_or_default(), LIMIT)
})
.await
.unwrap_or_default();
if state.files == files {
return;
}
if files.is_empty() {
if request(run, "closeExternalProject", json!({ "projectFileName": project_name(server) })).await {
state.files.clear();
// Keep suppressing delayed diagnostics for unopened files from this instance.
}
return;
}
server.external_diagnostics.store(true, Ordering::Release);
let roots: Vec<_> = files.iter().map(|p| json!({ "fileName": p })).collect();
if request(
run,
"openExternalProject",
json!({
"projectFileName": project_name(server), "rootFiles": roots,
// What an inferred project assumes, so sharing a project changes no diagnostics.
"options": { "allowJs": true, "target": "ES2022", "module": "ESNext",
"moduleResolution": "Node", "allowNonTsExtensions": true, "jsx": "preserve",
"allowSyntheticDefaultImports": true, "strictNullChecks": true,
"strictFunctionTypes": true, "moduleDetection": "force" }
}),
)
.await
{
tracing::debug!(
"{}: registered {} external project files at {}",
server.name,
files.len(),
server.root.display()
);
state.files = files;
}
}
/// Re-registers config-less projects after sources or configurations changed on disk.
pub fn project_files_changed<'a>(&self, paths: impl IntoIterator<Item = &'a PathBuf>) {
let relevant = paths.into_iter().any(|p| {
p.extension().and_then(|s| s.to_str()).is_some_and(is_source)
|| p.file_name().is_some_and(|n| n == "tsconfig.json" || n == "jsconfig.json")
});
if !relevant {
return;
}
for server in self.servers.lock().iter().filter(|s| s.config.tsserver_project) {
if server.external_refresh_queued.swap(true, Ordering::AcqRel) {
continue;
}
let (me, server) = (self.me.clone(), server.clone());
self.runtime.spawn(async move {
tokio::time::sleep(REFRESH_DELAY).await;
server.external_refresh_queued.store(false, Ordering::Release);
if let (Some(me), Some(run)) = (me.upgrade(), server.running()) {
me.refresh_external_project(&server, &run).await;
}
});
}
}
pub(crate) async fn close_external_project(&self, server: &Server, run: &Running) {
let mut state = server.external_project.lock().await;
if state.generation == run.generation && !state.files.is_empty() {
request(run, "closeExternalProject", json!({ "projectFileName": project_name(server) })).await;
state.files.clear();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn write(root: &Path, path: &str) {
let path = root.join(path);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, "").unwrap();
}
#[test]
fn selects_sources_honouring_ignores_exclusions_and_roots() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
for path in [
"a.ts",
"src/b.js",
"src/c.tsx",
"readme.md",
"node_modules/n.ts",
"dist/d.js",
"excluded/e.ts",
"ignored/i.ts",
"other/f.py",
] {
write(root, path);
}
std::fs::write(root.join(".gitignore"), "ignored/\n").unwrap();
assert_eq!(
source_files(root, root, vec!["excluded".into()], LIMIT),
vec![root.join("a.ts"), root.join("src/b.js"), root.join("src/c.tsx")]
);
assert!(source_files(root, root, vec![], 1).is_empty());
std::fs::File::create(root.join("large.js")).unwrap().set_len(SCRIPT_BYTES_LIMIT + 1).unwrap();
assert!(source_files(root, root, vec![], LIMIT).is_empty());
std::fs::remove_file(root.join("large.js")).unwrap();
std::fs::File::create(root.join("large.ts")).unwrap().set_len(SCRIPT_BYTES_LIMIT + 1).unwrap();
assert_eq!(source_files(root, root, vec!["excluded".into()], LIMIT).len(), 4);
std::fs::remove_file(root.join("large.ts")).unwrap();
assert_eq!(source_files(&root.join("src"), root, vec![], LIMIT).len(), 2);
}
#[test]
fn root_ancestor_and_nested_configs_keep_existing_projects() {
for name in ["tsconfig.json", "jsconfig.json"] {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write(root, "a.ts");
write(root, "pkg/b.js");
assert_eq!(source_files(root, root, vec![], LIMIT).len(), 2);
write(root, &format!("pkg/{name}"));
assert!(source_files(root, root, vec![], LIMIT).is_empty());
std::fs::remove_file(root.join("pkg").join(name)).unwrap();
write(root, name);
assert!(source_files(root, root, vec![], LIMIT).is_empty());
assert!(source_files(&root.join("pkg"), root, vec![], LIMIT).is_empty());
}
}
#[test]
fn additions_deletions_and_renames_change_membership() {
let dir = tempfile::tempdir().unwrap();
write(dir.path(), "a.js");
let first = source_files(dir.path(), dir.path(), vec![], LIMIT);
write(dir.path(), "b.ts");
assert_eq!(source_files(dir.path(), dir.path(), vec![], LIMIT).len(), 2);
std::fs::rename(dir.path().join("b.ts"), dir.path().join("b.txt")).unwrap();
assert_eq!(source_files(dir.path(), dir.path(), vec![], LIMIT), first);
std::fs::remove_file(dir.path().join("a.js")).unwrap();
assert!(source_files(dir.path(), dir.path(), vec![], LIMIT).is_empty());
}
}
+7 -1
View File
@@ -120,7 +120,10 @@ impl ClientHandler {
};
let Some(path) = uri_to_path(params.uri.as_str()) else { return };
// Files opened only for a request (references search) are not the user's problems.
if self.server.suppresses_diagnostics(&path) {
if (self.server.external_diagnostics.load(std::sync::atomic::Ordering::Acquire)
&& !mgr.docs.lock().values().any(|d| d.path == path))
|| self.server.suppresses_diagnostics(&path)
{
return;
}
let conv = mgr.conv(self.encoding());
@@ -228,6 +231,9 @@ impl Handler for ClientHandler {
}
}
"tsserver/request" => mgr.forward_tsserver_requests(self.server.clone(), self.generation, params),
"language/status" if self.server.name == "jdtls" && params["type"] == "ServiceReady" => {
mgr.java_import_ready(self.server.clone(), self.generation);
}
// The Angular server's project loading, shown like `$/progress`; one
// task while several projects load.
"angular/projectLoadingStart" if !self.server.progress.lock().contains_key(ANGULAR_LOADING) => self
+203 -12
View File
@@ -30,6 +30,55 @@ const MAX_LAUNCH_ERRORS: usize = 10;
/// Build markers can be published just after `java/buildWorkspace` answers.
const ERRORS_WAIT: Duration = Duration::from_secs(2);
const ACCESS_OPTIONS: [&str; 2] = [
"org.eclipse.jdt.core.compiler.problem.forbiddenReference",
"org.eclipse.jdt.core.compiler.problem.discouragedReference",
];
fn compiler_defaults(cfg: &ide_language::LanguageServerConfig) -> Value {
// updateSettings persists project options. Only use it when jdtls redirects
// generated metadata into its workspace, and never touch an existing .settings.
let metadata = cfg.args.iter().rev().find_map(|a| {
a.strip_prefix("--jvm-arg=").unwrap_or(a).strip_prefix("-Djava.import.generatesMetadataFilesAtProjectRoot=")
});
if metadata != Some("false") {
return json!({});
}
let settings = crate::handler::all_settings(cfg);
let Some(url) = settings
.pointer("/java/settings/url")
.or_else(|| settings.pointer("/settings/java/settings/url"))
.and_then(Value::as_str)
else {
return json!({});
};
let path = crate::uri::uri_to_path(url).unwrap_or_else(|| PathBuf::from(url));
let Ok(prefs) = std::fs::read_to_string(path) else { return json!({}) };
let mut options = serde_json::Map::new();
for line in prefs.lines() {
if let Some((key, value)) = line.split_once('=')
&& ACCESS_OPTIONS.contains(&key.trim())
&& ["error", "warning", "info", "ignore"].contains(&value.trim())
{
options.insert(key.trim().into(), json!(value.trim()));
}
}
Value::Object(options)
}
fn cache_owned_maven_settings(root: &Path) -> bool {
root.join("pom.xml").is_file() && matches!(root.join(".settings").try_exists(), Ok(false))
}
fn project_path(uri: &str) -> Option<PathBuf> {
// jdtls commands use Java's `file:/p` as well as LSP's `file:///p`.
let normalized = match uri.strip_prefix("file:") {
Some(rest) if !rest.starts_with("//") => format!("file://{rest}"),
_ => uri.to_owned(),
};
crate::uri::uri_to_path(&normalized)
}
/// Result of `java/buildWorkspace` (jdtls `BuildWorkspaceStatus`).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
@@ -71,6 +120,8 @@ pub struct JavaLaunchInfo {
pub project_root: Option<ide_core::IpcPath>,
pub class_paths: Vec<String>,
pub module_paths: Vec<String>,
/// Path list separator of the host that runs the JVM.
pub path_list_separator: String,
/// JDK home jdtls compiles the project against.
pub java_home: Option<ide_core::IpcPath>,
/// The `java` launcher to use (filled in by the caller from the installed JDKs).
@@ -126,14 +177,10 @@ pub fn parse_classpaths(v: &Value) -> JavaClasspath {
.unwrap_or_default()
};
JavaClasspath {
project_root: v.get("projectRoot").and_then(Value::as_str).map(|r| {
// jdtls answers `file:/p/demo` (Java URI style) as well as `file:///p/demo`.
let normalized = match r.strip_prefix("file:") {
Some(rest) if !rest.starts_with("//") => format!("file://{rest}"),
_ => r.to_owned(),
};
crate::uri::uri_to_path(&normalized).unwrap_or_else(|| PathBuf::from(r)).into()
}),
project_root: v
.get("projectRoot")
.and_then(Value::as_str)
.map(|r| project_path(r).unwrap_or_else(|| PathBuf::from(r)).into()),
class_paths: strings("classpaths"),
module_paths: strings("modulepaths"),
}
@@ -174,6 +221,56 @@ async fn cancellable_wait<T>(cancel: &CancellationToken, wait: impl std::future:
}
impl LspManager {
/// m2e generates a project-specific forbidden-reference error, overriding
/// workspace defaults. Apply those defaults only to cache-owned metadata.
async fn java_configure_compiler(&self, server: &Server, run: &Running) -> IdeResult<bool> {
let options = compiler_defaults(&server.effective_config());
if options.as_object().is_none_or(|o| o.is_empty()) {
return Ok(false);
}
let command = |name: &str, arguments: Value| {
let params = json!({"command": name, "arguments": arguments});
let peer = run.peer.clone();
async move {
tokio::time::timeout(
COMMAND_TIMEOUT,
peer.request::<_, Value>("workspace/executeCommand", params, None),
)
.await
.unwrap_or(Err(ide_jsonrpc::RpcError::Timeout))
.map_err(|e| crate::rpc_err(SERVER, "workspace/executeCommand", e))
}
};
let projects = command("java.project.getAll", json!([])).await?;
let mut changed = false;
for uri in projects.as_array().into_iter().flatten().filter_map(Value::as_str) {
let Some(root) = project_path(uri) else { continue };
// An existing .settings is owned by the project, even if it currently
// contains preferences for another Eclipse plugin.
if !cache_owned_maven_settings(&root) {
continue;
}
let keys: Vec<_> = options.as_object().unwrap().keys().collect();
let current = command("java.project.getSettings", json!([uri, keys])).await?;
if options.as_object().unwrap().iter().all(|(k, v)| current.get(k) == Some(v)) {
continue;
}
command("java.project.updateSettings", json!([uri, options])).await?;
changed = true;
}
Ok(changed)
}
pub(crate) fn java_import_ready(self: &Arc<Self>, server: Arc<Server>, generation: u64) {
let mgr = self.clone();
self.runtime.spawn(async move {
let Some(run) = server.running_gen(generation) else { return };
if let Err(e) = mgr.java_configure_compiler(&server, &run).await {
tracing::warn!("jdtls: cannot apply compiler defaults: {e}");
}
});
}
/// The jdtls instance serving `path` (a module directory or source file),
/// started if needed and waited for.
async fn jdtls(&self, path: &Path, cancel: &CancellationToken) -> IdeResult<(Arc<Server>, Running)> {
@@ -192,7 +289,7 @@ impl LspManager {
ide_core::event::ServerStatus::Missing => {
"jdtls is not installed (Settings › Languages & Frameworks › Language Servers)".to_owned()
}
ide_core::event::ServerStatus::Misconfigured => {
ide_core::event::ServerStatus::Misconfigured | ide_core::event::ServerStatus::Crashed => {
server.status_message().unwrap_or_else(|| "jdtls cannot start".to_owned())
}
_ => format!("jdtls is not ready ({status:?})"),
@@ -209,9 +306,12 @@ impl LspManager {
timeout: Duration,
cancel: &CancellationToken,
) -> IdeResult<Value> {
let started = Instant::now();
let (server, run) = self.jdtls(path, cancel).await?;
let ready_ms = started.elapsed().as_millis();
let phase = params.get("command").and_then(Value::as_str).unwrap_or(method).to_owned();
let fut = run.peer.request::<Value, Value>(method, params, Some(cancel.clone()));
match tokio::time::timeout(timeout, fut).await {
let result = match tokio::time::timeout(timeout, fut).await {
Ok(r) => r.map_err(|e| crate::rpc_err(&server.name, method, e)),
Err(_) => Err(IdeError::new(
ErrorKind::Protocol,
@@ -220,7 +320,15 @@ impl LspManager {
timeout.as_secs()
),
)),
}
};
tracing::info!(
phase,
ready_ms,
request_ms = started.elapsed().as_millis() - ready_ms,
success = result.is_ok(),
"Java launch request"
);
result
}
/// `workspace/executeCommand` on the jdtls serving `path`.
@@ -286,7 +394,16 @@ impl LspManager {
/// Incremental build of the jdtls workspace (`java/buildWorkspace`), so
/// class files are up to date before a launch.
pub async fn java_build(&self, path: &Path, cancel: &CancellationToken) -> IdeResult<JavaBuildStatus> {
let v = self.jdtls_request(path, "java/buildWorkspace", json!(false), BUILD_TIMEOUT, cancel).await?;
let mut v = self.jdtls_request(path, "java/buildWorkspace", json!(false), BUILD_TIMEOUT, cancel).await?;
// The first build can finish the Maven import before ServiceReady is handled.
let (server, run) = self.jdtls(path, cancel).await?;
let changed = cancellable_wait(cancel, self.java_configure_compiler(&server, &run)).await??;
// ServiceReady can update the options concurrently with that first build.
let recheck = v.as_i64() == Some(2)
&& compiler_defaults(&server.effective_config()).as_object().is_some_and(|o| !o.is_empty());
if changed || recheck {
v = self.jdtls_request(path, "java/buildWorkspace", json!(false), BUILD_TIMEOUT, cancel).await?;
}
Ok(JavaBuildStatus::from_code(v.as_i64().unwrap_or(0)))
}
@@ -351,6 +468,7 @@ impl LspManager {
project_root: cp.project_root,
class_paths: cp.class_paths,
module_paths: cp.module_paths,
path_list_separator: if cfg!(windows) { ";" } else { ":" }.into(),
java_home: java_home.map(Into::into),
java_exec: None,
shorten_command_line: None,
@@ -436,6 +554,79 @@ mod tests {
assert_eq!(shown, [(4, 5, "a1"), (10, 5, "a2")]);
}
#[test]
fn compiler_defaults_require_redirected_metadata_and_respect_server_settings() {
let tmp = tempfile::tempdir().unwrap();
let file = tmp.path().join("compiler defaults 测试.prefs");
std::fs::write(&file, format!("{}=warning\n{}=ignore\nother=error\n", ACCESS_OPTIONS[0], ACCESS_OPTIONS[1]))
.unwrap();
let mut cfg: ide_language::LanguageServerConfig = toml::from_str("name = 'jdtls'\ncommand = 'jdtls'").unwrap();
cfg.initialization_options = Some(
toml::Value::try_from(json!({"settings": {"java": {"settings": {"url": file.to_string_lossy()}}}}))
.unwrap(),
);
assert_eq!(compiler_defaults(&cfg), json!({}));
for prefix in ["", "--jvm-arg="] {
cfg.args = vec![format!("{prefix}-Djava.import.generatesMetadataFilesAtProjectRoot=false")];
assert_eq!(compiler_defaults(&cfg), json!({ACCESS_OPTIONS[0]: "warning", ACCESS_OPTIONS[1]: "ignore"}));
}
cfg.args.push("--jvm-arg=-Djava.import.generatesMetadataFilesAtProjectRoot=true".into());
assert_eq!(compiler_defaults(&cfg), json!({}), "the final JVM property wins");
cfg.args.pop();
cfg.settings = Some(json!({"java": {"settings": {"url": "https://example.invalid/custom.prefs"}}}));
assert_eq!(compiler_defaults(&cfg), json!({}), "explicit server settings win");
}
#[test]
fn project_settings_are_never_updated_on_disk() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
assert!(!cache_owned_maven_settings(root));
std::fs::write(root.join("pom.xml"), "<project/>").unwrap();
assert!(cache_owned_maven_settings(root));
let lsp_uri = crate::uri::path_to_uri_string(root);
let java_uri = lsp_uri.replacen("file://", "file:", 1);
for uri in [lsp_uri, java_uri] {
assert_eq!(project_path(&uri).as_deref(), Some(root));
assert!(cache_owned_maven_settings(&project_path(&uri).unwrap()));
}
std::fs::create_dir(root.join(".settings")).unwrap();
assert!(!cache_owned_maven_settings(root), "even unrelated Eclipse settings belong to the project");
let prefs = root.join(".settings/org.eclipse.jdt.core.prefs");
std::fs::write(&prefs, format!("{}=error\n", ACCESS_OPTIONS[0])).unwrap();
assert!(!cache_owned_maven_settings(root));
assert_eq!(std::fs::read_to_string(prefs).unwrap(), format!("{}=error\n", ACCESS_OPTIONS[0]));
}
#[test]
fn access_warnings_do_not_hide_real_compile_errors() {
let path = PathBuf::from("src/Main.java");
let restriction =
"Access restriction: The type 'BASE64Decoder' is not accessible (restriction on required library rt.jar)";
let diagnostic = |severity, message: &str| lsp_types::Diagnostic {
severity: Some(severity),
message: message.into(),
..Default::default()
};
let warnings: Vec<_> = [restriction, "The type 'BASE64Encoder' is discouraged"]
.into_iter()
.map(|message| (path.clone(), diagnostic(lsp_types::DiagnosticSeverity::WARNING, message)))
.collect();
assert_eq!(compile_errors(warnings.clone(), 10), (Vec::new(), 0));
let mut diagnostics = warnings;
diagnostics.push((
path.clone(),
diagnostic(lsp_types::DiagnosticSeverity::ERROR, "Missing cannot be resolved to a type"),
));
let (errors, count) = compile_errors(diagnostics, 10);
assert_eq!(count, 1);
assert_eq!(errors[0].message, "Missing cannot be resolved to a type");
// A project's explicit error severity must still be respected.
let (_, count) =
compile_errors(vec![(path, diagnostic(lsp_types::DiagnosticSeverity::ERROR, restriction))], 10);
assert_eq!(count, 1);
}
#[test]
fn classpaths() {
let v = json!({
+81 -9
View File
@@ -9,6 +9,7 @@
mod angular;
mod caps;
mod convert;
mod external_project;
mod file_ops;
mod handler;
mod inspect;
@@ -89,6 +90,8 @@ struct Attached {
struct DocState {
path: PathBuf,
/// `path` with links resolved, to recognise a document under another name.
canonical: PathBuf,
uri: lsp::Uri,
language: LanguageId,
version: i32,
@@ -169,7 +172,9 @@ pub struct LspManager {
prepare_hook: RwLock<Option<Arc<PrepareFn>>>,
servers_hook: RwLock<Option<Arc<ServersFn>>>,
candidates_hook: RwLock<Option<Arc<CandidateFn>>>,
excludes_hook: RwLock<Option<Arc<external_project::ExcludesFn>>>,
shutting_down: AtomicBool,
execution_allowed: AtomicBool,
/// `window/showMessage` texts already surfaced; servers repeat them on every restart/reconfigure.
shown_messages: Mutex<HashSet<(String, String)>>,
/// Virtual (non-`file:`) URIs a server returned, by the server that owns them.
@@ -299,7 +304,9 @@ impl LspManager {
prepare_hook: RwLock::new(None),
servers_hook: RwLock::new(None),
candidates_hook: RwLock::new(None),
excludes_hook: RwLock::new(None),
shutting_down: AtomicBool::new(false),
execution_allowed: AtomicBool::new(true),
shown_messages: Mutex::new(HashSet::new()),
virtual_origins: Mutex::new(HashMap::new()),
virtual_docs: Mutex::new(HashMap::new()),
@@ -367,6 +374,9 @@ impl LspManager {
/// The definitions of the servers `language` uses.
fn language_servers(&self, language: &LanguageId) -> Vec<LanguageServerConfig> {
if !self.execution_allowed.load(Ordering::Acquire) {
return Vec::new();
}
let Some(cfg) = self.languages.get(language) else { return Vec::new() };
match self.servers_hook.read().clone() {
Some(hook) => hook(language, &cfg.language_servers),
@@ -413,7 +423,7 @@ impl LspManager {
}
fn ensure_started(&self, server: &Arc<Server>) {
if self.shutting_down.load(Ordering::Acquire) {
if self.shutting_down.load(Ordering::Acquire) || !self.execution_allowed.load(Ordering::Acquire) {
return;
}
let Some(generation) = server.begin_start() else { return };
@@ -431,7 +441,13 @@ impl LspManager {
// The settings hook may touch the filesystem (e.g. scanning for JDKs); TCP
// servers may take a while to start listening.
let launched = tokio::task::spawn_blocking(move || {
if !me.execution_allowed.load(Ordering::Acquire) {
return Err(LaunchError::Config("Project is in Restricted Mode".into()));
}
let mut cfg = me.configured(&tail);
if !me.execution_allowed.load(Ordering::Acquire) || cancel.is_cancelled() {
return Err(LaunchError::Config("Project is in Restricted Mode".into()));
}
tail.set_effective_config(cfg.clone());
let prepare = me.prepare_hook.read().clone();
if !cancel.is_cancelled()
@@ -448,6 +464,9 @@ impl LspManager {
let missing = !me.launcher.available(&cfg, &root);
return Err(if missing { LaunchError::Missing } else { LaunchError::Config(message.clone()) });
}
if !me.execution_allowed.load(Ordering::Acquire) || cancel.is_cancelled() {
return Err(LaunchError::Config("Project is in Restricted Mode".into()));
}
me.launcher.launch_cancellable(&cfg, &root, handler, Box::new(move |line| tail.push_stderr(line)), &cancel)
})
.await
@@ -459,24 +478,37 @@ impl LspManager {
let cfg = server.effective_config();
let message =
cfg.install_hint.clone().unwrap_or_else(|| format!("`{}` was not found on PATH", cfg.command));
if server.finish_start(generation, Conn::Missing) {
server.set_status(&self.sink, ide_core::event::ServerStatus::Missing, Some(message));
if server.fail_start(
generation,
Conn::Missing,
&self.sink,
ide_core::event::ServerStatus::Missing,
Some(message),
) {
self.attach_fallbacks();
}
return;
}
Err(LaunchError::Config(message)) => {
tracing::warn!("{}: {message}", server.name);
if server.finish_start(generation, Conn::Failed) {
server.set_status(&self.sink, ide_core::event::ServerStatus::Misconfigured, Some(message));
}
server.fail_start(
generation,
Conn::Failed,
&self.sink,
ide_core::event::ServerStatus::Misconfigured,
Some(message),
);
return;
}
Err(LaunchError::Failed(message)) => {
tracing::warn!("{}: {message}", server.name);
if server.finish_start(generation, Conn::Failed) {
server.set_status(&self.sink, ide_core::event::ServerStatus::Crashed, Some(message));
}
server.fail_start(
generation,
Conn::Failed,
&self.sink,
ide_core::event::ServerStatus::Crashed,
Some(message),
);
return;
}
};
@@ -517,6 +549,8 @@ impl LspManager {
}
let run = Running { generation, peer: peer.clone(), caps: Arc::new(RwLock::new(caps)) };
self.refresh_external_project(&server, &run).await;
// Publish readiness and (re)open attached documents atomically w.r.t. did_change.
{
let mut docs = self.docs.lock();
@@ -559,6 +593,7 @@ impl LspManager {
};
self.clear_server_state(&server);
if intentional {
server.announce_down();
return;
}
let tail = server.stderr_tail(5);
@@ -575,6 +610,9 @@ impl LspManager {
match server.record_crash() {
Some(delay) => {
server.set_status(&self.sink, ServerStatus::Crashed, Some(format!("{message}\nRestarting…")));
// Woken after the crash is reported: a request that finds no instance
// reads this status to say why. The restart sets `Starting` again.
server.announce_down();
tokio::time::sleep(delay).await;
if server.is_idle_gen(generation) {
self.ensure_started(&server);
@@ -587,6 +625,8 @@ impl LspManager {
ServerStatus::Crashed,
Some(format!("{message}\nCrashed repeatedly; restart it manually.")),
);
// Requests still waiting for a start read this status for their error.
server.announce_down();
}
}
}
@@ -661,6 +701,11 @@ impl LspManager {
if let Some(t) = self.docs.lock().values().find(|d| d.path == path).map(|d| d.text.clone()) {
return Some(t);
}
if let Ok(key) = dunce::canonicalize(path)
&& let Some(text) = self.docs.lock().values().find(|d| d.canonical == key).map(|d| d.text.clone())
{
return Some(text);
}
let key = path.to_str().filter(|s| is_virtual_uri(s))?;
self.virtual_docs.lock().get(key).map(|d| Arc::new(DocText::new(&d.text)))
}
@@ -706,8 +751,10 @@ impl LspManager {
text: &str,
servers: Vec<Arc<Server>>,
) {
let canonical = dunce::canonicalize(&path).unwrap_or_else(|_| path.clone());
let mut state = DocState {
path,
canonical,
uri,
language: language.clone(),
version: version as i32,
@@ -1975,6 +2022,10 @@ impl LspManager {
/// The server's base configuration with the settings hook applied.
fn configured(&self, server: &Server) -> LanguageServerConfig {
let mut cfg = server.config.clone();
if !self.execution_allowed.load(Ordering::Acquire) {
cfg.launch_error = Some("Project is in Restricted Mode".into());
return cfg;
}
if let Some(hook) = self.config_hook.read().clone() {
hook(&mut cfg, &server.root);
}
@@ -1985,7 +2036,27 @@ impl LspManager {
/// (e.g. a new project interpreter); stopped ones pick it up on their next start.
/// A change of `settings` alone reaches a running server without a restart.
/// Servers no longer enabled stop; newly enabled ones attach to open documents.
pub fn set_execution_allowed(&self, allowed: bool) {
let previous = self.execution_allowed.swap(allowed, Ordering::AcqRel);
if !allowed {
for server in self.servers.lock().iter() {
server.start_token().cancel();
}
}
if allowed && !previous {
for server in self.servers.lock().iter() {
server.reset_for_restart();
self.ensure_started(server);
}
}
}
pub async fn reconfigure(&self) {
if !self.execution_allowed.load(Ordering::Acquire) {
let servers = self.servers.lock().clone();
join_all(servers.iter().map(|s| self.stop_instance(s, true))).await;
return;
}
self.stop_unlisted().await;
let Some(me) = self.me.upgrade() else { return };
let servers: Vec<Arc<Server>> = self.servers.lock().clone();
@@ -2212,6 +2283,7 @@ impl LspManager {
return;
}
let graceful = async {
self.close_external_project(server, &run).await;
peer.request::<_, Value>("shutdown", (), None).await.ok();
peer.notify("exit", ()).ok();
peer.closed().await;
+9 -5
View File
@@ -167,23 +167,27 @@ impl LspManager {
/// readable as text are left out and flagged on the guard.
async fn open_transient(&self, t: &Target, files: Vec<PathBuf>, cancel: &CancellationToken) -> TransientFiles<'_> {
// The server already sees the user's files, however large.
let open: HashSet<PathBuf> = self.docs.lock().values().map(|d| d.path.clone()).collect();
let open: HashSet<PathBuf> = self.docs.lock().values().map(|d| d.canonical.clone()).collect();
let (read, skipped) = {
let cancel = cancel.clone();
tokio::task::spawn_blocking(move || {
let mut read = Vec::new();
let mut skipped = false;
for f in files.into_iter().filter(|f| !open.contains(f)) {
for f in files {
if cancel.is_cancelled() {
break;
}
let canonical = dunce::canonicalize(&f).unwrap_or_else(|_| f.clone());
if open.contains(&canonical) {
continue;
}
// A file gone since the search no longer mentions anything.
let Ok(meta) = std::fs::metadata(&f) else { continue };
if !meta.is_file() {
continue;
}
match (meta.len() <= MAX_CANDIDATE_BYTES).then(|| std::fs::read_to_string(&f)) {
Some(Ok(text)) => read.push((f, text)),
Some(Ok(text)) => read.push((f, canonical, text)),
_ => skipped = true,
}
}
@@ -198,8 +202,8 @@ impl LspManager {
return guard;
}
let docs = self.docs.lock();
for (path, text) in read {
if docs.values().any(|d| d.path == path) {
for (path, canonical, text) in read {
if docs.values().any(|d| d.canonical == canonical) {
continue;
}
let uri = crate::uri::path_to_uri(&path);
+63 -9
View File
@@ -99,6 +99,9 @@ pub struct Server {
/// Woken when the last `$/progress` task ends.
idle_notify: Notify,
transient: Mutex<HashMap<PathBuf, Transient>>,
pub external_project: tokio::sync::Mutex<crate::external_project::State>,
pub external_diagnostics: std::sync::atomic::AtomicBool,
pub external_refresh_queued: std::sync::atomic::AtomicBool,
}
impl Server {
@@ -123,6 +126,9 @@ impl Server {
published_notify: Notify::new(),
idle_notify: Notify::new(),
transient: Mutex::new(HashMap::new()),
external_project: tokio::sync::Mutex::new(Default::default()),
external_diagnostics: std::sync::atomic::AtomicBool::new(false),
external_refresh_queued: std::sync::atomic::AtomicBool::new(false),
}
}
@@ -162,15 +168,27 @@ impl Server {
Some(st.generation)
}
/// Starting → Missing/Failed.
pub fn finish_start(&self, generation: u64, conn: Conn) -> bool {
let mut st = self.state.lock();
if st.generation == generation && matches!(st.conn, Conn::Starting) {
/// Starting → Missing/Failed, publishing the status the failed start reports
/// before waiters wake: a request that wakes on `Phase::Down` reads the status
/// to say why it has no server, and must not still see `Starting`.
pub fn fail_start(
&self,
generation: u64,
conn: Conn,
sink: &SharedSink,
status: ServerStatus,
message: Option<String>,
) -> bool {
{
let mut st = self.state.lock();
if st.generation != generation || !matches!(st.conn, Conn::Starting) {
return false;
}
st.conn = conn;
self.phase.send_replace(Phase::Down);
return true;
}
false
self.set_status(sink, status, message);
self.announce_down();
true
}
/// Starting → Ready. False when the start was superseded or cancelled.
@@ -191,6 +209,12 @@ impl Server {
}
}
/// Wakes requests waiting for a start that ended without an instance, after
/// its status is published.
pub fn announce_down(&self) {
self.phase.send_replace(Phase::Down);
}
pub fn running(&self) -> Option<Running> {
match &self.state.lock().conn {
Conn::Ready(r) => Some(r.clone()),
@@ -246,7 +270,8 @@ impl Server {
}
/// Instance `generation`'s connection closed. `None` if superseded, else whether
/// the close was intentional.
/// the close was intentional. Waiters are woken by [`Self::announce_down`] once
/// the crash is reported, not here: they read the status to say why.
pub fn on_closed(&self, generation: u64) -> Option<bool> {
let mut st = self.state.lock();
if st.generation != generation {
@@ -255,7 +280,6 @@ impl Server {
match &st.conn {
Conn::Ready(_) | Conn::Starting => {
st.conn = Conn::Idle;
self.phase.send_replace(Phase::Down);
Some(false)
}
_ => Some(true),
@@ -529,6 +553,36 @@ mod tests {
assert_eq!(s.record_crash(), Some(Duration::from_millis(500)));
}
#[tokio::test(flavor = "multi_thread")]
async fn a_failed_start_reports_its_status_before_waiters_wake() {
// The race behind "jdtls is not ready (Starting)": a request woken by the
// phase change reads the status to say why it has no server, so the status
// must already be the failure's.
let s = Arc::new(server());
let sink: SharedSink = Arc::new(|_: IdeEvent| {});
let generation = s.begin_start().unwrap();
let waiter = {
let s = s.clone();
tokio::spawn(async move {
let run = s.wait_ready(Duration::from_secs(30)).await;
(run.is_none(), s.status())
})
};
tokio::task::yield_now().await;
assert!(s.fail_start(generation, Conn::Failed, &sink, ServerStatus::Crashed, Some("boom".into())));
assert_eq!(waiter.await.unwrap(), (true, ServerStatus::Crashed));
}
#[test]
fn a_superseded_start_is_not_announced() {
let s = server();
let generation = s.begin_start().unwrap();
s.begin_stop(); // abandons the start and invalidates its generation
let sink: SharedSink = Arc::new(|_: IdeEvent| {});
assert!(!s.fail_start(generation, Conn::Failed, &sink, ServerStatus::Crashed, Some("boom".into())));
assert_eq!(s.status(), ServerStatus::Stopped);
}
#[test]
fn crash_excerpt_keeps_the_exception_line() {
// Node.js 24 crash of vue-language-server (issue #25), verbatim shape.
+67 -1
View File
@@ -210,6 +210,7 @@ fn server_cfg(name: &str) -> LanguageServerConfig {
fallback: false,
max_java: None,
launch_error: None,
tsserver_project: false,
}
}
@@ -234,7 +235,8 @@ fn harness_configured(
adjust: impl Fn(&mut LanguageServerConfig),
) -> Harness {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().to_path_buf();
// Edits and references report canonical paths.
let root = dunce::canonicalize(dir.path()).unwrap();
let lang = LanguageConfig {
id: LanguageId::new(lang_id),
name: "Mock".into(),
@@ -2258,3 +2260,67 @@ async fn rename_edits_files_outside_the_analysis_scope() {
assert_eq!(paths, vec![a, b.clone()]);
eventually(|| uris(&s.notified("textDocument/didClose")) == vec![uri_of(&b)]).await;
}
#[tokio::test]
async fn external_projects_are_capability_gated_and_follow_lifecycle() {
for capable in [false, true] {
let mut caps = incremental_caps();
caps["executeCommandProvider"] = json!({ "commands": if capable {
vec!["typescript.tsserverRequest"]
} else { vec!["_typescript.organizeImports"] } });
let h = harness_configured("typescript", "ts", &[("arbitrary-name", caps)], |cfg| {
cfg.tsserver_project = true;
});
let s = h.launcher.state("arbitrary-name");
s.respond("workspace/executeCommand", json!({ "success": true, "body": true }));
std::fs::write(h.root.join("unopened.ts"), "export const helper = 1;").unwrap();
h.open("main.ts", "helper");
h.wait_status("arbitrary-name", ServerStatus::Ready).await;
if !capable {
assert!(s.requested("workspace/executeCommand").is_empty());
h.mgr.shutdown_all().await;
assert!(s.requested("workspace/executeCommand").is_empty());
continue;
}
let requests = s.requested("workspace/executeCommand");
assert_eq!(requests[0]["arguments"][0], "openExternalProject");
assert_eq!(requests[0]["arguments"][1]["rootFiles"].as_array().unwrap().len(), 2);
assert_eq!(s.docs.lock().len(), 1, "registration must not open source documents");
std::fs::write(h.root.join("new.ts"), "export const newHelper = 1;").unwrap();
h.mgr.project_files_changed([&h.root.join("new.ts")]);
eventually(|| s.requested("workspace/executeCommand").len() >= 2).await;
assert_eq!(
s.requested("workspace/executeCommand")[1]["arguments"][1]["rootFiles"].as_array().unwrap().len(),
3
);
std::fs::rename(h.root.join("new.ts"), h.root.join("renamed.ts")).unwrap();
h.mgr.project_files_changed([&h.root.join("renamed.ts")]);
eventually(|| s.requested("workspace/executeCommand").len() >= 3).await;
assert!(s.requested("workspace/executeCommand")[2].to_string().contains("renamed.ts"));
std::fs::remove_file(h.root.join("renamed.ts")).unwrap();
h.mgr.project_files_changed([&h.root.join("renamed.ts")]);
eventually(|| s.requested("workspace/executeCommand").len() >= 4).await;
let id = h.mgr.servers()[0].id;
h.mgr.restart(id).await.unwrap();
h.wait_status("arbitrary-name", ServerStatus::Ready).await;
eventually(|| {
s.requested("workspace/executeCommand")
.iter()
.filter(|r| r["arguments"][0] == "openExternalProject")
.count()
>= 5
})
.await;
std::fs::write(h.root.join("tsconfig.json"), "{}").unwrap();
h.mgr.project_files_changed([&h.root.join("tsconfig.json")]);
eventually(|| {
s.requested("workspace/executeCommand")
.iter()
.filter(|r| r["arguments"][0] == "closeExternalProject")
.count()
>= 2
})
.await;
h.mgr.shutdown_all().await;
}
}
+3
View File
@@ -78,6 +78,7 @@ async fn missing_binary_is_reported_with_install_hint() {
fallback: false,
max_java: None,
launch_error: None,
tsserver_project: false,
};
let (mgr, events) = manager(dir.path(), LanguageRegistry::from_configs(vec![lang("nope", "nope", server)]));
let path = dir.path().join("a.nope");
@@ -247,6 +248,7 @@ async fn typescript_language_server_end_to_end() {
fallback: false,
max_java: None,
launch_error: None,
tsserver_project: false,
};
let (mgr, events) = manager(&root, LanguageRegistry::from_configs(vec![lang("typescript", "ts", server)]));
let doc = DocId::next();
@@ -292,6 +294,7 @@ fn npm_server(tools: &Path, name: &str, bin: &str, init: Option<serde_json::Valu
fallback: false,
max_java: None,
launch_error: None,
tsserver_project: false,
}
}
+38 -1
View File
@@ -3,7 +3,7 @@
#![cfg(unix)]
use ide_core::event::ServerStatus;
use ide_core::{DocId, IdeEvent, LanguageId};
use ide_core::{CancellationToken, DocId, IdeEvent, LanguageId};
use ide_language::{LanguageConfig, LanguageRegistry, LanguageServerConfig};
use ide_lsp::{LspManager, ProcessLauncher};
use parking_lot::Mutex;
@@ -65,3 +65,40 @@ async fn crash_before_the_handshake_reports_what_the_server_printed() {
assert!(message.contains(". . ."), "{message}");
mgr.shutdown_all().await;
}
#[tokio::test(flavor = "multi_thread")]
async fn a_request_that_needs_a_crashed_jdtls_reports_why_it_crashed() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().to_path_buf();
let script = root.join("fake-jdtls");
std::fs::write(&script, LAUNCHER).unwrap();
std::fs::set_permissions(&script, std::os::unix::fs::PermissionsExt::from_mode(0o755)).unwrap();
let server: LanguageServerConfig =
toml::from_str(&format!("name = \"jdtls\"\ncommand = {:?}", script.to_string_lossy())).unwrap();
let lang: LanguageConfig = toml::from_str("id = \"java\"\nname = \"Java\"\nextensions = [\"java\"]").unwrap();
let lang = LanguageConfig { language_servers: vec![server], ..lang };
let registry = Arc::new(LanguageRegistry::from_configs(vec![lang]));
let events = Arc::new(Mutex::new(Vec::new()));
let sink = {
let events = events.clone();
Arc::new(move |e: IdeEvent| events.lock().push(e))
};
let mgr = LspManager::with_launcher(root.clone(), registry, sink, Arc::new(ProcessLauncher));
let path = root.join("A.java");
std::fs::write(&path, "class A {}\n").unwrap();
mgr.did_open(DocId::next(), &path, &LanguageId::new("java"), 1, "class A {}\n");
for _ in 0..1000 {
let crashed =
events.lock().iter().any(|e| matches!(e, IdeEvent::LanguageServer { status: ServerStatus::Crashed, .. }));
if crashed {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
let error = mgr.java_classpath(&path, false, &CancellationToken::new()).await.unwrap_err();
assert!(error.message.contains("jdtls exited with code 1"), "{}", error.message);
assert!(error.message.contains("'python' is not recognized"), "{}", error.message);
mgr.shutdown_all().await;
}
+33 -1
View File
@@ -92,7 +92,10 @@ impl LoadedPlugin {
}
}
type ProjectPolicy = Arc<dyn Fn(&Path) -> bool + Send + Sync>;
pub struct PluginHost {
project_policy: RwLock<ProjectPolicy>,
engine: PluginEngine,
plugins_dir: PathBuf,
sink: Arc<dyn PluginEventSink>,
@@ -116,6 +119,7 @@ impl PluginHost {
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default();
Ok(Self {
project_policy: RwLock::new(Arc::new(|_| false)),
engine: PluginEngine::new().map_err(IdeError::internal)?,
plugins_dir,
sink,
@@ -159,7 +163,30 @@ impl PluginHost {
}
/// Loads new dev plugins from `<root>/.openidea/plugins/` and unloads vanished ones.
pub fn set_project_policy(&self, policy: ProjectPolicy) {
*self.project_policy.write() = policy;
}
pub fn restrict_project(&self, root: &Path) {
let _guard = self.lifecycle.lock();
let ids: Vec<_> = self
.plugins
.read()
.iter()
.filter(|(_, p)| p.dev_root.as_deref() == Some(root))
.map(|(id, _)| id.clone())
.collect();
for id in ids {
self.unload(&id);
}
self.emit(None, PluginEvent::CommandsChanged);
}
pub fn sync_project(&self, root: &Path) {
if !(self.project_policy.read())(root) {
self.restrict_project(root);
return;
}
let _guard = self.lifecycle.lock();
let dirs = package_dirs(&ide_core::project_dir::path(root, "plugins"));
let stale: Vec<String> = self
@@ -448,7 +475,12 @@ impl PluginHost {
// ---- internals ----
fn get(&self, id: &str) -> IdeResult<Arc<LoadedPlugin>> {
self.plugins.read().get(id).cloned().ok_or_else(|| IdeError::not_found(format!("no plugin {id:?}")))
let plugin =
self.plugins.read().get(id).cloned().ok_or_else(|| IdeError::not_found(format!("no plugin {id:?}")))?;
if plugin.dev_root.as_deref().is_some_and(|root| !(self.project_policy.read())(root)) {
return Err(IdeError::unavailable("Project is in Restricted Mode"));
}
Ok(plugin)
}
fn is_loaded_from(&self, dir: &Path) -> bool {
+36
View File
@@ -380,6 +380,7 @@ fn state_persists_across_restarts() {
#[test]
fn dev_plugins_follow_the_project() {
let f = fixture(Limits::default());
f.host.set_project_policy(Arc::new(|_| true));
let dev = f.project.join(DEV_PLUGINS_DIR).join("toy");
copy_dir(&sample("language-toy"), &dev);
f.host.sync_project(&f.project);
@@ -474,3 +475,38 @@ fn catalog_lists_sample_packages() {
let x = catalog.iter().find(|c| c.id == "x").unwrap();
assert!(x.problem.as_deref().unwrap().contains("plugin.wasm"));
}
#[test]
fn restricted_dev_plugin_has_no_activation_registration() {
let f = fixture(Limits::default());
let dev = f.project.join(DEV_PLUGINS_DIR).join("hello");
copy_dir(&sample("hello"), &dev);
let manifest = dev.join("plugin.toml");
let text = std::fs::read_to_string(&manifest).unwrap();
std::fs::write(&manifest, text.split("[[contributes.commands]]").next().unwrap()).unwrap();
assert!(PluginManifest::load(&dev).unwrap().contributes.commands.is_empty());
let trusted = Arc::new(std::sync::atomic::AtomicBool::new(false));
let policy = trusted.clone();
f.host.set_project_policy(Arc::new(move |_| policy.load(std::sync::atomic::Ordering::Acquire)));
f.host.sync_project(&f.project);
let untrusted = f.host.list(Some(&f.project));
assert!(
!untrusted.plugins.iter().flat_map(|plugin| &plugin.commands).any(|command| command.id == "word-count"),
"untrusted development plugin registered its activation witness"
);
assert!(untrusted.plugins.is_empty());
assert!(f.host.info_of("hello").is_err());
// Positive control: activation itself registers these commands, not the manifest.
trusted.store(true, std::sync::atomic::Ordering::Release);
f.host.sync_project(&f.project);
let info = f.host.info_of("hello").unwrap();
assert_eq!(info.status, PluginStatus::Active);
assert!(info.commands.iter().any(|c| c.id == "word-count"));
trusted.store(false, std::sync::atomic::Ordering::Release);
assert!(f.host.reload("hello").is_err());
assert!(f.host.set_enabled("hello", true).is_err());
assert!(f.run("hello", "word-count", None).is_err());
f.host.sync_project(&f.project);
assert!(f.host.list(Some(&f.project)).plugins.is_empty());
}
+48 -11
View File
@@ -1,5 +1,5 @@
//! Upload, download, delete and synchronise project files with a deployment
//! server over SFTP.
//! server through a shared filesystem interface.
use crate::mapping::{Excludes, Mappings, posix};
use ide_core::{CancellationToken, IdeError, IdeResult};
@@ -10,6 +10,45 @@ use specta::Type;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
/// Mapping eligibility uses a common synthetic server root: mapping and exclusion
/// paths are both relative to that root, so checking never opens a connection.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct DeploymentCoverage {
pub server: String,
pub paths: Vec<String>,
pub skipped: Vec<TransferError>,
}
pub fn coverage(settings: &ide_core::settings::Settings, root: &Path, paths: &[PathBuf]) -> Vec<DeploymentCoverage> {
settings
.deployment
.servers
.iter()
.map(|server| {
let map = Mappings::new(server, root, "/");
let exclude = Excludes::new(&settings.deployment, server, "/");
let mut result = DeploymentCoverage { server: server.id.clone(), paths: Vec::new(), skipped: Vec::new() };
for path in paths {
let name = ide_core::ipc_path::to_ipc(path);
match mapped_path(&map, &exclude, path) {
Ok(_) => result.paths.push(name),
Err(message) => result.skipped.push(TransferError { path: name, message: message.into() }),
}
}
result
})
.collect()
}
fn mapped_path(map: &Mappings, exclude: &Excludes, local: &Path) -> Result<String, &'static str> {
let remote = map.remote(local).ok_or("Not covered by this server's mappings")?;
if exclude.local(local.strip_prefix(map.project_root()).unwrap_or(local)) || exclude.remote(&remote) {
return Err("Excluded from deployment");
}
Ok(remote)
}
/// Modification times closer than this count as equal (FAT, rounding by servers).
const MTIME_SLACK: u32 = 2;
@@ -96,7 +135,7 @@ pub fn status(local: Option<FileStamp>, remote: Option<FileStamp>) -> SyncStatus
}
}
/// Operations on one server through one SFTP session.
/// Operations on one server through its filesystem.
pub struct Deployment<'a> {
pub fs: &'a RemoteFs,
pub map: &'a Mappings,
@@ -171,7 +210,7 @@ impl Deployment<'_> {
break;
}
let rel = self.rel(local);
let Some(remote) = self.map.remote(local).filter(|r| !self.exclude.remote(r)) else {
let Some(remote) = mapped_path(self.map, self.exclude, local).ok() else {
report.skipped += 1;
continue;
};
@@ -193,7 +232,7 @@ impl Deployment<'_> {
let mut report = TransferReport::default();
let mut jobs: Vec<(String, PathBuf)> = Vec::new();
for p in paths {
let Some(remote) = self.map.remote(p).filter(|r| !self.exclude.remote(r)) else {
let Some(remote) = mapped_path(self.map, self.exclude, p).ok() else {
report.skipped += 1;
continue;
};
@@ -239,7 +278,7 @@ impl Deployment<'_> {
let mut report = TransferReport::default();
for p in paths {
// Never a whole mapping (the project folder went away, or a mapped folder did).
let Some(remote) = self.map.remote(p).filter(|r| !self.exclude.remote(r) && !self.map.is_root(r)) else {
let Some(remote) = mapped_path(self.map, self.exclude, p).ok().filter(|r| !self.map.is_root(r)) else {
report.skipped += 1;
continue;
};
@@ -257,12 +296,12 @@ impl Deployment<'_> {
/// Sync with Deployed: local and remote files under `local` side by side.
pub async fn compare(&self, local: &Path) -> IdeResult<Vec<SyncEntry>> {
let remote_root =
self.map.remote(local).ok_or_else(|| IdeError::invalid(format!("{} is not mapped", local.display())))?;
let remote_root = mapped_path(self.map, self.exclude, local)
.map_err(|reason| IdeError::invalid(format!("{}: {reason}", local.display())))?;
let mut by_rel: BTreeMap<String, SyncEntry> = BTreeMap::new();
let mut ignored = TransferReport::default();
for f in self.local_files(&[local.to_path_buf()], &mut ignored) {
let Some(remote_path) = self.map.remote(&f) else { continue };
let Ok(remote_path) = mapped_path(self.map, self.exclude, &f) else { continue };
let size = std::fs::metadata(&f).map(|m| m.len()).unwrap_or(0);
let stamp = FileStamp { size, mtime: mtime_of(&f) };
by_rel.insert(
@@ -319,9 +358,7 @@ impl Deployment<'_> {
break;
}
let local = step.path.split('/').filter(|s| !s.is_empty()).fold(root.clone(), |p, s| p.join(s));
let Some(remote) =
self.map.remote(&local).filter(|r| !self.exclude.remote(r) && !self.excluded_local(&local))
else {
let Some(remote) = mapped_path(self.map, self.exclude, &local).ok() else {
report.skipped += 1;
continue;
};
+3 -1
View File
@@ -6,7 +6,9 @@ pub mod mapping;
pub mod service;
pub mod target;
pub use deploy::{Deployment, SyncAction, SyncEntry, SyncStatus, SyncStep, TransferReport};
pub use deploy::{
Deployment, DeploymentCoverage, SyncAction, SyncEntry, SyncStatus, SyncStep, TransferReport, coverage,
};
pub use mapping::{Excludes, Mappings};
pub use service::{DeployedPath, RemoteService};
pub use target::{RemoteRuns, RemoteStart};
+58 -28
View File
@@ -1,14 +1,14 @@
//! The deployment and run-target operations of one app, over the shared SSH
//! connections: what the IPC commands and the save hook call.
//! Deployment over SFTP or local folders, and SSH run targets.
//! Shared by IPC commands and project save/delete hooks.
use crate::deploy::{Deployment, SyncEntry, SyncStep, TransferReport};
use crate::mapping::{Excludes, Mappings};
use crate::target::{RemoteRuns, RemoteStart, SyncFn};
use ide_core::remote_settings::{AutoUpload, DeploymentServer, RunTarget};
use ide_core::remote_settings::{AutoUpload, DeploymentServer, DeploymentType, RunTarget};
use ide_core::settings::Settings;
use ide_core::{CancellationToken, IdeError, IdeResult, SharedSink};
use ide_run::{OutputChunk, RunConfig, RunInfo};
use ide_ssh::{RemoteEntry, RemoteFs, SshManager};
use ide_ssh::{FsConnection, RemoteEntry, RemoteFs, SshManager};
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use specta::Type;
@@ -22,7 +22,7 @@ pub type Progress<'a> = &'a mut (dyn FnMut(&str, usize, usize) + Send);
/// A remote file opened for editing (a local copy uploaded back on save).
#[derive(Debug, Clone)]
struct RemoteEdit {
ssh_config: String,
connection: FsConnection,
remote: String,
}
@@ -37,7 +37,7 @@ pub struct DeployedPath {
pub struct RemoteService {
ssh: Arc<SshManager>,
/// Local copies of remote files: `<cache>/<ssh config>/<remote path>`.
/// Local copies of remote files, separated by filesystem connection.
cache: PathBuf,
edits: Mutex<HashMap<PathBuf, RemoteEdit>>,
pub runs: RemoteRuns,
@@ -71,12 +71,24 @@ impl RemoteService {
Self { ssh, cache, edits: Mutex::default(), runs: RemoteRuns::new() }
}
async fn fs(&self, ssh_config: &str) -> IdeResult<RemoteFs> {
if ssh_config.is_empty() {
return Err(IdeError::invalid("the deployment server has no SSH configuration"));
async fn fs(&self, connection: &FsConnection) -> IdeResult<RemoteFs> {
match connection {
FsConnection::Local => Ok(RemoteFs::Local),
FsConnection::Sftp { ssh } => {
if ssh.is_empty() {
return Err(IdeError::invalid("the deployment server has no SSH configuration"));
}
let session = self.ssh.session(ssh).await?;
Ok(RemoteFs::new(session.sftp().await?))
}
}
let session = self.ssh.session(ssh_config).await?;
Ok(RemoteFs::new(session.sftp().await?))
}
pub async fn test_connection(&self, connection: &FsConnection, path: &str) -> IdeResult<()> {
let fs = self.fs(connection).await?;
let result = fs.test(path).await;
fs.close().await;
result
}
/// Runs `f` with a [`Deployment`] on `server`.
@@ -88,7 +100,7 @@ impl RemoteService {
cancel: CancellationToken,
f: impl AsyncFnOnce(&Deployment<'_>) -> T,
) -> IdeResult<T> {
let fs = self.fs(&server.ssh_config).await?;
let fs = self.fs(&connection(server)).await?;
let remote_root = match fs.resolve(&server.root_path).await {
Ok(r) => r,
Err(e) => {
@@ -198,54 +210,57 @@ impl RemoteService {
/// The server root of `server_id`, resolved on the host.
pub async fn server_root(&self, settings: &Settings, server_id: &str) -> IdeResult<String> {
let server = server(settings, Some(server_id))?;
let fs = self.fs(&server.ssh_config).await?;
let fs = self.fs(&connection(&server)).await?;
let r = fs.resolve(&server.root_path).await;
fs.close().await;
r
}
pub async fn list(&self, ssh_config: &str, path: &str) -> IdeResult<Vec<RemoteEntry>> {
let fs = self.fs(ssh_config).await?;
pub async fn list(&self, connection: &FsConnection, path: &str) -> IdeResult<Vec<RemoteEntry>> {
let fs = self.fs(connection).await?;
let r = async { fs.list(&fs.resolve(path).await?).await }.await;
fs.close().await;
r
}
/// Downloads a remote file for editing; saving the copy uploads it back.
pub async fn open_remote(&self, ssh_config: &str, remote: &str) -> IdeResult<PathBuf> {
let rel = remote.split('/').filter(|s| !s.is_empty() && *s != "..").fold(PathBuf::new(), |p, s| p.join(s));
let local = self.cache.join(sanitize(ssh_config)).join(rel);
let fs = self.fs(ssh_config).await?;
pub async fn open_remote(&self, connection: &FsConnection, remote: &str) -> IdeResult<PathBuf> {
let rel = remote
.split('/')
.filter(|s| !s.is_empty() && *s != ".." && *s != ".")
.fold(PathBuf::new(), |p, s| p.join(s.replace('%', "%25").replace(':', "%3A").replace('\\', "%5C")));
let local = self.cache.join(sanitize(&cache_key(connection))).join(rel);
let fs = self.fs(connection).await?;
let r = fs.download(remote, &local).await;
fs.close().await;
r?;
self.edits
.lock()
.insert(local.clone(), RemoteEdit { ssh_config: ssh_config.to_owned(), remote: remote.to_owned() });
.insert(local.clone(), RemoteEdit { connection: connection.clone(), remote: remote.to_owned() });
Ok(local)
}
/// The remote file a local copy stands for.
pub fn remote_of(&self, local: &Path) -> Option<(String, String)> {
self.edits.lock().get(local).map(|e| (e.ssh_config.clone(), e.remote.clone()))
pub fn remote_of(&self, local: &Path) -> Option<(FsConnection, String)> {
self.edits.lock().get(local).map(|e| (e.connection.clone(), e.remote.clone()))
}
pub async fn remote_delete(&self, ssh_config: &str, remote: &str) -> IdeResult<()> {
let fs = self.fs(ssh_config).await?;
pub async fn remote_delete(&self, connection: &FsConnection, remote: &str) -> IdeResult<()> {
let fs = self.fs(connection).await?;
let r = fs.remove(remote).await;
fs.close().await;
r
}
pub async fn remote_mkdir(&self, ssh_config: &str, remote: &str) -> IdeResult<()> {
let fs = self.fs(ssh_config).await?;
pub async fn remote_mkdir(&self, connection: &FsConnection, remote: &str) -> IdeResult<()> {
let fs = self.fs(connection).await?;
let r = fs.mkdir_all(remote).await;
fs.close().await;
r
}
pub async fn remote_rename(&self, ssh_config: &str, from: &str, to: &str) -> IdeResult<()> {
let fs = self.fs(ssh_config).await?;
pub async fn remote_rename(&self, connection: &FsConnection, from: &str, to: &str) -> IdeResult<()> {
let fs = self.fs(connection).await?;
let r = fs.rename(from, to).await;
fs.close().await;
r
@@ -366,3 +381,18 @@ impl RemoteService {
fn sanitize(s: &str) -> String {
s.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' }).collect()
}
/// A server's transport, also used by the browser and remote editor save hook.
pub fn connection(server: &DeploymentServer) -> FsConnection {
match server.kind {
DeploymentType::Local => FsConnection::Local,
DeploymentType::Sftp => FsConnection::Sftp { ssh: server.ssh_config.clone() },
}
}
fn cache_key(connection: &FsConnection) -> String {
match connection {
FsConnection::Local => "local".to_owned(),
FsConnection::Sftp { ssh } => format!("sftp-{ssh}"),
}
}
+264
View File
@@ -0,0 +1,264 @@
//! Local deployment uses the service entry points used by SFTP and save hooks.
use ide_core::CancellationToken;
use ide_core::remote_settings::{AutoUpload, DeploymentMapping, DeploymentServer, DeploymentType};
use ide_core::settings::Settings;
use ide_remote::{RemoteService, SyncAction, SyncStatus, SyncStep};
use ide_ssh::{FsConnection, SshFiles, SshManager};
use std::path::Path;
use std::sync::Arc;
fn service(root: &Path) -> RemoteService {
RemoteService::new(
Arc::new(SshManager::with_files(
root.join("secrets"),
SshFiles { known_hosts: root.join("known_hosts"), config: None },
)),
root.join("cache"),
)
}
fn settings(target: &Path) -> Settings {
let mut s = Settings::default();
s.deployment.servers.push(DeploymentServer {
id: "local".into(),
kind: DeploymentType::Local,
root_path: target.to_string_lossy().into_owned(),
mappings: vec![DeploymentMapping { deployment: "app".into(), ..Default::default() }],
excluded_paths: vec!["app/private".into()],
..Default::default()
});
s.deployment.default_server = "local".into();
s.deployment.auto_upload = AutoUpload::Always;
s.deployment.delete_remote = true;
s
}
#[tokio::test]
async fn local_deployment_and_browser_share_all_paths() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("project");
let target = tmp.path().join("mounted");
std::fs::create_dir_all(root.join("src/nested")).unwrap();
std::fs::create_dir_all(root.join(".git")).unwrap();
std::fs::create_dir_all(root.join("private")).unwrap();
std::fs::create_dir(&target).unwrap();
std::fs::write(root.join("src/nested/a.txt"), "initial").unwrap();
std::fs::write(root.join(".git/config"), "excluded").unwrap();
std::fs::write(root.join("private/key"), "excluded remote path").unwrap();
let service = service(tmp.path());
let settings = settings(&target);
let cancel = CancellationToken::new();
service.test_connection(&FsConnection::Local, target.to_str().unwrap()).await.unwrap();
let report = service
.upload(&settings, &root, None, std::slice::from_ref(&root), cancel.clone(), &mut |_, _, _| {})
.await
.unwrap();
assert_eq!(report.files, 1, "{report:?}");
assert!(report.errors.is_empty(), "{report:?}");
assert!(!target.join("app/.git").exists());
assert!(!target.join("app/private").exists());
assert_eq!(
ide_ssh::sftp::mtime_of(&root.join("src/nested/a.txt")),
ide_ssh::sftp::mtime_of(&target.join("app/src/nested/a.txt"))
);
let compared = service.compare(&settings, &root, None, &root, cancel.clone()).await.unwrap();
assert_eq!(compared.iter().find(|e| e.path == "src/nested/a.txt").unwrap().status, SyncStatus::Same);
std::fs::write(root.join("src/nested/a.txt"), "saved").unwrap();
let saved = service.on_saved(&settings, &root, &root.join("src/nested/a.txt")).await.unwrap().unwrap();
assert!(saved.errors.is_empty());
let remote_file = target.join("app/src/nested/a.txt");
assert_eq!(std::fs::read_to_string(&remote_file).unwrap(), "saved");
std::fs::write(&remote_file, "downloaded").unwrap();
let report =
service.download(&settings, &root, None, &[root.join("src")], cancel.clone(), &mut |_, _, _| {}).await.unwrap();
assert!(report.errors.is_empty());
assert_eq!(std::fs::read_to_string(root.join("src/nested/a.txt")).unwrap(), "downloaded");
let connection = FsConnection::Local;
let entries = service.list(&connection, target.join("app/src/nested").to_str().unwrap()).await.unwrap();
assert_eq!(entries[0].name, "a.txt");
let cached = service.open_remote(&connection, remote_file.to_str().unwrap()).await.unwrap();
std::fs::write(&cached, "remote edit").unwrap();
assert!(service.on_saved(&settings, &root, &cached).await.unwrap().unwrap().errors.is_empty());
assert_eq!(std::fs::read_to_string(&remote_file).unwrap(), "remote edit");
service.remote_mkdir(&connection, target.join("browser/new/nested").to_str().unwrap()).await.unwrap();
service
.remote_rename(&connection, target.join("browser").to_str().unwrap(), target.join("renamed").to_str().unwrap())
.await
.unwrap();
service.remote_delete(&connection, target.join("renamed").to_str().unwrap()).await.unwrap();
assert!(!target.join("renamed").exists());
let report = service
.sync(
&settings,
&root,
None,
&[SyncStep { path: "src/nested/a.txt".into(), action: SyncAction::Download }],
cancel,
&mut |_, _, _| {},
)
.await
.unwrap();
assert_eq!(report.files, 1);
std::fs::remove_file(root.join("src/nested/a.txt")).unwrap();
assert!(
service
.on_deleted(&settings, &root, &[root.join("src/nested/a.txt")])
.await
.unwrap()
.unwrap()
.errors
.is_empty()
);
assert!(!remote_file.exists());
}
#[tokio::test]
async fn self_upload_download_and_aliases_never_destroy_content() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let service = service(root);
let mut settings = settings(root);
settings.deployment.servers[0].mappings[0].deployment.clear();
let file = root.join("file");
std::fs::write(&file, "keep me").unwrap();
for download in [false, true] {
let report = if download {
service
.download(
&settings,
root,
None,
std::slice::from_ref(&file),
CancellationToken::new(),
&mut |_, _, _| {},
)
.await
} else {
service
.upload(&settings, root, None, std::slice::from_ref(&file), CancellationToken::new(), &mut |_, _, _| {})
.await
}
.unwrap();
assert!(report.errors[0].message.contains("same file"));
assert_eq!(std::fs::read_to_string(&file).unwrap(), "keep me");
}
#[cfg(unix)]
{
let alias = root.join("alias");
std::fs::hard_link(&file, &alias).unwrap();
assert!(
ide_ssh::RemoteFs::Local
.upload(&file, alias.to_str().unwrap(), true)
.await
.unwrap_err()
.message
.contains("same file")
);
std::fs::remove_file(&alias).unwrap();
std::os::unix::fs::symlink(&file, &alias).unwrap();
assert!(
ide_ssh::RemoteFs::Local
.upload(&file, alias.to_str().unwrap(), true)
.await
.unwrap_err()
.message
.contains("same file")
);
ide_ssh::RemoteFs::Local.remove(alias.to_str().unwrap()).await.unwrap();
assert_eq!(std::fs::read_to_string(&file).unwrap(), "keep me");
}
}
#[cfg(unix)]
#[tokio::test]
async fn missing_non_directory_and_unwritable_targets_are_clear() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let service = service(tmp.path());
let target = tmp.path().join("target");
assert!(
service
.test_connection(&FsConnection::Local, target.to_str().unwrap())
.await
.unwrap_err()
.message
.contains("does not exist")
);
std::fs::write(&target, "file").unwrap();
assert!(
service
.test_connection(&FsConnection::Local, target.to_str().unwrap())
.await
.unwrap_err()
.message
.contains("Not a folder")
);
std::fs::remove_file(&target).unwrap();
std::fs::create_dir(&target).unwrap();
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o555)).unwrap();
let result = service.test_connection(&FsConnection::Local, target.to_str().unwrap()).await;
let file = tmp.path().join("source");
std::fs::write(&file, "data").unwrap();
let mut settings = settings(&target);
settings.deployment.servers[0].mappings[0].deployment.clear();
let report = service
.upload(&settings, tmp.path(), None, &[file], CancellationToken::new(), &mut |_, _, _| {})
.await
.unwrap();
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755)).unwrap();
assert!(result.unwrap_err().message.contains("not writable"));
assert_eq!(report.errors.len(), 1, "{report:?}");
}
#[tokio::test]
async fn coverage_matches_transfers_without_connecting() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("project");
let target = tmp.path().join("target");
for dir in ["src", "src-other", "assets", "src/private", "src/node_modules"] {
std::fs::create_dir_all(root.join(dir)).unwrap();
}
let names = ["src/a.txt", "assets/b.txt", "src-other/c.txt", "src/private/key", "src/node_modules/pkg"];
let paths: Vec<_> = names.iter().map(|name| root.join(name)).collect();
for path in &paths {
std::fs::write(path, "content").unwrap();
}
let mut settings = settings(&target);
settings.deployment.exclude = vec!["node_modules".into()];
settings.deployment.servers[0].mappings[0].local = "src".into();
settings.deployment.servers[0].excluded_paths = vec!["app/private".into()];
let mut assets = settings.deployment.servers[0].clone();
assets.id = "assets".into();
assets.mappings[0].local = "assets".into();
let mut offline = assets.clone();
offline.id = "offline".into();
offline.kind = DeploymentType::Sftp;
offline.ssh_config = "does-not-exist".into();
settings.deployment.servers.extend([assets, offline]);
let coverage = ide_remote::coverage(&settings, &root, &paths);
assert_eq!(coverage[0].paths, vec![ide_core::ipc_path::to_ipc(&paths[0])]);
assert_eq!(coverage[1].paths, vec![ide_core::ipc_path::to_ipc(&paths[1])]);
assert_eq!(coverage[2].paths, coverage[1].paths);
assert_eq!(coverage[0].skipped.len(), 4);
assert!(coverage[0].skipped[0].message.contains("mappings"));
assert!(coverage[0].skipped[2].message.contains("Excluded"));
let service = service(tmp.path());
let report = service
.upload(&settings, &root, Some("local"), &paths, CancellationToken::new(), &mut |_, _, _| {})
.await
.unwrap();
assert_eq!((report.files, report.skipped), (1, 4));
let entries =
service.compare(&settings, &root, Some("local"), &root.join("src"), CancellationToken::new()).await.unwrap();
assert_eq!(entries.len(), 1);
let report = service
.download(&settings, &root, Some("local"), &paths, CancellationToken::new(), &mut |_, _, _| {})
.await
.unwrap();
assert_eq!((report.files, report.skipped), (1, 4));
let report = service.delete_remote(&settings, &root, Some("local"), &paths).await.unwrap();
assert_eq!((report.files, report.skipped), (1, 4));
settings.deployment.servers[0].mappings.clear();
assert_eq!(ide_remote::coverage(&settings, &root, &[root.join("assets/b.txt")])[0].paths.len(), 1);
assert!(ide_remote::coverage(&settings, &root, &[root.join("../outside")])[0].paths.is_empty());
}
+2
View File
@@ -7,6 +7,7 @@ license.workspace = true
[dependencies]
ide-core.workspace = true
dunce.workspace = true
encoding_rs.workspace = true
serde.workspace = true
serde_json.workspace = true
@@ -24,4 +25,5 @@ quick-xml = "0.42"
libc = "0.2"
[dev-dependencies]
ide-build = { path = "../ide-build" }
tempfile.workspace = true
+11 -2
View File
@@ -78,6 +78,7 @@ struct FileConfig {
#[serde(default)]
args: Vec<String>,
cwd: Option<PathBuf>,
module: Option<String>,
#[serde(default)]
env: BTreeMap<String, String>,
#[serde(default, alias = "isTest", alias = "is_test")]
@@ -161,6 +162,7 @@ fn convert(configs: Vec<FileConfig>, local: bool) -> Vec<RunConfig> {
debug_adapter: c.debug_adapter.filter(|d| !d.is_empty()),
allow_parallel: c.allow_parallel,
extras: RunExtras {
module: c.module,
params: c.params,
folder: non_empty(c.folder),
local,
@@ -210,6 +212,9 @@ fn to_table(c: &RunConfig) -> Table {
if !c.args.is_empty() {
t.insert("args", value(c.args.iter().collect::<Array>()));
}
if let Some(module) = &c.extras.module {
t.insert("module", value(module));
}
if let Some(cwd) = &c.cwd {
t.insert("cwd", value(cwd.to_string_lossy().replace('\\', "/")));
}
@@ -335,7 +340,11 @@ fn write_array(path: &Path, key: &str, configs: &[&RunConfig], suppressed: Optio
/// Keeps the workspace-local file out of version control (`.openidea/.gitignore`).
fn ignore_local(root: &Path) -> IdeResult<()> {
let entry = format!("/{LOCAL_FILE}");
ignore_private(root, &format!("/{LOCAL_FILE}"))
}
/// Adds `entry` to `.openidea/.gitignore` unless it is already listed.
pub(crate) fn ignore_private(root: &Path, entry: &str) -> IdeResult<()> {
let file = project_dir::path(root, ".gitignore");
let current = fs::read_to_string(&file).unwrap_or_default();
if current.lines().any(|l| l.trim() == entry) {
@@ -347,7 +356,7 @@ fn ignore_local(root: &Path) -> IdeResult<()> {
} else if !text.ends_with('\n') {
text.push('\n');
}
text.push_str(&entry);
text.push_str(entry);
text.push('\n');
if let Some(dir) = file.parent() {
fs::create_dir_all(dir)?;
+21 -5
View File
@@ -3,6 +3,8 @@
//!
//! Detection never fails: unreadable or malformed files simply contribute nothing.
pub(crate) mod cmake;
use crate::RunConfig;
use serde_json::Value as Json;
use std::collections::{BTreeMap, HashSet};
@@ -22,6 +24,7 @@ pub(crate) fn detect_excluding(root: &Path, excluded_poms: &[PathBuf]) -> Vec<Ru
detect_maven(root, excluded_poms, &mut out);
detect_go(root, &mut out);
detect_python(root, &mut out);
cmake::detect(root, &mut out);
detect_make(root, &mut out);
detect_just(root, &mut out);
detect_godot(root, &mut out);
@@ -548,11 +551,11 @@ fn spring_boot_apps(root: &Path, build: &Path, out: &mut Vec<RunConfig>) {
Some(m) => format!("application:{m}:{class}"),
None => format!("application:{class}"),
};
out.push(
config(id, simple, "spring-boot", "java", &["-cp", "${classpath}", &class])
.in_dir(module_rel.as_ref().map(PathBuf::from))
.debug("java-debug"),
);
let mut app = config(id, simple, "spring-boot", "java", &["-cp", "${classpath}", &class])
.in_dir(module_rel.as_ref().map(PathBuf::from))
.debug("java-debug");
app.extras.module = Some(module_rel.clone().unwrap_or_default());
out.push(app);
}
}
}
@@ -680,6 +683,19 @@ fn is_go_main_package(dir: &Path) -> bool {
}
fn detect_go(root: &Path, out: &mut Vec<RunConfig>) {
if let Some(work) = read(&root.join("go.work")) {
let mut seen = HashSet::new();
for member in crate::go::work_modules(&work) {
let dir = root.join(member);
let dir = dir.canonicalize().unwrap_or(dir);
if dir.join("go.mod").is_file() && seen.insert(dir.clone()) {
let canonical_root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
let rel = dir.strip_prefix(&canonical_root).unwrap_or(&dir).to_path_buf();
detect_go_module(&dir, (!rel.as_os_str().is_empty()).then_some(rel), out);
}
}
return;
}
if root.join("go.mod").is_file() {
detect_go_module(root, None, out);
return;
+173
View File
@@ -0,0 +1,173 @@
//! CMake targets come from the File API codemodel, including evaluated subdirectories.
use super::{ConfigExt, config};
use crate::{BeforeLaunchTask, RunConfig};
use serde_json::Value;
use std::fs;
use std::path::{Path, PathBuf};
fn json(path: &Path) -> Option<Value> {
serde_json::from_slice(&fs::read(path).ok()?).ok()
}
fn reply_file(reply: &Path, name: &str) -> Option<Value> {
// File API references are file names, never paths outside the reply directory.
(Path::new(name).components().count() == 1).then(|| json(&reply.join(name))).flatten()
}
fn before(config: &mut RunConfig, id: &str) {
config.extras.before_launch.push(BeforeLaunchTask { kind: "run".into(), target: id.into() });
}
pub(super) fn detect(root: &Path, out: &mut Vec<RunConfig>) {
if !root.join("CMakeLists.txt").is_file() {
return;
}
let mut builds = Vec::new();
if let Ok(entries) = fs::read_dir(root) {
builds.extend(entries.flatten().filter_map(|e| {
let p = e.path();
(p.is_dir() && p.join("CMakeCache.txt").is_file()).then_some(p)
}));
}
if builds.is_empty() {
builds.push(root.join("build"));
}
builds.sort();
builds.dedup();
for build in builds {
let dir = build.strip_prefix(root).unwrap_or(&build).to_string_lossy().replace('\\', "/");
let configuration = fs::read_to_string(build.join("CMakeCache.txt"))
.ok()
.and_then(|cache| {
let values = cache.lines().find_map(|line| line.strip_prefix("CMAKE_CONFIGURATION_TYPES:STRING="))?;
Some(if values.split(';').any(|c| c == "Debug") {
"Debug".to_owned()
} else {
values.split(';').find(|s| !s.is_empty())?.to_owned()
})
})
.unwrap_or_else(|| "Debug".into());
let prefix = format!("cmake:{dir}");
let configure_id = format!("{prefix}:configure");
let build_id = format!("{prefix}:build");
let mut args = vec!["-S", ".", "-B", &dir, "-DCMAKE_EXPORT_COMPILE_COMMANDS=ON"];
if !build.join("CMakeCache.txt").is_file() {
args.push("-DCMAKE_BUILD_TYPE=Debug");
}
let mut configure = config(&configure_id, format!("CMake Configure ({dir})"), "cmake", "cmake", &args);
configure.extras.params.insert("buildDir".into(), dir.clone());
out.push(configure);
let mut all = config(
&build_id,
format!("CMake Build ({dir})"),
"cmake",
"cmake",
&["--build", &dir, "--config", &configuration],
);
// `cmake --build` regenerates by itself once a build directory is configured.
if !build.join("CMakeCache.txt").is_file() {
before(&mut all, &configure_id);
}
out.push(all);
let mut test = config(
format!("{prefix}:test"),
format!("CTest ({dir})"),
"cmake",
"ctest",
&["--test-dir", &dir, "-C", &configuration, "--output-on-failure"],
);
before(&mut test, &build_id);
out.push(test);
targets(root, &build, &dir, &prefix, out);
}
}
fn targets(root: &Path, build: &Path, dir: &str, prefix: &str, out: &mut Vec<RunConfig>) {
let reply = build.join(".cmake/api/v1/reply");
let Some(index_path) = fs::read_dir(&reply).ok().and_then(|entries| {
entries
.flatten()
.map(|e| e.path())
.filter(|p| {
let name = p.file_name().unwrap_or_default().to_string_lossy();
name.starts_with("index-") && name.ends_with(".json")
})
.max()
}) else {
return;
};
let Some(index) = json(&index_path) else { return };
let Some(reference) = index["objects"]
.as_array()
.and_then(|a| a.iter().find(|o| o["kind"] == "codemodel" && o["version"]["major"] == 2))
else {
return;
};
let Some(model) = reference["jsonFile"].as_str().and_then(|n| reply_file(&reply, n)) else { return };
let Some(configurations) = model["configurations"].as_array() else { return };
let source_root = model["paths"]["source"].as_str().map(PathBuf::from).unwrap_or_else(|| root.into());
if source_root.canonicalize().ok() != root.canonicalize().ok() {
return;
}
let source_root = dunce::canonicalize(&source_root).unwrap_or(source_root);
for configuration in configurations {
let name = configuration["name"].as_str().unwrap_or("");
let Some(targets) = configuration["targets"].as_array() else { continue };
for target in targets {
let Some(target) = target["jsonFile"].as_str().and_then(|n| reply_file(&reply, n)) else { continue };
if target["type"] != "EXECUTABLE" {
continue;
}
let (Some(target_name), Some(artifact)) =
(target["name"].as_str(), target["artifacts"][0]["path"].as_str())
else {
continue;
};
let id = format!("{prefix}:{name}:{target_name}");
let target_build_id = format!("{id}:build");
let mut args = vec!["--build", dir, "--target", target_name];
if !name.is_empty() {
args.extend(["--config", name]);
}
let build_config =
config(&target_build_id, format!("Build {target_name} ({dir} {name})"), "cmake", "cmake", &args);
out.push(build_config);
let binary = build.join(artifact);
let mut run =
config(&id, format!("{target_name} ({dir} {name})"), "native", &binary.to_string_lossy(), &[])
.debug("lldb-dap");
before(&mut run, &target_build_id);
let sources: Vec<String> = target["sources"]
.as_array()
.into_iter()
.flatten()
.filter_map(|s| {
let p = source_root.join(s["path"].as_str()?);
Some(p.to_string_lossy().replace('\\', "/"))
})
.collect();
run.extras.params.insert("cmakeSources".into(), sources.join("\n"));
if let Some(recorded) = model["paths"]["source"].as_str() {
run.extras.params.insert("cmakeSourceRoot".into(), recorded.into());
run.extras
.params
.insert("cmakeCanonicalSourceRoot".into(), source_root.to_string_lossy().replace('\\', "/"));
}
out.push(run);
}
}
}
/// Request the codemodel only when the user runs a configure configuration.
pub(crate) fn prepare(config: &RunConfig, cwd: &Path) -> crate::IdeResult<()> {
if config.kind == "cmake"
&& config.args.first().is_some_and(|a| a == "-S")
&& let Some(dir) = config.extras.params.get("buildDir")
{
let query = cwd.join(dir).join(".cmake/api/v1/query/client-openidea");
fs::create_dir_all(&query)?;
fs::write(query.join("codemodel-v2"), "")?;
}
Ok(())
}
+292
View File
@@ -0,0 +1,292 @@
//! Typed Go configurations use the process service for both build and execution.
use crate::RunConfig;
use ide_core::{IdeError, IdeResult};
use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
pub struct Plan {
pub build: RunConfig,
pub run: Option<RunConfig>,
}
pub fn plan(root: &Path, config: &RunConfig) -> IdeResult<Option<Plan>> {
if config.kind != "go" || !config.extras.params.contains_key("goKind") {
return Ok(None);
}
let p = &config.extras.params;
let get = |key: &str| p.get(key).map(String::as_str).unwrap_or("");
let module = root.join(if get("module").is_empty() { "." } else { get("module") });
let mut build = config.clone();
build.cwd = Some(module.clone().into());
let test = get("goKind") == "test";
if get("scope") == "directory" {
let target = module.join(get("target"));
build.cwd = Some(target.into());
if let Some(arg) = build.args.iter_mut().skip(1).find(|a| a.as_str() == get("target")) {
*arg = ".".into();
}
}
if test {
return Ok(Some(Plan { build, run: None }));
}
let output = if get("outputDir").is_empty() {
crate::config::ignore_private(root, "/bin/")?;
ide_core::project_dir::path(root, "bin")
} else {
root.join(get("outputDir"))
};
std::fs::create_dir_all(&output)?;
let mut hash = std::collections::hash_map::DefaultHasher::new();
config.id.hash(&mut hash);
let mut binary = output.join(format!("go-app-{:x}{}", hash.finish(), std::env::consts::EXE_SUFFIX));
// An explicit Go -o selects the executable just as it does on the command line.
let mut i = 1;
while i < build.args.len() {
if build.args[i] == "-o" {
if let Some(path) = build.args.get(i + 1) {
binary = module.join(path);
}
build.args.drain(i..(i + 2).min(build.args.len()));
} else if let Some(path) = build.args[i].strip_prefix("-o=") {
binary = module.join(path);
build.args.remove(i);
} else {
i += 1;
}
}
if binary.is_dir() || binary.to_string_lossy().ends_with(['/', '\\']) {
let target = Path::new(get("target").split('|').next().unwrap_or("."));
let name = target.file_stem().or_else(|| module.file_name()).unwrap_or_default().to_string_lossy();
binary = binary.join(format!("{name}{}", std::env::consts::EXE_SUFFIX));
}
if let Some(dir) = binary.parent() {
std::fs::create_dir_all(dir)?;
}
build.args.splice(1..1, ["-o".into(), binary.to_string_lossy().into_owned()]);
let run = if get("runAfterBuild") == "true" {
let mut run = config.clone();
run.command = binary.to_string_lossy().into_owned();
// Program args are stored separately as an argv vector by the form.
run.args = p
.get("programArgv")
.map(|s| serde_json::from_str(s))
.transpose()
.map_err(|e| IdeError::invalid(e.to_string()))?
.unwrap_or_default();
run.extras.params.clear();
Some(run)
} else {
None
};
Ok(Some(Plan { build, run }))
}
/// Uses Go's own build-file selection, including platform suffixes, cgo and build tags.
/// `-find` avoids dependency traversal and package downloads for this local directory.
pub fn package_sources(mut command: Command, dir: &Path, flags: &[String]) -> IdeResult<Vec<PathBuf>> {
command.args(["list", "-find", "-e", "-json"]);
let mut it = flags.iter();
while let Some(arg) = it.next() {
let name = arg.split('=').next().unwrap_or(arg);
if ["-tags", "-compiler", "-mod", "-modfile", "-overlay"].contains(&name) {
command.arg(arg);
if name == arg
&& let Some(value) = it.next()
{
command.arg(value);
}
} else if ["-race", "-msan", "-asan"].contains(&name) {
command.arg(arg);
}
}
command.arg(".").current_dir(dir).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
let mut child = command.spawn()?;
let stdout = child.stdout.take();
let stderr = child.stderr.take();
let reader = std::thread::spawn(move || {
use std::io::Read;
let mut text = String::new();
if let Some(mut stdout) = stdout {
let _ = stdout.read_to_string(&mut text);
}
text
});
let errors = std::thread::spawn(move || {
use std::io::Read;
let mut text = String::new();
if let Some(mut stderr) = stderr {
let _ = stderr.read_to_string(&mut text);
}
text
});
let deadline = Instant::now() + Duration::from_secs(10);
let status = loop {
match child.try_wait()? {
Some(status) => break status,
None if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(10)),
None => {
let _ = child.kill();
let _ = child.wait();
return Err(IdeError::unavailable("Go package discovery timed out"));
}
}
};
let text = reader.join().unwrap_or_default();
let errors = errors.join().unwrap_or_default();
if !status.success() {
return Err(IdeError::invalid(format!("Go package discovery failed: {errors}")));
}
let package: serde_json::Value = serde_json::from_str(&text).map_err(|e| IdeError::invalid(e.to_string()))?;
Ok(["GoFiles", "CgoFiles"]
.iter()
.flat_map(|key| package[key].as_array().into_iter().flatten())
.filter_map(|p| p.as_str())
.map(|p| dir.join(p))
.collect())
}
pub fn prepare_test_file(root: &Path, config: &mut RunConfig) -> IdeResult<()> {
let p = &config.extras.params;
if config.kind != "go" || p.get("goKind").is_none_or(|s| s != "test") || p.get("scope").is_none_or(|s| s != "file")
{
return Ok(());
}
let target = p.get("target").ok_or_else(|| IdeError::invalid("Go test file target is missing"))?;
let file = root.join(p.get("module").map(String::as_str).unwrap_or(".")).join(target);
let dir = file.parent().ok_or_else(|| IdeError::invalid("Go test file has no directory"))?;
let mut command = Command::new(&config.command);
crate::process::configure_environment(&mut command, root, config)?;
let mut files = package_sources(command, dir, &config.args)?;
files.push(file);
let at = config
.args
.iter()
.position(|a| a == target)
.ok_or_else(|| IdeError::invalid("Go test file target is missing"))?;
config.args.splice(at..=at, files.iter().map(|p| p.to_string_lossy().into_owned()));
Ok(())
}
/// Module directories from a go.work file. Quoted paths and comments follow Go's file syntax.
pub fn work_modules(text: &str) -> Vec<PathBuf> {
let mut tokens = Vec::new();
let mut chars = text.chars().peekable();
while let Some(ch) = chars.next() {
match ch {
'\n' | '(' | ')' => tokens.push(ch.to_string()),
ch if ch.is_whitespace() => {}
'/' if chars.peek() == Some(&'/') => {
for c in chars.by_ref() {
if c == '\n' {
tokens.push("\n".into());
break;
}
}
}
'"' | '`' => {
let Some(token) = quoted_work_path(&mut chars, ch) else { return Vec::new() };
tokens.push(token);
}
_ => {
let mut token = ch.to_string();
while chars.peek().is_some_and(|c| !c.is_whitespace() && *c != '(' && *c != ')') {
token.push(chars.next().unwrap());
}
tokens.push(token);
}
}
}
let mut out = Vec::new();
let mut start = true;
let mut tokens = tokens.into_iter();
while let Some(token) = tokens.next() {
if token == "\n" {
start = true;
continue;
}
if start
&& token == "use"
&& let Some(path) = tokens.next()
{
if path == "(" {
let mut paths = Vec::new();
let mut closed = false;
for path in tokens.by_ref() {
if path == ")" {
closed = true;
break;
}
if path != "\n" && !path.is_empty() {
paths.push(PathBuf::from(path));
}
}
if closed {
out.extend(paths);
}
} else if path != "\n" && !path.is_empty() {
out.push(PathBuf::from(path));
}
}
start = false;
}
let mut seen = std::collections::HashSet::new();
out.retain(|path| seen.insert(path.clone()));
out
}
fn quoted_work_path(chars: &mut std::iter::Peekable<std::str::Chars<'_>>, quote: char) -> Option<String> {
let mut bytes = Vec::new();
while let Some(ch) = chars.next() {
if ch == quote {
return String::from_utf8(bytes).ok();
}
if ch == '\\' && quote == '"' {
let escaped = chars.next()?;
let byte = match escaped {
'a' => 7,
'b' => 8,
'f' => 12,
'n' => 10,
'r' => 13,
't' => 9,
'v' => 11,
'\\' => b'\\',
'"' => b'"',
'x' => {
let digits: String = chars.by_ref().take(2).collect();
if digits.len() != 2 {
return None;
}
u8::from_str_radix(&digits, 16).ok()?
}
'u' | 'U' => {
let width = if escaped == 'u' { 4 } else { 8 };
let digits: String = chars.by_ref().take(width).collect();
if digits.len() != width {
return None;
}
let ch = char::from_u32(u32::from_str_radix(&digits, 16).ok()?)?;
bytes.extend_from_slice(ch.to_string().as_bytes());
continue;
}
'0'..='7' => {
let digits = format!("{escaped}{}", chars.by_ref().take(2).collect::<String>());
if digits.len() != 3 {
return None;
}
u8::from_str_radix(&digits, 8).ok()?
}
_ => return None,
};
bytes.push(byte);
} else {
if ch == '\n' && quote == '"' {
return None;
}
bytes.extend_from_slice(ch.to_string().as_bytes());
}
}
None
}
+11
View File
@@ -3,6 +3,7 @@
mod config;
mod detect;
pub mod envfile;
pub mod go;
pub mod gradle_jvm;
pub mod java_cmdline;
pub mod junit;
@@ -70,6 +71,10 @@ fn is_true(b: &bool) -> bool {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct RunExtras {
/// Module supplying a JVM application's classpath; empty means the project root.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub module: Option<String>,
/// Values of the configuration type's own form fields (the command line derives from them).
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
#[specta(optional)]
@@ -137,6 +142,7 @@ pub struct RunExtras {
impl Default for RunExtras {
fn default() -> Self {
Self {
module: None,
params: BTreeMap::new(),
folder: None,
local: false,
@@ -235,6 +241,11 @@ pub fn system_env() -> BTreeMap<String, String> {
ide_core::process::host_vars().filter_map(|(k, v)| Some((k.into_string().ok()?, v.into_string().ok()?))).collect()
}
/// Complete environment for a preparatory tool belonging to this run configuration.
pub fn resolved_env(root: &Path, config: &RunConfig) -> IdeResult<BTreeMap<String, String>> {
process::resolved_env(root, config)
}
/// Path of the user run configuration file for `root`.
pub fn user_configs_path(root: &Path) -> PathBuf {
config::config_path(root)
+116 -10
View File
@@ -46,10 +46,11 @@ struct StopCommand {
}
struct Entry {
project_root: PathBuf,
id: RunId,
config_id: String,
name: String,
pid: Option<u32>,
pid: Mutex<Option<u32>>,
stdin: Mutex<Option<Box<dyn Write + Send>>>,
/// The PTY master of a terminal-emulated run (resize; dropped on exit).
pty: Option<Mutex<Option<Box<dyn MasterPty + Send>>>>,
@@ -65,7 +66,7 @@ impl Entry {
id: self.id,
config_id: self.config_id.clone(),
name: self.name.clone(),
pid: self.pid,
pid: *self.pid.lock(),
running: !self.exited.load(Ordering::Acquire),
pty: self.pty.is_some(),
}
@@ -299,10 +300,17 @@ impl ProcessManager {
if config.command.trim().is_empty() {
return Err(IdeError::invalid("run configuration has no command"));
}
let plan = crate::go::plan(root, config)?;
let followup = plan.as_ref().and_then(|p| p.run.as_ref()).map(|run| {
let cwd = resolve_cwd(root, run.cwd.as_deref());
(run.clone(), cwd)
});
let config = plan.as_ref().map_or(config, |p| &p.build);
let cwd = resolve_cwd(root, config.cwd.as_deref());
if !cwd.is_dir() {
return Err(IdeError::not_found(format!("working directory does not exist: {}", cwd.display())));
}
crate::detect::cmake::prepare(config, &cwd)?;
let mut inv = invocation(config, &cwd);
let extras = &config.extras;
if extras.emulate_terminal && extras.stdin_file.is_some() {
@@ -326,8 +334,11 @@ impl ProcessManager {
let notice =
if extras.emulate_terminal { None } else { match_console_encoding(&mut inv, &mut env, config, options) };
let temp = shorten_java(&mut inv, config, &cwd)?;
if extras.emulate_terminal && followup.is_some() {
return Err(IdeError::invalid("Go build and run requires the output console"));
}
if extras.emulate_terminal {
return self.start_pty(config, &inv, &cwd, &env, stop, options, sink, on_output, temp);
return self.start_pty(root, config, &inv, &cwd, &env, stop, options, sink, on_output, temp);
}
let mut cmd = Command::new(&inv.program);
@@ -374,10 +385,11 @@ impl ProcessManager {
_ => None,
};
let entry = Arc::new(Entry {
project_root: root.to_path_buf(),
id,
config_id: config.id.clone(),
name: config.name.clone(),
pid: Some(child.id()),
pid: Mutex::new(Some(child.id())),
stdin: Mutex::new(stdin),
pty: None,
stop,
@@ -399,7 +411,7 @@ impl ProcessManager {
if let Some(err) = stderr {
spawn_reader(id, Stream::Stderr, err, tx.clone())?;
}
spawn_pump(rx, on_output, entry.clone(), sink, options.encoding.clone(), notice)?;
spawn_pump(rx, on_output.clone(), entry.clone(), sink, options.encoding.clone(), notice)?;
Ok(())
})();
if let Err(e) = result {
@@ -410,10 +422,17 @@ impl ProcessManager {
}
// The waiter owns the child; it must exist for the pump to ever finish.
let waiter_tx = tx;
let waiter_entry = entry.clone();
let run_root = root.to_path_buf();
std::thread::Builder::new()
.name(format!("openidea-run-wait-{}", id.0))
.spawn(move || {
let status = child.wait();
let mut status = child.wait();
if status.as_ref().is_ok_and(ExitStatus::success)
&& let Some((run, cwd)) = followup
{
status = run_followup(&run_root, &run, &cwd, &waiter_entry, &waiter_tx);
}
drop(temp);
let _ = waiter_tx.send(Msg::Exited(Exit::Pipes(status)));
})
@@ -427,6 +446,7 @@ impl ProcessManager {
#[allow(clippy::too_many_arguments)]
fn start_pty(
&self,
root: &Path,
config: &RunConfig,
inv: &Invocation,
cwd: &Path,
@@ -476,10 +496,11 @@ impl ProcessManager {
let id = RunId::next();
let entry = Arc::new(Entry {
project_root: root.to_path_buf(),
id,
config_id: config.id.clone(),
name: config.name.clone(),
pid: child.process_id(),
pid: Mutex::new(child.process_id()),
stdin: Mutex::new(Some(writer)),
pty: Some(Mutex::new(Some(pair.master))),
stop,
@@ -578,8 +599,9 @@ impl ProcessManager {
if entry.exited.load(Ordering::Acquire) {
return Ok(());
}
let Some(pid) = entry.pid else { return Ok(()) };
if entry.stopping.swap(true, Ordering::AcqRel) {
let already_stopping = entry.stopping.swap(true, Ordering::AcqRel);
let Some(pid) = *entry.pid.lock() else { return Ok(()) };
if already_stopping {
// Second stop request: don't wait out the grace period.
kill_tree(pid, true);
return Ok(());
@@ -622,10 +644,21 @@ impl ProcessManager {
}
/// Kills every running process tree at once (shutting down).
pub fn kill_project(&self, root: &Path) {
for entry in self.runs.lock().values().filter(|e| e.project_root == root) {
if !entry.exited.load(Ordering::Acquire)
&& let Some(pid) = *entry.pid.lock()
{
kill_tree(pid, true);
}
}
}
pub fn kill_all(&self) {
for entry in self.runs.lock().values() {
entry.stopping.store(true, Ordering::Release);
if !entry.exited.load(Ordering::Acquire)
&& let Some(pid) = entry.pid
&& let Some(pid) = *entry.pid.lock()
{
kill_tree(pid, true);
}
@@ -641,6 +674,67 @@ impl Drop for ProcessManager {
/// The environment a configuration's process gets on top of the inherited one: `.env`
/// files, then the configured variables (later wins).
fn run_followup(
root: &Path,
config: &RunConfig,
cwd: &Path,
entry: &Entry,
tx: &Sender<Msg>,
) -> std::io::Result<ExitStatus> {
let mut pid = entry.pid.lock();
if entry.stopping.load(Ordering::Acquire) {
return Err(std::io::Error::other("run stopped after build"));
}
let inv = invocation(config, cwd);
let mut cmd = Command::new(&inv.program);
cmd.args(&inv.args).current_dir(cwd).stdout(Stdio::piped()).stderr(Stdio::piped());
match config.extras.stdin_file.as_deref() {
Some(file) => {
cmd.stdin(std::fs::File::open(resolve_cwd(root, Some(Path::new(file))))?);
}
None => {
cmd.stdin(Stdio::piped());
}
}
let env = launch_env(root, config).map_err(|e| std::io::Error::other(e.to_string()))?;
apply_env(&mut cmd, &env);
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
cmd.process_group(0);
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
cmd.creation_flags(0x0000_0200 | ide_core::process::CREATE_NO_WINDOW);
}
(entry.output)(OutputChunk::System { text: format!("{}\n", inv.display) });
let mut child = cmd.spawn()?;
*pid = Some(child.id());
*entry.stdin.lock() = child.stdin.take().map(|p| Box::new(p) as Box<dyn Write + Send>);
drop(pid);
let readers = (|| {
if let Some(out) = child.stdout.take() {
spawn_reader(entry.id, Stream::Stdout, out, tx.clone())?;
}
if let Some(err) = child.stderr.take() {
spawn_reader(entry.id, Stream::Stderr, err, tx.clone())?;
}
Ok::<_, std::io::Error>(())
})();
if let Err(e) = readers {
let _ = child.kill();
let _ = child.wait();
return Err(e);
}
child.wait()
}
pub(crate) fn configure_environment(command: &mut Command, root: &Path, config: &RunConfig) -> IdeResult<()> {
apply_env(command, &launch_env(root, config)?);
Ok(())
}
fn launch_env(root: &Path, config: &RunConfig) -> IdeResult<LaunchEnv> {
let extras = &config.extras;
let mut vars = if extras.env_files.is_empty() {
@@ -661,6 +755,18 @@ fn launch_env(root: &Path, config: &RunConfig) -> IdeResult<LaunchEnv> {
Ok(LaunchEnv { clear: !extras.include_system_env, vars })
}
pub(crate) fn resolved_env(root: &Path, config: &RunConfig) -> IdeResult<BTreeMap<String, String>> {
let env = launch_env(root, config)?;
let mut vars = if env.clear { BTreeMap::new() } else { crate::system_env() };
if !env.clear {
vars.extend(
ide_core::process::shell_env().filter_map(|(k, v)| Some((k.into_string().ok()?, v.into_string().ok()?))),
);
}
vars.extend(env.vars);
Ok(vars)
}
fn apply_env(cmd: &mut Command, env: &LaunchEnv) {
if env.clear {
cmd.env_clear();
+28 -3
View File
@@ -216,7 +216,7 @@ pub fn deployment(root: &Path, base: &Path, config: &RunConfig, resources_only:
.map_err(|_| invalid("Build the WAR artifact before launching Tomcat"))?;
let war = PathBuf::from(war.trim());
if !war.is_file() || !war.extension().is_some_and(|e| e.eq_ignore_ascii_case("war")) {
return Err(invalid("The built WAR artifact does not exist"));
return Err(invalid(format!("The built WAR artifact does not exist: {}", war.display())));
}
let dest = base.join("deployment");
explode(&war, &dest, resources_only)?;
@@ -227,11 +227,14 @@ pub fn deployment(root: &Path, base: &Path, config: &RunConfig, resources_only:
.get("deploymentPath")
.filter(|v| !v.trim().is_empty())
.ok_or_else(|| invalid("Choose a WAR file or exploded directory"))?;
let path = root.join(value).canonicalize()?;
let path = root.join(value);
let path = path
.canonicalize()
.map_err(|e| invalid(format!("Invalid Tomcat deployment: {} ({e})", path.display())))?;
if (kind == "war" && (!path.is_file() || !path.extension().is_some_and(|e| e.eq_ignore_ascii_case("war"))))
|| (kind == "directory" && !path.is_dir())
{
return Err(invalid("Invalid Tomcat deployment"));
return Err(invalid(format!("Invalid Tomcat deployment: {}", path.display())));
}
Ok(path)
}
@@ -436,6 +439,28 @@ mod tests {
assert!(deployment(tmp.path(), &base, &c, false).unwrap().ends_with("WebContent"));
}
#[test]
fn deployment_errors_identify_the_checked_path() {
let tmp = tempfile::tempdir().unwrap();
let base = tmp.path().join("cache");
fs::create_dir_all(&base).unwrap();
for path in ["missing.war", r"C:\工作\timesheet\target\timesheet.war", r"\\server\share\timesheet.war"] {
fs::write(base.join("artifact-path.txt"), format!("{path}\r\n")).unwrap();
let error = deployment(tmp.path(), &base, &config(), false).unwrap_err().to_string();
assert!(error.contains(path), "{error}");
}
for kind in ["war", "directory"] {
let mut c = config();
c.extras.params.insert("deployment".into(), kind.into());
c.extras.params.insert("deploymentPath".into(), "missing".into());
let error = deployment(tmp.path(), &base, &c, false).unwrap_err().to_string();
assert!(error.contains(&tmp.path().join("missing").display().to_string()), "{error}");
c.extras.params.insert("deploymentPath".into(), "cache".into());
if kind == "war" {
assert!(deployment(tmp.path(), &base, &c, false).unwrap_err().to_string().contains("cache"));
}
}
}
#[test]
fn prepares_isolated_base_and_jdwp_only_for_run() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().join("Tomcat Home");
+28
View File
@@ -234,3 +234,31 @@ fn suppression_round_trip_and_resaving() {
assert_eq!(load_suppressed_config_ids(dir.path()).unwrap(), vec![ids[1].clone()]);
assert!(fs::read_to_string(dir.path().join(".openidea/.gitignore")).unwrap().contains("/run.local.toml"));
}
#[test]
fn java_module_is_optional_and_independent_of_working_directory() {
let dir = tempfile::tempdir().unwrap();
let path = user_configs_path(dir.path());
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(
&path,
r#"[[configurations]]
id = "user:main"
name = "Main"
kind = "application"
command = "java"
args = ["-cp", "${classpath}", "a.Main"]
cwd = "sibling"
"#,
)
.unwrap();
let mut configs = load_user_configs(dir.path()).unwrap();
assert_eq!(configs[0].extras.module, None);
for module in ["app", ""] {
configs[0].extras.module = Some(module.into());
save_user_configs(dir.path(), &configs).unwrap();
let loaded = load_user_configs(dir.path()).unwrap();
assert_eq!(loaded, configs);
assert_eq!(loaded[0].cwd.as_deref(), Some(std::path::Path::new("sibling")));
}
}
+88
View File
@@ -216,6 +216,7 @@ fn builds_nested_in_a_folder_of_repositories() {
assert_eq!(app.kind, "spring-boot");
assert_eq!(cmdline(app), "java -cp ${classpath} org.dromara.DromaraApplication");
assert_eq!(app.cwd.as_deref(), Some(Path::new("RuoYi-Vue-Plus/ruoyi-admin")));
assert_eq!(app.extras.module.as_deref(), Some("RuoYi-Vue-Plus/ruoyi-admin"));
assert_eq!(app.debug_adapter.as_deref(), Some("java-debug"));
let package = find(&c, "maven:package:RuoYi-Vue-Plus");
assert_eq!(package.name, "RuoYi-Vue-Plus: mvn package");
@@ -549,3 +550,90 @@ fn root_package_disables_nested_scan() {
assert!(ids(&c).contains(&"go:test"));
assert!(!ids(&c).contains(&"go:test:svc"));
}
#[test]
fn cmake_detection_is_read_only_and_preserves_existing_build_directories() {
let dir = project(&[
("CMakeLists.txt", "project(demo)"),
("cmake-build-debug/CMakeCache.txt", "CMAKE_CONFIGURATION_TYPES:STRING=Release;RelWithDebInfo\n"),
]);
let configs = detect(dir.path());
let configure = find(&configs, "cmake:cmake-build-debug:configure");
assert_eq!(configure.args, ["-S", ".", "-B", "cmake-build-debug", "-DCMAKE_EXPORT_COMPILE_COMMANDS=ON"]);
assert!(!dir.path().join("build").exists());
assert!(!dir.path().join("cmake-build-debug/.cmake").exists());
let build = find(&configs, "cmake:cmake-build-debug:build");
assert!(build.extras.before_launch.is_empty(), "a configured build regenerates by itself");
assert!(build.args.windows(2).any(|a| a == ["--config", "Release"]));
let test = find(&configs, "cmake:cmake-build-debug:test");
assert_eq!(test.extras.before_launch[0].target, build.id);
}
#[test]
fn cmake_configures_before_the_first_build() {
let dir = project(&[("CMakeLists.txt", "project(demo)")]);
let configs = detect(dir.path());
let build = find(&configs, "cmake:build:build");
assert_eq!(build.extras.before_launch[0].target, "cmake:build:configure");
}
#[test]
fn cmake_ignores_malformed_file_api_replies() {
let dir = project(&[
("CMakeLists.txt", "project(demo)"),
("build/CMakeCache.txt", ""),
("build/.cmake/api/v1/reply/index-1.json", "not JSON"),
]);
let configs = detect(dir.path());
assert_eq!(configs.iter().filter(|c| c.kind == "cmake").count(), 3);
assert!(!configs.iter().any(|c| c.kind == "native"));
}
#[test]
fn go_work_members_define_configs_including_deep_and_quoted_directories() {
let dir = project(&[
(
"go.work",
"go 1.22\n// use ./ignored\nuse (\n ./apps/deep/server // member\n \"./module with spaces\"\n)\nuse ./apps/deep/server\n",
),
("apps/deep/server/go.mod", "module example.com/server\ngo 1.22\n"),
("apps/deep/server/main.go", "package main\nfunc main() {}"),
("module with spaces/go.mod", "module example.com/lib\ngo 1.22\n"),
("ignored/go.mod", "module example.com/ignored\ngo 1.22\n"),
]);
let configs = detect(dir.path());
assert_eq!(configs.iter().filter(|c| c.kind == "go" && c.is_test).count(), 2);
assert_eq!(find(&configs, "go:run:apps/deep/server").cwd.as_deref(), Some(Path::new("apps/deep/server")));
assert!(configs.iter().all(|c| !c.id.contains("ignored")));
assert!(configs.iter().any(|c| c.id == "go:test:module with spaces"));
}
#[test]
fn go_work_parser_ignores_other_directives_comments_and_malformed_groups() {
use ide_run::go::work_modules;
assert_eq!(
work_modules("replace use => ./not-a-member\n// use ./also-not\nuse ./real\n"),
vec![std::path::PathBuf::from("./real")]
);
assert!(work_modules("use (\n./missing-close\n").is_empty());
assert!(work_modules("use \"unterminated").is_empty());
}
#[test]
fn go_work_paths_decode_go_string_escapes() {
use ide_run::go::work_modules;
for path in [r#""./\u0061pp""#, r#""./\x61pp""#, r#""./\141pp""#, "`./app`"] {
assert_eq!(work_modules(&format!("use {path}\n")), vec![std::path::PathBuf::from("./app")]);
}
assert!(work_modules(r#"use "./\qinvalid""#).is_empty());
}
#[test]
fn go_default_build_output_is_ignored_by_version_control() {
let dir = project(&[("go.mod", "module x\ngo 1.22\n"), ("main.go", "package main\nfunc main() {}")]);
let mut config = detect(dir.path()).into_iter().find(|c| c.kind == "go" && !c.is_test).unwrap();
config.extras.params.insert("goKind".into(), "package".into());
assert!(ide_run::go::plan(dir.path(), &config).unwrap().is_some());
let ignore = fs::read_to_string(dir.path().join(".openidea/.gitignore")).unwrap();
assert!(ignore.lines().any(|l| l == "/bin/"), "{ignore}");
}
+135
View File
@@ -291,6 +291,10 @@ fn env_files_system_env_and_redirects() {
config.extras.include_system_env = false;
config.extras.stdin_file = Some("input.txt".into());
config.extras.output_file = Some("logs/out.log".into());
let prepared = ide_run::resolved_env(root, &config).unwrap();
assert_eq!(prepared.get("FROM_FILE").map(String::as_str), Some("file"));
assert_eq!(prepared.get("OVERRIDDEN").map(String::as_str), Some("config"));
assert!(!prepared.contains_key("HOME"));
let (id, chunks) = start(&h, &config);
// stdin comes from the file: writing to it is refused.
assert!(h.pm.write_stdin(id, "x").is_err());
@@ -302,6 +306,7 @@ fn env_files_system_env_and_redirects() {
// With the system environment, HOME is inherited.
config.extras.include_system_env = true;
assert!(ide_run::resolved_env(root, &config).unwrap().contains_key("HOME"));
config.extras.stdin_file = None;
config.extras.output_file = None;
config.args = vec!["-c".into(), "printf '%s' \"${HOME:+set}\"".into()];
@@ -468,3 +473,133 @@ fn python_scripts_import_sibling_packages_and_preserve_env_file_paths() {
assert_eq!(text(&chunks, stdout), "import OK vendor OK\n");
}
}
#[test]
fn revoking_one_project_stops_only_its_runs() {
let h = harness();
let other = tempfile::tempdir().unwrap();
let (mine, _) = start(&h, &cfg("mine", "/bin/sh", &["-c", "sleep 30"]));
let theirs =
h.pm.start(
other.path(),
&cfg("theirs", "/bin/sh", &["-c", "sleep 30"]),
&StartOptions::default(),
h.sink.clone(),
Box::new(|_| {}),
)
.unwrap();
h.pm.kill_project(h.root.path());
wait_exit(&h, mine, Duration::from_secs(5));
assert!(h.pm.list().iter().any(|run| run.id == theirs.id && run.running));
h.pm.stop(theirs.id).unwrap();
}
#[test]
#[ignore = "requires CMake, CTest and a C++ compiler"]
fn cmake_configure_discovers_builds_and_runs_a_multifile_target() {
let h = harness();
std::fs::write(h.root.path().join("CMakeLists.txt"), "cmake_minimum_required(VERSION 3.20)\nproject(smoke LANGUAGES CXX)\nadd_executable(smoke main.cpp math.cpp)\nenable_testing()\nadd_test(NAME smoke COMMAND smoke)\n").unwrap();
std::fs::write(h.root.path().join("main.cpp"), "int add(int,int); int main() { return add(2,3)==5 ? 0 : 1; }\n")
.unwrap();
std::fs::write(h.root.path().join("math.cpp"), "int add(int a,int b) { return a+b; }\n").unwrap();
let initial = ide_run::detect(h.root.path());
assert!(!h.root.path().join("build").exists(), "detection must not modify the project");
let configure = initial.iter().find(|c| c.id.ends_with(":configure")).unwrap();
let (id, output) = start(&h, configure);
assert_eq!(wait_exit(&h, id, Duration::from_secs(60)), Some(0), "{}", text(&output, stderr));
assert!(h.root.path().join("build/.cmake/api/v1/query/client-openidea/codemodel-v2").exists());
let root = h.root.path().canonicalize().unwrap();
let configs = ide_run::detect(&root);
let target = configs.iter().find(|c| c.kind == "native").expect("File API executable");
assert_eq!(target.debug_adapter.as_deref(), Some("lldb-dap"));
let sources = &target.extras.params["cmakeSources"];
assert!(sources.lines().any(|s| s.ends_with("/main.cpp")));
assert!(sources.lines().any(|s| s.ends_with("/math.cpp")));
assert!(sources.lines().any(|s| s == root.join("main.cpp").to_string_lossy()));
assert_eq!(target.extras.params["cmakeCanonicalSourceRoot"], root.to_string_lossy());
let build = configs.iter().find(|c| c.id == target.extras.before_launch[0].target).unwrap();
let (id, output) = start(&h, build);
assert_eq!(wait_exit(&h, id, Duration::from_secs(60)), Some(0), "{}", text(&output, stderr));
let (id, _) = start(&h, target);
assert_eq!(wait_exit(&h, id, Duration::from_secs(10)), Some(0));
let ctest = configs.iter().find(|c| c.command == "ctest").unwrap();
let (id, output) = start(&h, ctest);
assert_eq!(wait_exit(&h, id, Duration::from_secs(10)), Some(0), "{}", text(&output, stdout));
}
#[test]
fn go_build_runs_with_separate_arguments_environment_and_working_directory() {
let h = harness();
let Some(go) = ide_run::which::find_executable("go", &std::env::var_os("PATH").unwrap_or_default()) else {
eprintln!("skipped: no Go toolchain");
return;
};
let module = h.root.path().join("module");
std::fs::create_dir_all(&module).unwrap();
std::fs::write(module.join("go.mod"), "module example.com/demo\ngo 1.22\n").unwrap();
std::fs::write(module.join("main.go"), "//go:build demo\n\npackage main\nimport (\"fmt\"; \"os\")\nfunc main(){cwd,_:=os.Getwd();fmt.Printf(\"%q %s %s\\n\",os.Args[1:],os.Getenv(\"GO_RUN_VALUE\"),cwd)}\n").unwrap();
let mut c = cfg("go-config", &go.to_string_lossy(), &["build", "-tags", "demo", "."]);
c.kind = "go".into();
c.env.insert("GO_RUN_VALUE".into(), "configured".into());
c.extras.params = BTreeMap::from([
("goKind".into(), "build".into()),
("scope".into(), "package".into()),
("target".into(), ".".into()),
("module".into(), "module".into()),
("outputDir".into(), "bin with spaces".into()),
("runAfterBuild".into(), "true".into()),
("programArgv".into(), r#"["-tags","two words"]"#.into()),
]);
let (id, out) = start(&h, &c);
assert_eq!(wait_exit(&h, id, Duration::from_secs(60)), Some(0), "{}", text(&out, stderr));
let output = text(&out, stdout);
assert!(output.contains(r#"["-tags" "two words"] configured"#), "{output}");
assert!(output.contains(&h.root.path().display().to_string()), "{output}");
assert!(std::fs::read_dir(h.root.path().join("bin with spaces")).unwrap().next().is_some());
c.extras.params.insert("runAfterBuild".into(), "false".into());
let (id, out) = start(&h, &c);
assert_eq!(wait_exit(&h, id, Duration::from_secs(60)), Some(0));
assert!(text(&out, stdout).is_empty());
c.extras.params.insert("runAfterBuild".into(), "true".into());
c.args = vec!["build".into(), "missing.go".into()];
let (id, out) = start(&h, &c);
assert_ne!(wait_exit(&h, id, Duration::from_secs(60)), Some(0));
assert!(text(&out, stdout).is_empty(), "a failed build must never run an older binary");
}
#[test]
fn go_file_tests_use_build_selected_sources_and_only_the_selected_test_file() {
let h = harness();
let Some(go) = ide_run::which::find_executable("go", &std::env::var_os("PATH").unwrap_or_default()) else {
eprintln!("skipped: no Go toolchain");
return;
};
std::fs::write(h.root.path().join("go.mod"), "module example.com/filetests\ngo 1.22\n").unwrap();
std::fs::write(h.root.path().join("main.go"), "package filetests\nfunc value() int { return 2 }\n").unwrap();
std::fs::write(h.root.path().join("generate.go"), "//go:build ignore\n\npackage main\nfunc main() {}\n").unwrap();
std::fs::write(
h.root.path().join("demo_test.go"),
"package filetests\nimport \"testing\"\nfunc TestChosen(t *testing.T) { if value()!=2 { t.Fatal(\"bad\") } }\n",
)
.unwrap();
std::fs::write(
h.root.path().join("other_test.go"),
"package filetests\nimport \"testing\"\nfunc TestOther(t *testing.T) { t.Fatal(\"must not run\") }\n",
)
.unwrap();
let mut c = cfg("go-file", &go.to_string_lossy(), &["test", "-json", "demo_test.go"]);
c.kind = "go".into();
c.extras.params = BTreeMap::from([
("goKind".into(), "test".into()),
("scope".into(), "file".into()),
("target".into(), "demo_test.go".into()),
]);
ide_run::go::prepare_test_file(h.root.path(), &mut c).unwrap();
assert!(!c.args.iter().any(|a| a.ends_with("generate.go") || a.ends_with("other_test.go")));
let (id, out) = start(&h, &c);
assert_eq!(wait_exit(&h, id, Duration::from_secs(60)), Some(0), "{}", text(&out, stderr));
assert!(text(&out, stdout).contains("TestChosen"));
assert!(!text(&out, stdout).contains("TestOther"));
}
+150
View File
@@ -0,0 +1,150 @@
//! Opt-in build-tool integration checks: cargo test -p ide-run --test tomcat_artifact -- --ignored
use std::{fs, path::Path, process::Command};
fn write(root: &Path, path: &str, text: &str) {
let path = root.join(path);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, text).unwrap();
}
fn build_and_deploy(root: &Path, id: &str, expected: &Path) {
let cache = root.join("工作 cache");
let model = ide_build::load(root, &cache, None);
let mut plan = ide_build::web::build(root, &model, id, &cache).unwrap();
plan.resolve_project(root, &std::env::vars().collect(), &ide_core::CancellationToken::new()).unwrap();
let output = Command::new(&plan.command)
.args(&plan.args)
.envs(&plan.env)
.current_dir(root.join(&plan.cwd))
.output()
.unwrap();
assert!(
output.status.success(),
"{}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let manifest = fs::read_to_string(cache.join("artifact-path.txt")).unwrap();
assert_eq!(Path::new(manifest.trim()).canonicalize().unwrap(), expected.canonicalize().unwrap());
let mut config: ide_run::RunConfig = serde_json::from_value(serde_json::json!({
"id": "tomcat:test", "name": "Tomcat", "kind": "tomcat", "command": "catalina",
"args": [], "env": {}, "detected": false, "isTest": false
}))
.unwrap();
config.cwd = Some("timesheet".into());
config.extras.params.insert("artifact".into(), id.into());
let deployed = ide_run::tomcat::deployment(root, &cache, &config, false).unwrap();
assert_eq!(fs::read_to_string(deployed.join("index.html")).unwrap(), "issue 515 timesheet");
let siblings: Vec<_> = fs::read_dir(expected.parent().unwrap())
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
.filter(|name| name.contains(".war."))
.collect();
assert!(siblings.is_empty(), "Unexpected WAR siblings: {siblings:?}");
}
#[test]
#[ignore = "requires Maven and downloads Maven plugins"]
fn maven_packaged_artifacts_deploy_from_root_and_nested_reactors() {
for (reactor, module, custom) in
[("", "", false), ("", "timesheet", false), ("parent", "apps/timesheet", true), ("timesheet", "", true)]
{
let tmp = tempfile::Builder::new().prefix("tomcat maven ").tempdir().unwrap();
let root = tmp.path();
let reactor_dir = root.join(reactor);
let module_dir = reactor_dir.join(module);
let plugins = "<pluginManagement><plugins><plugin><artifactId>maven-war-plugin</artifactId><version>3.4.0</version><configuration><failOnMissingWebXml>false</failOnMissingWebXml></configuration></plugin></plugins></pluginManagement>";
let parent = if module.is_empty() && custom {
write(&reactor_dir, ".mvn/maven.config", "-Drevision=2\n");
"<groupId>test</groupId><version>${revision}</version><properties><revision>1</revision></properties>"
.to_owned()
} else if module.is_empty() {
"<groupId>test</groupId><version>1</version>".to_owned()
} else {
write(
&reactor_dir,
"pom.xml",
&format!(
"<project><modelVersion>4.0.0</modelVersion><groupId>test</groupId><artifactId>parent</artifactId><version>1</version><packaging>pom</packaging><modules><module>core</module><module>{module}</module></modules><build>{plugins}</build></project>"
),
);
write(
&reactor_dir,
"core/pom.xml",
"<project><modelVersion>4.0.0</modelVersion><parent><groupId>test</groupId><artifactId>parent</artifactId><version>1</version></parent><artifactId>core</artifactId></project>",
);
let relative = "../".repeat(module.split('/').count());
format!(
"<parent><groupId>test</groupId><artifactId>parent</artifactId><version>1</version><relativePath>{relative}pom.xml</relativePath></parent>"
)
};
let dependency = if module.is_empty() {
""
} else {
"<dependencies><dependency><groupId>test</groupId><artifactId>core</artifactId><version>1</version></dependency></dependencies>"
};
let output = if custom {
"<directory>${project.basedir}/custom output</directory><finalName>custom-timesheet</finalName>"
} else {
"<finalName>timesheet</finalName>"
};
write(
&module_dir,
"pom.xml",
&format!(
"<project><modelVersion>4.0.0</modelVersion>{parent}<artifactId>timesheet</artifactId><packaging>war</packaging>{dependency}<build>{plugins}{output}</build></project>"
),
);
write(&module_dir, "src/main/webapp/index.html", "issue 515 timesheet");
if custom && !module.is_empty() {
for dir in [&reactor_dir, &reactor_dir.join("core"), &module_dir] {
let pom = dir.join("pom.xml");
let text =
fs::read_to_string(&pom).unwrap().replace("<version>1</version>", "<version>${revision}</version>");
fs::write(pom, text).unwrap();
}
let pom = reactor_dir.join("pom.xml");
fs::write(
&pom,
fs::read_to_string(&pom).unwrap().replace(
"<packaging>pom</packaging>",
"<packaging>pom</packaging><properties><revision>1</revision></properties>",
),
)
.unwrap();
write(&reactor_dir, ".mvn/maven.config", "-Drevision=2\n");
write(&module_dir, ".mvn/maven.config", "-Drevision=3\n");
}
let pom = module_dir.join("pom.xml").strip_prefix(root).unwrap().to_string_lossy().replace('\\', "/");
let expected =
module_dir.join(if custom { "custom output/custom-timesheet.war" } else { "target/timesheet.war" });
build_and_deploy(root, &format!("maven:{pom}"), &expected);
}
}
#[test]
#[ignore = "requires Gradle"]
fn gradle_packaged_artifacts_deploy_with_custom_archive_names() {
for module in ["", "apps/timesheet"] {
let tmp = tempfile::Builder::new().prefix("tomcat gradle ").tempdir().unwrap();
let root = tmp.path();
let settings = if module.is_empty() {
"rootProject.name = 'timesheet'\n"
} else {
"rootProject.name = 'timesheet'\ninclude 'apps:timesheet'\n"
};
write(root, "settings.gradle", settings);
write(
&root.join(module),
"build.gradle",
"plugins { id 'war' }\ntasks.named('war') { archiveFileName = 'custom-timesheet.war'; destinationDirectory = layout.projectDirectory.dir('custom output') }\n",
);
write(&root.join(module), "src/main/webapp/index.html", "issue 515 timesheet");
let project = if module.is_empty() { ":" } else { ":apps:timesheet" };
build_and_deploy(
root,
&format!("gradle:{project}"),
&root.join(module).join("custom output/custom-timesheet.war"),
);
}
}
+110
View File
@@ -277,6 +277,32 @@ pub(crate) fn merge_init_options(cfg: &mut LanguageServerConfig, layer: Value) {
}
}
/// Workspace compiler defaults; Eclipse project preferences still take precedence.
pub(crate) fn configure_java_compiler(cfg: &mut LanguageServerConfig, data_dir: &Path) {
let file = data_dir.join("lsp/jdtls/compiler-defaults.prefs");
const DEFAULTS: &str = "org.eclipse.jdt.core.compiler.problem.forbiddenReference=warning\n\
org.eclipse.jdt.core.compiler.problem.discouragedReference=warning\n";
let write = || -> ide_core::IdeResult<()> {
std::fs::create_dir_all(file.parent().expect("compiler preferences directory"))?;
if std::fs::read_to_string(&file).ok().as_deref() != Some(DEFAULTS) {
ide_vfs::write_atomic(&file, DEFAULTS.as_bytes())?;
}
Ok(())
};
if let Err(e) = write() {
tracing::warn!("jdtls: cannot write compiler defaults: {e}");
return;
}
// jdtls accepts absolute filesystem paths as well as URLs (including paths
// containing spaces and Windows backslashes). Explicit server settings win later.
let defaults = json!({"settings": {"java": {"settings": {"url": file.to_string_lossy()}}}});
let existing = cfg.initialization_options.clone();
merge_init_options(cfg, defaults);
if let Some(existing) = existing {
merge_init_options(cfg, serde_json::to_value(existing).expect("TOML initialization options"));
}
}
/// Adjusts a language server's launch configuration from settings: interpreter
/// paths, toolchain `PATH`, env (`GOROOT`, `RUST_SRC_PATH`, `VIRTUAL_ENV`) and
/// server settings (`python.pythonPath`, rust-analyzer `check.command`, `typescript.tsdk`).
@@ -358,6 +384,22 @@ pub fn configure_server(cfg: &mut LanguageServerConfig, root: &Path, server_root
}
if name == "gopls" {
let mut build = crate::go::build_settings(&s.languages.go.build_tags);
let module_env = crate::go::module_environment(server_root, &s.languages.go.modules);
for (key, value) in module_env {
build["env"][key] = json!(value);
}
let mut settings = cfg.settings.take().unwrap_or_else(|| json!({}));
// gopls retains omitted keys on reconfiguration. Explicit empty values
// restore defaults when the last project constraint is cleared.
let init = cfg.initialization_options.as_ref().and_then(|v| serde_json::to_value(v).ok());
for (key, default) in [("buildFlags", json!([])), ("env", json!({}))] {
if settings.get(key).is_none() {
settings[key] = init.as_ref().and_then(|v| v.get(key)).cloned().unwrap_or(default);
}
}
merge(&mut settings, build);
cfg.settings = Some(settings);
if let Some(goroot) = goroot(root, s) {
path_dirs.push(goroot.join("bin"));
cfg.env.insert("GOROOT".into(), goroot.to_string_lossy().into_owned());
@@ -462,6 +504,7 @@ fn custom_server(name: &str, ls: &LanguageServerSettings) -> LanguageServerConfi
fallback: false,
max_java: None,
launch_error: None,
tsserver_project: false,
}
}
@@ -624,6 +667,9 @@ pub fn apply_to_run_configs(configs: &mut [RunConfig], root: &Path, s: &Settings
}
}
"go" => {
for (key, value) in crate::go::module_environment(root, &s.languages.go.modules) {
c.env.entry(key).or_insert(value);
}
if let Some(goroot) = &go {
let bin = goroot.join("bin");
if c.command == "go" && bin.join(exe("go")).is_file() {
@@ -728,6 +774,70 @@ pub fn terminal_activation(root: &Path, s: &Settings, shell: Option<&str>) -> Op
script(target).is_file().then_some(line)
}
#[cfg(test)]
mod java_compiler_tests {
use super::*;
fn options(cfg: &LanguageServerConfig) -> Value {
serde_json::to_value(cfg.initialization_options.as_ref().unwrap()).unwrap()
}
#[test]
fn compiler_defaults_live_in_the_cache_and_preserve_other_options() {
let tmp = tempfile::tempdir().unwrap();
let cache = tmp.path().join("cache with spaces 测试");
let mut cfg = custom_server("jdtls", &LanguageServerSettings::default());
merge_init_options(&mut cfg, json!({"settings": {"java": {"format": {"tabSize": 4}}}}));
configure_java_compiler(&mut cfg, &cache);
let options = options(&cfg);
let path = PathBuf::from(options.pointer("/settings/java/settings/url").unwrap().as_str().unwrap());
assert!(path.starts_with(&cache));
let prefs = std::fs::read_to_string(&path).unwrap();
assert_eq!(prefs.lines().count(), 2);
for problem in ["forbiddenReference", "discouragedReference"] {
assert!(prefs.contains(&format!("org.eclipse.jdt.core.compiler.problem.{problem}=warning\n")));
}
assert_eq!(options.pointer("/settings/java/format/tabSize"), Some(&json!(4)));
let modified = std::fs::metadata(&path).unwrap().modified().unwrap();
configure_java_compiler(&mut cfg, &cache);
assert_eq!(std::fs::metadata(path).unwrap().modified().unwrap(), modified);
assert_eq!(self::options(&cfg), options);
}
#[test]
fn explicit_compiler_settings_keep_priority() {
let tmp = tempfile::tempdir().unwrap();
let mut cfg = custom_server("jdtls", &LanguageServerSettings::default());
merge_init_options(&mut cfg, json!({"settings": {"java": {"settings": {"url": "own.prefs"}}}}));
configure_java_compiler(&mut cfg, tmp.path());
assert_eq!(options(&cfg).pointer("/settings/java/settings/url"), Some(&json!("own.prefs")));
for settings in [
json!({"java": {"settings": {"url": "custom.prefs"}}}),
json!({"settings": {"java": {"settings": {"url": "custom.prefs"}}}}),
] {
cfg.settings = None;
let mut s = Settings::default();
s.language_servers.insert(
"jdtls".into(),
LanguageServerSettings { settings: Some(settings.clone()), ..Default::default() },
);
server_protocol_overrides(&mut cfg, &s);
assert_eq!(cfg.settings, Some(settings));
}
let mut s = Settings::default();
s.language_servers.insert(
"jdtls".into(),
LanguageServerSettings {
initialization_options: Some(json!({"settings": {"java": {"settings": {"url": "user.prefs"}}}})),
..Default::default()
},
);
server_protocol_overrides(&mut cfg, &s);
assert_eq!(options(&cfg).pointer("/settings/java/settings/url"), Some(&json!("user.prefs")));
}
}
#[cfg(test)]
mod maven_linkage_tests {
use super::*;
+25 -7
View File
@@ -1,6 +1,6 @@
//! Finding installed interpreters and toolchains.
use crate::exec::{query, search_path, which};
use crate::exec::{executables_in, query, search_path, which};
use crate::{Sdk, SdkKind, bin_dir, exe, home_dir, pinned, venv_of};
use ide_core::version_managers::{self, Tool};
use std::collections::HashSet;
@@ -236,11 +236,32 @@ fn subdirs(dir: &Path) -> Vec<PathBuf> {
// ------------------------------------------------------------------ node
/// Node's own version managers, by where they keep installs below the home directory.
const NODE_MANAGERS: &[(&str, &str)] =
&[(".nvm/versions/node", "nvm"), (".volta/tools/image/node", "volta"), (".local/share/fnm/node-versions", "fnm")];
/// Where a Node on the search path comes from: its version manager, "system" for one
/// of the system's own bin directories, "path" for anything else the user put on `PATH`.
pub(crate) fn node_source(path: &Path, home: Option<&Path>) -> &'static str {
let managed = home.and_then(|home| {
NODE_MANAGERS.iter().find(|(dir, _)| path.starts_with(home.join(dir))).map(|(_, manager)| *manager)
});
managed.or_else(|| version_managers::manager_of(path)).unwrap_or_else(|| {
if path.parent().is_some_and(ide_core::tool_path::is_system_dir) { "system" } else { "path" }
})
}
fn node() -> Vec<Sdk> {
let mut found: Vec<(PathBuf, &'static str)> = Vec::new();
// A mise / asdf shim picks the version by directory: its installs are listed instead.
if let Some(p) = which("node").filter(|p| !version_managers::is_shim(p)) {
let source = version_managers::manager_of(&p).unwrap_or("path");
// Every Node on the search path, not only the first one `which` finds: an SSH
// session's minimal PATH puts /usr/bin ahead of the user's own ~/.local/bin, so
// /usr/bin/node would be the only install listed (#440).
for p in executables_in("node", std::env::split_paths(&search_path())) {
// A mise / asdf shim picks the version by directory: its installs are listed instead.
if version_managers::is_shim(&p) {
continue;
}
let source = node_source(&p, home_dir().as_deref());
found.push((p, source));
}
if let Some(home) = home_dir() {
@@ -257,9 +278,6 @@ fn node() -> Vec<Sdk> {
for (v, manager) in version_managers::installs(Tool::Node).into_iter().rev() {
found.push((v.join("bin").join(exe("node")), manager));
}
for p in ["/opt/homebrew/bin/node", "/usr/local/bin/node", "/usr/bin/node"] {
found.push((PathBuf::from(p), "system"));
}
let found = dedupe_by_real_path(found);
par_map(&found, |(path, source)| {
let version = query(path, &["--version"], VERSION_TIMEOUT)?;
+11
View File
@@ -26,6 +26,17 @@ pub(crate) fn which(name: &str) -> Option<PathBuf> {
ide_core::process::find_executable(name, &path)
}
/// Every install of `name` on `dirs`, one per directory holding it, in `dirs` order (the
/// first is what `which` returns). Tool detection lists them all: a login shell's `PATH`
/// and an SSH exec channel's minimal one disagree on which comes first (#440).
pub(crate) fn executables_in(name: &str, dirs: impl Iterator<Item = PathBuf>) -> Vec<PathBuf> {
let pathext = ide_core::process::host_var_os("PATHEXT");
let names = ide_core::process::executable_names(name, cfg!(windows), pathext.as_deref().and_then(|p| p.to_str()));
dirs.filter(|d| !d.as_os_str().is_empty())
.filter_map(|dir| names.iter().map(|n| dir.join(n)).find(|p| p.is_file()))
.collect()
}
/// Runs `program args` and returns trimmed stdout (stderr when stdout is empty)
/// on success; `None` on failure or after `timeout`.
pub(crate) fn query(program: &Path, args: &[&str], timeout: Duration) -> Option<String> {
+329
View File
@@ -0,0 +1,329 @@
//! Go build constraints shared by language services and command execution.
use ide_core::settings::GoBuildTags;
use serde_json::{Value, json};
pub fn custom_tags(tags: &str) -> Vec<&str> {
tags.split(|c: char| c.is_whitespace() || c == ',').filter(|s| !s.is_empty()).collect()
}
/// The build category of gopls settings uses flat protocol keys (`buildFlags`, `env`).
/// Keep these in protocol settings so changes do not restart the server process.
pub fn build_settings(tags: &GoBuildTags) -> Value {
let mut out = json!({});
let mut flags = Vec::new();
let mut selected = custom_tags(&tags.custom);
if !tags.version.trim().is_empty() {
selected.push(tags.version.trim());
}
if !selected.is_empty() {
flags.push(format!("-tags={}", selected.join(",")));
}
if !tags.compiler.trim().is_empty() {
flags.push(format!("-compiler={}", tags.compiler.trim()));
}
if !flags.is_empty() {
out["buildFlags"] = json!(flags);
}
for (key, value) in [("GOOS", &tags.os), ("GOARCH", &tags.arch), ("CGO_ENABLED", &tags.cgo)] {
if !value.trim().is_empty() {
out["env"][key] = json!(value.trim());
}
}
let experiments = tags.experiments.split(',').map(str::trim).filter(|s| !s.is_empty()).collect::<Vec<_>>();
if !experiments.is_empty() {
out["env"]["GOEXPERIMENT"] = json!(experiments.join(","));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_do_not_override_toolchain_or_existing_server_settings() {
assert_eq!(build_settings(&GoBuildTags::default()), json!({}));
assert!(custom_tags(" , \n ").is_empty());
}
#[test]
fn constraints_map_to_protocol_build_settings() {
let tags = GoBuildTags {
os: "linux".into(),
arch: "arm64".into(),
version: "go1.22".into(),
compiler: "gc".into(),
cgo: "0".into(),
custom: "integration debug,feature".into(),
experiments: "loopvar, rangefunc ".into(),
};
assert_eq!(
build_settings(&tags),
json!({
"buildFlags": ["-tags=integration,debug,feature,go1.22", "-compiler=gc"],
"env": {"GOOS":"linux", "GOARCH":"arm64", "CGO_ENABLED":"0", "GOEXPERIMENT":"loopvar,rangefunc"}
})
);
}
}
/// Project build constraints for Go command launches. Explicit run environment wins.
pub fn apply_to_run(config: &mut ide_run::RunConfig, tags: &GoBuildTags) {
let settings = build_settings(tags);
if let Some(env) = settings["env"].as_object() {
for (key, value) in env {
if let Some(value) = value.as_str() {
config.env.entry(key.clone()).or_insert_with(|| value.into());
}
}
}
if config.extras.params.get("useTags").is_some_and(|v| v == "true") {
let extra = settings["buildFlags"]
.as_array()
.into_iter()
.flatten()
.filter_map(|v| v.as_str())
.map(str::to_owned)
.collect::<Vec<_>>();
let mut args = config.args.split_off(usize::from(!config.args.is_empty()));
merge_flags(&mut args, &extra);
config.args.extend(args);
}
}
fn merge_flags(args: &mut Vec<String>, extra: &[String]) {
let boundary = args.iter().position(|a| a == "-args" || a == "--").unwrap_or(args.len());
let tail = args.split_off(boundary);
let mut tags = Vec::new();
let mut merged = Vec::new();
let mut all = extra.iter().chain(args.iter());
while let Some(arg) = all.next() {
if arg == "-tags" {
if let Some(value) = all.next() {
tags.push(value.as_str());
}
} else if let Some(value) = arg.strip_prefix("-tags=") {
tags.push(value);
} else {
merged.push(arg.clone());
}
}
if !tags.is_empty() {
merged.insert(0, format!("-tags={}", tags.join(",")));
}
merged.extend(tail);
*args = merged;
}
fn delve_build_flags(args: &[String], files: bool) -> Value {
if !files {
return json!(args);
}
json!(
args.iter()
.map(|arg| format!("'{}'", arg.replace('\\', "\\\\").replace('\'', "\\'")))
.collect::<Vec<_>>()
.join(" ")
)
}
pub fn apply_to_debug(
env: &mut std::collections::BTreeMap<String, String>,
extra_json: &mut Option<String>,
tags: &GoBuildTags,
) -> ide_core::IdeResult<()> {
let mut extra: Value = extra_json
.as_deref()
.map(serde_json::from_str)
.transpose()
.map_err(|e| ide_core::IdeError::invalid(e.to_string()))?
.unwrap_or_else(|| json!({}));
let settings = build_settings(tags);
if let Some(values) = settings["env"].as_object() {
for (key, value) in values {
if let Some(value) = value.as_str() {
env.entry(key.clone()).or_insert_with(|| value.into());
}
}
}
if extra.get("__goToolArgs").is_some() {
let mut args: Vec<String> = extra
.get("__goToolArgs")
.cloned()
.map(serde_json::from_value)
.transpose()
.map_err(|e| ide_core::IdeError::invalid(e.to_string()))?
.unwrap_or_default();
if extra.get("__goUseTags").and_then(Value::as_bool) == Some(true) {
let flags = settings["buildFlags"]
.as_array()
.into_iter()
.flatten()
.filter_map(Value::as_str)
.map(str::to_owned)
.collect::<Vec<_>>();
merge_flags(&mut args, &flags);
}
if let Some(files) = extra.get("__goFiles").and_then(Value::as_array) {
if extra["mode"] == "test" {
if let Some(dir) = files.first().and_then(Value::as_str).and_then(|f| std::path::Path::new(f).parent())
{
let mut command = std::process::Command::new("go");
ide_core::process::host_env(&mut command);
command.envs(env.iter());
let sources = ide_run::go::package_sources(command, dir, &args)?;
args.extend(sources.iter().map(|p| p.to_string_lossy().into_owned()));
}
} else {
args.extend(files.iter().skip(1).filter_map(Value::as_str).map(str::to_owned));
}
}
// Delve appends its -o after array build flags; positional source files
// require the string form so all compiler flags precede the file list.
extra["buildFlags"] = delve_build_flags(&args, extra.get("__goFiles").is_some());
}
if let Some(output) = extra.get("output").and_then(Value::as_str).and_then(|p| std::path::Path::new(p).parent()) {
std::fs::create_dir_all(output)?;
}
if let Some(map) = extra.as_object_mut() {
map.remove("__goUseTags");
map.remove("__goToolArgs");
map.remove("__goFiles");
}
*extra_json = Some(extra.to_string());
Ok(())
}
#[cfg(test)]
mod command_tests {
use super::*;
use std::collections::BTreeMap;
#[test]
fn merges_custom_tags_without_touching_program_arguments() {
let mut flags = vec!["-tags".into(), "explicit".into(), ".".into(), "-args".into(), "-tags=program".into()];
merge_flags(&mut flags, &["-tags=project,debug".into()]);
assert_eq!(flags, ["-tags=project,debug,explicit", ".", "-args", "-tags=program"]);
}
#[test]
fn file_debug_flags_keep_delves_output_flags_before_source_files() {
let flags = vec!["-tags=demo".into(), "C:\\a b\\main.go".into(), "quote'file.go".into()];
assert_eq!(delve_build_flags(&flags, false), json!(flags));
assert_eq!(delve_build_flags(&flags, true), json!("'-tags=demo' 'C:\\\\a b\\\\main.go' 'quote\\'file.go'"));
}
#[test]
fn debug_env_and_flags_use_the_same_constraints_and_preserve_overrides() {
let tags = GoBuildTags { custom: "project".into(), os: "linux".into(), ..Default::default() };
let mut env = BTreeMap::from([("GOOS".into(), "darwin".into())]);
let mut extra =
Some(json!({"mode":"test", "__goUseTags":true, "__goToolArgs":["-tags", "explicit"]}).to_string());
apply_to_debug(&mut env, &mut extra, &tags).unwrap();
let extra: Value = serde_json::from_str(extra.as_deref().unwrap()).unwrap();
assert_eq!(extra["buildFlags"], json!(["-tags=project,explicit"]));
assert!(extra.get("__goUseTags").is_none());
assert_eq!(env["GOOS"], "darwin");
assert!(apply_to_debug(&mut env, &mut Some("{".into()), &tags).is_err());
}
}
pub fn debug_environment(
root: &std::path::Path,
settings: &ide_core::settings::Settings,
env: &mut std::collections::BTreeMap<String, String>,
) {
for (key, value) in module_environment(root, &settings.languages.go.modules) {
env.entry(key).or_insert(value);
}
if let Some(goroot) = crate::apply::goroot(root, settings) {
env.entry("GOROOT".into()).or_insert_with(|| goroot.to_string_lossy().into_owned());
env.entry("PATH".into()).or_insert_with(|| crate::exec::path_with(&[&goroot.join("bin")]));
}
if let Some(gopath) = crate::apply::setting_path(root, &settings.languages.go.gopath) {
env.entry("GOPATH".into()).or_insert_with(|| gopath.to_string_lossy().into_owned());
}
}
pub fn module_environment(
root: &std::path::Path,
modules: &ide_core::settings::GoModules,
) -> std::collections::BTreeMap<String, String> {
let mut env: std::collections::BTreeMap<String, String> = modules
.env
.iter()
.filter(|(key, value)| {
!key.is_empty()
&& !key.contains(['=', '\0'])
&& !key.contains(char::is_whitespace)
&& !value.trim().is_empty()
&& !value.contains('\0')
})
.map(|(key, value)| (key.clone(), value.clone()))
.collect();
for key in ["GOWORK", "GOMODCACHE"] {
if let Some(value) = env.get_mut(key)
&& value != "off"
&& value != "auto"
{
*value = root.join(value.as_str()).to_string_lossy().into_owned();
}
}
if !modules.auto_vendor {
let flags = env
.get("GOFLAGS")
.cloned()
.or_else(|| ide_core::process::host_var_os("GOFLAGS").map(|v| v.to_string_lossy().into_owned()))
.unwrap_or_default();
if !flags.split_whitespace().any(|flag| flag.starts_with("-mod=")) {
let workspace = match env.get("GOWORK").map(String::as_str) {
Some("off") => false,
Some(path) if path != "auto" => !path.is_empty(),
_ => root.ancestors().any(|dir| dir.join("go.work").is_file()),
};
env.insert(
"GOFLAGS".into(),
format!("{} -mod={}", flags.trim(), if workspace { "readonly" } else { "mod" }).trim().into(),
);
}
}
env
}
#[cfg(test)]
mod module_tests {
use super::*;
use ide_core::settings::GoModules;
use std::collections::BTreeMap;
#[test]
fn defaults_and_invalid_entries_leave_the_environment_unchanged() {
let dir = tempfile::tempdir().unwrap();
assert!(module_environment(dir.path(), &GoModules::default()).is_empty());
let modules = GoModules {
env: BTreeMap::from([
("BAD KEY".into(), "x".into()),
("GOPROXY".into(), "".into()),
("GOPRIVATE".into(), "example.com/private".into()),
]),
..Default::default()
};
assert_eq!(
module_environment(dir.path(), &modules),
BTreeMap::from([("GOPRIVATE".into(), "example.com/private".into())])
);
}
#[test]
fn disabling_automatic_vendor_uses_workspace_legal_flags_and_keeps_explicit_flags() {
let dir = tempfile::tempdir().unwrap();
let mut modules = GoModules { auto_vendor: false, env: BTreeMap::from([("GOFLAGS".into(), "-race".into())]) };
assert_eq!(module_environment(dir.path(), &modules)["GOFLAGS"], "-race -mod=mod");
std::fs::write(dir.path().join("go.work"), "go 1.22\n").unwrap();
assert_eq!(module_environment(dir.path(), &modules)["GOFLAGS"], "-race -mod=readonly");
modules.env.insert("GOWORK".into(), "off".into());
assert_eq!(module_environment(dir.path(), &modules)["GOFLAGS"], "-race -mod=mod");
modules.env.insert("GOFLAGS".into(), "-mod=vendor".into());
assert_eq!(module_environment(dir.path(), &modules)["GOFLAGS"], "-mod=vendor");
}
}
+1
View File
@@ -333,6 +333,7 @@ pub fn configure_jdtls(
if cfg.name != "jdtls" {
return;
}
crate::apply::configure_java_compiler(cfg, data_dir);
// A Lombok agent from the user's JVM arguments wins.
let own_agent = crate::lombok::agent_in_args(cfg.args.iter().map(String::as_str));
let agent = own_agent.as_deref().or(lombok);
+1
View File
@@ -9,6 +9,7 @@ mod angular;
pub mod apply;
mod discover;
mod exec;
pub mod go;
pub mod godot;
pub mod java;
pub mod jdtls;
Loaded 100 of 643 files, more files were not shown because too many files have changed in this diff. Show more