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

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

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

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

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

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

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

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

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

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

Closes #3311

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

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

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

Part of #3301
2026-11-14 16:07:01 +08:00
155 змінених файлів з 5177 додано та 410 видалено

No files matched your search

Створено
+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"
+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).
+5 -1
Переглянути файл
@@ -1358,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 }
}
}
+3
Переглянути файл
@@ -266,6 +266,8 @@ 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,
@@ -287,6 +289,7 @@ 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,
+225
Переглянути файл
@@ -5,8 +5,12 @@
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";
@@ -56,3 +60,224 @@ impl DebugManager {
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, "");
}
}
+20
Переглянути файл
@@ -24,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};
@@ -140,6 +141,8 @@ pub struct DebugCapabilities {
/// 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`].
@@ -595,6 +598,7 @@ pub(crate) fn capabilities_from(caps: &Value) -> DebugCapabilities {
jvm_instances: false,
jvm_heap: false,
js_page: false,
live_evaluate: false,
}
}
@@ -605,6 +609,7 @@ pub(crate) fn session_capabilities(session: &Session) -> DebugCapabilities {
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
}
@@ -1177,6 +1182,21 @@ impl DebugManager {
}
}
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();
}
+6
Переглянути файл
@@ -839,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}"),
}
}
+90 -3
Переглянути файл
@@ -160,8 +160,13 @@ struct Script {
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 {
@@ -194,6 +199,7 @@ impl Default for Script {
gdb15_starts_at_launch: false,
launch_after_configuration: false,
accept: &[],
custom: None,
}
}
}
@@ -512,6 +518,16 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
}
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"})
),
@@ -966,16 +982,16 @@ 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);
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);
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);
assert!(!script.js_page && !script.live_evaluate);
}
#[tokio::test]
@@ -1017,6 +1033,77 @@ async fn a_page_gets_its_requests_once_its_session_has_started() {
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]");
+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 {
+12
Переглянути файл
@@ -382,6 +382,15 @@ pub async fn debug_js_custom_breakpoints(
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)
}
@@ -598,6 +607,9 @@ 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
});
+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
});
+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();
+23 -2
Переглянути файл
@@ -27,17 +27,38 @@ fn git_dir(root: &Path) -> Option<std::path::PathBuf> {
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) {
out.insert(git.join("config"));
out.insert(git.join("info/exclude"));
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);
}
+46
Переглянути файл
@@ -797,6 +797,52 @@ fn warm_start_detects_changed_ignore_rules() {
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")]);
+122 -1
Переглянути файл
@@ -218,6 +218,37 @@ impl ClientHandler {
true
}
/// Ends every running task of a tsserver that died at its heap limit: the process
/// behind them is gone, so no `end` will ever arrive. Without this the tasks sit
/// as zombies until [`PROGRESS_STALE`] and the server stays "loading", holding
/// empty answers back for [`LOADING_WAIT`] — while vtsls already restarts
/// tsserver, which re-reports its own loading.
fn tsserver_died(&self, mgr: &LspManager) {
let events: Vec<IdeEvent> = {
let mut progress = self.server.progress.lock();
progress
.drain()
.map(|(_, p)| IdeEvent::Task {
id: p.task,
title: p.title,
message: p.message,
fraction: Some(1.0),
done: true,
cancellable: false,
})
.collect()
};
let ended = !events.is_empty();
for event in events {
self.server.emit(mgr.sink(), event);
}
if ended {
self.server.refresh_busy_status(mgr.sink());
self.server.progress_ended();
}
self.server.set_loading(mgr.sink(), false);
}
fn publish_diagnostics(&self, mgr: &LspManager, params: Value) {
// Problems come only from servers used for `diagnostics`.
if self.server.running_gen(self.generation).is_some_and(|run| !run.caps.read().restriction.diagnostics()) {
@@ -410,6 +441,10 @@ impl Handler for ClientHandler {
let text = params.get("message").and_then(Value::as_str).unwrap_or_default();
if self.server.config.tsserver_project && tsserver_ran_out_of_memory(text) {
tracing::warn!("[{}] {text}", self.server.name);
// The tasks of the dead tsserver never see their `end`: end them now
// instead of letting them sit as zombies until the stale window, and
// stop holding queries back — the restarted instance re-reports.
self.tsserver_died(&mgr);
if mgr.first_message(&self.server.name, TSSERVER_OUT_OF_MEMORY) {
mgr.sink().emit(IdeEvent::Notify {
level: NotifyLevel::Error,
@@ -567,7 +602,12 @@ fn merge(base: &mut Value, layer: Value) {
#[cfg(test)]
mod tests {
use super::tsserver_ran_out_of_memory;
use super::*;
use crate::server::Progress;
use ide_core::event::SharedSink;
use ide_core::{LanguageId, TaskId};
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
#[test]
fn tsserver_aborts_are_out_of_memory() {
@@ -579,4 +619,85 @@ mod tests {
assert!(!tsserver_ran_out_of_memory("Code: 134."));
assert!(tsserver_ran_out_of_memory("[error] TSServer exited. Code: 134. Signal: null\n"));
}
/// A vtsls server with two running tasks, one marked stalled, and `loading` set:
/// the state a tsserver OOM leaves behind.
fn loaded_vtsls(events: Arc<Mutex<Vec<IdeEvent>>>) -> (Arc<LspManager>, Arc<crate::server::Server>) {
let sink: SharedSink = {
let events = events.clone();
Arc::new(move |event: IdeEvent| events.lock().unwrap().push(event))
};
let languages = Arc::new(ide_language::LanguageRegistry::from_configs(vec![]));
let mgr = LspManager::new(PathBuf::from("/p"), languages, sink);
let cfg: ide_language::LanguageServerConfig =
toml::from_str("name = \"vtsls\"\ncommand = \"vtsls\"\ntsserver-project = true").unwrap();
let server = Arc::new(crate::server::Server::new(cfg, PathBuf::from("/p"), LanguageId::new("vue")));
server.progress.lock().insert(
"loading".into(),
Progress {
task: TaskId::next(),
title: "Initializing 'tsconfig.json'".into(),
message: None,
cancel: None,
last: tokio::time::Instant::now(),
fraction: Some(0.1),
stalled: false,
},
);
server.progress.lock().insert(
"stuck".into(),
Progress {
task: TaskId::next(),
title: "Analyzing 'index.vue' and its dependencies".into(),
message: None,
cancel: None,
last: tokio::time::Instant::now(),
fraction: Some(0.2),
stalled: true,
},
);
let quiet: SharedSink = Arc::new(|_: IdeEvent| {});
server.set_loading(&quiet, true);
(mgr, server)
}
#[test]
fn tsserver_dying_ends_every_task_and_the_loading_state() {
let events = Arc::new(Mutex::new(Vec::new()));
let (mgr, server) = loaded_vtsls(events.clone());
let handler = ClientHandler::new(Arc::downgrade(&mgr), server.clone(), 0);
handler.tsserver_died(&mgr);
// Both tasks ended (done: true), including the stalled one; none left running.
let tasks: Vec<_> = events
.lock()
.unwrap()
.iter()
.filter_map(|e| match e {
IdeEvent::Task { title, done, .. } => Some((title.clone(), *done)),
_ => None,
})
.collect();
assert_eq!(tasks.len(), 2, "{tasks:?}");
assert!(tasks.iter().all(|(_, done)| *done), "{tasks:?}");
assert!(server.progress.lock().is_empty());
assert!(!server.is_loading());
assert!(!server.is_busy());
}
#[test]
fn tsserver_dying_without_tasks_only_clears_loading() {
let events = Arc::new(Mutex::new(Vec::new()));
let (mgr, server) = loaded_vtsls(events.clone());
server.progress.lock().clear();
let handler = ClientHandler::new(Arc::downgrade(&mgr), server.clone(), 0);
handler.tsserver_died(&mgr);
assert!(events.lock().unwrap().is_empty());
assert!(!server.is_loading());
// A status event may be published, but no task events.
assert!(server.progress.lock().is_empty());
}
}
+47 -13
Переглянути файл
@@ -40,22 +40,37 @@ const LOOSE: &AsciiSet = &CONTROLS
.add(b'|')
.add(b'}');
/// Percent-encodes the characters URIs don't allow in every `file:` URI in `value`, string
/// values and object keys alike (a workspace edit's `changes`). Eclipse's jdtls sends a path
/// as is (`file:///H:/项目/…`), which `lsp_types::Uri` rejects, failing the whole response.
/// Fields of LSP messages whose value is a URI.
const URI_FIELDS: [&str; 9] =
["uri", "targetUri", "newUri", "oldUri", "baseUri", "scopeUri", "rootUri", "target", "href"];
/// Percent-encodes the characters URIs don't allow in the `file:` URIs of `value`: the URI
/// fields and the keys of a workspace edit's `changes`. Eclipse's jdtls sends a path as is
/// (`file:///H:/项目/…`), which `lsp_types::Uri` rejects, failing the whole response. Other
/// strings stay as sent: an edit's `newText` may well start with `file:` (YAML, `.env`).
pub fn encode_loose_file_uris(value: &mut Value) {
match value {
Value::String(s) => {
if let Some(encoded) = encode_loose(s) {
*s = encoded;
}
}
Value::Array(items) => items.iter_mut().for_each(encode_loose_file_uris),
Value::Object(map) => {
if map.keys().any(|k| encode_loose(k).is_some()) {
*map = std::mem::take(map).into_iter().map(|(k, v)| (encode_loose(&k).unwrap_or(k), v)).collect();
for (key, value) in map.iter_mut() {
match value {
Value::String(s) if URI_FIELDS.contains(&key.as_str()) => {
if let Some(encoded) = encode_loose(s) {
*s = encoded;
}
}
Value::Object(changes) if key == "changes" => {
if changes.keys().any(|k| encode_loose(k).is_some()) {
*changes = std::mem::take(changes)
.into_iter()
.map(|(k, v)| (encode_loose(&k).unwrap_or(k), v))
.collect();
}
changes.values_mut().for_each(encode_loose_file_uris);
}
_ => encode_loose_file_uris(value),
}
}
map.values_mut().for_each(encode_loose_file_uris);
}
_ => {}
}
@@ -161,9 +176,28 @@ mod tests {
assert!(v["edit"]["changes"].get("file:///tmp/%E7%9B%AE%E5%BD%95/b.java").is_some());
assert_eq!(v["text"], "项目", "only file: URIs change");
// Already encoded: untouched.
let mut encoded = serde_json::json!("file:///a%20b/%E9%A1%B9.java");
let mut encoded = serde_json::json!({ "uri": "file:///a%20b/%E9%A1%B9.java" });
encode_loose_file_uris(&mut encoded);
assert_eq!(encoded, "file:///a%20b/%E9%A1%B9.java");
assert_eq!(encoded["uri"], "file:///a%20b/%E9%A1%B9.java");
}
#[test]
fn text_that_starts_with_file_is_not_a_uri() {
// A YAML formatting edit and a completion whose text begins with `file:`.
let mut v = serde_json::json!({ "result": [
{ "range": { "start": { "line": 0, "character": 0 }, "end": { "line": 1, "character": 0 } }, "newText": "file: a b\n - c\n" },
{ "label": "file:", "insertText": "file: ${1:path}", "documentation": "file: the input" }
], "edit": { "changes": { "file:///tmp/a b.yaml": [{ "newText": "file: x y" }] } } });
encode_loose_file_uris(&mut v);
assert_eq!(v["result"][0]["newText"], "file: a b\n - c\n");
assert_eq!(v["result"][1]["insertText"], "file: ${1:path}");
assert_eq!(v["result"][1]["documentation"], "file: the input");
let changes = &v["edit"]["changes"]["file:///tmp/a%20b.yaml"];
assert_eq!(changes[0]["newText"], "file: x y");
// A diagnostic's help link is a URI too.
let mut d = serde_json::json!({ "codeDescription": { "href": "file:///tmp/help files/rule.html" } });
encode_loose_file_uris(&mut d);
assert_eq!(d["codeDescription"]["href"], "file:///tmp/help%20files/rule.html");
}
#[cfg(unix)]
+84
Переглянути файл
@@ -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
@@ -2104,6 +2120,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();
+15 -2
Переглянути файл
@@ -884,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(
@@ -1262,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<()> {
+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);
}
+3 -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");
+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");
}
+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
+6 -2
Переглянути файл
@@ -130,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 '…'**
@@ -194,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.
@@ -217,6 +219,8 @@ Turn on **Combine stashes and shelves in one tab** in the Git settings to show b
- **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.
@@ -246,5 +250,5 @@ Retention is set in the **Local History** section of **Settings › Appearance &
- Mercurial, Subversion, Perforce and other systems.
- Submodule commands and editable directory mappings.
- Per-line partial commits (block level only) and per-file stashes.
- Per-line partial commits (block level only).
- Git LFS beyond the large-file warning.
+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.
+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) { … }
实时模板、后缀补全设置和宏都按用户保存在本机,不保存在项目里。
新建文件用的模板(**文件 › 新建…** 创建的内容)在 **设置 › 编辑器 › 文件和代码模板** 中设置。
新建文件用的模板(**文件 › 新建 › 文件…** 创建的内容)在 **设置 › 编辑器 › 文件和代码模板** 中设置。
## 宏
+6 -2
Переглянути файл
@@ -130,6 +130,8 @@ OpenIDEA 会找到包含项目的仓库,以及向下最多四层的嵌套仓
点击标题栏的分支微件(或 **Git › 分支…**)打开分支弹窗,输入文字即可搜索分支和操作。弹窗中有 **最近**、**本地**、**远程** 和 **标签** 分组,顶部有 **更新项目…**、**提交…**、**推送…**、**新建分支…** 和 **签出标签或修订…**。齿轮按钮可以切换按前缀分组、显示最近分支和显示标签。
点击标题栏分支名旁的传入或传出标记(或分支弹窗中的 **传入和传出的提交…**)即可列出待拉取或待推送的提交(最新的在前)。点击某个提交查看其差异,或点 **在 Git 日志中打开** 在 Git 日志中显示它。
选中分支(`→` 或 `Enter`)查看它的操作:
- **签出**、**从 '…' 新建分支…**、**签出并变基到 '…'**
@@ -194,7 +196,7 @@ Git 工具窗口的 **日志** 标签页显示提交图。
**贮藏** 使用 `git stash`:
- **贮藏更改…**:填写消息,可选 **保留索引** 和 **包括未跟踪的文件**。贮藏总是包含全部更改,不能只贮藏部分文件。
- **贮藏更改…**:填写消息,可选 **保留索引** 和 **包括未跟踪的文件**,贮藏整个工作区;更改树右键菜单中的 **贮藏所选文件…** 只贮藏选中的文件。
- **取消贮藏更改…**:应用或弹出某个贮藏,恢复索引,或基于它新建分支。
- 提交工具窗口的 **贮藏** 标签页列出所有贮藏,可以应用、弹出和删除。
@@ -217,6 +219,8 @@ Git 工具窗口的 **日志** 标签页显示提交图。
- **显示所选内容的历史记录** 列出修改过所选行的提交。
- **显示当前修订版** 在日志中定位光标所在行的提交。
注解会跳过 `blame.ignoreRevsFile` 指向的文件(约定是仓库根目录下的 `.git-blame-ignore-revs`)中列出的提交,因此一次格式化或批量重命名的提交不会认领所有行。在注解行的右键菜单中选择 **忽略此提交**,会把当前行的提交加入该文件并刷新注解。
### 本地历史记录
本地历史记录独立于 Git 保存文件快照,包括已删除的文件,以及重构前自动记录的状态等带标签的快照。
@@ -246,5 +250,5 @@ Git 工具窗口的 **日志** 标签页显示提交图。
- Mercurial、Subversion、Perforce 等其他版本控制系统。
- 子模块命令、编辑目录映射。
- 按行的部分提交(只支持按代码块)以及按文件贮藏。
- 按行的部分提交(只支持按代码块)。
- 除大文件警告外的 Git LFS 支持。
+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
+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,
});
});
}
}
}
+3 -3
Переглянути файл
@@ -19,9 +19,9 @@ test("an unresolved module gets a startup timeout", async ({ page }) => {
);
await page.goto("/", { waitUntil: "commit" });
await page.locator("#splash").waitFor({ state: "attached" });
await page.clock.runFor(30_000);
await page.clock.runFor(60_000);
await expect(page.getByRole("textbox", { name: "Startup error details" })).toHaveValue(
/Startup did not finish within 30 seconds/,
/Startup did not finish within 60 seconds/,
);
});
@@ -83,7 +83,7 @@ test("a slow module load succeeds before the deadline and cancels the startup ti
await expect(page.locator("#root")).not.toBeEmpty();
await page.clock.runFor(4000);
await expect(page.locator("#splash")).toHaveCount(0);
await page.clock.runFor(35_000);
await page.clock.runFor(50_000);
await expect(page.locator("#boot-error")).toHaveCount(0);
expect(await page.evaluate(() => sessionStorage.getItem("boot-error-seen"))).toBeNull();
});
+1 -1
Переглянути файл
@@ -2,7 +2,7 @@
"name": "openidea",
"private": true,
"description": "An open-source IDE inspired by IntelliJ IDEA",
"version": "0.1.44",
"version": "0.1.45",
"license": "Apache-2.0",
"type": "module",
"scripts": {
+3 -3
Переглянути файл
@@ -11,7 +11,7 @@
copied: "已复制",
manual: "无法复制。请选择下方详情并手动复制。",
details: "启动错误详情",
timeout: "启动未在 30 秒内完成。",
timeout: "启动未在 60 秒内完成。",
outdatedMac:
"这台 Mac 的系统网页引擎过旧:OpenIDEA 的界面运行在 macOS 自带的 WebKit 引擎上,需要 macOS 13 Ventura 或更高版本。在 macOS 11、12 上更新 Safari 不会更新其他应用使用的引擎,需要升级 macOS。",
outdated:
@@ -25,7 +25,7 @@
copied: "Copied",
manual: "Could not copy. Select the details below and copy them.",
details: "Startup error details",
timeout: "Startup did not finish within 30 seconds.",
timeout: "Startup did not finish within 60 seconds.",
outdatedMac:
"The system web engine on this Mac is too old: OpenIDEA's interface runs on macOS's built-in WebKit and needs macOS 13 Ventura or later. On macOS 11 and 12 a Safari update doesn't update the engine other apps use, so macOS itself needs upgrading.",
outdated:
@@ -146,7 +146,7 @@
};
// The timeout counts only while the window shows: a hidden or covered window gets no
// animation frames, and the splash waits for those to finish startup.
let remaining = 30_000;
let remaining = 60_000;
let armedAt = 0;
let timer;
const arm = () => {
+1 -1
Переглянути файл
@@ -1,4 +1,4 @@
package openidea:plugin@1.7.0;
package openidea:plugin@1.8.0;
/// The commit being prepared in the window the current command or event came
/// from (the Commit tool window, or the Commit dialog while it is open): the
+1 -1
Переглянути файл
@@ -1,4 +1,4 @@
package openidea:plugin@1.7.0;
package openidea:plugin@1.8.0;
/// Plugin-owned project data sources. Requires database.sources and project trust.
/// Runs on the Database subsystem's host, including on remote project servers.
+10 -4
Переглянути файл
@@ -1,9 +1,10 @@
package openidea:plugin@1.7.0;
package openidea:plugin@1.8.0;
/// Project-scoped decorations (API 1.4). Requires project.read; paths must be inside
/// an open project. Sets belong to the plugin and disappear when it stops.
/// Editor and project-tree decorations (API 1.4). Requires project.read. An editor set
/// follows its file: the file's own project, or the nearest one when it is outside them all.
/// Tree sets are rooted in an open project. Sets belong to the plugin and disappear when it stops.
/// Styles: highlight, search, added, modified, removed, warning, error, info,
/// underline, strikethrough, muted. Unknown styles are ignored. Theme tokens only.
/// typo, weak, underline, strikethrough, muted. Unknown styles are ignored. Theme tokens only.
interface decorations {
use types.{error, range};
/// An editor annotation; after is plain text shown after the range.
@@ -22,4 +23,9 @@ interface decorations {
/// Replace this set's tree annotation for a file with a note shown after its name (API 1.7):
/// one line of at most 200 characters, with an optional tooltip.
set-file-note: func(path: string, set-id: string, note: string, tooltip: option<string>) -> result<_, error>;
/// Replace this set's tree icon for a file or folder with an id declared under
/// `[[contributes.fileIcons]]` (API 1.8), with an optional tooltip.
set-file-icon: func(path: string, set-id: string, icon: string, tooltip: option<string>) -> result<_, error>;
/// Replace all `(path, icon)` entries of a tree set at once (API 1.8; at most 10,000 entries).
set-file-icons: func(set-id: string, items: list<tuple<string, string>>) -> result<_, error>;
}
+1 -1
Переглянути файл
@@ -1,4 +1,4 @@
package openidea:plugin@1.7.0;
package openidea:plugin@1.8.0;
/// What the pane beside the Services tree shows for a plugin's node.
interface detail-types {
+1 -1
Переглянути файл
@@ -1,4 +1,4 @@
package openidea:plugin@1.7.0;
package openidea:plugin@1.8.0;
/// Optional export (world `plugin-diagnostics`): problems in the current,
/// possibly unsaved document, for the languages declared with the
+1 -1
Переглянути файл
@@ -1,4 +1,4 @@
package openidea:plugin@1.7.0;
package openidea:plugin@1.8.0;
/// HTTP endpoints found in a project (`[[contributes.endpoints]]`).
interface endpoints-provider {
+1 -1
Переглянути файл
@@ -1,4 +1,4 @@
package openidea:plugin@1.7.0;
package openidea:plugin@1.8.0;
/// Workbench events, delivered in batches to `event-handler.handle`.
interface events {
+1 -1
Переглянути файл
@@ -1,4 +1,4 @@
package openidea:plugin@1.7.0;
package openidea:plugin@1.8.0;
interface lifecycle {
use types.{context, error};
+1 -1
Переглянути файл
@@ -1,4 +1,4 @@
package openidea:plugin@1.7.0;
package openidea:plugin@1.8.0;
/// What other plugins contributed to the extension points this plugin declares
/// (`[[extension-points]]` in `plugin.toml`; the contributors list this plugin
+1 -1
Переглянути файл
@@ -1,4 +1,4 @@
package openidea:plugin@1.7.0;
package openidea:plugin@1.8.0;
/// Optional export (world `plugin-file-actions`): quick fixes and
/// refactorings that create, rename or delete files, next to the text edits
+1 -1
Переглянути файл
@@ -1,4 +1,4 @@
package openidea:plugin@1.7.0;
package openidea:plugin@1.8.0;
/// Files outside the open projects: the configuration of the program a
/// plugin integrates with, a tool installed elsewhere, a licence file.
+1 -1
Переглянути файл
@@ -1,4 +1,4 @@
package openidea:plugin@1.7.0;
package openidea:plugin@1.8.0;
/// Writes to the IDE log, tagged with the plugin id.
interface log {
+1 -1
Переглянути файл
@@ -1,4 +1,4 @@
package openidea:plugin@1.7.0;
package openidea:plugin@1.8.0;
/// Optional export (world `plugin-hover`): Markdown added to the hover of
/// documents of the languages declared with the `hover` provider kind,
+1 -1
Переглянути файл
@@ -1,4 +1,4 @@
package openidea:plugin@1.7.0;
package openidea:plugin@1.8.0;
/// Cross-file associations a plugin publishes for the files it declares under
/// `[[contributes.documentIndexers]]`. The host calls `index` in cancellable
+1 -1
Переглянути файл
@@ -1,4 +1,4 @@
package openidea:plugin@1.7.0;
package openidea:plugin@1.8.0;
/// Embedded languages. The host routes plugin providers and configured language
/// servers inside these ranges, preserving host-document UTF-16 coordinates.
+1 -1
Переглянути файл
@@ -1,4 +1,4 @@
package openidea:plugin@1.7.0;
package openidea:plugin@1.8.0;
/// Optional export (world `plugin-inlay-hints`): inline hints in documents of
/// the languages declared with the `inlay-hints` provider kind, including
+6 -3
Переглянути файл
@@ -1,11 +1,14 @@
package openidea:plugin@1.7.0;
package openidea:plugin@1.8.0;
/// Problems in files, shown in the editor and the Problems view.
interface diagnostics {
use types.{error, range, location};
/// Problems reported by a source for a file in this window's project.
/// Problems one producer reported for a file, wherever the file is: the host records every
/// diagnostics event it emits, so this follows the file, not the invoking window.
record file-problems { path: string, source: string, diagnostics: list<diagnostic> }
/// Current Problems view snapshot, optionally filtered to a project file.
/// The diagnostics the host recorded, one entry per producer, optionally
/// filtered to a file: language servers, builds, spelling and plugin
/// providers alike, whatever window or project opened the file.
/// Requires project.read and an invoking window. Empty for remote projects.
problems: func(path: option<string>) -> result<list<file-problems>, error>;
+1 -1
Переглянути файл
@@ -1,4 +1,4 @@
package openidea:plugin@1.7.0;
package openidea:plugin@1.8.0;
/// Additional language features, called only for declared API 1.4 provider kinds.
/// Editor calls have a 50 ms soft / 500 ms hard budget; formatting has 5 seconds.
+1 -1
Переглянути файл
@@ -1,4 +1,4 @@
package openidea:plugin@1.7.0;
package openidea:plugin@1.8.0;
/// Discovery of the IDE's built-in MCP server; never exposes its URL or token.
interface mcp {
+1 -1
Переглянути файл
@@ -8,7 +8,7 @@
/// `fs` and `workspace`, and everything else goes through the imports below,
/// gated by the capabilities granted in `plugin.toml` (a missing one returns
/// `error::denied`).
package openidea:plugin@1.7.0;
package openidea:plugin@1.8.0;
world plugin {
import log;
+1 -1
Переглянути файл
@@ -1,4 +1,4 @@
package openidea:plugin@1.7.0;
package openidea:plugin@1.8.0;
/// Editor features a plugin provides for the languages it declares under
/// `[[contributes.providers]]` (the host calls only declared kinds). They run
+1 -1
Переглянути файл
@@ -1,4 +1,4 @@
package openidea:plugin@1.7.0;
package openidea:plugin@1.8.0;
/// Project-scoped targets owned by the registering plugin (API 1.4).
interface run-targets {
+1 -1
Переглянути файл
@@ -1,4 +1,4 @@
package openidea:plugin@1.7.0;
package openidea:plugin@1.8.0;
/// Launches this plugin's configurations in the invoking window's Run tool window.
interface run {
+1 -1
Переглянути файл
@@ -1,4 +1,4 @@
package openidea:plugin@1.7.0;
package openidea:plugin@1.8.0;
/// Optional export (world `plugin-semantic-tokens`): semantic colouring of
/// documents of the languages declared with the `semantic-tokens` provider
+1 -1
Переглянути файл
@@ -1,4 +1,4 @@
package openidea:plugin@1.7.0;
package openidea:plugin@1.8.0;
/// Shared types. Positions are 0-based lines and UTF-16 code unit columns,
/// as in the editor and LSP.
+1 -1
Переглянути файл
@@ -1,4 +1,4 @@
package openidea:plugin@1.7.0;
package openidea:plugin@1.8.0;
/// Declarative views: tool windows declared
/// under `[[contributes.views]]` and custom editors under
+4 -2
Переглянути файл
@@ -1,4 +1,4 @@
package openidea:plugin@1.7.0;
package openidea:plugin@1.8.0;
interface workspace {
use types.{error, location};
@@ -135,7 +135,9 @@ interface editor {
/// Applies versioned edits to an open project document from a background
/// event. The path is project-relative and requires `project.read` and
/// `editor.write`. Stale versions are rejected without changing text.
/// `editor.write`. The host refuses VCS/IDE control directories and every
/// symlink, junction or reparse-point component before it finds the open
/// document. Stale versions are rejected without changing text.
/// These background edits are non-undoable and are not re-emitted as
/// `document-changed` events.
apply-edits-at: func(project: borrow<project>, path: string, version: u64, edits: list<text-edit>) -> result<u64, error>;
+11
Переглянути файл
@@ -244,6 +244,17 @@ pub async fn debug_restart_frame(state: State<'_, AppState>, session: DebugSessi
ide_host::ops::debug::debug_restart_frame(&state.host, "", session, frame_id).await
}
/// The body of a response a debugged page received.
#[tauri::command]
#[specta::specta]
pub async fn debug_js_response_body(
state: State<'_, AppState>,
session: DebugSessionId,
request_id: String,
) -> IdeResult<ide_dap::JsResponseBody> {
ide_host::ops::debug::debug_js_response_body(&state.host, "", session, request_id).await
}
/// Breakpoints of a debugged page on DOM events and on requests (js-debug).
#[tauri::command]
#[specta::specta]
+28 -1
Переглянути файл
@@ -65,8 +65,9 @@ pub fn apply(state: &AppState) {
}
// Another address, port or token: clients connect anew.
if let Some(running) = server.take() {
let held = (running.address(), running.port());
running.stop();
if let Some(port) = wanted.port {
if let Some(port) = port_to_wait_for(held, wanted.port) {
wait_until_free(address, port);
}
}
@@ -104,6 +105,14 @@ pub fn apply(state: &AppState) {
}
}
/// The port a restart listening on `wanted` waits for after stopping the server that listened
/// on `held`: only its own, at any address (`127.0.0.1` and `0.0.0.0` overlap). A port another
/// program holds (the run fell back to a free one) stays taken, and waiting for it would block
/// the window.
fn port_to_wait_for(held: (IpAddr, u16), wanted: Option<u16>) -> Option<u16> {
wanted.filter(|port| held.1 == *port)
}
/// Waits briefly for a stopped server's port to close, so the restart gets it again.
fn wait_until_free(address: IpAddr, port: u16) {
for _ in 0..100 {
@@ -243,3 +252,21 @@ pub fn mcp_respond(
) -> IdeResult<()> {
state.host.mcp_requests.answer(window.label(), id, allow, error)
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::Ipv4Addr;
#[test]
fn a_restart_waits_only_for_the_port_the_stopped_server_held() {
let local = IpAddr::V4(Ipv4Addr::LOCALHOST);
let any = IpAddr::V4(Ipv4Addr::UNSPECIFIED);
assert_eq!(port_to_wait_for((local, 64342), Some(64342)), Some(64342));
// Another address on the same port overlaps the old listener: wait for it too.
assert_eq!(port_to_wait_for((any, 64342), Some(64342)), Some(64342));
// The fixed port was taken by another program: the server ran on a free one.
assert_eq!(port_to_wait_for((local, 51234), Some(64342)), None);
assert_eq!(port_to_wait_for((local, 64342), None), None);
}
}
+2
Переглянути файл
@@ -325,6 +325,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
vcs::vcs_stashes,
vcs::vcs_stage,
vcs::vcs_add_to_ignore,
vcs::vcs_ignore_blame_revision,
vcs::vcs_unstage,
vcs::vcs_stage_patch,
vcs::vcs_discard,
@@ -527,6 +528,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
debug::debug_restart_frame,
debug::debug_capabilities,
debug::debug_js_custom_breakpoints,
debug::debug_js_response_body,
debug::debug_set_function_breakpoints,
debug::debug_set_data_breakpoints,
debug::debug_data_breakpoint_info,
+14 -1
Переглянути файл
@@ -239,6 +239,17 @@ pub async fn vcs_add_to_ignore(
ide_host::ops::vcs::vcs_add_to_ignore(&state.host, window.label(), paths, destination).await
}
#[tauri::command]
#[specta::specta]
pub async fn vcs_ignore_blame_revision(
window: Window,
state: State<'_, AppState>,
path: PathBuf,
commit: String,
) -> IdeResult<()> {
ide_host::ops::vcs::vcs_ignore_blame_revision(&state.host, window.label(), path, commit).await
}
#[tauri::command]
#[specta::specta]
pub async fn vcs_unstage(window: Window, state: State<'_, AppState>, paths: Vec<PathBuf>) -> IdeResult<()> {
@@ -428,8 +439,10 @@ pub async fn vcs_stash_push(
message: Option<String>,
include_untracked: bool,
keep_index: bool,
paths: Option<Vec<PathBuf>>,
) -> IdeResult<()> {
ide_host::ops::vcs::vcs_stash_push(&state.host, window.label(), repo, message, include_untracked, keep_index).await
ide_host::ops::vcs::vcs_stash_push(&state.host, window.label(), repo, message, include_untracked, keep_index, paths)
.await
}
#[tauri::command]
+67 -8
Переглянути файл
@@ -217,14 +217,18 @@ pub fn spec(os: Os) -> Vec<Entry> {
}
let mut file = vec![
act("file.newProject", "New Project…"),
act("workspace.create", "New Workspace…"),
act("workspace.open", "Open Workspace…"),
act("workspace.manage", "Manage Workspace…"),
act("file.new", "New…"),
act("file.newScratchFile", "New Scratch File…"),
sub(
"new",
"New",
vec![
act("file.newProject", "Project…"),
act("workspace.create", "Workspace…"),
SEP,
act("file.new", "File…"),
act("file.newScratchFile", "Scratch File"),
],
),
act("file.openProject", "Open…"),
act("project.attach", "Attach Project…"),
act("file.openFile", "Open File…"),
act("file.openInNewWindow", "Open in New Window…"),
sub(
@@ -232,6 +236,11 @@ pub fn spec(os: Os) -> Vec<Entry> {
"Open Recent",
vec![Entry::RecentProjects, SEP, act("welcome.recentProjects", "Manage Projects…")],
),
act("project.attach", "Attach Project…"),
SEP,
act("workspace.open", "Open Workspace…"),
act("workspace.manage", "Manage Workspace…"),
SEP,
act("file.closeProject", "Close Project"),
act("file.renameProject", "Rename Project…"),
SEP,
@@ -1612,7 +1621,7 @@ pub fn install<R: Runtime>(app: &AppHandle<R>, menu_state: &MenuState, recent: &
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
use std::collections::{BTreeSet, HashSet};
const ALL_OS: [Os; 3] = [Os::Mac, Os::Windows, Os::Linux];
@@ -1972,6 +1981,56 @@ mod tests {
}
}
#[test]
fn file_menu_starts_with_a_new_submenu() {
for os in ALL_OS {
let menus = spec(os);
let Some(Entry::Submenu { children: file, .. }) =
menus.iter().find(|e| matches!(e, Entry::Submenu { id: "file", .. }))
else {
panic!("File menu missing on {os:?}")
};
let Some(Entry::Submenu { id: "new", children: new, .. }) = file.first() else {
panic!("File menu does not start with New on {os:?}")
};
assert_eq!(
action_ids(new),
["file.newProject", "workspace.create", "file.new", "file.newScratchFile"],
"{os:?}"
);
}
}
#[test]
fn every_menu_item_has_a_catalog_label() {
fn walk(entries: &[Entry], out: &mut BTreeSet<&'static str>) {
for e in entries {
match e {
Entry::Submenu { id, children, .. } => {
out.insert(id);
walk(children, out);
}
Entry::Action { id, .. } => {
out.insert(id);
}
_ => {}
}
}
}
let mut ids = BTreeSet::new();
for os in ALL_OS {
walk(&spec(os), &mut ids);
}
for (locale, catalog) in [
("en", include_str!("../../src/platform/locales/en.json")),
("zh", include_str!("../../src/platform/locales/zh.json")),
] {
let catalog: serde_json::Map<String, serde_json::Value> = serde_json::from_str(catalog).unwrap();
let missing: Vec<_> = ids.iter().filter(|id| !catalog.contains_key(&format!("menu.{id}"))).collect();
assert!(missing.is_empty(), "{locale}.json lacks menu labels for {missing:?}");
}
}
#[test]
fn recent_labels_tildify_home() {
let recent = vec![ide_workspace::RecentProject {
+1 -1
Переглянути файл
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "OpenIDEA",
"version": "0.1.44",
"version": "0.1.45",
"identifier": "ai.l0ng.openidea",
"build": {
"beforeDevCommand": "pnpm dev",
+62 -2
Переглянути файл
@@ -657,6 +657,7 @@ export const commands = {
vcsStashes: (repo: string | null) => typedError<Stash[], IdeError>(__TAURI_INVOKE("vcs_stashes", { repo })),
vcsStage: (paths: string[]) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_stage", { paths })),
vcsAddToIgnore: (paths: string[], destination: IgnoreDestination) => typedError<IpcPath[], IdeError>(__TAURI_INVOKE("vcs_add_to_ignore", { paths, destination })),
vcsIgnoreBlameRevision: (path: string, commit: string) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_ignore_blame_revision", { path, commit })),
vcsUnstage: (paths: string[]) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_unstage", { paths })),
vcsStagePatch: (patch: string) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_stage_patch", { patch })),
vcsDiscard: (paths: string[]) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_discard", { paths })),
@@ -690,7 +691,7 @@ export const commands = {
vcsRenameBranch: (repo: string | null, from: string, to: string, unsetUpstream: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_rename_branch", { repo, from, to, unsetUpstream })),
vcsMerge: (repo: string | null, branch: string) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_merge", { repo, branch })),
vcsRebase: (repo: string | null, onto: string) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_rebase", { repo, onto })),
vcsStashPush: (repo: string | null, message: string | null, includeUntracked: boolean, keepIndex: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_stash_push", { repo, message, includeUntracked, keepIndex })),
vcsStashPush: (repo: string | null, message: string | null, includeUntracked: boolean, keepIndex: boolean, paths: string[] | null) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_stash_push", { repo, message, includeUntracked, keepIndex, paths })),
vcsStashApply: (repo: string | null, index: number, pop: boolean, reinstateIndex: boolean, branch: string | null) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_stash_apply", { repo, index, pop, reinstateIndex, branch })),
vcsStashDrop: (repo: string | null, index: number) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_stash_drop", { repo, index })),
/** Network operation; progress lines stream through `on_output`. Cancellable. */
@@ -1215,6 +1216,8 @@ export const commands = {
debugCapabilities: (session: DebugSessionId) => typedError<DebugCapabilities, IdeError>(__TAURI_INVOKE("debug_capabilities", { session })),
/** Breakpoints of a debugged page on DOM events and on requests (js-debug). */
debugJsCustomBreakpoints: (session: DebugSessionId, ids: string[], xhr: string[]) => typedError<null, IdeError>(__TAURI_INVOKE("debug_js_custom_breakpoints", { session, ids, xhr })),
/** The body of a response a debugged page received. */
debugJsResponseBody: (session: DebugSessionId, requestId: string) => typedError<JsResponseBody, IdeError>(__TAURI_INVOKE("debug_js_response_body", { session, requestId })),
/** Method breakpoints (all files); states aligned with `breakpoints`. */
debugSetFunctionBreakpoints: (breakpoints: FunctionBreakpoint[]) => typedError<BreakpointState[], IdeError>(__TAURI_INVOKE("debug_set_function_breakpoints", { breakpoints })),
/** Field watchpoints; states aligned with `breakpoints`. */
@@ -3964,6 +3967,8 @@ export type DebugCapabilities = {
* ([`DebugManager::js_set_custom_breakpoints`]).
*/
jsPage: boolean,
/** The adapter evaluates expressions while the program runs, without a stack frame. */
liveEvaluate: boolean,
};
/** A completion the adapter offers for an expression (DAP `completions`). */
@@ -3999,7 +4004,11 @@ export type DebugEvent =
* The adapter's `invalidated` event: what it shows for `areas` (`all`, `stacks`, `threads`,
* `variables`; empty means all) is stale and has to be fetched again.
*/
{ kind: "invalidated"; areas: string[] } | { kind: "jvmBackend"; state: JvmBackendState };
{ kind: "invalidated"; areas: string[] } | { kind: "jvmBackend"; state: JvmBackendState } |
/** A step in the life of one request of a debugged page. */
{ kind: "jsNetwork"; event: JsNetworkEvent } |
/** The adapter does not report the requests of this page: there will be no `JsNetwork`. */
{ kind: "jsNetworkUnavailable" };
/** A debug adapter session. */
export type DebugSessionId = number;
@@ -4015,6 +4024,8 @@ export type DebuggerSettings = {
inlineValues?: boolean,
/** Data Views: list variables by name instead of the adapter's order. */
sortValues?: boolean,
/** Data Views: evaluate watches while the program runs, where the debugger can (JavaScript). */
liveWatches?: boolean,
valueTooltip?: boolean,
/** While suspended, selecting an expression shows its value. */
valueTooltipOnSelection?: boolean,
@@ -6418,6 +6429,49 @@ export type JetBrainsSettings = {
files: { [key in string]: string },
};
/** The script frame that made a request, where the browser ran it (0-based line and column). */
export type JsInitiator = {
function: string,
url: string,
line: number,
column: number,
};
/**
* 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.
*/
export type JsNetworkEvent = {
kind: JsNetworkKind,
requestId: string,
/** The browser's monotonic clock, comparable between the events of a page. */
timestampMs: number,
/** Request and response. */
url: string | null,
method: string | null,
/** `Fetch`, `XHR`, `Script`, `Document`, … */
resourceType: string | null,
status: number | null,
mimeType: string | null,
/** The request's headers, or the response's. */
headers: { [key in string]: string },
/** Bytes received over the network, once finished. */
encodedLength: number | null,
errorText: string | null,
initiator: JsInitiator | null,
};
/** Which step of a request a [`JsNetworkEvent`] reports. */
export type JsNetworkKind = "request" | "response" | "finished" | "failed";
/** The body of a response, as text or as the browser's base64 for binary content. */
export type JsResponseBody = {
body: string,
base64Encoded: boolean,
/** The body was longer than [`RESPONSE_BODY_LIMIT`] and is cut there. */
truncated: boolean,
};
/** The backend that reads a debugged JVM's objects (instances, fields, referrers). */
export type JvmBackendState = { kind: "ready" } |
/** Turned off for the rest of the session: the debug adapter lacks what it needs. */
@@ -10297,6 +10351,12 @@ export type TypeScriptSettings = {
* or a path to a TypeScript `lib` directory.
*/
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).
*/
tsserverMaxMemory?: number,
};
/** A misspelt word. */
+23 -6
Переглянути файл
@@ -1335,7 +1335,10 @@ export class EditorController implements SlotHost {
* (`EditorKindContribution.isBusy`): asks before ending what runs in them,
* like closing a busy terminal session. Resolves false to keep the tabs.
*/
private async confirmBusyClose(removal: (s: TabsState) => TabsState): Promise<boolean> {
private async confirmBusyClose(
removal: (s: TabsState) => TabsState,
confirmed: Set<string> = new Set(),
): Promise<boolean> {
const kinds = useContributions.getState().editorKinds;
const current = useTabs.getState();
const remaining = openKeys(removal(current));
@@ -1343,15 +1346,19 @@ export class EditorController implements SlotHost {
for (const pane of Object.values(current.panes))
for (const tab of pane.tabs)
if (!remaining.has(tab.input.key) && kinds.get(tab.input.kind)?.isBusy) closing.set(tab.input.key, tab.input);
const busy: string[] = [];
for (const input of closing.values()) if (await inputBusy(input, kinds)) busy.push(tabTitle(input, kinds));
// Tabs whose ending was confirmed earlier in this close aren't asked about again.
const busy: EditorInput[] = [];
for (const input of closing.values())
if (!confirmed.has(input.key) && (await inputBusy(input, kinds))) busy.push(input);
if (!busy.length) return true;
const name = busy.join(", ");
return confirm({
const name = busy.map((input) => tabTitle(input, kinds)).join(", ");
const ok = await confirm({
title: t("editor.closeBusy.title", { name }),
message: t("editor.closeBusy.message", { name }),
ok: t("editor.closeBusy.terminate"),
});
if (ok) for (const input of busy) confirmed.add(input.key);
return ok;
}
/** Close exactly the originally selected tabs, evaluating references after every await. */
@@ -1388,6 +1395,8 @@ export class EditorController implements SlotHost {
try {
const authorized = new Set<string>();
const discards = new Map<string, CustomDocument>();
const confirmedBusy = new Set<string>();
let busyAsked = false;
for (;;) {
const current = useTabs.getState();
const d = closedDocuments(current, currentRemoval(current), useContributions.getState().editorKinds).find(
@@ -1397,6 +1406,13 @@ export class EditorController implements SlotHost {
// A later Cancel/save failure must not discard earlier documents.
const discard = [...discards.values()].find(stillLast);
if (!discard) break;
// Nor may a Cancel on ending a busy tab's session: ask before discarding anything.
if (!busyAsked) {
if (!(await this.confirmBusyClose(currentRemoval, confirmedBusy))) return false;
if (generation !== this.projectGeneration) return false;
busyAsked = true;
continue;
}
discards.delete(discard.input.key);
await discard.lifecycle.discard(discard.input);
// The await may have changed which documents lose their last tab.
@@ -1427,7 +1443,8 @@ export class EditorController implements SlotHost {
}
authorized.add(d.input.key);
}
if (!(await this.confirmBusyClose(currentRemoval))) return false;
// Again after the discards: a tab may have become busy while they ran.
if (!(await this.confirmBusyClose(currentRemoval, confirmedBusy))) return false;
if (generation !== this.projectGeneration) return false;
if (options.beforeRemove && !(await options.beforeRemove())) return false;
// Commit only the original target ids against the latest tab state.
+53 -1
Переглянути файл
@@ -4,7 +4,7 @@ import { createMemoryBackend } from "./backend";
import { findTab, initialTabsState, paneIds } from "./model/tabs";
import { useDocMeta } from "./model/document";
import { useProject } from "@/platform/project";
import { ask } from "@/workbench/messages";
import { ask, confirm } from "@/workbench/messages";
import type * as Messages from "@/workbench/messages";
import * as virtualDocs from "@/platform/virtualDocs";
import type { VirtualDocument } from "@/platform/ipc";
@@ -18,6 +18,7 @@ import {
vi.mock("@/workbench/messages", async (original) => ({
...(await original<typeof Messages>()),
ask: vi.fn(),
confirm: vi.fn(),
}));
class TestDocument implements EditorDocumentLifecycle {
@@ -248,6 +249,57 @@ describe("custom editor document lifetime", () => {
expect(findTab(useTabs.getState(), input().key)).toBeNull();
});
it("keeps discarded changes when ending a busy tab's session is cancelled", async () => {
const busy = registerEditorKind({
kind: "busy-test",
component: () => null,
title: (i) => i.key,
isBusy: () => true,
});
try {
const doc = await open();
await ctrl.openInput({ kind: "busy-test", key: "busy:1" }, { focus: false });
vi.mocked(ask).mockResolvedValue({ button: "discard", checked: false });
vi.mocked(confirm).mockResolvedValue(false);
const pane = useTabs.getState().activePane;
expect(await ctrl.closeAll(pane)).toBe(false);
expect(confirm).toHaveBeenCalledOnce();
expect(doc.text).toBe("edited");
expect(useTabs.getState().panes[pane]!.tabs.map((tab) => tab.input.key)).toEqual(["custom:a", "busy:1"]);
} finally {
busy();
vi.mocked(confirm).mockReset();
}
});
it("asks about a tab that becomes busy while changes are discarded", async () => {
let busyNow = false;
const busy = registerEditorKind({
kind: "busy-test",
component: () => null,
title: (i) => i.key,
isBusy: () => busyNow,
});
try {
const doc = await open();
doc.discard = async () => {
busyNow = true;
doc.pending = "";
doc.text = doc.saved;
};
await ctrl.openInput({ kind: "busy-test", key: "busy:1" }, { focus: false });
vi.mocked(ask).mockResolvedValue({ button: "discard", checked: false });
vi.mocked(confirm).mockResolvedValue(false);
const pane = useTabs.getState().activePane;
expect(await ctrl.closeAll(pane)).toBe(false);
expect(confirm).toHaveBeenCalledOnce();
expect(useTabs.getState().panes[pane]!.tabs.map((tab) => tab.input.key)).toContain("busy:1");
} finally {
busy();
vi.mocked(confirm).mockReset();
}
});
it("discards without a removal-triggered autosave", async () => {
const doc = await open();
useProject.setState({ settings: { files: { autoSave: true } } as never });
+4
Переглянути файл
@@ -438,7 +438,10 @@
.suggestion:focus-visible {
outline: 2px solid var(--focus-ring);
}
/* The file name truncates first; the hint stays on one line. */
.suggestionHint {
flex: none;
white-space: nowrap;
color: var(--fg-faint);
}
.removeChip {
@@ -458,6 +461,7 @@
color: var(--fg-strong);
}
.attachmentName {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
+13
Переглянути файл
@@ -88,3 +88,16 @@
max-width: 45%;
margin-left: auto;
}
.notes {
display: flex;
flex-direction: column;
gap: 8px;
height: 0;
}
.docked .notes {
height: auto;
}
.docked .notes:empty {
display: none;
}
+23 -20
Переглянути файл
@@ -221,26 +221,29 @@ export function HomeEditor({ docked = false }: { docked?: boolean; input?: Edito
/>
}
/>
{probe && (!docked || probe.error) && (
<p className={probe.error ? styles.warning : styles.note}>
{probe.error
? txTemplate(`${provider?.name ?? probe.provider} didn't start: ${probe.error}`)
: [probe.name, probe.version, probe.protocol].filter(Boolean).join(" · ")}
</p>
)}
{(error || (provider && !provider.installed) || (provider && !provider.config.length)) && (
<p
className={error || !provider?.installed ? styles.warning : styles.note}
role={error ? "alert" : undefined}
>
{error ??
(provider && !provider.installed ? (
txTemplate(`${provider.command} was not found on PATH`)
) : (
<T>Models and modes appear after this agent's first task.</T>
))}
</p>
)}
{/* Notes under the box don't take part in centring, so the box stays put as they come and go. */}
<div className={styles.notes}>
{probe && (!docked || probe.error) && (
<p className={probe.error ? styles.warning : styles.note}>
{probe.error
? txTemplate(`${provider?.name ?? probe.provider} didn't start: ${probe.error}`)
: [probe.name, probe.version, probe.protocol].filter(Boolean).join(" · ")}
</p>
)}
{(error || (provider && !provider.installed) || (provider && !provider.config.length)) && (
<p
className={error || !provider?.installed ? styles.warning : styles.note}
role={error ? "alert" : undefined}
>
{error ??
(provider && !provider.installed ? (
txTemplate(`${provider.command} was not found on PATH`)
) : (
<T>Models and modes appear after this agent's first task.</T>
))}
</p>
)}
</div>
</div>
</div>
);
+21
Переглянути файл
@@ -43,6 +43,27 @@
color: var(--fg-muted);
font-variant-numeric: tabular-nums;
}
/* In the composer: a × at the end removes the reference, as on the attached blocks. */
.removable {
padding-right: 2px;
}
.chipRemove {
display: grid;
flex: none;
place-items: center;
width: 16px;
height: 16px;
padding: 0;
border: 0;
border-radius: var(--radius-small);
background: none;
color: var(--fg-muted);
cursor: pointer;
}
.chipRemove:hover {
background: var(--bg-hover);
color: var(--fg-strong);
}
/* The file is gone (renamed or deleted since it was referenced). */
.missing {
color: var(--fg-muted);
+16 -2
Переглянути файл
@@ -55,8 +55,8 @@ export function openReference(path: string, mention: Mention) {
const nameOf = (m: Mention) => basename(m.rel.replace(/\/$/, "")) || m.rel;
const iconOf = (m: Mention) => (m.dir ? "folder" : fileIconName(nameOf(m), m.rel));
/** The chip as DOM, for the composer's editor. */
export function chipElement(path: string, mention: Mention): HTMLElement {
/** The chip as DOM, for the composer's editor; `onRemove` adds its × button. */
export function chipElement(path: string, mention: Mention, onRemove?: () => void): HTMLElement {
const chip = document.createElement("span");
chip.className = styles.chip!;
chip.dataset.refPath = path;
@@ -74,6 +74,20 @@ export function chipElement(path: string, mention: Mention): HTMLElement {
lines.textContent = linesLabel(mention.lines);
chip.append(lines);
}
if (onRemove) {
chip.classList.add(styles.removable!);
const remove = document.createElement("button");
remove.type = "button";
remove.className = styles.chipRemove!;
remove.setAttribute("aria-label", tx("Remove"));
remove.append(iconNode("close", 10));
remove.addEventListener("mousedown", (e) => e.preventDefault());
remove.addEventListener("click", (e) => {
e.stopPropagation();
onRemove();
});
chip.append(remove);
}
if (!mention.dir)
void fileExists(path).then((exists) => {
if (!exists) chip.classList.add(styles.missing!);
+12 -7
Переглянути файл
@@ -41,9 +41,11 @@
/* The task's state and its terminal and changes, just above the composer. */
.statusBar {
display: flex;
/* What doesn't fit moves to the next line whole, instead of every fact shrinking to a letter. */
flex-wrap: wrap;
/* The changes chip sits above the line; the facts stay on that line's baseline. */
align-items: flex-end;
gap: 6px;
gap: 2px 6px;
min-width: 0;
padding: 0 2px;
overflow: hidden;
@@ -54,6 +56,7 @@
.agent {
display: inline-flex;
flex: 0 1 auto;
max-width: 100%;
align-items: center;
gap: 4px;
height: 22px;
@@ -70,6 +73,7 @@
.where {
display: inline-flex;
flex: 0 1 auto;
max-width: 100%;
align-items: center;
/* The chips' own padding spaces them. */
gap: 2px;
@@ -87,20 +91,21 @@
height: 22px;
white-space: nowrap;
}
/* The changes sit above the meter, time and terminal; those keep their one row at the right. */
/* The rest of the line: the changes at its start, the meter, time and terminal at its end.
On a line of its own (a narrow session) the changes line up with the facts above. */
.statusEnd {
display: inline-flex;
flex: 0 1 auto;
flex-direction: column;
align-items: flex-end;
gap: 2px;
flex: 1 1 auto;
flex-wrap: wrap;
align-items: center;
gap: 2px 8px;
min-width: 0;
margin-left: auto;
}
/* Shrinks before the terminal buttons do: in a narrow docked session the meters clip, not the controls. */
.statusMeters {
display: inline-flex;
flex: 0 1 auto;
margin-left: auto;
align-items: center;
height: 22px;
gap: 8px;
+1 -1
Переглянути файл
@@ -387,7 +387,7 @@ function SessionStatus({
<T>{statusLabel(task.status)}</T>
{task.status === "limited" && task.resetsMs != null && ` · ${tx`resets ${formatSentAt(task.resetsMs)}`}`}
</span>
{/* What changed sits above the meter, time and terminal; those keep their one row at the right. */}
{/* What changed sits before the meter, time and terminal, or above them when the row is full. */}
<span className={styles.statusEnd}>
{(!!changes?.length || !!commits.length) && (
<span className={styles.statusChips}>
+10 -2
Переглянути файл
@@ -79,8 +79,16 @@ class ChipWidget extends WidgetType {
eq(other: ChipWidget) {
return other.path === this.path && other.text === this.text;
}
toDOM() {
const chip = chipElement(this.path, this.mention);
toDOM(view: EditorView) {
const chip = chipElement(this.path, this.mention, () => {
const from = view.posAtDOM(chip);
const at = view.state.field(painted).chips.find((c) => c.from === from);
if (!at) return;
// The space typed after the reference goes with it.
const end = view.state.sliceDoc(at.to, at.to + 1) === " " ? at.to + 1 : at.to;
view.dispatch({ changes: { from: at.from, to: end }, userEvent: "delete" });
view.focus();
});
// Keep the editor's focus and caret; a click opens the file.
chip.addEventListener("mousedown", (e) => e.preventDefault());
chip.addEventListener("click", (e) => {
+398
Переглянути файл
@@ -0,0 +1,398 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import type { AgentProvider, AgentTask, ProjectInfo } from "@/bindings";
import { useProject } from "@/platform/project";
import { HomeEditor } from "./HomeEditor";
import { SessionEditor } from "./SessionEditor";
import { emptyDraft, type ComposerDraft } from "./composerModel";
import { setCaret, textOf, typeIn } from "./composerTesting";
import { HOME_KEY, SESSION_KIND, sessionKey } from "./model";
import {
HOME,
composerDraft,
connect,
disconnect,
sendMessage,
startTask,
updateComposer,
updateDraft,
useAgents,
} from "./store";
const backend = vi.hoisted(() => ({
tauri: false,
create: vi.fn(),
prompt: vi.fn(),
open: vi.fn(),
close: vi.fn(),
}));
vi.mock("@tauri-apps/api/core", async (original) => ({
...(await original<typeof import("@tauri-apps/api/core")>()),
Channel: class {
onmessage = () => {};
},
}));
vi.mock("@/platform/ipc", async (original) => ({
...(await original<typeof import("@/platform/ipc")>()),
isTauri: () => backend.tauri,
commands: {
agentCreate: backend.create,
agentPrompt: backend.prompt,
agentSubscribe: async () => ({ status: "ok", data: [] }),
agentProviders: async () => ({ status: "ok", data: [provider] }),
agentProbe: async () => ({ status: "ok", data: null }),
agentEntries: async () => ({ status: "ok", data: [] }),
agentChanges: async () => ({ status: "ok", data: [] }),
vcsBranchInfo: async () => ({ status: "ok", data: { head: "main" } }),
vcsBranches: async () => ({ status: "ok", data: [] }),
},
}));
vi.mock("@/workbench/contributions", async (original) => ({
...(await original<typeof import("@/workbench/contributions")>()),
editorService: () => ({ openInput: backend.open, closeInput: backend.close }),
maybeEditorService: () => ({ activePath: () => null }),
toolWindowService: () => ({ show: vi.fn() }),
}));
const ok = <T,>(data: T) => ({ status: "ok" as const, data });
function deferred() {
let resolve!: (value: unknown) => void;
let reject!: (reason: Error) => void;
const promise = new Promise((done, fail) => {
resolve = done;
reject = fail;
});
return { promise, resolve, reject };
}
const task = (id: string): AgentTask => ({
id,
title: id,
provider: "codex",
status: "ready",
isolation: "local",
model: null,
cwd: "/project-a",
branch: null,
base: null,
baseCommit: null,
error: null,
createdMs: 0,
updatedMs: 0,
config: [],
commands: [],
usage: null,
queue: [],
});
const provider: AgentProvider = {
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: {},
};
const file = (name: string) => ({ path: `/project-a/${name}`, rel: name, attached: true });
const image = (name: string) => ({ mimeType: "image/png", data: name, name });
const mixed = (): ComposerDraft => ({
text: " submitted prompt ",
files: [file("sent.ts")],
images: [image("sent.png")],
});
const setProject = (root: string) => useProject.setState({ project: { root, trusted: true } as ProjectInfo });
beforeEach(() => {
disconnect();
vi.resetAllMocks();
backend.tauri = false;
backend.close.mockResolvedValue(false);
setProject("/project-a");
useProject.setState({ settings: null });
useAgents.setState({
loaded: true,
tasks: new Map([["A", task("A")]]),
entries: new Map([["A", []]]),
providers: [provider],
draft: { provider: "codex", config: {}, isolation: "local", base: null },
composers: new Map(),
sessionLocation: "editor",
lastComposer: HOME,
history: [],
focus: null,
});
});
afterEach(() => {
cleanup();
disconnect();
});
const paths = [
{ name: "queued message", key: "A", send: () => sendMessage("A", false), mock: backend.prompt },
{ name: "send now", key: "A", send: () => sendMessage("A", true), mock: backend.prompt },
{ name: "new task", key: HOME, send: () => startTask(), mock: backend.create },
];
describe.each(paths)("$name acknowledgement", (path) => {
function begin() {
const response = deferred();
path.mock.mockReturnValueOnce(response.promise);
const pending = path.send();
return { ...response, pending };
}
const success = () => ok(path.key === HOME ? task("created") : null);
it.each([
{ name: "text", draft: { ...emptyDraft(), text: "hello" } },
{ name: "file only", draft: { ...emptyDraft(), files: [file("sent.ts")] } },
{ name: "image only", draft: { ...emptyDraft(), images: [image("sent.png")] } },
{ name: "text, files and images", draft: mixed() },
])("clears unchanged $name after success", async ({ draft }) => {
updateComposer(path.key, draft);
const response = begin();
expect(path.mock).toHaveBeenCalledTimes(1);
expect(composerDraft(path.key)).toEqual(draft);
response.resolve(success());
await response.pending;
expect(composerDraft(path.key)).toEqual(emptyDraft());
});
it.each(["", " \n\t "])("does not send an empty draft (%j)", async (text) => {
updateComposer(path.key, { text });
await path.send();
expect(path.mock).not.toHaveBeenCalled();
expect(composerDraft(path.key).text).toBe(text);
});
it.each([
{ name: "replace text", patch: { text: "next prompt" } },
{ name: "append text", patch: { text: mixed().text + "and another instruction" } },
{ name: "delete text", patch: { text: "" } },
{ name: "whitespace edit", patch: { text: mixed().text + " " } },
{ name: "add file", patch: { files: [...mixed().files, file("next.ts")] } },
{ name: "remove file", patch: { files: [] } },
{ name: "replace file", patch: { files: [file("next.ts")] } },
{ name: "add image", patch: { images: [...mixed().images, image("next.png")] } },
{ name: "remove image", patch: { images: [] } },
{ name: "replace image", patch: { images: [image("next.png")] } },
{
name: "replace everything",
patch: { text: "next prompt", files: [file("next.ts")], images: [image("next.png")] },
},
])("keeps the newer draft after $name", async ({ patch }) => {
updateComposer(path.key, mixed());
const response = begin();
updateComposer(path.key, patch);
const next = structuredClone(composerDraft(path.key));
response.resolve(success());
await response.pending;
expect(composerDraft(path.key)).toEqual(next);
});
it("keeps a new revision even when edits restore the submitted content", async () => {
const submitted = mixed();
updateComposer(path.key, submitted);
const response = begin();
updateComposer(path.key, { text: "temporary change" });
updateComposer(path.key, submitted);
response.resolve(success());
await response.pending;
expect(composerDraft(path.key)).toEqual(submitted);
});
it.each([false, true])("keeps current content after a command error (edited=%s)", async (edited) => {
updateComposer(path.key, mixed());
const response = begin();
if (edited) updateComposer(path.key, { text: "unsent changes", images: [image("new.png")] });
const next = structuredClone(composerDraft(path.key));
const rejected = expect(response.pending).rejects.toThrow("send failed");
response.resolve({ status: "error", error: { kind: "internal", message: "send failed" } });
await rejected;
expect(composerDraft(path.key)).toEqual(next);
});
it.each([false, true])("keeps current content after a transport error (edited=%s)", async (edited) => {
updateComposer(path.key, mixed());
const response = begin();
if (edited) updateComposer(path.key, { files: [file("new.ts")] });
const next = structuredClone(composerDraft(path.key));
const rejected = expect(response.pending).rejects.toThrow("connection lost");
response.reject(new Error("connection lost"));
await rejected;
expect(composerDraft(path.key)).toEqual(next);
});
it("clears only the submitted composer and keeps the last edited destination", async () => {
updateComposer(path.key, mixed());
const response = begin();
updateComposer("B", { text: "another session", files: [file("other.ts")] });
response.resolve(success());
await response.pending;
expect(composerDraft(path.key)).toEqual(emptyDraft());
expect(composerDraft("B")).toEqual({ text: "another session", files: [file("other.ts")], images: [] });
expect(useAgents.getState().lastComposer).toBe("B");
});
it("still clears when task status, provider or model settings change", async () => {
updateComposer(path.key, mixed());
const response = begin();
updateDraft({ provider: "other", config: { model: "new-model", mode: "plan", effort: "high" } });
useAgents.setState({
providers: [],
tasks: new Map([["A", { ...task("A"), status: "running", model: "new-model" }]]),
});
response.resolve(success());
await response.pending;
expect(composerDraft(path.key)).toEqual(emptyDraft());
});
it.each(["disconnect", "reconnect", "switch project", "switch away and back"])(
"does not consume a retained draft after %s",
async (change) => {
updateComposer(path.key, mixed());
const response = begin();
disconnect();
if (change.startsWith("switch")) setProject("/project-b");
if (change === "switch away and back") {
disconnect();
setProject("/project-a");
}
if (change !== "disconnect") {
backend.tauri = true;
await connect();
}
response.resolve(success());
await response.pending;
expect(composerDraft(path.key)).toEqual(mixed());
},
);
it("does not recreate a deleted composer", async () => {
updateComposer(path.key, mixed());
const response = begin();
useAgents.setState({ composers: new Map() });
response.resolve(success());
await response.pending;
expect(useAgents.getState().composers.has(path.key)).toBe(false);
});
it("does not consume a retained draft after replacing the connection without disconnecting", async () => {
updateComposer(path.key, mixed());
const response = begin();
backend.tauri = true;
await connect();
response.resolve(success());
await response.pending;
expect(composerDraft(path.key)).toEqual(mixed());
});
it.each(["old first", "new first"])("handles overlapping submissions with %s", async (order) => {
updateComposer(path.key, { text: "first" });
const first = begin();
updateComposer(path.key, { text: "second" });
const second = begin();
if (order === "old first") {
first.resolve(success());
await first.pending;
expect(composerDraft(path.key).text).toBe("second");
second.resolve(success());
await second.pending;
} else {
second.resolve(success());
await second.pending;
updateComposer(path.key, { text: "third" });
first.resolve(success());
await first.pending;
}
expect(composerDraft(path.key).text).toBe(order === "old first" ? "" : "third");
});
it("keeps a third draft when both earlier acknowledgements arrive", async () => {
updateComposer(path.key, { text: "first" });
const first = begin();
updateComposer(path.key, { text: "second" });
const second = begin();
updateComposer(path.key, { text: "third", images: [image("third.png")] });
first.resolve(success());
second.resolve(success());
await Promise.all([first.pending, second.pending]);
expect(composerDraft(path.key)).toEqual({ text: "third", files: [], images: [image("third.png")] });
});
it("sends the submitted payload and remembers only the submitted Home prompt", async () => {
updateComposer(path.key, mixed());
const response = begin();
const prompt = { text: "submitted prompt", files: [file("sent.ts").path], images: mixed().images };
if (path.key === HOME) expect(backend.create).toHaveBeenCalledWith(expect.objectContaining({ prompt }));
else expect(backend.prompt).toHaveBeenCalledWith("A", prompt, path.name === "send now");
updateComposer(path.key, { text: "not submitted", images: [] });
response.resolve(success());
await response.pending;
expect(useAgents.getState().history).toEqual(path.key === HOME ? ["submitted prompt"] : []);
});
});
describe("composer UI during submission", () => {
it("keeps typing and attachments in the session after Send completes", async () => {
const response = deferred();
backend.prompt.mockReturnValue(response.promise);
render(<SessionEditor input={{ kind: SESSION_KIND, key: sessionKey("A"), data: { task: "A" } }} />);
const box = screen.getByRole("textbox", { name: "Message" });
typeIn(box, "sent message");
fireEvent.click(screen.getByRole("button", { name: "Send" }));
await waitFor(() => expect(backend.prompt).toHaveBeenCalledTimes(1));
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
expect(box).toHaveAttribute("contenteditable", "true");
typeIn(box, "next unsent message");
act(() => updateComposer("A", { files: [file("next.ts")], images: [image("next.png")] }));
await act(async () => response.resolve(ok(null)));
expect(textOf(box)).toBe("next unsent message");
expect(composerDraft("A").files).toEqual([file("next.ts")]);
expect(composerDraft("A").images).toEqual([image("next.png")]);
expect(screen.getByRole("button", { name: "Send" })).toBeEnabled();
});
it("clears an unchanged session draft after only moving the caret", async () => {
const response = deferred();
backend.prompt.mockReturnValue(response.promise);
render(<SessionEditor input={{ kind: SESSION_KIND, key: sessionKey("A"), data: { task: "A" } }} />);
const box = screen.getByRole("textbox", { name: "Message" });
typeIn(box, "sent message");
fireEvent.click(screen.getByRole("button", { name: "Send" }));
setCaret(box, 2);
await act(async () => response.resolve(ok(null)));
expect(textOf(box)).toBe("");
});
it("keeps the next Home draft when task creation succeeds and opens the created task", async () => {
const response = deferred();
backend.create.mockReturnValue(response.promise);
backend.close.mockResolvedValue(true);
const home = render(<HomeEditor />);
const box = screen.getByRole("textbox");
typeIn(box, "submitted task");
fireEvent.click(screen.getByRole("button", { name: "Start" }));
await waitFor(() => expect(backend.create).toHaveBeenCalledTimes(1));
expect(screen.getByRole("button", { name: "Starting…" })).toBeDisabled();
typeIn(box, "next unsent task");
await act(async () => response.resolve(ok(task("created"))));
expect(backend.close).toHaveBeenCalledWith(HOME_KEY);
expect(backend.open).toHaveBeenCalledWith(
{ kind: SESSION_KIND, key: sessionKey("created"), data: { task: "created", title: "created" } },
{ focus: true },
);
home.unmount();
render(<HomeEditor />);
expect(textOf(screen.getByRole("textbox"))).toBe("next unsent task");
expect(useAgents.getState().history).toEqual(["submitted task"]);
});
});
+13 -2
Переглянути файл
@@ -268,6 +268,15 @@ export function updateComposer(key: string, patch: Partial<ComposerDraft>) {
}));
}
/** An acknowledgement consumes only the submitted revision, in the same project connection. */
function clearSubmittedComposer(key: string, submitted: ComposerDraft, started: number) {
useAgents.setState((s) =>
generation === started && s.composers.get(key) === submitted
? { composers: new Map(s.composers).set(key, emptyDraft()) }
: s,
);
}
/** Session tabs on screen; a task can be shown in more than one split. */
const visible = new Map<string, number>();
const windowFocused = () => document.visibilityState !== "hidden" && document.hasFocus();
@@ -787,6 +796,7 @@ export async function startTask(
const { draft, providers } = useAgents.getState();
const composer = composerDraft(HOME);
if (!canSend(composer)) return;
const started = generation;
const options = draftConfig(
providers.find((p) => p.id === draft.provider),
draft,
@@ -821,7 +831,7 @@ export async function startTask(
}));
updateDraft({ projectRoot });
remember(composer.text.trim());
updateComposer(HOME, emptyDraft());
clearSubmittedComposer(HOME, composer, started);
if ((await editorService().closeInput?.(HOME_KEY)) !== false) openTask(task.id);
}
@@ -927,8 +937,9 @@ function remember(text: string) {
export async function sendMessage(task: string, now: boolean): Promise<void> {
const composer = composerDraft(task);
if (!canSend(composer)) return;
const started = generation;
await call(commands.agentPrompt(task, toPrompt(composer), now));
updateComposer(task, emptyDraft());
clearSubmittedComposer(task, composer, started);
}
/** Moves the caret to the end of composer `key`. */
-6
Переглянути файл
@@ -246,12 +246,6 @@
margin: 0 4px;
background: var(--border);
}
.treeFilter {
flex: 1;
min-width: 80px;
max-width: 280px;
margin-left: 4px;
}
.treeHost {
flex: 1;
min-height: 0;
+1 -9
Переглянути файл
@@ -41,7 +41,6 @@ beforeEach(() => {
sequences: [],
},
},
treeFilter: "",
separateProceduresAndFunctions: false,
treeExpanded: new Set(["pg", "pg/public", "pg/public/routines"]),
});
@@ -60,9 +59,6 @@ describe("routine grouping", () => {
expect(tree.queryByText("functions")).toBeNull();
fireEvent.mouseDown(tree.getAllByText("calculate")[1]!, { button: 0 });
expect(useDatabase.getState().selectedNode?.key).toBe("pg/public/routines/calculate(text)");
act(() => useDatabase.setState({ treeFilter: "refresh" }));
expect(tree.queryAllByText("calculate")).toHaveLength(0);
expect(tree.getByText("refresh_data")).toBeInTheDocument();
});
it("toggles through the action, persists the option, and restores the old expansion", async () => {
@@ -91,7 +87,7 @@ describe("routine grouping", () => {
});
});
it.each([false, true])("applies object and quick filters before grouping (separate: %s)", (separate) => {
it.each([false, true])("applies the object filter before grouping (separate: %s)", (separate) => {
const source = useDatabase.getState().sources[0]!;
useDatabase.setState({
separateProceduresAndFunctions: separate,
@@ -105,10 +101,6 @@ describe("routine grouping", () => {
expect(tree.getAllByText("calculate")).toHaveLength(2);
const folder = tree.getByText(separate ? "functions" : "routines").closest('[role="treeitem"]')!;
expect(within(folder as HTMLElement).getByText("2")).toBeInTheDocument();
act(() => useDatabase.setState({ treeFilter: "refresh" }));
expect(tree.queryByText("public")).toBeNull();
expect(tree.queryByText("routines")).toBeNull();
expect(tree.queryByText("functions")).toBeNull();
});
});
+9 -49
Переглянути файл
@@ -12,25 +12,17 @@ import { shortcutLabel } from "@/platform/keymap";
import {
Button,
EmptyState,
HighlightedText,
Icon,
IconButton,
Menu,
SearchField,
Tree,
showContextMenu,
tooltipAttrs,
type TreeHandle,
T,
} from "@/ui";
import {
databaseKey,
passesObjectFilter,
passesTreeFilter,
shownNames,
sourceIcon,
splitTarget,
targetOf,
} from "./model";
import { databaseKey, passesObjectFilter, shownNames, sourceIcon, splitTarget, targetOf } from "./model";
import {
nodeTarget,
isOtherFolder,
@@ -168,25 +160,10 @@ export function DatabaseView() {
[st.sources],
);
// While a quick filter is on, the schemas of expanded sources load their objects, so
// tables / routines matching the filter show up without expanding every schema node.
useEffect(() => {
if (!st.treeFilter.trim()) return;
const s = useDatabase.getState();
for (const [target, list] of Object.entries(s.schemas)) {
if (list.status !== "ok") continue;
const { id, database } = splitTarget(target);
if (!s.treeExpanded.has(database ? databaseKey(id, database) : id)) continue;
for (const schema of list.value) {
if (!s.tables[tablesKey(target, schema)]) void loadTables(target, schema);
}
}
}, [st.treeFilter, st.treeExpanded, st.schemas]);
/** Schemas tab › Object filter, plus the toolbar's quick filter, of the data source of target `id`. */
/** Schemas tab › Object filter of the data source of target `id`. */
const shown = (id: string) => {
const filter = sourceOf(id)?.introspection?.objectFilter;
return (o: { name: string }) => passesObjectFilter(o.name, filter) && passesTreeFilter(o.name, st.treeFilter);
return (o: { name: string }) => passesObjectFilter(o.name, filter);
};
const tablesOf = (id: string, schema: string) => {
const t = st.tables[tablesKey(id, schema)];
@@ -200,7 +177,7 @@ export function DatabaseView() {
(st.objects[tablesKey(id, schema)]?.packages ?? []).filter(shown(id));
const othersOf = (id: string, schema: string, folder: Folder) =>
(st.objects[tablesKey(id, schema)]?.others ?? []).filter((o) => otherFolder(o.kind) === folder).filter(shown(id));
/** Members of a schema folder, after object and quick filtering. */
/** Members of a schema folder, after object filtering. */
const folderSize = (id: string, schema: string, folder: Folder): number => {
if (isOtherFolder(folder)) return othersOf(id, schema, folder).length;
if (folder === "routines" || folder === "procedures" || folder === "functions")
@@ -227,19 +204,9 @@ export function DatabaseView() {
return shownNames(v.names, i?.allDatabases, i?.databases, v.current);
};
/** Under a quick filter a schema shows when its name matches, or its loaded objects do. */
const schemaVisible = (id: string, schema: string) =>
passesTreeFilter(schema, st.treeFilter) ||
!!tablesOf(id, schema)?.length ||
routinesOf(id, schema).length > 0 ||
packagesOf(id, schema).length > 0 ||
sequencesOf(id, schema).length > 0;
const schemasOf = (key: string, id: string) =>
status(key, st.schemas[id], (v) =>
shownSchemas(id, v)
.filter((schema) => schemaVisible(id, schema))
.map((schema) => ({ kind: "schema", key: `${id}/${schema}`, id, schema }) as Node),
shownSchemas(id, v).map((schema) => ({ kind: "schema", key: `${id}/${schema}`, id, schema }) as Node),
);
const children = (n: Node): readonly Node[] | undefined => {
@@ -563,7 +530,8 @@ export function DatabaseView() {
const color = target ? sourceOf(target)?.color : null;
return color ? `${styles.tintedRow} ${ROW_TINTS[color]}` : undefined;
}}
renderNode={(n: Node) => {
speedSearchField
renderNode={(n: Node, state) => {
const i = icon(n);
const d = detail(n);
const counted = n.kind === "source" || n.kind === "database" || n.kind === "folder" || n.kind === "group";
@@ -586,7 +554,7 @@ export function DatabaseView() {
{live && <span className={styles.connectedDot} title={tx("Connected")} />}
</span>
)}
<span className={labelClass}>{label(n)}</span>
<HighlightedText className={labelClass} text={label(n)} indices={state.matches} />
{n.kind === "source" && n.source.global && (
<span className={styles.treeDetail} title={tx("Available in all projects")}>
{tx("global")}
@@ -643,7 +611,6 @@ function DatabaseToolbar() {
const node = useDatabase((s) => s.selectedNode);
const connected = useDatabase((s) => s.connected);
const sources = useDatabase((s) => s.sources);
const treeFilter = useDatabase((s) => s.treeFilter);
const plus = useRef<HTMLButtonElement>(null);
const newOpen = useDatabase((s) => s.newMenuOpen);
const setNewOpen = (v: boolean) => useDatabase.setState({ newMenuOpen: v });
@@ -689,13 +656,6 @@ function DatabaseToolbar() {
>
<T>DDL</T>
</button>
<SearchField
className={styles.treeFilter}
value={treeFilter}
onChange={(treeFilter) => useDatabase.setState({ treeFilter })}
placeholder={tx("Filter objects")}
aria-label={tx("Filter objects")}
/>
</div>
);
}
+47 -24
Переглянути файл
@@ -4,7 +4,16 @@
* a toolbar (re-run, export / copy as CSV, JSON, SQL…), the grid and a floating row count.
*/
import { copySelectionOnKeyDown } from "@/ui/selectableText";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type MouseEvent } from "react";
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
type MouseEvent,
} from "react";
import clsx from "clsx";
import { call, commands, type DbPlanNode, type DbQueryResult } from "@/platform/ipc";
import { shortcutLabel } from "@/platform/keymap";
@@ -198,8 +207,32 @@ function Pager({ tab }: { tab: ResultTab }) {
);
}
/** Unsubmitted edits by the rows they were made on, kept while another result tab is shown. */
/**
* Unsubmitted edits by the rows they were made on, and the results whose edits are being
* submitted. They live outside the pane, which remounts when another result tab is selected,
* so a submit that ends while its pane is unmounted still clears what the remounted one shows.
*/
const keptEdits = new WeakMap<DbQueryResult, PendingEdits>();
const submittingResults = new Set<DbQueryResult>();
const NO_EDITS = emptyEdits();
const resultListeners = new Set<() => void>();
function notifyResults() {
for (const listener of resultListeners) listener();
}
function subscribeResults(listener: () => void) {
resultListeners.add(listener);
return () => void resultListeners.delete(listener);
}
function setKeptEdits(result: DbQueryResult, edits: PendingEdits) {
if (hasEdits(edits)) keptEdits.set(result, edits);
else keptEdits.delete(result);
notifyResults();
}
function setSubmittingResult(result: DbQueryResult, on: boolean) {
if (on) submittingResults.add(result);
else submittingResults.delete(result);
notifyResults();
}
function Result({ tab }: { tab: ResultTab }) {
const [view, setView] = useState<"table" | "chart">("table");
@@ -275,20 +308,12 @@ function Result({ tab }: { tab: ResultTab }) {
driver !== "clickhouse" &&
sources.some((s) => s !== null);
// The pending edits belong to the result they were made on: fresh rows (a
// re-run, another page) start over, without an effect. They outlive the pane,
// which remounts when another result tab is selected.
const [pending, setPending] = useState<{ outcome: DbQueryResult | null; edits: PendingEdits }>(() => ({
outcome: r ?? null,
edits: (r && keptEdits.get(r)) ?? emptyEdits(),
}));
const edits = pending.outcome === r ? pending.edits : emptyEdits();
const setEdits = (f: (e: PendingEdits) => PendingEdits) =>
setPending((previous) => {
const next = f(previous.outcome === r ? previous.edits : emptyEdits());
if (r) keptEdits.set(r, next);
return { outcome: r ?? null, edits: next };
});
// The pending edits belong to the result they were made on: fresh rows (a re-run,
// another page) start over.
const edits = useSyncExternalStore(subscribeResults, () => (r && keptEdits.get(r)) ?? NO_EDITS);
const setEdits = (f: (e: PendingEdits) => PendingEdits) => {
if (r) setKeptEdits(r, f(keptEdits.get(r) ?? NO_EDITS));
};
const loaded = r?.rows.length ?? 0;
const rowCount = loaded + edits.inserts.length;
const value = useCallback(
@@ -341,14 +366,12 @@ function Result({ tab }: { tab: ResultTab }) {
});
const dirty = hasEdits(edits);
const [submitting, setSubmitting] = useState(false);
const submitGuard = useRef(false);
const submitting = useSyncExternalStore(subscribeResults, () => !!r && submittingResults.has(r));
const busy = submitting || !!tab.busy;
const submit = async () => {
if (!r || !target || !pk || !editable || busy || submitGuard.current) return;
submitGuard.current = true;
setSubmitting(true);
if (!r || !target || !pk || !editable || busy || submittingResults.has(r)) return;
setSubmittingResult(r, true);
const list = buildRowEdits(resultNames, r.rows, pk, edits);
try {
// The Output log shows what the submit is about to change.
@@ -358,7 +381,8 @@ function Result({ tab }: { tab: ResultTab }) {
);
log("result", `${target.table}: ${n} row(s) changed`);
notifications().notify({ level: "info", title: `${target.table}: ${n} row(s) changed`, key: "db-submit" });
setEdits(() => emptyEdits());
// Also when this pane was unmounted meanwhile: the applied edits must not come back.
setKeptEdits(r, NO_EDITS);
await rerunResult(tab.id);
} catch (e) {
log("error", e instanceof Error ? e.message : String(e));
@@ -369,8 +393,7 @@ function Result({ tab }: { tab: ResultTab }) {
key: "db-submit",
});
} finally {
submitGuard.current = false;
setSubmitting(false);
setSubmittingResult(r, false);
}
};
+13 -4
Переглянути файл
@@ -3,10 +3,18 @@ import { describe, expect, it, vi } from "vitest";
import { SourceDialog } from "./SourceDialog";
const ipc = vi.hoisted(() => ({ call: vi.fn() }));
vi.mock("@/platform/ipc", async (importOriginal) => ({
...(await importOriginal<typeof import("@/platform/ipc")>()),
call: ipc.call,
}));
vi.mock("@/platform/ipc", async (importOriginal) => {
const real = await importOriginal<typeof import("@/platform/ipc")>();
// The commands `call` receives must not reach Tauri's invoke, which jsdom lacks.
const request =
(name: string) =>
(...args: unknown[]) => [name, ...args];
return {
...real,
call: ipc.call,
commands: { ...real.commands, dbTest: request("dbTest"), dbTestTunnel: request("dbTestTunnel") },
};
});
const savePassword = () => screen.getByRole("checkbox", { name: "Save password" }) as HTMLInputElement;
@@ -63,6 +71,7 @@ describe("Test SSH Tunnel", () => {
await waitFor(() =>
expect(screen.getByRole("status").textContent).toBe("SSH tunnel works: dev@bastion:22 reaches 127.0.0.1:5432"),
);
expect(ipc.call.mock.calls[0]?.[0]?.[0]).toBe("dbTestTunnel");
ipc.call.mockClear();
});
});
-11
Переглянути файл
@@ -12,7 +12,6 @@ import {
idleTargets,
matchesPattern,
parseSourceUrl,
passesTreeFilter,
schemaSwitchable,
shownNames,
sourceLabel,
@@ -392,16 +391,6 @@ describe("Schemas tab", () => {
expect(shownNames(names, false, ["app*"], "postgres")).toEqual(["app", "app_test", "postgres"]);
expect(shownNames(names, false, [])).toEqual([]);
});
it("quick-filters tree objects by a case-insensitive substring", () => {
expect(passesTreeFilter("users", "user")).toBe(true);
expect(passesTreeFilter("Users", "user")).toBe(true);
expect(passesTreeFilter("order_items", " IT ")).toBe(true);
expect(passesTreeFilter("users", "orders")).toBe(false);
expect(passesTreeFilter("users", "")).toBe(true);
expect(passesTreeFilter("users", " ")).toBe(true);
expect(passesTreeFilter("users", null)).toBe(true);
});
});
describe("active schema", () => {
-9
Переглянути файл
@@ -205,15 +205,6 @@ export function passesObjectFilter(name: string, filter: string | null | undefin
return !patterns.length || patterns.some((p) => matchesPattern(name.toLowerCase(), p.toLowerCase()));
}
/**
* The tree's quick filter (the toolbar's field): `name` contains it,
* case-insensitively (`*` / `?` still work through `matchesPattern` callers).
*/
export function passesTreeFilter(name: string, filter: string | null | undefined): boolean {
const q = (filter ?? "").trim().toLowerCase();
return !q || name.toLowerCase().includes(q);
}
/**
* Whether a console of `driver` gets the active-schema dropdown (a server with
* several schemas / databases to switch between; SQLite has one database only).
+60
Переглянути файл
@@ -231,6 +231,66 @@ describe("editable result grid", () => {
await act(async () => finish({ status: "ok", data: 1 }));
});
it("keeps a submission in flight from being repeated after switching result tabs", async () => {
let finish!: (value: { status: string; data: number }) => void;
backend.applyEdits.mockImplementation(
() =>
new Promise((resolve) => {
finish = resolve;
}),
);
backend.execute.mockImplementation(() => new Promise(() => {}));
const first = show("SELECT * FROM users", [{ name: "id" }, { name: "name" }], [["1", "Ann"]]);
const second: ResultTab = {
...first,
id: nextId++,
title: "Result 2",
outcome: { ...first.outcome, result: { ...first.outcome.result! } },
};
useDatabase.setState((s) => ({ results: [...s.results, second] }));
await act(async () => render(<ResultsView />));
fireEvent.click(screen.getByRole("button", { name: "Add Row" }));
await act(async () => fireEvent.click(screen.getByRole("button", { name: "Submit" })));
await act(async () => useDatabase.setState({ activeResult: second.id }));
await act(async () => useDatabase.setState({ activeResult: first.id }));
expect(screen.getByRole("button", { name: "Submit" })).toBeDisabled();
fireEvent.click(screen.getByRole("button", { name: "Submit" }));
expect(backend.applyEdits).toHaveBeenCalledTimes(1);
await act(async () => finish({ status: "ok", data: 1 }));
// The applied row is not offered again while the result re-runs.
expect(screen.getByRole("button", { name: "Submit" })).toBeDisabled();
});
it("drops applied edits from a remounted pane even when the refresh fails", async () => {
let finish!: (value: { status: string; data: number }) => void;
backend.applyEdits.mockImplementation(
() =>
new Promise((resolve) => {
finish = resolve;
}),
);
backend.execute.mockResolvedValue({ status: "error", error: { kind: "invalid", message: "gone" } });
const first = show("SELECT * FROM users", [{ name: "id" }, { name: "name" }], [["1", "Ann"]]);
const second: ResultTab = {
...first,
id: nextId++,
title: "Result 2",
outcome: { ...first.outcome, result: { ...first.outcome.result! } },
};
useDatabase.setState((s) => ({ results: [...s.results, second] }));
await act(async () => render(<ResultsView />));
fireEvent.click(screen.getByRole("button", { name: "Add Row" }));
await act(async () => fireEvent.click(screen.getByRole("button", { name: "Submit" })));
await act(async () => useDatabase.setState({ activeResult: second.id }));
await act(async () => useDatabase.setState({ activeResult: first.id }));
await act(async () => finish({ status: "ok", data: 1 }));
expect(screen.getByRole("button", { name: "Submit" })).toBeDisabled();
expect(backend.applyEdits).toHaveBeenCalledTimes(1);
});
it("uses the execution schema even after the console selection changes", async () => {
const tab = show("SELECT * FROM users", [{ name: "id" }, { name: "name" }], [["1", "Ann"]]);
Object.assign(tab, { schema: "s2" });
-3
Переглянути файл
@@ -164,8 +164,6 @@ interface DatabaseState {
consoles: Record<string, string>;
/** Console path → its active schema (unqualified names resolve there; see `consoleSchema`). */
consoleSchemas: Record<string, string>;
/** The Database tree's quick object filter (empty: everything shows). */
treeFilter: string;
separateProceduresAndFunctions: boolean;
completion: Record<string, DbCompletion>;
results: ResultTab[];
@@ -233,7 +231,6 @@ export const useDatabase = create<DatabaseState>(() => ({
objects: {},
consoles: readBindings(),
consoleSchemas: readConsoleSchemas(),
treeFilter: "",
separateProceduresAndFunctions: readSeparateProceduresAndFunctions(),
completion: {},
results: [],
+5 -1
Переглянути файл
@@ -13,7 +13,11 @@ describe("JavaScript events and requests section", () => {
expect(screen.getByRole("alert")).toHaveTextContent(
"The debugger of the running page does not take these breakpoints: unknown request",
);
act(() => setRefused(4, "eventBreakpoints", null));
// Another tool's refusal is not about breakpoints.
act(() => {
setRefused(4, "eventBreakpoints", null);
setRefused(4, "network", "");
});
expect(screen.queryByRole("alert")).toBeNull();
});
});
+200
Переглянути файл
@@ -0,0 +1,200 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import type { JsNetworkEvent, JsResponseBody } from "@/platform/ipc";
const asked: Array<[number, string]> = [];
const evaluated: unknown[][] = [];
let answer: { status: "ok"; data: JsResponseBody } | { status: "error"; error: { kind: string; message: string } };
vi.mock("@/platform/ipc", async (orig) => {
const real = await orig<typeof import("@/platform/ipc")>();
return {
...real,
commands: {
...real.commands,
debugJsResponseBody: (session: number, requestId: string) => {
asked.push([session, requestId]);
return Promise.resolve(answer);
},
debugEvaluateIn: (...args: unknown[]) => {
evaluated.push(args);
return Promise.resolve({ status: "ok", data: { value: "", variablesReference: 0 } });
},
},
};
});
const { applyNetworkEvents, useJsNetwork } = await import("../js/network");
const { NetworkView } = await import("../js/NetworkView");
const { useJsBreakpoints } = await import("../js/eventBreakpoints");
const { setRefused } = await import("../js/refusals");
const { handleDebugEvent, useDebug } = await import("../state");
const event = (kind: JsNetworkEvent["kind"], requestId: string, p: Partial<JsNetworkEvent> = {}): JsNetworkEvent => ({
kind,
requestId,
timestampMs: 1000,
url: null,
method: null,
resourceType: null,
status: null,
mimeType: null,
headers: {},
encodedLength: null,
errorText: null,
initiator: null,
...p,
});
function load() {
applyNetworkEvents(7, [
event("request", "1", {
url: "http://h/api/items",
method: "GET",
resourceType: "Fetch",
headers: { Accept: "*/*" },
}),
event("response", "1", {
status: 200,
mimeType: "application/json",
headers: { "Content-Type": "application/json" },
}),
event("finished", "1", { timestampMs: 1250, encodedLength: 2048 }),
event("request", "2", { url: "http://h/logo.png", method: "GET", resourceType: "Image" }),
event("request", "3", { url: "http://h/gone", method: "POST", resourceType: "XHR" }),
event("failed", "3", { timestampMs: 1100, errorText: "net::ERR_FAILED" }),
]);
}
beforeEach(() => {
useJsNetwork.setState({ rows: {} });
asked.length = 0;
evaluated.length = 0;
answer = { status: "ok", data: { body: '{"items":[]}', base64Encoded: false, truncated: false } };
});
describe("Network view", () => {
it("lists a session's requests with status, type, size and time", () => {
load();
render(<NetworkView session={7} />);
const rows = screen.getAllByRole("row").slice(1);
expect(rows).toHaveLength(3);
expect(rows[0]).toHaveTextContent("items");
expect(rows[0]).toHaveTextContent("200");
expect(rows[0]).toHaveTextContent("Fetch");
expect(rows[0]).toHaveTextContent("2.0 kB");
expect(rows[0]).toHaveTextContent("250 ms");
expect(rows[2]).toHaveTextContent("net::ERR_FAILED");
});
it("filters by a part of the URL", () => {
load();
render(<NetworkView session={7} />);
fireEvent.change(screen.getByLabelText("Filter requests"), { target: { value: "logo" } });
expect(screen.getAllByRole("row").slice(1)).toHaveLength(1);
});
it("shows a selected request's headers and loads its body", async () => {
load();
render(<NetworkView session={7} />);
fireEvent.click(screen.getAllByRole("row")[1]!);
expect(screen.getByText("http://h/api/items")).toBeInTheDocument();
expect(screen.getByText("Accept")).toBeInTheDocument();
expect(screen.getByText("Content-Type")).toBeInTheDocument();
await waitFor(() => expect(screen.getByText('{"items":[]}')).toBeInTheDocument());
expect(asked).toEqual([[7, "1"]]);
});
it("says so when a body is binary, cut or gone", async () => {
load();
answer = { status: "ok", data: { body: "AAEC", base64Encoded: true, truncated: false } };
const { unmount } = render(<NetworkView session={7} />);
fireEvent.click(screen.getAllByRole("row")[1]!);
await waitFor(() => expect(screen.getByText("Binary content")).toBeInTheDocument());
expect(screen.queryByText("AAEC")).toBeNull();
unmount();
answer = { status: "ok", data: { body: "abc", base64Encoded: false, truncated: true } };
const second = render(<NetworkView session={7} />);
fireEvent.click(screen.getAllByRole("row")[1]!);
await waitFor(() => expect(screen.getByText("Showing the first 1 MB")).toBeInTheDocument());
second.unmount();
// The session ended, or the browser dropped the response.
answer = { status: "error", error: { kind: "unavailable", message: "gone" } };
render(<NetworkView session={7} />);
fireEvent.click(screen.getAllByRole("row")[1]!);
await waitFor(() => expect(screen.getByText("Response body is no longer available")).toBeInTheDocument());
});
it("clears the list", () => {
load();
render(<NetworkView session={7} />);
fireEvent.click(screen.getByRole("button", { name: "Clear" }));
expect(screen.queryAllByRole("row").slice(1)).toHaveLength(0);
expect(screen.getByText(/^No requests yet/)).toBeInTheDocument();
});
it("filters by the kind of resource, like a browser's network panel", () => {
load();
render(<NetworkView session={7} />);
fireEvent.click(screen.getByRole("button", { name: "Fetch/XHR" }));
expect(screen.getByRole("button", { name: "Fetch/XHR" })).toHaveAttribute("aria-pressed", "true");
const shown = screen.getAllByRole("row").slice(1);
expect(shown).toHaveLength(2);
expect(shown[0]).toHaveTextContent("items");
expect(shown[1]).toHaveTextContent("gone");
fireEvent.click(screen.getByRole("button", { name: "Img" }));
expect(screen.getAllByRole("row").slice(1)).toHaveLength(1);
fireEvent.click(screen.getByRole("button", { name: "All" }));
expect(screen.getAllByRole("row").slice(1)).toHaveLength(3);
});
it("can show only what the project's own code requested", () => {
applyNetworkEvents(7, [
event("request", "1", {
url: "http://h/api/items",
resourceType: "Fetch",
initiator: { function: "load", url: "http://h/src/App.vue", line: 9, column: 2 },
}),
event("request", "2", {
url: "http://h/api/track",
resourceType: "Fetch",
initiator: { function: "send", url: "http://h/node_modules/.vite/deps/analytics.js", line: 1, column: 1 },
}),
]);
render(<NetworkView session={7} />);
fireEvent.click(screen.getByLabelText("My code only"));
const shown = screen.getAllByRole("row").slice(1);
expect(shown).toHaveLength(1);
expect(shown[0]).toHaveTextContent("items");
});
it("turns a request into a breakpoint on the next one to that address", () => {
useJsBreakpoints.setState({ root: null, events: {}, requests: [] });
load();
render(<NetworkView session={7} />);
fireEvent.click(screen.getAllByRole("row")[1]!);
fireEvent.click(screen.getByRole("button", { name: "Break on Next Request" }));
expect(useJsBreakpoints.getState().requests).toEqual([{ pattern: "/api/items", enabled: true }]);
});
it("reloads the page, to list the requests of its load", () => {
render(<NetworkView session={7} />);
// Recording starts while the page is already loading.
expect(screen.getByText("No requests yet. Reload the page to see the requests of its load.")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Reload Page" }));
expect(evaluated).toEqual([[7, null, "location.reload()", "repl"]]);
});
it("says that requests are not reported instead of waiting for them", () => {
render(<NetworkView session={7} />);
expect(screen.getByText(/^No requests yet/)).toBeInTheDocument();
const page = { id: 7, name: "page", status: "running", caps: { jsPage: true } };
useDebug.setState({ sessions: { 7: page } as unknown as ReturnType<typeof useDebug.getState>["sessions"] });
act(() => handleDebugEvent(7, { kind: "jsNetworkUnavailable" }));
expect(
screen.getByText("This version of the JavaScript debugger does not report a page's requests."),
).toBeInTheDocument();
setRefused(7, "network", null);
});
});
+1
Переглянути файл
@@ -107,6 +107,7 @@ const caps = (p: Partial<DebugCapabilities> = {}): DebugCapabilities => ({
jvmInstances: false,
jvmHeap: false,
jsPage: false,
liveEvaluate: false,
...p,
});
+26 -3
Переглянути файл
@@ -66,6 +66,24 @@ beforeEach(() => {
});
});
/** Where the page goes when it runs `expression`; null when it stays. Tracks the address. */
let pageAt = "http://localhost:5173/";
function run(expression: string): string | null {
let went: string | null = null;
const location = {
get href() {
return pageAt;
},
assign: (url: string) => {
went = url;
pageAt = url;
},
};
// The page's own globals may shadow the browser's (`const URL = "/api"`).
new Function("location", "URL", `return ${expression}`)(location, "/api");
return went;
}
describe("the browser of a project under the debugger", () => {
it("is started once: a second launch sends its page to the new address and selects it", async () => {
const first = await startBrowserSession(browserLaunch("a", "http://localhost:5173/", "chrome", "/p"));
@@ -76,13 +94,18 @@ describe("the browser of a project under the debugger", () => {
const again = await startBrowserSession(browserLaunch("b", "http://localhost:5173/cart", "chrome", "/p"));
expect(again).toBe(first);
expect(ipc.debugStart).toHaveBeenCalledTimes(1);
expect(ipc.debugEvaluateIn).toHaveBeenCalledWith(2, null, 'location.assign("http://localhost:5173/cart")', "repl");
const sentTo = () => ipc.debugEvaluateIn.mock.calls.map(([, , expression]) => run(String(expression)));
expect(sentTo()).toEqual(["http://localhost:5173/cart"]);
expect(useDebug.getState().active).toBe(2);
// The address it was started on: the page is where it is.
// Back to the address it was started on: the page is at /cart now, so it goes back.
ipc.debugEvaluateIn.mockClear();
await startBrowserSession(browserLaunch("a", "http://localhost:5173/", "chrome", "/p"));
expect(ipc.debugEvaluateIn).not.toHaveBeenCalled();
expect(sentTo()).toEqual(["http://localhost:5173/"]);
// A page already at the address stays as it is.
ipc.debugEvaluateIn.mockClear();
await startBrowserSession(browserLaunch("a", "http://localhost:5173/", "chrome", "/p"));
expect(sentTo()).toEqual([null]);
});
it("is another one for another browser, another project, or after the first has ended", async () => {
+151
Переглянути файл
@@ -0,0 +1,151 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, renderHook } from "@testing-library/react";
type Answer = { status: "ok"; data: unknown } | { status: "error"; error: { kind: string; message: string } };
const asked: Array<[number, number | null, string, string | null]> = [];
let answer: (expression: string) => Promise<Answer>;
vi.mock("@/platform/ipc", async (orig) => {
const real = await orig<typeof import("@/platform/ipc")>();
return {
...real,
commands: {
...real.commands,
debugEvaluateIn: (session: number, frame: number | null, expression: string, context: string | null) => {
asked.push([session, frame, expression, context]);
return answer(expression);
},
},
};
});
const { useProject } = await import("@/platform/project");
const { LIVE_WATCH_INTERVAL_MS, liveWatchesApply, useLiveWatches } = await import("../js/liveWatches");
type Session = Parameters<typeof useLiveWatches>[0];
const session = (p: Record<string, unknown> = {}): Session =>
({ id: 3, name: "page", status: "running", caps: { liveEvaluate: true }, ...p }) as Session;
const value = (text: string) => ({ status: "ok" as const, data: { value: text, variablesReference: 0 } });
type Settings = ReturnType<typeof useProject.getState>["settings"];
const setLiveWatches = (on: boolean | null) =>
useProject.setState({ settings: (on == null ? {} : { debugger: { liveWatches: on } }) as Settings });
const settle = () => act(async () => void (await vi.advanceTimersByTimeAsync(0)));
beforeEach(() => {
vi.useFakeTimers();
asked.length = 0;
answer = (expression) => Promise.resolve(value(`<${expression}>`));
setLiveWatches(true);
});
afterEach(() => vi.useRealTimers());
describe("watches while the program runs", () => {
it("evaluates each watch without a frame while the session runs", async () => {
const { result } = renderHook(() => useLiveWatches(session(), ["a", "b"], true));
await settle();
expect(asked).toEqual([
[3, null, "a", "watch"],
[3, null, "b", "watch"],
]);
expect(result.current.map((r) => [r.expression, (r.result as { value: string } | null)?.value, r.error])).toEqual([
["a", "<a>", null],
["b", "<b>", null],
]);
});
it("evaluates a multi-line watch as one expression", async () => {
const multiline = "items\n .filter((i) => i.done)\n .length";
renderHook(() => useLiveWatches(session(), [multiline, "b"], true));
await settle();
expect(asked.map((a) => a[2])).toEqual([multiline, "b"]);
});
it("refreshes every second and not faster", async () => {
expect(LIVE_WATCH_INTERVAL_MS).toBe(1000);
renderHook(() => useLiveWatches(session(), ["a"], true));
await settle();
expect(asked).toHaveLength(1);
await act(async () => void (await vi.advanceTimersByTimeAsync(999)));
expect(asked).toHaveLength(1);
await act(async () => void (await vi.advanceTimersByTimeAsync(1)));
expect(asked).toHaveLength(2);
});
it("skips a tick while the previous one is pending", async () => {
answer = () => new Promise(() => {});
renderHook(() => useLiveWatches(session({ id: 90 }), ["slow"], true));
await act(async () => void (await vi.advanceTimersByTimeAsync(3000)));
expect(asked).toHaveLength(1);
});
it("does not start a second round in a session when the watch list changes during a slow one", async () => {
let finish = () => {};
answer = () => new Promise((resolve) => (finish = () => resolve(value("done"))));
const { rerender } = renderHook(({ list }) => useLiveWatches(session({ id: 91 }), list, true), {
initialProps: { list: ["slow"] },
});
await settle();
rerender({ list: ["slow", "other"] });
await act(async () => void (await vi.advanceTimersByTimeAsync(2000)));
expect(asked).toEqual([[91, null, "slow", "watch"]]);
// Once it has answered, the next tick asks for the list as it is now.
answer = (expression) => Promise.resolve(value(`<${expression}>`));
finish();
await act(async () => void (await vi.advanceTimersByTimeAsync(1000)));
expect(asked.slice(1).map((a) => a[2])).toEqual(["slow", "other"]);
});
it("is off until turned on in the settings", () => {
setLiveWatches(null);
expect(liveWatchesApply(session())).toBe(false);
setLiveWatches(true);
expect(liveWatchesApply(session())).toBe(true);
});
it("stops when hidden, paused, terminated, switched off or unsupported", async () => {
const quiet: Array<[Session, boolean]> = [
[session(), false],
[session({ status: "paused" }), true],
[session({ status: "terminated" }), true],
[session({ caps: { liveEvaluate: false } }), true],
[session({ caps: null }), true],
[undefined, true],
];
for (const [s, visible] of quiet) {
const { result, unmount } = renderHook(() => useLiveWatches(s, ["a"], visible));
await act(async () => void (await vi.advanceTimersByTimeAsync(2500)));
expect(result.current).toEqual([]);
unmount();
}
setLiveWatches(false);
expect(liveWatchesApply(session())).toBe(false);
const { result } = renderHook(() => useLiveWatches(session(), ["a"], true));
await act(async () => void (await vi.advanceTimersByTimeAsync(2500)));
expect(result.current).toEqual([]);
expect(asked).toEqual([]);
});
it("an evaluation error becomes the watch's error", async () => {
answer = () =>
Promise.resolve({ status: "error", error: { kind: "internal", message: "ReferenceError: x is not defined" } });
const { result } = renderHook(() => useLiveWatches(session(), ["x"], true));
await settle();
expect(result.current).toEqual([{ expression: "x", result: null, error: "ReferenceError: x is not defined" }]);
});
it("drops a late answer of another session", async () => {
let release: (a: Answer) => void = () => {};
answer = () => new Promise<Answer>((resolve) => (release = resolve));
const { result, rerender } = renderHook(({ s }) => useLiveWatches(s, ["a"], true), {
initialProps: { s: session({ id: 3 }) },
});
await settle();
answer = (expression) => Promise.resolve(value(`new ${expression}`));
rerender({ s: session({ id: 4 }) });
await settle();
await act(async () => {
release(value("old"));
await vi.advanceTimersByTimeAsync(0);
});
expect(result.current.map((r) => (r.result as { value: string } | null)?.value)).toEqual(["new a"]);
});
});
+159
Переглянути файл
@@ -0,0 +1,159 @@
import { beforeEach, describe, expect, it } from "vitest";
import type { JsNetworkEvent } from "@/platform/ipc";
import {
applyNetworkEvents,
clearNetwork,
filterRows,
forgetNetwork,
MAX_ROWS,
rowCategory,
rowName,
useJsNetwork,
} from "../js/network";
const base: JsNetworkEvent = {
kind: "request",
requestId: "1",
timestampMs: 1000,
url: null,
method: null,
resourceType: null,
status: null,
mimeType: null,
headers: {},
encodedLength: null,
errorText: null,
initiator: null,
};
const request = (requestId: string, url: string, timestampMs = 1000): JsNetworkEvent => ({
...base,
requestId,
url,
method: "GET",
resourceType: "Fetch",
timestampMs,
headers: { Accept: "*/*" },
});
const response = (requestId: string, status = 200): JsNetworkEvent => ({
...base,
kind: "response",
requestId,
status,
mimeType: "application/json",
resourceType: "Fetch",
timestampMs: 1040,
headers: { "Content-Type": "application/json" },
});
const finished = (requestId: string, timestampMs = 1250): JsNetworkEvent => ({
...base,
kind: "finished",
requestId,
timestampMs,
encodedLength: 1234,
});
const rows = (session = 1) => useJsNetwork.getState().rows[session] ?? [];
beforeEach(() => useJsNetwork.setState({ rows: {} }));
describe("network rows of a debugged page", () => {
it("folds the events of one request into a row", () => {
applyNetworkEvents(1, [request("1", "http://h/api/items"), response("1"), finished("1")]);
expect(rows()).toHaveLength(1);
expect(rows()[0]).toMatchObject({
requestId: "1",
url: "http://h/api/items",
name: "items",
method: "GET",
type: "Fetch",
status: 200,
mimeType: "application/json",
size: 1234,
durationMs: 250,
error: null,
requestHeaders: { Accept: "*/*" },
responseHeaders: { "Content-Type": "application/json" },
});
});
it("a failed request keeps its error", () => {
applyNetworkEvents(1, [
request("2", "http://h/gone"),
{ ...base, kind: "failed", requestId: "2", timestampMs: 1100, errorText: "net::ERR_FAILED" },
]);
expect(rows()[0]).toMatchObject({ status: null, error: "net::ERR_FAILED", durationMs: 100 });
});
it("a reused request id after completion starts a new row", () => {
applyNetworkEvents(1, [request("1", "http://h/a"), finished("1")]);
applyNetworkEvents(1, [request("1", "http://h/b", 5000), response("1", 404)]);
expect(rows().map((r) => [r.url, r.status, r.size])).toEqual([
["http://h/a", null, 1234],
["http://h/b", 404, null],
]);
expect(new Set(rows().map((r) => r.key)).size).toBe(2);
});
it("events without a request are dropped", () => {
applyNetworkEvents(1, [response("9"), finished("9")]);
expect(rows()).toEqual([]);
});
it("keeps the newest rows of each session apart, up to the limit", () => {
const many = Array.from({ length: MAX_ROWS + 1 }, (_, i) => request(`r${i}`, `http://h/${i}`));
applyNetworkEvents(1, many);
applyNetworkEvents(2, [request("x", "http://h/other")]);
expect(MAX_ROWS).toBe(2000);
expect(rows(1)).toHaveLength(MAX_ROWS);
expect(rows(1)[0]!.url).toBe("http://h/1");
expect(rows(2)).toHaveLength(1);
});
it("filters by a part of the URL, ignoring case", () => {
applyNetworkEvents(1, [request("1", "http://h/api/Items"), request("2", "http://h/logo.png")]);
expect(filterRows(rows(), "ITEMS").map((r) => r.requestId)).toEqual(["1"]);
expect(filterRows(rows(), " ")).toHaveLength(2);
});
it("sorts resource types into the browser's filter groups", () => {
expect(["Fetch", "XHR"].map(rowCategory)).toEqual(["fetch", "fetch"]);
expect(["Document", "Script", "Stylesheet", "Image", "Font"].map(rowCategory)).toEqual([
"doc",
"js",
"css",
"img",
"font",
]);
expect(["WebSocket", "Media", "Other", ""].map(rowCategory)).toEqual(["other", "other", "other", "other"]);
});
it("filters by group, and by who made the request", () => {
const from = (url: string) => ({ function: "load", url, line: 1, column: 1 });
applyNetworkEvents(1, [
{ ...request("1", "http://h/api/items"), initiator: from("http://h/src/App.vue") },
{ ...request("2", "http://h/api/track"), initiator: from("http://h/node_modules/.vite/deps/analytics.js") },
{ ...request("3", "http://h/src/main.ts"), resourceType: "Script" },
]);
const ids = (rows: ReturnType<typeof filterRows>) => rows.map((r) => r.requestId);
expect(ids(filterRows(rows(), "", "fetch"))).toEqual(["1", "2"]);
expect(ids(filterRows(rows(), "", "js"))).toEqual(["3"]);
expect(ids(filterRows(rows(), "track", "fetch"))).toEqual(["2"]);
// Only what the project's own code asked for: not a library's request, not a tag in the page.
expect(ids(filterRows(rows(), "", "all", true))).toEqual(["1"]);
expect(ids(filterRows(rows(), "", "js", true))).toEqual([]);
});
it("names a row after the end of its path", () => {
expect(rowName("http://h/a/b.js?v=1")).toBe("b.js?v=1");
expect(rowName("http://h:5173/")).toBe("h:5173");
expect(rowName("http://h/dir/")).toBe("dir/");
expect(rowName("data:text/plain,hi")).toBe("data:text/plain,hi");
});
it("clears and forgets a session's rows", () => {
applyNetworkEvents(1, [request("1", "http://h/a")]);
clearNetwork(1);
expect(useJsNetwork.getState().rows[1]).toEqual([]);
forgetNetwork(1);
expect(1 in useJsNetwork.getState().rows).toBe(false);
});
});
+52
Переглянути файл
@@ -0,0 +1,52 @@
import { beforeEach, describe, expect, it } from "vitest";
import type { JsNetworkEvent } from "@/platform/ipc";
import { useDebug } from "../state";
import { applyNetworkEvents, useJsNetwork } from "../js/network";
import { watchNetworkSessions } from "../js/networkSessions";
type Sessions = ReturnType<typeof useDebug.getState>["sessions"];
function sessions(ids: number[]) {
const out: Record<number, unknown> = {};
for (const id of ids) out[id] = { id, name: `s${id}`, status: "running", caps: { jsPage: true } };
useDebug.setState({ sessions: out as Sessions });
}
const request: JsNetworkEvent = {
kind: "request",
requestId: "1",
timestampMs: 1,
url: "http://h/a",
method: "GET",
resourceType: "Fetch",
status: null,
mimeType: null,
headers: {},
encodedLength: null,
errorText: null,
initiator: null,
};
beforeEach(() => {
sessions([]);
useJsNetwork.setState({ rows: {} });
});
describe("the requests of page sessions", () => {
it("stay while the session is listed, finished or not", () => {
const stop = watchNetworkSessions();
sessions([1, 2]);
applyNetworkEvents(1, [request]);
sessions([1]);
expect(useJsNetwork.getState().rows[1]).toHaveLength(1);
stop();
});
it("are dropped when the session is removed", () => {
const stop = watchNetworkSessions();
sessions([1]);
applyNetworkEvents(1, [request]);
sessions([]);
expect(1 in useJsNetwork.getState().rows).toBe(false);
stop();
});
});
Loaded 100 of 155 files, more files were not shown because too many files have changed in this diff. Show more