Compare commits

...
Author SHA1 Message Date
l0ng-ai 8ec4032cf7 fix(vcs): let Commit and Shelf tree names use the whole row width
File and folder names in the Commit tool window's changes tree (and the Shelf tree, which shares the styles) were capped at 75% of the row, so a long folder name was cut with an ellipsis while a quarter of the row stayed empty, and the gap grew as the window widened. The name now takes the whole row; when it does not fit, the grey detail after it (the path or file count) is truncated first. Stash rows keep their previous shrinking.

Fixes #1367
2026-11-05 08:25:29 +08:00
l0ng-ai a0ac988963 fix(debugger): debug uv / poetry run configurations in the project's environment (#1395)
debugpy ran the project manager (`uv`, `poetry`, …) as a module. Configurations run through uv, poetry, pdm, pipenv, hatch or rye now pass the manager as the debuggee's interpreter, keep the script / -m module / tool as the target, and run the adapter and launcher on a plain interpreter (debugLauncherPython, now preferred by the backend for the adapter command).
2026-11-05 08:18:18 +08:00
l0ng-ai d5c4066748 feat(editor): colour global variables (the semantic global modifier) (#1393)
lua-language-server marks globals with the `global` token modifier, but
the editor dropped it, so a global variable looked like a local unless
rainbow highlighting was on. The modifier now sets `cm-sem-global`, drawn
in a new Language Defaults › Identifiers › Global variable colour
(`--syn-global`): library globals keep the predefined colour and calls the
function-call colour. Imported IntelliJ themes and .icls schemes take it
from DEFAULT_GLOBAL_VARIABLE. Built-in schemes default it to the local
variable colour (IntelliJ's fallback for the key), so they look as before
until a scheme sets it.

Fixes #1370
2026-11-05 07:59:45 +08:00
Pedro Pessoa 8d784ac164 feat(plugins): let plugin views open a project file at a line (#1362)
Plugin tool windows that list code locations (routes, failed tests,
queries) had no way to take the user to file:line. A host import
`views.open-location(path, line, column)` opens a file inside the project
in an editor of the window the call came from, caret at the given line and
column (0-based, UTF-16). It needs `project.read` and refuses paths outside
the project; the event goes only to the calling window, as `open-terminal`
does. The Rust SDK exposes it as `host::open_location` (with the mock), the
API table lists it, and the shipped components are rebuilt for the new WIT.

Closes #1334

Co-authored-by: yzPeedro <[email protected]>
2026-11-05 07:56:02 +08:00
Pedro Pessoa 7ffaa0b5da feat(plugins): let plugin tool windows use an SVG icon from the package (#1363)
A view's `icon` could only be a built-in icon name, so a plugin's tool
window button couldn't show the plugin's own mark. Like `fileIcons`, an
`icon` ending in `.svg` is now a path in the package: the host reads and
checks it at install time (a missing file or an SVG with `<script>` refuses
the install), serves it as the `viewIcon/<id>` asset, and the tool window
registers it as an external icon, falling back to the plugin icon if it
can't be loaded. Icon names work as before.

Closes #1335

Co-authored-by: yzPeedro <[email protected]>
2026-11-05 07:48:39 +08:00
l0ng-ai ab2b53ff80 fix(debugger): ignore a failed attach to a JVM that re-listened as its debugger detached (#1380)
A JDWP agent in server mode listens again, on a new port, when its debugger detaches. When the debugger detaches as the JVM exits (kotlin-debug-adapter on VM death, or Stop on a Maven/Gradle debug run), the announcement reached the Run feature, which attached again: the new session replaced the one that had just finished, then failed with "Cannot start debugger". An attach to a run's process within 3 s of a session on it ending now keeps the finished session until it succeeds, and a failure is logged instead of reported, its leftover session removed. Sessions record when they ended.
2026-11-05 07:45:32 +08:00
Daniel AdámekandDanielADK 2ba731f0ec feat(php): switch the PHP language level from the status bar (#1371)
With a PHP file focused, the status bar shows the PHP level Intelephense
checks against (`PHP 7.4`); the tooltip says where it comes from (the
settings, config.platform.php or require.php). Clicking it opens Auto (with
the composer.json version) and 5.6 to 8.5; picking one writes the same
project setting as Settings › Languages & Frameworks › PHP, so diagnostics
follow without reopening the project. The widget is hidden for other files.
For a file of a linked workspace project, the level comes from that
project's settings and the choices are disabled. The menu is also the
action "PHP Language Level".

Closes #1091

Co-authored-by: DanielADK <[email protected]>
2026-11-05 07:45:26 +08:00
devanddevadapter 742e504719 fix(terminal): keep IME commit gating after a composition (#1231)
Typing CJK punctuation such as `;` or `,` with fcitx5 in the Linux
AppImage sent each character twice: WebKitGTK commits it as
insertFromComposition + compositionend without compositionstart, so
xterm's CompositionHelper finalized the previous composition's stale
position and emitted a suffix. ImeCommitGate was meant to drop that second
emission, but compositionStart() disabled it for the rest of the session
and cancelled its rebase. The gate now works per commit: compositionstart
stands down only for its own composition, every keydown 229 starts a fresh
commit, and the textarea is rebased after each commit.

A keydown the IME did not take (Enter, an arrow, a letter outside the IME)
ends the open commit, so a key pressed right after a directly committed
punctuation mark is not dropped by the commit's timeout.

Closes #1230

Co-authored-by: devadapter <[email protected]>
2026-11-05 07:35:24 +08:00
l0ng-ai ebe059e00f fix(run): run a Python module that imports relatively as a module of its package (#1369)
Run or Debug from a Python file's context started it as a script
(`python src/tasks/cli.py`). A module of a package that imports relatively
then fails with "attempted relative import with no known parent package",
and the debugger stops on that exception.

A file with a relative import (`from .x import y`, `from . import x`) now
runs as `python -m <package>.<module>` from the folder its top package sits
in: the last `src` of its path, else the Python project's folder. A
package's `__main__.py` runs as the package. The project's interpreter is
kept (a relative venv path is adjusted to the new working directory), and
the debugger launches it with debugpy's `module`. Files without relative
imports run as scripts as before.

Fixes #1337
2026-11-05 07:30:13 +08:00
l0ng-ai e17e59ce7d fix(run): write Gradle init scripts on the host that runs the build (#1377)
The test-events and delegated-run init scripts were written to a path built from the desktop's temporary directory, which doesn't exist on a remote host. A new `run_gradle_init_script` command writes a script under the host's own temporary directory, named by its content, and returns the path Gradle reads it from.
2026-11-05 07:25:53 +08:00
l0ng-ai 8021a8ccca fix(debugger): evaluate console input as expressions under CodeLLDB (#1375)
CodeLLDB runs debug-console input as LLDB commands unless its console mode says otherwise, so Evaluate Expression, the Debug Console and breakpoint log messages failed with "'x' is not a valid command". Launch and attach requests now set `_adapterSettings.consoleMode` to `evaluate` unless the configuration picks a mode; LLDB commands still run with a leading backtick.
2026-11-05 07:06:56 +08:00
l0ng-ai 5a6a6f7e05 test(vcs): walk a fixture through edit, commit, branch, stash and a conflicting merge (#1360)
crates/ide-host/tests/vcs_flow.rs takes rust-app through a day with Git
via the ops the app's commands call: an unsaved edit marked in the
gutter against HEAD, Local Changes and the file's diff once saved, a
commit at the head of the log, a branch with its own change, checkout
back with the open file following, a stash pushed and popped, and a
merge that conflicts. Then the open file shows the conflict, the three
versions are offered, and Accept Theirs is committed as a merge.
2026-11-05 06:16:53 +08:00
l0ng-ai a7f0f8110b feat(php): built-in PHP live templates (#1372)
PHP files had no built-in live templates. A PHP group now ships with the
others (Settings › Editor › Live Templates, editable and switchable like
them):

- fore / forek: foreach over an iterable, as value or key => value, the
  caret starting on the iterable
- pubf, prof, prif and pubsf, prosf, prisf: public, protected or private
  method, static or not
- thr (throw new), eco (echo), inc / inco / rqr / rqro (include,
  include_once, require, require_once)

PHP variables are written `$$NAME$` in the bodies, so they expand with
their `$`.

Fixes #1234
2026-11-05 06:06:39 +08:00
l0ng-ai 9669e58398 feat(php): Intelephense stubs for required extensions, Additional stubs and Include paths (#1361)
Intelephense loads only its 68 default stub sets, so code using redis,
imagick, memcached, wordpress and the like showed "Undefined type", and
there was no way to point it at a platform's classes outside the project.

- Each ext-* in require or require-dev of the root composer.json adds its
  stub set, and Settings › PHP › Additional stubs adds others (wordpress).
  `stubs` replaces Intelephense's defaults, so the defaults are sent with
  the additions; nothing is sent when nothing is added.
- Settings › PHP › Include paths: folders (absolute, ~/ or relative to the
  project) sent as environment.includePaths.

Both apply on composer.json and settings changes without reopening the
project; Settings › Language Servers options still win.

Fixes #1232
Fixes #1233
2026-11-05 03:19:54 +08:00
l0ng-ai d939dca303 test(run): smoke-test running and testing each language's fixture (#1332)
* test(run): smoke-test running and testing each language's fixture

smoke/run.test.ts drives the frontend's run store against a real
openidea-server for TypeScript, Python, Rust, Go, Java and Kotlin: the
detected configurations, a run to its output, the tests with the
fixture's failing test switched on (counts, failure name, location and
message in the test tree), and Rerun Failed Tests running only that test.

`pnpm smoke` runs it; the Language smoke workflow runs it per language
after the code intelligence smoke.

* ci(language-smoke): let main's runs finish

Merges to main came faster than a run, so each one cancelled the run
before it: main never finished the smoke nor saved the build cache PRs
restore. Only a PR's runs are superseded now.
2026-11-05 03:02:01 +08:00
l0ng-ai 23378e0cc2 test(lsp): smoke-test parameter info, structure, auto-import, Optimize Imports and file moves (#1355)
The language smoke now also checks, per language and against the
server the app starts: parameter info in a call being typed, the
Structure view's symbols, completing a name that isn't imported yet
(the import comes with it, through the item's edits or its command),
Optimize Imports on a typed import, and moving a file updating the
files that reference it (where the server supports it).
2026-11-05 03:01:07 +08:00
l0ng-ai c83264ae75 fix(debugger): coloured Spring Boot output, Soft-Wrap and Scroll to End in the debug console (#1353)
Debugging a Spring Boot configuration showed its log uncoloured: Run adds
-Dspring.output.ansi.enabled=always (Spring only colours output for a
terminal, the process runs on pipes), the java-debug and Kotlin debug
launches built their VM options without it. They now add the same console
VM options as Run, unless the configuration sets the property itself.

The debug console also gets the Run console's Soft-Wrap toggle (default
from Settings › Console › Use soft wraps) and Scroll to End, which
follows new output, turns off when scrolling up and on again at the end.

Fixes #1262
2026-11-05 02:58:59 +08:00
104 changed files with 2802 additions and 169 deletions

No files matched your search

+17 -4
View File
@@ -2,6 +2,8 @@ name: Language smoke
# Everyday code intelligence (completion, go to definition, Find Usages, hover, diagnostics
# with a quick fix, an intention, formatting, rename) per language, against the language
# server the app starts by default: crates/ide-host/tests/language_smoke.rs.
# Then running and testing each fixture through the run store and openidea-server
# (Run, Run tests, the test tree, Rerun Failed Tests): smoke/run.test.ts.
on:
push:
branches: [main]
@@ -14,12 +16,18 @@ on:
- "fixtures/**"
- "Cargo.toml"
- "Cargo.lock"
- "src/features/run/**"
- "src/platform/**"
- "smoke/**"
- "package.json"
- "pnpm-lock.yaml"
- ".github/workflows/language-smoke.yml"
workflow_dispatch:
concurrency:
group: language-smoke-${{ github.ref }}
cancel-in-progress: true
# A new push supersedes a PR's run; on main every merge runs to the end (and saves the cache).
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
CARGO_TERM_COLOR: always
@@ -53,9 +61,10 @@ jobs:
shared-key: language-smoke
save-if: ${{ github.ref == 'refs/heads/main' && matrix.language == 'rust' }}
- if: matrix.language == 'typescript'
uses: actions/setup-node@v4
with: { node-version: 22 }
- uses: pnpm/action-setup@v4
with: { version: 11 }
- uses: actions/setup-node@v4
with: { node-version: 22, cache: pnpm }
- if: matrix.language == 'typescript'
run: npm install --global "@vtsls/language-server@$VTSLS" "typescript@$TYPESCRIPT"
@@ -95,3 +104,7 @@ jobs:
- run: cargo test --locked -p ide-host --test language_smoke --no-run
- run: cargo test --locked -p ide-host --test language_smoke -- --ignored --exact ${{ matrix.language }}
- run: pnpm install --frozen-lockfile
- run: cargo build --locked -p ide-host --bin openidea-server
- run: pnpm smoke smoke/run.test.ts -t "^${{ matrix.language }}$"
+2
View File
@@ -69,6 +69,8 @@ Shared agent skills live in `.agents/skills/` (`.claude/skills/` links to them):
- `cargo nextest run --workspace` (or `cargo test`), `cargo clippy --workspace --all-targets -- -D warnings`, `cargo fmt --all`.
- `cargo test -p ide-host --test language_smoke -- --ignored --exact <language>` — code intelligence
on `fixtures/` with that language's real server installed (CI: `Language smoke`, pinned versions).
- `pnpm smoke smoke/run.test.ts -t <language>` — run and test that language's fixture through the
run store and a real `openidea-server` (build it first: `cargo build -p ide-host --bin openidea-server`).
- `pnpm test`, `pnpm e2e`, `pnpm lint`, `pnpm format:check`, `pnpm knip`, `pnpm typecheck`,
`pnpm gen:bindings`.
- `pnpm perf` (UI interaction latency / frames / leaks), `pnpm perf:rust` (criterion
+6
View File
@@ -716,6 +716,12 @@ pub struct PhpSettings {
/// PHP version the language server checks the code against (`7.4`); empty = from
/// `composer.json` (`config.platform.php`, else the lowest version `require.php` allows).
pub language_level: String,
/// Folders outside the project whose code the language server indexes too (a platform a
/// module extends); absolute, `~/`, or relative to the project.
pub include_paths: Vec<String>,
/// Intelephense stub sets loaded on top of its defaults and the `ext-*` entries of
/// `composer.json` (`wordpress`).
pub stubs: Vec<String>,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
+18 -6
View File
@@ -589,11 +589,12 @@ impl DebugManager {
if let Some(command) = custom_command(&spec.command) {
spec.command = command;
}
// debugpy: run the adapter with the debuggee's interpreter (venvs carry their own debugpy).
// debugpy: run the adapter with the debuggee's interpreter (venvs carry their own debugpy),
// or with the launcher's when the debuggee runs through a manager (`uv run python`).
if config.adapter == "debugpy" {
let python = match arguments.get("python") {
Some(Value::String(p)) => Some(p.clone()),
Some(Value::Array(a)) => a.first().and_then(Value::as_str).map(str::to_owned),
let python = match (arguments.get("debugLauncherPython"), arguments.get("python")) {
(Some(Value::String(p)), _) | (_, Some(Value::String(p))) => Some(p.clone()),
(_, Some(Value::Array(a))) => a.first().and_then(Value::as_str).map(str::to_owned),
_ => None,
};
if let Some(python) = python {
@@ -651,8 +652,19 @@ impl DebugManager {
{
arguments["project"] = json!(real);
}
if config.adapter == "codelldb" && arguments.get("sourceLanguages").is_none() {
arguments["sourceLanguages"] = json!(["rust"]);
if config.adapter == "codelldb" {
if arguments.get("sourceLanguages").is_none() {
arguments["sourceLanguages"] = json!(["rust"]);
}
// The Debug Console and Evaluate Expression take expressions, as with every other
// debugger; LLDB commands still run with a leading backtick.
match arguments.get_mut("_adapterSettings") {
None => arguments["_adapterSettings"] = json!({"consoleMode": "evaluate"}),
Some(Value::Object(settings)) => {
settings.entry("consoleMode").or_insert_with(|| json!("evaluate"));
}
Some(_) => {}
}
}
let port = debug_server.map(|s| s.port);
self.start_session(id, owner, config.name, config.adapter, None, config.request, arguments, spec, port).await
+31
View File
@@ -698,6 +698,37 @@ async fn js_debug_launch_gets_port_and_workspace_folder() {
assert_eq!(launch["type"], "pwa-node");
}
/// CodeLLDB treats console input as LLDB commands unless told otherwise: Evaluate Expression
/// and the Debug Console send expressions, so its console evaluates them. A launch
/// configuration that picks a console mode keeps it.
#[tokio::test]
async fn codelldb_console_evaluates_expressions() {
let launch_with = |extra: Value| async move {
let h = harness(Script::default());
let config = LaunchConfig {
name: "demo".into(),
adapter: "codelldb".into(),
request: "launch".into(),
program: Some("/p/target/debug/demo".into()),
args: vec![],
cwd: Some("/p".into()),
env: BTreeMap::new(),
extra_json: Some(extra.to_string()),
};
h.start(config).await.unwrap();
h.request("launch")["arguments"].clone()
};
let launch = launch_with(json!({})).await;
assert_eq!(launch["_adapterSettings"]["consoleMode"], "evaluate");
assert_eq!(launch["sourceLanguages"], json!(["rust"]));
let launch =
launch_with(json!({"_adapterSettings": {"consoleMode": "commands", "showDisassembly": "never"}})).await;
assert_eq!(launch["_adapterSettings"], json!({"consoleMode": "commands", "showDisassembly": "never"}));
let launch = launch_with(json!({"_adapterSettings": {"showDisassembly": "never"}})).await;
assert_eq!(launch["_adapterSettings"], json!({"consoleMode": "evaluate", "showDisassembly": "never"}));
}
/// An adapter that is already listening (java-debug inside jdtls): the client
/// connects over TCP to the `debugServer` port instead of spawning anything.
#[tokio::test]
+18
View File
@@ -142,6 +142,21 @@ pub async fn run_gradle_jvm_args_script(_host: &Host, _window: &str, jvm_args: V
.map_err(|e| IdeError::internal(e.to_string()))?
}
/// Writes one of the IDE's Gradle init scripts (`text`; the file is named `<name>-<hash>`) on
/// the machine that runs the build, for `--init-script`, and returns its path.
pub async fn run_gradle_init_script(_host: &Host, _window: &str, name: String, text: String) -> IdeResult<String> {
if name.is_empty() || !name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-') {
return Err(IdeError::invalid(format!("invalid init script name {name:?}")));
}
tokio::task::spawn_blocking(move || {
ide_run::gradle_jvm::write_script(&ide_run::gradle_jvm::default_dir(), &name, &text)
.map(|p| p.to_string_lossy().into_owned())
.map_err(|e| IdeError::internal(format!("cannot write the Gradle init script: {e}")))
})
.await
.map_err(|e| IdeError::internal(e.to_string()))?
}
/// Results from the JUnit XML reports (Surefire, Failsafe, Gradle) of the module at
/// `module` (relative to the project root) written since `since_ms` (Unix epoch, ms).
pub async fn run_test_reports(
@@ -308,6 +323,9 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("run_gradle_jvm_args_script", |c| async move {
run_gradle_jvm_args_script(&c.host, &c.window, c.arg("jvmArgs")?).await
});
d.add("run_gradle_init_script", |c| async move {
run_gradle_init_script(&c.host, &c.window, c.arg("name")?, c.arg("text")?).await
});
d.add("run_test_reports", |c| async move {
run_test_reports(&c.host, &c.window, c.arg("module")?, c.arg("sinceMs")?).await
});
+40
View File
@@ -34,6 +34,42 @@ pub fn sdk_python_effective(host: &Host, window: &str) -> IdeResult<Option<ide_c
Ok(ide_sdk::apply::python_interpreter(&project.root, &project.settings.effective()).map(Into::into))
}
/// The PHP language level of the project `path` belongs to (a workspace member, by path, as
/// the language server configuration picks it).
#[derive(Clone, Debug, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct PhpLanguageLevels {
/// The one Intelephense checks against; `None`: its default.
pub effective: Option<ide_sdk::php::PhpLanguageLevel>,
/// The one Auto picks, from `composer.json`.
pub auto: Option<ide_sdk::php::PhpLanguageLevel>,
/// The workspace member `path` belongs to: its own settings choose the level, not the
/// workspace's. `None`: the opened project.
pub member: Option<ide_core::IpcPath>,
}
pub async fn sdk_php_language_level(host: &Host, window: &str, path: PathBuf) -> IdeResult<PhpLanguageLevels> {
let project = host.project(window)?;
let (project_root, project_settings) = (project.root.clone(), project.settings.effective());
let config_dir = host.config_dir.clone();
tokio::task::spawn_blocking(move || {
let member = ide_workspace::workspace::Workspace::load(&project_root).ok().flatten().and_then(|w| {
w.projects.into_iter().filter(|p| path.starts_with(p)).max_by_key(|p| p.components().count())
});
let (root, settings) = match &member {
Some(member) => (member, ide_workspace::SettingsStore::load(&config_dir, Some(member)).effective()),
None => (&project_root, project_settings),
};
PhpLanguageLevels {
effective: ide_sdk::php::language_level(root, &settings),
auto: ide_sdk::php::composer_level(root),
member: member.map(ide_core::IpcPath),
}
})
.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>> {
@@ -144,6 +180,10 @@ pub async fn sdk_python_pip(
pub(crate) fn register(d: &mut Dispatcher) {
d.add("sdk_detect", |c| async move { sdk_detect(&c.host, &c.window, c.arg("kind")?).await });
d.add("sdk_python_effective", |c| async move { sdk_python_effective(&c.host, &c.window) });
d.add(
"sdk_php_language_level",
|c| async move { sdk_php_language_level(&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
+11
View File
@@ -667,6 +667,17 @@ impl HostApi for PluginApi {
Ok(())
}
fn open_location(&self, client: Option<&str>, path: &Path, line: u32, column: u32) -> Result<(), HostError> {
let event = PluginEvent::OpenLocation {
plugin_id: self.id().to_owned(),
path: path.to_path_buf().into(),
line,
column,
};
self.shared.sink.emit(client, event);
Ok(())
}
fn setting(&self, root: Option<&Path>, key: &str) -> Option<String> {
let schema: Vec<super::ui_points::SettingSchema> = self
.shared
@@ -114,6 +114,7 @@ pub fn forwarded(event: &PluginEvent) -> bool {
| PluginEvent::ViewChanged { .. }
| PluginEvent::ViewMessage { .. }
| PluginEvent::OpenTerminal { .. }
| PluginEvent::OpenLocation { .. }
)
}
+2
View File
@@ -192,6 +192,8 @@ pub enum PluginEvent {
args: Vec<String>,
cwd: ide_core::IpcPath,
},
#[serde(rename_all = "camelCase")]
OpenLocation { plugin_id: String, path: ide_core::IpcPath, line: u32, column: u32 },
/// Plugin languages, or the servers they may start, changed: re-fetch
/// `languages` (the host re-detects open documents: [`crate::Host::plugin_languages_changed`]).
LanguagesChanged,
+10
View File
@@ -378,9 +378,19 @@ pub(super) fn view(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
}
let mut out = UiDecl::new(d.id.clone(), d.title.clone(), json(&d)?);
out.files = files;
if let Some(icon) = d.icon.as_deref().filter(|i| i.ends_with(".svg")) {
let path = std::path::PathBuf::from(icon);
let svg = super::icons::read_icon(pkg, &path)?;
out.assets.push((view_icon_asset(&d.id), svg));
out.files.push(path);
}
Ok(out)
}
pub fn view_icon_asset(id: &str) -> String {
format!("viewIcon/{id}")
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct CustomEditorDecl {
+282 -2
View File
@@ -1,6 +1,7 @@
//! Language smoke tests: the everyday code intelligence of each language — completion, go to
//! definition, Find Usages, hover, diagnostics with a quick fix, an intention, rename and
//! formatting — on its `fixtures/` project, through the `ops` the app's commands call and the
//! definition, Find Usages, hover, diagnostics with a quick fix, an intention, rename,
//! formatting, parameter info, the Structure view, auto-import, Optimize Imports and moving a
//! file — on its `fixtures/` project, through the `ops` the app's commands call and the
//! language server the app starts by default.
//!
//! `#[ignore]`d because they need the servers installed. CI's `Language smoke` job installs
@@ -68,6 +69,35 @@ struct Checks {
indent: Indent,
/// Renaming the symbol to the new name edits these files.
rename: (At, &'static str, &'static [&'static str]),
/// Parameter info in a call being typed: a signature naming this parameter.
signature: (Typed, &'static str),
/// The Structure view of a file: these symbols, at any depth.
outline: (&'static str, &'static [&'static str]),
/// Completing a name the file doesn't import yet also imports it.
auto_import: AutoImport,
/// Optimize Imports, with an import typed after a line.
organize_imports: Option<(Typed, Imports)>,
/// Moving a file updates its references: (file, new path, a file that then contains this).
file_move: Option<(&'static str, &'static str, &'static str, &'static str)>,
}
#[derive(Clone, Copy, Debug)]
struct AutoImport {
/// The line typed, ending in the start of the name.
typed: Typed,
/// The completion item.
label: &'static str,
/// What the item's import adds to the file.
result: &'static str,
}
/// What Optimize Imports does with the typed import.
#[derive(Clone, Copy, Debug)]
enum Imports {
/// Removes it (unused).
Removed,
/// Sorts it before this line.
Before(&'static str),
}
#[derive(Clone, Copy, Debug)]
@@ -432,6 +462,146 @@ impl Smoke {
}
}
async fn signature(&self, call: Typed, parameter: &str) {
let (doc, pos, before) = self.type_line(call).await;
self.retry("parameter info", self.warm_up, || async {
let found = ops::lsp::lsp_signature_help(&self.host, WINDOW, doc, pos).await;
match &found {
Ok(Some(h)) if h.signatures.iter().any(|s| s.label.contains(parameter)) => Ok(()),
_ => Err(found.map(|h| h.map(|h| h.signatures.into_iter().map(|s| s.label).collect::<Vec<_>>()))),
}
})
.await;
self.restore(doc, &before).await;
}
async fn outline(&self, file: &str, symbols: &[&str]) {
let (doc, _) = self.doc(file).await;
fn names(items: &[ide_core::OutlineItem], out: &mut BTreeSet<String>) {
for item in items {
out.insert(item.name.clone());
names(&item.children, out);
}
}
self.retry("structure", self.warm_up, || async {
let found = ops::doc::doc_outline(&self.host, WINDOW, doc).await;
let mut shown = BTreeSet::new();
if let Ok(items) = &found {
names(items, &mut shown);
}
if symbols.iter().all(|s| shown.contains(*s)) { Ok(()) } else { Err(shown) }
})
.await;
}
async fn auto_import(&self, a: AutoImport) {
let (doc, pos, before) = self.type_line(a.typed).await;
assert!(!before.contains(a.result), "{a:?}: the file already contains the result");
self.retry("auto-import completion", self.warm_up, || async {
let list = ops::lsp::lsp_completion(&self.host, WINDOW, RequestId(6), doc, pos, None).await;
let items = match &list {
Ok(l) => l.items.iter().filter(|i| i.label == a.label).cloned().collect::<Vec<_>>(),
Err(e) => return Err(format!("{e}")),
};
let text = self.doc(a.typed.file).await.1;
let mut imports = Vec::new();
for item in items {
// Servers compute the import when the item is resolved (on selection).
let item = if item.additional_edits.is_empty() {
ops::lsp::lsp_resolve_completion(&self.host, WINDOW, doc, item.clone()).await.unwrap_or(item)
} else {
item
};
let mut document = ide_text::Document::new(&text);
if document.apply_edits(&item.additional_edits).is_ok() && document.text().contains(a.result) {
return Ok(());
}
// Or accepting it runs a server command that edits the file (kotlin-lsp).
if let Some(command) = item.command.clone() {
let ran = ops::lsp::lsp_execute_command(&self.host, WINDOW, doc, command).await;
let text = self.doc(a.typed.file).await.1;
if ran.is_ok() && text.contains(a.result) {
return Ok(());
}
}
imports.push(format!("{:?} {:?}", item.additional_edits, item.command));
}
let labels: Vec<String> = list.map(|l| l.items.into_iter().take(20).map(|i| i.label).collect()).unwrap();
Err(format!("{:?} items' imports: {imports:?}; items: {labels:?}", a.label))
})
.await;
self.restore(doc, &before).await;
}
async fn organize_imports(&self, import: Typed, expect: Imports) {
let (doc, _, before) = self.type_line(import).await;
let typed = import.text.trim();
let organized = |text: &str| match expect {
Imports::Removed => !text.lines().any(|l| l.trim() == typed),
Imports::Before(line) => {
let at = |needle: &str| text.lines().position(|l| l.trim() == needle);
matches!((at(typed), at(line)), (Some(a), Some(b)) if a < b)
}
};
self.retry("optimize imports", self.warm_up, || async {
let text = self.doc(import.file).await.1;
// The whole file, as Optimize Imports asks (positions past the end fail on jdtls).
let last = text.split('\n').count() as u32 - 1;
let whole = Range::new(Pos::new(0, 0), Pos::new(last, utf16_len(text.rsplit('\n').next().unwrap_or(""))));
let kind = "source.organizeImports".to_string();
let found = ops::lsp::lsp_code_actions(&self.host, WINDOW, doc, whole, vec![kind.clone()]).await;
let actions: Vec<_> = match &found {
Ok(a) => a.iter().filter(|a| a.kind.as_ref().is_some_and(|k| k.starts_with(&kind))).cloned().collect(),
Err(e) => return Err(format!("{e}")),
};
let Some(action) = actions.iter().find(|a| a.is_preferred).or(actions.first()).cloned() else {
return Err(format!(
"no {kind} action: {:?}",
found.map(|a| a.into_iter().map(|a| a.title).collect::<Vec<_>>())
));
};
ops::lsp::lsp_apply_code_action(&self.host, WINDOW, doc, action).await.map_err(|e| e.to_string())?;
let text = self.doc(import.file).await.1;
if organized(&text) { Ok(()) } else { Err(text) }
})
.await;
self.restore(doc, &before).await;
}
async fn file_move(&self, from: &str, to: &str, referencing: &str, result: &str) {
// The servers know the referencing file (opened, as in an editor).
let _ = self.doc(referencing).await;
let moves = vec![ide_workspace::FileMove { from: self.path(from).into(), to: self.path(to).into() }];
let edit = self
.retry("move file", self.warm_up, || async {
let found =
ops::refactor::refactor_file_move_edits(&self.host, WINDOW, RequestId(7), moves.clone()).await;
let want = self.path(referencing);
match &found {
Ok(e)
if e.edit
.files
.iter()
.any(|f| matches!(f, ide_core::FileEdit::Edit { path, .. } if **path == *want)) =>
{
Ok(e.edit.clone())
}
_ => Err(found),
}
})
.await;
ops::refactor::refactor_move_files(&self.host, WINDOW, moves, edit, "Move".into())
.await
.unwrap_or_else(|e| self.fail("move file", e));
if !self.path(to).is_file() {
self.fail("move file", format_args!("{to} doesn't exist after the move"));
}
let text = self.doc(referencing).await.1;
if !text.contains(result) {
self.fail("move file", format_args!("{referencing} has no {result:?} after the move:\n{text}"));
}
}
async fn run(&self, c: &Checks) {
self.definition(c.definition.0, c.definition.1).await;
self.references(c.references.0, c.references.1).await;
@@ -440,7 +610,17 @@ impl Smoke {
self.quick_fix(c.quick_fix).await;
self.intention(c.intention.0, c.intention.1).await;
self.format(c.format.0, c.indent, c.format.1).await;
self.signature(c.signature.0, c.signature.1).await;
self.outline(c.outline.0, c.outline.1).await;
self.auto_import(c.auto_import).await;
if let Some((import, expect)) = c.organize_imports {
self.organize_imports(import, expect).await;
}
self.rename(c.rename.0, c.rename.1, c.rename.2).await;
// Last: it moves a file the other checks use.
if let Some((from, to, referencing, result)) = c.file_move {
self.file_move(from, to, referencing, result).await;
}
let shutdown = self.host.project(WINDOW).unwrap().lsp.clone();
let _ = tokio::time::timeout(Duration::from_secs(20), shutdown.shutdown_all()).await;
}
@@ -480,6 +660,22 @@ const TYPESCRIPT: Checks = Checks {
"formatPrice",
&["src/index.ts", "src/money.ts"],
),
signature: (typed("src/index.ts", "const total = cart.total();", " cart.add("), "product"),
outline: ("src/cart.ts", &["Cart", "add", "total"]),
auto_import: AutoImport {
typed: typed("src/index.ts", "const total = cart.total();", " addMon"),
label: "addMoney",
result: "addMoney",
},
organize_imports: Some((
typed(
"src/index.ts",
"import { cents, formatMoney } from \"./money.ts\";",
"import { readFileSync } from \"node:fs\";",
),
Imports::Removed,
)),
file_move: Some(("src/money.ts", "src/lib/money.ts", "src/index.ts", "\"./lib/money.ts\"")),
};
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
@@ -521,6 +717,19 @@ const PYTHON: Checks = Checks {
"TaskBoard",
&["src/tasks/cli.py", "src/tasks/models.py"],
),
signature: (typed("src/tasks/cli.py", "tasks = build_demo()", " tasks.add("), "title"),
outline: ("src/tasks/models.py", &["Priority", "Task", "TaskList", "pending"]),
auto_import: AutoImport {
typed: typed("src/tasks/cli.py", "tasks = build_demo()", " stamp = datetim"),
label: "datetime",
result: "import datetime",
},
organize_imports: Some((
typed("src/tasks/models.py", "from enum import IntEnum", "import abc"),
Imports::Before("import os"),
)),
// basedpyright has no `workspace/willRenameFiles`.
file_move: None,
};
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
@@ -558,6 +767,16 @@ const RUST: Checks = Checks {
),
indent: Indent::Spaces(4),
rename: (at("src/parser.rs", "pub fn parse(text: &str)", "parse"), "parse_all", &["src/main.rs", "src/parser.rs"]),
signature: (typed("src/main.rs", "let mut inventory = Inventory::new();", " inventory.take("), "sku"),
outline: ("src/lib.rs", &["Inventory", "Item", "take"]),
auto_import: AutoImport {
typed: typed("src/main.rs", "let mut inventory = Inventory::new();", " let _seen = HashSe"),
label: "HashSet",
result: "use std::collections::HashSet;",
},
// rust-analyzer has no `source.organizeImports`.
organize_imports: None,
file_move: Some(("src/parser.rs", "src/csv.rs", "src/lib.rs", "pub mod csv;")),
};
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
@@ -597,6 +816,16 @@ const GO: Checks = Checks {
"Open",
&["cmd/server/main.go", "internal/store/store.go"],
),
signature: (typed("cmd/server/main.go", "s := store.New()", "\ts.Create("), "title"),
outline: ("internal/store/store.go", &["Store", "New", "(*Store).Create"]),
auto_import: AutoImport {
typed: typed("cmd/server/main.go", "s := store.New()", "\t_ = strings.ToUpp"),
label: "ToUpper",
result: "\t\"strings\"",
},
organize_imports: Some((typed("cmd/server/main.go", "\"strconv\"", "\t\"sort\""), Imports::Removed)),
// gopls renames packages, not files: moving a Go file changes no references.
file_move: None,
};
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
@@ -653,6 +882,37 @@ const JAVA: Checks = Checks {
"submit",
&["src/main/java/com/example/orders/App.java", "src/main/java/com/example/orders/OrderBook.java"],
),
signature: (
typed(
"src/main/java/com/example/orders/App.java",
"long revenue = book.revenueCents();",
" book.place(",
),
"customer",
),
outline: (
"src/main/java/com/example/orders/OrderBook.java",
&["OrderBook", "place(String, String, int, long)", "revenueCents()"],
),
auto_import: AutoImport {
typed: typed(
"src/main/java/com/example/orders/App.java",
"long revenue = book.revenueCents();",
" ArrayLis",
),
label: "ArrayList",
result: "import java.util.ArrayList;",
},
organize_imports: Some((
typed("src/main/java/com/example/orders/OrderBook.java", "import java.util.Optional;", "import java.util.Map;"),
Imports::Removed,
)),
file_move: Some((
"src/main/java/com/example/orders/OrderBook.java",
"src/main/java/com/example/orders/Ledger.java",
"src/main/java/com/example/orders/App.java",
"Ledger book",
)),
};
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
@@ -705,6 +965,26 @@ const KOTLIN: Checks = Checks {
"find",
&["src/main/kotlin/com/example/library/App.kt", "src/main/kotlin/com/example/library/Library.kt"],
),
signature: (
typed("src/main/kotlin/com/example/library/App.kt", "val library = Library()", " library.lend("),
"isbn",
),
outline: ("src/main/kotlin/com/example/library/Library.kt", &["Library", "lend", "search"]),
auto_import: AutoImport {
typed: typed(
"src/main/kotlin/com/example/library/App.kt",
"val library = Library()",
" val stamp = LocalDateTi",
),
label: "LocalDateTime",
result: "import java.time.LocalDateTime",
},
organize_imports: Some((
typed("src/main/kotlin/com/example/library/App.kt", "import java.time.LocalDate", "import java.util.UUID"),
Imports::Removed,
)),
// kotlin-lsp has no `workspace/willRenameFiles`.
file_move: None,
};
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+77
View File
@@ -2045,6 +2045,83 @@ fn pinned_programs_fill_views_and_open_terminals() {
assert_eq!(args, ["follow", "build", "--lines=5"]);
}
#[test]
fn views_open_project_files_at_a_line() {
let f = fixture(Limits::default());
let info = f.install(&sample("jobs"));
assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error);
std::fs::write(f.project.join("jobs.log"), "build\ndeploy\nfailed\n").unwrap();
let press = |button: &str| {
f.take_events();
let result = f.host.view_event("jobs", "jobs", ide_plugin::ViewEvent::Button { id: button.into() }, f.inv());
let opened = f.take_events().into_iter().find_map(|(client, e)| match e {
PluginEvent::OpenLocation { path, line, column, .. } => Some((client, path.0, line, column)),
_ => None,
});
(result, opened)
};
let (result, opened) = press("log");
assert!(result.is_ok(), "{result:?}");
let (client, path, line, column) = opened.expect("a location");
assert_eq!((client.as_deref(), line, column), (Some("main"), 2, 4));
assert_eq!(path.canonicalize().unwrap(), f.project.join("jobs.log").canonicalize().unwrap());
let (result, opened) = press("outside");
assert!(result.is_err(), "a path outside the project is refused");
assert!(opened.is_none());
f.host.set_capability("jobs", "project.read", false).unwrap();
let (result, opened) = press("log");
assert!(result.unwrap_err().message.contains("project.read"), "needs project.read");
assert!(opened.is_none());
}
#[test]
fn views_can_use_an_svg_icon_from_the_package() {
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
let clean = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect width="16" height="16" fill="#08f"/></svg>"##;
let package = |dir: &str, icon: &str, svg: &str| {
let pkg = tmp.path().join(dir);
for (path, text) in [("panel/index.html", "<p>panel</p>"), ("icons/panel.svg", svg)] {
std::fs::create_dir_all(pkg.join(path).parent().unwrap()).unwrap();
std::fs::write(pkg.join(path), text).unwrap();
}
std::fs::write(
pkg.join("plugin.toml"),
format!(
r#"id = "panel-icon"
name = "Panel Icon"
version = "1.0.0"
engines.openidea = "^1.0"
[[contributes.views]]
id = "panel"
title = "Panel"
icon = "{icon}"
location = "bottom"
web = "panel/index.html"
"#
),
)
.unwrap();
pkg
};
assert!(f.host.install_from_dir(&package("missing", "icons/missing.svg", clean), &Consent::all()).is_err());
let scripted = clean.replace("<rect", "<script>alert(1)</script><rect");
assert!(f.host.install_from_dir(&package("scripted", "icons/panel.svg", &scripted), &Consent::all()).is_err());
let info = f.install(&package("clean", "icons/panel.svg", clean));
assert!(info.error.is_none(), "{:?}", info.error);
let view = f.host.ui_items("views").into_iter().find(|v| v.key == "panel").expect("the view");
assert_eq!(view.decl["icon"].as_str(), Some("icons/panel.svg"));
let assets = f.host.assets("panel-icon", "viewIcon/panel").unwrap();
assert_eq!(assets.len(), 1);
assert!(assets[0].1.contains("<rect"));
}
/// A plugin with web views and no code: its pages and their files are
/// installed with it and served only from the declared directories, while
/// it is enabled and only for the installed version; file icons and
+16
View File
@@ -141,3 +141,19 @@ async fn workspace_cmake_before_launch_tasks_resolve_in_the_member() {
assert!(configs.iter().any(|c| c.id == task.target && c.cwd == build.cwd));
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn gradle_init_scripts_are_written_by_the_host_that_runs_the_build() {
let tmp = tempfile::tempdir().unwrap();
let host = host(tmp.path());
let text = "allprojects { }\n".to_string();
let path =
ops::run::run_gradle_init_script(&host, "main", "openidea-gradle-tests".into(), text.clone()).await.unwrap();
assert!(path.contains("openidea-gradle-tests-"), "{path}");
assert_eq!(std::fs::read_to_string(&path).unwrap(), text);
let again = ops::run::run_gradle_init_script(&host, "main", "openidea-gradle-tests".into(), text).await.unwrap();
assert_eq!(again, path);
// A name is not a path.
let escaped = ops::run::run_gradle_init_script(&host, "main", "../escape".into(), String::new()).await;
assert!(escaped.is_err(), "{escaped:?}");
}
+209
View File
@@ -0,0 +1,209 @@
//! A day with Git on a `fixtures/` project, through the `ops` the app's commands call: an edit
//! marked in the gutter, Local Changes and its diff, a commit in the log, a branch, a stash, and
//! a merge that conflicts, resolved and committed. Open documents follow every step.
use ide_core::DocId;
use ide_host::{Host, HostConfig, ops};
use ide_vcs::{ChangeKind, CommitOptions, FileStatus, HunkKind, LogQuery};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::Arc;
use std::time::Duration;
const WINDOW: &str = "main";
/// The line every branch changes, in `src/lib.rs`.
const LINE: &str = "pub fn format_price(cents: u64) -> String {";
fn git(root: &Path, args: &[&str]) -> String {
let out = Command::new("git").current_dir(root).args(args).output().unwrap();
assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
String::from_utf8(out.stdout).unwrap()
}
fn copy_dir(from: &Path, to: &Path) {
std::fs::create_dir_all(to).unwrap();
for entry in std::fs::read_dir(from).unwrap().flatten() {
let (src, dst) = (entry.path(), to.join(entry.file_name()));
if entry.file_type().unwrap().is_dir() {
copy_dir(&src, &dst);
} else {
std::fs::copy(&src, &dst).unwrap();
}
}
}
struct Flow {
host: Arc<Host>,
root: PathBuf,
_tmp: tempfile::TempDir,
}
impl Flow {
async fn new(fixture: &str) -> Self {
let tmp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(tmp.path()).unwrap();
let root = base.join(fixture);
copy_dir(&Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures").join(fixture), &root);
git(&root, &["init", "-q", "-b", "main"]);
for (key, value) in [
("user.name", "Test"),
("user.email", "[email protected]"),
("commit.gpgsign", "false"),
("core.autocrlf", "false"),
] {
git(&root, &["config", key, value]);
}
git(&root, &["add", "-A"]);
git(&root, &["commit", "-q", "-m", "Initial commit"]);
let 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(|_| {})),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
}));
host.trust.decide(&root, true, false).unwrap();
let project = ops::project::open(&host, WINDOW, &root).await.unwrap();
for _ in 0..500 {
if !project.repos().is_empty() {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
Flow { host, root, _tmp: tmp }
}
async fn open(&self, file: &str) -> DocId {
ops::doc::open(&self.host, WINDOW, self.root.join(file)).await.unwrap().id
}
async fn text(&self, doc: DocId) -> String {
ops::doc::snapshot(&self.host, WINDOW, doc).await.unwrap().text
}
/// Types `with` over `LINE` in the editor (unsaved).
async fn edit_line(&self, doc: DocId, with: &str) {
let text = self.text(doc).await;
let current = text.lines().find(|l| l.starts_with("pub fn format_price")).unwrap().to_owned();
let project = self.host.project(WINDOW).unwrap();
project.documents.replace_all(doc, &text.replacen(&current, with, 1)).unwrap();
}
async fn save(&self, doc: DocId) {
ops::doc::save(&self.host, WINDOW, doc).await.unwrap();
}
async fn status(&self) -> Vec<FileStatus> {
ops::vcs::status(&self.host, WINDOW).await.unwrap()
}
async fn commit(&self, files: &[&str], message: &str) -> String {
let paths = files.iter().map(|f| self.root.join(f)).collect();
ops::vcs::vcs_stage(&self.host, WINDOW, paths).await.unwrap();
let options = CommitOptions { run_hooks: true, ..Default::default() };
ops::vcs::vcs_commit(&self.host, WINDOW, None, message.into(), options).await.unwrap()
}
async fn head(&self) -> ide_vcs::Commit {
let log = ops::vcs::vcs_log(&self.host, WINDOW, None, LogQuery::default()).await.unwrap();
log.into_iter().next().expect("a commit")
}
}
fn changed<'a>(status: &'a [FileStatus], file: &str) -> Option<&'a FileStatus> {
status.iter().find(|s| s.rel_path == file)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn change_commit_branch_stash_and_merge_a_conflict() {
let f = Flow::new("rust-app").await;
let lib_path = f.root.join("src/lib.rs");
let lib = f.open("src/lib.rs").await;
let original = f.text(lib).await;
let line = original.lines().position(|l| l == LINE).expect("the fixture's line") as u32;
// An edit in the editor: a modified line in the gutter, against HEAD, before saving.
let base = "pub fn format_price(cents: u64) -> String { // base";
f.edit_line(lib, base).await;
let hunks = ops::vcs::vcs_line_hunks(&f.host, WINDOW, lib_path.clone()).await.unwrap();
assert!(
matches!(&hunks[..], [h] if h.kind == HunkKind::Modified && h.start == line && h.old_text.contains(LINE)),
"{hunks:?}"
);
assert!(f.status().await.is_empty(), "nothing saved yet");
// Saved: Local Changes lists it, and its diff shows the edit.
f.save(lib).await;
let status = f.status().await;
assert_eq!(changed(&status, "src/lib.rs").and_then(|s| s.unstaged), Some(ChangeKind::Modified), "{status:?}");
let diff = ops::vcs::vcs_diff_text(&f.host, WINDOW, lib_path.clone(), false).await.unwrap();
assert!(diff.contains(&format!("-{LINE}")) && diff.contains(&format!("+{base}")), "{diff}");
// Commit: the log's head; nothing left to commit; no gutter marks.
let id = f.commit(&["src/lib.rs"], "Mark the base").await;
let head = f.head().await;
assert_eq!((head.id.as_str(), head.summary.as_str()), (id.as_str(), "Mark the base"));
assert!(f.status().await.is_empty());
assert!(ops::vcs::vcs_line_hunks(&f.host, WINDOW, lib_path.clone()).await.unwrap().is_empty());
// A branch changes the line its way.
ops::vcs::vcs_create_branch(&f.host, WINDOW, None, "feature".into(), None, true).await.unwrap();
let theirs = "pub fn format_price(cents: u64) -> String { // feature";
f.edit_line(lib, theirs).await;
f.save(lib).await;
f.commit(&["src/lib.rs"], "Feature's change").await;
let branches = ops::vcs::vcs_branches(&f.host, WINDOW, None).await.unwrap();
assert!(branches.iter().any(|b| b.name == "feature" && b.current), "{branches:?}");
// Back on main, the open file shows main's text.
ops::vcs::vcs_checkout(&f.host, WINDOW, None, "main".into(), false, None).await.unwrap();
assert!(f.text(lib).await.contains(base));
// Stash: work in progress put aside and brought back.
let main_rs = f.open("src/main.rs").await;
let wip = format!("{}// work in progress\n", f.text(main_rs).await);
f.host.project(WINDOW).unwrap().documents.replace_all(main_rs, &wip).unwrap();
f.save(main_rs).await;
ops::vcs::vcs_stash_push(&f.host, WINDOW, None, Some("wip".into()), false, false).await.unwrap();
assert!(f.status().await.is_empty());
assert!(!f.text(main_rs).await.contains("work in progress"));
let stashes = ops::vcs::vcs_stashes(&f.host, WINDOW, None).await.unwrap();
assert!(matches!(&stashes[..], [s] if s.message.contains("wip")), "{stashes:?}");
ops::vcs::vcs_stash_apply(&f.host, WINDOW, None, 0, true, false, None).await.unwrap();
assert_eq!(f.text(main_rs).await, wip);
assert!(ops::vcs::vcs_stashes(&f.host, WINDOW, None).await.unwrap().is_empty());
ops::vcs::vcs_discard(&f.host, WINDOW, vec![f.root.join("src/main.rs")]).await.unwrap();
assert!(f.status().await.is_empty());
// Main changes the same line: merging the branch conflicts.
let ours = "pub fn format_price(cents: u64) -> String { // main";
f.edit_line(lib, ours).await;
f.save(lib).await;
f.commit(&["src/lib.rs"], "Main's change").await;
let merged = ops::vcs::vcs_merge(&f.host, WINDOW, None, "feature".into()).await;
let status = f.status().await;
let conflicted = changed(&status, "src/lib.rs").map(|s| (s.staged, s.unstaged));
assert!(
matches!(conflicted, Some((Some(ChangeKind::Conflicted), _) | (_, Some(ChangeKind::Conflicted)))),
"merge: {merged:?}; status: {status:?}"
);
// The open file shows the conflict as Git left it.
assert!(f.text(lib).await.contains("<<<<<<<"), "{}", f.text(lib).await);
let versions = ops::vcs::vcs_conflict_versions(&f.host, WINDOW, lib_path.clone()).await.unwrap();
let has = |side: &Option<String>, line: &str| side.as_deref().is_some_and(|t| t.contains(line));
assert!(has(&versions.base, base) && has(&versions.ours, ours) && has(&versions.theirs, theirs), "{versions:?}");
// Resolved (Accept Theirs) and committed: a merge commit, nothing left.
let resolved = versions.theirs.clone().unwrap();
f.host.project(WINDOW).unwrap().documents.replace_all(lib, &resolved).unwrap();
f.save(lib).await;
f.commit(&["src/lib.rs"], "Merge feature").await;
let head = f.head().await;
assert_eq!(head.parents.len(), 2, "{head:?}");
assert!(f.status().await.is_empty());
assert_eq!(std::fs::read_to_string(&lib_path).unwrap(), resolved);
}
+51
View File
@@ -114,3 +114,54 @@ async fn linked_projects_share_navigation_but_keep_files_repositories_and_run_di
assert_eq!(automatic.index.find_files("same.txt", 100).len(), 1);
host.close_project("main");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn php_language_level_follows_the_member_of_the_file() {
use ide_sdk::php::{PhpLanguageLevel, PhpLevelSource};
let temp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(temp.path()).unwrap();
let (api, legacy) = (base.join("api"), base.join("legacy"));
for (member, composer) in [
(&api, r#"{"require": {"php": "^7.4"}}"#),
(&legacy, r#"{"require": {"php": "^7.4"}, "config": {"platform": {"php": "8.0.5"}}}"#),
] {
std::fs::create_dir(member).unwrap();
std::fs::write(member.join("composer.json"), composer).unwrap();
}
let root = base.join("workspace");
Workspace::save(&root, "PHP", vec![api.clone(), legacy.clone()], true).unwrap();
std::fs::write(root.join("composer.json"), r#"{"require": {"php": "^8.2"}}"#).unwrap();
let config_dir = base.join("config");
ide_workspace::SettingsStore::load(&config_dir, Some(&legacy))
.set(ide_workspace::SettingsScope::Project, "languages.php.languageLevel", "8.3".into())
.unwrap();
let host = Host::new(HostConfig {
config_dir,
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
host.trust.decide(&root, true, false).unwrap();
host.open_project("main", &root).unwrap();
let found = |version: &str, source| Some(PhpLanguageLevel { version: version.into(), source });
let level = async |path: std::path::PathBuf| ops::sdk::sdk_php_language_level(&host, "main", path).await.unwrap();
let level_of_api = level(api.join("src/Invoice.php")).await;
assert_eq!(level_of_api.effective, found("7.4.0", PhpLevelSource::Require));
assert_eq!(level_of_api.auto, level_of_api.effective);
assert_eq!(level_of_api.member.as_deref(), Some(api.as_path()));
// The member's own settings, not the workspace's.
let level_of_legacy = level(legacy.join("index.php")).await;
assert_eq!(level_of_legacy.effective, found("8.3.0", PhpLevelSource::Settings));
assert_eq!(level_of_legacy.auto, found("8.0.5", PhpLevelSource::Platform));
// Outside every member: the workspace's own.
let level_elsewhere = level(base.join("elsewhere.php")).await;
assert_eq!(level_elsewhere.effective, found("8.2.0", PhpLevelSource::Require));
assert_eq!(level_elsewhere.member, None);
host.close_project("main");
}
+1
View File
@@ -105,6 +105,7 @@ pub trait HostApi: Send + Sync {
args: Vec<String>,
cwd: Option<&Path>,
) -> Result<(), HostError>;
fn open_location(&self, client: Option<&str>, path: &Path, line: u32, column: u32) -> Result<(), HostError>;
/// A setting of the plugin as JSON (project over global over the schema default).
fn setting(&self, root: Option<&Path>, key: &str) -> Option<String>;
}
+15
View File
@@ -640,6 +640,21 @@ impl views::Host for StoreData {
let title = clip(title, crate::runtime::MAX_TITLE);
Ok(self.host.open_terminal(client.as_deref(), title, &program, args, cwd.as_deref()).map_err(to_wit))
}
fn open_location(&mut self, path: String, line: u32, column: u32) -> Fallible<()> {
if let Err(e) = self.require(Capability::ProjectRead) {
return Ok(Err(e));
}
let Some(root) = self.invocation.workspace_root.clone() else {
return Ok(Err(wit::Error::Invalid("no project is open in this window".into())));
};
let real = match sandbox::resolve_in_root(&root, &path) {
Ok(real) => real,
Err(e) => return Ok(Err(wit::Error::Invalid(e))),
};
let client = self.invocation.client.clone();
Ok(self.host.open_location(client.as_deref(), &real, line, column).map_err(to_wit))
}
}
impl settings::Host for StoreData {
Binary file not shown.
@@ -1 +1 @@
a50d1eb41ab8dd0c9c8d6bae8266906cd6eb5de8e6f6ca6f681990389472bb5b
1f7402e2b1b4602e4073eb61837c3dc8840f9a4f7275d800dbd6fcf6ce5e40c3
Binary file not shown.
@@ -1 +1 @@
2fb6aa879562733bf5b7f11dabe8fc6a415c9ef695868fe7845417a4245d2dc8
4f6cd5b5e0c12e7dcbccf66a6ad97276bf968bc3ad26f0fd08e1f7765cbfee14
+1
View File
@@ -12,6 +12,7 @@ activation = ["on-view:jobs", "on-command:*"]
[capabilities]
required = ["process:jobs"]
optional = ["project.read"]
[[contributes.views]]
id = "jobs"
+2
View File
@@ -64,6 +64,8 @@ impl Views for Jobs {
return self.follow();
}
ViewEvent::Button(b) if b == "follow" => return self.follow(),
ViewEvent::Button(b) if b == "log" => return host::open_location("jobs.log", 2, 4),
ViewEvent::Button(b) if b == "outside" => return host::open_location("../outside.log", 0, 0),
_ => return Ok(()),
}
host::refresh_view(VIEW);
@@ -1 +1 @@
25b0f419a68a3b1651d3c2774d834b05e40ede52bc5ed8c0eba6d27a204efe82
3d4dd1b6365c7fbafdd3c1f99f4405e5ee40fc5e56c97da960adfe7af2e7edb8
+11 -2
View File
@@ -612,9 +612,18 @@ pub fn configure_server(cfg: &mut LanguageServerConfig, root: &Path, server_root
// Intelephense reads it as `intelephense.environment.phpVersion` (`workspace/configuration`).
if name == "intelephense"
&& let Some(version) = crate::php::language_level(root, s)
&& let Some(level) = crate::php::language_level(root, s)
{
merge_init_options(cfg, json!({ "environment": { "phpVersion": version } }));
merge_init_options(cfg, json!({ "environment": { "phpVersion": level.version } }));
}
if name == "intelephense" {
if let Some(stubs) = crate::php::intelephense_stubs(root, s) {
merge_init_options(cfg, json!({ "stubs": stubs }));
}
let include_paths = crate::php::include_paths(root, s);
if !include_paths.is_empty() {
merge_init_options(cfg, json!({ "environment": { "includePaths": include_paths } }));
}
}
// Node-based servers (`#!/usr/bin/env node`) run on the selected Node.js.
+177 -11
View File
@@ -1,24 +1,53 @@
//! The PHP language level: the one chosen in Settings › PHP, else the one the root
//! `composer.json` declares (`config.platform.php`, else the lowest version `require.php` allows);
//! and the Intelephense storage that goes with it.
//! the Intelephense storage that goes with it; and the stub sets and include paths Intelephense indexes.
use crate::apply::{merge, merge_init_options};
use crate::apply::{merge, merge_init_options, setting_path};
use ide_core::settings::Settings;
use ide_language::LanguageServerConfig;
use serde_json::{Value, json};
use std::path::Path;
/// The project's PHP version as `X.Y.Z`; `None` when neither the settings nor
/// `composer.json` name one.
pub fn language_level(root: &Path, s: &Settings) -> Option<String> {
/// A PHP version and where it comes from.
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct PhpLanguageLevel {
/// `X.Y.Z`.
pub version: String,
pub source: PhpLevelSource,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub enum PhpLevelSource {
/// `languages.php.languageLevel`.
Settings,
/// `config.platform.php` of `composer.json`.
Platform,
/// The lowest version `require.php` of `composer.json` allows.
Require,
}
/// The project's PHP version: the one chosen in the settings, else [`composer_level`];
/// `None` when neither names one.
pub fn language_level(root: &Path, s: &Settings) -> Option<PhpLanguageLevel> {
let explicit = s.languages.php.language_level.trim();
if !explicit.is_empty() {
return version(explicit).map(dotted);
return version(explicit).map(|v| PhpLanguageLevel::new(v, PhpLevelSource::Settings));
}
composer_level(root)
}
/// The PHP version the root `composer.json` declares (the setting's Auto):
/// `config.platform.php`, else the lowest version `require.php` allows.
pub fn composer_level(root: &Path) -> Option<PhpLanguageLevel> {
let text = std::fs::read_to_string(root.join("composer.json")).ok()?;
let composer: Value = serde_json::from_str(&text).ok()?;
let platform = composer.pointer("/config/platform/php").and_then(Value::as_str).and_then(version);
platform.or_else(|| lowest_allowed(composer.pointer("/require/php")?.as_str()?)).map(dotted)
if let Some(platform) = composer.pointer("/config/platform/php").and_then(Value::as_str).and_then(version) {
return Some(PhpLanguageLevel::new(platform, PhpLevelSource::Platform));
}
lowest_allowed(composer.pointer("/require/php")?.as_str()?)
.map(|v| PhpLanguageLevel::new(v, PhpLevelSource::Require))
}
/// Gives each PHP version its own Intelephense workspace storage (below `data_dir`).
@@ -48,10 +77,74 @@ pub fn configure_intelephense_storage(cfg: &mut LanguageServerConfig, data_dir:
merge_init_options(cfg, json!({ "storagePath": storage.to_string_lossy() }));
}
/// The stub sets Intelephense loads when `stubs` is not set (Intelephense 1.18.5). Setting
/// `stubs` replaces them, so additions are sent together with these.
#[rustfmt::skip]
const DEFAULT_STUBS: &[&str] = &[
"apache", "bcmath", "bz2", "calendar", "com_dotnet", "Core", "ctype", "curl", "date", "dba", "dom", "enchant",
"exif", "fileinfo", "filter", "fpm", "ftp", "gd", "hash", "iconv", "imap", "intl", "json", "ldap", "libxml",
"mbstring", "mcrypt", "mssql", "mysqli", "oci8", "odbc", "openssl", "pcntl", "pcre", "PDO", "pgsql", "Phar",
"posix", "pspell", "random", "readline", "Reflection", "regex", "session", "shmop", "SimpleXML", "snmp", "soap",
"sockets", "sodium", "SPL", "sqlite3", "standard", "superglobals", "sybase", "sysvmsg", "sysvsem", "sysvshm",
"tidy", "tokenizer", "uri", "xml", "xmlreader", "xmlrpc", "xmlwriter", "Zend OPcache", "zip", "zlib",
];
/// Stub sets outside the defaults whose names are not lower case; the rest are the lower-case
/// extension name.
const MIXED_CASE_STUBS: &[&str] =
&["Ev", "FFI", "LuaSandbox", "Parle", "SQLite", "SaxonC", "SplType", "ZendCache", "ZendDebugger", "ZendUtils"];
/// `Zend OPcache`, `zend-opcache` and `zend_opcache` name the same stubs.
fn stub_key(name: &str) -> String {
name.chars().filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_lowercase()).collect()
}
/// The `stubs` Intelephense loads: its defaults plus a stub set for each `ext-*` that the root
/// `composer.json` requires (`require`, `require-dev`) and those in Settings › PHP. `None` when
/// that adds nothing to the defaults, so the server keeps its own.
pub fn intelephense_stubs(root: &Path, s: &Settings) -> Option<Vec<String>> {
let composer: Option<Value> =
std::fs::read_to_string(root.join("composer.json")).ok().and_then(|t| serde_json::from_str(&t).ok());
let extensions = ["require", "require-dev"]
.into_iter()
.filter_map(|section| composer.as_ref()?.get(section)?.as_object())
.flat_map(|deps| deps.keys())
.filter_map(|dep| dep.strip_prefix("ext-"))
.map(|ext| {
let key = stub_key(ext);
MIXED_CASE_STUBS
.iter()
.find(|stub| stub_key(stub) == key)
.map_or(ext.to_ascii_lowercase(), |s| s.to_string())
});
let configured = s.languages.php.stubs.iter().map(|stub| stub.trim().to_owned());
let mut stubs: Vec<String> = DEFAULT_STUBS.iter().map(|s| s.to_string()).collect();
let mut known: std::collections::HashSet<String> = stubs.iter().map(|s| stub_key(s)).collect();
for stub in extensions.chain(configured) {
if !stub.is_empty() && known.insert(stub_key(&stub)) {
stubs.push(stub);
}
}
(stubs.len() > DEFAULT_STUBS.len()).then_some(stubs)
}
/// The folders in Settings › PHP › Include paths, resolved against the project.
pub fn include_paths(root: &Path, s: &Settings) -> Vec<String> {
s.languages
.php
.include_paths
.iter()
.filter_map(|p| setting_path(root, p))
.map(|p| p.to_string_lossy().into_owned())
.collect()
}
type Version = (u32, u32, u32);
fn dotted((major, minor, patch): Version) -> String {
format!("{major}.{minor}.{patch}")
impl PhpLanguageLevel {
fn new((major, minor, patch): Version, source: PhpLevelSource) -> Self {
Self { version: format!("{major}.{minor}.{patch}"), source }
}
}
/// `7`, `7.4`, `v7.4.3`, `8.1.*`, `7.4.0-dev`: missing parts are 0.
@@ -105,13 +198,18 @@ mod tests {
use super::*;
fn level(composer: Option<&str>, explicit: &str) -> Option<String> {
levels(composer, explicit).0.map(|l| l.version)
}
/// The effective level and the composer one.
fn levels(composer: Option<&str>, explicit: &str) -> (Option<PhpLanguageLevel>, Option<PhpLanguageLevel>) {
let tmp = tempfile::tempdir().unwrap();
if let Some(composer) = composer {
std::fs::write(tmp.path().join("composer.json"), composer).unwrap();
}
let mut s = Settings::default();
s.languages.php.language_level = explicit.into();
language_level(tmp.path(), &s)
(language_level(tmp.path(), &s), composer_level(tmp.path()))
}
fn required(constraint: &str) -> Option<String> {
@@ -201,6 +299,74 @@ mod tests {
}
}
fn stubs(composer: Option<Value>, configured: &[&str]) -> Option<Vec<String>> {
let tmp = tempfile::tempdir().unwrap();
if let Some(composer) = composer {
std::fs::write(tmp.path().join("composer.json"), composer.to_string()).unwrap();
}
let mut s = Settings::default();
s.languages.php.stubs = configured.iter().map(|s| s.to_string()).collect();
intelephense_stubs(tmp.path(), &s)
}
fn added(stubs: Option<Vec<String>>) -> Vec<String> {
let stubs = stubs.expect("stubs");
assert_eq!(stubs[..DEFAULT_STUBS.len()], DEFAULT_STUBS.iter().map(|s| s.to_string()).collect::<Vec<_>>());
stubs[DEFAULT_STUBS.len()..].to_vec()
}
#[test]
fn required_extensions_and_configured_stubs_follow_the_defaults() {
let composer = json!({
"require": {"php": "^8.1", "ext-redis": "*", "ext-json": "*", "monolog/monolog": "^3"},
"require-dev": {"ext-Imagick": "*", "ext-ffi": "*", "ext-redis": "*"}
});
assert_eq!(
added(stubs(Some(composer), &[" wordpress ", "Redis", ""])),
["redis", "imagick", "FFI", "wordpress"]
);
assert_eq!(added(stubs(None, &["wordpress"])), ["wordpress"]);
}
#[test]
fn nothing_beyond_the_defaults_sends_nothing() {
assert_eq!(stubs(None, &[]), None);
assert_eq!(stubs(Some(json!({"require": {"php": "^8.1"}})), &[]), None);
// Default stub sets under their extension names: `ext-pdo`, `ext-zend-opcache`, `ext-simplexml`.
let defaults =
json!({"require": {"ext-pdo": "*", "ext-zend-opcache": "*", "ext-simplexml": "*", "ext-json": "*"}});
assert_eq!(stubs(Some(defaults), &["SPL", "core"]), None);
assert_eq!(stubs(Some(json!("not an object")), &[]), None);
}
#[test]
fn include_paths_resolve_against_the_project() {
let root = Path::new("/work/module");
let mut s = Settings::default();
s.languages.php.include_paths = vec!["../platform".into(), "/opt/prestashop".into(), " ".into()];
assert_eq!(
include_paths(root, &s),
[root.join("../platform").to_string_lossy().into_owned(), "/opt/prestashop".to_string()]
);
}
#[test]
fn each_level_names_its_source() {
let found = |version: &str, source| Some(PhpLanguageLevel { version: version.into(), source });
let require = r#"{"require": {"php": "^7.4"}}"#;
let platform = r#"{"require": {"php": "^7.4"}, "config": {"platform": {"php": "8.2.1"}}}"#;
let require_level = found("7.4.0", PhpLevelSource::Require);
assert_eq!(levels(Some(require), ""), (require_level.clone(), require_level.clone()));
let platform_level = found("8.2.1", PhpLevelSource::Platform);
assert_eq!(levels(Some(platform), ""), (platform_level.clone(), platform_level));
// An explicit level: the composer one is still what Auto would pick.
assert_eq!(levels(Some(require), "8.1"), (found("8.1.0", PhpLevelSource::Settings), require_level));
assert_eq!(levels(None, "8.1"), (found("8.1.0", PhpLevelSource::Settings), None));
assert_eq!(levels(None, ""), (None, None));
assert_eq!(levels(Some(r#"{"require": {"php": "*"}}"#), ""), (None, None));
}
#[test]
fn explicit_level_wins() {
let composer = r#"{"require": {"php": "^7.4"}, "config": {"platform": {"php": "7.4.33"}}}"#;
+24
View File
@@ -1325,6 +1325,30 @@ fn intelephense_gets_the_php_language_level() {
assert_eq!(options(cfg), json!({"clearCache": true, "environment": {"phpVersion": "8.1.0"}}));
}
#[test]
fn intelephense_gets_the_required_stubs_and_include_paths() {
use serde_json::json;
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("composer.json"), r#"{"require": {"php": "^8.1", "ext-redis": "*"}}"#).unwrap();
let mut s = Settings::default();
s.languages.php.stubs = vec!["wordpress".into()];
s.languages.php.include_paths = vec!["../platform".into()];
let mut cfg = server("intelephense");
configure_server(&mut cfg, tmp.path(), tmp.path(), &s);
let options = serde_json::to_value(cfg.initialization_options).unwrap();
let stubs: Vec<&str> = options["stubs"].as_array().unwrap().iter().filter_map(|v| v.as_str()).collect();
assert_eq!(stubs.len(), 70);
assert_eq!((stubs[0], &stubs[68..]), ("apache", &["redis", "wordpress"][..]));
assert_eq!(
options["environment"],
json!({"phpVersion": "8.1.0", "includePaths": [tmp.path().join("../platform").to_string_lossy()]})
);
let mut other = server("phpactor");
configure_server(&mut other, tmp.path(), tmp.path(), &s);
assert_eq!(other.initialization_options, None);
}
#[test]
fn go_constraints_change_only_protocol_settings() {
use serde_json::json;
+3 -1
View File
@@ -20,7 +20,9 @@ The fixtures are standalone: `rust-app` declares its own `[workspace]` and
The language smoke test (`crates/ide-host/tests/language_smoke.rs`) runs completion,
navigation, diagnostics, quick fixes, formatting and rename on these projects with real
language servers: when you change a fixture, run it for that language.
language servers, and `smoke/run.test.ts` runs each project's run and test configurations
(the intentionally failing test included, then Rerun Failed Tests): when you change a
fixture, run both for that language.
## Setup
+1
View File
@@ -9,6 +9,7 @@ const config: KnipConfig = {
"scripts/**/*.ts",
"perf/**/*.ts",
"e2e/**/*.ts",
"smoke/**/*.ts",
],
// Plugins' web pages are projects of their own (own package.json, checked by plugins/build.sh and CI).
ignore: ["plugins/**/web-src/**"],
+2 -1
View File
@@ -12,11 +12,12 @@
"tauri": "node scripts/tauri.ts",
"test": "vitest run",
"test:watch": "vitest",
"smoke": "vitest run --config smoke/vitest.config.ts",
"lint": "oxlint",
"format": "oxfmt",
"format:check": "oxfmt --check",
"knip": "knip",
"typecheck": "tsrs --noEmit && tsrs -p tsconfig.node.json --noEmit && tsrs -p perf --noEmit && tsrs -p scripts --noEmit",
"typecheck": "tsrs --noEmit && tsrs -p tsconfig.node.json --noEmit && tsrs -p perf --noEmit && tsrs -p scripts --noEmit && tsrs -p smoke --noEmit",
"gen:bindings": "cargo test -p openidea --test export_bindings",
"gen:windows-icons": "node scripts/gen-windows-icons.ts",
"gen:angular-grammar": "lezer-generator src/editor/core/cm/angular/angular.grammar --typeScript -o src/editor/core/cm/angular/parser.ts",
+18 -17
View File
@@ -125,23 +125,23 @@ work), but 0.1 code no longer runs: rebuild it against the 1.0 SDK.
### Contribution points
| Point | Declaration | Code needed |
| --------------- | -------------------------------------------------------------------------------------------------- | ------------- |
| `commands` | `id`, `title`, `category?`, `when?` | to run them |
| `themes` | `path` (theme JSON), `editor-scheme?` | no |
| `iconThemes` | `id`, `label`, `path` (icon theme JSON, see §5) | no |
| `languages` | `path` (`languages/<id>.toml` format, see §6) | no |
| `runDetectors` | `id`, `name`, `files`, `command`, `args?`, `test?` (§6.2) | no |
| `providers` | `kinds`, `languages` (editor providers, see _Providers_) | yes |
| `secrets` | `name`, `label`, `description?` (see _Network_) | to use them |
| `menus` | `menu` (`editor.context`, `projectView.context`, `editorTab.context`, `tools`), `command`, `when?` | no |
| `keymaps` | `command`, `key`, `mac?` (`Ctrl+Alt+C`; a chord: `Meta+K Meta+C`) | no |
| `statusBar` | `id`, `text`, `tooltip?`, `command?`, `align?`, `priority?` | no |
| `settings` | `schema` (JSON file), `title?` — one table, see _Settings_ | no |
| `views` | `id`, `title`, `icon?`, `location` (`left`, `right`, `bottom`), `web?` (see _Web views_) | without `web` |
| `customEditors` | `id`, `title`, `patterns` (file names), `priority` (`default`, `option`), `layout?`, `web?` | without `web` |
| `fileIcons` | `id`, `patterns` (file names), `icon` (SVG), `iconDark?` — whatever icon theme is in use | no |
| `fileTemplates` | `id`, `name`, `extension`, `path` (the new file's content; template variables work) — New menu | no |
| Point | Declaration | Code needed |
| --------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------- |
| `commands` | `id`, `title`, `category?`, `when?` | to run them |
| `themes` | `path` (theme JSON), `editor-scheme?` | no |
| `iconThemes` | `id`, `label`, `path` (icon theme JSON, see §5) | no |
| `languages` | `path` (`languages/<id>.toml` format, see §6) | no |
| `runDetectors` | `id`, `name`, `files`, `command`, `args?`, `test?` (§6.2) | no |
| `providers` | `kinds`, `languages` (editor providers, see _Providers_) | yes |
| `secrets` | `name`, `label`, `description?` (see _Network_) | to use them |
| `menus` | `menu` (`editor.context`, `projectView.context`, `editorTab.context`, `tools`), `command`, `when?` | no |
| `keymaps` | `command`, `key`, `mac?` (`Ctrl+Alt+C`; a chord: `Meta+K Meta+C`) | no |
| `statusBar` | `id`, `text`, `tooltip?`, `command?`, `align?`, `priority?` | no |
| `settings` | `schema` (JSON file), `title?` — one table, see _Settings_ | no |
| `views` | `id`, `title`, `icon?` (an icon name, or an SVG of the package), `location` (`left`, `right`, `bottom`), `web?` (see _Web views_) | without `web` |
| `customEditors` | `id`, `title`, `patterns` (file names), `priority` (`default`, `option`), `layout?`, `web?` | without `web` |
| `fileIcons` | `id`, `patterns` (file names), `icon` (SVG), `iconDark?` — whatever icon theme is in use | no |
| `fileTemplates` | `id`, `name`, `extension`, `path` (the new file's content; template variables work) — New menu | no |
Declared contributions are live as soon as the plugin is enabled — commands
show in Find Action, menus and the status bar, shortcuts work, themes and icon
@@ -313,6 +313,7 @@ then run `openidea-plugin check`.
| `host::refresh_view(view)` | `views.refresh` | The view (or custom editors) render again; call it when the content changed. |
| `host::post_view_message(view, text)` | `views.post-message` | To the script of the view's `html` body. |
| `host::open_terminal(title, program, args)` | `views.open-terminal` | A terminal tab in the invoking window running the approved `process:<program>` in the project root: output that keeps coming, interactive sessions. |
| `host::open_location(path, line, column)` | `views.open-location` | Opens a file inside the project in an editor of the invoking window, the caret at `line`/`column` (0-based, UTF-16): a view's links to code. Needs `project.read`. |
| `host::setting(key)`, `setting_bool`, `setting_string`, `setting_number` | `settings.get` | The user's value (project over global) or the schema's default. |
`run-command` receives an optional string argument: `executeAction("plugin.<plugin id>.<command id>", "arg")`
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
28e4bf10a41bdda011111b0e150ebe96205c57f410bad98c19eea3f1286349d6
9ad412cb7b853d40467c67305a1126bb87910d179ecfa1c88e13d4a900367c50
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
7e015486cc916f5edbac542863373fd7204f5bcd534f6c408d5f9d2b72913862
49a3c6bf2511ba137457397203244bdfca3d6e10f62dbc7fde9473dab07f6457
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
a6cf59e74595e13908e2cbf1752c58665c6963de258413c9d7332d2a7dd9cbd0
c199dc52e00d3943882d532e7e6095f7b2f8d7d6026cf1ccaa685dd6300f2319
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
b8dde206a74b222b93e61056d4cbe403617ae240d142e01e91420a2aacdb2da4
6047be5eeaa1330d7aa1f182e88ae2f352f244829ad9a01fdc3a2a081bdca95d
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
a681c2abd4a65d6aeb62efd514e9b170f4fca52b0c14d2d380a3b04be0a3fbc6
7a3ee410d228d1e39155aa042237d222d23d7f5a45e261086cffc45c6ee1ad32
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
3c83bc9559df54457e19e650cdd21bab9d609e22daaa1fc4f596910b70834a34
7658a95eb9fc27468cae4b1d20e3b5ab299fde478e07f69ab0fdb4cc10caf11d
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
63f1bbdf6eca5a4642cd3453ae384ff3f876de0373c12008b757a63852164b71
227a30ef0028f167a20825bdd775b15392161e641f6c68030e01d0c79e263bb5
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
0db5cb24c358651d0e728ab3efb52286290c1a38ec379435225c935d4165ad6a
046f769822102de0d4ecec99ad41027cb792941065226716f38a6e64c50ff364
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
257478b050ea360c55e904f2a89c6069293139ff0d97ba860de3f29dac927787
0b9c3572390a3caab7d47f9b975439f8dde3feff6b9044579d99b8334ac2a692
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
21e7510d6a3712bf0f21d53a5c8d652e3c2787d89a1fc25998cde96efe124207
0af62bcd4583d126a616cafbf2ddc80f039a3ead2b5dcb7144222b0ebc673432
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
04844bfbfbcb1770f4f4742658944c592922b037b2387e9340de58ef0d9baacc
24ef150beefb05622212228e1c44cb487fffe6526776762d713f3ebdf4c02ac0
+8
View File
@@ -245,6 +245,10 @@ mod imp {
wit::views::open_terminal(title, program, args).map_err(error)
}
pub fn open_location(path: &str, line: u32, column: u32) -> Result<(), Error> {
wit::views::open_location(path, line, column).map_err(error)
}
pub fn setting(key: &str) -> Option<String> {
wit::settings::get(key)
}
@@ -387,6 +391,10 @@ pub fn open_terminal(title: &str, program: &str, args: &[String]) -> Result<(),
imp::open_terminal(title, program, args)
}
pub fn open_location(path: &str, line: u32, column: u32) -> Result<(), Error> {
imp::open_location(path, line, column)
}
/// One of the plugin's settings (`[contributes.settings]`) as JSON: the
/// user's value (project over global) or the schema's default.
pub fn setting(key: &str) -> Option<String> {
+13
View File
@@ -74,6 +74,7 @@ struct State {
refreshed: Vec<String>,
view_messages: Vec<(String, String)>,
terminals: Vec<MockTerminal>,
locations: Vec<(String, u32, u32)>,
settings: BTreeMap<String, String>,
}
@@ -220,6 +221,10 @@ impl MockHost {
self.state.borrow().view_messages.clone()
}
pub fn opened_locations(&self) -> Vec<(String, u32, u32)> {
self.state.borrow().locations.clone()
}
/// Terminals the plugin opened.
pub fn terminals(&self) -> Vec<MockTerminal> {
self.state.borrow().terminals.clone()
@@ -595,6 +600,14 @@ pub mod current {
})
}
pub fn open_location(path: &str, line: u32, column: u32) -> Result<(), Error> {
with(|s| {
require(s, "project.read")?;
s.locations.push((path.into(), line, column));
Ok(())
})
}
pub fn setting(key: &str) -> Option<String> {
with(|s| s.settings.get(key).cloned())
}
+4
View File
@@ -133,6 +133,10 @@ interface views {
/// `program` (pinned by a `process:<program>` grant) with `args` in the
/// project root: long-running output (logs) and interactive sessions.
open-terminal: func(title: string, program: string, args: list<string>) -> result<_, error>;
/// Opens `path` (project-relative or absolute, inside the project) in an
/// editor of the window the call came from, the caret at `line` and
/// `column` (0-based; UTF-16 column): a view's links to code.
open-location: func(path: string, line: u32, column: u32) -> result<_, error>;
}
/// The plugin's own settings (`[contributes.settings]`), as the user set them
+73
View File
@@ -0,0 +1,73 @@
/**
* The frontend's view of the desktop app, with a real backend: the window's Tauri runtime
* (callbacks, `Channel`s, the event plugin) in-process, and every app command sent to an
* `openidea-server` through the route remote development uses (`src/platform/tauriCore.ts`).
*/
import { setInvokeRoute } from "@/platform/tauriCore";
import { encodeArgs } from "@/platform/remote";
import { Server } from "./server";
type Callback = (message: unknown) => void;
// One runtime per page, as in the app: the frontend registers its event listeners once
// (`src/platform/ipc.ts`), so they outlive the servers connected one after another.
const callbacks = new Map<number, Callback>();
const listeners: { event: string; handler: number }[] = [];
const channels = new Map<number, { onmessage: (data: unknown) => void }>();
let nextCallback = 1;
let installed = false;
function install() {
if (installed) return;
installed = true;
const internals = {
metadata: { currentWindow: { label: "main" }, currentWebview: { label: "main", windowLabel: "main" } },
transformCallback(cb: Callback) {
const id = nextCallback++;
callbacks.set(id, cb);
return id;
},
unregisterCallback(id: number) {
callbacks.delete(id);
},
convertFileSrc: (path: string) => path,
// Only the window's own plugins reach this: app commands take the route below.
async invoke(cmd: string, args: Record<string, unknown> = {}) {
if (cmd === "plugin:event|listen") {
listeners.push({ event: args.event as string, handler: args.handler as number });
return listeners.length;
}
if (cmd === "plugin:window|get_all_windows") return [];
if (cmd.startsWith("plugin:")) return null;
throw { kind: "unavailable", message: `${cmd} has no route`, detail: null };
},
};
Object.assign(window, {
__TAURI_INTERNALS__: internals,
__TAURI_EVENT_PLUGIN_INTERNALS__: { unregisterListener: () => {} },
});
}
/** Connects this page's frontend to `server`; returns a function that disconnects it. */
export function connectDesktop(server: Server): () => void {
install();
channels.clear();
server.onChannel = (id, data) => channels.get(id)?.onmessage(data);
server.onEvent = (target, event) => {
if (target !== null && target !== "main") return;
for (const l of listeners) {
if (l.event === "ide://event") callbacks.get(l.handler)?.({ event: l.event, id: 0, payload: event });
}
};
setInvokeRoute((cmd, args) => {
if (cmd.startsWith("plugin:")) return null;
const { json, channels: used } = encodeArgs(args);
for (const [id, channel] of used) channels.set(id, channel as unknown as { onmessage: (data: unknown) => void });
return server.invoke(cmd, JSON.parse(json));
});
return () => {
setInvokeRoute(null);
server.onChannel = () => {};
server.onEvent = () => {};
};
}
+232
View File
@@ -0,0 +1,232 @@
/**
* Running and testing each language's `fixtures/` project the way the Run tool window does:
* the backend detects the configurations, the run store starts them (with its test
* reporting), parses the output into the test tree, and reruns the failed tests.
*/
import { execFileSync } from "node:child_process";
import { cpSync, mkdtempSync, realpathSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, expect, test } from "vitest";
import { commands, type RunConfig } from "@/bindings";
import { onIdeEvent } from "@/platform/ipc";
import { openProject, useProject } from "@/platform/project";
import {
failedTests,
loadConfigs,
onRunExited,
rerunFailed,
resetRuns,
runtime,
startRun,
stopSession,
useRun,
waitForExit,
} from "@/features/run/store";
import { nodeStatus } from "@/features/run/testing/results";
import { connectDesktop } from "./desktop";
import { repoRoot, Server } from "./server";
interface Case {
fixture: string;
/** Runs in the project copy first (installs its dependencies). */
setup?: (root: string) => void;
/** A configuration that runs to completion printing this, or a server stopped once it prints it. */
run?: { config: string; prints: string; stop?: boolean };
tests: {
config: string;
/** The configuration with the fixture's intentionally failing test switched on. */
failing: (config: RunConfig) => RunConfig;
passed: number;
failed: string;
/** The failure's location ends with this file. */
file: string;
};
}
const env = (config: RunConfig, vars: Record<string, string>): RunConfig => ({
...config,
env: { ...config.env, ...vars },
});
const args = (config: RunConfig, extra: string[]): RunConfig => ({ ...config, args: [...config.args, ...extra] });
const CASES: Record<string, Case> = {
typescript: {
fixture: "ts-app",
setup: (root) => execFileSync("npm", ["install", "--no-audit", "--no-fund"], { cwd: root, stdio: "ignore" }),
run: { config: "npm:start", prints: "Total: $134.97" },
tests: {
config: "npm:test",
failing: (c) => env(c, { OPENIDEA_FAIL: "1" }),
passed: 4,
failed: "intentionally fails (set OPENIDEA_FAIL=1)",
file: "tests/cart.test.ts",
},
},
python: {
fixture: "python-app",
// A project venv with pytest, as the fixture's README sets it up.
setup: (root) => {
execFileSync("python3", ["-m", "venv", ".venv"], { cwd: root, stdio: "ignore" });
execFileSync(join(root, ".venv", "bin", "pip"), ["install", "-q", "pytest"], { cwd: root, stdio: "ignore" });
},
tests: {
config: "pytest",
failing: (c) => env(c, { OPENIDEA_FAIL: "1" }),
passed: 4,
failed: "test_intentionally_failing",
file: "tests/test_models.py",
},
},
rust: {
fixture: "rust-app",
run: { config: "cargo:run:inventory", prints: "Sold 3 mice, 9 left" },
tests: {
config: "cargo:test",
failing: (c) => args(c, ["--features", "failing-tests"]),
// `cargo test` stops after the failing unit tests: the integration tests don't run.
passed: 7,
failed: "intentionally_failing",
file: "src/lib.rs",
},
},
go: {
fixture: "go-app",
run: { config: "go:run:server", prints: "listening on", stop: true },
tests: {
config: "go:test",
failing: (c) => env(c, { OPENIDEA_FAIL: "1" }),
passed: 3,
failed: "TestIntentionallyFailing",
file: "store_test.go",
},
},
java: {
fixture: "java-app",
tests: {
config: "maven:test",
failing: (c) => args(c, ["-DopenideaFail=true"]),
passed: 2,
failed: "intentionallyFails",
file: "OrderBookTest.java",
},
},
kotlin: {
fixture: "kotlin-gradle",
run: { config: "gradle:run", prints: "Lent 'Kotlin in Action' until 2024-01-15" },
tests: {
config: "gradle:test",
failing: (c) => args(c, ["-PopenideaFail"]),
passed: 3,
failed: "intentionally failing",
file: "LibraryTest.kt",
},
},
};
let server: Server | null = null;
let disconnect: (() => void) | null = null;
let base = "";
afterEach(() => {
// As switching projects does: run ids restart with each server.
resetRuns();
disconnect?.();
server?.close();
if (base) rmSync(base, { recursive: true, force: true });
});
async function open(c: Case): Promise<string> {
base = realpathSync(mkdtempSync(join(tmpdir(), "openidea-smoke-")));
const root = join(base, c.fixture);
cpSync(join(repoRoot, "fixtures", c.fixture), root, { recursive: true });
c.setup?.(root);
server = await Server.start(join(base, "home"));
disconnect = connectDesktop(server);
const unsubscribe = onIdeEvent("runExited", (e) => onRunExited(e.run, e.code));
const previous = disconnect;
disconnect = () => {
unsubscribe();
previous();
};
await openProject(root);
const trusted = await commands.projectTrustSet(root, true, false);
if (trusted.status !== "ok") throw new Error(trusted.error.message);
useProject.setState({ project: { ...useProject.getState().project!, trusted: true } });
await loadConfigs();
return root;
}
function config(id: string): RunConfig {
const found = useRun.getState().configs.find((c) => c.id === id);
if (!found)
throw new Error(
`no configuration ${id}: ${useRun
.getState()
.configs.map((c) => c.id)
.join(", ")}`,
);
return found;
}
/** Runs `config` to its end; returns its session id and output. */
async function runToEnd(config: RunConfig, timeoutMs = 8 * 60_000): Promise<{ id: string; output: string[] }> {
const output: string[] = [];
const session = await startRun(config, undefined, undefined, { prepared: true, onLine: (l) => output.push(l) });
if (!session) throw new Error(`${config.id} didn't start`);
await waitForExit([session.id], timeoutMs);
const ended = useRun.getState().sessions.find((s) => s.id === session.id)!;
expect(ended.status, `${config.id} is still running:\n${output.join("\n")}`).not.toBe("running");
return { id: session.id, output };
}
async function smoke(language: string) {
const c = CASES[language]!;
await open(c);
if (c.run) {
const output: string[] = [];
const session = await startRun(config(c.run.config), undefined, undefined, {
prepared: true,
onLine: (l) => output.push(l),
});
expect(session, "the run starts").toBeTruthy();
await expect.poll(() => output.join("\n"), { timeout: 5 * 60_000 }).toContain(c.run.prints);
if (c.run.stop) await stopSession(session!.id);
await waitForExit([session!.id], 60_000);
const ended = useRun.getState().sessions.find((s) => s.id === session!.id)!;
expect(ended.status, output.join("\n")).toBe("finished");
if (!c.run.stop) expect(ended.exitCode, output.join("\n")).toBe(0);
}
// 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)));
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 });
const failure = tests.firstFailure()!;
expect(failure.name, log).toBe(c.tests.failed);
expect(failure.location?.path ?? "", `${JSON.stringify(failure.location)}${log}`).toMatch(
new RegExp(`${c.tests.file.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`),
);
expect(failure.output.join("\n"), "the failure's message is kept").not.toBe("");
expect(failedTests(id)).toHaveLength(1);
// Rerun Failed Tests runs that one test only.
const rerunOutput: string[] = [];
const before = useRun.getState().sessions.find((s) => s.id === id)!.startedAt;
expect(rerunFailed(id), "Rerun Failed Tests is offered").toBe(true);
const ended = () => {
const s = useRun.getState().sessions.find((s) => s.id === id)!;
return s.startedAt > before && s.endedAt !== null && s.endedAt >= s.startedAt;
};
await expect.poll(ended, { timeout: 8 * 60_000 }).toBe(true);
const again = runtime(id)!.tests!;
rerunOutput.push(...again.roots.map((r) => `${r.name}: ${nodeStatus(r)}`));
expect(again.counts(), rerunOutput.join("\n")).toMatchObject({ passed: 0, failed: 1 });
expect(again.firstFailure()?.name).toBe(c.tests.failed);
}
for (const language of Object.keys(CASES)) {
test(language, () => smoke(language));
}
+132
View File
@@ -0,0 +1,132 @@
/**
* `openidea-server --stdio`, spoken to as the desktop app does in remote development:
* JSON-RPC with `Content-Length` framing over the server's stdin / stdout.
*/
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
import { dirname, join } from "node:path";
const PROTOCOL_VERSION = 2;
const WINDOW = "main";
// Not `new URL(…, import.meta.url)`: the jsdom environment's `URL` isn't Node's.
export const repoRoot = join(dirname(import.meta.filename), "..");
/** The server binary: `OPENIDEA_SERVER`, else the workspace's debug build. */
function serverBinary(): string {
const exe = process.platform === "win32" ? ".exe" : "";
return process.env.OPENIDEA_SERVER ?? join(repoRoot, "target", "debug", `openidea-server${exe}`);
}
interface Pending {
resolve: (value: unknown) => void;
reject: (error: unknown) => void;
}
export class Server {
private nextId = 1;
private readonly pending = new Map<number, Pending>();
private buffer = Buffer.alloc(0);
private readonly stderr: string[] = [];
/** `channel` notifications: a value streamed to the `Channel` with this id. */
onChannel: (id: number, data: unknown) => void = () => {};
/** `event` notifications: an `IdeEvent` for a window (`null`: every window). */
onEvent: (window: string | null, event: unknown) => void = () => {};
private constructor(private readonly child: ChildProcessWithoutNullStreams) {
child.stdout.on("data", (chunk: Buffer) => this.receive(chunk));
child.stderr.on("data", (chunk: Buffer) => {
this.stderr.push(chunk.toString());
if (this.stderr.length > 200) this.stderr.shift();
});
child.on("exit", (code) => {
for (const p of this.pending.values()) p.reject(new Error(`openidea-server exited (${code}): ${this.log()}`));
this.pending.clear();
});
}
/** Starts a server whose settings, caches and tools live under `home`. */
static async start(home: string, env: Record<string, string> = {}): Promise<Server> {
const child = spawn(serverBinary(), ["--stdio"], {
env: { ...process.env, OPENIDEA_SERVER_HOME: home, ...env },
stdio: ["pipe", "pipe", "pipe"],
});
const server = new Server(child);
await server.request("initialize", { version: PROTOCOL_VERSION });
return server;
}
/** The tail of the server's log, for failure messages. */
log(): string {
return this.stderr.join("").split("\n").slice(-40).join("\n");
}
/** Runs an IPC command; rejects with the command's `IdeError`. */
async invoke(cmd: string, args: unknown): Promise<unknown> {
try {
return await this.request("invoke", { window: WINDOW, cmd, args });
} catch (e) {
const message = (e as { message?: string }).message ?? String(e);
try {
throw JSON.parse(message);
} catch (parsed) {
if (parsed instanceof SyntaxError) throw { kind: "protocol", message, detail: null };
throw parsed;
}
}
}
close() {
this.child.stdin.end();
this.child.kill();
}
private request(method: string, params: unknown): Promise<unknown> {
const id = this.nextId++;
return new Promise((resolve, reject) => {
this.pending.set(id, { resolve, reject });
this.send({ jsonrpc: "2.0", id, method, params });
});
}
private send(message: unknown) {
const body = Buffer.from(JSON.stringify(message));
this.child.stdin.write(`Content-Length: ${body.length}\r\n\r\n`);
this.child.stdin.write(body);
}
private receive(chunk: Buffer) {
this.buffer = Buffer.concat([this.buffer, chunk]);
for (;;) {
const end = this.buffer.indexOf("\r\n\r\n");
if (end < 0) return;
const length = /content-length:\s*(\d+)/i.exec(this.buffer.subarray(0, end).toString())?.[1];
if (!length) throw new Error(`bad header: ${this.buffer.subarray(0, end)}`);
const start = end + 4;
if (this.buffer.length < start + Number(length)) return;
const message = JSON.parse(this.buffer.subarray(start, start + Number(length)).toString());
this.buffer = this.buffer.subarray(start + Number(length));
this.dispatch(message);
}
}
private dispatch(message: {
id?: number;
method?: string;
params?: { id?: number; data?: unknown; window?: string | null; event?: unknown };
result?: unknown;
error?: { message: string };
}) {
if (message.method && message.id !== undefined) {
this.send({ jsonrpc: "2.0", id: message.id, error: { code: -32601, message: "not supported" } });
} else if (message.method === "channel") {
this.onChannel(message.params!.id!, message.params!.data);
} else if (message.method === "event") {
this.onEvent(message.params!.window ?? null, message.params!.event);
} else if (message.id !== undefined) {
const pending = this.pending.get(message.id);
this.pending.delete(message.id);
if (message.error) pending?.reject(message.error);
else pending?.resolve(message.result);
}
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"types": ["node", "vite/client"],
"paths": { "@/*": ["../src/*"] }
},
"include": ["./**/*.ts", "../src/**/*.d.ts"]
}
+20
View File
@@ -0,0 +1,20 @@
/**
* Backend smoke tests (`pnpm smoke`): the frontend's own modules driving a real
* `openidea-server` and real toolchains. Not part of `pnpm test`: they need the server
* built (`cargo build -p ide-host --bin openidea-server`) and each language's tools.
*/
import { defineConfig, mergeConfig } from "vitest/config";
import viteConfig from "../vite.config.ts";
export default mergeConfig(
viteConfig({ command: "serve", mode: "test" }),
defineConfig({
test: {
include: ["smoke/**/*.test.ts"],
testTimeout: 10 * 60_000,
hookTimeout: 5 * 60_000,
// Each file starts a server and toolchains of its own.
fileParallelism: false,
},
}),
);
+2
View File
@@ -343,6 +343,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
run::run_close_stdin,
run::run_resize,
run::run_gradle_jvm_args_script,
run::run_gradle_init_script,
run::run_tomcat_home,
run::run_tomcat_artifacts,
run::run_tomcat_build,
@@ -386,6 +387,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
tools::jdk_download,
sdk::sdk_detect,
sdk::sdk_python_effective,
sdk::sdk_php_language_level,
sdk::sdk_project_libraries,
sdk::sdk_locate_tools,
sdk::sdk_python_create_venv,
+12
View File
@@ -189,6 +189,18 @@ pub async fn run_gradle_jvm_args_script(
ide_host::ops::run::run_gradle_jvm_args_script(&state.host, window.label(), jvm_args).await
}
/// Writes one of the IDE's Gradle init scripts where the build runs; returns its path.
#[tauri::command]
#[specta::specta]
pub async fn run_gradle_init_script(
window: Window,
state: State<'_, AppState>,
name: String,
text: String,
) -> IdeResult<String> {
ide_host::ops::run::run_gradle_init_script(&state.host, window.label(), name, text).await
}
/// Results from the JUnit XML reports (Surefire, Failsafe, Gradle) of the module at
/// `module` (relative to the project root) written since `since_ms` (Unix epoch, ms).
#[tauri::command]
+11
View File
@@ -24,6 +24,17 @@ pub fn sdk_python_effective(window: Window, state: State<'_, AppState>) -> IdeRe
ide_host::ops::sdk::sdk_python_effective(&state.host, window.label())
}
/// The PHP language level of the project `path` belongs to: the effective one and Auto's.
#[tauri::command]
#[specta::specta]
pub async fn sdk_php_language_level(
window: Window,
state: State<'_, AppState>,
path: PathBuf,
) -> IdeResult<ide_host::ops::sdk::PhpLanguageLevels> {
ide_host::ops::sdk::sdk_php_language_level(&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]
+46 -1
View File
@@ -630,6 +630,8 @@ export const commands = {
runResize: (id: RunId, cols: number, rows: number) => typedError<null, IdeError>(__TAURI_INVOKE("run_resize", { id, cols, rows })),
/** Writes the Gradle init script adding `jvm_args` to the JVMs the build forks; returns its path. */
runGradleJvmArgsScript: (jvmArgs: string[]) => typedError<string, IdeError>(__TAURI_INVOKE("run_gradle_jvm_args_script", { jvmArgs })),
/** Writes one of the IDE's Gradle init scripts where the build runs; returns its path. */
runGradleInitScript: (name: string, text: string) => typedError<string, IdeError>(__TAURI_INVOKE("run_gradle_init_script", { name, text })),
runTomcatHome: (home: string) => typedError<TomcatHome, IdeError>(__TAURI_INVOKE("run_tomcat_home", { home })),
runTomcatArtifacts: () => typedError<WebArtifact[], IdeError>(__TAURI_INVOKE("run_tomcat_artifacts")),
runTomcatBuild: (config: RunConfig_Deserialize) => typedError<({
@@ -748,6 +750,8 @@ export const commands = {
sdkDetect: (kind: SdkKind) => typedError<Sdk[], IdeError>(__TAURI_INVOKE("sdk_detect", { kind })),
/** The interpreter the project uses: the configured one, else its virtualenv. */
sdkPythonEffective: () => typedError<string | null, IdeError>(__TAURI_INVOKE("sdk_python_effective")),
/** The PHP 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 })),
/**
* SDK / library source folders shown under "External Libraries" in the Project view,
* with the Maven / Gradle dependency jars of the build model.
@@ -5390,6 +5394,37 @@ export type PathCompletion = {
dir: boolean,
};
/** A PHP version and where it comes from. */
export type PhpLanguageLevel = {
/** `X.Y.Z`. */
version: string,
source: PhpLevelSource,
};
/**
* The PHP language level of the project `path` belongs to (a workspace member, by path, as
* the language server configuration picks it).
*/
export type PhpLanguageLevels = {
/** The one Intelephense checks against; `None`: its default. */
effective: PhpLanguageLevel | null,
/** The one Auto picks, from `composer.json`. */
auto: PhpLanguageLevel | null,
/**
* The workspace member `path` belongs to: its own settings choose the level, not the
* workspace's. `None`: the opened project.
*/
member: IpcPath | null,
};
export type PhpLevelSource =
/** `languages.php.languageLevel`. */
"settings" |
/** `config.platform.php` of `composer.json`. */
"platform" |
/** The lowest version `require.php` of `composer.json` allows. */
"require";
export type PhpSettings = {
/** `php` CLI executable; empty = the one on `PATH`. */
interpreter?: string,
@@ -5398,6 +5433,16 @@ export type PhpSettings = {
* `composer.json` (`config.platform.php`, else the lowest version `require.php` allows).
*/
languageLevel?: string,
/**
* Folders outside the project whose code the language server indexes too (a platform a
* module extends); absolute, `~/`, or relative to the project.
*/
includePaths?: string[],
/**
* Intelephense stub sets loaded on top of its defaults and the `ext-*` entries of
* `composer.json` (`wordpress`).
*/
stubs?: string[],
};
/** The focused editor when a plugin command is invoked. */
@@ -5435,7 +5480,7 @@ export type PluginEvent =
/** For the script of a view's `html` body. */
{ type: "viewMessage"; pluginId: string; view: string; message: string } |
/** Open a terminal running `program` (already approved and verified). */
{ type: "openTerminal"; pluginId: string; title: string; program: IpcPath; args: string[]; cwd: IpcPath } |
{ type: "openTerminal"; pluginId: string; title: string; program: IpcPath; args: string[]; cwd: IpcPath } | { type: "openLocation"; pluginId: string; path: IpcPath; line: number; column: number } |
/**
* Plugin languages, or the servers they may start, changed: re-fetch
* `languages` (the host re-detects open documents: [`crate::Host::plugin_languages_changed`]).
+12
View File
@@ -44,6 +44,18 @@ describe("semanticRanges", () => {
expect(semanticRanges(doc, input)).toEqual([{ from: 0, to: 3, className: "cm-sem-function" }]);
});
it("marks globals (lua-language-server's `global` modifier)", () => {
const input = {
tokenTypes: ["variable", "function"],
tokenModifiers: ["global"],
data: [0, 0, 3, 0, 1, 0, 4, 3, 1, 1],
};
expect(semanticRanges(doc, input)).toEqual([
{ from: 0, to: 3, className: "cm-sem-global" },
{ from: 4, to: 7, className: "cm-sem-function cm-sem-global" },
]);
});
it("colours a constructor (jdtls: class + `constructor`) as a function", () => {
const input = { tokenTypes: ["class"], tokenModifiers: ["constructor", "declaration"], data: [0, 0, 3, 0, 0b11] };
expect(semanticRanges(doc, input)[0]?.className).toBe("cm-sem-function cm-sem-declaration");
+1
View File
@@ -56,6 +56,7 @@ const MODIFIER_CLASS: Record<string, string> = {
declaration: "declaration",
definition: "declaration",
defaultLibrary: "builtin",
global: "global",
};
/** Number of `--syn-rainbow-<n>` colours (IDEA's five semantic highlighting colours). */
+2
View File
@@ -361,6 +361,8 @@ export const lspTheme = EditorView.theme({
".cm-sem-parameter": { color: "var(--syn-param, var(--syn-variable))" },
".cm-sem-self": { color: "var(--syn-self, var(--syn-keyword))" },
".cm-sem-keyword": { color: "var(--syn-keyword)", fontWeight: "var(--syn-keyword-bold, inherit)" },
// Before the builtin rule: library globals (`print`, `string`) keep the predefined colour.
".cm-sem-global:not(.cm-sem-function)": { color: "var(--syn-global, inherit)" },
".cm-sem-builtin:not(.cm-sem-function)": { color: "var(--syn-builtin, inherit)" },
".cm-sem-static": { fontStyle: "var(--syn-static-italic, italic)" },
".cm-sem-python-static": { fontStyle: "var(--syn-python-static-italic, normal)" },
+12 -1
View File
@@ -54,7 +54,7 @@ const { isJavaDebuggable, javaAttachConfig, javaLaunchConfig } = await import(".
const app: RunConfig = {
id: "application:a.Main",
name: "Main",
kind: "spring-boot",
kind: "application",
command: "java",
args: ["-Xmx1g", "-cp", "${classpath}", "a.Main", "--port", "8081 x"],
cwd: "api",
@@ -93,6 +93,17 @@ describe("Java debugging (java-debug through jdtls)", () => {
});
});
it("debugs Spring Boot with coloured console output, as Run does", async () => {
const boot = { ...app, kind: "spring-boot" };
expect(JSON.parse((await javaLaunchConfig(boot, "/p")).extraJson!).vmArgs).toBe(
"-Dspring.output.ansi.enabled=always -Xmx1g",
);
const own = { ...boot, args: ["-Dspring.output.ansi.enabled=never", ...app.args] };
expect(JSON.parse((await javaLaunchConfig(own, "/p")).extraJson!).vmArgs).toBe(
"-Dspring.output.ansi.enabled=never -Xmx1g",
);
});
it("passes the configuration's VM options to java-debug's vmArgs, before the main class", async () => {
const args = [
"-Ddemo.flag=on",
@@ -79,6 +79,27 @@ describe("run config → launch config", () => {
expect(extra(globalPytest).python).toBe("python3");
});
it("debugpy: uv / poetry run the debuggee in the project's environment", () => {
const debug = (command: string, args: string[]) =>
launchConfigFor(rc({ kind: "python", command, args, debugAdapter: "debugpy" }), "/p")!;
const script = debug("uv", ["run", "python", "main.py", "-v"]);
expect(script.program).toBe("/p/main.py");
expect(script.args).toEqual(["-v"]);
expect(extra(script)).toMatchObject({ python: ["uv", "run", "python"], debugLauncherPython: "python3" });
expect(extra(script).module).toBeUndefined();
const mod = debug("poetry", ["run", "python3", "-m", "app"]);
expect(extra(mod)).toMatchObject({ module: "app", python: ["poetry", "run", "python3"] });
const tool = debug("uv", ["run", "pytest", "-x"]);
expect(extra(tool)).toMatchObject({ module: "pytest", python: ["uv", "run", "python"] });
expect(tool.args).toEqual(["-x"]);
const django = debug("pdm", ["run", "python", "manage.py", "runserver"]);
expect(extra(django)).toMatchObject({ django: true, python: ["pdm", "run", "python"] });
});
it("js-debug: npm scripts run through the package manager, node scripts directly", () => {
const npm = launchConfigFor(
rc({ kind: "npm", command: "pnpm", args: ["run", "dev"], cwd: "web", debugAdapter: "js-debug" }),
@@ -0,0 +1,81 @@
import { beforeEach, expect, it, vi } from "vitest";
import { useProject } from "@/platform/project";
import type { LaunchConfig } from "@/platform/ipc";
import {
provideNotificationService,
provideToolWindowService,
type ToolWindowService,
} from "@/workbench/contributions";
import { adoptSession, handleDebugEvent, 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 attach: LaunchConfig = {
name: "AppKt",
adapter: "kotlin-debug-adapter",
request: "attach",
program: null,
args: [],
cwd: "/p",
env: {},
extraJson: JSON.stringify({ hostName: "127.0.0.1", port: 35549 }),
};
const notify = vi.fn();
beforeEach(() => {
useDebug.setState({ sessions: {}, active: null, console: {} });
useProject.setState({ project: { root: "/p" } as never });
provideNotificationService({ notify });
provideToolWindowService({ show: vi.fn() } as unknown as ToolWindowService);
vi.clearAllMocks();
// The adapter's session announces itself, then the attach fails: the JVM is gone.
ipc.debugStart.mockImplementation(async () => {
handleDebugEvent(43, { kind: "started", name: "AppKt", adapter: "kotlin-debug-adapter", parent: null });
return { status: "error", error: { kind: "internal", message: "attach failed: Internal error.", data: null } };
});
});
/** A finished session that debugged a JVM of run 7. */
function finished(endedAgoMs: number) {
adoptSession(1, { name: "AppKt", process: 7 });
handleDebugEvent(1, { kind: "terminated" });
useDebug.setState((s) => ({
sessions: { ...s.sessions, 1: { ...s.sessions[1]!, endedAt: Date.now() - endedAgoMs } },
}));
}
it("ignores a failed attach to the port a JVM announced as its debugger detached", async () => {
finished(100);
expect(await startSession(attach, { process: 7, rerunnable: false })).toBeNull();
expect(notify).not.toHaveBeenCalled();
const { sessions, active } = useDebug.getState();
expect(Object.keys(sessions)).toEqual(["1"]);
expect(active).toBe(1);
});
it("reports a failed attach to a JVM of another run, or long after the last session ended", async () => {
finished(100);
await startSession(attach, { process: 8, rerunnable: false });
expect(notify).toHaveBeenCalledWith(expect.objectContaining({ title: "Cannot start debugger for AppKt" }));
notify.mockClear();
useDebug.setState({ sessions: {}, active: null, console: {} });
finished(60_000);
await startSession(attach, { process: 7, rerunnable: false });
expect(notify).toHaveBeenCalledWith(expect.objectContaining({ title: "Cannot start debugger for AppKt" }));
expect(useDebug.getState().sessions[1]).toBeUndefined();
});
it("replaces the finished session once an attach soon after it succeeds", async () => {
finished(100);
ipc.debugStart.mockResolvedValueOnce({ status: "ok", data: 44 });
expect(await startSession(attach, { process: 7, rerunnable: false })).toBe(44);
expect(Object.keys(useDebug.getState().sessions)).toEqual(["44"]);
expect(notify).not.toHaveBeenCalled();
});
+3 -2
View File
@@ -7,6 +7,7 @@
import { executeAction } from "@/platform/actions";
import { call, commands, IpcError, nextRequestId, type LaunchConfig, type RunConfig } from "@/platform/ipc";
import {
consoleVmArgs,
formatCompileErrors,
isJvmApplication,
joinJavaArgs,
@@ -117,7 +118,7 @@ export async function javaLaunchConfig(
classPaths,
modulePaths: info.modulePaths,
javaExec: resolved.command,
vmArguments: parsed.vmArgs,
vmArguments: [...consoleVmArgs(rc), ...parsed.vmArgs],
programArgs: parsed.programArgs,
}),
};
@@ -139,7 +140,7 @@ export async function javaLaunchConfig(
classPaths,
modulePaths: info.modulePaths,
javaExec: resolved.command === "java" ? undefined : resolved.command,
vmArgs: joinJavaArgs(parsed.vmArgs),
vmArgs: joinJavaArgs([...consoleVmArgs(rc), ...parsed.vmArgs]),
args: joinJavaArgs(parsed.programArgs),
// java-debug spawns the JVM itself: it must shorten a classpath too long for the OS the same way Run does.
shortenCommandLine: info.shortenCommandLine ?? "none",
+28 -13
View File
@@ -58,15 +58,20 @@ function cargoLaunch(rc: RunConfig, cwd: string, adapter: string): LaunchConfig
};
}
const PYTHON_NAME = /^python[\d.]*(\.exe)?$/;
function pythonFor(rc: RunConfig, cwd: string): string {
const cmd = rc.command;
const name = cmd.split("/").pop() ?? cmd;
if (/^python[\d.]*(\.exe)?$/.test(name)) return resolveCommand(cmd, cwd);
if (PYTHON_NAME.test(name)) return resolveCommand(cmd, cwd);
// A venv tool such as `.venv/bin/pytest`: use the interpreter next to it.
if (cmd.includes("/")) return resolveCommand(cmd.slice(0, cmd.lastIndexOf("/") + 1) + "python", cwd);
return "python3";
}
/** Project managers whose `run` executes in the project's environment (`uv run python …`). */
const PYTHON_RUNNERS = new Set(["uv", "poetry", "pdm", "pipenv", "hatch", "rye"]);
function debugpyLaunch(rc: RunConfig, cwd: string): LaunchConfig {
const out = base(rc, "debugpy", cwd);
const dbg = workflowSettings().debugger;
@@ -78,19 +83,29 @@ function debugpyLaunch(rc: RunConfig, cwd: string): LaunchConfig {
redirectOutput: true,
};
if (dbg.python.gevent) extra.gevent = true;
const name = rc.command.split("/").pop() ?? rc.command;
const isPython = /^python[\d.]*(\.exe)?$/.test(name);
if (!isPython) {
const name = (rc.command.split("/").pop() ?? rc.command).replace(/\.exe$/, "");
let args = rc.args;
let tool: string | null = PYTHON_NAME.test(name) ? null : name;
if (PYTHON_RUNNERS.has(name) && args[0] === "run" && args[1]) {
// The debuggee runs through the manager (`uv run python <debugpy> …`) to get the project's
// environment; the adapter and its launcher need a plain interpreter.
const python = PYTHON_NAME.test(args[1]) ? args[1] : "python";
extra.python = [resolveCommand(rc.command, cwd), "run", python];
extra.debugLauncherPython = "python3";
tool = PYTHON_NAME.test(args[1]) ? null : args[1];
args = args.slice(2);
}
if (tool) {
// A console script (`pytest`, `uvicorn`, …): run it as a module.
extra.module = name.replace(/\.exe$/, "");
out.args = [...rc.args];
} else if (rc.args[0] === "-m" && rc.args[1]) {
extra.module = rc.args[1];
out.args = rc.args.slice(2);
} else if (rc.args[0]) {
out.program = join(cwd, rc.args[0]);
out.args = rc.args.slice(1);
if (rc.args[0].endsWith("manage.py")) extra.django = true;
extra.module = tool;
out.args = [...args];
} else if (args[0] === "-m" && args[1]) {
extra.module = args[1];
out.args = args.slice(2);
} else if (args[0]) {
out.program = join(cwd, args[0]);
out.args = args.slice(1);
if (args[0].endsWith("manage.py")) extra.django = true;
}
out.extraJson = JSON.stringify(extra);
return out;
+62 -12
View File
@@ -38,6 +38,8 @@ export interface DebugSession {
/** Originating configuration, retained for toolbar targeting and prepared debug reruns. */
runConfig?: RunConfig;
startedAt?: number;
/** When it terminated. */
endedAt?: number;
adapter: string;
parent: DebugSessionId | null;
/** Launch configuration (root sessions), used by Rerun. */
@@ -223,16 +225,58 @@ function ownSession(id: DebugSessionId, event: DebugEvent): DebugSession | null
return event.kind === "started" ? adoptSession(id, { name: event.name, adapter: event.adapter }) : adoptSession(id);
}
/** A start finished (`id` on success): sessions no start claimed are failed starts' leftovers. */
function settleStart(id: DebugSessionId | null) {
/**
* A start finished (`id` on success): sessions no start claimed are failed starts' leftovers,
* kept as terminated or, with `discard`, removed.
*/
function settleStart(id: DebugSessionId | null, discard = false) {
pendingStarts = Math.max(0, pendingStarts - 1);
if (id != null) provisional.delete(id);
if (pendingStarts > 0) return;
const leftovers = [...provisional];
provisional.clear();
if (discard) {
useDebug.setState((st) => {
const sessions = { ...st.sessions };
const console = { ...st.console };
for (const s of leftovers) {
delete sessions[s];
delete console[s];
ansiStreams.delete(s);
}
return { sessions, console, active: st.active != null && sessions[st.active] ? st.active : null };
});
publishContext();
return;
}
for (const s of leftovers) if (isLive(useDebug.getState().sessions[s])) markTerminated(s);
}
/** Drops finished sessions; running ones stay (multiple concurrent sessions are fine). */
function dropFinished() {
useDebug.setState((s) => {
const sessions = Object.fromEntries(Object.entries(s.sessions).filter(([, v]) => isLive(v)));
const consoleLines = Object.fromEntries(Object.entries(s.console).filter(([k]) => sessions[+k]));
for (const id of ansiStreams.keys()) if (!sessions[id]) ansiStreams.delete(id);
return { sessions, console: consoleLines, active: s.active != null && sessions[s.active] ? s.active : null };
});
publishContext();
}
/**
* A JDWP agent in server mode listens again, on a new port, when its debugger detaches; a
* debugger that detaches as the JVM exits leaves an announcement of a JVM about to be gone.
* An attach to `process` this soon after a session on it ended may be to such a JVM.
*/
const RELISTEN_MS = 3000;
function justDetached(process: RunId | null | undefined): boolean {
if (process == null) return false;
const now = Date.now();
return Object.values(useDebug.getState().sessions).some(
(s) => s.process === process && s.status === "terminated" && s.endedAt != null && now - s.endedAt < RELISTEN_MS,
);
}
export function publishContext() {
const s = activeSession();
const retained = Object.values(useDebug.getState().sessions);
@@ -460,14 +504,10 @@ export async function startSession(
notifications().notify({ group: "run", level: "warning", title: "Debugging needs the desktop app" });
return null;
}
// Drop finished sessions; keep running ones (multiple concurrent sessions are fine).
useDebug.setState((s) => {
const sessions = Object.fromEntries(Object.entries(s.sessions).filter(([, v]) => isLive(v)));
const consoleLines = Object.fromEntries(Object.entries(s.console).filter(([k]) => sessions[+k]));
for (const id of ansiStreams.keys()) if (!sessions[id]) ansiStreams.delete(id);
return { sessions, console: consoleLines, active: s.active != null && sessions[s.active] ? s.active : null };
});
publishContext();
// Attaching to a JVM that may be gone: the finished session stays until the attach succeeds.
const afterDetach = justDetached(options.process);
const previousActive = useDebug.getState().active;
if (!afterDetach) dropFinished();
try {
if (workflowSettings().run.showOnStart) toolWindowService().show("debug");
} catch {
@@ -482,6 +522,7 @@ export async function startSession(
const id = await call(commands.debugStart(launch));
console.info("Debug adapter started", { name: launch.name, ms: performance.now() - adapterStarted });
owned.add(id);
if (afterDetach) dropFinished();
if (options.preparation?.signal.aborted) {
await call(commands.debugStop(id)).catch(() => undefined);
settleStart(id);
@@ -526,7 +567,16 @@ export async function startSession(
void syncSpecial();
return id;
} catch (e) {
settleStart(null);
settleStart(null, afterDetach);
if (afterDetach) {
// The JVM that announced the port ended as its previous debugger detached.
console.info("Debugger not attached: the JVM is gone", { name: launch.name, error: e });
const { sessions, active } = useDebug.getState();
if (active == null && previousActive != null && sessions[previousActive])
useDebug.setState({ active: previousActive });
publishContext();
return null;
}
if (options.preparation?.signal.aborted || isCancelled(e)) return null;
if (options.preparation)
appendConsole(options.preparation.id, "stderr", (e instanceof Error ? e.message : String(e)) + "\n");
@@ -612,7 +662,7 @@ export function sessionSupports(cap: keyof DebugCapabilities, s: DebugSession |
}
function markTerminated(id: DebugSessionId) {
patch(id, { status: "terminated", frames: [], selectedFrame: null, stoppedThread: null });
patch(id, { status: "terminated", endedAt: Date.now(), frames: [], selectedFrame: null, stoppedThread: null });
const st = useDebug.getState();
if (st.active === id) {
// Prefer another live session (e.g. the parent of a finished child).
+36 -4
View File
@@ -4,6 +4,7 @@ import { useVirtualizer } from "@tanstack/react-virtual";
import { copySelectionOnKeyDown } from "@/ui/selectableText";
import { EmptyState, IconButton, showContextMenu, T, type MenuItem } from "@/ui";
import { tx } from "@/platform/i18n";
import { useWorkflowSettings } from "@/platform/workflowSettings";
import { spanStyle } from "@/ui/ansi";
import ansi from "@/ui/ansi.module.css";
import { appendConsole, clearConsole, evaluate, isLive, useDebug, type DebugSession } from "../state";
@@ -23,7 +24,14 @@ export function DebugServiceConsole({ sessionId }: { sessionId: number }) {
export function DebugConsole({ session, interactive = true }: { session: DebugSession; interactive?: boolean }) {
const lines = useDebug((s) => s.console[session.id]);
const scroller = useRef<HTMLDivElement>(null);
// Scroll to End: follows new output; scrolling up turns it off, back to the end turns it on.
const [follow, setFollow] = useState(true);
const stick = useRef(true);
stick.current = follow;
// Settings › Console › Use soft wraps is the default; the toggle overrides it for this console.
const defaultWrap = useWorkflowSettings((s) => s.console.softWrap);
const [wrapOverride, setWrapOverride] = useState<boolean | null>(null);
const softWrap = wrapOverride ?? defaultWrap;
const programmatic = useRef(false);
const [input, setInput] = useState("");
const history = useRef<string[]>(readHistory("console"));
@@ -39,14 +47,21 @@ export function DebugConsole({ session, interactive = true }: { session: DebugSe
getItemKey: (i) => lines?.[i]?.id ?? i,
});
// Wrapping changes every row's height; the end is pinned again below.
useLayoutEffect(() => virtualizer.measure?.(), [softWrap, virtualizer]);
useLayoutEffect(() => {
const el = scroller.current;
if (!el || !stick.current || !lines?.length) return;
programmatic.current = true;
virtualizer.scrollToIndex(lines.length - 1, { align: "end" });
el.scrollTop = el.scrollHeight;
requestAnimationFrame(() => (programmatic.current = false));
}, [lines, virtualizer]);
// Rows measured after this frame grow the content: pin the end once more.
requestAnimationFrame(() => {
if (stick.current) el.scrollTop = el.scrollHeight;
requestAnimationFrame(() => (programmatic.current = false));
});
}, [lines, virtualizer, follow, softWrap]);
const submit = async () => {
const e = input.trim();
@@ -101,11 +116,12 @@ export function DebugConsole({ session, interactive = true }: { session: DebugSe
<div className={styles.console}>
<div
ref={scroller}
className={clsx(styles.consoleOutput, ansi.palette)}
className={clsx(styles.consoleOutput, ansi.palette, !softWrap && styles.noWrap)}
onScroll={(e) => {
if (programmatic.current) return;
const el = e.currentTarget;
stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < 24;
const atEnd = el.scrollHeight - el.scrollTop - el.clientHeight < 24;
if (atEnd !== stick.current) setFollow(atEnd);
}}
onContextMenu={(e) => {
e.preventDefault();
@@ -170,6 +186,22 @@ export function DebugConsole({ session, interactive = true }: { session: DebugSe
{completion.popup}
</>
)}
<IconButton
icon="arrow-down"
title="Scroll to End"
size="small"
iconSize={12}
pressed={follow}
onClick={() => setFollow((v) => !v)}
/>
<IconButton
icon="soft-wrap"
title="Soft-Wrap"
size="small"
iconSize={12}
pressed={softWrap}
onClick={() => setWrapOverride(!softWrap)}
/>
<IconButton
icon="trash"
title="Clear Console"
@@ -251,6 +251,12 @@
cursor: text;
}
/* Soft-Wrap off: long lines scroll horizontally. */
.noWrap {
white-space: pre;
word-break: normal;
}
.out {
position: absolute;
top: 0;
+1
View File
@@ -130,6 +130,7 @@ export const ICLS_ATTRIBUTES: Record<string, Array<[Part, string]>> = {
DEFAULT_IDENTIFIER: [["FOREGROUND", "--syn-variable"]],
DEFAULT_LOCAL_VARIABLE: [["FOREGROUND", "--syn-variable"]],
DEFAULT_PARAMETER: [["FOREGROUND", "--syn-param"]],
DEFAULT_GLOBAL_VARIABLE: [["FOREGROUND", "--syn-global"]],
DEFAULT_PREDEFINED_SYMBOL: [["FOREGROUND", "--syn-builtin"]],
DEFAULT_METADATA: [
["FOREGROUND", "--syn-annotation"],
+4 -1
View File
@@ -12,7 +12,7 @@ import { onIdeEvent } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { shortcutLabel } from "@/platform/keymap";
import { BarButton } from "@/ui/BarButton";
import { registerStatusItem } from "@/workbench/contributions";
import { maybeEditorService, registerStatusItem } from "@/workbench/contributions";
import { removeTask, upsertTask, useTasks } from "@/workbench/tasks";
import { pluginsBackend } from "./backend";
import { bumpView, deliverViewMessage } from "./views/viewStore";
@@ -259,6 +259,9 @@ export function handleRuntimeEvent(e: PluginEvent): boolean {
// The program was approved (`process:`) and verified by the host.
void executeAction("terminal.runCommand", { program: e.program, args: e.args, name: e.title, cwd: e.cwd });
return true;
case "openLocation":
void maybeEditorService()?.openFile(e.path, { selection: { line: e.line, col: e.column }, focus: true });
return true;
default:
return false;
}
+44 -14
View File
@@ -290,6 +290,49 @@ function projectFileIcon(pluginId: string, decl: FileIconDecl): () => void {
export const pluginToolWindowId = (pluginId: string, view: string) => `plugin.${pluginId}.${view}`;
function registerView(pluginId: string, decl: ViewDecl, icon: string): () => void {
return registerToolWindow({
id: pluginToolWindowId(pluginId, decl.id),
title: decl.title,
icon,
anchor: decl.location,
order: 900,
component: () =>
decl.web ? (
<WebView pluginId={pluginId} view={decl.id} label={decl.title} />
) : (
<PluginView pluginId={pluginId} view={decl.id} label={decl.title} />
),
});
}
function projectView(pluginId: string, decl: ViewDecl): () => void {
if (!decl.icon?.endsWith(".svg")) return registerView(pluginId, decl, decl.icon ?? "plugin");
let disposed = false;
let undo = () => {};
void pluginsBackend()
.assets(pluginId, `viewIcon/${decl.id}`)
.then((assets) => {
if (disposed) return;
const svg = assets.find(([id]) => id === `viewIcon/${decl.id}`)?.[1];
const name = `ext:${pluginId}/view-${decl.id}`;
const unregister = svg ? registerExternalIcons([[name, svg]]) : () => {};
const remove = registerView(pluginId, decl, svg ? name : "plugin");
undo = () => {
remove();
unregister();
};
})
.catch((e: unknown) => {
console.warn(`plugin ${pluginId}: view icon ${decl.id}`, e);
if (!disposed) undo = registerView(pluginId, decl, "plugin");
});
return () => {
disposed = true;
undo();
};
}
/** Registers the client side of the P7 points; returns their disposer. */
export function defineViewUiPoints(): () => void {
const disposers = [
@@ -326,20 +369,7 @@ export function defineViewUiPoints(): () => void {
}),
defineUiPoint<ViewDecl>("views", {
title: "Tool Windows",
project: ({ pluginId, decl }) =>
registerToolWindow({
id: pluginToolWindowId(pluginId, decl.id),
title: decl.title,
icon: decl.icon ?? "plugin",
anchor: decl.location,
order: 900,
component: () =>
decl.web ? (
<WebView pluginId={pluginId} view={decl.id} label={decl.title} />
) : (
<PluginView pluginId={pluginId} view={decl.id} label={decl.title} />
),
}),
project: ({ pluginId, decl }) => projectView(pluginId, decl),
}),
defineUiPoint<CustomEditorDecl>("customEditors", {
title: "Editors",
+31
View File
@@ -256,6 +256,37 @@ describe("context configuration", () => {
expect(contextConfig("docs/readme.md", configs)).toBeNull();
});
it("runs a module that imports relatively as a module of its package", () => {
const cli = "from .models import Priority, TaskList\n\n\ndef main():\n pass\n";
expect(contextConfig("src/tasks/cli.py", configs, cli)).toMatchObject({
kind: "python",
command: "../.venv/bin/python",
args: ["-m", "tasks.cli"],
cwd: "src",
debugAdapter: "debugpy",
});
expect(contextConfig("app/api/routes.py", [], "from . import models\n")).toMatchObject({
command: "python3",
args: ["-m", "app.api.routes"],
cwd: null,
});
expect(contextConfig("src/app/__main__.py", [], "from .cli import main\n")).toMatchObject({
args: ["-m", "app"],
cwd: "src",
});
// Inside a nested Python project: from its folder (the venv command stays relative to the root).
const nested = [cfg({ id: "pytest", kind: "pytest", command: "backend/.venv/bin/pytest", cwd: "backend" })];
expect(contextConfig("backend/app/jobs.py", nested, "from .db import session\n")).toMatchObject({
command: "../backend/.venv/bin/python",
args: ["-m", "app.jobs"],
cwd: "backend",
});
// Absolute imports, a top-level file, a folder that can't be a package: as a script.
expect(contextConfig("src/tasks/cli.py", configs, "import os\n")!.args).toEqual(["src/tasks/cli.py"]);
expect(contextConfig("tool.py", [], "from .x import y\n")!.args).toEqual(["tool.py"]);
expect(contextConfig("my-scripts/run.py", [], "from .x import y\n")!.args).toEqual(["my-scripts/run.py"]);
});
it("runs pytest for a test file even without a detected pytest config", () => {
expect(contextConfig("worker/test_worker.py", [])).toMatchObject({
command: "python3",
+56 -7
View File
@@ -236,7 +236,48 @@ function mainConfigFor(relPath: string, configs: RunConfig[]): RunConfig | null
return null;
}
function scriptConfigFor(relPath: string, configs: RunConfig[], windows = isWindows()): RunConfig | null {
/** `from .models import …`, `from . import x`: the file is a module of a package. */
const RELATIVE_IMPORT = /^[ \t]*from[ \t]+\.+[\w.]*[ \t]+import\b/m;
const IDENTIFIER = /^[A-Za-z_]\w*$/;
/**
* A module that imports relatively runs as `python -m <package>.<module>` from the folder its top
* package sits in (the last `src` of its path, else the Python project's folder): run as a script,
* its relative imports fail. `null` for other files.
*/
export function pythonModuleRun(
relPath: string,
content: string | undefined,
configs: RunConfig[],
): { cwd: string; module: string } | null {
if (!content || !RELATIVE_IMPORT.test(content)) return null;
const project = closest(
configs.filter((c) => c.kind === "python" || c.kind === "pytest"),
relPath,
)?.cwd?.replace(/^\.\/?|\/+$/g, "");
const segs = relPath.replace(/\.py$/, "").split("/");
const first = project && relPath.startsWith(`${project}/`) ? project.split("/").length : 0;
const src = segs.lastIndexOf("src", segs.length - 2);
const start = src >= first ? src + 1 : first;
const parts = segs.slice(start);
// Only a module inside a package can import relatively.
if (parts.length < 2 || !parts.every((p) => IDENTIFIER.test(p))) return null;
if (parts.at(-1) === "__main__" || parts.at(-1) === "__init__") parts.pop();
return { cwd: segs.slice(0, start).join("/"), module: parts.join(".") };
}
/** A command relative to the project root (`.venv/bin/python`), from `cwd` inside it. */
function fromSubfolder(command: string, cwd: string): string {
if (!cwd || !command.includes("/") || command.startsWith("/") || /^[A-Za-z]:/.test(command)) return command;
return "../".repeat(cwd.split("/").length) + command;
}
function scriptConfigFor(
relPath: string,
configs: RunConfig[],
windows = isWindows(),
content?: string,
): RunConfig | null {
const base: RunConfig = {
id: `context:file:${relPath}`,
name: relPath.split("/").pop() ?? relPath,
@@ -251,10 +292,18 @@ function scriptConfigFor(relPath: string, configs: RunConfig[], windows = isWind
};
if (relPath.endsWith(".py")) {
// Reuse the project's interpreter (venv / uv / poetry) when detected.
const py = projectPython(configs);
if (py)
return { ...base, kind: "python", command: py.command, args: [...py.prefix, relPath], debugAdapter: "debugpy" };
return { ...base, kind: "python", command: "python3", args: [relPath], debugAdapter: "debugpy" };
const py = projectPython(configs) ?? { command: "python3", prefix: [] };
const module = pythonModuleRun(relPath, content, configs);
if (module)
return {
...base,
kind: "python",
command: fromSubfolder(py.command, module.cwd),
args: [...py.prefix, "-m", module.module],
cwd: module.cwd || null,
debugAdapter: "debugpy",
};
return { ...base, kind: "python", command: py.command, args: [...py.prefix, relPath], debugAdapter: "debugpy" };
}
if (/\.[cm]?js$/.test(relPath)) return { ...base, command: "node", args: [relPath], debugAdapter: "js-debug" };
if (/\.ts$/.test(relPath)) return { ...base, command: "npx", args: ["tsx", relPath], debugAdapter: "js-debug" };
@@ -278,7 +327,7 @@ export function contextConfig(
): RunConfig | null {
const role = fileRole(relPath, content, windows, hasDjangoTests(configs));
if (role === "test") return testConfigFor(relPath, configs);
if (role === "main") return mainConfigFor(relPath, configs) ?? scriptConfigFor(relPath, configs, windows);
if (role === "script") return scriptConfigFor(relPath, configs, windows);
if (role === "main") return mainConfigFor(relPath, configs) ?? scriptConfigFor(relPath, configs, windows, content);
if (role === "script") return scriptConfigFor(relPath, configs, windows, content);
return null;
}
+7 -10
View File
@@ -85,18 +85,15 @@ export function delegatedGradleConfig(config: RunConfig, module: string, ctx: De
};
}
/** Writes an init script where Gradle can read it; it never changes, so once per session. */
/**
* Writes an init script where Gradle reads it: on the machine that runs the build (the
* remote host in remote development). It never changes, so once per session.
*/
const scriptPaths = new Map<string, Promise<string>>();
function writeScript(name: string, text: string): Promise<string> {
let written = scriptPaths.get(name);
if (!written) {
written = (async () => {
const { tempDir, join } = await import("@tauri-apps/api/path");
const path = await join(await tempDir(), name);
await call(commands.fsDelete(path)).catch(() => {});
await call(commands.fsCreateFileBytes(path, Array.from(new TextEncoder().encode(text))));
return path;
})().catch((e) => {
written = call(commands.runGradleInitScript(name, text)).catch((e) => {
scriptPaths.delete(name);
throw e;
});
@@ -121,7 +118,7 @@ export function withTestEvents<T extends Pick<RunConfig, "kind" | "command" | "a
export async function gradleTestLaunch<T extends RunConfig>(config: T): Promise<T> {
if (!isTauri() || !config.isTest || !isGradleCommand(config)) return config;
try {
return withTestEvents(config, await writeScript("openidea-gradle-tests.gradle", TESTS_SCRIPT));
return withTestEvents(config, await writeScript("openidea-gradle-tests", TESTS_SCRIPT));
} catch (e) {
console.warn("Gradle test init script not written", e);
return config;
@@ -150,7 +147,7 @@ export async function gradleDelegation(config: RunConfig): Promise<RunConfig | n
};
if (!delegatedGradleConfig(config, module, ctx)) return null;
const [script, configurationCache] = await Promise.all([
writeScript("openidea-gradle-run.gradle", RUN_SCRIPT),
writeScript("openidea-gradle-run", RUN_SCRIPT),
supportsConfigurationCache(ctx),
]);
return delegatedGradleConfig(config, module, { ...ctx, script, configurationCache });
@@ -57,7 +57,15 @@ describe("languages settings", () => {
});
it("defaults the PHP language level to Auto and keeps only known levels", () => {
expect(validateSettings({}).languages.php).toEqual({ interpreter: "", languageLevel: "" });
expect(validateSettings({}).languages.php).toEqual({
interpreter: "",
languageLevel: "",
includePaths: [],
stubs: [],
});
const lists = validateLanguages({ php: { includePaths: [" ../platform ", "", 3, "../platform"], stubs: "redis" } });
expect(lists.php.includePaths).toEqual(["../platform"]);
expect(lists.php.stubs).toEqual([]);
expect(validateLanguages({ php: { languageLevel: "7.4" } }).php.languageLevel).toBe("7.4");
for (const level of ["7.4.3", "9.0", 8.1, null]) {
expect(validateLanguages({ php: { languageLevel: level } }).php.languageLevel).toBe("");
@@ -0,0 +1,233 @@
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { executeAction, registerAction } from "@/platform/actions";
import { setContext } from "@/platform/context";
import { useProject } from "@/platform/project";
import type { PhpLanguageLevels, ProjectInfo, Settings } from "@/bindings";
type FsChange = { created: string[]; modified: string[]; removed: string[] };
const mocks = vi.hoisted(() => ({
notify: vi.fn(),
level: vi.fn(),
set: vi.fn(),
fsChanged: undefined as ((e: FsChange) => void) | undefined,
component: null as React.ComponentType | null,
}));
vi.mock("@/platform/ipc", () => ({
call: (p: unknown) => p,
isTauri: () => true,
onIdeEvent: (name: string, handler: (e: FsChange) => void) => {
if (name === "fsChanged") mocks.fsChanged = handler;
return () => {};
},
commands: { sdkPhpLanguageLevel: mocks.level, settingsSet: mocks.set },
}));
vi.mock("@/workbench/contributions", () => ({
notifications: () => ({ notify: mocks.notify }),
registerStatusItem: (item: { component: React.ComponentType }) => {
mocks.component = item.component;
return () => {};
},
}));
import { activatePhpStatus } from "../languages/PhpStatus";
const fromComposer: PhpLanguageLevels = {
effective: { version: "7.4.0", source: "require" },
auto: { version: "7.4.0", source: "require" },
member: null,
};
const focus = (language: string, path: string) =>
act(() => {
setContext("editorLangId", language);
setContext("editorPath", path);
});
let dispose: () => void;
beforeEach(() => {
vi.clearAllMocks();
mocks.level.mockResolvedValue(fromComposer);
mocks.set.mockImplementation((_scope: string, _key: string, value: string) =>
Promise.resolve({ languages: { php: { languageLevel: JSON.parse(value) } } }),
);
useProject.setState({ project: { root: "/project" } as ProjectInfo, settings: null });
setContext("projectOpen", true);
setContext("editorLangId", null);
setContext("editorPath", null);
dispose = activatePhpStatus();
});
afterEach(() => {
vi.useRealTimers();
cleanup();
dispose();
});
const mount = () => {
const Component = mocks.component!;
return render(<Component />);
};
const pick = async (label: string) => {
await act(() => executeAction("php.languageLevelPopup"));
fireEvent.click(await screen.findByRole("menuitemcheckbox", { name: label }));
};
describe("PHP language level status", () => {
it("stays hidden for other editors without asking the backend", async () => {
mount();
await focus("typescript", "/project/src/app.ts");
expect(screen.queryByRole("button")).toBeNull();
expect(mocks.level).not.toHaveBeenCalled();
});
it("shows the effective level of the focused PHP file and where it comes from", async () => {
mount();
await focus("php", "/project/src/Invoice.php");
const button = await screen.findByRole("button", { name: "PHP Language Level: PHP 7.4" });
expect(button.textContent).toBe("PHP 7.4");
expect(button.dataset.tooltip).toBe("PHP 7.4.0, the lowest version require.php in composer.json allows");
expect(mocks.level).toHaveBeenCalledWith("/project/src/Invoice.php");
await focus("markdown", "/project/README.md");
expect(screen.queryByRole("button")).toBeNull();
});
it("names config.platform.php and the language server default", async () => {
mocks.level.mockResolvedValueOnce({
effective: { version: "7.2.34", source: "platform" },
auto: null,
member: null,
});
mount();
await focus("php", "/project/a.php");
const button = await screen.findByRole("button", { name: "PHP Language Level: PHP 7.2" });
expect(button.dataset.tooltip).toBe("PHP 7.2.34, from config.platform.php in composer.json");
mocks.level.mockResolvedValueOnce({ effective: null, auto: null, member: null });
await focus("php", "/project/b.php");
const fallback = await screen.findByRole("button", { name: "PHP Language Level: PHP" });
expect(fallback.dataset.tooltip).toBe(
"No PHP version in the settings or composer.json: Intelephense uses its default",
);
});
it("writes the picked level to the project settings", async () => {
mount();
await focus("php", "/project/src/Invoice.php");
await screen.findByRole("button", { name: "PHP Language Level: PHP 7.4" });
mocks.level.mockResolvedValue({ ...fromComposer, effective: { version: "8.1.0", source: "settings" } });
await pick("8.1");
await waitFor(() =>
expect(mocks.set).toHaveBeenCalledWith("project", "languages.php.languageLevel", JSON.stringify("8.1")),
);
const button = await screen.findByRole("button", { name: "PHP Language Level: PHP 8.1" });
expect(button.dataset.tooltip).toBe("PHP 8.1.0, set in the settings");
expect(useProject.getState().settings?.languages?.php?.languageLevel).toBe("8.1");
});
it("shows what Auto resolves to and clears the level with it", async () => {
mocks.level.mockResolvedValue({ ...fromComposer, effective: { version: "8.1.0", source: "settings" } });
useProject.setState({ settings: { languages: { php: { languageLevel: "8.1" } } } as Settings });
mount();
await focus("php", "/project/src/Invoice.php");
await screen.findByRole("button", { name: "PHP Language Level: PHP 8.1" });
await act(() => executeAction("php.languageLevelPopup"));
expect(screen.getByRole("menuitemcheckbox", { name: "8.1" }).getAttribute("aria-checked")).toBe("true");
const auto = screen.getByRole("menuitemcheckbox", { name: /^Auto/ });
expect(auto.getAttribute("aria-checked")).toBe("false");
expect(auto.textContent).toContain("7.4");
mocks.level.mockResolvedValue(fromComposer);
fireEvent.click(auto);
await waitFor(() =>
expect(mocks.set).toHaveBeenCalledWith("project", "languages.php.languageLevel", JSON.stringify("")),
);
expect(await screen.findByRole("button", { name: "PHP Language Level: PHP 7.4" })).toBeTruthy();
});
it("re-reads the level once after composer.json of the project or a linked project changes", async () => {
useProject.setState({ project: { root: "/project", workspace: { projects: ["/linked/api"] } } as ProjectInfo });
mount();
await focus("php", "/project/src/Invoice.php");
await screen.findByRole("button", { name: "PHP Language Level: PHP 7.4" });
vi.useFakeTimers();
const change = (...modified: string[]) => act(() => mocks.fsChanged!({ created: [], modified, removed: [] }));
await change("/project/vendor/acme/lib/composer.json", "/project/src/composer.json");
await act(() => vi.advanceTimersByTimeAsync(1000));
expect(mocks.level).toHaveBeenCalledTimes(1);
// Each change in a burst restarts the wait.
await change("/project/composer.json");
await act(() => vi.advanceTimersByTimeAsync(200));
await change("/project/composer.json");
await act(() => vi.advanceTimersByTimeAsync(299));
expect(mocks.level).toHaveBeenCalledTimes(1);
await act(() => vi.advanceTimersByTimeAsync(1));
expect(mocks.level).toHaveBeenCalledTimes(2);
await change("/linked/api/composer.json");
await act(() => vi.advanceTimersByTimeAsync(300));
expect(mocks.level).toHaveBeenCalledTimes(3);
});
it("re-reads the level when the PHP language level setting changes, not other settings", async () => {
mount();
await focus("php", "/project/src/Invoice.php");
await screen.findByRole("button", { name: "PHP Language Level: PHP 7.4" });
act(() => useProject.setState({ settings: { editor: { fontSize: 15 } } as Settings }));
expect(mocks.level).toHaveBeenCalledTimes(1);
act(() => useProject.setState({ settings: { languages: { php: { languageLevel: "8.1" } } } as Settings }));
await waitFor(() => expect(mocks.level).toHaveBeenCalledTimes(2));
});
it("shows a workspace member's level but leaves its choice to the member's settings", async () => {
mocks.level.mockResolvedValue({ ...fromComposer, member: "/project/api" });
mount();
await focus("php", "/project/api/src/Invoice.php");
await screen.findByRole("button", { name: "PHP Language Level: PHP 7.4" });
await act(() => executeAction("php.languageLevelPopup"));
expect(screen.getByText("Set in the settings of the linked project api")).toBeTruthy();
const items = screen.getAllByRole("menuitemcheckbox");
expect(items.map((item) => item.getAttribute("aria-disabled"))).toEqual(items.map(() => "true"));
expect(screen.getByRole("menuitemcheckbox", { name: /^Auto/ }).getAttribute("aria-checked")).toBe("true");
fireEvent.click(screen.getByRole("menuitemcheckbox", { name: "8.1" }));
expect(mocks.set).not.toHaveBeenCalled();
});
it("drops the previous file's level and menu when another PHP file is focused", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
mocks.level.mockResolvedValueOnce({ ...fromComposer, member: "/project/api" });
mount();
await focus("php", "/project/api/src/Order.php");
await screen.findByRole("button", { name: "PHP Language Level: PHP 7.4" });
await act(() => executeAction("php.languageLevelPopup"));
expect(screen.getByRole("menu")).toBeTruthy();
mocks.level.mockRejectedValueOnce(new Error("project closed"));
await focus("php", "/project/src/Invoice.php");
expect(screen.queryByRole("button")).toBeNull();
expect(screen.queryByRole("menu")).toBeNull();
await waitFor(() => expect(warn).toHaveBeenCalled());
expect(mocks.level).toHaveBeenLastCalledWith("/project/src/Invoice.php");
expect(screen.queryByRole("button")).toBeNull();
// The menu does not come back with the next level.
await focus("php", "/project/api/src/Order.php");
await screen.findByRole("button", { name: "PHP Language Level: PHP 7.4" });
expect(screen.queryByRole("menu")).toBeNull();
warn.mockRestore();
});
it("ignores levels it does not list and reports write failures", async () => {
await executeAction("php.selectLanguageLevel", "9.9");
expect(mocks.set).not.toHaveBeenCalled();
mocks.set.mockRejectedValueOnce(new Error("write failed"));
await executeAction("php.selectLanguageLevel", "8.1");
expect(mocks.notify).toHaveBeenCalledWith(expect.objectContaining({ level: "error" }));
expect(useProject.getState().settings).toBeNull();
});
it("opens the PHP settings page while the widget is hidden", async () => {
const run = vi.fn();
const off = registerAction({ id: "file.settings", title: "Settings…", run });
mount();
await focus("typescript", "/project/src/app.ts");
await executeAction("php.languageLevelPopup");
expect(run.mock.calls.map(([ctx]) => ctx.arg)).toEqual(["languages.php"]);
off();
});
});
+2
View File
@@ -38,6 +38,7 @@ import { installActionProxies, installSettingsHost } from "./window/bridge";
import { AssignShortcutOverlay, showAssignShortcut } from "./keymap/AssignShortcut";
import { settingsSearchEntries } from "./searchIndex";
import { activatePythonStatus } from "./languages/PythonStatus";
import { activatePhpStatus } from "./languages/PhpStatus";
import { nativeToAsciiAction } from "./nativeToAscii";
import { isImeKey } from "@/platform/ime";
import { trackIconThemePages } from "./iconThemePages";
@@ -109,6 +110,7 @@ export function activate(): () => void {
trackRuntimeSettings(),
trackIconThemePages(),
activatePythonStatus(),
activatePhpStatus(),
unloadBuiltinPages,
registerOverlay({ id: "settings.dialog", component: LazySettingsDialog }),
registerActions(zoomActions()),
@@ -6,7 +6,7 @@
import { useId, useMemo } from "react";
import { IconButton, Input, type SelectOption } from "@/ui";
import { PHP_LANGUAGE_LEVELS } from "@/platform/languageSettings";
import { Row, Section, Select, Toggle } from "../components/Form";
import { GlobListEditor, Row, Section, Select, Toggle } from "../components/Form";
import { choosePath } from "./api";
import { useSetting } from "../components/context";
import { SdkField, tildify, useDetected } from "./components";
@@ -120,6 +120,8 @@ const PHP_LEVEL_OPTIONS: SelectOption[] = PHP_LANGUAGE_LEVELS.map((level) =>
export function PhpPage() {
const [interpreter] = useSetting<string>("languages.php.interpreter");
const [level, setLevel] = useSetting<string>("languages.php.languageLevel");
const [includePaths, setIncludePaths] = useSetting<string[]>("languages.php.includePaths");
const [stubs, setStubs] = useSetting<string[]>("languages.php.stubs");
const { list } = useDetected("php");
const selected = list.find((s) => s.path === interpreter) ?? (interpreter ? undefined : list[0]);
return (
@@ -131,6 +133,34 @@ export function PhpPage() {
>
<Select value={level} options={PHP_LEVEL_OPTIONS} onChange={setLevel} aria-label="PHP language level" />
</Row>
<Row
label="Include paths"
stacked
hint="Folders outside the project whose classes and functions Intelephense indexes too, such as the platform a module extends. Absolute, or relative to the project."
>
<GlobListEditor
value={includePaths ?? []}
onChange={setIncludePaths}
placeholder="/path/to/platform"
emptyText="No include paths"
itemLabel="Include path"
aria-label="Include paths"
/>
</Row>
<Row
label="Additional stubs"
stacked
hint="Intelephense stub sets loaded on top of its defaults, such as wordpress. Each ext-* in require or require-dev of composer.json adds its own."
>
<GlobListEditor
value={stubs ?? []}
onChange={setStubs}
placeholder="Stub name, e.g. wordpress"
emptyText="No additional stubs"
itemLabel="Stub"
aria-label="Additional stubs"
/>
</Row>
</Section>
<Section title="CLI Interpreter">
<SdkRow
@@ -0,0 +1,188 @@
import { useEffect, useRef } from "react";
import { create } from "zustand";
import { executeAction, registerActions } from "@/platform/actions";
import { getContext, useContextKeys } from "@/platform/context";
import { call, commands, onIdeEvent, type PhpLanguageLevel, type PhpLanguageLevels } from "@/platform/ipc";
import { PHP_LANGUAGE_LEVELS } from "@/platform/languageSettings";
import { basename, useProject } from "@/platform/project";
import { tx } from "@/platform/i18n";
import { notifications, registerStatusItem } from "@/workbench/contributions";
import { BarButton, Menu, type MenuItem } from "@/ui";
import { phpLanguageLevel } from "./api";
/** What the widget last resolved for a file, so the popup action can read it without a render. */
interface Status extends PhpLanguageLevels {
root: string;
path: string;
}
const usePopup = create<{ open: boolean; status: Status | null }>(() => ({ open: false, status: null }));
const close = () => usePopup.setState({ open: false });
const reset = () => usePopup.setState({ open: false, status: null });
/** Whether the widget is on screen, read straight from the stores (the action runs outside a render). */
function popupShown(): boolean {
const root = useProject.getState().project?.root;
const status = usePopup.getState().status;
const context = getContext();
return !!root && status?.root === root && status.path === context.editorPath && context.editorLangId === "php";
}
/** `8.1.0` as `8.1`, the way the levels are listed. */
const shortVersion = (level: PhpLanguageLevel) => level.version.split(".").slice(0, 2).join(".");
function origin(level: PhpLanguageLevel | null): string {
if (!level) return tx("No PHP version in the settings or composer.json: Intelephense uses its default");
const version = level.version;
switch (level.source) {
case "settings":
return tx`PHP ${version}, set in the settings`;
case "platform":
return tx`PHP ${version}, from config.platform.php in composer.json`;
case "require":
return tx`PHP ${version}, the lowest version require.php in composer.json allows`;
}
}
function PhpStatus() {
const root = useProject((s) => s.project?.root);
const linked = useProject((s) => s.project?.workspace?.projects);
const configured = useProject((s) => s.settings?.languages?.php?.languageLevel ?? "");
const path = useContextKeys((s) =>
s.editorLangId === "php" && typeof s.editorPath === "string" ? s.editorPath : null,
);
const status = usePopup((s) => s.status);
const open = usePopup((s) => s.open);
const anchor = useRef<HTMLButtonElement>(null);
useEffect(() => reset, [root]);
// The menu belongs to the focused file.
useEffect(() => close, [path]);
useEffect(() => {
if (!root || !path) return;
let live = true;
let sequence = 0;
let timer: ReturnType<typeof setTimeout> | undefined;
// Auto reads the composer.json of the project or of the linked project the file is in.
const manifests = new Set([root, ...(linked ?? [])].map((dir) => `${dir}/composer.json`));
const refresh = async () => {
const request = ++sequence;
try {
const level = await phpLanguageLevel(path);
if (live && sequence === request) usePopup.setState({ status: level && { root, path, ...level } });
} catch (e) {
console.warn("Cannot read the PHP language level", e);
}
};
void refresh();
const off = onIdeEvent("fsChanged", (e) => {
if (![...e.created, ...e.modified, ...e.removed].some((file) => manifests.has(file))) return;
clearTimeout(timer);
timer = setTimeout(() => void refresh(), 300);
});
return () => {
live = false;
clearTimeout(timer);
off();
};
}, [root, linked, path, configured]);
// The widget only renders once its project's level is in the store, so the popup action
// sees the same state as the button without waiting for an effect to publish a flag.
if (!path || !popupShown() || !status) return null;
const { effective, auto, member } = status;
const label = effective ? `PHP ${shortVersion(effective)}` : "PHP";
const chosen = effective?.source === "settings" ? shortVersion(effective) : "";
// The choices write the workspace's settings, which a member does not read.
const name = member && basename(member);
const items: MenuItem[] = [
{ kind: "header", label: "PHP Language Level" },
...(name
? [{ kind: "header", label: tx`Set in the settings of the linked project ${name}` } satisfies MenuItem]
: []),
...PHP_LANGUAGE_LEVELS.map((level): MenuItem =>
level
? {
label: level,
verbatim: true,
checked: level === chosen,
disabled: !!member,
run: () => void executeAction("php.selectLanguageLevel", level),
}
: {
label: "Auto",
shortcut: auto ? shortVersion(auto) : undefined,
checked: !chosen,
disabled: !!member,
run: () => void executeAction("php.selectLanguageLevel", ""),
},
),
];
return (
<>
<BarButton
ref={anchor}
tooltip={origin(effective)}
aria-label={tx`PHP Language Level: ${label}`}
aria-haspopup="menu"
aria-expanded={open}
onClick={() => void executeAction("php.languageLevelPopup")}
>
{label}
</BarButton>
<Menu
open={open}
onClose={close}
anchor={anchor}
placement="top-end"
items={items}
aria-label="PHP Language Level"
/>
</>
);
}
export function activatePhpStatus(): () => void {
const dispose = [
registerStatusItem({
id: "php.languageLevel",
title: "PHP Language Level",
align: "right",
priority: 89,
component: PhpStatus,
}),
registerActions([
{
id: "php.languageLevelPopup",
title: "PHP Language Level",
category: "Settings",
keywords: ["php version"],
when: "projectOpen",
run: () =>
popupShown()
? usePopup.setState((s) => ({ open: !s.open }))
: void executeAction("file.settings", "languages.php"),
},
{
id: "php.selectLanguageLevel",
title: "Select PHP Language Level",
category: "Settings",
hidden: true,
run: async ({ arg }) => {
const project = useProject.getState().project;
if (!project || typeof arg !== "string" || !PHP_LANGUAGE_LEVELS.some((level) => level === arg)) return;
try {
const settings = await call(
commands.settingsSet("project", "languages.php.languageLevel", JSON.stringify(arg)),
);
if (useProject.getState().project === project) useProject.setState({ settings });
} catch (e) {
notifications().notify({ level: "error", title: "Cannot select PHP language level", body: String(e) });
}
},
},
]),
];
return () => {
reset();
dispose.reverse().forEach((d) => d());
};
}
+15 -1
View File
@@ -4,7 +4,16 @@
*/
import { Channel } from "@tauri-apps/api/core";
import { create } from "zustand";
import { call, commands, isTauri, nextRequestId, type PythonPackage, type Sdk, type SdkKind } from "@/platform/ipc";
import {
call,
commands,
isTauri,
nextRequestId,
type PhpLanguageLevels,
type PythonPackage,
type Sdk,
type SdkKind,
} from "@/platform/ipc";
export type { PythonPackage, Sdk, SdkKind };
@@ -97,6 +106,11 @@ export function effectivePython(): Promise<string | null> {
return isTauri() ? call(commands.sdkPythonEffective()) : Promise.resolve(null);
}
/** The PHP language level of the project `path` belongs to; null outside Tauri. */
export function phpLanguageLevel(path: string): Promise<PhpLanguageLevels | null> {
return isTauri() ? call(commands.sdkPhpLanguageLevel(path)) : Promise.resolve(null);
}
/** File / folder chooser (Tauri dialog); null when cancelled or outside Tauri. */
export async function choosePath(opts: {
directory: boolean;
@@ -154,6 +154,18 @@ describe("expansion", () => {
for (const a of ["psvm", "sout", "fori", "iter", "main"]) expect(has("java", a)).toBe(true);
for (const a of ["main", "fun", "val", "sout"]) expect(has("kotlin", a)).toBe(true);
for (const a of ["todo", "fixme"]) expect(has("go", a)).toBe(true);
for (const a of ["fore", "forek", "pubf", "prisf", "thr", "eco", "rqro"]) expect(has("php", a)).toBe(true);
expect(has("java", "fore")).toBe(false);
});
it("writes PHP variables with their $", () => {
const php = (abbrev: string) => BUILTIN_TEMPLATES.find((t) => t.id === `php.${abbrev}`)!;
const fore = apply("", expandTemplate(php("fore")).snippet, 0);
expect(fore.text).toBe("foreach ( as $value) {\n \n}");
// The caret starts on the iterable.
expect(fore.sel[0]).toEqual([9, 9]);
expect(apply("", expandTemplate(php("forek")).snippet, 0).text).toBe("foreach ( as $key => $value) {\n \n}");
expect(apply("", expandTemplate(php("prosf")).snippet, 0).text).toBe("protected static function ()\n{\n \n}");
});
it("merges user edits, deletions and user templates", () => {
+40
View File
@@ -264,6 +264,45 @@ const kotlin = group(
],
);
const method = (abbrev: string, modifiers: string): Def => ({
abbrev,
description: `${modifiers} function`,
body: `${modifiers} function $NAME$($PARAMETERS$)\n{\n\t$END$\n}`,
});
/** PHP's `$` is written `$$` in a template body. */
const php = group(
"PHP",
"php",
["php"],
[
{
abbrev: "fore",
description: "foreach (iterable as value)",
body: "foreach ($ITERABLE$ as $$$VALUE$) {\n\t$END$\n}",
variables: [v("ITERABLE"), v("VALUE", undefined, '"value"')],
},
{
abbrev: "forek",
description: "foreach (iterable as key => value)",
body: "foreach ($ITERABLE$ as $$$KEY$ => $$$VALUE$) {\n\t$END$\n}",
variables: [v("ITERABLE"), v("KEY", undefined, '"key"'), v("VALUE", undefined, '"value"')],
},
method("pubf", "public"),
method("prof", "protected"),
method("prif", "private"),
method("pubsf", "public static"),
method("prosf", "protected static"),
method("prisf", "private static"),
{ abbrev: "thr", description: "throw new", body: "throw new $END$;" },
{ abbrev: "eco", description: "echo", body: 'echo "$END$";' },
{ abbrev: "inc", description: "include", body: "include '$END$';" },
{ abbrev: "inco", description: "include_once", body: "include_once '$END$';" },
{ abbrev: "rqr", description: "require", body: "require '$END$';" },
{ abbrev: "rqro", description: "require_once", body: "require_once '$END$';" },
],
);
const general = group(
"General",
"any",
@@ -458,6 +497,7 @@ export const BUILTIN_TEMPLATES: readonly LiveTemplate[] = [
...go,
...java,
...kotlin,
...php,
...sqlQuery,
...sqlStatement,
...sqlCode,
+91 -15
View File
@@ -31,6 +31,12 @@ function gateWithRebase() {
return { gate, run, pending, rebases: () => rebased };
}
/** Collects the emissions that reach the PTY, like `Session.fromTerminal`. */
function emitter(gate: ImeCommitGate) {
const sent: string[] = [];
return { sent, emit: (data: string) => gate.accept() && sent.push(data) };
}
describe("ImeCommitGate", () => {
it("takes the first emission of a commit and drops the repeated one", () => {
const { gate } = gateWithRebase();
@@ -42,10 +48,7 @@ describe("ImeCommitGate", () => {
it("keeps the real text of the pi case and drops the stale suffix", () => {
// xterm sends the committed diff first, then a stale `value.substring(start)`.
const { gate, run } = gateWithRebase();
const sent: string[] = [];
const emit = (data: string) => {
if (gate.accept()) sent.push(data);
};
const { sent, emit } = emitter(gate);
gate.begin();
emit("在吗");
gate.finish();
@@ -54,37 +57,101 @@ describe("ImeCommitGate", () => {
expect(sent).toEqual(["在吗"]);
});
it("accepts every emission once the commit is over", () => {
const { gate, run } = gateWithRebase();
it("lets a key the IME did not take through right after a commit", () => {
// Punctuation committed directly, then Enter within the commit's timeout: Enter must run.
const { gate, rebases } = gateWithRebase();
const { sent, emit } = emitter(gate);
gate.begin();
emit(",");
gate.keystroke();
emit("\r");
expect(sent).toEqual([",", "\r"]);
expect(rebases()).toBe(1);
// Outside a commit, or during a composition, a keystroke changes nothing.
gate.keystroke();
gate.compositionStart();
gate.keystroke();
expect(rebases()).toBe(1);
});
it("starts a new commit on the next IME keystroke even inside the timeout", () => {
// Two commits can arrive back to back; the second must not be swallowed by the first
// commit's still-running timeout.
const { gate } = gateWithRebase();
gate.begin();
expect(gate.accept()).toBe(true);
gate.begin();
expect(gate.accept()).toBe(true);
expect(gate.accept()).toBe(false);
});
it("drops the stale suffix of a WebKitGTK punctuation commit without compositionstart", () => {
// fcitx5 commits `,` as insertFromComposition + compositionend without compositionstart:
// xterm sends the keydown diff `,`, then compositionend emits `好,` from the previous
// composition's position. That suffix is what showed up as a duplicate in the terminal.
const { gate, run, rebases } = gateWithRebase();
const { sent, emit } = emitter(gate);
gate.begin();
emit(",");
gate.finish();
emit("好,");
run();
expect(sent).toEqual([","]);
expect(rebases()).toBe(1);
});
it("stands down for a real composition, then gates the next commit again", () => {
// compositionstart must not disable the gate for the whole session: the very next
// punctuation commit still needs deduplication.
const { gate, run, rebases } = gateWithRebase();
gate.begin();
gate.compositionStart();
expect(gate.accept()).toBe(true);
expect(gate.accept()).toBe(true);
gate.finish();
run();
expect(rebases()).toBe(1);
gate.begin();
expect(gate.accept()).toBe(true);
expect(gate.accept()).toBe(false);
});
it("accepts every emission while composing", () => {
const { gate } = gateWithRebase();
gate.compositionStart();
expect(gate.accept()).toBe(true);
expect(gate.accept()).toBe(true);
expect(gate.accept()).toBe(true);
});
it("rebases the textarea when the commit closes, not before", () => {
it("rebases after a composition that xterm sent by itself", () => {
const { gate, run, rebases } = gateWithRebase();
gate.begin();
gate.compositionStart();
gate.finish();
expect(rebases()).toBe(0);
run();
expect(rebases()).toBe(1);
});
it("stands down once a real compositionstart arrives", () => {
const { gate, rebases } = gateWithRebase();
it("rebases after a commit whose compositionend arrives, not before", () => {
const { gate, run, rebases } = gateWithRebase();
gate.begin();
gate.compositionStart();
expect(gate.accept()).toBe(true);
expect(gate.accept()).toBe(true);
gate.finish();
expect(rebases()).toBe(0);
// And a later commit is not gated either.
gate.begin();
expect(gate.accept()).toBe(true);
run();
expect(rebases()).toBe(1);
});
it("opens a commit from a composition input when no keydown preceded it", () => {
const { gate } = gateWithRebase();
gate.input();
expect(gate.accept()).toBe(true);
expect(gate.accept()).toBe(false);
// A second input event belongs to the same commit and must not reopen it.
gate.input();
expect(gate.accept()).toBe(false);
});
it("closes a commit whose compositionend never arrives", () => {
@@ -96,4 +163,13 @@ describe("ImeCommitGate", () => {
expect(rebases()).toBe(1);
expect(gate.accept()).toBe(true);
});
it("does not rebase into a composition that started before the close ran", () => {
const { gate, run, rebases } = gateWithRebase();
gate.begin();
gate.finish();
gate.compositionStart();
run();
expect(rebases()).toBe(0);
});
});
+72 -21
View File
@@ -1,14 +1,31 @@
/**
* Collapses the repeated data emissions of one IME commit.
*
* WebKitGTK + fcitx5 commit text without firing `compositionstart`, so xterm's `CompositionHelper`
* never advances `_compositionPosition.start` and keeps appending the committed text to its hidden
* textarea. A commit then reaches `onData` twice — once with the committed text, once with a stale
* `value.substring(start)` — and the same text shows up in the terminal twice.
* WebKitGTK commits IME text with an inconsistent mix of events, and xterm's
* `CompositionHelper` only copes with part of it:
*
* One commit must send one chunk: take the first emission and drop the rest, then rebase the
* textarea so the next commit starts from an empty value. IMEs that do report `compositionstart`
* (macOS, IBus) keep xterm's own bookkeeping, so the gate stands down for them.
* - A composed word (`你好`) reports `compositionstart`, so xterm records where the
* composition starts in its hidden textarea and sends the committed text once on
* `compositionend`.
* - Text that the IME commits directly (punctuation such as `;` or `,`) reports
* `insertFromComposition` + `compositionend` **without** `compositionstart`. xterm then
* finalizes the *previous* composition's position: it sends the keydown's textarea diff
* first and, once `compositionend` runs, a stale `value.substring(start)` on top. That
* second emission is the duplicated suffix users see (typing `;你好,` sends `,` and then
* `好,`). Newer WebKitGTK delivers the same commits as `insertText` without any
* composition events, which xterm sends through its keydown path; the stale emission there
* carries the previous commit's text instead.
*
* One commit must send one chunk, so the gate lets the first emission through and drops the
* rest until the commit closes. A commit is delimited by the IME's own keystroke (code 229)
* outside a composition, or by a composition input when the platform sends no keydown.
* Commits that really compose are left to xterm: `compositionstart` stands the gate down for
* that commit only — it must not disable the gate for the session, because the very next
* punctuation commit still needs deduplication.
*
* xterm never clears its hidden textarea by itself, so every commit ends with a rebase: the
* textarea is emptied after xterm's own senders have run, keeping the next commit from
* carrying old text.
*/
/** How long a commit may stay open when its `compositionend` never arrives. */
@@ -27,8 +44,8 @@ const WINDOW_SCHEDULER: ImeScheduler = {
export class ImeCommitGate {
private open = false;
private sent = false;
private composing = false;
private timer: number | null = null;
private native = false;
private readonly rebase: () => void;
private readonly scheduler: ImeScheduler;
@@ -37,21 +54,61 @@ export class ImeCommitGate {
this.scheduler = scheduler;
}
/** A commit starts: the IME's own keystroke (code 229) or its composition input event. */
/**
* A commit starts: the IME's own keystroke (code 229) outside a composition.
*
* Always starts a fresh commit, even when the previous one is still inside its timeout:
* two commits can arrive back to back, and the second must not be swallowed.
*/
begin(): void {
if (this.native || this.open) return;
if (this.composing) return;
this.cancelTimer();
this.open = true;
this.sent = false;
this.timer = this.scheduler.after(() => this.close(), IME_COMMIT_TIMEOUT_MS);
}
/** The commit ended: let the emissions both of xterm's senders queued go out, then rebase. */
/**
* A composition input arrived. Opens a commit only when no commit is open: resetting
* `sent` here would let the emissions already queued for this commit go out again.
*/
input(): void {
if (this.composing || this.open) return;
this.open = true;
this.sent = false;
this.timer = this.scheduler.after(() => this.close(), IME_COMMIT_TIMEOUT_MS);
}
/**
* An IME composition begins. xterm's own composition handling owns this commit, so the
* gate stays out of its way — but only until the composition ends.
*/
compositionStart(): void {
this.composing = true;
this.cancelTimer();
this.open = false;
this.sent = false;
}
/**
* The composition — or, on WebKitGTK, a directly committed character — ended.
* Bubble phase: xterm's own `compositionend` listener ran first and queued its send, so a
* 0 ms close lands behind that emission and cannot cut the committed text off.
*/
finish(): void {
if (!this.open) return;
this.composing = false;
this.cancelTimer();
this.timer = this.scheduler.after(() => this.close(), 0);
}
/**
* A keystroke the IME did not take (Enter, an arrow, a letter typed outside it): the commit
* is over, and what that key sends must reach the PTY.
*/
keystroke(): void {
if (this.open && !this.composing) this.close();
}
/** Whether this emission should reach the PTY. */
accept(): boolean {
if (!this.open) return true;
@@ -60,19 +117,13 @@ export class ImeCommitGate {
return true;
}
/** The IME reports composition events, so xterm needs no help — stay out of its way. */
compositionStart(): void {
this.native = true;
this.cancelTimer();
this.open = false;
this.sent = false;
}
/** Ends the commit and rebases the textarea to an empty value. */
close(): void {
this.cancelTimer();
if (!this.open) return;
// A new composition started before this close ran; its own finish will rebase.
if (this.composing) return;
this.open = false;
this.sent = false;
this.rebase();
}
+4 -3
View File
@@ -413,17 +413,18 @@ export class TerminalSession {
/**
* Routes a WebKitGTK / fcitx5 IME commit through `ImeCommitGate`: the platform reports the
* composition's own keystroke (code 229) and its input event but never `compositionstart`,
* which is what xterm's own bookkeeping is built on.
* commit's own keystroke (code 229) and its input event, but reports `compositionstart` only
* for composed words — never for the punctuation an IME commits directly.
*/
private watchImeCommit() {
const textarea = this.term.textarea;
if (!textarea) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.keyCode === 229) this.ime.begin();
else this.ime.keystroke();
};
const onInput = (e: InputEvent) => {
if (e.inputType === "insertFromComposition" || e.inputType === "insertCompositionText") this.ime.begin();
if (e.inputType === "insertFromComposition" || e.inputType === "insertCompositionText") this.ime.input();
};
const onCompositionStart = () => this.ime.compositionStart();
// Bubble phase, i.e. after xterm's own listener: the rebase must land behind the emissions
+6 -2
View File
@@ -62,9 +62,9 @@
.icon {
flex: none;
}
/* The name takes the whole row; when it doesn't fit, the grey detail after it gives way first. */
.label {
flex: 0 0 auto;
max-width: 75%;
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
@@ -74,6 +74,7 @@
font-weight: 600;
}
.detail {
flex: 0 10000 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
@@ -190,6 +191,9 @@
align-items: center;
gap: 8px;
}
.stashRow > .detail {
flex-shrink: 1;
}
.stashMessage {
flex: 1;
min-width: 0;
+1
View File
@@ -113,6 +113,7 @@ export const ATTRIBUTES: readonly AttributeDef[] = [
syn("keyword", "Keyword", [LD], "keyword"),
syn("identifier", "Identifiers › Local variable", [LD], "variable"),
syn("parameter", "Identifiers › Parameter", [LD], "param"),
syn("globalVariable", "Identifiers › Global variable", [LD], "global"),
syn("constant", "Identifiers › Constant", [LD], "constant"),
syn("field", "Identifiers › Instance field", [LD], "property"),
{ id: "staticSymbol", name: "Identifiers › Static symbol", path: [LD], token: "static", italicOnly: true },
+11
View File
@@ -41,6 +41,17 @@ describe(".icls schemes", () => {
expect(customs).toEqual(before);
});
it("imports the global variable colour", () => {
const xml =
'<scheme name="G" parent_scheme="Darcula"><attributes><option name="DEFAULT_GLOBAL_VARIABLE"><value>' +
'<option name="FOREGROUND" value="9876aa"/><option name="FONT_TYPE" value="2"/></value></option></attributes></scheme>';
expect(parseIcls(xml).scheme.attributes.globalVariable).toEqual({
foreground: "#9876aa",
bold: false,
italic: true,
});
});
it.each([
["Darcula", "ffffff", "dark"],
["Default", "000000", "light"],
+1
View File
@@ -11,6 +11,7 @@ const ATTRIBUTE_NAMES: Record<string, string> = {
DEFAULT_IDENTIFIER: "identifier",
DEFAULT_LOCAL_VARIABLE: "identifier",
DEFAULT_PARAMETER: "parameter",
DEFAULT_GLOBAL_VARIABLE: "globalVariable",
DEFAULT_CONSTANT: "constant",
DEFAULT_INSTANCE_FIELD: "field",
DEFAULT_STATIC_FIELD: "constant",
+14 -2
View File
@@ -76,7 +76,12 @@ export interface LanguagesValid {
python: { interpreter: string; activateVirtualenv: boolean };
ruby: { interpreter: string };
node: { interpreter: string; packageManager: "" | "npm" | "pnpm" | "yarn" | "bun" };
php: { interpreter: string; languageLevel: (typeof PHP_LANGUAGE_LEVELS)[number] };
php: {
interpreter: string;
languageLevel: (typeof PHP_LANGUAGE_LEVELS)[number];
includePaths: string[];
stubs: string[];
};
rust: {
toolchain: string;
stdlib: string;
@@ -145,7 +150,7 @@ export const DEFAULT_LANGUAGES: LanguagesValid = {
python: { interpreter: "", activateVirtualenv: true },
ruby: { interpreter: "" },
node: { interpreter: "", packageManager: "" },
php: { interpreter: "", languageLevel: "" },
php: { interpreter: "", languageLevel: "", includePaths: [], stubs: [] },
rust: { toolchain: "", stdlib: "", checkOnSave: true, checkCommand: "check", rustfmtArgs: "" },
go: { goroot: "", gopath: "", buildTags: DEFAULT_GO_BUILD_TAGS, modules: { env: {}, autoVendor: true } },
typescript: { version: "bundled" },
@@ -191,6 +196,11 @@ export function normalizeFolder(path: string): string {
/** A stored preview font size within IDEA's 8..72, or `null` (follow the editor font). */
const previewSize = (v: unknown): number | null =>
typeof v === "number" && Number.isFinite(v) ? Math.min(72, Math.max(8, Math.round(v))) : null;
/** Non-empty trimmed strings, without duplicates. */
const strings = (v: unknown): string[] =>
Array.isArray(v)
? [...new Set(v.filter((x): x is string => typeof x === "string").map((x) => x.trim()))].filter((x) => x)
: [];
const folders = (v: unknown): string[] =>
Array.isArray(v)
? [
@@ -249,6 +259,8 @@ export function validateLanguages(raw: unknown): LanguagesValid {
php: {
interpreter: text(php.interpreter, d.php.interpreter),
languageLevel: oneOf(php.languageLevel, PHP_LANGUAGE_LEVELS, d.php.languageLevel),
includePaths: strings(php.includePaths),
stubs: strings(php.stubs),
},
rust: {
toolchain: text(rust.toolchain, d.rust.toolchain),
+9
View File
@@ -5761,6 +5761,15 @@
"PHP language level": "Niveau de langage PHP",
"The PHP version Intelephense checks the code against: newer syntax and functions are reported. Auto uses config.platform.php from composer.json, else the lowest version require.php allows.": "La version de PHP par rapport à laquelle Intelephense vérifie le code : la syntaxe et les fonctions plus récentes sont signalées. Auto utilise config.platform.php de composer.json, sinon la version la plus basse autorisée par require.php.",
"from composer.json": "depuis composer.json",
"PHP Language Level": "Niveau de langage PHP",
"PHP Language Level: {label}": "Niveau de langage PHP : {label}",
"Select PHP Language Level": "Sélectionner le niveau de langage PHP",
"Cannot select PHP language level": "Impossible de sélectionner le niveau de langage PHP",
"No PHP version in the settings or composer.json: Intelephense uses its default": "Aucune version de PHP dans les paramètres ni dans composer.json : Intelephense utilise sa version par défaut",
"PHP {version}, set in the settings": "PHP {version}, défini dans les paramètres",
"PHP {version}, from config.platform.php in composer.json": "PHP {version}, depuis config.platform.php de composer.json",
"PHP {version}, the lowest version require.php in composer.json allows": "PHP {version}, la version la plus basse autorisée par require.php de composer.json",
"Set in the settings of the linked project {name}": "Défini dans les paramètres du projet lié {name}",
"select files in a git repository to shelve": "sélectionnez les fichiers d'un dépôt git à mettre de côté",
"select files from a single git repository to shelve": "sélectionnez les fichiers d'un seul dépôt git à mettre de côté",
"External file changes sync may be slow": "La synchronisation des modifications externes de fichiers peut être lente",
Loaded 100 of 104 files, more files were not shown because too many files have changed in this diff. Show more