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

..
80 Коміти
Автор SHA1 Повідомлення Дата
l0ng-ai c5be2998ec chore: v0.1.45 2026-11-14 19:38:04 +08:00
l0ng-ai b83df2dbda fix(agents): keep the session status line readable in a narrow panel
The status line under a session wraps what doesn't fit to the next line
instead of shrinking every fact to a letter or two; the changes chip
starts that line and the meters end it. The current-file hint keeps to
one line, and references in the composer get a remove button like the
attached blocks.
2026-11-14 19:24:55 +08:00
l0ng-ai 9f7a35f170 fix(boot): allow 60 seconds for startup before showing the failure page
A cold start can take longer than 30 seconds before the workbench is up
(the first load of a development build, a slow machine), and the failure
page then stays over a window that did start.
2026-11-14 19:24:54 +08:00
l0ng-ai 52b3e14a8c fix(ui): translate template names, keep stash messages readable, retitle tool windows on a language switch (#3361)
Built-in template names are translated where the New menu, popup, dialog and actions show them (they stay identifiers); the Stash list gives its message the room, keeping the time whole and hiding the branch in a narrow list; an open tool window's header follows a language switch.
2026-11-14 19:04:08 +08:00
l0ng-ai 934e4ca0e7 test(database): keep the SSH tunnel test's command off Tauri's invoke (#3357)
The SSH tunnel test mocked call but not the command it receives, so commands.dbTestTunnel ran Tauri's invoke in jsdom and its unhandled rejection failed the Frontend job on main. The test stubs dbTest/dbTestTunnel now.
2026-11-14 18:52:31 +08:00
l0ng-ai a2809bda80 feat(database): search the tree from a field over the toolbar
Typing in the Database tree opens a search field over the toolbar's
first buttons, with a caret and a toggle that shows only the matching
objects; matched text is highlighted in the tree. The always-visible
"Filter objects" box in the toolbar is removed along with its state
and the loading it triggered.
2026-11-14 18:49:30 +08:00
l0ng-ai 0fd8389b33 fix: close gaps in the result submit, busy close and browser navigation fixes (#3352)
Pending result edits live only in the shared per-result store, so a submit finishing while its pane is unmounted clears the remounted pane even if the refresh fails; busy tabs are checked again after discards, skipping already-confirmed ones; the reused debug page's target is made canonical in the IDE instead of using the page's URL global.
2026-11-14 18:33:59 +08:00
l0ng-ai 19c0ae07f2 fix: close gaps in the LSP URI, index snapshot and MCP restart fixes (#3349)
Encode a diagnostic's codeDescription.href; track a linked worktree's shared config and exclude file in the index snapshot; wait for the stopped MCP server's port at any address, since 127.0.0.1 and 0.0.0.0 on one port overlap.
2026-11-14 18:31:45 +08:00
l0ng-ai 5a5b53d86b fix(vcs): stash the whole working tree again (#3348)
#3314 passed --literal-pathspecs on every stash push; a whole-tree stash with untracked files or keep-index makes git pass itself the pathspec :/, which then failed or left untracked files behind. Literal pathspecs are used only for chosen files. The integration tests' stash_push calls are updated to the new signature (they broke main's build).
2026-11-14 18:31:07 +08:00
l0ng-ai ac7a81db1c fix(agents): keep the new-task composer still when switching agents
The notes under the composer (agent version, first-task hint, warnings)
change from 0 to 2 lines as the agent is switched and probed. The home
view centres its content, so each change moved the composer. The notes
now sit in a zero-height container that doesn't take part in centring;
the docked layout keeps them in flow.
2026-11-14 18:27:14 +08:00
l0ng-ai 9681e1a118 fix(settings): select the page a search names over pages that mention it (#3345)
Settings search selected the first page in tree order matching anywhere (Plugins → Notifications, TypeScript → Keymap). A matching page whose title is the query, or starts with it, now wins.
2026-11-14 18:19:20 +08:00
l0ng-ai 293972baaa fix(mcp): wait only for the stopped server's own port on a restart (#3341)
With the fixed MCP port taken by another program the server runs on a free port, yet every restart polled the taken port for two seconds, blocking the window. A restart now waits only for the port the stopped server held.
2026-11-14 18:02:19 +08:00
l0ng-ai b173a53004 fix(index): check the ignore files above the project before a warm start (#3340)
The startup snapshot (#3286) did not record the ignore files above the project root that the walker reads, nor the enclosing repository's exclude file and config, so changing them while the project was closed kept a stale file list. They are now part of the snapshot's external inputs.
2026-11-14 17:58:25 +08:00
l0ng-ai f6e54f581b fix(debugger): send a reused browser page to each launch's address, keep multi-line watches whole (#3339)
A reused debug browser compared a new launch's URL with the one it was started on, so A → B → A left the page on B; the page now compares its own address. Live watches split the watch list on newlines, evaluating a multi-line watch line by line; the list is serialised as JSON now.
2026-11-14 17:55:35 +08:00
l0ng-ai 276a30849c fix(database): keep a result's submission from repeating after switching tabs (#3337)
The submit-in-flight state of a result pane was component-local, so switching result tabs during a submit re-enabled Submit on the restored edits (#3114) and could insert a row twice. It is kept per result now, and a successful submit drops the kept edits directly.
2026-11-14 17:53:03 +08:00
l0ng-ai 63abedaadd fix(editor): ask before ending a busy tab's session ahead of discarding changes (#3336)
Bulk closes discarded the documents answered with Discard before asking whether to end busy tabs' sessions (#3177), so Cancel there lost the changes. The busy-tab question is now asked before any discard.
2026-11-14 17:51:20 +08:00
l0ng-ai c22a7fa112 fix(lsp): encode only the URI fields of a server's messages (#3335)
#3234 percent-encoded every string starting with file:, so formatting edits and completions whose text begins with file: (YAML, .env) had spaces and newlines escaped. Only URI fields and workspace-edit changes keys are encoded now.
2026-11-14 17:49:21 +08:00
l0ng-ai a46f187428 fix(menu): give every menu item a catalog label (#3328)
22 menu items (Open File…, Remote Development…, Deployment and SSH items, Navigation Bar, Update Application, project-tab window items) had no menu.* catalog key and stayed English in translated menus. They get en/zh labels, and a test checks every menu action and submenu has both.
2026-11-14 16:57:33 +08:00
l0ng-ai ff826279f2 feat(menu): group the File menu's creation items under a New submenu (#3327)
File now starts with New › Project…, Workspace…, File…, Scratch File, followed by the open items, the workspace items and the project items in separated groups. The workspace items get menu.* catalog labels (they showed in English in other languages). Labels of the moved items are shortened in every bundled locale; docs naming these paths are updated.

Closes #3305
2026-11-14 16:55:16 +08:00
l0ng-ai 759fb3278d test(i18n): allow the tsserver memory sizes to read the same in every locale (#3326)
The tsserver memory limit options from #3325 are numbers with a unit whose Chinese text equals the English; list them as same-in-every-language so i18n.test.ts passes on main again.
2026-11-14 16:51:45 +08:00
Muuтаmuutot f7d19e1711 feat(vcs): list the incoming and outgoing commits (#3309)
The title-bar branch widget showed incoming/outgoing counts with no way to see the commits. Clicking a marker, or Incoming and Outgoing Commits… in the Branches popup, now opens a popup listing them (hash, subject, author, relative date) from the existing vcsLog ranges branch..upstream / upstream..branch. A row opens the commit's diff, Open in Git Log shows it in the log, and the keyboard moves and opens rows. A single-direction popup doesn't repeat its title as a section header. Empty states cover no upstream, up to date and an empty range. Docs and both text catalogs are updated.

Co-authored-by: muutot <[email protected]>
2026-11-14 16:47:27 +08:00
Qingтаqtvbwfn 97d202ade0 fix(vue): keep tsserver from crash-looping at its heap limit (#3325)
In Vue hybrid mode one tsserver program holds every .vue file's virtual TypeScript, and vtsls' default 3072 MB heap ended in a GC death spiral and restart loop. Hybrid mode now raises the heap to 8192 MB, runs without the separate syntax server, and turns off automatic type acquisition when a tsconfig/jsconfig is present. When tsserver dies at its heap limit, its running progress tasks are ended and the loading state is cleared, so the restarted instance reports fresh instead of leaving zombie tasks and held-back answers.

A new TypeScript setting, tsserver memory limit (0 = auto), sets maxTsServerMemory for every vtsls project; auto keeps 8192 MB in hybrid mode and the server default elsewhere.

Co-authored-by: qtvbwfn <[email protected]>
2026-11-14 16:46:07 +08:00
ntbang0901 f0f15366e4 feat(debugger): Network tab for a debugged page (#3315)
A page session gets a Network tab in the Debug tool window: the page's requests with status, method, type, size and time, filtered by URL, resource kind and My code only (a request sent through a library is attributed to the project code that called it). A request shows its headers, response body (text, cut at 1 MB) and initiator; Break on Next Request turns it into a request breakpoint, and Reload Page loads the page again. js-debug's enableNetworking is asked once the page's launch is answered; a js-debug that doesn't report requests says so in the tab.

Co-authored-by: ntbang0901 <[email protected]>
2026-11-14 16:37:23 +08:00
ntbang0901 fecbf8ed14 feat(debugger): evaluate watches while a JavaScript program runs (#3318)
js-debug evaluates without suspending, so with Settings › Debugger › Evaluate watches while the program runs (off by default) the watches of a running Node.js program or page are evaluated once a second. Nothing is evaluated while the Debug tool window is hidden or the session is paused or ended, and a slow round isn't joined by another. Other debuggers keep showing "Not suspended".

Co-authored-by: ntbang0901 <[email protected]>
2026-11-14 16:35:01 +08:00
Muuтаmuutot 2d00f09393 feat(vcs): let annotate skip the commits in blame.ignoreRevsFile (#3310)
Annotate passes the file blame.ignoreRevsFile names to git blame (resolved from the worktree root, read from the config snapshot so Restricted Mode starts no program); a configured file missing from disk is cleared with --no-ignore-revs-file. The annotation context menu gets Ignore This Commit, which appends the commit to the list git reads: the configured file, else .git-blame-ignore-revs at the repository root, which the local config then names. Existing lines and line endings are kept and a listed commit isn't repeated.

Co-authored-by: muutot <[email protected]>
2026-11-14 16:24:18 +08:00
Muuтаmuutot f154bf8c83 feat(vcs): stash only the selected files (#3314)
The changes tree context menu gets Stash Selected Files…, using the Stash dialog. git stash push gets the selected paths (with --literal-pathspecs), grouped per repository like Shelve, so a multi-repository selection stashes each repository's part; a path outside the repository is rejected.

Co-authored-by: muutot <[email protected]>
2026-11-14 16:22:07 +08:00
Mansur Akhmadiтаmansur-akhmadi d3720c0e11 feat(vcs): choose the repository in the Pull dialog when the project has several (#3312)
With two or more Git repositories, the Pull dialog shows a Repository combo before the remote. Picking one reloads its remotes, branches and branch info, and Pull runs in that repository; the choice stays local to the dialog.

Closes #3311

Co-authored-by: mansur-akhmadi <[email protected]>
2026-11-14 16:21:49 +08:00
l0ng-ai ec862b3668 test(libraries): index the refreshed node's children safely (#3324)
The refresh test from #3317 failed pnpm typecheck under noUncheckedIndexedAccess; it now uses optional access at each index.
2026-11-14 16:14:27 +08:00
l0ng-ai 735830d642 fix(remote): trust SSH host keys and passwords on the host that connects (#3323)
In a remote window (SSH, WSL, dev container) deployments, data source tests and run targets
connect from the server, but "Trust Host Key and Retry", a password entered for an SSH
configuration and the SSH settings page's checks went to the desktop's SSH manager. The server
then still didn't know the key ("no host key to trust" on the desktop), so a configuration
that worked in a local window could never connect from the remote one. The SSH configuration
commands are now served by the remote host too, like db_trust_ssh_host already was.

Fixes #3313
2026-11-14 16:09:39 +08:00
LunarFellerтаLunarFeller 65c0413dbb fix(agents): preserve drafts edited while sending (#3321)
Send and Start cleared the composer when the agent acknowledged the prompt, wiping text or attachments added while waiting. The acknowledgement now clears the composer only if it is still the submitted revision in the same project connection.

Co-authored-by: LunarFeller <[email protected]>
2026-11-14 16:08:25 +08:00
l0ng-ai 14bb7bcc6f fix(database): say that Oracle servers older than 12c are not supported (#3322)
The thin Oracle driver refuses servers older than 12c during the handshake with a generic "connections to this database server are not supported". That error now says the server is older than 12c and that OpenIDEA connects to 12c and later.

Part of #3301
2026-11-14 16:07:01 +08:00
l0ng-ai 8113d101ef fix(remote): finish a remote command whose channel stays open after it exits (#3319)
Session::exec returned only when the server closed the channel, so a remote find whose channel outlived the command (a background child holding stdout) hung sync before run on "Comparing project files" and the folder-by-folder fallback never ran. exec now drains output for a quiet period after the exit status and closes the channel, as spawn already did; the tree listing also stops when the run is cancelled.

Fixes #3299
2026-11-14 15:53:54 +08:00
l0ng-ai 5f7e05adc4 fix(libraries): keep External Libraries while a refresh is under way (#3317)
Partial streamed results (even an empty batch) replaced the whole External Libraries node, and the old node was removed before the new one was added, so the node vanished during every refresh. Partial results now keep the ecosystems that haven't reported yet and the node is replaced in place. When cargo metadata --offline fails because an edited manifest names crates not downloaded yet, the last graph for that workspace stays instead of an empty Cargo.lock fallback.
2026-11-14 15:46:08 +08:00
l0ng-ai f2b85eca4c fix(debugger): open the Debug window on Console, and on the view last picked (#3316)
The Debug window switched to Console only when it was open while the session was starting; a window opened later mounted on Threads & Variables, and a remount forgot the user's choice. It now opens on Console until a view is picked, then on the view last picked; a breakpoint or exception still brings Threads & Variables forward.
2026-11-14 15:46:03 +08:00
LunarFellerтаLunarFeller e41cf32a94 fix(http-client): preserve numeric environment values in requests (#3176)
Numeric values in http-client.env.json and http-client.private.env.json were parsed into serde_json numbers and printed back, which rounds large integers and long decimals (123456789012345678901234567890 became 1.2345678901234568e+29). Variables are now read as raw JSON tokens, so the URL, headers and body get the number exactly as written; strings, booleans, nulls and nested objects behave as before.

Refs #3105.

Co-authored-by: LunarFeller <[email protected]>
2026-11-14 15:32:01 +08:00
ntbang0901 0cd19a8c0f feat(debugger): break on DOM events and requests in a debugged page (#3306)
The Breakpoints dialog gets a JavaScript Events and Requests section: stop when a debugged page handles a mouse, keyboard, form, page or timer event, or sends a request whose URL contains a given text. They are kept per project and sent through js-debug's setCustomBreakpoints to every page session, live ones at once and later ones when they start. A page's requests wait until js-debug has answered its launch, since requests sent while it sets the session up are acknowledged and dropped. A stop on one of these says what it was for above the frames, and js-debug's Caught Exceptions filter explains that it is how errors a framework catches are stopped at. A new page session whose id was used before (after a backend restart) also gets the breakpoints.

Co-authored-by: ntbang0901 <[email protected]>
2026-11-14 15:26:07 +08:00
ntbang0901 fb76a6dab7 feat(debugger): open the page of a debugged dev server, and debug a URL from a console (#3302)
Debugging a Node.js program that prints a local address serving an HTML page can open that page in a browser debug session (Settings › Build, Execution, Deployment › Debugger › JavaScript, off by default). Ctrl/Cmd+Shift-clicking a URL in a console debugs it in the browser. The new web_serves_page command only asks loopback addresses, without a proxy, follows redirects only within this machine and times out after 10 s.

Co-authored-by: ntbang0901 <[email protected]>
2026-11-14 15:24:33 +08:00
LunarFellerтаLunarFeller f4502ffffe fix(database): keep terminating averages exact (#3303)
Selection and chart means stopped after 12 fractional digits even when the division terminates, which could also change an integer mean (1220703130000 instead of 1220703125000). The shared decimal division now continues until the remainder is zero when the reduced denominator has only factors 2 and 5; repeating quotients keep their rounding.

Co-authored-by: LunarFeller <[email protected]>
2026-11-14 15:10:35 +08:00
Akbar Husain 2430c892ba fix(editor): keep inlay hints and lenses while a language server is busy (#3307)
Inlay hints and Code Vision rows were replaced with whatever a language server answered while indexing or restarting, often nothing, so the text reflowed and jumped. Shown hints, server lenses and usages lenses are now kept while a server is starting or busy (and when a usages count is still computing), and refreshed once the server settles; a server that stays down still takes its decorations with it.

Co-authored-by: akbar1214 <[email protected]>
2026-11-14 15:09:43 +08:00
Muuтаmuutot 8876d04cb8 docs(vcs): creating a repository in an open project is supported (#3308)
The VCS guide said there is no git init action for an open project and listed it as unsupported. Git › Create Git Repository… runs git init in a chosen folder and registers the repository, so the guide now describes it next to Clone… and drops the unsupported entry.

Co-authored-by: muutot <[email protected]>
2026-11-14 15:07:54 +08:00
l0ng-ai eeb20f467c feat(plugins): list installed and marketplace plugins together, in foldable groups (#3300)
Settings › Plugins drops its Available / Installed tabs. Search and the category filter move to the top bar, and one list shows the installed plugins (plus linked and project plugins), then the suggested, featured and all marketplace plugins, examples and local packages; each group header folds it. The details follow the entry clicked last, falling back to the other side's first entry when nothing is listed on that side.
2026-11-14 13:58:16 +08:00
zhzhsxтаzhzhsx 4ccff88ccf fix(editor): align completion labels with the word being completed (#3297)
CodeMirror anchors the completion popup at the start of the word being completed, but each row starts with a kind icon, so labels sat one icon column to the right of the text. The popup is offset left by the row's leading inset (border, row margin, padding, icon and gap), kept in custom properties that the row rules use too, so the labels line up with the word.

Co-authored-by: zhzhsx <[email protected]>
2026-11-14 13:50:26 +08:00
ntbang0901 fdf29989ec feat(debugger): debug JavaScript that runs in the browser (#3294)
Code running in a browser could not be debugged: js-debug was only started for Node.js and launch.json entries of type chrome / msedge were skipped on import. A JavaScript Debug run configuration (URL, Chrome or Edge, web root) now opens the page with js-debug attached, so breakpoints in the page's sources are hit through source maps with the usual stepping, variables, evaluate and console. Each project gets its own browser profile under the IDE's local data, a project has one debugged browser of a kind (starting again reuses it), launch.json chrome / msedge launches import as this configuration, child sessions named about:blank take their parent's name, and Vue, Svelte, Astro and HTML files take breakpoints. Not yet available in remote projects.

Co-authored-by: ntbang0901 <[email protected]>
2026-11-14 13:49:21 +08:00
l0ng-ai 3c3b6a6fd1 fix(run): translate the message for a preferred JavaScript runtime that is not installed (#3298)
The message added in #3291 for a preferred runtime that is not installed was a plain template string, so it was never translated and textCatalog.test.ts failed on main. It goes through tx now, with the Chinese text in the catalog.
2026-11-14 13:43:35 +08:00
ntbang0901 49bb6b2d76 fix(debugger): keep watches per project (#3293)
Watches were one list shared by every project, so a watch added while debugging one project showed up, and usually failed to evaluate, in all the others. Each project now keeps its own list; a project without one starts from the old shared list once, since that list doesn't record which project each watch came from.

Co-authored-by: ntbang0901 <[email protected]>
2026-11-14 13:40:10 +08:00
zhzhsxтаzhzhsx 632e564d58 fix(editor): let Tab step out of the parentheses a completion inserted (#3292)
With "Jump outside closing bracket/quote with Tab" on, Tab only stepped over a closing character that typing had inserted; after accepting a call completion such as `order.withPrice()` it indented inside the parentheses. Completion transactions now register the pair at the tail of the inserted text, including call snippets whose caret moves inside in a later selection-only transaction.

Co-authored-by: zhzhsx <[email protected]>
2026-11-14 13:39:32 +08:00
zhzhsxтаzhzhsx 08333bcff8 fix(terminal): hide the tool window or refocus when a session ends (#3290)
With "Close session when it ends" on, a session that exited on its own closed its tab but left an empty Terminal tool window open, and the pane menu's Close Session did the same. Both paths now follow the tab close button: the tool window hides when no tabs remain; otherwise the active session takes the caret, which for a shell exiting on its own happens only when a terminal had the caret, so a background exit doesn't pull focus out of the editor.

Co-authored-by: zhzhsx <[email protected]>
2026-11-14 12:53:37 +08:00
l0ng-ai ae542b3044 feat(javascript): choose the preferred runtime on a JavaScript Runtime settings page (#3291)
The Node.js settings page becomes JavaScript Runtime with a Preferred runtime choice: Auto (the nearest deno.json / bun.lock, else Node.js, as before), Node.js, Bun or Deno. It decides what runs JavaScript and TypeScript files from the gutter and the editor's Run action, files outside the project included; a chosen runtime that is not installed is reported instead of falling back to Node.js. The package manager sits beside it, the Node interpreter and run target under a Node.js section. New setting languages.node.runtime.

Closes #3233
2026-11-14 12:48:05 +08:00
l0ng-ai 4cb98ff162 feat(tools): organize Language Servers settings by language (#3272)
Settings › Language Servers listed every tool in one flat list. The page now lists languages, with their file-type icon and how many tools each is missing; the selected language's tools are grouped into language servers, formatters, debuggers and plugins, each a small table with status, version and an inline Install link, and the selected tool's settings open under its table. Interchangeable servers get one "Language server for …" picker above them, and a Ready / Partly set up / Not set up label with an Install Missing button covers what the language needs. Update checks for installed tools show as an Update tag in the table and an Update button with the available version in the tool's details.
2026-11-14 12:37:05 +08:00
Akbar Husain cc8ccea888 perf(index): load an unchanged project's index from a startup snapshot (#3286)
Reopening a project re-walked every file, canonicalized each path and opened redb before results were available. The index now writes a flat columnar startup snapshot (paths, symbol text, file metadata, symbols, directory mtimes and the ignore inputs) and, on open, validates it against the disk: every directory's mtime, every file's mtime and size, the per-directory and external ignore files and the filter settings. Any difference falls back to the full walk, so the result is the same as before; an unchanged project is hydrated in bulk without walking the tree, and redb opens lazily. Watcher updates refresh the snapshot after reconciling the touched directories. A corrupt snapshot is rejected before any offset is used.

Warm reopen measured about 10x faster (20k files: 204 ms to 21 ms; 100k files: 967 ms to 96 ms); the first index is about 10% slower because it writes the snapshot.

Co-authored-by: akbar1214 <[email protected]>
2026-11-14 12:31:19 +08:00
l0ng-ai 75344c21ef fix(build): open Maven compile errors on Windows (#3288)
Maven prints Windows source paths as /C:/dev/p/A.java. Joined against the
working directory that became the drive-relative C:dev\p\A.java, so clicking a
build message failed with os error 3. Drop the slash before the drive letter
when parsing Maven paths, as file URIs already did.
2026-11-14 12:13:14 +08:00
Clarence 91ac7f417c fix(mybatis): find a mapper's XML by its file name before searching, and keep a found one (#3282)
In large projects a mapper interface often showed no gutter arrows to its XML: the Java-to-XML lookup searched every XML file for the namespace, which ran past the editor's deadline on a cold start and lost its cache when the instance was interrupted. The lookup now asks the project index for the XML named like the interface and confirms its namespace, falls back to the namespace search only when that finds nothing, and keeps a found path while the file still maps the namespace (only misses expire). Version 0.4.2; component rebuilt from source.

Co-authored-by: Clarence404 <[email protected]>
2026-11-14 11:52:52 +08:00
zhzhsxтаzhzhsx 2fd43aa29f fix(editor): keep the completion hint below the suggestion list (#3283)
CodeMirror rebuilds the completion list by removing the old <ul> and appending a new one, so the advertiser bar (insert/replace hint and Sort by name), which was only added when missing, ended up above the list after the first refresh. An existing bar is now moved back behind the list.

Co-authored-by: zhzhsx <[email protected]>
2026-11-14 11:51:40 +08:00
Muuтаmuutot 5b7d258adb feat(vcs): register a repository moved into an open project (#3269)
A repository folder moved or restored into an open project stayed invisible until the project was reopened: the open scan had already run, and the folder brings no `.git` event of its own where the watcher reports only the created directory. The watcher's rescan, which already ran when a `.git` / `.git/HEAD` appeared, now also runs when a created path is itself a repository root, so the repository is registered the same way a `git init` inside the project is. The project-open scan and the rescan share `discover_repositories`.

Co-authored-by: muutot <[email protected]>
2026-11-14 11:50:39 +08:00
l0ng-ai fd1cebdf98 fix(window): lay out project window tabs evenly with centred titles (#3285)
Project tabs of a merged window group now share the row equally with the project name centred; the close button sits on the left and shows on hover or keyboard focus, and the selected tab is marked by its background instead of an accent underline. The folder icon is gone. Reordering, the context menu and keyboard navigation are unchanged.

Closes #3266
2026-11-14 11:48:44 +08:00
l0ng-ai c9362247d0 ci: run the Maven launch classpath smoke for the runner JDK (#3273)
maven_launch_classpath_uses_the_runner_jdk_classifier (#2929) is an ignored project smoke test that CI never ran: add it to the java job's project_smoke step.
2026-11-14 11:33:04 +08:00
Muuтаmuutot 8ab9c688ac fix(vcs): make the line-separator check read bytes, and drop the unused check input (#3281)
The CRLF commit check compared the editor's text, which the host LF-normalises (open buffers and `text_of` alike), so it never fired. It now reads the file's bytes through fs_read_range, carrying the last byte across ranges so a CRLF that straddles two is found, and skips files fs_probe classifies as binary. The commit check context drops the `files` list no check read; checks get the paths that still exist.

Co-authored-by: muutot <[email protected]>
2026-11-14 11:23:40 +08:00
Deng Liuтаdengliu ba0b3d9c7d fix(completion): clear feature backoff on plugin updates and explicit completion (#3277)
Explicit completion (Ctrl+Space) now bypasses the per-document completion backoff, and a successful completion clears it. Plugin enable/disable or UI changes clear all backoffs, so a newly installed completion provider is asked again. On the host, an empty plugin result only counts as an answer when a completion or injection plugin actually covers the document; otherwise the request stays Unavailable and falls through to the language server.

Co-authored-by: dengliu <[email protected]>
2026-11-14 11:21:43 +08:00
Muuтаmuutot 96f8ee27dd fix(vcs): read the scoped status through git again, with the pathspec spelled right (#3280)
The scoped status read after a watcher batch handed gitoxide's in-process pathspec the paths as the platform spells them. On Windows a backslash path matched nothing, silently, so the merged answer dropped the file from the change list until a full refresh; a path that is the repository root became an empty pattern and failed the read. The scoped read is git status with the pathspec again, with /-separated patterns and the root meaning the full status, and Restricted Mode answers with the full gitoxide status since it starts no program.

Co-authored-by: muutot <[email protected]>
2026-11-14 11:19:10 +08:00
l0ng-ai 5529db667c feat(database): test a data source's SSH tunnel on its own (#3279)
The SSH/SSL tab gets Test SSH Tunnel: it logs in to the SSH host (shared SSH configuration or the settings typed in the dialog) and checks that the SSH host reaches the database host and port, without the database password or login. Success shows user@host:port and the database host:port reached; failures name the step (connecting, authenticating, reaching the database). An unknown host key offers Trust Host and Retry. The in-process SSH test server now forwards direct-tcpip channels.
2026-11-14 11:17:18 +08:00
l0ng-ai 9c55c28d5b feat(run): run, test and debug Zig projects from build.zig (#3278)
A build.zig project gets a configuration per build step (zig build, zig build run, zig build test and the project's own steps) and one per executable it declares, which runs zig build first and then the installed binary, debuggable with CodeLLDB. The build script is parsed, never run, to find them.

Zig files get gutter markers: pub fn main runs the executable built from the file (or zig run for a file on its own), a test block runs zig test <file> --test-filter <name>, a file with tests only runs them all. Debugging a single file or test builds it into a binary first (build-exe, test --test-no-exec) and launches that.

Adds fixtures/zig-app (Zig 0.17). Part of #3260.
2026-11-14 11:04:44 +08:00
l0ng-ai 009773c0d4 fix(debugger): debug Kotlin with kotlin-lsp without asking for kotlin-debug-adapter (#3274)
Since #3172, debugging a Maven / Gradle run of a module with Kotlin sources stopped before the
build and asked to install kotlin-debug-adapter whenever it was missing, although the backend
debugs Kotlin with kotlin-lsp's own debugger when kotlin-lsp is installed and only falls back
to kotlin-debug-adapter when it can't. The check now offers kotlin-debug-adapter only when
neither is installed.
2026-11-14 11:03:32 +08:00
l0ng-ai ba0f4329ea fix(vcs): refresh the diff navigator's handlers before paint (#3275)
DiffEditor's registered navigator refreshed its handlers in a passive effect, so a shortcut pressed right after the toolbar changed (Jump to Source once the working copy is found) ran the previous handler and did nothing. The handlers are refreshed in a layout effect now. This was also why the diffToolbar F4 / Cmd+Down test failed about one run in eight.
2026-11-14 10:50:31 +08:00
Deng Liuтаdengliu 4d88d44616 feat(language): add a Helm Template language for chart templates (#3261)
Files in a Helm chart's templates folder (yaml, yml, tpl, txt under a Chart.yaml / Chart.yml root) and *.gotmpl files are detected as Helm Template instead of YAML or plain text. They contain Go text/template + Sprig actions that are not valid YAML until rendered, so the YAML language server, formatter and tree-sitter syntax check no longer attach to them and flood the editor with errors.

A CodeMirror stream mode highlights YAML outside {{ … }} and keywords, variables, dot paths, functions and comments (including {{- /* … */ -}}) inside. .tpl outside a chart keeps its current type, since Smarty, Terraform and other engines use it too.

Co-authored-by: dengliu <[email protected]>
2026-11-14 10:46:56 +08:00
Muuтаmuutot 5e5d804b89 feat(vcs): refresh the working tree status incrementally (#3267)
A watcher batch used to re-read the whole working tree status, so a save in a large repository walked every file. The batch's paths now go to a scoped read (vcs_status_paths, gitoxide's status with a pathspec; git status with a pathspec as the fallback) and are merged into the loaded status: a path that is no longer changed drops out, a removed directory drops what is below it, new entries land in path order.

The full status still answers when the scoped one could be wrong: batches over 200 paths, a status that is not loaded yet, ignore-rule changes, a failed read, and batches that can split a rename. Git pairs a deletion with an addition across the whole index, so a pathspec read of a staged rename's target reports it as added and drops the source; batches that touch a rename, read one, or read an addition or deletion while the other side exists elsewhere take the full status. Restricted Mode keeps starting no program: the scoped read is in-process.

Co-authored-by: muutot <[email protected]>
2026-11-14 10:44:11 +08:00
Muuтаmuutot 83b9744a36 feat(vcs): annotate the file as of an earlier revision (#3265)
The annotation gutter's menu gains IDEA's annotate family:

- Annotate Previous Revision opens the file at the first parent of the annotated line's commit next to the commit's version, with the previous side annotated; on a file's first commit it says there is no previous revision.
- Annotate at Revision… opens the file's revision chooser and shows the picked revision annotated next to the working copy, following renames through historyPath.

One diff viewer provider annotates the left pane once per diff, so annotations the user closes stay closed when the panes rebuild. The diff opens at its first change, since the annotated line's number belongs to the working copy.

Co-authored-by: muutot <[email protected]>
2026-11-14 10:38:39 +08:00
l0ng-ai 4ab8959bac ci: run the Kotlin smoke against kotlin-lsp 271.2085.0 (#3270)
263.7084.0 is a JetBrains pre-release build that has expired: it now exits at startup ("This build of kotlin-server has expired"), so Language smoke (kotlin) failed on every branch.
2026-11-14 10:35:44 +08:00
GuoYuтаguoyucode d715643ff2 feat(mcp): a listen address for the IDE MCP server, 127.0.0.1 by default (#3146)
Agents › IDE MCP Server gets a Listen address setting (mcpServer.address): 127.0.0.1 by default, or another address of this computer (LAN / VPN) or 0.0.0.0 / :: so agents on other computers can connect. Requests must still carry the bearer token, and the Host check still refuses host names other than localhost (DNS rebinding). An address that isn't an IP address, or isn't on this computer, stops the server with an error naming it. Clients on this computer keep connecting through loopback; the discovery file records the address. The settings hint and docs warn that anyone who can reach the address and has the token can use the tools.

Co-authored-by: guoyucode <[email protected]>
2026-11-14 10:32:24 +08:00
Muuтаmuutot a5214cae38 feat(vcs): make before-commit checks a registry (#3268)
Before-commit checks register through one extension point (CommitCheck, registerCommitCheck, getCommitChecks), ordered by order then id. Each check reports a severity: an error holds the commit (the next click is Commit Anyway), a warning is shown without stopping it. Every built-in check reports error, so the Commit view, messages and the Commit Anyway flow are unchanged. The before-commit modifications (reformat, optimize imports, cleanup) run as one step ahead of the checks, so the analysis, TODO and CRLF checks read the rewritten files. A check that throws is reported instead of breaking the commit.

Co-authored-by: muutot <[email protected]>
2026-11-14 10:31:25 +08:00
Vadim LazukтаInf1mum 54dd3a167f fix(database,http-client,graphql): keep exact numeric text in aggregates, charts and JSON paths (#3175)
Large or very precise numbers were rounded through JavaScript doubles in database result aggregates (sum / average / min / max) and charts, in HTTP response formatting and in GraphQL variables and responses. Aggregates and charts now compute on exact decimal text, and a shared lossless JSON parser/serialiser (src/platform/losslessJson.ts) keeps number literals as written for HTTP and GraphQL.

Refs #3105

Co-authored-by: Inf1mum <[email protected]>
2026-11-14 10:28:39 +08:00
l0ng-ai 0938a507da fix(macos): require macOS 13, the first release whose system engine runs the app (#3271)
On macOS 11 and 12 other apps keep using the system's built-in WebKit (Safari 14.1 / 15.x) even after Safari is updated, so the Safari 16 interface could not start there. The bundle now declares macOS 13 as its minimum, the install docs say macOS 13, and the startup failure page no longer suggests updating Safari.

Fixes #2008
2026-11-14 10:23:17 +08:00
Daniel AdámekтаDanielADK a05a40e0a3 fix(editor): end a <<- heredoc at its tab-indented delimiter in shell highlighting (#3259)
The stock CodeMirror shell mode ends a heredoc body only on a line equal to the delimiter and drops the `-` of `<<-`, so a tab-indented closing delimiter highlighted the rest of the file as heredoc text. shellInterpolation now tracks heredocs itself: it reads the delimiter as bash does (quotes removed, ends at `|`, `;`, `>`…), strips leading tabs for `<<-`, queues several heredocs on one line, starts bodies on the next line, and leaves `<<` in `$(( ))` and `<<<` here-strings as operators.

Co-authored-by: DanielADK <[email protected]>
2026-11-14 09:08:41 +08:00
acaoтаamphetamlne 3b978721e3 test(plugins): exercise remote diagnostics through the host snapshot (#3254)
The remote plugin API test still filled the UI Problems reports after #3103 moved plugin diagnostics to the host-owned snapshot, so it failed on main with an empty result. It now publishes through the project's recording event sink and checks the snapshot's per-file / per-producer semantics, project close and RPC disconnect.

Co-authored-by: amphetamlne <[email protected]>
2026-11-14 09:07:46 +08:00
l0ng-ai 9793857024 fix(plugins): rebuild the diagnostics test fixture after formatting its source (#3264)
#3263 ran cargo fmt on the fixture's src/lib.rs, which changes its source fingerprint, so plugins/build.sh --check reported dist/plugin.wasm as stale.
2026-11-14 09:05:35 +08:00
l0ng-ai 8fb3b7b084 fix(vcs): keep F7 working in the diff it opens for the next file (#3263)
F7 / Shift+F7 past the last difference opened the neighbouring file with focus on the editor tab's host, so the diff's keymap no longer applied; it had only worked through a stale navigator registration that #3137 removed. The diff now forwards the host's focus to its focused pane, as the image and hex viewers do. Also fixes the htmlFrame test's race with its listener effect and formats the diagnostics plugin fixture, so main's Frontend and Plugins jobs pass.
2026-11-14 09:01:07 +08:00
Deng Liuтаdengliu 22fe1702be feat(plugins): dynamic file and folder icons by path (API 1.8) (#3257)
Static [[contributes.fileIcons]] entries match file names only, so a plugin could not tell a Helm chart folder, its templates or a Kustomize overlay from any other YAML file or folder. API 1.8 adds decorations.set-file-icon and the batched set-file-icons (up to 10,000 paths per set, requires project.read): a plugin assigns one of its declared file icons to an exact file or plain-folder path, shown in the project tree and editor tabs after the user's own file type icons. Under engines.openidea = "^1.8" a fileIcons entry may omit patterns, for icons used only this way. Both WIT copies, the SDK (host functions and mock), the plugins README and the bundled components are updated.

Co-authored-by: dengliu <[email protected]>
2026-11-14 07:20:33 +08:00
János Kranczlerтаjanoskranczler ad434af78c feat(agents): import sessions from other apps from the Agents tool window (#3255)
Importing Claude Code / Codex sessions started outside OpenIDEA was only reachable from the history popup of a docked session and imported one row at a time. A new Import Sessions from Other Apps… action is listed in the Agents tool window's options menu and in the empty task list. It opens a chooser that rescans on open, filters by title or folder, selects several sessions (modifier-click, Shift-click, Cmd/Ctrl-A, Select All) and imports them in one go: a single session opens as before, several are added to the list without opening tabs, failures are reported together, and Cancel stops the batch after the import in flight.

The set of sessions being imported now lives in the store, so neither the chooser nor the docked popup can import a session twice, and late scan results after a project switch are dropped.

Co-authored-by: janoskranczler <[email protected]>
2026-11-14 05:56:28 +08:00
l0ng-ai 6d103df5cf feat(run): show what each Java test printed, for Maven and Gradle (#3256)
Selecting a test in the Run tool window's test tree shows only that test's
output, and a class shows its own output followed by its tests'.

- Maven: Surefire's console names failures but not what passed tests printed.
  The JUnit XML reports carry each test case's <system-out>/<system-err>;
  they are now read into a separate `stdout` field and shown for every test,
  before the failure details. Failures already reported on the console get
  the printed lines in front of their stack trace.
- Gradle: output of a class's own setup and teardown (@BeforeAll, @AfterAll)
  arrived under the class descriptor and showed up as an extra child named
  after the class. The init script now sends it without a test name and it
  belongs to the class node.
2026-11-14 05:51:58 +08:00
LunarFellerтаLunarFeller e2d089a0c4 fix(agents): preserve prompt text during native image paste (#3250)
When the clipboard event carried no data (WebKitGTK pasting a screenshot), the composer started reading the image from the native clipboard but returned false, so CodeMirror still handled the paste and replaced the selected prompt text with an empty string. The handler now prevents the default paste and reports it as handled; the image is still attached when the native read returns one, and the prompt and selection stay as they were in every other outcome. The clipboard fixtures in the tests now implement getData so they go through CodeMirror's own paste path. This is the only native-clipboard paste fallback in the frontend.

Co-authored-by: LunarFeller <[email protected]>
2026-11-14 05:41:38 +08:00
l0ng-ai 47fa0e6f79 feat(typescript): run TypeScript 7's own language server when it is the selected TypeScript (#3252)
TypeScript 7 has no tsserver or JavaScript API; its platform package's native executable serves LSP itself. When the TypeScript setting selects one (the project's or a chosen directory), vtsls / typescript-language-server launch that executable with --lsp --stdio instead. The project's TypeScript 7 is listed as "TypeScript 7.x (native)".
2026-11-14 04:39:59 +08:00
457 змінених файлів з 15958 додано та 1825 видалено

No files matched your search

+1 -1
Переглянути файл
@@ -7,7 +7,7 @@ BASEDPYRIGHT=1.40.2
RUFF=0.17.3
GOPLS=v0.24.0
JDTLS=1.63.0-202610191322
KOTLIN_LSP=263.7084.0
KOTLIN_LSP=271.2085.0
GRADLE=8.14.3
INTELEPHENSE=1.18.5
# The JDK projects compile against (JAVA_HOME): library navigation opens its sources.
+1 -1
Переглянути файл
@@ -119,7 +119,7 @@ jobs:
- run: cargo test --locked -p ide-host --test language_smoke --no-run
- run: cargo test --locked -p ide-host --test language_smoke -- --ignored --exact ${{ matrix.language }}
- if: matrix.language == 'java'
run: cargo test --locked -p ide-host --test project_smoke -- --ignored --exact maven_sync jdk
run: cargo test --locked -p ide-host --test project_smoke -- --ignored --exact maven_sync jdk maven_launch_classpath_uses_the_runner_jdk_classifier
- if: matrix.language == 'kotlin'
run: cargo test --locked -p ide-host --test project_smoke -- --ignored --exact gradle_sync
- if: matrix.language == 'python'
Створено
+44 -44
Переглянути файл
@@ -4144,7 +4144,7 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "ide-agent"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"dunce",
"encoding_rs",
@@ -4168,7 +4168,7 @@ dependencies = [
[[package]]
name = "ide-analysis"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"ast-grep-core",
"ide-core",
@@ -4182,7 +4182,7 @@ dependencies = [
[[package]]
name = "ide-android"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"ide-core",
"serde",
@@ -4193,7 +4193,7 @@ dependencies = [
[[package]]
name = "ide-build"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"blake3",
"chardetng",
@@ -4211,7 +4211,7 @@ dependencies = [
[[package]]
name = "ide-cli"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"dirs 6.0.0",
"serde",
@@ -4225,7 +4225,7 @@ dependencies = [
[[package]]
name = "ide-clipboard"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"gtk",
"objc2 0.6.5",
@@ -4235,7 +4235,7 @@ dependencies = [
[[package]]
name = "ide-core"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"dunce",
"encoding_rs",
@@ -4252,7 +4252,7 @@ dependencies = [
[[package]]
name = "ide-coverage"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"ide-core",
"ide-run",
@@ -4266,7 +4266,7 @@ dependencies = [
[[package]]
name = "ide-dap"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"futures",
"ide-core",
@@ -4293,7 +4293,7 @@ dependencies = [
[[package]]
name = "ide-db"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"base64 0.22.1",
"bb8",
@@ -4329,7 +4329,7 @@ dependencies = [
[[package]]
name = "ide-docker"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"ide-core",
"serde",
@@ -4342,7 +4342,7 @@ dependencies = [
[[package]]
name = "ide-extension"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"base64 0.22.1",
"blake3",
@@ -4363,7 +4363,7 @@ dependencies = [
[[package]]
name = "ide-fonts"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"objc2-core-foundation",
"objc2-core-text",
@@ -4375,7 +4375,7 @@ dependencies = [
[[package]]
name = "ide-github"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"ide-core",
"ide-vcs",
@@ -4388,7 +4388,7 @@ dependencies = [
[[package]]
name = "ide-heap"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"criterion",
"ide-core",
@@ -4406,7 +4406,7 @@ dependencies = [
[[package]]
name = "ide-host"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"base64 0.22.1",
"blake3",
@@ -4477,7 +4477,7 @@ dependencies = [
[[package]]
name = "ide-http"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"ide-core",
"ide-vfs",
@@ -4496,7 +4496,7 @@ dependencies = [
[[package]]
name = "ide-index"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"blake3",
"criterion",
@@ -4518,7 +4518,7 @@ dependencies = [
[[package]]
name = "ide-jdwp"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"ide-jvm-inspect",
"proptest",
@@ -4530,7 +4530,7 @@ dependencies = [
[[package]]
name = "ide-jsonrpc"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"encoding_rs",
"futures",
@@ -4547,7 +4547,7 @@ dependencies = [
[[package]]
name = "ide-jvm-attach"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"libc",
"tempfile",
@@ -4556,7 +4556,7 @@ dependencies = [
[[package]]
name = "ide-jvm-inspect"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"futures",
"serde",
@@ -4567,7 +4567,7 @@ dependencies = [
[[package]]
name = "ide-language"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"blake3",
"criterion",
@@ -4624,7 +4624,7 @@ dependencies = [
[[package]]
name = "ide-lsp"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"dunce",
"encoding_rs",
@@ -4652,7 +4652,7 @@ dependencies = [
[[package]]
name = "ide-mcp"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"bytes",
"dunce",
@@ -4671,7 +4671,7 @@ dependencies = [
[[package]]
name = "ide-notebook"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"futures",
"ide-core",
@@ -4694,7 +4694,7 @@ dependencies = [
[[package]]
name = "ide-plugin"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"blake3",
"dunce",
@@ -4717,7 +4717,7 @@ dependencies = [
[[package]]
name = "ide-profiler"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"ide-core",
"ide-jvm-attach",
@@ -4731,7 +4731,7 @@ dependencies = [
[[package]]
name = "ide-remote"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"dunce",
"futures",
@@ -4751,7 +4751,7 @@ dependencies = [
[[package]]
name = "ide-run"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"dunce",
"encoding_rs",
@@ -4775,7 +4775,7 @@ dependencies = [
[[package]]
name = "ide-scaffold"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"ide-core",
"ide-tools",
@@ -4791,7 +4791,7 @@ dependencies = [
[[package]]
name = "ide-sdk"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"dunce",
"ide-core",
@@ -4812,7 +4812,7 @@ dependencies = [
[[package]]
name = "ide-search"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"criterion",
"encoding_rs",
@@ -4833,7 +4833,7 @@ dependencies = [
[[package]]
name = "ide-spell"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"ide-core",
"parking_lot",
@@ -4846,7 +4846,7 @@ dependencies = [
[[package]]
name = "ide-ssh"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"dirs 6.0.0",
"flate2",
@@ -4871,7 +4871,7 @@ dependencies = [
[[package]]
name = "ide-syntax"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"ide-core",
"ide-language",
@@ -4886,7 +4886,7 @@ dependencies = [
[[package]]
name = "ide-terminal"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"ide-core",
"libc",
@@ -4899,7 +4899,7 @@ dependencies = [
[[package]]
name = "ide-text"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"criterion",
"ide-core",
@@ -4912,7 +4912,7 @@ dependencies = [
[[package]]
name = "ide-tools"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"flate2",
"hex",
@@ -4933,7 +4933,7 @@ dependencies = [
[[package]]
name = "ide-vcs"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"blake3",
"dunce",
@@ -4954,7 +4954,7 @@ dependencies = [
[[package]]
name = "ide-vfs"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"chardetng",
"dunce",
@@ -4978,7 +4978,7 @@ dependencies = [
[[package]]
name = "ide-web"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"dirs 6.0.0",
"ide-core",
@@ -4994,7 +4994,7 @@ dependencies = [
[[package]]
name = "ide-workspace"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"blake3",
"dunce",
@@ -6595,7 +6595,7 @@ dependencies = [
[[package]]
name = "openidea"
version = "0.1.44"
version = "0.1.45"
dependencies = [
"base64 0.22.1",
"blake3",
+1 -1
Переглянути файл
@@ -5,7 +5,7 @@ members = ["crates/*", "src-tauri"]
exclude = ["sdk", "plugins"]
[workspace.package]
version = "0.1.44"
version = "0.1.45"
edition = "2024"
rust-version = "1.96"
license = "Apache-2.0"
+23 -4
Переглянути файл
@@ -144,6 +144,13 @@ fn bracket_pos(s: &str) -> Option<(u32, Option<u32>, &str)> {
Some((line, col, rest.trim_start()))
}
/// `/C:/p/A.java` → `C:/p/A.java`: Maven and file URIs put a `/` before the drive letter on Windows.
/// Joined as is, it would become the drive-relative `C:p/A.java`.
fn drive_path(p: &str) -> &str {
let b = p.as_bytes();
if b.len() > 2 && b[0] == b'/' && b[1].is_ascii_alphabetic() && b[2] == b':' { &p[1..] } else { p }
}
/// `file:///p/A.kt` → `/p/A.kt` (percent-decoded).
fn from_file_uri(s: &str) -> String {
let Some(rest) = s.strip_prefix("file://") else { return s.to_owned() };
@@ -162,9 +169,7 @@ fn from_file_uri(s: &str) -> String {
out.push(bytes[i]);
i += 1;
}
let path = String::from_utf8_lossy(&out).into_owned();
// `file:///C:/x` on Windows.
if path.len() > 2 && path.as_bytes()[0] == b'/' && path.as_bytes()[2] == b':' { path[1..].to_owned() } else { path }
drive_path(&String::from_utf8_lossy(&out)).to_owned()
}
/// Kotlin: `file:///p/A.kt:12:5 msg` or `/p/A.kt: (12, 5): msg`, after the `e: ` / `w: ` prefix.
@@ -222,7 +227,7 @@ fn maven_line(line: &str) -> Option<RawMessage> {
&& looks_like_source_path(path)
&& let Some((ln, col, msg)) = bracket_pos(&format!("[{pos}"))
{
return Some(RawMessage::new(severity, msg, ColUnit::Char).at(path, ln, col));
return Some(RawMessage::new(severity, msg, ColUnit::Char).at(drive_path(path), ln, col));
}
if let Some(m) = kotlin_line(rest) {
return Some(m);
@@ -456,6 +461,20 @@ mod tests {
assert_eq!(out[1].unit, ColUnit::Char);
}
#[test]
fn maven_windows_paths_drop_the_slash_before_the_drive() {
let out = parse(
Format::Maven,
"[ERROR] /C:/dev/p/src/main/java/a/A.java:[18,41] package a.b does not exist\n\
[ERROR] C:\\dev\\p\\B.java:[3,8] cannot find symbol\n\
[ERROR] file:///C:/dev/p/K.kt:4:9 Unresolved reference 'x'.\n",
);
assert_eq!(out.len(), 3, "{out:?}");
assert_eq!(pos(&out[0]), (Severity::Error, "C:/dev/p/src/main/java/a/A.java", 18, Some(41)));
assert_eq!(pos(&out[1]), (Severity::Error, "C:\\dev\\p\\B.java", 3, Some(8)));
assert_eq!(pos(&out[2]), (Severity::Error, "C:/dev/p/K.kt", 4, Some(9)));
}
#[test]
fn maven_detail_lines_with_level_prefix_and_kotlin() {
let out = parse(
+50
Переглянути файл
@@ -291,6 +291,56 @@ pub enum DebugEvent {
JvmBackend {
state: JvmBackendState,
},
/// A step in the life of one request of a debugged page.
JsNetwork {
event: JsNetworkEvent,
},
/// The adapter does not report the requests of this page: there will be no `JsNetwork`.
JsNetworkUnavailable,
}
/// Which step of a request a [`JsNetworkEvent`] reports.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum JsNetworkKind {
Request,
Response,
Finished,
Failed,
}
/// The script frame that made a request, where the browser ran it (0-based line and column).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct JsInitiator {
pub function: String,
pub url: String,
pub line: u32,
pub column: u32,
}
/// What a debugged page's browser reports about a request, reduced to what the Network view
/// shows. Events of one request share `request_id`; each kind fills its own fields.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct JsNetworkEvent {
pub kind: JsNetworkKind,
pub request_id: String,
/// The browser's monotonic clock, comparable between the events of a page.
pub timestamp_ms: u64,
/// Request and response.
pub url: Option<String>,
pub method: Option<String>,
/// `Fetch`, `XHR`, `Script`, `Document`, …
pub resource_type: Option<String>,
pub status: Option<u16>,
pub mime_type: Option<String>,
/// The request's headers, or the response's.
pub headers: BTreeMap<String, String>,
/// Bytes received over the network, once finished.
pub encoded_length: Option<u64>,
pub error_text: Option<String>,
pub initiator: Option<JsInitiator>,
}
/// The backend that reads a debugged JVM's objects (instances, fields, referrers).
+15 -2
Переглянути файл
@@ -291,7 +291,7 @@ pub enum NewTaskApprovals {
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct McpServerSettings {
/// Serve the tools on 127.0.0.1 while the IDE runs.
/// Serve the tools on [`Self::address`] while the IDE runs.
pub enabled: bool,
/// Run the tools that change the project or start processes without asking first
/// (IDEA's brave mode). Read from the IDE's own settings only, never a project's.
@@ -300,6 +300,9 @@ pub struct McpServerSettings {
pub agent_tab_limit: u32,
/// Archived agent tabs kept; older ones are closed.
pub agent_archive_limit: u32,
/// The IP address to listen on: `127.0.0.1` (this computer only, the default), another
/// address of this computer, or `0.0.0.0` / `::` for every address.
pub address: String,
/// Listen on [`Self::port`] in every run, rather than a free port each time, so HTTP clients and
/// port forwards keep working after a restart.
pub fixed_port: bool,
@@ -312,6 +315,8 @@ pub struct McpServerSettings {
/// The default of [`McpServerSettings::port`].
pub const DEFAULT_MCP_PORT: u16 = 26371;
/// The default of [`McpServerSettings::address`]: this computer only.
pub const DEFAULT_MCP_ADDRESS: &str = "127.0.0.1";
impl Default for McpServerSettings {
fn default() -> Self {
@@ -320,6 +325,7 @@ impl Default for McpServerSettings {
run_without_confirmation: false,
agent_tab_limit: 1,
agent_archive_limit: 999,
address: DEFAULT_MCP_ADDRESS.to_owned(),
fixed_port: true,
port: DEFAULT_MCP_PORT,
fixed_token: false,
@@ -1153,6 +1159,9 @@ pub struct NodeSettings {
pub run_target: String,
/// `npm` | `pnpm` | `yarn`; empty = detect from the lock file / `packageManager`.
pub package_manager: String,
/// Runtime for running JavaScript / TypeScript files: `node` | `bun` | `deno`; empty = from the
/// project (`deno.json` → Deno, `bun.lock` → Bun, else Node.js).
pub runtime: String,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
@@ -1349,11 +1358,15 @@ pub struct TypeScriptSettings {
/// `bundled` (the server's own TypeScript), `workspace` (`node_modules/typescript`),
/// or a path to a TypeScript `lib` directory.
pub version: String,
/// tsserver heap limit in MB. `0` = automatic: 8192 when vtsls hosts the Vue
/// plugin (hybrid mode; a Vue project's program alone needs ~3 GiB, and the
/// vtsls default 3072 dies in a GC death spiral), else unset (vtsls default).
pub tsserver_max_memory: u32,
}
impl Default for TypeScriptSettings {
fn default() -> Self {
Self { version: "bundled".into() }
Self { version: "bundled".into(), tsserver_max_memory: 0 }
}
}
+6
Переглянути файл
@@ -266,12 +266,16 @@ pub struct DebuggerSettings {
pub inline_values: bool,
/// Data Views: list variables by name instead of the adapter's order.
pub sort_values: bool,
/// Data Views: evaluate watches while the program runs, where the debugger can (JavaScript).
pub live_watches: bool,
pub value_tooltip: bool,
/// While suspended, selecting an expression shows its value.
pub value_tooltip_on_selection: bool,
pub value_tooltip_delay: u32,
/// Stepping: do not step into library code (debugpy `justMyCode`, js-debug `skipFiles`).
pub skip_library_code: bool,
/// JavaScript: debugging a dev server (`vite`, `next dev`) also opens its page in a browser session.
pub debug_server_page: bool,
pub python: PythonDebuggerSettings,
}
@@ -285,10 +289,12 @@ impl Default for DebuggerSettings {
run_to_cursor_inlay: true,
inline_values: true,
sort_values: false,
live_watches: false,
value_tooltip: true,
value_tooltip_on_selection: true,
value_tooltip_delay: 700,
skip_library_code: true,
debug_server_page: false,
python: PythonDebuggerSettings::default(),
}
}
+72
Переглянути файл
@@ -9,6 +9,7 @@
use ide_core::process::executable_names;
use ide_core::{IdeError, IdeResult};
use ide_language::{DebuggerConfig, LanguageRegistry, ProgramPin};
use serde_json::Value;
use std::future::Future;
use std::path::{Path, PathBuf};
use std::pin::Pin;
@@ -212,6 +213,50 @@ pub fn tools_root() -> Option<PathBuf> {
TOOLS_ROOT.read().clone()
}
/// The browser js-debug launches for `ty`, or `None` when the launch starts no browser.
fn launched_browser(ty: &str) -> Option<&'static str> {
match ty {
"pwa-chrome" | "chrome" => Some("chrome"),
"pwa-msedge" | "msedge" => Some("msedge"),
_ => None,
}
}
/// Gives a browser launch that names no `userDataDir` a profile of `project`'s own in the
/// IDE's local data (`cache/browser-profiles`), so sign-ins and site data are still there in
/// the next session. js-debug's own default is one temporary profile for every project.
pub(crate) fn set_browser_profile(arguments: &mut Value, project: &Path) {
if let Some(local_data) = tools_root().and_then(|tools| tools.parent().map(Path::to_owned)) {
set_browser_profile_in(arguments, project, &local_data);
}
}
fn set_browser_profile_in(arguments: &mut Value, project: &Path, local_data: &Path) {
let Some(obj) = arguments.as_object_mut() else { return };
let browser = obj.get("type").and_then(Value::as_str).and_then(launched_browser);
let launch = obj.get("request").and_then(Value::as_str) == Some("launch");
let (Some(browser), true, false) = (browser, launch, obj.contains_key("userDataDir")) else { return };
let dir = local_data.join("cache").join("browser-profiles").join(profile_name(project)).join(browser);
obj.insert("userDataDir".into(), dir.to_string_lossy().into_owned().into());
}
/// `<folder name>-<hash of the path>`: readable, and distinct for projects whose folders share a name.
fn profile_name(project: &Path) -> String {
// FNV-1a: the name has to stay the same across toolchains, which `DefaultHasher` doesn't promise.
let hash = project
.to_string_lossy()
.bytes()
.fold(0xcbf2_9ce4_8422_2325_u64, |h, b| (h ^ u64::from(b)).wrapping_mul(0x0000_0100_0000_01b3));
let name: String = project
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default()
.chars()
.map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' { c } else { '_' })
.collect();
format!("{name}-{hash:016x}")
}
/// Executable directories of managed installs, read at lookup time so tools installed
/// after startup are found without a restart: `<tool>/bin`, `<tool>/venv/bin`, npm's `.bin`.
fn managed_dirs(root: &Path) -> Vec<PathBuf> {
@@ -1040,6 +1085,33 @@ mod tests {
assert!(err.message.contains("runs inside the Java language server"), "{}", err.message);
}
#[test]
fn a_browser_launch_gets_a_profile_of_its_project() {
let local = Path::new("/data/local");
let profile = |args: serde_json::Value, project: &str| {
let mut args = args;
set_browser_profile_in(&mut args, Path::new(project), local);
args.get("userDataDir").cloned()
};
let chrome = serde_json::json!({ "type": "pwa-chrome", "request": "launch" });
let dir = profile(chrome.clone(), "/work/shop").unwrap();
let dir = Path::new(dir.as_str().unwrap());
assert!(dir.starts_with("/data/local/cache/browser-profiles"), "{dir:?}");
assert!(dir.ends_with("chrome"), "{dir:?}");
let name = dir.parent().unwrap().file_name().unwrap().to_string_lossy().into_owned();
assert!(name.starts_with("shop-") && name.len() == "shop-".len() + 16, "{name}");
// The same project again: the same profile. Another folder of that name, or Edge: another one.
assert_eq!(profile(chrome.clone(), "/work/shop").unwrap(), dir.to_string_lossy().as_ref());
assert_ne!(profile(chrome.clone(), "/other/shop").unwrap(), dir.to_string_lossy().as_ref());
let edge = profile(serde_json::json!({ "type": "pwa-msedge", "request": "launch" }), "/work/shop").unwrap();
assert!(Path::new(edge.as_str().unwrap()).ends_with("msedge"), "{edge}");
// A chosen profile (or the default one, `false`) is kept; Node and attach requests get none.
let own = serde_json::json!({ "type": "pwa-chrome", "request": "launch", "userDataDir": false });
assert_eq!(profile(own, "/work/shop"), Some(false.into()));
assert_eq!(profile(serde_json::json!({ "type": "pwa-node", "request": "launch" }), "/work/shop"), None);
assert_eq!(profile(serde_json::json!({ "type": "pwa-chrome", "request": "attach" }), "/work/shop"), None);
}
#[test]
fn port_conflicts_are_recognised_for_retry() {
let in_use = IdeError::unavailable(
+283
Переглянути файл
@@ -0,0 +1,283 @@
//! Requests of vscode-js-debug's own, beyond the Debug Adapter Protocol: what a page in a
//! browser offers a debugger besides stepping (breakpoints on DOM events and requests).
//! Each is a typed method; a js-debug build that lacks one answers with an error, which is
//! reported as the feature being unavailable.
use crate::session::Session;
use crate::{DebugManager, DebugSessionId, REQUEST_TIMEOUT};
use ide_core::event::{JsInitiator, JsNetworkEvent, JsNetworkKind};
use ide_core::{IdeError, IdeResult};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use specta::Type;
use std::collections::BTreeMap;
pub(crate) const ADAPTER: &str = "js-debug";
/// Whether `session` debugs a page: the child js-debug starts for a browser launch or attach.
/// The launch's own session only owns the browser.
pub(crate) fn is_page(session: &Session) -> bool {
session.adapter == ADAPTER
&& session.parent.is_some()
&& matches!(session.launch_type.get().map(String::as_str), Some("pwa-chrome" | "pwa-msedge"))
}
/// Waits until the adapter answered the `launch` of `session`. js-debug sets a page's session
/// up between `configurationDone` and that answer; a request of its own that arrives then is
/// answered and has no effect.
async fn launched(session: &Session) -> bool {
let mut launched = session.launched.subscribe();
let answered = tokio::time::timeout(REQUEST_TIMEOUT, launched.wait_for(|answered| *answered)).await;
matches!(answered, Ok(Ok(_)))
}
impl DebugManager {
/// One of js-debug's requests for pages, sent to the page `s` once its session has started.
async fn js_page_request(&self, s: DebugSessionId, command: &str, arguments: Value) -> IdeResult<Value> {
let session = self.session(s)?;
if !is_page(&session) {
return Err(IdeError::unavailable("this debug session is not a page in a browser"));
}
if !launched(&session).await {
return Err(IdeError::unavailable("the page's debug session did not finish starting"));
}
session
.conn
.request_timeout(command, arguments, REQUEST_TIMEOUT)
.await
.map_err(|e| IdeError::unavailable(format!("the JavaScript debugger refused `{command}`: {e}")))
}
/// Breakpoints on events (`listener:click`, `instrumentation:setTimeout`) and on requests
/// whose URL contains one of `xhr`. The lists replace the page's previous ones.
pub async fn js_set_custom_breakpoints(
&self,
s: DebugSessionId,
ids: Vec<String>,
xhr: Vec<String>,
) -> IdeResult<()> {
self.js_page_request(s, "setCustomBreakpoints", json!({ "ids": ids, "xhr": xhr })).await?;
Ok(())
}
}
/// The browser events js-debug is asked to pass on (`enableNetworking`'s `mirrorEvents`).
pub(crate) const NETWORK_EVENTS: [&str; 4] =
["requestWillBeSent", "responseReceived", "loadingFinished", "loadingFailed"];
/// Response bodies shown are cut at this many bytes.
pub const RESPONSE_BODY_LIMIT: usize = 1_048_576;
/// The body of a response, as text or as the browser's base64 for binary content.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct JsResponseBody {
pub body: String,
pub base64_encoded: bool,
/// The body was longer than [`RESPONSE_BODY_LIMIT`] and is cut there.
pub truncated: bool,
}
fn text(v: &Value, key: &str) -> Option<String> {
v.get(key).and_then(Value::as_str).map(str::to_owned)
}
fn headers(v: &Value) -> BTreeMap<String, String> {
v.get("headers")
.and_then(Value::as_object)
.map(|h| h.iter().filter_map(|(k, v)| Some((k.clone(), v.as_str()?.to_owned()))).collect())
.unwrap_or_default()
}
/// The frame a request is attributed to: the innermost one outside `node_modules`, looked
/// for through the stacks that scheduled this one (an `await`, a timer), since a request sent
/// through a library has the library innermost. A stack of library frames only gives its first.
fn initiator(data: &Value) -> Option<JsInitiator> {
let stack = data.pointer("/initiator/stack")?;
let frames = |stack: &Value| stack.get("callFrames").and_then(Value::as_array).cloned().unwrap_or_default();
let stacks = std::iter::successors(Some(stack), |stack| stack.get("parent")).take(32);
let all: Vec<Value> = stacks.flat_map(frames).collect();
let own = |frame: &&Value| {
frame.get("url").and_then(Value::as_str).is_some_and(|url| !url.is_empty() && !url.contains("/node_modules/"))
};
let frame = all.iter().find(own).or(all.first())?;
let number = |key: &str| frame.get(key).and_then(Value::as_u64).and_then(|n| u32::try_from(n).ok()).unwrap_or(0);
Some(JsInitiator {
function: text(frame, "functionName").unwrap_or_default(),
url: text(frame, "url")?,
line: number("lineNumber"),
column: number("columnNumber"),
})
}
/// js-debug's `networkEvent` (`{event, data}`, `data` being the browser's own parameters of
/// that event) as a [`JsNetworkEvent`]; `None` for events the Network view has no use for.
pub(crate) fn network_event(body: &Value) -> Option<JsNetworkEvent> {
let data = body.get("data")?;
let kind = match body.get("event").and_then(Value::as_str)? {
"requestWillBeSent" => JsNetworkKind::Request,
"responseReceived" => JsNetworkKind::Response,
"loadingFinished" => JsNetworkKind::Finished,
"loadingFailed" => JsNetworkKind::Failed,
_ => return None,
};
let part = match kind {
JsNetworkKind::Request => data.get("request"),
JsNetworkKind::Response => data.get("response"),
_ => None,
};
let part = part.unwrap_or(&Value::Null);
Some(JsNetworkEvent {
kind,
request_id: text(data, "requestId")?,
// Seconds with a fraction.
timestamp_ms: (data.get("timestamp").and_then(Value::as_f64).unwrap_or(0.0).max(0.0) * 1000.0).round() as u64,
url: text(part, "url"),
method: text(part, "method"),
resource_type: text(data, "type"),
status: part.get("status").and_then(Value::as_u64).and_then(|s| u16::try_from(s).ok()),
mime_type: text(part, "mimeType"),
headers: headers(part),
encoded_length: data.get("encodedDataLength").and_then(Value::as_f64).map(|n| n.max(0.0) as u64),
error_text: text(data, "errorText"),
initiator: if kind == JsNetworkKind::Request { initiator(data) } else { None },
})
}
/// `networkCall getResponseBody`'s answer, cut at `limit` bytes on a character boundary.
fn response_body(answer: &Value, limit: usize) -> JsResponseBody {
let mut body = text(answer, "body").unwrap_or_default();
let truncated = body.len() > limit;
if truncated {
let mut end = limit;
while !body.is_char_boundary(end) {
end -= 1;
}
body.truncate(end);
}
JsResponseBody {
body,
base64_encoded: answer.get("base64Encoded").and_then(Value::as_bool).unwrap_or(false),
truncated,
}
}
/// The arguments of `enableNetworking`, which has a page report its requests
/// ([`ide_core::event::DebugEvent::JsNetwork`]). A page session sends it as it starts.
pub(crate) fn networking_arguments() -> Value {
json!({ "mirrorEvents": NETWORK_EVENTS })
}
impl DebugManager {
/// The body of the response to `request_id`, while the browser still has it.
pub async fn js_response_body(&self, s: DebugSessionId, request_id: String) -> IdeResult<JsResponseBody> {
let call = json!({ "method": "getResponseBody", "params": { "requestId": request_id } });
let answer = self.js_page_request(s, "networkCall", call).await?;
Ok(response_body(&answer, RESPONSE_BODY_LIMIT))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn event(kind: &str, data: Value) -> Option<ide_core::event::JsNetworkEvent> {
network_event(&json!({ "event": kind, "data": data }))
}
#[test]
fn a_request_carries_what_was_asked_and_who_asked() {
let e = event(
"requestWillBeSent",
json!({
"requestId": "76695.31", "timestamp": 611807.5, "type": "Fetch",
"request": { "method": "GET", "url": "http://127.0.0.1:5195/src/main.ts", "headers": { "Accept": "*/*", "X-N": 1 } },
"initiator": { "type": "script", "stack": { "callFrames": [
{ "functionName": "load", "url": "http://127.0.0.1:5195/src/App.vue", "lineNumber": 9, "columnNumber": 2 }
] } }
}),
)
.unwrap();
assert_eq!(e.kind, JsNetworkKind::Request);
assert_eq!((e.request_id.as_str(), e.timestamp_ms), ("76695.31", 611_807_500));
assert_eq!(e.url.as_deref(), Some("http://127.0.0.1:5195/src/main.ts"));
assert_eq!((e.method.as_deref(), e.resource_type.as_deref()), (Some("GET"), Some("Fetch")));
// Only text headers are kept.
assert_eq!(e.headers.len(), 1);
assert_eq!(e.headers["Accept"], "*/*");
let from = e.initiator.unwrap();
assert_eq!((from.function.as_str(), from.line, from.column), ("load", 9, 2));
assert_eq!(from.url, "http://127.0.0.1:5195/src/App.vue");
}
#[test]
fn a_request_made_through_a_library_is_asked_by_the_code_that_called_the_library() {
let frame = |function: &str, url: &str| json!({ "functionName": function, "url": url, "lineNumber": 1, "columnNumber": 0 });
let lib = "http://localhost:5173/node_modules/.vite/deps/http-client.js";
let asked_by = |stack: Value| {
let data = json!({ "requestId": "1", "timestamp": 1.0, "request": { "method": "GET", "url": "http://localhost/api" },
"initiator": { "type": "script", "stack": stack } });
event("requestWillBeSent", data).unwrap().initiator.unwrap().function
};
// The innermost frame is the library's; the project's caller is further out.
let direct = json!({ "callFrames": [frame("dispatch", lib), frame("request", lib), frame("loadItems", "http://localhost:5173/src/api.ts")] });
assert_eq!(asked_by(direct), "loadItems");
// Across an `await`, the caller is in the stack that scheduled this one.
let scheduled = json!({ "callFrames": [frame("dispatch", lib)],
"parent": { "callFrames": [frame("then", lib)], "parent": { "callFrames": [frame("onMounted", "http://localhost:5173/src/App.vue")] } } });
assert_eq!(asked_by(scheduled), "onMounted");
// A library on its own: the frame that sent the request.
assert_eq!(asked_by(json!({ "callFrames": [frame("dispatch", lib), frame("poll", lib)] })), "dispatch");
}
#[test]
fn a_response_a_finish_and_a_failure_carry_their_outcome() {
let response = event(
"responseReceived",
json!({ "requestId": "7", "timestamp": 2.0, "type": "Script", "response": {
"url": "http://localhost/a.js", "status": 200, "mimeType": "text/javascript",
"headers": { "Content-Type": "text/javascript" }
} }),
)
.unwrap();
assert_eq!(response.kind, JsNetworkKind::Response);
assert_eq!((response.status, response.mime_type.as_deref()), (Some(200), Some("text/javascript")));
assert_eq!(response.headers["Content-Type"], "text/javascript");
let finished =
event("loadingFinished", json!({ "requestId": "7", "timestamp": 2.5, "encodedDataLength": 1234 })).unwrap();
assert_eq!((finished.kind, finished.encoded_length), (JsNetworkKind::Finished, Some(1234)));
let failed =
event("loadingFailed", json!({ "requestId": "8", "timestamp": 3.0, "errorText": "net::ERR_FAILED" }))
.unwrap();
assert_eq!((failed.kind, failed.error_text.as_deref()), (JsNetworkKind::Failed, Some("net::ERR_FAILED")));
}
#[test]
fn events_that_name_no_request_or_are_not_mirrored_are_dropped() {
assert!(event("requestWillBeSent", json!({ "timestamp": 1.0 })).is_none());
assert!(event("dataReceived", json!({ "requestId": "7", "timestamp": 1.0 })).is_none());
assert!(network_event(&json!({ "data": { "requestId": "7" } })).is_none());
// A request made by the page's parser has no script behind it.
let parser = event(
"requestWillBeSent",
json!({ "requestId": "9", "timestamp": 1.0, "request": { "method": "GET", "url": "http://localhost/" },
"initiator": { "type": "parser" } }),
)
.unwrap();
assert!(parser.initiator.is_none());
}
#[test]
fn a_response_body_is_cut_at_the_limit_on_a_character() {
let whole = response_body(&json!({ "body": "héllo", "base64Encoded": false }), 100);
assert_eq!((whole.body.as_str(), whole.base64_encoded, whole.truncated), ("héllo", false, false));
// `é` is two bytes: a cut inside it backs off to the character before.
let cut = response_body(&json!({ "body": "héllo" }), 2);
assert_eq!((cut.body.as_str(), cut.truncated), ("h", true));
let binary = response_body(&json!({ "body": "AAEC", "base64Encoded": true }), 100);
assert!(binary.base64_encoded && !binary.truncated);
assert_eq!(response_body(&json!({}), 100).body, "");
}
}
+44 -2
Переглянути файл
@@ -13,6 +13,7 @@ pub mod attach;
mod cargo;
pub mod connection;
pub mod jdwp;
mod js_debug;
mod jvm_memory;
pub mod kotlin;
pub mod kotlin_lsp;
@@ -23,6 +24,7 @@ use connection::{Connection, DapError};
use futures::future::join_all;
use ide_core::{DebugSessionId, IdeError, IdeResult, SharedSink};
use ide_language::LanguageRegistry;
pub use js_debug::JsResponseBody;
pub use jvm_memory::*;
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
@@ -136,6 +138,11 @@ pub struct DebugCapabilities {
pub jvm_instances: bool,
/// The session can be asked for a heap dump ([`DebugManager::jvm_heap_dump`]).
pub jvm_heap: bool,
/// The session debugs a page in a browser: js-debug's requests for pages apply
/// ([`DebugManager::js_set_custom_breakpoints`]).
pub js_page: bool,
/// The adapter evaluates expressions while the program runs, without a stack frame.
pub live_evaluate: bool,
}
/// Result of [`DebugManager::redefine_classes`].
@@ -393,6 +400,16 @@ fn no_session(s: DebugSessionId) -> IdeError {
IdeError::not_found(format!("no debug session {}", s.0))
}
/// The name of a child session: the adapter's, or the parent's when it gives none. js-debug
/// names a browser page's session after the page the browser starts on, before the URL
/// loads (`about:blank`, or nothing in a new profile), which says nothing about what is debugged.
fn child_name(configuration: &Value, parent: &str) -> String {
match configuration.get("name").and_then(Value::as_str) {
Some(name) if !name.trim().is_empty() && name != "about:blank" => name.to_owned(),
_ => parent.to_owned(),
}
}
/// Builds the `launch`/`attach` arguments: our fields first, then `extra_json` merged on top.
pub fn launch_arguments(config: &LaunchConfig) -> IdeResult<Value> {
// VS Code puts `type`, `request` and `name` in the arguments; js-debug picks launch
@@ -580,6 +597,8 @@ pub(crate) fn capabilities_from(caps: &Value) -> DebugCapabilities {
jvm_memory: false,
jvm_instances: false,
jvm_heap: false,
js_page: false,
live_evaluate: false,
}
}
@@ -589,6 +608,8 @@ pub(crate) fn session_capabilities(session: &Session) -> DebugCapabilities {
caps.jvm_memory = JVM_MEMORY_ADAPTERS.contains(&session.adapter.as_str());
caps.jvm_instances = session.jvm_inspector.lock().is_some();
caps.jvm_heap = JVM_MEMORY_ADAPTERS.contains(&session.adapter.as_str());
caps.js_page = js_debug::is_page(session);
caps.live_evaluate = session.adapter == js_debug::ADAPTER;
caps
}
@@ -744,6 +765,9 @@ impl DebugManager {
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into()));
let dbg = adapter::find_config(&self.languages, &config.adapter)
.ok_or_else(|| IdeError::unavailable(format!("unknown debug adapter `{}`", config.adapter)))?;
if config.adapter == "js-debug" {
adapter::set_browser_profile(&mut arguments, &cwd);
}
let mut spec = adapter::resolve(&dbg, cwd.clone());
if config.adapter == kotlin::ADAPTER
&& let Some(home) = arguments
@@ -872,8 +896,7 @@ impl DebugManager {
request: String,
configuration: Value,
) -> IdeResult<()> {
let name =
configuration.get("name").and_then(Value::as_str).map(str::to_owned).unwrap_or_else(|| parent.name.clone());
let name = child_name(&configuration, &parent.name);
let mut configuration = configuration;
if let Some(obj) = configuration.as_object_mut() {
obj.entry("request").or_insert_with(|| request.clone().into());
@@ -921,6 +944,9 @@ impl DebugManager {
.flatten();
let session =
Arc::new(Session::new(id, owner, name, adapter_name, parent, request, conn, spec, io.port, io.child));
if let Some(launch_type) = arguments.get("type").and_then(Value::as_str) {
let _ = session.launch_type.set(launch_type.to_owned());
}
// rdbg execs the debuggee in its own process and sends no DAP process event.
if let Some(pid) = rdbg_pid {
session.debuggee_pid.store(i64::from(pid), Ordering::Release);
@@ -1155,6 +1181,22 @@ impl DebugManager {
reply.await.unwrap_or(Err(DapError::Closed))?;
}
}
s.launched.send_replace(true);
// A page reports its requests from here on. This is the earliest js-debug takes the
// request (before its answer to `launch` the page has no adapter and the request is
// never answered), and the page is already loading: its document and first scripts
// are not in the list.
if js_debug::is_page(s) {
let answer = s.conn.send_request("enableNetworking", js_debug::networking_arguments())?;
let session = s.clone();
// Not waited for here: the page runs on. A refusal is told to the Network view,
// which would otherwise wait for requests that never come.
tokio::spawn(async move {
if matches!(answer.await, Ok(Err(_))) {
session.emit(ide_core::event::DebugEvent::JsNetworkUnavailable);
}
});
}
if let Some(link) = s.jdwp_link.lock().as_ref() {
link.release();
}
+13
Переглянути файл
@@ -57,6 +57,8 @@ pub(crate) struct Session {
pub job: Mutex<Option<ide_core::process::Job>>,
/// Set once `configurationDone` was sent; breakpoint updates go out live after that.
pub configured: AtomicBool,
/// Set once the adapter answered `launch` / `attach`, after the configuration.
pub launched: watch::Sender<bool>,
pub initialized: watch::Sender<bool>,
pub startup_cancel: ide_core::CancellationToken,
pub output_drained: watch::Sender<bool>,
@@ -89,6 +91,9 @@ pub(crate) struct Session {
progress: Mutex<HashMap<String, TaskId>>,
/// Relay between a Kotlin adapter and its JVM. Dropping it closes both sockets.
pub jdwp_link: parking_lot::Mutex<Option<ide_jdwp::JdwpLink>>,
/// The `type` of the launch or attach request (js-debug's `pwa-node`, `pwa-chrome`); a child
/// session's comes from the configuration the adapter started it with.
pub launch_type: std::sync::OnceLock<String>,
pub jvm_inspector: parking_lot::Mutex<Option<Arc<dyn ide_jvm_inspect::JvmInspector>>>,
/// One inspector request per session at a time.
pub inspect_lock: tokio::sync::Mutex<()>,
@@ -230,6 +235,7 @@ impl Session {
#[cfg(windows)]
job: Mutex::new(job),
configured: AtomicBool::new(false),
launched: watch::channel(false).0,
initialized: watch::channel(false).0,
startup_cancel: ide_core::CancellationToken::new(),
output_drained: watch::channel(true).0,
@@ -248,6 +254,7 @@ impl Session {
resolved_paths: Mutex::new(HashMap::new()),
progress: Mutex::new(HashMap::new()),
jdwp_link: parking_lot::Mutex::new(None),
launch_type: std::sync::OnceLock::new(),
jvm_inspector: parking_lot::Mutex::new(None),
inspect_lock: tokio::sync::Mutex::default(),
ended: ide_core::CancellationToken::new(),
@@ -832,6 +839,12 @@ async fn on_event(mgr: &Arc<DebugManager>, s: &Arc<Session>, event: &str, mut bo
.map(|a| a.iter().filter_map(Value::as_str).map(str::to_owned).collect())
.unwrap_or_default(),
}),
// js-debug, after `enableNetworking`.
"networkEvent" => {
if let Some(event) = crate::js_debug::network_event(&body) {
s.emit(DebugEvent::JsNetwork { event });
}
}
_ => tracing::trace!("dap: ignored event {event}"),
}
}
+212 -5
Переглянути файл
@@ -146,6 +146,10 @@ struct Script {
delay_initialize: Option<Duration>,
initialized_delay: Duration,
launch_delay: Duration,
/// Answer a child session's `launch` this long after its `configurationDone`, reading
/// requests meanwhile. Like js-debug, one of `accept` that arrives in between is answered
/// and has no effect: the log gets a `dropped` entry for it.
child_launch_delay: Duration,
/// Leave startup pending before `initialized` (false) or after configuration (true).
pending_startup: Option<bool>,
startup_error: Option<&'static str>,
@@ -154,8 +158,15 @@ struct Script {
/// `launch`, and a `configurationDone` sent while it runs is refused with `notStopped`.
gdb15_starts_at_launch: bool,
launch_after_configuration: bool,
/// Requests of the adapter's own (js-debug's `setCustomBreakpoints`…) answered with an empty body.
accept: &'static [&'static str],
/// Requests of the adapter's own answered with a body and followed by events.
custom: Option<fn(&Value) -> Option<CustomReply>>,
}
/// The answer to a request of the adapter's own: the response body, then events (name, body).
type CustomReply = (Value, Vec<(&'static str, Value)>);
impl Default for Script {
fn default() -> Self {
Self {
@@ -181,11 +192,14 @@ impl Default for Script {
delay_initialize: None,
initialized_delay: Duration::ZERO,
launch_delay: Duration::ZERO,
child_launch_delay: Duration::ZERO,
pending_startup: None,
startup_error: None,
close_during_startup: false,
gdb15_starts_at_launch: false,
launch_after_configuration: false,
accept: &[],
custom: None,
}
}
}
@@ -218,10 +232,15 @@ impl AdapterLauncher for FakeLauncher {
}
async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream: S, log: Log, script: Script) {
let (r, mut w) = tokio::io::split(stream);
let (r, w) = tokio::io::split(stream);
// Shared with the task that answers a child's `launch` late (`child_launch_delay`).
let w = Arc::new(tokio::sync::Mutex::new(w));
let mut r = BufReader::new(r);
let mut seq = 1000;
let mut pending_launch: Option<i64> = None;
let mut child = false;
// Between `configurationDone` and the answer to `launch`.
let starting = Arc::new(std::sync::atomic::AtomicBool::new(false));
let mut held: Vec<Value> = Vec::new();
let mut stopped = false;
let mut running = false;
@@ -230,7 +249,7 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
let mut v: Value = $v;
seq += 1;
v["seq"] = seq.into();
framing::write_message(&mut w, &serde_json::to_vec(&v).unwrap()).await.unwrap();
framing::write_message(&mut *w.lock().await, &serde_json::to_vec(&v).unwrap()).await.unwrap();
}};
}
macro_rules! ok {
@@ -292,6 +311,7 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
continue;
}
pending_launch = msg["seq"].as_i64();
child = msg["arguments"]["isChild"] == true;
if let Some(args) = &script.run_in_terminal {
send!(
json!({"type": "request", "command": "runInTerminal", "arguments": {"kind": "integrated", "args": args, "cwd": std::env::temp_dir()}})
@@ -299,7 +319,8 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
}
if script.start_debugging && msg["arguments"]["isChild"] != true {
send!(
json!({"type": "request", "command": "startDebugging", "arguments": {"request": "launch", "configuration": {"name": "child", "isChild": true}}})
// Like js-debug, the child's configuration repeats the launch's `type`.
json!({"type": "request", "command": "startDebugging", "arguments": {"request": "launch", "configuration": {"name": "child", "isChild": true, "type": msg["arguments"]["type"]}}})
);
}
if script.pending_startup != Some(false) {
@@ -378,7 +399,21 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
}
tokio::time::sleep(script.launch_delay).await;
if let Some(l) = pending_launch.take() {
send!(json!({"type": "response", "request_seq": l, "command": "launch", "success": true}));
let answer = json!({"type": "response", "request_seq": l, "command": "launch", "success": true});
if child && !script.child_launch_delay.is_zero() {
starting.store(true, Ordering::Release);
let (w, starting, delay) = (w.clone(), starting.clone(), script.child_launch_delay);
tokio::spawn(async move {
tokio::time::sleep(delay).await;
let mut answer = answer;
answer["seq"] = 999_999.into();
let _ = framing::write_message(&mut *w.lock().await, &serde_json::to_vec(&answer).unwrap())
.await;
starting.store(false, Ordering::Release);
});
} else {
send!(answer);
}
}
if let Some(pid) = script.debuggee_pid {
let event = if script.java_process_id {
@@ -474,9 +509,25 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
}
"disconnect" => {
ok!(msg, Value::Null);
let _ = w.shutdown().await;
let _ = w.lock().await.shutdown().await;
break;
}
other if script.accept.contains(&other) => {
if starting.load(Ordering::Acquire) {
log.lock().push(json!({"type": "dropped", "command": other}));
}
ok!(msg, json!({}))
}
other if script.custom.and_then(|custom| custom(&msg)).is_some() => {
if starting.load(Ordering::Acquire) {
log.lock().push(json!({"type": "dropped", "command": other}));
}
let (body, events) = script.custom.and_then(|custom| custom(&msg)).unwrap();
ok!(msg, body);
for (event, body) in events {
send!(json!({"type": "event", "event": event, "body": body}));
}
}
other => send!(
json!({"type": "response", "request_seq": msg["seq"], "command": other, "success": false, "message": "unsupported"})
),
@@ -905,6 +956,162 @@ async fn blocked_program_input_does_not_hold_up_shutdown() {
assert!(h.manager.send_input(session, "later").is_err());
}
fn js_config(launch_type: &str) -> LaunchConfig {
LaunchConfig {
adapter: "js-debug".into(),
program: None,
args: Vec::new(),
extra_json: Some(format!(r#"{{"type": "{launch_type}"}}"#)),
..config()
}
}
/// The session the adapter started under `parent`.
async fn child_of(h: &Harness, parent: DebugSessionId) -> DebugSessionId {
for _ in 0..200 {
if let Some(child) = h.manager.sessions(Some("main")).into_iter().find(|s| s.parent == Some(parent)) {
return child.id;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
panic!("no child session of {parent:?}");
}
#[tokio::test]
async fn a_browser_page_session_has_the_page_capability() {
let h = harness(Script { start_debugging: true, ..Script::default() });
let browser = h.start(js_config("pwa-chrome")).await.unwrap();
let page = h.manager.capabilities(child_of(&h, browser).await).unwrap();
assert!(page.js_page && page.live_evaluate);
assert!(!h.manager.capabilities(browser).unwrap().js_page, "the launch itself is not a page");
let node = h.start(js_config("pwa-node")).await.unwrap();
let process = h.manager.capabilities(child_of(&h, node).await).unwrap();
assert!(!process.js_page && process.live_evaluate);
let python = h.start(config()).await.unwrap();
let script = h.manager.capabilities(child_of(&h, python).await).unwrap();
assert!(!script.js_page && !script.live_evaluate);
}
#[tokio::test]
async fn custom_breakpoints_are_sent_to_a_page_and_refused_elsewhere() {
let h = harness(Script { start_debugging: true, accept: &["setCustomBreakpoints"], ..Script::default() });
let browser = h.start(js_config("pwa-chrome")).await.unwrap();
let page = child_of(&h, browser).await;
h.manager.js_set_custom_breakpoints(page, vec!["listener:click".into()], vec!["api/".into()]).await.unwrap();
assert_eq!(requests(&h, "setCustomBreakpoints"), [json!({"ids": ["listener:click"], "xhr": ["api/"]})]);
let not_a_page = h.manager.js_set_custom_breakpoints(browser, Vec::new(), Vec::new()).await.unwrap_err();
assert_eq!(not_a_page.kind, ide_core::error::ErrorKind::Unavailable);
// A js-debug build without the request.
let old = harness(Script { start_debugging: true, ..Script::default() });
let browser = old.start(js_config("pwa-chrome")).await.unwrap();
let page = child_of(&old, browser).await;
let refused = old.manager.js_set_custom_breakpoints(page, Vec::new(), Vec::new()).await.unwrap_err();
assert_eq!(refused.kind, ide_core::error::ErrorKind::Unavailable);
}
/// js-debug answers a request of its own that reaches a page after `configurationDone` and
/// before the page's `launch` is answered, and then does nothing with it.
#[tokio::test]
async fn a_page_gets_its_requests_once_its_session_has_started() {
let h = harness(Script {
start_debugging: true,
accept: &["setCustomBreakpoints"],
child_launch_delay: Duration::from_millis(150),
..Script::default()
});
let browser = h.start(js_config("pwa-chrome")).await.unwrap();
let page = child_of(&h, browser).await;
// Past the page's `configurationDone`, with its `launch` still unanswered.
tokio::time::sleep(Duration::from_millis(50)).await;
h.manager.js_set_custom_breakpoints(page, vec!["listener:click".into()], Vec::new()).await.unwrap();
assert_eq!(requests(&h, "setCustomBreakpoints"), [json!({"ids": ["listener:click"], "xhr": []})]);
let dropped = h.log.lock().iter().filter(|m| m["type"] == "dropped").count();
assert_eq!(dropped, 0, "the request reached the page before it took requests");
}
/// js-debug's network requests: `enableNetworking` is followed by one mirrored browser event.
fn network_adapter(msg: &Value) -> Option<CustomReply> {
match msg["command"].as_str()? {
"enableNetworking" => Some((
json!({}),
vec![(
"networkEvent",
json!({ "event": "requestWillBeSent", "data": {
"requestId": "7", "timestamp": 1.5, "type": "Fetch",
"request": { "method": "GET", "url": "http://localhost:5173/api/items", "headers": {} }
} }),
)],
)),
"networkCall" => Some((json!({ "body": "é".repeat(600_000), "base64Encoded": false }), Vec::new())),
_ => None,
}
}
#[tokio::test]
async fn a_page_reports_its_requests_and_their_bodies() {
let h = harness(Script {
start_debugging: true,
custom: Some(network_adapter),
child_launch_delay: Duration::from_millis(100),
..Script::default()
});
let browser = h.start(js_config("pwa-chrome")).await.unwrap();
let page = child_of(&h, browser).await;
// Recording is asked for by the session itself, as soon as js-debug takes the request:
// once it has answered the page's `launch` (before that, the request is never answered).
let DebugEvent::JsNetwork { event } = h.wait_event(|e| matches!(e, DebugEvent::JsNetwork { .. })).await else {
unreachable!()
};
assert_eq!(
requests(&h, "enableNetworking"),
[json!({ "mirrorEvents": ["requestWillBeSent", "responseReceived", "loadingFinished", "loadingFailed"] })]
);
let dropped = h.log.lock().iter().filter(|m| m["type"] == "dropped").count();
assert_eq!(dropped, 0, "recording was asked for before the page took requests");
assert_eq!((event.request_id.as_str(), event.timestamp_ms), ("7", 1500));
assert_eq!(event.url.as_deref(), Some("http://localhost:5173/api/items"));
let body = h.manager.js_response_body(page, "7".into()).await.unwrap();
assert_eq!(requests(&h, "networkCall"), [json!({ "method": "getResponseBody", "params": { "requestId": "7" } })]);
// 1.2 MB of two-byte characters: cut at the limit, on a character.
assert!(body.truncated && !body.base64_encoded);
assert_eq!(body.body.len(), 1_048_576);
assert!(body.body.chars().all(|c| c == 'é'));
assert_eq!(
h.manager.js_response_body(browser, "7".into()).await.unwrap_err().kind,
ide_core::error::ErrorKind::Unavailable
);
// A Node.js program is not a page: nothing is asked of it.
let node = h.start(js_config("pwa-node")).await.unwrap();
child_of(&h, node).await;
h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await;
assert_eq!(requests(&h, "enableNetworking").len(), 1);
}
#[tokio::test]
async fn a_page_whose_requests_the_adapter_does_not_report_says_so() {
// A js-debug build without `enableNetworking`.
let h = harness(Script { start_debugging: true, ..Script::default() });
let browser = h.start(js_config("pwa-chrome")).await.unwrap();
child_of(&h, browser).await;
h.wait_event(|e| matches!(e, DebugEvent::JsNetworkUnavailable)).await;
}
#[test]
fn a_child_session_is_named_by_the_adapter_unless_it_is_a_blank_page() {
assert_eq!(child_name(&json!({ "name": "server.js [4120]" }), "Server"), "server.js [4120]");
assert_eq!(child_name(&json!({ "name": "about:blank" }), "Shop in Chrome"), "Shop in Chrome");
assert_eq!(child_name(&json!({ "name": "" }), "Shop in Chrome"), "Shop in Chrome");
assert_eq!(child_name(&json!({}), "Shop in Chrome"), "Shop in Chrome");
}
#[test]
fn launch_arguments_merge_and_defaults() {
let mut c = config();
+46 -12
Переглянути файл
@@ -324,6 +324,17 @@ fn group_foreign_keys(rows: Vec<Vec<Option<String>>>, tables: &[CompletionTable]
/// Connection target: a data source id (its configured database), or
/// `<id>:<database>` for another database of the same server. Data source ids
/// never contain `:`.
/// Snowflake is HTTPS to a named host: a tunnel's local end cannot pass its certificate check.
fn tunneled(driver: DbDriver) -> bool {
!matches!(driver, DbDriver::Sqlite | DbDriver::Snowflake)
}
/// The database host and port the SSH host forwards to.
fn tunnel_target(source: &DataSource) -> (&str, u16) {
let port = source.port.or(source.driver.default_port()).unwrap_or_default();
(if source.host.trim().is_empty() { "localhost" } else { source.host.trim() }, port)
}
pub fn split_target(target: &str) -> (&str, Option<&str>) {
match target.split_once(':') {
Some((id, db)) if !db.is_empty() => (id, Some(db)),
@@ -655,9 +666,7 @@ impl DbManager {
ssh_secret: Option<String>,
shared: bool,
) -> IdeResult<(DataSource, Option<Arc<Tunnel>>)> {
// Snowflake is HTTPS to a named host: a tunnel's local end cannot pass its certificate check.
let tunneled = !matches!(source.driver, DbDriver::Sqlite | DbDriver::Snowflake);
let Some(ssh) = source.ssh.as_ref().filter(|s| s.enabled && tunneled) else {
let Some(ssh) = source.ssh.as_ref().filter(|s| s.enabled && tunneled(source.driver)) else {
return Ok((source.clone(), None));
};
let local = |t: &Tunnel| DataSource { host: "127.0.0.1".into(), port: Some(t.port()), ..source.clone() };
@@ -668,8 +677,25 @@ impl DbManager {
return Ok((local(&t), Some(t)));
}
}
let session = self.ssh_session(source, ssh, ssh_secret).await?;
let (target_host, target_port) = tunnel_target(source);
let t = Arc::new(Tunnel::open(session, target_host, target_port).await?);
if shared {
self.tunnels.lock().insert(source.id.clone(), t.clone());
}
Ok((local(&t), Some(t)))
}
/// The SSH session the tunnel of `source` goes through: its shared SSH configuration's, or one
/// opened with the settings typed in the data source.
async fn ssh_session(
&self,
source: &DataSource,
ssh: &DbSsh,
ssh_secret: Option<String>,
) -> IdeResult<Arc<ide_ssh::Session>> {
let config = ssh.config.trim();
let session = if config.is_empty() {
Ok(if config.is_empty() {
let secret = match ssh_secret {
Some(s) => Some(s),
None => self.secret(source, secrets::ssh_key(&source.id), ssh.save_secret).await,
@@ -688,14 +714,22 @@ impl DbManager {
self.ssh.set_secret(config, Some(s), save);
}
self.ssh.session(config).await?
};
let target_port = source.port.or(source.driver.default_port()).unwrap_or_default();
let target_host = if source.host.trim().is_empty() { "localhost" } else { source.host.trim() };
let t = Arc::new(Tunnel::open(session, target_host, target_port).await?);
if shared {
self.tunnels.lock().insert(source.id.clone(), t.clone());
}
Ok((local(&t), Some(t)))
})
}
/// Data Source › SSH/SSL › Test SSH Tunnel: logs in to the SSH host and checks that it reaches
/// the database host and port, without logging in to the database.
pub async fn test_tunnel(&self, source: &DataSource, ssh_secret: Option<String>) -> IdeResult<String> {
let source = &source.resolved()?;
let ssh = source
.ssh
.as_ref()
.filter(|s| s.enabled && tunneled(source.driver))
.ok_or_else(|| IdeError::invalid("This data source does not use an SSH tunnel"))?;
let session = self.ssh_session(source, ssh, ssh_secret).await?;
let (host, port) = tunnel_target(source);
drop(Tunnel::open(session.clone(), host, port).await?);
Ok(format!("{} reaches {host}:{port}", session.label()))
}
/// Connects with `source` as given (not necessarily saved): Test Connection.
+35
Переглянути файл
@@ -117,6 +117,14 @@ pub(crate) fn oracle_err(e: &oracledb::Error) -> IdeError {
// config first. Treat it as a logon failure, so the UI prompts for one.
let kind = match e.kind() {
oracledb::ErrorKind::NoCredentials => ErrorKind::PermissionDenied,
// The thin driver speaks the protocol of Oracle Database 12c and later only.
oracledb::ErrorKind::ServerVersionNotSupported => {
return IdeError::new(
ErrorKind::Unavailable,
"This Oracle Database is older than 12c. OpenIDEA connects to Oracle Database 12c and later; \
11g and earlier are not supported.",
);
}
_ => ErrorKind::InvalidInput,
};
return IdeError::new(kind, e.to_string());
@@ -571,6 +579,33 @@ mod tests {
assert_eq!(connect_string_with(&s("PROD=MYDB"), false).unwrap(), "PROD=MYDB");
}
#[test]
fn a_server_older_than_12c_is_named() {
use std::io::{Read, Write};
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let server = std::thread::spawn(move || {
let (mut socket, _) = listener.accept().unwrap();
let mut connect = [0u8; 1024];
let _ = socket.read(&mut connect);
// A TNS ACCEPT packet with protocol version 314, as Oracle Database 11g answers.
let mut accept = vec![0u8; 32];
accept[1] = 32;
accept[4] = 2;
accept[8..10].copy_from_slice(&314u16.to_be_bytes());
let _ = socket.write_all(&accept);
});
let config = oracledb::Config::default()
.set_credentials("scott", "tiger")
.set_connect_string(&format!("//127.0.0.1:{port}/ORCL"))
.unwrap()
.set_transport_connect_timeout(Some(std::time::Duration::from_secs(5)));
let err = oracle_err(&oracledb::connect(config).err().expect("an 11g server is refused"));
assert_eq!(err.kind, ErrorKind::Unavailable, "{err:?}");
assert!(err.message.contains("older than 12c"), "{}", err.message);
server.join().unwrap();
}
#[test]
fn a_tns_alias_resolves_when_tnsnames_exists() {
let alias = DataSource {
+96
Переглянути файл
@@ -0,0 +1,96 @@
//! Data Source › SSH/SSL › Test SSH Tunnel against an in-process SSH server: the SSH login and
//! the SSH host reaching the database host and port, with no database login.
use ide_core::error::ErrorKind;
use ide_db::{DataSource, DbDriver, DbManager, DbSsh, DbSshAuth};
use ide_ssh::{SshFiles, SshManager};
use std::sync::Arc;
use tokio::net::TcpListener;
struct Env {
_tmp: tempfile::TempDir,
db: DbManager,
ssh_port: u16,
}
async fn env() -> Env {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().to_path_buf();
let ssh_port = ide_ssh::testing::sshd(root.join("server"), Arc::new(|cmd| panic!("unexpected exec: {cmd}"))).await;
let ssh = Arc::new(SshManager::with_files(
root.join("ssh-secrets.json"),
SshFiles { known_hosts: root.join("known_hosts"), config: None },
));
let db = DbManager::new(root.clone(), root.join("passwords.json"), root.join("history.json"), ssh);
Env { _tmp: tmp, db, ssh_port }
}
impl Env {
/// A PostgreSQL source on `127.0.0.1:<db_port>` through the test server as `dev`.
fn source(&self, db_port: u16) -> DataSource {
DataSource {
id: "pg".into(),
name: "pg".into(),
driver: DbDriver::Postgres,
host: "127.0.0.1".into(),
port: Some(db_port),
user: "app".into(),
ssh: Some(DbSsh {
enabled: true,
host: "127.0.0.1".into(),
port: Some(self.ssh_port),
user: "dev".into(),
auth: DbSshAuth::Password,
..Default::default()
}),
..Default::default()
}
}
}
/// A port something listens on (and never speaks a database protocol).
async fn listening() -> (TcpListener, u16) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
(listener, port)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn the_tunnel_is_tested_without_a_database_login() {
let env = env().await;
let (_db, db_port) = listening().await;
let source = env.source(db_port);
// First contact: the host key has to be trusted, as with Test Connection.
let unknown = env.db.test_tunnel(&source, Some("secret".into())).await.unwrap_err();
assert!(unknown.message.starts_with("Unknown SSH host key"), "{unknown:?}");
env.db.trust_ssh_host("pg").await.unwrap();
let ok = env.db.test_tunnel(&source, Some("secret".into())).await.unwrap();
assert_eq!(ok, format!("[email protected]:{} reaches 127.0.0.1:{db_port}", env.ssh_port));
let wrong = env.db.test_tunnel(&source, Some("nope".into())).await.unwrap_err();
assert!(wrong.message.contains("SSH authentication failed"), "{}", wrong.message);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_database_port_the_ssh_host_cannot_reach_is_named() {
let env = env().await;
let (listener, closed) = listening().await;
drop(listener);
let source = env.source(closed);
let _ = env.db.test_tunnel(&source, Some("secret".into())).await;
env.db.trust_ssh_host("pg").await.unwrap();
let err = env.db.test_tunnel(&source, Some("secret".into())).await.unwrap_err();
assert!(err.message.contains(&format!("cannot reach 127.0.0.1:{closed}")), "{}", err.message);
}
#[tokio::test]
async fn a_source_without_a_tunnel_has_nothing_to_test() {
let env = env().await;
let mut source = env.source(5432);
source.ssh.as_mut().unwrap().enabled = false;
let err = env.db.test_tunnel(&source, None).await.unwrap_err();
assert_eq!(err.kind, ErrorKind::InvalidInput, "{err:?}");
}
+15
Переглянути файл
@@ -42,6 +42,10 @@ pub(crate) fn register(d: &mut Dispatcher) {
);
test(&c.host, &c.window, source, password, ssh_secret).await
});
d.add("db_test_tunnel", |c| async move {
let (source, ssh_secret) = (c.arg::<DataSource>("source")?, c.arg::<Option<String>>("sshSecret")?);
test_tunnel(&c.host, &c.window, source, ssh_secret).await
});
d.add("db_set_ssh_secret", |c| async move {
let (id, secret, save) = (c.arg::<String>("id")?, c.arg::<Option<String>>("secret")?, c.arg::<bool>("save")?);
set_ssh_secret(&c.host, &c.window, &id, secret, save).await
@@ -212,6 +216,17 @@ pub async fn test(
db.test(&source, password, ssh_secret).await
}
/// Checks the SSH tunnel of `source` alone: SSH login and the SSH host reaching the database.
pub async fn test_tunnel(
host: &Host,
window: &str,
source: DataSource,
ssh_secret: Option<String>,
) -> IdeResult<String> {
let db = host.project(window)?.db.clone();
db.test_tunnel(&source, ssh_secret).await
}
/// Remembers the SSH password / key passphrase of a data source (see `set_password`).
pub async fn set_ssh_secret(host: &Host, window: &str, id: &str, secret: Option<String>, save: bool) -> IdeResult<()> {
let db = host.project(window)?.db.clone();
+25
Переглянути файл
@@ -372,6 +372,25 @@ pub async fn debug_restart_frame(host: &Host, _window: &str, session: DebugSessi
host.debugger.restart_frame(session, frame_id).await
}
pub async fn debug_js_custom_breakpoints(
host: &Host,
_window: &str,
session: DebugSessionId,
ids: Vec<String>,
xhr: Vec<String>,
) -> IdeResult<()> {
host.debugger.js_set_custom_breakpoints(session, ids, xhr).await
}
pub async fn debug_js_response_body(
host: &Host,
_window: &str,
session: DebugSessionId,
request_id: String,
) -> IdeResult<ide_dap::JsResponseBody> {
host.debugger.js_response_body(session, request_id).await
}
pub fn debug_capabilities(host: &Host, _window: &str, session: DebugSessionId) -> IdeResult<DebugCapabilities> {
host.debugger.capabilities(session)
}
@@ -588,6 +607,12 @@ pub(crate) fn register(d: &mut Dispatcher) {
debug_restart_frame(&c.host, &c.window, c.arg("session")?, c.arg("frameId")?).await
});
d.add("debug_capabilities", |c| async move { debug_capabilities(&c.host, &c.window, c.arg("session")?) });
d.add("debug_js_response_body", |c| async move {
debug_js_response_body(&c.host, &c.window, c.arg("session")?, c.arg("requestId")?).await
});
d.add("debug_js_custom_breakpoints", |c| async move {
debug_js_custom_breakpoints(&c.host, &c.window, c.arg("session")?, c.arg("ids")?, c.arg("xhr")?).await
});
d.add("debug_set_function_breakpoints", |c| async move {
debug_set_function_breakpoints(&c.host, &c.window, c.arg("breakpoints")?).await
});
+8 -2
Переглянути файл
@@ -74,9 +74,15 @@ pub async fn lsp_completion(
host.end_request(request);
// Plugin providers (`[[contributes.providers]]`) add theirs.
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::Completion) else { return result };
let items = plugin_call(move || plugins.provide_completion(&d, pos, trigger.as_deref())).await.unwrap_or_default();
let (covered, items) = plugin_call(move || plugins.provide_completion_for_editor(&d, pos, trigger.as_deref()))
.await
.unwrap_or_default();
if items.is_empty() {
return Ok(result.unwrap_or(CompletionList { is_incomplete: true, items: Vec::new() }));
return if covered {
Ok(result.unwrap_or(CompletionList { is_incomplete: true, items: Vec::new() }))
} else {
result
};
}
let mut list = result.unwrap_or(CompletionList { is_incomplete: false, items: Vec::new() });
list.items.extend(items.into_iter().map(|(_, item)| plugin_completion(item)));
+2
Переглянути файл
@@ -31,6 +31,7 @@ pub mod scaffold;
pub mod sdk;
pub mod search;
pub mod spell;
mod ssh;
pub mod syntax;
pub mod term;
pub mod tools;
@@ -95,6 +96,7 @@ pub fn register_all(d: &mut crate::rpc::Dispatcher) {
deploy::register(d);
docker::register(d);
db::register(d);
ssh::register(d);
}
impl Host {
+29
Переглянути файл
@@ -0,0 +1,29 @@
//! SSH configuration commands shared by the desktop shell's IPC commands and the remote
//! server ([`crate::rpc`]). A remote window connects from the server (deployment, database
//! tunnels, run targets, SSH sessions), so the host keys it trusts and the passwords it is
//! given belong to the server's [`ide_ssh::SshManager`], not the desktop's.
use crate::rpc::Dispatcher;
use ide_core::remote_settings::SshConfiguration;
pub(crate) fn register(d: &mut Dispatcher) {
d.add("ssh_test", |c| async move {
let (config, secret) = (c.arg::<SshConfiguration>("config")?, c.arg::<Option<String>>("secret")?);
c.host.ssh.test(&config, secret).await
});
d.add("ssh_set_secret", |c| async move {
let (id, secret, save) = (c.arg::<String>("id")?, c.arg::<Option<String>>("secret")?, c.arg::<bool>("save")?);
c.host.ssh.set_secret(&id, secret, save);
Ok(())
});
d.add("ssh_password_saved", |c| async move { Ok(c.host.ssh.password_saved(&c.arg::<String>("key")?)) });
d.add("ssh_needs_secret", |c| async move {
let ssh = &c.host.ssh;
Ok(ssh.needs_secret(&ssh.config(&c.arg::<String>("id")?)?))
});
d.add("ssh_trust_host", |c| async move { c.host.ssh.trust_host(&c.arg::<String>("id")?).await });
d.add("ssh_connected", |c| async move { Ok(c.host.ssh.connected()) });
d.add("ssh_disconnect", |c| async move {
c.host.ssh.disconnect(&c.arg::<String>("id")?);
Ok(())
});
}
+21
Переглянути файл
@@ -22,6 +22,27 @@ pub async fn status(host: &Host, window: &str) -> IdeResult<Vec<FileStatus>> {
.await
}
/// Changes of `paths` only: the dirty-scope refresh after a watcher batch. `paths` that belong
/// to no repository are ignored, and an empty list is the full status.
pub async fn status_paths(host: &Host, window: &str, paths: Vec<std::path::PathBuf>) -> IdeResult<Vec<FileStatus>> {
let project = host.project(window)?;
host.blocking(move || {
let settings = project.settings.effective();
if project.is_trusted() {
apply_git_executable(&settings);
}
let repos = project.repos();
if repos.is_empty() {
return Err(ide_core::IdeError::unavailable("project is not a git repository"));
}
for r in repos.all() {
r.set_ignored_names(settings.ignored_names());
}
repos.status_paths(&paths)
})
.await
}
mod commands;
pub use commands::*;
+28 -2
Переглянути файл
@@ -585,6 +585,21 @@ pub async fn vcs_stage(host: &Host, window: &str, paths: Vec<PathBuf>) -> IdeRes
blocking(move || per_backend(&project, &paths, None, |r, paths| r.stage(paths))).await
}
/// Adds `commit` to annotate's ignore list in `path`'s repository (the file `blame.ignoreRevsFile`
/// names, else `.git-blame-ignore-revs`, which the local config then names).
pub async fn vcs_ignore_blame_revision(host: &Host, window: &str, path: PathBuf, commit: String) -> IdeResult<()> {
host.require_trust(window)?;
let project = host.project(window)?;
blocking(move || {
let result = project.with_disk_sync(|| project.with_repo_of(&path, |r| r.ignore_blame_revision(&commit)));
if result.is_ok() {
project.sink.emit(ide_core::IdeEvent::VcsChanged { root: project.root.clone().into() });
}
result
})
.await
}
/// Project-host operation (also registered with the remote dispatcher).
/// Returns the ignore files that were updated, so the UI can show them.
pub async fn vcs_add_to_ignore(
@@ -792,12 +807,19 @@ pub async fn vcs_stash_push(
message: Option<String>,
include_untracked: bool,
keep_index: bool,
paths: Option<Vec<PathBuf>>,
) -> IdeResult<()> {
host.require_trust(window)?;
let project = host.project(window)?;
let paths = paths.unwrap_or_default();
blocking(move || {
project.with_repo_recorded(repo.as_deref(), Some("Git stash"), |r| {
r.stash_push(message.as_deref(), include_untracked, keep_index)
if paths.is_empty() {
return project.with_repo_recorded(repo.as_deref(), Some("Git stash"), |r| {
r.stash_push(message.as_deref(), include_untracked, keep_index, &[])
});
}
per_repo(&project, &paths, Some("Git stash"), |r, paths| {
r.stash_push(message.as_deref(), include_untracked, keep_index, paths)
})
})
.await
@@ -1462,6 +1484,9 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("vcs_add_to_ignore", |c| async move {
vcs_add_to_ignore(&c.host, &c.window, c.arg("paths")?, c.arg("destination")?).await
});
d.add("vcs_ignore_blame_revision", |c| async move {
vcs_ignore_blame_revision(&c.host, &c.window, c.arg("path")?, c.arg("commit")?).await
});
d.add("vcs_unstage", |c| async move { vcs_unstage(&c.host, &c.window, c.arg("paths")?).await });
d.add("vcs_stage_patch", |c| async move { vcs_stage_patch(&c.host, &c.window, c.arg("patch")?).await });
d.add("vcs_discard", |c| async move { vcs_discard(&c.host, &c.window, c.arg("paths")?).await });
@@ -1520,6 +1545,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
c.arg("message")?,
c.arg("includeUntracked")?,
c.arg("keepIndex")?,
c.arg("paths")?,
)
.await
});
+96
Переглянути файл
@@ -2,10 +2,106 @@
use crate::Host;
use ide_core::IdeResult;
use ide_core::proxy_settings::{ProxyMode, ProxySettings};
use std::path::Path;
use std::time::Duration;
/// Whether `url` names this machine (`localhost` or a loopback address).
fn is_loopback(url: &reqwest::Url) -> bool {
let host = url.host_str().unwrap_or_default().trim_start_matches('[').trim_end_matches(']');
host.eq_ignore_ascii_case("localhost") || host.parse::<std::net::IpAddr>().is_ok_and(|ip| ip.is_loopback())
}
fn is_html(content_type: &str) -> bool {
let kind = content_type.split(';').next().unwrap_or_default().trim();
kind.eq_ignore_ascii_case("text/html") || kind.eq_ignore_ascii_case("application/xhtml+xml")
}
/// A client that reaches this machine only: no proxy, and a redirect is followed while it
/// stays here (a dev server sending `/` to `/app/`).
fn loopback_client() -> IdeResult<reqwest::Client> {
let direct = ProxySettings { mode: ProxyMode::None, ..ProxySettings::default() };
let stay = reqwest::redirect::Policy::custom(|attempt| {
if is_loopback(attempt.url()) && attempt.previous().len() < 5 { attempt.follow() } else { attempt.stop() }
});
ide_http::client::build_with_redirect(&direct, stay)
}
/// Whether `url`, a server on this machine, answers a browser with an HTML document: a page
/// to open, as a dev server has, and not an API endpoint. Other hosts are never asked.
pub async fn serves_page(url: &str) -> bool {
let Ok(url) = reqwest::Url::parse(url) else { return false };
if !matches!(url.scheme(), "http" | "https") || !is_loopback(&url) {
return false;
}
let Ok(client) = loopback_client() else { return false };
// A dev server may compile the page on this first request.
let request = client.get(url).header(reqwest::header::ACCEPT, "text/html").timeout(Duration::from_secs(10));
let Ok(response) = request.send().await else { return false };
let html = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.is_some_and(is_html);
response.status().is_success() && html
}
/// The URL serving `file`, under the window's project when it holds the file.
pub async fn url(host: &Host, window: &str, file: &Path) -> IdeResult<String> {
let root = host.project(window).ok().map(|p| p.root.clone());
host.web.url_for(root.as_deref(), file).await
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
/// A server on a free port that answers every request with `status` and `header`. It
/// listens on every interface, so that a test can reach it under a name that is not loopback.
async fn answering(status: &'static str, header: String) -> String {
let listener = tokio::net::TcpListener::bind("0.0.0.0:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
tokio::spawn(async move {
while let Ok((mut socket, _)) = listener.accept().await {
let mut request = [0u8; 2048];
let _ = socket.read(&mut request).await;
let response =
format!("HTTP/1.1 {status}\r\n{header}\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok");
let _ = socket.write_all(response.as_bytes()).await;
}
});
format!("http://127.0.0.1:{port}/")
}
async fn server(status: &'static str, content_type: &'static str) -> String {
answering(status, format!("Content-Type: {content_type}")).await
}
async fn redirecting_to(location: &str) -> String {
answering("302 Found", format!("Location: {location}")).await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_redirect_is_followed_on_this_machine_only() {
let page = server("200 OK", "text/html").await;
assert!(serves_page(&redirecting_to(&page).await).await);
// The same page under an address that is not loopback: it is not asked.
let elsewhere = page.replace("127.0.0.1", "0.0.0.0");
assert!(!serves_page(&redirecting_to(&elsewhere).await).await);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_page_is_an_html_answer_from_this_machine() {
assert!(serves_page(&server("200 OK", "text/html; charset=utf-8").await).await);
assert!(serves_page(&server("200 OK", "Text/HTML").await.replace("127.0.0.1", "localhost")).await);
// An API, an error page and a port nothing listens on are not pages.
assert!(!serves_page(&server("200 OK", "application/json").await).await);
assert!(!serves_page(&server("404 Not Found", "text/html").await).await);
assert!(!serves_page("http://127.0.0.1:1/").await);
// Other machines are not asked at all.
assert!(!serves_page("http://example.com/").await);
assert!(!serves_page("ftp://localhost/").await);
assert!(!serves_page("not a url").await);
}
}
+77 -13
Переглянути файл
@@ -1642,6 +1642,9 @@ impl HostApi for PluginApi {
) -> Result<(), HostError> {
self.set_decorations(path, set, Vec::new(), item, true)
}
fn file_icon_decorations(&self, set: &str, items: Vec<(PathBuf, String)>) -> Result<(), HostError> {
self.set_file_icon_decorations(set, items)
}
fn vcs_log(
&self,
root: &Path,
@@ -4124,6 +4127,9 @@ impl PluginApi {
{
return Err(HostError::Invalid("a file note is one non-empty line of at most 200 characters".into()));
}
if let Some(icon) = file.as_ref().and_then(|f| f.icon.as_ref()) {
ide_extension::validate_item_id(icon, "file icon id").map_err(HostError::Invalid)?;
}
items.retain(|d| decoration_style(&d.style));
if items.iter().any(|d| d.range.start > d.range.end) {
return Err(HostError::Invalid("reversed decoration range".into()));
@@ -4132,24 +4138,14 @@ impl PluginApi {
// The decoration follows the file, not the window's project: editors render
// whatever they have open, including files outside every project root. The
// set's root stays the path's own (deepest open) project for tree filtering.
let root = self
.shared
.workspace()
.map(|w| {
w.projects()
.into_iter()
.filter(|root| path.starts_with(root))
.max_by_key(|root| root.components().count())
})
.unwrap_or_default()
.unwrap_or_else(|| path.parent().map(Path::to_path_buf).unwrap_or_else(|| path.to_path_buf()));
let root = self.decoration_root(path);
Some(super::DecorationSet {
root: root.into(),
path: ide_core::IpcPath(path.to_path_buf()),
set_id: set.into(),
tree,
items,
file: file.filter(|f| f.note.is_some() || decoration_style(&f.style)),
file: file.filter(|f| f.note.is_some() || f.icon.is_some() || decoration_style(&f.style)),
})
} else {
None
@@ -4177,6 +4173,60 @@ impl PluginApi {
self.shared.sink.emit(None, PluginEvent::DecorationsChanged { plugin_id: self.id().into(), sets });
Ok(())
}
fn decoration_root(&self, path: &Path) -> PathBuf {
self.shared
.workspace()
.map(|w| {
w.projects()
.into_iter()
.filter(|root| path.starts_with(root))
.max_by_key(|root| root.components().count())
})
.unwrap_or_default()
.unwrap_or_else(|| path.parent().map(Path::to_path_buf).unwrap_or_else(|| path.to_path_buf()))
}
fn set_file_icon_decorations(&self, set: &str, items: Vec<(PathBuf, String)>) -> Result<(), HostError> {
if set.is_empty() || set.len() > 128 {
return Err(HostError::Invalid("set id must be 1-128 bytes".into()));
}
if items.len() > 10_000 {
return Err(HostError::Invalid("at most 10000 file icons per set".into()));
}
let mut by_path = BTreeMap::new();
for (path, icon) in items {
ide_extension::validate_item_id(&icon, "file icon id").map_err(HostError::Invalid)?;
by_path.insert(path, icon);
}
let mut all = self.shared.decorations.lock();
let mut sets = all.get(self.id()).cloned().unwrap_or_default();
sets.retain(|s| !(s.tree && s.set_id == set));
for (path, icon) in by_path {
let root = self.decoration_root(&path);
sets.push(super::DecorationSet {
root: root.into(),
path: ide_core::IpcPath(path),
set_id: set.into(),
tree: true,
items: Vec::new(),
file: Some(ide_plugin::FileDecoration {
badge: None,
style: String::new(),
tooltip: None,
note: None,
icon: Some(icon),
}),
});
}
let names: BTreeSet<_> = sets.iter().filter(|s| s.tree).map(|s| &s.set_id).collect();
if names.len() > 50 || serde_json::to_vec(&sets).map_err(failed)?.len() > 4 << 20 {
return Err(HostError::Invalid("decoration sets exceed plugin limits".into()));
}
all.insert(self.id().into(), sets.clone());
self.shared.sink.emit(None, PluginEvent::DecorationsChanged { plugin_id: self.id().into(), sets });
Ok(())
}
}
fn decoration_style(style: &str) -> bool {
matches!(
@@ -4330,7 +4380,8 @@ mod workbench_tests {
badge: Some("abc".into()),
style: "info".into(),
tooltip: None,
note: None
note: None,
icon: None,
})
)
.is_err()
@@ -4340,6 +4391,7 @@ mod workbench_tests {
style: String::new(),
tooltip: Some("why".into()),
note: Some(text.into()),
icon: None,
};
api.file_decorations(Some(&path), "notes", Some(note("entry point"))).unwrap();
let notes = |api: &PluginApi| {
@@ -4353,6 +4405,18 @@ mod workbench_tests {
for bad in ["", "two\nlines", &"x".repeat(201)] {
assert!(api.file_decorations(Some(&path), "notes", Some(note(bad))).is_err(), "{bad:?}");
}
api.file_icon_decorations(
"icons",
vec![(path.clone(), "helm-chart".into()), (root.join("chart"), "helm-dir".into())],
)
.unwrap();
let icons = api.shared.decoration_sets()["test"]
.iter()
.filter(|s| s.tree && s.set_id == "icons")
.filter_map(|s| s.file.as_ref().and_then(|f| f.icon.clone()))
.collect::<Vec<_>>();
assert_eq!(icons.len(), 2);
assert!(api.file_icon_decorations("icons", vec![(path.clone(), "BAD ID".into())]).is_err());
api.shared.clear_plugin("test", true);
assert!(api.shared.decoration_sets().is_empty());
}
+25 -2
Переглянути файл
@@ -396,15 +396,37 @@ impl PluginHost {
}
}
pub(super) fn has_document_providers(&self, kind: ProviderKind, doc: &ProviderDocument) -> bool {
self.provider_plugins(kind, &doc.language)
.into_iter()
.any(|p| p.dev_root.as_ref().is_none_or(|r| doc.path.starts_with(r)))
}
pub fn provide_completion(
&self,
doc: &ProviderDocument,
at: ide_core::Pos,
trigger: Option<&str>,
) -> Vec<(String, CompletionItem)> {
self.feature_documents(doc, Some(at))
self.provide_completion_for_editor(doc, at, trigger).1
}
pub(crate) fn provide_completion_for_editor(
&self,
doc: &ProviderDocument,
at: ide_core::Pos,
trigger: Option<&str>,
) -> (bool, Vec<(String, CompletionItem)>) {
// An injection-capable outer document (e.g. SQL embedded in text) may complete at other
// positions even when `at` is outside an injection, so avoid marking document-wide backoff.
let mut covered = self.has_document_providers(ProviderKind::Injections, doc);
let items = self
.feature_documents(doc, Some(at))
.into_iter()
.flat_map(|(doc, bounds)| {
if self.has_document_providers(ProviderKind::Completion, &doc) {
covered = true;
}
let (d, t) = (Arc::new(doc.clone()), trigger.map(str::to_owned));
self.document_calls(ProviderKind::Completion, &doc, move |rt, timeout| {
rt.provide_completion(&d, at, t.as_deref(), timeout)
@@ -419,7 +441,8 @@ impl PluginHost {
Some((p, i))
})
})
.collect()
.collect();
(covered, items)
}
pub fn provide_code_actions(
+20 -5
Переглянути файл
@@ -743,6 +743,7 @@ pub(super) fn custom_editor(pkg: &Package, decl: toml::Value) -> Result<UiDecl,
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct FileIconDecl {
id: String,
#[serde(default)]
patterns: Vec<String>,
icon: PathBuf,
#[serde(default)]
@@ -754,13 +755,19 @@ pub fn file_icon_asset(id: &str, dark: bool) -> String {
format!("fileIcon/{id}/{}", if dark { "dark" } else { "light" })
}
/// `[[contributes.fileIcons]]`: an icon for the files matching `patterns`,
/// whatever icon theme is in use (the user's own file type icons still win).
/// `[[contributes.fileIcons]]`: an icon for the files matching `patterns` (or
/// assigned dynamically through `decorations.set-file-icon` when `patterns` is
/// omitted in API 1.8), whatever icon theme is in use (the user's own file type
/// icons still win).
pub(super) fn file_icon(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
let d: FileIconDecl = decode(FILE_ICONS, decl)?;
validate_item_id(&d.id, "file icon id")?;
let what = format!("file icon {:?}", d.id);
file_patterns(&d.patterns, &what)?;
if d.patterns.is_empty() {
pkg.manifest.needs_api("contributes.fileIcons without patterns", 8)?;
} else {
file_patterns(&d.patterns, &what)?;
}
let mut files = Vec::new();
let mut assets = Vec::new();
for (path, dark) in [(Some(&d.icon), false), (d.icon_dark.as_ref(), true)] {
@@ -769,8 +776,16 @@ pub(super) fn file_icon(pkg: &Package, decl: toml::Value) -> Result<UiDecl, Stri
assets.push((file_icon_asset(&d.id, dark), svg));
files.push(path.clone());
}
let json = serde_json::json!({ "id": d.id, "patterns": d.patterns, "dark": d.icon_dark.is_some() });
let mut out = UiDecl::new(d.id.clone(), d.patterns.join(", "), json);
let bytes = serde_json::to_vec(&assets).map_err(|e| e.to_string())?;
let revision = blake3::hash(&bytes).to_hex().to_string();
let json = serde_json::json!({
"id": d.id,
"patterns": d.patterns,
"dark": d.icon_dark.is_some(),
"revision": revision,
});
let label = if d.patterns.is_empty() { d.id.clone() } else { d.patterns.join(", ") };
let mut out = UiDecl::new(d.id.clone(), label, json);
out.files = files;
out.assets = assets;
Ok(out)
+20 -9
Переглянути файл
@@ -447,6 +447,11 @@ impl Host {
.collect();
matches!(rest.as_slice(), [first, ..] if first == "worktrees" && rest.len() <= 2)
});
// A repository folder moved or restored into the project: no `.git` event is
// written inside it before the watcher sees the new directory, its creation is
// the signal. A `git clone` / `git init` inside the project already arrives
// through the `.git` paths above; this covers the whole tree being brought in.
git_dirs |= batch.created.iter().any(|p| p.join(".git").exists());
if git_dirs {
scan_repositories(&root, &repos, &settings, &sink, &build_cache);
}
@@ -864,6 +869,20 @@ fn scan_repositories(
sink: &SharedSink,
build_cache: &Path,
) {
match discover_repositories(root, settings, build_cache) {
Ok(found) => {
if found.roots() != repos.read().roots() {
*repos.write() = found;
sink.emit(IdeEvent::VcsChanged { root: root.to_path_buf().into() });
}
}
Err(e) => tracing::warn!("looking for git repositories: {e}"),
}
}
/// The repository containing `root` and those nested below it or below a content root.
/// The project-open scan and the watcher's rescan share these skip rules.
fn discover_repositories(root: &Path, settings: &SettingsStore, build_cache: &Path) -> IdeResult<Repos> {
let ignored = settings.effective().ignored_names();
let skip = |p: &Path| {
p.file_name().is_some_and(|n| {
@@ -875,15 +894,7 @@ fn scan_repositories(
};
let mut roots = vec![root.to_path_buf()];
roots.extend(content_roots(&settings.effective(), root, build_cache));
match Repos::discover_roots(&roots, &skip) {
Ok(found) => {
if found.roots() != repos.read().roots() {
*repos.write() = found;
sink.emit(IdeEvent::VcsChanged { root: root.to_path_buf().into() });
}
}
Err(e) => tracing::warn!("looking for git repositories: {e}"),
}
Repos::discover_roots(&roots, &skip)
}
impl Project {
+2 -1
Переглянути файл
@@ -270,7 +270,8 @@ impl Dispatcher {
.add("run_resize", |c| async move { ops::run::resize(&c.host, c.arg("id")?, c.arg("cols")?, c.arg("rows")?) })
.add("run_close_stdin", |c| async move { ops::run::close_stdin(&c.host, c.arg("id")?) })
.add("run_list", |c| async move { Ok(ops::run::list(&c.host)) })
.add("vcs_status", |c| async move { ops::vcs::status(&c.host, &c.window).await });
.add("vcs_status", |c| async move { ops::vcs::status(&c.host, &c.window).await })
.add("vcs_status_paths", |c| async move { ops::vcs::status_paths(&c.host, &c.window, c.arg("paths")?).await });
ops::register_all(&mut d);
d
}
+1
Переглянути файл
@@ -475,6 +475,7 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
| "vcs_clone"
| "vcs_init"
| "vcs_status"
| "vcs_status_paths"
| "vcs_roots"
| "vcs_branch_info"
| "vcs_line_hunks"
+15
Переглянути файл
@@ -99,6 +99,21 @@ async fn editor_ops_merge_the_fixture_format_selection_and_execute_owned_command
let path = root.join("a.txt");
std::fs::write(&path, "value\nnext\nend").unwrap();
let doc = ops::doc::open(&host, "main", path.clone()).await.unwrap();
// `a.toml` has neither a language server, a completion plugin, nor an injection plugin, so `Unavailable` is preserved.
let toml_path = root.join("a.toml");
std::fs::write(&toml_path, "k = 1\n").unwrap();
let toml_doc = ops::doc::open(&host, "main", toml_path).await.unwrap();
assert_eq!(
ops::lsp::lsp_completion(&host, "main", RequestId(89), toml_doc.id, Pos::default(), None)
.await
.unwrap_err()
.kind,
ide_core::error::ErrorKind::Unavailable
);
let sql_path = root.join("q.sql");
std::fs::write(&sql_path, "SELECT 1;").unwrap();
let sql_doc = ops::doc::open(&host, "main", sql_path).await.unwrap();
assert!(ops::lsp::lsp_completion(&host, "main", RequestId(90), sql_doc.id, Pos::default(), None).await.is_ok());
assert_eq!(
ops::lsp::lsp_signature_help(&host, "main", doc.id, Pos::default()).await.unwrap().unwrap().signatures[0]
.parameters,
+37
Переглянути файл
@@ -3760,6 +3760,43 @@ path = "templates/new.draw"
assert!(f.host.web_page("web-demo", "draw").is_err());
}
#[test]
fn file_icons_without_patterns_require_api_1_8_and_project_assets() {
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
let pkg = tmp.path().join("dynamic-icons");
std::fs::create_dir_all(pkg.join("icons")).unwrap();
std::fs::write(pkg.join("icons/helm-dir.svg"), r#"<svg viewBox="0 0 16 16"><circle cx="8" cy="8" r="6"/></svg>"#)
.unwrap();
let write_manifest = |api: &str| {
std::fs::write(
pkg.join("plugin.toml"),
format!(
r#"id = "dynamic-icons"
name = "Dynamic Icons"
version = "0.1.0"
engines.openidea = "{api}"
[[contributes.fileIcons]]
id = "helm-dir"
icon = "icons/helm-dir.svg"
"#
),
)
.unwrap();
};
write_manifest("^1.7");
let err = f.host.install_from_dir(&pkg, &Consent::all()).unwrap_err();
assert!(err.message.contains("1.8"), "{err:?}");
write_manifest("^1.8");
let info = f.install(&pkg);
assert!(info.error.is_none(), "{:?}", info.error);
let assets = f.host.assets("dynamic-icons", "fileIcon/helm-dir/").unwrap();
assert_eq!(assets.len(), 1);
assert!(assets[0].1.contains("<circle"));
}
#[test]
fn file_templates_fixture_installs_without_write_capabilities() {
let f = fixture(Limits::default());
+49 -16
Переглянути файл
@@ -367,6 +367,7 @@ async fn remote_projects_get_plugin_views_and_custom_editors() {
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn api14_remote_calls_keep_browser_listeners_and_secrets_on_the_desktop() {
use ide_core::{Diagnostic, Pos, Range, Severity};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let tmp = tempfile::tempdir().unwrap();
let desktop = Arc::new(
@@ -426,42 +427,70 @@ async fn api14_remote_calls_keep_browser_listeners_and_secrets_on_the_desktop()
"severity": "warning", "message": message, "source": "remote-check", "code": null
}]})
};
let publish = |path: &Path, source: &str, message: Option<&str>| {
let items = message
.map(|message| Diagnostic {
range: Range::new(Pos::new(0, 1), Pos::new(0, 3)),
severity: Severity::Warning,
message: message.into(),
source: Some(source.into()),
code: None,
unnecessary: false,
deprecated: false,
server: None,
})
.into_iter()
.collect();
host.project("main").unwrap().sink.emit(IdeEvent::Diagnostics {
path: path.to_path_buf().into(),
source: source.into(),
items,
});
};
let files = json!([
problem(&remote_file, "remote warning"),
problem(&other_file, "other warning"),
problem(&outside_file, "outside warning")
]);
invoke(&peer, "mcp_report_problems", json!({"reset": true, "files": files})).await.unwrap();
// UI reports are not the source of the plugin's host-owned snapshot.
assert_eq!(invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap(), json!("[]"));
publish(&remote_file, "remote-check", Some("remote warning"));
publish(&other_file, "remote-check", Some("other warning"));
publish(&outside_file, "remote-check", Some("outside warning"));
let problems = invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap();
let problems = problems.as_str().unwrap();
assert!(problems.contains("remote warning"), "{problems}");
assert!(problems.contains("other warning"), "{problems}");
assert!(!problems.contains("outside warning"), "{problems}");
assert!(problems.contains("outside warning"), "{problems}");
for path in ["remote.txt", remote_file.to_str().unwrap()] {
let problems = invoke(&peer, "plugin_run_command", run("problems", Some(path))).await.unwrap();
let problems = problems.as_str().unwrap();
assert!(problems.contains("remote warning"), "{problems}");
assert!(!problems.contains("other warning"), "{problems}");
assert!(!problems.contains("outside warning"), "{problems}");
assert!(problems.contains(remote_file.to_str().unwrap()), "{problems}");
}
assert_eq!(invoke(&peer, "plugin_run_command", run("problems", Some("missing.txt"))).await.unwrap(), json!("[]"));
invoke(&peer, "mcp_report_problems", json!({"reset": false, "files": [{"path": remote_file, "items": []}]}))
.await
.unwrap();
publish(&remote_file, "second-check", Some("second warning"));
invoke(&peer, "mcp_report_problems", json!({"reset": true, "files": []})).await.unwrap();
let problems = invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap();
assert!(problems.as_str().unwrap().contains("remote warning"));
assert!(problems.as_str().unwrap().contains("second warning"));
publish(&remote_file, "remote-check", None);
let problems = invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap();
assert!(!problems.as_str().unwrap().contains("remote warning"));
assert!(problems.as_str().unwrap().contains("second warning"));
assert!(problems.as_str().unwrap().contains("other warning"));
invoke(
&peer,
"mcp_report_problems",
json!({"reset": true, "files": [problem(&remote_file, "replacement warning")]}),
)
.await
.unwrap();
publish(&remote_file, "second-check", Some("replacement warning"));
let problems = invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap();
assert!(problems.as_str().unwrap().contains("replacement warning"));
assert!(!problems.as_str().unwrap().contains("other warning"));
invoke(&peer, "mcp_report_problems", json!({"reset": true, "files": []})).await.unwrap();
assert!(!problems.as_str().unwrap().contains("second warning"));
assert!(problems.as_str().unwrap().contains("other warning"));
publish(&remote_file, "second-check", None);
publish(&other_file, "remote-check", None);
publish(&outside_file, "remote-check", None);
assert_eq!(invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap(), json!("[]"));
invoke(&peer, "plugin_run_command", run("open-editor", Some("remote.txt"))).await.unwrap();
invoke(&peer, "plugin_run_command", run("decorations", remote_file.to_str())).await.unwrap();
@@ -513,11 +542,11 @@ async fn api14_remote_calls_keep_browser_listeners_and_secrets_on_the_desktop()
stream.read_to_string(&mut response).await.unwrap();
assert!(response.contains("You can close this tab"));
assert_eq!(oauth.await.unwrap().unwrap(), json!("code=desktop%2Bcode"));
invoke(&peer, "mcp_report_problems", json!({"reset": true, "files": files})).await.unwrap();
publish(&remote_file, "remote-check", Some("remote warning"));
invoke(&peer, "close_project", json!({})).await.unwrap();
invoke(&peer, "open_project", json!({"path": project})).await.unwrap();
assert_eq!(invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap(), json!("[]"));
invoke(&peer, "mcp_report_problems", json!({"reset": true, "files": files})).await.unwrap();
publish(&remote_file, "remote-check", Some("remote warning"));
peer.kill();
tokio::time::timeout(Duration::from_secs(5), server).await.unwrap().unwrap();
let plugin_host = plugins.host().clone();
@@ -537,7 +566,11 @@ async fn api14_remote_calls_keep_browser_listeners_and_secrets_on_the_desktop()
})
.await
.unwrap();
assert_eq!(result.as_deref(), Some("[]"));
// Disconnect clears desktop reports, not the still-live host project snapshot.
let problems = result.as_deref().unwrap();
assert!(problems.contains("remote warning"), "{problems}");
assert!(problems.contains("remote-check"), "{problems}");
assert!(problems.contains(remote_file.to_str().unwrap()), "{problems}");
plugins.host().shutdown();
desktop.shutdown();
}
+14
Переглянути файл
@@ -341,6 +341,7 @@ async fn local_deployment_runs_on_the_project_host() {
"db_save_sources",
"db_set_password",
"db_test",
"db_test_tunnel",
"db_set_ssh_secret",
"db_trust_ssh_host",
"db_connected",
@@ -364,6 +365,13 @@ async fn local_deployment_runs_on_the_project_host() {
"db_table_page",
"db_apply_edits",
"db_console_path",
"ssh_test",
"ssh_set_secret",
"ssh_password_saved",
"ssh_needs_secret",
"ssh_trust_host",
"ssh_connected",
"ssh_disconnect",
] {
assert!(dispatcher.commands().contains(&cmd), "missing host command {cmd}");
}
@@ -372,6 +380,12 @@ async fn local_deployment_runs_on_the_project_host() {
tokio::spawn(rpc::serve(host, dispatcher, slot, sr, sw));
let (cr, cw) = tokio::io::split(client_io);
let peer = Peer::connect(cr, cw, Arc::new(Client::default()));
// A remote window's host-key and password remedies reach the server's SSH manager,
// the one its deployment / database connections failed in.
let trust = invoke(&peer, "ssh_trust_host", json!({"id": "nothing-pending"})).await.unwrap_err();
assert!(trust.message.contains("no host key to trust"), "{trust:?}");
invoke(&peer, "ssh_set_secret", json!({"id": "deploy", "secret": "pw", "save": false})).await.unwrap();
assert_eq!(invoke(&peer, "ssh_connected", json!({})).await.unwrap(), json!([]));
invoke(&peer, "project_trust_set", json!({"path": project, "trusted": true, "folder": false})).await.unwrap();
invoke(&peer, "open_project", json!({"path": project})).await.unwrap();
let deployment = json!({"servers": [{"id":"local", "kind":"local", "rootPath":target}], "defaultServer":"local"});
+1
Переглянути файл
@@ -172,6 +172,7 @@ async fn restricted_vcs_commands_run_no_program_of_the_repository() {
}
let calls: Vec<Call> = vec![
call!("vcs_status", "", ops::vcs::status(host, w)),
call!("vcs_status_paths", "", ops::vcs::status_paths(host, w, vec![file.clone()])),
sync!("vcs_roots", "", vcs::vcs_roots(host, w)),
call!("vcs_branch_info", "", vcs::vcs_branch_info(host, w, root())),
call!("vcs_line_hunks", "", vcs::vcs_line_hunks(host, w, file.clone())),
+1 -1
Переглянути файл
@@ -102,7 +102,7 @@ async fn stash_and_unstash_reload_clean_open_documents_before_returning() {
let f = Fixture::new().await;
let edited = format!("{ORIGINAL}\n");
f.saved_edit(&edited);
vcs::vcs_stash_push(&f.host, "main", None, None, false, false).await.unwrap();
vcs::vcs_stash_push(&f.host, "main", None, None, false, false, None).await.unwrap();
f.assert_clean(ORIGINAL);
vcs::vcs_stash_apply(&f.host, "main", None, 0, true, false, None).await.unwrap();
f.assert_clean(&edited);
+1 -1
Переглянути файл
@@ -168,7 +168,7 @@ async fn change_commit_branch_stash_and_merge_a_conflict() {
let wip = format!("{}// work in progress\n", f.text(main_rs).await);
f.host.project(WINDOW).unwrap().documents.replace_all(main_rs, &wip).unwrap();
f.save(main_rs).await;
ops::vcs::vcs_stash_push(&f.host, WINDOW, None, Some("wip".into()), false, false).await.unwrap();
ops::vcs::vcs_stash_push(&f.host, WINDOW, None, Some("wip".into()), false, false, None).await.unwrap();
assert!(f.status().await.is_empty());
assert!(!f.text(main_rs).await.contains("work in progress"));
let stashes = ops::vcs::vcs_stashes(&f.host, WINDOW, None).await.unwrap();
+54
Переглянути файл
@@ -0,0 +1,54 @@
//! A repository folder moved into an open project is registered while the project is open:
//! the folder brings no `.git` event of its own, so the watcher's "created path" is what the
//! rescan has to answer to (the open scan already ran).
use ide_core::NullSink;
use ide_host::{Host, HostConfig};
use std::{path::Path, sync::Arc, time::Duration};
fn git_init(dir: &Path) {
std::fs::create_dir_all(dir).unwrap();
let status = std::process::Command::new("git").args(["init", "-q"]).current_dir(dir).status();
assert!(status.is_ok_and(|status| status.success()), "git init in {}", dir.display());
}
async fn wait_until(what: &str, mut done: impl FnMut() -> bool) {
let deadline = tokio::time::Instant::now() + Duration::from_secs(15);
while !done() {
assert!(tokio::time::Instant::now() < deadline, "timed out waiting for {what}");
tokio::time::sleep(Duration::from_millis(20)).await;
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn moving_a_repository_into_the_project_registers_it() {
let temp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(temp.path()).unwrap();
let root = base.join("project");
git_init(&root);
// Prepared outside the project, as a restored or moved folder would be.
let prepared = base.join("prepared");
git_init(&prepared);
let host = Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
host.trust.decide(&root, true, false).unwrap();
let project = host.open_project("main", &root).unwrap();
// The scan on project open runs in the background: wait for it before moving anything in.
wait_until("the project's own repository", || project.repos().roots().len() == 1).await;
std::fs::rename(&prepared, root.join("moved")).unwrap();
wait_until("the moved repository to be registered", || {
project.repos().roots().iter().any(|r| r.ends_with("moved"))
})
.await;
}
+1 -1
Переглянути файл
@@ -9,7 +9,7 @@ license.workspace = true
ide-core.workspace = true
ide-vfs.workspace = true
serde.workspace = true
serde_json.workspace = true
serde_json = { workspace = true, features = ["raw_value"] }
specta.workspace = true
tokio.workspace = true
tracing.workspace = true
+98 -10
Переглянути файл
@@ -52,26 +52,35 @@ fn search_dirs(file: Option<&Path>, root: Option<&Path>) -> Vec<PathBuf> {
}
fn parse_env(text: &str) -> IdeResult<EnvMap> {
use serde_json::value::RawValue;
let invalid = |e| IdeError::invalid(format!("invalid environment file: {e}"));
// 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 value: Box<RawValue> = serde_json::from_str(text).map_err(invalid)?;
let mut out = EnvMap::new();
let Some(envs) = value.as_object() else { return Ok(out) };
if !value.get().starts_with('{') {
return Ok(out);
}
let envs: BTreeMap<String, Box<RawValue>> = serde_json::from_str(value.get()).map_err(invalid)?;
for (env, vars) in envs {
let Some(vars) = vars.as_object() else { continue };
if !vars.get().starts_with('{') {
continue;
}
let vars: BTreeMap<String, Box<RawValue>> = serde_json::from_str(vars.get()).map_err(invalid)?;
let mut map = BTreeMap::new();
for (k, v) in vars {
let s = match v {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Number(n) => n.to_string(),
serde_json::Value::Bool(b) => b.to_string(),
let raw = v.get();
let s = match raw.as_bytes().first() {
Some(b'"') => serde_json::from_str::<String>(raw).map_err(invalid)?,
// Keep numeric tokens: Value::Number would round decimals and large integers.
Some(b'-' | b'0'..=b'9' | b't' | b'f') => raw.to_owned(),
// Objects configure SSL / auth in IDEA; not variables.
_ => continue,
};
map.insert(k.clone(), s);
map.insert(k, s);
}
out.insert(env.clone(), map);
out.insert(env, map);
}
Ok(out)
}
@@ -304,6 +313,85 @@ impl Resolver<'_> {
mod tests {
use super::*;
#[test]
fn preserves_exact_numeric_environment_tokens() {
for token in [
"0",
"-0",
"0.0",
"-0.000",
"1",
"-1",
"9007199254740991",
"9007199254740992",
"9007199254740993",
"9223372036854775807",
"-9223372036854775808",
"18446744073709551615",
"18446744073709551616",
"123456789012345678901234567890",
"-123456789012345678901234567890",
"1.0000000000000000002",
"-1.0000000000000000002",
"0.12345678901234567890123456789",
"-0.0000000000000000000000000001",
"1e-400",
"-1e-400",
"1e400",
"-1e400",
"1.2300e+20",
"1.2300E+020",
] {
let json = format!(r#"{{"dev":{{"value":{token}}}}}"#);
let envs = Environments::from_json(&json, "").unwrap();
let vars = envs.variables(Some("dev"));
assert_eq!(vars["value"], token);
let resolver = Resolver { file_vars: &[], env: vars };
let mut missing = BTreeSet::new();
assert_eq!(resolver.substitute("{\"value\":{{value}}}", &mut missing), format!(r#"{{"value":{token}}}"#));
assert!(missing.is_empty());
}
}
#[test]
fn rejects_malformed_numeric_environment_tokens() {
for token in ["01", "+1", "1.", ".1", "NaN", "Infinity", "--1", "1e", "1e+", "1e--2", "0xFF"] {
let json = format!(r#"{{"dev":{{"value":{token}}}}}"#);
assert!(Environments::from_json(&json, "").is_err(), "accepted {token}");
}
assert!(Environments::from_json(r#"{"dev":{"ssl":{"invalid":01}}}"#, "").is_err());
}
#[test]
fn reads_numeric_tokens_with_whitespace_and_escaped_names() {
let envs = Environments::from_json(
"\u{feff} \n { \"de\\u0076\": \n {\"va\\u006cue\": \t 1.2300E+020, \"value\": 1.0000000000000000002 }} \n",
"",
)
.unwrap();
assert_eq!(envs.names(), vec!["dev"]);
assert_eq!(envs.variables(Some("dev"))["value"], "1.0000000000000000002");
}
#[test]
fn exact_environment_parsing_keeps_types_and_precedence() {
let envs = Environments::from_json(
r#"{"$shared":{"shared":1.0000000000000000002},"dev":{"v":1,"v":9007199254740993,"enabled":true,"disabled":false,"s":"a\n\"b","null":null,"ssl":{},"arr":[]},"other":42}"#,
r#"{"dev":{"v":9223372036854775807}}"#,
).unwrap();
let vars = envs.variables(Some("dev"));
assert_eq!(vars["v"], "9223372036854775807");
assert_eq!(vars["shared"], "1.0000000000000000002");
assert_eq!(vars["enabled"], "true");
assert_eq!(vars["disabled"], "false");
assert_eq!(vars["s"], "a\n\"b");
assert!(!vars.contains_key("null"));
assert!(!vars.contains_key("ssl"));
assert!(!vars.contains_key("arr"));
assert_eq!(Environments::from_json("[]", "").unwrap().names(), Vec::<String>::new());
assert!(Environments::from_json(r#"{"dev":{"v":01}}"#, "").is_err());
}
#[test]
fn finds_variable_keys_in_every_environment_private_first() {
let tmp = tempfile::tempdir().unwrap();
+62
Переглянути файл
@@ -249,6 +249,68 @@ mod tests {
assert_eq!(url("GET https://api.test/cb?u=http://x\n"), "https://api.test/cb?u=http://x");
}
#[tokio::test]
async fn sends_exact_environment_numbers_to_a_local_server() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut request = Vec::new();
let (headers, body) = loop {
let mut buffer = [0u8; 4096];
let read = socket.read(&mut buffer).await.unwrap();
assert!(read > 0, "request ended before its body");
request.extend_from_slice(&buffer[..read]);
let Some(end) = request.windows(4).position(|w| w == b"\r\n\r\n") else { continue };
let headers = String::from_utf8_lossy(&request[..end]);
let length: usize = headers
.lines()
.find_map(|line| {
let (name, value) = line.split_once(':')?;
name.eq_ignore_ascii_case("content-length").then(|| value.trim().parse().unwrap())
})
.unwrap();
if request.len() >= end + 4 + length {
break (
String::from_utf8(request[..end].to_vec()).unwrap(),
String::from_utf8(request[end + 4..end + 4 + length].to_vec()).unwrap(),
);
}
};
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{body}",
body.len()
);
socket.write_all(response.as_bytes()).await.unwrap();
(headers, body)
});
let tmp = tempfile::tempdir().unwrap();
std::fs::write(
tmp.path().join(crate::env::PUBLIC_FILE),
r#"{"dev":{"id":123456789012345678901234567890,"amount":1.0000000000000000002,"tiny":1e-400}}"#,
)
.unwrap();
let file = tmp.path().join("precision.http");
let text = format!(
"POST http://{addr}/echo?id={{{{id}}}}\nContent-Type: application/json\nX-Exact-Amount: {{{{amount}}}}\n\n{{\"id\":{{{{id}}}},\"amount\":{{{{amount}}}},\"tiny\":{{{{tiny}}}}}}\n"
);
let input =
crate::RunInput { text: &text, index: 0, file: Some(&file), root: Some(tmp.path()), env: Some("dev") };
let exchange =
tokio::time::timeout(std::time::Duration::from_secs(5), crate::run(input, &CancellationToken::new()))
.await
.unwrap()
.unwrap();
let expected = r#"{"id":123456789012345678901234567890,"amount":1.0000000000000000002,"tiny":1e-400}"#;
let (headers, body) = server.await.unwrap();
assert_eq!(headers.lines().next().unwrap(), "POST /echo?id=123456789012345678901234567890 HTTP/1.1");
assert!(headers.lines().any(|line| line.eq_ignore_ascii_case("x-exact-amount: 1.0000000000000000002")));
assert_eq!(body, expected);
assert_eq!(exchange.request.body.as_deref(), Some(expected));
assert_eq!(exchange.response.unwrap().body.as_deref(), Some(expected));
}
#[tokio::test]
async fn executes_against_a_local_server() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
+4
Переглянути файл
@@ -31,3 +31,7 @@ criterion = { version = "0.5", default-features = false }
[[bench]]
name = "index"
harness = false
[[bench]]
name = "startup"
harness = false
+64
Переглянути файл
@@ -0,0 +1,64 @@
//! `cargo bench -p ide-index --bench startup`: open a persisted 20k-file
//! project and wait for the index to become idle. This is the full warm-start
//! path (columnar snapshot, directory/file manifest, lazy redb).
use criterion::{Criterion, black_box, criterion_group, criterion_main};
use ide_core::NullSink;
use ide_index::ProjectIndex;
use ide_language::{LanguageRegistry, Syntax};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
const FILES: usize = 20_000;
fn project(root: &Path) {
for i in 0..FILES {
let dir = root.join(format!("mod{}/src", i % 50));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(format!("File{i}.rs")), format!("fn symbol{i}() {{}}\n")).unwrap();
}
}
fn bench(c: &mut Criterion) {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("project");
let cache = tmp.path().join("cache");
project(&root);
let languages = Arc::new(LanguageRegistry::load(None).unwrap());
let syntax = Arc::new(Syntax::new());
let open = || {
ProjectIndex::open(
&root,
&cache,
Vec::<PathBuf>::new(),
vec![],
languages.clone(),
syntax.clone(),
Arc::new(NullSink),
)
.unwrap()
};
let first = open();
assert!(first.wait_idle(Duration::from_secs(30)));
drop(first);
let mut group = c.benchmark_group("startup_20k");
group.sample_size(10);
group.bench_function("reopen_wait_idle", |b| {
b.iter(|| {
let index = open();
assert!(index.wait_idle(Duration::from_secs(30)));
black_box(index.status().files)
})
});
group.finish();
}
criterion_group! {
name = benches;
config = Criterion::default().measurement_time(Duration::from_secs(3));
targets = bench
}
criterion_main!(benches);
+91
Переглянути файл
@@ -24,6 +24,19 @@ pub(crate) fn char_mask(s: &str) -> u64 {
m
}
/// A relative index key must stay below the project root.
pub(crate) fn safe_rel(rel: &str) -> bool {
let mut any = false;
for component in Path::new(rel).components() {
if matches!(component, std::path::Component::Normal(_)) {
any = true;
} else {
return false;
}
}
any
}
#[inline]
pub(crate) fn mask_bit(b: u8) -> u64 {
match b.to_ascii_lowercase() {
@@ -553,6 +566,67 @@ impl IndexData {
self.by_path.get(path).copied()
}
/// Reserves the flat tables for a bulk warm-start hydration.
pub(crate) fn reserve(&mut self, files: usize, symbols: usize, dirs: usize) {
self.files.reserve(files);
self.by_path.reserve(files);
self.dirs.reserve(dirs);
self.hot.rows.reserve(files);
self.dirs_hot.rows.reserve(dirs);
self.syms.head.reserve(symbols);
self.syms.container.reserve(symbols);
self.syms.kind.reserve(symbols);
self.syms.range.reserve(symbols);
}
/// Hydrates a validated warm-start snapshot in one pass, without per-file
/// duplicate checks, ancestor walks or compaction checks.
pub(crate) fn load_bulk(
&mut self,
entries: Vec<(FileEntry, Vec<StoredSymbol>)>,
dirs: Vec<(Box<str>, PathBuf, u32)>,
) {
self.files = Vec::with_capacity(entries.len());
self.by_path = HashMap::with_capacity(entries.len());
self.hot = HotFiles::default();
self.dirs_hot = HotDirs::default();
self.syms = SymTable::default();
self.dirs = HashMap::with_capacity(dirs.len());
let symbols: usize = entries.iter().map(|(_, symbols)| symbols.len()).sum();
self.reserve(entries.len(), symbols, dirs.len());
self.symbol_count = 0;
for (slot, (mut entry, symbols)) in entries.into_iter().enumerate() {
let (sym_start, sym_mask) = self.syms.push(&symbols);
entry.sym_start = sym_start;
entry.sym_len = symbols.len() as u32;
self.symbol_count += entry.sym_len;
let rel = self.hot.append(&entry.rel);
self.hot.set(
slot as u32,
FileRow {
rel,
name_start: entry.name_start,
sym_start,
sym_len: entry.sym_len,
sym_mask,
rel_mask: entry.rel_mask,
name_mask: entry.name_mask,
dir_mask: char_mask(entry.dir()),
ascii: entry.rel.is_ascii(),
live: true,
},
);
self.by_path.insert(entry.path.clone(), slot as u32);
self.files.push(Some(entry));
}
self.file_count = self.files.len() as u32;
for (rel, path, count) in dirs {
let row = self.dirs_hot.push(&rel, &path);
self.dirs.insert(rel, (path, count, row));
}
self.free.clear();
}
/// The persisted form of a file, rebuilt from [`IndexData::syms`].
pub(crate) fn record(&self, e: &FileEntry) -> FileRecord {
FileRecord {
@@ -793,6 +867,23 @@ mod tests {
assert_eq!(d.symbol_count, d.files.iter().flatten().map(|e| e.sym_len).sum::<u32>());
}
#[test]
fn bulk_load_matches_incremental_insert() {
let mut incremental = IndexData::default();
insert(&mut incremental, "a/b.rs", 2);
insert(&mut incremental, "c.rs", 1);
assert_consistent(&incremental);
let mut bulk = IndexData::default();
bulk.load_bulk(vec![entry("a/b.rs", 2), entry("c.rs", 1)], vec![("a/".into(), PathBuf::from("/r/a"), 1)]);
assert_consistent(&bulk);
assert_eq!((bulk.file_count, bulk.symbol_count), (2, 3));
assert_eq!(bulk.hot.text(), incremental.hot.text());
assert_eq!(bulk.files.len(), incremental.files.len());
assert_eq!(bulk.slot(Path::new("/r/a/b.rs")), incremental.slot(Path::new("/r/a/b.rs")));
assert_eq!(bulk.dirs["a/"].1, 1);
}
#[test]
fn dirs_follow_inserts_and_removals() {
let mut d = IndexData::default();
+45
Переглянути файл
@@ -138,6 +138,22 @@ impl PathFilter {
&self.config.0 == extra_roots && self.config.1 == excludes
}
/// Stable fingerprint of the persisted-scope configuration; a start-up
/// snapshot is only trusted when this still matches.
pub(crate) fn stamp(&self) -> String {
let mut stamp = format!("include_root={}\n", self.config.0.include_root);
for folder in &self.config.0.folders {
stamp.push_str(&folder.to_string_lossy());
stamp.push('\n');
}
stamp.push_str("--\n");
for exclude in &self.config.1 {
stamp.push_str(exclude);
stamp.push('\n');
}
stamp
}
pub(crate) fn reload(&self) -> Self {
Self::new(&self.root, &self.config.0, &self.config.1)
}
@@ -146,6 +162,11 @@ impl PathFilter {
&self.root
}
/// Whether content roots outside the project root are indexed with it.
pub(crate) fn has_extra_roots(&self) -> bool {
!self.extra.is_empty()
}
/// The project root, then the extra content roots.
pub(crate) fn roots(&self) -> impl Iterator<Item = &Path> {
std::iter::once(self.root.as_path())
@@ -227,6 +248,30 @@ impl PathFilter {
dir.is_some_and(|d| gitignore.matched_path_or_any_parents(d, true).is_ignore())
}
/// All directories at or below `dir` that the index would walk, in walk order.
pub(crate) fn dirs(&self, dir: &Path) -> Vec<PathBuf> {
let Some((root, _, _)) = self.base_of(dir) else { return Vec::new() };
let root = root.to_path_buf();
let excludes = self.excludes.clone();
let walk_root = root.clone();
let walk_excludes = excludes.clone();
let walker = WalkBuilder::new(dir)
.hidden(false)
.parents(true)
.ignore(true)
.git_ignore(true)
.git_global(true)
.git_exclude(true)
.require_git(ide_core::ignored::inside_git_repo(dir))
.follow_links(false)
.filter_entry(move |e| {
let name = e.file_name().to_string_lossy();
!Self::skip_name(&name) && !glob_excluded(&walk_excludes, &walk_root, e.path())
})
.build();
walker.flatten().filter(|e| e.file_type().is_some_and(|t| t.is_dir())).map(|e| e.into_path()).collect()
}
/// All indexable files at or below `dir`, walked in parallel.
/// Stops early (returning what was found so far) once `cancel` is set.
pub(crate) fn walk(&self, dir: &Path, cancel: &AtomicBool) -> Vec<Found> {
+587 -37
Переглянути файл
@@ -1,75 +1,429 @@
//! Persistence of file records in redb, so a restart only re-parses changed files.
use crate::data::FileRecord;
use crate::data::{FileEntry, FileRecord, IndexData, StoredSymbol};
use crate::filter::{PathFilter, mtime_of};
use ide_core::{Range, SymbolKind};
use parking_lot::RwLock;
use redb::{Database, Durability, ReadableDatabase, ReadableTable, TableDefinition, TableError};
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
const FILES: TableDefinition<&str, &[u8]> = TableDefinition::new("files");
const META: TableDefinition<&str, &[u8]> = TableDefinition::new("meta");
/// Bump when [`FileRecord`] or its encoding changes.
const SCHEMA: &str = "openidea-index-3";
/// The real Git directory for a checkout/worktree (`root/.git` may be a file
/// containing `gitdir: ...`).
fn git_dir(root: &Path) -> Option<std::path::PathBuf> {
let dot_git = root.join(".git");
if dot_git.is_dir() {
return Some(dot_git);
}
let text = std::fs::read_to_string(&dot_git).ok()?;
let target = text.strip_prefix("gitdir:")?.trim();
let path = Path::new(target);
Some(if path.is_absolute() { path.to_owned() } else { root.join(path) })
}
/// The config and exclude file of the Git directory `git`; a linked worktree's directory
/// (`.git/worktrees/<name>`) reads the shared ones its `commondir` names as well.
fn insert_git_inputs(out: &mut std::collections::BTreeSet<std::path::PathBuf>, git: &Path) {
out.insert(git.join("config"));
out.insert(git.join("info/exclude"));
if let Ok(common) = std::fs::read_to_string(git.join("commondir")) {
let common = Path::new(common.trim());
let common = if common.is_absolute() { common.to_owned() } else { git.join(common) };
out.insert(common.join("config"));
out.insert(common.join("info/exclude"));
}
}
/// External ignore/config inputs, including candidates that do not exist yet.
/// These are outside the directory manifest (`.git` is skipped), so the full
/// path set must match at warm start.
pub(crate) fn external_ignore_candidates(root: &Path) -> Vec<std::path::PathBuf> {
let mut out = std::collections::BTreeSet::new();
if let Some(git) = git_dir(root) {
insert_git_inputs(&mut out, &git);
}
out.insert(root.join(".git/config"));
out.insert(root.join(".git/info/exclude"));
// The walker also reads the ignore files above the root (`parents`), and a project nested
// in a repository uses that repository's exclude file and config.
for dir in root.ancestors().skip(1) {
out.insert(dir.join(".gitignore"));
out.insert(dir.join(".ignore"));
if let Some(git) = git_dir(dir) {
insert_git_inputs(&mut out, &git);
}
}
if let Some(path) = ignore::gitignore::gitconfig_excludes_path() {
out.insert(path);
}
out.insert(std::path::PathBuf::from("/etc/gitconfig"));
if let Some(path) = std::env::var_os("GIT_CONFIG_GLOBAL") {
out.insert(path.into());
}
if let Some(home) = std::env::var_os("HOME") {
let home = std::path::PathBuf::from(home);
out.insert(home.join(".gitconfig"));
out.insert(
std::env::var_os("XDG_CONFIG_HOME")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| home.join(".config"))
.join("git/config"),
);
}
out.into_iter().collect()
}
/// Existing per-directory ignore files. Adding/removing one changes the
/// directory mtime, which the manifest already validates before this list is used.
fn per_dir_ignore_candidates(root: &Path, dirs: &[String]) -> Vec<std::path::PathBuf> {
let mut out = std::collections::BTreeSet::new();
for rel in dirs {
let dir = if rel.is_empty() { root.to_owned() } else { root.join(rel) };
for name in [".gitignore", ".ignore"] {
let path = dir.join(name);
if path.is_file() {
out.insert(path);
}
}
}
out.into_iter().collect()
}
/// One file's fixed-size columns in the start-up snapshot.
#[derive(Clone, Copy, Serialize, Deserialize, Default)]
struct FileMeta {
mtime: u64,
size: u64,
hash: [u8; 32],
ignored: bool,
/// End of this file's symbols in [`Snapshot::symbols`].
sym_end: u32,
}
/// One symbol's fixed-size columns; names and containers share [`Snapshot::text`].
#[derive(Clone, Copy, Serialize, Deserialize)]
struct FlatSymbol {
name_start: u32,
name_len: u32,
container_start: u32,
container_len: u32,
kind: SymbolKind,
range: Range,
}
/// A columnar copy of the index for fast start-up: a handful of contiguous
/// arrays and two string arenas instead of one postcard record per redb key.
#[derive(Serialize, Deserialize, Default)]
pub(crate) struct Snapshot {
rels: String,
rel_ends: Vec<u32>,
meta: Vec<FileMeta>,
symbols: Vec<FlatSymbol>,
text: String,
dirs: String,
dir_ends: Vec<u32>,
dir_mtimes: Vec<u64>,
dir_counts: Vec<u32>,
filter: String,
stamp: String,
ignore_paths: String,
ignore_ends: Vec<u32>,
ignore_mtimes: Vec<u64>,
ignore_sizes: Vec<u64>,
ignore_present: Vec<bool>,
ignore_external: Vec<bool>,
}
impl Snapshot {
pub(crate) fn len(&self) -> usize {
self.meta.len()
}
pub(crate) fn filter(&self) -> &str {
&self.filter
}
pub(crate) fn dirs_len(&self) -> usize {
self.dir_mtimes.len()
}
pub(crate) fn dir(&self, dir: usize) -> &str {
let start = if dir == 0 { 0 } else { self.dir_ends[dir - 1] as usize };
&self.dirs[start..self.dir_ends[dir] as usize]
}
pub(crate) fn dir_mtime(&self, dir: usize) -> u64 {
self.dir_mtimes[dir]
}
pub(crate) fn dir_count(&self, dir: usize) -> u32 {
self.dir_counts[dir]
}
pub(crate) fn ignore_len(&self) -> usize {
self.ignore_mtimes.len()
}
pub(crate) fn ignore_path(&self, i: usize) -> &str {
let start = if i == 0 { 0 } else { self.ignore_ends[i - 1] as usize };
&self.ignore_paths[start..self.ignore_ends[i] as usize]
}
pub(crate) fn ignore_mtime(&self, i: usize) -> u64 {
self.ignore_mtimes[i]
}
pub(crate) fn ignore_size(&self, i: usize) -> u64 {
self.ignore_sizes[i]
}
pub(crate) fn ignore_present(&self, i: usize) -> bool {
self.ignore_present[i]
}
pub(crate) fn ignore_external(&self, i: usize) -> bool {
self.ignore_external[i]
}
pub(crate) fn mtime(&self, file: usize) -> u64 {
self.meta[file].mtime
}
pub(crate) fn size(&self, file: usize) -> u64 {
self.meta[file].size
}
/// Rejects a corrupt snapshot before any offsets are used for slicing.
fn valid(&self) -> bool {
let max = u32::MAX as usize;
if self.rels.len() > max || self.text.len() > max || self.dirs.len() > max || self.ignore_paths.len() > max {
return false;
}
if self.rel_ends.len() != self.meta.len() {
return false;
}
let boundary = |text: &str, end: u32| end as usize <= text.len() && text.is_char_boundary(end as usize);
let mut rel_end = 0;
for end in &self.rel_ends {
if *end < rel_end || !boundary(&self.rels, *end) {
return false;
}
rel_end = *end;
}
if rel_end as usize != self.rels.len() {
return false;
}
let mut start = 0;
for end in &self.rel_ends {
let rel = &self.rels[start..*end as usize];
if !crate::data::safe_rel(rel) {
return false;
}
start = *end as usize;
}
let mut sym_end = 0;
for meta in &self.meta {
if meta.sym_end < sym_end || meta.sym_end as usize > self.symbols.len() {
return false;
}
sym_end = meta.sym_end;
}
if sym_end as usize != self.symbols.len() {
return false;
}
if !self.symbols.iter().all(|s| {
let Some(name_end) = (s.name_start as usize).checked_add(s.name_len as usize) else {
return false;
};
let Some(container_end) = (s.container_start as usize).checked_add(s.container_len as usize) else {
return false;
};
name_end <= self.text.len()
&& self.text.is_char_boundary(s.name_start as usize)
&& self.text.is_char_boundary(name_end)
&& (s.container_start == u32::MAX
|| (s.container_start as usize <= self.text.len()
&& container_end <= self.text.len()
&& self.text.is_char_boundary(s.container_start as usize)
&& self.text.is_char_boundary(container_end)))
}) {
return false;
}
if self.dir_ends.len() != self.dir_mtimes.len() || self.dir_counts.len() != self.dir_mtimes.len() {
return false;
}
if self.dir_ends.first() != Some(&0) {
return false;
}
let mut dir_end = 0;
for end in &self.dir_ends {
if *end < dir_end || !boundary(&self.dirs, *end) {
return false;
}
dir_end = *end;
}
if dir_end as usize != self.dirs.len() {
return false;
}
let mut start = 0;
for end in &self.dir_ends {
let dir = &self.dirs[start..*end as usize];
if !dir.is_empty() && !crate::data::safe_rel(dir) {
return false;
}
start = *end as usize;
}
if self.ignore_ends.len() != self.ignore_mtimes.len()
|| self.ignore_sizes.len() != self.ignore_mtimes.len()
|| self.ignore_present.len() != self.ignore_mtimes.len()
|| self.ignore_external.len() != self.ignore_mtimes.len()
{
return false;
}
let mut ignore_end = 0;
for end in &self.ignore_ends {
if *end < ignore_end || !boundary(&self.ignore_paths, *end) {
return false;
}
ignore_end = *end;
}
if ignore_end as usize != self.ignore_paths.len() {
return false;
}
let mut start = 0;
for end in &self.ignore_ends {
if start == *end as usize {
return false;
}
start = *end as usize;
}
true
}
pub(crate) fn rel(&self, file: usize) -> &str {
let start = if file == 0 { 0 } else { self.rel_ends[file - 1] as usize };
&self.rels[start..self.rel_ends[file] as usize]
}
pub(crate) fn entry(&self, file: usize, root: &Path) -> (FileEntry, Vec<StoredSymbol>) {
let meta = self.meta[file];
let sym_start = if file == 0 { 0 } else { self.meta[file - 1].sym_end as usize };
let symbols = (sym_start..meta.sym_end as usize)
.map(|i| {
let s = self.symbols[i];
let name_start = s.name_start as usize;
let name_end = name_start + s.name_len as usize;
let name = &self.text[name_start..name_end];
let container = (s.container_start != u32::MAX).then(|| {
let start = s.container_start as usize;
&self.text[start..start + s.container_len as usize]
});
StoredSymbol {
name: name.to_owned(),
kind: s.kind,
container: container.map(str::to_owned),
range: s.range,
}
})
.collect();
let rel = self.rel(file);
FileEntry::new(
root.join(rel),
rel.to_owned(),
FileRecord { mtime: meta.mtime, size: meta.size, hash: meta.hash, symbols, ignored: meta.ignored },
)
}
}
type Result<T> = std::result::Result<T, String>;
fn err(e: impl std::fmt::Display) -> String {
e.to_string()
}
fn fit(n: usize) -> Result<u32> {
u32::try_from(n).map_err(|_| "index snapshot exceeds the 4 GiB span limit".to_owned())
}
pub(crate) struct Store {
/// Write-locked only for [`Store::compact`].
db: RwLock<Database>,
/// Opened lazily: a valid warm-start snapshot serves start-up without
/// touching redb. Write-locked only for [`Store::compact`].
db: RwLock<Option<Database>>,
/// Columnar warm-start snapshot, kept beside the database.
snapshot: std::path::PathBuf,
/// Recent-file list, readable without opening redb.
recent_file: std::path::PathBuf,
stamp: String,
}
impl Store {
/// Opens (or creates) `cache_dir/index.redb`. Records written by another
/// schema / extractor version or for another root are discarded. Returns
/// `None` when the database cannot be used (e.g. locked by another window);
/// the index then runs in memory only.
/// Opens (or creates) the cache directory. The redb database itself is opened
/// lazily so a valid columnar snapshot can serve start-up without touching it.
/// Returns `None` only when the cache directory cannot be created; the index
/// then runs in memory only.
pub(crate) fn open(cache_dir: &Path, root: &Path, fingerprint: &str) -> Option<Self> {
if let Err(e) = std::fs::create_dir_all(cache_dir) {
tracing::warn!("index cache {}: {e}", cache_dir.display());
return None;
}
let file = cache_dir.join("index.redb");
let stamp = format!("{SCHEMA}\n{}\n{fingerprint}\n{}", env!("INDEX_SOURCE_HASH"), root.display());
Some(Self {
db: RwLock::new(None),
snapshot: cache_dir.join("index.snapshot"),
recent_file: cache_dir.join("recent.snapshot"),
stamp,
})
}
fn open_db(&self) -> Result<Database> {
let file = self.snapshot.with_file_name("index.redb");
let db = match Database::create(&file) {
Ok(db) => db,
Err(redb::DatabaseError::DatabaseAlreadyOpen) => {
tracing::warn!("index cache {} is in use; indexing in memory only", file.display());
return None;
return Err(format!("{} is in use", file.display()));
}
Err(e) => {
tracing::warn!("index cache {} unreadable ({e}); rebuilding", file.display());
let _ = std::fs::remove_file(&file);
Database::create(&file).map_err(|e| tracing::warn!("index cache: {e}")).ok()?
let _ = std::fs::remove_file(&self.snapshot);
Database::create(&file).map_err(err)?
}
};
let store = Self { db: RwLock::new(db) };
let stamp = format!("{SCHEMA}\n{}\n{fingerprint}\n{}", env!("INDEX_SOURCE_HASH"), root.display());
match store.meta("stamp") {
Ok(Some(v)) if v == stamp.as_bytes() => {}
_ => {
if let Err(e) = store.reset(&stamp) {
tracing::warn!("index cache reset failed: {e}");
return None;
}
let valid = {
let txn = db.begin_read().map_err(err)?;
match txn.open_table(META) {
Ok(table) => table.get("stamp").map_err(err)?.is_some_and(|v| v.value() == self.stamp.as_bytes()),
Err(TableError::TableDoesNotExist(_)) => false,
Err(e) => return Err(err(e)),
}
};
if !valid {
let _ = std::fs::remove_file(&self.snapshot);
let _ = std::fs::remove_file(&self.recent_file);
Self::reset_db(&db, &self.stamp)?;
}
Some(store)
Ok(db)
}
fn meta(&self, key: &str) -> Result<Option<Vec<u8>>> {
let txn = self.db.read().begin_read().map_err(err)?;
let table = match txn.open_table(META) {
Ok(t) => t,
Err(TableError::TableDoesNotExist(_)) => return Ok(None),
Err(e) => return Err(err(e)),
};
Ok(table.get(key).map_err(err)?.map(|v| v.value().to_vec()))
fn ensure_db(&self) -> Result<()> {
if self.db.read().is_some() {
return Ok(());
}
let mut slot = self.db.write();
if slot.is_none() {
*slot = Some(self.open_db()?);
}
Ok(())
}
fn reset(&self, stamp: &str) -> Result<()> {
let txn = self.db.read().begin_write().map_err(err)?;
fn reset_db(db: &Database, stamp: &str) -> Result<()> {
let txn = db.begin_write().map_err(err)?;
match txn.delete_table(FILES) {
Ok(_) | Err(TableError::TableDoesNotExist(_)) => {}
Err(e) => return Err(err(e)),
@@ -82,16 +436,31 @@ impl Store {
txn.commit().map_err(err)
}
fn meta(&self, key: &str) -> Result<Option<Vec<u8>>> {
self.ensure_db()?;
let db = self.db.read();
let txn = db.as_ref().expect("ensured").begin_read().map_err(err)?;
let table = match txn.open_table(META) {
Ok(t) => t,
Err(TableError::TableDoesNotExist(_)) => return Ok(None),
Err(e) => return Err(err(e)),
};
Ok(table.get(key).map_err(err)?.map(|v| v.value().to_vec()))
}
/// All persisted records; undecodable entries are skipped (and re-indexed).
pub(crate) fn load(&self) -> Result<Vec<(String, FileRecord)>> {
let txn = self.db.read().begin_read().map_err(err)?;
self.ensure_db()?;
let db = self.db.read();
let txn = db.as_ref().expect("ensured").begin_read().map_err(err)?;
let table = match txn.open_table(FILES) {
Ok(t) => t,
Err(TableError::TableDoesNotExist(_)) => return Ok(Vec::new()),
Err(e) => return Err(err(e)),
};
let mut out = Vec::new();
for item in table.iter().map_err(err)? {
let iter = table.iter().map_err(err)?;
let mut out = Vec::with_capacity(iter.size_hint().0);
for item in iter {
let (k, v) = item.map_err(err)?;
if let Ok(rec) = postcard::from_bytes::<FileRecord>(v.value()) {
out.push((k.value().to_owned(), rec));
@@ -100,10 +469,115 @@ impl Store {
Ok(out)
}
/// Writes the current index as one columnar snapshot for fast next start-up.
/// Only records persisted below `root` are included; content roots outside it
/// are rescanned, as they are for the per-file records.
pub(crate) fn save_snapshot(&self, data: &IndexData, filter: &PathFilter, not_after: SystemTime) -> Result<()> {
let guard = not_after.duration_since(UNIX_EPOCH).map_or(0, |d| d.as_nanos() as u64);
let root = filter.root();
let mut snap = Snapshot { filter: filter.stamp(), stamp: self.stamp.clone(), ..Default::default() };
let mut dirs: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
dirs.insert(String::new());
for dir in filter.dirs(root) {
if let Ok(rel) = dir.strip_prefix(root) {
dirs.insert(rel.to_string_lossy().replace('\\', "/").trim_end_matches('/').to_owned());
}
}
for e in data.files.iter().flatten().filter(|e| e.path.starts_with(root)) {
dirs.insert(e.dir().trim_end_matches('/').to_owned());
snap.rels.push_str(&e.rel);
snap.rel_ends.push(fit(snap.rels.len())?);
for s in data.symbols_of(e) {
let name_start = fit(snap.text.len())?;
snap.text.push_str(s.name);
let (container_start, container_len) = match s.container {
Some(c) => {
let start = fit(snap.text.len())?;
snap.text.push_str(c);
(start, fit(c.len())?)
}
None => (u32::MAX, 0),
};
snap.symbols.push(FlatSymbol {
name_start,
name_len: fit(s.name.len())?,
container_start,
container_len,
kind: s.kind,
range: s.range,
});
}
snap.meta.push(FileMeta {
mtime: e.mtime,
size: e.size,
hash: e.hash,
ignored: e.ignored,
sym_end: fit(snap.symbols.len())?,
});
}
let dir_list: Vec<String> = dirs.into_iter().collect();
for rel in &dir_list {
let path = if rel.is_empty() { root.to_owned() } else { root.join(rel) };
let mtime = std::fs::symlink_metadata(&path).ok().map(|m| mtime_of(&m)).unwrap_or(0);
if mtime > guard {
let _ = std::fs::remove_file(&self.snapshot);
return Ok(());
}
let count = if rel.is_empty() {
0
} else {
let key = format!("{rel}/");
data.dirs.get(key.as_str()).map_or(0, |d| d.1)
};
snap.dirs.push_str(rel);
snap.dir_ends.push(fit(snap.dirs.len())?);
snap.dir_mtimes.push(mtime);
snap.dir_counts.push(count);
}
let external: std::collections::BTreeSet<std::path::PathBuf> =
external_ignore_candidates(root).into_iter().collect();
let mut candidates: std::collections::BTreeSet<std::path::PathBuf> =
per_dir_ignore_candidates(root, &dir_list).into_iter().collect();
candidates.extend(external.iter().cloned());
for path in candidates {
let meta = std::fs::metadata(&path).ok();
let present = meta.is_some();
let mtime = meta.as_ref().map_or(0, mtime_of);
if mtime > guard {
let _ = std::fs::remove_file(&self.snapshot);
return Ok(());
}
snap.ignore_paths.push_str(&path.to_string_lossy());
snap.ignore_ends.push(fit(snap.ignore_paths.len())?);
snap.ignore_mtimes.push(if present { mtime } else { 0 });
snap.ignore_sizes.push(if present { meta.as_ref().map_or(0, |m| m.len()) } else { 0 });
snap.ignore_present.push(present);
snap.ignore_external.push(external.contains(&path));
}
fit(snap.rels.len())?;
fit(snap.text.len())?;
fit(snap.dirs.len())?;
fit(snap.ignore_paths.len())?;
let bytes = postcard::to_allocvec(&snap).map_err(err)?;
let tmp = self.snapshot.with_extension("snapshot.tmp");
std::fs::write(&tmp, &bytes).map_err(err)?;
let _ = std::fs::remove_file(&self.snapshot);
std::fs::rename(&tmp, &self.snapshot).map_err(err)
}
/// The columnar start-up snapshot, if it was written and still decodes.
pub(crate) fn load_snapshot(&self) -> Option<Snapshot> {
let bytes = std::fs::read(&self.snapshot).ok()?;
let snapshot: Snapshot = postcard::from_bytes(&bytes).ok()?;
(snapshot.stamp == self.stamp && snapshot.valid()).then_some(snapshot)
}
/// Applies upserts and deletions in one transaction. Non-durable commits
/// are cheap (no fsync) and become durable with the next durable one.
pub(crate) fn apply(&self, puts: &[(String, FileRecord)], deletes: &[String], durable: bool) -> Result<()> {
let mut txn = self.db.read().begin_write().map_err(err)?;
self.ensure_db()?;
let db = self.db.read();
let mut txn = db.as_ref().expect("ensured").begin_write().map_err(err)?;
if !durable {
txn.set_durability(Durability::None).map_err(err)?;
}
@@ -120,25 +594,47 @@ impl Store {
table.remove(rel.as_str()).map_err(err)?;
}
}
// Any file change makes the columnar start-up snapshot stale.
let _ = std::fs::remove_file(&self.snapshot);
txn.commit().map_err(err)
}
/// Reclaims free pages in the database file; `true` if anything was compacted.
pub(crate) fn compact(&self) -> Result<bool> {
self.db.write().compact().map_err(err)
self.ensure_db()?;
let mut db = self.db.write();
db.as_mut().expect("ensured").compact().map_err(err)
}
pub(crate) fn load_recent(&self) -> Vec<String> {
if let Some(recent) = self.load_recent_file() {
return recent;
}
self.meta("recent").ok().flatten().and_then(|v| postcard::from_bytes(&v).ok()).unwrap_or_default()
}
/// Recent files without opening redb (the warm-start path).
pub(crate) fn load_recent_file(&self) -> Option<Vec<String>> {
let bytes = std::fs::read(&self.recent_file).ok()?;
postcard::from_bytes(&bytes).ok()
}
pub(crate) fn save_recent(&self, recent: &[String]) -> Result<()> {
self.save_recent_file(recent)?;
let bytes = postcard::to_allocvec(recent).map_err(err)?;
self.set_meta("recent", &bytes)
}
/// Rewrites only the recent sidecar, without opening redb.
pub(crate) fn save_recent_file(&self, recent: &[String]) -> Result<()> {
let bytes = postcard::to_allocvec(recent).map_err(err)?;
std::fs::write(&self.recent_file, &bytes).map_err(err)
}
fn set_meta(&self, key: &str, value: &[u8]) -> Result<()> {
let txn = self.db.read().begin_write().map_err(err)?;
self.ensure_db()?;
let db = self.db.read();
let txn = db.as_ref().expect("ensured").begin_write().map_err(err)?;
txn.open_table(META).map_err(err)?.insert(key, value).map_err(err)?;
txn.commit().map_err(err)
}
@@ -183,4 +679,58 @@ mod tests {
let s = Store::open(dir.path(), Path::new("/p"), "fp").unwrap();
assert!(s.load().unwrap().is_empty());
}
#[test]
fn snapshot_roundtrips_and_is_invalidated_by_changes() {
use crate::data::{FileEntry, IndexData, StoredSymbol};
use ide_core::{Range, SymbolKind};
let dir = tempfile::tempdir().unwrap();
let store = Store::open(dir.path(), Path::new("/p"), "fp").unwrap();
let record = FileRecord {
mtime: 7,
size: 3,
symbols: vec![StoredSymbol {
name: "OrderController".into(),
kind: SymbolKind::Class,
container: Some("Order".into()),
range: Range::default(),
}],
..Default::default()
};
let mut data = IndexData::default();
let (entry, symbols) =
FileEntry::new(std::path::PathBuf::from("/p/src/Order.rs"), "src/Order.rs".into(), record);
data.insert(entry, symbols);
// Content roots outside the project root are rescanned, not snapshotted.
let (entry, symbols) =
FileEntry::new(std::path::PathBuf::from("/elsewhere/x.rs"), "x.rs".into(), FileRecord::default());
data.insert(entry, symbols);
store
.save_snapshot(&data, &PathFilter::new(Path::new("/p"), &Vec::new().into(), &[]), SystemTime::now())
.unwrap();
let snapshot = store.load_snapshot().expect("snapshot");
assert_eq!(snapshot.len(), 1);
assert_eq!(snapshot.rel(0), "src/Order.rs");
let (entry, symbols) = snapshot.entry(0, Path::new("/p"));
assert_eq!(&*entry.rel, "src/Order.rs");
assert_eq!(symbols.len(), 1);
assert_eq!(symbols[0].name, "OrderController");
assert_eq!(symbols[0].container.as_deref(), Some("Order"));
store.apply(&[], &[], false).unwrap();
assert!(store.load_snapshot().is_none(), "a file change invalidates the snapshot");
}
#[test]
fn snapshot_validation_rejects_bad_paths_and_split_utf8() {
let escaping =
Snapshot { rels: "../x".into(), rel_ends: vec![4], meta: vec![FileMeta::default()], ..Default::default() };
assert!(!escaping.valid());
let split =
Snapshot { rels: "é".into(), rel_ends: vec![1], meta: vec![FileMeta::default()], ..Default::default() };
assert!(!split.valid());
}
}
+128
Переглянути файл
@@ -772,6 +772,95 @@ fn persisted_symbols_do_not_load_through_escaping_symlink_ancestors() {
assert_eq!(initial.lock()[0], 0, "unsafe cached symbols must never be published");
}
#[test]
fn incremental_changes_refresh_the_warm_start_snapshot() {
let f = Fixture::new(&[("src/a.rs", "fn a() {}\n")]);
let idx = f.open();
let changed = f.write("src/a.rs", "fn renamed() {}\n");
idx.update(&[changed], &[]);
assert!(idx.wait_idle(WAIT));
std::thread::sleep(Duration::from_millis(150));
assert!(f.cache.join("index.snapshot").exists(), "a watcher update must regenerate the snapshot");
}
#[test]
fn warm_start_detects_changed_ignore_rules() {
let f = Fixture::new(&[("sub/generated/a.rs", "fn a() {}\n"), (".git/HEAD", "ref: refs/heads/main\n")]);
f.write("sub/.gitignore", "");
let idx = f.open();
assert_eq!(idx.status().files, 2, "generated/a.rs plus .gitignore itself");
drop(idx);
std::thread::sleep(Duration::from_millis(20));
f.write("sub/.gitignore", "generated/\n");
let idx = f.open();
assert_eq!(idx.status().files, 1, "an updated ignore rule invalidates the warm snapshot");
}
#[test]
fn warm_start_detects_changed_ignore_rules_above_the_project() {
// The project is a folder of a repository whose ignore files sit above it.
let f = Fixture::new(&[("generated/a.rs", "fn a() {}\n"), ("src/b.rs", "fn b() {}\n")]);
let repo = f.root.parent().unwrap().to_owned();
std::fs::create_dir_all(repo.join(".git/info")).unwrap();
std::fs::write(repo.join(".git/HEAD"), "ref: refs/heads/main\n").unwrap();
let idx = f.open();
assert_eq!(idx.status().files, 2);
drop(idx);
std::fs::write(repo.join(".gitignore"), "proj/generated/\n").unwrap();
let idx = f.open();
assert_eq!(idx.status().files, 1, "a new ignore file above the project invalidates the warm snapshot");
drop(idx);
std::thread::sleep(Duration::from_millis(20));
std::fs::write(repo.join(".gitignore"), "").unwrap();
std::fs::write(repo.join(".git/info/exclude"), "proj/src/\n").unwrap();
let idx = f.open();
assert_eq!(idx.status().files, 1, "so do the enclosing repository's changed rules");
assert!(!symbols(&idx, "a", SymbolFilter::All).is_empty());
}
#[test]
fn warm_start_detects_a_changed_exclude_file_of_a_linked_worktree() {
// The project is a linked worktree: its `.git` file points into the main repository,
// whose shared `info/exclude` the walker reads through `commondir`.
let f = Fixture::new(&[("generated/a.rs", "fn a() {}\n"), ("src/b.rs", "fn b() {}\n")]);
let main = f.root.parent().unwrap().join("main/.git");
let worktree = main.join("worktrees/proj");
std::fs::create_dir_all(main.join("info")).unwrap();
std::fs::create_dir_all(&worktree).unwrap();
std::fs::write(main.join("HEAD"), "ref: refs/heads/main\n").unwrap();
std::fs::write(worktree.join("HEAD"), "ref: refs/heads/wt\n").unwrap();
std::fs::write(worktree.join("commondir"), "../..\n").unwrap();
std::fs::write(f.root.join(".git"), format!("gitdir: {}\n", worktree.display())).unwrap();
let idx = f.open();
assert_eq!(idx.status().files, 2);
drop(idx);
std::fs::write(main.join("info/exclude"), "generated/\n").unwrap();
let idx = f.open();
assert_eq!(idx.status().files, 1, "the shared exclude file invalidates the warm snapshot");
}
#[test]
fn warm_start_detects_a_new_file_in_an_existing_empty_directory() {
let f = Fixture::new(&[("src/a.rs", "fn a() {}\n")]);
std::fs::create_dir(f.root.join("empty")).unwrap();
let idx = f.open();
assert_eq!(idx.status().files, 1);
assert!(idx.find_dirs("empty", 10).is_empty());
drop(idx);
let idx = f.open();
assert!(idx.find_dirs("empty", 10).is_empty(), "empty folders are not searchable after warm start");
drop(idx);
f.write("empty/new.rs", "fn new_symbol() {}\n");
let idx = f.open();
assert_eq!(idx.status().files, 2);
assert_eq!(symbols(&idx, "new_symbol", SymbolFilter::All), ["new_symbol"]);
}
#[test]
fn rule_changes_refresh_only_the_affected_index_subtree() {
for (rule, subtree) in [(".git/info/exclude", ""), ("sub/.gitignore", "sub"), ("sub/.ignore", "sub")] {
@@ -834,3 +923,42 @@ fn repository_rule_changes_refresh_members_of_an_unindexed_container() {
assert!(idx.wait_idle(WAIT));
assert_eq!(files(&idx, "item.txt"), ["member/generated/item.txt"]);
}
#[test]
fn warm_start_sees_edits_deletions_and_renames_made_while_closed() {
let f = Fixture::new(&[
("src/a.rs", "fn aaaa() {}\n"),
("src/deep/b.rs", "fn bbbb() {}\n"),
("c.rs", "fn cccc() {}\n"),
]);
let idx = f.open();
assert!(idx.wait_idle(WAIT));
drop(idx);
std::thread::sleep(Duration::from_millis(200));
// Warm start of an unchanged tree keeps everything.
let idx = f.open();
assert!(idx.wait_idle(WAIT));
assert_eq!(symbols(&idx, "aaaa", SymbolFilter::All), ["aaaa"]);
drop(idx);
std::thread::sleep(Duration::from_millis(20));
// Same size, new content; a deletion; a new file two levels down; a rename.
f.write("src/a.rs", "fn zzzz() {}\n");
std::fs::remove_file(f.root.join("c.rs")).unwrap();
f.write("src/deep/deeper/n.rs", "fn nnnn() {}\n");
std::fs::rename(f.root.join("src/deep/b.rs"), f.root.join("src/deep/r.rs")).unwrap();
let idx = f.open();
assert!(idx.wait_idle(WAIT));
assert!(symbols(&idx, "aaaa", SymbolFilter::All).is_empty(), "stale content");
assert_eq!(symbols(&idx, "zzzz", SymbolFilter::All), ["zzzz"]);
assert!(symbols(&idx, "cccc", SymbolFilter::All).is_empty(), "deleted file");
assert_eq!(symbols(&idx, "nnnn", SymbolFilter::All), ["nnnn"]);
assert_eq!(symbols(&idx, "bbbb", SymbolFilter::All), ["bbbb"]);
assert_eq!(idx.status().files, 3);
drop(idx);
// After the cold rescan, the next warm start agrees.
std::thread::sleep(Duration::from_millis(200));
let idx = f.open();
assert!(idx.wait_idle(WAIT));
assert_eq!(idx.status().files, 3);
assert_eq!(symbols(&idx, "zzzz", SymbolFilter::All), ["zzzz"]);
}
+308 -43
Переглянути файл
@@ -1,8 +1,8 @@
//! The background indexing thread: initial load + full scan, then incremental
//! updates queued by the watcher.
use crate::data::{FileEntry, FileRecord, IndexData, StoredSymbol};
use crate::filter::{Found, PathFilter};
use crate::data::{FileEntry, FileRecord, IndexData, StoredSymbol, safe_rel};
use crate::filter::{Found, PathFilter, mtime_of};
use crate::store::Store;
use ide_core::{IdeEvent, SharedSink, TaskId};
use ide_language::{LanguageRegistry, Syntax};
@@ -12,12 +12,16 @@ use std::collections::{HashMap, HashSet, VecDeque};
use std::io::Read;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
use std::time::{Duration, Instant, SystemTime};
/// Files larger than this are listed but not parsed for symbols.
pub const MAX_SYMBOL_FILE: u64 = 2 * 1024 * 1024;
const CHUNK: usize = 512;
const PROGRESS_EVERY: Duration = Duration::from_millis(250);
#[cfg(not(test))]
const SNAPSHOT_DELAY: Duration = Duration::from_secs(2);
#[cfg(test)]
const SNAPSHOT_DELAY: Duration = Duration::from_millis(50);
pub(crate) const MAX_RECENT: usize = 50;
pub(crate) enum Job {
@@ -55,6 +59,11 @@ pub(crate) struct Shared {
queue: Mutex<Queue>,
wake: Condvar,
idle: Condvar,
/// Coalesced post-update snapshot refresh.
snapshot_due: Mutex<Option<Instant>>,
/// Directories whose watcher events still need reconciliation before a
/// refreshed snapshot can be trusted.
pending_dirs: Mutex<HashSet<PathBuf>>,
}
impl Shared {
@@ -79,6 +88,8 @@ impl Shared {
queue: Mutex::default(),
wake: Condvar::new(),
idle: Condvar::new(),
snapshot_due: Mutex::default(),
pending_dirs: Mutex::default(),
}
}
@@ -135,8 +146,15 @@ impl Shared {
q.busy = true;
}
if scan {
self.load_persisted();
self.full_scan();
// A warm start can trust the snapshot when neither the scope nor any
// directory/file metadata changed. Rebuilding the walk's file list is
// the expensive part; validate the flat manifest first.
if !self.try_fast_start() {
let snapshot_guard = SystemTime::now();
let found = self.walk_roots(None);
self.load_persisted(&found);
self.full_scan(Some(found), snapshot_guard);
}
} else {
self.indexing.store(false, Ordering::SeqCst);
}
@@ -147,17 +165,38 @@ impl Shared {
self.idle.notify_all();
}
loop {
let mut save_due = false;
let jobs: Vec<Job> = {
let mut q = self.queue.lock();
while q.jobs.is_empty() && !self.is_cancelled() {
self.wake.wait(&mut q);
loop {
if self.is_cancelled() {
return;
}
if !q.jobs.is_empty() {
break;
}
let due = *self.snapshot_due.lock();
if let Some(due) = due {
if Instant::now() >= due {
save_due = true;
break;
}
self.wake.wait_until(&mut q, due);
} else {
self.wake.wait(&mut q);
}
}
if self.is_cancelled() {
return;
if save_due {
Vec::new()
} else {
q.busy = true;
q.jobs.drain(..).collect()
}
q.busy = true;
q.jobs.drain(..).collect()
};
if save_due {
self.save_snapshot_now();
continue;
}
self.process(jobs);
let mut q = self.queue.lock();
q.busy = false;
@@ -167,27 +206,114 @@ impl Shared {
}
}
fn load_persisted(&self) {
let Some(store) = &self.store else { return };
/// Warm start: validate the snapshot's flat directory/file manifest and hydrate
/// without walking the tree. A changed scope, symlink, directory mtime, file
/// mtime or file size falls back to the full walk.
fn try_fast_start(&self) -> bool {
let started = Instant::now();
let Some(store) = &self.store else { return false };
let load_started = Instant::now();
let Some(snapshot) = store.load_snapshot() else { return false };
let loaded = load_started.elapsed();
let filter = self.filter();
let root = filter.root().to_owned();
match store.load() {
Ok(records) => {
let entries: Vec<(FileEntry, Vec<StoredSymbol>)> = records
.into_par_iter()
.filter(|(rel, _)| {
let path = root.join(rel);
!filter.is_excluded(&path, false) && dunce::canonicalize(&path).is_ok_and(|real| real == path)
})
.map(|(rel, rec)| FileEntry::new(root.join(&rel), rel, rec))
.collect();
let mut d = self.data.write();
for (e, symbols) in entries {
d.insert(e, symbols);
}
}
Err(e) => tracing::warn!("index cache load failed: {e}"),
if filter.has_extra_roots() || snapshot.filter() != filter.stamp() {
return false;
}
let root = filter.root();
for i in 0..snapshot.dirs_len() {
let rel = snapshot.dir(i);
let path = if rel.is_empty() { root.to_owned() } else { root.join(rel) };
let Ok(meta) = std::fs::symlink_metadata(&path) else { return false };
if !meta.is_dir() || mtime_of(&meta) != snapshot.dir_mtime(i) {
return false;
}
}
let external = crate::store::external_ignore_candidates(root);
let saved_external: Vec<&str> = (0..snapshot.ignore_len())
.filter(|&i| snapshot.ignore_external(i))
.map(|i| snapshot.ignore_path(i))
.collect();
if external.len() != saved_external.len()
|| external.iter().zip(&saved_external).any(|(path, saved)| path.to_string_lossy() != **saved)
{
return false;
}
for i in 0..snapshot.ignore_len() {
let path = Path::new(snapshot.ignore_path(i));
let meta = std::fs::metadata(path).ok();
let present = meta.is_some();
if present != snapshot.ignore_present(i) {
return false;
}
if let Some(meta) = meta
&& (mtime_of(&meta) != snapshot.ignore_mtime(i) || meta.len() != snapshot.ignore_size(i))
{
return false;
}
}
let build_started = Instant::now();
let entries: Vec<Option<(FileEntry, Vec<StoredSymbol>)>> = (0..snapshot.len())
.into_par_iter()
.map(|i| {
let path = root.join(snapshot.rel(i));
let meta = std::fs::symlink_metadata(&path).ok()?;
if !meta.is_file() || mtime_of(&meta) != snapshot.mtime(i) || meta.len() != snapshot.size(i) {
return None;
}
Some(snapshot.entry(i, root))
})
.collect();
let built = build_started.elapsed();
if entries.iter().any(Option::is_none) {
return false;
}
let validated = entries;
let mut entries = Vec::with_capacity(validated.len());
for entry in validated {
entries.push(entry.expect("validated above"));
}
let dirs: Vec<(Box<str>, PathBuf, u32)> = (0..snapshot.dirs_len())
.filter_map(|i| {
let rel = snapshot.dir(i);
(!rel.is_empty() && snapshot.dir_count(i) > 0).then(|| {
let key = format!("{rel}/");
(key.into_boxed_str(), root.join(rel), snapshot.dir_count(i))
})
})
.collect();
let insert_started = Instant::now();
let mut d = self.data.write();
d.load_bulk(entries, dirs);
drop(d);
tracing::info!(
"index snapshot: warm start in {:?} (load {loaded:?}, validate/build {built:?}, insert {:?})",
started.elapsed(),
insert_started.elapsed()
);
#[cfg(test)]
eprintln!(
"index snapshot: warm start load {loaded:?}, validate/build {built:?}, insert {:?}, total {:?}",
insert_started.elapsed(),
started.elapsed()
);
self.indexing.store(false, Ordering::SeqCst);
self.emit_state();
let recent = store.load_recent_file().unwrap_or_else(|| {
let recent = store.load_recent();
let _ = store.save_recent_file(&recent);
recent
});
let mut r = self.recent.lock();
for p in recent.into_iter().map(|rel| root.join(rel)) {
if !r.contains(&p) {
r.push(p);
}
}
r.truncate(MAX_RECENT);
true
}
fn load_recent(&self, store: &Store, root: &Path) {
let recent: Vec<PathBuf> = store.load_recent().into_iter().map(|rel| root.join(rel)).collect();
let mut r = self.recent.lock();
for p in recent {
@@ -198,6 +324,72 @@ impl Shared {
r.truncate(MAX_RECENT);
}
fn load_persisted(&self, found: &[Found]) {
let Some(store) = &self.store else { return };
let root = self.filter().root().to_owned();
let seen: HashSet<&Path> = found.iter().map(|f| f.path.as_path()).collect();
let started = Instant::now();
match store.load_snapshot() {
Some(snapshot) => {
let entries: Vec<(FileEntry, Vec<StoredSymbol>)> = (0..snapshot.len())
.into_par_iter()
.filter_map(|i| {
let rel = snapshot.rel(i);
if !safe_rel(rel) {
return None;
}
let path = root.join(rel);
seen.contains(path.as_path()).then(|| snapshot.entry(i, &root))
})
.collect();
let mut d = self.data.write();
let symbols = entries.iter().map(|(_, symbols)| symbols.len()).sum();
d.reserve(entries.len(), symbols, 0);
for (e, symbols) in entries {
d.insert(e, symbols);
}
tracing::info!(
"index snapshot: hydrated {} files, {} symbols in {:?}",
d.file_count,
d.symbol_count,
started.elapsed()
);
#[cfg(test)]
eprintln!("index snapshot: hydrated {} files in {:?}", d.file_count, started.elapsed());
}
None => match store.load() {
Ok(records) => {
// Fallback for an index that changed since the snapshot: redb
// records are decoded one file at a time and filtered by the walk.
let entries: Vec<(FileEntry, Vec<StoredSymbol>)> = records
.into_par_iter()
.filter(|(rel, _)| {
safe_rel(rel) && {
let path = root.join(rel);
seen.contains(path.as_path())
}
})
.map(|(rel, rec)| FileEntry::new(root.join(&rel), rel, rec))
.collect();
let mut d = self.data.write();
let symbols = entries.iter().map(|(_, symbols)| symbols.len()).sum();
d.reserve(entries.len(), symbols, 0);
for (e, symbols) in entries {
d.insert(e, symbols);
}
tracing::info!(
"index cache: hydrated {} files, {} symbols in {:?}",
d.file_count,
d.symbol_count,
started.elapsed()
);
}
Err(e) => tracing::warn!("index cache load failed: {e}"),
},
}
self.load_recent(store, &root);
}
/// A cancelled scan (project closed) still ends its progress task and the indexing state.
fn end_scan(&self, task: TaskId) {
self.indexing.store(false, Ordering::SeqCst);
@@ -212,11 +404,24 @@ impl Shared {
self.emit_state();
}
fn full_scan(&self) {
self.scan(None);
fn walk_roots(&self, dirs: Option<&[PathBuf]>) -> Vec<Found> {
let filter = self.filter();
let roots: Vec<&Path> = match dirs {
Some(dirs) => dirs.iter().map(PathBuf::as_path).collect(),
None => filter.roots().collect(),
};
let mut found = Vec::new();
for root in roots {
found.extend(filter.walk(root, &self.cancelled));
}
found
}
fn scan(&self, dirs: Option<&[PathBuf]>) {
fn full_scan(&self, found: Option<Vec<Found>>, guard: SystemTime) {
self.scan(None, found, guard);
}
fn scan(&self, dirs: Option<&[PathBuf]>, prewalked: Option<Vec<Found>>, guard: SystemTime) {
let started = Instant::now();
self.indexing.store(true, Ordering::SeqCst);
self.emit_state();
@@ -231,14 +436,7 @@ impl Shared {
});
let filter = self.filter();
let mut found = Vec::new();
let roots: Vec<&Path> = match dirs {
Some(dirs) => dirs.iter().map(PathBuf::as_path).collect(),
None => filter.roots().collect(),
};
for root in roots {
found.extend(filter.walk(root, &self.cancelled));
}
let found = prewalked.unwrap_or_else(|| self.walk_roots(dirs));
if self.is_cancelled() {
return self.end_scan(task);
}
@@ -285,6 +483,17 @@ impl Shared {
}
}
self.persist(&[], &[], true);
if let Some(store) = &self.store {
let recent: Vec<String> = self.recent.lock().iter().filter_map(|p| filter.store_key(p)).collect();
let d = self.data.read();
if let Err(e) = store.save_snapshot(&d, &filter, guard) {
tracing::warn!("index snapshot: {e}");
}
if let Err(e) = store.save_recent_file(&recent) {
tracing::warn!("index recent snapshot: {e}");
}
}
*self.snapshot_due.lock() = None;
self.indexing.store(false, Ordering::SeqCst);
self.sink.emit(IdeEvent::Task {
id: task,
@@ -347,6 +556,36 @@ impl Shared {
self.persist(&puts, &[], false);
}
fn schedule_snapshot(&self) {
if self.store.is_none() {
return;
}
let mut due = self.snapshot_due.lock();
if due.is_none() {
*due = Some(Instant::now() + SNAPSHOT_DELAY);
}
}
fn save_snapshot_now(&self) {
let dirs: Vec<PathBuf> = self.pending_dirs.lock().drain().collect();
if !dirs.is_empty() {
// Reconcile the directories touched since the last scan before
// recording their mtimes; otherwise a missed watcher event could be
// blessed into the manifest.
self.scan(Some(&dirs), None, SystemTime::now());
return;
}
if let Some(store) = &self.store {
let filter = self.filter();
let guard = SystemTime::now();
let d = self.data.read();
if let Err(e) = store.save_snapshot(&d, &filter, guard) {
tracing::warn!("index snapshot: {e}");
}
}
*self.snapshot_due.lock() = None;
}
fn persist(&self, puts: &[(String, FileRecord)], deletes: &[String], durable: bool) {
if let Some(store) = &self.store
&& let Err(e) = store.apply(puts, deletes, durable)
@@ -406,6 +645,17 @@ impl Shared {
Job::Reparse(before) => reparse.push(before),
}
}
{
let mut pending = self.pending_dirs.lock();
for path in changed.iter().chain(removed.iter()) {
let dir = if path.is_dir() {
path.clone()
} else {
path.parent().map_or_else(|| path.clone(), Path::to_owned)
};
pending.insert(dir);
}
}
if !rule_dirs.is_empty() {
{
let mut filter = self.filter.write();
@@ -420,14 +670,14 @@ impl Shared {
}
}
if !rescan {
self.scan(Some(&roots));
self.scan(Some(&roots), None, SystemTime::now());
changed.retain(|p| !roots.iter().any(|root| p.starts_with(root)));
removed.retain(|p| !roots.iter().any(|root| p.starts_with(root)));
}
}
if rescan {
// The scan also picks up whatever `changed` / `removed` reported.
self.full_scan();
self.full_scan(None, SystemTime::now());
changed.clear();
removed.clear();
}
@@ -522,6 +772,7 @@ impl Shared {
if touched {
self.persist(&[], &[], true);
self.emit_state();
self.schedule_snapshot();
}
}
}
@@ -541,3 +792,17 @@ enum Extracted {
Unchanged,
New(FileRecord),
}
#[cfg(test)]
mod tests {
use crate::data::safe_rel;
#[test]
fn cache_keys_stay_below_the_root() {
assert!(safe_rel("src/main.rs"));
assert!(safe_rel("a/b/c.rs"));
for bad in ["", "/etc/passwd", "../out.rs", "a/../../b.rs", "./a.rs"] {
assert!(!safe_rel(bad), "{bad:?}");
}
}
}
+26
Переглянути файл
@@ -40,6 +40,7 @@ const BUILTIN: &[(&str, &str)] = &[
("groovy", include_str!("../../../languages/groovy.toml")),
("haskell", include_str!("../../../languages/haskell.toml")),
("hcl", include_str!("../../../languages/hcl.toml")),
("helm-template", include_str!("../../../languages/helm-template.toml")),
("html", include_str!("../../../languages/html.toml")),
("http", include_str!("../../../languages/http.toml")),
("ignore", include_str!("../../../languages/ignore.toml")),
@@ -548,6 +549,7 @@ mod tests {
"Groovy",
"Haskell",
"Haxe",
"Helm Template",
"HXML",
"HTTP",
"IDL",
@@ -841,6 +843,30 @@ mod tests {
assert!(reg.is_framework_marker(&root.join("src/manage.py")));
}
#[test]
fn helm_charts_make_yaml_and_notes_in_templates_folders_helm_templates() {
let reg = reg();
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let detect = |file: &str| reg.detect(&root.join(file), None).as_str().to_owned();
std::fs::create_dir_all(root.join("charts/podinfo/templates/tests")).unwrap();
std::fs::create_dir_all(root.join("k8s/templates")).unwrap();
std::fs::write(root.join("charts/podinfo/Chart.yaml"), "apiVersion: v2\nname: podinfo\n").unwrap();
assert_eq!(detect("charts/podinfo/Chart.yaml"), "yaml");
assert_eq!(detect("charts/podinfo/values.yaml"), "yaml");
assert_eq!(detect("charts/podinfo/templates/deployment.yaml"), "helm-template");
assert_eq!(detect("charts/podinfo/templates/tests/connection.yml"), "helm-template");
assert_eq!(detect("charts/podinfo/templates/_helpers.tpl"), "helm-template");
assert_eq!(detect("charts/podinfo/templates/NOTES.txt"), "helm-template");
// No `Chart.yaml` / `Chart.yml` above this `templates` folder.
assert_eq!(detect("k8s/templates/deployment.yaml"), "yaml");
assert_eq!(detect("common.gotmpl"), "helm-template");
// Outside a chart, `.tpl` belongs to other template engines (Smarty, Terraform).
assert_ne!(detect("site/index.tpl"), "helm-template");
assert!(reg.is_framework_marker(&root.join("charts/podinfo/Chart.yaml")));
assert!(reg.is_framework_marker(&root.join("charts/podinfo/Chart.yml")));
}
#[test]
fn the_django_fixture_has_templates_and_a_page() {
let reg = reg();
+122 -1
Переглянути файл
@@ -218,6 +218,37 @@ impl ClientHandler {
true
}
/// Ends every running task of a tsserver that died at its heap limit: the process
/// behind them is gone, so no `end` will ever arrive. Without this the tasks sit
/// as zombies until [`PROGRESS_STALE`] and the server stays "loading", holding
/// empty answers back for [`LOADING_WAIT`] — while vtsls already restarts
/// tsserver, which re-reports its own loading.
fn tsserver_died(&self, mgr: &LspManager) {
let events: Vec<IdeEvent> = {
let mut progress = self.server.progress.lock();
progress
.drain()
.map(|(_, p)| IdeEvent::Task {
id: p.task,
title: p.title,
message: p.message,
fraction: Some(1.0),
done: true,
cancellable: false,
})
.collect()
};
let ended = !events.is_empty();
for event in events {
self.server.emit(mgr.sink(), event);
}
if ended {
self.server.refresh_busy_status(mgr.sink());
self.server.progress_ended();
}
self.server.set_loading(mgr.sink(), false);
}
fn publish_diagnostics(&self, mgr: &LspManager, params: Value) {
// Problems come only from servers used for `diagnostics`.
if self.server.running_gen(self.generation).is_some_and(|run| !run.caps.read().restriction.diagnostics()) {
@@ -410,6 +441,10 @@ impl Handler for ClientHandler {
let text = params.get("message").and_then(Value::as_str).unwrap_or_default();
if self.server.config.tsserver_project && tsserver_ran_out_of_memory(text) {
tracing::warn!("[{}] {text}", self.server.name);
// The tasks of the dead tsserver never see their `end`: end them now
// instead of letting them sit as zombies until the stale window, and
// stop holding queries back — the restarted instance re-reports.
self.tsserver_died(&mgr);
if mgr.first_message(&self.server.name, TSSERVER_OUT_OF_MEMORY) {
mgr.sink().emit(IdeEvent::Notify {
level: NotifyLevel::Error,
@@ -567,7 +602,12 @@ fn merge(base: &mut Value, layer: Value) {
#[cfg(test)]
mod tests {
use super::tsserver_ran_out_of_memory;
use super::*;
use crate::server::Progress;
use ide_core::event::SharedSink;
use ide_core::{LanguageId, TaskId};
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
#[test]
fn tsserver_aborts_are_out_of_memory() {
@@ -579,4 +619,85 @@ mod tests {
assert!(!tsserver_ran_out_of_memory("Code: 134."));
assert!(tsserver_ran_out_of_memory("[error] TSServer exited. Code: 134. Signal: null\n"));
}
/// A vtsls server with two running tasks, one marked stalled, and `loading` set:
/// the state a tsserver OOM leaves behind.
fn loaded_vtsls(events: Arc<Mutex<Vec<IdeEvent>>>) -> (Arc<LspManager>, Arc<crate::server::Server>) {
let sink: SharedSink = {
let events = events.clone();
Arc::new(move |event: IdeEvent| events.lock().unwrap().push(event))
};
let languages = Arc::new(ide_language::LanguageRegistry::from_configs(vec![]));
let mgr = LspManager::new(PathBuf::from("/p"), languages, sink);
let cfg: ide_language::LanguageServerConfig =
toml::from_str("name = \"vtsls\"\ncommand = \"vtsls\"\ntsserver-project = true").unwrap();
let server = Arc::new(crate::server::Server::new(cfg, PathBuf::from("/p"), LanguageId::new("vue")));
server.progress.lock().insert(
"loading".into(),
Progress {
task: TaskId::next(),
title: "Initializing 'tsconfig.json'".into(),
message: None,
cancel: None,
last: tokio::time::Instant::now(),
fraction: Some(0.1),
stalled: false,
},
);
server.progress.lock().insert(
"stuck".into(),
Progress {
task: TaskId::next(),
title: "Analyzing 'index.vue' and its dependencies".into(),
message: None,
cancel: None,
last: tokio::time::Instant::now(),
fraction: Some(0.2),
stalled: true,
},
);
let quiet: SharedSink = Arc::new(|_: IdeEvent| {});
server.set_loading(&quiet, true);
(mgr, server)
}
#[test]
fn tsserver_dying_ends_every_task_and_the_loading_state() {
let events = Arc::new(Mutex::new(Vec::new()));
let (mgr, server) = loaded_vtsls(events.clone());
let handler = ClientHandler::new(Arc::downgrade(&mgr), server.clone(), 0);
handler.tsserver_died(&mgr);
// Both tasks ended (done: true), including the stalled one; none left running.
let tasks: Vec<_> = events
.lock()
.unwrap()
.iter()
.filter_map(|e| match e {
IdeEvent::Task { title, done, .. } => Some((title.clone(), *done)),
_ => None,
})
.collect();
assert_eq!(tasks.len(), 2, "{tasks:?}");
assert!(tasks.iter().all(|(_, done)| *done), "{tasks:?}");
assert!(server.progress.lock().is_empty());
assert!(!server.is_loading());
assert!(!server.is_busy());
}
#[test]
fn tsserver_dying_without_tasks_only_clears_loading() {
let events = Arc::new(Mutex::new(Vec::new()));
let (mgr, server) = loaded_vtsls(events.clone());
server.progress.lock().clear();
let handler = ClientHandler::new(Arc::downgrade(&mgr), server.clone(), 0);
handler.tsserver_died(&mgr);
assert!(events.lock().unwrap().is_empty());
assert!(!server.is_loading());
// A status event may be published, but no task events.
assert!(server.progress.lock().is_empty());
}
}
+47 -13
Переглянути файл
@@ -40,22 +40,37 @@ const LOOSE: &AsciiSet = &CONTROLS
.add(b'|')
.add(b'}');
/// Percent-encodes the characters URIs don't allow in every `file:` URI in `value`, string
/// values and object keys alike (a workspace edit's `changes`). Eclipse's jdtls sends a path
/// as is (`file:///H:/项目/…`), which `lsp_types::Uri` rejects, failing the whole response.
/// Fields of LSP messages whose value is a URI.
const URI_FIELDS: [&str; 9] =
["uri", "targetUri", "newUri", "oldUri", "baseUri", "scopeUri", "rootUri", "target", "href"];
/// Percent-encodes the characters URIs don't allow in the `file:` URIs of `value`: the URI
/// fields and the keys of a workspace edit's `changes`. Eclipse's jdtls sends a path as is
/// (`file:///H:/项目/…`), which `lsp_types::Uri` rejects, failing the whole response. Other
/// strings stay as sent: an edit's `newText` may well start with `file:` (YAML, `.env`).
pub fn encode_loose_file_uris(value: &mut Value) {
match value {
Value::String(s) => {
if let Some(encoded) = encode_loose(s) {
*s = encoded;
}
}
Value::Array(items) => items.iter_mut().for_each(encode_loose_file_uris),
Value::Object(map) => {
if map.keys().any(|k| encode_loose(k).is_some()) {
*map = std::mem::take(map).into_iter().map(|(k, v)| (encode_loose(&k).unwrap_or(k), v)).collect();
for (key, value) in map.iter_mut() {
match value {
Value::String(s) if URI_FIELDS.contains(&key.as_str()) => {
if let Some(encoded) = encode_loose(s) {
*s = encoded;
}
}
Value::Object(changes) if key == "changes" => {
if changes.keys().any(|k| encode_loose(k).is_some()) {
*changes = std::mem::take(changes)
.into_iter()
.map(|(k, v)| (encode_loose(&k).unwrap_or(k), v))
.collect();
}
changes.values_mut().for_each(encode_loose_file_uris);
}
_ => encode_loose_file_uris(value),
}
}
map.values_mut().for_each(encode_loose_file_uris);
}
_ => {}
}
@@ -161,9 +176,28 @@ mod tests {
assert!(v["edit"]["changes"].get("file:///tmp/%E7%9B%AE%E5%BD%95/b.java").is_some());
assert_eq!(v["text"], "项目", "only file: URIs change");
// Already encoded: untouched.
let mut encoded = serde_json::json!("file:///a%20b/%E9%A1%B9.java");
let mut encoded = serde_json::json!({ "uri": "file:///a%20b/%E9%A1%B9.java" });
encode_loose_file_uris(&mut encoded);
assert_eq!(encoded, "file:///a%20b/%E9%A1%B9.java");
assert_eq!(encoded["uri"], "file:///a%20b/%E9%A1%B9.java");
}
#[test]
fn text_that_starts_with_file_is_not_a_uri() {
// A YAML formatting edit and a completion whose text begins with `file:`.
let mut v = serde_json::json!({ "result": [
{ "range": { "start": { "line": 0, "character": 0 }, "end": { "line": 1, "character": 0 } }, "newText": "file: a b\n - c\n" },
{ "label": "file:", "insertText": "file: ${1:path}", "documentation": "file: the input" }
], "edit": { "changes": { "file:///tmp/a b.yaml": [{ "newText": "file: x y" }] } } });
encode_loose_file_uris(&mut v);
assert_eq!(v["result"][0]["newText"], "file: a b\n - c\n");
assert_eq!(v["result"][1]["insertText"], "file: ${1:path}");
assert_eq!(v["result"][1]["documentation"], "file: the input");
let changes = &v["edit"]["changes"]["file:///tmp/a%20b.yaml"];
assert_eq!(changes[0]["newText"], "file: x y");
// A diagnostic's help link is a URI too.
let mut d = serde_json::json!({ "codeDescription": { "href": "file:///tmp/help files/rule.html" } });
encode_loose_file_uris(&mut d);
assert_eq!(d["codeDescription"]["href"], "file:///tmp/help%20files/rule.html");
}
#[cfg(unix)]
+19 -1
Переглянути файл
@@ -5,6 +5,7 @@
use serde::{Deserialize, Serialize};
use std::io;
use std::net::{IpAddr, Ipv4Addr};
use std::path::{Path, PathBuf};
/// The discovery file, relative to the app data directory.
@@ -39,6 +40,9 @@ pub fn kept_token(path: &Path) -> io::Result<String> {
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Discovery {
/// Where a client on this computer connects (the file of an IDE without this field: `127.0.0.1`).
#[serde(default = "loopback")]
pub address: IpAddr,
pub port: u16,
pub token: String,
/// The IDE's process.
@@ -47,6 +51,10 @@ pub struct Discovery {
pub version: String,
}
fn loopback() -> IpAddr {
IpAddr::V4(Ipv4Addr::LOCALHOST)
}
impl Discovery {
pub fn read(path: &Path) -> io::Result<Self> {
let bytes = std::fs::read(path)?;
@@ -103,7 +111,13 @@ mod tests {
fn round_trips_privately_and_only_its_owner_removes_it() {
let tmp = tempfile::tempdir().unwrap();
let path = discovery_path(tmp.path());
let ours = Discovery { port: 1234, token: "t".into(), pid: std::process::id(), version: "0.1.0".into() };
let ours = Discovery {
address: "100.92.72.95".parse().unwrap(),
port: 1234,
token: "t".into(),
pid: std::process::id(),
version: "0.1.0".into(),
};
ours.write(&path).unwrap();
assert_eq!(Discovery::read(&path).unwrap(), ours);
#[cfg(unix)]
@@ -118,6 +132,10 @@ mod tests {
ours.write(&path).unwrap();
Discovery::remove_if_ours(&path);
assert!(!path.exists());
// The file of an IDE that listened on 127.0.0.1 only has no address.
std::fs::write(&path, r#"{"port":1,"token":"t","pid":1,"version":"0.1.0"}"#).unwrap();
assert_eq!(Discovery::read(&path).unwrap().address, loopback());
}
#[test]
+16 -6
Переглянути файл
@@ -2,14 +2,15 @@
//! the IDE's built-in tools.
//!
//! - **Transport:** MCP Streamable HTTP (2025-06-18; 2025-03-26 and 2024-11-05
//! clients too) on `127.0.0.1:<random port>`, endpoint `/mcp`. Responses are
//! clients too) on `127.0.0.1:<random port>` (or another address of
//! [`ServerOptions`]), endpoint `/mcp`. Responses are
//! plain JSON (no SSE streams: `GET` is 405). Every request carries
//! `Authorization: Bearer <token>` (random, per run); requests from another
//! `Origin` or `Host` are refused (DNS rebinding).
//! - **Projects:** the `X-OpenIDEA-Project` header or the `?project=` query
//! names a folder; the server picks the open project containing it.
//! - **Discovery:** `<app-data>/mcp/server.json` (`port`, `token`, `pid`,
//! `version`; readable only by the user) while the server runs.
//! - **Discovery:** `<app-data>/mcp/server.json` (`address`, `port`, `token`,
//! `pid`, `version`; readable only by the user) while the server runs.
//! - **Stdio:** `<app exe> --mcp-stdio [--project <path>]` reads the discovery
//! file and relays newline-delimited JSON-RPC to the server ([`stdio`]), so
//! persisted client configurations hold no token.
@@ -23,7 +24,7 @@ mod server;
pub mod stdio;
pub use discovery::{DISCOVERY_FILE, Discovery, TOKEN_FILE, discovery_path, kept_token, token_path};
pub use server::{McpServer, ServerOptions};
pub use server::{McpServer, ServerOptions, local_address};
pub use tokio_util::sync::CancellationToken;
use std::path::{Path, PathBuf};
@@ -85,9 +86,15 @@ pub trait ToolRegistry: Send + Sync + 'static {
) -> Result<String, ToolCallError>;
}
/// The URL of the server at `port` for `project`.
/// The URL of the server at `127.0.0.1:<port>` for `project`.
pub fn endpoint_url(port: u16, project: &Path) -> String {
format!("http://127.0.0.1:{port}/mcp?project={}", encode_query(&project.to_string_lossy()))
endpoint_url_at(std::net::Ipv4Addr::LOCALHOST.into(), port, project)
}
/// The URL of the server at `<address>:<port>` for `project`.
pub fn endpoint_url_at(address: std::net::IpAddr, port: u16, project: &Path) -> String {
let at = std::net::SocketAddr::new(address, port);
format!("http://{at}/mcp?project={}", encode_query(&project.to_string_lossy()))
}
/// The stdio bridge's command line for `project`: `exe --mcp-stdio --project <project>`.
@@ -133,6 +140,9 @@ mod tests {
fn urls_and_commands_name_the_project() {
let url = endpoint_url(4321, Path::new("/home/me/my app/项目"));
assert_eq!(url, "http://127.0.0.1:4321/mcp?project=/home/me/my%20app/%E9%A1%B9%E7%9B%AE");
let ip = |s: &str| s.parse::<std::net::IpAddr>().unwrap();
assert_eq!(endpoint_url_at(ip("100.92.72.95"), 80, Path::new("/p")), "http://100.92.72.95:80/mcp?project=/p");
assert_eq!(endpoint_url_at(ip("fd7a::1"), 80, Path::new("/p")), "http://[fd7a::1]:80/mcp?project=/p");
assert_eq!(
stdio_command(Path::new("/Applications/OpenIDEA.app/Contents/MacOS/openidea"), Path::new("/p")),
["/Applications/OpenIDEA.app/Contents/MacOS/openidea", "--mcp-stdio", "--project", "/p"]
+81 -20
Переглянути файл
@@ -9,6 +9,7 @@ use hyper::header::{self, HeaderMap, HeaderValue};
use hyper::{Method, Request, Response, StatusCode};
use serde_json::{Value, json};
use std::convert::Infallible;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
use std::path::{Path, PathBuf};
use std::pin::Pin;
use std::sync::Arc;
@@ -40,6 +41,8 @@ type Body = Either<Full<Bytes>, EventStream>;
struct Shared {
registry: Arc<dyn ToolRegistry>,
token: String,
/// The address listened on (`0.0.0.0` / `::`: every address).
address: IpAddr,
port: u16,
requests: protocol::Requests,
cancel: CancellationToken,
@@ -48,6 +51,7 @@ struct Shared {
/// The running server. Stopping it (or dropping it) closes the port and
/// removes its discovery file.
pub struct McpServer {
address: IpAddr,
port: u16,
/// Why the port asked for in [`ServerOptions::port`] was not used.
port_unavailable: Option<String>,
@@ -60,7 +64,10 @@ pub struct McpServer {
/// How [`McpServer::start_with`] listens.
#[derive(Clone, Debug, Default)]
pub struct ServerOptions {
/// Listen on `127.0.0.1:<port>`. When it can't be bound (in use, or reserved by the
/// The address to listen on; `None`: `127.0.0.1`, this computer only. Another address of
/// this computer, or `0.0.0.0` / `::` for every address, lets other computers connect.
pub address: Option<IpAddr>,
/// Listen on `<address>:<port>`. When it can't be bound (in use, or reserved by the
/// system), a free port is taken instead and [`McpServer::port_unavailable`] says why.
/// `None`: a free port.
pub port: Option<u16>,
@@ -86,15 +93,20 @@ impl McpServer {
runtime: &tokio::runtime::Handle,
options: ServerOptions,
) -> std::io::Result<Self> {
let address = options.address.unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST));
let bind = |port: u16| {
std::net::TcpListener::bind(SocketAddr::new(address, port))
.map_err(|e| std::io::Error::new(e.kind(), format!("{}: {e}", SocketAddr::new(address, port))))
};
let (listener, port_unavailable) = match options.port {
Some(port) => match std::net::TcpListener::bind(("127.0.0.1", port)) {
Some(port) => match bind(port) {
Ok(listener) => (listener, None),
Err(e) => {
tracing::warn!("MCP server: port {port} unavailable ({e}); taking a free one");
(std::net::TcpListener::bind(("127.0.0.1", 0))?, Some(format!("port {port}: {e}")))
(bind(0)?, Some(format!("port {port}: {e}")))
}
},
None => (std::net::TcpListener::bind(("127.0.0.1", 0))?, None),
None => (bind(0)?, None),
};
listener.set_nonblocking(true)?;
let port = listener.local_addr()?.port();
@@ -108,6 +120,7 @@ impl McpServer {
};
if let Some(path) = &discovery {
let info = Discovery {
address: local_address(address),
port,
token: token.clone(),
pid: std::process::id(),
@@ -120,13 +133,19 @@ impl McpServer {
let shared = Arc::new(Shared {
registry,
token: token.clone(),
address,
port,
requests: Default::default(),
cancel: cancel.clone(),
});
runtime.spawn(accept(listener, shared, stopped));
tracing::info!("MCP server on 127.0.0.1:{port}");
Ok(Self { port, port_unavailable, token, discovery, shutdown, cancel })
tracing::info!("MCP server on {}", SocketAddr::new(address, port));
Ok(Self { address, port, port_unavailable, token, discovery, shutdown, cancel })
}
/// The address listened on (`0.0.0.0` / `::`: every address).
pub fn address(&self) -> IpAddr {
self.address
}
pub fn port(&self) -> u16 {
@@ -143,14 +162,24 @@ impl McpServer {
&self.token
}
/// The endpoint URL for `project`.
/// The endpoint URL for `project`, at the address a client on this computer connects to.
pub fn url(&self, project: &Path) -> String {
crate::endpoint_url(self.port, project)
crate::endpoint_url_at(local_address(self.address), self.port, project)
}
pub fn stop(self) {}
}
/// The address a client on this computer connects to for a server listening on `address`:
/// loopback for every address (`0.0.0.0` / `::`), else `address` itself.
pub fn local_address(address: IpAddr) -> IpAddr {
match address {
IpAddr::V4(a) if a.is_unspecified() => IpAddr::V4(Ipv4Addr::LOCALHOST),
IpAddr::V6(a) if a.is_unspecified() => IpAddr::V6(Ipv6Addr::LOCALHOST),
a => a,
}
}
impl Drop for McpServer {
fn drop(&mut self) {
self.cancel.cancel();
@@ -212,7 +241,7 @@ async fn respond(req: Request<Incoming>, shared: &Arc<Shared>, scope: String) ->
if req.uri().path() != "/mcp" {
return plain(StatusCode::NOT_FOUND, "not found: the endpoint is /mcp");
}
if !local_request(req.headers(), shared.port) {
if !local_request(req.headers(), shared.address, shared.port) {
return plain(StatusCode::FORBIDDEN, "requests from web pages are refused");
}
if !authorized(req.headers(), &shared.token) {
@@ -343,11 +372,16 @@ impl hyper::body::Body for EventStream {
}
}
/// No `Origin` other than this server's own, and a loopback `Host`: a web
/// page (DNS rebinding) can't reach the tools even before the token check.
fn local_request(headers: &HeaderMap, port: u16) -> bool {
/// No `Origin` other than this server's own, and a `Host` naming this server by IP address
/// (loopback, the address listened on, or any when listening on every address) or as
/// `localhost`: never another host name, so a web page (DNS rebinding) can't reach the tools
/// even before the token check.
fn local_request(headers: &HeaderMap, address: IpAddr, port: u16) -> bool {
let ours = |authority: &str| {
["127.0.0.1", "localhost", "[::1]"].iter().any(|host| authority.eq_ignore_ascii_case(&format!("{host}:{port}")))
authority.eq_ignore_ascii_case(&format!("localhost:{port}"))
|| authority.parse::<SocketAddr>().is_ok_and(|a| {
a.port() == port && (a.ip().is_loopback() || a.ip() == address || address.is_unspecified())
})
};
let host_ok = headers.get(header::HOST).is_none_or(|h| h.to_str().is_ok_and(ours));
let origin_ok = headers.get(header::ORIGIN).is_none_or(|o| {
@@ -534,7 +568,7 @@ mod tests {
}
#[test]
fn only_loopback_hosts_and_origins_of_this_server_pass() {
fn only_hosts_and_origins_of_this_server_pass() {
let headers = |pairs: &[(header::HeaderName, &str)]| {
let mut h = HeaderMap::new();
for (k, v) in pairs {
@@ -542,16 +576,43 @@ mod tests {
}
h
};
assert!(local_request(&headers(&[]), 80));
assert!(local_request(&headers(&[(header::HOST, "127.0.0.1:80")]), 80));
let loopback = IpAddr::V4(Ipv4Addr::LOCALHOST);
let host = |h: &str, address: IpAddr| local_request(&headers(&[(header::HOST, h)]), address, 80);
assert!(local_request(&headers(&[]), loopback, 80));
assert!(host("127.0.0.1:80", loopback));
assert!(host("[::1]:80", loopback));
assert!(local_request(
&headers(&[(header::HOST, "localhost:80"), (header::ORIGIN, "http://localhost:80")]),
loopback,
80
));
assert!(!local_request(&headers(&[(header::HOST, "evil.example:80")]), 80));
assert!(!local_request(&headers(&[(header::HOST, "127.0.0.1:81")]), 80));
assert!(!local_request(&headers(&[(header::ORIGIN, "https://evil.example")]), 80));
assert!(!local_request(&headers(&[(header::ORIGIN, "null")]), 80));
assert!(!host("evil.example:80", loopback));
assert!(!host("127.0.0.1:81", loopback));
assert!(!host("100.92.72.95:80", loopback), "another address of a loopback server");
assert!(!local_request(&headers(&[(header::ORIGIN, "https://evil.example")]), loopback, 80));
assert!(!local_request(&headers(&[(header::ORIGIN, "null")]), loopback, 80));
// Listening on one address: that address too, still never a host name.
let lan: IpAddr = "100.92.72.95".parse().unwrap();
assert!(host("100.92.72.95:80", lan) && host("127.0.0.1:80", lan));
assert!(!host("100.92.72.96:80", lan) && !host("evil.example:80", lan));
// Listening on every address: any IP address, still never a host name.
let every: IpAddr = "0.0.0.0".parse().unwrap();
assert!(host("192.168.1.7:80", every) && host("[fe80::1]:80", every));
assert!(!host("evil.example:80", every) && !host("192.168.1.7:81", every));
assert!(local_request(
&headers(&[(header::HOST, "192.168.1.7:80"), (header::ORIGIN, "http://192.168.1.7:80")]),
every,
80
));
}
#[test]
fn clients_on_this_computer_connect_to_loopback_for_every_address() {
let ip = |s: &str| s.parse::<IpAddr>().unwrap();
assert_eq!(local_address(ip("0.0.0.0")), ip("127.0.0.1"));
assert_eq!(local_address(ip("::")), ip("::1"));
assert_eq!(local_address(ip("100.92.72.95")), ip("100.92.72.95"));
}
#[test]
+3 -2
Переглянути файл
@@ -148,13 +148,14 @@ async fn post(
)
};
let info = Discovery::read(discovery).map_err(|_| not_running())?;
let stream = tokio::net::TcpStream::connect(("127.0.0.1", info.port)).await.map_err(|_| not_running())?;
let at = std::net::SocketAddr::new(info.address, info.port);
let stream = tokio::net::TcpStream::connect(at).await.map_err(|_| not_running())?;
let (mut sender, conn) = hyper::client::conn::http1::handshake(hyper_util::rt::TokioIo::new(stream))
.await
.map_err(|e| format!("cannot reach OpenIDEA: {e}"))?;
tokio::spawn(conn);
let request = Request::post(format!("/mcp?project={}", crate::encode_query(&project.to_string_lossy())))
.header(header::HOST, format!("127.0.0.1:{}", info.port))
.header(header::HOST, at.to_string())
.header(header::AUTHORIZATION, format!("Bearer {}", info.token))
.header("mcp-session-id", scope)
.header(header::CONTENT_TYPE, "application/json")
+32 -1
Переглянути файл
@@ -373,7 +373,7 @@ async fn a_fixed_port_and_token_are_used_and_a_taken_port_falls_back_to_a_free_o
s.server.take().unwrap().stop();
let free = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap().local_addr().unwrap().port();
let token = "ab".repeat(32);
let options = ServerOptions { port: Some(free), token: Some(token.clone()) };
let options = ServerOptions { port: Some(free), token: Some(token.clone()), ..Default::default() };
let registry = Arc::new(Registry { project: s.project.clone() });
let runtime = tokio::runtime::Handle::current();
s.server =
@@ -393,3 +393,34 @@ async fn a_fixed_port_and_token_are_used_and_a_taken_port_falls_back_to_a_free_o
second.port_unavailable()
);
}
#[tokio::test(flavor = "multi_thread")]
async fn another_listen_address_is_used_and_one_not_on_this_computer_fails() {
let mut s = setup();
s.server.take().unwrap().stop();
let registry = Arc::new(Registry { project: s.project.clone() });
let runtime = tokio::runtime::Handle::current();
// Every address: clients here use loopback; other computers name the server by an address of it.
let every = ServerOptions { address: Some("0.0.0.0".parse().unwrap()), ..Default::default() };
s.server = Some(McpServer::start_with(registry.clone(), Some(s.discovery.clone()), &runtime, every).unwrap());
let port = s.server().port();
assert_eq!(s.server().address().to_string(), "0.0.0.0");
assert!(s.server().url(&s.project).starts_with(&format!("http://127.0.0.1:{port}/mcp?")));
assert_eq!(Discovery::read(&s.discovery).unwrap().address.to_string(), "127.0.0.1");
let ping = json!({ "jsonrpc": "2.0", "id": 1, "method": "ping" }).to_string();
let request = |host: String| {
format!(
"POST /mcp HTTP/1.1\r\nHost: {host}\r\n{}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{ping}",
bearer(&s),
ping.len()
)
};
assert_eq!(http(port, &request(format!("192.0.2.7:{port}"))).await.0, 200);
assert_eq!(http(port, &request(format!("evil.example:{port}"))).await.0, 403, "never a host name");
// Not an address of this computer: the server does not start, and says which address.
let elsewhere = ServerOptions { address: Some("192.0.2.1".parse().unwrap()), ..Default::default() };
let error = McpServer::start_with(registry, None, &runtime, elsewhere).err().expect("no such address here");
assert!(error.to_string().contains("192.0.2.1"), "{error}");
}
+5
Переглянути файл
@@ -234,6 +234,11 @@ pub trait HostApi: Send + Sync {
let _ = (path, set, item);
Err(HostError::Failed("decorations unavailable".into()))
}
/// Replace all `(path, icon)` entries of a tree decoration set at once (API 1.8).
fn file_icon_decorations(&self, set: &str, items: Vec<(PathBuf, String)>) -> Result<(), HostError> {
let _ = (set, items);
Err(HostError::Failed("decorations unavailable".into()))
}
/// Trusted repository history.
fn vcs_log(
&self,
+3
Переглянути файл
@@ -932,6 +932,9 @@ pub struct FileDecoration {
/// Text shown after the name (API 1.7 `set-file-note`).
#[serde(default)]
pub note: Option<String>,
/// `[[contributes.fileIcons]]` id for this file or folder (API 1.8 `set-file-icon`).
#[serde(default)]
pub icon: Option<String>,
}
/// Commit metadata, date in Unix seconds.
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
+35 -2
Переглянути файл
@@ -2505,7 +2505,13 @@ impl bindings::openidea::plugin::decorations::Host for StoreData {
.file_decorations(
Some(&real),
&set_id,
Some(crate::FileDecoration { badge: item.badge, style: item.style, tooltip: item.tooltip, note: None }),
Some(crate::FileDecoration {
badge: item.badge,
style: item.style,
tooltip: item.tooltip,
note: None,
icon: None,
}),
)
.map_err(to_wit))
}
@@ -2523,9 +2529,36 @@ impl bindings::openidea::plugin::decorations::Host for StoreData {
Ok(real) => real,
Err(e) => return Ok(Err(e)),
};
let item = crate::FileDecoration { badge: None, style: String::new(), tooltip, note: Some(note) };
let item = crate::FileDecoration { badge: None, style: String::new(), tooltip, note: Some(note), icon: None };
Ok(self.host.file_decorations(Some(&real), &set_id, Some(item)).map_err(to_wit))
}
fn set_file_icon(&mut self, path: String, set_id: String, icon: String, tooltip: Option<String>) -> Fallible<()> {
if let Err(e) = self.require(Capability::ProjectRead) {
return Ok(Err(e));
}
let real = match self.decoration_path(&path) {
Ok(real) => real,
Err(e) => return Ok(Err(e)),
};
let item = crate::FileDecoration { badge: None, style: String::new(), tooltip, note: None, icon: Some(icon) };
Ok(self.host.file_decorations(Some(&real), &set_id, Some(item)).map_err(to_wit))
}
fn set_file_icons(&mut self, set_id: String, items: Vec<(String, String)>) -> Fallible<()> {
if let Err(e) = self.require(Capability::ProjectRead) {
return Ok(Err(e));
}
if items.len() > 10_000 {
return Ok(Err(wit::Error::Invalid("at most 10000 file icons per set".into())));
}
let mut resolved = Vec::with_capacity(items.len());
for (path, icon) in items {
match self.decoration_path(&path) {
Ok(real) => resolved.push((real, icon)),
Err(e) => return Ok(Err(e)),
}
}
Ok(self.host.file_icon_decorations(&set_id, resolved).map_err(to_wit))
}
}
pub(crate) mod language_features_bindings {
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
6c50332c868a890b91a7f92ef7f92e76a4818b7435291e99c77c0b9f0931b81c
e616b87b24371581ad648f9c7072259de7732d122b5887b488f7bd480ebd69c2
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
fdcb208365db90bcd06d0d0df3d436b69d1229c40f7c63dd6f1ab768e0d236b8
75eeecc467bff141bda14f1f080633348af348f7259e793be81e6548d2008669
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
3108abe8d381b224ec4b44ecf4c43c045671bd5e221ee21582e25497f8c14dff
4aaf7754e895f022d34dad05bd2b8018e3a61ba18771b8c95d9f070cb212530c
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
876a84b7da5a4b27fce47cf71a4dc28eb1ea6c9d54283c1b73a2ae2411653bf5
e573144879a7caa0975e17b233476eb317ff54bef23fd8fa64280d3649bad700
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
234dc693e01e484b91fa7f2d0ac0e7757fb69c9fba81f06bf8ef1cd3624228d8
36dd40ef751a617f1ad59491312889c12481b05666e580f21f7c2552df76edbc
+2 -6
Переглянути файл
@@ -39,12 +39,8 @@ impl Plugin for Fixture {
hover: None,
after: Some(after.into()),
};
host::set_decorations(
path,
"fixture",
&[item("typo", "typo message"), item("weak", "weak message")],
)
.map(|()| None)
host::set_decorations(path, "fixture", &[item("typo", "typo message"), item("weak", "weak message")])
.map(|()| None)
}
_ => Err(Error::NotFound(id.into())),
}
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
927c329fd21d12fd5f423b937e1be756be45a4c6dceee08679e762d5d9f511f2
724ed3bbd828e4fc1925c1b555bdaec9268633cb587806a661f8838e5a540545
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
e7c77fde915e0765f91929dba2aa8eff606f2302e078b7305d272a4de5b9d80b
1a3685234f1b3b0957184c44c603c0eefe7e3084b88d0df32da268b29292ee92
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
3feb9df8b408dedbc3586864db00b92c8cb244c8ad5b8f10c95edb8eb3d30df1
4283de821d69a18438f58062e4bc28b63ba5025cc5f9fa51f21ff475b677a6a5
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
3bfce29f90b9d2f20082b9bdd0957c6c58c265a346305ec948f514537a4579c8
0f25487b8e488e5ec01fabd2951bdb03d80bc26e4e9aa508394a7f9cd09d1759
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
ca72c5cfa2f76a93bc07495d50e58181a9c6c2fc75854cf548bb0278814ddb87
80f9dc28e4f168dd22ccee1c53e1f6b08c446622eb10f07de29be5fc45e9fb1c
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
8a4a7df8fd6cf397750ac24cedf06174ed50e4214ef44029e29f7d0557d426ba
5355cb4ef373eba3c54806eb00294fcd5fccb3cf05dae3e1c2a4ad45ab958f03
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
8d12313675e0869e087ac4c79bdfb1a9738867e28af2437eff27cb07254f572b
fc75ede42e6db7619fa28fc8999f091d39e3c1ed401356db3a3f07de54287c99
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
1b2ad52d4cbb4abb662be5772f050844836f8e3f0c525edd0de19ff45e95a148
8793601496f1727cf912e4cdf86ec48fc340a17585098e98dee52a125cb8a16d
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
13babf73d8d3d029aba26641b01a4add3abb3dab84394d8bd676edb064dc0c19
fb2db14a645cad42941d20a25a3ff87ca6fdee635fdb40953383ff30ce16af8a
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
a7790bf28783d57ed47f635a58981b80ddaecd7074cf88c539465b57132a9f8f
fc66b3b56a1ab1c9d547e3efe6bb4693dca908bb18be8b8ebd26f2fb41e4436c
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
af7070f6ca3a14cf480260aa21b7a6e95038996d224d40e53a487c96a8f3acf2
c76830df9dee3caba932ffaddaabce88ac7ef23b3957b3e8e570b4939dd58d9d
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
16930ecfe74d096e6dcaa5fbf35664128b4d3844f47993a947aabe9d2a9650de
3a41a06d54939a9e85fcf6b0bf15b336f928a3caf66576bd2da62c5b8b177c1d
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
25eaf7ac2837ade4b3204698d3af41854324ece6ec196daba84bea6bb41f6cde
f30a8c0a7893519872628c001314efd567f87f4dfaf718a737ea3631b55881b0
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
f7cd477be706ee5da2656ffb5435cb2b52443aec1270b3b1d804b2bdc6ed8bc8
cb65533984ffd8684c9e9f5ddeef92a8f387735c12310b25282ee7bbf2d73cd7
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
c97989134a8e8a0a12013909f4726288b6d9cdaa44d02a58ff505a6cbb1fb9a1
d11f9cb1130f3f3aaa89514bf47ec8ece9b2ca0edf1d82c11f80b262325f16be
Loaded 100 of 457 files, more files were not shown because too many files have changed in this diff. Show more