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

..
Автор SHA1 Повідомлення Дата
l0ng-ai 44c87ce727 fix(macos): require macOS 13, the first release whose system engine runs the app
The interface targets Safari 16 (container queries and more), but other apps on macOS 11
and 12 keep using the system's built-in WebKit (Safari 14.1 / 15.x) even after Safari itself
is updated, so OpenIDEA could not start there. The bundle now declares macOS 13 as its
minimum, so macOS refuses to open it on older systems with its own message. The install docs
say macOS 13, and the startup failure page no longer suggests updating Safari.

Fixes #2008
2026-11-14 10:23:00 +08:00
349 змінених файлів з 1640 додано та 14989 видалено

No files matched your search

+1 -1
Переглянути файл
@@ -7,7 +7,7 @@ BASEDPYRIGHT=1.40.2
RUFF=0.17.3
GOPLS=v0.24.0
JDTLS=1.63.0-202610191322
KOTLIN_LSP=271.2085.0
KOTLIN_LSP=263.7084.0
GRADLE=8.14.3
INTELEPHENSE=1.18.5
# The JDK projects compile against (JAVA_HOME): library navigation opens its sources.
+1 -1
Переглянути файл
@@ -119,7 +119,7 @@ jobs:
- run: cargo test --locked -p ide-host --test language_smoke --no-run
- run: cargo test --locked -p ide-host --test language_smoke -- --ignored --exact ${{ matrix.language }}
- if: matrix.language == 'java'
run: cargo test --locked -p ide-host --test project_smoke -- --ignored --exact maven_sync jdk maven_launch_classpath_uses_the_runner_jdk_classifier
run: cargo test --locked -p ide-host --test project_smoke -- --ignored --exact maven_sync jdk
- if: matrix.language == 'kotlin'
run: cargo test --locked -p ide-host --test project_smoke -- --ignored --exact gradle_sync
- if: matrix.language == 'python'
Створено
+44 -44
Переглянути файл
@@ -4144,7 +4144,7 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "ide-agent"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"dunce",
"encoding_rs",
@@ -4168,7 +4168,7 @@ dependencies = [
[[package]]
name = "ide-analysis"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"ast-grep-core",
"ide-core",
@@ -4182,7 +4182,7 @@ dependencies = [
[[package]]
name = "ide-android"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"ide-core",
"serde",
@@ -4193,7 +4193,7 @@ dependencies = [
[[package]]
name = "ide-build"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"blake3",
"chardetng",
@@ -4211,7 +4211,7 @@ dependencies = [
[[package]]
name = "ide-cli"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"dirs 6.0.0",
"serde",
@@ -4225,7 +4225,7 @@ dependencies = [
[[package]]
name = "ide-clipboard"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"gtk",
"objc2 0.6.5",
@@ -4235,7 +4235,7 @@ dependencies = [
[[package]]
name = "ide-core"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"dunce",
"encoding_rs",
@@ -4252,7 +4252,7 @@ dependencies = [
[[package]]
name = "ide-coverage"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"ide-core",
"ide-run",
@@ -4266,7 +4266,7 @@ dependencies = [
[[package]]
name = "ide-dap"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"futures",
"ide-core",
@@ -4293,7 +4293,7 @@ dependencies = [
[[package]]
name = "ide-db"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"base64 0.22.1",
"bb8",
@@ -4329,7 +4329,7 @@ dependencies = [
[[package]]
name = "ide-docker"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"ide-core",
"serde",
@@ -4342,7 +4342,7 @@ dependencies = [
[[package]]
name = "ide-extension"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"base64 0.22.1",
"blake3",
@@ -4363,7 +4363,7 @@ dependencies = [
[[package]]
name = "ide-fonts"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"objc2-core-foundation",
"objc2-core-text",
@@ -4375,7 +4375,7 @@ dependencies = [
[[package]]
name = "ide-github"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"ide-core",
"ide-vcs",
@@ -4388,7 +4388,7 @@ dependencies = [
[[package]]
name = "ide-heap"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"criterion",
"ide-core",
@@ -4406,7 +4406,7 @@ dependencies = [
[[package]]
name = "ide-host"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"base64 0.22.1",
"blake3",
@@ -4477,7 +4477,7 @@ dependencies = [
[[package]]
name = "ide-http"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"ide-core",
"ide-vfs",
@@ -4496,7 +4496,7 @@ dependencies = [
[[package]]
name = "ide-index"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"blake3",
"criterion",
@@ -4518,7 +4518,7 @@ dependencies = [
[[package]]
name = "ide-jdwp"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"ide-jvm-inspect",
"proptest",
@@ -4530,7 +4530,7 @@ dependencies = [
[[package]]
name = "ide-jsonrpc"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"encoding_rs",
"futures",
@@ -4547,7 +4547,7 @@ dependencies = [
[[package]]
name = "ide-jvm-attach"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"libc",
"tempfile",
@@ -4556,7 +4556,7 @@ dependencies = [
[[package]]
name = "ide-jvm-inspect"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"futures",
"serde",
@@ -4567,7 +4567,7 @@ dependencies = [
[[package]]
name = "ide-language"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"blake3",
"criterion",
@@ -4624,7 +4624,7 @@ dependencies = [
[[package]]
name = "ide-lsp"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"dunce",
"encoding_rs",
@@ -4652,7 +4652,7 @@ dependencies = [
[[package]]
name = "ide-mcp"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"bytes",
"dunce",
@@ -4671,7 +4671,7 @@ dependencies = [
[[package]]
name = "ide-notebook"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"futures",
"ide-core",
@@ -4694,7 +4694,7 @@ dependencies = [
[[package]]
name = "ide-plugin"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"blake3",
"dunce",
@@ -4717,7 +4717,7 @@ dependencies = [
[[package]]
name = "ide-profiler"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"ide-core",
"ide-jvm-attach",
@@ -4731,7 +4731,7 @@ dependencies = [
[[package]]
name = "ide-remote"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"dunce",
"futures",
@@ -4751,7 +4751,7 @@ dependencies = [
[[package]]
name = "ide-run"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"dunce",
"encoding_rs",
@@ -4775,7 +4775,7 @@ dependencies = [
[[package]]
name = "ide-scaffold"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"ide-core",
"ide-tools",
@@ -4791,7 +4791,7 @@ dependencies = [
[[package]]
name = "ide-sdk"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"dunce",
"ide-core",
@@ -4812,7 +4812,7 @@ dependencies = [
[[package]]
name = "ide-search"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"criterion",
"encoding_rs",
@@ -4833,7 +4833,7 @@ dependencies = [
[[package]]
name = "ide-spell"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"ide-core",
"parking_lot",
@@ -4846,7 +4846,7 @@ dependencies = [
[[package]]
name = "ide-ssh"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"dirs 6.0.0",
"flate2",
@@ -4871,7 +4871,7 @@ dependencies = [
[[package]]
name = "ide-syntax"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"ide-core",
"ide-language",
@@ -4886,7 +4886,7 @@ dependencies = [
[[package]]
name = "ide-terminal"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"ide-core",
"libc",
@@ -4899,7 +4899,7 @@ dependencies = [
[[package]]
name = "ide-text"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"criterion",
"ide-core",
@@ -4912,7 +4912,7 @@ dependencies = [
[[package]]
name = "ide-tools"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"flate2",
"hex",
@@ -4933,7 +4933,7 @@ dependencies = [
[[package]]
name = "ide-vcs"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"blake3",
"dunce",
@@ -4954,7 +4954,7 @@ dependencies = [
[[package]]
name = "ide-vfs"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"chardetng",
"dunce",
@@ -4978,7 +4978,7 @@ dependencies = [
[[package]]
name = "ide-web"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"dirs 6.0.0",
"ide-core",
@@ -4994,7 +4994,7 @@ dependencies = [
[[package]]
name = "ide-workspace"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"blake3",
"dunce",
@@ -6595,7 +6595,7 @@ dependencies = [
[[package]]
name = "openidea"
version = "0.1.45"
version = "0.1.44"
dependencies = [
"base64 0.22.1",
"blake3",
+1 -1
Переглянути файл
@@ -5,7 +5,7 @@ members = ["crates/*", "src-tauri"]
exclude = ["sdk", "plugins"]
[workspace.package]
version = "0.1.45"
version = "0.1.44"
edition = "2024"
rust-version = "1.96"
license = "Apache-2.0"
+4 -23
Переглянути файл
@@ -144,13 +144,6 @@ fn bracket_pos(s: &str) -> Option<(u32, Option<u32>, &str)> {
Some((line, col, rest.trim_start()))
}
/// `/C:/p/A.java` → `C:/p/A.java`: Maven and file URIs put a `/` before the drive letter on Windows.
/// Joined as is, it would become the drive-relative `C:p/A.java`.
fn drive_path(p: &str) -> &str {
let b = p.as_bytes();
if b.len() > 2 && b[0] == b'/' && b[1].is_ascii_alphabetic() && b[2] == b':' { &p[1..] } else { p }
}
/// `file:///p/A.kt` → `/p/A.kt` (percent-decoded).
fn from_file_uri(s: &str) -> String {
let Some(rest) = s.strip_prefix("file://") else { return s.to_owned() };
@@ -169,7 +162,9 @@ fn from_file_uri(s: &str) -> String {
out.push(bytes[i]);
i += 1;
}
drive_path(&String::from_utf8_lossy(&out)).to_owned()
let path = String::from_utf8_lossy(&out).into_owned();
// `file:///C:/x` on Windows.
if path.len() > 2 && path.as_bytes()[0] == b'/' && path.as_bytes()[2] == b':' { path[1..].to_owned() } else { path }
}
/// Kotlin: `file:///p/A.kt:12:5 msg` or `/p/A.kt: (12, 5): msg`, after the `e: ` / `w: ` prefix.
@@ -227,7 +222,7 @@ fn maven_line(line: &str) -> Option<RawMessage> {
&& looks_like_source_path(path)
&& let Some((ln, col, msg)) = bracket_pos(&format!("[{pos}"))
{
return Some(RawMessage::new(severity, msg, ColUnit::Char).at(drive_path(path), ln, col));
return Some(RawMessage::new(severity, msg, ColUnit::Char).at(path, ln, col));
}
if let Some(m) = kotlin_line(rest) {
return Some(m);
@@ -461,20 +456,6 @@ mod tests {
assert_eq!(out[1].unit, ColUnit::Char);
}
#[test]
fn maven_windows_paths_drop_the_slash_before_the_drive() {
let out = parse(
Format::Maven,
"[ERROR] /C:/dev/p/src/main/java/a/A.java:[18,41] package a.b does not exist\n\
[ERROR] C:\\dev\\p\\B.java:[3,8] cannot find symbol\n\
[ERROR] file:///C:/dev/p/K.kt:4:9 Unresolved reference 'x'.\n",
);
assert_eq!(out.len(), 3, "{out:?}");
assert_eq!(pos(&out[0]), (Severity::Error, "C:/dev/p/src/main/java/a/A.java", 18, Some(41)));
assert_eq!(pos(&out[1]), (Severity::Error, "C:\\dev\\p\\B.java", 3, Some(8)));
assert_eq!(pos(&out[2]), (Severity::Error, "C:/dev/p/K.kt", 4, Some(9)));
}
#[test]
fn maven_detail_lines_with_level_prefix_and_kotlin() {
let out = parse(
-50
Переглянути файл
@@ -291,56 +291,6 @@ pub enum DebugEvent {
JvmBackend {
state: JvmBackendState,
},
/// A step in the life of one request of a debugged page.
JsNetwork {
event: JsNetworkEvent,
},
/// The adapter does not report the requests of this page: there will be no `JsNetwork`.
JsNetworkUnavailable,
}
/// Which step of a request a [`JsNetworkEvent`] reports.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum JsNetworkKind {
Request,
Response,
Finished,
Failed,
}
/// The script frame that made a request, where the browser ran it (0-based line and column).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct JsInitiator {
pub function: String,
pub url: String,
pub line: u32,
pub column: u32,
}
/// What a debugged page's browser reports about a request, reduced to what the Network view
/// shows. Events of one request share `request_id`; each kind fills its own fields.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct JsNetworkEvent {
pub kind: JsNetworkKind,
pub request_id: String,
/// The browser's monotonic clock, comparable between the events of a page.
pub timestamp_ms: u64,
/// Request and response.
pub url: Option<String>,
pub method: Option<String>,
/// `Fetch`, `XHR`, `Script`, `Document`, …
pub resource_type: Option<String>,
pub status: Option<u16>,
pub mime_type: Option<String>,
/// The request's headers, or the response's.
pub headers: BTreeMap<String, String>,
/// Bytes received over the network, once finished.
pub encoded_length: Option<u64>,
pub error_text: Option<String>,
pub initiator: Option<JsInitiator>,
}
/// The backend that reads a debugged JVM's objects (instances, fields, referrers).
+2 -15
Переглянути файл
@@ -291,7 +291,7 @@ pub enum NewTaskApprovals {
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct McpServerSettings {
/// Serve the tools on [`Self::address`] while the IDE runs.
/// Serve the tools on 127.0.0.1 while the IDE runs.
pub enabled: bool,
/// Run the tools that change the project or start processes without asking first
/// (IDEA's brave mode). Read from the IDE's own settings only, never a project's.
@@ -300,9 +300,6 @@ pub struct McpServerSettings {
pub agent_tab_limit: u32,
/// Archived agent tabs kept; older ones are closed.
pub agent_archive_limit: u32,
/// The IP address to listen on: `127.0.0.1` (this computer only, the default), another
/// address of this computer, or `0.0.0.0` / `::` for every address.
pub address: String,
/// Listen on [`Self::port`] in every run, rather than a free port each time, so HTTP clients and
/// port forwards keep working after a restart.
pub fixed_port: bool,
@@ -315,8 +312,6 @@ pub struct McpServerSettings {
/// The default of [`McpServerSettings::port`].
pub const DEFAULT_MCP_PORT: u16 = 26371;
/// The default of [`McpServerSettings::address`]: this computer only.
pub const DEFAULT_MCP_ADDRESS: &str = "127.0.0.1";
impl Default for McpServerSettings {
fn default() -> Self {
@@ -325,7 +320,6 @@ impl Default for McpServerSettings {
run_without_confirmation: false,
agent_tab_limit: 1,
agent_archive_limit: 999,
address: DEFAULT_MCP_ADDRESS.to_owned(),
fixed_port: true,
port: DEFAULT_MCP_PORT,
fixed_token: false,
@@ -1159,9 +1153,6 @@ pub struct NodeSettings {
pub run_target: String,
/// `npm` | `pnpm` | `yarn`; empty = detect from the lock file / `packageManager`.
pub package_manager: String,
/// Runtime for running JavaScript / TypeScript files: `node` | `bun` | `deno`; empty = from the
/// project (`deno.json` → Deno, `bun.lock` → Bun, else Node.js).
pub runtime: String,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
@@ -1358,15 +1349,11 @@ pub struct TypeScriptSettings {
/// `bundled` (the server's own TypeScript), `workspace` (`node_modules/typescript`),
/// or a path to a TypeScript `lib` directory.
pub version: String,
/// tsserver heap limit in MB. `0` = automatic: 8192 when vtsls hosts the Vue
/// plugin (hybrid mode; a Vue project's program alone needs ~3 GiB, and the
/// vtsls default 3072 dies in a GC death spiral), else unset (vtsls default).
pub tsserver_max_memory: u32,
}
impl Default for TypeScriptSettings {
fn default() -> Self {
Self { version: "bundled".into(), tsserver_max_memory: 0 }
Self { version: "bundled".into() }
}
}
-6
Переглянути файл
@@ -266,16 +266,12 @@ pub struct DebuggerSettings {
pub inline_values: bool,
/// Data Views: list variables by name instead of the adapter's order.
pub sort_values: bool,
/// Data Views: evaluate watches while the program runs, where the debugger can (JavaScript).
pub live_watches: bool,
pub value_tooltip: bool,
/// While suspended, selecting an expression shows its value.
pub value_tooltip_on_selection: bool,
pub value_tooltip_delay: u32,
/// Stepping: do not step into library code (debugpy `justMyCode`, js-debug `skipFiles`).
pub skip_library_code: bool,
/// JavaScript: debugging a dev server (`vite`, `next dev`) also opens its page in a browser session.
pub debug_server_page: bool,
pub python: PythonDebuggerSettings,
}
@@ -289,12 +285,10 @@ impl Default for DebuggerSettings {
run_to_cursor_inlay: true,
inline_values: true,
sort_values: false,
live_watches: false,
value_tooltip: true,
value_tooltip_on_selection: true,
value_tooltip_delay: 700,
skip_library_code: true,
debug_server_page: false,
python: PythonDebuggerSettings::default(),
}
}
-72
Переглянути файл
@@ -9,7 +9,6 @@
use ide_core::process::executable_names;
use ide_core::{IdeError, IdeResult};
use ide_language::{DebuggerConfig, LanguageRegistry, ProgramPin};
use serde_json::Value;
use std::future::Future;
use std::path::{Path, PathBuf};
use std::pin::Pin;
@@ -213,50 +212,6 @@ pub fn tools_root() -> Option<PathBuf> {
TOOLS_ROOT.read().clone()
}
/// The browser js-debug launches for `ty`, or `None` when the launch starts no browser.
fn launched_browser(ty: &str) -> Option<&'static str> {
match ty {
"pwa-chrome" | "chrome" => Some("chrome"),
"pwa-msedge" | "msedge" => Some("msedge"),
_ => None,
}
}
/// Gives a browser launch that names no `userDataDir` a profile of `project`'s own in the
/// IDE's local data (`cache/browser-profiles`), so sign-ins and site data are still there in
/// the next session. js-debug's own default is one temporary profile for every project.
pub(crate) fn set_browser_profile(arguments: &mut Value, project: &Path) {
if let Some(local_data) = tools_root().and_then(|tools| tools.parent().map(Path::to_owned)) {
set_browser_profile_in(arguments, project, &local_data);
}
}
fn set_browser_profile_in(arguments: &mut Value, project: &Path, local_data: &Path) {
let Some(obj) = arguments.as_object_mut() else { return };
let browser = obj.get("type").and_then(Value::as_str).and_then(launched_browser);
let launch = obj.get("request").and_then(Value::as_str) == Some("launch");
let (Some(browser), true, false) = (browser, launch, obj.contains_key("userDataDir")) else { return };
let dir = local_data.join("cache").join("browser-profiles").join(profile_name(project)).join(browser);
obj.insert("userDataDir".into(), dir.to_string_lossy().into_owned().into());
}
/// `<folder name>-<hash of the path>`: readable, and distinct for projects whose folders share a name.
fn profile_name(project: &Path) -> String {
// FNV-1a: the name has to stay the same across toolchains, which `DefaultHasher` doesn't promise.
let hash = project
.to_string_lossy()
.bytes()
.fold(0xcbf2_9ce4_8422_2325_u64, |h, b| (h ^ u64::from(b)).wrapping_mul(0x0000_0100_0000_01b3));
let name: String = project
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default()
.chars()
.map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' { c } else { '_' })
.collect();
format!("{name}-{hash:016x}")
}
/// Executable directories of managed installs, read at lookup time so tools installed
/// after startup are found without a restart: `<tool>/bin`, `<tool>/venv/bin`, npm's `.bin`.
fn managed_dirs(root: &Path) -> Vec<PathBuf> {
@@ -1085,33 +1040,6 @@ mod tests {
assert!(err.message.contains("runs inside the Java language server"), "{}", err.message);
}
#[test]
fn a_browser_launch_gets_a_profile_of_its_project() {
let local = Path::new("/data/local");
let profile = |args: serde_json::Value, project: &str| {
let mut args = args;
set_browser_profile_in(&mut args, Path::new(project), local);
args.get("userDataDir").cloned()
};
let chrome = serde_json::json!({ "type": "pwa-chrome", "request": "launch" });
let dir = profile(chrome.clone(), "/work/shop").unwrap();
let dir = Path::new(dir.as_str().unwrap());
assert!(dir.starts_with("/data/local/cache/browser-profiles"), "{dir:?}");
assert!(dir.ends_with("chrome"), "{dir:?}");
let name = dir.parent().unwrap().file_name().unwrap().to_string_lossy().into_owned();
assert!(name.starts_with("shop-") && name.len() == "shop-".len() + 16, "{name}");
// The same project again: the same profile. Another folder of that name, or Edge: another one.
assert_eq!(profile(chrome.clone(), "/work/shop").unwrap(), dir.to_string_lossy().as_ref());
assert_ne!(profile(chrome.clone(), "/other/shop").unwrap(), dir.to_string_lossy().as_ref());
let edge = profile(serde_json::json!({ "type": "pwa-msedge", "request": "launch" }), "/work/shop").unwrap();
assert!(Path::new(edge.as_str().unwrap()).ends_with("msedge"), "{edge}");
// A chosen profile (or the default one, `false`) is kept; Node and attach requests get none.
let own = serde_json::json!({ "type": "pwa-chrome", "request": "launch", "userDataDir": false });
assert_eq!(profile(own, "/work/shop"), Some(false.into()));
assert_eq!(profile(serde_json::json!({ "type": "pwa-node", "request": "launch" }), "/work/shop"), None);
assert_eq!(profile(serde_json::json!({ "type": "pwa-chrome", "request": "attach" }), "/work/shop"), None);
}
#[test]
fn port_conflicts_are_recognised_for_retry() {
let in_use = IdeError::unavailable(
-283
Переглянути файл
@@ -1,283 +0,0 @@
//! Requests of vscode-js-debug's own, beyond the Debug Adapter Protocol: what a page in a
//! browser offers a debugger besides stepping (breakpoints on DOM events and requests).
//! Each is a typed method; a js-debug build that lacks one answers with an error, which is
//! reported as the feature being unavailable.
use crate::session::Session;
use crate::{DebugManager, DebugSessionId, REQUEST_TIMEOUT};
use ide_core::event::{JsInitiator, JsNetworkEvent, JsNetworkKind};
use ide_core::{IdeError, IdeResult};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use specta::Type;
use std::collections::BTreeMap;
pub(crate) const ADAPTER: &str = "js-debug";
/// Whether `session` debugs a page: the child js-debug starts for a browser launch or attach.
/// The launch's own session only owns the browser.
pub(crate) fn is_page(session: &Session) -> bool {
session.adapter == ADAPTER
&& session.parent.is_some()
&& matches!(session.launch_type.get().map(String::as_str), Some("pwa-chrome" | "pwa-msedge"))
}
/// 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 {
let mut launched = session.launched.subscribe();
let answered = tokio::time::timeout(REQUEST_TIMEOUT, launched.wait_for(|answered| *answered)).await;
matches!(answered, Ok(Ok(_)))
}
impl DebugManager {
/// One of js-debug's requests for pages, sent to the page `s` once its session has started.
async fn js_page_request(&self, s: DebugSessionId, command: &str, arguments: Value) -> IdeResult<Value> {
let session = self.session(s)?;
if !is_page(&session) {
return Err(IdeError::unavailable("this debug session is not a page in a browser"));
}
if !launched(&session).await {
return Err(IdeError::unavailable("the page's debug session did not finish starting"));
}
session
.conn
.request_timeout(command, arguments, REQUEST_TIMEOUT)
.await
.map_err(|e| IdeError::unavailable(format!("the JavaScript debugger refused `{command}`: {e}")))
}
/// Breakpoints on events (`listener:click`, `instrumentation:setTimeout`) and on requests
/// whose URL contains one of `xhr`. The lists replace the page's previous ones.
pub async fn js_set_custom_breakpoints(
&self,
s: DebugSessionId,
ids: Vec<String>,
xhr: Vec<String>,
) -> IdeResult<()> {
self.js_page_request(s, "setCustomBreakpoints", json!({ "ids": ids, "xhr": xhr })).await?;
Ok(())
}
}
/// The browser events js-debug is asked to pass on (`enableNetworking`'s `mirrorEvents`).
pub(crate) const NETWORK_EVENTS: [&str; 4] =
["requestWillBeSent", "responseReceived", "loadingFinished", "loadingFailed"];
/// Response bodies shown are cut at this many bytes.
pub const RESPONSE_BODY_LIMIT: usize = 1_048_576;
/// The body of a response, as text or as the browser's base64 for binary content.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct JsResponseBody {
pub body: String,
pub base64_encoded: bool,
/// The body was longer than [`RESPONSE_BODY_LIMIT`] and is cut there.
pub truncated: bool,
}
fn text(v: &Value, key: &str) -> Option<String> {
v.get(key).and_then(Value::as_str).map(str::to_owned)
}
fn headers(v: &Value) -> BTreeMap<String, String> {
v.get("headers")
.and_then(Value::as_object)
.map(|h| h.iter().filter_map(|(k, v)| Some((k.clone(), v.as_str()?.to_owned()))).collect())
.unwrap_or_default()
}
/// The frame a request is attributed to: the innermost one outside `node_modules`, looked
/// for through the stacks that scheduled this one (an `await`, a timer), since a request sent
/// through a library has the library innermost. A stack of library frames only gives its first.
fn initiator(data: &Value) -> Option<JsInitiator> {
let stack = data.pointer("/initiator/stack")?;
let frames = |stack: &Value| stack.get("callFrames").and_then(Value::as_array).cloned().unwrap_or_default();
let stacks = std::iter::successors(Some(stack), |stack| stack.get("parent")).take(32);
let all: Vec<Value> = stacks.flat_map(frames).collect();
let own = |frame: &&Value| {
frame.get("url").and_then(Value::as_str).is_some_and(|url| !url.is_empty() && !url.contains("/node_modules/"))
};
let frame = all.iter().find(own).or(all.first())?;
let number = |key: &str| frame.get(key).and_then(Value::as_u64).and_then(|n| u32::try_from(n).ok()).unwrap_or(0);
Some(JsInitiator {
function: text(frame, "functionName").unwrap_or_default(),
url: text(frame, "url")?,
line: number("lineNumber"),
column: number("columnNumber"),
})
}
/// js-debug's `networkEvent` (`{event, data}`, `data` being the browser's own parameters of
/// that event) as a [`JsNetworkEvent`]; `None` for events the Network view has no use for.
pub(crate) fn network_event(body: &Value) -> Option<JsNetworkEvent> {
let data = body.get("data")?;
let kind = match body.get("event").and_then(Value::as_str)? {
"requestWillBeSent" => JsNetworkKind::Request,
"responseReceived" => JsNetworkKind::Response,
"loadingFinished" => JsNetworkKind::Finished,
"loadingFailed" => JsNetworkKind::Failed,
_ => return None,
};
let part = match kind {
JsNetworkKind::Request => data.get("request"),
JsNetworkKind::Response => data.get("response"),
_ => None,
};
let part = part.unwrap_or(&Value::Null);
Some(JsNetworkEvent {
kind,
request_id: text(data, "requestId")?,
// Seconds with a fraction.
timestamp_ms: (data.get("timestamp").and_then(Value::as_f64).unwrap_or(0.0).max(0.0) * 1000.0).round() as u64,
url: text(part, "url"),
method: text(part, "method"),
resource_type: text(data, "type"),
status: part.get("status").and_then(Value::as_u64).and_then(|s| u16::try_from(s).ok()),
mime_type: text(part, "mimeType"),
headers: headers(part),
encoded_length: data.get("encodedDataLength").and_then(Value::as_f64).map(|n| n.max(0.0) as u64),
error_text: text(data, "errorText"),
initiator: if kind == JsNetworkKind::Request { initiator(data) } else { None },
})
}
/// `networkCall getResponseBody`'s answer, cut at `limit` bytes on a character boundary.
fn response_body(answer: &Value, limit: usize) -> JsResponseBody {
let mut body = text(answer, "body").unwrap_or_default();
let truncated = body.len() > limit;
if truncated {
let mut end = limit;
while !body.is_char_boundary(end) {
end -= 1;
}
body.truncate(end);
}
JsResponseBody {
body,
base64_encoded: answer.get("base64Encoded").and_then(Value::as_bool).unwrap_or(false),
truncated,
}
}
/// The arguments of `enableNetworking`, which has a page report its requests
/// ([`ide_core::event::DebugEvent::JsNetwork`]). A page session sends it as it starts.
pub(crate) fn networking_arguments() -> Value {
json!({ "mirrorEvents": NETWORK_EVENTS })
}
impl DebugManager {
/// The body of the response to `request_id`, while the browser still has it.
pub async fn js_response_body(&self, s: DebugSessionId, request_id: String) -> IdeResult<JsResponseBody> {
let call = json!({ "method": "getResponseBody", "params": { "requestId": request_id } });
let answer = self.js_page_request(s, "networkCall", call).await?;
Ok(response_body(&answer, RESPONSE_BODY_LIMIT))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn event(kind: &str, data: Value) -> Option<ide_core::event::JsNetworkEvent> {
network_event(&json!({ "event": kind, "data": data }))
}
#[test]
fn a_request_carries_what_was_asked_and_who_asked() {
let e = event(
"requestWillBeSent",
json!({
"requestId": "76695.31", "timestamp": 611807.5, "type": "Fetch",
"request": { "method": "GET", "url": "http://127.0.0.1:5195/src/main.ts", "headers": { "Accept": "*/*", "X-N": 1 } },
"initiator": { "type": "script", "stack": { "callFrames": [
{ "functionName": "load", "url": "http://127.0.0.1:5195/src/App.vue", "lineNumber": 9, "columnNumber": 2 }
] } }
}),
)
.unwrap();
assert_eq!(e.kind, JsNetworkKind::Request);
assert_eq!((e.request_id.as_str(), e.timestamp_ms), ("76695.31", 611_807_500));
assert_eq!(e.url.as_deref(), Some("http://127.0.0.1:5195/src/main.ts"));
assert_eq!((e.method.as_deref(), e.resource_type.as_deref()), (Some("GET"), Some("Fetch")));
// Only text headers are kept.
assert_eq!(e.headers.len(), 1);
assert_eq!(e.headers["Accept"], "*/*");
let from = e.initiator.unwrap();
assert_eq!((from.function.as_str(), from.line, from.column), ("load", 9, 2));
assert_eq!(from.url, "http://127.0.0.1:5195/src/App.vue");
}
#[test]
fn a_request_made_through_a_library_is_asked_by_the_code_that_called_the_library() {
let frame = |function: &str, url: &str| json!({ "functionName": function, "url": url, "lineNumber": 1, "columnNumber": 0 });
let lib = "http://localhost:5173/node_modules/.vite/deps/http-client.js";
let asked_by = |stack: Value| {
let data = json!({ "requestId": "1", "timestamp": 1.0, "request": { "method": "GET", "url": "http://localhost/api" },
"initiator": { "type": "script", "stack": stack } });
event("requestWillBeSent", data).unwrap().initiator.unwrap().function
};
// The innermost frame is the library's; the project's caller is further out.
let direct = json!({ "callFrames": [frame("dispatch", lib), frame("request", lib), frame("loadItems", "http://localhost:5173/src/api.ts")] });
assert_eq!(asked_by(direct), "loadItems");
// Across an `await`, the caller is in the stack that scheduled this one.
let scheduled = json!({ "callFrames": [frame("dispatch", lib)],
"parent": { "callFrames": [frame("then", lib)], "parent": { "callFrames": [frame("onMounted", "http://localhost:5173/src/App.vue")] } } });
assert_eq!(asked_by(scheduled), "onMounted");
// A library on its own: the frame that sent the request.
assert_eq!(asked_by(json!({ "callFrames": [frame("dispatch", lib), frame("poll", lib)] })), "dispatch");
}
#[test]
fn a_response_a_finish_and_a_failure_carry_their_outcome() {
let response = event(
"responseReceived",
json!({ "requestId": "7", "timestamp": 2.0, "type": "Script", "response": {
"url": "http://localhost/a.js", "status": 200, "mimeType": "text/javascript",
"headers": { "Content-Type": "text/javascript" }
} }),
)
.unwrap();
assert_eq!(response.kind, JsNetworkKind::Response);
assert_eq!((response.status, response.mime_type.as_deref()), (Some(200), Some("text/javascript")));
assert_eq!(response.headers["Content-Type"], "text/javascript");
let finished =
event("loadingFinished", json!({ "requestId": "7", "timestamp": 2.5, "encodedDataLength": 1234 })).unwrap();
assert_eq!((finished.kind, finished.encoded_length), (JsNetworkKind::Finished, Some(1234)));
let failed =
event("loadingFailed", json!({ "requestId": "8", "timestamp": 3.0, "errorText": "net::ERR_FAILED" }))
.unwrap();
assert_eq!((failed.kind, failed.error_text.as_deref()), (JsNetworkKind::Failed, Some("net::ERR_FAILED")));
}
#[test]
fn events_that_name_no_request_or_are_not_mirrored_are_dropped() {
assert!(event("requestWillBeSent", json!({ "timestamp": 1.0 })).is_none());
assert!(event("dataReceived", json!({ "requestId": "7", "timestamp": 1.0 })).is_none());
assert!(network_event(&json!({ "data": { "requestId": "7" } })).is_none());
// A request made by the page's parser has no script behind it.
let parser = event(
"requestWillBeSent",
json!({ "requestId": "9", "timestamp": 1.0, "request": { "method": "GET", "url": "http://localhost/" },
"initiator": { "type": "parser" } }),
)
.unwrap();
assert!(parser.initiator.is_none());
}
#[test]
fn a_response_body_is_cut_at_the_limit_on_a_character() {
let whole = response_body(&json!({ "body": "héllo", "base64Encoded": false }), 100);
assert_eq!((whole.body.as_str(), whole.base64_encoded, whole.truncated), ("héllo", false, false));
// `é` is two bytes: a cut inside it backs off to the character before.
let cut = response_body(&json!({ "body": "héllo" }), 2);
assert_eq!((cut.body.as_str(), cut.truncated), ("h", true));
let binary = response_body(&json!({ "body": "AAEC", "base64Encoded": true }), 100);
assert!(binary.base64_encoded && !binary.truncated);
assert_eq!(response_body(&json!({}), 100).body, "");
}
}
+2 -44
Переглянути файл
@@ -13,7 +13,6 @@ pub mod attach;
mod cargo;
pub mod connection;
pub mod jdwp;
mod js_debug;
mod jvm_memory;
pub mod kotlin;
pub mod kotlin_lsp;
@@ -24,7 +23,6 @@ use connection::{Connection, DapError};
use futures::future::join_all;
use ide_core::{DebugSessionId, IdeError, IdeResult, SharedSink};
use ide_language::LanguageRegistry;
pub use js_debug::JsResponseBody;
pub use jvm_memory::*;
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
@@ -138,11 +136,6 @@ pub struct DebugCapabilities {
pub jvm_instances: bool,
/// The session can be asked for a heap dump ([`DebugManager::jvm_heap_dump`]).
pub jvm_heap: bool,
/// The session debugs a page in a browser: js-debug's requests for pages apply
/// ([`DebugManager::js_set_custom_breakpoints`]).
pub js_page: bool,
/// The adapter evaluates expressions while the program runs, without a stack frame.
pub live_evaluate: bool,
}
/// Result of [`DebugManager::redefine_classes`].
@@ -400,16 +393,6 @@ fn no_session(s: DebugSessionId) -> IdeError {
IdeError::not_found(format!("no debug session {}", s.0))
}
/// The name of a child session: the adapter's, or the parent's when it gives none. js-debug
/// names a browser page's session after the page the browser starts on, before the URL
/// loads (`about:blank`, or nothing in a new profile), which says nothing about what is debugged.
fn child_name(configuration: &Value, parent: &str) -> String {
match configuration.get("name").and_then(Value::as_str) {
Some(name) if !name.trim().is_empty() && name != "about:blank" => name.to_owned(),
_ => parent.to_owned(),
}
}
/// Builds the `launch`/`attach` arguments: our fields first, then `extra_json` merged on top.
pub fn launch_arguments(config: &LaunchConfig) -> IdeResult<Value> {
// VS Code puts `type`, `request` and `name` in the arguments; js-debug picks launch
@@ -597,8 +580,6 @@ pub(crate) fn capabilities_from(caps: &Value) -> DebugCapabilities {
jvm_memory: false,
jvm_instances: false,
jvm_heap: false,
js_page: false,
live_evaluate: false,
}
}
@@ -608,8 +589,6 @@ pub(crate) fn session_capabilities(session: &Session) -> DebugCapabilities {
caps.jvm_memory = JVM_MEMORY_ADAPTERS.contains(&session.adapter.as_str());
caps.jvm_instances = session.jvm_inspector.lock().is_some();
caps.jvm_heap = JVM_MEMORY_ADAPTERS.contains(&session.adapter.as_str());
caps.js_page = js_debug::is_page(session);
caps.live_evaluate = session.adapter == js_debug::ADAPTER;
caps
}
@@ -765,9 +744,6 @@ impl DebugManager {
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into()));
let dbg = adapter::find_config(&self.languages, &config.adapter)
.ok_or_else(|| IdeError::unavailable(format!("unknown debug adapter `{}`", config.adapter)))?;
if config.adapter == "js-debug" {
adapter::set_browser_profile(&mut arguments, &cwd);
}
let mut spec = adapter::resolve(&dbg, cwd.clone());
if config.adapter == kotlin::ADAPTER
&& let Some(home) = arguments
@@ -896,7 +872,8 @@ impl DebugManager {
request: String,
configuration: Value,
) -> IdeResult<()> {
let name = child_name(&configuration, &parent.name);
let name =
configuration.get("name").and_then(Value::as_str).map(str::to_owned).unwrap_or_else(|| parent.name.clone());
let mut configuration = configuration;
if let Some(obj) = configuration.as_object_mut() {
obj.entry("request").or_insert_with(|| request.clone().into());
@@ -944,9 +921,6 @@ impl DebugManager {
.flatten();
let session =
Arc::new(Session::new(id, owner, name, adapter_name, parent, request, conn, spec, io.port, io.child));
if let Some(launch_type) = arguments.get("type").and_then(Value::as_str) {
let _ = session.launch_type.set(launch_type.to_owned());
}
// rdbg execs the debuggee in its own process and sends no DAP process event.
if let Some(pid) = rdbg_pid {
session.debuggee_pid.store(i64::from(pid), Ordering::Release);
@@ -1181,22 +1155,6 @@ impl DebugManager {
reply.await.unwrap_or(Err(DapError::Closed))?;
}
}
s.launched.send_replace(true);
// A page reports its requests from here on. This is the earliest js-debug takes the
// request (before its answer to `launch` the page has no adapter and the request is
// never answered), and the page is already loading: its document and first scripts
// are not in the list.
if js_debug::is_page(s) {
let answer = s.conn.send_request("enableNetworking", js_debug::networking_arguments())?;
let session = s.clone();
// Not waited for here: the page runs on. A refusal is told to the Network view,
// which would otherwise wait for requests that never come.
tokio::spawn(async move {
if matches!(answer.await, Ok(Err(_))) {
session.emit(ide_core::event::DebugEvent::JsNetworkUnavailable);
}
});
}
if let Some(link) = s.jdwp_link.lock().as_ref() {
link.release();
}
-13
Переглянути файл
@@ -57,8 +57,6 @@ pub(crate) struct Session {
pub job: Mutex<Option<ide_core::process::Job>>,
/// Set once `configurationDone` was sent; breakpoint updates go out live after that.
pub configured: AtomicBool,
/// Set once the adapter answered `launch` / `attach`, after the configuration.
pub launched: watch::Sender<bool>,
pub initialized: watch::Sender<bool>,
pub startup_cancel: ide_core::CancellationToken,
pub output_drained: watch::Sender<bool>,
@@ -91,9 +89,6 @@ pub(crate) struct Session {
progress: Mutex<HashMap<String, TaskId>>,
/// Relay between a Kotlin adapter and its JVM. Dropping it closes both sockets.
pub jdwp_link: parking_lot::Mutex<Option<ide_jdwp::JdwpLink>>,
/// 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>,
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<()>,
@@ -235,7 +230,6 @@ impl Session {
#[cfg(windows)]
job: Mutex::new(job),
configured: AtomicBool::new(false),
launched: watch::channel(false).0,
initialized: watch::channel(false).0,
startup_cancel: ide_core::CancellationToken::new(),
output_drained: watch::channel(true).0,
@@ -254,7 +248,6 @@ impl Session {
resolved_paths: Mutex::new(HashMap::new()),
progress: Mutex::new(HashMap::new()),
jdwp_link: parking_lot::Mutex::new(None),
launch_type: std::sync::OnceLock::new(),
jvm_inspector: parking_lot::Mutex::new(None),
inspect_lock: tokio::sync::Mutex::default(),
ended: ide_core::CancellationToken::new(),
@@ -839,12 +832,6 @@ async fn on_event(mgr: &Arc<DebugManager>, s: &Arc<Session>, event: &str, mut bo
.map(|a| a.iter().filter_map(Value::as_str).map(str::to_owned).collect())
.unwrap_or_default(),
}),
// js-debug, after `enableNetworking`.
"networkEvent" => {
if let Some(event) = crate::js_debug::network_event(&body) {
s.emit(DebugEvent::JsNetwork { event });
}
}
_ => tracing::trace!("dap: ignored event {event}"),
}
}
+5 -212
Переглянути файл
@@ -146,10 +146,6 @@ struct Script {
delay_initialize: Option<Duration>,
initialized_delay: Duration,
launch_delay: Duration,
/// Answer a child session's `launch` this long after its `configurationDone`, reading
/// requests meanwhile. Like js-debug, one of `accept` that arrives in between is answered
/// and has no effect: the log gets a `dropped` entry for it.
child_launch_delay: Duration,
/// Leave startup pending before `initialized` (false) or after configuration (true).
pending_startup: Option<bool>,
startup_error: Option<&'static str>,
@@ -158,15 +154,8 @@ struct Script {
/// `launch`, and a `configurationDone` sent while it runs is refused with `notStopped`.
gdb15_starts_at_launch: bool,
launch_after_configuration: bool,
/// Requests of the adapter's own (js-debug's `setCustomBreakpoints`…) answered with an empty body.
accept: &'static [&'static str],
/// Requests of the adapter's own answered with a body and followed by events.
custom: Option<fn(&Value) -> Option<CustomReply>>,
}
/// The answer to a request of the adapter's own: the response body, then events (name, body).
type CustomReply = (Value, Vec<(&'static str, Value)>);
impl Default for Script {
fn default() -> Self {
Self {
@@ -192,14 +181,11 @@ impl Default for Script {
delay_initialize: None,
initialized_delay: Duration::ZERO,
launch_delay: Duration::ZERO,
child_launch_delay: Duration::ZERO,
pending_startup: None,
startup_error: None,
close_during_startup: false,
gdb15_starts_at_launch: false,
launch_after_configuration: false,
accept: &[],
custom: None,
}
}
}
@@ -232,15 +218,10 @@ impl AdapterLauncher for FakeLauncher {
}
async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream: S, log: Log, script: Script) {
let (r, w) = tokio::io::split(stream);
// Shared with the task that answers a child's `launch` late (`child_launch_delay`).
let w = Arc::new(tokio::sync::Mutex::new(w));
let (r, mut w) = tokio::io::split(stream);
let mut r = BufReader::new(r);
let mut seq = 1000;
let mut pending_launch: Option<i64> = None;
let mut child = false;
// Between `configurationDone` and the answer to `launch`.
let starting = Arc::new(std::sync::atomic::AtomicBool::new(false));
let mut held: Vec<Value> = Vec::new();
let mut stopped = false;
let mut running = false;
@@ -249,7 +230,7 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
let mut v: Value = $v;
seq += 1;
v["seq"] = seq.into();
framing::write_message(&mut *w.lock().await, &serde_json::to_vec(&v).unwrap()).await.unwrap();
framing::write_message(&mut w, &serde_json::to_vec(&v).unwrap()).await.unwrap();
}};
}
macro_rules! ok {
@@ -311,7 +292,6 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
continue;
}
pending_launch = msg["seq"].as_i64();
child = msg["arguments"]["isChild"] == true;
if let Some(args) = &script.run_in_terminal {
send!(
json!({"type": "request", "command": "runInTerminal", "arguments": {"kind": "integrated", "args": args, "cwd": std::env::temp_dir()}})
@@ -319,8 +299,7 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
}
if script.start_debugging && msg["arguments"]["isChild"] != true {
send!(
// Like js-debug, the child's configuration repeats the launch's `type`.
json!({"type": "request", "command": "startDebugging", "arguments": {"request": "launch", "configuration": {"name": "child", "isChild": true, "type": msg["arguments"]["type"]}}})
json!({"type": "request", "command": "startDebugging", "arguments": {"request": "launch", "configuration": {"name": "child", "isChild": true}}})
);
}
if script.pending_startup != Some(false) {
@@ -399,21 +378,7 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
}
tokio::time::sleep(script.launch_delay).await;
if let Some(l) = pending_launch.take() {
let answer = json!({"type": "response", "request_seq": l, "command": "launch", "success": true});
if child && !script.child_launch_delay.is_zero() {
starting.store(true, Ordering::Release);
let (w, starting, delay) = (w.clone(), starting.clone(), script.child_launch_delay);
tokio::spawn(async move {
tokio::time::sleep(delay).await;
let mut answer = answer;
answer["seq"] = 999_999.into();
let _ = framing::write_message(&mut *w.lock().await, &serde_json::to_vec(&answer).unwrap())
.await;
starting.store(false, Ordering::Release);
});
} else {
send!(answer);
}
send!(json!({"type": "response", "request_seq": l, "command": "launch", "success": true}));
}
if let Some(pid) = script.debuggee_pid {
let event = if script.java_process_id {
@@ -509,25 +474,9 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
}
"disconnect" => {
ok!(msg, Value::Null);
let _ = w.lock().await.shutdown().await;
let _ = w.shutdown().await;
break;
}
other if script.accept.contains(&other) => {
if starting.load(Ordering::Acquire) {
log.lock().push(json!({"type": "dropped", "command": other}));
}
ok!(msg, json!({}))
}
other if script.custom.and_then(|custom| custom(&msg)).is_some() => {
if starting.load(Ordering::Acquire) {
log.lock().push(json!({"type": "dropped", "command": other}));
}
let (body, events) = script.custom.and_then(|custom| custom(&msg)).unwrap();
ok!(msg, body);
for (event, body) in events {
send!(json!({"type": "event", "event": event, "body": body}));
}
}
other => send!(
json!({"type": "response", "request_seq": msg["seq"], "command": other, "success": false, "message": "unsupported"})
),
@@ -956,162 +905,6 @@ async fn blocked_program_input_does_not_hold_up_shutdown() {
assert!(h.manager.send_input(session, "later").is_err());
}
fn js_config(launch_type: &str) -> LaunchConfig {
LaunchConfig {
adapter: "js-debug".into(),
program: None,
args: Vec::new(),
extra_json: Some(format!(r#"{{"type": "{launch_type}"}}"#)),
..config()
}
}
/// The session the adapter started under `parent`.
async fn child_of(h: &Harness, parent: DebugSessionId) -> DebugSessionId {
for _ in 0..200 {
if let Some(child) = h.manager.sessions(Some("main")).into_iter().find(|s| s.parent == Some(parent)) {
return child.id;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
panic!("no child session of {parent:?}");
}
#[tokio::test]
async fn a_browser_page_session_has_the_page_capability() {
let h = harness(Script { start_debugging: true, ..Script::default() });
let browser = h.start(js_config("pwa-chrome")).await.unwrap();
let page = h.manager.capabilities(child_of(&h, browser).await).unwrap();
assert!(page.js_page && page.live_evaluate);
assert!(!h.manager.capabilities(browser).unwrap().js_page, "the launch itself is not a page");
let node = h.start(js_config("pwa-node")).await.unwrap();
let process = h.manager.capabilities(child_of(&h, node).await).unwrap();
assert!(!process.js_page && process.live_evaluate);
let python = h.start(config()).await.unwrap();
let script = h.manager.capabilities(child_of(&h, python).await).unwrap();
assert!(!script.js_page && !script.live_evaluate);
}
#[tokio::test]
async fn custom_breakpoints_are_sent_to_a_page_and_refused_elsewhere() {
let h = harness(Script { start_debugging: true, accept: &["setCustomBreakpoints"], ..Script::default() });
let browser = h.start(js_config("pwa-chrome")).await.unwrap();
let page = child_of(&h, browser).await;
h.manager.js_set_custom_breakpoints(page, vec!["listener:click".into()], vec!["api/".into()]).await.unwrap();
assert_eq!(requests(&h, "setCustomBreakpoints"), [json!({"ids": ["listener:click"], "xhr": ["api/"]})]);
let not_a_page = h.manager.js_set_custom_breakpoints(browser, Vec::new(), Vec::new()).await.unwrap_err();
assert_eq!(not_a_page.kind, ide_core::error::ErrorKind::Unavailable);
// A js-debug build without the request.
let old = harness(Script { start_debugging: true, ..Script::default() });
let browser = old.start(js_config("pwa-chrome")).await.unwrap();
let page = child_of(&old, browser).await;
let refused = old.manager.js_set_custom_breakpoints(page, Vec::new(), Vec::new()).await.unwrap_err();
assert_eq!(refused.kind, ide_core::error::ErrorKind::Unavailable);
}
/// js-debug answers a request of its own that reaches a page after `configurationDone` and
/// before the page's `launch` is answered, and then does nothing with it.
#[tokio::test]
async fn a_page_gets_its_requests_once_its_session_has_started() {
let h = harness(Script {
start_debugging: true,
accept: &["setCustomBreakpoints"],
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;
// Past the page's `configurationDone`, with its `launch` still unanswered.
tokio::time::sleep(Duration::from_millis(50)).await;
h.manager.js_set_custom_breakpoints(page, vec!["listener:click".into()], Vec::new()).await.unwrap();
assert_eq!(requests(&h, "setCustomBreakpoints"), [json!({"ids": ["listener:click"], "xhr": []})]);
let dropped = h.log.lock().iter().filter(|m| m["type"] == "dropped").count();
assert_eq!(dropped, 0, "the request reached the page before it took requests");
}
/// js-debug's network requests: `enableNetworking` is followed by one mirrored browser event.
fn network_adapter(msg: &Value) -> Option<CustomReply> {
match msg["command"].as_str()? {
"enableNetworking" => Some((
json!({}),
vec![(
"networkEvent",
json!({ "event": "requestWillBeSent", "data": {
"requestId": "7", "timestamp": 1.5, "type": "Fetch",
"request": { "method": "GET", "url": "http://localhost:5173/api/items", "headers": {} }
} }),
)],
)),
"networkCall" => Some((json!({ "body": "é".repeat(600_000), "base64Encoded": false }), Vec::new())),
_ => None,
}
}
#[tokio::test]
async fn a_page_reports_its_requests_and_their_bodies() {
let h = harness(Script {
start_debugging: true,
custom: Some(network_adapter),
child_launch_delay: Duration::from_millis(100),
..Script::default()
});
let browser = h.start(js_config("pwa-chrome")).await.unwrap();
let page = child_of(&h, browser).await;
// Recording is asked for by the session itself, as soon as js-debug takes the request:
// once it has answered the page's `launch` (before that, the request is never answered).
let DebugEvent::JsNetwork { event } = h.wait_event(|e| matches!(e, DebugEvent::JsNetwork { .. })).await else {
unreachable!()
};
assert_eq!(
requests(&h, "enableNetworking"),
[json!({ "mirrorEvents": ["requestWillBeSent", "responseReceived", "loadingFinished", "loadingFailed"] })]
);
let dropped = h.log.lock().iter().filter(|m| m["type"] == "dropped").count();
assert_eq!(dropped, 0, "recording was asked for before the page took requests");
assert_eq!((event.request_id.as_str(), event.timestamp_ms), ("7", 1500));
assert_eq!(event.url.as_deref(), Some("http://localhost:5173/api/items"));
let body = h.manager.js_response_body(page, "7".into()).await.unwrap();
assert_eq!(requests(&h, "networkCall"), [json!({ "method": "getResponseBody", "params": { "requestId": "7" } })]);
// 1.2 MB of two-byte characters: cut at the limit, on a character.
assert!(body.truncated && !body.base64_encoded);
assert_eq!(body.body.len(), 1_048_576);
assert!(body.body.chars().all(|c| c == 'é'));
assert_eq!(
h.manager.js_response_body(browser, "7".into()).await.unwrap_err().kind,
ide_core::error::ErrorKind::Unavailable
);
// A Node.js program is not a page: nothing is asked of it.
let node = h.start(js_config("pwa-node")).await.unwrap();
child_of(&h, node).await;
h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await;
assert_eq!(requests(&h, "enableNetworking").len(), 1);
}
#[tokio::test]
async fn a_page_whose_requests_the_adapter_does_not_report_says_so() {
// A js-debug build without `enableNetworking`.
let h = harness(Script { start_debugging: true, ..Script::default() });
let browser = h.start(js_config("pwa-chrome")).await.unwrap();
child_of(&h, browser).await;
h.wait_event(|e| matches!(e, DebugEvent::JsNetworkUnavailable)).await;
}
#[test]
fn a_child_session_is_named_by_the_adapter_unless_it_is_a_blank_page() {
assert_eq!(child_name(&json!({ "name": "server.js [4120]" }), "Server"), "server.js [4120]");
assert_eq!(child_name(&json!({ "name": "about:blank" }), "Shop in Chrome"), "Shop in Chrome");
assert_eq!(child_name(&json!({ "name": "" }), "Shop in Chrome"), "Shop in Chrome");
assert_eq!(child_name(&json!({}), "Shop in Chrome"), "Shop in Chrome");
}
#[test]
fn launch_arguments_merge_and_defaults() {
let mut c = config();
+12 -46
Переглянути файл
@@ -324,17 +324,6 @@ fn group_foreign_keys(rows: Vec<Vec<Option<String>>>, tables: &[CompletionTable]
/// Connection target: a data source id (its configured database), or
/// `<id>:<database>` for another database of the same server. Data source ids
/// never contain `:`.
/// Snowflake is HTTPS to a named host: a tunnel's local end cannot pass its certificate check.
fn tunneled(driver: DbDriver) -> bool {
!matches!(driver, DbDriver::Sqlite | DbDriver::Snowflake)
}
/// The database host and port the SSH host forwards to.
fn tunnel_target(source: &DataSource) -> (&str, u16) {
let port = source.port.or(source.driver.default_port()).unwrap_or_default();
(if source.host.trim().is_empty() { "localhost" } else { source.host.trim() }, port)
}
pub fn split_target(target: &str) -> (&str, Option<&str>) {
match target.split_once(':') {
Some((id, db)) if !db.is_empty() => (id, Some(db)),
@@ -666,7 +655,9 @@ impl DbManager {
ssh_secret: Option<String>,
shared: bool,
) -> IdeResult<(DataSource, Option<Arc<Tunnel>>)> {
let Some(ssh) = source.ssh.as_ref().filter(|s| s.enabled && tunneled(source.driver)) else {
// Snowflake is HTTPS to a named host: a tunnel's local end cannot pass its certificate check.
let tunneled = !matches!(source.driver, DbDriver::Sqlite | DbDriver::Snowflake);
let Some(ssh) = source.ssh.as_ref().filter(|s| s.enabled && tunneled) else {
return Ok((source.clone(), None));
};
let local = |t: &Tunnel| DataSource { host: "127.0.0.1".into(), port: Some(t.port()), ..source.clone() };
@@ -677,25 +668,8 @@ impl DbManager {
return Ok((local(&t), Some(t)));
}
}
let session = self.ssh_session(source, ssh, ssh_secret).await?;
let (target_host, target_port) = tunnel_target(source);
let t = Arc::new(Tunnel::open(session, target_host, target_port).await?);
if shared {
self.tunnels.lock().insert(source.id.clone(), t.clone());
}
Ok((local(&t), Some(t)))
}
/// The SSH session the tunnel of `source` goes through: its shared SSH configuration's, or one
/// opened with the settings typed in the data source.
async fn ssh_session(
&self,
source: &DataSource,
ssh: &DbSsh,
ssh_secret: Option<String>,
) -> IdeResult<Arc<ide_ssh::Session>> {
let config = ssh.config.trim();
Ok(if config.is_empty() {
let session = if config.is_empty() {
let secret = match ssh_secret {
Some(s) => Some(s),
None => self.secret(source, secrets::ssh_key(&source.id), ssh.save_secret).await,
@@ -714,22 +688,14 @@ impl DbManager {
self.ssh.set_secret(config, Some(s), save);
}
self.ssh.session(config).await?
})
}
/// Data Source › SSH/SSL › Test SSH Tunnel: logs in to the SSH host and checks that it reaches
/// the database host and port, without logging in to the database.
pub async fn test_tunnel(&self, source: &DataSource, ssh_secret: Option<String>) -> IdeResult<String> {
let source = &source.resolved()?;
let ssh = source
.ssh
.as_ref()
.filter(|s| s.enabled && tunneled(source.driver))
.ok_or_else(|| IdeError::invalid("This data source does not use an SSH tunnel"))?;
let session = self.ssh_session(source, ssh, ssh_secret).await?;
let (host, port) = tunnel_target(source);
drop(Tunnel::open(session.clone(), host, port).await?);
Ok(format!("{} reaches {host}:{port}", session.label()))
};
let target_port = source.port.or(source.driver.default_port()).unwrap_or_default();
let target_host = if source.host.trim().is_empty() { "localhost" } else { source.host.trim() };
let t = Arc::new(Tunnel::open(session, target_host, target_port).await?);
if shared {
self.tunnels.lock().insert(source.id.clone(), t.clone());
}
Ok((local(&t), Some(t)))
}
/// Connects with `source` as given (not necessarily saved): Test Connection.
-35
Переглянути файл
@@ -117,14 +117,6 @@ pub(crate) fn oracle_err(e: &oracledb::Error) -> IdeError {
// config first. Treat it as a logon failure, so the UI prompts for one.
let kind = match e.kind() {
oracledb::ErrorKind::NoCredentials => ErrorKind::PermissionDenied,
// The thin driver speaks the protocol of Oracle Database 12c and later only.
oracledb::ErrorKind::ServerVersionNotSupported => {
return IdeError::new(
ErrorKind::Unavailable,
"This Oracle Database is older than 12c. OpenIDEA connects to Oracle Database 12c and later; \
11g and earlier are not supported.",
);
}
_ => ErrorKind::InvalidInput,
};
return IdeError::new(kind, e.to_string());
@@ -579,33 +571,6 @@ mod tests {
assert_eq!(connect_string_with(&s("PROD=MYDB"), false).unwrap(), "PROD=MYDB");
}
#[test]
fn a_server_older_than_12c_is_named() {
use std::io::{Read, Write};
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let server = std::thread::spawn(move || {
let (mut socket, _) = listener.accept().unwrap();
let mut connect = [0u8; 1024];
let _ = socket.read(&mut connect);
// A TNS ACCEPT packet with protocol version 314, as Oracle Database 11g answers.
let mut accept = vec![0u8; 32];
accept[1] = 32;
accept[4] = 2;
accept[8..10].copy_from_slice(&314u16.to_be_bytes());
let _ = socket.write_all(&accept);
});
let config = oracledb::Config::default()
.set_credentials("scott", "tiger")
.set_connect_string(&format!("//127.0.0.1:{port}/ORCL"))
.unwrap()
.set_transport_connect_timeout(Some(std::time::Duration::from_secs(5)));
let err = oracle_err(&oracledb::connect(config).err().expect("an 11g server is refused"));
assert_eq!(err.kind, ErrorKind::Unavailable, "{err:?}");
assert!(err.message.contains("older than 12c"), "{}", err.message);
server.join().unwrap();
}
#[test]
fn a_tns_alias_resolves_when_tnsnames_exists() {
let alias = DataSource {
-96
Переглянути файл
@@ -1,96 +0,0 @@
//! Data Source › SSH/SSL › Test SSH Tunnel against an in-process SSH server: the SSH login and
//! the SSH host reaching the database host and port, with no database login.
use ide_core::error::ErrorKind;
use ide_db::{DataSource, DbDriver, DbManager, DbSsh, DbSshAuth};
use ide_ssh::{SshFiles, SshManager};
use std::sync::Arc;
use tokio::net::TcpListener;
struct Env {
_tmp: tempfile::TempDir,
db: DbManager,
ssh_port: u16,
}
async fn env() -> Env {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().to_path_buf();
let ssh_port = ide_ssh::testing::sshd(root.join("server"), Arc::new(|cmd| panic!("unexpected exec: {cmd}"))).await;
let ssh = Arc::new(SshManager::with_files(
root.join("ssh-secrets.json"),
SshFiles { known_hosts: root.join("known_hosts"), config: None },
));
let db = DbManager::new(root.clone(), root.join("passwords.json"), root.join("history.json"), ssh);
Env { _tmp: tmp, db, ssh_port }
}
impl Env {
/// A PostgreSQL source on `127.0.0.1:<db_port>` through the test server as `dev`.
fn source(&self, db_port: u16) -> DataSource {
DataSource {
id: "pg".into(),
name: "pg".into(),
driver: DbDriver::Postgres,
host: "127.0.0.1".into(),
port: Some(db_port),
user: "app".into(),
ssh: Some(DbSsh {
enabled: true,
host: "127.0.0.1".into(),
port: Some(self.ssh_port),
user: "dev".into(),
auth: DbSshAuth::Password,
..Default::default()
}),
..Default::default()
}
}
}
/// A port something listens on (and never speaks a database protocol).
async fn listening() -> (TcpListener, u16) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
(listener, port)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn the_tunnel_is_tested_without_a_database_login() {
let env = env().await;
let (_db, db_port) = listening().await;
let source = env.source(db_port);
// First contact: the host key has to be trusted, as with Test Connection.
let unknown = env.db.test_tunnel(&source, Some("secret".into())).await.unwrap_err();
assert!(unknown.message.starts_with("Unknown SSH host key"), "{unknown:?}");
env.db.trust_ssh_host("pg").await.unwrap();
let ok = env.db.test_tunnel(&source, Some("secret".into())).await.unwrap();
assert_eq!(ok, format!("[email protected]:{} reaches 127.0.0.1:{db_port}", env.ssh_port));
let wrong = env.db.test_tunnel(&source, Some("nope".into())).await.unwrap_err();
assert!(wrong.message.contains("SSH authentication failed"), "{}", wrong.message);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_database_port_the_ssh_host_cannot_reach_is_named() {
let env = env().await;
let (listener, closed) = listening().await;
drop(listener);
let source = env.source(closed);
let _ = env.db.test_tunnel(&source, Some("secret".into())).await;
env.db.trust_ssh_host("pg").await.unwrap();
let err = env.db.test_tunnel(&source, Some("secret".into())).await.unwrap_err();
assert!(err.message.contains(&format!("cannot reach 127.0.0.1:{closed}")), "{}", err.message);
}
#[tokio::test]
async fn a_source_without_a_tunnel_has_nothing_to_test() {
let env = env().await;
let mut source = env.source(5432);
source.ssh.as_mut().unwrap().enabled = false;
let err = env.db.test_tunnel(&source, None).await.unwrap_err();
assert_eq!(err.kind, ErrorKind::InvalidInput, "{err:?}");
}
-15
Переглянути файл
@@ -42,10 +42,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
);
test(&c.host, &c.window, source, password, ssh_secret).await
});
d.add("db_test_tunnel", |c| async move {
let (source, ssh_secret) = (c.arg::<DataSource>("source")?, c.arg::<Option<String>>("sshSecret")?);
test_tunnel(&c.host, &c.window, source, ssh_secret).await
});
d.add("db_set_ssh_secret", |c| async move {
let (id, secret, save) = (c.arg::<String>("id")?, c.arg::<Option<String>>("secret")?, c.arg::<bool>("save")?);
set_ssh_secret(&c.host, &c.window, &id, secret, save).await
@@ -216,17 +212,6 @@ pub async fn test(
db.test(&source, password, ssh_secret).await
}
/// Checks the SSH tunnel of `source` alone: SSH login and the SSH host reaching the database.
pub async fn test_tunnel(
host: &Host,
window: &str,
source: DataSource,
ssh_secret: Option<String>,
) -> IdeResult<String> {
let db = host.project(window)?.db.clone();
db.test_tunnel(&source, ssh_secret).await
}
/// Remembers the SSH password / key passphrase of a data source (see `set_password`).
pub async fn set_ssh_secret(host: &Host, window: &str, id: &str, secret: Option<String>, save: bool) -> IdeResult<()> {
let db = host.project(window)?.db.clone();
-25
Переглянути файл
@@ -372,25 +372,6 @@ pub async fn debug_restart_frame(host: &Host, _window: &str, session: DebugSessi
host.debugger.restart_frame(session, frame_id).await
}
pub async fn debug_js_custom_breakpoints(
host: &Host,
_window: &str,
session: DebugSessionId,
ids: Vec<String>,
xhr: Vec<String>,
) -> IdeResult<()> {
host.debugger.js_set_custom_breakpoints(session, ids, xhr).await
}
pub async fn debug_js_response_body(
host: &Host,
_window: &str,
session: DebugSessionId,
request_id: String,
) -> IdeResult<ide_dap::JsResponseBody> {
host.debugger.js_response_body(session, request_id).await
}
pub fn debug_capabilities(host: &Host, _window: &str, session: DebugSessionId) -> IdeResult<DebugCapabilities> {
host.debugger.capabilities(session)
}
@@ -607,12 +588,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
debug_restart_frame(&c.host, &c.window, c.arg("session")?, c.arg("frameId")?).await
});
d.add("debug_capabilities", |c| async move { debug_capabilities(&c.host, &c.window, c.arg("session")?) });
d.add("debug_js_response_body", |c| async move {
debug_js_response_body(&c.host, &c.window, c.arg("session")?, c.arg("requestId")?).await
});
d.add("debug_js_custom_breakpoints", |c| async move {
debug_js_custom_breakpoints(&c.host, &c.window, c.arg("session")?, c.arg("ids")?, c.arg("xhr")?).await
});
d.add("debug_set_function_breakpoints", |c| async move {
debug_set_function_breakpoints(&c.host, &c.window, c.arg("breakpoints")?).await
});
+2 -8
Переглянути файл
@@ -74,15 +74,9 @@ pub async fn lsp_completion(
host.end_request(request);
// Plugin providers (`[[contributes.providers]]`) add theirs.
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::Completion) else { return result };
let (covered, items) = plugin_call(move || plugins.provide_completion_for_editor(&d, pos, trigger.as_deref()))
.await
.unwrap_or_default();
let items = plugin_call(move || plugins.provide_completion(&d, pos, trigger.as_deref())).await.unwrap_or_default();
if items.is_empty() {
return if covered {
Ok(result.unwrap_or(CompletionList { is_incomplete: true, items: Vec::new() }))
} else {
result
};
return Ok(result.unwrap_or(CompletionList { is_incomplete: true, items: Vec::new() }));
}
let mut list = result.unwrap_or(CompletionList { is_incomplete: false, items: Vec::new() });
list.items.extend(items.into_iter().map(|(_, item)| plugin_completion(item)));
-2
Переглянути файл
@@ -31,7 +31,6 @@ pub mod scaffold;
pub mod sdk;
pub mod search;
pub mod spell;
mod ssh;
pub mod syntax;
pub mod term;
pub mod tools;
@@ -96,7 +95,6 @@ pub fn register_all(d: &mut crate::rpc::Dispatcher) {
deploy::register(d);
docker::register(d);
db::register(d);
ssh::register(d);
}
impl Host {
-29
Переглянути файл
@@ -1,29 +0,0 @@
//! SSH configuration commands shared by the desktop shell's IPC commands and the remote
//! server ([`crate::rpc`]). A remote window connects from the server (deployment, database
//! tunnels, run targets, SSH sessions), so the host keys it trusts and the passwords it is
//! given belong to the server's [`ide_ssh::SshManager`], not the desktop's.
use crate::rpc::Dispatcher;
use ide_core::remote_settings::SshConfiguration;
pub(crate) fn register(d: &mut Dispatcher) {
d.add("ssh_test", |c| async move {
let (config, secret) = (c.arg::<SshConfiguration>("config")?, c.arg::<Option<String>>("secret")?);
c.host.ssh.test(&config, secret).await
});
d.add("ssh_set_secret", |c| async move {
let (id, secret, save) = (c.arg::<String>("id")?, c.arg::<Option<String>>("secret")?, c.arg::<bool>("save")?);
c.host.ssh.set_secret(&id, secret, save);
Ok(())
});
d.add("ssh_password_saved", |c| async move { Ok(c.host.ssh.password_saved(&c.arg::<String>("key")?)) });
d.add("ssh_needs_secret", |c| async move {
let ssh = &c.host.ssh;
Ok(ssh.needs_secret(&ssh.config(&c.arg::<String>("id")?)?))
});
d.add("ssh_trust_host", |c| async move { c.host.ssh.trust_host(&c.arg::<String>("id")?).await });
d.add("ssh_connected", |c| async move { Ok(c.host.ssh.connected()) });
d.add("ssh_disconnect", |c| async move {
c.host.ssh.disconnect(&c.arg::<String>("id")?);
Ok(())
});
}
-21
Переглянути файл
@@ -22,27 +22,6 @@ pub async fn status(host: &Host, window: &str) -> IdeResult<Vec<FileStatus>> {
.await
}
/// Changes of `paths` only: the dirty-scope refresh after a watcher batch. `paths` that belong
/// to no repository are ignored, and an empty list is the full status.
pub async fn status_paths(host: &Host, window: &str, paths: Vec<std::path::PathBuf>) -> IdeResult<Vec<FileStatus>> {
let project = host.project(window)?;
host.blocking(move || {
let settings = project.settings.effective();
if project.is_trusted() {
apply_git_executable(&settings);
}
let repos = project.repos();
if repos.is_empty() {
return Err(ide_core::IdeError::unavailable("project is not a git repository"));
}
for r in repos.all() {
r.set_ignored_names(settings.ignored_names());
}
repos.status_paths(&paths)
})
.await
}
mod commands;
pub use commands::*;
+2 -28
Переглянути файл
@@ -585,21 +585,6 @@ pub async fn vcs_stage(host: &Host, window: &str, paths: Vec<PathBuf>) -> IdeRes
blocking(move || per_backend(&project, &paths, None, |r, paths| r.stage(paths))).await
}
/// Adds `commit` to annotate's ignore list in `path`'s repository (the file `blame.ignoreRevsFile`
/// names, else `.git-blame-ignore-revs`, which the local config then names).
pub async fn vcs_ignore_blame_revision(host: &Host, window: &str, path: PathBuf, commit: String) -> IdeResult<()> {
host.require_trust(window)?;
let project = host.project(window)?;
blocking(move || {
let result = project.with_disk_sync(|| project.with_repo_of(&path, |r| r.ignore_blame_revision(&commit)));
if result.is_ok() {
project.sink.emit(ide_core::IdeEvent::VcsChanged { root: project.root.clone().into() });
}
result
})
.await
}
/// Project-host operation (also registered with the remote dispatcher).
/// Returns the ignore files that were updated, so the UI can show them.
pub async fn vcs_add_to_ignore(
@@ -807,19 +792,12 @@ pub async fn vcs_stash_push(
message: Option<String>,
include_untracked: bool,
keep_index: bool,
paths: Option<Vec<PathBuf>>,
) -> IdeResult<()> {
host.require_trust(window)?;
let project = host.project(window)?;
let paths = paths.unwrap_or_default();
blocking(move || {
if paths.is_empty() {
return project.with_repo_recorded(repo.as_deref(), Some("Git stash"), |r| {
r.stash_push(message.as_deref(), include_untracked, keep_index, &[])
});
}
per_repo(&project, &paths, Some("Git stash"), |r, paths| {
r.stash_push(message.as_deref(), include_untracked, keep_index, paths)
project.with_repo_recorded(repo.as_deref(), Some("Git stash"), |r| {
r.stash_push(message.as_deref(), include_untracked, keep_index)
})
})
.await
@@ -1484,9 +1462,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("vcs_add_to_ignore", |c| async move {
vcs_add_to_ignore(&c.host, &c.window, c.arg("paths")?, c.arg("destination")?).await
});
d.add("vcs_ignore_blame_revision", |c| async move {
vcs_ignore_blame_revision(&c.host, &c.window, c.arg("path")?, c.arg("commit")?).await
});
d.add("vcs_unstage", |c| async move { vcs_unstage(&c.host, &c.window, c.arg("paths")?).await });
d.add("vcs_stage_patch", |c| async move { vcs_stage_patch(&c.host, &c.window, c.arg("patch")?).await });
d.add("vcs_discard", |c| async move { vcs_discard(&c.host, &c.window, c.arg("paths")?).await });
@@ -1545,7 +1520,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
c.arg("message")?,
c.arg("includeUntracked")?,
c.arg("keepIndex")?,
c.arg("paths")?,
)
.await
});
-96
Переглянути файл
@@ -2,106 +2,10 @@
use crate::Host;
use ide_core::IdeResult;
use ide_core::proxy_settings::{ProxyMode, ProxySettings};
use std::path::Path;
use std::time::Duration;
/// Whether `url` names this machine (`localhost` or a loopback address).
fn is_loopback(url: &reqwest::Url) -> bool {
let host = url.host_str().unwrap_or_default().trim_start_matches('[').trim_end_matches(']');
host.eq_ignore_ascii_case("localhost") || host.parse::<std::net::IpAddr>().is_ok_and(|ip| ip.is_loopback())
}
fn is_html(content_type: &str) -> bool {
let kind = content_type.split(';').next().unwrap_or_default().trim();
kind.eq_ignore_ascii_case("text/html") || kind.eq_ignore_ascii_case("application/xhtml+xml")
}
/// A client that reaches this machine only: no proxy, and a redirect is followed while it
/// stays here (a dev server sending `/` to `/app/`).
fn loopback_client() -> IdeResult<reqwest::Client> {
let direct = ProxySettings { mode: ProxyMode::None, ..ProxySettings::default() };
let stay = reqwest::redirect::Policy::custom(|attempt| {
if is_loopback(attempt.url()) && attempt.previous().len() < 5 { attempt.follow() } else { attempt.stop() }
});
ide_http::client::build_with_redirect(&direct, stay)
}
/// Whether `url`, a server on this machine, answers a browser with an HTML document: a page
/// to open, as a dev server has, and not an API endpoint. Other hosts are never asked.
pub async fn serves_page(url: &str) -> bool {
let Ok(url) = reqwest::Url::parse(url) else { return false };
if !matches!(url.scheme(), "http" | "https") || !is_loopback(&url) {
return false;
}
let Ok(client) = loopback_client() else { return false };
// A dev server may compile the page on this first request.
let request = client.get(url).header(reqwest::header::ACCEPT, "text/html").timeout(Duration::from_secs(10));
let Ok(response) = request.send().await else { return false };
let html = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.is_some_and(is_html);
response.status().is_success() && html
}
/// The URL serving `file`, under the window's project when it holds the file.
pub async fn url(host: &Host, window: &str, file: &Path) -> IdeResult<String> {
let root = host.project(window).ok().map(|p| p.root.clone());
host.web.url_for(root.as_deref(), file).await
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
/// A server on a free port that answers every request with `status` and `header`. It
/// listens on every interface, so that a test can reach it under a name that is not loopback.
async fn answering(status: &'static str, header: String) -> String {
let listener = tokio::net::TcpListener::bind("0.0.0.0:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
tokio::spawn(async move {
while let Ok((mut socket, _)) = listener.accept().await {
let mut request = [0u8; 2048];
let _ = socket.read(&mut request).await;
let response =
format!("HTTP/1.1 {status}\r\n{header}\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok");
let _ = socket.write_all(response.as_bytes()).await;
}
});
format!("http://127.0.0.1:{port}/")
}
async fn server(status: &'static str, content_type: &'static str) -> String {
answering(status, format!("Content-Type: {content_type}")).await
}
async fn redirecting_to(location: &str) -> String {
answering("302 Found", format!("Location: {location}")).await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_redirect_is_followed_on_this_machine_only() {
let page = server("200 OK", "text/html").await;
assert!(serves_page(&redirecting_to(&page).await).await);
// The same page under an address that is not loopback: it is not asked.
let elsewhere = page.replace("127.0.0.1", "0.0.0.0");
assert!(!serves_page(&redirecting_to(&elsewhere).await).await);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_page_is_an_html_answer_from_this_machine() {
assert!(serves_page(&server("200 OK", "text/html; charset=utf-8").await).await);
assert!(serves_page(&server("200 OK", "Text/HTML").await.replace("127.0.0.1", "localhost")).await);
// An API, an error page and a port nothing listens on are not pages.
assert!(!serves_page(&server("200 OK", "application/json").await).await);
assert!(!serves_page(&server("404 Not Found", "text/html").await).await);
assert!(!serves_page("http://127.0.0.1:1/").await);
// Other machines are not asked at all.
assert!(!serves_page("http://example.com/").await);
assert!(!serves_page("ftp://localhost/").await);
assert!(!serves_page("not a url").await);
}
}
+2 -25
Переглянути файл
@@ -396,37 +396,15 @@ impl PluginHost {
}
}
pub(super) fn has_document_providers(&self, kind: ProviderKind, doc: &ProviderDocument) -> bool {
self.provider_plugins(kind, &doc.language)
.into_iter()
.any(|p| p.dev_root.as_ref().is_none_or(|r| doc.path.starts_with(r)))
}
pub fn provide_completion(
&self,
doc: &ProviderDocument,
at: ide_core::Pos,
trigger: Option<&str>,
) -> Vec<(String, CompletionItem)> {
self.provide_completion_for_editor(doc, at, trigger).1
}
pub(crate) fn provide_completion_for_editor(
&self,
doc: &ProviderDocument,
at: ide_core::Pos,
trigger: Option<&str>,
) -> (bool, Vec<(String, CompletionItem)>) {
// An injection-capable outer document (e.g. SQL embedded in text) may complete at other
// positions even when `at` is outside an injection, so avoid marking document-wide backoff.
let mut covered = self.has_document_providers(ProviderKind::Injections, doc);
let items = self
.feature_documents(doc, Some(at))
self.feature_documents(doc, Some(at))
.into_iter()
.flat_map(|(doc, bounds)| {
if self.has_document_providers(ProviderKind::Completion, &doc) {
covered = true;
}
let (d, t) = (Arc::new(doc.clone()), trigger.map(str::to_owned));
self.document_calls(ProviderKind::Completion, &doc, move |rt, timeout| {
rt.provide_completion(&d, at, t.as_deref(), timeout)
@@ -441,8 +419,7 @@ impl PluginHost {
Some((p, i))
})
})
.collect();
(covered, items)
.collect()
}
pub fn provide_code_actions(
+9 -20
Переглянути файл
@@ -447,11 +447,6 @@ impl Host {
.collect();
matches!(rest.as_slice(), [first, ..] if first == "worktrees" && rest.len() <= 2)
});
// A repository folder moved or restored into the project: no `.git` event is
// written inside it before the watcher sees the new directory, its creation is
// the signal. A `git clone` / `git init` inside the project already arrives
// through the `.git` paths above; this covers the whole tree being brought in.
git_dirs |= batch.created.iter().any(|p| p.join(".git").exists());
if git_dirs {
scan_repositories(&root, &repos, &settings, &sink, &build_cache);
}
@@ -869,20 +864,6 @@ fn scan_repositories(
sink: &SharedSink,
build_cache: &Path,
) {
match discover_repositories(root, settings, build_cache) {
Ok(found) => {
if found.roots() != repos.read().roots() {
*repos.write() = found;
sink.emit(IdeEvent::VcsChanged { root: root.to_path_buf().into() });
}
}
Err(e) => tracing::warn!("looking for git repositories: {e}"),
}
}
/// The repository containing `root` and those nested below it or below a content root.
/// The project-open scan and the watcher's rescan share these skip rules.
fn discover_repositories(root: &Path, settings: &SettingsStore, build_cache: &Path) -> IdeResult<Repos> {
let ignored = settings.effective().ignored_names();
let skip = |p: &Path| {
p.file_name().is_some_and(|n| {
@@ -894,7 +875,15 @@ fn discover_repositories(root: &Path, settings: &SettingsStore, build_cache: &Pa
};
let mut roots = vec![root.to_path_buf()];
roots.extend(content_roots(&settings.effective(), root, build_cache));
Repos::discover_roots(&roots, &skip)
match Repos::discover_roots(&roots, &skip) {
Ok(found) => {
if found.roots() != repos.read().roots() {
*repos.write() = found;
sink.emit(IdeEvent::VcsChanged { root: root.to_path_buf().into() });
}
}
Err(e) => tracing::warn!("looking for git repositories: {e}"),
}
}
impl Project {
+1 -2
Переглянути файл
@@ -270,8 +270,7 @@ impl Dispatcher {
.add("run_resize", |c| async move { ops::run::resize(&c.host, c.arg("id")?, c.arg("cols")?, c.arg("rows")?) })
.add("run_close_stdin", |c| async move { ops::run::close_stdin(&c.host, c.arg("id")?) })
.add("run_list", |c| async move { Ok(ops::run::list(&c.host)) })
.add("vcs_status", |c| async move { ops::vcs::status(&c.host, &c.window).await })
.add("vcs_status_paths", |c| async move { ops::vcs::status_paths(&c.host, &c.window, c.arg("paths")?).await });
.add("vcs_status", |c| async move { ops::vcs::status(&c.host, &c.window).await });
ops::register_all(&mut d);
d
}
-1
Переглянути файл
@@ -475,7 +475,6 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
| "vcs_clone"
| "vcs_init"
| "vcs_status"
| "vcs_status_paths"
| "vcs_roots"
| "vcs_branch_info"
| "vcs_line_hunks"
-15
Переглянути файл
@@ -99,21 +99,6 @@ async fn editor_ops_merge_the_fixture_format_selection_and_execute_owned_command
let path = root.join("a.txt");
std::fs::write(&path, "value\nnext\nend").unwrap();
let doc = ops::doc::open(&host, "main", path.clone()).await.unwrap();
// `a.toml` has neither a language server, a completion plugin, nor an injection plugin, so `Unavailable` is preserved.
let toml_path = root.join("a.toml");
std::fs::write(&toml_path, "k = 1\n").unwrap();
let toml_doc = ops::doc::open(&host, "main", toml_path).await.unwrap();
assert_eq!(
ops::lsp::lsp_completion(&host, "main", RequestId(89), toml_doc.id, Pos::default(), None)
.await
.unwrap_err()
.kind,
ide_core::error::ErrorKind::Unavailable
);
let sql_path = root.join("q.sql");
std::fs::write(&sql_path, "SELECT 1;").unwrap();
let sql_doc = ops::doc::open(&host, "main", sql_path).await.unwrap();
assert!(ops::lsp::lsp_completion(&host, "main", RequestId(90), sql_doc.id, Pos::default(), None).await.is_ok());
assert_eq!(
ops::lsp::lsp_signature_help(&host, "main", doc.id, Pos::default()).await.unwrap().unwrap().signatures[0]
.parameters,
+3 -5
Переглянути файл
@@ -821,10 +821,8 @@ fn a_reporting_task_outlives_its_background_budget() {
#[test]
fn background_tasks_scale_and_renew_cpu_fuel_budget() {
// Room for activation (about 3_100 on macOS, more on Windows) but not for `patient`'s
// whole run (about 9_000) without the refill on each report.
let f = fixture(Limits {
fuel: 6_000,
fuel: 3_500,
timeout: Duration::from_millis(500),
background: Duration::from_millis(5_000),
..Limits::default()
@@ -833,14 +831,14 @@ fn background_tasks_scale_and_renew_cpu_fuel_budget() {
if f.host.set_capability("misbehave", "process:git", true).is_err() {
return;
}
// Warm up activation in its own call so `patient` starts with a fresh budget.
// Warm up activation in its own call so `patient` starts with a fresh 3_500-fuel budget.
assert_eq!(f.run("misbehave", "echo", Some("warm")).unwrap().as_deref(), Some("warm"));
let out = f.run("misbehave", "patient", None).expect("background task succeeds").expect("output");
assert_eq!(out, "still here");
let err = f.run("misbehave", "spin", None).unwrap_err();
assert!(err.contains("exceeded its CPU budget (6000 fuel per call)"), "{err}");
assert!(err.contains("exceeded its CPU budget (3500 fuel per call)"), "{err}");
}
#[test]
-14
Переглянути файл
@@ -341,7 +341,6 @@ async fn local_deployment_runs_on_the_project_host() {
"db_save_sources",
"db_set_password",
"db_test",
"db_test_tunnel",
"db_set_ssh_secret",
"db_trust_ssh_host",
"db_connected",
@@ -365,13 +364,6 @@ async fn local_deployment_runs_on_the_project_host() {
"db_table_page",
"db_apply_edits",
"db_console_path",
"ssh_test",
"ssh_set_secret",
"ssh_password_saved",
"ssh_needs_secret",
"ssh_trust_host",
"ssh_connected",
"ssh_disconnect",
] {
assert!(dispatcher.commands().contains(&cmd), "missing host command {cmd}");
}
@@ -380,12 +372,6 @@ async fn local_deployment_runs_on_the_project_host() {
tokio::spawn(rpc::serve(host, dispatcher, slot, sr, sw));
let (cr, cw) = tokio::io::split(client_io);
let peer = Peer::connect(cr, cw, Arc::new(Client::default()));
// A remote window's host-key and password remedies reach the server's SSH manager,
// the one its deployment / database connections failed in.
let trust = invoke(&peer, "ssh_trust_host", json!({"id": "nothing-pending"})).await.unwrap_err();
assert!(trust.message.contains("no host key to trust"), "{trust:?}");
invoke(&peer, "ssh_set_secret", json!({"id": "deploy", "secret": "pw", "save": false})).await.unwrap();
assert_eq!(invoke(&peer, "ssh_connected", json!({})).await.unwrap(), json!([]));
invoke(&peer, "project_trust_set", json!({"path": project, "trusted": true, "folder": false})).await.unwrap();
invoke(&peer, "open_project", json!({"path": project})).await.unwrap();
let deployment = json!({"servers": [{"id":"local", "kind":"local", "rootPath":target}], "defaultServer":"local"});
-1
Переглянути файл
@@ -172,7 +172,6 @@ async fn restricted_vcs_commands_run_no_program_of_the_repository() {
}
let calls: Vec<Call> = vec![
call!("vcs_status", "", ops::vcs::status(host, w)),
call!("vcs_status_paths", "", ops::vcs::status_paths(host, w, vec![file.clone()])),
sync!("vcs_roots", "", vcs::vcs_roots(host, w)),
call!("vcs_branch_info", "", vcs::vcs_branch_info(host, w, root())),
call!("vcs_line_hunks", "", vcs::vcs_line_hunks(host, w, file.clone())),
+1 -1
Переглянути файл
@@ -102,7 +102,7 @@ async fn stash_and_unstash_reload_clean_open_documents_before_returning() {
let f = Fixture::new().await;
let edited = format!("{ORIGINAL}\n");
f.saved_edit(&edited);
vcs::vcs_stash_push(&f.host, "main", None, None, false, false, None).await.unwrap();
vcs::vcs_stash_push(&f.host, "main", None, None, false, false).await.unwrap();
f.assert_clean(ORIGINAL);
vcs::vcs_stash_apply(&f.host, "main", None, 0, true, false, None).await.unwrap();
f.assert_clean(&edited);
+1 -1
Переглянути файл
@@ -168,7 +168,7 @@ async fn change_commit_branch_stash_and_merge_a_conflict() {
let wip = format!("{}// work in progress\n", f.text(main_rs).await);
f.host.project(WINDOW).unwrap().documents.replace_all(main_rs, &wip).unwrap();
f.save(main_rs).await;
ops::vcs::vcs_stash_push(&f.host, WINDOW, None, Some("wip".into()), false, false, None).await.unwrap();
ops::vcs::vcs_stash_push(&f.host, WINDOW, None, Some("wip".into()), false, false).await.unwrap();
assert!(f.status().await.is_empty());
assert!(!f.text(main_rs).await.contains("work in progress"));
let stashes = ops::vcs::vcs_stashes(&f.host, WINDOW, None).await.unwrap();
-54
Переглянути файл
@@ -1,54 +0,0 @@
//! A repository folder moved into an open project is registered while the project is open:
//! the folder brings no `.git` event of its own, so the watcher's "created path" is what the
//! rescan has to answer to (the open scan already ran).
use ide_core::NullSink;
use ide_host::{Host, HostConfig};
use std::{path::Path, sync::Arc, time::Duration};
fn git_init(dir: &Path) {
std::fs::create_dir_all(dir).unwrap();
let status = std::process::Command::new("git").args(["init", "-q"]).current_dir(dir).status();
assert!(status.is_ok_and(|status| status.success()), "git init in {}", dir.display());
}
async fn wait_until(what: &str, mut done: impl FnMut() -> bool) {
let deadline = tokio::time::Instant::now() + Duration::from_secs(15);
while !done() {
assert!(tokio::time::Instant::now() < deadline, "timed out waiting for {what}");
tokio::time::sleep(Duration::from_millis(20)).await;
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn moving_a_repository_into_the_project_registers_it() {
let temp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(temp.path()).unwrap();
let root = base.join("project");
git_init(&root);
// Prepared outside the project, as a restored or moved folder would be.
let prepared = base.join("prepared");
git_init(&prepared);
let host = Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
host.trust.decide(&root, true, false).unwrap();
let project = host.open_project("main", &root).unwrap();
// The scan on project open runs in the background: wait for it before moving anything in.
wait_until("the project's own repository", || project.repos().roots().len() == 1).await;
std::fs::rename(&prepared, root.join("moved")).unwrap();
wait_until("the moved repository to be registered", || {
project.repos().roots().iter().any(|r| r.ends_with("moved"))
})
.await;
}
+1 -1
Переглянути файл
@@ -9,7 +9,7 @@ license.workspace = true
ide-core.workspace = true
ide-vfs.workspace = true
serde.workspace = true
serde_json = { workspace = true, features = ["raw_value"] }
serde_json.workspace = true
specta.workspace = true
tokio.workspace = true
tracing.workspace = true
+10 -98
Переглянути файл
@@ -52,35 +52,26 @@ fn search_dirs(file: Option<&Path>, root: Option<&Path>) -> Vec<PathBuf> {
}
fn parse_env(text: &str) -> IdeResult<EnvMap> {
use serde_json::value::RawValue;
let invalid = |e| IdeError::invalid(format!("invalid environment file: {e}"));
// Editors on Windows (Notepad) save UTF-8 with a byte order mark, which serde_json rejects.
let text = text.strip_prefix('\u{feff}').unwrap_or(text);
let value: Box<RawValue> = serde_json::from_str(text).map_err(invalid)?;
let value: serde_json::Value =
serde_json::from_str(text).map_err(|e| IdeError::invalid(format!("invalid environment file: {e}")))?;
let mut out = EnvMap::new();
if !value.get().starts_with('{') {
return Ok(out);
}
let envs: BTreeMap<String, Box<RawValue>> = serde_json::from_str(value.get()).map_err(invalid)?;
let Some(envs) = value.as_object() else { return Ok(out) };
for (env, vars) in envs {
if !vars.get().starts_with('{') {
continue;
}
let vars: BTreeMap<String, Box<RawValue>> = serde_json::from_str(vars.get()).map_err(invalid)?;
let Some(vars) = vars.as_object() else { continue };
let mut map = BTreeMap::new();
for (k, v) in vars {
let raw = v.get();
let s = match raw.as_bytes().first() {
Some(b'"') => serde_json::from_str::<String>(raw).map_err(invalid)?,
// Keep numeric tokens: Value::Number would round decimals and large integers.
Some(b'-' | b'0'..=b'9' | b't' | b'f') => raw.to_owned(),
let s = match v {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Number(n) => n.to_string(),
serde_json::Value::Bool(b) => b.to_string(),
// Objects configure SSL / auth in IDEA; not variables.
_ => continue,
};
map.insert(k, s);
map.insert(k.clone(), s);
}
out.insert(env, map);
out.insert(env.clone(), map);
}
Ok(out)
}
@@ -313,85 +304,6 @@ impl Resolver<'_> {
mod tests {
use super::*;
#[test]
fn preserves_exact_numeric_environment_tokens() {
for token in [
"0",
"-0",
"0.0",
"-0.000",
"1",
"-1",
"9007199254740991",
"9007199254740992",
"9007199254740993",
"9223372036854775807",
"-9223372036854775808",
"18446744073709551615",
"18446744073709551616",
"123456789012345678901234567890",
"-123456789012345678901234567890",
"1.0000000000000000002",
"-1.0000000000000000002",
"0.12345678901234567890123456789",
"-0.0000000000000000000000000001",
"1e-400",
"-1e-400",
"1e400",
"-1e400",
"1.2300e+20",
"1.2300E+020",
] {
let json = format!(r#"{{"dev":{{"value":{token}}}}}"#);
let envs = Environments::from_json(&json, "").unwrap();
let vars = envs.variables(Some("dev"));
assert_eq!(vars["value"], token);
let resolver = Resolver { file_vars: &[], env: vars };
let mut missing = BTreeSet::new();
assert_eq!(resolver.substitute("{\"value\":{{value}}}", &mut missing), format!(r#"{{"value":{token}}}"#));
assert!(missing.is_empty());
}
}
#[test]
fn rejects_malformed_numeric_environment_tokens() {
for token in ["01", "+1", "1.", ".1", "NaN", "Infinity", "--1", "1e", "1e+", "1e--2", "0xFF"] {
let json = format!(r#"{{"dev":{{"value":{token}}}}}"#);
assert!(Environments::from_json(&json, "").is_err(), "accepted {token}");
}
assert!(Environments::from_json(r#"{"dev":{"ssl":{"invalid":01}}}"#, "").is_err());
}
#[test]
fn reads_numeric_tokens_with_whitespace_and_escaped_names() {
let envs = Environments::from_json(
"\u{feff} \n { \"de\\u0076\": \n {\"va\\u006cue\": \t 1.2300E+020, \"value\": 1.0000000000000000002 }} \n",
"",
)
.unwrap();
assert_eq!(envs.names(), vec!["dev"]);
assert_eq!(envs.variables(Some("dev"))["value"], "1.0000000000000000002");
}
#[test]
fn exact_environment_parsing_keeps_types_and_precedence() {
let envs = Environments::from_json(
r#"{"$shared":{"shared":1.0000000000000000002},"dev":{"v":1,"v":9007199254740993,"enabled":true,"disabled":false,"s":"a\n\"b","null":null,"ssl":{},"arr":[]},"other":42}"#,
r#"{"dev":{"v":9223372036854775807}}"#,
).unwrap();
let vars = envs.variables(Some("dev"));
assert_eq!(vars["v"], "9223372036854775807");
assert_eq!(vars["shared"], "1.0000000000000000002");
assert_eq!(vars["enabled"], "true");
assert_eq!(vars["disabled"], "false");
assert_eq!(vars["s"], "a\n\"b");
assert!(!vars.contains_key("null"));
assert!(!vars.contains_key("ssl"));
assert!(!vars.contains_key("arr"));
assert_eq!(Environments::from_json("[]", "").unwrap().names(), Vec::<String>::new());
assert!(Environments::from_json(r#"{"dev":{"v":01}}"#, "").is_err());
}
#[test]
fn finds_variable_keys_in_every_environment_private_first() {
let tmp = tempfile::tempdir().unwrap();
-62
Переглянути файл
@@ -249,68 +249,6 @@ mod tests {
assert_eq!(url("GET https://api.test/cb?u=http://x\n"), "https://api.test/cb?u=http://x");
}
#[tokio::test]
async fn sends_exact_environment_numbers_to_a_local_server() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut request = Vec::new();
let (headers, body) = loop {
let mut buffer = [0u8; 4096];
let read = socket.read(&mut buffer).await.unwrap();
assert!(read > 0, "request ended before its body");
request.extend_from_slice(&buffer[..read]);
let Some(end) = request.windows(4).position(|w| w == b"\r\n\r\n") else { continue };
let headers = String::from_utf8_lossy(&request[..end]);
let length: usize = headers
.lines()
.find_map(|line| {
let (name, value) = line.split_once(':')?;
name.eq_ignore_ascii_case("content-length").then(|| value.trim().parse().unwrap())
})
.unwrap();
if request.len() >= end + 4 + length {
break (
String::from_utf8(request[..end].to_vec()).unwrap(),
String::from_utf8(request[end + 4..end + 4 + length].to_vec()).unwrap(),
);
}
};
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{body}",
body.len()
);
socket.write_all(response.as_bytes()).await.unwrap();
(headers, body)
});
let tmp = tempfile::tempdir().unwrap();
std::fs::write(
tmp.path().join(crate::env::PUBLIC_FILE),
r#"{"dev":{"id":123456789012345678901234567890,"amount":1.0000000000000000002,"tiny":1e-400}}"#,
)
.unwrap();
let file = tmp.path().join("precision.http");
let text = format!(
"POST http://{addr}/echo?id={{{{id}}}}\nContent-Type: application/json\nX-Exact-Amount: {{{{amount}}}}\n\n{{\"id\":{{{{id}}}},\"amount\":{{{{amount}}}},\"tiny\":{{{{tiny}}}}}}\n"
);
let input =
crate::RunInput { text: &text, index: 0, file: Some(&file), root: Some(tmp.path()), env: Some("dev") };
let exchange =
tokio::time::timeout(std::time::Duration::from_secs(5), crate::run(input, &CancellationToken::new()))
.await
.unwrap()
.unwrap();
let expected = r#"{"id":123456789012345678901234567890,"amount":1.0000000000000000002,"tiny":1e-400}"#;
let (headers, body) = server.await.unwrap();
assert_eq!(headers.lines().next().unwrap(), "POST /echo?id=123456789012345678901234567890 HTTP/1.1");
assert!(headers.lines().any(|line| line.eq_ignore_ascii_case("x-exact-amount: 1.0000000000000000002")));
assert_eq!(body, expected);
assert_eq!(exchange.request.body.as_deref(), Some(expected));
assert_eq!(exchange.response.unwrap().body.as_deref(), Some(expected));
}
#[tokio::test]
async fn executes_against_a_local_server() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
-4
Переглянути файл
@@ -31,7 +31,3 @@ criterion = { version = "0.5", default-features = false }
[[bench]]
name = "index"
harness = false
[[bench]]
name = "startup"
harness = false
-64
Переглянути файл
@@ -1,64 +0,0 @@
//! `cargo bench -p ide-index --bench startup`: open a persisted 20k-file
//! project and wait for the index to become idle. This is the full warm-start
//! path (columnar snapshot, directory/file manifest, lazy redb).
use criterion::{Criterion, black_box, criterion_group, criterion_main};
use ide_core::NullSink;
use ide_index::ProjectIndex;
use ide_language::{LanguageRegistry, Syntax};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
const FILES: usize = 20_000;
fn project(root: &Path) {
for i in 0..FILES {
let dir = root.join(format!("mod{}/src", i % 50));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(format!("File{i}.rs")), format!("fn symbol{i}() {{}}\n")).unwrap();
}
}
fn bench(c: &mut Criterion) {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("project");
let cache = tmp.path().join("cache");
project(&root);
let languages = Arc::new(LanguageRegistry::load(None).unwrap());
let syntax = Arc::new(Syntax::new());
let open = || {
ProjectIndex::open(
&root,
&cache,
Vec::<PathBuf>::new(),
vec![],
languages.clone(),
syntax.clone(),
Arc::new(NullSink),
)
.unwrap()
};
let first = open();
assert!(first.wait_idle(Duration::from_secs(30)));
drop(first);
let mut group = c.benchmark_group("startup_20k");
group.sample_size(10);
group.bench_function("reopen_wait_idle", |b| {
b.iter(|| {
let index = open();
assert!(index.wait_idle(Duration::from_secs(30)));
black_box(index.status().files)
})
});
group.finish();
}
criterion_group! {
name = benches;
config = Criterion::default().measurement_time(Duration::from_secs(3));
targets = bench
}
criterion_main!(benches);
-91
Переглянути файл
@@ -24,19 +24,6 @@ pub(crate) fn char_mask(s: &str) -> u64 {
m
}
/// A relative index key must stay below the project root.
pub(crate) fn safe_rel(rel: &str) -> bool {
let mut any = false;
for component in Path::new(rel).components() {
if matches!(component, std::path::Component::Normal(_)) {
any = true;
} else {
return false;
}
}
any
}
#[inline]
pub(crate) fn mask_bit(b: u8) -> u64 {
match b.to_ascii_lowercase() {
@@ -566,67 +553,6 @@ impl IndexData {
self.by_path.get(path).copied()
}
/// Reserves the flat tables for a bulk warm-start hydration.
pub(crate) fn reserve(&mut self, files: usize, symbols: usize, dirs: usize) {
self.files.reserve(files);
self.by_path.reserve(files);
self.dirs.reserve(dirs);
self.hot.rows.reserve(files);
self.dirs_hot.rows.reserve(dirs);
self.syms.head.reserve(symbols);
self.syms.container.reserve(symbols);
self.syms.kind.reserve(symbols);
self.syms.range.reserve(symbols);
}
/// Hydrates a validated warm-start snapshot in one pass, without per-file
/// duplicate checks, ancestor walks or compaction checks.
pub(crate) fn load_bulk(
&mut self,
entries: Vec<(FileEntry, Vec<StoredSymbol>)>,
dirs: Vec<(Box<str>, PathBuf, u32)>,
) {
self.files = Vec::with_capacity(entries.len());
self.by_path = HashMap::with_capacity(entries.len());
self.hot = HotFiles::default();
self.dirs_hot = HotDirs::default();
self.syms = SymTable::default();
self.dirs = HashMap::with_capacity(dirs.len());
let symbols: usize = entries.iter().map(|(_, symbols)| symbols.len()).sum();
self.reserve(entries.len(), symbols, dirs.len());
self.symbol_count = 0;
for (slot, (mut entry, symbols)) in entries.into_iter().enumerate() {
let (sym_start, sym_mask) = self.syms.push(&symbols);
entry.sym_start = sym_start;
entry.sym_len = symbols.len() as u32;
self.symbol_count += entry.sym_len;
let rel = self.hot.append(&entry.rel);
self.hot.set(
slot as u32,
FileRow {
rel,
name_start: entry.name_start,
sym_start,
sym_len: entry.sym_len,
sym_mask,
rel_mask: entry.rel_mask,
name_mask: entry.name_mask,
dir_mask: char_mask(entry.dir()),
ascii: entry.rel.is_ascii(),
live: true,
},
);
self.by_path.insert(entry.path.clone(), slot as u32);
self.files.push(Some(entry));
}
self.file_count = self.files.len() as u32;
for (rel, path, count) in dirs {
let row = self.dirs_hot.push(&rel, &path);
self.dirs.insert(rel, (path, count, row));
}
self.free.clear();
}
/// The persisted form of a file, rebuilt from [`IndexData::syms`].
pub(crate) fn record(&self, e: &FileEntry) -> FileRecord {
FileRecord {
@@ -867,23 +793,6 @@ mod tests {
assert_eq!(d.symbol_count, d.files.iter().flatten().map(|e| e.sym_len).sum::<u32>());
}
#[test]
fn bulk_load_matches_incremental_insert() {
let mut incremental = IndexData::default();
insert(&mut incremental, "a/b.rs", 2);
insert(&mut incremental, "c.rs", 1);
assert_consistent(&incremental);
let mut bulk = IndexData::default();
bulk.load_bulk(vec![entry("a/b.rs", 2), entry("c.rs", 1)], vec![("a/".into(), PathBuf::from("/r/a"), 1)]);
assert_consistent(&bulk);
assert_eq!((bulk.file_count, bulk.symbol_count), (2, 3));
assert_eq!(bulk.hot.text(), incremental.hot.text());
assert_eq!(bulk.files.len(), incremental.files.len());
assert_eq!(bulk.slot(Path::new("/r/a/b.rs")), incremental.slot(Path::new("/r/a/b.rs")));
assert_eq!(bulk.dirs["a/"].1, 1);
}
#[test]
fn dirs_follow_inserts_and_removals() {
let mut d = IndexData::default();
-45
Переглянути файл
@@ -138,22 +138,6 @@ impl PathFilter {
&self.config.0 == extra_roots && self.config.1 == excludes
}
/// Stable fingerprint of the persisted-scope configuration; a start-up
/// snapshot is only trusted when this still matches.
pub(crate) fn stamp(&self) -> String {
let mut stamp = format!("include_root={}\n", self.config.0.include_root);
for folder in &self.config.0.folders {
stamp.push_str(&folder.to_string_lossy());
stamp.push('\n');
}
stamp.push_str("--\n");
for exclude in &self.config.1 {
stamp.push_str(exclude);
stamp.push('\n');
}
stamp
}
pub(crate) fn reload(&self) -> Self {
Self::new(&self.root, &self.config.0, &self.config.1)
}
@@ -162,11 +146,6 @@ impl PathFilter {
&self.root
}
/// Whether content roots outside the project root are indexed with it.
pub(crate) fn has_extra_roots(&self) -> bool {
!self.extra.is_empty()
}
/// The project root, then the extra content roots.
pub(crate) fn roots(&self) -> impl Iterator<Item = &Path> {
std::iter::once(self.root.as_path())
@@ -248,30 +227,6 @@ impl PathFilter {
dir.is_some_and(|d| gitignore.matched_path_or_any_parents(d, true).is_ignore())
}
/// All directories at or below `dir` that the index would walk, in walk order.
pub(crate) fn dirs(&self, dir: &Path) -> Vec<PathBuf> {
let Some((root, _, _)) = self.base_of(dir) else { return Vec::new() };
let root = root.to_path_buf();
let excludes = self.excludes.clone();
let walk_root = root.clone();
let walk_excludes = excludes.clone();
let walker = WalkBuilder::new(dir)
.hidden(false)
.parents(true)
.ignore(true)
.git_ignore(true)
.git_global(true)
.git_exclude(true)
.require_git(ide_core::ignored::inside_git_repo(dir))
.follow_links(false)
.filter_entry(move |e| {
let name = e.file_name().to_string_lossy();
!Self::skip_name(&name) && !glob_excluded(&walk_excludes, &walk_root, e.path())
})
.build();
walker.flatten().filter(|e| e.file_type().is_some_and(|t| t.is_dir())).map(|e| e.into_path()).collect()
}
/// All indexable files at or below `dir`, walked in parallel.
/// Stops early (returning what was found so far) once `cancel` is set.
pub(crate) fn walk(&self, dir: &Path, cancel: &AtomicBool) -> Vec<Found> {
+39 -589
Переглянути файл
@@ -1,429 +1,75 @@
//! Persistence of file records in redb, so a restart only re-parses changed files.
use crate::data::{FileEntry, FileRecord, IndexData, StoredSymbol};
use crate::filter::{PathFilter, mtime_of};
use ide_core::{Range, SymbolKind};
use crate::data::FileRecord;
use parking_lot::RwLock;
use redb::{Database, Durability, ReadableDatabase, ReadableTable, TableDefinition, TableError};
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
const FILES: TableDefinition<&str, &[u8]> = TableDefinition::new("files");
const META: TableDefinition<&str, &[u8]> = TableDefinition::new("meta");
/// Bump when [`FileRecord`] or its encoding changes.
const SCHEMA: &str = "openidea-index-3";
/// The real Git directory for a checkout/worktree (`root/.git` may be a file
/// containing `gitdir: ...`).
fn git_dir(root: &Path) -> Option<std::path::PathBuf> {
let dot_git = root.join(".git");
if dot_git.is_dir() {
return Some(dot_git);
}
let text = std::fs::read_to_string(&dot_git).ok()?;
let target = text.strip_prefix("gitdir:")?.trim();
let path = Path::new(target);
Some(if path.is_absolute() { path.to_owned() } else { root.join(path) })
}
/// The config and exclude file of the Git directory `git`; a linked worktree's directory
/// (`.git/worktrees/<name>`) reads the shared ones its `commondir` names as well.
fn insert_git_inputs(out: &mut std::collections::BTreeSet<std::path::PathBuf>, git: &Path) {
out.insert(git.join("config"));
out.insert(git.join("info/exclude"));
if let Ok(common) = std::fs::read_to_string(git.join("commondir")) {
let common = Path::new(common.trim());
let common = if common.is_absolute() { common.to_owned() } else { git.join(common) };
out.insert(common.join("config"));
out.insert(common.join("info/exclude"));
}
}
/// External ignore/config inputs, including candidates that do not exist yet.
/// These are outside the directory manifest (`.git` is skipped), so the full
/// path set must match at warm start.
pub(crate) fn external_ignore_candidates(root: &Path) -> Vec<std::path::PathBuf> {
let mut out = std::collections::BTreeSet::new();
if let Some(git) = git_dir(root) {
insert_git_inputs(&mut out, &git);
}
out.insert(root.join(".git/config"));
out.insert(root.join(".git/info/exclude"));
// The walker also reads the ignore files above the root (`parents`), and a project nested
// in a repository uses that repository's exclude file and config.
for dir in root.ancestors().skip(1) {
out.insert(dir.join(".gitignore"));
out.insert(dir.join(".ignore"));
if let Some(git) = git_dir(dir) {
insert_git_inputs(&mut out, &git);
}
}
if let Some(path) = ignore::gitignore::gitconfig_excludes_path() {
out.insert(path);
}
out.insert(std::path::PathBuf::from("/etc/gitconfig"));
if let Some(path) = std::env::var_os("GIT_CONFIG_GLOBAL") {
out.insert(path.into());
}
if let Some(home) = std::env::var_os("HOME") {
let home = std::path::PathBuf::from(home);
out.insert(home.join(".gitconfig"));
out.insert(
std::env::var_os("XDG_CONFIG_HOME")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| home.join(".config"))
.join("git/config"),
);
}
out.into_iter().collect()
}
/// Existing per-directory ignore files. Adding/removing one changes the
/// directory mtime, which the manifest already validates before this list is used.
fn per_dir_ignore_candidates(root: &Path, dirs: &[String]) -> Vec<std::path::PathBuf> {
let mut out = std::collections::BTreeSet::new();
for rel in dirs {
let dir = if rel.is_empty() { root.to_owned() } else { root.join(rel) };
for name in [".gitignore", ".ignore"] {
let path = dir.join(name);
if path.is_file() {
out.insert(path);
}
}
}
out.into_iter().collect()
}
/// One file's fixed-size columns in the start-up snapshot.
#[derive(Clone, Copy, Serialize, Deserialize, Default)]
struct FileMeta {
mtime: u64,
size: u64,
hash: [u8; 32],
ignored: bool,
/// End of this file's symbols in [`Snapshot::symbols`].
sym_end: u32,
}
/// One symbol's fixed-size columns; names and containers share [`Snapshot::text`].
#[derive(Clone, Copy, Serialize, Deserialize)]
struct FlatSymbol {
name_start: u32,
name_len: u32,
container_start: u32,
container_len: u32,
kind: SymbolKind,
range: Range,
}
/// A columnar copy of the index for fast start-up: a handful of contiguous
/// arrays and two string arenas instead of one postcard record per redb key.
#[derive(Serialize, Deserialize, Default)]
pub(crate) struct Snapshot {
rels: String,
rel_ends: Vec<u32>,
meta: Vec<FileMeta>,
symbols: Vec<FlatSymbol>,
text: String,
dirs: String,
dir_ends: Vec<u32>,
dir_mtimes: Vec<u64>,
dir_counts: Vec<u32>,
filter: String,
stamp: String,
ignore_paths: String,
ignore_ends: Vec<u32>,
ignore_mtimes: Vec<u64>,
ignore_sizes: Vec<u64>,
ignore_present: Vec<bool>,
ignore_external: Vec<bool>,
}
impl Snapshot {
pub(crate) fn len(&self) -> usize {
self.meta.len()
}
pub(crate) fn filter(&self) -> &str {
&self.filter
}
pub(crate) fn dirs_len(&self) -> usize {
self.dir_mtimes.len()
}
pub(crate) fn dir(&self, dir: usize) -> &str {
let start = if dir == 0 { 0 } else { self.dir_ends[dir - 1] as usize };
&self.dirs[start..self.dir_ends[dir] as usize]
}
pub(crate) fn dir_mtime(&self, dir: usize) -> u64 {
self.dir_mtimes[dir]
}
pub(crate) fn dir_count(&self, dir: usize) -> u32 {
self.dir_counts[dir]
}
pub(crate) fn ignore_len(&self) -> usize {
self.ignore_mtimes.len()
}
pub(crate) fn ignore_path(&self, i: usize) -> &str {
let start = if i == 0 { 0 } else { self.ignore_ends[i - 1] as usize };
&self.ignore_paths[start..self.ignore_ends[i] as usize]
}
pub(crate) fn ignore_mtime(&self, i: usize) -> u64 {
self.ignore_mtimes[i]
}
pub(crate) fn ignore_size(&self, i: usize) -> u64 {
self.ignore_sizes[i]
}
pub(crate) fn ignore_present(&self, i: usize) -> bool {
self.ignore_present[i]
}
pub(crate) fn ignore_external(&self, i: usize) -> bool {
self.ignore_external[i]
}
pub(crate) fn mtime(&self, file: usize) -> u64 {
self.meta[file].mtime
}
pub(crate) fn size(&self, file: usize) -> u64 {
self.meta[file].size
}
/// Rejects a corrupt snapshot before any offsets are used for slicing.
fn valid(&self) -> bool {
let max = u32::MAX as usize;
if self.rels.len() > max || self.text.len() > max || self.dirs.len() > max || self.ignore_paths.len() > max {
return false;
}
if self.rel_ends.len() != self.meta.len() {
return false;
}
let boundary = |text: &str, end: u32| end as usize <= text.len() && text.is_char_boundary(end as usize);
let mut rel_end = 0;
for end in &self.rel_ends {
if *end < rel_end || !boundary(&self.rels, *end) {
return false;
}
rel_end = *end;
}
if rel_end as usize != self.rels.len() {
return false;
}
let mut start = 0;
for end in &self.rel_ends {
let rel = &self.rels[start..*end as usize];
if !crate::data::safe_rel(rel) {
return false;
}
start = *end as usize;
}
let mut sym_end = 0;
for meta in &self.meta {
if meta.sym_end < sym_end || meta.sym_end as usize > self.symbols.len() {
return false;
}
sym_end = meta.sym_end;
}
if sym_end as usize != self.symbols.len() {
return false;
}
if !self.symbols.iter().all(|s| {
let Some(name_end) = (s.name_start as usize).checked_add(s.name_len as usize) else {
return false;
};
let Some(container_end) = (s.container_start as usize).checked_add(s.container_len as usize) else {
return false;
};
name_end <= self.text.len()
&& self.text.is_char_boundary(s.name_start as usize)
&& self.text.is_char_boundary(name_end)
&& (s.container_start == u32::MAX
|| (s.container_start as usize <= self.text.len()
&& container_end <= self.text.len()
&& self.text.is_char_boundary(s.container_start as usize)
&& self.text.is_char_boundary(container_end)))
}) {
return false;
}
if self.dir_ends.len() != self.dir_mtimes.len() || self.dir_counts.len() != self.dir_mtimes.len() {
return false;
}
if self.dir_ends.first() != Some(&0) {
return false;
}
let mut dir_end = 0;
for end in &self.dir_ends {
if *end < dir_end || !boundary(&self.dirs, *end) {
return false;
}
dir_end = *end;
}
if dir_end as usize != self.dirs.len() {
return false;
}
let mut start = 0;
for end in &self.dir_ends {
let dir = &self.dirs[start..*end as usize];
if !dir.is_empty() && !crate::data::safe_rel(dir) {
return false;
}
start = *end as usize;
}
if self.ignore_ends.len() != self.ignore_mtimes.len()
|| self.ignore_sizes.len() != self.ignore_mtimes.len()
|| self.ignore_present.len() != self.ignore_mtimes.len()
|| self.ignore_external.len() != self.ignore_mtimes.len()
{
return false;
}
let mut ignore_end = 0;
for end in &self.ignore_ends {
if *end < ignore_end || !boundary(&self.ignore_paths, *end) {
return false;
}
ignore_end = *end;
}
if ignore_end as usize != self.ignore_paths.len() {
return false;
}
let mut start = 0;
for end in &self.ignore_ends {
if start == *end as usize {
return false;
}
start = *end as usize;
}
true
}
pub(crate) fn rel(&self, file: usize) -> &str {
let start = if file == 0 { 0 } else { self.rel_ends[file - 1] as usize };
&self.rels[start..self.rel_ends[file] as usize]
}
pub(crate) fn entry(&self, file: usize, root: &Path) -> (FileEntry, Vec<StoredSymbol>) {
let meta = self.meta[file];
let sym_start = if file == 0 { 0 } else { self.meta[file - 1].sym_end as usize };
let symbols = (sym_start..meta.sym_end as usize)
.map(|i| {
let s = self.symbols[i];
let name_start = s.name_start as usize;
let name_end = name_start + s.name_len as usize;
let name = &self.text[name_start..name_end];
let container = (s.container_start != u32::MAX).then(|| {
let start = s.container_start as usize;
&self.text[start..start + s.container_len as usize]
});
StoredSymbol {
name: name.to_owned(),
kind: s.kind,
container: container.map(str::to_owned),
range: s.range,
}
})
.collect();
let rel = self.rel(file);
FileEntry::new(
root.join(rel),
rel.to_owned(),
FileRecord { mtime: meta.mtime, size: meta.size, hash: meta.hash, symbols, ignored: meta.ignored },
)
}
}
type Result<T> = std::result::Result<T, String>;
fn err(e: impl std::fmt::Display) -> String {
e.to_string()
}
fn fit(n: usize) -> Result<u32> {
u32::try_from(n).map_err(|_| "index snapshot exceeds the 4 GiB span limit".to_owned())
}
pub(crate) struct Store {
/// Opened lazily: a valid warm-start snapshot serves start-up without
/// touching redb. Write-locked only for [`Store::compact`].
db: RwLock<Option<Database>>,
/// Columnar warm-start snapshot, kept beside the database.
snapshot: std::path::PathBuf,
/// Recent-file list, readable without opening redb.
recent_file: std::path::PathBuf,
stamp: String,
/// Write-locked only for [`Store::compact`].
db: RwLock<Database>,
}
impl Store {
/// Opens (or creates) the cache directory. The redb database itself is opened
/// lazily so a valid columnar snapshot can serve start-up without touching it.
/// Returns `None` only when the cache directory cannot be created; the index
/// then runs in memory only.
/// Opens (or creates) `cache_dir/index.redb`. Records written by another
/// schema / extractor version or for another root are discarded. Returns
/// `None` when the database cannot be used (e.g. locked by another window);
/// the index then runs in memory only.
pub(crate) fn open(cache_dir: &Path, root: &Path, fingerprint: &str) -> Option<Self> {
if let Err(e) = std::fs::create_dir_all(cache_dir) {
tracing::warn!("index cache {}: {e}", cache_dir.display());
return None;
}
let stamp = format!("{SCHEMA}\n{}\n{fingerprint}\n{}", env!("INDEX_SOURCE_HASH"), root.display());
Some(Self {
db: RwLock::new(None),
snapshot: cache_dir.join("index.snapshot"),
recent_file: cache_dir.join("recent.snapshot"),
stamp,
})
}
fn open_db(&self) -> Result<Database> {
let file = self.snapshot.with_file_name("index.redb");
let file = cache_dir.join("index.redb");
let db = match Database::create(&file) {
Ok(db) => db,
Err(redb::DatabaseError::DatabaseAlreadyOpen) => {
return Err(format!("{} is in use", file.display()));
tracing::warn!("index cache {} is in use; indexing in memory only", file.display());
return None;
}
Err(e) => {
tracing::warn!("index cache {} unreadable ({e}); rebuilding", file.display());
let _ = std::fs::remove_file(&file);
let _ = std::fs::remove_file(&self.snapshot);
Database::create(&file).map_err(err)?
Database::create(&file).map_err(|e| tracing::warn!("index cache: {e}")).ok()?
}
};
let valid = {
let txn = db.begin_read().map_err(err)?;
match txn.open_table(META) {
Ok(table) => table.get("stamp").map_err(err)?.is_some_and(|v| v.value() == self.stamp.as_bytes()),
Err(TableError::TableDoesNotExist(_)) => false,
Err(e) => return Err(err(e)),
let store = Self { db: RwLock::new(db) };
let stamp = format!("{SCHEMA}\n{}\n{fingerprint}\n{}", env!("INDEX_SOURCE_HASH"), root.display());
match store.meta("stamp") {
Ok(Some(v)) if v == stamp.as_bytes() => {}
_ => {
if let Err(e) = store.reset(&stamp) {
tracing::warn!("index cache reset failed: {e}");
return None;
}
}
}
Some(store)
}
fn meta(&self, key: &str) -> Result<Option<Vec<u8>>> {
let txn = self.db.read().begin_read().map_err(err)?;
let table = match txn.open_table(META) {
Ok(t) => t,
Err(TableError::TableDoesNotExist(_)) => return Ok(None),
Err(e) => return Err(err(e)),
};
if !valid {
let _ = std::fs::remove_file(&self.snapshot);
let _ = std::fs::remove_file(&self.recent_file);
Self::reset_db(&db, &self.stamp)?;
}
Ok(db)
Ok(table.get(key).map_err(err)?.map(|v| v.value().to_vec()))
}
fn ensure_db(&self) -> Result<()> {
if self.db.read().is_some() {
return Ok(());
}
let mut slot = self.db.write();
if slot.is_none() {
*slot = Some(self.open_db()?);
}
Ok(())
}
fn reset_db(db: &Database, stamp: &str) -> Result<()> {
let txn = db.begin_write().map_err(err)?;
fn reset(&self, stamp: &str) -> Result<()> {
let txn = self.db.read().begin_write().map_err(err)?;
match txn.delete_table(FILES) {
Ok(_) | Err(TableError::TableDoesNotExist(_)) => {}
Err(e) => return Err(err(e)),
@@ -436,31 +82,16 @@ impl Store {
txn.commit().map_err(err)
}
fn meta(&self, key: &str) -> Result<Option<Vec<u8>>> {
self.ensure_db()?;
let db = self.db.read();
let txn = db.as_ref().expect("ensured").begin_read().map_err(err)?;
let table = match txn.open_table(META) {
Ok(t) => t,
Err(TableError::TableDoesNotExist(_)) => return Ok(None),
Err(e) => return Err(err(e)),
};
Ok(table.get(key).map_err(err)?.map(|v| v.value().to_vec()))
}
/// All persisted records; undecodable entries are skipped (and re-indexed).
pub(crate) fn load(&self) -> Result<Vec<(String, FileRecord)>> {
self.ensure_db()?;
let db = self.db.read();
let txn = db.as_ref().expect("ensured").begin_read().map_err(err)?;
let txn = self.db.read().begin_read().map_err(err)?;
let table = match txn.open_table(FILES) {
Ok(t) => t,
Err(TableError::TableDoesNotExist(_)) => return Ok(Vec::new()),
Err(e) => return Err(err(e)),
};
let iter = table.iter().map_err(err)?;
let mut out = Vec::with_capacity(iter.size_hint().0);
for item in iter {
let mut out = Vec::new();
for item in table.iter().map_err(err)? {
let (k, v) = item.map_err(err)?;
if let Ok(rec) = postcard::from_bytes::<FileRecord>(v.value()) {
out.push((k.value().to_owned(), rec));
@@ -469,115 +100,10 @@ impl Store {
Ok(out)
}
/// Writes the current index as one columnar snapshot for fast next start-up.
/// Only records persisted below `root` are included; content roots outside it
/// are rescanned, as they are for the per-file records.
pub(crate) fn save_snapshot(&self, data: &IndexData, filter: &PathFilter, not_after: SystemTime) -> Result<()> {
let guard = not_after.duration_since(UNIX_EPOCH).map_or(0, |d| d.as_nanos() as u64);
let root = filter.root();
let mut snap = Snapshot { filter: filter.stamp(), stamp: self.stamp.clone(), ..Default::default() };
let mut dirs: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
dirs.insert(String::new());
for dir in filter.dirs(root) {
if let Ok(rel) = dir.strip_prefix(root) {
dirs.insert(rel.to_string_lossy().replace('\\', "/").trim_end_matches('/').to_owned());
}
}
for e in data.files.iter().flatten().filter(|e| e.path.starts_with(root)) {
dirs.insert(e.dir().trim_end_matches('/').to_owned());
snap.rels.push_str(&e.rel);
snap.rel_ends.push(fit(snap.rels.len())?);
for s in data.symbols_of(e) {
let name_start = fit(snap.text.len())?;
snap.text.push_str(s.name);
let (container_start, container_len) = match s.container {
Some(c) => {
let start = fit(snap.text.len())?;
snap.text.push_str(c);
(start, fit(c.len())?)
}
None => (u32::MAX, 0),
};
snap.symbols.push(FlatSymbol {
name_start,
name_len: fit(s.name.len())?,
container_start,
container_len,
kind: s.kind,
range: s.range,
});
}
snap.meta.push(FileMeta {
mtime: e.mtime,
size: e.size,
hash: e.hash,
ignored: e.ignored,
sym_end: fit(snap.symbols.len())?,
});
}
let dir_list: Vec<String> = dirs.into_iter().collect();
for rel in &dir_list {
let path = if rel.is_empty() { root.to_owned() } else { root.join(rel) };
let mtime = std::fs::symlink_metadata(&path).ok().map(|m| mtime_of(&m)).unwrap_or(0);
if mtime > guard {
let _ = std::fs::remove_file(&self.snapshot);
return Ok(());
}
let count = if rel.is_empty() {
0
} else {
let key = format!("{rel}/");
data.dirs.get(key.as_str()).map_or(0, |d| d.1)
};
snap.dirs.push_str(rel);
snap.dir_ends.push(fit(snap.dirs.len())?);
snap.dir_mtimes.push(mtime);
snap.dir_counts.push(count);
}
let external: std::collections::BTreeSet<std::path::PathBuf> =
external_ignore_candidates(root).into_iter().collect();
let mut candidates: std::collections::BTreeSet<std::path::PathBuf> =
per_dir_ignore_candidates(root, &dir_list).into_iter().collect();
candidates.extend(external.iter().cloned());
for path in candidates {
let meta = std::fs::metadata(&path).ok();
let present = meta.is_some();
let mtime = meta.as_ref().map_or(0, mtime_of);
if mtime > guard {
let _ = std::fs::remove_file(&self.snapshot);
return Ok(());
}
snap.ignore_paths.push_str(&path.to_string_lossy());
snap.ignore_ends.push(fit(snap.ignore_paths.len())?);
snap.ignore_mtimes.push(if present { mtime } else { 0 });
snap.ignore_sizes.push(if present { meta.as_ref().map_or(0, |m| m.len()) } else { 0 });
snap.ignore_present.push(present);
snap.ignore_external.push(external.contains(&path));
}
fit(snap.rels.len())?;
fit(snap.text.len())?;
fit(snap.dirs.len())?;
fit(snap.ignore_paths.len())?;
let bytes = postcard::to_allocvec(&snap).map_err(err)?;
let tmp = self.snapshot.with_extension("snapshot.tmp");
std::fs::write(&tmp, &bytes).map_err(err)?;
let _ = std::fs::remove_file(&self.snapshot);
std::fs::rename(&tmp, &self.snapshot).map_err(err)
}
/// The columnar start-up snapshot, if it was written and still decodes.
pub(crate) fn load_snapshot(&self) -> Option<Snapshot> {
let bytes = std::fs::read(&self.snapshot).ok()?;
let snapshot: Snapshot = postcard::from_bytes(&bytes).ok()?;
(snapshot.stamp == self.stamp && snapshot.valid()).then_some(snapshot)
}
/// Applies upserts and deletions in one transaction. Non-durable commits
/// are cheap (no fsync) and become durable with the next durable one.
pub(crate) fn apply(&self, puts: &[(String, FileRecord)], 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)?;
let mut txn = self.db.read().begin_write().map_err(err)?;
if !durable {
txn.set_durability(Durability::None).map_err(err)?;
}
@@ -594,47 +120,25 @@ impl Store {
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()?;
let mut db = self.db.write();
db.as_mut().expect("ensured").compact().map_err(err)
self.db.write().compact().map_err(err)
}
pub(crate) fn load_recent(&self) -> Vec<String> {
if let Some(recent) = self.load_recent_file() {
return recent;
}
self.meta("recent").ok().flatten().and_then(|v| postcard::from_bytes(&v).ok()).unwrap_or_default()
}
/// Recent files without opening redb (the warm-start path).
pub(crate) fn load_recent_file(&self) -> Option<Vec<String>> {
let bytes = std::fs::read(&self.recent_file).ok()?;
postcard::from_bytes(&bytes).ok()
}
pub(crate) fn save_recent(&self, recent: &[String]) -> Result<()> {
self.save_recent_file(recent)?;
let bytes = postcard::to_allocvec(recent).map_err(err)?;
self.set_meta("recent", &bytes)
}
/// Rewrites only the recent sidecar, without opening redb.
pub(crate) fn save_recent_file(&self, recent: &[String]) -> Result<()> {
let bytes = postcard::to_allocvec(recent).map_err(err)?;
std::fs::write(&self.recent_file, &bytes).map_err(err)
}
fn set_meta(&self, key: &str, value: &[u8]) -> Result<()> {
self.ensure_db()?;
let db = self.db.read();
let txn = db.as_ref().expect("ensured").begin_write().map_err(err)?;
let txn = self.db.read().begin_write().map_err(err)?;
txn.open_table(META).map_err(err)?.insert(key, value).map_err(err)?;
txn.commit().map_err(err)
}
@@ -679,58 +183,4 @@ mod tests {
let s = Store::open(dir.path(), Path::new("/p"), "fp").unwrap();
assert!(s.load().unwrap().is_empty());
}
#[test]
fn snapshot_roundtrips_and_is_invalidated_by_changes() {
use crate::data::{FileEntry, IndexData, StoredSymbol};
use ide_core::{Range, SymbolKind};
let dir = tempfile::tempdir().unwrap();
let store = Store::open(dir.path(), Path::new("/p"), "fp").unwrap();
let record = FileRecord {
mtime: 7,
size: 3,
symbols: vec![StoredSymbol {
name: "OrderController".into(),
kind: SymbolKind::Class,
container: Some("Order".into()),
range: Range::default(),
}],
..Default::default()
};
let mut data = IndexData::default();
let (entry, symbols) =
FileEntry::new(std::path::PathBuf::from("/p/src/Order.rs"), "src/Order.rs".into(), record);
data.insert(entry, symbols);
// Content roots outside the project root are rescanned, not snapshotted.
let (entry, symbols) =
FileEntry::new(std::path::PathBuf::from("/elsewhere/x.rs"), "x.rs".into(), FileRecord::default());
data.insert(entry, symbols);
store
.save_snapshot(&data, &PathFilter::new(Path::new("/p"), &Vec::new().into(), &[]), SystemTime::now())
.unwrap();
let snapshot = store.load_snapshot().expect("snapshot");
assert_eq!(snapshot.len(), 1);
assert_eq!(snapshot.rel(0), "src/Order.rs");
let (entry, symbols) = snapshot.entry(0, Path::new("/p"));
assert_eq!(&*entry.rel, "src/Order.rs");
assert_eq!(symbols.len(), 1);
assert_eq!(symbols[0].name, "OrderController");
assert_eq!(symbols[0].container.as_deref(), Some("Order"));
store.apply(&[], &[], false).unwrap();
assert!(store.load_snapshot().is_none(), "a file change invalidates the snapshot");
}
#[test]
fn snapshot_validation_rejects_bad_paths_and_split_utf8() {
let escaping =
Snapshot { rels: "../x".into(), rel_ends: vec![4], meta: vec![FileMeta::default()], ..Default::default() };
assert!(!escaping.valid());
let split =
Snapshot { rels: "é".into(), rel_ends: vec![1], meta: vec![FileMeta::default()], ..Default::default() };
assert!(!split.valid());
}
}
-128
Переглянути файл
@@ -772,95 +772,6 @@ fn persisted_symbols_do_not_load_through_escaping_symlink_ancestors() {
assert_eq!(initial.lock()[0], 0, "unsafe cached symbols must never be published");
}
#[test]
fn incremental_changes_refresh_the_warm_start_snapshot() {
let f = Fixture::new(&[("src/a.rs", "fn a() {}\n")]);
let idx = f.open();
let changed = f.write("src/a.rs", "fn renamed() {}\n");
idx.update(&[changed], &[]);
assert!(idx.wait_idle(WAIT));
std::thread::sleep(Duration::from_millis(150));
assert!(f.cache.join("index.snapshot").exists(), "a watcher update must regenerate the snapshot");
}
#[test]
fn warm_start_detects_changed_ignore_rules() {
let f = Fixture::new(&[("sub/generated/a.rs", "fn a() {}\n"), (".git/HEAD", "ref: refs/heads/main\n")]);
f.write("sub/.gitignore", "");
let idx = f.open();
assert_eq!(idx.status().files, 2, "generated/a.rs plus .gitignore itself");
drop(idx);
std::thread::sleep(Duration::from_millis(20));
f.write("sub/.gitignore", "generated/\n");
let idx = f.open();
assert_eq!(idx.status().files, 1, "an updated ignore rule invalidates the warm snapshot");
}
#[test]
fn warm_start_detects_changed_ignore_rules_above_the_project() {
// The project is a folder of a repository whose ignore files sit above it.
let f = Fixture::new(&[("generated/a.rs", "fn a() {}\n"), ("src/b.rs", "fn b() {}\n")]);
let repo = f.root.parent().unwrap().to_owned();
std::fs::create_dir_all(repo.join(".git/info")).unwrap();
std::fs::write(repo.join(".git/HEAD"), "ref: refs/heads/main\n").unwrap();
let idx = f.open();
assert_eq!(idx.status().files, 2);
drop(idx);
std::fs::write(repo.join(".gitignore"), "proj/generated/\n").unwrap();
let idx = f.open();
assert_eq!(idx.status().files, 1, "a new ignore file above the project invalidates the warm snapshot");
drop(idx);
std::thread::sleep(Duration::from_millis(20));
std::fs::write(repo.join(".gitignore"), "").unwrap();
std::fs::write(repo.join(".git/info/exclude"), "proj/src/\n").unwrap();
let idx = f.open();
assert_eq!(idx.status().files, 1, "so do the enclosing repository's changed rules");
assert!(!symbols(&idx, "a", SymbolFilter::All).is_empty());
}
#[test]
fn warm_start_detects_a_changed_exclude_file_of_a_linked_worktree() {
// The project is a linked worktree: its `.git` file points into the main repository,
// whose shared `info/exclude` the walker reads through `commondir`.
let f = Fixture::new(&[("generated/a.rs", "fn a() {}\n"), ("src/b.rs", "fn b() {}\n")]);
let main = f.root.parent().unwrap().join("main/.git");
let worktree = main.join("worktrees/proj");
std::fs::create_dir_all(main.join("info")).unwrap();
std::fs::create_dir_all(&worktree).unwrap();
std::fs::write(main.join("HEAD"), "ref: refs/heads/main\n").unwrap();
std::fs::write(worktree.join("HEAD"), "ref: refs/heads/wt\n").unwrap();
std::fs::write(worktree.join("commondir"), "../..\n").unwrap();
std::fs::write(f.root.join(".git"), format!("gitdir: {}\n", worktree.display())).unwrap();
let idx = f.open();
assert_eq!(idx.status().files, 2);
drop(idx);
std::fs::write(main.join("info/exclude"), "generated/\n").unwrap();
let idx = f.open();
assert_eq!(idx.status().files, 1, "the shared exclude file invalidates the warm snapshot");
}
#[test]
fn warm_start_detects_a_new_file_in_an_existing_empty_directory() {
let f = Fixture::new(&[("src/a.rs", "fn a() {}\n")]);
std::fs::create_dir(f.root.join("empty")).unwrap();
let idx = f.open();
assert_eq!(idx.status().files, 1);
assert!(idx.find_dirs("empty", 10).is_empty());
drop(idx);
let idx = f.open();
assert!(idx.find_dirs("empty", 10).is_empty(), "empty folders are not searchable after warm start");
drop(idx);
f.write("empty/new.rs", "fn new_symbol() {}\n");
let idx = f.open();
assert_eq!(idx.status().files, 2);
assert_eq!(symbols(&idx, "new_symbol", SymbolFilter::All), ["new_symbol"]);
}
#[test]
fn rule_changes_refresh_only_the_affected_index_subtree() {
for (rule, subtree) in [(".git/info/exclude", ""), ("sub/.gitignore", "sub"), ("sub/.ignore", "sub")] {
@@ -923,42 +834,3 @@ fn repository_rule_changes_refresh_members_of_an_unindexed_container() {
assert!(idx.wait_idle(WAIT));
assert_eq!(files(&idx, "item.txt"), ["member/generated/item.txt"]);
}
#[test]
fn warm_start_sees_edits_deletions_and_renames_made_while_closed() {
let f = Fixture::new(&[
("src/a.rs", "fn aaaa() {}\n"),
("src/deep/b.rs", "fn bbbb() {}\n"),
("c.rs", "fn cccc() {}\n"),
]);
let idx = f.open();
assert!(idx.wait_idle(WAIT));
drop(idx);
std::thread::sleep(Duration::from_millis(200));
// Warm start of an unchanged tree keeps everything.
let idx = f.open();
assert!(idx.wait_idle(WAIT));
assert_eq!(symbols(&idx, "aaaa", SymbolFilter::All), ["aaaa"]);
drop(idx);
std::thread::sleep(Duration::from_millis(20));
// Same size, new content; a deletion; a new file two levels down; a rename.
f.write("src/a.rs", "fn zzzz() {}\n");
std::fs::remove_file(f.root.join("c.rs")).unwrap();
f.write("src/deep/deeper/n.rs", "fn nnnn() {}\n");
std::fs::rename(f.root.join("src/deep/b.rs"), f.root.join("src/deep/r.rs")).unwrap();
let idx = f.open();
assert!(idx.wait_idle(WAIT));
assert!(symbols(&idx, "aaaa", SymbolFilter::All).is_empty(), "stale content");
assert_eq!(symbols(&idx, "zzzz", SymbolFilter::All), ["zzzz"]);
assert!(symbols(&idx, "cccc", SymbolFilter::All).is_empty(), "deleted file");
assert_eq!(symbols(&idx, "nnnn", SymbolFilter::All), ["nnnn"]);
assert_eq!(symbols(&idx, "bbbb", SymbolFilter::All), ["bbbb"]);
assert_eq!(idx.status().files, 3);
drop(idx);
// After the cold rescan, the next warm start agrees.
std::thread::sleep(Duration::from_millis(200));
let idx = f.open();
assert!(idx.wait_idle(WAIT));
assert_eq!(idx.status().files, 3);
assert_eq!(symbols(&idx, "zzzz", SymbolFilter::All), ["zzzz"]);
}
+41 -306
Переглянути файл
@@ -1,8 +1,8 @@
//! The background indexing thread: initial load + full scan, then incremental
//! updates queued by the watcher.
use crate::data::{FileEntry, FileRecord, IndexData, StoredSymbol, safe_rel};
use crate::filter::{Found, PathFilter, mtime_of};
use crate::data::{FileEntry, FileRecord, IndexData, StoredSymbol};
use crate::filter::{Found, PathFilter};
use crate::store::Store;
use ide_core::{IdeEvent, SharedSink, TaskId};
use ide_language::{LanguageRegistry, Syntax};
@@ -12,16 +12,12 @@ use std::collections::{HashMap, HashSet, VecDeque};
use std::io::Read;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant, SystemTime};
use std::time::{Duration, Instant};
/// Files larger than this are listed but not parsed for symbols.
pub const MAX_SYMBOL_FILE: u64 = 2 * 1024 * 1024;
const CHUNK: usize = 512;
const PROGRESS_EVERY: Duration = Duration::from_millis(250);
#[cfg(not(test))]
const SNAPSHOT_DELAY: Duration = Duration::from_secs(2);
#[cfg(test)]
const SNAPSHOT_DELAY: Duration = Duration::from_millis(50);
pub(crate) const MAX_RECENT: usize = 50;
pub(crate) enum Job {
@@ -59,11 +55,6 @@ pub(crate) struct Shared {
queue: Mutex<Queue>,
wake: Condvar,
idle: Condvar,
/// Coalesced post-update snapshot refresh.
snapshot_due: Mutex<Option<Instant>>,
/// Directories whose watcher events still need reconciliation before a
/// refreshed snapshot can be trusted.
pending_dirs: Mutex<HashSet<PathBuf>>,
}
impl Shared {
@@ -88,8 +79,6 @@ impl Shared {
queue: Mutex::default(),
wake: Condvar::new(),
idle: Condvar::new(),
snapshot_due: Mutex::default(),
pending_dirs: Mutex::default(),
}
}
@@ -146,15 +135,8 @@ impl Shared {
q.busy = true;
}
if scan {
// A warm start can trust the snapshot when neither the scope nor any
// directory/file metadata changed. Rebuilding the walk's file list is
// the expensive part; validate the flat manifest first.
if !self.try_fast_start() {
let snapshot_guard = SystemTime::now();
let found = self.walk_roots(None);
self.load_persisted(&found);
self.full_scan(Some(found), snapshot_guard);
}
self.load_persisted();
self.full_scan();
} else {
self.indexing.store(false, Ordering::SeqCst);
}
@@ -165,38 +147,17 @@ impl Shared {
self.idle.notify_all();
}
loop {
let mut save_due = false;
let jobs: Vec<Job> = {
let mut q = self.queue.lock();
loop {
if self.is_cancelled() {
return;
}
if !q.jobs.is_empty() {
break;
}
let due = *self.snapshot_due.lock();
if let Some(due) = due {
if Instant::now() >= due {
save_due = true;
break;
}
self.wake.wait_until(&mut q, due);
} else {
self.wake.wait(&mut q);
}
while q.jobs.is_empty() && !self.is_cancelled() {
self.wake.wait(&mut q);
}
if save_due {
Vec::new()
} else {
q.busy = true;
q.jobs.drain(..).collect()
if self.is_cancelled() {
return;
}
q.busy = true;
q.jobs.drain(..).collect()
};
if save_due {
self.save_snapshot_now();
continue;
}
self.process(jobs);
let mut q = self.queue.lock();
q.busy = false;
@@ -206,114 +167,27 @@ impl Shared {
}
}
/// Warm start: validate the snapshot's flat directory/file manifest and hydrate
/// without walking the tree. A changed scope, symlink, directory mtime, file
/// mtime or file size falls back to the full walk.
fn try_fast_start(&self) -> bool {
let started = Instant::now();
let Some(store) = &self.store else { return false };
let load_started = Instant::now();
let Some(snapshot) = store.load_snapshot() else { return false };
let loaded = load_started.elapsed();
fn load_persisted(&self) {
let Some(store) = &self.store else { return };
let filter = self.filter();
if filter.has_extra_roots() || snapshot.filter() != filter.stamp() {
return false;
}
let root = filter.root();
for i in 0..snapshot.dirs_len() {
let rel = snapshot.dir(i);
let path = if rel.is_empty() { root.to_owned() } else { root.join(rel) };
let Ok(meta) = std::fs::symlink_metadata(&path) else { return false };
if !meta.is_dir() || mtime_of(&meta) != snapshot.dir_mtime(i) {
return false;
}
}
let external = crate::store::external_ignore_candidates(root);
let saved_external: Vec<&str> = (0..snapshot.ignore_len())
.filter(|&i| snapshot.ignore_external(i))
.map(|i| snapshot.ignore_path(i))
.collect();
if external.len() != saved_external.len()
|| external.iter().zip(&saved_external).any(|(path, saved)| path.to_string_lossy() != **saved)
{
return false;
}
for i in 0..snapshot.ignore_len() {
let path = Path::new(snapshot.ignore_path(i));
let meta = std::fs::metadata(path).ok();
let present = meta.is_some();
if present != snapshot.ignore_present(i) {
return false;
}
if let Some(meta) = meta
&& (mtime_of(&meta) != snapshot.ignore_mtime(i) || meta.len() != snapshot.ignore_size(i))
{
return false;
}
}
let build_started = Instant::now();
let entries: Vec<Option<(FileEntry, Vec<StoredSymbol>)>> = (0..snapshot.len())
.into_par_iter()
.map(|i| {
let path = root.join(snapshot.rel(i));
let meta = std::fs::symlink_metadata(&path).ok()?;
if !meta.is_file() || mtime_of(&meta) != snapshot.mtime(i) || meta.len() != snapshot.size(i) {
return None;
let root = filter.root().to_owned();
match store.load() {
Ok(records) => {
let entries: Vec<(FileEntry, Vec<StoredSymbol>)> = records
.into_par_iter()
.filter(|(rel, _)| {
let path = root.join(rel);
!filter.is_excluded(&path, false) && dunce::canonicalize(&path).is_ok_and(|real| real == path)
})
.map(|(rel, rec)| FileEntry::new(root.join(&rel), rel, rec))
.collect();
let mut d = self.data.write();
for (e, symbols) in entries {
d.insert(e, symbols);
}
Some(snapshot.entry(i, root))
})
.collect();
let built = build_started.elapsed();
if entries.iter().any(Option::is_none) {
return false;
}
let validated = entries;
let mut entries = Vec::with_capacity(validated.len());
for entry in validated {
entries.push(entry.expect("validated above"));
}
let dirs: Vec<(Box<str>, PathBuf, u32)> = (0..snapshot.dirs_len())
.filter_map(|i| {
let rel = snapshot.dir(i);
(!rel.is_empty() && snapshot.dir_count(i) > 0).then(|| {
let key = format!("{rel}/");
(key.into_boxed_str(), root.join(rel), snapshot.dir_count(i))
})
})
.collect();
let insert_started = Instant::now();
let mut d = self.data.write();
d.load_bulk(entries, dirs);
drop(d);
tracing::info!(
"index snapshot: warm start in {:?} (load {loaded:?}, validate/build {built:?}, insert {:?})",
started.elapsed(),
insert_started.elapsed()
);
#[cfg(test)]
eprintln!(
"index snapshot: warm start load {loaded:?}, validate/build {built:?}, insert {:?}, total {:?}",
insert_started.elapsed(),
started.elapsed()
);
self.indexing.store(false, Ordering::SeqCst);
self.emit_state();
let recent = store.load_recent_file().unwrap_or_else(|| {
let recent = store.load_recent();
let _ = store.save_recent_file(&recent);
recent
});
let mut r = self.recent.lock();
for p in recent.into_iter().map(|rel| root.join(rel)) {
if !r.contains(&p) {
r.push(p);
}
Err(e) => tracing::warn!("index cache load failed: {e}"),
}
r.truncate(MAX_RECENT);
true
}
fn load_recent(&self, store: &Store, root: &Path) {
let recent: Vec<PathBuf> = store.load_recent().into_iter().map(|rel| root.join(rel)).collect();
let mut r = self.recent.lock();
for p in recent {
@@ -324,72 +198,6 @@ impl Shared {
r.truncate(MAX_RECENT);
}
fn load_persisted(&self, found: &[Found]) {
let Some(store) = &self.store else { return };
let root = self.filter().root().to_owned();
let seen: HashSet<&Path> = found.iter().map(|f| f.path.as_path()).collect();
let started = Instant::now();
match store.load_snapshot() {
Some(snapshot) => {
let entries: Vec<(FileEntry, Vec<StoredSymbol>)> = (0..snapshot.len())
.into_par_iter()
.filter_map(|i| {
let rel = snapshot.rel(i);
if !safe_rel(rel) {
return None;
}
let path = root.join(rel);
seen.contains(path.as_path()).then(|| snapshot.entry(i, &root))
})
.collect();
let mut d = self.data.write();
let symbols = entries.iter().map(|(_, symbols)| symbols.len()).sum();
d.reserve(entries.len(), symbols, 0);
for (e, symbols) in entries {
d.insert(e, symbols);
}
tracing::info!(
"index snapshot: hydrated {} files, {} symbols in {:?}",
d.file_count,
d.symbol_count,
started.elapsed()
);
#[cfg(test)]
eprintln!("index snapshot: hydrated {} files in {:?}", d.file_count, started.elapsed());
}
None => match store.load() {
Ok(records) => {
// Fallback for an index that changed since the snapshot: redb
// records are decoded one file at a time and filtered by the walk.
let entries: Vec<(FileEntry, Vec<StoredSymbol>)> = records
.into_par_iter()
.filter(|(rel, _)| {
safe_rel(rel) && {
let path = root.join(rel);
seen.contains(path.as_path())
}
})
.map(|(rel, rec)| FileEntry::new(root.join(&rel), rel, rec))
.collect();
let mut d = self.data.write();
let symbols = entries.iter().map(|(_, symbols)| symbols.len()).sum();
d.reserve(entries.len(), symbols, 0);
for (e, symbols) in entries {
d.insert(e, symbols);
}
tracing::info!(
"index cache: hydrated {} files, {} symbols in {:?}",
d.file_count,
d.symbol_count,
started.elapsed()
);
}
Err(e) => tracing::warn!("index cache load failed: {e}"),
},
}
self.load_recent(store, &root);
}
/// A cancelled scan (project closed) still ends its progress task and the indexing state.
fn end_scan(&self, task: TaskId) {
self.indexing.store(false, Ordering::SeqCst);
@@ -404,24 +212,11 @@ impl Shared {
self.emit_state();
}
fn walk_roots(&self, dirs: Option<&[PathBuf]>) -> Vec<Found> {
let filter = self.filter();
let roots: Vec<&Path> = match dirs {
Some(dirs) => dirs.iter().map(PathBuf::as_path).collect(),
None => filter.roots().collect(),
};
let mut found = Vec::new();
for root in roots {
found.extend(filter.walk(root, &self.cancelled));
}
found
fn full_scan(&self) {
self.scan(None);
}
fn full_scan(&self, found: Option<Vec<Found>>, guard: SystemTime) {
self.scan(None, found, guard);
}
fn scan(&self, dirs: Option<&[PathBuf]>, prewalked: Option<Vec<Found>>, guard: SystemTime) {
fn scan(&self, dirs: Option<&[PathBuf]>) {
let started = Instant::now();
self.indexing.store(true, Ordering::SeqCst);
self.emit_state();
@@ -436,7 +231,14 @@ impl Shared {
});
let filter = self.filter();
let found = prewalked.unwrap_or_else(|| self.walk_roots(dirs));
let mut found = Vec::new();
let roots: Vec<&Path> = match dirs {
Some(dirs) => dirs.iter().map(PathBuf::as_path).collect(),
None => filter.roots().collect(),
};
for root in roots {
found.extend(filter.walk(root, &self.cancelled));
}
if self.is_cancelled() {
return self.end_scan(task);
}
@@ -483,17 +285,6 @@ impl Shared {
}
}
self.persist(&[], &[], true);
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();
if let Err(e) = store.save_snapshot(&d, &filter, guard) {
tracing::warn!("index snapshot: {e}");
}
if let Err(e) = store.save_recent_file(&recent) {
tracing::warn!("index recent snapshot: {e}");
}
}
*self.snapshot_due.lock() = None;
self.indexing.store(false, Ordering::SeqCst);
self.sink.emit(IdeEvent::Task {
id: task,
@@ -556,36 +347,6 @@ impl Shared {
self.persist(&puts, &[], false);
}
fn schedule_snapshot(&self) {
if self.store.is_none() {
return;
}
let mut due = self.snapshot_due.lock();
if due.is_none() {
*due = Some(Instant::now() + SNAPSHOT_DELAY);
}
}
fn save_snapshot_now(&self) {
let dirs: Vec<PathBuf> = self.pending_dirs.lock().drain().collect();
if !dirs.is_empty() {
// Reconcile the directories touched since the last scan before
// recording their mtimes; otherwise a missed watcher event could be
// blessed into the manifest.
self.scan(Some(&dirs), None, SystemTime::now());
return;
}
if let Some(store) = &self.store {
let filter = self.filter();
let guard = SystemTime::now();
let d = self.data.read();
if let Err(e) = store.save_snapshot(&d, &filter, guard) {
tracing::warn!("index snapshot: {e}");
}
}
*self.snapshot_due.lock() = None;
}
fn persist(&self, puts: &[(String, FileRecord)], deletes: &[String], durable: bool) {
if let Some(store) = &self.store
&& let Err(e) = store.apply(puts, deletes, durable)
@@ -645,17 +406,6 @@ impl Shared {
Job::Reparse(before) => reparse.push(before),
}
}
{
let mut pending = self.pending_dirs.lock();
for path in changed.iter().chain(removed.iter()) {
let dir = if path.is_dir() {
path.clone()
} else {
path.parent().map_or_else(|| path.clone(), Path::to_owned)
};
pending.insert(dir);
}
}
if !rule_dirs.is_empty() {
{
let mut filter = self.filter.write();
@@ -670,14 +420,14 @@ impl Shared {
}
}
if !rescan {
self.scan(Some(&roots), None, SystemTime::now());
self.scan(Some(&roots));
changed.retain(|p| !roots.iter().any(|root| p.starts_with(root)));
removed.retain(|p| !roots.iter().any(|root| p.starts_with(root)));
}
}
if rescan {
// The scan also picks up whatever `changed` / `removed` reported.
self.full_scan(None, SystemTime::now());
self.full_scan();
changed.clear();
removed.clear();
}
@@ -772,7 +522,6 @@ impl Shared {
if touched {
self.persist(&[], &[], true);
self.emit_state();
self.schedule_snapshot();
}
}
}
@@ -792,17 +541,3 @@ enum Extracted {
Unchanged,
New(FileRecord),
}
#[cfg(test)]
mod tests {
use crate::data::safe_rel;
#[test]
fn cache_keys_stay_below_the_root() {
assert!(safe_rel("src/main.rs"));
assert!(safe_rel("a/b/c.rs"));
for bad in ["", "/etc/passwd", "../out.rs", "a/../../b.rs", "./a.rs"] {
assert!(!safe_rel(bad), "{bad:?}");
}
}
}
-26
Переглянути файл
@@ -40,7 +40,6 @@ const BUILTIN: &[(&str, &str)] = &[
("groovy", include_str!("../../../languages/groovy.toml")),
("haskell", include_str!("../../../languages/haskell.toml")),
("hcl", include_str!("../../../languages/hcl.toml")),
("helm-template", include_str!("../../../languages/helm-template.toml")),
("html", include_str!("../../../languages/html.toml")),
("http", include_str!("../../../languages/http.toml")),
("ignore", include_str!("../../../languages/ignore.toml")),
@@ -549,7 +548,6 @@ mod tests {
"Groovy",
"Haskell",
"Haxe",
"Helm Template",
"HXML",
"HTTP",
"IDL",
@@ -843,30 +841,6 @@ mod tests {
assert!(reg.is_framework_marker(&root.join("src/manage.py")));
}
#[test]
fn helm_charts_make_yaml_and_notes_in_templates_folders_helm_templates() {
let reg = reg();
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let detect = |file: &str| reg.detect(&root.join(file), None).as_str().to_owned();
std::fs::create_dir_all(root.join("charts/podinfo/templates/tests")).unwrap();
std::fs::create_dir_all(root.join("k8s/templates")).unwrap();
std::fs::write(root.join("charts/podinfo/Chart.yaml"), "apiVersion: v2\nname: podinfo\n").unwrap();
assert_eq!(detect("charts/podinfo/Chart.yaml"), "yaml");
assert_eq!(detect("charts/podinfo/values.yaml"), "yaml");
assert_eq!(detect("charts/podinfo/templates/deployment.yaml"), "helm-template");
assert_eq!(detect("charts/podinfo/templates/tests/connection.yml"), "helm-template");
assert_eq!(detect("charts/podinfo/templates/_helpers.tpl"), "helm-template");
assert_eq!(detect("charts/podinfo/templates/NOTES.txt"), "helm-template");
// No `Chart.yaml` / `Chart.yml` above this `templates` folder.
assert_eq!(detect("k8s/templates/deployment.yaml"), "yaml");
assert_eq!(detect("common.gotmpl"), "helm-template");
// Outside a chart, `.tpl` belongs to other template engines (Smarty, Terraform).
assert_ne!(detect("site/index.tpl"), "helm-template");
assert!(reg.is_framework_marker(&root.join("charts/podinfo/Chart.yaml")));
assert!(reg.is_framework_marker(&root.join("charts/podinfo/Chart.yml")));
}
#[test]
fn the_django_fixture_has_templates_and_a_page() {
let reg = reg();
+1 -122
Переглянути файл
@@ -218,37 +218,6 @@ impl ClientHandler {
true
}
/// Ends every running task of a tsserver that died at its heap limit: the process
/// behind them is gone, so no `end` will ever arrive. Without this the tasks sit
/// as zombies until [`PROGRESS_STALE`] and the server stays "loading", holding
/// empty answers back for [`LOADING_WAIT`] — while vtsls already restarts
/// tsserver, which re-reports its own loading.
fn tsserver_died(&self, mgr: &LspManager) {
let events: Vec<IdeEvent> = {
let mut progress = self.server.progress.lock();
progress
.drain()
.map(|(_, p)| IdeEvent::Task {
id: p.task,
title: p.title,
message: p.message,
fraction: Some(1.0),
done: true,
cancellable: false,
})
.collect()
};
let ended = !events.is_empty();
for event in events {
self.server.emit(mgr.sink(), event);
}
if ended {
self.server.refresh_busy_status(mgr.sink());
self.server.progress_ended();
}
self.server.set_loading(mgr.sink(), false);
}
fn publish_diagnostics(&self, mgr: &LspManager, params: Value) {
// Problems come only from servers used for `diagnostics`.
if self.server.running_gen(self.generation).is_some_and(|run| !run.caps.read().restriction.diagnostics()) {
@@ -441,10 +410,6 @@ impl Handler for ClientHandler {
let text = params.get("message").and_then(Value::as_str).unwrap_or_default();
if self.server.config.tsserver_project && tsserver_ran_out_of_memory(text) {
tracing::warn!("[{}] {text}", self.server.name);
// The tasks of the dead tsserver never see their `end`: end them now
// instead of letting them sit as zombies until the stale window, and
// stop holding queries back — the restarted instance re-reports.
self.tsserver_died(&mgr);
if mgr.first_message(&self.server.name, TSSERVER_OUT_OF_MEMORY) {
mgr.sink().emit(IdeEvent::Notify {
level: NotifyLevel::Error,
@@ -602,12 +567,7 @@ fn merge(base: &mut Value, layer: Value) {
#[cfg(test)]
mod tests {
use super::*;
use crate::server::Progress;
use ide_core::event::SharedSink;
use ide_core::{LanguageId, TaskId};
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use super::tsserver_ran_out_of_memory;
#[test]
fn tsserver_aborts_are_out_of_memory() {
@@ -619,85 +579,4 @@ mod tests {
assert!(!tsserver_ran_out_of_memory("Code: 134."));
assert!(tsserver_ran_out_of_memory("[error] TSServer exited. Code: 134. Signal: null\n"));
}
/// A vtsls server with two running tasks, one marked stalled, and `loading` set:
/// the state a tsserver OOM leaves behind.
fn loaded_vtsls(events: Arc<Mutex<Vec<IdeEvent>>>) -> (Arc<LspManager>, Arc<crate::server::Server>) {
let sink: SharedSink = {
let events = events.clone();
Arc::new(move |event: IdeEvent| events.lock().unwrap().push(event))
};
let languages = Arc::new(ide_language::LanguageRegistry::from_configs(vec![]));
let mgr = LspManager::new(PathBuf::from("/p"), languages, sink);
let cfg: ide_language::LanguageServerConfig =
toml::from_str("name = \"vtsls\"\ncommand = \"vtsls\"\ntsserver-project = true").unwrap();
let server = Arc::new(crate::server::Server::new(cfg, PathBuf::from("/p"), LanguageId::new("vue")));
server.progress.lock().insert(
"loading".into(),
Progress {
task: TaskId::next(),
title: "Initializing 'tsconfig.json'".into(),
message: None,
cancel: None,
last: tokio::time::Instant::now(),
fraction: Some(0.1),
stalled: false,
},
);
server.progress.lock().insert(
"stuck".into(),
Progress {
task: TaskId::next(),
title: "Analyzing 'index.vue' and its dependencies".into(),
message: None,
cancel: None,
last: tokio::time::Instant::now(),
fraction: Some(0.2),
stalled: true,
},
);
let quiet: SharedSink = Arc::new(|_: IdeEvent| {});
server.set_loading(&quiet, true);
(mgr, server)
}
#[test]
fn tsserver_dying_ends_every_task_and_the_loading_state() {
let events = Arc::new(Mutex::new(Vec::new()));
let (mgr, server) = loaded_vtsls(events.clone());
let handler = ClientHandler::new(Arc::downgrade(&mgr), server.clone(), 0);
handler.tsserver_died(&mgr);
// Both tasks ended (done: true), including the stalled one; none left running.
let tasks: Vec<_> = events
.lock()
.unwrap()
.iter()
.filter_map(|e| match e {
IdeEvent::Task { title, done, .. } => Some((title.clone(), *done)),
_ => None,
})
.collect();
assert_eq!(tasks.len(), 2, "{tasks:?}");
assert!(tasks.iter().all(|(_, done)| *done), "{tasks:?}");
assert!(server.progress.lock().is_empty());
assert!(!server.is_loading());
assert!(!server.is_busy());
}
#[test]
fn tsserver_dying_without_tasks_only_clears_loading() {
let events = Arc::new(Mutex::new(Vec::new()));
let (mgr, server) = loaded_vtsls(events.clone());
server.progress.lock().clear();
let handler = ClientHandler::new(Arc::downgrade(&mgr), server.clone(), 0);
handler.tsserver_died(&mgr);
assert!(events.lock().unwrap().is_empty());
assert!(!server.is_loading());
// A status event may be published, but no task events.
assert!(server.progress.lock().is_empty());
}
}
+1 -2
Переглянути файл
@@ -482,8 +482,7 @@ mod tests {
std::fs::create_dir_all(output.join("test001")).unwrap();
// As jdtls writes it: the class's source file name in the path, the library's
// classpath attributes after the folder.
// JDT writes the folder as a portable path, `/` on Windows too, each `/` escaped.
let escaped = output.to_string_lossy().replace('\\', "/").replace('/', "\\/");
let escaped = output.to_string_lossy().replace('/', "\\/");
let handle = format!("=demo/{escaped}=/ai.openidea.kotlin=/true=/<test001(Db.class");
let uri = format!("jdt://contents/main-0/test001/Db.kt?{}", utf8_percent_encode(&handle, PATH));
assert_eq!(folder_class(&uri), Some(("test001".into(), "Db".into())));
+13 -47
Переглянути файл
@@ -40,37 +40,22 @@ const LOOSE: &AsciiSet = &CONTROLS
.add(b'|')
.add(b'}');
/// Fields of LSP messages whose value is a URI.
const URI_FIELDS: [&str; 9] =
["uri", "targetUri", "newUri", "oldUri", "baseUri", "scopeUri", "rootUri", "target", "href"];
/// Percent-encodes the characters URIs don't allow in the `file:` URIs of `value`: the URI
/// fields and the keys of a workspace edit's `changes`. Eclipse's jdtls sends a path as is
/// (`file:///H:/项目/…`), which `lsp_types::Uri` rejects, failing the whole response. Other
/// strings stay as sent: an edit's `newText` may well start with `file:` (YAML, `.env`).
/// Percent-encodes the characters URIs don't allow in every `file:` URI in `value`, string
/// values and object keys alike (a workspace edit's `changes`). Eclipse's jdtls sends a path
/// as is (`file:///H:/项目/…`), which `lsp_types::Uri` rejects, failing the whole response.
pub fn encode_loose_file_uris(value: &mut Value) {
match value {
Value::String(s) => {
if let Some(encoded) = encode_loose(s) {
*s = encoded;
}
}
Value::Array(items) => items.iter_mut().for_each(encode_loose_file_uris),
Value::Object(map) => {
for (key, value) in map.iter_mut() {
match value {
Value::String(s) if URI_FIELDS.contains(&key.as_str()) => {
if let Some(encoded) = encode_loose(s) {
*s = encoded;
}
}
Value::Object(changes) if key == "changes" => {
if changes.keys().any(|k| encode_loose(k).is_some()) {
*changes = std::mem::take(changes)
.into_iter()
.map(|(k, v)| (encode_loose(&k).unwrap_or(k), v))
.collect();
}
changes.values_mut().for_each(encode_loose_file_uris);
}
_ => encode_loose_file_uris(value),
}
if map.keys().any(|k| encode_loose(k).is_some()) {
*map = std::mem::take(map).into_iter().map(|(k, v)| (encode_loose(&k).unwrap_or(k), v)).collect();
}
map.values_mut().for_each(encode_loose_file_uris);
}
_ => {}
}
@@ -176,28 +161,9 @@ mod tests {
assert!(v["edit"]["changes"].get("file:///tmp/%E7%9B%AE%E5%BD%95/b.java").is_some());
assert_eq!(v["text"], "项目", "only file: URIs change");
// Already encoded: untouched.
let mut encoded = serde_json::json!({ "uri": "file:///a%20b/%E9%A1%B9.java" });
let mut encoded = serde_json::json!("file:///a%20b/%E9%A1%B9.java");
encode_loose_file_uris(&mut encoded);
assert_eq!(encoded["uri"], "file:///a%20b/%E9%A1%B9.java");
}
#[test]
fn text_that_starts_with_file_is_not_a_uri() {
// A YAML formatting edit and a completion whose text begins with `file:`.
let mut v = serde_json::json!({ "result": [
{ "range": { "start": { "line": 0, "character": 0 }, "end": { "line": 1, "character": 0 } }, "newText": "file: a b\n - c\n" },
{ "label": "file:", "insertText": "file: ${1:path}", "documentation": "file: the input" }
], "edit": { "changes": { "file:///tmp/a b.yaml": [{ "newText": "file: x y" }] } } });
encode_loose_file_uris(&mut v);
assert_eq!(v["result"][0]["newText"], "file: a b\n - c\n");
assert_eq!(v["result"][1]["insertText"], "file: ${1:path}");
assert_eq!(v["result"][1]["documentation"], "file: the input");
let changes = &v["edit"]["changes"]["file:///tmp/a%20b.yaml"];
assert_eq!(changes[0]["newText"], "file: x y");
// A diagnostic's help link is a URI too.
let mut d = serde_json::json!({ "codeDescription": { "href": "file:///tmp/help files/rule.html" } });
encode_loose_file_uris(&mut d);
assert_eq!(d["codeDescription"]["href"], "file:///tmp/help%20files/rule.html");
assert_eq!(encoded, "file:///a%20b/%E9%A1%B9.java");
}
#[cfg(unix)]
+1 -19
Переглянути файл
@@ -5,7 +5,6 @@
use serde::{Deserialize, Serialize};
use std::io;
use std::net::{IpAddr, Ipv4Addr};
use std::path::{Path, PathBuf};
/// The discovery file, relative to the app data directory.
@@ -40,9 +39,6 @@ pub fn kept_token(path: &Path) -> io::Result<String> {
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Discovery {
/// Where a client on this computer connects (the file of an IDE without this field: `127.0.0.1`).
#[serde(default = "loopback")]
pub address: IpAddr,
pub port: u16,
pub token: String,
/// The IDE's process.
@@ -51,10 +47,6 @@ pub struct Discovery {
pub version: String,
}
fn loopback() -> IpAddr {
IpAddr::V4(Ipv4Addr::LOCALHOST)
}
impl Discovery {
pub fn read(path: &Path) -> io::Result<Self> {
let bytes = std::fs::read(path)?;
@@ -111,13 +103,7 @@ mod tests {
fn round_trips_privately_and_only_its_owner_removes_it() {
let tmp = tempfile::tempdir().unwrap();
let path = discovery_path(tmp.path());
let ours = Discovery {
address: "100.92.72.95".parse().unwrap(),
port: 1234,
token: "t".into(),
pid: std::process::id(),
version: "0.1.0".into(),
};
let ours = Discovery { port: 1234, token: "t".into(), pid: std::process::id(), version: "0.1.0".into() };
ours.write(&path).unwrap();
assert_eq!(Discovery::read(&path).unwrap(), ours);
#[cfg(unix)]
@@ -132,10 +118,6 @@ mod tests {
ours.write(&path).unwrap();
Discovery::remove_if_ours(&path);
assert!(!path.exists());
// The file of an IDE that listened on 127.0.0.1 only has no address.
std::fs::write(&path, r#"{"port":1,"token":"t","pid":1,"version":"0.1.0"}"#).unwrap();
assert_eq!(Discovery::read(&path).unwrap().address, loopback());
}
#[test]
+6 -16
Переглянути файл
@@ -2,15 +2,14 @@
//! the IDE's built-in tools.
//!
//! - **Transport:** MCP Streamable HTTP (2025-06-18; 2025-03-26 and 2024-11-05
//! clients too) on `127.0.0.1:<random port>` (or another address of
//! [`ServerOptions`]), endpoint `/mcp`. Responses are
//! clients too) on `127.0.0.1:<random port>`, endpoint `/mcp`. Responses are
//! plain JSON (no SSE streams: `GET` is 405). Every request carries
//! `Authorization: Bearer <token>` (random, per run); requests from another
//! `Origin` or `Host` are refused (DNS rebinding).
//! - **Projects:** the `X-OpenIDEA-Project` header or the `?project=` query
//! names a folder; the server picks the open project containing it.
//! - **Discovery:** `<app-data>/mcp/server.json` (`address`, `port`, `token`,
//! `pid`, `version`; readable only by the user) while the server runs.
//! - **Discovery:** `<app-data>/mcp/server.json` (`port`, `token`, `pid`,
//! `version`; readable only by the user) while the server runs.
//! - **Stdio:** `<app exe> --mcp-stdio [--project <path>]` reads the discovery
//! file and relays newline-delimited JSON-RPC to the server ([`stdio`]), so
//! persisted client configurations hold no token.
@@ -24,7 +23,7 @@ mod server;
pub mod stdio;
pub use discovery::{DISCOVERY_FILE, Discovery, TOKEN_FILE, discovery_path, kept_token, token_path};
pub use server::{McpServer, ServerOptions, local_address};
pub use server::{McpServer, ServerOptions};
pub use tokio_util::sync::CancellationToken;
use std::path::{Path, PathBuf};
@@ -86,15 +85,9 @@ pub trait ToolRegistry: Send + Sync + 'static {
) -> Result<String, ToolCallError>;
}
/// The URL of the server at `127.0.0.1:<port>` for `project`.
/// The URL of the server at `port` for `project`.
pub fn endpoint_url(port: u16, project: &Path) -> String {
endpoint_url_at(std::net::Ipv4Addr::LOCALHOST.into(), port, project)
}
/// The URL of the server at `<address>:<port>` for `project`.
pub fn endpoint_url_at(address: std::net::IpAddr, port: u16, project: &Path) -> String {
let at = std::net::SocketAddr::new(address, port);
format!("http://{at}/mcp?project={}", encode_query(&project.to_string_lossy()))
format!("http://127.0.0.1:{port}/mcp?project={}", encode_query(&project.to_string_lossy()))
}
/// The stdio bridge's command line for `project`: `exe --mcp-stdio --project <project>`.
@@ -140,9 +133,6 @@ mod tests {
fn urls_and_commands_name_the_project() {
let url = endpoint_url(4321, Path::new("/home/me/my app/项目"));
assert_eq!(url, "http://127.0.0.1:4321/mcp?project=/home/me/my%20app/%E9%A1%B9%E7%9B%AE");
let ip = |s: &str| s.parse::<std::net::IpAddr>().unwrap();
assert_eq!(endpoint_url_at(ip("100.92.72.95"), 80, Path::new("/p")), "http://100.92.72.95:80/mcp?project=/p");
assert_eq!(endpoint_url_at(ip("fd7a::1"), 80, Path::new("/p")), "http://[fd7a::1]:80/mcp?project=/p");
assert_eq!(
stdio_command(Path::new("/Applications/OpenIDEA.app/Contents/MacOS/openidea"), Path::new("/p")),
["/Applications/OpenIDEA.app/Contents/MacOS/openidea", "--mcp-stdio", "--project", "/p"]
+20 -81
Переглянути файл
@@ -9,7 +9,6 @@ use hyper::header::{self, HeaderMap, HeaderValue};
use hyper::{Method, Request, Response, StatusCode};
use serde_json::{Value, json};
use std::convert::Infallible;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
use std::path::{Path, PathBuf};
use std::pin::Pin;
use std::sync::Arc;
@@ -41,8 +40,6 @@ type Body = Either<Full<Bytes>, EventStream>;
struct Shared {
registry: Arc<dyn ToolRegistry>,
token: String,
/// The address listened on (`0.0.0.0` / `::`: every address).
address: IpAddr,
port: u16,
requests: protocol::Requests,
cancel: CancellationToken,
@@ -51,7 +48,6 @@ struct Shared {
/// The running server. Stopping it (or dropping it) closes the port and
/// removes its discovery file.
pub struct McpServer {
address: IpAddr,
port: u16,
/// Why the port asked for in [`ServerOptions::port`] was not used.
port_unavailable: Option<String>,
@@ -64,10 +60,7 @@ pub struct McpServer {
/// How [`McpServer::start_with`] listens.
#[derive(Clone, Debug, Default)]
pub struct ServerOptions {
/// The address to listen on; `None`: `127.0.0.1`, this computer only. Another address of
/// this computer, or `0.0.0.0` / `::` for every address, lets other computers connect.
pub address: Option<IpAddr>,
/// Listen on `<address>:<port>`. When it can't be bound (in use, or reserved by the
/// Listen on `127.0.0.1:<port>`. When it can't be bound (in use, or reserved by the
/// system), a free port is taken instead and [`McpServer::port_unavailable`] says why.
/// `None`: a free port.
pub port: Option<u16>,
@@ -93,20 +86,15 @@ impl McpServer {
runtime: &tokio::runtime::Handle,
options: ServerOptions,
) -> std::io::Result<Self> {
let address = options.address.unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST));
let bind = |port: u16| {
std::net::TcpListener::bind(SocketAddr::new(address, port))
.map_err(|e| std::io::Error::new(e.kind(), format!("{}: {e}", SocketAddr::new(address, port))))
};
let (listener, port_unavailable) = match options.port {
Some(port) => match bind(port) {
Some(port) => match std::net::TcpListener::bind(("127.0.0.1", port)) {
Ok(listener) => (listener, None),
Err(e) => {
tracing::warn!("MCP server: port {port} unavailable ({e}); taking a free one");
(bind(0)?, Some(format!("port {port}: {e}")))
(std::net::TcpListener::bind(("127.0.0.1", 0))?, Some(format!("port {port}: {e}")))
}
},
None => (bind(0)?, None),
None => (std::net::TcpListener::bind(("127.0.0.1", 0))?, None),
};
listener.set_nonblocking(true)?;
let port = listener.local_addr()?.port();
@@ -120,7 +108,6 @@ impl McpServer {
};
if let Some(path) = &discovery {
let info = Discovery {
address: local_address(address),
port,
token: token.clone(),
pid: std::process::id(),
@@ -133,19 +120,13 @@ impl McpServer {
let shared = Arc::new(Shared {
registry,
token: token.clone(),
address,
port,
requests: Default::default(),
cancel: cancel.clone(),
});
runtime.spawn(accept(listener, shared, stopped));
tracing::info!("MCP server on {}", SocketAddr::new(address, port));
Ok(Self { address, port, port_unavailable, token, discovery, shutdown, cancel })
}
/// The address listened on (`0.0.0.0` / `::`: every address).
pub fn address(&self) -> IpAddr {
self.address
tracing::info!("MCP server on 127.0.0.1:{port}");
Ok(Self { port, port_unavailable, token, discovery, shutdown, cancel })
}
pub fn port(&self) -> u16 {
@@ -162,24 +143,14 @@ impl McpServer {
&self.token
}
/// The endpoint URL for `project`, at the address a client on this computer connects to.
/// The endpoint URL for `project`.
pub fn url(&self, project: &Path) -> String {
crate::endpoint_url_at(local_address(self.address), self.port, project)
crate::endpoint_url(self.port, project)
}
pub fn stop(self) {}
}
/// The address a client on this computer connects to for a server listening on `address`:
/// loopback for every address (`0.0.0.0` / `::`), else `address` itself.
pub fn local_address(address: IpAddr) -> IpAddr {
match address {
IpAddr::V4(a) if a.is_unspecified() => IpAddr::V4(Ipv4Addr::LOCALHOST),
IpAddr::V6(a) if a.is_unspecified() => IpAddr::V6(Ipv6Addr::LOCALHOST),
a => a,
}
}
impl Drop for McpServer {
fn drop(&mut self) {
self.cancel.cancel();
@@ -241,7 +212,7 @@ async fn respond(req: Request<Incoming>, shared: &Arc<Shared>, scope: String) ->
if req.uri().path() != "/mcp" {
return plain(StatusCode::NOT_FOUND, "not found: the endpoint is /mcp");
}
if !local_request(req.headers(), shared.address, shared.port) {
if !local_request(req.headers(), shared.port) {
return plain(StatusCode::FORBIDDEN, "requests from web pages are refused");
}
if !authorized(req.headers(), &shared.token) {
@@ -372,16 +343,11 @@ impl hyper::body::Body for EventStream {
}
}
/// No `Origin` other than this server's own, and a `Host` naming this server by IP address
/// (loopback, the address listened on, or any when listening on every address) or as
/// `localhost`: never another host name, so a web page (DNS rebinding) can't reach the tools
/// even before the token check.
fn local_request(headers: &HeaderMap, address: IpAddr, port: u16) -> bool {
/// No `Origin` other than this server's own, and a loopback `Host`: a web
/// page (DNS rebinding) can't reach the tools even before the token check.
fn local_request(headers: &HeaderMap, port: u16) -> bool {
let ours = |authority: &str| {
authority.eq_ignore_ascii_case(&format!("localhost:{port}"))
|| authority.parse::<SocketAddr>().is_ok_and(|a| {
a.port() == port && (a.ip().is_loopback() || a.ip() == address || address.is_unspecified())
})
["127.0.0.1", "localhost", "[::1]"].iter().any(|host| authority.eq_ignore_ascii_case(&format!("{host}:{port}")))
};
let host_ok = headers.get(header::HOST).is_none_or(|h| h.to_str().is_ok_and(ours));
let origin_ok = headers.get(header::ORIGIN).is_none_or(|o| {
@@ -568,7 +534,7 @@ mod tests {
}
#[test]
fn only_hosts_and_origins_of_this_server_pass() {
fn only_loopback_hosts_and_origins_of_this_server_pass() {
let headers = |pairs: &[(header::HeaderName, &str)]| {
let mut h = HeaderMap::new();
for (k, v) in pairs {
@@ -576,43 +542,16 @@ mod tests {
}
h
};
let loopback = IpAddr::V4(Ipv4Addr::LOCALHOST);
let host = |h: &str, address: IpAddr| local_request(&headers(&[(header::HOST, h)]), address, 80);
assert!(local_request(&headers(&[]), loopback, 80));
assert!(host("127.0.0.1:80", loopback));
assert!(host("[::1]:80", loopback));
assert!(local_request(&headers(&[]), 80));
assert!(local_request(&headers(&[(header::HOST, "127.0.0.1:80")]), 80));
assert!(local_request(
&headers(&[(header::HOST, "localhost:80"), (header::ORIGIN, "http://localhost:80")]),
loopback,
80
));
assert!(!host("evil.example:80", loopback));
assert!(!host("127.0.0.1:81", loopback));
assert!(!host("100.92.72.95:80", loopback), "another address of a loopback server");
assert!(!local_request(&headers(&[(header::ORIGIN, "https://evil.example")]), loopback, 80));
assert!(!local_request(&headers(&[(header::ORIGIN, "null")]), loopback, 80));
// Listening on one address: that address too, still never a host name.
let lan: IpAddr = "100.92.72.95".parse().unwrap();
assert!(host("100.92.72.95:80", lan) && host("127.0.0.1:80", lan));
assert!(!host("100.92.72.96:80", lan) && !host("evil.example:80", lan));
// Listening on every address: any IP address, still never a host name.
let every: IpAddr = "0.0.0.0".parse().unwrap();
assert!(host("192.168.1.7:80", every) && host("[fe80::1]:80", every));
assert!(!host("evil.example:80", every) && !host("192.168.1.7:81", every));
assert!(local_request(
&headers(&[(header::HOST, "192.168.1.7:80"), (header::ORIGIN, "http://192.168.1.7:80")]),
every,
80
));
}
#[test]
fn clients_on_this_computer_connect_to_loopback_for_every_address() {
let ip = |s: &str| s.parse::<IpAddr>().unwrap();
assert_eq!(local_address(ip("0.0.0.0")), ip("127.0.0.1"));
assert_eq!(local_address(ip("::")), ip("::1"));
assert_eq!(local_address(ip("100.92.72.95")), ip("100.92.72.95"));
assert!(!local_request(&headers(&[(header::HOST, "evil.example:80")]), 80));
assert!(!local_request(&headers(&[(header::HOST, "127.0.0.1:81")]), 80));
assert!(!local_request(&headers(&[(header::ORIGIN, "https://evil.example")]), 80));
assert!(!local_request(&headers(&[(header::ORIGIN, "null")]), 80));
}
#[test]
+2 -3
Переглянути файл
@@ -148,14 +148,13 @@ async fn post(
)
};
let info = Discovery::read(discovery).map_err(|_| not_running())?;
let at = std::net::SocketAddr::new(info.address, info.port);
let stream = tokio::net::TcpStream::connect(at).await.map_err(|_| not_running())?;
let stream = tokio::net::TcpStream::connect(("127.0.0.1", info.port)).await.map_err(|_| not_running())?;
let (mut sender, conn) = hyper::client::conn::http1::handshake(hyper_util::rt::TokioIo::new(stream))
.await
.map_err(|e| format!("cannot reach OpenIDEA: {e}"))?;
tokio::spawn(conn);
let request = Request::post(format!("/mcp?project={}", crate::encode_query(&project.to_string_lossy())))
.header(header::HOST, at.to_string())
.header(header::HOST, format!("127.0.0.1:{}", info.port))
.header(header::AUTHORIZATION, format!("Bearer {}", info.token))
.header("mcp-session-id", scope)
.header(header::CONTENT_TYPE, "application/json")
+1 -32
Переглянути файл
@@ -373,7 +373,7 @@ async fn a_fixed_port_and_token_are_used_and_a_taken_port_falls_back_to_a_free_o
s.server.take().unwrap().stop();
let free = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap().local_addr().unwrap().port();
let token = "ab".repeat(32);
let options = ServerOptions { port: Some(free), token: Some(token.clone()), ..Default::default() };
let options = ServerOptions { port: Some(free), token: Some(token.clone()) };
let registry = Arc::new(Registry { project: s.project.clone() });
let runtime = tokio::runtime::Handle::current();
s.server =
@@ -393,34 +393,3 @@ async fn a_fixed_port_and_token_are_used_and_a_taken_port_falls_back_to_a_free_o
second.port_unavailable()
);
}
#[tokio::test(flavor = "multi_thread")]
async fn another_listen_address_is_used_and_one_not_on_this_computer_fails() {
let mut s = setup();
s.server.take().unwrap().stop();
let registry = Arc::new(Registry { project: s.project.clone() });
let runtime = tokio::runtime::Handle::current();
// Every address: clients here use loopback; other computers name the server by an address of it.
let every = ServerOptions { address: Some("0.0.0.0".parse().unwrap()), ..Default::default() };
s.server = Some(McpServer::start_with(registry.clone(), Some(s.discovery.clone()), &runtime, every).unwrap());
let port = s.server().port();
assert_eq!(s.server().address().to_string(), "0.0.0.0");
assert!(s.server().url(&s.project).starts_with(&format!("http://127.0.0.1:{port}/mcp?")));
assert_eq!(Discovery::read(&s.discovery).unwrap().address.to_string(), "127.0.0.1");
let ping = json!({ "jsonrpc": "2.0", "id": 1, "method": "ping" }).to_string();
let request = |host: String| {
format!(
"POST /mcp HTTP/1.1\r\nHost: {host}\r\n{}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{ping}",
bearer(&s),
ping.len()
)
};
assert_eq!(http(port, &request(format!("192.0.2.7:{port}"))).await.0, 200);
assert_eq!(http(port, &request(format!("evil.example:{port}"))).await.0, 403, "never a host name");
// Not an address of this computer: the server does not start, and says which address.
let elsewhere = ServerOptions { address: Some("192.0.2.1".parse().unwrap()), ..Default::default() };
let error = McpServer::start_with(registry, None, &runtime, elsewhere).err().expect("no such address here");
assert!(error.to_string().contains("192.0.2.1"), "{error}");
}
+4 -5
Переглянути файл
@@ -209,11 +209,10 @@ impl Deployment<'_> {
let start = out.len();
// One `find` over SSH instead of a listing per folder (thousands of round trips on a
// big project); the same entries the walk below would keep.
let tree = tokio::select! {
tree = self.fs.tree(dir, self.exclude.prunable_names()) => tree,
() = self.cancel.cancelled() => return Err(IdeError::cancelled()),
};
if let Some(entries) = tree {
if let Some(entries) = self.fs.tree(dir, self.exclude.prunable_names()).await {
if self.cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let base = dir.trim_end_matches('/');
out.extend(entries.into_iter().filter(|e| {
let rel = e.path.strip_prefix(base).unwrap_or(&e.path);
+1 -1
Переглянути файл
@@ -403,7 +403,7 @@ async fn download_into_copies_only_the_selected_items() {
assert!(std::fs::read_dir(&elsewhere).unwrap().next().is_none(), "nothing written through the links");
}
let missing = [server.join("gone").to_str().unwrap().replace('\\', "/")];
let missing = [server.join("gone").to_str().unwrap().to_owned()];
let report = service
.download_into(&FsConnection::Local, &missing, &project, &CancellationToken::new(), &mut |_, _, _| {})
.await
-2
Переглянути файл
@@ -5,7 +5,6 @@
pub(crate) mod cmake;
mod dotnet;
mod zig;
use crate::RunConfig;
use ide_core::project_roots::expand_members;
@@ -73,7 +72,6 @@ pub(crate) fn detect_with_profiles(
}
}
}
detect_projects(root, &["build.zig"], zig::detect, &mut out);
dotnet::detect(root, &mut out);
detect_projects(root, &["Makefile", "makefile", "GNUmakefile"], detect_make, &mut out);
detect_projects(root, &["justfile", "Justfile", ".justfile"], detect_just, &mut out);
-232
Переглянути файл
@@ -1,232 +0,0 @@
//! Zig projects (`build.zig`): the build's steps (`zig build <step>`) and, for each executable it
//! declares, the installed binary (`zig-out/bin/<name>`), built first and debuggable with CodeLLDB.
//! Read from the build script's source: detecting configurations never runs the project's build code.
use super::{ConfigExt, config, read};
use crate::{BeforeLaunchTask, RunConfig};
use std::path::Path;
pub(super) const BUILD_ID: &str = "zig:build";
pub(super) fn detect(dir: &Path, out: &mut Vec<RunConfig>) {
let Some(src) = read(&dir.join("build.zig")) else { return };
let src = code_only(&src);
out.push(config(BUILD_ID, "zig build", "zig", "zig", &["build"]));
for step in steps(&src) {
// `install` is what plain `zig build` runs.
if step == "install" || step == "uninstall" {
continue;
}
let mut c = config(format!("zig:{step}"), format!("zig build {step}"), "zig", "zig", &["build", &step]);
if step == "test" || step.starts_with("test-") || step.starts_with("test_") {
c = c.test();
}
out.push(c);
}
for exe in executables(&src) {
let binary = if cfg!(windows) {
format!("zig-out\\bin\\{}.exe", exe.name)
} else {
format!("./zig-out/bin/{}", exe.name)
};
let mut c = config(format!("zig:exe:{}", exe.name), exe.name.clone(), "zig-application", &binary, &[])
.debug("codelldb");
c.extras.before_launch.push(BeforeLaunchTask { kind: "run".into(), target: BUILD_ID.into() });
if let Some(source) = exe.source {
c.extras.params.insert("source".into(), source);
}
out.push(c);
}
}
/// `src` with comments blanked out (Zig has only `//` comments) and string contents kept.
fn code_only(src: &str) -> String {
let mut out = String::with_capacity(src.len());
for line in src.lines() {
let mut in_string = false;
let mut escaped = false;
let mut end = line.len();
let bytes = line.as_bytes();
for (i, &b) in bytes.iter().enumerate() {
if in_string {
match b {
_ if escaped => escaped = false,
b'\\' => escaped = true,
b'"' => in_string = false,
_ => {}
}
} else if b == b'"' {
in_string = true;
} else if b == b'/' && bytes.get(i + 1) == Some(&b'/') {
end = i;
break;
}
}
out.push_str(&line[..end]);
out.push('\n');
}
out
}
/// The string literal starting at `s` (after optional whitespace), unescaped only for `\"`.
fn string_at(s: &str) -> Option<String> {
let rest = s.trim_start().strip_prefix('"')?;
let mut value = String::new();
let mut chars = rest.chars();
while let Some(c) = chars.next() {
match c {
'"' => return Some(value),
'\\' => value.push(chars.next()?),
c => value.push(c),
}
}
None
}
/// Step names from `<build>.step("name", "description")`, in order and without duplicates.
fn steps(src: &str) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for (i, _) in src.match_indices(".step(") {
let preceded = src[..i].chars().next_back().is_some_and(|c| c.is_alphanumeric() || c == '_');
if let Some(name) = string_at(&src[i + ".step(".len()..])
&& preceded
&& !name.is_empty()
&& !out.contains(&name)
{
out.push(name);
}
}
out
}
struct Executable {
name: String,
/// Root source file, relative to the build script's folder.
source: Option<String>,
}
/// `addExecutable(.{ .name = "…", … .root_source_file = <b>.path("…") … })`: the name at the
/// options' top level (an `imports` entry has its own `.name`) and the first root source file.
fn executables(src: &str) -> Vec<Executable> {
let mut out: Vec<Executable> = Vec::new();
for (start, _) in src.match_indices("addExecutable(") {
let body = &src[start + "addExecutable(".len()..];
let (mut depth, mut in_string, mut escaped) = (1usize, false, false);
let mut name = None;
let mut source = None;
for (i, c) in body.char_indices() {
if in_string {
match c {
_ if escaped => escaped = false,
'\\' => escaped = true,
'"' => in_string = false,
_ => {}
}
continue;
}
match c {
'"' => in_string = true,
'(' | '{' | '[' => depth += 1,
')' | '}' | ']' => {
depth -= 1;
if depth == 0 {
break;
}
}
'.' => {
let field = &body[i + 1..];
let value =
|key: &str| field.strip_prefix(key)?.trim_start().strip_prefix('=').map(str::trim_start);
if depth == 2 && name.is_none() {
name = value("name").and_then(string_at);
}
if source.is_none()
&& let Some(v) = value("root_source_file")
&& let Some(call) = v.find(".path(")
&& v[..call].chars().all(|c| c.is_alphanumeric() || c == '_')
{
source = string_at(&v[call + ".path(".len()..]);
}
}
_ => {}
}
}
if let Some(name) = name
&& !name.is_empty()
&& !out.iter().any(|e| e.name == name)
{
out.push(Executable { name, source });
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
const INIT: &str = r#"const std = @import("std");
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const mod = b.addModule("app", .{
.root_source_file = b.path("src/root.zig"),
.target = target,
});
// const old = b.addExecutable(.{ .name = "commented-out" });
const exe = b.addExecutable(.{
.name = "app",
.root_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.imports = &.{
.{ .name = "app", .module = mod },
},
}),
});
b.installArtifact(exe);
const tool = b.addExecutable(.{ .name = "gen-docs", .root_module = b.createModule(.{
.root_source_file = b.path("tools/gen_docs.zig"),
}) });
_ = tool;
const run_step = b.step("run", "Run the app");
const test_step = b.step("test", "Run tests");
const docs = b.step("docs", "Generate docs // not a comment");
_ = .{ run_step, test_step, docs };
}
"#;
#[test]
fn steps_and_executables_come_from_the_build_script() {
let src = code_only(INIT);
assert_eq!(steps(&src), ["run", "test", "docs"]);
let exes = executables(&src);
assert_eq!(
exes.iter().map(|e| (e.name.as_str(), e.source.as_deref())).collect::<Vec<_>>(),
[("app", Some("src/main.zig")), ("gen-docs", Some("tools/gen_docs.zig"))]
);
}
#[test]
fn a_zig_project_gets_build_steps_and_debuggable_executables() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("build.zig"), INIT).unwrap();
let mut out = Vec::new();
detect(dir.path(), &mut out);
let ids: Vec<_> = out.iter().map(|c| c.id.as_str()).collect();
assert_eq!(ids, ["zig:build", "zig:run", "zig:test", "zig:docs", "zig:exe:app", "zig:exe:gen-docs"]);
let test = out.iter().find(|c| c.id == "zig:test").unwrap();
assert!(test.is_test);
assert_eq!(
(test.command.as_str(), test.args.as_slice()),
("zig", ["build".to_owned(), "test".to_owned()].as_slice())
);
let app = out.iter().find(|c| c.id == "zig:exe:app").unwrap();
assert_eq!(app.kind, "zig-application");
assert!(app.command.ends_with(if cfg!(windows) { "app.exe" } else { "zig-out/bin/app" }));
assert_eq!(app.debug_adapter.as_deref(), Some("codelldb"));
assert_eq!(app.extras.before_launch[0].kind, "run");
assert_eq!(app.extras.before_launch[0].target, BUILD_ID);
assert_eq!(app.extras.params.get("source").map(String::as_str), Some("src/main.zig"));
}
}
+2 -28
Переглянути файл
@@ -1,8 +1,7 @@
//! `.vscode/launch.json`: JSON with comments and trailing commas, `configurations` and
//! `compounds`. Python (`debugpy`, the older `python`) and Node.js (`node`, `pwa-node`,
//! `node-terminal`) launches become run configurations of the matching kind, their attach
//! requests Python Remote Debug / Attach to Node.js configurations. A browser launch
//! (`chrome`, `msedge` and their `pwa-` names) becomes a JavaScript Debug configuration. Variables with a fixed
//! requests Python Remote Debug / Attach to Node.js configurations. Variables with a fixed
//! meaning (`${workspaceFolder}`, `${userHome}`, `${env:NAME}`…) are replaced when importing;
//! an entry using one that depends on the moment VS Code launches it (`${file}`,
//! `${input:…}`, `${command:…}`) is skipped.
@@ -47,7 +46,6 @@ const PYTHON_ATTACH: &[&str] = &["connect", "host", "port", "pathMappings"];
const NODE_LAUNCH: &[&str] = &["program", "args", "runtimeExecutable", "runtimeArgs"];
const NODE_ATTACH: &[&str] = &["address", "port", "localRoot", "remoteRoot"];
const NODE_TERMINAL: &[&str] = &["command"];
const BROWSER_LAUNCH: &[&str] = &["url", "webRoot"];
const COMPOUND: &[&str] = &["configurations"];
const PACKAGE_MANAGERS: &[&str] = &["npm", "pnpm", "yarn", "bun"];
@@ -223,14 +221,7 @@ impl Cx<'_> {
("node" | "pwa-node", "launch") => (self.node_launch(name, obj)?, NODE_LAUNCH),
("node" | "pwa-node", "attach") => (self.node_attach(name, obj)?, NODE_ATTACH),
("node-terminal", "launch") => (self.node_terminal(name, obj)?, NODE_TERMINAL),
("chrome" | "pwa-chrome" | "msedge" | "pwa-msedge", "launch") => {
(self.browser_launch(name, obj, ty)?, BROWSER_LAUNCH)
}
(
"debugpy" | "python" | "node" | "pwa-node" | "node-terminal" | "chrome" | "pwa-chrome" | "msedge"
| "pwa-msedge",
other,
) => {
("debugpy" | "python" | "node" | "pwa-node" | "node-terminal", other) => {
return Err(SkipReason::UnsupportedRequest { request: other.to_owned() });
}
(other, _) => return Err(SkipReason::UnsupportedType { type_: other.to_owned() }),
@@ -349,23 +340,6 @@ impl Cx<'_> {
Ok(c)
}
/// A page opened in Chrome or Edge with the debugger attached: a JavaScript Debug configuration.
fn browser_launch(&self, name: &str, obj: &Map<String, Value>, ty: &str) -> Res<RunConfig> {
let Some(url) = self.string(obj, "url")? else {
return Err(SkipReason::NothingToRun);
};
let browser = if ty.ends_with("msedge") { "edge" } else { "chrome" };
let mut c = config(name, "js-browser", String::new(), Vec::new(), Some("js-debug"));
c.extras.params = BTreeMap::from([("url".into(), url), ("browser".into(), browser.into())]);
if let Some(web_root) = self.string(obj, "webRoot")? {
let web_root = self.path_from(web_root, self.root);
if web_root != "." {
c.extras.params.insert("webRoot".into(), web_root);
}
}
Ok(c)
}
/// `localRoot` (relative to the project root, empty for the root itself) and `remoteRoot`.
fn roots(&self, obj: &Map<String, Value>, c: &mut RunConfig) -> Res<()> {
if let Some(local) = self.string(obj, "localRoot")? {
+1 -37
Переглянути файл
@@ -229,12 +229,7 @@ fn nextjs_recipes() {
assert_eq!(server.args, s(&["run", "dev"]));
assert_eq!(server.debug_adapter.as_deref(), Some("js-debug"));
let client = get(&r, "Next.js: debug client-side");
assert_eq!((client.kind.as_str(), client.command.as_str()), ("js-browser", ""));
assert_eq!(client.debug_adapter.as_deref(), Some("js-debug"));
assert_eq!(client.extras.params["url"], "http://localhost:3000");
assert_eq!(client.extras.params["browser"], "chrome");
assert!(ignored(&r, "Next.js: debug client-side").is_empty());
assert_eq!(reason(&r, "Next.js: debug client-side"), &SkipReason::UnsupportedType { type_: "chrome".into() });
let full = get(&r, "Next.js: debug full stack");
assert_eq!((full.kind.as_str(), full.command.as_str()), ("node", "node"));
@@ -408,37 +403,6 @@ fn attach_requests_become_remote_debug_configurations() {
assert_eq!(reason(&r, "Odd"), &SkipReason::UnsupportedRequest { request: "test".into() });
}
#[test]
fn browser_launches_become_javascript_debug_configurations() {
let (_dir, r) = run(r#"{
"configurations": [
{ "name": "Edge", "type": "pwa-msedge", "request": "launch", "url": "http://localhost:5173/app",
"webRoot": "${workspaceFolder}/web", "sourceMaps": true, "userDataDir": false },
{ "name": "Root", "type": "chrome", "request": "launch", "url": "http://localhost:8080",
"webRoot": "${workspaceFolder}" },
{ "name": "Page file", "type": "chrome", "request": "launch", "file": "${workspaceFolder}/index.html" },
{ "name": "Running browser", "type": "chrome", "request": "attach", "port": 9222 }
]
}"#);
let edge = get(&r, "Edge");
assert_eq!((edge.kind.as_str(), edge.debug_adapter.as_deref()), ("js-browser", Some("js-debug")));
assert_eq!(
edge.extras.params,
BTreeMap::from([
("browser".into(), "edge".into()),
("url".into(), "http://localhost:5173/app".into()),
("webRoot".into(), "web".into())
])
);
assert_eq!(ignored(&r, "Edge"), ["userDataDir"]);
// The project root is the default web root.
assert!(!get(&r, "Root").extras.params.contains_key("webRoot"));
assert_eq!(reason(&r, "Page file"), &SkipReason::NothingToRun);
assert_eq!(reason(&r, "Running browser"), &SkipReason::UnsupportedRequest { request: "attach".into() });
}
#[test]
fn the_block_for_this_os_overrides_the_entry() {
let os = if cfg!(windows) {
+4 -95
Переглянути файл
@@ -1168,22 +1168,6 @@ pub fn configure_server(cfg: &mut LanguageServerConfig, root: &Path, server_root
let plugin = vue::tsserver_plugin(&vue);
merge_init_options(cfg, json!({ "vtsls": { "tsserver": { "globalPlugins": [plugin] } } }));
cfg.tsserver_project_extras.extensions = vec![vue::EXTENSION.into()];
// Hybrid mode: one program holds every .vue's virtual TypeScript, so the
// heap must clear it (vtsls' default 3072 dies in a GC death spiral), and
// the syntax tsserver duplicates the plugin and its file watching.
merge_init_options(
cfg,
json!({ "typescript": { "tsserver": { "useSyntaxServer": "never", "maxTsServerMemory": 8192 } } }),
);
// A configured project resolves its own types; the typings installer only
// serves inferred projects and keeps scanning npm in the background.
if server_root.ancestors().any(|d| d.join("tsconfig.json").is_file() || d.join("jsconfig.json").is_file()) {
merge_init_options(cfg, json!({ "typescript": { "disableAutomaticTypeAcquisition": true } }));
}
}
let memory = s.languages.typescript.tsserver_max_memory;
if name == "vtsls" && memory > 0 {
merge_init_options(cfg, json!({ "typescript": { "tsserver": { "maxTsServerMemory": memory } } }));
}
if name == "vtsls"
&& cfg.tsserver_project
@@ -1830,17 +1814,10 @@ pub fn without_derived_in(config: &RunConfig, root: &Path, fallback: Option<&Pat
if path.is_absolute()
&& let Some(name) = path.file_name().map(|n| n.to_string_lossy().into_owned())
{
// Generated configurations name the bare program (`java`), also on Windows where the
// derived path ends in `java.exe`.
let bare =
name.len().checked_sub(4).filter(|&at| name[at..].eq_ignore_ascii_case(".exe")).map(|at| &name[..at]);
for candidate in bare.into_iter().chain([name.as_str()]) {
let mut probe = out.clone();
probe.command = candidate.to_owned();
if applied(&probe).command == config.command {
out.command = candidate.to_owned();
break;
}
let mut probe = out.clone();
probe.command = name.clone();
if applied(&probe).command == config.command {
out.command = name;
}
}
out
@@ -2127,74 +2104,6 @@ mod gradle_runtime_tests {
mod maven_linkage_tests {
use super::*;
#[test]
fn hybrid_vtsls_gets_one_server_a_full_heap_and_no_type_acquisition() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
// The Vue package the hybrid block finds through the command's package dir.
let bin = root.join("tools/node_modules/.bin/vue-language-server");
let pkg = root.join("tools/node_modules/@vue/language-server/package.json");
std::fs::create_dir_all(bin.parent().unwrap()).unwrap();
std::fs::create_dir_all(pkg.parent().unwrap()).unwrap();
std::fs::write(&bin, "#!/bin/sh\n").unwrap();
std::fs::write(&pkg, "{}").unwrap();
let mut s = Settings::default();
s.tools
.insert(vue::SERVER.into(), ide_core::settings::ToolSettings { path: bin.to_string_lossy().into_owned() });
std::fs::write(root.join("tsconfig.json"), "{}").unwrap();
let mut cfg = custom_server("vtsls", &LanguageServerSettings::default());
configure_server(&mut cfg, root, root, &s);
let options = serde_json::to_value(cfg.initialization_options).unwrap();
assert_eq!(options.pointer("/typescript/tsserver/useSyntaxServer"), Some(&json!("never")));
assert_eq!(options.pointer("/typescript/tsserver/maxTsServerMemory"), Some(&json!(8192)));
assert_eq!(options.pointer("/typescript/disableAutomaticTypeAcquisition"), Some(&json!(true)));
// A user-set limit wins over the automatic one.
s.languages.typescript.tsserver_max_memory = 6144;
let mut cfg = custom_server("vtsls", &LanguageServerSettings::default());
configure_server(&mut cfg, root, root, &s);
let options = serde_json::to_value(cfg.initialization_options).unwrap();
assert_eq!(options.pointer("/typescript/tsserver/maxTsServerMemory"), Some(&json!(6144)));
}
#[test]
fn hybrid_without_a_config_keeps_type_acquisition_and_plain_vtsls_stays_default() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let bin = root.join("tools/node_modules/.bin/vue-language-server");
let pkg = root.join("tools/node_modules/@vue/language-server/package.json");
std::fs::create_dir_all(bin.parent().unwrap()).unwrap();
std::fs::create_dir_all(pkg.parent().unwrap()).unwrap();
std::fs::write(&bin, "#!/bin/sh\n").unwrap();
std::fs::write(&pkg, "{}").unwrap();
let mut s = Settings::default();
s.tools
.insert(vue::SERVER.into(), ide_core::settings::ToolSettings { path: bin.to_string_lossy().into_owned() });
// No tsconfig/jsconfig anywhere: inferred projects still want the typings installer.
let mut cfg = custom_server("vtsls", &LanguageServerSettings::default());
configure_server(&mut cfg, root, root, &s);
let options = serde_json::to_value(cfg.initialization_options).unwrap();
assert!(options.pointer("/typescript/disableAutomaticTypeAcquisition").is_none());
assert_eq!(options.pointer("/typescript/tsserver/maxTsServerMemory"), Some(&json!(8192)));
// Without the Vue package there is no hybrid mode: nothing is injected. The
// command points at a bare binary, so the package lookup finds nothing
// (a bare name would resolve on the host's PATH and break the test).
std::fs::write(root.join("bare-vue-language-server"), "#!/bin/sh\n").unwrap();
s.tools
.insert(vue::SERVER.into(), ide_core::settings::ToolSettings { path: "bare-vue-language-server".into() });
let mut cfg = custom_server("vtsls", &LanguageServerSettings::default());
configure_server(&mut cfg, root, root, &s);
let options = serde_json::to_value(cfg.initialization_options).unwrap();
assert!(options.pointer("/typescript/tsserver/useSyntaxServer").is_none());
assert!(options.pointer("/typescript/tsserver/maxTsServerMemory").is_none());
// A user-set limit applies to plain TypeScript projects too.
s.languages.typescript.tsserver_max_memory = 12288;
let mut cfg = custom_server("vtsls", &LanguageServerSettings::default());
configure_server(&mut cfg, root, root, &s);
let options = serde_json::to_value(cfg.initialization_options).unwrap();
assert_eq!(options.pointer("/typescript/tsserver/maxTsServerMemory"), Some(&json!(12288)));
assert!(options.pointer("/typescript/tsserver/useSyntaxServer").is_none());
}
#[test]
fn default_maven_repository_reads_the_user_settings() {
let tmp = tempfile::tempdir().unwrap();
+1 -1
Переглянути файл
@@ -35,7 +35,7 @@ pub(crate) fn configure(cfg: &mut LanguageServerConfig, root: &Path, server_root
});
if node.is_none() || find_command("node", &search, server_root).is_none() {
cfg.launch_error = Some(
"Astro Language Server needs Node.js. Install it or select its executable in Settings › Languages & Frameworks › JavaScript Runtime."
"Astro Language Server needs Node.js. Install it or select its executable in Settings › Languages & Frameworks › Node.js."
.into(),
);
return;
-23
Переглянути файл
@@ -332,12 +332,6 @@ fn cached_cancellable(
.unwrap_or_default()
}
/// What `ecosystem` last found under `root` with the same settings, from before its manifests changed.
fn last_found(root: &Path, ecosystem: &'static str, key: &str) -> Option<Vec<LibraryGroup>> {
let cache = CACHE.lock().unwrap_or_else(|e| e.into_inner());
cache.get(&(root.to_path_buf(), ecosystem)).filter(|c| c.key == key).map(|c| c.groups.clone())
}
fn cached_optional(
root: &Path,
ecosystem: &'static str,
@@ -813,9 +807,6 @@ fn rust_groups(root: &Path, s: &Settings, locked: bool, cancel: &CancellationTok
let roots = parse_cargo_metadata(&json)?;
Some(group("Cargo dependencies", "cargo", named_roots(roots)).into_iter().collect())
});
// An edited manifest that names crates not downloaded yet fails offline until the next
// resolution writes Cargo.lock: the last graph stays meanwhile, as a failed sync keeps the model.
let graph = graph.or_else(|| last_found(root, "cargo", &r.toolchain));
out.extend(graph.unwrap_or_else(|| {
let home = ide_core::process::host_var_os("CARGO_HOME")
.map(PathBuf::from)
@@ -1116,20 +1107,6 @@ mod go_cache_tests {
assert_eq!(groups[0].kind, "cargo");
assert_eq!(groups[0].roots[0].path, source);
assert_eq!(std::fs::read_to_string(calls).unwrap().lines().count(), 1);
// The manifest now names a crate that isn't downloaded: the offline graph fails, the last one stays.
std::fs::write(
bin.join("cargo"),
format!(
"#!/bin/sh\nif [ \"$1\" = locate-project ]; then echo '{}'; exit; fi\nexit 101\n",
root.join("Cargo.toml").display()
),
)
.unwrap();
std::fs::write(root.join("Cargo.toml"), "[workspace]\nmembers=['members/*']\n# edited\n").unwrap();
let groups = project_libraries(root, &settings, &[]);
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].roots[0].path, source);
}
#[cfg(unix)]
+5 -14
Переглянути файл
@@ -194,23 +194,14 @@ impl Session {
}
channel.eof().await.map_err(|e| self.failed(e))?;
let mut out = ExecOutput::default();
loop {
// After the exit status, output still in flight is read until the channel goes quiet: a
// background child that inherited stdout keeps the channel open past the exit (as in `spawn`).
let msg = match out.code {
None => channel.wait().await,
Some(_) => tokio::time::timeout(EXIT_DRAIN, channel.wait()).await.ok().flatten(),
};
while let Some(msg) = channel.wait().await {
match msg {
Some(ChannelMsg::Data { data }) => out.stdout.extend_from_slice(&data),
Some(ChannelMsg::ExtendedData { data, ext: 1 }) => out.stderr.extend_from_slice(&data),
Some(ChannelMsg::ExitStatus { exit_status }) => out.code = Some(exit_status),
Some(_) => {}
None => break,
ChannelMsg::Data { data } => out.stdout.extend_from_slice(&data),
ChannelMsg::ExtendedData { data, ext: 1 } => out.stderr.extend_from_slice(&data),
ChannelMsg::ExitStatus { exit_status } => out.code = Some(exit_status),
_ => {}
}
}
// Closed here, the channel doesn't stay open on the server for that child.
let _ = channel.close().await;
Ok(out)
}
+1 -23
Переглянути файл
@@ -1,8 +1,7 @@
//! An in-process SSH server for tests (feature `testing`): password `dev` /
//! `secret`, commands answered by a closure, SFTP served from a local
//! folder (remote `/x` is `<root>/x`), and a shell that echoes its input
//! (`exit` ends it with status 0). `direct-tcpip` channels are forwarded to local TCP
//! ports, and refused when nothing listens there.
//! (`exit` ends it with status 0).
use russh::server::{self, Auth, ChannelOpenHandle, Msg, Server as _, Session};
use russh::{Channel, ChannelId};
@@ -72,27 +71,6 @@ impl server::Handler for Conn {
Ok(())
}
async fn channel_open_direct_tcpip(
&mut self,
channel: Channel<Msg>,
host: &str,
port: u32,
_originator_address: &str,
_originator_port: u32,
reply: ChannelOpenHandle,
_session: &mut Session,
) -> Result<(), Self::Error> {
let Ok(mut upstream) = tokio::net::TcpStream::connect((host, port as u16)).await else {
return Ok(());
};
reply.accept().await;
tokio::spawn(async move {
let mut stream = channel.into_stream();
let _ = tokio::io::copy_bidirectional(&mut stream, &mut upstream).await;
});
Ok(())
}
async fn exec_request(
&mut self,
channel: ChannelId,
-14
Переглянути файл
@@ -321,20 +321,6 @@ async fn a_process_that_exits_finishes_even_while_its_channel_stays_open() {
assert_eq!((code, seen.lock().as_str()), (Some(3), "ran: linger"));
}
#[tokio::test]
async fn a_command_that_exits_returns_even_while_its_channel_stays_open() {
let env = env().await;
let session = env.connect_trusted(&env.target(SshAuth::Password), Some("secret")).await.ok().unwrap();
let out = tokio::time::timeout(std::time::Duration::from_secs(10), session.exec("linger", None))
.await
.expect("the exit status must end the command")
.unwrap();
assert_eq!(
(out.code, out.stdout.as_slice(), out.stderr.as_slice()),
(Some(3), b"ran: linger".as_slice(), b"oops".as_slice())
);
}
#[tokio::test]
async fn upload_reports_a_command_that_stops_reading() {
let env = env().await;
-12
Переглянути файл
@@ -41,14 +41,6 @@ pub trait Backend: Send + Sync {
/// as `unstaged`.
fn status(&self) -> IdeResult<Vec<FileStatus>>;
/// Working-area changes for `paths` only (absolute; those outside the root
/// are ignored), which is what a watcher batch needs. The default is the
/// full status: always correct, just not proportional to the batch.
fn status_paths(&self, paths: &[PathBuf]) -> IdeResult<Vec<FileStatus>> {
let _ = paths;
self.status()
}
/// Text of `path` at `rev`; `None` when absent there.
fn file_at(&self, path: &Path, rev: Option<&str>, encoding: TextEncoding) -> IdeResult<Option<String>>;
@@ -197,10 +189,6 @@ impl Backend for Repo {
Repo::status(self)
}
fn status_paths(&self, paths: &[PathBuf]) -> IdeResult<Vec<FileStatus>> {
Repo::status_paths(self, paths)
}
fn file_at(&self, path: &Path, rev: Option<&str>, encoding: TextEncoding) -> IdeResult<Option<String>> {
Repo::file_at(self, path, rev, encoding)
}
+2 -33
Переглянути файл
@@ -764,24 +764,6 @@ impl Repo {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
status::status(self)
}
/// Status of `paths` only (absolute; those outside the repository are ignored), for the
/// dirty-scope refresh after a watcher batch. An empty list is the full status.
pub fn status_paths(&self, paths: &[PathBuf]) -> IdeResult<Vec<FileStatus>> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
// Restricted Mode starts no program of the repository, and the scoped read is `git status`
// with a pathspec: answer with the full gitoxide status instead.
if !self.execution_allowed.load(std::sync::atomic::Ordering::Acquire) {
return status::status(self);
}
if paths.is_empty() {
return status::status_paths(self, &[]);
}
let rel: Vec<String> = paths.iter().filter_map(|p| self.given_rel_path(p).ok()).collect();
if rel.is_empty() {
return Ok(Vec::new());
}
status::status_paths(self, &rel)
}
pub fn branch_info(&self) -> IdeResult<BranchInfo> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
refs::branch_info(self)
@@ -884,12 +866,6 @@ impl Repo {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
write::blame(self, path, current_text, revision, encoding)
}
/// Adds `commit` to the list `blame.ignoreRevsFile` names, so annotate skips it; with none
/// configured, to `.git-blame-ignore-revs` at the worktree root, which the local config then names.
pub fn ignore_blame_revision(&self, commit: &str) -> IdeResult<()> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
write::ignore_blame_revision(self, commit)
}
/// History for Selection: commits that changed `lines` (0-based, inclusive, of the
/// current text: `current_text`, or the file on disk), mapped to HEAD's lines first.
pub fn block_history(
@@ -1268,17 +1244,10 @@ impl Repo {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
write::rebase(self, onto)
}
/// `paths` (absolute) stashes only those files; empty stashes the whole working tree.
/// `keep_index`: changes already staged stay in the index (and the working tree).
pub fn stash_push(
&self,
message: Option<&str>,
include_untracked: bool,
keep_index: bool,
paths: &[PathBuf],
) -> IdeResult<()> {
pub fn stash_push(&self, message: Option<&str>, include_untracked: bool, keep_index: bool) -> IdeResult<()> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
write::stash_push(self, message, include_untracked, keep_index, paths)
write::stash_push(self, message, include_untracked, keep_index)
}
/// `reinstate_index`: also restore what was staged when the stash was made.
pub fn stash_apply(&self, index: u32, pop: bool, reinstate_index: bool) -> IdeResult<()> {
+1 -1
Переглянути файл
@@ -148,7 +148,7 @@ impl Repo {
}
/// `path` relative to the worktree, spelled as given.
pub(crate) fn given_rel_path(&self, path: &Path) -> IdeResult<String> {
fn given_rel_path(&self, path: &Path) -> IdeResult<String> {
let rel = if path.is_absolute() {
self.strip_root(path)
.ok_or_else(|| IdeError::invalid(format!("{} is outside the repository", path.display())))?
+2 -19
Переглянути файл
@@ -146,34 +146,17 @@ impl Repos {
/// The reads go through [`Backend`], the Changes-view surface — repositories of a
/// future second backend slot in here once `Repos` learns to hold them.
pub fn status(&self) -> IdeResult<Vec<FileStatus>> {
let groups: Vec<(Arc<Repo>, Vec<PathBuf>)> = self.repos.iter().map(|repo| (repo.clone(), Vec::new())).collect();
self.read_status(groups, false)
}
/// [`status`](Self::status) for `paths` only: each path goes to its innermost repository,
/// so a repository without one is not read at all and a nested repository keeps its own
/// files (the enclosing one's "untracked folder" entry for them is dropped).
pub fn status_paths(&self, paths: &[PathBuf]) -> IdeResult<Vec<FileStatus>> {
self.read_status(self.group(paths), true)
}
/// Reads one status per repository: the whole working tree, or `scoped` the paths it was
/// given. A nested repository reports its own files, so the enclosing one's "untracked
/// folder" entry for it is dropped. A repository that cannot be read is left out; fails
/// only when all fail.
fn read_status(&self, groups: Vec<(Arc<Repo>, Vec<PathBuf>)>, scoped: bool) -> IdeResult<Vec<FileStatus>> {
let mut out = Vec::new();
let mut first_error = None;
let mut read = 0;
for (repo, mine) in groups {
for repo in &self.repos {
let nested: Vec<&Path> = self
.repos
.iter()
.map(|r| r.root())
.filter(|r| *r != repo.root() && r.starts_with(repo.root()))
.collect();
let backend = repo.as_ref() as &dyn Backend;
let status = match if scoped { backend.status_paths(&mine) } else { backend.status() } {
let status = match (repo.as_ref() as &dyn Backend).status() {
Ok(status) => {
read += 1;
status
+3 -47
Переглянути файл
@@ -24,38 +24,9 @@ pub(crate) fn status(r: &Repo) -> IdeResult<Vec<FileStatus>> {
tracing::debug!("status via gitoxide failed, asking git: {}", e.message);
cli_status(r, &ignored)
})?;
Ok(collect(r, &ignored, map, conflicts))
}
/// Status of `paths` only (repository-relative, `/`-separated): the dirty-scope refresh.
///
/// A watcher batch touches a few files, and the full status walks the whole tree; git's own
/// pathspec filtering keeps that batch proportional to what changed. An empty list is the
/// full status, so a caller that lost the paths still gets a correct answer.
pub(crate) fn status_paths(r: &Repo, paths: &[String]) -> IdeResult<Vec<FileStatus>> {
// Pathspecs always take `/`, and a path that *is* the repository root names everything: both
// matter, because `Repo::status_paths` hands over paths as the platform spells them.
let patterns: Vec<String> =
paths.iter().map(|path| path.replace('\\', "/")).filter(|path| !path.is_empty()).collect();
if patterns.is_empty() {
return status(r);
}
let ignored = r.ignored.read().unwrap_or_else(|p| p.into_inner()).clone();
// `git status` with the pathspec, for the same reason the full read falls back to it: gitoxide's
// in-process pathspec matched nothing for a plain relative path here, and a silent empty
// answer is worse than the ~40 ms a `git` process costs on Windows.
let (map, conflicts) = cli_status_paths(r, &ignored, &patterns)?;
Ok(collect(r, &ignored, map, conflicts))
}
fn collect(
r: &Repo,
ignored: &IgnoredNames,
map: BTreeMap<String, Entry>,
conflicts: HashSet<String>,
) -> Vec<FileStatus> {
let mut disk = r.gix().is_ok_and(|repo| crate::repo::ignores_case(&repo)).then(DiskSpelling::default);
map.into_iter()
Ok(map
.into_iter()
.filter(|(_, e)| e.staged.is_some() || e.unstaged.is_some())
// Ignored names mean "the IDE treats these as nonexistent": hide their tracked
// changes too, not just untracked hits above. Conflicts stay visible so merges
@@ -80,7 +51,7 @@ fn collect(
renamed_from: e.renamed_from,
}
})
.collect()
.collect())
}
/// Spells repository paths the way a case-insensitive disk does, so that the tree, tabs and
@@ -245,21 +216,6 @@ fn cli_status(r: &Repo, ignored: &IgnoredNames) -> IdeResult<Entries> {
Ok(parse_porcelain_v2(&String::from_utf8_lossy(&out.stdout), ignored))
}
/// `git status` for `paths` only, parsed and filtered like the full one. `--literal-pathspecs`
/// keeps a `*` or `[` in a file name from being read as a pattern.
fn cli_status_paths(r: &Repo, ignored: &IgnoredNames, paths: &[String]) -> IdeResult<Entries> {
let overrides = crate::cli::filter_overrides(r.root(), &ide_core::CancellationToken::new())?;
let mut args: Vec<&str> = overrides.iter().map(String::as_str).collect();
args.extend(["--literal-pathspecs", "status", "--porcelain=v2", "-z", "--untracked-files=all"]);
if !crate::execution_allowed() {
args.push("--ignore-submodules=dirty");
}
args.push("--");
args.extend(paths.iter().map(String::as_str));
let out = crate::cli::run(r.root(), &args, None)?;
Ok(parse_porcelain_v2(&String::from_utf8_lossy(&out.stdout), ignored))
}
fn parse_porcelain_v2(out: &str, ignored: &IgnoredNames) -> Entries {
let kind = |c: u8| match c {
b'M' | b'T' => Some(ChangeKind::Modified),
+15 -124
Переглянути файл
@@ -43,70 +43,6 @@ fn run_paths(r: &Repo, args: &[&str], paths: &[String]) -> IdeResult<()> {
cli::run_paths(r.root(), &base, paths).map(|_| ())
}
/// The annotate ignore list at the worktree root: git's conventional `blame.ignoreRevsFile`.
pub const BLAME_IGNORE_REVS_FILE: &str = ".git-blame-ignore-revs";
/// The file `blame.ignoreRevsFile` names, resolved from the worktree root as git does (it may not
/// exist). The value comes from the gitoxide config snapshot, which Restricted Mode still parses,
/// so resolving it starts no program.
fn configured_ignore_revs(r: &Repo) -> Option<PathBuf> {
let repo = r.gix().ok()?;
let configured = repo.config_snapshot().string("blame.ignoreRevsFile")?;
let configured = configured.to_str_lossy();
let configured = configured.trim();
if configured.is_empty() {
return None;
}
let path = Path::new(configured);
Some(if path.is_absolute() { path.to_path_buf() } else { r.root().join(path) })
}
/// Arguments for the file `blame.ignoreRevsFile` names, which git does not accept as an
/// absent file: with a value configured but missing from disk, `--no-ignore-revs-file`
/// clears it so annotate still works.
fn ignore_revs_args(r: &Repo) -> Vec<String> {
match configured_ignore_revs(r) {
Some(path) if path.is_file() => vec!["--ignore-revs-file".to_string(), path.to_string_lossy().into_owned()],
Some(_) => vec!["--no-ignore-revs-file".to_string()],
None => Vec::new(),
}
}
/// `existing` (an ignore list's text) with `commit` appended: lines keep their text and order, a
/// listed commit isn't repeated, and the result ends with a newline in the list's line ending.
fn append_revision(existing: &str, commit: &str) -> String {
let revision = commit.trim();
let newline = if existing.contains("\r\n") { "\r\n" } else { "\n" };
let mut lines: Vec<&str> = existing.lines().collect();
if !revision.is_empty() && !lines.iter().any(|line| line.trim() == revision) {
lines.push(revision);
}
if lines.is_empty() { String::new() } else { lines.join(newline) + newline }
}
/// Adds `commit` to the list `blame.ignoreRevsFile` names; with none configured, to
/// `.git-blame-ignore-revs` at the worktree root, which the local config then names.
pub(crate) fn ignore_blame_revision(r: &Repo, commit: &str) -> IdeResult<()> {
let configured = configured_ignore_revs(r);
let path = configured.clone().unwrap_or_else(|| r.root().join(BLAME_IGNORE_REVS_FILE));
let existing = match std::fs::read_to_string(&path) {
Ok(text) => text,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(e) => return Err(e.into()),
};
let updated = append_revision(&existing, commit);
if updated != existing {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
std::fs::write(&path, updated)?;
}
if configured.is_none() {
cli::run(r.root(), &["config", "--local", "blame.ignoreRevsFile", BLAME_IGNORE_REVS_FILE], None)?;
}
Ok(())
}
pub(crate) fn blame(
r: &Repo,
path: &Path,
@@ -125,9 +61,8 @@ pub(crate) fn blame(
Err(error) => return Err(error.into()),
};
let text = current_text.map(str::to_owned).unwrap_or_else(|| decode(&disk, encoding).into_owned());
let ignore = ignore_revs_args(r);
if encoding.has_ascii_conversion() {
return converted_blame(r, path, &rel, &text, revision, encoding, &ignore);
return converted_blame(r, path, &rel, &text, revision, encoding);
}
let contents = match current_text {
Some(_) if crate::diff::normalize_eol(&text) == crate::diff::normalize_eol(&decode(&disk, encoding)) => disk,
@@ -159,32 +94,23 @@ pub(crate) fn blame(
// older releases reject the pair (`fatal: cannot use --contents with final
// commit object name`) and take the same plain path — the edited lines are
// marked uncommitted below either way (issue #755).
let contents_args = {
let mut args = vec!["--literal-pathspecs", "blame", "--porcelain"];
args.extend(ignore.iter().map(String::as_str));
args.extend(["--contents", "-", revision, "--", &rel]);
args
};
let plain_args: Vec<&str> = {
let mut args = vec!["--literal-pathspecs", "blame"];
if !crate::execution_allowed() {
args.push("--no-textconv");
}
args.extend(ignore.iter().map(String::as_str));
args.extend(["--porcelain", revision, "--", &rel]);
args
let contents_args = ["--literal-pathspecs", "blame", "--porcelain", "--contents", "-", revision, "--", &rel];
let plain_args: &[&str] = if crate::execution_allowed() {
&["--literal-pathspecs", "blame", "--porcelain", revision, "--", &rel]
} else {
&["--literal-pathspecs", "blame", "--no-textconv", "--porcelain", revision, "--", &rel]
};
let (out, contents_blame) = if crate::execution_allowed() {
let out = cli::run_raw(r.root(), &contents_args, Some(&contents))?;
if out.code == Some(0) {
(out, true)
} else if String::from_utf8_lossy(&out.stderr).contains("cannot use --contents") {
(run(r.root(), &plain_args, None)?, false)
(run(r.root(), plain_args, None)?, false)
} else {
return Err(cli::failure(&contents_args, &out.stderr, &out.stdout, out.code));
}
} else {
(run(r.root(), &plain_args, None)?, false)
(run(r.root(), plain_args, None)?, false)
};
let mut lines = crate::blame::parse_porcelain(&out.stdout_str());
for line in &mut lines {
@@ -216,21 +142,16 @@ fn converted_blame(
text: &str,
revision: &str,
encoding: TextEncoding,
ignore: &[String],
) -> IdeResult<Vec<BlameLine>> {
let current = crate::diff::normalize_eol(text);
let mut result = uncommitted_lines(current.split_inclusive('\n').count());
let Some(base) = r.file_bytes_at(path, Some(revision))? else { return Ok(result) };
let args: Vec<&str> = {
let mut args = vec!["--literal-pathspecs", "blame"];
if !crate::execution_allowed() {
args.push("--no-textconv");
}
args.extend(ignore.iter().map(String::as_str));
args.extend(["--porcelain", revision, "--", rel]);
args
let args: &[&str] = if crate::execution_allowed() {
&["--literal-pathspecs", "blame", "--porcelain", revision, "--", rel]
} else {
&["--literal-pathspecs", "blame", "--no-textconv", "--porcelain", revision, "--", rel]
};
let out = run(r.root(), &args, None)?;
let out = run(r.root(), args, None)?;
let physical = crate::blame::parse_porcelain(&out.stdout_str());
let raw = crate::text_encoding::decode_raw(&base, encoding);
let raw = crate::diff::normalize_eol(&raw);
@@ -1128,18 +1049,8 @@ pub(crate) fn rebase(r: &Repo, onto: &str) -> IdeResult<()> {
})
}
pub(crate) fn stash_push(
r: &Repo,
message: Option<&str>,
include_untracked: bool,
keep_index: bool,
paths: &[PathBuf],
) -> IdeResult<()> {
let rel = r.rel_paths(paths)?;
// Literal pathspecs only for the chosen files: git's whole-tree stash passes itself `:/`,
// which a literal pathspec turns into a file of that name.
let mut args = if rel.is_empty() { vec![] } else { vec!["--literal-pathspecs"] };
args.extend(["stash", "push", "-q"]);
pub(crate) fn stash_push(r: &Repo, message: Option<&str>, include_untracked: bool, keep_index: bool) -> IdeResult<()> {
let mut args = vec!["stash", "push", "-q"];
if include_untracked {
args.push("--include-untracked");
}
@@ -1149,10 +1060,6 @@ pub(crate) fn stash_push(
if let Some(m) = message.filter(|m| !m.trim().is_empty()) {
args.extend(["-m", m]);
}
if !rel.is_empty() {
args.push("--");
args.extend(rel.iter().map(String::as_str));
}
run(r.root(), &args, None).map(|_| ())
}
@@ -1476,19 +1383,3 @@ fn decode_diff(bytes: &[u8], encoding: TextEncoding) -> String {
}
text
}
#[cfg(test)]
mod tests {
use super::append_revision;
#[test]
fn a_revision_is_appended_once_keeping_the_list() {
let (a, b) = ("a".repeat(40), "b".repeat(40));
assert_eq!(append_revision("", &b), format!("{b}\n"));
assert_eq!(append_revision(&format!("{a}\n# comment\n"), &b), format!("{a}\n# comment\n{b}\n"));
assert_eq!(append_revision(&a, &b), format!("{a}\n{b}\n"));
let listed = format!("# reformat\n {b} \n{a}\n");
assert_eq!(append_revision(&listed, &b), listed);
assert_eq!(append_revision(&format!("{a}\r\n"), &b), format!("{a}\r\n{b}\r\n"));
}
}
-131
Переглянути файл
@@ -1,131 +0,0 @@
//! Annotate skips the commits listed in `blame.ignoreRevsFile`.
use ide_vcs::Repo;
use std::path::{Path, PathBuf};
use std::process::Command;
struct Fixture {
_tmp: tempfile::TempDir,
dir: PathBuf,
}
fn git_in(dir: &Path, args: &[&str]) -> String {
let out = Command::new("git")
.current_dir(dir)
.args(["-c", "init.defaultBranch=main", "-c", "core.quotepath=off"])
.args(args)
.env("GIT_TERMINAL_PROMPT", "0")
.output()
.expect("git runs");
assert!(out.status.success(), "git {args:?} failed: {}", String::from_utf8_lossy(&out.stderr));
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
impl Fixture {
fn new() -> Self {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("work");
std::fs::create_dir(&dir).unwrap();
git_in(&dir, &["init", "-q"]);
for (k, v) in [
("user.name", "Test User"),
("user.email", "[email protected]"),
("commit.gpgsign", "false"),
("core.autocrlf", "false"),
] {
git_in(&dir, &["config", k, v]);
}
Fixture { _tmp: tmp, dir }
}
fn git(&self, args: &[&str]) -> String {
git_in(&self.dir, args)
}
fn write(&self, rel: &str, content: &str) {
std::fs::write(self.dir.join(rel), content).unwrap();
}
fn commit_all(&self, msg: &str) -> String {
self.git(&["add", "-A"]);
self.git(&["commit", "-q", "-m", msg]);
self.git(&["rev-parse", "HEAD"])
}
fn path(&self, rel: &str) -> PathBuf {
self.dir.join(rel)
}
/// The repository of a trusted project.
fn repo(&self) -> Repo {
let repo = Repo::discover(&self.dir).unwrap().expect("repository found");
repo.set_execution_allowed(true);
repo
}
/// The repository of a project in Restricted Mode.
fn restricted(&self) -> Repo {
Repo::discover(&self.dir).unwrap().expect("repository found")
}
}
/// A line added by `first` and reformatted by `second`; annotate attributes it to
/// `second` until `second` is ignored, then back to `first`.
fn reformatted_file() -> (Fixture, String, String) {
let fx = Fixture::new();
fx.write("file.txt", "first\nsecond\n");
let first = fx.commit_all("add file");
fx.write("file.txt", "first\n second\n");
let second = fx.commit_all("reformat");
(fx, first, second)
}
fn commits(repo: &Repo, path: &Path) -> Vec<String> {
repo.blame(path, None, None, encoding_rs::UTF_8).unwrap().into_iter().map(|line| line.commit).collect()
}
#[test]
fn a_listed_commit_gives_its_lines_back_to_the_earlier_one() {
let (fx, first, second) = reformatted_file();
let file = fx.path("file.txt");
assert_eq!(commits(&fx.repo(), &file), vec![first.clone(), second.clone()]);
fx.write(".git-blame-ignore-revs", &format!("{second}\n"));
fx.git(&["config", "blame.ignoreRevsFile", ".git-blame-ignore-revs"]);
let ignored = vec![first.clone(), first.clone()];
assert_eq!(commits(&fx.repo(), &file), ignored);
// Restricted Mode reads the same configuration and skips the same commit.
assert_eq!(commits(&fx.restricted(), &file), ignored);
}
#[test]
fn a_configured_file_that_does_not_exist_is_skipped() {
let (fx, _, second) = reformatted_file();
fx.git(&["config", "blame.ignoreRevsFile", ".git-blame-ignore-revs"]);
let file = fx.path("file.txt");
// The missing list is passed to no blame, so annotate still works.
assert_eq!(commits(&fx.repo(), &file)[1], second);
assert_eq!(commits(&fx.restricted(), &file)[1], second);
}
#[test]
fn ignoring_a_commit_starts_the_list_and_configures_it_once() {
let (fx, first, second) = reformatted_file();
fx.repo().ignore_blame_revision(&second).unwrap();
fx.repo().ignore_blame_revision(&second).unwrap();
assert_eq!(std::fs::read_to_string(fx.path(".git-blame-ignore-revs")).unwrap(), format!("{second}\n"));
assert_eq!(fx.git(&["config", "--local", "--get", "blame.ignoreRevsFile"]), ".git-blame-ignore-revs");
let repo = fx.repo();
assert_eq!(commits(&repo, &fx.path("file.txt")), vec![first.clone(), first.clone()]);
}
#[test]
fn ignoring_a_commit_adds_it_to_the_configured_list() {
let (fx, first, second) = reformatted_file();
fx.write("custom-revs", &format!("# formatting\r\n{first}\r\n"));
fx.git(&["config", "--local", "blame.ignoreRevsFile", "custom-revs"]);
fx.repo().ignore_blame_revision(&second).unwrap();
// Lines and line endings kept; the configured file is the one annotate reads.
assert_eq!(
std::fs::read_to_string(fx.path("custom-revs")).unwrap(),
format!("# formatting\r\n{first}\r\n{second}\r\n")
);
assert!(!fx.path(".git-blame-ignore-revs").exists());
assert_eq!(fx.git(&["config", "--local", "--get", "blame.ignoreRevsFile"]), "custom-revs");
let blamed = commits(&fx.repo(), &fx.path("file.txt"));
assert_ne!(blamed[1], second);
}
+3 -48
Переглянути файл
@@ -769,11 +769,11 @@ fn stash_push_list_apply_pop() {
let repo = fx.repo();
fx.write("s.txt", "change 1\n");
repo.stash_push(Some("first stash"), false, false, &[]).unwrap();
repo.stash_push(Some("first stash"), false, false).unwrap();
assert_eq!(fx.read("s.txt"), "base\n");
fx.write("s.txt", "change 2\n");
fx.write("u.txt", "untracked\n");
repo.stash_push(None, true, false, &[]).unwrap();
repo.stash_push(None, true, false).unwrap();
assert!(!fx.path("u.txt").exists());
let stashes = repo.stashes().unwrap();
@@ -808,7 +808,7 @@ fn stash_keep_index_reinstate_index_and_branch() {
fx.write("a.txt", "a staged\n");
fx.git(&["add", "a.txt"]);
fx.write("b.txt", "b unstaged\n");
repo.stash_push(Some("keep"), false, true, &[]).unwrap();
repo.stash_push(Some("keep"), false, true).unwrap();
// Keep index: the staged change stays, the unstaged one is stashed.
assert_eq!(fx.read("a.txt"), "a staged\n");
assert_eq!(fx.read("b.txt"), "b\n");
@@ -2065,48 +2065,3 @@ fn a_case_only_directory_rename_keeps_status_and_head_content() {
assert_eq!(status_of(&st, "src/financial/table/index.vue").staged, Some(ChangeKind::Modified));
assert_eq!(fx.git(&["diff", "--cached", "--name-only"]), "src/financial/table/index.vue");
}
#[test]
fn status_paths_reads_only_the_named_paths() {
// The dirty-scope refresh: a watcher batch names a few paths, and git's pathspec
// filtering keeps the answer proportional to the batch instead of the whole tree.
let fx = Fixture::new();
fx.write("a.txt", "a\n");
fx.write("b.txt", "a\n");
fx.write("dir/c.txt", "a\n");
fx.commit_all("base");
fx.write("a.txt", "b\n");
fx.write("b.txt", "b\n");
fx.write("dir/c.txt", "b\n");
fx.write("new.txt", "n\n");
let repo = fx.repo();
repo.set_ignored_names(ide_core::settings::Settings::default().ignored_names());
let one = repo.status_paths(&[fx.path("a.txt")]).unwrap();
assert_eq!(one.len(), 1, "{one:#?}");
assert_eq!(one[0].rel_path, "a.txt");
assert_eq!(one[0].unstaged, Some(ChangeKind::Modified));
// A directory stands for everything below it, as the watcher reports a removed one.
let dir = repo.status_paths(&[fx.path("dir")]).unwrap();
assert_eq!(dir.iter().map(|s| s.rel_path.as_str()).collect::<Vec<_>>(), ["dir/c.txt"]);
// A nested file: the path a watcher hands over is spelled by the platform, and the pathspec
// needs `/` whatever the platform's separator is.
let nested = repo.status_paths(&[fx.path("dir").join("c.txt")]).unwrap();
assert_eq!(nested.iter().map(|s| s.rel_path.as_str()).collect::<Vec<_>>(), ["dir/c.txt"]);
// The repository root itself names everything, not a path called "".
assert_eq!(repo.status_paths(&[fx.path("")]).unwrap().len(), 4);
// A new file the batch names is reported as untracked, not as a directory entry.
let new = repo.status_paths(&[fx.path("new.txt")]).unwrap();
assert_eq!((new[0].rel_path.as_str(), new[0].unstaged), ("new.txt", Some(ChangeKind::Untracked)));
// Paths outside the repository are ignored rather than an error; nothing named is nothing read.
let outside = if cfg!(windows) { PathBuf::from("C:/elsewhere/x.txt") } else { PathBuf::from("/elsewhere/x.txt") };
assert!(repo.status_paths(&[outside]).unwrap().is_empty());
// An empty list is the full status, so a caller that lost the paths stays correct.
assert_eq!(repo.status_paths(&[]).unwrap().len(), 4);
}
-124
Переглянути файл
@@ -1,124 +0,0 @@
//! Stashing only the selected files.
use ide_core::error::ErrorKind;
use ide_vcs::Repo;
use std::path::{Path, PathBuf};
use std::process::Command;
struct Fixture {
_tmp: tempfile::TempDir,
dir: PathBuf,
}
fn git_in(dir: &Path, args: &[&str]) -> String {
let out = Command::new("git")
.current_dir(dir)
.args(["-c", "init.defaultBranch=main", "-c", "core.quotepath=off"])
.args(args)
.env("GIT_TERMINAL_PROMPT", "0")
.output()
.expect("git runs");
assert!(out.status.success(), "git {args:?} failed: {}", String::from_utf8_lossy(&out.stderr));
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
fn configure(dir: &Path) {
for (k, v) in [
("user.name", "Test User"),
("user.email", "[email protected]"),
("commit.gpgsign", "false"),
("core.autocrlf", "false"),
] {
git_in(dir, &["config", k, v]);
}
}
impl Fixture {
fn new() -> Self {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("work");
std::fs::create_dir(&dir).unwrap();
git_in(&dir, &["init", "-q"]);
configure(&dir);
Fixture { _tmp: tmp, dir }
}
fn git(&self, args: &[&str]) -> String {
git_in(&self.dir, args)
}
fn write(&self, rel: &str, content: &str) {
std::fs::write(self.dir.join(rel), content).unwrap();
}
fn read(&self, rel: &str) -> String {
std::fs::read_to_string(self.dir.join(rel)).unwrap()
}
fn commit_all(&self, msg: &str) {
self.git(&["add", "-A"]);
self.git(&["commit", "-q", "-m", msg]);
}
fn repo(&self) -> Repo {
let repo = Repo::discover(&self.dir).unwrap().expect("repository found");
repo.set_execution_allowed(true);
repo
}
fn path(&self, rel: &str) -> PathBuf {
self.dir.join(rel)
}
}
#[test]
fn stash_push_stashes_only_the_given_paths() {
let fx = Fixture::new();
fx.write("a.txt", "a\n");
fx.write("b.txt", "b\n");
fx.commit_all("base");
let repo = fx.repo();
fx.write("a.txt", "a changed\n");
fx.write("b.txt", "b changed\n");
repo.stash_push(Some("only a"), false, false, &[fx.path("a.txt")]).unwrap();
assert_eq!(fx.read("a.txt"), "a\n");
assert_eq!(fx.read("b.txt"), "b changed\n");
assert_eq!(repo.stashes().unwrap().len(), 1);
repo.stash_apply(0, true, false).unwrap();
assert_eq!(fx.read("a.txt"), "a changed\n");
assert_eq!(fx.read("b.txt"), "b changed\n");
assert!(repo.stashes().unwrap().is_empty());
}
#[test]
fn stash_push_stashes_only_the_given_untracked_paths() {
let fx = Fixture::new();
fx.write("a.txt", "a\n");
fx.commit_all("base");
let repo = fx.repo();
fx.write("new.txt", "new\n");
fx.write("other.txt", "other\n");
repo.stash_push(None, true, false, &[fx.path("new.txt")]).unwrap();
assert!(!fx.path("new.txt").exists());
assert!(fx.path("other.txt").exists());
assert_eq!(repo.stashes().unwrap().len(), 1);
repo.stash_apply(0, true, false).unwrap();
assert_eq!(fx.read("new.txt"), "new\n");
}
#[test]
fn stash_push_rejects_paths_outside_the_repository() {
let fx = Fixture::new();
fx.write("a.txt", "a\n");
fx.commit_all("base");
let repo = fx.repo();
fx.write("a.txt", "a changed\n");
let outside = tempfile::tempdir().unwrap();
std::fs::write(outside.path().join("outside.txt"), "outside\n").unwrap();
let error = repo.stash_push(None, false, false, &[outside.path().join("outside.txt")]).unwrap_err();
assert_eq!(error.kind, ErrorKind::InvalidInput);
assert!(error.message.contains("outside the repository"), "{error:?}");
// Nothing was stashed and the change is still in the working tree.
assert!(repo.stashes().unwrap().is_empty());
assert_eq!(fx.read("a.txt"), "a changed\n");
}
+2 -3
Переглянути файл
@@ -18,9 +18,8 @@ Agent tasks started inside OpenIDEA (see [Agents](/en/guide/agents)) get the sam
</Step>
</Steps>
By default the server listens on `127.0.0.1` only, and every request must carry a bearer token. Requests from another origin, or naming the server by a host name other than `localhost`, are refused.
The server listens on `127.0.0.1` only, and every request must carry a bearer token. Requests from another origin or host are refused.
- **Listen address.** `127.0.0.1` (the default) lets only this computer connect. To let agents on another computer connect, enter another address of this computer (a LAN or VPN address) or `0.0.0.0` for every address; they connect to `http://<that address>:<port>/mcp` with the token. Anyone who can reach the address and has the token can use the tools, so keep it to networks you trust. **Status** shows the address when it is not `127.0.0.1`.
- **Port.** By default it listens on the same port, 26371, in every run (**Use a fixed port** and **Port**), so HTTP clients and port forwards (an SSH tunnel to another machine, say) keep working after a restart. When the port is in use or reserved, that run takes a free port and **Status** says why. Turn **Use a fixed port** off for a free port in every run.
- **Token.** By default the token changes each time OpenIDEA starts. Turn on **Keep the token** to keep one across runs, stored in OpenIDEA's application data for your account only; **Regenerate Token** replaces it, and clients configured with the old one stop working.
@@ -29,7 +28,7 @@ By default the server listens on `127.0.0.1` only, and every request must carry
The settings page has two buttons under **Client configuration**. Both copy an `mcpServers` entry in the JSON format most clients accept.
- **Copy Stdio Config** (recommended): the client starts OpenIDEA's executable with `--mcp-stdio`, which relays to the running IDE. It holds no token and keeps working after OpenIDEA restarts.
- **Copy HTTP Config**: the client connects to `http://<address>:<port>/mcp` (`127.0.0.1` when listening on every address) with `Authorization: Bearer <token>`. It keeps working after OpenIDEA restarts only with a fixed port and a kept token; otherwise copy it again after each restart.
- **Copy HTTP Config**: the client connects to `http://127.0.0.1:<port>/mcp` with `Authorization: Bearer <token>`. It keeps working after OpenIDEA restarts only with a fixed port and a kept token; otherwise copy it again after each restart.
The stdio configuration looks like this (the path is the one on your machine):
+2 -2
Переглянути файл
@@ -75,7 +75,7 @@ Pick **VS Code (macOS)** or **VS Code (Windows)** as the keymap; then `⇧⌘P`
| Extensions | **Settings › Plugins** |
| `settings.json` | **Settings** (`⌘,` / `Ctrl+Alt+S`), stored in `settings.toml`; per-project values in `.openidea/settings.toml` |
| `launch.json` | Run configurations: **Import Run Configurations from .vscode/launch.json** |
| Multi-root workspace | **File › New › Workspace…** and **Attach Project…** |
| Multi-root workspace | **File › New Workspace…** and **Attach Project…** |
`.editorconfig` files are applied. VS Code settings and extensions are not imported.
@@ -84,7 +84,7 @@ Pick **VS Code (macOS)** or **VS Code (Windows)** as the keymap; then `⇧⌘P`
Pick **Eclipse (macOS)** or **Eclipse** as the keymap.
- **Formatter.** To keep using an Eclipse formatter profile for Java, open **Settings › Editor › Code Style › Java**, set **Formatting profile** to **Custom Eclipse formatter XML** and choose the exported XML file.
- **Workspaces and projects.** Eclipse opens many projects in one workspace. OpenIDEA opens one project per window; to work on several folders together, create a workspace with **File › New › Workspace…** or add folders with **Attach Project…**.
- **Workspaces and projects.** Eclipse opens many projects in one workspace. OpenIDEA opens one project per window; to work on several folders together, create a workspace with **File › New Workspace…** or add folders with **Attach Project…**.
- **Perspectives.** OpenIDEA has no perspectives. Arrange tool windows the way you like and save the arrangement with **Window › Tool Window Layouts › Save Current Layout as…**.
## Concepts at a glance
+1 -1
Переглянути файл
@@ -13,7 +13,7 @@ A scratch file is a throwaway file in any language: a place to try a snippet, ke
<Steps>
<Step title="Create one">
Choose **File › New › Scratch File** (or **Scratch File** at the top of the Project view's **New** menu).
Choose **File › New Scratch File…** (or **Scratch File** at the top of the Project view's **New** menu).
</Step>
<Step title="Pick a language">
Type to filter the language list and press `↵` / `Enter`.
+1 -1
Переглянути файл
@@ -73,7 +73,7 @@ The page also has **Expand abbreviations with Tab** and **Show live templates in
Live templates, postfix settings and macros are stored per user on this machine, not in the project.
For new-file templates (what **File › New › File…** creates), see **Settings › Editor › File and Code Templates**.
For new-file templates (what **File › New…** creates), see **Settings › Editor › File and Code Templates**.
## Macros
+5 -10
Переглянути файл
@@ -41,7 +41,8 @@ It reads repository state (status, diffs, blame, log) in-process, and runs the `
- **Git › Clone…** opens **Clone Repository**. Enter a **URL:** and **Directory:**, and optionally check **Shallow clone with a history truncated to** N commits. The **GitHub** source in the sidebar lists your GitHub repositories (see [GitHub](/en/guide/github)).
- When creating a project, check **Create Git repository** in the New Project dialog.
- **Git › Create Git Repository…** runs `git init` in a folder you pick (the project root by default) and registers the new repository right away; a repository created in the terminal is picked up the same way.
There is no action to run `git init` in a project that is already open; run it in the terminal and OpenIDEA picks the repository up.
OpenIDEA finds the repository that contains the project and nested repositories up to four levels deep (skipping folders such as `node_modules`, `target` and `build`). Status, commit, fetch and update cover all of them; branch and log actions work on the selected repository. Submodules show as nested repositories, without submodule commands.
@@ -130,8 +131,6 @@ Other ways to compare:
Click the branch widget in the title bar (or **Git › Branches…**) to open the branches popup. Type to search branches and actions. The popup shows **Recent**, **Local**, **Remote** and **Tags**, and at the top **Update Project…**, **Commit…**, **Push…**, **New Branch…** and **Checkout Tag or Revision…**. The gear button toggles grouping by prefix, recent branches and tags.
Click the incoming or outgoing marker next to the branch name in the title bar (or **Incoming and Outgoing Commits…** in the branches popup) to list the commits to pull or push, newest first. Click a commit for its diff, or **Open in Git Log** to show it in the Git log.
Select a branch (`→` or `Enter`) for its actions:
- **Checkout**, **New Branch from '…'…**, **Checkout and Rebase onto '…'**
@@ -196,7 +195,7 @@ Limits: interactive rebase doesn't support ranges with merge commits, and **Undo
**Stash** uses `git stash`:
- **Stash Changes…**: a message, **Keep index** and **Include untracked files**; it stashes the whole working tree. **Stash Selected Files…** in the changes tree's context menu stashes only the selected files.
- **Stash Changes…**: a message, **Keep index** and **Include untracked files**. Stashes always include all changes; there is no per-file stash.
- **Unstash Changes…**: apply or pop a stash, reinstate the index, or create a branch from it.
- The **Stash** tab of the Commit tool window lists stashes with apply, pop and drop.
@@ -213,14 +212,10 @@ Turn on **Combine stashes and shelves in one tab** in the Git settings to show b
## Blame and history
- **Annotate with Git Blame** shows author and date per line in the gutter (also from the gutter's context menu). Click an entry to open the commit in the log. **Show inline blame for the current line** in the Git settings shows the author at the end of the caret line.
- **Annotate Previous Revision** in an annotated line's menu opens the file as it was before that commit, annotated, next to the commit's version.
- **Annotate at Revision…** picks any revision the file has and shows it annotated next to the working copy.
- **Show History** opens the file's history in a tab of the Git tool window.
- **Show History for Selection** shows the commits that changed the selected lines.
- **Show Current Revision** reveals the commit of the caret line in the log.
Annotate skips the commits listed in the file `blame.ignoreRevsFile` names — git's convention is `.git-blame-ignore-revs` at the repository root — so a reformatting or mass-rename commit does not claim every line. **Ignore This Commit** in an annotated line's menu adds the current line's commit to that file and refreshes the annotations.
### Local history
Local history keeps snapshots of your files independent of Git, including deleted files and labelled states such as the one taken before a refactoring.
@@ -249,6 +244,6 @@ Retention is set in the **Local History** section of **Settings › Appearance &
## Not supported
- Mercurial, Subversion, Perforce and other systems.
- Submodule commands and editable directory mappings.
- Per-line partial commits (block level only).
- `git init` for an open project, submodule commands, editable directory mappings.
- Per-line partial commits (block level only) and per-file stashes.
- Git LFS beyond the large-file warning.
+3 -4
Переглянути файл
@@ -49,11 +49,10 @@ All servers, debuggers and formatters are listed in **Settings › Languages & F
The Astro and Angular servers also run from the project's own `node_modules/.bin` when it has them.
### JavaScript runtime
### Node.js
**Settings › Languages & Frameworks › JavaScript Runtime** has:
**Settings › Languages & Frameworks › Node.js** has:
- **Preferred runtime**: what runs JavaScript and TypeScript files from the gutter and the editor's **Run** action: Node.js, Bun or Deno. **Auto** looks for the nearest `deno.json` / `deno.jsonc` (Deno) or `bun.lock` / `bun.lockb` (Bun) up to the project root, and otherwise uses Node.js.
- **Node interpreter**: the `node` that runs npm scripts, Node files and the Node-based language servers. **Default** uses `node` on `PATH`. The list shows every Node.js found on `PATH` and those installed by nvm, Volta, fnm, mise and asdf.
- **Run in target**: run Node and package-manager commands locally or in one of the run targets from **Settings › Build, Execution, Deployment › Run Targets**.
- **Package manager**: `npm`, `pnpm`, `yarn` or `bun` for the detected script configurations. **Auto** reads `packageManager` in `package.json`, then the lock file (`pnpm-lock.yaml`, `yarn.lock`, `bun.lock` / `bun.lockb`, `package-lock.json`), and otherwise uses npm.
@@ -99,7 +98,7 @@ With **Do not step into library scripts** (**Settings › Build, Execution, Depl
- **React**: JSX and TSX get their own highlighting modes and full TypeScript server support. There is no React-specific tooling beyond that.
- **Vue**: `.vue` files are highlighted per block and served by Vue Language Server in hybrid mode. In a Vue CLI project without `tsconfig.json`, webpack's `resolve.alias` entries (such as `@/`) are read so imports resolve. Prettier formats `.vue` files. Emmet works in templates.
- **Svelte**: Svelte Language Server; highlighting uses the HTML mode (script as TypeScript, style as CSS). Emmet works in markup.
- **Astro**: Astro Language Server with its own highlighting mode. It needs Node.js; if it can't find one, select it in **Settings › Languages & Frameworks › JavaScript Runtime**.
- **Astro**: Astro Language Server with its own highlighting mode. It needs Node.js; if it can't find one, select it in **Settings › Languages & Frameworks › Node.js**.
- **Angular**: in projects with `angular.json` or `@angular/core` installed, templates in HTML files and inline `template:` strings get Angular highlighting, and the Angular Language Server adds template completion, navigation and rename. The server is configured for the project's Angular version.
## Limitations
+2 -2
Переглянути файл
@@ -11,7 +11,7 @@ This page covers how a plugin works with files:
projects.
- **Other files** (`fs.wit`, API 1.3): files outside the projects, under explicit grants,
and the plugin's own private directory.
- **File templates**: entries of the **File › New › File…** menu, no code needed.
- **File templates**: entries of the **File › New…** menu, no code needed.
## File actions
@@ -180,7 +180,7 @@ as an argument to an approved process.
## File templates
`[[contributes.fileTemplates]]` adds entries to the **File › New › File…** menu. They need no code and grant
`[[contributes.fileTemplates]]` adds entries to the **File › New…** menu. They need no code and grant
no file access: files are created only when the user invokes a template, through the IDE's
normal New File flow.
+1 -1
Переглянути файл
@@ -44,7 +44,7 @@ Declare the API your plugin needs with `engines.openidea` in the manifest, for e
## Where plugins come from
- **Marketplace:** **Settings › Plugins** lists the installed plugins first, then the official plugins and the community plugins of the [registry](https://github.com/l0ng-ai/openidea-plugins). See [Publish to the marketplace](/en/plugins/publishing/marketplace).
- **Marketplace:** **Settings › Plugins › Available** lists the official plugins and the community plugins of the [registry](https://github.com/l0ng-ai/openidea-plugins). See [Publish to the marketplace](/en/plugins/publishing/marketplace).
- **Disk:** **Install Plugin from Disk…** (a folder) or **Install Plugin from Package File…** (an `.oiplugin` or `.zip`) in the gear menu of **Settings › Plugins**.
- **Linked folders:** `openidea-plugin link` loads a folder you are developing, without installing it.
- **Project plugins:** packages in `<project>/.openidea/plugins/<name>/` load in trusted projects only, after you approve their permissions.
+1 -1
Переглянути файл
@@ -3,7 +3,7 @@ title: "Publish to the marketplace"
description: "How the default marketplace is built, official versus community plugins, and how to list your plugin through the community registry."
---
The IDE's default marketplace is the `plugins-index.json` attached to the latest OpenIDEA release. Users browse it in **Settings › Plugins**, filter by category, and install with one click. It lists two kinds of plugins.
The IDE's default marketplace is the `plugins-index.json` attached to the latest OpenIDEA release. Users browse it in **Settings › Plugins › Available**, filter by category, and install with one click. It lists two kinds of plugins.
| | Official | Community |
| ------------------- | ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
+1 -1
Переглянути файл
@@ -41,7 +41,7 @@ openidea-plugin merge market/index.json https://github.com/l0ng-ai/openidea/rele
## Point the IDE at it
In **Settings › Plugins**, open the gear menu and choose **Plugin Repository…**. Enter the **Marketplace index URL** (`https://…/index.json` or a folder path); leave it empty to go back to the default repository. The marketplace plugins in the list then come from your index.
In **Settings › Plugins**, open the gear menu and choose **Plugin Repository…**. Enter the **Marketplace index URL** (`https://…/index.json` or a folder path); leave it empty to go back to the default repository. The **Available** tab then lists your index.
The IDE reads one index. If you want users to keep seeing the official plugins, merge the official index into yours.
+2 -3
Переглянути файл
@@ -17,9 +17,8 @@ OpenIDEA 内置了一个 [Model Context Protocol](https://modelcontextprotocol.i
</Step>
</Steps>
服务器默认只监听 `127.0.0.1`,每个请求都必须带上访问令牌。来自其他 Origin 的请求,以及用 `localhost` 以外的主机名访问服务器的请求,都会被拒绝。
服务器只监听 `127.0.0.1`,每个请求都必须带上访问令牌。来自其他 Origin 或 Host 的请求会被拒绝。
- **监听地址。** `127.0.0.1`(默认)只允许本机连接。要让其他电脑上的智能体连接,就填本机的其他地址(局域网或 VPN 地址),或填 `0.0.0.0` 监听所有地址;对方带着令牌连接 `http://<该地址>:<端口>/mcp`。任何能访问该地址并持有令牌的人都能使用这些工具,所以只在可信的网络里这样做。地址不是 `127.0.0.1` 时,**状态**会显示它。
- **端口。** 默认每次运行都监听同一个端口 26371(**使用固定端口** 和 **端口**),这样 OpenIDEA 重启后 HTTP 客户端和端口转发(例如到另一台机器的 SSH 隧道)仍然有效。端口被占用或被系统保留时,本次运行改用一个空闲端口,**状态** 中会说明原因。关闭 **使用固定端口** 则每次运行使用一个空闲端口。
- **令牌。** 默认每次启动 OpenIDEA 都会换一个新令牌。打开 **保留令牌** 后会在多次运行间保留同一个令牌,保存在 OpenIDEA 的应用数据目录中,只有你的账户可读;**重新生成令牌** 会替换它,使用旧令牌配置的客户端随之失效。
@@ -28,7 +27,7 @@ OpenIDEA 内置了一个 [Model Context Protocol](https://modelcontextprotocol.i
设置页面的 **客户端配置** 下有两个按钮,都会复制一段大多数客户端都能识别的 `mcpServers` JSON 配置。
- **复制 Stdio 配置**(推荐):客户端以 `--mcp-stdio` 参数启动 OpenIDEA 的可执行文件,由它转发给正在运行的 IDE。配置里没有令牌,OpenIDEA 重启后依然可用。
- **复制 HTTP 配置**:客户端带着 `Authorization: Bearer <token>` 连接 `http://<地址>:<端口>/mcp`(监听所有地址时为 `127.0.0.1`)。只有同时使用固定端口并保留令牌时,OpenIDEA 重启后这份配置才继续有效;否则每次重启后都要重新复制。
- **复制 HTTP 配置**:客户端带着 `Authorization: Bearer <token>` 连接 `http://127.0.0.1:<port>/mcp`。只有同时使用固定端口并保留令牌时,OpenIDEA 重启后这份配置才继续有效;否则每次重启后都要重新复制。
stdio 配置大致如下(路径以你机器上的为准):
+2 -2
Переглянути файл
@@ -75,7 +75,7 @@ Vim 模式会读取 `~/.ideavimrc`(或 `~/.openideavimrc`)。通过 **工具
| 扩展 | **设置 › 插件** |
| `settings.json` | **设置**(`⌘,` / `Ctrl+Alt+S`),保存在 `settings.toml`;项目级的值保存在 `.openidea/settings.toml` |
| `launch.json` | 运行配置:**从 .vscode/launch.json 导入运行配置** |
| 多根工作区 | **文件 › 新建 › 工作区…** 和 **附加项目…** |
| 多根工作区 | **文件 › 新建工作区…** 和 **附加项目…** |
项目中的 `.editorconfig` 会生效。VS Code 的设置和扩展不会导入。
@@ -84,7 +84,7 @@ Vim 模式会读取 `~/.ideavimrc`(或 `~/.openideavimrc`)。通过 **工具
把快捷键方案切换为 **Eclipse (macOS)** 或 **Eclipse**。
- **格式化器。** 想继续用 Eclipse 的 Java 格式化配置,打开 **设置 › 编辑器 › 代码样式 › Java**,把 **格式化方案** 设为 **自定义 Eclipse 格式化 XML**,然后选择导出的 XML 文件。
- **工作空间与项目。** Eclipse 在一个工作空间里打开多个项目;OpenIDEA 每个窗口打开一个项目。需要同时处理多个文件夹时,用 **文件 › 新建 › 工作区…** 创建工作区,或用 **附加项目…** 添加文件夹。
- **工作空间与项目。** Eclipse 在一个工作空间里打开多个项目;OpenIDEA 每个窗口打开一个项目。需要同时处理多个文件夹时,用 **文件 › 新建工作区…** 创建工作区,或用 **附加项目…** 添加文件夹。
- **透视图。** OpenIDEA 没有透视图。按自己的习惯摆放工具窗口,再用 **窗口 › 工具窗口布局 › 将当前布局另存为…** 保存。
## 概念对照
+1 -1
Переглянути файл
@@ -13,7 +13,7 @@ description: "在项目之外保留临时文件,并在 TODO 工具窗口中汇
<Steps>
<Step title="新建">
选择 **文件 › 新建 › 临时文件**(或项目视图 **新建** 菜单最上面的 **临时文件**)。
选择 **文件 › 新建临时文件…**(或项目视图 **新建** 菜单最上面的 **临时文件**)。
</Step>
<Step title="选择语言">
输入文字过滤语言列表,按 `↵` / `Enter` 确认。
+1 -1
Переглянути файл
@@ -67,7 +67,7 @@ items.for → for (var item : items) { … }
实时模板、后缀补全设置和宏都按用户保存在本机,不保存在项目里。
新建文件用的模板(**文件 › 新建 › 文件…** 创建的内容)在 **设置 › 编辑器 › 文件和代码模板** 中设置。
新建文件用的模板(**文件 › 新建…** 创建的内容)在 **设置 › 编辑器 › 文件和代码模板** 中设置。
## 宏
+5 -10
Переглянути файл
@@ -41,7 +41,8 @@ OpenIDEA 内置 Git 支持,Git 也是它唯一支持的版本控制系统。
- **Git › 克隆…** 打开 **克隆仓库**。填写 **URL:** 和 **目录:**,需要时勾选 **浅克隆,历史记录截断为** N 个提交。侧栏中的 **GitHub** 会列出你的 GitHub 仓库(见 [GitHub](/zh/guide/github))。
- 新建项目时,在新建项目对话框中勾选 **创建 Git 仓库**。
- **Git › 创建 Git 仓库…** 会在你选择的目录(默认是项目根目录)里执行 `git init`,并立即注册这个新仓库;在终端里创建的仓库同样会被识别。
对已经打开的项目,没有执行 `git init` 的操作;在终端中执行后 OpenIDEA 会自动识别仓库。
OpenIDEA 会找到包含项目的仓库,以及向下最多四层的嵌套仓库(跳过 `node_modules`、`target`、`build` 等目录)。状态、提交、获取和更新覆盖所有仓库;分支和日志操作针对所选仓库。子模块显示为嵌套仓库,但没有子模块相关命令。
@@ -130,8 +131,6 @@ OpenIDEA 会找到包含项目的仓库,以及向下最多四层的嵌套仓
点击标题栏的分支微件(或 **Git › 分支…**)打开分支弹窗,输入文字即可搜索分支和操作。弹窗中有 **最近**、**本地**、**远程** 和 **标签** 分组,顶部有 **更新项目…**、**提交…**、**推送…**、**新建分支…** 和 **签出标签或修订…**。齿轮按钮可以切换按前缀分组、显示最近分支和显示标签。
点击标题栏分支名旁的传入或传出标记(或分支弹窗中的 **传入和传出的提交…**)即可列出待拉取或待推送的提交(最新的在前)。点击某个提交查看其差异,或点 **在 Git 日志中打开** 在 Git 日志中显示它。
选中分支(`→` 或 `Enter`)查看它的操作:
- **签出**、**从 '…' 新建分支…**、**签出并变基到 '…'**
@@ -196,7 +195,7 @@ Git 工具窗口的 **日志** 标签页显示提交图。
**贮藏** 使用 `git stash`:
- **贮藏更改…**:填写消息,可选 **保留索引** 和 **包括未跟踪的文件**,贮藏整个工作区;更改树右键菜单中的 **贮藏所选文件…** 只贮藏选中的文件。
- **贮藏更改…**:填写消息,可选 **保留索引** 和 **包括未跟踪的文件**。贮藏总是包含全部更改,不能只贮藏部分文件。
- **取消贮藏更改…**:应用或弹出某个贮藏,恢复索引,或基于它新建分支。
- 提交工具窗口的 **贮藏** 标签页列出所有贮藏,可以应用、弹出和删除。
@@ -213,14 +212,10 @@ Git 工具窗口的 **日志** 标签页显示提交图。
## Blame 与历史
- **使用 Git Blame 注解** 在边栏中逐行显示作者和日期(也可以从边栏右键菜单打开)。点击某条记录会在日志中打开对应提交。在 Git 设置中打开 **为当前行显示内联 Git Blame**,会在光标所在行末尾显示作者。
- 注解行的右键菜单中,**标注上一版本** 会打开该提交之前版本的文件(已注解),与这个提交的版本并排显示。
- **标注指定版本…** 可选择文件的任意版本,把该版本(已注解)与工作区文件并排显示。
- **显示历史记录** 在 Git 工具窗口的标签页中打开文件历史。
- **显示所选内容的历史记录** 列出修改过所选行的提交。
- **显示当前修订版** 在日志中定位光标所在行的提交。
注解会跳过 `blame.ignoreRevsFile` 指向的文件(约定是仓库根目录下的 `.git-blame-ignore-revs`)中列出的提交,因此一次格式化或批量重命名的提交不会认领所有行。在注解行的右键菜单中选择 **忽略此提交**,会把当前行的提交加入该文件并刷新注解。
### 本地历史记录
本地历史记录独立于 Git 保存文件快照,包括已删除的文件,以及重构前自动记录的状态等带标签的快照。
@@ -249,6 +244,6 @@ Git 工具窗口的 **日志** 标签页显示提交图。
## 暂不支持
- Mercurial、Subversion、Perforce 等其他版本控制系统。
- 子模块命令、编辑目录映射。
- 按行的部分提交(只支持按代码块)。
- 对已打开的项目执行 `git init`、子模块命令、编辑目录映射。
- 按行的部分提交(只支持按代码块)以及按文件贮藏。
- 除大文件警告外的 Git LFS 支持。
+3 -4
Переглянути файл
@@ -49,11 +49,10 @@ Vue、Astro 和 Angular 服务器也会使用这里选择的版本。TypeScript
如果项目自己的 `node_modules/.bin` 里有 Astro 或 Angular 服务器,会优先使用项目里的。
### JavaScript 运行时
### Node.js
**设置 › 语言和框架 › JavaScript 运行时** 包含:
**设置 › 语言和框架 › Node.js** 包含:
- **首选运行时**:从行号栏和编辑器的 **运行** 操作运行 JavaScript、TypeScript 文件时用 Node.js、Bun 还是 Deno。**自动** 会从文件所在目录向上找到项目根目录,最近的 `deno.json` / `deno.jsonc` 用 Deno,`bun.lock` / `bun.lockb` 用 Bun,都没有时用 Node.js。
- **Node 解释器**:运行 npm 脚本、Node 文件以及基于 Node 的语言服务器所用的 `node`。**默认** 表示使用 `PATH` 上的 `node`。列表会列出 `PATH` 上找到的所有 Node.js,以及 nvm、Volta、fnm、mise、asdf 安装的版本。
- **在目标中运行**:在本机,或在 **设置 › 构建、执行、部署 › 运行目标** 中配置的某个运行目标里执行 Node 和包管理器命令。
- **软件包管理器**:检测出的脚本配置使用 `npm`、`pnpm`、`yarn` 还是 `bun`。**自动** 先读 `package.json` 的 `packageManager`,再看锁文件(`pnpm-lock.yaml`、`yarn.lock`、`bun.lock` / `bun.lockb`、`package-lock.json`),都没有时用 npm。
@@ -99,7 +98,7 @@ Jest 和 Vitest 的输出会解析成测试树,支持 **重新运行失败的
- **React**:JSX 和 TSX 有各自的高亮模式,并获得完整的 TypeScript 服务器支持;除此之外没有 React 专属功能。
- **Vue**:`.vue` 文件按块高亮,由 Vue Language Server 以 hybrid 模式提供支持。没有 `tsconfig.json` 的 Vue CLI 项目会读取 webpack 的 `resolve.alias`(如 `@/`),让 import 能正确解析。Prettier 可格式化 `.vue` 文件,模板中可用 Emmet。
- **Svelte**:使用 Svelte Language Server;高亮沿用 HTML 模式(script 按 TypeScript、style 按 CSS),标记部分可用 Emmet。
- **Astro**:使用 Astro Language Server,有专门的高亮模式。它需要 Node.js,找不到时请在 **设置 › 语言和框架 › JavaScript 运行时** 中选择。
- **Astro**:使用 Astro Language Server,有专门的高亮模式。它需要 Node.js,找不到时请在 **设置 › 语言和框架 › Node.js** 中选择。
- **Angular**:在含 `angular.json` 或已安装 `@angular/core` 的项目中,HTML 模板和组件里内联的 `template:` 字符串使用 Angular 高亮,Angular Language Server 提供模板补全、跳转和重命名,并按项目的 Angular 版本配置。
## 已知限制
+2 -2
Переглянути файл
@@ -8,7 +8,7 @@ description: "创建、重命名或删除文件的快速修复,读写项目文
- **文件操作**(`file-actions.wit`,API 1.3):创建、重命名或删除文件的快速修复和重构,作为一次可撤销的工作区编辑应用。
- **项目文件**(`workspace.wit`):读取、写入、查找和搜索打开项目中的文件。
- **其他文件**(`fs.wit`,API 1.3):在明确授权下访问项目之外的文件,以及插件自己的私有目录。
- **文件模板**:**文件 › 新建 › 文件…** 菜单中的条目,不需要代码。
- **文件模板**:**文件 › 新建…** 菜单中的条目,不需要代码。
## 文件操作
@@ -162,7 +162,7 @@ fs::write_file("~/.config/mytool/state.json", "{}")?;
## 文件模板
`[[contributes.fileTemplates]]` 在 **文件 › 新建 › 文件…** 菜单中添加条目。不需要代码,也不授予任何文件访问权限:
`[[contributes.fileTemplates]]` 在 **文件 › 新建…** 菜单中添加条目。不需要代码,也不授予任何文件访问权限:
只有用户调用模板时,才通过 IDE 正常的新建文件流程创建文件。
```toml
+1 -1
Переглянути файл
@@ -44,7 +44,7 @@ OpenIDEA 插件可以为 IDE 增加命令、编辑器功能、工具窗口、主
## 插件从哪里来
- **插件市场:** **设置 › 插件** 先列出已安装的插件,再列出官方插件和[社区登记仓库](https://github.com/l0ng-ai/openidea-plugins)中的社区插件。见 [发布到市场](/zh/plugins/publishing/marketplace)。
- **插件市场:** **设置 › 插件 › 可用** 列出官方插件和[社区登记仓库](https://github.com/l0ng-ai/openidea-plugins)中的社区插件。见 [发布到市场](/zh/plugins/publishing/marketplace)。
- **磁盘:** 在 **设置 › 插件** 的齿轮菜单中选择 **从磁盘安装插件…**(文件夹)或 **从插件包文件安装…**(`.oiplugin` 或 `.zip`)。
- **链接的文件夹:** `openidea-plugin link` 让 IDE 直接加载你正在开发的文件夹,无需安装。
- **项目插件:** 放在 `<project>/.openidea/plugins/<name>/` 下的插件包,只在受信任的项目中、且你批准其权限后才会加载。
+1 -1
Переглянути файл
@@ -3,7 +3,7 @@ title: "发布到市场"
description: "默认插件市场如何构建,官方插件与社区插件的区别,以及如何通过社区登记仓库上架你的插件。"
---
IDE 默认的插件市场是最新 OpenIDEA release 附带的 `plugins-index.json`。用户在 **设置 › 插件** 中浏览、按分类筛选,一键安装。其中有两类插件:
IDE 默认的插件市场是最新 OpenIDEA release 附带的 `plugins-index.json`。用户在 **设置 › 插件 › 可用** 中浏览、按分类筛选,一键安装。其中有两类插件:
| | 官方插件 | 社区插件 |
| ---------- | ----------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
+1 -1
Переглянути файл
@@ -41,7 +41,7 @@ openidea-plugin merge market/index.json https://github.com/l0ng-ai/openidea/rele
## 让 IDE 使用它
在 **设置 › 插件** 中打开齿轮菜单,选择 **插件仓库…**,在 **插件市场索引 URL:** 中填入 `https://…/index.json` 或一个文件夹路径;留空则恢复默认仓库。之后插件列表里的市场插件就来自你的索引。
在 **设置 › 插件** 中打开齿轮菜单,选择 **插件仓库…**,在 **插件市场索引 URL:** 中填入 `https://…/index.json` 或一个文件夹路径;留空则恢复默认仓库。之后 **可用** 标签页列出的就是你的索引。
IDE 只读取一个索引。如果希望用户仍能看到官方插件,请把官方索引合并进你的索引。
-133
Переглянути файл
@@ -1,133 +0,0 @@
import type { Page } from "@playwright/test";
import type { AgentTask } from "../src/bindings";
import { expect, test } from "./fixtures";
const task: AgentTask = {
id: "draft-ack",
title: "Draft acknowledgement",
provider: "codex",
status: "ready",
isolation: "local",
model: null,
cwd: "/dev/acme-api",
branch: null,
base: null,
baseCommit: null,
error: null,
createdMs: 0,
updatedMs: 0,
config: [],
commands: [],
usage: null,
queue: [],
};
async function prepare(page: Page, home: boolean, failure: boolean) {
await page.evaluate(
async ({ task, home, failure }) => {
const storePath = "/src/features/agents/store.ts";
const ipcPath = "/src/platform/ipc.ts";
const store = await import(storePath);
const { commands } = await import(ipcPath);
const provider = {
id: "codex",
name: "Codex",
command: "codex",
program: "codex",
args: [],
defaultProgram: null,
defaultArgs: [],
loginHint: null,
commandOverridden: false,
commandEditable: true,
commandReadOnlyReason: null,
installed: true,
env: {},
commands: [],
source: { kind: "custom" },
config: [],
thoughtLevels: {},
};
// Delayed local acknowledgements exercise the real editor without starting an agent.
commands.agentProviders = async () => ({ status: "ok", data: [provider] });
commands.agentProbe = async () => ({ status: "ok", data: null });
commands.agentChanges = async () => ({ status: "ok", data: [] });
const acknowledge = (data: unknown, prompt: unknown) => {
document.documentElement.dataset.submittedPrompt = JSON.stringify(prompt);
return new Promise((resolve) =>
document.addEventListener(
"test:agent-ack",
() => {
resolve(
failure
? { status: "error", error: { kind: "internal", message: "Local send failed" } }
: { status: "ok", data },
);
},
{ once: true },
),
);
};
commands.agentCreate = (request: { prompt: unknown }) => acknowledge(task, request.prompt);
commands.agentPrompt = (_id: string, prompt: unknown) => acknowledge(null, prompt);
store.useAgents.setState({
loaded: true,
providers: [provider],
tasks: new Map([[task.id, task]]),
entries: new Map([[task.id, []]]),
composers: new Map(),
draft: { provider: "codex", config: {}, isolation: "local", base: null },
sessionLocation: "editor",
});
if (home) store.openHome();
else store.openTask(task.id);
},
{ task, home, failure },
);
}
for (const home of [false, true]) {
for (const edited of [false, true]) {
for (const failure of [false, true]) {
test(`${home ? "new task" : "session"}: ${failure ? "failed" : "successful"} acknowledgement with ${edited ? "new typing" : "unchanged draft"}`, async ({
workbench: page,
}) => {
await prepare(page, home, failure);
const box = page.getByRole("textbox", { name: "Message", exact: true });
await box.fill("submitted prompt");
await page.getByRole("button", { name: home ? "Start" : "Send", exact: true }).click();
await expect(page.locator("html")).toHaveAttribute(
"data-submitted-prompt",
JSON.stringify({ text: "submitted prompt", files: [], images: [] }),
);
await expect(page.getByRole("button", { name: home ? "Starting…" : "Send", exact: true })).toBeDisabled();
if (edited) await box.fill("next unsent prompt");
await page.evaluate(() => document.dispatchEvent(new Event("test:agent-ack")));
if (home && !failure) {
await expect(page.getByRole("tab", { name: "Draft acknowledgement" })).toBeVisible();
await page.evaluate(async () => {
const storePath = "/src/features/agents/store.ts";
(await import(storePath)).openHome();
});
}
const expected = edited ? "next unsent prompt" : failure ? "submitted prompt" : "";
if (expected) await expect(box).toHaveText(expected);
else await expect(box.locator(".cm-placeholder")).toBeVisible();
await expect
.poll(() =>
page.evaluate(
async (key) => {
const storePath = "/src/features/agents/store.ts";
return (await import(storePath)).composerDraft(key).text;
},
home ? "home" : task.id,
),
)
.toBe(expected);
await expect(page.getByRole("button", { name: home ? "Start" : "Send", exact: true })).toBeEnabled({
enabled: !!expected,
});
});
}
}
}
-212
Переглянути файл
@@ -1,212 +0,0 @@
import type { Page } from "@playwright/test";
import type { AgentTask } from "../src/bindings";
import { expect, test } from "./fixtures";
const task: AgentTask = {
id: "source-a",
title: "Source task in A",
provider: "codex",
status: "ready",
isolation: "local",
model: null,
cwd: "/dev/acme-api",
branch: null,
base: null,
baseCommit: null,
error: null,
createdMs: 0,
updatedMs: 0,
config: [],
commands: [],
usage: null,
queue: [],
capabilities: { fork: true },
};
async function prepare(page: Page, kind: "create" | "fork", failure: boolean, holdClose = false, closeFailure = false) {
await page.evaluate(
async ({ task, kind, failure, holdClose, closeFailure }) => {
const storePath = "/src/features/agents/store.ts";
const ipcPath = "/src/platform/ipc.ts";
const projectPath = "/src/platform/project.ts";
const trustPath = "/src/platform/projectTrust.ts";
const contributionsPath = "/src/workbench/contributions.ts";
const store = await import(storePath);
const { commands } = await import(ipcPath);
const project = await import(projectPath);
const initial = project.useProject.getState().project;
(await import(trustPath)).provideProjectTrustPrompt(async () => true);
commands.openProject = async (root: string) => ({
status: "ok",
data: {
...initial,
root,
name: root === task.cwd ? "acme-api" : "project-b",
trusted: true,
},
});
commands.closeProject = async () => {};
const provider = {
id: "codex",
name: "Codex",
command: "codex",
program: "codex",
args: [],
defaultProgram: null,
defaultArgs: [],
loginHint: null,
commandOverridden: false,
commandEditable: true,
commandReadOnlyReason: null,
installed: true,
env: {},
commands: [],
source: { kind: "custom" },
config: [],
thoughtLevels: {},
};
commands.agentProviders = async () => ({ status: "ok", data: [provider] });
commands.agentProbe = async () => ({ status: "ok", data: null });
commands.agentEntries = async () => ({ status: "ok", data: [] });
commands.agentChanges = async () => ({ status: "ok", data: [] });
const complete = () => {
document.documentElement.dataset.taskRequest = "pending";
return new Promise((resolve) =>
document.addEventListener(
"test:task-complete",
() => {
resolve(
failure
? { status: "error", error: { kind: "internal", message: "Local operation failed" } }
: { status: "ok", data: { ...task, id: "completed-a", title: "Task from project A" } },
);
setTimeout(() => {
document.documentElement.dataset.taskRequest = "settled";
}, 0);
},
{ once: true },
),
);
};
commands.agentCreate = complete;
commands.agentFork = complete;
if (holdClose) {
const editors = (await import(contributionsPath)).editorService();
const close = editors.closeInput;
editors.closeInput = async (key: string) => {
const closed = await close(key);
if (key !== "agent:new") return closed;
document.documentElement.dataset.homeClosing = "pending";
await new Promise((resolve, reject) =>
document.addEventListener(
"test:close-complete",
() => {
if (closeFailure) reject(new Error("Local close failed"));
else resolve(null);
setTimeout(() => {
document.documentElement.dataset.homeClosing = "settled";
}, 0);
},
{ once: true },
),
);
return closed;
};
}
store.useAgents.setState({
loaded: true,
tasks: new Map([[task.id, task]]),
providers: [provider],
composers: new Map(),
history: [],
entries: new Map([[task.id, [{ type: "user", text: "Existing prompt in A", attachments: [] }]]]),
draft: { provider: "codex", config: {}, isolation: "local", base: null },
sessionLocation: "editor",
});
if (kind === "create") store.openHome("Submitted task in A");
else store.openTask(task.id);
},
{ task, kind, failure, holdClose, closeFailure },
);
if (kind === "create") await page.getByRole("button", { name: "Start", exact: true }).click();
else {
await page.getByText("Existing prompt in A", { exact: true }).hover();
await page.getByRole("button", { name: "Fork", exact: true }).click();
}
await expect(page.locator("html")).toHaveAttribute("data-task-request", "pending");
}
async function changeProject(page: Page, change: "A to B" | "A to B to A" | "reopen A") {
await page.evaluate(
async ({ change, task }) => {
const storePath = "/src/features/agents/store.ts";
const projectPath = "/src/platform/project.ts";
const store = await import(storePath);
const project = await import(projectPath);
if (change === "reopen A") await project.closeProject();
else await project.openProject("/dev/project-b");
if (change !== "A to B") await project.openProject(task.cwd);
const root = project.useProject.getState().project.root;
store.useAgents.setState({
loaded: true,
tasks: new Map([
["current-project", { ...task, id: "current-project", title: "Current project's task", cwd: root }],
]),
entries: new Map(),
history: ["Current project history"],
});
store.openHome("Current project draft");
},
{ change, task },
);
await expect(page.getByRole("textbox", { name: "Message", exact: true })).toHaveText("Current project draft");
}
async function expectCurrentProject(page: Page) {
await expect(page.getByRole("textbox", { name: "Message", exact: true })).toHaveText("Current project draft");
await expect(page.getByRole("tab", { name: "New Agent Task" })).toBeVisible();
await expect(page.getByRole("tab", { name: "Task from project A" })).toHaveCount(0);
await expect(page.getByText(/Local (operation|close) failed/)).toHaveCount(0);
expect(
await page.evaluate(async () => {
const storePath = "/src/features/agents/store.ts";
const store = await import(storePath);
return { tasks: [...store.useAgents.getState().tasks.keys()], history: store.useAgents.getState().history };
}),
).toEqual({ tasks: ["current-project"], history: ["Current project history"] });
}
for (const kind of ["create", "fork"] as const) {
for (const failure of [false, true]) {
for (const change of ["A to B", "A to B to A", "reopen A"] as const) {
test(`${kind}: old ${failure ? "failure" : "success"} after ${change}`, async ({ workbench: page }) => {
await prepare(page, kind, failure);
await changeProject(page, change);
await page.evaluate(() => document.dispatchEvent(new Event("test:task-complete")));
await expect(page.locator("html")).toHaveAttribute("data-task-request", "settled");
await expectCurrentProject(page);
});
}
test(`${kind}: ${failure ? "failure" : "success"} in the current project`, async ({ workbench: page }) => {
await prepare(page, kind, failure);
await page.evaluate(() => document.dispatchEvent(new Event("test:task-complete")));
await expect(page.locator("html")).toHaveAttribute("data-task-request", "settled");
if (failure) await expect(page.getByText(/Local operation failed/).first()).toBeVisible();
else await expect(page.getByRole("tab", { name: "Task from project A" })).toBeVisible();
});
}
}
for (const closeFailure of [false, true]) {
test(`create: project changes while Home closes (${closeFailure ? "failure" : "success"})`, async ({
workbench: page,
}) => {
await prepare(page, "create", false, true, closeFailure);
await page.evaluate(() => document.dispatchEvent(new Event("test:task-complete")));
await expect(page.locator("html")).toHaveAttribute("data-home-closing", "pending");
await changeProject(page, "A to B to A");
await page.evaluate(() => document.dispatchEvent(new Event("test:close-complete")));
await expect(page.locator("html")).toHaveAttribute("data-home-closing", "settled");
await expectCurrentProject(page);
});
}
+3 -3
Переглянути файл
@@ -19,9 +19,9 @@ test("an unresolved module gets a startup timeout", async ({ page }) => {
);
await page.goto("/", { waitUntil: "commit" });
await page.locator("#splash").waitFor({ state: "attached" });
await page.clock.runFor(60_000);
await page.clock.runFor(30_000);
await expect(page.getByRole("textbox", { name: "Startup error details" })).toHaveValue(
/Startup did not finish within 60 seconds/,
/Startup did not finish within 30 seconds/,
);
});
@@ -83,7 +83,7 @@ test("a slow module load succeeds before the deadline and cancels the startup ti
await expect(page.locator("#root")).not.toBeEmpty();
await page.clock.runFor(4000);
await expect(page.locator("#splash")).toHaveCount(0);
await page.clock.runFor(50_000);
await page.clock.runFor(35_000);
await expect(page.locator("#boot-error")).toHaveCount(0);
expect(await page.evaluate(() => sessionStorage.getItem("boot-error-seen"))).toBeNull();
});
-19
Переглянути файл
@@ -71,27 +71,8 @@ test("completion lists the server's items and inserts the chosen one", async ({
const method = popup.locator("li").filter({ has: page.locator(".cm-completionLabel", { hasText: /^toDto$/ }) });
await expect(method.locator(".cm-completionDetail")).toHaveText("()");
await expect(method.locator(".cm-lsp-type")).toHaveText("OrderDto");
// CodeMirror anchors the popup at the completion's `from` (the word start), and the kind icon
// hangs left of the label. With no prefix typed yet `from` is the caret, so the label's x must
// match the caret column rather than the popup's left edge.
const wordStart = await editor.locator(".cm-content").evaluate(async (content) => {
const viewModule = "/node_modules/.vite/deps/@codemirror_view.js";
const { EditorView } = await import(/* @vite-ignore */ viewModule);
const view = EditorView.findFromDOM(content as HTMLElement)!;
return view.coordsAtPos(view.state.selection.main.head)!.left;
});
const label = await method.locator(".cm-completionLabel").boundingBox();
// Subpixel rounding differs per platform/font; a mis-set inset is a whole icon column off.
expect(Math.abs(label!.x - wordStart)).toBeLessThanOrEqual(2);
await page.keyboard.type("toD");
await expect(popup.getByText("total", { exact: true })).toBeHidden();
// Refreshing the results rebuilds the list; the hint bar must stay below it, not jump to the top.
const footerBelowList = await popup.evaluate((t) => {
const list = t.querySelector(":scope > ul")!;
const footer = t.querySelector(":scope > .cm-lsp-completion-footer")!;
return footer.getBoundingClientRect().top >= list.getBoundingClientRect().bottom - 1;
});
expect(footerBelowList).toBe(true);
await page.keyboard.press("Enter");
await expect(popup).toBeHidden();
await expect(editor.locator(".cm-activeLine")).toHaveText(/^\s*order\.toDto\(\)$/);
-79
Переглянути файл
@@ -1,79 +0,0 @@
import type { Page } from "@playwright/test";
import { expect, test } from "./fixtures";
const cases = [
{ name: "positive fraction", count: 8192, total: "1", expected: "0.0001220703125" },
{ name: "negative fraction", count: 8192, total: "-1", expected: "-0.0001220703125" },
{ name: "integer result", count: 8192, total: "10000000000000000", expected: "1220703125000" },
{ name: "cancelled divisor factor", count: 24576, total: "3", expected: "0.0001220703125" },
];
async function seedResult(page: Page, count: number, total: string, chart: boolean) {
await page.evaluate(
async ({ count, total, chart }) => {
const storePath = "/src/features/database/store.ts";
const contributionsPath = "/src/workbench/contributions.ts";
const { useDatabase, RESULTS_VIEW } = await import(storePath);
const { showServiceView } = await import(contributionsPath);
const values = [total, ...Array<string>(count - 1).fill("0"), null, null];
const columns = [{ name: "value", typeName: "NUMERIC" }];
if (chart) columns.unshift({ name: "category", typeName: "TEXT" });
const rows = values.map((value) => (chart ? ["all", value] : [value]));
const text = "SELECT value FROM finite_means";
useDatabase.setState({
loaded: true,
sources: [{ id: "finite", name: "Finite means", driver: "postgres", readOnly: true }],
consoles: { "finite-means.sql": "finite" },
results: [
{
id: 1,
title: "Result 1",
path: "finite-means.sql",
sourceId: "finite",
outcome: {
statement: { text, from: 0, to: text.length },
error: null,
result: {
statement: text,
columns,
rows,
affected: null,
hasMore: false,
elapsedMs: 1,
executionMs: 1,
},
},
page: 0,
pageSize: rows.length,
},
],
activeResult: 1,
});
showServiceView(RESULTS_VIEW);
},
{ count, total, chart },
);
await expect(page.getByRole("grid", { name: "Result 1", exact: true }).getByRole("gridcell").first()).toBeVisible();
}
for (const { name, count, total, expected } of cases) {
test(`selection mean stays exact: ${name}`, async ({ workbench: page }) => {
await seedResult(page, count, total, false);
const grid = page.getByRole("grid", { name: "Result 1", exact: true });
await grid.getByRole("gridcell").first().click();
await page.keyboard.press("Meta+a");
await page.getByRole("button", { name: "Show Record / Value / Aggregates", exact: true }).click();
const panel = page.getByRole("complementary", { name: "Value panel", exact: true });
await panel.getByRole("tab", { name: "Aggregates", exact: true }).click();
await expect(panel.getByRole("row", { name: /^Avg / }).getByRole("cell")).toHaveText(expected);
await expect(panel.getByRole("row", { name: "Nulls 2", exact: true })).toBeVisible();
});
test(`chart mean stays exact: ${name}`, async ({ workbench: page }) => {
await seedResult(page, count, total, true);
await page.getByRole("button", { name: "Chart", exact: true }).click();
const chart = page.getByRole("img", { name: "value (mean) by category", exact: true });
await expect(chart).toBeVisible();
await expect(chart.locator("rect title")).toHaveText(`all: ${expected}`);
});
}
Loaded 100 of 349 files, more files were not shown because too many files have changed in this diff. Show more