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

...
Автор SHA1 Повідомлення Дата
l0ng-ai ee3ad070fd feat(vcs): show the number of changed files in the Project view's branch suffix
With "Show branch and incoming/outgoing commits in the Project view" on, a
repository root with local changes now also shows how many files changed
(staged, unstaged or untracked; ignored files aside), e.g. main/↑2 ✎7, with
a tooltip; a clean repository shows nothing extra. The count follows the
working tree status as it refreshes, from the branches already read, without
asking git again.
2026-11-15 01:08:33 +08:00
l0ng-ai 7fe6f5f58d feat(run): run icons on the profiles of launchSettings.json (#3415)
A .NET project's Properties/launchSettings.json shows a run icon on each Project profile; Run / Debug launch the project's configuration for that profile, or a temporary copy for a profile added since detection ran. The package.json scripts gutter is generalised into entryGutter for this.
2026-11-15 01:04:59 +08:00
l0ng-ai abf904553d feat(build): build .NET solutions and projects in the Build tool window (#3414)
Build Project / Build Module / Rebuild / Build Artifacts build a .NET solution or project (dotnet build, --no-incremental, dotnet publish), and MSBuild's canonical file(line,col): error CODE lines become Build messages linking to the source. A .NET run configuration's "Build .NET project" before-launch task builds its project in the Build tool window instead of a Run tab.
2026-11-15 01:00:28 +08:00
l0ng-ai d425388f1a fix(run): read .NET launch profiles from a launchSettings.json with a byte order mark (#3412)
Visual Studio saves launchSettings.json with a UTF-8 byte order mark, which serde_json rejects, so a project's launch profiles came out empty and a configuration naming one failed with "launch profile is missing". jsonc::strip drops a leading byte order mark, which also covers devcontainer.json, the .vscode/launch.json import, tsconfig.json and run detection.
2026-11-15 00:54:37 +08:00
Akbar Husain de123e2a21 perf(index): stream the cold scan instead of chunk joins (#3407)
The initial scan parsed files in 512-file chunks (rayon join, then insert and redb commit), so every join idled the pool on its slowest file and insert/persist serialized between chunks. The scan now parses the whole todo list in one rayon pass, serializes cache records inside the parse, and streams them over a bounded channel to the index thread, which inserts and persists in batches of 2048 alongside the parse. Records stay byte-identical; the scan's log line carries walk / classify / extract / insert / persist / durable laps.

Co-authored-by: akbar1214 <[email protected]>
2026-11-15 00:48:27 +08:00
Muuтаmuutot d19dbec56a feat(editor): Python inheritance in navigation from one shared graph (#3397)
Python's servers answer no type hierarchy, so the navigation built on it did nothing for Python. The inheritance graph moves out of the gutter into src/editor/navigation/inheritanceGraph.ts (one fetch per project, the name map and base resolver), and Type Hierarchy, Structure's Show Inherited, Super Method and Go to Implementation use it for Python, merged with whatever the server answers. The gutter keeps its per-file import reading.

The subclass map is built when a subtype lookup first needs it; the files whose imports decide a base are read in one batch, bases resolve in parallel, and an import whose name is no class of the project is not looked up.

Co-authored-by: muutot <[email protected]>
2026-11-15 00:41:25 +08:00
l0ng-ai 1a204b5f4b fix(lsp): show TypeScript 7 as the server when its own tsc --lsp runs (#3408)
A project on TypeScript 7 runs the compiler's native tsc --lsp in place of vtsls / typescript-language-server, but the status bar and the server popup still named the configured server. The settings hook gives such a server a display name, carried by ServerInfo and the languageServer event as an optional label; the server's name stays its identity.
2026-11-15 00:25:51 +08:00
l0ng-ai 3ec7daf845 fix(profiler): name js-debug as the recorder of a page's CPU profile (#3406)
A page's CPU profile is read like a Node.js run's .cpuprofile files, so its snapshot header said "node --cpu-prof". profiler.showCpuProfiles takes an optional profiler name; the debugger passes js-debug.
2026-11-15 00:16:52 +08:00
ntbang0901 112ac05052 feat(debugger): replace the responses a debugged page receives (#3329)
A page session can answer requests itself: rules (URL part → status, content type, body) are kept per project, edited from the Network tab (Overrides, Override Response… on a request), and applied through js-debug's CDP proxy with Fetch.enable / Fetch.fulfillRequest. Interception is switched off before the connection is dropped, unknown statuses get a reason phrase, cross-origin pages get CORS headers and preflight answers, and rules present at page start wait for the launch response.

The Network list marks a request overridden only when it was sent after the rule that answers it was saved or enabled.

Co-authored-by: ntbang0901 <[email protected]>
2026-11-15 00:15:09 +08:00
ntbang0901 10b7340e2a feat(debugger): CPU profile of a debugged page (#3320)
The More menu of a page session has Start / Stop CPU Profile (js-debug's startProfile / stopProfile); the recording opens in the Profiler tool window as a snapshot named after the session. The .cpuprofile reader prefers js-debug's $vscode.locations, so a frame opens the project's source at its line instead of the URL the browser ran.

Co-authored-by: ntbang0901 <[email protected]>
2026-11-15 00:01:16 +08:00
Andrea Basileтаandreabasile 6fa5b368e6 feat(python): a Docker or Compose run target as the project interpreter (#3364)
A Docker or Compose run target can be the project's Python interpreter (languages.python.interpreter = "target:<id>"). The IDE runs the target's Python once in a new container (entrypoint and service dependencies skipped), copies its sys.path folders outside the standard library and the project with docker cp, once per image, and gives them to basedpyright / Pyright as extraPaths with an empty local virtualenv as interpreter. Packages show read-only with a refresh that copies again; Python run configurations without a target of their own run on that target; the copy shows under External Libraries. Snapshots require a trusted project.

Co-authored-by: andreabasile <[email protected]>
2026-11-14 23:53:40 +08:00
Mr0Bread dd40273952 test(database): cover result submission lifecycle interleavings (#3346)
Covers paging staying disabled while a remounted result applies edits, and an apply finishing after its result tab closed without refreshing another tab. (The refresh-failure case is already covered on main.)

Co-authored-by: Mr0Bread <[email protected]>
2026-11-14 23:43:23 +08:00
l0ng-ai 21bc81c6da fix(ui): remember splitter positions in the Git and other tool windows (#3401)
None of the Git views gave SplitGroup a storageKey, so their splitters reset whenever the view was recreated. They, the Commit dialog and the Todo, Find, Problems, Usages, Build, HTTP Client and Dependency Analyzer splits now store their sizes. A view whose preview comes and goes keys sizes per panel set, and SplitGroup applies that set's sizes when the key changes, without remounting the view.
2026-11-14 23:42:02 +08:00
111 змінених файлів з 5710 додано та 429 видалено

No files matched your search

Створено
+2
Переглянути файл
@@ -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",
+51 -1
Переглянути файл
@@ -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<_>>();
+61
Переглянути файл
@@ -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(
+4
Переглянути файл
@@ -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>,
},
+9 -1
Переглянути файл
@@ -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);
}
}
+23 -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(
+2
Переглянути файл
@@ -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
+528
Переглянути файл
@@ -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(&current)).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(&current)),
};
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, &current);
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!((&params["responseCode"], &params["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(&params, "Access-Control-Allow-Origin"), Some("http://localhost:5173"));
assert_eq!(header(&params, "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, &params["responseCode"]), ("Fetch.fulfillRequest", &json!(204)));
assert_eq!(header(&params, "Access-Control-Allow-Origin"), Some("http://localhost:5173"));
assert_eq!(header(&params, "Access-Control-Allow-Methods"), Some("PUT"));
assert_eq!(header(&params, "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(), &params["requestId"], &params["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;
}
}
+41 -1
Переглянути файл
@@ -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`).
+2
Переглянути файл
@@ -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;
+11
Переглянути файл
@@ -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}"),
}
}
+136
Переглянути файл
@@ -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]");
+18
Переглянути файл
@@ -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::*;
+32
Переглянути файл
@@ -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
});
+6
Переглянути файл
@@ -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() {
+1 -1
Переглянути файл
@@ -508,7 +508,7 @@ pub fn list(host: &Host) -> Vec<RunInfo> {
}
mod commands;
mod container;
pub(crate) mod container;
pub use commands::*;
#[cfg(test)]
+26 -15
Переглянути файл
@@ -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);
+12 -2
Переглянути файл
@@ -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",
+298
Переглянути файл
@@ -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))
}
+2
Переглянути файл
@@ -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)
}
+1
Переглянути файл
@@ -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(())
+174
Переглянути файл
@@ -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();
}
+22
Переглянути файл
@@ -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()?;
+164 -48
Переглянути файл
@@ -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;
+4
Переглянути файл
@@ -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>,
+1
Переглянути файл
@@ -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()
}
+12 -1
Переглянути файл
@@ -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) {
+4
Переглянути файл
@@ -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
+1
Переглянути файл
@@ -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,
}
}
+3
Переглянути файл
@@ -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,
}
}
+54 -3
Переглянути файл
@@ -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]},
+69
Переглянути файл
@@ -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>> {
+2
Переглянути файл
@@ -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]
+16
Переглянути файл
@@ -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
+418
Переглянути файл
@@ -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");
}
}
+1
Переглянути файл
@@ -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;
+4
Переглянути файл
@@ -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();
}
+45 -1
Переглянути файл
@@ -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.
+3
Переглянути файл
@@ -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.
+11
Переглянути файл
@@ -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**:
+2
Переглянути файл
@@ -78,4 +78,6 @@ OpenIDEA 安装的语言服务器和调试器会装进容器,位于容器用
| **运行选项** | 额外的 `docker run` 参数,例如 `--network host`。 |
| **以镜像的用户运行,而不是项目文件夹的所有者(Linux)** | 在 Linux 上,容器默认以项目文件夹所有者的身份运行,写出的文件仍归你所有。勾选后保留镜像自带的用户。 |
容器类目标也可以作为项目的 Python 解释器,让编辑器识别镜像中安装的软件包,见 [Python](/zh/languages/python#以-docker-或-compose-运行目标作为解释器)。
运行目标的整体说明(包括 SSH 目标)见[运行配置](/zh/guide/run)。
+11
Переглянути файл
@@ -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 解释器**,再选 **解释器类型**:
+29
Переглянути файл
@@ -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> {
+5
Переглянути файл
@@ -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,
+20
Переглянути файл
@@ -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" |
+53 -19
Переглянути файл
@@ -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;
+128
Переглянути файл
@@ -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 });
});
});
+21 -165
Переглянути файл
@@ -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,
+17 -6
Переглянути файл
@@ -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) {
+293
Переглянути файл
@@ -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;
}
+1 -1
Переглянути файл
@@ -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: [] });
+160
Переглянути файл
@@ -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"]);
});
});
+177
Переглянути файл
@@ -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;
}
+5 -1
Переглянути файл
@@ -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"
+6 -1
Переглянути файл
@@ -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>
+1 -1
Переглянути файл
@@ -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>
+4 -4
Переглянути файл
@@ -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)}
/>
+1 -1
Переглянути файл
@@ -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>
+93 -2
Переглянути файл
@@ -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");
+68 -7
Переглянути файл
@@ -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,
+26 -1
Переглянути файл
@@ -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 -2
Переглянути файл
@@ -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 };
}
+73
Переглянути файл
@@ -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(
+58
Переглянути файл
@@ -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();
});
});
+94
Переглянути файл
@@ -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: "" }]);
});
});
+17
Переглянути файл
@@ -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");
+111
Переглянути файл
@@ -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();
});
});
+191
Переглянути файл
@@ -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();
});
});
+21
Переглянути файл
@@ -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",
+6
Переглянути файл
@@ -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());
}
+11
Переглянути файл
@@ -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;
+60 -4
Переглянути файл
@@ -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>
);
}
+135
Переглянути файл
@@ -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);
}
+201
Переглянути файл
@@ -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;
}
+7 -1
Переглянути файл
@@ -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;
}
+94
Переглянути файл
@@ -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();
};
}
+1 -1
Переглянути файл
@@ -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>>> }>(() => ({
+144
Переглянути файл
@@ -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]);
}
});
}
+1
Переглянути файл
@@ -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 -----------------------------------------------------------------
+9 -1
Переглянути файл
@@ -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)} />
</>
);
}
+1 -1
Переглянути файл
@@ -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 && (
+55
Переглянути файл
@@ -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([]);
});
});
+46
Переглянути файл
@@ -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).
}
}
+11
Переглянути файл
@@ -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",
+7 -3
Переглянути файл
@@ -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 });
+6 -10
Переглянути файл
@@ -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", () => {
+7 -18
Переглянути файл
@@ -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 = {
+2
Переглянути файл
@@ -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"],
+70
Переглянути файл
@@ -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();
});
});
+103
Переглянути файл
@@ -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);
}
+37 -20
Переглянути файл
@@ -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.
+27 -1
Переглянути файл
@@ -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");
});
});
+6 -1
Переглянути файл
@@ -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}
+24 -9
Переглянути файл
@@ -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) => {
+106
Переглянути файл
@@ -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();
});
});
+21
Переглянути файл
@@ -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 });
+112 -15
Переглянути файл
@@ -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" />
+31 -9
Переглянути файл
@@ -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
+37 -1
Переглянути файл
@@ -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