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

..
1 Коміти
Автор SHA1 Повідомлення Дата
l0ng-ai 9497693454 fix(remote): trust SSH host keys and passwords on the host that connects
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:24 +08:00
155 змінених файлів з 410 додано та 5177 видалено

No files matched your search

Створено
+44 -44
Переглянути файл
@@ -4144,7 +4144,7 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "ide-agent"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"dunce",
"encoding_rs",
@@ -4168,7 +4168,7 @@ dependencies = [
[[package]]
name = "ide-analysis"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"ast-grep-core",
"ide-core",
@@ -4182,7 +4182,7 @@ dependencies = [
[[package]]
name = "ide-android"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"ide-core",
"serde",
@@ -4193,7 +4193,7 @@ dependencies = [
[[package]]
name = "ide-build"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"blake3",
"chardetng",
@@ -4211,7 +4211,7 @@ dependencies = [
[[package]]
name = "ide-cli"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"dirs 6.0.0",
"serde",
@@ -4225,7 +4225,7 @@ dependencies = [
[[package]]
name = "ide-clipboard"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"gtk",
"objc2 0.6.5",
@@ -4235,7 +4235,7 @@ dependencies = [
[[package]]
name = "ide-core"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"dunce",
"encoding_rs",
@@ -4252,7 +4252,7 @@ dependencies = [
[[package]]
name = "ide-coverage"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"ide-core",
"ide-run",
@@ -4266,7 +4266,7 @@ dependencies = [
[[package]]
name = "ide-dap"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"futures",
"ide-core",
@@ -4293,7 +4293,7 @@ dependencies = [
[[package]]
name = "ide-db"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"base64 0.22.1",
"bb8",
@@ -4329,7 +4329,7 @@ dependencies = [
[[package]]
name = "ide-docker"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"ide-core",
"serde",
@@ -4342,7 +4342,7 @@ dependencies = [
[[package]]
name = "ide-extension"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"base64 0.22.1",
"blake3",
@@ -4363,7 +4363,7 @@ dependencies = [
[[package]]
name = "ide-fonts"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"objc2-core-foundation",
"objc2-core-text",
@@ -4375,7 +4375,7 @@ dependencies = [
[[package]]
name = "ide-github"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"ide-core",
"ide-vcs",
@@ -4388,7 +4388,7 @@ dependencies = [
[[package]]
name = "ide-heap"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"criterion",
"ide-core",
@@ -4406,7 +4406,7 @@ dependencies = [
[[package]]
name = "ide-host"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"base64 0.22.1",
"blake3",
@@ -4477,7 +4477,7 @@ dependencies = [
[[package]]
name = "ide-http"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"ide-core",
"ide-vfs",
@@ -4496,7 +4496,7 @@ dependencies = [
[[package]]
name = "ide-index"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"blake3",
"criterion",
@@ -4518,7 +4518,7 @@ dependencies = [
[[package]]
name = "ide-jdwp"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"ide-jvm-inspect",
"proptest",
@@ -4530,7 +4530,7 @@ dependencies = [
[[package]]
name = "ide-jsonrpc"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"encoding_rs",
"futures",
@@ -4547,7 +4547,7 @@ dependencies = [
[[package]]
name = "ide-jvm-attach"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"libc",
"tempfile",
@@ -4556,7 +4556,7 @@ dependencies = [
[[package]]
name = "ide-jvm-inspect"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"futures",
"serde",
@@ -4567,7 +4567,7 @@ dependencies = [
[[package]]
name = "ide-language"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"blake3",
"criterion",
@@ -4624,7 +4624,7 @@ dependencies = [
[[package]]
name = "ide-lsp"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"dunce",
"encoding_rs",
@@ -4652,7 +4652,7 @@ dependencies = [
[[package]]
name = "ide-mcp"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"bytes",
"dunce",
@@ -4671,7 +4671,7 @@ dependencies = [
[[package]]
name = "ide-notebook"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"futures",
"ide-core",
@@ -4694,7 +4694,7 @@ dependencies = [
[[package]]
name = "ide-plugin"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"blake3",
"dunce",
@@ -4717,7 +4717,7 @@ dependencies = [
[[package]]
name = "ide-profiler"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"ide-core",
"ide-jvm-attach",
@@ -4731,7 +4731,7 @@ dependencies = [
[[package]]
name = "ide-remote"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"dunce",
"futures",
@@ -4751,7 +4751,7 @@ dependencies = [
[[package]]
name = "ide-run"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"dunce",
"encoding_rs",
@@ -4775,7 +4775,7 @@ dependencies = [
[[package]]
name = "ide-scaffold"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"ide-core",
"ide-tools",
@@ -4791,7 +4791,7 @@ dependencies = [
[[package]]
name = "ide-sdk"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"dunce",
"ide-core",
@@ -4812,7 +4812,7 @@ dependencies = [
[[package]]
name = "ide-search"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"criterion",
"encoding_rs",
@@ -4833,7 +4833,7 @@ dependencies = [
[[package]]
name = "ide-spell"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"ide-core",
"parking_lot",
@@ -4846,7 +4846,7 @@ dependencies = [
[[package]]
name = "ide-ssh"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"dirs 6.0.0",
"flate2",
@@ -4871,7 +4871,7 @@ dependencies = [
[[package]]
name = "ide-syntax"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"ide-core",
"ide-language",
@@ -4886,7 +4886,7 @@ dependencies = [
[[package]]
name = "ide-terminal"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"ide-core",
"libc",
@@ -4899,7 +4899,7 @@ dependencies = [
[[package]]
name = "ide-text"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"criterion",
"ide-core",
@@ -4912,7 +4912,7 @@ dependencies = [
[[package]]
name = "ide-tools"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"flate2",
"hex",
@@ -4933,7 +4933,7 @@ dependencies = [
[[package]]
name = "ide-vcs"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"blake3",
"dunce",
@@ -4954,7 +4954,7 @@ dependencies = [
[[package]]
name = "ide-vfs"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"chardetng",
"dunce",
@@ -4978,7 +4978,7 @@ dependencies = [
[[package]]
name = "ide-web"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"dirs 6.0.0",
"ide-core",
@@ -4994,7 +4994,7 @@ dependencies = [
[[package]]
name = "ide-workspace"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"blake3",
"dunce",
@@ -6595,7 +6595,7 @@ dependencies = [
[[package]]
name = "openidea"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"base64 0.22.1",
"blake3",
+1 -1
Переглянути файл
@@ -5,7 +5,7 @@ members = ["crates/*", "src-tauri"]
exclude = ["sdk", "plugins"]
[workspace.package]
version = "0.1.45"
version = "0.1.44"
edition = "2024"
rust-version = "1.96"
license = "Apache-2.0"
-50
Переглянути файл
@@ -291,56 +291,6 @@ 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).
+1 -5
Переглянути файл
@@ -1358,15 +1358,11 @@ 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(), tsserver_max_memory: 0 }
Self { version: "bundled".into() }
}
}
-3
Переглянути файл
@@ -266,8 +266,6 @@ 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,
@@ -289,7 +287,6 @@ 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,12 +5,8 @@
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";
@@ -60,224 +56,3 @@ 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,7 +24,6 @@ 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};
@@ -141,8 +140,6 @@ 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`].
@@ -598,7 +595,6 @@ pub(crate) fn capabilities_from(caps: &Value) -> DebugCapabilities {
jvm_instances: false,
jvm_heap: false,
js_page: false,
live_evaluate: false,
}
}
@@ -609,7 +605,6 @@ 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
}
@@ -1182,21 +1177,6 @@ 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,12 +839,6 @@ 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}"),
}
}
+3 -90
Переглянути файл
@@ -160,13 +160,8 @@ 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 {
@@ -199,7 +194,6 @@ impl Default for Script {
gdb15_starts_at_launch: false,
launch_after_configuration: false,
accept: &[],
custom: None,
}
}
}
@@ -518,16 +512,6 @@ 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"})
),
@@ -982,16 +966,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 && page.live_evaluate);
assert!(page.js_page);
assert!(!h.manager.capabilities(browser).unwrap().js_page, "the launch itself is not a page");
let node = h.start(js_config("pwa-node")).await.unwrap();
let process = h.manager.capabilities(child_of(&h, node).await).unwrap();
assert!(!process.js_page && process.live_evaluate);
assert!(!process.js_page);
let python = h.start(config()).await.unwrap();
let script = h.manager.capabilities(child_of(&h, python).await).unwrap();
assert!(!script.js_page && !script.live_evaluate);
assert!(!script.js_page);
}
#[tokio::test]
@@ -1033,77 +1017,6 @@ 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,14 +117,6 @@ 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());
@@ -579,33 +571,6 @@ 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,15 +382,6 @@ 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)
}
@@ -607,9 +598,6 @@ 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
});
+2 -28
Переглянути файл
@@ -585,21 +585,6 @@ 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(
@@ -807,19 +792,12 @@ 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 || {
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)
project.with_repo_recorded(repo.as_deref(), Some("Git stash"), |r| {
r.stash_push(message.as_deref(), include_untracked, keep_index)
})
})
.await
@@ -1484,9 +1462,6 @@ 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 });
@@ -1545,7 +1520,6 @@ 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, None).await.unwrap();
vcs::vcs_stash_push(&f.host, "main", None, None, false, false).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, None).await.unwrap();
ops::vcs::vcs_stash_push(&f.host, WINDOW, None, Some("wip".into()), false, false).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();
+2 -23
Переглянути файл
@@ -27,38 +27,17 @@ 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) {
insert_git_inputs(&mut out, &git);
out.insert(git.join("config"));
out.insert(git.join("info/exclude"));
}
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,52 +797,6 @@ 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")]);
+1 -122
Переглянути файл
@@ -218,37 +218,6 @@ 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()) {
@@ -441,10 +410,6 @@ 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,
@@ -602,12 +567,7 @@ fn merge(base: &mut Value, layer: Value) {
#[cfg(test)]
mod tests {
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};
use super::tsserver_ran_out_of_memory;
#[test]
fn tsserver_aborts_are_out_of_memory() {
@@ -619,85 +579,4 @@ 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());
}
}
+13 -47
Переглянути файл
@@ -40,37 +40,22 @@ const LOOSE: &AsciiSet = &CONTROLS
.add(b'|')
.add(b'}');
/// 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`).
/// 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.
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) => {
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),
}
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();
}
map.values_mut().for_each(encode_loose_file_uris);
}
_ => {}
}
@@ -176,28 +161,9 @@ 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!({ "uri": "file:///a%20b/%E9%A1%B9.java" });
let mut encoded = serde_json::json!("file:///a%20b/%E9%A1%B9.java");
encode_loose_file_uris(&mut encoded);
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");
assert_eq!(encoded, "file:///a%20b/%E9%A1%B9.java");
}
#[cfg(unix)]
-84
Переглянути файл
@@ -1168,22 +1168,6 @@ 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
@@ -2120,74 +2104,6 @@ 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();
+2 -15
Переглянути файл
@@ -884,12 +884,6 @@ 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(
@@ -1268,17 +1262,10 @@ 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,
paths: &[PathBuf],
) -> IdeResult<()> {
pub fn stash_push(&self, message: Option<&str>, include_untracked: bool, keep_index: bool) -> IdeResult<()> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
write::stash_push(self, message, include_untracked, keep_index, paths)
write::stash_push(self, message, include_untracked, keep_index)
}
/// `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<()> {
+15 -124
Переглянути файл
@@ -43,70 +43,6 @@ 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,
@@ -125,9 +61,8 @@ 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, &ignore);
return converted_blame(r, path, &rel, &text, revision, encoding);
}
let contents = match current_text {
Some(_) if crate::diff::normalize_eol(&text) == crate::diff::normalize_eol(&decode(&disk, encoding)) => disk,
@@ -159,32 +94,23 @@ 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 = {
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 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 (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 {
@@ -216,21 +142,16 @@ 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: 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 args: &[&str] = if crate::execution_allowed() {
&["--literal-pathspecs", "blame", "--porcelain", revision, "--", rel]
} else {
&["--literal-pathspecs", "blame", "--no-textconv", "--porcelain", revision, "--", rel]
};
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);
@@ -1128,18 +1049,8 @@ 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,
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"]);
pub(crate) fn stash_push(r: &Repo, message: Option<&str>, include_untracked: bool, keep_index: bool) -> IdeResult<()> {
let mut args = vec!["stash", "push", "-q"];
if include_untracked {
args.push("--include-untracked");
}
@@ -1149,10 +1060,6 @@ pub(crate) fn stash_push(
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(|_| ())
}
@@ -1476,19 +1383,3 @@ 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
Переглянути файл
@@ -1,131 +0,0 @@
//! 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
Переглянути файл
@@ -1,124 +0,0 @@
//! 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 › File…** creates), see **Settings › Editor › File and Code Templates**.
For new-file templates (what **File › New…** creates), see **Settings › Editor › File and Code Templates**.
## Macros
+2 -6
Переглянути файл
@@ -130,8 +130,6 @@ 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 '…'**
@@ -196,7 +194,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**; it stashes the whole working tree. **Stash Selected Files…** in the changes tree's context menu stashes only the selected files.
- **Stash Changes…**: a message, **Keep index** and **Include untracked files**. Stashes always include all changes; there is no per-file stash.
- **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.
@@ -219,8 +217,6 @@ 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.
@@ -250,5 +246,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).
- Per-line partial commits (block level only) and per-file stashes.
- 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 › File…** menu, no code needed.
- **File templates**: entries of the **File › New…** 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 › File…** menu. They need no code and grant
`[[contributes.fileTemplates]]` adds entries to the **File › New…** 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) { … }
实时模板、后缀补全设置和宏都按用户保存在本机,不保存在项目里。
新建文件用的模板(**文件 › 新建 › 文件…** 创建的内容)在 **设置 › 编辑器 › 文件和代码模板** 中设置。
新建文件用的模板(**文件 › 新建…** 创建的内容)在 **设置 › 编辑器 › 文件和代码模板** 中设置。
## 宏
+2 -6
Переглянути файл
@@ -130,8 +130,6 @@ OpenIDEA 会找到包含项目的仓库,以及向下最多四层的嵌套仓
点击标题栏的分支微件(或 **Git › 分支…**)打开分支弹窗,输入文字即可搜索分支和操作。弹窗中有 **最近**、**本地**、**远程** 和 **标签** 分组,顶部有 **更新项目…**、**提交…**、**推送…**、**新建分支…** 和 **签出标签或修订…**。齿轮按钮可以切换按前缀分组、显示最近分支和显示标签。
点击标题栏分支名旁的传入或传出标记(或分支弹窗中的 **传入和传出的提交…**)即可列出待拉取或待推送的提交(最新的在前)。点击某个提交查看其差异,或点 **在 Git 日志中打开** 在 Git 日志中显示它。
选中分支(`→` 或 `Enter`)查看它的操作:
- **签出**、**从 '…' 新建分支…**、**签出并变基到 '…'**
@@ -196,7 +194,7 @@ Git 工具窗口的 **日志** 标签页显示提交图。
**贮藏** 使用 `git stash`:
- **贮藏更改…**:填写消息,可选 **保留索引** 和 **包括未跟踪的文件**,贮藏整个工作区;更改树右键菜单中的 **贮藏所选文件…** 只贮藏选中的文件。
- **贮藏更改…**:填写消息,可选 **保留索引** 和 **包括未跟踪的文件**。贮藏总是包含全部更改,不能只贮藏部分文件。
- **取消贮藏更改…**:应用或弹出某个贮藏,恢复索引,或基于它新建分支。
- 提交工具窗口的 **贮藏** 标签页列出所有贮藏,可以应用、弹出和删除。
@@ -219,8 +217,6 @@ Git 工具窗口的 **日志** 标签页显示提交图。
- **显示所选内容的历史记录** 列出修改过所选行的提交。
- **显示当前修订版** 在日志中定位光标所在行的提交。
注解会跳过 `blame.ignoreRevsFile` 指向的文件(约定是仓库根目录下的 `.git-blame-ignore-revs`)中列出的提交,因此一次格式化或批量重命名的提交不会认领所有行。在注解行的右键菜单中选择 **忽略此提交**,会把当前行的提交加入该文件并刷新注解。
### 本地历史记录
本地历史记录独立于 Git 保存文件快照,包括已删除的文件,以及重构前自动记录的状态等带标签的快照。
@@ -250,5 +246,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
Переглянути файл
@@ -1,133 +0,0 @@
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(60_000);
await page.clock.runFor(30_000);
await expect(page.getByRole("textbox", { name: "Startup error details" })).toHaveValue(
/Startup did not finish within 60 seconds/,
/Startup did not finish within 30 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(50_000);
await page.clock.runFor(35_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.45",
"version": "0.1.44",
"license": "Apache-2.0",
"type": "module",
"scripts": {
+3 -3
Переглянути файл
@@ -11,7 +11,7 @@
copied: "已复制",
manual: "无法复制。请选择下方详情并手动复制。",
details: "启动错误详情",
timeout: "启动未在 60 秒内完成。",
timeout: "启动未在 30 秒内完成。",
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 60 seconds.",
timeout: "Startup did not finish within 30 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 = 60_000;
let remaining = 30_000;
let armedAt = 0;
let timer;
const arm = () => {
+1 -1
Переглянути файл
@@ -1,4 +1,4 @@
package openidea:plugin@1.8.0;
package openidea:plugin@1.7.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.8.0;
package openidea:plugin@1.7.0;
/// Plugin-owned project data sources. Requires database.sources and project trust.
/// Runs on the Database subsystem's host, including on remote project servers.
+4 -10
Переглянути файл
@@ -1,10 +1,9 @@
package openidea:plugin@1.8.0;
package openidea:plugin@1.7.0;
/// 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.
/// 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.
/// Styles: highlight, search, added, modified, removed, warning, error, info,
/// typo, weak, underline, strikethrough, muted. Unknown styles are ignored. Theme tokens only.
/// 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.
@@ -23,9 +22,4 @@ 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.8.0;
package openidea:plugin@1.7.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.8.0;
package openidea:plugin@1.7.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.8.0;
package openidea:plugin@1.7.0;
/// HTTP endpoints found in a project (`[[contributes.endpoints]]`).
interface endpoints-provider {
+1 -1
Переглянути файл
@@ -1,4 +1,4 @@
package openidea:plugin@1.8.0;
package openidea:plugin@1.7.0;
/// Workbench events, delivered in batches to `event-handler.handle`.
interface events {
+1 -1
Переглянути файл
@@ -1,4 +1,4 @@
package openidea:plugin@1.8.0;
package openidea:plugin@1.7.0;
interface lifecycle {
use types.{context, error};
+1 -1
Переглянути файл
@@ -1,4 +1,4 @@
package openidea:plugin@1.8.0;
package openidea:plugin@1.7.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.8.0;
package openidea:plugin@1.7.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.8.0;
package openidea:plugin@1.7.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.8.0;
package openidea:plugin@1.7.0;
/// Writes to the IDE log, tagged with the plugin id.
interface log {
+1 -1
Переглянути файл
@@ -1,4 +1,4 @@
package openidea:plugin@1.8.0;
package openidea:plugin@1.7.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.8.0;
package openidea:plugin@1.7.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.8.0;
package openidea:plugin@1.7.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.8.0;
package openidea:plugin@1.7.0;
/// Optional export (world `plugin-inlay-hints`): inline hints in documents of
/// the languages declared with the `inlay-hints` provider kind, including
+3 -6
Переглянути файл
@@ -1,14 +1,11 @@
package openidea:plugin@1.8.0;
package openidea:plugin@1.7.0;
/// Problems in files, shown in the editor and the Problems view.
interface diagnostics {
use types.{error, range, location};
/// 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.
/// Problems reported by a source for a file in this window's project.
record file-problems { path: string, source: string, diagnostics: list<diagnostic> }
/// 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.
/// Current Problems view snapshot, optionally filtered to a project 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.8.0;
package openidea:plugin@1.7.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.8.0;
package openidea:plugin@1.7.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.8.0;
package openidea:plugin@1.7.0;
world plugin {
import log;
+1 -1
Переглянути файл
@@ -1,4 +1,4 @@
package openidea:plugin@1.8.0;
package openidea:plugin@1.7.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.8.0;
package openidea:plugin@1.7.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.8.0;
package openidea:plugin@1.7.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.8.0;
package openidea:plugin@1.7.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.8.0;
package openidea:plugin@1.7.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.8.0;
package openidea:plugin@1.7.0;
/// Declarative views: tool windows declared
/// under `[[contributes.views]]` and custom editors under
+2 -4
Переглянути файл
@@ -1,4 +1,4 @@
package openidea:plugin@1.8.0;
package openidea:plugin@1.7.0;
interface workspace {
use types.{error, location};
@@ -135,9 +135,7 @@ 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`. 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.
/// `editor.write`. 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,17 +244,6 @@ 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]
+1 -28
Переглянути файл
@@ -65,9 +65,8 @@ 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) = port_to_wait_for(held, wanted.port) {
if let Some(port) = wanted.port {
wait_until_free(address, port);
}
}
@@ -105,14 +104,6 @@ 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 {
@@ -252,21 +243,3 @@ 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,7 +325,6 @@ 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,
@@ -528,7 +527,6 @@ 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,
+1 -14
Переглянути файл
@@ -239,17 +239,6 @@ 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<()> {
@@ -439,10 +428,8 @@ 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, paths)
.await
ide_host::ops::vcs::vcs_stash_push(&state.host, window.label(), repo, message, include_untracked, keep_index).await
}
#[tauri::command]
+8 -67
Переглянути файл
@@ -217,18 +217,14 @@ pub fn spec(os: Os) -> Vec<Entry> {
}
let mut file = vec![
sub(
"new",
"New",
vec![
act("file.newProject", "Project…"),
act("workspace.create", "Workspace…"),
SEP,
act("file.new", "File…"),
act("file.newScratchFile", "Scratch File"),
],
),
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…"),
act("file.openProject", "Open…"),
act("project.attach", "Attach Project…"),
act("file.openFile", "Open File…"),
act("file.openInNewWindow", "Open in New Window…"),
sub(
@@ -236,11 +232,6 @@ 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,
@@ -1621,7 +1612,7 @@ pub fn install<R: Runtime>(app: &AppHandle<R>, menu_state: &MenuState, recent: &
#[cfg(test)]
mod tests {
use super::*;
use std::collections::{BTreeSet, HashSet};
use std::collections::HashSet;
const ALL_OS: [Os; 3] = [Os::Mac, Os::Windows, Os::Linux];
@@ -1981,56 +1972,6 @@ 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.45",
"version": "0.1.44",
"identifier": "ai.l0ng.openidea",
"build": {
"beforeDevCommand": "pnpm dev",
+2 -62
Переглянути файл
@@ -657,7 +657,6 @@ 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 })),
@@ -691,7 +690,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, paths: string[] | null) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_stash_push", { repo, message, includeUntracked, keepIndex, paths })),
vcsStashPush: (repo: string | null, message: string | null, includeUntracked: boolean, keepIndex: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_stash_push", { repo, message, includeUntracked, keepIndex })),
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. */
@@ -1216,8 +1215,6 @@ 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`. */
@@ -3967,8 +3964,6 @@ 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`). */
@@ -4004,11 +3999,7 @@ 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 } |
/** 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" };
{ kind: "invalidated"; areas: string[] } | { kind: "jvmBackend"; state: JvmBackendState };
/** A debug adapter session. */
export type DebugSessionId = number;
@@ -4024,8 +4015,6 @@ 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,
@@ -6429,49 +6418,6 @@ 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. */
@@ -10351,12 +10297,6 @@ 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. */
+6 -23
Переглянути файл
@@ -1335,10 +1335,7 @@ 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,
confirmed: Set<string> = new Set(),
): Promise<boolean> {
private async confirmBusyClose(removal: (s: TabsState) => TabsState): Promise<boolean> {
const kinds = useContributions.getState().editorKinds;
const current = useTabs.getState();
const remaining = openKeys(removal(current));
@@ -1346,19 +1343,15 @@ 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);
// 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);
const busy: string[] = [];
for (const input of closing.values()) if (await inputBusy(input, kinds)) busy.push(tabTitle(input, kinds));
if (!busy.length) return true;
const name = busy.map((input) => tabTitle(input, kinds)).join(", ");
const ok = await confirm({
const name = busy.join(", ");
return 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. */
@@ -1395,8 +1388,6 @@ 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(
@@ -1406,13 +1397,6 @@ 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.
@@ -1443,8 +1427,7 @@ export class EditorController implements SlotHost {
}
authorized.add(d.input.key);
}
// Again after the discards: a tab may have become busy while they ran.
if (!(await this.confirmBusyClose(currentRemoval, confirmedBusy))) return false;
if (!(await this.confirmBusyClose(currentRemoval))) 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.
+1 -53
Переглянути файл
@@ -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, confirm } from "@/workbench/messages";
import { ask } from "@/workbench/messages";
import type * as Messages from "@/workbench/messages";
import * as virtualDocs from "@/platform/virtualDocs";
import type { VirtualDocument } from "@/platform/ipc";
@@ -18,7 +18,6 @@ import {
vi.mock("@/workbench/messages", async (original) => ({
...(await original<typeof Messages>()),
ask: vi.fn(),
confirm: vi.fn(),
}));
class TestDocument implements EditorDocumentLifecycle {
@@ -249,57 +248,6 @@ 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,10 +438,7 @@
.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 {
@@ -461,7 +458,6 @@
color: var(--fg-strong);
}
.attachmentName {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
-13
Переглянути файл
@@ -88,16 +88,3 @@
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;
}
+20 -23
Переглянути файл
@@ -221,29 +221,26 @@ export function HomeEditor({ docked = false }: { docked?: boolean; input?: Edito
/>
}
/>
{/* 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>
{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>
);
-21
Переглянути файл
@@ -43,27 +43,6 @@
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);
+2 -16
Переглянути файл
@@ -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; `onRemove` adds its × button. */
export function chipElement(path: string, mention: Mention, onRemove?: () => void): HTMLElement {
/** The chip as DOM, for the composer's editor. */
export function chipElement(path: string, mention: Mention): HTMLElement {
const chip = document.createElement("span");
chip.className = styles.chip!;
chip.dataset.refPath = path;
@@ -74,20 +74,6 @@ export function chipElement(path: string, mention: Mention, onRemove?: () => voi
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!);
+7 -12
Переглянути файл
@@ -41,11 +41,9 @@
/* 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: 2px 6px;
gap: 6px;
min-width: 0;
padding: 0 2px;
overflow: hidden;
@@ -56,7 +54,6 @@
.agent {
display: inline-flex;
flex: 0 1 auto;
max-width: 100%;
align-items: center;
gap: 4px;
height: 22px;
@@ -73,7 +70,6 @@
.where {
display: inline-flex;
flex: 0 1 auto;
max-width: 100%;
align-items: center;
/* The chips' own padding spaces them. */
gap: 2px;
@@ -91,21 +87,20 @@
height: 22px;
white-space: nowrap;
}
/* 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. */
/* The changes sit above the meter, time and terminal; those keep their one row at the right. */
.statusEnd {
display: inline-flex;
flex: 1 1 auto;
flex-wrap: wrap;
align-items: center;
gap: 2px 8px;
flex: 0 1 auto;
flex-direction: column;
align-items: flex-end;
gap: 2px;
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 before the meter, time and terminal, or above them when the row is full. */}
{/* What changed sits above the meter, time and terminal; those keep their one row at the right. */}
<span className={styles.statusEnd}>
{(!!changes?.length || !!commits.length) && (
<span className={styles.statusChips}>
+2 -10
Переглянути файл
@@ -79,16 +79,8 @@ class ChipWidget extends WidgetType {
eq(other: ChipWidget) {
return other.path === this.path && other.text === this.text;
}
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();
});
toDOM() {
const chip = chipElement(this.path, this.mention);
// Keep the editor's focus and caret; a click opens the file.
chip.addEventListener("mousedown", (e) => e.preventDefault());
chip.addEventListener("click", (e) => {
-398
Переглянути файл
@@ -1,398 +0,0 @@
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"]);
});
});
+2 -13
Переглянути файл
@@ -268,15 +268,6 @@ 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();
@@ -796,7 +787,6 @@ 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,
@@ -831,7 +821,7 @@ export async function startTask(
}));
updateDraft({ projectRoot });
remember(composer.text.trim());
clearSubmittedComposer(HOME, composer, started);
updateComposer(HOME, emptyDraft());
if ((await editorService().closeInput?.(HOME_KEY)) !== false) openTask(task.id);
}
@@ -937,9 +927,8 @@ 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));
clearSubmittedComposer(task, composer, started);
updateComposer(task, emptyDraft());
}
/** Moves the caret to the end of composer `key`. */
+6
Переглянути файл
@@ -246,6 +246,12 @@
margin: 0 4px;
background: var(--border);
}
.treeFilter {
flex: 1;
min-width: 80px;
max-width: 280px;
margin-left: 4px;
}
.treeHost {
flex: 1;
min-height: 0;
+9 -1
Переглянути файл
@@ -41,6 +41,7 @@ beforeEach(() => {
sequences: [],
},
},
treeFilter: "",
separateProceduresAndFunctions: false,
treeExpanded: new Set(["pg", "pg/public", "pg/public/routines"]),
});
@@ -59,6 +60,9 @@ 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 () => {
@@ -87,7 +91,7 @@ describe("routine grouping", () => {
});
});
it.each([false, true])("applies the object filter before grouping (separate: %s)", (separate) => {
it.each([false, true])("applies object and quick filters before grouping (separate: %s)", (separate) => {
const source = useDatabase.getState().sources[0]!;
useDatabase.setState({
separateProceduresAndFunctions: separate,
@@ -101,6 +105,10 @@ 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();
});
});
+49 -9
Переглянути файл
@@ -12,17 +12,25 @@ import { shortcutLabel } from "@/platform/keymap";
import {
Button,
EmptyState,
HighlightedText,
Icon,
IconButton,
Menu,
SearchField,
Tree,
showContextMenu,
tooltipAttrs,
type TreeHandle,
T,
} from "@/ui";
import { databaseKey, passesObjectFilter, shownNames, sourceIcon, splitTarget, targetOf } from "./model";
import {
databaseKey,
passesObjectFilter,
passesTreeFilter,
shownNames,
sourceIcon,
splitTarget,
targetOf,
} from "./model";
import {
nodeTarget,
isOtherFolder,
@@ -160,10 +168,25 @@ export function DatabaseView() {
[st.sources],
);
/** Schemas tab › Object filter of the data source of target `id`. */
// 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`. */
const shown = (id: string) => {
const filter = sourceOf(id)?.introspection?.objectFilter;
return (o: { name: string }) => passesObjectFilter(o.name, filter);
return (o: { name: string }) => passesObjectFilter(o.name, filter) && passesTreeFilter(o.name, st.treeFilter);
};
const tablesOf = (id: string, schema: string) => {
const t = st.tables[tablesKey(id, schema)];
@@ -177,7 +200,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 filtering. */
/** Members of a schema folder, after object and quick 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")
@@ -204,9 +227,19 @@ 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).map((schema) => ({ kind: "schema", key: `${id}/${schema}`, id, schema }) as Node),
shownSchemas(id, v)
.filter((schema) => schemaVisible(id, schema))
.map((schema) => ({ kind: "schema", key: `${id}/${schema}`, id, schema }) as Node),
);
const children = (n: Node): readonly Node[] | undefined => {
@@ -530,8 +563,7 @@ export function DatabaseView() {
const color = target ? sourceOf(target)?.color : null;
return color ? `${styles.tintedRow} ${ROW_TINTS[color]}` : undefined;
}}
speedSearchField
renderNode={(n: Node, state) => {
renderNode={(n: Node) => {
const i = icon(n);
const d = detail(n);
const counted = n.kind === "source" || n.kind === "database" || n.kind === "folder" || n.kind === "group";
@@ -554,7 +586,7 @@ export function DatabaseView() {
{live && <span className={styles.connectedDot} title={tx("Connected")} />}
</span>
)}
<HighlightedText className={labelClass} text={label(n)} indices={state.matches} />
<span className={labelClass}>{label(n)}</span>
{n.kind === "source" && n.source.global && (
<span className={styles.treeDetail} title={tx("Available in all projects")}>
{tx("global")}
@@ -611,6 +643,7 @@ 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 });
@@ -656,6 +689,13 @@ 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>
);
}
+24 -47
Переглянути файл
@@ -4,16 +4,7 @@
* 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,
useSyncExternalStore,
type MouseEvent,
} from "react";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type MouseEvent } from "react";
import clsx from "clsx";
import { call, commands, type DbPlanNode, type DbQueryResult } from "@/platform/ipc";
import { shortcutLabel } from "@/platform/keymap";
@@ -207,32 +198,8 @@ function Pager({ tab }: { tab: ResultTab }) {
);
}
/**
* 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.
*/
/** Unsubmitted edits by the rows they were made on, kept while another result tab is shown. */
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");
@@ -308,12 +275,20 @@ 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.
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));
};
// 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 };
});
const loaded = r?.rows.length ?? 0;
const rowCount = loaded + edits.inserts.length;
const value = useCallback(
@@ -366,12 +341,14 @@ function Result({ tab }: { tab: ResultTab }) {
});
const dirty = hasEdits(edits);
const submitting = useSyncExternalStore(subscribeResults, () => !!r && submittingResults.has(r));
const [submitting, setSubmitting] = useState(false);
const submitGuard = useRef(false);
const busy = submitting || !!tab.busy;
const submit = async () => {
if (!r || !target || !pk || !editable || busy || submittingResults.has(r)) return;
setSubmittingResult(r, true);
if (!r || !target || !pk || !editable || busy || submitGuard.current) return;
submitGuard.current = true;
setSubmitting(true);
const list = buildRowEdits(resultNames, r.rows, pk, edits);
try {
// The Output log shows what the submit is about to change.
@@ -381,8 +358,7 @@ 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" });
// Also when this pane was unmounted meanwhile: the applied edits must not come back.
setKeptEdits(r, NO_EDITS);
setEdits(() => emptyEdits());
await rerunResult(tab.id);
} catch (e) {
log("error", e instanceof Error ? e.message : String(e));
@@ -393,7 +369,8 @@ function Result({ tab }: { tab: ResultTab }) {
key: "db-submit",
});
} finally {
setSubmittingResult(r, false);
submitGuard.current = false;
setSubmitting(false);
}
};
+4 -13
Переглянути файл
@@ -3,18 +3,10 @@ import { describe, expect, it, vi } from "vitest";
import { SourceDialog } from "./SourceDialog";
const ipc = vi.hoisted(() => ({ call: vi.fn() }));
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") },
};
});
vi.mock("@/platform/ipc", async (importOriginal) => ({
...(await importOriginal<typeof import("@/platform/ipc")>()),
call: ipc.call,
}));
const savePassword = () => screen.getByRole("checkbox", { name: "Save password" }) as HTMLInputElement;
@@ -71,7 +63,6 @@ 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,6 +12,7 @@ import {
idleTargets,
matchesPattern,
parseSourceUrl,
passesTreeFilter,
schemaSwitchable,
shownNames,
sourceLabel,
@@ -391,6 +392,16 @@ 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,6 +205,15 @@ 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,66 +231,6 @@ 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,6 +164,8 @@ 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[];
@@ -231,6 +233,7 @@ export const useDatabase = create<DatabaseState>(() => ({
objects: {},
consoles: readBindings(),
consoleSchemas: readConsoleSchemas(),
treeFilter: "",
separateProceduresAndFunctions: readSeparateProceduresAndFunctions(),
completion: {},
results: [],
+1 -5
Переглянути файл
@@ -13,11 +13,7 @@ describe("JavaScript events and requests section", () => {
expect(screen.getByRole("alert")).toHaveTextContent(
"The debugger of the running page does not take these breakpoints: unknown request",
);
// Another tool's refusal is not about breakpoints.
act(() => {
setRefused(4, "eventBreakpoints", null);
setRefused(4, "network", "");
});
act(() => setRefused(4, "eventBreakpoints", null));
expect(screen.queryByRole("alert")).toBeNull();
});
});
-200
Переглянути файл
@@ -1,200 +0,0 @@
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,7 +107,6 @@ const caps = (p: Partial<DebugCapabilities> = {}): DebugCapabilities => ({
jvmInstances: false,
jvmHeap: false,
jsPage: false,
liveEvaluate: false,
...p,
});
+3 -26
Переглянути файл
@@ -66,24 +66,6 @@ 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"));
@@ -94,18 +76,13 @@ 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);
const sentTo = () => ipc.debugEvaluateIn.mock.calls.map(([, , expression]) => run(String(expression)));
expect(sentTo()).toEqual(["http://localhost:5173/cart"]);
expect(ipc.debugEvaluateIn).toHaveBeenCalledWith(2, null, 'location.assign("http://localhost:5173/cart")', "repl");
expect(useDebug.getState().active).toBe(2);
// Back to the address it was started on: the page is at /cart now, so it goes back.
// The address it was started on: the page is where it is.
ipc.debugEvaluateIn.mockClear();
await startBrowserSession(browserLaunch("a", "http://localhost:5173/", "chrome", "/p"));
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]);
expect(ipc.debugEvaluateIn).not.toHaveBeenCalled();
});
it("is another one for another browser, another project, or after the first has ended", async () => {
-151
Переглянути файл
@@ -1,151 +0,0 @@
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
Переглянути файл
@@ -1,159 +0,0 @@
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
Переглянути файл
@@ -1,52 +0,0 @@
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