дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-15 22:25:48 +02:00
Порівняти коміти
13
Коміти
| Автор | SHA1 | Дата | |
|---|---|---|---|
|
|
ee3ad070fd | ||
|
|
7fe6f5f58d | ||
|
|
abf904553d | ||
|
|
d425388f1a | ||
|
|
de123e2a21 | ||
|
|
d19dbec56a | ||
|
|
1a204b5f4b | ||
|
|
3ec7daf845 | ||
|
|
112ac05052 | ||
|
|
10b7340e2a | ||
|
|
6fa5b368e6 | ||
|
|
dd40273952 | ||
|
|
21bc81c6da |
No files matched your search
Створено
@@ -4269,6 +4269,7 @@ dependencies = [
|
||||
name = "ide-dap"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"futures",
|
||||
"ide-core",
|
||||
"ide-heap",
|
||||
@@ -4287,6 +4288,7 @@ dependencies = [
|
||||
"specta",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"windows-sys 0.61.2",
|
||||
|
||||
@@ -53,6 +53,8 @@ pub enum BuildSystem {
|
||||
TypeScript,
|
||||
#[serde(rename = "cmake")]
|
||||
CMake,
|
||||
#[serde(rename = "dotnet")]
|
||||
Dotnet,
|
||||
}
|
||||
|
||||
/// One command of a build.
|
||||
@@ -165,6 +167,8 @@ fn detect_dir(dir: &Path, model: &BuildModel, root: &Path) -> Option<BuildSystem
|
||||
|| has(dir, &["build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts"])
|
||||
{
|
||||
Some(BuildSystem::Gradle)
|
||||
} else if dotnet_target(dir).is_some() {
|
||||
Some(BuildSystem::Dotnet)
|
||||
} else if has(dir, &["Cargo.toml"]) {
|
||||
Some(BuildSystem::Cargo)
|
||||
} else if has(dir, &["go.mod", "go.work"]) {
|
||||
@@ -225,7 +229,7 @@ pub fn plan(
|
||||
.or((!model.gradle.is_empty()).then_some(BuildSystem::Gradle))
|
||||
.or_else(|| model.maven.as_ref().filter(|m| !m.projects.is_empty()).map(|_| BuildSystem::Maven))
|
||||
.ok_or_else(|| {
|
||||
IdeError::not_found("no supported build system (Maven, Gradle, Cargo, Go, TypeScript, CMake) found")
|
||||
IdeError::not_found("no supported build system (Maven, Gradle, .NET, Cargo, Go, TypeScript, CMake) found")
|
||||
})?;
|
||||
let module = module_dir.as_deref().filter(|d| *d != root);
|
||||
let title = match module {
|
||||
@@ -244,6 +248,7 @@ pub fn plan(
|
||||
BuildSystem::Go => go_steps(file.and_then(Path::parent).unwrap_or(&dir), kind)?,
|
||||
BuildSystem::TypeScript => ts_steps(&dir, kind),
|
||||
BuildSystem::CMake => cmake_steps(&dir, kind, cmake.and_then(<[_]>::first)),
|
||||
BuildSystem::Dotnet => dotnet_steps(&dir, kind)?,
|
||||
};
|
||||
Ok(BuildPlan { system, title, steps })
|
||||
}
|
||||
@@ -451,6 +456,33 @@ fn has_build_script(dir: &Path) -> bool {
|
||||
.is_some_and(|v| v.pointer("/scripts/build").is_some_and(serde_json::Value::is_string))
|
||||
}
|
||||
|
||||
/// The solution (`.sln` / `.slnx`) or project (`.csproj` / `.fsproj` / `.vbproj`) file `dir`
|
||||
/// builds: a solution first, then a project; the first by name when there are several.
|
||||
fn dotnet_target(dir: &Path) -> Option<PathBuf> {
|
||||
let mut files: Vec<PathBuf> =
|
||||
std::fs::read_dir(dir).ok()?.flatten().map(|e| e.path()).filter(|p| p.is_file()).collect();
|
||||
files.sort();
|
||||
let ext = |p: &Path| p.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
|
||||
let solution = files.iter().find(|p| matches!(ext(p).as_str(), "sln" | "slnx"));
|
||||
solution.or_else(|| files.iter().find(|p| matches!(ext(p).as_str(), "csproj" | "fsproj" | "vbproj"))).cloned()
|
||||
}
|
||||
|
||||
/// `dotnet build` of the directory's solution or project; Rebuild skips incremental builds,
|
||||
/// Build Artifacts publishes. The summary that repeats every message is left out.
|
||||
fn dotnet_steps(dir: &Path, kind: BuildKind) -> IdeResult<Vec<BuildStep>> {
|
||||
let target = dotnet_target(dir).ok_or_else(|| IdeError::not_found("no .NET solution or project found"))?;
|
||||
let target = target.file_name().map_or_else(String::new, |n| n.to_string_lossy().into_owned());
|
||||
let verb = if kind == BuildKind::Artifacts { "publish" } else { "build" };
|
||||
let mut step = BuildStep::new("dotnet", &[verb, &target, "-nologo", "-clp:NoSummary"], dir, Format::Msbuild);
|
||||
if kind == BuildKind::Rebuild {
|
||||
step.args.push("--no-incremental".into());
|
||||
}
|
||||
// The classic console logger prints the canonical `file(line,col): error` lines; the
|
||||
// terminal logger (.NET 8+) only appears on a terminal, but rules it out for older SDKs too.
|
||||
step.env.insert("MSBUILDTERMINALLOGGER".into(), "off".into());
|
||||
Ok(vec![step])
|
||||
}
|
||||
|
||||
fn ts_steps(dir: &Path, kind: BuildKind) -> Vec<BuildStep> {
|
||||
let script = has_build_script(dir);
|
||||
if script && (kind == BuildKind::Artifacts || !dir.join("tsconfig.json").is_file()) {
|
||||
@@ -1141,6 +1173,24 @@ mod tests {
|
||||
("pnpm", &["run".to_owned(), "build".to_owned()][..])
|
||||
);
|
||||
|
||||
// .NET: the solution, else the project; a module build is the file's project. An ASP.NET
|
||||
// project's package.json (its front end) doesn't make it a TypeScript build.
|
||||
let net = tmp.path().join("net");
|
||||
write(&net, "App.sln", "");
|
||||
write(&net, "src/Api/Api.csproj", "<Project/>");
|
||||
write(&net, "src/Api/package.json", "{}");
|
||||
write(&net, "src/Api/Program.cs", "");
|
||||
let p = plan(&net, &BuildModel::default(), BuildKind::Build, None, None).unwrap();
|
||||
assert_eq!(p.system, BuildSystem::Dotnet);
|
||||
assert_eq!(p.steps[0].args, ["build", "App.sln", "-nologo", "-clp:NoSummary"]);
|
||||
assert_eq!(p.steps[0].env["MSBUILDTERMINALLOGGER"], "off");
|
||||
let p = plan(&net, &BuildModel::default(), BuildKind::Rebuild, Some(&net.join("src/Api/Program.cs")), None)
|
||||
.unwrap();
|
||||
assert_eq!(p.steps[0].args, ["build", "Api.csproj", "-nologo", "-clp:NoSummary", "--no-incremental"]);
|
||||
assert_eq!(p.steps[0].cwd, net.join("src/Api"));
|
||||
let p = plan(&net, &BuildModel::default(), BuildKind::Artifacts, None, None).unwrap();
|
||||
assert_eq!(p.steps[0].args[0], "publish");
|
||||
|
||||
let cm = tmp.path().join("cm");
|
||||
write(&cm, "CMakeLists.txt", "project(app)");
|
||||
let args = |p: &BuildPlan| p.steps.iter().map(|s| s.args.join(" ")).collect::<Vec<_>>();
|
||||
|
||||
@@ -25,6 +25,8 @@ pub(crate) enum Format {
|
||||
Go,
|
||||
/// GCC / Clang (through `cmake --build`): `src/a.cpp:12:5: error: msg`.
|
||||
Gcc,
|
||||
/// MSBuild (`dotnet build`): `C:\p\A.cs(12,5): error CS1002: msg [C:\p\App.csproj]`.
|
||||
Msbuild,
|
||||
}
|
||||
|
||||
/// What a printed column counts.
|
||||
@@ -268,6 +270,43 @@ fn tsc_line(line: &str) -> Option<RawMessage> {
|
||||
Some(m)
|
||||
}
|
||||
|
||||
/// MSBuild's canonical form: `path(12,5): error CS1002: msg [project]`, `path(12,5,12,9): …`,
|
||||
/// or `path : error NU1101: msg [project]` for a project-level message (no position).
|
||||
/// The path may contain spaces (`C:\Users\Jane Doe\…`); "error" / "warning" are never localized.
|
||||
fn msbuild_line(line: &str) -> Option<RawMessage> {
|
||||
let line = line.trim();
|
||||
// The project the message came from, appended by the console logger.
|
||||
let line = match line.rfind(" [") {
|
||||
Some(i) if line.ends_with(']') => &line[..i],
|
||||
_ => line,
|
||||
};
|
||||
let (head, severity, rest) =
|
||||
[(" error ", Severity::Error), (" warning ", Severity::Warning)].into_iter().find_map(|(word, severity)| {
|
||||
let i = line.find(&format!(":{word}"))?;
|
||||
Some((&line[..i], severity, &line[i + word.len() + 1..]))
|
||||
})?;
|
||||
let (code, msg) = rest.split_once(':')?;
|
||||
let code = code.trim();
|
||||
if code.is_empty() || code.contains(' ') {
|
||||
return None;
|
||||
}
|
||||
let head = head.trim_end();
|
||||
let (path, pos) = match head.strip_suffix(')').and_then(|h| h.rsplit_once('(')) {
|
||||
Some((path, pos)) => (path, pos.split(',').map(num).collect::<Vec<_>>()),
|
||||
None => (head, Vec::new()),
|
||||
};
|
||||
let at = |i: usize| pos.get(i).copied().flatten();
|
||||
// `MSBUILD : error MSB1009` and the like name no file.
|
||||
let file = path.rsplit(['/', '\\']).next().is_some_and(|n| n.contains('.'));
|
||||
let mut m = RawMessage::new(severity, msg.trim(), ColUnit::Utf16);
|
||||
if file {
|
||||
m.path = Some(path.trim().to_owned());
|
||||
(m.line, m.col, m.end_line, m.end_col) = (at(0), at(1), at(2), at(3));
|
||||
}
|
||||
m.code = Some(code.to_owned());
|
||||
Some(m)
|
||||
}
|
||||
|
||||
/// go: `./a.go:12:5: msg` (errors only; `go vet` style `a.go:12: msg` too).
|
||||
fn go_line(line: &str) -> Option<RawMessage> {
|
||||
let (path, ln, col, rest) = path_line_col(line)?;
|
||||
@@ -404,6 +443,7 @@ impl OutputParser {
|
||||
Format::Tsc => tsc_line(&line),
|
||||
Format::Go => go_line(&line),
|
||||
Format::Gcc => gcc_line(&line),
|
||||
Format::Msbuild => msbuild_line(&line),
|
||||
Format::Cargo => None,
|
||||
};
|
||||
if let Some(msg) = parsed {
|
||||
@@ -551,6 +591,27 @@ mod tests {
|
||||
assert!(plain.messages.is_empty() && plain.console.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn msbuild_canonical_messages() {
|
||||
let out = parse(
|
||||
Format::Msbuild,
|
||||
" Determining projects to restore...\n\
|
||||
C:\\Users\\Jane Doe\\App\\Program.cs(12,5): error CS1002: ; expected [C:\\Users\\Jane Doe\\App\\App.csproj]\n\
|
||||
/p/App/Models/User.cs(3,8,3,12): warning CS0168: The variable 'e' is declared but never used [/p/App/App.csproj]\n\
|
||||
/p/App/App.csproj : error NU1101: Unable to find package Foo. [/p/App/App.csproj]\n\
|
||||
MSBUILD : error MSB1009: Project file does not exist.\n\
|
||||
Build FAILED.\n",
|
||||
);
|
||||
assert_eq!(out.len(), 4, "{out:?}");
|
||||
assert_eq!(pos(&out[0]), (Severity::Error, "C:\\Users\\Jane Doe\\App\\Program.cs", 12, Some(5)));
|
||||
assert_eq!((out[0].code.as_deref(), out[0].text.as_str()), (Some("CS1002"), "; expected"));
|
||||
assert_eq!(pos(&out[1]), (Severity::Warning, "/p/App/Models/User.cs", 3, Some(8)));
|
||||
assert_eq!((out[1].end_line, out[1].end_col), (Some(3), Some(12)));
|
||||
assert_eq!((out[2].path.as_deref(), out[2].line), (Some("/p/App/App.csproj"), None));
|
||||
assert_eq!(out[2].text, "Unable to find package Foo.");
|
||||
assert_eq!((out[3].path.as_deref(), out[3].code.as_deref()), (None, Some("MSB1009")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tsc_plain_and_pretty() {
|
||||
let out = parse(
|
||||
|
||||
@@ -118,6 +118,10 @@ pub enum IdeEvent {
|
||||
LanguageServer {
|
||||
server: ServerId,
|
||||
name: String,
|
||||
/// What the user sees as the server when another program serves in its place.
|
||||
#[serde(default)]
|
||||
#[specta(optional)]
|
||||
label: Option<String>,
|
||||
status: ServerStatus,
|
||||
message: Option<String>,
|
||||
},
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
//! JSON with comments (`devcontainer.json`, `deno.jsonc`, `tsconfig.json`): turned
|
||||
//! into plain JSON for `serde_json`.
|
||||
|
||||
/// Strips `//` and `/* */` comments and trailing commas so JSONC parses as JSON.
|
||||
/// Strips `//` and `/* */` comments, trailing commas and a leading byte order mark (Visual
|
||||
/// Studio writes one into `launchSettings.json`) so JSONC parses as JSON.
|
||||
pub fn strip(src: &str) -> String {
|
||||
let src = src.strip_prefix('\u{feff}').unwrap_or(src);
|
||||
let mut out = String::with_capacity(src.len());
|
||||
let mut chars = src.chars().peekable();
|
||||
let mut in_str = false;
|
||||
@@ -87,4 +89,10 @@ mod tests {
|
||||
assert_eq!(v["url"], "https://x.dev//y /* no */");
|
||||
assert_eq!(v["q"], "say \"hi\",");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drops_a_byte_order_mark() {
|
||||
let v: serde_json::Value = serde_json::from_str(&strip("\u{feff}{ \"a\": 1 }")).unwrap();
|
||||
assert_eq!(v["a"], 1);
|
||||
}
|
||||
}
|
||||
@@ -1092,7 +1092,8 @@ impl Default for ExternalFormatterSettings {
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct PythonSettings {
|
||||
/// Project interpreter executable; empty = auto-detect (project venv, else `python3`).
|
||||
/// Project interpreter executable; empty = auto-detect (project venv, else `python3`);
|
||||
/// `target:<run-target-id>` = the Python of a Docker or Compose run target.
|
||||
pub interpreter: String,
|
||||
/// Activate the interpreter's virtualenv in new terminal sessions.
|
||||
pub activate_virtualenv: bool,
|
||||
@@ -1121,6 +1122,17 @@ impl Default for PythonSettings {
|
||||
}
|
||||
}
|
||||
|
||||
impl PythonSettings {
|
||||
/// What `interpreter` starts with when the project interpreter is a run target's.
|
||||
pub const TARGET_PREFIX: &str = "target:";
|
||||
|
||||
/// The run target whose Python is the project interpreter (`target:<id>`), if one is.
|
||||
pub fn run_target(&self) -> Option<&str> {
|
||||
let id = self.interpreter.trim().strip_prefix(Self::TARGET_PREFIX)?.trim();
|
||||
(!id.is_empty()).then_some(id)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct PhpSettings {
|
||||
@@ -1542,6 +1554,16 @@ impl Settings {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn python_interpreter_may_name_a_run_target() {
|
||||
let python = |interpreter: &str| PythonSettings { interpreter: interpreter.into(), ..Default::default() };
|
||||
assert_eq!(python("target:web").run_target(), Some("web"));
|
||||
assert_eq!(python(" target: web ").run_target(), Some("web"));
|
||||
for local in ["", "target:", "/usr/bin/python3", ".venv/bin/python", "targets/python"] {
|
||||
assert_eq!(python(local).run_target(), None, "{local}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removed_one_themes_read_as_islands_themes() {
|
||||
let settings: Settings = serde_json::from_str(
|
||||
|
||||
@@ -7,6 +7,8 @@ license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
futures.workspace = true
|
||||
tokio-tungstenite.workspace = true
|
||||
base64.workspace = true
|
||||
tempfile.workspace = true
|
||||
ide-core.workspace = true
|
||||
ide-language.workspace = true
|
||||
|
||||
@@ -0,0 +1,528 @@
|
||||
//! Replacing the responses a debugged page receives. js-debug hands out a WebSocket that
|
||||
//! speaks the browser's own protocol (`requestCDPProxy`); over it the browser is told to hold
|
||||
//! the requests whose URL matches a rule (`Fetch.enable`) and each held request is answered
|
||||
//! here, with the rule's response or by letting it through.
|
||||
//!
|
||||
//! A held request stays held until it is answered, so interception is always switched off
|
||||
//! (`Fetch.disable`) before the connection is given up: a page whose requests nobody answers hangs.
|
||||
|
||||
use crate::{DebugManager, DebugSessionId, REQUEST_TIMEOUT, js_debug};
|
||||
use base64::Engine;
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use specta::Type;
|
||||
use std::collections::HashMap;
|
||||
use tokio::sync::{oneshot, watch};
|
||||
use tokio_tungstenite::tungstenite::{Message, http::StatusCode};
|
||||
|
||||
/// A response given in place of the server's to every request whose URL contains `url_contains`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct JsResponseRule {
|
||||
pub url_contains: String,
|
||||
pub status: u16,
|
||||
pub content_type: String,
|
||||
pub body: String,
|
||||
}
|
||||
|
||||
/// The browser's URL pattern (`*` any text, `?` one character, `\` escapes) for "contains `part`".
|
||||
pub(crate) fn url_pattern(part: &str) -> String {
|
||||
let mut pattern = String::with_capacity(part.len() + 2);
|
||||
pattern.push('*');
|
||||
for c in part.chars() {
|
||||
if matches!(c, '*' | '?' | '\\') {
|
||||
pattern.push('\\');
|
||||
}
|
||||
pattern.push(c);
|
||||
}
|
||||
pattern.push('*');
|
||||
pattern
|
||||
}
|
||||
|
||||
pub(crate) fn rule_for<'a>(rules: &'a [JsResponseRule], url: &str) -> Option<&'a JsResponseRule> {
|
||||
rules.iter().find(|rule| url.contains(&rule.url_contains))
|
||||
}
|
||||
|
||||
/// What to tell the browser about a request it holds: the first matching rule's response, or
|
||||
/// to send the request on. Every held request gets one of the two.
|
||||
pub(crate) fn answer(held: &Value, rules: &[JsResponseRule]) -> (&'static str, Value) {
|
||||
let request_id = held.get("requestId").and_then(Value::as_str).unwrap_or_default();
|
||||
let url = held.pointer("/request/url").and_then(Value::as_str).unwrap_or_default();
|
||||
let Some(rule) = rule_for(rules, url) else {
|
||||
return ("Fetch.continueRequest", json!({ "requestId": request_id }));
|
||||
};
|
||||
let header = |name: &str, value: &str| json!({ "name": name, "value": value });
|
||||
// A page on another origin only reads a response that names its origin. The server would
|
||||
// say so; the response given in its place has to.
|
||||
let mut cross_origin = Vec::new();
|
||||
if let Some(origin) = request_header(held, "Origin") {
|
||||
cross_origin.push(header("Access-Control-Allow-Origin", origin));
|
||||
cross_origin.push(header("Access-Control-Allow-Credentials", "true"));
|
||||
}
|
||||
let method = held.pointer("/request/method").and_then(Value::as_str).unwrap_or_default();
|
||||
// The browser's question before such a request: allowed here, the server may not exist.
|
||||
if method == "OPTIONS"
|
||||
&& let Some(asked) = request_header(held, "Access-Control-Request-Method")
|
||||
{
|
||||
cross_origin.push(header("Access-Control-Allow-Methods", asked));
|
||||
if let Some(asked) = request_header(held, "Access-Control-Request-Headers") {
|
||||
cross_origin.push(header("Access-Control-Allow-Headers", asked));
|
||||
}
|
||||
let allowed = json!({
|
||||
"requestId": request_id,
|
||||
"responseCode": 204,
|
||||
"responsePhrase": phrase(204),
|
||||
"responseHeaders": cross_origin,
|
||||
});
|
||||
return ("Fetch.fulfillRequest", allowed);
|
||||
}
|
||||
let mut headers = vec![header("Content-Type", &rule.content_type)];
|
||||
headers.extend(cross_origin);
|
||||
let replaced = json!({
|
||||
"requestId": request_id,
|
||||
"responseCode": rule.status,
|
||||
"responsePhrase": phrase(rule.status),
|
||||
"responseHeaders": headers,
|
||||
"body": base64::engine::general_purpose::STANDARD.encode(rule.body.as_bytes()),
|
||||
});
|
||||
("Fetch.fulfillRequest", replaced)
|
||||
}
|
||||
|
||||
/// A header of the held request; the browser reports names as the page wrote them.
|
||||
fn request_header<'a>(held: &'a Value, name: &str) -> Option<&'a str> {
|
||||
held.pointer("/request/headers")?.as_object()?.iter().find(|(key, _)| key.eq_ignore_ascii_case(name))?.1.as_str()
|
||||
}
|
||||
|
||||
/// The browser refuses a response without a phrase when it has none of its own for the status.
|
||||
fn phrase(status: u16) -> &'static str {
|
||||
StatusCode::from_u16(status).ok().and_then(|status| status.canonical_reason()).unwrap_or("Unknown")
|
||||
}
|
||||
|
||||
fn interception(rules: &[JsResponseRule]) -> Value {
|
||||
let patterns: Vec<Value> = rules
|
||||
.iter()
|
||||
.map(|rule| json!({ "urlPattern": url_pattern(&rule.url_contains), "requestStage": "Request" }))
|
||||
.collect();
|
||||
json!({ "patterns": patterns })
|
||||
}
|
||||
|
||||
/// Applies `rules` to the page behind js-debug's CDP proxy at `ws_url` until their sender is
|
||||
/// dropped (the session is gone) or the proxy closes the connection. The browser holds
|
||||
/// requests only while there are rules. `ready` hears whether the proxy reports held
|
||||
/// requests at all; nothing is held before it has agreed to.
|
||||
pub(crate) async fn serve(
|
||||
ws_url: String,
|
||||
mut rules: watch::Receiver<Vec<JsResponseRule>>,
|
||||
ready: oneshot::Sender<Result<(), String>>,
|
||||
) {
|
||||
let socket = match tokio_tungstenite::connect_async(ws_url.as_str()).await {
|
||||
Ok((socket, _)) => socket,
|
||||
Err(e) => {
|
||||
let _ = ready.send(Err(e.to_string()));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let (mut write, mut read) = socket.split();
|
||||
let mut last_id = 0u64;
|
||||
let mut call = |method: &str, params: Value| {
|
||||
last_id += 1;
|
||||
(last_id, Message::text(json!({ "id": last_id, "method": method, "params": params }).to_string()))
|
||||
};
|
||||
|
||||
let (subscription, subscribe) = call("JsDebug.subscribe", json!({ "events": ["Fetch.requestPaused"] }));
|
||||
let subscribed = tokio::time::timeout(REQUEST_TIMEOUT, async {
|
||||
write.send(subscribe).await.map_err(|e| e.to_string())?;
|
||||
loop {
|
||||
let reply = read.next().await.ok_or("the connection closed")?.map_err(|e| e.to_string())?;
|
||||
let Some(reply) = reply.to_text().ok().and_then(|text| serde_json::from_str::<Value>(text).ok()) else {
|
||||
continue;
|
||||
};
|
||||
if reply.get("id").and_then(Value::as_u64) != Some(subscription) {
|
||||
continue;
|
||||
}
|
||||
break match reply.get("error") {
|
||||
Some(error) => Err(error.get("message").and_then(Value::as_str).unwrap_or("refused").to_owned()),
|
||||
None => Ok(()),
|
||||
};
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|_| Err("no answer".to_owned()));
|
||||
if let Err(why) = subscribed {
|
||||
let _ = ready.send(Err(why));
|
||||
let _ = write.close().await;
|
||||
return;
|
||||
}
|
||||
let _ = ready.send(Ok(()));
|
||||
|
||||
let mut current = rules.borrow_and_update().clone();
|
||||
let mut holding = !current.is_empty();
|
||||
if holding && write.send(call("Fetch.enable", interception(¤t)).1).await.is_err() {
|
||||
return;
|
||||
}
|
||||
// The request each sent replacement is for, by command id, until the browser has taken it.
|
||||
let mut replacing: HashMap<u64, String> = HashMap::new();
|
||||
loop {
|
||||
tokio::select! {
|
||||
changed = rules.changed() => {
|
||||
if changed.is_err() {
|
||||
break;
|
||||
}
|
||||
current = rules.borrow_and_update().clone();
|
||||
let (_, command) = match (current.is_empty(), holding) {
|
||||
(true, false) => continue,
|
||||
(true, true) => call("Fetch.disable", json!({})),
|
||||
(false, _) => call("Fetch.enable", interception(¤t)),
|
||||
};
|
||||
holding = !current.is_empty();
|
||||
if write.send(command).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
message = read.next() => {
|
||||
// The proxy closed (the page or the session ended): nothing is held any more.
|
||||
let Some(Ok(message)) = message else { return };
|
||||
let Ok(text) = message.to_text() else { continue };
|
||||
let Ok(message) = serde_json::from_str::<Value>(text) else { continue };
|
||||
if let Some(id) = message.get("id").and_then(Value::as_u64) {
|
||||
// The browser did not take the replacement: let the request through, or
|
||||
// it stays held and the page waits for it.
|
||||
if let Some(request_id) = replacing.remove(&id)
|
||||
&& message.get("error").is_some()
|
||||
{
|
||||
let (_, go_on) = call("Fetch.continueRequest", json!({ "requestId": request_id }));
|
||||
if write.send(go_on).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if message.get("method").and_then(Value::as_str) != Some("Fetch.requestPaused") {
|
||||
continue;
|
||||
}
|
||||
let Some(held) = message.get("params") else { continue };
|
||||
let Some(request_id) = held.get("requestId").and_then(Value::as_str) else { continue };
|
||||
let (method, params) = answer(held, ¤t);
|
||||
let (id, command) = call(method, params);
|
||||
if method == "Fetch.fulfillRequest" {
|
||||
replacing.insert(id, request_id.to_owned());
|
||||
}
|
||||
if write.send(command).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if holding {
|
||||
let _ = write.send(call("Fetch.disable", json!({})).1).await;
|
||||
}
|
||||
let _ = write.close().await;
|
||||
}
|
||||
|
||||
impl DebugManager {
|
||||
/// Makes `rules` the page's response rules; none switches the replacing off. The first
|
||||
/// rules of a session open the connection to js-debug's CDP proxy, later ones go over it.
|
||||
/// An error means the rules are not applied.
|
||||
pub async fn js_set_response_overrides(&self, s: DebugSessionId, rules: Vec<JsResponseRule>) -> IdeResult<()> {
|
||||
let session = self.session(s)?;
|
||||
if !js_debug::is_page(&session) {
|
||||
return Err(IdeError::unavailable("this debug session is not a page in a browser"));
|
||||
}
|
||||
// Held to the end: two callers at once would each open a connection.
|
||||
let mut applied = session.js_overrides.lock().await;
|
||||
if applied.as_ref().is_some_and(|sender| sender.send(rules.clone()).is_ok()) {
|
||||
return Ok(());
|
||||
}
|
||||
// No connection yet, or the proxy closed it.
|
||||
applied.take();
|
||||
if rules.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let refused =
|
||||
|why: String| IdeError::unavailable(format!("the JavaScript debugger has no browser connection: {why}"));
|
||||
if !js_debug::launched(&session).await {
|
||||
return Err(refused("the page's debug session did not finish starting".into()));
|
||||
}
|
||||
let proxy = session
|
||||
.conn
|
||||
.request_timeout("requestCDPProxy", json!({}), REQUEST_TIMEOUT)
|
||||
.await
|
||||
.map_err(|e| refused(e.to_string()))?;
|
||||
let (Some(host), Some(port)) =
|
||||
(proxy.get("host").and_then(Value::as_str), proxy.get("port").and_then(Value::as_u64))
|
||||
else {
|
||||
return Err(refused("no address".into()));
|
||||
};
|
||||
let path = proxy.get("path").and_then(Value::as_str).unwrap_or_default();
|
||||
let (sender, receiver) = watch::channel(rules);
|
||||
let (ready, is_ready) = oneshot::channel();
|
||||
tokio::spawn(serve(format!("ws://{host}:{port}{path}"), receiver, ready));
|
||||
match is_ready.await {
|
||||
Ok(Ok(())) => {
|
||||
*applied = Some(sender);
|
||||
Ok(())
|
||||
}
|
||||
Ok(Err(why)) => Err(refused(why)),
|
||||
Err(_) => Err(refused("no answer".into())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use serde_json::json;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
pub(crate) fn rule(url_contains: &str, status: u16, body: &str) -> JsResponseRule {
|
||||
JsResponseRule {
|
||||
url_contains: url_contains.into(),
|
||||
status,
|
||||
content_type: "application/json".into(),
|
||||
body: body.into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_part_of_a_url_becomes_a_pattern_that_matches_it_anywhere() {
|
||||
assert_eq!(url_pattern("api/items"), "*api/items*");
|
||||
// The pattern's own wildcards and escape character are taken literally.
|
||||
assert_eq!(url_pattern(r"a*b?c\d"), r"*a\*b\?c\\d*");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_rule_whose_text_is_in_the_url_answers() {
|
||||
let rules = [rule("api/items", 201, "{\"a\":\"é\"}"), rule("api", 500, "later")];
|
||||
assert_eq!(rule_for(&rules, "http://h/api/items?x=1").map(|r| r.status), Some(201));
|
||||
assert_eq!(rule_for(&rules, "http://h/api/users").map(|r| r.status), Some(500));
|
||||
assert!(rule_for(&rules, "http://h/logo.png").is_none());
|
||||
|
||||
let (command, params) = answer(&held("7", "http://h/api/items")["params"], &rules);
|
||||
assert_eq!(command, "Fetch.fulfillRequest");
|
||||
assert_eq!(
|
||||
params,
|
||||
json!({
|
||||
"requestId": "7",
|
||||
"responseCode": 201,
|
||||
"responsePhrase": "Created",
|
||||
"responseHeaders": [{ "name": "Content-Type", "value": "application/json" }],
|
||||
// base64 of the UTF-8 text `{"a":"é"}`
|
||||
"body": "eyJhIjoiw6kifQ==",
|
||||
})
|
||||
);
|
||||
// A request the browser held that no rule is for goes on to the network.
|
||||
assert_eq!(
|
||||
answer(&held("8", "http://h/logo.png")["params"], &rules),
|
||||
("Fetch.continueRequest", json!({ "requestId": "8" }))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_status_the_browser_has_no_phrase_for_is_given_one() {
|
||||
// The browser refuses a response whose status it cannot name.
|
||||
let (_, params) = answer(&held("1", "http://h/api")["params"], &[rule("api", 419, "")]);
|
||||
assert_eq!((¶ms["responseCode"], ¶ms["responsePhrase"]), (&json!(419), &json!("Unknown")));
|
||||
}
|
||||
|
||||
fn header<'a>(params: &'a Value, name: &str) -> Option<&'a str> {
|
||||
params["responseHeaders"].as_array()?.iter().find(|h| h["name"] == name)?["value"].as_str()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_replaced_response_passes_the_pages_cross_origin_check() {
|
||||
let rules = [rule("api/items", 200, "[]")];
|
||||
let from_page = |method: &str, headers: Value| json!({ "requestId": "3", "request": { "url": "http://localhost:8080/api/items", "method": method, "headers": headers } });
|
||||
// A page on another origin: the response names that origin, as the real server would.
|
||||
let (_, params) = answer(&from_page("GET", json!({ "origin": "http://localhost:5173" })), &rules);
|
||||
assert_eq!(header(¶ms, "Access-Control-Allow-Origin"), Some("http://localhost:5173"));
|
||||
assert_eq!(header(¶ms, "Access-Control-Allow-Credentials"), Some("true"));
|
||||
assert_eq!(params["body"], "W10=");
|
||||
|
||||
// The browser's question before such a request is answered too: the server may not exist.
|
||||
let preflight = from_page(
|
||||
"OPTIONS",
|
||||
json!({
|
||||
"Origin": "http://localhost:5173",
|
||||
"Access-Control-Request-Method": "PUT",
|
||||
"Access-Control-Request-Headers": "content-type,x-token",
|
||||
}),
|
||||
);
|
||||
let (command, params) = answer(&preflight, &rules);
|
||||
assert_eq!((command, ¶ms["responseCode"]), ("Fetch.fulfillRequest", &json!(204)));
|
||||
assert_eq!(header(¶ms, "Access-Control-Allow-Origin"), Some("http://localhost:5173"));
|
||||
assert_eq!(header(¶ms, "Access-Control-Allow-Methods"), Some("PUT"));
|
||||
assert_eq!(header(¶ms, "Access-Control-Allow-Headers"), Some("content-type,x-token"));
|
||||
assert_eq!(params.get("body"), None);
|
||||
|
||||
// Same origin: nothing but the rule's own header.
|
||||
let (_, params) = answer(&from_page("GET", json!({})), &rules);
|
||||
assert_eq!(params["responseHeaders"], json!([{ "name": "Content-Type", "value": "application/json" }]));
|
||||
}
|
||||
|
||||
/// A stand-in for js-debug's CDP proxy: what it receives, and a way to send it events.
|
||||
pub(crate) async fn proxy() -> (String, mpsc::UnboundedReceiver<Value>, mpsc::UnboundedSender<Value>) {
|
||||
proxy_refusing(|_| false).await
|
||||
}
|
||||
|
||||
/// A proxy that answers the commands `refused` picks with an error, like the real one does
|
||||
/// for what it or the browser does not accept.
|
||||
pub(crate) async fn proxy_refusing(
|
||||
refused: fn(&Value) -> bool,
|
||||
) -> (String, mpsc::UnboundedReceiver<Value>, mpsc::UnboundedSender<Value>) {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let url = format!("ws://127.0.0.1:{}/session", listener.local_addr().unwrap().port());
|
||||
let (received_tx, received) = mpsc::unbounded_channel();
|
||||
let (events, mut events_rx) = mpsc::unbounded_channel::<Value>();
|
||||
tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.unwrap();
|
||||
let (mut write, mut read) = tokio_tungstenite::accept_async(stream).await.unwrap().split();
|
||||
loop {
|
||||
tokio::select! {
|
||||
message = read.next() => {
|
||||
let Some(Ok(message)) = message else { break };
|
||||
let Ok(text) = message.to_text() else { continue };
|
||||
let Ok(request) = serde_json::from_str::<Value>(text) else { continue };
|
||||
let reply = if refused(&request) {
|
||||
json!({ "id": request["id"], "error": { "code": -32602, "message": "refused" } })
|
||||
} else {
|
||||
json!({ "id": request["id"], "result": {} })
|
||||
};
|
||||
let _ = write.send(Message::text(reply.to_string())).await;
|
||||
let _ = received_tx.send(request);
|
||||
}
|
||||
Some(event) = events_rx.recv() => {
|
||||
let _ = write.send(Message::text(event.to_string())).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
(url, received, events)
|
||||
}
|
||||
|
||||
pub(crate) async fn next(received: &mut mpsc::UnboundedReceiver<Value>) -> (String, Value) {
|
||||
let request = tokio::time::timeout(std::time::Duration::from_secs(5), received.recv())
|
||||
.await
|
||||
.expect("the proxy got a request in time")
|
||||
.expect("the connection is open");
|
||||
(request["method"].as_str().unwrap().to_owned(), request["params"].clone())
|
||||
}
|
||||
|
||||
/// The browser's event for a request it holds.
|
||||
fn held(request_id: &str, url: &str) -> Value {
|
||||
json!({ "method": "Fetch.requestPaused", "params": { "requestId": request_id, "request": { "url": url } } })
|
||||
}
|
||||
|
||||
type Serving = (tokio::task::JoinHandle<()>, oneshot::Receiver<Result<(), String>>);
|
||||
|
||||
fn serving(url: String, rules: watch::Receiver<Vec<JsResponseRule>>) -> Serving {
|
||||
let (ready, is_ready) = oneshot::channel();
|
||||
(tokio::spawn(serve(url, rules, ready)), is_ready)
|
||||
}
|
||||
|
||||
async fn ended(task: tokio::task::JoinHandle<()>) {
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), task).await.unwrap().unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn held_requests_are_answered_and_interception_ends_before_the_connection() {
|
||||
let (url, mut received, events) = proxy().await;
|
||||
let (rules, watched) = tokio::sync::watch::channel(vec![rule("api/items", 200, "[]")]);
|
||||
let (serving, ready) = serving(url, watched);
|
||||
assert_eq!(ready.await.unwrap(), Ok(()));
|
||||
|
||||
assert_eq!(
|
||||
next(&mut received).await,
|
||||
("JsDebug.subscribe".into(), json!({ "events": ["Fetch.requestPaused"] }))
|
||||
);
|
||||
assert_eq!(
|
||||
next(&mut received).await,
|
||||
(
|
||||
"Fetch.enable".into(),
|
||||
json!({ "patterns": [{ "urlPattern": "*api/items*", "requestStage": "Request" }] })
|
||||
)
|
||||
);
|
||||
|
||||
events.send(held("1", "http://h/api/items")).unwrap();
|
||||
let (command, params) = next(&mut received).await;
|
||||
assert_eq!(
|
||||
(command.as_str(), ¶ms["requestId"], ¶ms["responseCode"]),
|
||||
("Fetch.fulfillRequest", &json!("1"), &json!(200))
|
||||
);
|
||||
events.send(held("2", "http://h/other")).unwrap();
|
||||
assert_eq!(next(&mut received).await, ("Fetch.continueRequest".into(), json!({ "requestId": "2" })));
|
||||
|
||||
// New rules replace the browser's patterns.
|
||||
rules.send(vec![rule("api/users", 404, "{}")]).unwrap();
|
||||
assert_eq!(
|
||||
next(&mut received).await,
|
||||
(
|
||||
"Fetch.enable".into(),
|
||||
json!({ "patterns": [{ "urlPattern": "*api/users*", "requestStage": "Request" }] })
|
||||
)
|
||||
);
|
||||
|
||||
// No rules left: the browser stops holding requests. The connection stays for the
|
||||
// rules that come next, and goes with the session.
|
||||
rules.send(Vec::new()).unwrap();
|
||||
assert_eq!(next(&mut received).await.0, "Fetch.disable");
|
||||
rules.send(vec![rule("api/items", 200, "[]")]).unwrap();
|
||||
assert_eq!(next(&mut received).await.0, "Fetch.enable");
|
||||
rules.send(Vec::new()).unwrap();
|
||||
assert_eq!(next(&mut received).await.0, "Fetch.disable");
|
||||
drop(rules);
|
||||
ended(serving).await;
|
||||
// Already off: not switched off a second time.
|
||||
assert!(received.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_session_that_goes_away_switches_interception_off() {
|
||||
let (url, mut received, _events) = proxy().await;
|
||||
let (rules, watched) = tokio::sync::watch::channel(vec![rule("api", 200, "[]")]);
|
||||
let (serving, _ready) = serving(url, watched);
|
||||
assert_eq!(next(&mut received).await.0, "JsDebug.subscribe");
|
||||
assert_eq!(next(&mut received).await.0, "Fetch.enable");
|
||||
drop(rules);
|
||||
assert_eq!(next(&mut received).await.0, "Fetch.disable");
|
||||
ended(serving).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_response_the_browser_refuses_lets_the_request_through() {
|
||||
let (url, mut received, events) = proxy_refusing(|request| request["method"] == "Fetch.fulfillRequest").await;
|
||||
let (_rules, watched) = tokio::sync::watch::channel(vec![rule("api", 200, "[]")]);
|
||||
let (_serving, _ready) = serving(url, watched);
|
||||
assert_eq!(next(&mut received).await.0, "JsDebug.subscribe");
|
||||
assert_eq!(next(&mut received).await.0, "Fetch.enable");
|
||||
events.send(held("9", "http://h/api")).unwrap();
|
||||
assert_eq!(next(&mut received).await.0, "Fetch.fulfillRequest");
|
||||
// Unanswered, the request would hang the page.
|
||||
assert_eq!(next(&mut received).await, ("Fetch.continueRequest".into(), json!({ "requestId": "9" })));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_proxy_that_does_not_report_held_requests_is_not_asked_to_hold_any() {
|
||||
let (url, mut received, _events) = proxy_refusing(|request| request["method"] == "JsDebug.subscribe").await;
|
||||
let (_rules, watched) = tokio::sync::watch::channel(vec![rule("api", 200, "[]")]);
|
||||
let (serving, ready) = serving(url, watched);
|
||||
assert_eq!(next(&mut received).await.0, "JsDebug.subscribe");
|
||||
assert_eq!(ready.await.unwrap(), Err("refused".to_owned()));
|
||||
ended(serving).await;
|
||||
assert!(received.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn no_connection_to_the_proxy_is_reported() {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let url = format!("ws://127.0.0.1:{}/session", listener.local_addr().unwrap().port());
|
||||
drop(listener);
|
||||
let (_rules, watched) = tokio::sync::watch::channel(vec![rule("api", 200, "[]")]);
|
||||
let (serving, ready) = serving(url, watched);
|
||||
assert!(ready.await.unwrap().is_err());
|
||||
ended(serving).await;
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,8 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use specta::Type;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
pub(crate) const ADAPTER: &str = "js-debug";
|
||||
|
||||
@@ -25,7 +27,7 @@ pub(crate) fn is_page(session: &Session) -> bool {
|
||||
/// Waits until the adapter answered the `launch` of `session`. js-debug sets a page's session
|
||||
/// up between `configurationDone` and that answer; a request of its own that arrives then is
|
||||
/// answered and has no effect.
|
||||
async fn launched(session: &Session) -> bool {
|
||||
pub(crate) async fn launched(session: &Session) -> bool {
|
||||
let mut launched = session.launched.subscribe();
|
||||
let answered = tokio::time::timeout(REQUEST_TIMEOUT, launched.wait_for(|answered| *answered)).await;
|
||||
matches!(answered, Ok(Ok(_)))
|
||||
@@ -59,6 +61,44 @@ impl DebugManager {
|
||||
self.js_page_request(s, "setCustomBreakpoints", json!({ "ids": ids, "xhr": xhr })).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Starts recording the page's CPU profile; [`Self::js_stop_profile`] ends it.
|
||||
pub async fn js_start_profile(&self, s: DebugSessionId) -> IdeResult<()> {
|
||||
self.js_page_request(s, "startProfile", json!({ "type": "cpu" })).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Ends the recording and returns a new directory holding it as `page.cpuprofile`
|
||||
/// (what `ide-profiler` reads a directory of).
|
||||
pub async fn js_stop_profile(&self, s: DebugSessionId) -> IdeResult<PathBuf> {
|
||||
let session = self.session(s)?;
|
||||
// js-debug names the file it records into when the recording starts.
|
||||
let Some(recorded) = session.js_profile.lock().take() else {
|
||||
return Err(IdeError::invalid("no CPU profile is being recorded"));
|
||||
};
|
||||
self.js_page_request(s, "stopProfile", json!({})).await?;
|
||||
static HANDED_OVER: AtomicU32 = AtomicU32::new(0);
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"openidea-js-profile-{}-{}-{}",
|
||||
std::process::id(),
|
||||
s.0,
|
||||
HANDED_OVER.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
let copied = tokio::task::spawn_blocking({
|
||||
let dir = dir.clone();
|
||||
move || {
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
std::fs::copy(&recorded, dir.join("page.cpuprofile"))?;
|
||||
// The adapter's own copy is in the system's temporary directory: no use keeping two.
|
||||
let _ = std::fs::remove_file(&recorded);
|
||||
Ok::<_, std::io::Error>(())
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
copied.map_err(|e| IdeError::unavailable(format!("the recorded CPU profile cannot be read: {e}")))?;
|
||||
Ok(dir)
|
||||
}
|
||||
}
|
||||
|
||||
/// The browser events js-debug is asked to pass on (`enableNetworking`'s `mirrorEvents`).
|
||||
|
||||
@@ -13,6 +13,7 @@ pub mod attach;
|
||||
mod cargo;
|
||||
pub mod connection;
|
||||
pub mod jdwp;
|
||||
mod js_cdp;
|
||||
mod js_debug;
|
||||
mod jvm_memory;
|
||||
pub mod kotlin;
|
||||
@@ -24,6 +25,7 @@ use connection::{Connection, DapError};
|
||||
use futures::future::join_all;
|
||||
use ide_core::{DebugSessionId, IdeError, IdeResult, SharedSink};
|
||||
use ide_language::LanguageRegistry;
|
||||
pub use js_cdp::JsResponseRule;
|
||||
pub use js_debug::JsResponseBody;
|
||||
pub use jvm_memory::*;
|
||||
use parking_lot::Mutex;
|
||||
|
||||
@@ -94,6 +94,11 @@ pub(crate) struct Session {
|
||||
/// The `type` of the launch or attach request (js-debug's `pwa-node`, `pwa-chrome`); a child
|
||||
/// session's comes from the configuration the adapter started it with.
|
||||
pub launch_type: std::sync::OnceLock<String>,
|
||||
/// The response rules of a page once a connection applies them (`js_cdp::serve`).
|
||||
/// Dropped with the session, which switches the browser's interception off.
|
||||
pub js_overrides: tokio::sync::Mutex<Option<tokio::sync::watch::Sender<Vec<crate::JsResponseRule>>>>,
|
||||
/// The file js-debug records the page's CPU profile into, while one is being recorded.
|
||||
pub js_profile: parking_lot::Mutex<Option<std::path::PathBuf>>,
|
||||
pub jvm_inspector: parking_lot::Mutex<Option<Arc<dyn ide_jvm_inspect::JvmInspector>>>,
|
||||
/// One inspector request per session at a time.
|
||||
pub inspect_lock: tokio::sync::Mutex<()>,
|
||||
@@ -255,6 +260,8 @@ impl Session {
|
||||
progress: Mutex::new(HashMap::new()),
|
||||
jdwp_link: parking_lot::Mutex::new(None),
|
||||
launch_type: std::sync::OnceLock::new(),
|
||||
js_overrides: tokio::sync::Mutex::new(None),
|
||||
js_profile: parking_lot::Mutex::new(None),
|
||||
jvm_inspector: parking_lot::Mutex::new(None),
|
||||
inspect_lock: tokio::sync::Mutex::default(),
|
||||
ended: ide_core::CancellationToken::new(),
|
||||
@@ -842,6 +849,10 @@ async fn on_event(mgr: &Arc<DebugManager>, s: &Arc<Session>, event: &str, mut bo
|
||||
s.emit(DebugEvent::JsNetwork { event });
|
||||
}
|
||||
}
|
||||
// js-debug, after `startProfile`: where it records the profile.
|
||||
"profileStarted" => {
|
||||
*s.js_profile.lock() = str_("file").map(std::path::PathBuf::from);
|
||||
}
|
||||
_ => tracing::trace!("dap: ignored event {event}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1104,6 +1104,142 @@ async fn a_page_whose_requests_the_adapter_does_not_report_says_so() {
|
||||
h.wait_event(|e| matches!(e, DebugEvent::JsNetworkUnavailable)).await;
|
||||
}
|
||||
|
||||
/// Where the fake adapter says its CDP proxy listens (`requestCDPProxy`).
|
||||
static CDP_PROXY_PORT: std::sync::atomic::AtomicU16 = std::sync::atomic::AtomicU16::new(0);
|
||||
|
||||
fn proxy_adapter(msg: &Value) -> Option<CustomReply> {
|
||||
let port = CDP_PROXY_PORT.load(std::sync::atomic::Ordering::Relaxed);
|
||||
(msg["command"] == "requestCDPProxy")
|
||||
.then(|| (json!({ "host": "127.0.0.1", "port": port, "path": "/session" }), Vec::new()))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn response_overrides_reach_the_page_through_the_adapters_proxy() {
|
||||
use crate::js_cdp::tests::{next, proxy, rule};
|
||||
let (url, mut received, _events) = proxy().await;
|
||||
let port: u16 = url.trim_start_matches("ws://127.0.0.1:").trim_end_matches("/session").parse().unwrap();
|
||||
CDP_PROXY_PORT.store(port, std::sync::atomic::Ordering::Relaxed);
|
||||
|
||||
let h = harness(Script {
|
||||
start_debugging: true,
|
||||
custom: Some(proxy_adapter),
|
||||
child_launch_delay: Duration::from_millis(150),
|
||||
..Script::default()
|
||||
});
|
||||
let browser = h.start(js_config("pwa-chrome")).await.unwrap();
|
||||
let page = child_of(&h, browser).await;
|
||||
|
||||
// Rules that are there as the page starts wait until its session takes requests.
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
h.manager.js_set_response_overrides(page, vec![rule("api/items", 200, "[]")]).await.unwrap();
|
||||
let dropped = h.log.lock().iter().filter(|m| m["type"] == "dropped").count();
|
||||
assert_eq!(dropped, 0, "the proxy was asked for before the page took requests");
|
||||
assert_eq!(next(&mut received).await.0, "JsDebug.subscribe");
|
||||
assert_eq!(
|
||||
next(&mut received).await,
|
||||
("Fetch.enable".into(), json!({ "patterns": [{ "urlPattern": "*api/items*", "requestStage": "Request" }] }))
|
||||
);
|
||||
// Changed rules go over the connection that is open: the proxy is asked for once.
|
||||
h.manager.js_set_response_overrides(page, vec![rule("api/users", 200, "[]")]).await.unwrap();
|
||||
assert_eq!(next(&mut received).await.1["patterns"][0]["urlPattern"], "*api/users*");
|
||||
assert_eq!(requests(&h, "requestCDPProxy").len(), 1);
|
||||
|
||||
h.manager.js_set_response_overrides(page, Vec::new()).await.unwrap();
|
||||
assert_eq!(next(&mut received).await.0, "Fetch.disable");
|
||||
// A rule switched back on.
|
||||
h.manager.js_set_response_overrides(page, vec![rule("api/items", 200, "[]")]).await.unwrap();
|
||||
assert_eq!(next(&mut received).await.0, "Fetch.enable");
|
||||
assert_eq!(requests(&h, "requestCDPProxy").len(), 1);
|
||||
|
||||
let not_a_page = h.manager.js_set_response_overrides(browser, vec![rule("api", 200, "")]).await.unwrap_err();
|
||||
assert_eq!(not_a_page.kind, ide_core::error::ErrorKind::Unavailable);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn response_overrides_are_unavailable_without_the_adapters_proxy() {
|
||||
use crate::js_cdp::tests::rule;
|
||||
let h = harness(Script { start_debugging: true, ..Script::default() });
|
||||
let browser = h.start(js_config("pwa-chrome")).await.unwrap();
|
||||
let page = child_of(&h, browser).await;
|
||||
let refused = h.manager.js_set_response_overrides(page, vec![rule("api", 200, "")]).await.unwrap_err();
|
||||
assert_eq!(refused.kind, ide_core::error::ErrorKind::Unavailable);
|
||||
// Nothing to switch off is not an error.
|
||||
h.manager.js_set_response_overrides(page, Vec::new()).await.unwrap();
|
||||
}
|
||||
|
||||
/// A port nothing listens on, which the fake adapter gives as its CDP proxy.
|
||||
static DEAD_PROXY_PORT: std::sync::atomic::AtomicU16 = std::sync::atomic::AtomicU16::new(0);
|
||||
|
||||
fn dead_proxy_adapter(msg: &Value) -> Option<CustomReply> {
|
||||
let port = DEAD_PROXY_PORT.load(std::sync::atomic::Ordering::Relaxed);
|
||||
(msg["command"] == "requestCDPProxy")
|
||||
.then(|| (json!({ "host": "127.0.0.1", "port": port, "path": "/session" }), Vec::new()))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn response_overrides_that_cannot_reach_the_browser_are_an_error() {
|
||||
use crate::js_cdp::tests::rule;
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
DEAD_PROXY_PORT.store(listener.local_addr().unwrap().port(), std::sync::atomic::Ordering::Relaxed);
|
||||
drop(listener);
|
||||
|
||||
let h = harness(Script { start_debugging: true, custom: Some(dead_proxy_adapter), ..Script::default() });
|
||||
let browser = h.start(js_config("pwa-chrome")).await.unwrap();
|
||||
let page = child_of(&h, browser).await;
|
||||
let unreachable = h.manager.js_set_response_overrides(page, vec![rule("api", 200, "")]).await.unwrap_err();
|
||||
assert_eq!(unreachable.kind, ide_core::error::ErrorKind::Unavailable);
|
||||
// The next rules try again instead of going to a connection that never was.
|
||||
let again = h.manager.js_set_response_overrides(page, vec![rule("api", 200, "")]).await.unwrap_err();
|
||||
assert_eq!(again.kind, ide_core::error::ErrorKind::Unavailable);
|
||||
assert_eq!(requests(&h, "requestCDPProxy").len(), 2);
|
||||
}
|
||||
|
||||
/// The file the fake adapter says it records a CPU profile into (`profileStarted`).
|
||||
static PROFILE_FILE: parking_lot::Mutex<String> = parking_lot::Mutex::new(String::new());
|
||||
|
||||
fn profiling_adapter(msg: &Value) -> Option<CustomReply> {
|
||||
match msg["command"].as_str()? {
|
||||
"startProfile" => {
|
||||
Some((json!({}), vec![("profileStarted", json!({ "file": *PROFILE_FILE.lock(), "type": "cpu" }))]))
|
||||
}
|
||||
"stopProfile" => Some((json!({}), vec![("profilerStateUpdate", json!({ "label": "", "running": false }))])),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_pages_cpu_profile_is_handed_over_as_a_cpuprofile_file() {
|
||||
let recorded = std::env::temp_dir().join(format!("openidea-dap-profile-{}", std::process::id()));
|
||||
std::fs::write(&recorded, r#"{"nodes":[],"samples":[]}"#).unwrap();
|
||||
*PROFILE_FILE.lock() = recorded.to_string_lossy().into_owned();
|
||||
|
||||
let h = harness(Script { start_debugging: true, custom: Some(profiling_adapter), ..Script::default() });
|
||||
let browser = h.start(js_config("pwa-chrome")).await.unwrap();
|
||||
let page = child_of(&h, browser).await;
|
||||
|
||||
// Nothing is being recorded yet.
|
||||
let idle = h.manager.js_stop_profile(page).await.unwrap_err();
|
||||
assert_eq!(idle.kind, ide_core::error::ErrorKind::InvalidInput);
|
||||
|
||||
h.manager.js_start_profile(page).await.unwrap();
|
||||
assert_eq!(requests(&h, "startProfile"), [json!({ "type": "cpu" })]);
|
||||
let dir = h.manager.js_stop_profile(page).await.unwrap();
|
||||
assert_eq!(requests(&h, "stopProfile").len(), 1);
|
||||
let files: Vec<_> = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name()).collect();
|
||||
assert_eq!(files, ["page.cpuprofile"]);
|
||||
assert_eq!(std::fs::read_to_string(dir.join("page.cpuprofile")).unwrap(), r#"{"nodes":[],"samples":[]}"#);
|
||||
// One recording is handed over once.
|
||||
assert_eq!(h.manager.js_stop_profile(page).await.unwrap_err().kind, ide_core::error::ErrorKind::InvalidInput);
|
||||
|
||||
// The adapter's file isn't there when it is asked for (the hand-over above took the first one).
|
||||
assert!(!recorded.exists());
|
||||
h.manager.js_start_profile(page).await.unwrap();
|
||||
assert_eq!(h.manager.js_stop_profile(page).await.unwrap_err().kind, ide_core::error::ErrorKind::Unavailable);
|
||||
|
||||
assert_eq!(h.manager.js_start_profile(browser).await.unwrap_err().kind, ide_core::error::ErrorKind::Unavailable);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[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]");
|
||||
|
||||
@@ -542,6 +542,24 @@ pub fn compose_config(ep: &DockerEndpoint, dir: &Path, files: &[String]) -> IdeR
|
||||
if out.success { Ok(out.stdout) } else { Err(failure(&args, &out.stderr, &out.stdout)) }
|
||||
}
|
||||
|
||||
/// Runs the docker CLI with `args` in `dir` and returns its standard output; a non-zero
|
||||
/// exit is an error with docker's message.
|
||||
pub fn cli(
|
||||
ep: &DockerEndpoint,
|
||||
dir: Option<&Path>,
|
||||
args: &[String],
|
||||
timeout: Duration,
|
||||
cancel: Option<&CancellationToken>,
|
||||
) -> IdeResult<String> {
|
||||
let args: Vec<&str> = args.iter().map(String::as_str).collect();
|
||||
let mut cmd = command(ep, &args)?;
|
||||
if let Some(dir) = dir {
|
||||
cmd.current_dir(dir);
|
||||
}
|
||||
let out = process::run(cmd, timeout, cancel)?;
|
||||
if out.success { Ok(out.stdout) } else { Err(failure(&args, &out.stderr, &out.stdout)) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod local_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -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_overrides(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
session: DebugSessionId,
|
||||
rules: Vec<ide_dap::JsResponseRule>,
|
||||
) -> IdeResult<()> {
|
||||
host.debugger.js_set_response_overrides(session, rules).await
|
||||
}
|
||||
|
||||
pub async fn debug_js_response_body(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
@@ -391,6 +400,18 @@ pub async fn debug_js_response_body(
|
||||
host.debugger.js_response_body(session, request_id).await
|
||||
}
|
||||
|
||||
pub async fn debug_js_start_profile(host: &Host, _window: &str, session: DebugSessionId) -> IdeResult<()> {
|
||||
host.debugger.js_start_profile(session).await
|
||||
}
|
||||
|
||||
pub async fn debug_js_stop_profile(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
session: DebugSessionId,
|
||||
) -> IdeResult<ide_core::IpcPath> {
|
||||
host.debugger.js_stop_profile(session).await.map(Into::into)
|
||||
}
|
||||
|
||||
pub fn debug_capabilities(host: &Host, _window: &str, session: DebugSessionId) -> IdeResult<DebugCapabilities> {
|
||||
host.debugger.capabilities(session)
|
||||
}
|
||||
@@ -607,12 +628,23 @@ 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_overrides", |c| async move {
|
||||
debug_js_response_overrides(&c.host, &c.window, c.arg("session")?, c.arg("rules")?).await
|
||||
});
|
||||
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
|
||||
});
|
||||
d.add(
|
||||
"debug_js_start_profile",
|
||||
|c| async move { debug_js_start_profile(&c.host, &c.window, c.arg("session")?).await },
|
||||
);
|
||||
d.add(
|
||||
"debug_js_stop_profile",
|
||||
|c| async move { debug_js_stop_profile(&c.host, &c.window, c.arg("session")?).await },
|
||||
);
|
||||
d.add("debug_set_function_breakpoints", |c| async move {
|
||||
debug_set_function_breakpoints(&c.host, &c.window, c.arg("breakpoints")?).await
|
||||
});
|
||||
|
||||
@@ -239,6 +239,12 @@ pub fn set_settings(
|
||||
let toolchain = ["languages", "tools", "languageServers", "projectStructure"]
|
||||
.iter()
|
||||
.any(|k| key == *k || key.starts_with(&format!("{k}.")));
|
||||
// A run target chosen as the Python interpreter (or changed) needs a snapshot of its libraries.
|
||||
if ["languages", "languages.python", "languages.python.interpreter", "runTargets"].contains(&key) {
|
||||
for project in affected() {
|
||||
crate::ops::sdk::container_python::ensure(host, &project);
|
||||
}
|
||||
}
|
||||
// Code style only reaches servers as settings (e.g. jdtls' formatter profile); builds don't depend on it.
|
||||
if code_style || toolchain {
|
||||
for project in affected() {
|
||||
|
||||
@@ -508,7 +508,7 @@ pub fn list(host: &Host) -> Vec<RunInfo> {
|
||||
}
|
||||
|
||||
mod commands;
|
||||
mod container;
|
||||
pub(crate) mod container;
|
||||
pub use commands::*;
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -33,23 +33,11 @@ pub(super) async fn prepare(host: &Host, root: &Path, target: RunTarget, config:
|
||||
let root = root.to_path_buf();
|
||||
let data_dir = host.data_dir.clone();
|
||||
host.blocking(move || {
|
||||
if !ide_docker::endpoint_is_local(&endpoint)? {
|
||||
return Err(IdeError::invalid(format!(
|
||||
"Run target '{}' uses a Docker daemon on another machine: the project can only be mounted from a local one",
|
||||
target.name
|
||||
)));
|
||||
}
|
||||
require_local(&endpoint, &target)?;
|
||||
let docker = ide_docker::docker_executable().ok_or_else(|| {
|
||||
IdeError::unavailable("docker executable not found; install Docker and make sure it is on PATH")
|
||||
})?;
|
||||
let container_root = match target.container_root.trim() {
|
||||
"" if target.kind == RunTargetKind::Compose => {
|
||||
let json = ide_docker::compose_config(&endpoint, &root, &target.compose_files)?;
|
||||
compose_container_root(&json, target.service.trim(), &root)?
|
||||
}
|
||||
"" => default_container_root(&root),
|
||||
folder => folder.to_owned(),
|
||||
};
|
||||
let container_root = container_root(&endpoint, &root, &target)?;
|
||||
let file_env = env_files(&root, &config)?;
|
||||
let name = container_name(&config.id, &unique());
|
||||
let debugging = if debug { Some(Debugging::prepare(&data_dir)?) } else { None };
|
||||
@@ -75,6 +63,29 @@ pub(super) async fn prepare(host: &Host, root: &Path, target: RunTarget, config:
|
||||
.await
|
||||
}
|
||||
|
||||
/// The project can only be bind-mounted from a daemon on this machine.
|
||||
pub(crate) fn require_local(endpoint: &DockerEndpoint, target: &RunTarget) -> IdeResult<()> {
|
||||
if !ide_docker::endpoint_is_local(endpoint)? {
|
||||
return Err(IdeError::invalid(format!(
|
||||
"Run target '{}' uses a Docker daemon on another machine: the project can only be mounted from a local one",
|
||||
target.name
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Where the project is in the target's containers.
|
||||
pub(crate) fn container_root(endpoint: &DockerEndpoint, root: &Path, target: &RunTarget) -> IdeResult<String> {
|
||||
Ok(match target.container_root.trim() {
|
||||
"" if target.kind == RunTargetKind::Compose => {
|
||||
let json = ide_docker::compose_config(endpoint, root, &target.compose_files)?;
|
||||
compose_container_root(&json, target.service.trim(), root)?
|
||||
}
|
||||
"" => default_container_root(root),
|
||||
folder => folder.to_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
/// What a debug run mounts and publishes.
|
||||
struct Debugging {
|
||||
port: u16,
|
||||
@@ -104,7 +115,7 @@ fn free_port() -> IdeResult<u16> {
|
||||
}
|
||||
|
||||
/// The target's Docker connection (Services tool window), by id; none = the CLI's daemon.
|
||||
fn endpoint(data_dir: &Path, target: &RunTarget) -> IdeResult<DockerEndpoint> {
|
||||
pub(crate) fn endpoint(data_dir: &Path, target: &RunTarget) -> IdeResult<DockerEndpoint> {
|
||||
let id = target.docker_connection.trim();
|
||||
if id.is_empty() {
|
||||
return Ok(DockerEndpoint::Default);
|
||||
|
||||
@@ -10,6 +10,9 @@ use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
pub(crate) mod container_python;
|
||||
pub use container_python::{ContainerPython, sdk_container_python, sdk_container_python_refresh};
|
||||
|
||||
fn join_err(e: impl std::fmt::Display) -> IdeError {
|
||||
IdeError::internal(e.to_string())
|
||||
}
|
||||
@@ -232,12 +235,17 @@ pub async fn sdk_project_libraries(
|
||||
let project = host.project(window)?;
|
||||
let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective());
|
||||
let cancel = host.begin_project_request(window, request);
|
||||
let container = container_python::library_group(host, &root, &settings);
|
||||
let libraries = {
|
||||
let (root, settings, cancel, on_groups) = (root.clone(), settings.clone(), cancel.clone(), on_groups.clone());
|
||||
tokio::task::spawn_blocking(move || {
|
||||
ide_sdk::libraries::project_libraries_stream(&root, &settings, &[], &cancel, &|groups| {
|
||||
if let Some(group) = &container {
|
||||
let _ = on_groups.send(vec![group.clone()]);
|
||||
}
|
||||
let groups = ide_sdk::libraries::project_libraries_stream(&root, &settings, &[], &cancel, &|groups| {
|
||||
let _ = on_groups.send(groups);
|
||||
})
|
||||
});
|
||||
container.into_iter().chain(groups).collect::<Vec<_>>()
|
||||
})
|
||||
};
|
||||
let dependencies = {
|
||||
@@ -377,6 +385,8 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
);
|
||||
d.add("sdk_python_effective", |c| async move { sdk_python_effective(&c.host, &c.window, c.arg("near")?) });
|
||||
d.add("sdk_php_effective", |c| async move { sdk_php_effective(&c.host, &c.window) });
|
||||
d.add("sdk_container_python", |c| async move { sdk_container_python(&c.host, &c.window) });
|
||||
d.add("sdk_container_python_refresh", |c| async move { sdk_container_python_refresh(&c.host, &c.window).await });
|
||||
d.add("sdk_php_xdebug", |c| async move { sdk_php_xdebug(&c.host, &c.window, c.arg("php")?, c.arg("cwd")?).await });
|
||||
d.add(
|
||||
"sdk_php_language_level",
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
//! A Docker or Compose run target's Python as the project interpreter: snapshots of the
|
||||
//! container's library folders for the language server (see `ide_sdk::container_python`).
|
||||
//!
|
||||
//! A snapshot runs the target's Python once in a new container (the image's entrypoint
|
||||
//! and the service's dependencies skipped), then copies its `sys.path` folders out of
|
||||
//! the stopped container with `docker cp`. Nothing is installed in the container.
|
||||
|
||||
use crate::Host;
|
||||
use crate::project::Project;
|
||||
use ide_core::event::NotifyLevel;
|
||||
use ide_core::remote_settings::{RunTarget, RunTargetKind};
|
||||
use ide_core::settings::Settings;
|
||||
use ide_core::{IdeError, IdeEvent, IdeResult, IpcPath, TaskId};
|
||||
use ide_sdk::container_python::{self as snapshots, Snapshot};
|
||||
use ide_sdk::python::PythonPackage;
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, LazyLock, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Building or pulling the image may come first.
|
||||
const PROBE_TIMEOUT: Duration = Duration::from_secs(15 * 60);
|
||||
const COPY_TIMEOUT: Duration = Duration::from_secs(10 * 60);
|
||||
const QUICK: Duration = Duration::from_secs(30);
|
||||
|
||||
/// The project interpreter when it is a run target's.
|
||||
#[derive(Clone, Debug, serde::Serialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ContainerPython {
|
||||
/// The run target's id.
|
||||
pub target: String,
|
||||
/// Its name; empty when no run target has the id.
|
||||
pub name: String,
|
||||
/// The Python version of the snapshot in use; `None`: no snapshot yet.
|
||||
pub version: Option<String>,
|
||||
/// The image the snapshot was copied from.
|
||||
pub image: Option<String>,
|
||||
/// The copied library folders on this machine.
|
||||
pub folders: Vec<IpcPath>,
|
||||
/// Packages of the snapshot (read-only).
|
||||
pub packages: Vec<PythonPackage>,
|
||||
/// A snapshot is being taken.
|
||||
pub updating: bool,
|
||||
}
|
||||
|
||||
/// Where snapshots are kept, and the Docker connections the targets refer to.
|
||||
#[derive(Clone)]
|
||||
struct Dirs {
|
||||
data: PathBuf,
|
||||
/// `<local data>/container-python/`: `<project>/<target>/<image>/` and `neutral/`.
|
||||
cache: PathBuf,
|
||||
}
|
||||
|
||||
impl Dirs {
|
||||
fn of(host: &Host) -> Self {
|
||||
Self::new(&host.data_dir, &host.local_data_dir)
|
||||
}
|
||||
|
||||
fn new(data_dir: &Path, local_data_dir: &Path) -> Self {
|
||||
Self { data: data_dir.to_path_buf(), cache: local_data_dir.join("container-python") }
|
||||
}
|
||||
|
||||
fn target(&self, root: &Path, target: &str) -> PathBuf {
|
||||
snapshots::target_dir(&self.cache.join(crate::project::project_key(root)), target)
|
||||
}
|
||||
}
|
||||
|
||||
/// Target folders with a snapshot being taken.
|
||||
static UPDATING: LazyLock<Mutex<HashSet<PathBuf>>> = LazyLock::new(Default::default);
|
||||
|
||||
fn updating(dir: &Path) -> bool {
|
||||
UPDATING.lock().unwrap_or_else(|e| e.into_inner()).contains(dir)
|
||||
}
|
||||
|
||||
/// The snapshot in use for the project's interpreter, if it is a run target's.
|
||||
fn current(dirs: &Dirs, root: &Path, settings: &Settings) -> Option<(PathBuf, Snapshot)> {
|
||||
let target = settings.languages.python.run_target()?;
|
||||
snapshots::current(&dirs.target(root, target))
|
||||
}
|
||||
|
||||
/// Language server configuration: basedpyright / pyright see the container's libraries.
|
||||
pub(crate) fn configure_server(
|
||||
cfg: &mut ide_language::LanguageServerConfig,
|
||||
local_data_dir: &Path,
|
||||
root: &Path,
|
||||
settings: &Settings,
|
||||
) {
|
||||
if settings.languages.python.run_target().is_none() {
|
||||
return;
|
||||
}
|
||||
let current = current(&Dirs::new(local_data_dir, local_data_dir), root, settings);
|
||||
snapshots::configure_server(cfg, current.as_ref().map(|(d, s)| (d.as_path(), s)));
|
||||
}
|
||||
|
||||
/// External Libraries: the snapshot's folders, named after the run target.
|
||||
pub(crate) fn library_group(host: &Host, root: &Path, settings: &Settings) -> Option<ide_sdk::libraries::LibraryGroup> {
|
||||
let target = settings.languages.python.run_target()?;
|
||||
let (dir, snapshot) = current(&Dirs::of(host), root, settings)?;
|
||||
let name = settings.run_targets.iter().find(|t| t.id == target).map_or(target, |t| t.name.as_str());
|
||||
snapshots::library_group(name, &dir, &snapshot)
|
||||
}
|
||||
|
||||
/// The project interpreter when it is a run target's, with its snapshot.
|
||||
pub fn sdk_container_python(host: &Host, window: &str) -> IdeResult<Option<ContainerPython>> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
Ok(describe(host, &project.root, &project.settings.effective()))
|
||||
}
|
||||
|
||||
fn describe(host: &Host, root: &Path, settings: &Settings) -> Option<ContainerPython> {
|
||||
let target = settings.languages.python.run_target()?;
|
||||
let name = settings.run_targets.iter().find(|t| t.id == target).map(|t| t.name.clone()).unwrap_or_default();
|
||||
let dir = Dirs::of(host).target(root, target);
|
||||
let mut out = ContainerPython {
|
||||
target: target.to_owned(),
|
||||
name,
|
||||
version: None,
|
||||
image: None,
|
||||
folders: Vec::new(),
|
||||
packages: Vec::new(),
|
||||
updating: updating(&dir),
|
||||
};
|
||||
if let Some((dir, snapshot)) = snapshots::current(&dir) {
|
||||
out.folders = snapshots::local_folders(&dir, &snapshot).into_iter().map(Into::into).collect();
|
||||
out.packages = snapshots::packages(&dir, &snapshot);
|
||||
out.version = Some(snapshot.probe.version);
|
||||
out.image = Some(snapshot.image);
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Takes a new snapshot of the project interpreter's container (also of an image that
|
||||
/// has one already), then the language servers pick it up.
|
||||
pub async fn sdk_container_python_refresh(host: &Host, window: &str) -> IdeResult<Option<ContainerPython>> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
update(Dirs::of(host), &project, true).await?;
|
||||
Ok(describe(host, &project.root, &project.settings.effective()))
|
||||
}
|
||||
|
||||
/// Takes a snapshot when the project interpreter is a run target's and the current image
|
||||
/// has none yet (a new choice, a rebuilt image), in the background. Errors are notified.
|
||||
pub(crate) fn ensure(host: &Host, project: &Arc<Project>) {
|
||||
if !project.is_trusted() || project.settings.effective().languages.python.run_target().is_none() {
|
||||
return;
|
||||
}
|
||||
let (dirs, project) = (Dirs::of(host), project.clone());
|
||||
host.runtime.spawn(async move {
|
||||
if let Err(e) = update(dirs, &project, false).await
|
||||
&& e.kind != ide_core::error::ErrorKind::Cancelled
|
||||
{
|
||||
project.sink.emit(IdeEvent::Notify {
|
||||
level: NotifyLevel::Warning,
|
||||
title: "Cannot copy the container's Python libraries".into(),
|
||||
body: Some(e.message),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn update(dirs: Dirs, project: &Arc<Project>, force: bool) -> IdeResult<()> {
|
||||
let settings = project.settings.effective();
|
||||
let Some(id) = settings.languages.python.run_target() else { return Ok(()) };
|
||||
let target = ide_remote::service::run_target(&settings, id)?;
|
||||
if target.kind == RunTargetKind::Ssh {
|
||||
return Err(IdeError::invalid(format!(
|
||||
"Run target '{}' runs over SSH: only a Docker or Compose run target can be the project interpreter",
|
||||
target.name
|
||||
)));
|
||||
}
|
||||
let dir = dirs.target(&project.root, id);
|
||||
if !UPDATING.lock().unwrap_or_else(|e| e.into_inner()).insert(dir.clone()) {
|
||||
return Ok(());
|
||||
}
|
||||
let _done = Updating(dir.clone());
|
||||
let endpoint = crate::ops::run::container::endpoint(&dirs.data, &target)?;
|
||||
let task = TaskId::next();
|
||||
let title = format!("Python libraries of {}", target.name);
|
||||
let (root, sink) = (project.root.clone(), project.sink.clone());
|
||||
let progress = move |message: &str, done: bool| {
|
||||
sink.emit(IdeEvent::Task {
|
||||
id: task,
|
||||
title: title.clone(),
|
||||
message: Some(message.to_owned()),
|
||||
fraction: None,
|
||||
done,
|
||||
cancellable: false,
|
||||
})
|
||||
};
|
||||
let report = progress.clone();
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
take(&endpoint, &root, &target, &dir, &dirs.cache, force, &|message| report(message, false))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))
|
||||
.and_then(|r| r);
|
||||
progress(if result.is_ok() { "Done" } else { "Failed" }, true);
|
||||
if result? {
|
||||
project.lsp.reconfigure().await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct Updating(PathBuf);
|
||||
|
||||
impl Drop for Updating {
|
||||
fn drop(&mut self) {
|
||||
UPDATING.lock().unwrap_or_else(|e| e.into_inner()).remove(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes the probe container, also when copying failed.
|
||||
struct Container<'a> {
|
||||
endpoint: &'a ide_docker::DockerEndpoint,
|
||||
name: String,
|
||||
}
|
||||
|
||||
impl Drop for Container<'_> {
|
||||
fn drop(&mut self) {
|
||||
let args = ["rm", "--force", "--volumes", &self.name].map(String::from);
|
||||
if let Err(e) = ide_docker::cli(self.endpoint, None, &args, QUICK, None) {
|
||||
tracing::warn!("cannot remove container {}: {}", self.name, e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Takes the snapshot in `target_dir` and makes it the one in use; `false` when the image
|
||||
/// had one already and it is in use (unless `force`).
|
||||
fn take(
|
||||
endpoint: &ide_docker::DockerEndpoint,
|
||||
root: &Path,
|
||||
target: &RunTarget,
|
||||
target_dir: &Path,
|
||||
cache: &Path,
|
||||
force: bool,
|
||||
progress: &dyn Fn(&str),
|
||||
) -> IdeResult<bool> {
|
||||
crate::ops::run::container::require_local(endpoint, target)?;
|
||||
let container_root = crate::ops::run::container::container_root(endpoint, root, target)?;
|
||||
let name = format!("openidea-python-{}", unique());
|
||||
let args = ide_remote::docker::python_probe_args(target, root, &name, snapshots::PROBE)?;
|
||||
progress("Starting the container's Python");
|
||||
let container = Container { endpoint, name };
|
||||
let out = ide_docker::cli(endpoint, Some(root), &args, PROBE_TIMEOUT, None);
|
||||
let probe = snapshots::parse_probe(&out?)?;
|
||||
let inspect = ["inspect".into(), "--format".into(), "{{.Image}}".into(), container.name.clone()];
|
||||
let image = ide_docker::cli(endpoint, None, &inspect, QUICK, None)?.trim().to_owned();
|
||||
if image.is_empty() {
|
||||
return Err(IdeError::internal(format!("docker inspect printed no image for {}", container.name)));
|
||||
}
|
||||
let dir = target_dir.join(snapshots::dir_name(&image));
|
||||
let before = snapshots::current(target_dir).map(|(_, s)| s.image);
|
||||
if !force && snapshots::read(&dir).is_some() {
|
||||
snapshots::make_current(target_dir, &image)?;
|
||||
return Ok(before.as_deref() != Some(image.as_str()));
|
||||
}
|
||||
let partial = target_dir.join(format!("{}.partial", snapshots::dir_name(&image)));
|
||||
if partial.exists() {
|
||||
std::fs::remove_dir_all(&partial)?;
|
||||
}
|
||||
std::fs::create_dir_all(&partial)?;
|
||||
let mut folders = Vec::new();
|
||||
for folder in snapshots::folders_to_copy(&probe, &container_root) {
|
||||
progress(&format!("Copying {folder}"));
|
||||
let local = snapshots::local_folder(&partial, &folder);
|
||||
let Some(parent) = local.parent() else { continue };
|
||||
std::fs::create_dir_all(parent)?;
|
||||
// Into an existing folder: `docker cp` puts the source folder in it under its own name.
|
||||
let args = ["cp".into(), format!("{}:{folder}", container.name), format!("{}/", parent.to_string_lossy())];
|
||||
match ide_docker::cli(endpoint, None, &args, COPY_TIMEOUT, None) {
|
||||
Ok(_) => folders.push(folder),
|
||||
Err(e) => tracing::warn!("cannot copy {folder} from {}: {}", container.name, e.message),
|
||||
}
|
||||
}
|
||||
drop(container);
|
||||
progress("Preparing the language server's interpreter");
|
||||
let snapshot = Snapshot {
|
||||
neutral_python: snapshots::neutral_python(cache, &probe.version),
|
||||
image: image.clone(),
|
||||
probe,
|
||||
folders,
|
||||
};
|
||||
snapshots::write(&partial, &snapshot)?;
|
||||
if dir.exists() {
|
||||
std::fs::remove_dir_all(&dir)?;
|
||||
}
|
||||
std::fs::rename(&partial, &dir)?;
|
||||
snapshots::make_current(target_dir, &image)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Distinguishes probe containers.
|
||||
fn unique() -> String {
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
static COUNT: AtomicU32 = AtomicU32::new(0);
|
||||
let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs());
|
||||
format!("{:x}-{}-{}", secs & 0xff_ffff, std::process::id(), COUNT.fetch_add(1, Ordering::Relaxed))
|
||||
}
|
||||
@@ -174,6 +174,7 @@ impl Host {
|
||||
let root = member.as_ref().unwrap_or(window_root);
|
||||
ide_sdk::apply::server_launch_overrides(cfg, &effective);
|
||||
ide_sdk::apply::configure_server(cfg, root, server_root, &effective);
|
||||
crate::ops::sdk::container_python::configure_server(cfg, &data_dir, root, &effective);
|
||||
if cfg.name == "jdtls" {
|
||||
// A workspace member keeps its build model under its own key (Build Project).
|
||||
let cache = match &member {
|
||||
@@ -630,6 +631,7 @@ impl Host {
|
||||
if let Some(cmd) = post_start {
|
||||
self.environment.spawn_post_start(cmd, project.root.clone(), project.sink.clone());
|
||||
}
|
||||
crate::ops::sdk::container_python::ensure(self, &project);
|
||||
Ok(project)
|
||||
}
|
||||
|
||||
|
||||
@@ -289,6 +289,7 @@ impl crate::Host {
|
||||
}
|
||||
}
|
||||
project.lsp.reconfigure().await;
|
||||
crate::ops::sdk::container_python::ensure(self, &project);
|
||||
project.sink.emit(ide_core::IdeEvent::ProjectTrustChanged { root: project.root.clone().into(), trusted });
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
//! A Compose run target as the Python interpreter, against a real daemon and the real
|
||||
//! basedpyright. Ignored by default: `OPENIDEA_DOCKER_TEST_DIR` names an empty folder the
|
||||
//! daemon can bind-mount (on this machine, or at the same path in a VM such as OrbStack's),
|
||||
//! `docker` with the Compose plugin and `basedpyright-langserver` must be on `PATH`, and
|
||||
//! this machine's Python must not have Django installed.
|
||||
#![cfg(unix)]
|
||||
|
||||
use ide_core::IdeEvent;
|
||||
use ide_host::{Host, HostConfig, ops};
|
||||
use ide_run::{OutputChunk, RunConfig};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const WINDOW: &str = "main";
|
||||
type Events = Arc<Mutex<Vec<IdeEvent>>>;
|
||||
|
||||
fn host(base: &Path, events: &Events) -> Arc<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({
|
||||
let events = events.clone();
|
||||
move |_| {
|
||||
let events = events.clone();
|
||||
Arc::new(move |e: IdeEvent| events.lock().unwrap().push(e))
|
||||
}
|
||||
}),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
}))
|
||||
}
|
||||
|
||||
/// basedpyright's latest diagnostics of `path` once `done` accepts them.
|
||||
async fn diagnostics(events: &Events, path: &Path, timeout: Duration, done: impl Fn(&[String]) -> bool) -> Vec<String> {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
let latest = events.lock().unwrap().iter().rev().find_map(|e| match e {
|
||||
IdeEvent::Diagnostics { path: p, items, source } if **p == *path && source.contains("basedpyright") => {
|
||||
Some(items.iter().map(|d| d.message.clone()).collect::<Vec<_>>())
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
if let Some(messages) = &latest
|
||||
&& done(messages)
|
||||
{
|
||||
return messages.clone();
|
||||
}
|
||||
assert!(Instant::now() < deadline, "timed out; last diagnostics: {latest:?}");
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
}
|
||||
|
||||
fn unresolved(messages: &[String]) -> bool {
|
||||
messages.iter().any(|m| m.contains("\"django") && m.contains("could not be resolved"))
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[ignore = "requires a Docker daemon, basedpyright and OPENIDEA_DOCKER_TEST_DIR"]
|
||||
async fn compose_target_is_the_python_interpreter() {
|
||||
let root = PathBuf::from(std::env::var("OPENIDEA_DOCKER_TEST_DIR").expect("OPENIDEA_DOCKER_TEST_DIR"));
|
||||
let root = dunce::canonicalize(root).unwrap();
|
||||
let files = [
|
||||
("compose.yaml", "services:\n web:\n build: .\n volumes:\n - .:/app\n working_dir: /app\n"),
|
||||
(
|
||||
"Dockerfile",
|
||||
"FROM python:3.12-slim\nCOPY requirements.txt /tmp/requirements.txt\n\
|
||||
RUN pip install --no-cache-dir -r /tmp/requirements.txt\n",
|
||||
),
|
||||
("requirements.txt", "Django==5.1.3\n"),
|
||||
(
|
||||
"app/main.py",
|
||||
"import django\nfrom django.http import HttpResponse\n\nprint(django.get_version(), HttpResponse)\n",
|
||||
),
|
||||
];
|
||||
for (path, text) in files {
|
||||
std::fs::create_dir_all(root.join(path).parent().unwrap()).unwrap();
|
||||
std::fs::write(root.join(path), text).unwrap();
|
||||
}
|
||||
let main = root.join("app/main.py");
|
||||
let state = tempfile::tempdir().unwrap();
|
||||
let events: Events = Arc::default();
|
||||
let host = host(state.path(), &events);
|
||||
host.trust.decide(&root, true, false).unwrap();
|
||||
host.open_project(WINDOW, &root).unwrap();
|
||||
let set = |key: &str, value: serde_json::Value| {
|
||||
ops::project::set_settings(
|
||||
&host,
|
||||
WINDOW,
|
||||
ide_workspace::settings::SettingsScope::Project,
|
||||
key,
|
||||
&value.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
};
|
||||
set("runTargets", serde_json::json!([{ "kind": "compose", "id": "web", "name": "Compose web", "service": "web" }]));
|
||||
|
||||
// A local interpreter: Django is only in the image.
|
||||
ops::doc::open(&host, WINDOW, main.clone()).await.unwrap();
|
||||
let before = diagnostics(&events, &main, Duration::from_secs(120), unresolved).await;
|
||||
eprintln!("local interpreter: {before:?}");
|
||||
|
||||
// The run target as the interpreter: a snapshot is taken in the background, then the
|
||||
// server restarts with the copied site-packages as `extraPaths`.
|
||||
set("languages.python.interpreter", serde_json::json!("target:web"));
|
||||
let after = diagnostics(&events, &main, Duration::from_secs(900), |m| !unresolved(m)).await;
|
||||
eprintln!("run target interpreter: {after:?}");
|
||||
assert!(after.iter().all(|m| !m.contains("could not be resolved")), "{after:?}");
|
||||
|
||||
let info = ops::sdk::sdk_container_python(&host, WINDOW).unwrap().expect("a run target interpreter");
|
||||
eprintln!("snapshot: version {:?}, image {:?}, folders {:?}", info.version, info.image, info.folders);
|
||||
assert_eq!(info.name, "Compose web");
|
||||
assert!(info.version.as_deref().is_some_and(|v| v.starts_with("3.12.")), "{info:?}");
|
||||
assert!(info.packages.iter().any(|p| p.name == "Django" && p.version == "5.1.3"), "{:?}", info.packages);
|
||||
let site = info.folders.iter().find(|f| f.0.ends_with("site-packages")).expect("site-packages copied");
|
||||
assert!(site.0.join("django/__init__.py").is_file());
|
||||
assert!(site.0.starts_with(state.path().join("data/container-python")), "{site:?}");
|
||||
let servers = ops::lsp::lsp_servers(&host, WINDOW).unwrap();
|
||||
eprintln!("servers: {:?}", servers.iter().map(|s| (&s.name, s.status)).collect::<Vec<_>>());
|
||||
|
||||
// Run: a Python configuration without its own target runs in the container.
|
||||
let out = Arc::new(Mutex::new(String::new()));
|
||||
let sink = out.clone();
|
||||
let config = RunConfig {
|
||||
id: "main".into(),
|
||||
name: "main".into(),
|
||||
kind: "python".into(),
|
||||
command: "python3".into(),
|
||||
args: vec!["app/main.py".into()],
|
||||
cwd: None,
|
||||
env: Default::default(),
|
||||
detected: false,
|
||||
is_test: false,
|
||||
debug_adapter: None,
|
||||
allow_parallel: false,
|
||||
extras: Default::default(),
|
||||
};
|
||||
ops::run::start(
|
||||
&host,
|
||||
WINDOW,
|
||||
config,
|
||||
Arc::new(move |chunk| match chunk {
|
||||
OutputChunk::Stdout { text } | OutputChunk::Stderr { text } | OutputChunk::System { text } => {
|
||||
sink.lock().unwrap().push_str(&text)
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
for _ in 0..1200 {
|
||||
if out.lock().unwrap().contains("Process finished") {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
let out = out.lock().unwrap().clone();
|
||||
eprintln!("run output: {out}");
|
||||
assert!(out.contains("5.1.3") && out.contains("exit code 0"), "{out}");
|
||||
|
||||
// Refresh takes a new snapshot of the same image; nothing is left running.
|
||||
let refreshed = ops::sdk::sdk_container_python_refresh(&host, WINDOW).await.unwrap().unwrap();
|
||||
assert_eq!(refreshed.image, info.image);
|
||||
let probes = Command::new("docker")
|
||||
.args(["ps", "--all", "--quiet", "--filter", "label=openidea.python-probe=true"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(String::from_utf8_lossy(&probes.stdout).trim(), "", "probe containers are removed");
|
||||
Command::new("docker").args(["compose", "down", "--rmi", "local"]).current_dir(&root).status().unwrap();
|
||||
}
|
||||
@@ -599,6 +599,28 @@ impl Store {
|
||||
txn.commit().map_err(err)
|
||||
}
|
||||
|
||||
/// [`Self::apply`] for records already serialized (by the parse pool).
|
||||
pub(crate) fn apply_raw(&self, puts: &[(String, Vec<u8>)], deletes: &[String], durable: bool) -> Result<()> {
|
||||
self.ensure_db()?;
|
||||
let db = self.db.read();
|
||||
let mut txn = db.as_ref().expect("ensured").begin_write().map_err(err)?;
|
||||
if !durable {
|
||||
txn.set_durability(Durability::None).map_err(err)?;
|
||||
}
|
||||
{
|
||||
let mut table = txn.open_table(FILES).map_err(err)?;
|
||||
for (rel, bytes) in puts {
|
||||
table.insert(rel.as_str(), bytes.as_slice()).map_err(err)?;
|
||||
}
|
||||
for rel in deletes {
|
||||
table.remove(rel.as_str()).map_err(err)?;
|
||||
}
|
||||
}
|
||||
// Any file change makes the columnar start-up snapshot stale.
|
||||
let _ = std::fs::remove_file(&self.snapshot);
|
||||
txn.commit().map_err(err)
|
||||
}
|
||||
|
||||
/// Reclaims free pages in the database file; `true` if anything was compacted.
|
||||
pub(crate) fn compact(&self) -> Result<bool> {
|
||||
self.ensure_db()?;
|
||||
|
||||
@@ -17,6 +17,8 @@ use std::time::{Duration, Instant, SystemTime};
|
||||
/// Files larger than this are listed but not parsed for symbols.
|
||||
pub const MAX_SYMBOL_FILE: u64 = 2 * 1024 * 1024;
|
||||
const CHUNK: usize = 512;
|
||||
/// Insert/persist batch of the initial scan's streaming pass.
|
||||
const SCAN_BATCH: usize = 2048;
|
||||
const PROGRESS_EVERY: Duration = Duration::from_millis(250);
|
||||
#[cfg(not(test))]
|
||||
const SNAPSHOT_DELAY: Duration = Duration::from_secs(2);
|
||||
@@ -436,10 +438,13 @@ impl Shared {
|
||||
});
|
||||
|
||||
let filter = self.filter();
|
||||
let walk_started = Instant::now();
|
||||
let found = prewalked.unwrap_or_else(|| self.walk_roots(dirs));
|
||||
let walk = walk_started.elapsed();
|
||||
if self.is_cancelled() {
|
||||
return self.end_scan(task);
|
||||
}
|
||||
let classify_started = Instant::now();
|
||||
let seen: HashSet<&Path> = found.iter().map(|f| f.path.as_path()).collect();
|
||||
let (todo, stale): (Vec<&Found>, Vec<PathBuf>) = {
|
||||
let d = self.data.read();
|
||||
@@ -452,6 +457,7 @@ impl Shared {
|
||||
.collect();
|
||||
(todo, stale)
|
||||
};
|
||||
let classify = classify_started.elapsed();
|
||||
if !stale.is_empty() {
|
||||
let mut d = self.data.write();
|
||||
for path in &stale {
|
||||
@@ -462,33 +468,76 @@ impl Shared {
|
||||
self.persist(&[], &stale, false);
|
||||
|
||||
let total = todo.len();
|
||||
let mut done = 0usize;
|
||||
let mut last_progress = Instant::now();
|
||||
for chunk in todo.chunks(CHUNK) {
|
||||
if self.is_cancelled() {
|
||||
return self.end_scan(task);
|
||||
}
|
||||
self.index_files(chunk.iter().copied(), false);
|
||||
done += chunk.len();
|
||||
if last_progress.elapsed() >= PROGRESS_EVERY {
|
||||
last_progress = Instant::now();
|
||||
self.sink.emit(IdeEvent::Task {
|
||||
id: task,
|
||||
title: "Indexing".into(),
|
||||
message: Some(format!("{done} / {total} files")),
|
||||
fraction: Some(done as f32 / total.max(1) as f32),
|
||||
done: false,
|
||||
cancellable: false,
|
||||
let mut laps = ChunkLaps::default();
|
||||
{
|
||||
// One streaming parallel pass: parse on the rayon pool without
|
||||
// per-chunk joins (a straggler file at a join's tail idles every
|
||||
// other core), while parsed files flow to this thread to be
|
||||
// inserted and persisted in batches. `filter` moves into the
|
||||
// parse thread; this thread keeps the outer one.
|
||||
let filter = self.filter();
|
||||
// Bounded: when inserting and persisting fall behind, the parse waits instead
|
||||
// of holding the whole project's records (and their bytes) in the channel.
|
||||
let (tx, rx) = std::sync::mpsc::sync_channel::<ParsedFile>(2 * SCAN_BATCH);
|
||||
let cancelled = &self.cancelled;
|
||||
let mut done = 0usize;
|
||||
let mut last_progress = Instant::now();
|
||||
let pass_started = Instant::now();
|
||||
std::thread::scope(|scope| -> () {
|
||||
scope.spawn(move || {
|
||||
todo.par_iter().for_each(|f| {
|
||||
if cancelled.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
let Some(out) = self.parse_found(f, &filter, false) else { return };
|
||||
let _ = tx.send(out);
|
||||
});
|
||||
// `tx` drops here: the consumer's loop ends with the channel.
|
||||
});
|
||||
}
|
||||
let mut batch: Vec<ParsedFile> = Vec::with_capacity(SCAN_BATCH);
|
||||
for out in rx {
|
||||
if self.is_cancelled() {
|
||||
break;
|
||||
}
|
||||
batch.push(out);
|
||||
if batch.len() < SCAN_BATCH {
|
||||
continue;
|
||||
}
|
||||
done += batch.len();
|
||||
laps.add(self.publish(batch.drain(..)));
|
||||
if last_progress.elapsed() >= PROGRESS_EVERY {
|
||||
last_progress = Instant::now();
|
||||
self.sink.emit(IdeEvent::Task {
|
||||
id: task,
|
||||
title: "Indexing".into(),
|
||||
message: Some(format!("{done} / {total} files")),
|
||||
fraction: Some(done as f32 / total.max(1) as f32),
|
||||
done: false,
|
||||
cancellable: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
if !batch.is_empty() && !self.is_cancelled() {
|
||||
done += batch.len();
|
||||
laps.add(self.publish(batch.drain(..)));
|
||||
}
|
||||
});
|
||||
laps.extract = pass_started.elapsed();
|
||||
}
|
||||
if self.is_cancelled() {
|
||||
return self.end_scan(task);
|
||||
}
|
||||
let durable_started = Instant::now();
|
||||
self.persist(&[], &[], true);
|
||||
let durable = durable_started.elapsed();
|
||||
if let Some(store) = &self.store {
|
||||
let recent: Vec<String> = self.recent.lock().iter().filter_map(|p| filter.store_key(p)).collect();
|
||||
let d = self.data.read();
|
||||
let snapshot_started = Instant::now();
|
||||
if let Err(e) = store.save_snapshot(&d, &filter, guard) {
|
||||
tracing::warn!("index snapshot: {e}");
|
||||
}
|
||||
tracing::info!("index snapshot: written in {:?}", snapshot_started.elapsed());
|
||||
if let Err(e) = store.save_recent_file(&recent) {
|
||||
tracing::warn!("index recent snapshot: {e}");
|
||||
}
|
||||
@@ -506,54 +555,85 @@ impl Shared {
|
||||
self.emit_state();
|
||||
let d = self.data.read();
|
||||
tracing::info!(
|
||||
"indexed {} files ({} parsed, {} symbols) in {:?}",
|
||||
"indexed {} files ({} parsed, {} symbols) in {:?} (walk {:?}, classify {:?}, extract {:?}, insert {:?}, persist {:?}, durable {:?})",
|
||||
d.file_count,
|
||||
total,
|
||||
d.symbol_count,
|
||||
started.elapsed()
|
||||
started.elapsed(),
|
||||
walk,
|
||||
classify,
|
||||
laps.extract,
|
||||
laps.insert,
|
||||
laps.persist,
|
||||
durable
|
||||
);
|
||||
}
|
||||
|
||||
/// Extracts records in parallel, then publishes them to memory and disk.
|
||||
/// `force` re-parses files whose content is unchanged (their language changed).
|
||||
fn index_files<'a>(&self, files: impl Iterator<Item = &'a Found>, force: bool) {
|
||||
fn index_files<'a>(&self, files: impl Iterator<Item = &'a Found>, force: bool) -> ChunkLaps {
|
||||
let mut laps = ChunkLaps::default();
|
||||
let files: Vec<&Found> = files.collect();
|
||||
let filter = self.filter();
|
||||
let extracted: Vec<(String, FileRecord, PathBuf)> = files
|
||||
.par_iter()
|
||||
.filter_map(|f| {
|
||||
if self.is_cancelled() {
|
||||
return None;
|
||||
}
|
||||
let rel = filter.rel(&f.path)?;
|
||||
let prev_hash = if force { None } else { self.data.read().get(&f.path).map(|e| e.hash) };
|
||||
match self.extract(f, prev_hash) {
|
||||
Extracted::Unchanged => {
|
||||
// Same content, new mtime: keep the symbols, refresh metadata.
|
||||
let mut rec = self.data.read().record_of(&f.path)?;
|
||||
rec.mtime = f.mtime;
|
||||
rec.size = f.size;
|
||||
rec.ignored = f.ignored;
|
||||
Some((rel, rec, f.path.clone()))
|
||||
}
|
||||
Extracted::New(rec) => Some((rel, rec, f.path.clone())),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let started = Instant::now();
|
||||
let extracted: Vec<ParsedFile> = files.par_iter().filter_map(|f| self.parse_found(f, &filter, force)).collect();
|
||||
laps.extract = started.elapsed();
|
||||
if extracted.is_empty() {
|
||||
return;
|
||||
return laps;
|
||||
}
|
||||
laps.add(self.publish(extracted.into_iter()));
|
||||
laps
|
||||
}
|
||||
|
||||
/// Reads and parses one file (parallel-safe), serializing its record for
|
||||
/// the cache alongside the parse. `None` when the file is outside the
|
||||
/// scope or the scan was cancelled.
|
||||
fn parse_found(&self, f: &Found, filter: &std::sync::Arc<PathFilter>, force: bool) -> Option<ParsedFile> {
|
||||
if self.is_cancelled() {
|
||||
return None;
|
||||
}
|
||||
let rel = filter.rel(&f.path)?;
|
||||
let prev_hash = if force { None } else { self.data.read().get(&f.path).map(|e| e.hash) };
|
||||
let rec = match self.extract(f, prev_hash) {
|
||||
Extracted::Unchanged => {
|
||||
// Same content, new mtime: keep the symbols, refresh metadata.
|
||||
let mut rec = self.data.read().record_of(&f.path)?;
|
||||
rec.mtime = f.mtime;
|
||||
rec.size = f.size;
|
||||
rec.ignored = f.ignored;
|
||||
rec
|
||||
}
|
||||
Extracted::New(rec) => rec,
|
||||
};
|
||||
let key = filter.store_key(&f.path);
|
||||
let bytes = postcard::to_allocvec(&rec).ok();
|
||||
Some(ParsedFile { rel, rec, key, bytes, path: f.path.clone() })
|
||||
}
|
||||
|
||||
/// Inserts a parsed batch into the in-memory index and persists the records.
|
||||
fn publish(&self, batch: impl Iterator<Item = ParsedFile>) -> ChunkLaps {
|
||||
let mut laps = ChunkLaps::default();
|
||||
let started = Instant::now();
|
||||
let mut entries: Vec<(FileEntry, Vec<StoredSymbol>)> = Vec::new();
|
||||
let mut puts: Vec<(String, Vec<u8>)> = Vec::new();
|
||||
for out in batch {
|
||||
let ParsedFile { rel, rec, key, bytes, path } = out;
|
||||
if let (Some(key), Some(bytes)) = (key, bytes) {
|
||||
puts.push((key, bytes));
|
||||
}
|
||||
entries.push(FileEntry::new(path, rel, rec));
|
||||
}
|
||||
let puts: Vec<(String, FileRecord)> =
|
||||
extracted.iter().filter_map(|(_, rec, path)| Some((filter.store_key(path)?, rec.clone()))).collect();
|
||||
{
|
||||
let entries: Vec<(FileEntry, Vec<StoredSymbol>)> =
|
||||
extracted.into_iter().map(|(rel, rec, path)| FileEntry::new(path, rel, rec)).collect();
|
||||
let mut d = self.data.write();
|
||||
for (e, symbols) in entries {
|
||||
d.insert(e, symbols);
|
||||
}
|
||||
}
|
||||
self.persist(&puts, &[], false);
|
||||
laps.insert = started.elapsed();
|
||||
let started = Instant::now();
|
||||
self.persist_raw(&puts, &[], false);
|
||||
laps.persist = started.elapsed();
|
||||
laps
|
||||
}
|
||||
|
||||
fn schedule_snapshot(&self) {
|
||||
@@ -594,6 +674,15 @@ impl Shared {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Self::persist`] for records already serialized by the parse pool.
|
||||
fn persist_raw(&self, puts: &[(String, Vec<u8>)], deletes: &[String], durable: bool) {
|
||||
if let Some(store) = &self.store
|
||||
&& let Err(e) = store.apply_raw(puts, deletes, durable)
|
||||
{
|
||||
tracing::warn!("index cache write failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
fn extract(&self, f: &Found, prev_hash: Option<[u8; 32]>) -> Extracted {
|
||||
let mut rec = FileRecord { mtime: f.mtime, size: f.size, ignored: f.ignored, ..Default::default() };
|
||||
if f.ignored || f.size > MAX_SYMBOL_FILE {
|
||||
@@ -793,6 +882,33 @@ enum Extracted {
|
||||
New(FileRecord),
|
||||
}
|
||||
|
||||
/// Wall-time laps of one `index_files` chunk, accumulated by the scan.
|
||||
#[derive(Default)]
|
||||
struct ChunkLaps {
|
||||
extract: Duration,
|
||||
insert: Duration,
|
||||
persist: Duration,
|
||||
}
|
||||
|
||||
impl ChunkLaps {
|
||||
fn add(&mut self, other: ChunkLaps) {
|
||||
self.extract += other.extract;
|
||||
self.insert += other.insert;
|
||||
self.persist += other.persist;
|
||||
}
|
||||
}
|
||||
|
||||
/// One file parsed off the index thread: its record ready to publish, already
|
||||
/// serialized for the cache (in parallel with the parse).
|
||||
struct ParsedFile {
|
||||
rel: String,
|
||||
rec: FileRecord,
|
||||
/// Cache key and postcard bytes; `None` when outside the persisted root.
|
||||
key: Option<String>,
|
||||
bytes: Option<Vec<u8>>,
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::data::safe_rel;
|
||||
|
||||
@@ -198,6 +198,10 @@ pub struct LanguageServerConfig {
|
||||
/// it parses a document.
|
||||
#[serde(default)]
|
||||
pub resync_on_configuration: bool,
|
||||
/// Set by the settings hook: what the user sees as the server when another program serves
|
||||
/// in its place (TypeScript 7's own `tsc --lsp` for vtsls).
|
||||
#[serde(skip)]
|
||||
pub display_name: Option<String>,
|
||||
/// Set on plugin languages: start exactly this approved program.
|
||||
#[serde(skip)]
|
||||
pub pinned: Option<ProgramPin>,
|
||||
|
||||
@@ -3855,6 +3855,7 @@ impl LspManager {
|
||||
languages: s.languages(),
|
||||
status: s.status(),
|
||||
pid: s.running().and_then(|r| r.peer.pid()),
|
||||
label: s.display_name(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -213,6 +213,11 @@ impl Server {
|
||||
config
|
||||
}
|
||||
|
||||
/// The configured server's display name for the current (or last) instance.
|
||||
pub fn display_name(&self) -> Option<String> {
|
||||
self.effective.lock().as_ref().and_then(|c| c.display_name.clone())
|
||||
}
|
||||
|
||||
pub fn set_effective_config(&self, generation: u64, config: LanguageServerConfig) {
|
||||
let st = self.state.lock();
|
||||
if st.generation == generation && matches!(st.conn, Conn::Starting | Conn::Ready(_)) {
|
||||
@@ -507,7 +512,13 @@ impl Server {
|
||||
return;
|
||||
}
|
||||
*cur = (status, message.clone());
|
||||
st.events.push_back(IdeEvent::LanguageServer { server: self.id, name: self.name.clone(), status, message });
|
||||
st.events.push_back(IdeEvent::LanguageServer {
|
||||
server: self.id,
|
||||
name: self.name.clone(),
|
||||
label: self.display_name(),
|
||||
status,
|
||||
message,
|
||||
});
|
||||
}
|
||||
|
||||
fn queue_cleanup(&self, st: &mut State) {
|
||||
|
||||
@@ -341,6 +341,10 @@ pub struct ServerInfo {
|
||||
pub status: ServerStatus,
|
||||
/// OS process of the running instance (for memory attribution).
|
||||
pub pid: Option<u32>,
|
||||
/// What the user sees as the server when another program serves in its place.
|
||||
#[serde(default)]
|
||||
#[specta(optional)]
|
||||
pub label: Option<String>,
|
||||
}
|
||||
|
||||
/// Read-only contents a language server provides for a non-`file:` URI
|
||||
|
||||
@@ -268,6 +268,7 @@ fn server_cfg(name: &str) -> LanguageServerConfig {
|
||||
tsserver_project: false,
|
||||
tsserver_project_extras: Default::default(),
|
||||
resync_on_configuration: false,
|
||||
display_name: None,
|
||||
pinned: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -426,6 +426,7 @@ async fn missing_binary_is_reported_with_install_hint() {
|
||||
tsserver_project: false,
|
||||
tsserver_project_extras: Default::default(),
|
||||
resync_on_configuration: false,
|
||||
display_name: None,
|
||||
pinned: None,
|
||||
};
|
||||
let (mgr, events) = manager(dir.path(), LanguageRegistry::from_configs(vec![lang("nope", "nope", server)]));
|
||||
@@ -698,6 +699,7 @@ async fn typescript_language_server_end_to_end() {
|
||||
tsserver_project: false,
|
||||
tsserver_project_extras: Default::default(),
|
||||
resync_on_configuration: false,
|
||||
display_name: None,
|
||||
pinned: None,
|
||||
};
|
||||
let (mgr, events) = manager(&root, LanguageRegistry::from_configs(vec![lang("typescript", "ts", server)]));
|
||||
@@ -750,6 +752,7 @@ fn npm_server(tools: &Path, name: &str, bin: &str, init: Option<serde_json::Valu
|
||||
tsserver_project: false,
|
||||
tsserver_project_extras: Default::default(),
|
||||
resync_on_configuration: false,
|
||||
display_name: None,
|
||||
pinned: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,6 +109,17 @@ pub(crate) fn cpuprofile_runs_code_of(json: &str, root: &Path) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
/// The file on disk of one `$vscode.locations` entry and its 0-based line. The entry lists a
|
||||
/// frame's places in order: the source it was mapped to, then the script that ran, which a
|
||||
/// page serves from a URL and has no file.
|
||||
fn source_location(entry: &Value) -> Option<(PathBuf, u32)> {
|
||||
entry.get("locations")?.as_array()?.iter().find_map(|l| {
|
||||
let path = Path::new(l.pointer("/source/path")?.as_str()?);
|
||||
let line = l.get("lineNumber")?.as_u64()?;
|
||||
path.is_absolute().then(|| (path.to_path_buf(), line.saturating_sub(1) as u32))
|
||||
})
|
||||
}
|
||||
|
||||
/// Adds a V8 CPU profile (`node --cpu-prof`), weighted by sampled time in milliseconds.
|
||||
pub(crate) fn add_cpuprofile(b: &mut TreeBuilder, json: &str) -> Result<(), String> {
|
||||
let v: Value = serde_json::from_str(json).map_err(|e| e.to_string())?;
|
||||
@@ -146,6 +157,8 @@ pub(crate) fn add_cpuprofile(b: &mut TreeBuilder, json: &str) -> Result<(), Stri
|
||||
}
|
||||
}
|
||||
}
|
||||
// A recording made through a debugger (js-debug) says where each frame is in the sources.
|
||||
let mapped = v.pointer("/$vscode/locations").and_then(Value::as_array);
|
||||
let frame_of = |n: &Value| -> Option<FrameKey> {
|
||||
let cf = n.get("callFrame")?;
|
||||
let name = cf.get("functionName").and_then(Value::as_str).unwrap_or_default();
|
||||
@@ -153,10 +166,22 @@ pub(crate) fn add_cpuprofile(b: &mut TreeBuilder, json: &str) -> Result<(), Stri
|
||||
if name == "(root)" {
|
||||
return None;
|
||||
}
|
||||
let file = url_path(url);
|
||||
let name = if name.is_empty() { "(anonymous)".to_owned() } else { name.to_owned() };
|
||||
let line =
|
||||
file.as_ref().and(cf.get("lineNumber").and_then(Value::as_i64)).filter(|l| *l >= 0).map(|l| l as u32);
|
||||
let (file, line) = n
|
||||
.get("locationId")
|
||||
.and_then(Value::as_u64)
|
||||
.and_then(|id| mapped?.get(id as usize))
|
||||
.and_then(source_location)
|
||||
.map(|(file, line)| (Some(file), Some(line)))
|
||||
.unwrap_or_else(|| {
|
||||
let file = url_path(url);
|
||||
let line = file
|
||||
.as_ref()
|
||||
.and(cf.get("lineNumber").and_then(Value::as_i64))
|
||||
.filter(|l| *l >= 0)
|
||||
.map(|l| l as u32);
|
||||
(file, line)
|
||||
});
|
||||
Some(FrameKey { name, file, line, kind: ProfileFrameKind::Code })
|
||||
};
|
||||
for (id, weight) in self_time {
|
||||
@@ -301,6 +326,32 @@ mod tests {
|
||||
assert!(add_cpuprofile(&mut TreeBuilder::new(), "{}").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cpuprofile_frames_take_the_source_a_debugger_mapped_them_to() {
|
||||
// js-debug's recording of a page: scripts are URLs, and `$vscode.locations` holds where
|
||||
// each frame is in the sources (1-based lines), first the mapped file, then the script.
|
||||
let json = r#"{"nodes":[
|
||||
{"id":1,"callFrame":{"functionName":"(root)","url":"","lineNumber":-1},"children":[2,3,4]},
|
||||
{"id":2,"callFrame":{"functionName":"increment","url":"http://localhost:5173/src/App.vue","lineNumber":40},"hitCount":3,"locationId":0},
|
||||
{"id":3,"callFrame":{"functionName":"inline","url":"http://localhost:5173/","lineNumber":9},"hitCount":1,"locationId":1},
|
||||
{"id":4,"callFrame":{"functionName":"(idle)","url":"","lineNumber":-1},"hitCount":1,"locationId":2}],
|
||||
"$vscode":{"rootPath":"/work/shop","locations":[
|
||||
{"locations":[
|
||||
{"lineNumber":13,"columnNumber":10,"source":{"path":"/work/shop/src/App.vue","sourceReference":0}},
|
||||
{"lineNumber":41,"columnNumber":21,"source":{"path":"localhost꞉5173/src/App.vue","sourceReference":7}}]},
|
||||
{"locations":[{"lineNumber":10,"columnNumber":1,"source":{"path":"localhost꞉5173/","sourceReference":9}}]},
|
||||
{"locations":[]}]}}"#;
|
||||
let mut b = TreeBuilder::new();
|
||||
add_cpuprofile(&mut b, json).unwrap();
|
||||
let p = b.finish("node", "samples", ProfileMode::Cpu);
|
||||
let frame = |name: &str| p.frames.iter().find(|f| f.name == name).unwrap();
|
||||
assert_eq!(frame("increment").file.as_deref(), Some(Path::new("/work/shop/src/App.vue")));
|
||||
assert_eq!(frame("increment").line, Some(12));
|
||||
// No file on disk behind the frame: it stays without one.
|
||||
assert_eq!((frame("inline").file.as_deref(), frame("inline").line), (None, None));
|
||||
assert_eq!(frame("(idle)").file, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cpuprofile_without_deltas_uses_hit_counts() {
|
||||
let json = r#"{"nodes":[{"id":1,"callFrame":{"functionName":"(root)","url":""},"children":[2]},
|
||||
|
||||
@@ -178,6 +178,47 @@ pub fn container_config(config: &RunConfig, run: &ContainerRun) -> IdeResult<Run
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The docker CLI arguments that run `python -c code` in a new container of the target's
|
||||
/// image or service, named `name`. The container is kept when it exits, so `docker cp` can
|
||||
/// copy from it while nothing runs; the caller removes it. The image's entrypoint is
|
||||
/// skipped (it may wait for other services), as are the service's dependencies.
|
||||
pub fn python_probe_args(target: &RunTarget, root: &Path, name: &str, code: &str) -> IdeResult<Vec<String>> {
|
||||
let python = match target.python.trim() {
|
||||
"" => "python3",
|
||||
python => python,
|
||||
};
|
||||
let mut args: Vec<String> = Vec::new();
|
||||
match target.kind {
|
||||
RunTargetKind::Docker => {
|
||||
if target.image.trim().is_empty() {
|
||||
return Err(IdeError::invalid(format!("Run target '{}' has no image", target.name)));
|
||||
}
|
||||
args.push("run".into());
|
||||
}
|
||||
RunTargetKind::Compose => {
|
||||
if target.service.trim().is_empty() {
|
||||
return Err(IdeError::invalid(format!("Run target '{}' has no service", target.name)));
|
||||
}
|
||||
args.push("compose".into());
|
||||
for file in target.compose_files.iter().map(|f| f.trim()).filter(|f| !f.is_empty()) {
|
||||
args.extend(["--file".into(), root.join(file).to_string_lossy().into_owned()]);
|
||||
}
|
||||
args.extend(["run", "--no-deps", "--no-TTY"].map(String::from));
|
||||
}
|
||||
RunTargetKind::Ssh => {
|
||||
return Err(IdeError::invalid(format!("Run target '{}' is not a container", target.name)));
|
||||
}
|
||||
}
|
||||
args.extend(["--name".into(), name.to_owned(), "--label".into(), "openidea.python-probe=true".into()]);
|
||||
args.extend(["--entrypoint".into(), python.to_owned()]);
|
||||
args.push(match target.kind {
|
||||
RunTargetKind::Compose => target.service.trim().to_owned(),
|
||||
_ => target.image.trim().to_owned(),
|
||||
});
|
||||
args.extend(["-c".into(), code.to_owned()]);
|
||||
Ok(args)
|
||||
}
|
||||
|
||||
/// Project managers whose `run` executes in the project's environment (`uv run python …`).
|
||||
const PYTHON_RUNNERS: [&str; 6] = ["uv", "poetry", "pdm", "pipenv", "hatch", "rye"];
|
||||
|
||||
@@ -429,6 +470,34 @@ mod tests {
|
||||
assert!(container_config(&c, &run(&target, &none, &none)).unwrap_err().message.contains("no service"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn python_probe_runs_the_interpreter_in_a_kept_container() {
|
||||
let root = Path::new("/work/proj");
|
||||
let target = RunTarget {
|
||||
kind: RunTargetKind::Compose,
|
||||
compose_files: vec!["compose.yaml".into()],
|
||||
service: "web".into(),
|
||||
python: "/venv/bin/python".into(),
|
||||
run_options: "--rm".into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
python_probe_args(&target, root, "openidea-python-1", "print(1)").unwrap().join(" "),
|
||||
"compose --file /work/proj/compose.yaml run --no-deps --no-TTY --name openidea-python-1 \
|
||||
--label openidea.python-probe=true --entrypoint /venv/bin/python web -c print(1)"
|
||||
);
|
||||
let target = RunTarget { kind: RunTargetKind::Docker, image: "python:3.12".into(), ..Default::default() };
|
||||
assert_eq!(
|
||||
python_probe_args(&target, root, "p", "x").unwrap().join(" "),
|
||||
"run --name p --label openidea.python-probe=true --entrypoint python3 python:3.12 -c x"
|
||||
);
|
||||
let ssh = RunTarget { name: "box".into(), ..Default::default() };
|
||||
assert!(python_probe_args(&ssh, root, "p", "x").unwrap_err().message.contains("not a container"));
|
||||
let empty = RunTarget { kind: RunTargetKind::Compose, name: "c".into(), ..Default::default() };
|
||||
assert!(python_probe_args(&empty, root, "p", "x").unwrap_err().message.contains("no service"));
|
||||
}
|
||||
|
||||
/// The container's command line of `c` debugged on `target` (after the image).
|
||||
#[cfg(unix)]
|
||||
fn debugged(target: &RunTarget, c: &RunConfig) -> IdeResult<Vec<String>> {
|
||||
|
||||
@@ -68,6 +68,8 @@ fn profiles_filter_and_preserve_launch_properties() {
|
||||
assert_eq!(p["http"].command_line_args, "--message \"hello world\"");
|
||||
assert_eq!(p["http"].working_directory, "work");
|
||||
assert!(parse_profiles("invalid").is_empty());
|
||||
// Visual Studio saves launchSettings.json with a byte order mark.
|
||||
assert_eq!(parse_profiles(&format!("\u{feff}{PROFILES}")).len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -69,7 +69,11 @@ pub fn ruff_configuration(root: &Path, configured: &str) -> IdeResult<Option<Pat
|
||||
/// root, else the Python the project pins for mise / asdf. A configured
|
||||
/// interpreter whose executable is gone (uninstalled, moved) is skipped like
|
||||
/// PyCharm's "Invalid" SDKs, so runs fall back instead of failing on a dead path.
|
||||
/// `None` when the project interpreter is a run target's (see [`crate::container_python`]).
|
||||
pub fn python_interpreter(root: &Path, s: &Settings) -> Option<PathBuf> {
|
||||
if s.languages.python.run_target().is_some() {
|
||||
return None;
|
||||
}
|
||||
setting_path(root, &s.languages.python.interpreter)
|
||||
.filter(|p| p.is_file())
|
||||
.or_else(|| project_venv_python(root))
|
||||
@@ -78,6 +82,9 @@ pub fn python_interpreter(root: &Path, s: &Settings) -> Option<PathBuf> {
|
||||
|
||||
/// An explicit project interpreter wins; otherwise use the closest virtualenv to the file.
|
||||
pub fn python_interpreter_near(root: &Path, near: Option<&Path>, s: &Settings) -> Option<PathBuf> {
|
||||
if s.languages.python.run_target().is_some() {
|
||||
return None;
|
||||
}
|
||||
if let Some(python) = setting_path(root, &s.languages.python.interpreter).filter(|p| p.is_file()) {
|
||||
return Some(python);
|
||||
}
|
||||
@@ -1156,6 +1163,7 @@ pub fn configure_server(cfg: &mut LanguageServerConfig, root: &Path, server_root
|
||||
{
|
||||
cfg.command = native.to_string_lossy().into_owned();
|
||||
cfg.args = vec!["--lsp".into(), "--stdio".into()];
|
||||
cfg.display_name = Some("TypeScript 7".into());
|
||||
cfg.initialization_options = None;
|
||||
cfg.tsserver_project = false;
|
||||
return;
|
||||
@@ -1299,6 +1307,7 @@ fn custom_server(name: &str, ls: &LanguageServerSettings) -> LanguageServerConfi
|
||||
tsserver_project: false,
|
||||
tsserver_project_extras: Default::default(),
|
||||
resync_on_configuration: false,
|
||||
display_name: None,
|
||||
pinned: None,
|
||||
}
|
||||
}
|
||||
@@ -1476,6 +1485,9 @@ pub fn run_target<'a>(config: &RunConfig, settings: &'a Settings) -> Option<&'a
|
||||
|| matches!(config.kind.as_str(), "node" | "npm" | "vitest" | "jest" | "mocha")
|
||||
{
|
||||
&settings.languages.node.run_target
|
||||
} else if let Some(target) = settings.languages.python.run_target().filter(|_| is_python_config(config, &name)) {
|
||||
// The project interpreter is a run target's: Python runs there.
|
||||
return Some(target);
|
||||
} else {
|
||||
return non_empty(&settings.run.default_target);
|
||||
};
|
||||
@@ -1488,6 +1500,10 @@ pub fn run_target<'a>(config: &RunConfig, settings: &'a Settings) -> Option<&'a
|
||||
}
|
||||
}
|
||||
|
||||
fn is_python_config(config: &RunConfig, program: &str) -> bool {
|
||||
matches!(config.kind.as_str(), "python" | "pytest" | "django-test") || is_python_command(program)
|
||||
}
|
||||
|
||||
/// Whether the configuration's `JAVA_HOME` is the one Settings choose (the Maven runner JRE,
|
||||
/// the Gradle JVM) rather than a choice of its own: an IDE-generated build-tool configuration —
|
||||
/// a Maven goal or Gradle task from the tool window — carries the model's `JAVA_HOME`, which
|
||||
|
||||
@@ -0,0 +1,418 @@
|
||||
//! A Docker or Compose run target's Python as the project interpreter
|
||||
//! (`languages.python.interpreter = "target:<id>"`, PyCharm's Docker / Docker Compose
|
||||
//! interpreter).
|
||||
//!
|
||||
//! basedpyright / pyright run on this machine, so the library folders of the
|
||||
//! container's `sys.path` are copied here once per image (a snapshot) and given to
|
||||
//! the server as `extraPaths`. Its interpreter is a neutral one: an empty virtualenv
|
||||
//! the IDE creates, so no local site-packages mix in. Without any local Python the
|
||||
//! server gets `extraPaths` alone and its bundled typeshed standard library.
|
||||
//!
|
||||
//! The snapshot is read-only: packages change in the image (requirements, Dockerfile)
|
||||
//! and a rebuilt image gets a new snapshot. Taking one runs the docker CLI (`ide-host`).
|
||||
|
||||
use crate::apply::merge_init_options;
|
||||
use crate::exec::{query_in, which};
|
||||
use crate::libraries::{LibraryGroup, LibraryRoot};
|
||||
use crate::python::PythonPackage;
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use ide_language::LanguageServerConfig;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Run in the container with `python -c`: prints [`PROBE_MARKER`] and a JSON
|
||||
/// [`Probe`] (an entrypoint may print more around it).
|
||||
pub const PROBE: &str = "import json,os,sys,sysconfig\n\
|
||||
p=sysconfig.get_paths()\n\
|
||||
print('OPENIDEA-PYTHON '+json.dumps({'version':'%d.%d.%d'%sys.version_info[:3],\
|
||||
'executable':sys.executable,'stdlib':[p.get('stdlib'),p.get('platstdlib')],\
|
||||
'path':[x for x in sys.path if x and os.path.isdir(x)]}))";
|
||||
const PROBE_MARKER: &str = "OPENIDEA-PYTHON ";
|
||||
|
||||
/// What [`PROBE`] prints about the container's interpreter.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Probe {
|
||||
/// `3.12.7`.
|
||||
pub version: String,
|
||||
pub executable: String,
|
||||
/// `stdlib` and `platstdlib` of `sysconfig`.
|
||||
pub stdlib: Vec<Option<String>>,
|
||||
/// The folders of `sys.path`, in order.
|
||||
pub path: Vec<String>,
|
||||
}
|
||||
|
||||
pub fn parse_probe(out: &str) -> IdeResult<Probe> {
|
||||
let json = out
|
||||
.lines()
|
||||
.rev()
|
||||
.find_map(|l| l.trim().strip_prefix(PROBE_MARKER))
|
||||
.ok_or_else(|| IdeError::internal(format!("the container's Python printed no paths: {}", out.trim())))?;
|
||||
serde_json::from_str(json).map_err(|e| IdeError::internal(format!("cannot read the container's Python paths: {e}")))
|
||||
}
|
||||
|
||||
/// The container folders a snapshot copies: the `sys.path` folders outside the standard
|
||||
/// library (its stubs come with the server) and outside the project, which is here already.
|
||||
/// A folder inside another one of the list is copied with it.
|
||||
pub fn folders_to_copy(probe: &Probe, container_root: &str) -> Vec<String> {
|
||||
let under = |path: &str, dir: &str| {
|
||||
let dir = dir.trim_end_matches('/');
|
||||
path == dir || path.starts_with(&format!("{dir}/")) || dir.is_empty()
|
||||
};
|
||||
let stdlib: Vec<&str> = probe.stdlib.iter().flatten().map(String::as_str).collect();
|
||||
// `site-packages` lives inside the standard library's folder.
|
||||
let packages = |path: &str| path.split('/').any(|c| c == "site-packages" || c == "dist-packages");
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for path in &probe.path {
|
||||
let path = path.trim_end_matches('/');
|
||||
if !path.starts_with('/')
|
||||
|| (stdlib.iter().any(|s| under(path, s)) && !packages(path))
|
||||
|| (!container_root.trim().is_empty() && under(path, container_root))
|
||||
|| out.iter().any(|o| under(path, o))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
out.retain(|o| !under(o, path));
|
||||
out.push(path.to_owned());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Where a snapshot in `dir` keeps its copy of the container folder `container`.
|
||||
pub fn local_folder(dir: &Path, container: &str) -> PathBuf {
|
||||
container.split('/').filter(|c| !c.is_empty() && *c != "." && *c != "..").fold(dir.join("fs"), |p, c| p.join(c))
|
||||
}
|
||||
|
||||
/// `snapshot.json` of a snapshot folder.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct Snapshot {
|
||||
/// `sha256:…` of the image the folders were copied from.
|
||||
pub image: String,
|
||||
pub probe: Probe,
|
||||
/// The copied container folders ([`local_folder`] has them here).
|
||||
pub folders: Vec<String>,
|
||||
/// The neutral interpreter given to the language server; `None` without a local Python.
|
||||
pub neutral_python: Option<PathBuf>,
|
||||
}
|
||||
|
||||
const SNAPSHOT_FILE: &str = "snapshot.json";
|
||||
/// Names the snapshot folder in use, in the target's folder.
|
||||
const CURRENT_FILE: &str = "current";
|
||||
|
||||
/// A target's snapshots: `<cache>/<target>/<image>/`.
|
||||
pub fn target_dir(cache: &Path, target_id: &str) -> PathBuf {
|
||||
cache.join(dir_name(target_id))
|
||||
}
|
||||
|
||||
/// The folder name of an image id or target id.
|
||||
pub fn dir_name(id: &str) -> String {
|
||||
let id = id.strip_prefix("sha256:").map(|hex| &hex[..hex.len().min(16)]).unwrap_or(id);
|
||||
let name: String =
|
||||
id.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' }).collect();
|
||||
if name.is_empty() { "_".into() } else { name }
|
||||
}
|
||||
|
||||
/// The snapshot in use for the target whose folder is `target_dir`, with its folder.
|
||||
pub fn current(target_dir: &Path) -> Option<(PathBuf, Snapshot)> {
|
||||
let name = std::fs::read_to_string(target_dir.join(CURRENT_FILE)).ok()?;
|
||||
let dir = target_dir.join(dir_name(name.trim()));
|
||||
read(&dir).map(|s| (dir, s))
|
||||
}
|
||||
|
||||
/// The complete snapshot in `dir`, if there is one.
|
||||
pub fn read(dir: &Path) -> Option<Snapshot> {
|
||||
serde_json::from_str(&std::fs::read_to_string(dir.join(SNAPSHOT_FILE)).ok()?).ok()
|
||||
}
|
||||
|
||||
/// Writes `snapshot.json` (last: a folder without it is incomplete).
|
||||
pub fn write(dir: &Path, snapshot: &Snapshot) -> IdeResult<()> {
|
||||
let json = serde_json::to_string_pretty(snapshot).map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
std::fs::write(dir.join(SNAPSHOT_FILE), json)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Makes the snapshot of `image` the one in use and removes the target's other snapshots.
|
||||
pub fn make_current(target_dir: &Path, image: &str) -> IdeResult<()> {
|
||||
let keep = dir_name(image);
|
||||
std::fs::write(target_dir.join(CURRENT_FILE), &keep)?;
|
||||
for entry in std::fs::read_dir(target_dir)?.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir()
|
||||
&& entry.file_name().to_string_lossy() != keep.as_str()
|
||||
&& let Err(e) = std::fs::remove_dir_all(&path)
|
||||
{
|
||||
tracing::warn!("cannot remove the old Python snapshot {}: {e}", path.display());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The local folders of a snapshot that exist.
|
||||
pub fn local_folders(dir: &Path, snapshot: &Snapshot) -> Vec<PathBuf> {
|
||||
snapshot.folders.iter().map(|f| local_folder(dir, f)).filter(|p| p.is_dir()).collect()
|
||||
}
|
||||
|
||||
/// basedpyright / pyright: the neutral interpreter and the snapshot's folders as
|
||||
/// `extraPaths`. Without a snapshot yet the server only loses the local interpreter.
|
||||
pub fn configure_server(cfg: &mut LanguageServerConfig, snapshot: Option<(&Path, &Snapshot)>) {
|
||||
let section = match cfg.name.as_str() {
|
||||
"basedpyright" => "basedpyright",
|
||||
"pyright" => "python",
|
||||
_ => return,
|
||||
};
|
||||
let Some((dir, snapshot)) = snapshot else { return };
|
||||
if let Some(python) = snapshot.neutral_python.as_deref().filter(|p| p.is_file()) {
|
||||
merge_init_options(cfg, json!({ "python": { "pythonPath": python.to_string_lossy() } }));
|
||||
if let Some(venv) = crate::venv_of(python) {
|
||||
cfg.env.insert("VIRTUAL_ENV".into(), venv.to_string_lossy().into_owned());
|
||||
}
|
||||
}
|
||||
let paths: Vec<String> = local_folders(dir, snapshot).iter().map(|p| p.to_string_lossy().into_owned()).collect();
|
||||
merge_init_options(cfg, json!({ "settings": { section: { "analysis": { "extraPaths": paths } } } }));
|
||||
}
|
||||
|
||||
/// External Libraries: the snapshot's folders, named after the target.
|
||||
pub fn library_group(target_name: &str, dir: &Path, snapshot: &Snapshot) -> Option<LibraryGroup> {
|
||||
let short = snapshot.probe.version.split('.').take(2).collect::<Vec<_>>().join(".");
|
||||
let roots: Vec<LibraryRoot> = snapshot
|
||||
.folders
|
||||
.iter()
|
||||
.map(|f| (f, local_folder(dir, f)))
|
||||
.filter(|(_, p)| p.is_dir())
|
||||
.map(|(f, p)| LibraryRoot { name: f.rsplit('/').next().unwrap_or(f).to_owned(), path: p.into() })
|
||||
.collect();
|
||||
(!roots.is_empty()).then(|| LibraryGroup {
|
||||
name: format!("Python {short} ({target_name})"),
|
||||
kind: "python".into(),
|
||||
roots,
|
||||
})
|
||||
}
|
||||
|
||||
/// The packages of a snapshot, from the `*.dist-info` / `*.egg-info` metadata it copied.
|
||||
pub fn packages(dir: &Path, snapshot: &Snapshot) -> Vec<PythonPackage> {
|
||||
let mut out: Vec<PythonPackage> = Vec::new();
|
||||
for folder in local_folders(dir, snapshot) {
|
||||
let Ok(entries) = std::fs::read_dir(&folder) else { continue };
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
let path = entry.path();
|
||||
let metadata = if name.ends_with(".dist-info") {
|
||||
path.join("METADATA")
|
||||
} else if name.ends_with(".egg-info") {
|
||||
if path.is_dir() { path.join("PKG-INFO") } else { path }
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
let Some(package) = std::fs::read_to_string(&metadata).ok().and_then(|m| package_of(&m)) else {
|
||||
continue;
|
||||
};
|
||||
if !out.iter().any(|p| p.name.eq_ignore_ascii_case(&package.name)) {
|
||||
out.push(package);
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort_by_key(|p| p.name.to_lowercase());
|
||||
out
|
||||
}
|
||||
|
||||
fn package_of(metadata: &str) -> Option<PythonPackage> {
|
||||
let header = |key: &str| {
|
||||
metadata
|
||||
.lines()
|
||||
.take_while(|l| !l.is_empty())
|
||||
.find_map(|l| l.strip_prefix(key).and_then(|v| v.strip_prefix(':')))
|
||||
.map(|v| v.trim().to_owned())
|
||||
};
|
||||
Some(PythonPackage { name: header("Name")?, version: header("Version").unwrap_or_default() })
|
||||
}
|
||||
|
||||
/// The neutral interpreter for a container Python of `version`: an empty virtualenv in
|
||||
/// `cache`, made from a local Python of the same minor version when there is one. `None`
|
||||
/// when this machine has no Python at all.
|
||||
pub fn neutral_python(cache: &Path, version: &str) -> Option<PathBuf> {
|
||||
neutral_python_with(cache, version, which)
|
||||
}
|
||||
|
||||
pub(crate) fn neutral_python_with(
|
||||
cache: &Path,
|
||||
version: &str,
|
||||
which: impl Fn(&str) -> Option<PathBuf>,
|
||||
) -> Option<PathBuf> {
|
||||
let short = version.split('.').take(2).collect::<Vec<_>>().join(".");
|
||||
let named = (!short.is_empty()).then(|| format!("python{short}"));
|
||||
let (base, label) = match named.as_deref().and_then(|n| which(n).map(|p| (p, short.clone()))) {
|
||||
Some(found) => found,
|
||||
None => (which("python3").or_else(|| which("python"))?, "default".to_owned()),
|
||||
};
|
||||
let venv = cache.join("neutral").join(label);
|
||||
let python = venv.join(crate::bin_dir()).join(crate::exe("python"));
|
||||
if python.is_file() && venv.join("pyvenv.cfg").is_file() {
|
||||
return Some(python);
|
||||
}
|
||||
std::fs::create_dir_all(&venv).ok()?;
|
||||
let location = venv.to_string_lossy().into_owned();
|
||||
query_in(&base, &["-m", "venv", "--without-pip", &location], None, &[], Duration::from_secs(60));
|
||||
python.is_file().then_some(python)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::tests::server;
|
||||
|
||||
fn probe() -> Probe {
|
||||
Probe {
|
||||
version: "3.12.7".into(),
|
||||
executable: "/usr/local/bin/python3".into(),
|
||||
stdlib: vec![Some("/usr/local/lib/python3.12".into()), Some("/usr/local/lib/python3.12".into())],
|
||||
path: [
|
||||
"/workspaces/app",
|
||||
"/workspaces/app/src",
|
||||
"/usr/local/lib/python3.12",
|
||||
"/usr/local/lib/python3.12/lib-dynload",
|
||||
"/opt/lib/extra",
|
||||
"/usr/local/lib/python3.12/site-packages",
|
||||
"/usr/local/lib/python3.12/site-packages/setuptools/_vendor",
|
||||
"relative",
|
||||
]
|
||||
.map(String::from)
|
||||
.to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copies_library_folders_outside_the_stdlib_and_the_project() {
|
||||
assert_eq!(
|
||||
folders_to_copy(&probe(), "/workspaces/app"),
|
||||
["/opt/lib/extra", "/usr/local/lib/python3.12/site-packages"]
|
||||
);
|
||||
let mut p = probe();
|
||||
p.path.insert(0, "/opt/lib/extra/inner".into());
|
||||
assert_eq!(folders_to_copy(&p, "/workspaces/app")[0], "/opt/lib/extra", "the outer folder replaces the inner");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_the_probe_after_other_output() {
|
||||
let line = format!("{PROBE_MARKER}{}", serde_json::to_string(&probe()).unwrap());
|
||||
let out = format!("waiting for the entrypoint\n{line}\nbye\n");
|
||||
assert_eq!(parse_probe(&out).unwrap(), probe());
|
||||
assert!(parse_probe("python3: not found").unwrap_err().message.contains("not found"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirrors_container_paths_and_names_folders() {
|
||||
let dir = Path::new("snap");
|
||||
assert_eq!(
|
||||
local_folder(dir, "/usr/local/lib/../site-packages"),
|
||||
Path::new("snap").join("fs").join("usr").join("local").join("lib").join("site-packages")
|
||||
);
|
||||
assert_eq!(dir_name("sha256:0123456789abcdef0123"), "0123456789abcdef");
|
||||
assert_eq!(dir_name("target/../x y"), "target____x_y");
|
||||
}
|
||||
|
||||
fn snapshot_in(dir: &Path, neutral: Option<PathBuf>) -> Snapshot {
|
||||
let snapshot = Snapshot {
|
||||
image: "sha256:abc".into(),
|
||||
probe: probe(),
|
||||
folders: vec!["/usr/local/lib/python3.12/site-packages".into(), "/gone".into()],
|
||||
neutral_python: neutral,
|
||||
};
|
||||
let site = local_folder(dir, &snapshot.folders[0]);
|
||||
std::fs::create_dir_all(site.join("Django-5.1.3.dist-info")).unwrap();
|
||||
std::fs::write(
|
||||
site.join("Django-5.1.3.dist-info/METADATA"),
|
||||
"Metadata-Version: 2.1\nName: Django\nVersion: 5.1.3\n\nName: not a header\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::create_dir_all(site.join("asgiref-3.8.1.dist-info")).unwrap();
|
||||
std::fs::write(site.join("asgiref-3.8.1.dist-info/METADATA"), "Name: asgiref\nVersion: 3.8.1\n").unwrap();
|
||||
std::fs::write(site.join("legacy.egg-info"), "Name: legacy\nVersion: 0.1\n").unwrap();
|
||||
snapshot
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lists_packages_and_library_roots_of_a_snapshot() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let snapshot = snapshot_in(tmp.path(), None);
|
||||
let names: Vec<String> =
|
||||
packages(tmp.path(), &snapshot).into_iter().map(|p| format!("{} {}", p.name, p.version)).collect();
|
||||
assert_eq!(names, ["asgiref 3.8.1", "Django 5.1.3", "legacy 0.1"]);
|
||||
let group = library_group("web", tmp.path(), &snapshot).unwrap();
|
||||
assert_eq!(group.name, "Python 3.12 (web)");
|
||||
assert_eq!(group.roots.len(), 1, "a folder that was not copied is left out");
|
||||
assert_eq!(group.roots[0].name, "site-packages");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_snapshot_switches_and_drops_old_ones() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let target = target_dir(tmp.path(), "web");
|
||||
for image in ["sha256:old", "sha256:new"] {
|
||||
let dir = target.join(dir_name(image));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
write(&dir, &Snapshot { image: image.into(), ..Default::default() }).unwrap();
|
||||
}
|
||||
std::fs::create_dir_all(target.join("new.partial")).unwrap();
|
||||
assert!(current(&target).is_none());
|
||||
make_current(&target, "sha256:new").unwrap();
|
||||
let (dir, snapshot) = current(&target).unwrap();
|
||||
assert_eq!((dir, snapshot.image.as_str()), (target.join("new"), "sha256:new"));
|
||||
assert!(!target.join("old").exists() && !target.join("new.partial").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn basedpyright_gets_the_neutral_interpreter_and_extra_paths() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let venv = tmp.path().join("neutral");
|
||||
let python = venv.join(crate::bin_dir()).join(crate::exe("python"));
|
||||
std::fs::create_dir_all(python.parent().unwrap()).unwrap();
|
||||
std::fs::write(&python, "").unwrap();
|
||||
std::fs::write(venv.join("pyvenv.cfg"), "include-system-site-packages = false\n").unwrap();
|
||||
let snapshot = snapshot_in(tmp.path(), Some(python.clone()));
|
||||
let site = local_folder(tmp.path(), &snapshot.folders[0]).to_string_lossy().into_owned();
|
||||
|
||||
let mut cfg = server("basedpyright");
|
||||
configure_server(&mut cfg, Some((tmp.path(), &snapshot)));
|
||||
let opts = serde_json::to_value(cfg.initialization_options.as_ref().unwrap()).unwrap();
|
||||
assert_eq!(opts["python"]["pythonPath"], python.to_string_lossy().as_ref());
|
||||
assert_eq!(opts["settings"]["basedpyright"]["analysis"]["extraPaths"], json!([site]));
|
||||
assert_eq!(cfg.env["VIRTUAL_ENV"], venv.to_string_lossy().as_ref());
|
||||
|
||||
let mut cfg = server("pyright");
|
||||
configure_server(&mut cfg, Some((tmp.path(), &snapshot)));
|
||||
let opts = serde_json::to_value(cfg.initialization_options.as_ref().unwrap()).unwrap();
|
||||
assert_eq!(opts["settings"]["python"]["analysis"]["extraPaths"], json!([site]));
|
||||
|
||||
let mut cfg = server("ruff");
|
||||
configure_server(&mut cfg, Some((tmp.path(), &snapshot)));
|
||||
assert!(cfg.initialization_options.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_local_python_only_extra_paths_reach_the_server() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
assert_eq!(neutral_python_with(tmp.path(), "3.12.7", |_| None), None);
|
||||
let snapshot = snapshot_in(tmp.path(), None);
|
||||
let mut cfg = server("basedpyright");
|
||||
configure_server(&mut cfg, Some((tmp.path(), &snapshot)));
|
||||
let opts = serde_json::to_value(cfg.initialization_options.as_ref().unwrap()).unwrap();
|
||||
assert!(opts.get("python").is_none(), "no pythonPath: the server falls back to its bundled typeshed");
|
||||
assert_eq!(opts["settings"]["basedpyright"]["analysis"]["extraPaths"].as_array().unwrap().len(), 1);
|
||||
assert!(!cfg.env.contains_key("VIRTUAL_ENV"));
|
||||
}
|
||||
|
||||
// A real `python3 -m venv`: runs where a Python is installed (the dev container, CI).
|
||||
#[test]
|
||||
fn neutral_python_is_an_empty_virtualenv() {
|
||||
let Some(base) = which("python3") else { return };
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let which = |name: &str| (name == "python3").then(|| base.clone());
|
||||
let python = neutral_python_with(tmp.path(), "0.0.1", which).unwrap();
|
||||
assert!(python.starts_with(tmp.path().join("neutral").join("default")));
|
||||
let cfg = std::fs::read_to_string(crate::venv_of(&python).unwrap().join("pyvenv.cfg")).unwrap();
|
||||
assert!(cfg.contains("include-system-site-packages = false"));
|
||||
assert_eq!(neutral_python_with(tmp.path(), "0.0.1", which), Some(python), "reused");
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ mod angular;
|
||||
pub mod apply;
|
||||
pub mod arduino;
|
||||
mod astro;
|
||||
pub mod container_python;
|
||||
mod discover;
|
||||
mod exec;
|
||||
pub mod go;
|
||||
|
||||
@@ -254,6 +254,10 @@ fn module_settings(root: &Path, settings: &Settings) -> Settings {
|
||||
&mut l.go.gopath,
|
||||
&mut l.java.jdk,
|
||||
] {
|
||||
// `target:<id>` names a run target, not a path.
|
||||
if value.trim().starts_with(ide_core::settings::PythonSettings::TARGET_PREFIX) {
|
||||
continue;
|
||||
}
|
||||
if let Some(path) = crate::apply::setting_path(root, value) {
|
||||
*value = path.to_string_lossy().into_owned();
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ fn fake_venv(root: &Path, name: &str) -> std::path::PathBuf {
|
||||
py
|
||||
}
|
||||
|
||||
fn server(name: &str) -> LanguageServerConfig {
|
||||
pub(crate) fn server(name: &str) -> LanguageServerConfig {
|
||||
LanguageServerConfig {
|
||||
name: name.into(),
|
||||
command: name.into(),
|
||||
@@ -42,6 +42,7 @@ fn server(name: &str) -> LanguageServerConfig {
|
||||
tsserver_project: false,
|
||||
tsserver_project_extras: Default::default(),
|
||||
resync_on_configuration: false,
|
||||
display_name: None,
|
||||
pinned: None,
|
||||
}
|
||||
}
|
||||
@@ -188,6 +189,48 @@ fn pyright_gets_python_path_and_venv_env() {
|
||||
assert!(cfg.env["PATH"].starts_with(&*tmp.path().join(".venv").join(bin_dir()).to_string_lossy()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_run_target_interpreter_hides_local_pythons() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
fake_venv(tmp.path(), ".venv");
|
||||
let mut s = Settings::default();
|
||||
s.languages.python.interpreter = "target:web".into();
|
||||
assert_eq!(python_interpreter(tmp.path(), &s), None, "the project virtualenv is not the interpreter");
|
||||
assert_eq!(terminal_activation(tmp.path(), &s, "bash"), None);
|
||||
assert!(!tool_install_environment(tmp.path(), &s).commands.contains_key("python"));
|
||||
let mut cfg = server("basedpyright");
|
||||
configure_server(&mut cfg, tmp.path(), tmp.path(), &s);
|
||||
let opts = cfg.initialization_options.map(|o| serde_json::to_value(o).unwrap());
|
||||
assert!(opts.and_then(|o| o.pointer("/python/pythonPath").cloned()).is_none());
|
||||
assert!(!cfg.env.contains_key("VIRTUAL_ENV"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn python_configurations_run_on_the_interpreters_target() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
fake_venv(tmp.path(), ".venv");
|
||||
let mut s = Settings::default();
|
||||
s.languages.python.interpreter = "target:web".into();
|
||||
s.run.default_target = "box".into();
|
||||
let mut own = run("python", "python3", &["main.py"]);
|
||||
own.extras.target = Some(String::new());
|
||||
let mut configs = vec![
|
||||
run("python", "python3", &["manage.py", "runserver"]),
|
||||
run("pytest", "python3", &["-m", "pytest"]),
|
||||
run("npm", "npm", &["run", "dev"]),
|
||||
own,
|
||||
];
|
||||
apply_to_run_configs(&mut configs, tmp.path(), &s);
|
||||
assert_eq!(configs[0].extras.target.as_deref(), Some("web"));
|
||||
assert_eq!(configs[0].command, "python3", "the container resolves the interpreter");
|
||||
assert!(!configs[0].env.contains_key("VIRTUAL_ENV"));
|
||||
assert_eq!(configs[1].extras.target.as_deref(), Some("web"));
|
||||
assert_eq!(configs[2].extras.target.as_deref(), Some("box"), "other languages keep the default target");
|
||||
assert_eq!(configs[3].extras.target.as_deref(), Some(""), "a configuration's own choice wins");
|
||||
let derived = without_derived(&configs[0], tmp.path(), &s);
|
||||
assert_eq!(derived.extras.target, None, "the target is derived from the interpreter");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jdtls_vm_args() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -1387,6 +1430,7 @@ fn typescript_7_runs_its_own_language_server() {
|
||||
configure_server(&mut cfg, tmp.path(), tmp.path(), &s);
|
||||
assert_eq!(Path::new(&cfg.command), native, "{name}");
|
||||
assert_eq!(cfg.args, ["--lsp", "--stdio"]);
|
||||
assert_eq!(cfg.display_name.as_deref(), Some("TypeScript 7"));
|
||||
assert!(cfg.initialization_options.is_none());
|
||||
}
|
||||
// Picked as a directory (its `lib`, as for older versions), too.
|
||||
|
||||
@@ -96,4 +96,7 @@ Container target options:
|
||||
| **Run options** | Extra `docker run` options, for example `--network host`. |
|
||||
| **Run as the image's user, not the project folder's owner (Linux)** | On Linux the container runs as the owner of the project folder by default, so the files it writes stay yours. Tick this to keep the image's user. |
|
||||
|
||||
A container target can also be the project's Python interpreter, so the editor resolves the packages installed in the
|
||||
image; see [Python](/en/languages/python#a-docker-or-compose-run-target-as-the-interpreter).
|
||||
|
||||
See [Run configurations](/en/guide/run) for run targets in general, including SSH targets.
|
||||
@@ -49,6 +49,17 @@ The selected interpreter is passed to basedpyright and Pyright (`python.pythonPa
|
||||
|
||||
The **Python Interpreter** item in the status bar shows the current interpreter. Click it to switch, or to open **Add New Interpreter…** and **Interpreter Settings…**.
|
||||
|
||||
### A Docker or Compose run target as the interpreter
|
||||
|
||||
When the project's dependencies are installed only in an image, choose a Docker or Docker Compose [run target](/en/guide/remote/containers#docker-and-docker-compose-run-targets) as the interpreter: the interpreter list and the status bar item show those targets next to the local interpreters.
|
||||
|
||||
- **Editor.** The IDE runs the target's Python once in a new container (the image's entrypoint and the service's dependencies are skipped) and copies the folders of its `sys.path` outside the standard library and the project, such as `site-packages`, to its cache with `docker cp`. basedpyright and Pyright get the copies as `extraPaths`, with an empty virtualenv the IDE creates as their interpreter, so local packages don't mix in. Without any Python on this machine they get `extraPaths` alone and use their bundled standard library stubs. The copy is shown under **External Libraries**.
|
||||
- **Rebuilds.** The copy belongs to the image: when the project opens or the interpreter is chosen, a rebuilt image gets a new copy. The refresh button on the packages list copies again.
|
||||
- **Run.** Python run configurations without a run target of their own run on that target.
|
||||
- **Packages.** The list comes from the copy and is read-only: change the image or its requirements, rebuild it, and copy again. Nothing is installed into the container.
|
||||
|
||||
A `pyrightconfig.json` or a `[tool.basedpyright]` / `[tool.pyright]` section with its own `extraPaths` replaces the copied paths. The interpreter's Python version is the empty virtualenv's, from a local Python of the same version when there is one.
|
||||
|
||||
### Creating an environment
|
||||
|
||||
On **Settings › Languages & Frameworks › Python Interpreter**, choose **Add Python Interpreter** and pick the **Interpreter type**:
|
||||
|
||||
@@ -78,4 +78,6 @@ OpenIDEA 安装的语言服务器和调试器会装进容器,位于容器用
|
||||
| **运行选项** | 额外的 `docker run` 参数,例如 `--network host`。 |
|
||||
| **以镜像的用户运行,而不是项目文件夹的所有者(Linux)** | 在 Linux 上,容器默认以项目文件夹所有者的身份运行,写出的文件仍归你所有。勾选后保留镜像自带的用户。 |
|
||||
|
||||
容器类目标也可以作为项目的 Python 解释器,让编辑器识别镜像中安装的软件包,见 [Python](/zh/languages/python#以-docker-或-compose-运行目标作为解释器)。
|
||||
|
||||
运行目标的整体说明(包括 SSH 目标)见[运行配置](/zh/guide/run)。
|
||||
@@ -49,6 +49,17 @@ Ruff 只为位于 Ruff 配置之下的文件启动:`ruff.toml`、`.ruff.toml`
|
||||
|
||||
状态栏上的 **Python 解释器** 显示当前解释器,点击可以切换,也可以打开 **添加新的解释器…** 和 **解释器设置…**。
|
||||
|
||||
### 以 Docker 或 Compose 运行目标作为解释器
|
||||
|
||||
如果项目的依赖只安装在镜像里,可以把 Docker 或 Docker Compose [运行目标](/zh/guide/remote/containers#docker-和-docker-compose-运行目标)选为解释器:解释器列表和状态栏项会把这些目标列在本地解释器旁边。
|
||||
|
||||
- **编辑器。** IDE 在新容器中运行一次目标的 Python(跳过镜像的入口点和服务的依赖),然后用 `docker cp` 把其 `sys.path` 中标准库和项目以外的目录(例如 `site-packages`)复制到缓存。basedpyright 和 Pyright 以 `extraPaths` 的形式获得这些副本,解释器则是 IDE 创建的空虚拟环境,因此不会混入本地软件包。本机完全没有 Python 时,只传 `extraPaths`,并使用它们自带的标准库存根。副本显示在 **外部库** 下。
|
||||
- **重新构建。** 副本与镜像对应:项目打开或选择解释器时,重新构建过的镜像会得到新的副本。软件包列表上的刷新按钮会重新复制。
|
||||
- **运行。** 没有自己运行目标的 Python 运行配置会在该目标上运行。
|
||||
- **软件包。** 列表来自副本,并且只读:请修改镜像或其依赖要求,重新构建,再重新复制。不会向容器中安装任何内容。
|
||||
|
||||
如果 `pyrightconfig.json` 或 `[tool.basedpyright]` / `[tool.pyright]` 设置了自己的 `extraPaths`,它们会取代复制的路径。解释器的 Python 版本取自空虚拟环境;本机有相同版本的 Python 时会用它来创建。
|
||||
|
||||
### 创建环境
|
||||
|
||||
在 **设置 › 语言和框架 › Python 解释器** 中选择 **添加 Python 解释器**,再选 **解释器类型**:
|
||||
|
||||
@@ -244,6 +244,18 @@ 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 responses a debugged page gets in place of the server's, by a part of the URL; none
|
||||
/// switches the replacing off.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn debug_js_response_overrides(
|
||||
state: State<'_, AppState>,
|
||||
session: DebugSessionId,
|
||||
rules: Vec<ide_dap::JsResponseRule>,
|
||||
) -> IdeResult<()> {
|
||||
ide_host::ops::debug::debug_js_response_overrides(&state.host, "", session, rules).await
|
||||
}
|
||||
|
||||
/// The body of a response a debugged page received.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
@@ -267,6 +279,23 @@ pub async fn debug_js_custom_breakpoints(
|
||||
ide_host::ops::debug::debug_js_custom_breakpoints(&state.host, "", session, ids, xhr).await
|
||||
}
|
||||
|
||||
/// Starts recording a debugged page's CPU profile.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn debug_js_start_profile(state: State<'_, AppState>, session: DebugSessionId) -> IdeResult<()> {
|
||||
ide_host::ops::debug::debug_js_start_profile(&state.host, "", session).await
|
||||
}
|
||||
|
||||
/// Ends the recording: a directory holding the profile as `page.cpuprofile`.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn debug_js_stop_profile(
|
||||
state: State<'_, AppState>,
|
||||
session: DebugSessionId,
|
||||
) -> IdeResult<ide_core::IpcPath> {
|
||||
ide_host::ops::debug::debug_js_stop_profile(&state.host, "", session).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn debug_capabilities(state: State<'_, AppState>, session: DebugSessionId) -> IdeResult<DebugCapabilities> {
|
||||
|
||||
@@ -477,6 +477,8 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
sdk::sdk_python_interpreter,
|
||||
sdk::sdk_python_effective,
|
||||
sdk::sdk_php_effective,
|
||||
sdk::sdk_container_python,
|
||||
sdk::sdk_container_python_refresh,
|
||||
sdk::sdk_php_xdebug,
|
||||
sdk::sdk_php_language_level,
|
||||
sdk::sdk_php_composer_install,
|
||||
@@ -531,6 +533,9 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
debug::debug_capabilities,
|
||||
debug::debug_js_custom_breakpoints,
|
||||
debug::debug_js_response_body,
|
||||
debug::debug_js_response_overrides,
|
||||
debug::debug_js_start_profile,
|
||||
debug::debug_js_stop_profile,
|
||||
debug::debug_set_function_breakpoints,
|
||||
debug::debug_set_data_breakpoints,
|
||||
debug::debug_data_breakpoint_info,
|
||||
|
||||
@@ -38,6 +38,26 @@ pub fn sdk_python_effective(
|
||||
ide_host::ops::sdk::sdk_python_effective(&state.host, window.label(), near)
|
||||
}
|
||||
|
||||
/// The project interpreter when it is a Docker or Compose run target's, with its snapshot.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn sdk_container_python(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
) -> IdeResult<Option<ide_host::ops::sdk::ContainerPython>> {
|
||||
ide_host::ops::sdk::sdk_container_python(&state.host, window.label())
|
||||
}
|
||||
|
||||
/// Copies the container's Python libraries again (after the image changed).
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn sdk_container_python_refresh(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
) -> IdeResult<Option<ide_host::ops::sdk::ContainerPython>> {
|
||||
ide_host::ops::sdk::sdk_container_python_refresh(&state.host, window.label()).await
|
||||
}
|
||||
|
||||
/// The PHP interpreter chosen in Settings › Languages & Frameworks › PHP; `None`: the `php` on `PATH`.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
|
||||
+79
-3
@@ -1038,6 +1038,40 @@ export const commands = {
|
||||
sdkPythonEffective: (near: string | null) => typedError<string | null, IdeError>(__TAURI_INVOKE("sdk_python_effective", { near })),
|
||||
/** The PHP interpreter chosen in Settings › Languages & Frameworks › PHP; `None`: the `php` on `PATH`. */
|
||||
sdkPhpEffective: () => typedError<string | null, IdeError>(__TAURI_INVOKE("sdk_php_effective")),
|
||||
/** The project interpreter when it is a Docker or Compose run target's, with its snapshot. */
|
||||
sdkContainerPython: () => typedError<{
|
||||
/** The run target's id. */
|
||||
target: string,
|
||||
/** Its name; empty when no run target has the id. */
|
||||
name: string,
|
||||
/** The Python version of the snapshot in use; `None`: no snapshot yet. */
|
||||
version: string | null,
|
||||
/** The image the snapshot was copied from. */
|
||||
image: string | null,
|
||||
/** The copied library folders on this machine. */
|
||||
folders: IpcPath[],
|
||||
/** Packages of the snapshot (read-only). */
|
||||
packages: PythonPackage[],
|
||||
/** A snapshot is being taken. */
|
||||
updating: boolean,
|
||||
} | null, IdeError>(__TAURI_INVOKE("sdk_container_python")),
|
||||
/** Copies the container's Python libraries again (after the image changed). */
|
||||
sdkContainerPythonRefresh: () => typedError<{
|
||||
/** The run target's id. */
|
||||
target: string,
|
||||
/** Its name; empty when no run target has the id. */
|
||||
name: string,
|
||||
/** The Python version of the snapshot in use; `None`: no snapshot yet. */
|
||||
version: string | null,
|
||||
/** The image the snapshot was copied from. */
|
||||
image: string | null,
|
||||
/** The copied library folders on this machine. */
|
||||
folders: IpcPath[],
|
||||
/** Packages of the snapshot (read-only). */
|
||||
packages: PythonPackage[],
|
||||
/** A snapshot is being taken. */
|
||||
updating: boolean,
|
||||
} | null, IdeError>(__TAURI_INVOKE("sdk_container_python_refresh")),
|
||||
/**
|
||||
* The version of the PHP interpreter `php` and of the Xdebug it loads, run in `cwd` (default: the
|
||||
* project root).
|
||||
@@ -1225,6 +1259,15 @@ export const commands = {
|
||||
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 })),
|
||||
/**
|
||||
* The responses a debugged page gets in place of the server's, by a part of the URL; none
|
||||
* switches the replacing off.
|
||||
*/
|
||||
debugJsResponseOverrides: (session: DebugSessionId, rules: JsResponseRule[]) => typedError<null, IdeError>(__TAURI_INVOKE("debug_js_response_overrides", { session, rules })),
|
||||
/** Starts recording a debugged page's CPU profile. */
|
||||
debugJsStartProfile: (session: DebugSessionId) => typedError<null, IdeError>(__TAURI_INVOKE("debug_js_start_profile", { session })),
|
||||
/** Ends the recording: a directory holding the profile as `page.cpuprofile`. */
|
||||
debugJsStopProfile: (session: DebugSessionId) => typedError<IpcPath, IdeError>(__TAURI_INVOKE("debug_js_stop_profile", { session })),
|
||||
/** 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`. */
|
||||
@@ -2758,7 +2801,7 @@ export type BuildResult = {
|
||||
messages: BuildMessage[],
|
||||
};
|
||||
|
||||
export type BuildSystem = "maven" | "gradle" | "cargo" | "go" | "typescript" | "cmake";
|
||||
export type BuildSystem = "maven" | "gradle" | "cargo" | "go" | "typescript" | "cmake" | "dotnet";
|
||||
|
||||
export type BuildTool = "maven" | "gradle";
|
||||
|
||||
@@ -3380,6 +3423,24 @@ export type ConsoleSettings = {
|
||||
|
||||
export type ContainerOp = "start" | "stop" | "restart" | "pause" | "unpause" | "kill";
|
||||
|
||||
/** The project interpreter when it is a run target's. */
|
||||
export type ContainerPython = {
|
||||
/** The run target's id. */
|
||||
target: string,
|
||||
/** Its name; empty when no run target has the id. */
|
||||
name: string,
|
||||
/** The Python version of the snapshot in use; `None`: no snapshot yet. */
|
||||
version: string | null,
|
||||
/** The image the snapshot was copied from. */
|
||||
image: string | null,
|
||||
/** The copied library folders on this machine. */
|
||||
folders: IpcPath[],
|
||||
/** Packages of the snapshot (read-only). */
|
||||
packages: PythonPackage[],
|
||||
/** A snapshot is being taken. */
|
||||
updating: boolean,
|
||||
};
|
||||
|
||||
/** ContentAction data exchanged with the IDE. */
|
||||
export type ContentAction = {
|
||||
id: string,
|
||||
@@ -5994,7 +6055,9 @@ cancellable: boolean } |
|
||||
/** Project indexing state changed (dumb mode on/off). */
|
||||
{ type: "indexState"; indexing: boolean; files: number; symbols: number } |
|
||||
/** Git status / branch information should be refreshed. */
|
||||
{ type: "vcsChanged"; root: IpcPath } | { type: "languageServer"; server: ServerId; name: string; status: ServerStatus; message: string | null } |
|
||||
{ type: "vcsChanged"; root: IpcPath } | { type: "languageServer"; server: ServerId; name: string;
|
||||
/** What the user sees as the server when another program serves in its place. */
|
||||
label?: string | null; status: ServerStatus; message: string | null } |
|
||||
/** A running server lacks a capability; the UI offers the managed tool as a replacement. */
|
||||
{ type: "languageServerWarning"; name: string; message: string; installTool: string } |
|
||||
/**
|
||||
@@ -6515,6 +6578,14 @@ export type JsResponseBody = {
|
||||
truncated: boolean,
|
||||
};
|
||||
|
||||
/** A response given in place of the server's to every request whose URL contains `url_contains`. */
|
||||
export type JsResponseRule = {
|
||||
urlContains: string,
|
||||
status: number,
|
||||
contentType: string,
|
||||
body: string,
|
||||
};
|
||||
|
||||
/** 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. */
|
||||
@@ -8717,7 +8788,10 @@ export type PythonPackage = {
|
||||
};
|
||||
|
||||
export type PythonSettings = {
|
||||
/** Project interpreter executable; empty = auto-detect (project venv, else `python3`). */
|
||||
/**
|
||||
* Project interpreter executable; empty = auto-detect (project venv, else `python3`);
|
||||
* `target:<run-target-id>` = the Python of a Docker or Compose run target.
|
||||
*/
|
||||
interpreter?: string,
|
||||
/** Activate the interpreter's virtualenv in new terminal sessions. */
|
||||
activateVirtualenv?: boolean,
|
||||
@@ -9458,6 +9532,8 @@ export type ServerInfo = {
|
||||
status: ServerStatus,
|
||||
/** OS process of the running instance (for memory attribution). */
|
||||
pid: number | null,
|
||||
/** What the user sees as the server when another program serves in its place. */
|
||||
label?: string | null,
|
||||
};
|
||||
|
||||
export type ServerStatus = "starting" | "ready" | "busy" | "crashed" | "stopped" | "missing" |
|
||||
|
||||
@@ -71,7 +71,7 @@ import {
|
||||
import { showHint, showPendingHint } from "./hint";
|
||||
import { outsideProjectReason } from "./outsideProject";
|
||||
import { caretAnchor, openPopup, updatePopup, type ListPopupItem } from "./popupStore";
|
||||
import { readManyFileLines, targetLabel } from "./files";
|
||||
import { readManyFileLines, targetLabel, withDocument } from "./files";
|
||||
import {
|
||||
documentLinks,
|
||||
followReference,
|
||||
@@ -85,6 +85,7 @@ import { featureBackoff } from "./availability";
|
||||
import { requestHover, showPhpConstructDocumentation } from "./hover";
|
||||
import { declarationSummary, renderCode } from "./markdown";
|
||||
import { enclosingType } from "@/editor/navigation/lineMarkers";
|
||||
import { projectPythonHierarchy, pythonImplementationTargets } from "@/editor/navigation/pythonHierarchy";
|
||||
import { editorPrefs } from "@/platform/editorSettings";
|
||||
import { isVirtualUri, virtualContainer } from "@/platform/virtualDocs";
|
||||
import { isAddCaretGesture } from "@/editor/core/cm/multiCaret";
|
||||
@@ -370,6 +371,30 @@ async function onDeclarationName(view: EditorView, offset: number): Promise<bool
|
||||
return isDeclarationName(at);
|
||||
}
|
||||
|
||||
/**
|
||||
* Go to Implementation targets for Python from the IDE's inheritance graph: its servers
|
||||
* declare no type hierarchy and analyse one root, so the graph also knows the attached
|
||||
* dependency project's implementations.
|
||||
*/
|
||||
async function pythonImplementations(doc: EditorDocContext, pos: Pos): Promise<DeclarationTarget[]> {
|
||||
if (doc.language !== "python") return [];
|
||||
const outline = (path: string) =>
|
||||
path === doc.path
|
||||
? lspRequest(() => commands.docOutline(doc.doc))
|
||||
: withDocument(path, (d) => lspRequest(() => commands.docOutline(d)));
|
||||
const hierarchy = await projectPythonHierarchy().catch(() => null);
|
||||
if (!hierarchy) return [];
|
||||
return pythonImplementationTargets(hierarchy, doc.path, pos, outline).catch(() => []);
|
||||
}
|
||||
|
||||
/** `more` after `first`, without a target whose declaration line is already there. */
|
||||
function mergeTargets(first: readonly DeclarationTarget[], more: readonly DeclarationTarget[]): DeclarationTarget[] {
|
||||
return dedupeLocations([
|
||||
...first,
|
||||
...more.filter((m) => !first.some((f) => f.path === m.path && f.range.start.line === m.range.start.line)),
|
||||
]);
|
||||
}
|
||||
|
||||
async function gotoResolved(
|
||||
view: EditorView,
|
||||
kind: GotoKind,
|
||||
@@ -419,6 +444,9 @@ async function gotoResolved(
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Python: its servers have no type hierarchy and see one root, so the IDE's inheritance
|
||||
// graph answers implementations too (merged with the server's, not replacing them).
|
||||
const graphTargets = kind === "implementation" ? pythonImplementations(doc, pos) : null;
|
||||
let serverDown = false;
|
||||
let streamed: ReturnType<typeof streamedImplementations> | null = null;
|
||||
try {
|
||||
@@ -429,32 +457,38 @@ async function gotoResolved(
|
||||
} catch (e) {
|
||||
// The chooser shows the first ones: closing it cancelled the search, or the rest failed.
|
||||
if (streamed && (await streamed.opened())) {
|
||||
await streamed.complete([]);
|
||||
await streamed.complete(graphTargets ? await graphTargets : []);
|
||||
return;
|
||||
}
|
||||
if (superseded()) return;
|
||||
if (isCancelled(e) || !declaration) {
|
||||
reportLspError(view, e, true);
|
||||
return;
|
||||
}
|
||||
// Web references and links need no server.
|
||||
if (
|
||||
(await gotoWebReference(view, doc, pos, offset, superseded)) ||
|
||||
(await followReferenceAt(view, doc, offset, superseded))
|
||||
)
|
||||
return;
|
||||
serverDown = isUnavailable(e);
|
||||
// Dumb mode: the server is not ready / failing — answer from the syntax tree.
|
||||
// An import path is not a name to look up: only its file matters.
|
||||
const fb = moduleName ? null : await gotoFallback(view, doc, pos);
|
||||
// Python: the graph answers implementations the server cannot (no type hierarchy).
|
||||
const graph = graphTargets ? await graphTargets : [];
|
||||
if (superseded()) return;
|
||||
if (!fb) {
|
||||
if (graph.length) locs = graph;
|
||||
else if (isCancelled(e) || !declaration) {
|
||||
reportLspError(view, e, true);
|
||||
return;
|
||||
} else {
|
||||
// Web references and links need no server.
|
||||
if (
|
||||
(await gotoWebReference(view, doc, pos, offset, superseded)) ||
|
||||
(await followReferenceAt(view, doc, offset, superseded))
|
||||
)
|
||||
return;
|
||||
serverDown = isUnavailable(e);
|
||||
// Dumb mode: the server is not ready / failing — answer from the syntax tree.
|
||||
// An import path is not a name to look up: only its file matters.
|
||||
const fb = moduleName ? null : await gotoFallback(view, doc, pos);
|
||||
if (superseded()) return;
|
||||
if (!fb) {
|
||||
reportLspError(view, e, true);
|
||||
return;
|
||||
}
|
||||
locs = fb;
|
||||
fromSyntax = true;
|
||||
}
|
||||
locs = fb;
|
||||
fromSyntax = true;
|
||||
}
|
||||
if (graphTargets) locs = mergeTargets(locs, await graphTargets);
|
||||
if (streamed && (await streamed.opened())) {
|
||||
await streamed.complete(locs);
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import type { commands, Location, OutlineItem } from "@/platform/ipc";
|
||||
|
||||
// Python has no type hierarchy server: the IDE's inheritance graph answers implementations.
|
||||
const graph = vi.hoisted(() => [
|
||||
{ path: "/p/order.py", line: 0, col: 6, name: "Order", bases: [] },
|
||||
{ path: "/p/online.py", line: 0, col: 6, name: "OnlineOrder", bases: ["Order"] },
|
||||
]);
|
||||
// The project index is the production entry: build one from the graph above so the
|
||||
// tests never reach the IPC the real fetch needs.
|
||||
vi.mock("@/editor/navigation/inheritanceGraph", async (orig) => {
|
||||
const real = await orig<typeof import("@/editor/navigation/inheritanceGraph")>();
|
||||
const index = real.inheritanceIndex(graph, { readFiles: async () => new Map() });
|
||||
const subclasses = await real.subclassMap(index);
|
||||
return {
|
||||
...real,
|
||||
pythonInheritanceGraph: async () => graph,
|
||||
projectPythonIndex: async () => ({ classes: graph, index, subclasses: async () => subclasses }),
|
||||
};
|
||||
});
|
||||
|
||||
const { lspGotoImplementation, docOutline, fsReadText } = vi.hoisted(() => ({
|
||||
lspGotoImplementation: vi.fn<typeof commands.lspGotoImplementation>(),
|
||||
docOutline: vi.fn<typeof commands.docOutline>(),
|
||||
fsReadText: vi.fn<typeof commands.fsReadText>(async () => ({
|
||||
status: "error",
|
||||
error: { kind: "notFound", message: "not found" },
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/platform/ipc", async (orig) => {
|
||||
const real = await orig<typeof import("@/platform/ipc")>();
|
||||
return {
|
||||
...real,
|
||||
isTauri: () => true,
|
||||
commands: { ...real.commands, lspGotoImplementation, docOutline, fsReadText },
|
||||
};
|
||||
});
|
||||
vi.mock("@tauri-apps/api/core", async (orig) => ({
|
||||
...(await orig<object>()),
|
||||
Channel: class {
|
||||
onmessage = () => {};
|
||||
},
|
||||
}));
|
||||
|
||||
const { gotoTargets } = await import("./navigation");
|
||||
const { lspDoc } = await import("./lsp");
|
||||
const { useLspPopup, closePopup } = await import("./popupStore");
|
||||
const { provideEditorService } = await import("@/workbench/contributions");
|
||||
const openFile = vi.fn(async () => {});
|
||||
provideEditorService({ openFile } as never);
|
||||
|
||||
const range = (line: number, col: number) => ({ start: { line, col }, end: { line, col } });
|
||||
const at = (path: string, line: number): Location => ({
|
||||
path,
|
||||
range: { start: { line, col: 0 }, end: { line, col: 1 } },
|
||||
});
|
||||
|
||||
// class Order:
|
||||
// def save(self):
|
||||
const OUTLINE: OutlineItem[] = [
|
||||
{
|
||||
name: "Order",
|
||||
detail: null,
|
||||
kind: "class",
|
||||
range: { start: { line: 0, col: 0 }, end: { line: 2, col: 8 } },
|
||||
selection_range: range(0, 6),
|
||||
children: [
|
||||
{
|
||||
name: "save",
|
||||
detail: null,
|
||||
kind: "method",
|
||||
range: { start: { line: 1, col: 4 }, end: { line: 2, col: 8 } },
|
||||
selection_range: range(1, 8),
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
let view: EditorView;
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
docOutline.mockResolvedValue({ status: "ok", data: OUTLINE });
|
||||
lspGotoImplementation.mockResolvedValue({ status: "ok", data: [] });
|
||||
view = new EditorView({
|
||||
state: EditorState.create({
|
||||
doc: "class Order:\n def save(self):\n pass\n",
|
||||
extensions: [lspDoc.of({ doc: 1, path: "/p/order.py", language: "python", uiMode: "Python", readOnly: false })],
|
||||
}),
|
||||
parent: document.body,
|
||||
});
|
||||
view.dispatch({ selection: { anchor: 6 } });
|
||||
});
|
||||
afterEach(() => {
|
||||
closePopup();
|
||||
view.destroy();
|
||||
document.body.replaceChildren();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("Go to Implementation on Python", () => {
|
||||
it("answers from the IDE's inheritance graph when the server finds none", async () => {
|
||||
await gotoTargets(view, "implementation");
|
||||
expect(openFile).toHaveBeenCalledWith("/p/online.py", { selection: { line: 0, col: 6 }, focus: true });
|
||||
expect(lspGotoImplementation).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("merges the graph's implementations with the server's answer", async () => {
|
||||
lspGotoImplementation.mockResolvedValue({ status: "ok", data: [at("/p/other.py", 3)] });
|
||||
await gotoTargets(view, "implementation");
|
||||
const popup = useLspPopup.getState().popup;
|
||||
expect(popup?.kind).toBe("list");
|
||||
expect(popup?.kind === "list" ? popup.items : []).toHaveLength(2);
|
||||
expect(popup?.kind === "list" ? popup.title : null).toBe("Choose Implementation of Order (2 found)");
|
||||
expect(openFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to the graph when the server has no type hierarchy", async () => {
|
||||
lspGotoImplementation.mockResolvedValue({
|
||||
status: "error",
|
||||
error: { kind: "unavailable", message: "no type hierarchy" },
|
||||
});
|
||||
await gotoTargets(view, "implementation");
|
||||
expect(openFile).toHaveBeenCalledWith("/p/online.py", { selection: { line: 0, col: 6 }, focus: true });
|
||||
});
|
||||
});
|
||||
@@ -50,7 +50,16 @@ import {
|
||||
type MarkerCandidate,
|
||||
type MarkerGroup,
|
||||
} from "./lineMarkers";
|
||||
import { baseName, overridersInType, superMethodTargets, type SuperDeps, type SuperMethod } from "./structureNav";
|
||||
import { overridersInType, superMethodTargets, type SuperDeps, type SuperMethod } from "./structureNav";
|
||||
import {
|
||||
classLocation,
|
||||
declarationKey,
|
||||
forgetInheritanceGraph,
|
||||
inheritanceIndex,
|
||||
methodInOutline,
|
||||
pythonInheritanceGraph,
|
||||
type InheritanceIndex,
|
||||
} from "./inheritanceGraph";
|
||||
import styles from "./LineMarkers.module.css";
|
||||
|
||||
/** Lines above / below the viewport that are asked with it. */
|
||||
@@ -93,30 +102,6 @@ export function affectsMarkers(paths: readonly string[]): boolean {
|
||||
* it again for each edit would walk every Python file again. Dropped when other files
|
||||
* change or a server settles (the index may have moved on).
|
||||
*/
|
||||
let inheritanceGraphCache: { root: string; promise: Promise<ClassInheritance[]> } | null = null;
|
||||
|
||||
/** The project whose graph is cached; `""` for no project (a loose file). */
|
||||
function projectRoot(): string {
|
||||
return useProject.getState().project?.root ?? "";
|
||||
}
|
||||
|
||||
/** Forgets the cached graph (files changed, a server settled, or tests). */
|
||||
export function forgetInheritanceGraph() {
|
||||
inheritanceGraphCache = null;
|
||||
}
|
||||
|
||||
function inheritanceGraph(): Promise<ClassInheritance[]> {
|
||||
const root = projectRoot();
|
||||
if (!inheritanceGraphCache || inheritanceGraphCache.root !== root) {
|
||||
inheritanceGraphCache = { root, promise: lspRequest(() => commands.navInheritance()) };
|
||||
// A failed request must not stick: the next pass retries it.
|
||||
inheritanceGraphCache.promise.catch(() => {
|
||||
if (inheritanceGraphCache?.root === root) inheritanceGraphCache = null;
|
||||
});
|
||||
}
|
||||
return inheritanceGraphCache.promise;
|
||||
}
|
||||
|
||||
/** Subscribes once per window and stays subscribed, so the settle echo guard survives remounts. */
|
||||
function watchChanges() {
|
||||
if (watching) return;
|
||||
@@ -267,19 +252,18 @@ export async function fillMarkers(
|
||||
}
|
||||
// Python without a type-hierarchy provider: the IDE's inheritance graph holds every
|
||||
// `class X(A)` of the project, so ↑/↓ are computed from it instead.
|
||||
const byName = new Map<string, ClassInheritance[]>();
|
||||
if (graph) for (const cls of graph) byName.set(cls.name, [...(byName.get(cls.name) ?? []), cls]);
|
||||
const resolver: InheritanceIndex | null = graph ? inheritanceIndex(graph, deps) : null;
|
||||
const byName = resolver?.byName ?? new Map<string, ClassInheritance[]>();
|
||||
// The inheritance graph's direct subclasses, keyed by the *declaration* each base
|
||||
// resolves to (path + position), so classes sharing a bare name never mix up.
|
||||
const resolver = baseResolver(byName, deps);
|
||||
const subclassesOf = new Map<string, ClassInheritance[]>();
|
||||
for (const cls of graph ?? []) {
|
||||
for (const base of new Set(cls.bases)) {
|
||||
// Only this file's classes are looked up below: read another file's imports only
|
||||
// when its base may name one of them, not for every shared or library base name.
|
||||
const exact = resolver.exact(base, cls.path);
|
||||
const exact = resolver ? resolver.exact(base, cls.path) : [];
|
||||
const named = exact.length || !byName.get(base)?.some((c) => c.path === path) ? exact : null;
|
||||
for (const decl of named ?? (await resolver.resolve(base, cls.path))) {
|
||||
for (const decl of named ?? (await resolver!.resolve(base, cls.path))) {
|
||||
const key = declarationKey(decl);
|
||||
subclassesOf.set(key, [...(subclassesOf.get(key) ?? []), cls]);
|
||||
}
|
||||
@@ -327,11 +311,11 @@ export async function fillMarkers(
|
||||
const c = up[i]!;
|
||||
const self = classOf(c);
|
||||
if (!self) continue;
|
||||
const found = await superMethodsFrom(resolver, self.name, c.symbol, deps);
|
||||
const found = resolver ? await superMethodsFrom(resolver, self.name, c.symbol, deps) : [];
|
||||
if (!found.length) continue;
|
||||
const known = supers[i]!;
|
||||
// Same merge rule as ↓: add the graph's super methods to what the server answered.
|
||||
const extra = found.map((f) => f.location).filter((l) => !known.some((s) => sameLocation(s.location, l)));
|
||||
const extra = found.map((f) => f.location).filter((l) => !known.some((s) => sameDeclarationLine(s.location, l)));
|
||||
const owners = [...new Set(found.map((f) => f.owner))].sort((a, b) => a.localeCompare(b));
|
||||
entry(c).supers = {
|
||||
kind: "overriding",
|
||||
@@ -370,149 +354,21 @@ export async function fillMarkers(
|
||||
|
||||
// One entry per declaration line: the server and the graph may point at the same
|
||||
// declaration with different columns (the name vs the whole statement).
|
||||
const sameLocation = (a: Location, b: Location) => a.path === b.path && a.range.start.line === b.range.start.line;
|
||||
const sameDeclarationLine = (a: Location, b: Location) =>
|
||||
a.path === b.path && a.range.start.line === b.range.start.line;
|
||||
|
||||
/** `first`, then the locations of `more` not already in it. */
|
||||
function mergeLocations(first: readonly Location[], more: readonly Location[]): Location[] {
|
||||
return [...first, ...more.filter((l) => !first.some((f) => sameLocation(f, l)))];
|
||||
return [...first, ...more.filter((l) => !first.some((f) => sameDeclarationLine(f, l)))];
|
||||
}
|
||||
|
||||
/** Identifies one class declaration in the inheritance graph. */
|
||||
function declarationKey(cls: ClassInheritance): string {
|
||||
return `${cls.path}:${cls.line}:${cls.col}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The classes a base name names. A bare name is only trusted when it is unique in the
|
||||
* project, or names a class in the subclass's own file: a `Config` declared in hundreds
|
||||
* of modules must not make every one of them a subclass of every other. When several
|
||||
* files declare it, the subclass's own imports decide (`from x.y import Config`).
|
||||
*/
|
||||
interface BaseResolver {
|
||||
/** The graph's classes by name. */
|
||||
byName: ReadonlyMap<string, ClassInheritance[]>;
|
||||
/** A base that names one class, or a class of the subclass's own file. */
|
||||
exact(name: string, from: string): ClassInheritance[];
|
||||
/** `exact`, else the declaration the file's imports point at (empty when unknown). */
|
||||
resolve(name: string, from: string): Promise<ClassInheritance[]>;
|
||||
}
|
||||
|
||||
function baseResolver(byName: ReadonlyMap<string, ClassInheritance[]>, deps: MarkerDeps): BaseResolver {
|
||||
const imports = new Map<string, Promise<Map<string, ImportBinding> | null>>();
|
||||
const answers = new Map<string, Promise<ClassInheritance[]>>();
|
||||
const exact = (name: string, from: string): ClassInheritance[] => {
|
||||
const found = byName.get(name) ?? [];
|
||||
return found.length <= 1 ? found : found.filter((c) => c.path === from);
|
||||
};
|
||||
const bindingsOf = (from: string): Promise<Map<string, ImportBinding> | null> => {
|
||||
let pending = imports.get(from);
|
||||
if (!pending) {
|
||||
pending = deps
|
||||
.readFiles([from])
|
||||
.then((files) => {
|
||||
const lines = files.get(from);
|
||||
return lines ? importBindings(lines) : null;
|
||||
})
|
||||
.catch(() => null);
|
||||
imports.set(from, pending);
|
||||
}
|
||||
return pending;
|
||||
};
|
||||
return {
|
||||
byName,
|
||||
exact,
|
||||
resolve: (name, from) => {
|
||||
const found = exact(name, from);
|
||||
if (found.length) return Promise.resolve(found);
|
||||
// No class of that name at all: it may be an import alias (`import X as Y`).
|
||||
const key = `${from}\u0000${name}`;
|
||||
let pending = answers.get(key);
|
||||
if (!pending) {
|
||||
pending = (async () => {
|
||||
const binding = (await bindingsOf(from))?.get(name);
|
||||
if (!binding || !deps.moduleFile) return [];
|
||||
// `from . import X` names the package itself: resolve its `__init__`.
|
||||
const module = binding.module;
|
||||
const named = /^\.+$/.test(module) ? `${module.endsWith(".") ? module : `${module}.`}__init__` : module;
|
||||
const file = await deps.moduleFile(named, from).catch(() => null);
|
||||
// The declaration carries the imported (pre-alias) name.
|
||||
return file ? (byName.get(binding.name) ?? []).filter((c) => c.path === file) : [];
|
||||
})();
|
||||
answers.set(key, pending);
|
||||
}
|
||||
return pending;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a name written in a file comes from: the module of a `from` import and the
|
||||
* name it is declared under there (equal unless the import aliases it).
|
||||
*/
|
||||
export interface ImportBinding {
|
||||
module: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The local name -> declaration bindings of a file's `from` imports:
|
||||
* `from a.b import X as Y` binds `Y` to (`a.b`, `X`). Plain `import a.b` statements
|
||||
* are skipped: a base written through them keeps its qualifier, which this graph
|
||||
* does not store.
|
||||
*/
|
||||
export function importBindings(lines: readonly string[]): Map<string, ImportBinding> {
|
||||
const out = new Map<string, ImportBinding>();
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = (lines[i] ?? "").replace(/#.*$/, "");
|
||||
const from = /^\s*from\s+(\.*[\w.]*)\s+import\s+(.*)$/.exec(line);
|
||||
if (!from) continue;
|
||||
let body = from[2]!.trim();
|
||||
if (body.startsWith("(")) {
|
||||
// The names may span lines inside the parentheses.
|
||||
let text = body.slice(1);
|
||||
while (!text.includes(")") && i + 1 < lines.length) text += ` ${(lines[++i] ?? "").replace(/#.*$/, "")}`;
|
||||
body = text.slice(0, text.indexOf(")") < 0 ? undefined : text.indexOf(")"));
|
||||
}
|
||||
for (const part of body.split(",")) {
|
||||
const [name, alias] = part.trim().split(/\s+as\s+/);
|
||||
const declared = (name ?? "").trim();
|
||||
if (!declared || declared === "*" || !/^\w+$/.test(declared)) continue;
|
||||
const local = (alias ?? declared).trim();
|
||||
if (!/^\w+$/.test(local)) continue;
|
||||
out.set(local, { module: from[1]!, name: declared });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The class declaration itself as a navigation target (its name identifier). */
|
||||
function classLocation(cls: ClassInheritance): Location {
|
||||
const start = { line: cls.line, col: cls.col };
|
||||
return { path: cls.path, range: { start, end: start } };
|
||||
}
|
||||
|
||||
/** The method `method` (bare name) directly inside the class at `cls`'s position. */
|
||||
function methodInOutline(outline: readonly OutlineItem[], cls: ClassInheritance, method: string): OutlineItem | null {
|
||||
const at = cls;
|
||||
const find = (items: readonly OutlineItem[]): OutlineItem | null => {
|
||||
for (const it of items) {
|
||||
if (it.kind === "class" && it.selection_range.start.line === at.line && it.selection_range.start.col === at.col) {
|
||||
return it.children.find((ch) => ch.kind === "method" && baseName(ch.name) === method) ?? null;
|
||||
}
|
||||
const deeper = find(it.children);
|
||||
if (deeper) return deeper;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return find(outline);
|
||||
}
|
||||
|
||||
/**
|
||||
* Super methods of `method` declared by a class in `byName`, walking the inheritance
|
||||
* graph's bases up to a few levels. Empty when no base declares the method.
|
||||
*/
|
||||
async function superMethodsFrom(
|
||||
resolver: BaseResolver,
|
||||
resolver: InheritanceIndex,
|
||||
start: string,
|
||||
method: string,
|
||||
deps: MarkerDeps,
|
||||
@@ -895,7 +751,7 @@ function markerDeps(ctx: EditorDocContext, items: OutlineItem[], request: Reques
|
||||
readFiles: readManyFileLines,
|
||||
// Python servers answer no type hierarchy (basedpyright, pyrefly): the IDE's
|
||||
// own index knows every `class X(Base)` of the project, attached roots included.
|
||||
inheritance: ctx.language === "python" ? inheritanceGraph : undefined,
|
||||
inheritance: ctx.language === "python" ? pythonInheritanceGraph : undefined,
|
||||
// A base name several modules declare is resolved through the file's imports.
|
||||
moduleFile:
|
||||
ctx.language === "python" ? (name, from) => lspRequest(() => commands.navResolveModule(name, from)) : undefined,
|
||||
|
||||
@@ -19,7 +19,8 @@ import { isVirtualUri } from "@/platform/virtualDocs";
|
||||
import { editorService } from "@/workbench/contributions";
|
||||
import { fileIconName } from "@/ui";
|
||||
import { gotoTestOrSubject, type TestNavDeps } from "./gotoTest";
|
||||
import { baseName, memberAt, methodStop, superMethods } from "./structureNav";
|
||||
import { projectPythonHierarchy, pythonSuperDeps } from "./pythonHierarchy";
|
||||
import { baseName, memberAt, methodStop, superMethods, type SuperDeps } from "./structureNav";
|
||||
import { tx } from "@/platform/i18n";
|
||||
|
||||
const EDITOR_WHEN = "textEditorActive && (editorFocus || !toolWindowFocus)";
|
||||
@@ -71,17 +72,27 @@ async function superFallback(ctx: EditorDocContext, pos: Pos): Promise<Location[
|
||||
return dedupeLocations(locs).filter((l) => !(l.path === ctx.path && l.range.start.line === pos.line));
|
||||
}
|
||||
|
||||
/** Super Method deps: Python answers them from the IDE's inheritance graph (its servers have no type hierarchy). */
|
||||
async function superDeps(ctx: EditorDocContext): Promise<SuperDeps> {
|
||||
const outline = (path: string) => withDoc(path, outlineOf);
|
||||
if (ctx.language === "python") {
|
||||
const hierarchy = await projectPythonHierarchy().catch(() => null);
|
||||
if (hierarchy) return pythonSuperDeps(hierarchy, outline);
|
||||
}
|
||||
return {
|
||||
supertypes: (path: string, pos: Pos) =>
|
||||
withDoc(path, (doc) => lspRequest(() => commands.lspTypeHierarchy(doc, pos, "incoming"))),
|
||||
outline,
|
||||
};
|
||||
}
|
||||
|
||||
async function superMethod(view: EditorView) {
|
||||
const ctx = docOf(view);
|
||||
if (!ctx) return;
|
||||
const at = await caretOutline(view, ctx);
|
||||
if (!at) return;
|
||||
const { method, type } = memberAt(at.outline, at.caret);
|
||||
const deps = {
|
||||
supertypes: (path: string, pos: Pos) =>
|
||||
withDoc(path, (doc) => lspRequest(() => commands.lspTypeHierarchy(doc, pos, "incoming"))),
|
||||
outline: (path: string) => withDoc(path, outlineOf),
|
||||
};
|
||||
const deps = await superDeps(ctx);
|
||||
let locs: Location[] = [];
|
||||
if (method && type) locs = await superMethods(ctx.path, type.selection_range.start, method.name, deps);
|
||||
else if (type) {
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
/**
|
||||
* The Python inheritance graph of the project, shared by every feature that needs it
|
||||
* (gutter markers, type hierarchy, structure view, inspections).
|
||||
*
|
||||
* A language server's `typeHierarchy` is not used: basedpyright and pyrefly declare no
|
||||
* `typeHierarchyProvider`, and a server analyses a single root, so an attached
|
||||
* dependency project's classes would be invisible anyway. The IDE index knows every
|
||||
* `class X(Base)` of every content root, which is the whole answer this needs.
|
||||
*/
|
||||
import { commands, type ClassInheritance, type Location, type OutlineItem } from "@/platform/ipc";
|
||||
import { lspRequest } from "@/editor/lsp/lsp";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { readManyFileLines } from "@/editor/lsp/files";
|
||||
import { baseName } from "./structureNav";
|
||||
|
||||
/**
|
||||
* The graph of the open project, shared across documents and kept across edits: it is a
|
||||
* whole-project index answer, not a per-document one. Dropped when other files change or
|
||||
* a server settles (the index may have moved on).
|
||||
*/
|
||||
let cache: { root: string; promise: Promise<ClassInheritance[]> } | null = null;
|
||||
|
||||
/** Forgets the cached graph and everything derived from it (files changed, a server settled, or tests). */
|
||||
export function forgetInheritanceGraph() {
|
||||
cache = null;
|
||||
indexCache = null;
|
||||
}
|
||||
|
||||
/** The project's classes and their base names, fetched once per project and invalidation. */
|
||||
export function pythonInheritanceGraph(): Promise<ClassInheritance[]> {
|
||||
const root = useProject.getState().project?.root ?? "";
|
||||
if (!cache || cache.root !== root) {
|
||||
cache = { root, promise: lspRequest(() => commands.navInheritance()) };
|
||||
// A failed request must not stick: the next caller retries it.
|
||||
cache.promise.catch(() => {
|
||||
if (cache?.root === root) cache = null;
|
||||
});
|
||||
}
|
||||
return cache.promise;
|
||||
}
|
||||
|
||||
/** Where a name written in a file comes from: the module of a `from` import and the name it is declared under there. */
|
||||
export interface ImportBinding {
|
||||
module: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The local name -> declaration bindings of a file's `from` imports:
|
||||
* `from a.b import X as Y` binds `Y` to (`a.b`, `X`). Plain `import a.b` statements are
|
||||
* skipped: a base written through them keeps its qualifier, which the graph does not store.
|
||||
*/
|
||||
export function importBindings(lines: readonly string[]): Map<string, ImportBinding> {
|
||||
const out = new Map<string, ImportBinding>();
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = (lines[i] ?? "").replace(/#.*$/, "");
|
||||
const from = /^\s*from\s+(\.*[\w.]*)\s+import\s+(.*)$/.exec(line);
|
||||
if (!from) continue;
|
||||
let body = from[2]!.trim();
|
||||
if (body.startsWith("(")) {
|
||||
// The names may span lines inside the parentheses.
|
||||
let text = body.slice(1);
|
||||
while (!text.includes(")") && i + 1 < lines.length) text += ` ${(lines[++i] ?? "").replace(/#.*$/, "")}`;
|
||||
body = text.slice(0, text.indexOf(")") < 0 ? undefined : text.indexOf(")"));
|
||||
}
|
||||
for (const part of body.split(",")) {
|
||||
const [name, alias] = part.trim().split(/\s+as\s+/);
|
||||
const declared = (name ?? "").trim();
|
||||
if (!declared || declared === "*" || !/^\w+$/.test(declared)) continue;
|
||||
const local = (alias ?? declared).trim();
|
||||
if (!/^\w+$/.test(local)) continue;
|
||||
out.set(local, { module: from[1]!, name: declared });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** What an index needs from the editor: a file's lines, and a module's file. */
|
||||
export interface InheritanceDeps {
|
||||
readFiles: (paths: readonly string[]) => Promise<Map<string, string[] | null>>;
|
||||
/** `from a.b import X`: the file `a.b` names, `.x` resolved relative to `from`. */
|
||||
moduleFile?: (module: string, from: string) => Promise<string | null>;
|
||||
}
|
||||
|
||||
/** Resolves the base names the graph stores to class declarations. */
|
||||
export interface InheritanceIndex {
|
||||
/** The graph's classes by name. */
|
||||
byName: ReadonlyMap<string, ClassInheritance[]>;
|
||||
/** A base that names one class, or a class of the subclass's own file. */
|
||||
exact(name: string, from: string): ClassInheritance[];
|
||||
/** `exact`, else the declaration the file's imports point at (empty when unknown). */
|
||||
resolve(name: string, from: string): Promise<ClassInheritance[]>;
|
||||
/** Reads the imports of `paths` in one batch, ahead of the `resolve` calls that need them. */
|
||||
readImports(paths: readonly string[]): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Index a graph for lookups. A base name is only trusted when it names one class in the
|
||||
* project or a class of the subclass's own file: a `Config` declared in hundreds of
|
||||
* modules must not make every one of them a subclass of every other. When several files
|
||||
* declare it, the subclass file's own imports decide (`from x.y import Config`).
|
||||
*/
|
||||
export function inheritanceIndex(graph: readonly ClassInheritance[], deps: InheritanceDeps): InheritanceIndex {
|
||||
const byName = new Map<string, ClassInheritance[]>();
|
||||
for (const cls of graph) byName.set(cls.name, [...(byName.get(cls.name) ?? []), cls]);
|
||||
const imports = new Map<string, Promise<Map<string, ImportBinding> | null>>();
|
||||
const answers = new Map<string, Promise<ClassInheritance[]>>();
|
||||
const exact = (name: string, from: string): ClassInheritance[] => {
|
||||
const found = byName.get(name) ?? [];
|
||||
return found.length <= 1 ? found : found.filter((c) => c.path === from);
|
||||
};
|
||||
const bindingsOf = (from: string): Promise<Map<string, ImportBinding> | null> => {
|
||||
let pending = imports.get(from);
|
||||
if (!pending) {
|
||||
pending = deps
|
||||
.readFiles([from])
|
||||
.then((files) => {
|
||||
const lines = files.get(from);
|
||||
return lines ? importBindings(lines) : null;
|
||||
})
|
||||
.catch(() => null);
|
||||
imports.set(from, pending);
|
||||
}
|
||||
return pending;
|
||||
};
|
||||
return {
|
||||
byName,
|
||||
exact,
|
||||
readImports: async (paths) => {
|
||||
// Without a module lookup an import never resolves a base: nothing to read for it.
|
||||
const unread = deps.moduleFile ? [...new Set(paths)].filter((p) => !imports.has(p)) : [];
|
||||
if (!unread.length) return;
|
||||
const files = deps.readFiles(unread).catch(() => new Map<string, string[] | null>());
|
||||
for (const p of unread) {
|
||||
imports.set(
|
||||
p,
|
||||
files.then((f) => {
|
||||
const lines = f.get(p);
|
||||
return lines ? importBindings(lines) : null;
|
||||
}),
|
||||
);
|
||||
}
|
||||
await files;
|
||||
},
|
||||
resolve: (name, from) => {
|
||||
const found = exact(name, from);
|
||||
if (found.length || !deps.moduleFile) return Promise.resolve(found);
|
||||
const moduleFile = deps.moduleFile;
|
||||
// No class of that name at all: it may be an import alias (`import X as Y`).
|
||||
const key = `${from}\u0000${name}`;
|
||||
let pending = answers.get(key);
|
||||
if (!pending) {
|
||||
pending = (async () => {
|
||||
const binding = (await bindingsOf(from))?.get(name);
|
||||
// The graph has no class under the imported name: no module lookup can find one.
|
||||
if (!binding || !byName.get(binding.name)?.length) return [];
|
||||
// `from . import X` names the package itself: resolve its `__init__`.
|
||||
const module = binding.module;
|
||||
const named = /^\.+$/.test(module) ? `${module.endsWith(".") ? module : `${module}.`}__init__` : module;
|
||||
const file = await moduleFile(named, from).catch(() => null);
|
||||
// The declaration carries the imported (pre-alias) name.
|
||||
return file ? (byName.get(binding.name) ?? []).filter((c) => c.path === file) : [];
|
||||
})();
|
||||
answers.set(key, pending);
|
||||
}
|
||||
return pending;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The graph indexed once per project, with what every consumer derives from it. */
|
||||
export interface PythonIndex {
|
||||
classes: readonly ClassInheritance[];
|
||||
index: InheritanceIndex;
|
||||
/**
|
||||
* Direct subclasses, keyed by [`declarationKey`] of the base they write. Built on first use:
|
||||
* it resolves every base of the project, which supertype lookups never need.
|
||||
*/
|
||||
subclasses: () => Promise<Map<string, ClassInheritance[]>>;
|
||||
}
|
||||
|
||||
let indexCache: { source: Promise<ClassInheritance[]>; pending: Promise<PythonIndex> } | null = null;
|
||||
|
||||
/**
|
||||
* The project's graph indexed once, shared by every consumer: the gutter, the type
|
||||
* hierarchy and the inspections all need the same name map, resolver and subclass map,
|
||||
* and rebuilding them per call (or per feature) would walk the graph again.
|
||||
*/
|
||||
/** What the project index reads: file lines and the file a module names. */
|
||||
const PROJECT_DEPS: InheritanceDeps = {
|
||||
readFiles: readManyFileLines,
|
||||
moduleFile: (module, from) => lspRequest(() => commands.navResolveModule(module, from)),
|
||||
};
|
||||
|
||||
export function projectPythonIndex(): Promise<PythonIndex> {
|
||||
const source = pythonInheritanceGraph();
|
||||
if (!indexCache || indexCache.source !== source) {
|
||||
const pending = source.then(async (graph) => {
|
||||
const index = inheritanceIndex(graph, PROJECT_DEPS);
|
||||
let subclasses: Promise<Map<string, ClassInheritance[]>> | null = null;
|
||||
return { classes: graph, index, subclasses: () => (subclasses ??= subclassMap(index)) };
|
||||
});
|
||||
indexCache = { source, pending };
|
||||
pending.catch(() => {
|
||||
if (indexCache?.pending === pending) indexCache = null;
|
||||
});
|
||||
}
|
||||
return indexCache.pending;
|
||||
}
|
||||
|
||||
/** Identifies one class declaration in the graph. */
|
||||
export function declarationKey(cls: ClassInheritance): string {
|
||||
return `${cls.path}:${cls.line}:${cls.col}`;
|
||||
}
|
||||
|
||||
/** The class declaration itself as a navigation target (its name identifier). */
|
||||
export function classLocation(cls: ClassInheritance): Location {
|
||||
const start = { line: cls.line, col: cls.col };
|
||||
return { path: cls.path, range: { start, end: start } };
|
||||
}
|
||||
|
||||
/** The method `method` (bare name) directly inside the class at `cls`'s position. */
|
||||
export function methodInOutline(
|
||||
outline: readonly OutlineItem[],
|
||||
cls: ClassInheritance,
|
||||
method: string,
|
||||
): OutlineItem | null {
|
||||
const find = (items: readonly OutlineItem[]): OutlineItem | null => {
|
||||
for (const it of items) {
|
||||
if (
|
||||
it.kind === "class" &&
|
||||
it.selection_range.start.line === cls.line &&
|
||||
it.selection_range.start.col === cls.col
|
||||
) {
|
||||
return it.children.find((ch) => ch.kind === "method" && baseName(ch.name) === method) ?? null;
|
||||
}
|
||||
const deeper = find(it.children);
|
||||
if (deeper) return deeper;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return find(outline);
|
||||
}
|
||||
|
||||
/** Direct subclasses, keyed by [`declarationKey`] of the base they write. */
|
||||
export async function subclassMap(index: InheritanceIndex): Promise<Map<string, ClassInheritance[]>> {
|
||||
const pairs: Array<[ClassInheritance, string]> = [];
|
||||
for (const sameNamed of index.byName.values()) {
|
||||
for (const cls of sameNamed) for (const base of new Set(cls.bases)) pairs.push([cls, base]);
|
||||
}
|
||||
// The files whose imports decide a base: read together, not one request per class.
|
||||
await index.readImports(pairs.filter(([cls, base]) => !index.exact(base, cls.path).length).map(([cls]) => cls.path));
|
||||
const resolved = await Promise.all(pairs.map(([cls, base]) => index.resolve(base, cls.path)));
|
||||
const out = new Map<string, ClassInheritance[]>();
|
||||
pairs.forEach(([cls], i) => {
|
||||
for (const decl of resolved[i]!) {
|
||||
const key = declarationKey(decl);
|
||||
out.set(key, [...(out.get(key) ?? []), cls]);
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The declarations `cls` directly inherits from, in the order it writes them. */
|
||||
export async function baseDeclarations(index: InheritanceIndex, cls: ClassInheritance): Promise<ClassInheritance[]> {
|
||||
const out: ClassInheritance[] = [];
|
||||
for (const base of cls.bases) out.push(...(await index.resolve(base, cls.path)));
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every ancestor of `cls`, nearest first (breadth-first, each declaration once), up to
|
||||
* `depth` levels. The order within a level follows the source's base order.
|
||||
*/
|
||||
export async function ancestors(
|
||||
index: InheritanceIndex,
|
||||
cls: ClassInheritance,
|
||||
depth = 8,
|
||||
): Promise<ClassInheritance[]> {
|
||||
const seen = new Set<string>([declarationKey(cls)]);
|
||||
const out: ClassInheritance[] = [];
|
||||
let level = await baseDeclarations(index, cls);
|
||||
for (let d = 0; d < depth && level.length; d++) {
|
||||
const next: ClassInheritance[] = [];
|
||||
for (const parent of level) {
|
||||
if (!seen.add(declarationKey(parent))) continue;
|
||||
out.push(parent);
|
||||
next.push(...(await baseDeclarations(index, parent)));
|
||||
}
|
||||
level = next;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -28,13 +28,13 @@ import {
|
||||
emptyCache,
|
||||
enabledGroups,
|
||||
fillMarkers,
|
||||
importBindings,
|
||||
markerBatches,
|
||||
markersOf,
|
||||
noteOtherChanges,
|
||||
openMarkerTargets,
|
||||
type MarkerDeps,
|
||||
} from "./implementationGutter";
|
||||
import { importBindings } from "./inheritanceGraph";
|
||||
|
||||
const ALL = enabledGroups({ showGutterIcons: true, gutterIconsDisabled: [] });
|
||||
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ClassInheritance, OutlineItem } from "@/platform/ipc";
|
||||
import { superMethods } from "./structureNav";
|
||||
import { inheritanceIndex, subclassMap } from "./inheritanceGraph";
|
||||
import {
|
||||
classAt,
|
||||
classItem,
|
||||
isPythonPath,
|
||||
pythonAncestors,
|
||||
pythonDescendants,
|
||||
pythonHierarchy,
|
||||
pythonImplementationTargets,
|
||||
pythonSuperDeps,
|
||||
pythonSupertypes,
|
||||
} from "./pythonHierarchy";
|
||||
|
||||
const cls = (path: string, line: number, name: string, bases: string[] = []): ClassInheritance => ({
|
||||
path,
|
||||
line,
|
||||
col: 6,
|
||||
name,
|
||||
bases,
|
||||
});
|
||||
|
||||
// `from .animal import Base` in dog.py: two modules declare a `Base`, the import picks one.
|
||||
const GRAPH = [
|
||||
cls("/p/animal.py", 0, "Base"),
|
||||
cls("/p/other.py", 0, "Base"),
|
||||
cls("/p/living.py", 0, "Living"),
|
||||
cls("/p/dog.py", 2, "Dog", ["Base"]),
|
||||
cls("/p/puppy.py", 4, "Puppy", ["Dog"]),
|
||||
];
|
||||
const FILES: Record<string, string[]> = {
|
||||
"/p/dog.py": ["from .animal import Base", "", "class Dog(Base):", " def bark(self):\n", " pass\n"],
|
||||
};
|
||||
|
||||
const hierarchy = pythonHierarchy(GRAPH, {
|
||||
readFiles: async (paths) => new Map(paths.map((p) => [p, FILES[p] ?? null])),
|
||||
// `.animal` names the animal module, as the backend's module resolver would.
|
||||
moduleFile: async (module) => (module === ".animal" ? "/p/animal.py" : null),
|
||||
});
|
||||
|
||||
const item = (name: string, kind: OutlineItem["kind"], start: number, end: number, children: OutlineItem[] = []) => ({
|
||||
name,
|
||||
detail: null,
|
||||
kind,
|
||||
range: { start: { line: start, col: 0 }, end: { line: end, col: 1 } },
|
||||
selection_range: { start: { line: start, col: 6 }, end: { line: start, col: 6 + name.length } },
|
||||
children,
|
||||
});
|
||||
|
||||
const OUTLINES: Record<string, OutlineItem[]> = {
|
||||
"/p/dog.py": [item("Dog", "class", 2, 6, [item("bark", "method", 3, 4)])],
|
||||
"/p/animal.py": [item("Base", "class", 0, 5, [item("bark", "method", 1, 2), item("eat", "method", 3, 4)])],
|
||||
"/p/living.py": [item("Living", "class", 0, 5, [item("age", "field", 1, 2)])],
|
||||
"/p/puppy.py": [item("Puppy", "class", 4, 9, [item("bark", "method", 5, 6)])],
|
||||
};
|
||||
const outlineOf = async (path: string) => OUTLINES[path] ?? [];
|
||||
|
||||
describe("Python inheritance graph helpers", () => {
|
||||
it("knows Python sources and finds the class declared at a position", () => {
|
||||
expect(isPythonPath("/p/dog.py")).toBe(true);
|
||||
expect(isPythonPath("/p/dog.pyi")).toBe(true);
|
||||
expect(isPythonPath("/p/Dog.java")).toBe(false);
|
||||
expect(classAt(hierarchy, "/p/dog.py", { line: 2, col: 6 })?.name).toBe("Dog");
|
||||
expect(classAt(hierarchy, "/p/dog.py", { line: 2, col: 0 })?.name).toBe("Dog");
|
||||
expect(classAt(hierarchy, "/p/dog.py", { line: 7, col: 6 })).toBeNull();
|
||||
});
|
||||
|
||||
it("resolves an ambiguous base name through the file's imports", async () => {
|
||||
const dog = classAt(hierarchy, "/p/dog.py", { line: 2, col: 6 })!;
|
||||
expect((await pythonSupertypes(hierarchy, dog)).map((c) => c.path)).toEqual(["/p/animal.py"]);
|
||||
expect((await pythonAncestors(hierarchy, dog)).map((c) => c.name)).toEqual(["Base"]);
|
||||
// A Base in another module is no supertype of Dog.
|
||||
const other = classAt(hierarchy, "/p/other.py", { line: 0, col: 6 })!;
|
||||
expect(await pythonDescendants(hierarchy, other)).toEqual([]);
|
||||
});
|
||||
|
||||
it("lists every descendant level by level", async () => {
|
||||
const base = classAt(hierarchy, "/p/animal.py", { line: 0, col: 6 })!;
|
||||
expect((await pythonDescendants(hierarchy, base)).map((c) => c.name)).toEqual(["Dog", "Puppy"]);
|
||||
});
|
||||
|
||||
it("shapes graph classes as hierarchy items at their name", () => {
|
||||
const dog = classAt(hierarchy, "/p/dog.py", { line: 2, col: 6 })!;
|
||||
expect(classItem(dog)).toMatchObject({
|
||||
name: "Dog",
|
||||
kind: "class",
|
||||
path: "/p/dog.py",
|
||||
selectionRange: { start: { line: 2, col: 6 }, end: { line: 2, col: 9 } },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Python Super Method", () => {
|
||||
it("finds the nearest super method through the graph's supertypes", async () => {
|
||||
const deps = pythonSuperDeps(hierarchy, outlineOf);
|
||||
const locs = await superMethods("/p/dog.py", { line: 2, col: 6 }, "bark", deps);
|
||||
// Dog inherits from Base, which declares bark.
|
||||
expect(locs.map((l) => `${l.path}:${l.range.start.line}`)).toEqual(["/p/animal.py:1"]);
|
||||
});
|
||||
|
||||
it("answers the super class of a type with no method at the caret", async () => {
|
||||
const items = await pythonSuperDeps(hierarchy, outlineOf).supertypes("/p/dog.py", { line: 2, col: 6 });
|
||||
expect(items.map((i) => i.name)).toEqual(["Base"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Python Go to Implementation", () => {
|
||||
it("targets every descendant class of the class at the caret", async () => {
|
||||
const targets = await pythonImplementationTargets(hierarchy, "/p/dog.py", { line: 2, col: 6 }, outlineOf);
|
||||
expect(targets.map((l) => `${l.path}:${l.range.start.line}`)).toEqual(["/p/puppy.py:4"]);
|
||||
});
|
||||
|
||||
it("targets the overriding methods of a method at the caret", async () => {
|
||||
const targets = await pythonImplementationTargets(hierarchy, "/p/dog.py", { line: 3, col: 8 }, outlineOf);
|
||||
expect(targets.map((l) => `${l.path}:${l.range.start.line}`)).toEqual(["/p/puppy.py:5"]);
|
||||
});
|
||||
|
||||
it("keeps the server's answer useful when the project graph knows nothing", async () => {
|
||||
// The graph's own answer: a class without descendants has no implementation.
|
||||
const targets = await pythonImplementationTargets(hierarchy, "/p/puppy.py", { line: 4, col: 6 }, outlineOf);
|
||||
expect(targets).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the project's subclass map", () => {
|
||||
it("reads the deciding imports in one batch and looks up only modules that can name a class", async () => {
|
||||
const graph = [
|
||||
cls("/p/a.py", 0, "Base"),
|
||||
cls("/p/b.py", 0, "Base"),
|
||||
cls("/p/c.py", 0, "Child", ["Base"]),
|
||||
cls("/p/d.py", 0, "Model", ["BaseModel"]),
|
||||
cls("/p/e.py", 0, "Other", ["Base"]),
|
||||
];
|
||||
const files: Record<string, string[]> = {
|
||||
"/p/c.py": ["from .a import Base"],
|
||||
"/p/d.py": ["from pydantic import BaseModel"],
|
||||
"/p/e.py": ["from .b import Base"],
|
||||
};
|
||||
const reads: string[][] = [];
|
||||
const lookups: string[] = [];
|
||||
const index = inheritanceIndex(graph, {
|
||||
readFiles: async (paths) => {
|
||||
reads.push([...paths]);
|
||||
return new Map(paths.map((p) => [p, files[p] ?? null]));
|
||||
},
|
||||
moduleFile: async (module) => {
|
||||
lookups.push(module);
|
||||
return module === ".a" ? "/p/a.py" : module === ".b" ? "/p/b.py" : null;
|
||||
},
|
||||
});
|
||||
const map = await subclassMap(index);
|
||||
expect(reads).toEqual([["/p/c.py", "/p/d.py", "/p/e.py"]]);
|
||||
// pydantic's BaseModel is no class of the project: its module is never looked up.
|
||||
expect(lookups.sort()).toEqual([".a", ".b"]);
|
||||
expect(map.get("/p/a.py:0:6")?.map((c) => c.name)).toEqual(["Child"]);
|
||||
expect(map.get("/p/b.py:0:6")?.map((c) => c.name)).toEqual(["Other"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* Type hierarchy, super methods and implementations for Python from the IDE's inheritance
|
||||
* graph. basedpyright and pyrefly declare no `typeHierarchyProvider` and analyse a single
|
||||
* root, so those features cannot be answered by the server; the graph knows every
|
||||
* `class X(Base)` of the project, attached dependency roots included.
|
||||
*/
|
||||
import { type ClassInheritance, type HierarchyItem, type Location, type OutlineItem, type Pos } from "@/platform/ipc";
|
||||
import {
|
||||
ancestors,
|
||||
baseDeclarations,
|
||||
classLocation,
|
||||
declarationKey,
|
||||
inheritanceIndex,
|
||||
methodInOutline,
|
||||
projectPythonIndex,
|
||||
subclassMap,
|
||||
type InheritanceDeps,
|
||||
type InheritanceIndex,
|
||||
} from "./inheritanceGraph";
|
||||
import { baseName, memberAt, type SuperDeps } from "./structureNav";
|
||||
|
||||
/** The project's classes with their base names, indexed for lookups. */
|
||||
export interface PythonHierarchy {
|
||||
index: InheritanceIndex;
|
||||
/** Classes by `path:line`, so a position lookup does not scan the whole project graph. */
|
||||
byLine: ReadonlyMap<string, ClassInheritance[]>;
|
||||
/** Direct subclasses by [`declarationKey`] of the base they write (from the shared index). */
|
||||
subclasses?: () => Promise<ReadonlyMap<string, ClassInheritance[]>>;
|
||||
}
|
||||
|
||||
/** Indexes `classes`; `deps` reads the files an ambiguous base name's imports point at. */
|
||||
export function pythonHierarchy(classes: readonly ClassInheritance[], deps: InheritanceDeps): PythonHierarchy {
|
||||
const byLine = new Map<string, ClassInheritance[]>();
|
||||
for (const cls of classes) {
|
||||
const key = `${cls.path}:${cls.line}`;
|
||||
byLine.set(key, [...(byLine.get(key) ?? []), cls]);
|
||||
}
|
||||
return { index: inheritanceIndex(classes, deps), byLine };
|
||||
}
|
||||
|
||||
/** Whether `path` is a Python source the inheritance graph covers. */
|
||||
export const isPythonPath = (path: string) => /\.pyi?$/.test(path);
|
||||
|
||||
let cache: { classes: readonly ClassInheritance[]; byLine: Map<string, ClassInheritance[]> } | null = null;
|
||||
|
||||
/** The open project's graph, indexed once and shared with the gutter and the inspections. */
|
||||
export async function projectPythonHierarchy(): Promise<PythonHierarchy> {
|
||||
const project = await projectPythonIndex();
|
||||
if (cache?.classes !== project.classes) {
|
||||
const byLine = new Map<string, ClassInheritance[]>();
|
||||
for (const cls of project.classes) {
|
||||
const key = `${cls.path}:${cls.line}`;
|
||||
byLine.set(key, [...(byLine.get(key) ?? []), cls]);
|
||||
}
|
||||
cache = { classes: project.classes, byLine };
|
||||
}
|
||||
return { index: project.index, byLine: cache.byLine, subclasses: project.subclasses };
|
||||
}
|
||||
|
||||
/** The graph's class declared on `pos`'s line in `path` (the column picks between two on one line). */
|
||||
export function classAt(hierarchy: PythonHierarchy, path: string, pos: Pos): ClassInheritance | null {
|
||||
const onLine = hierarchy.byLine.get(`${path}:${pos.line}`) ?? [];
|
||||
return onLine.find((c) => c.col === pos.col) ?? (onLine.length === 1 ? onLine[0]! : null);
|
||||
}
|
||||
|
||||
/** A graph class as the type-hierarchy / supertype item a language server would return. */
|
||||
export function classItem(cls: ClassInheritance): HierarchyItem {
|
||||
const range = { start: { line: cls.line, col: cls.col }, end: { line: cls.line, col: cls.col + cls.name.length } };
|
||||
return {
|
||||
name: cls.name,
|
||||
kind: "class",
|
||||
detail: null,
|
||||
path: cls.path,
|
||||
range,
|
||||
selectionRange: range,
|
||||
fromRanges: [],
|
||||
};
|
||||
}
|
||||
|
||||
/** The declarations `cls` directly inherits from, in the order it writes them. */
|
||||
export const pythonSupertypes = (hierarchy: PythonHierarchy, cls: ClassInheritance) =>
|
||||
baseDeclarations(hierarchy.index, cls);
|
||||
|
||||
/** The Python supertypes of the type at `pos` in `path`, as hierarchy items. */
|
||||
export async function pythonSupertypeItems(
|
||||
hierarchy: PythonHierarchy,
|
||||
path: string,
|
||||
pos: Pos,
|
||||
): Promise<HierarchyItem[]> {
|
||||
const cls = classAt(hierarchy, path, pos);
|
||||
return cls ? (await pythonSupertypes(hierarchy, cls)).map(classItem) : [];
|
||||
}
|
||||
|
||||
const subclassMaps = new WeakMap<PythonHierarchy, Promise<Map<string, ClassInheritance[]>>>();
|
||||
|
||||
/** Direct subclasses: the shared index's map, or one derived for a hand-built hierarchy. */
|
||||
function subclassesOf(hierarchy: PythonHierarchy): Promise<ReadonlyMap<string, ClassInheritance[]>> {
|
||||
if (hierarchy.subclasses) return hierarchy.subclasses();
|
||||
let map = subclassMaps.get(hierarchy);
|
||||
if (!map) subclassMaps.set(hierarchy, (map = subclassMap(hierarchy.index)));
|
||||
return map;
|
||||
}
|
||||
|
||||
/** The declarations directly inheriting from `cls`. */
|
||||
export async function pythonSubtypes(hierarchy: PythonHierarchy, cls: ClassInheritance): Promise<ClassInheritance[]> {
|
||||
return (await subclassesOf(hierarchy)).get(declarationKey(cls)) ?? [];
|
||||
}
|
||||
|
||||
/** Every ancestor of `cls`, nearest first. */
|
||||
export const pythonAncestors = (hierarchy: PythonHierarchy, cls: ClassInheritance) => ancestors(hierarchy.index, cls);
|
||||
|
||||
/** Every descendant of `cls`, nearest level first, each declaration once. */
|
||||
export async function pythonDescendants(
|
||||
hierarchy: PythonHierarchy,
|
||||
cls: ClassInheritance,
|
||||
depth = 8,
|
||||
): Promise<ClassInheritance[]> {
|
||||
const subclasses = await subclassesOf(hierarchy);
|
||||
const seen = new Set<string>([declarationKey(cls)]);
|
||||
const out: ClassInheritance[] = [];
|
||||
let level = [cls];
|
||||
for (let d = 0; d < depth && level.length; d++) {
|
||||
const next: ClassInheritance[] = [];
|
||||
for (const parent of level) {
|
||||
for (const sub of subclasses.get(declarationKey(parent)) ?? []) {
|
||||
if (!seen.add(declarationKey(sub))) continue;
|
||||
out.push(sub);
|
||||
next.push(sub);
|
||||
}
|
||||
}
|
||||
level = next;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Super Method (⌘U) deps backed by the graph, for a language server without a type hierarchy. */
|
||||
export function pythonSuperDeps(
|
||||
hierarchy: PythonHierarchy,
|
||||
outline: (path: string) => Promise<OutlineItem[]>,
|
||||
): SuperDeps {
|
||||
return {
|
||||
supertypes: (path, pos) => pythonSupertypeItems(hierarchy, path, pos),
|
||||
outline,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Go to Implementation targets of the Python class or method at `pos` in `path`: the classes
|
||||
* inheriting from it, or the methods overriding it, anywhere below it in the graph.
|
||||
*/
|
||||
export async function pythonImplementationTargets(
|
||||
hierarchy: PythonHierarchy,
|
||||
path: string,
|
||||
pos: Pos,
|
||||
outline: (path: string) => Promise<OutlineItem[]>,
|
||||
depth = 8,
|
||||
): Promise<Location[]> {
|
||||
const outlines = new Map<string, Promise<OutlineItem[]>>();
|
||||
const outlineOf = (p: string) => {
|
||||
let o = outlines.get(p);
|
||||
if (!o) outlines.set(p, (o = outline(p).catch(() => [] as OutlineItem[])));
|
||||
return o;
|
||||
};
|
||||
const member = memberAt(await outlineOf(path), pos);
|
||||
if (!member.type) return [];
|
||||
const cls = classAt(hierarchy, path, member.type.selection_range.start);
|
||||
if (!cls) return [];
|
||||
const descendants = await pythonDescendants(hierarchy, cls, depth);
|
||||
if (!member.method) return descendants.map(classLocation);
|
||||
const name = baseName(member.method.name);
|
||||
const targets: Location[] = [];
|
||||
for (const sub of descendants) {
|
||||
const method = methodInOutline(await outlineOf(sub.path), sub, name);
|
||||
if (method) targets.push({ path: sub.path, range: method.selection_range });
|
||||
}
|
||||
return targets;
|
||||
}
|
||||
@@ -440,7 +440,11 @@ export function DependencyAnalyzer(_props: { input: EditorInput }) {
|
||||
|
||||
const treeView = (
|
||||
<div className={styles.body}>
|
||||
<SplitGroup orientation="horizontal" id="maven-dependency-analyzer-split">
|
||||
<SplitGroup
|
||||
orientation="horizontal"
|
||||
id="maven-dependency-analyzer-split"
|
||||
storageKey="openidea.dependencyAnalyzer.split"
|
||||
>
|
||||
<SplitPanel id="dependencies" minSize={200} className={styles.panel}>
|
||||
<Tree<Node>
|
||||
aria-label="Dependencies"
|
||||
|
||||
@@ -339,7 +339,12 @@ export function BuildView() {
|
||||
}
|
||||
return (
|
||||
<div className={styles.view}>
|
||||
<SplitGroup orientation="horizontal" className={styles.split} id="build-view-split">
|
||||
<SplitGroup
|
||||
orientation="horizontal"
|
||||
className={styles.split}
|
||||
id="build-view-split"
|
||||
storageKey="openidea.build.split"
|
||||
>
|
||||
<SplitPanel id="build-tree" defaultSize="38%" minSize="120px" className={styles.pane}>
|
||||
<BuildTree />
|
||||
</SplitPanel>
|
||||
|
||||
@@ -410,7 +410,7 @@ export function ProblemsView() {
|
||||
<div className={styles.view}>
|
||||
<div className={styles.body} onKeyDownCapture={onKeyDownCapture}>
|
||||
{options.preview ? (
|
||||
<SplitGroup orientation="horizontal" id="problems-preview-split">
|
||||
<SplitGroup orientation="horizontal" id="problems-preview-split" storageKey="openidea.problems.previewSplit">
|
||||
<SplitPanel id="problems" minSize={120} className={styles.panel}>
|
||||
{list}
|
||||
</SplitPanel>
|
||||
|
||||
@@ -5,13 +5,13 @@ import { getAction, executeAction } from "@/platform/actions";
|
||||
import { BarButton, Button, IconButton, Popup, T } from "@/ui";
|
||||
import { downloadJdkActions } from "@/platform/jdk";
|
||||
import { relativePath, useProject } from "@/platform/project";
|
||||
import { STATUS_LABEL, restartServer, servedByOther, serversSummary, useServers } from "./servers";
|
||||
import { STATUS_LABEL, restartServer, servedByOther, serverLabel, serversSummary, useServers } from "./servers";
|
||||
import styles from "./ServerStatus.module.css";
|
||||
import { tx } from "@/platform/i18n";
|
||||
|
||||
export function ServerStatus() {
|
||||
const map = useServers((s) => s.servers);
|
||||
const servers = useMemo(() => [...map.values()].sort((a, b) => a.name.localeCompare(b.name)), [map]);
|
||||
const servers = useMemo(() => [...map.values()].sort((a, b) => serverLabel(a).localeCompare(serverLabel(b))), [map]);
|
||||
const root = useProject((s) => s.project?.root ?? "");
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLButtonElement>(null);
|
||||
@@ -56,7 +56,7 @@ export function ServerStatus() {
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className={styles.name}>{s.name}</span>
|
||||
<span className={styles.name}>{serverLabel(s)}</span>
|
||||
<span className={styles.status}>{STATUS_LABEL[s.status]}</span>
|
||||
<span className={styles.spacer} />
|
||||
{jdkFix ? (
|
||||
@@ -87,7 +87,7 @@ export function ServerStatus() {
|
||||
<IconButton
|
||||
icon="refresh"
|
||||
size="small"
|
||||
title={s.status === "missing" ? tx`Check for ${s.name} again` : tx`Restart ${s.name}`}
|
||||
title={s.status === "missing" ? tx`Check for ${s.name} again` : tx`Restart ${serverLabel(s)}`}
|
||||
disabled={s.status === "starting"}
|
||||
onClick={() => void restartServer(s.id)}
|
||||
/>
|
||||
|
||||
@@ -401,7 +401,7 @@ function UsagesSplit({ preview, children }: { preview: ReactNode; children: Reac
|
||||
const show = useUsagesOptions((s) => s.preview);
|
||||
if (!show) return children;
|
||||
return (
|
||||
<SplitGroup orientation="horizontal" id="usages-preview-split">
|
||||
<SplitGroup orientation="horizontal" id="usages-preview-split" storageKey="openidea.usages.previewSplit">
|
||||
<SplitPanel id="usages" minSize={160} className={styles.panel}>
|
||||
{children}
|
||||
</SplitPanel>
|
||||
|
||||
@@ -71,6 +71,28 @@ const typeHierarchy = vi.fn((pos: { line: number; col: number }): HierarchyItem[
|
||||
);
|
||||
// The positions a hierarchy request is answered at; anything else is "no symbol".
|
||||
let servedAt = (pos: { line: number; col: number }) => pos.line === 2 && pos.col === 7;
|
||||
// Python: basedpyright and pyrefly declare no type hierarchy; the IDE's inheritance graph answers.
|
||||
const graph = [
|
||||
{ path: "/p/base.py", line: 0, col: 6, name: "Base", bases: [] },
|
||||
{ path: "/p/mid.py", line: 0, col: 6, name: "Middle", bases: ["Base"] },
|
||||
{ path: "/p/leaf.py", line: 0, col: 6, name: "Leaf", bases: ["Middle"] },
|
||||
];
|
||||
// The project index is the production entry: build one from the graph above so the
|
||||
// tests never reach the IPC the real fetch needs.
|
||||
vi.mock("@/editor/navigation/inheritanceGraph", async (orig) => {
|
||||
const real = await orig<typeof import("@/editor/navigation/inheritanceGraph")>();
|
||||
const index = real.inheritanceIndex(graph, { readFiles: async () => new Map() });
|
||||
const subclasses = await real.subclassMap(index);
|
||||
return {
|
||||
...real,
|
||||
pythonInheritanceGraph: async () => graph,
|
||||
projectPythonIndex: async () => ({ classes: graph, index, subclasses: async () => subclasses }),
|
||||
};
|
||||
});
|
||||
let serverUnavailable = false;
|
||||
/** A partial answer a Python server gives (it resolved only its own root). */
|
||||
let servedOverride: HierarchyItem[] | null = null;
|
||||
let caret = { doc: 1, path: "/p/Orders.java", line: 4, col: 10, word: "x", view: null };
|
||||
|
||||
vi.mock("@/platform/ipc", async (orig) => {
|
||||
const real = await orig<typeof import("@/platform/ipc")>();
|
||||
@@ -81,7 +103,10 @@ vi.mock("@/platform/ipc", async (orig) => {
|
||||
...real.commands,
|
||||
docOutline: async () => OUTLINE,
|
||||
cancelRequest,
|
||||
lspTypeHierarchy: async (_doc: number, pos: { line: number; col: number }) => typeHierarchy(pos),
|
||||
lspTypeHierarchy: async (_doc: number, pos: { line: number; col: number }) => {
|
||||
if (serverUnavailable) throw new real.IpcError({ kind: "unavailable", message: "no type hierarchy" });
|
||||
return servedOverride ?? typeHierarchy(pos);
|
||||
},
|
||||
lspCallHierarchy: async (_request: number, _doc: number, pos: { line: number; col: number }) => {
|
||||
// The server prepares a hierarchy only on the method name.
|
||||
if (!servedAt(pos)) throw new real.IpcError({ kind: "notFound", message: "No symbol" });
|
||||
@@ -92,7 +117,7 @@ vi.mock("@/platform/ipc", async (orig) => {
|
||||
});
|
||||
vi.mock("./ipc", async (orig) => ({
|
||||
...(await orig<typeof import("./ipc")>()),
|
||||
caretContext: () => ({ doc: 1, path: "/p/Orders.java", line: 4, col: 10, word: "x", view: null }),
|
||||
caretContext: () => caret,
|
||||
withDocument: async <T>(_path: string, fn: (doc: number) => Promise<T>) => fn(1),
|
||||
lspRequest: async <T>(run: () => Promise<T>) => run(),
|
||||
hint: (...a: unknown[]) => hint(...a),
|
||||
@@ -206,6 +231,72 @@ describe("type hierarchy", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Python type hierarchy", () => {
|
||||
const pythonCaret = (path: string, name: string) => ({ doc: 1, path, line: 0, col: 6, word: name, view: null });
|
||||
const restore = () => {
|
||||
serverUnavailable = false;
|
||||
caret = { doc: 1, path: "/p/Orders.java", line: 4, col: 10, word: "x", view: null };
|
||||
useHierarchy.setState({ mode: "call", direction: "incoming" });
|
||||
};
|
||||
|
||||
it("builds subtypes from the graph when the server answers unavailable", async () => {
|
||||
hint.mockClear();
|
||||
serverUnavailable = true;
|
||||
caret = pythonCaret("/p/mid.py", "Middle");
|
||||
try {
|
||||
useHierarchy.setState({ mode: "type", direction: "outgoing" });
|
||||
await showHierarchy("type");
|
||||
const s = useHierarchy.getState();
|
||||
expect(s.children.get(ROOT_ID)?.map((n) => n.item.name)).toEqual(["Leaf"]);
|
||||
expect(s.failed.size).toBe(0);
|
||||
expect(s.notes.size).toBe(0);
|
||||
expect(hint).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("merges a Python server's partial answer with the graph (attached project)", async () => {
|
||||
// pyrefly answers only what it resolved in its own root: `object`. The base class of
|
||||
// this demo lives in an attached project, so only the graph knows it.
|
||||
servedOverride = [typeItem("object", 114)];
|
||||
caret = pythonCaret("/p/leaf.py", "Leaf");
|
||||
try {
|
||||
useHierarchy.setState({ mode: "type", direction: "incoming" });
|
||||
await showHierarchy("type");
|
||||
const top = useHierarchy.getState().children.get(ROOT_ID) ?? [];
|
||||
expect(top.map((n) => n.item.name)).toEqual(["object", "Middle", "Base"]);
|
||||
expect(useHierarchy.getState().failed.size).toBe(0);
|
||||
} finally {
|
||||
servedOverride = null;
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("builds the supertype chain from the graph, each node expanding on demand", async () => {
|
||||
serverUnavailable = true;
|
||||
caret = pythonCaret("/p/leaf.py", "Leaf");
|
||||
try {
|
||||
useHierarchy.setState({ mode: "type", direction: "incoming" });
|
||||
await showHierarchy("type");
|
||||
const top = useHierarchy.getState().children.get(ROOT_ID) ?? [];
|
||||
expect(top.map((n) => n.item.name)).toEqual(["Middle", "Base"]);
|
||||
const middle = top.find((n) => n.item.name === "Middle")!;
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
useHierarchy
|
||||
.getState()
|
||||
.children.get(middle.id)
|
||||
?.map((n) => n.item.name),
|
||||
).toEqual(["Base"]),
|
||||
);
|
||||
expect(useHierarchy.getState().failed.size).toBe(0);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("closing the hierarchy tab", () => {
|
||||
it("clears the tree and hides the tool window", async () => {
|
||||
const { closeHierarchy, useHierarchy } = await import("./hierarchy");
|
||||
|
||||
@@ -1,11 +1,28 @@
|
||||
/** Call / type hierarchy state, with lazily expanded levels. */
|
||||
import { create } from "zustand";
|
||||
import type { HierarchyDirection, HierarchyItem, OutlineItem, Pos, RequestId, SymbolKind } from "@/bindings";
|
||||
import {
|
||||
classAt,
|
||||
classItem,
|
||||
isPythonPath,
|
||||
projectPythonHierarchy,
|
||||
pythonAncestors,
|
||||
pythonSubtypes,
|
||||
} from "@/editor/navigation/pythonHierarchy";
|
||||
import { incompleteNote } from "@/editor/lsp/lsp";
|
||||
import { IpcError, call, commands, nextRequestId } from "@/platform/ipc";
|
||||
import { toolWindowService } from "@/workbench/contributions";
|
||||
import { basename } from "@/platform/project";
|
||||
import { caretContext, caretSynced, hint, lspRequest, reportError, withDocument, type CaretContext } from "./ipc";
|
||||
import {
|
||||
caretContext,
|
||||
caretSynced,
|
||||
hint,
|
||||
isUnavailable,
|
||||
lspRequest,
|
||||
reportError,
|
||||
withDocument,
|
||||
type CaretContext,
|
||||
} from "./ipc";
|
||||
import { tx } from "@/platform/i18n";
|
||||
|
||||
export type HierarchyMode = "call" | "type";
|
||||
@@ -96,11 +113,55 @@ export function hierarchyTitle(mode: HierarchyMode, direction: HierarchyDirectio
|
||||
return direction === "incoming" ? tx`Supertypes of ${name}` : tx`Subtypes of ${name}`;
|
||||
}
|
||||
|
||||
async function request(mode: HierarchyMode, doc: number, line: number, col: number, direction: HierarchyDirection) {
|
||||
if (mode === "type") {
|
||||
const items = await lspRequest(() => commands.lspTypeHierarchy(doc, { line, col }, direction));
|
||||
return { items, incomplete: null };
|
||||
/** `first`, then the items of `more` no item of `first` already names. */
|
||||
function mergeItems(first: readonly HierarchyItem[], more: readonly HierarchyItem[]): HierarchyItem[] {
|
||||
const seen = new Set(first.map(itemKey));
|
||||
return [...first, ...more.filter((item) => !seen.has(itemKey(item)))];
|
||||
}
|
||||
|
||||
/**
|
||||
* Type hierarchy items of the type at `pos`. A Python server's answer is merged with the
|
||||
* IDE's inheritance graph rather than trusted: pyrefly answers only what it resolved in
|
||||
* its own root (`object` for a class whose base lives in an attached project), so a
|
||||
* non-empty answer still misses the attached project's supertypes.
|
||||
*/
|
||||
async function typeHierarchyItems(
|
||||
path: string,
|
||||
doc: number,
|
||||
pos: Pos,
|
||||
direction: HierarchyDirection,
|
||||
): Promise<HierarchyItem[]> {
|
||||
const python = isPythonPath(path);
|
||||
let served: HierarchyItem[] = [];
|
||||
let unavailable = false;
|
||||
try {
|
||||
served = await lspRequest(() => commands.lspTypeHierarchy(doc, pos, direction));
|
||||
} catch (e) {
|
||||
if (!python || !isUnavailable(e)) throw e;
|
||||
unavailable = true;
|
||||
}
|
||||
if (!python) return served;
|
||||
const hierarchy = await projectPythonHierarchy();
|
||||
const cls = classAt(hierarchy, path, pos);
|
||||
if (!cls) {
|
||||
if (unavailable) throw new IpcError({ kind: "notFound", message: "No type at the caret" });
|
||||
return served;
|
||||
}
|
||||
// Supertypes are the whole chain, subtypes only the direct ones: each node expands lazily.
|
||||
const graph = direction === "incoming" ? await pythonAncestors(hierarchy, cls) : await pythonSubtypes(hierarchy, cls);
|
||||
return mergeItems(served, graph.map(classItem));
|
||||
}
|
||||
|
||||
async function request(
|
||||
mode: HierarchyMode,
|
||||
doc: number,
|
||||
path: string,
|
||||
line: number,
|
||||
col: number,
|
||||
direction: HierarchyDirection,
|
||||
) {
|
||||
if (mode === "type")
|
||||
return { items: await typeHierarchyItems(path, doc, { line, col }, direction), incomplete: null };
|
||||
const id = nextRequestId();
|
||||
inflight.add(id);
|
||||
try {
|
||||
@@ -174,7 +235,7 @@ async function loadRoot(quiet = false): Promise<RootResult> {
|
||||
useHierarchy.setState({ generation, children: new Map(), failed: new Set(), notes: new Map(), loading: true });
|
||||
try {
|
||||
const { items, incomplete } = await withDocument(root.path, (doc) =>
|
||||
request(s.mode, doc, root.line, root.col, s.direction),
|
||||
request(s.mode, doc, root.path, root.line, root.col, s.direction),
|
||||
);
|
||||
setChildren(generation, ROOT_ID, [rootKey(root)], items, incomplete && incompleteNote(incomplete, root.name));
|
||||
prefetchLevels(useHierarchy.getState().children.get(ROOT_ID) ?? []);
|
||||
@@ -227,7 +288,7 @@ export async function loadChildren(node: HierarchyNode, ahead = false) {
|
||||
const at = node.item.selectionRange.start;
|
||||
try {
|
||||
const { items, incomplete } = await withDocument(node.item.path, (doc) =>
|
||||
request(mode, doc, at.line, at.col, direction),
|
||||
request(mode, doc, node.item.path, at.line, at.col, direction),
|
||||
);
|
||||
setChildren(
|
||||
generation,
|
||||
|
||||
@@ -71,6 +71,7 @@ describe("server status", () => {
|
||||
root: "/p",
|
||||
status: "ready",
|
||||
pid: null,
|
||||
label: null,
|
||||
});
|
||||
pending.splice(0).forEach((resolve) => resolve([info(11, "basedpyright"), info(12, "pyright")]));
|
||||
await vi.waitFor(() => expect(useServers.getState().servers.size).toBe(2));
|
||||
@@ -78,7 +79,7 @@ describe("server status", () => {
|
||||
|
||||
// The fallback stops and leaves the list; a snapshot taken before that answers late.
|
||||
const status = (id: number, name: string, status: ServerInfo["status"]) =>
|
||||
emitLocalIdeEvent({ type: "languageServer", server: id as ServerId, name, status, message: null });
|
||||
emitLocalIdeEvent({ type: "languageServer", server: id as ServerId, name, label: null, status, message: null });
|
||||
status(13, "ty", "starting");
|
||||
status(12, "pyright", "stopped");
|
||||
emitLocalIdeEvent({ type: "languageServerRemoved", server: 12 as ServerId });
|
||||
@@ -91,6 +92,30 @@ describe("server status", () => {
|
||||
expect(serversSummary([...useServers.getState().servers.values()])?.text).toBe("basedpyright");
|
||||
});
|
||||
|
||||
it("names a server by the program that serves in its place", () => {
|
||||
useServers.setState({ servers: new Map() });
|
||||
dispose = installServerEvents();
|
||||
const id = 21 as ServerId;
|
||||
emitLocalIdeEvent({
|
||||
type: "languageServer",
|
||||
server: id,
|
||||
name: "vtsls",
|
||||
label: null,
|
||||
status: "starting",
|
||||
message: null,
|
||||
});
|
||||
emitLocalIdeEvent({
|
||||
type: "languageServer",
|
||||
server: id,
|
||||
name: "vtsls",
|
||||
label: "TypeScript 7",
|
||||
status: "ready",
|
||||
message: null,
|
||||
});
|
||||
expect(useServers.getState().servers.get(id)).toMatchObject({ name: "vtsls", label: "TypeScript 7" });
|
||||
expect(serversSummary([...useServers.getState().servers.values()])?.text).toBe("TypeScript 7");
|
||||
});
|
||||
|
||||
it("shows a running server busy only once it stays busy", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
|
||||
@@ -10,6 +10,8 @@ import { tx } from "@/platform/i18n";
|
||||
export interface ServerEntry {
|
||||
id: ServerId;
|
||||
name: string;
|
||||
/** What the user sees as the server when another program serves in its place (TypeScript 7 for vtsls). */
|
||||
label?: string | null;
|
||||
status: ServerStatus;
|
||||
message: string | null;
|
||||
languages: string[];
|
||||
@@ -36,6 +38,11 @@ const removedAt = new Map<ServerId, number>();
|
||||
export const BUSY_SHOW_DELAY_MS = 500;
|
||||
const pendingBusy = new Map<ServerId, ReturnType<typeof setTimeout>>();
|
||||
|
||||
/** The name shown for a server. */
|
||||
export function serverLabel(s: Pick<ServerEntry, "name" | "label">): string {
|
||||
return s.label ?? s.name;
|
||||
}
|
||||
|
||||
function notifyMissing(e: ServerEntry) {
|
||||
if (notifiedMissing.has(e.name)) return;
|
||||
notifiedMissing.add(e.name);
|
||||
@@ -81,6 +88,7 @@ export async function refreshServers() {
|
||||
map.set(s.id, {
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
label: newer ? p.label : s.label,
|
||||
status: newer ? p.status : s.status,
|
||||
languages: s.languages,
|
||||
root: s.root,
|
||||
@@ -122,7 +130,7 @@ export function installServerEvents(): () => void {
|
||||
lastEvent.set(e.server, ++eventSeq);
|
||||
clearTimeout(pendingBusy.get(e.server));
|
||||
pendingBusy.delete(e.server);
|
||||
const entry = { id: e.server, name: e.name, status: e.status, message: e.message };
|
||||
const entry = { id: e.server, name: e.name, label: e.label, status: e.status, message: e.message };
|
||||
if (e.status === "busy" && useServers.getState().servers.get(e.server)?.status === "ready") {
|
||||
pendingBusy.set(
|
||||
e.server,
|
||||
@@ -200,7 +208,7 @@ export function serversSummary(
|
||||
const tone = bad.length ? "error" : busy.length ? "busy" : servers.some((s) => s.status === "ready") ? "ok" : "idle";
|
||||
// A stopped server (one a preferred server took over from) isn't worth naming next to running ones.
|
||||
const active = servers.filter((s) => s.status !== "stopped");
|
||||
const names = [...new Set((active.length ? active : servers).map((s) => s.name))];
|
||||
const names = [...new Set((active.length ? active : servers).map(serverLabel))];
|
||||
const text = names.length === 1 ? names[0]! : `${names[0]} +${names.length - 1}`;
|
||||
return { text, tone };
|
||||
}
|
||||
|
||||
@@ -263,6 +263,79 @@ describe("editable result grid", () => {
|
||||
expect(screen.getByRole("button", { name: "Submit" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("blocks paging while a remounted result is applying edits", async () => {
|
||||
let finish!: (value: { status: string; data: number }) => void;
|
||||
const applying = new Promise((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
backend.applyEdits.mockReturnValue(applying);
|
||||
const first = show("SELECT * FROM users", [{ name: "id" }, { name: "name" }], [["1", "Ann"]]);
|
||||
first.outcome.result!.hasMore = true;
|
||||
useDatabase.setState({
|
||||
results: [
|
||||
first,
|
||||
{
|
||||
...first,
|
||||
id: nextId++,
|
||||
title: "Result 2",
|
||||
outcome: { ...first.outcome, result: { ...first.outcome.result! } },
|
||||
},
|
||||
],
|
||||
});
|
||||
await act(async () => render(<ResultsView />));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add Row" }));
|
||||
const cells = screen.getAllByRole("gridcell");
|
||||
fireEvent.mouseDown(cells[2]!, { button: 0 });
|
||||
fireEvent.paste(screen.getByRole("grid"), { clipboardData: { getData: () => "3\tCara" } });
|
||||
try {
|
||||
await act(async () => fireEvent.click(screen.getByRole("button", { name: "Submit" })));
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Result 2" }));
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Result 1" }));
|
||||
expect(screen.getByRole("gridcell", { name: "Cara" })).toBeTruthy();
|
||||
await act(async () => fireEvent.click(screen.getByRole("button", { name: "Next Page" })));
|
||||
expect(backend.execute).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole("button", { name: "Add Row" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Rerun Query" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Last Page" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Page size" })).toBeDisabled();
|
||||
expect(backend.applyEdits).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
await act(async () => finish({ status: "ok", data: 1 }));
|
||||
}
|
||||
});
|
||||
|
||||
it("finishes an apply after its result closes without refreshing another tab", async () => {
|
||||
let finish!: (value: { status: string; data: number }) => void;
|
||||
backend.applyEdits.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
finish = resolve;
|
||||
}),
|
||||
);
|
||||
const first = show("SELECT * FROM users", [{ name: "id" }, { name: "name" }], [["1", "Ann"]]);
|
||||
useDatabase.setState({
|
||||
results: [
|
||||
first,
|
||||
{
|
||||
...first,
|
||||
id: nextId++,
|
||||
title: "Result 2",
|
||||
outcome: { ...first.outcome, result: { ...first.outcome.result! } },
|
||||
},
|
||||
],
|
||||
});
|
||||
await act(async () => render(<ResultsView />));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add Row" }));
|
||||
await act(async () => fireEvent.click(screen.getByRole("button", { name: "Submit" })));
|
||||
fireEvent.keyDown(screen.getByRole("tab", { name: "Result 1" }), { key: "Delete" });
|
||||
expect(screen.getByRole("tab", { name: "Result 2" })).toHaveAttribute("aria-selected", "true");
|
||||
await act(async () => finish({ status: "ok", data: 1 }));
|
||||
expect(screen.queryByRole("tab", { name: "Result 1" })).toBeNull();
|
||||
expect(screen.getByRole("gridcell", { name: "Ann" })).toBeTruthy();
|
||||
expect(backend.execute).not.toHaveBeenCalled();
|
||||
expect(backend.applyEdits).toHaveBeenCalledTimes(1);
|
||||
expect(useDatabase.getState().output.map((line) => line.text)).toContain("users: 1 row(s) changed");
|
||||
});
|
||||
|
||||
it("drops applied edits from a remounted pane even when the refresh fails", async () => {
|
||||
let finish!: (value: { status: string; data: number }) => void;
|
||||
backend.applyEdits.mockImplementation(
|
||||
|
||||
@@ -19,6 +19,7 @@ vi.mock("@/platform/ipc", async (orig) => {
|
||||
evaluated.push(args);
|
||||
return Promise.resolve({ status: "ok", data: { value: "", variablesReference: 0 } });
|
||||
},
|
||||
debugJsResponseOverrides: () => Promise.resolve({ status: "ok", data: null }),
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -26,6 +27,7 @@ vi.mock("@/platform/ipc", async (orig) => {
|
||||
const { applyNetworkEvents, useJsNetwork } = await import("../js/network");
|
||||
const { NetworkView } = await import("../js/NetworkView");
|
||||
const { useJsBreakpoints } = await import("../js/eventBreakpoints");
|
||||
const { loadResponseOverrides, saveOverride } = await import("../js/responseOverrides");
|
||||
const { setRefused } = await import("../js/refusals");
|
||||
const { handleDebugEvent, useDebug } = await import("../state");
|
||||
|
||||
@@ -66,6 +68,8 @@ function load() {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
loadResponseOverrides(null);
|
||||
useJsNetwork.setState({ rows: {} });
|
||||
asked.length = 0;
|
||||
evaluated.length = 0;
|
||||
@@ -197,4 +201,58 @@ describe("Network view", () => {
|
||||
).toBeInTheDocument();
|
||||
setRefused(7, "network", null);
|
||||
});
|
||||
|
||||
it("marks the requests whose response a rule replaces", () => {
|
||||
loadResponseOverrides("/p");
|
||||
saveOverride({ pattern: "/api/items", status: 200, contentType: "application/json", body: "[]", enabled: true });
|
||||
load();
|
||||
render(<NetworkView session={7} />);
|
||||
const rows = screen.getAllByRole("row").slice(1);
|
||||
expect(rows[0]).toHaveTextContent("overridden");
|
||||
expect(rows[1]).not.toHaveTextContent("overridden");
|
||||
expect(screen.getByRole("button", { name: "Overrides (1)" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not mark a request as overridden when the page's debugger refused the rules, and says why", () => {
|
||||
loadResponseOverrides("/p");
|
||||
saveOverride({ pattern: "/api/items", status: 200, contentType: "application/json", body: "[]", enabled: true });
|
||||
load();
|
||||
setRefused(7, "overrides", "no browser connection");
|
||||
render(<NetworkView session={7} />);
|
||||
expect(screen.getAllByRole("row")[1]).not.toHaveTextContent("overridden");
|
||||
expect(screen.getByRole("alert")).toHaveTextContent(
|
||||
"Responses are not replaced in this page: no browser connection",
|
||||
);
|
||||
setRefused(7, "overrides", null);
|
||||
});
|
||||
|
||||
it("opens the override editor on a request, filled with its response", async () => {
|
||||
loadResponseOverrides("/p");
|
||||
load();
|
||||
render(<NetworkView session={7} />);
|
||||
fireEvent.click(screen.getAllByRole("row")[1]!);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Override Response…" }));
|
||||
await waitFor(() => expect(screen.getByLabelText("Part of the URL")).toHaveValue("/api/items"));
|
||||
expect(screen.getByLabelText("Status")).toHaveValue("200");
|
||||
expect(screen.getByLabelText("Content type")).toHaveValue("application/json");
|
||||
expect(screen.getByLabelText("Response body")).toHaveValue('{"items":[]}');
|
||||
});
|
||||
|
||||
it("starts from an empty body when the request's own can't be read", async () => {
|
||||
loadResponseOverrides("/p");
|
||||
load();
|
||||
answer = { status: "error", error: { kind: "unavailable", message: "gone" } };
|
||||
render(<NetworkView session={7} />);
|
||||
fireEvent.click(screen.getAllByRole("row")[1]!);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Override Response…" }));
|
||||
await waitFor(() => expect(screen.getByLabelText("Part of the URL")).toHaveValue("/api/items"));
|
||||
expect(screen.getByLabelText("Response body")).toHaveValue("");
|
||||
});
|
||||
|
||||
it("opens the list of overrides from the toolbar", () => {
|
||||
loadResponseOverrides("/p");
|
||||
render(<NetworkView session={7} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Overrides" }));
|
||||
expect(screen.getByText("No overrides yet")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
|
||||
vi.mock("@/platform/ipc", async (orig) => {
|
||||
const real = await orig<typeof import("@/platform/ipc")>();
|
||||
return {
|
||||
...real,
|
||||
commands: { ...real.commands, debugJsResponseOverrides: () => Promise.resolve({ status: "ok", data: null }) },
|
||||
};
|
||||
});
|
||||
|
||||
const { loadResponseOverrides, saveOverride, useResponseOverrides } = await import("../js/responseOverrides");
|
||||
const { OverridesDialog } = await import("../js/OverridesDialog");
|
||||
|
||||
const draft = { pattern: "/api/items", status: 200, contentType: "application/json", body: '{"items":[]}' };
|
||||
const rules = () => useResponseOverrides.getState().rules;
|
||||
const field = (label: string) => screen.getByLabelText(label) as HTMLInputElement | HTMLTextAreaElement;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
loadResponseOverrides("/p");
|
||||
});
|
||||
|
||||
describe("Response Overrides dialog", () => {
|
||||
it("opens on a request with its address, status, type and body filled in", () => {
|
||||
render(<OverridesDialog open onClose={() => {}} draft={draft} />);
|
||||
expect(field("Part of the URL").value).toBe("/api/items");
|
||||
expect(field("Status").value).toBe("200");
|
||||
expect(field("Content type").value).toBe("application/json");
|
||||
expect(field("Response body").value).toBe('{"items":[]}');
|
||||
expect(rules()).toEqual([]);
|
||||
});
|
||||
|
||||
it("saves the edited response as a rule that is on", () => {
|
||||
render(<OverridesDialog open onClose={() => {}} draft={draft} />);
|
||||
fireEvent.change(field("Response body"), { target: { value: '{"items":["from the IDE"]}' } });
|
||||
fireEvent.change(field("Status"), { target: { value: "201" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
expect(rules()).toMatchObject([
|
||||
{
|
||||
pattern: "/api/items",
|
||||
status: 201,
|
||||
contentType: "application/json",
|
||||
body: '{"items":["from the IDE"]}',
|
||||
enabled: true,
|
||||
},
|
||||
]);
|
||||
// Saved once more, it is the same rule.
|
||||
fireEvent.change(field("Response body"), { target: { value: "[]" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
expect(rules()).toHaveLength(1);
|
||||
expect(rules()[0]!.body).toBe("[]");
|
||||
});
|
||||
|
||||
it("edits the rule a request already has instead of adding a second one", () => {
|
||||
const id = saveOverride({ ...draft, body: "old", enabled: true });
|
||||
render(<OverridesDialog open onClose={() => {}} draft={draft} />);
|
||||
expect(field("Response body").value).toBe("old");
|
||||
fireEvent.change(field("Response body"), { target: { value: "new" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
expect(rules()).toMatchObject([{ id, body: "new" }]);
|
||||
});
|
||||
|
||||
it("warns that a very short text replaces many responses, and saves nothing without one", () => {
|
||||
render(<OverridesDialog open onClose={() => {}} draft={{ ...draft, pattern: "/" }} />);
|
||||
expect(screen.getByText("This also replaces every other response whose URL contains it")).toBeInTheDocument();
|
||||
fireEvent.change(field("Part of the URL"), { target: { value: " " } });
|
||||
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("lists the rules, switches them on and off, and removes the selected one", () => {
|
||||
saveOverride({ ...draft, enabled: true });
|
||||
saveOverride({ ...draft, pattern: "/api/users", enabled: true });
|
||||
render(<OverridesDialog open onClose={() => {}} />);
|
||||
const list = screen.getByRole("list", { name: "Overrides" });
|
||||
expect(within(list).getAllByRole("listitem")).toHaveLength(2);
|
||||
fireEvent.click(within(list).getByLabelText("/api/users"));
|
||||
expect(rules().map((r) => r.enabled)).toEqual([true, false]);
|
||||
|
||||
fireEvent.click(within(list).getByText("/api/items"));
|
||||
expect(field("Part of the URL").value).toBe("/api/items");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Remove" }));
|
||||
expect(rules().map((r) => r.pattern)).toEqual(["/api/users"]);
|
||||
});
|
||||
|
||||
it("starts an empty rule from the list", () => {
|
||||
render(<OverridesDialog open onClose={() => {}} />);
|
||||
expect(screen.getByText("No overrides yet")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add Override" }));
|
||||
fireEvent.change(field("Part of the URL"), { target: { value: "/api/new" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
expect(rules()).toMatchObject([{ pattern: "/api/new", status: 200, contentType: "application/json", body: "" }]);
|
||||
});
|
||||
});
|
||||
@@ -87,6 +87,7 @@ import { registerDebugActions } from "../actions";
|
||||
import { breakpointsOf, loadBreakpoints, toggleBreakpoint } from "../breakpoints/store";
|
||||
import { loadSpecialBreakpoints, removeAllSpecial, useSpecialBreakpoints } from "../breakpoints/special";
|
||||
import { publishContext, updateExecutionPoint, useExecutionPoint, useDebug, type DebugSession } from "../state";
|
||||
import { useJsProfile, watchProfiledSessions } from "../js/profile";
|
||||
import { focusDebugView, blurDebugView } from "../ui/focusedView";
|
||||
import { useOverlays } from "../ui/popoverStore";
|
||||
import { forgetMemory, selectMemoryClass } from "../memory/memoryState";
|
||||
@@ -408,6 +409,22 @@ describe("debugger actions (IDEA parity)", () => {
|
||||
expect(isActionEnabled(refresh)).toBe(false);
|
||||
});
|
||||
|
||||
it("offers Start CPU Profile for a live page, and Stop only while the page is recorded", () => {
|
||||
const unwatch = watchProfiledSessions();
|
||||
const start = getAction("debug.startPageProfile")!;
|
||||
const stop = getAction("debug.stopPageProfile")!;
|
||||
const enabled = () => [isActionEnabled(start), isActionEnabled(stop)];
|
||||
paused({ caps: caps({ jsPage: false }) });
|
||||
expect(enabled()).toEqual([false, false]);
|
||||
paused({ status: "running", caps: caps({ jsPage: true }) });
|
||||
expect(enabled()).toEqual([true, false]);
|
||||
useJsProfile.setState({ recording: { 3: true } });
|
||||
expect(enabled()).toEqual([false, true]);
|
||||
paused({ status: "terminated", caps: caps({ jsPage: true }) });
|
||||
expect(enabled()).toEqual([false, false]);
|
||||
unwatch();
|
||||
});
|
||||
|
||||
it("Show Execution Point reveals the top frame with focus", async () => {
|
||||
paused();
|
||||
await executeAction("debug.showExecutionPoint");
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const calls: Array<[string, number]> = [];
|
||||
let stopFails = false;
|
||||
vi.mock("@/platform/ipc", async (orig) => {
|
||||
const real = await orig<typeof import("@/platform/ipc")>();
|
||||
return {
|
||||
...real,
|
||||
commands: {
|
||||
...real.commands,
|
||||
debugJsStartProfile: (session: number) => {
|
||||
calls.push(["start", session]);
|
||||
return Promise.resolve({ status: "ok", data: null });
|
||||
},
|
||||
debugJsStopProfile: (session: number) => {
|
||||
calls.push(["stop", session]);
|
||||
return Promise.resolve(
|
||||
stopFails
|
||||
? { status: "error", error: { kind: "unavailable", message: "the session ended" } }
|
||||
: { status: "ok", data: "/tmp/openidea-js-profile-1" },
|
||||
);
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const shown = vi.fn(async (_id: string, _arg?: unknown) => true);
|
||||
vi.mock("@/platform/actions", async (orig) => {
|
||||
const real = await orig<typeof import("@/platform/actions")>();
|
||||
return { ...real, executeAction: (id: string, arg?: unknown) => shown(id, arg) };
|
||||
});
|
||||
|
||||
const { useProject } = await import("@/platform/project");
|
||||
const { useDebug } = await import("../state");
|
||||
const { isPageProfiled, pageProfileItems, startPageProfile, stopPageProfile, useJsProfile, watchProfiledSessions } =
|
||||
await import("../js/profile");
|
||||
|
||||
type DebugState = ReturnType<typeof useDebug.getState>;
|
||||
function sessions(list: Array<{ id: number; page: boolean; status?: string }>, active: number | null) {
|
||||
const out: Record<number, unknown> = {};
|
||||
for (const s of list)
|
||||
out[s.id] = { id: s.id, name: `page ${s.id}`, status: s.status ?? "running", caps: { jsPage: s.page } };
|
||||
useDebug.setState({ sessions: out as DebugState["sessions"], active });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
calls.length = 0;
|
||||
stopFails = false;
|
||||
shown.mockClear();
|
||||
useJsProfile.setState({ recording: {} });
|
||||
useProject.setState({ project: { root: "/work/shop" } as ReturnType<typeof useProject.getState>["project"] });
|
||||
sessions([{ id: 4, page: true }], 4);
|
||||
});
|
||||
|
||||
describe("CPU profile of a debugged page", () => {
|
||||
it("starts recording in the selected page session", async () => {
|
||||
await startPageProfile();
|
||||
expect(calls).toEqual([["start", 4]]);
|
||||
expect(isPageProfiled(4)).toBe(true);
|
||||
// Already recording: not started twice.
|
||||
await startPageProfile();
|
||||
expect(calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does nothing in a session that is not a page", async () => {
|
||||
sessions([{ id: 9, page: false }], 9);
|
||||
await startPageProfile();
|
||||
await stopPageProfile();
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
it("stops, and hands the recording to the Profiler", async () => {
|
||||
await startPageProfile();
|
||||
await stopPageProfile();
|
||||
expect(calls).toEqual([
|
||||
["start", 4],
|
||||
["stop", 4],
|
||||
]);
|
||||
expect(shown).toHaveBeenCalledWith("profiler.showCpuProfiles", {
|
||||
name: "page 4",
|
||||
dir: "/tmp/openidea-js-profile-1",
|
||||
root: "/work/shop",
|
||||
profiler: "js-debug",
|
||||
});
|
||||
expect(isPageProfiled(4)).toBe(false);
|
||||
});
|
||||
|
||||
it("gives up quietly when the recording can't be had", async () => {
|
||||
await startPageProfile();
|
||||
stopFails = true;
|
||||
await expect(stopPageProfile()).resolves.toBeUndefined();
|
||||
expect(shown).not.toHaveBeenCalled();
|
||||
expect(isPageProfiled(4)).toBe(false);
|
||||
});
|
||||
|
||||
it("offers the one of Start and Stop that applies, in a page's More menu only", () => {
|
||||
const action = (recording: boolean) =>
|
||||
pageProfileItems(true, recording).flatMap((i) => (i.kind === "action" ? [i.action] : []));
|
||||
expect(action(false)).toEqual(["debug.startPageProfile"]);
|
||||
expect(action(true)).toEqual(["debug.stopPageProfile"]);
|
||||
expect(pageProfileItems(false, false)).toEqual([]);
|
||||
});
|
||||
|
||||
it("forgets a recording whose session ended", async () => {
|
||||
const stop = watchProfiledSessions();
|
||||
await startPageProfile();
|
||||
sessions([{ id: 4, page: true, status: "terminated" }], 4);
|
||||
expect(isPageProfiled(4)).toBe(false);
|
||||
stop();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { JsResponseRule } from "@/platform/ipc";
|
||||
|
||||
const sent: Array<[number, JsResponseRule[]]> = [];
|
||||
let refuse: number | null = null;
|
||||
vi.mock("@/platform/ipc", async (orig) => {
|
||||
const real = await orig<typeof import("@/platform/ipc")>();
|
||||
return {
|
||||
...real,
|
||||
commands: {
|
||||
...real.commands,
|
||||
debugJsResponseOverrides: (session: number, rules: JsResponseRule[]) => {
|
||||
sent.push([session, rules]);
|
||||
return Promise.resolve(
|
||||
session === refuse
|
||||
? { status: "error", error: { kind: "unavailable", message: "refused" } }
|
||||
: { status: "ok", data: null },
|
||||
);
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const { useDebug } = await import("../state");
|
||||
const {
|
||||
activeRules,
|
||||
answeredBy,
|
||||
loadResponseOverrides,
|
||||
overriddenBy,
|
||||
removeOverride,
|
||||
saveOverride,
|
||||
syncResponseOverrides,
|
||||
toggleOverride,
|
||||
useResponseOverrides,
|
||||
watchOverrideSessions,
|
||||
} = await import("../js/responseOverrides");
|
||||
|
||||
const { refusedIn } = await import("../js/refusals");
|
||||
|
||||
type Sessions = ReturnType<typeof useDebug.getState>["sessions"];
|
||||
function sessions(list: Array<{ id: number; page: boolean; status?: string }>) {
|
||||
const out: Record<number, unknown> = {};
|
||||
for (const s of list)
|
||||
out[s.id] = { id: s.id, name: `s${s.id}`, status: s.status ?? "running", caps: { jsPage: s.page } };
|
||||
useDebug.setState({ sessions: out as Sessions });
|
||||
}
|
||||
const rules = () => useResponseOverrides.getState().rules;
|
||||
const items = { pattern: "/api/items", status: 200, contentType: "application/json", body: "[]", enabled: true };
|
||||
|
||||
beforeEach(async () => {
|
||||
localStorage.clear();
|
||||
sessions([]);
|
||||
loadResponseOverrides(null);
|
||||
await Promise.resolve();
|
||||
sent.length = 0;
|
||||
refuse = null;
|
||||
});
|
||||
|
||||
describe("response overrides", () => {
|
||||
it("sends the rules that are on and name a part of a URL", () => {
|
||||
const sentRules = activeRules([
|
||||
{ id: "a", ...items },
|
||||
{ id: "b", ...items, pattern: "/api/off", enabled: false },
|
||||
{ id: "c", ...items, pattern: " " },
|
||||
]);
|
||||
expect(sentRules).toEqual([
|
||||
{ urlContains: "/api/items", status: 200, contentType: "application/json", body: "[]" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("adds a rule, and updates it under the same id", () => {
|
||||
loadResponseOverrides("/a");
|
||||
const id = saveOverride({ ...items, pattern: " /api/items " });
|
||||
expect(rules()).toEqual([{ id, ...items }]);
|
||||
expect(saveOverride({ ...items, id, body: "[1]" })).toBe(id);
|
||||
expect(rules()).toEqual([{ id, ...items, body: "[1]" }]);
|
||||
toggleOverride(id, false);
|
||||
expect(rules()[0]!.enabled).toBe(false);
|
||||
removeOverride(id);
|
||||
expect(rules()).toEqual([]);
|
||||
});
|
||||
|
||||
it("answers 200 when the status is not an HTTP status", () => {
|
||||
loadResponseOverrides("/a");
|
||||
saveOverride({ ...items, status: 99 });
|
||||
saveOverride({ ...items, pattern: "/b", status: 600 });
|
||||
saveOverride({ ...items, pattern: "/c", status: Number.NaN });
|
||||
saveOverride({ ...items, pattern: "/d", status: 404 });
|
||||
expect(rules().map((r) => r.status)).toEqual([200, 200, 200, 404]);
|
||||
});
|
||||
|
||||
it("is stored per project", () => {
|
||||
loadResponseOverrides("/a");
|
||||
saveOverride(items);
|
||||
loadResponseOverrides("/b");
|
||||
expect(rules()).toEqual([]);
|
||||
loadResponseOverrides("/a");
|
||||
expect(rules().map((r) => r.pattern)).toEqual(["/api/items"]);
|
||||
});
|
||||
|
||||
it("tells which rule replaces a URL's response", () => {
|
||||
const list = [
|
||||
{ id: "off", ...items, pattern: "/api", enabled: false },
|
||||
{ id: "on", ...items },
|
||||
];
|
||||
expect(overriddenBy("http://h/api/items?x=1", list)?.id).toBe("on");
|
||||
expect(overriddenBy("http://h/api/users", list)).toBeNull();
|
||||
});
|
||||
|
||||
it("marks only the requests sent after a rule came into force", async () => {
|
||||
const { networkSerial } = await import("../js/network");
|
||||
const before = { url: "http://x/api/items?page=1", seq: networkSerial() - 1 };
|
||||
const id = saveOverride({
|
||||
pattern: "/api/items",
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: "[]",
|
||||
enabled: true,
|
||||
});
|
||||
const rules = useResponseOverrides.getState().rules;
|
||||
const after = { url: "http://x/api/items?page=2", seq: networkSerial() };
|
||||
expect(answeredBy(before, rules)).toBe(false);
|
||||
expect(answeredBy(after, rules)).toBe(true);
|
||||
// Rules the project has when it opens answer every request of its sessions.
|
||||
loadResponseOverrides(useResponseOverrides.getState().root);
|
||||
const loaded = useResponseOverrides.getState().rules;
|
||||
expect(answeredBy(before, loaded)).toBe(loaded.some((r) => r.id === id));
|
||||
});
|
||||
|
||||
it("syncs every live page session and swallows a refusal", async () => {
|
||||
sessions([
|
||||
{ id: 1, page: true },
|
||||
{ id: 2, page: false },
|
||||
{ id: 3, page: true },
|
||||
{ id: 4, page: true, status: "terminated" },
|
||||
]);
|
||||
loadResponseOverrides("/a");
|
||||
useResponseOverrides.setState({ rules: [{ id: "a", ...items }] });
|
||||
await Promise.resolve();
|
||||
sent.length = 0;
|
||||
refuse = 1;
|
||||
await expect(syncResponseOverrides()).resolves.toBeUndefined();
|
||||
expect(sent.map(([id, r]) => [id, r.length])).toEqual([
|
||||
[1, 1],
|
||||
[3, 1],
|
||||
]);
|
||||
// The page whose responses are not replaced is remembered, with the reason.
|
||||
expect([1, 3].map((id) => refusedIn(id, "overrides"))).toEqual(["refused", undefined]);
|
||||
refuse = null;
|
||||
await syncResponseOverrides();
|
||||
expect(refusedIn(1, "overrides")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("gives a page that starts later the rules, once, and nothing when none is on", async () => {
|
||||
loadResponseOverrides("/a");
|
||||
const stop = watchOverrideSessions();
|
||||
sessions([{ id: 5, page: true }]);
|
||||
await Promise.resolve();
|
||||
expect(sent).toEqual([]);
|
||||
|
||||
saveOverride(items);
|
||||
await Promise.resolve();
|
||||
sent.length = 0;
|
||||
sessions([
|
||||
{ id: 5, page: true },
|
||||
{ id: 7, page: true },
|
||||
]);
|
||||
sessions([
|
||||
{ id: 5, page: true },
|
||||
{ id: 7, page: true },
|
||||
]);
|
||||
await Promise.resolve();
|
||||
expect(sent.map(([id]) => id)).toEqual([7]);
|
||||
stop();
|
||||
});
|
||||
|
||||
it("gives a new page the rules when its session id was used before", async () => {
|
||||
loadResponseOverrides("/a");
|
||||
saveOverride(items);
|
||||
const stop = watchOverrideSessions();
|
||||
sessions([{ id: 1, page: true }]);
|
||||
await Promise.resolve();
|
||||
// Ids start over after the backend restarts.
|
||||
sessions([]);
|
||||
sent.length = 0;
|
||||
sessions([{ id: 1, page: true }]);
|
||||
await Promise.resolve();
|
||||
expect(sent.map(([id]) => id)).toEqual([1]);
|
||||
stop();
|
||||
});
|
||||
});
|
||||
@@ -41,6 +41,7 @@ import { refreshMemory, useMemory } from "./memory/memoryState";
|
||||
import { closeInstances, instancesOutdated, showInstances, showReferrers, useInstances } from "./memory/instancesState";
|
||||
import { jumpToClass } from "./memory/jumpToClass";
|
||||
import { captureHeap } from "./heap/heapState";
|
||||
import { startPageProfile, stopPageProfile } from "./js/profile";
|
||||
import { activeCapturedSnapshot, openHeapDump, saveHeapDump } from "./heap/heapDumpFiles";
|
||||
import { browserLaunch, browserLaunchConfig, browserUrl, installedDebugBrowser, isBrowserConfig } from "./browser";
|
||||
import { debugUnsupportedReason, launchConfigFor, withEnvFiles } from "./launch";
|
||||
@@ -584,6 +585,26 @@ export function registerDebugActions(): () => void {
|
||||
if (s && sessionSupports("jvmHeap", s)) void captureHeap(s.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "debug.startPageProfile",
|
||||
title: "Start CPU Profile",
|
||||
description: "Record where the debugged page spends its time",
|
||||
category: "Debug",
|
||||
icon: "profile",
|
||||
keywords: ["profiler", "performance", "flame graph", "browser"],
|
||||
when: "debugHasJsPage && !debugPageProfiled",
|
||||
run: () => startPageProfile(),
|
||||
},
|
||||
{
|
||||
id: "debug.stopPageProfile",
|
||||
title: "Stop CPU Profile",
|
||||
description: "Stop recording the page and open the profile",
|
||||
category: "Debug",
|
||||
icon: "profile",
|
||||
keywords: ["profiler", "performance", "flame graph", "browser"],
|
||||
when: "debugPageProfiled",
|
||||
run: () => stopPageProfile(),
|
||||
},
|
||||
{
|
||||
// The host opens dumps for a trusted project only (F21).
|
||||
id: "debug.heap.open",
|
||||
|
||||
@@ -22,6 +22,8 @@ import { loadBreakpoints } from "./breakpoints/store";
|
||||
import { loadExceptionBreakpoints } from "./breakpoints/exceptions";
|
||||
import { loadJsBreakpoints, watchPageSessions } from "./js/eventBreakpoints";
|
||||
import { watchNetworkSessions } from "./js/networkSessions";
|
||||
import { loadResponseOverrides, watchOverrideSessions } from "./js/responseOverrides";
|
||||
import { watchProfiledSessions } from "./js/profile";
|
||||
import { loadSpecialBreakpoints } from "./breakpoints/special";
|
||||
import { breakpointGutter } from "./editor/breakpointGutter";
|
||||
import { executionPoint } from "./editor/execution";
|
||||
@@ -66,6 +68,7 @@ export function activate(): () => void {
|
||||
loadExceptionBreakpoints(useProject.getState().project?.root ?? null);
|
||||
loadSpecialBreakpoints(useProject.getState().project?.root ?? null);
|
||||
loadJsBreakpoints(useProject.getState().project?.root ?? null);
|
||||
loadResponseOverrides(useProject.getState().project?.root ?? null);
|
||||
const disposers = [
|
||||
() => useDebugRunSessions.setState({ sessions: [], serviceSessions: [], Console: null }),
|
||||
registerDebugActions(),
|
||||
@@ -146,10 +149,13 @@ export function activate(): () => void {
|
||||
loadExceptionBreakpoints(root);
|
||||
loadSpecialBreakpoints(root);
|
||||
loadJsBreakpoints(root);
|
||||
loadResponseOverrides(root);
|
||||
}
|
||||
}),
|
||||
watchPageSessions(),
|
||||
watchNetworkSessions(),
|
||||
watchOverrideSessions(),
|
||||
watchProfiledSessions(),
|
||||
];
|
||||
return () => disposers.reverse().forEach((d) => d());
|
||||
}
|
||||
@@ -48,9 +48,20 @@
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
margin-left: 6px;
|
||||
border-radius: 3px;
|
||||
padding: 0 4px;
|
||||
background: var(--bg-selected);
|
||||
color: var(--fg-muted);
|
||||
font-size: var(--fs-small);
|
||||
}
|
||||
|
||||
.split {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
|
||||
@@ -11,7 +11,9 @@ import {
|
||||
type NetworkCategory,
|
||||
type NetworkRow,
|
||||
} from "./network";
|
||||
import { OverridesDialog, type OverrideDraft } from "./OverridesDialog";
|
||||
import { useRefused } from "./refusals";
|
||||
import { answeredBy, useResponseOverrides } from "./responseOverrides";
|
||||
import styles from "./Network.module.css";
|
||||
|
||||
const NO_ROWS: NetworkRow[] = [];
|
||||
@@ -39,10 +41,13 @@ export function formatDuration(ms: number | null): string {
|
||||
const Row = memo(function Row({
|
||||
row,
|
||||
selected,
|
||||
overridden,
|
||||
onSelect,
|
||||
}: {
|
||||
row: NetworkRow;
|
||||
selected: boolean;
|
||||
/** A response override applies to requests to this URL. */
|
||||
overridden: boolean;
|
||||
onSelect: (key: string) => void;
|
||||
}) {
|
||||
return (
|
||||
@@ -56,7 +61,14 @@ const Row = memo(function Row({
|
||||
<span role="cell" className={styles.name}>
|
||||
{row.name}
|
||||
</span>
|
||||
<span role="cell">{row.error ?? row.status ?? ""}</span>
|
||||
<span role="cell">
|
||||
{row.error ?? row.status ?? ""}
|
||||
{overridden && (
|
||||
<span className={styles.badge}>
|
||||
<T>overridden</T>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span role="cell">{row.method}</span>
|
||||
<span role="cell">{row.type}</span>
|
||||
<span role="cell" className={styles.number}>
|
||||
@@ -126,7 +138,15 @@ function ResponseBody({ session, requestId }: { session: number; requestId: stri
|
||||
);
|
||||
}
|
||||
|
||||
function Detail({ session, row }: { session: number; row: NetworkRow }) {
|
||||
function Detail({
|
||||
session,
|
||||
row,
|
||||
onOverride,
|
||||
}: {
|
||||
session: number;
|
||||
row: NetworkRow;
|
||||
onOverride: (row: NetworkRow) => void;
|
||||
}) {
|
||||
const tx = useTx();
|
||||
return (
|
||||
<div className={styles.detail} aria-label={tx("Request details")}>
|
||||
@@ -144,6 +164,9 @@ function Detail({ session, row }: { session: number; row: NetworkRow }) {
|
||||
<Button size="small" variant="ghost" onClick={() => addRequestBreakpoint(requestPattern(row.url))}>
|
||||
<T>Break on Next Request</T>
|
||||
</Button>
|
||||
<Button size="small" variant="ghost" onClick={() => onOverride(row)}>
|
||||
<T>Override Response…</T>
|
||||
</Button>
|
||||
</div>
|
||||
<Headers title={tx("Request Headers")} headers={row.requestHeaders} />
|
||||
<Headers title={tx("Response Headers")} headers={row.responseHeaders} />
|
||||
@@ -176,6 +199,23 @@ export function NetworkView({ session }: { session: number }) {
|
||||
const shown = useMemo(() => filterRows(rows, query, category, mine), [rows, query, category, mine]);
|
||||
const selected = rows.find((r) => r.key === selectedKey) ?? null;
|
||||
const notReported = useRefused(session, "network") != null;
|
||||
const overrides = useResponseOverrides((s) => s.rules);
|
||||
const overridesOn = overrides.filter((r) => r.enabled).length;
|
||||
const notReplaced = useRefused(session, "overrides");
|
||||
// The override editor: open on the list (`draft` null) or on a request's response.
|
||||
const [editing, setEditing] = useState<{ draft: OverrideDraft | null } | null>(null);
|
||||
const overrideResponse = async (row: NetworkRow) => {
|
||||
// The request's own body is where the edit starts; one that can't be read as text starts empty.
|
||||
const own = await call(commands.debugJsResponseBody(session, row.requestId)).catch(() => null);
|
||||
setEditing({
|
||||
draft: {
|
||||
pattern: requestPattern(row.url),
|
||||
status: row.status ?? 200,
|
||||
contentType: row.mimeType ?? "application/json",
|
||||
body: own && !own.base64Encoded ? own.body : "",
|
||||
},
|
||||
});
|
||||
};
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<div className={styles.toolbar}>
|
||||
@@ -199,12 +239,21 @@ export function NetworkView({ session }: { session: number }) {
|
||||
))}
|
||||
</div>
|
||||
<Checkbox checked={mine} onChange={setMine} label="My code only" />
|
||||
<Button size="small" variant="ghost" onClick={() => setEditing({ draft: null })}>
|
||||
{tx("Overrides")}
|
||||
{overridesOn ? ` (${overridesOn})` : ""}
|
||||
</Button>
|
||||
<IconButton icon="refresh" title="Reload Page" size="small" onClick={() => reloadPage(session)} />
|
||||
<IconButton icon="trash" title="Clear" size="small" onClick={() => clearNetwork(session)} />
|
||||
<span className={styles.count}>
|
||||
{shown.length === rows.length ? rows.length : `${shown.length} / ${rows.length}`}
|
||||
</span>
|
||||
</div>
|
||||
{notReplaced != null && overridesOn > 0 && (
|
||||
<div className={styles.refused} role="alert">
|
||||
{tx`Responses are not replaced in this page: ${notReplaced}`}
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.split}>
|
||||
<div className={styles.table} role="table" aria-label={tx("Requests")}>
|
||||
<div role="row" className={styles.head}>
|
||||
@@ -225,11 +274,18 @@ export function NetworkView({ session }: { session: number }) {
|
||||
</div>
|
||||
)}
|
||||
{shown.map((row) => (
|
||||
<Row key={row.key} row={row} selected={row.key === selectedKey} onSelect={setSelectedKey} />
|
||||
<Row
|
||||
key={row.key}
|
||||
row={row}
|
||||
selected={row.key === selectedKey}
|
||||
overridden={notReplaced == null && answeredBy(row, overrides)}
|
||||
onSelect={setSelectedKey}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{selected && <Detail session={session} row={selected} />}
|
||||
{selected && <Detail session={session} row={selected} onOverride={(row) => void overrideResponse(row)} />}
|
||||
</div>
|
||||
<OverridesDialog open={editing != null} onClose={() => setEditing(null)} draft={editing?.draft} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
.root {
|
||||
display: flex;
|
||||
height: 420px;
|
||||
min-height: 0;
|
||||
font-size: var(--fs-ui);
|
||||
}
|
||||
|
||||
.side {
|
||||
flex: 0 0 240px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
border-right: 1px solid var(--border-popup);
|
||||
}
|
||||
|
||||
.sideHead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 8px 4px 12px;
|
||||
}
|
||||
|
||||
.sideTitle {
|
||||
font-size: var(--fs-small);
|
||||
font-weight: 600;
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 24px;
|
||||
padding: 0 8px 0 12px;
|
||||
}
|
||||
|
||||
.item:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.itemOn,
|
||||
.itemOn:hover {
|
||||
background: var(--bg-list-selected);
|
||||
}
|
||||
|
||||
.itemName {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-small);
|
||||
text-align: left;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.itemStatus {
|
||||
color: var(--fg-subtle);
|
||||
font-size: var(--fs-small);
|
||||
}
|
||||
|
||||
.editor {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.grow {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: var(--fs-small);
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
min-height: 120px;
|
||||
resize: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 6px 8px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-small);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.body:focus {
|
||||
border-color: var(--border-popup);
|
||||
}
|
||||
|
||||
.warning {
|
||||
color: var(--danger);
|
||||
font-size: var(--fs-small);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.note {
|
||||
padding: 8px 12px;
|
||||
color: var(--fg-subtle);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { useState } from "react";
|
||||
import { useTx } from "@/platform/i18n";
|
||||
import { Button, Checkbox, Dialog, IconButton, Input, T } from "@/ui";
|
||||
import {
|
||||
removeOverride,
|
||||
saveOverride,
|
||||
toggleOverride,
|
||||
useResponseOverrides,
|
||||
type ResponseOverride,
|
||||
} from "./responseOverrides";
|
||||
import styles from "./Overrides.module.css";
|
||||
|
||||
/** A response to start a rule from: a request's own, as the Network tab shows it. */
|
||||
export interface OverrideDraft {
|
||||
pattern: string;
|
||||
status: number;
|
||||
contentType: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
interface Form {
|
||||
/** The rule being edited; absent for one not saved yet. */
|
||||
id?: string;
|
||||
pattern: string;
|
||||
status: string;
|
||||
contentType: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
const EMPTY: Form = { pattern: "", status: "200", contentType: "application/json", body: "" };
|
||||
|
||||
const formOf = (rule: ResponseOverride): Form => ({
|
||||
id: rule.id,
|
||||
pattern: rule.pattern,
|
||||
status: String(rule.status),
|
||||
contentType: rule.contentType,
|
||||
body: rule.body,
|
||||
});
|
||||
|
||||
/** The rule a request already has is edited; otherwise the request's response starts a new one. */
|
||||
function initialForm(draft: OverrideDraft | null | undefined, rules: readonly ResponseOverride[]): Form | null {
|
||||
if (!draft) return null;
|
||||
const pattern = draft.pattern.trim();
|
||||
const existing = rules.find((r) => r.pattern === pattern);
|
||||
return existing
|
||||
? formOf(existing)
|
||||
: { pattern, status: String(draft.status), contentType: draft.contentType, body: draft.body };
|
||||
}
|
||||
|
||||
function OverridesBody({ onClose, draft }: { onClose: () => void; draft?: OverrideDraft | null }) {
|
||||
const tx = useTx();
|
||||
const rules = useResponseOverrides((s) => s.rules);
|
||||
const [form, setForm] = useState<Form | null>(() => initialForm(draft, useResponseOverrides.getState().rules));
|
||||
const set = (patch: Partial<Form>) => setForm((f) => (f ? { ...f, ...patch } : f));
|
||||
const pattern = form?.pattern.trim() ?? "";
|
||||
const save = () => {
|
||||
if (!form || !pattern) return;
|
||||
const enabled = rules.find((r) => r.id === form.id)?.enabled ?? true;
|
||||
const id = saveOverride({
|
||||
id: form.id,
|
||||
pattern,
|
||||
status: Number(form.status),
|
||||
contentType: form.contentType.trim() || "text/plain",
|
||||
body: form.body,
|
||||
enabled,
|
||||
});
|
||||
setForm({ ...form, id, pattern });
|
||||
};
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onClose={onClose}
|
||||
title="Response Overrides"
|
||||
width={760}
|
||||
aria-label="Response Overrides"
|
||||
buttons={[{ label: "Done", role: "default", onClick: onClose }]}
|
||||
>
|
||||
<div className={styles.root}>
|
||||
<div className={styles.side}>
|
||||
<div className={styles.sideHead}>
|
||||
<span className={styles.sideTitle}>
|
||||
<T>Overrides</T>
|
||||
</span>
|
||||
<IconButton icon="plus" title="Add Override" size="small" onClick={() => setForm({ ...EMPTY })} />
|
||||
</div>
|
||||
{rules.length === 0 ? (
|
||||
<div className={styles.note}>
|
||||
<T>No overrides yet</T>
|
||||
</div>
|
||||
) : (
|
||||
<ul className={styles.list} aria-label={tx("Overrides")}>
|
||||
{rules.map((rule) => (
|
||||
<li key={rule.id} className={`${styles.item} ${rule.id === form?.id ? styles.itemOn : ""}`}>
|
||||
<Checkbox
|
||||
checked={rule.enabled}
|
||||
onChange={(on) => toggleOverride(rule.id, on)}
|
||||
aria-label={rule.pattern}
|
||||
/>
|
||||
<button type="button" className={styles.itemName} onClick={() => setForm(formOf(rule))}>
|
||||
{rule.pattern}
|
||||
</button>
|
||||
<span className={styles.itemStatus}>{rule.status}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
{form ? (
|
||||
<div className={styles.editor}>
|
||||
<label className={styles.field}>
|
||||
<span className={styles.label}>
|
||||
<T>Requests whose URL contains</T>
|
||||
</span>
|
||||
<Input
|
||||
mono
|
||||
value={form.pattern}
|
||||
onChange={(e) => set({ pattern: e.target.value })}
|
||||
placeholder="e.g. /api/items"
|
||||
aria-label="Part of the URL"
|
||||
/>
|
||||
</label>
|
||||
{pattern.length > 0 && pattern.length < 3 && (
|
||||
<div className={styles.warning}>
|
||||
<T>This also replaces every other response whose URL contains it</T>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.row}>
|
||||
<label className={styles.field}>
|
||||
<span className={styles.label}>
|
||||
<T>Status</T>
|
||||
</span>
|
||||
<Input mono value={form.status} onChange={(e) => set({ status: e.target.value })} aria-label="Status" />
|
||||
</label>
|
||||
<label className={`${styles.field} ${styles.grow}`}>
|
||||
<span className={styles.label}>
|
||||
<T>Content type</T>
|
||||
</span>
|
||||
<Input
|
||||
mono
|
||||
value={form.contentType}
|
||||
onChange={(e) => set({ contentType: e.target.value })}
|
||||
aria-label="Content type"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label className={`${styles.field} ${styles.grow}`}>
|
||||
<span className={styles.label}>
|
||||
<T>Response body</T>
|
||||
</span>
|
||||
<textarea
|
||||
className={styles.body}
|
||||
value={form.body}
|
||||
onChange={(e) => set({ body: e.target.value })}
|
||||
spellCheck={false}
|
||||
aria-label={tx("Response body")}
|
||||
/>
|
||||
</label>
|
||||
<div className={styles.actions}>
|
||||
<Button size="small" variant="primary" disabled={!pattern} onClick={save}>
|
||||
<T>Save</T>
|
||||
</Button>
|
||||
{form.id && (
|
||||
<Button
|
||||
size="small"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
removeOverride(form.id!);
|
||||
setForm(null);
|
||||
}}
|
||||
>
|
||||
<T>Remove</T>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={`${styles.editor} ${styles.note}`}>
|
||||
<T>Select an override, or add one, to edit the response it gives.</T>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The page's response overrides: the list, and the editor of one. Opened on a request
|
||||
* (`draft`), the editor starts from that request's response.
|
||||
*/
|
||||
export function OverridesDialog({
|
||||
open,
|
||||
onClose,
|
||||
draft,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
draft?: OverrideDraft | null;
|
||||
}) {
|
||||
// Mounted per opening, so the editor starts from the request it was opened on.
|
||||
return open ? <OverridesBody onClose={onClose} draft={draft} /> : null;
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import type { JsInitiator, JsNetworkEvent } from "@/platform/ipc";
|
||||
export interface NetworkRow {
|
||||
/** Unique among rows; a browser reuses request ids. */
|
||||
key: string;
|
||||
/** Order in which the IDE saw the requests (`networkSerial`). */
|
||||
seq: number;
|
||||
requestId: string;
|
||||
url: string;
|
||||
name: string;
|
||||
@@ -37,6 +39,9 @@ export const useJsNetwork = create<{ rows: Record<number, NetworkRow[]> }>(() =>
|
||||
let serial = 0;
|
||||
|
||||
/** The end of the URL's path with its query, or the host for the root path. */
|
||||
/** The `seq` the next request will get. */
|
||||
export const networkSerial = () => serial;
|
||||
|
||||
export function rowName(url: string): string {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
@@ -59,6 +64,7 @@ function apply(rows: NetworkRow[], e: JsNetworkEvent) {
|
||||
if (e.kind === "request") {
|
||||
const url = e.url ?? "";
|
||||
const started: NetworkRow = {
|
||||
seq: serial,
|
||||
key: `${e.requestId}#${serial++}`,
|
||||
requestId: e.requestId,
|
||||
url,
|
||||
@@ -77,7 +83,7 @@ function apply(rows: NetworkRow[], e: JsNetworkEvent) {
|
||||
initiator: e.initiator,
|
||||
};
|
||||
// Sent again while open: the request was redirected, and goes on under its new URL.
|
||||
if (at >= 0) rows[at] = { ...started, key: rows[at]!.key, startedAt: rows[at]!.startedAt };
|
||||
if (at >= 0) rows[at] = { ...started, key: rows[at]!.key, seq: rows[at]!.seq, startedAt: rows[at]!.startedAt };
|
||||
else rows.push(started);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Recording the CPU profile of a debugged page: js-debug samples the page between Start and
|
||||
* Stop, and the recording opens in the Profiler tool window like any other snapshot.
|
||||
*/
|
||||
import { create } from "zustand";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { setContext } from "@/platform/context";
|
||||
import { call, commands } from "@/platform/ipc";
|
||||
import { useProject } from "@/platform/project";
|
||||
import type { MenuItem } from "@/ui";
|
||||
import { notifications } from "@/workbench/contributions";
|
||||
import { activeSession, isLive, useDebug, type DebugSession } from "../state";
|
||||
|
||||
/** Page sessions whose CPU profile is being recorded. */
|
||||
export const useJsProfile = create<{ recording: Record<number, boolean> }>(() => ({ recording: {} }));
|
||||
|
||||
export const isPageProfiled = (session: number) => !!useJsProfile.getState().recording[session];
|
||||
|
||||
function setRecording(session: number, on: boolean) {
|
||||
useJsProfile.setState((s) => {
|
||||
const recording = { ...s.recording };
|
||||
if (on) recording[session] = true;
|
||||
else delete recording[session];
|
||||
return { recording };
|
||||
});
|
||||
}
|
||||
|
||||
/** The selected session when it debugs a page. */
|
||||
function selectedPage(): DebugSession | null {
|
||||
const s = activeSession();
|
||||
return s && isLive(s) && s.caps?.jsPage ? s : null;
|
||||
}
|
||||
|
||||
export async function startPageProfile(): Promise<void> {
|
||||
const page = selectedPage();
|
||||
if (!page || isPageProfiled(page.id)) return;
|
||||
try {
|
||||
await call(commands.debugJsStartProfile(page.id));
|
||||
setRecording(page.id, true);
|
||||
} catch (e) {
|
||||
notifications().notify({
|
||||
group: "run",
|
||||
level: "warning",
|
||||
title: "Cannot record a CPU profile",
|
||||
body: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopPageProfile(): Promise<void> {
|
||||
const page = selectedPage();
|
||||
if (!page || !isPageProfiled(page.id)) return;
|
||||
setRecording(page.id, false);
|
||||
try {
|
||||
const dir = await call(commands.debugJsStopProfile(page.id));
|
||||
const root = useProject.getState().project?.root ?? "";
|
||||
await executeAction("profiler.showCpuProfiles", { name: page.name, dir, root, profiler: "js-debug" });
|
||||
} catch {
|
||||
// The page ended while it was recorded: there is no profile to show.
|
||||
}
|
||||
}
|
||||
|
||||
/** The More menu's entry of a session: Start or Stop for a page, nothing for any other program. */
|
||||
export function pageProfileItems(page: boolean, recording: boolean): MenuItem[] {
|
||||
if (!page) return [];
|
||||
return [
|
||||
{ kind: "separator" },
|
||||
{ kind: "action", action: recording ? "debug.stopPageProfile" : "debug.startPageProfile", icon: "profile" },
|
||||
];
|
||||
}
|
||||
|
||||
function publishProfiled() {
|
||||
const page = selectedPage();
|
||||
setContext("debugPageProfiled", !!page && isPageProfiled(page.id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Forgets the recording of a session that ended, and keeps `debugPageProfiled` on the selected
|
||||
* session. Returns the unsubscribe function.
|
||||
*/
|
||||
export function watchProfiledSessions(): () => void {
|
||||
const offSessions = useDebug.subscribe((s) => {
|
||||
for (const id of Object.keys(useJsProfile.getState().recording)) {
|
||||
const session = s.sessions[+id];
|
||||
if (!session || !isLive(session)) setRecording(+id, false);
|
||||
}
|
||||
publishProfiled();
|
||||
});
|
||||
const offRecording = useJsProfile.subscribe(publishProfiled);
|
||||
return () => {
|
||||
offSessions();
|
||||
offRecording();
|
||||
};
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
import { create } from "zustand";
|
||||
|
||||
export type JsTool = "eventBreakpoints" | "network";
|
||||
export type JsTool = "eventBreakpoints" | "network" | "overrides";
|
||||
|
||||
/** Per session, the reason each refused tool was refused for. */
|
||||
export const useJsRefusals = create<{ refused: Record<number, Partial<Record<JsTool, string>>> }>(() => ({
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Responses a debugged page gets in place of the server's: "a request whose URL contains
|
||||
* this text is answered with this status, content type and body". Kept per project and
|
||||
* given to every page session, to try a screen against data the backend doesn't return yet.
|
||||
*/
|
||||
import { create } from "zustand";
|
||||
import { call, commands, type JsResponseRule } from "@/platform/ipc";
|
||||
import { isLive, isLiveSession, useDebug } from "../state";
|
||||
import { networkSerial } from "./network";
|
||||
import { recordRefusal } from "./refusals";
|
||||
|
||||
export interface ResponseOverride {
|
||||
id: string;
|
||||
/** Part of the URL. */
|
||||
pattern: string;
|
||||
status: number;
|
||||
contentType: string;
|
||||
body: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
const PREFIX = "openidea.debugger.jsOverrides:";
|
||||
|
||||
export const useResponseOverrides = create<{ root: string | null; rules: ResponseOverride[] }>(() => ({
|
||||
root: null,
|
||||
rules: [],
|
||||
}));
|
||||
|
||||
/** A status a browser accepts for a response; anything else answers 200. */
|
||||
const validStatus = (status: number) => (Number.isInteger(status) && status >= 100 && status <= 599 ? status : 200);
|
||||
|
||||
function stored(root: string): ResponseOverride[] {
|
||||
try {
|
||||
const raw = JSON.parse(localStorage.getItem(PREFIX + root) ?? "[]") as unknown;
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return (raw as Array<Partial<ResponseOverride>>)
|
||||
.filter((r) => typeof r?.id === "string" && typeof r.pattern === "string")
|
||||
.map((r) => ({
|
||||
id: r.id!,
|
||||
pattern: r.pattern!,
|
||||
status: validStatus(Number(r.status)),
|
||||
contentType: typeof r.contentType === "string" ? r.contentType : "application/json",
|
||||
body: typeof r.body === "string" ? r.body : "",
|
||||
enabled: r.enabled !== false,
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The first request (`NetworkRow.seq`) each rule can have answered, for rules added, turned on or
|
||||
* pointed at other URLs while pages ran; rules loaded with the project answer from the start.
|
||||
*/
|
||||
const since = new Map<string, number>();
|
||||
|
||||
export function loadResponseOverrides(root: string | null) {
|
||||
since.clear();
|
||||
useResponseOverrides.setState({ root, rules: root ? stored(root) : [] });
|
||||
void syncResponseOverrides();
|
||||
}
|
||||
|
||||
function change(rules: ResponseOverride[]) {
|
||||
useResponseOverrides.setState({ rules });
|
||||
const { root } = useResponseOverrides.getState();
|
||||
if (root) {
|
||||
try {
|
||||
localStorage.setItem(PREFIX + root, JSON.stringify(rules));
|
||||
} catch {
|
||||
// Storage unavailable: the rules last for this session only.
|
||||
}
|
||||
}
|
||||
void syncResponseOverrides();
|
||||
}
|
||||
|
||||
/** Adds a rule, or replaces the one with `rule.id`. Returns the rule's id. */
|
||||
|
||||
/** Whether the rule that matches `row` was in force when the page sent it. */
|
||||
export function answeredBy(row: { url: string; seq: number }, rules: readonly ResponseOverride[]): boolean {
|
||||
const rule = overriddenBy(row.url, rules);
|
||||
return rule != null && row.seq >= (since.get(rule.id) ?? 0);
|
||||
}
|
||||
|
||||
export function saveOverride(rule: Omit<ResponseOverride, "id"> & { id?: string }): string {
|
||||
const { rules } = useResponseOverrides.getState();
|
||||
const id = rule.id ?? `o${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
||||
const saved = { ...rule, id, pattern: rule.pattern.trim(), status: validStatus(rule.status) };
|
||||
const before = rules.find((r) => r.id === id);
|
||||
if (saved.enabled && (!before?.enabled || before.pattern !== saved.pattern)) since.set(id, networkSerial());
|
||||
change(rules.some((r) => r.id === id) ? rules.map((r) => (r.id === id ? saved : r)) : [...rules, saved]);
|
||||
return id;
|
||||
}
|
||||
|
||||
export function removeOverride(id: string) {
|
||||
change(useResponseOverrides.getState().rules.filter((r) => r.id !== id));
|
||||
}
|
||||
|
||||
export function toggleOverride(id: string, on: boolean) {
|
||||
if (on && !useResponseOverrides.getState().rules.find((r) => r.id === id)?.enabled) since.set(id, networkSerial());
|
||||
change(useResponseOverrides.getState().rules.map((r) => (r.id === id ? { ...r, enabled: on } : r)));
|
||||
}
|
||||
|
||||
/** What the debugger is given: the rules that are on and name a part of a URL. */
|
||||
export function activeRules(rules: readonly ResponseOverride[]): JsResponseRule[] {
|
||||
return rules
|
||||
.filter((r) => r.enabled && r.pattern.trim())
|
||||
.map((r) => ({ urlContains: r.pattern.trim(), status: r.status, contentType: r.contentType, body: r.body }));
|
||||
}
|
||||
|
||||
/** The rule that replaces the response of `url`, as the debugger picks it: the first that is on and matches. */
|
||||
export function overriddenBy(url: string, rules: readonly ResponseOverride[]): ResponseOverride | null {
|
||||
return rules.find((r) => r.enabled && r.pattern.trim() && url.includes(r.pattern.trim())) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gives the live page sessions (`only` those, when given) the rules. A page whose debugger
|
||||
* refuses keeps the server's responses, which is recorded for the views to say.
|
||||
*/
|
||||
export async function syncResponseOverrides(only?: readonly number[]): Promise<void> {
|
||||
const rules = activeRules(useResponseOverrides.getState().rules);
|
||||
const pages = Object.values(useDebug.getState().sessions).filter(
|
||||
(s) => isLive(s) && s.caps?.jsPage && (!only || only.includes(s.id)),
|
||||
);
|
||||
await Promise.all(
|
||||
pages.map((s) =>
|
||||
recordRefusal(s.id, "overrides", call(commands.debugJsResponseOverrides(s.id, rules)), () => isLiveSession(s.id)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** Gives each page session the rules when it appears. Returns the unsubscribe function. */
|
||||
export function watchOverrideSessions(): () => void {
|
||||
const seen = new Set<number>();
|
||||
return useDebug.subscribe((s) => {
|
||||
// Ids start over after the backend restarts: a removed session's id may come back as a new page.
|
||||
for (const id of seen) if (!s.sessions[id]) seen.delete(id);
|
||||
for (const session of Object.values(s.sessions)) {
|
||||
if (seen.has(session.id) || !session.caps?.jsPage || !isLive(session)) continue;
|
||||
seen.add(session.id);
|
||||
// A new page has none: nothing to send.
|
||||
if (activeRules(useResponseOverrides.getState().rules).length) void syncResponseOverrides([session.id]);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -392,6 +392,7 @@ export function publishContext() {
|
||||
);
|
||||
// Refresh Memory View acts on the selected session only.
|
||||
setContext("debugHasJvmMemory", !!s && isLive(s) && sessionSupports("jvmMemory", s));
|
||||
setContext("debugHasJsPage", !!s && isLive(s) && sessionSupports("jsPage", s));
|
||||
}
|
||||
|
||||
// --- console -----------------------------------------------------------------
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
addWatch,
|
||||
closeSessionTab,
|
||||
evaluate,
|
||||
isLive,
|
||||
selectFrame,
|
||||
selectSession,
|
||||
selectThread,
|
||||
@@ -43,6 +44,7 @@ import { sessionTabs } from "./sessionTabs";
|
||||
import { stopNote } from "../js/eventBreakpoints";
|
||||
import { NetworkView } from "../js/NetworkView";
|
||||
import { liveWatchesApply, useLiveWatches } from "../js/liveWatches";
|
||||
import { pageProfileItems, useJsProfile } from "../js/profile";
|
||||
import { MemoryView } from "../memory/MemoryView";
|
||||
import { DebugConsole } from "./DebugConsole";
|
||||
import { VariablesView, type VarRoot } from "./VariablesView";
|
||||
@@ -86,6 +88,12 @@ const MORE_ITEMS: MenuItem[] = [
|
||||
function MoreActions() {
|
||||
const ref = useRef<HTMLButtonElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const page = useDebug((s) => {
|
||||
const a = s.active == null ? undefined : s.sessions[s.active];
|
||||
return a && isLive(a) && a.caps?.jsPage ? a.id : null;
|
||||
});
|
||||
const recording = useJsProfile((s) => page != null && !!s.recording[page]);
|
||||
const items = useMemo(() => [...MORE_ITEMS, ...pageProfileItems(page != null, recording)], [page, recording]);
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
@@ -99,7 +107,7 @@ function MoreActions() {
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
/>
|
||||
<Menu open={open} anchor={ref} items={MORE_ITEMS} placement="bottom-start" onClose={() => setOpen(false)} />
|
||||
<Menu open={open} anchor={ref} items={items} placement="bottom-start" onClose={() => setOpen(false)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -261,7 +261,7 @@ export function HttpClientView() {
|
||||
void refreshHistory();
|
||||
}, []);
|
||||
return (
|
||||
<SplitGroup orientation="horizontal" className={styles.root}>
|
||||
<SplitGroup orientation="horizontal" className={styles.root} storageKey="openidea.httpClient.split">
|
||||
<SplitPanel id="http-history" defaultSize="32%" minSize="160px">
|
||||
<div className={styles.list} role="list" aria-label={tx("Response history")}>
|
||||
{running && (
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Profile, ProfileSpec } from "@/platform/ipc";
|
||||
|
||||
const collected: ProfileSpec[] = [];
|
||||
let fails = false;
|
||||
const profile = { profiler: "v8", unit: "samples", mode: "cpu", total: 3, frames: [] } as unknown as Profile;
|
||||
vi.mock("@/platform/ipc", async (orig) => {
|
||||
const real = await orig<typeof import("@/platform/ipc")>();
|
||||
return {
|
||||
...real,
|
||||
commands: {
|
||||
...real.commands,
|
||||
profilerCollect: (spec: ProfileSpec) => {
|
||||
collected.push(spec);
|
||||
return Promise.resolve(
|
||||
fails
|
||||
? { status: "error", error: { kind: "invalidInput", message: "no samples" } }
|
||||
: { status: "ok", data: profile },
|
||||
);
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const { showCpuProfiles } = await import("./cpuProfiles");
|
||||
const { resetProfiler, useProfiler } = await import("./store");
|
||||
|
||||
beforeEach(() => {
|
||||
collected.length = 0;
|
||||
fails = false;
|
||||
resetProfiler();
|
||||
});
|
||||
|
||||
describe("CPU profiles recorded elsewhere", () => {
|
||||
it("open as a snapshot named after what was profiled", async () => {
|
||||
await showCpuProfiles({ name: "localhost:5173", dir: "/tmp/p", root: "/work/shop" });
|
||||
expect(collected).toEqual([{ kind: "cpuProfiles", dir: "/tmp/p", root: "/work/shop" }]);
|
||||
expect(useProfiler.getState().snapshots.map((s) => [s.name, s.profile.total])).toEqual([["localhost:5173", 3]]);
|
||||
});
|
||||
|
||||
it("name what recorded them when the caller says", async () => {
|
||||
await showCpuProfiles({ name: "page", dir: "/tmp/p", root: "/work/shop", profiler: "js-debug" });
|
||||
await showCpuProfiles({ name: "script", dir: "/tmp/q", root: "/work/shop" });
|
||||
expect(useProfiler.getState().snapshots.map((s) => s.profile.profiler)).toEqual(["v8", "js-debug"]);
|
||||
});
|
||||
|
||||
it("add nothing when the directory can't be read as a profile, or isn't named", async () => {
|
||||
fails = true;
|
||||
await expect(showCpuProfiles({ name: "page", dir: "/tmp/p", root: "/work/shop" })).resolves.toBeUndefined();
|
||||
await showCpuProfiles({ name: "page" });
|
||||
await showCpuProfiles(undefined);
|
||||
expect(collected).toHaveLength(1);
|
||||
expect(useProfiler.getState().snapshots).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { call, commands } from "@/platform/ipc";
|
||||
import { notifications, toolWindowService } from "@/workbench/contributions";
|
||||
import { addSnapshot } from "./store";
|
||||
|
||||
/** A directory of V8 `.cpuprofile` files recorded by another feature (the debugger, for a page). */
|
||||
interface CpuProfiles {
|
||||
/** What was profiled: the snapshot's name. */
|
||||
name: string;
|
||||
dir: string;
|
||||
/** The project root, which tells the project's frames from library ones. */
|
||||
root: string;
|
||||
/** What recorded the profile, shown in the snapshot's header (default: `node --cpu-prof`). */
|
||||
profiler?: string;
|
||||
}
|
||||
|
||||
function isCpuProfiles(arg: unknown): arg is CpuProfiles {
|
||||
const a = arg as Partial<CpuProfiles> | null | undefined;
|
||||
return (
|
||||
typeof a?.name === "string" &&
|
||||
typeof a.dir === "string" &&
|
||||
typeof a.root === "string" &&
|
||||
(a.profiler === undefined || typeof a.profiler === "string")
|
||||
);
|
||||
}
|
||||
|
||||
/** Opens the recording as a snapshot in the Profiler tool window (`profiler.showCpuProfiles`). */
|
||||
export async function showCpuProfiles(arg: unknown): Promise<void> {
|
||||
if (!isCpuProfiles(arg)) return;
|
||||
try {
|
||||
const profile = await call(commands.profilerCollect({ kind: "cpuProfiles", dir: arg.dir, root: arg.root }));
|
||||
addSnapshot(arg.name, arg.profiler ? { ...profile, profiler: arg.profiler } : profile);
|
||||
} catch (e) {
|
||||
notifications().notify({
|
||||
group: "run",
|
||||
level: "warning",
|
||||
title: "No CPU profile to show",
|
||||
body: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
toolWindowService().show("profiler", true);
|
||||
} catch {
|
||||
// No workbench (tests).
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
import { executeAction, registerActions } from "@/platform/actions";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { registerRunExecutor, registerToolWindow } from "@/workbench/contributions";
|
||||
import { showCpuProfiles } from "./cpuProfiles";
|
||||
import { profilerExecutor } from "./executor";
|
||||
import { ProfilerTabs, ProfilerView } from "./ProfilerView";
|
||||
import { resetProfiler } from "./store";
|
||||
@@ -35,6 +36,16 @@ export function activate(): () => void {
|
||||
keywords: ["profiler", "flame graph", "cpu"],
|
||||
run: () => executeAction("run.withExecutor", { executor: "profiler" }).then(() => undefined),
|
||||
},
|
||||
{
|
||||
id: "profiler.showCpuProfiles",
|
||||
title: "Show CPU Profile",
|
||||
description:
|
||||
"Opens the V8 .cpuprofile files of a directory as a snapshot (arg: { name, dir, root, profiler? }).",
|
||||
category: "Run",
|
||||
icon: "profile",
|
||||
hidden: true,
|
||||
run: ({ arg }) => showCpuProfiles(arg),
|
||||
},
|
||||
{
|
||||
id: "run.profileAllocations",
|
||||
title: "Profile Allocations",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
import { useProject } from "@/platform/project";
|
||||
import { buildCMake, isCMakeBuild } from "./cmake";
|
||||
import { dotnetBuildConfig } from "./dotnetTypes";
|
||||
import { dotnetProjectFile } from "./dotnetTypes";
|
||||
import { useBuildPresentation } from "@/platform/projectPresentation";
|
||||
import { buildTask } from "./buildTask";
|
||||
import { moduleTaskError } from "./configs";
|
||||
@@ -84,8 +84,12 @@ async function runTask(
|
||||
): Promise<string | null> {
|
||||
const target = task.target ?? "";
|
||||
switch (task.kind) {
|
||||
case "dotnetBuild":
|
||||
return (await runToEnd(dotnetBuildConfig(config), chain, show)) ? null : "Build failed";
|
||||
case "dotnetBuild": {
|
||||
// In the Build tool window, like Build Project: its messages link to the sources.
|
||||
const project = dotnetProjectFile(config);
|
||||
if (!project) return "Select a .NET project";
|
||||
return buildTask("build.buildModule", { target: project, activate: false, wait: true });
|
||||
}
|
||||
case "build": {
|
||||
if (target) return buildCMake(target, show === true);
|
||||
const error = await buildTask(BUILD_ACTION, { activate: false });
|
||||
|
||||
@@ -3,7 +3,7 @@ import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { commands } from "@/platform/ipc";
|
||||
import type { RunConfig } from "@/platform/ipc";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { dotnetBuildConfig, dotnetProjectType, dotnetTestType } from "./dotnetTypes";
|
||||
import { dotnetProjectFile, dotnetProjectType, dotnetTestType } from "./dotnetTypes";
|
||||
import { ConfigForm } from "./ConfigForm";
|
||||
import { validateConfig } from "./configs";
|
||||
import { formParams, newConfigOfType, registerBuiltinTypes, withParams } from "./types";
|
||||
@@ -99,16 +99,12 @@ describe(".NET configurations", () => {
|
||||
expect(formParams({ ...typed, args: ["build", "A"] }, dotnetProjectType)).toBeNull();
|
||||
});
|
||||
|
||||
it("builds edited project/framework from the open root instead of a profile cwd", () => {
|
||||
it("builds the edited project from the open root instead of a profile cwd", () => {
|
||||
useProject.setState({ project: { root: "/repo" } as never });
|
||||
const c = withParams(
|
||||
{ ...config(), cwd: "profile/work" },
|
||||
{ ...params, project: "other/App.csproj", framework: "net9.0" },
|
||||
);
|
||||
const build = dotnetBuildConfig(c);
|
||||
expect(build.args).toEqual(["build", "other/App.csproj", "--configuration", "Debug", "--framework", "net9.0"]);
|
||||
expect(build.cwd).toBe("/repo");
|
||||
expect(build.beforeLaunch).toEqual([]);
|
||||
const c = withParams({ ...config(), cwd: "profile/work" }, { ...params, project: "other/App.csproj" });
|
||||
expect(dotnetProjectFile(c)).toBe("/repo/other/App.csproj");
|
||||
expect(dotnetProjectFile(withParams(c, { ...params, project: "C:\\src\\App.csproj" }))).toBe("C:\\src\\App.csproj");
|
||||
expect(dotnetProjectFile(withParams(c, { ...params, project: "" }))).toBeNull();
|
||||
});
|
||||
|
||||
it("edits project, framework, profile, arguments and environment in the shared form", () => {
|
||||
|
||||
@@ -61,24 +61,13 @@ function dotnetRunParams(c: RunConfig): Record<string, string> | null {
|
||||
return { project: "", ...p };
|
||||
}
|
||||
|
||||
/** Build the current project/framework, independently of the run's profile working directory. */
|
||||
export function dotnetBuildConfig(config: RunConfig): RunConfig {
|
||||
const p = config.params ?? {};
|
||||
return {
|
||||
...config,
|
||||
id: `dotnet-build:${config.id}`,
|
||||
name: `dotnet build (${p.project || ""})`,
|
||||
kind: "shell",
|
||||
args: ["build", p.project || "", "--configuration", "Debug", ...(p.framework ? ["--framework", p.framework] : [])],
|
||||
cwd: useProject.getState().project?.root ?? null,
|
||||
env: {},
|
||||
params: {},
|
||||
isTest: false,
|
||||
debugAdapter: null,
|
||||
beforeLaunch: [],
|
||||
emulateTerminal: false,
|
||||
stdinFile: undefined,
|
||||
};
|
||||
/** The configuration's project file as a path the Build tool window builds (its own project, not the profile's cwd). */
|
||||
export function dotnetProjectFile(config: RunConfig): string | null {
|
||||
const project = config.params?.project?.trim();
|
||||
if (!project) return null;
|
||||
if (/^(?:[A-Za-z]:[\\/]|\/)/.test(project)) return project;
|
||||
const root = useProject.getState().project?.root;
|
||||
return root ? `${root}/${project}` : null;
|
||||
}
|
||||
|
||||
export const dotnetProjectType: RunConfigTypeContribution = {
|
||||
|
||||
@@ -64,6 +64,7 @@ import { isDebugOnly, registerBuiltinTypes } from "./types";
|
||||
import { junitType } from "./junitType";
|
||||
import { listenForApplicationDeactivation, showApplicationUpdate, updatableSessions } from "./applicationUpdate";
|
||||
import { npmGutter } from "./npmGutter";
|
||||
import { launchSettingsGutter } from "./launchSettingsGutter";
|
||||
import { runGutter } from "./runGutter";
|
||||
import { gradleDelegation } from "./gradleRun";
|
||||
import { activateStackTrace, useStackTraces } from "./stackTrace";
|
||||
@@ -446,6 +447,7 @@ export function activate(): () => void {
|
||||
}),
|
||||
registerEditorContribution({ id: "run.targets", order: 55, extensions: runGutter }),
|
||||
registerEditorContribution({ id: "run.npmScripts", order: 58, extensions: npmGutter }),
|
||||
registerEditorContribution({ id: "run.launchSettings", order: 58, extensions: launchSettingsGutter }),
|
||||
registerMenuGroup({
|
||||
id: "run.context",
|
||||
menus: ["editor", "projectView", "editorTab"],
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { RunConfig } from "@/platform/ipc";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { profileConfig, projectProfiles } from "./launchSettingsGutter";
|
||||
|
||||
const SETTINGS = `{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"applicationUrl": "http://localhost:5170"
|
||||
},
|
||||
"IIS Express": {
|
||||
"commandName": "IISExpress"
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project"
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
const config = (id: string, name: string, launchProfile: string): RunConfig =>
|
||||
({
|
||||
id,
|
||||
name,
|
||||
kind: "dotnet",
|
||||
command: "dotnet",
|
||||
args: ["run", "--project", "src/Api/Api.csproj"],
|
||||
params: { project: "src/Api/Api.csproj", framework: "net9.0", launchProfile },
|
||||
}) as unknown as RunConfig;
|
||||
|
||||
afterEach(() => useProject.setState({ project: null }));
|
||||
|
||||
describe("launchSettings.json run icons", () => {
|
||||
it("mark the profiles a run can use, on their lines", () => {
|
||||
expect(projectProfiles(SETTINGS)).toEqual([
|
||||
{ name: "http", line: 3 },
|
||||
{ name: "https", line: 10 },
|
||||
]);
|
||||
// While the file doesn't parse, every profile keeps its icon.
|
||||
expect(projectProfiles(SETTINGS.replace('"https": {', '"https": {,')).map((e) => e.name)).toEqual([
|
||||
"http",
|
||||
"IIS Express",
|
||||
]);
|
||||
});
|
||||
|
||||
it("run the project's configuration for the profile, or a temporary copy of it", () => {
|
||||
useProject.setState({ project: { root: "/repo" } as never });
|
||||
const path = "/repo/src/Api/Properties/launchSettings.json";
|
||||
const configs = [
|
||||
config("dotnet:src/Api/Api.csproj:run:", "Api (.NET Project)", ""),
|
||||
config("dotnet:src/Api/Api.csproj:run:http", "Api: http", "http"),
|
||||
];
|
||||
expect(profileConfig(configs, path, "http")).toEqual({ config: configs[1], saved: true });
|
||||
const added = profileConfig(configs, path, "https")!;
|
||||
expect(added.saved).toBe(false);
|
||||
expect(added.config.name).toBe("Api: https");
|
||||
expect(added.config.params?.launchProfile).toBe("https");
|
||||
expect(added.config.args).toEqual([
|
||||
"run",
|
||||
"--project",
|
||||
"src/Api/Api.csproj",
|
||||
"--framework",
|
||||
"net9.0",
|
||||
"--launch-profile",
|
||||
"https",
|
||||
]);
|
||||
expect(profileConfig(configs, "/repo/src/Other/Properties/launchSettings.json", "http")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Gutter ▶ icons on the profiles of a .NET project's `Properties/launchSettings.json`:
|
||||
* Run / Debug launch the project's configuration for that profile.
|
||||
*/
|
||||
import type { Extension } from "@codemirror/state";
|
||||
import type { EditorDocContext } from "@/editor/api";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import type { RunConfig } from "@/platform/ipc";
|
||||
import { shortcutLabel } from "@/platform/keymap";
|
||||
import { useProject } from "@/platform/project";
|
||||
import type { MenuItem } from "@/ui";
|
||||
import { dotnetCommand } from "./dotnetTypes";
|
||||
import { entryGutter, objectEntries, type ScriptEntry } from "./npmGutter";
|
||||
import { loadConfigs, useRun } from "./store";
|
||||
|
||||
const FILE = /(^|\/)Properties\/launchSettings\.json$/;
|
||||
|
||||
/** The project directory of a `launchSettings.json`, relative to the project root when inside it. */
|
||||
function projectDir(path: string): string {
|
||||
const dir = path.replace(FILE, "");
|
||||
const root = useProject.getState().project?.root;
|
||||
return root && dir.startsWith(`${root}/`) ? dir.slice(root.length + 1) : root === dir ? "" : dir;
|
||||
}
|
||||
|
||||
const dirOf = (project: string) => project.replace(/\\/g, "/").replace(/\/?[^/]*$/, "");
|
||||
|
||||
/**
|
||||
* The project's configuration for `profile`: the one already set to it, else a temporary copy of
|
||||
* the project's configuration with that launch profile (a profile added since detection ran).
|
||||
*/
|
||||
export function profileConfig(
|
||||
configs: readonly RunConfig[],
|
||||
path: string,
|
||||
profile: string,
|
||||
): { config: RunConfig; saved: boolean } | null {
|
||||
const dir = projectDir(path);
|
||||
const ofProject = configs.filter((c) => c.kind === "dotnet" && dirOf(c.params?.project ?? "") === dir);
|
||||
const saved = ofProject.find((c) => c.params?.launchProfile === profile);
|
||||
if (saved) return { config: saved, saved: true };
|
||||
const base = ofProject.find((c) => !c.params?.launchProfile) ?? ofProject[0];
|
||||
if (!base) return null;
|
||||
const params = { ...base.params, launchProfile: profile };
|
||||
return {
|
||||
config: {
|
||||
...base,
|
||||
id: `context:dotnet:${base.params?.project ?? dir}:${profile}`,
|
||||
name: `${base.name.replace(/ \(.NET Project\)$|: .*$/, "")}: ${profile}`,
|
||||
args: dotnetCommand(params).args,
|
||||
params,
|
||||
},
|
||||
saved: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The profiles a run can use: `commandName: "Project"` (IIS and Docker profiles are run by other
|
||||
* tools). While the text doesn't parse, every profile key keeps its icon.
|
||||
*/
|
||||
export function projectProfiles(text: string): ScriptEntry[] {
|
||||
const entries = objectEntries(text, "profiles");
|
||||
type Profiles = Record<string, { commandName?: unknown } | undefined>;
|
||||
let profiles: Profiles;
|
||||
try {
|
||||
profiles = (JSON.parse(text.replace(/^\uFEFF/, "")) as { profiles?: Profiles }).profiles ?? {};
|
||||
} catch {
|
||||
return entries;
|
||||
}
|
||||
return entries.filter((e) => profiles[e.name]?.commandName === "Project");
|
||||
}
|
||||
|
||||
async function profileMenu(entry: ScriptEntry, path: string): Promise<MenuItem[]> {
|
||||
try {
|
||||
// Detection reads the disk: a profile added moments ago is already there.
|
||||
await loadConfigs();
|
||||
} catch {
|
||||
// Use what is loaded.
|
||||
}
|
||||
const found = profileConfig(useRun.getState().configs, path, entry.name);
|
||||
if (!found) return [{ label: tx`No .NET run configuration for this project`, disabled: true }];
|
||||
const { config, saved } = found;
|
||||
return [
|
||||
{
|
||||
label: tx`Run '${config.name}'`,
|
||||
icon: "run",
|
||||
shortcut: shortcutLabel("run.runContext"),
|
||||
run: () =>
|
||||
saved
|
||||
? void executeAction("run.runConfig", config.id)
|
||||
: void executeAction("run.runConfig", { config, temporary: true }),
|
||||
},
|
||||
{
|
||||
label: tx`Debug '${config.name}'`,
|
||||
icon: "debug",
|
||||
shortcut: shortcutLabel("run.debugContext"),
|
||||
run: () => void executeAction("run.debugConfig", config),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function launchSettingsGutter(ctx: EditorDocContext): Extension {
|
||||
return entryGutter(ctx, FILE, projectProfiles, profileMenu);
|
||||
}
|
||||
@@ -21,13 +21,15 @@ export interface ScriptEntry {
|
||||
line: number;
|
||||
}
|
||||
|
||||
/** The top-level `scripts` entries of `package.json` text, in document order. */
|
||||
export const scriptEntries = (text: string): ScriptEntry[] => objectEntries(text, "scripts");
|
||||
|
||||
/**
|
||||
* The top-level `scripts` entries of `package.json` text, in document order
|
||||
* (a small JSON scanner: script values may contain braces, colons, escapes).
|
||||
* Entries parsed so far survive malformed text, so a trailing comma keeps
|
||||
* earlier scripts visible.
|
||||
* The keys of the top-level object `section` of JSON text, in document order
|
||||
* (a small JSON scanner: values may contain braces, colons, escapes). Entries
|
||||
* parsed so far survive malformed text, so a trailing comma keeps earlier ones visible.
|
||||
*/
|
||||
export function scriptEntries(text: string): ScriptEntry[] {
|
||||
export function objectEntries(text: string, section: string): ScriptEntry[] {
|
||||
const entries: ScriptEntry[] = [];
|
||||
let i = 0;
|
||||
let line = 0;
|
||||
@@ -132,7 +134,7 @@ export function scriptEntries(text: string): ScriptEntry[] {
|
||||
if (text[i] !== ":") break;
|
||||
i++;
|
||||
ws();
|
||||
if (key === "scripts" && text[i] === "{") {
|
||||
if (key === section && text[i] === "{") {
|
||||
i++;
|
||||
ws();
|
||||
if (text[i] !== "}") {
|
||||
@@ -153,7 +155,7 @@ export function scriptEntries(text: string): ScriptEntry[] {
|
||||
break;
|
||||
}
|
||||
}
|
||||
i++; // the closing `}` of `scripts`
|
||||
i++; // the closing `}` of the section
|
||||
} else {
|
||||
value();
|
||||
}
|
||||
@@ -302,19 +304,13 @@ interface Placed extends ScriptEntry {
|
||||
|
||||
const setEntries = StateEffect.define<Placed[]>();
|
||||
|
||||
const entriesField = StateField.define<Placed[]>({
|
||||
create: () => [],
|
||||
update(value, tr) {
|
||||
for (const e of tr.effects) if (e.is(setEntries)) return e.value;
|
||||
if (!tr.docChanged) return value;
|
||||
return value.map((p) => ({ ...p, pos: tr.state.doc.lineAt(tr.changes.mapPos(p.pos, -1)).from }));
|
||||
},
|
||||
});
|
||||
type EntryMenu = (entry: ScriptEntry, path: string, content: string) => Promise<MenuItem[]>;
|
||||
|
||||
class ScriptMarker extends GutterMarker {
|
||||
constructor(
|
||||
readonly entry: ScriptEntry,
|
||||
readonly path: string,
|
||||
readonly menu: EntryMenu,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
@@ -330,7 +326,7 @@ class ScriptMarker extends GutterMarker {
|
||||
el.innerHTML = gutterIconMarkup("gutter-run") ?? "";
|
||||
el.addEventListener("click", () => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
void scriptMenuItems(this.entry, this.path, view.state.doc.toString()).then((items) =>
|
||||
void this.menu(this.entry, this.path, view.state.doc.toString()).then((items) =>
|
||||
showContextMenu({ x: rect.left, y: rect.bottom + 2 }, items),
|
||||
);
|
||||
});
|
||||
@@ -343,8 +339,29 @@ const scriptSpacer = gutterSpacer(styles.runMarker ?? "");
|
||||
const DEBOUNCE = 300;
|
||||
|
||||
export function npmGutter(ctx: EditorDocContext): Extension {
|
||||
return entryGutter(ctx, /(^|\/)package\.json$/, scriptEntries, scriptMenuItems);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run ▶ icons on the entries `entries` finds in files matching `file`; a click opens `menu`.
|
||||
* Shared by `package.json` scripts and `launchSettings.json` profiles.
|
||||
*/
|
||||
export function entryGutter(
|
||||
ctx: EditorDocContext,
|
||||
file: RegExp,
|
||||
entries: (text: string) => ScriptEntry[],
|
||||
menu: EntryMenu,
|
||||
): Extension {
|
||||
const path = ctx.path;
|
||||
if (ctx.virtual || !/(^|\/)package\.json$/.test(path) || !useProject.getState().project?.root) return [];
|
||||
if (ctx.virtual || !file.test(path) || !useProject.getState().project?.root) return [];
|
||||
const entriesField = StateField.define<Placed[]>({
|
||||
create: () => [],
|
||||
update(value, tr) {
|
||||
for (const e of tr.effects) if (e.is(setEntries)) return e.value;
|
||||
if (!tr.docChanged) return value;
|
||||
return value.map((p) => ({ ...p, pos: tr.state.doc.lineAt(tr.changes.mapPos(p.pos, -1)).from }));
|
||||
},
|
||||
});
|
||||
const detector = ViewPlugin.fromClass(
|
||||
class {
|
||||
timer: ReturnType<typeof setTimeout> | undefined;
|
||||
@@ -358,10 +375,10 @@ export function npmGutter(ctx: EditorDocContext): Extension {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = setTimeout(() => {
|
||||
const view = this.view;
|
||||
const entries = scriptEntries(view.state.doc.toString())
|
||||
const found = entries(view.state.doc.toString())
|
||||
.filter((e) => e.line < view.state.doc.lines)
|
||||
.map((e) => ({ ...e, pos: view.state.doc.line(e.line + 1).from }));
|
||||
view.dispatch({ effects: setEntries.of(entries) });
|
||||
view.dispatch({ effects: setEntries.of(found) });
|
||||
}, delay);
|
||||
}
|
||||
destroy() {
|
||||
@@ -377,7 +394,7 @@ export function npmGutter(ctx: EditorDocContext): Extension {
|
||||
class: `${styles.runGutter} cm-openidea-gutter-run`,
|
||||
markers: (view) =>
|
||||
RangeSet.of(
|
||||
view.state.field(entriesField).map((e) => new ScriptMarker(e, path).range(e.pos)),
|
||||
view.state.field(entriesField).map((e) => new ScriptMarker(e, path, menu).range(e.pos)),
|
||||
true,
|
||||
),
|
||||
// Reserve width only while the file has scripts, like the run gutter.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { HierarchyItem, OutlineItem, Pos } from "@/platform/ipc";
|
||||
import type { ClassInheritance, HierarchyItem, OutlineItem, Pos } from "@/platform/ipc";
|
||||
import { pythonHierarchy, pythonSupertypeItems } from "@/editor/navigation/pythonHierarchy";
|
||||
import { inheritedMembers, typeKey, typesIn } from "../structure/inherited";
|
||||
import { keyOutline, symbolPathAt, withInherited } from "../structure/outline";
|
||||
|
||||
@@ -75,3 +76,28 @@ describe("Show Inherited", () => {
|
||||
expect(symbolPathAt(bare, { line: 3, col: 0 })).toEqual([bare[0]!.key]);
|
||||
});
|
||||
});
|
||||
|
||||
// Python servers declare no `typeHierarchyProvider`: the IDE's inheritance graph answers instead.
|
||||
const PY_GRAPH: ClassInheritance[] = [
|
||||
{ path: "/p/dog.py", line: 0, col: 6, name: "Dog", bases: ["Animal"] },
|
||||
{ path: "/p/animal.py", line: 0, col: 6, name: "Animal", bases: ["Living"] },
|
||||
{ path: "/p/living.py", line: 0, col: 6, name: "Living", bases: [] },
|
||||
];
|
||||
const pyHierarchy = pythonHierarchy(PY_GRAPH, { readFiles: async () => new Map() });
|
||||
const pyOutlines: Record<string, OutlineItem[]> = {
|
||||
"/p/animal.py": [item("Animal", "class", 0, [item("eat", "method", 2), item("sleep", "method", 3)])],
|
||||
"/p/living.py": [item("Living", "class", 0, [item("age", "field", 1)])],
|
||||
};
|
||||
const pyDeps = {
|
||||
supertypes: (path: string, pos: Pos) => pythonSupertypeItems(pyHierarchy, path, pos),
|
||||
outline: async (path: string) => pyOutlines[path] ?? [],
|
||||
};
|
||||
|
||||
describe("Show Inherited without a type hierarchy (Python)", () => {
|
||||
it("collects supertype members from the IDE's inheritance graph", async () => {
|
||||
const pyDog = item("Dog", "class", 0, [item("bark", "method", 1)]);
|
||||
const members = await inheritedMembers("/p/dog.py", pyDog, pyDeps);
|
||||
expect(members.map((m) => `${m.owner}.${m.item.name}`)).toEqual(["Animal.eat", "Animal.sleep", "Living.age"]);
|
||||
expect(members[0]!.path).toBe("/p/animal.py");
|
||||
});
|
||||
});
|
||||
@@ -422,7 +422,12 @@ function FindTabView({ tab }: { tab: FindTab }) {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<SplitGroup orientation="horizontal" className={styles.split} id="find-results-split">
|
||||
<SplitGroup
|
||||
orientation="horizontal"
|
||||
className={styles.split}
|
||||
id="find-results-split"
|
||||
storageKey={showPreview ? "openidea.find.previewSplit" : undefined}
|
||||
>
|
||||
<SplitPanel id="results" minSize={120} className={styles.splitPanel}>
|
||||
<Tree<Node>
|
||||
className={styles.tree}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { useEffect } from "react";
|
||||
import { create } from "zustand";
|
||||
import { editorsFor, useEditorRegistry } from "@/editor/api";
|
||||
import { projectPythonHierarchy, pythonSupertypeItems } from "@/editor/navigation/pythonHierarchy";
|
||||
import { call, commands, IpcError, type OutlineItem, type Pos } from "@/platform/ipc";
|
||||
import { isVirtualUri } from "@/platform/virtualDocs";
|
||||
import { loadJson, saveJson } from "../common/open";
|
||||
@@ -53,8 +54,16 @@ async function withDoc<T>(path: string, fn: (doc: number) => Promise<T>): Promis
|
||||
|
||||
const supertypesOf = (doc: number, pos: Pos) => call(commands.lspTypeHierarchy(doc, pos, "incoming"));
|
||||
|
||||
export const inheritedDeps = (path: string, doc: number): InheritedDeps => ({
|
||||
supertypes: (p, pos) => (p === path ? supertypesOf(doc, pos) : withDoc(p, (d) => supertypesOf(d, pos))),
|
||||
/** Python's supertypes: its servers declare no `typeHierarchyProvider`, the IDE's graph answers instead. */
|
||||
async function pythonSupertypesOf(path: string, pos: Pos) {
|
||||
const hierarchy = await projectPythonHierarchy().catch(() => null);
|
||||
return hierarchy ? pythonSupertypeItems(hierarchy, path, pos) : [];
|
||||
}
|
||||
|
||||
export const inheritedDeps = (path: string, doc: number, python = false): InheritedDeps => ({
|
||||
supertypes: python
|
||||
? (p, pos) => pythonSupertypesOf(p, pos)
|
||||
: (p, pos) => (p === path ? supertypesOf(doc, pos) : withDoc(p, (d) => supertypesOf(d, pos))),
|
||||
outline: (p) => (p === path ? call(commands.docOutline(doc)) : withDoc(p, (d) => call(commands.docOutline(d)))),
|
||||
});
|
||||
|
||||
@@ -70,17 +79,23 @@ async function probeHierarchy(doc: number, types: readonly OutlineItem[]): Promi
|
||||
}
|
||||
}
|
||||
|
||||
async function loadInherited(doc: number, path: string, items: readonly OutlineItem[], my: number) {
|
||||
async function loadInherited(doc: number, path: string, items: readonly OutlineItem[], my: number, language: string) {
|
||||
const types = typesIn(items);
|
||||
// Probed once per file; a reload after edits keeps the answer.
|
||||
// Probed once per file; a reload after edits keeps the answer. Python needs no probe: the
|
||||
// graph answers its supertypes while its servers declare no type hierarchy.
|
||||
const known = useOutline.getState();
|
||||
const supported = known.path === path && known.inheritedSupported ? true : await probeHierarchy(doc, types);
|
||||
const supported =
|
||||
language === "python"
|
||||
? types.length > 0
|
||||
: known.path === path && known.inheritedSupported
|
||||
? true
|
||||
: await probeHierarchy(doc, types);
|
||||
if (my !== seq) return;
|
||||
if (!supported || !useStructureOptions.getState().showInherited) {
|
||||
useOutline.setState({ inheritedSupported: supported, inherited: null });
|
||||
return;
|
||||
}
|
||||
const deps = inheritedDeps(path, doc);
|
||||
const deps = inheritedDeps(path, doc, language === "python");
|
||||
const entries = await Promise.all(
|
||||
types.map(async (t) => [typeKey(t), await inheritedMembers(path, t, deps).catch(() => [])] as const),
|
||||
);
|
||||
@@ -88,7 +103,7 @@ async function loadInherited(doc: number, path: string, items: readonly OutlineI
|
||||
useOutline.setState({ inheritedSupported: true, inherited: new Map(entries.filter(([, m]) => m.length)) });
|
||||
}
|
||||
|
||||
async function load(doc: number, path: string) {
|
||||
async function load(doc: number, path: string, language: string) {
|
||||
const my = ++seq;
|
||||
useOutline.setState((s) =>
|
||||
s.path === path
|
||||
@@ -99,7 +114,7 @@ async function load(doc: number, path: string) {
|
||||
const items = await call(commands.docOutline(doc));
|
||||
if (my !== seq) return;
|
||||
useOutline.setState({ path, doc, items, loading: false, error: null });
|
||||
void loadInherited(doc, path, items, my);
|
||||
void loadInherited(doc, path, items, my, language);
|
||||
} catch (e) {
|
||||
if (my === seq) useOutline.setState({ path, doc, loading: false, error: String((e as Error)?.message ?? e) });
|
||||
}
|
||||
@@ -124,7 +139,7 @@ function start(): () => void {
|
||||
});
|
||||
return;
|
||||
}
|
||||
void load(ctx.doc, ctx.path);
|
||||
void load(ctx.doc, ctx.path, ctx.language);
|
||||
};
|
||||
reloadNow();
|
||||
const unsubActive = useEditorRegistry.subscribe((s, prev) => {
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ContainerPython } from "@/bindings";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
container: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
packages: vi.fn(),
|
||||
effective: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/platform/ipc", async (importOriginal) => {
|
||||
const real = await importOriginal<typeof import("@/platform/ipc")>();
|
||||
return {
|
||||
...real,
|
||||
isTauri: () => true,
|
||||
commands: {
|
||||
...real.commands,
|
||||
sdkDetect: vi.fn(async () => ({ status: "ok", data: [] })),
|
||||
sdkContainerPython: mocks.container,
|
||||
sdkContainerPythonRefresh: mocks.refresh,
|
||||
sdkPythonPackages: mocks.packages,
|
||||
sdkPythonEffective: mocks.effective,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const { PageContext } = await import("../components/context");
|
||||
const { PythonInterpreterPage } = await import("../languages/PythonInterpreterPage");
|
||||
const { interpreterTarget, targetChoices, useSdks } = await import("../languages/api");
|
||||
const { DEFAULT_SETTINGS } = await import("../model/schema");
|
||||
const { useSettingsDialog } = await import("../model/staging");
|
||||
const { DEFAULT_RUN_TARGET } = await import("@/platform/remoteSettings");
|
||||
|
||||
const ok = <T,>(data: T) => ({ status: "ok" as const, data });
|
||||
const SNAPSHOT: ContainerPython = {
|
||||
target: "web",
|
||||
name: "Compose web",
|
||||
version: "3.12.7",
|
||||
image: "sha256:0123456789abcdef",
|
||||
folders: ["/cache/web/0123/fs/usr/local/lib/python3.12/site-packages"],
|
||||
packages: [
|
||||
{ name: "Django", version: "5.1.3" },
|
||||
{ name: "asgiref", version: "3.8.1" },
|
||||
],
|
||||
updating: false,
|
||||
};
|
||||
const TARGETS = [
|
||||
{ ...DEFAULT_RUN_TARGET, kind: "compose" as const, id: "web", name: "Compose web", service: "web" },
|
||||
{ ...DEFAULT_RUN_TARGET, kind: "ssh" as const, id: "box", name: "Build box" },
|
||||
];
|
||||
|
||||
function mount(interpreter: string) {
|
||||
const settings = structuredClone(DEFAULT_SETTINGS) as typeof DEFAULT_SETTINGS & Record<string, unknown>;
|
||||
settings.languages.python.interpreter = interpreter;
|
||||
settings.runTargets = TARGETS;
|
||||
useSettingsDialog.setState({ base: settings, globalBase: structuredClone(DEFAULT_SETTINGS), staged: {}, query: "" });
|
||||
render(
|
||||
<PageContext.Provider value={{ pageId: "python.interpreter", scope: "project" }}>
|
||||
<PythonInterpreterPage />
|
||||
</PageContext.Provider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
Object.assign(window, { __TAURI_INTERNALS__: { transformCallback: () => 1 } });
|
||||
useSdks.setState({ byKind: { python: [] }, loading: {}, errors: {} });
|
||||
mocks.container.mockResolvedValue(ok(SNAPSHOT));
|
||||
mocks.refresh.mockResolvedValue(ok(SNAPSHOT));
|
||||
mocks.effective.mockResolvedValue(ok(null));
|
||||
mocks.packages.mockResolvedValue(ok([]));
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("run target interpreter", () => {
|
||||
it("names Docker and Compose targets only", () => {
|
||||
expect(targetChoices(TARGETS)).toEqual([
|
||||
{ value: "target:web", label: "Compose web", verbatim: true, detail: "Docker Compose service web" },
|
||||
]);
|
||||
expect(interpreterTarget(" target:web ")).toBe("web");
|
||||
for (const local of ["", "target:", "/usr/bin/python3", null]) expect(interpreterTarget(local)).toBeNull();
|
||||
});
|
||||
|
||||
it("lists the snapshot's packages read-only", async () => {
|
||||
mount("target:web");
|
||||
expect(await screen.findByText("Django")).toBeInTheDocument();
|
||||
expect(screen.getByText("Compose web — Python 3.12.7 · 0123456789ab")).toBeInTheDocument();
|
||||
expect(screen.getByText(/read-only here/)).toBeInTheDocument();
|
||||
for (const name of ["Install", "Uninstall", "Upgrade"]) {
|
||||
expect(screen.getByRole("button", { name })).toBeDisabled();
|
||||
}
|
||||
expect(mocks.packages).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Copy Libraries from the Container Again" }));
|
||||
await waitFor(() => expect(mocks.refresh).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it("offers the container targets in the interpreter list", async () => {
|
||||
mount("");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Python interpreter" }));
|
||||
expect(await screen.findByRole("option", { name: /Compose web/ })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("option", { name: /Build box/ })).toBeNull();
|
||||
expect(mocks.container).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -141,6 +141,27 @@ describe("Python interpreter status", () => {
|
||||
expect(screen.getByText("/usr/local/bin/python3.11")).toBeTruthy();
|
||||
expect(screen.queryByText("/venv/bin/python")).toBeNull();
|
||||
});
|
||||
it("shows a run target interpreter by name and offers container targets", async () => {
|
||||
mocks.files.mockResolvedValue([{ path: "/project/manage.py" }]);
|
||||
mocks.detect.mockResolvedValue([{ path: "/venv/python", name: "Python 3.12 (.venv)" }]);
|
||||
const runTargets = [
|
||||
{ kind: "compose", id: "web", name: "Compose web", service: "web" },
|
||||
{ kind: "docker", id: "img", name: "Image", image: "python:3.12" },
|
||||
{ kind: "ssh", id: "box", name: "Build box" },
|
||||
];
|
||||
useProject.setState({
|
||||
settings: { languages: { python: { interpreter: "target:web" } }, runTargets } as never,
|
||||
});
|
||||
mount();
|
||||
const button = await screen.findByRole("button", { name: "Python Interpreter: Compose web" });
|
||||
expect(button.querySelector("svg")).toBeNull();
|
||||
await act(() => executeAction("python.interpreterPopup"));
|
||||
expect(await screen.findByText("Docker Compose service web")).toBeTruthy();
|
||||
expect(screen.getByText("Image")).toBeTruthy();
|
||||
expect(screen.queryByText("Build box")).toBeNull();
|
||||
await act(async () => screen.getByText("Image").click());
|
||||
expect(mocks.set).toHaveBeenCalledWith("project", "languages.python.interpreter", JSON.stringify("target:img"));
|
||||
});
|
||||
it("opens the Python page when the widget is hidden", async () => {
|
||||
const run = vi.fn();
|
||||
const off = registerAction({ id: "file.settings", title: "Settings…", run });
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
* Settings › Python › Dependencies (PyCharm's "Python Interpreter" page): the
|
||||
* project interpreter and its packages (pip / uv pip). The interpreter feeds
|
||||
* run configurations, basedpyright / pyright (`python.pythonPath`) and the
|
||||
* terminal's virtualenv activation.
|
||||
* terminal's virtualenv activation. A Docker or Compose run target can be the
|
||||
* interpreter (`target:<id>`): its libraries are a read-only snapshot of the image.
|
||||
*/
|
||||
import { isCancelled } from "@/platform/ipc";
|
||||
import { useCallback, useEffect, useMemo, useState, type KeyboardEvent, type MouseEvent } from "react";
|
||||
@@ -10,7 +11,18 @@ import clsx from "clsx";
|
||||
import { Button, IconButton, Input, Spinner, showContextMenu, T } from "@/ui";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { Section, Toggle } from "../components/Form";
|
||||
import { effectivePython, listPackages, pip, type PythonPackage } from "./api";
|
||||
import {
|
||||
containerPython,
|
||||
effectivePython,
|
||||
interpreterTarget,
|
||||
listPackages,
|
||||
pip,
|
||||
refreshContainerPython,
|
||||
targetChoices,
|
||||
type ContainerPython,
|
||||
type PythonPackage,
|
||||
} from "./api";
|
||||
import { validateRunTargets, type RunTargetValid } from "@/platform/remoteSettings";
|
||||
import { AddInterpreterDialog } from "./AddInterpreterDialog";
|
||||
import { useSetting } from "../components/context";
|
||||
import { SdkField, tildify } from "./components";
|
||||
@@ -63,6 +75,54 @@ function usePackages(python: string | null) {
|
||||
};
|
||||
}
|
||||
|
||||
/** The snapshot of a run target interpreter; polled while one is being taken. */
|
||||
function useContainerPython(target: string | null) {
|
||||
const [state, setState] = useState<{ target: string; info: ContainerPython | null; error: string | null } | null>(
|
||||
null,
|
||||
);
|
||||
const [tick, setTick] = useState(0);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!target) return;
|
||||
let live = true;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const load = () =>
|
||||
containerPython()
|
||||
.then((info) => {
|
||||
if (!live) return;
|
||||
setState({ target, info, error: null });
|
||||
if (info?.updating) timer = setTimeout(load, 2000);
|
||||
})
|
||||
.catch((e: unknown) => live && setState({ target, info: null, error: String((e as Error)?.message ?? e) }));
|
||||
void load();
|
||||
return () => {
|
||||
live = false;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [target, tick]);
|
||||
const refresh = useCallback(() => {
|
||||
if (!target) return;
|
||||
setRefreshing(true);
|
||||
refreshContainerPython()
|
||||
.then((info) => setState({ target, info, error: null }))
|
||||
.catch((e: unknown) =>
|
||||
setState((s) => ({ target, info: s?.info ?? null, error: String((e as Error)?.message ?? e) })),
|
||||
)
|
||||
.finally(() => {
|
||||
setRefreshing(false);
|
||||
setTick((t) => t + 1);
|
||||
});
|
||||
}, [target]);
|
||||
const same = !!target && state?.target === target;
|
||||
return {
|
||||
info: same ? state.info : null,
|
||||
error: same ? state.error : null,
|
||||
busy: refreshing || (same && !!state.info?.updating),
|
||||
loading: !!target && !same,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
|
||||
export function PythonInterpreterPage() {
|
||||
const [interpreter, setInterpreter] = useSetting<string>("languages.python.interpreter");
|
||||
const [activate, setActivate] = useSetting<boolean>("languages.python.activateVirtualenv");
|
||||
@@ -84,6 +144,10 @@ export function PythonInterpreterPage() {
|
||||
})),
|
||||
[rows, detected],
|
||||
);
|
||||
const [rawTargets] = useSetting<RunTargetValid[] | undefined>("runTargets");
|
||||
const targets = useMemo(() => targetChoices(validateRunTargets(rawTargets)), [rawTargets]);
|
||||
const target = interpreterTarget(interpreter);
|
||||
const container = useContainerPython(target);
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
@@ -109,11 +173,22 @@ export function PythonInterpreterPage() {
|
||||
}, [root, interpreter, created]);
|
||||
// A configured interpreter whose executable is gone (PyCharm's "Invalid"): runs fall back.
|
||||
const invalid =
|
||||
!!interpreter && resolved?.interpreter === interpreter && !sameInterpreter(resolved.path, interpreter, root);
|
||||
!target &&
|
||||
!!interpreter &&
|
||||
resolved?.interpreter === interpreter &&
|
||||
!sameInterpreter(resolved.path, interpreter, root);
|
||||
|
||||
// Without a configured interpreter the project's virtualenv is used (like run configurations).
|
||||
const python = interpreter || auto;
|
||||
const { packages, error, loading, reload } = usePackages(python);
|
||||
const python = target ? null : interpreter || auto;
|
||||
const local = usePackages(python);
|
||||
const { packages, error, loading, reload } = target
|
||||
? {
|
||||
packages: container.info?.version ? container.info.packages : null,
|
||||
error: container.error,
|
||||
loading: container.loading || container.busy,
|
||||
reload: container.refresh,
|
||||
}
|
||||
: local;
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
const [installing, setInstalling] = useState(false);
|
||||
@@ -167,6 +242,15 @@ export function PythonInterpreterPage() {
|
||||
|
||||
const autoDetail = auto ? tildify(auto) : "No virtualenv in the project";
|
||||
const current = detected?.find((s) => s.path === python);
|
||||
const containerDetail = !target
|
||||
? null
|
||||
: container.info?.version
|
||||
? `${container.info.name || target} — Python ${container.info.version}${container.info.image ? ` · ${container.info.image.replace(/^sha256:/, "").slice(0, 12)}` : ""}`
|
||||
: container.busy
|
||||
? tx("Copying the libraries from the container…")
|
||||
: container.info && !container.info.name
|
||||
? tx("No run target has this id: choose another interpreter.")
|
||||
: tx("The container's libraries have not been copied yet.");
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
@@ -183,7 +267,7 @@ export function PythonInterpreterPage() {
|
||||
browseTitle="Select Python Interpreter"
|
||||
aria-label="Python interpreter"
|
||||
invalid={invalid}
|
||||
extra={extras}
|
||||
extra={[...extras, ...targets]}
|
||||
exclude={hidden}
|
||||
optionIcon={(sdk) => interpreterIcon(sdk.source)}
|
||||
/>
|
||||
@@ -196,6 +280,11 @@ export function PythonInterpreterPage() {
|
||||
<T>Add Interpreter…</T>
|
||||
</Button>
|
||||
</div>
|
||||
{containerDetail && (
|
||||
<div className={styles.path} title={container.info?.folders.join("\n")}>
|
||||
{containerDetail}
|
||||
</div>
|
||||
)}
|
||||
{python && (
|
||||
<div className={styles.path} title={python}>
|
||||
{current && !invalid ? `${current.name} — ` : ""}
|
||||
@@ -222,21 +311,21 @@ export function PythonInterpreterPage() {
|
||||
icon="minus"
|
||||
size="small"
|
||||
title="Uninstall"
|
||||
disabled={!selected || !!job}
|
||||
disabled={!python || !selected || !!job}
|
||||
onClick={() => selected && run("uninstall", selected)}
|
||||
/>
|
||||
<IconButton
|
||||
icon="arrow-up"
|
||||
size="small"
|
||||
title="Upgrade"
|
||||
disabled={!selected || !!job}
|
||||
disabled={!python || !selected || !!job}
|
||||
onClick={() => selected && run("upgrade", selected)}
|
||||
/>
|
||||
<IconButton
|
||||
icon="refresh"
|
||||
size="small"
|
||||
title="Reload List of Packages"
|
||||
disabled={!python || loading}
|
||||
title={target ? "Copy Libraries from the Container Again" : "Reload List of Packages"}
|
||||
disabled={(!python && !target) || loading}
|
||||
onClick={reload}
|
||||
/>
|
||||
<span className={styles.spacer} />
|
||||
@@ -296,7 +385,7 @@ export function PythonInterpreterPage() {
|
||||
aria-selected={selected === p.name}
|
||||
className={clsx(selected === p.name && styles.selected)}
|
||||
onMouseDown={() => setSelected(p.name)}
|
||||
onContextMenu={(e) => menu(e, p)}
|
||||
onContextMenu={(e) => (target ? undefined : menu(e, p))}
|
||||
onKeyDown={(e) => onRowKey(e, i)}
|
||||
>
|
||||
<td>{p.name}</td>
|
||||
@@ -305,20 +394,20 @@ export function PythonInterpreterPage() {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{!python && (
|
||||
{!python && !target && (
|
||||
<div className={styles.empty}>
|
||||
<T>No interpreter selected. Choose one above or add a new one.</T>
|
||||
</div>
|
||||
)}
|
||||
{python && error && !invalid && (
|
||||
{(python || target) && error && !invalid && (
|
||||
<div className={clsx(styles.empty, styles.error)} role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{python && !error && packages && shown.length === 0 && (
|
||||
{(python || target) && !error && packages && shown.length === 0 && (
|
||||
<div className={styles.empty}>{query ? tx("No packages match") : tx("No packages installed")}</div>
|
||||
)}
|
||||
{python && !error && !packages && loading && (
|
||||
{(python || target) && !error && !packages && loading && (
|
||||
<div className={styles.empty}>
|
||||
<T>Loading packages…</T>
|
||||
</div>
|
||||
@@ -326,6 +415,14 @@ export function PythonInterpreterPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{target && (
|
||||
<div className={styles.note}>
|
||||
<T>
|
||||
Packages come from the container image and are read-only here. To change them, edit the image or its
|
||||
requirements and rebuild it, then copy the libraries again.
|
||||
</T>
|
||||
</div>
|
||||
)}
|
||||
{job && (
|
||||
<div className={styles.status}>
|
||||
<Spinner size={14} label="Working" />
|
||||
|
||||
@@ -8,8 +8,9 @@ import { notifications, registerStatusItem } from "@/workbench/contributions";
|
||||
import { useEditorRegistry } from "@/editor/api";
|
||||
import { BarButton, Menu, type MenuItem } from "@/ui";
|
||||
import { Icon } from "@/ui/icons";
|
||||
import { effectivePython, type Sdk } from "./api";
|
||||
import { effectivePython, interpreterTarget, targetChoices, type Sdk } from "./api";
|
||||
import { tildify } from "./components";
|
||||
import { validateRunTargets } from "@/platform/remoteSettings";
|
||||
import { interpreterIcon, interpreterPathKey } from "./interpreters";
|
||||
|
||||
/** What the widget last resolved for a project, so the popup action can read it without a render. */
|
||||
@@ -90,6 +91,7 @@ function PythonStatus() {
|
||||
const root = useProject((s) => s.project?.root);
|
||||
const trusted = useProject((s) => s.project?.trusted !== false);
|
||||
const configured = useProject((s) => s.settings?.languages?.python?.interpreter ?? "");
|
||||
const runTargets = useProject((s) => s.settings?.runTargets);
|
||||
const hidden = useProject((s) => s.settings?.languages?.python?.hiddenInterpreters ?? EMPTY);
|
||||
const members = useProject((s) => s.project?.workspace?.projects);
|
||||
const status = usePopup((s) => s.status);
|
||||
@@ -162,12 +164,19 @@ function PythonStatus() {
|
||||
current.sdks.filter((sdk) => !ignored.has(interpreterPathKey(sdk.path))),
|
||||
current.python,
|
||||
);
|
||||
// Docker / Compose run targets can be the interpreter too (`target:<id>`).
|
||||
const targets = targetChoices(validateRunTargets(runTargets));
|
||||
const target = interpreterTarget(current.configured) ? current.configured.trim() : null;
|
||||
const targetLabel = target ? (targets.find((t) => t.value === target)?.label ?? target) : null;
|
||||
// A configured interpreter the backend cannot resolve (its file is gone) reads
|
||||
// as "Invalid" — PyCharm marks such SDKs instead of showing them as working.
|
||||
const invalid = !!current.configured && !current.python;
|
||||
const label = invalid
|
||||
? tx("Invalid interpreter")
|
||||
: (choices.find((sdk) => sdk.path === current.python)?.name ?? tx("No interpreter"));
|
||||
const invalid = !target && !!current.configured && !current.python;
|
||||
const label =
|
||||
targetLabel ??
|
||||
(invalid
|
||||
? tx("Invalid interpreter")
|
||||
: (choices.find((sdk) => sdk.path === current.python)?.name ?? tx("No interpreter")));
|
||||
const select = (value: string) => void executeAction("python.selectInterpreter", value);
|
||||
const names = new Map<string, number>();
|
||||
for (const sdk of choices) names.set(sdk.name, (names.get(sdk.name) ?? 0) + 1);
|
||||
const items: MenuItem[] = [
|
||||
@@ -178,8 +187,15 @@ function PythonStatus() {
|
||||
verbatim: true,
|
||||
// Several system interpreters share a name ("Python 3.11"); their paths tell them apart.
|
||||
shortcut: (names.get(sdk.name) ?? 0) > 1 ? tildify(sdk.path) : undefined,
|
||||
checked: sdk.path === current.python,
|
||||
run: () => void executeAction("python.selectInterpreter", sdk.path),
|
||||
checked: !target && sdk.path === current.python,
|
||||
run: () => select(sdk.path),
|
||||
})),
|
||||
...targets.map((t): MenuItem => ({
|
||||
label: t.label,
|
||||
verbatim: true,
|
||||
shortcut: t.detail,
|
||||
checked: t.value === target,
|
||||
run: () => select(t.value),
|
||||
})),
|
||||
{ kind: "separator" },
|
||||
{ kind: "action", action: "python.addInterpreter" },
|
||||
@@ -189,13 +205,19 @@ function PythonStatus() {
|
||||
<>
|
||||
<BarButton
|
||||
ref={anchor}
|
||||
tooltip={invalid ? tx("The configured interpreter does not exist") : (current.python ?? tx("No interpreter"))}
|
||||
tooltip={
|
||||
target
|
||||
? (targets.find((t) => t.value === target)?.detail ?? target)
|
||||
: invalid
|
||||
? tx("The configured interpreter does not exist")
|
||||
: (current.python ?? tx("No interpreter"))
|
||||
}
|
||||
aria-label={tx`Python Interpreter: ${label}`}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
onClick={() => void executeAction("python.interpreterPopup")}
|
||||
>
|
||||
{(!current.python || invalid) && <Icon name="warning" size={14} />}
|
||||
{!target && (!current.python || invalid) && <Icon name="warning" size={14} />}
|
||||
{label}
|
||||
</BarButton>
|
||||
<Menu
|
||||
|
||||
@@ -4,10 +4,14 @@
|
||||
*/
|
||||
import { Channel } from "@tauri-apps/api/core";
|
||||
import { create } from "zustand";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import type { RunTargetValid } from "@/platform/remoteSettings";
|
||||
import type { SelectOption } from "@/ui";
|
||||
import {
|
||||
call,
|
||||
commands,
|
||||
isTauri,
|
||||
type ContainerPython,
|
||||
nextRequestId,
|
||||
type PhpLanguageLevels,
|
||||
type ProjectInterpreter,
|
||||
@@ -16,7 +20,7 @@ import {
|
||||
type SdkKind,
|
||||
} from "@/platform/ipc";
|
||||
|
||||
export type { ProjectInterpreter, PythonPackage, Sdk, SdkKind };
|
||||
export type { ContainerPython, ProjectInterpreter, PythonPackage, Sdk, SdkKind };
|
||||
|
||||
interface SdkCache {
|
||||
byKind: Partial<Record<SdkKind, Sdk[]>>;
|
||||
@@ -129,6 +133,38 @@ export function effectivePython(near: string | null = null): Promise<string | nu
|
||||
return isTauri() ? call(commands.sdkPythonEffective(near)) : Promise.resolve(null);
|
||||
}
|
||||
|
||||
/** What `languages.python.interpreter` starts with when the interpreter is a run target's. */
|
||||
const TARGET_PREFIX = "target:";
|
||||
|
||||
/** The run target id of a `target:<id>` interpreter value; null for a local interpreter. */
|
||||
export function interpreterTarget(value: string | null | undefined): string | null {
|
||||
const v = value?.trim() ?? "";
|
||||
if (!v.startsWith(TARGET_PREFIX)) return null;
|
||||
return v.slice(TARGET_PREFIX.length).trim() || null;
|
||||
}
|
||||
|
||||
/** Docker and Compose run targets as interpreter choices (`target:<id>`). */
|
||||
export function targetChoices(targets: readonly RunTargetValid[]): SelectOption[] {
|
||||
return targets
|
||||
.filter((t) => t.kind === "docker" || t.kind === "compose")
|
||||
.map((t) => ({
|
||||
value: `${TARGET_PREFIX}${t.id}`,
|
||||
label: t.name || t.id,
|
||||
verbatim: true,
|
||||
detail: t.kind === "compose" ? tx`Docker Compose service ${t.service}` : tx`Docker image ${t.image}`,
|
||||
}));
|
||||
}
|
||||
|
||||
/** The project interpreter when it is a Docker or Compose run target's, with its library snapshot. */
|
||||
export function containerPython(): Promise<ContainerPython | null> {
|
||||
return isTauri() ? call(commands.sdkContainerPython()) : Promise.resolve(null);
|
||||
}
|
||||
|
||||
/** Copies the container's libraries again (after the image was rebuilt). */
|
||||
export function refreshContainerPython(): Promise<ContainerPython | null> {
|
||||
return isTauri() ? call(commands.sdkContainerPythonRefresh()) : Promise.resolve(null);
|
||||
}
|
||||
|
||||
/** The PHP language level of the project `path` belongs to; null outside Tauri. */
|
||||
export function phpLanguageLevel(path: string): Promise<PhpLanguageLevels | null> {
|
||||
return isTauri() ? call(commands.sdkPhpLanguageLevel(path)) : Promise.resolve(null);
|
||||
|
||||
Loaded 100 of 111 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Заблокувати користувача