Compare commits

..
Author SHA1 Message Date
Ernstjan FreriksandEjaYF b4e2d4493b fix(run): keep the Run/Debug Configurations toolbar visible with a long list (#2384)
With many configurations the dialog body itself scrolled, taking the list toolbar (+ − copy …) out of view. The dialog body is now a flex column whose panes fill it: the configuration list and the form scroll on their own, the toolbar and footer stay visible, and the body's height is a preferred size that shrinks with the window or when the dialog is resized.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Refs #2254

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

Closes #2332

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

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

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

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

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

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

Closes #2292

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

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

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

Fixes #2305

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

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

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

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

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

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

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

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

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

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

Refs #2272 (Windows CI failure)

---------

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

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

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

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

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

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

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

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

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

* chore: regenerate bindings for classpath library kind
2026-11-09 17:32:07 +08:00
599 changed files with 16915 additions and 1820 deletions

No files matched your search

Generated
+69 -59
View File
@@ -1522,7 +1522,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a"
dependencies = [
"quote",
"syn 3.0.9",
"syn 2.0.119",
]
[[package]]
@@ -4180,7 +4180,7 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "ide-agent"
version = "0.1.39"
version = "0.1.40"
dependencies = [
"dunce",
"encoding_rs",
@@ -4204,7 +4204,7 @@ dependencies = [
[[package]]
name = "ide-analysis"
version = "0.1.39"
version = "0.1.40"
dependencies = [
"ast-grep-core",
"ide-core",
@@ -4218,7 +4218,7 @@ dependencies = [
[[package]]
name = "ide-android"
version = "0.1.39"
version = "0.1.40"
dependencies = [
"ide-core",
"serde",
@@ -4229,7 +4229,7 @@ dependencies = [
[[package]]
name = "ide-build"
version = "0.1.39"
version = "0.1.40"
dependencies = [
"blake3",
"dirs 6.0.0",
@@ -4245,7 +4245,7 @@ dependencies = [
[[package]]
name = "ide-cli"
version = "0.1.39"
version = "0.1.40"
dependencies = [
"dirs 6.0.0",
"serde",
@@ -4259,8 +4259,9 @@ dependencies = [
[[package]]
name = "ide-clipboard"
version = "0.1.39"
version = "0.1.40"
dependencies = [
"gtk",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
@@ -4268,7 +4269,7 @@ dependencies = [
[[package]]
name = "ide-core"
version = "0.1.39"
version = "0.1.40"
dependencies = [
"dunce",
"encoding_rs",
@@ -4286,7 +4287,7 @@ dependencies = [
[[package]]
name = "ide-coverage"
version = "0.1.39"
version = "0.1.40"
dependencies = [
"ide-core",
"ide-run",
@@ -4300,7 +4301,7 @@ dependencies = [
[[package]]
name = "ide-dap"
version = "0.1.39"
version = "0.1.40"
dependencies = [
"futures",
"ide-core",
@@ -4323,7 +4324,7 @@ dependencies = [
[[package]]
name = "ide-db"
version = "0.1.39"
version = "0.1.40"
dependencies = [
"bb8",
"blake3",
@@ -4353,7 +4354,7 @@ dependencies = [
[[package]]
name = "ide-docker"
version = "0.1.39"
version = "0.1.40"
dependencies = [
"ide-core",
"serde",
@@ -4366,7 +4367,7 @@ dependencies = [
[[package]]
name = "ide-extension"
version = "0.1.39"
version = "0.1.40"
dependencies = [
"base64 0.22.1",
"blake3",
@@ -4386,7 +4387,7 @@ dependencies = [
[[package]]
name = "ide-fonts"
version = "0.1.39"
version = "0.1.40"
dependencies = [
"objc2-core-foundation",
"objc2-core-text",
@@ -4398,7 +4399,7 @@ dependencies = [
[[package]]
name = "ide-github"
version = "0.1.39"
version = "0.1.40"
dependencies = [
"ide-core",
"ide-vcs",
@@ -4411,7 +4412,7 @@ dependencies = [
[[package]]
name = "ide-host"
version = "0.1.39"
version = "0.1.40"
dependencies = [
"base64 0.22.1",
"blake3",
@@ -4478,7 +4479,7 @@ dependencies = [
[[package]]
name = "ide-http"
version = "0.1.39"
version = "0.1.40"
dependencies = [
"ide-core",
"parking_lot",
@@ -4496,7 +4497,7 @@ dependencies = [
[[package]]
name = "ide-index"
version = "0.1.39"
version = "0.1.40"
dependencies = [
"blake3",
"criterion",
@@ -4518,7 +4519,7 @@ dependencies = [
[[package]]
name = "ide-jsonrpc"
version = "0.1.39"
version = "0.1.40"
dependencies = [
"encoding_rs",
"futures",
@@ -4535,7 +4536,7 @@ dependencies = [
[[package]]
name = "ide-language"
version = "0.1.39"
version = "0.1.40"
dependencies = [
"blake3",
"criterion",
@@ -4591,7 +4592,7 @@ dependencies = [
[[package]]
name = "ide-lsp"
version = "0.1.39"
version = "0.1.40"
dependencies = [
"dunce",
"futures",
@@ -4617,7 +4618,7 @@ dependencies = [
[[package]]
name = "ide-mcp"
version = "0.1.39"
version = "0.1.40"
dependencies = [
"bytes",
"dunce",
@@ -4636,7 +4637,7 @@ dependencies = [
[[package]]
name = "ide-notebook"
version = "0.1.39"
version = "0.1.40"
dependencies = [
"futures",
"ide-core",
@@ -4659,7 +4660,7 @@ dependencies = [
[[package]]
name = "ide-plugin"
version = "0.1.39"
version = "0.1.40"
dependencies = [
"blake3",
"dunce",
@@ -4682,7 +4683,7 @@ dependencies = [
[[package]]
name = "ide-profiler"
version = "0.1.39"
version = "0.1.40"
dependencies = [
"ide-core",
"ide-run",
@@ -4695,7 +4696,7 @@ dependencies = [
[[package]]
name = "ide-remote"
version = "0.1.39"
version = "0.1.40"
dependencies = [
"dunce",
"globset",
@@ -4714,7 +4715,7 @@ dependencies = [
[[package]]
name = "ide-run"
version = "0.1.39"
version = "0.1.40"
dependencies = [
"dunce",
"encoding_rs",
@@ -4738,7 +4739,7 @@ dependencies = [
[[package]]
name = "ide-sdk"
version = "0.1.39"
version = "0.1.40"
dependencies = [
"dunce",
"ide-core",
@@ -4746,6 +4747,7 @@ dependencies = [
"ide-run",
"ide-vfs",
"ignore",
"quick-xml 0.42.0",
"serde",
"serde_json",
"specta",
@@ -4757,7 +4759,7 @@ dependencies = [
[[package]]
name = "ide-search"
version = "0.1.39"
version = "0.1.40"
dependencies = [
"criterion",
"globset",
@@ -4777,7 +4779,7 @@ dependencies = [
[[package]]
name = "ide-spell"
version = "0.1.39"
version = "0.1.40"
dependencies = [
"ide-core",
"parking_lot",
@@ -4790,7 +4792,7 @@ dependencies = [
[[package]]
name = "ide-ssh"
version = "0.1.39"
version = "0.1.40"
dependencies = [
"dirs 6.0.0",
"flate2",
@@ -4811,7 +4813,7 @@ dependencies = [
[[package]]
name = "ide-syntax"
version = "0.1.39"
version = "0.1.40"
dependencies = [
"ide-core",
"ide-language",
@@ -4826,7 +4828,7 @@ dependencies = [
[[package]]
name = "ide-terminal"
version = "0.1.39"
version = "0.1.40"
dependencies = [
"ide-core",
"libc",
@@ -4839,7 +4841,7 @@ dependencies = [
[[package]]
name = "ide-text"
version = "0.1.39"
version = "0.1.40"
dependencies = [
"criterion",
"ide-core",
@@ -4852,7 +4854,7 @@ dependencies = [
[[package]]
name = "ide-tools"
version = "0.1.39"
version = "0.1.40"
dependencies = [
"flate2",
"hex",
@@ -4873,7 +4875,7 @@ dependencies = [
[[package]]
name = "ide-vcs"
version = "0.1.39"
version = "0.1.40"
dependencies = [
"blake3",
"dunce",
@@ -4894,7 +4896,7 @@ dependencies = [
[[package]]
name = "ide-vfs"
version = "0.1.39"
version = "0.1.40"
dependencies = [
"chardetng",
"dunce",
@@ -4918,7 +4920,7 @@ dependencies = [
[[package]]
name = "ide-web"
version = "0.1.39"
version = "0.1.40"
dependencies = [
"ide-core",
"open",
@@ -4934,7 +4936,7 @@ dependencies = [
[[package]]
name = "ide-workspace"
version = "0.1.39"
version = "0.1.40"
dependencies = [
"blake3",
"dunce",
@@ -5581,9 +5583,9 @@ dependencies = [
[[package]]
name = "libsqlite3-sys"
version = "0.37.0"
version = "0.39.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1"
checksum = "14a5f01b87b92705d07b433c06f96cbc764637d81a0bffb5734ce17cf0f9a2b3"
dependencies = [
"cc",
"pkg-config",
@@ -6363,6 +6365,15 @@ dependencies = [
"objc2-foundation 0.2.2",
]
[[package]]
name = "objc2-system-configuration"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396"
dependencies = [
"objc2-core-foundation",
]
[[package]]
name = "objc2-ui-kit"
version = "0.2.2"
@@ -6512,7 +6523,7 @@ dependencies = [
[[package]]
name = "openidea"
version = "0.1.39"
version = "0.1.40"
dependencies = [
"base64 0.22.1",
"blake3",
@@ -8629,8 +8640,7 @@ checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620"
[[package]]
name = "sqlx"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "378620ccc25c62c89d8be1c819e76a88d59bdcc3304733330788948e619bfd71"
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
dependencies = [
"sqlx-core",
"sqlx-macros",
@@ -8642,8 +8652,7 @@ dependencies = [
[[package]]
name = "sqlx-core"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb"
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
dependencies = [
"base64 0.22.1",
"bytes",
@@ -8665,7 +8674,7 @@ dependencies = [
"rustls",
"serde",
"serde_json",
"sha2 0.10.9",
"sha2 0.11.1",
"smallvec",
"thiserror 2.0.21",
"tokio",
@@ -8678,8 +8687,7 @@ dependencies = [
[[package]]
name = "sqlx-macros"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd2b84f2bc39a5705ef27ec785a11c934a41bbd4a24941e257927cddc26b60bf"
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
dependencies = [
"proc-macro2",
"quote",
@@ -8691,8 +8699,7 @@ dependencies = [
[[package]]
name = "sqlx-macros-core"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb8d96de5fdc85a5c4ec813432b523ec637e80ba98f046555f75f7908ddac7c3"
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
dependencies = [
"cfg-if",
"dotenvy",
@@ -8703,7 +8710,7 @@ dependencies = [
"quote",
"serde",
"serde_json",
"sha2 0.10.9",
"sha2 0.11.1",
"sqlx-core",
"sqlx-mysql",
"sqlx-postgres",
@@ -8716,8 +8723,7 @@ dependencies = [
[[package]]
name = "sqlx-mysql"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90b8020fe17c5f2c245bfa2505d7ef59c5604839527c740266ad2214acebea27"
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
dependencies = [
"bitflags 2.13.2",
"byteorder",
@@ -8742,8 +8748,7 @@ dependencies = [
[[package]]
name = "sqlx-postgres"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e"
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
dependencies = [
"atoi",
"base64 0.22.1",
@@ -8777,8 +8782,7 @@ dependencies = [
[[package]]
name = "sqlx-sqlite"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c"
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
dependencies = [
"atoi",
"flume",
@@ -9543,7 +9547,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.3",
"getrandom 0.3.4",
"once_cell",
"rustix",
"windows-sys 0.61.2",
@@ -11309,6 +11313,12 @@ name = "whoami"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c"
dependencies = [
"libc",
"libredox",
"objc2-system-configuration",
"web-sys",
]
[[package]]
name = "win_uds"
+6 -3
View File
@@ -5,7 +5,7 @@ members = ["crates/*", "src-tauri"]
exclude = ["sdk", "plugins"]
[workspace.package]
version = "0.1.39"
version = "0.1.40"
edition = "2024"
rust-version = "1.96"
license = "Apache-2.0"
@@ -103,7 +103,8 @@ portable-pty = "0.9"
reqwest = { version = "0.13", default-features = false, features = ["rustls-no-provider", "http2", "charset", "socks", "gzip", "deflate", "brotli"] }
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] }
rustls-platform-verifier = "0.7"
sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio", "tls-rustls-ring-webpki", "sqlite", "postgres", "mysql"] }
# Until SQLx releases its libsqlite3-sys 0.39 update (RUSTSEC-2026-0459).
sqlx = { version = "0.9", git = "https://github.com/transact-rs/sqlx", rev = "10462fa4414c6c7f1ed1d39bb31678e3b726878c", default-features = false, features = ["runtime-tokio", "tls-rustls-ring-webpki", "sqlite", "postgres", "mysql"] }
tiberius = { version = "0.13", default-features = false, features = ["tds73", "rustls", "sql-browser-tokio"] }
oracle = { version = "0.6", default-features = false }
bb8 = "0.9"
@@ -131,7 +132,9 @@ debug = false
[profile.release]
codegen-units = 1
lto = true
# Thin LTO: fat LTO's whole-program link outgrew the 16 GB release runners
# (Linux arm64 was killed mid-link from v0.1.39 on).
lto = "thin"
opt-level = 3
# Unwind, so a panic fails one task or command instead of quitting the IDE.
panic = "unwind"
+11 -1
View File
@@ -69,11 +69,21 @@ the [official plugins](plugins/official). The plugin API is still a preview.
### Contributors
<a href="https://github.com/l0ng-ai/openidea/graphs/contributors">
<img src="https://contrib.rocks/image?repo=l0ng-ai/openidea&max=100" alt="OpenIDEA contributors">
<img src="https://contrib.rocks/image?repo=l0ng-ai/openidea&max=500" alt="OpenIDEA contributors">
</a>
## License
[Apache 2.0](LICENSE). Bundled third-party assets are listed in [`THIRD_PARTY_LICENSES.md`](THIRD_PARTY_LICENSES.md).
Arduino sketches (`.ino`) have C++ syntax highlighting and code intelligence via Arduino
Language Server. For completion, signature help, hover and definition navigation, install
Arduino IDE 2 and the board's core. Configure the server command and arguments in
Settings > Languages & Frameworks > Language Servers, or set tool paths in `settings.toml`
using `languages.arduino.cli`, `languages.arduino.clangd`, `languages.arduino.languageServer`
and `languages.arduino.cliConfig`. OpenIDEA
automatically discovers the IDE's Arduino CLI, clangd and Arduino Language Server on
Windows, macOS and Linux; board FQBN is read from `sketch.yaml` or
`languages.arduino.fqbn` in `settings.toml`.
<sub>Not affiliated with JetBrains. IntelliJ IDEA is a trademark of JetBrains s.r.o.</sub>
+10 -1
View File
@@ -169,8 +169,10 @@ impl Shared {
let commands = config::commands_of(&json!({ "availableCommands": commands }), &spec.id);
let options = config::options_of(&json!({ "configOptions": options }));
let _ = tokio::task::spawn_blocking(move || {
// Notifies subscribers itself when the discovery
// changed the cached config; unchanged results
// stay silent.
shared.remember_discovered(&spec.id, &options, &commands);
shared.emit(AgentEvent::Providers);
})
.await;
}
@@ -253,6 +255,13 @@ impl Shared {
{
tracing::warn!("saving agent settings: {e}");
}
// The cached provider config actually changed (a session report, discovery,
// or a turn update): tell subscribers to re-list, so a changed model list
// reaches the composer without waiting for the next task (#1490). Unchanged
// reports stay silent, so routine session traffic emits nothing. The cache
// lock is released first; the sink never runs under it.
drop(cache);
self.emit(AgentEvent::Providers);
}
}
+43
View File
@@ -2146,6 +2146,49 @@ async fn reload_providers_updates_registry_install_clears_stale_probes_and_notif
assert!(probe.error.is_none());
}
/// A session report that changes the cached provider config notifies subscribers,
/// so the composer re-lists models without waiting for the next task (#1490).
/// Reports that change nothing stay silent.
#[tokio::test(flavor = "multi_thread")]
async fn remembered_config_changes_notify_subscribers_once() {
let tmp = tempfile::tempdir().unwrap();
let mgr = manager(tmp.path(), Arc::new(FakeAgent::default()));
let events = Arc::new(Mutex::new(Vec::new()));
let sink = events.clone();
mgr.subscribe(Arc::new(move |e| sink.lock().push(e)));
events.lock().clear();
let reported = config::options_of(&json!({ "configOptions": [
{ "id": "model", "category": "model", "type": "select", "currentValue": "default",
"options": [{ "value": "default" }, { "value": "custom-opus" }] },
] }));
mgr.shared.remember("claude", Some(&reported), None, Some("default"));
assert!(
events.lock().iter().any(|ev| matches!(ev, AgentEvent::Providers)),
"a first report changing the cache notifies subscribers"
);
events.lock().clear();
// The same report again: the cache is unchanged, so subscribers hear nothing.
mgr.shared.remember("claude", Some(&reported), None, Some("default"));
assert!(events.lock().is_empty(), "an unchanged report must stay silent");
// A changed model list notifies again, and providers() serves the new list.
let updated = config::options_of(&json!({ "configOptions": [
{ "id": "model", "category": "model", "type": "select", "currentValue": "default",
"options": [{ "value": "default" }, { "value": "custom-opus" }, { "value": "custom-sonnet" }] },
] }));
mgr.shared.remember("claude", Some(&updated), None, Some("default"));
assert!(
events.lock().iter().any(|ev| matches!(ev, AgentEvent::Providers)),
"a changed model list notifies subscribers"
);
let claude = mgr.providers().into_iter().find(|p| p.id == "claude").unwrap();
let model = claude.config.iter().find(|o| o.category.as_deref() == Some("model")).unwrap();
let values: Vec<_> = model.values.iter().map(|v| v.value.as_str()).collect();
assert_eq!(values, ["default", "custom-opus", "custom-sonnet"]);
}
#[tokio::test(flavor = "multi_thread")]
async fn idle_agents_stop_and_resume_their_session_on_the_next_message() {
let new = || NewAgentTask {
+3
View File
@@ -11,3 +11,6 @@ license.workspace = true
objc2 = { version = "0.6", default-features = false, features = ["std"] }
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSDictionary", "NSEnumerator", "NSObject", "NSString", "NSURL", "NSValue"] }
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSPasteboard"] }
[target.'cfg(target_os = "linux")'.dependencies]
gtk = "0.18"
+60 -1
View File
@@ -21,6 +21,12 @@
use std::path::PathBuf;
/// Replaces every clipboard flavor with plain text. `owner` is the calling window
/// handle on Windows, ignored elsewhere. Call on the main thread on macOS and Linux.
pub fn set_text(text: &str, owner: usize) -> std::io::Result<()> {
imp::set_text(text, owner)
}
/// Size of `DROPFILES`: `pFiles`, `pt` (two `LONG`s), `fNC`, `fWide`.
const HEADER: usize = 20;
@@ -165,8 +171,12 @@ mod imp {
/// Opens the clipboard as its owner: the list is written in full, so no
/// owning window is needed.
fn open() -> bool {
open_for(ptr::null_mut())
}
fn open_for(owner: Handle) -> bool {
for _ in 0..OPEN_ATTEMPTS {
if unsafe { OpenClipboard(ptr::null_mut()) } != 0 {
if unsafe { OpenClipboard(owner) } != 0 {
return true;
}
std::thread::sleep(OPEN_RETRY);
@@ -230,6 +240,29 @@ mod imp {
}
}
pub fn set_text(text: &str, owner: usize) -> std::io::Result<()> {
let (memory, _) = global_text(text);
if memory.is_null() {
return Err(std::io::Error::last_os_error());
}
// SAFETY: ownership transfers only on success; otherwise we free the allocation.
unsafe {
if !open_for(owner as Handle) {
GlobalFree(memory);
return Err(std::io::Error::last_os_error());
}
let taken = EmptyClipboard() != 0 && !SetClipboardData(CF_UNICODETEXT, memory).is_null();
let error = std::io::Error::last_os_error();
CloseClipboard();
if taken {
Ok(())
} else {
GlobalFree(memory);
Err(error)
}
}
}
pub fn set_files(paths: &[PathBuf]) -> bool {
let bytes = encode(paths);
if bytes.len() <= HEADER {
@@ -340,6 +373,19 @@ mod imp {
})
}
pub fn set_text(text: &str, _owner: usize) -> std::io::Result<()> {
debug_assert!(MainThreadMarker::new().is_some(), "NSPasteboard access requires the main thread");
autoreleasepool(|_| {
let board = NSPasteboard::generalPasteboard();
board.clearContents();
if board.setString_forType(&NSString::from_str(text), unsafe { NSPasteboardTypeString }) {
Ok(())
} else {
Err(std::io::Error::other("Cannot write clipboard text"))
}
})
}
pub fn set_files(paths: &[PathBuf]) -> bool {
debug_assert!(MainThreadMarker::new().is_some(), "NSPasteboard access requires the main thread");
autoreleasepool(|_| {
@@ -370,6 +416,19 @@ mod imp {
mod imp {
use std::path::PathBuf;
pub fn set_text(text: &str, _owner: usize) -> std::io::Result<()> {
#[cfg(target_os = "linux")]
{
gtk::Clipboard::get(&gtk::gdk::SELECTION_CLIPBOARD).set_text(text);
Ok(())
}
#[cfg(not(target_os = "linux"))]
{
let _ = text;
Err(std::io::Error::other("Clipboard text is unavailable on this platform"))
}
}
pub fn has_files() -> bool {
false
}
+6
View File
@@ -148,6 +148,12 @@ pub enum IdeEvent {
SettingsChanged {
key: String,
},
UpdateStatusChanged {
status: crate::updates::UpdateStatus,
},
UpdateAvailable {
update: crate::updates::UpdateInfo,
},
/// The project's source / test / resource / excluded roots changed (build files
/// changed or folders were marked); the UI re-reads them with `project_roots`.
ProjectRootsChanged {
+1
View File
@@ -27,6 +27,7 @@ pub mod settings;
pub mod task;
pub mod text;
pub mod tool_path;
pub mod updates;
pub mod version_managers;
pub mod workflow_settings;
pub mod wsl;
+101
View File
@@ -277,6 +277,10 @@ pub struct CustomKeymap {
pub struct GeneralSettings {
pub reopen_last_project: bool,
pub confirm_exit: bool,
/// When on, the last window closes instead of showing the Welcome screen.
pub close_without_welcome: bool,
/// When on, closing a window drops its saved editor tabs, so they are not restored next time.
pub close_tabs_with_window: bool,
/// Where Open… puts a project while one is open: `ask` | `new` | `same` | `attach`.
pub open_project_in: String,
/// Initial folder of the Open / Clone dialogs; empty = the OS default.
@@ -292,6 +296,7 @@ pub struct GeneralSettings {
pub project_name: String,
/// Where updates come from: `auto` (the CNB mirror for the Chinese UI, else GitHub) | `github` | `cnb`.
pub update_source: String,
pub auto_check_updates: bool,
}
impl Default for GeneralSettings {
@@ -299,6 +304,8 @@ impl Default for GeneralSettings {
Self {
reopen_last_project: true,
confirm_exit: true,
close_without_welcome: false,
close_tabs_with_window: false,
open_project_in: "ask".into(),
default_project_directory: String::new(),
save_on_idle: false,
@@ -307,6 +314,7 @@ impl Default for GeneralSettings {
power_save_mode: false,
project_name: String::new(),
update_source: "auto".into(),
auto_check_updates: true,
}
}
}
@@ -361,6 +369,21 @@ impl Default for NotificationGroupSettings {
}
}
/// Where the Navigation Bar (the file path breadcrumbs) is shown. Mirrors
/// IDEA's `View | Appearance | Navigation Bar` (Top / Status Bar / Don't show).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum NavigationBarPosition {
/// Above the editor, below the main menu / toolbar.
Top,
/// In the footer, next to the status bar widgets — the default, matching
/// where the bar lived before the three-state choice existed.
#[default]
StatusBar,
/// Not shown at all.
Hidden,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct AppearanceSettings {
@@ -409,6 +432,9 @@ pub struct AppearanceSettings {
/// View › Appearance › Toolbar; hidden leaves IDEA's compact window header.
pub show_main_toolbar: bool,
pub show_status_bar: bool,
/// View › Appearance › Navigation Bar: the file path breadcrumbs, shown in
/// the status bar by default, above the editor, or not at all.
pub navigation_bar: NavigationBarPosition,
/// Status bar widgets shown (`true`) or hidden (`false`) by the user
/// (status bar context menu); widgets not listed use their default.
pub status_bar_widgets: BTreeMap<String, bool>,
@@ -481,6 +507,7 @@ impl Default for AppearanceSettings {
widescreen_tool_windows: false,
show_main_toolbar: true,
show_status_bar: true,
navigation_bar: NavigationBarPosition::StatusBar,
status_bar_widgets: BTreeMap::new(),
main_toolbar: None,
}
@@ -518,6 +545,9 @@ pub struct EditorSettings {
/// Color Scheme › Language Defaults › Semantic highlighting: local variables and
/// parameters get a colour derived from their name (IDEA's rainbow identifiers).
pub semantic_highlighting: bool,
/// Docstring markup format whose tags and keywords the Python editor highlights
/// (`plain`, `rest`, `epytext`, `google`, `numpy`).
pub docstring_format: String,
/// Color Scheme › Language Defaults › Rainbow brackets: `()`, `[]`, `{}` coloured by depth.
pub rainbow_brackets: bool,
pub ligatures: bool,
@@ -562,6 +592,7 @@ impl Default for EditorSettings {
color_overrides: BTreeMap::new(),
custom_schemes: BTreeMap::new(),
semantic_highlighting: false,
docstring_format: "rest".into(),
rainbow_brackets: false,
ligatures: false,
fallback_font: String::new(),
@@ -654,6 +685,7 @@ pub struct LanguagesSettings {
pub java: JavaSettings,
pub ruby: RubySettings,
pub markdown: MarkdownSettings,
pub arduino: ArduinoSettings,
}
impl Default for LanguagesSettings {
@@ -675,10 +707,22 @@ impl Default for LanguagesSettings {
java: JavaSettings::default(),
ruby: RubySettings::default(),
markdown: MarkdownSettings::default(),
arduino: ArduinoSettings::default(),
}
}
}
/// Arduino tool discovery paths and default board FQBN, configured in `settings.toml`.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct ArduinoSettings {
pub cli: String,
pub clangd: String,
pub language_server: String,
pub cli_config: String,
pub fqbn: String,
}
/// Languages & Frameworks › Markdown. Like IDEA's, it is application-level only.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
@@ -896,6 +940,8 @@ pub struct JavaSettings {
/// Extra JVM options for the Java language server (jdtls), space-separated
/// (VS Code's `java.jdt.ls.vmargs`).
pub jdtls_vm_args: String,
/// Share one JDT LS across builds in an ordinary project window; other windows remain separate.
pub jdtls_shared: bool,
/// Build Tools › Maven.
pub maven: MavenSettings,
/// Build Tools › Gradle.
@@ -1179,6 +1225,26 @@ impl Settings {
mod tests {
use super::*;
#[test]
fn window_close_settings_default_off_and_round_trip() {
for old in [r#"{}"#, r#"{"general":{"confirmExit":false}}"#] {
let settings: Settings = serde_json::from_str(old).unwrap();
assert!(!settings.general.close_without_welcome);
assert!(!settings.general.close_tabs_with_window);
}
for close_window in [false, true] {
for close_tabs in [false, true] {
let mut settings = Settings::default();
settings.general.close_without_welcome = close_window;
settings.general.close_tabs_with_window = close_tabs;
let json = serde_json::to_value(&settings).unwrap();
assert_eq!(json["general"]["closeWithoutWelcome"], close_window);
assert_eq!(json["general"]["closeTabsWithWindow"], close_tabs);
assert_eq!(serde_json::from_value::<Settings>(json).unwrap().general, settings.general);
}
}
}
#[test]
fn cmake_profile_argument_arrays_round_trip() {
let mut settings = Settings::default();
@@ -1241,6 +1307,21 @@ mod tests {
}
}
#[test]
fn docstring_format_defaults_and_round_trips() {
let old: Settings = serde_json::from_str(r#"{"editor":{"semanticHighlighting":true}}"#).unwrap();
assert_eq!(old.editor.docstring_format, "rest");
assert_eq!(Settings::default().editor.docstring_format, "rest");
for format in ["plain", "rest", "epytext", "google", "numpy"] {
let mut settings = Settings::default();
settings.editor.docstring_format = format.into();
let json = serde_json::to_value(&settings).unwrap();
assert_eq!(json["editor"]["docstringFormat"], format);
let restored: Settings = serde_json::from_value(json).unwrap();
assert_eq!(restored.editor.docstring_format, format);
}
}
#[test]
fn tool_window_badges_default_on_and_round_trip() {
let old: Settings = serde_json::from_str(r#"{"appearance":{"showToolWindowNames":true}}"#).unwrap();
@@ -1477,6 +1558,26 @@ mod maven_linkage_tests {
}
}
#[cfg(test)]
mod java_settings_tests {
use super::*;
#[test]
fn shared_jdtls_defaults_off_and_round_trips() {
for old in [r#"{}"#, r#"{"jdk":"/jdk"}"#] {
let java: JavaSettings = serde_json::from_str(old).unwrap();
assert!(!java.jdtls_shared);
}
for shared in [false, true] {
let java = JavaSettings { jdtls_shared: shared, ..JavaSettings::default() };
let json = serde_json::to_value(&java).unwrap();
assert_eq!(json["jdtlsShared"], serde_json::json!(shared));
assert_eq!(serde_json::from_value::<JavaSettings>(json).unwrap(), java);
}
assert!(serde_json::from_str::<JavaSettings>(r#"{"jdtlsShared":"true"}"#).is_err());
}
}
#[cfg(test)]
mod gradle_settings_tests {
use super::*;
+151
View File
@@ -0,0 +1,151 @@
use serde::{Deserialize, Serialize};
use specta::Type;
use std::collections::HashSet;
use std::time::{Duration, Instant};
pub const CHECK_INTERVAL: Duration = Duration::from_secs(4 * 60 * 60);
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum UpdateSource {
#[default]
Github,
Cnb,
}
impl UpdateSource {
pub fn resolve(preference: &str, locale: &str, system_locale: &str) -> Self {
match preference {
"github" => return Self::Github,
"cnb" => return Self::Cnb,
_ => {}
}
let locale = locale.trim();
let locale = if matches!(locale, "" | "auto" | "system") { system_locale } else { locale };
if locale.split(['-', '_']).next().is_some_and(|base| base.eq_ignore_ascii_case("zh")) {
Self::Cnb
} else {
Self::Github
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct UpdateInfo {
pub version: String,
pub current_version: String,
pub notes: Option<String>,
pub date: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct UpdateCheck {
pub enabled: bool,
pub update: Option<UpdateInfo>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct UpdateStatus {
/// Unix milliseconds of the last successful check in this process.
pub last_checked: Option<f64>,
pub checking: bool,
}
/// One schedule and announcement history for the desktop process, independent of its windows.
#[derive(Default)]
pub struct UpdatePolicy {
attempted: Option<Instant>,
pub status: UpdateStatus,
announced: HashSet<String>,
}
impl UpdatePolicy {
pub fn due_in(&self, now: Instant) -> Duration {
self.attempted.map_or(Duration::ZERO, |at| CHECK_INTERVAL.saturating_sub(now.saturating_duration_since(at)))
}
pub fn begin(&mut self, now: Instant) {
self.attempted = Some(now);
self.status.checking = true;
}
pub fn finish(&mut self, checked_at: Option<f64>) {
self.status.checking = false;
if let Some(at) = checked_at {
self.status.last_checked = Some(at);
}
}
pub fn announced(&self, version: &str) -> bool {
self.announced.contains(version)
}
/// Manual checks always show their result, but also count as an announcement.
pub fn announce(&mut self, version: &str) -> bool {
self.announced.insert(version.to_owned())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn automatic_and_manual_checks_resolve_the_same_preferred_source() {
for (preference, locale, system, expected) in [
("auto", "zh-CN", "en-US", UpdateSource::Cnb),
("auto", " ZH_CN ", "en-US", UpdateSource::Cnb),
("auto", "system", "zh-CN", UpdateSource::Cnb),
("auto", "auto", "zh-TW", UpdateSource::Cnb),
("auto", "", "zh", UpdateSource::Cnb),
("auto", "en", "zh-CN", UpdateSource::Github),
("github", "zh", "zh-CN", UpdateSource::Github),
("cnb", "en", "en-US", UpdateSource::Cnb),
] {
assert_eq!(UpdateSource::resolve(preference, locale, system), expected);
}
}
#[test]
fn checks_at_startup_and_every_four_hours_even_after_failure() {
let mut policy = UpdatePolicy::default();
let now = Instant::now();
assert_eq!(policy.due_in(now), Duration::ZERO);
policy.begin(now);
assert!(policy.status.checking);
policy.finish(None);
assert_eq!(policy.status.last_checked, None);
assert_eq!(policy.due_in(now + CHECK_INTERVAL - Duration::from_secs(1)), Duration::from_secs(1));
assert_eq!(policy.due_in(now + CHECK_INTERVAL), Duration::ZERO);
policy.begin(now + CHECK_INTERVAL);
policy.finish(Some(1234.0));
assert_eq!(policy.status.last_checked, Some(1234.0));
assert!(!policy.status.checking);
assert_eq!(policy.due_in(now + CHECK_INTERVAL), CHECK_INTERVAL);
}
#[test]
fn manual_checks_reset_the_schedule_without_losing_last_success_on_failure() {
let mut policy = UpdatePolicy::default();
let now = Instant::now();
policy.begin(now);
policy.finish(Some(1000.0));
policy.begin(now + Duration::from_secs(60));
policy.finish(None);
assert_eq!(policy.status.last_checked, Some(1000.0));
assert_eq!(policy.due_in(now + CHECK_INTERVAL), Duration::from_secs(60));
}
#[test]
fn announcements_are_per_version_across_windows_sources_and_manual_checks() {
let mut policy = UpdatePolicy::default();
assert!(policy.announce("0.1.40"));
assert!(!policy.announce("0.1.40"));
assert!(policy.announce("0.1.41"));
assert!(!policy.announce("0.1.40"));
assert!(UpdatePolicy::default().announce("0.1.40"));
}
}
+1 -1
View File
@@ -92,7 +92,7 @@ pub struct SequenceInfo {
pub data_type: String,
}
/// A schema's routines and sequences (the tree's `routines` / `sequences` folders).
/// A schema's routines (procedures and functions) and sequences.
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
#[serde(rename = "DbSchemaObjects")]
+1 -1
View File
@@ -32,7 +32,7 @@ use std::path::{Component, Path, PathBuf};
pub const MANIFEST_FILE: &str = "plugin.toml";
/// The plugin API (engine) version this host implements.
pub const ENGINE_VERSION: &str = "1.4.0";
pub const ENGINE_VERSION: &str = "1.5.0";
/// The 0.1 API, still accepted for contribution-only packages and 0.1 components.
pub const LEGACY_API: &str = "0.1";
/// Marketplace categories, in the order they are shown.
+2 -2
View File
@@ -627,8 +627,8 @@ mod tests {
#[test]
fn finds_remote_by_repo() {
let remotes = vec![
ide_vcs::Remote { name: "origin".into(), url: "[email protected]:me/App.git".into() },
ide_vcs::Remote { name: "upstream".into(), url: "https://github.com/org/app/".into() },
ide_vcs::Remote { name: "origin".into(), url: "[email protected]:me/App.git".into(), resolved_url: None },
ide_vcs::Remote { name: "upstream".into(), url: "https://github.com/org/app/".into(), resolved_url: None },
];
assert_eq!(remote_for(&remotes, "me/app").map(|r| r.name.as_str()), Some("origin"));
assert_eq!(remote_for(&remotes, "org/app").map(|r| r.name.as_str()), Some("upstream"));
+79
View File
@@ -1,9 +1,39 @@
//! Storage policy and best-effort migration of rebuildable desktop data.
use std::ffi::OsString;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
/// Moves the rebuildable local data (tools, language servers, indexes, caches);
/// configuration and roaming data keep following the OS.
pub const LOCAL_DATA_ENV: &str = "OPENIDEA_LOCAL_DATA_DIR";
/// `$OPENIDEA_LOCAL_DATA_DIR/<identifier>` when set, else the OS local data directory
/// (Tauri's `app_local_data_dir`). The identifier keeps release and per-worktree
/// debug builds apart under one root. A set but unusable path is an error, never a
/// silent fallback to the OS location.
pub fn local_data_dir(identifier: &str) -> Result<PathBuf, String> {
resolve_local(std::env::var_os(LOCAL_DATA_ENV), dirs::data_local_dir(), identifier)
}
fn resolve_local(var: Option<OsString>, os: Option<PathBuf>, identifier: &str) -> Result<PathBuf, String> {
let Some(root) = var.filter(|v| !v.is_empty()).map(PathBuf::from) else {
return os.map(|dir| dir.join(identifier)).ok_or_else(|| "cannot find the local data directory".into());
};
// The working directory depends on how the IDE was launched.
if !root.is_absolute() {
return Err(format!("{LOCAL_DATA_ENV} must be an absolute path: {}", root.display()));
}
let dir = root.join(identifier);
let probe = dir.join(".openidea-write-check");
fs::create_dir_all(&dir)
.and_then(|()| fs::write(&probe, b""))
.and_then(|()| fs::remove_file(&probe))
.map_err(|error| format!("{LOCAL_DATA_ENV}: cannot write to {}: {error}", dir.display()))?;
Ok(dir)
}
/// Unknown entries and recovery/user content deliberately stay in roaming data.
pub fn is_local(entry: &Path) -> bool {
["tools", "language-server-runtimes", "index", "cache", "lsp", "notebook-runtime", "server", "logs"]
@@ -276,6 +306,55 @@ fn relative_wrapper(text: &str, source: &Path) -> String {
mod tests {
use super::*;
#[test]
fn local_dir_follows_the_os_unless_the_variable_is_set() {
let os = Some(PathBuf::from("/os-local"));
for var in [None, Some(OsString::new())] {
assert_eq!(
resolve_local(var, os.clone(), "ai.l0ng.openidea").unwrap(),
Path::new("/os-local/ai.l0ng.openidea")
);
}
assert!(resolve_local(None, None, "id").is_err());
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("missing/nested");
let dir = resolve_local(Some(root.clone().into()), os.clone(), "ai.l0ng.openidea").unwrap();
assert_eq!(dir, root.join("ai.l0ng.openidea"));
assert!(dir.is_dir());
assert_eq!(fs::read_dir(&dir).unwrap().count(), 0);
// Debug worktrees have their own identifiers and stay apart under one root.
assert_ne!(resolve_local(Some(root.clone().into()), None, "dev-a").unwrap(), dir);
}
#[test]
fn local_dir_rejects_relative_and_unwritable_paths() {
for relative in ["local", "./local", "../local"] {
let error = resolve_local(Some(relative.into()), Some("/os".into()), "id").unwrap_err();
assert!(error.contains(LOCAL_DATA_ENV) && error.contains("absolute"), "{error}");
}
let tmp = tempfile::tempdir().unwrap();
let blocker = tmp.path().join("file");
fs::write(&blocker, "").unwrap();
let error = resolve_local(Some(blocker.into()), Some("/os".into()), "id").unwrap_err();
assert!(error.contains("cannot write"), "{error}");
}
#[cfg(unix)]
#[test]
fn local_dir_rejects_an_existing_read_only_directory() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("id");
fs::create_dir(&dir).unwrap();
fs::set_permissions(&dir, fs::Permissions::from_mode(0o555)).unwrap();
// Root ignores permission bits.
if fs::write(dir.join("probe"), "").is_err() {
assert!(resolve_local(Some(tmp.path().into()), None, "id").unwrap_err().contains("cannot write"));
}
fs::set_permissions(&dir, fs::Permissions::from_mode(0o755)).unwrap();
}
#[test]
fn wrappers_accept_mixed_separators_without_rewriting_neighbor_paths() {
let text = "@echo off\r\n\"C:\\old\\tools/demo\\payload.exe\" \"C:\\old\\tools/demo-other\\x\" %*\r\n";
+32
View File
@@ -28,6 +28,8 @@ pub struct Reports(RwLock<State>);
struct State {
windows: HashMap<String, Window>,
focused: Option<String>,
/// Windows in the order they were last focused, the most recent last.
focus_order: Vec<String>,
remote: HashMap<String, PathBuf>,
libraries: HashMap<String, Vec<PathBuf>>,
}
@@ -46,8 +48,16 @@ impl Reports {
state.windows.entry(window.into()).or_default().focused = editor;
if focused {
state.focused = Some(window.into());
state.focus_order.retain(|w| w != window);
state.focus_order.push(window.into());
}
}
/// Sorts `windows` by when they were last focused, the most recent first; windows
/// never focused keep their order after them.
pub(crate) fn most_recently_focused_first(&self, windows: &mut [String]) {
let order = &self.0.read().focus_order;
windows.sort_by_key(|w| std::cmp::Reverse(order.iter().position(|o| o == w)));
}
pub fn report_problems(&self, window: &str, reset: bool, files: Vec<FileProblems>) {
let mut state = self.0.write();
let problems = &mut state.windows.entry(window.into()).or_default().problems;
@@ -68,6 +78,7 @@ impl Reports {
state.windows.remove(window);
state.remote.remove(window);
state.libraries.remove(window);
state.focus_order.retain(|w| w != window);
if state.focused.as_deref() == Some(window) {
state.focused = None;
}
@@ -122,3 +133,24 @@ impl Reports {
files.into_iter().map(|(path, items)| FileProblems { path: path.into(), items }).collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn windows_sort_by_their_last_focus() {
let reports = Reports::default();
for window in ["a", "b", "a", "c"] {
reports.set_focused_editor(window, None, true);
}
reports.set_focused_editor("b", None, false);
let mut windows: Vec<String> = ["a", "b", "c", "never"].map(String::from).into();
reports.most_recently_focused_first(&mut windows);
assert_eq!(windows, ["c", "a", "b", "never"]);
reports.forget_window("c");
let mut windows: Vec<String> = ["never", "c", "b", "a"].map(String::from).into();
reports.most_recently_focused_first(&mut windows);
assert_eq!(windows, ["a", "b", "never", "c"]);
}
}
+2 -1
View File
@@ -552,7 +552,8 @@ pub async fn build_run(
let mut combined: Option<compile::BuildPlan> = None;
for member in workspace.projects.iter().filter(|p| file.as_ref().is_none_or(|f| f.starts_with(p))) {
let settings = ide_workspace::SettingsStore::load(&config_dir, Some(member)).effective();
let member_cache = cache.join(crate::project::project_key(member));
let member_cache =
if member == &root { cache.clone() } else { cache.join(crate::project::project_key(member)) };
let model = with_profiles(load(member, &member_cache, &settings), &member_cache);
let plan = prepare_plan(
compile::plan(member, &model, kind, file.as_deref(), settings.cmake.profiles.as_deref())?,
+35 -4
View File
@@ -12,13 +12,18 @@ pub(super) fn scopes(project: &Project, config: &Path) -> IdeResult<Vec<Scope>>
}
fn scopes_at(root: &Path, cache: &Path, config: &Path, settings: Settings) -> IdeResult<Vec<Scope>> {
let window_root = root;
Ok(match ide_workspace::workspace::Workspace::load(root)? {
Some(workspace) => workspace
.projects
.into_iter()
.filter(|p| p.is_dir())
.map(|root| Scope {
cache: cache.join(crate::project::project_key(&root)),
cache: if root == window_root {
cache.to_owned()
} else {
cache.join(crate::project::project_key(&root))
},
settings: ide_workspace::SettingsStore::load(config, Some(&root)).effective(),
root,
})
@@ -36,10 +41,12 @@ pub(super) fn load_scopes(root: &Path, scopes: &[Scope]) -> BuildModel {
let mut combined = BuildModel::default();
for scope in scopes {
let mut model = load(&scope.root, &scope.cache, &scope.settings);
if scope.root == root {
if scopes.len() == 1 && scope.root == root {
return model;
}
if let Some(maven) = &mut model.maven {
if scope.root != root
&& let Some(maven) = &mut model.maven
{
let owner = ide_core::ipc_path::to_ipc(&scope.root);
for p in &mut maven.projects {
p.project_root = Some(owner.clone());
@@ -73,7 +80,7 @@ pub(super) fn load_scopes(root: &Path, scopes: &[Scope]) -> BuildModel {
}
}
}
for g in &mut model.gradle {
for g in model.gradle.iter_mut().filter(|_| scope.root != root) {
g.dir = absolute(&scope.root, &g.dir);
for p in &mut g.projects {
p.dir = absolute(&scope.root, &p.dir);
@@ -145,6 +152,30 @@ pub(super) fn update_files(root: &Path, scopes: &[Scope], model: &BuildModel, to
mod tests {
use super::*;
#[test]
fn attached_primary_keeps_its_cache_and_combines_build_models() {
let tmp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(tmp.path()).unwrap();
let root = base.join("primary");
let other = base.join("other");
for path in [&root, &other] {
std::fs::create_dir(path).unwrap();
std::fs::write(path.join("pom.xml"), "<project><artifactId>app</artifactId></project>").unwrap();
}
let cache = base.join("cache");
ide_build::save_maven_profiles(&cache, &["kept".into()]).unwrap();
ide_workspace::workspace::Workspace::attach(&root, "Primary", vec![root.clone(), other.clone()]).unwrap();
let scopes = scopes_at(&root, &cache, &base.join("config"), Settings::default()).unwrap();
assert_eq!(scopes[0].cache, cache);
assert_eq!(ide_build::maven_profiles(&scopes[0].cache), ["kept"]);
assert_eq!(scopes[1].cache, cache.join(crate::project::project_key(&other)));
let model = load_scopes(&root, &scopes);
let projects = model.maven.unwrap().projects;
assert_eq!(projects.len(), 2);
assert_eq!(projects[0].pom, "pom.xml");
assert_eq!(projects[1].pom, absolute(&other, "pom.xml"));
}
#[test]
fn workspace_models_use_member_settings_paths_and_caches() {
let temp = tempfile::tempdir().unwrap();
+1 -1
View File
@@ -82,7 +82,7 @@ fn go_source(project: &crate::Project, config_dir: &Path, path: &Path) -> IdeRes
workspace
.projects
.into_iter()
.filter(|root| path.starts_with(root))
.filter(|root| **root != project.root && path.starts_with(root))
.max_by_key(|root| root.components().count())
});
let settings = match &member {
+3
View File
@@ -197,6 +197,7 @@ fn plugin_completion(item: ide_plugin::CompletionItem) -> CompletionItem {
CompletionItem {
label: item.label,
label_detail: None,
label_description: None,
detail: item.detail,
kind,
documentation: item.documentation.map(|value| Markup { kind: "markdown".into(), value }),
@@ -822,6 +823,8 @@ fn plugin_code_action(plugin: &str, root: &Path, a: ide_plugin::CodeAction) -> C
.and_then(|r| serde_json::to_value(r).ok());
CodeAction {
title: a.title,
diagnostics: Vec::new(),
original_diagnostics: None,
kind: a.kind,
is_preferred: a.preferred,
disabled_reason,
+6
View File
@@ -41,6 +41,11 @@ pub fn find_files(host: &Host, window: &str, query: String, limit: u32) -> IdeRe
Ok(host.project(window)?.index.find_files(&query, limit as usize))
}
pub async fn find_files_named(host: &Host, window: &str, name: String) -> IdeResult<Vec<IpcPath>> {
let project = host.project(window)?;
blocking(move || Ok(project.index.files_named(&name).into_iter().map(Into::into).collect())).await
}
pub fn find_dirs(host: &Host, window: &str, query: String, limit: u32) -> IdeResult<Vec<FileMatch>> {
Ok(host.project(window)?.index.find_dirs(&query, limit as usize))
}
@@ -167,6 +172,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("index_status", |c| async move { index_status(&c.host, &c.window) });
d.add("index_file_extensions", |c| async move { index_file_extensions(&c.host, &c.window) });
d.add("find_files", |c| async move { find_files(&c.host, &c.window, c.arg("query")?, c.arg("limit")?) });
d.add("find_files_named", |c| async move { find_files_named(&c.host, &c.window, c.arg("name")?).await });
d.add("find_dirs", |c| async move { find_dirs(&c.host, &c.window, c.arg("query")?, c.arg("limit")?) });
d.add("find_symbols", |c| async move {
find_symbols(&c.host, &c.window, c.arg("query")?, c.arg("filter")?, c.arg("limit")?)
+61 -20
View File
@@ -13,6 +13,8 @@ pub struct ProjectInfo {
pub trusted: bool,
pub root: ide_core::IpcPath,
pub name: String,
#[serde(default)]
pub display_name: String,
pub is_git: bool,
pub settings: Settings,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -24,17 +26,23 @@ pub struct ProjectInfo {
pub fn info(project: &Project) -> ProjectInfo {
let settings = project.settings.effective();
let custom = settings.general.project_name.trim();
let primary = if custom.is_empty() {
project.root.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default()
} else {
custom.to_owned()
};
let workspace = ide_workspace::workspace::Workspace::load(&project.root).ok().flatten();
ProjectInfo {
trusted: project.is_trusted(),
name: if custom.is_empty() {
project.root.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default()
} else {
custom.to_owned()
},
display_name: workspace
.as_ref()
.map(|w| w.display_name(&project.root, &primary))
.unwrap_or_else(|| primary.clone()),
name: workspace.as_ref().filter(|w| w.is_container(&project.root)).map(|w| w.name.clone()).unwrap_or(primary),
is_git: !project.repos.read().is_empty(),
settings,
root: project.root.clone().into(),
workspace: ide_workspace::workspace::Workspace::load(&project.root).ok().flatten(),
workspace,
unity: ide_workspace::unity::detect(&project.root),
}
}
@@ -63,38 +71,71 @@ pub fn roots(host: &Host, window: &str) -> IdeResult<ide_workspace::ProjectRoots
pub fn save_workspace(host: &Host, window: &str, name: &str, paths: Vec<std::path::PathBuf>) -> IdeResult<ProjectInfo> {
let project = host.project(window)?;
ide_workspace::workspace::Workspace::save(&project.root, name, paths, false)?;
let previous = ide_workspace::workspace::Workspace::load(&project.root)?
.ok_or_else(|| ide_core::IdeError::invalid("This folder is not a workspace"))?;
for path in paths.iter().filter(|p| !previous.projects.contains(p)) {
require_attachment_trust(host, path)?;
}
if !previous.is_container(&project.root) && paths.first() != Some(&project.root) {
return Err(ide_core::IdeError::invalid("The primary project cannot be detached"));
}
if !previous.is_container(&project.root) && paths == [project.root.clone()] {
std::fs::remove_file(project.root.join(ide_workspace::workspace::FILE))?;
} else {
let workspace = ide_workspace::workspace::Workspace::save(&project.root, name, paths, false)?;
keep_attachment_private(&project.root, &workspace)?;
}
refresh_workspace(&project);
Ok(info(&project))
}
fn require_attachment_trust(host: &Host, path: &Path) -> IdeResult<()> {
if !host.trust.trusted(path)? {
return Err(ide_core::IdeError::invalid("Trust the project before attaching it"));
}
Ok(())
}
fn keep_attachment_private(root: &Path, workspace: &ide_workspace::workspace::Workspace) -> IdeResult<()> {
if !workspace.is_container(root) {
ide_vcs::changelists::ensure_ignored(&root.join(".openidea"), &["/workspace.json"])?;
}
Ok(())
}
fn refresh_workspace(project: &Project) {
project.settings.reload();
project.refresh_index_scope();
project.refresh_repositories();
project.sink.emit(ide_core::IdeEvent::ProjectRootsChanged { root: project.root.clone().into() });
Ok(info(&project))
}
/// Links a folder, or prepares an automatic workspace for the caller to open in place.
/// Attaches a trusted folder without replacing the current project.
pub fn attach_workspace(host: &Host, window: &str, path: &Path) -> IdeResult<ide_core::IpcPath> {
let project = host.project(window)?;
let path = dunce::canonicalize(path)?;
if project.light {
return Err(ide_core::IdeError::invalid("Open a project before attaching a folder"));
}
if path == project.root {
return Ok(project.root.clone().into());
}
require_attachment_trust(host, &path)?;
if let Some(mut workspace) = ide_workspace::workspace::Workspace::load(&project.root)? {
if !workspace.projects.contains(&path) {
workspace.projects.push(path);
save_workspace(host, window, &workspace.name, workspace.projects)?;
}
return Ok(project.root.clone().into());
} else {
let workspace = ide_workspace::workspace::Workspace::attach(
&project.root,
&info(&project).name,
vec![project.root.clone(), path],
)?;
keep_attachment_private(&project.root, &workspace)?;
refresh_workspace(&project);
}
if path == project.root {
return Ok(project.root.clone().into());
}
Ok(ide_workspace::workspace::Workspace::create_for_project(
&host.data_dir,
&project.root,
&info(&project).name,
&path,
)?
.into())
Ok(project.root.clone().into())
}
/// What [`set_settings`] changed; the caller tells the windows.
+11 -10
View File
@@ -24,7 +24,7 @@ pub(super) fn project_configs(
let root_suppressed = ide_run::load_suppressed_config_ids(root)?;
let mut all = detected_and_saved(root, settings, &user, &root_suppressed);
if let Some(workspace) = ide_workspace::workspace::Workspace::load(root)? {
for member in workspace.projects.into_iter().filter(|p| p.is_dir()) {
for member in workspace.projects.into_iter().filter(|p| p != root && p.is_dir()) {
let settings = ide_workspace::SettingsStore::load(config_dir, Some(&member)).effective();
let saved = ide_run::load_user_configs(&member)?;
let suppressed = ide_run::load_suppressed_config_ids(&member)?;
@@ -145,20 +145,21 @@ fn settings_for(
config: &mut RunConfig,
) -> IdeResult<(std::path::PathBuf, ide_core::settings::Settings)> {
let workspace = ide_workspace::workspace::Workspace::load(&project.root)?;
if workspace.is_some() {
let owner = ide_run::scope::owner(&project.root, config);
let member = workspace.as_ref().and_then(|workspace| {
workspace
.projects
.iter()
.filter(|root| **root != project.root && owner.starts_with(root))
.max_by_key(|root| root.components().count())
.cloned()
});
if workspace.as_ref().is_some_and(|w| w.is_container(&project.root)) || member.is_some() {
if config.extras.module.is_none() && !matches!(config.kind.as_str(), "application" | "spring-boot") {
config.extras.module = config.cwd.as_deref().map(ide_core::ipc_path::to_ipc);
}
ide_run::scope::rebase(config, &project.root, None);
}
let member = workspace.and_then(|workspace| {
let owner = ide_run::scope::owner(&project.root, config);
workspace
.projects
.into_iter()
.filter(|root| owner.starts_with(root))
.max_by_key(|root| root.components().count())
});
Ok(match member {
Some(root) => {
let settings = ide_workspace::SettingsStore::load(&host.config_dir, Some(&root)).effective();
+1 -1
View File
@@ -61,7 +61,7 @@ pub(crate) fn search_project(
ide_search::SearchScope::FileSet { set } => Some(set),
_ => None,
};
let workspace = ide_workspace::workspace::Workspace::load(root)?.is_some();
let workspace = ide_workspace::workspace::Workspace::load(root)?.is_some_and(|w| w.is_container(root));
let include_root = !workspace
|| matches!(query.scope, ide_search::SearchScope::Directory { .. } | ide_search::SearchScope::Files { .. });
let content_roots = match query.scope {
+2
View File
@@ -24,8 +24,10 @@ fn tools(host: &Host, window: &str) -> IdeResult<Tools> {
.collect();
// Godot is usually an app bundle / Flatpak outside the search path.
let found = ide_sdk::godot::find(None).map(|p| (ide_sdk::godot::COMMAND.to_owned(), p));
let runtime = ide_sdk::apply::tool_install_environment(&root, &settings);
let work_dir = (!root.as_os_str().is_empty()).then_some(root);
Ok(Tools::new(&data)
.with_runtime(runtime.commands, runtime.env)
.with_custom_paths(custom)
.with_found_paths(found.into_iter().collect())
.with_work_dir(work_dir))
+15 -3
View File
@@ -673,10 +673,11 @@ pub async fn vcs_rename_branch(
repo: Option<PathBuf>,
from: String,
to: String,
unset_upstream: bool,
) -> IdeResult<()> {
host.require_trust(window)?;
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.rename_branch(&from, &to))).await
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.rename_branch(&from, &to, unset_upstream))).await
}
pub async fn vcs_merge(host: &Host, window: &str, repo: Option<PathBuf>, branch: String) -> IdeResult<()> {
@@ -995,7 +996,17 @@ pub async fn vcs_rebase_with(
pub async fn vcs_remotes(host: &Host, window: &str, repo: Option<PathBuf>) -> IdeResult<Vec<Remote>> {
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.remotes())).await
blocking(move || {
let mut remotes = project.with_log_repo_at(repo.as_deref(), |r| r.remotes())?;
for r in &mut remotes {
let resolved = ide_ssh::resolve_ssh_remote_url(&r.url);
if resolved != r.url {
r.resolved_url = Some(resolved);
}
}
Ok(remotes)
})
.await
}
pub async fn vcs_edit_remote(host: &Host, window: &str, repo: Option<PathBuf>, edit: RemoteEdit) -> IdeResult<()> {
@@ -1379,7 +1390,8 @@ pub(crate) fn register(d: &mut Dispatcher) {
vcs_restore_branch(&c.host, &c.window, c.arg("repo")?, c.arg("deleted")?).await
});
d.add("vcs_rename_branch", |c| async move {
vcs_rename_branch(&c.host, &c.window, c.arg("repo")?, c.arg("from")?, c.arg("to")?).await
vcs_rename_branch(&c.host, &c.window, c.arg("repo")?, c.arg("from")?, c.arg("to")?, c.arg("unset_upstream")?)
.await
});
d.add("vcs_merge", |c| async move { vcs_merge(&c.host, &c.window, c.arg("repo")?, c.arg("branch")?).await });
d.add("vcs_rebase", |c| async move { vcs_rebase(&c.host, &c.window, c.arg("repo")?, c.arg("onto")?).await });
+40 -6
View File
@@ -110,6 +110,10 @@ pub trait PluginWorkspace: Send + Sync {
fn trusted(&self, root: &Path) -> bool;
/// Root of the project open in `window`.
fn project_of(&self, window: &str) -> Option<PathBuf>;
/// Windows showing the open project at `root`, the most recently focused first.
fn project_windows(&self, _root: &Path) -> Vec<String> {
Vec::new()
}
/// Shows `items` as producer `source`'s diagnostics of `path` (replacing earlier ones).
fn publish_diagnostics(&self, source: &str, path: &Path, items: Vec<ide_core::Diagnostic>)
-> Result<(), HostError>;
@@ -1758,17 +1762,31 @@ impl HostApi for PluginApi {
body: String,
actions: Vec<ide_plugin::NotificationAction>,
) -> Result<(), HostError> {
if !actions.is_empty() {
let commands = self.shared.ui.items("commands", Some(self.id()));
if let Some(a) = actions.iter().find(|a| !commands.iter().any(|c| c.key == a.command)) {
return Err(HostError::NotFound(format!("the plugin declares no command {:?}", a.command)));
}
}
check_actions(self, &actions)?;
let plugin_id = self.id().to_owned();
self.shared.sink.emit(client, PluginEvent::Notification { plugin_id, level, title, body, actions });
Ok(())
}
fn notify_project(
&self,
root: &Path,
level: NotificationLevel,
title: String,
body: String,
actions: Vec<ide_plugin::NotificationAction>,
) -> Result<(), HostError> {
check_actions(self, &actions)?;
let windows = self.shared.workspace().map(|w| w.project_windows(root)).unwrap_or_default();
let window = windows
.into_iter()
.find(|w| !self.shared.client_closed(w))
.ok_or_else(|| HostError::NotFound(format!("the project {} is not open", root.display())))?;
let plugin_id = self.id().to_owned();
self.shared.sink.emit(Some(&window), PluginEvent::Notification { plugin_id, level, title, body, actions });
Ok(())
}
fn copy_to_clipboard(&self, client: Option<&str>, text: String) -> Result<(), HostError> {
let client = from_window(client, "copy to the clipboard")?;
self.shared.sink.emit(Some(client), PluginEvent::CopyToClipboard { plugin_id: self.id().to_owned(), text });
@@ -2718,6 +2736,15 @@ fn not_writable(path: &Path, e: ide_core::error::IdeError) -> HostError {
}
}
/// Notification buttons must name commands the plugin declares.
fn check_actions(api: &PluginApi, actions: &[ide_plugin::NotificationAction]) -> Result<(), HostError> {
let commands = if actions.is_empty() { Vec::new() } else { api.shared.ui.items("commands", Some(api.id())) };
match actions.iter().find(|a| !commands.iter().any(|c| c.key == a.command)) {
Some(a) => Err(HostError::NotFound(format!("the plugin declares no command {:?}", a.command))),
None => Ok(()),
}
}
/// [`PluginWorkspace`] over the desktop host's open projects.
pub struct HostWorkspace(pub std::sync::Weak<crate::Host>);
@@ -3016,6 +3043,13 @@ impl PluginWorkspace for HostWorkspace {
self.0.upgrade()?.project(window).ok().map(|p| p.root.clone())
}
fn project_windows(&self, root: &Path) -> Vec<String> {
let Some(host) = self.0.upgrade() else { return Vec::new() };
let mut windows = host.windows_for_root(root);
host.mcp_reports.most_recently_focused_first(&mut windows);
windows
}
fn publish_diagnostics(
&self,
source: &str,
+47 -1
View File
@@ -78,6 +78,25 @@ pub fn read_source(path: &Path) -> IdeResult<IntellijThemeSource> {
/// The theme plugins of the JetBrains IDEs (and Android Studio) of this user,
/// the themes their IDEs are set to first.
pub fn installed_themes() -> Vec<InstalledIntellijTheme> {
find_installed(&local_ide_dirs())
}
/// The settings folder of a JetBrains IDE (or Android Studio) of this user.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct InstalledIdeSettings {
/// The IDE and its version: `IntelliJ IDEA 2025.2`.
pub ide: String,
/// What Import Settings reads.
pub path: IpcPath,
}
/// The settings folders of the JetBrains IDEs of this user, newest first, one per product.
pub fn installed_ide_settings() -> Vec<InstalledIdeSettings> {
settings_folders(&local_ide_dirs())
}
fn local_ide_dirs() -> Vec<IdeDir> {
let Some(home) = dirs::home_dir() else { return Vec::new() };
let layout = if cfg!(target_os = "macos") {
Layout::Mac
@@ -86,7 +105,15 @@ pub fn installed_themes() -> Vec<InstalledIntellijTheme> {
} else {
Layout::Xdg
};
find_installed(&ide_dirs(layout, &home, |key| std::env::var_os(key)))
ide_dirs(layout, &home, |key| std::env::var_os(key))
}
/// An IDE that was installed but never run has no `options/` yet: nothing to import.
fn settings_folders(ides: &[IdeDir]) -> Vec<InstalledIdeSettings> {
ides.iter()
.filter(|ide| ide.config.join("options").is_dir())
.map(|ide| InstalledIdeSettings { ide: ide.label.clone(), path: ide.config.clone().into() })
.collect()
}
fn read(path: &Path) -> Result<IntellijThemeSource, String> {
@@ -881,4 +908,23 @@ mod tests {
assert_eq!(found, [theme("shallow-id", "Shallow", false, true), theme("deep-sea-id", "Deep Sea", true, false)]);
assert!(find_installed(&[]).is_empty());
}
#[test]
fn lists_the_settings_folders_of_ides_that_have_been_run() {
let tmp = tempfile::tempdir().unwrap();
let ide = |label: &str, product: &str, version: (u32, u32)| {
let config = tmp.path().join(format!("{product}{}.{}", version.0, version.1));
IdeDir { label: label.into(), product: product.into(), version, config: config.clone(), plugins: config }
};
let used = ide("IntelliJ IDEA 2025.2", "IntelliJIdea", (2025, 2));
let fresh = ide("PyCharm 2025.1", "PyCharm", (2025, 1));
let used_config = used.config.clone();
write(&used_config.join("options/editor.xml"), b"<application/>");
std::fs::create_dir_all(&fresh.config).unwrap();
assert_eq!(
settings_folders(&[used, fresh]),
[InstalledIdeSettings { ide: "IntelliJ IDEA 2025.2".into(), path: used_config.into() }]
);
assert!(settings_folders(&[]).is_empty());
}
}
+34 -8
View File
@@ -83,7 +83,7 @@ impl Host {
let opening = self.opening(window);
let _opening = opening.lock();
// Reject a damaged manifest before replacing the currently open project.
ide_workspace::workspace::Workspace::load(&root)?;
let is_workspace = ide_workspace::workspace::Workspace::load(&root)?.is_some();
// The window reloaded (or asked again) for the project it already has: keep
// it, with its warm index, language servers and unsaved documents.
if let Ok(current) = self.project(window)
@@ -96,7 +96,7 @@ impl Host {
self.wait_detached_closes();
let sink: SharedSink = (self.sink_for)(window);
// Before anything the project starts: its children get the project's environment.
let trusted = Arc::new(AtomicBool::new(self.trust.trusted(&root)?));
let trusted = Arc::new(AtomicBool::new(self.trust.window_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));
@@ -137,6 +137,16 @@ impl Host {
lsp.set_execution_allowed(trusted.load(Ordering::Acquire));
lsp.set_content_roots(extra_roots.clone());
let settings = Arc::new(settings);
{
let settings = settings.clone();
// Workspace members have independent settings; target servers have their own path mappings.
lsp.set_jdtls_shared_hook(Box::new(move || {
let effective = settings.effective();
!is_workspace
&& effective.languages.java.jdtls_shared
&& effective.language_server_targets.get("java").is_none_or(|target| target.target.is_empty())
}));
}
{
let settings = settings.clone();
let root = root.clone();
@@ -188,14 +198,15 @@ impl Host {
.as_ref()
.map(|p| SettingsStore::load(&config_dir, Some(p)).effective())
.unwrap_or_else(|| settings.effective());
let root = member.as_ref().unwrap_or(&root);
let window_root = &root;
let root = member.as_ref().unwrap_or(window_root);
ide_sdk::apply::server_launch_overrides(cfg, &effective);
ide_sdk::apply::configure_server(cfg, root, server_root, &effective);
if cfg.name == "jdtls" {
// A workspace member keeps its build model under its own key (Build Project).
let cache = match &member {
Some(member) => build_cache.join(crate::project::project_key(member)),
None => build_cache.clone(),
Some(member) if member != window_root => build_cache.join(crate::project::project_key(member)),
_ => build_cache.clone(),
};
let profiles = ide_build::maven_profiles(&cache);
ide_sdk::maven_profiles::configure_jdtls(cfg, root, &effective, &profiles, &cache);
@@ -1214,17 +1225,32 @@ pub fn history_retention(settings: &ide_core::settings::Settings) -> ide_workspa
}
}
/// A workspace uses its root only for state, never as indexed content.
/// Only explicit workspace containers exclude their root from indexed content.
fn index_roots(root: &Path, folders: Vec<PathBuf>) -> ide_index::IndexRoots {
ide_index::IndexRoots { include_root: !root.join(ide_workspace::workspace::FILE).is_file(), folders }
let container =
ide_workspace::workspace::Workspace::load(root).ok().flatten().is_some_and(|w| w.is_container(root));
ide_index::IndexRoots { include_root: !container, folders: folders.into_iter().filter(|p| p != root).collect() }
}
/// Workspace members in manifest order, or extra Project Structure directories and the
/// Gradle project folders outside the project as of the last sync (`build_cache`).
fn content_roots(settings: &ide_core::settings::Settings, root: &Path, build_cache: &Path) -> Vec<PathBuf> {
if let Ok(Some(workspace)) = ide_workspace::workspace::Workspace::load(root) {
return workspace.projects;
if workspace.is_container(root) {
return workspace.projects;
}
let mut roots = workspace.projects.into_iter().filter(|p| !p.starts_with(root)).collect::<Vec<_>>();
for path in extra_content_roots(settings, root, build_cache) {
if !roots.contains(&path) {
roots.push(path);
}
}
return roots;
}
extra_content_roots(settings, root, build_cache)
}
fn extra_content_roots(settings: &ide_core::settings::Settings, root: &Path, build_cache: &Path) -> Vec<PathBuf> {
let mut out: Vec<PathBuf> = Vec::new();
let user = settings.project_structure.content_roots.iter().map(|r| PathBuf::from(r.trim()));
for p in user.chain(ide_build::gradle_external_roots(root, build_cache, &settings.languages.java.gradle)) {
+4 -4
View File
@@ -9,7 +9,7 @@ use std::sync::{Arc, Mutex, mpsc};
use std::time::{Duration, Instant};
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
const EOF_GRACE: Duration = Duration::from_secs(5);
const EOF_GRACE: Duration = Duration::from_secs(10);
/// `<host> <port>` from the arguments after `--connect`.
pub fn parse_args(args: &[String]) -> Result<(String, u16), String> {
@@ -25,7 +25,7 @@ pub fn parse_args(args: &[String]) -> Result<(String, u16), String> {
/// Connects to `host:port`, copies `input` to it and its replies to `output`, and returns when
/// the target is done (the copy of `input` may still be blocked: the caller exits). Fails only
/// when the connection can't be opened. An input EOF allows five idle seconds for a final reply.
/// when the connection can't be opened. An input EOF allows ten idle seconds for a final reply.
pub fn relay(host: &str, port: u16, mut input: impl Read + Send + 'static, mut output: impl Write) -> io::Result<()> {
let mut from_target = connect(host, port)?;
from_target.set_read_timeout(Some(Duration::from_millis(100)))?;
@@ -140,11 +140,11 @@ mod tests {
let mut request = Vec::new();
socket.read_to_end(&mut request).unwrap();
// Remain open after seeing EOF, like an idle keep-alive server.
wait.recv_timeout(Duration::from_secs(10)).unwrap();
wait.recv_timeout(Duration::from_secs(15)).unwrap();
});
let start = Instant::now();
relay("127.0.0.1", port, io::empty(), io::sink()).unwrap();
assert!(start.elapsed() < Duration::from_secs(8));
assert!(start.elapsed() < Duration::from_secs(13));
done.send(()).unwrap();
server.join().unwrap();
}
+31 -2
View File
@@ -56,6 +56,22 @@ impl TrustStore {
|| self.inherited.lock().contains(&root)))
}
pub fn window_trusted(&self, root: &Path) -> IdeResult<bool> {
if !self.trusted(root)? {
return Ok(false);
}
if let Some(workspace) = ide_workspace::workspace::Workspace::load(root)?
&& !workspace.is_container(root)
{
for member in workspace.projects.iter().filter(|p| *p != root && p.exists()) {
if !self.trusted(member)? {
return Ok(false);
}
}
}
Ok(true)
}
/// Trusts `path` for this process without recording a decision (see [`TRUSTED_ROOT_ENV`]).
pub fn inherit(&self, path: &Path) -> IdeResult<()> {
let root = dunce::canonicalize(path)?;
@@ -134,7 +150,7 @@ impl crate::Host {
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 trusted = self.trust.window_trusted(&project.root).unwrap_or(false);
let previous = project.trusted.swap(trusted, std::sync::atomic::Ordering::AcqRel);
if previous == trusted {
continue;
@@ -221,13 +237,17 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
| "app_cli_uninstall"
| "app_log_location"
| "app_check_update"
| "app_startup_update_check"
| "app_update_window_ready"
| "app_update_status"
| "app_update_announcement"
| "app_install_update"
| "caches_schedule_invalidation"
| "clear_caches"
| "clipboard_file_list"
| "clipboard_has_files"
| "clipboard_image"
| "clipboard_set_file_list"
| "clipboard_write_text"
| "jdk_download_vendors"
| "jdk_download_releases"
| "jdk_download_location"
@@ -271,6 +291,7 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
| "deploy_coverage"
| "deploy_test_connection"
| "find_files"
| "find_files_named"
| "find_dirs"
| "find_symbols"
| "mark_recent"
@@ -434,3 +455,11 @@ impl TrustStore {
Ok(TrustStatus { decided: trusted || state.restricted.contains(&root), root: root.into(), trusted })
}
}
#[cfg(test)]
mod tests {
#[test]
fn restricted_mode_allows_clipboard_write_text() {
assert!(super::restricted_command_allowed("clipboard_write_text"));
}
}
+38
View File
@@ -126,6 +126,10 @@ impl PluginWorkspace for TestWorkspace {
fn trusted(&self, _root: &Path) -> bool {
self.trusted
}
fn project_windows(&self, root: &Path) -> Vec<String> {
// `main` shows the fixture project; other open roots have no window left.
if root == self.root { vec!["closing".into(), "main".into()] } else { Vec::new() }
}
fn project_of(&self, _window: &str) -> Option<PathBuf> {
if let Some(left) = self.project_queries_left.lock().as_mut() {
if *left == 0 {
@@ -4801,6 +4805,40 @@ fn notification_actions_keep_commands_and_window_scope() {
assert_eq!(f.status("jobs"), PluginStatus::Active, "a refused notification leaves the plugin running");
}
#[test]
fn project_notifications_go_to_a_window_of_that_project_only() {
let f = fixture(Limits::default());
f.install(&sample("jobs"));
f.host.set_capability("jobs", "project.read", false).unwrap();
f.host.forget_client("closing");
let other = f.project.parent().unwrap().join("other");
std::fs::create_dir(&other).unwrap();
f.workspace.other_roots.lock().push(other.clone());
f.take_events();
let notifications = |f: &Fixture| {
f.take_events()
.into_iter()
.filter_map(|(client, event)| match event {
PluginEvent::Notification { title, actions, .. } => Some((client, title, actions)),
_ => None,
})
.collect::<Vec<_>>()
};
// A scheduled call has no invoking window; a closing window is skipped.
let path = f.project.join("notes.txt").to_string_lossy().into_owned();
f.host.run_command("jobs", "notify-project", Some(&path), Invocation::default()).unwrap();
let sent = notifications(&f);
assert_eq!(sent.len(), 1);
assert_eq!((sent[0].0.as_deref(), sent[0].1.as_str()), (Some("main"), "Project update"));
assert_eq!((sent[0].2[0].command.as_str(), sent[0].2[0].argument.as_deref()), ("context", Some(path.as_str())));
// A project without a window fails without falling back to another one, even when invoked from one.
let path = other.join("notes.txt").to_string_lossy().into_owned();
let error = f.host.run_command("jobs", "notify-project", Some(&path), f.inv()).unwrap_err().message;
assert!(error.contains("not open"), "{error}");
assert!(notifications(&f).is_empty());
assert_eq!(f.status("jobs"), PluginStatus::Active);
}
#[test]
fn directory_prompts_and_project_requests_use_the_calling_window_without_a_capability() {
use ide_plugin::Prompt;
+35 -1
View File
@@ -38,13 +38,17 @@ async fn application_commands_work_in_restricted_projects_and_without_a_project(
"app_cli_uninstall",
"app_log_location",
"app_check_update",
"app_startup_update_check",
"app_update_window_ready",
"app_update_status",
"app_update_announcement",
"app_install_update",
"caches_schedule_invalidation",
"clear_caches",
"clipboard_file_list",
"clipboard_has_files",
"clipboard_image",
"clipboard_set_file_list",
"clipboard_write_text",
"jdk_download_vendors",
"jdk_download_releases",
"jdk_download_location",
@@ -448,3 +452,33 @@ async fn revocation_still_applies_when_a_project_folder_was_removed() {
assert!(!project.is_trusted());
host.shutdown().await;
}
#[test]
fn attached_member_trust_is_required_after_restart_and_revocation() {
let tmp = tempfile::tempdir().unwrap();
let (config, data) = (tmp.path().join("config"), tmp.path().join("data"));
let base = dunce::canonicalize(tmp.path()).unwrap();
let root = base.join("primary");
let other = base.join("other");
for path in [&root, &other] {
std::fs::create_dir(path).unwrap();
}
ide_workspace::workspace::Workspace::attach(&root, "Primary", vec![root.clone(), other.clone()]).unwrap();
let trust = TrustStore::load(&config, &data);
trust.decide(&root, true, false).unwrap();
trust.inherit(&other).unwrap();
assert!(trust.window_trusted(&root).unwrap());
let trust = TrustStore::load(&config, &data);
assert!(trust.status(&root).unwrap().trusted);
assert!(!trust.window_trusted(&root).unwrap());
assert!(!trust.status(&other).unwrap().decided);
trust.decide(&root, true, false).unwrap();
assert!(!trust.window_trusted(&root).unwrap(), "trusting only the root does not recover the window");
trust.decide(&other, true, false).unwrap();
assert!(trust.window_trusted(&root).unwrap());
trust.decide(&other, false, false).unwrap();
assert!(trust.status(&other).unwrap().decided);
assert!(!trust.window_trusted(&root).unwrap());
trust.decide(&other, true, false).unwrap();
assert!(trust.window_trusted(&root).unwrap());
}
+2
View File
@@ -197,6 +197,8 @@ async fn code_actions_skip_a_command_no_server_provides_after_their_edit() {
let info = ops::doc::open(&host, "main", file.clone()).await.unwrap();
// Optimize Imports as vtsls offers it: the edit, then a command its client runs itself.
let action = |edit: bool| CodeAction {
diagnostics: Vec::new(),
original_diagnostics: None,
title: "Organize Imports".into(),
kind: Some("source.organizeImports".into()),
is_preferred: false,
+134 -14
View File
@@ -32,11 +32,15 @@ async fn linked_projects_share_navigation_but_keep_files_repositories_and_run_di
});
std::fs::write(root.join("internal-needle.txt"), "needle").unwrap();
host.trust.decide(&root, true, false).unwrap();
for member in &roots {
host.trust.decide(member, 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());
ops::project::attach_workspace(&host, "main", &roots[1]).unwrap();
assert_eq!(project.content_roots(), roots);
assert!(!root.join(".openidea/.gitignore").exists());
assert!(project.index.wait_idle(Duration::from_secs(10)));
assert!(project.index.find_files("internal-needle", 100).is_empty());
assert!(project.index.find_files("workspace.json", 100).is_empty());
@@ -98,20 +102,6 @@ async fn linked_projects_share_navigation_but_keep_files_repositories_and_run_di
std::fs::write(invalid.join(ide_workspace::workspace::FILE), "broken").unwrap();
assert!(host.open_project("main", &invalid).is_err());
assert!(Arc::ptr_eq(&host.project("main").unwrap(), &project));
host.open_project("main", &roots[0]).unwrap();
let automatic = ops::project::attach_workspace(&host, "main", &roots[1]).unwrap();
assert_eq!(host.project("main").unwrap().root, roots[0]);
let automatic = host.open_project("main", &automatic).unwrap();
assert_eq!(automatic.content_roots(), roots);
assert_eq!(ops::project::info(&automatic).name, "frontend");
assert!(automatic.root.starts_with(base.join("data/workspaces")));
assert!(automatic.index.wait_idle(Duration::from_secs(10)));
assert!(automatic.index.find_files("workspace.json", 100).is_empty());
let updated = ops::project::save_workspace(&host, "main", "frontend", vec![roots[1].clone()]).unwrap();
assert_eq!(updated.workspace.unwrap().projects, vec![roots[1].clone()]);
assert_eq!(automatic.root, updated.root.as_ref());
assert!(automatic.index.wait_idle(Duration::from_secs(10)));
assert_eq!(automatic.index.find_files("same.txt", 100).len(), 1);
host.close_project("main");
}
@@ -206,3 +196,133 @@ async fn build_tool_availability_follows_workspace_members_and_trust() {
assert_eq!(model.gradle.len(), 1);
host.close_project("main");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn attach_and_detach_preserve_the_primary_project_session() {
let temp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(temp.path()).unwrap();
let roots = vec![base.join("primary"), base.join("other")];
for root in &roots {
std::fs::create_dir(root).unwrap();
std::fs::write(root.join("same.txt"), "needle").unwrap();
std::fs::write(root.join("package.json"), r#"{"scripts":{"test":"echo test"}}"#).unwrap();
}
let host = Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_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,
});
host.trust.decide(&roots[0], true, false).unwrap();
let project = host.open_project("main", &roots[0]).unwrap();
assert!(project.index.wait_idle(Duration::from_secs(10)));
let configs = ops::run::configs(&host, "main").unwrap();
assert!(!configs.is_empty());
let cache = project.build_cache_dir();
let doc = ops::doc::open(&host, "main", roots[0].join("same.txt")).await.unwrap();
ops::doc::edit(
&host,
"main",
doc.id,
doc.version,
ChangeSet { changes: vec![TextChange { from: 0, to: 0, insert: "unsaved ".into() }] },
)
.unwrap();
assert!(ops::project::attach_workspace(&host, "main", &roots[1]).is_err());
host.trust.decide(&roots[1], false, false).unwrap();
assert!(ops::project::attach_workspace(&host, "main", &roots[1]).is_err());
assert!(!roots[0].join(ide_workspace::workspace::FILE).exists());
let existing_index = &project.index as *const _;
host.trust.decide(&roots[1], true, false).unwrap();
assert_eq!(ops::project::attach_workspace(&host, "main", &roots[1]).unwrap().as_ref(), roots[0]);
assert!(Arc::ptr_eq(&host.project("main").unwrap(), &project));
assert_eq!(&host.project("main").unwrap().index as *const _, existing_index);
assert_eq!(project.root, roots[0]);
assert_eq!(project.build_cache_dir(), cache);
assert!(!base.join("data/workspaces").exists());
assert_eq!(Workspace::load(&roots[0]).unwrap().unwrap().projects, roots);
assert!(
std::fs::read_to_string(roots[0].join(".openidea/.gitignore")).unwrap().lines().any(|l| l == "/workspace.json")
);
assert_eq!(project.content_roots(), vec![roots[1].clone()]);
let attached = ops::project::info(&project);
assert_eq!(attached.name, "primary");
assert_eq!(attached.display_name, "primary, other");
ops::project::set_settings(
&host,
"main",
ide_workspace::SettingsScope::Project,
"general.projectName",
&serde_json::to_string(&attached.name).unwrap(),
)
.unwrap();
assert_eq!(ops::project::info(&project).display_name, "primary, other");
let restricted = base.join("restricted");
std::fs::create_dir(&restricted).unwrap();
host.trust.decide(&restricted, false, false).unwrap();
assert!(
ops::project::save_workspace(&host, "main", "Primary", vec![roots[0].clone(), roots[1].clone(), restricted])
.is_err()
);
let all = ops::run::configs(&host, "main").unwrap();
assert_eq!(all.len(), configs.len() * 2);
for config in &configs {
assert_eq!(all.iter().filter(|c| c.id == config.id).count(), 1);
}
assert!(project.index.wait_idle(Duration::from_secs(10)));
assert_eq!(project.index.find_files("same.txt", 100).len(), 2);
let hits = Arc::new(Mutex::new(Vec::new()));
let out = hits.clone();
let query = serde_json::from_value(serde_json::json!({"pattern":"needle", "isRegex":false,"caseSensitive":false,"wholeWord":false,"include":[],"exclude":[],"scope":{"kind":"project"},"maxMatches":100})).unwrap();
ops::search::start(
&host,
"main",
RequestId::next(),
query,
Arc::new(move |event| {
if let ops::search::SearchEvent::File { file } = event {
out.lock().unwrap().push(file);
}
}),
)
.await
.unwrap();
assert_eq!(hits.lock().unwrap().len(), 2);
assert!(ops::project::save_workspace(&host, "main", "Primary", vec![roots[1].clone()]).is_err());
let updated = ops::project::save_workspace(&host, "main", "Primary", vec![roots[0].clone()]).unwrap();
assert!(updated.workspace.is_none());
assert_eq!(updated.name, "primary");
assert_eq!(updated.root.as_ref(), roots[0]);
assert!(!roots[0].join(ide_workspace::workspace::FILE).exists());
assert!(project.content_roots().is_empty());
assert_eq!(
ops::run::configs(&host, "main").unwrap().iter().map(|c| &c.id).collect::<Vec<_>>(),
configs.iter().map(|c| &c.id).collect::<Vec<_>>()
);
assert!(project.documents.text_of(&roots[0].join("same.txt")).unwrap().starts_with("unsaved"));
assert!(project.index.wait_idle(Duration::from_secs(10)));
assert_eq!(project.index.find_files("same.txt", 100).len(), 1);
let nested = roots[0].join("nested");
std::fs::create_dir(&nested).unwrap();
std::fs::write(nested.join("same.txt"), "needle").unwrap();
host.trust.decide(&nested, true, false).unwrap();
Workspace::attach(&nested, "Nested", vec![nested.clone()]).unwrap();
ops::project::attach_workspace(&host, "main", &nested).unwrap();
assert!(project.content_roots().is_empty());
let container = base.join("container");
Workspace::save(&container, "Container", vec![], true).unwrap();
host.trust.decide(&container, true, false).unwrap();
assert!(ops::project::attach_workspace(&host, "main", &container).is_err());
host.close_project("main");
let reopened = host.open_project("main", &roots[0]).unwrap();
assert_eq!(ops::project::info(&reopened).name, "primary");
assert_eq!(ops::project::info(&reopened).display_name, "primary, nested");
host.set_project_trust(&nested, false, false).await.unwrap();
assert!(!reopened.is_trusted());
host.close_project("main");
}
+9
View File
@@ -234,6 +234,15 @@ impl ProjectIndex {
matching::find_files(&d, &matching::Recents { paths: &recent }, query, limit)
}
/// All indexed files with this exact basename, for resolving printed source locations.
/// Unlike interactive fuzzy search, this must not truncate candidates before package matching.
pub fn files_named(&self, name: &str) -> Vec<PathBuf> {
let data = self.shared.data.read();
let mut files: Vec<_> = data.files.iter().flatten().filter(|file| file.name() == name).collect();
files.sort_by(|a, b| a.rel.len().cmp(&b.rel.len()).then_with(|| a.rel.cmp(&b.rel)));
files.into_iter().map(|file| file.path.clone()).collect()
}
/// Up to `limit` indexed files below `dir` (VCS-ignored ones left out) whose path relative to
/// `dir` satisfies `matches`: a file list without walking the disk. `None` while nothing is
/// indexed yet (first scan without a cache).
+21
View File
@@ -57,6 +57,27 @@ fn files(idx: &ProjectIndex, q: &str) -> Vec<String> {
idx.find_files(q, 20).into_iter().map(|m| m.rel_path).collect()
}
#[test]
fn exact_file_names_keep_all_source_candidates() {
let f = Fixture::new(&[
("src/OtherOrders.kt", "package other"),
("src/Orders.kt.bak", "backup"),
("target/Orders.kt", "excluded"),
("src/orders.kt", "different case"),
]);
for i in 0..65 {
f.write(&format!("module{i}/Orders.kt"), &format!("package module{i}"));
}
let source = f.write("service/src/Orders.kt", "package com.example.orders");
let idx = f.open();
let candidates = idx.files_named("Orders.kt");
assert_eq!(candidates.len(), 66);
assert!(candidates.contains(&source));
assert!(candidates.iter().all(|path| path.file_name().unwrap() == "Orders.kt"));
assert_eq!(candidates, idx.files_named("Orders.kt"));
assert!(idx.files_named("Missing.kt").is_empty());
}
fn symbols(idx: &ProjectIndex, q: &str, filter: SymbolFilter) -> Vec<String> {
idx.find_symbols(q, filter, 20).into_iter().map(|m| m.name).collect()
}
+14 -2
View File
@@ -14,14 +14,14 @@ mod markers;
pub mod plugin_point;
mod registry;
mod syntax;
pub use markers::marked_root;
pub use markers::{arduino_sketch_root, is_arduino_sketch_dir, marked_root};
pub use plugin_point::PluginLanguages;
pub use registry::{FileTypes, LanguageRegistry};
pub use syntax::{
CSS_CLASS, CSS_ID, CSS_VARIABLE, ConfigurationKey, ExternalFold, ExternalSymbol, ExternalSyntax, ExternalToken,
FileReference, Literal, LiteralKind, MAX_CACHED_TREES, MAX_PARSE_BYTES, ParsedTree, SpellingKind, SpellingRegion,
Syntax, SyntaxCache, SyntaxCacheStats, SyntaxHighlightKind, SyntaxHighlights, TreeError, WebReference,
WebReferenceKind, merge_visibility, set_external_syntax,
WebReferenceKind, merge_visibility, set_external_syntax, shift_pos, shift_range,
};
/// Parsed form of `languages/<id>.toml`. Backend-only; the UI gets [`LanguageInfo`].
@@ -332,3 +332,15 @@ pub struct SymbolTag {
pub container: Option<String>,
pub range: Range,
}
/// One inline TS/JS `<script>` block of a Vue SFC / HTML document.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ScriptRegion {
/// Document-absolute UTF-8 byte offset of `text`, for tree-sitter parsing.
/// Convert to UTF-16 positions before exposing ranges through IPC.
pub base: usize,
/// The language the block's `lang` attribute picks.
pub language: LanguageId,
/// The script text.
pub text: String,
}
+21
View File
@@ -19,6 +19,27 @@ pub fn marked_root(project_root: Option<&Path>, file: &Path, markers: &[String])
best
}
/// `dir` contains at least one `.ino` regular file.
pub fn is_arduino_sketch_dir(dir: &Path) -> bool {
std::fs::read_dir(dir).is_ok_and(|entries| {
entries.flatten().any(|entry| {
entry.path().extension().is_some_and(|ext| ext.eq_ignore_ascii_case("ino"))
&& entry.file_type().is_ok_and(|kind| kind.is_file())
})
})
}
/// Nearest enclosing directory of `path` (including `path` when it is already a
/// directory) within `project` that contains a `.ino` file.
pub fn arduino_sketch_root(project: &Path, path: &Path) -> Option<PathBuf> {
let start = usize::from(!path.is_dir());
path.ancestors()
.skip(start)
.take_while(|dir| dir.starts_with(project))
.find(|dir| is_arduino_sketch_dir(dir))
.map(Path::to_path_buf)
}
/// `dir` contains `marker`: a relative path, a file name pattern with one
/// `*` (`*.csproj`), or a TOML file name (no pattern) and a dotted table it
/// must contain (`pyproject.toml#tool.ruff`).
+8
View File
@@ -70,6 +70,7 @@ const BUILTIN: &[(&str, &str)] = &[
("proto", include_str!("../../../languages/proto.toml")),
("python", include_str!("../../../languages/python.toml")),
("r", include_str!("../../../languages/r.toml")),
("restructuredtext", include_str!("../../../languages/restructuredtext.toml")),
("ruby", include_str!("../../../languages/ruby.toml")),
("rust", include_str!("../../../languages/rust.toml")),
("sass", include_str!("../../../languages/sass.toml")),
@@ -562,6 +563,7 @@ mod tests {
"R",
"RPM Changes",
"RPM Spec",
"reStructuredText",
"Ruby",
"SAS",
"Scala",
@@ -628,6 +630,12 @@ mod tests {
let asciidoc = reg.get(&LanguageId::new("asciidoc")).unwrap();
assert_eq!(asciidoc.ui_mode.as_deref(), Some("AsciiDoc"));
assert_eq!(asciidoc.line_comment.as_deref(), Some("//"));
for path in ["guide.rst", "README.RST"] {
assert_eq!(detect(&reg, path), "restructuredtext");
}
let rst = reg.get(&LanguageId::new("restructuredtext")).unwrap();
assert_eq!(rst.ui_mode.as_deref(), Some("reStructuredText"));
assert_eq!(rst.line_comment.as_deref(), Some(".."));
assert_eq!(reg.detect(Path::new("script"), Some("#!/usr/bin/env python3")).as_str(), "python");
assert_eq!(detect(&reg, "README"), "plaintext");
assert_eq!(detect(&reg, "unknown.zzz"), "plaintext");
+44 -3
View File
@@ -195,9 +195,18 @@ impl SyntaxCache {
|| {
self.with_tree(doc, lang, version, text, |g, tree, text| {
let query = g.outline_query()?;
Some(outline::outline(g, query, tree, text))
// HTML documents answer from the cached tree, with nested
// <script> declarations.
Some(outline::outline_nested(g, query, tree, text, &|rl, rt| self.syntax.outline(rl, rt)))
})
// Web containers (Vue SFC / Svelte / JSP) have no grammar of their
// own, hence no cached tree: the syntax layer answers with a fresh
// HTML parse, which the memo covers per version.
// shortcut: no incremental re-parse for containers, upgrade when
// their trees join the cache.
.or_else(|| self.syntax.web_outline(lang, text))
.or_else(|| super::external::outline(lang, text))
.or_else(|| self.syntax.builtin_outline(lang, text))
},
)
}
@@ -256,8 +265,11 @@ impl SyntaxCache {
let Some(result) = compute() else { return Vec::new() };
if let Some(e) = &entry {
let mut e = e.lock();
// Not if the document moved on (or its tree was dropped) meanwhile.
if e.version == version && e.tree.is_some() {
// Containers without their own grammar memoize their web outline too.
// Never store an answer after the document moves on.
if e.version == version
&& (e.tree.is_some() || (e.lang == *lang && self.syntax.web_grammar_name(lang) == Some("html")))
{
*slot(e.memo.at(version, lang)) = Some(Arc::new(result.clone()));
}
}
@@ -474,6 +486,35 @@ mod tests {
assert!(cache.outline(id, &LanguageId::new("plaintext"), 1, &text).is_empty());
}
#[test]
fn web_outline_memo_tracks_versions_without_a_cached_tree() {
for language in ["html", "vue", "svelte", "jsp"] {
let cache = cache();
let id = DocId::next();
let lang = LanguageId::new(language);
let mut doc = Document::new("<script setup lang=ts>const first = 1;</script>");
cache.open(id, &lang, 0);
let first = cache.outline(id, &lang, 0, &doc.text());
let hits = cache.stats().hits;
assert_eq!(cache.outline(id, &lang, 0, &doc.text()), first);
assert_eq!(cache.stats().hits, hits + 1, "{language}: memo hit");
let edits = edit(&mut doc, 0, 0, "<script>const second = 2;</script>\n");
cache.edit(id, doc.version(), &edits);
let second = cache.outline(id, &lang, doc.version(), &doc.text());
assert_ne!(second, first);
assert_eq!(second, cache.syntax().outline(&lang, &doc.text()));
// An older query must not replace the current version's memo.
cache.outline(id, &lang, 0, "<script>const old = 0;</script>");
let hits = cache.stats().hits;
assert_eq!(cache.outline(id, &lang, doc.version(), &doc.text()), second);
assert_eq!(cache.stats().hits, hits + 1);
cache.clear();
assert!(cache.entries.lock().get(&id).unwrap().lock().memo.outline.is_none());
cache.close(id);
}
}
#[test]
fn version_mismatch_and_lifecycle() {
let cache = cache();
+25
View File
@@ -96,6 +96,19 @@ pub(crate) fn byte_at(text: &str, pos: Pos) -> Option<usize> {
Some(start + utf16_to_byte(&text[start..line_end], pos.col as usize))
}
/// Shifts a position from a document suffix that starts at `base` into document
/// coordinates: the suffix's first line continues `base`'s line, later lines
/// add the base line. Used to map inline `<script>` region answers (Vue SFC /
/// HTML) back onto the whole document; positions are 0-based lines / UTF-16 cols.
pub fn shift_pos(pos: Pos, base: Pos) -> Pos {
if pos.line == 0 { Pos::new(base.line, base.col + pos.col) } else { Pos::new(base.line + pos.line, pos.col) }
}
/// [`shift_pos`] for both ends of a range.
pub fn shift_range(range: Range, base: Pos) -> Range {
Range::new(shift_pos(range.start, base), shift_pos(range.end, base))
}
pub(crate) fn utf16_to_byte(s: &str, offset: usize) -> usize {
let mut units = 0;
for (i, c) in s.char_indices() {
@@ -160,6 +173,18 @@ mod tests {
}
}
#[test]
fn shifts_suffix_positions_into_document_coordinates() {
let base = Pos::new(3, 7);
assert_eq!(shift_pos(Pos::new(0, 2), base), Pos::new(3, 9));
assert_eq!(shift_pos(Pos::new(0, 0), base), base);
assert_eq!(shift_pos(Pos::new(2, 1), base), Pos::new(5, 1));
assert_eq!(
shift_range(Range::new(Pos::new(0, 1), Pos::new(1, 4)), base),
Range::new(Pos::new(3, 8), Pos::new(4, 4))
);
}
#[test]
fn long_minified_line_is_linear() {
// 200k symbols-worth of positions on one 4 MB line.
+50 -1
View File
@@ -9,16 +9,19 @@ mod highlight;
pub(crate) mod lines;
mod literals;
mod outline;
mod rst;
mod selection;
mod sfc;
mod web;
use crate::{FoldRange, LanguageConfig, LanguageRegistry, SymbolTag};
use crate::{FoldRange, LanguageConfig, LanguageRegistry, ScriptRegion, SymbolTag};
pub use cache::{MAX_CACHED_TREES, SyntaxCache, SyntaxCacheStats};
pub use configuration::ConfigurationKey;
pub use external::{ExternalFold, ExternalSymbol, ExternalSyntax, ExternalToken, set_external_syntax};
use grammars::{SPECS, Spec};
pub use highlight::{SyntaxHighlightKind, SyntaxHighlights};
use ide_core::{LanguageId, OutlineItem, Pos, Range};
pub use lines::{shift_pos, shift_range};
pub use literals::{Literal, LiteralKind, SpellingKind, SpellingRegion};
pub use outline::merge_visibility;
use std::cell::RefCell;
@@ -165,13 +168,39 @@ impl Syntax {
}
pub fn outline(&self, lang: &LanguageId, text: &str) -> Vec<OutlineItem> {
// HTML containers (Vue SFC / Svelte / JSP have no grammar of their own;
// HTML itself parses alike): HTML outline with each inline `<script>`'s
// TS/JS declarations nested under its entry, in document coordinates.
if let Some(items) = self.web_outline(lang, text) {
return items;
}
self.analyze(lang, text, |g, tree, text| {
let query = g.outline_query()?;
Some(outline::outline(g, query, tree, text))
})
.or_else(|| self.builtin_outline(lang, text))
.unwrap_or_default()
}
/// Outline for languages without a grammar that have a built-in parser.
fn builtin_outline(&self, lang: &LanguageId, text: &str) -> Option<Vec<OutlineItem>> {
(lang.as_str() == "restructuredtext" && text.len() <= MAX_PARSE_BYTES).then(|| rst::outline(text))
}
/// Outline of an HTML container document (html, vue, svelte, jsp): `None`
/// for every other language, CSS containers included.
pub(crate) fn web_outline(&self, lang: &LanguageId, text: &str) -> Option<Vec<OutlineItem>> {
let g = self.web_grammar(lang)?;
if g.spec.name != "html" || text.len() > MAX_PARSE_BYTES {
return None;
}
let tree = parse(&g.language, text, None)?;
let query = g.outline_query()?;
Some(outline::outline_nested(g, query, &tree, text, &|region_lang, region_text| {
self.outline(region_lang, region_text)
}))
}
pub fn folding(&self, lang: &LanguageId, text: &str) -> Vec<FoldRange> {
let tokens = self.comment_tokens(lang);
self.analyze(lang, text, |g, tree, text| Some(folding::folding(g.spec, g.kinds(), tree, text, tokens)))
@@ -248,6 +277,19 @@ impl Syntax {
self.with_web_tree(lang, text, |_, _, tree| web::anchors(tree, text, name)).unwrap_or_default()
}
/// Inline TS/JS `<script>` regions of a Vue SFC / HTML document, in
/// document order. Empty for other languages or non-JavaScript blocks.
pub fn script_regions(&self, lang: &LanguageId, text: &str) -> Vec<ScriptRegion> {
self.with_web_tree(lang, text, |_g, _css, tree| sfc::script_regions(tree, text)).unwrap_or_default()
}
/// [`Self::script_regions`] of an already parsed document (`ParsedTree`
/// lends the tree and text) — the cache-aware way for other crates, which
/// reuses a cached incremental HTML tree instead of parsing again.
pub fn script_regions_of(tree: &Tree, text: &str) -> Vec<ScriptRegion> {
sfc::script_regions(tree, text)
}
/// Highlighting from the grammar's own query; `None` without one.
pub fn highlights(&self, lang: &LanguageId, text: &str) -> Option<SyntaxHighlights> {
self.analyze(lang, text, |g, tree, text| {
@@ -280,6 +322,13 @@ impl Syntax {
self.grammar(lang).map(|g| g.spec.name)
}
/// Name of the grammar web features parse `lang` with (html for Vue SFC /
/// Svelte / JSP, css for SCSS / Less); `None` when `lang` has its own
/// grammar or no web features. Lets other crates recognize containers.
pub fn web_grammar_name(&self, lang: &LanguageId) -> Option<&'static str> {
self.web_grammar(lang).map(|g| g.spec.name)
}
/// Grammar name and tree-sitter language for `lang`, for engines that parse
/// on their own (structural search patterns).
pub fn ts_language(&self, lang: &LanguageId) -> Option<(&'static str, Language)> {
+65 -2
View File
@@ -13,9 +13,9 @@
//! macro event operator key heading impl extension.
use super::Grammar;
use super::lines::LineIndex;
use super::lines::{LineIndex, shift_range};
use crate::SymbolTag;
use ide_core::{OutlineItem, SymbolKind, Visibility};
use ide_core::{LanguageId, OutlineItem, Pos, SymbolKind, Visibility};
use std::collections::HashMap;
use tree_sitter::{Node, Query, QueryCursor, StreamingIterator, Tree};
@@ -296,6 +296,69 @@ pub(crate) fn outline(g: &Grammar, q: &OutlineQuery, tree: &Tree, text: &str) ->
collect(g, q, tree, text).into_iter().map(convert).collect()
}
/// [`outline`], plus, for the HTML grammar, each inline `<script>` region's
/// TS/JS declarations nested under its `script` entry and shifted to document
/// coordinates (Vue SFC / HTML documents). `block` outlines one region's
/// language, answering in region-local coordinates.
pub(crate) fn outline_nested(
g: &Grammar,
q: &OutlineQuery,
tree: &Tree,
text: &str,
block: &impl Fn(&LanguageId, &str) -> Vec<OutlineItem>,
) -> Vec<OutlineItem> {
let mut items = outline(g, q, tree, text);
if g.spec.name != "html" {
return items;
}
let lines = LineIndex::new(text);
for region in super::sfc::script_regions(tree, text) {
let mut decls = block(&region.language, &region.text);
if decls.is_empty() {
continue;
}
let base = lines.pos(region.base);
shift_outline(&mut decls, base);
if !attach_script(&mut items, base, &mut Some(decls)) {
// The region and the entry come from the same `script_element` node,
// so this means the outline query no longer matches script elements.
tracing::debug!("outline: no script entry for the <script> region at byte {}", region.base);
}
}
items
}
/// Recursively shifts region-local positions in `items` to document coordinates.
fn shift_outline(items: &mut [OutlineItem], base: Pos) {
for item in items {
item.range = shift_range(item.range, base);
item.selection_range = shift_range(item.selection_range, base);
shift_outline(&mut item.children, base);
}
}
/// Nests `decls` under the innermost `script` entry covering `base`. The
/// region and the entry both come from the same `script_element` node, so the
/// entry always exists; a miss means the outline query no longer matches it.
fn attach_script(items: &mut [OutlineItem], base: Pos, decls: &mut Option<Vec<OutlineItem>>) -> bool {
let covers = |range: &ide_core::Range| {
(range.start.line, range.start.col) <= (base.line, base.col)
&& (base.line, base.col) < (range.end.line, range.end.col)
};
for item in items.iter_mut() {
if item.name.eq_ignore_ascii_case("script") && covers(&item.range) {
item.children.extend(decls.take().unwrap_or_default());
return true;
}
}
for item in items.iter_mut() {
if attach_script(&mut item.children, base, decls) {
return true;
}
}
false
}
pub(crate) fn tags(g: &Grammar, q: &OutlineQuery, tree: &Tree, text: &str) -> Vec<SymbolTag> {
fn walk(items: Vec<Built>, container: Option<&str>, out: &mut Vec<SymbolTag>) {
for b in items {
+131
View File
@@ -0,0 +1,131 @@
//! reStructuredText section titles for the outline. No bundled grammar nests
//! sections (tree-sitter-rst emits them flat), so levels follow docutils: each
//! adornment style (character, with or without overline) takes the next level
//! the first time it appears.
use super::lines::LineIndex;
use ide_core::{OutlineItem, SymbolKind};
struct Line<'a> {
start: usize,
text: &'a str,
}
struct Heading {
level: usize,
start: usize,
end: usize,
name_start: usize,
name_end: usize,
}
pub(crate) fn outline(text: &str) -> Vec<OutlineItem> {
let lines: Vec<Line> = text
.split_inclusive('\n')
.scan(0, |at, raw| {
let start = *at;
*at += raw.len();
Some(Line { start, text: raw.trim_end_matches(['\n', '\r']) })
})
.collect();
let mut heads = headings(&lines);
for k in 0..heads.len() {
let end = heads[k + 1..].iter().find(|h| h.level <= heads[k].level).map_or(text.len(), |h| h.start);
heads[k].end = heads[k].start + text[heads[k].start..end].trim_end().len();
}
let index = LineIndex::new(text);
let mut i = 0;
nest(&heads, &mut i, None, text, &index)
}
fn headings(lines: &[Line]) -> Vec<Heading> {
let mut styles: Vec<(char, bool)> = Vec::new();
let mut out = Vec::new();
let mut push = |style: (char, bool), start: usize, title: &Line| {
let level = styles.iter().position(|&s| s == style).unwrap_or_else(|| {
styles.push(style);
styles.len() - 1
});
let name = title.text.trim();
let name_start = title.start + (title.text.len() - title.text.trim_start().len());
out.push(Heading { level, start, end: 0, name_start, name_end: name_start + name.len() });
};
// A title starts a new block: it follows a blank line, the start, or another title.
let mut at_block_start = true;
let mut i = 0;
while i < lines.len() {
let line = &lines[i];
if line.text.trim().is_empty() {
at_block_start = true;
i += 1;
continue;
}
if at_block_start {
if let Some(over) = adornment(line.text) {
if let [title, under, ..] = &lines[i + 1..]
&& !title.text.trim().is_empty()
&& adornment(title.text).is_none()
&& adornment(under.text) == Some(over)
&& fits(title.text.trim_end(), over.1)
{
push((over.0, true), line.start, title);
i += 3;
continue;
}
} else if !line.text.starts_with([' ', '\t'])
&& !starts_body_element(line.text)
&& let Some(under) = lines.get(i + 1).and_then(|l| adornment(l.text))
&& fits(line.text.trim_end(), under.1)
{
push((under.0, false), line.start, line);
i += 2;
continue;
}
}
// Indented lines (literal blocks after `::`, directive bodies, quotes)
// never hold titles: they are skipped here because adornments and
// underlined titles must start in column 0.
at_block_start = false;
i += 1;
}
out
}
/// `(character, length)` of a line made of one repeated ASCII punctuation character.
fn adornment(line: &str) -> Option<(char, usize)> {
let line = line.trim_end();
let c = line.chars().next().filter(char::is_ascii_punctuation)?;
line.chars().all(|x| x == c).then_some((c, line.len()))
}
/// docutils accepts a short adornment (with a warning) unless it is shorter
/// than 4 characters, which reads as ordinary text instead.
// ponytail: counts chars, not display columns; wide CJK titles get a looser check.
fn fits(title: &str, adornment_len: usize) -> bool {
adornment_len >= title.chars().count() || adornment_len >= 4
}
/// Lines docutils parses as another body element before trying a title:
/// explicit markup (`..`, `__`), bullets, field lists, doctests, line blocks.
fn starts_body_element(line: &str) -> bool {
let marker = |m: &str| line == m || line.strip_prefix(m).is_some_and(|r| r.starts_with(' '));
["..", "__", "-", "*", "+", "•", ">>>", "|"].into_iter().any(marker)
|| (line.starts_with(':') && line[1..].find(':').is_some_and(|k| k > 0 && marker(&line[..k + 2])))
}
fn nest(heads: &[Heading], i: &mut usize, parent: Option<usize>, text: &str, index: &LineIndex) -> Vec<OutlineItem> {
let mut items = Vec::new();
while let Some(h) = heads.get(*i).filter(|h| parent.is_none_or(|p| h.level > p)) {
*i += 1;
items.push(OutlineItem {
name: text[h.name_start..h.name_end].to_owned(),
detail: None,
kind: SymbolKind::Key,
range: index.range(h.start, h.end),
selection_range: index.range(h.name_start, h.name_end),
children: nest(heads, i, Some(h.level), text, index),
visibility: None,
});
}
items
}
+92
View File
@@ -0,0 +1,92 @@
//! Inline TS/JS `<script>` regions located with the HTML grammar's
//! `script_element` nodes. The block's `lang` attribute selects the grammar.
//!
//! Blocks whose `type` attribute names anything but JavaScript are skipped
//! (`application/json`, `text/template`, `importmap`, …), as are unknown
//! `lang` values.
use super::web::walk;
use crate::ScriptRegion;
use ide_core::LanguageId;
use tree_sitter::{Node, Tree};
/// The TS/JS grammar a `lang` attribute value names; `None` skips the block.
fn lang_grammar(value: &str) -> Option<&'static str> {
match value {
"ts" | "typescript" => Some("typescript"),
"tsx" => Some("tsx"),
"" | "js" | "jsx" | "javascript" => Some("javascript"),
_ => None,
}
}
/// `true` when a `type` attribute value names JavaScript (or is absent).
fn is_js_type(value: &str) -> bool {
matches!(value, "" | "module" | "text/javascript" | "application/javascript")
}
/// The `<script>` regions of a document parsed with the HTML grammar,
/// in document order. `base` is each block's byte offset into the document.
pub(crate) fn script_regions(tree: &Tree, text: &str) -> Vec<ScriptRegion> {
let mut out = Vec::new();
walk(tree.root_node(), &mut |n| {
if n.kind() != "script_element" {
return true;
}
let mut lang = "javascript";
let mut skip = false;
let mut c = n.walk();
for ch in n.children(&mut c) {
match ch.kind() {
// The start tag precedes the raw text, so the attributes are
// known before the region is taken.
"start_tag" => {
let mut c2 = ch.walk();
for attr in ch.children(&mut c2) {
if attr.kind() != "attribute" {
continue;
}
let (name, value) = attribute(attr, text);
match name.to_ascii_lowercase().as_str() {
"lang" => match lang_grammar(&value.to_ascii_lowercase()) {
Some(g) => lang = g,
None => skip = true,
},
"type" if !is_js_type(&value.trim().to_ascii_lowercase()) => skip = true,
"src" => skip = true,
_ => {}
}
}
}
"raw_text" if !skip => {
out.push(ScriptRegion {
base: ch.start_byte(),
language: LanguageId::new(lang),
text: text[ch.start_byte()..ch.end_byte()].to_string(),
});
}
_ => {}
}
}
false // the raw text is not markup
});
out
}
/// An attribute's `(name, value)`; a bare attribute (`setup`) has an empty value.
fn attribute<'t>(attr: Node<'t>, text: &'t str) -> (&'t str, &'t str) {
let mut c = attr.walk();
let mut name = "";
let mut value = "";
for ch in attr.children(&mut c) {
match ch.kind() {
"attribute_name" => name = ch.utf8_text(text.as_bytes()).unwrap_or(""),
"quoted_attribute_value" => {
value = ch.named_child(0).and_then(|v| v.utf8_text(text.as_bytes()).ok()).unwrap_or("");
}
"attribute_value" => value = ch.utf8_text(text.as_bytes()).unwrap_or(""),
_ => {}
}
}
(name, value)
}
+317
View File
@@ -759,6 +759,7 @@ fn outline_html_css_xml() {
Field body
Field div
Field script
Variable a
",
);
check_outline(
@@ -1409,3 +1410,319 @@ fn spelling_regions_cover_declared_names_only() {
.collect();
assert_eq!(names, ["Bird", "fly", "text"]);
}
// ---------------------------------------------------------------------------
// Script regions of Vue SFCs and HTML documents
#[test]
fn script_regions_skip_typed_blocks() {
let src = "<html><body><script type=\"module\">var x = 1;</script>\
<script type=\"application/json\">{\"a\":1}</script>\
<script type=\"importmap\">{}</script>\
<script>var y = 2;</script></body></html>";
let regions = syntax().script_regions(&lang("html"), src);
assert_eq!(regions.len(), 2, "module and plain scripts; json/importmap skipped");
assert_eq!(regions[0].language.as_str(), "javascript");
}
#[test]
fn script_regions_pick_the_lang_attribute() {
let src = "<script setup lang=\"tsx\">\nconst App = () => <div/>\n</script>\n";
let regions = syntax().script_regions(&lang("vue"), src);
assert_eq!(regions.len(), 1);
assert_eq!(regions[0].language.as_str(), "tsx");
assert!(regions[0].text.contains("const App"));
}
#[test]
fn script_regions_empty_for_other_languages() {
assert!(syntax().script_regions(&lang("rust"), "fn main() {}").is_empty());
assert!(syntax().script_regions(&lang("vue"), "<template><div/></template>").is_empty());
}
#[test]
fn script_regions_classify_attributes() {
for language in ["vue", "html"] {
for (attributes, expected) in [
("", Some("javascript")),
("setup lang=ts", Some("typescript")),
("lang='typescript'", Some("typescript")),
("lang=tsx", Some("tsx")),
("lang=js", Some("javascript")),
("lang=jsx", Some("javascript")),
("LANG=TS", Some("typescript")),
("type=module", Some("javascript")),
("TYPE=' text/JAVASCRIPT '", Some("javascript")),
("type=application/javascript", Some("javascript")),
("type=application/json", None),
("type='application/ld+json'", None),
("type=text/template", None),
("type=importmap", None),
("type=speculationrules", None),
("lang=coffee", None),
("lang=ts type=application/json", None),
("src='./external.ts' lang=ts", None),
("SRC=external.js", None),
("src", None),
] {
let src = format!("<script {attributes}>const value = 1;</script>");
let regions = syntax().script_regions(&lang(language), &src);
assert_eq!(regions.len(), usize::from(expected.is_some()), "{language}: {attributes}");
if let Some(expected) = expected {
assert_eq!(regions[0].language.as_str(), expected, "{attributes}");
assert_eq!(regions[0].text, "const value = 1;");
assert_eq!(&src[regions[0].base..regions[0].base + regions[0].text.len()], regions[0].text);
}
}
}
}
#[test]
fn script_regions_preserve_document_offsets_and_line_endings() {
use super::lines::LineIndex;
let src = "<template>中文😀</template>\r\n<script lang=ts>const 名 = '😀';\r\n</script>\r\n<script setup lang=jsx>const View = () => <div/>;</script>";
let regions = syntax().script_regions(&lang("vue"), src);
assert_eq!(regions.len(), 2);
let idx = LineIndex::new(src);
let doc = ide_text::Document::new(src);
for (region, (body, pos)) in
regions.iter().zip([("const 名 = '😀';\r\n", Pos::new(1, 16)), ("const View = () => <div/>;", Pos::new(3, 23))])
{
assert_eq!(region.base, src.find(body).unwrap());
assert_eq!(region.text, body);
assert_eq!(idx.pos(region.base), pos);
assert_eq!(idx.pos(region.base), doc.byte_to_pos(src[..region.base].replace("\r\n", "\n").len()));
assert_eq!(
idx.pos(region.base + body.len()),
doc.byte_to_pos(src[..region.base + body.len()].replace("\r\n", "\n").len())
);
if let Some(name) = body.find('名') {
assert_eq!(
idx.range(region.base + name, region.base + name + '名'.len_utf8()),
Range::new(Pos::new(1, 22), Pos::new(1, 23))
);
}
}
let same_line = "<template>中文😀</template><script>let x = 1;</script>";
let region = &syntax().script_regions(&lang("html"), same_line)[0];
assert_eq!(LineIndex::new(same_line).pos(region.base), Pos::new(0, 33));
}
#[test]
fn script_regions_tolerate_incomplete_markup() {
for src in [
"<script",
"<script lang=",
"<script lang='ts",
"<script>const unfinished =",
"<template><div><script lang=ts>const x = 1;</script>",
"<script></script>",
"<script src='missing.js'>ignored</script><script>const x = 1;",
] {
for region in syntax().script_regions(&lang("vue"), src) {
assert_eq!(src.get(region.base..region.base + region.text.len()), Some(region.text.as_str()));
}
}
}
#[test]
fn script_regions_large_vue_document() {
for count in [2_000, 4_000, 8_000] {
let src =
"<template><div>中文😀</div></template>\r\n<script setup lang=ts>const x = 1;</script>\r\n".repeat(count);
let start = std::time::Instant::now();
let regions = syntax().script_regions(&lang("vue"), &src);
eprintln!("script regions: {} bytes, {count} blocks: {:?}", src.len(), start.elapsed());
assert_eq!(regions.len(), count);
assert!(regions.windows(2).all(|pair| pair[0].base < pair[1].base));
}
}
#[test]
fn script_regions_tolerate_deep_markup() {
let src = "<div>".repeat(1_000) + "<script>const x = 1;</script>";
let regions = syntax().script_regions(&lang("html"), &src);
assert_eq!(regions.len(), 1);
assert_eq!(regions[0].base, src.find("const").unwrap());
}
// ---------------------------------------------------------------------------
// Outline with nested <script> declarations (Vue SFC / HTML)
#[test]
fn html_outline_nests_script_declarations() {
let src = "<html><body>\n<script>\nfunction boot() {}\nconst app = 1;\n</script>\n\
<script type=\"application/json\">{\"a\": 1}</script>\n</body></html>";
check_outline(
"html",
src,
"
Field html
Field body
Field script
Function boot
Constant app
Field script
",
);
}
#[test]
fn vue_outline_nests_script_declarations_without_a_grammar() {
let src = "<template>\n <div class=\"app\"/>\n</template>\n\
<script setup lang=\"ts\">\ninterface Props { size: number }\nfunction scale(n: number) {}\n</script>\n\
<style scoped>\n.app { color: red }\n</style>";
check_outline(
"vue",
src,
"
Field template
Field script
Interface Props
Property size
Function scale
Field style
",
);
}
#[test]
fn script_outline_positions_are_document_coordinates() {
use super::lines::LineIndex;
let src = "<template><!-- 😀 --></template>\r\n<script lang=\"ts\">\r\nconst answer = 42;\r\n</script>";
let outline = syntax().outline(&lang("vue"), src);
let decl = &outline.iter().find(|i| i.name == "script").unwrap().children[0];
// Cross-check against the document's own byte -> UTF-16 conversion.
let idx = LineIndex::new(src);
let at = src.find("answer").unwrap();
assert_eq!(decl.selection_range, idx.range(at, at + "answer".len()));
// `@def.constant` captures the declarator (`answer = 42`), not the `const` keyword.
assert_eq!(decl.range, idx.range(at, src.find("42;").unwrap() + 2));
}
#[test]
fn cached_outline_matches_syntax_outline_for_web_documents() {
use ide_core::DocId;
use std::sync::Arc;
let cache = SyntaxCache::new(Arc::new(Syntax::new()));
let id = DocId::next();
let src = "<template><div/></template>\n<script lang=\"ts\">\nconst app = 1;\n</script>\n";
cache.open(id, &lang("vue"), 0);
assert_eq!(cache.outline(id, &lang("vue"), 0, src), syntax().outline(&lang("vue"), src));
cache.open(id, &lang("html"), 0);
assert_eq!(cache.outline(id, &lang("html"), 0, src), syntax().outline(&lang("html"), src));
}
#[test]
fn script_outlines_compose_positions_across_multiple_blocks() {
use super::lines::LineIndex;
let src = "<template>中文😀</template><script lang=ts>/* 中文😀 */ function first() { return 1; }</script>\r\n<script setup lang=ts>/* 中文😀 */ const second = 2;\r\nfunction third() {}\r\n</script>";
let lines = LineIndex::new(src);
for id in ["html", "vue", "svelte", "jsp"] {
let items = syntax().outline(&lang(id), src);
let scripts: Vec<_> = items.iter().filter(|item| item.name == "script").collect();
assert_eq!(scripts.len(), 2, "{id}");
assert_eq!(scripts[0].children.len(), 1);
assert_eq!(scripts[1].children.len(), 2);
for (item, name) in scripts.iter().flat_map(|s| &s.children).zip(["first", "second", "third"]) {
let byte = src.find(name).unwrap();
assert_eq!(item.name, name);
assert_eq!(item.selection_range, lines.range(byte, byte + name.len()), "{id}: {name}");
}
}
}
#[test]
fn script_outline_attaches_to_case_insensitive_html_tags() {
check_outline("html", "<SCRIPT>function boot() {}</SCRIPT>", "Field SCRIPT\n Function boot");
}
#[test]
fn outline_restructuredtext_levels_follow_first_use_of_each_adornment() {
let src = "\
=========
Project
=========
Install
~~~~~~~
From source
-----------
Literal block::
Not a title
-----------
Intro
--
A paragraph
with a line
------------
.. comment
-----------
Usage
~~~~~
.. note::
Indented
--------
Short underline, still a title
------
Underline only
==============
";
check_outline(
"restructuredtext",
src,
"
Key Project
Key Install
Key From source
Key Usage
Key Short underline, still a title
Key Underline only
",
);
let items = syntax().outline(&lang("restructuredtext"), src);
let project = &items[0];
assert_eq!(project.range.start, Pos::new(0, 0));
assert_eq!((project.selection_range.start, project.selection_range.end), (Pos::new(1, 1), Pos::new(1, 8)));
let install = &project.children[0];
assert_eq!((install.range.start, install.range.end), (Pos::new(4, 0), Pos::new(23, 11)));
assert!(syntax().outline(&lang("restructuredtext"), "Title\n\n").is_empty());
}
#[test]
fn outline_restructuredtext_unicode_crlf_and_cached_edits() {
use ide_core::DocId;
use std::sync::Arc;
let src = "=========\r\n 中文😀\r\n=========\r\n\r\n子章节\r\n------\r\n\r\nBody\r\n\r\nOther\r\n------\r\n";
let id = DocId::next();
let language = lang("restructuredtext");
let cache = SyntaxCache::new(Arc::new(Syntax::new()));
cache.open(id, &language, 0);
let items = cache.outline(id, &language, 0, src);
assert_eq!(items, syntax().outline(&language, src));
assert_eq!(items[0].name, "中文😀");
assert_eq!(items[0].selection_range, Range::new(Pos::new(1, 1), Pos::new(1, 5)));
assert_eq!(items[0].children[0].name, "子章节");
assert_eq!(items[0].children[0].selection_range, Range::new(Pos::new(4, 0), Pos::new(4, 3)));
assert_eq!(items[0].children[0].range.end, Pos::new(7, 4));
assert_eq!(items[0].children[1].name, "Other");
let edited = src.replace("子章节", "新章节😀");
let updated = cache.outline(id, &language, 1, &edited);
assert_eq!(updated, syntax().outline(&language, &edited));
assert_eq!(updated[0].children[0].selection_range.end, Pos::new(4, 5));
assert_ne!(items, updated);
}
+14 -7
View File
@@ -81,13 +81,20 @@ fn css_regions<'t>(grammar: &str, tree: &Tree, text: &'t str) -> Vec<(usize, &'t
}
/// Pre-order walk; `f` returns whether to descend.
fn walk<'t>(n: Node<'t>, f: &mut dyn FnMut(Node<'t>) -> bool) {
if !f(n) {
return;
}
let mut c = n.walk();
for child in n.children(&mut c) {
walk(child, f);
pub(crate) fn walk<'t>(n: Node<'t>, f: &mut dyn FnMut(Node<'t>) -> bool) {
let mut cursor = n.walk();
loop {
if f(cursor.node()) && cursor.goto_first_child() {
continue;
}
loop {
if cursor.goto_next_sibling() {
break;
}
if !cursor.goto_parent() {
return;
}
}
}
}
+210
View File
@@ -0,0 +1,210 @@
//! Arduino sketches need their own board context, including their C/C++ sources.
use crate::LspManager;
use crate::server::Server;
use ide_core::{LanguageId, Location};
use serde_json::Value;
use std::path::{Path, PathBuf};
use std::sync::Arc;
pub(crate) const SERVER: &str = "arduino-language-server";
pub(crate) fn sketch_root(project: &Path, file: &Path) -> Option<PathBuf> {
ide_language::arduino_sketch_root(project, file)
}
pub(crate) fn configure_client_capabilities(capabilities: &mut Value) {
// Arduino's proxy cannot decode the boolean full form, panics on inlayHint (#225),
// and cannot handle clangd's semanticTokens refresh request (#125).
for section in ["textDocument", "workspace"] {
if let Some(section) = capabilities[section].as_object_mut() {
section.remove("semanticTokens");
section.remove("inlayHint");
}
}
// ALS uses go.bug.st/lsp (LSP 3.14) which requires a `range` field on `lsp.TextEdit`.
// Clangd returns `InsertReplaceEdit` (`insert` + `replace`) when `insertReplaceSupport` is advertised,
// causing ALS to drop the connection with "json: undefined required field range into lsp.TextEdit".
if let Some(completion) = capabilities.get_mut("textDocument").and_then(|t| t.get_mut("completion"))
&& let Some(item) = completion.get_mut("completionItem").and_then(|c| c.as_object_mut())
{
item.remove("insertReplaceSupport");
}
}
pub(crate) fn sanitize_server_capabilities(capabilities: &mut Value) {
// Arduino Language Server's request dispatcher advertises documentSymbol and
// workspaceSymbol, but panics with "not implemented" / "unimplemented" when called.
// Tree-sitter handles C++ outline/symbols in OpenIDEA without hitting ALS.
if let Some(obj) = capabilities.as_object_mut() {
obj.remove("documentSymbolProvider");
obj.remove("workspaceSymbolProvider");
}
}
impl LspManager {
pub(crate) fn arduino_servers(&self, language: &LanguageId, path: &Path) -> Option<Vec<Arc<Server>>> {
if !matches!(language.as_str(), "c" | "cpp") {
return None;
}
let configs = self.language_servers(&LanguageId::new("arduino"));
let cfg = configs.iter().find(|cfg| cfg.name == SERVER)?;
let project = self.project_root_for(path);
if sketch_root(&project, path).is_some() {
let server = self.server_for(cfg, &LanguageId::new("arduino"), path);
server.add_language(language);
let mut servers = vec![server];
servers.extend(
self.language_servers(language)
.iter()
.filter(|cfg| cfg.name != "clangd" && cfg.name != SERVER)
.filter(|cfg| {
!cfg.require_root_marker
|| crate::launch::marked_root(&project, path, &cfg.root_markers).is_some()
})
.map(|cfg| self.server_for(cfg, language, path)),
);
return Some(servers);
}
if !path.starts_with(&project)
&& let Some(server) = self.origin_server(&crate::uri::path_to_uri_string(path))
&& server.name == SERVER
{
return Some(vec![server]);
}
None
}
pub(crate) fn note_arduino_sources(&self, server: &Arc<Server>, locations: &[Location]) {
if server.name != SERVER {
return;
}
let mut origins = None;
for location in locations {
let path: &Path = &location.path;
if location.uri.is_none() && !path.starts_with(self.project_root_for(path)) {
origins
.get_or_insert_with(|| self.virtual_origins.lock())
.insert(crate::uri::path_to_uri_string(path), server.id);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bundled_server_allows_diagnostics() {
let registry = ide_language::LanguageRegistry::load(None).unwrap();
let language = registry.get(&LanguageId::new("arduino")).unwrap();
let cfg = language.language_servers.iter().find(|cfg| cfg.name == SERVER).unwrap();
assert!(crate::caps::Restriction::new(SERVER, &cfg.features, &cfg.code_action_kinds).diagnostics());
}
#[tokio::test]
async fn inspection_checks_the_sketch_server_and_root_for_all_sources() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let sketch = root.join("examples/blink");
std::fs::create_dir_all(&sketch).unwrap();
std::fs::write(sketch.join("blink.ino"), "").unwrap();
let registry = Arc::new(ide_language::LanguageRegistry::load(None).unwrap());
let manager = LspManager::new(root.to_path_buf(), registry, Arc::new(|_: ide_core::IdeEvent| {}));
let expected_root = sketch.clone();
manager.set_config_hook(Box::new(move |cfg, server_root| {
cfg.command = std::env::current_exe().unwrap().to_string_lossy().into_owned();
if cfg.name != SERVER || server_root != expected_root {
cfg.launch_error = Some("unavailable".into());
}
}));
for name in ["blink.ino", "helper.cpp", "helper.h"] {
assert!(manager.serves_path(&sketch.join(name)), "{name}");
}
assert!(!manager.serves_path(&root.join("main.cpp")));
}
#[test]
fn nested_sketches_keep_separate_board_roots() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let inner = root.join("examples/blink");
std::fs::create_dir_all(inner.join("src")).unwrap();
std::fs::write(root.join("main.ino"), "").unwrap();
std::fs::write(inner.join("blink.ino"), "").unwrap();
assert_eq!(sketch_root(root, &inner.join("blink.ino")), Some(inner.clone()));
assert_eq!(sketch_root(root, &inner.join("src/helper.cpp")), Some(inner));
assert_eq!(sketch_root(root, &root.join("helper.h")), Some(root.to_path_buf()));
assert_eq!(sketch_root(&root.join("examples"), &root.join("other.cpp")), None);
}
#[test]
fn ordinary_cpp_projects_and_ino_named_directories_are_not_sketches() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join("not-a-file.ino")).unwrap();
assert_eq!(sketch_root(dir.path(), &dir.path().join("main.cpp")), None);
}
#[tokio::test]
async fn cpp_routing_is_scoped_to_sketches_and_respects_disabled_arduino() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let sketch = root.join("examples/blink");
std::fs::create_dir_all(&sketch).unwrap();
std::fs::write(sketch.join("blink.ino"), "").unwrap();
let registry = Arc::new(ide_language::LanguageRegistry::load(None).unwrap());
let manager = LspManager::new(root.to_path_buf(), registry, Arc::new(|_: ide_core::IdeEvent| {}));
let cpp = LanguageId::new("cpp");
let sketch_servers = manager.servers_for(&cpp, &sketch.join("helper.cpp"));
assert_eq!(sketch_servers.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(), [SERVER]);
assert_eq!(sketch_servers[0].root, sketch);
assert!(sketch_servers[0].languages().contains(&LanguageId::new("arduino")));
assert_eq!(manager.servers_for(&cpp, &root.join("main.cpp"))[0].name, "clangd");
manager.set_servers_hook(Box::new(
|language, defined| {
if language.as_str() == "arduino" { Vec::new() } else { defined.to_vec() }
},
));
assert_eq!(manager.servers_for(&cpp, &sketch.join("helper.cpp"))[0].name, "clangd");
}
#[test]
fn capabilities_are_sanitized_for_arduino_proxy_quirks() {
let mut client = serde_json::json!({
"textDocument": {
"semanticTokens": { "dynamicRegistration": false },
"inlayHint": { "dynamicRegistration": false },
"completion": {
"completionItem": {
"snippetSupport": true,
"insertReplaceSupport": true
}
}
},
"workspace": {
"semanticTokens": { "refreshSupport": true },
"inlayHint": { "refreshSupport": true }
}
});
configure_client_capabilities(&mut client);
assert!(client["textDocument"].get("semanticTokens").is_none());
assert!(client["textDocument"].get("inlayHint").is_none());
assert!(client["textDocument"]["completion"]["completionItem"].get("insertReplaceSupport").is_none());
assert_eq!(client["textDocument"]["completion"]["completionItem"]["snippetSupport"], true);
assert!(client["workspace"].get("semanticTokens").is_none());
assert!(client["workspace"].get("inlayHint").is_none());
let mut server = serde_json::json!({
"documentSymbolProvider": {},
"workspaceSymbolProvider": {},
"completionProvider": { "triggerCharacters": ["."] },
"hoverProvider": true
});
sanitize_server_capabilities(&mut server);
assert!(server.get("documentSymbolProvider").is_none());
assert!(server.get("workspaceSymbolProvider").is_none());
assert!(server.get("completionProvider").is_some());
assert!(server.get("hoverProvider").is_some());
}
}
+96 -7
View File
@@ -301,6 +301,7 @@ pub fn completion_item(conv: &Conv, path: &Path, item: lsp::CompletionItem, serv
let mut out = CompletionItem {
label: item.label.clone(),
label_detail: None,
label_description: None,
detail: None,
kind: None,
documentation: None,
@@ -349,6 +350,9 @@ pub fn overlay_completion(conv: &Conv, path: &Path, out: &mut CompletionItem, it
if let Some(d) = label_details.and_then(|d| d.detail) {
out.label_detail = Some(d);
}
if let Some(d) = description.clone() {
out.label_description = Some(d);
}
if let Some(d) = detail.or(description) {
out.detail = Some(d);
}
@@ -425,10 +429,12 @@ pub fn lsp_completion_item(text: &DocText, enc: Encoding, item: &CompletionItem)
});
lsp::CompletionItem {
label: item.label.clone(),
label_details: item
.label_detail
.clone()
.map(|detail| lsp::CompletionItemLabelDetails { detail: Some(detail), description: None }),
label_details: (item.label_detail.is_some() || item.label_description.is_some()).then(|| {
lsp::CompletionItemLabelDetails {
detail: item.label_detail.clone(),
description: item.label_description.clone(),
}
}),
kind: item.kind.map(lsp_completion_kind),
detail: item.detail.clone(),
documentation: item.documentation.as_ref().map(lsp_documentation),
@@ -562,10 +568,18 @@ pub fn lsp_command(c: &Command) -> lsp::Command {
}
}
pub fn code_action(conv: &Conv, a: lsp::CodeActionOrCommand, server: ServerId) -> CodeAction {
pub fn code_action(
conv: &Conv,
path: &Path,
a: lsp::CodeActionOrCommand,
server: ServerId,
server_name: &str,
) -> CodeAction {
match a {
lsp::CodeActionOrCommand::Command(c) => CodeAction {
title: c.title.clone(),
diagnostics: Vec::new(),
original_diagnostics: None,
kind: None,
is_preferred: false,
disabled_reason: None,
@@ -576,6 +590,13 @@ pub fn code_action(conv: &Conv, a: lsp::CodeActionOrCommand, server: ServerId) -
},
lsp::CodeActionOrCommand::CodeAction(a) => CodeAction {
title: a.title,
original_diagnostics: a.diagnostics.as_ref().and_then(|d| serde_json::to_string(d).ok()),
diagnostics: a
.diagnostics
.unwrap_or_default()
.into_iter()
.map(|d| diagnostic(conv, path, d, server_name))
.collect(),
kind: a.kind.map(|k| k.as_str().to_owned()),
is_preferred: a.is_preferred.unwrap_or(false),
disabled_reason: a.disabled.map(|d| d.reason),
@@ -591,7 +612,7 @@ pub fn lsp_code_action(a: &CodeAction) -> lsp::CodeAction {
lsp::CodeAction {
title: a.title.clone(),
kind: a.kind.clone().map(lsp::CodeActionKind::from),
diagnostics: None,
diagnostics: a.original_diagnostics.as_deref().and_then(|d| serde_json::from_str(d).ok()),
edit: None,
command: a.command.as_ref().map(lsp_command),
is_preferred: a.is_preferred.then_some(true),
@@ -1067,6 +1088,66 @@ mod tests {
use super::*;
use serde_json::json;
#[test]
fn code_action_diagnostics_use_ide_coordinates_and_round_trip_for_resolve() {
let path = Path::new("/go.mod");
let conv = Conv::new(Encoding::Utf8, |_| Some(Arc::new(DocText::new("😀 dependency"))));
let raw: lsp::Diagnostic = serde_json::from_value(json!({
"range": { "start": { "line": 0, "character": 5 }, "end": { "line": 0, "character": 15 } },
"message": "github.com/sergi/go-diff is not used in this module",
"severity": 2,
"code": 123,
"source": "go mod tidy",
"data": { "fix": "remove" }
}))
.unwrap();
let incoming = lsp::CodeAction {
title: "Remove dependency: github.com/sergi/go-diff".into(),
diagnostics: Some(vec![raw.clone()]),
..Default::default()
};
let action = code_action(&conv, path, lsp::CodeActionOrCommand::CodeAction(incoming), ServerId(1), "gopls");
assert_eq!(action.diagnostics, vec![diagnostic(&conv, path, raw.clone(), "gopls")]);
assert_eq!(action.diagnostics[0].range, Range::new(Pos::new(0, 3), Pos::new(0, 13)));
assert_eq!(action.diagnostics[0].code.as_deref(), Some("123"));
let action: CodeAction = serde_json::from_value(serde_json::to_value(action).unwrap()).unwrap();
assert_eq!(lsp_code_action(&action).diagnostics, Some(vec![raw.clone()]));
let resolved = merge_code_action(&conv, action, lsp::CodeAction::default());
assert_eq!(lsp_code_action(&resolved).diagnostics, Some(vec![raw]));
assert_eq!(resolved.diagnostics.len(), 1);
}
#[test]
fn code_actions_without_diagnostics_keep_them_absent_for_resolve() {
let conv = Conv::utf16();
for incoming in [
lsp::CodeActionOrCommand::CodeAction(lsp::CodeAction { title: "Fix".into(), ..Default::default() }),
lsp::CodeActionOrCommand::Command(lsp::Command {
title: "Fix".into(),
command: "fix".into(),
arguments: None,
}),
] {
let action = code_action(&conv, Path::new("/a"), incoming, ServerId(1), "srv");
assert!(action.diagnostics.is_empty());
assert!(lsp_code_action(&action).diagnostics.is_none());
}
let raw = lsp::Diagnostic::new_simple(lsp::Range::default(), "problem".into());
let expected = diagnostic(&conv, Path::new("/a"), raw.clone(), "srv");
let action = code_action(
&conv,
Path::new("/a"),
lsp::CodeActionOrCommand::CodeAction(lsp::CodeAction {
diagnostics: Some(vec![raw]),
..Default::default()
}),
ServerId(1),
"srv",
);
assert_eq!(action.diagnostics, vec![expected]);
assert_eq!(action.diagnostics[0].source.as_deref(), Some("srv"));
}
#[test]
fn merged_rename_edits_keep_the_first_servers_ones() {
let edit = |l: u32, c: u32, text: &str| TextEdit {
@@ -1209,7 +1290,8 @@ mod tests {
assert_eq!(a.insert_text, "println!($0)");
assert!(a.is_snippet);
assert_eq!(a.label_detail.as_deref(), Some("(…)"));
assert_eq!(a.detail.as_deref(), Some("std"));
assert_eq!(a.label_description.as_deref(), Some("std"));
assert_eq!(a.detail.as_deref(), Some("std"), "no `detail` from the server: the description stands in");
assert_eq!(a.kind, Some(CompletionKind::Function));
assert_eq!(a.replace_range, Some(Range::new(p(0, 4), p(0, 6))));
assert_eq!(a.replace_range_tab, Some(Range::new(p(0, 4), p(0, 9))));
@@ -1225,6 +1307,9 @@ mod tests {
let back = lsp_completion_item(&text, Encoding::Utf16, &a);
assert_eq!(back.data, Some(json!({"k": 1})));
assert!(matches!(back.text_edit, Some(lsp::CompletionTextEdit::InsertAndReplace(_))));
let details = back.label_details.expect("label details survive the resolve round trip");
assert_eq!(details.detail.as_deref(), Some("(…)"));
assert_eq!(details.description.as_deref(), Some("std"));
}
#[test]
@@ -1255,6 +1340,10 @@ mod tests {
let item = completion_item(&Conv::utf16(), Path::new("/Main.java"), items[0].clone(), ServerId(1));
assert_eq!(item.replace_range, Some(Range::new(p(2, 19), p(2, end))));
assert_eq!(item.replace_range_tab, None);
// The list shows `print (Object obj)` and `void`; the long signature is only for the docs panel.
assert_eq!(item.label_detail.as_deref(), Some("(Object obj)"));
assert_eq!(item.label_description.as_deref(), Some("void"));
assert_eq!(item.detail.as_deref(), Some("PrintStream.print(Object obj) : void"));
assert_eq!(item.filter_text.as_deref(), Some("print(Object obj) : void"));
assert_eq!(item.insert_text, "print(${0});");
assert!(item.is_snippet);
+6
View File
@@ -268,6 +268,12 @@ impl Handler for ClientHandler {
"window/showMessage" => self.show_message(&mgr, &params),
"window/logMessage" => {
let text = params.get("message").and_then(Value::as_str).unwrap_or_default();
if self.server.name == "jdtls"
&& text.contains("Can't use Java ")
&& text.contains(" to import Gradle project ")
{
self.show_message(&mgr, &json!({ "type": 1, "message": text.lines().next().unwrap_or(text) }));
}
match params.get("type").and_then(Value::as_u64) {
Some(1) if self.server.name == "jdtls" && text.contains(crate::jdtls::reserved_names::IMPORT_FAILURE) => {
tracing::warn!("[{}] {text}", self.server.name);
+11
View File
@@ -63,6 +63,17 @@ impl LspManager {
/// run doesn't prompt to install every language it meets.
pub fn serves_path(&self, path: &Path) -> bool {
let lang = self.languages.detect(path, None);
let arduino = if lang.as_str() == "arduino" {
Some(self.servers_for(&lang, path))
} else {
self.arduino_servers(&lang, path)
};
if let Some(servers) = arduino {
return servers.iter().any(|server| {
let cfg = self.configured(server);
cfg.launch_error.is_none() && self.launcher.available(&cfg, &server.root)
});
}
self.language_servers(&lang).iter().any(|ls| {
if ls.require_root_marker
&& crate::launch::marked_root(&self.project_root, path, &ls.root_markers).is_none()
+48 -20
View File
@@ -627,7 +627,11 @@ impl LspManager {
.docs
.lock()
.values()
.filter(|d| d.language.as_str() == "java" && d.path.starts_with(&server.root))
.filter(|d| {
d.language.as_str() == "java"
&& d.path.is_absolute()
&& d.servers.iter().any(|a| a.server.id == server.id)
})
.map(|d| crate::uri::path_to_uri_string(&d.path))
.collect();
if uris.is_empty() {
@@ -652,26 +656,50 @@ impl LspManager {
/// started if needed and waited for.
async fn jdtls(&self, path: &Path, cancel: &CancellationToken) -> IdeResult<(Arc<Server>, Running)> {
let java = LanguageId::new("java");
let server = self
.servers_for(&java, path)
.into_iter()
.find(|s| s.name == SERVER)
.ok_or_else(|| IdeError::unavailable("no Java language server (jdtls) is configured"))?;
self.ensure_started(&server);
match cancellable_wait(cancel, server.wait_ready(READY_TIMEOUT)).await? {
Some(run) => Ok((server, run)),
None => {
let status = server.status();
Err(IdeError::unavailable(match status {
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::Crashed => {
server.status_message().unwrap_or_else(|| "jdtls cannot start".to_owned())
}
_ => format!("jdtls is not ready ({status:?})"),
}))
let deadline = tokio::time::Instant::now() + READY_TIMEOUT;
loop {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let server = self
.servers_for(&java, path)
.into_iter()
.find(|s| s.name == SERVER)
.ok_or_else(|| IdeError::unavailable("no Java language server (jdtls) is configured"))?;
self.ensure_started(&server);
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
if let Some(run) = cancellable_wait(cancel, server.wait_ready(remaining)).await? {
return Ok((server, run));
}
let status = server.status();
if !matches!(
status,
ide_core::event::ServerStatus::Missing
| ide_core::event::ServerStatus::Misconfigured
| ide_core::event::ServerStatus::Crashed
) && tokio::time::Instant::now() < deadline
&& let Ok(_restart) =
cancellable_wait(cancel, tokio::time::timeout_at(deadline, self.reconfigure_lock.lock())).await?
{
// A restart publishes Down while reaping its old JVM. Its successor may
// also have a new identity after changing the shared workspace.
if let Some(current) = self.servers_for(&java, path).into_iter().find(|s| s.name == SERVER)
&& current.root.is_dir()
&& (current.id != server.id || current.is_starting() || current.running().is_some())
{
continue;
}
}
let status = server.status();
return Err(IdeError::unavailable(match status {
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::Crashed => {
server.status_message().unwrap_or_else(|| "jdtls cannot start".to_owned())
}
_ => format!("jdtls is not ready ({status:?})"),
}));
}
}
+257 -47
View File
@@ -7,6 +7,7 @@
//! never `lsp-types`, so the IPC surface stays stable.
mod angular;
mod arduino;
mod caps;
mod clang_tidy;
mod convert;
@@ -175,6 +176,20 @@ impl DocSnap {
/// Receives the folders outside the project that servers want watched.
pub type WatchRootsFn = dyn Fn(Vec<PathBuf>) + Send + Sync;
pub type JdtlsSharedFn = dyn Fn() -> bool + Send + Sync;
#[derive(Default)]
struct JdtlsWorkspace {
initialized: bool,
shared: bool,
roots: Vec<PathBuf>,
transitioning: bool,
}
fn is_local_jdtls(config: &LanguageServerConfig) -> bool {
config.name == "jdtls" && config.target_key.is_none() && config.target.is_none()
}
pub struct LspManager {
me: Weak<LspManager>,
project_root: PathBuf,
@@ -195,6 +210,9 @@ pub struct LspManager {
candidates_hook: RwLock<Option<Arc<CandidateFn>>>,
excludes_hook: RwLock<Option<Arc<external_project::ExcludesFn>>>,
watch_roots_hook: RwLock<Option<Arc<WatchRootsFn>>>,
jdtls_shared_hook: RwLock<Option<Arc<JdtlsSharedFn>>>,
jdtls_workspace: Mutex<JdtlsWorkspace>,
reconfigure_lock: tokio::sync::Mutex<()>,
shutting_down: AtomicBool,
execution_allowed: AtomicBool,
java_usages_warned: AtomicBool,
@@ -402,6 +420,9 @@ impl LspManager {
candidates_hook: RwLock::new(None),
excludes_hook: RwLock::new(None),
watch_roots_hook: RwLock::new(None),
jdtls_shared_hook: RwLock::new(None),
jdtls_workspace: Mutex::new(Default::default()),
reconfigure_lock: tokio::sync::Mutex::new(()),
shutting_down: AtomicBool::new(false),
execution_allowed: AtomicBool::new(true),
java_usages_warned: AtomicBool::new(false),
@@ -416,7 +437,56 @@ impl LspManager {
// ---- server lookup & lifecycle ----
pub fn set_content_roots(&self, roots: Vec<PathBuf>) {
*self.content_roots.write() = roots;
let changed = {
let mut current = self.content_roots.write();
let changed = *current != roots;
*current = roots;
changed
};
if changed
&& self.desired_jdtls_workspace().0
&& let Some(me) = self.me.upgrade()
{
me.reconfigure_soon();
}
}
/// Shares JDT LS within this manager; callers reconfigure after the hook's value changes.
pub fn set_jdtls_shared_hook(&self, hook: Box<JdtlsSharedFn>) {
*self.jdtls_shared_hook.write() = Some(Arc::from(hook));
}
fn desired_jdtls_workspace(&self) -> (bool, Vec<PathBuf>) {
let shared = self.jdtls_shared_hook.read().as_ref().is_some_and(|hook| hook());
let mut roots = Vec::new();
if shared {
roots.push(self.project_root.clone());
for root in self.content_roots.read().iter() {
if root.is_absolute() && root.is_dir() && !roots.contains(root) {
roots.push(root.clone());
}
}
}
(shared, roots)
}
fn jdtls_workspace_state(&self) -> parking_lot::MutexGuard<'_, JdtlsWorkspace> {
let mut state = self.jdtls_workspace.lock();
if !state.initialized {
(state.shared, state.roots) = self.desired_jdtls_workspace();
state.initialized = true;
}
state
}
fn server_workspace_roots(&self, server: &Server) -> Vec<PathBuf> {
if is_local_jdtls(&server.config) {
let state = self.jdtls_workspace_state();
if state.shared {
return state.roots.clone();
}
}
vec![server.root.clone()]
}
fn project_root_for(&self, path: &Path) -> PathBuf {
@@ -431,16 +501,23 @@ impl LspManager {
}
fn server_for(&self, cfg: &LanguageServerConfig, language: &LanguageId, path: &Path) -> Arc<Server> {
let shared = is_local_jdtls(cfg).then(|| self.jdtls_workspace_state());
let mut servers = self.servers.lock();
let root = launch::find_root(&self.project_root_for(path), path, &cfg.root_markers).unwrap_or_else(|| {
// Outside the project (library sources, …): reuse a running instance.
servers
.iter()
.filter(|s| s.name == cfg.name && s.config.target_key == cfg.target_key)
.min_by_key(|s| s.root != self.project_root)
.map(|s| s.root.clone())
.unwrap_or_else(|| self.project_root.clone())
});
let project = self.project_root_for(path);
let sketch = (cfg.name == arduino::SERVER).then(|| arduino::sketch_root(&project, path)).flatten();
let root = if shared.as_ref().is_some_and(|state| state.shared) {
self.project_root.clone()
} else {
sketch.or_else(|| launch::find_root(&project, path, &cfg.root_markers)).unwrap_or_else(|| {
// Outside the project (library sources, …): reuse a running instance.
servers
.iter()
.filter(|s| s.name == cfg.name && s.config.target_key == cfg.target_key)
.min_by_key(|s| s.root != self.project_root)
.map(|s| s.root.clone())
.unwrap_or_else(|| self.project_root.clone())
})
};
if let Some(s) =
servers.iter().find(|s| s.name == cfg.name && s.root == root && s.config.target_key == cfg.target_key)
{
@@ -456,6 +533,9 @@ impl LspManager {
/// only while all servers listed before it are missing. One that requires a
/// root marker serves only files below one, and stands in for no other.
fn servers_for(&self, language: &LanguageId, path: &Path) -> Vec<Arc<Server>> {
if let Some(servers) = self.arduino_servers(language, path) {
return servers;
}
let mut out = Vec::new();
let mut available_before = false;
for sc in &self.language_servers(language) {
@@ -490,6 +570,7 @@ impl LspManager {
/// to the open documents (and start them). Also attaches servers newly
/// enabled in settings.
fn attach_fallbacks(&self) {
let _gate = self.injections.gate.lock();
let open: Vec<(DocId, LanguageId, PathBuf)> = self
.docs
.lock()
@@ -538,6 +619,27 @@ impl LspManager {
if self.shutting_down.load(Ordering::Acquire) || !self.execution_allowed.load(Ordering::Acquire) {
return;
}
let workspace = is_local_jdtls(&server.config).then(|| self.jdtls_workspace_state());
if let Some(state) = &workspace {
if state.transitioning || !self.servers.lock().iter().any(|s| s.id == server.id) {
return;
}
let (shared, roots) = self.desired_jdtls_workspace();
if state.shared != shared || (shared && state.roots != roots) {
if let Some(me) = self.me.upgrade() {
me.reconfigure_soon();
}
return;
}
}
let startup = if is_local_jdtls(&server.config) {
match server.startup_lock.clone().try_lock_owned() {
Ok(guard) => Some(guard),
Err(_) => return,
}
} else {
None
};
// `begin_start` only claims Idle servers, so a claimed server is never a
// live one. Its root is the process's working directory: starting with
// one deleted from disk fails (`os error 267` on Windows). Such a
@@ -553,7 +655,10 @@ impl LspManager {
return;
}
let server = server.clone();
self.runtime.spawn(async move { me.run_instance(server, generation).await });
self.runtime.spawn(async move {
let _startup = startup;
me.run_instance(server, generation).await;
});
}
async fn run_instance(self: Arc<Self>, server: Arc<Server>, generation: u64) {
@@ -561,6 +666,7 @@ impl LspManager {
let handler = Arc::new(handler::ClientHandler::new(self.me.clone(), server.clone(), generation));
let me = self.clone();
let tail = server.clone();
let launch_cancel = cancel.clone();
// 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 || {
@@ -568,17 +674,17 @@ impl LspManager {
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() {
if !me.execution_allowed.load(Ordering::Acquire) || launch_cancel.is_cancelled() {
return Err(LaunchError::Config("Project is in Restricted Mode".into()));
}
tail.set_effective_config(generation, cfg.clone());
let prepare = me.prepare_hook.read().clone();
if cfg.target.is_none()
&& !cancel.is_cancelled()
&& !launch_cancel.is_cancelled()
&& me.launcher.available(&cfg, &tail.root)
&& let Some(prepare) = prepare
{
prepare(&cfg, &tail.root, &cancel);
prepare(&cfg, &tail.root, &launch_cancel);
cfg = me.configured(&tail);
}
tail.set_effective_config(generation, cfg.clone());
@@ -588,7 +694,7 @@ impl LspManager {
let missing = cfg.target.is_none() && !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() {
if !me.execution_allowed.load(Ordering::Acquire) || launch_cancel.is_cancelled() {
return Err(LaunchError::Config("Project is in Restricted Mode".into()));
}
me.launcher.launch_resolved(
@@ -596,7 +702,7 @@ impl LspManager {
&root,
handler,
Box::new(move |line| tail.push_stderr(generation, line)),
&cancel,
&launch_cancel,
)
})
.await
@@ -605,7 +711,7 @@ impl LspManager {
let peer = match launched {
Ok((peer, binary)) => {
if !server.set_resolved_binary(generation, binary) {
peer.kill();
Self::kill_peer(&server, &peer).await;
return;
}
peer
@@ -662,15 +768,24 @@ impl LspManager {
init["initializationOptions"]["triggerFiles"] = json!([uri::path_to_uri_string(&path)]);
}
}
let result = tokio::time::timeout(INITIALIZE_TIMEOUT, peer.request::<_, Value>("initialize", init, None))
.await
.unwrap_or(Err(RpcError::Timeout));
let result = tokio::select! {
biased;
() = cancel.cancelled() => {
Self::kill_peer(&server, &peer).await;
return;
}
result = tokio::time::timeout(INITIALIZE_TIMEOUT, peer.request::<_, Value>("initialize", init, Some(cancel.clone()))) => result.unwrap_or(Err(RpcError::Timeout)),
};
let caps = match result {
Ok(result) => {
if let Some(info) = result.get("serverInfo") {
tracing::info!("{} initialized: {info}", server.name);
}
let mut caps = ServerCaps::parse(result.get("capabilities").cloned().unwrap_or_else(|| json!({})));
let mut caps_val = result.get("capabilities").cloned().unwrap_or_else(|| json!({}));
if server.name == arduino::SERVER {
arduino::sanitize_server_capabilities(&mut caps_val);
}
let mut caps = ServerCaps::parse(caps_val);
let cfg = server.effective_config();
caps.restrict(caps::Restriction::new(&server.name, &cfg.features, &cfg.code_action_kinds));
caps
@@ -681,7 +796,7 @@ impl LspManager {
// The server is exiting: let it, so the crash report has its exit code and last output.
let _ = tokio::time::timeout(Duration::from_secs(3), peer.closed()).await;
}
peer.kill();
Self::kill_peer(&server, &peer).await;
// Treated like a crash so transient failures retry with backoff.
self.spawn_supervisor(server, generation, peer);
return;
@@ -690,7 +805,7 @@ impl LspManager {
let run =
Running { generation, peer: peer.clone(), caps: Arc::new(RwLock::new(caps)), watchers: Default::default() };
if !server.set_initializing(run.clone()) {
peer.kill();
Self::kill_peer(&server, &peer).await;
return;
}
if let Err(e) = peer.notify("initialized", json!({})) {
@@ -709,24 +824,30 @@ impl LspManager {
// Publish readiness and (re)open attached documents atomically w.r.t. did_change.
let mut reopened = Vec::new();
{
let ready = {
let mut docs = self.docs.lock();
if !server.mark_ready(generation, run.clone()) {
drop(docs);
peer.kill();
return;
}
let open_close = run.caps.read().open_close;
for (id, d) in docs.iter_mut() {
let params = d.did_open_params();
for a in d.servers.iter_mut().filter(|a| a.server.id == server.id && a.opened_gen != Some(generation)) {
if open_close && let Err(e) = peer.notify("textDocument/didOpen", params.clone()) {
tracing::debug!("{}: didOpen: {e}", server.name);
false
} else {
let open_close = run.caps.read().open_close;
for (id, d) in docs.iter_mut() {
let params = d.did_open_params();
for a in
d.servers.iter_mut().filter(|a| a.server.id == server.id && a.opened_gen != Some(generation))
{
if open_close && let Err(e) = peer.notify("textDocument/didOpen", params.clone()) {
tracing::debug!("{}: didOpen: {e}", server.name);
}
a.opened_gen = Some(generation);
reopened.push(*id);
}
a.opened_gen = Some(generation);
reopened.push(*id);
}
true
}
};
if !ready {
Self::kill_peer(&server, &peer).await;
return;
}
for doc in reopened {
self.schedule_pull(doc, Some(&server), Duration::ZERO);
@@ -767,7 +888,6 @@ impl LspManager {
fn initialize_params(&self, server: &Server) -> Value {
let root_uri = uri::path_to_uri(&server.root);
let name = server.root.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "root".into());
let cfg = server.effective_config();
let mut init_options =
cfg.initialization_options.as_ref().and_then(|v| serde_json::to_value(v).ok()).unwrap_or(Value::Null);
@@ -776,11 +896,24 @@ impl LspManager {
init_options = json!({});
}
init_options["settings"] = handler::all_settings(&cfg);
let shared = is_local_jdtls(&server.config) && self.jdtls_workspace_state().shared;
if shared {
init_options["workspaceFolders"] = json!(
self.server_workspace_roots(server)
.iter()
.map(|root| uri::path_to_uri_string(root))
.collect::<Vec<_>>()
);
}
let triggers: Vec<_> = self
.docs
.lock()
.values()
.filter(|d| d.language.as_str() == "java" && d.path.starts_with(&server.root))
.filter(|d| {
d.language.as_str() == "java"
&& d.path.is_absolute()
&& d.servers.iter().any(|a| a.server.id == server.id)
})
.map(|d| uri::path_to_uri_string(&d.path))
.collect();
if !triggers.is_empty() && init_options.get("triggerFiles").is_none() {
@@ -794,10 +927,13 @@ impl LspManager {
"locale": "en",
"rootPath": server.root.to_string_lossy(),
"rootUri": root_uri,
"workspaceFolders": [{ "uri": root_uri, "name": name }],
"workspaceFolders": self.workspace_folders(server),
"capabilities": caps::client_capabilities(),
"trace": "off",
});
if server.name == arduino::SERVER {
arduino::configure_client_capabilities(&mut params["capabilities"]);
}
if server.name == "gopls" {
params["capabilities"]["experimental"]["interactiveResolve"] = json!({ "inputTypes": ["string"] });
}
@@ -811,8 +947,16 @@ impl LspManager {
}
pub(crate) fn workspace_folders(&self, server: &Server) -> Value {
let name = server.root.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "root".into());
json!([{ "uri": uri::path_to_uri(&server.root), "name": name }])
json!(
self.server_workspace_roots(server)
.iter()
.map(|root| {
let name =
root.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "root".into());
json!({"uri": uri::path_to_uri(root), "name": name})
})
.collect::<Vec<_>>()
)
}
pub(crate) fn sink(&self) -> &SharedSink {
@@ -862,6 +1006,7 @@ impl LspManager {
/// Opens a read-only virtual document (`jdt://…`) on the server that
/// returned its URI (`didOpen` only: it is never changed or saved).
pub fn did_open_virtual(&self, doc: DocId, uri: &str, language: &LanguageId, version: u32, text: &str) {
let _gate = self.injections.gate.lock();
if self.docs.lock().contains_key(&doc) {
self.close_injections(doc);
self.close_document(doc);
@@ -1428,6 +1573,7 @@ impl LspManager {
let locs = convert::goto_response(&self.conv(t.enc()), r);
let locs = library_sources(snap, pos, locs).await;
self.note_virtual(&t.server, locs.iter().map(|l| l.uri.as_deref()));
self.note_arduino_sources(&t.server, &locs);
let locs = merge_locations(first, locs);
if !locs.is_empty() {
return Ok(locs);
@@ -1656,6 +1802,7 @@ impl LspManager {
.collect();
let locations: Vec<Location> = classified.iter().map(|r| r.location.clone()).collect();
self.note_virtual(&t.server, locations.iter().map(|l| l.uri.as_deref()));
self.note_arduino_sources(&t.server, &locations);
Ok(References { locations, classified, incomplete: widened.incomplete, libraries_skipped })
}
.await;
@@ -1723,6 +1870,7 @@ impl LspManager {
locs.retain(|l| !declarations.contains(l));
}
self.note_virtual(&t.server, locs.iter().map(|l| l.uri.as_deref()));
self.note_arduino_sources(&t.server, &locs);
acc.locations.extend(locs);
acc.incomplete = acc.incomplete.or(w.incomplete);
}
@@ -1926,7 +2074,8 @@ impl LspManager {
.unwrap_or_default()
.into_iter()
.map(|a| {
let mut action = convert::code_action(&conv, a, t.server.id);
let mut action =
convert::code_action(&conv, &snap.path, a, t.server.id, &t.server.name);
self.tag_injected_origin(doc, &mut action.data);
self.tag_injected_command(doc, action.server, &mut action.command);
action
@@ -2450,15 +2599,22 @@ impl LspManager {
.into_iter()
.flat_map(|(paths, change)| paths.iter().map(move |p| (p, change)));
let watchers = run.watchers.read();
let roots = self.server_workspace_roots(&s);
let changes: Vec<(&PathBuf, Change)> = if watchers.is_empty() {
// Dart watches the filesystem itself and rejects this unregistered notification.
if s.name == "dart" {
continue;
}
let languages = s.languages();
all.filter(|(p, change)| {
p.starts_with(&s.root) && languages.contains(&self.languages.detect(p, None)) && send(p, *change)
roots.iter().any(|root| p.starts_with(root))
&& languages.contains(&self.languages.detect(p, None))
&& send(p, *change)
})
.collect()
} else {
all.chain(excluded_dirs.iter().map(|p| (p, Change::Created)))
.filter(|(p, change)| watchers.wants(&s.root, p, *change) && send(p, *change))
.filter(|(p, change)| roots.iter().any(|root| watchers.wants(root, p, *change)) && send(p, *change))
.collect()
};
drop(watchers);
@@ -3036,7 +3192,8 @@ impl LspManager {
}
}
if allowed && !previous {
for server in self.servers.lock().iter() {
let servers = self.servers.lock().clone();
for server in &servers {
server.reset_for_restart();
self.ensure_started(server);
}
@@ -3053,11 +3210,13 @@ impl LspManager {
}
pub async fn reconfigure(&self) {
let _reconfigure = self.reconfigure_lock.lock().await;
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.reconfigure_jdtls_workspace().await;
self.stop_unlisted().await;
let Some(me) = self.me.upgrade() else { return };
let servers: Vec<Arc<Server>> = self.servers.lock().clone();
@@ -3120,6 +3279,39 @@ impl LspManager {
self.injection_providers_changed();
}
async fn reconfigure_jdtls_workspace(&self) {
let (shared, roots) = self.desired_jdtls_workspace();
let retired = {
// Opens attach atomically with root selection; no document can acquire an old
// instance between removing the registry entries and detaching its documents.
let _gate = self.injections.gate.lock();
let mut state = self.jdtls_workspace_state();
if state.shared == shared && (!shared || state.roots == roots) {
return;
}
state.transitioning = true;
state.shared = shared;
state.roots = roots;
let mut servers = self.servers.lock();
let retired: Vec<_> = servers.iter().filter(|s| is_local_jdtls(&s.config)).cloned().collect();
servers.retain(|s| !is_local_jdtls(&s.config));
for doc in self.docs.lock().values_mut() {
doc.servers.retain(|a| !is_local_jdtls(&a.server.config));
}
retired
};
for server in &retired {
self.stop_instance(server, true).await;
}
self.forget(&retired);
self.jdtls_workspace.lock().transitioning = false;
self.attach_fallbacks();
let pending: Vec<_> = self.servers.lock().iter().filter(|s| is_local_jdtls(&s.config)).cloned().collect();
for server in pending {
self.ensure_started(&server);
}
}
/// Detaches fallbacks shadowed by enabled servers and instances whose target
/// no longer matches the document's language. Stop them once unused.
async fn detach_shadowed_servers(&self) {
@@ -3137,6 +3329,7 @@ impl LspManager {
.filter(|a| {
!wanted.iter().any(|w| w.id == a.server.id)
&& (configs.iter().any(|c| c.fallback && c.name == a.server.name)
|| (a.server.name == "clangd" && wanted.iter().any(|s| s.name == arduino::SERVER))
|| !configs
.iter()
.any(|c| c.name == a.server.name && c.target_key == a.server.config.target_key))
@@ -3255,6 +3448,7 @@ impl LspManager {
}
pub async fn restart(&self, server: ide_core::ServerId) -> IdeResult<()> {
let _reconfigure = self.reconfigure_lock.lock().await;
let s = self
.servers
.lock()
@@ -3282,7 +3476,12 @@ impl LspManager {
/// Gracefully stops the current instance (`shutdown` + `exit`, then kill).
async fn stop_instance(&self, server: &Arc<Server>, final_stop: bool) {
let Some(run) = server.begin_stop(&self.sink, final_stop) else { return };
let Some(run) = server.begin_stop(&self.sink, final_stop) else {
if is_local_jdtls(&server.config) {
let _startup = server.startup_lock.lock().await;
}
return;
};
let peer = run.peer.clone();
// A server we connected to without starting it (an open Godot editor) is not
// ours to shut down: only the connection closes.
@@ -3302,13 +3501,24 @@ impl LspManager {
peer.kill();
let _ = tokio::time::timeout(Duration::from_secs(2), peer.closed()).await;
}
peer.kill();
Self::kill_peer(server, &peer).await;
if is_local_jdtls(&server.config) {
let _startup = server.startup_lock.lock().await;
}
server.end_stop(run.generation, &self.sink);
self.file_watch_roots_changed();
}
async fn kill_peer(server: &Server, peer: &ide_jsonrpc::Peer) {
peer.kill();
if is_local_jdtls(&server.config) && peer.pid().is_some() {
peer.exited().await;
}
}
pub async fn shutdown_all(&self) {
self.shutting_down.store(true, Ordering::Release);
let _reconfigure = self.reconfigure_lock.lock().await;
let servers: Vec<Arc<Server>> = self.servers.lock().clone();
join_all(servers.iter().map(|s| self.stop_instance(s, true))).await;
}
+77 -7
View File
@@ -52,6 +52,7 @@ pub enum Phase {
struct State {
generation: u64,
conn: Conn,
restarting: bool,
events: VecDeque<IdeEvent>,
publishing: bool,
}
@@ -119,6 +120,8 @@ pub struct Server {
phase_notify: Notify,
transient: Mutex<HashMap<PathBuf, Transient>>,
pub java_build_lock: Arc<tokio::sync::Mutex<()>>,
/// JDT retirement waits for launch and initialization cleanup, even before `Ready`.
pub startup_lock: Arc<tokio::sync::Mutex<()>>,
pub java_autobuild_suspended: std::sync::atomic::AtomicBool,
pub external_project: tokio::sync::Mutex<crate::external_project::State>,
pub external_diagnostics: std::sync::atomic::AtomicBool,
@@ -138,7 +141,13 @@ impl Server {
effective: Mutex::new(None),
resolved_binary: RwLock::new(None),
languages: Mutex::new(vec![language]),
state: Mutex::new(State { generation: 0, conn: Conn::Idle, events: VecDeque::new(), publishing: false }),
state: Mutex::new(State {
generation: 0,
conn: Conn::Idle,
restarting: false,
events: VecDeque::new(),
publishing: false,
}),
initializing: Mutex::new(None),
start_cancel: Mutex::new(CancellationToken::new()),
phase: watch::channel(Phase::Down).0,
@@ -158,6 +167,7 @@ impl Server {
phase_notify: Notify::new(),
transient: Mutex::new(HashMap::new()),
java_build_lock: Arc::new(tokio::sync::Mutex::new(())),
startup_lock: Arc::new(tokio::sync::Mutex::new(())),
java_autobuild_suspended: std::sync::atomic::AtomicBool::new(false),
external_project: tokio::sync::Mutex::new(Default::default()),
external_diagnostics: std::sync::atomic::AtomicBool::new(false),
@@ -214,6 +224,7 @@ impl Server {
}
st.generation += 1;
st.conn = Conn::Starting;
st.restarting = false;
*self.start_cancel.lock() = CancellationToken::new();
*self.started.lock() = Some(Instant::now());
self.phase.send_replace(Phase::Starting);
@@ -269,6 +280,10 @@ impl Server {
}
}
pub fn is_starting(&self) -> bool {
matches!(self.state.lock().conn, Conn::Starting)
}
/// The instance told `initialized`, ready or not.
pub fn instance(&self) -> Option<Running> {
self.running().or_else(|| self.initializing.lock().clone())
@@ -293,21 +308,43 @@ impl Server {
self.running().filter(|r| r.generation == generation)
}
/// Waits while the server is starting; returns the instance once ready.
/// Waits through startup or a planned restart; returns the instance once ready.
pub async fn wait_ready(&self, timeout: Duration) -> Option<Running> {
let mut rx = self.phase.subscribe();
let _ = tokio::time::timeout(timeout, rx.wait_for(|p| *p != Phase::Starting)).await;
self.running()
tokio::time::timeout(timeout, async {
loop {
{
let st = self.state.lock();
// Read both under the writer's lock: a restart can begin just after a wakeup.
if *rx.borrow_and_update() != Phase::Starting {
return match &st.conn {
Conn::Ready(run) => Some(run.clone()),
_ => None,
};
}
}
if rx.changed().await.is_err() {
return None;
}
}
})
.await
.ok()
.flatten()
}
/// Ready → Stopping, returning the instance to shut down. An in-flight
/// start is abandoned instead.
pub fn begin_stop(&self, sink: &SharedSink, final_stop: bool) -> Option<Running> {
let mut st = self.state.lock();
if final_stop {
st.restarting = false;
}
match std::mem::replace(&mut st.conn, Conn::Idle) {
Conn::Ready(run) => {
st.conn = Conn::Stopping;
self.phase.send_replace(Phase::Down);
st.restarting = !final_stop;
self.phase.send_replace(if st.restarting { Phase::Starting } else { Phase::Down });
Some(run)
}
Conn::Starting => {
@@ -315,7 +352,8 @@ impl Server {
self.start_cancel.lock().cancel();
self.queue_cleanup(&mut st);
self.queue_status(&mut st, ServerStatus::Stopped, None);
self.phase.send_replace(Phase::Down);
st.restarting = !final_stop;
self.phase.send_replace(if st.restarting { Phase::Starting } else { Phase::Down });
drop(st);
self.publish(sink);
None
@@ -324,6 +362,7 @@ impl Server {
st.conn = other;
if final_stop {
self.queue_status(&mut st, ServerStatus::Stopped, None);
self.phase.send_replace(Phase::Down);
}
drop(st);
self.publish(sink);
@@ -345,7 +384,7 @@ impl Server {
self.queue_cleanup(&mut st);
self.queue_status(&mut st, ServerStatus::Stopped, None);
st.conn = Conn::Idle;
self.phase.send_replace(Phase::Down);
self.phase.send_replace(if st.restarting { Phase::Starting } else { Phase::Down });
drop(st);
self.publish(sink);
}
@@ -899,6 +938,37 @@ mod tests {
assert_eq!(s.status(), ServerStatus::Stopped);
}
#[tokio::test]
async fn final_stop_cancels_pending_restarts_in_every_phase() {
for phase in ["starting", "stopping", "between"] {
let s = server();
let sink: SharedSink = Arc::new(|_: IdeEvent| {});
let generation = s.begin_start().unwrap();
if phase == "starting" {
s.begin_stop(&sink, false);
} else {
let mut st = s.state.lock();
st.conn = Conn::Stopping;
st.restarting = true;
}
if phase == "between" {
s.end_stop(generation, &sink);
}
assert!(
tokio::time::timeout(Duration::from_millis(10), s.wait_ready(Duration::from_secs(30))).await.is_err()
);
s.begin_stop(&sink, true);
s.end_stop(generation, &sink);
assert_eq!(*s.phase.borrow(), Phase::Down);
assert!(
tokio::time::timeout(Duration::from_secs(1), s.wait_ready(Duration::from_secs(30)))
.await
.unwrap()
.is_none()
);
}
}
#[test]
fn failure_publication_can_restart_without_overwriting_the_new_phase() {
for conn in [Conn::Missing, Conn::Failed] {
+14 -2
View File
@@ -2,7 +2,7 @@
use ide_core::event::ServerStatus;
use ide_core::settings::ExternalFormatter;
use ide_core::{Location, Range, ServerId, SymbolKind, TextEdit, WorkspaceEdit};
use ide_core::{Diagnostic, Location, Range, ServerId, SymbolKind, TextEdit, WorkspaceEdit};
use serde::{Deserialize, Serialize};
use specta::Type;
@@ -49,7 +49,11 @@ pub struct CompletionItem {
pub label: String,
/// e.g. `(req: CreateOrderRequest)` shown right after the label.
pub label_detail: Option<String>,
/// e.g. return type, shown right-aligned.
/// `labelDetails.description`: a return type or module path, shown right-aligned. Servers
/// such as JDT.LS also send the whole signature as `detail`, which would repeat the label.
pub label_description: Option<String>,
/// The signature or type when the server sent no `label_description`; the documentation panel
/// shows it either way.
pub detail: Option<String>,
pub kind: Option<CompletionKind>,
pub documentation: Option<Markup>,
@@ -193,6 +197,14 @@ pub struct InlineCompletionItem {
#[serde(rename_all = "camelCase")]
pub struct CodeAction {
pub title: String,
/// Diagnostics this action fixes, in IDE coordinates.
#[serde(default)]
#[specta(optional)]
pub diagnostics: Vec<Diagnostic>,
/// Original server diagnostics, including encoding and data, for codeAction/resolve.
#[serde(default)]
#[specta(optional)]
pub original_diagnostics: Option<String>,
/// LSP kind, e.g. `quickfix`, `refactor.extract`, `source.organizeImports`.
pub kind: Option<String>,
pub is_preferred: bool,
+589 -9
View File
@@ -35,6 +35,8 @@ struct MockState {
responder: Mutex<Option<Responder>>,
/// Holds the handling of notifications of this method until the gate opens.
held: Mutex<Option<(String, Arc<Gate>)>>,
initialize_gate: Mutex<Option<Arc<Gate>>>,
launch_gate: Mutex<Option<Arc<Gate>>>,
peer: Mutex<Option<Peer>>,
}
@@ -73,7 +75,9 @@ fn utf16_to_byte(text: &str, line: u64, col: u64) -> usize {
text.len()
}
struct MockHandler(Arc<MockState>);
type MockConnection = Mutex<Option<Peer>>;
struct MockHandler(Arc<MockState>, std::sync::Weak<MockConnection>);
impl Handler for MockHandler {
fn on_notification(&self, method: &str, params: Value) {
@@ -118,8 +122,14 @@ impl Handler for MockHandler {
s.docs.lock().remove(&uri);
}
"exit" => {
if let Some(p) = s.peer.lock().take() {
p.kill();
if let Some(connection) = self.1.upgrade()
&& let Some(peer) = connection.lock().take()
{
peer.kill();
}
let mut latest = s.peer.lock();
if latest.as_ref().is_some_and(Peer::is_closed) {
*latest = None;
}
}
_ => {}
@@ -131,7 +141,13 @@ impl Handler for MockHandler {
let s = &self.0;
s.requests.lock().push((method.to_owned(), params.clone()));
match method {
"initialize" => Ok(json!({ "capabilities": s.caps.lock().clone(), "serverInfo": { "name": "mock" } })),
"initialize" => {
let gate = s.initialize_gate.lock().take();
if let Some(gate) = gate {
gate.hold();
}
Ok(json!({ "capabilities": s.caps.lock().clone(), "serverInfo": { "name": "mock" } }))
}
"shutdown" => Ok(Value::Null),
_ => {
if let Some(error) = s.errors.lock().get(method).cloned() {
@@ -150,6 +166,7 @@ impl Handler for MockHandler {
#[derive(Default)]
struct MockLauncher {
servers: Mutex<HashMap<String, Arc<MockState>>>,
connections: Mutex<Vec<Arc<MockConnection>>>,
}
impl MockLauncher {
@@ -186,10 +203,18 @@ impl Launcher for MockLauncher {
state.docs.lock().clear();
let (client_io, server_io) = tokio::io::duplex(1 << 20);
let (sr, sw) = tokio::io::split(server_io);
let server = Peer::connect(sr, sw, Arc::new(MockHandler(state.clone())));
let connection = Arc::new(Mutex::new(None));
let server = Peer::connect(sr, sw, Arc::new(MockHandler(state.clone(), Arc::downgrade(&connection))));
*connection.lock() = Some(server.clone());
self.connections.lock().push(connection);
*state.peer.lock() = Some(server);
let (cr, cw) = tokio::io::split(client_io);
Ok(Peer::connect(cr, cw, handler))
let client = Peer::connect(cr, cw, handler);
let gate = state.launch_gate.lock().take();
if let Some(gate) = gate {
gate.hold();
}
Ok(client)
}
}
@@ -395,6 +420,45 @@ fn change(range: Range, text: &str) -> AppliedChange {
AppliedChange { range, text: text.into() }
}
#[tokio::test(flavor = "multi_thread")]
async fn dart_file_watching_requires_an_explicit_registration() {
for (language, extension, name, fallback) in
[("dart", "dart", "dart", false), ("dart", "dart", "alternative-dart", true), ("java", "java", "jdtls", true)]
{
let h = harness_for(language, extension, &[(name, incremental_caps())], &[]);
let (doc, _) = h.open(&format!("open.{extension}"), "x");
h.wait_status(name, ServerStatus::Ready).await;
let s = h.launcher.state(name);
let path = h.root.join(format!("closed.{extension}"));
let notify = || h.mgr.watched_files_changed(&[], std::slice::from_ref(&path), &[], &[]);
notify();
h.mgr.hover(doc, Pos::new(0, 0), Default::default()).await.unwrap();
assert_eq!(s.notified("workspace/didChangeWatchedFiles").len(), usize::from(fallback), "{name}");
s.peer()
.request::<_, Value>(
"client/registerCapability",
json!({ "registrations": [{ "id": "watch", "method": "workspace/didChangeWatchedFiles", "registerOptions": { "watchers": [{ "globPattern": "**/*" }] } }] }),
None,
)
.await
.unwrap();
notify();
eventually(|| s.notified("workspace/didChangeWatchedFiles").len() == usize::from(fallback) + 1).await;
s.peer()
.request::<_, Value>(
"client/unregisterCapability",
json!({ "unregisterations": [{ "id": "watch", "method": "workspace/didChangeWatchedFiles" }] }),
None,
)
.await
.unwrap();
notify();
h.mgr.hover(doc, Pos::new(0, 0), Default::default()).await.unwrap();
assert_eq!(s.notified("workspace/didChangeWatchedFiles").len(), usize::from(fallback) * 2 + 1, "{name}");
h.mgr.shutdown_all().await;
}
}
// ------------------------------------------------------------------ tests
#[tokio::test(flavor = "multi_thread")]
@@ -420,6 +484,451 @@ async fn linked_projects_initialize_separate_language_server_roots() {
h.mgr.shutdown_all().await;
}
fn java_siblings(h: &Harness) -> Vec<PathBuf> {
["first", "second"]
.into_iter()
.map(|name| {
let root = h.root.join(name);
std::fs::create_dir(&root).unwrap();
std::fs::write(root.join("mock.toml"), "").unwrap();
let path = root.join("Main.java");
std::fs::write(&path, "class Main {}").unwrap();
path
})
.collect()
}
#[tokio::test(flavor = "multi_thread")]
async fn java_sharing_is_opt_in_and_keeps_module_requests_on_one_instance() {
for shared in [false, true] {
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
h.mgr.set_jdtls_shared_hook(Box::new(move || shared));
let paths = java_siblings(&h);
std::thread::scope(|scope| {
for path in &paths {
scope.spawn(|| h.mgr.did_open(DocId::next(), path, &h.lang, 1, "class Main {}"));
}
});
let state = h.launcher.state("jdtls");
let count = if shared { 1 } else { 2 };
eventually(|| state.requested("initialize").len() == count).await;
assert_eq!(state.launches.load(Ordering::SeqCst), count);
assert_eq!(h.mgr.servers().len(), count);
for path in &paths {
h.mgr.java_classpath(path.parent().unwrap(), false, &Default::default()).await.unwrap();
}
let requests = state.requested("workspace/executeCommand");
let classpaths: Vec<_> = requests.iter().filter(|r| r["command"] == "java.project.getClasspaths").collect();
assert_eq!(classpaths.len(), 2);
for path in &paths {
assert!(classpaths.iter().any(|r| r["arguments"][0] == uri_of(path.parent().unwrap())));
}
if shared {
let init = &state.requested("initialize")[0];
assert_eq!(init["rootUri"], uri_of(&h.root));
assert_eq!(init["initializationOptions"]["workspaceFolders"], json!([uri_of(&h.root)]));
}
h.mgr.shutdown_all().await;
}
}
#[tokio::test(flavor = "multi_thread")]
async fn java_sharing_toggle_retires_old_instances_and_reopens_every_document() {
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
let shared = Arc::new(std::sync::atomic::AtomicBool::new(false));
h.mgr.set_jdtls_shared_hook(Box::new({
let shared = shared.clone();
move || shared.load(Ordering::SeqCst)
}));
let paths = java_siblings(&h);
for path in &paths {
h.mgr.did_open(DocId::next(), path, &h.lang, 1, "class Main {}");
}
let state = h.launcher.state("jdtls");
eventually(|| state.requested("initialize").len() == 2).await;
eventually(|| {
paths
.iter()
.all(|path| state.notified("textDocument/didOpen").iter().any(|v| v["textDocument"]["uri"] == uri_of(path)))
})
.await;
let old: Vec<_> = h.mgr.servers().into_iter().map(|s| s.id).collect();
shared.store(true, Ordering::SeqCst);
h.mgr.reconfigure().await;
eventually(|| state.launches.load(Ordering::SeqCst) == 3 && state.docs.lock().len() == 2).await;
assert_eq!(h.mgr.servers().len(), 1);
for id in old {
assert!(
h.events
.lock()
.iter()
.any(|event| matches!(event, IdeEvent::LanguageServerRemoved {server} if *server == id))
);
}
shared.store(false, Ordering::SeqCst);
h.mgr.reconfigure().await;
eventually(|| state.launches.load(Ordering::SeqCst) == 5).await;
assert_eq!(h.mgr.servers().len(), 2);
eventually(|| {
paths.iter().all(|path| {
state.notified("textDocument/didOpen").iter().filter(|v| v["textDocument"]["uri"] == uri_of(path)).count()
== 3
})
})
.await;
h.mgr.shutdown_all().await;
}
#[tokio::test(flavor = "multi_thread")]
async fn java_sharing_cancels_a_retiring_initialize_before_starting_its_replacement() {
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
let shared = Arc::new(std::sync::atomic::AtomicBool::new(false));
h.mgr.set_jdtls_shared_hook(Box::new({
let shared = shared.clone();
move || shared.load(Ordering::SeqCst)
}));
let state = h.launcher.state("jdtls");
let gate = Arc::new(Gate::default());
*state.initialize_gate.lock() = Some(gate.clone());
let path = java_siblings(&h).remove(0);
h.mgr.did_open(DocId::next(), &path, &h.lang, 1, "class Main {}");
eventually(|| gate.entered()).await;
let old = state.peer();
shared.store(true, Ordering::SeqCst);
let switched = tokio::time::timeout(Duration::from_secs(2), h.mgr.reconfigure()).await;
let closed_before_response = old.is_closed();
gate.open();
switched.expect("initialize cancellation must not wait for its response");
eventually(|| state.requested("initialize").len() == 2).await;
assert!(closed_before_response, "the old transport must close before its initialize response is released");
assert_eq!(h.mgr.servers().len(), 1);
assert_eq!(h.mgr.servers()[0].root.as_ref(), h.root.as_path());
h.mgr.shutdown_all().await;
}
#[tokio::test(flavor = "multi_thread")]
async fn shared_java_waits_for_an_abandoned_launcher_before_replacing_the_workspace() {
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
h.mgr.set_jdtls_shared_hook(Box::new(|| true));
let state = h.launcher.state("jdtls");
let gate = Arc::new(Gate::default());
*state.launch_gate.lock() = Some(gate.clone());
h.open("Main.java", "class Main {}");
eventually(|| gate.entered()).await;
let old = state.peer();
let id = h.mgr.servers()[0].id;
let external = tempfile::tempdir().unwrap();
h.mgr.set_content_roots(vec![dunce::canonicalize(external.path()).unwrap()]);
eventually(|| h.mgr.servers().iter().all(|s| s.id != id)).await;
h.open("Another.java", "class Another {}");
let mut transition = tokio::spawn({
let manager = h.mgr.clone();
async move { manager.reconfigure().await }
});
let pending = tokio::time::timeout(Duration::from_millis(100), &mut transition).await.is_err();
let launches = state.launches.load(Ordering::SeqCst);
let old_alive = !old.is_closed();
gate.open();
assert!(pending, "retirement must wait for the launcher to return");
assert_eq!(launches, 1, "the retiring launcher still owns its peer");
assert!(old_alive);
transition.await.unwrap();
h.mgr.reconfigure().await;
eventually(|| state.launches.load(Ordering::SeqCst) == 2 && state.docs.lock().len() == 2).await;
assert!(old.is_closed());
assert_eq!(h.mgr.servers().len(), 1);
h.mgr.shutdown_all().await;
}
#[tokio::test(flavor = "multi_thread")]
async fn java_classpath_waits_for_a_config_restart_that_is_reaping_startup() {
for (shared, failed) in [(false, false), (true, false), (false, true)] {
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
let changed = Arc::new(std::sync::atomic::AtomicBool::new(false));
h.mgr.set_jdtls_shared_hook(Box::new({
let changed = changed.clone();
move || shared && changed.load(Ordering::SeqCst)
}));
h.mgr.set_config_hook(Box::new({
let changed = changed.clone();
move |cfg, _| {
let changed = changed.load(Ordering::SeqCst);
cfg.env.insert("MOCK_CONFIG_REVISION".into(), changed.to_string());
if failed && changed {
cfg.launch_error = Some("restart configuration error".into());
}
}
}));
let state = h.launcher.state("jdtls");
let gate = Arc::new(Gate::default());
*state.launch_gate.lock() = Some(gate.clone());
let path = java_siblings(&h).remove(0);
h.mgr.did_open(DocId::next(), &path, &h.lang, 1, "class Main {}");
eventually(|| gate.entered()).await;
let old = h.mgr.servers()[0].id;
changed.store(true, Ordering::SeqCst);
let restarting = tokio::spawn({
let manager = h.mgr.clone();
async move { manager.reconfigure().await }
});
h.wait_status("jdtls", ServerStatus::Stopped).await;
let mut query = tokio::spawn({
let manager = h.mgr.clone();
async move { manager.java_classpath(path.parent().unwrap(), false, &Default::default()).await }
});
let early = tokio::time::timeout(Duration::from_millis(100), &mut query).await;
gate.open();
restarting.await.unwrap();
let result = match early {
Ok(result) => result.unwrap(),
Err(_) => query.await.unwrap(),
};
if failed {
assert!(result.unwrap_err().message.contains("restart configuration error"));
} else {
assert!(result.is_ok(), "a configuration restart is transient: {result:?}");
assert_eq!(state.launches.load(Ordering::SeqCst), 2);
assert_eq!(h.mgr.servers()[0].id != old, shared);
}
h.mgr.shutdown_all().await;
}
}
#[tokio::test(flavor = "multi_thread")]
async fn java_classpath_restart_barrier_wait_remains_cancellable() {
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
let changed = Arc::new(std::sync::atomic::AtomicBool::new(false));
h.mgr.set_config_hook(Box::new({
let changed = changed.clone();
move |cfg, _| {
cfg.env.insert("MOCK_CONFIG_REVISION".into(), changed.load(Ordering::SeqCst).to_string());
}
}));
let state = h.launcher.state("jdtls");
let gate = Arc::new(Gate::default());
*state.launch_gate.lock() = Some(gate.clone());
h.open("Main.java", "class Main {}");
eventually(|| gate.entered()).await;
changed.store(true, Ordering::SeqCst);
let restarting = tokio::spawn({
let manager = h.mgr.clone();
async move { manager.reconfigure().await }
});
h.wait_status("jdtls", ServerStatus::Stopped).await;
let cancel = CancellationToken::new();
let mut query = tokio::spawn({
let manager = h.mgr.clone();
let root = h.root.clone();
let cancel = cancel.clone();
async move { manager.java_classpath(&root, false, &cancel).await }
});
let early = tokio::time::timeout(Duration::from_millis(100), &mut query).await;
let waiting = early.is_err();
cancel.cancel();
let result = match early {
Ok(result) => Ok(result),
Err(_) => tokio::time::timeout(Duration::from_secs(1), query).await,
};
gate.open();
restarting.await.unwrap();
assert!(waiting, "the query must wait behind the restart barrier");
assert_eq!(result.unwrap().unwrap().unwrap_err().kind, ide_core::error::ErrorKind::Cancelled);
h.mgr.shutdown_all().await;
}
#[tokio::test(flavor = "multi_thread")]
async fn java_classpath_does_not_retry_permanently_unavailable_servers() {
for missing in [false, true] {
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
if missing {
h.launcher.state("jdtls").missing.store(true, Ordering::SeqCst);
} else {
h.mgr.set_config_hook(Box::new(|cfg, _| {
cfg.launch_error = Some("permanent mock configuration error".into())
}));
}
let result =
tokio::time::timeout(Duration::from_secs(1), h.mgr.java_classpath(&h.root, false, &Default::default()))
.await
.expect("permanent failures must not incur the Java readiness timeout");
let error = result.unwrap_err();
assert!(error.message.contains(if missing { "not installed" } else { "permanent mock configuration error" }));
h.mgr.shutdown_all().await;
}
}
#[tokio::test(flavor = "multi_thread")]
async fn shared_java_external_roots_reach_initialization_queries_and_watches() {
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
h.mgr.set_jdtls_shared_hook(Box::new(|| true));
let external = tempfile::tempdir().unwrap();
let external = dunce::canonicalize(external.path()).unwrap();
h.mgr.set_content_roots(vec![external.clone(), external.clone(), h.root.clone()]);
let file = external.join("External.java");
std::fs::write(&file, "class External {}").unwrap();
h.mgr.did_open(DocId::next(), &file, &h.lang, 1, "class External {}");
let state = h.launcher.state("jdtls");
h.wait_status("jdtls", ServerStatus::Ready).await;
let init = &state.requested("initialize")[0];
assert_eq!(init["initializationOptions"]["workspaceFolders"], json!([uri_of(&h.root), uri_of(&external)]));
assert_eq!(init["initializationOptions"]["triggerFiles"], json!([uri_of(&file)]));
let folders: Value = state.peer().request("workspace/workspaceFolders", Value::Null, None).await.unwrap();
assert_eq!(folders, init["workspaceFolders"]);
state.peer().request::<_, Value>("client/registerCapability", json!({"registrations":[{
"id":"external-java", "method":"workspace/didChangeWatchedFiles", "registerOptions":{"watchers":[{"globPattern":"**/*.java"}]}
}]}), None).await.unwrap();
let other = external.join("Other.java");
h.mgr.watched_files_changed(std::slice::from_ref(&other), &[], &[], &[]);
eventually(|| {
state.notified("workspace/didChangeWatchedFiles").iter().any(|v| v["changes"][0]["uri"] == uri_of(&other))
})
.await;
h.mgr.set_content_roots(vec![]);
h.mgr.reconfigure().await;
eventually(|| state.requested("initialize").len() == 2).await;
assert_eq!(state.requested("initialize")[1]["initializationOptions"]["workspaceFolders"], json!([uri_of(&h.root)]));
assert_eq!(h.mgr.servers().len(), 1);
h.mgr.shutdown_all().await;
}
#[tokio::test(flavor = "multi_thread")]
async fn trusting_an_existing_java_instance_restarts_it_without_locking_the_registry() {
for shared in [false, true] {
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
h.mgr.set_jdtls_shared_hook(Box::new(move || shared));
h.open("Main.java", "class Main {}");
h.wait_status("jdtls", ServerStatus::Ready).await;
let state = h.launcher.state("jdtls");
h.mgr.set_execution_allowed(false);
h.mgr.reconfigure().await;
h.mgr.set_execution_allowed(true);
eventually(|| state.launches.load(Ordering::SeqCst) == 2 && state.docs.lock().len() == 1).await;
assert_eq!(h.mgr.servers().len(), 1);
h.mgr.shutdown_all().await;
}
}
#[tokio::test(flavor = "multi_thread")]
async fn java_sharing_preserves_other_servers_and_marker_scoped_fallbacks() {
for fallback in [false, true] {
let h = harness_for(
"java",
"java",
&[("jdtls", incremental_caps()), ("other", incremental_caps())],
if fallback { &["other"] } else { &[] },
);
h.mgr.set_jdtls_shared_hook(Box::new(|| true));
h.launcher.state("jdtls").missing.store(fallback, Ordering::SeqCst);
for path in java_siblings(&h) {
h.mgr.did_open(DocId::next(), &path, &h.lang, 1, "class Main {}");
}
eventually(|| h.launcher.state("other").requested("initialize").len() == 2).await;
let servers = h.mgr.servers();
assert_eq!(servers.iter().filter(|s| s.name == "jdtls").count(), 1);
assert_eq!(servers.iter().filter(|s| s.name == "other").count(), 2);
assert_eq!(h.launcher.state("jdtls").launches.load(Ordering::SeqCst), usize::from(!fallback));
h.mgr.shutdown_all().await;
}
}
#[tokio::test(flavor = "multi_thread")]
async fn java_sharing_keeps_target_instances_separate_and_preserves_them_on_toggle() {
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
let shared = Arc::new(std::sync::atomic::AtomicBool::new(true));
h.mgr.set_jdtls_shared_hook(Box::new({
let shared = shared.clone();
move || shared.load(Ordering::SeqCst)
}));
h.mgr.set_servers_hook(Box::new(|_, configs| {
let mut target = configs[0].clone();
target.target_key = Some("container".into());
target
.initialization_options
.as_mut()
.unwrap()
.as_table_mut()
.unwrap()
.insert("selectedTarget".into(), toml::Value::Boolean(true));
vec![configs[0].clone(), target]
}));
h.open("Main.java", "class Main {}");
for path in java_siblings(&h) {
h.mgr.did_open(DocId::next(), &path, &h.lang, 1, "class Main {}");
}
let state = h.launcher.state("jdtls");
eventually(|| h.mgr.servers().iter().filter(|s| s.status == ServerStatus::Ready).count() == 4).await;
let initial = state.requested("initialize");
assert_eq!(initial.len(), 4);
let targets: Vec<_> = initial.iter().filter(|v| v["initializationOptions"]["selectedTarget"] == true).collect();
assert_eq!(targets.len(), 3, "target roots retain their marker-based identity");
assert!(targets.iter().all(|v| v["initializationOptions"].get("workspaceFolders").is_none()));
let local = initial.iter().find(|v| v["initializationOptions"]["selectedTarget"] != true).unwrap();
assert_eq!(local["rootUri"], uri_of(&h.root));
assert_eq!(local["initializationOptions"]["workspaceFolders"], json!([uri_of(&h.root)]));
let old: HashSet<_> = h.mgr.servers().into_iter().map(|s| s.id).collect();
shared.store(false, Ordering::SeqCst);
h.mgr.reconfigure().await;
eventually(|| h.mgr.servers().iter().filter(|s| s.status == ServerStatus::Ready).count() == 6).await;
assert_eq!(
h.mgr.servers().iter().filter(|s| old.contains(&s.id)).count(),
3,
"local sharing does not retire targets"
);
h.mgr.shutdown_all().await;
}
#[tokio::test(flavor = "multi_thread")]
async fn virtual_java_documents_opened_during_a_toggle_attach_to_the_successor() {
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
let shared = Arc::new(std::sync::atomic::AtomicBool::new(false));
h.mgr.set_jdtls_shared_hook(Box::new({
let shared = shared.clone();
move || shared.load(Ordering::SeqCst)
}));
let (doc, _) = h.open("Main.java", "class Main {}");
h.wait_status("jdtls", ServerStatus::Ready).await;
let state = h.launcher.state("jdtls");
let uris: Vec<_> = (0..16).map(|n| format!("jdt://contents/project/Virtual{n}.class")).collect();
state.respond(
"textDocument/definition",
json!(
uris.iter()
.map(|uri| json!({"uri":uri,"range":{"start":{"line":0,"character":0},"end":{"line":0,"character":1}}}))
.collect::<Vec<_>>()
),
);
h.mgr.goto(doc, Pos::default(), GotoKind::Definition).await.unwrap();
shared.store(true, Ordering::SeqCst);
let toggle = tokio::spawn({
let manager = h.mgr.clone();
async move { manager.reconfigure().await }
});
let mut opened = Vec::new();
for uri in uris {
let manager = h.mgr.clone();
let language = h.lang.clone();
let doc = DocId::next();
opened.push((
doc,
std::thread::spawn(move || manager.did_open_virtual(doc, &uri, &language, 0, "class Virtual {}")),
));
}
let docs: Vec<_> = opened
.into_iter()
.map(|(doc, thread)| {
thread.join().unwrap();
doc
})
.collect();
toggle.await.unwrap();
eventually(|| state.docs.lock().len() == docs.len() + 1).await;
assert_eq!(h.mgr.servers().len(), 1);
for doc in docs {
h.mgr.hover(doc, Pos::default(), Default::default()).await.unwrap();
}
h.mgr.shutdown_all().await;
}
#[tokio::test(flavor = "multi_thread")]
async fn files_changed_on_disk_reach_the_servers_of_their_language() {
let h = harness(&[("mock", incremental_caps())]);
@@ -792,7 +1301,7 @@ async fn completion_mapping_merge_and_resolve() {
"isIncomplete": false,
"itemDefaults": { "data": { "from": "defaults" } },
"items": [{
"label": "bar", "kind": 2, "detail": "fn() -> u8", "labelDetails": { "detail": "()" },
"label": "bar", "kind": 2, "detail": "fn() -> u8", "labelDetails": { "detail": "()", "description": "u8" },
"textEdit": { "newText": "bar()$0", "insert": {"start":{"line":0,"character":12},"end":{"line":0,"character":14}},
"replace": {"start":{"line":0,"character":12},"end":{"line":0,"character":15}} },
"insertTextFormat": 2, "sortText": "0001"
@@ -813,6 +1322,7 @@ async fn completion_mapping_merge_and_resolve() {
assert_eq!(bar.insert_text, "bar()$0");
assert!(bar.is_snippet);
assert_eq!(bar.label_detail.as_deref(), Some("()"));
assert_eq!(bar.label_description.as_deref(), Some("u8"));
assert_eq!(bar.replace_range, Some(rng(0, 12, 0, 14)));
assert_eq!(bar.replace_range_tab, Some(rng(0, 12, 0, 15)));
assert_eq!(bar.data, Some(json!({ "from": "defaults" })));
@@ -828,9 +1338,13 @@ async fn completion_mapping_merge_and_resolve() {
assert_eq!(resolved.documentation.as_ref().unwrap().value, "**docs**");
assert_eq!(resolved.additional_edits[0].new_text, "use foo;\n");
assert_eq!(resolved.insert_text, "bar()$0", "fields absent from the resolve reply are kept");
assert_eq!(resolved.label_detail.as_deref(), Some("()"));
assert_eq!(resolved.label_description.as_deref(), Some("u8"));
assert_eq!(resolved.detail.as_deref(), Some("fn() -> u8"));
assert_eq!(resolved.replace_range_tab, Some(rng(0, 12, 0, 15)));
let sent = &a.requested("completionItem/resolve")[0];
assert_eq!(sent["data"], json!({ "from": "defaults" }));
assert_eq!(sent["labelDetails"], json!({ "detail": "()", "description": "u8" }));
assert_eq!(sent["textEdit"]["replace"]["end"]["character"], 15);
// Server b has no resolve support: the item comes back unchanged.
@@ -935,7 +1449,7 @@ async fn diagnostics_become_events_and_feed_code_actions() {
s.respond(
"textDocument/codeAction",
json!([
{ "title": "Remove", "kind": "quickfix", "isPreferred": true, "data": { "id": 7 } },
{ "title": "Remove", "kind": "quickfix", "isPreferred": true, "data": { "id": 7 }, "diagnostics": [diag(0)] },
{ "title": "Run", "command": "mock.run", "arguments": [1] }
]),
);
@@ -950,6 +1464,9 @@ async fn diagnostics_become_events_and_feed_code_actions() {
assert_eq!(actions.len(), 2);
assert_eq!(actions[0].kind.as_deref(), Some("quickfix"));
assert!(actions[0].is_preferred);
assert_eq!(actions[0].diagnostics[0].range, rng(0, 4, 0, 10));
assert_eq!(actions[0].diagnostics[0].message, "unused variable");
assert!(actions[1].diagnostics.is_empty());
assert_eq!(actions[1].command.as_ref().unwrap().command, "mock.run");
let req = &s.requested("textDocument/codeAction")[0];
assert_eq!(req["context"]["only"], json!(["quickfix"]));
@@ -959,6 +1476,8 @@ async fn diagnostics_become_events_and_feed_code_actions() {
let resolved = h.mgr.resolve_code_action(doc, actions[0].clone()).await.unwrap();
assert_eq!(s.requested("codeAction/resolve")[0]["data"], json!({ "id": 7 }));
assert_eq!(s.requested("codeAction/resolve")[0]["diagnostics"], json!([diag(0)]));
assert_eq!(resolved.diagnostics, actions[0].diagnostics);
let edit = resolved.edit.unwrap();
assert!(
matches!(&edit.files[0], FileEdit::Edit { path: p, edits, .. } if p == &path && edits[0].range == rng(0, 0, 1, 0))
@@ -1877,6 +2396,34 @@ async fn java_source_paths_reach_initialization_live_configuration_and_builds()
assert_eq!(init["initializationOptions"]["triggerFiles"], json!([uri_of(&file)]));
}
#[tokio::test(flavor = "multi_thread")]
async fn java_build_waits_for_a_reconfigured_server_to_restart() {
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
h.open("App.java", "class App {}\n");
h.wait_status("jdtls", ServerStatus::Ready).await;
let state = h.launcher.state("jdtls");
state.respond("java/buildWorkspace", json!(1));
let gate = Arc::new(Gate::default());
*state.held.lock() = Some(("exit".into(), gate.clone()));
h.mgr.set_config_hook(Box::new(|cfg, _| cfg.args.push("--new-configuration".into())));
let reconfigure = tokio::spawn({
let mgr = h.mgr.clone();
async move { mgr.reconfigure().await }
});
eventually(|| gate.entered()).await;
let mut build = tokio::spawn({
let (mgr, root) = (h.mgr.clone(), h.root.clone());
async move { mgr.java_build(&root, false, &Default::default()).await }
});
let early = tokio::time::timeout(Duration::from_millis(100), &mut build).await;
gate.open();
reconfigure.await.unwrap();
assert!(early.is_err(), "a planned restart must not fail a pending build: {early:?}");
assert_eq!(build.await.unwrap().unwrap(), ide_lsp::jdtls::JavaBuildStatus::Succeeded);
assert_eq!(state.launches.load(Ordering::SeqCst), 2);
h.mgr.shutdown_all().await;
}
#[tokio::test(flavor = "multi_thread")]
async fn java_build_asks_for_a_full_build_when_rebuilding() {
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
@@ -2730,7 +3277,7 @@ async fn noop_handler_is_usable_for_raw_peers() {
let (a, b) = tokio::io::duplex(1024);
let (ar, aw) = tokio::io::split(a);
let (br, bw) = tokio::io::split(b);
let _server = Peer::connect(br, bw, Arc::new(MockHandler(Arc::default())));
let _server = Peer::connect(br, bw, Arc::new(MockHandler(Arc::default(), std::sync::Weak::new())));
let client = Peer::connect(ar, aw, Arc::new(NoopHandler));
let v: Value = client.request("shutdown", (), None).await.unwrap();
assert_eq!(v, Value::Null);
@@ -5136,6 +5683,39 @@ async fn jdtls_import_failure_names_reserved_device_files_on_windows() {
let _ = std::fs::remove_file(&nul);
}
#[tokio::test(flavor = "multi_thread")]
async fn gradle_incompatible_jdk_log_is_surfaced_once_across_message_channels() {
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
let s = h.launcher.state("jdtls");
h.open("A.java", "class A {}");
h.wait_status("jdtls", ServerStatus::Ready).await;
let failure = "Can't use Java 27.0.1.0.3 and Gradle 9.7.1 to import Gradle project kotlin-gradle.";
for _ in 0..2 {
s.peer()
.notify(
"window/logMessage",
json!({ "type": 1, "message": format!("{failure}\njava.lang.Exception: details stay in the log") }),
)
.unwrap();
}
eventually(|| h.events.lock().iter().any(|e| matches!(e, IdeEvent::Notify { body: Some(b), .. } if b == failure)))
.await;
s.peer().notify("window/showMessage", json!({ "type": 1, "message": failure })).unwrap();
s.peer().notify("window/logMessage", json!({ "type": 1, "message": "unrelated internal error" })).unwrap();
s.peer().notify("window/showMessage", json!({ "type": 2, "message": "after" })).unwrap();
eventually(|| h.events.lock().iter().any(|e| matches!(e, IdeEvent::Notify { body: Some(b), .. } if b == "after")))
.await;
let events = h.events.lock();
let messages: Vec<_> = events
.iter()
.filter_map(|e| match e {
IdeEvent::Notify { body: Some(body), .. } => Some(body.as_str()),
_ => None,
})
.collect();
assert_eq!(messages, vec![failure, "after"]);
}
/// Pull Members Up / Push Members Down: the extension's answers, refusals and edits.
#[tokio::test(flavor = "multi_thread")]
async fn java_hierarchy_refactorings_use_the_extension_commands() {
+98
View File
@@ -63,6 +63,104 @@ fn lang(id: &str, ext: &str, server: LanguageServerConfig) -> LanguageConfig {
}
}
#[tokio::test(flavor = "multi_thread")]
#[ignore = "requires OPENIDEA_TEST_JDTLS and OPENIDEA_TEST_GRADLE_HOME, with JDK 21+"]
async fn shared_jdtls_imports_independent_gradle_builds_and_external_root() {
use serde_json::json;
let jdtls = std::env::var("OPENIDEA_TEST_JDTLS").expect("set OPENIDEA_TEST_JDTLS");
let gradle_home = std::env::var("OPENIDEA_TEST_GRADLE_HOME").expect("set OPENIDEA_TEST_GRADLE_HOME");
let dir = tempfile::tempdir().unwrap();
let temp_root = dunce::canonicalize(dir.path()).unwrap();
let root = temp_root.join("workspace");
std::fs::create_dir(&root).unwrap();
let root = dunce::canonicalize(root).unwrap();
let external = temp_root.join("external");
let mut files = Vec::new();
for (name, build) in [("alpha", root.join("alpha")), ("beta", root.join("beta")), ("external", external.clone())] {
let source_dir = build.join("app/src/main/java/demo");
std::fs::create_dir_all(&source_dir).unwrap();
std::fs::write(build.join("settings.gradle"), format!("rootProject.name = '{name}'\ninclude 'app'\n")).unwrap();
std::fs::write(build.join("build.gradle"), "").unwrap();
std::fs::write(build.join("app/build.gradle"), "plugins { id 'java' }\n").unwrap();
let source = format!(
"package demo; public class Main {{ public static void main(String[] args) {{ {name}Marker(); }} static void {name}Marker() {{}} }}\n"
);
let path = source_dir.join("Main.java");
std::fs::write(&path, &source).unwrap();
files.push((name, path, source));
}
let mut java = LanguageRegistry::load(None).unwrap().get(&LanguageId::new("java")).unwrap();
java.language_servers.retain(|s| s.name == "jdtls");
let server = &mut java.language_servers[0];
server.command = jdtls;
server.env.insert("GRADLE_USER_HOME".into(), dir.path().join("gradle-user-home").to_string_lossy().into_owned());
server.args.extend(["-data".into(), dir.path().join("jdtls-data").to_string_lossy().into_owned()]);
let options = server.initialization_options.as_mut().unwrap().as_table_mut().unwrap();
let settings = options.get_mut("settings").unwrap().as_table_mut().unwrap();
let java_settings = settings.get_mut("java").unwrap().as_table_mut().unwrap();
let imports = java_settings.get_mut("import").unwrap().as_table_mut().unwrap();
let gradle = imports.get_mut("gradle").unwrap().as_table_mut().unwrap();
gradle.insert("home".into(), toml::Value::String(gradle_home));
gradle.insert("wrapper".into(), toml::Value::try_from(json!({"enabled": false})).unwrap());
if let Ok(home) = std::env::var("OPENIDEA_TEST_GRADLE_JAVA_HOME") {
gradle.insert("java".into(), toml::Value::try_from(json!({"home": home})).unwrap());
}
let (mgr, events) = manager(&root, LanguageRegistry::from_configs(vec![java]));
mgr.set_jdtls_shared_hook(Box::new(|| true));
mgr.set_content_roots(vec![external]);
for (_, path, source) in &files {
mgr.did_open(DocId::next(), path, &LanguageId::new("java"), 1, source);
}
retry("shared jdtls ready", Duration::from_secs(60), || {
let status = last_status(&events, "jdtls");
if let Some((ServerStatus::Crashed | ServerStatus::Missing | ServerStatus::Misconfigured, message)) = &status {
panic!("shared jdtls failed: {message:?}");
}
async move { matches!(status, Some((ServerStatus::Ready | ServerStatus::Busy, _))).then_some(()) }
})
.await;
for (name, path, _) in &files {
let cp = retry(&format!("{name} Gradle classpath"), Duration::from_secs(180), || async {
let cp =
tokio::time::timeout(Duration::from_secs(10), mgr.java_classpath(path, false, &Default::default()))
.await
.ok()?
.ok()?;
cp.class_paths.iter().any(|p| p.contains(&format!("/{name}/app/"))).then_some(cp)
})
.await;
assert!(
cp.class_paths
.iter()
.all(|p| !files.iter().any(|(other, _, _)| other != name && p.contains(&format!("/{other}/app/")))),
"classpath leaked between builds: {cp:?}"
);
let launch = tokio::time::timeout(
Duration::from_secs(30),
mgr.java_launch(path, false, false, false, false, &Default::default()),
)
.await
.expect("launch resolution timed out")
.expect("launch resolution failed");
assert_eq!(launch.class_paths, cp.class_paths, "launch resolved a different build for {name}");
}
let servers = mgr.servers().into_iter().filter(|s| s.name == "jdtls").collect::<Vec<_>>();
assert_eq!(servers.len(), 1, "expected one JDT LS: {servers:?}");
assert!(servers[0].pid.is_some(), "JDT LS process is not running");
for (_, path, _) in [&files[0], &files[2]] {
let bad = path.parent().unwrap().join("Broken.java");
let bad_source = "package demo; class Broken { MissingType x; }";
std::fs::write(&bad, bad_source).unwrap();
mgr.did_open(DocId::next(), &bad, &LanguageId::new("java"), 1, bad_source);
retry("diagnostics from shared JDT LS", Duration::from_secs(120), || {
let found = events.lock().iter().any(|e| matches!(e, IdeEvent::Diagnostics { path, items, .. } if path.0 == bad && items.iter().any(|d| d.message.contains("MissingType"))));
async move { found.then_some(()) }
}).await;
}
tokio::time::timeout(Duration::from_secs(10), mgr.shutdown_all()).await.unwrap();
}
#[tokio::test(flavor = "multi_thread")]
#[ignore = "requires kmp-lsp on PATH (or OPENIDEA_TEST_KMP_LSP)"]
async fn kmp_lsp_java_end_to_end() {
+14 -1
View File
@@ -54,7 +54,7 @@ use views_bindings::exports::openidea::plugin::view_provider as wit_view;
use views_bindings::openidea::plugin::view_types as wit_vt;
/// The API version of the world above, `major.minor` of [`host_api`].
pub const API_VERSION: &str = "1.4";
pub const API_VERSION: &str = "1.5";
const EPOCH_TICK: Duration = Duration::from_millis(10);
pub(crate) const MAX_TEXT: usize = 1 << 20;
pub(crate) const MAX_TITLE: usize = 256;
@@ -259,6 +259,19 @@ pub trait HostApi: Send + Sync {
body: String,
actions: Vec<crate::NotificationAction>,
) -> Result<(), HostError>;
/// [`HostApi::notify`] in the most recently focused window of the open project at
/// `root`; not-found (and nothing shown) once it has closed.
fn notify_project(
&self,
root: &Path,
level: NotificationLevel,
title: String,
body: String,
actions: Vec<crate::NotificationAction>,
) -> Result<(), HostError> {
let _ = (root, level, title, body, actions);
Err(HostError::NotFound("project notifications unavailable".into()))
}
fn copy_to_clipboard(&self, client: Option<&str>, text: String) -> Result<(), HostError> {
let _ = (client, text);
Ok(())
+41 -15
View File
@@ -362,6 +362,25 @@ impl StoreData {
}
}
/// Checks `ui.notify` buttons; the host checks that their commands are declared.
fn notification_actions(actions: Vec<ui::NotificationAction>) -> Result<Vec<crate::NotificationAction>, wit::Error> {
const MAX_ACTIONS: usize = 3;
if actions.len() > MAX_ACTIONS {
return Err(wit::Error::Invalid(format!("a notification has at most {MAX_ACTIONS} actions")));
}
let mut checked = Vec::with_capacity(actions.len());
for a in actions {
if a.label.trim().is_empty() || a.label.len() > 256 || a.label.chars().any(char::is_control) {
return Err(wit::Error::Invalid(format!("invalid action label {:?}", a.label)));
}
if a.argument.as_ref().is_some_and(|arg| arg.len() > 64 << 10) {
return Err(wit::Error::Invalid("an action argument is limited to 64 KiB".into()));
}
checked.push(crate::NotificationAction { label: a.label, command: a.command, argument: a.argument });
}
Ok(checked)
}
impl ui::Host for StoreData {
fn notify(
&mut self,
@@ -370,22 +389,29 @@ impl ui::Host for StoreData {
body: String,
actions: Vec<ui::NotificationAction>,
) -> Fallible<()> {
const MAX_ACTIONS: usize = 3;
if actions.len() > MAX_ACTIONS {
return Ok(Err(wit::Error::Invalid(format!("a notification has at most {MAX_ACTIONS} actions"))));
}
let mut checked = Vec::with_capacity(actions.len());
for a in actions {
if a.label.trim().is_empty() || a.label.len() > 256 || a.label.chars().any(char::is_control) {
return Ok(Err(wit::Error::Invalid(format!("invalid action label {:?}", a.label))));
}
if a.argument.as_ref().is_some_and(|arg| arg.len() > 64 << 10) {
return Ok(Err(wit::Error::Invalid("an action argument is limited to 64 KiB".into())));
}
checked.push(crate::NotificationAction { label: a.label, command: a.command, argument: a.argument });
}
let actions = match notification_actions(actions) {
Ok(actions) => actions,
Err(e) => return Ok(Err(e)),
};
let (title, body) = (clip(title, MAX_TITLE), clip(body, 16 << 10));
Ok(self.host.notify(self.client(), notification_level(level), title, body, checked).map_err(to_wit))
Ok(self.host.notify(self.client(), notification_level(level), title, body, actions).map_err(to_wit))
}
fn notify_project(
&mut self,
target: Resource<ProjectRes>,
level: wit::NotificationLevel,
title: String,
body: String,
actions: Vec<ui::NotificationAction>,
) -> Fallible<()> {
let actions = match notification_actions(actions) {
Ok(actions) => actions,
Err(e) => return Ok(Err(e)),
};
let root = self.table.get(&target)?.root.clone();
let (title, body) = (clip(title, MAX_TITLE), clip(body, 16 << 10));
Ok(self.host.notify_project(&root, notification_level(level), title, body, actions).map_err(to_wit))
}
fn show_notification(
+5 -5
View File
@@ -91,15 +91,15 @@ fn the_sdk_carries_the_hosts_wit() {
#[test]
fn the_version_check_runs_compatible_components_and_explains_the_rest() {
let host = v("1.4.0");
for ok in ["1.0.0", "1.3.0", "1.3.7", "1.4.0", "1.0.0-rc.1"] {
let host = v("1.5.0");
for ok in ["1.0.0", "1.3.0", "1.4.7", "1.5.0", "1.0.0-rc.1"] {
assert_eq!(check_api(&v(ok), &host), Ok(()), "{ok}");
}
let newer = check_api(&v("1.5.0"), &host).unwrap_err();
assert!(newer.contains("1.5") && newer.contains("update OpenIDEA"), "{newer}");
let newer = check_api(&v("1.6.0"), &host).unwrap_err();
assert!(newer.contains("1.6") && newer.contains("update OpenIDEA"), "{newer}");
assert!(check_api(&v("2.0.0"), &host).unwrap_err().contains("update OpenIDEA"));
let older = check_api(&v("0.2.0"), &host).unwrap_err();
assert!(older.contains("rebuild it with the 1.4 SDK"), "{older}");
assert!(older.contains("rebuild it with the 1.5 SDK"), "{older}");
// 0.x: every minor is its own API.
assert_eq!(check_api(&v("0.2.0"), &v("0.2.5")), Ok(()));
assert!(check_api(&v("0.1.0"), &v("0.2.0")).is_err());
Binary file not shown.
@@ -1 +1 @@
4647fa9aec9732e3dd824efccd61b1c6b9c36645a3586f402f6a7a47419db61f
9963d6606bb6b859bebe1f55892488944f6d5e3308e6c0109f38f4b1a67b01f5
Binary file not shown.
@@ -1 +1 @@
6787b195df1057176ea9fcf11d0fcaf8cd347fc182fab94fe3a229063deed831
7ab9937fdb2e78d0d62d9b85b78a0a6d9511224f85908c3a5a5e9c0d4b5e7e14
Binary file not shown.
@@ -1 +1 @@
c2d17076337b1225ef8b0323b335a3b03bf022485088957c520e0cb797d7df7d
cf1f91d8accf8339bf6e22bef5067bfe3cba75d38cd30d8748b0aa451ee2495b
Binary file not shown.
@@ -1 +1 @@
415a4cd17c573aeeb1b45d4b1af55834eab5e686c4f624495323e4fb63c3ac71
12ad7d649d752f2a40b28dee718a3e04b64597496ab43787c084e3cb774ccfe6
Binary file not shown.
@@ -1 +1 @@
5f502b7650acd5d07bffba8016680248b5ceac0926013916ff351562e3c6d831
4bfce353b3c94490c5f95a046cd2371042b243c326bd63c65619e1489d7ab889
Binary file not shown.
@@ -1 +1 @@
19e8d460269999aeac7b4021bfedb803ad0d68c24ce1c22f058070fb406a64c3
cfbd6aac217a7baea907affe4e1e7f89d1330843398e913e68b71f3a99cfb71b
Binary file not shown.
@@ -1 +1 @@
63809abb0f8a4f40b0c0f465062cc8ba0ebfd03d41a108ff19866e6565eda931
dcf00138820979f523bd93e1f6b08c627c6f061ae5b6e91b340646f905593cf3
Binary file not shown.
@@ -1 +1 @@
99c968b98d8f1d895c428b4048451275bd322e7070aa0a020737097efea17eb1
98e0e01e8405ae7501e89c0dab06e6672b5240a831b19e8fbf0604a3171b31e9
Binary file not shown.
@@ -1 +1 @@
c40a0fd8c6cf791e70ba0335c6cf0b2c82b250670ebad59b185e3e8719172409
fcee11f27ff9ae0038e8067712e269ccd49423372f692e9351c24f82906aafad
Binary file not shown.
@@ -1 +1 @@
162774155d8b49d53b0c0c055736bd098723c66b02c0e1dc4389985982c57282
5c777d7b146277fa803587d1788a7359b08478def00c4574dd565d784200e39a
Binary file not shown.
@@ -1 +1 @@
39e3de11f69c09348906878367318157fca9a777f9ac1c4fb6ed303fa058e5a2
825b9ee483796efd24fcfe358b94f4667d87e311475769ec9e41ba9af6d03a6a
Binary file not shown.
@@ -1 +1 @@
4a3f595d2a6a9b79da7e316a3da86c287e9761ea670319c331467e7b10639663
45b8cbd2ed7635ce04651937574dde9cf441dd619d76b8996f522c671950eaee
Binary file not shown.
@@ -1 +1 @@
bede1f0eaec356e3e35302e65e85531870625560bc9a3197f1acb58969beb4bd
e456b7ddf087ffbf2df62431bf9df47fad1929079c99a941a5deaf30599109f9
Binary file not shown.
@@ -1 +1 @@
ad2dc0ad12adb9c6b25017b2cef78b7496e96d1bb6e88b24ea9b7b682120e786
e0d8b590ad6ffd9569c6362802b2be909cb34b37b18cc8cc8492f9c460610e20
Binary file not shown.
@@ -1 +1 @@
2c329cf7af36c9d65ed5b6be7c2a017c7a5a6214a4893cab7ae574acb76fe62f
d2512203db3e5c6df93e9ccd5ff8daed8f1ebd28462b8caf7d500e814d3b6671
+8
View File
@@ -75,6 +75,14 @@ impl Plugin for Jobs {
)?;
Ok(None)
}
"notify-project" => {
// The argument is a path in the target project, as a scheduled check would have.
let path = arg.unwrap_or_default();
let project = host::project_of(path).ok_or("no project contains the path")?;
let action = openidea_plugin::NotificationAction::new("Disable", "context").argument(path);
project.notify(NotificationLevel::Info, "Project update", path, &[action])?;
Ok(None)
}
"copy" => {
host::copy_to_clipboard(arg.unwrap_or_default())?;
Ok(None)
@@ -1 +1 @@
39c85942177e937e48f32bcde86a0068dac484ba028a8d6785a5a2728db9e11b
b32da5137b552cfad2557716343ed8ac9236b34fec018e3f2facc7b78b67e72a
Binary file not shown.
@@ -1 +1 @@
30a8cc05a40c7b99a78700430ae87d025ad7567b44384fdb696119aa421f8174
ae58467cdb6e98a491d739884eee96d239f80451de749950d81f4fd480edba58
+3 -3
View File
@@ -5,9 +5,9 @@ wit_bindgen::generate!({
inline: "
package fixture:misbehave;
world misbehave {
include openidea:plugin/plugin-syntax@1.4.0;
export openidea:plugin/providers@1.4.0;
export openidea:plugin/file-actions@1.4.0;
include openidea:plugin/plugin-syntax@1.5.0;
export openidea:plugin/providers@1.5.0;
export openidea:plugin/file-actions@1.5.0;
}
",
path: "../../../../../wit",
Binary file not shown.
@@ -1 +1 @@
5f9c92b23c00269ec2b5dc8829ce526a8fc4c0d76ecb2925ec9a06ffeac5f0b4
e378c85a4fe395c7b172065a33a2f15ad76852ac42d4099d2cf1f64540e13143
Binary file not shown.
Loaded 100 of 599 files, more files were not shown because too many files have changed in this diff. Show more