дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-15 06:05:39 +02:00
Порівняти коміти
51
Коміти
| Автор | SHA1 | Дата | |
|---|---|---|---|
|
|
fd0c8a9f05 | ||
|
|
13e18af777 | ||
|
|
488e819b46 | ||
|
|
ad8df8fe00 | ||
|
|
19d6eab21a | ||
|
|
5c0befeff0 | ||
|
|
07bd8ded5e | ||
|
|
4ab85d5f49 | ||
|
|
3c91adfae2 | ||
|
|
5cfa6614a1 | ||
|
|
e875d97f7c | ||
|
|
31016ee684 | ||
|
|
6a93f1cce6 | ||
|
|
2b4e3c79f8 | ||
|
|
4e90d9fd5c | ||
|
|
ffc2f75d3b | ||
|
|
19587af8b1 | ||
|
|
b2c22ccec6 | ||
|
|
f795aa3232 | ||
|
|
9239b99f3a | ||
|
|
c5be2998ec | ||
|
|
b83df2dbda | ||
|
|
9f7a35f170 | ||
|
|
52b3e14a8c | ||
|
|
934e4ca0e7 | ||
|
|
a2809bda80 | ||
|
|
0fd8389b33 | ||
|
|
19c0ae07f2 | ||
|
|
5a5b53d86b | ||
|
|
ac7a81db1c | ||
|
|
9681e1a118 | ||
|
|
293972baaa | ||
|
|
b173a53004 | ||
|
|
f6e54f581b | ||
|
|
276a30849c | ||
|
|
63abedaadd | ||
|
|
c22a7fa112 | ||
|
|
a46f187428 | ||
|
|
ff826279f2 | ||
|
|
759fb3278d | ||
|
|
f7d19e1711 | ||
|
|
97d202ade0 | ||
|
|
f0f15366e4 | ||
|
|
fecbf8ed14 | ||
|
|
2d00f09393 | ||
|
|
f154bf8c83 | ||
|
|
d3720c0e11 | ||
|
|
ec862b3668 | ||
|
|
735830d642 | ||
|
|
65c0413dbb | ||
|
|
14bb7bcc6f |
No files matched your search
Створено
+44
-44
@@ -4144,7 +4144,7 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "ide-agent"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4168,7 +4168,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-analysis"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"ast-grep-core",
|
||||
"ide-core",
|
||||
@@ -4182,7 +4182,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-android"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"serde",
|
||||
@@ -4193,7 +4193,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-build"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"chardetng",
|
||||
@@ -4211,7 +4211,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-cli"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"serde",
|
||||
@@ -4225,7 +4225,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-clipboard"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"gtk",
|
||||
"objc2 0.6.5",
|
||||
@@ -4235,7 +4235,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-core"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4252,7 +4252,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-coverage"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-run",
|
||||
@@ -4266,7 +4266,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-dap"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"ide-core",
|
||||
@@ -4293,7 +4293,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-db"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bb8",
|
||||
@@ -4329,7 +4329,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-docker"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"serde",
|
||||
@@ -4342,7 +4342,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-extension"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"blake3",
|
||||
@@ -4363,7 +4363,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-fonts"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-text",
|
||||
@@ -4375,7 +4375,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-github"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-vcs",
|
||||
@@ -4388,7 +4388,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-heap"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"ide-core",
|
||||
@@ -4406,7 +4406,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-host"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"blake3",
|
||||
@@ -4477,7 +4477,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-http"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-vfs",
|
||||
@@ -4496,7 +4496,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-index"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"criterion",
|
||||
@@ -4518,7 +4518,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-jdwp"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"ide-jvm-inspect",
|
||||
"proptest",
|
||||
@@ -4530,7 +4530,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-jsonrpc"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"futures",
|
||||
@@ -4547,7 +4547,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-jvm-attach"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"tempfile",
|
||||
@@ -4556,7 +4556,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-jvm-inspect"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"serde",
|
||||
@@ -4567,7 +4567,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-language"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"criterion",
|
||||
@@ -4624,7 +4624,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-lsp"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4652,7 +4652,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-mcp"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"dunce",
|
||||
@@ -4671,7 +4671,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-notebook"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"ide-core",
|
||||
@@ -4694,7 +4694,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-plugin"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dunce",
|
||||
@@ -4717,7 +4717,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-profiler"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-jvm-attach",
|
||||
@@ -4731,7 +4731,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-remote"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"futures",
|
||||
@@ -4751,7 +4751,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-run"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4775,7 +4775,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-scaffold"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-tools",
|
||||
@@ -4791,7 +4791,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-sdk"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"ide-core",
|
||||
@@ -4812,7 +4812,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-search"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"encoding_rs",
|
||||
@@ -4833,7 +4833,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-spell"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"parking_lot",
|
||||
@@ -4846,7 +4846,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-ssh"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"flate2",
|
||||
@@ -4871,7 +4871,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-syntax"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-language",
|
||||
@@ -4886,7 +4886,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-terminal"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"libc",
|
||||
@@ -4899,7 +4899,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-text"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"ide-core",
|
||||
@@ -4912,7 +4912,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-tools"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"hex",
|
||||
@@ -4933,7 +4933,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-vcs"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dunce",
|
||||
@@ -4954,7 +4954,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-vfs"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"chardetng",
|
||||
"dunce",
|
||||
@@ -4978,7 +4978,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-web"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"ide-core",
|
||||
@@ -4994,7 +4994,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-workspace"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dunce",
|
||||
@@ -6595,7 +6595,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openidea"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"blake3",
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ members = ["crates/*", "src-tauri"]
|
||||
exclude = ["sdk", "plugins"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
edition = "2024"
|
||||
rust-version = "1.96"
|
||||
license = "Apache-2.0"
|
||||
|
||||
@@ -291,6 +291,56 @@ pub enum DebugEvent {
|
||||
JvmBackend {
|
||||
state: JvmBackendState,
|
||||
},
|
||||
/// A step in the life of one request of a debugged page.
|
||||
JsNetwork {
|
||||
event: JsNetworkEvent,
|
||||
},
|
||||
/// The adapter does not report the requests of this page: there will be no `JsNetwork`.
|
||||
JsNetworkUnavailable,
|
||||
}
|
||||
|
||||
/// Which step of a request a [`JsNetworkEvent`] reports.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum JsNetworkKind {
|
||||
Request,
|
||||
Response,
|
||||
Finished,
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// The script frame that made a request, where the browser ran it (0-based line and column).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct JsInitiator {
|
||||
pub function: String,
|
||||
pub url: String,
|
||||
pub line: u32,
|
||||
pub column: u32,
|
||||
}
|
||||
|
||||
/// What a debugged page's browser reports about a request, reduced to what the Network view
|
||||
/// shows. Events of one request share `request_id`; each kind fills its own fields.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct JsNetworkEvent {
|
||||
pub kind: JsNetworkKind,
|
||||
pub request_id: String,
|
||||
/// The browser's monotonic clock, comparable between the events of a page.
|
||||
pub timestamp_ms: u64,
|
||||
/// Request and response.
|
||||
pub url: Option<String>,
|
||||
pub method: Option<String>,
|
||||
/// `Fetch`, `XHR`, `Script`, `Document`, …
|
||||
pub resource_type: Option<String>,
|
||||
pub status: Option<u16>,
|
||||
pub mime_type: Option<String>,
|
||||
/// The request's headers, or the response's.
|
||||
pub headers: BTreeMap<String, String>,
|
||||
/// Bytes received over the network, once finished.
|
||||
pub encoded_length: Option<u64>,
|
||||
pub error_text: Option<String>,
|
||||
pub initiator: Option<JsInitiator>,
|
||||
}
|
||||
|
||||
/// The backend that reads a debugged JVM's objects (instances, fields, referrers).
|
||||
|
||||
@@ -1358,11 +1358,15 @@ pub struct TypeScriptSettings {
|
||||
/// `bundled` (the server's own TypeScript), `workspace` (`node_modules/typescript`),
|
||||
/// or a path to a TypeScript `lib` directory.
|
||||
pub version: String,
|
||||
/// tsserver heap limit in MB. `0` = automatic: 8192 when vtsls hosts the Vue
|
||||
/// plugin (hybrid mode; a Vue project's program alone needs ~3 GiB, and the
|
||||
/// vtsls default 3072 dies in a GC death spiral), else unset (vtsls default).
|
||||
pub tsserver_max_memory: u32,
|
||||
}
|
||||
|
||||
impl Default for TypeScriptSettings {
|
||||
fn default() -> Self {
|
||||
Self { version: "bundled".into() }
|
||||
Self { version: "bundled".into(), tsserver_max_memory: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -266,6 +266,8 @@ pub struct DebuggerSettings {
|
||||
pub inline_values: bool,
|
||||
/// Data Views: list variables by name instead of the adapter's order.
|
||||
pub sort_values: bool,
|
||||
/// Data Views: evaluate watches while the program runs, where the debugger can (JavaScript).
|
||||
pub live_watches: bool,
|
||||
pub value_tooltip: bool,
|
||||
/// While suspended, selecting an expression shows its value.
|
||||
pub value_tooltip_on_selection: bool,
|
||||
@@ -287,6 +289,7 @@ impl Default for DebuggerSettings {
|
||||
run_to_cursor_inlay: true,
|
||||
inline_values: true,
|
||||
sort_values: false,
|
||||
live_watches: false,
|
||||
value_tooltip: true,
|
||||
value_tooltip_on_selection: true,
|
||||
value_tooltip_delay: 700,
|
||||
|
||||
@@ -5,8 +5,12 @@
|
||||
|
||||
use crate::session::Session;
|
||||
use crate::{DebugManager, DebugSessionId, REQUEST_TIMEOUT};
|
||||
use ide_core::event::{JsInitiator, JsNetworkEvent, JsNetworkKind};
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use specta::Type;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub(crate) const ADAPTER: &str = "js-debug";
|
||||
|
||||
@@ -56,3 +60,224 @@ impl DebugManager {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The browser events js-debug is asked to pass on (`enableNetworking`'s `mirrorEvents`).
|
||||
pub(crate) const NETWORK_EVENTS: [&str; 4] =
|
||||
["requestWillBeSent", "responseReceived", "loadingFinished", "loadingFailed"];
|
||||
|
||||
/// Response bodies shown are cut at this many bytes.
|
||||
pub const RESPONSE_BODY_LIMIT: usize = 1_048_576;
|
||||
|
||||
/// The body of a response, as text or as the browser's base64 for binary content.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct JsResponseBody {
|
||||
pub body: String,
|
||||
pub base64_encoded: bool,
|
||||
/// The body was longer than [`RESPONSE_BODY_LIMIT`] and is cut there.
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
fn text(v: &Value, key: &str) -> Option<String> {
|
||||
v.get(key).and_then(Value::as_str).map(str::to_owned)
|
||||
}
|
||||
|
||||
fn headers(v: &Value) -> BTreeMap<String, String> {
|
||||
v.get("headers")
|
||||
.and_then(Value::as_object)
|
||||
.map(|h| h.iter().filter_map(|(k, v)| Some((k.clone(), v.as_str()?.to_owned()))).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The frame a request is attributed to: the innermost one outside `node_modules`, looked
|
||||
/// for through the stacks that scheduled this one (an `await`, a timer), since a request sent
|
||||
/// through a library has the library innermost. A stack of library frames only gives its first.
|
||||
fn initiator(data: &Value) -> Option<JsInitiator> {
|
||||
let stack = data.pointer("/initiator/stack")?;
|
||||
let frames = |stack: &Value| stack.get("callFrames").and_then(Value::as_array).cloned().unwrap_or_default();
|
||||
let stacks = std::iter::successors(Some(stack), |stack| stack.get("parent")).take(32);
|
||||
let all: Vec<Value> = stacks.flat_map(frames).collect();
|
||||
let own = |frame: &&Value| {
|
||||
frame.get("url").and_then(Value::as_str).is_some_and(|url| !url.is_empty() && !url.contains("/node_modules/"))
|
||||
};
|
||||
let frame = all.iter().find(own).or(all.first())?;
|
||||
let number = |key: &str| frame.get(key).and_then(Value::as_u64).and_then(|n| u32::try_from(n).ok()).unwrap_or(0);
|
||||
Some(JsInitiator {
|
||||
function: text(frame, "functionName").unwrap_or_default(),
|
||||
url: text(frame, "url")?,
|
||||
line: number("lineNumber"),
|
||||
column: number("columnNumber"),
|
||||
})
|
||||
}
|
||||
|
||||
/// js-debug's `networkEvent` (`{event, data}`, `data` being the browser's own parameters of
|
||||
/// that event) as a [`JsNetworkEvent`]; `None` for events the Network view has no use for.
|
||||
pub(crate) fn network_event(body: &Value) -> Option<JsNetworkEvent> {
|
||||
let data = body.get("data")?;
|
||||
let kind = match body.get("event").and_then(Value::as_str)? {
|
||||
"requestWillBeSent" => JsNetworkKind::Request,
|
||||
"responseReceived" => JsNetworkKind::Response,
|
||||
"loadingFinished" => JsNetworkKind::Finished,
|
||||
"loadingFailed" => JsNetworkKind::Failed,
|
||||
_ => return None,
|
||||
};
|
||||
let part = match kind {
|
||||
JsNetworkKind::Request => data.get("request"),
|
||||
JsNetworkKind::Response => data.get("response"),
|
||||
_ => None,
|
||||
};
|
||||
let part = part.unwrap_or(&Value::Null);
|
||||
Some(JsNetworkEvent {
|
||||
kind,
|
||||
request_id: text(data, "requestId")?,
|
||||
// Seconds with a fraction.
|
||||
timestamp_ms: (data.get("timestamp").and_then(Value::as_f64).unwrap_or(0.0).max(0.0) * 1000.0).round() as u64,
|
||||
url: text(part, "url"),
|
||||
method: text(part, "method"),
|
||||
resource_type: text(data, "type"),
|
||||
status: part.get("status").and_then(Value::as_u64).and_then(|s| u16::try_from(s).ok()),
|
||||
mime_type: text(part, "mimeType"),
|
||||
headers: headers(part),
|
||||
encoded_length: data.get("encodedDataLength").and_then(Value::as_f64).map(|n| n.max(0.0) as u64),
|
||||
error_text: text(data, "errorText"),
|
||||
initiator: if kind == JsNetworkKind::Request { initiator(data) } else { None },
|
||||
})
|
||||
}
|
||||
|
||||
/// `networkCall getResponseBody`'s answer, cut at `limit` bytes on a character boundary.
|
||||
fn response_body(answer: &Value, limit: usize) -> JsResponseBody {
|
||||
let mut body = text(answer, "body").unwrap_or_default();
|
||||
let truncated = body.len() > limit;
|
||||
if truncated {
|
||||
let mut end = limit;
|
||||
while !body.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
body.truncate(end);
|
||||
}
|
||||
JsResponseBody {
|
||||
body,
|
||||
base64_encoded: answer.get("base64Encoded").and_then(Value::as_bool).unwrap_or(false),
|
||||
truncated,
|
||||
}
|
||||
}
|
||||
|
||||
/// The arguments of `enableNetworking`, which has a page report its requests
|
||||
/// ([`ide_core::event::DebugEvent::JsNetwork`]). A page session sends it as it starts.
|
||||
pub(crate) fn networking_arguments() -> Value {
|
||||
json!({ "mirrorEvents": NETWORK_EVENTS })
|
||||
}
|
||||
|
||||
impl DebugManager {
|
||||
/// The body of the response to `request_id`, while the browser still has it.
|
||||
pub async fn js_response_body(&self, s: DebugSessionId, request_id: String) -> IdeResult<JsResponseBody> {
|
||||
let call = json!({ "method": "getResponseBody", "params": { "requestId": request_id } });
|
||||
let answer = self.js_page_request(s, "networkCall", call).await?;
|
||||
Ok(response_body(&answer, RESPONSE_BODY_LIMIT))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn event(kind: &str, data: Value) -> Option<ide_core::event::JsNetworkEvent> {
|
||||
network_event(&json!({ "event": kind, "data": data }))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_request_carries_what_was_asked_and_who_asked() {
|
||||
let e = event(
|
||||
"requestWillBeSent",
|
||||
json!({
|
||||
"requestId": "76695.31", "timestamp": 611807.5, "type": "Fetch",
|
||||
"request": { "method": "GET", "url": "http://127.0.0.1:5195/src/main.ts", "headers": { "Accept": "*/*", "X-N": 1 } },
|
||||
"initiator": { "type": "script", "stack": { "callFrames": [
|
||||
{ "functionName": "load", "url": "http://127.0.0.1:5195/src/App.vue", "lineNumber": 9, "columnNumber": 2 }
|
||||
] } }
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(e.kind, JsNetworkKind::Request);
|
||||
assert_eq!((e.request_id.as_str(), e.timestamp_ms), ("76695.31", 611_807_500));
|
||||
assert_eq!(e.url.as_deref(), Some("http://127.0.0.1:5195/src/main.ts"));
|
||||
assert_eq!((e.method.as_deref(), e.resource_type.as_deref()), (Some("GET"), Some("Fetch")));
|
||||
// Only text headers are kept.
|
||||
assert_eq!(e.headers.len(), 1);
|
||||
assert_eq!(e.headers["Accept"], "*/*");
|
||||
let from = e.initiator.unwrap();
|
||||
assert_eq!((from.function.as_str(), from.line, from.column), ("load", 9, 2));
|
||||
assert_eq!(from.url, "http://127.0.0.1:5195/src/App.vue");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_request_made_through_a_library_is_asked_by_the_code_that_called_the_library() {
|
||||
let frame = |function: &str, url: &str| json!({ "functionName": function, "url": url, "lineNumber": 1, "columnNumber": 0 });
|
||||
let lib = "http://localhost:5173/node_modules/.vite/deps/http-client.js";
|
||||
let asked_by = |stack: Value| {
|
||||
let data = json!({ "requestId": "1", "timestamp": 1.0, "request": { "method": "GET", "url": "http://localhost/api" },
|
||||
"initiator": { "type": "script", "stack": stack } });
|
||||
event("requestWillBeSent", data).unwrap().initiator.unwrap().function
|
||||
};
|
||||
// The innermost frame is the library's; the project's caller is further out.
|
||||
let direct = json!({ "callFrames": [frame("dispatch", lib), frame("request", lib), frame("loadItems", "http://localhost:5173/src/api.ts")] });
|
||||
assert_eq!(asked_by(direct), "loadItems");
|
||||
// Across an `await`, the caller is in the stack that scheduled this one.
|
||||
let scheduled = json!({ "callFrames": [frame("dispatch", lib)],
|
||||
"parent": { "callFrames": [frame("then", lib)], "parent": { "callFrames": [frame("onMounted", "http://localhost:5173/src/App.vue")] } } });
|
||||
assert_eq!(asked_by(scheduled), "onMounted");
|
||||
// A library on its own: the frame that sent the request.
|
||||
assert_eq!(asked_by(json!({ "callFrames": [frame("dispatch", lib), frame("poll", lib)] })), "dispatch");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_response_a_finish_and_a_failure_carry_their_outcome() {
|
||||
let response = event(
|
||||
"responseReceived",
|
||||
json!({ "requestId": "7", "timestamp": 2.0, "type": "Script", "response": {
|
||||
"url": "http://localhost/a.js", "status": 200, "mimeType": "text/javascript",
|
||||
"headers": { "Content-Type": "text/javascript" }
|
||||
} }),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(response.kind, JsNetworkKind::Response);
|
||||
assert_eq!((response.status, response.mime_type.as_deref()), (Some(200), Some("text/javascript")));
|
||||
assert_eq!(response.headers["Content-Type"], "text/javascript");
|
||||
|
||||
let finished =
|
||||
event("loadingFinished", json!({ "requestId": "7", "timestamp": 2.5, "encodedDataLength": 1234 })).unwrap();
|
||||
assert_eq!((finished.kind, finished.encoded_length), (JsNetworkKind::Finished, Some(1234)));
|
||||
|
||||
let failed =
|
||||
event("loadingFailed", json!({ "requestId": "8", "timestamp": 3.0, "errorText": "net::ERR_FAILED" }))
|
||||
.unwrap();
|
||||
assert_eq!((failed.kind, failed.error_text.as_deref()), (JsNetworkKind::Failed, Some("net::ERR_FAILED")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn events_that_name_no_request_or_are_not_mirrored_are_dropped() {
|
||||
assert!(event("requestWillBeSent", json!({ "timestamp": 1.0 })).is_none());
|
||||
assert!(event("dataReceived", json!({ "requestId": "7", "timestamp": 1.0 })).is_none());
|
||||
assert!(network_event(&json!({ "data": { "requestId": "7" } })).is_none());
|
||||
// A request made by the page's parser has no script behind it.
|
||||
let parser = event(
|
||||
"requestWillBeSent",
|
||||
json!({ "requestId": "9", "timestamp": 1.0, "request": { "method": "GET", "url": "http://localhost/" },
|
||||
"initiator": { "type": "parser" } }),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(parser.initiator.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_response_body_is_cut_at_the_limit_on_a_character() {
|
||||
let whole = response_body(&json!({ "body": "héllo", "base64Encoded": false }), 100);
|
||||
assert_eq!((whole.body.as_str(), whole.base64_encoded, whole.truncated), ("héllo", false, false));
|
||||
// `é` is two bytes: a cut inside it backs off to the character before.
|
||||
let cut = response_body(&json!({ "body": "héllo" }), 2);
|
||||
assert_eq!((cut.body.as_str(), cut.truncated), ("h", true));
|
||||
let binary = response_body(&json!({ "body": "AAEC", "base64Encoded": true }), 100);
|
||||
assert!(binary.base64_encoded && !binary.truncated);
|
||||
assert_eq!(response_body(&json!({}), 100).body, "");
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ use connection::{Connection, DapError};
|
||||
use futures::future::join_all;
|
||||
use ide_core::{DebugSessionId, IdeError, IdeResult, SharedSink};
|
||||
use ide_language::LanguageRegistry;
|
||||
pub use js_debug::JsResponseBody;
|
||||
pub use jvm_memory::*;
|
||||
use parking_lot::Mutex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -140,6 +141,8 @@ pub struct DebugCapabilities {
|
||||
/// The session debugs a page in a browser: js-debug's requests for pages apply
|
||||
/// ([`DebugManager::js_set_custom_breakpoints`]).
|
||||
pub js_page: bool,
|
||||
/// The adapter evaluates expressions while the program runs, without a stack frame.
|
||||
pub live_evaluate: bool,
|
||||
}
|
||||
|
||||
/// Result of [`DebugManager::redefine_classes`].
|
||||
@@ -595,6 +598,7 @@ pub(crate) fn capabilities_from(caps: &Value) -> DebugCapabilities {
|
||||
jvm_instances: false,
|
||||
jvm_heap: false,
|
||||
js_page: false,
|
||||
live_evaluate: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -605,6 +609,7 @@ pub(crate) fn session_capabilities(session: &Session) -> DebugCapabilities {
|
||||
caps.jvm_instances = session.jvm_inspector.lock().is_some();
|
||||
caps.jvm_heap = JVM_MEMORY_ADAPTERS.contains(&session.adapter.as_str());
|
||||
caps.js_page = js_debug::is_page(session);
|
||||
caps.live_evaluate = session.adapter == js_debug::ADAPTER;
|
||||
caps
|
||||
}
|
||||
|
||||
@@ -1177,6 +1182,21 @@ impl DebugManager {
|
||||
}
|
||||
}
|
||||
s.launched.send_replace(true);
|
||||
// A page reports its requests from here on. This is the earliest js-debug takes the
|
||||
// request (before its answer to `launch` the page has no adapter and the request is
|
||||
// never answered), and the page is already loading: its document and first scripts
|
||||
// are not in the list.
|
||||
if js_debug::is_page(s) {
|
||||
let answer = s.conn.send_request("enableNetworking", js_debug::networking_arguments())?;
|
||||
let session = s.clone();
|
||||
// Not waited for here: the page runs on. A refusal is told to the Network view,
|
||||
// which would otherwise wait for requests that never come.
|
||||
tokio::spawn(async move {
|
||||
if matches!(answer.await, Ok(Err(_))) {
|
||||
session.emit(ide_core::event::DebugEvent::JsNetworkUnavailable);
|
||||
}
|
||||
});
|
||||
}
|
||||
if let Some(link) = s.jdwp_link.lock().as_ref() {
|
||||
link.release();
|
||||
}
|
||||
|
||||
@@ -839,6 +839,12 @@ async fn on_event(mgr: &Arc<DebugManager>, s: &Arc<Session>, event: &str, mut bo
|
||||
.map(|a| a.iter().filter_map(Value::as_str).map(str::to_owned).collect())
|
||||
.unwrap_or_default(),
|
||||
}),
|
||||
// js-debug, after `enableNetworking`.
|
||||
"networkEvent" => {
|
||||
if let Some(event) = crate::js_debug::network_event(&body) {
|
||||
s.emit(DebugEvent::JsNetwork { event });
|
||||
}
|
||||
}
|
||||
_ => tracing::trace!("dap: ignored event {event}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,8 +160,13 @@ struct Script {
|
||||
launch_after_configuration: bool,
|
||||
/// Requests of the adapter's own (js-debug's `setCustomBreakpoints`…) answered with an empty body.
|
||||
accept: &'static [&'static str],
|
||||
/// Requests of the adapter's own answered with a body and followed by events.
|
||||
custom: Option<fn(&Value) -> Option<CustomReply>>,
|
||||
}
|
||||
|
||||
/// The answer to a request of the adapter's own: the response body, then events (name, body).
|
||||
type CustomReply = (Value, Vec<(&'static str, Value)>);
|
||||
|
||||
impl Default for Script {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@@ -194,6 +199,7 @@ impl Default for Script {
|
||||
gdb15_starts_at_launch: false,
|
||||
launch_after_configuration: false,
|
||||
accept: &[],
|
||||
custom: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -512,6 +518,16 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
|
||||
}
|
||||
ok!(msg, json!({}))
|
||||
}
|
||||
other if script.custom.and_then(|custom| custom(&msg)).is_some() => {
|
||||
if starting.load(Ordering::Acquire) {
|
||||
log.lock().push(json!({"type": "dropped", "command": other}));
|
||||
}
|
||||
let (body, events) = script.custom.and_then(|custom| custom(&msg)).unwrap();
|
||||
ok!(msg, body);
|
||||
for (event, body) in events {
|
||||
send!(json!({"type": "event", "event": event, "body": body}));
|
||||
}
|
||||
}
|
||||
other => send!(
|
||||
json!({"type": "response", "request_seq": msg["seq"], "command": other, "success": false, "message": "unsupported"})
|
||||
),
|
||||
@@ -966,16 +982,16 @@ async fn a_browser_page_session_has_the_page_capability() {
|
||||
let h = harness(Script { start_debugging: true, ..Script::default() });
|
||||
let browser = h.start(js_config("pwa-chrome")).await.unwrap();
|
||||
let page = h.manager.capabilities(child_of(&h, browser).await).unwrap();
|
||||
assert!(page.js_page);
|
||||
assert!(page.js_page && page.live_evaluate);
|
||||
assert!(!h.manager.capabilities(browser).unwrap().js_page, "the launch itself is not a page");
|
||||
|
||||
let node = h.start(js_config("pwa-node")).await.unwrap();
|
||||
let process = h.manager.capabilities(child_of(&h, node).await).unwrap();
|
||||
assert!(!process.js_page);
|
||||
assert!(!process.js_page && process.live_evaluate);
|
||||
|
||||
let python = h.start(config()).await.unwrap();
|
||||
let script = h.manager.capabilities(child_of(&h, python).await).unwrap();
|
||||
assert!(!script.js_page);
|
||||
assert!(!script.js_page && !script.live_evaluate);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1017,6 +1033,77 @@ async fn a_page_gets_its_requests_once_its_session_has_started() {
|
||||
assert_eq!(dropped, 0, "the request reached the page before it took requests");
|
||||
}
|
||||
|
||||
/// js-debug's network requests: `enableNetworking` is followed by one mirrored browser event.
|
||||
fn network_adapter(msg: &Value) -> Option<CustomReply> {
|
||||
match msg["command"].as_str()? {
|
||||
"enableNetworking" => Some((
|
||||
json!({}),
|
||||
vec![(
|
||||
"networkEvent",
|
||||
json!({ "event": "requestWillBeSent", "data": {
|
||||
"requestId": "7", "timestamp": 1.5, "type": "Fetch",
|
||||
"request": { "method": "GET", "url": "http://localhost:5173/api/items", "headers": {} }
|
||||
} }),
|
||||
)],
|
||||
)),
|
||||
"networkCall" => Some((json!({ "body": "é".repeat(600_000), "base64Encoded": false }), Vec::new())),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_page_reports_its_requests_and_their_bodies() {
|
||||
let h = harness(Script {
|
||||
start_debugging: true,
|
||||
custom: Some(network_adapter),
|
||||
child_launch_delay: Duration::from_millis(100),
|
||||
..Script::default()
|
||||
});
|
||||
let browser = h.start(js_config("pwa-chrome")).await.unwrap();
|
||||
let page = child_of(&h, browser).await;
|
||||
|
||||
// Recording is asked for by the session itself, as soon as js-debug takes the request:
|
||||
// once it has answered the page's `launch` (before that, the request is never answered).
|
||||
let DebugEvent::JsNetwork { event } = h.wait_event(|e| matches!(e, DebugEvent::JsNetwork { .. })).await else {
|
||||
unreachable!()
|
||||
};
|
||||
assert_eq!(
|
||||
requests(&h, "enableNetworking"),
|
||||
[json!({ "mirrorEvents": ["requestWillBeSent", "responseReceived", "loadingFinished", "loadingFailed"] })]
|
||||
);
|
||||
let dropped = h.log.lock().iter().filter(|m| m["type"] == "dropped").count();
|
||||
assert_eq!(dropped, 0, "recording was asked for before the page took requests");
|
||||
assert_eq!((event.request_id.as_str(), event.timestamp_ms), ("7", 1500));
|
||||
assert_eq!(event.url.as_deref(), Some("http://localhost:5173/api/items"));
|
||||
|
||||
let body = h.manager.js_response_body(page, "7".into()).await.unwrap();
|
||||
assert_eq!(requests(&h, "networkCall"), [json!({ "method": "getResponseBody", "params": { "requestId": "7" } })]);
|
||||
// 1.2 MB of two-byte characters: cut at the limit, on a character.
|
||||
assert!(body.truncated && !body.base64_encoded);
|
||||
assert_eq!(body.body.len(), 1_048_576);
|
||||
assert!(body.body.chars().all(|c| c == 'é'));
|
||||
|
||||
assert_eq!(
|
||||
h.manager.js_response_body(browser, "7".into()).await.unwrap_err().kind,
|
||||
ide_core::error::ErrorKind::Unavailable
|
||||
);
|
||||
|
||||
// A Node.js program is not a page: nothing is asked of it.
|
||||
let node = h.start(js_config("pwa-node")).await.unwrap();
|
||||
child_of(&h, node).await;
|
||||
h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await;
|
||||
assert_eq!(requests(&h, "enableNetworking").len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_page_whose_requests_the_adapter_does_not_report_says_so() {
|
||||
// A js-debug build without `enableNetworking`.
|
||||
let h = harness(Script { start_debugging: true, ..Script::default() });
|
||||
let browser = h.start(js_config("pwa-chrome")).await.unwrap();
|
||||
child_of(&h, browser).await;
|
||||
h.wait_event(|e| matches!(e, DebugEvent::JsNetworkUnavailable)).await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_child_session_is_named_by_the_adapter_unless_it_is_a_blank_page() {
|
||||
assert_eq!(child_name(&json!({ "name": "server.js [4120]" }), "Server"), "server.js [4120]");
|
||||
|
||||
@@ -117,6 +117,14 @@ pub(crate) fn oracle_err(e: &oracledb::Error) -> IdeError {
|
||||
// config first. Treat it as a logon failure, so the UI prompts for one.
|
||||
let kind = match e.kind() {
|
||||
oracledb::ErrorKind::NoCredentials => ErrorKind::PermissionDenied,
|
||||
// The thin driver speaks the protocol of Oracle Database 12c and later only.
|
||||
oracledb::ErrorKind::ServerVersionNotSupported => {
|
||||
return IdeError::new(
|
||||
ErrorKind::Unavailable,
|
||||
"This Oracle Database is older than 12c. OpenIDEA connects to Oracle Database 12c and later; \
|
||||
11g and earlier are not supported.",
|
||||
);
|
||||
}
|
||||
_ => ErrorKind::InvalidInput,
|
||||
};
|
||||
return IdeError::new(kind, e.to_string());
|
||||
@@ -571,6 +579,33 @@ mod tests {
|
||||
assert_eq!(connect_string_with(&s("PROD=MYDB"), false).unwrap(), "PROD=MYDB");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_older_than_12c_is_named() {
|
||||
use std::io::{Read, Write};
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let server = std::thread::spawn(move || {
|
||||
let (mut socket, _) = listener.accept().unwrap();
|
||||
let mut connect = [0u8; 1024];
|
||||
let _ = socket.read(&mut connect);
|
||||
// A TNS ACCEPT packet with protocol version 314, as Oracle Database 11g answers.
|
||||
let mut accept = vec![0u8; 32];
|
||||
accept[1] = 32;
|
||||
accept[4] = 2;
|
||||
accept[8..10].copy_from_slice(&314u16.to_be_bytes());
|
||||
let _ = socket.write_all(&accept);
|
||||
});
|
||||
let config = oracledb::Config::default()
|
||||
.set_credentials("scott", "tiger")
|
||||
.set_connect_string(&format!("//127.0.0.1:{port}/ORCL"))
|
||||
.unwrap()
|
||||
.set_transport_connect_timeout(Some(std::time::Duration::from_secs(5)));
|
||||
let err = oracle_err(&oracledb::connect(config).err().expect("an 11g server is refused"));
|
||||
assert_eq!(err.kind, ErrorKind::Unavailable, "{err:?}");
|
||||
assert!(err.message.contains("older than 12c"), "{}", err.message);
|
||||
server.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tns_alias_resolves_when_tnsnames_exists() {
|
||||
let alias = DataSource {
|
||||
|
||||
@@ -12,6 +12,7 @@ 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)]
|
||||
@@ -179,6 +180,7 @@ 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).
|
||||
@@ -472,11 +474,52 @@ 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 =
|
||||
@@ -487,9 +530,5 @@ pub fn console_path(host: &Host, id: &str) -> IdeResult<IpcPath> {
|
||||
};
|
||||
let dir = host.data_dir.join("consoles").join("db").join(&name);
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
let file = dir.join("console.sql");
|
||||
if !file.exists() {
|
||||
std::fs::write(&file, "")?;
|
||||
}
|
||||
Ok(file.into())
|
||||
Ok(dir)
|
||||
}
|
||||
@@ -382,6 +382,15 @@ pub async fn debug_js_custom_breakpoints(
|
||||
host.debugger.js_set_custom_breakpoints(session, ids, xhr).await
|
||||
}
|
||||
|
||||
pub async fn debug_js_response_body(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
session: DebugSessionId,
|
||||
request_id: String,
|
||||
) -> IdeResult<ide_dap::JsResponseBody> {
|
||||
host.debugger.js_response_body(session, request_id).await
|
||||
}
|
||||
|
||||
pub fn debug_capabilities(host: &Host, _window: &str, session: DebugSessionId) -> IdeResult<DebugCapabilities> {
|
||||
host.debugger.capabilities(session)
|
||||
}
|
||||
@@ -598,6 +607,9 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
debug_restart_frame(&c.host, &c.window, c.arg("session")?, c.arg("frameId")?).await
|
||||
});
|
||||
d.add("debug_capabilities", |c| async move { debug_capabilities(&c.host, &c.window, c.arg("session")?) });
|
||||
d.add("debug_js_response_body", |c| async move {
|
||||
debug_js_response_body(&c.host, &c.window, c.arg("session")?, c.arg("requestId")?).await
|
||||
});
|
||||
d.add("debug_js_custom_breakpoints", |c| async move {
|
||||
debug_js_custom_breakpoints(&c.host, &c.window, c.arg("session")?, c.arg("ids")?, c.arg("xhr")?).await
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ use super::doc::blocking;
|
||||
use crate::Host;
|
||||
use crate::Project;
|
||||
use crate::rpc::Dispatcher;
|
||||
use ide_core::{DocId, IdeResult, IpcPath, Location, Pos};
|
||||
use ide_core::{DocId, IdeResult, IpcPath, LanguageId, Location, Pos};
|
||||
use ide_index::{ExtensionCount, FileMatch, IndexStatus, SymbolFilter, SymbolMatch};
|
||||
use ide_language::{FileReference, WebReference};
|
||||
use ide_workspace::file_refs::{self, PathCompletion};
|
||||
@@ -22,6 +22,23 @@ 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();
|
||||
@@ -174,6 +191,74 @@ 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,
|
||||
@@ -249,6 +334,7 @@ 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
|
||||
});
|
||||
|
||||
@@ -585,6 +585,21 @@ pub async fn vcs_stage(host: &Host, window: &str, paths: Vec<PathBuf>) -> IdeRes
|
||||
blocking(move || per_backend(&project, &paths, None, |r, paths| r.stage(paths))).await
|
||||
}
|
||||
|
||||
/// Adds `commit` to annotate's ignore list in `path`'s repository (the file `blame.ignoreRevsFile`
|
||||
/// names, else `.git-blame-ignore-revs`, which the local config then names).
|
||||
pub async fn vcs_ignore_blame_revision(host: &Host, window: &str, path: PathBuf, commit: String) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
let result = project.with_disk_sync(|| project.with_repo_of(&path, |r| r.ignore_blame_revision(&commit)));
|
||||
if result.is_ok() {
|
||||
project.sink.emit(ide_core::IdeEvent::VcsChanged { root: project.root.clone().into() });
|
||||
}
|
||||
result
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Project-host operation (also registered with the remote dispatcher).
|
||||
/// Returns the ignore files that were updated, so the UI can show them.
|
||||
pub async fn vcs_add_to_ignore(
|
||||
@@ -792,12 +807,19 @@ pub async fn vcs_stash_push(
|
||||
message: Option<String>,
|
||||
include_untracked: bool,
|
||||
keep_index: bool,
|
||||
paths: Option<Vec<PathBuf>>,
|
||||
) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let paths = paths.unwrap_or_default();
|
||||
blocking(move || {
|
||||
project.with_repo_recorded(repo.as_deref(), Some("Git stash"), |r| {
|
||||
r.stash_push(message.as_deref(), include_untracked, keep_index)
|
||||
if paths.is_empty() {
|
||||
return project.with_repo_recorded(repo.as_deref(), Some("Git stash"), |r| {
|
||||
r.stash_push(message.as_deref(), include_untracked, keep_index, &[])
|
||||
});
|
||||
}
|
||||
per_repo(&project, &paths, Some("Git stash"), |r, paths| {
|
||||
r.stash_push(message.as_deref(), include_untracked, keep_index, paths)
|
||||
})
|
||||
})
|
||||
.await
|
||||
@@ -1462,6 +1484,9 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("vcs_add_to_ignore", |c| async move {
|
||||
vcs_add_to_ignore(&c.host, &c.window, c.arg("paths")?, c.arg("destination")?).await
|
||||
});
|
||||
d.add("vcs_ignore_blame_revision", |c| async move {
|
||||
vcs_ignore_blame_revision(&c.host, &c.window, c.arg("path")?, c.arg("commit")?).await
|
||||
});
|
||||
d.add("vcs_unstage", |c| async move { vcs_unstage(&c.host, &c.window, c.arg("paths")?).await });
|
||||
d.add("vcs_stage_patch", |c| async move { vcs_stage_patch(&c.host, &c.window, c.arg("patch")?).await });
|
||||
d.add("vcs_discard", |c| async move { vcs_discard(&c.host, &c.window, c.arg("paths")?).await });
|
||||
@@ -1520,6 +1545,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
c.arg("message")?,
|
||||
c.arg("includeUntracked")?,
|
||||
c.arg("keepIndex")?,
|
||||
c.arg("paths")?,
|
||||
)
|
||||
.await
|
||||
});
|
||||
|
||||
@@ -357,6 +357,7 @@ 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"
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
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());
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
//! 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);
|
||||
}
|
||||
@@ -821,8 +821,10 @@ fn a_reporting_task_outlives_its_background_budget() {
|
||||
|
||||
#[test]
|
||||
fn background_tasks_scale_and_renew_cpu_fuel_budget() {
|
||||
// Room for activation (about 3_100 on macOS, more on Windows) but not for `patient`'s
|
||||
// whole run (about 9_000) without the refill on each report.
|
||||
let f = fixture(Limits {
|
||||
fuel: 3_500,
|
||||
fuel: 6_000,
|
||||
timeout: Duration::from_millis(500),
|
||||
background: Duration::from_millis(5_000),
|
||||
..Limits::default()
|
||||
@@ -831,14 +833,14 @@ fn background_tasks_scale_and_renew_cpu_fuel_budget() {
|
||||
if f.host.set_capability("misbehave", "process:git", true).is_err() {
|
||||
return;
|
||||
}
|
||||
// Warm up activation in its own call so `patient` starts with a fresh 3_500-fuel budget.
|
||||
// Warm up activation in its own call so `patient` starts with a fresh budget.
|
||||
assert_eq!(f.run("misbehave", "echo", Some("warm")).unwrap().as_deref(), Some("warm"));
|
||||
|
||||
let out = f.run("misbehave", "patient", None).expect("background task succeeds").expect("output");
|
||||
assert_eq!(out, "still here");
|
||||
|
||||
let err = f.run("misbehave", "spin", None).unwrap_err();
|
||||
assert!(err.contains("exceeded its CPU budget (3500 fuel per call)"), "{err}");
|
||||
assert!(err.contains("exceeded its CPU budget (6000 fuel per call)"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -365,6 +365,7 @@ 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",
|
||||
|
||||
@@ -66,6 +66,7 @@ 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",
|
||||
|
||||
@@ -102,7 +102,7 @@ async fn stash_and_unstash_reload_clean_open_documents_before_returning() {
|
||||
let f = Fixture::new().await;
|
||||
let edited = format!("{ORIGINAL}\n");
|
||||
f.saved_edit(&edited);
|
||||
vcs::vcs_stash_push(&f.host, "main", None, None, false, false).await.unwrap();
|
||||
vcs::vcs_stash_push(&f.host, "main", None, None, false, false, None).await.unwrap();
|
||||
f.assert_clean(ORIGINAL);
|
||||
vcs::vcs_stash_apply(&f.host, "main", None, 0, true, false, None).await.unwrap();
|
||||
f.assert_clean(&edited);
|
||||
|
||||
@@ -168,7 +168,7 @@ async fn change_commit_branch_stash_and_merge_a_conflict() {
|
||||
let wip = format!("{}// work in progress\n", f.text(main_rs).await);
|
||||
f.host.project(WINDOW).unwrap().documents.replace_all(main_rs, &wip).unwrap();
|
||||
f.save(main_rs).await;
|
||||
ops::vcs::vcs_stash_push(&f.host, WINDOW, None, Some("wip".into()), false, false).await.unwrap();
|
||||
ops::vcs::vcs_stash_push(&f.host, WINDOW, None, Some("wip".into()), false, false, None).await.unwrap();
|
||||
assert!(f.status().await.is_empty());
|
||||
assert!(!f.text(main_rs).await.contains("work in progress"));
|
||||
let stashes = ops::vcs::vcs_stashes(&f.host, WINDOW, None).await.unwrap();
|
||||
|
||||
@@ -27,17 +27,38 @@ fn git_dir(root: &Path) -> Option<std::path::PathBuf> {
|
||||
Some(if path.is_absolute() { path.to_owned() } else { root.join(path) })
|
||||
}
|
||||
|
||||
/// The config and exclude file of the Git directory `git`; a linked worktree's directory
|
||||
/// (`.git/worktrees/<name>`) reads the shared ones its `commondir` names as well.
|
||||
fn insert_git_inputs(out: &mut std::collections::BTreeSet<std::path::PathBuf>, git: &Path) {
|
||||
out.insert(git.join("config"));
|
||||
out.insert(git.join("info/exclude"));
|
||||
if let Ok(common) = std::fs::read_to_string(git.join("commondir")) {
|
||||
let common = Path::new(common.trim());
|
||||
let common = if common.is_absolute() { common.to_owned() } else { git.join(common) };
|
||||
out.insert(common.join("config"));
|
||||
out.insert(common.join("info/exclude"));
|
||||
}
|
||||
}
|
||||
|
||||
/// External ignore/config inputs, including candidates that do not exist yet.
|
||||
/// These are outside the directory manifest (`.git` is skipped), so the full
|
||||
/// path set must match at warm start.
|
||||
pub(crate) fn external_ignore_candidates(root: &Path) -> Vec<std::path::PathBuf> {
|
||||
let mut out = std::collections::BTreeSet::new();
|
||||
if let Some(git) = git_dir(root) {
|
||||
out.insert(git.join("config"));
|
||||
out.insert(git.join("info/exclude"));
|
||||
insert_git_inputs(&mut out, &git);
|
||||
}
|
||||
out.insert(root.join(".git/config"));
|
||||
out.insert(root.join(".git/info/exclude"));
|
||||
// The walker also reads the ignore files above the root (`parents`), and a project nested
|
||||
// in a repository uses that repository's exclude file and config.
|
||||
for dir in root.ancestors().skip(1) {
|
||||
out.insert(dir.join(".gitignore"));
|
||||
out.insert(dir.join(".ignore"));
|
||||
if let Some(git) = git_dir(dir) {
|
||||
insert_git_inputs(&mut out, &git);
|
||||
}
|
||||
}
|
||||
if let Some(path) = ignore::gitignore::gitconfig_excludes_path() {
|
||||
out.insert(path);
|
||||
}
|
||||
|
||||
@@ -797,6 +797,52 @@ fn warm_start_detects_changed_ignore_rules() {
|
||||
assert_eq!(idx.status().files, 1, "an updated ignore rule invalidates the warm snapshot");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warm_start_detects_changed_ignore_rules_above_the_project() {
|
||||
// The project is a folder of a repository whose ignore files sit above it.
|
||||
let f = Fixture::new(&[("generated/a.rs", "fn a() {}\n"), ("src/b.rs", "fn b() {}\n")]);
|
||||
let repo = f.root.parent().unwrap().to_owned();
|
||||
std::fs::create_dir_all(repo.join(".git/info")).unwrap();
|
||||
std::fs::write(repo.join(".git/HEAD"), "ref: refs/heads/main\n").unwrap();
|
||||
let idx = f.open();
|
||||
assert_eq!(idx.status().files, 2);
|
||||
drop(idx);
|
||||
|
||||
std::fs::write(repo.join(".gitignore"), "proj/generated/\n").unwrap();
|
||||
let idx = f.open();
|
||||
assert_eq!(idx.status().files, 1, "a new ignore file above the project invalidates the warm snapshot");
|
||||
drop(idx);
|
||||
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
std::fs::write(repo.join(".gitignore"), "").unwrap();
|
||||
std::fs::write(repo.join(".git/info/exclude"), "proj/src/\n").unwrap();
|
||||
let idx = f.open();
|
||||
assert_eq!(idx.status().files, 1, "so do the enclosing repository's changed rules");
|
||||
assert!(!symbols(&idx, "a", SymbolFilter::All).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warm_start_detects_a_changed_exclude_file_of_a_linked_worktree() {
|
||||
// The project is a linked worktree: its `.git` file points into the main repository,
|
||||
// whose shared `info/exclude` the walker reads through `commondir`.
|
||||
let f = Fixture::new(&[("generated/a.rs", "fn a() {}\n"), ("src/b.rs", "fn b() {}\n")]);
|
||||
let main = f.root.parent().unwrap().join("main/.git");
|
||||
let worktree = main.join("worktrees/proj");
|
||||
std::fs::create_dir_all(main.join("info")).unwrap();
|
||||
std::fs::create_dir_all(&worktree).unwrap();
|
||||
std::fs::write(main.join("HEAD"), "ref: refs/heads/main\n").unwrap();
|
||||
std::fs::write(worktree.join("HEAD"), "ref: refs/heads/wt\n").unwrap();
|
||||
std::fs::write(worktree.join("commondir"), "../..\n").unwrap();
|
||||
std::fs::write(f.root.join(".git"), format!("gitdir: {}\n", worktree.display())).unwrap();
|
||||
let idx = f.open();
|
||||
assert_eq!(idx.status().files, 2);
|
||||
drop(idx);
|
||||
|
||||
std::fs::write(main.join("info/exclude"), "generated/\n").unwrap();
|
||||
let idx = f.open();
|
||||
assert_eq!(idx.status().files, 1, "the shared exclude file invalidates the warm snapshot");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warm_start_detects_a_new_file_in_an_existing_empty_directory() {
|
||||
let f = Fixture::new(&[("src/a.rs", "fn a() {}\n")]);
|
||||
|
||||
@@ -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, 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, 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,
|
||||
};
|
||||
|
||||
/// Parsed form of `languages/<id>.toml`. Backend-only; the UI gets [`LanguageInfo`].
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
//! 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());
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ mod external;
|
||||
mod folding;
|
||||
mod grammars;
|
||||
mod highlight;
|
||||
mod inheritance;
|
||||
pub(crate) mod lines;
|
||||
mod literals;
|
||||
mod outline;
|
||||
@@ -22,6 +23,7 @@ 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;
|
||||
|
||||
@@ -218,6 +218,37 @@ impl ClientHandler {
|
||||
true
|
||||
}
|
||||
|
||||
/// Ends every running task of a tsserver that died at its heap limit: the process
|
||||
/// behind them is gone, so no `end` will ever arrive. Without this the tasks sit
|
||||
/// as zombies until [`PROGRESS_STALE`] and the server stays "loading", holding
|
||||
/// empty answers back for [`LOADING_WAIT`] — while vtsls already restarts
|
||||
/// tsserver, which re-reports its own loading.
|
||||
fn tsserver_died(&self, mgr: &LspManager) {
|
||||
let events: Vec<IdeEvent> = {
|
||||
let mut progress = self.server.progress.lock();
|
||||
progress
|
||||
.drain()
|
||||
.map(|(_, p)| IdeEvent::Task {
|
||||
id: p.task,
|
||||
title: p.title,
|
||||
message: p.message,
|
||||
fraction: Some(1.0),
|
||||
done: true,
|
||||
cancellable: false,
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let ended = !events.is_empty();
|
||||
for event in events {
|
||||
self.server.emit(mgr.sink(), event);
|
||||
}
|
||||
if ended {
|
||||
self.server.refresh_busy_status(mgr.sink());
|
||||
self.server.progress_ended();
|
||||
}
|
||||
self.server.set_loading(mgr.sink(), false);
|
||||
}
|
||||
|
||||
fn publish_diagnostics(&self, mgr: &LspManager, params: Value) {
|
||||
// Problems come only from servers used for `diagnostics`.
|
||||
if self.server.running_gen(self.generation).is_some_and(|run| !run.caps.read().restriction.diagnostics()) {
|
||||
@@ -410,6 +441,10 @@ impl Handler for ClientHandler {
|
||||
let text = params.get("message").and_then(Value::as_str).unwrap_or_default();
|
||||
if self.server.config.tsserver_project && tsserver_ran_out_of_memory(text) {
|
||||
tracing::warn!("[{}] {text}", self.server.name);
|
||||
// The tasks of the dead tsserver never see their `end`: end them now
|
||||
// instead of letting them sit as zombies until the stale window, and
|
||||
// stop holding queries back — the restarted instance re-reports.
|
||||
self.tsserver_died(&mgr);
|
||||
if mgr.first_message(&self.server.name, TSSERVER_OUT_OF_MEMORY) {
|
||||
mgr.sink().emit(IdeEvent::Notify {
|
||||
level: NotifyLevel::Error,
|
||||
@@ -567,7 +602,12 @@ fn merge(base: &mut Value, layer: Value) {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::tsserver_ran_out_of_memory;
|
||||
use super::*;
|
||||
use crate::server::Progress;
|
||||
use ide_core::event::SharedSink;
|
||||
use ide_core::{LanguageId, TaskId};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
#[test]
|
||||
fn tsserver_aborts_are_out_of_memory() {
|
||||
@@ -579,4 +619,85 @@ mod tests {
|
||||
assert!(!tsserver_ran_out_of_memory("Code: 134."));
|
||||
assert!(tsserver_ran_out_of_memory("[error] TSServer exited. Code: 134. Signal: null\n"));
|
||||
}
|
||||
|
||||
/// A vtsls server with two running tasks, one marked stalled, and `loading` set:
|
||||
/// the state a tsserver OOM leaves behind.
|
||||
fn loaded_vtsls(events: Arc<Mutex<Vec<IdeEvent>>>) -> (Arc<LspManager>, Arc<crate::server::Server>) {
|
||||
let sink: SharedSink = {
|
||||
let events = events.clone();
|
||||
Arc::new(move |event: IdeEvent| events.lock().unwrap().push(event))
|
||||
};
|
||||
let languages = Arc::new(ide_language::LanguageRegistry::from_configs(vec![]));
|
||||
let mgr = LspManager::new(PathBuf::from("/p"), languages, sink);
|
||||
let cfg: ide_language::LanguageServerConfig =
|
||||
toml::from_str("name = \"vtsls\"\ncommand = \"vtsls\"\ntsserver-project = true").unwrap();
|
||||
let server = Arc::new(crate::server::Server::new(cfg, PathBuf::from("/p"), LanguageId::new("vue")));
|
||||
server.progress.lock().insert(
|
||||
"loading".into(),
|
||||
Progress {
|
||||
task: TaskId::next(),
|
||||
title: "Initializing 'tsconfig.json'".into(),
|
||||
message: None,
|
||||
cancel: None,
|
||||
last: tokio::time::Instant::now(),
|
||||
fraction: Some(0.1),
|
||||
stalled: false,
|
||||
},
|
||||
);
|
||||
server.progress.lock().insert(
|
||||
"stuck".into(),
|
||||
Progress {
|
||||
task: TaskId::next(),
|
||||
title: "Analyzing 'index.vue' and its dependencies".into(),
|
||||
message: None,
|
||||
cancel: None,
|
||||
last: tokio::time::Instant::now(),
|
||||
fraction: Some(0.2),
|
||||
stalled: true,
|
||||
},
|
||||
);
|
||||
let quiet: SharedSink = Arc::new(|_: IdeEvent| {});
|
||||
server.set_loading(&quiet, true);
|
||||
(mgr, server)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tsserver_dying_ends_every_task_and_the_loading_state() {
|
||||
let events = Arc::new(Mutex::new(Vec::new()));
|
||||
let (mgr, server) = loaded_vtsls(events.clone());
|
||||
let handler = ClientHandler::new(Arc::downgrade(&mgr), server.clone(), 0);
|
||||
|
||||
handler.tsserver_died(&mgr);
|
||||
|
||||
// Both tasks ended (done: true), including the stalled one; none left running.
|
||||
let tasks: Vec<_> = events
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
IdeEvent::Task { title, done, .. } => Some((title.clone(), *done)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(tasks.len(), 2, "{tasks:?}");
|
||||
assert!(tasks.iter().all(|(_, done)| *done), "{tasks:?}");
|
||||
assert!(server.progress.lock().is_empty());
|
||||
assert!(!server.is_loading());
|
||||
assert!(!server.is_busy());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tsserver_dying_without_tasks_only_clears_loading() {
|
||||
let events = Arc::new(Mutex::new(Vec::new()));
|
||||
let (mgr, server) = loaded_vtsls(events.clone());
|
||||
server.progress.lock().clear();
|
||||
let handler = ClientHandler::new(Arc::downgrade(&mgr), server.clone(), 0);
|
||||
|
||||
handler.tsserver_died(&mgr);
|
||||
|
||||
assert!(events.lock().unwrap().is_empty());
|
||||
assert!(!server.is_loading());
|
||||
// A status event may be published, but no task events.
|
||||
assert!(server.progress.lock().is_empty());
|
||||
}
|
||||
}
|
||||
@@ -482,7 +482,8 @@ mod tests {
|
||||
std::fs::create_dir_all(output.join("test001")).unwrap();
|
||||
// As jdtls writes it: the class's source file name in the path, the library's
|
||||
// classpath attributes after the folder.
|
||||
let escaped = output.to_string_lossy().replace('/', "\\/");
|
||||
// JDT writes the folder as a portable path, `/` on Windows too, each `/` escaped.
|
||||
let escaped = output.to_string_lossy().replace('\\', "/").replace('/', "\\/");
|
||||
let handle = format!("=demo/{escaped}=/ai.openidea.kotlin=/true=/<test001(Db.class");
|
||||
let uri = format!("jdt://contents/main-0/test001/Db.kt?{}", utf8_percent_encode(&handle, PATH));
|
||||
assert_eq!(folder_class(&uri), Some(("test001".into(), "Db".into())));
|
||||
|
||||
@@ -40,22 +40,37 @@ const LOOSE: &AsciiSet = &CONTROLS
|
||||
.add(b'|')
|
||||
.add(b'}');
|
||||
|
||||
/// Percent-encodes the characters URIs don't allow in every `file:` URI in `value`, string
|
||||
/// values and object keys alike (a workspace edit's `changes`). Eclipse's jdtls sends a path
|
||||
/// as is (`file:///H:/项目/…`), which `lsp_types::Uri` rejects, failing the whole response.
|
||||
/// Fields of LSP messages whose value is a URI.
|
||||
const URI_FIELDS: [&str; 9] =
|
||||
["uri", "targetUri", "newUri", "oldUri", "baseUri", "scopeUri", "rootUri", "target", "href"];
|
||||
|
||||
/// Percent-encodes the characters URIs don't allow in the `file:` URIs of `value`: the URI
|
||||
/// fields and the keys of a workspace edit's `changes`. Eclipse's jdtls sends a path as is
|
||||
/// (`file:///H:/项目/…`), which `lsp_types::Uri` rejects, failing the whole response. Other
|
||||
/// strings stay as sent: an edit's `newText` may well start with `file:` (YAML, `.env`).
|
||||
pub fn encode_loose_file_uris(value: &mut Value) {
|
||||
match value {
|
||||
Value::String(s) => {
|
||||
if let Some(encoded) = encode_loose(s) {
|
||||
*s = encoded;
|
||||
}
|
||||
}
|
||||
Value::Array(items) => items.iter_mut().for_each(encode_loose_file_uris),
|
||||
Value::Object(map) => {
|
||||
if map.keys().any(|k| encode_loose(k).is_some()) {
|
||||
*map = std::mem::take(map).into_iter().map(|(k, v)| (encode_loose(&k).unwrap_or(k), v)).collect();
|
||||
for (key, value) in map.iter_mut() {
|
||||
match value {
|
||||
Value::String(s) if URI_FIELDS.contains(&key.as_str()) => {
|
||||
if let Some(encoded) = encode_loose(s) {
|
||||
*s = encoded;
|
||||
}
|
||||
}
|
||||
Value::Object(changes) if key == "changes" => {
|
||||
if changes.keys().any(|k| encode_loose(k).is_some()) {
|
||||
*changes = std::mem::take(changes)
|
||||
.into_iter()
|
||||
.map(|(k, v)| (encode_loose(&k).unwrap_or(k), v))
|
||||
.collect();
|
||||
}
|
||||
changes.values_mut().for_each(encode_loose_file_uris);
|
||||
}
|
||||
_ => encode_loose_file_uris(value),
|
||||
}
|
||||
}
|
||||
map.values_mut().for_each(encode_loose_file_uris);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -161,9 +176,28 @@ mod tests {
|
||||
assert!(v["edit"]["changes"].get("file:///tmp/%E7%9B%AE%E5%BD%95/b.java").is_some());
|
||||
assert_eq!(v["text"], "项目", "only file: URIs change");
|
||||
// Already encoded: untouched.
|
||||
let mut encoded = serde_json::json!("file:///a%20b/%E9%A1%B9.java");
|
||||
let mut encoded = serde_json::json!({ "uri": "file:///a%20b/%E9%A1%B9.java" });
|
||||
encode_loose_file_uris(&mut encoded);
|
||||
assert_eq!(encoded, "file:///a%20b/%E9%A1%B9.java");
|
||||
assert_eq!(encoded["uri"], "file:///a%20b/%E9%A1%B9.java");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_that_starts_with_file_is_not_a_uri() {
|
||||
// A YAML formatting edit and a completion whose text begins with `file:`.
|
||||
let mut v = serde_json::json!({ "result": [
|
||||
{ "range": { "start": { "line": 0, "character": 0 }, "end": { "line": 1, "character": 0 } }, "newText": "file: a b\n - c\n" },
|
||||
{ "label": "file:", "insertText": "file: ${1:path}", "documentation": "file: the input" }
|
||||
], "edit": { "changes": { "file:///tmp/a b.yaml": [{ "newText": "file: x y" }] } } });
|
||||
encode_loose_file_uris(&mut v);
|
||||
assert_eq!(v["result"][0]["newText"], "file: a b\n - c\n");
|
||||
assert_eq!(v["result"][1]["insertText"], "file: ${1:path}");
|
||||
assert_eq!(v["result"][1]["documentation"], "file: the input");
|
||||
let changes = &v["edit"]["changes"]["file:///tmp/a%20b.yaml"];
|
||||
assert_eq!(changes[0]["newText"], "file: x y");
|
||||
// A diagnostic's help link is a URI too.
|
||||
let mut d = serde_json::json!({ "codeDescription": { "href": "file:///tmp/help files/rule.html" } });
|
||||
encode_loose_file_uris(&mut d);
|
||||
assert_eq!(d["codeDescription"]["href"], "file:///tmp/help%20files/rule.html");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
|
||||
@@ -403,7 +403,7 @@ async fn download_into_copies_only_the_selected_items() {
|
||||
assert!(std::fs::read_dir(&elsewhere).unwrap().next().is_none(), "nothing written through the links");
|
||||
}
|
||||
|
||||
let missing = [server.join("gone").to_str().unwrap().to_owned()];
|
||||
let missing = [server.join("gone").to_str().unwrap().replace('\\', "/")];
|
||||
let report = service
|
||||
.download_into(&FsConnection::Local, &missing, &project, &CancellationToken::new(), &mut |_, _, _| {})
|
||||
.await
|
||||
|
||||
@@ -1168,6 +1168,22 @@ pub fn configure_server(cfg: &mut LanguageServerConfig, root: &Path, server_root
|
||||
let plugin = vue::tsserver_plugin(&vue);
|
||||
merge_init_options(cfg, json!({ "vtsls": { "tsserver": { "globalPlugins": [plugin] } } }));
|
||||
cfg.tsserver_project_extras.extensions = vec![vue::EXTENSION.into()];
|
||||
// Hybrid mode: one program holds every .vue's virtual TypeScript, so the
|
||||
// heap must clear it (vtsls' default 3072 dies in a GC death spiral), and
|
||||
// the syntax tsserver duplicates the plugin and its file watching.
|
||||
merge_init_options(
|
||||
cfg,
|
||||
json!({ "typescript": { "tsserver": { "useSyntaxServer": "never", "maxTsServerMemory": 8192 } } }),
|
||||
);
|
||||
// A configured project resolves its own types; the typings installer only
|
||||
// serves inferred projects and keeps scanning npm in the background.
|
||||
if server_root.ancestors().any(|d| d.join("tsconfig.json").is_file() || d.join("jsconfig.json").is_file()) {
|
||||
merge_init_options(cfg, json!({ "typescript": { "disableAutomaticTypeAcquisition": true } }));
|
||||
}
|
||||
}
|
||||
let memory = s.languages.typescript.tsserver_max_memory;
|
||||
if name == "vtsls" && memory > 0 {
|
||||
merge_init_options(cfg, json!({ "typescript": { "tsserver": { "maxTsServerMemory": memory } } }));
|
||||
}
|
||||
if name == "vtsls"
|
||||
&& cfg.tsserver_project
|
||||
@@ -1814,10 +1830,17 @@ pub fn without_derived_in(config: &RunConfig, root: &Path, fallback: Option<&Pat
|
||||
if path.is_absolute()
|
||||
&& let Some(name) = path.file_name().map(|n| n.to_string_lossy().into_owned())
|
||||
{
|
||||
let mut probe = out.clone();
|
||||
probe.command = name.clone();
|
||||
if applied(&probe).command == config.command {
|
||||
out.command = name;
|
||||
// Generated configurations name the bare program (`java`), also on Windows where the
|
||||
// derived path ends in `java.exe`.
|
||||
let bare =
|
||||
name.len().checked_sub(4).filter(|&at| name[at..].eq_ignore_ascii_case(".exe")).map(|at| &name[..at]);
|
||||
for candidate in bare.into_iter().chain([name.as_str()]) {
|
||||
let mut probe = out.clone();
|
||||
probe.command = candidate.to_owned();
|
||||
if applied(&probe).command == config.command {
|
||||
out.command = candidate.to_owned();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
@@ -2104,6 +2127,74 @@ mod gradle_runtime_tests {
|
||||
mod maven_linkage_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hybrid_vtsls_gets_one_server_a_full_heap_and_no_type_acquisition() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
// The Vue package the hybrid block finds through the command's package dir.
|
||||
let bin = root.join("tools/node_modules/.bin/vue-language-server");
|
||||
let pkg = root.join("tools/node_modules/@vue/language-server/package.json");
|
||||
std::fs::create_dir_all(bin.parent().unwrap()).unwrap();
|
||||
std::fs::create_dir_all(pkg.parent().unwrap()).unwrap();
|
||||
std::fs::write(&bin, "#!/bin/sh\n").unwrap();
|
||||
std::fs::write(&pkg, "{}").unwrap();
|
||||
let mut s = Settings::default();
|
||||
s.tools
|
||||
.insert(vue::SERVER.into(), ide_core::settings::ToolSettings { path: bin.to_string_lossy().into_owned() });
|
||||
std::fs::write(root.join("tsconfig.json"), "{}").unwrap();
|
||||
let mut cfg = custom_server("vtsls", &LanguageServerSettings::default());
|
||||
configure_server(&mut cfg, root, root, &s);
|
||||
let options = serde_json::to_value(cfg.initialization_options).unwrap();
|
||||
assert_eq!(options.pointer("/typescript/tsserver/useSyntaxServer"), Some(&json!("never")));
|
||||
assert_eq!(options.pointer("/typescript/tsserver/maxTsServerMemory"), Some(&json!(8192)));
|
||||
assert_eq!(options.pointer("/typescript/disableAutomaticTypeAcquisition"), Some(&json!(true)));
|
||||
// A user-set limit wins over the automatic one.
|
||||
s.languages.typescript.tsserver_max_memory = 6144;
|
||||
let mut cfg = custom_server("vtsls", &LanguageServerSettings::default());
|
||||
configure_server(&mut cfg, root, root, &s);
|
||||
let options = serde_json::to_value(cfg.initialization_options).unwrap();
|
||||
assert_eq!(options.pointer("/typescript/tsserver/maxTsServerMemory"), Some(&json!(6144)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hybrid_without_a_config_keeps_type_acquisition_and_plain_vtsls_stays_default() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let bin = root.join("tools/node_modules/.bin/vue-language-server");
|
||||
let pkg = root.join("tools/node_modules/@vue/language-server/package.json");
|
||||
std::fs::create_dir_all(bin.parent().unwrap()).unwrap();
|
||||
std::fs::create_dir_all(pkg.parent().unwrap()).unwrap();
|
||||
std::fs::write(&bin, "#!/bin/sh\n").unwrap();
|
||||
std::fs::write(&pkg, "{}").unwrap();
|
||||
let mut s = Settings::default();
|
||||
s.tools
|
||||
.insert(vue::SERVER.into(), ide_core::settings::ToolSettings { path: bin.to_string_lossy().into_owned() });
|
||||
// No tsconfig/jsconfig anywhere: inferred projects still want the typings installer.
|
||||
let mut cfg = custom_server("vtsls", &LanguageServerSettings::default());
|
||||
configure_server(&mut cfg, root, root, &s);
|
||||
let options = serde_json::to_value(cfg.initialization_options).unwrap();
|
||||
assert!(options.pointer("/typescript/disableAutomaticTypeAcquisition").is_none());
|
||||
assert_eq!(options.pointer("/typescript/tsserver/maxTsServerMemory"), Some(&json!(8192)));
|
||||
// Without the Vue package there is no hybrid mode: nothing is injected. The
|
||||
// command points at a bare binary, so the package lookup finds nothing
|
||||
// (a bare name would resolve on the host's PATH and break the test).
|
||||
std::fs::write(root.join("bare-vue-language-server"), "#!/bin/sh\n").unwrap();
|
||||
s.tools
|
||||
.insert(vue::SERVER.into(), ide_core::settings::ToolSettings { path: "bare-vue-language-server".into() });
|
||||
let mut cfg = custom_server("vtsls", &LanguageServerSettings::default());
|
||||
configure_server(&mut cfg, root, root, &s);
|
||||
let options = serde_json::to_value(cfg.initialization_options).unwrap();
|
||||
assert!(options.pointer("/typescript/tsserver/useSyntaxServer").is_none());
|
||||
assert!(options.pointer("/typescript/tsserver/maxTsServerMemory").is_none());
|
||||
// A user-set limit applies to plain TypeScript projects too.
|
||||
s.languages.typescript.tsserver_max_memory = 12288;
|
||||
let mut cfg = custom_server("vtsls", &LanguageServerSettings::default());
|
||||
configure_server(&mut cfg, root, root, &s);
|
||||
let options = serde_json::to_value(cfg.initialization_options).unwrap();
|
||||
assert_eq!(options.pointer("/typescript/tsserver/maxTsServerMemory"), Some(&json!(12288)));
|
||||
assert!(options.pointer("/typescript/tsserver/useSyntaxServer").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_maven_repository_reads_the_user_settings() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -884,6 +884,12 @@ impl Repo {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
write::blame(self, path, current_text, revision, encoding)
|
||||
}
|
||||
/// Adds `commit` to the list `blame.ignoreRevsFile` names, so annotate skips it; with none
|
||||
/// configured, to `.git-blame-ignore-revs` at the worktree root, which the local config then names.
|
||||
pub fn ignore_blame_revision(&self, commit: &str) -> IdeResult<()> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
write::ignore_blame_revision(self, commit)
|
||||
}
|
||||
/// History for Selection: commits that changed `lines` (0-based, inclusive, of the
|
||||
/// current text: `current_text`, or the file on disk), mapped to HEAD's lines first.
|
||||
pub fn block_history(
|
||||
@@ -1262,10 +1268,17 @@ impl Repo {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
write::rebase(self, onto)
|
||||
}
|
||||
/// `paths` (absolute) stashes only those files; empty stashes the whole working tree.
|
||||
/// `keep_index`: changes already staged stay in the index (and the working tree).
|
||||
pub fn stash_push(&self, message: Option<&str>, include_untracked: bool, keep_index: bool) -> IdeResult<()> {
|
||||
pub fn stash_push(
|
||||
&self,
|
||||
message: Option<&str>,
|
||||
include_untracked: bool,
|
||||
keep_index: bool,
|
||||
paths: &[PathBuf],
|
||||
) -> IdeResult<()> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
write::stash_push(self, message, include_untracked, keep_index)
|
||||
write::stash_push(self, message, include_untracked, keep_index, paths)
|
||||
}
|
||||
/// `reinstate_index`: also restore what was staged when the stash was made.
|
||||
pub fn stash_apply(&self, index: u32, pop: bool, reinstate_index: bool) -> IdeResult<()> {
|
||||
|
||||
@@ -43,6 +43,70 @@ fn run_paths(r: &Repo, args: &[&str], paths: &[String]) -> IdeResult<()> {
|
||||
cli::run_paths(r.root(), &base, paths).map(|_| ())
|
||||
}
|
||||
|
||||
/// The annotate ignore list at the worktree root: git's conventional `blame.ignoreRevsFile`.
|
||||
pub const BLAME_IGNORE_REVS_FILE: &str = ".git-blame-ignore-revs";
|
||||
|
||||
/// The file `blame.ignoreRevsFile` names, resolved from the worktree root as git does (it may not
|
||||
/// exist). The value comes from the gitoxide config snapshot, which Restricted Mode still parses,
|
||||
/// so resolving it starts no program.
|
||||
fn configured_ignore_revs(r: &Repo) -> Option<PathBuf> {
|
||||
let repo = r.gix().ok()?;
|
||||
let configured = repo.config_snapshot().string("blame.ignoreRevsFile")?;
|
||||
let configured = configured.to_str_lossy();
|
||||
let configured = configured.trim();
|
||||
if configured.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let path = Path::new(configured);
|
||||
Some(if path.is_absolute() { path.to_path_buf() } else { r.root().join(path) })
|
||||
}
|
||||
|
||||
/// Arguments for the file `blame.ignoreRevsFile` names, which git does not accept as an
|
||||
/// absent file: with a value configured but missing from disk, `--no-ignore-revs-file`
|
||||
/// clears it so annotate still works.
|
||||
fn ignore_revs_args(r: &Repo) -> Vec<String> {
|
||||
match configured_ignore_revs(r) {
|
||||
Some(path) if path.is_file() => vec!["--ignore-revs-file".to_string(), path.to_string_lossy().into_owned()],
|
||||
Some(_) => vec!["--no-ignore-revs-file".to_string()],
|
||||
None => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `existing` (an ignore list's text) with `commit` appended: lines keep their text and order, a
|
||||
/// listed commit isn't repeated, and the result ends with a newline in the list's line ending.
|
||||
fn append_revision(existing: &str, commit: &str) -> String {
|
||||
let revision = commit.trim();
|
||||
let newline = if existing.contains("\r\n") { "\r\n" } else { "\n" };
|
||||
let mut lines: Vec<&str> = existing.lines().collect();
|
||||
if !revision.is_empty() && !lines.iter().any(|line| line.trim() == revision) {
|
||||
lines.push(revision);
|
||||
}
|
||||
if lines.is_empty() { String::new() } else { lines.join(newline) + newline }
|
||||
}
|
||||
|
||||
/// Adds `commit` to the list `blame.ignoreRevsFile` names; with none configured, to
|
||||
/// `.git-blame-ignore-revs` at the worktree root, which the local config then names.
|
||||
pub(crate) fn ignore_blame_revision(r: &Repo, commit: &str) -> IdeResult<()> {
|
||||
let configured = configured_ignore_revs(r);
|
||||
let path = configured.clone().unwrap_or_else(|| r.root().join(BLAME_IGNORE_REVS_FILE));
|
||||
let existing = match std::fs::read_to_string(&path) {
|
||||
Ok(text) => text,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
let updated = append_revision(&existing, commit);
|
||||
if updated != existing {
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
std::fs::write(&path, updated)?;
|
||||
}
|
||||
if configured.is_none() {
|
||||
cli::run(r.root(), &["config", "--local", "blame.ignoreRevsFile", BLAME_IGNORE_REVS_FILE], None)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn blame(
|
||||
r: &Repo,
|
||||
path: &Path,
|
||||
@@ -61,8 +125,9 @@ pub(crate) fn blame(
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
let text = current_text.map(str::to_owned).unwrap_or_else(|| decode(&disk, encoding).into_owned());
|
||||
let ignore = ignore_revs_args(r);
|
||||
if encoding.has_ascii_conversion() {
|
||||
return converted_blame(r, path, &rel, &text, revision, encoding);
|
||||
return converted_blame(r, path, &rel, &text, revision, encoding, &ignore);
|
||||
}
|
||||
let contents = match current_text {
|
||||
Some(_) if crate::diff::normalize_eol(&text) == crate::diff::normalize_eol(&decode(&disk, encoding)) => disk,
|
||||
@@ -94,23 +159,32 @@ pub(crate) fn blame(
|
||||
// older releases reject the pair (`fatal: cannot use --contents with final
|
||||
// commit object name`) and take the same plain path — the edited lines are
|
||||
// marked uncommitted below either way (issue #755).
|
||||
let contents_args = ["--literal-pathspecs", "blame", "--porcelain", "--contents", "-", revision, "--", &rel];
|
||||
let plain_args: &[&str] = if crate::execution_allowed() {
|
||||
&["--literal-pathspecs", "blame", "--porcelain", revision, "--", &rel]
|
||||
} else {
|
||||
&["--literal-pathspecs", "blame", "--no-textconv", "--porcelain", revision, "--", &rel]
|
||||
let contents_args = {
|
||||
let mut args = vec!["--literal-pathspecs", "blame", "--porcelain"];
|
||||
args.extend(ignore.iter().map(String::as_str));
|
||||
args.extend(["--contents", "-", revision, "--", &rel]);
|
||||
args
|
||||
};
|
||||
let plain_args: Vec<&str> = {
|
||||
let mut args = vec!["--literal-pathspecs", "blame"];
|
||||
if !crate::execution_allowed() {
|
||||
args.push("--no-textconv");
|
||||
}
|
||||
args.extend(ignore.iter().map(String::as_str));
|
||||
args.extend(["--porcelain", revision, "--", &rel]);
|
||||
args
|
||||
};
|
||||
let (out, contents_blame) = if crate::execution_allowed() {
|
||||
let out = cli::run_raw(r.root(), &contents_args, Some(&contents))?;
|
||||
if out.code == Some(0) {
|
||||
(out, true)
|
||||
} else if String::from_utf8_lossy(&out.stderr).contains("cannot use --contents") {
|
||||
(run(r.root(), plain_args, None)?, false)
|
||||
(run(r.root(), &plain_args, None)?, false)
|
||||
} else {
|
||||
return Err(cli::failure(&contents_args, &out.stderr, &out.stdout, out.code));
|
||||
}
|
||||
} else {
|
||||
(run(r.root(), plain_args, None)?, false)
|
||||
(run(r.root(), &plain_args, None)?, false)
|
||||
};
|
||||
let mut lines = crate::blame::parse_porcelain(&out.stdout_str());
|
||||
for line in &mut lines {
|
||||
@@ -142,16 +216,21 @@ fn converted_blame(
|
||||
text: &str,
|
||||
revision: &str,
|
||||
encoding: TextEncoding,
|
||||
ignore: &[String],
|
||||
) -> IdeResult<Vec<BlameLine>> {
|
||||
let current = crate::diff::normalize_eol(text);
|
||||
let mut result = uncommitted_lines(current.split_inclusive('\n').count());
|
||||
let Some(base) = r.file_bytes_at(path, Some(revision))? else { return Ok(result) };
|
||||
let args: &[&str] = if crate::execution_allowed() {
|
||||
&["--literal-pathspecs", "blame", "--porcelain", revision, "--", rel]
|
||||
} else {
|
||||
&["--literal-pathspecs", "blame", "--no-textconv", "--porcelain", revision, "--", rel]
|
||||
let args: Vec<&str> = {
|
||||
let mut args = vec!["--literal-pathspecs", "blame"];
|
||||
if !crate::execution_allowed() {
|
||||
args.push("--no-textconv");
|
||||
}
|
||||
args.extend(ignore.iter().map(String::as_str));
|
||||
args.extend(["--porcelain", revision, "--", rel]);
|
||||
args
|
||||
};
|
||||
let out = run(r.root(), args, None)?;
|
||||
let out = run(r.root(), &args, None)?;
|
||||
let physical = crate::blame::parse_porcelain(&out.stdout_str());
|
||||
let raw = crate::text_encoding::decode_raw(&base, encoding);
|
||||
let raw = crate::diff::normalize_eol(&raw);
|
||||
@@ -1049,8 +1128,18 @@ pub(crate) fn rebase(r: &Repo, onto: &str) -> IdeResult<()> {
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn stash_push(r: &Repo, message: Option<&str>, include_untracked: bool, keep_index: bool) -> IdeResult<()> {
|
||||
let mut args = vec!["stash", "push", "-q"];
|
||||
pub(crate) fn stash_push(
|
||||
r: &Repo,
|
||||
message: Option<&str>,
|
||||
include_untracked: bool,
|
||||
keep_index: bool,
|
||||
paths: &[PathBuf],
|
||||
) -> IdeResult<()> {
|
||||
let rel = r.rel_paths(paths)?;
|
||||
// Literal pathspecs only for the chosen files: git's whole-tree stash passes itself `:/`,
|
||||
// which a literal pathspec turns into a file of that name.
|
||||
let mut args = if rel.is_empty() { vec![] } else { vec!["--literal-pathspecs"] };
|
||||
args.extend(["stash", "push", "-q"]);
|
||||
if include_untracked {
|
||||
args.push("--include-untracked");
|
||||
}
|
||||
@@ -1060,6 +1149,10 @@ pub(crate) fn stash_push(r: &Repo, message: Option<&str>, include_untracked: boo
|
||||
if let Some(m) = message.filter(|m| !m.trim().is_empty()) {
|
||||
args.extend(["-m", m]);
|
||||
}
|
||||
if !rel.is_empty() {
|
||||
args.push("--");
|
||||
args.extend(rel.iter().map(String::as_str));
|
||||
}
|
||||
run(r.root(), &args, None).map(|_| ())
|
||||
}
|
||||
|
||||
@@ -1383,3 +1476,19 @@ fn decode_diff(bytes: &[u8], encoding: TextEncoding) -> String {
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::append_revision;
|
||||
|
||||
#[test]
|
||||
fn a_revision_is_appended_once_keeping_the_list() {
|
||||
let (a, b) = ("a".repeat(40), "b".repeat(40));
|
||||
assert_eq!(append_revision("", &b), format!("{b}\n"));
|
||||
assert_eq!(append_revision(&format!("{a}\n# comment\n"), &b), format!("{a}\n# comment\n{b}\n"));
|
||||
assert_eq!(append_revision(&a, &b), format!("{a}\n{b}\n"));
|
||||
let listed = format!("# reformat\n {b} \n{a}\n");
|
||||
assert_eq!(append_revision(&listed, &b), listed);
|
||||
assert_eq!(append_revision(&format!("{a}\r\n"), &b), format!("{a}\r\n{b}\r\n"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
//! Annotate skips the commits listed in `blame.ignoreRevsFile`.
|
||||
|
||||
use ide_vcs::Repo;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
struct Fixture {
|
||||
_tmp: tempfile::TempDir,
|
||||
dir: PathBuf,
|
||||
}
|
||||
|
||||
fn git_in(dir: &Path, args: &[&str]) -> String {
|
||||
let out = Command::new("git")
|
||||
.current_dir(dir)
|
||||
.args(["-c", "init.defaultBranch=main", "-c", "core.quotepath=off"])
|
||||
.args(args)
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.output()
|
||||
.expect("git runs");
|
||||
assert!(out.status.success(), "git {args:?} failed: {}", String::from_utf8_lossy(&out.stderr));
|
||||
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path().join("work");
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
git_in(&dir, &["init", "-q"]);
|
||||
for (k, v) in [
|
||||
("user.name", "Test User"),
|
||||
("user.email", "[email protected]"),
|
||||
("commit.gpgsign", "false"),
|
||||
("core.autocrlf", "false"),
|
||||
] {
|
||||
git_in(&dir, &["config", k, v]);
|
||||
}
|
||||
Fixture { _tmp: tmp, dir }
|
||||
}
|
||||
fn git(&self, args: &[&str]) -> String {
|
||||
git_in(&self.dir, args)
|
||||
}
|
||||
fn write(&self, rel: &str, content: &str) {
|
||||
std::fs::write(self.dir.join(rel), content).unwrap();
|
||||
}
|
||||
fn commit_all(&self, msg: &str) -> String {
|
||||
self.git(&["add", "-A"]);
|
||||
self.git(&["commit", "-q", "-m", msg]);
|
||||
self.git(&["rev-parse", "HEAD"])
|
||||
}
|
||||
fn path(&self, rel: &str) -> PathBuf {
|
||||
self.dir.join(rel)
|
||||
}
|
||||
/// The repository of a trusted project.
|
||||
fn repo(&self) -> Repo {
|
||||
let repo = Repo::discover(&self.dir).unwrap().expect("repository found");
|
||||
repo.set_execution_allowed(true);
|
||||
repo
|
||||
}
|
||||
/// The repository of a project in Restricted Mode.
|
||||
fn restricted(&self) -> Repo {
|
||||
Repo::discover(&self.dir).unwrap().expect("repository found")
|
||||
}
|
||||
}
|
||||
|
||||
/// A line added by `first` and reformatted by `second`; annotate attributes it to
|
||||
/// `second` until `second` is ignored, then back to `first`.
|
||||
fn reformatted_file() -> (Fixture, String, String) {
|
||||
let fx = Fixture::new();
|
||||
fx.write("file.txt", "first\nsecond\n");
|
||||
let first = fx.commit_all("add file");
|
||||
fx.write("file.txt", "first\n second\n");
|
||||
let second = fx.commit_all("reformat");
|
||||
(fx, first, second)
|
||||
}
|
||||
|
||||
fn commits(repo: &Repo, path: &Path) -> Vec<String> {
|
||||
repo.blame(path, None, None, encoding_rs::UTF_8).unwrap().into_iter().map(|line| line.commit).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_listed_commit_gives_its_lines_back_to_the_earlier_one() {
|
||||
let (fx, first, second) = reformatted_file();
|
||||
let file = fx.path("file.txt");
|
||||
assert_eq!(commits(&fx.repo(), &file), vec![first.clone(), second.clone()]);
|
||||
|
||||
fx.write(".git-blame-ignore-revs", &format!("{second}\n"));
|
||||
fx.git(&["config", "blame.ignoreRevsFile", ".git-blame-ignore-revs"]);
|
||||
let ignored = vec![first.clone(), first.clone()];
|
||||
assert_eq!(commits(&fx.repo(), &file), ignored);
|
||||
// Restricted Mode reads the same configuration and skips the same commit.
|
||||
assert_eq!(commits(&fx.restricted(), &file), ignored);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_configured_file_that_does_not_exist_is_skipped() {
|
||||
let (fx, _, second) = reformatted_file();
|
||||
fx.git(&["config", "blame.ignoreRevsFile", ".git-blame-ignore-revs"]);
|
||||
let file = fx.path("file.txt");
|
||||
// The missing list is passed to no blame, so annotate still works.
|
||||
assert_eq!(commits(&fx.repo(), &file)[1], second);
|
||||
assert_eq!(commits(&fx.restricted(), &file)[1], second);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignoring_a_commit_starts_the_list_and_configures_it_once() {
|
||||
let (fx, first, second) = reformatted_file();
|
||||
fx.repo().ignore_blame_revision(&second).unwrap();
|
||||
fx.repo().ignore_blame_revision(&second).unwrap();
|
||||
assert_eq!(std::fs::read_to_string(fx.path(".git-blame-ignore-revs")).unwrap(), format!("{second}\n"));
|
||||
assert_eq!(fx.git(&["config", "--local", "--get", "blame.ignoreRevsFile"]), ".git-blame-ignore-revs");
|
||||
let repo = fx.repo();
|
||||
assert_eq!(commits(&repo, &fx.path("file.txt")), vec![first.clone(), first.clone()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignoring_a_commit_adds_it_to_the_configured_list() {
|
||||
let (fx, first, second) = reformatted_file();
|
||||
fx.write("custom-revs", &format!("# formatting\r\n{first}\r\n"));
|
||||
fx.git(&["config", "--local", "blame.ignoreRevsFile", "custom-revs"]);
|
||||
fx.repo().ignore_blame_revision(&second).unwrap();
|
||||
// Lines and line endings kept; the configured file is the one annotate reads.
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(fx.path("custom-revs")).unwrap(),
|
||||
format!("# formatting\r\n{first}\r\n{second}\r\n")
|
||||
);
|
||||
assert!(!fx.path(".git-blame-ignore-revs").exists());
|
||||
assert_eq!(fx.git(&["config", "--local", "--get", "blame.ignoreRevsFile"]), "custom-revs");
|
||||
let blamed = commits(&fx.repo(), &fx.path("file.txt"));
|
||||
assert_ne!(blamed[1], second);
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
//! Stashing only the selected files.
|
||||
|
||||
use ide_core::error::ErrorKind;
|
||||
use ide_vcs::Repo;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
struct Fixture {
|
||||
_tmp: tempfile::TempDir,
|
||||
dir: PathBuf,
|
||||
}
|
||||
|
||||
fn git_in(dir: &Path, args: &[&str]) -> String {
|
||||
let out = Command::new("git")
|
||||
.current_dir(dir)
|
||||
.args(["-c", "init.defaultBranch=main", "-c", "core.quotepath=off"])
|
||||
.args(args)
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.output()
|
||||
.expect("git runs");
|
||||
assert!(out.status.success(), "git {args:?} failed: {}", String::from_utf8_lossy(&out.stderr));
|
||||
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
||||
}
|
||||
|
||||
fn configure(dir: &Path) {
|
||||
for (k, v) in [
|
||||
("user.name", "Test User"),
|
||||
("user.email", "[email protected]"),
|
||||
("commit.gpgsign", "false"),
|
||||
("core.autocrlf", "false"),
|
||||
] {
|
||||
git_in(dir, &["config", k, v]);
|
||||
}
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path().join("work");
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
git_in(&dir, &["init", "-q"]);
|
||||
configure(&dir);
|
||||
Fixture { _tmp: tmp, dir }
|
||||
}
|
||||
fn git(&self, args: &[&str]) -> String {
|
||||
git_in(&self.dir, args)
|
||||
}
|
||||
fn write(&self, rel: &str, content: &str) {
|
||||
std::fs::write(self.dir.join(rel), content).unwrap();
|
||||
}
|
||||
fn read(&self, rel: &str) -> String {
|
||||
std::fs::read_to_string(self.dir.join(rel)).unwrap()
|
||||
}
|
||||
fn commit_all(&self, msg: &str) {
|
||||
self.git(&["add", "-A"]);
|
||||
self.git(&["commit", "-q", "-m", msg]);
|
||||
}
|
||||
fn repo(&self) -> Repo {
|
||||
let repo = Repo::discover(&self.dir).unwrap().expect("repository found");
|
||||
repo.set_execution_allowed(true);
|
||||
repo
|
||||
}
|
||||
fn path(&self, rel: &str) -> PathBuf {
|
||||
self.dir.join(rel)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stash_push_stashes_only_the_given_paths() {
|
||||
let fx = Fixture::new();
|
||||
fx.write("a.txt", "a\n");
|
||||
fx.write("b.txt", "b\n");
|
||||
fx.commit_all("base");
|
||||
let repo = fx.repo();
|
||||
|
||||
fx.write("a.txt", "a changed\n");
|
||||
fx.write("b.txt", "b changed\n");
|
||||
repo.stash_push(Some("only a"), false, false, &[fx.path("a.txt")]).unwrap();
|
||||
assert_eq!(fx.read("a.txt"), "a\n");
|
||||
assert_eq!(fx.read("b.txt"), "b changed\n");
|
||||
assert_eq!(repo.stashes().unwrap().len(), 1);
|
||||
|
||||
repo.stash_apply(0, true, false).unwrap();
|
||||
assert_eq!(fx.read("a.txt"), "a changed\n");
|
||||
assert_eq!(fx.read("b.txt"), "b changed\n");
|
||||
assert!(repo.stashes().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stash_push_stashes_only_the_given_untracked_paths() {
|
||||
let fx = Fixture::new();
|
||||
fx.write("a.txt", "a\n");
|
||||
fx.commit_all("base");
|
||||
let repo = fx.repo();
|
||||
|
||||
fx.write("new.txt", "new\n");
|
||||
fx.write("other.txt", "other\n");
|
||||
repo.stash_push(None, true, false, &[fx.path("new.txt")]).unwrap();
|
||||
assert!(!fx.path("new.txt").exists());
|
||||
assert!(fx.path("other.txt").exists());
|
||||
assert_eq!(repo.stashes().unwrap().len(), 1);
|
||||
|
||||
repo.stash_apply(0, true, false).unwrap();
|
||||
assert_eq!(fx.read("new.txt"), "new\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stash_push_rejects_paths_outside_the_repository() {
|
||||
let fx = Fixture::new();
|
||||
fx.write("a.txt", "a\n");
|
||||
fx.commit_all("base");
|
||||
let repo = fx.repo();
|
||||
fx.write("a.txt", "a changed\n");
|
||||
|
||||
let outside = tempfile::tempdir().unwrap();
|
||||
std::fs::write(outside.path().join("outside.txt"), "outside\n").unwrap();
|
||||
let error = repo.stash_push(None, false, false, &[outside.path().join("outside.txt")]).unwrap_err();
|
||||
assert_eq!(error.kind, ErrorKind::InvalidInput);
|
||||
assert!(error.message.contains("outside the repository"), "{error:?}");
|
||||
|
||||
// Nothing was stashed and the change is still in the working tree.
|
||||
assert!(repo.stashes().unwrap().is_empty());
|
||||
assert_eq!(fx.read("a.txt"), "a changed\n");
|
||||
}
|
||||
@@ -67,6 +67,8 @@ 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.
|
||||
|
||||
@@ -107,6 +107,8 @@ 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 |
|
||||
|
||||
@@ -75,7 +75,7 @@ Pick **VS Code (macOS)** or **VS Code (Windows)** as the keymap; then `⇧⌘P`
|
||||
| Extensions | **Settings › Plugins** |
|
||||
| `settings.json` | **Settings** (`⌘,` / `Ctrl+Alt+S`), stored in `settings.toml`; per-project values in `.openidea/settings.toml` |
|
||||
| `launch.json` | Run configurations: **Import Run Configurations from .vscode/launch.json** |
|
||||
| Multi-root workspace | **File › New Workspace…** and **Attach Project…** |
|
||||
| Multi-root workspace | **File › New › Workspace…** and **Attach Project…** |
|
||||
|
||||
`.editorconfig` files are applied. VS Code settings and extensions are not imported.
|
||||
|
||||
@@ -84,7 +84,7 @@ Pick **VS Code (macOS)** or **VS Code (Windows)** as the keymap; then `⇧⌘P`
|
||||
Pick **Eclipse (macOS)** or **Eclipse** as the keymap.
|
||||
|
||||
- **Formatter.** To keep using an Eclipse formatter profile for Java, open **Settings › Editor › Code Style › Java**, set **Formatting profile** to **Custom Eclipse formatter XML** and choose the exported XML file.
|
||||
- **Workspaces and projects.** Eclipse opens many projects in one workspace. OpenIDEA opens one project per window; to work on several folders together, create a workspace with **File › New Workspace…** or add folders with **Attach Project…**.
|
||||
- **Workspaces and projects.** Eclipse opens many projects in one workspace. OpenIDEA opens one project per window; to work on several folders together, create a workspace with **File › New › Workspace…** or add folders with **Attach Project…**.
|
||||
- **Perspectives.** OpenIDEA has no perspectives. Arrange tool windows the way you like and save the arrangement with **Window › Tool Window Layouts › Save Current Layout as…**.
|
||||
|
||||
## Concepts at a glance
|
||||
|
||||
@@ -13,7 +13,7 @@ A scratch file is a throwaway file in any language: a place to try a snippet, ke
|
||||
|
||||
<Steps>
|
||||
<Step title="Create one">
|
||||
Choose **File › New Scratch File…** (or **Scratch File** at the top of the Project view's **New** menu).
|
||||
Choose **File › New › Scratch File** (or **Scratch File** at the top of the Project view's **New** menu).
|
||||
</Step>
|
||||
<Step title="Pick a language">
|
||||
Type to filter the language list and press `↵` / `Enter`.
|
||||
|
||||
@@ -73,7 +73,7 @@ The page also has **Expand abbreviations with Tab** and **Show live templates in
|
||||
|
||||
Live templates, postfix settings and macros are stored per user on this machine, not in the project.
|
||||
|
||||
For new-file templates (what **File › New…** creates), see **Settings › Editor › File and Code Templates**.
|
||||
For new-file templates (what **File › New › File…** creates), see **Settings › Editor › File and Code Templates**.
|
||||
|
||||
## Macros
|
||||
|
||||
|
||||
@@ -130,6 +130,8 @@ Other ways to compare:
|
||||
|
||||
Click the branch widget in the title bar (or **Git › Branches…**) to open the branches popup. Type to search branches and actions. The popup shows **Recent**, **Local**, **Remote** and **Tags**, and at the top **Update Project…**, **Commit…**, **Push…**, **New Branch…** and **Checkout Tag or Revision…**. The gear button toggles grouping by prefix, recent branches and tags.
|
||||
|
||||
Click the incoming or outgoing marker next to the branch name in the title bar (or **Incoming and Outgoing Commits…** in the branches popup) to list the commits to pull or push, newest first. Click a commit for its diff, or **Open in Git Log** to show it in the Git log.
|
||||
|
||||
Select a branch (`→` or `Enter`) for its actions:
|
||||
|
||||
- **Checkout**, **New Branch from '…'…**, **Checkout and Rebase onto '…'**
|
||||
@@ -194,7 +196,7 @@ Limits: interactive rebase doesn't support ranges with merge commits, and **Undo
|
||||
|
||||
**Stash** uses `git stash`:
|
||||
|
||||
- **Stash Changes…**: a message, **Keep index** and **Include untracked files**. Stashes always include all changes; there is no per-file stash.
|
||||
- **Stash Changes…**: a message, **Keep index** and **Include untracked files**; it stashes the whole working tree. **Stash Selected Files…** in the changes tree's context menu stashes only the selected files.
|
||||
- **Unstash Changes…**: apply or pop a stash, reinstate the index, or create a branch from it.
|
||||
- The **Stash** tab of the Commit tool window lists stashes with apply, pop and drop.
|
||||
|
||||
@@ -217,6 +219,8 @@ Turn on **Combine stashes and shelves in one tab** in the Git settings to show b
|
||||
- **Show History for Selection** shows the commits that changed the selected lines.
|
||||
- **Show Current Revision** reveals the commit of the caret line in the log.
|
||||
|
||||
Annotate skips the commits listed in the file `blame.ignoreRevsFile` names — git's convention is `.git-blame-ignore-revs` at the repository root — so a reformatting or mass-rename commit does not claim every line. **Ignore This Commit** in an annotated line's menu adds the current line's commit to that file and refreshes the annotations.
|
||||
|
||||
### Local history
|
||||
|
||||
Local history keeps snapshots of your files independent of Git, including deleted files and labelled states such as the one taken before a refactoring.
|
||||
@@ -246,5 +250,5 @@ Retention is set in the **Local History** section of **Settings › Appearance &
|
||||
|
||||
- Mercurial, Subversion, Perforce and other systems.
|
||||
- Submodule commands and editable directory mappings.
|
||||
- Per-line partial commits (block level only) and per-file stashes.
|
||||
- Per-line partial commits (block level only).
|
||||
- Git LFS beyond the large-file warning.
|
||||
@@ -11,7 +11,7 @@ This page covers how a plugin works with files:
|
||||
projects.
|
||||
- **Other files** (`fs.wit`, API 1.3): files outside the projects, under explicit grants,
|
||||
and the plugin's own private directory.
|
||||
- **File templates**: entries of the **File › New…** menu, no code needed.
|
||||
- **File templates**: entries of the **File › New › File…** menu, no code needed.
|
||||
|
||||
## File actions
|
||||
|
||||
@@ -180,7 +180,7 @@ as an argument to an approved process.
|
||||
|
||||
## File templates
|
||||
|
||||
`[[contributes.fileTemplates]]` adds entries to the **File › New…** menu. They need no code and grant
|
||||
`[[contributes.fileTemplates]]` adds entries to the **File › New › File…** menu. They need no code and grant
|
||||
no file access: files are created only when the user invokes a template, through the IDE's
|
||||
normal New File flow.
|
||||
|
||||
|
||||
@@ -67,6 +67,8 @@ OpenIDEA 本身不带模型或账号:它启动你已安装并登录的 agent
|
||||
|
||||
在你自己的消息上可以 **编辑**(其后的消息会被替换,已经改动的文件保持不变)或 **撤回消息**。在回复上可以 **复制 Markdown**、**引用回复**、**重新生成回复**,以及 **从此处创建分支对话**,从这一点开始一个新任务。
|
||||
|
||||
右键点击链接并选择 **复制链接地址** 可复制它的 URL;**复制** 复制选中的文字。
|
||||
|
||||
### 权限
|
||||
|
||||
agent 要运行命令或调用需要授权的工具时,任务中会显示请求,并归入 **等待输入**。从 agent 提供的选项中选择,例如 **允许一次**、**在此会话中允许**、**始终允许**、**拒绝**、**始终拒绝** 或 **拒绝并停止**。很多 agent 还有模式选择器,可以自动批准编辑甚至所有操作,请谨慎使用。
|
||||
|
||||
@@ -107,6 +107,8 @@ description: "多光标与选区、行编辑、剪贴板历史、代码折叠、
|
||||
|
||||
标签页的位置、数量上限以及达到上限后的处理方式、预览标签页、排序方式,以及关闭标签页后激活哪一个,都在 **设置 › 编辑器 › 常规 › 编辑器标签页** 中设置。
|
||||
|
||||
标签页放在左侧或右侧时,标签页列的宽度随最长的名称变化,但不超过一个上限。拖动该列靠近编辑器的边缘(或聚焦后按 `←` / `→`)可以调整上限;调整对所有编辑器组生效,并会被记住。
|
||||
|
||||
## 拆分编辑器
|
||||
|
||||
| 操作 | macOS | Windows / Linux |
|
||||
|
||||
@@ -75,7 +75,7 @@ Vim 模式会读取 `~/.ideavimrc`(或 `~/.openideavimrc`)。通过 **工具
|
||||
| 扩展 | **设置 › 插件** |
|
||||
| `settings.json` | **设置**(`⌘,` / `Ctrl+Alt+S`),保存在 `settings.toml`;项目级的值保存在 `.openidea/settings.toml` |
|
||||
| `launch.json` | 运行配置:**从 .vscode/launch.json 导入运行配置** |
|
||||
| 多根工作区 | **文件 › 新建工作区…** 和 **附加项目…** |
|
||||
| 多根工作区 | **文件 › 新建 › 工作区…** 和 **附加项目…** |
|
||||
|
||||
项目中的 `.editorconfig` 会生效。VS Code 的设置和扩展不会导入。
|
||||
|
||||
@@ -84,7 +84,7 @@ Vim 模式会读取 `~/.ideavimrc`(或 `~/.openideavimrc`)。通过 **工具
|
||||
把快捷键方案切换为 **Eclipse (macOS)** 或 **Eclipse**。
|
||||
|
||||
- **格式化器。** 想继续用 Eclipse 的 Java 格式化配置,打开 **设置 › 编辑器 › 代码样式 › Java**,把 **格式化方案** 设为 **自定义 Eclipse 格式化 XML**,然后选择导出的 XML 文件。
|
||||
- **工作空间与项目。** Eclipse 在一个工作空间里打开多个项目;OpenIDEA 每个窗口打开一个项目。需要同时处理多个文件夹时,用 **文件 › 新建工作区…** 创建工作区,或用 **附加项目…** 添加文件夹。
|
||||
- **工作空间与项目。** Eclipse 在一个工作空间里打开多个项目;OpenIDEA 每个窗口打开一个项目。需要同时处理多个文件夹时,用 **文件 › 新建 › 工作区…** 创建工作区,或用 **附加项目…** 添加文件夹。
|
||||
- **透视图。** OpenIDEA 没有透视图。按自己的习惯摆放工具窗口,再用 **窗口 › 工具窗口布局 › 将当前布局另存为…** 保存。
|
||||
|
||||
## 概念对照
|
||||
|
||||
@@ -13,7 +13,7 @@ description: "在项目之外保留临时文件,并在 TODO 工具窗口中汇
|
||||
|
||||
<Steps>
|
||||
<Step title="新建">
|
||||
选择 **文件 › 新建临时文件…**(或项目视图 **新建** 菜单最上面的 **临时文件**)。
|
||||
选择 **文件 › 新建 › 临时文件**(或项目视图 **新建** 菜单最上面的 **临时文件**)。
|
||||
</Step>
|
||||
<Step title="选择语言">
|
||||
输入文字过滤语言列表,按 `↵` / `Enter` 确认。
|
||||
|
||||
@@ -67,7 +67,7 @@ items.for → for (var item : items) { … }
|
||||
|
||||
实时模板、后缀补全设置和宏都按用户保存在本机,不保存在项目里。
|
||||
|
||||
新建文件用的模板(**文件 › 新建…** 创建的内容)在 **设置 › 编辑器 › 文件和代码模板** 中设置。
|
||||
新建文件用的模板(**文件 › 新建 › 文件…** 创建的内容)在 **设置 › 编辑器 › 文件和代码模板** 中设置。
|
||||
|
||||
## 宏
|
||||
|
||||
|
||||
@@ -130,6 +130,8 @@ OpenIDEA 会找到包含项目的仓库,以及向下最多四层的嵌套仓
|
||||
|
||||
点击标题栏的分支微件(或 **Git › 分支…**)打开分支弹窗,输入文字即可搜索分支和操作。弹窗中有 **最近**、**本地**、**远程** 和 **标签** 分组,顶部有 **更新项目…**、**提交…**、**推送…**、**新建分支…** 和 **签出标签或修订…**。齿轮按钮可以切换按前缀分组、显示最近分支和显示标签。
|
||||
|
||||
点击标题栏分支名旁的传入或传出标记(或分支弹窗中的 **传入和传出的提交…**)即可列出待拉取或待推送的提交(最新的在前)。点击某个提交查看其差异,或点 **在 Git 日志中打开** 在 Git 日志中显示它。
|
||||
|
||||
选中分支(`→` 或 `Enter`)查看它的操作:
|
||||
|
||||
- **签出**、**从 '…' 新建分支…**、**签出并变基到 '…'**
|
||||
@@ -194,7 +196,7 @@ Git 工具窗口的 **日志** 标签页显示提交图。
|
||||
|
||||
**贮藏** 使用 `git stash`:
|
||||
|
||||
- **贮藏更改…**:填写消息,可选 **保留索引** 和 **包括未跟踪的文件**。贮藏总是包含全部更改,不能只贮藏部分文件。
|
||||
- **贮藏更改…**:填写消息,可选 **保留索引** 和 **包括未跟踪的文件**,贮藏整个工作区;更改树右键菜单中的 **贮藏所选文件…** 只贮藏选中的文件。
|
||||
- **取消贮藏更改…**:应用或弹出某个贮藏,恢复索引,或基于它新建分支。
|
||||
- 提交工具窗口的 **贮藏** 标签页列出所有贮藏,可以应用、弹出和删除。
|
||||
|
||||
@@ -217,6 +219,8 @@ Git 工具窗口的 **日志** 标签页显示提交图。
|
||||
- **显示所选内容的历史记录** 列出修改过所选行的提交。
|
||||
- **显示当前修订版** 在日志中定位光标所在行的提交。
|
||||
|
||||
注解会跳过 `blame.ignoreRevsFile` 指向的文件(约定是仓库根目录下的 `.git-blame-ignore-revs`)中列出的提交,因此一次格式化或批量重命名的提交不会认领所有行。在注解行的右键菜单中选择 **忽略此提交**,会把当前行的提交加入该文件并刷新注解。
|
||||
|
||||
### 本地历史记录
|
||||
|
||||
本地历史记录独立于 Git 保存文件快照,包括已删除的文件,以及重构前自动记录的状态等带标签的快照。
|
||||
@@ -246,5 +250,5 @@ Git 工具窗口的 **日志** 标签页显示提交图。
|
||||
|
||||
- Mercurial、Subversion、Perforce 等其他版本控制系统。
|
||||
- 子模块命令、编辑目录映射。
|
||||
- 按行的部分提交(只支持按代码块)以及按文件贮藏。
|
||||
- 按行的部分提交(只支持按代码块)。
|
||||
- 除大文件警告外的 Git LFS 支持。
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import type { AgentTask } from "../src/bindings";
|
||||
import { expect, test } from "./fixtures";
|
||||
|
||||
const task: AgentTask = {
|
||||
id: "draft-ack",
|
||||
title: "Draft acknowledgement",
|
||||
provider: "codex",
|
||||
status: "ready",
|
||||
isolation: "local",
|
||||
model: null,
|
||||
cwd: "/dev/acme-api",
|
||||
branch: null,
|
||||
base: null,
|
||||
baseCommit: null,
|
||||
error: null,
|
||||
createdMs: 0,
|
||||
updatedMs: 0,
|
||||
config: [],
|
||||
commands: [],
|
||||
usage: null,
|
||||
queue: [],
|
||||
};
|
||||
|
||||
async function prepare(page: Page, home: boolean, failure: boolean) {
|
||||
await page.evaluate(
|
||||
async ({ task, home, failure }) => {
|
||||
const storePath = "/src/features/agents/store.ts";
|
||||
const ipcPath = "/src/platform/ipc.ts";
|
||||
const store = await import(storePath);
|
||||
const { commands } = await import(ipcPath);
|
||||
const provider = {
|
||||
id: "codex",
|
||||
name: "Codex",
|
||||
command: "codex",
|
||||
program: "codex",
|
||||
args: [],
|
||||
defaultProgram: null,
|
||||
defaultArgs: [],
|
||||
loginHint: null,
|
||||
commandOverridden: false,
|
||||
commandEditable: true,
|
||||
commandReadOnlyReason: null,
|
||||
installed: true,
|
||||
env: {},
|
||||
commands: [],
|
||||
source: { kind: "custom" },
|
||||
config: [],
|
||||
thoughtLevels: {},
|
||||
};
|
||||
// Delayed local acknowledgements exercise the real editor without starting an agent.
|
||||
commands.agentProviders = async () => ({ status: "ok", data: [provider] });
|
||||
commands.agentProbe = async () => ({ status: "ok", data: null });
|
||||
commands.agentChanges = async () => ({ status: "ok", data: [] });
|
||||
const acknowledge = (data: unknown, prompt: unknown) => {
|
||||
document.documentElement.dataset.submittedPrompt = JSON.stringify(prompt);
|
||||
return new Promise((resolve) =>
|
||||
document.addEventListener(
|
||||
"test:agent-ack",
|
||||
() => {
|
||||
resolve(
|
||||
failure
|
||||
? { status: "error", error: { kind: "internal", message: "Local send failed" } }
|
||||
: { status: "ok", data },
|
||||
);
|
||||
},
|
||||
{ once: true },
|
||||
),
|
||||
);
|
||||
};
|
||||
commands.agentCreate = (request: { prompt: unknown }) => acknowledge(task, request.prompt);
|
||||
commands.agentPrompt = (_id: string, prompt: unknown) => acknowledge(null, prompt);
|
||||
store.useAgents.setState({
|
||||
loaded: true,
|
||||
providers: [provider],
|
||||
tasks: new Map([[task.id, task]]),
|
||||
entries: new Map([[task.id, []]]),
|
||||
composers: new Map(),
|
||||
draft: { provider: "codex", config: {}, isolation: "local", base: null },
|
||||
sessionLocation: "editor",
|
||||
});
|
||||
if (home) store.openHome();
|
||||
else store.openTask(task.id);
|
||||
},
|
||||
{ task, home, failure },
|
||||
);
|
||||
}
|
||||
|
||||
for (const home of [false, true]) {
|
||||
for (const edited of [false, true]) {
|
||||
for (const failure of [false, true]) {
|
||||
test(`${home ? "new task" : "session"}: ${failure ? "failed" : "successful"} acknowledgement with ${edited ? "new typing" : "unchanged draft"}`, async ({
|
||||
workbench: page,
|
||||
}) => {
|
||||
await prepare(page, home, failure);
|
||||
const box = page.getByRole("textbox", { name: "Message", exact: true });
|
||||
await box.fill("submitted prompt");
|
||||
await page.getByRole("button", { name: home ? "Start" : "Send", exact: true }).click();
|
||||
await expect(page.locator("html")).toHaveAttribute(
|
||||
"data-submitted-prompt",
|
||||
JSON.stringify({ text: "submitted prompt", files: [], images: [] }),
|
||||
);
|
||||
await expect(page.getByRole("button", { name: home ? "Starting…" : "Send", exact: true })).toBeDisabled();
|
||||
if (edited) await box.fill("next unsent prompt");
|
||||
await page.evaluate(() => document.dispatchEvent(new Event("test:agent-ack")));
|
||||
if (home && !failure) {
|
||||
await expect(page.getByRole("tab", { name: "Draft acknowledgement" })).toBeVisible();
|
||||
await page.evaluate(async () => {
|
||||
const storePath = "/src/features/agents/store.ts";
|
||||
(await import(storePath)).openHome();
|
||||
});
|
||||
}
|
||||
const expected = edited ? "next unsent prompt" : failure ? "submitted prompt" : "";
|
||||
if (expected) await expect(box).toHaveText(expected);
|
||||
else await expect(box.locator(".cm-placeholder")).toBeVisible();
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
async (key) => {
|
||||
const storePath = "/src/features/agents/store.ts";
|
||||
return (await import(storePath)).composerDraft(key).text;
|
||||
},
|
||||
home ? "home" : task.id,
|
||||
),
|
||||
)
|
||||
.toBe(expected);
|
||||
await expect(page.getByRole("button", { name: home ? "Start" : "Send", exact: true })).toBeEnabled({
|
||||
enabled: !!expected,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -198,6 +198,37 @@ 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 });
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -19,9 +19,9 @@ test("an unresolved module gets a startup timeout", async ({ page }) => {
|
||||
);
|
||||
await page.goto("/", { waitUntil: "commit" });
|
||||
await page.locator("#splash").waitFor({ state: "attached" });
|
||||
await page.clock.runFor(30_000);
|
||||
await page.clock.runFor(60_000);
|
||||
await expect(page.getByRole("textbox", { name: "Startup error details" })).toHaveValue(
|
||||
/Startup did not finish within 30 seconds/,
|
||||
/Startup did not finish within 60 seconds/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -83,7 +83,7 @@ test("a slow module load succeeds before the deadline and cancels the startup ti
|
||||
await expect(page.locator("#root")).not.toBeEmpty();
|
||||
await page.clock.runFor(4000);
|
||||
await expect(page.locator("#splash")).toHaveCount(0);
|
||||
await page.clock.runFor(35_000);
|
||||
await page.clock.runFor(50_000);
|
||||
await expect(page.locator("#boot-error")).toHaveCount(0);
|
||||
expect(await page.evaluate(() => sessionStorage.getItem("boot-error-seen"))).toBeNull();
|
||||
});
|
||||
@@ -285,3 +285,38 @@ 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));
|
||||
});
|
||||
@@ -172,6 +172,20 @@ 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");
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
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();
|
||||
});
|
||||
@@ -20,7 +20,9 @@ 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("list", { name: /language servers/i })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("dialog", { name: "Settings" }).getByRole("list", { name: "Languages", exact: true }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
const ROOT = "/dev/acme-api";
|
||||
|
||||
@@ -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 list = dialog.getByRole("list", { name: /language servers/i });
|
||||
await expect(list).toBeVisible();
|
||||
|
||||
await list.locator('[data-tool="gopls"]').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 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(details).toContainText("Installed");
|
||||
await expect(details).toContainText("1.0.0");
|
||||
await expect(gopls).toContainText("Installed");
|
||||
await expect(gopls).toContainText("1.0.0");
|
||||
await expect(details.getByRole("button", { name: "Uninstall" })).toBeVisible();
|
||||
|
||||
// Tools without an installer explain how to get them.
|
||||
await list.locator('[data-tool="solargraph"]').click();
|
||||
await languages.getByRole("button", { name: /^Ruby\b/ }).click();
|
||||
await expect(dialog.getByLabel("solargraph details")).toContainText("gem install solargraph");
|
||||
});
|
||||
|
||||
|
||||
@@ -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*='details']"],
|
||||
["languages.servers", "[class*='body'] > [class*='language'][aria-label]"],
|
||||
["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.44",
|
||||
"version": "0.1.45",
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
copied: "已复制",
|
||||
manual: "无法复制。请选择下方详情并手动复制。",
|
||||
details: "启动错误详情",
|
||||
timeout: "启动未在 30 秒内完成。",
|
||||
timeout: "启动未在 60 秒内完成。",
|
||||
outdatedMac:
|
||||
"这台 Mac 的系统网页引擎过旧:OpenIDEA 的界面运行在 macOS 自带的 WebKit 引擎上,需要 macOS 13 Ventura 或更高版本。在 macOS 11、12 上更新 Safari 不会更新其他应用使用的引擎,需要升级 macOS。",
|
||||
outdated:
|
||||
@@ -25,7 +25,7 @@
|
||||
copied: "Copied",
|
||||
manual: "Could not copy. Select the details below and copy them.",
|
||||
details: "Startup error details",
|
||||
timeout: "Startup did not finish within 30 seconds.",
|
||||
timeout: "Startup did not finish within 60 seconds.",
|
||||
outdatedMac:
|
||||
"The system web engine on this Mac is too old: OpenIDEA's interface runs on macOS's built-in WebKit and needs macOS 13 Ventura or later. On macOS 11 and 12 a Safari update doesn't update the engine other apps use, so macOS itself needs upgrading.",
|
||||
outdated:
|
||||
@@ -146,7 +146,7 @@
|
||||
};
|
||||
// The timeout counts only while the window shows: a hidden or covered window gets no
|
||||
// animation frames, and the splash waits for those to finish startup.
|
||||
let remaining = 30_000;
|
||||
let remaining = 60_000;
|
||||
let armedAt = 0;
|
||||
let timer;
|
||||
const arm = () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package openidea:plugin@1.7.0;
|
||||
package openidea:plugin@1.8.0;
|
||||
|
||||
/// The commit being prepared in the window the current command or event came
|
||||
/// from (the Commit tool window, or the Commit dialog while it is open): the
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package openidea:plugin@1.7.0;
|
||||
package openidea:plugin@1.8.0;
|
||||
|
||||
/// Plugin-owned project data sources. Requires database.sources and project trust.
|
||||
/// Runs on the Database subsystem's host, including on remote project servers.
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
package openidea:plugin@1.7.0;
|
||||
package openidea:plugin@1.8.0;
|
||||
|
||||
/// Project-scoped decorations (API 1.4). Requires project.read; paths must be inside
|
||||
/// an open project. Sets belong to the plugin and disappear when it stops.
|
||||
/// Editor and project-tree decorations (API 1.4). Requires project.read. An editor set
|
||||
/// follows its file: the file's own project, or the nearest one when it is outside them all.
|
||||
/// Tree sets are rooted in an open project. Sets belong to the plugin and disappear when it stops.
|
||||
/// Styles: highlight, search, added, modified, removed, warning, error, info,
|
||||
/// underline, strikethrough, muted. Unknown styles are ignored. Theme tokens only.
|
||||
/// typo, weak, underline, strikethrough, muted. Unknown styles are ignored. Theme tokens only.
|
||||
interface decorations {
|
||||
use types.{error, range};
|
||||
/// An editor annotation; after is plain text shown after the range.
|
||||
@@ -22,4 +23,9 @@ interface decorations {
|
||||
/// Replace this set's tree annotation for a file with a note shown after its name (API 1.7):
|
||||
/// one line of at most 200 characters, with an optional tooltip.
|
||||
set-file-note: func(path: string, set-id: string, note: string, tooltip: option<string>) -> result<_, error>;
|
||||
/// Replace this set's tree icon for a file or folder with an id declared under
|
||||
/// `[[contributes.fileIcons]]` (API 1.8), with an optional tooltip.
|
||||
set-file-icon: func(path: string, set-id: string, icon: string, tooltip: option<string>) -> result<_, error>;
|
||||
/// Replace all `(path, icon)` entries of a tree set at once (API 1.8; at most 10,000 entries).
|
||||
set-file-icons: func(set-id: string, items: list<tuple<string, string>>) -> result<_, error>;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package openidea:plugin@1.7.0;
|
||||
package openidea:plugin@1.8.0;
|
||||
|
||||
/// What the pane beside the Services tree shows for a plugin's node.
|
||||
interface detail-types {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package openidea:plugin@1.7.0;
|
||||
package openidea:plugin@1.8.0;
|
||||
|
||||
/// Optional export (world `plugin-diagnostics`): problems in the current,
|
||||
/// possibly unsaved document, for the languages declared with the
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package openidea:plugin@1.7.0;
|
||||
package openidea:plugin@1.8.0;
|
||||
|
||||
/// HTTP endpoints found in a project (`[[contributes.endpoints]]`).
|
||||
interface endpoints-provider {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package openidea:plugin@1.7.0;
|
||||
package openidea:plugin@1.8.0;
|
||||
|
||||
/// Workbench events, delivered in batches to `event-handler.handle`.
|
||||
interface events {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package openidea:plugin@1.7.0;
|
||||
package openidea:plugin@1.8.0;
|
||||
|
||||
interface lifecycle {
|
||||
use types.{context, error};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package openidea:plugin@1.7.0;
|
||||
package openidea:plugin@1.8.0;
|
||||
|
||||
/// What other plugins contributed to the extension points this plugin declares
|
||||
/// (`[[extension-points]]` in `plugin.toml`; the contributors list this plugin
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package openidea:plugin@1.7.0;
|
||||
package openidea:plugin@1.8.0;
|
||||
|
||||
/// Optional export (world `plugin-file-actions`): quick fixes and
|
||||
/// refactorings that create, rename or delete files, next to the text edits
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package openidea:plugin@1.7.0;
|
||||
package openidea:plugin@1.8.0;
|
||||
|
||||
/// Files outside the open projects: the configuration of the program a
|
||||
/// plugin integrates with, a tool installed elsewhere, a licence file.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package openidea:plugin@1.7.0;
|
||||
package openidea:plugin@1.8.0;
|
||||
|
||||
/// Writes to the IDE log, tagged with the plugin id.
|
||||
interface log {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package openidea:plugin@1.7.0;
|
||||
package openidea:plugin@1.8.0;
|
||||
|
||||
/// Optional export (world `plugin-hover`): Markdown added to the hover of
|
||||
/// documents of the languages declared with the `hover` provider kind,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package openidea:plugin@1.7.0;
|
||||
package openidea:plugin@1.8.0;
|
||||
|
||||
/// Cross-file associations a plugin publishes for the files it declares under
|
||||
/// `[[contributes.documentIndexers]]`. The host calls `index` in cancellable
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package openidea:plugin@1.7.0;
|
||||
package openidea:plugin@1.8.0;
|
||||
|
||||
/// Embedded languages. The host routes plugin providers and configured language
|
||||
/// servers inside these ranges, preserving host-document UTF-16 coordinates.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package openidea:plugin@1.7.0;
|
||||
package openidea:plugin@1.8.0;
|
||||
|
||||
/// Optional export (world `plugin-inlay-hints`): inline hints in documents of
|
||||
/// the languages declared with the `inlay-hints` provider kind, including
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
package openidea:plugin@1.7.0;
|
||||
package openidea:plugin@1.8.0;
|
||||
|
||||
/// Problems in files, shown in the editor and the Problems view.
|
||||
interface diagnostics {
|
||||
use types.{error, range, location};
|
||||
/// Problems reported by a source for a file in this window's project.
|
||||
/// Problems one producer reported for a file, wherever the file is: the host records every
|
||||
/// diagnostics event it emits, so this follows the file, not the invoking window.
|
||||
record file-problems { path: string, source: string, diagnostics: list<diagnostic> }
|
||||
/// Current Problems view snapshot, optionally filtered to a project file.
|
||||
/// The diagnostics the host recorded, one entry per producer, optionally
|
||||
/// filtered to a file: language servers, builds, spelling and plugin
|
||||
/// providers alike, whatever window or project opened the file.
|
||||
/// Requires project.read and an invoking window. Empty for remote projects.
|
||||
problems: func(path: option<string>) -> result<list<file-problems>, error>;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package openidea:plugin@1.7.0;
|
||||
package openidea:plugin@1.8.0;
|
||||
|
||||
/// Additional language features, called only for declared API 1.4 provider kinds.
|
||||
/// Editor calls have a 50 ms soft / 500 ms hard budget; formatting has 5 seconds.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package openidea:plugin@1.7.0;
|
||||
package openidea:plugin@1.8.0;
|
||||
|
||||
/// Discovery of the IDE's built-in MCP server; never exposes its URL or token.
|
||||
interface mcp {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
/// `fs` and `workspace`, and everything else goes through the imports below,
|
||||
/// gated by the capabilities granted in `plugin.toml` (a missing one returns
|
||||
/// `error::denied`).
|
||||
package openidea:plugin@1.7.0;
|
||||
package openidea:plugin@1.8.0;
|
||||
|
||||
world plugin {
|
||||
import log;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package openidea:plugin@1.7.0;
|
||||
package openidea:plugin@1.8.0;
|
||||
|
||||
/// Editor features a plugin provides for the languages it declares under
|
||||
/// `[[contributes.providers]]` (the host calls only declared kinds). They run
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package openidea:plugin@1.7.0;
|
||||
package openidea:plugin@1.8.0;
|
||||
|
||||
/// Project-scoped targets owned by the registering plugin (API 1.4).
|
||||
interface run-targets {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package openidea:plugin@1.7.0;
|
||||
package openidea:plugin@1.8.0;
|
||||
|
||||
/// Launches this plugin's configurations in the invoking window's Run tool window.
|
||||
interface run {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package openidea:plugin@1.7.0;
|
||||
package openidea:plugin@1.8.0;
|
||||
|
||||
/// Optional export (world `plugin-semantic-tokens`): semantic colouring of
|
||||
/// documents of the languages declared with the `semantic-tokens` provider
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package openidea:plugin@1.7.0;
|
||||
package openidea:plugin@1.8.0;
|
||||
|
||||
/// Shared types. Positions are 0-based lines and UTF-16 code unit columns,
|
||||
/// as in the editor and LSP.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package openidea:plugin@1.7.0;
|
||||
package openidea:plugin@1.8.0;
|
||||
|
||||
/// Declarative views: tool windows declared
|
||||
/// under `[[contributes.views]]` and custom editors under
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package openidea:plugin@1.7.0;
|
||||
package openidea:plugin@1.8.0;
|
||||
|
||||
interface workspace {
|
||||
use types.{error, location};
|
||||
@@ -135,7 +135,9 @@ interface editor {
|
||||
|
||||
/// Applies versioned edits to an open project document from a background
|
||||
/// event. The path is project-relative and requires `project.read` and
|
||||
/// `editor.write`. Stale versions are rejected without changing text.
|
||||
/// `editor.write`. The host refuses VCS/IDE control directories and every
|
||||
/// symlink, junction or reparse-point component before it finds the open
|
||||
/// document. Stale versions are rejected without changing text.
|
||||
/// These background edits are non-undoable and are not re-emitted as
|
||||
/// `document-changed` events.
|
||||
apply-edits-at: func(project: borrow<project>, path: string, version: u64, edits: list<text-edit>) -> result<u64, error>;
|
||||
|
||||
@@ -362,3 +362,10 @@ 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)
|
||||
}
|
||||
@@ -244,6 +244,17 @@ pub async fn debug_restart_frame(state: State<'_, AppState>, session: DebugSessi
|
||||
ide_host::ops::debug::debug_restart_frame(&state.host, "", session, frame_id).await
|
||||
}
|
||||
|
||||
/// The body of a response a debugged page received.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn debug_js_response_body(
|
||||
state: State<'_, AppState>,
|
||||
session: DebugSessionId,
|
||||
request_id: String,
|
||||
) -> IdeResult<ide_dap::JsResponseBody> {
|
||||
ide_host::ops::debug::debug_js_response_body(&state.host, "", session, request_id).await
|
||||
}
|
||||
|
||||
/// Breakpoints of a debugged page on DOM events and on requests (js-debug).
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
|
||||
@@ -65,8 +65,9 @@ pub fn apply(state: &AppState) {
|
||||
}
|
||||
// Another address, port or token: clients connect anew.
|
||||
if let Some(running) = server.take() {
|
||||
let held = (running.address(), running.port());
|
||||
running.stop();
|
||||
if let Some(port) = wanted.port {
|
||||
if let Some(port) = port_to_wait_for(held, wanted.port) {
|
||||
wait_until_free(address, port);
|
||||
}
|
||||
}
|
||||
@@ -104,6 +105,14 @@ pub fn apply(state: &AppState) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The port a restart listening on `wanted` waits for after stopping the server that listened
|
||||
/// on `held`: only its own, at any address (`127.0.0.1` and `0.0.0.0` overlap). A port another
|
||||
/// program holds (the run fell back to a free one) stays taken, and waiting for it would block
|
||||
/// the window.
|
||||
fn port_to_wait_for(held: (IpAddr, u16), wanted: Option<u16>) -> Option<u16> {
|
||||
wanted.filter(|port| held.1 == *port)
|
||||
}
|
||||
|
||||
/// Waits briefly for a stopped server's port to close, so the restart gets it again.
|
||||
fn wait_until_free(address: IpAddr, port: u16) {
|
||||
for _ in 0..100 {
|
||||
@@ -243,3 +252,21 @@ pub fn mcp_respond(
|
||||
) -> IdeResult<()> {
|
||||
state.host.mcp_requests.answer(window.label(), id, allow, error)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
#[test]
|
||||
fn a_restart_waits_only_for_the_port_the_stopped_server_held() {
|
||||
let local = IpAddr::V4(Ipv4Addr::LOCALHOST);
|
||||
let any = IpAddr::V4(Ipv4Addr::UNSPECIFIED);
|
||||
assert_eq!(port_to_wait_for((local, 64342), Some(64342)), Some(64342));
|
||||
// Another address on the same port overlaps the old listener: wait for it too.
|
||||
assert_eq!(port_to_wait_for((any, 64342), Some(64342)), Some(64342));
|
||||
// The fixed port was taken by another program: the server ran on a free one.
|
||||
assert_eq!(port_to_wait_for((local, 51234), Some(64342)), None);
|
||||
assert_eq!(port_to_wait_for((local, 64342), None), None);
|
||||
}
|
||||
}
|
||||
@@ -248,6 +248,7 @@ 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,
|
||||
@@ -325,6 +326,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
vcs::vcs_stashes,
|
||||
vcs::vcs_stage,
|
||||
vcs::vcs_add_to_ignore,
|
||||
vcs::vcs_ignore_blame_revision,
|
||||
vcs::vcs_unstage,
|
||||
vcs::vcs_stage_patch,
|
||||
vcs::vcs_discard,
|
||||
@@ -527,6 +529,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
debug::debug_restart_frame,
|
||||
debug::debug_capabilities,
|
||||
debug::debug_js_custom_breakpoints,
|
||||
debug::debug_js_response_body,
|
||||
debug::debug_set_function_breakpoints,
|
||||
debug::debug_set_data_breakpoints,
|
||||
debug::debug_data_breakpoint_info,
|
||||
@@ -744,5 +747,6 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
db::db_table_page,
|
||||
db::db_apply_edits,
|
||||
db::db_console_path,
|
||||
db::db_consoles,
|
||||
])
|
||||
}
|
||||
@@ -115,6 +115,14 @@ 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]
|
||||
|
||||
@@ -239,6 +239,17 @@ pub async fn vcs_add_to_ignore(
|
||||
ide_host::ops::vcs::vcs_add_to_ignore(&state.host, window.label(), paths, destination).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn vcs_ignore_blame_revision(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
path: PathBuf,
|
||||
commit: String,
|
||||
) -> IdeResult<()> {
|
||||
ide_host::ops::vcs::vcs_ignore_blame_revision(&state.host, window.label(), path, commit).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn vcs_unstage(window: Window, state: State<'_, AppState>, paths: Vec<PathBuf>) -> IdeResult<()> {
|
||||
@@ -428,8 +439,10 @@ pub async fn vcs_stash_push(
|
||||
message: Option<String>,
|
||||
include_untracked: bool,
|
||||
keep_index: bool,
|
||||
paths: Option<Vec<PathBuf>>,
|
||||
) -> IdeResult<()> {
|
||||
ide_host::ops::vcs::vcs_stash_push(&state.host, window.label(), repo, message, include_untracked, keep_index).await
|
||||
ide_host::ops::vcs::vcs_stash_push(&state.host, window.label(), repo, message, include_untracked, keep_index, paths)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
@@ -217,14 +217,18 @@ pub fn spec(os: Os) -> Vec<Entry> {
|
||||
}
|
||||
|
||||
let mut file = vec![
|
||||
act("file.newProject", "New Project…"),
|
||||
act("workspace.create", "New Workspace…"),
|
||||
act("workspace.open", "Open Workspace…"),
|
||||
act("workspace.manage", "Manage Workspace…"),
|
||||
act("file.new", "New…"),
|
||||
act("file.newScratchFile", "New Scratch File…"),
|
||||
sub(
|
||||
"new",
|
||||
"New",
|
||||
vec![
|
||||
act("file.newProject", "Project…"),
|
||||
act("workspace.create", "Workspace…"),
|
||||
SEP,
|
||||
act("file.new", "File…"),
|
||||
act("file.newScratchFile", "Scratch File"),
|
||||
],
|
||||
),
|
||||
act("file.openProject", "Open…"),
|
||||
act("project.attach", "Attach Project…"),
|
||||
act("file.openFile", "Open File…"),
|
||||
act("file.openInNewWindow", "Open in New Window…"),
|
||||
sub(
|
||||
@@ -232,6 +236,11 @@ pub fn spec(os: Os) -> Vec<Entry> {
|
||||
"Open Recent",
|
||||
vec![Entry::RecentProjects, SEP, act("welcome.recentProjects", "Manage Projects…")],
|
||||
),
|
||||
act("project.attach", "Attach Project…"),
|
||||
SEP,
|
||||
act("workspace.open", "Open Workspace…"),
|
||||
act("workspace.manage", "Manage Workspace…"),
|
||||
SEP,
|
||||
act("file.closeProject", "Close Project"),
|
||||
act("file.renameProject", "Rename Project…"),
|
||||
SEP,
|
||||
@@ -1612,7 +1621,7 @@ pub fn install<R: Runtime>(app: &AppHandle<R>, menu_state: &MenuState, recent: &
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashSet;
|
||||
use std::collections::{BTreeSet, HashSet};
|
||||
|
||||
const ALL_OS: [Os; 3] = [Os::Mac, Os::Windows, Os::Linux];
|
||||
|
||||
@@ -1972,6 +1981,56 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_menu_starts_with_a_new_submenu() {
|
||||
for os in ALL_OS {
|
||||
let menus = spec(os);
|
||||
let Some(Entry::Submenu { children: file, .. }) =
|
||||
menus.iter().find(|e| matches!(e, Entry::Submenu { id: "file", .. }))
|
||||
else {
|
||||
panic!("File menu missing on {os:?}")
|
||||
};
|
||||
let Some(Entry::Submenu { id: "new", children: new, .. }) = file.first() else {
|
||||
panic!("File menu does not start with New on {os:?}")
|
||||
};
|
||||
assert_eq!(
|
||||
action_ids(new),
|
||||
["file.newProject", "workspace.create", "file.new", "file.newScratchFile"],
|
||||
"{os:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_menu_item_has_a_catalog_label() {
|
||||
fn walk(entries: &[Entry], out: &mut BTreeSet<&'static str>) {
|
||||
for e in entries {
|
||||
match e {
|
||||
Entry::Submenu { id, children, .. } => {
|
||||
out.insert(id);
|
||||
walk(children, out);
|
||||
}
|
||||
Entry::Action { id, .. } => {
|
||||
out.insert(id);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut ids = BTreeSet::new();
|
||||
for os in ALL_OS {
|
||||
walk(&spec(os), &mut ids);
|
||||
}
|
||||
for (locale, catalog) in [
|
||||
("en", include_str!("../../src/platform/locales/en.json")),
|
||||
("zh", include_str!("../../src/platform/locales/zh.json")),
|
||||
] {
|
||||
let catalog: serde_json::Map<String, serde_json::Value> = serde_json::from_str(catalog).unwrap();
|
||||
let missing: Vec<_> = ids.iter().filter(|id| !catalog.contains_key(&format!("menu.{id}"))).collect();
|
||||
assert!(missing.is_empty(), "{locale}.json lacks menu labels for {missing:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent_labels_tildify_home() {
|
||||
let recent = vec![ide_workspace::RecentProject {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "OpenIDEA",
|
||||
"version": "0.1.44",
|
||||
"version": "0.1.45",
|
||||
"identifier": "ai.l0ng.openidea",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
|
||||
+88
-2
@@ -475,6 +475,11 @@ 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`. */
|
||||
@@ -657,6 +662,7 @@ export const commands = {
|
||||
vcsStashes: (repo: string | null) => typedError<Stash[], IdeError>(__TAURI_INVOKE("vcs_stashes", { repo })),
|
||||
vcsStage: (paths: string[]) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_stage", { paths })),
|
||||
vcsAddToIgnore: (paths: string[], destination: IgnoreDestination) => typedError<IpcPath[], IdeError>(__TAURI_INVOKE("vcs_add_to_ignore", { paths, destination })),
|
||||
vcsIgnoreBlameRevision: (path: string, commit: string) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_ignore_blame_revision", { path, commit })),
|
||||
vcsUnstage: (paths: string[]) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_unstage", { paths })),
|
||||
vcsStagePatch: (patch: string) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_stage_patch", { patch })),
|
||||
vcsDiscard: (paths: string[]) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_discard", { paths })),
|
||||
@@ -690,7 +696,7 @@ export const commands = {
|
||||
vcsRenameBranch: (repo: string | null, from: string, to: string, unsetUpstream: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_rename_branch", { repo, from, to, unsetUpstream })),
|
||||
vcsMerge: (repo: string | null, branch: string) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_merge", { repo, branch })),
|
||||
vcsRebase: (repo: string | null, onto: string) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_rebase", { repo, onto })),
|
||||
vcsStashPush: (repo: string | null, message: string | null, includeUntracked: boolean, keepIndex: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_stash_push", { repo, message, includeUntracked, keepIndex })),
|
||||
vcsStashPush: (repo: string | null, message: string | null, includeUntracked: boolean, keepIndex: boolean, paths: string[] | null) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_stash_push", { repo, message, includeUntracked, keepIndex, paths })),
|
||||
vcsStashApply: (repo: string | null, index: number, pop: boolean, reinstateIndex: boolean, branch: string | null) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_stash_apply", { repo, index, pop, reinstateIndex, branch })),
|
||||
vcsStashDrop: (repo: string | null, index: number) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_stash_drop", { repo, index })),
|
||||
/** Network operation; progress lines stream through `on_output`. Cancellable. */
|
||||
@@ -1215,6 +1221,8 @@ export const commands = {
|
||||
debugCapabilities: (session: DebugSessionId) => typedError<DebugCapabilities, IdeError>(__TAURI_INVOKE("debug_capabilities", { session })),
|
||||
/** Breakpoints of a debugged page on DOM events and on requests (js-debug). */
|
||||
debugJsCustomBreakpoints: (session: DebugSessionId, ids: string[], xhr: string[]) => typedError<null, IdeError>(__TAURI_INVOKE("debug_js_custom_breakpoints", { session, ids, xhr })),
|
||||
/** The body of a response a debugged page received. */
|
||||
debugJsResponseBody: (session: DebugSessionId, requestId: string) => typedError<JsResponseBody, IdeError>(__TAURI_INVOKE("debug_js_response_body", { session, requestId })),
|
||||
/** Method breakpoints (all files); states aligned with `breakpoints`. */
|
||||
debugSetFunctionBreakpoints: (breakpoints: FunctionBreakpoint[]) => typedError<BreakpointState[], IdeError>(__TAURI_INVOKE("debug_set_function_breakpoints", { breakpoints })),
|
||||
/** Field watchpoints; states aligned with `breakpoints`. */
|
||||
@@ -1723,6 +1731,8 @@ 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 */
|
||||
@@ -2886,6 +2896,25 @@ 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,
|
||||
@@ -3964,6 +3993,8 @@ export type DebugCapabilities = {
|
||||
* ([`DebugManager::js_set_custom_breakpoints`]).
|
||||
*/
|
||||
jsPage: boolean,
|
||||
/** The adapter evaluates expressions while the program runs, without a stack frame. */
|
||||
liveEvaluate: boolean,
|
||||
};
|
||||
|
||||
/** A completion the adapter offers for an expression (DAP `completions`). */
|
||||
@@ -3999,7 +4030,11 @@ export type DebugEvent =
|
||||
* The adapter's `invalidated` event: what it shows for `areas` (`all`, `stacks`, `threads`,
|
||||
* `variables`; empty means all) is stale and has to be fetched again.
|
||||
*/
|
||||
{ kind: "invalidated"; areas: string[] } | { kind: "jvmBackend"; state: JvmBackendState };
|
||||
{ kind: "invalidated"; areas: string[] } | { kind: "jvmBackend"; state: JvmBackendState } |
|
||||
/** A step in the life of one request of a debugged page. */
|
||||
{ kind: "jsNetwork"; event: JsNetworkEvent } |
|
||||
/** The adapter does not report the requests of this page: there will be no `JsNetwork`. */
|
||||
{ kind: "jsNetworkUnavailable" };
|
||||
|
||||
/** A debug adapter session. */
|
||||
export type DebugSessionId = number;
|
||||
@@ -4015,6 +4050,8 @@ export type DebuggerSettings = {
|
||||
inlineValues?: boolean,
|
||||
/** Data Views: list variables by name instead of the adapter's order. */
|
||||
sortValues?: boolean,
|
||||
/** Data Views: evaluate watches while the program runs, where the debugger can (JavaScript). */
|
||||
liveWatches?: boolean,
|
||||
valueTooltip?: boolean,
|
||||
/** While suspended, selecting an expression shows its value. */
|
||||
valueTooltipOnSelection?: boolean,
|
||||
@@ -6418,6 +6455,49 @@ export type JetBrainsSettings = {
|
||||
files: { [key in string]: string },
|
||||
};
|
||||
|
||||
/** The script frame that made a request, where the browser ran it (0-based line and column). */
|
||||
export type JsInitiator = {
|
||||
function: string,
|
||||
url: string,
|
||||
line: number,
|
||||
column: number,
|
||||
};
|
||||
|
||||
/**
|
||||
* What a debugged page's browser reports about a request, reduced to what the Network view
|
||||
* shows. Events of one request share `request_id`; each kind fills its own fields.
|
||||
*/
|
||||
export type JsNetworkEvent = {
|
||||
kind: JsNetworkKind,
|
||||
requestId: string,
|
||||
/** The browser's monotonic clock, comparable between the events of a page. */
|
||||
timestampMs: number,
|
||||
/** Request and response. */
|
||||
url: string | null,
|
||||
method: string | null,
|
||||
/** `Fetch`, `XHR`, `Script`, `Document`, … */
|
||||
resourceType: string | null,
|
||||
status: number | null,
|
||||
mimeType: string | null,
|
||||
/** The request's headers, or the response's. */
|
||||
headers: { [key in string]: string },
|
||||
/** Bytes received over the network, once finished. */
|
||||
encodedLength: number | null,
|
||||
errorText: string | null,
|
||||
initiator: JsInitiator | null,
|
||||
};
|
||||
|
||||
/** Which step of a request a [`JsNetworkEvent`] reports. */
|
||||
export type JsNetworkKind = "request" | "response" | "finished" | "failed";
|
||||
|
||||
/** The body of a response, as text or as the browser's base64 for binary content. */
|
||||
export type JsResponseBody = {
|
||||
body: string,
|
||||
base64Encoded: boolean,
|
||||
/** The body was longer than [`RESPONSE_BODY_LIMIT`] and is cut there. */
|
||||
truncated: boolean,
|
||||
};
|
||||
|
||||
/** The backend that reads a debugged JVM's objects (instances, fields, referrers). */
|
||||
export type JvmBackendState = { kind: "ready" } |
|
||||
/** Turned off for the rest of the session: the debug adapter lacks what it needs. */
|
||||
@@ -10297,6 +10377,12 @@ export type TypeScriptSettings = {
|
||||
* or a path to a TypeScript `lib` directory.
|
||||
*/
|
||||
version?: string,
|
||||
/**
|
||||
* tsserver heap limit in MB. `0` = automatic: 8192 when vtsls hosts the Vue
|
||||
* plugin (hybrid mode; a Vue project's program alone needs ~3 GiB, and the
|
||||
* vtsls default 3072 dies in a GC death spiral), else unset (vtsls default).
|
||||
*/
|
||||
tsserverMaxMemory?: number,
|
||||
};
|
||||
|
||||
/** A misspelt word. */
|
||||
|
||||
@@ -682,6 +682,10 @@ 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
Reference in new issue
Заблокувати користувача