Compare commits

...
Author SHA1 Message Date
l0ng-ai 5526ed5865 fix(java): replace module outputs with launch snapshots on Windows
Maven launches copy each module's class directory into a snapshot and put
the snapshot on the classpath in place of the original. The originals came
from `module.join("target/classes")`, which on Windows reads
`...\app\target/classes`, while JDT LS reports `...\app\target\classes`.
Compared as text, the original was never removed, so every resource was on
the classpath twice; MyBatis then failed with "XML fragments parsed from
previous mappers already contains value", and any classpath* scan saw
duplicates. Mixed-language builds deduplicated their outputs the same way.

Launch class directories are joined per component, and classpath entries
are compared by a key that ignores the verbatim prefix, the separator,
trailing separators and, on Windows, case.
2026-11-03 20:27:11 +08:00
Park Jong Hyun 4b1614af1f fix(spell): close a Markdown code fence only on a matching fence (#986)
Any ``` line closed an open fence, so a ```` block showing a ``` block ended early and the inner code was spell checked as prose. Fences now close as in CommonMark: the same character, at least as long as the opening fence, and no info string.

Co-authored-by: fhrmrk <[email protected]>
2026-11-03 19:29:16 +08:00
Park Jong Hyun bdeca5803b fix(spell): skip backslash commands like \brief in comments (#985)
Comments and Markdown went through the string-escape rule, which drops only the letter after a backslash, so the Doxygen command `\brief` was reported as the typo "rief". Outside string literals the whole word after a backslash is skipped now (Doxygen / TeX commands, escapes quoted in comments); string literals still skip only the escape letter.

Co-authored-by: fhrmrk <[email protected]>
2026-11-03 19:29:03 +08:00
Park Jong Hyun 839bd0cdb0 fix(http): add http:// to a URL whose query holds another URL (#988)
A request URL without a scheme defaults to http://, but the check was `contains("://")`, so `GET localhost:8080/login?next=http://…` was sent as is and failed to parse. Only a `://` after a leading RFC 3986 scheme counts now, both for HTTP Client requests and the proxy connection check.

Co-authored-by: fhrmrk <[email protected]>
2026-11-03 19:28:21 +08:00
Park Jong Hyun ebf2c2f8e3 fix(web): redirect a served root without a trailing slash (#987)
`/<token>/<key>` has no segments after the key, so the trailing-slash flag kept its initial value and the root's index.html was served at a URL without the slash; its relative links then resolved above the root and returned 404. The flag now comes from the path, so the root redirects to `/<token>/<key>/` like any other directory.

Co-authored-by: fhrmrk <[email protected]>
2026-11-03 19:28:10 +08:00
Jaw c599265ced fix(format): re-detect indents after Reformat Code (#957)
After Reformat Code rewrote a file's indents (e.g. spaces to tabs per Code Style), the editor kept the indents detected when the file was opened: tabs rendered at the old width, the status bar showed the old indent, and Enter inserted the old indent, mixing tabs and spaces until the file was reopened.

Whole-file format edits from language servers and contributed formatters are now tagged `format.reformat`, and after such a transaction the document runs the open-time indent detection again and reapplies its settings, so every split picks up the new indent.

Refs #935.

Co-authored-by: jaw52 <[email protected]>
2026-11-03 19:25:48 +08:00
l0ng-ai 867774b88a Merge pull request #607 from a-rookie-of-C-language/fix/settings-window-shadow-595
fix(windows): de-parent settings window so DWM paints its shadow
2026-11-03 19:22:02 +08:00
l0ng-ai d54e852c38 fix(java): bundle Lombok with the ECJ constructor-call fix (#1027)
JDT LS 1.63's ECJ 3.46.200 keeps a constructor's super()/this() call as the first of its statements. Lombok 1.18.48 wrapped that call in the @SneakyThrows try block (ECJ internal error, whole workspace build failed), emitted @NonNull checks before it, and generated constructors without it.

The bundled agent is now 1.18.48-openidea.2: v1.18.48 with lombok#4059 and the upstream constructor-call fix lombok#4089. The new file name gives JDT LS a new workspace, so classes from the previous agent are rebuilt.

Fixes #1000
Fixes #830
2026-11-03 19:07:33 +08:00
l0ng-ai efd03630b6 fix(navigation): label string-literal targets by their literal (#1025)
Navigation targets that start at a string literal (a Spring @Value("${key}") consumer reached from a configuration key) are named by the literal instead of the first identifier fragment inside it, which showed as "$". Applies to the declaration chooser, the quick-definition popup and the gutter line-marker chooser.
2026-11-03 18:40:26 +08:00
l0ng-ai bbc6a2bc4c fix(run): keep toolchain-derived values out of saved run configurations (#1023)
The run list applies Languages & Frameworks to every configuration it returns (interpreter or toolchain command, PATH, VIRTUAL_ENV, JAVA_HOME, GOROOT, GOPATH, Go module variables, RUSTUP_TOOLCHAIN), and every save sends the user configurations back from that list, so the derived values were written to run.toml: reopening a configuration showed the IDE's whole PATH in its environment, and since applied values never replace existing ones, a saved configuration kept the old toolchain after the interpreter or SDK changed.

Saving now drops what settings derive: an environment entry or an absolute command counts as derived when applying settings to the configuration without it produces it again. The user's own values, including ones for the same variables, are kept. Covers python, pytest, django-test, npm, cargo, go, maven, tomcat, ruby, rake, rspec and godot; Gradle stays as saved, as the run list already leaves it unapplied.
2026-11-03 18:17:00 +08:00
arookieofc cc4a29fa5d chore(ci): reformat types.ts and regenerate bindings to keep main green (#990)
Reformats src/features/run/types.ts with oxfmt and moves RubySettings to its sorted position in the generated src/bindings.ts.
2026-11-03 18:14:03 +08:00
l0ng-ai 5ad179a9e6 perf(java): stop configuration indexing from blocking navigation and keep it warm (#1012)
Java Go to Definition consults the Spring configuration command only inside @Value / getProperty / getRequiredProperty arguments (tree-sitter check), so ordinary Ctrl+click and Ctrl+hover go straight to JDT instead of waiting on a project-wide index rebuild.

The java-usages bundle (1.2.1) keeps its configuration snapshot across editor open/close, builds, saves of non-Java resources and reconciles; it invalidates on Java model changes and unsaved Java edits only.

Fixes #998
Fixes #1002
2026-11-03 18:12:43 +08:00
Jaw 291fa9affe feat(window): close other / left / right project tabs from the window-group tab menu (#965)
Right-clicking a project tab in a merged window group offers Close Other Tabs, Close Tabs to the Left and Close Tabs to the Right (left/right disabled at the ends); the same commands are in the Command Palette and can take shortcuts.

The backend closes the projects one at a time, focusing each so its own unsaved-changes prompt is visible, as Quit does. Cancelling a prompt stops the batch and leaves that project and the rest open; the clicked tab stays and is selected when the batch finishes. Starting Quit clears a pending batch.

Closes #942.

Co-authored-by: jaw52 <[email protected]>
2026-11-03 17:57:42 +08:00
Azeem Hassniandazeemhassni ecdd43a55a fix(vfs): poll for changes when the OS file watcher cannot start (#906)
Opening a project failed outright when the OS file watcher could not start (inotify limits used up, a file system without change notifications). The watcher now falls back to polling the same non-ignored directories every 2s, per directory unless flat so excluded trees like `node_modules` and `target` are not rescanned, and the project opens with an "External file changes sync may be slow" warning that gives the reason. Directory mtime changes seen only by polling are dropped so folders are not reported as modified.

Fixes #891.

Co-authored-by: azeemhassni <[email protected]>
2026-11-03 17:37:40 +08:00
Jaw ec465964d2 feat(editor): highlight JSP pages (#937)
JSP files had no highlighting and showed a `?` icon; the HTML mode left directives, scriptlets, JSP comments and EL as plain text and flagged `<c:out/>` / `<jsp:include/>` as unclosed elements.

- A JSP grammar layer (comments, directives, scriptlets, expressions, declarations, EL) with HTML and Java overlays, so markup split by JSP elements still nests and braces match across scriptlets.
- EL as its own grammar top: member/index/method calls, namespaced functions, keyword operators, ternaries, lambdas and collection literals.
- Block comments use `<%-- --%>` in markup and `//` in scriptlets; JSP comments and scriptlets fold.
- `.jsp .jspf .jspx .jsph .tag .tagf .tagx` map to the JSP language with a file icon; `gen:jsp-grammar` regenerates the parser and a test checks it matches the grammar.

Closes #870.

Co-authored-by: jaw52 <[email protected]>
2026-11-03 17:29:59 +08:00
arookieofc 86d082974f Merge pull request #2 from pcastro93/wayland-csd-shadow
fix(linux): draw GTK's CSD shadow on frameless windows under Wayland
2026-11-03 13:27:35 +08:00
Piettro CastroandClaude Opus 5.5 ec5302d838 fix(linux): draw GTK's CSD shadow on frameless windows under Wayland
tao makes frameless Wayland windows client-side decorated (an empty GTK
title bar, so the compositor adds no frame) but leaves them undecorated,
so GTK draws no shadow or resize margin and dark windows blend into
whatever is behind them. Decorating the window after build keeps that
empty title bar: GTK adds the theme's shadow and resize edges, nothing
else. Applies to project windows and the Settings window; X11 is
unchanged (decorating there would bring back the WM frame).

- Detect Wayland from GDK's backend (tao's own test), not env vars.
- Measure windows by GTK's content size, which excludes the shadow
  margin, so remembered geometry and Settings placement don't grow.
- Skip the in-page resize handles on Wayland; GTK provides the edges.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 00:14:14 -05:00
arookieofc dc14a46a17 Merge pull request #1 from fuqiuluo/fix/pr-607-revert-edge
fix(linux): keep settings window edge subtle
2026-11-03 08:42:45 +08:00
fuqiuluo d830d21d46 fix(linux): keep the settings edge above content 2026-11-03 07:20:03 +08:00
fuqiuluo fd8161a5a7 Revert "fix(linux): make the frameless settings window edge readable"
This reverts commit bbb1be69a2.
2026-11-03 07:15:24 +08:00
arookieofc bbb1be69a2 fix(linux): make the frameless settings window edge readable
#595 follow-up: the 1px --border-window hairline is nearly invisible on
the dark theme (#40434a against a dark desktop), which fuqiuluo confirmed
on Fedora 44 GNOME Wayland after the first edge commit. Blend the
foreground tone into the ring so the boundary reads on every theme;
still CSS-only, since Wayland gives undecorated windows no compositor
shadow to paint outside the surface.
2026-11-03 07:05:16 +08:00
arookieofc 58ff3e93d0 fix(linux): paint a hairline edge on the frameless settings window
Linux compositors draw no shadow for undecorated windows (Wayland has
no server-side decorations at all), so the frameless settings window is
indistinguishable from the desktop there — #595's follow-up after the
Windows DWM-shadow fix. Paint a 1px --border-window inset ring around
the window root: the webview draws the same hairline on every
windowing system.
2026-11-02 20:50:29 +08:00
arookieofc 795f8e85f1 fix(windows): de-parent settings window on Windows so DWM paints its shadow
An owned child (WS_POPUP) of a borderless parent gets no DWM shadow,
leaving the frameless settings window flat against its project window.
Ownership is already tracked by the settings-<parent> label (parent-close
cleanup in on_window_event, menu modality), so make the settings window
top-level on Windows; macOS and Linux keep the native parent link.
2026-11-02 20:50:29 +08:00
81 changed files with 1931 additions and 150 deletions

No files matched your search

+1
View File
@@ -10,6 +10,7 @@
"target",
"src/bindings.ts",
"src/editor/core/cm/angular/parser*.ts",
"src/editor/core/cm/jsp/parser*.ts",
"docs/plugins/themes.md",
"pnpm-lock.yaml",
"**/*.toml",
Generated
+1
View File
@@ -6153,6 +6153,7 @@ dependencies = [
"block2 0.6.2",
"dirs 6.0.0",
"dunce",
"gtk",
"ide-agent",
"ide-analysis",
"ide-build",
+3 -2
View File
@@ -2,8 +2,9 @@
## Project Lombok
`plugins/lombok/lombok-1.18.48-openidea.1.jar` contains Project Lombok v1.18.48 with the Equinox
classloader fix from [projectlombok/lombok#4059](https://github.com/projectlombok/lombok/pull/4059).
`plugins/lombok/lombok-1.18.48-openidea.2.jar` contains Project Lombok v1.18.48 with the Equinox
classloader fix from [projectlombok/lombok#4059](https://github.com/projectlombok/lombok/pull/4059)
and the ECJ constructor-call fix from [projectlombok/lombok#4089](https://github.com/projectlombok/lombok/pull/4089).
Copyright (C) 2009-2021 The Project Lombok Authors. MIT license and bundled component
notices are retained in [plugins/lombok/LICENSE](plugins/lombok/LICENSE) and inside the jar.
Source tag, patch commits and build instructions: [plugins/lombok/README.md](plugins/lombok/README.md).
+31 -4
View File
@@ -158,8 +158,11 @@ fn detect_dir(dir: &Path, model: &BuildModel, root: &Path) -> Option<BuildSystem
}
/// `root/rel`, without a trailing separator for the root itself.
/// `root` / `rel`, joined per `/`-separated component: on Windows a single join of
/// `a/b` keeps the `/`, and such a path does not compare equal to the same directory
/// as other tools (JDT LS) spell it.
fn subdir(root: &Path, rel: &str) -> PathBuf {
if rel.is_empty() { root.to_path_buf() } else { root.join(rel) }
rel.split('/').filter(|part| !part.is_empty()).fold(root.to_path_buf(), |path, part| path.join(part))
}
fn rel_str(root: &Path, dir: &Path) -> String {
@@ -737,11 +740,11 @@ fn launch_class_dirs(system: BuildSystem, module: &Path, test: bool) -> Vec<Path
.iter()
.flat_map(|s| {
["classes/kotlin", "classes/groovy", "classes/scala", "classes/java", "resources"]
.map(|d| module.join("build").join(d).join(s))
.map(|d| subdir(module, &format!("build/{d}/{s}")))
})
.collect(),
BuildSystem::Maven if test => vec![module.join("target/test-classes"), module.join("target/classes")],
BuildSystem::Maven => vec![module.join("target/classes")],
BuildSystem::Maven if test => vec![subdir(module, "target/test-classes"), subdir(module, "target/classes")],
BuildSystem::Maven => vec![subdir(module, "target/classes")],
_ => Vec::new(),
};
dirs.into_iter().filter(|d| d.is_dir()).collect()
@@ -751,6 +754,30 @@ fn launch_class_dirs(system: BuildSystem, module: &Path, test: bool) -> Vec<Path
mod tests {
use super::*;
/// Launch outputs use the platform separator throughout, so they compare equal to
/// the same directories as JDT LS reports them.
#[test]
fn launch_dirs_use_one_separator() {
let root = tempfile::tempdir().unwrap();
let module = subdir(root.path(), "parent/app");
for dir in ["target/classes", "target/test-classes", "build/classes/java/main", "build/resources/test"] {
std::fs::create_dir_all(subdir(&module, dir)).unwrap();
}
let foreign = if std::path::MAIN_SEPARATOR == '\\' { '/' } else { '\\' };
let dirs = [
launch_class_dirs(BuildSystem::Maven, &module, true),
launch_class_dirs(BuildSystem::Gradle, &module, true),
]
.concat();
assert_eq!(dirs.len(), 4, "{dirs:?}");
for dir in &dirs {
let suffix = dir.strip_prefix(root.path()).unwrap().to_string_lossy().into_owned();
assert!(!suffix.contains(foreign), "{suffix}");
}
assert_eq!(subdir(root.path(), ""), root.path());
assert_eq!(subdir(root.path(), "a//b/"), root.path().join("a").join("b"));
}
fn write(root: &Path, rel: &str, text: &str) {
let p = root.join(rel);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
+65 -3
View File
@@ -86,7 +86,7 @@ async fn resolve_launch(
};
let mut class_paths: Vec<String> = built.iter().map(|p| p.to_string_lossy().into_owned()).collect();
for p in std::mem::take(&mut info.class_paths) {
if !class_paths.contains(&p) {
if !class_paths.iter().any(|c| same_entry(c, &p, cfg!(windows))) {
class_paths.push(p);
}
}
@@ -131,15 +131,44 @@ async fn resolve_launch(
.map_err(|e| IdeError::internal(e.to_string()))?
}
/// A classpath entry as a comparable key. Entries come from JDT LS and from our own
/// build model, which spell one directory differently on Windows (`\\?\` prefix,
/// `/` or `\\`, drive-letter case); compared as text, a module output stayed on the
/// classpath next to its launch snapshot and every resource was found twice.
fn entry_key(path: &str, windows: bool) -> String {
let path = match path.strip_prefix(r"\\?\UNC\") {
Some(share) => format!(r"\\{share}"),
None => path.strip_prefix(r"\\?\").unwrap_or(path).to_owned(),
};
let mut key = if windows { path.replace('\\', "/") } else { path };
while key.len() > 1 && key.ends_with('/') {
key.pop();
}
if windows { key.to_lowercase() } else { key }
}
fn same_entry(a: &str, b: &str, windows: bool) -> bool {
a == b || entry_key(a, windows) == entry_key(b, windows)
}
fn use_snapshot(info: &mut JavaLaunchInfo, built: &[PathBuf], snapshot: &std::path::Path) -> Vec<PathBuf> {
use_snapshot_on(info, built, snapshot, cfg!(windows))
}
fn use_snapshot_on(
info: &mut JavaLaunchInfo,
built: &[PathBuf],
snapshot: &std::path::Path,
windows: bool,
) -> Vec<PathBuf> {
let mut copies = Vec::new();
for (index, source) in built.iter().enumerate() {
let dest = snapshot.join(index.to_string());
let original = source.to_string_lossy();
info.class_paths.retain(|p| p != original.as_ref());
info.class_paths.retain(|p| !same_entry(p, &original, windows));
let mut modular = false;
for path in &mut info.module_paths {
if path == original.as_ref() {
if same_entry(path, &original, windows) {
*path = dest.to_string_lossy().into_owned();
modular = true;
}
@@ -185,6 +214,39 @@ pub(crate) fn register(d: &mut Dispatcher) {
mod tests {
use super::*;
/// JDT LS and our build model spell a module output differently on Windows; the
/// snapshot must still replace it, or resources (MyBatis mapper XML) load twice.
#[test]
fn snapshots_replace_outputs_however_windows_spells_them() {
let built = PathBuf::from(r"D:\workspace\arm\arm-web\target/classes");
let jdtls = [
r"D:\workspace\arm\arm-web\target\classes",
r"d:\workspace\arm\arm-web\target\classes",
r"D:/workspace/arm/arm-web/target/classes/",
r"\\?\D:\workspace\arm\arm-web\target\classes",
];
for spelled in jdtls {
let mut info = JavaLaunchInfo {
class_paths: vec![spelled.into(), r"D:\m2\mybatis.jar".into()],
module_paths: vec![spelled.into()],
..Default::default()
};
use_snapshot_on(&mut info, std::slice::from_ref(&built), std::path::Path::new(r"C:\cache\launch-x"), true);
assert_eq!(info.class_paths, [r"D:\m2\mybatis.jar"], "{spelled}");
assert_eq!(
info.module_paths,
[std::path::Path::new(r"C:\cache\launch-x").join("0").to_string_lossy()],
"{spelled}"
);
}
assert!(same_entry(r"\\?\UNC\server\share\app\target\classes", r"\\server\share\app\target/classes", true));
// Different directories stay different, and other platforms keep case and `\`.
assert!(!same_entry(r"D:\a\target\classes", r"D:\a\target\test-classes", true));
assert!(!same_entry("/work/App/target/classes", "/work/app/target/classes", false));
assert!(!same_entry(r"/work/a\b", "/work/a/b", false));
assert!(same_entry("/work/app/target/classes/", "/work/app/target/classes", false));
}
#[test]
fn launch_snapshots_survive_jdt_overwrites_and_replace_module_outputs() {
let root = tempfile::tempdir().unwrap();
+12 -1
View File
@@ -62,13 +62,24 @@ fn detected_and_saved(
all
}
/// Saves the user configurations. They come from [`configs`], toolchains applied, so what
/// settings derived is dropped again: only the user's own choices reach `run.toml`.
pub fn save_configs(
host: &Host,
window: &str,
configs: &[RunConfig],
removed_detected_ids: &[String],
) -> IdeResult<()> {
ide_run::save_user_configs_with_removals(&host.project(window)?.root, configs, removed_detected_ids)
let project = host.project(window)?;
let settings = project.settings.effective();
let configs: Vec<RunConfig> = configs
.iter()
.map(|c| match c.kind.as_str() {
"gradle" => c.clone(),
_ => ide_sdk::apply::without_derived(c, &project.root, &settings),
})
.collect();
ide_run::save_user_configs_with_removals(&project.root, &configs, removed_detected_ids)
}
pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit<OutputChunk>) -> IdeResult<RunInfo> {
+7
View File
@@ -365,6 +365,13 @@ impl Host {
}),
)?
};
if let Some(reason) = watcher.polling() {
sink.emit(IdeEvent::Notify {
level: ide_core::event::NotifyLevel::Warning,
title: "External file changes sync may be slow".into(),
body: Some(reason.into()),
});
}
// Scratch files live outside the project; watch them too so the
// Project view and open editors follow changes made there.
+11 -1
View File
@@ -57,6 +57,16 @@ pub fn shared() -> IdeResult<reqwest::Client> {
Ok(client)
}
/// `url`, with `http://` in front unless it starts with a scheme: a `://` later
/// on (`localhost:8080/login?next=http://…`) does not count.
pub(crate) fn with_scheme(url: &str) -> String {
let has_scheme = url.split_once("://").is_some_and(|(scheme, _)| {
scheme.starts_with(|c: char| c.is_ascii_alphabetic())
&& scheme.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
});
if has_scheme { url.to_owned() } else { format!("http://{url}") }
}
/// HTTP Proxy › Check connection: GETs `url` through `proxy` (the settings as
/// edited, not yet applied). Any response but `407` counts as connected.
pub async fn check_connection(proxy: &ProxySettings, url: &str, cancel: &CancellationToken) -> IdeResult<String> {
@@ -64,7 +74,7 @@ pub async fn check_connection(proxy: &ProxySettings, url: &str, cancel: &Cancell
return Err(IdeError::invalid(problem));
}
let url = url.trim();
let url = if url.contains("://") { url.to_owned() } else { format!("http://{url}") };
let url = with_scheme(url);
let client = build(proxy)?;
let request = client.get(&url).timeout(Duration::from_secs(30));
let response = tokio::select! {
+16 -4
View File
@@ -1,6 +1,6 @@
//! Preparing (substitution, body includes) and executing requests.
use crate::client::describe;
use crate::client::{describe, with_scheme};
use crate::env::{Environments, Resolver};
use crate::parse::{BodyPart, RawRequest};
use ide_core::{CancellationToken, IdeError, IdeResult};
@@ -84,10 +84,8 @@ pub fn prepare(
Some((_, host)) => format!("http://{host}{url}"),
None => return Err(IdeError::invalid(format!("relative URL {url} needs a Host header"))),
}
} else if !url.contains("://") {
format!("http://{url}")
} else {
url
with_scheme(&url)
};
Ok(PreparedRequest {
name: raw.block.name.clone(),
@@ -214,6 +212,20 @@ mod tests {
assert_eq!(p.url, "http://localhost:9000/status");
}
#[test]
fn urls_without_a_scheme_default_to_http() {
let url = |text: &str| {
let f = parse(text);
prepare(&f.requests[0], &f.variables, &Environments::default(), None, None).unwrap().url
};
assert_eq!(url("GET localhost:8080/health\n"), "http://localhost:8080/health");
assert_eq!(
url("GET localhost:8080/login?next=http://localhost:3000/\n"),
"http://localhost:8080/login?next=http://localhost:3000/"
);
assert_eq!(url("GET https://api.test/cb?u=http://x\n"), "https://api.test/cb?u=http://x");
}
#[tokio::test]
async fn executes_against_a_local_server() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
+4
View File
@@ -45,6 +45,7 @@ const BUILTIN: &[(&str, &str)] = &[
("javascriptreact", include_str!("../../../languages/javascriptreact.toml")),
("json", include_str!("../../../languages/json.toml")),
("jsonc", include_str!("../../../languages/jsonc.toml")),
("jsp", include_str!("../../../languages/jsp.toml")),
("julia", include_str!("../../../languages/julia.toml")),
("kotlin", include_str!("../../../languages/kotlin.toml")),
("latex", include_str!("../../../languages/latex.toml")),
@@ -522,6 +523,7 @@ mod tests {
"Vue",
"Angular Template",
"ERB",
"JSP",
];
fn reg() -> LanguageRegistry {
@@ -803,6 +805,8 @@ mod tests {
("a.less", "less"),
("App.vue", "vue"),
("App.svelte", "svelte"),
("index.jsp", "jsp"),
("WEB-INF/tags/card.tag", "jsp"),
("package.json", "json"),
("tsconfig.json", "jsonc"),
("tsconfig.app.json", "jsonc"),
@@ -1,9 +1,9 @@
//! Source ranges for Spring-style configuration keys. This is a syntax index,
//! not a YAML evaluator: aliases, merge keys and runtime profile precedence are
//! intentionally left to the YAML server / application.
use super::lines::LineIndex;
use super::lines::{LineIndex, byte_at};
use super::{ParsedTree, Syntax, TreeError};
use ide_core::{LanguageId, Range};
use ide_core::{LanguageId, Pos, Range};
use tree_sitter::Node;
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -26,6 +26,40 @@ impl Syntax {
}
}
/// Spring's property lookups whose first argument names a configuration key.
const PROPERTY_LOOKUPS: [&str; 2] = ["getProperty", "getRequiredProperty"];
impl Syntax {
/// Whether `pos` in Java `text` lies where a configuration key can be written:
/// inside the arguments of a `@Value` annotation or of a `getProperty` /
/// `getRequiredProperty` call. Everywhere else Go to Definition is ordinary
/// Java navigation and need not consult the configuration index. A file the
/// grammar cannot parse answers false.
pub fn java_configuration_site(&self, text: &str, pos: Pos, cancelled: &dyn Fn() -> bool) -> bool {
let Some(at) = byte_at(text, pos) else { return false };
self.with_parsed(&LanguageId::new("java"), text, cancelled, |tree| {
let mut node = tree.tree.root_node().descendant_for_byte_range(at, at);
while let Some(n) = node {
let Some(parent) = n.parent() else { break };
let callee = parent.child_by_field_name("name").map(|name| &text[name.byte_range()]);
match (n.kind(), parent.kind(), callee) {
("annotation_argument_list", "annotation", Some(name)) => {
return name.rsplit('.').next() == Some("Value");
}
("argument_list", "method_invocation", Some(name)) if PROPERTY_LOOKUPS.contains(&name) => {
return true;
}
// Keys are expressions; nothing above a body or declaration can hold one.
(_, "block" | "class_body" | "interface_body" | "program", _) => return false,
_ => node = Some(parent),
}
}
false
})
.unwrap_or(false)
}
}
impl ParsedTree<'_> {
pub fn configuration_keys(&self, cancelled: &dyn Fn() -> bool) -> Vec<ConfigurationKey> {
let mut out = Vec::new();
@@ -188,6 +222,50 @@ fn unescape_property(raw: &str) -> Option<String> {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn java_configuration_sites() {
let text = r#"package a;
import org.springframework.beans.factory.annotation.Value;
class A {
@Value("${app.port:80}") int port;
@org.springframework.beans.factory.annotation.Value(value = Keys.NAME) String name;
@Values("x") int other;
@Override public String toString() {
String v = env.getProperty("app.host", "h") + env.getRequiredProperty(Keys.USER);
return helper.getValue("😀 app.port") + String.valueOf(port) + env.getProperty("k").trim();
}
}
"#;
let syntax = Syntax::new();
let at = |needle: &str, offset: u32| {
let line = text.lines().position(|l| l.contains(needle)).unwrap();
let byte = text.lines().nth(line).unwrap().find(needle).unwrap();
let col = text.lines().nth(line).unwrap()[..byte].encode_utf16().count() as u32 + offset;
syntax.java_configuration_site(text, Pos::new(line as u32, col), &|| false)
};
// @Value arguments (literal, qualified annotation, constant key).
assert!(at("${app.port", 2));
assert!(at("Keys.NAME", 6));
// Lookup arguments, including a constant and the default value.
assert!(at("\"app.host\"", 3));
assert!(at("\"h\"", 1));
assert!(at("Keys.USER", 1));
assert!(at("\"k\"", 1));
// Ordinary Java: names, other annotations, other calls, the lookup's own name and receiver.
assert!(!at("@Value(\"$", 2));
assert!(!at("int port", 4));
assert!(!at("@Values", 9));
assert!(!at("@Override", 2));
assert!(!at("getProperty(\"app", 3));
assert!(!at("env.getProperty(\"app", 1));
assert!(!at("app.port\")", 1));
assert!(!at("valueOf", 2));
assert!(!at(".trim", 2));
// Out of range and non-Java input.
assert!(!syntax.java_configuration_site(text, Pos::new(99, 0), &|| false));
assert!(!syntax.java_configuration_site("", Pos::new(0, 0), &|| false));
}
#[test]
fn yaml_structures_and_utf16() {
let text = "app:\n 'time-out': 1\n hosts:\n - url: a\n - {url: b}\n map: {east: {size: 2}}\n---\n\"app.name\": ok\n'😀': {port: 3}\n";
+10
View File
@@ -86,6 +86,16 @@ pub(crate) fn utf16_len(s: &str) -> u32 {
if s.is_ascii() { s.len() as u32 } else { s.chars().map(|c| c.len_utf16() as u32).sum() }
}
/// Byte offset of `pos` (line, UTF-16 column) in `text`; None past the last line.
pub(crate) fn byte_at(text: &str, pos: Pos) -> Option<usize> {
let mut start = 0;
for _ in 0..pos.line {
start += text[start..].find('\n')? + 1;
}
let line_end = text[start..].find('\n').map_or(text.len(), |i| start + i);
Some(start + utf16_to_byte(&text[start..line_end], pos.col as usize))
}
pub(crate) fn utf16_to_byte(s: &str, offset: usize) -> usize {
let mut units = 0;
for (i, c) in s.char_indices() {
+2 -2
View File
@@ -201,11 +201,11 @@ impl Syntax {
/// The grammar web features parse `lang` with: its own for HTML and CSS, the
/// CSS grammar for SCSS / Less (selectors and custom properties parse alike),
/// HTML for Vue / Svelte single-file components.
/// HTML for Vue / Svelte single-file components and JSP pages.
fn web_grammar(&self, lang: &LanguageId) -> Option<&Grammar> {
let name = match lang.as_str() {
"scss" | "less" => "css",
"vue" | "svelte" => "html",
"vue" | "svelte" | "jsp" => "html",
_ => self.grammar(lang).map(|g| g.spec.name)?,
};
web::is_web_grammar(name).then(|| self.grammar(&LanguageId::new(name))).flatten()
+1 -10
View File
@@ -6,7 +6,7 @@
//! `<style>` elements of HTML documents and feed the symbol index (see
//! [`super::Syntax::tags`]), so a lookup is an exact index query.
use super::lines::{LineIndex, utf16_to_byte};
use super::lines::{LineIndex, byte_at};
use super::{Grammar, parse};
use crate::SymbolTag;
use ide_core::{Pos, Range, SymbolKind};
@@ -130,15 +130,6 @@ pub(crate) fn definitions(g: &Grammar, css: Option<&Grammar>, tree: &Tree, text:
out
}
fn byte_at(text: &str, pos: Pos) -> Option<usize> {
let mut start = 0;
for _ in 0..pos.line {
start += text[start..].find('\n')? + 1;
}
let line_end = text[start..].find('\n').map_or(text.len(), |i| start + i);
Some(start + utf16_to_byte(&text[start..line_end], pos.col as usize))
}
/// The whitespace-separated token of `value` (starting at byte `base`) around byte `at`.
fn token_at(value: &str, base: usize, at: usize) -> Option<(usize, &str)> {
let rel = at.checked_sub(base)?;
+14 -2
View File
@@ -117,13 +117,25 @@ impl LspManager {
return Ok(None);
}
// Definition in ordinary Java code remains JDT's definition. Reverse
// configuration navigation is offered on string expressions; field/config
// accessor usages are merged into the ordinary Find Usages response.
// configuration navigation is offered only inside `@Value` / property
// lookup arguments: the command builds a project-wide index, and every
// Ctrl+click and Ctrl+hover probe would otherwise wait on it before JDT
// answers. Field/config accessor usages are merged into Find Usages.
if !config && !usages {
let text = snap.text.as_str();
if !text.contains("Value") && !text.contains("getProperty") && !text.contains("getRequiredProperty") {
return Ok(None);
}
let text = text.to_owned();
let cancelled = cancel.clone();
let site = tokio::task::spawn_blocking(move || {
syntax().java_configuration_site(&text, pos, &|| cancelled.is_cancelled())
})
.await
.unwrap_or(false);
if !site {
return Ok(None);
}
}
let key = if config {
let text = snap.text.as_str().to_owned();
+34 -1
View File
@@ -3035,7 +3035,40 @@ async fn configuration_extension_failure_preserves_native_java_navigation() {
"start": {"line": 0, "character": 1}, "end": {"line": 0, "character": 6}
}}),
);
let result = h.mgr.goto(doc, Pos::new(0, 2), GotoKind::Definition).await.unwrap();
let result = h.mgr.goto(doc, Pos::new(0, 12), GotoKind::Definition).await.unwrap();
assert_eq!(result.len(), 1);
assert_eq!(s.requested("workspace/executeCommand").len(), 1);
assert_eq!(s.requested("textDocument/definition").len(), 1);
}
/// The configuration command indexes the whole project, so Go to Definition (and
/// every Ctrl+hover probe) consults it only inside a key argument; ordinary Java
/// symbols in a Spring file go straight to JDT.
#[tokio::test(flavor = "multi_thread")]
async fn java_definition_consults_configuration_only_inside_key_arguments() {
let mut caps = incremental_caps();
caps["executeCommandProvider"] = json!({"commands": ["openidea.java.configuration"]});
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
let s = h.launcher.state("jdtls");
let text = "class A {\n @Value(\"${app.port}\") int port;\n String a() { return env.getProperty(Keys.HOST) + x.getValue(); }\n}\n";
let (doc, path) = h.open("A.java", text);
h.wait_status("jdtls", ServerStatus::Ready).await;
s.respond("workspace/executeCommand", json!({"locations": [], "keys": [], "roots": []}));
s.respond(
"textDocument/definition",
json!({"uri": uri_of(&path), "range": {
"start": {"line": 0, "character": 6}, "end": {"line": 0, "character": 7}
}}),
);
// Field type, field name, method name, the lookup's receiver and name, another call.
for pos in [Pos::new(1, 24), Pos::new(1, 28), Pos::new(2, 9), Pos::new(2, 22), Pos::new(2, 28), Pos::new(2, 53)] {
assert_eq!(h.mgr.goto(doc, pos, GotoKind::Definition).await.unwrap().len(), 1, "{pos:?}");
}
assert!(s.requested("workspace/executeCommand").is_empty());
assert_eq!(s.requested("textDocument/definition").len(), 6);
// Inside a placeholder and a constant lookup key: configuration first, JDT as fallback.
for pos in [Pos::new(1, 13), Pos::new(2, 41)] {
h.mgr.goto(doc, pos, GotoKind::Definition).await.unwrap();
}
assert_eq!(s.requested("workspace/executeCommand").len(), 2);
}
+41
View File
@@ -769,6 +769,47 @@ pub fn apply_to_run_configs(configs: &mut [RunConfig], root: &Path, s: &Settings
}
}
/// `config` as it should be saved: without what [`apply_to_run_configs`] derives from
/// settings, so a configuration read from the run list, edited and saved keeps following
/// Languages & Frameworks instead of freezing today's toolchain (and the IDE's whole
/// `PATH`) into `run.toml`. A value counts as derived when applying settings to the
/// configuration without it produces it again: an environment entry (`PATH`,
/// `VIRTUAL_ENV`, `JAVA_HOME`, `GOROOT`, …) or an absolute command its bare name
/// resolves to (the project interpreter, a toolchain's `cargo` / `go`, Maven's `mvn`).
pub fn without_derived(config: &RunConfig, root: &Path, s: &Settings) -> RunConfig {
let applied = |c: &RunConfig| {
let mut c = c.clone();
apply_to_run_configs(std::slice::from_mut(&mut c), root, s);
c
};
let mut out = config.clone();
// Until nothing changes: some entries are derived only once others are gone
// (the virtualenv's `PATH` is added only without `VIRTUAL_ENV`).
while let Some(key) = out
.env
.iter()
.find(|(key, value)| {
let mut probe = out.clone();
probe.env.remove(*key);
applied(&probe).env.get(*key) == Some(*value)
})
.map(|(key, _)| key.clone())
{
out.env.remove(&key);
}
let path = Path::new(&config.command);
if path.is_absolute()
&& let Some(name) = path.file_name().map(|n| n.to_string_lossy().into_owned())
{
let mut probe = out.clone();
probe.command = name.clone();
if applied(&probe).command == config.command {
out.command = name;
}
}
out
}
fn sh_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', r"'\''"))
}
+2 -2
View File
@@ -884,7 +884,7 @@ mod tests {
configure_jdtls(&mut plain, Path::new("/p"), Path::new("/data"), &[], None, None, JDTLS_MIN_JAVA, &[], None);
let data = |c: &LanguageServerConfig| c.args[c.args.iter().position(|a| a == "-data").unwrap() + 1].clone();
assert_ne!(data(&cfg), data(&plain), "a separate workspace, so everything is rebuilt with the agent");
let patched = Path::new("/managed/lombok-1.18.48-openidea.1.jar");
let patched = Path::new("/managed/lombok-1.18.48-openidea.2.jar");
let mut fixed = jdtls();
configure_jdtls(
&mut fixed,
@@ -1173,7 +1173,7 @@ mod kotlin_bridge_bundle_tests {
/// Installs the app-owned JDT LS command bundle, falling back to ordinary LSP on IO failure.
pub fn usages_bundle(data_dir: &Path) -> Option<PathBuf> {
let extension = data_dir.join("java-usages-1.2.0.jar");
let extension = data_dir.join("java-usages-1.2.1.jar");
let bytes = include_bytes!("../../../plugins/java-usages/openidea-java-usages.jar");
if std::fs::read(&extension).is_ok_and(|old| old == bytes) {
clean_usages_bundles(data_dir, &extension);
+1 -1
View File
@@ -507,7 +507,7 @@ mod tests {
let cache = repo.join("org/projectlombok/lombok/1.18.48");
fs::create_dir_all(&cache).unwrap();
fs::write(cache.join("lombok-1.18.48.jar"), b"official agent").unwrap();
let patched = PathBuf::from("/managed/lombok-1.18.48-openidea.1.jar");
let patched = PathBuf::from("/managed/lombok-1.18.48-openidea.2.jar");
let selected = lombok_agent(tmp.path(), None, Some(patched.clone()), Some(repo.clone()));
assert_eq!(selected.jar(), Some(patched.as_path()));
assert_eq!(selected.problem(), None);
+82 -1
View File
@@ -1,6 +1,6 @@
use crate::apply::{
apply_to_run_configs, configure_server, jdtls_jdk, maven_command, maven_options, project_jdk, python_interpreter,
terminal_activation, tsdk,
terminal_activation, tsdk, without_derived,
};
use crate::python::parse_pip_list;
use crate::{bin_dir, exe, venv_of};
@@ -1108,3 +1108,84 @@ fn go_modules_configure_gopls_in_place_and_commands_with_explicit_run_overrides(
configure_server(&mut cleared, tmp.path(), tmp.path(), &Settings::default());
assert_eq!(cleared.settings.unwrap()["env"], json!({}));
}
#[test]
fn saving_a_loaded_run_configuration_drops_what_settings_derived() {
if cfg!(windows) {
return; // Unix paths
}
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let touch = |rel: &str| {
let p = root.join(rel);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(&p, "").unwrap();
p.to_string_lossy().into_owned()
};
let mut s = Settings::default();
s.languages.python.interpreter = fake_venv(root, ".venv").to_string_lossy().into_owned();
s.languages.node.interpreter = touch("node/bin/node");
touch("rustup/toolchains/stable/bin/cargo");
s.languages.rust.toolchain = root.join("rustup/toolchains/stable").to_string_lossy().into_owned();
touch("go/bin/go");
s.languages.go.goroot = root.join("go").to_string_lossy().into_owned();
s.languages.go.gopath = root.join("gopath").to_string_lossy().into_owned();
s.languages.go.modules.env = BTreeMap::from([("GOPROXY".into(), "https://proxy.example".into())]);
fake_jdk(&root.join("jdk17"));
s.languages.java.jdk = root.join("jdk17").to_string_lossy().into_owned();
touch("maven/bin/mvn");
s.languages.java.maven.home = root.join("maven").to_string_lossy().into_owned();
s.languages.ruby.interpreter = touch("ruby/bin/ruby");
touch("ruby/bin/bundle");
s.tools.insert("godot".into(), ide_core::settings::ToolSettings { path: touch("Godot/godot") });
let saved = |kind: &str, command: &str, args: &[&str], env: &[(&str, &str)]| {
let mut c = run(kind, command, args);
c.detected = false;
c.env = env.iter().map(|(k, v)| ((*k).to_owned(), (*v).to_owned())).collect();
c
};
// As the user saved them: bare tools, only their own environment.
let configs = [
saved("python", "python", &["app.py"], &[]),
saved("pytest", ".venv/bin/pytest", &["-x"], &[("FOO", "1")]),
saved("npm", "npm", &["run", "dev"], &[]),
saved("cargo", "cargo", &["run"], &[]),
saved("go", "go", &["run", "."], &[]),
saved("go", "go", &["mod", "tidy"], &[("GOPROXY", "off")]),
saved("maven", "mvn", &["package"], &[]),
saved("tomcat", "catalina", &[], &[]),
saved("ruby", "ruby", &["app.rb"], &[]),
saved("rake", "bundle", &["exec", "rake"], &[("RAILS_ENV", "test")]),
saved("godot", "godot", &["--path", "."], &[]),
// The user's own values that merely look like derived ones stay.
saved("cargo", "/opt/rust/bin/cargo", &["build"], &[("PATH", "/custom/bin")]),
saved("maven", "mvn", &["verify"], &[("JAVA_HOME", "/other/jdk")]),
saved("python", "python", &["x.py"], &[("VIRTUAL_ENV", "/elsewhere/venv")]),
];
for user in configs {
let mut loaded = user.clone();
apply_to_run_configs(std::slice::from_mut(&mut loaded), root, &s);
assert_ne!(loaded, user, "{} {}: settings apply to it", user.kind, user.command);
let stored = without_derived(&loaded, root, &s);
assert_eq!(stored, user, "{} {}: saved as the user left it", user.kind, user.command);
let mut reloaded = stored;
apply_to_run_configs(std::slice::from_mut(&mut reloaded), root, &s);
assert_eq!(reloaded, loaded, "{} {}: runs the same after a save", user.kind, user.command);
}
// Another bare name for the interpreter comes back as the interpreter's own.
let mut loaded = saved("python", "python3", &["app.py"], &[]);
apply_to_run_configs(std::slice::from_mut(&mut loaded), root, &s);
assert_eq!(without_derived(&loaded, root, &s).command, exe("python"));
// A saved configuration follows a later interpreter change instead of a frozen PATH.
let mut loaded = saved("ruby", "ruby", &["app.rb"], &[]);
apply_to_run_configs(std::slice::from_mut(&mut loaded), root, &s);
let mut next = without_derived(&loaded, root, &s);
s.languages.ruby.interpreter = touch("ruby2/bin/ruby");
apply_to_run_configs(std::slice::from_mut(&mut next), root, &s);
let ruby2 = root.join("ruby2/bin");
assert_eq!(next.command, ruby2.join("ruby").to_string_lossy());
assert!(next.env["PATH"].starts_with(&*ruby2.to_string_lossy()), "{}", next.env["PATH"]);
}
+20 -4
View File
@@ -18,7 +18,8 @@ use std::time::SystemTime;
pub use words::{MIN_WORD_CHARS, WordSpan, words};
/// Where a region's words come from; strings and comments understand escapes.
/// Where a region's words come from; strings understand escapes, comments and
/// prose skip backslash commands (`\brief`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RegionKind {
Comment,
@@ -421,15 +422,22 @@ fn capitalize(s: &str) -> String {
/// blocks and inline code spans.
pub fn prose_regions(text: &str) -> Vec<Region> {
let mut out = Vec::new();
let mut fence: Option<&str> = None;
// The open fence: its character and length.
let mut fence: Option<(char, usize)> = None;
let mut offset = 0;
for line in text.split_inclusive('\n') {
let start = offset;
offset += line.len();
let trimmed = line.trim_start();
let marker = ["```", "~~~"].into_iter().find(|m| trimmed.starts_with(m));
let marker = ['`', '~'].into_iter().find_map(|c| {
let n = trimmed.len() - trimmed.trim_start_matches(c).len();
(n >= 3).then_some((c, n))
});
match (fence, marker) {
(Some(open), Some(m)) if m == open => {
// CommonMark: a closing fence uses the same character, is at least as
// long as the opening one and has no info string, so a ```` fence can
// show a ``` block.
(Some((open, open_len)), Some((c, n))) if c == open && n >= open_len && trimmed[n..].trim().is_empty() => {
fence = None;
continue;
}
@@ -720,6 +728,14 @@ mod tests {
assert_eq!(spans, ["Some prose ", " here\n", "more\n"]);
}
#[test]
fn markdown_fences_close_on_a_matching_fence_only() {
let text = "Example:\n````markdown\n```rust\nfn wrng() {}\n```\n````\nmore\n~~~\n```\nnot clsed\n~~~~\nend\n";
let regions = prose_regions(text);
let spans: Vec<_> = regions.iter().map(|r| &text[r.start..r.end]).collect();
assert_eq!(spans, ["Example:\n", "more\n", "end\n"]);
}
#[test]
fn cancellation() {
let s = Speller::default();
+35 -5
View File
@@ -17,7 +17,11 @@ pub struct WordSpan {
/// Words of `text[start..end]` in order, as absolute byte ranges.
pub fn words(text: &str, start: usize, end: usize, kind: RegionKind, out: &mut Vec<WordSpan>) {
let Some(slice) = text.get(start..end) else { return };
let escapes = !matches!(kind, RegionKind::Name);
let escapes = match kind {
RegionKind::Name => Escapes::None,
RegionKind::String => Escapes::Char,
RegionKind::Comment | RegionKind::Prose => Escapes::Word,
};
let mut chunk_start = None;
for (i, c) in slice.char_indices().chain(std::iter::once((slice.len(), ' '))) {
if c.is_whitespace() {
@@ -30,6 +34,18 @@ pub fn words(text: &str, start: usize, end: usize, kind: RegionKind, out: &mut V
}
}
/// What a `\` hides from the spell checker.
#[derive(Clone, Copy, PartialEq, Eq)]
enum Escapes {
/// Identifiers have no escapes.
None,
/// String literals: the escape letter (`\n`, `\t`), the rest of the word is text.
Char,
/// Comments and prose: the whole word, a Doxygen / TeX command (`\brief`,
/// `\param`) or an escape quoted in a comment (`\n`).
Word,
}
/// URLs, e-mail addresses and the like are not prose.
fn skip_chunk(chunk: &str) -> bool {
if chunk.contains("://") || chunk.starts_with("www.") {
@@ -41,7 +57,7 @@ fn skip_chunk(chunk: &str) -> bool {
}
}
fn chunk(slice: &str, from: usize, to: usize, escapes: bool, base: usize, out: &mut Vec<WordSpan>) {
fn chunk(slice: &str, from: usize, to: usize, escapes: Escapes, base: usize, out: &mut Vec<WordSpan>) {
let text = &slice[from..to];
if skip_chunk(text) {
return;
@@ -57,9 +73,15 @@ fn chunk(slice: &str, from: usize, to: usize, escapes: bool, base: usize, out: &
if let Some(s) = run.take() {
token(text, s, i, base + from, out);
}
// `\n`, `\t`, `é`: the escape letter is not part of the next word.
if escapes && c == '\\' {
chars.next();
if c == '\\' {
match escapes {
Escapes::None => {}
// `\n`, `\t`, `é`: the escape letter is not part of the next word.
Escapes::Char => {
chars.next();
}
Escapes::Word => while chars.next_if(|&(_, c)| c.is_alphanumeric() || c == '_').is_some() {},
}
}
}
if let Some(s) = run {
@@ -164,6 +186,14 @@ mod tests {
assert_eq!(split("// 中文注释 naïve", RegionKind::Comment), ["naïve"]);
}
#[test]
fn skips_backslash_commands_in_comments() {
assert_eq!(split("/// \\brief Returns the sum.", RegionKind::Comment), ["Returns", "the", "sum"]);
assert_eq!(split("@see \\ref Foo, \\param[in] count", RegionKind::Comment), ["see", "Foo", "count"]);
assert_eq!(split("prints \\nthe line", RegionKind::Comment), ["prints", "line"]);
assert_eq!(split("\\emph{word} text", RegionKind::Prose), ["word", "text"]);
}
#[test]
fn keeps_apostrophes_inside_words() {
assert_eq!(split("'don't' shouldn't", RegionKind::Comment), ["don't", "shouldn't"]);
+7 -5
View File
@@ -637,13 +637,15 @@ pub static TOOLS: &[ToolSpec] = &[
display: "Lombok (jdtls agent)",
kind: Plugin,
commands: &["lombok.jar"],
// Equinox removed getModuleClassLoader(boolean), breaking lombok.config lookup.
// Return to Maven Central once an official release includes lombok#4059.
// Equinox removed getModuleClassLoader(boolean), breaking lombok.config lookup (lombok#4059);
// ECJ 3.46.200 keeps the constructor call in a constructor's statements, which
// @SneakyThrows / @NonNull must not wrap or precede (lombok#4089).
// Return to Maven Central once an official release includes both.
// Source commit, build instructions and checksum: plugins/lombok/README.md.
method: Method::Bundled {
filename: "lombok-1.18.48-openidea.1.jar",
version: "1.18.48-openidea.1",
bytes: include_bytes!("../../../plugins/lombok/lombok-1.18.48-openidea.1.jar"),
filename: "lombok-1.18.48-openidea.2.jar",
version: "1.18.48-openidea.2",
bytes: include_bytes!("../../../plugins/lombok/lombok-1.18.48-openidea.2.jar"),
},
fallback: None,
version: V,
+2 -2
View File
@@ -56,9 +56,9 @@ fn bundled_lombok_replaces_legacy_installs_on_every_platform_without_downloads()
let info = tools.info("lombok", &[]).unwrap();
let path = managed_bundle(env.root.parent().unwrap(), "lombok").unwrap();
assert_eq!(info.status, ToolStatus::Installed);
assert_eq!(info.version.as_deref(), Some("1.18.48-openidea.1"));
assert_eq!(info.version.as_deref(), Some("1.18.48-openidea.2"));
assert_eq!(info.path, Some(path.clone().into()));
assert_eq!(path.file_name().unwrap(), "lombok-1.18.48-openidea.1.jar");
assert_eq!(path.file_name().unwrap(), "lombok-1.18.48-openidea.2.jar");
assert!(!info.can_install && !info.can_uninstall);
assert_eq!(tools.unavailable_reason(registry::find("lombok").unwrap()), None);
assert_eq!(fs::read(&legacy).unwrap(), b"old official agent", "other windows may still use it");
+130 -29
View File
@@ -5,12 +5,15 @@
//! that skips excluded / git-ignored directories (Linux, where a recursive
//! inotify watch would register every directory of `node_modules` and
//! `target` and exhaust `max_user_watches`).
//!
//! When the OS watcher cannot start (inotify limits used up, a file system
//! without change notifications), the same directories are polled instead.
use crate::FsBatch;
use crate::rules::{ExcludeMatcher, GitignoreStack};
use ide_core::{IdeError, IdeEvent, IdeResult, SharedSink};
use notify_debouncer_full::notify::event::{ModifyKind, RenameMode};
use notify_debouncer_full::notify::{self, EventKind, RecommendedWatcher, RecursiveMode, Watcher as _};
use notify_debouncer_full::notify::{self, EventKind, PollWatcher, RecommendedWatcher, RecursiveMode, WatcherKind};
use notify_debouncer_full::{DebounceEventResult, DebouncedEvent, Debouncer, NoCache, new_debouncer_opt};
use parking_lot::{Condvar, Mutex, RwLock};
use std::collections::{BTreeMap, BTreeSet, HashSet};
@@ -23,6 +26,8 @@ use std::time::{Duration, Instant};
const DEBOUNCE: Duration = Duration::from_millis(100);
/// Upper bound on how long a continuous stream of events is held back.
const MAX_DELAY: Duration = Duration::from_millis(1000);
/// How often the polling fallback rescans the watched directories.
const POLL_INTERVAL: Duration = Duration::from_secs(2);
/// How directories are registered with the OS.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -43,6 +48,7 @@ impl Default for WatchMode {
}
type OnChange = Box<dyn Fn(&FsBatch) + Send + Sync>;
type Emit = Arc<dyn Fn(&FsBatch) + Send + Sync>;
/// Debounced watcher. Emits `IdeEvent::FsChanged` to `sink` and invokes
/// `on_change` (used by the index) with the same batch. Stops when dropped;
@@ -51,12 +57,14 @@ pub struct Watcher {
inner: Inner,
excludes: SharedExcludes,
rules: Arc<GitignoreStack>,
polling: Option<String>,
}
type SharedExcludes = Arc<RwLock<ExcludeMatcher>>;
enum Inner {
Recursive(#[allow(dead_code)] Debouncer<RecommendedWatcher, NoCache>),
Polling(#[allow(dead_code)] Debouncer<PollWatcher, NoCache>),
PerDirectory { state: Arc<DirState>, thread: Option<JoinHandle<()>> },
}
@@ -76,7 +84,7 @@ impl Watcher {
let filter = EventFilter::new(root.clone(), &excludes);
let shared = filter.excludes.clone();
let rules = filter.gitignore.clone();
let emit = move |batch: &FsBatch| {
let emit: Emit = Arc::new(move |batch: &FsBatch| {
// VCS-metadata-only batches (`batch.vcs`) carry no worktree paths:
// consumers that refresh on any `fsChanged` would reschedule purely
// from the backend's own `.git/index` write.
@@ -91,13 +99,21 @@ impl Watcher {
sink.emit(IdeEvent::ExcludedDirsChanged { dirs: batch.excluded_dirs.iter().map(Into::into).collect() });
}
on_change(batch);
});
let (inner, polling) = match Self::start_native(&root, filter.clone(), emit.clone(), mode) {
Ok(inner) => (inner, None),
// Rather than failing to open the project, keep it in sync a little later.
Err(e) => {
tracing::warn!("{e}; polling {} for changes instead", root.display());
(Self::start_polling(&root, filter, emit, mode)?, Some(e.message))
}
};
let inner = match mode {
WatchMode::Recursive => Self::start_recursive(root, filter, emit, RecursiveMode::Recursive),
WatchMode::Flat => Self::start_recursive(root, filter, emit, RecursiveMode::NonRecursive),
WatchMode::PerDirectory => Self::start_per_directory(root, filter, emit),
}?;
Ok(Self { inner, excludes: shared, rules })
Ok(Self { inner, excludes: shared, rules, polling })
}
/// Why changes are polled for instead of reported by the OS; `None` when they are.
pub fn polling(&self) -> Option<&str> {
self.polling.as_deref()
}
/// Replaces the exclude globs (Settings › Project Structure / File Types
@@ -116,21 +132,53 @@ impl Watcher {
/// Number of OS-level watches (1 in recursive and flat mode).
pub fn watch_count(&self) -> usize {
match &self.inner {
Inner::Recursive(_) => 1,
Inner::Recursive(_) | Inner::Polling(_) => 1,
Inner::PerDirectory { state, .. } => state.watched.lock().len(),
}
}
fn start_recursive(
root: PathBuf,
fn start_native(root: &Path, filter: EventFilter, emit: Emit, mode: WatchMode) -> IdeResult<Inner> {
let config = notify::Config::default();
match mode {
WatchMode::Recursive => {
Self::start_debounced(root, filter, emit, RecursiveMode::Recursive, config).map(Inner::Recursive)
}
WatchMode::Flat => {
Self::start_debounced(root, filter, emit, RecursiveMode::NonRecursive, config).map(Inner::Recursive)
}
WatchMode::PerDirectory => Self::start_per_directory::<RecommendedWatcher>(root, filter, emit, config),
}
}
/// Polls per directory unless flat: a recursive poll would rescan
/// `node_modules` and `target` every round.
fn start_polling(root: &Path, filter: EventFilter, emit: Emit, mode: WatchMode) -> IdeResult<Inner> {
let config = notify::Config::default().with_poll_interval(POLL_INTERVAL);
match mode {
WatchMode::Flat => {
Self::start_debounced(root, filter, emit, RecursiveMode::NonRecursive, config).map(Inner::Polling)
}
WatchMode::Recursive | WatchMode::PerDirectory => {
Self::start_per_directory::<PollWatcher>(root, filter, emit, config)
}
}
}
fn start_debounced<W: notify::Watcher>(
root: &Path,
filter: EventFilter,
emit: impl Fn(&FsBatch) + Send + 'static,
emit: Emit,
recursive: RecursiveMode,
) -> IdeResult<Inner> {
config: notify::Config,
) -> IdeResult<Debouncer<W, NoCache>> {
let polled = W::kind() == WatcherKind::PollWatcher;
// No file-id cache: building it walks and stats the whole tree before `watch` returns,
// which made opening large projects block for seconds. Renames arrive as remove + create.
let handler = move |result: DebounceEventResult| match result {
Ok(events) => {
Ok(mut events) => {
if polled {
events.retain(|e| !is_dir_touch(&e.event));
}
let (batch, _) = filter.batch_detailed(&events);
if !batch.is_empty() {
emit(&batch);
@@ -142,18 +190,19 @@ impl Watcher {
}
}
};
let mut debouncer =
new_debouncer_opt::<_, RecommendedWatcher, _>(DEBOUNCE, None, handler, NoCache, notify::Config::default())
.map_err(|e| io_error(format!("cannot start file watcher: {e}")))?;
debouncer.watch(&root, recursive).map_err(|e| io_error(format!("cannot watch {}: {e}", root.display())))?;
Ok(Inner::Recursive(debouncer))
let mut debouncer = new_debouncer_opt::<_, W, _>(DEBOUNCE, None, handler, NoCache, config)
.map_err(|e| io_error(format!("cannot start file watcher: {e}")))?;
debouncer.watch(root, recursive).map_err(|e| io_error(format!("cannot watch {}: {e}", root.display())))?;
Ok(debouncer)
}
fn start_per_directory(
root: PathBuf,
fn start_per_directory<W: notify::Watcher + Send + 'static>(
root: &Path,
filter: EventFilter,
emit: impl Fn(&FsBatch) + Send + 'static,
emit: Emit,
config: notify::Config,
) -> IdeResult<Inner> {
let polled = W::kind() == WatcherKind::PollWatcher;
let state = Arc::new(DirState {
filter,
watcher: Mutex::new(None),
@@ -163,10 +212,11 @@ impl Watcher {
stop: AtomicBool::new(false),
});
let weak: Weak<DirState> = Arc::downgrade(&state);
let watcher = RecommendedWatcher::new(
let watcher = W::new(
move |event: notify::Result<notify::Event>| {
let Some(state) = weak.upgrade() else { return };
match event {
Ok(event) if polled && is_dir_touch(&event) => {}
Ok(event) => {
state.queue.lock().push((event, Instant::now()));
state.wake.notify_one();
@@ -174,12 +224,12 @@ impl Watcher {
Err(e) => tracing::warn!("file watcher: {e}"),
}
},
notify::Config::default(),
config,
)
.map_err(|e| io_error(format!("cannot start file watcher: {e}")))?;
*state.watcher.lock() = Some(watcher);
*state.watcher.lock() = Some(Box::new(watcher));
let mut created = Vec::new();
state.watch_tree(&root, false, &mut created);
state.watch_tree(root, false, &mut created);
if state.watched.lock().is_empty() {
return Err(io_error(format!("cannot watch {}", root.display())));
}
@@ -187,7 +237,7 @@ impl Watcher {
let state = state.clone();
std::thread::Builder::new()
.name("openidea-fs-watch".into())
.spawn(move || state.run(emit))
.spawn(move || state.run(&*emit))
.map_err(|e| io_error(format!("cannot start file watcher: {e}")))?
};
Ok(Inner::PerDirectory { state, thread: Some(thread) })
@@ -212,11 +262,17 @@ fn io_error(msg: String) -> IdeError {
IdeError::new(ide_core::error::ErrorKind::Io, msg)
}
/// A directory's own mtime moving as entries come and go: polling sees it, the
/// OS watchers report only the entries (also seen, through that directory's watch).
fn is_dir_touch(event: &notify::Event) -> bool {
matches!(event.kind, EventKind::Modify(_)) && event.paths.iter().all(|p| p.is_dir())
}
/// State of the per-directory strategy, shared with the notify callback
/// (weakly) and the debounce thread.
struct DirState {
filter: EventFilter,
watcher: Mutex<Option<RecommendedWatcher>>,
watcher: Mutex<Option<Box<dyn notify::Watcher + Send>>>,
watched: Mutex<HashSet<PathBuf>>,
queue: Mutex<Vec<(notify::Event, Instant)>>,
wake: Condvar,
@@ -396,6 +452,7 @@ fn merge(prev: Option<Change>, next: Change) -> Change {
}
}
#[derive(Clone)]
pub(crate) struct EventFilter {
root: PathBuf,
excludes: SharedExcludes,
@@ -791,7 +848,7 @@ mod tests {
fn watcher_state(w: &Watcher) -> &DirState {
match &w.inner {
Inner::PerDirectory { state, .. } => state,
Inner::Recursive(_) => panic!("not per-directory"),
Inner::Recursive(_) | Inner::Polling(_) => panic!("not per-directory"),
}
}
@@ -969,4 +1026,48 @@ mod tests {
std::thread::sleep(Duration::from_millis(300));
assert!(!all_paths(&batches.lock()).contains(&root.join("sub/deep.txt")));
}
/// The fallback used when the OS watcher cannot start.
fn polling_watcher(root: &Path, excludes: Vec<String>, batches: Arc<Mutex<Vec<FsBatch>>>) -> Watcher {
let filter = EventFilter::new(root.to_path_buf(), &excludes);
let (excludes, rules) = (filter.excludes.clone(), filter.gitignore.clone());
let emit: Emit = Arc::new(move |b: &FsBatch| batches.lock().push(b.clone()));
let inner = Watcher::start_polling(root, filter, emit, WatchMode::default()).unwrap();
Watcher { inner, excludes, rules, polling: Some("cannot start file watcher".into()) }
}
#[test]
fn polling_skips_excluded_dirs_and_follows_new_ones() {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
for d in ["src/a", "node_modules/x"] {
std::fs::create_dir_all(root.join(d)).unwrap();
}
let batches: Arc<Mutex<Vec<FsBatch>>> = Arc::default();
let watcher = polling_watcher(&root, vec!["**/node_modules".into()], batches.clone());
// root, src, src/a
assert_eq!(watcher.watch_count(), 3);
std::fs::write(root.join("node_modules/x/z.js"), "x").unwrap();
std::fs::write(root.join("src/a/f.rs"), "x").unwrap();
std::fs::create_dir_all(root.join("src/new/inner")).unwrap();
std::fs::write(root.join("src/new/inner/g.rs"), "x").unwrap();
assert!(
wait_for(&batches, |b| {
let paths = all_paths(b);
paths.contains(&root.join("src/a/f.rs")) && paths.contains(&root.join("src/new/inner/g.rs"))
}),
"{:?}",
batches.lock()
);
let paths = all_paths(&batches.lock());
assert!(!paths.iter().any(|p| p.starts_with(root.join("node_modules"))), "{paths:?}");
// Folders whose entries changed aren't reported as modified, as with inotify.
assert!(!paths.contains(&root.join("src")) && !paths.contains(&root.join("src/a")), "{paths:?}");
assert_eq!(watcher.watch_count(), 5);
// Later writes inside a new folder are seen through its own poll.
std::fs::write(root.join("src/new/inner/h.rs"), "x").unwrap();
assert!(wait_for(&batches, |b| all_paths(b).contains(&root.join("src/new/inner/h.rs"))));
}
}
+6 -2
View File
@@ -241,9 +241,9 @@ async fn resolve(shared: &Shared, target: &str) -> Response {
let Some(root) = shared.roots.read().get(&key).cloned() else { return NOT_FOUND };
let Ok(real_root) = tokio::fs::canonicalize(&root).await else { return NOT_FOUND };
let mut file = root;
let mut trailing_slash = true;
// `/<token>/<key>` names a directory too: it redirects like any other.
let trailing_slash = path.ends_with('/');
for seg in segments {
trailing_slash = seg.is_empty();
if seg.is_empty() {
continue;
}
@@ -347,6 +347,10 @@ mod tests {
let base = url.trim_end_matches("/web/index.html");
assert!(get(&format!("{base}/web/sub/a%20b.js"), None).await.contains("text/javascript"));
assert!(get(&format!("{base}/web"), None).await.contains("Location: /"));
// The served root itself, so relative URLs of its index page resolve inside it.
let page = get(base, None).await;
let root_path = base.trim_start_matches("http://").split_once('/').unwrap().1;
assert!(page.starts_with("HTTP/1.1 301") && page.contains(&format!("Location: /{root_path}/\r\n")), "{page}");
assert!(get(&format!("{base}/web/"), None).await.ends_with("<h1>hi</h1>"));
assert!(get(&format!("{base}/../secret.txt"), None).await.starts_with("HTTP/1.1 404"));
assert!(get(&format!("{base}/%2e%2e/secret.txt"), None).await.starts_with("HTTP/1.1 404"));
+1 -1
View File
@@ -28,7 +28,7 @@ The specialized Maven/Gradle tool-window model still describes the workspace dir
Open two project windows, then use **Window → Merge All Project Windows**. The project tabs occupy their own row below the main toolbar. Click a tab or use **Next/Previous Project Tab** to switch; drag tabs to reorder them. The default shortcuts are ⌥⌘PageDown/PageUp on macOS and Ctrl+Alt+PageDown/PageUp on Windows/Linux.
Use the tab's close button to close that project, or **Move Tab to New Window** to detach the current tab. Closing uses the existing unsaved-changes guard. Quit visits project windows in sequence so a hidden project's prompt is visible. Groups restore when **Reopen last project** is enabled.
Use the tab's close button to close that project, or **Move Tab to New Window** to detach the current tab. Right-click a tab for **Close Other Tabs**, **Close Tabs to the Left** and **Close Tabs to the Right**, which close the projects one at a time and keep the clicked tab; cancelling one project's unsaved-changes prompt leaves it and the rest open. The same commands are in the Command Palette and can be assigned shortcuts. Closing uses the existing unsaved-changes guard. Quit visits project windows in sequence so a hidden project's prompt is visible. Groups restore when **Reopen last project** is enabled.
The grouping model lives in `ide-workspace`, independently of project ownership. The desktop adapter keeps each member's WebView and backend services alive, presents the selected window at the group's position/size, and hides its peers. This is an application-drawn tab strip, not the macOS native tab strip. Its persistence is application-local in `window-groups.json`. Language servers, terminals, editor buffers and settings remain independent between group members.
+1
View File
@@ -5,6 +5,7 @@ const config: KnipConfig = {
project: [
"src/**/*.{ts,tsx,css}",
"!src/editor/core/cm/angular/parser.terms.ts",
"!src/editor/core/cm/jsp/parser.terms.ts",
"scripts/**/*.ts",
"perf/**/*.ts",
"e2e/**/*.ts",
+6
View File
@@ -0,0 +1,6 @@
id = "jsp"
name = "JSP"
extensions = ["jsp", "jspx", "jspf", "jsph", "tag", "tagf", "tagx"]
block-comment = ["<%--", "--%>"]
indent = { tab-size = 2, insert-spaces = true }
ui-mode = "JSP"
+2
View File
@@ -19,6 +19,7 @@
"typecheck": "tsrs --noEmit && tsrs -p tsconfig.node.json --noEmit && tsrs -p perf --noEmit && tsrs -p scripts --noEmit",
"gen:bindings": "cargo test -p openidea --test export_bindings",
"gen:angular-grammar": "lezer-generator src/editor/core/cm/angular/angular.grammar --typeScript -o src/editor/core/cm/angular/parser.ts",
"gen:jsp-grammar": "lezer-generator src/editor/core/cm/jsp/jsp.grammar --typeScript -o src/editor/core/cm/jsp/parser.ts",
"gen:theme-docs": "node scripts/theme/gen-docs.ts",
"theme:import-intellij": "node scripts/theme/import-intellij-theme.ts",
"dev:app": "node scripts/tauri.ts dev --config src-tauri/tauri.dev.conf.json",
@@ -39,6 +40,7 @@
"@codemirror/autocomplete": "^6.20.3",
"@codemirror/commands": "^6.11.1",
"@codemirror/lang-html": "^6.4.12",
"@codemirror/lang-java": "^6.0.2",
"@codemirror/lang-javascript": "^6.2.5",
"@codemirror/language": "^6.13.1",
"@codemirror/language-data": "^6.5.2",
+8 -5
View File
@@ -53,14 +53,17 @@ files for disk discovery. It does not guess a global association from matching
text in unrelated projects.
Indexing is lazy. The first explicit navigation/search builds a compact snapshot;
ordinary typing, code-lens probes and bulk scoped reference analysis do not start indexing. At most eight project snapshots are
ordinary typing, code-lens probes, bulk scoped reference analysis, and Java Go to Definition / Ctrl+hover outside a `@Value` or property-lookup argument (checked with the bundled tree-sitter grammar) do not start indexing. At most eight project snapshots are
retained, with a shared 100,000-association retention cap. Records contain property names, element handles and source locations;
ASTs and bindings are discarded after indexing. A lexical prefilter avoids AST
construction for ordinary Java units, but every accepted association is resolved
semantically. Warm requests reuse the snapshot. Java model/reconcile changes
invalidate it conservatively, including dependent-type changes; open working-copy
content fingerprints also prevent stale answers before reconciliation. This is
**snapshot invalidation**, not a fully incremental Java semantic index.
semantically. Warm requests reuse the snapshot. Java model changes (sources on
disk, classpath, archives) invalidate it conservatively, including dependent-type
changes; a fingerprint of working copies with unsaved changes covers edits before
they are saved. Opening or closing an editor, reconciling, building, and non-Java
resource changes (configuration files are parsed by the client; metadata files are
checked by timestamp) keep it. This is **snapshot invalidation**, not a fully
incremental Java semantic index.
Metadata/JAR timestamps are checked before reuse. The client caches parsed disk
configuration files by modification time and size (at most 100,000 cached keys), overlays open buffers, and
+1 -1
View File
@@ -2,7 +2,7 @@ Manifest-Version: 1.0
Bundle-ManifestVersion: 2
Bundle-Name: OpenIDEA Java usages
Bundle-SymbolicName: ai.openidea.usages;singleton:=true
Bundle-Version: 1.2.0
Bundle-Version: 1.2.1
Bundle-RequiredExecutionEnvironment: JavaSE-21
Require-Bundle: org.eclipse.jdt.ls.core,org.eclipse.jdt.core,
org.eclipse.jdt.core.manipulation,org.eclipse.core.runtime,
+4 -1
View File
@@ -57,7 +57,7 @@ is not a claim of full IntelliJ feature parity.
## Rebuild and delivery
The checked-in jar is embedded by ide-sdk `include_bytes!`, installed as
`java-usages-1.2.0.jar`, and added alongside existing initialization bundles.
`java-usages-1.2.1.jar`, and added alongside existing initialization bundles.
Users need no compiler, Maven or Python. Manifest/install version must change
when replacing the bundle because OSGi caches bundles.
@@ -70,6 +70,9 @@ and compare SHA-256. The prior 1.1.1 output was
`1e569939cba22c7db6e2c9b53de2a4c10ccaabe762a51427119a04af3d974ae4`.
Version 1.2.0 adds the configuration command; its reproducible SHA-256 is
`9140f1d2ecf27eadfe66b40a17daa18a08a65927ebef79456ba55d486ce5eeba`.
Version 1.2.1 keeps the configuration snapshot across editor open/close, saves of
unrelated content and non-Java resource changes; its reproducible SHA-256 is
`cbae3e3a0bcf2818b4cc9d98998bd9b81f92e1ae7803dc757134485f9776a4e1`.
```sh
export PATH=/path/to/jdk25/bin:$PATH
Binary file not shown.
@@ -27,9 +27,23 @@ public final class ConfigurationCommand implements IDelegateCommandHandler {
private static final String VALUE = "org.springframework.beans.factory.annotation.Value";
private static final String RESOLVER = "org.springframework.core.env.PropertyResolver";
private static final int MAX_UNITS = 20_000, MAX_ENTRIES = 100_000, MAX_BYTES = 2 * 1024 * 1024;
private static final AtomicLong REVISION = new AtomicLong();
private static final AtomicLong REVISION = new AtomicLong(), GENERATION = new AtomicLong();
private static final Map<String, Index> CACHE = new LinkedHashMap<>();
static { JavaCore.addElementChangedListener(event -> REVISION.incrementAndGet(), ElementChangedEvent.POST_CHANGE | ElementChangedEvent.POST_RECONCILE); }
/** Unsaved edits are covered by the working-copy digest, so reconciles never
* invalidate. Opening/closing a working copy (F_PRIMARY_WORKING_COPY) and
* non-Java resources, reported as F_CONTENT on a project, root or package
* (configuration files are parsed by the client; metadata files carry their
* own stamps), do not change the index either. */
private static final int IGNORED_FLAGS = IJavaElementDelta.F_CHILDREN | IJavaElementDelta.F_PRIMARY_WORKING_COPY
| IJavaElementDelta.F_AST_AFFECTED | IJavaElementDelta.F_FINE_GRAINED;
static { JavaCore.addElementChangedListener(event -> { if (affects(event.getDelta())) REVISION.incrementAndGet(); }, ElementChangedEvent.POST_CHANGE); }
static boolean affects(IJavaElementDelta delta) {
int flags = delta.getFlags() & ~IGNORED_FLAGS;
if (delta.getElement().getElementType() < IJavaElement.COMPILATION_UNIT) flags &= ~IJavaElementDelta.F_CONTENT;
if (delta.getKind() != IJavaElementDelta.CHANGED || flags != 0) return true;
for (IJavaElementDelta child : delta.getAffectedChildren()) if (affects(child)) return true;
return false;
}
private record Entry(String name, String handle, Location location, boolean relaxed) {}
private record FileStamp(java.nio.file.attribute.FileTime modified, long size) {}
@@ -38,7 +52,7 @@ public final class ConfigurationCommand implements IDelegateCommandHandler {
catch (java.io.IOException e) { return new FileStamp(null, -1); }
}
private record Index(long revision, String workingCopies, List<Entry> entries, List<String> roots,
Map<Path, FileStamp> files, boolean incomplete, int parsed) {}
Map<Path, FileStamp> files, boolean incomplete, int parsed, long generation) {}
@Override
public Object executeCommand(String command, List<Object> args, IProgressMonitor monitor) throws Exception {
@@ -108,7 +122,7 @@ public final class ConfigurationCommand implements IDelegateCommandHandler {
}
return Map.of("locations", locations, "keys", keys, "roots", index.roots,
"incomplete", index.incomplete || locations.size() >= 2000,
"parsedUnits", index.parsed, "revision", index.revision);
"parsedUnits", index.parsed, "revision", index.revision, "generation", index.generation);
}
private static boolean sameUri(String a, String b) {
@@ -156,9 +170,12 @@ public final class ConfigurationCommand implements IDelegateCommandHandler {
for (ICompilationUnit copy : JavaCore.getWorkingCopies(null)) copies.put(copy.getPrimary().getHandleIdentifier(), copy);
for (ICompilationUnit copy : JavaCore.getWorkingCopies(JavaLanguageServerPlugin.getInstance().getWorkingCopyOwner()))
copies.put(copy.getPrimary().getHandleIdentifier(), copy);
// Only unsaved contents differ from what the Java model reports for disk,
// so merely opening or closing an editor keeps the snapshot.
var digest = java.security.MessageDigest.getInstance("SHA-256");
for (var entry : copies.entrySet()) {
check(monitor);
if (!entry.getValue().hasUnsavedChanges()) continue;
digest.update(entry.getKey().getBytes(java.nio.charset.StandardCharsets.UTF_8));
String source = entry.getValue().getSource();
if (source != null) digest.update(source.getBytes(java.nio.charset.StandardCharsets.UTF_8));
@@ -209,7 +226,7 @@ public final class ConfigurationCommand implements IDelegateCommandHandler {
// Return project locations as well; the client excludes nested independent modules.
roots.add(project.getProject().getLocationURI().toString());
builder.metadataDirectory(project.getProject().getLocation().toFile().toPath().resolve("src/main/resources"), project);
Index result = new Index(revision, workingCopies, List.copyOf(builder.entries), List.copyOf(roots), Map.copyOf(builder.files), builder.incomplete, builder.parsed);
Index result = new Index(revision, workingCopies, List.copyOf(builder.entries), List.copyOf(roots), Map.copyOf(builder.files), builder.incomplete, builder.parsed, GENERATION.incrementAndGet());
CACHE.remove(id);
while (!CACHE.isEmpty() && (CACHE.size() >= 8 ||
CACHE.values().stream().mapToInt(value -> value.entries.size()).sum() + result.entries.size() > MAX_ENTRIES))
+28 -1
View File
@@ -200,6 +200,33 @@ try:
warm = []
for _ in range(20):
start = time.perf_counter(); query('app.time-out'); warm.append(time.perf_counter() - start)
# The snapshot stays warm across editor activity that cannot change it:
# opening/closing another file, a full build, and an edit once it is saved.
generation = query('app.time-out')['generation']
other = source_dir / 'demo/NotSpring.java'
other_text = other.read_text()
send({'jsonrpc': '2.0', 'method': 'textDocument/didOpen', 'params': {'textDocument': {'uri': other.as_uri(), 'languageId': 'java', 'version': 1, 'text': other_text}}})
assert query('app.time-out')['generation'] == generation, 'opening an editor rebuilt the index'
send({'jsonrpc': '2.0', 'method': 'textDocument/didClose', 'params': {'textDocument': {'uri': other.as_uri()}}})
request('java/buildWorkspace', True)
assert query('app.time-out')['generation'] == generation, 'closing an editor or building rebuilt the index'
saved_text = other_text + '// saved\n'
send({'jsonrpc': '2.0', 'method': 'textDocument/didOpen', 'params': {'textDocument': {'uri': other.as_uri(), 'languageId': 'java', 'version': 1, 'text': other_text}}})
send({'jsonrpc': '2.0', 'method': 'textDocument/didChange', 'params': {'textDocument': {'uri': other.as_uri(), 'version': 2}, 'contentChanges': [{'text': saved_text}]}})
other.write_text(saved_text)
send({'jsonrpc': '2.0', 'method': 'textDocument/didSave', 'params': {'textDocument': {'uri': other.as_uri()}}})
after_save = query('app.time-out')['generation']
assert query('app.time-out')['generation'] == after_save, 'a saved editor keeps rebuilding the index'
send({'jsonrpc': '2.0', 'method': 'textDocument/didClose', 'params': {'textDocument': {'uri': other.as_uri()}}})
after_close = query('app.time-out')['generation']
assert query('app.time-out')['generation'] == after_close
# A source added on disk still invalidates it.
added = write('demo/Added.java', 'package demo; @org.springframework.boot.context.properties.ConfigurationProperties("added") class Added { int size; }')
send({'jsonrpc': '2.0', 'method': 'workspace/didChangeWatchedFiles', 'params': {'changes': [{'uri': added.as_uri(), 'type': 1}]}})
deadline = time.monotonic() + 30
while not query('added.size')['locations']:
assert time.monotonic() < deadline, 'a new source on disk did not reach the index'
time.sleep(0.2)
# Unsaved changes must replace old keys, and closing must restore disk keys.
changed = source.replace('prefix="app"', 'prefix="edited"').replace('${app.time-out:30}', '${edited.timeout:30}')
send({'jsonrpc': '2.0', 'method': 'textDocument/didChange', 'params': {'textDocument': {'uri': file.as_uri(), 'version': 2}, 'contentChanges': [{'text': changed}]}})
@@ -211,7 +238,7 @@ try:
send({'jsonrpc': '2.0', 'method': 'textDocument/didClose', 'params': {'textDocument': {'uri': file.as_uri()}}})
restored = query('app.nested.url')
assert restored['locations'], restored
evidence = {'stress': os.environ.get('OPENIDEA_CONFIGURATION_STRESS') == '1', 'editRebuildMs': edit_ms, 'cases': results, 'warmMedianMs': sorted(warm)[len(warm)//2] * 1000, 'warmMaxMs': max(warm) * 1000}
evidence = {'stress': os.environ.get('OPENIDEA_CONFIGURATION_STRESS') == '1', 'editRebuildMs': edit_ms, 'cases': results, 'warmMedianMs': sorted(warm)[len(warm)//2] * 1000, 'warmMaxMs': max(warm) * 1000, 'generations': [generation, after_save, after_close]}
(work / 'evidence.json').write_text(json.dumps(evidence, indent=2))
print(json.dumps(evidence, indent=2))
finally:
+46 -14
View File
@@ -1,8 +1,15 @@
# Lombok agent for JDT LS
`lombok-1.18.48-openidea.1.jar` is the app-provided Java agent for JDT LS. It includes
[projectlombok/lombok#4059](https://github.com/projectlombok/lombok/pull/4059), which restores
`lombok.config` lookup after Equinox removed `getModuleClassLoader(boolean)`.
`lombok-1.18.48-openidea.2.jar` is the app-provided Java agent for JDT LS. It includes:
- [projectlombok/lombok#4059](https://github.com/projectlombok/lombok/pull/4059), which restores
`lombok.config` lookup after Equinox removed `getModuleClassLoader(boolean)`.
- [projectlombok/lombok#4089](https://github.com/projectlombok/lombok/pull/4089) (fixes
lombok#4084 / #4094). ECJ 3.46.200, bundled with JDT LS 1.63, keeps a constructor's explicit or
implicit `super()` / `this()` call as the first element of its statements. Lombok 1.18.48 wrapped
that call in the `@SneakyThrows` try block (ECJ then fails with "Internal Error compiling …
BlockScope cannot be cast to MethodScope" and the whole workspace build fails) and put `@NonNull`
checks before it; constructors Lombok generates lacked the statement.
The jar is embedded by `ide-tools`, so every platform receives the same bytes without a download.
Both Java server startup and launch diagnostics obtain it through `managed_bundle`. Tool detection
@@ -12,21 +19,27 @@ The filename includes the patch version because JDT LS workspace keys include th
changing agents must rebuild classes produced by the old agent. Old jars remain for running windows.
Replace this temporary bundle with an official Maven Central release once that release includes
#4059. Restore the registry's Maven Central install method and remove the embedded jar. Keep the
#4059 and #4089. Restore the registry's Maven Central install method and remove the embedded jar. Keep the
workspace migration test when changing its path.
## Provenance and build
- Repository: https://github.com/projectlombok/lombok
- Base tag: `v1.18.48` (`b48657c83ce44d027984f539bd36048293ce5e8e`).
- Cherry-picked commits from PR #4059, in order:
- `2d535482a913de46c27d06c5b0b294918ede3f9c`
- `3098f4dd78b5349c2f382e7310e64db810dd380b`
- Upstream version: `1.18.48`; `openidea.1` identifies this distribution of the patched agent.
- Cherry-picked commits, in order:
- PR #4059 (`chagong/lombok` `fix-equinox-bundle-wiring`):
- `2d535482a913de46c27d06c5b0b294918ede3f9c`
- `3098f4dd78b5349c2f382e7310e64db810dd380b`
- PR #4089 (`Rawi01/lombok` `eclipse-constructor-call-statement`):
- `7832faa49b35c839088866daf865384c82cd555f`
- `3f3a80b857a969839ab1baba12767aec4965ac32`
- Upstream version: `1.18.48`; `openidea.2` identifies this distribution of the patched agent
(`openidea.1` had #4059 only).
Upstream's own `java -jar ... --version` still reports `1.18.48`.
- SHA-256: `10f5cd1a32b946d3ff1c075735b94f35fffdbac11dea2be29d24dbf591dd51a4`
- SHA-256: `2978d8b2d55dac1bb4936ba44562bc4549931d4e71a7ebfeaebccd60bcdf7379`
- License: [MIT, including bundled component notices](LICENSE), also retained inside the jar.
- Build environment: macOS arm64, OpenJDK 23.0.1, Apache Ant 1.10.15, upstream Ivy setup.
- Build environment: macOS arm64, Temurin 25.0.5, Apache Ant 1.10.15, upstream Ivy setup
(`openidea.1` was built with OpenJDK 23.0.1).
Build using the upstream Ant entry point described in its contributing guide:
@@ -35,16 +48,22 @@ git clone https://github.com/projectlombok/lombok.git
cd lombok
git checkout -b openidea-1.18.48 v1.18.48
git fetch https://github.com/chagong/lombok.git fix-equinox-bundle-wiring
git fetch https://github.com/Rawi01/lombok.git eclipse-constructor-call-statement
git cherry-pick 2d535482a913de46c27d06c5b0b294918ede3f9c 3098f4dd78b5349c2f382e7310e64db810dd380b
git cherry-pick 7832faa49b35c839088866daf865384c82cd555f 3f3a80b857a969839ab1baba12767aec4965ac32
ant dist
cp dist/lombok.jar /path/to/openidea/plugins/lombok/lombok-1.18.48-openidea.1.jar
cp dist/lombok.jar /path/to/openidea/plugins/lombok/lombok-1.18.48-openidea.2.jar
```
The changelog context conflicts because the first commit adds a release-note line under the
older development heading and the second removes it. Resolve mechanically by adding then removing
only that line, retaining the complete v1.18.48 changelog. The final source diff from v1.18.48 changes
only `src/eclipseAgent/lombok/launch/PatchFixesHider.java`, `test/core/src/lombok/TestEclipse.java`,
and `test/core/src/lombok/launch/PatchFixesHiderTest.java`; no other Java source changes were made.
only that line, retaining the complete v1.18.48 changelog (the second commit is then empty and
skipped). The #4089 commits apply cleanly. The final source diff from v1.18.48 changes
`src/eclipseAgent/lombok/launch/PatchFixesHider.java`, `test/core/src/lombok/TestEclipse.java`,
`test/core/src/lombok/launch/PatchFixesHiderTest.java` (#4059) and the Eclipse handlers
`EclipseHandlerUtil`, `HandleConstructor`, `HandleFieldNameConstants`, `HandleNonNull`,
`HandleSneakyThrows`, `HandleStandardException`, `HandleSuperBuilder`, `HandleUtilityClass` (#4089);
no other Java source changes were made.
In the build checkout, the two remote resolvers in
`buildScripts/ivysettings.xml` were reordered to try Maven Central before Eclipse staging; the
local resolver and all dependency coordinates were retained. The JVM was configured to use the
@@ -66,6 +85,19 @@ On macOS arm64 with JDT LS 1.63.0 / Equinox 3.25.0, server OpenJDK 25 and projec
identical `javap` output, including default `void` setters.
- Upstream `PatchFixesHiderTest` passed both the standard OSGi and legacy Equinox lookup tests.
`openidea.2` (#4089) on macOS arm64, JDT LS 1.63.0-202610191322 (ECJ
`compiler.batch_3.46.200.v20261008-1115`), server Temurin 25:
- ECJ batch with the agent, compliance 17 and 25, against ECJ 3.46.200 and 3.46.100: `@SneakyThrows`
constructors with implicit `super()`, explicit `super()` and `this()`; `@NonNull` constructor
parameters; `@RequiredArgsConstructor` with an initialized final field, `@AllArgsConstructor`,
`@NoArgsConstructor`, `@Builder` record, `@Value @Builder`, `@UtilityClass`. `openidea.1` failed
with "Internal Error compiling" on 3.46.200; `openidea.2` compiled on both ECJ versions, the
programs ran with identical output, and `@NonNull` checks run after the constructor call again.
- Real JDT LS `java/buildWorkspace(true)` of the same sources with the agent: `openidea.1` reported
Failed with three internal errors and wrote no classes; `openidea.2` Succeeded with all classes.
- `plugins/java-usages/tests/verify.py` and `read-write.py` passed with `openidea.2`.
Windows and Linux runtime validation has not been performed. Rust tests cover source selection
for both supported architectures on all three OS variants, offline provisioning, corruption
repair, concurrent windows, explicit user overrides, and JDT LS workspace migration.
+1 -1
View File
@@ -1 +1 @@
10f5cd1a32b946d3ff1c075735b94f35fffdbac11dea2be29d24dbf591dd51a4 lombok-1.18.48-openidea.1.jar
2978d8b2d55dac1bb4936ba44562bc4549931d4e71a7ebfeaebccd60bcdf7379 lombok-1.18.48-openidea.2.jar
+3
View File
@@ -17,6 +17,9 @@ importers:
'@codemirror/lang-html':
specifier: ^6.4.12
version: 6.4.12
'@codemirror/lang-java':
specifier: ^6.0.2
version: 6.0.2
'@codemirror/lang-javascript':
specifier: ^6.2.5
version: 6.2.5
+4
View File
@@ -79,6 +79,10 @@ objc2-foundation = { version = "0.3", default-features = false, features = ["std
# Used only by the debug font-restriction override; Cargo dependency cfg cannot test debug_assertions.
objc2-web-kit = { version = "0.3", default-features = false, features = ["std", "WKWebViewConfiguration", "WKPreferences"] }
[target.'cfg(target_os = "linux")'.dependencies]
# Wayland detection and the client size without GTK's CSD shadow (linux_window_state.rs).
gtk = "0.18"
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = [
"Win32_Foundation",
+6
View File
@@ -15,6 +15,8 @@ pub struct AppInfo {
pub version: String,
pub platform: String,
pub config_dir: ide_core::IpcPath,
/// Linux on a Wayland display: GTK draws the frameless windows' shadow and resize edges.
pub wayland: bool,
}
pub use ide_host::ops::project::ProjectInfo;
@@ -27,6 +29,10 @@ pub fn app_info(state: State<'_, AppState>) -> AppInfo {
version: env!("CARGO_PKG_VERSION").into(),
platform: std::env::consts::OS.into(),
config_dir: state.config_dir.clone().into(),
#[cfg(target_os = "linux")]
wayland: crate::linux_window_state::is_wayland(),
#[cfg(not(target_os = "linux"))]
wayland: false,
}
}
+2
View File
@@ -77,6 +77,8 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
window_group::window_group_select,
window_group::window_group_detach,
window_group::window_group_close,
window_group::window_group_close_tabs,
window_group::window_group_cancel_close,
window_group::window_group_reorder,
window_group::window_group_restore,
app::light_edit_open,
+14
View File
@@ -41,6 +41,20 @@ pub fn window_group_close(window: WebviewWindow, label: String) -> IdeResult<()>
.map_err(|e| IdeError::internal(e.to_string()))
}
/// Closes several project tabs one by one (each asks about its own unsaved changes), then selects `keep`.
#[tauri::command]
#[specta::specta]
pub fn window_group_close_tabs(window: WebviewWindow, keep: String, labels: Vec<String>) -> IdeResult<()> {
groups::close_tabs(window.app_handle(), window.label(), &keep, labels)
}
/// This window kept its project open (unsaved-changes prompt cancelled); stops a batch close.
#[tauri::command]
#[specta::specta]
pub fn window_group_cancel_close(window: WebviewWindow) {
groups::cancel_close(window.app_handle(), window.label());
}
#[tauri::command]
#[specta::specta]
pub async fn window_group_reorder(window: WebviewWindow, labels: Vec<String>) -> IdeResult<()> {
+40 -2
View File
@@ -14,6 +14,7 @@ mod runtime {
use super::*;
use parking_lot::Mutex;
use std::collections::HashMap;
use std::sync::OnceLock;
use tauri::{AppHandle, Listener, Manager, Window, WindowEvent, plugin::TauriPlugin};
const FILE: &str = tauri_plugin_window_state::DEFAULT_FILENAME;
@@ -63,8 +64,42 @@ mod runtime {
visible: bool,
}
static WAYLAND: OnceLock<bool> = OnceLock::new();
/// GDK's backend, as tao tests it (`GDK_BACKEND=x11` under a Wayland session is X11).
/// Known once the plugin is set up, before any window exists.
pub fn is_wayland() -> bool {
WAYLAND.get().copied().unwrap_or(false)
}
/// The client size that `set_size` (`gtk_window_resize`) takes. On Wayland, decorated windows
/// get a GTK CSD shadow margin that tao's inner and outer sizes (the configure size) include.
pub fn content_size(window: &Window) -> tauri::Result<tauri::PhysicalSize<u32>> {
let inner = window.inner_size()?;
if !is_wayland() {
return Ok(inner);
}
// GTK objects belong to the main thread. `gtk_window_get_size` follows the configure
// synchronously; the webview's allocation only catches up on the next layout.
let (tx, rx) = std::sync::mpsc::sync_channel(1);
let cloned = window.clone();
window.run_on_main_thread(move || {
let sizes = cloned.gtk_window().ok().zip(cloned.inner_size().ok()).zip(cloned.scale_factor().ok());
let _ = tx.send(sizes.map(|((gtk, inner), scale)| {
let (width, height) = gtk::prelude::GtkWindowExt::size(&gtk);
let content = tauri::LogicalSize::new(width.max(0) as u32, height.max(0) as u32);
(content.to_physical::<u32>(scale), inner)
}));
})?;
let Some((content, inner)) = rx.recv().ok().flatten() else { return Ok(inner) };
// An unmapped window reports its size request; never exceed the configure size.
let usable =
content.width > 1 && content.height > 1 && content.width <= inner.width && content.height <= inner.height;
Ok(if usable { content } else { inner })
}
fn frame(window: &Window) -> Option<Frame> {
let size = window.inner_size().ok()?;
let size = content_size(window).ok()?;
Some(Frame {
visible: window.is_visible().ok()?,
size: Size(size.width, size.height),
@@ -381,6 +416,9 @@ mod runtime {
pub fn plugin() -> TauriPlugin<tauri::Wry> {
tauri::plugin::Builder::new("linux-window-state")
.setup(|app, _| {
// Plugins are set up on the main thread once GTK is initialised, before any window exists.
use gtk::gdk::prelude::DisplayExtManual;
let _ = WAYLAND.set(gtk::gdk::Display::default().is_some_and(|d| d.backend().is_wayland()));
let saved = app
.path()
.app_config_dir()
@@ -466,7 +504,7 @@ mod runtime {
}
#[cfg(target_os = "linux")]
pub use runtime::{normal_size_requested, plugin, prepare, save_before_close};
pub use runtime::{content_size, is_wayland, normal_size_requested, plugin, prepare, save_before_close};
#[cfg(test)]
mod tests {
+81
View File
@@ -6,6 +6,7 @@ use crate::{
use ide_core::{IdeError, IdeEvent, IdeResult};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::collections::VecDeque;
use std::path::PathBuf;
use std::sync::atomic::Ordering;
use tauri::{AppHandle, Manager, WebviewWindow};
@@ -65,6 +66,10 @@ fn copy_frame(from: &WebviewWindow, to: &WebviewWindow) -> IdeResult<()> {
if !maximized {
to.unmaximize().map_err(err)?;
to.set_position(from.outer_position().map_err(err)?).map_err(err)?;
// `set_size` excludes GTK's CSD shadow, which tao's inner size includes on Wayland.
#[cfg(target_os = "linux")]
let size = crate::linux_window_state::content_size(&from.as_ref().window()).map_err(err)?;
#[cfg(not(target_os = "linux"))]
let size = from.inner_size().map_err(err)?;
#[cfg(target_os = "linux")]
crate::linux_window_state::normal_size_requested(to.app_handle(), to.label(), size);
@@ -170,6 +175,82 @@ pub fn destroyed(app: &AppHandle, caller: &str) {
});
}
changed(app);
let advance = state.tab_closes.lock().as_mut().is_some_and(|batch| {
let closing = batch.pending.front().is_some_and(|l| l == caller);
if closing {
batch.pending.pop_front();
}
closing
});
if advance {
close_next(app);
}
}
/// Tabs left to close; the first one is closing now.
pub struct TabCloses {
keep: String,
pending: VecDeque<String>,
}
/// Closes `labels` one at a time so each project's unsaved-changes prompt is shown in turn, then selects `keep`.
pub fn close_tabs(app: &AppHandle, caller: &str, keep: &str, labels: Vec<String>) -> IdeResult<()> {
let state = app.state::<WindowsState>();
let members = state
.groups
.lock()
.group(caller)
.map(|g| g.members.clone())
.ok_or_else(|| IdeError::not_found("This window is not grouped"))?;
if !members.iter().any(|m| m == keep) {
return Err(IdeError::invalid("The window is not in this group"));
}
let pending = members.into_iter().filter(|m| m != keep && labels.contains(m)).collect();
*state.tab_closes.lock() = Some(TabCloses { keep: keep.to_owned(), pending });
close_next(app);
Ok(())
}
/// The closing tab's project vetoed (its unsaved-changes prompt was cancelled): leave the rest open.
pub fn cancel_close(app: &AppHandle, caller: &str) {
let state = app.state::<WindowsState>();
let mut batch = state.tab_closes.lock();
if batch.as_ref().is_some_and(|b| b.pending.front().is_some_and(|l| l == caller)) {
*batch = None;
}
}
fn close_next(app: &AppHandle) {
let state = app.state::<WindowsState>();
loop {
let next = {
let mut batch = state.tab_closes.lock();
let Some(b) = batch.as_mut() else { return };
match b.pending.front() {
Some(label) => Ok(label.clone()),
None => Err(batch.take().unwrap().keep),
}
};
match next {
Ok(label) => {
if let Some(window) = app.get_webview_window(&label) {
windows::focus(&window);
if window.close().is_ok() {
return;
}
}
if let Some(b) = state.tab_closes.lock().as_mut() {
b.pending.pop_front();
}
}
Err(keep) => {
if let Some(window) = app.get_webview_window(&keep) {
windows::focus(&window);
}
return;
}
}
}
}
#[derive(Serialize, Deserialize)]
+33 -5
View File
@@ -67,6 +67,8 @@ pub struct WindowsState {
last_focused: Mutex<Option<String>>,
/// Set by Quit while windows close one by one.
quitting: AtomicBool,
/// Project tabs closing one by one (Close Other Tabs and friends).
pub tab_closes: Mutex<Option<crate::window_groups::TabCloses>>,
/// Relaunch once the quit completes (after installing an update).
restart: AtomicBool,
}
@@ -109,7 +111,9 @@ impl WindowsState {
pub fn set_quitting(&self, on: bool) {
self.quitting.store(on, Ordering::SeqCst);
if !on {
if on {
*self.tab_closes.lock() = None;
} else {
self.restart.store(false, Ordering::SeqCst);
}
}
@@ -303,6 +307,8 @@ fn build_project_window(app: &AppHandle, mut config: WindowConfig) -> IdeResult<
let window = WebviewWindowBuilder::from_config(app, &config)
.and_then(|b| dev_font_preferences(b).build())
.map_err(|e| IdeError::internal(format!("cannot create window: {e}")))?;
#[cfg(target_os = "linux")]
decorate_csd(&window);
apply_zoom(app, &window);
#[cfg(target_os = "macos")]
align_titlebar(app, &window);
@@ -325,6 +331,17 @@ fn build_project_window(app: &AppHandle, mut config: WindowConfig) -> IdeResult<
Ok(window)
}
/// Wayland: tao already makes frameless windows client-side decorated (an empty GTK title bar, so
/// the compositor adds no frame), but undecorated GTK windows get no shadow or resize margin.
/// Decorating keeps that empty title bar, so GTK adds the theme's shadow and resize edges and
/// nothing else. X11 has no such title bar: decorating there would bring back the WM frame.
#[cfg(target_os = "linux")]
fn decorate_csd(window: &WebviewWindow) {
if crate::linux_window_state::is_wayland() {
let _ = window.set_decorations(true);
}
}
/// Sets the saved IDE zoom before the page loads; applied later by the frontend,
/// it would visibly scale up the splash.
fn apply_zoom(app: &AppHandle, window: &WebviewWindow) {
@@ -796,13 +813,19 @@ pub fn open_settings_window(
#[cfg(target_os = "linux")]
crate::linux_window_state::prepare(app, &mut config);
let builder = WebviewWindowBuilder::from_config(app, &config);
// AppKit child windows can disappear when dragged to another display's Space.
// The settings label already tracks ownership and parent-close cleanup.
#[cfg(not(target_os = "macos"))]
// Windows: an owned child (WS_POPUP) of a borderless parent gets no DWM
// shadow, leaving the frameless settings window flat against its parent.
// Ownership is tracked by the `settings-<parent>` label instead
// (parent-close cleanup, menu modality), so only Linux keeps the native
// parent link. AppKit child windows can disappear when dragged to another
// display's Space, so macOS keeps it too.
#[cfg(target_os = "linux")]
let builder = builder.and_then(|b| b.parent(&parent));
let window = builder
.and_then(|b| dev_font_preferences(b).build())
.map_err(|e| IdeError::internal(format!("cannot create settings window: {e}")))?;
#[cfg(target_os = "linux")]
decorate_csd(&window);
apply_zoom(app, &window);
#[cfg(target_os = "macos")]
align_titlebar(app, &window);
@@ -883,7 +906,12 @@ pub(super) fn place_settings_window(window: &WebviewWindow, parent: &WebviewWind
let size = parent.outer_size()?.to_logical::<f64>(scale);
let own_scale = window.scale_factor()?;
let own = window.outer_size()?.to_logical::<f64>(own_scale);
let inner = window.inner_size()?.to_logical::<f64>(own_scale);
// On Wayland, the frame is GTK's CSD shadow margin, which tao's inner size includes.
#[cfg(target_os = "linux")]
let inner = crate::linux_window_state::content_size(&window.as_ref().window())?;
#[cfg(not(target_os = "linux"))]
let inner = window.inner_size()?;
let inner = inner.to_logical::<f64>(own_scale);
let frame = (own.width - inner.width, own.height - inner.height);
let bounds = fit_bounds(
(pos.x, pos.y, size.width, size.height),
+1
View File
@@ -46,6 +46,7 @@
"jsx",
"vue",
"svelte",
"jsp",
"dart",
"cs",
"fs",
+11 -5
View File
@@ -23,6 +23,10 @@ export const commands = {
windowGroupSelect: (label: string) => typedError<null, IdeError>(__TAURI_INVOKE("window_group_select", { label })),
windowGroupDetach: () => typedError<null, IdeError>(__TAURI_INVOKE("window_group_detach")),
windowGroupClose: (label: string) => typedError<null, IdeError>(__TAURI_INVOKE("window_group_close", { label })),
/** Closes several project tabs one by one (each asks about its own unsaved changes), then selects `keep`. */
windowGroupCloseTabs: (keep: string, labels: string[]) => typedError<null, IdeError>(__TAURI_INVOKE("window_group_close_tabs", { keep, labels })),
/** This window kept its project open (unsaved-changes prompt cancelled); stops a batch close. */
windowGroupCancelClose: () => __TAURI_INVOKE<void>("window_group_cancel_close"),
windowGroupReorder: (labels: string[]) => typedError<null, IdeError>(__TAURI_INVOKE("window_group_reorder", { labels })),
windowGroupRestore: () => typedError<null, IdeError>(__TAURI_INVOKE("window_group_restore")),
/**
@@ -1386,6 +1390,8 @@ export type AppInfo = {
version: string,
platform: string,
configDir: IpcPath,
/** Linux on a Wayland display: GTK draws the frameless windows' shadow and resize edges. */
wayland: boolean,
};
export type AppMetrics = {
@@ -5326,16 +5332,16 @@ export type RevisionChange = {
export type RootKind = "source" | "test" | "resources" | "testResources" | "excluded";
export type RubySettings = {
/** Project interpreter executable; empty = auto-detect (`.ruby-version`, mise / asdf, else `ruby` on PATH). */
interpreter?: string,
};
export type RouteSource = {
path: string,
text: string,
};
export type RubySettings = {
/** Project interpreter executable; empty = auto-detect (`.ruby-version`, mise / asdf, else `ruby` on PATH). */
interpreter?: string,
};
export type RunConfig = RunConfig_Serialize | RunConfig_Deserialize;
export type RunConfig_Deserialize = {
+176
View File
@@ -0,0 +1,176 @@
/**
* JSP pages, layered like IntelliJ's JSP view provider: `jsp.grammar` parses
* the JSP layer, and the template text around its elements is one HTML
* document. That is an overlay, so markup split by a scriptlet still nests
* and `value="<%= id %>"` is an attribute with a hole. The scriptlets are
* one Java method body, an overlay too (`<% for (…) { %> … <% } %>` balances
* its braces), each expression and declaration Java of its own.
*/
import { LRLanguage, LanguageSupport, foldInside, foldNodeProp } from "@codemirror/language";
import { html } from "@codemirror/lang-html";
import { javaLanguage } from "@codemirror/lang-java";
import { styleTags, tags as t } from "@lezer/highlight";
import {
Parser,
parseMixed,
type Input,
type NestedParse,
type PartialParse,
type SyntaxNode,
type SyntaxNodeRef,
type TreeFragment,
} from "@lezer/common";
import { JAVA_TAGS } from "../languages";
import { parser } from "./parser";
const baseHTML = html({ selfClosingTags: true });
const JSP_COMMENT = { block: { open: "<%--", close: "--%>" } };
const baseParser = parser.configure({
props: [
styleTags({
JspComment: t.blockComment,
'"<%" "<%=" "<%!" "<%@" "%>"': t.processingInstruction,
// Not `ELExpression/"}"`: the cursor highlighting a page does not match
// that context once the expression's content is mounted.
'"${" "#{" ELEnd/...': t.special(t.brace),
DirectiveName: t.keyword,
AttributeName: t.attributeName,
AttributeValue: t.attributeValue,
Is: t.definitionOperator,
VariableName: t.variableName,
PropertyName: t.propertyName,
"CallExpression/MemberExpression/PropertyName": t.function(t.propertyName),
"CallExpression/VariableName FunctionName": t.function(t.variableName),
Namespace: t.namespace,
Number: t.number,
String: t.string,
BooleanLiteral: t.bool,
null: t.null,
"not empty div mod lt gt le ge instanceof eq ne and or": t.operatorKeyword,
ArithOp: t.arithmeticOperator,
CompareOp: t.compareOperator,
LogicOp: t.logicOperator,
'"->"': t.operator,
'"."': t.derefOperator,
"( )": t.paren,
"[ ]": t.squareBracket,
'SetOrMapLiteral/"{" SetOrMapLiteral/"}"': t.brace,
", ;": t.separator,
'":"': t.punctuation,
}),
foldNodeProp.add({
"Scriptlet Declaration JspExpression": foldInside,
JspComment: (node, state) => ({
from: node.from + 4,
to: state.sliceDoc(node.to - 4, node.to) == "--%>" ? node.to - 4 : node.to,
}),
}),
],
});
const javaParser = javaLanguage.parser.configure({ props: [styleTags(JAVA_TAGS)] });
/** `input` with a `;` at `at`. */
class TerminatedInput implements Input {
constructor(
private readonly input: Input,
private readonly at: number,
) {}
get length() {
return this.input.length;
}
get lineChunks() {
return this.input.lineChunks;
}
chunk(from: number): string {
if (from == this.at) return ";";
const text = this.input.chunk(from);
return from < this.at && this.at < from + text.length ? text.slice(0, this.at - from) : text;
}
read(from: number, to: number): string {
const text = this.input.read(from, to);
return from <= this.at && this.at < to
? text.slice(0, this.at - from) + ";" + text.slice(this.at - from + 1)
: text;
}
}
/**
* Java has no expression top: an expression is parsed as a statement, its
* trailing space read as the `;` (`<%= name %>`), so that a lone name is not
* a call missing its arguments. Without one, it ends in an error node.
*/
class ExpressionParser extends Parser {
private readonly java = javaParser.configure({});
createParse(
input: Input,
fragments: readonly TreeFragment[],
ranges: readonly { from: number; to: number }[],
): PartialParse {
const end = ranges[ranges.length - 1]!.to;
const terminated = /\s/.test(input.read(end - 1, end)) ? new TerminatedInput(input, end - 1) : input;
return this.java.createParse(terminated, fragments, ranges);
}
}
// To reuse old inner trees, parseMixed takes those that the node at a mount's
// start and its ancestors had with the same parser, so each kind of mount has
// its own instance: an expression at the start of a page would otherwise take
// the scriptlets' tree.
const htmlMixed: NestedParse = { parser: baseHTML.language.parser, overlay: (node) => node.name == "Text" },
scriptletParser = javaParser.configure({}),
expressionMixed = { parser: new ExpressionParser() },
declarationMixed = { parser: javaParser.configure({ top: "ClassContent" }) },
elMixed = { parser: baseParser.configure({ top: "EL" }) };
/** The code of the scriptlets in `template`. */
function scriptletCode(template: SyntaxNode): { from: number; to: number }[] {
const code: { from: number; to: number }[] = [];
for (const scriptlet of template.getChildren("Scriptlet")) {
const java = scriptlet.getChild("JavaCode");
if (java) code.push({ from: java.from, to: java.to });
}
return code;
}
// The HTML overlay is on the document and the scriptlets' on the template it
// holds: a node mounts one tree, and an overlay's predicate does not see the
// nodes of an overlay inside it.
function mixJsp(node: SyntaxNodeRef): NestedParse | null {
switch (node.name) {
case "Document":
return htmlMixed;
case "Template": {
const overlay = scriptletCode(node.node);
return overlay.length ? { parser: scriptletParser, overlay } : null;
}
case "JavaCode": {
const element = node.node.parent?.name;
return element == "JspExpression" ? expressionMixed : element == "Declaration" ? declarationMixed : null;
}
case "ELContent":
return elMixed;
}
return null;
}
export const jspLanguage = LRLanguage.define({
name: "jsp",
parser: baseParser.configure({ wrap: parseMixed(mixJsp) }),
languageData: { commentTokens: JSP_COMMENT },
});
export function jsp(): LanguageSupport {
return new LanguageSupport(jspLanguage, [
baseHTML.support,
// Markup comments out of the page, with a JSP comment, as in IntelliJ.
baseHTML.language.data.of({ commentTokens: JSP_COMMENT }),
]);
}
+132
View File
@@ -0,0 +1,132 @@
// JSP pages: the JSP layer of a page, as IntelliJ's JSP view provider sees it.
// Comments, directives, scripting elements and EL expressions are parsed here;
// the template text between them is HTML and the scripting elements' code is
// Java, both mounted by `./index.ts`. EL expressions (`${…}`, `#{…}`) are their
// own top. Regenerate with `pnpm gen:jsp-grammar`.
@top Document { Template }
// The page's content, a node of its own: the HTML overlay is mounted on the
// document and the scriptlets' Java overlay on this.
Template {
(Text | JspComment | Directive | Declaration | JspExpression | Scriptlet | ELExpression)*
}
Scriptlet { "<%" JavaCode? "%>" }
JspExpression { "<%=" JavaCode? "%>" }
Declaration { "<%!" JavaCode? "%>" }
ELExpression { ("${" | "#{") ELContent? ELEnd { "}" } }
@skip { space } {
Directive { "<%@" DirectiveName { name } Attribute* "%>" }
Attribute { AttributeName { name } (Is AttributeValue)? }
@top EL { expression? (";" expression?)* }
expression[@isGroup=Expression] {
literal |
VariableName |
ParenthesizedExpression { "(" (expression ("," expression)*)? ")" } |
ListLiteral { "[" (expression ("," expression)*)? "]" } |
SetOrMapLiteral { "{" (entry ("," entry)*)? "}" } |
MemberExpression { expression !member ("." PropertyName | "[" expression "]") } |
CallExpression { expression !call ArgList } |
FunctionCall { Namespace ":" FunctionName ArgList } |
UnaryExpression { (kw<"not"> | kw<"empty"> | LogicOp<"!"> | ArithOp<"-">) !prefix expression } |
BinaryExpression {
expression !mult (ArithOp<"*" | "/" | "%"> | kw<"div"> | kw<"mod">) expression |
expression !add ArithOp<"+" | "-"> expression |
expression !concat ArithOp<"+="> expression |
expression !rel (CompareOp<"<" | ">" | "<=" | ">="> | kw<"lt"> | kw<"gt"> | kw<"le"> | kw<"ge"> | kw<"instanceof">) expression |
expression !equal (CompareOp<"==" | "!="> | kw<"eq"> | kw<"ne">) expression |
expression !and (LogicOp<"&&"> | kw<"and">) expression |
expression !or (LogicOp<"||"> | kw<"or">) expression
} |
ConditionalExpression { expression !ternary LogicOp<"?"> expression LogicOp<":"> expression } |
LambdaExpression { expression !lambda "->" expression } |
AssignmentExpression { expression !assign Is expression }
}
entry { expression (":" expression)? }
ArgList { "(" (expression ("," expression)*)? ")" }
}
@precedence {
member,
call,
prefix @right,
mult @left,
add @left,
concat @left,
rel @left,
equal @left,
and @left,
or @left,
ternary @right,
lambda @right,
assign @right
}
literal {
Number |
String |
@specialize[@name=BooleanLiteral]<identifier, "true" | "false"> |
kw<"null">
}
VariableName { identifier }
PropertyName { identifier }
FunctionName { identifier }
Is { "=" }
kw<term> { @specialize[@name={term}]<identifier, term> }
ArithOp<expr> { expr }
CompareOp<expr> { expr }
LogicOp<expr> { expr }
// Tried in this order, before the tokens below: a comment before `<%`.
@external tokens jspComment from "./tokens" { JspComment }
@external tokens text from "./tokens" { Text }
@external tokens javaCode from "./tokens" { JavaCode }
@external tokens elContent from "./tokens" { ELContent }
// `fn` of `fn:join(…)`, so that an identifier before a `:` stays a ternary's.
@external tokens namespace from "./tokens" { Namespace }
@tokens {
space { $[ \t\n\r]+ }
identifier { $[a-zA-Z_$\u{a1}-\u{10ffff}] $[a-zA-Z0-9_$\u{a1}-\u{10ffff}]* }
// Directive and attribute names: `include`, `pageEncoding`, `name-given`.
name { $[a-zA-Z_] $[a-zA-Z0-9_\-]* }
Number {
(@digit+ ("." @digit*)? | "." @digit+) ($[eE] $[+\-]? @digit+)?
}
String {
'"' (!["\\] | "\\" _)* '"'? |
"'" (!['\\] | "\\" _)* "'"?
}
AttributeValue {
'"' !["]* '"'? |
"'" ![']* "'"?
}
@precedence { Number, "." }
"<%" "<%=" "<%!" "<%@" "%>" "${" "#{"
"(" ")" "[" "]" "{" "}" "." "," ";" ":" "->"
}
@detectDelim
+252
View File
@@ -0,0 +1,252 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { EditorState } from "@codemirror/state";
import { ensureSyntaxTree, foldable, matchBrackets } from "@codemirror/language";
import type { Tree } from "@lezer/common";
import { buildParserFile } from "@lezer/generator";
import { highlightTree, tagHighlighter, tags as t } from "@lezer/highlight";
import { findLanguageDescription, loadSupport } from "../languages";
import { jsp, jspLanguage } from "./index";
const highlighter = tagHighlighter([
{ tag: t.processingInstruction, class: "pi" },
{ tag: t.special(t.brace), class: "el" },
{ tag: t.blockComment, class: "comment" },
{ tag: t.operatorKeyword, class: "opkw" },
{ tag: t.keyword, class: "keyword" },
{ tag: t.bool, class: "bool" },
{ tag: t.namespace, class: "ns" },
{ tag: t.typeName, class: "type" },
{ tag: t.definition(t.variableName), class: "def" },
{ tag: t.function(t.variableName), class: "call" },
{ tag: t.function(t.propertyName), class: "method" },
{ tag: t.variableName, class: "var" },
{ tag: t.propertyName, class: "prop" },
{ tag: t.attributeName, class: "attr" },
{ tag: t.attributeValue, class: "value" },
{ tag: t.string, class: "str" },
{ tag: t.number, class: "num" },
{ tag: t.tagName, class: "tag" },
]);
/** `text:class` of the styled tokens of `doc`, from `tree` or a fresh parse. */
function tokens(doc: string, tree: Tree = jspLanguage.parser.parse(doc)): string[] {
const out: string[] = [];
highlightTree(tree, highlighter, (from, to, cls) => out.push(`${doc.slice(from, to)}:${cls}`));
return out;
}
/** The error nodes of `doc`'s tree, with the text they start. */
function errors(doc: string): string[] {
const out: string[] = [];
jspLanguage.parser.parse(doc).iterate({
enter: (node) => void (node.type.isError && out.push(doc.slice(node.from, node.from + 8))),
});
return out;
}
/** The tree of `state`, parsed to the end. */
function stateTree(state: EditorState): Tree {
const tree = ensureSyntaxTree(state, state.doc.length, 5000);
if (!tree) throw new Error("parse did not finish");
return tree;
}
/** A ~8 KB page with directives, scriptlets around markup, expressions and EL. */
function page(): string {
let doc = `<%@ page contentType="text/html;charset=UTF-8" %>\n<%@ taglib prefix="c" uri="jakarta.tags.core" %>\n`;
for (let i = 0; i < 40; i++) {
doc +=
`<%-- row ${i} --%>\n<% if (rows${i} != null) { %>\n` +
` <tr class="r${i}"><td><%= rows${i}.size() %></td><td>\${fn:length(names${i})}</td></tr>\n` +
`<% } %>\n<c:out value="\${user${i}.name}"/>\n`;
}
return doc;
}
describe("JSP pages", () => {
it("highlights directives and JSP comments", () => {
expect(tokens(`<%@ page import="java.util.*" %><%-- <b>off</b> --%>`)).toEqual([
"<%@:pi",
"page:keyword",
"import:attr",
'"java.util.*":value',
"%>:pi",
"<%-- <b>off</b> --%>:comment",
]);
});
it("parses the scriptlets as one Java body, around the markup", () => {
const doc = `<% for (String s : list) { %><li><%= s %></li><% } %>`;
expect(errors(doc)).toEqual([]);
expect(tokens(doc)).toEqual([
"<%:pi",
"for:keyword",
"String:type",
"s:def",
"list:var",
"%>:pi",
"li:tag",
"<%=:pi",
"s:var",
"%>:pi",
"li:tag",
"<%:pi",
"%>:pi",
]);
const state = EditorState.create({ doc, extensions: jsp() });
stateTree(state);
const open = doc.indexOf("{");
expect(matchBrackets(state, open, 1)?.end?.from).toBe(doc.lastIndexOf("}"));
});
it("keeps markup in a scriptlet's strings out of the page", () => {
const doc = `<% String cell = "<td>"; %><p>x</p>`;
expect(errors(doc)).toEqual([]);
expect(tokens(doc)).toContain('"<td>":str');
expect(tokens(doc).filter((s) => s.endsWith(":tag"))).toEqual(["p:tag", "p:tag"]);
});
it("highlights expressions and declarations as Java", () => {
const doc = `<%= name %><%= req.getParameter("q") %><%! private int count = 0; %>`;
expect(errors(doc)).toEqual([]);
expect(tokens(doc)).toEqual([
"<%=:pi",
"name:var",
"%>:pi",
"<%=:pi",
"req:var",
"getParameter:call",
'"q":str',
"%>:pi",
"<%!:pi",
"private:keyword",
"int:type",
"count:def",
"0:num",
"%>:pi",
]);
});
it("highlights EL expressions", () => {
const doc = `<p>\${fn:escapeXml(item.title)} #{a ? b : not empty c.d[0] and x.size() gt 2}</p>`;
expect(errors(doc)).toEqual([]);
expect(tokens(doc)).toEqual([
"p:tag",
"${:el",
"fn:ns",
"escapeXml:call",
"item:var",
"title:prop",
"}:el",
"#{:el",
"a:var",
"b:var",
"not:opkw",
"empty:opkw",
"c:var",
"d:prop",
"0:num",
"and:opkw",
"x:var",
"size:method",
"gt:opkw",
"2:num",
"}:el",
"p:tag",
]);
});
it("ends an EL expression at its own brace, past nested ones and strings", () => {
const doc = `\${ {'k': '}'}.k == true }<b>`;
expect(errors(doc)).toEqual([]);
expect(tokens(doc)).toEqual(["${:el", "'k':str", "'}':str", "k:prop", "true:bool", "}:el", "b:tag"]);
});
it("parses markup with elements in attribute values and custom tags", () => {
const doc =
`<c:forEach var="i" items="\${items}"><c:out value="\${i}"/></c:forEach>` +
`<input value="<%= x %>" onclick="go('<%= id %>')"><jsp:include page="f.jspf"/><p></p>`;
expect(errors(doc)).toEqual([]);
expect(tokens(doc).slice(0, 8)).toEqual([
"c:forEach:tag",
"var:attr",
'"i":value',
"items:attr",
'":value',
"${:el",
"items:var",
"}:el",
]);
});
it("parses EL in a script as EL, like the container", () => {
expect(tokens(`<script>let n = \`\${user.name}\`;</script>`)).toContain("user:var");
});
it("keeps escaped elements as text", () => {
expect(tokens(`<p>\\\${a} <\\% b %></p>`)).toEqual(["p:tag", "p:tag"]);
});
it("comments with JSP comments in the markup and Java ones in scriptlets", () => {
const doc = `<p>x</p><% int a; %>`;
const state = EditorState.create({ doc, extensions: jsp() });
stateTree(state);
const at = (pos: number) =>
state.languageDataAt<{ line?: string; block?: { open: string } }>("commentTokens", pos)[0];
expect(at(4)?.block?.open).toBe("<%--");
expect(at(doc.indexOf("int") + 1)?.line).toBe("//");
});
it("folds JSP comments and scriptlets", () => {
const doc = `<%--\n a\n--%>\n<%\n int a;\n%>`;
const state = EditorState.create({ doc, extensions: jsp() });
stateTree(state);
expect(foldable(state, 0, 4)).toEqual({ from: 4, to: doc.indexOf("--%>") });
const line = state.doc.line(4);
expect(foldable(state, line.from, line.to)).toEqual({ from: line.to, to: doc.lastIndexOf("%>") });
});
it("highlights like a fresh parse after resuming and editing", () => {
const doc = page();
// Opening parses the first 3000 characters; the rest resumes from that tree.
let state = EditorState.create({ doc, extensions: jsp() });
expect(tokens(doc, stateTree(state))).toEqual(tokens(doc));
const at = (text: string, n = 1) => {
let pos = -1;
for (let i = 0; i < n; i++) pos = state.doc.toString().indexOf(text, pos + 1);
return pos;
};
const edits = [
() => ({ from: at("rows12.size()") + 4, insert: "x" }),
() => ({ from: at(".size() %>", 15) + 7, insert: "+1" }),
() => ({ from: at("<% } %>", 20), insert: "<% int k = 0; %>\n" }),
() => ({ from: at("<%-- row 7"), to: at("<%-- row 7") + 4 }),
() => ({ from: at("names3"), to: at("names3") + 6, insert: "a.b[0]" }),
() => ({ from: 0, insert: "${top}\n" }),
() => ({ from: at("<tr", 10), insert: "<%= " }),
];
for (const edit of edits) {
state = state.update({ changes: edit() }).state;
const text = state.doc.toString();
expect(tokens(text, stateTree(state))).toEqual(tokens(text));
}
});
it("is generated from jsp.grammar", () => {
const dir = import.meta.dirname;
const { parser, terms } = buildParserFile(readFileSync(resolve(dir, "jsp.grammar"), "utf8"), {
fileName: "src/editor/core/cm/jsp/jsp.grammar",
moduleStyle: "es",
typeScript: true,
});
expect(parser).toBe(readFileSync(resolve(dir, "parser.ts"), "utf8"));
expect(terms).toBe(readFileSync(resolve(dir, "parser.terms.ts"), "utf8"));
});
it("is the JSP mode", async () => {
const desc = findLanguageDescription("JSP", "/p/index.jsp");
expect((await loadSupport(desc!))?.language).toBe(jspLanguage);
});
});
+39
View File
@@ -0,0 +1,39 @@
// This file was generated by lezer-generator. You probably shouldn't edit it.
export const
JspComment = 1,
Text = 2,
JavaCode = 3,
ELContent = 4,
Namespace = 5,
Document = 6,
Template = 7,
Directive = 10,
Attribute = 12,
Is = 14,
AttributeValue = 15,
Declaration = 17,
JspExpression = 19,
Scriptlet = 21,
ELExpression = 22,
EL = 27,
Number = 28,
String = 29,
BooleanLiteral = 30,
_null = 31,
VariableName = 32,
PropertyName = 45,
ArgList = 47,
FunctionName = 49,
not = 51,
empty = 52,
div = 57,
mod = 58,
lt = 62,
gt = 63,
le = 64,
ge = 65,
_instanceof = 66,
eq = 68,
ne = 69,
and = 71,
or = 73
+24
View File
@@ -0,0 +1,24 @@
// This file was generated by lezer-generator. You probably shouldn't edit it.
import {LRParser} from "@lezer/lr"
import {jspComment, text, javaCode, elContent, namespace} from "./tokens"
const spec_identifier = {__proto__:null,true:60, false:60, null:62, not:102, empty:104, div:114, mod:116, lt:124, gt:126, le:128, ge:130, instanceof:132, eq:136, ne:138, and:142, or:146}
export const parser = LRParser.deserialize({
version: 14,
states: "1WOqOsOOQ!sQ!rOOO!zQpO'#CfO#POtO'#CmO#XOtO'#CoO#aOtO'#CqO#iOxO'#CrOOOR'#EP'#EPO#qOsO'#CcQOOOOOOOQO'#E['#E[OOQO'#C|'#C|O#xQ!rO'#DPO$PQ!rO'#DTO$WQ!rO'#DVQ&bQ#tOOO&iQpO'#D^OOQ`'#Dc'#DcOOQ`'#Dd'#DdOxQ!rO'#D`OOQO'#EZ'#EZO&nQ!rO'#ETQ&xQpOOOOQO'#Cg'#CgO&}QpO,59QOOOR,59X,59XO'VOpO,59XOOOR,59Z,59ZO'[OpO,59ZOOOR,59],59]O'aOpO,59]OOOR'#Cu'#CuOOOR,59^,59^O#lOpO,59^OOOR-E7}-E7}OOQO,59k,59kO'fQ#tO,59kOOQO,59o,59oO'pQ#tO,59oO'zQ#tO'#E^OOQO,59q,59qO(XQpO,59qOOQ`'#Cj'#CjO(aQ!bO,59sOxQ!rO,59sO(fQ!rO'#D]OOQO,59v,59vOOQ`'#Df'#DfOOQ`'#Di'#DiOOQ`'#Dj'#DjOOQ`'#Dk'#DkOOQ`'#Dq'#DqOOQ`'#Dt'#DtOOQ`'#Dv'#DvOxQ!rO,5:POxQ!rO,5:POxQ!rO,5:POxQ!rO,5:POxQ!rO,5:POxQ!rO,5:POxQ!rO,5:POOQ`'#Dy'#DyOxQ!rO,5:dOxQ!rO,5:gOxQ!rO,5:iO(mQ!bO,59xO(rQ#tO,59zO+[Q#tO,5:oOOQO-E8R-E8ROOQO'#Ci'#CiO+fQpO'#ChOOQO'#EQ'#EQO+qQpO1G.lOOOR1G.l1G.lOOOR1G.s1G.sOOOR1G.u1G.uOOOR1G.w1G.wOOOR1G.x1G.xOxQ!rO'#ERO+yQpO1G/VOOQO1G/V1G/VO,RQpO1G/ZOOQO1G/Z1G/ZOxQ!rO,5:xOxQ!rO'#ESO,ZQpO1G/]OOQO1G/]1G/]OOQO'#DZ'#DZOOQO1G/_1G/_O,cQ#tO1G/_O,jQ#tO,59wOOQO,59w,59wO.}Q#tO1G/kO/bQ#tO1G/kO2[Q#tO1G/kO2cQ#tO1G/kO4vQ#tO1G/kO5WQ#tO1G/kO7[Q#tO1G/kO8UQ#tO1G0OO8]Q#tO1G0RO9VQ#tO1G0TOOQO'#D_'#D_O9pQpO1G/dO9uQpO,59SOOQO-E8O-E8OOOOR7+$W7+$WO9zQ#tO,5:mOOQO-E8P-E8POOQO7+$q7+$qOOQO7+$u7+$uO:XQ#tO1G0dOOQO,5:n,5:nOOQO-E8Q-E8QOOQO7+$w7+$wOOQO7+$y7+$yO:cQpO1G/cOOQO1G/c1G/cOOQ`'#Dz'#DzOxQ!rO7+%jOOQO7+%O7+%OOOQO1G.n1G.nOOQO7+$}7+$}O:kQ#tO<<IU",
stateData: ";h~O!zOS~OPVOQVOXQO`RObSOdTOgUOhUO~O!xVP~PVOT`OlYOmYOnYOoYOr[Ov]Ox^O!TcO!UcO#PZO#RaO#SbO~O!reO~PxO!{gO~ORjOWiO~ORlOWkO~ORnOWmO~OSqOjoO~O!xVX~PVOqsO~PxOuuO~PxOjxO~PxOr}Ov|O|{O!Z!WO![!WO!`!ZO!a!ZO!b!ZO!c!ZO!d!ZO!f![O!g![O!i!]O!k!^O!p!aO!|zO#S!QO#T!PO#U!PO#V!PO#W!QO#X!RO#Y!SO#Z!SO#[!SO#]!SO#^!TO#_!TO#`!UO#a!VO#b!_O~O!reO~P$_Oz!cO~O!r!wX!x!wX~PxO!reO~OW!kO!{!gO~OW!lO~OW!mO~OW!nO~Oq!rOt!pO~P$_Ot!pOu!tO~P$_Oz!uOj#QXt#QX~P$_Oj!xOt!vO~O#P!yO~Oq!}O~PxO#P#YO~Or}Ov|O|{O!Z!Sa![!Sa!`!Sa!a!Sa!b!Sa!c!Sa!d!Sa!f!Sa!g!Sa!i!Sa!k!Sa!p!Sa!r!Sa!x!Sa!|!Sa#S!Sa#T!Sa#U!Sa#V!Sa#W!Sa#X!Sa#Y!Sa#Z!Sa#[!Sa#]!Sa#^!Sa#_!Sa#`!Sa#a!Sa#b!Saq!Sat!Sau!Saj!Saz!Sa~O!r!wa!x!wa~P$_O!|zOW[X!{[X~OW#^O!{!gO~Oq#aOt!pO~Ot!pOu#bO~Oj#fOt!vO~Ou#gO~P$_Oq#iOt!pO~P$_Or}Ov|O|{O!`!Xi!a!Xi!b!Xi!c!Xi!d!Xi!f!Xi!g!Xi!i!Xi!k!Xi!p!Xi!r!Xi!x!Xi!|!Xi#S!Xi#W!Xi#X!Xi#Y!Xi#Z!Xi#[!Xi#]!Xi#^!Xi#_!Xi#`!Xi#a!Xi#b!Xiq!Xit!Xiu!Xij!Xiz!Xi~O!Z!Xi![!Xi#T!Xi#U!Xi#V!Xi~P,tO!Z!WO![!WO#T!PO#U!PO#V!PO~P,tOr}Ov|O|{O!Z!WO![!WO#S!QO#T!PO#U!PO#V!PO#W!QO!`!Xi!a!Xi!b!Xi!c!Xi!d!Xi!f!Xi!g!Xi!i!Xi!k!Xi!p!Xi!r!Xi!x!Xi!|!Xi#Y!Xi#Z!Xi#[!Xi#]!Xi#^!Xi#_!Xi#`!Xi#a!Xi#b!Xiq!Xit!Xiu!Xij!Xiz!Xi~O#X!Xi~P/uO#X!RO~P/uOr}Ov|O|{O!Z!WO![!WO!`!ZO!a!ZO!b!ZO!c!ZO!d!ZO#S!QO#T!PO#U!PO#V!PO#W!QO#X!RO#Y!SO#Z!SO#[!SO#]!SO!i!Xi!k!Xi!p!Xi!r!Xi!x!Xi!|!Xi#`!Xi#a!Xi#b!Xiq!Xit!Xiu!Xij!Xiz!Xi~O!f!Xi!g!Xi#^!Xi#_!Xi~P2jO!f![O!g![O#^!TO#_!TO~P2jOr}Ov|O|{O!Z!WO![!WO!`!ZO!a!ZO!b!ZO!c!ZO!d!ZO!f![O!g![O!i!]O#S!QO#T!PO#U!PO#V!PO#W!QO#X!RO#Y!SO#Z!SO#[!SO#]!SO#^!TO#_!TO#`!UO~O!k!Xi!p!Xi!r!Xi!x!Xi!|!Xi#a!Xi#b!Xiq!Xit!Xiu!Xij!Xiz!Xi~P5hOz#jO~P$_O!k!^O!p!aO#a!VO#b!_O!r!oi!x!oi!|!oiq!oit!oiu!oij!oiz!oi~P5hO!r!qi!x!qiq!qit!qiu!qij!qiz!qi~P$_Or}O~O_#mO~Oq!uat!uau!ua~P$_Oj#Qit#Qi~P$_Oq#nOt!pO~O!k!^O#a!VO#b!_O!p!ly!r!ly!x!ly!|!lyq!lyt!lyu!lyj!lyz!ly~P5hOl|~",
goto: "+X#RPPPPPPP#SPP#V#Z#^#b#fPP#VP#VP#V#VPP#vPPPPPP#|PP#|PPP#|P#|P#|P$f#|$i#|%T#|PP%W%W#|%pPP&V&k'OPPPPP'bPP'sP(TP#|(d(s#|P#|P(v(|)S)b)hPPPPP)o#|P+RRXOTVOWRhQT!ih!jT!hh!jf!b_tvw!e!{!|#V#X#_#cR#[!hQpUR!oq}dP[]^ce|}!W!X!Y!Z![!]!^!`!a!b!p!u!v#kR!z{z!O_tvw!d!e!{!|#O#P#Q#R#S#T#U#V#W#X#_#c#oR#l#ZR#Z!c}cP[]^ce|}!W!X!Y!Z![!]!^!`!a!b!p!u!v#kw!W_tvw!e!{!|#P#Q#R#S#T#U#V#W#X#_#c#ou!X_tvw!e!{!|#Q#R#S#T#U#V#W#X#_#c#os!Y_tvw!e!{!|#R#S#T#U#V#W#X#_#c#oq!Z_tvw!e!{!|#S#T#U#V#W#X#_#c#oo![_tvw!e!{!|#T#U#V#W#X#_#c#om!]_tvw!e!{!|#U#V#W#X#_#c#ok!^_tvw!e!{!|#V#W#X#_#c#ok!`_tvw!e!{!|#V#W#X#_#c#oR#k#VQWORrWQ!jhR#]!jQ!qtQ!svU#`!q!s#hR#h!|Q!wyR#e!wSfP_R!ffQ_PQt[Qv]Sw^!vQ!dcQ!eeQ!{|Q!|}Q#O!WQ#P!XQ#Q!YQ#R!ZQ#S![Q#T!]Q#U!^Q#V!`Q#W!aQ#X!bQ#_!pQ#c!uR#o#kQy^R#d!v",
nodeNames: "⚠ JspComment Text JavaCode ELContent Namespace Document Template %> <%@ Directive DirectiveName Attribute AttributeName Is AttributeValue <%! Declaration <%= JspExpression <% Scriptlet ELExpression ${ #{ ELEnd } EL Number String BooleanLiteral null VariableName ) ( ParenthesizedExpression , ] [ ListLiteral { SetOrMapLiteral : MemberExpression . PropertyName CallExpression ArgList FunctionCall FunctionName UnaryExpression not empty LogicOp ArithOp BinaryExpression ArithOp div mod ArithOp ArithOp CompareOp lt gt le ge instanceof CompareOp eq ne LogicOp and LogicOp or ConditionalExpression LogicOp LogicOp LambdaExpression -> AssignmentExpression ;",
maxTerm: 110,
nodeProps: [
["openedBy", 8,"<%@ <%! <%= <%",26,"{",33,"(",37,"["],
["closedBy", -4,9,16,18,20,"%>",34,")",38,"]",40,"}"],
["group", -16,28,29,30,31,32,35,39,41,43,46,48,50,55,74,77,79,"Expression"]
],
skippedNodes: [0],
repeatNodeCount: 5,
tokenData: "5S~RuXY#fYZ#f]^#fpq#fqr#wrs$Ust(etu(puv*[vw*iwx*txy.wyz.|z{/R{|/W|}/e}!O/j!O!P/w!P!Q0y!Q![1O![!]1a!]!^1f!^!_1k!_!`2j!`!a2w!a!b3U!c!}3Z!}#O4c#P#Q4h#R#S3Z#T#o3Z#o#p4m#p#q4r#q#r4}$g;'S)b;'S;=`*P<%lO)b~#kS!z~XY#fYZ#f]^#fpq#fU#|P#RQ!_!`$PS$UO#_SR$]V_PmQOr$Urs$rs#O$U#O#P$y#P;'S$U;'S;=`(_<%lO$UR$yO_PmQR%OU_POr$Urs%bs;'S$U;'S;=`'p;=`<%l&O<%lO$UR%iV_PmQOr&Ors&js#O&O#O#P&o#P;'S&O;'S;=`'j<%lO&OQ&TVmQOr&Ors&js#O&O#O#P&o#P;'S&O;'S;=`'j<%lO&OQ&oOmQQ&rRO;'S&O;'S;=`&{;=`O&OQ'QWmQOr&Ors&js#O&O#O#P&o#P;'S&O;'S;=`'j;=`<%l&O<%lO&OQ'mP;=`<%l&OR'uWmQOr&Ors&js#O&O#O#P&o#P;'S&O;'S;=`'j;=`<%l$U<%lO&OR(bP;=`<%l$U~(hP#o#p(k~(pOh~V(uX#PUtu)b!Q![)b!c!})b#R#S)b#T#o)b#o#p*V$g;'S)b;'S;=`*P<%lO)bU)gW#PUtu)b!Q![)b!c!})b#R#S)b#T#o)b$g;'S)b;'S;=`*P<%lO)bU*SP;=`<%l)bP*[OgPT*aP#VS!`!a*dP*iOWP~*lPvw*o~*tO#`~R*{V_PmQOw*twx$rx#O*t#O#P+b#P;'S*t;'S;=`.q<%lO*tR+gU_POw*twx+yx;'S*t;'S;=`.S;=`<%l,g<%lO*tR,QV_PmQOw,gwx&jx#O,g#O#P-R#P;'S,g;'S;=`-|<%lO,gQ,lVmQOw,gwx&jx#O,g#O#P-R#P;'S,g;'S;=`-|<%lO,gQ-URO;'S,g;'S;=`-_;=`O,gQ-dWmQOw,gwx&jx#O,g#O#P-R#P;'S,g;'S;=`-|;=`<%l,g<%lO,gQ.PP;=`<%l,gR.XWmQOw,gwx&jx#O,g#O#P-R#P;'S,g;'S;=`-|;=`<%l*t<%lO,gR.tP;=`<%l*t~.|Or~~/ROq~~/WO#T~~/]P#W~!_!`/`~/eO#X~~/jOt~~/oP#S~!`!a/r~/wO!p~~/|P|~!Q![0P~0URl~!Q![0P!g!h0_#X#Y0_~0bR{|0k}!O0k!Q![0q~0nP!Q![0q~0vPl~!Q![0q~1OO#U~~1TSl~!O!P0P!Q![1O!g!h0_#X#Y0_~1fOz~~1kO!r~~1pQ#YSuv1v!_!`2eP1{RdPqr2U!_!`2Z!b!c2`P2ZO`PP2`ObPP2eOXP~2jO#[~~2oP!|~!_!`2r~2wO#^~~2|P#Z~!_!`3P~3UO#]~~3ZO#b~V3bX#PU!{Ptu)b}!O3}!Q![3Z!c!}3Z#R#S3Z#T#o3Z$g;'S)b;'S;=`*P<%lO)bP4ST!{P}!O3}!Q![3}!c!}3}#R#S3}#T#o3}~4hOv~~4mOu~~4rOx~~4uP#p#q4x~4}O#a~~5SOj~",
tokenizers: [jspComment, text, javaCode, elContent, namespace, 0, 1, 2],
topRules: {"Document":[0,6],"EL":[1,27]},
specialized: [{term: 93, get: (value: keyof typeof spec_identifier) => spec_identifier[value] || -1}],
tokenPrec: 1217
})
+101
View File
@@ -0,0 +1,101 @@
/**
* External tokens of `jsp.grammar`: the runs the JSP layer leaves to HTML and
* Java, the comments, and the extent of EL expressions.
*/
import { ExternalTokenizer, type InputStream } from "@lezer/lr";
import { ELContent, JavaCode, JspComment, Namespace, Text } from "./parser.terms";
const DOUBLE_QUOTE = 34,
HASH = 35,
DOLLAR = 36,
PERCENT = 37,
SINGLE_QUOTE = 39,
PAREN_L = 40,
DASH = 45,
COLON = 58,
LT = 60,
GT = 62,
BACKSLASH = 92,
UNDERSCORE = 95,
BRACE_L = 123,
BRACE_R = 125;
/** Whether `<%` starts `offset` code units ahead. */
function scriptStart(input: InputStream, offset = 0): boolean {
return input.peek(offset) == LT && input.peek(offset + 1) == PERCENT;
}
/** Whether `${` or `#{` starts `offset` code units ahead. */
function elStart(input: InputStream, offset = 0): boolean {
const ch = input.peek(offset);
return (ch == DOLLAR || ch == HASH) && input.peek(offset + 1) == BRACE_L;
}
/** `<%-- … --%>`, to the end of the page when unterminated. */
export const jspComment = new ExternalTokenizer((input) => {
if (!scriptStart(input) || input.peek(2) != DASH || input.peek(3) != DASH) return;
input.advance(4);
while (input.next >= 0) {
if (input.next == DASH && input.peek(1) == DASH && input.peek(2) == PERCENT && input.peek(3) == GT) {
input.advance(4);
break;
}
input.advance();
}
input.acceptToken(JspComment);
});
/**
* Template text: up to a scripting element or an EL expression. Escaped ones
* (`<\%`, `\${`, `\#{`) are text.
*/
export const text = new ExternalTokenizer((input) => {
const start = input.pos;
while (input.next >= 0 && !scriptStart(input) && !elStart(input)) {
input.advance(input.next == BACKSLASH && elStart(input, 1) ? 2 : 1);
}
if (input.pos > start) input.acceptToken(Text);
});
/** The code of a scripting element, up to its `%>` (`%\>` quotes one). */
export const javaCode = new ExternalTokenizer((input) => {
const start = input.pos;
while (input.next >= 0 && !(input.next == PERCENT && input.peek(1) == GT)) {
input.advance(input.next == PERCENT && input.peek(1) == BACKSLASH && input.peek(2) == GT ? 3 : 1);
}
if (input.pos > start) input.acceptToken(JavaCode);
});
/** An EL expression's content: up to the `}` closing it, past nested braces and strings. */
export const elContent = new ExternalTokenizer((input) => {
const start = input.pos;
let depth = 0;
for (let ch; (ch = input.next) >= 0; input.advance()) {
if (ch == BRACE_R) {
if (!depth) break;
depth--;
} else if (ch == BRACE_L) {
depth++;
} else if (ch == DOUBLE_QUOTE || ch == SINGLE_QUOTE) {
input.advance();
while (input.next >= 0 && input.next != ch) input.advance(input.next == BACKSLASH ? 2 : 1);
if (input.next < 0) break;
}
}
if (input.pos > start) input.acceptToken(ELContent);
});
const isIdentifierStart = (ch: number) =>
(ch >= 65 && ch <= 90) || (ch >= 97 && ch <= 122) || ch == UNDERSCORE || ch == DOLLAR || ch >= 0xa1;
const isIdentifier = (ch: number) => isIdentifierStart(ch) || (ch >= 48 && ch <= 57);
/** An identifier followed by `:name(`, a function's namespace (`fn:join(…)`). */
export const namespace = new ExternalTokenizer((input, stack) => {
if (!isIdentifierStart(input.next) || !stack.canShift(Namespace)) return;
let i = 1;
while (isIdentifier(input.peek(i))) i++;
const end = i;
if (input.peek(i++) != COLON || !isIdentifierStart(input.peek(i++))) return;
while (isIdentifier(input.peek(i))) i++;
if (input.peek(i) == PAREN_L) input.acceptToken(Namespace, end);
});
+7 -2
View File
@@ -102,6 +102,7 @@ const OWN_MODES = [
extensions: ["erb", "rhtml"],
load: () => import("./erb").then((m) => m.erb()),
}),
LanguageDescription.of({ name: "JSP", load: () => import("./jsp").then((m) => m.jsp()) }),
];
const BATCH_KEYWORDS = new Set([
@@ -222,14 +223,18 @@ const CSS_TAGS: Parameters<typeof styleTags>[0] = {
// @lezer/javascript styles `.` as a deref operator, but not `?.`.
const JS_TAGS: Parameters<typeof styleTags>[0] = { "?.": t.derefOperator };
// @lezer/java tags annotations as plain identifiers.
export const JAVA_TAGS: Parameters<typeof styleTags>[0] = {
"MarkerAnnotation/Identifier Annotation/Identifier MarkerAnnotation Annotation": t.annotation,
};
/** Highlighting the stock grammars lack or colour unlike IntelliJ. */
const EXTRA_TAGS: Record<string, Parameters<typeof styleTags>[0]> = {
JavaScript: JS_TAGS,
TypeScript: JS_TAGS,
JSX: JS_TAGS,
TSX: JS_TAGS,
// @lezer/java tags annotations as plain identifiers.
Java: { "MarkerAnnotation/Identifier Annotation/Identifier MarkerAnnotation Annotation": t.annotation },
Java: JAVA_TAGS,
CSS: CSS_TAGS,
SCSS: CSS_TAGS,
Sass: CSS_TAGS,
+28 -1
View File
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { EditorState } from "@codemirror/state";
import { language as languageFacet } from "@codemirror/language";
import { indentUnit, language as languageFacet } from "@codemirror/language";
import { registerSaveParticipant, remoteChange } from "@/editor/api";
import { createMemoryBackend, type MemoryBackend } from "../backend";
import { _resetLanguages } from "../cm/languages";
@@ -56,6 +56,33 @@ describe("DocumentManager + slots", () => {
expect(formatOptionsOf(slot.currentState())).toEqual({ tabSize: 1, insertSpaces: false });
});
it("detects the indents again after Reformat Code rewrote them", async () => {
const settings = {
editor: {
codeStyle: {
languages: { java: { useTab: true, tabSize: 1, indent: 1 } },
detectIndents: true,
editorconfig: false,
},
},
};
const { manager } = setup({ "/p/A.java": "class A {\n int x;\n}\n" }, settings);
const model = await manager.acquire("/p/A.java");
const first = new EditorSlot(model, "t1", host);
const split = new EditorSlot(model, "t2", host, { from: first });
expect(first.currentState().tabSize).toBe(4);
// Typing keeps the indents detected when the file was opened.
first.dispatchLocal({ changes: { from: 10, to: 14, insert: "\t" }, userEvent: "input.type" });
expect(first.currentState().tabSize).toBe(4);
first.dispatchLocal({ changes: { from: 10, to: 11, insert: " " }, userEvent: "undo" });
first.dispatchLocal({ changes: { from: 10, to: 14, insert: "\t" }, userEvent: "format.reformat" });
for (const slot of [first, split]) {
expect(slot.currentState().tabSize).toBe(1);
expect(slot.currentState().facet(indentUnit)).toBe("\t");
}
expect(useDocMeta.getState()["/p/A.java"]?.indent).toEqual({ tabSize: 1, insertSpaces: false });
});
it("shares one document between split slots and syncs edits to the backend", async () => {
const { backend, manager, frame } = setup({ "/p/a.txt": "hello\n" });
const model = await manager.acquire("/p/a.txt");
+14 -1
View File
@@ -392,7 +392,7 @@ export class DocModel {
/** Per-file indentation chosen from the status bar; wins over settings until the file is closed. */
indentOverride: { tabSize: number; insertSpaces: boolean } | null = null;
/** Indentation detected in the file's text at open time. */
/** Indentation detected in the file's text at open time and after Reformat Code. */
detectedIndent: { insertSpaces: boolean; size?: number } | null = null;
/** Last tree-sitter highlighting, for slots opened before the next refresh. */
private highlights: { counter: number; value: SyntaxHighlights } | null = null;
@@ -418,6 +418,19 @@ export class DocModel {
return effectiveIndent(this.settings(), this.info, this.editorconfig, null);
}
/**
* Detects the indents again after Reformat Code rewrote them: tabs written by
* the formatter then render at the Code Style tab size and new lines indent
* like them, instead of following the indents detected at open time.
*/
redetectIndent() {
const detected = detectIndent(this.sync.localText.sliceString(0, 200_000));
const current = this.detectedIndent;
if (detected?.insertSpaces === current?.insertSpaces && detected?.size === current?.size) return;
this.detectedIndent = detected;
this.applySettings();
}
/** Soft wraps toggled for this file (wins over settings until the file is closed). */
softWrapOverride: boolean | null = null;
+4
View File
@@ -199,6 +199,7 @@ export class EditorSlot {
private onTransactions(trs: readonly Transaction[], view: EditorView) {
view.update(trs);
let selection = false;
let reformatted = false;
let navigatedFrom: { line: number; col: number } | undefined;
for (const tr of trs) {
if (!navigatedFrom && tr.selection && isNavigation(tr)) {
@@ -217,8 +218,10 @@ export class EditorSlot {
)
this.host.onUserEdit(this, tr);
}
if (tr.docChanged && tr.isUserEvent("format.reformat")) reformatted = true;
if (tr.selection || tr.docChanged) selection = true;
}
if (reformatted) this.model.redetectIndent();
if (selection) this.host.onSelection(this, navigatedFrom);
}
@@ -231,6 +234,7 @@ export class EditorSlot {
const tr = this.state.update(spec);
this.state = tr.state;
if (tr.docChanged) this.model.onLocal(tr.changes, this);
if (tr.docChanged && tr.isUserEvent("format.reformat")) this.model.redetectIndent();
}
/** Applies changes that are already in the model (other slot / backend). */
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { targetLabel } from "./files";
describe("targetLabel", () => {
const at = (line: string, needle: string) => targetLabel(line, line.indexOf(needle));
it.each([
// Spring configuration consumers start at the string literal, not a name.
[' @Value("${app.name}") private String appName;', '"${app', '"${app.name}"'],
[" @Value('${app.name}') String appName", "'${app", "'${app.name}'"],
[" const key = `app.${env}`;", "`app", "`app.${env}`"],
[' String s = "say \\"hi\\"" + x;', '"say', '"say \\"hi\\""'],
// Unterminated (multi-line) literals end at the line end.
[' String block = "unterminated', '"unterminated', '"unterminated'],
// Ordinary declarations keep the identifier.
[" private String appName;", "appName", "appName"],
[" public int getTimeOut() { return timeOut; }", "getTimeOut", "getTimeOut"],
[" int $inner = 1;", "$inner", "$inner"],
[" time-out: 30", "time", "time"],
])("%s", (line, needle, expected) => {
expect(at(line, needle)).toBe(expected);
});
it("shortens long literals", () => {
const line = `x("${"a".repeat(100)}")`;
const label = targetLabel(line, 2)!;
expect(label).toHaveLength(60);
expect(label.endsWith("…")).toBe(true);
});
it("has no label for empty literals, numbers or blank positions", () => {
expect(targetLabel(' x = "";', 6)).toBeNull();
expect(targetLabel(" port: 8080", 8)).toBeNull();
expect(targetLabel(" ", 1)).toBeNull();
});
});
+21 -1
View File
@@ -53,8 +53,28 @@ export async function withDocument<T>(path: string, fn: (doc: number) => Promise
}
}
/** Longest literal label before it is shortened with an ellipsis. */
const MAX_LITERAL_LABEL = 60;
/**
* Display name of a navigation target starting at `col` of `line`: the string
* literal when the target is one (a Spring `@Value("${key}")` consumer, a
* configuration key in quotes), else the identifier there; null when neither.
*/
export function targetLabel(line: string, col: number): string | null {
const quote = line[col];
if (quote === '"' || quote === "'" || quote === "`") {
let end = col + 1;
while (end < line.length && line[end] !== quote) end += line[end] === "\\" ? 2 : 1;
const literal = line.slice(col, Math.min(end + 1, line.length));
if (literal.length > 2)
return literal.length > MAX_LITERAL_LABEL ? `${literal.slice(0, MAX_LITERAL_LABEL - 1)}…` : literal;
}
return identifierAt(line, col);
}
/** The identifier at (or starting right after) `col` of `line`; null when there is none. */
export function identifierAt(line: string, col: number): string | null {
function identifierAt(line: string, col: number): string | null {
const re = /[\p{L}\p{N}_$]+/gu;
for (let m = re.exec(line); m; m = re.exec(line)) {
const end = m.index + m[0].length;
+7 -2
View File
@@ -161,7 +161,7 @@ async function format(
}
view.dispatch({
changes,
userEvent: "format",
userEvent: "format.reformat",
annotations: isolateHistory.of("full"),
scrollIntoView: false,
});
@@ -235,7 +235,12 @@ function applyContributedFormat(
if (opts.explicit) showHint(view, "No lines changed: content is already properly formatted");
return NOTHING;
}
view.dispatch({ changes, userEvent: "format", annotations: isolateHistory.of("full"), scrollIntoView: false });
view.dispatch({
changes,
userEvent: "format.reformat",
annotations: isolateHistory.of("full"),
scrollIntoView: false,
});
return { applied: true, changes: changes.length, lines: formattedLines(state.doc, changes) };
}
+2 -2
View File
@@ -26,7 +26,7 @@ import { offsetToPos, rangeToOffsets } from "./positions";
import { showHint } from "./hint";
import { caretAnchor, focusPopup, openPopup, useLspPopup, type DefinitionTarget, type DocEntry } from "./popupStore";
import { selectedLookupDoc, showLookupDocs } from "./completion";
import { identifierAt, readFileLines, withDocument } from "./files";
import { readFileLines, targetLabel, withDocument } from "./files";
import { docWindowVisible, moveDocPopupToWindow, showInDocWindow } from "./docs";
import { structure } from "@/editor/structure";
import { basename } from "@/platform/project";
@@ -326,7 +326,7 @@ async function definitionTarget(loc: Location, fallbackLanguage: string | null):
const [lines, declaration] = await Promise.all([readFileLines(loc.path), declarationRange(loc)]);
if (!lines) return null;
const { first, last } = definitionSpan(loc, lines.length, declaration);
const name = identifierAt(lines[loc.range.start.line] ?? "", loc.range.start.col) ?? basename(loc.path);
const name = targetLabel(lines[loc.range.start.line] ?? "", loc.range.start.col) ?? basename(loc.path);
return {
location: loc,
name,
+2 -2
View File
@@ -48,7 +48,7 @@ import {
} from "./syntaxFallback";
import { showHint, showPendingHint } from "./hint";
import { caretAnchor, openPopup, type ListPopupItem } from "./popupStore";
import { identifierAt, readManyFileLines } from "./files";
import { readManyFileLines, targetLabel } from "./files";
import { documentLinks, followReference, knownLinks, linkReference, referenceAt, textReference } from "./links";
import { featureBackoff } from "./availability";
import { requestHover, showPhpConstructDocumentation } from "./hover";
@@ -111,7 +111,7 @@ export async function locationItems(locs: readonly DeclarationTarget[]): Promise
run: () => navigateTo(l),
};
// Server targets: the declared name (speed search matches it), file and line after it.
const name = identifierAt(files.get(l.path)?.[l.range.start.line] ?? "", l.range.start.col);
const name = targetLabel(files.get(l.path)?.[l.range.start.line] ?? "", l.range.start.col);
return {
id: `${i}`,
label: name ?? basename(l.path),
+2 -2
View File
@@ -6,7 +6,7 @@
*/
import type { Location, OutlineItem, SymbolKind } from "@/platform/ipc";
import { tx } from "@/platform/i18n";
import { identifierAt } from "@/editor/lsp/files";
import { targetLabel } from "@/editor/lsp/files";
import { baseName, type SuperMethod } from "./structureNav";
export type MarkerKind = "implemented" | "overridden" | "implementing" | "overriding";
@@ -151,7 +151,7 @@ export function enclosingTypeName(lines: readonly string[] | null | undefined, t
/** Display name of a target: the type itself for type markers, the type around it for methods. */
export function targetName(lines: readonly string[] | null | undefined, target: Location, method: boolean): string {
if (!method) {
const name = identifierAt(lines?.[target.range.start.line] ?? "", target.range.start.col);
const name = targetLabel(lines?.[target.range.start.line] ?? "", target.range.start.col);
if (name) return name;
}
return enclosingTypeName(lines, target);
+2 -1
View File
@@ -125,10 +125,11 @@ async function onCloseRequested(deps: CloseDeps): Promise<boolean> {
},
deps,
);
if (!ok && ctx.quitting) await commands.appCancelQuit();
if (!ok) await (ctx.quitting ? commands.appCancelQuit() : commands.windowGroupCancelClose());
return ok;
} catch (e) {
maybeEditorService()?.cancelClose?.();
void commands.windowGroupCancelClose().catch(() => {});
deps.onSaveFailed(e);
return false;
} finally {
+36 -1
View File
@@ -23,7 +23,7 @@ import { connectRemote, isRemoteWindow } from "@/platform/remote";
import { holdSplash } from "@/platform/splash";
import { editorService, maybeEditorService, notifications } from "@/workbench/contributions";
import { chooseProjectWindow } from "@/workbench/projectWindow";
import { cycleWindowTab } from "@/platform/windowGroups";
import { cycleWindowTab, useWindowGroup } from "@/platform/windowGroups";
import { showWorkspaceDialog } from "./WorkspaceDialog";
export const LAUNCH_EVENT = "openidea://launch";
@@ -222,6 +222,20 @@ export async function consumeLaunch(): Promise<void> {
}
}
/** Closes the project tabs on `side` of the `arg` tab (default: the current one), keeping that tab. */
function closeProjectTabs(side: "others" | "left" | "right"): Action["run"] {
return async ({ arg }) => {
const tabs = useWindowGroup.getState().tabs;
const keep = typeof arg === "string" ? arg : tabs.find((tab) => tab.current)?.label;
const index = tabs.findIndex((tab) => tab.label === keep);
if (!keep || index < 0) return;
const labels = tabs
.filter((_, i) => (side === "left" ? i < index : side === "right" ? i > index : i !== index))
.map((tab) => tab.label);
if (labels.length) await call(commands.windowGroupCloseTabs(keep, labels));
};
}
export function windowActions(): Action[] {
return [
{
@@ -302,6 +316,27 @@ export function windowActions(): Action[] {
if (typeof arg === "string") await call(commands.windowGroupClose(arg));
},
},
{
id: "window.closeOtherTabs",
title: "Close Other Project Tabs",
category: t("app.category.window"),
when: "windowGrouped",
run: closeProjectTabs("others"),
},
{
id: "window.closeTabsToLeft",
title: "Close Project Tabs to the Left",
category: t("app.category.window"),
when: "windowGrouped",
run: closeProjectTabs("left"),
},
{
id: "window.closeTabsToRight",
title: "Close Project Tabs to the Right",
category: t("app.category.window"),
when: "windowGrouped",
run: closeProjectTabs("right"),
},
{
id: "window.reorderTabs",
title: "Reorder Project Tabs",
+3 -1
View File
@@ -457,7 +457,9 @@ export function withParams(config: RunConfig, params: Record<string, string>, ty
...(config.kind === "dotnet" && config.beforeLaunch === undefined
? { beforeLaunch: [{ kind: "dotnetBuild", target: "" }] }
: {}),
...(config.kind === "dotnet-test" || config.kind === "django-test" || config.kind === "rspec" ? { isTest: true } : {}),
...(config.kind === "dotnet-test" || config.kind === "django-test" || config.kind === "rspec"
? { isTest: true }
: {}),
params: params.goKind ? { ...params, programArgv: JSON.stringify(parseArgs(params.programArgs ?? "")) } : params,
...(config.kind === "go-test" && !config.id.startsWith("template:") ? { kind: "go" } : {}),
...(params.goKind ? { isTest: params.goKind === "test" } : {}),
@@ -22,6 +22,7 @@ vi.mock("@/platform/ipc", async (importOriginal) => {
});
import { registerAction, executeAction } from "@/platform/actions";
import { usePlatform } from "@/platform/platformInfo";
import { useProject } from "@/platform/project";
import { useKeymap } from "@/platform/keymap";
import { ContextMenuHost } from "@/ui";
@@ -29,6 +30,8 @@ import { PageContext, useSetting } from "../components/context";
import { DEFAULT_SETTINGS, setAt } from "../model/schema";
import { openSettings, stage, useSettingsDialog } from "../model/staging";
import { SettingsDialog } from "../dialog/SettingsDialog";
import { SettingsWindow } from "../window/SettingsWindow";
import dialogStyles from "../dialog/SettingsDialog.module.css";
import { activate } from "..";
import { MessagesHost } from "@/workbench/messages";
@@ -471,6 +474,23 @@ describe("Settings dialog", () => {
});
});
describe("native Settings window root", () => {
it("marks the window edge on Linux, whose frameless windows get no compositor shadow", () => {
const saved = usePlatform.getState();
usePlatform.setState({ os: "linux" });
let view = render(<SettingsWindow />);
let root = document.querySelector(`.${dialogStyles.window}`);
expect(root).toHaveClass(dialogStyles.windowEdge!);
view.unmount();
usePlatform.setState({ os: "windows" });
view = render(<SettingsWindow />);
root = document.querySelector(`.${dialogStyles.window}`);
expect(root).not.toHaveClass(dialogStyles.windowEdge!);
view.unmount();
usePlatform.setState(saved);
});
});
describe("native Settings window background", () => {
it.each([
["a hex frame", "#26282c", "", "#26282c"],
@@ -203,6 +203,19 @@
background: var(--bg-dialog, var(--bg-elev));
color: var(--fg);
}
/* Linux: on Wayland GTK draws the frameless window's shadow (`decorate_csd` in
windows.rs), but frameless X11 windows have none, so mark the edge in CSS. */
.windowEdge {
position: relative;
}
.windowEdge::after {
content: "";
position: absolute;
inset: 0;
z-index: 1;
pointer-events: none;
box-shadow: inset 0 0 0 1px var(--border-window);
}
.windowBody {
flex: 1;
min-height: 0;
@@ -1,4 +1,5 @@
/** Content of the native Settings window: title bar, page tree + page, buttons. */
import clsx from "clsx";
import { useEffect, useRef } from "react";
import { OverlayHost } from "@/workbench/OverlayHost";
import { usePlatform } from "@/platform/platformInfo";
@@ -10,6 +11,7 @@ import styles from "../dialog/SettingsDialog.module.css";
export function SettingsWindow() {
useApplyTheme();
const os = usePlatform((s) => s.os);
const ownTitleBar = usePlatform((s) => s.overlayTitleBar || s.framelessTitleBar);
const open = useSettingsDialog((s) => s.open);
const title = useSettingsTitle();
@@ -20,8 +22,8 @@ export function SettingsWindow() {
}, [open]);
return (
<div ref={ref} className={styles.window}>
{/* macOS overlays traffic lights on it; Windows is frameless. Linux keeps the OS title bar. */}
<div ref={ref} className={clsx(styles.window, os === "linux" && styles.windowEdge)}>
{/* macOS overlays traffic lights on it; Windows and Linux are frameless (tauri.<os>.conf.json). */}
{ownTitleBar && <TitleBar minimal dialog title={title} />}
{open && (
<>
+4 -2
View File
@@ -1,8 +1,10 @@
/**
* Settings window ↔ its project window.
*
* Settings live in a native child window `settings-<parent>` (see
* src-tauri/src/windows.rs) that only runs the settings pages. The action
* Settings live in a separate window `settings-<parent>` (see
* src-tauri/src/windows.rs) that only runs the settings pages (a native
* child of the parent on macOS/Linux; top-level on Windows so DWM paints
* its shadow). The action
* registry stays in the project window: it sends the settings window a
* description of every action (Keymap page, links that run actions) and runs
* the actions the settings window forwards.
+5 -1
View File
@@ -5008,6 +5008,9 @@
"Window group": "窗口组",
"Select Project Tab": "选择项目标签页",
"Close Project Tab": "关闭项目标签页",
"Close Other Project Tabs": "关闭其他项目标签页",
"Close Project Tabs to the Left": "关闭左侧项目标签页",
"Close Project Tabs to the Right": "关闭右侧项目标签页",
"Reorder Project Tabs": "重新排列项目标签页",
"Close {name}": "关闭 {name}",
"Tomcat Server": "Tomcat 服务器",
@@ -5547,5 +5550,6 @@
"Passed as --settings; empty keeps DJANGO_SETTINGS_MODULE or the default in manage.py": "作为 --settings 传入;留空则沿用 DJANGO_SETTINGS_MODULE 或 manage.py 中的默认值",
"e.g. --keepdb --parallel": "例如 --keepdb --parallel",
"select files in a git repository to shelve": "请选择 Git 仓库中的文件以搁置更改",
"select files from a single git repository to shelve": "请一次只选择同一个 Git 仓库中的文件以搁置更改"
"select files from a single git repository to shelve": "请一次只选择同一个 Git 仓库中的文件以搁置更改",
"External file changes sync may be slow": "外部文件更改同步可能较慢"
}
+4
View File
@@ -9,6 +9,8 @@ interface PlatformInfo {
overlayTitleBar: boolean;
/** Frameless window: the title bar draws the main menu and caption buttons (Tauri, Windows and Linux). */
framelessTitleBar: boolean;
/** Linux on a Wayland display: GTK draws the frameless windows' shadow and resize edges. */
wayland: boolean;
}
/** OS from the user agent (before the backend answers, or in a browser). "darwin" must not read as Windows. */
@@ -28,6 +30,7 @@ export const usePlatform = create<PlatformInfo>(() => {
version: null,
overlayTitleBar: isTauri() && os === "macos",
framelessTitleBar: isTauri() && (os === "windows" || os === "linux"),
wayland: false,
};
});
@@ -40,6 +43,7 @@ export async function loadPlatformInfo() {
version: info.version,
overlayTitleBar: info.platform === "macos",
framelessTitleBar: info.platform === "windows" || info.platform === "linux",
wayland: info.wayland,
});
} catch {
/* keep the user-agent guess */
+3
View File
@@ -172,6 +172,9 @@ describe("fileIconName", () => {
["main.dart", "file-dart"],
["App.vue", "file-vue"],
["App.svelte", "file-svelte"],
["index.jsp", "file-jsp"],
["footer.jspf", "file-jsp"],
["card.tag", "file-jsp"],
["styles.scss", "file-scss"],
["styles.sass", "file-sass"],
["styles.less", "file-less"],
+2
View File
@@ -76,6 +76,7 @@ const ICONS = {
"file-npm": "openidea/fileTypes/npm",
"file-vue": "fileTypes/vue",
"file-svelte": "openidea/fileTypes/svelte",
"file-jsp": "openidea/fileTypes/jsp",
"file-yaml": "fileTypes/yaml",
"file-toml": "fileTypes/toml",
"file-html": "fileTypes/html",
@@ -822,6 +823,7 @@ const EXT_ICONS = table({
"file-tsx": "tsx",
"file-vue": "vue",
"file-svelte": "svelte",
"file-jsp": "jsp jspx jspf jsph tag tagf tagx",
"file-json": "json jsonc json5 jsonl ndjson geojson topojson webmanifest har avsc code-workspace code-snippets map",
"file-md": "md mdx markdown mdown mkd mkdn mdwn ronn workbook",
"file-rst": "rst",
+4
View File
@@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 4C14 2.89543 13.1046 2 12 2H4C2.89543 2 2 2.89543 2 4V12C2 13.1046 2.89543 14 4 14H12C13.1046 14 14 13.1046 14 12V4Z" fill="#E76F00"/>
<path d="M4.5 5.5L3 8L4.5 10.5M11.5 5.5L13 8L11.5 10.5M9 5.5L7 10.5" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 384 B

+4
View File
@@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 4C14 2.89543 13.1046 2 12 2H4C2.89543 2 2 2.89543 2 4V12C2 13.1046 2.89543 14 4 14H12C13.1046 14 14 13.1046 14 12V4Z" fill="#F0883E"/>
<path d="M4.5 5.5L3 8L4.5 10.5M11.5 5.5L13 8L11.5 10.5M9 5.5L7 10.5" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 384 B

+27
View File
@@ -2,6 +2,7 @@ import { beforeEach, expect, it, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { executeAction } from "@/platform/actions";
import { useWindowGroup } from "@/platform/windowGroups";
import { useContextMenuStore } from "@/ui/Menu";
import { WindowGroupTabs } from "./WindowGroupTabs";
vi.mock("@/platform/actions", async (importOriginal) => ({
@@ -37,3 +38,29 @@ it("removes the tab row when the group dissolves", () => {
render(<WindowGroupTabs />);
expect(screen.queryByRole("tablist")).not.toBeInTheDocument();
});
it("offers closing the other tabs and those on either side of the clicked tab", () => {
useWindowGroup.setState({
tabs: [
{ label: "main", name: "Frontend", root: "/front", current: true },
{ label: "project-1", name: "Backend", root: "/back", current: false },
{ label: "project-2", name: "Docs", root: "/docs", current: false },
],
});
render(<WindowGroupTabs />);
const menuFor = (name: string) => {
fireEvent.contextMenu(screen.getByRole("tab", { name }));
const items = useContextMenuStore.getState().menu!.items;
return (label: string) =>
items.find((i) => "label" in i && i.label === label) as { disabled?: boolean; run?: () => unknown };
};
const first = menuFor("Frontend");
expect(first("Close Tabs to the Left").disabled).toBe(true);
expect(first("Close Tabs to the Right").disabled).toBe(false);
const middle = menuFor("Backend");
void middle("Close Other Tabs").run?.();
expect(executeAction).toHaveBeenLastCalledWith("window.closeOtherTabs", "project-1");
void middle("Close Tabs to the Left").run?.();
expect(executeAction).toHaveBeenLastCalledWith("window.closeTabsToLeft", "project-1");
expect(menuFor("Docs")("Close Tabs to the Right").disabled).toBe(true);
});
+13
View File
@@ -61,13 +61,26 @@ export function WindowGroupTabs() {
}}
onContextMenu={(e) => {
e.preventDefault();
const index = tabs.indexOf(tab);
showContextMenu(e, [
{
label: "Move Tab to New Window",
disabled: !tab.current,
run: () => executeAction("window.detachTab"),
},
{ kind: "separator" },
{ label: "Close Tab", run: () => executeAction("window.closeTab", tab.label) },
{ label: "Close Other Tabs", run: () => executeAction("window.closeOtherTabs", tab.label) },
{
label: "Close Tabs to the Left",
disabled: index <= 0,
run: () => executeAction("window.closeTabsToLeft", tab.label),
},
{
label: "Close Tabs to the Right",
disabled: index >= tabs.length - 1,
run: () => executeAction("window.closeTabsToRight", tab.label),
},
]);
}}
>
+7 -5
View File
@@ -1,11 +1,12 @@
/**
* Frameless-window resize handles: Windows gives undecorated windows no
* resize borders, so the title bar's frameless look leaves the window
* stuck at its launch size. Eight invisible hit areas along the edges
* Frameless-window resize handles: Windows and Linux on X11 give undecorated
* windows no resize borders, so the title bar's frameless look leaves the
* window stuck at its launch size. Eight invisible hit areas along the edges
* start a native resize drag, like any other desktop app.
*
* macOS renders its own overlay title bar but still provides resize
* handles, so nothing is added there.
* handles, and on Wayland GTK draws resize edges in the client-side shadow
* (`decorate_csd` in windows.rs), so nothing is added there.
*/
import { useEffect, useState } from "react";
import { usePlatform } from "@/platform/platformInfo";
@@ -33,7 +34,8 @@ async function start(dir: Dir) {
export function WindowResizeHandles() {
// A frameless window needs them; a decorated one already has native borders.
const frameless = usePlatform((s) => s.framelessTitleBar);
const enabled = isTauri() && frameless;
const gtkEdges = usePlatform((s) => s.os === "linux" && s.wayland);
const enabled = isTauri() && frameless && !gtkEdges;
// A maximized or full-screen window has nothing to resize; the edges belong to the content.
const [sized, setSized] = useState(false);
useEffect(() => {