Compare commits

...
Author SHA1 Message Date
l0ng-ai eb258b0912 fix(java): list dependencies' implementations after the project's in Go to Implementation
Go to Implementation on a type or method declared in a dependency (Runnable, a Spring interface) listed only the project's implementations once the project had one. openidea.java.implementations (java-usages 1.5.0) now answers {locations, complete}: complete when the type is declared in the sources, which no dependency implements. Its search also covers the project's Kotlin classes attached by the Kotlin bridge.

When the answer is not complete, ide-lsp passes those found first to the new lsp_goto_implementation channel and runs the standard request for the rest. The chooser opens with the project's implementations ("1 found, searching…") and fills in the dependencies' when the search ends; closing it cancels the search. Gutter markers keep the source-only answer for project types.
2026-11-06 08:14:57 +08:00
l0ng-ai 086c89df5b fix(settings): fit long labels in aligned settings forms (#1643)
The aligned settings forms had a fixed 130px label column and labels don't wrap, so "Project folder on target" and longer translations ran under their field. The aligned rows of a section now share one subgrid label column, at least 130px and as wide as its widest label (Run Targets, SSH Configurations, CMake, Tools).
2026-11-06 08:11:50 +08:00
l0ng-ai 5325d98c3f fix(vim): save Normal-mode Vim edits (#1642)
codemirror-vim labels its own edits input.type.compose to group them for undo; the Normal-mode IME extender matched that label alone and marked them transient, so the document model and saves missed them. Only transactions of a view with an IME composition in progress are transient now.
2026-11-06 08:04:25 +08:00
Andrea Basileandandreabasile 589cf81bae feat(run): Docker and Compose run targets (#1640)
A run target can be a Docker image or a Compose service: the configuration runs as a local docker run --rm / docker compose run --rm with the project bind-mounted, so the console, PTY, stdin, Stop and test parsers work unchanged. Only daemons on this machine are accepted; on Linux the container runs as the project folder's owner; Stop runs docker stop with the same docker CLI. Debugging, coverage and profiling stay on this machine. Refs #1083 (step A1).

Co-authored-by: andreabasile <[email protected]>
2026-11-06 07:34:00 +08:00
l0ng-ai a5d1b16e59 fix(debugger): debug PHP with php-debug on the configured PHP (#1641)
php-debug had no launch template: the generic one sent the configuration's command as the program, so artisan serve failed or ran the PHP binary as a script, and PHPUnit skipped breakpoints unless php.ini enabled Xdebug's debug mode. The template runs the script on the PHP the command names or the one from Settings, enables debug mode with -d options on the port php-debug picks, and PHP files and php scripts become debuggable. php-debug returns to the configuration form's Debug adapter list.
2026-11-06 07:27:22 +08:00
Daniel AdámekandDanielADK b450f4028b fix(debugger): finish PHP Remote Debug: taken port, waiting state, Port field (#1639)
A taken port now says which port is in use instead of Node's EADDRINUSE text; a listening PHP Remote Debug session shows "Listening for Xdebug on port N" instead of "Running…"; clearing the only field of a debug-only remote type keeps the form and shows the field as required; php-debug leaves the generic Debug adapter list until it has a launch template (#1623).

Co-authored-by: DanielADK <[email protected]>
2026-11-06 06:56:51 +08:00
Deng Liuanddengliu 1f76e71621 fix(viewers): yield image viewer to registered text editor wrappers (#1638)
A plugin's side-by-side custom editor for an image-extension subtype (such as *.excalidraw.svg) was intercepted by the image viewer. The image viewer now yields to a matching text editor wrapper, and an image tab already open switches to the text editor when such a wrapper registers later (after plugins load).

Co-authored-by: dengliu <[email protected]>
2026-11-06 06:54:57 +08:00
l0ng-ai e6b800971a fix(debugger): offer to install Bash Debug, and point debugger hints to Settings (#1637)
Bash Debug had no entry in the missing-adapter table, so a shell script debug session without it failed with a plain error pointing at mason.nvim. The install hints of the debuggers OpenIDEA installs itself (CodeLLDB, vscode-js-debug, Kotlin Debug Adapter, netcoredbg, Bash Debug) now point to Settings › Languages & Frameworks › Language Servers, in languages/*.toml and in ide-dap's built-in definitions.
2026-11-06 06:35:01 +08:00
l0ng-ai a6cf4442d8 fix(editor): auto-save when the app is deactivated while focus is in a frame (#1636)
With focus in a plugin's web view, the window's blur fires when the frame takes focus, before any edit, and not again when the user switches applications, so edits made in the frame weren't auto-saved. The editor now also saves on the application-deactivated event.
2026-11-06 06:33:12 +08:00
Vadim LazukandInf1mum 83d2f67b4f feat(debugger): add PHP Remote Debug (Xdebug) run configuration (#1635)
A debug-only PHP Remote Debug (Xdebug) configuration: php-debug listens on the port (9003 by default) with optional path mappings, and the form shows how to start the remote side. php-debug is also offered as a debug adapter in the configuration form.

Co-authored-by: Inf1mum <[email protected]>
2026-11-06 06:28:02 +08:00
Vadim LazukandInf1mum 3ec30fff3f fix(debugger): offer to install php-debug when it is missing (#1633)
php-debug had no entry in the missing-adapter table, so a PHP debug session without it failed with a plain error pointing at mason.nvim. It now offers Install and Debug like debugpy, Delve or js-debug, and the hint points to Settings › Languages & Frameworks › Language Servers.

Co-authored-by: Inf1mum <[email protected]>
2026-11-06 06:27:57 +08:00
Vadim LazukandInf1mum 009bb21f0b fix(editor): pad the Reformat File dialog (#1634)
Dialog bodies have no padding of their own; the Reformat File dialog put its groups straight into the body, so they sat against the edges. They are now wrapped in a padded column like the other dialogs' forms.

Co-authored-by: Inf1mum <[email protected]>
2026-11-06 06:27:53 +08:00
l0ng-ai 43bb10048d test(phpstan): read the pause before logging the run (#1632)
The fake PHP logged its run before reading the pause file, so a test that reset the pause on seeing the run could make that run skip its pause on a slow machine and finish before the next save cancelled it.
2026-11-06 05:58:13 +08:00
l0ng-ai 1b4ca3cf1f test(smoke): switch the failing test on in the saved configuration (#1631)
Reruns launch the configuration as it is saved now (#1579), so Rerun Failed Tests dropped the switch the smoke test applied only to the first run and the failing test was skipped. The switched-on configuration is now put into the configuration list before the run.
2026-11-06 05:55:56 +08:00
NealimeKennaandNealimeKenna 08f2d9756d fix(language): update tree-sitter-haskell to 0.24.1 (#1630)
tree-sitter-haskell 0.23.1's array.h is miscompiled by GCC 16 under strict aliasing, so Haskell parsing aborted the process (glibc "corrupted size vs. prev_size"); 0.24 fixes it. 0.24 renames the node type_synomym to type_synonym, so the Haskell outline query follows.

Co-authored-by: NealimeKenna <[email protected]>
2026-11-06 05:53:07 +08:00
l0ng-ai b4bd0ec1e2 feat(debugger): Reload Changed Classes in the editor context menu (#1627)
Shown on a Java file while a debug session can reload classes (java-debug hot code replace); it runs the existing debug.reloadChangedClasses action.
2026-11-06 05:35:43 +08:00
l0ng-ai 7634ae81f1 fix(linux): start on Wayland with the NVIDIA driver (#1625)
WebKitGTK's DMA-BUF renderer fails on a Wayland session with the NVIDIA driver ("Error 71 (Protocol error) dispatching to Wayland display") and the app exits before showing a window. On that combination WEBKIT_DISABLE_DMABUF_RENDERER=1 is set at startup unless the user already set it.
2026-11-06 05:32:58 +08:00
Arnaud Ritti 7bd96a998b feat(plugins): when clauses that test whether the project contains a file (#1620)
Plugin when clauses can use workspaceContains:<path>, a path relative to the project root (any project of a workspace). Installation refuses empty, absolute and .. paths. The keys are re-checked on plugin changes, project switches, created or removed files and changes in ignored folders; plugins that don't use the key do no IPC. The key regex avoids lookbehind for WebKit.

Co-authored-by: arnaud-ritti <[email protected]>
2026-11-06 05:27:50 +08:00
Vadim LazukandInf1mum 4830f42168 feat(vcs): add Preview Diff toggle to the Commit tool window (#1615)
A Preview Diff toggle in the Commit tool window's changes toolbar (and View Options › Show Diff Preview) shows the selected file's working-tree diff in an embedded, resizable pane: beside the changes and the commit message when the window is bottom-docked, below them when side-docked, as in the Local Changes tab. Off by default and remembered; empty states when nothing or several files are selected, or there are no changes.

Co-authored-by: Inf1mum <[email protected]>
2026-11-06 03:18:25 +08:00
l0ng-ai 800949b7b5 fix(agents): wrap a permission request line in a narrow conversation (#1617)
A permission request line kept the title, "Waiting for your answer" and the answers on one row, so in a narrow agent conversation a long command title squeezed the answers until they were cut off. Title, state and answers now wrap next to the icon: a long title takes its own line and the state and answers go below it, in line with the title.
2026-11-06 03:14:41 +08:00
l0ng-ai 6026374973 perf(java): find implementations in the project sources first (#1616)
JDT LS answers textDocument/implementation by building the type hierarchy over every dependency jar, so Go to Implementation and the implemented/overridden gutter markers were slow in projects with many dependencies. The app-owned JDT LS bundle (1.4.0) adds openidea.java.implementations: subtypes, or overriding methods with type arguments substituted and abstract ones left out, within the workspace's source folders only. ide-lsp asks it first and falls back to the standard request when the sources have none or the command is missing.
2026-11-06 03:12:18 +08:00
l0ng-ai 0a47a685d9 fix(debugger): type input for the program in the debug console (#1614)
A program started through runInTerminal had stdin from /dev/null, and CodeLLDB's launcher only hands the program a terminal it can read from when it runs on one. runInTerminal now starts the command on a pseudo-terminal, forwards its output to the console (CRLF as LF, without CodeLLDB's clear-screen request) and reports that the program takes input; while it runs, the console's input line goes to it, while paused it evaluates as before. debugpy and js-debug programs started in a terminal get the same.
2026-11-06 03:02:42 +08:00
Vadim LazukandInf1mum 653f2ff276 feat(ui): close tabs and panel headers with middle mouse button (#1613)
A middle click on a tool window header hides the window, as its Hide button does; presses on the header's tabs and buttons keep their own behaviour. Editor tabs already closed on a middle click, except when Settings › Editor Tabs hides the close button: that marked the tabs as not closable, so a middle click (and Delete) did nothing. A tab item now hides only its close button and still closes.

Co-authored-by: Inf1mum <[email protected]>
2026-11-06 02:55:26 +08:00
l0ng-ai 8689271e7c feat(php): offer composer install when vendor/ is missing or differs from composer.lock (#1611)
PHP files and composer.json of a Composer project show a banner when vendor/autoload.php is missing or a package of composer.lock is not installed at its locked version (packages-dev only unless the install used --no-dev; config.vendor-dir honoured). Install runs composer install in the Run tool window with the PHP of Settings › PHP first on PATH; Ignore hides it until the project is reopened. Rechecked when composer.json, composer.lock or vendor change; not shown in an untrusted project. A Composer Install action does the same.
2026-11-06 02:43:22 +08:00
kjct0s_andKJ-AIML 7ad02da722 fix(window): cover the whole screen with a full-screen project window group on notched Macs (#1608)
A project tab group in full screen uses simple fullscreen and keeps its title bar for the window buttons. AppKit's constrainFrameRect:toScreen: keeps a titled window below the camera housing whenever it is placed, shown or resized, so on a notched display the group sat 32 pt low with the status bar off-screen. Windows in simple fullscreen now keep the frame they ask for; every other window still goes through AppKit's constraint.

Co-authored-by: KJ-AIML <[email protected]>
2026-11-06 02:33:46 +08:00
l0ng-ai 1dda103bf0 fix(app): close a project window without blocking the UI thread (#1610)
close_project was a synchronous command, so it ran on the UI thread and dropped the project there: stopping its file watcher and index worker waits for the batch of file events they are applying, many while a build or rust-analyzer writes output. The command now runs off the main thread, and the project is dropped on the project shutdown thread, which a project opened next still waits for (the index database is released first). The window Destroyed path gets the same.
2026-11-06 02:29:45 +08:00
l0ng-ai 844cc93b90 feat(php): reformat PHP files with the project's PHP-CS-Fixer configuration (#1606)
When a .php file has a .php-cs-fixer.php or .php-cs-fixer.dist.php above it and vendor/bin/php-cs-fixer is installed, Reformat Code and format on save run PHP-CS-Fixer on a temporary copy of the unsaved text with that configuration and apply the result as edits. PHP comes from the PHP interpreter setting, or PATH. A selection formats the whole file, with a note. Without a configuration, the language server keeps formatting.
2026-11-06 02:00:33 +08:00
l0ng-ai c95a525235 feat(php): New › PHP File and PHP Class with the namespace from composer.json (#1605)
New › PHP File writes <?php and the caret; New › PHP Class offers Class, Interface, Trait, Enum and PHPUnit Test with the namespace taken from the nearest composer.json's autoload / autoload-dev PSR-4 map (longest mapped folder wins; no namespace line outside every mapping). The New dialog shows 'namespace …', and Create New Test… on a .php file opens PHP Class with PHPUnit Test.
2026-11-06 01:52:01 +08:00
l0ng-ai 4002eaa8e9 fix(plugins): list project files from the index instead of walking the disk (#1604)
project.find-files walked the whole tree on every call; MyBatis Navigator calls it from editor providers interrupted after 500 ms, so on a large project each call timed out, counted as a crash, and the plugin was stopped after three. The host now answers from the project index (VCS-ignored and hidden files left out, as in the walk) and only walks the disk when no index covers the root yet.
2026-11-06 01:26:01 +08:00
l0ng-ai 98268106b8 feat(php): mark source and test roots from composer.json autoload (#1602)
Folders in composer.json's autoload.psr-4 become Sources Roots and those in autoload-dev.psr-4 Test Sources Roots (string or array values; paths into vendor/ or outside the folder are skipped). It runs before the package.json case, so a Laravel app's tests/ is credited to composer.json, and editing composer.json reruns detection.
2026-11-06 01:16:32 +08:00
l0ng-ai 2e9a637158 feat(editor): insert a documentation comment stub on Enter after /** (#1600)
Enter after a /** that opens a new comment closes it with the caret on the summary line, in every language with /* */ comments. Above a PHP function or method the stub lists its parameters (@param with the declared type, ... for variadics) and the declared return type (@return; none for void and constructors). Settings › Editor › General › Smart Keys › Enter › Insert documentation comment stub turns it off.
2026-11-06 01:14:38 +08:00
l0ng-ai f50a8a301c fix(keymap): bind table editor Submit and Revert in the VS Code presets (#1598)
The VS Code presets list every IntelliJ action explicitly; the table editor's Submit / Revert actions were missing there, failing keymap.test.ts. They use ⌘↩ / ⌥⌘Z (Ctrl+Enter / Ctrl+Alt+Z on Windows), active only while a table editor's grid has focus.
2026-11-06 01:13:02 +08:00
l0ng-ai 07df51f308 fix(lsp): keep rust-analyzer's developer notices out of notifications (#1597)
A rust-analyzer built from source reports its own slow main-loop turns ("overly long loop turn took …") as error messages meant for its developers. They are logged now instead of shown as error notifications.
2026-11-06 01:08:27 +08:00
l0ng-ai 38ff63658e feat(vcs): delete changed files from the Changes view (#1596)
Delete… (⌫ / Delete) in the Changes tree was only offered for unversioned files. It now applies to any selected files still on disk, moving them to the Trash; a tracked file then shows as a deleted change.
2026-11-06 01:05:36 +08:00
146 changed files with 4938 additions and 237 deletions

No files matched your search

Generated
+7 -5
View File
@@ -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]]
@@ -4182,6 +4182,7 @@ dependencies = [
"ide-language",
"memchr",
"parking_lot",
"portable-pty",
"serde",
"serde_json",
"specta",
@@ -4543,6 +4544,7 @@ dependencies = [
"ignore",
"parking_lot",
"serde",
"serde_json",
"specta",
"tempfile",
"tokio",
@@ -8378,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]]
@@ -9980,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",
+3
View File
@@ -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(),
}
}
+3
View File
@@ -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,
},
+59 -6
View File
@@ -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);
}
}
+1
View File
@@ -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]
+5 -5
View File
@@ -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]
+10
View File
@@ -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<()> {
+86 -19
View File
@@ -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();
+34
View File
@@ -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();
+140
View File
@@ -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());
}
+86
View File
@@ -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());
}
}
+28 -1
View File
@@ -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}");
}
}
}
+108
View File
@@ -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(&copy, unsaved)?;
formatter::run_php_cs_fixer(tools.php_cs_fixer_command(php, fixer, &copy)?, &Default::default())?;
let formatted = std::fs::read_to_string(&copy)?;
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");
+44
View File
@@ -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");
}
}
+8
View File
@@ -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
});
+26
View File
@@ -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 },
+25 -2
View File
@@ -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::*;
+108
View File
@@ -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))
}
+31
View File
@@ -96,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>> {
@@ -211,6 +238,10 @@ pub(crate) fn register(d: &mut Dispatcher) {
"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
+3 -3
View File
@@ -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();
+78
View File
@@ -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
@@ -459,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 {
@@ -738,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()?;
@@ -821,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();
+122
View File
@@ -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();
}
+2
View File
@@ -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}");
}
+22
View File
@@ -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)
+1 -1
View File
@@ -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
+6
View File
@@ -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
View File
@@ -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();
+74 -1
View File
@@ -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())]);
+2 -1
View File
@@ -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
+347
View File
@@ -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'");
}
}
+2 -1
View File
@@ -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;
+3
View File
@@ -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() {
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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);
+70
View File
@@ -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)
}
+45
View File
@@ -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))
}
+82
View File
@@ -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(&[
+1 -1
View File
@@ -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,
+10 -1
View File
@@ -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.
+3
View File
@@ -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;
}
}
+198
View File
@@ -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)
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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
View File
@@ -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"
+7
View File
@@ -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
+4 -1
View File
@@ -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 });
+2 -1
View File
@@ -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());
+7
View File
@@ -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<()> {
+17
View File
@@ -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)`.
+3
View File
@@ -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,
@@ -400,6 +402,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
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,
+11
View File
@@ -42,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]
+4
View File
@@ -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.
+51
View File
@@ -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
View File
@@ -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) {
+73 -5
View File
@@ -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 })),
@@ -776,6 +784,11 @@ export const commands = {
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.
@@ -2378,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 = {
/**
@@ -2837,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`.
@@ -6487,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,
@@ -6870,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,
};
+11
View File
@@ -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)),
};
+77
View File
@@ -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);
});
});
+88
View File
@@ -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;
};
}
+2
View File
@@ -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 });
+19
View File
@@ -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";
@@ -319,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();
}
});
});
+39
View File
@@ -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";
@@ -344,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)));
@@ -353,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)
@@ -452,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);
+18
View File
@@ -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 });
+14 -3
View File
@@ -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");
});
});
+8 -1
View File
@@ -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;
+21
View File
@@ -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
View File
@@ -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>
);
}
+102 -4
View File
@@ -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 -1
View File
@@ -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");
+99 -6
View File
@@ -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 });
});
});
+12 -1
View File
@@ -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 {
+7 -5
View File
@@ -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>
);
}
@@ -30,7 +30,7 @@ vi.mock("@/workbench/contributions", async (importOriginal) => {
import type { DebugCapabilities, DebugSessionId } from "@/platform/ipc";
import { getContext } from "@/platform/context";
import { _resetI18nForTests, setLocale } from "@/platform/i18n";
import { hotSwapMessage, onHotCodeReplace, reloadChangedClasses } from "../hotswap";
import { hotSwapMenuItems, hotSwapMessage, onHotCodeReplace, reloadChangedClasses } from "../hotswap";
import { publishContext, useDebug, type DebugSession } from "../state";
function session(id: number, hotCodeReplace: boolean): DebugSession {
@@ -80,6 +80,13 @@ describe("HotSwap", () => {
expect(getContext().debugCanHotSwap).toBe(false);
});
it("offers the reload in the editor menu of a Java file while a session can reload classes", () => {
expect(hotSwapMenuItems(["/p/app/src/A.java"])).toEqual([{ kind: "action", action: "debug.reloadChangedClasses" }]);
expect(hotSwapMenuItems(["/p/app/pom.xml"])).toEqual([]);
useDebug.setState({ sessions: { 2: session(2, false) }, active: 2 });
expect(hotSwapMenuItems(["/p/app/src/A.java"])).toEqual([]);
});
it("builds, reloads in Java sessions and reports the count", async () => {
await reloadChangedClasses();
expect(ipc.javaBuildWorkspace).toHaveBeenCalledWith("/p/app");
@@ -23,6 +23,42 @@ describe("run config → launch config", () => {
expect(launchConfigFor(rc({}), "/p")).toBeNull();
});
it("php-debug: the script runs on the PHP from Settings with Xdebug's debug mode, on a free port", () => {
const xdebug = ["-dxdebug.mode=debug", "-dxdebug.start_with_request=yes", "-dxdebug.client_port=${port}"];
// `artisan serve` with PHP at Default: the script is a file of the project.
const serve = launchConfigFor(
rc({ kind: "php", command: "php", args: ["artisan", "serve"], debugAdapter: "php-debug" }),
"/app",
"/opt/[email protected]/bin/php",
)!;
expect(serve).toMatchObject({ adapter: "php-debug", program: "/app/artisan", args: ["serve"], cwd: "/app" });
expect(extra(serve)).toEqual({ port: 0, runtimeArgs: xdebug, runtimeExecutable: "/opt/[email protected]/bin/php" });
// The PHP the command names wins, and its options stay with it.
const file = launchConfigFor(
rc({
kind: "php",
command: "/usr/bin/php8.4",
args: ["-dmemory_limit=1G", "src/a.php", "x"],
debugAdapter: "php-debug",
}),
"/app",
"/opt/[email protected]/bin/php",
)!;
expect(file).toMatchObject({ program: "/app/src/a.php", args: ["x"] });
expect(extra(file)).toEqual({
port: 0,
runtimeArgs: [...xdebug, "-dmemory_limit=1G"],
runtimeExecutable: "/usr/bin/php8.4",
});
// PHPUnit runs its script directly; without a PHP in Settings, php-debug uses the `php` on PATH.
const unit = launchConfigFor(
rc({ kind: "phpunit", command: "vendor/bin/phpunit", args: ["--filter", "testA"], debugAdapter: "php-debug" }),
"/app",
)!;
expect(unit).toMatchObject({ program: "/app/vendor/bin/phpunit", args: ["--filter", "testA"] });
expect(extra(unit)).toEqual({ port: 0, runtimeArgs: xdebug });
});
it("cargo run builds the binary through codelldb's cargo integration", () => {
const l = launchConfigFor(
rc({
@@ -25,6 +25,20 @@ describe("missing debug adapters", () => {
"netcoredbg",
);
expect(missingAdapter("delve", unavailable("debug adapter `delve` not found (x)"))?.tool).toBe("delve");
expect(missingAdapter("bash-debug", unavailable("debug adapter `bash-debug` not found (x)"))?.title).toBe(
"Bash Debug is not installed",
);
});
it("offers the managed install of the missing php-debug adapter", () => {
const e = unavailable(
"debug adapter `php-debug` not found (`php-debug-adapter` is neither installed by OpenIDEA nor on PATH). Install PHP Debug (Xdebug) in Settings › Languages & Frameworks › Language Servers, and enable Xdebug",
);
expect(missingAdapter("php-debug", e)).toMatchObject({
tool: "php-debug",
title: "PHP Debug (Xdebug) is not installed",
body: e.message,
});
});
it("leaves other failures and adapters without an installer alone", () => {
+44 -1
View File
@@ -30,9 +30,12 @@ const {
goRemoteType,
pythonRemoteType,
nodeRemoteType,
phpRemoteType,
jdwpAgentArgs,
xdebugCommand,
remoteAttachConfig,
remoteJvmTarget,
remoteWaitingNote,
} = remote;
function configOf(type: (typeof REMOTE_TYPES)[number], params: Record<string, string> = {}): RunConfig {
@@ -67,7 +70,9 @@ describe("remote debug configuration types", () => {
port: "Port must be a number from 1 to 65535",
});
expect(missingParams(configOf(type, { port: "abc" }), type).port).toBeDefined();
expect(missingParams(configOf(type, { host: "" }), type)).toEqual({ host: "Host is required" });
// PHP Remote Debug has no host: php-debug listens itself, Xdebug connects to it.
if (type.fields.some((f) => f.key === "host"))
expect(missingParams(configOf(type, { host: "" }), type)).toEqual({ host: "Host is required" });
});
it("Remote JVM Debug defaults to attaching to localhost:5005", () => {
@@ -157,6 +162,44 @@ describe("remote attach requests", () => {
});
});
it("PHP Remote Debug defaults to port 9003 and shows the xdebug command line", () => {
expect(configOf(phpRemoteType).params).toEqual({ port: "9003" });
const field = phpRemoteType.fields.find((f) => f.key === "hint")!;
expect(field.control).toBe("output");
expect(field.value!({ port: "9004" })).toContain("-dxdebug.client_port=9004");
expect(xdebugCommand({})).toBe(
"php -dxdebug.mode=debug -dxdebug.start_with_request=yes -dxdebug.client_port=9003 script.php",
);
});
it("PHP Remote Debug launches php-debug without a program and maps the roots", () => {
const l = remoteAttachConfig(configOf(phpRemoteType, { localRoot: ".", remoteRoot: "/var/www/html" }), "/p")!;
expect(l).toMatchObject({ adapter: "php-debug", request: "launch", cwd: "/p" });
expect(l.program).toBeNull();
expect(JSON.parse(l.extraJson!)).toEqual({ port: 9003, pathMappings: { "/var/www/html": "/p" } });
const plain = JSON.parse(remoteAttachConfig(configOf(phpRemoteType), "/p")!.extraJson!);
expect(plain).toEqual({ port: 9003 });
});
it.each([
[{ remoteRoot: "/app", localRoot: "web" }, { "/app": "/p/web" }],
[{ remoteRoot: "/app", localRoot: "/srv/site" }, { "/app": "/srv/site" }],
])("PHP Remote Debug maps the server path %j to the local root", (params, pathMappings) => {
const l = remoteAttachConfig(configOf(phpRemoteType, params), "/p")!;
expect(JSON.parse(l.extraJson!)).toEqual({ port: 9003, pathMappings });
expect(l).toMatchObject({ args: [], env: {} });
});
it("PHP Remote Debug keeps its form when Port, its only value, is cleared", () => {
const cleared = withParams(configOf(phpRemoteType), {}, phpRemoteType);
expect(missingParams(cleared, phpRemoteType)).toEqual({ port: "Port is required" });
});
it("PHP Remote Debug says which port it waits on; the attaching types don't", () => {
expect(remoteWaitingNote(configOf(phpRemoteType, { port: "9010" }))).toBe("Listening for Xdebug on port 9010");
for (const t of REMOTE_TYPES.filter((x) => x !== phpRemoteType)) expect(remoteWaitingNote(configOf(t))).toBeNull();
});
it("start-the-remote-side hints follow the port", () => {
expect(remote.delveServerCommand({ port: "4000" })).toBe(
"dlv debug --headless --listen=:4000 --api-version=2 --accept-multiclient",
@@ -0,0 +1,52 @@
import { describe, expect, it, vi } from "vitest";
import { IpcError, type LaunchConfig } from "@/platform/ipc";
import { provideNotificationService } from "@/workbench/contributions";
import { startFailureMessage } from "../startFailure";
import { startSession, useDebug } from "../state";
const ipc = vi.hoisted(() => ({ debugStart: vi.fn() }));
vi.mock("@/platform/ipc", async (original) => {
const real = await original<typeof import("@/platform/ipc")>();
return { ...real, isTauri: () => true, commands: { ...real.commands, ...ipc } };
});
const PORT_IN_USE =
"Port 9003 is already in use: another IDE or debug session may be listening on it. Stop it or choose another port.";
describe("debug start failures", () => {
it.each([
"launch failed: listen EADDRINUSE: address already in use :::9003",
"launch failed: listen EADDRINUSE: address already in use 127.0.0.1:9003",
])("names the port another listener holds: %s", (message) => {
expect(startFailureMessage(new IpcError({ kind: "invalidInput", message }))).toBe(PORT_IN_USE);
});
it("keeps other failures as they are", () => {
expect(startFailureMessage(new Error("launch failed: Attach requests are not supported"))).toBe(
"launch failed: Attach requests are not supported",
);
expect(startFailureMessage("debug adapter exited")).toBe("debug adapter exited");
});
it("are what the notification of a failed start says", async () => {
const notify = vi.fn();
provideNotificationService({ notify });
useDebug.setState({ sessions: {}, active: null, console: {} });
const message = "launch failed: listen EADDRINUSE: address already in use :::9003";
ipc.debugStart.mockResolvedValueOnce({ status: "error", error: { kind: "invalidInput", message, data: null } });
const listen: LaunchConfig = {
name: "Listen for Xdebug",
adapter: "php-debug",
request: "launch",
program: null,
args: [],
cwd: "/p",
env: {},
extraJson: JSON.stringify({ port: 9003 }),
};
expect(await startSession(listen)).toBeNull();
expect(notify).toHaveBeenCalledWith(
expect.objectContaining({ title: "Cannot start debugger for Listen for Xdebug", body: PORT_IN_USE }),
);
});
});
@@ -28,6 +28,7 @@ vi.mock("@/platform/ipc", async (importOriginal) => {
const confirmTerminate = vi.hoisted(() => vi.fn(async () => false));
vi.mock("@/workbench/confirmTerminate", () => ({ confirmTerminate }));
import type { RunConfig } from "@/platform/ipc";
import { useDebug, type DebugSession } from "../state";
import { DebugTabs, DebugToolWindow } from "../ui/DebugToolWindow";
@@ -63,6 +64,33 @@ describe("debug tool window", () => {
expect(hot()).toEqual([true, true]);
});
it("says a PHP Remote Debug session listens for Xdebug while nothing is suspended", () => {
const runConfig = {
id: "php-remote:web",
name: "web",
kind: "php-remote",
command: "",
args: [],
cwd: null,
env: {},
detected: false,
isTest: false,
debugAdapter: "php-debug",
params: { port: "9010" },
} as RunConfig;
const waiting = { ...session, adapter: "php-debug", status: "running" as const, threads: [], frames: [] };
useDebug.setState({ sessions: { 1: { ...waiting, runConfig } }, active: 1 });
const { unmount } = render(<DebugToolWindow />);
fireEvent.click(screen.getByRole("tab", { name: "Threads & Variables" }));
expect(screen.getByText("Listening for Xdebug on port 9010")).toBeTruthy();
expect(screen.queryByText("Running…")).toBeNull();
unmount();
useDebug.setState({ sessions: { 1: waiting }, active: 1 });
render(<DebugToolWindow />);
fireEvent.click(screen.getByRole("tab", { name: "Threads & Variables" }));
expect(screen.getByText("Running…")).toBeTruthy();
});
it("shows preparation output, preserves a chosen tab and opens variables on pause", () => {
useDebug.setState({ sessions: { 1: { ...session, status: "starting" } }, active: 1 });
render(<DebugToolWindow />);
+4 -1
View File
@@ -299,7 +299,10 @@ async function startConfig(arg: unknown) {
}
const dotnet = config.kind === "dotnet" && config.debugAdapter === "netcoredbg";
const prepared = dotnet ? await call(commands.runDotnetResolve(config, true)) : config;
const launch = launchConfigFor(prepared, root);
// php-debug starts the PHP chosen in Settings unless the command names one.
const php =
config.debugAdapter === "php-debug" && isTauri() ? await call(commands.sdkPhpEffective()).catch(() => null) : null;
const launch = launchConfigFor(prepared, root, php);
if (!launch) {
notifications().notify({
group: "run",
+7
View File
@@ -9,6 +9,7 @@ import { call, commands, isTauri, type DebugEvent, type DebugSessionId } from "@
import { maybeEditorService, notifications } from "@/workbench/contributions";
import { isLive, useDebug, type DebugSession } from "./state";
import { tx } from "@/platform/i18n";
import type { MenuItem } from "@/ui/Menu";
const KEY = "debug.hotswap";
/** `BUILD_COMPLETE` events of our own build are not worth a prompt. */
@@ -18,6 +19,12 @@ export function hotSwapSessions(): DebugSession[] {
return Object.values(useDebug.getState().sessions).filter((s) => isLive(s) && !!s.caps?.hotCodeReplace);
}
/** Editor menu: Reload Changed Classes on a Java file while a debug session can reload classes. */
export function hotSwapMenuItems(paths: readonly string[]): MenuItem[] {
const java = paths.length === 1 && /\.java$/i.test(paths[0]!);
return java && hotSwapSessions().length ? [{ kind: "action", action: "debug.reloadChangedClasses" }] : [];
}
/** "3 classes", translated per count (the sentence around it is translated on its own). */
const classesText = (n: number) => (n === 1 ? tx`${n} class` : tx`${n} classes`);
+6 -1
View File
@@ -24,7 +24,7 @@ import { breakpointGutter } from "./editor/breakpointGutter";
import { executionPoint } from "./editor/execution";
import { runToCursorInlay } from "./editor/runToCursor";
import { installSmartStepPicking, smartStepTargets } from "./editor/smartStep";
import { onHotCodeReplace } from "./hotswap";
import { hotSwapMenuItems, onHotCodeReplace } from "./hotswap";
import {
activeSession,
handleDebugEvent,
@@ -68,6 +68,11 @@ export function activate(): () => void {
menus: ["editor"],
items: () => (activeSession()?.status === "paused" ? [{ kind: "action", action: "debug.evaluate" }] : []),
}),
registerMenuGroup({
id: "debugger.hotswap",
menus: ["editor"],
items: (_menu, { paths }) => hotSwapMenuItems(paths),
}),
// Stop (⌘F2) offers the running debug sessions beside run processes.
registerMenuGroup({
id: "debugger.stop",
+35 -1
View File
@@ -356,11 +356,44 @@ function flutterAttach(rc: RunConfig, cwd: string, adapter: string): LaunchConfi
};
}
const PHP_NAME = /^php(\d+(\.\d+)*)?(\.exe)?$/i;
/**
* php-debug runs `runtimeExecutable runtimeArgs program args` and doesn't enable Xdebug: debug mode and
* the connection back come from `-d` options, to the free port php-debug picks for `port: 0` (another
* IDE may listen on 9003). `php` is the PHP chosen in Settings, used when the command doesn't name one.
*/
function phpDebugLaunch(rc: RunConfig, cwd: string, php: string | null): LaunchConfig {
const name = rc.command.split(/[\\/]/).pop() ?? rc.command;
const runtimeArgs = ["-dxdebug.mode=debug", "-dxdebug.start_with_request=yes", "-dxdebug.client_port=${port}"];
let runtime = php;
let program: string;
let args: string[];
if (PHP_NAME.test(name)) {
// `php [options] script args`: the options stay with PHP; the script is a file (`artisan`).
if (name !== rc.command) runtime = resolveCommand(rc.command, cwd);
const at = rc.args.findIndex((a) => !a.startsWith("-"));
runtimeArgs.push(...(at < 0 ? rc.args : rc.args.slice(0, at)));
program = at < 0 ? "" : join(cwd, rc.args[at]!);
args = at < 0 ? [] : rc.args.slice(at + 1);
} else {
// A PHP script run directly (`vendor/bin/phpunit`; on Windows its `.bat` shim sits beside it).
program = resolveCommand(rc.command.replace(/\.bat$/i, ""), cwd);
args = [...rc.args];
}
return {
...base(rc, "php-debug", cwd),
program,
args,
extraJson: JSON.stringify({ port: 0, runtimeArgs, ...(runtime ? { runtimeExecutable: runtime } : {}) }),
};
}
/**
* The launch configuration that debugs `rc`, or `null` when it has no adapter.
* `root` is the project root (default working directory).
*/
export function launchConfigFor(rc: RunConfig, root: string): LaunchConfig | null {
export function launchConfigFor(rc: RunConfig, root: string, php: string | null = null): LaunchConfig | null {
const adapter = rc.debugAdapter;
if (!adapter || debugUnsupportedReason(rc)) return null;
const cwd = rc.cwd ? join(root, rc.cwd) : root;
@@ -374,6 +407,7 @@ export function launchConfigFor(rc: RunConfig, root: string): LaunchConfig | nul
if (adapter === "js-debug") return jsDebugLaunch(rc, cwd);
if (adapter === "delve") return delveLaunch(rc, cwd, root);
if (adapter === "godot") return godotLaunch(rc, cwd);
if (adapter === "php-debug") return phpDebugLaunch(rc, cwd, php);
if (adapter === "netcoredbg" && rc.kind === "dotnet") {
if (!rc.params?.targetPath) return null;
return {
+2
View File
@@ -12,6 +12,8 @@ const INSTALLABLE: Record<string, { tool: string; title: string }> = {
"js-debug": { tool: "js-debug", title: "The JavaScript debugger (vscode-js-debug) is not installed" },
delve: { tool: "delve", title: "Delve is not installed" },
codelldb: { tool: "codelldb", title: "CodeLLDB is not installed" },
"php-debug": { tool: "php-debug", title: "PHP Debug (Xdebug) is not installed" },
"bash-debug": { tool: "bash-debug", title: "Bash Debug is not installed" },
};
export interface MissingAdapter {
+67 -9
View File
@@ -2,8 +2,10 @@
* Remote debug run configurations: the debugger attaches to a process started
* elsewhere (another machine, a container) over host:port, like IDEA's Remote JVM
* Debug, GoLand's Go Remote, PyCharm's remote debugpy and IDEA's Attach to Node.js.
* PHP Remote Debug is the exception: its php-debug listens and Xdebug connects to it.
* Only Debug applies to them (`debugOnly`); they launch no process.
*/
import { tx } from "@/platform/i18n";
import type { LaunchConfig, RunConfig } from "@/platform/ipc";
import { workflowSettings } from "@/platform/workflowSettings";
import type { RunConfigTypeContribution, RunConfigTypeField } from "@/workbench/contributions";
@@ -13,6 +15,7 @@ export const GO_REMOTE = "go-remote";
export const PYTHON_REMOTE = "python-remote";
export const NODE_REMOTE = "node-remote";
export const GODOT_ATTACH = "godot-attach";
export const PHP_REMOTE = "php-remote";
type Params = Readonly<Record<string, string>>;
@@ -221,6 +224,38 @@ export const nodeRemoteType: RunConfigTypeContribution = {
commandLine: () => null,
};
// ---------------------------------------------------------------------------
// PHP Remote Debug (Xdebug): php-debug listens, the remote PHP connects to it
/** How to start the remote side, so Xdebug connects back to the listening php-debug. */
export function xdebugCommand(p: Params): string {
return `php -dxdebug.mode=debug -dxdebug.start_with_request=yes -dxdebug.client_port=${portOf(p, 9003)} script.php`;
}
export const phpRemoteType: RunConfigTypeContribution = {
id: PHP_REMOTE,
label: "PHP Remote Debug (Xdebug)",
icon: "file-php",
description: "Listen for Xdebug connections: debug PHP requests served elsewhere (php-fpm, a container)",
defaults: { port: "9003" },
debugOnly: true,
debugAdapter: "php-debug",
fields: [
portField,
{
key: "hint",
label: "Start the remote side with",
control: "output",
mono: true,
value: xdebugCommand,
hint: "For web requests: the XDEBUG_SESSION cookie or query parameter; point xdebug.client_host at this machine when PHP runs in a container",
},
localRootField,
remoteRootField("e.g. /var/www/html"),
],
commandLine: () => null,
};
// ---------------------------------------------------------------------------
// Attach to Godot (the editor's debug adapter, Editor Settings › Network › Debug Adapter)
@@ -250,13 +285,14 @@ export const REMOTE_TYPES: readonly RunConfigTypeContribution[] = [
goRemoteType,
pythonRemoteType,
nodeRemoteType,
phpRemoteType,
godotAttachType,
];
export const isRemoteConfig = (c: Pick<RunConfig, "kind">) => REMOTE_TYPES.some((t) => t.id === c.kind);
// ---------------------------------------------------------------------------
// Attach requests
// Attach / listen requests
function isAbsolute(p: string) {
return p.startsWith("/") || /^[A-Za-z]:[\\/]/.test(p);
@@ -281,11 +317,17 @@ export function serverAddress(host: string, port: number): string {
return `${host.includes(":") && !host.startsWith("[") ? `[${host}]` : host}:${port}`;
}
function attachBase(rc: RunConfig, adapter: string, root: string, extra: Record<string, unknown>): LaunchConfig {
function requestBase(
rc: RunConfig,
adapter: string,
root: string,
request: "attach" | "launch",
extra: Record<string, unknown>,
): LaunchConfig {
return {
name: rc.name,
adapter,
request: "attach",
request,
program: null,
args: [],
cwd: root,
@@ -295,9 +337,11 @@ function attachBase(rc: RunConfig, adapter: string, root: string, extra: Record<
}
/**
* The attach request of a Go / Python / Node.js remote configuration. Delve's headless
* The request of a Go / Python / Node.js / PHP remote configuration. Delve's headless
* server and `debugpy --listen` speak DAP themselves (`debugServer` connects there);
* js-debug runs here and connects to the inspector.
* js-debug runs here and connects to the inspector. PHP Remote Debug is the one
* `launch`: php-debug is the side that listens, and each Xdebug connection becomes
* a thread, so nothing attaches and no program is named.
*/
export function remoteAttachConfig(rc: RunConfig, root: string): LaunchConfig | null {
const p = rc.params ?? {};
@@ -306,32 +350,46 @@ export function remoteAttachConfig(rc: RunConfig, root: string): LaunchConfig |
const skip = workflowSettings().debugger.skipLibraryCode;
switch (rc.kind) {
case GO_REMOTE:
return attachBase(rc, "delve", root, {
return requestBase(rc, "delve", root, "attach", {
debugServer: serverAddress(host, portOf(p, 2345)),
mode: "remote",
...(map ? { substitutePath: [{ from: map.local, to: map.remote }] } : {}),
});
case PYTHON_REMOTE:
return attachBase(rc, "debugpy", root, {
return requestBase(rc, "debugpy", root, "attach", {
debugServer: serverAddress(host, portOf(p, 5678)),
justMyCode: skip,
...(map ? { pathMappings: [{ localRoot: map.local, remoteRoot: map.remote }] } : {}),
});
case GODOT_ATTACH:
return attachBase(rc, "godot", root, { debugServer: serverAddress(host, portOf(p, 6006)), project: root });
return requestBase(rc, "godot", root, "attach", {
debugServer: serverAddress(host, portOf(p, 6006)),
project: root,
});
case NODE_REMOTE:
return attachBase(rc, "js-debug", root, {
return requestBase(rc, "js-debug", root, "attach", {
type: "pwa-node",
address: host,
port: portOf(p, 9229),
...(map ? { localRoot: map.local, remoteRoot: map.remote } : {}),
skipFiles: skip ? ["<node_internals>/**", "**/node_modules/**"] : [],
});
case PHP_REMOTE:
return requestBase(rc, "php-debug", root, "launch", {
port: portOf(p, 9003),
...(map ? { pathMappings: { [map.remote]: map.local } } : {}),
});
default:
return null;
}
}
/** What a session of `rc` shows while nothing is suspended, when it waits for the debuggee to connect. */
export function remoteWaitingNote(rc: Pick<RunConfig, "kind" | "params"> | undefined): string | null {
if (rc?.kind !== PHP_REMOTE) return null;
return tx`Listening for Xdebug on port ${portOf(rc.params ?? {}, 9003)}`;
}
/** What a Remote JVM Debug configuration attaches to (before the listen mode relay). */
export function remoteJvmTarget(rc: RunConfig): { mode: JdwpMode; host: string; port: number; module: string } {
const p = rc.params ?? {};
+16
View File
@@ -0,0 +1,16 @@
/**
* Why a debug session didn't start, as the user reads it. An adapter that listens for
* the debuggee (php-debug waiting for Xdebug) fails with Node's
* `listen EADDRINUSE: address already in use :::9003` when another IDE holds the port.
*/
import { tx } from "@/platform/i18n";
const PORT_IN_USE = /\bEADDRINUSE\b.*:(\d+)\s*$/;
export function startFailureMessage(e: unknown): string {
const message = e instanceof Error ? e.message : String(e);
const port = PORT_IN_USE.exec(message)?.[1];
return port
? tx`Port ${port} is already in use: another IDE or debug session may be listening on it. Stop it or choose another port.`
: message;
}
+17 -1
View File
@@ -30,6 +30,7 @@ import { interpolateLog, logParts, suspends } from "./breakpoints/model";
import { syncSpecial, useSpecialBreakpoints } from "./breakpoints/special";
import { refreshExceptionFilters } from "./breakpoints/exceptions";
import { missingAdapter } from "./missingAdapter";
import { startFailureMessage } from "./startFailure";
export type SessionStatus = "starting" | "running" | "paused" | "terminated";
@@ -45,6 +46,8 @@ export interface DebugSession {
parent: DebugSessionId | null;
/** Launch configuration (root sessions), used by Rerun. */
launch: LaunchConfig | null;
/** The program runs in a terminal the IDE gave it: the console types into it while it runs. */
programInput?: boolean;
/** Process the session is attached to (a Run tab's build tool); stopped with the session. */
process: RunId | null;
status: SessionStatus;
@@ -605,7 +608,7 @@ export async function startSession(
group: "run",
level: "error",
title: `Cannot start debugger for ${launch.name}`,
body: e instanceof Error ? e.message : String(e),
body: startFailureMessage(e),
key: "debug.start",
});
return null;
@@ -944,6 +947,16 @@ export async function setVariableValue(
return result;
}
/** Whether the console types into the session's program (it runs, in a terminal the IDE gave it). */
export function readsProgramInput(s: DebugSession): boolean {
return !!s.programInput && s.status === "running";
}
/** Types `text` into the session's program; its terminal echoes it to the console. */
export async function sendProgramInput(session: DebugSession, text: string): Promise<void> {
await call(commands.debugSendInput(session.id, text));
}
export async function evaluate(
expression: string,
context: "repl" | "watch" | "hover" | "clipboard",
@@ -1022,6 +1035,9 @@ export function handleDebugEvent(id: DebugSessionId, event: DebugEvent) {
case "thread":
if (s.status === "paused") void loadThreads(id);
break;
case "programInput":
patch(id, { programInput: true });
break;
case "breakpointsChanged":
void refreshStates(event.path);
break;
+28 -3
View File
@@ -16,7 +16,16 @@ import {
type FindOptions,
type FindResult,
} from "@/ui/consoleFind";
import { appendConsole, clearConsole, evaluate, isLive, useDebug, type DebugSession } from "../state";
import {
appendConsole,
clearConsole,
evaluate,
isLive,
readsProgramInput,
sendProgramInput,
useDebug,
type DebugSession,
} from "../state";
import { pushHistory, readHistory, setConsoleHistoryBrowser } from "./expressionHistory";
import { useExpressionCompletion } from "./ExpressionCompletion";
import styles from "./DebugToolWindow.module.css";
@@ -245,6 +254,16 @@ export function DebugConsole({ session, interactive = true }: { session: DebugSe
}, [findOpen, current, virtualizer]);
const submit = async () => {
if (readsProgramInput(session)) {
// Like the Run console: the line as typed, empty or not, goes to the program.
setInput("");
try {
await sendProgramInput(session, `${input}\n`);
} catch (err) {
appendConsole(session.id, "stderr", `${err instanceof Error ? err.message : String(err)}\n`);
}
return;
}
const e = input.trim();
if (!e) return;
pushHistory("console", e);
@@ -466,10 +485,16 @@ export function DebugConsole({ session, interactive = true }: { session: DebugSe
value={input}
onChange={(e) => {
setInput(e.target.value);
completion.onInput();
if (!readsProgramInput(session)) completion.onInput();
}}
onKeyDown={onKeyDown}
placeholder={isLive(session) ? "Evaluate in the debug console" : "Session finished"}
placeholder={
!isLive(session)
? "Session finished"
: readsProgramInput(session)
? "Input for the program (pause to evaluate)"
: "Evaluate in the debug console"
}
disabled={!isLive(session)}
aria-label={tx("Debug console input")}
spellCheck={false}
+6 -1
View File
@@ -37,6 +37,7 @@ import {
useDebug,
type DebugSession,
} from "../state";
import { remoteWaitingNote } from "../remote";
import { DebugConsole } from "./DebugConsole";
import { VariablesView, type VarRoot } from "./VariablesView";
import { useExpressionCompletion } from "./ExpressionCompletion";
@@ -304,7 +305,11 @@ function FramesPane({ session }: { session: DebugSession }) {
onBlur={() => blurDebugView("frames")}
>
{!paused && (
<div className={styles.note}>{session.status === "terminated" ? tx("Finished") : tx("Running…")}</div>
<div className={styles.note}>
{session.status === "terminated"
? tx("Finished")
: (remoteWaitingNote(rootOf(session).runConfig) ?? tx("Running…"))}
</div>
)}
{items.map((item) =>
item.kind === "hidden" ? (
@@ -0,0 +1,206 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getContext, setContext } from "@/platform/context";
import { useProject } from "@/platform/project";
import { usePlugins } from "../store";
import {
affectsWorkspaceContains,
refreshWorkspaceContains,
startWorkspaceContains,
workspaceContainsKeys,
} from "../workspaceContains";
const ipc = vi.hoisted(() => ({
handlers: {} as Record<string, (e: unknown) => void>,
files: {} as Record<string, string[]>,
readDir: vi.fn(),
}));
vi.mock("@/platform/ipc", () => ({
call: (p: unknown) => p,
isTauri: () => true,
commands: { fsReadDir: (dir: string) => ipc.readDir(dir) },
onIdeEvent: (name: string, handler: (e: unknown) => void) => {
ipc.handlers[name] = handler;
return () => {
delete ipc.handlers[name];
};
},
}));
describe("workspaceContains", () => {
it("collects keys from when clauses", () => {
expect(
workspaceContainsKeys([
"workspaceContains:a/b.txt && editorFocus",
null,
"!workspaceContains:pom.xml",
"workspaceContains:a/b.txt",
]),
).toEqual(["workspaceContains:a/b.txt", "workspaceContains:pom.xml"]);
});
it("sets each key from its parent folder's listing", async () => {
const list = vi.fn(async (dir: string) => (dir === "/p/.devcontainer" ? ["devcontainer.json"] : ["src"]));
await refreshWorkspaceContains(
["/p"],
["workspaceContains:.devcontainer/devcontainer.json", "workspaceContains:pom.xml"],
list,
);
expect(getContext()["workspaceContains:.devcontainer/devcontainer.json"]).toBe(true);
expect(getContext()["workspaceContains:pom.xml"]).toBe(false);
expect(list).toHaveBeenCalledWith("/p");
});
it("is false without a project or when the folder can't be read", async () => {
const list = vi.fn(async (): Promise<string[]> => {
throw new Error("missing");
});
await refreshWorkspaceContains(["/p"], ["workspaceContains:x/y"], list);
expect(getContext()["workspaceContains:x/y"]).toBe(false);
setContext("workspaceContains:x/y", true);
list.mockClear();
await refreshWorkspaceContains([], ["workspaceContains:x/y"], list);
expect(list).not.toHaveBeenCalled();
expect(getContext()["workspaceContains:x/y"]).toBe(false);
});
it("is true when any root contains the path", async () => {
const list = vi.fn(async (dir: string) => (dir === "/b" ? ["pom.xml"] : []));
await refreshWorkspaceContains(["/a", "/b"], ["workspaceContains:pom.xml"], list);
expect(getContext()["workspaceContains:pom.xml"]).toBe(true);
});
it("ignores keys that are absolute or climb out of the project, and a trailing slash", async () => {
const list = vi.fn(async (dir: string) => (dir === "/p" ? [".devcontainer"] : []));
await refreshWorkspaceContains(
["/p"],
[
"workspaceContains:/etc/passwd",
"workspaceContains:../a",
"workspaceContains:a/../../b",
"workspaceContains:.devcontainer/",
],
list,
);
expect(list).toHaveBeenCalledTimes(1);
expect(list).toHaveBeenCalledWith("/p");
expect(getContext()["workspaceContains:/etc/passwd"]).toBe(false);
expect(getContext()["workspaceContains:../a"]).toBe(false);
expect(getContext()["workspaceContains:a/../../b"]).toBe(false);
expect(getContext()["workspaceContains:.devcontainer/"]).toBe(true);
});
it("only picks keys at a word boundary", () => {
expect(workspaceContainsKeys(["xworkspaceContains:../a", "a && workspaceContains:b"])).toEqual([
"workspaceContains:b",
]);
});
it("refreshes only when a watched path or one of its parents changes", () => {
const keys = ["workspaceContains:.devcontainer/devcontainer.json"];
const affects = (paths: string[], roots = ["/p"]) => affectsWorkspaceContains(paths, roots, keys);
expect(affects(["/p2/x"])).toBe(false);
expect(affects(["/p/src/a.ts"])).toBe(false);
expect(affects(["/p/.devcontainer"])).toBe(true);
expect(affects(["/p/.devcontainer/devcontainer.json"])).toBe(true);
expect(affects(["/q/.devcontainer"], ["/p", "/q"])).toBe(true);
expect(affects(["/p/.devcontainer"], [])).toBe(false);
});
it("ignores a stale refresh that resolves after a newer one", async () => {
const key = "workspaceContains:pom.xml";
let releaseOld: (names: string[]) => void = () => {};
let releaseNew: (names: string[]) => void = () => {};
const old = new Promise<string[]>((resolve) => (releaseOld = resolve));
const fresh = new Promise<string[]>((resolve) => (releaseNew = resolve));
let latest = 0;
const first = refreshWorkspaceContains(
["/a"],
[key],
() => old,
(
(t) => () =>
t === latest
)(++latest),
);
const second = refreshWorkspaceContains(
["/b"],
[key],
() => fresh,
(
(t) => () =>
t === latest
)(++latest),
);
releaseNew(["pom.xml"]);
await second;
releaseOld([]);
await first;
expect(getContext()[key]).toBe(true);
});
});
describe("startWorkspaceContains", () => {
const key = "workspaceContains:.devcontainer/devcontainer.json";
const setProject = (root: string) => useProject.setState({ project: { root, workspace: null } as never });
const keyIs = (value: boolean) => vi.waitFor(() => expect(getContext()[key]).toBe(value));
const emit = (name: string, event: unknown) => {
const handler = ipc.handlers[name];
if (!handler) {
throw new Error(`no handler for ${name}`);
}
handler(event);
};
let stop: () => void;
beforeEach(() => {
ipc.files = { "/p": ["src"], "/q": [".devcontainer"], "/q/.devcontainer": ["devcontainer.json"] };
ipc.readDir.mockReset();
ipc.readDir.mockImplementation(async (dir: string) => (ipc.files[dir] ?? []).map((name) => ({ name })));
usePlugins.setState({ ui: [{ pluginId: "x", point: "menus", key: "k", decl: { when: key } }] as never });
setProject("/p");
setContext(key, undefined);
stop = startWorkspaceContains();
});
afterEach(() => stop());
it("re-evaluates when the file is created and deleted", async () => {
await keyIs(false);
ipc.files["/p"] = [".devcontainer"];
ipc.files["/p/.devcontainer"] = ["devcontainer.json"];
emit("fsChanged", { created: ["/p/.devcontainer"], modified: [], removed: [] });
await keyIs(true);
ipc.files["/p/.devcontainer"] = [];
emit("fsChanged", { created: [], modified: [], removed: ["/p/.devcontainer/devcontainer.json"] });
await keyIs(false);
});
it("re-evaluates when the project changes", async () => {
await keyIs(false);
setProject("/q");
await keyIs(true);
});
it.each([["/p/.devcontainer"], ["/p"]])("re-evaluates when ignored folder %s changes", async (dir) => {
await keyIs(false);
ipc.files["/p/.devcontainer"] = ["devcontainer.json"];
emit("excludedDirsChanged", { dirs: [dir] });
await keyIs(true);
});
it("ignores unrelated changes", async () => {
await keyIs(false);
ipc.readDir.mockClear();
emit("fsChanged", { created: ["/p/src/a.ts"], modified: [], removed: [] });
emit("excludedDirsChanged", { dirs: ["/p/node_modules"] });
expect(ipc.readDir).not.toHaveBeenCalled();
});
it("stops listening when disposed", async () => {
await keyIs(false);
stop();
expect(ipc.handlers.fsChanged).toBeUndefined();
expect(ipc.handlers.excludedDirsChanged).toBeUndefined();
});
});
Loaded 100 of 146 files, more files were not shown because too many files have changed in this diff. Show more