дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-14 13:45:49 +02:00
Порівняти коміти
1
Коміти
| Автор | SHA1 | Дата | |
|---|---|---|---|
|
|
9497693454 |
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"
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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, "");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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")]);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<()> {
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 没有透视图。按自己的习惯摆放工具窗口,再用 **窗口 › 工具窗口布局 › 将当前布局另存为…** 保存。
|
||||
|
||||
## 概念对照
|
||||
|
||||
@@ -13,7 +13,7 @@ description: "在项目之外保留临时文件,并在 TODO 工具窗口中汇
|
||||
|
||||
<Steps>
|
||||
<Step title="新建">
|
||||
选择 **文件 › 新建 › 临时文件**(或项目视图 **新建** 菜单最上面的 **临时文件**)。
|
||||
选择 **文件 › 新建临时文件…**(或项目视图 **新建** 菜单最上面的 **临时文件**)。
|
||||
</Step>
|
||||
<Step title="选择语言">
|
||||
输入文字过滤语言列表,按 `↵` / `Enter` 确认。
|
||||
|
||||
@@ -67,7 +67,7 @@ items.for → for (var item : items) { … }
|
||||
|
||||
实时模板、后缀补全设置和宏都按用户保存在本机,不保存在项目里。
|
||||
|
||||
新建文件用的模板(**文件 › 新建 › 文件…** 创建的内容)在 **设置 › 编辑器 › 文件和代码模板** 中设置。
|
||||
新建文件用的模板(**文件 › 新建…** 创建的内容)在 **设置 › 编辑器 › 文件和代码模板** 中设置。
|
||||
|
||||
## 宏
|
||||
|
||||
|
||||
@@ -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 支持。
|
||||
@@ -8,7 +8,7 @@ description: "创建、重命名或删除文件的快速修复,读写项目文
|
||||
- **文件操作**(`file-actions.wit`,API 1.3):创建、重命名或删除文件的快速修复和重构,作为一次可撤销的工作区编辑应用。
|
||||
- **项目文件**(`workspace.wit`):读取、写入、查找和搜索打开项目中的文件。
|
||||
- **其他文件**(`fs.wit`,API 1.3):在明确授权下访问项目之外的文件,以及插件自己的私有目录。
|
||||
- **文件模板**:**文件 › 新建 › 文件…** 菜单中的条目,不需要代码。
|
||||
- **文件模板**:**文件 › 新建…** 菜单中的条目,不需要代码。
|
||||
|
||||
## 文件操作
|
||||
|
||||
@@ -162,7 +162,7 @@ fs::write_file("~/.config/mytool/state.json", "{}")?;
|
||||
|
||||
## 文件模板
|
||||
|
||||
`[[contributes.fileTemplates]]` 在 **文件 › 新建 › 文件…** 菜单中添加条目。不需要代码,也不授予任何文件访问权限:
|
||||
`[[contributes.fileTemplates]]` 在 **文件 › 新建…** 菜单中添加条目。不需要代码,也不授予任何文件访问权限:
|
||||
只有用户调用模板时,才通过 IDE 正常的新建文件流程创建文件。
|
||||
|
||||
```toml
|
||||
|
||||
@@ -1,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,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,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,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.
|
||||
|
||||
@@ -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,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,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,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,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,4 +1,4 @@
|
||||
package openidea:plugin@1.8.0;
|
||||
package openidea:plugin@1.7.0;
|
||||
|
||||
interface lifecycle {
|
||||
use types.{context, error};
|
||||
|
||||
@@ -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,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,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,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,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,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,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,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
|
||||
|
||||
@@ -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,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,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 {
|
||||
|
||||
@@ -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,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,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,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,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,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,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
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,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. */
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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!);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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`. */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -107,7 +107,6 @@ const caps = (p: Partial<DebugCapabilities> = {}): DebugCapabilities => ({
|
||||
jvmInstances: false,
|
||||
jvmHeap: false,
|
||||
jsPage: false,
|
||||
liveEvaluate: false,
|
||||
...p,
|
||||
});
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
Reference in new issue
Заблокувати користувача