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

...
45 Коміти
Автор SHA1 Повідомлення Дата
Vadim LazukтаInf1mum d62e9855b1 feat(plugins): read a project file's bytes (API 1.5) (#2524)
`read-file` returns UTF-8 text only, so a plugin could not read a PDF, image, archive or font inside the open project, and `fs.read-bytes` needs a per-path grant. `workspace.project` gains `read-bytes(path)` with `read-file`'s rules (`project.read`, `resolve_in_root` confinement, `not-found` for missing paths, 16 MiB cap; always the file on disk). The Rust SDK exposes `Project::read_bytes` and the mock host `add_file_bytes`. Web custom editors get `document.bytes()` (at most 64 MiB, read-only).

Fixes #2347

Co-authored-by: Inf1mum <[email protected]>
2026-11-11 01:50:10 +08:00
l0ng-ai 9660a47958 fix(i18n): say 依赖于 for project dependencies in Chinese (#2577)
The Project Structure dependency labels translated "depends on" as 取决于 ("is determined by"); they now read 依赖于.
2026-11-11 01:44:31 +08:00
l0ng-ai 2d404a8664 fix(i18n): restore the missing commas in the en and zh text catalogs (#2576)
Two merged changes (#2568, #2572) each appended entries next to "Recent color", and the textual merge dropped the comma between them, so en.json and zh.json no longer parsed.
2026-11-11 01:43:13 +08:00
aconeshanaтаaconeshana 83cbc8ff5c fix(vcs): open the source file on double-click in the modal Local Changes (#2500)
In the modal Local Changes view (Git tool window), double-click and Enter always opened the diff. `openNode` in `CommitView.tsx` ignored the commit interface. The modal view now opens the source file and the non-modal Commit tool window keeps the diff. A new `vcs.commit.doubleClickOpensDiff` setting (unset = follow the interface) is exposed as Show on Double-Click → Source / Diff / Follow the Interface.

Co-authored-by: aconeshana <[email protected]>
2026-11-11 01:42:25 +08:00
Muuтаmuutot eab165b4e0 fix(project-structure): list only configured projects as dependency candidates (#2572)
The Dependencies checklist offered every recently opened project as a candidate, which made the list long and noisy. A standalone project now lists only its configured dependency projects; in a workspace the other roots attached to the window stay candidates, and Add Project… picks a new one (the empty-state note says so).

Co-authored-by: muutot <[email protected]>
2026-11-11 01:41:48 +08:00
Muuтаmuutot 5bdf4835b0 feat(editor): lean location chooser rows with the container column (#2482)
The location choosers (Choose Declaration / Implementation and the gutter marker targets) showed the name, a source-line preview, the file name and often a full absolute path on every row. Rows now have one layout: icon + name, the declaring container, and `path:line` right-aligned.

Co-authored-by: muutot <[email protected]>
2026-11-11 01:41:27 +08:00
Muuтаmuutot 1acc007e47 feat(editor): fall back to type-hierarchy subtypes for gutter markers (#2434)
Servers that answer implementation requests with nothing (Pyrefly) still know the subtypes in the type hierarchy. When a type's implementation batch comes back empty, the gutter asks `typeHierarchy/subtypes` and uses its answer for the ↓ marker. The ↑ marker on class declarations is dropped.

Co-authored-by: muutot <[email protected]>
2026-11-11 01:40:50 +08:00
Bin 727dabe579 fix(editor): anchor the colour picker and complete its controls (#2568)
Colour swatches overlapped the Git change indicators, and the native colour picker could open at the window's top-left corner. The gutter spacing is adjusted and the native picker is replaced by a picker anchored beside the swatch (react-colorful) with screen sampling, a preview, recent colours and R/G/B/A%/HEX input. Markup files use the syntax tree, so colour-like template or HTML text no longer gets a swatch while embedded and inline styles still do.

Co-authored-by: ffgenius <[email protected]>
2026-11-11 01:40:07 +08:00
Muuтаmuutot c70203b640 feat(ui): numeric mnemonics in the Select In popup (#2567)
The Select In popup (Alt+F1) labels its first nine selectable rows 1–9, and pressing the digit activates the row. `MenuList`/`Menu` gained an optional `numbers` flag and `showContextMenu` an options argument; only Select In opts in, and submenus never inherit the numbering.

Co-authored-by: muutot <[email protected]>
2026-11-11 01:39:21 +08:00
Muuтаmuutot c088fa8b26 fix(editor): collapse empty icon gutters (#2566)
The run, npm-script, HTTP-endpoint and bookmark gutters reserved a column even when a file had none of their icons, pushing the code right by an empty strip. Each now emits a zero-width, non-interactive spacer marker when it has nothing to show, so the column collapses; files with icons are unchanged. A shared `gutterSpacer` factory keeps the spacer from reacting to clicks.

Co-authored-by: muutot <[email protected]>
2026-11-11 01:39:06 +08:00
l0ng-ai 481cf172b5 feat(java): offer every Java language level, 5 through 25 (#2575)
The Java language level settings offered only the LTS releases, and only those reached jdtls. Every level from 5 to 25 is now listed, and each maps to JDT's compliance value (1.5 to 1.8, then 9 to 25), so projects on 6 or 7 can be set up with a jdtls that still compiles them.

Fixes #2465
2026-11-11 01:29:26 +08:00
l0ng-ai ad7f03090a fix(python): name conda environments after their prefix on Windows (#2571)
Conda interpreters were labelled with the interpreter's grandparent folder, which on Windows (python.exe at the prefix root) named every env under envs/ "conda: envs" and the base env after the install folder; the colliding names made the interpreter popup show every full path. Env names and homes now come from the prefix itself (base for the installation), and creating a conda env on Windows finds python.exe at the prefix root instead of reporting it as not created.

Fixes #2512
2026-11-11 01:18:53 +08:00
l0ng-ai 0e3b35e1e5 fix(nav): answer unresolved names only from dependency roots (#2569)
When the language server found no declaration, Go to Declaration and the Ctrl/Cmd-hover link fell back to every index symbol with that name in the file's navigation roots, its own root included. The server already sees that root, so an unresolved member call such as r.save() offered every save in the project. The index fallback now searches only the roots the server can't see: configured dependency projects and extra content roots.
2026-11-11 01:15:36 +08:00
Shohruh Ismoilovтаshohruh-genius 025e3e9255 fix(project): detect project types and honor workspace type sorting (#2522)
Workspace roots always sorted by name and only Maven/Gradle roots had a type icon. Project types are now detected from root build/SDK file names (no file contents or tools are read, up to four concurrent listings) through one registry (projectType.ts) that supplies names, icons, markers and refresh handling; roots show the type's icon, and Sort by Type groups roots by type with natural name order inside each type. Flutter and Vite logos come from Simple Icons (CC0, recorded in THIRD_PARTY_LICENSES.md).

Co-authored-by: shohruh-genius <[email protected]>
2026-11-11 01:07:40 +08:00
Deng Liuтаdengliu d78a73452b feat(docker): Compose schema association and internal reference completion/navigation (#2455)
Docker Compose files get Compose Specification completion, hover and validation from yaml-language-server: languages/yaml.toml maps the pinned compose-spec schema to the standard Compose filename globs, and ide-sdk / ide-lsp feed content-detected Compose files and user overrides (Kept / Excluded as !path negations) into yaml.schemas, reconfiguring only when that set changes. The Compose editor completes and navigates (⌘-click / ⌘B) references to declared services, networks, volumes, configs and secrets in depends_on, links, volumes_from, extends.service, networks, volumes, configs and secrets.

Co-authored-by: dengliu <[email protected]>
2026-11-11 01:06:56 +08:00
黄贵波 eed2de8d66 fix(window): zoom to the work area in step with the webview frames (#1450)
Double-clicking the title bar ran NSWindow zoom:, and WebKit doesn't lay the page out during an animated resize, so the window grew empty and the content appeared only at the end. The backend now steps the frame itself over 350 ms (smoothstep), waiting for each webview presentation (_doAfterNextPresentationUpdate:, checked with respondsToSelector: and falling back to a fixed 16 ms step), so the page grows with the window. A window restored maximized by the window-state plugin goes back through AppKit's unmaximize; the frame to restore to is kept in window-zoom.json so a window left zoomed at quit can still restore. Marker lanes recompute at most every 120 ms during resize bursts.

Closes #1448

Co-authored-by: IsGuibo <[email protected]>
2026-11-11 01:05:17 +08:00
Muuтаmuutot e3808fc7c2 feat(project): resolve navigation into attached dependency projects (#2448)
Settings › Project Structure › Dependencies lists other projects this one depends on (and, in a workspace, the roots each member depends on). Dependency projects join the content roots, so the index, Project view and run see them, and navigation resolves across them: nav_resolve_module maps an import path to a file in the file's navigation roots, and nav_index_definitions answers bare-name lookups filtered to those roots inside the index (so own-root matches can't evict dependency matches). navigation_roots is memoized for 1 s, and hand-written member_dependencies keys with the other path separator match on Windows.

Co-authored-by: muutot <[email protected]>
2026-11-11 01:01:09 +08:00
Budi Santosoтаboenkkk 33786c14ba feat(db): Oracle thin driver and package nodes (#2129)
Oracle data sources now connect through the pure-Rust oracledb thin driver (pinned to 26.0.0-beta.5) instead of ODPI-C, so no Oracle Instant Client, ORACLE_HOME or Instant Client setting is needed; TNS descriptors and tnsnames.ora aliases still work in the Database field. The database tree gains a packages folder for Oracle schemas, and a package's DDL opens as its specification and body. The database guide's Oracle section is rewritten for the thin driver.

Co-authored-by: boenkkk <[email protected]>
2026-11-11 00:31:43 +08:00
ntbang0901 51ffd093b7 feat(keymap): import an IntelliJ IDEA keymap XML (#716)
Settings › Keymap › gear menu › Import IntelliJ IDEA Keymap… reads a keymap XML exported from IntelliJ IDEA (or one from its keymaps folder): the parent attribute picks the matching preset, IDEA action ids map through the table IdeaVim's :action uses (moved to src/platform/ideaActions.ts), Swing keystrokes, two-stroke chords and mouse shortcuts are converted, and the result is saved as a new custom keymap with a summary of actions that have no counterpart and skipped shortcuts.

Co-authored-by: ntbang0901 <[email protected]>
2026-11-11 00:25:31 +08:00
Mansur Akhmadiтаmansur-akhmadi 379709f13f feat(editor): rainbow indent guides (#1751)
Settings › Editor › General › Appearance › Rainbow indent guides (off by default, under Show indent guides): guide N is drawn in --syn-rainbow-((N-1) mod 5 + 1), so each level of a deep block has its own colour. With the per-guide strips this only swaps the image each guide uses (--openidea-guide-1…5 from theme.ts), so theme and scheme switches recolour open editors through CSS; code lens rows follow the setting.

Closes #1560

Co-authored-by: mansur-akhmadi <[email protected]>
2026-11-11 00:23:10 +08:00
SunZhiQiang 0311bd3bf8 feat(markdown): add full-screen preview and PNG export to Mermaid diagrams (#2460)
Drawn Mermaid diagrams in the Markdown preview and agent replies get Full screen and Download image buttons. Full screen fits and centres the drawing, pans on drag and zooms with Ctrl/Cmd+wheel; Esc closes it. Download saves a 2x PNG on the editor background through the native save dialog. reid() moved to svgIds.ts so the full-screen copy reuses it.

Co-authored-by: panda-student <[email protected]>
2026-11-11 00:17:20 +08:00
Deng Liuтаdengliu df87c3ba1f feat(vcs): add git blame commit popup and per-commit gutter colors (#2484)
Annotate gutter lines get a background tint per commit, ordered from older (cool) to newer (warm) using the project colour tokens, and hovering highlights every line of that commit. Hovering a line opens a non-modal commit popup with the full hash (opens the commit in Git Log), author, date and the full message with issue and commit-hash links; commit metadata comes from the single-commit log fast path and is cached.

Co-authored-by: dengliu <[email protected]>
2026-11-11 00:15:29 +08:00
SunZhiQiang e8ef07f918 feat(vcs): drag unversioned files onto a changelist to add them (#2531)
The changes tree registered drag sources only for rows that already belonged to a changelist, so files under Unversioned Files could not be dragged. Drag sources and drop targets are now separate: every file or folder row drags, and dropping unversioned paths on a changelist runs Add to VCS (git add) before moving them into that list.

Co-authored-by: panda-student <[email protected]>
2026-11-11 00:12:24 +08:00
KangHyunTaeтаrkdgusxo66 6a612a832c fix(vim): run a key typed right after a Normal-mode IME composition as a Vim command (#2564)
On WebKit, CodeMirror drops the first keydown within 100 ms of compositionend (meant for the IME's own Enter). In Normal mode that key never reached Vim, and a printable key was written into the document — e.g. Korean ㄱ, switch to English, quick j. A single ASCII character that arrives as text input in that window now runs as a Vim key through vimKeyFromEvent (so <Space>, <CR> and langmap apply); a keydown that does reach the editor clears the window, and keys that type no text are still dropped.

Fixes #2562

Co-authored-by: rkdgusxo66 <[email protected]>
2026-11-11 00:07:33 +08:00
DrPepperтаmakise2060 e09cb2353f fix(workbench): keep the resize cursor while dragging a splitter (#2560)
A resize drag set document.body.style.cursor, so as soon as the pointer left the 1px handle the element under it (tree, editor, button) showed its own cursor mid-drag. The drag now holds the cursor on <html> with a data-drag-cursor rule that overrides descendant cursors, released on pointerup/pointercancel/window blur, with nested locks restoring the outer cursor. Tool-window gaps, split and stripe handles, the dialog resize grip and database / Git Log column resizing share it, and the handles use ew-resize / ns-resize throughout.

Co-authored-by: makise2060 <[email protected]>
2026-11-11 00:06:59 +08:00
l0ng-ai bf54006f05 fix(mybatis): find a mapper's XML by searching for its namespace (#2565)
Java → XML lookups read every XML file in the project to find the namespace; with hundreds of XML files that ran past the provider's 500 ms budget, so no gutter markers appeared and repeated timeouts disabled the plugin. The lookup now searches for namespace="…" with the host's text search and reads only the matching files, cached per namespace for 30 s. The plugin requires engine ^1.4 (0.4.1).

Fixes #2505
2026-11-11 00:05:33 +08:00
l0ng-ai ad39bda16f docs: add security policy for private vulnerability reports (#2449)
SECURITY.md asks for vulnerability reports through GitHub's private advisory form and says what to include; the issue chooser links the same form.
2026-11-11 00:01:58 +08:00
l0ng-ai 283b3bbeea fix(agent): show the details an agent sends with an error (#2563)
ACP agents report failures as a generic JSON-RPC error and put the cause in data.details (or a string data). Only the message was shown, so such failures read just "Internal error". The details are now appended unless the message already contains them.
2026-11-10 23:57:29 +08:00
l0ng-ai 51e4ec0ab9 fix(dap): keep output events whose text isn't UTF-8 (#2561)
netcoredbg on Windows embeds the debuggee's console output in the ANSI code page (GBK on a Chinese system). The message wasn't valid UTF-8, failed to parse and was dropped, so Debug console lines with non-ASCII text went missing. A message body that isn't UTF-8 is now decoded like other tool output (UTF-8, else the system code page) and parsed again.

Fixes #2489
2026-11-10 23:55:59 +08:00
ppxbтаppxb 5593260635 fix(maven): run generated build-tool goals with the configured JDK (#2440)
A Maven goal or Gradle task run from the build tool window carried the JAVA_HOME the build model had when the tree was built, and that value was frozen into the (persisted) temporary configuration, so correcting the JDK in Settings only took effect after a restart, or not at all. For IDE-generated build-tool configurations JAVA_HOME is now derived: Settings (the Maven runner JRE, the Gradle JVM) decide at each launch, a saved configuration drops the derived value, and a dead path no longer outlives its setting. Configurations of the user's own, and Tomcat's JDK field, keep their JAVA_HOME.

Fixes #2433

Co-authored-by: ppxb <[email protected]>
2026-11-10 23:44:09 +08:00
l0ng-ai f4f648567f fix(agents): preserve upward scrolling during streaming replies (#2502)
Small upward scrolls while a reply streamed were still treated as being at the end, so each update pulled the view back down. Following pauses on upward wheel, touch or keyboard input before the browser scrolls, and queued updates can't resume it until the user scrolls down or jumps to the latest reply; streaming, resize and late diagram updates leave the viewport alone meanwhile.
2026-11-10 23:41:44 +08:00
l0ng-ai 7e365a3e0f fix(search): resizable preview split in Search Everywhere (#2496)
Search Everywhere had a fixed 220px preview and a separate results height cap. It now has a remembered vertical split (initially one third results, two thirds preview); the query keeps focus while resizing and the active result stays visible when the list shrinks. Action names line up with file names, shortcuts stay readable, and scope controls move to a second row in narrow popups.
2026-11-10 23:41:36 +08:00
l0ng-ai 7594a98e8c fix(search): use 16px file icons and spinners in Find and Usages (#2493)
Find results and Show Usages use the native 16px file/folder icons, and the four search indicators share the 16px eight-spoke animation, replacing 12/14px overrides; the preview and rerun controls get their proper icons.
2026-11-10 23:41:29 +08:00
l0ng-ai 839123caf2 fix(run): use 16px test status icons and the shared running indicator (#2487)
Run and Services test trees now use 16px status and filter icons and the shared eight-spoke running indicator. The fixed 11/13px glyphs made columns uneven, and a status change shifted the name; the running indicator now follows the tree's icon size.
2026-11-10 23:41:22 +08:00
l0ng-ai 9153c66849 fix(run): keep Run/Debug Configurations usable in narrow windows (#2485)
The Name field was squeezed and the dialog overflowed in narrow or zoomed windows because the sidebar, label column and header controls had fixed widths. Add a remembered sidebar splitter, shared label columns, and wrapping Name/storage and footer groups; Before launch actions stay together and both panes keep scrolling independently when the dialog is resized.
2026-11-10 23:41:15 +08:00
l0ng-ai 27ee6fde61 fix(vcs): show feedback for explicit Git commands (#2444)
Update could fail when incoming files collided with local untracked files and show nothing, because VCS popups were disabled. The shared Git command runners now mark progress and results as responses to a user command, so Update, Pull, Fetch, Push and local Git operations stay visible; background notifications and explicitly handled or cancelled failures stay quiet.
2026-11-10 23:41:06 +08:00
l0ng-ai 0574691d7e fix(format): reformat with the Code Style indent, not the detected one (#2554)
Without a per-language Tabs and Indents override, Reformat Code asked the
language server for the indent detected in the file. A file indented with 2
spaces (e.g. formatted elsewhere with Google style) then reformatted to 2
spaces and reported "already properly formatted", while a new file got 4.
Reformat now always asks for the Code Style indent (EditorConfig included);
detection only affects editing, and an indent chosen for the file in the
status bar still applies.

Fixes #2467
2026-11-10 23:37:29 +08:00
l0ng-ai 5948f8a7a3 fix(hierarchy): show no expand arrow on types without subtypes (#2553)
The type hierarchy loaded a level only when its node was expanded, so every
type showed an expand arrow, also one without subtypes. The level below each
shown type is now loaded ahead, four requests at a time, so a leaf shows as
one. Call hierarchies stay lazy.

Fixes #2471
2026-11-10 23:34:42 +08:00
l0ng-ai 828db7bd13 fix(hierarchy): let the hierarchy tab be closed (#2552)
The Hierarchy tool window's content tab had no close button, so a type or
call hierarchy stayed until another one replaced it. The tab is closable
now; closing it clears the tree and hides the tool window, which has no
other content.

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

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

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

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

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

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

Fixes #2001

Co-authored-by: zaojiaoci <[email protected]>
2026-11-10 23:19:14 +08:00
323 змінених файлів з 10266 додано та 1793 видалено

No files matched your search

+3
Переглянути файл
@@ -3,3 +3,6 @@ contact_links:
- name: Download the latest release
url: https://github.com/l0ng-ai/openidea/releases/latest
about: Many problems are already fixed. Please check the latest version before reporting.
- name: Report a security vulnerability
url: https://github.com/l0ng-ai/openidea/security/advisories/new
about: Please report security problems privately, not in a public issue.
Створено
+43 -79
Переглянути файл
@@ -1522,7 +1522,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a"
dependencies = [
"quote",
"syn 2.0.119",
"syn 3.0.9",
]
[[package]]
@@ -1578,16 +1578,6 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "darling"
version = "0.13.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a01d95850c592940db9b8194bc39f4bc0e89dee5c4265e4b1807c34a9aba453c"
dependencies = [
"darling_core 0.13.4",
"darling_macro 0.13.4",
]
[[package]]
name = "darling"
version = "0.23.0"
@@ -1608,20 +1598,6 @@ dependencies = [
"darling_macro 0.24.1",
]
[[package]]
name = "darling_core"
version = "0.13.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "859d65a907b6852c9361e3185c862aae7fafd2887876799fa55f5f99dc40d610"
dependencies = [
"fnv",
"ident_case",
"proc-macro2",
"quote",
"strsim 0.10.0",
"syn 1.0.109",
]
[[package]]
name = "darling_core"
version = "0.23.0"
@@ -1631,7 +1607,7 @@ dependencies = [
"ident_case",
"proc-macro2",
"quote",
"strsim 0.11.1",
"strsim",
"syn 2.0.119",
]
@@ -1644,21 +1620,10 @@ dependencies = [
"ident_case",
"proc-macro2",
"quote",
"strsim 0.11.1",
"strsim",
"syn 3.0.9",
]
[[package]]
name = "darling_macro"
version = "0.13.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c972679f83bdf9c42bd905396b6c3588a843a17f0f16dfcfa3e2c5d57441835"
dependencies = [
"darling_core 0.13.4",
"quote",
"syn 1.0.109",
]
[[package]]
name = "darling_macro"
version = "0.23.0"
@@ -2685,7 +2650,7 @@ checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"libc",
"wasi",
"wasi 0.11.1+wasi-snapshot-preview1",
]
[[package]]
@@ -4333,7 +4298,7 @@ dependencies = [
"ide-core",
"ide-http",
"ide-ssh",
"oracle",
"oracledb",
"parking_lot",
"reqwest",
"rustls",
@@ -4370,6 +4335,7 @@ version = "0.1.40"
dependencies = [
"base64 0.22.1",
"blake3",
"dunce",
"ed25519-dalek",
"ide-core",
"parking_lot",
@@ -5751,7 +5717,7 @@ checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"
dependencies = [
"libc",
"log",
"wasi",
"wasi 0.11.1+wasi-snapshot-preview1",
"windows-sys 0.61.2",
]
@@ -6487,15 +6453,6 @@ dependencies = [
"memchr",
]
[[package]]
name = "odpic-sys"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "920b5474a5128a9f0232df5a0ffc50aaa5b077b29b8b06ab0131985ac82793ed"
dependencies = [
"cc",
]
[[package]]
name = "once_cell"
version = "1.21.4"
@@ -6609,29 +6566,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "oracle"
version = "0.6.3"
name = "oracledb"
version = "26.0.0-beta.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3db40fe6e4df881b683691ade5ef1f7b1afd52aefa115581f7b92855524d7ec0"
checksum = "27b98c7346f2bb5169ec4b2d4f066fb616529ac766b395440c5019fe88d1ef45"
dependencies = [
"cc",
"odpic-sys",
"once_cell",
"oracle_procmacro",
"paste",
"rustversion",
]
[[package]]
name = "oracle_procmacro"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad247f3421d57de56a0d0408d3249d4b1048a522be2013656d92f022c3d8af27"
dependencies = [
"darling 0.13.4",
"proc-macro2",
"quote",
"syn 1.0.109",
"aes",
"base16ct",
"base64ct",
"cbc",
"chrono",
"pbkdf2",
"pkcs8",
"rand 0.10.3",
"rustls",
"sha2 0.11.1",
"uuid",
"webpki-roots",
"whoami",
]
[[package]]
@@ -8918,12 +8870,6 @@ dependencies = [
"unicode-properties",
]
[[package]]
name = "strsim"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623"
[[package]]
name = "strsim"
version = "0.11.1"
@@ -8960,7 +8906,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
@@ -9183,7 +9128,7 @@ dependencies = [
"serde",
"serde_json",
"static_vcruntime",
"strsim 0.11.1",
"strsim",
"tauri-utils",
"tauri-winres",
"walkdir",
@@ -10723,6 +10668,15 @@ version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasi"
version = "0.14.7+wasi-0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c"
dependencies = [
"wasip2",
]
[[package]]
name = "wasip2"
version = "1.0.4+wasi-0.2.12"
@@ -10732,6 +10686,15 @@ dependencies = [
"wit-bindgen",
]
[[package]]
name = "wasite"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42"
dependencies = [
"wasi 0.14.7+wasi-0.2.4",
]
[[package]]
name = "wasm-bindgen"
version = "0.2.129"
@@ -11315,6 +11278,7 @@ dependencies = [
"libc",
"libredox",
"objc2-system-configuration",
"wasite",
"web-sys",
]
+2 -1
Переглянути файл
@@ -106,7 +106,8 @@ rustls-platform-verifier = "0.7"
# 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 }
# Pure-Rust thin Oracle driver; pinned to the beta until it stabilizes.
oracledb = "=26.0.0-beta.5"
bb8 = "0.9"
uuid = { version = "1", features = ["v4"] }
# OS credential stores
+26
Переглянути файл
@@ -0,0 +1,26 @@
# Security policy
## Reporting a vulnerability
Please do not report security problems in public issues, pull requests or discussions.
Report them privately through GitHub:
[Report a vulnerability](https://github.com/l0ng-ai/openidea/security/advisories/new)
(repository **Security** tab → **Report a vulnerability**). Only the maintainers can see
the report.
Include what you can of:
- the affected version, platform, and whether remote development (`openidea-server`)
or plugins are involved;
- steps to reproduce, or a proof of concept;
- the impact you expect, and any fix you suggest.
We will acknowledge the report, keep you updated in the advisory, and agree on a
disclosure date with you. Fixes are released as a new version, and the advisory is
published once users can update.
## Supported versions
Security fixes go into the latest release. Please check that the problem still occurs
there before reporting.
+7
Переглянути файл
@@ -1,5 +1,12 @@
# Third-party licenses
## Flutter and Vite project icons
`src/ui/brands/flutter.svg` and `vite.svg` use the unmodified logo paths from
[Simple Icons](https://github.com/simple-icons/simple-icons), retrieved through
Iconify (`simple-icons:flutter`, `simple-icons:vite`) and filled with the project
icon colors. Licensed under [CC0 1.0 Universal](https://github.com/simple-icons/simple-icons/blob/develop/LICENSE.md).
## Project Lombok
`java/lombok/lombok-1.18.48-openidea.3.jar` contains Project Lombok v1.18.48 with the Equinox
+19 -1
Переглянути файл
@@ -1722,7 +1722,7 @@ impl Task {
format!("The agent process exited:\n{}", tail.join("\n"))
}
}
RpcError::Server { message, .. } => message,
RpcError::Server { message, data, .. } => with_details(message, data.as_ref()),
e => e.to_string(),
}
}
@@ -2180,6 +2180,24 @@ fn usage_limit(e: &RpcError) -> Option<Option<f64>> {
.then_some(None)
}
/// An error's message with the detail ACP agents put in `data` (`{"details": …}` or a string):
/// generic codes such as "Internal error" say nothing without it.
fn with_details(message: String, data: Option<&Value>) -> String {
let details = match data {
Some(Value::String(text)) => text.as_str(),
Some(data) => data.get("details").and_then(Value::as_str).unwrap_or_default(),
None => "",
}
.trim();
if details.is_empty() || message.contains(details) {
message
} else if message.trim().is_empty() {
details.to_owned()
} else {
format!("{message}: {details}")
}
}
fn client_info() -> Value {
json!({ "name": "OpenIDEA", "version": env!("CARGO_PKG_VERSION") })
}
+14
Переглянути файл
@@ -2568,3 +2568,17 @@ fn streamed_text_is_sent_as_appends() {
let rewritten = entry_event("t", &mut streamed, None, 5, &agent(&format!("{}x!??", "é".repeat(4999))));
assert!(matches!(rewritten, AgentEvent::Entry { .. }), "a rewritten end is sent whole");
}
#[test]
fn error_messages_keep_the_agents_details() {
let details = json!({ "details": "spawn claude ENOENT" });
assert_eq!(with_details("Internal error".into(), Some(&details)), "Internal error: spawn claude ENOENT");
assert_eq!(
with_details("Internal error".into(), Some(&json!("model not found"))),
"Internal error: model not found"
);
assert_eq!(with_details(String::new(), Some(&details)), "spawn claude ENOENT");
assert_eq!(with_details("spawn claude ENOENT".into(), Some(&details)), "spawn claude ENOENT");
assert_eq!(with_details("Internal error".into(), Some(&json!({ "other": 1 }))), "Internal error");
assert_eq!(with_details("Internal error".into(), None), "Internal error");
}
+3
Переглянути файл
@@ -73,6 +73,8 @@ pub struct EditorAppearanceSettings {
pub whitespace_inner: bool,
pub whitespace_trailing: bool,
pub indent_guides: bool,
/// Colour indent guides by depth (`--syn-rainbow-*`), while `indent_guides` is on.
pub rainbow_indent_guides: bool,
/// Right margin and visual guides from Code Style.
pub hard_wrap_guides: bool,
pub intention_bulb: bool,
@@ -95,6 +97,7 @@ impl Default for EditorAppearanceSettings {
whitespace_inner: true,
whitespace_trailing: true,
indent_guides: true,
rainbow_indent_guides: false,
hard_wrap_guides: true,
intention_bulb: true,
distraction_free_width: 80,
+26
Переглянути файл
@@ -1273,6 +1273,11 @@ pub struct ProjectStructureSettings {
pub unmarked: Vec<String>,
/// Content roots besides the project folder (absolute paths), shown and searched with it.
pub content_roots: Vec<String>,
/// Other projects (absolute paths) this project depends on; indexed and navigable like content roots.
pub dependencies: Vec<String>,
/// Per-workspace-member dependencies: member root (IPC path) → the roots it depends on.
/// A member not listed here (or with an empty list) navigates only into itself.
pub member_dependencies: BTreeMap<String, Vec<String>>,
/// "Exclude files": `;`-separated name patterns (`*.log;tmp*`) excluded at any depth.
pub exclude_files: String,
/// Project compiler output directory (relative to project root or absolute; empty = default).
@@ -1446,6 +1451,27 @@ mod tests {
}
}
#[test]
fn double_click_opens_diff_is_unset_by_default_and_round_trips() {
let old: Settings = serde_json::from_str(r#"{"vcs":{"commit":{"nonModal":true}}}"#).unwrap();
assert_eq!(old.vcs.commit.double_click_opens_diff, None);
// The unset value shows up as `null` in the defaults: `SettingsStore::set` only
// accepts a key path it finds there.
let json = serde_json::to_value(Settings::default()).unwrap();
assert_eq!(json["vcs"]["commit"]["doubleClickOpensDiff"], serde_json::Value::Null);
for on in [true, false] {
let mut settings = Settings::default();
settings.vcs.commit.double_click_opens_diff = Some(on);
let json = serde_json::to_value(&settings).unwrap();
assert_eq!(json["vcs"]["commit"]["doubleClickOpensDiff"], on);
let restored: Settings = serde_json::from_value(json).unwrap();
assert_eq!(restored.vcs.commit.double_click_opens_diff, Some(on));
}
// Explicit `null` reads back as unset.
let cleared: Settings = serde_json::from_str(r#"{"vcs":{"commit":{"doubleClickOpensDiff":null}}}"#).unwrap();
assert_eq!(cleared.vcs.commit.double_click_opens_diff, None);
}
#[test]
fn agent_meters_default_and_round_trip() {
let old: Settings = serde_json::from_str(r#"{"agents":{"showCost":true}}"#).unwrap();
+8
Переглянути файл
@@ -115,6 +115,13 @@ pub struct CommitSettings {
pub wrap_at_margin: bool,
/// Plugin before-commit checks turned off (`<plugin id>/<check id>`); the rest run.
pub disabled_plugin_checks: Vec<String>,
/// Double-click / Enter in the changes view: `None` follows the interface (the
/// Commit tool window opens the diff, Local Changes the source file).
///
/// No `skip_serializing_if`: an unset value must still appear in
/// `Settings::default()`, or `SettingsStore::set` rejects the key as unknown.
#[specta(optional)]
pub double_click_opens_diff: Option<bool>,
}
impl Default for CommitSettings {
@@ -135,6 +142,7 @@ impl Default for CommitSettings {
show_right_margin: true,
wrap_at_margin: false,
disabled_plugin_checks: Vec::new(),
double_click_opens_diff: None,
}
}
}
+36 -7
Переглянути файл
@@ -246,13 +246,7 @@ impl Connection {
fn dispatch(inner: &Inner, incoming: &mpsc::UnboundedSender<Incoming>, body: &[u8]) {
tracing::trace!(target: "ide_dap::wire", "<-- {}", String::from_utf8_lossy(body));
let msg: Value = match serde_json::from_slice(body) {
Ok(v) => v,
Err(e) => {
tracing::warn!("dap: unparsable message: {e}");
return;
}
};
let Some(msg) = parse_message(body) else { return };
match msg.get("type").and_then(Value::as_str) {
Some("response") => {
let Some(request_seq) = msg.get("request_seq").and_then(Value::as_i64) else {
@@ -285,6 +279,25 @@ fn dispatch(inner: &Inner, incoming: &mpsc::UnboundedSender<Incoming>, body: &[u
}
}
/// A message body as JSON. Some adapters pass the debuggee's output through as it was
/// written: netcoredbg on Windows embeds console output in the ANSI code page (GBK on a
/// Chinese system), which isn't valid UTF-8 and made the whole `output` event unparsable,
/// so every line with non-ASCII text went missing. Such a body is decoded like other tool
/// output (UTF-8, else the system code page) before parsing.
fn parse_message(body: &[u8]) -> Option<Value> {
let error = match serde_json::from_slice(body) {
Ok(v) => return Some(v),
Err(e) => e,
};
if std::str::from_utf8(body).is_err()
&& let Ok(v) = serde_json::from_str(&ide_core::output_encoding::decode_tool_output(body))
{
return Some(v);
}
tracing::warn!("dap: unparsable message: {error}");
None
}
/// `message`, or the formatted `body.error` (`{format, variables}`) when present.
fn error_message(msg: &Value) -> String {
if let Some(err) = msg.pointer("/body/error")
@@ -306,6 +319,22 @@ fn error_message(msg: &Value) -> String {
mod tests {
use super::*;
#[test]
fn parses_output_events_with_non_utf8_text() {
// "中文" in GBK inside an output event, as netcoredbg sends it on a Chinese Windows.
let mut body = br#"{"seq":5,"type":"event","event":"output","body":{"category":"stdout","output":""#.to_vec();
body.extend_from_slice(&[0xd6, 0xd0, 0xce, 0xc4]);
body.extend_from_slice(br#"\n"}}"#);
let msg = parse_message(&body).expect("parsed");
assert_eq!(msg["event"], "output");
let output = msg["body"]["output"].as_str().unwrap();
assert!(output.ends_with('\n'));
// Off Windows (UTF-8 code page) the bytes decode lossily; the line still arrives.
#[cfg(not(windows))]
assert!(output.starts_with(char::REPLACEMENT_CHARACTER));
assert!(parse_message(b"{not json").is_none());
}
#[test]
fn formats_structured_errors() {
let msg =
+1 -1
Переглянути файл
@@ -20,7 +20,7 @@ parking_lot.workspace = true
futures.workspace = true
sqlx.workspace = true
tiberius.workspace = true
oracle.workspace = true
oracledb.workspace = true
bb8.workspace = true
tokio-util = { workspace = true, features = ["compat"] }
dirs.workspace = true
+3 -3
Переглянути файл
@@ -417,7 +417,7 @@ fn binary_type(name: &str, text: bool) -> bool {
}
/// Bytes as text: UTF-8, else hex (grids) or with invalid sequences replaced (catalog names).
fn bytes_text(bytes: Vec<u8>, mode: Cells) -> String {
pub(crate) fn bytes_text(bytes: Vec<u8>, mode: Cells) -> String {
match (String::from_utf8(bytes), mode) {
(Ok(s), _) => s,
(Err(e), Cells::Display) => hex(e.as_bytes()),
@@ -1042,7 +1042,7 @@ pub enum Held {
Mysql(Box<bb8::PooledConnection<'static, MysqlManager>>),
SqlServer(Box<bb8::PooledConnection<'static, MssqlConnectionManager>>),
Clickhouse(crate::clickhouse::ClickhousePool),
Oracle(crate::oracle::OracleConnection),
Oracle(Box<crate::oracle::OracleConnection>),
}
impl Held {
@@ -1058,7 +1058,7 @@ impl Held {
}
Held::Clickhouse(p.clone())
}
Pool::Oracle(p) => Held::Oracle(p.get().await?),
Pool::Oracle(p) => Held::Oracle(Box::new(p.get().await?)),
})
}
+41 -3
Переглянути файл
@@ -57,6 +57,7 @@ impl DbTableKind {
pub enum DbObjectKind {
Function,
Procedure,
Package,
Trigger,
Sequence,
}
@@ -92,12 +93,21 @@ pub struct SequenceInfo {
pub data_type: String,
}
/// A schema's routines (procedures and functions) and sequences.
/// An Oracle package (its procedures and functions are not listed separately).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
#[serde(rename = "DbPackageInfo")]
pub struct PackageInfo {
pub name: String,
}
/// A schema's routines (procedures and functions), packages and sequences.
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
#[serde(rename = "DbSchemaObjects")]
pub struct SchemaObjects {
pub routines: Vec<RoutineInfo>,
pub packages: Vec<PackageInfo>,
pub sequences: Vec<SequenceInfo>,
}
@@ -296,7 +306,7 @@ fn through_tunnel(e: IdeError, source: &DataSource, via: Option<&str>) -> IdeErr
/// Options › Run keep-alive query: pings the pool until it is closed or dropped
/// (a dropped SSH tunnel or an idle-killing proxy stays alive meanwhile).
fn keep_alive(pool: &Arc<Pool>, every: Duration) {
pub(crate) fn keep_alive(pool: &Arc<Pool>, every: Duration) {
let pool = Arc::downgrade(pool);
tokio::spawn(async move {
let mut tick = tokio::time::interval(every);
@@ -853,6 +863,14 @@ impl DbManager {
})
.collect();
}
if let Some(q) = sql::packages(driver, schema) {
out.packages = pool
.rows(&q, MAX_META_ROWS)
.await?
.into_iter()
.filter_map(|r| Some(PackageInfo { name: r.into_iter().next().flatten()? }))
.collect();
}
if let Some(q) = sql::sequences(driver, schema) {
out.sequences = pool
.rows(&q, MAX_META_ROWS)
@@ -870,9 +888,29 @@ impl DbManager {
Ok(out)
}
/// The definition of a routine (every overload), trigger or sequence.
/// The definition of a routine (every overload), package (spec and body), trigger or sequence.
pub async fn object_ddl(&self, id: &str, schema: &str, kind: DbObjectKind, name: &str) -> IdeResult<String> {
let pool = self.pool(id).await?;
if kind == DbObjectKind::Package {
let mut defs: Vec<String> = Vec::new();
for body in [false, true] {
let Some(q) = sql::package_ddl(pool.driver(), schema, name, body) else { continue };
match pool.rows(&q, MAX_META_ROWS).await {
Ok(rows) => defs.extend(rows.into_iter().filter_map(|r| r.into_iter().next().flatten())),
// A spec-only package has no body: the spec alone still opens.
Err(e) if body => tracing::warn!("package body {schema}.{name}: {}", e.message),
Err(e) => return Err(e),
}
}
if defs.is_empty() {
return Err(IdeError::not_found(format!("{schema}.{name}")));
}
return Ok(defs
.iter()
.map(|d| format!("{};\n", d.trim_end().trim_end_matches(';')))
.collect::<Vec<_>>()
.join("\n"));
}
let (q, column) = sql::object_ddl(pool.driver(), schema, kind, name)
.ok_or_else(|| IdeError::invalid(format!("no {kind:?} objects in this database")))?;
let defs: Vec<String> = pool
File diff suppressed because it is too large. Load diff
+1 -3
Переглянути файл
@@ -20,7 +20,7 @@ pub enum DbDriver {
/// Microsoft SQL Server.
SqlServer,
Clickhouse,
/// Oracle Database (via Oracle Instant Client).
/// Oracle Database (pure-Rust thin driver, no client library).
Oracle,
}
@@ -88,8 +88,6 @@ pub struct DbOptions {
pub auto_disconnect_secs: Option<u32>,
/// PostgreSQL: show only the configured database, its schemas right under the data source.
pub single_database: bool,
/// Oracle: directory of the Oracle Instant Client (empty: `ORACLE_HOME` / system search path).
pub instant_client: String,
}
/// Schemas tab: the databases / schemas the tree shows. Names may use `*` and `?`.
+39
Переглянути файл
@@ -664,6 +664,32 @@ pub fn routines(driver: DbDriver, schema: &str) -> Option<String> {
}
}
/// Rows: `name` of a schema's packages (Oracle only; their procedures and functions are not
/// listed separately, as in IDEA).
pub fn packages(driver: DbDriver, schema: &str) -> Option<String> {
match driver {
DbDriver::Oracle => Some(format!(
"SELECT object_name FROM all_objects WHERE owner = {} AND object_type = 'PACKAGE' \
AND object_name NOT LIKE 'BIN$%' ORDER BY 1",
quote_literal(driver, schema)
)),
_ => None,
}
}
/// The statement reading a package's specification (`body` false) or body as DDL.
pub fn package_ddl(driver: DbDriver, schema: &str, name: &str, body: bool) -> Option<String> {
match driver {
DbDriver::Oracle => Some(format!(
"SELECT DBMS_METADATA.GET_DDL('{}', {}, {}) FROM DUAL",
if body { "PACKAGE_BODY" } else { "PACKAGE" },
quote_literal(driver, name),
quote_literal(driver, schema)
)),
_ => None,
}
}
/// Rows: `name, data type` of a schema's sequences (PostgreSQL, Oracle).
pub fn sequences(driver: DbDriver, schema: &str) -> Option<String> {
match driver {
@@ -963,6 +989,7 @@ mod tests {
assert!(completion_columns(DbDriver::Oracle).contains("all_tab_columns"));
assert!(triggers(DbDriver::Oracle, "hr", "t").contains("all_triggers"));
assert!(routines(DbDriver::Oracle, "hr").unwrap().contains("all_objects"));
assert!(packages(DbDriver::Oracle, "hr").unwrap().contains("object_type = 'PACKAGE'"));
assert!(sequences(DbDriver::Oracle, "hr").unwrap().contains("all_sequences"));
}
@@ -984,6 +1011,17 @@ mod tests {
let (q, col) = object_ddl(DbDriver::Oracle, "hr", K::Procedure, "p").unwrap();
assert!(q.contains("DBMS_METADATA.GET_DDL('PROCEDURE'") && col == 0);
assert!(object_ddl(DbDriver::Oracle, "hr", K::Sequence, "q").unwrap().0.contains("all_sequences"));
assert!(
package_ddl(DbDriver::Oracle, "hr", "pkg", false)
.unwrap()
.contains("DBMS_METADATA.GET_DDL('PACKAGE', 'pkg', 'hr')")
);
assert!(
package_ddl(DbDriver::Oracle, "hr", "pkg", true)
.unwrap()
.contains("DBMS_METADATA.GET_DDL('PACKAGE_BODY', 'pkg', 'hr')")
);
assert!(package_ddl(DbDriver::Postgres, "public", "pkg", false).is_none());
}
#[test]
@@ -1001,6 +1039,7 @@ mod tests {
fn schema_objects_per_dialect() {
use crate::DbObjectKind as K;
assert!(routines(DbDriver::Sqlite, "main").is_none());
assert!(packages(DbDriver::Sqlite, "main").is_none());
assert!(sequences(DbDriver::Mysql, "db").is_none());
assert!(routines(DbDriver::Postgres, "public").unwrap().contains("n.nspname = 'public'"));
assert_eq!(
+1
Переглянути файл
@@ -18,6 +18,7 @@ semver = "1"
blake3.workspace = true
zip.workspace = true
base64.workspace = true
dunce.workspace = true
ed25519-dalek = "3"
[dev-dependencies]
+18 -2
Переглянути файл
@@ -441,11 +441,13 @@ pub fn resolve_program(name: &str, path_var: Option<&OsStr>) -> Result<PathBuf,
/// `p` with its parent canonicalized but the file name as found: PATH
/// entries like `/var` → `/private/var` (macOS) or `~` links collapse to
/// their real directory, while a linked program (`cargo` → rustup) keeps
/// its link name so the shim still dispatches on argv[0].
/// its link name so the shim still dispatches on argv[0]. `dunce` keeps the
/// path in its usual form on Windows: a `\\?\`-prefixed pin would never
/// match the path the program was actually started with.
fn canonical_dir(p: &Path) -> PathBuf {
let Some(dir) = p.parent() else { return p.to_path_buf() };
let Some(name) = p.file_name() else { return p.to_path_buf() };
std::fs::canonicalize(dir).map(|d| d.join(name)).unwrap_or_else(|_| p.to_path_buf())
dunce::canonicalize(dir).map(|d| d.join(name)).unwrap_or_else(|_| p.to_path_buf())
}
fn is_executable(p: &Path) -> bool {
@@ -498,6 +500,20 @@ mod tests {
list.iter().map(|s| s.parse().unwrap()).collect()
}
#[test]
#[cfg(windows)]
fn pinned_program_path_has_no_verbatim_prefix() {
// std::fs::canonicalize returns \\?\-prefixed paths on Windows; a pin
// stored in that form would never match the plain path the program is
// started with. dunce keeps the usual form.
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("cargo.exe"), b"").unwrap();
let pinned = resolve_program("cargo.exe", Some(dir.path().as_os_str())).unwrap();
let shown = pinned.to_string_lossy();
assert!(!shown.starts_with(r"\\?\"), "verbatim prefix leaked into the pin: {shown}");
assert!(pinned.is_absolute());
}
fn req<'a>(version: &'a str, hash: &'a str, required: &[&str], optional: &[&str]) -> Request<'a> {
Request {
subject: Subject::Installed("p".into()),
+69 -1
Переглянути файл
@@ -8,7 +8,7 @@ use ide_language::{FileReference, WebReference};
use ide_workspace::file_refs::{self, PathCompletion};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
/// A web reference no language server resolves and where it points: `class="card"` →
/// `.card` selectors, `id` → `#id` selectors, `href="#top"` → the element, `var(--x)` →
@@ -112,6 +112,68 @@ pub async fn web_goto(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeRes
.await
}
/// The file a Python import path names: `a.b.c` is `a/b/c.py(i)` or `a/b/c/__init__.py(i)`
/// in the project or one of its content roots (an attached dependency project included);
/// `.x` names it relative to `from`'s directory. Neither the language server (it analyses a
/// single root) nor the definition index resolves a package segment, so ⌘B on the `a.b` of
/// `from a.b import x` needs this.
pub async fn nav_resolve_module(host: &Host, window: &str, name: String, from: IpcPath) -> IdeResult<Option<IpcPath>> {
let project = host.project(window)?;
blocking(move || {
let dots = name.len() - name.trim_start_matches('.').len();
let parts: Vec<&str> = name.split('.').filter(|s| !s.is_empty()).collect();
if parts.is_empty() {
return Ok(None);
}
let rel = parts.iter().collect::<PathBuf>();
let bases: Vec<PathBuf> = if dots > 0 {
let dir = PathBuf::from(from);
let dir = dir.parent().map(Path::to_path_buf).unwrap_or(dir);
let base = (0..dots.saturating_sub(1)).fold(dir, |p, _| p.parent().map(Path::to_path_buf).unwrap_or(p));
vec![base]
} else {
crate::project::navigation_roots(&project, &from)
};
for base in bases {
let dir = base.join(&rel);
for candidate in
[dir.with_extension("py"), dir.with_extension("pyi"), dir.join("__init__.py"), dir.join("__init__.pyi")]
{
if candidate.is_file() {
return Ok(Some(IpcPath::from(candidate)));
}
}
}
Ok(None)
})
.await
}
/// Index definitions named `name` in the other roots `from` may navigate into (its configured
/// dependencies and extra content roots — see `navigation_roots`): the cross-root answer when
/// the language server cannot resolve an import into an attached dependency project. `from`'s
/// own root is left out: the server sees it, so a name it could not resolve there is not
/// answered by every declaration that happens to share it (any `save` for `repo.save()`).
pub async fn nav_index_definitions(
host: &Host,
window: &str,
name: String,
from: IpcPath,
) -> IdeResult<Vec<SymbolMatch>> {
let project = host.project(window)?;
blocking(move || {
let roots = crate::project::navigation_roots(&project, &from);
let Some((_own, roots)) = roots.split_first() else { return Ok(Vec::new()) };
if roots.is_empty() {
return Ok(Vec::new());
}
// Filter inside the index: a cap applied before the root filter can fill with
// own-root matches and evict the dependency roots entirely.
Ok(project.index.symbols_named_within(&name, None, roots, 500))
})
.await
}
/// The existing file or directory a path written in `from` names (`href`, `url()`, a string literal).
pub async fn fs_resolve_reference(
host: &Host,
@@ -181,6 +243,12 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("remove_recent", |c| async move { remove_recent(&c.host, &c.window, c.arg("path")?) });
d.add("recent_files", |c| async move { recent_files(&c.host, &c.window) });
d.add("web_goto", |c| async move { web_goto(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?).await });
d.add("nav_resolve_module", |c| async move {
nav_resolve_module(&c.host, &c.window, c.arg("name")?, c.arg("from")?).await
});
d.add("nav_index_definitions", |c| async move {
nav_index_definitions(&c.host, &c.window, c.arg("name")?, c.arg("from")?).await
});
d.add("fs_resolve_reference", |c| async move {
fs_resolve_reference(&c.host, &c.window, c.arg("from")?, c.arg("value")?).await
});
+4 -1
Переглянути файл
@@ -221,7 +221,10 @@ pub fn set_settings(
|| key == "editor.codeStyle"
|| key.starts_with("editor.codeStyle.")
|| key == "inspections"
|| key.starts_with("inspections.");
|| key.starts_with("inspections.")
|| key == "files"
|| key == "files.composeFileOverrides"
|| key.starts_with("files.composeFileOverrides.");
let toolchain = ["languages", "tools", "languageServers", "projectStructure"]
.iter()
.any(|k| key == *k || key.starts_with(&format!("{k}.")));
+106 -1
Переглянути файл
@@ -13,9 +13,11 @@ use ide_vfs::Watcher;
use ide_workspace::documents::DocumentObserver;
use ide_workspace::{DocumentInfo, Documents, FileMoves, LocalHistory, ProjectRoots, SettingsStore, SourceFolder};
use parking_lot::{Mutex, RwLock};
use std::collections::{BTreeMap, HashMap};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
/// Everything bound to one open project folder.
pub struct Project {
@@ -1197,9 +1199,81 @@ fn content_roots(settings: &ide_core::settings::Settings, root: &Path, build_cac
extra_content_roots(settings, root, build_cache)
}
/// The roots a file may navigate into: its own root (the workspace member containing it, or
/// the project root) plus that root's configured dependencies. A workspace member depends
/// only on the roots picked per member in Settings › Project Structure › Dependencies —
/// being attached to the workspace never makes its code navigable by itself.
/// Cached navigation roots per (project, file), with the instant they were computed.
type NavigationRootsCache = HashMap<(PathBuf, PathBuf), (Instant, Vec<PathBuf>)>;
pub(crate) fn navigation_roots(project: &Project, from: &Path) -> Vec<PathBuf> {
// Hover probes and import-segment clicks repeat these lookups for the same file,
// and each uncached call re-reads the workspace file: memoize briefly. The short
// TTL keeps settings and workspace edits near-immediate.
static CACHE: Mutex<Option<NavigationRootsCache>> = Mutex::new(None);
const TTL: Duration = Duration::from_secs(1);
const CAP: usize = 256;
let key = (project.root.clone(), from.to_path_buf());
if let Some((at, roots)) = CACHE.lock().as_ref().and_then(|m| m.get(&key))
&& at.elapsed() < TTL
{
return roots.clone();
}
let roots = navigation_roots_uncached(project, from);
if let Some(map) = CACHE.lock().as_mut() {
if map.len() >= CAP {
map.retain(|_, (at, _)| at.elapsed() < TTL);
}
map.insert(key, (Instant::now(), roots.clone()));
}
roots
}
fn navigation_roots_uncached(project: &Project, from: &Path) -> Vec<PathBuf> {
let settings = project.settings.effective();
let workspace = ide_workspace::workspace::Workspace::load(&project.root).ok().flatten();
let own = workspace
.as_ref()
.and_then(|w| w.projects.iter().filter(|p| from.starts_with(p)).max_by_key(|p| p.components().count()).cloned())
.unwrap_or_else(|| project.root.clone());
let mut roots = vec![own.clone()];
let own_key = ide_core::ipc_path::to_ipc(&own);
if let Some(deps) = member_dependencies_for(&settings.project_structure.member_dependencies, &own_key) {
for dep in deps {
let dep = PathBuf::from(dep.trim());
if dep.is_absolute() && dep.is_dir() && !roots.contains(&dep) {
roots.push(dep);
}
}
}
if workspace.is_none() {
for root in content_roots(&settings, &project.root, &project.build_cache_dir()) {
if !roots.contains(&root) {
roots.push(root);
}
}
}
roots
}
/// The dependency roots configured for `own`, keyed by IPC path. The settings UI writes
/// keys as it received the member paths (IPC form); a hand-edited settings.toml may not,
/// so compare normalized rather than only by exact match.
fn member_dependencies_for<'a>(map: &'a BTreeMap<String, Vec<String>>, own_key: &str) -> Option<&'a Vec<String>> {
if let Some(deps) = map.get(own_key) {
return Some(deps);
}
map.iter().find(|(key, _)| ide_core::ipc_path::to_ipc(&PathBuf::from(key)) == own_key).map(|(_, deps)| deps)
}
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()));
let user = settings
.project_structure
.content_roots
.iter()
.chain(settings.project_structure.dependencies.iter())
.map(|r| PathBuf::from(r.trim()));
for p in user.chain(ide_build::gradle_external_roots(root, build_cache, &settings.languages.java.gradle)) {
if p.is_absolute() && p.is_dir() && !p.starts_with(root) && !out.contains(&p) {
out.push(p);
@@ -1811,3 +1885,34 @@ mod composer_tests {
assert!(!changed(&[]));
}
}
#[cfg(test)]
mod content_roots_tests {
use super::content_roots;
#[test]
#[cfg(windows)]
fn member_dependencies_match_hand_written_keys() {
let mut map = std::collections::BTreeMap::new();
map.insert("B:\\proj\\member".to_string(), vec!["B:/dep".to_string()]);
let deps = super::member_dependencies_for(&map, "B:/proj/member");
assert_eq!(deps.map(Vec::as_slice), Some(["B:/dep".to_string()].as_slice()));
}
#[test]
fn dependency_projects_join_the_content_roots() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("app");
let shared = tmp.path().join("shared");
let dep = tmp.path().join("lib");
for dir in [&root, &shared, &dep] {
std::fs::create_dir_all(dir).unwrap();
}
let mut settings = ide_core::settings::Settings::default();
settings.project_structure.content_roots = vec![shared.to_string_lossy().into_owned()];
settings.project_structure.dependencies =
vec![dep.to_string_lossy().into_owned(), root.to_string_lossy().into_owned()];
let roots = content_roots(&settings, &root, &tmp.path().join("build"));
assert_eq!(roots, vec![shared, dep]);
}
}
+72 -7
Переглянути файл
@@ -98,6 +98,44 @@ impl PluginWorkspace for TestWorkspace {
fn projects(&self) -> Vec<PathBuf> {
std::iter::once(self.root.clone()).chain(self.other_roots.lock().iter().cloned()).collect()
}
/// Find in Files over the fixture's files on disk.
fn search_text(
&self,
_client: Option<&str>,
root: &Path,
query: ide_plugin::TextQuery,
limit: u32,
cancel: &ide_core::CancellationToken,
) -> Result<ide_plugin::TextSearch, HostError> {
let query = ide_search::SearchQuery {
pattern: query.pattern,
is_regex: query.regex,
case_sensitive: query.case_sensitive,
whole_word: query.whole_word,
include: query.include,
exclude: query.exclude,
scope: ide_search::SearchScope::Project,
max_matches: limit,
context: Default::default(),
};
let files = Mutex::new(Vec::new());
let summary = ide_search::search(root, &query, &|_| None, cancel, &|file| files.lock().push(file))
.map_err(|e| HostError::Failed(e.to_string()))?;
let matches = files
.into_inner()
.into_iter()
.flat_map(|file| {
file.matches.into_iter().map(move |m| ide_plugin::TextMatch {
location: ide_plugin::Location {
path: PathBuf::from(&file.path.0),
range: ide_core::Range::new(ide_core::Pos::new(m.line, 0), ide_core::Pos::new(m.line, 0)),
},
line: m.text,
})
})
.collect();
Ok(ide_plugin::TextSearch { matches, limit_hit: summary.limit_hit })
}
fn buffer_text(&self, path: &Path) -> Option<String> {
if path.ends_with("event-barrier.txt") {
let gate = self.event_read_gate.lock().take();
@@ -451,6 +489,29 @@ fn read_file_is_confined_to_the_project() {
assert_eq!(f.status("misbehave"), PluginStatus::Active);
}
/// `project.read-bytes` returns the exact bytes on disk, where `read-file`
/// wants UTF-8, and follows `read-file`'s confinement rules.
#[test]
fn read_bytes_reads_the_project_file_exactly_and_stays_inside() {
let f = fixture(Limits::default());
f.install(&misbehave_pkg());
let binary = [0x25u8, 0x50, 0x44, 0x46, 0x00, 0xff, 0x1b, 0x7f];
std::fs::write(f.project.join("logo.bin"), binary).unwrap();
let hex = binary.iter().map(|b| format!("{b:02x}")).collect::<Vec<_>>().join(" ");
assert_eq!(f.run("misbehave", "read-bytes", Some("logo.bin")).unwrap().unwrap(), format!("8 {hex}"));
let abs = f.project.join("logo.bin");
assert_eq!(f.run("misbehave", "read-bytes", Some(abs.to_str().unwrap())).unwrap().unwrap(), format!("8 {hex}"));
// The same refusal and not-found behaviour as `read-file`; like every
// refusal, it is the plugin's to handle: it keeps running.
let err = f.run("misbehave", "read-bytes", Some("../secret.txt")).unwrap_err();
assert!(err.contains("outside the project root"), "{err}");
let err = f.run("misbehave", "read-bytes", Some("src/missing.bin")).unwrap_err();
assert!(err.contains("not found"), "{err}");
let err = f.run("misbehave", "read-bytes", Some("src")).unwrap_err();
assert!(err.contains("not a file"), "{err}");
assert_eq!(f.status("misbehave"), PluginStatus::Active);
}
/// `fs.read` reaches the granted paths and nothing else, through the real
/// component: the same checks the runtime makes, against a real file system.
#[test]
@@ -2740,9 +2801,12 @@ fn official_mybatis_navigates_between_interface_and_xml() {
let xml = "<mapper namespace=\"com.acme.UserMapper\">\n <select id=\"findById\">select 1</select>\n</mapper>\n";
std::fs::write(&java_path, java).unwrap();
std::fs::write(&xml_path, xml).unwrap();
// Noise the index has to skip.
for i in 0..200 {
std::fs::write(f.project.join(format!("src/f{i}.xml")), "<beans/>").unwrap();
// Noise: other mappers, some large, that the lookup must not read one by one.
let filler = " <select id=\"q\">select 1</select>\n".repeat(3000);
for i in 0..600 {
let body = if i % 50 == 0 { filler.as_str() } else { "" };
let other = format!("<mapper namespace=\"com.acme.Other{i}\">\n{body}</mapper>\n");
std::fs::write(f.project.join(format!("src/f{i}.xml")), other).unwrap();
}
let doc = |path: &Path, language: &str, text: &str| ide_plugin::ProviderDocument {
path: path.to_path_buf(),
@@ -2751,9 +2815,9 @@ fn official_mybatis_navigates_between_interface_and_xml() {
text: text.into(),
};
let jdoc = doc(&java_path, "java", java);
// The first call indexes the XML files; on a slow machine that runs past the
// edit-path budget, so the answer is dropped (the index is kept) and calls
// queued behind it are skipped. The editor asks again; so does this.
// The first call searches the XML files; on a slow machine that can run past the
// edit-path budget, so the answer is dropped and calls queued behind it are
// skipped. The editor asks again; so does this.
let deadline = Instant::now() + Duration::from_secs(10);
let markers = loop {
let markers = f.host.provide_line_markers(&jdoc);
@@ -2782,7 +2846,7 @@ fn official_mybatis_navigates_between_interface_and_xml() {
"not its language"
);
// Warm (index cached): well inside the edit-path budget.
// Warm (lookup cached): well inside the edit-path budget.
let slow = f.host.info_of("openidea.mybatis").unwrap().stats.slow_calls;
let mut times = Vec::new();
for _ in 0..20 {
@@ -5723,6 +5787,7 @@ fn api14_denies_new_privileges_and_requires_an_invoking_window() {
("open-editors", "", "editor.read"),
("open-editor", "a.txt", "project.read"),
("search-text", "needle", "project.read"),
("read-bytes", "a.txt", "project.read"),
("problems", "", "project.read"),
("decorations", "a.txt", "project.read"),
("clear-decorations", "", "project.read"),
+10
Переглянути файл
@@ -52,6 +52,8 @@ fn search_dirs(file: Option<&Path>, root: Option<&Path>) -> Vec<PathBuf> {
}
fn parse_env(text: &str) -> IdeResult<EnvMap> {
// Editors on Windows (Notepad) save UTF-8 with a byte order mark, which serde_json rejects.
let text = text.strip_prefix('\u{feff}').unwrap_or(text);
let value: serde_json::Value =
serde_json::from_str(text).map_err(|e| IdeError::invalid(format!("invalid environment file: {e}")))?;
let mut out = EnvMap::new();
@@ -262,4 +264,12 @@ mod tests {
let envs = Environments::load(Some(&other.path().join("s.http")), Some(root)).unwrap();
assert_eq!(envs.names(), vec!["dev"]);
}
#[test]
fn reads_env_files_saved_with_a_byte_order_mark() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join(PUBLIC_FILE), "\u{feff}{\"dev\":{\"host\":\"h\"}}").unwrap();
let envs = Environments::load(Some(&tmp.path().join("a.http")), Some(tmp.path())).unwrap();
assert_eq!(envs.names(), vec!["dev"]);
}
}
+13
Переглянути файл
@@ -319,6 +319,19 @@ impl ProjectIndex {
matching::symbols_named(&self.shared.data.read(), name, kind, limit)
}
/// Symbols named exactly `name` (of `kind`, if given) whose file is inside one of `roots`
/// (the project root plus its configured dependency roots): answers bounded by the cap
/// never evict matches from a root because another root filled it first.
pub fn symbols_named_within(
&self,
name: &str,
kind: Option<SymbolKind>,
roots: &[PathBuf],
limit: usize,
) -> Vec<SymbolMatch> {
matching::symbols_named_within(&self.shared.data.read(), name, kind, roots, limit)
}
/// Incrementally re-indexes changed paths (called from the vfs watcher).
/// Cheap: the work is queued for the indexing thread.
pub fn update(&self, changed: &[PathBuf], removed: &[PathBuf]) {
+15
Переглянути файл
@@ -526,11 +526,26 @@ pub(crate) fn find_symbols_until(
/// Symbols named exactly `name` (and of `kind`, when given), by path then position.
pub(crate) fn symbols_named(data: &IndexData, name: &str, kind: Option<SymbolKind>, limit: usize) -> Vec<SymbolMatch> {
symbols_named_within(data, name, kind, &[], limit)
}
/// Symbols named exactly `name` (and of `kind`, when given) whose file is inside one of
/// `roots` (empty = every indexed file), by path then position. Filtering while walking
/// entries keeps symbols outside the roots from materialising or filling the cap.
pub(crate) fn symbols_named_within(
data: &IndexData,
name: &str,
kind: Option<SymbolKind>,
roots: &[PathBuf],
limit: usize,
) -> Vec<SymbolMatch> {
let mask = char_mask(name);
let within = |e: &FileEntry| roots.is_empty() || roots.iter().any(|root| e.path.starts_with(root));
let mut out: Vec<SymbolMatch> = data
.files
.iter()
.flatten()
.filter(|e| within(e))
.flat_map(|e| {
e.symbols.iter().filter(|s| s.mask == mask && &*s.name == name && kind.is_none_or(|k| s.kind == k)).map(
move |s| SymbolMatch {
+21
Переглянути файл
@@ -528,6 +528,27 @@ fn css_selectors_are_found_by_exact_name() {
assert!(idx.symbols_named(".card", Some(SymbolKind::Field), 10).is_empty());
}
#[test]
fn symbols_named_within_keeps_out_of_root_matches_from_evicting_a_root() {
let f = Fixture::new(&[
("a/w.rs", "struct Decoder;\nstruct Decoder;\n"),
("b/x.rs", "struct Decoder;\nstruct Decoder;\nstruct Decoder;\n"),
("dep/b.rs", "struct Decoder;\n"),
]);
let idx = f.open();
let own = f.root.join("a");
let dep = f.root.join("dep");
// Unfiltered, the cap fills in rel-path order — own root, then the out-of-root
// content root b/ — so dep/ is evicted before the caller's root filter runs.
let unfiltered = idx.symbols_named("Decoder", None, 3);
let unfiltered: Vec<&str> = unfiltered.iter().map(|m| m.rel_path.as_ref()).collect();
assert_eq!(unfiltered, vec!["a/w.rs", "a/w.rs", "b/x.rs"]);
let found: Vec<String> =
idx.symbols_named_within("Decoder", None, &[own, dep], 3).into_iter().map(|m| m.rel_path).collect();
assert_eq!(found, vec!["a/w.rs".to_string(), "a/w.rs".to_string(), "dep/b.rs".to_string()]);
assert!(idx.symbols_named_within("Decoder", None, &[f.root.join("none")], 3).is_empty());
}
#[test]
fn content_roots_and_excludes_change_without_reopening() {
let fx = Fixture::new(&[("src/main.rs", "fn alpha() {}\n"), ("src/gen/out.rs", "fn generated() {}\n")]);
+4
Переглянути файл
@@ -10,6 +10,10 @@ use specta::Type;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
/// Pinned Compose Specification JSON Schema URL associated with `yaml-language-server`.
pub const COMPOSE_SCHEMA_URL: &str =
"https://raw.githubusercontent.com/compose-spec/compose-go/v2.16.2/schema/compose-spec.json";
mod markers;
pub mod plugin_point;
mod registry;
+287
Переглянути файл
@@ -0,0 +1,287 @@
//! Docker Compose content detection and `yaml-language-server` schema association
//! for open YAML documents whose filename does not already match `*compose*.y(a)ml`.
use ide_language::{COMPOSE_SCHEMA_URL, LanguageServerConfig};
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
pub const SERVER: &str = "yaml-language-server";
const TOP_SECTIONS: &[&str] = &["version", "name", "networks", "volumes", "configs", "secrets", "include"];
const SERVICE_KEYS: &[&str] = &[
"image",
"build",
"container_name",
"hostname",
"domainname",
"ports",
"expose",
"environment",
"env_file",
"volumes",
"volumes_from",
"networks",
"network_mode",
"depends_on",
"command",
"entrypoint",
"restart",
"pull_policy",
"healthcheck",
"deploy",
"develop",
"labels",
"annotations",
"platform",
"profiles",
"user",
"working_dir",
"privileged",
"read_only",
"stdin_open",
"tty",
"init",
"cap_add",
"cap_drop",
"cgroup",
"cgroup_parent",
"configs",
"secrets",
"cpus",
"cpu_count",
"cpu_percent",
"cpu_shares",
"cpu_period",
"cpu_quota",
"cpuset",
"devices",
"dns",
"dns_opt",
"dns_search",
"extends",
"external_links",
"extra_hosts",
"group_add",
"ipc",
"isolation",
"links",
"logging",
"mac_address",
"mem_limit",
"mem_reservation",
"oom_kill_disable",
"oom_score_adj",
"pid",
"pids_limit",
"post_start",
"pre_stop",
"runtime",
"scale",
"security_opt",
"shm_size",
"stop_grace_period",
"stop_signal",
"storage_opt",
"sysctls",
"tmpfs",
"ulimits",
"userns_mode",
"uts",
];
fn parse_key(trimmed: &str) -> Option<&str> {
let colon = trimmed.find(':')?;
let after = &trimmed[colon + 1..];
if !after.is_empty() && !after.starts_with(char::is_whitespace) {
return None;
}
let key = trimmed[..colon].trim_end();
let key = key
.strip_prefix('"')
.and_then(|s| s.strip_suffix('"'))
.or_else(|| key.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')))
.unwrap_or(key);
(!key.is_empty() && key.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')))
.then_some(key)
}
/// Whether `path`'s filename already matches the Compose schema globs in `languages/yaml.toml`.
pub fn is_named_compose_file(path: &Path) -> bool {
let Some(name) = path.file_name().and_then(|n| n.to_str()) else { return false };
let Some(stem) = name.strip_suffix(".yaml").or_else(|| name.strip_suffix(".yml")) else {
return false;
};
// `<head>` or `<head>.*`, where `<head>` is `compose`, `docker-compose`, `*-compose` or `*.compose`.
let is_head = |head: &str| {
head == "compose"
|| head == "docker-compose"
|| head.strip_suffix("-compose").is_some_and(|p| !p.is_empty())
|| head.strip_suffix(".compose").is_some_and(|p| !p.is_empty())
};
is_head(stem) || stem.match_indices('.').any(|(i, _)| i + 1 < stem.len() && is_head(&stem[..i]))
}
/// Whether `path` is a YAML file whose name does not match a Compose glob, but whose content is Compose.
pub fn is_content_detected_compose_file(path: &Path, text: &str) -> bool {
!is_named_compose_file(path) && is_compose_content(text)
}
/// Whether `text` looks like a Docker Compose YAML configuration.
pub fn is_compose_content(text: &str) -> bool {
let mut has_services = false;
let mut has_compose_top = false;
let mut has_api_version = false;
let mut has_kind = false;
let mut has_on = false;
let mut has_jobs = false;
let mut in_services = false;
let mut service_indent = None;
let mut prop_indent = None;
let mut has_service_prop = false;
for raw in text.lines().take(600) {
let line = raw.trim_end_matches('\r');
let trimmed = line.trim_start();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
let lead = line.len() - trimmed.len();
if lead == 0 {
in_services = false;
if let Some(key) = parse_key(trimmed) {
if matches!(key, "openapi" | "swagger" | "AWSTemplateFormatVersion") {
return false;
}
has_api_version |= key == "apiVersion";
has_kind |= key == "kind";
has_on |= key == "on";
has_jobs |= key == "jobs";
if (has_api_version && has_kind) || (has_on && has_jobs) {
return false;
}
if key == "services" {
has_services = true;
in_services = true;
service_indent = None;
prop_indent = None;
} else if TOP_SECTIONS.contains(&key) || key.starts_with("x-") {
has_compose_top = true;
}
}
continue;
}
if in_services && !has_service_prop {
let svc_indent = *service_indent.get_or_insert(lead);
if lead == svc_indent {
prop_indent = None;
} else if lead > svc_indent {
let p_indent = *prop_indent.get_or_insert(lead);
if lead == p_indent
&& let Some(prop) = parse_key(trimmed)
&& (SERVICE_KEYS.contains(&prop) || prop.starts_with("x-"))
{
has_service_prop = true;
}
}
}
}
has_services && (has_compose_top || has_service_prop)
}
/// Appends open content-detected Compose files to `yaml-language-server`'s
/// `yaml.schemas[COMPOSE_SCHEMA_URL]` unless excluded by user settings.
pub fn configure_yaml_compose_schemas(cfg: &mut LanguageServerConfig, compose_files: &[PathBuf]) {
if cfg.name != SERVER || compose_files.is_empty() {
return;
}
let mut settings = cfg.settings.take().filter(Value::is_object).unwrap_or_else(|| json!({}));
let schemas = settings
.as_object_mut()
.expect("object")
.entry("yaml")
.or_insert_with(|| json!({}))
.as_object_mut()
.and_then(|yaml| yaml.entry("schemas").or_insert_with(|| json!({})).as_object_mut());
if let Some(schemas) = schemas {
let entry = schemas.entry(COMPOSE_SCHEMA_URL).or_insert_with(|| json!([]));
if let Value::String(single) = entry {
*entry = json!([single.clone()]);
}
if let Some(list) = entry.as_array_mut() {
for path in compose_files {
let p = path.to_string_lossy().replace('\\', "/");
let negated = format!("!{p}");
if list.iter().any(|v| v.as_str() == Some(&p) || v.as_str() == Some(&negated)) {
continue;
}
list.push(Value::String(p));
}
}
}
cfg.settings = Some(settings);
}
#[cfg(test)]
mod tests {
use super::*;
use ide_language::LanguageRegistry;
#[test]
fn detects_compose_content_and_ignores_other_manifests() {
assert!(is_compose_content("services:\n db:\n image: postgres:16\n"));
assert!(is_compose_content("version: \"3.9\"\nservices:\n app:\n foo: bar\n"));
assert!(!is_compose_content("apiVersion: v1\nkind: Service\nservices:\n db:\n image: x\n"));
assert!(!is_compose_content("on: push\njobs:\n build:\n runs-on: ubuntu-latest\n"));
assert!(is_named_compose_file(Path::new("/work/docker-compose.yml")));
assert!(is_named_compose_file(Path::new("/work/playground-compose.yaml")));
assert!(!is_named_compose_file(Path::new("/work/custom-stack.yml")));
for named in
["compose.yaml", "compose.override.yml", "docker-compose.prod.yaml", "a.compose.yml", "x-compose.ci.yml"]
{
assert!(is_named_compose_file(Path::new(named)), "{named}");
}
for unnamed in ["docker-compose-dev.yml", "compose-prod.yaml", "composer.yml", "decompose.yaml", "-compose.yml"]
{
assert!(!is_named_compose_file(Path::new(unnamed)), "{unnamed}");
}
assert!(is_content_detected_compose_file(
Path::new("/work/docker-compose-dev.yml"),
"services:\n db:\n image: postgres:16\n"
));
assert!(!is_content_detected_compose_file(
Path::new("/work/docker-compose.yml"),
"services:\n db:\n image: postgres:16\n"
));
assert!(is_content_detected_compose_file(
Path::new("/work/custom-stack.yml"),
"services:\n db:\n image: postgres:16\n"
));
}
#[test]
fn merges_content_detected_files_into_yaml_schemas_unless_excluded() {
let reg = LanguageRegistry::load(None).unwrap();
let yaml = reg.get(&ide_core::LanguageId::new("yaml")).unwrap();
let mut cfg = yaml.language_servers[0].clone();
let a = PathBuf::from("/work/custom-stack.yml");
let b = PathBuf::from("/work/excluded.yml");
cfg.settings.as_mut().unwrap()["yaml"]["schemas"][COMPOSE_SCHEMA_URL]
.as_array_mut()
.unwrap()
.push(json!("!/work/excluded.yml"));
configure_yaml_compose_schemas(&mut cfg, &[a, b]);
let list = cfg.settings.as_ref().unwrap()["yaml"]["schemas"][COMPOSE_SCHEMA_URL]
.as_array()
.unwrap()
.iter()
.filter_map(Value::as_str)
.collect::<Vec<_>>();
assert!(list.contains(&"compose.yml"));
assert!(list.contains(&"*-compose.yml"));
assert!(list.contains(&"/work/custom-stack.yml"));
assert!(!list.contains(&"/work/excluded.yml"));
assert!(list.contains(&"!/work/excluded.yml"));
}
}
+45 -3
Переглянути файл
@@ -10,6 +10,7 @@ mod angular;
mod arduino;
mod caps;
mod clang_tidy;
mod compose;
mod convert;
mod external_project;
mod file_ops;
@@ -107,6 +108,8 @@ struct DocState {
version: i32,
text: Arc<DocText>,
servers: Vec<Attached>,
/// Open YAML document whose filename does not match a Compose glob and whose content is Compose.
compose_detected: bool,
}
impl DocState {
@@ -1017,6 +1020,7 @@ impl LspManager {
servers: Vec<Arc<Server>>,
) {
let canonical = dunce::canonicalize(&path).unwrap_or_else(|_| path.clone());
let compose_detected = language.as_str() == "yaml" && compose::is_content_detected_compose_file(&path, text);
let mut state = DocState {
path,
canonical,
@@ -1025,9 +1029,12 @@ impl LspManager {
version: version as i32,
text: Arc::new(DocText::new(text)),
servers: servers.iter().map(|s| Attached { server: s.clone(), opened_gen: None }).collect(),
compose_detected,
};
{
let compose_added = {
let mut docs = self.docs.lock();
let already_detected =
compose_detected && docs.values().any(|d| d.compose_detected && d.path == state.path);
let params = state.did_open_params();
for a in &mut state.servers {
if let Some(run) = a.server.running() {
@@ -1038,13 +1045,17 @@ impl LspManager {
}
}
docs.insert(doc, state);
}
compose_detected && !already_detected
};
self.schedule_pull(doc, None, Duration::ZERO);
for s in &servers {
self.ensure_started(s);
if s.running().is_some()
&& let Some(me) = self.me.upgrade()
{
if s.name == compose::SERVER && compose_added {
me.reconfigure_soon();
}
let server = s.clone();
self.runtime.spawn(async move { me.check_clang_tidy(&server).await });
}
@@ -1115,7 +1126,18 @@ impl LspManager {
tracing::debug!("{}: didChange: {e}", server.name);
}
}
let compose_changed = if d.language.as_str() == "yaml" && !compose::is_named_compose_file(&d.path) {
let now = compose::is_compose_content(text.as_str());
let changed = std::mem::replace(&mut d.compose_detected, now) != now;
let closed_path = d.path.clone();
changed && !docs.iter().any(|(id, other)| *id != doc && other.compose_detected && other.path == closed_path)
} else {
false
};
drop(docs);
if compose_changed && let Some(me) = self.me.upgrade() {
me.reconfigure_soon();
}
self.schedule_pull(doc, None, pull::AFTER_EDIT);
self.invalidate_injections(doc);
self.schedule_injections(doc);
@@ -1175,7 +1197,16 @@ impl LspManager {
}
fn close_document(&self, doc: DocId) {
let Some(d) = self.docs.lock().remove(&doc) else { return };
let (d, compose_removed) = {
let mut docs = self.docs.lock();
let Some(d) = docs.remove(&doc) else { return };
let removed =
d.compose_detected && !docs.values().any(|other| other.compose_detected && other.path == d.path);
(d, removed)
};
if compose_removed && let Some(me) = self.me.upgrade() {
me.reconfigure_soon();
}
for a in &d.servers {
self.forget_pulled(&a.server, &d.path, a.server.running().as_ref());
let Some(run) = a.server.running() else { continue };
@@ -3169,9 +3200,20 @@ impl LspManager {
if let Some(hook) = self.config_hook.read().clone() {
hook(&mut cfg, &server.root);
}
if cfg.name == compose::SERVER {
compose::configure_yaml_compose_schemas(&mut cfg, &self.open_compose_files());
}
cfg
}
fn open_compose_files(&self) -> Vec<PathBuf> {
let mut files: Vec<PathBuf> =
self.docs.lock().values().filter(|d| d.compose_detected).map(|d| d.path.clone()).collect();
files.sort();
files.dedup();
files
}
/// Restarts running servers whose settings-derived configuration changed
/// (e.g. a new project interpreter); stopped ones pick it up on their next start.
/// A change of `settings` alone reaches a running server without a restart.
+32
Переглянути файл
@@ -169,6 +169,19 @@ pub fn read_text(real: &Path) -> Result<String, String> {
String::from_utf8(bytes).map_err(|_| format!("{}: not valid UTF-8 text", real.display()))
}
/// Reads the bytes of a file that `resolve_in_root` already approved: binary
/// content is fine, the size cap is the same as [`read_text`]'s.
pub fn read_bytes(real: &Path) -> Result<Vec<u8>, String> {
let meta = std::fs::metadata(real).map_err(|e| e.to_string())?;
if !meta.is_file() {
return Err(format!("{}: not a file", real.display()));
}
if meta.len() > MAX_READ_BYTES {
return Err(format!("{}: file is larger than {} MiB", real.display(), MAX_READ_BYTES >> 20));
}
std::fs::read(real).map_err(|e| e.to_string())
}
/// Why a read outside the project was refused.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Refusal {
@@ -528,6 +541,25 @@ mod tests {
assert!(read_text(&p).unwrap_err().contains("UTF-8"));
}
#[test]
fn read_bytes_returns_the_exact_bytes_up_to_the_cap() {
let tmp = tempfile::tempdir().unwrap();
// The bytes binary files carry survive, where read_text refuses them.
let binary = [0x25, 0x50, 0x44, 0x46, 0x00, 0xff, 0xfe, 0x1b];
std::fs::write(tmp.path().join("b.bin"), binary).unwrap();
let p = resolve_in_root(tmp.path(), "b.bin").unwrap();
assert_eq!(read_bytes(&p).unwrap(), binary);
// A directory is not a file; the size cap is read_text's.
assert!(read_bytes(&resolve_in_root(tmp.path(), ".").unwrap()).unwrap_err().contains("not a file"));
let big = tmp.path().join("big.bin");
std::fs::write(&big, vec![0u8; (MAX_READ_BYTES + 1) as usize]).unwrap();
let err = read_bytes(&big).unwrap_err();
assert!(err.contains("larger than"), "{err}");
let at_cap = tmp.path().join("at-cap.bin");
std::fs::write(&at_cap, vec![7u8; MAX_READ_BYTES as usize]).unwrap();
assert_eq!(read_bytes(&at_cap).unwrap().len(), MAX_READ_BYTES as usize);
}
/// The grant a manifest author would write for `dir`: the plain path, no
/// `\\?\` prefix.
fn grant_for(dir: &Path) -> Capability {
+16
Переглянути файл
@@ -696,6 +696,22 @@ impl workspace::HostProject for StoreData {
Ok(sandbox::read_text(&real).map_err(wit::Error::Failed))
}
fn read_bytes(&mut self, this: Resource<ProjectRes>, path: String) -> Fallible<Vec<u8>> {
if let Err(e) = self.require(Capability::ProjectRead) {
return Ok(Err(e));
}
let root = self.table.get(&this)?.root.clone();
let real = match sandbox::resolve_in_root(&root, &path) {
Ok(real) => real,
Err(e) => {
let e = if e.ends_with("no such file") { wit::Error::NotFound(e) } else { wit::Error::Invalid(e) };
return Ok(Err(e));
}
};
// The bytes on disk: an unsaved text buffer does not apply, binary files are the point.
Ok(sandbox::read_bytes(&real).map_err(wit::Error::Failed))
}
fn exists(&mut self, this: Resource<ProjectRes>, path: String) -> Fallible<bool> {
if let Err(e) = self.require(Capability::ProjectRead) {
return Ok(Err(e));
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
163429536090fe11286cce425fabad3e84c14e1ea4e4b678be88a0bad0a1285a
45ac83943d767245797a22a108169867e66791ca6f0a7e2bc70ca4ebfda7974a
+4
Переглянути файл
@@ -104,6 +104,10 @@ title = "reveal"
id = "search-text"
title = "search-text"
[[contributes.commands]]
id = "read-bytes"
title = "read-bytes"
[[contributes.commands]]
id = "problems"
title = "problems"
+1
Переглянути файл
@@ -25,6 +25,7 @@ impl Plugin for Api14 {
.search_text(&openidea_plugin::TextQuery { pattern: arg.into(), ..Default::default() }, 10)?;
Ok(Some(format!("{result:?}")))
}
"read-bytes" => Ok(Some(format!("{:?}", ctx.project().unwrap().read_bytes(arg)?))),
"problems" => Ok(Some(format!("{:?}", host::problems(if arg.is_empty() { None } else { Some(arg) })?))),
"decorations" => {
host::set_decorations(
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
bfdd78a242472a1279e0e393f66f0d96a856338e103aad454239a451df22cd05
3d32267d42eada262ac000a6d1eda9070bea833f7cd5a0ae5c09a82ee6719f76
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
37276095967174307db4c5113d85b777138f60ee82d26b4af8ef3f2acd90aafa
31092e0b376f28f0910d0d502066fd3aeb2b6e071637aee11af83eded0a21e95
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
b9597de10e7182e1ed488f80b6c1deb9f810912797b2bf02c9cae0347ee38bc8
beddd694c7b95317f03539d5096a0a249d25ed1c2444c483e2cbf4269bc172f9
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
3ee642e6d1f983ba5e186007e2115b0a104383510cc8acc13f3e0a83fe1ca5b6
f98ea4207669eab6f5bef16d39e3f8bdc8a2d53a4ad7b46dd85c4546587cc437
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
43f7af5d824853a3fbaa61ef408127cd2aac0134e6ec02ca2fb35813db4b0bd9
3e822039e39554e3923b967fa2afb03bd2f816b4eb61d143dc9b621c066089ff
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
d85df5d6fb5e9d746fe4b28e1f8546c30fe6dc53c1b9dfd9201bc6a9dacf2306
3cce72ed6d50690874bb4a21ef8f74307873bc19dd46c9c0f4df4e679592f9a8
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
17fcb4251b6bdd055e4e856bd8d69d7eca52693cefb818b547787907022b7943
3f02b9e8be539c5f73f19e4ee894f5c07c35f1780ceb7e30b85f4bb055a6eaab
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
50d8a7304714e0810da7f8a2f723155287c26807848049b00492cb9def6c7763
4f918e2fc7e3467f2f83e9173906688d95831b25675be63eb3845e6d6096d09d
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
8442bac7628c5e3ddbf2af2337781016197028054c83d4589dbbcf479c77ff87
82096b10bdf00ea13c70e3c122473e6e78d3194689d046fd3c293bb3d51fbe01
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
054bdb7b732f74b0266880bfea3699bb9994bbdba4054dc2935a6cc0bcdf57de
a3d54e4f17f47a419cade142f963c3f2f29ecb322859df4a87453782d25379f4
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
fb3097d0429782d24ac647433bb7146a29f9b28a505bd2e7693c0244dac3c1e1
e098b329617036ce9287b449cf173b49138eca6f2ea56f580c13eba95077160f
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
e6da5f8b4c30861c17c7da1b62992de6132ed6ab99f404292193e39a40a68637
feb25270f4459eb03e2a6c43715fc05b1ae6c14177b1a35891f06ef1bd783cba
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
514f87032622056e8b89f1c4d7950bb0a466c9c857fe27d65a523ea7d3c35911
10ee58ead746983df8a1b2df1290c0de056fc7846ec53c5bb65ff512b014c7ff
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
8908a092da5958e55fab506e081695fc7b48ea3bf287832aaac3e27e4c2adb21
a331930b86df4ac86a13728582a49e61db68793b18e171fde8a043dca5fbbe49
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
2a4996cbd60e29f4c3c47748d9ac7a4ca05ff4d8c55519fb6e06cec88b868be5
1bd66a940d516f3ab39284b5721cc3995e255016b9e987f69aef2481543c4e3f
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
4385c1553a4aaed9030d462a72677d07b02eaa0d08b8c1770f42116efca95d94
29116dfa55042b4a5b0c34f0f2dbde11070ffa3c41b5a013cf6901e59bdcf6fe
+7
Переглянути файл
@@ -66,6 +66,13 @@ impl commands::Guest for Misbehave {
let project = ctx.project().ok_or_else(|| failed("no project is open"))?;
project.read_file(&arg.unwrap_or_default()).map(Some)
}
// The bytes `project.read-bytes` returns, as "len hex hex …" so binary survives.
"read-bytes" => {
let project = ctx.project().ok_or_else(|| failed("no project is open"))?;
let bytes = project.read_bytes(&arg.unwrap_or_default())?;
let hex: Vec<String> = bytes.iter().map(|b| format!("{b:02x}")).collect();
Ok(Some(format!("{} {}", bytes.len(), hex.join(" "))))
}
"root" => ctx.project().map(|p| p.root()).transpose(),
// The `fs.read` grants: the text of `arg`, or the refusal naming what it needs.
"fsread" => openidea::plugin::fs::read_file(&arg.unwrap_or_default()).map(Some),
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
777d722fbe5673ca37165c2032389ea7c9518f7b0b3b51953e26ba01b50b3ec4
4b8cd615a5302f73e1e285f7e1bb78698f642e2362caf8b5d0e9d2cb915d6e40
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
e9b3cc0b1bf82b06ace741ccb9f9e23d0dab2f61a5695d786dec08b7839f77aa
25ecb8286427c19b3f3d6881d8d34b54541bfdb8c94c5bf8f85e731798f5991d
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
fac47c0d2e74cb236b063e8edc617a991aaf7eecf6cf71a75404310ed270f75e
25ffd18ec3229df2ca8ba875f58dd494041ec3982610b3d0982dade3bfd832cb
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
ba6c1563853158291b0524146031363c45288757cf959cf143db60437beb4b93
61160a74f1998423c48290f9d34d3d3db9ee7b6633db15077d1c06602cc44c3f
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
57e4765ec72da0bb4768b5c097fead43e9484449abfc80c925bccd7ecc91af7e
638a63af9e8a52d7596fbf87cfc925f6f92359c2b7716e71531efbca20d38998
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
6877c01f91c1ec0dfee326b6825d0d3fbbb14a67837eb7fd0eba10a727535b5b
96a3429aa8143429ab1fcc60793424103fc144cba923b9b97704694bc044150b
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
3b7535831c9f5b9d910fc7a73a44ca492c4751236fd4ac610844dd99b1467f85
0fd230df9fa72ae3216155b10a1f6388f1634f524f54be6faaf5a86d01fcafd2
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
7b20e7413eed468a60675814e88f25d27b96b379817bedaf512fdaca7f9995e4
a60bcd2ed532f6449fef095cb7ebd45f7a36b3c9e94c0336e670b584206e682a
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
c4a043bf1fbed43f3225d6453612302b3e8a55ff630cbb521d73df60ea43cb1a
128deab3ef53cd6742f01c7475ef908aaab7aa78a437623bc1cb3f1b75b3ed39
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
0150cf5f2f05df5666e7fe26f0164e9913c1d931d7ad52221c683d3b93dbcbe3
1108fb26bca68149a00aa34f9865307353edbd571c5448aab23a732166df0551
+150 -12
Переглянути файл
@@ -785,6 +785,18 @@ pub fn configure_java_compiler(cfg: &mut LanguageServerConfig, data_dir: &Path,
configure_java_compiler_with_level(cfg, data_dir, inspections, None);
}
/// A Java language level as (file-name tag, JDT compliance value): `6` / `1.6` → `("6", "1.6")`,
/// `17` → `("17", "17")`. Levels 5–8 use JDT's `1.x` spelling; unknown values are ignored.
fn java_compliance(level: &str) -> Option<(String, String)> {
let level = level.trim();
let major: u32 = level.strip_prefix("1.").unwrap_or(level).parse().ok()?;
match major {
5..=8 => Some((major.to_string(), format!("1.{major}"))),
9..=99 if !level.starts_with("1.") => Some((major.to_string(), major.to_string())),
_ => None,
}
}
/// Like [`configure_java_compiler`], also applying the Project Structure Java language level
/// (`source`, `compliance`, `codegen.targetPlatform`) when configured.
pub fn configure_java_compiler_with_level(
@@ -796,14 +808,7 @@ pub fn configure_java_compiler_with_level(
const DEFAULTS: &str = "org.eclipse.jdt.core.compiler.problem.forbiddenReference=warning\n\
org.eclipse.jdt.core.compiler.problem.discouragedReference=warning\n";
let serial = missing_serial_version(inspections);
let normalized_level = language_level.map(str::trim).and_then(|l| match l {
"8" | "1.8" => Some(("8", "1.8")),
"11" => Some(("11", "11")),
"17" => Some(("17", "17")),
"21" => Some(("21", "21")),
"25" => Some(("25", "25")),
_ => None,
});
let normalized_level = language_level.and_then(java_compliance);
let mut stem = "compiler-defaults".to_owned();
let mut content = DEFAULTS.to_owned();
if let Some(level) = serial {
@@ -904,6 +909,47 @@ pub fn configure_server(cfg: &mut LanguageServerConfig, root: &Path, server_root
}
}
if name == "yaml-language-server" && !s.files.compose_file_overrides.is_empty() {
let mut settings = cfg.settings.take().filter(Value::is_object).unwrap_or_else(|| json!({}));
let mut kept = Vec::new();
let mut excluded = Vec::new();
for (path, status) in &s.files.compose_file_overrides {
let p = Path::new(path);
let abs = if p.is_absolute() { p.to_path_buf() } else { root.join(p) };
let normalized = abs.to_string_lossy().replace('\\', "/");
match status {
ide_core::settings::ComposeFileOverride::Kept => kept.push(normalized),
ide_core::settings::ComposeFileOverride::Excluded => excluded.push(normalized),
}
}
let yaml = settings
.as_object_mut()
.expect("object")
.entry("yaml")
.or_insert_with(|| json!({}))
.as_object_mut()
.expect("object");
let schemas = yaml.entry("schemas").or_insert_with(|| json!({})).as_object_mut().expect("object");
let entry = schemas.entry(ide_language::COMPOSE_SCHEMA_URL).or_insert_with(|| json!([]));
if let Value::String(single) = entry {
*entry = json!([single.clone()]);
}
if let Some(list) = entry.as_array_mut() {
for p in kept {
if !list.iter().any(|v| v.as_str() == Some(&p)) {
list.push(Value::String(p));
}
}
for p in &excluded {
let neg = format!("!{p}");
if !list.iter().any(|v| v.as_str() == Some(&neg)) {
list.push(Value::String(neg));
}
}
}
cfg.settings = Some(settings);
}
if name == "rust-analyzer" {
let r = &s.languages.rust;
let extra_args: Vec<&str> = r.rustfmt_args.split_whitespace().collect();
@@ -1314,6 +1360,45 @@ pub fn run_target<'a>(config: &RunConfig, settings: &'a Settings) -> Option<&'a
}
}
/// Whether the configuration's `JAVA_HOME` is the one Settings choose (the Maven runner JRE,
/// the Gradle JVM) rather than a choice of its own: an IDE-generated build-tool configuration —
/// a Maven goal or Gradle task from the tool window — carries the model's `JAVA_HOME`, which
/// Settings derived when the model was built (IDEA has no JRE field on such a task either).
/// Such a value is derived: Settings win, and [`without_derived`] drops it, so a corrected JDK
/// applies to the next run, also across a restart (#2433). Every other configuration keeps its
/// own `JAVA_HOME`, even one this build cannot parse.
fn java_home_is_derived(c: &RunConfig) -> bool {
generated_maven_goal(c) || (c.id.starts_with("gradle:task:") && c.extras.params.is_empty())
}
/// Applies the `JAVA_HOME` of `env` (the one Settings resolved) to `c`. A derived value is
/// replaced, also when Settings resolve none (a dead path must not outlive the setting it came
/// from); an explicit `JAVA_HOME` of the configuration still wins.
fn apply_java_home(c: &mut RunConfig, env: &std::collections::BTreeMap<String, String>, derived: bool) {
match env.get("JAVA_HOME") {
Some(value) if derived => {
c.env.insert("JAVA_HOME".into(), value.clone());
}
Some(value) => {
c.env.entry("JAVA_HOME".into()).or_insert_with(|| value.clone());
}
None if derived => {
c.env.remove("JAVA_HOME");
}
None => {}
}
}
/// The Maven environment from Settings (the runner JRE), applied to `c`: a derived `JAVA_HOME`
/// is replaced, an explicit one stays (see [`apply_java_home`]).
pub fn apply_maven_env(c: &mut RunConfig, root: &Path, s: &Settings) {
let env = maven_build_env(root, s, None);
apply_java_home(c, &env, java_home_is_derived(c));
for (key, value) in env {
c.env.entry(key).or_insert(value);
}
}
/// Points run configurations at the selected toolchains: Python configs use the
/// project interpreter, npm scripts the chosen package manager and Node.js,
/// cargo / go configs the selected toolchain, Maven / Gradle their runner JDK
@@ -1445,7 +1530,9 @@ pub fn apply_to_run_configs(configs: &mut [RunConfig], root: &Path, s: &Settings
if matches!(c.command.as_str(), "gradle" | "gradle.bat") {
c.command = resolve_gradle_command(root, &build_dir, s, false).to_string_lossy().into_owned();
}
for (key, value) in gradle_build_env(root, &build_dir, &c.command, s, None) {
let env = gradle_build_env(root, &build_dir, &c.command, s, None);
apply_java_home(c, &env, java_home_is_derived(c));
for (key, value) in env {
c.env.entry(key).or_insert(value);
}
}
@@ -1475,9 +1562,7 @@ pub fn apply_to_run_configs(configs: &mut [RunConfig], root: &Path, s: &Settings
}
}
"maven" => {
for (key, value) in maven_build_env(root, s, None) {
c.env.entry(key).or_insert(value);
}
apply_maven_env(c, root, s);
if let Some(mvn) = &mvn
&& (c.detected
|| generated_maven_goal(c)
@@ -1524,6 +1609,11 @@ pub fn without_derived(config: &RunConfig, root: &Path, s: &Settings) -> RunConf
.env
.iter()
.find(|(key, value)| {
// Settings choose the `JAVA_HOME` of a generated build-tool configuration, also when
// a corrected setting no longer reproduces the value the model froze there (#2433).
if key.as_str() == "JAVA_HOME" && java_home_is_derived(&out) {
return true;
}
let mut probe = out.clone();
probe.env.remove(*key);
let mut plain = probe.clone();
@@ -1846,6 +1936,36 @@ mod maven_linkage_tests {
configure_maven_import(&mut cfg, tmp.path(), &[], Some(&files));
assert!(serde_json::to_value(cfg.initialization_options).unwrap().get("projectConfigurations").is_none());
}
#[test]
fn compose_file_overrides_populate_yaml_schemas_and_typed_exclusions() {
let root = Path::new("/work");
let mut s = Settings::default();
s.files.compose_file_overrides.insert("custom-stack.yml".into(), ide_core::settings::ComposeFileOverride::Kept);
s.files
.compose_file_overrides
.insert("/work/docker/playground-compose.yml".into(), ide_core::settings::ComposeFileOverride::Excluded);
let mut cfg = custom_server("yaml-language-server", &LanguageServerSettings::default());
cfg.settings = Some(json!({
"yaml": {
"schemas": {
ide_language::COMPOSE_SCHEMA_URL: ["compose.yml", "*-compose.yml"]
}
}
}));
configure_server(&mut cfg, root, root, &s);
let yaml = &cfg.settings.as_ref().unwrap()["yaml"];
assert!(yaml.get("disableSchemaDetection").is_none());
let list = yaml["schemas"][ide_language::COMPOSE_SCHEMA_URL]
.as_array()
.unwrap()
.iter()
.filter_map(Value::as_str)
.collect::<Vec<_>>();
assert!(list.contains(&"compose.yml"));
assert!(list.contains(&"/work/custom-stack.yml"));
assert!(list.contains(&"!/work/docker/playground-compose.yml"));
}
}
#[cfg(all(test, unix))]
@@ -1873,3 +1993,21 @@ mod ruby_lsp_tests {
assert!(!dirs.iter().any(|d| d.starts_with("/nonexistent")), "{dirs:?}");
}
}
#[cfg(test)]
mod java_compliance_tests {
#[test]
fn maps_every_java_language_level_to_jdt_compliance() {
let c = |l: &str| super::java_compliance(l);
assert_eq!(c("6"), Some(("6".into(), "1.6".into())));
assert_eq!(c("1.7"), Some(("7".into(), "1.7".into())));
assert_eq!(c("8"), Some(("8".into(), "1.8".into())));
assert_eq!(c(" 1.8 "), Some(("8".into(), "1.8".into())));
assert_eq!(c("14"), Some(("14".into(), "14".into())));
assert_eq!(c("25"), Some(("25".into(), "25".into())));
assert_eq!(c("1.14"), None);
assert_eq!(c("4"), None);
assert_eq!(c(""), None);
assert_eq!(c("latest"), None);
}
}
+39 -8
Переглянути файл
@@ -297,14 +297,7 @@ fn python_sdk_with_version(path: &Path, source: &str, root: Option<&Path>, versi
_ => format!("Python {short} ({dir})"),
}
}
(None, "conda") => {
let env = path
.parent()
.and_then(Path::parent)
.and_then(|p| p.file_name())
.map(|n| n.to_string_lossy().into_owned());
format!("Python {short} (conda: {})", env.unwrap_or_default())
}
(None, "conda") => format!("Python {short} (conda: {})", conda_env_name(path).unwrap_or_default()),
(None, "pixi") => {
let env = pixi_env_name(path).unwrap_or_default();
format!("Python {short} (pixi: {env})")
@@ -320,12 +313,30 @@ fn python_sdk_with_version(path: &Path, source: &str, root: Option<&Path>, versi
source: source.to_owned(),
home: venv
.or_else(|| (source == "pixi").then(|| pixi_env_home(path)).flatten())
.or_else(|| (source == "conda").then(|| conda_env_home(path)).flatten())
.or_else(|| path.parent().and_then(Path::parent).map(Path::to_path_buf))
.map(Into::into),
extra: None,
}
}
/// The prefix of a conda interpreter: `<env>/bin/python` on Unix, `<env>/python.exe` on Windows.
fn conda_env_home(python: &Path) -> Option<PathBuf> {
let dir = python.parent()?;
let bin = dir.file_name().is_some_and(|n| n == "bin" || n.eq_ignore_ascii_case("Scripts"));
Some(if bin { dir.parent()? } else { dir }.to_path_buf())
}
/// The name conda gives an environment: `base` for the installation itself (it holds
/// `condabin/`), else the prefix's folder (`envs/<name>`, or a `--prefix` env's directory).
fn conda_env_name(python: &Path) -> Option<String> {
let home = conda_env_home(python)?;
if home.join("condabin").is_dir() {
return Some("base".into());
}
home.file_name().map(|n| n.to_string_lossy().into_owned())
}
/// `.pixi/envs/<env>` of a pixi interpreter (`<env>/python`, `<env>/bin/python`).
fn pixi_env_home(python: &Path) -> Option<PathBuf> {
let dir = python.parent()?;
@@ -934,6 +945,26 @@ mod pixi_tests {
mod conda_tests {
use super::*;
#[test]
fn conda_envs_are_named_after_their_prefix_on_every_layout() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("anaconda3");
std::fs::create_dir_all(root.join("condabin")).unwrap();
let name = |python: PathBuf| super::conda_env_name(&python);
// Windows: python.exe at the prefix root.
assert_eq!(name(root.join("envs/agent-gateway/python.exe")).as_deref(), Some("agent-gateway"));
assert_eq!(name(root.join("python.exe")).as_deref(), Some("base"));
// Unix: <prefix>/bin/python.
assert_eq!(name(root.join("envs/ml/bin/python")).as_deref(), Some("ml"));
assert_eq!(name(root.join("bin/python")).as_deref(), Some("base"));
// A `--prefix` env outside envs/.
assert_eq!(name(tmp.path().join("work/.conda/python.exe")).as_deref(), Some(".conda"));
assert_eq!(
super::conda_env_home(&root.join("envs/agent-gateway/python.exe")),
Some(root.join("envs/agent-gateway"))
);
}
#[test]
fn conda_env_list_names_named_prefix_and_bare_prefix_envs() {
// `conda env list` on Windows with Anaconda on a second drive (#2240).
+5 -1
Переглянути файл
@@ -152,7 +152,11 @@ fn create_conda_env(
}
}
stream(&conda, &args, None, &[], cancel, on_line)?;
let python = location.join(bin_dir()).join(exe("python"));
// Conda keeps `python.exe` at the prefix root on Windows, not under Scripts/.
let python = [location.join(exe("python")), location.join(bin_dir()).join(exe("python"))]
.into_iter()
.find(|p| p.is_file())
.unwrap_or_else(|| location.join(bin_dir()).join(exe("python")));
crate::discover::python_sdk(&python, "conda", location.parent().and_then(Path::parent))
.ok_or_else(|| IdeError::internal(format!("{} was not created", python.display())))
}
+79
Переглянути файл
@@ -476,6 +476,85 @@ fn maven_runner_jre_overrides_the_project_jdk() {
}
}
/// #2433: a `JAVA_HOME` an IDE-generated build-tool configuration carries came from Settings, so
/// a corrected setting reaches the next run — also when an older version baked the value into a
/// temporary configuration (and persisted it). A configuration of the user's own keeps its
/// `JAVA_HOME`. Table-driven over the build-tool kinds and the state of the carried value.
#[test]
fn settings_win_over_a_generated_configurations_java_home() {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
for name in ["old-jdk", "new-jdk", "user-jdk", "runner-jdk"] {
fake_jdk(&root.join(name));
}
let old = root.join("old-jdk").to_string_lossy().into_owned();
let new = root.join("new-jdk").to_string_lossy().into_owned();
let user = root.join("user-jdk").to_string_lossy().into_owned();
let runner = root.join("runner-jdk").to_string_lossy().into_owned();
let gone = root.join("removed-jdk").to_string_lossy().into_owned(); // never created
let mut settings = Settings::default();
settings.languages.java.jdk = "new-jdk".into();
assert_eq!(crate::apply::maven_build_env(&root, &settings, None)["JAVA_HOME"], new);
let launch = |config: &RunConfig| {
let mut c = config.clone();
if c.kind == "maven" {
// The Maven launcher's own path (`run_start`).
crate::apply::prepare_maven_run(&mut c, &root, &settings);
} else {
apply_to_run_configs(std::slice::from_mut(&mut c), &root, &settings);
}
c
};
// (kind, id, carried JAVA_HOME, what it runs with, whether a save keeps it)
for (kind, id, carried, runs_with, kept) in [
// A Maven goal from the tool window: Settings win, whether the carried JDK still exists.
("maven", "maven:goal:pom.xml:compile", Some(old.as_str()), new.as_str(), false),
("maven", "maven:goal:pom.xml:compile", Some(gone.as_str()), new.as_str(), false),
("maven", "maven:goal:pom.xml:compile", None, new.as_str(), false),
// A saved Maven configuration keeps the JDK it carries.
("maven", "maven:test", Some(user.as_str()), user.as_str(), true),
("maven", "my-goal", Some(user.as_str()), user.as_str(), true),
// A Gradle task from the tool window: the Gradle JVM / Project JDK decides.
("gradle", "gradle:task:[\":\",\":app\"]:test", Some(user.as_str()), new.as_str(), false),
("gradle", "gradle:task:[\":\",\":app\"]:test", Some(gone.as_str()), new.as_str(), false),
// A Gradle task of the user's own keeps its JDK.
("gradle", "my-task", Some(user.as_str()), user.as_str(), true),
// Tomcat keeps the JDK it carries: it is a field of that configuration.
("tomcat", "tomcat:local", Some(user.as_str()), user.as_str(), true),
] {
let command = if kind == "tomcat" { "catalina" } else { "mvn" };
let mut config = run(kind, command, &["compile"]);
config.id = id.into();
config.detected = false;
if let Some(home) = carried {
config.env.insert("JAVA_HOME".into(), home.to_owned());
}
let case = format!("{kind} {id} carried={carried:?}");
assert_eq!(launch(&config).env.get("JAVA_HOME").map(String::as_str), Some(runs_with), "{case}");
// Saved: a derived value is dropped so a later settings change still reaches it; the
// user's own value survives the round trip.
let saved = without_derived(&config, &root, &settings);
assert_eq!(saved.env.contains_key("JAVA_HOME"), kept, "{case} (saved={saved:?})");
assert_eq!(launch(&saved).env.get("JAVA_HOME").map(String::as_str), Some(runs_with), "{case} (after a save)");
}
// A corrected setting reaches a goal configuration that was saved with the old JDK baked in,
// without reopening the project: the Maven runner JRE (the reporter's field, #2433) wins.
let mut goal = run("maven", "mvn", &["compile"]);
goal.id = "maven:goal:pom.xml:compile".into();
goal.detected = false;
goal.env.insert("JAVA_HOME".into(), old.clone());
let saved = without_derived(&goal, &root, &settings);
assert!(!saved.env.contains_key("JAVA_HOME"));
let mut moved = settings.clone();
moved.languages.java.maven.runner_jre = "runner-jdk".into();
let mut next = saved;
crate::apply::prepare_maven_run(&mut next, &root, &moved);
assert_eq!(next.env["JAVA_HOME"], runner);
}
#[test]
fn maven_daemon_uses_the_runner_jre_version() {
let tmp = tempfile::tempdir().unwrap();
+134 -62
Переглянути файл
@@ -101,6 +101,22 @@ fn new_id() -> String {
uuid::Uuid::new_v4().to_string()
}
/// Runs a credential-store operation off the async runtime.
///
/// The Linux Secret Service client is blocking (`zbus`) and starts a runtime of
/// its own; calling it while a runtime already drives the current thread panics
/// with "Cannot start a runtime from within a runtime". The store may also wait
/// on an OS prompt, so run it on a plain thread when called from async code.
fn off_runtime<R: Send>(f: impl FnOnce() -> R + Send) -> R {
if tokio::runtime::Handle::try_current().is_err() {
return f();
}
std::thread::scope(|scope| match scope.spawn(f).join() {
Ok(value) => value,
Err(panic) => std::panic::resume_unwind(panic),
})
}
impl SecretFile {
pub fn new(path: PathBuf) -> Self {
Self::with_legacy(path, |v| serde_json::from_value(v).ok())
@@ -115,85 +131,95 @@ impl SecretFile {
}
pub fn get(&self, key: &str) -> Option<String> {
let _g = LOCK.lock();
match self.load() {
Contents::Index(mut index) if index.keys.contains(key) => match self.vault.get(&service(&index.id), key) {
Ok(secret) => Some(secret),
Err(StoreError::NoEntry) => {
// Removed outside the app: stop listing it.
index.keys.remove(key);
self.write_logged(&index);
None
off_runtime(|| {
let _g = LOCK.lock();
match self.load() {
Contents::Index(mut index) if index.keys.contains(key) => {
match self.vault.get(&service(&index.id), key) {
Ok(secret) => Some(secret),
Err(StoreError::NoEntry) => {
// Removed outside the app: stop listing it.
index.keys.remove(key);
self.write_logged(&index);
None
}
Err(e) => {
tracing::warn!("saved passwords: {key}: {e}");
None
}
}
}
Err(e) => {
tracing::warn!("saved passwords: {key}: {e}");
None
}
},
Contents::Index(_) => None,
Contents::Plaintext(mut old, _) => old.remove(key),
}
Contents::Index(_) => None,
Contents::Plaintext(mut old, _) => old.remove(key),
}
})
}
pub fn set(&self, key: &str, secret: &str) -> IdeResult<()> {
let _g = LOCK.lock();
let mut index = match self.load() {
Contents::Index(index) => index,
Contents::Plaintext(_, why) => return Err(unavailable(&why)),
};
let service = service(&index.id);
self.vault.set(&service, key, secret).map_err(|e| unavailable(&e.to_string()))?;
if index.keys.insert(key.to_owned())
&& let Err(e) = self.write(&index)
{
let _ = self.vault.delete(&service, key);
return Err(e);
}
Ok(())
off_runtime(|| {
let _g = LOCK.lock();
let mut index = match self.load() {
Contents::Index(index) => index,
Contents::Plaintext(_, why) => return Err(unavailable(&why)),
};
let service = service(&index.id);
self.vault.set(&service, key, secret).map_err(|e| unavailable(&e.to_string()))?;
if index.keys.insert(key.to_owned())
&& let Err(e) = self.write(&index)
{
let _ = self.vault.delete(&service, key);
return Err(e);
}
Ok(())
})
}
pub fn delete(&self, key: &str) {
let _g = LOCK.lock();
match self.load() {
Contents::Index(mut index) => {
if !index.keys.contains(key) {
return;
off_runtime(|| {
let _g = LOCK.lock();
match self.load() {
Contents::Index(mut index) => {
if !index.keys.contains(key) {
return;
}
match self.vault.delete(&service(&index.id), key) {
Ok(()) | Err(StoreError::NoEntry) => {}
Err(e) => {
// Kept in the index so that the next delete tries again.
tracing::warn!("saved passwords: could not forget {key}: {e}");
return;
}
}
index.keys.remove(key);
self.write_logged(&index);
}
match self.vault.delete(&service(&index.id), key) {
Ok(()) | Err(StoreError::NoEntry) => {}
Err(e) => {
// Kept in the index so that the next delete tries again.
Contents::Plaintext(mut old, _) => {
if old.remove(key).is_none() {
return;
}
// A move halfway may have copied it already.
if let Some(id) = self.pending_id()
&& let Err(e) = self.vault.delete(&service(&id), key)
&& !matches!(e, StoreError::NoEntry)
{
tracing::warn!("saved passwords: could not forget {key}: {e}");
return;
}
self.write_logged(&old);
}
index.keys.remove(key);
self.write_logged(&index);
}
Contents::Plaintext(mut old, _) => {
if old.remove(key).is_none() {
return;
}
// A move halfway may have copied it already.
if let Some(id) = self.pending_id()
&& let Err(e) = self.vault.delete(&service(&id), key)
&& !matches!(e, StoreError::NoEntry)
{
tracing::warn!("saved passwords: could not forget {key}: {e}");
return;
}
self.write_logged(&old);
}
}
})
}
/// Every stored key, sorted (the secrets themselves stay private).
pub fn keys(&self) -> Vec<String> {
let _g = LOCK.lock();
match self.load() {
Contents::Index(index) => index.keys.into_iter().collect(),
Contents::Plaintext(old, _) => old.into_keys().collect(),
}
off_runtime(|| {
let _g = LOCK.lock();
match self.load() {
Contents::Index(index) => index.keys.into_iter().collect(),
Contents::Plaintext(old, _) => old.into_keys().collect(),
}
})
}
fn load(&self) -> Contents {
@@ -558,4 +584,50 @@ mod tests {
assert_eq!(mode & 0o777, 0o600);
}
}
/// A credential store that behaves like the Linux Secret Service client:
/// its blocking API runs a tokio runtime.
#[derive(Default)]
struct NestedRuntimeVault {
entries: Mutex<HashMap<(String, String), String>>,
}
fn nested_runtime() {
tokio::runtime::Builder::new_multi_thread().build().expect("a nested runtime").block_on(async {});
}
impl Vault for NestedRuntimeVault {
fn available(&self) -> bool {
true
}
fn get(&self, service: &str, key: &str) -> StoreResult<String> {
nested_runtime();
self.entries.lock().get(&(service.to_owned(), key.to_owned())).cloned().ok_or(StoreError::NoEntry)
}
fn set(&self, service: &str, key: &str, secret: &str) -> StoreResult<()> {
nested_runtime();
self.entries.lock().insert((service.to_owned(), key.to_owned()), secret.to_owned());
Ok(())
}
fn delete(&self, service: &str, key: &str) -> StoreResult<()> {
nested_runtime();
self.entries.lock().remove(&(service.to_owned(), key.to_owned())).map(|_| ()).ok_or(StoreError::NoEntry)
}
}
/// Saving a password from a Tauri command runs on a tokio runtime; the
/// Secret Service's blocking client starts a runtime of its own and panics
/// when both share a thread ("Cannot start a runtime from within a runtime").
#[tokio::test(flavor = "multi_thread")]
async fn credential_store_access_leaves_the_async_runtime_first() {
let tmp = tempfile::tempdir().unwrap();
let vault = Arc::new(NestedRuntimeVault::default());
let file =
SecretFile::with_vault(tmp.path().join("db-passwords.json"), |v| serde_json::from_value(v).ok(), vault);
file.set("s1", "hunter2").unwrap();
assert_eq!(file.get("s1").as_deref(), Some("hunter2"));
assert_eq!(file.keys(), ["s1"]);
file.delete("s1");
assert_eq!(file.get("s1"), None);
}
}
+71
Переглянути файл
@@ -642,6 +642,77 @@ mod tests {
assert_eq!(reloaded, store.effective());
}
#[test]
fn unset_settings_keep_their_defaults_json_shape() {
// `SettingsStore::set` validates a key path against this JSON: a new field that
// serializes away when unset (`skip_serializing_if`) cannot be written.
let commit = defaults_json()["vcs"]["commit"].clone();
assert_eq!(
commit,
json!({
"nonModal": true,
"clearInitialMessage": false,
"analyzeCode": true,
"checkTodo": true,
"reformat": false,
"optimizeImports": false,
"cleanup": false,
"blankLineAfterSubject": true,
"limitSubject": true,
"subjectLimit": 72,
"limitBody": false,
"bodyLimit": 72,
"showRightMargin": true,
"wrapAtMargin": false,
"disabledPluginChecks": [],
"doubleClickOpensDiff": null,
})
);
}
#[test]
fn tri_state_commit_setting_writes_and_clears() {
let tmp = tempfile::tempdir().unwrap();
let cfg = tmp.path().join("cfg");
let store = SettingsStore::load(&cfg, None);
assert_eq!(store.effective().vcs.commit.double_click_opens_diff, None);
// A layer without the key: the unset state.
std::fs::create_dir_all(&cfg).unwrap();
std::fs::write(cfg.join("settings.toml"), "[vcs.commit]\nnonModal = false\n").unwrap();
assert_eq!(store.reload().vcs.commit.double_click_opens_diff, None);
for on in [true, false] {
let s = store.set(SettingsScope::Global, "vcs.commit.doubleClickOpensDiff", json!(on)).unwrap();
assert_eq!(s.vcs.commit.double_click_opens_diff, Some(on));
assert!(!store.effective().vcs.commit.non_modal);
assert_eq!(
std::fs::read_to_string(cfg.join("settings.toml")).unwrap(),
format!("[vcs.commit]\nnonModal = false\ndoubleClickOpensDiff = {on}\n")
);
assert_eq!(SettingsStore::load(&cfg, None).effective().vcs.commit.double_click_opens_diff, Some(on));
}
// `null` removes the key from the layer: back to following the interface.
let s = store.set(SettingsScope::Global, "vcs.commit.doubleClickOpensDiff", json!(null)).unwrap();
assert_eq!(s.vcs.commit.double_click_opens_diff, None);
assert_eq!(std::fs::read_to_string(cfg.join("settings.toml")).unwrap(), "[vcs.commit]\nnonModal = false\n");
// A layer already lacking the table keeps its other keys when the key is set.
let fresh = tmp.path().join("fresh");
let store = SettingsStore::load(&fresh, None);
let s = store.set(SettingsScope::Global, "vcs.commit.doubleClickOpensDiff", json!(true)).unwrap();
assert_eq!(s.vcs.commit.double_click_opens_diff, Some(true));
assert!(s.vcs.commit.non_modal);
assert_eq!(
std::fs::read_to_string(fresh.join("settings.toml")).unwrap(),
"[vcs.commit]\ndoubleClickOpensDiff = true\n"
);
// Wrong types are still rejected.
assert_eq!(
store.set(SettingsScope::Global, "vcs.commit.doubleClickOpensDiff", json!("yes")).unwrap_err().kind,
ide_core::error::ErrorKind::InvalidInput
);
}
#[test]
fn inspection_profiles_live_in_their_layers() {
let tmp = tempfile::tempdir().unwrap();
+11 -20
Переглянути файл
@@ -16,7 +16,7 @@ Open it from **Tools › Database** or **View › Tool Windows › Database**. T
| PostgreSQL | PostgreSQL, Amazon Redshift, CockroachDB, Greenplum, Neon, Supabase, TimescaleDB, YugabyteDB |
| MySQL | MySQL, MariaDB, Amazon Aurora MySQL, OceanBase (MySQL mode), SingleStore, StarRocks, TiDB |
| SQL Server | Microsoft SQL Server |
| Oracle | Oracle (needs Oracle Instant Client, see below) |
| Oracle | Oracle 19 or later (built-in thin driver, no client library) |
| ClickHouse | ClickHouse (over HTTP) |
| SQLite | SQLite |
@@ -44,7 +44,6 @@ The data source dialog has a name, an optional **Color:** (tints the tree and co
- **Read-only**: blocks writes at the session level where the database supports it (PostgreSQL, MySQL, SQLite, Oracle, ClickHouse) and disables grid editing.
- **Transaction control:** **Auto** or **Manual** (ClickHouse is always Auto).
- **Default schema:** (PostgreSQL `search_path`) or **Current schema:** (Oracle).
- **Instant Client:** (Oracle).
- **Time zone:**, a keep-alive query interval, and **Auto-disconnect after** a number of idle seconds.
- **Single database mode** (PostgreSQL): show only the configured database.
- **Startup script:** runs on every new connection.
@@ -141,28 +140,20 @@ The export button offers **Export Data…** and quick copies: **Copy as CSV**, *
**Import Data from File…** loads CSV, TSV or TXT into a table: the separator is detected, the first row can be a header matched to columns by name, and empty values can become `NULL`. The import runs in one transaction. It is not available for ClickHouse.
## Oracle Instant Client
## Oracle
Oracle support uses the Oracle client libraries, which OpenIDEA does not bundle.
Oracle connections use a built-in pure-Rust thin driver, so no Oracle client library or `ORACLE_HOME` is needed. It
supports Oracle Database 19 or later.
<Steps>
<Step title="Install Instant Client">
Download the Basic (or Basic Light) package of Oracle Instant Client 19 or later for your OS and architecture, and
unpack it in a stable place.
</Step>
<Step title="Point the data source at it">
In the data source's **Options** tab, set **Instant Client:** to that directory. If you leave it empty,
`ORACLE_HOME` or the system library path is used.
</Step>
<Step title="Connect">
On **General**, set host, port (1521) and the service name (for example `ORCLPDB1`) in **Database:**. A TNS
descriptor or alias in **Database:** is passed through as is. URLs such as
`oracle://[email protected]:1521/ORCLPDB1` and `jdbc:oracle:thin:@//db.example.com:1521/ORCLPDB1` also work.
</Step>
</Steps>
On **General**, set host, port (1521) and the service name (for example `ORCLPDB1`) in **Database:**. A full TNS
descriptor, or a TNS alias resolved from `tnsnames.ora` (`TNS_ADMIN` or `$ORACLE_HOME/network/admin`), also works there.
URLs such as `oracle://[email protected]:1521/ORCLPDB1` and `jdbc:oracle:thin:@//db.example.com:1521/ORCLPDB1` are
accepted too.
If the libraries can't be loaded, Test Connection fails with `DPI-1047` and a hint to set the Instant Client directory. On macOS, remove the quarantine flag if the libraries are blocked: `xattr -dr com.apple.quarantine /path/to/instantclient`.
If login fails with `ORA-01017` although the password is right, the account may only have old 10G password verifiers;
setting the same password again (`ALTER USER … IDENTIFIED BY …`) creates the newer ones.
The tree shows packages (their DDL is the specification and body) next to tables, views, routines and sequences.
Oracle schemas are database users; system accounts appear only with **Show internal system schemas**.
## Not supported
+7 -18
Переглянути файл
@@ -16,7 +16,7 @@ description: "连接 SQL 数据库、浏览架构、在查询控制台中执行
| PostgreSQL | PostgreSQL、Amazon Redshift、CockroachDB、Greenplum、Neon、Supabase、TimescaleDB、YugabyteDB |
| MySQL | MySQL、MariaDB、Amazon Aurora MySQL、OceanBase (MySQL mode)、SingleStore、StarRocks、TiDB |
| SQL Server | Microsoft SQL Server |
| Oracle | Oracle(需要 Oracle Instant Client,见下文) |
| Oracle | Oracle 19 及以上(内置 thin 驱动,无需客户端库) |
| ClickHouse | ClickHouse(通过 HTTP) |
| SQLite | SQLite |
@@ -44,7 +44,6 @@ description: "连接 SQL 数据库、浏览架构、在查询控制台中执行
- **只读**:在数据库支持时于会话级别禁止写入(PostgreSQL、MySQL、SQLite、Oracle、ClickHouse),同时禁用表格编辑。
- **事务控制:** **自动** 或 **手动**(ClickHouse 只能自动)。
- **默认架构:**(PostgreSQL 的 `search_path`)或 **当前模式:**(Oracle)。
- **Instant Client:**(Oracle)。
- **时区:**、保持活动查询的间隔,以及空闲多少秒后 **自动断开连接**。
- **单数据库模式**(PostgreSQL):只显示配置的数据库。
- **启动脚本:**:每次新建连接时执行。
@@ -141,25 +140,15 @@ SQL 补全基于已连接的架构:`FROM`/`JOIN` 后补全表(并根据外
**从文件导入数据…** 把 CSV、TSV 或 TXT 导入到表中:自动识别分隔符,第一行可作为标题并按名称匹配列,空值可以视为 `NULL`。导入在一个事务中完成。ClickHouse 不支持导入。
## Oracle Instant Client
## Oracle
Oracle 支持依赖 Oracle 客户端库,OpenIDEA 不自带。
Oracle 连接使用内置的纯 Rust thin 驱动,不需要 Oracle 客户端库或 `ORACLE_HOME`。支持 Oracle Database 19 及以上版本。
<Steps>
<Step title="安装 Instant Client">
下载与操作系统和架构匹配的 Oracle Instant Client 19 或更高版本的 Basic(或 Basic Light)包,解压到一个固定位置。
</Step>
<Step title="在数据源中指定目录">
在数据源的 **选项** 标签页中,把 **Instant Client:** 设为该目录。留空时使用 `ORACLE_HOME` 或系统库路径。
</Step>
<Step title="连接">
在 **常规** 中填写主机、端口(1521),并在 **数据库:** 中填写服务名(例如 `ORCLPDB1`)。在 **数据库:** 中填写 TNS
描述符或别名时会原样传给客户端。也可以使用 `oracle://[email protected]:1521/ORCLPDB1` 或
`jdbc:oracle:thin:@//db.example.com:1521/ORCLPDB1` 这样的 URL。
</Step>
</Steps>
在 **常规** 中填写主机、端口(1521),并在 **数据库:** 中填写服务名(例如 `ORCLPDB1`)。也可以在这里填写完整的 TNS 描述符,或从 `tnsnames.ora`(`TNS_ADMIN` 或 `$ORACLE_HOME/network/admin`)解析的 TNS 别名。也支持 `oracle://[email protected]:1521/ORCLPDB1`、`jdbc:oracle:thin:@//db.example.com:1521/ORCLPDB1` 这样的 URL。
如果客户端库加载失败,测试连接会报 `DPI-1047` 并提示设置 Instant Client 目录。在 macOS 上如果库文件被系统拦截,可以去掉隔离标记:`xattr -dr com.apple.quarantine /path/to/instantclient`。
如果密码正确但登录报 `ORA-01017`,可能是该账号只有旧的 10G 密码校验值;把密码重新设置为同一个值(`ALTER USER … IDENTIFIED BY …`)即可生成新的校验值。
树中在表、视图、例程和序列旁边显示包(其 DDL 为包规范和包体)。
Oracle 的架构就是数据库用户;系统账户只在勾选 **显示内部系统架构** 后显示。
+73
Переглянути файл
@@ -66,6 +66,79 @@ async function openHistory(page: Page) {
return stream;
}
for (const placement of ["editor", "toolWindow"] as const) {
test(`lets small upward wheel steps leave the end while replies stream in the ${placement}`, async ({ page }) => {
const stream = await openHistory(page);
await page.evaluate(
async ({ id, placement }) => {
const path = "/src/features/agents/store.ts";
const store = await import(path);
const state = store.useAgents.getState();
store.useAgents.setState({
tasks: new Map(state.tasks).set(id, { ...state.tasks.get(id)!, status: "running" }),
});
if (placement === "toolWindow") store.dockTask(id);
},
{ id: task.id, placement },
);
// The working indicator sits in the tray below the conversation.
await expect(
page.getByRole("status").getByText("Working…", { exact: true }).filter({ visible: true }),
).toBeVisible();
await expect(stream.locator(":scope > *")).toHaveCount(80 * 3);
await expect.poll(() => stream.evaluate((el) => el.scrollHeight - el.clientHeight - el.scrollTop)).toBeLessThan(2);
const timer = await page.evaluate(async (id) => {
const path = "/src/features/agents/store.ts";
const store = await import(path);
const el = [...document.querySelectorAll<HTMLElement>("[class*='_stream_']")].find((el) => el.offsetHeight)!;
el.addEventListener(
"wheel",
() => {
el.dataset.streamWheelStart = String(el.scrollTop);
},
{ capture: true, once: true },
);
let chunks = 0;
return window.setInterval(() => {
const state = store.useAgents.getState();
const entries = state.entries.get(id)!.slice();
const last = entries.at(-1)!;
if (last.type !== "agent") throw new Error("Expected a streaming reply");
entries[entries.length - 1] = { ...last, text: `${last.text}\n\nStreaming chunk ${++chunks}` };
store.useAgents.setState({ entries: new Map(state.entries).set(id, entries) });
el.dataset.streamChunks = String(chunks);
}, 40);
}, task.id);
const chunks = async () => Number(await stream.getAttribute("data-stream-chunks"));
try {
await stream.hover();
for (let i = 0; i < 8; i++) {
const previous = await chunks();
await page.mouse.wheel(0, -12);
await expect.poll(chunks).toBeGreaterThan(previous);
}
const top = await stream.evaluate((el) => el.scrollTop);
expect(Number(await stream.getAttribute("data-stream-wheel-start")) - top).toBeGreaterThan(80);
const previous = await chunks();
await expect.poll(chunks).toBeGreaterThan(previous + 3);
expect(Math.abs((await stream.evaluate((el) => el.scrollTop)) - top)).toBeLessThanOrEqual(1);
const jump = page.getByRole("button", { name: "Jump to Latest" });
await expect(jump).toBeVisible();
await jump.click();
await expect
.poll(() => stream.evaluate((el) => el.scrollHeight - el.clientHeight - el.scrollTop))
.toBeLessThan(2);
const resumed = await chunks();
await expect.poll(chunks).toBeGreaterThan(resumed + 3);
await expect
.poll(() => stream.evaluate((el) => el.scrollHeight - el.clientHeight - el.scrollTop))
.toBeLessThan(2);
} finally {
await page.evaluate((timer) => window.clearInterval(timer), timer);
}
});
}
for (const selected of [false, true]) {
test(`keeps long-chat scroll geometry stable while jumping through history (selected: ${selected})`, async ({
page,
+149
Переглянути файл
@@ -0,0 +1,149 @@
/**
* Double-click in the changes tree: the diff or the source file, from the Show on Double-Click
* menu, the modal Commit dialog and the non-modal Commit window defaults. Git is stubbed at the IPC layer.
*/
import type { Locator, Page } from "@playwright/test";
import { expect, openFromTree, stubDesktop, test } from "./fixtures";
const ROOT = "/dev/acme-api";
const FILE = "build.gradle.kts";
/** Opened before stubbing so the file under test has no editor tab until a double-click. */
const PRELOADED = "settings.gradle.kts";
/** A repository with one modified tracked file; the diff's HEAD side is a stubbed text. */
async function stubRepository(page: Page) {
await stubDesktop(page);
await page.evaluate(async (root) => {
const changes = [
{
path: `${root}/build.gradle.kts`,
relPath: "build.gradle.kts",
staged: null,
unstaged: "modified",
renamedFrom: null,
},
];
const h = window.__ipc.handlers;
h.vcs_roots = () => [{ root, operation: null, head: "main", headCommit: "1111111" }];
h.vcs_branch_info = () => ({
head: "main",
headCommit: "1111111",
upstream: null,
ahead: 0,
behind: 0,
operation: null,
});
h.vcs_status = () => changes;
h.vcs_file_at = () => "plugins {}\n";
const vcsModule = "/src/features/vcs/store.ts";
const { useVcs, refreshNow } = await import(/* @vite-ignore */ vcsModule);
useVcs.setState({ available: true, root, repo: root, branchesLoaded: false, stashesLoaded: false });
await refreshNow();
}, ROOT);
}
/**
* Settings writes land in the project store the way the backend answers `settings_set`: the dotted
* key is set, `null` removes it, and the store takes the returned settings.
*/
async function stubSettingsWrites(page: Page) {
await page.evaluate(async () => {
const storeModule = "/src/platform/project.ts";
const { useProject } = await import(/* @vite-ignore */ storeModule);
window.__ipc.handlers.settings_set = (args) => {
const { key, value } = args as { key: string; value: string };
const next = structuredClone(useProject.getState().settings ?? {}) as Record<string, unknown>;
const path = key.split(".");
let node = next;
for (const seg of path.slice(0, -1)) {
node[seg] ??= {};
node = node[seg] as Record<string, unknown>;
}
const last = path[path.length - 1]!;
const parsed: unknown = JSON.parse(value);
if (parsed === null) delete node[last];
else node[last] = parsed;
useProject.setState({ settings: next as never });
return next;
};
});
}
/** Sets a commit setting in the store directly, before the window that reads it opens. */
async function setCommitSetting(page: Page, key: "nonModal" | "doubleClickOpensDiff", value: boolean | null) {
await page.evaluate(
async ({ key, value }) => {
const storeModule = "/src/platform/project.ts";
const { useProject } = await import(/* @vite-ignore */ storeModule);
const next = structuredClone(useProject.getState().settings ?? {}) as Record<string, unknown>;
const vcs = (next.vcs ??= {}) as Record<string, Record<string, unknown>>;
const commit = (vcs.commit ??= {});
if (value === null) delete commit[key];
else commit[key] = value;
useProject.setState({ settings: next as never });
},
{ key, value },
);
}
const diffTab = (page: Page) => page.getByRole("tab", { name: `${FILE} (HEAD ↔ Your version)` });
const sourceTab = (page: Page) =>
page.getByRole("tablist", { name: "Editor tabs" }).getByRole("tab", { name: FILE, exact: true });
async function openCommitWindow(page: Page) {
await openFromTree(page, PRELOADED);
await stubRepository(page);
await stubSettingsWrites(page);
await page.keyboard.press("Meta+k");
return page.getByRole("region", { name: "Commit", exact: true });
}
async function doubleClickChange(panel: Locator) {
await panel.getByRole("tree", { name: "Changes" }).getByText(FILE, { exact: true }).dblclick();
}
async function pickDoubleClickMode(page: Page, panel: Locator, mode: string) {
await panel.getByRole("button", { name: "View Options" }).click();
await page.getByRole("menuitem", { name: "Show on Double-Click" }).hover();
const item = page.getByRole("menuitemcheckbox", { name: mode, exact: true });
await item.click();
await expect(item).toBeHidden();
}
test("non-modal Commit window: double-click opens the diff by default", async ({ workbench: page }) => {
const panel = await openCommitWindow(page);
await expect(panel.getByRole("textbox", { name: "Commit message" })).toBeFocused();
await doubleClickChange(panel);
await expect(diffTab(page)).toHaveAttribute("aria-selected", "true");
await expect(sourceTab(page)).toHaveCount(0);
});
test("Show on Double-Click → Source opens the file itself, and Follow the Interface restores the diff", async ({
workbench: page,
}) => {
const panel = await openCommitWindow(page);
await pickDoubleClickMode(page, panel, "Source");
await doubleClickChange(panel);
await expect(sourceTab(page)).toHaveAttribute("aria-selected", "true");
await expect(diffTab(page)).toHaveCount(0);
await pickDoubleClickMode(page, panel, "Follow the Interface");
await doubleClickChange(panel);
await expect(diffTab(page)).toHaveAttribute("aria-selected", "true");
});
test("modal Commit dialog: double-click opens the diff in a front window, not an editor tab", async ({
workbench: page,
}) => {
await openFromTree(page, PRELOADED);
await stubRepository(page);
await stubSettingsWrites(page);
await setCommitSetting(page, "nonModal", false);
await page.keyboard.press("Meta+k");
const dialog = page.getByRole("dialog", { name: "Commit Changes" });
await expect(dialog).toBeVisible();
await doubleClickChange(dialog);
await expect(page.getByRole("dialog", { name: /\(HEAD ↔ Your version\)/ })).toBeVisible();
await expect(sourceTab(page)).toHaveCount(0);
await expect(diffTab(page)).toHaveCount(0);
});
+82
Переглянути файл
@@ -0,0 +1,82 @@
/**
* Dragging an unversioned file onto a changelist in the Commit tool window: it is
* added to Git (IDEA's Unversioned Files behaviour) and joins that list.
*/
import type { Page } from "@playwright/test";
import { expect, ipcCalls, openFromTree, stubDesktop, test } from "./fixtures";
const ROOT = "/dev/acme-api";
/** A repository with a modified tracked file and an untracked one; staging tracks the latter. */
async function stubRepository(page: Page) {
await stubDesktop(page);
await page.evaluate(async (root) => {
let staged = false;
const h = window.__ipc.handlers;
h.vcs_roots = () => [{ root, operation: null, head: "main", headCommit: "1111111" }];
h.vcs_branch_info = () => ({
head: "main",
headCommit: "1111111",
upstream: null,
ahead: 0,
behind: 0,
operation: null,
});
h.vcs_status = () => [
{
path: `${root}/build.gradle.kts`,
relPath: "build.gradle.kts",
staged: null,
unstaged: "modified",
renamedFrom: null,
},
{
path: `${root}/notes.txt`,
relPath: "notes.txt",
staged: staged ? "added" : null,
unstaged: staged ? null : "untracked",
renamedFrom: null,
},
];
h.vcs_stage = () => {
staged = true;
return null;
};
const vcsModule = "/src/features/vcs/store.ts";
const { useVcs, refreshNow } = await import(/* @vite-ignore */ vcsModule);
useVcs.setState({ available: true, root, repo: root, branchesLoaded: false, stashesLoaded: false });
await refreshNow();
}, ROOT);
}
test("dragging an unversioned file onto a changelist adds it to Git", async ({ workbench: page }) => {
await openFromTree(page, "build.gradle.kts");
await stubRepository(page);
await page.keyboard.press("Meta+k");
const panel = page.getByRole("region", { name: "Commit", exact: true });
const changes = panel.getByRole("tree", { name: "Changes" });
// Unversioned Files starts collapsed.
await changes
.getByRole("treeitem", { name: /Unversioned Files/ })
.locator("[data-tree-toggle]")
.click();
const source = changes.getByText("notes.txt", { exact: true });
await expect(source).toBeVisible();
const target = changes.getByText("Changes", { exact: true }).first();
const from = (await source.boundingBox())!;
const to = (await target.boundingBox())!;
await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2);
await page.mouse.down();
await page.mouse.move(to.x + to.width / 2, to.y + to.height / 2, { steps: 10 });
await page.mouse.up();
// The drop adds the file to Git instead of only moving a changelist assignment.
await expect.poll(async () => (await ipcCalls(page, "vcs_stage")).length).toBe(1);
expect((await ipcCalls(page, "vcs_stage"))[0]).toMatchObject({ paths: [`${ROOT}/notes.txt`] });
// It is versioned now: the Unversioned Files group is gone and the file stays under Changes.
await expect(changes.getByRole("treeitem", { name: /Unversioned Files/ })).toHaveCount(0);
await expect(changes.getByRole("treeitem", { name: /notes\.txt/ })).toBeVisible();
});
Loaded 100 of 323 files, more files were not shown because too many files have changed in this diff. Show more