mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-07 18:38:33 -10:00
Compare commits
47
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb258b0912 | ||
|
|
086c89df5b | ||
|
|
5325d98c3f | ||
|
|
589cf81bae | ||
|
|
a5d1b16e59 | ||
|
|
b450f4028b | ||
|
|
1f76e71621 | ||
|
|
e6b800971a | ||
|
|
a6cf4442d8 | ||
|
|
83d2f67b4f | ||
|
|
3ec30fff3f | ||
|
|
009bb21f0b | ||
|
|
43bb10048d | ||
|
|
1b4ca3cf1f | ||
|
|
08f2d9756d | ||
|
|
b4bd0ec1e2 | ||
|
|
7634ae81f1 | ||
|
|
7bd96a998b | ||
|
|
4830f42168 | ||
|
|
800949b7b5 | ||
|
|
6026374973 | ||
|
|
0a47a685d9 | ||
|
|
653f2ff276 | ||
|
|
8689271e7c | ||
|
|
7ad02da722 | ||
|
|
1dda103bf0 | ||
|
|
844cc93b90 | ||
|
|
c95a525235 | ||
|
|
4002eaa8e9 | ||
|
|
98268106b8 | ||
|
|
2e9a637158 | ||
|
|
f50a8a301c | ||
|
|
07df51f308 | ||
|
|
38ff63658e | ||
|
|
7dbce43c88 | ||
|
|
f68d8f499a | ||
|
|
7a715758a6 | ||
|
|
e5c250aad8 | ||
|
|
ad64506336 | ||
|
|
f3fe837a66 | ||
|
|
af0ffb809e | ||
|
|
31e477b7fb | ||
|
|
425801be34 | ||
|
|
269f3b22fd | ||
|
|
2c6131999c | ||
|
|
97177486e9 | ||
|
|
57d35ecdb4 |
No files matched your search
Generated
+8
-5
@@ -1496,7 +1496,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3001,7 +3001,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c48eccd894bd15555b0346470e63491ded94aefe76b3fcb1052084b5a393238"
|
||||
dependencies = [
|
||||
"bstr",
|
||||
"hashbrown 0.16.1",
|
||||
"hashbrown 0.17.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4121,6 +4121,7 @@ dependencies = [
|
||||
name = "ide-build"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dirs 6.0.0",
|
||||
"dunce",
|
||||
"ide-core",
|
||||
@@ -4181,6 +4182,7 @@ dependencies = [
|
||||
"ide-language",
|
||||
"memchr",
|
||||
"parking_lot",
|
||||
"portable-pty",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
@@ -4542,6 +4544,7 @@ dependencies = [
|
||||
"ignore",
|
||||
"parking_lot",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
@@ -8377,7 +8380,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d204abcbdf8e88729306a8d0ca01a79d8a49969fe1f84696909d6dbc4321c1a"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
"hashbrown 0.16.1",
|
||||
"hashbrown 0.17.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -9979,9 +9982,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter-haskell"
|
||||
version = "0.23.1"
|
||||
version = "0.24.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "977c51e504548cba13fc27cb5a2edab2124cf6716a1934915d07ab99523b05a4"
|
||||
checksum = "781de30e0d1ec8e6db4ed799d36ce1cedaf4684374dada45ebe2a8ea44c3f814"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"tree-sitter-language",
|
||||
|
||||
@@ -6,6 +6,7 @@ edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
blake3.workspace = true
|
||||
dunce.workspace = true
|
||||
ide-core.workspace = true
|
||||
zip.workspace = true
|
||||
|
||||
@@ -2,18 +2,49 @@
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::io::Read;
|
||||
use std::time::SystemTime;
|
||||
|
||||
/// `(byte length, mtime, blake3 hex)`; the hash is recorded for files inside the project only.
|
||||
type Stamp = (u64, SystemTime, Option<String>);
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LaunchFingerprint {
|
||||
version: u32,
|
||||
parameters: String,
|
||||
files: BTreeMap<PathBuf, Option<(u64, SystemTime)>>,
|
||||
/// Per input: a [`Stamp`] for regular files, `None` for
|
||||
/// directories and inputs that cannot be resolved. The content hash catches external
|
||||
/// edits that preserve both length and mtime (timestamp-preserving tools, coarse
|
||||
/// timestamp granularity), which `(length, mtime)` alone would miss. Only files inside
|
||||
/// the project are hashed; toolchain inputs such as the JDK's `lib/modules` keep the stamp.
|
||||
files: BTreeMap<PathBuf, Option<Stamp>>,
|
||||
}
|
||||
|
||||
/// `blake3` hex of a file's bytes, streamed in chunks so memory stays bounded. An
|
||||
/// unreadable file records a sentinel so one permission problem does not drop the whole
|
||||
/// fingerprint (that would just force a normal rebuild).
|
||||
fn content_hash(path: &Path) -> IdeResult<String> {
|
||||
let mut file = match fs::File::open(path) {
|
||||
Ok(file) => file,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok("missing".into()),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
let mut hasher = blake3::Hasher::new();
|
||||
let mut buffer = [0; 64 * 1024];
|
||||
loop {
|
||||
let count = file.read(&mut buffer)?;
|
||||
if count == 0 {
|
||||
break;
|
||||
}
|
||||
hasher.update(&buffer[..count]);
|
||||
}
|
||||
Ok(hasher.finalize().to_hex().to_string())
|
||||
}
|
||||
|
||||
fn scan(
|
||||
root: &Path,
|
||||
path: &Path,
|
||||
files: &mut BTreeMap<PathBuf, Option<(u64, SystemTime)>>,
|
||||
files: &mut BTreeMap<PathBuf, Option<Stamp>>,
|
||||
ancestors: &mut HashSet<PathBuf>,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<()> {
|
||||
@@ -36,11 +67,12 @@ fn scan(
|
||||
// Directory mtimes change on irrelevant JDT writes. File paths detect additions/deletions.
|
||||
files.insert(path.to_path_buf(), None);
|
||||
for entry in fs::read_dir(path)? {
|
||||
scan(&entry?.path(), files, ancestors, cancel)?;
|
||||
scan(root, &entry?.path(), files, ancestors, cancel)?;
|
||||
}
|
||||
ancestors.remove(&canonical);
|
||||
} else if meta.is_file() {
|
||||
files.insert(path.to_path_buf(), Some((meta.len(), meta.modified()?)));
|
||||
let hash = if path.starts_with(root) { Some(content_hash(path)?) } else { None };
|
||||
files.insert(path.to_path_buf(), Some((meta.len(), meta.modified()?, hash)));
|
||||
} else {
|
||||
return Err(IdeError::internal("unsupported launch input file"));
|
||||
}
|
||||
@@ -145,11 +177,11 @@ pub fn launch_fingerprint(
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
let mut files = BTreeMap::new();
|
||||
for path in inputs {
|
||||
scan(&path, &mut files, &mut HashSet::new(), cancel)?;
|
||||
scan(root, &path, &mut files, &mut HashSet::new(), cancel)?;
|
||||
}
|
||||
// Store project inputs relative to the project, including each file's relative path.
|
||||
let files = files.into_iter().map(|(p, stamp)| (p.strip_prefix(root).unwrap_or(&p).to_path_buf(), stamp)).collect();
|
||||
Ok(Some(LaunchFingerprint { version: 1, parameters, files }))
|
||||
Ok(Some(LaunchFingerprint { version: 2, parameters, files }))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -231,6 +263,25 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// An external tool that keeps the byte length and the mtime (timestamp-preserving
|
||||
/// writes, coarse-granularity filesystems) still changes the content: the fingerprint
|
||||
/// must differ via the content hash, not via `(length, mtime)`.
|
||||
#[test]
|
||||
fn external_edit_preserving_length_and_mtime_still_invalidates() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
let model = fixture(root);
|
||||
let source = root.join("lib/src/main/kotlin/Channel.kt");
|
||||
let before = fingerprint(root, &model);
|
||||
assert!(before.is_some());
|
||||
let mtime = fs::metadata(&source).unwrap().modified().unwrap();
|
||||
// Same 5-byte length, different content.
|
||||
write(root, "lib/src/main/kotlin/Channel.kt", "valve");
|
||||
fs::File::options().write(true).open(&source).unwrap().set_modified(mtime).unwrap();
|
||||
let after = fingerprint(root, &model);
|
||||
assert_ne!(before, after, "a same-length, same-mtime content change must invalidate the fingerprint");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_options_jdk_goal_and_wrapper_invalidate_without_using_class_outputs() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -252,6 +252,8 @@ pub struct SmartKeysSettings {
|
||||
/// Reformat on paste: `none` | `indentBlock` | `indentEachLine`.
|
||||
pub reformat_on_paste: String,
|
||||
pub smart_indent: bool,
|
||||
/// Enter › Insert documentation comment stub: Enter after `/**` closes the comment.
|
||||
pub insert_doc_stub: bool,
|
||||
/// Backspace › Unindent: `autoindent` (proper indent position) | `nearest` indent position | `none`.
|
||||
pub backspace_unindent: String,
|
||||
}
|
||||
@@ -267,6 +269,7 @@ impl Default for SmartKeysSettings {
|
||||
tab_out_of_pair: true,
|
||||
reformat_on_paste: "indentEachLine".into(),
|
||||
smart_indent: true,
|
||||
insert_doc_stub: true,
|
||||
backspace_unindent: "autoindent".into(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,6 +229,9 @@ pub enum DebugEvent {
|
||||
thread_id: i64,
|
||||
started: bool,
|
||||
},
|
||||
/// The program runs in a terminal the IDE gave it (`runInTerminal`): what is typed for it
|
||||
/// in the debug console reaches its input.
|
||||
ProgramInput,
|
||||
BreakpointsChanged {
|
||||
path: crate::IpcPath,
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Settings of remote work: SSH configurations, SFTP deployment and SSH run
|
||||
//! targets. The fields hang off `Settings` in `settings.rs`.
|
||||
//! Settings of remote work: SSH configurations, SFTP deployment and run
|
||||
//! targets (SSH, Docker, Compose). The fields hang off `Settings` in `settings.rs`.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
@@ -140,22 +140,55 @@ pub struct DeploymentMapping {
|
||||
pub web: String,
|
||||
}
|
||||
|
||||
/// Build, Execution, Deployment › Run Targets: run configurations on another machine.
|
||||
/// Where a run target runs configurations.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum RunTargetKind {
|
||||
/// Another machine over SSH, the project synchronised to its folder.
|
||||
#[default]
|
||||
Ssh,
|
||||
/// A new container of an image (`docker run --rm`), the project bind-mounted.
|
||||
Docker,
|
||||
/// A new container of a Compose service (`docker compose run --rm`).
|
||||
Compose,
|
||||
}
|
||||
|
||||
/// Build, Execution, Deployment › Run Targets: run configurations on another machine
|
||||
/// or in a container.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct RunTarget {
|
||||
pub kind: RunTargetKind,
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
/// [`SshConfiguration::id`].
|
||||
/// [`SshConfiguration::id`] (SSH).
|
||||
pub ssh_config: String,
|
||||
/// Remote folder the project is synchronised to and run from.
|
||||
/// Remote folder the project is synchronised to and run from (SSH).
|
||||
pub project_root: String,
|
||||
/// Upload changed project files before each run.
|
||||
/// Upload changed project files before each run (SSH).
|
||||
pub sync_before_run: bool,
|
||||
/// Interpreters on the target; empty = the one on the remote `PATH`.
|
||||
pub python: String,
|
||||
pub node: String,
|
||||
pub java: String,
|
||||
/// Id of a Docker connection of the Services tool window (Docker, Compose);
|
||||
/// empty = the CLI's own daemon.
|
||||
pub docker_connection: String,
|
||||
/// Image to run (Docker).
|
||||
pub image: String,
|
||||
/// Compose files, relative to the project; empty = the project's default one (Compose).
|
||||
pub compose_files: Vec<String>,
|
||||
/// Service to run (Compose).
|
||||
pub service: String,
|
||||
/// Where the project is in the container; empty = `/workspaces/<folder>` (Docker),
|
||||
/// the service's bind mount of the project (Compose).
|
||||
pub container_root: String,
|
||||
/// Published ports, as `docker run --publish` takes them (`8000:8000`).
|
||||
pub ports: Vec<String>,
|
||||
/// More options for `docker run` / `docker compose run`, shell-like quoted.
|
||||
pub run_options: String,
|
||||
/// Run as the image's user instead of the project folder's owner (Linux).
|
||||
pub image_user: bool,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -176,4 +209,24 @@ mod deployment_tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_target_kind_defaults_to_ssh_and_round_trips() {
|
||||
let legacy: RunTarget =
|
||||
serde_json::from_str(r#"{"id":"t","name":"Build box","sshConfig":"ssh","projectRoot":"/srv"}"#).unwrap();
|
||||
assert_eq!((legacy.kind, legacy.ssh_config.as_str()), (RunTargetKind::Ssh, "ssh"));
|
||||
let docker = RunTarget {
|
||||
kind: RunTargetKind::Compose,
|
||||
docker_connection: "local".into(),
|
||||
compose_files: vec!["compose.dev.yaml".into()],
|
||||
service: "web".into(),
|
||||
ports: vec!["8000:8000".into()],
|
||||
image_user: true,
|
||||
..legacy
|
||||
};
|
||||
let json = serde_json::to_value(&docker).unwrap();
|
||||
assert_eq!(json["kind"], "compose");
|
||||
assert_eq!(json["composeFiles"], serde_json::json!(["compose.dev.yaml"]));
|
||||
assert_eq!(serde_json::from_value::<RunTarget>(json).unwrap(), docker);
|
||||
}
|
||||
}
|
||||
@@ -143,7 +143,7 @@ fn nvm_bins(dir: &Path) -> Vec<PathBuf> {
|
||||
}
|
||||
|
||||
/// `v22.10.1` as `[22, 10, 1]`: v22 is newer than v9, v20.10 newer than v20.9.
|
||||
fn version_key(name: &str) -> Vec<u64> {
|
||||
pub fn version_key(name: &str) -> Vec<u64> {
|
||||
name.trim_start_matches('v').split('.').map(|n| n.parse().unwrap_or(0)).collect()
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ specta.workspace = true
|
||||
tokio = { workspace = true, features = ["net"] }
|
||||
parking_lot.workspace = true
|
||||
memchr = "2"
|
||||
portable-pty.workspace = true
|
||||
tracing.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -64,13 +64,13 @@ fn builtin(adapter: &str) -> Option<DebuggerConfig> {
|
||||
"codelldb",
|
||||
&["--port", PORT_PLACEHOLDER],
|
||||
"tcp",
|
||||
"Install the CodeLLDB adapter (https://github.com/vadimcn/codelldb/releases) and put `codelldb` on PATH",
|
||||
"Install CodeLLDB in Settings › Languages & Frameworks › Language Servers",
|
||||
),
|
||||
"kotlin-debug-adapter" => cfg(
|
||||
"kotlin-debug-adapter",
|
||||
&[],
|
||||
"stdio",
|
||||
"Install the Kotlin Debug Adapter from Settings > Language Servers",
|
||||
"Install Kotlin Debug Adapter in Settings › Languages & Frameworks › Language Servers",
|
||||
),
|
||||
// Windows installs `python.exe`; `python3.exe` there is at most a Store alias.
|
||||
"debugpy" => cfg(
|
||||
@@ -84,7 +84,7 @@ fn builtin(adapter: &str) -> Option<DebuggerConfig> {
|
||||
// Without a host it listens on `localhost`, which may resolve to ::1 only.
|
||||
&[PORT_PLACEHOLDER, "127.0.0.1"],
|
||||
"tcp",
|
||||
"Install vscode-js-debug's DAP server (js-debug-dap-*.tar.gz from https://github.com/microsoft/vscode-js-debug/releases) as `js-debug-adapter`",
|
||||
"Install JavaScript Debugger (vscode-js-debug) in Settings › Languages & Frameworks › Language Servers",
|
||||
),
|
||||
"delve" => cfg(
|
||||
"dlv",
|
||||
@@ -96,7 +96,7 @@ fn builtin(adapter: &str) -> Option<DebuggerConfig> {
|
||||
"netcoredbg",
|
||||
&["--interpreter=vscode"],
|
||||
"stdio",
|
||||
"Install Samsung netcoredbg (https://github.com/Samsung/netcoredbg/releases)",
|
||||
"Install netcoredbg (.NET debugger) in Settings › Languages & Frameworks › Language Servers",
|
||||
),
|
||||
"lldb-dap" => {
|
||||
cfg("lldb-dap", &[], "stdio", "Install LLVM's lldb-dap (brew install llvm; on macOS `xcrun -f lldb-dap`)")
|
||||
@@ -668,7 +668,7 @@ mod tests {
|
||||
assert!(find_binary("definitely-not-a-real-binary-openidea", &spec.cwd).is_none());
|
||||
let err = missing(&spec);
|
||||
assert_eq!(err.kind, ide_core::error::ErrorKind::Unavailable);
|
||||
assert!(err.message.contains("netcoredbg/releases"));
|
||||
assert!(err.message.contains("Settings › Languages & Frameworks › Language Servers"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -1266,6 +1266,16 @@ impl DebugManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Types `text` into the terminal the session's program runs in (see [`ide_core::event::DebugEvent::ProgramInput`]).
|
||||
pub fn send_input(&self, s: DebugSessionId, text: &str) -> IdeResult<()> {
|
||||
let session = self.session(s)?;
|
||||
let mut program = session.program.lock();
|
||||
let program = program
|
||||
.as_mut()
|
||||
.ok_or_else(|| IdeError::invalid("The program doesn't read input from the debug console"))?;
|
||||
program.write(text).map_err(|e| IdeError::internal(format!("cannot write to the program: {e}")))
|
||||
}
|
||||
|
||||
/// Flutter's DAP adapter: injects the changed sources into the running app.
|
||||
/// Not part of the DAP standard; adapters that do not know the request reject it.
|
||||
pub async fn hot_reload(&self, s: DebugSessionId) -> IdeResult<()> {
|
||||
|
||||
@@ -43,6 +43,8 @@ pub(crate) struct Session {
|
||||
pub adapter_process: Mutex<Option<Child>>,
|
||||
/// Processes started for `runInTerminal`.
|
||||
pub debuggees: Mutex<Vec<Child>>,
|
||||
/// The terminal a `runInTerminal` program runs in: typed input goes to it.
|
||||
pub program: Mutex<Option<ProgramTerminal>>,
|
||||
/// Set once `configurationDone` was sent; breakpoint updates go out live after that.
|
||||
pub configured: AtomicBool,
|
||||
pub initialized: watch::Sender<bool>,
|
||||
@@ -90,6 +92,7 @@ impl Session {
|
||||
caps: Mutex::new(Value::Null),
|
||||
adapter_process: Mutex::new(child),
|
||||
debuggees: Mutex::new(Vec::new()),
|
||||
program: Mutex::new(None),
|
||||
configured: AtomicBool::new(false),
|
||||
initialized: watch::channel(false).0,
|
||||
terminated: AtomicBool::new(false),
|
||||
@@ -266,6 +269,9 @@ impl Session {
|
||||
for mut c in std::mem::take(&mut *self.debuggees.lock()) {
|
||||
let _ = c.start_kill();
|
||||
}
|
||||
if let Some(mut program) = self.program.lock().take() {
|
||||
let _ = program.child.kill();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -451,8 +457,25 @@ fn on_reverse_request(mgr: &Arc<DebugManager>, s: &Arc<Session>, seq: i64, comma
|
||||
}
|
||||
}
|
||||
|
||||
/// `runInTerminal`: spawns the debuggee directly and forwards its output to the console.
|
||||
/// A `runInTerminal` program and the pseudo-terminal it runs in.
|
||||
pub(crate) struct ProgramTerminal {
|
||||
writer: Box<dyn std::io::Write + Send>,
|
||||
child: Box<dyn portable_pty::Child + Send + Sync>,
|
||||
_master: Box<dyn portable_pty::MasterPty + Send>,
|
||||
}
|
||||
|
||||
impl ProgramTerminal {
|
||||
pub(crate) fn write(&mut self, text: &str) -> std::io::Result<()> {
|
||||
self.writer.write_all(text.as_bytes())?;
|
||||
self.writer.flush()
|
||||
}
|
||||
}
|
||||
|
||||
/// `runInTerminal`: starts the debuggee on a pseudo-terminal, as an IDE terminal would (CodeLLDB
|
||||
/// starts a launcher that gives the program its terminal, and the program reads input only from
|
||||
/// a terminal), forwards its output to the console, and keeps the terminal for typed input.
|
||||
pub(crate) fn run_in_terminal(s: &Arc<Session>, args: &Value) -> Result<Value, String> {
|
||||
use portable_pty::{CommandBuilder, PtySize, native_pty_system};
|
||||
let argv: Vec<String> = args
|
||||
.get("args")
|
||||
.and_then(Value::as_array)
|
||||
@@ -461,16 +484,25 @@ pub(crate) fn run_in_terminal(s: &Arc<Session>, args: &Value) -> Result<Value, S
|
||||
let Some((program, rest)) = argv.split_first() else {
|
||||
return Err("runInTerminal without args".into());
|
||||
};
|
||||
let mut cmd = tokio::process::Command::new(program);
|
||||
ide_core::process::prepare(cmd.as_std_mut());
|
||||
cmd.args(rest)
|
||||
.envs(ide_core::process::shell_env())
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true);
|
||||
if let Some(cwd) = args.get("cwd").and_then(Value::as_str).filter(|c| !c.is_empty()) {
|
||||
cmd.current_dir(cwd);
|
||||
let pair = native_pty_system()
|
||||
.openpty(PtySize { rows: 24, cols: 200, pixel_width: 0, pixel_height: 0 })
|
||||
.map_err(|e| format!("cannot open a terminal for {program}: {e}"))?;
|
||||
let mut cmd = CommandBuilder::new(program);
|
||||
// CodeLLDB's launcher clears the terminal first; the console keeps the session's earlier output.
|
||||
cmd.args(rest.iter().filter(|a| *a != "--clear-screen"));
|
||||
for (k, v) in ide_core::process::host_env_fixes() {
|
||||
match v {
|
||||
Some(v) => cmd.env(k, v),
|
||||
None => cmd.env_remove(k),
|
||||
}
|
||||
}
|
||||
for (k, v) in ide_core::process::shell_env() {
|
||||
cmd.env(k, v);
|
||||
}
|
||||
cmd.env("TERM", "xterm-256color");
|
||||
match args.get("cwd").and_then(Value::as_str).filter(|c| !c.is_empty()) {
|
||||
Some(cwd) => cmd.cwd(cwd),
|
||||
None => cmd.cwd(&s.spec.cwd),
|
||||
}
|
||||
if let Some(env) = args.get("env").and_then(Value::as_object) {
|
||||
for (k, v) in env {
|
||||
@@ -480,18 +512,53 @@ pub(crate) fn run_in_terminal(s: &Arc<Session>, args: &Value) -> Result<Value, S
|
||||
};
|
||||
}
|
||||
}
|
||||
let mut child = cmd.spawn().map_err(|e| format!("failed to start {program}: {e}"))?;
|
||||
let pid = child.id();
|
||||
if let Some(out) = child.stdout.take() {
|
||||
forward_output(s, out, "stdout");
|
||||
let child = pair.slave.spawn_command(cmd).map_err(|e| format!("failed to start {program}: {e}"))?;
|
||||
drop(pair.slave);
|
||||
let reader = pair.master.try_clone_reader().map_err(|e| format!("terminal of {program}: {e}"))?;
|
||||
let writer = pair.master.take_writer().map_err(|e| format!("terminal of {program}: {e}"))?;
|
||||
let pid = child.process_id();
|
||||
forward_terminal_output(s, reader);
|
||||
if let Some(mut previous) = s.program.lock().replace(ProgramTerminal { writer, child, _master: pair.master }) {
|
||||
let _ = previous.child.kill();
|
||||
}
|
||||
if let Some(err) = child.stderr.take() {
|
||||
forward_output(s, err, "stderr");
|
||||
}
|
||||
s.debuggees.lock().push(child);
|
||||
s.owner.sink.emit(IdeEvent::Debug { session: s.id, event: DebugEvent::ProgramInput });
|
||||
Ok(json!({ "processId": pid }))
|
||||
}
|
||||
|
||||
/// The program's terminal output, as stdout lines (`\r\n` as `\n`; colours are kept).
|
||||
fn forward_terminal_output(s: &Session, mut reader: Box<dyn std::io::Read + Send>) {
|
||||
let session = s.id;
|
||||
let sink = s.owner.sink.clone();
|
||||
let _ = std::thread::Builder::new().name(format!("openidea-debuggee-{}", session.0)).spawn(move || {
|
||||
let mut buf = vec![0u8; 8192];
|
||||
let mut pending: Vec<u8> = Vec::new();
|
||||
loop {
|
||||
match reader.read(&mut buf) {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => pending.extend_from_slice(&buf[..n]),
|
||||
}
|
||||
// Keep incomplete UTF-8 sequences, and a `\r` that may start a `\r\n`, for the next chunk.
|
||||
let mut valid = match std::str::from_utf8(&pending) {
|
||||
Ok(_) => pending.len(),
|
||||
Err(e) if e.error_len().is_none() => e.valid_up_to(),
|
||||
Err(_) => pending.len(),
|
||||
};
|
||||
if valid > 0 && pending[valid - 1] == b'\r' {
|
||||
valid -= 1;
|
||||
}
|
||||
let output = String::from_utf8_lossy(&pending[..valid]).replace("\r\n", "\n");
|
||||
pending.drain(..valid);
|
||||
if !output.is_empty() {
|
||||
sink.emit(IdeEvent::Debug { session, event: DebugEvent::Output { category: "stdout".into(), output } });
|
||||
}
|
||||
}
|
||||
if !pending.is_empty() {
|
||||
let output = String::from_utf8_lossy(&pending).replace("\r\n", "\n");
|
||||
sink.emit(IdeEvent::Debug { session, event: DebugEvent::Output { category: "stdout".into(), output } });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn forward_output<R: AsyncRead + Unpin + Send + 'static>(s: &Session, mut stream: R, category: &'static str) {
|
||||
let session = s.id;
|
||||
let sink = s.owner.sink.clone();
|
||||
|
||||
@@ -468,6 +468,40 @@ async fn run_in_terminal_spawns_debuggee() {
|
||||
assert!(response["body"]["processId"].as_u64().unwrap() > 0);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn run_in_terminal_program_reads_typed_input() {
|
||||
let h = harness(Script {
|
||||
// CodeLLDB's launcher asks to clear the terminal; the console keeps its output.
|
||||
run_in_terminal: Some(vec![
|
||||
"sh".into(),
|
||||
"-c".into(),
|
||||
"[ -t 0 ] && echo 'name?' && read name && echo \"hello $name\"".into(),
|
||||
"--clear-screen".into(),
|
||||
]),
|
||||
..Script::default()
|
||||
});
|
||||
let session = h.start(config()).await.unwrap();
|
||||
h.wait_event(|e| matches!(e, DebugEvent::ProgramInput)).await;
|
||||
h.wait_event(|e| matches!(e, DebugEvent::Output { output, .. } if output.contains("name?"))).await;
|
||||
h.manager.send_input(session, "Bob\n").unwrap();
|
||||
h.wait_event(|e| matches!(e, DebugEvent::Output { output, .. } if output.contains("hello Bob"))).await;
|
||||
let output: String = h
|
||||
.events
|
||||
.lock()
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
IdeEvent::Debug { event: DebugEvent::Output { category, output }, .. } if category == "stdout" => {
|
||||
Some(output.clone())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert!(!output.contains('\r'), "{output:?}");
|
||||
h.manager.stop(session).await.unwrap();
|
||||
assert!(h.manager.send_input(session, "x\n").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_arguments_merge_and_defaults() {
|
||||
let mut c = config();
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
//! Drives php-debug (vscode-php-debug) the way a PHP Remote Debug (Xdebug)
|
||||
//! configuration does: a launch without a program makes it listen, and every PHP
|
||||
//! run that Xdebug connects stops at the breakpoint set in the local copy of the
|
||||
//! file, through `pathMappings`, until the session is stopped. A second listener on
|
||||
//! the same port fails to start and names the port.
|
||||
//!
|
||||
//! Ignored by default (needs PHP with Xdebug 3, Node.js and php-debug). Run with
|
||||
//! `PHP_DEBUG_ADAPTER=<php-debug-adapter> cargo test -p ide-dap --test php_debug -- --ignored --nocapture`
|
||||
//! (`PHP_BIN` picks the PHP executable, default `php`).
|
||||
|
||||
use ide_core::event::DebugEvent;
|
||||
use ide_core::ipc_path::to_ipc;
|
||||
use ide_core::{IdeEvent, SharedSink};
|
||||
use ide_dap::*;
|
||||
use ide_language::LanguageRegistry;
|
||||
use parking_lot::Mutex;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const SCRIPT: &str = "<?php\n$greeting = \"hello\";\n$n = 40 + 2;\necho \"$greeting $n\\n\";\n";
|
||||
/// 0-based line of `$n = 40 + 2;`.
|
||||
const BREAK_LINE: u32 = 2;
|
||||
|
||||
async fn wait_for(events: &Mutex<Vec<IdeEvent>>, timeout: Duration, pred: impl Fn(&DebugEvent) -> bool) -> DebugEvent {
|
||||
let start = Instant::now();
|
||||
loop {
|
||||
if let Some(e) = events.lock().iter().find_map(|e| match e {
|
||||
IdeEvent::Debug { event, .. } if pred(event) => Some(event.clone()),
|
||||
_ => None,
|
||||
}) {
|
||||
return e;
|
||||
}
|
||||
assert!(start.elapsed() < timeout, "timed out; events: {:#?}", events.lock());
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
}
|
||||
|
||||
fn listen_config(port: u16, served: &Path, local: &Path) -> LaunchConfig {
|
||||
// As remote.ts builds it for Remote root = `served`, Local root = `local`.
|
||||
let mapping = serde_json::Map::from_iter([(to_ipc(served), to_ipc(local).into())]);
|
||||
LaunchConfig {
|
||||
name: "PHP Remote".into(),
|
||||
adapter: "php-debug".into(),
|
||||
request: "launch".into(),
|
||||
program: None,
|
||||
args: vec![],
|
||||
cwd: Some(local.to_path_buf().into()),
|
||||
env: BTreeMap::new(),
|
||||
extra_json: Some(serde_json::json!({ "port": port, "pathMappings": mapping }).to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[ignore]
|
||||
async fn listens_for_xdebug_until_stopped_with_path_mappings() {
|
||||
let Ok(adapter) = std::env::var("PHP_DEBUG_ADAPTER") else {
|
||||
eprintln!("PHP_DEBUG_ADAPTER not set; skipping");
|
||||
return;
|
||||
};
|
||||
let php = std::env::var("PHP_BIN").unwrap_or_else(|_| "php".into());
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
// The project here, and the copy PHP runs from (a container's /var/www/html).
|
||||
let (local, served) = (dir.path().join("local"), dir.path().join("served"));
|
||||
for d in [&local, &served] {
|
||||
std::fs::create_dir_all(d).unwrap();
|
||||
std::fs::write(d.join("index.php"), SCRIPT).unwrap();
|
||||
}
|
||||
// Resolved like the project root the UI sends (`/private/var` on macOS, no `\\?\` on Windows).
|
||||
let resolved = |p: &Path| PathBuf::from(to_ipc(&std::fs::canonicalize(p).unwrap()));
|
||||
let (local, served) = (resolved(&local), resolved(&served));
|
||||
let port = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap().local_addr().unwrap().port();
|
||||
|
||||
let manager = DebugManager::new(Arc::new(LanguageRegistry::load(None).unwrap()));
|
||||
let events: Arc<Mutex<Vec<IdeEvent>>> = Arc::default();
|
||||
let sink: SharedSink = {
|
||||
let events = events.clone();
|
||||
Arc::new(move |e: IdeEvent| events.lock().push(e))
|
||||
};
|
||||
let local_script = local.join("index.php");
|
||||
manager
|
||||
.set_breakpoints(
|
||||
local_script.clone(),
|
||||
vec![Breakpoint {
|
||||
line: BREAK_LINE,
|
||||
condition: None,
|
||||
hit_condition: None,
|
||||
log_message: None,
|
||||
enabled: true,
|
||||
}],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let id = manager
|
||||
.start_with(listen_config(port, &served, &local), SessionOwner::new("main", sink.clone()), |_| {
|
||||
Some(adapter.clone())
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|e| panic!("start: {e:?}"));
|
||||
|
||||
// Requests in a row each stop; the session keeps listening between them.
|
||||
for request in 1..=2 {
|
||||
events.lock().clear();
|
||||
let run = std::process::Command::new(&php)
|
||||
.args([
|
||||
"-dxdebug.mode=debug",
|
||||
"-dxdebug.start_with_request=yes",
|
||||
"-dxdebug.client_host=127.0.0.1",
|
||||
&format!("-dxdebug.client_port={port}"),
|
||||
"index.php",
|
||||
])
|
||||
.current_dir(&served)
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let stopped = wait_for(&events, Duration::from_secs(30), |e| matches!(e, DebugEvent::Stopped { .. })).await;
|
||||
let DebugEvent::Stopped { thread_id, .. } = stopped else { unreachable!() };
|
||||
let frames = manager.stack_trace(id, thread_id).await.unwrap();
|
||||
assert_eq!(frames[0].line, BREAK_LINE, "request {request}");
|
||||
let top = frames[0].path.as_deref().and_then(|p| p.canonicalize().ok());
|
||||
assert_eq!(top, local_script.canonicalize().ok(), "request {request}");
|
||||
manager.resume(id, thread_id).await.unwrap();
|
||||
wait_for(&events, Duration::from_secs(30), |e| matches!(e, DebugEvent::Thread { started: false, .. })).await;
|
||||
let out = run.wait_with_output().unwrap();
|
||||
assert_eq!(String::from_utf8_lossy(&out.stdout), "hello 42\n", "request {request}");
|
||||
assert!(manager.sessions(None).iter().any(|s| s.id == id), "still listening after request {request}");
|
||||
}
|
||||
|
||||
// Another listener on the port (another IDE): the start fails and says why.
|
||||
let err = manager
|
||||
.start_with(listen_config(port, &served, &local), SessionOwner::new("main", sink), |_| Some(adapter.clone()))
|
||||
.await
|
||||
.expect_err("a second listener on the same port");
|
||||
assert!(err.message.contains("EADDRINUSE") && err.message.ends_with(&format!(":{port}")), "{err:?}");
|
||||
assert_eq!(manager.sessions(None).len(), 1);
|
||||
|
||||
manager.stop(id).await.unwrap();
|
||||
assert!(manager.sessions(None).is_empty());
|
||||
}
|
||||
@@ -440,3 +440,89 @@ pub fn compose_op(
|
||||
});
|
||||
run_streaming(ep, None, &args, cancel, on_output)
|
||||
}
|
||||
|
||||
/// Whether `ep` is a daemon on this machine, where a bind mount of the project
|
||||
/// is this machine's folder. The CLI's default daemon and a context are looked
|
||||
/// up (`DOCKER_HOST`, else the context's endpoint).
|
||||
pub fn endpoint_is_local(ep: &DockerEndpoint) -> IdeResult<bool> {
|
||||
let url = match ep {
|
||||
DockerEndpoint::Socket { path } => path.trim().to_owned(),
|
||||
DockerEndpoint::Tcp { host, .. } => {
|
||||
let host = host.trim();
|
||||
if host.contains("://") { host.to_owned() } else { format!("tcp://{host}") }
|
||||
}
|
||||
DockerEndpoint::Default => {
|
||||
match ide_core::process::host_var_os("DOCKER_HOST").map(|v| v.to_string_lossy().trim().to_owned()) {
|
||||
Some(url) if !url.is_empty() => url,
|
||||
_ => context_host(ep, None)?,
|
||||
}
|
||||
}
|
||||
DockerEndpoint::Context { name } => context_host(&DockerEndpoint::Default, Some(name.trim()))?,
|
||||
};
|
||||
Ok(daemon_url_is_local(&url))
|
||||
}
|
||||
|
||||
/// The daemon URL of context `name` (the current one with `None`).
|
||||
fn context_host(ep: &DockerEndpoint, name: Option<&str>) -> IdeResult<String> {
|
||||
let mut args = vec!["context", "inspect", "--format", "{{.Endpoints.docker.Host}}"];
|
||||
args.extend(name);
|
||||
Ok(run(ep, &args, QUICK)?.trim().to_owned())
|
||||
}
|
||||
|
||||
/// `unix://`, `npipe://`, a bare socket path and TCP on the loopback address are
|
||||
/// this machine; `ssh://` and other hosts are not.
|
||||
pub fn daemon_url_is_local(url: &str) -> bool {
|
||||
let url = url.trim();
|
||||
let Some((scheme, rest)) = url.split_once("://") else { return true };
|
||||
match scheme {
|
||||
"unix" | "npipe" => true,
|
||||
"tcp" | "http" | "https" => {
|
||||
let host = rest.split('/').next().unwrap_or_default();
|
||||
let host = match host.strip_prefix('[') {
|
||||
Some(v6) => v6.split(']').next().unwrap_or_default(),
|
||||
None => host.rsplit_once(':').map_or(host, |(h, _)| h),
|
||||
};
|
||||
matches!(host, "localhost" | "127.0.0.1" | "::1")
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// `docker compose config --format json` of the project at `dir` (`files`
|
||||
/// relative to it; none = the project's default file).
|
||||
pub fn compose_config(ep: &DockerEndpoint, dir: &Path, files: &[String]) -> IdeResult<String> {
|
||||
let mut args: Vec<&str> = vec!["compose"];
|
||||
for f in files.iter().map(|f| f.trim()).filter(|f| !f.is_empty()) {
|
||||
args.extend(["--file", f]);
|
||||
}
|
||||
args.extend(["config", "--format", "json"]);
|
||||
let mut cmd = command(ep, &args)?;
|
||||
cmd.current_dir(dir);
|
||||
let out = process::run(cmd, QUICK, None)?;
|
||||
if out.success { Ok(out.stdout) } else { Err(failure(&args, &out.stderr, &out.stdout)) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod local_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn local_daemon_urls() {
|
||||
for url in [
|
||||
"unix:///var/run/docker.sock",
|
||||
"/var/run/docker.sock",
|
||||
"npipe:////./pipe/docker_engine",
|
||||
"tcp://localhost:2375",
|
||||
"tcp://127.0.0.1:2376",
|
||||
"tcp://[::1]:2375",
|
||||
"",
|
||||
] {
|
||||
assert!(daemon_url_is_local(url), "{url}");
|
||||
}
|
||||
for url in ["ssh://me@build", "tcp://build:2376", "tcp://10.0.0.5:2375", "https://docker.example.com"] {
|
||||
assert!(!daemon_url_is_local(url), "{url}");
|
||||
}
|
||||
assert!(endpoint_is_local(&DockerEndpoint::Socket { path: "/run/user/1000/docker.sock".into() }).unwrap());
|
||||
assert!(!endpoint_is_local(&DockerEndpoint::Tcp { host: "build:2376".into(), tls_cert_dir: None }).unwrap());
|
||||
}
|
||||
}
|
||||
@@ -98,7 +98,15 @@ fn parse_unary(t: &[Tok], p: &mut usize, src: &str) -> Result<(), String> {
|
||||
*p += 1;
|
||||
Ok(())
|
||||
}
|
||||
Some(Tok::Id(_)) => {
|
||||
Some(Tok::Id(id)) => {
|
||||
if let Some(path) = id.strip_prefix("workspaceContains:")
|
||||
&& (path.is_empty()
|
||||
|| path.starts_with('/')
|
||||
|| path.contains('\\')
|
||||
|| path.split('/').any(|s| s == ".."))
|
||||
{
|
||||
return Err(format!("workspaceContains: needs a relative path inside the project: {src}"));
|
||||
}
|
||||
*p += 1;
|
||||
if matches!(t.get(*p), Some(Tok::Op("==" | "!="))) {
|
||||
*p += 1;
|
||||
@@ -133,4 +141,23 @@ mod tests {
|
||||
assert!(validate_when(bad).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_contains_paths_stay_in_the_project() {
|
||||
for ok in [
|
||||
"workspaceContains:pom.xml",
|
||||
"workspaceContains:.devcontainer/devcontainer.json",
|
||||
"!workspaceContains:a/b && editorFocus",
|
||||
] {
|
||||
assert!(validate_when(ok).is_ok(), "{ok}");
|
||||
}
|
||||
for bad in [
|
||||
"workspaceContains:",
|
||||
"workspaceContains:/etc/passwd",
|
||||
"workspaceContains:../x",
|
||||
"workspaceContains:a/../../x",
|
||||
] {
|
||||
assert!(validate_when(bad).unwrap_err().contains("workspaceContains"), "{bad}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -92,6 +92,33 @@ fn format_range(text: &ide_text::Document, range: Range) -> formatter::FormatRan
|
||||
}
|
||||
}
|
||||
|
||||
/// The edits PHP-CS-Fixer makes to the unsaved text: it fixes a temporary copy with the file's
|
||||
/// name (from stdin it doesn't fix anything), so the file on disk stays as it is.
|
||||
fn php_cs_fixer_edits(
|
||||
tools: &Tools,
|
||||
php: Option<&Path>,
|
||||
fixer: &formatter::PhpCsFixer,
|
||||
path: &Path,
|
||||
unsaved: &str,
|
||||
) -> IdeResult<Vec<TextEdit>> {
|
||||
let name = path.file_name().ok_or_else(|| IdeError::invalid("PHP-CS-Fixer: file has no name"))?;
|
||||
let dir = tempfile::tempdir()?;
|
||||
let copy = dir.path().join(name);
|
||||
std::fs::write(©, unsaved)?;
|
||||
formatter::run_php_cs_fixer(tools.php_cs_fixer_command(php, fixer, ©)?, &Default::default())?;
|
||||
let formatted = std::fs::read_to_string(©)?;
|
||||
let text = ide_text::Document::new(unsaved);
|
||||
Ok(text
|
||||
.diff(&formatted)
|
||||
.changes
|
||||
.into_iter()
|
||||
.map(|c| TextEdit {
|
||||
range: Range::new(text.offset_to_pos(c.from), text.offset_to_pos(c.to)),
|
||||
new_text: c.insert,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn format(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
@@ -103,6 +130,34 @@ pub async fn format(
|
||||
let snap = project.documents.snapshot(doc)?;
|
||||
let settings = project.settings.effective();
|
||||
let language = project.languages.get(&snap.language);
|
||||
// A committed PHP-CS-Fixer configuration takes over Reformat Code for `.php` files.
|
||||
if options.tool.is_none()
|
||||
&& snap.path.extension().is_some_and(|e| e == "php")
|
||||
&& let Some(fixer) = formatter::php_cs_fixer(&snap.path)
|
||||
{
|
||||
if options.on_save && !settings.editor.format_on_save {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
host.require_trust(window)?;
|
||||
if snap.read_only {
|
||||
return Err(IdeError::invalid("The file is read-only"));
|
||||
}
|
||||
let php = ide_sdk::apply::setting_path(&project.root, &settings.languages.php.interpreter);
|
||||
let tools = Tools::new(&host.local_data_dir);
|
||||
let edits = tokio::task::spawn_blocking(move || {
|
||||
php_cs_fixer_edits(&tools, php.as_deref(), &fixer, &snap.path, &snap.text)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))??;
|
||||
if range.is_some() {
|
||||
project.sink.emit(IdeEvent::Notify {
|
||||
level: NotifyLevel::Info,
|
||||
title: "PHP-CS-Fixer".into(),
|
||||
body: Some("PHP-CS-Fixer does not support selection formatting; formatted the whole file".into()),
|
||||
});
|
||||
}
|
||||
return Ok(edits);
|
||||
}
|
||||
let mut selected = external_tool(options.tool, language.as_ref(), &snap.path, &settings.languages)?;
|
||||
if let Some(tool) = selected
|
||||
&& options.tool.is_none()
|
||||
@@ -273,6 +328,59 @@ mod tests {
|
||||
assert_eq!(rust.message, "oxfmt cannot format Rust files");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn php_cs_fixer_needs_a_config_and_the_project_binary() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
std::fs::create_dir_all(root.join("src/Http")).unwrap();
|
||||
let file = root.join("src/Http/Kernel.php");
|
||||
assert_eq!(formatter::php_cs_fixer(&file), None);
|
||||
std::fs::write(root.join(".php-cs-fixer.dist.php"), "<?php").unwrap();
|
||||
assert_eq!(formatter::php_cs_fixer(&file), None, "no vendor/bin/php-cs-fixer");
|
||||
std::fs::create_dir_all(root.join("vendor/bin")).unwrap();
|
||||
std::fs::write(root.join("vendor/bin/php-cs-fixer"), "").unwrap();
|
||||
let found = formatter::php_cs_fixer(&file).unwrap();
|
||||
assert_eq!(found.config, root.join(".php-cs-fixer.dist.php"));
|
||||
assert_eq!(found.bin, root.join("vendor/bin/php-cs-fixer"));
|
||||
std::fs::write(root.join(".php-cs-fixer.php"), "<?php").unwrap();
|
||||
assert_eq!(formatter::php_cs_fixer(&file).unwrap().config, root.join(".php-cs-fixer.php"));
|
||||
std::fs::write(root.join("src/.php-cs-fixer.dist.php"), "<?php").unwrap();
|
||||
assert_eq!(formatter::php_cs_fixer(&file).unwrap().config, root.join("src/.php-cs-fixer.dist.php"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn php_cs_fixer_fixes_a_copy_of_the_unsaved_text() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
let fixer = formatter::PhpCsFixer { config: root.join(".php-cs-fixer.dist.php"), bin: root.join("fixer") };
|
||||
std::fs::write(&fixer.config, "<?php").unwrap();
|
||||
let php = root.join("php");
|
||||
// Stands in for `php vendor/bin/php-cs-fixer fix … <file>`: rewrites the last argument.
|
||||
std::fs::write(
|
||||
&php,
|
||||
"#!/bin/sh\nfor last; do :; done\ncase \"$last\" in */Kernel.php) ;; *) exit 3 ;; esac\n\
|
||||
[ \"$2\" = fix ] || exit 4\nsed 's/ echo/ echo/' \"$last\" > \"$last.new\" && mv \"$last.new\" \"$last\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::set_permissions(&php, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let file = root.join("src/Kernel.php");
|
||||
let tools = Tools::new(&root.join("tools"));
|
||||
let edits = php_cs_fixer_edits(&tools, Some(&php), &fixer, &file, "<?php\n echo 1;\n").unwrap();
|
||||
assert_eq!(edits.len(), 1);
|
||||
assert_eq!(edits[0].new_text, " ");
|
||||
assert!(!file.exists(), "the file on disk is left alone");
|
||||
|
||||
std::fs::write(&php, "#!/bin/sh\necho 'Configuration file is invalid' >&2\nexit 16\n").unwrap();
|
||||
let err = php_cs_fixer_edits(&tools, Some(&php), &fixer, &file, "<?php\n").unwrap_err();
|
||||
assert!(
|
||||
err.message.starts_with("PHP-CS-Fixer: ") && err.message.contains("Configuration file is invalid"),
|
||||
"{}",
|
||||
err.message
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ruff_ranges_are_one_based_unicode_columns() {
|
||||
let text = ide_text::Document::new("😀x = 1\ny = 2\n");
|
||||
|
||||
@@ -245,6 +245,7 @@ impl Host {
|
||||
project.kotlin_bridge.iter().cloned().collect(),
|
||||
Some(project.lsp.clone()),
|
||||
Some((self.debugger.clone(), window.to_owned())),
|
||||
Some(project),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -257,6 +258,7 @@ impl Host {
|
||||
bridges: Vec<Arc<kotlin_bridge::KotlinBridge>>,
|
||||
lsp: Option<Arc<ide_lsp::LspManager>>,
|
||||
debug: Option<(Arc<ide_dap::DebugManager>, String)>,
|
||||
retired: Option<Arc<Project>>,
|
||||
) -> std::thread::JoinHandle<()> {
|
||||
for bridge in &bridges {
|
||||
bridge.stop();
|
||||
@@ -266,6 +268,10 @@ impl Host {
|
||||
}
|
||||
let runtime = self.runtime.clone();
|
||||
std::thread::spawn(move || {
|
||||
// Dropping the project stops its file watcher and index worker, which first finish the
|
||||
// batch they are applying (many file events while a build writes its output), so it
|
||||
// isn't done on the closing caller's thread (the UI thread for a window close).
|
||||
let retired = retired.map(|project| std::thread::spawn(move || drop(project)));
|
||||
runtime.block_on(async move {
|
||||
for bridge in bridges {
|
||||
bridge.shutdown().await;
|
||||
@@ -280,6 +286,9 @@ impl Host {
|
||||
debug.stop_key(&key).await;
|
||||
}
|
||||
});
|
||||
if let Some(retired) = retired {
|
||||
let _ = retired.join();
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -319,6 +328,7 @@ impl Host {
|
||||
projects.iter().filter_map(|project| project.kotlin_bridge.clone()).collect(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
));
|
||||
let deadline = std::time::Instant::now() + EXIT_SHUTDOWN_WAIT;
|
||||
while pending.iter().any(|handle| !handle.is_finished()) {
|
||||
@@ -422,3 +432,37 @@ impl Host {
|
||||
self.projects.write().insert(window.to_owned(), project);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn detached_close_releases_the_project_before_a_reopen() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let base = dunce::canonicalize(temp.path()).unwrap();
|
||||
let root = base.join("project");
|
||||
std::fs::create_dir(&root).unwrap();
|
||||
std::fs::write(root.join("main.rs"), "fn main() {}\n").unwrap();
|
||||
let host = Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
data_dir: base.join("data"),
|
||||
local_data_dir: base.join("local"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(|_| {})),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
});
|
||||
let project = Arc::downgrade(&host.open_project("main", &root).unwrap());
|
||||
host.close_project_detached("main");
|
||||
assert!(host.project("main").is_err());
|
||||
// The project (its file watcher and index database) is dropped off the closing thread, but
|
||||
// before opening a project waits out the close.
|
||||
host.wait_detached_closes();
|
||||
assert!(project.upgrade().is_none(), "the closed project is dropped");
|
||||
host.open_project("main", &root).unwrap();
|
||||
host.close_project("main");
|
||||
}
|
||||
}
|
||||
@@ -162,6 +162,11 @@ pub async fn debug_pause(host: &Host, _window: &str, session: DebugSessionId, th
|
||||
host.debugger.pause(session, thread_id).await
|
||||
}
|
||||
|
||||
/// Types `text` into the session's program (a `runInTerminal` program).
|
||||
pub async fn debug_send_input(host: &Host, _window: &str, session: DebugSessionId, text: String) -> IdeResult<()> {
|
||||
host.debugger.send_input(session, &text)
|
||||
}
|
||||
|
||||
pub async fn debug_hot_reload(host: &Host, _window: &str, session: DebugSessionId) -> IdeResult<()> {
|
||||
host.debugger.hot_reload(session).await
|
||||
}
|
||||
@@ -381,6 +386,9 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
"debug_pause",
|
||||
|c| async move { debug_pause(&c.host, &c.window, c.arg("session")?, c.arg("threadId")?).await },
|
||||
);
|
||||
d.add("debug_send_input", |c| async move {
|
||||
debug_send_input(&c.host, &c.window, c.arg("session")?, c.arg("text")?).await
|
||||
});
|
||||
d.add("debug_step", |c| async move {
|
||||
debug_step(&c.host, &c.window, c.arg("session")?, c.arg("threadId")?, c.arg("kind")?).await
|
||||
});
|
||||
|
||||
@@ -242,6 +242,29 @@ mod tests {
|
||||
assert!(build().is_err());
|
||||
assert_eq!(fs::read_to_string(root.join("calls")).unwrap().lines().count(), 5);
|
||||
}
|
||||
/// An external tool (e.g. an AI code editor) rewrites a source file with the same byte
|
||||
/// length and preserves its mtime. The `(length, mtime)` fingerprint reads that as
|
||||
/// "unchanged" and would reuse the stale snapshot; the content hash must catch it.
|
||||
#[test]
|
||||
fn external_edit_preserving_length_and_mtime_rebuilds_the_snapshot() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
let model = fixture(root);
|
||||
let cache = root.join("cache");
|
||||
let cancel = CancellationToken::new();
|
||||
let mut session = Session::new(cache.clone());
|
||||
let first = session.build(root, &model, root, false, None, &cancel).unwrap();
|
||||
assert_eq!(fs::read_to_string(first.snapshot.join("0/Main.class")).unwrap(), "first");
|
||||
|
||||
let source = root.join("src/main/kotlin/Main.kt");
|
||||
let mtime = fs::metadata(&source).unwrap().modified().unwrap();
|
||||
fs::write(&source, "other").unwrap(); // 5 bytes, same length as "first"
|
||||
fs::File::options().write(true).open(&source).unwrap().set_modified(mtime).unwrap();
|
||||
let rebuilt = session.build(root, &model, root, false, None, &cancel).unwrap();
|
||||
assert_ne!(rebuilt.snapshot, first.snapshot, "a same-length, same-mtime content change must rebuild");
|
||||
assert_eq!(fs::read_to_string(rebuilt.snapshot.join("0/Main.class")).unwrap(), "other");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_drop_removes_superseded_snapshots_and_preserves_records_across_reopen() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -257,6 +257,28 @@ pub async fn lsp_implementations(
|
||||
result
|
||||
}
|
||||
|
||||
/// Go to Implementation of the symbol at `pos`. Implementations found before a slower
|
||||
/// search for the rest (Java: the project's, before the dependencies') arrive on
|
||||
/// `on_partial`; the result has them all. Cancel with `cancel_request(request)`.
|
||||
pub async fn lsp_goto_implementation(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
request: RequestId,
|
||||
doc: DocId,
|
||||
pos: Pos,
|
||||
on_partial: super::Channel<Vec<Location>>,
|
||||
) -> IdeResult<Vec<Location>> {
|
||||
let lsp = host.project(window)?.lsp.clone();
|
||||
let cancel = host.begin_request(request);
|
||||
let result = lsp
|
||||
.goto_implementation(doc, pos, cancel, &|locs| {
|
||||
let _ = on_partial.send(locs);
|
||||
})
|
||||
.await;
|
||||
host.end_request(request);
|
||||
result
|
||||
}
|
||||
|
||||
/// Contents of a read-only language-server document, including JDT library
|
||||
/// classes and disk class files. Cached per project.
|
||||
pub async fn lsp_virtual_document(host: &Host, window: &str, uri: String) -> IdeResult<VirtualDocument> {
|
||||
@@ -746,6 +768,10 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("lsp_implementations", |c| async move {
|
||||
lsp_implementations(&c.host, &c.window, c.arg("request")?, c.arg("doc")?, c.arg("positions")?).await
|
||||
});
|
||||
d.add("lsp_goto_implementation", |c| async move {
|
||||
let on_partial = c.sender("onPartial")?;
|
||||
lsp_goto_implementation(&c.host, &c.window, c.arg("request")?, c.arg("doc")?, c.arg("pos")?, on_partial).await
|
||||
});
|
||||
d.add(
|
||||
"lsp_resolve_class_file",
|
||||
|c| async move { lsp_resolve_class_file(&c.host, &c.window, c.arg("path")?).await },
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use super::Emit;
|
||||
use crate::Host;
|
||||
use ide_core::remote_settings::RunTargetKind;
|
||||
use ide_core::{IdeError, IdeResult, RunId};
|
||||
use ide_run::{OutputChunk, RunConfig, RunInfo, StartOptions};
|
||||
use std::time::Duration;
|
||||
@@ -140,12 +141,33 @@ pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit
|
||||
if config.command.is_empty() {
|
||||
return Err(IdeError::invalid("run configuration has no command"));
|
||||
}
|
||||
// Settings › Run Targets: over SSH.
|
||||
// Settings › Run Targets: over SSH, or in a container.
|
||||
if config.kind == "tomcat" && config.extras.target.as_deref().is_some_and(|t| !t.is_empty()) {
|
||||
return Err(IdeError::invalid("Tomcat Local requires the local machine"));
|
||||
}
|
||||
if config.extras.target.as_deref().is_some_and(|t| !t.is_empty()) {
|
||||
if let Some(id) = config.extras.target.as_deref().filter(|t| !t.is_empty()) {
|
||||
let settings = project.settings.effective();
|
||||
let target = ide_remote::service::run_target(&settings, id)?;
|
||||
if target.kind != RunTargetKind::Ssh {
|
||||
// A local run of the docker CLI: the command is resolved in the container, and
|
||||
// this machine's toolchains and `PYTHONPATH` don't apply.
|
||||
let config = container::prepare(host, &project.root, target, config).await?;
|
||||
let encoding = settings.console.encoding;
|
||||
let options = StartOptions {
|
||||
encoding: Some(encoding).filter(|e| !e.trim().is_empty()),
|
||||
python_paths: Vec::new(),
|
||||
// A folder on this machine: not in the container.
|
||||
ruby_reporter: None,
|
||||
};
|
||||
project.require_trust()?;
|
||||
return host.processes.start(
|
||||
&project.root,
|
||||
&config,
|
||||
&options,
|
||||
project.sink.clone(),
|
||||
Box::new(move |c| on_output(c)),
|
||||
);
|
||||
}
|
||||
let out = on_output.clone();
|
||||
return host
|
||||
.remote
|
||||
@@ -271,4 +293,5 @@ pub fn list(host: &Host) -> Vec<RunInfo> {
|
||||
}
|
||||
|
||||
mod commands;
|
||||
mod container;
|
||||
pub use commands::*;
|
||||
@@ -0,0 +1,108 @@
|
||||
//! Docker and Compose run targets: the configuration becomes a local run of the
|
||||
//! docker CLI (see `ide_remote::docker`).
|
||||
|
||||
use crate::Host;
|
||||
use ide_core::remote_settings::{RunTarget, RunTargetKind};
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use ide_docker::DockerEndpoint;
|
||||
use ide_remote::docker::{
|
||||
ContainerRun, compose_container_root, container_config, container_name, default_container_root,
|
||||
};
|
||||
use ide_run::RunConfig;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// `config` rewritten to run in a new container of `target`'s image or service.
|
||||
pub(super) async fn prepare(host: &Host, root: &Path, target: RunTarget, config: RunConfig) -> IdeResult<RunConfig> {
|
||||
if config.kind == "dotnet" || config.kind == "dotnet-test" {
|
||||
return Err(IdeError::invalid(".NET Project requires the local machine"));
|
||||
}
|
||||
let endpoint = endpoint(&host.data_dir, &target)?;
|
||||
let root = root.to_path_buf();
|
||||
host.blocking(move || {
|
||||
if !ide_docker::endpoint_is_local(&endpoint)? {
|
||||
return Err(IdeError::invalid(format!(
|
||||
"Run target '{}' uses a Docker daemon on another machine: the project can only be mounted from a local one",
|
||||
target.name
|
||||
)));
|
||||
}
|
||||
let docker = ide_docker::docker_executable().ok_or_else(|| {
|
||||
IdeError::unavailable("docker executable not found; install Docker and make sure it is on PATH")
|
||||
})?;
|
||||
let container_root = match target.container_root.trim() {
|
||||
"" if target.kind == RunTargetKind::Compose => {
|
||||
let json = ide_docker::compose_config(&endpoint, &root, &target.compose_files)?;
|
||||
compose_container_root(&json, target.service.trim(), &root)?
|
||||
}
|
||||
"" => default_container_root(&root),
|
||||
folder => folder.to_owned(),
|
||||
};
|
||||
let file_env = env_files(&root, &config)?;
|
||||
let name = container_name(&config.id, &unique());
|
||||
container_config(
|
||||
&config,
|
||||
&ContainerRun {
|
||||
target: &target,
|
||||
root: &root,
|
||||
container_root: &container_root,
|
||||
docker: &docker.to_string_lossy(),
|
||||
endpoint_env: &ide_docker::run_env(&endpoint),
|
||||
file_env: &file_env,
|
||||
name: &name,
|
||||
user: user(&root, &target),
|
||||
},
|
||||
)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// The target's Docker connection (Services tool window), by id; none = the CLI's daemon.
|
||||
fn endpoint(data_dir: &Path, target: &RunTarget) -> IdeResult<DockerEndpoint> {
|
||||
let id = target.docker_connection.trim();
|
||||
if id.is_empty() {
|
||||
return Ok(DockerEndpoint::Default);
|
||||
}
|
||||
ide_docker::load_settings(data_dir).connections.into_iter().find(|c| c.id == id).map(|c| c.endpoint).ok_or_else(
|
||||
|| {
|
||||
IdeError::not_found(format!(
|
||||
"Run target '{}' uses a Docker connection that no longer exists; choose one in Settings › Run Targets",
|
||||
target.name
|
||||
))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// The configuration's `.env` files, read here: the container gets their variables.
|
||||
fn env_files(root: &Path, config: &RunConfig) -> IdeResult<BTreeMap<String, String>> {
|
||||
let extras = &config.extras;
|
||||
if extras.env_files.is_empty() {
|
||||
return Ok(BTreeMap::new());
|
||||
}
|
||||
let paths: Vec<PathBuf> = extras
|
||||
.env_files
|
||||
.iter()
|
||||
.map(|f| if Path::new(f).is_absolute() { PathBuf::from(f) } else { root.join(f) })
|
||||
.collect();
|
||||
let base = if extras.include_system_env { ide_run::system_env() } else { BTreeMap::new() };
|
||||
ide_run::envfile::load(&paths, &base).map_err(|e| IdeError::not_found(format!("cannot read .env file: {e}")))
|
||||
}
|
||||
|
||||
/// On Linux the container writes files as the project folder's owner, unless the
|
||||
/// target keeps the image's user. Docker Desktop and OrbStack map ownership themselves.
|
||||
fn user(root: &Path, target: &RunTarget) -> Option<(u32, u32)> {
|
||||
#[cfg(target_os = "linux")]
|
||||
if !target.image_user {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
return std::fs::metadata(root).ok().map(|m| (m.uid(), m.gid()));
|
||||
}
|
||||
let _ = (root, target);
|
||||
None
|
||||
}
|
||||
|
||||
/// Distinguishes runs of the same configuration.
|
||||
fn unique() -> String {
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
static COUNT: AtomicU32 = AtomicU32::new(0);
|
||||
let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs());
|
||||
format!("{:x}-{}", secs & 0xff_ffff, COUNT.fetch_add(1, Ordering::Relaxed))
|
||||
}
|
||||
@@ -52,6 +52,14 @@ pub fn sdk_python_effective(host: &Host, window: &str) -> IdeResult<Option<ide_c
|
||||
Ok(ide_sdk::apply::python_interpreter(&project.root, &project.settings.effective()).map(Into::into))
|
||||
}
|
||||
|
||||
/// The PHP interpreter chosen in Settings › Languages & Frameworks › PHP; `None`: the `php` on `PATH`.
|
||||
pub fn sdk_php_effective(host: &Host, window: &str) -> IdeResult<Option<ide_core::IpcPath>> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let settings = project.settings.effective();
|
||||
Ok(ide_sdk::apply::setting_path(&project.root, &settings.languages.php.interpreter).map(Into::into))
|
||||
}
|
||||
|
||||
/// The PHP language level of the project `path` belongs to (a workspace member, by path, as
|
||||
/// the language server configuration picks it).
|
||||
#[derive(Clone, Debug, serde::Serialize, specta::Type)]
|
||||
@@ -88,6 +96,33 @@ pub async fn sdk_php_language_level(host: &Host, window: &str, path: PathBuf) ->
|
||||
.map_err(join_err)
|
||||
}
|
||||
|
||||
/// The Composer project `path` belongs to, when its dependencies need `composer install`.
|
||||
#[derive(Clone, Debug, serde::Serialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ComposerInstallState {
|
||||
pub root: ide_core::IpcPath,
|
||||
pub reason: ide_sdk::php::ComposerInstall,
|
||||
}
|
||||
|
||||
/// `None` in an untrusted project too: `composer install` runs the project's scripts.
|
||||
pub async fn sdk_php_composer_install(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
path: PathBuf,
|
||||
) -> IdeResult<Option<ComposerInstallState>> {
|
||||
let project = host.project(window)?;
|
||||
if !project.is_trusted() {
|
||||
return Ok(None);
|
||||
}
|
||||
let root = project.root.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
ide_sdk::php::composer_install(&path, &root)
|
||||
.map(|(root, reason)| ComposerInstallState { root: ide_core::IpcPath::from(root), reason })
|
||||
})
|
||||
.await
|
||||
.map_err(join_err)
|
||||
}
|
||||
|
||||
/// SDK / library source folders shown under "External Libraries" in the Project view,
|
||||
/// with the Maven / Gradle dependency jars of the build model.
|
||||
pub async fn sdk_project_libraries(host: &Host, window: &str) -> IdeResult<Vec<ide_sdk::libraries::LibraryGroup>> {
|
||||
@@ -198,10 +233,15 @@ pub async fn sdk_python_pip(
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("sdk_detect", |c| async move { sdk_detect(&c.host, &c.window, c.arg("kind")?).await });
|
||||
d.add("sdk_python_effective", |c| async move { sdk_python_effective(&c.host, &c.window) });
|
||||
d.add("sdk_php_effective", |c| async move { sdk_php_effective(&c.host, &c.window) });
|
||||
d.add(
|
||||
"sdk_php_language_level",
|
||||
|c| async move { sdk_php_language_level(&c.host, &c.window, c.arg("path")?).await },
|
||||
);
|
||||
d.add(
|
||||
"sdk_php_composer_install",
|
||||
|c| async move { sdk_php_composer_install(&c.host, &c.window, c.arg("path")?).await },
|
||||
);
|
||||
d.add("sdk_project_libraries", |c| async move { sdk_project_libraries(&c.host, &c.window).await });
|
||||
d.add("sdk_locate_tools", |c| async move {
|
||||
sdk_locate_tools(&c.host, &c.window, c.arg("names")?, c.arg("near")?).await
|
||||
|
||||
@@ -611,7 +611,7 @@ mod tests {
|
||||
}
|
||||
|
||||
/// A project with PHPStan whose PHP logs each run, waits `pause` seconds, prints `stderr` and
|
||||
/// reports `report`.
|
||||
/// reports `report`. `pause` is read before the run is logged, so a test that sees the run can change it.
|
||||
#[cfg(unix)]
|
||||
struct Fixture {
|
||||
_dir: tempfile::TempDir,
|
||||
@@ -634,8 +634,8 @@ mod tests {
|
||||
// Named php8.5: the selected PHP runs vendor/bin/phpstan, whatever its name.
|
||||
let php = base.join("php/php8.5");
|
||||
std::fs::create_dir_all(php.parent().unwrap()).unwrap();
|
||||
let script = "#!/bin/sh\nshift\necho \"$XDEBUG_MODE $*\" >> runs.log\n\
|
||||
sleep \"$(cat pause 2>/dev/null || echo 0)\"\ncat stderr >&2 2>/dev/null\ncat report\n";
|
||||
let script = "#!/bin/sh\nshift\npause=\"$(cat pause 2>/dev/null || echo 0)\"\n\
|
||||
echo \"$XDEBUG_MODE $*\" >> runs.log\nsleep \"$pause\"\ncat stderr >&2 2>/dev/null\ncat report\n";
|
||||
std::fs::write(&php, script).unwrap();
|
||||
std::fs::set_permissions(&php, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
std::fs::create_dir_all(root.join(".openidea")).unwrap();
|
||||
|
||||
@@ -30,6 +30,11 @@ pub trait PluginWorkspace: Send + Sync {
|
||||
fn projects(&self) -> Vec<PathBuf>;
|
||||
/// Unsaved text of the open document at `path`.
|
||||
fn buffer_text(&self, path: &Path) -> Option<String>;
|
||||
/// Up to `limit` files of the project index below `root` whose root-relative path satisfies
|
||||
/// `matches`; `None` when no index covers `root` yet (the caller walks the disk instead).
|
||||
fn indexed_files(&self, _root: &Path, _matches: &dyn Fn(&Path) -> bool, _limit: usize) -> Option<Vec<PathBuf>> {
|
||||
None
|
||||
}
|
||||
/// Version and text of the open document at `path`.
|
||||
fn document(&self, path: &Path) -> Option<(u64, String)>;
|
||||
/// Applies `edits` to the open document at `path` if it is still at
|
||||
@@ -306,8 +311,9 @@ impl PluginApi {
|
||||
// allowed, and installing it later approves nothing by itself.
|
||||
let pin = self.shared.pins.read().get(&(self.id().to_owned(), cap.clone())).cloned().ok_or_else(|| {
|
||||
HostError::Failed(format!(
|
||||
"{cap}: `{program}` was not found when this permission was allowed; \
|
||||
install it, then allow {cap} again in Settings › Plugins"
|
||||
"{cap}: `{program}` was not found on PATH when this permission was allowed. \
|
||||
Restarting doesn't approve a program installed later: once `{program}` is on PATH, \
|
||||
clear {cap} in Settings › Plugins and select it again"
|
||||
))
|
||||
})?;
|
||||
pin.verify().map(Path::to_path_buf).map_err(|e| HostError::Denied(format!("{cap}: {e}")))
|
||||
@@ -458,6 +464,19 @@ impl HostApi for PluginApi {
|
||||
.build()
|
||||
.map_err(|e| HostError::Invalid(format!("{glob:?}: {e}")))?
|
||||
.compile_matcher();
|
||||
// Hidden files and folders are left out, as in the walk below.
|
||||
let visible = |rel: &Path| !rel.iter().any(|c| c.to_str().is_some_and(|c| c.starts_with('.')));
|
||||
// The project index already lists the files: walking a large tree on every call can take
|
||||
// longer than an editor provider's deadline (the plugin is then stopped as crashed).
|
||||
if let Some(files) = self
|
||||
.shared
|
||||
.workspace()
|
||||
.and_then(|w| w.indexed_files(root, &|rel| visible(rel) && matcher.is_match(rel), limit))
|
||||
{
|
||||
let mut out: Vec<String> = files.iter().map(|p| to_ipc(p)).collect();
|
||||
out.sort();
|
||||
return Ok(out);
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for (n, entry) in ignore::WalkBuilder::new(root).hidden(true).build().flatten().enumerate() {
|
||||
if n >= MAX_WALK || out.len() >= limit {
|
||||
@@ -737,6 +756,12 @@ impl PluginWorkspace for HostWorkspace {
|
||||
HostWorkspace::projects(self).iter().find_map(|p| p.documents.text_of(path))
|
||||
}
|
||||
|
||||
fn indexed_files(&self, root: &Path, matches: &dyn Fn(&Path) -> bool, limit: usize) -> Option<Vec<PathBuf>> {
|
||||
// The index of the project rooted at `root` (a workspace member's files are in its own).
|
||||
let project = HostWorkspace::projects(self).into_iter().find(|p| p.root == root)?;
|
||||
project.index.files_matching(root, matches, limit)
|
||||
}
|
||||
|
||||
fn document(&self, path: &Path) -> Option<(u64, String)> {
|
||||
let (project, doc) = self.open_document(path)?;
|
||||
let snap = project.documents.snapshot(doc).ok()?;
|
||||
@@ -820,6 +845,60 @@ mod tests {
|
||||
assert_eq!(api.vcs_status(&root).unwrap().unwrap().root, to_ipc(&root));
|
||||
}
|
||||
|
||||
/// A project index that lists `files` (root-relative), whatever is on disk.
|
||||
struct Indexed(PathBuf, Vec<&'static str>);
|
||||
impl PluginWorkspace for Indexed {
|
||||
fn projects(&self) -> Vec<PathBuf> {
|
||||
vec![self.0.clone()]
|
||||
}
|
||||
fn buffer_text(&self, _: &Path) -> Option<String> {
|
||||
None
|
||||
}
|
||||
fn document(&self, _: &Path) -> Option<(u64, String)> {
|
||||
None
|
||||
}
|
||||
fn apply_edits(&self, _: &Path, _: u64, _: &[TextEdit]) -> Result<u64, HostError> {
|
||||
Err(HostError::Stale)
|
||||
}
|
||||
fn trusted(&self, _: &Path) -> bool {
|
||||
true
|
||||
}
|
||||
fn project_of(&self, _: &str) -> Option<PathBuf> {
|
||||
None
|
||||
}
|
||||
fn publish_diagnostics(&self, _: &str, _: &Path, _: Vec<ide_core::Diagnostic>) -> Result<(), HostError> {
|
||||
Ok(())
|
||||
}
|
||||
fn indexed_files(&self, root: &Path, matches: &dyn Fn(&Path) -> bool, limit: usize) -> Option<Vec<PathBuf>> {
|
||||
(root == self.0).then(|| {
|
||||
self.1.iter().map(Path::new).filter(|rel| matches(rel)).take(limit).map(|rel| root.join(rel)).collect()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_files_uses_the_project_index_when_there_is_one() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().canonicalize().unwrap();
|
||||
let api = api(&root, Arc::default());
|
||||
*api.shared.workspace.write() = Some(Arc::new(Indexed(
|
||||
root.clone(),
|
||||
vec!["src/b/UserMapper.xml", "src/a/OrderMapper.xml", ".idea/workspace.xml", "pom.xml"],
|
||||
)));
|
||||
// Listed by the index, not walked: none of these exist on disk.
|
||||
assert_eq!(
|
||||
api.find_files(&root, "**/*Mapper.xml", 10).unwrap(),
|
||||
[to_ipc(&root.join("src/a/OrderMapper.xml")), to_ipc(&root.join("src/b/UserMapper.xml"))]
|
||||
);
|
||||
assert_eq!(api.find_files(&root, "**/*.xml", 10).unwrap().len(), 3, "hidden folders are left out");
|
||||
assert_eq!(api.find_files(&root, "**/*.xml", 1).unwrap().len(), 1);
|
||||
// Another root: no index covers it, so the disk is walked.
|
||||
let other = root.join("other");
|
||||
std::fs::create_dir_all(&other).unwrap();
|
||||
std::fs::write(other.join("a.xml"), "").unwrap();
|
||||
assert_eq!(api.find_files(&other, "*.xml", 10).unwrap(), [to_ipc(&other.join("a.xml"))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn programs_run_only_as_approved() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -831,7 +910,8 @@ mod tests {
|
||||
// Allowed while the program was missing: say how to fix it rather than "not granted".
|
||||
let err = api.approved_program("lint").unwrap_err().to_string();
|
||||
assert!(
|
||||
err.contains("was not found when this permission was allowed") && err.contains("allow process:lint again"),
|
||||
err.contains("was not found on PATH when this permission was allowed")
|
||||
&& err.contains("clear process:lint in Settings › Plugins and select it again"),
|
||||
"{err}"
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
//! Docker run targets through `ops::run::start`, with a stand-in `docker`
|
||||
//! (`OPENIDEA_DOCKER`) that prints what it was given.
|
||||
#![cfg(unix)]
|
||||
|
||||
use ide_core::NullSink;
|
||||
use ide_host::{Host, HostConfig, ops};
|
||||
use ide_run::{OutputChunk, RunConfig};
|
||||
use parking_lot::Mutex;
|
||||
use std::collections::BTreeMap;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
fn host(base: &Path) -> Arc<Host> {
|
||||
Arc::new(Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
data_dir: base.join("data"),
|
||||
local_data_dir: base.join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(NullSink)),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
}))
|
||||
}
|
||||
|
||||
fn config(target: &str, root: &Path) -> RunConfig {
|
||||
RunConfig {
|
||||
id: "app".into(),
|
||||
name: "app".into(),
|
||||
kind: "python".into(),
|
||||
command: root.join(".venv/bin/python").to_string_lossy().into_owned(),
|
||||
args: vec![root.join("main.py").to_string_lossy().into_owned()],
|
||||
cwd: None,
|
||||
env: BTreeMap::from([("DATA".to_owned(), root.join("data").to_string_lossy().into_owned())]),
|
||||
detected: false,
|
||||
is_test: false,
|
||||
debug_adapter: None,
|
||||
allow_parallel: false,
|
||||
extras: ide_run::RunExtras { target: Some(target.into()), ..Default::default() },
|
||||
}
|
||||
}
|
||||
|
||||
async fn output_of(host: &Host, config: RunConfig) -> String {
|
||||
let out = Arc::new(Mutex::new(String::new()));
|
||||
let sink = out.clone();
|
||||
ops::run::start(
|
||||
host,
|
||||
"main",
|
||||
config,
|
||||
Arc::new(move |chunk| match chunk {
|
||||
OutputChunk::Stdout { text } | OutputChunk::Stderr { text } | OutputChunk::System { text } => {
|
||||
sink.lock().push_str(&text)
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
for _ in 0..200 {
|
||||
if out.lock().contains("Process finished") {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(25)).await;
|
||||
}
|
||||
out.lock().clone()
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn docker_targets_run_through_the_docker_cli() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let base = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let docker = base.join("docker");
|
||||
std::fs::write(
|
||||
&docker,
|
||||
"#!/bin/sh\nif [ \"$1\" = context ]; then echo unix:///var/run/docker.sock; exit 0; fi\n\
|
||||
for a in \"$@\"; do echo \"arg:$a\"; done\necho \"DATA=$DATA\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::set_permissions(&docker, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
// SAFETY: the only test of this binary, before any thread looks docker up.
|
||||
unsafe {
|
||||
std::env::set_var("OPENIDEA_DOCKER", &docker);
|
||||
std::env::remove_var("DOCKER_HOST");
|
||||
}
|
||||
|
||||
let root = base.join("proj");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let host = host(&base.join("app"));
|
||||
std::fs::create_dir_all(&host.data_dir).unwrap();
|
||||
std::fs::write(
|
||||
host.data_dir.join("docker.json"),
|
||||
r#"{"connections": [{"id": "far", "name": "Build box", "endpoint": {"kind": "tcp", "host": "build:2376"}}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
host.trust.decide(&root, true, false).unwrap();
|
||||
host.open_project("main", &root).unwrap();
|
||||
let targets = serde_json::json!([
|
||||
{ "kind": "docker", "id": "py", "name": "Python", "image": "python:3.12" },
|
||||
{ "kind": "docker", "id": "far", "name": "Far", "image": "python:3.12", "dockerConnection": "far" },
|
||||
]);
|
||||
ops::project::set_settings(
|
||||
&host,
|
||||
"main",
|
||||
ide_workspace::settings::SettingsScope::Project,
|
||||
"runTargets",
|
||||
&targets.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let out = output_of(&host, config("py", &root)).await;
|
||||
let args: Vec<&str> = out.lines().filter_map(|l| l.strip_prefix("arg:")).collect();
|
||||
assert_eq!(&args[..4], ["run", "--rm", "--interactive", "--init"], "{out}");
|
||||
assert!(args.contains(&format!("{}:/workspaces/proj", root.display()).as_str()), "{out}");
|
||||
assert!(args.ends_with(&["python:3.12", "python3", "/workspaces/proj/main.py"]), "{out}");
|
||||
assert!(out.contains("DATA=/workspaces/proj/data"), "{out}");
|
||||
assert!(!args.iter().any(|a| a.contains("/workspaces/proj/data")), "values stay out of the command line");
|
||||
|
||||
let err = ops::run::start(&host, "main", config("far", &root), Arc::new(|_| {})).await.unwrap_err();
|
||||
assert!(err.message.contains("another machine"), "{}", err.message);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
//! Docker and Compose run targets against a real daemon. Ignored by default:
|
||||
//! `OPENIDEA_DOCKER_TEST_DIR` names an empty folder the daemon can bind-mount
|
||||
//! (on this machine, or at the same path in a VM such as OrbStack's), and
|
||||
//! `docker` with the Compose plugin must be on `PATH`.
|
||||
#![cfg(unix)]
|
||||
|
||||
use ide_core::NullSink;
|
||||
use ide_host::{Host, HostConfig, ops};
|
||||
use ide_run::{OutputChunk, RunConfig};
|
||||
use parking_lot::Mutex;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
fn host(base: &Path) -> Arc<Host> {
|
||||
Arc::new(Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
data_dir: base.join("data"),
|
||||
local_data_dir: base.join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(NullSink)),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
}))
|
||||
}
|
||||
|
||||
fn config(id: &str, target: &str, root: &Path, args: &[&str]) -> RunConfig {
|
||||
RunConfig {
|
||||
id: id.into(),
|
||||
name: id.into(),
|
||||
kind: "python".into(),
|
||||
command: root.join(".venv/bin/python").to_string_lossy().into_owned(),
|
||||
args: args.iter().map(|a| a.to_string()).collect(),
|
||||
cwd: None,
|
||||
env: BTreeMap::from([("GREETING".to_owned(), "hello".to_owned())]),
|
||||
detected: false,
|
||||
is_test: false,
|
||||
debug_adapter: None,
|
||||
allow_parallel: false,
|
||||
extras: ide_run::RunExtras { target: Some(target.into()), ..Default::default() },
|
||||
}
|
||||
}
|
||||
|
||||
type Output = Arc<Mutex<String>>;
|
||||
|
||||
async fn start(host: &Host, config: RunConfig) -> (ide_core::RunId, Output) {
|
||||
let out: Output = Arc::default();
|
||||
let sink = out.clone();
|
||||
let info = ops::run::start(
|
||||
host,
|
||||
"main",
|
||||
config,
|
||||
Arc::new(move |chunk| match chunk {
|
||||
OutputChunk::Stdout { text } | OutputChunk::Stderr { text } | OutputChunk::System { text } => {
|
||||
sink.lock().push_str(&text)
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
(info.id, out)
|
||||
}
|
||||
|
||||
async fn finished(out: &Output, secs: u64) -> String {
|
||||
for _ in 0..secs * 10 {
|
||||
if out.lock().contains("Process finished") {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
out.lock().clone()
|
||||
}
|
||||
|
||||
fn containers(label: &str) -> String {
|
||||
let out = Command::new("docker").args(["ps", "--all", "--quiet", "--filter", label]).output().unwrap();
|
||||
String::from_utf8_lossy(&out.stdout).trim().to_owned()
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore = "requires a Docker daemon and OPENIDEA_DOCKER_TEST_DIR"]
|
||||
async fn runs_in_docker_and_compose_containers() {
|
||||
let root = PathBuf::from(std::env::var("OPENIDEA_DOCKER_TEST_DIR").expect("OPENIDEA_DOCKER_TEST_DIR"));
|
||||
let root = dunce::canonicalize(root).unwrap();
|
||||
let name = root.file_name().unwrap().to_string_lossy().into_owned();
|
||||
std::fs::write(
|
||||
root.join("main.py"),
|
||||
"import os\nprint('cwd=' + os.getcwd())\nprint('GREETING=' + os.environ['GREETING'])\n\
|
||||
open('out.txt', 'w').write('ok')\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
root.join("compose.yaml"),
|
||||
"services:\n app:\n image: python:3.12-alpine\n volumes:\n - .:/app\n",
|
||||
)
|
||||
.unwrap();
|
||||
let state = tempfile::tempdir().unwrap();
|
||||
let host = host(state.path());
|
||||
host.trust.decide(&root, true, false).unwrap();
|
||||
host.open_project("main", &root).unwrap();
|
||||
let targets = serde_json::json!([
|
||||
{ "kind": "docker", "id": "py", "name": "Python", "image": "python:3.12-alpine" },
|
||||
{ "kind": "compose", "id": "app", "name": "Compose", "service": "app" },
|
||||
]);
|
||||
ops::project::set_settings(
|
||||
&host,
|
||||
"main",
|
||||
ide_workspace::settings::SettingsScope::Project,
|
||||
"runTargets",
|
||||
&targets.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let (_, out) = start(&host, config("docker-main", "py", &root, &[&root.join("main.py").to_string_lossy()])).await;
|
||||
let out = finished(&out, 120).await;
|
||||
assert!(out.contains(&format!("cwd=/workspaces/{name}")), "{out}");
|
||||
assert!(out.contains("GREETING=hello") && out.contains("exit code 0"), "{out}");
|
||||
let written = root.join("out.txt");
|
||||
assert_eq!(std::fs::read_to_string(&written).unwrap(), "ok");
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
let (file, folder) = (std::fs::metadata(&written).unwrap(), std::fs::metadata(&root).unwrap());
|
||||
assert_eq!((file.uid(), file.gid()), (folder.uid(), folder.gid()), "written as the folder's owner");
|
||||
}
|
||||
std::fs::remove_file(&written).unwrap();
|
||||
assert_eq!(containers("label=openidea.run=docker-main"), "", "removed when the run ends");
|
||||
|
||||
let (_, out) = start(&host, config("compose-main", "app", &root, &["main.py"])).await;
|
||||
let out = finished(&out, 120).await;
|
||||
assert!(out.contains("cwd=/app") && out.contains("GREETING=hello") && out.contains("exit code 0"), "{out}");
|
||||
std::fs::remove_file(&written).unwrap();
|
||||
|
||||
// Stop: `docker stop` ends the container; `--rm` removes it.
|
||||
let (id, out) =
|
||||
start(&host, config("sleep", "py", &root, &["-c", "print('up', flush=True); import time; time.sleep(600)"]))
|
||||
.await;
|
||||
for _ in 0..600 {
|
||||
if out.lock().contains("up\n") {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
assert_ne!(containers("label=openidea.run=sleep"), "", "{}", out.lock());
|
||||
ops::run::stop(&host, "main", id).unwrap();
|
||||
let out = finished(&out, 30).await;
|
||||
assert!(out.contains(" stop openidea-sleep-"), "{out}");
|
||||
for _ in 0..100 {
|
||||
if containers("label=openidea.run=sleep").is_empty() {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
assert_eq!(containers("label=openidea.run=sleep"), "", "{out}");
|
||||
Command::new("docker").args(["compose", "down"]).current_dir(&root).status().unwrap();
|
||||
}
|
||||
@@ -35,6 +35,8 @@ async fn opens_unity_without_generated_solutions_and_excludes_caches_from_the_in
|
||||
assert!(unity.projects.is_empty() && unity.solutions.is_empty());
|
||||
assert!(project.index.wait_idle(Duration::from_secs(20)));
|
||||
assert_eq!(project.index.find_files("Player.cs", 100).len(), 1);
|
||||
let listed = project.index.files_matching(&project.root, |rel| rel.ends_with("Player.cs"), 10).unwrap();
|
||||
assert_eq!(listed.len(), 1);
|
||||
for name in ["Cached.cs", "Temporary.cs", "Editor.log"] {
|
||||
assert!(project.index.find_files(name, 100).is_empty(), "indexed generated file {name}");
|
||||
}
|
||||
|
||||
@@ -197,6 +197,28 @@ impl ProjectIndex {
|
||||
matching::find_files(&d, &matching::Recents { paths: &recent }, query, limit)
|
||||
}
|
||||
|
||||
/// Up to `limit` indexed files below `dir` (VCS-ignored ones left out) whose path relative to
|
||||
/// `dir` satisfies `matches`: a file list without walking the disk. `None` while nothing is
|
||||
/// indexed yet (first scan without a cache).
|
||||
pub fn files_matching(&self, dir: &Path, matches: impl Fn(&Path) -> bool, limit: usize) -> Option<Vec<PathBuf>> {
|
||||
let d = self.shared.data.read();
|
||||
if d.file_count == 0 {
|
||||
return None;
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for file in d.files.iter().flatten().filter(|f| !f.ignored) {
|
||||
if out.len() >= limit {
|
||||
break;
|
||||
}
|
||||
if let Ok(rel) = file.path.strip_prefix(dir)
|
||||
&& matches(rel)
|
||||
{
|
||||
out.push(file.path.clone());
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Fuzzy search for folders that hold indexed files; `rel_path` ends in `/`.
|
||||
pub fn find_dirs(&self, query: &str, limit: usize) -> Vec<FileMatch> {
|
||||
matching::find_dirs(&self.shared.data.read(), query, limit)
|
||||
|
||||
@@ -52,7 +52,7 @@ tree-sitter-proto = "0.6.0"
|
||||
tree-sitter-make = "1.1.1"
|
||||
tree-sitter-cmake = "0.7.4"
|
||||
tree-sitter-xml = "0.7.0"
|
||||
tree-sitter-haskell = "0.23.1"
|
||||
tree-sitter-haskell = "0.24.1"
|
||||
tree-sitter-dart = "0.2.0"
|
||||
tree-sitter-graphql = "0.3.0"
|
||||
tree-sitter-gdscript = "6.1.0"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
(header module: (module) @name) @outline.module
|
||||
(data_type name: (name) @name) @def.enum
|
||||
(newtype name: (name) @name) @def.struct
|
||||
(type_synomym name: (name) @name) @def.type
|
||||
(type_synonym name: (name) @name) @def.type
|
||||
(type_family name: (name) @name) @def.type
|
||||
(class name: (name) @name) @def.interface
|
||||
(instance name: (name) @name patterns: (_) @detail) @outline.namespace
|
||||
|
||||
@@ -136,6 +136,12 @@ impl ClientHandler {
|
||||
|
||||
fn show_message(&self, mgr: &LspManager, params: &Value) {
|
||||
let text = params.get("message").and_then(Value::as_str).unwrap_or_default().to_owned();
|
||||
// rust-analyzer built from source reports its own slow main-loop turns as errors, meant for
|
||||
// its developers (`poke_rust_analyzer_developer`); nothing for the user to act on.
|
||||
if text.starts_with("overly long loop turn") {
|
||||
tracing::warn!("[{}] {text}", self.server.name);
|
||||
return;
|
||||
}
|
||||
let level = match params.get("type").and_then(Value::as_u64) {
|
||||
Some(1) => NotifyLevel::Error,
|
||||
Some(2) => NotifyLevel::Warning,
|
||||
|
||||
+103
-3
@@ -315,6 +315,27 @@ async fn library_sources(snap: &DocSnap, pos: Pos, locs: Vec<Location>) -> Vec<L
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
const JAVA_IMPLEMENTATIONS: &str = "openidea.java.implementations";
|
||||
|
||||
/// The answer of [`JAVA_IMPLEMENTATIONS`]: `complete` when the type is declared in the
|
||||
/// sources, so no dependency can implement it.
|
||||
#[derive(serde::Deserialize)]
|
||||
struct SourceImplementations {
|
||||
locations: Vec<lsp::Location>,
|
||||
complete: bool,
|
||||
}
|
||||
|
||||
/// Receives the implementations found so far, before a slower search for the rest.
|
||||
type Partial<'a> = Option<&'a (dyn Fn(Vec<Location>) + Send + Sync)>;
|
||||
|
||||
/// `first`, then those of `more` not in it (same file and start).
|
||||
fn merge_locations(first: Vec<Location>, more: Vec<Location>) -> Vec<Location> {
|
||||
let mut seen: HashSet<(PathBuf, Pos)> = first.iter().map(|l| (l.path.to_path_buf(), l.range.start)).collect();
|
||||
let mut out = first;
|
||||
out.extend(more.into_iter().filter(|l| seen.insert((l.path.to_path_buf(), l.range.start))));
|
||||
out
|
||||
}
|
||||
|
||||
fn goto_method(kind: GotoKind) -> (&'static str, &'static str, &'static str) {
|
||||
match kind {
|
||||
GotoKind::Definition => ("textDocument/definition", "definitionProvider", "go to definition"),
|
||||
@@ -1229,7 +1250,22 @@ impl LspManager {
|
||||
}
|
||||
let (method, cap, feature) = goto_method(kind);
|
||||
let (snap, targets) = self.doc_targets(doc, feature, |c| c.has(cap)).await?;
|
||||
self.goto_in(&snap, &targets, method, pos, None).await
|
||||
self.goto_in(&snap, &targets, method, pos, None, None).await
|
||||
}
|
||||
|
||||
/// Go to Implementation, with the implementations found first passed to `partial` while
|
||||
/// the search goes on: jdtls answers from the project's sources quickly, then searches every
|
||||
/// dependency jar for a type declared in one. Returns them all, the first ones first.
|
||||
pub async fn goto_implementation(
|
||||
&self,
|
||||
doc: DocId,
|
||||
pos: Pos,
|
||||
cancel: CancellationToken,
|
||||
partial: &(dyn Fn(Vec<Location>) + Send + Sync),
|
||||
) -> IdeResult<Vec<Location>> {
|
||||
let (method, cap, feature) = goto_method(GotoKind::Implementation);
|
||||
let (snap, targets) = self.doc_targets(doc, feature, |c| c.has(cap)).await?;
|
||||
self.goto_in(&snap, &targets, method, pos, Some(&cancel), Some(partial)).await
|
||||
}
|
||||
|
||||
/// Implementations of the symbols at each of `positions` (gutter line
|
||||
@@ -1246,7 +1282,7 @@ impl LspManager {
|
||||
let (snap, targets) = self.doc_targets(doc, feature, |c| c.has(cap)).await?;
|
||||
let requests = positions.iter().copied().map(|pos| {
|
||||
let (snap, targets, cancel) = (&snap, &targets, &cancel);
|
||||
async move { self.goto_in(snap, targets, method, pos, Some(cancel)).await }
|
||||
async move { self.goto_in(snap, targets, method, pos, Some(cancel), None).await }
|
||||
});
|
||||
let results: Vec<IdeResult<Vec<Location>>> =
|
||||
futures::stream::iter(requests).buffered(IMPLEMENTATION_CONCURRENCY).collect().await;
|
||||
@@ -1259,6 +1295,46 @@ impl LspManager {
|
||||
Ok(results.into_iter().map(Result::unwrap_or_default).collect())
|
||||
}
|
||||
|
||||
/// jdtls: the implementations in the project's sources (`openidea.java.implementations`),
|
||||
/// and whether they are all there are. Its standard request builds the type hierarchy over
|
||||
/// every dependency jar too, which takes long in a project with many dependencies.
|
||||
async fn java_source_implementations(
|
||||
&self,
|
||||
snap: &DocSnap,
|
||||
t: &Target,
|
||||
pos: Pos,
|
||||
cancel: Option<&CancellationToken>,
|
||||
) -> (Vec<Location>, bool) {
|
||||
if t.server.name != "jdtls" || !t.caps().commands().iter().any(|c| c == JAVA_IMPLEMENTATIONS) {
|
||||
return (Vec::new(), false);
|
||||
}
|
||||
let params = snap.pos_params(t, pos);
|
||||
let arguments =
|
||||
json!([params["textDocument"]["uri"], params["position"]["line"], params["position"]["character"]]);
|
||||
let result: IdeResult<SourceImplementations> = self
|
||||
.call(
|
||||
t,
|
||||
"workspace/executeCommand",
|
||||
json!({ "command": JAVA_IMPLEMENTATIONS, "arguments": arguments }),
|
||||
cancel,
|
||||
DEFAULT_TIMEOUT,
|
||||
)
|
||||
.await;
|
||||
match result {
|
||||
Ok(found) => {
|
||||
let locs =
|
||||
convert::goto_response(&self.conv(t.enc()), lsp::GotoDefinitionResponse::Array(found.locations));
|
||||
let locs = library_sources(snap, pos, locs).await;
|
||||
self.note_virtual(&t.server, locs.iter().map(|l| l.uri.as_deref()));
|
||||
(locs, found.complete)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!("{JAVA_IMPLEMENTATIONS}: {}", e.message);
|
||||
(Vec::new(), false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn goto_in(
|
||||
&self,
|
||||
snap: &DocSnap,
|
||||
@@ -1266,9 +1342,24 @@ impl LspManager {
|
||||
method: &str,
|
||||
pos: Pos,
|
||||
cancel: Option<&CancellationToken>,
|
||||
partial: Partial<'_>,
|
||||
) -> IdeResult<Vec<Location>> {
|
||||
let mut out = Outcome::new();
|
||||
for t in targets.iter().filter(|t| snap.asks(t, pos)) {
|
||||
// Found in the sources before the standard request searches the dependencies too.
|
||||
let mut first = Vec::new();
|
||||
if method == "textDocument/implementation" {
|
||||
let (locs, complete) = self.java_source_implementations(snap, t, pos, cancel).await;
|
||||
if complete && !locs.is_empty() {
|
||||
return Ok(locs);
|
||||
}
|
||||
if let Some(partial) = partial
|
||||
&& !locs.is_empty()
|
||||
{
|
||||
partial(locs.clone());
|
||||
first = locs;
|
||||
}
|
||||
}
|
||||
let r = loading::while_loading(&t.server, cancel, loading::LOADING_WAIT, goto_is_empty, || {
|
||||
self.call::<_, Option<lsp::GotoDefinitionResponse>>(
|
||||
t,
|
||||
@@ -1284,12 +1375,19 @@ impl LspManager {
|
||||
let locs = convert::goto_response(&self.conv(t.enc()), r);
|
||||
let locs = library_sources(snap, pos, locs).await;
|
||||
self.note_virtual(&t.server, locs.iter().map(|l| l.uri.as_deref()));
|
||||
let locs = merge_locations(first, locs);
|
||||
if !locs.is_empty() {
|
||||
return Ok(locs);
|
||||
}
|
||||
out.value = Some(Vec::new());
|
||||
}
|
||||
Ok(None) if !first.is_empty() => return Ok(first),
|
||||
Ok(None) => out.value = Some(Vec::new()),
|
||||
// Those shown stay valid when the rest can't be found.
|
||||
Err(e) if !first.is_empty() && e.kind != ErrorKind::Cancelled => {
|
||||
tracing::debug!("go to implementation in the dependencies: {}", e.message);
|
||||
return Ok(first);
|
||||
}
|
||||
Err(e) => out.fail(e),
|
||||
}
|
||||
}
|
||||
@@ -1473,7 +1571,9 @@ impl LspManager {
|
||||
return Vec::new();
|
||||
}
|
||||
let defining = self.ready_targets(&snap.servers, |c| c.has("definitionProvider")).await;
|
||||
self.goto_in(&snap, &defining, "textDocument/definition", pos, Some(&cancel)).await.unwrap_or_default()
|
||||
self.goto_in(&snap, &defining, "textDocument/definition", pos, Some(&cancel), None)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let (results, declarations) = futures::join!(join_all(requests), declarations);
|
||||
let mut out = Outcome::new();
|
||||
|
||||
@@ -954,6 +954,11 @@ async fn apply_edit_hook_and_server_requests() {
|
||||
assert_eq!(v, Value::Null);
|
||||
assert!(peer.request::<_, Value>("unknown/method", Value::Null, None).await.is_err());
|
||||
|
||||
peer.notify(
|
||||
"window/showMessage",
|
||||
json!({ "type": 1, "message": "overly long loop turn took 113ms (event handling took 95ms): PrimeCaches" }),
|
||||
)
|
||||
.unwrap();
|
||||
peer.notify("window/showMessage", json!({ "type": 2, "message": "careful" })).unwrap();
|
||||
peer.notify("window/showMessage", json!({ "type": 3, "message": "info only" })).unwrap();
|
||||
peer.notify("window/logMessage", json!({ "type": 1, "message": "log" })).unwrap();
|
||||
@@ -962,7 +967,10 @@ async fn apply_edit_hook_and_server_requests() {
|
||||
})
|
||||
.await;
|
||||
let notifies = h.events.lock().iter().filter(|e| matches!(e, IdeEvent::Notify { .. })).count();
|
||||
assert_eq!(notifies, 2, "showMessageRequest error + showMessage warning; info is not surfaced");
|
||||
assert_eq!(
|
||||
notifies, 2,
|
||||
"showMessageRequest error + showMessage warning; info and rust-analyzer's developer notices are not surfaced"
|
||||
);
|
||||
|
||||
// Dynamically registered capability is now usable.
|
||||
s.respond(
|
||||
@@ -1796,6 +1804,71 @@ async fn implementations_per_position() {
|
||||
assert_eq!(err.kind, ide_core::error::ErrorKind::Cancelled);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn java_implementations_come_from_sources_first() {
|
||||
let caps = json!({
|
||||
"textDocumentSync": 1,
|
||||
"implementationProvider": true,
|
||||
"executeCommandProvider": { "commands": ["openidea.java.implementations"] },
|
||||
});
|
||||
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
|
||||
let (doc, path) = h.open("Repo.java", "interface Repo {}\nclass A implements Repo {}\nclass B {}\nclass C {}\n");
|
||||
h.wait_status("jdtls", ServerStatus::Ready).await;
|
||||
let s = h.launcher.state("jdtls");
|
||||
let uri = uri_of(&path);
|
||||
let at = move |lines: &[u32]| {
|
||||
lines
|
||||
.iter()
|
||||
.map(|&l| json!({ "uri": uri, "range": {"start":{"line":l,"character":6},"end":{"line":l,"character":7}} }))
|
||||
.collect::<Value>()
|
||||
};
|
||||
*s.responder.lock() = Some(Arc::new(move |_, method, params| match method {
|
||||
// Line 0 is declared in the sources; line 2 in a dependency, which implements it on line 3.
|
||||
"workspace/executeCommand" => Some(match params["arguments"][1].as_u64() {
|
||||
Some(0) => json!({ "locations": at(&[1]), "complete": true }),
|
||||
Some(2) => json!({ "locations": at(&[1]), "complete": false }),
|
||||
_ => json!({ "locations": [], "complete": true }),
|
||||
}),
|
||||
"textDocument/implementation" => Some(at(&[3, 1])),
|
||||
_ => None,
|
||||
}));
|
||||
let lines = |locs: &[ide_core::Location]| locs.iter().map(|l| l.range.start.line).collect::<Vec<_>>();
|
||||
let found = h.mgr.goto(doc, Pos::new(0, 10), GotoKind::Implementation).await.unwrap();
|
||||
assert_eq!(lines(&found), vec![1]);
|
||||
let markers = h.mgr.implementations(doc, &[Pos::new(0, 10)], Default::default()).await.unwrap();
|
||||
assert_eq!(lines(&markers[0]), vec![1]);
|
||||
assert!(s.requested("textDocument/implementation").is_empty(), "no search through the dependencies");
|
||||
let asked = s.requested("workspace/executeCommand");
|
||||
assert_eq!(asked[0]["command"], "openidea.java.implementations");
|
||||
assert_eq!((asked[0]["arguments"][1].clone(), asked[0]["arguments"][2].clone()), (json!(0), json!(10)));
|
||||
|
||||
// A dependency's type: those in the sources first, then the dependencies' too.
|
||||
let partial = Mutex::new(Vec::new());
|
||||
let found = h
|
||||
.mgr
|
||||
.goto_implementation(doc, Pos::new(2, 6), Default::default(), &|locs| partial.lock().push(lines(&locs)))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(*partial.lock(), vec![vec![1]]);
|
||||
assert_eq!(lines(&found), vec![1, 3]);
|
||||
assert_eq!(s.requested("textDocument/implementation").len(), 1);
|
||||
// Without a receiver for the first ones (gutter markers), just all of them.
|
||||
let found = h.mgr.goto(doc, Pos::new(2, 6), GotoKind::Implementation).await.unwrap();
|
||||
assert_eq!(lines(&found), vec![3, 1]);
|
||||
assert_eq!(s.requested("textDocument/implementation").len(), 2);
|
||||
|
||||
// None in the sources: the standard request, with the dependencies.
|
||||
let partial = Mutex::new(Vec::new());
|
||||
let found = h
|
||||
.mgr
|
||||
.goto_implementation(doc, Pos::new(3, 6), Default::default(), &|locs| partial.lock().push(lines(&locs)))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(partial.lock().is_empty());
|
||||
assert_eq!(lines(&found), vec![3, 1]);
|
||||
assert_eq!(s.requested("textDocument/implementation").len(), 3);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn navigation_rename_format_and_decorations() {
|
||||
let h = harness(&[("mock", incremental_caps())]);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "ide-remote"
|
||||
description = "Remote work over SSH: SFTP deployment (mappings, upload, download, sync) and run targets"
|
||||
description = "Remote work over SSH: SFTP deployment (mappings, upload, download, sync) and run targets (SSH, Docker, Compose)"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
@@ -10,6 +10,7 @@ ide-core.workspace = true
|
||||
ide-ssh.workspace = true
|
||||
ide-run.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
specta.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
//! Run configurations on Docker and Compose run targets: rewritten into a local
|
||||
//! `docker run --rm` / `docker compose run --rm` of the configuration's program,
|
||||
//! with the project bind-mounted and its paths and interpreters swapped for the
|
||||
//! container's. The console, stdin, Stop and test parsers then work as for any
|
||||
//! local run.
|
||||
|
||||
use crate::target::remote_command;
|
||||
use ide_core::remote_settings::{RunTarget, RunTargetKind};
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use ide_run::RunConfig;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
|
||||
/// What the rewrite needs besides the configuration and its target.
|
||||
pub struct ContainerRun<'a> {
|
||||
pub target: &'a RunTarget,
|
||||
/// The project on this machine.
|
||||
pub root: &'a Path,
|
||||
/// Where the project is in the container.
|
||||
pub container_root: &'a str,
|
||||
/// The docker executable.
|
||||
pub docker: &'a str,
|
||||
/// The connection's variables for the docker CLI (`ide_docker::run_env`).
|
||||
pub endpoint_env: &'a BTreeMap<String, String>,
|
||||
/// Variables of the configuration's `.env` files, already read.
|
||||
pub file_env: &'a BTreeMap<String, String>,
|
||||
/// Container name, unique per run.
|
||||
pub name: &'a str,
|
||||
/// `--user uid:gid`; `None` keeps the image's user.
|
||||
pub user: Option<(u32, u32)>,
|
||||
}
|
||||
|
||||
/// The container folder of the project when a Docker target sets none.
|
||||
pub fn default_container_root(root: &Path) -> String {
|
||||
let name = root.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
let name: String =
|
||||
name.chars().map(|c| if c.is_alphanumeric() || matches!(c, '_' | '-' | '.') { c } else { '-' }).collect();
|
||||
format!("/workspaces/{}", if name.is_empty() { "project" } else { &name })
|
||||
}
|
||||
|
||||
/// A container name for a run of `config_id`: Docker allows `[a-zA-Z0-9][a-zA-Z0-9_.-]*`.
|
||||
pub fn container_name(config_id: &str, unique: &str) -> String {
|
||||
let id: String = config_id
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-') { c } else { '-' })
|
||||
.take(40)
|
||||
.collect();
|
||||
format!("openidea-{}-{unique}", id.trim_matches('-'))
|
||||
}
|
||||
|
||||
/// `config` as a local run of the docker CLI that runs it in a new container.
|
||||
pub fn container_config(config: &RunConfig, run: &ContainerRun) -> IdeResult<RunConfig> {
|
||||
let target = run.target;
|
||||
let command = remote_command(config, target, run.root, run.container_root);
|
||||
// Variables go by name (`-e NAME`) so their values stay out of the command line
|
||||
// echoed in the console; the docker CLI reads them from its own environment.
|
||||
let mut env: BTreeMap<String, String> = run.file_env.clone();
|
||||
if matches!(config.kind.as_str(), "python" | "pytest" | "django-test")
|
||||
&& !command.env.contains_key("PYTHONUNBUFFERED")
|
||||
{
|
||||
env.insert("PYTHONUNBUFFERED".into(), "1".into());
|
||||
}
|
||||
env.extend(command.env.clone());
|
||||
let pty = config.extras.emulate_terminal;
|
||||
let mut args: Vec<String> = Vec::new();
|
||||
match target.kind {
|
||||
RunTargetKind::Docker => {
|
||||
if target.image.trim().is_empty() {
|
||||
return Err(IdeError::invalid(format!("Run target '{}' has no image", target.name)));
|
||||
}
|
||||
args.extend(["run", "--rm", "--interactive", "--init"].map(String::from));
|
||||
if pty {
|
||||
args.push("--tty".into());
|
||||
}
|
||||
let root = run.root.to_string_lossy();
|
||||
args.extend(["--volume".into(), format!("{root}:{}", run.container_root)]);
|
||||
}
|
||||
RunTargetKind::Compose => {
|
||||
if target.service.trim().is_empty() {
|
||||
return Err(IdeError::invalid(format!("Run target '{}' has no service", target.name)));
|
||||
}
|
||||
args.push("compose".into());
|
||||
for file in target.compose_files.iter().map(|f| f.trim()).filter(|f| !f.is_empty()) {
|
||||
args.extend(["--file".into(), run.root.join(file).to_string_lossy().into_owned()]);
|
||||
}
|
||||
args.extend(["run", "--rm"].map(String::from));
|
||||
if !pty {
|
||||
args.push("--no-TTY".into());
|
||||
}
|
||||
}
|
||||
RunTargetKind::Ssh => return Err(IdeError::internal("an SSH target does not run in a container")),
|
||||
}
|
||||
args.extend(["--name".into(), run.name.to_owned(), "--label".into(), format!("openidea.run={}", config.id)]);
|
||||
args.extend(["--workdir".into(), command.cwd.clone()]);
|
||||
let mut docker_env = run.endpoint_env.clone();
|
||||
for (key, value) in &env {
|
||||
if run.endpoint_env.contains_key(key) {
|
||||
// `PATH`, `DOCKER_HOST`…: the CLI's own value differs from the container's.
|
||||
args.extend(["--env".into(), format!("{key}={value}")]);
|
||||
} else {
|
||||
args.extend(["--env".into(), key.clone()]);
|
||||
docker_env.insert(key.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
for port in target.ports.iter().map(|p| p.trim()).filter(|p| !p.is_empty()) {
|
||||
args.extend(["--publish".into(), port.to_owned()]);
|
||||
}
|
||||
if let Some((uid, gid)) = run.user {
|
||||
args.extend(["--user".into(), format!("{uid}:{gid}")]);
|
||||
}
|
||||
let options = target.run_options.trim();
|
||||
if !options.is_empty() {
|
||||
args.extend(ide_run::import::split_args(options));
|
||||
}
|
||||
args.push(match target.kind {
|
||||
RunTargetKind::Compose => target.service.trim().to_owned(),
|
||||
_ => target.image.trim().to_owned(),
|
||||
});
|
||||
match command.shell_line {
|
||||
Some(line) => args.extend(["sh".into(), "-c".into(), line]),
|
||||
None => {
|
||||
args.push(command.program);
|
||||
args.extend(command.args);
|
||||
}
|
||||
}
|
||||
let mut out = config.clone();
|
||||
out.command = run.docker.to_owned();
|
||||
out.args = args;
|
||||
out.cwd = Some(run.root.to_path_buf().into());
|
||||
out.env = docker_env;
|
||||
let extras = &mut out.extras;
|
||||
extras.target = None;
|
||||
extras.env_files.clear();
|
||||
// The docker CLI needs `HOME` (contexts, credentials); the container gets only `env`.
|
||||
extras.include_system_env = true;
|
||||
// `docker stop` signals the container's process (the CLI may not forward a signal).
|
||||
extras.stop_command = Some(format!("{} stop {}", shell_word(run.docker), run.name));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// `word` for this machine's shell (the stop command runs there): the docker CLI found
|
||||
/// outside `PATH` (Docker.app's bundle) may contain spaces.
|
||||
fn shell_word(word: &str) -> String {
|
||||
if !word.contains(|c: char| c.is_whitespace() || "'\"$`\\&;|<>()*?#~".contains(c)) || word.is_empty() {
|
||||
word.to_owned()
|
||||
} else if cfg!(windows) {
|
||||
format!("\"{word}\"")
|
||||
} else {
|
||||
format!("'{}'", word.replace('\'', "'\\''"))
|
||||
}
|
||||
}
|
||||
|
||||
/// The project's folder in the container of a Compose `service`, from the JSON of
|
||||
/// `docker compose config --format json`: the target of a bind mount of the project
|
||||
/// or a folder above it.
|
||||
pub fn compose_container_root(config_json: &str, service: &str, root: &Path) -> IdeResult<String> {
|
||||
let model: serde_json::Value = serde_json::from_str(config_json)
|
||||
.map_err(|e| IdeError::internal(format!("cannot read `docker compose config`: {e}")))?;
|
||||
let Some(svc) = model.pointer(&format!("/services/{}", service.replace('~', "~0").replace('/', "~1"))) else {
|
||||
return Err(IdeError::not_found(format!("the Compose files have no service '{service}'")));
|
||||
};
|
||||
let mounts = svc.get("volumes").and_then(|v| v.as_array()).into_iter().flatten();
|
||||
let mut best: Option<(usize, String)> = None;
|
||||
for m in mounts {
|
||||
if m.get("type").and_then(|t| t.as_str()) != Some("bind") {
|
||||
continue;
|
||||
}
|
||||
let (Some(source), Some(target)) =
|
||||
(m.get("source").and_then(|s| s.as_str()), m.get("target").and_then(|t| t.as_str()))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Ok(rel) = root.strip_prefix(source) else { continue };
|
||||
let depth = Path::new(source).components().count();
|
||||
if best.as_ref().is_none_or(|(d, _)| depth > *d) {
|
||||
let rel = crate::mapping::posix(rel);
|
||||
let target = target.trim_end_matches('/');
|
||||
let folder = match (target.is_empty(), rel.is_empty()) {
|
||||
(true, true) => "/".to_owned(),
|
||||
(_, true) => target.to_owned(),
|
||||
_ => format!("{target}/{rel}"),
|
||||
};
|
||||
best = Some((depth, folder));
|
||||
}
|
||||
}
|
||||
best.map(|(_, folder)| folder).ok_or_else(|| {
|
||||
IdeError::invalid(format!(
|
||||
"service '{service}' does not mount the project folder: add a bind mount of it, or set the container folder of the run target"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn config(command: &str, args: &[&str]) -> RunConfig {
|
||||
RunConfig {
|
||||
id: "app".into(),
|
||||
name: "app".into(),
|
||||
kind: "python".into(),
|
||||
command: command.into(),
|
||||
args: args.iter().map(|s| s.to_string()).collect(),
|
||||
cwd: None,
|
||||
env: BTreeMap::from([("DATA".to_owned(), "/work/proj/data".to_owned())]),
|
||||
detected: false,
|
||||
is_test: false,
|
||||
debug_adapter: None,
|
||||
allow_parallel: false,
|
||||
extras: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn run<'a>(
|
||||
target: &'a RunTarget,
|
||||
endpoint: &'a BTreeMap<String, String>,
|
||||
files: &'a BTreeMap<String, String>,
|
||||
) -> ContainerRun<'a> {
|
||||
ContainerRun {
|
||||
target,
|
||||
root: Path::new("/work/proj"),
|
||||
container_root: "/workspaces/proj",
|
||||
docker: "/usr/local/bin/docker",
|
||||
endpoint_env: endpoint,
|
||||
file_env: files,
|
||||
name: "openidea-app-1",
|
||||
user: Some((501, 20)),
|
||||
}
|
||||
}
|
||||
|
||||
// Unix paths are not absolute on Windows.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn docker_target_runs_the_program_in_a_new_container() {
|
||||
let target = RunTarget {
|
||||
kind: RunTargetKind::Docker,
|
||||
name: "py".into(),
|
||||
image: "python:3.12".into(),
|
||||
ports: vec!["8000:8000".into(), " ".into()],
|
||||
run_options: r#"--network host --label "a b""#.into(),
|
||||
..Default::default()
|
||||
};
|
||||
let endpoint = BTreeMap::from([
|
||||
("DOCKER_CONTEXT".to_owned(), "orbstack".to_owned()),
|
||||
("PATH".to_owned(), "/usr/local/bin".to_owned()),
|
||||
]);
|
||||
let files = BTreeMap::from([("SECRET".to_owned(), "s3".to_owned())]);
|
||||
let mut c = config("/work/proj/.venv/bin/python", &["-u", "/work/proj/main.py"]);
|
||||
c.env.insert("PATH".into(), "/opt/bin".into());
|
||||
c.extras.target = Some("t".into());
|
||||
c.extras.env_files = vec![".env".into()];
|
||||
let out = container_config(&c, &run(&target, &endpoint, &files)).unwrap();
|
||||
assert_eq!(out.command, "/usr/local/bin/docker");
|
||||
assert_eq!(
|
||||
out.args.join(" "),
|
||||
"run --rm --interactive --init --volume /work/proj:/workspaces/proj \
|
||||
--name openidea-app-1 --label openidea.run=app --workdir /workspaces/proj \
|
||||
--env DATA --env PATH=/opt/bin --env PYTHONUNBUFFERED --env SECRET \
|
||||
--publish 8000:8000 --user 501:20 --network host --label a b \
|
||||
python:3.12 python3 -u /workspaces/proj/main.py"
|
||||
);
|
||||
assert_eq!(out.env["DATA"], "/workspaces/proj/data", "values reach the CLI's environment, mapped");
|
||||
assert_eq!(out.env["SECRET"], "s3");
|
||||
assert_eq!(out.env["PATH"], "/usr/local/bin", "the CLI keeps its own PATH");
|
||||
assert_eq!(out.env["DOCKER_CONTEXT"], "orbstack");
|
||||
assert_eq!(out.cwd.as_deref(), Some(Path::new("/work/proj")));
|
||||
assert_eq!(out.extras.target, None);
|
||||
assert!(out.extras.env_files.is_empty() && out.extras.include_system_env);
|
||||
assert_eq!(out.extras.stop_command.as_deref(), Some("/usr/local/bin/docker stop openidea-app-1"));
|
||||
assert_eq!(out.kind, "python", "test and output parsers still apply");
|
||||
|
||||
let mut c = config("npm run dev", &[]);
|
||||
c.kind = "npm".into();
|
||||
c.extras.emulate_terminal = true;
|
||||
let target = RunTarget { kind: RunTargetKind::Docker, image: "node:22".into(), ..Default::default() };
|
||||
let mut r = run(&target, &endpoint, &files);
|
||||
r.user = None;
|
||||
let out = container_config(&c, &r).unwrap();
|
||||
assert!(out.args.starts_with(&["run", "--rm", "--interactive", "--init", "--tty"].map(String::from)));
|
||||
assert!(!out.args.contains(&"--user".to_string()) && !out.args.contains(&"PYTHONUNBUFFERED".to_string()));
|
||||
assert!(out.args.ends_with(&["node:22", "sh", "-c", "npm run dev"].map(String::from)));
|
||||
|
||||
let target = RunTarget { kind: RunTargetKind::Docker, name: "none".into(), ..Default::default() };
|
||||
assert!(container_config(&c, &run(&target, &endpoint, &files)).unwrap_err().message.contains("no image"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn compose_target_runs_in_a_new_container_of_the_service() {
|
||||
let target = RunTarget {
|
||||
kind: RunTargetKind::Compose,
|
||||
compose_files: vec!["compose.yaml".into(), "compose.dev.yaml".into()],
|
||||
service: "web".into(),
|
||||
..Default::default()
|
||||
};
|
||||
let none = BTreeMap::new();
|
||||
let mut c = config("/work/proj/.venv/bin/python", &["manage.py", "test"]);
|
||||
c.kind = "django-test".into();
|
||||
c.cwd = Some(PathBuf::from("/work/proj/backend").into());
|
||||
let mut r = run(&target, &none, &none);
|
||||
r.container_root = "/app";
|
||||
let out = container_config(&c, &r).unwrap();
|
||||
assert_eq!(
|
||||
out.args.join(" "),
|
||||
"compose --file /work/proj/compose.yaml --file /work/proj/compose.dev.yaml run --rm --no-TTY \
|
||||
--name openidea-app-1 --label openidea.run=app --workdir /app/backend \
|
||||
--env DATA --env PYTHONUNBUFFERED --user 501:20 web python3 manage.py test"
|
||||
);
|
||||
assert_eq!(out.env["DATA"], "/app/data");
|
||||
|
||||
let target = RunTarget { kind: RunTargetKind::Compose, name: "c".into(), ..Default::default() };
|
||||
assert!(container_config(&c, &run(&target, &none, &none)).unwrap_err().message.contains("no service"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn compose_container_root_follows_the_project_bind_mount() {
|
||||
let json = r#"{"services": {
|
||||
"web": {"volumes": [
|
||||
{"type": "volume", "source": "data", "target": "/data"},
|
||||
{"type": "bind", "source": "/work", "target": "/src"},
|
||||
{"type": "bind", "source": "/work/proj", "target": "/app/"}
|
||||
]},
|
||||
"worker": {"volumes": [{"type": "bind", "source": "/work", "target": "/src"}]},
|
||||
"db": {}
|
||||
}}"#;
|
||||
let root = Path::new("/work/proj");
|
||||
assert_eq!(compose_container_root(json, "web", root).unwrap(), "/app", "the closest mount wins");
|
||||
assert_eq!(compose_container_root(json, "worker", root).unwrap(), "/src/proj");
|
||||
assert!(compose_container_root(json, "db", root).unwrap_err().message.contains("does not mount"));
|
||||
assert_eq!(compose_container_root(json, "api", root).unwrap_err().kind, ide_core::error::ErrorKind::NotFound);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_and_default_folder() {
|
||||
assert_eq!(container_name("workspace:a/b:Run app", "k3x"), "openidea-workspace-a-b-Run-app-k3x");
|
||||
assert_eq!(default_container_root(Path::new("/work/my proj")), "/workspaces/my-proj");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn stop_quotes_the_docker_cli_for_the_shell() {
|
||||
assert_eq!(shell_word("/usr/local/bin/docker"), "/usr/local/bin/docker");
|
||||
assert_eq!(shell_word("/Volumes/My Apps/docker"), "'/Volumes/My Apps/docker'");
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
//! Remote work over SSH: SFTP deployment ([`deploy`], [`mapping`]) and run
|
||||
//! configurations on run targets ([`target`]).
|
||||
//! configurations on run targets ([`target`]; Docker and Compose ones in [`docker`]).
|
||||
|
||||
pub mod deploy;
|
||||
pub mod docker;
|
||||
pub mod import;
|
||||
pub mod mapping;
|
||||
pub mod service;
|
||||
|
||||
@@ -338,6 +338,9 @@ impl RemoteService {
|
||||
) -> IdeResult<RunInfo> {
|
||||
let target_id = config.extras.target.as_deref().unwrap_or_default();
|
||||
let target = run_target(settings, target_id)?;
|
||||
if target.kind != ide_core::remote_settings::RunTargetKind::Ssh {
|
||||
return Err(IdeError::internal("container run targets start as local runs of the docker CLI"));
|
||||
}
|
||||
let session = self.ssh.session(&target.ssh_config).await?;
|
||||
let fs = RemoteFs::new(session.sftp().await?);
|
||||
let folder = if target.project_root.trim().is_empty() {
|
||||
|
||||
@@ -241,7 +241,7 @@ pub(crate) fn config(name: &str, kind: &str, command: String, args: Vec<String>,
|
||||
|
||||
/// Splits a command line into words: whitespace, `'…'` and `"…"` quoting, backslash escapes
|
||||
/// (like `parseArgs` in `src/platform/commandLine.ts`).
|
||||
pub(crate) fn split_args(line: &str) -> Vec<String> {
|
||||
pub fn split_args(line: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut cur = String::new();
|
||||
let mut has = false;
|
||||
|
||||
@@ -367,7 +367,7 @@ fn php() -> Vec<Sdk> {
|
||||
}
|
||||
}
|
||||
let found = dedupe_by_real_path(found);
|
||||
par_map(&found, |(path, source)| {
|
||||
let mut sdks = par_map(&found, |(path, source)| {
|
||||
let version = query(path, &["-r", "echo PHP_VERSION;"], VERSION_TIMEOUT)?;
|
||||
let version = version.trim().to_owned();
|
||||
if version.is_empty() {
|
||||
@@ -381,7 +381,16 @@ fn php() -> Vec<Sdk> {
|
||||
home: path.parent().and_then(Path::parent).map(Into::into),
|
||||
extra: None,
|
||||
})
|
||||
})
|
||||
});
|
||||
newest_first(&mut sdks);
|
||||
sdks
|
||||
}
|
||||
|
||||
/// Newest version first, number by number (8.3.12 before 8.3.9); stable, so on equal
|
||||
/// versions the `PATH` install stays first.
|
||||
pub(crate) fn newest_first(sdks: &mut [Sdk]) {
|
||||
let key = |s: &Sdk| ide_core::tool_path::version_key(s.version.as_deref().unwrap_or(""));
|
||||
sdks.sort_by_key(|s| std::cmp::Reverse(key(s)));
|
||||
}
|
||||
|
||||
fn node() -> Vec<Sdk> {
|
||||
|
||||
@@ -1169,7 +1169,7 @@ mod kotlin_bridge_bundle_tests {
|
||||
|
||||
/// Installs the app-owned JDT LS command bundle, falling back to ordinary LSP on IO failure.
|
||||
pub fn usages_bundle(data_dir: &Path) -> Option<PathBuf> {
|
||||
let extension = data_dir.join("java-usages-1.3.0.jar");
|
||||
let extension = data_dir.join("java-usages-1.5.0.jar");
|
||||
let bytes = include_bytes!("../../../java/java-usages/openidea-java-usages.jar");
|
||||
if std::fs::read(&extension).is_ok_and(|old| old == bytes) {
|
||||
clean_usages_bundles(data_dir, &extension);
|
||||
|
||||
@@ -52,6 +52,43 @@ pub fn composer_level(root: &Path) -> Option<PhpLanguageLevel> {
|
||||
.map(|v| PhpLanguageLevel::new(v, PhpLevelSource::Require))
|
||||
}
|
||||
|
||||
/// Why `composer install` is due in a Composer project.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ComposerInstall {
|
||||
/// No `vendor/autoload.php`.
|
||||
Missing,
|
||||
/// A package of `composer.lock` is not installed, or at another version.
|
||||
Outdated,
|
||||
}
|
||||
|
||||
/// The Composer project `file` belongs to (the nearest `composer.json` at or below `root`), and
|
||||
/// whether its dependencies need `composer install`.
|
||||
pub fn composer_install(file: &Path, root: &Path) -> Option<(PathBuf, ComposerInstall)> {
|
||||
let dir = file.ancestors().filter(|d| d.starts_with(root)).find(|d| d.join("composer.json").is_file())?;
|
||||
let composer: Value = serde_json::from_str(&std::fs::read_to_string(dir.join("composer.json")).ok()?).ok()?;
|
||||
let vendor = dir.join(composer.pointer("/config/vendor-dir").and_then(Value::as_str).unwrap_or("vendor"));
|
||||
if !vendor.join("autoload.php").is_file() {
|
||||
return Some((dir.to_path_buf(), ComposerInstall::Missing));
|
||||
}
|
||||
let read = |path: PathBuf| std::fs::read_to_string(path).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok());
|
||||
let lock = read(dir.join("composer.lock"))?;
|
||||
let installed = read(vendor.join("composer/installed.json")).unwrap_or(Value::Null);
|
||||
// Composer 2 writes `{"packages": [...], "dev": bool}`, Composer 1 the list itself.
|
||||
let packages = installed.get("packages").unwrap_or(&installed).as_array().cloned().unwrap_or_default();
|
||||
let versions: std::collections::HashMap<&str, &str> =
|
||||
packages.iter().filter_map(|p| Some((p.get("name")?.as_str()?, p.get("version")?.as_str()?))).collect();
|
||||
let dev = installed.get("dev").and_then(Value::as_bool).unwrap_or(true);
|
||||
let sections: &[&str] = if dev { &["packages", "packages-dev"] } else { &["packages"] };
|
||||
let outdated = sections
|
||||
.iter()
|
||||
.filter_map(|section| lock.get(*section).and_then(Value::as_array))
|
||||
.flatten()
|
||||
.filter_map(|p| Some((p.get("name")?.as_str()?, p.get("version")?.as_str()?)))
|
||||
.any(|(name, version)| versions.get(name) != Some(&version));
|
||||
outdated.then(|| (dir.to_path_buf(), ComposerInstall::Outdated))
|
||||
}
|
||||
|
||||
/// Gives each PHP version its own Intelephense workspace storage (below `data_dir`).
|
||||
/// Intelephense keeps the built-in functions of the version a workspace was first indexed
|
||||
/// with in that storage, and reuses them after the version changes. Without a version, or
|
||||
@@ -226,6 +263,39 @@ fn lower_bound(alternative: &str) -> Option<Version> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn composer_install_is_due_without_vendor_or_when_the_lock_differs() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
let app = root.join("app");
|
||||
std::fs::create_dir_all(app.join("src")).unwrap();
|
||||
let file = app.join("src/Kernel.php");
|
||||
assert_eq!(composer_install(&file, root), None, "not a Composer project");
|
||||
std::fs::write(app.join("composer.json"), r#"{"config": {"vendor-dir": "lib"}}"#).unwrap();
|
||||
assert_eq!(composer_install(&file, root), Some((app.clone(), ComposerInstall::Missing)));
|
||||
std::fs::create_dir_all(app.join("lib/composer")).unwrap();
|
||||
std::fs::write(app.join("lib/autoload.php"), "<?php").unwrap();
|
||||
assert_eq!(composer_install(&file, root), None, "installed, no lock to compare with");
|
||||
let lock = r#"{"packages": [{"name": "a/a", "version": "v1.2.0"}], "packages-dev": [{"name": "t/t", "version": "10.0.0"}]}"#;
|
||||
std::fs::write(app.join("composer.lock"), lock).unwrap();
|
||||
let installed = |packages: &str, dev: bool| {
|
||||
let json = format!(r#"{{"packages": [{packages}], "dev": {dev}}}"#);
|
||||
std::fs::write(app.join("lib/composer/installed.json"), json).unwrap();
|
||||
composer_install(&file, root)
|
||||
};
|
||||
let a = r#"{"name": "a/a", "version": "v1.2.0"}"#;
|
||||
let t = r#"{"name": "t/t", "version": "10.0.0"}"#;
|
||||
assert_eq!(installed(&format!("{a},{t}"), true), None);
|
||||
assert_eq!(installed(a, false), None, "`--no-dev` leaves out the dev packages");
|
||||
assert_eq!(installed(a, true), Some((app.clone(), ComposerInstall::Outdated)), "a dev package is missing");
|
||||
let old = r#"{"name": "a/a", "version": "v1.1.0"}"#;
|
||||
assert_eq!(installed(&format!("{old},{t}"), true), Some((app.clone(), ComposerInstall::Outdated)));
|
||||
// Composer 1 lists the packages at the top level.
|
||||
std::fs::write(app.join("lib/composer/installed.json"), format!("[{a},{t}]")).unwrap();
|
||||
assert_eq!(composer_install(&file, root), None);
|
||||
assert_eq!(composer_install(&root.join("other.php"), root), None, "outside the Composer project");
|
||||
}
|
||||
|
||||
fn level(composer: Option<&str>, explicit: &str) -> Option<String> {
|
||||
levels(composer, explicit).0.map(|l| l.version)
|
||||
}
|
||||
|
||||
@@ -1322,7 +1322,29 @@ fn php_sources_and_selected_interpreter() {
|
||||
assert_eq!(source("/opt/homebrew/opt/[email protected]/bin/php"), "homebrew");
|
||||
assert_eq!(source("/home/me/bin/php"), "path");
|
||||
|
||||
use crate::discover::{is_versioned_php_name, versioned_php_in};
|
||||
use crate::discover::{is_versioned_php_name, newest_first, versioned_php_in};
|
||||
let sdk = |path: &str, version: &str| crate::Sdk {
|
||||
path: std::path::PathBuf::from(path).into(),
|
||||
name: format!("PHP {version}"),
|
||||
version: Some(version.into()),
|
||||
source: "path".into(),
|
||||
home: None,
|
||||
extra: None,
|
||||
};
|
||||
let mut sdks = vec![
|
||||
sdk("/opt/homebrew/bin/php", "8.4.1"),
|
||||
sdk("/opt/homebrew/opt/[email protected]/bin/php", "8.1.31"),
|
||||
sdk("/opt/homebrew/opt/[email protected]/bin/php", "8.2.20"),
|
||||
sdk("/home/me/.phpbrew/php/php-8.3.9/bin/php", "8.3.9"),
|
||||
sdk("/home/me/.phpbrew/php/php-8.3.12/bin/php", "8.3.12"),
|
||||
sdk("/home/me/.phpbrew/php/php-7.4.33/bin/php", "7.4.33"),
|
||||
sdk("/usr/bin/php", "8.4.1"),
|
||||
];
|
||||
newest_first(&mut sdks);
|
||||
let order: Vec<_> = sdks.iter().map(|s| s.version.clone().unwrap()).collect();
|
||||
assert_eq!(order, ["8.4.1", "8.4.1", "8.3.12", "8.3.9", "8.2.20", "8.1.31", "7.4.33"]);
|
||||
// Stable: on equal versions the PATH install stays first.
|
||||
assert_eq!(sdks[0].path.0, std::path::PathBuf::from("/opt/homebrew/bin/php"));
|
||||
for name in ["php8.1", "php81", "php8", "php8.3.exe"] {
|
||||
assert!(is_versioned_php_name(name), "{name}");
|
||||
}
|
||||
|
||||
@@ -120,6 +120,7 @@ impl TerminalManager {
|
||||
for (k, v) in env {
|
||||
cmd.env(k, v);
|
||||
}
|
||||
apply_powershell_env(&shell, &mut cmd);
|
||||
|
||||
let child = pair
|
||||
.slave
|
||||
@@ -633,6 +634,18 @@ fn terminal_env(user: &BTreeMap<String, String>) -> BTreeMap<String, String> {
|
||||
env
|
||||
}
|
||||
|
||||
/// Older pwsh versions can crash while formatting a localized update banner
|
||||
/// (PowerShell/PowerShell#28131). Keep explicit preferences in the final environment.
|
||||
fn apply_powershell_env(shell: &str, cmd: &mut CommandBuilder) {
|
||||
let name = shell.rsplit(['/', '\\']).next().unwrap_or(shell);
|
||||
if cfg!(windows)
|
||||
&& (name.eq_ignore_ascii_case("pwsh") || name.eq_ignore_ascii_case("pwsh.exe"))
|
||||
&& cmd.get_env("POWERSHELL_UPDATECHECK").is_none_or(|v| v.is_empty())
|
||||
{
|
||||
cmd.env("POWERSHELL_UPDATECHECK", "Off");
|
||||
}
|
||||
}
|
||||
|
||||
/// Git Bash's MSYSTEM profile on Windows, when nobody chose one. A bare
|
||||
/// `bash.exe` would run with the `MSYS` default, whose PATH leaves `git` and
|
||||
/// the mingw64 tools out unless `<Git>\cmd` happens to be inherited — the Git
|
||||
@@ -938,6 +951,56 @@ mod tests {
|
||||
mod shell_start_on_every_platform {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn powershell_update_notifications_default_to_off_on_windows() {
|
||||
for shell in ["pwsh", "pwsh.exe", "PWSH.EXE", "C:\\Program Files\\PowerShell\\7\\PwSh.ExE", "C:/pwsh/pwsh.exe"]
|
||||
{
|
||||
for value in [None, Some("")] {
|
||||
let mut cmd = CommandBuilder::new(shell);
|
||||
cmd.env_clear();
|
||||
if let Some(value) = value {
|
||||
cmd.env("POWERSHELL_UPDATECHECK", value);
|
||||
}
|
||||
apply_powershell_env(shell, &mut cmd);
|
||||
let want = if cfg!(windows) { Some("Off") } else { value };
|
||||
assert_eq!(cmd.get_env("POWERSHELL_UPDATECHECK"), want.map(std::ffi::OsStr::new), "{shell}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn powershell_update_notification_preferences_are_preserved() {
|
||||
for value in ["Default", "LTS", "Off"] {
|
||||
let mut cmd = CommandBuilder::new("pwsh.exe");
|
||||
cmd.env_clear();
|
||||
cmd.env("POWERSHELL_UPDATECHECK", value);
|
||||
apply_powershell_env("pwsh.exe", &mut cmd);
|
||||
assert_eq!(cmd.get_env("POWERSHELL_UPDATECHECK"), Some(std::ffi::OsStr::new(value)));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn powershell_update_notification_preferences_ignore_key_case() {
|
||||
let mut cmd = CommandBuilder::new("pwsh.exe");
|
||||
cmd.env_clear();
|
||||
cmd.env("POWERSHELL_UPDATECHECK", "Off");
|
||||
cmd.env("powershell_updatecheck", "Default");
|
||||
apply_powershell_env("pwsh.exe", &mut cmd);
|
||||
assert_eq!(cmd.get_env("POWERSHELL_UPDATECHECK"), Some(std::ffi::OsStr::new("Default")));
|
||||
assert_eq!(cmd.iter_full_env_as_str().count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_shells_keep_their_update_notification_environment() {
|
||||
for shell in ["powershell.exe", "cmd.exe", "wsl.exe", "bash.exe", "C:\\pwsh.exe\\other.exe", "other-pwsh.exe"] {
|
||||
let mut cmd = CommandBuilder::new(shell);
|
||||
cmd.env_clear();
|
||||
apply_powershell_env(shell, &mut cmd);
|
||||
assert_eq!(cmd.get_env("POWERSHELL_UPDATECHECK"), None, "{shell}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn posix_shells_start_as_login_shells_everywhere() {
|
||||
let (shell, args) = resolve_shell(Some("C:\\Program Files\\Git\\bin\\bash.exe"), &[]);
|
||||
|
||||
@@ -29,6 +29,24 @@ pub fn display_name(tool: &str) -> &str {
|
||||
crate::registry::find(tool).map_or(tool, |t| t.display)
|
||||
}
|
||||
|
||||
/// A project's PHP-CS-Fixer: its committed configuration and `vendor/bin/php-cs-fixer`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PhpCsFixer {
|
||||
pub config: PathBuf,
|
||||
pub bin: PathBuf,
|
||||
}
|
||||
|
||||
/// PHP-CS-Fixer for `file`: the nearest folder above it with `.php-cs-fixer.php` (which wins, as
|
||||
/// PHP-CS-Fixer documents) or `.php-cs-fixer.dist.php`, and the nearest `vendor/bin/php-cs-fixer`.
|
||||
/// `None` without both: Reformat Code keeps the language server.
|
||||
pub fn php_cs_fixer(file: &Path) -> Option<PhpCsFixer> {
|
||||
let config = file.parent()?.ancestors().find_map(|dir| {
|
||||
[".php-cs-fixer.php", ".php-cs-fixer.dist.php"].iter().map(|name| dir.join(name)).find(|p| p.is_file())
|
||||
})?;
|
||||
let bin = find_up(file, &Path::new("vendor").join("bin").join("php-cs-fixer"))?;
|
||||
Some(PhpCsFixer { config, bin })
|
||||
}
|
||||
|
||||
fn package_bin(package: &Path, tool: &str) -> IdeResult<PathBuf> {
|
||||
let name = display_name(tool);
|
||||
let json: serde_json::Value = serde_json::from_slice(&std::fs::read(package.join("package.json"))?)
|
||||
@@ -159,6 +177,33 @@ pub fn arguments(
|
||||
args
|
||||
}
|
||||
|
||||
impl Tools {
|
||||
/// `php vendor/bin/php-cs-fixer fix` of `target` with the project's configuration, from the
|
||||
/// configuration's folder; `php` is the interpreter chosen in the settings, else the one on `PATH`.
|
||||
pub fn php_cs_fixer_command(&self, php: Option<&Path>, fixer: &PhpCsFixer, target: &Path) -> IdeResult<Command> {
|
||||
let php = match php {
|
||||
Some(php) => php.to_path_buf(),
|
||||
None => self.which("php").ok_or_else(|| IdeError::unavailable("PHP-CS-Fixer: PHP is not installed"))?,
|
||||
};
|
||||
let mut cmd = self.command(php);
|
||||
cmd.arg(&fixer.bin)
|
||||
.args(["fix", "--config"])
|
||||
.arg(&fixer.config)
|
||||
.args(["--using-cache=no", "--no-interaction", "--show-progress=none"])
|
||||
.arg(target);
|
||||
if let Some(dir) = fixer.config.parent() {
|
||||
cmd.current_dir(dir);
|
||||
}
|
||||
Ok(cmd)
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs a [`Tools::php_cs_fixer_command`], which rewrites its target file in place.
|
||||
pub fn run_php_cs_fixer(mut command: Command, cancel: &CancellationToken) -> IdeResult<String> {
|
||||
crate::process::run_input(&mut command, "", Duration::from_secs(30), cancel)
|
||||
.map_err(|e| IdeError::invalid(format!("PHP-CS-Fixer: {}", e.message.trim())))
|
||||
}
|
||||
|
||||
pub fn run(tool: &str, mut command: Command, text: &str, cancel: &CancellationToken) -> IdeResult<String> {
|
||||
crate::process::run_input(&mut command, text, Duration::from_secs(15), cancel).map_err(|e| e.with_tool(tool))
|
||||
}
|
||||
|
||||
@@ -868,17 +868,18 @@ impl Documents {
|
||||
.then(|| st.path.clone())
|
||||
})
|
||||
.collect();
|
||||
self.reload_from_disk(&paths, force_clean);
|
||||
self.reload_from_disk(&paths, force_clean, true);
|
||||
}
|
||||
|
||||
/// Re-reads from disk after an external change. Clean documents reload
|
||||
/// silently; dirty ones are left alone and reported via `IdeEvent::Notify`.
|
||||
/// Changes matching our own last save are ignored.
|
||||
pub fn on_disk_changed(&self, paths: &[PathBuf]) {
|
||||
self.reload_from_disk(paths, false);
|
||||
self.reload_from_disk(paths, false, false);
|
||||
}
|
||||
|
||||
fn reload_from_disk(&self, paths: &[PathBuf], force_clean: bool) {
|
||||
/// `dedup`: blind scans skip files whose `(mtime, length)` stamp still matches.
|
||||
fn reload_from_disk(&self, paths: &[PathBuf], force_clean: bool, dedup: bool) {
|
||||
for path in paths {
|
||||
let Some(id) = self.by_path(path) else { continue };
|
||||
let result = self.with_doc(id, |st| {
|
||||
@@ -887,7 +888,23 @@ impl Documents {
|
||||
}
|
||||
let Ok(meta) = std::fs::metadata(&st.path) else { return Ok(()) };
|
||||
let mtime = ide_vfs::mtime_ms(&meta);
|
||||
if mtime == st.disk_mtime && meta.len() == st.disk_len && (!force_clean || st.dirty) {
|
||||
let unchanged = mtime == st.disk_mtime && meta.len() == st.disk_len;
|
||||
// Blind scans trust the stamp. OS events are authoritative, but they also
|
||||
// echo our own saves: a clean document whose stamp matches is re-checked by
|
||||
// bytes — an external write that preserved both length and mtime differs
|
||||
// from what saving this buffer would write, our own save's echo does not.
|
||||
// Native-to-ascii properties documents and dirty documents keep the stamp
|
||||
// dedup: the former's disk bytes legitimately differ from the buffer text,
|
||||
// the latter warn once per disk state.
|
||||
if unchanged
|
||||
&& (!force_clean || st.dirty)
|
||||
&& (dedup
|
||||
|| st.dirty
|
||||
|| st.native_to_ascii
|
||||
|| ide_vfs::encode(&st.doc.text_for_save(), &st.encoding, st.has_bom)
|
||||
.map(|bytes| bytes == std::fs::read(&st.path).unwrap_or_default())
|
||||
.unwrap_or(true))
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
if st.dirty {
|
||||
|
||||
@@ -32,6 +32,7 @@ const BUILD_FILES: &[&str] = &[
|
||||
"Pipfile",
|
||||
"requirements.txt",
|
||||
"package.json",
|
||||
"composer.json",
|
||||
];
|
||||
|
||||
/// Folder names that are (or contain) conventional roots.
|
||||
@@ -214,6 +215,15 @@ fn module_roots(root: &Path, rel: &str, files: &[String], out: &mut Vec<SourceFo
|
||||
}
|
||||
}
|
||||
|
||||
// Before `package.json`: a Laravel app's `tests/` is the PHP test root.
|
||||
if has("composer.json")
|
||||
&& let Ok(text) = std::fs::read_to_string(dir.join("composer.json"))
|
||||
{
|
||||
for (sub, kind) in composer_roots(&text) {
|
||||
add(&sub, kind, "composer.json");
|
||||
}
|
||||
}
|
||||
|
||||
if has("package.json") {
|
||||
// A JVM build's `src` holds source sets, not JavaScript.
|
||||
if !has("pom.xml") && !GRADLE_FILES.iter().any(|f| has(f)) {
|
||||
@@ -230,6 +240,37 @@ fn module_roots(root: &Path, rel: &str, files: &[String], out: &mut Vec<SourceFo
|
||||
}
|
||||
}
|
||||
|
||||
/// `autoload.psr-4` folders as Source roots and `autoload-dev.psr-4` ones as Test roots, relative to
|
||||
/// the `composer.json` folder; paths that leave it or point into `vendor/` are left out.
|
||||
fn composer_roots(text: &str) -> Vec<(String, RootKind)> {
|
||||
let Ok(json) = serde_json::from_str::<serde_json::Value>(text) else { return Vec::new() };
|
||||
let mut out = Vec::new();
|
||||
for (section, kind) in [("autoload", RootKind::Source), ("autoload-dev", RootKind::Test)] {
|
||||
let Some(map) = json.get(section).and_then(|a| a.get("psr-4")).and_then(|m| m.as_object()) else { continue };
|
||||
for value in map.values() {
|
||||
let paths: Vec<&str> = match value {
|
||||
serde_json::Value::String(p) => vec![p.as_str()],
|
||||
serde_json::Value::Array(a) => a.iter().filter_map(|p| p.as_str()).collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
for path in paths {
|
||||
let parts: Vec<&str> = path.split(['/', '\\']).filter(|c| !c.is_empty() && *c != ".").collect();
|
||||
if path.starts_with(['/', '\\'])
|
||||
|| parts.iter().any(|c| *c == ".." || c.contains(':'))
|
||||
|| parts.first() == Some(&"vendor")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let sub = parts.join("/");
|
||||
if !out.iter().any(|(p, _)| *p == sub) {
|
||||
out.push((sub, kind));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Gradle's conventional `src/<set>/<lang>` folders that exist: `(set, lang)`.
|
||||
fn source_sets(dir: &Path) -> Vec<(String, String)> {
|
||||
let mut out = Vec::new();
|
||||
@@ -307,6 +348,47 @@ mod tests {
|
||||
assert!(roots.roots.iter().any(|r| r.path == "server/src" && r.kind == RootKind::Source));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composer_psr4_folders_are_source_and_test_roots() {
|
||||
let dir = tree(&[
|
||||
"app/",
|
||||
"database/factories/",
|
||||
"database/seeders/",
|
||||
"lib/",
|
||||
"tests/",
|
||||
"vendor/acme/src/",
|
||||
"package.json",
|
||||
]);
|
||||
fs::write(
|
||||
dir.path().join("composer.json"),
|
||||
r#"{
|
||||
"autoload": { "psr-4": {
|
||||
"App\\": "app/",
|
||||
"Database\\Factories\\": "database/factories/",
|
||||
"Database\\Seeders\\": ["database/seeders/", "lib", "missing/"],
|
||||
"Vendored\\": "vendor/acme/src/",
|
||||
"Outside\\": "../elsewhere/"
|
||||
} },
|
||||
"autoload-dev": { "psr-4": { "Tests\\": "tests/" } }
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let mut roots = summary(&detect(dir.path()));
|
||||
roots.sort();
|
||||
assert_eq!(
|
||||
roots,
|
||||
[
|
||||
"[email protected]",
|
||||
"database/[email protected]",
|
||||
"database/[email protected]",
|
||||
"[email protected]",
|
||||
"[email protected]",
|
||||
]
|
||||
);
|
||||
assert!(affects_roots(Path::new("composer.json")));
|
||||
assert!(composer_roots("not json").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maven_conventional_roots_and_target() {
|
||||
let dir = tree(&[
|
||||
|
||||
@@ -403,6 +403,46 @@ fn external_changes() {
|
||||
f.docs.on_disk_changed(&[f.root.join("other.txt")]);
|
||||
}
|
||||
|
||||
/// An IDE operation that rewrites a file with the same length and mtime still refreshes
|
||||
/// the clean buffer.
|
||||
#[test]
|
||||
fn own_disk_change_preserving_length_and_mtime_reloads_a_clean_document() {
|
||||
let f = fixture();
|
||||
let path = f.root.join("stamp.txt");
|
||||
fs::write(&path, "first").unwrap();
|
||||
let id = f.docs.open(&path).unwrap().id;
|
||||
let mtime = fs::metadata(&path).unwrap().modified().unwrap();
|
||||
fs::write(&path, "other").unwrap();
|
||||
fs::File::options().write(true).open(&path).unwrap().set_modified(mtime).unwrap();
|
||||
f.docs.sync_after_disk_change();
|
||||
assert_eq!(f.docs.snapshot(id).unwrap().text, "other");
|
||||
}
|
||||
|
||||
/// An external write that keeps the byte length and the mtime (timestamp-preserving
|
||||
/// tools, coarse-granularity filesystems) leaves the `(mtime, length)` stamp equal:
|
||||
/// the OS event is still authoritative, so a clean document reloads.
|
||||
#[test]
|
||||
fn external_change_preserving_length_and_mtime_reloads_a_clean_document() {
|
||||
let f = fixture();
|
||||
let path = f.root.join("stamp.txt");
|
||||
fs::write(&path, "first").unwrap(); // 5 bytes
|
||||
let id = f.docs.open(&path).unwrap().id;
|
||||
let mtime = fs::metadata(&path).unwrap().modified().unwrap();
|
||||
|
||||
fs::write(&path, "other").unwrap(); // 5 bytes, different content
|
||||
fs::File::options().write(true).open(&path).unwrap().set_modified(mtime).unwrap();
|
||||
f.docs.on_disk_changed(std::slice::from_ref(&path));
|
||||
let snap = f.docs.snapshot(id).unwrap();
|
||||
assert_eq!(snap.text, "other");
|
||||
assert!(!snap.dirty);
|
||||
|
||||
// The reload recorded the new stamp: repeating the same event changes nothing.
|
||||
let before = f.events.lock().len();
|
||||
f.docs.on_disk_changed(std::slice::from_ref(&path));
|
||||
assert_eq!(f.events.lock().len(), before);
|
||||
assert_eq!(f.docs.snapshot(id).unwrap().text, "other");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encodings_and_read_only() {
|
||||
let f = fixture();
|
||||
|
||||
@@ -324,7 +324,7 @@ a token.
|
||||
|
||||
<!-- BEGIN GENERATED TOKEN REFERENCE: pnpm gen:theme-docs -->
|
||||
|
||||
240 themeable tokens: 121 in the UI theme file, 119 in the editor colour scheme file.
|
||||
242 themeable tokens: 121 in the UI theme file, 121 in the editor colour scheme file.
|
||||
|
||||
### Surfaces (UI theme)
|
||||
|
||||
@@ -446,6 +446,8 @@ a token.
|
||||
| `--syn-python-predefined-usage` | `var(--syn-predefined)` | `var(--syn-predefined)` |
|
||||
| `--syn-python-fstring` | `#a5c261` | `var(--syn-string)` |
|
||||
| `--syn-python-bytes` | `#a5c261` | `var(--syn-string)` |
|
||||
| `--syn-php-variable` | `var(--syn-property)` | `var(--syn-property)` |
|
||||
| `--syn-php-function-call` | `var(--syn-function)` | `var(--syn-function)` |
|
||||
| `--syn-builtin` | `#8888c6` | `#000080` |
|
||||
| `--syn-self` | `#94558d` | `#94558d` |
|
||||
| `--syn-keyword-arg` | `#aa4926` | `#660099` |
|
||||
|
||||
@@ -2,7 +2,7 @@ Manifest-Version: 1.0
|
||||
Bundle-ManifestVersion: 2
|
||||
Bundle-Name: OpenIDEA Java usages
|
||||
Bundle-SymbolicName: ai.openidea.usages;singleton:=true
|
||||
Bundle-Version: 1.3.0
|
||||
Bundle-Version: 1.5.0
|
||||
Bundle-RequiredExecutionEnvironment: JavaSE-21
|
||||
Require-Bundle: org.eclipse.jdt.ls.core,org.eclipse.jdt.core,
|
||||
org.eclipse.jdt.core.manipulation,org.eclipse.core.runtime,
|
||||
|
||||
@@ -57,7 +57,7 @@ is not a claim of full IntelliJ feature parity.
|
||||
## Rebuild and delivery
|
||||
|
||||
The checked-in jar is embedded by ide-sdk `include_bytes!`, installed as
|
||||
`java-usages-1.3.0.jar`, and added alongside existing initialization bundles.
|
||||
`java-usages-1.5.0.jar`, and added alongside existing initialization bundles.
|
||||
Users need no compiler, Maven or Python. Manifest/install version must change
|
||||
when replacing the bundle because OSGi caches bundles.
|
||||
|
||||
@@ -75,6 +75,14 @@ unrelated content and non-Java resource changes; its reproducible SHA-256 is
|
||||
`cbae3e3a0bcf2818b4cc9d98998bd9b81f92e1ae7803dc757134485f9776a4e1`.
|
||||
Version 1.3.0 adds the class-hierarchy refactoring commands; its reproducible SHA-256 is
|
||||
`cca4cc385ace286111730d2a6164333b8958d74d69631548eadca8863255c24a`.
|
||||
Version 1.4.0 adds `openidea.java.implementations` (Go to Implementation in the workspace's
|
||||
source folders, before the standard request that also searches every dependency jar); its
|
||||
reproducible SHA-256 is `d7601493eda846811b4bb8bfe7f3d56e3234a45e663648210db3937cebc770a5`.
|
||||
Version 1.5.0 answers `{locations, complete}`: `complete` when the type is declared in the sources
|
||||
(no dependency implements it), otherwise the app also runs the standard request and adds the
|
||||
dependencies' implementations after the first ones. The search also covers the project's Kotlin
|
||||
classes attached by the Kotlin bridge; its reproducible SHA-256 is
|
||||
`7588748c2797f9da2beb209714a75c29b4e31b155f095329bfb7e399d807c20a`.
|
||||
|
||||
```sh
|
||||
export PATH=/path/to/jdk25/bin:$PATH
|
||||
@@ -104,6 +112,7 @@ python3 java/java-usages/tests/verify.py /path/to/jdtls /path/to/lombok.jar
|
||||
python3 java/java-usages/tests/read-write.py /path/to/jdtls /path/to/lombok.jar > /tmp/read-write.json
|
||||
OPENIDEA_USAGES_INDEX_STRESS=1 python3 java/java-usages/tests/read-write.py /path/to/jdtls /path/to/lombok.jar > /tmp/read-write-indexed.json
|
||||
python3 java/java-usages/tests/hierarchy.py /path/to/jdtls
|
||||
python3 java/java-usages/tests/implementations.py /path/to/jdtls
|
||||
```
|
||||
|
||||
The harnesses select the JDT LS configuration for the current platform. The first
|
||||
|
||||
Binary file not shown.
@@ -7,6 +7,9 @@
|
||||
<delegateCommandHandler class="ai.openidea.configuration.ConfigurationCommand">
|
||||
<command id="openidea.java.configuration"/>
|
||||
</delegateCommandHandler>
|
||||
<delegateCommandHandler class="ai.openidea.implementations.ImplementationsCommand">
|
||||
<command id="openidea.java.implementations"/>
|
||||
</delegateCommandHandler>
|
||||
<delegateCommandHandler class="ai.openidea.hierarchy.HierarchyCommand">
|
||||
<command id="openidea.java.hierarchy.members"/>
|
||||
<command id="openidea.java.hierarchy.refactor"/>
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package ai.openidea.implementations;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.eclipse.core.resources.ResourcesPlugin;
|
||||
import org.eclipse.core.runtime.IProgressMonitor;
|
||||
import org.eclipse.core.runtime.OperationCanceledException;
|
||||
import org.eclipse.jdt.core.*;
|
||||
import org.eclipse.jdt.internal.corext.util.MethodOverrideTester;
|
||||
import org.eclipse.jdt.ls.core.internal.IDelegateCommandHandler;
|
||||
import org.eclipse.jdt.ls.core.internal.JDTUtils;
|
||||
import org.eclipse.jdt.ls.core.internal.JavaLanguageServerPlugin;
|
||||
import org.eclipse.lsp4j.Location;
|
||||
|
||||
/**
|
||||
* Implementations of the type or method at a position, looked up in the workspace's own code
|
||||
* only (source folders and bridged Kotlin classes): {@code {locations, complete}}. The standard
|
||||
* request builds the type hierarchy over every dependency jar too, which takes long in a project
|
||||
* with many dependencies. {@code complete} when the type is declared in the sources, which no
|
||||
* dependency extends; otherwise the client also asks the standard request for the dependencies'
|
||||
* implementations.
|
||||
*/
|
||||
public final class ImplementationsCommand implements IDelegateCommandHandler {
|
||||
/** Classpath attribute of the Kotlin bridge's library entries (`java/kotlin-bridge`). */
|
||||
private static final String KOTLIN_OUTPUT = "ai.openidea.kotlin";
|
||||
|
||||
@Override
|
||||
public Object executeCommand(String command, List<Object> arguments, IProgressMonitor monitor) throws Exception {
|
||||
List<Location> locations = new ArrayList<>();
|
||||
boolean complete = find(arguments, locations, monitor);
|
||||
return Map.of("locations", locations, "complete", complete);
|
||||
}
|
||||
|
||||
/** Adds the implementations to {@code result}; whether they are all there are. */
|
||||
private static boolean find(List<Object> arguments, List<Location> result, IProgressMonitor monitor) throws Exception {
|
||||
String uri = (String) arguments.get(0);
|
||||
int line = ((Number) arguments.get(1)).intValue();
|
||||
int column = ((Number) arguments.get(2)).intValue();
|
||||
ITypeRoot root = JDTUtils.resolveTypeRoot(uri);
|
||||
if (root == null) return false;
|
||||
var preferences = JavaLanguageServerPlugin.getPreferencesManager();
|
||||
IJavaElement element = JDTUtils.findElementAtSelection(root, line, column, preferences, monitor);
|
||||
IMethod method = null;
|
||||
IType type;
|
||||
if (element instanceof IMethod m) {
|
||||
int flags = m.getFlags();
|
||||
if (m.isConstructor() || Flags.isStatic(flags) || Flags.isPrivate(flags) || Flags.isFinal(flags)) return false;
|
||||
method = m;
|
||||
type = m.getDeclaringType();
|
||||
} else if (element instanceof IType t) {
|
||||
type = t;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
if (type == null || type.getJavaProject() == null) return false;
|
||||
IRegion sources = JavaCore.newRegion();
|
||||
for (IJavaProject project : JavaCore.create(ResourcesPlugin.getWorkspace().getRoot()).getJavaProjects()) {
|
||||
for (IPackageFragmentRoot fragmentRoot : project.getPackageFragmentRoots()) {
|
||||
if (fragmentRoot.getKind() == IPackageFragmentRoot.K_SOURCE || isKotlinOutput(fragmentRoot)) {
|
||||
sources.add(fragmentRoot);
|
||||
}
|
||||
}
|
||||
}
|
||||
ITypeHierarchy hierarchy = type.getJavaProject().newTypeHierarchy(type, sources, monitor);
|
||||
for (IType subtype : hierarchy.getAllSubtypes(type)) {
|
||||
if (monitor.isCanceled()) throw new OperationCanceledException();
|
||||
IMember found;
|
||||
if (method == null) {
|
||||
found = subtype.isInterface() ? null : subtype;
|
||||
} else {
|
||||
// Seen from the subtype, so its type arguments substitute the method's type variables.
|
||||
IMethod overriding = new MethodOverrideTester(subtype, hierarchy).findOverridingMethodInType(subtype, method);
|
||||
found = overriding == null || Flags.isAbstract(overriding.getFlags()) ? null : overriding;
|
||||
}
|
||||
Location location = found == null ? null : JDTUtils.toLocation(found);
|
||||
if (location != null) result.add(location);
|
||||
}
|
||||
return !type.isBinary();
|
||||
}
|
||||
|
||||
/** The project's compiled Kotlin classes, attached as a library by the Kotlin bridge. */
|
||||
private static boolean isKotlinOutput(IPackageFragmentRoot root) throws JavaModelException {
|
||||
if (root.getKind() != IPackageFragmentRoot.K_BINARY) return false;
|
||||
IClasspathEntry entry = root.getRawClasspathEntry();
|
||||
if (entry == null) return false;
|
||||
for (IClasspathAttribute attribute : entry.getExtraAttributes()) {
|
||||
if (KOTLIN_OUTPUT.equals(attribute.getName()) && "true".equals(attribute.getValue())) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
"""Go to Implementation in project sources against a real JDT LS. Argument: jdtls home."""
|
||||
import json
|
||||
import pathlib
|
||||
import platform
|
||||
import queue
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import urllib.parse
|
||||
|
||||
home = pathlib.Path(sys.argv[1]).resolve()
|
||||
bundle = pathlib.Path(__file__).resolve().parents[1] / 'openidea-java-usages.jar'
|
||||
work = pathlib.Path(tempfile.mkdtemp(prefix='openidea-implementations-'))
|
||||
config = work / 'config'
|
||||
config.mkdir()
|
||||
system, machine = platform.system(), platform.machine().lower()
|
||||
config_name = {'Windows': 'config_win', 'Darwin': 'config_mac'}.get(system, 'config_linux')
|
||||
config_names = ([config_name + '_arm'] if machine in ('arm64', 'aarch64') else []) + [config_name]
|
||||
config_source = next(home / name / 'config.ini' for name in config_names if (home / name / 'config.ini').is_file())
|
||||
(config / 'config.ini').write_bytes(config_source.read_bytes())
|
||||
project = work / 'project'
|
||||
(project / 'src/zoo').mkdir(parents=True)
|
||||
(project / '.project').write_text('<projectDescription><name>zoo</name><buildSpec><buildCommand><name>org.eclipse.jdt.core.javabuilder</name></buildCommand></buildSpec><natures><nature>org.eclipse.jdt.core.javanature</nature></natures></projectDescription>')
|
||||
# `kotlin`: the project's compiled Kotlin classes, as the Kotlin bridge attaches them.
|
||||
(project / '.classpath').write_text('<classpath><classpathentry kind="src" path="src"/><classpathentry kind="lib" path="kotlin"><attributes><attribute name="ai.openidea.kotlin" value="true"/></attributes></classpathentry><classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER"/><classpathentry kind="output" path="bin"/></classpath>')
|
||||
sources = {
|
||||
'Repo.java': '''package zoo;
|
||||
|
||||
public interface Repo<T> {
|
||||
void save(T item);
|
||||
|
||||
default String name() {
|
||||
return "repo";
|
||||
}
|
||||
}
|
||||
''',
|
||||
'SubRepo.java': '''package zoo;
|
||||
|
||||
public interface SubRepo extends Repo<String> {
|
||||
}
|
||||
''',
|
||||
'Base.java': '''package zoo;
|
||||
|
||||
public abstract class Base implements Repo<Integer> {
|
||||
public abstract void save(Integer item);
|
||||
}
|
||||
''',
|
||||
'Numbers.java': '''package zoo;
|
||||
|
||||
public class Numbers extends Base {
|
||||
public void save(Integer item) {
|
||||
}
|
||||
}
|
||||
''',
|
||||
'Words.java': '''package zoo;
|
||||
|
||||
public class Words implements SubRepo {
|
||||
public void save(String item) {
|
||||
}
|
||||
|
||||
public String name() {
|
||||
return "words";
|
||||
}
|
||||
}
|
||||
''',
|
||||
'Task.java': '''package zoo;
|
||||
|
||||
public class Task implements Runnable {
|
||||
public void run() {
|
||||
}
|
||||
}
|
||||
''',
|
||||
'Use.java': '''package zoo;
|
||||
|
||||
public class Use {
|
||||
void use(Repo<String> repo, Runnable task) {
|
||||
repo.save("x");
|
||||
task.run();
|
||||
}
|
||||
|
||||
void alone() {
|
||||
}
|
||||
}
|
||||
''',
|
||||
}
|
||||
for name, text in sources.items():
|
||||
(project / 'src/zoo' / name).write_text(text)
|
||||
uri = lambda name: (project / 'src/zoo' / name).as_uri()
|
||||
# Stands in for a Kotlin class of the project implementing the Java interface.
|
||||
(work / 'kotlin-src/zoo').mkdir(parents=True)
|
||||
(work / 'kotlin-src/zoo/Longs.java').write_text('package zoo;\n\npublic class Longs implements Repo<Long> {\n public void save(Long item) {\n }\n}\n')
|
||||
subprocess.run(['javac', '-implicit:none', '-sourcepath', str(project / 'src'), '-d', str(project / 'kotlin'), str(work / 'kotlin-src/zoo/Longs.java')], check=True)
|
||||
|
||||
log = (work / 'stderr.log').open('w')
|
||||
proc = subprocess.Popen(['java', '-Declipse.application=org.eclipse.jdt.ls.core.id1', '-Dosgi.bundles.defaultStartLevel=4', '-Declipse.product=org.eclipse.jdt.ls.core.product', '-Xmx1G', '--add-modules=ALL-SYSTEM', '--add-opens', 'java.base/java.util=ALL-UNNAMED', '--add-opens', 'java.base/java.lang=ALL-UNNAMED', '-jar', str(next((home / 'plugins').glob('org.eclipse.equinox.launcher_*.jar'))), '-configuration', str(config), '-data', str(work / 'metadata')], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=log)
|
||||
messages = queue.Queue()
|
||||
lock = threading.Lock()
|
||||
|
||||
|
||||
def send(message):
|
||||
data = json.dumps(message).encode()
|
||||
with lock:
|
||||
proc.stdin.write(f'Content-Length: {len(data)}\r\n\r\n'.encode() + data)
|
||||
proc.stdin.flush()
|
||||
|
||||
|
||||
def read():
|
||||
while True:
|
||||
headers = {}
|
||||
while True:
|
||||
line = proc.stdout.readline()
|
||||
if not line:
|
||||
return
|
||||
if line == b'\r\n':
|
||||
break
|
||||
k, v = line.decode().split(':', 1)
|
||||
headers[k.lower()] = v.strip()
|
||||
message = json.loads(proc.stdout.read(int(headers['content-length'])))
|
||||
if 'method' in message and 'id' in message:
|
||||
send({'jsonrpc': '2.0', 'id': message['id'], 'result': [] if message['method'] == 'workspace/configuration' else None})
|
||||
else:
|
||||
messages.put(message)
|
||||
|
||||
|
||||
threading.Thread(target=read, daemon=True).start()
|
||||
seq = 0
|
||||
|
||||
|
||||
def request(method, params):
|
||||
global seq
|
||||
seq += 1
|
||||
send({'jsonrpc': '2.0', 'id': seq, 'method': method, 'params': params})
|
||||
deadline = time.monotonic() + 180
|
||||
while time.monotonic() < deadline:
|
||||
msg = messages.get(timeout=max(1, deadline - time.monotonic()))
|
||||
if msg.get('id') == seq:
|
||||
if 'error' in msg:
|
||||
raise RuntimeError(msg['error'])
|
||||
return msg.get('result')
|
||||
raise TimeoutError(method)
|
||||
|
||||
|
||||
def position(name, needle):
|
||||
lines = sources[name].splitlines()
|
||||
line = next(i for i, t in enumerate(lines) if needle in t)
|
||||
return line, lines[line].index(needle)
|
||||
|
||||
|
||||
def command(name, *args):
|
||||
return request('workspace/executeCommand', {'command': name, 'arguments': list(args)})
|
||||
|
||||
|
||||
|
||||
|
||||
def implementations(file, needle):
|
||||
line, col = position(file, needle)
|
||||
found = command('openidea.java.implementations', uri(file), line, col)
|
||||
names = []
|
||||
for loc in found['locations']:
|
||||
name = pathlib.Path(urllib.parse.unquote(urllib.parse.urlparse(loc['uri']).path)).name
|
||||
names.append(f"{name}:{loc['range']['start']['line']}")
|
||||
return sorted(names), found['complete']
|
||||
|
||||
|
||||
try:
|
||||
init = request('initialize', {'processId': None, 'rootUri': project.as_uri(), 'workspaceFolders': [{'uri': project.as_uri(), 'name': 'zoo'}], 'capabilities': {}, 'initializationOptions': {'bundles': [str(bundle)], 'extendedClientCapabilities': {'classFileContentsSupport': True}, 'settings': {'java': {'import': {'gradle': {'enabled': False}, 'maven': {'enabled': False}}}}}})
|
||||
assert 'openidea.java.implementations' in init['capabilities']['executeCommandProvider']['commands']
|
||||
send({'jsonrpc': '2.0', 'method': 'initialized', 'params': {}})
|
||||
for name, text in sources.items():
|
||||
send({'jsonrpc': '2.0', 'method': 'textDocument/didOpen', 'params': {'textDocument': {'uri': uri(name), 'languageId': 'java', 'version': 1, 'text': text}}})
|
||||
request('java/buildWorkspace', True)
|
||||
# (implementations in the sources, whether they are all there are)
|
||||
cases = {
|
||||
# Classes, abstract ones too, and those of the bridged Kotlin output; not the sub-interface.
|
||||
('Repo.java', 'Repo<T>'): (['Base.java:2', 'Longs.java:0', 'Numbers.java:2', 'Words.java:2'], True),
|
||||
# Through the type arguments; not the abstract re-declaration.
|
||||
('Repo.java', 'save(T'): (['Longs.java:0', 'Numbers.java:3', 'Words.java:3'], True),
|
||||
('Use.java', 'save("x")'): (['Longs.java:0', 'Numbers.java:3', 'Words.java:3'], True),
|
||||
('Repo.java', 'name()'): (['Words.java:6'], True),
|
||||
# A library type's implementations in the sources; the library has more.
|
||||
('Use.java', 'Runnable task'): (['Task.java:2'], False),
|
||||
('Use.java', 'run();'): (['Task.java:3'], False),
|
||||
('Use.java', 'alone()'): ([], True),
|
||||
}
|
||||
failures = []
|
||||
for (file, needle), expected in cases.items():
|
||||
actual = implementations(file, needle)
|
||||
print(f'{file} {needle!r}: {actual}')
|
||||
if actual != expected:
|
||||
failures.append((file, needle, expected, actual))
|
||||
assert not failures, failures
|
||||
print('ok')
|
||||
finally:
|
||||
proc.kill()
|
||||
shutil.rmtree(work, ignore_errors=True)
|
||||
@@ -17,4 +17,4 @@ install-hint = "dotnet tool install --global csharp-ls"
|
||||
adapter = "netcoredbg"
|
||||
command = "netcoredbg"
|
||||
args = ["--interpreter=vscode"]
|
||||
install-hint = "Install Samsung netcoredbg (https://github.com/Samsung/netcoredbg/releases)"
|
||||
install-hint = "Install netcoredbg (.NET debugger) in Settings › Languages & Frameworks › Language Servers"
|
||||
@@ -18,4 +18,4 @@ initialization-options = { AutomaticWorkspaceInit = true }
|
||||
adapter = "netcoredbg"
|
||||
command = "netcoredbg"
|
||||
args = ["--interpreter=vscode"]
|
||||
install-hint = "Install Samsung netcoredbg (https://github.com/Samsung/netcoredbg/releases)"
|
||||
install-hint = "Install netcoredbg (.NET debugger) in Settings › Languages & Frameworks › Language Servers"
|
||||
@@ -59,4 +59,4 @@ adapter = "js-debug"
|
||||
command = "js-debug-adapter"
|
||||
args = ["${port}", "127.0.0.1"]
|
||||
transport = "tcp"
|
||||
install-hint = "Install vscode-js-debug's DAP server (js-debug-dap-*.tar.gz from https://github.com/microsoft/vscode-js-debug/releases) as `js-debug-adapter`"
|
||||
install-hint = "Install JavaScript Debugger (vscode-js-debug) in Settings › Languages & Frameworks › Language Servers"
|
||||
@@ -58,4 +58,4 @@ adapter = "js-debug"
|
||||
command = "js-debug-adapter"
|
||||
args = ["${port}", "127.0.0.1"]
|
||||
transport = "tcp"
|
||||
install-hint = "Install vscode-js-debug's DAP server (js-debug-dap-*.tar.gz from https://github.com/microsoft/vscode-js-debug/releases) as `js-debug-adapter`"
|
||||
install-hint = "Install JavaScript Debugger (vscode-js-debug) in Settings › Languages & Frameworks › Language Servers"
|
||||
@@ -32,4 +32,4 @@ install-hint = "brew install kotlin-language-server (https://github.com/fwcd/kot
|
||||
[debugger]
|
||||
adapter = "kotlin-debug-adapter"
|
||||
command = "kotlin-debug-adapter"
|
||||
install-hint = "Install https://github.com/fwcd/kotlin-debug-adapter and put `kotlin-debug-adapter` on PATH"
|
||||
install-hint = "Install Kotlin Debug Adapter in Settings › Languages & Frameworks › Language Servers"
|
||||
+1
-1
@@ -29,4 +29,4 @@ install-hint = "Install phpactor (https://phpactor.readthedocs.io/en/master/usag
|
||||
[debugger]
|
||||
adapter = "php-debug"
|
||||
command = "php-debug-adapter"
|
||||
install-hint = "Install vscode-php-debug (Xdebug) as `php-debug-adapter` (e.g. via mason.nvim) and enable Xdebug"
|
||||
install-hint = "Install PHP Debug (Xdebug) in Settings › Languages & Frameworks › Language Servers, and enable Xdebug"
|
||||
+1
-1
@@ -23,4 +23,4 @@ adapter = "codelldb"
|
||||
command = "codelldb"
|
||||
args = ["--port", "${port}"]
|
||||
transport = "tcp"
|
||||
install-hint = "Install the CodeLLDB adapter (https://github.com/vadimcn/codelldb/releases) and put `codelldb` on PATH"
|
||||
install-hint = "Install CodeLLDB in Settings › Languages & Frameworks › Language Servers"
|
||||
@@ -17,4 +17,4 @@ install-hint = "npm i -g bash-language-server (optionally install shellcheck and
|
||||
[debugger]
|
||||
adapter = "bash-debug"
|
||||
command = "bash-debug-adapter"
|
||||
install-hint = "Install https://github.com/rogalmic/vscode-bash-debug as `bash-debug-adapter` (e.g. via mason.nvim)"
|
||||
install-hint = "Install Bash Debug in Settings › Languages & Frameworks › Language Servers"
|
||||
@@ -69,4 +69,4 @@ adapter = "js-debug"
|
||||
command = "js-debug-adapter"
|
||||
args = ["${port}", "127.0.0.1"]
|
||||
transport = "tcp"
|
||||
install-hint = "Install vscode-js-debug's DAP server (js-debug-dap-*.tar.gz from https://github.com/microsoft/vscode-js-debug/releases) as `js-debug-adapter`"
|
||||
install-hint = "Install JavaScript Debugger (vscode-js-debug) in Settings › Languages & Frameworks › Language Servers"
|
||||
@@ -58,4 +58,4 @@ adapter = "js-debug"
|
||||
command = "js-debug-adapter"
|
||||
args = ["${port}", "127.0.0.1"]
|
||||
transport = "tcp"
|
||||
install-hint = "Install vscode-js-debug's DAP server (js-debug-dap-*.tar.gz from https://github.com/microsoft/vscode-js-debug/releases) as `js-debug-adapter`"
|
||||
install-hint = "Install JavaScript Debugger (vscode-js-debug) in Settings › Languages & Frameworks › Language Servers"
|
||||
+1
-1
@@ -16,4 +16,4 @@ adapter = "codelldb"
|
||||
command = "codelldb"
|
||||
args = ["--port", "${port}"]
|
||||
transport = "tcp"
|
||||
install-hint = "Install the CodeLLDB adapter (https://github.com/vadimcn/codelldb/releases)"
|
||||
install-hint = "Install CodeLLDB in Settings › Languages & Frameworks › Language Servers"
|
||||
@@ -152,6 +152,13 @@ is loaded. Menus, shortcuts and status bar items refer to the plugin's
|
||||
declared commands. A plugin shortcut never takes a keystroke the IDE (or the
|
||||
user) already uses; the user can rebind it in the keymap editor.
|
||||
|
||||
A `when` clause (on commands and menus) can test whether the open project
|
||||
contains a file or folder: `when = "workspaceContains:.devcontainer/devcontainer.json"`.
|
||||
The path is exact (no globs) and relative to the project root; in a workspace the
|
||||
key is true when any of its projects contains the path. It updates when
|
||||
files change. An IDE that doesn't know the key treats it as
|
||||
false: the plugin still installs and the key reads as false there.
|
||||
|
||||
### Activation
|
||||
|
||||
Code is instantiated lazily: when one of the plugin's commands runs, or when
|
||||
|
||||
@@ -34,6 +34,8 @@
|
||||
"--syn-python-predefined-usage": "#c4a0f2",
|
||||
"--syn-python-fstring": "#9ccc7a",
|
||||
"--syn-python-bytes": "#9ccc7a",
|
||||
"--syn-php-variable": "#b9a3f0",
|
||||
"--syn-php-function-call": "#f2d07a",
|
||||
"--syn-builtin": "#6fc3df",
|
||||
"--syn-self": "#d6849c",
|
||||
"--syn-keyword-arg": "#c4a0f2",
|
||||
|
||||
+4
-1
@@ -200,7 +200,10 @@ async function smoke(language: string) {
|
||||
}
|
||||
|
||||
// Tests, with the intentionally failing one switched on: one failure, located, the rest passed.
|
||||
const { id, output } = await runToEnd(c.tests.failing(config(c.tests.config)));
|
||||
// Switched on in the saved configuration, as reruns launch the configuration as it is saved now.
|
||||
const failing = c.tests.failing(config(c.tests.config));
|
||||
useRun.setState((s) => ({ configs: s.configs.map((x) => (x.id === failing.id ? failing : x)) }));
|
||||
const { id, output } = await runToEnd(failing);
|
||||
const tests = runtime(id)!.tests!;
|
||||
const log = `\n--- output ---\n${output.slice(-80).join("\n")}`;
|
||||
expect(tests.counts(), log).toMatchObject({ passed: c.tests.passed, failed: 1 });
|
||||
|
||||
@@ -129,7 +129,8 @@ pub fn project_rename(window: Window, state: State<'_, AppState>, name: String)
|
||||
Ok(project_info(&project))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
// Not on the main thread: closing stops the project's language servers, runs and file watcher.
|
||||
#[tauri::command(async)]
|
||||
#[specta::specta]
|
||||
pub fn close_project(window: Window, state: State<'_, AppState>) {
|
||||
crate::windows::close_auxiliary_windows(window.app_handle(), window.label());
|
||||
|
||||
@@ -92,6 +92,13 @@ pub async fn debug_resume(state: State<'_, AppState>, session: DebugSessionId, t
|
||||
ide_host::ops::debug::debug_resume(&state.host, "", session, thread_id).await
|
||||
}
|
||||
|
||||
/// Types `text` into the session's program (a `runInTerminal` program).
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn debug_send_input(state: State<'_, AppState>, session: DebugSessionId, text: String) -> IdeResult<()> {
|
||||
ide_host::ops::debug::debug_send_input(&state.host, "", session, text).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn debug_pause(state: State<'_, AppState>, session: DebugSessionId, thread_id: i64) -> IdeResult<()> {
|
||||
|
||||
@@ -80,6 +80,23 @@ pub async fn lsp_goto(
|
||||
ide_host::ops::lsp::lsp_goto(&state.host, window.label(), doc, pos, kind).await
|
||||
}
|
||||
|
||||
/// Go to Implementation of the symbol at `pos`. Implementations found before a
|
||||
/// slower search for the rest arrive on `on_partial`; the result has them all.
|
||||
/// Cancel with `cancel_request(request)`.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn lsp_goto_implementation(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
request: RequestId,
|
||||
doc: DocId,
|
||||
pos: Pos,
|
||||
on_partial: tauri::ipc::Channel<Vec<Location>>,
|
||||
) -> IdeResult<Vec<Location>> {
|
||||
let on_partial = super::sender(on_partial);
|
||||
ide_host::ops::lsp::lsp_goto_implementation(&state.host, window.label(), request, doc, pos, on_partial).await
|
||||
}
|
||||
|
||||
/// Implementations of the symbols at `positions`, one list per position (the
|
||||
/// editor's "is implemented / overridden" gutter markers). Cancel with
|
||||
/// `cancel_request(request)`.
|
||||
|
||||
@@ -219,6 +219,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
lsp::lsp_signature_help,
|
||||
lsp::lsp_goto,
|
||||
lsp::lsp_implementations,
|
||||
lsp::lsp_goto_implementation,
|
||||
lsp::lsp_virtual_document,
|
||||
lsp::lsp_resolve_class_file,
|
||||
lsp::lsp_references,
|
||||
@@ -381,6 +382,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
debug::debug_completions,
|
||||
debug::debug_resume,
|
||||
debug::debug_pause,
|
||||
debug::debug_send_input,
|
||||
debug::debug_hot_reload,
|
||||
debug::debug_hot_restart,
|
||||
debug::debug_step,
|
||||
@@ -398,7 +400,9 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
tools::jdk_download,
|
||||
sdk::sdk_detect,
|
||||
sdk::sdk_python_effective,
|
||||
sdk::sdk_php_effective,
|
||||
sdk::sdk_php_language_level,
|
||||
sdk::sdk_php_composer_install,
|
||||
sdk::sdk_project_libraries,
|
||||
sdk::sdk_locate_tools,
|
||||
sdk::sdk_python_create_venv,
|
||||
|
||||
@@ -24,6 +24,13 @@ pub fn sdk_python_effective(window: Window, state: State<'_, AppState>) -> IdeRe
|
||||
ide_host::ops::sdk::sdk_python_effective(&state.host, window.label())
|
||||
}
|
||||
|
||||
/// The PHP interpreter chosen in Settings › Languages & Frameworks › PHP; `None`: the `php` on `PATH`.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn sdk_php_effective(window: Window, state: State<'_, AppState>) -> IdeResult<Option<ide_core::IpcPath>> {
|
||||
ide_host::ops::sdk::sdk_php_effective(&state.host, window.label())
|
||||
}
|
||||
|
||||
/// The PHP language level of the project `path` belongs to: the effective one and Auto's.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
@@ -35,6 +42,17 @@ pub async fn sdk_php_language_level(
|
||||
ide_host::ops::sdk::sdk_php_language_level(&state.host, window.label(), path).await
|
||||
}
|
||||
|
||||
/// The Composer project `path` belongs to, when its dependencies need `composer install`.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn sdk_php_composer_install(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
path: PathBuf,
|
||||
) -> IdeResult<Option<ide_host::ops::sdk::ComposerInstallState>> {
|
||||
ide_host::ops::sdk::sdk_php_composer_install(&state.host, window.label(), path).await
|
||||
}
|
||||
|
||||
/// SDK / library source folders shown under "External Libraries" in the Project view,
|
||||
/// with the Maven / Gradle dependency jars of the build model.
|
||||
#[tauri::command]
|
||||
|
||||
@@ -10,6 +10,8 @@ mod dev;
|
||||
mod events;
|
||||
mod jump_list;
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
mod linux_webkit;
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
mod linux_window_state;
|
||||
mod logging;
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -68,6 +70,8 @@ pub fn run() {
|
||||
// Before the first window: one taskbar identity for every launch path.
|
||||
jump_list::set_app_id(&context.config().identifier);
|
||||
logging::init(&context.config().identifier);
|
||||
#[cfg(target_os = "linux")]
|
||||
linux_webkit::configure();
|
||||
|
||||
let refresh_shell_path = ide_host::shell_env::inherit_login_shell_path(&context.config().identifier);
|
||||
// Managed LSP/DAP installs; must run before other threads start and before PATH is cached.
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
//! WebKitGTK's DMA-BUF renderer fails on Wayland with the NVIDIA driver: the window
|
||||
//! never shows and GDK logs "Error 71 (Protocol error) dispatching to Wayland display".
|
||||
//! On that combination the renderer is turned off before the first web view is created.
|
||||
|
||||
const DISABLE_DMABUF: &str = "WEBKIT_DISABLE_DMABUF_RENDERER";
|
||||
|
||||
/// Run before other threads start (`set_var`).
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn configure() {
|
||||
let nvidia = ["/proc/driver/nvidia/version", "/sys/module/nvidia"].iter().any(|p| std::path::Path::new(p).exists());
|
||||
if disable_dmabuf(|k| std::env::var(k).ok(), nvidia) {
|
||||
tracing::info!("Wayland with the NVIDIA driver: {DISABLE_DMABUF}=1");
|
||||
unsafe { std::env::set_var(DISABLE_DMABUF, "1") };
|
||||
}
|
||||
}
|
||||
|
||||
/// A Wayland session on the NVIDIA driver, unless the user already set the variable.
|
||||
fn disable_dmabuf(var: impl Fn(&str) -> Option<String>, nvidia: bool) -> bool {
|
||||
if !nvidia || var(DISABLE_DMABUF).is_some() {
|
||||
return false;
|
||||
}
|
||||
let wayland_display = var("WAYLAND_DISPLAY").is_some_and(|v| !v.is_empty());
|
||||
// GDK tries the backends of `GDK_BACKEND` in order.
|
||||
let gdk_wayland =
|
||||
var("GDK_BACKEND").is_none_or(|b| matches!(b.split(',').next().map(str::trim), Some("wayland" | "*" | "")));
|
||||
wayland_display && gdk_wayland
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn decide(vars: &[(&str, &str)], nvidia: bool) -> bool {
|
||||
disable_dmabuf(|k| vars.iter().find(|(name, _)| *name == k).map(|(_, v)| v.to_string()), nvidia)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turns_the_dmabuf_renderer_off_on_wayland_with_nvidia_only() {
|
||||
assert!(decide(&[("WAYLAND_DISPLAY", "wayland-0")], true));
|
||||
assert!(decide(&[("WAYLAND_DISPLAY", "wayland-0"), ("GDK_BACKEND", "wayland,x11")], true));
|
||||
assert!(!decide(&[("WAYLAND_DISPLAY", "wayland-0")], false));
|
||||
assert!(!decide(&[("DISPLAY", ":0")], true));
|
||||
assert!(!decide(&[("WAYLAND_DISPLAY", "")], true));
|
||||
assert!(!decide(&[("WAYLAND_DISPLAY", "wayland-0"), ("GDK_BACKEND", "x11")], true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_the_users_own_setting() {
|
||||
assert!(!decide(&[("WAYLAND_DISPLAY", "wayland-0"), (DISABLE_DMABUF, "0")], true));
|
||||
}
|
||||
}
|
||||
+44
-9
@@ -8,7 +8,7 @@ use objc2::{
|
||||
runtime::{AnyClass, AnyObject, Imp, ProtocolObject, Sel},
|
||||
};
|
||||
use objc2_app_kit::{NSWindow, NSWindowUserTabbingPreference};
|
||||
use objc2_foundation::{NSNotificationCenter, NSObjectProtocol};
|
||||
use objc2_foundation::{NSNotificationCenter, NSObjectProtocol, NSRect};
|
||||
use tauri::{AppHandle, Manager, WebviewWindow};
|
||||
|
||||
fn tabbing_preference(app: &AppHandle) -> IdeResult<TabbingPreference> {
|
||||
@@ -136,30 +136,35 @@ pub fn set_simple_fullscreen(window: &WebviewWindow, on: bool) -> tauri::Result<
|
||||
})
|
||||
}
|
||||
|
||||
/// A window in simple fullscreen: its `NSWindow` and the minimum size restored when it leaves.
|
||||
struct Covering {
|
||||
native: usize,
|
||||
min_size: objc2_foundation::NSSize,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// Minimum sizes of windows in simple fullscreen, restored when they leave it.
|
||||
static MIN_SIZES: std::cell::RefCell<std::collections::HashMap<String, objc2_foundation::NSSize>> =
|
||||
std::cell::RefCell::default();
|
||||
static COVERING: std::cell::RefCell<std::collections::HashMap<String, Covering>> = std::cell::RefCell::default();
|
||||
}
|
||||
|
||||
/// Simple fullscreen drops the title bar and resizing; ours is transparent over the web toolbar,
|
||||
/// so keep it and its buttons, the green one (enabled only for a resizable window) leading back
|
||||
/// out of fullscreen. A minimum size of the whole screen keeps the edges from being dragged.
|
||||
/// A titled window is kept below the menu bar or camera housing; see `constrain_frame_rect`.
|
||||
fn keep_window_buttons(window: &WebviewWindow, on: bool) {
|
||||
use objc2_app_kit::NSWindowStyleMask;
|
||||
let Ok(pointer) = window.ns_window() else { return };
|
||||
let native = unsafe { &*pointer.cast::<NSWindow>() };
|
||||
let label = window.label().to_owned();
|
||||
if !on {
|
||||
if let Some(size) = MIN_SIZES.with_borrow_mut(|sizes| sizes.remove(&label)) {
|
||||
native.setMinSize(size);
|
||||
if let Some(covering) = COVERING.with_borrow_mut(|windows| windows.remove(&label)) {
|
||||
native.setMinSize(covering.min_size);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let Some(screen) = native.screen() else { return };
|
||||
let frame = screen.frame();
|
||||
MIN_SIZES.with_borrow_mut(|sizes| {
|
||||
sizes.entry(label).or_insert_with(|| native.minSize());
|
||||
COVERING.with_borrow_mut(|windows| {
|
||||
windows.entry(label).or_insert_with(|| Covering { native: pointer as usize, min_size: native.minSize() });
|
||||
});
|
||||
native.setMinSize(frame.size);
|
||||
native.setStyleMask(native.styleMask() | NSWindowStyleMask::Titled | NSWindowStyleMask::Resizable);
|
||||
@@ -177,7 +182,7 @@ pub fn forget_simple_fullscreen(app: &AppHandle, label: &str) -> bool {
|
||||
if was {
|
||||
let label = label.to_owned();
|
||||
let _ = app.run_on_main_thread(move || {
|
||||
MIN_SIZES.with_borrow_mut(|sizes| sizes.remove(&label));
|
||||
COVERING.with_borrow_mut(|windows| windows.remove(&label));
|
||||
present(any);
|
||||
});
|
||||
}
|
||||
@@ -218,9 +223,39 @@ pub fn intercept_toggle_fullscreen(window: &WebviewWindow) {
|
||||
let _ = NATIVE_TOGGLE_FULLSCREEN.set(native);
|
||||
let replacement: Imp = std::mem::transmute(toggle_fullscreen as ToggleFullScreen);
|
||||
objc2::ffi::class_replaceMethod(class, selector, replacement, types);
|
||||
|
||||
let selector = objc2::sel!(constrainFrameRect:toScreen:);
|
||||
let Some(method) = (*class).instance_method(selector) else { return };
|
||||
let native: ConstrainFrameRect = std::mem::transmute(method.implementation());
|
||||
let types = objc2::ffi::method_getTypeEncoding(method);
|
||||
let _ = NATIVE_CONSTRAIN_FRAME_RECT.set(native);
|
||||
let replacement: Imp = std::mem::transmute(constrain_frame_rect as ConstrainFrameRect);
|
||||
objc2::ffi::class_replaceMethod(class, selector, replacement, types);
|
||||
});
|
||||
}
|
||||
|
||||
type ConstrainFrameRect = unsafe extern "C-unwind" fn(*mut AnyObject, Sel, NSRect, *mut AnyObject) -> NSRect;
|
||||
|
||||
static NATIVE_CONSTRAIN_FRAME_RECT: std::sync::OnceLock<ConstrainFrameRect> = std::sync::OnceLock::new();
|
||||
|
||||
/// AppKit keeps a titled window's top edge below the menu bar, and on a notched display below the
|
||||
/// camera housing, whenever the window is placed, shown or resized. A window in simple fullscreen
|
||||
/// is titled for its buttons but must still cover the whole screen.
|
||||
unsafe extern "C-unwind" fn constrain_frame_rect(
|
||||
this: *mut AnyObject,
|
||||
selector: Sel,
|
||||
frame: NSRect,
|
||||
screen: *mut AnyObject,
|
||||
) -> NSRect {
|
||||
if COVERING.with_borrow(|windows| windows.values().any(|covering| covering.native == this as usize)) {
|
||||
return frame;
|
||||
}
|
||||
match NATIVE_CONSTRAIN_FRAME_RECT.get() {
|
||||
Some(native) => unsafe { native(this, selector, frame, screen) },
|
||||
None => frame,
|
||||
}
|
||||
}
|
||||
|
||||
unsafe extern "C-unwind" fn toggle_fullscreen(this: *mut AnyObject, selector: Sel, sender: *mut AnyObject) {
|
||||
if let Some(window) = APP.get().and_then(|app| simple_fullscreen_window(app, this)) {
|
||||
if let Err(error) = crate::window_groups::toggle_fullscreen(&window) {
|
||||
|
||||
+75
-5
@@ -409,6 +409,12 @@ export const commands = {
|
||||
* `cancel_request(request)`.
|
||||
*/
|
||||
lspImplementations: (request: RequestId, doc: DocId, positions: Pos[]) => typedError<Location[][], IdeError>(__TAURI_INVOKE("lsp_implementations", { request, doc, positions })),
|
||||
/**
|
||||
* Go to Implementation of the symbol at `pos`. Implementations found before a
|
||||
* slower search for the rest arrive on `on_partial`; the result has them all.
|
||||
* Cancel with `cancel_request(request)`.
|
||||
*/
|
||||
lspGotoImplementation: (request: RequestId, doc: DocId, pos: Pos, onPartial: Channel<Location[]>) => typedError<Location[], IdeError>(__TAURI_INVOKE("lsp_goto_implementation", { request, doc, pos, onPartial })),
|
||||
/**
|
||||
* Contents of a read-only language-server document, including JDT library
|
||||
* classes and disk class files. Cached per project.
|
||||
@@ -735,6 +741,8 @@ export const commands = {
|
||||
debugCompletions: (session: DebugSessionId, frameId: number | null, text: string, line: number, column: number) => typedError<DebugCompletion[], IdeError>(__TAURI_INVOKE("debug_completions", { session, frameId, text, line, column })),
|
||||
debugResume: (session: DebugSessionId, threadId: number) => typedError<null, IdeError>(__TAURI_INVOKE("debug_resume", { session, threadId })),
|
||||
debugPause: (session: DebugSessionId, threadId: number) => typedError<null, IdeError>(__TAURI_INVOKE("debug_pause", { session, threadId })),
|
||||
/** Types `text` into the session's program (a `runInTerminal` program). */
|
||||
debugSendInput: (session: DebugSessionId, text: string) => typedError<null, IdeError>(__TAURI_INVOKE("debug_send_input", { session, text })),
|
||||
debugHotReload: (session: DebugSessionId) => typedError<null, IdeError>(__TAURI_INVOKE("debug_hot_reload", { session })),
|
||||
debugHotRestart: (session: DebugSessionId) => typedError<null, IdeError>(__TAURI_INVOKE("debug_hot_restart", { session })),
|
||||
debugStep: (session: DebugSessionId, threadId: number, kind: StepKind) => typedError<null, IdeError>(__TAURI_INVOKE("debug_step", { session, threadId, kind })),
|
||||
@@ -772,8 +780,15 @@ export const commands = {
|
||||
sdkDetect: (kind: SdkKind) => typedError<Sdk[], IdeError>(__TAURI_INVOKE("sdk_detect", { kind })),
|
||||
/** The interpreter the project uses: the configured one, else its virtualenv. */
|
||||
sdkPythonEffective: () => typedError<string | null, IdeError>(__TAURI_INVOKE("sdk_python_effective")),
|
||||
/** The PHP interpreter chosen in Settings › Languages & Frameworks › PHP; `None`: the `php` on `PATH`. */
|
||||
sdkPhpEffective: () => typedError<string | null, IdeError>(__TAURI_INVOKE("sdk_php_effective")),
|
||||
/** The PHP language level of the project `path` belongs to: the effective one and Auto's. */
|
||||
sdkPhpLanguageLevel: (path: string) => typedError<PhpLanguageLevels, IdeError>(__TAURI_INVOKE("sdk_php_language_level", { path })),
|
||||
/** The Composer project `path` belongs to, when its dependencies need `composer install`. */
|
||||
sdkPhpComposerInstall: (path: string) => typedError<{
|
||||
root: IpcPath,
|
||||
reason: ComposerInstall,
|
||||
} | null, IdeError>(__TAURI_INVOKE("sdk_php_composer_install", { path })),
|
||||
/**
|
||||
* SDK / library source folders shown under "External Libraries" in the Project view,
|
||||
* with the Maven / Gradle dependency jars of the build model.
|
||||
@@ -2376,6 +2391,19 @@ export type ComposeOp = "start" | "stop" | "restart" |
|
||||
/** Stops and removes the project's containers and networks. */
|
||||
"down";
|
||||
|
||||
/** Why `composer install` is due in a Composer project. */
|
||||
export type ComposerInstall =
|
||||
/** No `vendor/autoload.php`. */
|
||||
"missing" |
|
||||
/** A package of `composer.lock` is not installed, or at another version. */
|
||||
"outdated";
|
||||
|
||||
/** The Composer project `path` belongs to, when its dependencies need `composer install`. */
|
||||
export type ComposerInstallState = {
|
||||
root: IpcPath,
|
||||
reason: ComposerInstall,
|
||||
};
|
||||
|
||||
/** The user's answer on an install or consent page. */
|
||||
export type Consent = {
|
||||
/**
|
||||
@@ -2835,7 +2863,12 @@ export type DebugEvent =
|
||||
* First event of every session (only build output of a root session's
|
||||
* `cargo` step can precede it). `parent` is set for adapter-started child sessions.
|
||||
*/
|
||||
{ kind: "started"; name: string; adapter: string; parent: DebugSessionId | null } | { kind: "initialized" } | { kind: "stopped"; threadId: number; reason: string; description: string | null; allThreadsStopped: boolean } | { kind: "continued"; threadId: number; allThreadsContinued: boolean } | { kind: "output"; category: string; output: string } | { kind: "thread"; threadId: number; started: boolean } | { kind: "breakpointsChanged"; path: IpcPath } | { kind: "terminated" } | { kind: "exited"; code: number } |
|
||||
{ kind: "started"; name: string; adapter: string; parent: DebugSessionId | null } | { kind: "initialized" } | { kind: "stopped"; threadId: number; reason: string; description: string | null; allThreadsStopped: boolean } | { kind: "continued"; threadId: number; allThreadsContinued: boolean } | { kind: "output"; category: string; output: string } | { kind: "thread"; threadId: number; started: boolean } |
|
||||
/**
|
||||
* The program runs in a terminal the IDE gave it (`runInTerminal`): what is typed for it
|
||||
* in the debug console reaches its input.
|
||||
*/
|
||||
{ kind: "programInput" } | { kind: "breakpointsChanged"; path: IpcPath } | { kind: "terminated" } | { kind: "exited"; code: number } |
|
||||
/**
|
||||
* java-debug's `hotcodereplace` event: `change_type` is `BUILD_COMPLETE` (classes
|
||||
* changed and can be reloaded), `STARTING`, `END`, `WARNING` or `ERROR`.
|
||||
@@ -6485,22 +6518,57 @@ export type RunSettings = {
|
||||
terminationTimeoutMs?: number,
|
||||
};
|
||||
|
||||
/** Build, Execution, Deployment › Run Targets: run configurations on another machine. */
|
||||
/**
|
||||
* Build, Execution, Deployment › Run Targets: run configurations on another machine
|
||||
* or in a container.
|
||||
*/
|
||||
export type RunTarget = {
|
||||
kind?: RunTargetKind,
|
||||
id?: string,
|
||||
name?: string,
|
||||
/** [`SshConfiguration::id`]. */
|
||||
/** [`SshConfiguration::id`] (SSH). */
|
||||
sshConfig?: string,
|
||||
/** Remote folder the project is synchronised to and run from. */
|
||||
/** Remote folder the project is synchronised to and run from (SSH). */
|
||||
projectRoot?: string,
|
||||
/** Upload changed project files before each run. */
|
||||
/** Upload changed project files before each run (SSH). */
|
||||
syncBeforeRun?: boolean,
|
||||
/** Interpreters on the target; empty = the one on the remote `PATH`. */
|
||||
python?: string,
|
||||
node?: string,
|
||||
java?: string,
|
||||
/**
|
||||
* Id of a Docker connection of the Services tool window (Docker, Compose);
|
||||
* empty = the CLI's own daemon.
|
||||
*/
|
||||
dockerConnection?: string,
|
||||
/** Image to run (Docker). */
|
||||
image?: string,
|
||||
/** Compose files, relative to the project; empty = the project's default one (Compose). */
|
||||
composeFiles?: string[],
|
||||
/** Service to run (Compose). */
|
||||
service?: string,
|
||||
/**
|
||||
* Where the project is in the container; empty = `/workspaces/<folder>` (Docker),
|
||||
* the service's bind mount of the project (Compose).
|
||||
*/
|
||||
containerRoot?: string,
|
||||
/** Published ports, as `docker run --publish` takes them (`8000:8000`). */
|
||||
ports?: string[],
|
||||
/** More options for `docker run` / `docker compose run`, shell-like quoted. */
|
||||
runOptions?: string,
|
||||
/** Run as the image's user instead of the project folder's owner (Linux). */
|
||||
imageUser?: boolean,
|
||||
};
|
||||
|
||||
/** Where a run target runs configurations. */
|
||||
export type RunTargetKind =
|
||||
/** Another machine over SSH, the project synchronised to its folder. */
|
||||
"ssh" |
|
||||
/** A new container of an image (`docker run --rm`), the project bind-mounted. */
|
||||
"docker" |
|
||||
/** A new container of a Compose service (`docker compose run --rm`). */
|
||||
"compose";
|
||||
|
||||
export type RustSettings = {
|
||||
/** Directory holding `cargo` / `rustc`; empty = `PATH` / rustup default. */
|
||||
toolchain?: string,
|
||||
@@ -6868,6 +6936,8 @@ export type SmartKeysSettings = {
|
||||
/** Reformat on paste: `none` | `indentBlock` | `indentEachLine`. */
|
||||
reformatOnPaste?: string,
|
||||
smartIndent?: boolean,
|
||||
/** Enter › Insert documentation comment stub: Enter after `/**` closes the comment. */
|
||||
insertDocStub?: boolean,
|
||||
/** Backspace › Unindent: `autoindent` (proper indent position) | `nearest` indent position | `none`. */
|
||||
backspaceUnindent?: string,
|
||||
};
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
type SyntaxHighlights,
|
||||
type TextChange,
|
||||
} from "@/platform/ipc";
|
||||
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
||||
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { customRegions } from "./cm/regions";
|
||||
@@ -60,6 +61,11 @@ export interface DocBackend {
|
||||
onDirty(handler: (e: { doc: DocId; dirty: boolean }) => void): () => void;
|
||||
/** Files changed on disk (used to refresh nothing but closed-file state). */
|
||||
onFsChanged?(handler: (e: { removed: string[] }) => void): () => void;
|
||||
/**
|
||||
* Every OpenIDEA window lost focus to another application. Also seen while focus is in a frame
|
||||
* (a plugin's web view), which hides the window's own `blur`.
|
||||
*/
|
||||
onApplicationDeactivated?(handler: () => void): () => void;
|
||||
/**
|
||||
* An open document's language changed (Settings › File Types, Override File
|
||||
* Type), or its own mode (`DocumentInfo.uiMode`).
|
||||
@@ -86,6 +92,11 @@ export const ipcBackend: DocBackend = {
|
||||
onPatched: (h) => onIdeEvent("docPatched", (e) => h(e)),
|
||||
onDirty: (h) => onIdeEvent("docDirty", (e) => h(e)),
|
||||
onFsChanged: (h) => onIdeEvent("fsChanged", (e) => h(e)),
|
||||
onApplicationDeactivated: (h) => {
|
||||
if (!isTauri()) return () => {};
|
||||
const listener = getCurrentWebviewWindow().listen("ide://application-deactivated", () => h());
|
||||
return () => void listener.then((unlisten) => unlisten());
|
||||
},
|
||||
onLanguage: (h) => onIdeEvent("docLanguage", (e) => h(e)),
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { EditorSelection, EditorState } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { undo, history } from "@codemirror/commands";
|
||||
import { enterInDocComment, phpDocTags } from "./docComment";
|
||||
|
||||
const views: EditorView[] = [];
|
||||
afterEach(() => views.splice(0).forEach((v) => v.destroy()));
|
||||
|
||||
const slashStar = EditorState.languageData.of(() => [
|
||||
{ commentTokens: { line: "//", block: { open: "/*", close: "*/" } } },
|
||||
]);
|
||||
const hash = EditorState.languageData.of(() => [{ commentTokens: { line: "#" } }]);
|
||||
|
||||
/** `|` marks the caret. */
|
||||
function enter(text: string, language = "php", tokens = slashStar) {
|
||||
const at = text.indexOf("|");
|
||||
const view = new EditorView({
|
||||
state: EditorState.create({
|
||||
doc: text.replace("|", ""),
|
||||
selection: EditorSelection.cursor(at),
|
||||
extensions: [tokens, history()],
|
||||
}),
|
||||
});
|
||||
views.push(view);
|
||||
const handled = enterInDocComment(language)(view);
|
||||
const s = view.state;
|
||||
const caret = s.selection.main.head;
|
||||
return { handled, view, text: s.doc.sliceString(0, caret) + "|" + s.doc.sliceString(caret) };
|
||||
}
|
||||
|
||||
describe("Enter after /**", () => {
|
||||
it("lists a PHP function's parameters and return type, caret on the summary line", () => {
|
||||
const r = enter("/**|\nfunction f(int $a, $b): string {}\n/**\n * old\n */\nfunction g() {}");
|
||||
expect(r.handled).toBe(true);
|
||||
expect(r.text).toBe(
|
||||
"/**\n * |\n * @param int $a\n * @param $b\n * @return string\n */\nfunction f(int $a, $b): string {}\n/**\n * old\n */\nfunction g() {}",
|
||||
);
|
||||
undo(r.view);
|
||||
expect(r.view.state.doc.toString()).toBe(
|
||||
"/**\nfunction f(int $a, $b): string {}\n/**\n * old\n */\nfunction g() {}",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the indentation of the /** line inside a class", () => {
|
||||
expect(
|
||||
enter("class A {\n /**|\n public static function make(?array $o = [], string ...$rest): ?static {}\n}")
|
||||
.text,
|
||||
).toBe(
|
||||
"class A {\n /**\n * |\n * @param ?array $o\n * @param string ...$rest\n * @return ?static\n */\n public static function make(?array $o = [], string ...$rest): ?static {}\n}",
|
||||
);
|
||||
});
|
||||
|
||||
it("gives constructors and void methods no @return; promoted parameters keep their type", () => {
|
||||
expect(phpDocTags("public function __construct(private int $a, #[Attr] readonly Foo&Bar $b) {}")).toEqual([
|
||||
"@param int $a",
|
||||
"@param Foo&Bar $b",
|
||||
]);
|
||||
expect(phpDocTags("function run(array &$x = array(1, 2), &$y): void {}")).toEqual(["@param array $x", "@param $y"]);
|
||||
expect(phpDocTags("abstract protected function find(int|string $id): ?Model;")).toEqual([
|
||||
"@param int|string $id",
|
||||
"@return ?Model",
|
||||
]);
|
||||
});
|
||||
|
||||
it("closes the comment with an empty summary above anything else, and in other /* */ languages", () => {
|
||||
expect(enter("/**|\nclass A {}").text).toBe("/**\n * |\n */\nclass A {}");
|
||||
expect(enter(" /**|\n\nconst x = 1;", "javascript").text).toBe(" /**\n * |\n */\n\nconst x = 1;");
|
||||
});
|
||||
|
||||
it("only breaks the line in an existing doc block, after other text, or without /* */ comments", () => {
|
||||
expect(enter("/**|\n * old\n */\nfunction f() {}").handled).toBe(false);
|
||||
expect(enter("$a = 1; /**|").handled).toBe(false);
|
||||
expect(enter("/** x|").handled).toBe(false);
|
||||
expect(enter("/**|", "python", hash).handled).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Smart Keys › Enter › Insert documentation comment stub: Enter right after a `/**` that opens a
|
||||
* new comment closes it, the caret on the summary line. In PHP the stub of a function lists its
|
||||
* parameters (`@param`) and return type (`@return`), as PhpStorm does.
|
||||
*/
|
||||
import { EditorSelection } from "@codemirror/state";
|
||||
import type { Command } from "@codemirror/view";
|
||||
|
||||
/** `@param` / `@return` lines for the PHP function declared in `text` (the code below the `/**`). */
|
||||
export function phpDocTags(text: string): string[] {
|
||||
const m =
|
||||
/^\s*(?:#\[[^\]]*\]\s*)*(?:(?:abstract|final|public|protected|private|static)\s+)*function\s+&?\s*(\w+)\s*\(/.exec(
|
||||
text,
|
||||
);
|
||||
if (!m) return [];
|
||||
let depth = 1;
|
||||
let i = m[0].length;
|
||||
for (; i < text.length && depth > 0; i++) {
|
||||
const c = text[i];
|
||||
if (c === "(" || c === "[") depth++;
|
||||
else if (c === ")" || c === "]") depth--;
|
||||
}
|
||||
if (depth > 0) return [];
|
||||
const tags = splitTopLevel(text.slice(m[0].length, i - 1)).flatMap((param) => {
|
||||
const p = /^(?:#\[[^\]]*\]\s*)*(?:(?:public|protected|private|readonly)\s+)*([^$=.]*?)\s*(\.\.\.)?\s*(\$\w+)/.exec(
|
||||
param.trim(),
|
||||
);
|
||||
if (!p) return [];
|
||||
// `&$x` passes by reference: the `&` belongs to no type (`Foo&Bar` is an intersection).
|
||||
const type = p[1]!.replace(/&\s*$/, "").trim().replace(/\s+/g, " ");
|
||||
const name = (p[2] ?? "") + p[3];
|
||||
return [type ? `@param ${type} ${name}` : `@param ${name}`];
|
||||
});
|
||||
const ret = /^\s*:\s*([?\w\\|&() ]+?)\s*(?:\{|;|$)/.exec(text.slice(i));
|
||||
const type = ret?.[1]?.trim();
|
||||
if (type && type !== "void" && m[1] !== "__construct") tags.push(`@return ${type}`);
|
||||
return tags;
|
||||
}
|
||||
|
||||
function splitTopLevel(params: string): string[] {
|
||||
const out: string[] = [];
|
||||
let depth = 0;
|
||||
let start = 0;
|
||||
for (let i = 0; i < params.length; i++) {
|
||||
const c = params[i];
|
||||
if (c === "(" || c === "[") depth++;
|
||||
else if (c === ")" || c === "]") depth--;
|
||||
else if (c === "," && depth === 0) {
|
||||
out.push(params.slice(start, i));
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
out.push(params.slice(start));
|
||||
return out.filter((p) => p.trim());
|
||||
}
|
||||
|
||||
/** The Enter command for documents of `language` (stub tags in PHP). */
|
||||
export function enterInDocComment(language: string | undefined): Command {
|
||||
return (view) => {
|
||||
const { state } = view;
|
||||
if (state.readOnly || state.selection.ranges.length !== 1) return false;
|
||||
const r = state.selection.main;
|
||||
if (!r.empty) return false;
|
||||
const block = state.languageDataAt<{ block?: { open: string; close: string } }>("commentTokens", r.head)[0]?.block;
|
||||
if (block?.open !== "/*" || block.close !== "*/") return false;
|
||||
const line = state.doc.lineAt(r.head);
|
||||
const before = line.text.slice(0, r.head - line.from);
|
||||
if (before.trim() !== "/**" || line.text.slice(r.head - line.from).trim()) return false;
|
||||
// A `/**` that already starts a doc block (the next line continues it) only breaks the line.
|
||||
const next = line.number < state.doc.lines ? state.doc.line(line.number + 1).text.trimStart() : "";
|
||||
if (next.startsWith("*")) return false;
|
||||
const indent = /^[ \t]*/.exec(before)![0];
|
||||
const br = state.lineBreak;
|
||||
const below = line.number < state.doc.lines ? state.sliceDoc(state.doc.line(line.number + 1).from) : "";
|
||||
const tags = language === "php" ? phpDocTags(below.slice(0, 4000)) : [];
|
||||
const summary = `${br}${indent} * `;
|
||||
const insert = summary + tags.map((t) => `${br}${indent} * ${t}`).join("") + `${br}${indent} */`;
|
||||
view.dispatch(
|
||||
state.update({
|
||||
changes: { from: r.head, insert },
|
||||
selection: EditorSelection.cursor(r.head + summary.length),
|
||||
scrollIntoView: true,
|
||||
userEvent: "input",
|
||||
}),
|
||||
);
|
||||
return true;
|
||||
};
|
||||
}
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
selectHumpRight,
|
||||
} from "./editing";
|
||||
import { enterInLineComment, lineCommentStyle, type LineCommentStyle } from "./comments";
|
||||
import { enterInDocComment } from "./docComment";
|
||||
import { skipClosingQuote, smartBackspace, smartBackspaceApplies, tabOut } from "./smartKeys";
|
||||
|
||||
// --- soft wraps ---------------------------------------------------------------
|
||||
@@ -571,6 +572,7 @@ export function prefsExtension(o: PrefsOptions): Extension {
|
||||
{ key: "Home", run: cursorLineStart, shift: selectLineStart, preventDefault: true },
|
||||
{ mac: "Mod-ArrowLeft", run: cursorLineStart, shift: selectLineStart, preventDefault: true },
|
||||
);
|
||||
if (!o.readOnly && k.insertDocStub) keys.push({ key: "Enter", run: enterInDocComment(o.language) });
|
||||
if (!o.readOnly) keys.push({ key: "Enter", run: enterInLineComment });
|
||||
if (!k.smartIndent) keys.push({ key: "Enter", run: insertNewlineKeepIndent });
|
||||
if (k.backspaceUnindent === "none") keys.push({ key: "Backspace", run: deleteCharBackwardStrict });
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createMemoryBackend } from "./backend";
|
||||
import { initialTabsState } from "./model/tabs";
|
||||
import { useDocMeta } from "./model/document";
|
||||
import { resetDistractionFree, setDistractionFree } from "@/workbench/distractionFree";
|
||||
import { registerTextEditorWrapper } from "@/workbench/contributions";
|
||||
import { registerActions } from "@/platform/actions";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { resolveMenuItems } from "@/ui";
|
||||
@@ -262,6 +263,24 @@ describe("EditorController", () => {
|
||||
useProject.setState({ lightEdit: false });
|
||||
});
|
||||
|
||||
it("opens a file requested while the LightEdit session is being restored", async () => {
|
||||
ctrl = new EditorController(createMemoryBackend({ "/x/a.txt": "a", "/y/b.md": "b", "/z/launch.lua": "l" }));
|
||||
ctrl.start();
|
||||
useProject.setState({ lightEdit: true });
|
||||
await ctrl.openFile("/x/a.txt", { focus: false });
|
||||
await ctrl.openFile("/y/b.md", { focus: false });
|
||||
useProject.setState({ lightEdit: false });
|
||||
// A launch with a file path: LightEdit starts and the file opens before the old tabs are back.
|
||||
useProject.setState({ lightEdit: true });
|
||||
await ctrl.openFile("/z/launch.lua", { focus: false });
|
||||
expect(openFilePaths(useTabs.getState())).toEqual(["/x/a.txt", "/y/b.md", "/z/launch.lua"]);
|
||||
const s = useTabs.getState();
|
||||
const active = s.panes[s.activePane]!;
|
||||
expect(active.tabs.find((t) => t.id === active.active)?.input.path).toBe("/z/launch.lua");
|
||||
await ctrl.closeEverything();
|
||||
useProject.setState({ lightEdit: false });
|
||||
});
|
||||
|
||||
it("closes a tab by its input key", async () => {
|
||||
ctrl = new EditorController(createMemoryBackend());
|
||||
ctrl.start();
|
||||
@@ -301,4 +320,22 @@ describe("EditorController", () => {
|
||||
dispose();
|
||||
useProject.setState({ project: null, settings: null });
|
||||
});
|
||||
|
||||
it("switches an open image tab to a text tab when a matching text editor wrapper registers", async () => {
|
||||
ctrl = new EditorController(createMemoryBackend({ "/p/arch.diagram.svg": "<svg/>" }));
|
||||
ctrl.start();
|
||||
await ctrl.openInput({ kind: "image", key: "image:/p/arch.diagram.svg", path: "/p/arch.diagram.svg" });
|
||||
expect(openFilePaths(useTabs.getState())).toEqual([]);
|
||||
const offWrapper = registerTextEditorWrapper({
|
||||
id: "plugin.acme.diagram.canvas",
|
||||
matches: (p) => p.endsWith(".diagram.svg"),
|
||||
component: () => null,
|
||||
});
|
||||
try {
|
||||
expect(openFilePaths(useTabs.getState())).toEqual(["/p/arch.diagram.svg"]);
|
||||
await vi.waitFor(() => expect(ctrl?.activeSlot?.model.path).toBe("/p/arch.diagram.svg"));
|
||||
} finally {
|
||||
offWrapper();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -23,13 +23,16 @@ import {
|
||||
menuGroupItems,
|
||||
notifications,
|
||||
resolveFileEditor,
|
||||
textEditorWrapperFor,
|
||||
useContributions,
|
||||
useStandaloneEditor,
|
||||
useTextEditorWrappers,
|
||||
type EditorCloseSession,
|
||||
type EditorInput,
|
||||
type OpenOptions,
|
||||
type FileEditorResolution,
|
||||
type OpenEditorTab,
|
||||
type TextEditorWrapperContribution,
|
||||
} from "@/workbench/contributions";
|
||||
import { useDistractionFree } from "@/workbench/distractionFree";
|
||||
import { ask } from "@/workbench/messages";
|
||||
@@ -237,6 +240,8 @@ export class EditorController implements SlotHost {
|
||||
private selectionRequests = new Map<string, number>();
|
||||
private activationGeneration = 0;
|
||||
private admitting = false;
|
||||
/** Tabs restored from a saved session: not a user's activation, so a pending open still activates its tab. */
|
||||
private restoring = false;
|
||||
private closeRequests = 0;
|
||||
private projectTransition: Promise<void> = Promise.resolve();
|
||||
|
||||
@@ -269,6 +274,7 @@ export class EditorController implements SlotHost {
|
||||
prevTabs = s;
|
||||
if (
|
||||
!this.admitting &&
|
||||
!this.restoring &&
|
||||
(s.activePane !== prev.activePane || s.panes[s.activePane]?.active !== prev.panes[prev.activePane]?.active)
|
||||
)
|
||||
++this.activationGeneration;
|
||||
@@ -341,6 +347,11 @@ export class EditorController implements SlotHost {
|
||||
);
|
||||
// Gutter, centred column and sticky lines follow Distraction Free Mode.
|
||||
this.disposers.push(useDistractionFree.subscribe(() => this.manager.applySettings()));
|
||||
this.disposers.push(
|
||||
useTextEditorWrappers.subscribe(({ wrappers }) => {
|
||||
this.upgradeWrappedImageTabs(wrappers);
|
||||
}),
|
||||
);
|
||||
if (prevRoot) this.restoreSession(prevRoot);
|
||||
if (this.manager.backend.onFsChanged)
|
||||
this.disposers.push(this.manager.backend.onFsChanged((e) => this.onFilesRemoved(e.removed)));
|
||||
@@ -350,6 +361,8 @@ export class EditorController implements SlotHost {
|
||||
void this.saveCustomDocuments(true).catch((e) => this.documentError(e));
|
||||
}
|
||||
};
|
||||
if (this.manager.backend.onApplicationDeactivated)
|
||||
this.disposers.push(this.manager.backend.onApplicationDeactivated(onWindowBlur));
|
||||
const onPageHide = () => this.saveSession();
|
||||
if (typeof window !== "undefined") {
|
||||
if (this.manager.backend.syncFromDisk)
|
||||
@@ -449,6 +462,35 @@ export class EditorController implements SlotHost {
|
||||
|
||||
// --- session (open tabs, splits, carets per project) ---------------------------
|
||||
|
||||
private upgradeWrappedImageTabs(wrappers: ReadonlyMap<string, TextEditorWrapperContribution>) {
|
||||
const s = useTabs.getState();
|
||||
let changed = false;
|
||||
const panes: TabsState["panes"] = {};
|
||||
const toEnsure: string[] = [];
|
||||
for (const [paneId, pane] of Object.entries(s.panes)) {
|
||||
let paneChanged = false;
|
||||
const tabs = pane.tabs.map((tab) => {
|
||||
const path = tab.input.path;
|
||||
if (
|
||||
tab.input.kind === "image" &&
|
||||
path &&
|
||||
textEditorWrapperFor(wrappers, path) &&
|
||||
!pane.tabs.some((other) => other.id !== tab.id && other.input.key === fileKey(path))
|
||||
) {
|
||||
paneChanged = true;
|
||||
changed = true;
|
||||
if (pane.active === tab.id) toEnsure.push(tab.id);
|
||||
return { ...tab, input: fileInput(path) };
|
||||
}
|
||||
return tab;
|
||||
});
|
||||
panes[paneId] = paneChanged ? { ...pane, tabs } : pane;
|
||||
}
|
||||
if (!changed) return;
|
||||
useTabs.setState({ ...s, panes }, true);
|
||||
for (const tabId of toEnsure) void this.ensureSlot(tabId);
|
||||
}
|
||||
|
||||
private scheduleSessionSave() {
|
||||
if (!this.sessionRoot) return;
|
||||
clearTimeout(this.sessionTimer);
|
||||
@@ -486,7 +528,12 @@ export class EditorController implements SlotHost {
|
||||
pane.tabs = pane.tabs.map((tab) => (documentLifecycle(tab.input, kinds) ? { ...tab, preview: false } : tab));
|
||||
this.restoredViews = new Map(Object.entries(session.files));
|
||||
this.restoredTabs = new Set(Object.values(state.panes).flatMap((p) => p.tabs.map((t) => t.id)));
|
||||
useTabs.setState(state, true);
|
||||
this.restoring = true;
|
||||
try {
|
||||
useTabs.setState(state, true);
|
||||
} finally {
|
||||
this.restoring = false;
|
||||
}
|
||||
for (const pane of Object.values(state.panes)) if (pane.active) void this.ensureSlot(pane.active);
|
||||
}
|
||||
|
||||
|
||||
@@ -481,6 +481,24 @@ describe("custom editor document lifetime", () => {
|
||||
expect(findTab(useTabs.getState(), input().key)).toBeNull();
|
||||
});
|
||||
|
||||
it("autosaves when the application is deactivated while focus is in a frame", async () => {
|
||||
ctrl.stop();
|
||||
let deactivated = () => {};
|
||||
ctrl = new EditorController({
|
||||
...createMemoryBackend(),
|
||||
onApplicationDeactivated: (handler) => {
|
||||
deactivated = handler;
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
ctrl.start();
|
||||
const doc = await open();
|
||||
useProject.setState({ settings: { files: { autoSave: true } } as never });
|
||||
// Focus inside a plugin's frame: the window's own blur has already fired, before the edit.
|
||||
deactivated();
|
||||
await vi.waitFor(() => expect(doc.saved).toBe("edited"));
|
||||
});
|
||||
|
||||
it("does not prompt or blur-autosave a document until execution has drained", async () => {
|
||||
const doc = await open();
|
||||
useProject.setState({ settings: { files: { autoSave: true } } as never });
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, render, screen, within } from "@testing-library/react";
|
||||
import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { useTabs } from "../controller";
|
||||
import type { Tab } from "../model/tabs";
|
||||
import { EditorAreaView } from "./EditorArea";
|
||||
|
||||
const { closeTab } = vi.hoisted(() => ({ closeTab: vi.fn() }));
|
||||
vi.mock("../controller", async (original) => ({
|
||||
...(await original<object>()),
|
||||
editorController: () => ({ publishContext: vi.fn(), tabMenu: () => [] }),
|
||||
editorController: () => ({ publishContext: vi.fn(), tabMenu: () => [], closeTab }),
|
||||
}));
|
||||
vi.mock("./TextEditorHost", () => ({ TextEditorHost: () => <input aria-label="Source" /> }));
|
||||
vi.mock("./FindBar", () => ({ FindBar: () => null }));
|
||||
@@ -34,7 +35,10 @@ function seed(pinned: boolean[], tabs: Record<string, unknown>) {
|
||||
useProject.setState({ settings: { editor: { tabs } } as never });
|
||||
}
|
||||
|
||||
beforeEach(() => cleanup());
|
||||
beforeEach(() => {
|
||||
cleanup();
|
||||
closeTab.mockClear();
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
useTabs.setState(originalTabs, true);
|
||||
@@ -69,4 +73,11 @@ describe("pinned tabs in a separate row", () => {
|
||||
render(<EditorAreaView />);
|
||||
expect(screen.getAllByRole("tablist", { name: "Editor tabs" })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("middle click closes an editor tab", () => {
|
||||
seed([false, false, false], { placement: "top" });
|
||||
render(<EditorAreaView />);
|
||||
fireEvent.mouseDown(screen.getByRole("tab", { name: /f2/ }), { button: 1 });
|
||||
expect(closeTab).toHaveBeenCalledWith("p1", "t2");
|
||||
});
|
||||
});
|
||||
@@ -400,8 +400,15 @@
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Dialog bodies get no padding of their own; wrap the groups like Analysis dialogs' .form. */
|
||||
.reformatBody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 14px;
|
||||
}
|
||||
.reformatGroup {
|
||||
margin: 0 0 10px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
display: flex;
|
||||
|
||||
@@ -17,6 +17,7 @@ vi.mock("@/workbench/contributions", async (orig) => {
|
||||
const { LspPopups, DocumentationWindow } = await import("./Popups");
|
||||
const { closePopup, openPopup, useLspPopup } = await import("./popupStore");
|
||||
const { useDocWindow, pushHistory, stepHistory, hoverCandidates } = await import("./docs");
|
||||
import popupStyles from "./Popups.module.css";
|
||||
|
||||
const loc = (path: string, line: number): Location => ({
|
||||
path,
|
||||
@@ -223,3 +224,23 @@ describe("quick definition popup", () => {
|
||||
expect(openFile).toHaveBeenCalledWith("/p/b.ts", { selection: { line: 5, col: 4 }, focus: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("reformat dialog", () => {
|
||||
it("wraps its content in the padded body", () => {
|
||||
const onRun = vi.fn();
|
||||
openPopup({
|
||||
kind: "reformat",
|
||||
title: "Reformat File",
|
||||
hasSelection: false,
|
||||
canOptimizeImports: true,
|
||||
initial: { selectionOnly: false, optimizeImports: false },
|
||||
onRun,
|
||||
});
|
||||
render(<LspPopups />);
|
||||
const body = screen.getByRole("dialog", { name: "Reformat File" }).querySelector("[data-dialog-body]")!;
|
||||
expect(body.firstElementChild).toHaveClass(popupStyles.reformatBody!);
|
||||
expect(body.firstElementChild!.querySelectorAll("fieldset")).toHaveLength(2);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Run" }));
|
||||
expect(onRun).toHaveBeenCalledWith({ selectionOnly: false, optimizeImports: false });
|
||||
});
|
||||
});
|
||||
+36
-29
@@ -621,36 +621,43 @@ function ReformatDialog({ popup }: { popup: Extract<LspPopup, { kind: "reformat"
|
||||
{ label: "Cancel", role: "cancel" },
|
||||
]}
|
||||
>
|
||||
<fieldset className={styles.reformatGroup}>
|
||||
<legend className={styles.reformatLegend}>
|
||||
<T>Scope</T>
|
||||
</legend>
|
||||
<label className={styles.reformatOption}>
|
||||
<input type="radio" name="reformat-scope" checked={!selectionOnly} onChange={() => setSelectionOnly(false)} />
|
||||
<T>Whole file</T>
|
||||
</label>
|
||||
<label className={clsx(styles.reformatOption, !popup.hasSelection && styles.disabled)}>
|
||||
<input
|
||||
type="radio"
|
||||
name="reformat-scope"
|
||||
checked={selectionOnly}
|
||||
disabled={!popup.hasSelection}
|
||||
onChange={() => setSelectionOnly(true)}
|
||||
<div className={styles.reformatBody}>
|
||||
<fieldset className={styles.reformatGroup}>
|
||||
<legend className={styles.reformatLegend}>
|
||||
<T>Scope</T>
|
||||
</legend>
|
||||
<label className={styles.reformatOption}>
|
||||
<input
|
||||
type="radio"
|
||||
name="reformat-scope"
|
||||
checked={!selectionOnly}
|
||||
onChange={() => setSelectionOnly(false)}
|
||||
/>
|
||||
<T>Whole file</T>
|
||||
</label>
|
||||
<label className={clsx(styles.reformatOption, !popup.hasSelection && styles.disabled)}>
|
||||
<input
|
||||
type="radio"
|
||||
name="reformat-scope"
|
||||
checked={selectionOnly}
|
||||
disabled={!popup.hasSelection}
|
||||
onChange={() => setSelectionOnly(true)}
|
||||
/>
|
||||
<T>Selected text</T>
|
||||
</label>
|
||||
</fieldset>
|
||||
<fieldset className={styles.reformatGroup}>
|
||||
<legend className={styles.reformatLegend}>
|
||||
<T>Options</T>
|
||||
</legend>
|
||||
<Checkbox
|
||||
checked={optimizeImports}
|
||||
disabled={!popup.canOptimizeImports}
|
||||
onChange={setOptimizeImports}
|
||||
label="Optimize imports"
|
||||
/>
|
||||
<T>Selected text</T>
|
||||
</label>
|
||||
</fieldset>
|
||||
<fieldset className={styles.reformatGroup}>
|
||||
<legend className={styles.reformatLegend}>
|
||||
<T>Options</T>
|
||||
</legend>
|
||||
<Checkbox
|
||||
checked={optimizeImports}
|
||||
disabled={!popup.canOptimizeImports}
|
||||
onChange={setOptimizeImports}
|
||||
label="Optimize imports"
|
||||
/>
|
||||
</fieldset>
|
||||
</fieldset>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,13 +15,17 @@ import {
|
||||
type Tooltip,
|
||||
type ViewUpdate,
|
||||
} from "@codemirror/view";
|
||||
import { Channel } from "@tauri-apps/api/core";
|
||||
import {
|
||||
commands,
|
||||
isCancelled,
|
||||
nextRequestId,
|
||||
type DocId,
|
||||
type GotoKind,
|
||||
type Highlight,
|
||||
type Location,
|
||||
type Pos,
|
||||
type RequestId,
|
||||
type WebReferenceKind,
|
||||
} from "@/platform/ipc";
|
||||
import {
|
||||
@@ -48,7 +52,7 @@ import {
|
||||
} from "./syntaxFallback";
|
||||
import { showHint, showPendingHint } from "./hint";
|
||||
import { outsideProjectReason } from "./outsideProject";
|
||||
import { caretAnchor, openPopup, type ListPopupItem } from "./popupStore";
|
||||
import { caretAnchor, openPopup, updatePopup, type ListPopupItem } from "./popupStore";
|
||||
import { readManyFileLines, targetLabel } from "./files";
|
||||
import {
|
||||
documentLinks,
|
||||
@@ -134,9 +138,89 @@ export async function locationItems(locs: readonly DeclarationTarget[]): Promise
|
||||
|
||||
/** "Choose Implementation of foo (3 found)". */
|
||||
export function chooserTitle(kind: GotoKind, name: string | null, count: number, fromSyntax = false): string {
|
||||
if (kind === "implementation" && !fromSyntax)
|
||||
return name ? tx`Choose Implementation of ${name} (${count} found)` : tx`Choose Implementation (${count} found)`;
|
||||
return `${TITLES[kind]}${name ? ` of ${name}` : ""} (${count} found${fromSyntax ? ", based on syntax" : ""})`;
|
||||
}
|
||||
|
||||
/** The chooser's title while the server is still searching for more. */
|
||||
function searchingTitle(name: string | null, count: number): string {
|
||||
return name
|
||||
? tx`Choose Implementation of ${name} (${count} found, searching…)`
|
||||
: tx`Choose Implementation (${count} found, searching…)`;
|
||||
}
|
||||
|
||||
function nameAt(view: EditorView, offset: number): string | null {
|
||||
const word = view.state.wordAt(offset);
|
||||
return word ? view.state.sliceDoc(word.from, word.to) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Go to Implementation whose chooser opens with the implementations the server finds first
|
||||
* (Java: the project's) while it searches on (the dependencies'), and fills in when it ends.
|
||||
* Closing the chooser cancels the search.
|
||||
*/
|
||||
function streamedImplementations(view: EditorView, offset: number, onOpen: () => void) {
|
||||
const name = nameAt(view, offset);
|
||||
let current: RequestId | null = null;
|
||||
let finished = false;
|
||||
let opened = false;
|
||||
let closed = false;
|
||||
let first: DeclarationTarget[] = [];
|
||||
let showing: Promise<void> = Promise.resolve();
|
||||
return {
|
||||
request(doc: DocId, pos: Pos): Promise<DeclarationTarget[]> {
|
||||
const request = (current = nextRequestId());
|
||||
finished = false;
|
||||
const channel = new Channel<Location[]>();
|
||||
channel.onmessage = (locs) => {
|
||||
if (finished || opened || current !== request || !locs.length) return;
|
||||
opened = true;
|
||||
onOpen();
|
||||
first = dedupeLocations(locs);
|
||||
showing = locationItems(first).then((items) => {
|
||||
// All of them arrived meanwhile: shown as usual.
|
||||
if (finished) {
|
||||
opened = false;
|
||||
return;
|
||||
}
|
||||
openPopup({
|
||||
kind: "list",
|
||||
anchor: caretAnchor(view, offset),
|
||||
title: searchingTitle(name, first.length),
|
||||
items,
|
||||
pin: pinToFind(name ?? "", first),
|
||||
onClose: () => {
|
||||
closed = true;
|
||||
if (!finished) void commands.cancelRequest(request).catch(() => {});
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
return lspRequest(() => commands.lspGotoImplementation(request, doc, pos, channel)).finally(() => {
|
||||
finished = true;
|
||||
});
|
||||
},
|
||||
/** Whether the chooser opened with the first ones (and so the search's result goes there). */
|
||||
async opened(): Promise<boolean> {
|
||||
await showing;
|
||||
return opened;
|
||||
},
|
||||
/** Fills the opened chooser with all of them, after those it shows. */
|
||||
async complete(locs: readonly DeclarationTarget[]) {
|
||||
if (closed) return;
|
||||
const all = dedupeLocations([...first, ...locs]);
|
||||
const items = await locationItems(all);
|
||||
if (closed) return;
|
||||
updatePopup("list", {
|
||||
title: chooserTitle("implementation", name, all.length),
|
||||
items,
|
||||
pin: pinToFind(name ?? "", all),
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Pin button of the targets chooser: the targets as a Find tool window tab (code-insight). */
|
||||
export function pinToFind(symbol: string, locations: readonly Location[]) {
|
||||
return {
|
||||
@@ -175,14 +259,25 @@ export async function gotoTargets(view: EditorView, kind: GotoKind, at?: number)
|
||||
let fromSyntax = false;
|
||||
const declaration = kind === "definition" || kind === "declaration";
|
||||
let serverDown = false;
|
||||
let streamed: ReturnType<typeof streamedImplementations> | null = null;
|
||||
try {
|
||||
const done = showPendingHint(view, tx`Finding navigation target…`);
|
||||
const stream = kind === "implementation" ? streamedImplementations(view, offset, done) : null;
|
||||
streamed = stream;
|
||||
const request = stream
|
||||
? () => stream.request(doc.doc, pos)
|
||||
: () => lspRequest(() => commands.lspGoto(doc.doc, pos, kind));
|
||||
try {
|
||||
locs = dedupeLocations(await gotoAfterEdits(view, () => lspRequest(() => commands.lspGoto(doc.doc, pos, kind))));
|
||||
locs = dedupeLocations(await gotoAfterEdits(view, request));
|
||||
} finally {
|
||||
done();
|
||||
}
|
||||
} catch (e) {
|
||||
// The chooser shows the first ones: closing it cancelled the search, or the rest failed.
|
||||
if (streamed && (await streamed.opened())) {
|
||||
await streamed.complete([]);
|
||||
return;
|
||||
}
|
||||
if (isCancelled(e) || !declaration) {
|
||||
reportLspError(view, e, true);
|
||||
return;
|
||||
@@ -199,6 +294,10 @@ export async function gotoTargets(view: EditorView, kind: GotoKind, at?: number)
|
||||
locs = fb;
|
||||
fromSyntax = true;
|
||||
}
|
||||
if (streamed && (await streamed.opened())) {
|
||||
await streamed.complete(locs);
|
||||
return;
|
||||
}
|
||||
if (!locs.length) {
|
||||
// The server has no definition here: a link (`href`, `#include`, Markdown) or a file path.
|
||||
if (
|
||||
@@ -234,8 +333,7 @@ export async function gotoTargets(view: EditorView, kind: GotoKind, at?: number)
|
||||
}, 150);
|
||||
return;
|
||||
}
|
||||
const word = view.state.wordAt(offset);
|
||||
const name = word ? view.state.sliceDoc(word.from, word.to) : null;
|
||||
const name = nameAt(view, offset);
|
||||
openPopup({
|
||||
kind: "list",
|
||||
anchor: caretAnchor(view, offset),
|
||||
|
||||
@@ -10,8 +10,17 @@ import { registerAction } from "@/platform/actions";
|
||||
const lspGoto = vi.hoisted(() => vi.fn<typeof commands.lspGoto>());
|
||||
vi.mock("@/platform/ipc", async (orig) => {
|
||||
const real = await orig<typeof import("@/platform/ipc")>();
|
||||
return { ...real, isTauri: () => true, commands: { ...real.commands, lspGoto } };
|
||||
// Go to Implementation streams (`lspGotoImplementation`); here it answers all at once.
|
||||
const lspGotoImplementation: typeof commands.lspGotoImplementation = (_request, doc, pos) =>
|
||||
lspGoto(doc, pos, "implementation");
|
||||
return { ...real, isTauri: () => true, commands: { ...real.commands, lspGoto, lspGotoImplementation } };
|
||||
});
|
||||
vi.mock("@tauri-apps/api/core", async (orig) => ({
|
||||
...(await orig<object>()),
|
||||
Channel: class {
|
||||
onmessage = () => {};
|
||||
},
|
||||
}));
|
||||
|
||||
const { gotoTargets, navigationExtension } = await import("./navigation");
|
||||
const { lspDoc } = await import("./lsp");
|
||||
|
||||
@@ -1,16 +1,35 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import type { commands } from "@/platform/ipc";
|
||||
import type { commands, Location } from "@/platform/ipc";
|
||||
|
||||
const lspGoto = vi.hoisted(() => vi.fn<typeof commands.lspGoto>());
|
||||
const { lspGoto, lspGotoImplementation, cancelRequest } = vi.hoisted(() => ({
|
||||
lspGoto: vi.fn<typeof commands.lspGoto>(),
|
||||
lspGotoImplementation: vi.fn<typeof commands.lspGotoImplementation>(),
|
||||
cancelRequest: vi.fn<typeof commands.cancelRequest>(async () => {}),
|
||||
}));
|
||||
vi.mock("@/platform/ipc", async (orig) => {
|
||||
const real = await orig<typeof import("@/platform/ipc")>();
|
||||
return { ...real, isTauri: () => true, commands: { ...real.commands, lspGoto } };
|
||||
const fsReadText: typeof commands.fsReadText = async () => ({
|
||||
status: "error",
|
||||
error: { kind: "notFound", message: "not found" },
|
||||
});
|
||||
return {
|
||||
...real,
|
||||
isTauri: () => true,
|
||||
commands: { ...real.commands, lspGoto, lspGotoImplementation, cancelRequest, fsReadText },
|
||||
};
|
||||
});
|
||||
vi.mock("@tauri-apps/api/core", async (orig) => ({
|
||||
...(await orig<object>()),
|
||||
Channel: class {
|
||||
onmessage = () => {};
|
||||
},
|
||||
}));
|
||||
|
||||
const { gotoTargets } = await import("./navigation");
|
||||
const { lspDoc } = await import("./lsp");
|
||||
const { useLspPopup, closePopup } = await import("./popupStore");
|
||||
const { provideEditorService } = await import("@/workbench/contributions");
|
||||
const openFile = vi.fn(async () => {});
|
||||
provideEditorService({ openFile } as never);
|
||||
@@ -50,13 +69,87 @@ describe("slow navigation feedback", () => {
|
||||
});
|
||||
|
||||
it("replaces progress with the server error", async () => {
|
||||
let resolve!: (result: Awaited<ReturnType<typeof commands.lspGoto>>) => void;
|
||||
lspGoto.mockImplementationOnce(() => new Promise((r) => (resolve = r)));
|
||||
const search = slowImplementations();
|
||||
const pending = gotoTargets(view, "implementation");
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
resolve({ status: "error", error: { kind: "protocol", message: "Server timed out" } });
|
||||
search.resolve({ status: "error", error: { kind: "protocol", message: "Server timed out" } });
|
||||
await pending;
|
||||
expect(document.querySelector(".cm-lsp-hint")?.textContent).toBe("Server timed out");
|
||||
expect(openFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
const at = (path: string, line: number): Location => ({
|
||||
path,
|
||||
range: { start: { line, col: 0 }, end: { line, col: 1 } },
|
||||
uri: null,
|
||||
});
|
||||
|
||||
/** A Go to Implementation request answered when the test says: the first ones, then all. */
|
||||
function slowImplementations() {
|
||||
let channel!: { onmessage: (locs: Location[]) => void };
|
||||
let resolve!: (result: Awaited<ReturnType<typeof commands.lspGotoImplementation>>) => void;
|
||||
lspGotoImplementation.mockImplementationOnce((_request, _doc, _pos, onPartial) => {
|
||||
channel = onPartial as unknown as typeof channel;
|
||||
return new Promise((r) => (resolve = r));
|
||||
});
|
||||
return {
|
||||
first: (locs: Location[]) => channel.onmessage(locs),
|
||||
resolve: (r: Parameters<typeof resolve>[0]) => resolve(r),
|
||||
};
|
||||
}
|
||||
|
||||
const listPopup = () => {
|
||||
const popup = useLspPopup.getState().popup;
|
||||
return popup?.kind === "list" ? popup : null;
|
||||
};
|
||||
|
||||
describe("Go to Implementation while the search goes on", () => {
|
||||
afterEach(() => {
|
||||
closePopup();
|
||||
cancelRequest.mockClear();
|
||||
lspGotoImplementation.mockReset();
|
||||
});
|
||||
|
||||
it("opens the chooser with the implementations found first, then adds the rest", async () => {
|
||||
const search = slowImplementations();
|
||||
const pending = gotoTargets(view, "implementation");
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
search.first([at("/p/Own.java", 3)]);
|
||||
await vi.waitFor(() => expect(listPopup()?.title).toBe("Choose Implementation of run (1 found, searching…)"));
|
||||
expect(listPopup()!.items.map((i) => i.label)).toEqual(["Own.java"]);
|
||||
expect(document.querySelector(".cm-lsp-hint")).toBeNull();
|
||||
|
||||
search.resolve({ status: "ok", data: [at("/p/Library.java", 8), at("/p/Own.java", 3)] });
|
||||
await pending;
|
||||
expect(listPopup()?.title).toBe("Choose Implementation of run (2 found)");
|
||||
expect(listPopup()!.items.map((i) => i.label)).toEqual(["Own.java", "Library.java"]);
|
||||
expect(openFile).not.toHaveBeenCalled();
|
||||
expect(cancelRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cancels the search when the chooser closes", async () => {
|
||||
const search = slowImplementations();
|
||||
const pending = gotoTargets(view, "implementation");
|
||||
await vi.waitFor(() => expect(lspGotoImplementation).toHaveBeenCalled());
|
||||
search.first([at("/p/Own.java", 3)]);
|
||||
await vi.waitFor(() => expect(listPopup()).not.toBeNull());
|
||||
closePopup();
|
||||
expect(cancelRequest).toHaveBeenCalledExactlyOnceWith(lspGotoImplementation.mock.lastCall![0]);
|
||||
|
||||
search.resolve({ status: "error", error: { kind: "cancelled", message: "cancelled" } });
|
||||
await pending;
|
||||
expect(listPopup()).toBeNull();
|
||||
expect(document.querySelector(".cm-lsp-hint")).toBeNull();
|
||||
});
|
||||
|
||||
it("goes to the only implementation when none came first", async () => {
|
||||
const search = slowImplementations();
|
||||
const pending = gotoTargets(view, "implementation");
|
||||
await vi.waitFor(() => expect(lspGotoImplementation).toHaveBeenCalled());
|
||||
search.resolve({ status: "ok", data: [at("/p/Library.java", 8)] });
|
||||
await pending;
|
||||
expect(listPopup()).toBeNull();
|
||||
expect(openFile).toHaveBeenCalledWith("/p/Library.java", { selection: { line: 8, col: 0 }, focus: true });
|
||||
});
|
||||
});
|
||||
@@ -471,9 +471,20 @@
|
||||
min-width: 0;
|
||||
color: var(--fg-secondary);
|
||||
}
|
||||
/* Title, state and answers on one line when they fit; a long title takes its own line and
|
||||
the state and answers go below it, in line with it. */
|
||||
.permissionBody {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
gap: 2px 6px;
|
||||
}
|
||||
/* Up to three lines; the full text is its tooltip. */
|
||||
.permissionTitle {
|
||||
display: -webkit-box;
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
-webkit-box-orient: vertical;
|
||||
@@ -482,7 +493,7 @@
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.permissionLine > svg,
|
||||
.permissionLine > .muted {
|
||||
.permissionBody > .muted {
|
||||
flex: none;
|
||||
}
|
||||
.permissionAnswers {
|
||||
|
||||
@@ -1213,11 +1213,13 @@ function PermissionLine({ task, entry }: { task: AgentTask; entry: PermissionEnt
|
||||
return (
|
||||
<div className={styles.permissionLine}>
|
||||
<Icon name={!entry.chosen ? "warning" : entry.chosen === "Cancelled" ? "minus" : "check"} size={13} />
|
||||
<span className={styles.permissionTitle} title={shortTitle(task, entry.title)}>
|
||||
{shortTitle(task, entry.title)}
|
||||
</span>
|
||||
<span className={styles.muted}>{entry.chosen ?? <T>Waiting for your answer</T>}</span>
|
||||
{!entry.chosen && <PermissionAnswers task={task} entry={entry} className={styles.permissionAnswers} />}
|
||||
<div className={styles.permissionBody}>
|
||||
<span className={styles.permissionTitle} title={shortTitle(task, entry.title)}>
|
||||
{shortTitle(task, entry.title)}
|
||||
</span>
|
||||
<span className={styles.muted}>{entry.chosen ?? <T>Waiting for your answer</T>}</span>
|
||||
{!entry.chosen && <PermissionAnswers task={task} entry={entry} className={styles.permissionAnswers} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { filterColumns } from "./ColumnsPopup";
|
||||
|
||||
const columns = ["id", "user_id", "name", "created_at", "username"].map((name) => ({ name, type: "TEXT" }));
|
||||
|
||||
describe("filterColumns", () => {
|
||||
it("lists every column for an empty query", () => {
|
||||
expect(filterColumns(columns, " ")).toEqual([0, 1, 2, 3, 4]);
|
||||
});
|
||||
|
||||
it("puts prefix matches first, then other matches, ignoring case", () => {
|
||||
expect(filterColumns(columns, "US")).toEqual([1, 4]);
|
||||
expect(filterColumns(columns, "name")).toEqual([2, 4]);
|
||||
expect(filterColumns(columns, "_id")).toEqual([1]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Go to Column in a table editor (⌘F12 / Ctrl+F12): the grid's columns, filtered as you type;
|
||||
* Enter selects that column's cell in the current row.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { Icon, Input, List, Popup, listNavigate } from "@/ui";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import type { GridColumn } from "./Grid";
|
||||
import styles from "./Database.module.css";
|
||||
|
||||
export function filterColumns(columns: readonly GridColumn[], query: string): number[] {
|
||||
const q = query.trim().toLowerCase();
|
||||
const all = columns.map((_, i) => i);
|
||||
if (!q) return all;
|
||||
const starts = all.filter((i) => columns[i]!.name.toLowerCase().startsWith(q));
|
||||
const contains = all.filter((i) => !starts.includes(i) && columns[i]!.name.toLowerCase().includes(q));
|
||||
return [...starts, ...contains];
|
||||
}
|
||||
|
||||
export function ColumnsPopup(props: {
|
||||
anchor: { x: number; y: number };
|
||||
table: string;
|
||||
columns: readonly GridColumn[];
|
||||
onPick: (col: number) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [active, setActive] = useState(0);
|
||||
const shown = filterColumns(props.columns, query);
|
||||
const current = Math.min(active, shown.length - 1);
|
||||
const pick = (i: number) => {
|
||||
const col = shown[i];
|
||||
if (col !== undefined) props.onPick(col);
|
||||
};
|
||||
return (
|
||||
<Popup
|
||||
open
|
||||
anchor={props.anchor}
|
||||
placement="bottom"
|
||||
onClose={props.onClose}
|
||||
aria-label={tx`Columns of ${props.table}`}
|
||||
className={styles.columnsPopup}
|
||||
trapFocus
|
||||
>
|
||||
<Input
|
||||
autoFocus
|
||||
aria-label={tx("Search columns")}
|
||||
placeholder={tx`Columns of ${props.table}`}
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setActive(0);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
pick(current);
|
||||
return;
|
||||
}
|
||||
const next = listNavigate(e.key, { items: shown, active: current });
|
||||
if (next !== null) {
|
||||
e.preventDefault();
|
||||
setActive(next);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<List
|
||||
items={shown}
|
||||
getKey={(col) => String(col)}
|
||||
activeIndex={current}
|
||||
onActiveChange={setActive}
|
||||
onActivate={(_, i) => pick(i)}
|
||||
focusable={false}
|
||||
hoverSelects
|
||||
rowHeight={22}
|
||||
aria-label={tx("Columns")}
|
||||
className={styles.columnsList}
|
||||
empty={<span>{tx("No matching columns")}</span>}
|
||||
renderItem={(col) => {
|
||||
const c = props.columns[col]!;
|
||||
return (
|
||||
<span className={styles.columnsRow}>
|
||||
<Icon name={c.primaryKey ? "db-column-key" : c.foreignKey ? "db-column-fk" : "db-column"} size={16} />
|
||||
{c.name}
|
||||
{c.type && c.type !== "NULL" && <span className={styles.treeType}>{c.type}</span>}
|
||||
</span>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
@@ -90,6 +90,7 @@ export interface DataViewProps extends Pick<
|
||||
/** The search bar is shown (the toolbar's search button, ⌘F in the grid). */
|
||||
searchOpen?: boolean;
|
||||
onSearchOpenChange?: (open: boolean) => void;
|
||||
revealColumn?: GridProps["revealColumn"];
|
||||
"aria-label"?: string;
|
||||
}
|
||||
|
||||
@@ -200,6 +201,7 @@ export function DataView(props: DataViewProps) {
|
||||
isMatch={test ? (r, c) => matchSet.has(`${r}:${c}`) : undefined}
|
||||
currentMatch={currentMatch}
|
||||
onFind={onSearchOpenChange && openSearch}
|
||||
revealColumn={props.revealColumn}
|
||||
aria-label={props["aria-label"]}
|
||||
columns={columns}
|
||||
rowCount={shown}
|
||||
|
||||
@@ -823,6 +823,25 @@
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
/* Go to Column (ColumnsPopup.tsx). */
|
||||
.columnsPopup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
width: 320px;
|
||||
padding: 8px;
|
||||
}
|
||||
.columnsList {
|
||||
height: min(320px, 50vh);
|
||||
}
|
||||
.columnsRow {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.filterPopup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -57,6 +57,8 @@ export interface GridProps {
|
||||
currentMatch?: { row: number; col: number } | null;
|
||||
/** ⌘F / Ctrl+F in the grid (opens the search bar). */
|
||||
onFind?: () => void;
|
||||
/** Go to Column: a new object selects that column's cell in the current row, scrolls to it and focuses the grid. */
|
||||
revealColumn?: { col: number } | null;
|
||||
"aria-label"?: string;
|
||||
}
|
||||
|
||||
@@ -160,15 +162,37 @@ export function Grid(props: GridProps) {
|
||||
estimateSize: () => ROW_H,
|
||||
overscan: 12,
|
||||
});
|
||||
/** Scrolls sideways so column `c` is visible (the first one with the row numbers). */
|
||||
const scrollColumnIntoView = (c: number) => {
|
||||
const el = scrollRef.current;
|
||||
if (!el || c < 0 || c >= widths.length) return;
|
||||
const left = c ? NUM_W + widths.slice(0, c).reduce((a, b) => a + b, 0) : 0;
|
||||
const right = NUM_W + widths.slice(0, c + 1).reduce((a, b) => a + b, 0);
|
||||
if (left < el.scrollLeft) el.scrollLeft = left;
|
||||
else if (right > el.scrollLeft + el.clientWidth) el.scrollLeft = Math.min(left, right - el.clientWidth);
|
||||
};
|
||||
const { currentMatch } = props;
|
||||
useEffect(() => {
|
||||
if (!currentMatch) return;
|
||||
const { row, col } = currentMatch;
|
||||
setSel({ r0: row, c0: col, r1: row, c1: col });
|
||||
virtualizer.scrollToIndex(row, { align: "auto" });
|
||||
scrollColumnIntoView(col);
|
||||
// Follows the match only, not the virtualizer's identity.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentMatch?.row, currentMatch?.col]);
|
||||
const { revealColumn } = props;
|
||||
useEffect(() => {
|
||||
if (!revealColumn || !rowCount) return;
|
||||
const { col } = revealColumn;
|
||||
const row = sel ? Math.min(sel.r1, rowCount - 1) : 0;
|
||||
setSel({ r0: row, c0: col, r1: row, c1: col });
|
||||
virtualizer.scrollToIndex(row, { align: "auto" });
|
||||
scrollColumnIntoView(col);
|
||||
scrollRef.current?.focus({ preventScroll: true });
|
||||
// Runs once per request.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [revealColumn]);
|
||||
const totalWidth = useMemo(() => NUM_W + widths.reduce((a, b) => a + b, 0), [widths]);
|
||||
const template = `${NUM_W}px ${widths.map((w) => `${w}px`).join(" ")}`;
|
||||
|
||||
@@ -255,6 +279,7 @@ export function Grid(props: GridProps) {
|
||||
const c = Math.max(0, Math.min(columns.length - 1, sel.c1 + m[1]));
|
||||
setSel(e.shiftKey ? { ...sel, r1: r, c1: c } : { r0: r, c0: c, r1: r, c1: c });
|
||||
virtualizer.scrollToIndex(r, { align: "auto" });
|
||||
scrollColumnIntoView(c);
|
||||
};
|
||||
|
||||
const onCellMouseDown = (e: MouseEvent, r: number, c: number) => {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* a WHERE filter, and pending cell edits / added / deleted rows submitted
|
||||
* together in one transaction. Tables without a primary key are read-only.
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
call,
|
||||
commands,
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { Button, EmptyState, Icon, IconButton, Select, Spinner, T, showContextMenu } from "@/ui";
|
||||
import { notifications, type EditorInput } from "@/workbench/contributions";
|
||||
import { SqlFilterInput } from "./SqlFilterInput";
|
||||
import { ColumnsPopup } from "./ColumnsPopup";
|
||||
import { DataView, ExtractorSelect, PanelToggle, exportMenu, type DataPanel } from "./DataView";
|
||||
import type { ExportFormat } from "./export";
|
||||
import {
|
||||
@@ -30,6 +31,7 @@ import {
|
||||
import { openDialog, sourceById, withConnection } from "./store";
|
||||
import type { TableRef } from "./tableInput";
|
||||
import styles from "./Database.module.css";
|
||||
import { registerTableEditor, tableEditorChanged, type TableEditorHandle } from "./tableEditorFocus";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { shortcutLabel } from "@/platform/keymap";
|
||||
|
||||
@@ -179,9 +181,30 @@ export function TableEditor({ input }: { input: EditorInput }) {
|
||||
});
|
||||
|
||||
const dirty = hasEdits(edits);
|
||||
// Submit / Revert actions (⌘↩ / ⌥⌘Z) reach the latest state through the handle.
|
||||
const viewRef = useRef<HTMLDivElement>(null);
|
||||
const handle = useRef<TableEditorHandle>({
|
||||
dirty: false,
|
||||
submit: () => {},
|
||||
revert: () => {},
|
||||
showColumns: () => {},
|
||||
}).current;
|
||||
handle.dirty = editable && dirty;
|
||||
handle.submit = () => {
|
||||
if (editable && dirty) void submit();
|
||||
};
|
||||
handle.revert = () => setEdits(emptyEdits());
|
||||
const [columnsAt, setColumnsAt] = useState<{ x: number; y: number } | null>(null);
|
||||
const [revealColumn, setRevealColumn] = useState<{ col: number } | null>(null);
|
||||
handle.showColumns = () => {
|
||||
const r = viewRef.current?.getBoundingClientRect();
|
||||
if (page && r) setColumnsAt({ x: r.left + r.width / 2, y: r.top + 48 });
|
||||
};
|
||||
useEffect(() => registerTableEditor(viewRef.current!, handle), [handle]);
|
||||
useEffect(() => tableEditorChanged(handle), [handle, dirty, editable]);
|
||||
const orderInvalid = orderDraft !== null && !parseOrderBy(orderDraft, names);
|
||||
return (
|
||||
<div className={styles.view}>
|
||||
<div className={styles.view} ref={viewRef} data-db-table-editor="">
|
||||
<div className={styles.toolbar}>
|
||||
<IconButton icon="refresh" title="Reload Page" onClick={reload} disabled={loading} />
|
||||
<div className={styles.pager}>
|
||||
@@ -328,6 +351,7 @@ export function TableEditor({ input }: { input: EditorInput }) {
|
||||
columns={gridColumns}
|
||||
rowCount={rowCount}
|
||||
fixedRows={loaded}
|
||||
revealColumn={revealColumn}
|
||||
value={value}
|
||||
panel={panel}
|
||||
onPanelChange={setPanel}
|
||||
@@ -355,6 +379,18 @@ export function TableEditor({ input }: { input: EditorInput }) {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{columnsAt && (
|
||||
<ColumnsPopup
|
||||
anchor={columnsAt}
|
||||
table={ref.table}
|
||||
columns={gridColumns}
|
||||
onPick={(col) => {
|
||||
setColumnsAt(null);
|
||||
setRevealColumn({ col });
|
||||
}}
|
||||
onClose={() => setColumnsAt(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loaded 100 of 183 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user