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

...
Автор SHA1 Повідомлення Дата
l0ng-ai 7c9ff885dd fix(debugger): let a Python program read input while it is debugged
debugpy launched the program with console "internalConsole", which gives it no
standard input: input() raised EOFError right after printing its prompt. The
program now runs on the pseudo-terminal the debugger already provides for
runInTerminal, so what is typed in the debug console reaches it, as in a Run.
Its output reaches the console from that terminal, so debugpy no longer
captures it a second time (redirectOutput).
2026-11-14 21:33:53 +08:00
l0ng-ai 4e90d9fd5c fix: keep a JAR application's derived Windows java.exe off saved configurations; fix three Windows-only tests (#3368)
Saving a JAR Application configuration on Windows stored the project JDK's full ...\bin\java.exe instead of the generated java, so a later Project JDK change no longer reached it: undoing a derived command only tried the file name. The name without .exe is tried first now.

Windows-only test failures on main: the plugin fuel test's 3_500 budget did not cover activation on Windows (6_000 still proves the per-report refill, which patient needs about 9_000 without); the class-folder test built JDT's memento from a native path; the download test passed its missing remote path with backslashes.
2026-11-14 21:26:01 +08:00
pravdomil ffc2f75d3b fix(markdown): restore the Preview only scroll position when going Back after clicking a link to another Markdown file (#3363)
In Preview only, navigation history recorded the hidden editor's caret, and the preview's caret-follow effect then scrolled to that line over the offset the preview had restored. History now records the line the preview shows, the current entry is refreshed before opening another file, and the preview keeps its restored offset when the caret is already on the line it shows.

Co-authored-by: pravdomil <[email protected]>
2026-11-14 21:25:18 +08:00
Akbar Husain 19587af8b1 fix(vcs): Rollback acts on the checked changes when the selection has none (#3330)
Toolbar Rollback in the Commit window resolved its targets from the tree selection only, so with changes checked but an unversioned file selected it reported "No changes to roll back". The selection is used while it holds rollbackable changes; otherwise the checked changes are.

Co-authored-by: akbar1214 <[email protected]>
2026-11-14 21:25:12 +08:00
akshathayadav378-boopтаakshathayadav378-boop b2c22ccec6 fix(ui): keep a file drag from selecting the text it passes over (#3358)
Dragging a file from the Project view selected the editor text the pointer passed over (#3334). WebKit keeps contenteditable text selectable whatever its user-select, and starts a selection there during a mouse drag even when the press selected nothing. beginDrag() now clears the selection and marks every pointer drag (startPointerDrag, the Project view's own file drag, tool window stripe buttons): no selection, and inputs, textareas and contenteditable out of hit testing (drop targets are their containers).

Co-authored-by: akshathayadav378-boop <[email protected]>
2026-11-14 21:24:20 +08:00
LunarFellerтаLunarFeller f795aa3232 fix(agents): ignore task replies from previous projects (#3356)
Starting or forking a task in project A and switching to B before the reply arrived let A's late reply insert its task into B and replace B's New Agent Task page. startTask and forkTask now check the connection generation they started under before handling the reply or error, and again after Home closes, covering reconnects and A -> B -> A.

Co-authored-by: LunarFeller <[email protected]>
2026-11-14 21:13:23 +08:00
Shohruh Ismoilovтаshohruh-genius 9239b99f3a test(settings): follow the grouped Language Servers page (#3360)
The Language Servers page now groups tools by language (#3272). The search, settings and settingsPaneLayout specs still looked for the old single tool list, and the details pane directly under the body, so they failed on main.

Co-authored-by: shohruh-genius <[email protected]>
2026-11-14 21:10:05 +08:00
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
354 змінених файлів з 14995 додано та 1647 видалено

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);
}
}
+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 -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,
+5 -3
Переглянути файл
@@ -821,8 +821,10 @@ fn a_reporting_task_outlives_its_background_budget() {
#[test]
fn background_tasks_scale_and_renew_cpu_fuel_budget() {
// Room for activation (about 3_100 on macOS, more on Windows) but not for `patient`'s
// whole run (about 9_000) without the refill on each report.
let f = fixture(Limits {
fuel: 3_500,
fuel: 6_000,
timeout: Duration::from_millis(500),
background: Duration::from_millis(5_000),
..Limits::default()
@@ -831,14 +833,14 @@ fn background_tasks_scale_and_renew_cpu_fuel_budget() {
if f.host.set_capability("misbehave", "process:git", true).is_err() {
return;
}
// Warm up activation in its own call so `patient` starts with a fresh 3_500-fuel budget.
// Warm up activation in its own call so `patient` starts with a fresh budget.
assert_eq!(f.run("misbehave", "echo", Some("warm")).unwrap().as_deref(), Some("warm"));
let out = f.run("misbehave", "patient", None).expect("background task succeeds").expect("output");
assert_eq!(out, "still here");
let err = f.run("misbehave", "spin", None).unwrap_err();
assert!(err.contains("exceeded its CPU budget (3500 fuel per call)"), "{err}");
assert!(err.contains("exceeded its CPU budget (6000 fuel per call)"), "{err}");
}
#[test]
+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());
}
}
+2 -1
Переглянути файл
@@ -482,7 +482,8 @@ mod tests {
std::fs::create_dir_all(output.join("test001")).unwrap();
// As jdtls writes it: the class's source file name in the path, the library's
// classpath attributes after the folder.
let escaped = output.to_string_lossy().replace('/', "\\/");
// JDT writes the folder as a portable path, `/` on Windows too, each `/` escaped.
let escaped = output.to_string_lossy().replace('\\', "/").replace('/', "\\/");
let handle = format!("=demo/{escaped}=/ai.openidea.kotlin=/true=/<test001(Db.class");
let uri = format!("jdt://contents/main-0/test001/Db.kt?{}", utf8_percent_encode(&handle, PATH));
assert_eq!(folder_class(&uri), Some(("test001".into(), "Db".into())));
+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 -4
Переглянути файл
@@ -209,10 +209,11 @@ impl Deployment<'_> {
let start = out.len();
// One `find` over SSH instead of a listing per folder (thousands of round trips on a
// big project); the same entries the walk below would keep.
if let Some(entries) = self.fs.tree(dir, self.exclude.prunable_names()).await {
if self.cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let tree = tokio::select! {
tree = self.fs.tree(dir, self.exclude.prunable_names()) => tree,
() = self.cancel.cancelled() => return Err(IdeError::cancelled()),
};
if let Some(entries) = tree {
let base = dir.trim_end_matches('/');
out.extend(entries.into_iter().filter(|e| {
let rel = e.path.strip_prefix(base).unwrap_or(&e.path);
+1 -1
Переглянути файл
@@ -403,7 +403,7 @@ async fn download_into_copies_only_the_selected_items() {
assert!(std::fs::read_dir(&elsewhere).unwrap().next().is_none(), "nothing written through the links");
}
let missing = [server.join("gone").to_str().unwrap().to_owned()];
let missing = [server.join("gone").to_str().unwrap().replace('\\', "/")];
let report = service
.download_into(&FsConnection::Local, &missing, &project, &CancellationToken::new(), &mut |_, _, _| {})
.await
+2
Переглянути файл
@@ -5,6 +5,7 @@
pub(crate) mod cmake;
mod dotnet;
mod zig;
use crate::RunConfig;
use ide_core::project_roots::expand_members;
@@ -72,6 +73,7 @@ pub(crate) fn detect_with_profiles(
}
}
}
detect_projects(root, &["build.zig"], zig::detect, &mut out);
dotnet::detect(root, &mut out);
detect_projects(root, &["Makefile", "makefile", "GNUmakefile"], detect_make, &mut out);
detect_projects(root, &["justfile", "Justfile", ".justfile"], detect_just, &mut out);
+232
Переглянути файл
@@ -0,0 +1,232 @@
//! Zig projects (`build.zig`): the build's steps (`zig build <step>`) and, for each executable it
//! declares, the installed binary (`zig-out/bin/<name>`), built first and debuggable with CodeLLDB.
//! Read from the build script's source: detecting configurations never runs the project's build code.
use super::{ConfigExt, config, read};
use crate::{BeforeLaunchTask, RunConfig};
use std::path::Path;
pub(super) const BUILD_ID: &str = "zig:build";
pub(super) fn detect(dir: &Path, out: &mut Vec<RunConfig>) {
let Some(src) = read(&dir.join("build.zig")) else { return };
let src = code_only(&src);
out.push(config(BUILD_ID, "zig build", "zig", "zig", &["build"]));
for step in steps(&src) {
// `install` is what plain `zig build` runs.
if step == "install" || step == "uninstall" {
continue;
}
let mut c = config(format!("zig:{step}"), format!("zig build {step}"), "zig", "zig", &["build", &step]);
if step == "test" || step.starts_with("test-") || step.starts_with("test_") {
c = c.test();
}
out.push(c);
}
for exe in executables(&src) {
let binary = if cfg!(windows) {
format!("zig-out\\bin\\{}.exe", exe.name)
} else {
format!("./zig-out/bin/{}", exe.name)
};
let mut c = config(format!("zig:exe:{}", exe.name), exe.name.clone(), "zig-application", &binary, &[])
.debug("codelldb");
c.extras.before_launch.push(BeforeLaunchTask { kind: "run".into(), target: BUILD_ID.into() });
if let Some(source) = exe.source {
c.extras.params.insert("source".into(), source);
}
out.push(c);
}
}
/// `src` with comments blanked out (Zig has only `//` comments) and string contents kept.
fn code_only(src: &str) -> String {
let mut out = String::with_capacity(src.len());
for line in src.lines() {
let mut in_string = false;
let mut escaped = false;
let mut end = line.len();
let bytes = line.as_bytes();
for (i, &b) in bytes.iter().enumerate() {
if in_string {
match b {
_ if escaped => escaped = false,
b'\\' => escaped = true,
b'"' => in_string = false,
_ => {}
}
} else if b == b'"' {
in_string = true;
} else if b == b'/' && bytes.get(i + 1) == Some(&b'/') {
end = i;
break;
}
}
out.push_str(&line[..end]);
out.push('\n');
}
out
}
/// The string literal starting at `s` (after optional whitespace), unescaped only for `\"`.
fn string_at(s: &str) -> Option<String> {
let rest = s.trim_start().strip_prefix('"')?;
let mut value = String::new();
let mut chars = rest.chars();
while let Some(c) = chars.next() {
match c {
'"' => return Some(value),
'\\' => value.push(chars.next()?),
c => value.push(c),
}
}
None
}
/// Step names from `<build>.step("name", "description")`, in order and without duplicates.
fn steps(src: &str) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for (i, _) in src.match_indices(".step(") {
let preceded = src[..i].chars().next_back().is_some_and(|c| c.is_alphanumeric() || c == '_');
if let Some(name) = string_at(&src[i + ".step(".len()..])
&& preceded
&& !name.is_empty()
&& !out.contains(&name)
{
out.push(name);
}
}
out
}
struct Executable {
name: String,
/// Root source file, relative to the build script's folder.
source: Option<String>,
}
/// `addExecutable(.{ .name = "…", … .root_source_file = <b>.path("…") … })`: the name at the
/// options' top level (an `imports` entry has its own `.name`) and the first root source file.
fn executables(src: &str) -> Vec<Executable> {
let mut out: Vec<Executable> = Vec::new();
for (start, _) in src.match_indices("addExecutable(") {
let body = &src[start + "addExecutable(".len()..];
let (mut depth, mut in_string, mut escaped) = (1usize, false, false);
let mut name = None;
let mut source = None;
for (i, c) in body.char_indices() {
if in_string {
match c {
_ if escaped => escaped = false,
'\\' => escaped = true,
'"' => in_string = false,
_ => {}
}
continue;
}
match c {
'"' => in_string = true,
'(' | '{' | '[' => depth += 1,
')' | '}' | ']' => {
depth -= 1;
if depth == 0 {
break;
}
}
'.' => {
let field = &body[i + 1..];
let value =
|key: &str| field.strip_prefix(key)?.trim_start().strip_prefix('=').map(str::trim_start);
if depth == 2 && name.is_none() {
name = value("name").and_then(string_at);
}
if source.is_none()
&& let Some(v) = value("root_source_file")
&& let Some(call) = v.find(".path(")
&& v[..call].chars().all(|c| c.is_alphanumeric() || c == '_')
{
source = string_at(&v[call + ".path(".len()..]);
}
}
_ => {}
}
}
if let Some(name) = name
&& !name.is_empty()
&& !out.iter().any(|e| e.name == name)
{
out.push(Executable { name, source });
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
const INIT: &str = r#"const std = @import("std");
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const mod = b.addModule("app", .{
.root_source_file = b.path("src/root.zig"),
.target = target,
});
// const old = b.addExecutable(.{ .name = "commented-out" });
const exe = b.addExecutable(.{
.name = "app",
.root_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.imports = &.{
.{ .name = "app", .module = mod },
},
}),
});
b.installArtifact(exe);
const tool = b.addExecutable(.{ .name = "gen-docs", .root_module = b.createModule(.{
.root_source_file = b.path("tools/gen_docs.zig"),
}) });
_ = tool;
const run_step = b.step("run", "Run the app");
const test_step = b.step("test", "Run tests");
const docs = b.step("docs", "Generate docs // not a comment");
_ = .{ run_step, test_step, docs };
}
"#;
#[test]
fn steps_and_executables_come_from_the_build_script() {
let src = code_only(INIT);
assert_eq!(steps(&src), ["run", "test", "docs"]);
let exes = executables(&src);
assert_eq!(
exes.iter().map(|e| (e.name.as_str(), e.source.as_deref())).collect::<Vec<_>>(),
[("app", Some("src/main.zig")), ("gen-docs", Some("tools/gen_docs.zig"))]
);
}
#[test]
fn a_zig_project_gets_build_steps_and_debuggable_executables() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("build.zig"), INIT).unwrap();
let mut out = Vec::new();
detect(dir.path(), &mut out);
let ids: Vec<_> = out.iter().map(|c| c.id.as_str()).collect();
assert_eq!(ids, ["zig:build", "zig:run", "zig:test", "zig:docs", "zig:exe:app", "zig:exe:gen-docs"]);
let test = out.iter().find(|c| c.id == "zig:test").unwrap();
assert!(test.is_test);
assert_eq!(
(test.command.as_str(), test.args.as_slice()),
("zig", ["build".to_owned(), "test".to_owned()].as_slice())
);
let app = out.iter().find(|c| c.id == "zig:exe:app").unwrap();
assert_eq!(app.kind, "zig-application");
assert!(app.command.ends_with(if cfg!(windows) { "app.exe" } else { "zig-out/bin/app" }));
assert_eq!(app.debug_adapter.as_deref(), Some("codelldb"));
assert_eq!(app.extras.before_launch[0].kind, "run");
assert_eq!(app.extras.before_launch[0].target, BUILD_ID);
assert_eq!(app.extras.params.get("source").map(String::as_str), Some("src/main.zig"));
}
}
+28 -2
Переглянути файл
@@ -1,7 +1,8 @@
//! `.vscode/launch.json`: JSON with comments and trailing commas, `configurations` and
//! `compounds`. Python (`debugpy`, the older `python`) and Node.js (`node`, `pwa-node`,
//! `node-terminal`) launches become run configurations of the matching kind, their attach
//! requests Python Remote Debug / Attach to Node.js configurations. Variables with a fixed
//! requests Python Remote Debug / Attach to Node.js configurations. A browser launch
//! (`chrome`, `msedge` and their `pwa-` names) becomes a JavaScript Debug configuration. Variables with a fixed
//! meaning (`${workspaceFolder}`, `${userHome}`, `${env:NAME}`…) are replaced when importing;
//! an entry using one that depends on the moment VS Code launches it (`${file}`,
//! `${input:…}`, `${command:…}`) is skipped.
@@ -46,6 +47,7 @@ const PYTHON_ATTACH: &[&str] = &["connect", "host", "port", "pathMappings"];
const NODE_LAUNCH: &[&str] = &["program", "args", "runtimeExecutable", "runtimeArgs"];
const NODE_ATTACH: &[&str] = &["address", "port", "localRoot", "remoteRoot"];
const NODE_TERMINAL: &[&str] = &["command"];
const BROWSER_LAUNCH: &[&str] = &["url", "webRoot"];
const COMPOUND: &[&str] = &["configurations"];
const PACKAGE_MANAGERS: &[&str] = &["npm", "pnpm", "yarn", "bun"];
@@ -221,7 +223,14 @@ impl Cx<'_> {
("node" | "pwa-node", "launch") => (self.node_launch(name, obj)?, NODE_LAUNCH),
("node" | "pwa-node", "attach") => (self.node_attach(name, obj)?, NODE_ATTACH),
("node-terminal", "launch") => (self.node_terminal(name, obj)?, NODE_TERMINAL),
("debugpy" | "python" | "node" | "pwa-node" | "node-terminal", other) => {
("chrome" | "pwa-chrome" | "msedge" | "pwa-msedge", "launch") => {
(self.browser_launch(name, obj, ty)?, BROWSER_LAUNCH)
}
(
"debugpy" | "python" | "node" | "pwa-node" | "node-terminal" | "chrome" | "pwa-chrome" | "msedge"
| "pwa-msedge",
other,
) => {
return Err(SkipReason::UnsupportedRequest { request: other.to_owned() });
}
(other, _) => return Err(SkipReason::UnsupportedType { type_: other.to_owned() }),
@@ -340,6 +349,23 @@ impl Cx<'_> {
Ok(c)
}
/// A page opened in Chrome or Edge with the debugger attached: a JavaScript Debug configuration.
fn browser_launch(&self, name: &str, obj: &Map<String, Value>, ty: &str) -> Res<RunConfig> {
let Some(url) = self.string(obj, "url")? else {
return Err(SkipReason::NothingToRun);
};
let browser = if ty.ends_with("msedge") { "edge" } else { "chrome" };
let mut c = config(name, "js-browser", String::new(), Vec::new(), Some("js-debug"));
c.extras.params = BTreeMap::from([("url".into(), url), ("browser".into(), browser.into())]);
if let Some(web_root) = self.string(obj, "webRoot")? {
let web_root = self.path_from(web_root, self.root);
if web_root != "." {
c.extras.params.insert("webRoot".into(), web_root);
}
}
Ok(c)
}
/// `localRoot` (relative to the project root, empty for the root itself) and `remoteRoot`.
fn roots(&self, obj: &Map<String, Value>, c: &mut RunConfig) -> Res<()> {
if let Some(local) = self.string(obj, "localRoot")? {
+37 -1
Переглянути файл
@@ -229,7 +229,12 @@ fn nextjs_recipes() {
assert_eq!(server.args, s(&["run", "dev"]));
assert_eq!(server.debug_adapter.as_deref(), Some("js-debug"));
assert_eq!(reason(&r, "Next.js: debug client-side"), &SkipReason::UnsupportedType { type_: "chrome".into() });
let client = get(&r, "Next.js: debug client-side");
assert_eq!((client.kind.as_str(), client.command.as_str()), ("js-browser", ""));
assert_eq!(client.debug_adapter.as_deref(), Some("js-debug"));
assert_eq!(client.extras.params["url"], "http://localhost:3000");
assert_eq!(client.extras.params["browser"], "chrome");
assert!(ignored(&r, "Next.js: debug client-side").is_empty());
let full = get(&r, "Next.js: debug full stack");
assert_eq!((full.kind.as_str(), full.command.as_str()), ("node", "node"));
@@ -403,6 +408,37 @@ fn attach_requests_become_remote_debug_configurations() {
assert_eq!(reason(&r, "Odd"), &SkipReason::UnsupportedRequest { request: "test".into() });
}
#[test]
fn browser_launches_become_javascript_debug_configurations() {
let (_dir, r) = run(r#"{
"configurations": [
{ "name": "Edge", "type": "pwa-msedge", "request": "launch", "url": "http://localhost:5173/app",
"webRoot": "${workspaceFolder}/web", "sourceMaps": true, "userDataDir": false },
{ "name": "Root", "type": "chrome", "request": "launch", "url": "http://localhost:8080",
"webRoot": "${workspaceFolder}" },
{ "name": "Page file", "type": "chrome", "request": "launch", "file": "${workspaceFolder}/index.html" },
{ "name": "Running browser", "type": "chrome", "request": "attach", "port": 9222 }
]
}"#);
let edge = get(&r, "Edge");
assert_eq!((edge.kind.as_str(), edge.debug_adapter.as_deref()), ("js-browser", Some("js-debug")));
assert_eq!(
edge.extras.params,
BTreeMap::from([
("browser".into(), "edge".into()),
("url".into(), "http://localhost:5173/app".into()),
("webRoot".into(), "web".into())
])
);
assert_eq!(ignored(&r, "Edge"), ["userDataDir"]);
// The project root is the default web root.
assert!(!get(&r, "Root").extras.params.contains_key("webRoot"));
assert_eq!(reason(&r, "Page file"), &SkipReason::NothingToRun);
assert_eq!(reason(&r, "Running browser"), &SkipReason::UnsupportedRequest { request: "attach".into() });
}
#[test]
fn the_block_for_this_os_overrides_the_entry() {
let os = if cfg!(windows) {
+95 -4
Переглянути файл
@@ -1168,6 +1168,22 @@ pub fn configure_server(cfg: &mut LanguageServerConfig, root: &Path, server_root
let plugin = vue::tsserver_plugin(&vue);
merge_init_options(cfg, json!({ "vtsls": { "tsserver": { "globalPlugins": [plugin] } } }));
cfg.tsserver_project_extras.extensions = vec![vue::EXTENSION.into()];
// Hybrid mode: one program holds every .vue's virtual TypeScript, so the
// heap must clear it (vtsls' default 3072 dies in a GC death spiral), and
// the syntax tsserver duplicates the plugin and its file watching.
merge_init_options(
cfg,
json!({ "typescript": { "tsserver": { "useSyntaxServer": "never", "maxTsServerMemory": 8192 } } }),
);
// A configured project resolves its own types; the typings installer only
// serves inferred projects and keeps scanning npm in the background.
if server_root.ancestors().any(|d| d.join("tsconfig.json").is_file() || d.join("jsconfig.json").is_file()) {
merge_init_options(cfg, json!({ "typescript": { "disableAutomaticTypeAcquisition": true } }));
}
}
let memory = s.languages.typescript.tsserver_max_memory;
if name == "vtsls" && memory > 0 {
merge_init_options(cfg, json!({ "typescript": { "tsserver": { "maxTsServerMemory": memory } } }));
}
if name == "vtsls"
&& cfg.tsserver_project
@@ -1814,10 +1830,17 @@ pub fn without_derived_in(config: &RunConfig, root: &Path, fallback: Option<&Pat
if path.is_absolute()
&& let Some(name) = path.file_name().map(|n| n.to_string_lossy().into_owned())
{
let mut probe = out.clone();
probe.command = name.clone();
if applied(&probe).command == config.command {
out.command = name;
// Generated configurations name the bare program (`java`), also on Windows where the
// derived path ends in `java.exe`.
let bare =
name.len().checked_sub(4).filter(|&at| name[at..].eq_ignore_ascii_case(".exe")).map(|at| &name[..at]);
for candidate in bare.into_iter().chain([name.as_str()]) {
let mut probe = out.clone();
probe.command = candidate.to_owned();
if applied(&probe).command == config.command {
out.command = candidate.to_owned();
break;
}
}
}
out
@@ -2104,6 +2127,74 @@ mod gradle_runtime_tests {
mod maven_linkage_tests {
use super::*;
#[test]
fn hybrid_vtsls_gets_one_server_a_full_heap_and_no_type_acquisition() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
// The Vue package the hybrid block finds through the command's package dir.
let bin = root.join("tools/node_modules/.bin/vue-language-server");
let pkg = root.join("tools/node_modules/@vue/language-server/package.json");
std::fs::create_dir_all(bin.parent().unwrap()).unwrap();
std::fs::create_dir_all(pkg.parent().unwrap()).unwrap();
std::fs::write(&bin, "#!/bin/sh\n").unwrap();
std::fs::write(&pkg, "{}").unwrap();
let mut s = Settings::default();
s.tools
.insert(vue::SERVER.into(), ide_core::settings::ToolSettings { path: bin.to_string_lossy().into_owned() });
std::fs::write(root.join("tsconfig.json"), "{}").unwrap();
let mut cfg = custom_server("vtsls", &LanguageServerSettings::default());
configure_server(&mut cfg, root, root, &s);
let options = serde_json::to_value(cfg.initialization_options).unwrap();
assert_eq!(options.pointer("/typescript/tsserver/useSyntaxServer"), Some(&json!("never")));
assert_eq!(options.pointer("/typescript/tsserver/maxTsServerMemory"), Some(&json!(8192)));
assert_eq!(options.pointer("/typescript/disableAutomaticTypeAcquisition"), Some(&json!(true)));
// A user-set limit wins over the automatic one.
s.languages.typescript.tsserver_max_memory = 6144;
let mut cfg = custom_server("vtsls", &LanguageServerSettings::default());
configure_server(&mut cfg, root, root, &s);
let options = serde_json::to_value(cfg.initialization_options).unwrap();
assert_eq!(options.pointer("/typescript/tsserver/maxTsServerMemory"), Some(&json!(6144)));
}
#[test]
fn hybrid_without_a_config_keeps_type_acquisition_and_plain_vtsls_stays_default() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let bin = root.join("tools/node_modules/.bin/vue-language-server");
let pkg = root.join("tools/node_modules/@vue/language-server/package.json");
std::fs::create_dir_all(bin.parent().unwrap()).unwrap();
std::fs::create_dir_all(pkg.parent().unwrap()).unwrap();
std::fs::write(&bin, "#!/bin/sh\n").unwrap();
std::fs::write(&pkg, "{}").unwrap();
let mut s = Settings::default();
s.tools
.insert(vue::SERVER.into(), ide_core::settings::ToolSettings { path: bin.to_string_lossy().into_owned() });
// No tsconfig/jsconfig anywhere: inferred projects still want the typings installer.
let mut cfg = custom_server("vtsls", &LanguageServerSettings::default());
configure_server(&mut cfg, root, root, &s);
let options = serde_json::to_value(cfg.initialization_options).unwrap();
assert!(options.pointer("/typescript/disableAutomaticTypeAcquisition").is_none());
assert_eq!(options.pointer("/typescript/tsserver/maxTsServerMemory"), Some(&json!(8192)));
// Without the Vue package there is no hybrid mode: nothing is injected. The
// command points at a bare binary, so the package lookup finds nothing
// (a bare name would resolve on the host's PATH and break the test).
std::fs::write(root.join("bare-vue-language-server"), "#!/bin/sh\n").unwrap();
s.tools
.insert(vue::SERVER.into(), ide_core::settings::ToolSettings { path: "bare-vue-language-server".into() });
let mut cfg = custom_server("vtsls", &LanguageServerSettings::default());
configure_server(&mut cfg, root, root, &s);
let options = serde_json::to_value(cfg.initialization_options).unwrap();
assert!(options.pointer("/typescript/tsserver/useSyntaxServer").is_none());
assert!(options.pointer("/typescript/tsserver/maxTsServerMemory").is_none());
// A user-set limit applies to plain TypeScript projects too.
s.languages.typescript.tsserver_max_memory = 12288;
let mut cfg = custom_server("vtsls", &LanguageServerSettings::default());
configure_server(&mut cfg, root, root, &s);
let options = serde_json::to_value(cfg.initialization_options).unwrap();
assert_eq!(options.pointer("/typescript/tsserver/maxTsServerMemory"), Some(&json!(12288)));
assert!(options.pointer("/typescript/tsserver/useSyntaxServer").is_none());
}
#[test]
fn default_maven_repository_reads_the_user_settings() {
let tmp = tempfile::tempdir().unwrap();
+1 -1
Переглянути файл
@@ -35,7 +35,7 @@ pub(crate) fn configure(cfg: &mut LanguageServerConfig, root: &Path, server_root
});
if node.is_none() || find_command("node", &search, server_root).is_none() {
cfg.launch_error = Some(
"Astro Language Server needs Node.js. Install it or select its executable in Settings › Languages & Frameworks › Node.js."
"Astro Language Server needs Node.js. Install it or select its executable in Settings › Languages & Frameworks › JavaScript Runtime."
.into(),
);
return;
+23
Переглянути файл
@@ -332,6 +332,12 @@ fn cached_cancellable(
.unwrap_or_default()
}
/// What `ecosystem` last found under `root` with the same settings, from before its manifests changed.
fn last_found(root: &Path, ecosystem: &'static str, key: &str) -> Option<Vec<LibraryGroup>> {
let cache = CACHE.lock().unwrap_or_else(|e| e.into_inner());
cache.get(&(root.to_path_buf(), ecosystem)).filter(|c| c.key == key).map(|c| c.groups.clone())
}
fn cached_optional(
root: &Path,
ecosystem: &'static str,
@@ -807,6 +813,9 @@ fn rust_groups(root: &Path, s: &Settings, locked: bool, cancel: &CancellationTok
let roots = parse_cargo_metadata(&json)?;
Some(group("Cargo dependencies", "cargo", named_roots(roots)).into_iter().collect())
});
// An edited manifest that names crates not downloaded yet fails offline until the next
// resolution writes Cargo.lock: the last graph stays meanwhile, as a failed sync keeps the model.
let graph = graph.or_else(|| last_found(root, "cargo", &r.toolchain));
out.extend(graph.unwrap_or_else(|| {
let home = ide_core::process::host_var_os("CARGO_HOME")
.map(PathBuf::from)
@@ -1107,6 +1116,20 @@ mod go_cache_tests {
assert_eq!(groups[0].kind, "cargo");
assert_eq!(groups[0].roots[0].path, source);
assert_eq!(std::fs::read_to_string(calls).unwrap().lines().count(), 1);
// The manifest now names a crate that isn't downloaded: the offline graph fails, the last one stays.
std::fs::write(
bin.join("cargo"),
format!(
"#!/bin/sh\nif [ \"$1\" = locate-project ]; then echo '{}'; exit; fi\nexit 101\n",
root.join("Cargo.toml").display()
),
)
.unwrap();
std::fs::write(root.join("Cargo.toml"), "[workspace]\nmembers=['members/*']\n# edited\n").unwrap();
let groups = project_libraries(root, &settings, &[]);
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].roots[0].path, source);
}
#[cfg(unix)]
+14 -5
Переглянути файл
@@ -194,14 +194,23 @@ impl Session {
}
channel.eof().await.map_err(|e| self.failed(e))?;
let mut out = ExecOutput::default();
while let Some(msg) = channel.wait().await {
loop {
// After the exit status, output still in flight is read until the channel goes quiet: a
// background child that inherited stdout keeps the channel open past the exit (as in `spawn`).
let msg = match out.code {
None => channel.wait().await,
Some(_) => tokio::time::timeout(EXIT_DRAIN, channel.wait()).await.ok().flatten(),
};
match msg {
ChannelMsg::Data { data } => out.stdout.extend_from_slice(&data),
ChannelMsg::ExtendedData { data, ext: 1 } => out.stderr.extend_from_slice(&data),
ChannelMsg::ExitStatus { exit_status } => out.code = Some(exit_status),
_ => {}
Some(ChannelMsg::Data { data }) => out.stdout.extend_from_slice(&data),
Some(ChannelMsg::ExtendedData { data, ext: 1 }) => out.stderr.extend_from_slice(&data),
Some(ChannelMsg::ExitStatus { exit_status }) => out.code = Some(exit_status),
Some(_) => {}
None => break,
}
}
// Closed here, the channel doesn't stay open on the server for that child.
let _ = channel.close().await;
Ok(out)
}
+23 -1
Переглянути файл
@@ -1,7 +1,8 @@
//! An in-process SSH server for tests (feature `testing`): password `dev` /
//! `secret`, commands answered by a closure, SFTP served from a local
//! folder (remote `/x` is `<root>/x`), and a shell that echoes its input
//! (`exit` ends it with status 0).
//! (`exit` ends it with status 0). `direct-tcpip` channels are forwarded to local TCP
//! ports, and refused when nothing listens there.
use russh::server::{self, Auth, ChannelOpenHandle, Msg, Server as _, Session};
use russh::{Channel, ChannelId};
@@ -71,6 +72,27 @@ impl server::Handler for Conn {
Ok(())
}
async fn channel_open_direct_tcpip(
&mut self,
channel: Channel<Msg>,
host: &str,
port: u32,
_originator_address: &str,
_originator_port: u32,
reply: ChannelOpenHandle,
_session: &mut Session,
) -> Result<(), Self::Error> {
let Ok(mut upstream) = tokio::net::TcpStream::connect((host, port as u16)).await else {
return Ok(());
};
reply.accept().await;
tokio::spawn(async move {
let mut stream = channel.into_stream();
let _ = tokio::io::copy_bidirectional(&mut stream, &mut upstream).await;
});
Ok(())
}
async fn exec_request(
&mut self,
channel: ChannelId,
+14
Переглянути файл
@@ -321,6 +321,20 @@ async fn a_process_that_exits_finishes_even_while_its_channel_stays_open() {
assert_eq!((code, seen.lock().as_str()), (Some(3), "ran: linger"));
}
#[tokio::test]
async fn a_command_that_exits_returns_even_while_its_channel_stays_open() {
let env = env().await;
let session = env.connect_trusted(&env.target(SshAuth::Password), Some("secret")).await.ok().unwrap();
let out = tokio::time::timeout(std::time::Duration::from_secs(10), session.exec("linger", None))
.await
.expect("the exit status must end the command")
.unwrap();
assert_eq!(
(out.code, out.stdout.as_slice(), out.stderr.as_slice()),
(Some(3), b"ran: linger".as_slice(), b"oops".as_slice())
);
}
#[tokio::test]
async fn upload_reports_a_command_that_stops_reading() {
let env = env().await;
+12
Переглянути файл
@@ -41,6 +41,14 @@ pub trait Backend: Send + Sync {
/// as `unstaged`.
fn status(&self) -> IdeResult<Vec<FileStatus>>;
/// Working-area changes for `paths` only (absolute; those outside the root
/// are ignored), which is what a watcher batch needs. The default is the
/// full status: always correct, just not proportional to the batch.
fn status_paths(&self, paths: &[PathBuf]) -> IdeResult<Vec<FileStatus>> {
let _ = paths;
self.status()
}
/// Text of `path` at `rev`; `None` when absent there.
fn file_at(&self, path: &Path, rev: Option<&str>, encoding: TextEncoding) -> IdeResult<Option<String>>;
@@ -189,6 +197,10 @@ impl Backend for Repo {
Repo::status(self)
}
fn status_paths(&self, paths: &[PathBuf]) -> IdeResult<Vec<FileStatus>> {
Repo::status_paths(self, paths)
}
fn file_at(&self, path: &Path, rev: Option<&str>, encoding: TextEncoding) -> IdeResult<Option<String>> {
Repo::file_at(self, path, rev, encoding)
}
+33 -2
Переглянути файл
@@ -764,6 +764,24 @@ impl Repo {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
status::status(self)
}
/// Status of `paths` only (absolute; those outside the repository are ignored), for the
/// dirty-scope refresh after a watcher batch. An empty list is the full status.
pub fn status_paths(&self, paths: &[PathBuf]) -> IdeResult<Vec<FileStatus>> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
// Restricted Mode starts no program of the repository, and the scoped read is `git status`
// with a pathspec: answer with the full gitoxide status instead.
if !self.execution_allowed.load(std::sync::atomic::Ordering::Acquire) {
return status::status(self);
}
if paths.is_empty() {
return status::status_paths(self, &[]);
}
let rel: Vec<String> = paths.iter().filter_map(|p| self.given_rel_path(p).ok()).collect();
if rel.is_empty() {
return Ok(Vec::new());
}
status::status_paths(self, &rel)
}
pub fn branch_info(&self) -> IdeResult<BranchInfo> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
refs::branch_info(self)
@@ -866,6 +884,12 @@ impl Repo {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
write::blame(self, path, current_text, revision, encoding)
}
/// Adds `commit` to the list `blame.ignoreRevsFile` names, so annotate skips it; with none
/// configured, to `.git-blame-ignore-revs` at the worktree root, which the local config then names.
pub fn ignore_blame_revision(&self, commit: &str) -> IdeResult<()> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
write::ignore_blame_revision(self, commit)
}
/// History for Selection: commits that changed `lines` (0-based, inclusive, of the
/// current text: `current_text`, or the file on disk), mapped to HEAD's lines first.
pub fn block_history(
@@ -1244,10 +1268,17 @@ impl Repo {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
write::rebase(self, onto)
}
/// `paths` (absolute) stashes only those files; empty stashes the whole working tree.
/// `keep_index`: changes already staged stay in the index (and the working tree).
pub fn stash_push(&self, message: Option<&str>, include_untracked: bool, keep_index: bool) -> IdeResult<()> {
pub fn stash_push(
&self,
message: Option<&str>,
include_untracked: bool,
keep_index: bool,
paths: &[PathBuf],
) -> IdeResult<()> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
write::stash_push(self, message, include_untracked, keep_index)
write::stash_push(self, message, include_untracked, keep_index, paths)
}
/// `reinstate_index`: also restore what was staged when the stash was made.
pub fn stash_apply(&self, index: u32, pop: bool, reinstate_index: bool) -> IdeResult<()> {
+1 -1
Переглянути файл
@@ -148,7 +148,7 @@ impl Repo {
}
/// `path` relative to the worktree, spelled as given.
fn given_rel_path(&self, path: &Path) -> IdeResult<String> {
pub(crate) fn given_rel_path(&self, path: &Path) -> IdeResult<String> {
let rel = if path.is_absolute() {
self.strip_root(path)
.ok_or_else(|| IdeError::invalid(format!("{} is outside the repository", path.display())))?
+19 -2
Переглянути файл
@@ -146,17 +146,34 @@ impl Repos {
/// The reads go through [`Backend`], the Changes-view surface — repositories of a
/// future second backend slot in here once `Repos` learns to hold them.
pub fn status(&self) -> IdeResult<Vec<FileStatus>> {
let groups: Vec<(Arc<Repo>, Vec<PathBuf>)> = self.repos.iter().map(|repo| (repo.clone(), Vec::new())).collect();
self.read_status(groups, false)
}
/// [`status`](Self::status) for `paths` only: each path goes to its innermost repository,
/// so a repository without one is not read at all and a nested repository keeps its own
/// files (the enclosing one's "untracked folder" entry for them is dropped).
pub fn status_paths(&self, paths: &[PathBuf]) -> IdeResult<Vec<FileStatus>> {
self.read_status(self.group(paths), true)
}
/// Reads one status per repository: the whole working tree, or `scoped` the paths it was
/// given. A nested repository reports its own files, so the enclosing one's "untracked
/// folder" entry for it is dropped. A repository that cannot be read is left out; fails
/// only when all fail.
fn read_status(&self, groups: Vec<(Arc<Repo>, Vec<PathBuf>)>, scoped: bool) -> IdeResult<Vec<FileStatus>> {
let mut out = Vec::new();
let mut first_error = None;
let mut read = 0;
for repo in &self.repos {
for (repo, mine) in groups {
let nested: Vec<&Path> = self
.repos
.iter()
.map(|r| r.root())
.filter(|r| *r != repo.root() && r.starts_with(repo.root()))
.collect();
let status = match (repo.as_ref() as &dyn Backend).status() {
let backend = repo.as_ref() as &dyn Backend;
let status = match if scoped { backend.status_paths(&mine) } else { backend.status() } {
Ok(status) => {
read += 1;
status
+47 -3
Переглянути файл
@@ -24,9 +24,38 @@ pub(crate) fn status(r: &Repo) -> IdeResult<Vec<FileStatus>> {
tracing::debug!("status via gitoxide failed, asking git: {}", e.message);
cli_status(r, &ignored)
})?;
Ok(collect(r, &ignored, map, conflicts))
}
/// Status of `paths` only (repository-relative, `/`-separated): the dirty-scope refresh.
///
/// A watcher batch touches a few files, and the full status walks the whole tree; git's own
/// pathspec filtering keeps that batch proportional to what changed. An empty list is the
/// full status, so a caller that lost the paths still gets a correct answer.
pub(crate) fn status_paths(r: &Repo, paths: &[String]) -> IdeResult<Vec<FileStatus>> {
// Pathspecs always take `/`, and a path that *is* the repository root names everything: both
// matter, because `Repo::status_paths` hands over paths as the platform spells them.
let patterns: Vec<String> =
paths.iter().map(|path| path.replace('\\', "/")).filter(|path| !path.is_empty()).collect();
if patterns.is_empty() {
return status(r);
}
let ignored = r.ignored.read().unwrap_or_else(|p| p.into_inner()).clone();
// `git status` with the pathspec, for the same reason the full read falls back to it: gitoxide's
// in-process pathspec matched nothing for a plain relative path here, and a silent empty
// answer is worse than the ~40 ms a `git` process costs on Windows.
let (map, conflicts) = cli_status_paths(r, &ignored, &patterns)?;
Ok(collect(r, &ignored, map, conflicts))
}
fn collect(
r: &Repo,
ignored: &IgnoredNames,
map: BTreeMap<String, Entry>,
conflicts: HashSet<String>,
) -> Vec<FileStatus> {
let mut disk = r.gix().is_ok_and(|repo| crate::repo::ignores_case(&repo)).then(DiskSpelling::default);
Ok(map
.into_iter()
map.into_iter()
.filter(|(_, e)| e.staged.is_some() || e.unstaged.is_some())
// Ignored names mean "the IDE treats these as nonexistent": hide their tracked
// changes too, not just untracked hits above. Conflicts stay visible so merges
@@ -51,7 +80,7 @@ pub(crate) fn status(r: &Repo) -> IdeResult<Vec<FileStatus>> {
renamed_from: e.renamed_from,
}
})
.collect())
.collect()
}
/// Spells repository paths the way a case-insensitive disk does, so that the tree, tabs and
@@ -216,6 +245,21 @@ fn cli_status(r: &Repo, ignored: &IgnoredNames) -> IdeResult<Entries> {
Ok(parse_porcelain_v2(&String::from_utf8_lossy(&out.stdout), ignored))
}
/// `git status` for `paths` only, parsed and filtered like the full one. `--literal-pathspecs`
/// keeps a `*` or `[` in a file name from being read as a pattern.
fn cli_status_paths(r: &Repo, ignored: &IgnoredNames, paths: &[String]) -> IdeResult<Entries> {
let overrides = crate::cli::filter_overrides(r.root(), &ide_core::CancellationToken::new())?;
let mut args: Vec<&str> = overrides.iter().map(String::as_str).collect();
args.extend(["--literal-pathspecs", "status", "--porcelain=v2", "-z", "--untracked-files=all"]);
if !crate::execution_allowed() {
args.push("--ignore-submodules=dirty");
}
args.push("--");
args.extend(paths.iter().map(String::as_str));
let out = crate::cli::run(r.root(), &args, None)?;
Ok(parse_porcelain_v2(&String::from_utf8_lossy(&out.stdout), ignored))
}
fn parse_porcelain_v2(out: &str, ignored: &IgnoredNames) -> Entries {
let kind = |c: u8| match c {
b'M' | b'T' => Some(ChangeKind::Modified),
+124 -15
Переглянути файл
@@ -43,6 +43,70 @@ fn run_paths(r: &Repo, args: &[&str], paths: &[String]) -> IdeResult<()> {
cli::run_paths(r.root(), &base, paths).map(|_| ())
}
/// The annotate ignore list at the worktree root: git's conventional `blame.ignoreRevsFile`.
pub const BLAME_IGNORE_REVS_FILE: &str = ".git-blame-ignore-revs";
/// The file `blame.ignoreRevsFile` names, resolved from the worktree root as git does (it may not
/// exist). The value comes from the gitoxide config snapshot, which Restricted Mode still parses,
/// so resolving it starts no program.
fn configured_ignore_revs(r: &Repo) -> Option<PathBuf> {
let repo = r.gix().ok()?;
let configured = repo.config_snapshot().string("blame.ignoreRevsFile")?;
let configured = configured.to_str_lossy();
let configured = configured.trim();
if configured.is_empty() {
return None;
}
let path = Path::new(configured);
Some(if path.is_absolute() { path.to_path_buf() } else { r.root().join(path) })
}
/// Arguments for the file `blame.ignoreRevsFile` names, which git does not accept as an
/// absent file: with a value configured but missing from disk, `--no-ignore-revs-file`
/// clears it so annotate still works.
fn ignore_revs_args(r: &Repo) -> Vec<String> {
match configured_ignore_revs(r) {
Some(path) if path.is_file() => vec!["--ignore-revs-file".to_string(), path.to_string_lossy().into_owned()],
Some(_) => vec!["--no-ignore-revs-file".to_string()],
None => Vec::new(),
}
}
/// `existing` (an ignore list's text) with `commit` appended: lines keep their text and order, a
/// listed commit isn't repeated, and the result ends with a newline in the list's line ending.
fn append_revision(existing: &str, commit: &str) -> String {
let revision = commit.trim();
let newline = if existing.contains("\r\n") { "\r\n" } else { "\n" };
let mut lines: Vec<&str> = existing.lines().collect();
if !revision.is_empty() && !lines.iter().any(|line| line.trim() == revision) {
lines.push(revision);
}
if lines.is_empty() { String::new() } else { lines.join(newline) + newline }
}
/// Adds `commit` to the list `blame.ignoreRevsFile` names; with none configured, to
/// `.git-blame-ignore-revs` at the worktree root, which the local config then names.
pub(crate) fn ignore_blame_revision(r: &Repo, commit: &str) -> IdeResult<()> {
let configured = configured_ignore_revs(r);
let path = configured.clone().unwrap_or_else(|| r.root().join(BLAME_IGNORE_REVS_FILE));
let existing = match std::fs::read_to_string(&path) {
Ok(text) => text,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(e) => return Err(e.into()),
};
let updated = append_revision(&existing, commit);
if updated != existing {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
std::fs::write(&path, updated)?;
}
if configured.is_none() {
cli::run(r.root(), &["config", "--local", "blame.ignoreRevsFile", BLAME_IGNORE_REVS_FILE], None)?;
}
Ok(())
}
pub(crate) fn blame(
r: &Repo,
path: &Path,
@@ -61,8 +125,9 @@ pub(crate) fn blame(
Err(error) => return Err(error.into()),
};
let text = current_text.map(str::to_owned).unwrap_or_else(|| decode(&disk, encoding).into_owned());
let ignore = ignore_revs_args(r);
if encoding.has_ascii_conversion() {
return converted_blame(r, path, &rel, &text, revision, encoding);
return converted_blame(r, path, &rel, &text, revision, encoding, &ignore);
}
let contents = match current_text {
Some(_) if crate::diff::normalize_eol(&text) == crate::diff::normalize_eol(&decode(&disk, encoding)) => disk,
@@ -94,23 +159,32 @@ pub(crate) fn blame(
// older releases reject the pair (`fatal: cannot use --contents with final
// commit object name`) and take the same plain path — the edited lines are
// marked uncommitted below either way (issue #755).
let contents_args = ["--literal-pathspecs", "blame", "--porcelain", "--contents", "-", revision, "--", &rel];
let plain_args: &[&str] = if crate::execution_allowed() {
&["--literal-pathspecs", "blame", "--porcelain", revision, "--", &rel]
} else {
&["--literal-pathspecs", "blame", "--no-textconv", "--porcelain", revision, "--", &rel]
let contents_args = {
let mut args = vec!["--literal-pathspecs", "blame", "--porcelain"];
args.extend(ignore.iter().map(String::as_str));
args.extend(["--contents", "-", revision, "--", &rel]);
args
};
let plain_args: Vec<&str> = {
let mut args = vec!["--literal-pathspecs", "blame"];
if !crate::execution_allowed() {
args.push("--no-textconv");
}
args.extend(ignore.iter().map(String::as_str));
args.extend(["--porcelain", revision, "--", &rel]);
args
};
let (out, contents_blame) = if crate::execution_allowed() {
let out = cli::run_raw(r.root(), &contents_args, Some(&contents))?;
if out.code == Some(0) {
(out, true)
} else if String::from_utf8_lossy(&out.stderr).contains("cannot use --contents") {
(run(r.root(), plain_args, None)?, false)
(run(r.root(), &plain_args, None)?, false)
} else {
return Err(cli::failure(&contents_args, &out.stderr, &out.stdout, out.code));
}
} else {
(run(r.root(), plain_args, None)?, false)
(run(r.root(), &plain_args, None)?, false)
};
let mut lines = crate::blame::parse_porcelain(&out.stdout_str());
for line in &mut lines {
@@ -142,16 +216,21 @@ fn converted_blame(
text: &str,
revision: &str,
encoding: TextEncoding,
ignore: &[String],
) -> IdeResult<Vec<BlameLine>> {
let current = crate::diff::normalize_eol(text);
let mut result = uncommitted_lines(current.split_inclusive('\n').count());
let Some(base) = r.file_bytes_at(path, Some(revision))? else { return Ok(result) };
let args: &[&str] = if crate::execution_allowed() {
&["--literal-pathspecs", "blame", "--porcelain", revision, "--", rel]
} else {
&["--literal-pathspecs", "blame", "--no-textconv", "--porcelain", revision, "--", rel]
let args: Vec<&str> = {
let mut args = vec!["--literal-pathspecs", "blame"];
if !crate::execution_allowed() {
args.push("--no-textconv");
}
args.extend(ignore.iter().map(String::as_str));
args.extend(["--porcelain", revision, "--", rel]);
args
};
let out = run(r.root(), args, None)?;
let out = run(r.root(), &args, None)?;
let physical = crate::blame::parse_porcelain(&out.stdout_str());
let raw = crate::text_encoding::decode_raw(&base, encoding);
let raw = crate::diff::normalize_eol(&raw);
@@ -1049,8 +1128,18 @@ pub(crate) fn rebase(r: &Repo, onto: &str) -> IdeResult<()> {
})
}
pub(crate) fn stash_push(r: &Repo, message: Option<&str>, include_untracked: bool, keep_index: bool) -> IdeResult<()> {
let mut args = vec!["stash", "push", "-q"];
pub(crate) fn stash_push(
r: &Repo,
message: Option<&str>,
include_untracked: bool,
keep_index: bool,
paths: &[PathBuf],
) -> IdeResult<()> {
let rel = r.rel_paths(paths)?;
// Literal pathspecs only for the chosen files: git's whole-tree stash passes itself `:/`,
// which a literal pathspec turns into a file of that name.
let mut args = if rel.is_empty() { vec![] } else { vec!["--literal-pathspecs"] };
args.extend(["stash", "push", "-q"]);
if include_untracked {
args.push("--include-untracked");
}
@@ -1060,6 +1149,10 @@ pub(crate) fn stash_push(r: &Repo, message: Option<&str>, include_untracked: boo
if let Some(m) = message.filter(|m| !m.trim().is_empty()) {
args.extend(["-m", m]);
}
if !rel.is_empty() {
args.push("--");
args.extend(rel.iter().map(String::as_str));
}
run(r.root(), &args, None).map(|_| ())
}
@@ -1383,3 +1476,19 @@ fn decode_diff(bytes: &[u8], encoding: TextEncoding) -> String {
}
text
}
#[cfg(test)]
mod tests {
use super::append_revision;
#[test]
fn a_revision_is_appended_once_keeping_the_list() {
let (a, b) = ("a".repeat(40), "b".repeat(40));
assert_eq!(append_revision("", &b), format!("{b}\n"));
assert_eq!(append_revision(&format!("{a}\n# comment\n"), &b), format!("{a}\n# comment\n{b}\n"));
assert_eq!(append_revision(&a, &b), format!("{a}\n{b}\n"));
let listed = format!("# reformat\n {b} \n{a}\n");
assert_eq!(append_revision(&listed, &b), listed);
assert_eq!(append_revision(&format!("{a}\r\n"), &b), format!("{a}\r\n{b}\r\n"));
}
}
+131
Переглянути файл
@@ -0,0 +1,131 @@
//! Annotate skips the commits listed in `blame.ignoreRevsFile`.
use ide_vcs::Repo;
use std::path::{Path, PathBuf};
use std::process::Command;
struct Fixture {
_tmp: tempfile::TempDir,
dir: PathBuf,
}
fn git_in(dir: &Path, args: &[&str]) -> String {
let out = Command::new("git")
.current_dir(dir)
.args(["-c", "init.defaultBranch=main", "-c", "core.quotepath=off"])
.args(args)
.env("GIT_TERMINAL_PROMPT", "0")
.output()
.expect("git runs");
assert!(out.status.success(), "git {args:?} failed: {}", String::from_utf8_lossy(&out.stderr));
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
impl Fixture {
fn new() -> Self {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("work");
std::fs::create_dir(&dir).unwrap();
git_in(&dir, &["init", "-q"]);
for (k, v) in [
("user.name", "Test User"),
("user.email", "[email protected]"),
("commit.gpgsign", "false"),
("core.autocrlf", "false"),
] {
git_in(&dir, &["config", k, v]);
}
Fixture { _tmp: tmp, dir }
}
fn git(&self, args: &[&str]) -> String {
git_in(&self.dir, args)
}
fn write(&self, rel: &str, content: &str) {
std::fs::write(self.dir.join(rel), content).unwrap();
}
fn commit_all(&self, msg: &str) -> String {
self.git(&["add", "-A"]);
self.git(&["commit", "-q", "-m", msg]);
self.git(&["rev-parse", "HEAD"])
}
fn path(&self, rel: &str) -> PathBuf {
self.dir.join(rel)
}
/// The repository of a trusted project.
fn repo(&self) -> Repo {
let repo = Repo::discover(&self.dir).unwrap().expect("repository found");
repo.set_execution_allowed(true);
repo
}
/// The repository of a project in Restricted Mode.
fn restricted(&self) -> Repo {
Repo::discover(&self.dir).unwrap().expect("repository found")
}
}
/// A line added by `first` and reformatted by `second`; annotate attributes it to
/// `second` until `second` is ignored, then back to `first`.
fn reformatted_file() -> (Fixture, String, String) {
let fx = Fixture::new();
fx.write("file.txt", "first\nsecond\n");
let first = fx.commit_all("add file");
fx.write("file.txt", "first\n second\n");
let second = fx.commit_all("reformat");
(fx, first, second)
}
fn commits(repo: &Repo, path: &Path) -> Vec<String> {
repo.blame(path, None, None, encoding_rs::UTF_8).unwrap().into_iter().map(|line| line.commit).collect()
}
#[test]
fn a_listed_commit_gives_its_lines_back_to_the_earlier_one() {
let (fx, first, second) = reformatted_file();
let file = fx.path("file.txt");
assert_eq!(commits(&fx.repo(), &file), vec![first.clone(), second.clone()]);
fx.write(".git-blame-ignore-revs", &format!("{second}\n"));
fx.git(&["config", "blame.ignoreRevsFile", ".git-blame-ignore-revs"]);
let ignored = vec![first.clone(), first.clone()];
assert_eq!(commits(&fx.repo(), &file), ignored);
// Restricted Mode reads the same configuration and skips the same commit.
assert_eq!(commits(&fx.restricted(), &file), ignored);
}
#[test]
fn a_configured_file_that_does_not_exist_is_skipped() {
let (fx, _, second) = reformatted_file();
fx.git(&["config", "blame.ignoreRevsFile", ".git-blame-ignore-revs"]);
let file = fx.path("file.txt");
// The missing list is passed to no blame, so annotate still works.
assert_eq!(commits(&fx.repo(), &file)[1], second);
assert_eq!(commits(&fx.restricted(), &file)[1], second);
}
#[test]
fn ignoring_a_commit_starts_the_list_and_configures_it_once() {
let (fx, first, second) = reformatted_file();
fx.repo().ignore_blame_revision(&second).unwrap();
fx.repo().ignore_blame_revision(&second).unwrap();
assert_eq!(std::fs::read_to_string(fx.path(".git-blame-ignore-revs")).unwrap(), format!("{second}\n"));
assert_eq!(fx.git(&["config", "--local", "--get", "blame.ignoreRevsFile"]), ".git-blame-ignore-revs");
let repo = fx.repo();
assert_eq!(commits(&repo, &fx.path("file.txt")), vec![first.clone(), first.clone()]);
}
#[test]
fn ignoring_a_commit_adds_it_to_the_configured_list() {
let (fx, first, second) = reformatted_file();
fx.write("custom-revs", &format!("# formatting\r\n{first}\r\n"));
fx.git(&["config", "--local", "blame.ignoreRevsFile", "custom-revs"]);
fx.repo().ignore_blame_revision(&second).unwrap();
// Lines and line endings kept; the configured file is the one annotate reads.
assert_eq!(
std::fs::read_to_string(fx.path("custom-revs")).unwrap(),
format!("# formatting\r\n{first}\r\n{second}\r\n")
);
assert!(!fx.path(".git-blame-ignore-revs").exists());
assert_eq!(fx.git(&["config", "--local", "--get", "blame.ignoreRevsFile"]), "custom-revs");
let blamed = commits(&fx.repo(), &fx.path("file.txt"));
assert_ne!(blamed[1], second);
}
+48 -3
Переглянути файл
@@ -769,11 +769,11 @@ fn stash_push_list_apply_pop() {
let repo = fx.repo();
fx.write("s.txt", "change 1\n");
repo.stash_push(Some("first stash"), false, false).unwrap();
repo.stash_push(Some("first stash"), false, false, &[]).unwrap();
assert_eq!(fx.read("s.txt"), "base\n");
fx.write("s.txt", "change 2\n");
fx.write("u.txt", "untracked\n");
repo.stash_push(None, true, false).unwrap();
repo.stash_push(None, true, false, &[]).unwrap();
assert!(!fx.path("u.txt").exists());
let stashes = repo.stashes().unwrap();
@@ -808,7 +808,7 @@ fn stash_keep_index_reinstate_index_and_branch() {
fx.write("a.txt", "a staged\n");
fx.git(&["add", "a.txt"]);
fx.write("b.txt", "b unstaged\n");
repo.stash_push(Some("keep"), false, true).unwrap();
repo.stash_push(Some("keep"), false, true, &[]).unwrap();
// Keep index: the staged change stays, the unstaged one is stashed.
assert_eq!(fx.read("a.txt"), "a staged\n");
assert_eq!(fx.read("b.txt"), "b\n");
@@ -2065,3 +2065,48 @@ fn a_case_only_directory_rename_keeps_status_and_head_content() {
assert_eq!(status_of(&st, "src/financial/table/index.vue").staged, Some(ChangeKind::Modified));
assert_eq!(fx.git(&["diff", "--cached", "--name-only"]), "src/financial/table/index.vue");
}
#[test]
fn status_paths_reads_only_the_named_paths() {
// The dirty-scope refresh: a watcher batch names a few paths, and git's pathspec
// filtering keeps the answer proportional to the batch instead of the whole tree.
let fx = Fixture::new();
fx.write("a.txt", "a\n");
fx.write("b.txt", "a\n");
fx.write("dir/c.txt", "a\n");
fx.commit_all("base");
fx.write("a.txt", "b\n");
fx.write("b.txt", "b\n");
fx.write("dir/c.txt", "b\n");
fx.write("new.txt", "n\n");
let repo = fx.repo();
repo.set_ignored_names(ide_core::settings::Settings::default().ignored_names());
let one = repo.status_paths(&[fx.path("a.txt")]).unwrap();
assert_eq!(one.len(), 1, "{one:#?}");
assert_eq!(one[0].rel_path, "a.txt");
assert_eq!(one[0].unstaged, Some(ChangeKind::Modified));
// A directory stands for everything below it, as the watcher reports a removed one.
let dir = repo.status_paths(&[fx.path("dir")]).unwrap();
assert_eq!(dir.iter().map(|s| s.rel_path.as_str()).collect::<Vec<_>>(), ["dir/c.txt"]);
// A nested file: the path a watcher hands over is spelled by the platform, and the pathspec
// needs `/` whatever the platform's separator is.
let nested = repo.status_paths(&[fx.path("dir").join("c.txt")]).unwrap();
assert_eq!(nested.iter().map(|s| s.rel_path.as_str()).collect::<Vec<_>>(), ["dir/c.txt"]);
// The repository root itself names everything, not a path called "".
assert_eq!(repo.status_paths(&[fx.path("")]).unwrap().len(), 4);
// A new file the batch names is reported as untracked, not as a directory entry.
let new = repo.status_paths(&[fx.path("new.txt")]).unwrap();
assert_eq!((new[0].rel_path.as_str(), new[0].unstaged), ("new.txt", Some(ChangeKind::Untracked)));
// Paths outside the repository are ignored rather than an error; nothing named is nothing read.
let outside = if cfg!(windows) { PathBuf::from("C:/elsewhere/x.txt") } else { PathBuf::from("/elsewhere/x.txt") };
assert!(repo.status_paths(&[outside]).unwrap().is_empty());
// An empty list is the full status, so a caller that lost the paths stays correct.
assert_eq!(repo.status_paths(&[]).unwrap().len(), 4);
}
+124
Переглянути файл
@@ -0,0 +1,124 @@
//! Stashing only the selected files.
use ide_core::error::ErrorKind;
use ide_vcs::Repo;
use std::path::{Path, PathBuf};
use std::process::Command;
struct Fixture {
_tmp: tempfile::TempDir,
dir: PathBuf,
}
fn git_in(dir: &Path, args: &[&str]) -> String {
let out = Command::new("git")
.current_dir(dir)
.args(["-c", "init.defaultBranch=main", "-c", "core.quotepath=off"])
.args(args)
.env("GIT_TERMINAL_PROMPT", "0")
.output()
.expect("git runs");
assert!(out.status.success(), "git {args:?} failed: {}", String::from_utf8_lossy(&out.stderr));
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
fn configure(dir: &Path) {
for (k, v) in [
("user.name", "Test User"),
("user.email", "[email protected]"),
("commit.gpgsign", "false"),
("core.autocrlf", "false"),
] {
git_in(dir, &["config", k, v]);
}
}
impl Fixture {
fn new() -> Self {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("work");
std::fs::create_dir(&dir).unwrap();
git_in(&dir, &["init", "-q"]);
configure(&dir);
Fixture { _tmp: tmp, dir }
}
fn git(&self, args: &[&str]) -> String {
git_in(&self.dir, args)
}
fn write(&self, rel: &str, content: &str) {
std::fs::write(self.dir.join(rel), content).unwrap();
}
fn read(&self, rel: &str) -> String {
std::fs::read_to_string(self.dir.join(rel)).unwrap()
}
fn commit_all(&self, msg: &str) {
self.git(&["add", "-A"]);
self.git(&["commit", "-q", "-m", msg]);
}
fn repo(&self) -> Repo {
let repo = Repo::discover(&self.dir).unwrap().expect("repository found");
repo.set_execution_allowed(true);
repo
}
fn path(&self, rel: &str) -> PathBuf {
self.dir.join(rel)
}
}
#[test]
fn stash_push_stashes_only_the_given_paths() {
let fx = Fixture::new();
fx.write("a.txt", "a\n");
fx.write("b.txt", "b\n");
fx.commit_all("base");
let repo = fx.repo();
fx.write("a.txt", "a changed\n");
fx.write("b.txt", "b changed\n");
repo.stash_push(Some("only a"), false, false, &[fx.path("a.txt")]).unwrap();
assert_eq!(fx.read("a.txt"), "a\n");
assert_eq!(fx.read("b.txt"), "b changed\n");
assert_eq!(repo.stashes().unwrap().len(), 1);
repo.stash_apply(0, true, false).unwrap();
assert_eq!(fx.read("a.txt"), "a changed\n");
assert_eq!(fx.read("b.txt"), "b changed\n");
assert!(repo.stashes().unwrap().is_empty());
}
#[test]
fn stash_push_stashes_only_the_given_untracked_paths() {
let fx = Fixture::new();
fx.write("a.txt", "a\n");
fx.commit_all("base");
let repo = fx.repo();
fx.write("new.txt", "new\n");
fx.write("other.txt", "other\n");
repo.stash_push(None, true, false, &[fx.path("new.txt")]).unwrap();
assert!(!fx.path("new.txt").exists());
assert!(fx.path("other.txt").exists());
assert_eq!(repo.stashes().unwrap().len(), 1);
repo.stash_apply(0, true, false).unwrap();
assert_eq!(fx.read("new.txt"), "new\n");
}
#[test]
fn stash_push_rejects_paths_outside_the_repository() {
let fx = Fixture::new();
fx.write("a.txt", "a\n");
fx.commit_all("base");
let repo = fx.repo();
fx.write("a.txt", "a changed\n");
let outside = tempfile::tempdir().unwrap();
std::fs::write(outside.path().join("outside.txt"), "outside\n").unwrap();
let error = repo.stash_push(None, false, false, &[outside.path().join("outside.txt")]).unwrap_err();
assert_eq!(error.kind, ErrorKind::InvalidInput);
assert!(error.message.contains("outside the repository"), "{error:?}");
// Nothing was stashed and the change is still in the working tree.
assert!(repo.stashes().unwrap().is_empty());
assert_eq!(fx.read("a.txt"), "a changed\n");
}
+1 -1
Переглянути файл
@@ -41,7 +41,7 @@ User-visible text is written in English at the call site and goes through the te
## WebKit compatibility
On macOS and Linux the app runs in WebKit, and the supported floor is Safari 16 (macOS 11 can update to it). `pnpm build` runs `scripts/check-webkit.ts` on the bundle, and `src/platform/webkitCompatibility.test.ts` checks application regexes. Avoid:
On macOS and Linux the app runs in WebKit, and the supported floor is Safari 16, the engine of macOS 13 (on macOS 11 and 12 a Safari update doesn't update the engine other apps use). `pnpm build` runs `scripts/check-webkit.ts` on the bundle, and `src/platform/webkitCompatibility.test.ts` checks application regexes. Avoid:
- regex lookbehind (`(?<=…)`, `(?<!…)`), which needs Safari 16.4, and the regex `v` flag;
- import attributes;
+1 -1
Переглянути файл
@@ -9,7 +9,7 @@ In the file names below, `<version>` is the release version, for example `0.1.40
## macOS
macOS 11 or later.
macOS 13 Ventura or later.
| Mac | File |
| ------------- | -------------------------------- |
+3 -2
Переглянути файл
@@ -18,8 +18,9 @@ Agent tasks started inside OpenIDEA (see [Agents](/en/guide/agents)) get the sam
</Step>
</Steps>
The server listens on `127.0.0.1` only, and every request must carry a bearer token. Requests from another origin or host are refused.
By default the server listens on `127.0.0.1` only, and every request must carry a bearer token. Requests from another origin, or naming the server by a host name other than `localhost`, are refused.
- **Listen address.** `127.0.0.1` (the default) lets only this computer connect. To let agents on another computer connect, enter another address of this computer (a LAN or VPN address) or `0.0.0.0` for every address; they connect to `http://<that address>:<port>/mcp` with the token. Anyone who can reach the address and has the token can use the tools, so keep it to networks you trust. **Status** shows the address when it is not `127.0.0.1`.
- **Port.** By default it listens on the same port, 26371, in every run (**Use a fixed port** and **Port**), so HTTP clients and port forwards (an SSH tunnel to another machine, say) keep working after a restart. When the port is in use or reserved, that run takes a free port and **Status** says why. Turn **Use a fixed port** off for a free port in every run.
- **Token.** By default the token changes each time OpenIDEA starts. Turn on **Keep the token** to keep one across runs, stored in OpenIDEA's application data for your account only; **Regenerate Token** replaces it, and clients configured with the old one stop working.
@@ -28,7 +29,7 @@ The server listens on `127.0.0.1` only, and every request must carry a bearer to
The settings page has two buttons under **Client configuration**. Both copy an `mcpServers` entry in the JSON format most clients accept.
- **Copy Stdio Config** (recommended): the client starts OpenIDEA's executable with `--mcp-stdio`, which relays to the running IDE. It holds no token and keeps working after OpenIDEA restarts.
- **Copy HTTP Config**: the client connects to `http://127.0.0.1:<port>/mcp` with `Authorization: Bearer <token>`. It keeps working after OpenIDEA restarts only with a fixed port and a kept token; otherwise copy it again after each restart.
- **Copy HTTP Config**: the client connects to `http://<address>:<port>/mcp` (`127.0.0.1` when listening on every address) with `Authorization: Bearer <token>`. It keeps working after OpenIDEA restarts only with a fixed port and a kept token; otherwise copy it again after each restart.
The stdio configuration looks like this (the path is the one on your machine):
+2 -2
Переглянути файл
@@ -75,7 +75,7 @@ Pick **VS Code (macOS)** or **VS Code (Windows)** as the keymap; then `⇧⌘P`
| Extensions | **Settings › Plugins** |
| `settings.json` | **Settings** (`⌘,` / `Ctrl+Alt+S`), stored in `settings.toml`; per-project values in `.openidea/settings.toml` |
| `launch.json` | Run configurations: **Import Run Configurations from .vscode/launch.json** |
| Multi-root workspace | **File › New Workspace…** and **Attach Project…** |
| Multi-root workspace | **File › New › Workspace…** and **Attach Project…** |
`.editorconfig` files are applied. VS Code settings and extensions are not imported.
@@ -84,7 +84,7 @@ Pick **VS Code (macOS)** or **VS Code (Windows)** as the keymap; then `⇧⌘P`
Pick **Eclipse (macOS)** or **Eclipse** as the keymap.
- **Formatter.** To keep using an Eclipse formatter profile for Java, open **Settings › Editor › Code Style › Java**, set **Formatting profile** to **Custom Eclipse formatter XML** and choose the exported XML file.
- **Workspaces and projects.** Eclipse opens many projects in one workspace. OpenIDEA opens one project per window; to work on several folders together, create a workspace with **File › New Workspace…** or add folders with **Attach Project…**.
- **Workspaces and projects.** Eclipse opens many projects in one workspace. OpenIDEA opens one project per window; to work on several folders together, create a workspace with **File › New › Workspace…** or add folders with **Attach Project…**.
- **Perspectives.** OpenIDEA has no perspectives. Arrange tool windows the way you like and save the arrangement with **Window › Tool Window Layouts › Save Current Layout as…**.
## Concepts at a glance
+1 -1
Переглянути файл
@@ -13,7 +13,7 @@ A scratch file is a throwaway file in any language: a place to try a snippet, ke
<Steps>
<Step title="Create one">
Choose **File › New Scratch File…** (or **Scratch File** at the top of the Project view's **New** menu).
Choose **File › New › Scratch File** (or **Scratch File** at the top of the Project view's **New** menu).
</Step>
<Step title="Pick a language">
Type to filter the language list and press `↵` / `Enter`.
+1 -1
Переглянути файл
@@ -73,7 +73,7 @@ The page also has **Expand abbreviations with Tab** and **Show live templates in
Live templates, postfix settings and macros are stored per user on this machine, not in the project.
For new-file templates (what **File › New…** creates), see **Settings › Editor › File and Code Templates**.
For new-file templates (what **File › New › File…** creates), see **Settings › Editor › File and Code Templates**.
## Macros
+10 -5
Переглянути файл
@@ -41,8 +41,7 @@ It reads repository state (status, diffs, blame, log) in-process, and runs the `
- **Git › Clone…** opens **Clone Repository**. Enter a **URL:** and **Directory:**, and optionally check **Shallow clone with a history truncated to** N commits. The **GitHub** source in the sidebar lists your GitHub repositories (see [GitHub](/en/guide/github)).
- When creating a project, check **Create Git repository** in the New Project dialog.
There is no action to run `git init` in a project that is already open; run it in the terminal and OpenIDEA picks the repository up.
- **Git › Create Git Repository…** runs `git init` in a folder you pick (the project root by default) and registers the new repository right away; a repository created in the terminal is picked up the same way.
OpenIDEA finds the repository that contains the project and nested repositories up to four levels deep (skipping folders such as `node_modules`, `target` and `build`). Status, commit, fetch and update cover all of them; branch and log actions work on the selected repository. Submodules show as nested repositories, without submodule commands.
@@ -131,6 +130,8 @@ Other ways to compare:
Click the branch widget in the title bar (or **Git › Branches…**) to open the branches popup. Type to search branches and actions. The popup shows **Recent**, **Local**, **Remote** and **Tags**, and at the top **Update Project…**, **Commit…**, **Push…**, **New Branch…** and **Checkout Tag or Revision…**. The gear button toggles grouping by prefix, recent branches and tags.
Click the incoming or outgoing marker next to the branch name in the title bar (or **Incoming and Outgoing Commits…** in the branches popup) to list the commits to pull or push, newest first. Click a commit for its diff, or **Open in Git Log** to show it in the Git log.
Select a branch (`→` or `Enter`) for its actions:
- **Checkout**, **New Branch from '…'…**, **Checkout and Rebase onto '…'**
@@ -195,7 +196,7 @@ Limits: interactive rebase doesn't support ranges with merge commits, and **Undo
**Stash** uses `git stash`:
- **Stash Changes…**: a message, **Keep index** and **Include untracked files**. Stashes always include all changes; there is no per-file stash.
- **Stash Changes…**: a message, **Keep index** and **Include untracked files**; it stashes the whole working tree. **Stash Selected Files…** in the changes tree's context menu stashes only the selected files.
- **Unstash Changes…**: apply or pop a stash, reinstate the index, or create a branch from it.
- The **Stash** tab of the Commit tool window lists stashes with apply, pop and drop.
@@ -212,10 +213,14 @@ Turn on **Combine stashes and shelves in one tab** in the Git settings to show b
## Blame and history
- **Annotate with Git Blame** shows author and date per line in the gutter (also from the gutter's context menu). Click an entry to open the commit in the log. **Show inline blame for the current line** in the Git settings shows the author at the end of the caret line.
- **Annotate Previous Revision** in an annotated line's menu opens the file as it was before that commit, annotated, next to the commit's version.
- **Annotate at Revision…** picks any revision the file has and shows it annotated next to the working copy.
- **Show History** opens the file's history in a tab of the Git tool window.
- **Show History for Selection** shows the commits that changed the selected lines.
- **Show Current Revision** reveals the commit of the caret line in the log.
Annotate skips the commits listed in the file `blame.ignoreRevsFile` names — git's convention is `.git-blame-ignore-revs` at the repository root — so a reformatting or mass-rename commit does not claim every line. **Ignore This Commit** in an annotated line's menu adds the current line's commit to that file and refreshes the annotations.
### Local history
Local history keeps snapshots of your files independent of Git, including deleted files and labelled states such as the one taken before a refactoring.
@@ -244,6 +249,6 @@ Retention is set in the **Local History** section of **Settings › Appearance &
## Not supported
- Mercurial, Subversion, Perforce and other systems.
- `git init` for an open project, submodule commands, editable directory mappings.
- Per-line partial commits (block level only) and per-file stashes.
- Submodule commands and editable directory mappings.
- Per-line partial commits (block level only).
- Git LFS beyond the large-file warning.
+4 -3
Переглянути файл
@@ -49,10 +49,11 @@ All servers, debuggers and formatters are listed in **Settings › Languages & F
The Astro and Angular servers also run from the project's own `node_modules/.bin` when it has them.
### Node.js
### JavaScript runtime
**Settings › Languages & Frameworks › Node.js** has:
**Settings › Languages & Frameworks › JavaScript Runtime** has:
- **Preferred runtime**: what runs JavaScript and TypeScript files from the gutter and the editor's **Run** action: Node.js, Bun or Deno. **Auto** looks for the nearest `deno.json` / `deno.jsonc` (Deno) or `bun.lock` / `bun.lockb` (Bun) up to the project root, and otherwise uses Node.js.
- **Node interpreter**: the `node` that runs npm scripts, Node files and the Node-based language servers. **Default** uses `node` on `PATH`. The list shows every Node.js found on `PATH` and those installed by nvm, Volta, fnm, mise and asdf.
- **Run in target**: run Node and package-manager commands locally or in one of the run targets from **Settings › Build, Execution, Deployment › Run Targets**.
- **Package manager**: `npm`, `pnpm`, `yarn` or `bun` for the detected script configurations. **Auto** reads `packageManager` in `package.json`, then the lock file (`pnpm-lock.yaml`, `yarn.lock`, `bun.lock` / `bun.lockb`, `package-lock.json`), and otherwise uses npm.
@@ -98,7 +99,7 @@ With **Do not step into library scripts** (**Settings › Build, Execution, Depl
- **React**: JSX and TSX get their own highlighting modes and full TypeScript server support. There is no React-specific tooling beyond that.
- **Vue**: `.vue` files are highlighted per block and served by Vue Language Server in hybrid mode. In a Vue CLI project without `tsconfig.json`, webpack's `resolve.alias` entries (such as `@/`) are read so imports resolve. Prettier formats `.vue` files. Emmet works in templates.
- **Svelte**: Svelte Language Server; highlighting uses the HTML mode (script as TypeScript, style as CSS). Emmet works in markup.
- **Astro**: Astro Language Server with its own highlighting mode. It needs Node.js; if it can't find one, select it in **Settings › Languages & Frameworks › Node.js**.
- **Astro**: Astro Language Server with its own highlighting mode. It needs Node.js; if it can't find one, select it in **Settings › Languages & Frameworks › JavaScript Runtime**.
- **Angular**: in projects with `angular.json` or `@angular/core` installed, templates in HTML files and inline `template:` strings get Angular highlighting, and the Angular Language Server adds template completion, navigation and rename. The server is configured for the project's Angular version.
## Limitations
+2 -2
Переглянути файл
@@ -11,7 +11,7 @@ This page covers how a plugin works with files:
projects.
- **Other files** (`fs.wit`, API 1.3): files outside the projects, under explicit grants,
and the plugin's own private directory.
- **File templates**: entries of the **File › New…** menu, no code needed.
- **File templates**: entries of the **File › New › File…** menu, no code needed.
## File actions
@@ -180,7 +180,7 @@ as an argument to an approved process.
## File templates
`[[contributes.fileTemplates]]` adds entries to the **File › New…** menu. They need no code and grant
`[[contributes.fileTemplates]]` adds entries to the **File › New › File…** menu. They need no code and grant
no file access: files are created only when the user invokes a template, through the IDE's
normal New File flow.
+1 -1
Переглянути файл
@@ -44,7 +44,7 @@ Declare the API your plugin needs with `engines.openidea` in the manifest, for e
## Where plugins come from
- **Marketplace:** **Settings › Plugins › Available** lists the official plugins and the community plugins of the [registry](https://github.com/l0ng-ai/openidea-plugins). See [Publish to the marketplace](/en/plugins/publishing/marketplace).
- **Marketplace:** **Settings › Plugins** lists the installed plugins first, then the official plugins and the community plugins of the [registry](https://github.com/l0ng-ai/openidea-plugins). See [Publish to the marketplace](/en/plugins/publishing/marketplace).
- **Disk:** **Install Plugin from Disk…** (a folder) or **Install Plugin from Package File…** (an `.oiplugin` or `.zip`) in the gear menu of **Settings › Plugins**.
- **Linked folders:** `openidea-plugin link` loads a folder you are developing, without installing it.
- **Project plugins:** packages in `<project>/.openidea/plugins/<name>/` load in trusted projects only, after you approve their permissions.
+1 -1
Переглянути файл
@@ -3,7 +3,7 @@ title: "Publish to the marketplace"
description: "How the default marketplace is built, official versus community plugins, and how to list your plugin through the community registry."
---
The IDE's default marketplace is the `plugins-index.json` attached to the latest OpenIDEA release. Users browse it in **Settings › Plugins › Available**, filter by category, and install with one click. It lists two kinds of plugins.
The IDE's default marketplace is the `plugins-index.json` attached to the latest OpenIDEA release. Users browse it in **Settings › Plugins**, filter by category, and install with one click. It lists two kinds of plugins.
| | Official | Community |
| ------------------- | ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
+1 -1
Переглянути файл
@@ -41,7 +41,7 @@ openidea-plugin merge market/index.json https://github.com/l0ng-ai/openidea/rele
## Point the IDE at it
In **Settings › Plugins**, open the gear menu and choose **Plugin Repository…**. Enter the **Marketplace index URL** (`https://…/index.json` or a folder path); leave it empty to go back to the default repository. The **Available** tab then lists your index.
In **Settings › Plugins**, open the gear menu and choose **Plugin Repository…**. Enter the **Marketplace index URL** (`https://…/index.json` or a folder path); leave it empty to go back to the default repository. The marketplace plugins in the list then come from your index.
The IDE reads one index. If you want users to keep seeing the official plugins, merge the official index into yours.
+1 -1
Переглянути файл
@@ -41,7 +41,7 @@ description: "前端 UI 代码的规则:设计 token、CSS Modules、图标、
## WebKit 兼容性
在 macOS 和 Linux 上应用运行于 WebKit,最低支持 Safari 16(macOS 11 可以升级到这个版本)。`pnpm build` 会对产物运行 `scripts/check-webkit.ts`,`src/platform/webkitCompatibility.test.ts` 会检查应用中的正则。请避免:
在 macOS 和 Linux 上应用运行于 WebKit,最低支持 Safari 16,即 macOS 13 的引擎(macOS 11、12 上更新 Safari 不会更新其他应用使用的引擎)。`pnpm build` 会对产物运行 `scripts/check-webkit.ts`,`src/platform/webkitCompatibility.test.ts` 会检查应用中的正则。请避免:
- 正则后行断言(`(?<=…)`、`(?<!…)`,需要 Safari 16.4)和正则 `v` 标志;
- import attributes;
+1 -1
Переглянути файл
@@ -9,7 +9,7 @@ description: "在 macOS、Windows 或 Linux 上下载并安装 OpenIDEA,以及
## macOS
需要 macOS 11 或更高版本。
需要 macOS 13 Ventura 或更高版本。
| Mac | 文件 |
| ---------- | -------------------------------- |
+3 -2
Переглянути файл
@@ -17,8 +17,9 @@ OpenIDEA 内置了一个 [Model Context Protocol](https://modelcontextprotocol.i
</Step>
</Steps>
服务器只监听 `127.0.0.1`,每个请求都必须带上访问令牌。来自其他 Origin 或 Host 的请求会被拒绝。
服务器默认只监听 `127.0.0.1`,每个请求都必须带上访问令牌。来自其他 Origin 的请求,以及用 `localhost` 以外的主机名访问服务器的请求,都会被拒绝。
- **监听地址。** `127.0.0.1`(默认)只允许本机连接。要让其他电脑上的智能体连接,就填本机的其他地址(局域网或 VPN 地址),或填 `0.0.0.0` 监听所有地址;对方带着令牌连接 `http://<该地址>:<端口>/mcp`。任何能访问该地址并持有令牌的人都能使用这些工具,所以只在可信的网络里这样做。地址不是 `127.0.0.1` 时,**状态**会显示它。
- **端口。** 默认每次运行都监听同一个端口 26371(**使用固定端口** 和 **端口**),这样 OpenIDEA 重启后 HTTP 客户端和端口转发(例如到另一台机器的 SSH 隧道)仍然有效。端口被占用或被系统保留时,本次运行改用一个空闲端口,**状态** 中会说明原因。关闭 **使用固定端口** 则每次运行使用一个空闲端口。
- **令牌。** 默认每次启动 OpenIDEA 都会换一个新令牌。打开 **保留令牌** 后会在多次运行间保留同一个令牌,保存在 OpenIDEA 的应用数据目录中,只有你的账户可读;**重新生成令牌** 会替换它,使用旧令牌配置的客户端随之失效。
@@ -27,7 +28,7 @@ OpenIDEA 内置了一个 [Model Context Protocol](https://modelcontextprotocol.i
设置页面的 **客户端配置** 下有两个按钮,都会复制一段大多数客户端都能识别的 `mcpServers` JSON 配置。
- **复制 Stdio 配置**(推荐):客户端以 `--mcp-stdio` 参数启动 OpenIDEA 的可执行文件,由它转发给正在运行的 IDE。配置里没有令牌,OpenIDEA 重启后依然可用。
- **复制 HTTP 配置**:客户端带着 `Authorization: Bearer <token>` 连接 `http://127.0.0.1:<port>/mcp`。只有同时使用固定端口并保留令牌时,OpenIDEA 重启后这份配置才继续有效;否则每次重启后都要重新复制。
- **复制 HTTP 配置**:客户端带着 `Authorization: Bearer <token>` 连接 `http://<地址>:<端口>/mcp`(监听所有地址时为 `127.0.0.1`)。只有同时使用固定端口并保留令牌时,OpenIDEA 重启后这份配置才继续有效;否则每次重启后都要重新复制。
stdio 配置大致如下(路径以你机器上的为准):
+2 -2
Переглянути файл
@@ -75,7 +75,7 @@ Vim 模式会读取 `~/.ideavimrc`(或 `~/.openideavimrc`)。通过 **工具
| 扩展 | **设置 › 插件** |
| `settings.json` | **设置**(`⌘,` / `Ctrl+Alt+S`),保存在 `settings.toml`;项目级的值保存在 `.openidea/settings.toml` |
| `launch.json` | 运行配置:**从 .vscode/launch.json 导入运行配置** |
| 多根工作区 | **文件 › 新建工作区…** 和 **附加项目…** |
| 多根工作区 | **文件 › 新建 › 工作区…** 和 **附加项目…** |
项目中的 `.editorconfig` 会生效。VS Code 的设置和扩展不会导入。
@@ -84,7 +84,7 @@ Vim 模式会读取 `~/.ideavimrc`(或 `~/.openideavimrc`)。通过 **工具
把快捷键方案切换为 **Eclipse (macOS)** 或 **Eclipse**。
- **格式化器。** 想继续用 Eclipse 的 Java 格式化配置,打开 **设置 › 编辑器 › 代码样式 › Java**,把 **格式化方案** 设为 **自定义 Eclipse 格式化 XML**,然后选择导出的 XML 文件。
- **工作空间与项目。** Eclipse 在一个工作空间里打开多个项目;OpenIDEA 每个窗口打开一个项目。需要同时处理多个文件夹时,用 **文件 › 新建工作区…** 创建工作区,或用 **附加项目…** 添加文件夹。
- **工作空间与项目。** Eclipse 在一个工作空间里打开多个项目;OpenIDEA 每个窗口打开一个项目。需要同时处理多个文件夹时,用 **文件 › 新建 › 工作区…** 创建工作区,或用 **附加项目…** 添加文件夹。
- **透视图。** OpenIDEA 没有透视图。按自己的习惯摆放工具窗口,再用 **窗口 › 工具窗口布局 › 将当前布局另存为…** 保存。
## 概念对照
+1 -1
Переглянути файл
@@ -13,7 +13,7 @@ description: "在项目之外保留临时文件,并在 TODO 工具窗口中汇
<Steps>
<Step title="新建">
选择 **文件 › 新建临时文件…**(或项目视图 **新建** 菜单最上面的 **临时文件**)。
选择 **文件 › 新建 › 临时文件**(或项目视图 **新建** 菜单最上面的 **临时文件**)。
</Step>
<Step title="选择语言">
输入文字过滤语言列表,按 `↵` / `Enter` 确认。
+1 -1
Переглянути файл
@@ -67,7 +67,7 @@ items.for → for (var item : items) { … }
实时模板、后缀补全设置和宏都按用户保存在本机,不保存在项目里。
新建文件用的模板(**文件 › 新建…** 创建的内容)在 **设置 › 编辑器 › 文件和代码模板** 中设置。
新建文件用的模板(**文件 › 新建 › 文件…** 创建的内容)在 **设置 › 编辑器 › 文件和代码模板** 中设置。
## 宏
+10 -5
Переглянути файл
@@ -41,8 +41,7 @@ OpenIDEA 内置 Git 支持,Git 也是它唯一支持的版本控制系统。
- **Git › 克隆…** 打开 **克隆仓库**。填写 **URL:** 和 **目录:**,需要时勾选 **浅克隆,历史记录截断为** N 个提交。侧栏中的 **GitHub** 会列出你的 GitHub 仓库(见 [GitHub](/zh/guide/github))。
- 新建项目时,在新建项目对话框中勾选 **创建 Git 仓库**。
对已经打开的项目,没有执行 `git init` 的操作;在终端中执行后 OpenIDEA 会自动识别仓库。
- **Git › 创建 Git 仓库…** 会在你选择的目录(默认是项目根目录)里执行 `git init`,并立即注册这个新仓库;在终端里创建的仓库同样会被识别。
OpenIDEA 会找到包含项目的仓库,以及向下最多四层的嵌套仓库(跳过 `node_modules`、`target`、`build` 等目录)。状态、提交、获取和更新覆盖所有仓库;分支和日志操作针对所选仓库。子模块显示为嵌套仓库,但没有子模块相关命令。
@@ -131,6 +130,8 @@ OpenIDEA 会找到包含项目的仓库,以及向下最多四层的嵌套仓
点击标题栏的分支微件(或 **Git › 分支…**)打开分支弹窗,输入文字即可搜索分支和操作。弹窗中有 **最近**、**本地**、**远程** 和 **标签** 分组,顶部有 **更新项目…**、**提交…**、**推送…**、**新建分支…** 和 **签出标签或修订…**。齿轮按钮可以切换按前缀分组、显示最近分支和显示标签。
点击标题栏分支名旁的传入或传出标记(或分支弹窗中的 **传入和传出的提交…**)即可列出待拉取或待推送的提交(最新的在前)。点击某个提交查看其差异,或点 **在 Git 日志中打开** 在 Git 日志中显示它。
选中分支(`→` 或 `Enter`)查看它的操作:
- **签出**、**从 '…' 新建分支…**、**签出并变基到 '…'**
@@ -195,7 +196,7 @@ Git 工具窗口的 **日志** 标签页显示提交图。
**贮藏** 使用 `git stash`:
- **贮藏更改…**:填写消息,可选 **保留索引** 和 **包括未跟踪的文件**。贮藏总是包含全部更改,不能只贮藏部分文件。
- **贮藏更改…**:填写消息,可选 **保留索引** 和 **包括未跟踪的文件**,贮藏整个工作区;更改树右键菜单中的 **贮藏所选文件…** 只贮藏选中的文件。
- **取消贮藏更改…**:应用或弹出某个贮藏,恢复索引,或基于它新建分支。
- 提交工具窗口的 **贮藏** 标签页列出所有贮藏,可以应用、弹出和删除。
@@ -212,10 +213,14 @@ Git 工具窗口的 **日志** 标签页显示提交图。
## Blame 与历史
- **使用 Git Blame 注解** 在边栏中逐行显示作者和日期(也可以从边栏右键菜单打开)。点击某条记录会在日志中打开对应提交。在 Git 设置中打开 **为当前行显示内联 Git Blame**,会在光标所在行末尾显示作者。
- 注解行的右键菜单中,**标注上一版本** 会打开该提交之前版本的文件(已注解),与这个提交的版本并排显示。
- **标注指定版本…** 可选择文件的任意版本,把该版本(已注解)与工作区文件并排显示。
- **显示历史记录** 在 Git 工具窗口的标签页中打开文件历史。
- **显示所选内容的历史记录** 列出修改过所选行的提交。
- **显示当前修订版** 在日志中定位光标所在行的提交。
注解会跳过 `blame.ignoreRevsFile` 指向的文件(约定是仓库根目录下的 `.git-blame-ignore-revs`)中列出的提交,因此一次格式化或批量重命名的提交不会认领所有行。在注解行的右键菜单中选择 **忽略此提交**,会把当前行的提交加入该文件并刷新注解。
### 本地历史记录
本地历史记录独立于 Git 保存文件快照,包括已删除的文件,以及重构前自动记录的状态等带标签的快照。
@@ -244,6 +249,6 @@ Git 工具窗口的 **日志** 标签页显示提交图。
## 暂不支持
- Mercurial、Subversion、Perforce 等其他版本控制系统。
- 对已打开的项目执行 `git init`、子模块命令、编辑目录映射。
- 按行的部分提交(只支持按代码块)以及按文件贮藏。
- 子模块命令、编辑目录映射。
- 按行的部分提交(只支持按代码块)。
- 除大文件警告外的 Git LFS 支持。
+4 -3
Переглянути файл
@@ -49,10 +49,11 @@ Vue、Astro 和 Angular 服务器也会使用这里选择的版本。TypeScript
如果项目自己的 `node_modules/.bin` 里有 Astro 或 Angular 服务器,会优先使用项目里的。
### Node.js
### JavaScript 运行时
**设置 › 语言和框架 › Node.js** 包含:
**设置 › 语言和框架 › JavaScript 运行时** 包含:
- **首选运行时**:从行号栏和编辑器的 **运行** 操作运行 JavaScript、TypeScript 文件时用 Node.js、Bun 还是 Deno。**自动** 会从文件所在目录向上找到项目根目录,最近的 `deno.json` / `deno.jsonc` 用 Deno,`bun.lock` / `bun.lockb` 用 Bun,都没有时用 Node.js。
- **Node 解释器**:运行 npm 脚本、Node 文件以及基于 Node 的语言服务器所用的 `node`。**默认** 表示使用 `PATH` 上的 `node`。列表会列出 `PATH` 上找到的所有 Node.js,以及 nvm、Volta、fnm、mise、asdf 安装的版本。
- **在目标中运行**:在本机,或在 **设置 › 构建、执行、部署 › 运行目标** 中配置的某个运行目标里执行 Node 和包管理器命令。
- **软件包管理器**:检测出的脚本配置使用 `npm`、`pnpm`、`yarn` 还是 `bun`。**自动** 先读 `package.json` 的 `packageManager`,再看锁文件(`pnpm-lock.yaml`、`yarn.lock`、`bun.lock` / `bun.lockb`、`package-lock.json`),都没有时用 npm。
@@ -98,7 +99,7 @@ Jest 和 Vitest 的输出会解析成测试树,支持 **重新运行失败的
- **React**:JSX 和 TSX 有各自的高亮模式,并获得完整的 TypeScript 服务器支持;除此之外没有 React 专属功能。
- **Vue**:`.vue` 文件按块高亮,由 Vue Language Server 以 hybrid 模式提供支持。没有 `tsconfig.json` 的 Vue CLI 项目会读取 webpack 的 `resolve.alias`(如 `@/`),让 import 能正确解析。Prettier 可格式化 `.vue` 文件,模板中可用 Emmet。
- **Svelte**:使用 Svelte Language Server;高亮沿用 HTML 模式(script 按 TypeScript、style 按 CSS),标记部分可用 Emmet。
- **Astro**:使用 Astro Language Server,有专门的高亮模式。它需要 Node.js,找不到时请在 **设置 › 语言和框架 › Node.js** 中选择。
- **Astro**:使用 Astro Language Server,有专门的高亮模式。它需要 Node.js,找不到时请在 **设置 › 语言和框架 › JavaScript 运行时** 中选择。
- **Angular**:在含 `angular.json` 或已安装 `@angular/core` 的项目中,HTML 模板和组件里内联的 `template:` 字符串使用 Angular 高亮,Angular Language Server 提供模板补全、跳转和重命名,并按项目的 Angular 版本配置。
## 已知限制
+2 -2
Переглянути файл
@@ -8,7 +8,7 @@ description: "创建、重命名或删除文件的快速修复,读写项目文
- **文件操作**(`file-actions.wit`,API 1.3):创建、重命名或删除文件的快速修复和重构,作为一次可撤销的工作区编辑应用。
- **项目文件**(`workspace.wit`):读取、写入、查找和搜索打开项目中的文件。
- **其他文件**(`fs.wit`,API 1.3):在明确授权下访问项目之外的文件,以及插件自己的私有目录。
- **文件模板**:**文件 › 新建…** 菜单中的条目,不需要代码。
- **文件模板**:**文件 › 新建 › 文件…** 菜单中的条目,不需要代码。
## 文件操作
@@ -162,7 +162,7 @@ fs::write_file("~/.config/mytool/state.json", "{}")?;
## 文件模板
`[[contributes.fileTemplates]]` 在 **文件 › 新建…** 菜单中添加条目。不需要代码,也不授予任何文件访问权限:
`[[contributes.fileTemplates]]` 在 **文件 › 新建 › 文件…** 菜单中添加条目。不需要代码,也不授予任何文件访问权限:
只有用户调用模板时,才通过 IDE 正常的新建文件流程创建文件。
```toml
+1 -1
Переглянути файл
@@ -44,7 +44,7 @@ OpenIDEA 插件可以为 IDE 增加命令、编辑器功能、工具窗口、主
## 插件从哪里来
- **插件市场:** **设置 › 插件 › 可用** 列出官方插件和[社区登记仓库](https://github.com/l0ng-ai/openidea-plugins)中的社区插件。见 [发布到市场](/zh/plugins/publishing/marketplace)。
- **插件市场:** **设置 › 插件** 先列出已安装的插件,再列出官方插件和[社区登记仓库](https://github.com/l0ng-ai/openidea-plugins)中的社区插件。见 [发布到市场](/zh/plugins/publishing/marketplace)。
- **磁盘:** 在 **设置 › 插件** 的齿轮菜单中选择 **从磁盘安装插件…**(文件夹)或 **从插件包文件安装…**(`.oiplugin` 或 `.zip`)。
- **链接的文件夹:** `openidea-plugin link` 让 IDE 直接加载你正在开发的文件夹,无需安装。
- **项目插件:** 放在 `<project>/.openidea/plugins/<name>/` 下的插件包,只在受信任的项目中、且你批准其权限后才会加载。
+1 -1
Переглянути файл
@@ -3,7 +3,7 @@ title: "发布到市场"
description: "默认插件市场如何构建,官方插件与社区插件的区别,以及如何通过社区登记仓库上架你的插件。"
---
IDE 默认的插件市场是最新 OpenIDEA release 附带的 `plugins-index.json`。用户在 **设置 › 插件 › 可用** 中浏览、按分类筛选,一键安装。其中有两类插件:
IDE 默认的插件市场是最新 OpenIDEA release 附带的 `plugins-index.json`。用户在 **设置 › 插件** 中浏览、按分类筛选,一键安装。其中有两类插件:
| | 官方插件 | 社区插件 |
| ---------- | ----------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
+1 -1
Переглянути файл
@@ -41,7 +41,7 @@ openidea-plugin merge market/index.json https://github.com/l0ng-ai/openidea/rele
## 让 IDE 使用它
在 **设置 › 插件** 中打开齿轮菜单,选择 **插件仓库…**,在 **插件市场索引 URL:** 中填入 `https://…/index.json` 或一个文件夹路径;留空则恢复默认仓库。之后 **可用** 标签页列出的就是你的索引。
在 **设置 › 插件** 中打开齿轮菜单,选择 **插件仓库…**,在 **插件市场索引 URL:** 中填入 `https://…/index.json` 或一个文件夹路径;留空则恢复默认仓库。之后插件列表里的市场插件就来自你的索引。
IDE 只读取一个索引。如果希望用户仍能看到官方插件,请把官方索引合并进你的索引。
+133
Переглянути файл
@@ -0,0 +1,133 @@
import type { Page } from "@playwright/test";
import type { AgentTask } from "../src/bindings";
import { expect, test } from "./fixtures";
const task: AgentTask = {
id: "draft-ack",
title: "Draft acknowledgement",
provider: "codex",
status: "ready",
isolation: "local",
model: null,
cwd: "/dev/acme-api",
branch: null,
base: null,
baseCommit: null,
error: null,
createdMs: 0,
updatedMs: 0,
config: [],
commands: [],
usage: null,
queue: [],
};
async function prepare(page: Page, home: boolean, failure: boolean) {
await page.evaluate(
async ({ task, home, failure }) => {
const storePath = "/src/features/agents/store.ts";
const ipcPath = "/src/platform/ipc.ts";
const store = await import(storePath);
const { commands } = await import(ipcPath);
const provider = {
id: "codex",
name: "Codex",
command: "codex",
program: "codex",
args: [],
defaultProgram: null,
defaultArgs: [],
loginHint: null,
commandOverridden: false,
commandEditable: true,
commandReadOnlyReason: null,
installed: true,
env: {},
commands: [],
source: { kind: "custom" },
config: [],
thoughtLevels: {},
};
// Delayed local acknowledgements exercise the real editor without starting an agent.
commands.agentProviders = async () => ({ status: "ok", data: [provider] });
commands.agentProbe = async () => ({ status: "ok", data: null });
commands.agentChanges = async () => ({ status: "ok", data: [] });
const acknowledge = (data: unknown, prompt: unknown) => {
document.documentElement.dataset.submittedPrompt = JSON.stringify(prompt);
return new Promise((resolve) =>
document.addEventListener(
"test:agent-ack",
() => {
resolve(
failure
? { status: "error", error: { kind: "internal", message: "Local send failed" } }
: { status: "ok", data },
);
},
{ once: true },
),
);
};
commands.agentCreate = (request: { prompt: unknown }) => acknowledge(task, request.prompt);
commands.agentPrompt = (_id: string, prompt: unknown) => acknowledge(null, prompt);
store.useAgents.setState({
loaded: true,
providers: [provider],
tasks: new Map([[task.id, task]]),
entries: new Map([[task.id, []]]),
composers: new Map(),
draft: { provider: "codex", config: {}, isolation: "local", base: null },
sessionLocation: "editor",
});
if (home) store.openHome();
else store.openTask(task.id);
},
{ task, home, failure },
);
}
for (const home of [false, true]) {
for (const edited of [false, true]) {
for (const failure of [false, true]) {
test(`${home ? "new task" : "session"}: ${failure ? "failed" : "successful"} acknowledgement with ${edited ? "new typing" : "unchanged draft"}`, async ({
workbench: page,
}) => {
await prepare(page, home, failure);
const box = page.getByRole("textbox", { name: "Message", exact: true });
await box.fill("submitted prompt");
await page.getByRole("button", { name: home ? "Start" : "Send", exact: true }).click();
await expect(page.locator("html")).toHaveAttribute(
"data-submitted-prompt",
JSON.stringify({ text: "submitted prompt", files: [], images: [] }),
);
await expect(page.getByRole("button", { name: home ? "Starting…" : "Send", exact: true })).toBeDisabled();
if (edited) await box.fill("next unsent prompt");
await page.evaluate(() => document.dispatchEvent(new Event("test:agent-ack")));
if (home && !failure) {
await expect(page.getByRole("tab", { name: "Draft acknowledgement" })).toBeVisible();
await page.evaluate(async () => {
const storePath = "/src/features/agents/store.ts";
(await import(storePath)).openHome();
});
}
const expected = edited ? "next unsent prompt" : failure ? "submitted prompt" : "";
if (expected) await expect(box).toHaveText(expected);
else await expect(box.locator(".cm-placeholder")).toBeVisible();
await expect
.poll(() =>
page.evaluate(
async (key) => {
const storePath = "/src/features/agents/store.ts";
return (await import(storePath)).composerDraft(key).text;
},
home ? "home" : task.id,
),
)
.toBe(expected);
await expect(page.getByRole("button", { name: home ? "Start" : "Send", exact: true })).toBeEnabled({
enabled: !!expected,
});
});
}
}
}
Loaded 100 of 354 files, more files were not shown because too many files have changed in this diff. Show more