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

..
Автор SHA1 Повідомлення Дата
l0ng-ai 9497693454 fix(remote): trust SSH host keys and passwords on the host that connects
In a remote window (SSH, WSL, dev container) deployments, data source tests and run targets
connect from the server, but "Trust Host Key and Retry", a password entered for an SSH
configuration and the SSH settings page's checks went to the desktop's SSH manager. The server
then still didn't know the key ("no host key to trust" on the desktop), so a configuration
that worked in a local window could never connect from the remote one. The SSH configuration
commands are now served by the remote host too, like db_trust_ssh_host already was.

Fixes #3313
2026-11-14 16:09:24 +08:00
237 змінених файлів з 549 додано та 8512 видалено

No files matched your search

Створено
+44 -44
Переглянути файл
@@ -4144,7 +4144,7 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "ide-agent"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"dunce",
"encoding_rs",
@@ -4168,7 +4168,7 @@ dependencies = [
[[package]]
name = "ide-analysis"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"ast-grep-core",
"ide-core",
@@ -4182,7 +4182,7 @@ dependencies = [
[[package]]
name = "ide-android"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"ide-core",
"serde",
@@ -4193,7 +4193,7 @@ dependencies = [
[[package]]
name = "ide-build"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"blake3",
"chardetng",
@@ -4211,7 +4211,7 @@ dependencies = [
[[package]]
name = "ide-cli"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"dirs 6.0.0",
"serde",
@@ -4225,7 +4225,7 @@ dependencies = [
[[package]]
name = "ide-clipboard"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"gtk",
"objc2 0.6.5",
@@ -4235,7 +4235,7 @@ dependencies = [
[[package]]
name = "ide-core"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"dunce",
"encoding_rs",
@@ -4252,7 +4252,7 @@ dependencies = [
[[package]]
name = "ide-coverage"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"ide-core",
"ide-run",
@@ -4266,7 +4266,7 @@ dependencies = [
[[package]]
name = "ide-dap"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"futures",
"ide-core",
@@ -4293,7 +4293,7 @@ dependencies = [
[[package]]
name = "ide-db"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"base64 0.22.1",
"bb8",
@@ -4329,7 +4329,7 @@ dependencies = [
[[package]]
name = "ide-docker"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"ide-core",
"serde",
@@ -4342,7 +4342,7 @@ dependencies = [
[[package]]
name = "ide-extension"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"base64 0.22.1",
"blake3",
@@ -4363,7 +4363,7 @@ dependencies = [
[[package]]
name = "ide-fonts"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"objc2-core-foundation",
"objc2-core-text",
@@ -4375,7 +4375,7 @@ dependencies = [
[[package]]
name = "ide-github"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"ide-core",
"ide-vcs",
@@ -4388,7 +4388,7 @@ dependencies = [
[[package]]
name = "ide-heap"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"criterion",
"ide-core",
@@ -4406,7 +4406,7 @@ dependencies = [
[[package]]
name = "ide-host"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"base64 0.22.1",
"blake3",
@@ -4477,7 +4477,7 @@ dependencies = [
[[package]]
name = "ide-http"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"ide-core",
"ide-vfs",
@@ -4496,7 +4496,7 @@ dependencies = [
[[package]]
name = "ide-index"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"blake3",
"criterion",
@@ -4518,7 +4518,7 @@ dependencies = [
[[package]]
name = "ide-jdwp"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"ide-jvm-inspect",
"proptest",
@@ -4530,7 +4530,7 @@ dependencies = [
[[package]]
name = "ide-jsonrpc"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"encoding_rs",
"futures",
@@ -4547,7 +4547,7 @@ dependencies = [
[[package]]
name = "ide-jvm-attach"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"libc",
"tempfile",
@@ -4556,7 +4556,7 @@ dependencies = [
[[package]]
name = "ide-jvm-inspect"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"futures",
"serde",
@@ -4567,7 +4567,7 @@ dependencies = [
[[package]]
name = "ide-language"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"blake3",
"criterion",
@@ -4624,7 +4624,7 @@ dependencies = [
[[package]]
name = "ide-lsp"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"dunce",
"encoding_rs",
@@ -4652,7 +4652,7 @@ dependencies = [
[[package]]
name = "ide-mcp"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"bytes",
"dunce",
@@ -4671,7 +4671,7 @@ dependencies = [
[[package]]
name = "ide-notebook"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"futures",
"ide-core",
@@ -4694,7 +4694,7 @@ dependencies = [
[[package]]
name = "ide-plugin"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"blake3",
"dunce",
@@ -4717,7 +4717,7 @@ dependencies = [
[[package]]
name = "ide-profiler"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"ide-core",
"ide-jvm-attach",
@@ -4731,7 +4731,7 @@ dependencies = [
[[package]]
name = "ide-remote"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"dunce",
"futures",
@@ -4751,7 +4751,7 @@ dependencies = [
[[package]]
name = "ide-run"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"dunce",
"encoding_rs",
@@ -4775,7 +4775,7 @@ dependencies = [
[[package]]
name = "ide-scaffold"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"ide-core",
"ide-tools",
@@ -4791,7 +4791,7 @@ dependencies = [
[[package]]
name = "ide-sdk"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"dunce",
"ide-core",
@@ -4812,7 +4812,7 @@ dependencies = [
[[package]]
name = "ide-search"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"criterion",
"encoding_rs",
@@ -4833,7 +4833,7 @@ dependencies = [
[[package]]
name = "ide-spell"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"ide-core",
"parking_lot",
@@ -4846,7 +4846,7 @@ dependencies = [
[[package]]
name = "ide-ssh"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"dirs 6.0.0",
"flate2",
@@ -4871,7 +4871,7 @@ dependencies = [
[[package]]
name = "ide-syntax"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"ide-core",
"ide-language",
@@ -4886,7 +4886,7 @@ dependencies = [
[[package]]
name = "ide-terminal"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"ide-core",
"libc",
@@ -4899,7 +4899,7 @@ dependencies = [
[[package]]
name = "ide-text"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"criterion",
"ide-core",
@@ -4912,7 +4912,7 @@ dependencies = [
[[package]]
name = "ide-tools"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"flate2",
"hex",
@@ -4933,7 +4933,7 @@ dependencies = [
[[package]]
name = "ide-vcs"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"blake3",
"dunce",
@@ -4954,7 +4954,7 @@ dependencies = [
[[package]]
name = "ide-vfs"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"chardetng",
"dunce",
@@ -4978,7 +4978,7 @@ dependencies = [
[[package]]
name = "ide-web"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"dirs 6.0.0",
"ide-core",
@@ -4994,7 +4994,7 @@ dependencies = [
[[package]]
name = "ide-workspace"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"blake3",
"dunce",
@@ -6595,7 +6595,7 @@ dependencies = [
[[package]]
name = "openidea"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"base64 0.22.1",
"blake3",
+1 -1
Переглянути файл
@@ -5,7 +5,7 @@ members = ["crates/*", "src-tauri"]
exclude = ["sdk", "plugins"]
[workspace.package]
version = "0.1.45"
version = "0.1.44"
edition = "2024"
rust-version = "1.96"
license = "Apache-2.0"
-50
Переглянути файл
@@ -291,56 +291,6 @@ pub enum DebugEvent {
JvmBackend {
state: JvmBackendState,
},
/// A step in the life of one request of a debugged page.
JsNetwork {
event: JsNetworkEvent,
},
/// The adapter does not report the requests of this page: there will be no `JsNetwork`.
JsNetworkUnavailable,
}
/// Which step of a request a [`JsNetworkEvent`] reports.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum JsNetworkKind {
Request,
Response,
Finished,
Failed,
}
/// The script frame that made a request, where the browser ran it (0-based line and column).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct JsInitiator {
pub function: String,
pub url: String,
pub line: u32,
pub column: u32,
}
/// What a debugged page's browser reports about a request, reduced to what the Network view
/// shows. Events of one request share `request_id`; each kind fills its own fields.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct JsNetworkEvent {
pub kind: JsNetworkKind,
pub request_id: String,
/// The browser's monotonic clock, comparable between the events of a page.
pub timestamp_ms: u64,
/// Request and response.
pub url: Option<String>,
pub method: Option<String>,
/// `Fetch`, `XHR`, `Script`, `Document`, …
pub resource_type: Option<String>,
pub status: Option<u16>,
pub mime_type: Option<String>,
/// The request's headers, or the response's.
pub headers: BTreeMap<String, String>,
/// Bytes received over the network, once finished.
pub encoded_length: Option<u64>,
pub error_text: Option<String>,
pub initiator: Option<JsInitiator>,
}
/// The backend that reads a debugged JVM's objects (instances, fields, referrers).
+1 -5
Переглянути файл
@@ -1358,15 +1358,11 @@ pub struct TypeScriptSettings {
/// `bundled` (the server's own TypeScript), `workspace` (`node_modules/typescript`),
/// or a path to a TypeScript `lib` directory.
pub version: String,
/// tsserver heap limit in MB. `0` = automatic: 8192 when vtsls hosts the Vue
/// plugin (hybrid mode; a Vue project's program alone needs ~3 GiB, and the
/// vtsls default 3072 dies in a GC death spiral), else unset (vtsls default).
pub tsserver_max_memory: u32,
}
impl Default for TypeScriptSettings {
fn default() -> Self {
Self { version: "bundled".into(), tsserver_max_memory: 0 }
Self { version: "bundled".into() }
}
}
-3
Переглянути файл
@@ -266,8 +266,6 @@ pub struct DebuggerSettings {
pub inline_values: bool,
/// Data Views: list variables by name instead of the adapter's order.
pub sort_values: bool,
/// Data Views: evaluate watches while the program runs, where the debugger can (JavaScript).
pub live_watches: bool,
pub value_tooltip: bool,
/// While suspended, selecting an expression shows its value.
pub value_tooltip_on_selection: bool,
@@ -289,7 +287,6 @@ impl Default for DebuggerSettings {
run_to_cursor_inlay: true,
inline_values: true,
sort_values: false,
live_watches: false,
value_tooltip: true,
value_tooltip_on_selection: true,
value_tooltip_delay: 700,
-225
Переглянути файл
@@ -5,12 +5,8 @@
use crate::session::Session;
use crate::{DebugManager, DebugSessionId, REQUEST_TIMEOUT};
use ide_core::event::{JsInitiator, JsNetworkEvent, JsNetworkKind};
use ide_core::{IdeError, IdeResult};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use specta::Type;
use std::collections::BTreeMap;
pub(crate) const ADAPTER: &str = "js-debug";
@@ -60,224 +56,3 @@ impl DebugManager {
Ok(())
}
}
/// The browser events js-debug is asked to pass on (`enableNetworking`'s `mirrorEvents`).
pub(crate) const NETWORK_EVENTS: [&str; 4] =
["requestWillBeSent", "responseReceived", "loadingFinished", "loadingFailed"];
/// Response bodies shown are cut at this many bytes.
pub const RESPONSE_BODY_LIMIT: usize = 1_048_576;
/// The body of a response, as text or as the browser's base64 for binary content.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct JsResponseBody {
pub body: String,
pub base64_encoded: bool,
/// The body was longer than [`RESPONSE_BODY_LIMIT`] and is cut there.
pub truncated: bool,
}
fn text(v: &Value, key: &str) -> Option<String> {
v.get(key).and_then(Value::as_str).map(str::to_owned)
}
fn headers(v: &Value) -> BTreeMap<String, String> {
v.get("headers")
.and_then(Value::as_object)
.map(|h| h.iter().filter_map(|(k, v)| Some((k.clone(), v.as_str()?.to_owned()))).collect())
.unwrap_or_default()
}
/// The frame a request is attributed to: the innermost one outside `node_modules`, looked
/// for through the stacks that scheduled this one (an `await`, a timer), since a request sent
/// through a library has the library innermost. A stack of library frames only gives its first.
fn initiator(data: &Value) -> Option<JsInitiator> {
let stack = data.pointer("/initiator/stack")?;
let frames = |stack: &Value| stack.get("callFrames").and_then(Value::as_array).cloned().unwrap_or_default();
let stacks = std::iter::successors(Some(stack), |stack| stack.get("parent")).take(32);
let all: Vec<Value> = stacks.flat_map(frames).collect();
let own = |frame: &&Value| {
frame.get("url").and_then(Value::as_str).is_some_and(|url| !url.is_empty() && !url.contains("/node_modules/"))
};
let frame = all.iter().find(own).or(all.first())?;
let number = |key: &str| frame.get(key).and_then(Value::as_u64).and_then(|n| u32::try_from(n).ok()).unwrap_or(0);
Some(JsInitiator {
function: text(frame, "functionName").unwrap_or_default(),
url: text(frame, "url")?,
line: number("lineNumber"),
column: number("columnNumber"),
})
}
/// js-debug's `networkEvent` (`{event, data}`, `data` being the browser's own parameters of
/// that event) as a [`JsNetworkEvent`]; `None` for events the Network view has no use for.
pub(crate) fn network_event(body: &Value) -> Option<JsNetworkEvent> {
let data = body.get("data")?;
let kind = match body.get("event").and_then(Value::as_str)? {
"requestWillBeSent" => JsNetworkKind::Request,
"responseReceived" => JsNetworkKind::Response,
"loadingFinished" => JsNetworkKind::Finished,
"loadingFailed" => JsNetworkKind::Failed,
_ => return None,
};
let part = match kind {
JsNetworkKind::Request => data.get("request"),
JsNetworkKind::Response => data.get("response"),
_ => None,
};
let part = part.unwrap_or(&Value::Null);
Some(JsNetworkEvent {
kind,
request_id: text(data, "requestId")?,
// Seconds with a fraction.
timestamp_ms: (data.get("timestamp").and_then(Value::as_f64).unwrap_or(0.0).max(0.0) * 1000.0).round() as u64,
url: text(part, "url"),
method: text(part, "method"),
resource_type: text(data, "type"),
status: part.get("status").and_then(Value::as_u64).and_then(|s| u16::try_from(s).ok()),
mime_type: text(part, "mimeType"),
headers: headers(part),
encoded_length: data.get("encodedDataLength").and_then(Value::as_f64).map(|n| n.max(0.0) as u64),
error_text: text(data, "errorText"),
initiator: if kind == JsNetworkKind::Request { initiator(data) } else { None },
})
}
/// `networkCall getResponseBody`'s answer, cut at `limit` bytes on a character boundary.
fn response_body(answer: &Value, limit: usize) -> JsResponseBody {
let mut body = text(answer, "body").unwrap_or_default();
let truncated = body.len() > limit;
if truncated {
let mut end = limit;
while !body.is_char_boundary(end) {
end -= 1;
}
body.truncate(end);
}
JsResponseBody {
body,
base64_encoded: answer.get("base64Encoded").and_then(Value::as_bool).unwrap_or(false),
truncated,
}
}
/// The arguments of `enableNetworking`, which has a page report its requests
/// ([`ide_core::event::DebugEvent::JsNetwork`]). A page session sends it as it starts.
pub(crate) fn networking_arguments() -> Value {
json!({ "mirrorEvents": NETWORK_EVENTS })
}
impl DebugManager {
/// The body of the response to `request_id`, while the browser still has it.
pub async fn js_response_body(&self, s: DebugSessionId, request_id: String) -> IdeResult<JsResponseBody> {
let call = json!({ "method": "getResponseBody", "params": { "requestId": request_id } });
let answer = self.js_page_request(s, "networkCall", call).await?;
Ok(response_body(&answer, RESPONSE_BODY_LIMIT))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn event(kind: &str, data: Value) -> Option<ide_core::event::JsNetworkEvent> {
network_event(&json!({ "event": kind, "data": data }))
}
#[test]
fn a_request_carries_what_was_asked_and_who_asked() {
let e = event(
"requestWillBeSent",
json!({
"requestId": "76695.31", "timestamp": 611807.5, "type": "Fetch",
"request": { "method": "GET", "url": "http://127.0.0.1:5195/src/main.ts", "headers": { "Accept": "*/*", "X-N": 1 } },
"initiator": { "type": "script", "stack": { "callFrames": [
{ "functionName": "load", "url": "http://127.0.0.1:5195/src/App.vue", "lineNumber": 9, "columnNumber": 2 }
] } }
}),
)
.unwrap();
assert_eq!(e.kind, JsNetworkKind::Request);
assert_eq!((e.request_id.as_str(), e.timestamp_ms), ("76695.31", 611_807_500));
assert_eq!(e.url.as_deref(), Some("http://127.0.0.1:5195/src/main.ts"));
assert_eq!((e.method.as_deref(), e.resource_type.as_deref()), (Some("GET"), Some("Fetch")));
// Only text headers are kept.
assert_eq!(e.headers.len(), 1);
assert_eq!(e.headers["Accept"], "*/*");
let from = e.initiator.unwrap();
assert_eq!((from.function.as_str(), from.line, from.column), ("load", 9, 2));
assert_eq!(from.url, "http://127.0.0.1:5195/src/App.vue");
}
#[test]
fn a_request_made_through_a_library_is_asked_by_the_code_that_called_the_library() {
let frame = |function: &str, url: &str| json!({ "functionName": function, "url": url, "lineNumber": 1, "columnNumber": 0 });
let lib = "http://localhost:5173/node_modules/.vite/deps/http-client.js";
let asked_by = |stack: Value| {
let data = json!({ "requestId": "1", "timestamp": 1.0, "request": { "method": "GET", "url": "http://localhost/api" },
"initiator": { "type": "script", "stack": stack } });
event("requestWillBeSent", data).unwrap().initiator.unwrap().function
};
// The innermost frame is the library's; the project's caller is further out.
let direct = json!({ "callFrames": [frame("dispatch", lib), frame("request", lib), frame("loadItems", "http://localhost:5173/src/api.ts")] });
assert_eq!(asked_by(direct), "loadItems");
// Across an `await`, the caller is in the stack that scheduled this one.
let scheduled = json!({ "callFrames": [frame("dispatch", lib)],
"parent": { "callFrames": [frame("then", lib)], "parent": { "callFrames": [frame("onMounted", "http://localhost:5173/src/App.vue")] } } });
assert_eq!(asked_by(scheduled), "onMounted");
// A library on its own: the frame that sent the request.
assert_eq!(asked_by(json!({ "callFrames": [frame("dispatch", lib), frame("poll", lib)] })), "dispatch");
}
#[test]
fn a_response_a_finish_and_a_failure_carry_their_outcome() {
let response = event(
"responseReceived",
json!({ "requestId": "7", "timestamp": 2.0, "type": "Script", "response": {
"url": "http://localhost/a.js", "status": 200, "mimeType": "text/javascript",
"headers": { "Content-Type": "text/javascript" }
} }),
)
.unwrap();
assert_eq!(response.kind, JsNetworkKind::Response);
assert_eq!((response.status, response.mime_type.as_deref()), (Some(200), Some("text/javascript")));
assert_eq!(response.headers["Content-Type"], "text/javascript");
let finished =
event("loadingFinished", json!({ "requestId": "7", "timestamp": 2.5, "encodedDataLength": 1234 })).unwrap();
assert_eq!((finished.kind, finished.encoded_length), (JsNetworkKind::Finished, Some(1234)));
let failed =
event("loadingFailed", json!({ "requestId": "8", "timestamp": 3.0, "errorText": "net::ERR_FAILED" }))
.unwrap();
assert_eq!((failed.kind, failed.error_text.as_deref()), (JsNetworkKind::Failed, Some("net::ERR_FAILED")));
}
#[test]
fn events_that_name_no_request_or_are_not_mirrored_are_dropped() {
assert!(event("requestWillBeSent", json!({ "timestamp": 1.0 })).is_none());
assert!(event("dataReceived", json!({ "requestId": "7", "timestamp": 1.0 })).is_none());
assert!(network_event(&json!({ "data": { "requestId": "7" } })).is_none());
// A request made by the page's parser has no script behind it.
let parser = event(
"requestWillBeSent",
json!({ "requestId": "9", "timestamp": 1.0, "request": { "method": "GET", "url": "http://localhost/" },
"initiator": { "type": "parser" } }),
)
.unwrap();
assert!(parser.initiator.is_none());
}
#[test]
fn a_response_body_is_cut_at_the_limit_on_a_character() {
let whole = response_body(&json!({ "body": "héllo", "base64Encoded": false }), 100);
assert_eq!((whole.body.as_str(), whole.base64_encoded, whole.truncated), ("héllo", false, false));
// `é` is two bytes: a cut inside it backs off to the character before.
let cut = response_body(&json!({ "body": "héllo" }), 2);
assert_eq!((cut.body.as_str(), cut.truncated), ("h", true));
let binary = response_body(&json!({ "body": "AAEC", "base64Encoded": true }), 100);
assert!(binary.base64_encoded && !binary.truncated);
assert_eq!(response_body(&json!({}), 100).body, "");
}
}
-20
Переглянути файл
@@ -24,7 +24,6 @@ use connection::{Connection, DapError};
use futures::future::join_all;
use ide_core::{DebugSessionId, IdeError, IdeResult, SharedSink};
use ide_language::LanguageRegistry;
pub use js_debug::JsResponseBody;
pub use jvm_memory::*;
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
@@ -141,8 +140,6 @@ pub struct DebugCapabilities {
/// The session debugs a page in a browser: js-debug's requests for pages apply
/// ([`DebugManager::js_set_custom_breakpoints`]).
pub js_page: bool,
/// The adapter evaluates expressions while the program runs, without a stack frame.
pub live_evaluate: bool,
}
/// Result of [`DebugManager::redefine_classes`].
@@ -598,7 +595,6 @@ pub(crate) fn capabilities_from(caps: &Value) -> DebugCapabilities {
jvm_instances: false,
jvm_heap: false,
js_page: false,
live_evaluate: false,
}
}
@@ -609,7 +605,6 @@ pub(crate) fn session_capabilities(session: &Session) -> DebugCapabilities {
caps.jvm_instances = session.jvm_inspector.lock().is_some();
caps.jvm_heap = JVM_MEMORY_ADAPTERS.contains(&session.adapter.as_str());
caps.js_page = js_debug::is_page(session);
caps.live_evaluate = session.adapter == js_debug::ADAPTER;
caps
}
@@ -1182,21 +1177,6 @@ impl DebugManager {
}
}
s.launched.send_replace(true);
// A page reports its requests from here on. This is the earliest js-debug takes the
// request (before its answer to `launch` the page has no adapter and the request is
// never answered), and the page is already loading: its document and first scripts
// are not in the list.
if js_debug::is_page(s) {
let answer = s.conn.send_request("enableNetworking", js_debug::networking_arguments())?;
let session = s.clone();
// Not waited for here: the page runs on. A refusal is told to the Network view,
// which would otherwise wait for requests that never come.
tokio::spawn(async move {
if matches!(answer.await, Ok(Err(_))) {
session.emit(ide_core::event::DebugEvent::JsNetworkUnavailable);
}
});
}
if let Some(link) = s.jdwp_link.lock().as_ref() {
link.release();
}
-6
Переглянути файл
@@ -839,12 +839,6 @@ async fn on_event(mgr: &Arc<DebugManager>, s: &Arc<Session>, event: &str, mut bo
.map(|a| a.iter().filter_map(Value::as_str).map(str::to_owned).collect())
.unwrap_or_default(),
}),
// js-debug, after `enableNetworking`.
"networkEvent" => {
if let Some(event) = crate::js_debug::network_event(&body) {
s.emit(DebugEvent::JsNetwork { event });
}
}
_ => tracing::trace!("dap: ignored event {event}"),
}
}
+3 -90
Переглянути файл
@@ -160,13 +160,8 @@ struct Script {
launch_after_configuration: bool,
/// Requests of the adapter's own (js-debug's `setCustomBreakpoints`…) answered with an empty body.
accept: &'static [&'static str],
/// Requests of the adapter's own answered with a body and followed by events.
custom: Option<fn(&Value) -> Option<CustomReply>>,
}
/// The answer to a request of the adapter's own: the response body, then events (name, body).
type CustomReply = (Value, Vec<(&'static str, Value)>);
impl Default for Script {
fn default() -> Self {
Self {
@@ -199,7 +194,6 @@ impl Default for Script {
gdb15_starts_at_launch: false,
launch_after_configuration: false,
accept: &[],
custom: None,
}
}
}
@@ -518,16 +512,6 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
}
ok!(msg, json!({}))
}
other if script.custom.and_then(|custom| custom(&msg)).is_some() => {
if starting.load(Ordering::Acquire) {
log.lock().push(json!({"type": "dropped", "command": other}));
}
let (body, events) = script.custom.and_then(|custom| custom(&msg)).unwrap();
ok!(msg, body);
for (event, body) in events {
send!(json!({"type": "event", "event": event, "body": body}));
}
}
other => send!(
json!({"type": "response", "request_seq": msg["seq"], "command": other, "success": false, "message": "unsupported"})
),
@@ -982,16 +966,16 @@ async fn a_browser_page_session_has_the_page_capability() {
let h = harness(Script { start_debugging: true, ..Script::default() });
let browser = h.start(js_config("pwa-chrome")).await.unwrap();
let page = h.manager.capabilities(child_of(&h, browser).await).unwrap();
assert!(page.js_page && page.live_evaluate);
assert!(page.js_page);
assert!(!h.manager.capabilities(browser).unwrap().js_page, "the launch itself is not a page");
let node = h.start(js_config("pwa-node")).await.unwrap();
let process = h.manager.capabilities(child_of(&h, node).await).unwrap();
assert!(!process.js_page && process.live_evaluate);
assert!(!process.js_page);
let python = h.start(config()).await.unwrap();
let script = h.manager.capabilities(child_of(&h, python).await).unwrap();
assert!(!script.js_page && !script.live_evaluate);
assert!(!script.js_page);
}
#[tokio::test]
@@ -1033,77 +1017,6 @@ async fn a_page_gets_its_requests_once_its_session_has_started() {
assert_eq!(dropped, 0, "the request reached the page before it took requests");
}
/// js-debug's network requests: `enableNetworking` is followed by one mirrored browser event.
fn network_adapter(msg: &Value) -> Option<CustomReply> {
match msg["command"].as_str()? {
"enableNetworking" => Some((
json!({}),
vec![(
"networkEvent",
json!({ "event": "requestWillBeSent", "data": {
"requestId": "7", "timestamp": 1.5, "type": "Fetch",
"request": { "method": "GET", "url": "http://localhost:5173/api/items", "headers": {} }
} }),
)],
)),
"networkCall" => Some((json!({ "body": "é".repeat(600_000), "base64Encoded": false }), Vec::new())),
_ => None,
}
}
#[tokio::test]
async fn a_page_reports_its_requests_and_their_bodies() {
let h = harness(Script {
start_debugging: true,
custom: Some(network_adapter),
child_launch_delay: Duration::from_millis(100),
..Script::default()
});
let browser = h.start(js_config("pwa-chrome")).await.unwrap();
let page = child_of(&h, browser).await;
// Recording is asked for by the session itself, as soon as js-debug takes the request:
// once it has answered the page's `launch` (before that, the request is never answered).
let DebugEvent::JsNetwork { event } = h.wait_event(|e| matches!(e, DebugEvent::JsNetwork { .. })).await else {
unreachable!()
};
assert_eq!(
requests(&h, "enableNetworking"),
[json!({ "mirrorEvents": ["requestWillBeSent", "responseReceived", "loadingFinished", "loadingFailed"] })]
);
let dropped = h.log.lock().iter().filter(|m| m["type"] == "dropped").count();
assert_eq!(dropped, 0, "recording was asked for before the page took requests");
assert_eq!((event.request_id.as_str(), event.timestamp_ms), ("7", 1500));
assert_eq!(event.url.as_deref(), Some("http://localhost:5173/api/items"));
let body = h.manager.js_response_body(page, "7".into()).await.unwrap();
assert_eq!(requests(&h, "networkCall"), [json!({ "method": "getResponseBody", "params": { "requestId": "7" } })]);
// 1.2 MB of two-byte characters: cut at the limit, on a character.
assert!(body.truncated && !body.base64_encoded);
assert_eq!(body.body.len(), 1_048_576);
assert!(body.body.chars().all(|c| c == 'é'));
assert_eq!(
h.manager.js_response_body(browser, "7".into()).await.unwrap_err().kind,
ide_core::error::ErrorKind::Unavailable
);
// A Node.js program is not a page: nothing is asked of it.
let node = h.start(js_config("pwa-node")).await.unwrap();
child_of(&h, node).await;
h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await;
assert_eq!(requests(&h, "enableNetworking").len(), 1);
}
#[tokio::test]
async fn a_page_whose_requests_the_adapter_does_not_report_says_so() {
// A js-debug build without `enableNetworking`.
let h = harness(Script { start_debugging: true, ..Script::default() });
let browser = h.start(js_config("pwa-chrome")).await.unwrap();
child_of(&h, browser).await;
h.wait_event(|e| matches!(e, DebugEvent::JsNetworkUnavailable)).await;
}
#[test]
fn a_child_session_is_named_by_the_adapter_unless_it_is_a_blank_page() {
assert_eq!(child_name(&json!({ "name": "server.js [4120]" }), "Server"), "server.js [4120]");
-35
Переглянути файл
@@ -117,14 +117,6 @@ pub(crate) fn oracle_err(e: &oracledb::Error) -> IdeError {
// config first. Treat it as a logon failure, so the UI prompts for one.
let kind = match e.kind() {
oracledb::ErrorKind::NoCredentials => ErrorKind::PermissionDenied,
// The thin driver speaks the protocol of Oracle Database 12c and later only.
oracledb::ErrorKind::ServerVersionNotSupported => {
return IdeError::new(
ErrorKind::Unavailable,
"This Oracle Database is older than 12c. OpenIDEA connects to Oracle Database 12c and later; \
11g and earlier are not supported.",
);
}
_ => ErrorKind::InvalidInput,
};
return IdeError::new(kind, e.to_string());
@@ -579,33 +571,6 @@ mod tests {
assert_eq!(connect_string_with(&s("PROD=MYDB"), false).unwrap(), "PROD=MYDB");
}
#[test]
fn a_server_older_than_12c_is_named() {
use std::io::{Read, Write};
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let server = std::thread::spawn(move || {
let (mut socket, _) = listener.accept().unwrap();
let mut connect = [0u8; 1024];
let _ = socket.read(&mut connect);
// A TNS ACCEPT packet with protocol version 314, as Oracle Database 11g answers.
let mut accept = vec![0u8; 32];
accept[1] = 32;
accept[4] = 2;
accept[8..10].copy_from_slice(&314u16.to_be_bytes());
let _ = socket.write_all(&accept);
});
let config = oracledb::Config::default()
.set_credentials("scott", "tiger")
.set_connect_string(&format!("//127.0.0.1:{port}/ORCL"))
.unwrap()
.set_transport_connect_timeout(Some(std::time::Duration::from_secs(5)));
let err = oracle_err(&oracledb::connect(config).err().expect("an 11g server is refused"));
assert_eq!(err.kind, ErrorKind::Unavailable, "{err:?}");
assert!(err.message.contains("older than 12c"), "{}", err.message);
server.join().unwrap();
}
#[test]
fn a_tns_alias_resolves_when_tnsnames_exists() {
let alias = DataSource {
+6 -45
Переглянути файл
@@ -12,7 +12,6 @@ use ide_db::{
};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::path::PathBuf;
/// One statement of a console run.
#[derive(Clone, Serialize, Deserialize, Type)]
@@ -180,7 +179,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
apply_edits(&c.host, &c.window, &id, &schema, &table, edits).await
});
d.add("db_console_path", |c| async move { console_path(&c.host, &c.arg::<String>("id")?) });
d.add("db_consoles", |c| async move { consoles(&c.host, &c.arg::<String>("id")?, c.arg("create")?) });
}
/// The data sources configured for the project (the tree's roots).
@@ -474,52 +472,11 @@ pub async fn apply_edits(
/// created on first use. Consoles are files of the project, so a remote
/// window resolves it on the host the project runs on.
pub fn console_path(host: &Host, id: &str) -> IdeResult<IpcPath> {
let file = console_dir(host, id)?.join("console.sql");
if !file.exists() {
std::fs::write(&file, "")?;
}
Ok(file.into())
}
/// The query consoles of data source `id`: the default `console.sql`, then
/// `console_1.sql`, `console_2.sql`… in order. `create` adds the next one first
/// (DataGrip's New › Query Console).
pub fn consoles(host: &Host, id: &str, create: bool) -> IdeResult<Vec<IpcPath>> {
let dir = console_dir(host, id)?;
let default = dir.join("console.sql");
let mut numbered: Vec<(u32, PathBuf)> = std::fs::read_dir(&dir)?
.filter_map(|e| {
let path = e.ok()?.path();
let n = path.file_name()?.to_str()?.strip_prefix("console_")?.strip_suffix(".sql")?.parse().ok()?;
Some((n, path))
})
.collect();
numbered.sort();
if create {
// The default console comes first; once it exists, the next number.
if !default.exists() {
std::fs::write(&default, "")?;
} else {
let n = numbered.last().map_or(1, |(n, _)| n + 1);
let file = dir.join(format!("console_{n}.sql"));
std::fs::write(&file, "")?;
numbered.push((n, file));
}
}
let mut out: Vec<IpcPath> = Vec::new();
if default.exists() {
out.push(default.into());
}
out.extend(numbered.into_iter().map(|(_, p)| p.into()));
Ok(out)
}
/// `<dataDir>/consoles/db/<id>`: `<id>` for the default database, `<id>.<database>` for the others.
fn console_dir(host: &Host, id: &str) -> IdeResult<PathBuf> {
let (source, database) = ide_db::split_target(id);
if source.is_empty() || !source.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') {
return Err(IdeError::invalid("invalid data source id"));
}
// One console per database: `<id>` for the default one, `<id>.<database>` for the others.
let name = match database {
Some(db) => {
let safe: String =
@@ -530,5 +487,9 @@ fn console_dir(host: &Host, id: &str) -> IdeResult<PathBuf> {
};
let dir = host.data_dir.join("consoles").join("db").join(&name);
std::fs::create_dir_all(&dir)?;
Ok(dir)
let file = dir.join("console.sql");
if !file.exists() {
std::fs::write(&file, "")?;
}
Ok(file.into())
}
-12
Переглянути файл
@@ -382,15 +382,6 @@ pub async fn debug_js_custom_breakpoints(
host.debugger.js_set_custom_breakpoints(session, ids, xhr).await
}
pub async fn debug_js_response_body(
host: &Host,
_window: &str,
session: DebugSessionId,
request_id: String,
) -> IdeResult<ide_dap::JsResponseBody> {
host.debugger.js_response_body(session, request_id).await
}
pub fn debug_capabilities(host: &Host, _window: &str, session: DebugSessionId) -> IdeResult<DebugCapabilities> {
host.debugger.capabilities(session)
}
@@ -607,9 +598,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
debug_restart_frame(&c.host, &c.window, c.arg("session")?, c.arg("frameId")?).await
});
d.add("debug_capabilities", |c| async move { debug_capabilities(&c.host, &c.window, c.arg("session")?) });
d.add("debug_js_response_body", |c| async move {
debug_js_response_body(&c.host, &c.window, c.arg("session")?, c.arg("requestId")?).await
});
d.add("debug_js_custom_breakpoints", |c| async move {
debug_js_custom_breakpoints(&c.host, &c.window, c.arg("session")?, c.arg("ids")?, c.arg("xhr")?).await
});
+1 -87
Переглянути файл
@@ -2,7 +2,7 @@ use super::doc::blocking;
use crate::Host;
use crate::Project;
use crate::rpc::Dispatcher;
use ide_core::{DocId, IdeResult, IpcPath, LanguageId, Location, Pos};
use ide_core::{DocId, IdeResult, IpcPath, Location, Pos};
use ide_index::{ExtensionCount, FileMatch, IndexStatus, SymbolFilter, SymbolMatch};
use ide_language::{FileReference, WebReference};
use ide_workspace::file_refs::{self, PathCompletion};
@@ -22,23 +22,6 @@ pub struct WebGoto {
const MAX_WEB_TARGETS: usize = 200;
/// A Python class definition and the base names written in its source. The UI builds a
/// name → locations index from every class in the project (attached dependency roots
/// included) to compute ↑ overrides / ↓ overridden gutter markers without a language
/// server's type-hierarchy provider.
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct ClassInheritance {
/// Start of the class definition's selection range (0-based line, UTF-16 column).
pub path: IpcPath,
pub line: u32,
pub col: u32,
pub name: String,
/// Base names as written in source (bare name, e.g. `AllToAllTokenDispatcher`; a
/// qualified name keeps only its last segment).
pub bases: Vec<String>,
}
/// The project root and its resource roots (served at `/`), for file references.
fn reference_roots(project: &Project) -> (PathBuf, Vec<PathBuf>) {
let resources = project.roots().resource_dirs().iter().map(|d| project.root.join(d)).collect();
@@ -191,74 +174,6 @@ pub async fn nav_index_definitions(
.await
}
/// Every Python class in the project (attached dependency roots included) and its base
/// names. The index covers content roots, so this is the whole-project answer the UI uses
/// for inheritance gutter markers when no type-hierarchy provider exists.
pub async fn nav_inheritance(host: &Host, window: &str) -> IdeResult<Vec<ClassInheritance>> {
let project = host.project(window)?;
let syntax = host.syntax();
blocking(move || {
let python = LanguageId::new("python");
let files = project.index.files_with_extensions(&["py".into(), "pyi".into()], 20_000);
let mut cache = CLASS_BASES.lock();
let mut out = Vec::new();
for path in files {
// The open (possibly unsaved) buffer is keyed by its text, a file on disk by its
// size and modification time, so only changed files are parsed again.
let open = project.documents.text_of(&path);
let stamp = match &open {
Some(text) => Stamp::Text(text_hash(text)),
None => match std::fs::metadata(&path) {
Ok(m) => Stamp::Disk(m.len(), m.modified().ok()),
Err(_) => continue,
},
};
let classes = match cache.get(&path) {
Some((cached, classes)) if *cached == stamp => classes,
_ => {
let text = match open {
Some(text) => text,
None => match project.text_of(&path) {
Ok(text) => text,
Err(_) => continue,
},
};
let classes = syntax.class_bases(&python, &text);
&cache.entry(path.clone()).insert_entry((stamp, classes)).into_mut().1
}
};
out.extend(classes.iter().map(|class| ClassInheritance {
path: IpcPath::from(path.clone()),
line: class.line,
col: class.col,
name: class.name.clone(),
bases: class.bases.clone(),
}));
}
Ok(out)
})
.await
}
#[derive(Clone, PartialEq, Eq)]
enum Stamp {
Text(u64),
Disk(u64, Option<std::time::SystemTime>),
}
fn text_hash(text: &str) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
text.hash(&mut h);
h.finish()
}
type ClassBasesCache = std::collections::HashMap<PathBuf, (Stamp, Vec<ide_language::ClassBases>)>;
/// Class bases per Python file, reused while the file is unchanged.
static CLASS_BASES: std::sync::LazyLock<parking_lot::Mutex<ClassBasesCache>> =
std::sync::LazyLock::new(Default::default);
/// The existing file or directory a path written in `from` names (`href`, `url()`, a string literal).
pub async fn fs_resolve_reference(
host: &Host,
@@ -334,7 +249,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("nav_index_definitions", |c| async move {
nav_index_definitions(&c.host, &c.window, c.arg("name")?, c.arg("from")?).await
});
d.add("nav_inheritance", |c| async move { nav_inheritance(&c.host, &c.window).await });
d.add("fs_resolve_reference", |c| async move {
fs_resolve_reference(&c.host, &c.window, c.arg("from")?, c.arg("value")?).await
});
+2 -28
Переглянути файл
@@ -585,21 +585,6 @@ pub async fn vcs_stage(host: &Host, window: &str, paths: Vec<PathBuf>) -> IdeRes
blocking(move || per_backend(&project, &paths, None, |r, paths| r.stage(paths))).await
}
/// Adds `commit` to annotate's ignore list in `path`'s repository (the file `blame.ignoreRevsFile`
/// names, else `.git-blame-ignore-revs`, which the local config then names).
pub async fn vcs_ignore_blame_revision(host: &Host, window: &str, path: PathBuf, commit: String) -> IdeResult<()> {
host.require_trust(window)?;
let project = host.project(window)?;
blocking(move || {
let result = project.with_disk_sync(|| project.with_repo_of(&path, |r| r.ignore_blame_revision(&commit)));
if result.is_ok() {
project.sink.emit(ide_core::IdeEvent::VcsChanged { root: project.root.clone().into() });
}
result
})
.await
}
/// Project-host operation (also registered with the remote dispatcher).
/// Returns the ignore files that were updated, so the UI can show them.
pub async fn vcs_add_to_ignore(
@@ -807,19 +792,12 @@ pub async fn vcs_stash_push(
message: Option<String>,
include_untracked: bool,
keep_index: bool,
paths: Option<Vec<PathBuf>>,
) -> IdeResult<()> {
host.require_trust(window)?;
let project = host.project(window)?;
let paths = paths.unwrap_or_default();
blocking(move || {
if paths.is_empty() {
return project.with_repo_recorded(repo.as_deref(), Some("Git stash"), |r| {
r.stash_push(message.as_deref(), include_untracked, keep_index, &[])
});
}
per_repo(&project, &paths, Some("Git stash"), |r, paths| {
r.stash_push(message.as_deref(), include_untracked, keep_index, paths)
project.with_repo_recorded(repo.as_deref(), Some("Git stash"), |r| {
r.stash_push(message.as_deref(), include_untracked, keep_index)
})
})
.await
@@ -1484,9 +1462,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("vcs_add_to_ignore", |c| async move {
vcs_add_to_ignore(&c.host, &c.window, c.arg("paths")?, c.arg("destination")?).await
});
d.add("vcs_ignore_blame_revision", |c| async move {
vcs_ignore_blame_revision(&c.host, &c.window, c.arg("path")?, c.arg("commit")?).await
});
d.add("vcs_unstage", |c| async move { vcs_unstage(&c.host, &c.window, c.arg("paths")?).await });
d.add("vcs_stage_patch", |c| async move { vcs_stage_patch(&c.host, &c.window, c.arg("patch")?).await });
d.add("vcs_discard", |c| async move { vcs_discard(&c.host, &c.window, c.arg("paths")?).await });
@@ -1545,7 +1520,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
c.arg("message")?,
c.arg("includeUntracked")?,
c.arg("keepIndex")?,
c.arg("paths")?,
)
.await
});
-1
Переглянути файл
@@ -357,7 +357,6 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
| "docker_set_registry_password"
| "docker_run_env"
| "db_console_path"
| "db_consoles"
| "db_export_file"
| "vcs_write_patch"
| "app_metrics"
-37
Переглянути файл
@@ -1,37 +0,0 @@
use ide_core::NullSink;
use ide_host::ops::db;
use ide_host::{Host, HostConfig};
use std::sync::Arc;
#[tokio::test]
async fn new_query_consoles_are_numbered_after_the_default_one() {
let tmp = tempfile::tempdir().unwrap();
let base = tmp.path();
let host = Arc::new(Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
}));
let names = |paths: Vec<ide_core::IpcPath>| -> Vec<String> {
paths.iter().map(|p| p.0.file_name().unwrap().to_string_lossy().into_owned()).collect()
};
assert!(db::consoles(&host, "pg", false).unwrap().is_empty());
// The first new console is the default one, then console_1, console_2…
assert_eq!(names(db::consoles(&host, "pg", true).unwrap()), ["console.sql"]);
assert_eq!(names(db::consoles(&host, "pg", true).unwrap()), ["console.sql", "console_1.sql"]);
let all = db::consoles(&host, "pg", true).unwrap();
assert_eq!(names(all.clone()), ["console.sql", "console_1.sql", "console_2.sql"]);
// Numbers sort numerically, and the default console path is the first one.
std::fs::write(all[0].0.with_file_name("console_10.sql"), "").unwrap();
assert_eq!(names(db::consoles(&host, "pg", false).unwrap())[3], "console_10.sql");
assert_eq!(db::console_path(&host, "pg").unwrap().0, all[0].0);
// Each database of a source has its own consoles.
assert!(db::consoles(&host, "pg:other", false).unwrap().is_empty());
assert!(db::consoles(&host, "../x", true).is_err());
}
-141
Переглянути файл
@@ -1,141 +0,0 @@
//! The native `nav_inheritance` command: it must return every Python class definition
//! in the project — attached dependency roots included — together with the base names
//! written in its source, so the UI can compute inheritance gutter markers without a
//! language server's type-hierarchy provider.
use ide_host::{Host, HostConfig, ops};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Duration, Instant};
const WINDOW: &str = "main";
struct Opened {
host: Arc<Host>,
root: PathBuf,
_tmp: tempfile::TempDir,
}
/// Opens a synthetic project whose Python files carry known class / base shapes, then
/// waits for the index to settle (it walks content roots, so dependency roots are covered).
async fn open() -> Opened {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap().join("proj");
let src = root.join("src");
let dep = root.join("vendor");
std::fs::create_dir_all(&src).unwrap();
std::fs::create_dir_all(&dep).unwrap();
// Own root: a plain class, a class with two bases + a keyword arg, a qualified base,
// and a subscripted generic base. A comment / docstring that mentions `class X(Y)`
// must not be reported.
std::fs::write(
src.join("app.py"),
concat!(
"class Base:\n",
" pass\n",
"\n",
"class Mixin:\n",
" pass\n",
"\n",
"class Child(Base, Mixin, metaclass=Meta):\n",
" # class Ghost(Spook): must be ignored\n",
" \"\"\"class Phantom(Apparition): must be ignored\"\"\"\n",
" pass\n",
"\n",
"class Qualified(pkg.mod.Base):\n",
" pass\n",
"\n",
"class Generic(Generic[T]):\n",
" pass\n",
),
)
.unwrap();
// A second own-root file to prove the command scans more than one file.
std::fs::write(src.join("extra.pyi"), "class Stub(Base):\n ...\n").unwrap();
// An attached dependency root (content root) — must be covered by the index.
std::fs::write(dep.join("dep.py"), "class DepBase:\n pass\n").unwrap();
let app = tmp.path().join("app");
let host = Arc::new(Host::new(HostConfig {
config_dir: app.join("config"),
data_dir: app.join("data"),
local_data_dir: app.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(|_| {})),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
}));
host.trust.decide(&root, true, false).unwrap();
ops::project::open(&host, WINDOW, &root).await.unwrap();
let deadline = Instant::now() + Duration::from_secs(30);
loop {
let s = ops::nav::index_status(&host, WINDOW).unwrap();
if !s.indexing && s.files > 0 {
break;
}
assert!(Instant::now() < deadline, "indexing timed out; status: {s:?}");
tokio::time::sleep(Duration::from_millis(100)).await;
}
Opened { host, root, _tmp: tmp }
}
fn find<'a>(all: &'a [ide_host::ops::nav::ClassInheritance], name: &str) -> &'a ide_host::ops::nav::ClassInheritance {
all.iter().find(|c| c.name == name).unwrap_or_else(|| panic!("no class {name:?} in {all:#?}"))
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn returns_all_python_classes_and_bases() {
let p = open().await;
let all = ops::nav::nav_inheritance(&p.host, WINDOW).await.unwrap();
// Both own-root Python files are scanned (src/app.py + src/extra.pyi).
let base = find(&all, "Base");
assert!(base.path.0.ends_with("src/app.py"), "Base path: {:?}", base.path);
// The location is the class name identifier (selection-range start), i.e. after `class `.
assert_eq!(base.line, 0);
assert_eq!(base.col, 6);
assert!(base.bases.is_empty(), "a bare class has no bases: {:?}", base.bases);
let child = find(&all, "Child");
assert_eq!(child.line, 6);
assert_eq!(child.col, 6);
// The `metaclass=Meta` keyword argument is not a base.
assert!(child.bases.contains(&"Base".to_string()), "Child bases: {:?}", child.bases);
assert!(child.bases.contains(&"Mixin".to_string()), "Child bases: {:?}", child.bases);
assert!(!child.bases.iter().any(|b| b == "Meta"), "metaclass must be skipped: {:?}", child.bases);
// A qualified base keeps only its last segment.
let qualified = find(&all, "Qualified");
assert_eq!(qualified.bases, vec!["Base".to_string()], "qualified base: {:?}", qualified.bases);
// A subscripted generic base keeps the identifier.
let generic = find(&all, "Generic");
assert_eq!(generic.bases, vec!["Generic".to_string()], "generic base: {:?}", generic.bases);
// Comments / docstrings are not mistaken for class definitions.
assert!(!all.iter().any(|c| c.name == "Ghost" || c.name == "Phantom"), "parsed a comment: {all:#?}");
// The .pyi stub in the second own-root file is included.
let stub = find(&all, "Stub");
assert!(stub.path.0.ends_with("src/extra.pyi"), "Stub path: {:?}", stub.path);
assert_eq!(stub.bases, vec!["Base".to_string()]);
// The attached dependency root is covered by the project index.
let dep = find(&all, "DepBase");
assert!(dep.path.0.starts_with(&p.root), "DepBase path: {:?}", dep.path);
// Every returned entry is a Python file.
assert!(all.iter().all(|c| { matches!(c.path.0.extension().and_then(|e| e.to_str()), Some("py" | "pyi")) }));
// A file changed on disk is parsed again; the others come from the cache.
let extra = p.root.join("src/extra.pyi");
std::fs::write(&extra, "class Stub(Child): ...\nclass Added(Base): ...\n").unwrap();
let again = ops::nav::nav_inheritance(&p.host, WINDOW).await.unwrap();
assert_eq!(find(&again, "Stub").bases, vec!["Child".to_string()]);
assert_eq!(find(&again, "Added").bases, vec!["Base".to_string()]);
assert_eq!(find(&again, "Child").bases, child.bases);
}
+3 -5
Переглянути файл
@@ -821,10 +821,8 @@ fn a_reporting_task_outlives_its_background_budget() {
#[test]
fn background_tasks_scale_and_renew_cpu_fuel_budget() {
// Room for activation (about 3_100 on macOS, more on Windows) but not for `patient`'s
// whole run (about 9_000) without the refill on each report.
let f = fixture(Limits {
fuel: 6_000,
fuel: 3_500,
timeout: Duration::from_millis(500),
background: Duration::from_millis(5_000),
..Limits::default()
@@ -833,14 +831,14 @@ fn background_tasks_scale_and_renew_cpu_fuel_budget() {
if f.host.set_capability("misbehave", "process:git", true).is_err() {
return;
}
// Warm up activation in its own call so `patient` starts with a fresh budget.
// Warm up activation in its own call so `patient` starts with a fresh 3_500-fuel budget.
assert_eq!(f.run("misbehave", "echo", Some("warm")).unwrap().as_deref(), Some("warm"));
let out = f.run("misbehave", "patient", None).expect("background task succeeds").expect("output");
assert_eq!(out, "still here");
let err = f.run("misbehave", "spin", None).unwrap_err();
assert!(err.contains("exceeded its CPU budget (6000 fuel per call)"), "{err}");
assert!(err.contains("exceeded its CPU budget (3500 fuel per call)"), "{err}");
}
#[test]
-1
Переглянути файл
@@ -365,7 +365,6 @@ async fn local_deployment_runs_on_the_project_host() {
"db_table_page",
"db_apply_edits",
"db_console_path",
"db_consoles",
"ssh_test",
"ssh_set_secret",
"ssh_password_saved",
-1
Переглянути файл
@@ -66,7 +66,6 @@ async fn application_commands_work_in_restricted_projects_and_without_a_project(
"docker_set_registry_password",
"docker_run_env",
"db_console_path",
"db_consoles",
"db_export_file",
"vcs_write_patch",
"remote_bootstrap_list",
+1 -1
Переглянути файл
@@ -102,7 +102,7 @@ async fn stash_and_unstash_reload_clean_open_documents_before_returning() {
let f = Fixture::new().await;
let edited = format!("{ORIGINAL}\n");
f.saved_edit(&edited);
vcs::vcs_stash_push(&f.host, "main", None, None, false, false, None).await.unwrap();
vcs::vcs_stash_push(&f.host, "main", None, None, false, false).await.unwrap();
f.assert_clean(ORIGINAL);
vcs::vcs_stash_apply(&f.host, "main", None, 0, true, false, None).await.unwrap();
f.assert_clean(&edited);
+1 -1
Переглянути файл
@@ -168,7 +168,7 @@ async fn change_commit_branch_stash_and_merge_a_conflict() {
let wip = format!("{}// work in progress\n", f.text(main_rs).await);
f.host.project(WINDOW).unwrap().documents.replace_all(main_rs, &wip).unwrap();
f.save(main_rs).await;
ops::vcs::vcs_stash_push(&f.host, WINDOW, None, Some("wip".into()), false, false, None).await.unwrap();
ops::vcs::vcs_stash_push(&f.host, WINDOW, None, Some("wip".into()), false, false).await.unwrap();
assert!(f.status().await.is_empty());
assert!(!f.text(main_rs).await.contains("work in progress"));
let stashes = ops::vcs::vcs_stashes(&f.host, WINDOW, None).await.unwrap();
+2 -23
Переглянути файл
@@ -27,38 +27,17 @@ fn git_dir(root: &Path) -> Option<std::path::PathBuf> {
Some(if path.is_absolute() { path.to_owned() } else { root.join(path) })
}
/// The config and exclude file of the Git directory `git`; a linked worktree's directory
/// (`.git/worktrees/<name>`) reads the shared ones its `commondir` names as well.
fn insert_git_inputs(out: &mut std::collections::BTreeSet<std::path::PathBuf>, git: &Path) {
out.insert(git.join("config"));
out.insert(git.join("info/exclude"));
if let Ok(common) = std::fs::read_to_string(git.join("commondir")) {
let common = Path::new(common.trim());
let common = if common.is_absolute() { common.to_owned() } else { git.join(common) };
out.insert(common.join("config"));
out.insert(common.join("info/exclude"));
}
}
/// External ignore/config inputs, including candidates that do not exist yet.
/// These are outside the directory manifest (`.git` is skipped), so the full
/// path set must match at warm start.
pub(crate) fn external_ignore_candidates(root: &Path) -> Vec<std::path::PathBuf> {
let mut out = std::collections::BTreeSet::new();
if let Some(git) = git_dir(root) {
insert_git_inputs(&mut out, &git);
out.insert(git.join("config"));
out.insert(git.join("info/exclude"));
}
out.insert(root.join(".git/config"));
out.insert(root.join(".git/info/exclude"));
// The walker also reads the ignore files above the root (`parents`), and a project nested
// in a repository uses that repository's exclude file and config.
for dir in root.ancestors().skip(1) {
out.insert(dir.join(".gitignore"));
out.insert(dir.join(".ignore"));
if let Some(git) = git_dir(dir) {
insert_git_inputs(&mut out, &git);
}
}
if let Some(path) = ignore::gitignore::gitconfig_excludes_path() {
out.insert(path);
}
-46
Переглянути файл
@@ -797,52 +797,6 @@ fn warm_start_detects_changed_ignore_rules() {
assert_eq!(idx.status().files, 1, "an updated ignore rule invalidates the warm snapshot");
}
#[test]
fn warm_start_detects_changed_ignore_rules_above_the_project() {
// The project is a folder of a repository whose ignore files sit above it.
let f = Fixture::new(&[("generated/a.rs", "fn a() {}\n"), ("src/b.rs", "fn b() {}\n")]);
let repo = f.root.parent().unwrap().to_owned();
std::fs::create_dir_all(repo.join(".git/info")).unwrap();
std::fs::write(repo.join(".git/HEAD"), "ref: refs/heads/main\n").unwrap();
let idx = f.open();
assert_eq!(idx.status().files, 2);
drop(idx);
std::fs::write(repo.join(".gitignore"), "proj/generated/\n").unwrap();
let idx = f.open();
assert_eq!(idx.status().files, 1, "a new ignore file above the project invalidates the warm snapshot");
drop(idx);
std::thread::sleep(Duration::from_millis(20));
std::fs::write(repo.join(".gitignore"), "").unwrap();
std::fs::write(repo.join(".git/info/exclude"), "proj/src/\n").unwrap();
let idx = f.open();
assert_eq!(idx.status().files, 1, "so do the enclosing repository's changed rules");
assert!(!symbols(&idx, "a", SymbolFilter::All).is_empty());
}
#[test]
fn warm_start_detects_a_changed_exclude_file_of_a_linked_worktree() {
// The project is a linked worktree: its `.git` file points into the main repository,
// whose shared `info/exclude` the walker reads through `commondir`.
let f = Fixture::new(&[("generated/a.rs", "fn a() {}\n"), ("src/b.rs", "fn b() {}\n")]);
let main = f.root.parent().unwrap().join("main/.git");
let worktree = main.join("worktrees/proj");
std::fs::create_dir_all(main.join("info")).unwrap();
std::fs::create_dir_all(&worktree).unwrap();
std::fs::write(main.join("HEAD"), "ref: refs/heads/main\n").unwrap();
std::fs::write(worktree.join("HEAD"), "ref: refs/heads/wt\n").unwrap();
std::fs::write(worktree.join("commondir"), "../..\n").unwrap();
std::fs::write(f.root.join(".git"), format!("gitdir: {}\n", worktree.display())).unwrap();
let idx = f.open();
assert_eq!(idx.status().files, 2);
drop(idx);
std::fs::write(main.join("info/exclude"), "generated/\n").unwrap();
let idx = f.open();
assert_eq!(idx.status().files, 1, "the shared exclude file invalidates the warm snapshot");
}
#[test]
fn warm_start_detects_a_new_file_in_an_existing_empty_directory() {
let f = Fixture::new(&[("src/a.rs", "fn a() {}\n")]);
+5 -5
Переглянути файл
@@ -22,11 +22,11 @@ pub use markers::{arduino_sketch_root, is_arduino_sketch_dir, marked_root};
pub use plugin_point::PluginLanguages;
pub use registry::{FileTypes, LanguageRegistry};
pub use syntax::{
CSS_CLASS, CSS_ID, CSS_VARIABLE, ClassBases, ConfigurationKey, ExternalFold, ExternalSymbol, ExternalSyntax,
ExternalToken, FileReference, Literal, LiteralKind, MAX_CACHED_TREES, MAX_PARSE_BYTES, ParsedTree, SpellingKind,
SpellingRegion, Syntax, SyntaxCache, SyntaxCacheStats, SyntaxHighlightKind, SyntaxHighlights, TreeError,
VueComponentImport, VueRefAccess, VueRefElement, WebReference, WebReferenceKind, merge_visibility,
set_external_syntax, shift_pos, shift_range,
CSS_CLASS, CSS_ID, CSS_VARIABLE, ConfigurationKey, ExternalFold, ExternalSymbol, ExternalSyntax, ExternalToken,
FileReference, Literal, LiteralKind, MAX_CACHED_TREES, MAX_PARSE_BYTES, ParsedTree, SpellingKind, SpellingRegion,
Syntax, SyntaxCache, SyntaxCacheStats, SyntaxHighlightKind, SyntaxHighlights, TreeError, VueComponentImport,
VueRefAccess, VueRefElement, WebReference, WebReferenceKind, merge_visibility, set_external_syntax, shift_pos,
shift_range,
};
/// Parsed form of `languages/<id>.toml`. Backend-only; the UI gets [`LanguageInfo`].
-171
Переглянути файл
@@ -1,171 +0,0 @@
//! Extraction of Python class base names, used to build an inheritance graph
//! without an LSP `typeHierarchyProvider`.
use super::lines::LineIndex;
use crate::{LanguageId, Syntax};
use tree_sitter::{Node, Tree};
/// A Python class definition and the base names written in its source.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ClassBases {
/// The class name (bare identifier).
pub name: String,
/// 0-based line of the class name (selection-range start).
pub line: u32,
/// 0-based UTF-16 column of the class name (selection-range start).
pub col: u32,
/// Source-written base names (last segment of a qualified name).
pub bases: Vec<String>,
}
impl Syntax {
/// Class definitions of `text` with their base names, in source order.
/// Only Python is supported; every other language returns an empty vector.
pub fn class_bases(&self, lang: &LanguageId, text: &str) -> Vec<ClassBases> {
self.analyze(lang, text, |g, tree, text| (g.spec.name == "python").then(|| extract(tree, text)))
.unwrap_or_default()
}
}
fn extract(tree: &Tree, text: &str) -> Vec<ClassBases> {
let idx = LineIndex::new(text);
let mut out = Vec::new();
let mut cursor = tree.walk();
visit(&mut cursor, &mut |node| {
if node.kind() != "class_definition" {
return;
}
let Some(name_node) = node.child_by_field_name("name") else { return };
let name = text.get(name_node.byte_range()).unwrap_or_default().to_owned();
if name.is_empty() {
return;
}
// Selection-range start of the class: its name identifier (`class Foo`
// -> `Foo`), matching `outline.rs` and the gutter marker candidates.
let range = idx.range(name_node.start_byte(), name_node.end_byte());
let bases = node.child_by_field_name("superclasses").map(|args| base_names(args, text)).unwrap_or_default();
out.push(ClassBases { name, line: range.start.line, col: range.start.col, bases });
});
out
}
/// Depth-first pre-order traversal of all descendants of `cursor`'s node.
fn visit(cursor: &mut tree_sitter::TreeCursor, f: &mut impl FnMut(Node)) {
loop {
f(cursor.node());
if cursor.goto_first_child() {
continue;
}
loop {
if cursor.goto_next_sibling() {
break;
}
if !cursor.goto_parent() {
return;
}
}
}
}
fn base_names(args: Node, text: &str) -> Vec<String> {
let mut out = Vec::new();
let mut cursor = args.walk();
for arg in args.named_children(&mut cursor) {
if let Some(name) = base_name(arg, text) {
out.push(name);
}
}
out
}
/// Bare name of one base expression, or `None` for keyword arguments and
/// expressions that are not a (possibly qualified) name.
fn base_name(arg: Node, text: &str) -> Option<String> {
match arg.kind() {
"identifier" => text.get(arg.byte_range()).map(str::to_owned),
// `pkg.mod.Base` -> `Base`.
"attribute" => {
let attr = arg.child_by_field_name("attribute")?;
(attr.kind() == "identifier").then(|| text.get(attr.byte_range()).unwrap_or_default().to_owned())
}
// `Generic[T]` -> `Generic`.
"subscript" => {
let value = arg.child_by_field_name("value")?;
(value.kind() == "identifier").then(|| text.get(value.byte_range()).unwrap_or_default().to_owned())
}
// `metaclass=Meta`, `**kwargs`, `*bases` and other expressions.
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn bases(src: &str) -> Vec<ClassBases> {
static S: std::sync::OnceLock<Syntax> = std::sync::OnceLock::new();
S.get_or_init(Syntax::new).class_bases(&LanguageId::new("python"), src)
}
fn shape(src: &str) -> Vec<(String, u32, u32, Vec<String>)> {
bases(src).into_iter().map(|c| (c.name, c.line, c.col, c.bases)).collect()
}
#[test]
fn no_bases() {
assert_eq!(shape("class A: pass"), vec![("A".to_owned(), 0, 6, vec![])]);
}
#[test]
fn multiple_bases_skips_metaclass() {
assert_eq!(
shape("class B(A, C, metaclass=Meta): pass"),
vec![("B".to_owned(), 0, 6, vec!["A".to_owned(), "C".to_owned()])]
);
}
#[test]
fn qualified_name_takes_last_segment() {
assert_eq!(shape("class D(pkg.mod.Base): pass"), vec![("D".to_owned(), 0, 6, vec!["Base".to_owned()])]);
}
#[test]
fn subscript_takes_value() {
assert_eq!(shape("class E(Generic[T]): pass"), vec![("E".to_owned(), 0, 6, vec!["Generic".to_owned()])]);
}
#[test]
fn multiline_bases() {
let src = "class F(\n A,\n B,\n):\n pass";
assert_eq!(shape(src), vec![("F".to_owned(), 0, 6, vec!["A".to_owned(), "B".to_owned()])]);
}
#[test]
fn nested_classes() {
let src = "class Outer:\n class Inner(Y):\n pass";
assert_eq!(
shape(src),
vec![("Outer".to_owned(), 0, 6, vec![]), ("Inner".to_owned(), 1, 10, vec!["Y".to_owned()]),]
);
}
#[test]
fn comments_and_strings_are_ignored() {
let src = r#"
# class Fake(Nope): pass
doc = """
class InDocstring(X): pass
"""
s = 'class InString(Y): pass'
class Real(Base): pass
"#;
assert_eq!(shape(src), vec![("Real".to_owned(), 6, 6, vec!["Base".to_owned()])]);
}
#[test]
fn other_languages_are_empty() {
static S: std::sync::OnceLock<Syntax> = std::sync::OnceLock::new();
let s = S.get_or_init(Syntax::new);
assert!(s.class_bases(&LanguageId::new("rust"), "struct A;").is_empty());
}
}
-2
Переглянути файл
@@ -6,7 +6,6 @@ mod external;
mod folding;
mod grammars;
mod highlight;
mod inheritance;
pub(crate) mod lines;
mod literals;
mod outline;
@@ -23,7 +22,6 @@ pub use external::{ExternalFold, ExternalSymbol, ExternalSyntax, ExternalToken,
use grammars::{SPECS, Spec};
pub use highlight::{SyntaxHighlightKind, SyntaxHighlights};
use ide_core::{LanguageId, OutlineItem, Pos, Range};
pub use inheritance::ClassBases;
pub use lines::{shift_pos, shift_range};
pub use literals::{Literal, LiteralKind, SpellingKind, SpellingRegion};
pub use outline::merge_visibility;
+1 -122
Переглянути файл
@@ -218,37 +218,6 @@ impl ClientHandler {
true
}
/// Ends every running task of a tsserver that died at its heap limit: the process
/// behind them is gone, so no `end` will ever arrive. Without this the tasks sit
/// as zombies until [`PROGRESS_STALE`] and the server stays "loading", holding
/// empty answers back for [`LOADING_WAIT`] — while vtsls already restarts
/// tsserver, which re-reports its own loading.
fn tsserver_died(&self, mgr: &LspManager) {
let events: Vec<IdeEvent> = {
let mut progress = self.server.progress.lock();
progress
.drain()
.map(|(_, p)| IdeEvent::Task {
id: p.task,
title: p.title,
message: p.message,
fraction: Some(1.0),
done: true,
cancellable: false,
})
.collect()
};
let ended = !events.is_empty();
for event in events {
self.server.emit(mgr.sink(), event);
}
if ended {
self.server.refresh_busy_status(mgr.sink());
self.server.progress_ended();
}
self.server.set_loading(mgr.sink(), false);
}
fn publish_diagnostics(&self, mgr: &LspManager, params: Value) {
// Problems come only from servers used for `diagnostics`.
if self.server.running_gen(self.generation).is_some_and(|run| !run.caps.read().restriction.diagnostics()) {
@@ -441,10 +410,6 @@ impl Handler for ClientHandler {
let text = params.get("message").and_then(Value::as_str).unwrap_or_default();
if self.server.config.tsserver_project && tsserver_ran_out_of_memory(text) {
tracing::warn!("[{}] {text}", self.server.name);
// The tasks of the dead tsserver never see their `end`: end them now
// instead of letting them sit as zombies until the stale window, and
// stop holding queries back — the restarted instance re-reports.
self.tsserver_died(&mgr);
if mgr.first_message(&self.server.name, TSSERVER_OUT_OF_MEMORY) {
mgr.sink().emit(IdeEvent::Notify {
level: NotifyLevel::Error,
@@ -602,12 +567,7 @@ fn merge(base: &mut Value, layer: Value) {
#[cfg(test)]
mod tests {
use super::*;
use crate::server::Progress;
use ide_core::event::SharedSink;
use ide_core::{LanguageId, TaskId};
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use super::tsserver_ran_out_of_memory;
#[test]
fn tsserver_aborts_are_out_of_memory() {
@@ -619,85 +579,4 @@ mod tests {
assert!(!tsserver_ran_out_of_memory("Code: 134."));
assert!(tsserver_ran_out_of_memory("[error] TSServer exited. Code: 134. Signal: null\n"));
}
/// A vtsls server with two running tasks, one marked stalled, and `loading` set:
/// the state a tsserver OOM leaves behind.
fn loaded_vtsls(events: Arc<Mutex<Vec<IdeEvent>>>) -> (Arc<LspManager>, Arc<crate::server::Server>) {
let sink: SharedSink = {
let events = events.clone();
Arc::new(move |event: IdeEvent| events.lock().unwrap().push(event))
};
let languages = Arc::new(ide_language::LanguageRegistry::from_configs(vec![]));
let mgr = LspManager::new(PathBuf::from("/p"), languages, sink);
let cfg: ide_language::LanguageServerConfig =
toml::from_str("name = \"vtsls\"\ncommand = \"vtsls\"\ntsserver-project = true").unwrap();
let server = Arc::new(crate::server::Server::new(cfg, PathBuf::from("/p"), LanguageId::new("vue")));
server.progress.lock().insert(
"loading".into(),
Progress {
task: TaskId::next(),
title: "Initializing 'tsconfig.json'".into(),
message: None,
cancel: None,
last: tokio::time::Instant::now(),
fraction: Some(0.1),
stalled: false,
},
);
server.progress.lock().insert(
"stuck".into(),
Progress {
task: TaskId::next(),
title: "Analyzing 'index.vue' and its dependencies".into(),
message: None,
cancel: None,
last: tokio::time::Instant::now(),
fraction: Some(0.2),
stalled: true,
},
);
let quiet: SharedSink = Arc::new(|_: IdeEvent| {});
server.set_loading(&quiet, true);
(mgr, server)
}
#[test]
fn tsserver_dying_ends_every_task_and_the_loading_state() {
let events = Arc::new(Mutex::new(Vec::new()));
let (mgr, server) = loaded_vtsls(events.clone());
let handler = ClientHandler::new(Arc::downgrade(&mgr), server.clone(), 0);
handler.tsserver_died(&mgr);
// Both tasks ended (done: true), including the stalled one; none left running.
let tasks: Vec<_> = events
.lock()
.unwrap()
.iter()
.filter_map(|e| match e {
IdeEvent::Task { title, done, .. } => Some((title.clone(), *done)),
_ => None,
})
.collect();
assert_eq!(tasks.len(), 2, "{tasks:?}");
assert!(tasks.iter().all(|(_, done)| *done), "{tasks:?}");
assert!(server.progress.lock().is_empty());
assert!(!server.is_loading());
assert!(!server.is_busy());
}
#[test]
fn tsserver_dying_without_tasks_only_clears_loading() {
let events = Arc::new(Mutex::new(Vec::new()));
let (mgr, server) = loaded_vtsls(events.clone());
server.progress.lock().clear();
let handler = ClientHandler::new(Arc::downgrade(&mgr), server.clone(), 0);
handler.tsserver_died(&mgr);
assert!(events.lock().unwrap().is_empty());
assert!(!server.is_loading());
// A status event may be published, but no task events.
assert!(server.progress.lock().is_empty());
}
}
+1 -2
Переглянути файл
@@ -482,8 +482,7 @@ mod tests {
std::fs::create_dir_all(output.join("test001")).unwrap();
// As jdtls writes it: the class's source file name in the path, the library's
// classpath attributes after the folder.
// JDT writes the folder as a portable path, `/` on Windows too, each `/` escaped.
let escaped = output.to_string_lossy().replace('\\', "/").replace('/', "\\/");
let escaped = output.to_string_lossy().replace('/', "\\/");
let handle = format!("=demo/{escaped}=/ai.openidea.kotlin=/true=/<test001(Db.class");
let uri = format!("jdt://contents/main-0/test001/Db.kt?{}", utf8_percent_encode(&handle, PATH));
assert_eq!(folder_class(&uri), Some(("test001".into(), "Db".into())));
+13 -47
Переглянути файл
@@ -40,37 +40,22 @@ const LOOSE: &AsciiSet = &CONTROLS
.add(b'|')
.add(b'}');
/// Fields of LSP messages whose value is a URI.
const URI_FIELDS: [&str; 9] =
["uri", "targetUri", "newUri", "oldUri", "baseUri", "scopeUri", "rootUri", "target", "href"];
/// Percent-encodes the characters URIs don't allow in the `file:` URIs of `value`: the URI
/// fields and the keys of a workspace edit's `changes`. Eclipse's jdtls sends a path as is
/// (`file:///H:/项目/…`), which `lsp_types::Uri` rejects, failing the whole response. Other
/// strings stay as sent: an edit's `newText` may well start with `file:` (YAML, `.env`).
/// Percent-encodes the characters URIs don't allow in every `file:` URI in `value`, string
/// values and object keys alike (a workspace edit's `changes`). Eclipse's jdtls sends a path
/// as is (`file:///H:/项目/…`), which `lsp_types::Uri` rejects, failing the whole response.
pub fn encode_loose_file_uris(value: &mut Value) {
match value {
Value::String(s) => {
if let Some(encoded) = encode_loose(s) {
*s = encoded;
}
}
Value::Array(items) => items.iter_mut().for_each(encode_loose_file_uris),
Value::Object(map) => {
for (key, value) in map.iter_mut() {
match value {
Value::String(s) if URI_FIELDS.contains(&key.as_str()) => {
if let Some(encoded) = encode_loose(s) {
*s = encoded;
}
}
Value::Object(changes) if key == "changes" => {
if changes.keys().any(|k| encode_loose(k).is_some()) {
*changes = std::mem::take(changes)
.into_iter()
.map(|(k, v)| (encode_loose(&k).unwrap_or(k), v))
.collect();
}
changes.values_mut().for_each(encode_loose_file_uris);
}
_ => encode_loose_file_uris(value),
}
if map.keys().any(|k| encode_loose(k).is_some()) {
*map = std::mem::take(map).into_iter().map(|(k, v)| (encode_loose(&k).unwrap_or(k), v)).collect();
}
map.values_mut().for_each(encode_loose_file_uris);
}
_ => {}
}
@@ -176,28 +161,9 @@ mod tests {
assert!(v["edit"]["changes"].get("file:///tmp/%E7%9B%AE%E5%BD%95/b.java").is_some());
assert_eq!(v["text"], "项目", "only file: URIs change");
// Already encoded: untouched.
let mut encoded = serde_json::json!({ "uri": "file:///a%20b/%E9%A1%B9.java" });
let mut encoded = serde_json::json!("file:///a%20b/%E9%A1%B9.java");
encode_loose_file_uris(&mut encoded);
assert_eq!(encoded["uri"], "file:///a%20b/%E9%A1%B9.java");
}
#[test]
fn text_that_starts_with_file_is_not_a_uri() {
// A YAML formatting edit and a completion whose text begins with `file:`.
let mut v = serde_json::json!({ "result": [
{ "range": { "start": { "line": 0, "character": 0 }, "end": { "line": 1, "character": 0 } }, "newText": "file: a b\n - c\n" },
{ "label": "file:", "insertText": "file: ${1:path}", "documentation": "file: the input" }
], "edit": { "changes": { "file:///tmp/a b.yaml": [{ "newText": "file: x y" }] } } });
encode_loose_file_uris(&mut v);
assert_eq!(v["result"][0]["newText"], "file: a b\n - c\n");
assert_eq!(v["result"][1]["insertText"], "file: ${1:path}");
assert_eq!(v["result"][1]["documentation"], "file: the input");
let changes = &v["edit"]["changes"]["file:///tmp/a%20b.yaml"];
assert_eq!(changes[0]["newText"], "file: x y");
// A diagnostic's help link is a URI too.
let mut d = serde_json::json!({ "codeDescription": { "href": "file:///tmp/help files/rule.html" } });
encode_loose_file_uris(&mut d);
assert_eq!(d["codeDescription"]["href"], "file:///tmp/help%20files/rule.html");
assert_eq!(encoded, "file:///a%20b/%E9%A1%B9.java");
}
#[cfg(unix)]
+1 -1
Переглянути файл
@@ -403,7 +403,7 @@ async fn download_into_copies_only_the_selected_items() {
assert!(std::fs::read_dir(&elsewhere).unwrap().next().is_none(), "nothing written through the links");
}
let missing = [server.join("gone").to_str().unwrap().replace('\\', "/")];
let missing = [server.join("gone").to_str().unwrap().to_owned()];
let report = service
.download_into(&FsConnection::Local, &missing, &project, &CancellationToken::new(), &mut |_, _, _| {})
.await
+4 -95
Переглянути файл
@@ -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
@@ -1830,17 +1814,10 @@ pub fn without_derived_in(config: &RunConfig, root: &Path, fallback: Option<&Pat
if path.is_absolute()
&& let Some(name) = path.file_name().map(|n| n.to_string_lossy().into_owned())
{
// Generated configurations name the bare program (`java`), also on Windows where the
// derived path ends in `java.exe`.
let bare =
name.len().checked_sub(4).filter(|&at| name[at..].eq_ignore_ascii_case(".exe")).map(|at| &name[..at]);
for candidate in bare.into_iter().chain([name.as_str()]) {
let mut probe = out.clone();
probe.command = candidate.to_owned();
if applied(&probe).command == config.command {
out.command = candidate.to_owned();
break;
}
let mut probe = out.clone();
probe.command = name.clone();
if applied(&probe).command == config.command {
out.command = name;
}
}
out
@@ -2127,74 +2104,6 @@ mod gradle_runtime_tests {
mod maven_linkage_tests {
use super::*;
#[test]
fn hybrid_vtsls_gets_one_server_a_full_heap_and_no_type_acquisition() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
// The Vue package the hybrid block finds through the command's package dir.
let bin = root.join("tools/node_modules/.bin/vue-language-server");
let pkg = root.join("tools/node_modules/@vue/language-server/package.json");
std::fs::create_dir_all(bin.parent().unwrap()).unwrap();
std::fs::create_dir_all(pkg.parent().unwrap()).unwrap();
std::fs::write(&bin, "#!/bin/sh\n").unwrap();
std::fs::write(&pkg, "{}").unwrap();
let mut s = Settings::default();
s.tools
.insert(vue::SERVER.into(), ide_core::settings::ToolSettings { path: bin.to_string_lossy().into_owned() });
std::fs::write(root.join("tsconfig.json"), "{}").unwrap();
let mut cfg = custom_server("vtsls", &LanguageServerSettings::default());
configure_server(&mut cfg, root, root, &s);
let options = serde_json::to_value(cfg.initialization_options).unwrap();
assert_eq!(options.pointer("/typescript/tsserver/useSyntaxServer"), Some(&json!("never")));
assert_eq!(options.pointer("/typescript/tsserver/maxTsServerMemory"), Some(&json!(8192)));
assert_eq!(options.pointer("/typescript/disableAutomaticTypeAcquisition"), Some(&json!(true)));
// A user-set limit wins over the automatic one.
s.languages.typescript.tsserver_max_memory = 6144;
let mut cfg = custom_server("vtsls", &LanguageServerSettings::default());
configure_server(&mut cfg, root, root, &s);
let options = serde_json::to_value(cfg.initialization_options).unwrap();
assert_eq!(options.pointer("/typescript/tsserver/maxTsServerMemory"), Some(&json!(6144)));
}
#[test]
fn hybrid_without_a_config_keeps_type_acquisition_and_plain_vtsls_stays_default() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let bin = root.join("tools/node_modules/.bin/vue-language-server");
let pkg = root.join("tools/node_modules/@vue/language-server/package.json");
std::fs::create_dir_all(bin.parent().unwrap()).unwrap();
std::fs::create_dir_all(pkg.parent().unwrap()).unwrap();
std::fs::write(&bin, "#!/bin/sh\n").unwrap();
std::fs::write(&pkg, "{}").unwrap();
let mut s = Settings::default();
s.tools
.insert(vue::SERVER.into(), ide_core::settings::ToolSettings { path: bin.to_string_lossy().into_owned() });
// No tsconfig/jsconfig anywhere: inferred projects still want the typings installer.
let mut cfg = custom_server("vtsls", &LanguageServerSettings::default());
configure_server(&mut cfg, root, root, &s);
let options = serde_json::to_value(cfg.initialization_options).unwrap();
assert!(options.pointer("/typescript/disableAutomaticTypeAcquisition").is_none());
assert_eq!(options.pointer("/typescript/tsserver/maxTsServerMemory"), Some(&json!(8192)));
// Without the Vue package there is no hybrid mode: nothing is injected. The
// command points at a bare binary, so the package lookup finds nothing
// (a bare name would resolve on the host's PATH and break the test).
std::fs::write(root.join("bare-vue-language-server"), "#!/bin/sh\n").unwrap();
s.tools
.insert(vue::SERVER.into(), ide_core::settings::ToolSettings { path: "bare-vue-language-server".into() });
let mut cfg = custom_server("vtsls", &LanguageServerSettings::default());
configure_server(&mut cfg, root, root, &s);
let options = serde_json::to_value(cfg.initialization_options).unwrap();
assert!(options.pointer("/typescript/tsserver/useSyntaxServer").is_none());
assert!(options.pointer("/typescript/tsserver/maxTsServerMemory").is_none());
// A user-set limit applies to plain TypeScript projects too.
s.languages.typescript.tsserver_max_memory = 12288;
let mut cfg = custom_server("vtsls", &LanguageServerSettings::default());
configure_server(&mut cfg, root, root, &s);
let options = serde_json::to_value(cfg.initialization_options).unwrap();
assert_eq!(options.pointer("/typescript/tsserver/maxTsServerMemory"), Some(&json!(12288)));
assert!(options.pointer("/typescript/tsserver/useSyntaxServer").is_none());
}
#[test]
fn default_maven_repository_reads_the_user_settings() {
let tmp = tempfile::tempdir().unwrap();
+2 -15
Переглянути файл
@@ -884,12 +884,6 @@ impl Repo {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
write::blame(self, path, current_text, revision, encoding)
}
/// Adds `commit` to the list `blame.ignoreRevsFile` names, so annotate skips it; with none
/// configured, to `.git-blame-ignore-revs` at the worktree root, which the local config then names.
pub fn ignore_blame_revision(&self, commit: &str) -> IdeResult<()> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
write::ignore_blame_revision(self, commit)
}
/// History for Selection: commits that changed `lines` (0-based, inclusive, of the
/// current text: `current_text`, or the file on disk), mapped to HEAD's lines first.
pub fn block_history(
@@ -1268,17 +1262,10 @@ impl Repo {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
write::rebase(self, onto)
}
/// `paths` (absolute) stashes only those files; empty stashes the whole working tree.
/// `keep_index`: changes already staged stay in the index (and the working tree).
pub fn stash_push(
&self,
message: Option<&str>,
include_untracked: bool,
keep_index: bool,
paths: &[PathBuf],
) -> IdeResult<()> {
pub fn stash_push(&self, message: Option<&str>, include_untracked: bool, keep_index: bool) -> IdeResult<()> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
write::stash_push(self, message, include_untracked, keep_index, paths)
write::stash_push(self, message, include_untracked, keep_index)
}
/// `reinstate_index`: also restore what was staged when the stash was made.
pub fn stash_apply(&self, index: u32, pop: bool, reinstate_index: bool) -> IdeResult<()> {
+15 -124
Переглянути файл
@@ -43,70 +43,6 @@ fn run_paths(r: &Repo, args: &[&str], paths: &[String]) -> IdeResult<()> {
cli::run_paths(r.root(), &base, paths).map(|_| ())
}
/// The annotate ignore list at the worktree root: git's conventional `blame.ignoreRevsFile`.
pub const BLAME_IGNORE_REVS_FILE: &str = ".git-blame-ignore-revs";
/// The file `blame.ignoreRevsFile` names, resolved from the worktree root as git does (it may not
/// exist). The value comes from the gitoxide config snapshot, which Restricted Mode still parses,
/// so resolving it starts no program.
fn configured_ignore_revs(r: &Repo) -> Option<PathBuf> {
let repo = r.gix().ok()?;
let configured = repo.config_snapshot().string("blame.ignoreRevsFile")?;
let configured = configured.to_str_lossy();
let configured = configured.trim();
if configured.is_empty() {
return None;
}
let path = Path::new(configured);
Some(if path.is_absolute() { path.to_path_buf() } else { r.root().join(path) })
}
/// Arguments for the file `blame.ignoreRevsFile` names, which git does not accept as an
/// absent file: with a value configured but missing from disk, `--no-ignore-revs-file`
/// clears it so annotate still works.
fn ignore_revs_args(r: &Repo) -> Vec<String> {
match configured_ignore_revs(r) {
Some(path) if path.is_file() => vec!["--ignore-revs-file".to_string(), path.to_string_lossy().into_owned()],
Some(_) => vec!["--no-ignore-revs-file".to_string()],
None => Vec::new(),
}
}
/// `existing` (an ignore list's text) with `commit` appended: lines keep their text and order, a
/// listed commit isn't repeated, and the result ends with a newline in the list's line ending.
fn append_revision(existing: &str, commit: &str) -> String {
let revision = commit.trim();
let newline = if existing.contains("\r\n") { "\r\n" } else { "\n" };
let mut lines: Vec<&str> = existing.lines().collect();
if !revision.is_empty() && !lines.iter().any(|line| line.trim() == revision) {
lines.push(revision);
}
if lines.is_empty() { String::new() } else { lines.join(newline) + newline }
}
/// Adds `commit` to the list `blame.ignoreRevsFile` names; with none configured, to
/// `.git-blame-ignore-revs` at the worktree root, which the local config then names.
pub(crate) fn ignore_blame_revision(r: &Repo, commit: &str) -> IdeResult<()> {
let configured = configured_ignore_revs(r);
let path = configured.clone().unwrap_or_else(|| r.root().join(BLAME_IGNORE_REVS_FILE));
let existing = match std::fs::read_to_string(&path) {
Ok(text) => text,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(e) => return Err(e.into()),
};
let updated = append_revision(&existing, commit);
if updated != existing {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
std::fs::write(&path, updated)?;
}
if configured.is_none() {
cli::run(r.root(), &["config", "--local", "blame.ignoreRevsFile", BLAME_IGNORE_REVS_FILE], None)?;
}
Ok(())
}
pub(crate) fn blame(
r: &Repo,
path: &Path,
@@ -125,9 +61,8 @@ pub(crate) fn blame(
Err(error) => return Err(error.into()),
};
let text = current_text.map(str::to_owned).unwrap_or_else(|| decode(&disk, encoding).into_owned());
let ignore = ignore_revs_args(r);
if encoding.has_ascii_conversion() {
return converted_blame(r, path, &rel, &text, revision, encoding, &ignore);
return converted_blame(r, path, &rel, &text, revision, encoding);
}
let contents = match current_text {
Some(_) if crate::diff::normalize_eol(&text) == crate::diff::normalize_eol(&decode(&disk, encoding)) => disk,
@@ -159,32 +94,23 @@ pub(crate) fn blame(
// older releases reject the pair (`fatal: cannot use --contents with final
// commit object name`) and take the same plain path — the edited lines are
// marked uncommitted below either way (issue #755).
let contents_args = {
let mut args = vec!["--literal-pathspecs", "blame", "--porcelain"];
args.extend(ignore.iter().map(String::as_str));
args.extend(["--contents", "-", revision, "--", &rel]);
args
};
let plain_args: Vec<&str> = {
let mut args = vec!["--literal-pathspecs", "blame"];
if !crate::execution_allowed() {
args.push("--no-textconv");
}
args.extend(ignore.iter().map(String::as_str));
args.extend(["--porcelain", revision, "--", &rel]);
args
let contents_args = ["--literal-pathspecs", "blame", "--porcelain", "--contents", "-", revision, "--", &rel];
let plain_args: &[&str] = if crate::execution_allowed() {
&["--literal-pathspecs", "blame", "--porcelain", revision, "--", &rel]
} else {
&["--literal-pathspecs", "blame", "--no-textconv", "--porcelain", revision, "--", &rel]
};
let (out, contents_blame) = if crate::execution_allowed() {
let out = cli::run_raw(r.root(), &contents_args, Some(&contents))?;
if out.code == Some(0) {
(out, true)
} else if String::from_utf8_lossy(&out.stderr).contains("cannot use --contents") {
(run(r.root(), &plain_args, None)?, false)
(run(r.root(), plain_args, None)?, false)
} else {
return Err(cli::failure(&contents_args, &out.stderr, &out.stdout, out.code));
}
} else {
(run(r.root(), &plain_args, None)?, false)
(run(r.root(), plain_args, None)?, false)
};
let mut lines = crate::blame::parse_porcelain(&out.stdout_str());
for line in &mut lines {
@@ -216,21 +142,16 @@ fn converted_blame(
text: &str,
revision: &str,
encoding: TextEncoding,
ignore: &[String],
) -> IdeResult<Vec<BlameLine>> {
let current = crate::diff::normalize_eol(text);
let mut result = uncommitted_lines(current.split_inclusive('\n').count());
let Some(base) = r.file_bytes_at(path, Some(revision))? else { return Ok(result) };
let args: Vec<&str> = {
let mut args = vec!["--literal-pathspecs", "blame"];
if !crate::execution_allowed() {
args.push("--no-textconv");
}
args.extend(ignore.iter().map(String::as_str));
args.extend(["--porcelain", revision, "--", rel]);
args
let args: &[&str] = if crate::execution_allowed() {
&["--literal-pathspecs", "blame", "--porcelain", revision, "--", rel]
} else {
&["--literal-pathspecs", "blame", "--no-textconv", "--porcelain", revision, "--", rel]
};
let out = run(r.root(), &args, None)?;
let out = run(r.root(), args, None)?;
let physical = crate::blame::parse_porcelain(&out.stdout_str());
let raw = crate::text_encoding::decode_raw(&base, encoding);
let raw = crate::diff::normalize_eol(&raw);
@@ -1128,18 +1049,8 @@ pub(crate) fn rebase(r: &Repo, onto: &str) -> IdeResult<()> {
})
}
pub(crate) fn stash_push(
r: &Repo,
message: Option<&str>,
include_untracked: bool,
keep_index: bool,
paths: &[PathBuf],
) -> IdeResult<()> {
let rel = r.rel_paths(paths)?;
// Literal pathspecs only for the chosen files: git's whole-tree stash passes itself `:/`,
// which a literal pathspec turns into a file of that name.
let mut args = if rel.is_empty() { vec![] } else { vec!["--literal-pathspecs"] };
args.extend(["stash", "push", "-q"]);
pub(crate) fn stash_push(r: &Repo, message: Option<&str>, include_untracked: bool, keep_index: bool) -> IdeResult<()> {
let mut args = vec!["stash", "push", "-q"];
if include_untracked {
args.push("--include-untracked");
}
@@ -1149,10 +1060,6 @@ pub(crate) fn stash_push(
if let Some(m) = message.filter(|m| !m.trim().is_empty()) {
args.extend(["-m", m]);
}
if !rel.is_empty() {
args.push("--");
args.extend(rel.iter().map(String::as_str));
}
run(r.root(), &args, None).map(|_| ())
}
@@ -1476,19 +1383,3 @@ fn decode_diff(bytes: &[u8], encoding: TextEncoding) -> String {
}
text
}
#[cfg(test)]
mod tests {
use super::append_revision;
#[test]
fn a_revision_is_appended_once_keeping_the_list() {
let (a, b) = ("a".repeat(40), "b".repeat(40));
assert_eq!(append_revision("", &b), format!("{b}\n"));
assert_eq!(append_revision(&format!("{a}\n# comment\n"), &b), format!("{a}\n# comment\n{b}\n"));
assert_eq!(append_revision(&a, &b), format!("{a}\n{b}\n"));
let listed = format!("# reformat\n {b} \n{a}\n");
assert_eq!(append_revision(&listed, &b), listed);
assert_eq!(append_revision(&format!("{a}\r\n"), &b), format!("{a}\r\n{b}\r\n"));
}
}
-131
Переглянути файл
@@ -1,131 +0,0 @@
//! Annotate skips the commits listed in `blame.ignoreRevsFile`.
use ide_vcs::Repo;
use std::path::{Path, PathBuf};
use std::process::Command;
struct Fixture {
_tmp: tempfile::TempDir,
dir: PathBuf,
}
fn git_in(dir: &Path, args: &[&str]) -> String {
let out = Command::new("git")
.current_dir(dir)
.args(["-c", "init.defaultBranch=main", "-c", "core.quotepath=off"])
.args(args)
.env("GIT_TERMINAL_PROMPT", "0")
.output()
.expect("git runs");
assert!(out.status.success(), "git {args:?} failed: {}", String::from_utf8_lossy(&out.stderr));
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
impl Fixture {
fn new() -> Self {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("work");
std::fs::create_dir(&dir).unwrap();
git_in(&dir, &["init", "-q"]);
for (k, v) in [
("user.name", "Test User"),
("user.email", "[email protected]"),
("commit.gpgsign", "false"),
("core.autocrlf", "false"),
] {
git_in(&dir, &["config", k, v]);
}
Fixture { _tmp: tmp, dir }
}
fn git(&self, args: &[&str]) -> String {
git_in(&self.dir, args)
}
fn write(&self, rel: &str, content: &str) {
std::fs::write(self.dir.join(rel), content).unwrap();
}
fn commit_all(&self, msg: &str) -> String {
self.git(&["add", "-A"]);
self.git(&["commit", "-q", "-m", msg]);
self.git(&["rev-parse", "HEAD"])
}
fn path(&self, rel: &str) -> PathBuf {
self.dir.join(rel)
}
/// The repository of a trusted project.
fn repo(&self) -> Repo {
let repo = Repo::discover(&self.dir).unwrap().expect("repository found");
repo.set_execution_allowed(true);
repo
}
/// The repository of a project in Restricted Mode.
fn restricted(&self) -> Repo {
Repo::discover(&self.dir).unwrap().expect("repository found")
}
}
/// A line added by `first` and reformatted by `second`; annotate attributes it to
/// `second` until `second` is ignored, then back to `first`.
fn reformatted_file() -> (Fixture, String, String) {
let fx = Fixture::new();
fx.write("file.txt", "first\nsecond\n");
let first = fx.commit_all("add file");
fx.write("file.txt", "first\n second\n");
let second = fx.commit_all("reformat");
(fx, first, second)
}
fn commits(repo: &Repo, path: &Path) -> Vec<String> {
repo.blame(path, None, None, encoding_rs::UTF_8).unwrap().into_iter().map(|line| line.commit).collect()
}
#[test]
fn a_listed_commit_gives_its_lines_back_to_the_earlier_one() {
let (fx, first, second) = reformatted_file();
let file = fx.path("file.txt");
assert_eq!(commits(&fx.repo(), &file), vec![first.clone(), second.clone()]);
fx.write(".git-blame-ignore-revs", &format!("{second}\n"));
fx.git(&["config", "blame.ignoreRevsFile", ".git-blame-ignore-revs"]);
let ignored = vec![first.clone(), first.clone()];
assert_eq!(commits(&fx.repo(), &file), ignored);
// Restricted Mode reads the same configuration and skips the same commit.
assert_eq!(commits(&fx.restricted(), &file), ignored);
}
#[test]
fn a_configured_file_that_does_not_exist_is_skipped() {
let (fx, _, second) = reformatted_file();
fx.git(&["config", "blame.ignoreRevsFile", ".git-blame-ignore-revs"]);
let file = fx.path("file.txt");
// The missing list is passed to no blame, so annotate still works.
assert_eq!(commits(&fx.repo(), &file)[1], second);
assert_eq!(commits(&fx.restricted(), &file)[1], second);
}
#[test]
fn ignoring_a_commit_starts_the_list_and_configures_it_once() {
let (fx, first, second) = reformatted_file();
fx.repo().ignore_blame_revision(&second).unwrap();
fx.repo().ignore_blame_revision(&second).unwrap();
assert_eq!(std::fs::read_to_string(fx.path(".git-blame-ignore-revs")).unwrap(), format!("{second}\n"));
assert_eq!(fx.git(&["config", "--local", "--get", "blame.ignoreRevsFile"]), ".git-blame-ignore-revs");
let repo = fx.repo();
assert_eq!(commits(&repo, &fx.path("file.txt")), vec![first.clone(), first.clone()]);
}
#[test]
fn ignoring_a_commit_adds_it_to_the_configured_list() {
let (fx, first, second) = reformatted_file();
fx.write("custom-revs", &format!("# formatting\r\n{first}\r\n"));
fx.git(&["config", "--local", "blame.ignoreRevsFile", "custom-revs"]);
fx.repo().ignore_blame_revision(&second).unwrap();
// Lines and line endings kept; the configured file is the one annotate reads.
assert_eq!(
std::fs::read_to_string(fx.path("custom-revs")).unwrap(),
format!("# formatting\r\n{first}\r\n{second}\r\n")
);
assert!(!fx.path(".git-blame-ignore-revs").exists());
assert_eq!(fx.git(&["config", "--local", "--get", "blame.ignoreRevsFile"]), "custom-revs");
let blamed = commits(&fx.repo(), &fx.path("file.txt"));
assert_ne!(blamed[1], second);
}
+3 -3
Переглянути файл
@@ -769,11 +769,11 @@ fn stash_push_list_apply_pop() {
let repo = fx.repo();
fx.write("s.txt", "change 1\n");
repo.stash_push(Some("first stash"), false, false, &[]).unwrap();
repo.stash_push(Some("first stash"), false, false).unwrap();
assert_eq!(fx.read("s.txt"), "base\n");
fx.write("s.txt", "change 2\n");
fx.write("u.txt", "untracked\n");
repo.stash_push(None, true, false, &[]).unwrap();
repo.stash_push(None, true, false).unwrap();
assert!(!fx.path("u.txt").exists());
let stashes = repo.stashes().unwrap();
@@ -808,7 +808,7 @@ fn stash_keep_index_reinstate_index_and_branch() {
fx.write("a.txt", "a staged\n");
fx.git(&["add", "a.txt"]);
fx.write("b.txt", "b unstaged\n");
repo.stash_push(Some("keep"), false, true, &[]).unwrap();
repo.stash_push(Some("keep"), false, true).unwrap();
// Keep index: the staged change stays, the unstaged one is stashed.
assert_eq!(fx.read("a.txt"), "a staged\n");
assert_eq!(fx.read("b.txt"), "b\n");
-124
Переглянути файл
@@ -1,124 +0,0 @@
//! Stashing only the selected files.
use ide_core::error::ErrorKind;
use ide_vcs::Repo;
use std::path::{Path, PathBuf};
use std::process::Command;
struct Fixture {
_tmp: tempfile::TempDir,
dir: PathBuf,
}
fn git_in(dir: &Path, args: &[&str]) -> String {
let out = Command::new("git")
.current_dir(dir)
.args(["-c", "init.defaultBranch=main", "-c", "core.quotepath=off"])
.args(args)
.env("GIT_TERMINAL_PROMPT", "0")
.output()
.expect("git runs");
assert!(out.status.success(), "git {args:?} failed: {}", String::from_utf8_lossy(&out.stderr));
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
fn configure(dir: &Path) {
for (k, v) in [
("user.name", "Test User"),
("user.email", "[email protected]"),
("commit.gpgsign", "false"),
("core.autocrlf", "false"),
] {
git_in(dir, &["config", k, v]);
}
}
impl Fixture {
fn new() -> Self {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("work");
std::fs::create_dir(&dir).unwrap();
git_in(&dir, &["init", "-q"]);
configure(&dir);
Fixture { _tmp: tmp, dir }
}
fn git(&self, args: &[&str]) -> String {
git_in(&self.dir, args)
}
fn write(&self, rel: &str, content: &str) {
std::fs::write(self.dir.join(rel), content).unwrap();
}
fn read(&self, rel: &str) -> String {
std::fs::read_to_string(self.dir.join(rel)).unwrap()
}
fn commit_all(&self, msg: &str) {
self.git(&["add", "-A"]);
self.git(&["commit", "-q", "-m", msg]);
}
fn repo(&self) -> Repo {
let repo = Repo::discover(&self.dir).unwrap().expect("repository found");
repo.set_execution_allowed(true);
repo
}
fn path(&self, rel: &str) -> PathBuf {
self.dir.join(rel)
}
}
#[test]
fn stash_push_stashes_only_the_given_paths() {
let fx = Fixture::new();
fx.write("a.txt", "a\n");
fx.write("b.txt", "b\n");
fx.commit_all("base");
let repo = fx.repo();
fx.write("a.txt", "a changed\n");
fx.write("b.txt", "b changed\n");
repo.stash_push(Some("only a"), false, false, &[fx.path("a.txt")]).unwrap();
assert_eq!(fx.read("a.txt"), "a\n");
assert_eq!(fx.read("b.txt"), "b changed\n");
assert_eq!(repo.stashes().unwrap().len(), 1);
repo.stash_apply(0, true, false).unwrap();
assert_eq!(fx.read("a.txt"), "a changed\n");
assert_eq!(fx.read("b.txt"), "b changed\n");
assert!(repo.stashes().unwrap().is_empty());
}
#[test]
fn stash_push_stashes_only_the_given_untracked_paths() {
let fx = Fixture::new();
fx.write("a.txt", "a\n");
fx.commit_all("base");
let repo = fx.repo();
fx.write("new.txt", "new\n");
fx.write("other.txt", "other\n");
repo.stash_push(None, true, false, &[fx.path("new.txt")]).unwrap();
assert!(!fx.path("new.txt").exists());
assert!(fx.path("other.txt").exists());
assert_eq!(repo.stashes().unwrap().len(), 1);
repo.stash_apply(0, true, false).unwrap();
assert_eq!(fx.read("new.txt"), "new\n");
}
#[test]
fn stash_push_rejects_paths_outside_the_repository() {
let fx = Fixture::new();
fx.write("a.txt", "a\n");
fx.commit_all("base");
let repo = fx.repo();
fx.write("a.txt", "a changed\n");
let outside = tempfile::tempdir().unwrap();
std::fs::write(outside.path().join("outside.txt"), "outside\n").unwrap();
let error = repo.stash_push(None, false, false, &[outside.path().join("outside.txt")]).unwrap_err();
assert_eq!(error.kind, ErrorKind::InvalidInput);
assert!(error.message.contains("outside the repository"), "{error:?}");
// Nothing was stashed and the change is still in the working tree.
assert!(repo.stashes().unwrap().is_empty());
assert_eq!(fx.read("a.txt"), "a changed\n");
}
-2
Переглянути файл
@@ -67,8 +67,6 @@ The task tab shows the conversation: your messages, the agent's replies, its rea
On your own messages you can **Edit** (the messages after it are replaced; files already changed stay as they are) or **Retract Message**. On replies you can **Copy Markdown**, **Quote Reply**, **Regenerate Response** and **Branch Conversation from Here**, which starts a new task from that point.
Right-click a link and choose **Copy Link Address** to copy its URL; **Copy** copies the selected text.
### Permissions
When an agent wants to run a command or call a tool that needs approval, the task shows the request and is listed as **Needs Input**. Choose one of the options the agent offers, such as **Allow Once**, **Allow for This Session**, **Always Allow**, **Decline**, **Always Decline** or **Decline and Stop**. Many agents also have a mode picker that approves edits or everything automatically; use it with care.
-2
Переглянути файл
@@ -107,8 +107,6 @@ To format on every save, enable **Reformat code** (and optionally **Optimize imp
Tab placement, the tab limit and what happens when it is reached, the preview tab, sorting, and which tab is activated after one closes are set in **Settings › Editor › General › Editor Tabs**.
With tabs placed left or right, the tab column is as wide as its longest name, up to a limit. Drag the column's inner edge (or focus it and press `←` / `→`) to change the limit; it applies to every editor group and is remembered.
## Split editors
| Action | macOS | Windows / Linux |
+2 -2
Переглянути файл
@@ -75,7 +75,7 @@ Pick **VS Code (macOS)** or **VS Code (Windows)** as the keymap; then `⇧⌘P`
| Extensions | **Settings › Plugins** |
| `settings.json` | **Settings** (`⌘,` / `Ctrl+Alt+S`), stored in `settings.toml`; per-project values in `.openidea/settings.toml` |
| `launch.json` | Run configurations: **Import Run Configurations from .vscode/launch.json** |
| Multi-root workspace | **File › New › Workspace…** and **Attach Project…** |
| Multi-root workspace | **File › New Workspace…** and **Attach Project…** |
`.editorconfig` files are applied. VS Code settings and extensions are not imported.
@@ -84,7 +84,7 @@ Pick **VS Code (macOS)** or **VS Code (Windows)** as the keymap; then `⇧⌘P`
Pick **Eclipse (macOS)** or **Eclipse** as the keymap.
- **Formatter.** To keep using an Eclipse formatter profile for Java, open **Settings › Editor › Code Style › Java**, set **Formatting profile** to **Custom Eclipse formatter XML** and choose the exported XML file.
- **Workspaces and projects.** Eclipse opens many projects in one workspace. OpenIDEA opens one project per window; to work on several folders together, create a workspace with **File › New › Workspace…** or add folders with **Attach Project…**.
- **Workspaces and projects.** Eclipse opens many projects in one workspace. OpenIDEA opens one project per window; to work on several folders together, create a workspace with **File › New Workspace…** or add folders with **Attach Project…**.
- **Perspectives.** OpenIDEA has no perspectives. Arrange tool windows the way you like and save the arrangement with **Window › Tool Window Layouts › Save Current Layout as…**.
## Concepts at a glance
+1 -1
Переглянути файл
@@ -13,7 +13,7 @@ A scratch file is a throwaway file in any language: a place to try a snippet, ke
<Steps>
<Step title="Create one">
Choose **File › New › Scratch File** (or **Scratch File** at the top of the Project view's **New** menu).
Choose **File › New Scratch File…** (or **Scratch File** at the top of the Project view's **New** menu).
</Step>
<Step title="Pick a language">
Type to filter the language list and press `↵` / `Enter`.
+1 -1
Переглянути файл
@@ -73,7 +73,7 @@ The page also has **Expand abbreviations with Tab** and **Show live templates in
Live templates, postfix settings and macros are stored per user on this machine, not in the project.
For new-file templates (what **File › New › File…** creates), see **Settings › Editor › File and Code Templates**.
For new-file templates (what **File › New…** creates), see **Settings › Editor › File and Code Templates**.
## Macros
+2 -6
Переглянути файл
@@ -130,8 +130,6 @@ Other ways to compare:
Click the branch widget in the title bar (or **Git › Branches…**) to open the branches popup. Type to search branches and actions. The popup shows **Recent**, **Local**, **Remote** and **Tags**, and at the top **Update Project…**, **Commit…**, **Push…**, **New Branch…** and **Checkout Tag or Revision…**. The gear button toggles grouping by prefix, recent branches and tags.
Click the incoming or outgoing marker next to the branch name in the title bar (or **Incoming and Outgoing Commits…** in the branches popup) to list the commits to pull or push, newest first. Click a commit for its diff, or **Open in Git Log** to show it in the Git log.
Select a branch (`→` or `Enter`) for its actions:
- **Checkout**, **New Branch from '…'…**, **Checkout and Rebase onto '…'**
@@ -196,7 +194,7 @@ Limits: interactive rebase doesn't support ranges with merge commits, and **Undo
**Stash** uses `git stash`:
- **Stash Changes…**: a message, **Keep index** and **Include untracked files**; it stashes the whole working tree. **Stash Selected Files…** in the changes tree's context menu stashes only the selected files.
- **Stash Changes…**: a message, **Keep index** and **Include untracked files**. Stashes always include all changes; there is no per-file stash.
- **Unstash Changes…**: apply or pop a stash, reinstate the index, or create a branch from it.
- The **Stash** tab of the Commit tool window lists stashes with apply, pop and drop.
@@ -219,8 +217,6 @@ Turn on **Combine stashes and shelves in one tab** in the Git settings to show b
- **Show History for Selection** shows the commits that changed the selected lines.
- **Show Current Revision** reveals the commit of the caret line in the log.
Annotate skips the commits listed in the file `blame.ignoreRevsFile` names — git's convention is `.git-blame-ignore-revs` at the repository root — so a reformatting or mass-rename commit does not claim every line. **Ignore This Commit** in an annotated line's menu adds the current line's commit to that file and refreshes the annotations.
### Local history
Local history keeps snapshots of your files independent of Git, including deleted files and labelled states such as the one taken before a refactoring.
@@ -250,5 +246,5 @@ Retention is set in the **Local History** section of **Settings › Appearance &
- Mercurial, Subversion, Perforce and other systems.
- Submodule commands and editable directory mappings.
- Per-line partial commits (block level only).
- Per-line partial commits (block level only) and per-file stashes.
- Git LFS beyond the large-file warning.
+2 -2
Переглянути файл
@@ -11,7 +11,7 @@ This page covers how a plugin works with files:
projects.
- **Other files** (`fs.wit`, API 1.3): files outside the projects, under explicit grants,
and the plugin's own private directory.
- **File templates**: entries of the **File › New › File…** menu, no code needed.
- **File templates**: entries of the **File › New…** menu, no code needed.
## File actions
@@ -180,7 +180,7 @@ as an argument to an approved process.
## File templates
`[[contributes.fileTemplates]]` adds entries to the **File › New › File…** menu. They need no code and grant
`[[contributes.fileTemplates]]` adds entries to the **File › New…** menu. They need no code and grant
no file access: files are created only when the user invokes a template, through the IDE's
normal New File flow.
-2
Переглянути файл
@@ -67,8 +67,6 @@ OpenIDEA 本身不带模型或账号:它启动你已安装并登录的 agent
在你自己的消息上可以 **编辑**(其后的消息会被替换,已经改动的文件保持不变)或 **撤回消息**。在回复上可以 **复制 Markdown**、**引用回复**、**重新生成回复**,以及 **从此处创建分支对话**,从这一点开始一个新任务。
右键点击链接并选择 **复制链接地址** 可复制它的 URL;**复制** 复制选中的文字。
### 权限
agent 要运行命令或调用需要授权的工具时,任务中会显示请求,并归入 **等待输入**。从 agent 提供的选项中选择,例如 **允许一次**、**在此会话中允许**、**始终允许**、**拒绝**、**始终拒绝** 或 **拒绝并停止**。很多 agent 还有模式选择器,可以自动批准编辑甚至所有操作,请谨慎使用。
-2
Переглянути файл
@@ -107,8 +107,6 @@ description: "多光标与选区、行编辑、剪贴板历史、代码折叠、
标签页的位置、数量上限以及达到上限后的处理方式、预览标签页、排序方式,以及关闭标签页后激活哪一个,都在 **设置 › 编辑器 › 常规 › 编辑器标签页** 中设置。
标签页放在左侧或右侧时,标签页列的宽度随最长的名称变化,但不超过一个上限。拖动该列靠近编辑器的边缘(或聚焦后按 `←` / `→`)可以调整上限;调整对所有编辑器组生效,并会被记住。
## 拆分编辑器
| 操作 | macOS | Windows / Linux |
+2 -2
Переглянути файл
@@ -75,7 +75,7 @@ Vim 模式会读取 `~/.ideavimrc`(或 `~/.openideavimrc`)。通过 **工具
| 扩展 | **设置 › 插件** |
| `settings.json` | **设置**(`⌘,` / `Ctrl+Alt+S`),保存在 `settings.toml`;项目级的值保存在 `.openidea/settings.toml` |
| `launch.json` | 运行配置:**从 .vscode/launch.json 导入运行配置** |
| 多根工作区 | **文件 › 新建 › 工作区…** 和 **附加项目…** |
| 多根工作区 | **文件 › 新建工作区…** 和 **附加项目…** |
项目中的 `.editorconfig` 会生效。VS Code 的设置和扩展不会导入。
@@ -84,7 +84,7 @@ Vim 模式会读取 `~/.ideavimrc`(或 `~/.openideavimrc`)。通过 **工具
把快捷键方案切换为 **Eclipse (macOS)** 或 **Eclipse**。
- **格式化器。** 想继续用 Eclipse 的 Java 格式化配置,打开 **设置 › 编辑器 › 代码样式 › Java**,把 **格式化方案** 设为 **自定义 Eclipse 格式化 XML**,然后选择导出的 XML 文件。
- **工作空间与项目。** Eclipse 在一个工作空间里打开多个项目;OpenIDEA 每个窗口打开一个项目。需要同时处理多个文件夹时,用 **文件 › 新建 › 工作区…** 创建工作区,或用 **附加项目…** 添加文件夹。
- **工作空间与项目。** Eclipse 在一个工作空间里打开多个项目;OpenIDEA 每个窗口打开一个项目。需要同时处理多个文件夹时,用 **文件 › 新建工作区…** 创建工作区,或用 **附加项目…** 添加文件夹。
- **透视图。** OpenIDEA 没有透视图。按自己的习惯摆放工具窗口,再用 **窗口 › 工具窗口布局 › 将当前布局另存为…** 保存。
## 概念对照
+1 -1
Переглянути файл
@@ -13,7 +13,7 @@ description: "在项目之外保留临时文件,并在 TODO 工具窗口中汇
<Steps>
<Step title="新建">
选择 **文件 › 新建 › 临时文件**(或项目视图 **新建** 菜单最上面的 **临时文件**)。
选择 **文件 › 新建临时文件…**(或项目视图 **新建** 菜单最上面的 **临时文件**)。
</Step>
<Step title="选择语言">
输入文字过滤语言列表,按 `↵` / `Enter` 确认。
+1 -1
Переглянути файл
@@ -67,7 +67,7 @@ items.for → for (var item : items) { … }
实时模板、后缀补全设置和宏都按用户保存在本机,不保存在项目里。
新建文件用的模板(**文件 › 新建 › 文件…** 创建的内容)在 **设置 › 编辑器 › 文件和代码模板** 中设置。
新建文件用的模板(**文件 › 新建…** 创建的内容)在 **设置 › 编辑器 › 文件和代码模板** 中设置。
## 宏
+2 -6
Переглянути файл
@@ -130,8 +130,6 @@ OpenIDEA 会找到包含项目的仓库,以及向下最多四层的嵌套仓
点击标题栏的分支微件(或 **Git › 分支…**)打开分支弹窗,输入文字即可搜索分支和操作。弹窗中有 **最近**、**本地**、**远程** 和 **标签** 分组,顶部有 **更新项目…**、**提交…**、**推送…**、**新建分支…** 和 **签出标签或修订…**。齿轮按钮可以切换按前缀分组、显示最近分支和显示标签。
点击标题栏分支名旁的传入或传出标记(或分支弹窗中的 **传入和传出的提交…**)即可列出待拉取或待推送的提交(最新的在前)。点击某个提交查看其差异,或点 **在 Git 日志中打开** 在 Git 日志中显示它。
选中分支(`→` 或 `Enter`)查看它的操作:
- **签出**、**从 '…' 新建分支…**、**签出并变基到 '…'**
@@ -196,7 +194,7 @@ Git 工具窗口的 **日志** 标签页显示提交图。
**贮藏** 使用 `git stash`:
- **贮藏更改…**:填写消息,可选 **保留索引** 和 **包括未跟踪的文件**,贮藏整个工作区;更改树右键菜单中的 **贮藏所选文件…** 只贮藏选中的文件。
- **贮藏更改…**:填写消息,可选 **保留索引** 和 **包括未跟踪的文件**。贮藏总是包含全部更改,不能只贮藏部分文件。
- **取消贮藏更改…**:应用或弹出某个贮藏,恢复索引,或基于它新建分支。
- 提交工具窗口的 **贮藏** 标签页列出所有贮藏,可以应用、弹出和删除。
@@ -219,8 +217,6 @@ Git 工具窗口的 **日志** 标签页显示提交图。
- **显示所选内容的历史记录** 列出修改过所选行的提交。
- **显示当前修订版** 在日志中定位光标所在行的提交。
注解会跳过 `blame.ignoreRevsFile` 指向的文件(约定是仓库根目录下的 `.git-blame-ignore-revs`)中列出的提交,因此一次格式化或批量重命名的提交不会认领所有行。在注解行的右键菜单中选择 **忽略此提交**,会把当前行的提交加入该文件并刷新注解。
### 本地历史记录
本地历史记录独立于 Git 保存文件快照,包括已删除的文件,以及重构前自动记录的状态等带标签的快照。
@@ -250,5 +246,5 @@ Git 工具窗口的 **日志** 标签页显示提交图。
- Mercurial、Subversion、Perforce 等其他版本控制系统。
- 子模块命令、编辑目录映射。
- 按行的部分提交(只支持按代码块)。
- 按行的部分提交(只支持按代码块)以及按文件贮藏。
- 除大文件警告外的 Git LFS 支持。
+2 -2
Переглянути файл
@@ -8,7 +8,7 @@ description: "创建、重命名或删除文件的快速修复,读写项目文
- **文件操作**(`file-actions.wit`,API 1.3):创建、重命名或删除文件的快速修复和重构,作为一次可撤销的工作区编辑应用。
- **项目文件**(`workspace.wit`):读取、写入、查找和搜索打开项目中的文件。
- **其他文件**(`fs.wit`,API 1.3):在明确授权下访问项目之外的文件,以及插件自己的私有目录。
- **文件模板**:**文件 › 新建 › 文件…** 菜单中的条目,不需要代码。
- **文件模板**:**文件 › 新建…** 菜单中的条目,不需要代码。
## 文件操作
@@ -162,7 +162,7 @@ fs::write_file("~/.config/mytool/state.json", "{}")?;
## 文件模板
`[[contributes.fileTemplates]]` 在 **文件 › 新建 › 文件…** 菜单中添加条目。不需要代码,也不授予任何文件访问权限:
`[[contributes.fileTemplates]]` 在 **文件 › 新建…** 菜单中添加条目。不需要代码,也不授予任何文件访问权限:
只有用户调用模板时,才通过 IDE 正常的新建文件流程创建文件。
```toml
-133
Переглянути файл
@@ -1,133 +0,0 @@
import type { Page } from "@playwright/test";
import type { AgentTask } from "../src/bindings";
import { expect, test } from "./fixtures";
const task: AgentTask = {
id: "draft-ack",
title: "Draft acknowledgement",
provider: "codex",
status: "ready",
isolation: "local",
model: null,
cwd: "/dev/acme-api",
branch: null,
base: null,
baseCommit: null,
error: null,
createdMs: 0,
updatedMs: 0,
config: [],
commands: [],
usage: null,
queue: [],
};
async function prepare(page: Page, home: boolean, failure: boolean) {
await page.evaluate(
async ({ task, home, failure }) => {
const storePath = "/src/features/agents/store.ts";
const ipcPath = "/src/platform/ipc.ts";
const store = await import(storePath);
const { commands } = await import(ipcPath);
const provider = {
id: "codex",
name: "Codex",
command: "codex",
program: "codex",
args: [],
defaultProgram: null,
defaultArgs: [],
loginHint: null,
commandOverridden: false,
commandEditable: true,
commandReadOnlyReason: null,
installed: true,
env: {},
commands: [],
source: { kind: "custom" },
config: [],
thoughtLevels: {},
};
// Delayed local acknowledgements exercise the real editor without starting an agent.
commands.agentProviders = async () => ({ status: "ok", data: [provider] });
commands.agentProbe = async () => ({ status: "ok", data: null });
commands.agentChanges = async () => ({ status: "ok", data: [] });
const acknowledge = (data: unknown, prompt: unknown) => {
document.documentElement.dataset.submittedPrompt = JSON.stringify(prompt);
return new Promise((resolve) =>
document.addEventListener(
"test:agent-ack",
() => {
resolve(
failure
? { status: "error", error: { kind: "internal", message: "Local send failed" } }
: { status: "ok", data },
);
},
{ once: true },
),
);
};
commands.agentCreate = (request: { prompt: unknown }) => acknowledge(task, request.prompt);
commands.agentPrompt = (_id: string, prompt: unknown) => acknowledge(null, prompt);
store.useAgents.setState({
loaded: true,
providers: [provider],
tasks: new Map([[task.id, task]]),
entries: new Map([[task.id, []]]),
composers: new Map(),
draft: { provider: "codex", config: {}, isolation: "local", base: null },
sessionLocation: "editor",
});
if (home) store.openHome();
else store.openTask(task.id);
},
{ task, home, failure },
);
}
for (const home of [false, true]) {
for (const edited of [false, true]) {
for (const failure of [false, true]) {
test(`${home ? "new task" : "session"}: ${failure ? "failed" : "successful"} acknowledgement with ${edited ? "new typing" : "unchanged draft"}`, async ({
workbench: page,
}) => {
await prepare(page, home, failure);
const box = page.getByRole("textbox", { name: "Message", exact: true });
await box.fill("submitted prompt");
await page.getByRole("button", { name: home ? "Start" : "Send", exact: true }).click();
await expect(page.locator("html")).toHaveAttribute(
"data-submitted-prompt",
JSON.stringify({ text: "submitted prompt", files: [], images: [] }),
);
await expect(page.getByRole("button", { name: home ? "Starting…" : "Send", exact: true })).toBeDisabled();
if (edited) await box.fill("next unsent prompt");
await page.evaluate(() => document.dispatchEvent(new Event("test:agent-ack")));
if (home && !failure) {
await expect(page.getByRole("tab", { name: "Draft acknowledgement" })).toBeVisible();
await page.evaluate(async () => {
const storePath = "/src/features/agents/store.ts";
(await import(storePath)).openHome();
});
}
const expected = edited ? "next unsent prompt" : failure ? "submitted prompt" : "";
if (expected) await expect(box).toHaveText(expected);
else await expect(box.locator(".cm-placeholder")).toBeVisible();
await expect
.poll(() =>
page.evaluate(
async (key) => {
const storePath = "/src/features/agents/store.ts";
return (await import(storePath)).composerDraft(key).text;
},
home ? "home" : task.id,
),
)
.toBe(expected);
await expect(page.getByRole("button", { name: home ? "Start" : "Send", exact: true })).toBeEnabled({
enabled: !!expected,
});
});
}
}
}
-31
Переглянути файл
@@ -198,37 +198,6 @@ test("copies reply Markdown with the keyboard and keeps code copying separate",
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe("const a = 1;");
});
test("copies a reply link's address from its context menu and keeps Copy for the selected text", async ({
page,
context,
}) => {
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
const url = "https://example.com/pull/42?tab=files#diff";
const reply = await openReply(page, `[**Review changes**](${url})\n\nOrdinary reply text`);
const link = reply.getByRole("link", { name: "Review changes" });
await link.locator("strong").click({ button: "right" });
await page.getByRole("menuitem", { name: "Copy Link Address" }).click();
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(url);
await link.evaluate((el) => {
const range = document.createRange();
range.selectNodeContents(el);
window.getSelection()!.removeAllRanges();
window.getSelection()!.addRange(range);
});
await link.click({ button: "right" });
await page
.getByRole("menuitem", { name: /^Copy\b/ })
.first()
.click();
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe("Review changes");
const paragraph = reply.getByText("Ordinary reply text", { exact: true });
await paragraph.click({ button: "right" });
await expect(page.getByRole("menuitem", { name: "Copy Link Address" })).toHaveCount(0);
await page.keyboard.press("Escape");
});
test("keeps latest reply actions visible and quotes into the draft without shifting content", async ({ page }) => {
const reply = await openReply(page, REPLY);
const copy = page.getByRole("button", { name: "Copy Markdown", exact: true });
-212
Переглянути файл
@@ -1,212 +0,0 @@
import type { Page } from "@playwright/test";
import type { AgentTask } from "../src/bindings";
import { expect, test } from "./fixtures";
const task: AgentTask = {
id: "source-a",
title: "Source task in A",
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: [],
capabilities: { fork: true },
};
async function prepare(page: Page, kind: "create" | "fork", failure: boolean, holdClose = false, closeFailure = false) {
await page.evaluate(
async ({ task, kind, failure, holdClose, closeFailure }) => {
const storePath = "/src/features/agents/store.ts";
const ipcPath = "/src/platform/ipc.ts";
const projectPath = "/src/platform/project.ts";
const trustPath = "/src/platform/projectTrust.ts";
const contributionsPath = "/src/workbench/contributions.ts";
const store = await import(storePath);
const { commands } = await import(ipcPath);
const project = await import(projectPath);
const initial = project.useProject.getState().project;
(await import(trustPath)).provideProjectTrustPrompt(async () => true);
commands.openProject = async (root: string) => ({
status: "ok",
data: {
...initial,
root,
name: root === task.cwd ? "acme-api" : "project-b",
trusted: true,
},
});
commands.closeProject = async () => {};
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: {},
};
commands.agentProviders = async () => ({ status: "ok", data: [provider] });
commands.agentProbe = async () => ({ status: "ok", data: null });
commands.agentEntries = async () => ({ status: "ok", data: [] });
commands.agentChanges = async () => ({ status: "ok", data: [] });
const complete = () => {
document.documentElement.dataset.taskRequest = "pending";
return new Promise((resolve) =>
document.addEventListener(
"test:task-complete",
() => {
resolve(
failure
? { status: "error", error: { kind: "internal", message: "Local operation failed" } }
: { status: "ok", data: { ...task, id: "completed-a", title: "Task from project A" } },
);
setTimeout(() => {
document.documentElement.dataset.taskRequest = "settled";
}, 0);
},
{ once: true },
),
);
};
commands.agentCreate = complete;
commands.agentFork = complete;
if (holdClose) {
const editors = (await import(contributionsPath)).editorService();
const close = editors.closeInput;
editors.closeInput = async (key: string) => {
const closed = await close(key);
if (key !== "agent:new") return closed;
document.documentElement.dataset.homeClosing = "pending";
await new Promise((resolve, reject) =>
document.addEventListener(
"test:close-complete",
() => {
if (closeFailure) reject(new Error("Local close failed"));
else resolve(null);
setTimeout(() => {
document.documentElement.dataset.homeClosing = "settled";
}, 0);
},
{ once: true },
),
);
return closed;
};
}
store.useAgents.setState({
loaded: true,
tasks: new Map([[task.id, task]]),
providers: [provider],
composers: new Map(),
history: [],
entries: new Map([[task.id, [{ type: "user", text: "Existing prompt in A", attachments: [] }]]]),
draft: { provider: "codex", config: {}, isolation: "local", base: null },
sessionLocation: "editor",
});
if (kind === "create") store.openHome("Submitted task in A");
else store.openTask(task.id);
},
{ task, kind, failure, holdClose, closeFailure },
);
if (kind === "create") await page.getByRole("button", { name: "Start", exact: true }).click();
else {
await page.getByText("Existing prompt in A", { exact: true }).hover();
await page.getByRole("button", { name: "Fork", exact: true }).click();
}
await expect(page.locator("html")).toHaveAttribute("data-task-request", "pending");
}
async function changeProject(page: Page, change: "A to B" | "A to B to A" | "reopen A") {
await page.evaluate(
async ({ change, task }) => {
const storePath = "/src/features/agents/store.ts";
const projectPath = "/src/platform/project.ts";
const store = await import(storePath);
const project = await import(projectPath);
if (change === "reopen A") await project.closeProject();
else await project.openProject("/dev/project-b");
if (change !== "A to B") await project.openProject(task.cwd);
const root = project.useProject.getState().project.root;
store.useAgents.setState({
loaded: true,
tasks: new Map([
["current-project", { ...task, id: "current-project", title: "Current project's task", cwd: root }],
]),
entries: new Map(),
history: ["Current project history"],
});
store.openHome("Current project draft");
},
{ change, task },
);
await expect(page.getByRole("textbox", { name: "Message", exact: true })).toHaveText("Current project draft");
}
async function expectCurrentProject(page: Page) {
await expect(page.getByRole("textbox", { name: "Message", exact: true })).toHaveText("Current project draft");
await expect(page.getByRole("tab", { name: "New Agent Task" })).toBeVisible();
await expect(page.getByRole("tab", { name: "Task from project A" })).toHaveCount(0);
await expect(page.getByText(/Local (operation|close) failed/)).toHaveCount(0);
expect(
await page.evaluate(async () => {
const storePath = "/src/features/agents/store.ts";
const store = await import(storePath);
return { tasks: [...store.useAgents.getState().tasks.keys()], history: store.useAgents.getState().history };
}),
).toEqual({ tasks: ["current-project"], history: ["Current project history"] });
}
for (const kind of ["create", "fork"] as const) {
for (const failure of [false, true]) {
for (const change of ["A to B", "A to B to A", "reopen A"] as const) {
test(`${kind}: old ${failure ? "failure" : "success"} after ${change}`, async ({ workbench: page }) => {
await prepare(page, kind, failure);
await changeProject(page, change);
await page.evaluate(() => document.dispatchEvent(new Event("test:task-complete")));
await expect(page.locator("html")).toHaveAttribute("data-task-request", "settled");
await expectCurrentProject(page);
});
}
test(`${kind}: ${failure ? "failure" : "success"} in the current project`, async ({ workbench: page }) => {
await prepare(page, kind, failure);
await page.evaluate(() => document.dispatchEvent(new Event("test:task-complete")));
await expect(page.locator("html")).toHaveAttribute("data-task-request", "settled");
if (failure) await expect(page.getByText(/Local operation failed/).first()).toBeVisible();
else await expect(page.getByRole("tab", { name: "Task from project A" })).toBeVisible();
});
}
}
for (const closeFailure of [false, true]) {
test(`create: project changes while Home closes (${closeFailure ? "failure" : "success"})`, async ({
workbench: page,
}) => {
await prepare(page, "create", false, true, closeFailure);
await page.evaluate(() => document.dispatchEvent(new Event("test:task-complete")));
await expect(page.locator("html")).toHaveAttribute("data-home-closing", "pending");
await changeProject(page, "A to B to A");
await page.evaluate(() => document.dispatchEvent(new Event("test:close-complete")));
await expect(page.locator("html")).toHaveAttribute("data-home-closing", "settled");
await expectCurrentProject(page);
});
}
+3 -3
Переглянути файл
@@ -19,9 +19,9 @@ test("an unresolved module gets a startup timeout", async ({ page }) => {
);
await page.goto("/", { waitUntil: "commit" });
await page.locator("#splash").waitFor({ state: "attached" });
await page.clock.runFor(60_000);
await page.clock.runFor(30_000);
await expect(page.getByRole("textbox", { name: "Startup error details" })).toHaveValue(
/Startup did not finish within 60 seconds/,
/Startup did not finish within 30 seconds/,
);
});
@@ -83,7 +83,7 @@ test("a slow module load succeeds before the deadline and cancels the startup ti
await expect(page.locator("#root")).not.toBeEmpty();
await page.clock.runFor(4000);
await expect(page.locator("#splash")).toHaveCount(0);
await page.clock.runFor(50_000);
await page.clock.runFor(35_000);
await expect(page.locator("#boot-error")).toHaveCount(0);
expect(await page.evaluate(() => sessionStorage.getItem("boot-error-seen"))).toBeNull();
});
-35
Переглянути файл
@@ -285,38 +285,3 @@ test("vertical tabs leave a narrow editor group most of its width", async ({ wor
expect(contentBox!.width).toBeGreaterThanOrEqual(paneBox!.width * 0.6 - 1);
}
});
test("dragging the vertical tab column's edge resizes it and the width is kept", async ({ workbench: page }) => {
await openFromTree(page, "settings.gradle.kts");
const placeLeft = () =>
page.evaluate(async () => {
const projectModule = "/src/platform/project.ts";
const schemaModule = "/src/features/settings/model/schema.ts";
const [{ useProject }, { DEFAULT_SETTINGS }] = await Promise.all([
import(/* @vite-ignore */ projectModule),
import(/* @vite-ignore */ schemaModule),
]);
const base = useProject.getState().settings ?? DEFAULT_SETTINGS;
useProject.setState({
settings: { ...base, editor: { ...base.editor, tabs: { ...base.editor.tabs, placement: "left" } } },
});
});
await placeLeft();
const strip = page.getByRole("tablist", { name: "Editor tabs" });
const handle = page.getByRole("separator", { name: "Resize editor tabs" });
const before = (await strip.boundingBox())!.width;
const box = (await handle.boundingBox())!;
await page.mouse.move(box.x, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(box.x - 60, box.y + box.height / 2, { steps: 5 });
await page.mouse.up();
await expect.poll(async () => (await strip.boundingBox())!.width).toBeLessThan(before - 40);
const narrowed = (await strip.boundingBox())!.width;
// The next session starts with the dragged width.
await page.reload();
await openFromTree(page, "settings.gradle.kts");
await placeLeft();
await expect.poll(async () => Math.round((await strip.boundingBox())!.width)).toBe(Math.round(narrowed));
});
-14
Переглянути файл
@@ -172,20 +172,6 @@ test("accepting a deleted version removes the file before staging", async ({ pag
]);
});
test("a long side shows only the lines it changed against the base", async ({ page }) => {
// Edits spread over a long file make the default diff give up and mark everything between them.
const base = Array.from({ length: 2000 }, (_, i) => `"node_modules/pkg-${i}": "1.0.${i}",`);
const bump = (at: number) => lines(...base.map((l, i) => (i % 100 === at ? l.replace('"1.0.', '"2.0.') : l)));
await mountMerge(page, [lines(...base), bump(3), bump(50)]);
for (const [name, line] of [
["Your version", 3],
["Their version", 50],
] as const) {
const deleted = page.getByRole("region", { name }).locator(".cm-deletedChunk").first().locator(".cm-deletedLine");
await expect(deleted).toHaveText([base[line]!]);
}
});
test("the result starts as the base and every change waits for a decision", async ({ page }) => {
await mountMerge(page);
await expect(status(page)).toHaveText("3 changes left, 2 conflicts");
-102
Переглянути файл
@@ -1,102 +0,0 @@
import { expect, stubDesktop, test } from "./fixtures";
test.use({ video: "on" });
test("offers file templates for the selected workspace member", async ({ workbench: page }) => {
await stubDesktop(page);
await page.evaluate(async () => {
const markers: Record<string, string> = {
"/work/api": "go.mod",
"/work/web": "pom.xml",
"/work/library": "pom.xml",
};
window.__ipc.handlers.fs_read_dir = ({ path }) => {
const name = markers[String(path)];
return name ? [{ name, path: `${path}/${name}`, kind: "file", size: 0, mtimeMs: 0 }] : [];
};
window.__ipc.handlers.run_configs = () => [
{
id: "workspace:web:tomcat",
name: "Web / Tomcat",
kind: "tomcat",
command: "catalina",
args: ["run"],
cwd: "/work/web",
module: "/work/web",
env: {},
detected: true,
isTest: false,
debugAdapter: null,
},
];
window.__ipc.handlers.project_roots = () => ({ folders: [] });
const module = "/src/platform/project.ts";
const { useProject } = await import(/* @vite-ignore */ module);
const current = useProject.getState().project;
useProject.setState({
project: {
...current,
name: "Workspace",
root: "/workspace",
workspace: { version: 1, name: "Workspace", projects: Object.keys(markers) },
},
});
});
const newMenu = async (path: string) => {
await page.locator(`[data-project-path="${path}"]`).click({ button: "right" });
await page.getByRole("menuitem", { name: "New", exact: true }).hover();
await expect(page.getByRole("menuitem", { name: "From Template…", exact: true })).toBeVisible();
};
await newMenu("/work/api");
await expect(page.getByRole("menuitem", { name: "Go File", exact: true })).toBeVisible();
await page.screenshot({ path: test.info().outputPath("go-new-menu.png"), animations: "disabled" });
await expect(page.getByRole("menuitem", { name: "Go Module", exact: true })).toBeVisible();
await expect(page.getByRole("menuitem", { name: "JSON File", exact: true })).toBeVisible();
await expect(page.getByRole("menuitem", { name: "Java Class", exact: true })).toHaveCount(0);
await expect(page.getByRole("menuitem", { name: "Tomcat Context Descriptor", exact: true })).toHaveCount(0);
await page.keyboard.press("Escape");
await page.keyboard.press("Escape");
await newMenu("/work/web");
await expect(page.getByRole("menuitem", { name: "Java Class", exact: true })).toBeVisible();
await expect(page.getByRole("menuitem", { name: "Tomcat Context Descriptor", exact: true })).toBeVisible();
await expect(page.getByRole("menuitem", { name: "Go File", exact: true })).toHaveCount(0);
await expect(page.getByRole("menuitem", { name: "TypeScript File", exact: true })).toBeVisible();
await page.screenshot({ path: test.info().outputPath("tomcat-new-menu.png"), animations: "disabled" });
await page.keyboard.press("Escape");
await page.keyboard.press("Escape");
await page.locator('[data-project-path="/work/library"]').click();
await page.keyboard.press("Meta+n");
const popup = page.getByRole("dialog", { name: "New", exact: true });
await expect(popup.getByText("Java Class", { exact: true })).toBeVisible();
await expect(popup.getByText("Tomcat Context Descriptor", { exact: true })).toHaveCount(0);
await expect(popup.getByText("Go File", { exact: true })).toHaveCount(0);
});
test("uses the single project's type in the keyboard New popup", async ({ workbench: page }) => {
await stubDesktop(page);
await page.evaluate(async () => {
window.__ipc.handlers.fs_read_dir = ({ path }) =>
path === "/work/script" ? [{ name: "pyproject.toml", path: "/work/script/pyproject.toml", kind: "file" }] : [];
window.__ipc.handlers.run_configs = () => [];
window.__ipc.handlers.project_roots = () => ({ folders: [] });
const module = "/src/platform/project.ts";
const { useProject } = await import(/* @vite-ignore */ module);
useProject.setState({
project: { ...useProject.getState().project, name: "script", root: "/work/script" },
});
});
await page.locator('[data-project-path="/work/script"]').click();
await page.keyboard.press("Meta+n");
const popup = page.getByRole("dialog", { name: "New", exact: true });
await expect(popup.getByText("Python File", { exact: true })).toBeVisible();
await expect(popup.getByText("Go File", { exact: true })).toHaveCount(0);
await expect(popup.getByText("Tomcat Context Descriptor", { exact: true })).toHaveCount(0);
await popup.getByRole("textbox", { name: "Search for file type" }).fill("from template");
await popup.getByText("From Template…", { exact: true }).click();
await expect(
page.getByRole("dialog", { name: "New File from Template" }).getByText("Go File", { exact: true }),
).toBeVisible();
});
+1 -3
Переглянути файл
@@ -20,9 +20,7 @@ test("Search Everywhere finds and runs an action", async ({ workbench: page }) =
await expect(option).toBeVisible();
await page.keyboard.press("Enter");
await expect(page.getByRole("dialog", { name: "Settings" })).toBeVisible();
await expect(
page.getByRole("dialog", { name: "Settings" }).getByRole("list", { name: "Languages", exact: true }),
).toBeVisible();
await expect(page.getByRole("list", { name: /language servers/i })).toBeVisible();
});
const ROOT = "/dev/acme-api";
+8 -8
Переглянути файл
@@ -86,20 +86,20 @@ test("Language Servers page installs a tool with progress (in-memory installer)"
// The page tree is virtualized; search brings the page into view.
await dialog.getByRole("textbox", { name: "Search settings" }).fill("Language Servers");
await dialog.getByRole("treeitem", { name: "Language Servers", exact: true }).click();
const languages = dialog.getByRole("list", { name: "Languages", exact: true });
await languages.getByRole("button", { name: /^Go\b/ }).click();
const gopls = dialog.getByLabel("Go tools").locator('[data-tool="gopls"]');
await gopls.click();
await expect(gopls).toContainText("Missing");
const list = dialog.getByRole("list", { name: /language servers/i });
await expect(list).toBeVisible();
await list.locator('[data-tool="gopls"]').click();
const details = dialog.getByLabel("gopls details");
await expect(details).toContainText("Missing");
await details.getByRole("button", { name: "Install gopls" }).click();
await expect(details.getByRole("progressbar", { name: "Installing gopls" })).toBeVisible();
await expect(gopls).toContainText("Installed");
await expect(gopls).toContainText("1.0.0");
await expect(details).toContainText("Installed");
await expect(details).toContainText("1.0.0");
await expect(details.getByRole("button", { name: "Uninstall" })).toBeVisible();
// Tools without an installer explain how to get them.
await languages.getByRole("button", { name: /^Ruby\b/ }).click();
await list.locator('[data-tool="solargraph"]').click();
await expect(dialog.getByLabel("solargraph details")).toContainText("gem install solargraph");
});
+1 -1
Переглянути файл
@@ -3,7 +3,7 @@ import { expect, test } from "./fixtures";
for (const [id, selector] of [
["build.deployment", "[class*='layout'] > [class*='note']"],
["editor.inspections", "[class*='split'] > [class*='details']"],
["languages.servers", "[class*='body'] > [class*='language'][aria-label]"],
["languages.servers", "[class*='body'] > [class*='details']"],
["plugins", "[class*='body'] > [class*='details']"],
["agents.list", "section[aria-label='Codex']"],
]) {
+1 -1
Переглянути файл
@@ -2,7 +2,7 @@
"name": "openidea",
"private": true,
"description": "An open-source IDE inspired by IntelliJ IDEA",
"version": "0.1.45",
"version": "0.1.44",
"license": "Apache-2.0",
"type": "module",
"scripts": {
+3 -3
Переглянути файл
@@ -11,7 +11,7 @@
copied: "已复制",
manual: "无法复制。请选择下方详情并手动复制。",
details: "启动错误详情",
timeout: "启动未在 60 秒内完成。",
timeout: "启动未在 30 秒内完成。",
outdatedMac:
"这台 Mac 的系统网页引擎过旧:OpenIDEA 的界面运行在 macOS 自带的 WebKit 引擎上,需要 macOS 13 Ventura 或更高版本。在 macOS 11、12 上更新 Safari 不会更新其他应用使用的引擎,需要升级 macOS。",
outdated:
@@ -25,7 +25,7 @@
copied: "Copied",
manual: "Could not copy. Select the details below and copy them.",
details: "Startup error details",
timeout: "Startup did not finish within 60 seconds.",
timeout: "Startup did not finish within 30 seconds.",
outdatedMac:
"The system web engine on this Mac is too old: OpenIDEA's interface runs on macOS's built-in WebKit and needs macOS 13 Ventura or later. On macOS 11 and 12 a Safari update doesn't update the engine other apps use, so macOS itself needs upgrading.",
outdated:
@@ -146,7 +146,7 @@
};
// The timeout counts only while the window shows: a hidden or covered window gets no
// animation frames, and the splash waits for those to finish startup.
let remaining = 60_000;
let remaining = 30_000;
let armedAt = 0;
let timer;
const arm = () => {
+1 -1
Переглянути файл
@@ -1,4 +1,4 @@
package openidea:plugin@1.8.0;
package openidea:plugin@1.7.0;
/// The commit being prepared in the window the current command or event came
/// from (the Commit tool window, or the Commit dialog while it is open): the
+1 -1
Переглянути файл
@@ -1,4 +1,4 @@
package openidea:plugin@1.8.0;
package openidea:plugin@1.7.0;
/// Plugin-owned project data sources. Requires database.sources and project trust.
/// Runs on the Database subsystem's host, including on remote project servers.
+4 -10
Переглянути файл
@@ -1,10 +1,9 @@
package openidea:plugin@1.8.0;
package openidea:plugin@1.7.0;
/// Editor and project-tree decorations (API 1.4). Requires project.read. An editor set
/// follows its file: the file's own project, or the nearest one when it is outside them all.
/// Tree sets are rooted in an open project. Sets belong to the plugin and disappear when it stops.
/// Project-scoped decorations (API 1.4). Requires project.read; paths must be inside
/// an open project. Sets belong to the plugin and disappear when it stops.
/// Styles: highlight, search, added, modified, removed, warning, error, info,
/// typo, weak, underline, strikethrough, muted. Unknown styles are ignored. Theme tokens only.
/// underline, strikethrough, muted. Unknown styles are ignored. Theme tokens only.
interface decorations {
use types.{error, range};
/// An editor annotation; after is plain text shown after the range.
@@ -23,9 +22,4 @@ interface decorations {
/// Replace this set's tree annotation for a file with a note shown after its name (API 1.7):
/// one line of at most 200 characters, with an optional tooltip.
set-file-note: func(path: string, set-id: string, note: string, tooltip: option<string>) -> result<_, error>;
/// Replace this set's tree icon for a file or folder with an id declared under
/// `[[contributes.fileIcons]]` (API 1.8), with an optional tooltip.
set-file-icon: func(path: string, set-id: string, icon: string, tooltip: option<string>) -> result<_, error>;
/// Replace all `(path, icon)` entries of a tree set at once (API 1.8; at most 10,000 entries).
set-file-icons: func(set-id: string, items: list<tuple<string, string>>) -> result<_, error>;
}
+1 -1
Переглянути файл
@@ -1,4 +1,4 @@
package openidea:plugin@1.8.0;
package openidea:plugin@1.7.0;
/// What the pane beside the Services tree shows for a plugin's node.
interface detail-types {
+1 -1
Переглянути файл
@@ -1,4 +1,4 @@
package openidea:plugin@1.8.0;
package openidea:plugin@1.7.0;
/// Optional export (world `plugin-diagnostics`): problems in the current,
/// possibly unsaved document, for the languages declared with the
+1 -1
Переглянути файл
@@ -1,4 +1,4 @@
package openidea:plugin@1.8.0;
package openidea:plugin@1.7.0;
/// HTTP endpoints found in a project (`[[contributes.endpoints]]`).
interface endpoints-provider {
+1 -1
Переглянути файл
@@ -1,4 +1,4 @@
package openidea:plugin@1.8.0;
package openidea:plugin@1.7.0;
/// Workbench events, delivered in batches to `event-handler.handle`.
interface events {
+1 -1
Переглянути файл
@@ -1,4 +1,4 @@
package openidea:plugin@1.8.0;
package openidea:plugin@1.7.0;
interface lifecycle {
use types.{context, error};
+1 -1
Переглянути файл
@@ -1,4 +1,4 @@
package openidea:plugin@1.8.0;
package openidea:plugin@1.7.0;
/// What other plugins contributed to the extension points this plugin declares
/// (`[[extension-points]]` in `plugin.toml`; the contributors list this plugin
+1 -1
Переглянути файл
@@ -1,4 +1,4 @@
package openidea:plugin@1.8.0;
package openidea:plugin@1.7.0;
/// Optional export (world `plugin-file-actions`): quick fixes and
/// refactorings that create, rename or delete files, next to the text edits
+1 -1
Переглянути файл
@@ -1,4 +1,4 @@
package openidea:plugin@1.8.0;
package openidea:plugin@1.7.0;
/// Files outside the open projects: the configuration of the program a
/// plugin integrates with, a tool installed elsewhere, a licence file.
+1 -1
Переглянути файл
@@ -1,4 +1,4 @@
package openidea:plugin@1.8.0;
package openidea:plugin@1.7.0;
/// Writes to the IDE log, tagged with the plugin id.
interface log {
+1 -1
Переглянути файл
@@ -1,4 +1,4 @@
package openidea:plugin@1.8.0;
package openidea:plugin@1.7.0;
/// Optional export (world `plugin-hover`): Markdown added to the hover of
/// documents of the languages declared with the `hover` provider kind,
+1 -1
Переглянути файл
@@ -1,4 +1,4 @@
package openidea:plugin@1.8.0;
package openidea:plugin@1.7.0;
/// Cross-file associations a plugin publishes for the files it declares under
/// `[[contributes.documentIndexers]]`. The host calls `index` in cancellable
+1 -1
Переглянути файл
@@ -1,4 +1,4 @@
package openidea:plugin@1.8.0;
package openidea:plugin@1.7.0;
/// Embedded languages. The host routes plugin providers and configured language
/// servers inside these ranges, preserving host-document UTF-16 coordinates.
+1 -1
Переглянути файл
@@ -1,4 +1,4 @@
package openidea:plugin@1.8.0;
package openidea:plugin@1.7.0;
/// Optional export (world `plugin-inlay-hints`): inline hints in documents of
/// the languages declared with the `inlay-hints` provider kind, including
+3 -6
Переглянути файл
@@ -1,14 +1,11 @@
package openidea:plugin@1.8.0;
package openidea:plugin@1.7.0;
/// Problems in files, shown in the editor and the Problems view.
interface diagnostics {
use types.{error, range, location};
/// Problems one producer reported for a file, wherever the file is: the host records every
/// diagnostics event it emits, so this follows the file, not the invoking window.
/// Problems reported by a source for a file in this window's project.
record file-problems { path: string, source: string, diagnostics: list<diagnostic> }
/// The diagnostics the host recorded, one entry per producer, optionally
/// filtered to a file: language servers, builds, spelling and plugin
/// providers alike, whatever window or project opened the file.
/// Current Problems view snapshot, optionally filtered to a project file.
/// Requires project.read and an invoking window. Empty for remote projects.
problems: func(path: option<string>) -> result<list<file-problems>, error>;
+1 -1
Переглянути файл
@@ -1,4 +1,4 @@
package openidea:plugin@1.8.0;
package openidea:plugin@1.7.0;
/// Additional language features, called only for declared API 1.4 provider kinds.
/// Editor calls have a 50 ms soft / 500 ms hard budget; formatting has 5 seconds.
+1 -1
Переглянути файл
@@ -1,4 +1,4 @@
package openidea:plugin@1.8.0;
package openidea:plugin@1.7.0;
/// Discovery of the IDE's built-in MCP server; never exposes its URL or token.
interface mcp {
+1 -1
Переглянути файл
@@ -8,7 +8,7 @@
/// `fs` and `workspace`, and everything else goes through the imports below,
/// gated by the capabilities granted in `plugin.toml` (a missing one returns
/// `error::denied`).
package openidea:plugin@1.8.0;
package openidea:plugin@1.7.0;
world plugin {
import log;
+1 -1
Переглянути файл
@@ -1,4 +1,4 @@
package openidea:plugin@1.8.0;
package openidea:plugin@1.7.0;
/// Editor features a plugin provides for the languages it declares under
/// `[[contributes.providers]]` (the host calls only declared kinds). They run
+1 -1
Переглянути файл
@@ -1,4 +1,4 @@
package openidea:plugin@1.8.0;
package openidea:plugin@1.7.0;
/// Project-scoped targets owned by the registering plugin (API 1.4).
interface run-targets {
+1 -1
Переглянути файл
@@ -1,4 +1,4 @@
package openidea:plugin@1.8.0;
package openidea:plugin@1.7.0;
/// Launches this plugin's configurations in the invoking window's Run tool window.
interface run {
+1 -1
Переглянути файл
@@ -1,4 +1,4 @@
package openidea:plugin@1.8.0;
package openidea:plugin@1.7.0;
/// Optional export (world `plugin-semantic-tokens`): semantic colouring of
/// documents of the languages declared with the `semantic-tokens` provider
+1 -1
Переглянути файл
@@ -1,4 +1,4 @@
package openidea:plugin@1.8.0;
package openidea:plugin@1.7.0;
/// Shared types. Positions are 0-based lines and UTF-16 code unit columns,
/// as in the editor and LSP.
+1 -1
Переглянути файл
@@ -1,4 +1,4 @@
package openidea:plugin@1.8.0;
package openidea:plugin@1.7.0;
/// Declarative views: tool windows declared
/// under `[[contributes.views]]` and custom editors under
+2 -4
Переглянути файл
@@ -1,4 +1,4 @@
package openidea:plugin@1.8.0;
package openidea:plugin@1.7.0;
interface workspace {
use types.{error, location};
@@ -135,9 +135,7 @@ interface editor {
/// Applies versioned edits to an open project document from a background
/// event. The path is project-relative and requires `project.read` and
/// `editor.write`. The host refuses VCS/IDE control directories and every
/// symlink, junction or reparse-point component before it finds the open
/// document. Stale versions are rejected without changing text.
/// `editor.write`. Stale versions are rejected without changing text.
/// These background edits are non-undoable and are not re-emitted as
/// `document-changed` events.
apply-edits-at: func(project: borrow<project>, path: string, version: u64, edits: list<text-edit>) -> result<u64, error>;
-7
Переглянути файл
@@ -362,10 +362,3 @@ pub async fn db_apply_edits(
pub fn db_console_path(state: State<'_, AppState>, id: String) -> IdeResult<IpcPath> {
ide_host::ops::db::console_path(&state.host, &id)
}
/// The query consoles of data source `id`; `create` adds the next one first.
#[tauri::command]
#[specta::specta]
pub fn db_consoles(state: State<'_, AppState>, id: String, create: bool) -> IdeResult<Vec<IpcPath>> {
ide_host::ops::db::consoles(&state.host, &id, create)
}
-11
Переглянути файл
@@ -244,17 +244,6 @@ pub async fn debug_restart_frame(state: State<'_, AppState>, session: DebugSessi
ide_host::ops::debug::debug_restart_frame(&state.host, "", session, frame_id).await
}
/// The body of a response a debugged page received.
#[tauri::command]
#[specta::specta]
pub async fn debug_js_response_body(
state: State<'_, AppState>,
session: DebugSessionId,
request_id: String,
) -> IdeResult<ide_dap::JsResponseBody> {
ide_host::ops::debug::debug_js_response_body(&state.host, "", session, request_id).await
}
/// Breakpoints of a debugged page on DOM events and on requests (js-debug).
#[tauri::command]
#[specta::specta]
+1 -28
Переглянути файл
@@ -65,9 +65,8 @@ pub fn apply(state: &AppState) {
}
// Another address, port or token: clients connect anew.
if let Some(running) = server.take() {
let held = (running.address(), running.port());
running.stop();
if let Some(port) = port_to_wait_for(held, wanted.port) {
if let Some(port) = wanted.port {
wait_until_free(address, port);
}
}
@@ -105,14 +104,6 @@ pub fn apply(state: &AppState) {
}
}
/// The port a restart listening on `wanted` waits for after stopping the server that listened
/// on `held`: only its own, at any address (`127.0.0.1` and `0.0.0.0` overlap). A port another
/// program holds (the run fell back to a free one) stays taken, and waiting for it would block
/// the window.
fn port_to_wait_for(held: (IpAddr, u16), wanted: Option<u16>) -> Option<u16> {
wanted.filter(|port| held.1 == *port)
}
/// Waits briefly for a stopped server's port to close, so the restart gets it again.
fn wait_until_free(address: IpAddr, port: u16) {
for _ in 0..100 {
@@ -252,21 +243,3 @@ pub fn mcp_respond(
) -> IdeResult<()> {
state.host.mcp_requests.answer(window.label(), id, allow, error)
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::Ipv4Addr;
#[test]
fn a_restart_waits_only_for_the_port_the_stopped_server_held() {
let local = IpAddr::V4(Ipv4Addr::LOCALHOST);
let any = IpAddr::V4(Ipv4Addr::UNSPECIFIED);
assert_eq!(port_to_wait_for((local, 64342), Some(64342)), Some(64342));
// Another address on the same port overlaps the old listener: wait for it too.
assert_eq!(port_to_wait_for((any, 64342), Some(64342)), Some(64342));
// The fixed port was taken by another program: the server ran on a free one.
assert_eq!(port_to_wait_for((local, 51234), Some(64342)), None);
assert_eq!(port_to_wait_for((local, 64342), None), None);
}
}
-4
Переглянути файл
@@ -248,7 +248,6 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
nav::web_goto,
nav::nav_resolve_module,
nav::nav_index_definitions,
nav::nav_inheritance,
nav::fs_resolve_reference,
nav::path_completions,
nav::web_unresolved_references,
@@ -326,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,
@@ -529,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,
@@ -747,6 +744,5 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
db::db_table_page,
db::db_apply_edits,
db::db_console_path,
db::db_consoles,
])
}
-8
Переглянути файл
@@ -115,14 +115,6 @@ pub async fn nav_index_definitions(
ide_host::ops::nav::nav_index_definitions(&state.host, window.label(), name, from).await
}
/// Every Python class in the project (attached dependency roots included) and its base names,
/// for native inheritance gutter markers.
#[tauri::command]
#[specta::specta]
pub async fn nav_inheritance(window: Window, state: State<'_, AppState>) -> IdeResult<Vec<ClassInheritance>> {
ide_host::ops::nav::nav_inheritance(&state.host, window.label()).await
}
/// The existing file or directory a path written in `from` names (`href`, `url()`, a string literal).
#[tauri::command]
#[specta::specta]
+1 -14
Переглянути файл
@@ -239,17 +239,6 @@ pub async fn vcs_add_to_ignore(
ide_host::ops::vcs::vcs_add_to_ignore(&state.host, window.label(), paths, destination).await
}
#[tauri::command]
#[specta::specta]
pub async fn vcs_ignore_blame_revision(
window: Window,
state: State<'_, AppState>,
path: PathBuf,
commit: String,
) -> IdeResult<()> {
ide_host::ops::vcs::vcs_ignore_blame_revision(&state.host, window.label(), path, commit).await
}
#[tauri::command]
#[specta::specta]
pub async fn vcs_unstage(window: Window, state: State<'_, AppState>, paths: Vec<PathBuf>) -> IdeResult<()> {
@@ -439,10 +428,8 @@ pub async fn vcs_stash_push(
message: Option<String>,
include_untracked: bool,
keep_index: bool,
paths: Option<Vec<PathBuf>>,
) -> IdeResult<()> {
ide_host::ops::vcs::vcs_stash_push(&state.host, window.label(), repo, message, include_untracked, keep_index, paths)
.await
ide_host::ops::vcs::vcs_stash_push(&state.host, window.label(), repo, message, include_untracked, keep_index).await
}
#[tauri::command]
+8 -67
Переглянути файл
@@ -217,18 +217,14 @@ pub fn spec(os: Os) -> Vec<Entry> {
}
let mut file = vec![
sub(
"new",
"New",
vec![
act("file.newProject", "Project…"),
act("workspace.create", "Workspace…"),
SEP,
act("file.new", "File…"),
act("file.newScratchFile", "Scratch File"),
],
),
act("file.newProject", "New Project…"),
act("workspace.create", "New Workspace…"),
act("workspace.open", "Open Workspace…"),
act("workspace.manage", "Manage Workspace…"),
act("file.new", "New…"),
act("file.newScratchFile", "New Scratch File…"),
act("file.openProject", "Open…"),
act("project.attach", "Attach Project…"),
act("file.openFile", "Open File…"),
act("file.openInNewWindow", "Open in New Window…"),
sub(
@@ -236,11 +232,6 @@ pub fn spec(os: Os) -> Vec<Entry> {
"Open Recent",
vec![Entry::RecentProjects, SEP, act("welcome.recentProjects", "Manage Projects…")],
),
act("project.attach", "Attach Project…"),
SEP,
act("workspace.open", "Open Workspace…"),
act("workspace.manage", "Manage Workspace…"),
SEP,
act("file.closeProject", "Close Project"),
act("file.renameProject", "Rename Project…"),
SEP,
@@ -1621,7 +1612,7 @@ pub fn install<R: Runtime>(app: &AppHandle<R>, menu_state: &MenuState, recent: &
#[cfg(test)]
mod tests {
use super::*;
use std::collections::{BTreeSet, HashSet};
use std::collections::HashSet;
const ALL_OS: [Os; 3] = [Os::Mac, Os::Windows, Os::Linux];
@@ -1981,56 +1972,6 @@ mod tests {
}
}
#[test]
fn file_menu_starts_with_a_new_submenu() {
for os in ALL_OS {
let menus = spec(os);
let Some(Entry::Submenu { children: file, .. }) =
menus.iter().find(|e| matches!(e, Entry::Submenu { id: "file", .. }))
else {
panic!("File menu missing on {os:?}")
};
let Some(Entry::Submenu { id: "new", children: new, .. }) = file.first() else {
panic!("File menu does not start with New on {os:?}")
};
assert_eq!(
action_ids(new),
["file.newProject", "workspace.create", "file.new", "file.newScratchFile"],
"{os:?}"
);
}
}
#[test]
fn every_menu_item_has_a_catalog_label() {
fn walk(entries: &[Entry], out: &mut BTreeSet<&'static str>) {
for e in entries {
match e {
Entry::Submenu { id, children, .. } => {
out.insert(id);
walk(children, out);
}
Entry::Action { id, .. } => {
out.insert(id);
}
_ => {}
}
}
}
let mut ids = BTreeSet::new();
for os in ALL_OS {
walk(&spec(os), &mut ids);
}
for (locale, catalog) in [
("en", include_str!("../../src/platform/locales/en.json")),
("zh", include_str!("../../src/platform/locales/zh.json")),
] {
let catalog: serde_json::Map<String, serde_json::Value> = serde_json::from_str(catalog).unwrap();
let missing: Vec<_> = ids.iter().filter(|id| !catalog.contains_key(&format!("menu.{id}"))).collect();
assert!(missing.is_empty(), "{locale}.json lacks menu labels for {missing:?}");
}
}
#[test]
fn recent_labels_tildify_home() {
let recent = vec![ide_workspace::RecentProject {
+1 -1
Переглянути файл
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "OpenIDEA",
"version": "0.1.45",
"version": "0.1.44",
"identifier": "ai.l0ng.openidea",
"build": {
"beforeDevCommand": "pnpm dev",
+2 -88
Переглянути файл
@@ -475,11 +475,6 @@ export const commands = {
* plus its configured dependencies).
*/
navIndexDefinitions: (name: string, from: IpcPath) => typedError<SymbolMatch[], IdeError>(__TAURI_INVOKE("nav_index_definitions", { name, from })),
/**
* Every Python class in the project (attached dependency roots included) and its base names,
* for native inheritance gutter markers.
*/
navInheritance: () => typedError<ClassInheritance[], IdeError>(__TAURI_INVOKE("nav_inheritance")),
/** The existing file or directory a path written in `from` names (`href`, `url()`, a string literal). */
fsResolveReference: (from: IpcPath, value: string) => typedError<string | null, IdeError>(__TAURI_INVOKE("fs_resolve_reference", { from, value })),
/** Files and folders that continue `prefix`, a path being typed in `from`. */
@@ -662,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 })),
@@ -696,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. */
@@ -1221,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`. */
@@ -1731,8 +1723,6 @@ export const commands = {
dbTablePage: (request: RequestId, id: string, schema: string, table: string, filter: string | null, sort: DbSortKey[], offset: number, limit: number) => typedError<DbTablePage, IdeError>(__TAURI_INVOKE("db_table_page", { request, id, schema, table, filter, sort, offset, limit })),
dbApplyEdits: (id: string, schema: string, table: string, edits: DbRowEdit[]) => typedError<number | null, IdeError>(__TAURI_INVOKE("db_apply_edits", { id, schema, table, edits })),
dbConsolePath: (id: string) => typedError<IpcPath, IdeError>(__TAURI_INVOKE("db_console_path", { id })),
/** The query consoles of data source `id`; `create` adds the next one first. */
dbConsoles: (id: string, create: boolean) => typedError<IpcPath[], IdeError>(__TAURI_INVOKE("db_consoles", { id, create })),
};
/* Types */
@@ -2896,25 +2886,6 @@ export type ChoiceOption = {
label: string,
};
/**
* A Python class definition and the base names written in its source. The UI builds a
* name → locations index from every class in the project (attached dependency roots
* included) to compute ↑ overrides / ↓ overridden gutter markers without a language
* server's type-hierarchy provider.
*/
export type ClassInheritance = {
/** Start of the class definition's selection range (0-based line, UTF-16 column). */
path: IpcPath,
line: number,
col: number,
name: string,
/**
* Base names as written in source (bare name, e.g. `AllToAllTokenDispatcher`; a
* qualified name keeps only its last segment).
*/
bases: string[],
};
export type ClassifiedReference = {
/** Optional for older Java extensions; missing means unclassified. */
access?: ReferenceAccess | null,
@@ -3993,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`). */
@@ -4030,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;
@@ -4050,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,
@@ -6455,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. */
@@ -10377,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. */
-4
Переглянути файл
@@ -682,10 +682,6 @@ export function setShownLine(path: string, line: number | null) {
}, true);
}
export function shownLine(path: string): number | null {
return useShownLines.getState()[path] ?? null;
}
export function useShownLine(path: string | null): number | null {
return useShownLines((s) => (path ? (s[path] ?? null) : null));
}
Loaded 100 of 237 files, more files were not shown because too many files have changed in this diff. Show more