From 357b728f1ddd5d2efac480caa2c964bb9e5a658b Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Wed, 28 Oct 2026 09:07:50 +0800 Subject: [PATCH] Editor: quick fixes, completion, navigation while typing, and split-pane drag (#109) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(editor): Drag tabs and files between split panes HTML5 drag and drop never reaches the webview while native file drop is enabled, so tab, project-file and changelist drags did nothing. They now run on pointer events: - Drag an editor tab onto another pane, or onto a pane's edge to split it. - Drag files from the Project view into an editor pane (center opens them there, an edge opens them in a new split). - Drag files between changelists in the Commit view. - "Drag-and-drop with Alt pressed only" applies to tabs and stripe buttons. * fix(lsp): Deliver server settings and start servers on a supported JDK - workspace/configuration answers a server's own section (basedpyright) from its settings block; basedpyright runs in "standard" mode and keeps auto search paths on, so `src/` layouts resolve from `tests/`. - jdtls: formatting follows the editor indent, calls complete without guessed arguments, and JDK internals stay out of completion. - `max-java` in a language server config picks an installed JDK no newer than that (kotlin-language-server fails on JDK 25). - Server initialization may take up to 5 minutes (first Gradle import). - `lsp_resend_diagnostics` re-emits stored diagnostics after a reload. - Spelling only checks declared names (not called methods like `substring`) and accepts plurals of known software terms. * feat(editor): Quick fixes, completion and navigation while typing Quick fixes - Errors get a red quick-fix bulb; Apply Quick Fix (⌥⇧↵) fixes the most severe problem at the caret, and the error hover offers the fix directly. - Code generated by a code action is re-indented to the file's style. - Python: Implement abstract methods (⌃I or the bulb) from the bases. - Duplicate code actions are listed once. Completion - Overloads sharing a name, and a name importable from several modules, are no longer merged into one entry. - Auto-import entries show the module they import from. - Call snippets without a stop (`new ArrayList<>()`) leave the caret inside the parentheses with parameter info, or past them when nothing takes arguments; parameter info also opens after server call snippets. - The selected item is resolved ahead of acceptance, so its auto-import is applied (rust-analyzer). Navigation and diagnostics - Language server requests wait for pending edits, so Go to Declaration right after typing answers for the current text; an empty answer right after an edit is retried while the server catches up. - Diagnostics come back after a window reload; error underlines stay visible under typo / unused highlighting. - Implementation gutter markers no longer refresh in a loop while the server reports progress. - A missing alternative server no longer shows as a server error. * chore(lint): Skip agent worktrees under .claude * fix(editor): Skip cancelled LSP requests and limit generated-code reformat - Requests cancelled while waiting for pending edits are not sent. - Code generated by an action is reformatted per changed hunk, so an added import doesn't reformat the code between it and the generated lines. - A long busy spell caused by a settle refresh re-runs it only once. - Files dropped on a file row of another changelist move to that changelist. - Python Implement methods: a nested class without bases ends the lookup, class bodies continue through unindented string lines, and `@abstractproperty` stubs become `@property`. - Resend diagnostics goes through the host ops, and custom servers get `max_java`. * fix(editor): Rank the exact type first in Java constructor completion jdtls appends the call or body to filterText (`String(${0})`); matching on the label instead lets typing `String` rank `String` above `StringBuilder` and the other types sharing its sort text. * fix(editor): List identical completion items once kotlin-language-server repeats a class once per classpath entry declaring it, so `String()` and `String` were listed three times each. * fix(editor): Keep the internal cancellation message out of the UI text catalog --- crates/ide-host/src/ops/lsp.rs | 6 + crates/ide-host/src/project.rs | 1 + crates/ide-language/src/lib.rs | 4 + crates/ide-language/src/syntax/literals.rs | 16 +- crates/ide-language/src/syntax/tests.rs | 9 + crates/ide-lsp/src/handler.rs | 6 +- crates/ide-lsp/src/lib.rs | 26 ++- crates/ide-lsp/src/server.rs | 5 + crates/ide-lsp/tests/mock_server.rs | 10 + crates/ide-lsp/tests/real_servers.rs | 3 + crates/ide-sdk/src/apply.rs | 1 + crates/ide-sdk/src/java.rs | 13 ++ crates/ide-sdk/src/tests.rs | 27 +++ crates/ide-spell/src/dict/software.txt | 12 + crates/ide-spell/src/lib.rs | 20 +- eslint.config.js | 16 +- languages/java.toml | 5 + languages/kotlin.toml | 2 + languages/python.toml | 8 + src-tauri/src/commands/lsp.rs | 7 + src-tauri/src/commands/mod.rs | 1 + src/bindings.ts | 2 + src/editor/api.ts | 6 + src/editor/core/controller.ts | 19 +- src/editor/core/model/session.test.ts | 14 +- src/editor/core/model/tabs.ts | 20 +- src/editor/core/view/EditorArea.tsx | 60 ++--- src/editor/lsp/Popups.module.css | 4 - src/editor/lsp/Popups.tsx | 2 +- src/editor/lsp/actions.ts | 13 +- src/editor/lsp/availability.test.ts | 59 +++++ src/editor/lsp/availability.ts | 37 ++++ src/editor/lsp/completion.test.ts | 73 +++++- src/editor/lsp/completion.ts | 97 ++++++-- src/editor/lsp/completionItems.ts | 58 ++++- src/editor/lsp/diagnostics.ts | 24 +- src/editor/lsp/format.test.ts | 10 +- src/editor/lsp/format.ts | 49 +++- src/editor/lsp/hover.ts | 31 ++- src/editor/lsp/inlineCompletion.ts | 5 +- src/editor/lsp/intentions.test.ts | 71 +++++- src/editor/lsp/intentions.ts | 187 ++++++++++++++-- src/editor/lsp/lsp.test.ts | 29 +++ src/editor/lsp/lsp.ts | 9 +- src/editor/lsp/navigation.ts | 21 +- src/editor/lsp/pythonMembers.test.ts | 72 ++++++ src/editor/lsp/pythonMembers.ts | 209 ++++++++++++++++++ src/editor/lsp/signature.ts | 19 ++ src/editor/lsp/syntaxFallback.test.ts | 1 + src/editor/lsp/theme.ts | 8 +- src/editor/navigation/implementationGutter.ts | 6 +- src/features/code-insight/ServerStatus.tsx | 7 +- src/features/code-insight/servers.test.ts | 17 +- src/features/code-insight/servers.ts | 15 +- src/features/project-tree/ProjectView.tsx | 13 ++ src/features/settings/appearanceRuntime.ts | 12 +- src/features/vcs/CommitView.tsx | 63 +++--- src/platform/ipc.ts | 6 + src/platform/keymaps/intellij-macos.ts | 1 + src/platform/keymaps/intellij-windows.ts | 1 + src/platform/keymaps/vscode-macos.ts | 1 + src/platform/locales/text/zh.json | 4 + src/ui/Tabs.tsx | 79 ++++--- src/ui/expui/codeInsight/quickfixBulb.svg | 6 + .../expui/codeInsight/quickfixBulb_dark.svg | 6 + src/ui/icons.tsx | 1 + src/ui/index.ts | 1 + src/ui/pointerDrag.module.css | 19 ++ src/ui/pointerDrag.ts | 156 +++++++++++++ src/workbench/contributions.ts | 2 + src/workbench/toolwindows/ToolWindows.tsx | 4 +- 71 files changed, 1630 insertions(+), 197 deletions(-) create mode 100644 src/editor/lsp/availability.test.ts create mode 100644 src/editor/lsp/lsp.test.ts create mode 100644 src/editor/lsp/pythonMembers.test.ts create mode 100644 src/editor/lsp/pythonMembers.ts create mode 100644 src/ui/expui/codeInsight/quickfixBulb.svg create mode 100644 src/ui/expui/codeInsight/quickfixBulb_dark.svg create mode 100644 src/ui/pointerDrag.module.css create mode 100644 src/ui/pointerDrag.ts diff --git a/crates/ide-host/src/ops/lsp.rs b/crates/ide-host/src/ops/lsp.rs index 6be7e202..1d398e82 100644 --- a/crates/ide-host/src/ops/lsp.rs +++ b/crates/ide-host/src/ops/lsp.rs @@ -241,6 +241,11 @@ pub fn lsp_servers(host: &Host, window: &str) -> IdeResult> { Ok(host.project(window)?.lsp.servers()) } +pub fn lsp_resend_diagnostics(host: &Host, window: &str) -> IdeResult<()> { + host.project(window)?.lsp.resend_diagnostics(); + Ok(()) +} + pub async fn lsp_restart(host: &Host, window: &str, server: ServerId) -> IdeResult<()> { host.project(window)?.lsp.restart(server).await } @@ -349,6 +354,7 @@ pub(crate) fn register(d: &mut Dispatcher) { lsp_type_hierarchy(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?, c.arg("direction")?).await }); d.add("lsp_servers", |c| async move { lsp_servers(&c.host, &c.window) }); + d.add("lsp_resend_diagnostics", |c| async move { lsp_resend_diagnostics(&c.host, &c.window) }); d.add("lsp_restart", |c| async move { lsp_restart(&c.host, &c.window, c.arg("server")?).await }); d.add("apply_workspace_edit", |c| async move { apply_workspace_edit(&c.host, &c.window, c.arg("edit")?) }); d.add("lsp_execute_command", |c| async move { diff --git a/crates/ide-host/src/project.rs b/crates/ide-host/src/project.rs index ee92e219..53a36bce 100644 --- a/crates/ide-host/src/project.rs +++ b/crates/ide-host/src/project.rs @@ -119,6 +119,7 @@ impl Host { let effective = settings.effective(); ide_sdk::apply::server_launch_overrides(cfg, &effective); ide_sdk::apply::configure_server(cfg, &root, server_root, &effective); + ide_sdk::java::cap_java_home(cfg, &ide_sdk::java::installed_jdks()); if cfg.name == "jdtls" { let bundles: Vec = ide_tools::managed_bundle(&data_dir, "java-debug").into_iter().collect(); diff --git a/crates/ide-language/src/lib.rs b/crates/ide-language/src/lib.rs index 0a016a6d..ac0a3cf8 100644 --- a/crates/ide-language/src/lib.rs +++ b/crates/ide-language/src/lib.rs @@ -91,6 +91,10 @@ pub struct LanguageServerConfig { /// basedpyright): only used while none of those is installed. #[serde(default)] pub fallback: bool, + /// Newest Java release the server runs on (kotlin-language-server: 21). A + /// newer `JAVA_HOME` is replaced by the newest installed JDK that fits. + #[serde(default)] + pub max_java: Option, /// Set by the settings hook when the server can't run here (jdtls without /// a JDK 21+); reported as the failure instead of launching it. #[serde(skip)] diff --git a/crates/ide-language/src/syntax/literals.rs b/crates/ide-language/src/syntax/literals.rs index 5914a9c1..29d0d2ec 100644 --- a/crates/ide-language/src/syntax/literals.rs +++ b/crates/ide-language/src/syntax/literals.rs @@ -79,20 +79,24 @@ fn is_name_leaf(kind: &str) -> bool { kind.contains("identifier") || kind == "name" } +/// Node kinds whose `name` field is a use, not a declaration. +const USE_PARENTS: &[&str] = + &["invocation", "call", "access", "scoped", "reference", "expression", "selector", "qualified", "use_", "import"]; + /// Whether the identifier leaf under `cursor` is the name a declaration /// introduces (`fn name`, `let pattern`, `int *declarator`, parameters, /// `name = …` assignments) rather than a use of one. fn declares(cursor: &TreeCursor<'_>, node: &Node<'_>) -> bool { let parent = node.parent().map(|p| p.kind()).unwrap_or_default(); - // Paths and calls name what is declared elsewhere (`use a::b::name`, `a.name()`). - let reference = ["scoped", "qualified", "invocation", "member_access", "use_", "import", "call"] - .iter() - .any(|k| parent.contains(k)); match cursor.field_name() { - Some("name") => !reference, + // Uses name their target with a `name` field too: Java `s.substring()`, + // C# `a.Name`, Rust `use a::b::name`. + Some("name") => !USE_PARENTS.iter().any(|p| parent.contains(p)), Some("pattern" | "declarator") => true, Some("left") => parent.contains("assignment") || parent.contains("declarator"), - _ => parent.contains("parameter"), + // A parameter's name, not its type (`String text`). + Some("type") => false, + _ => parent.contains("parameter") && !node.kind().contains("type"), } } diff --git a/crates/ide-language/src/syntax/tests.rs b/crates/ide-language/src/syntax/tests.rs index fb344abf..02c0ff6d 100644 --- a/crates/ide-language/src/syntax/tests.rs +++ b/crates/ide-language/src/syntax/tests.rs @@ -1320,4 +1320,13 @@ fn spelling_regions_cover_declared_names_only() { .map(|r| &rs[r.start..r.end]) .collect(); assert_eq!(names, ["run", "n"]); + let java = "class Bird { String fly(String text) { return text.substring(1); } }\n"; + let names: Vec<&str> = syntax() + .spelling_regions(&lang("java"), java) + .unwrap() + .iter() + .filter(|r| r.kind == SpellingKind::Name) + .map(|r| &java[r.start..r.end]) + .collect(); + assert_eq!(names, ["Bird", "fly", "text"]); } diff --git a/crates/ide-lsp/src/handler.rs b/crates/ide-lsp/src/handler.rs index 35be7f5b..0a235a15 100644 --- a/crates/ide-lsp/src/handler.rs +++ b/crates/ide-lsp/src/handler.rs @@ -132,7 +132,11 @@ impl ClientHandler { .iter() // vtsls asks for the whole configuration as section `""`. .map(|item| match item.get("section").and_then(Value::as_str).filter(|s| !s.is_empty()) { - Some(section) if section == self.server.name => options.clone(), + // The server's own section: its block under `settings` (basedpyright asks + // `basedpyright` and `basedpyright.analysis`), else the options themselves. + Some(section) if section == self.server.name => { + options.get("settings").and_then(|s| s.get(section)).unwrap_or(&options).clone() + } // Servers that take settings under `initializationOptions.settings` // (jdtls) ask for the same sections later. Some(section) => [&options, options.get("settings").unwrap_or(&Value::Null)] diff --git a/crates/ide-lsp/src/lib.rs b/crates/ide-lsp/src/lib.rs index f003d36d..d313d417 100644 --- a/crates/ide-lsp/src/lib.rs +++ b/crates/ide-lsp/src/lib.rs @@ -56,6 +56,12 @@ use types::*; const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); const COMPLETION_TIMEOUT: Duration = Duration::from_secs(5); const LONG_TIMEOUT: Duration = Duration::from_secs(30); + +/// How long a server may take to answer `initialize`. Some import the whole +/// build there (kotlin-lsp, jdtls on a large Gradle project) and need minutes +/// on a cold start; the status shows "starting" meanwhile, and a crash still +/// ends the wait at once. +const INITIALIZE_TIMEOUT: Duration = Duration::from_secs(300); /// How long a request waits for a server that is still initializing. const START_WAIT: Duration = Duration::from_secs(10); /// File moves may start a server (jdtls takes a while to import). @@ -421,7 +427,7 @@ impl LspManager { }; let init = self.initialize_params(&server); - let result = tokio::time::timeout(Duration::from_secs(60), peer.request::<_, Value>("initialize", init, None)) + let result = tokio::time::timeout(INITIALIZE_TIMEOUT, peer.request::<_, Value>("initialize", init, None)) .await .unwrap_or(Err(RpcError::Timeout)); let caps = match result { @@ -1953,6 +1959,24 @@ impl LspManager { *self.apply_edit_hook.write() = Some(Arc::from(hook)); } + /// Emits every server's current diagnostics again: a UI that (re)loaded + /// after they were published has missed those events. + pub fn resend_diagnostics(&self) { + let servers: Vec> = self.servers.lock().clone(); + for server in servers { + let encoding = server.running().map(|r| r.caps.read().encoding).unwrap_or_default(); + let conv = self.conv(encoding); + for (path, items) in server.diagnostics_by_path() { + let items = items.into_iter().map(|d| convert::diagnostic(&conv, &path, d, &server.name)).collect(); + self.sink.emit(ide_core::IdeEvent::Diagnostics { + path: path.into(), + source: server.name.clone(), + items, + }); + } + } + } + pub fn servers(&self) -> Vec { self.servers .lock() diff --git a/crates/ide-lsp/src/server.rs b/crates/ide-lsp/src/server.rs index 04cbb9f8..43d522f7 100644 --- a/crates/ide-lsp/src/server.rs +++ b/crates/ide-lsp/src/server.rs @@ -461,6 +461,11 @@ impl Server { d.iter().flat_map(|(p, items)| items.iter().map(move |i| (p.clone(), i.clone()))).collect() } + /// The current diagnostics of each file that has some. + pub fn diagnostics_by_path(&self) -> Vec<(PathBuf, Vec)> { + self.diagnostics.lock().iter().map(|(p, d)| (p.clone(), d.clone())).collect() + } + pub fn take_diagnostic_paths(&self) -> Vec { self.published.lock().clear(); self.diagnostics.lock().drain().map(|(p, _)| p).collect() diff --git a/crates/ide-lsp/tests/mock_server.rs b/crates/ide-lsp/tests/mock_server.rs index 9ca8e202..4f0da19c 100644 --- a/crates/ide-lsp/tests/mock_server.rs +++ b/crates/ide-lsp/tests/mock_server.rs @@ -207,6 +207,7 @@ fn server_cfg(name: &str) -> LanguageServerConfig { install_hint: Some(format!("install {name}")), connect: None, fallback: false, + max_java: None, launch_error: None, } } @@ -522,6 +523,15 @@ async fn diagnostics_become_events_and_feed_code_actions() { assert_eq!(items[0].range, rng(0, 4, 0, 10)); } + // A reloaded UI asks for them again. + h.events.lock().clear(); + h.mgr.resend_diagnostics(); + let again = h.events.lock().iter().find_map(|e| match e { + IdeEvent::Diagnostics { path, items, .. } => Some((path.clone(), items.len())), + _ => None, + }); + assert_eq!(again, Some((path.clone().into(), 2))); + s.respond( "textDocument/codeAction", json!([ diff --git a/crates/ide-lsp/tests/real_servers.rs b/crates/ide-lsp/tests/real_servers.rs index 121bb027..b0ea1701 100644 --- a/crates/ide-lsp/tests/real_servers.rs +++ b/crates/ide-lsp/tests/real_servers.rs @@ -74,6 +74,7 @@ async fn missing_binary_is_reported_with_install_hint() { install_hint: Some("npm i -g nope-ls".into()), connect: None, fallback: false, + max_java: None, launch_error: None, }; let (mgr, events) = manager(dir.path(), LanguageRegistry::from_configs(vec![lang("nope", "nope", server)])); @@ -203,6 +204,7 @@ async fn typescript_language_server_end_to_end() { install_hint: None, connect: None, fallback: false, + max_java: None, launch_error: None, }; let (mgr, events) = manager(&root, LanguageRegistry::from_configs(vec![lang("typescript", "ts", server)])); @@ -246,6 +248,7 @@ fn npm_server(tools: &Path, name: &str, bin: &str, init: Option LanguageServerConfi }), connect: None, fallback: false, + max_java: None, launch_error: None, } } diff --git a/crates/ide-sdk/src/java.rs b/crates/ide-sdk/src/java.rs index 163427cb..ae228dfc 100644 --- a/crates/ide-sdk/src/java.rs +++ b/crates/ide-sdk/src/java.rs @@ -161,6 +161,19 @@ pub fn jdtls_jdks(min_java: u32) -> Arc<[Jdk]> { } } +/// For a server with `max-java`: when the `JAVA_HOME` it would get (its env, +/// else the IDE's) is a newer JDK, points it at the newest of `jdks` that fits. +pub fn cap_java_home(cfg: &mut LanguageServerConfig, jdks: &[Jdk]) { + let Some(max) = cfg.max_java else { return }; + let current = cfg.env.get("JAVA_HOME").cloned().or_else(|| std::env::var("JAVA_HOME").ok()); + if current.as_deref().and_then(|h| jdk_at(Path::new(h))).is_some_and(|j| j.major <= max) { + return; + } + if let Some(jdk) = jdks.iter().filter(|j| j.major <= max).max_by_key(|j| j.major) { + cfg.env.insert("JAVA_HOME".into(), jdk.home.to_string_lossy().into_owned()); + } +} + /// Oldest Java version jdtls runs on when its install can't tell (its launcher's check). pub const JDTLS_MIN_JAVA: u32 = 21; diff --git a/crates/ide-sdk/src/tests.rs b/crates/ide-sdk/src/tests.rs index fb669960..ff7e157e 100644 --- a/crates/ide-sdk/src/tests.rs +++ b/crates/ide-sdk/src/tests.rs @@ -32,6 +32,7 @@ fn server(name: &str) -> LanguageServerConfig { install_hint: None, connect: None, fallback: false, + max_java: None, launch_error: None, } } @@ -598,3 +599,29 @@ fn vue_language_server_gets_a_typescript_with_the_language_service_api() { let lib = project.join("node_modules/typescript/lib"); assert_eq!(cfg.args, [format!("--tsdk={}", lib.to_string_lossy())]); } + +#[test] +fn max_java_picks_an_older_jdk() { + let dir = tempfile::tempdir().unwrap(); + let jdk = |major: u32| { + let home = dir.path().join(format!("jdk-{major}")); + std::fs::create_dir_all(home.join("bin")).unwrap(); + std::fs::write(home.join("release"), format!("JAVA_VERSION=\"{major}.0.1\"\n")).unwrap(); + std::fs::write(home.join("bin").join(exe("java")), "").unwrap(); + crate::java::jdk_at(&home).unwrap() + }; + let (j17, j21, j25) = (jdk(17), jdk(21), jdk(25)); + let jdks = [j25.clone(), j21.clone(), j17]; + let mut cfg = LanguageServerConfig { max_java: Some(21), ..server("kotlin-language-server") }; + cfg.env.insert("JAVA_HOME".into(), j25.home.to_string_lossy().into_owned()); + crate::java::cap_java_home(&mut cfg, &jdks); + assert_eq!(cfg.env["JAVA_HOME"], j21.home.to_string_lossy()); + + // A JAVA_HOME that fits, or no limit: left alone. + crate::java::cap_java_home(&mut cfg, &[]); + assert_eq!(cfg.env["JAVA_HOME"], j21.home.to_string_lossy()); + let mut free = server("other"); + free.env.insert("JAVA_HOME".into(), j25.home.to_string_lossy().into_owned()); + crate::java::cap_java_home(&mut free, &jdks); + assert_eq!(free.env["JAVA_HOME"], j25.home.to_string_lossy()); +} diff --git a/crates/ide-spell/src/dict/software.txt b/crates/ide-spell/src/dict/software.txt index 478f3ddb..2ab77be6 100644 --- a/crates/ide-spell/src/dict/software.txt +++ b/crates/ide-spell/src/dict/software.txt @@ -551,3 +551,15 @@ vulkan wgpu woff woff2 +breakpoint +sku +dropdown +navbar +superclass +typedef +eslint +unittest +jvm +jdk +ux +endpoint diff --git a/crates/ide-spell/src/lib.rs b/crates/ide-spell/src/lib.rs index 0ac12038..67d54fa1 100644 --- a/crates/ide-spell/src/lib.rs +++ b/crates/ide-spell/src/lib.rs @@ -162,6 +162,14 @@ fn software_terms() -> &'static HashSet<&'static str> { }) } +/// A software term, or its plural (`endpoints`, `regexes`). +fn software_term(lower: &str) -> bool { + let terms = software_terms(); + terms.contains(lower) + || lower.strip_suffix('s').is_some_and(|w| terms.contains(w)) + || lower.strip_suffix("es").is_some_and(|w| terms.contains(w)) +} + /// Words of a custom dictionary file: a plain word list or a Hunspell `.dic` /// (leading count line and `/FLAGS` ignored). Cached by modification time. fn dictionary_file(path: &Path) -> Arc> { @@ -230,9 +238,7 @@ impl Speller { } fn user_word(&self, lower: &str) -> bool { - self.accepted.contains(lower) - || software_terms().contains(lower) - || self.custom.iter().any(|d| d.contains(lower)) + self.accepted.contains(lower) || software_term(lower) || self.custom.iter().any(|d| d.contains(lower)) } /// In a dictionary, in any case style. @@ -439,6 +445,14 @@ mod tests { assert!(!s.is_correct("lenght")); } + #[test] + fn software_terms_and_their_plurals() { + let s = Speller::default(); + for ok in ["breakpoint", "breakpoints", "endpoints", "datasets", "regexes"] { + assert!(s.is_correct(ok), "{ok}"); + } + } + #[test] fn agent_nouns_of_known_words() { let s = Speller::default(); diff --git a/eslint.config.js b/eslint.config.js index 0c59b7c3..1e2febed 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -3,7 +3,21 @@ import tseslint from "typescript-eslint"; import reactHooks from "eslint-plugin-react-hooks"; export default tseslint.config( - { ignores: ["dist", "target", "src-tauri", "crates", "src/bindings.ts", "fixtures", "plugins", "test-results", "playwright-report"] }, + { + ignores: [ + "dist", + "target", + "src-tauri", + "crates", + "src/bindings.ts", + "fixtures", + "plugins", + "test-results", + "playwright-report", + // Agent worktrees: whole checkouts of the repo. + ".claude", + ], + }, js.configs.recommended, ...tseslint.configs.recommended, { diff --git a/languages/java.toml b/languages/java.toml index 6dd7660f..e8ec0caa 100644 --- a/languages/java.toml +++ b/languages/java.toml @@ -38,6 +38,11 @@ eclipse = { downloadSources = true } references = { includeDecompiledSources = true } signatureHelp = { enabled = true } configuration = { updateBuildConfiguration = "automatic" } +# Generated code (implement methods, getters, toString…) indents like the editor (`indent` above). +format = { insertSpaces = true, tabSize = 4 } +# Calls complete as `name()` with the caret inside and parameter info, not guessed arguments; +# JDK internals (`sun.*`, `com.sun.*`, `jdk.*`) and AWT's `List` stay out of completion and auto-import. +completion = { guessMethodArguments = "off", filteredTypes = ["java.awt.List", "com.sun.*", "sun.*", "jdk.*", "org.graalvm.*", "io.micrometer.shaded.*"] } [language-servers.initialization-options.settings.java.import] # Keep Eclipse metadata (.project, .classpath, .settings) out of the project. diff --git a/languages/kotlin.toml b/languages/kotlin.toml index 74651b31..212bb950 100644 --- a/languages/kotlin.toml +++ b/languages/kotlin.toml @@ -19,6 +19,8 @@ name = "kotlin-language-server" # Alternative: used only when the server above is not installed. fallback = true command = "kotlin-language-server" +# Its bundled Kotlin compiler can't parse newer JDK versions ("25.0.4.1"). +max-java = 21 root-markers = ["settings.gradle.kts", "settings.gradle", "build.gradle.kts", "build.gradle", "pom.xml"] install-hint = "brew install kotlin-language-server (https://github.com/fwcd/kotlin-language-server)" diff --git a/languages/python.toml b/languages/python.toml index 4f6a9a62..3dd270bc 100644 --- a/languages/python.toml +++ b/languages/python.toml @@ -15,6 +15,14 @@ args = ["--stdio"] root-markers = ["pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "Pipfile", "pyrightconfig.json"] install-hint = "pip install basedpyright (or: uv tool install basedpyright)" +# basedpyright defaults to "recommended", which flags unannotated attributes and +# every unknown type. "standard" reports real problems; a project's own +# `[tool.basedpyright]` / pyrightconfig.json still takes precedence. +[language-servers.initialization-options.settings.basedpyright.analysis] +typeCheckingMode = "standard" +# Off whenever analysis settings are sent without it: `src/` layouts would stop resolving. +autoSearchPaths = true + [[language-servers]] name = "pyright" # Alternative: used only when the server above is not installed. diff --git a/src-tauri/src/commands/lsp.rs b/src-tauri/src/commands/lsp.rs index 44e8a4a7..40294164 100644 --- a/src-tauri/src/commands/lsp.rs +++ b/src-tauri/src/commands/lsp.rs @@ -309,6 +309,13 @@ pub fn lsp_servers(window: Window, state: State<'_, AppState>) -> IdeResult) -> IdeResult<()> { + ide_host::ops::lsp::lsp_resend_diagnostics(&state.host, window.label()) +} + #[tauri::command] #[specta::specta] pub async fn lsp_restart(window: Window, state: State<'_, AppState>, server: ServerId) -> IdeResult<()> { diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 7048be4a..e60f2d4a 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -174,6 +174,7 @@ pub fn builder() -> tauri_specta::Builder { lsp::lsp_call_hierarchy, lsp::lsp_type_hierarchy, lsp::lsp_servers, + lsp::lsp_resend_diagnostics, lsp::lsp_restart, lsp::apply_workspace_edit, lsp::lsp_execute_command, diff --git a/src/bindings.ts b/src/bindings.ts index f8e6e914..ab7bb6fb 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -336,6 +336,8 @@ export const commands = { lspCallHierarchy: (doc: DocId, pos: Pos, direction: HierarchyDirection) => typedError(__TAURI_INVOKE("lsp_call_hierarchy", { doc, pos, direction })), lspTypeHierarchy: (doc: DocId, pos: Pos, direction: HierarchyDirection) => typedError(__TAURI_INVOKE("lsp_type_hierarchy", { doc, pos, direction })), lspServers: () => typedError(__TAURI_INVOKE("lsp_servers")), + /** Re-emits the language servers' current diagnostics (the UI loaded after they arrived). */ + lspResendDiagnostics: () => typedError(__TAURI_INVOKE("lsp_resend_diagnostics")), lspRestart: (server: ServerId) => typedError(__TAURI_INVOKE("lsp_restart", { server })), /** Applies a (previewed) workspace edit, e.g. after rename confirmation. */ applyWorkspaceEdit: (edit: WorkspaceEdit) => typedError(__TAURI_INVOKE("apply_workspace_edit", { edit })), diff --git a/src/editor/api.ts b/src/editor/api.ts index 78892027..83bd1192 100644 --- a/src/editor/api.ts +++ b/src/editor/api.ts @@ -192,6 +192,12 @@ export function whenSynced(view: EditorView): Promise { return view.state.facet(documentSynced)?.() ?? Promise.resolve(); } +/** Every open editor's edits reached the backend (language servers answer from its copy). */ +export function whenAllSynced(): Promise { + const editors = useEditorRegistry.getState().editors; + return editors.size ? Promise.all([...editors].map((e) => whenSynced(e.view))).then(() => {}) : Promise.resolve(); +} + /** True for doc changes that did not originate from typing in *this* view. */ export function isExternalChange(tr: Transaction): boolean { return !!tr.annotation(remoteChange) || !!tr.annotation(mirroredChange); diff --git a/src/editor/core/controller.ts b/src/editor/core/controller.ts index 50d35a6d..363cb733 100644 --- a/src/editor/core/controller.ts +++ b/src/editor/core/controller.ts @@ -52,6 +52,7 @@ import { removePane, reopenClosed, setActivePane, + splitAtEdge, sortTabs, splitPane, tabOptions, @@ -784,7 +785,7 @@ export class EditorController implements SlotHost { this.navSuppress++; try { let s = useTabs.getState(); - let paneId = s.activePane; + let paneId = o.pane && s.panes[o.pane] ? o.pane : s.activePane; if (o.side) { const right = neighbourPane(s, paneId); if (right) paneId = right; @@ -799,7 +800,7 @@ export class EditorController implements SlotHost { } const found = findTab(s, input.key, paneId); let tabId: string; - if (found && (!o.side || found.paneId === paneId)) { + if (found && (!(o.side || o.pane) || found.paneId === paneId)) { paneId = found.paneId; s = activateTab(setActivePane(s, paneId), paneId, found.tab.id); if (!o.preview) s = promoteTab(s, paneId, found.tab.id); @@ -935,6 +936,20 @@ export class EditorController implements SlotHost { if (slot?.view) slot.focus(); else if (slot) slot.wantsFocus = true; } + /** Files dropped on an editor: opened in that pane, or in a new split at `edge`. */ + async openFilesIn(paths: readonly string[], paneId: string, edge: SplitEdge | "center") { + const s = useTabs.getState(); + if (!s.panes[paneId] || !paths.length) return; + let target = paneId; + if (edge !== "center") { + const r = splitAtEdge(s, paneId, edge); + useTabs.setState(r.state, true); + target = r.paneId; + } + for (const [i, path] of paths.entries()) await this.openFile(path, { pane: target, focus: i === paths.length - 1 }); + const pane = useTabs.getState().panes[target]; + if (pane && !pane.tabs.length) this.apply((st) => removePane(st, target)); + } /** IDEA "Move to Opposite Group"; without a split, "Split and Move Right". */ moveToOpposite(paneId: string, tabId: string) { const target = neighbourPane(useTabs.getState(), paneId); diff --git a/src/editor/core/model/session.test.ts b/src/editor/core/model/session.test.ts index 27aab7a4..11697d12 100644 --- a/src/editor/core/model/session.test.ts +++ b/src/editor/core/model/session.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { initialTabsState, moveTabToNewSplit, openTab, paneIds, splitPane, togglePin, type TabsState } from "./tabs"; +import { initialTabsState, moveTabToNewSplit, openTab, paneIds, splitAtEdge, splitPane, togglePin, type TabsState } from "./tabs"; import { loadSession, parseSession, restoreTabsState, serializeSession, slashPath } from "./session"; const file = (path: string) => ({ kind: "file", key: `file:${path}`, path }); @@ -145,3 +145,15 @@ describe("sessions saved with Windows backslash paths", () => { } }); }); + +describe("splitAtEdge", () => { + it("adds an empty active pane on the given side", () => { + const s = open(initialTabsState(), "/a"); + const pane = s.activePane; + const r = splitAtEdge(s, pane, "right"); + expect(paneIds(r.state)).toEqual([pane, r.paneId]); + expect(r.state.panes[r.paneId]!.tabs).toEqual([]); + expect(r.state.activePane).toBe(r.paneId); + expect(paths(r.state, pane)).toEqual(["/a"]); + }); +}); diff --git a/src/editor/core/model/tabs.ts b/src/editor/core/model/tabs.ts index 5e6ef31f..7a8a52c2 100644 --- a/src/editor/core/model/tabs.ts +++ b/src/editor/core/model/tabs.ts @@ -425,21 +425,29 @@ export function moveTabToNewSplit( const tab = src?.tabs.find((t) => t.id === tabId); if (!src || !tab || !s.panes[targetPane]) return null; if (fromPane === targetPane && src.tabs.length === 1) return null; - let next = { ...removeTabs(s, fromPane, new Set([tabId])), closed: s.closed }; + const next = { ...removeTabs(s, fromPane, new Set([tabId])), closed: s.closed }; // The source pane may have collapsed away; split the target if it survived, else the active pane. const anchor = next.panes[targetPane] ? targetPane : next.activePane; - const id = nextId("pane"); const moved: Tab = { ...tab, preview: false }; - const pane: Pane = { id, tabs: [moved], active: moved.id, mru: [moved.id] }; + return splitAtEdge(next, anchor, edge, { tabs: [moved], active: moved.id, mru: [moved.id] }); +} + +/** A new pane (empty unless `content` is given) at `edge` of `anchor`, made active. */ +export function splitAtEdge( + s: TabsState, + anchor: string, + edge: SplitEdge, + content: Omit = { tabs: [], active: null, mru: [] }, +): { state: TabsState; paneId: string } { + const id = nextId("pane"); const dir = edge === "left" || edge === "right" ? "row" : "column"; const before = edge === "left" || edge === "top"; - const layout = mapLayout(next.layout, (n) => { + const layout = mapLayout(s.layout, (n) => { if (n.kind === "pane" && n.paneId === anchor) { const added: LayoutNode = { kind: "pane", paneId: id }; return { kind: "split", id: nextId("split"), dir, children: before ? [added, n] : [n, added] }; } return n; })!; - next = { ...next, layout, panes: { ...next.panes, [id]: pane }, activePane: id }; - return { state: next, paneId: id }; + return { state: { ...s, layout, panes: { ...s.panes, [id]: { id, ...content } }, activePane: id }, paneId: id }; } diff --git a/src/editor/core/view/EditorArea.tsx b/src/editor/core/view/EditorArea.tsx index 18a22316..ba47dd50 100644 --- a/src/editor/core/view/EditorArea.tsx +++ b/src/editor/core/view/EditorArea.tsx @@ -4,7 +4,7 @@ * decorations, middle-click close, Alt+click close others, double-click to * keep a preview / maximize the editor, context menu) and the active editor. */ -import { Fragment, useMemo, useRef, useState, type DragEvent, type FocusEvent, type ReactNode } from "react"; +import { Fragment, useEffect, useMemo, useRef, useState, type FocusEvent, type ReactNode } from "react"; import clsx from "clsx"; import { useShallow } from "zustand/react/shallow"; import { executeAction } from "@/platform/actions"; @@ -32,6 +32,8 @@ import { SplitPanel, Tabs, showContextMenu, + registerDropTarget, + FILES_DRAG, type TabItem, type TabsDnd, } from "@/ui"; @@ -197,6 +199,33 @@ function PaneView({ paneId }: { paneId: string }) { return { all: strip(0), rest: strip(pinnedCount) }; }, [ctrl, paneId, pinnedCount]); + // Dropping a tab or project files on the editor: an edge splits, the middle opens / moves here. + const contentRef = useRef(null); + const hasPane = !!pane; + useEffect(() => { + const el = contentRef.current; + if (!el) return; + const zoneAt = (x: number, y: number) => dropZoneAt(x, y, el.getBoundingClientRect()); + return registerDropTarget(el, { + accepts: (type) => type === EDITOR_TAB_MIME || type === FILES_DRAG, + over: (_, x, y) => setDropZone(zoneAt(x, y)), + leave: () => setDropZone(null), + drop: (data, x, y) => { + const zone = zoneAt(x, y); + setDropZone(null); + if (data.type === FILES_DRAG) { + void ctrl.openFilesIn(data.payload.split("\n").filter(Boolean), paneId, zone); + return; + } + const p = parseTabPayload(data.payload); + if (!p) return; + const target = useTabs.getState().panes[paneId]; + if (zone === "center") ctrl.moveTab(p.paneId, p.tabId, paneId, target?.tabs.length ?? 0); + else ctrl.moveTabToSplit(p.paneId, p.tabId, paneId, zone); + }, + }); + }, [ctrl, paneId, hasPane]); + if (!pane) return null; const active = pane.tabs.find((t) => t.id === pane.active) ?? null; @@ -208,33 +237,6 @@ function PaneView({ paneId }: { paneId: string }) { if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setContext("editorFocus", false); }; - // Dropping a tab on the editor: an edge splits, the middle moves the tab here. - const isTabDrag = (e: DragEvent) => [...e.dataTransfer.types].includes(EDITOR_TAB_MIME); - const contentDnd = { - onDragOverCapture: (e: DragEvent) => { - if (!isTabDrag(e)) return; - e.preventDefault(); - e.stopPropagation(); - e.dataTransfer.dropEffect = "move"; - const zone = dropZoneAt(e.clientX, e.clientY, e.currentTarget.getBoundingClientRect()); - if (zone !== dropZone) setDropZone(zone); - }, - onDragLeave: (e: DragEvent) => { - if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setDropZone(null); - }, - onDropCapture: (e: DragEvent) => { - if (!isTabDrag(e)) return; - e.preventDefault(); - e.stopPropagation(); - const zone = dropZoneAt(e.clientX, e.clientY, e.currentTarget.getBoundingClientRect()); - setDropZone(null); - const p = parseTabPayload(e.dataTransfer.getData(EDITOR_TAB_MIME)); - if (!p) return; - if (zone === "center") ctrl.moveTab(p.paneId, p.tabId, paneId, pane.tabs.length); - else ctrl.moveTabToSplit(p.paneId, p.tabId, paneId, zone); - }, - }; - const hidden = tabPrefs.placement === "none" || distractionFree; const strip = (stripItems: TabItem[], stripDnd: TabsDnd, trailing: ReactNode) => ( {active ? : } {dropZone &&