mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
Editor: quick fixes, completion, navigation while typing, and split-pane drag (#109)
* 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
This commit is contained in:
@@ -241,6 +241,11 @@ pub fn lsp_servers(host: &Host, window: &str) -> IdeResult<Vec<ServerInfo>> {
|
||||
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 {
|
||||
|
||||
@@ -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<PathBuf> =
|
||||
ide_tools::managed_bundle(&data_dir, "java-debug").into_iter().collect();
|
||||
|
||||
@@ -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<u32>,
|
||||
/// 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)]
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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<Arc<Server>> = 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<ServerInfo> {
|
||||
self.servers
|
||||
.lock()
|
||||
|
||||
@@ -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<lsp::Diagnostic>)> {
|
||||
self.diagnostics.lock().iter().map(|(p, d)| (p.clone(), d.clone())).collect()
|
||||
}
|
||||
|
||||
pub fn take_diagnostic_paths(&self) -> Vec<PathBuf> {
|
||||
self.published.lock().clear();
|
||||
self.diagnostics.lock().drain().map(|(p, _)| p).collect()
|
||||
|
||||
@@ -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!([
|
||||
|
||||
@@ -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<serde_json::Valu
|
||||
install_hint: None,
|
||||
connect: None,
|
||||
fallback: false,
|
||||
max_java: None,
|
||||
launch_error: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -342,6 +342,7 @@ fn custom_server(name: &str, ls: &LanguageServerSettings) -> LanguageServerConfi
|
||||
}),
|
||||
connect: None,
|
||||
fallback: false,
|
||||
max_java: None,
|
||||
launch_error: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -551,3 +551,15 @@ vulkan
|
||||
wgpu
|
||||
woff
|
||||
woff2
|
||||
breakpoint
|
||||
sku
|
||||
dropdown
|
||||
navbar
|
||||
superclass
|
||||
typedef
|
||||
eslint
|
||||
unittest
|
||||
jvm
|
||||
jdk
|
||||
ux
|
||||
endpoint
|
||||
|
||||
@@ -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<HashSet<String>> {
|
||||
@@ -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();
|
||||
|
||||
+15
-1
@@ -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,
|
||||
{
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)"
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -309,6 +309,13 @@ pub fn lsp_servers(window: Window, state: State<'_, AppState>) -> IdeResult<Vec<
|
||||
ide_host::ops::lsp::lsp_servers(&state.host, window.label())
|
||||
}
|
||||
|
||||
/// Re-emits the language servers' current diagnostics (the UI loaded after they arrived).
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn lsp_resend_diagnostics(window: Window, state: State<'_, AppState>) -> 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<()> {
|
||||
|
||||
@@ -174,6 +174,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
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,
|
||||
|
||||
@@ -336,6 +336,8 @@ export const commands = {
|
||||
lspCallHierarchy: (doc: DocId, pos: Pos, direction: HierarchyDirection) => typedError<HierarchyItem[], IdeError>(__TAURI_INVOKE("lsp_call_hierarchy", { doc, pos, direction })),
|
||||
lspTypeHierarchy: (doc: DocId, pos: Pos, direction: HierarchyDirection) => typedError<HierarchyItem[], IdeError>(__TAURI_INVOKE("lsp_type_hierarchy", { doc, pos, direction })),
|
||||
lspServers: () => typedError<ServerInfo[], IdeError>(__TAURI_INVOKE("lsp_servers")),
|
||||
/** Re-emits the language servers' current diagnostics (the UI loaded after they arrived). */
|
||||
lspResendDiagnostics: () => typedError<null, IdeError>(__TAURI_INVOKE("lsp_resend_diagnostics")),
|
||||
lspRestart: (server: ServerId) => typedError<null, IdeError>(__TAURI_INVOKE("lsp_restart", { server })),
|
||||
/** Applies a (previewed) workspace edit, e.g. after rename confirmation. */
|
||||
applyWorkspaceEdit: (edit: WorkspaceEdit) => typedError<null, IdeError>(__TAURI_INVOKE("apply_workspace_edit", { edit })),
|
||||
|
||||
@@ -192,6 +192,12 @@ export function whenSynced(view: EditorView): Promise<void> {
|
||||
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<void> {
|
||||
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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<Pane, "id"> = { 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 };
|
||||
}
|
||||
|
||||
@@ -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<HTMLDivElement>(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<HTMLDivElement>) => {
|
||||
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<HTMLDivElement>) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setDropZone(null);
|
||||
},
|
||||
onDropCapture: (e: DragEvent<HTMLDivElement>) => {
|
||||
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) => (
|
||||
<Tabs
|
||||
@@ -299,7 +301,7 @@ function PaneView({ paneId }: { paneId: string }) {
|
||||
role="tabpanel"
|
||||
id={active ? `editor-${paneId}-panel-${active.id}` : undefined}
|
||||
aria-labelledby={active ? `editor-${paneId}-tab-${active.id}` : undefined}
|
||||
{...contentDnd}
|
||||
ref={contentRef}
|
||||
>
|
||||
{active ? <PaneContent paneId={paneId} tab={active} /> : <EmptyEditor />}
|
||||
{dropZone && <div className={clsx(styles.dropZone, styles[`drop-${dropZone}`])} aria-hidden="true" />}
|
||||
|
||||
@@ -178,10 +178,6 @@
|
||||
}
|
||||
.bulb {
|
||||
flex: none;
|
||||
color: var(--warning);
|
||||
}
|
||||
.bulbFix {
|
||||
color: var(--danger);
|
||||
}
|
||||
.empty {
|
||||
font-size: var(--fs-ui);
|
||||
|
||||
@@ -44,7 +44,7 @@ import { useUsagesStatus } from "./navigation";
|
||||
import styles from "./Popups.module.css";
|
||||
|
||||
export function BulbIcon({ fix }: { fix?: boolean }) {
|
||||
return <Icon name="bulb" size={16} className={clsx(styles.bulb, fix && styles.bulbFix)} />;
|
||||
return <Icon name={fix ? "bulb-fix" : "bulb"} size={16} className={styles.bulb} />;
|
||||
}
|
||||
|
||||
function ItemIcon({ item }: { item: ListPopupItem }) {
|
||||
|
||||
@@ -10,7 +10,8 @@ import { quickDefinition, quickDocumentation } from "./hover";
|
||||
import { expressionTypeInfo } from "./typeInfo";
|
||||
import { gotoTargets, highlightUsages } from "./navigation";
|
||||
import { gotoDiagnostic } from "./diagnostics";
|
||||
import { applyKindSilently, runKindAction, showIntentions } from "./intentions";
|
||||
import { applyKindSilently, applyQuickFix, runKindAction, showIntentions } from "./intentions";
|
||||
import { implementPythonAbstractMethods } from "./pythonMembers";
|
||||
import { workflowSettings, type ActionsOnSaveValid } from "@/platform/workflowSettings";
|
||||
import { startRename } from "./rename";
|
||||
import { formatDocument, reformatDialog } from "./format";
|
||||
@@ -154,6 +155,9 @@ export function registerLspActions(): () => void {
|
||||
editorAction("code.showIntentions", "Show Context Actions", "Code", showIntentions, {
|
||||
keywords: ["intention", "quick fix", "lightbulb"],
|
||||
}),
|
||||
editorAction("code.applyQuickFix", "Apply Quick Fix", "Code", applyQuickFix, {
|
||||
keywords: ["fix error", "problem", "lightbulb"],
|
||||
}),
|
||||
editorAction("code.reformat", "Reformat Code", "Code", reformat, {
|
||||
keywords: ["format", "prettier"],
|
||||
}),
|
||||
@@ -228,7 +232,12 @@ export function registerLspActions(): () => void {
|
||||
// Implement / Override Methods: the server's code actions for them (jdtls "Override/Implement
|
||||
// Methods…", rust-analyzer "Implement missing members", TS "Implement interface"…).
|
||||
editorAction("code.implementMethods", "Implement Methods…", "Code", (v) =>
|
||||
runKindAction(v, "", { title: IMPLEMENT_TITLE, none: "Nothing to implement", label: "Implement Members" }),
|
||||
runKindAction(v, "", {
|
||||
title: IMPLEMENT_TITLE,
|
||||
none: "Nothing to implement",
|
||||
label: "Implement Members",
|
||||
fallback: () => implementPythonAbstractMethods(v),
|
||||
}),
|
||||
),
|
||||
editorAction("code.overrideMethods", "Override Methods…", "Code", (v) =>
|
||||
runKindAction(v, "", { title: OVERRIDE_TITLE, none: "Nothing to override", label: "Override Members" }),
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { IdeEvent } from "@/bindings";
|
||||
|
||||
const listeners = vi.hoisted(() => new Set<(e: IdeEvent) => void>());
|
||||
vi.mock("@/platform/ipc", async (orig) => {
|
||||
const real = await orig<typeof import("@/platform/ipc")>();
|
||||
return {
|
||||
...real,
|
||||
isTauri: () => true,
|
||||
onIdeEvent: (_type: string, fn: (e: IdeEvent) => void) => {
|
||||
listeners.add(fn);
|
||||
return () => listeners.delete(fn);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const { onServerSettled } = await import("./availability");
|
||||
|
||||
const status = (s: string) => {
|
||||
for (const fn of listeners) fn({ type: "languageServer", server: 1, status: s } as unknown as IdeEvent);
|
||||
};
|
||||
|
||||
describe("onServerSettled", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
listeners.clear();
|
||||
});
|
||||
|
||||
it("re-runs a callback that keeps the server busy only once", () => {
|
||||
vi.useFakeTimers();
|
||||
const fn = vi.fn();
|
||||
onServerSettled(fn);
|
||||
status("starting");
|
||||
status("ready");
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
for (let i = 0; i < 2; i++) {
|
||||
status("busy");
|
||||
vi.advanceTimersByTime(2000);
|
||||
status("ready");
|
||||
}
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
// Indexing that starts later is a new settle.
|
||||
vi.advanceTimersByTime(10_000);
|
||||
status("busy");
|
||||
vi.advanceTimersByTime(5000);
|
||||
status("ready");
|
||||
expect(fn).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("ignores short busy spells", () => {
|
||||
vi.useFakeTimers();
|
||||
const fn = vi.fn();
|
||||
onServerSettled(fn);
|
||||
status("busy");
|
||||
vi.advanceTimersByTime(100);
|
||||
status("ready");
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -46,6 +46,43 @@ export function onLspRefresh(kind: LspRefreshKind, fn: () => void): () => void {
|
||||
};
|
||||
}
|
||||
|
||||
/** A busy spell at least this long is indexing / a build, not the server answering a request. */
|
||||
const LONG_BUSY_MS = 1500;
|
||||
|
||||
/** A busy spell starting this soon after `fn` ran may be `fn`'s own requests. */
|
||||
const ECHO_MS = 2000;
|
||||
|
||||
/**
|
||||
* Calls `fn` when a server becomes ready after starting or after a long busy
|
||||
* spell (indexing), when earlier answers may have been incomplete. Short busy
|
||||
* spells are ignored: jdtls reports progress for the very requests a caller
|
||||
* sends, and re-asking on each of them would never stop. For the same reason
|
||||
* a long spell that began right after `fn` ran (slow requests `fn` sent, or
|
||||
* indexing right after start) re-runs it only once in a row.
|
||||
*/
|
||||
export function onServerSettled(fn: () => void): () => void {
|
||||
if (!isTauri()) return () => {};
|
||||
const since = new Map<number, number>();
|
||||
const fired = new Map<number, { at: number; echoed: boolean }>();
|
||||
return onIdeEvent("languageServer", (e) => {
|
||||
if (e.status === "starting") {
|
||||
since.set(e.server, -Infinity);
|
||||
fired.delete(e.server);
|
||||
} else if (e.status === "busy") {
|
||||
if (!since.has(e.server)) since.set(e.server, Date.now());
|
||||
} else if (e.status === "ready") {
|
||||
const t = since.get(e.server);
|
||||
since.delete(e.server);
|
||||
if (t === undefined || Date.now() - t < LONG_BUSY_MS) return;
|
||||
const last = fired.get(e.server);
|
||||
const echo = !!last && t - last.at < ECHO_MS;
|
||||
if (echo && last.echoed) return;
|
||||
fired.set(e.server, { at: Date.now(), echoed: echo });
|
||||
fn();
|
||||
} else since.delete(e.server);
|
||||
});
|
||||
}
|
||||
|
||||
export function installAvailabilityEvents(): () => void {
|
||||
return onIdeEvent("languageServer", (e) => {
|
||||
if (e.status === "ready") serverBackoff.clear();
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { EditorState, StateEffect } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import type { CompletionItem } from "@/bindings";
|
||||
import { completionSymbolKind, identifierEnd, toCmCompletion, triggerCharacter } from "./completionItems";
|
||||
import type { EditorDocContext } from "@/editor/api";
|
||||
import { lspDoc } from "./lsp";
|
||||
import {
|
||||
completionSymbolKind,
|
||||
identifierEnd,
|
||||
toCmCompletion,
|
||||
triggerCharacter,
|
||||
typeColumnText,
|
||||
withoutDuplicates,
|
||||
} from "./completionItems";
|
||||
import {
|
||||
applyLspCompletion,
|
||||
callParens,
|
||||
@@ -63,6 +72,23 @@ describe("toCmCompletion", () => {
|
||||
expect(c.displayLabel).toBe("@GetMapping");
|
||||
});
|
||||
|
||||
it("filters on the label when filterText only adds the call or body", () => {
|
||||
for (const filterText of ["String(${0})", "List<>()", "Runnable() {\n\t${0}\n};"]) {
|
||||
const label = filterText.match(/^\w+/)![0];
|
||||
const c = toCmCompletion(item({ label, filterText }));
|
||||
expect(c.label).toBe(label);
|
||||
expect(c.displayLabel).toBeUndefined();
|
||||
}
|
||||
expect(toCmCompletion(item({ label: "foo", filterText: "foo_bar" })).label).toBe("foo_bar");
|
||||
});
|
||||
|
||||
it("lists identical items once", () => {
|
||||
const ctor = item({ label: "String()", kind: "constructor", data: "1" });
|
||||
const overload = item({ label: "String()", kind: "constructor", labelDetail: "(bytes: ByteArray)" });
|
||||
const out = withoutDuplicates([ctor, { ...ctor, data: "2" }, overload, { ...ctor, server: 2 }]);
|
||||
expect(out).toEqual([ctor, overload, { ...ctor, server: 2 }]);
|
||||
});
|
||||
|
||||
it("boosts preselected and demotes deprecated items", () => {
|
||||
expect(toCmCompletion(item({ preselect: true })).boost).toBe(99);
|
||||
expect(toCmCompletion(item({ deprecated: true })).boost).toBeLessThan(0);
|
||||
@@ -122,12 +148,31 @@ describe("applying completions", () => {
|
||||
expect(view.state.selection.main.head).toBe(14);
|
||||
});
|
||||
|
||||
it("keeps overloads sharing a filterText apart", () => {
|
||||
const { req } = setup("x.ti|");
|
||||
const [a, b] = ["times(other: Int)", "times(other: Long)"].map((label) =>
|
||||
makeCompletion(item({ label, filterText: "times" }), req),
|
||||
);
|
||||
expect([a!.label, b!.label]).toEqual(["times", "times"]);
|
||||
expect(a!.apply).not.toBe(b!.apply);
|
||||
});
|
||||
|
||||
it("replaces the rest of the identifier with ⇥", () => {
|
||||
const { view, req } = setup("service.cr|Order()");
|
||||
applyLspCompletion(view, makeCompletion(item({ insertText: "create" }), req), 8, 10, "replace");
|
||||
expect(view.state.doc.toString()).toBe("service.create()");
|
||||
});
|
||||
|
||||
it("leaves the caret inside a call snippet without stops, past `)` once nothing takes arguments", async () => {
|
||||
const { view, req } = setup("new Arr|");
|
||||
view.dispatch({ effects: StateEffect.appendConfig.of(lspDoc.of({ doc: 1 } as EditorDocContext)) });
|
||||
apply(view, makeCompletion(item({ kind: "constructor", insertText: "ArrayList<>()", isSnippet: true }), req), 4);
|
||||
expect(view.state.doc.toString()).toBe("new ArrayList<>()");
|
||||
expect(view.state.selection.main.head).toBe(16);
|
||||
// Signature help fails outside Tauri: nothing to pass.
|
||||
await vi.waitFor(() => expect(view.state.selection.main.head).toBe(17));
|
||||
});
|
||||
|
||||
it("uses the server's ⇥ range when given", () => {
|
||||
const { view, req } = setup("a.cr|Order.x");
|
||||
const c = makeCompletion(
|
||||
@@ -280,3 +325,27 @@ describe("explicit completion", () => {
|
||||
view.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("typeColumnText", () => {
|
||||
const edit = (new_text: string) => [{ range: { start: { line: 0, col: 0 }, end: { line: 0, col: 0 } }, new_text }];
|
||||
|
||||
it("names the module an auto-import comes from", () => {
|
||||
expect(typeColumnText({ detail: "Auto-import", additionalEdits: edit("\nfrom collections import OrderedDict") })).toBe(
|
||||
"collections",
|
||||
);
|
||||
expect(typeColumnText({ detail: "Map<K, V>", additionalEdits: edit("import java.util.HashMap;\n") })).toBe(
|
||||
"Map<K, V> (java.util)",
|
||||
);
|
||||
expect(typeColumnText({ detail: null, additionalEdits: edit("use std::collections::HashMap;\n") })).toBe(
|
||||
"std::collections",
|
||||
);
|
||||
expect(typeColumnText({ detail: null, additionalEdits: edit('import { a } from "./money";\n') })).toBe("./money");
|
||||
});
|
||||
|
||||
it("keeps a detail that already says where", () => {
|
||||
expect(typeColumnText({ detail: "java.util.ArrayList", additionalEdits: edit("import java.util.ArrayList;\n") })).toBe(
|
||||
"java.util.ArrayList",
|
||||
);
|
||||
expect(typeColumnText({ detail: "fn() ", additionalEdits: [] })).toBe("fn() ");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,14 +35,21 @@ import {
|
||||
type RequestId,
|
||||
} from "@/platform/ipc";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { completionSymbolKind, identifierEnd, toCmCompletion, triggerCharacter } from "./completionItems";
|
||||
import {
|
||||
completionSymbolKind,
|
||||
identifierEnd,
|
||||
toCmCompletion,
|
||||
triggerCharacter,
|
||||
typeColumnText,
|
||||
withoutDuplicates,
|
||||
} from "./completionItems";
|
||||
import { lspSnippetToCm, snippetVariables } from "./snippet";
|
||||
import { docOf, isUnavailable, lspDoc, lspRequest, reportLspError } from "./lsp";
|
||||
import { offsetToPos, posToOffset, textEditsToChanges } from "./positions";
|
||||
import { isEmptyMarkup, renderCode, renderMarkup } from "./markdown";
|
||||
import { selectableText } from "@/ui/selectableText";
|
||||
import { iconNode } from "./iconMarkup";
|
||||
import { triggerSignatureHelp } from "./signature";
|
||||
import { parameterInfoOrLeave, triggerSignatureHelp } from "./signature";
|
||||
import { serverBackoff } from "./availability";
|
||||
import { pathCompletionSource } from "./fileReferences";
|
||||
import { editorPrefs } from "@/platform/editorSettings";
|
||||
@@ -71,17 +78,42 @@ const isLsp = (c: Completion): c is LspCompletion => "lsp" in c;
|
||||
// Resolve (documentation, lazily computed edits)
|
||||
|
||||
const resolving = new WeakMap<CompletionItem, Promise<CompletionItem>>();
|
||||
/** Items whose resolve has answered, for accepting them synchronously. */
|
||||
const resolvedItems = new WeakMap<CompletionItem, CompletionItem>();
|
||||
|
||||
function resolveItem(doc: number, item: CompletionItem): Promise<CompletionItem> {
|
||||
if (item.data == null && !isEmptyMarkup(item.documentation)) return Promise.resolve(item);
|
||||
let p = resolving.get(item);
|
||||
if (!p) {
|
||||
p = lspRequest(() => commands.lspResolveCompletion(doc, item)).catch(() => item);
|
||||
p = lspRequest(() => commands.lspResolveCompletion(doc, item)).then(
|
||||
(r) => {
|
||||
resolvedItems.set(item, r);
|
||||
return r;
|
||||
},
|
||||
() => item,
|
||||
);
|
||||
resolving.set(item, p);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the selected lookup item ahead of acceptance: some servers compute
|
||||
* its extra edits (rust-analyzer's `use` import) only on resolve, against the
|
||||
* text as it was, so resolving after the insertion finds nothing.
|
||||
*/
|
||||
const prefetchResolve = ViewPlugin.fromClass(
|
||||
class {
|
||||
private last: Completion | null = null;
|
||||
update(u: ViewUpdate) {
|
||||
const c = selectedCompletion(u.state);
|
||||
if (c === this.last) return;
|
||||
this.last = c;
|
||||
if (c && isLsp(c) && c.lsp.data != null && !c.lsp.additionalEdits.length) void resolveItem(c.req.doc, c.lsp);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* F1 in the lookup (IDEA's documentation popup next to the lookup): docs then
|
||||
* follow the selection until the lookup closes. Without the auto-popup setting
|
||||
@@ -166,7 +198,7 @@ export function applyLspCompletion(
|
||||
) {
|
||||
if (!isLsp(completion)) return;
|
||||
const { req } = completion;
|
||||
const item = completion.resolved ?? completion.lsp;
|
||||
const item = completion.resolved ?? resolvedItems.get(completion.lsp) ?? completion.lsp;
|
||||
const state = view.state;
|
||||
const delta = to - req.pos;
|
||||
const mapOld = (offset: number) =>
|
||||
@@ -191,6 +223,7 @@ export function applyLspCompletion(
|
||||
.filter((c) => c.to <= start || c.from >= end);
|
||||
|
||||
let insertedParens: ReturnType<typeof callParens> = null;
|
||||
let bareCall: number | null = null;
|
||||
if (item.isSnippet) {
|
||||
if (extra.length) {
|
||||
const tr = state.update({ changes: extra, userEvent: "input.complete" });
|
||||
@@ -199,6 +232,19 @@ export function applyLspCompletion(
|
||||
end = tr.changes.mapPos(end, 1);
|
||||
}
|
||||
snippet(lspSnippetToCm(item.insertText, snippetVariables(req.path)))(view, completion, start, end);
|
||||
// A call snippet without a stop (`ArrayList<>()`): the caret goes inside until
|
||||
// signature help says there is nothing to pass.
|
||||
const head = view.state.selection.main.head;
|
||||
if (
|
||||
editorPrefs().completion.insertParentheses &&
|
||||
CALLABLE.has(item.kind ?? "") &&
|
||||
/\(\)$/.test(item.insertText) &&
|
||||
!/\$/.test(item.insertText) &&
|
||||
view.state.sliceDoc(head - 2, head) === "()"
|
||||
) {
|
||||
view.dispatch({ selection: EditorSelection.cursor(head - 1) });
|
||||
bareCall = head - 1;
|
||||
}
|
||||
} else {
|
||||
const parens = editorPrefs().completion.insertParentheses
|
||||
? callParens(item, state.doc.lineAt(start), start, end)
|
||||
@@ -215,9 +261,15 @@ export function applyLspCompletion(
|
||||
});
|
||||
}
|
||||
|
||||
if (!completion.resolved && !item.additionalEdits.length && item.data != null)
|
||||
if (item === completion.lsp && !item.additionalEdits.length && item.data != null)
|
||||
void applyLateEdits(view, completion, start);
|
||||
if (insertedParens === "inside" && !item.command) setTimeout(() => triggerSignatureHelp(view), 0);
|
||||
// Parameter info for a call left open, plain (`()` added here) or a server snippet (`fly(${1})`).
|
||||
const callSnippet = item.isSnippet && CALLABLE.has(item.kind ?? "") && /\(\$\{?\d/.test(item.insertText);
|
||||
// Unless the item's command asks for it itself (other commands, like jdtls's
|
||||
// `java.completion.onDidSelect`, are bookkeeping).
|
||||
const hintsCommand = item.command?.command === "editor.action.triggerParameterHints";
|
||||
if ((insertedParens === "inside" || callSnippet) && !hintsCommand) setTimeout(() => triggerSignatureHelp(view), 0);
|
||||
else if (bareCall !== null) void parameterInfoOrLeave(view, bareCall);
|
||||
runCommand(view, item);
|
||||
}
|
||||
|
||||
@@ -314,7 +366,13 @@ export function makeCompletion(item: CompletionItem, req: RequestContext): LspCo
|
||||
return Object.assign(cm, {
|
||||
lsp: item,
|
||||
req,
|
||||
apply: applyLsp,
|
||||
// CodeMirror merges options with the same label, detail and `apply`: overloads
|
||||
// sharing a filterText (`times(other: Int)`, `times(other: Long)`…) and the same
|
||||
// name imported from different modules need their own.
|
||||
apply:
|
||||
cm.displayLabel || (item.filterText && item.filterText !== item.label) || item.additionalEdits.length
|
||||
? (...a: Parameters<typeof applyLsp>) => applyLsp(...a)
|
||||
: applyLsp,
|
||||
info: (c: Completion) => renderInfo(c as LspCompletion),
|
||||
});
|
||||
}
|
||||
@@ -369,7 +427,10 @@ async function lspCompletionSource(ctx: CompletionContext): Promise<CompletionRe
|
||||
});
|
||||
let list;
|
||||
try {
|
||||
list = await lspRequest(() => commands.lspCompletion(id, doc.doc, offsetToPos(ctx.state.doc, ctx.pos), trigger));
|
||||
list = await lspRequest(
|
||||
() => commands.lspCompletion(id, doc.doc, offsetToPos(ctx.state.doc, ctx.pos), trigger),
|
||||
() => inflight === id,
|
||||
);
|
||||
} catch (e) {
|
||||
if (isUnavailable(e)) {
|
||||
serverBackoff.mark(doc.doc);
|
||||
@@ -383,9 +444,18 @@ async function lspCompletionSource(ctx: CompletionContext): Promise<CompletionRe
|
||||
const req: RequestContext = { doc: doc.doc, path: doc.path, language: doc.uiMode, pos: ctx.pos, text: ctx.state.doc };
|
||||
const prefs = editorPrefs().completion;
|
||||
const typed = word?.text ?? "";
|
||||
const items = prefs.matchCase
|
||||
? list.items.filter((it) => matchesCase(it.filterText ?? it.label, typed, prefs.caseSensitivity))
|
||||
: list.items;
|
||||
let items = withoutDuplicates(
|
||||
prefs.matchCase
|
||||
? list.items.filter((it) => matchesCase(it.filterText ?? it.label, typed, prefs.caseSensitivity))
|
||||
: list.items,
|
||||
);
|
||||
// A server snippet named like a live template (jdtls `sout`, `fori`) would be listed twice; the template wins.
|
||||
if (items.some((it) => it.kind === "snippet")) {
|
||||
const own = await contributedSource(ctx);
|
||||
if (ctx.aborted) return null;
|
||||
const names = new Set(own?.options.map((o) => o.label));
|
||||
if (names.size) items = items.filter((it) => it.kind !== "snippet" || !names.has(it.label));
|
||||
}
|
||||
return {
|
||||
from: word ? word.from : ctx.pos,
|
||||
options: items.map((item) => makeCompletion(item, req)),
|
||||
@@ -616,11 +686,11 @@ function kindBadge(c: Completion): Node | null {
|
||||
}
|
||||
|
||||
function typeColumn(c: Completion): Node | null {
|
||||
const detail = isLsp(c) ? c.lsp.detail : (c as RichCompletion).typeText;
|
||||
const detail = isLsp(c) ? typeColumnText(c.lsp) : (c as RichCompletion).typeText?.replace(/\s+/g, " ");
|
||||
if (!detail) return null;
|
||||
const span = document.createElement("span");
|
||||
span.className = "cm-lsp-type";
|
||||
span.textContent = detail.replace(/\s+/g, " ");
|
||||
span.textContent = detail;
|
||||
return span;
|
||||
}
|
||||
|
||||
@@ -684,6 +754,7 @@ const completionFooter = ViewPlugin.fromClass(
|
||||
export function completionExtension() {
|
||||
return [
|
||||
lookupKeys,
|
||||
prefetchResolve,
|
||||
autocompletion({
|
||||
override: [gated(lspCompletionSource), gated(contributedSource), gated(pathCompletionSource)],
|
||||
icons: false,
|
||||
|
||||
@@ -41,7 +41,7 @@ export function completionSymbolKind(kind: CompletionKind | null): string {
|
||||
* original label is shown through `displayLabel`.
|
||||
*/
|
||||
export function toCmCompletion(item: CompletionItem): Completion {
|
||||
const filter = item.filterText && item.filterText !== item.label ? item.filterText : null;
|
||||
const filter = item.filterText && item.filterText !== item.label && !extendsLabel(item) ? item.filterText : null;
|
||||
const c: Completion = {
|
||||
label: filter ?? item.label,
|
||||
type: completionSymbolKind(item.kind),
|
||||
@@ -55,6 +55,62 @@ export function toCmCompletion(item: CompletionItem): Completion {
|
||||
return c;
|
||||
}
|
||||
|
||||
/**
|
||||
* A `filterText` that is the label plus what the item inserts after it (jdtls
|
||||
* `String(${0})`, `List<>()`): typing a word never reaches that part, and
|
||||
* matching on it would rank the exact `String` like `StringBuilder`.
|
||||
*/
|
||||
function extendsLabel(item: CompletionItem): boolean {
|
||||
const rest = item.filterText!.slice(item.label.length);
|
||||
return item.filterText!.startsWith(item.label) && /^[^\p{L}\p{N}_$]/u.test(rest);
|
||||
}
|
||||
|
||||
/**
|
||||
* Items that look and insert the same, listed once. kotlin-language-server
|
||||
* repeats a class once per classpath entry declaring it (`String()` ×3);
|
||||
* these all have their own `apply` (see `makeCompletion`), so CodeMirror
|
||||
* wouldn't merge them.
|
||||
*/
|
||||
export function withoutDuplicates(items: readonly CompletionItem[]): CompletionItem[] {
|
||||
const seen = new Set<string>();
|
||||
return items.filter((it) => {
|
||||
const key = JSON.stringify([it.server, it.label, it.labelDetail, it.detail, it.kind, it.insertText, it.additionalEdits]);
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
const IMPORT_SOURCE = [
|
||||
/^\s*from\s+([\w.]+)\s+import\b/m, // Python
|
||||
/^\s*import\s+(?:static\s+)?([\w.]+?)\.\w+;?\s*$/m, // Java, Kotlin
|
||||
/^\s*use\s+([\w:]+)::/m, // Rust
|
||||
/\bfrom\s+["']([^"']+)["']/, // JS / TS
|
||||
];
|
||||
|
||||
/**
|
||||
* Where an auto-import item comes from (`collections`, `java.util`): the
|
||||
* module its import edit names. Servers like pyright put it nowhere else, so
|
||||
* same-named items (`OrderedDict` from typing / collections) look identical.
|
||||
*/
|
||||
export function importSource(item: Pick<CompletionItem, "additionalEdits">): string | null {
|
||||
for (const e of item.additionalEdits) {
|
||||
for (const re of IMPORT_SOURCE) {
|
||||
const m = re.exec(e.new_text);
|
||||
if (m) return m[1]!;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Text of the right-aligned type column: the detail, with the import source when it lacks one. */
|
||||
export function typeColumnText(item: Pick<CompletionItem, "detail" | "additionalEdits">): string | null {
|
||||
const detail = item.detail?.replace(/\s+/g, " ") || null;
|
||||
const source = importSource(item);
|
||||
if (!source || detail?.includes(source)) return detail;
|
||||
return !detail || /^auto[- ]?import$/i.test(detail) ? source : `${detail} (${source})`;
|
||||
}
|
||||
|
||||
const WORD_CHAR = /[\p{L}\p{N}_$]/u;
|
||||
|
||||
/** End of the identifier starting at `pos` in `line` (for ⇥ Replace without a server range). */
|
||||
|
||||
@@ -9,7 +9,8 @@ import { EditorSelection, StateEffect, StateField } from "@codemirror/state";
|
||||
import { EditorView, ViewPlugin } from "@codemirror/view";
|
||||
import { forEachDiagnostic, linter, setDiagnostics, type Diagnostic as LintDiagnostic } from "@codemirror/lint";
|
||||
import type { Diagnostic, Severity } from "@/bindings";
|
||||
import { onIdeEvent } from "@/platform/ipc";
|
||||
import { commands, ideEventsReady, isTauri, onIdeEvent } from "@/platform/ipc";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { publishLocalDiagnostics } from "@/platform/localDiagnostics";
|
||||
import { scrollbarMarks, type ScrollbarMark } from "@/editor/api";
|
||||
import { rangeToOffsets } from "./positions";
|
||||
@@ -89,11 +90,20 @@ export function subscribeDiagnostics(path: string, l: Listener): () => void {
|
||||
|
||||
export function installDiagnosticsEvents(): () => void {
|
||||
const off = onIdeEvent("diagnostics", (e) => storeDiagnostics(e.path, e.source, e.items));
|
||||
// Diagnostics published before this window (re)loaded were missed: ask for them again.
|
||||
const resend = () => {
|
||||
if (isTauri()) void ideEventsReady().then(() => commands.lspResendDiagnostics()).catch(() => {});
|
||||
};
|
||||
if (useProject.getState().project) resend();
|
||||
const offProject = useProject.subscribe((s, prev) => {
|
||||
if (s.project?.root !== prev.project?.root && s.project) resend();
|
||||
});
|
||||
// Another inspection profile (or edits to it) re-filters every open file.
|
||||
const offProfile = subscribeInspectionProfile(() => listeners.forEach((set) => set.forEach((l) => l())));
|
||||
return () => {
|
||||
off();
|
||||
offProfile();
|
||||
offProject();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -118,13 +128,16 @@ export function toLintDiagnostics(doc: Text, items: readonly Diagnostic[]): Lint
|
||||
to,
|
||||
severity: d.severity,
|
||||
message: d.message,
|
||||
source: [d.source, d.code].filter(Boolean).join(" ") || undefined,
|
||||
source: [d.source, isInternalCode(d.code) ? null : d.code].filter(Boolean).join(" ") || undefined,
|
||||
markClass: classes.length ? classes.join(" ") : undefined,
|
||||
};
|
||||
});
|
||||
return out.sort((a, b) => a.from - b.from || SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity]);
|
||||
}
|
||||
|
||||
/** Problem ids that mean nothing to a reader (jdtls: `67109264`); short ones (TS `2322`) stay. */
|
||||
const isInternalCode = (code: string | null | undefined) => !!code && /^\d{6,}$/.test(code);
|
||||
|
||||
const MARK_COLOR: Partial<Record<Severity, string>> = {
|
||||
error: "var(--danger)",
|
||||
warning: "var(--warning)",
|
||||
@@ -218,6 +231,13 @@ const lintTheme = EditorView.theme({
|
||||
},
|
||||
".cm-lintRange.cm-lsp-unnecessary *": { color: "var(--fg-unused, var(--fg-faint))" },
|
||||
".cm-lintRange.cm-lsp-deprecated": { textDecoration: "line-through", textDecorationColor: "var(--fg-muted)" },
|
||||
// An error or warning on the same range (merged into one mark) keeps its underline.
|
||||
".cm-lintRange-error.cm-lsp-typo, .cm-lintRange-error.cm-lsp-unnecessary": {
|
||||
textDecoration: "var(--editor-error-decoration, underline wavy var(--danger))",
|
||||
},
|
||||
".cm-lintRange-warning.cm-lsp-typo, .cm-lintRange-warning.cm-lsp-unnecessary": {
|
||||
textDecoration: "var(--editor-warning-decoration, underline wavy var(--warning))",
|
||||
},
|
||||
".cm-lintRange-active": { backgroundColor: "transparent" },
|
||||
".cm-lintRange-warning.cm-lintRange-active": { backgroundColor: "var(--editor-warning-background, transparent)" },
|
||||
".cm-lintPoint:after": { display: "none" },
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
protectedRegions,
|
||||
type FormatGuardOptions,
|
||||
} from "@/platform/formatGuard";
|
||||
import { formattedLines } from "./format";
|
||||
import { dedentsToColumnZero, formattedLines } from "./format";
|
||||
|
||||
describe("formattedLines", () => {
|
||||
const doc = Text.of(["fn main(){", "let x=1;", "}", "", "fn other() {}"]);
|
||||
@@ -97,3 +97,11 @@ describe("formatter markers and Do not format", () => {
|
||||
expect(formatExcluded("/p/other/docs.md", "/p", globs)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dedentsToColumnZero", () => {
|
||||
it("spots range formatting done without the surrounding code", () => {
|
||||
expect(dedentsToColumnZero("\n override fun f() { }\n", "\noverride fun f() {}\n")).toBe(true);
|
||||
expect(dedentsToColumnZero("\n\tfun f() {\n\t}\n", "\n fun f() {\n }\n")).toBe(false);
|
||||
expect(dedentsToColumnZero("fun f() {}\n", "fun f() { }\n")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
import { indentUnit } from "@codemirror/language";
|
||||
import { isolateHistory } from "@codemirror/commands";
|
||||
import type { Text } from "@codemirror/state";
|
||||
import type { EditorState, Text } from "@codemirror/state";
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import { commands } from "@/platform/ipc";
|
||||
import { shortcutLabel } from "@/platform/keymap";
|
||||
@@ -63,6 +63,12 @@ function formattedHint(view: EditorView, lines: number) {
|
||||
});
|
||||
}
|
||||
|
||||
/** LSP `FormattingOptions`: with spaces the indent width is the indent unit, not the tab size. */
|
||||
export function formatOptionsOf(state: EditorState): { tabSize: number; insertSpaces: boolean } {
|
||||
const unit = state.facet(indentUnit);
|
||||
return unit.includes("\t") ? { tabSize: state.tabSize, insertSpaces: false } : { tabSize: unit.length || state.tabSize, insertSpaces: true };
|
||||
}
|
||||
|
||||
export async function formatDocument(
|
||||
view: EditorView,
|
||||
opts: { explicit: boolean; selectionOnly?: boolean } = { explicit: true },
|
||||
@@ -90,7 +96,7 @@ async function format(view: EditorView, opts: { explicit: boolean; selectionOnly
|
||||
const sel = state.selection.main;
|
||||
const useSelection = (opts.selectionOnly ?? true) && !sel.empty;
|
||||
const range = useSelection ? { start: offsetToPos(state.doc, sel.from), end: offsetToPos(state.doc, sel.to) } : null;
|
||||
const options = { tabSize: state.tabSize, insertSpaces: !state.facet(indentUnit).includes("\t") };
|
||||
const options = formatOptionsOf(state);
|
||||
let edits;
|
||||
try {
|
||||
edits = await lspRequest(() => commands.lspFormat(doc.doc, range, options));
|
||||
@@ -116,6 +122,45 @@ async function format(view: EditorView, opts: { explicit: boolean; selectionOnly
|
||||
return { applied: true, changes: changes.length, lines: formattedLines(state.doc, changes) };
|
||||
}
|
||||
|
||||
/** Whether `after` has a non-blank line at column 0 where all of `before` was indented. */
|
||||
export function dedentsToColumnZero(before: string, after: string): boolean {
|
||||
const minIndent = (t: string) =>
|
||||
Math.min(...t.split("\n").filter((l) => l.trim()).map((l) => l.length - l.trimStart().length));
|
||||
return minIndent(before) > 0 && minIndent(after) === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reformats lines `from..to` silently (generated code: quick fixes, Generate),
|
||||
* merged into the edit's undo step. False when the server can't format ranges.
|
||||
*/
|
||||
export async function formatLines(view: EditorView, from: number, to: number): Promise<boolean> {
|
||||
const doc = docOf(view);
|
||||
if (!doc || doc.readOnly) return false;
|
||||
const { project, settings } = useProject.getState();
|
||||
if (formatExcluded(doc.path, project?.root ?? null, formatGuardOptions(settings).doNotFormat)) return false;
|
||||
await whenSynced(view);
|
||||
const state = view.state;
|
||||
const range = { start: offsetToPos(state.doc, from), end: offsetToPos(state.doc, to) };
|
||||
const options = formatOptionsOf(state);
|
||||
let edits;
|
||||
try {
|
||||
edits = await lspRequest(() => commands.lspFormat(doc.doc, range, options));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (view.state.doc !== state.doc) return false;
|
||||
// Only inside the range: some servers answer a range request with whole-file edits.
|
||||
const changes = textEditsToChanges(state.doc, edits).filter((c) => c.from >= from && c.to <= to && state.sliceDoc(c.from, c.to) !== c.insert);
|
||||
if (!changes.length) return true;
|
||||
// A server formatting the range out of context (kotlin-language-server) moves
|
||||
// indented code to column 0: its answer is ignored then.
|
||||
const delta = changes.reduce((d, c) => d + c.insert.length - (c.to - c.from), 0);
|
||||
const next = state.update({ changes }).state.doc;
|
||||
if (dedentsToColumnZero(state.sliceDoc(from, to), next.sliceString(from, to + delta))) return false;
|
||||
view.dispatch({ changes, userEvent: "format", scrollIntoView: false });
|
||||
return true;
|
||||
}
|
||||
|
||||
function applyContributedFormat(
|
||||
view: EditorView,
|
||||
format: ContributedFormatter,
|
||||
|
||||
+29
-2
@@ -31,6 +31,7 @@ import { docWindowVisible, moveDocPopupToWindow, showInDocWindow } from "./docs"
|
||||
import { structure } from "@/editor/structure";
|
||||
import { basename } from "@/platform/project";
|
||||
import { iconNode } from "./iconMarkup";
|
||||
import { applyCodeAction, quickFixesAt } from "./intentions";
|
||||
import { editorPrefs } from "@/platform/editorSettings";
|
||||
import { CONTRIBUTED_HOVER, contributedAt, type ContributedHover } from "@/editor/api";
|
||||
import { navigationModifierHeld, onNavigationModifier, pointerTarget } from "@/platform/navigationModifier";
|
||||
@@ -50,7 +51,10 @@ export async function requestHover(view: EditorView, pos: number): Promise<Hover
|
||||
if (hoverRequest !== null && isTauri()) void commands.cancelRequest(hoverRequest);
|
||||
const id = (hoverRequest = nextRequestId());
|
||||
try {
|
||||
const h = await lspRequest(() => commands.lspHover(id, doc.doc, offsetToPos(view.state.doc, pos)));
|
||||
const h = await lspRequest(
|
||||
() => commands.lspHover(id, doc.doc, offsetToPos(view.state.doc, pos)),
|
||||
() => hoverRequest === id,
|
||||
);
|
||||
return h && !isEmptyMarkup(h.contents) ? h : null;
|
||||
} finally {
|
||||
if (hoverRequest === id) hoverRequest = null;
|
||||
@@ -60,7 +64,7 @@ export async function requestHover(view: EditorView, pos: number): Promise<Hover
|
||||
function renderDiagnostics(view: EditorView, diags: PlacedDiagnostic[]): HTMLElement {
|
||||
const box = document.createElement("div");
|
||||
box.className = "cm-lsp-hover-diagnostics";
|
||||
for (const { diagnostic: d, from } of diags) {
|
||||
for (const { diagnostic: d, from, to } of diags) {
|
||||
const row = document.createElement("div");
|
||||
row.className = `cm-lsp-diag cm-lsp-diag-${d.severity}`;
|
||||
row.appendChild(
|
||||
@@ -87,6 +91,7 @@ function renderDiagnostics(view: EditorView, diags: PlacedDiagnostic[]): HTMLEle
|
||||
fix.className = "cm-lsp-link-button";
|
||||
const keys = shortcutLabel("code.showIntentions");
|
||||
fix.textContent = keys ? `More actions… ${keys}` : "More actions…";
|
||||
if (d.severity === "error" || d.severity === "warning") offerQuickFix(view, meta, fix, from, to);
|
||||
fix.addEventListener("mousedown", (e) => e.preventDefault());
|
||||
fix.addEventListener("click", () => {
|
||||
view.dispatch({ selection: EditorSelection.cursor(from) });
|
||||
@@ -100,6 +105,28 @@ function renderDiagnostics(view: EditorView, diags: PlacedDiagnostic[]): HTMLEle
|
||||
return box;
|
||||
}
|
||||
|
||||
/** IDEA's hover link to the problem's preferred quick fix, shown once the server named one. */
|
||||
function offerQuickFix(view: EditorView, meta: HTMLElement, before: HTMLElement, from: number, to: number) {
|
||||
const doc = view.state.doc;
|
||||
void quickFixesAt(view, from, to)
|
||||
.then(([action]) => {
|
||||
if (!action || view.state.doc !== doc || !before.isConnected) return;
|
||||
const link = document.createElement("button");
|
||||
link.type = "button";
|
||||
link.className = "cm-lsp-link-button";
|
||||
const keys = shortcutLabel("code.applyQuickFix");
|
||||
link.textContent = keys ? `${action.title} ${keys}` : action.title;
|
||||
link.addEventListener("mousedown", (e) => e.preventDefault());
|
||||
link.addEventListener("click", () => {
|
||||
view.dispatch({ effects: closeHoverTooltips });
|
||||
view.focus();
|
||||
void applyCodeAction(view, action);
|
||||
});
|
||||
meta.insertBefore(link, before);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the plain hover (problems + quick documentation) stays out: while
|
||||
* ⌘/Ctrl is held the ⌘-hover link and its brief info (`navigation.ts`) own the
|
||||
|
||||
@@ -163,8 +163,9 @@ export async function requestInline(view: EditorView, automatic: boolean): Promi
|
||||
const id = (inflight = nextRequestId());
|
||||
const caret = sel.main.head;
|
||||
try {
|
||||
const items = await lspRequest(() =>
|
||||
commands.lspInlineCompletion(id, doc.doc, offsetToPos(state.doc, caret), automatic),
|
||||
const items = await lspRequest(
|
||||
() => commands.lspInlineCompletion(id, doc.doc, offsetToPos(state.doc, caret), automatic),
|
||||
() => inflight === id,
|
||||
);
|
||||
if (view.state.doc !== state.doc || !view.state.selection.eq(sel)) return false;
|
||||
const suggestions = toSuggestions(view.state, caret, items);
|
||||
|
||||
@@ -28,7 +28,8 @@ vi.mock("@/platform/ipc", async (orig) => {
|
||||
return { ...actual, isTauri: () => true, commands: { ...actual.commands, lspResolveCodeAction } };
|
||||
});
|
||||
|
||||
const { intentionPreview, withInspectionControls } = await import("./intentions");
|
||||
const { bulbLine, changedLines, indentUnderBrace, intentionPreview, lightsBulb, listedCodeActions, withInspectionControls } =
|
||||
await import("./intentions");
|
||||
const { setFileDiagnostics } = await import("./diagnostics");
|
||||
const { lspDoc } = await import("./lsp");
|
||||
|
||||
@@ -109,3 +110,71 @@ describe("intention preview", () => {
|
||||
expect(lspResolveCodeAction).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("listedCodeActions", () => {
|
||||
const action = (title: string, kind: string): CodeAction => ({ ...resolved, title, kind, edit: null });
|
||||
it("lists one action per title, quick fixes first", () => {
|
||||
const list = listedCodeActions([
|
||||
action("Generate toString()", "quickassist"),
|
||||
action("Add unimplemented methods", "quickfix"),
|
||||
action("Generate toString()", "source.generate.toString"),
|
||||
]);
|
||||
expect(list.map((a) => [a.title, a.kind])).toEqual([
|
||||
["Add unimplemented methods", "quickfix"],
|
||||
["Generate toString()", "quickassist"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("changedLines", () => {
|
||||
const text = (s: string) => EditorState.create({ doc: s }).doc;
|
||||
it("spans the lines an edit inserted", () => {
|
||||
expect(changedLines(text("class A {\n}\n"), text("class A {\n f() {\n }\n}\n"))).toEqual([{ from: 2, to: 3 }]);
|
||||
});
|
||||
it("keeps separate edits apart", () => {
|
||||
const before = text("package p;\n\nclass A {\n int a;\n int b;\n void f() {\n }\n}\n");
|
||||
const after = text("package p;\n\nimport java.util.List;\n\nclass A {\n int a;\n int b;\n void f() {\n List<String> x = null;\n x.size();\n }\n}\n");
|
||||
expect(changedLines(before, after)).toEqual([
|
||||
{ from: 3, to: 4 },
|
||||
{ from: 9, to: 10 },
|
||||
]);
|
||||
});
|
||||
it("is empty for equal texts", () => {
|
||||
expect(changedLines(text("a\nb"), text("a\nb"))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bulbLine", () => {
|
||||
const state = (s: string) => EditorState.create({ doc: s });
|
||||
it("prefers an indented caret line, then the lines around it", () => {
|
||||
expect(bulbLine(state(" x\ny"), 1)).toEqual({ line: 1, gutter: false });
|
||||
expect(bulbLine(state("x\n\ny"), 1)).toEqual({ line: 2, gutter: false });
|
||||
});
|
||||
it("falls back to the gutter when no nearby line has room", () => {
|
||||
expect(bulbLine(state("x\n y\nz"), 1)).toEqual({ line: 1, gutter: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("lightsBulb", () => {
|
||||
const kinds = (k: string | null) => lightsBulb({ ...resolved, kind: k });
|
||||
it("lights for fixes and refactorings only", () => {
|
||||
expect([null, "quickfix", "refactor.extract", "quickassist"].map(kinds)).toEqual([true, true, true, true]);
|
||||
expect(["source.organizeImports", "source.doc", "gopls.doc.features"].map(kinds)).toEqual([false, false, false]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("indentUnderBrace", () => {
|
||||
const text = (s: string) => EditorState.create({ doc: s }).doc;
|
||||
it("moves code generated at the brace's column one level in", () => {
|
||||
const doc = text("class A : B {\noverride fun f() {\n g()\n}\n}");
|
||||
expect(indentUnderBrace(doc, 2, 4, " ").map((c) => [doc.lineAt(c.from).number, c.insert])).toEqual([
|
||||
[2, " "],
|
||||
[3, " "],
|
||||
[4, " "],
|
||||
]);
|
||||
});
|
||||
it("leaves code already indented, or not under a brace", () => {
|
||||
expect(indentUnderBrace(text("class A {\n f()\n}"), 2, 2, " ")).toEqual([]);
|
||||
expect(indentUnderBrace(text("class A:\npass\nx"), 2, 3, " ")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
+166
-21
@@ -3,15 +3,19 @@
|
||||
* intentions popup (quick fixes first, preferred in bold), Optimize Imports and
|
||||
* Generate. Refactor This and the Refactor menu live in `@/editor/refactor`.
|
||||
*/
|
||||
import { StateEffect, StateField, type Extension } from "@codemirror/state";
|
||||
import { StateEffect, StateField, type Extension, type Text } from "@codemirror/state";
|
||||
import { EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view";
|
||||
import { forEachDiagnostic } from "@codemirror/lint";
|
||||
import { indentUnit } from "@codemirror/language";
|
||||
import { diff } from "@codemirror/merge";
|
||||
import { commands, isCancelled, type CodeAction } from "@/platform/ipc";
|
||||
import { contributedIntentions, whenSynced } from "@/editor/api";
|
||||
import { contributedIntentions, intentionProviders, whenSynced } from "@/editor/api";
|
||||
import { shortcutLabel } from "@/platform/keymap";
|
||||
import { debounce, docOf, isUnavailable, lspRequest, reportLspError } from "./lsp";
|
||||
import { offsetsToRange } from "./positions";
|
||||
import { showHint } from "./hint";
|
||||
import { formatLines } from "./format";
|
||||
import { pythonMemberIntentions } from "./pythonMembers";
|
||||
import { caretAnchor, openPopup, type ListPopupItem, type ListPreview } from "./popupStore";
|
||||
import { editPreviewLines } from "./workspaceEdit";
|
||||
import { serverBackoff } from "./availability";
|
||||
@@ -88,6 +92,43 @@ async function fetchIntentions(view: EditorView): Promise<CodeAction[]> {
|
||||
return actions;
|
||||
}
|
||||
|
||||
/** Enabled quick fixes of the problem at `from..to`, preferred first. */
|
||||
export async function quickFixesAt(view: EditorView, from: number, to: number): Promise<CodeAction[]> {
|
||||
const doc = docOf(view);
|
||||
if (!doc) return [];
|
||||
await whenSynced(view);
|
||||
const actions = await lspRequest(() => commands.lspCodeActions(doc.doc, offsetsToRange(view.state.doc, from, to), ["quickfix"]));
|
||||
return sortCodeActions(actions.filter((a) => isQuickFix(a) && !a.disabledReason));
|
||||
}
|
||||
|
||||
/**
|
||||
* ⌥⇧↵: applies the preferred (else first) quick fix of the most severe problem
|
||||
* at the caret, or of one elsewhere on the caret line.
|
||||
*/
|
||||
export async function applyQuickFix(view: EditorView) {
|
||||
const state = view.state;
|
||||
const caret = state.selection.main.head;
|
||||
const line = state.doc.lineAt(caret);
|
||||
const problems: Array<PlacedProblem & { rank: number }> = [];
|
||||
forEachDiagnostic(state, (d, from, to) => {
|
||||
if (d.severity === "hint" || to < line.from || from > line.to) return;
|
||||
const rank = (d.severity === "error" ? 0 : d.severity === "warning" ? 1 : 2) + (from <= caret && caret <= to ? 0 : 3);
|
||||
problems.push({ from, to, severity: d.severity, rank });
|
||||
});
|
||||
problems.sort((a, b) => a.rank - b.rank);
|
||||
try {
|
||||
for (const p of problems) {
|
||||
const fix = (await quickFixesAt(view, p.from, p.to))[0];
|
||||
if (view.state.doc !== state.doc) return;
|
||||
if (fix) return applyCodeAction(view, fix);
|
||||
}
|
||||
} catch (e) {
|
||||
reportLspError(view, e, true);
|
||||
return;
|
||||
}
|
||||
showHint(view, "No quick fixes available");
|
||||
}
|
||||
|
||||
export async function applyCodeAction(view: EditorView, action: CodeAction) {
|
||||
const doc = docOf(view);
|
||||
if (!doc) return;
|
||||
@@ -95,11 +136,82 @@ export async function applyCodeAction(view: EditorView, action: CodeAction) {
|
||||
showHint(view, action.disabledReason);
|
||||
return;
|
||||
}
|
||||
const before = view.state.doc;
|
||||
try {
|
||||
await lspRequest(() => commands.lspApplyCodeAction(doc.doc, action));
|
||||
} catch (e) {
|
||||
reportLspError(view, e, true);
|
||||
return;
|
||||
}
|
||||
if (!action.kind?.startsWith("source.organizeImports") && !action.kind?.startsWith("source.fixAll"))
|
||||
await reformatGenerated(view, before);
|
||||
}
|
||||
|
||||
/**
|
||||
* Code the server generated follows its own indentation (jdtls tabs,
|
||||
* tsserver 4 spaces); like IDEA, the lines an action inserted are reformatted
|
||||
* to the file's style.
|
||||
*/
|
||||
async function reformatGenerated(view: EditorView, before: Text) {
|
||||
for (let i = 0; i < 100 && view.state.doc === before; i++) await new Promise((r) => setTimeout(r, 20));
|
||||
// Bottom up, so the line numbers of the hunks above stay valid.
|
||||
for (const lines of changedLines(before, view.state.doc).reverse()) {
|
||||
if (lines.to - lines.from < 1) continue;
|
||||
const doc = view.state.doc;
|
||||
const shift = indentUnderBrace(doc, lines.from, lines.to, view.state.facet(indentUnit));
|
||||
// A server that generated code without its context formats ranges the same
|
||||
// way (back to column 0): its range formatting is skipped then.
|
||||
if (shift.length) view.dispatch({ changes: shift, userEvent: "format", scrollIntoView: false });
|
||||
else if (!(await formatLines(view, doc.line(lines.from).from, doc.line(lines.to).to)) && view.state.doc !== doc) return;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generated lines right under a `{` line but not indented past it (servers
|
||||
* that format the snippet on its own, kotlin-language-server): the block moves
|
||||
* one level in, keeping its own nesting. Never applies to indentation-based
|
||||
* languages, whose blocks don't open with `{`.
|
||||
*/
|
||||
export function indentUnderBrace(doc: Text, fromLine: number, toLine: number, unit: string): Array<{ from: number; insert: string }> {
|
||||
if (fromLine < 2) return [];
|
||||
const prev = doc.line(fromLine - 1).text;
|
||||
if (!prev.trimEnd().endsWith("{")) return [];
|
||||
const lead = (t: string) => t.slice(0, t.length - t.trimStart().length);
|
||||
const target = lead(prev) + unit;
|
||||
let first: string | null = null;
|
||||
for (let n = fromLine; n <= toLine && first === null; n++) if (doc.line(n).text.trim()) first = lead(doc.line(n).text);
|
||||
if (first === null || first.length > lead(prev).length || !target.startsWith(first)) return [];
|
||||
const extra = target.slice(first.length);
|
||||
const out: Array<{ from: number; insert: string }> = [];
|
||||
for (let n = fromLine; n <= toLine; n++) {
|
||||
const line = doc.line(n);
|
||||
if (line.text.trim()) out.push({ from: line.from, insert: extra });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1-based line spans of `after` that an edit changed, top to bottom: an added
|
||||
* import and the code using it are separate spans, so the user's code between
|
||||
* them is left alone.
|
||||
*/
|
||||
export function changedLines(before: Text, after: Text): Array<{ from: number; to: number }> {
|
||||
if (before.eq(after)) return [];
|
||||
const spans: Array<{ from: number; to: number }> = [];
|
||||
const a = before.toString();
|
||||
const b = after.toString();
|
||||
for (const c of diff(a, b)) {
|
||||
// Text inserted at a line's end or start (`{⏎ added`, `added⏎`) leaves that line as it was.
|
||||
const lead = b[c.fromB] === "\n" && (c.toA === c.fromA || a[c.fromA] === "\n");
|
||||
const trail = c.toB > c.fromB && b[c.toB - 1] === "\n" && (c.toA === c.fromA || a[c.toA - 1] === "\n");
|
||||
const from = after.lineAt(lead ? c.fromB + 1 : c.fromB).number;
|
||||
const end = trail ? c.toB - 1 : c.toB;
|
||||
const to = Math.max(from, after.lineAt(end).number);
|
||||
const last = spans[spans.length - 1];
|
||||
if (last && from <= last.to + 1) last.to = Math.max(last.to, to);
|
||||
else spans.push({ from, to });
|
||||
}
|
||||
return spans;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -127,8 +239,17 @@ export async function applyKindSilently(view: EditorView, kind: string): Promise
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorted, one action per title: servers can report the same action under two
|
||||
* kinds (jdtls: "Generate toString()" as quickassist and source.generate).
|
||||
*/
|
||||
export function listedCodeActions(actions: readonly CodeAction[]): CodeAction[] {
|
||||
const seen = new Set<string>();
|
||||
return sortCodeActions(actions).filter((a) => !seen.has(a.title) && !!seen.add(a.title));
|
||||
}
|
||||
|
||||
function actionItems(view: EditorView, actions: CodeAction[], resolved?: Map<CodeAction, Promise<CodeAction>>): ListPopupItem[] {
|
||||
return sortCodeActions(actions).map((a, i) => ({
|
||||
return listedCodeActions(actions).map((a, i) => ({
|
||||
id: `${i}`,
|
||||
label: a.title,
|
||||
icon: isQuickFix(a) ? "bulb-fix" : "bulb",
|
||||
@@ -217,7 +338,7 @@ export async function showIntentions(view: EditorView) {
|
||||
} catch (e) {
|
||||
failure = { error: e };
|
||||
}
|
||||
const sorted = sortCodeActions(actions);
|
||||
const sorted = listedCodeActions(actions);
|
||||
const resolved = new Map<CodeAction, Promise<CodeAction>>();
|
||||
const items = withInspectionControls(view, [...local, ...actionItems(view, sorted, resolved)]);
|
||||
if (!items.length) {
|
||||
@@ -305,6 +426,16 @@ function noActionReason(view: EditorView, none: string): string {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lightbulb
|
||||
|
||||
/**
|
||||
* Fixes and refactorings light the bulb; source actions (organize imports,
|
||||
* generate…) and server extras (gopls "Browse documentation") are offered on
|
||||
* almost every line and only appear in the ⌥↵ list.
|
||||
*/
|
||||
export function lightsBulb(a: CodeAction): boolean {
|
||||
const kind = a.kind ?? "";
|
||||
return !kind || kind === "quickassist" || /^(quickfix|refactor)(\.|$)/.test(kind);
|
||||
}
|
||||
|
||||
interface Bulb {
|
||||
line: number;
|
||||
quickFix: boolean;
|
||||
@@ -331,11 +462,19 @@ const bulbPlugin = ViewPlugin.fromClass(
|
||||
this.lookup();
|
||||
}
|
||||
update(u: ViewUpdate) {
|
||||
if (u.docChanged || u.selectionSet) this.lookup();
|
||||
if (u.docChanged || u.selectionSet || (u.focusChanged && u.view.hasFocus)) this.lookup();
|
||||
else if (u.focusChanged) {
|
||||
this.lookup.cancel();
|
||||
// A blur dispatched from inside the update is not allowed.
|
||||
queueMicrotask(() => {
|
||||
if (!this.view.hasFocus && this.view.state.field(bulbField, false)) this.view.dispatch({ effects: setBulb.of(null) });
|
||||
});
|
||||
}
|
||||
}
|
||||
async run() {
|
||||
const doc = docOf(this.view);
|
||||
if (!doc || doc.readOnly || !this.view.dom.isConnected) return;
|
||||
// As in IDEA, only the focused editor shows the bulb.
|
||||
if (!doc || doc.readOnly || !this.view.dom.isConnected || !this.view.hasFocus) return;
|
||||
const state = this.view.state;
|
||||
const typoFix = spellingIntentions(this.view).length > 0;
|
||||
const local = typoFix || contributedIntentions(this.view).length > 0;
|
||||
@@ -350,9 +489,9 @@ const bulbPlugin = ViewPlugin.fromClass(
|
||||
if (!local) return;
|
||||
}
|
||||
}
|
||||
if (this.view.state.doc !== state.doc || !this.view.state.selection.eq(state.selection)) return;
|
||||
if (this.view.state.doc !== state.doc || !this.view.state.selection.eq(state.selection) || !this.view.hasFocus) return;
|
||||
const line = state.doc.lineAt(state.selection.main.head).number;
|
||||
const any = actions.length > 0 || local;
|
||||
const any = actions.some(lightsBulb) || local;
|
||||
this.view.dispatch({
|
||||
effects: setBulb.of(any ? { line, quickFix: typoFix || actions.some(isQuickFix) } : null),
|
||||
});
|
||||
@@ -365,21 +504,20 @@ const bulbPlugin = ViewPlugin.fromClass(
|
||||
|
||||
/**
|
||||
* New-UI bulb: floats at the start of the text on the caret line, or on the
|
||||
* line below / above when the caret line's text starts at the left edge.
|
||||
* line below / above when the caret line's text starts at the left edge; in
|
||||
* the gutter when none of them has room.
|
||||
*/
|
||||
const BULB_COLUMNS = 3;
|
||||
|
||||
function bulbLine(state: EditorView["state"], line: number): number {
|
||||
export function bulbLine(state: EditorView["state"], line: number): { line: number; gutter: boolean } {
|
||||
const doc = state.doc;
|
||||
const free = (n: number) => {
|
||||
if (n < 1 || n > doc.lines) return false;
|
||||
const text = doc.line(n).text;
|
||||
return text.length - text.trimStart().length >= BULB_COLUMNS || !text.trim();
|
||||
};
|
||||
if (free(line)) return line;
|
||||
if (free(line + 1)) return line + 1;
|
||||
if (free(line - 1)) return line - 1;
|
||||
return line;
|
||||
for (const n of [line, line + 1, line - 1]) if (free(n)) return { line: n, gutter: false };
|
||||
return { line, gutter: true };
|
||||
}
|
||||
|
||||
class FloatingBulb {
|
||||
@@ -393,6 +531,7 @@ class FloatingBulb {
|
||||
this.dom.setAttribute("aria-label", "Show intentions");
|
||||
this.dom.style.display = "none";
|
||||
this.dom.appendChild(iconNode("bulb", 16));
|
||||
this.dom.appendChild(iconNode("bulb-fix", 16, "cm-lsp-bulb-fix-icon"));
|
||||
this.dom.addEventListener("mousedown", (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -420,18 +559,24 @@ class FloatingBulb {
|
||||
this.view.requestMeasure({
|
||||
key: this,
|
||||
read: (view) => {
|
||||
const line = view.state.doc.line(bulbLine(view.state, bulb.line));
|
||||
const block = view.lineBlockAt(line.from);
|
||||
const at = bulbLine(view.state, bulb.line);
|
||||
const line = view.state.doc.line(at.line);
|
||||
const content = view.contentDOM.getBoundingClientRect();
|
||||
const scroller = view.scrollDOM.getBoundingClientRect();
|
||||
// The text row: the line's block also holds inline widgets above it (code vision).
|
||||
const coords = view.coordsAtPos(line.from, 1);
|
||||
const block = view.lineBlockAt(line.from);
|
||||
const textTop = coords ? coords.top - scroller.top : block.top + view.documentTop - scroller.top;
|
||||
return {
|
||||
top: block.top + (view.documentTop - scroller.top) + view.scrollDOM.scrollTop,
|
||||
left: content.left - scroller.left + view.scrollDOM.scrollLeft + 4,
|
||||
height: block.height,
|
||||
top: textTop + view.scrollDOM.scrollTop,
|
||||
left: content.left - scroller.left + view.scrollDOM.scrollLeft + (at.gutter ? -20 : 4),
|
||||
height: coords ? coords.bottom - coords.top : block.height,
|
||||
gutter: at.gutter,
|
||||
};
|
||||
},
|
||||
write: ({ top, left, height }) => {
|
||||
write: ({ top, left, height, gutter }) => {
|
||||
this.dom.style.display = "";
|
||||
this.dom.classList.toggle("cm-lsp-bulb-gutter", gutter);
|
||||
this.dom.style.top = `${top}px`;
|
||||
this.dom.style.left = `${left}px`;
|
||||
this.dom.style.height = `${Math.min(height, this.view.defaultLineHeight)}px`;
|
||||
@@ -447,5 +592,5 @@ class FloatingBulb {
|
||||
const floatingBulb = ViewPlugin.fromClass(FloatingBulb);
|
||||
|
||||
export function intentionsExtension(): Extension {
|
||||
return [bulbField, bulbPlugin, floatingBulb];
|
||||
return [bulbField, bulbPlugin, floatingBulb, intentionProviders.of(pythonMemberIntentions)];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { _addEditor, _removeEditor, documentSynced, type EditorDocContext } from "@/editor/api";
|
||||
|
||||
vi.mock("@/platform/ipc", async (orig) => {
|
||||
const actual = await orig<typeof import("@/platform/ipc")>();
|
||||
return { ...actual, isTauri: () => true };
|
||||
});
|
||||
|
||||
const { lspRequest } = await import("./lsp");
|
||||
|
||||
describe("lspRequest", () => {
|
||||
it("waits until open editors' edits reached the backend", async () => {
|
||||
let synced!: () => void;
|
||||
const pending = new Promise<void>((r) => (synced = r));
|
||||
const view = new EditorView({ state: EditorState.create({ extensions: documentSynced.of(() => pending) }) });
|
||||
const handle = { view, ctx: {} as EditorDocContext };
|
||||
_addEditor(handle);
|
||||
const run = vi.fn(async () => ({ status: "ok" as const, data: 1 }));
|
||||
const result = lspRequest(run);
|
||||
await Promise.resolve();
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
synced();
|
||||
expect(await result).toBe(1);
|
||||
_removeEditor(handle);
|
||||
view.destroy();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
/** Shared plumbing: per-editor document facet, IPC wrapper and error policy. */
|
||||
import { Facet } from "@codemirror/state";
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import type { EditorDocContext } from "@/editor/api";
|
||||
import { whenAllSynced, type EditorDocContext } from "@/editor/api";
|
||||
import { IpcError, call, isCancelled, isTauri } from "@/platform/ipc";
|
||||
import type { IdeError } from "@/bindings";
|
||||
import { showHint } from "./hint";
|
||||
@@ -20,10 +20,15 @@ type Result<T> = { status: "ok"; data: T } | { status: "error"; error: IdeError
|
||||
/**
|
||||
* Runs an LSP command. Synchronous throws (no Tauri runtime in a plain browser)
|
||||
* become `unavailable` rejections so callers have a single error path.
|
||||
* `wanted` is checked once pending edits are synced: a request cancelled
|
||||
* meanwhile is not sent (the backend ignores cancelling an id it hasn't seen).
|
||||
*/
|
||||
export async function lspRequest<T>(run: () => Promise<Result<T>>): Promise<T> {
|
||||
export async function lspRequest<T>(run: () => Promise<Result<T>>, wanted?: () => boolean): Promise<T> {
|
||||
if (!isTauri()) throw new IpcError({ kind: "unavailable", message: "No language server available" });
|
||||
try {
|
||||
// Edits typed just before (⌘B right after typing) must reach the server first.
|
||||
await whenAllSynced();
|
||||
if (wanted && !wanted()) throw new IpcError({ kind: "cancelled", message: "cancelled" });
|
||||
return await call(run());
|
||||
} catch (e) {
|
||||
if (e instanceof IpcError) throw e;
|
||||
|
||||
@@ -128,6 +128,22 @@ export function pinToFind(symbol: string, locations: readonly Location[]) {
|
||||
};
|
||||
}
|
||||
|
||||
/** When each editor's document last changed (`performance.now()`). */
|
||||
const editedAt = new WeakMap<EditorView, number>();
|
||||
|
||||
/**
|
||||
* Some servers analyse an edit asynchronously and answer from the previous
|
||||
* version meanwhile (kotlin-language-server: nothing for ~300 ms after typing).
|
||||
* An empty answer right after an edit is asked again a few times.
|
||||
*/
|
||||
async function gotoAfterEdits(view: EditorView, request: () => Promise<DeclarationTarget[]>) {
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
const locs = await request();
|
||||
if (locs.length || attempt >= 3 || performance.now() - (editedAt.get(view) ?? -Infinity) > 1500) return locs;
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
}
|
||||
}
|
||||
|
||||
export async function gotoTargets(view: EditorView, kind: GotoKind, at?: number) {
|
||||
const offset = at ?? view.state.selection.main.head;
|
||||
if (kind === "definition" || kind === "declaration") {
|
||||
@@ -142,7 +158,7 @@ export async function gotoTargets(view: EditorView, kind: GotoKind, at?: number)
|
||||
const declaration = kind === "definition" || kind === "declaration";
|
||||
let serverDown = false;
|
||||
try {
|
||||
locs = dedupeLocations(await lspRequest(() => commands.lspGoto(doc.doc, pos, kind)));
|
||||
locs = dedupeLocations(await gotoAfterEdits(view, () => lspRequest(() => commands.lspGoto(doc.doc, pos, kind))));
|
||||
} catch (e) {
|
||||
if (isCancelled(e) || !declaration) {
|
||||
reportLspError(view, e, true);
|
||||
@@ -630,6 +646,9 @@ const highlightPlugin = ViewPlugin.fromClass(
|
||||
|
||||
export function navigationExtension(): Extension {
|
||||
return [
|
||||
EditorView.updateListener.of((u) => {
|
||||
if (u.docChanged) editedAt.set(u.view, performance.now());
|
||||
}),
|
||||
linkField,
|
||||
linkPlugin,
|
||||
highlightField,
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { classMembers, enclosingClass, memberStubs } from "./pythonMembers";
|
||||
|
||||
const src = `from abc import ABC, abstractmethod
|
||||
|
||||
|
||||
class Shape(ABC):
|
||||
@property
|
||||
@abstractmethod
|
||||
def name(self) -> str: ...
|
||||
|
||||
@abstractmethod
|
||||
def area(
|
||||
self, scale: float = 1.0
|
||||
) -> float:
|
||||
"""Area."""
|
||||
|
||||
def describe(self) -> str:
|
||||
return self.name
|
||||
|
||||
|
||||
class Square(Shape):
|
||||
def __init__(self, side: float):
|
||||
self.side = side
|
||||
`.split("\n");
|
||||
|
||||
describe("python members", () => {
|
||||
it("finds the class enclosing a line", () => {
|
||||
expect(enclosingClass(src, 4)).toBe(4);
|
||||
expect(enclosingClass(src, 13)).toBe(4);
|
||||
expect(enclosingClass(src, 21)).toBe(19);
|
||||
expect(enclosingClass(src, 1)).toBeNull();
|
||||
});
|
||||
|
||||
it("lists methods with their decorators and signatures", () => {
|
||||
const members = classMembers(src, 4);
|
||||
expect(members.map((m) => [m.name, m.abstract])).toEqual([
|
||||
["name", true],
|
||||
["area", true],
|
||||
["describe", false],
|
||||
]);
|
||||
expect(members[0]!.lines).toEqual(["@property", "def name(self) -> str:"]);
|
||||
expect(members[1]!.lines).toEqual(["def area(", " self, scale: float = 1.0", ") -> float:"]);
|
||||
});
|
||||
|
||||
it("stops at a nested class without bases", () => {
|
||||
const nested = ["class Outer(Base):", " class Inner:", " x = 1", ""];
|
||||
expect(enclosingClass(nested, 3)).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps a body going through an unindented string", () => {
|
||||
const lines = [
|
||||
"class A(ABC):",
|
||||
" @abc.abstractproperty",
|
||||
" def doc(self):",
|
||||
' return """',
|
||||
"text",
|
||||
'"""',
|
||||
" @abstractmethod",
|
||||
" def run(self): ...",
|
||||
"x = 1",
|
||||
];
|
||||
const members = classMembers(lines, 1);
|
||||
expect(members.map((m) => m.lines)).toEqual([["@property", "def doc(self):"], ["def run(self):"]]);
|
||||
expect(members.every((m) => m.abstract)).toBe(true);
|
||||
});
|
||||
|
||||
it("writes stubs at the body's indentation", () => {
|
||||
const [name] = classMembers(src, 4);
|
||||
expect(memberStubs([name!], " ", " ")).toBe(" @property\n def name(self) -> str:\n pass");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* Python "Implement abstract methods": Python servers (pyright, basedpyright)
|
||||
* offer no such code action. The bases of the class at the caret are resolved
|
||||
* with Go to Declaration; their `@abstractmethod`s the class lacks get stubs
|
||||
* at the end of its body.
|
||||
*/
|
||||
import { indentUnit } from "@codemirror/language";
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import { commands } from "@/platform/ipc";
|
||||
import type { ContributedIntention } from "@/editor/api";
|
||||
import { docOf, lspRequest, reportLspError } from "./lsp";
|
||||
import { offsetToPos } from "./positions";
|
||||
import { readFileLines } from "./files";
|
||||
import { showHint } from "./hint";
|
||||
|
||||
const CLASS_HEADER = /^(\s*)class\s+\w+\s*\((.*)\)\s*:/;
|
||||
const CLASS = /^\s*class\s/;
|
||||
const DEF = /^\s*(?:async\s+)?def\s+(\w+)/;
|
||||
|
||||
const indentOf = (line: string) => line.length - line.trimStart().length;
|
||||
const isPython = (view: EditorView) => docOf(view)?.uiMode === "Python";
|
||||
|
||||
/** 1-based number of the class header line enclosing `line` (or on it); null unless it has bases. */
|
||||
export function enclosingClass(lines: readonly string[], line: number): number | null {
|
||||
let limit = Infinity;
|
||||
for (let n = line; n >= 1; n--) {
|
||||
const text = lines[n - 1]!;
|
||||
if (!text.trim()) continue;
|
||||
const indent = indentOf(text);
|
||||
if (n !== line && indent >= limit) continue;
|
||||
if (CLASS.test(text)) return CLASS_HEADER.test(text) ? n : null;
|
||||
limit = Math.min(limit, indent);
|
||||
if (indent === 0 && n !== line) return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Last 1-based line of the body of the class headed at `header`. */
|
||||
function bodyEnd(lines: readonly string[], header: number): number {
|
||||
const indent = indentOf(lines[header - 1]!);
|
||||
let last = header;
|
||||
let quote: string | null = null;
|
||||
for (let n = header + 1; n <= lines.length; n++) {
|
||||
const text = lines[n - 1]!;
|
||||
// Lines inside a triple-quoted string belong to it, however indented.
|
||||
const inString = quote !== null;
|
||||
for (const q of text.match(/"""|'''/g) ?? []) {
|
||||
if (quote === null) quote = q;
|
||||
else if (q === quote) quote = null;
|
||||
}
|
||||
if (inString) {
|
||||
last = n;
|
||||
continue;
|
||||
}
|
||||
if (!text.trim()) continue;
|
||||
if (indentOf(text) <= indent) break;
|
||||
last = n;
|
||||
}
|
||||
return last;
|
||||
}
|
||||
|
||||
export interface PythonMember {
|
||||
name: string;
|
||||
abstract: boolean;
|
||||
/** Decorators (without `@abstractmethod`) and the signature, dedented. */
|
||||
lines: string[];
|
||||
}
|
||||
|
||||
/** Methods declared directly in the class headed at `header` (1-based). */
|
||||
export function classMembers(lines: readonly string[], header: number): PythonMember[] {
|
||||
const end = bodyEnd(lines, header);
|
||||
const out: PythonMember[] = [];
|
||||
let memberIndent = -1;
|
||||
let decorators: string[] = [];
|
||||
for (let n = header + 1; n <= end; n++) {
|
||||
const text = lines[n - 1]!;
|
||||
if (!text.trim()) continue;
|
||||
const indent = indentOf(text);
|
||||
if (memberIndent < 0) memberIndent = indent;
|
||||
if (indent !== memberIndent) continue;
|
||||
const trimmed = text.trim();
|
||||
if (trimmed.startsWith("@")) {
|
||||
decorators.push(trimmed);
|
||||
continue;
|
||||
}
|
||||
const def = DEF.exec(text);
|
||||
if (def) {
|
||||
// The signature may span lines: up to the `:` closing it.
|
||||
const sig: string[] = [];
|
||||
let depth = 0;
|
||||
for (let m = n; m <= end; m++) {
|
||||
const part = lines[m - 1]!;
|
||||
sig.push(part.slice(Math.min(memberIndent, indentOf(part))));
|
||||
for (const ch of part) depth += ch === "(" || ch === "[" ? 1 : ch === ")" || ch === "]" ? -1 : 0;
|
||||
if (depth <= 0 && /:\s*(#.*)?$|:\s*\.\.\.\s*$|:\s*\S/.test(part)) break;
|
||||
}
|
||||
// `def f(self) -> int: ...` keeps only the signature.
|
||||
sig[sig.length - 1] = sig[sig.length - 1]!.replace(/:\s*(\.\.\.|pass)?\s*(#.*)?$/, ":");
|
||||
const abstract = decorators.some((d) => /^@(abc\.)?abstract(method|property)\b/.test(d));
|
||||
out.push({
|
||||
name: def[1]!,
|
||||
abstract,
|
||||
lines: [
|
||||
...decorators
|
||||
.filter((d) => !/^@(abc\.)?abstractmethod\b/.test(d))
|
||||
.map((d) => d.replace(/^@(abc\.)?abstractproperty\b/, "@property")),
|
||||
...sig,
|
||||
],
|
||||
});
|
||||
}
|
||||
decorators = [];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Base class names of a header and where each starts in the line. */
|
||||
function bases(header: string): Array<{ name: string; col: number }> {
|
||||
const m = CLASS_HEADER.exec(header);
|
||||
if (!m) return [];
|
||||
const start = header.indexOf("(") + 1;
|
||||
const out: Array<{ name: string; col: number }> = [];
|
||||
for (const b of m[2]!.matchAll(/(?:^|,)\s*([A-Za-z_][\w.]*)\s*(?=,|$|\[)/g)) {
|
||||
const name = b[1]!;
|
||||
if (name === "object" || name.includes("=")) continue;
|
||||
// The last segment of `abc.ABC`: that's what Go to Declaration resolves.
|
||||
const col = start + b.index! + b[0].indexOf(name) + name.lastIndexOf(".") + 1;
|
||||
out.push({ name, col });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Stub text for `members`, indented by `indent`. */
|
||||
export function memberStubs(members: readonly PythonMember[], indent: string, unit: string): string {
|
||||
return members.map((m) => [...m.lines.map((l) => indent + l), `${indent}${unit}pass`].join("\n")).join("\n\n");
|
||||
}
|
||||
|
||||
async function abstractMembersOfBases(view: EditorView, header: number): Promise<PythonMember[]> {
|
||||
const doc = docOf(view)!;
|
||||
const state = view.state;
|
||||
const line = state.doc.line(header);
|
||||
const out = new Map<string, PythonMember>();
|
||||
for (const base of bases(line.text)) {
|
||||
const pos = offsetToPos(state.doc, line.from + base.col);
|
||||
const [loc] = await lspRequest(() => commands.lspGoto(doc.doc, pos, "definition"));
|
||||
if (!loc) continue;
|
||||
const lines = await readFileLines(loc.path);
|
||||
if (!lines) continue;
|
||||
const target = enclosingClass(lines, loc.range.start.line + 1);
|
||||
if (!target) continue;
|
||||
for (const m of classMembers(lines, target)) if (m.abstract && !out.has(m.name)) out.set(m.name, m);
|
||||
}
|
||||
return [...out.values()];
|
||||
}
|
||||
|
||||
/** ⌃I / the intention: stubs for the abstract methods of the bases the class lacks. */
|
||||
export async function implementPythonAbstractMethods(view: EditorView): Promise<boolean> {
|
||||
if (!isPython(view)) return false;
|
||||
const lines = view.state.doc.toJSON();
|
||||
const header = enclosingClass(lines, view.state.doc.lineAt(view.state.selection.main.head).number);
|
||||
if (!header) return false;
|
||||
let abstract: PythonMember[];
|
||||
try {
|
||||
abstract = await abstractMembersOfBases(view, header);
|
||||
} catch (e) {
|
||||
reportLspError(view, e, true);
|
||||
return true;
|
||||
}
|
||||
const state = view.state;
|
||||
if (state.doc.toJSON().join("\n") !== lines.join("\n")) return true;
|
||||
const own = new Set(classMembers(lines, header).map((m) => m.name));
|
||||
const missing = abstract.filter((m) => !own.has(m.name));
|
||||
if (!missing.length) {
|
||||
showHint(view, "No abstract methods to implement");
|
||||
return true;
|
||||
}
|
||||
const end = bodyEnd(lines, header);
|
||||
const unit = state.facet(indentUnit) || " ";
|
||||
const bodyLines = [];
|
||||
for (let n = header + 1; n <= end; n++) if (lines[n - 1]!.trim()) bodyLines.push(n);
|
||||
const first = bodyLines[0] ? lines[bodyLines[0] - 1]! : null;
|
||||
const indent = first
|
||||
? first.slice(0, indentOf(first))
|
||||
: lines[header - 1]!.slice(0, indentOf(lines[header - 1]!)) + unit;
|
||||
const stubs = memberStubs(missing, indent, unit);
|
||||
// A body that is only `pass` / `...` is replaced.
|
||||
const placeholder = bodyLines.length === 1 && /^\s*(pass|\.\.\.)\s*$/.test(first!);
|
||||
const changes = !bodyLines.length
|
||||
? { from: state.doc.line(header).to, insert: `\n${stubs}` }
|
||||
: placeholder
|
||||
? { from: state.doc.line(bodyLines[0]!).from, to: state.doc.line(end).to, insert: stubs }
|
||||
: { from: state.doc.line(end).to, insert: `\n\n${stubs}` };
|
||||
view.dispatch({ changes, userEvent: "input.complete", scrollIntoView: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
/** The bulb on a Python class header with bases. */
|
||||
export function pythonMemberIntentions(view: EditorView): ContributedIntention[] {
|
||||
if (!isPython(view)) return [];
|
||||
const line = view.state.doc.lineAt(view.state.selection.main.head);
|
||||
if (!bases(line.text).length) return [];
|
||||
return [
|
||||
{
|
||||
id: "python.implementAbstract",
|
||||
label: "Implement abstract methods",
|
||||
icon: "bulb",
|
||||
run: () => void implementPythonAbstractMethods(view),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -107,6 +107,25 @@ async function fetchSignature(view: EditorView, explicit: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The caret was left inside the `()` of a completed call whose snippet doesn't
|
||||
* say whether it takes arguments (jdtls `new ArrayList<>()`): parameter info
|
||||
* when some signature has parameters, else the caret moves past `)`.
|
||||
*/
|
||||
export async function parameterInfoOrLeave(view: EditorView, inside: number) {
|
||||
const doc = docOf(view);
|
||||
const state = view.state;
|
||||
let help: SignatureHelp | null = null;
|
||||
try {
|
||||
if (doc) help = await lspRequest(() => commands.lspSignatureHelp(doc.doc, offsetToPos(state.doc, inside)));
|
||||
} catch {
|
||||
// No signature help: treated as nothing to fill in.
|
||||
}
|
||||
if (view.state.doc !== state.doc || view.state.selection.main.head !== inside) return;
|
||||
if (help?.signatures.some((sig) => sig.parameters.length)) view.dispatch({ effects: setSignature.of({ pos: inside, help }) });
|
||||
else view.dispatch({ selection: EditorSelection.cursor(inside + 1) });
|
||||
}
|
||||
|
||||
/** ⌘P or the completion command `editor.action.triggerParameterHints`. */
|
||||
export function triggerSignatureHelp(view: EditorView, explicit = false) {
|
||||
void fetchSignature(view, explicit);
|
||||
|
||||
@@ -447,6 +447,7 @@ describe("modifier-hover navigation recovery", () => {
|
||||
await vi.advanceTimersByTimeAsync(3001);
|
||||
cmd.lspGoto.mockReturnValueOnce(ok([target]));
|
||||
move(view);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(cmd.lspGoto).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
|
||||
@@ -202,7 +202,8 @@ export const lspTheme = EditorView.theme({
|
||||
".cm-lsp-diag-warning .cm-lsp-diag-icon": { color: "var(--warning)" },
|
||||
".cm-lsp-diag-info .cm-lsp-diag-icon, .cm-lsp-diag-hint .cm-lsp-diag-icon": { color: "var(--info)" },
|
||||
".cm-lsp-diag-message": { color: "var(--fg-strong)", whiteSpace: "pre-wrap", lineHeight: "18px" },
|
||||
".cm-lsp-diag-meta": { display: "flex", gap: "12px", marginTop: "6px", alignItems: "baseline" },
|
||||
".cm-lsp-diag-meta": { display: "flex", flexWrap: "wrap", columnGap: "12px", rowGap: "2px", marginTop: "6px", alignItems: "baseline" },
|
||||
".cm-lsp-diag-meta > *": { whiteSpace: "nowrap" },
|
||||
".cm-lsp-diag-source": { color: "var(--fg-subtle)", fontSize: "var(--fs-small)" },
|
||||
".cm-lsp-link-button": {
|
||||
border: "none",
|
||||
@@ -311,9 +312,12 @@ export const lspTheme = EditorView.theme({
|
||||
color: "var(--fg-bulb, var(--warning))",
|
||||
cursor: "pointer",
|
||||
},
|
||||
// Above the sticky gutters (z-index 200).
|
||||
".cm-lsp-bulb-gutter": { zIndex: "201", backgroundColor: "var(--bg-editor-gutter, var(--bg-editor))" },
|
||||
".cm-lsp-bulb .cm-openidea-icon": { display: "inline-flex", borderRadius: "var(--radius-small)", padding: "1px" },
|
||||
".cm-lsp-bulb:hover .cm-openidea-icon": { backgroundColor: "var(--bg-toolbar-hover)" },
|
||||
".cm-lsp-bulb-fix": { color: "var(--danger)" },
|
||||
".cm-lsp-bulb .cm-lsp-bulb-fix-icon, .cm-lsp-bulb-fix .cm-openidea-icon:first-child": { display: "none" },
|
||||
".cm-lsp-bulb-fix .cm-lsp-bulb-fix-icon": { display: "inline-flex" },
|
||||
|
||||
// --- inlay hints (rounded grey chips) -------------------------------------
|
||||
".cm-lsp-inlay": {
|
||||
|
||||
@@ -14,7 +14,7 @@ import { RangeSet, StateEffect, StateField, type Extension, type Text } from "@c
|
||||
import { EditorView, GutterMarker, ViewPlugin, gutter, type ViewUpdate } from "@codemirror/view";
|
||||
import type { EditorDocContext } from "@/editor/api";
|
||||
import { docOf, debounce, isUnavailable, lspRequest } from "@/editor/lsp/lsp";
|
||||
import { serverBackoff } from "@/editor/lsp/availability";
|
||||
import { onServerSettled, serverBackoff } from "@/editor/lsp/availability";
|
||||
import { readManyFileLines, withDocument } from "@/editor/lsp/files";
|
||||
import { locationItems, navigateTo, pinToFind } from "@/editor/lsp/navigation";
|
||||
import { openPopup } from "@/editor/lsp/popupStore";
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
commands,
|
||||
isCancelled,
|
||||
nextRequestId,
|
||||
onIdeEvent,
|
||||
type HierarchyItem,
|
||||
type Location,
|
||||
type OutlineItem,
|
||||
@@ -241,8 +240,7 @@ class MarkersPlugin {
|
||||
if (s.settings !== prev.settings) this.refresh();
|
||||
});
|
||||
// Answers given while a server was starting or indexing may be incomplete.
|
||||
this.offServers = onIdeEvent("languageServer", (e) => {
|
||||
if (e.status !== "ready") return;
|
||||
this.offServers = onServerSettled(() => {
|
||||
this.invalidate();
|
||||
this.refresh();
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import { getAction, executeAction } from "@/platform/actions";
|
||||
import { BarButton, Button, IconButton, Popup, T } from "@/ui";
|
||||
import { downloadJdkActions } from "@/platform/jdk";
|
||||
import { relativePath, useProject } from "@/platform/project";
|
||||
import { STATUS_LABEL, restartServer, serversSummary, useServers } from "./servers";
|
||||
import { STATUS_LABEL, restartServer, servedByOther, serversSummary, useServers } from "./servers";
|
||||
import styles from "./ServerStatus.module.css";
|
||||
import { tx } from "@/platform/i18n";
|
||||
|
||||
@@ -49,7 +49,10 @@ export function ServerStatus() {
|
||||
return (
|
||||
<div key={s.id} className={styles.server}>
|
||||
<div className={styles.line}>
|
||||
<span className={clsx(styles.dot, styles[toneOf(s.status)])} aria-hidden />
|
||||
<span
|
||||
className={clsx(styles.dot, styles[s.status === "missing" && servedByOther(s, servers) ? "idle" : toneOf(s.status)])}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className={styles.name}>{s.name}</span>
|
||||
<span className={styles.status}>{STATUS_LABEL[s.status]}</span>
|
||||
<span className={styles.spacer} />
|
||||
|
||||
@@ -20,7 +20,7 @@ vi.mock("@/platform/ipc", async (importOriginal) => {
|
||||
|
||||
const { emitLocalIdeEvent } = await import("@/platform/ipc");
|
||||
const { useProject } = await import("@/platform/project");
|
||||
const { installServerEvents, useServers } = await import("./servers");
|
||||
const { installServerEvents, serversSummary, useServers } = await import("./servers");
|
||||
|
||||
describe("server status", () => {
|
||||
let dispose = () => {};
|
||||
@@ -40,3 +40,18 @@ describe("server status", () => {
|
||||
expect(useServers.getState().servers.get(id)).toMatchObject({ status: "missing", message: "install jdtls" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("serversSummary", () => {
|
||||
const entry = (id: number, name: string, status: ServerInfo["status"]) => ({
|
||||
id: id as ServerId,
|
||||
name,
|
||||
status,
|
||||
message: null,
|
||||
languages: ["typescript"],
|
||||
root: "/p",
|
||||
});
|
||||
it("doesn't flag a missing server whose languages another one serves", () => {
|
||||
expect(serversSummary([entry(1, "vtsls", "missing"), entry(2, "typescript-language-server", "ready")])?.tone).toBe("ok");
|
||||
expect(serversSummary([entry(1, "vtsls", "missing")])?.tone).toBe("error");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -129,10 +129,23 @@ export const STATUS_LABEL: Record<ServerStatus, string> = {
|
||||
misconfigured: "Not started",
|
||||
};
|
||||
|
||||
/**
|
||||
* A missing server whose languages another installed one serves (vtsls missing,
|
||||
* typescript-language-server running as its fallback) is not a problem.
|
||||
*/
|
||||
export function servedByOther(server: ServerEntry, servers: readonly ServerEntry[]): boolean {
|
||||
return (
|
||||
server.languages.length > 0 &&
|
||||
server.languages.every((l) => servers.some((o) => o !== server && o.status !== "missing" && o.languages.includes(l)))
|
||||
);
|
||||
}
|
||||
|
||||
/** One-line summary for the status bar. */
|
||||
export function serversSummary(servers: readonly ServerEntry[]): { text: string; tone: "ok" | "busy" | "error" | "idle" } | null {
|
||||
if (!servers.length) return null;
|
||||
const bad = servers.filter((s) => s.status === "crashed" || s.status === "missing" || s.status === "misconfigured");
|
||||
const bad = servers.filter(
|
||||
(s) => s.status === "crashed" || s.status === "misconfigured" || (s.status === "missing" && !servedByOther(s, servers)),
|
||||
);
|
||||
const busy = servers.filter((s) => s.status === "starting" || s.status === "busy");
|
||||
const tone = bad.length ? "error" : busy.length ? "busy" : servers.some((s) => s.status === "ready") ? "ok" : "idle";
|
||||
const names = [...new Set(servers.map((s) => s.name))];
|
||||
|
||||
@@ -28,14 +28,17 @@ import { useEffectiveRoots } from "@/platform/projectRoots";
|
||||
import { macStroke, strokeOf } from "@/platform/keymap";
|
||||
import { prepareMenuGroups, useFileDecoration } from "@/workbench/contributions";
|
||||
import {
|
||||
FILES_DRAG,
|
||||
HighlightedText,
|
||||
Icon,
|
||||
Popup,
|
||||
dragSession,
|
||||
Tree,
|
||||
acquirePopup,
|
||||
fileIconName,
|
||||
folderIconName,
|
||||
showContextMenu,
|
||||
type DragSession,
|
||||
type TreeHandle,
|
||||
type TreeRowState,
|
||||
} from "@/ui";
|
||||
@@ -443,6 +446,8 @@ export function ProjectView() {
|
||||
startY = e.clientY;
|
||||
let active = false;
|
||||
let paths: string[] = [];
|
||||
// Files (not folders) can also be dropped on an editor to open them there.
|
||||
let files: DragSession | null = null;
|
||||
const move = (ev: PointerEvent) => {
|
||||
if (!active) {
|
||||
if (Math.hypot(ev.clientX - startX, ev.clientY - startY) < DRAG_THRESHOLD) return;
|
||||
@@ -454,10 +459,14 @@ export function ProjectView() {
|
||||
const nodes = keys.map((k) => m.byKey.get(k)).filter((n): n is ProjectNode => !!n && !n.root && !n.placeholder);
|
||||
paths = nodes.map((n) => n.path);
|
||||
if (!paths.length) return cleanup();
|
||||
const filePaths = nodes.filter((n) => !n.dir).map((n) => n.path);
|
||||
if (filePaths.length) files = dragSession({ type: FILES_DRAG, payload: filePaths.join("\n") });
|
||||
}
|
||||
const target = folderAt(containerRef.current, ev.clientX, ev.clientY);
|
||||
const valid = target && !paths.some((p) => target.path === p || isInside(target.path, p));
|
||||
setDropTarget(valid ? target.key : null);
|
||||
if (target) files?.cancel();
|
||||
else files?.move(ev.clientX, ev.clientY);
|
||||
setDrag({
|
||||
paths,
|
||||
label: paths.length === 1 ? nameOf(paths[0]!) : tx`${paths.length} items`,
|
||||
@@ -469,8 +478,11 @@ export function ProjectView() {
|
||||
const up = (ev: PointerEvent) => {
|
||||
const target = active ? folderAt(containerRef.current, ev.clientX, ev.clientY) : null;
|
||||
const valid = target && !paths.some((p) => target.path === p || isInside(target.path, p));
|
||||
const session = files;
|
||||
files = null;
|
||||
cleanup();
|
||||
if (active && valid) void dropPaths(paths, target.path, ev.altKey);
|
||||
else if (active && !target) session?.drop(ev.clientX, ev.clientY);
|
||||
};
|
||||
const key = (ev: KeyboardEvent) => {
|
||||
if (ev.key === "Escape" && active) {
|
||||
@@ -483,6 +495,7 @@ export function ProjectView() {
|
||||
window.removeEventListener("pointermove", move);
|
||||
window.removeEventListener("pointerup", up);
|
||||
window.removeEventListener("keydown", key, true);
|
||||
files?.cancel();
|
||||
setDropTarget(null);
|
||||
setDrag(null);
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { executeAction, type Action } from "@/platform/actions";
|
||||
import { call, commands, isTauri } from "@/platform/ipc";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { setDragRequiresAlt } from "@/ui";
|
||||
import { ZOOM_LIMITS } from "./model/behaviorSchema";
|
||||
import { DEFAULT_SETTINGS, validateSettings, type ValidSettings } from "./model/schema";
|
||||
|
||||
@@ -126,16 +127,9 @@ export function zoomActions(): Action[] {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Drag-and-drop with Alt pressed only (tool window buttons and editor tabs)
|
||||
|
||||
const DRAG_SOURCES = "[data-stripe-button], [role='tab']";
|
||||
|
||||
export function installAltDragGuard(enabled: () => boolean): () => void {
|
||||
const onDragStart = (e: DragEvent) => {
|
||||
if (!enabled() || e.altKey) return;
|
||||
const el = e.target instanceof Element ? e.target : null;
|
||||
if (el?.closest(DRAG_SOURCES)) e.preventDefault();
|
||||
};
|
||||
document.addEventListener("dragstart", onDragStart, true);
|
||||
return () => document.removeEventListener("dragstart", onDragStart, true);
|
||||
setDragRequiresAlt(enabled);
|
||||
return () => setDragRequiresAlt(() => false);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Commit tool window: change list with inclusion checkboxes, message, stashes. */
|
||||
import { useWorkflowSettings } from "@/platform/workflowSettings";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type MouseEvent } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent, type PointerEvent } from "react";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import clsx from "clsx";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
@@ -18,7 +18,9 @@ import {
|
||||
Tabs,
|
||||
Tree,
|
||||
fileIconName,
|
||||
registerDropTarget,
|
||||
showContextMenu,
|
||||
startPointerDrag,
|
||||
type MenuItem,
|
||||
type TreeHandle,
|
||||
T,
|
||||
@@ -424,35 +426,38 @@ function ChangesPanel() {
|
||||
{n.label}
|
||||
</span>
|
||||
);
|
||||
// Pointer-driven (HTML5 drag and drop never reaches the Tauri webview):
|
||||
// files and folders drag; any row takes them into its changelist.
|
||||
const list = n.changelist;
|
||||
const dnd =
|
||||
n.changelist !== undefined
|
||||
? {
|
||||
draggable: n.type !== "group",
|
||||
onDragStart: (e: DragEvent<HTMLSpanElement>) => {
|
||||
const paths =
|
||||
selected.has(n.key) && selected.size > 1
|
||||
? filesOf(selectedNodes()).map((f) => f.path)
|
||||
: n.files.map((f) => f.path);
|
||||
e.dataTransfer.setData(CHANGES_MIME, paths.map(stripSlash).join("\n"));
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
},
|
||||
onDragOver: (e: DragEvent<HTMLSpanElement>) => {
|
||||
if (![...e.dataTransfer.types].includes(CHANGES_MIME)) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
if (dropTarget !== n.changelist) setDropTarget(n.changelist ?? null);
|
||||
},
|
||||
onDragLeave: () => setDropTarget(null),
|
||||
onDrop: (e: DragEvent<HTMLSpanElement>) => {
|
||||
const data = e.dataTransfer.getData(CHANGES_MIME);
|
||||
setDropTarget(null);
|
||||
if (!data || !n.changelist) return;
|
||||
e.preventDefault();
|
||||
moveToChangelist(data.split("\n"), n.changelist);
|
||||
},
|
||||
onDragEnd: () => setDropTarget(null),
|
||||
}
|
||||
: {};
|
||||
list === undefined
|
||||
? {}
|
||||
: {
|
||||
ref: (el: HTMLSpanElement | null) =>
|
||||
el
|
||||
? registerDropTarget(el, {
|
||||
accepts: (type) => type === CHANGES_MIME,
|
||||
over: () => setDropTarget(list),
|
||||
leave: () => setDropTarget(null),
|
||||
drop: (data) => {
|
||||
setDropTarget(null);
|
||||
moveToChangelist(data.payload.split("\n"), list);
|
||||
},
|
||||
})
|
||||
: undefined,
|
||||
...(n.type !== "group" && {
|
||||
onPointerDown: (e: PointerEvent<HTMLSpanElement>) => {
|
||||
const paths =
|
||||
selected.has(n.key) && selected.size > 1
|
||||
? filesOf(selectedNodes()).map((f) => f.path)
|
||||
: n.files.map((f) => f.path);
|
||||
startPointerDrag(e, e.currentTarget, {
|
||||
type: CHANGES_MIME,
|
||||
payload: paths.map(stripSlash).join("\n"),
|
||||
});
|
||||
},
|
||||
}),
|
||||
};
|
||||
return (
|
||||
<span
|
||||
className={clsx(
|
||||
|
||||
@@ -57,6 +57,12 @@ function ensureListening() {
|
||||
});
|
||||
}
|
||||
|
||||
/** Resolves once backend events reach this window's handlers. */
|
||||
export async function ideEventsReady(): Promise<void> {
|
||||
ensureListening();
|
||||
await unlisten;
|
||||
}
|
||||
|
||||
/** Subscribes to one backend event type (or `"*"`); returns an unsubscribe function. */
|
||||
export function onIdeEvent<K extends IdeEvent["type"]>(type: K | "*", handler: (event: EventOf<K>) => void): () => void {
|
||||
ensureListening();
|
||||
|
||||
@@ -82,6 +82,7 @@ export const intellijMacos: Record<string, string[]> = {
|
||||
"code.quickDocumentation": ["F1", "Ctrl+J"],
|
||||
"code.quickDefinition": ["Alt+Space", "Meta+Y"],
|
||||
"code.showIntentions": ["Alt+Enter"],
|
||||
"code.applyQuickFix": ["Alt+Shift+Enter"],
|
||||
"code.reformat": ["Meta+Alt+L"],
|
||||
"code.reformatDialog": ["Meta+Alt+Shift+L"],
|
||||
"code.optimizeImports": ["Ctrl+Alt+O"],
|
||||
|
||||
@@ -83,6 +83,7 @@ export const intellijWindows: Record<string, string[]> = {
|
||||
"code.quickDocumentation": ["Ctrl+Q"],
|
||||
"code.quickDefinition": ["Ctrl+Shift+I"],
|
||||
"code.showIntentions": ["Alt+Enter"],
|
||||
"code.applyQuickFix": ["Alt+Shift+Enter"],
|
||||
"code.reformat": ["Ctrl+Alt+L"],
|
||||
"code.reformatDialog": ["Ctrl+Alt+Shift+L"],
|
||||
"code.optimizeImports": ["Ctrl+Alt+O"],
|
||||
|
||||
@@ -83,6 +83,7 @@ export const vscodeMacos: Record<string, string[]> = {
|
||||
"code.quickDocumentation": ["Meta+K Meta+I"],
|
||||
"code.quickDefinition": ["Alt+F12"],
|
||||
"code.showIntentions": ["Meta+."],
|
||||
"code.applyQuickFix": [],
|
||||
"code.generate": [],
|
||||
"code.reformat": ["Alt+Shift+F"],
|
||||
"code.reformatDialog": [],
|
||||
|
||||
@@ -3141,6 +3141,10 @@
|
||||
"The file changed; rename cancelled": "文件已更改,重命名已取消",
|
||||
"No suggestions": "无建议",
|
||||
"No intentions available": "没有可用的意图操作",
|
||||
"No quick fixes available": "没有可用的快速修复",
|
||||
"No abstract methods to implement": "没有需要实现的抽象方法",
|
||||
"Implement abstract methods": "实现抽象方法",
|
||||
"Apply Quick Fix": "应用快速修复",
|
||||
"The document changed; invoke the refactoring again": "文档已更改,请重新调用重构",
|
||||
"The document changed; the name was not applied": "文档已更改,名称未应用",
|
||||
"The file changed; Safe Delete cancelled": "文件已更改,安全删除已取消",
|
||||
|
||||
+39
-40
@@ -11,7 +11,6 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
type DragEvent,
|
||||
type KeyboardEvent,
|
||||
type MouseEvent,
|
||||
type ReactNode,
|
||||
@@ -21,6 +20,7 @@ import { Icon, type IconName } from "./icons";
|
||||
import { IconButton } from "./Button";
|
||||
import { Menu, type MenuItem } from "./Menu";
|
||||
import { tooltipAttrs } from "./Tooltip";
|
||||
import { registerDropTarget, startPointerDrag } from "./pointerDrag";
|
||||
import { T } from "./T";
|
||||
import styles from "./Tabs.module.css";
|
||||
import { tx } from "@/platform/i18n";
|
||||
@@ -148,37 +148,36 @@ export function Tabs({
|
||||
[register],
|
||||
);
|
||||
|
||||
const accepts = (e: DragEvent) => !!dnd && [...e.dataTransfer.types].includes(dnd.type);
|
||||
const indexAt = (e: DragEvent) =>
|
||||
dropIndex(
|
||||
vertical ? e.clientY : e.clientX,
|
||||
items.map((it) => {
|
||||
const r = tabRefs.current.get(it.id)?.parentElement?.getBoundingClientRect();
|
||||
return vertical ? { left: r?.top ?? 0, width: r?.height ?? 0 } : { left: r?.left ?? 0, width: r?.width ?? 0 };
|
||||
}),
|
||||
);
|
||||
const dropHandlers = dnd
|
||||
? {
|
||||
onDragOver: (e: DragEvent<HTMLDivElement>) => {
|
||||
if (!accepts(e)) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
const i = indexAt(e);
|
||||
if (i !== drop) setDrop(i);
|
||||
},
|
||||
onDragLeave: (e: DragEvent<HTMLDivElement>) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setDrop(null);
|
||||
},
|
||||
onDrop: (e: DragEvent<HTMLDivElement>) => {
|
||||
if (!accepts(e)) return;
|
||||
e.preventDefault();
|
||||
const payload = e.dataTransfer.getData(dnd.type);
|
||||
const i = indexAt(e);
|
||||
setDrop(null);
|
||||
if (payload) dnd.onDrop(payload, i);
|
||||
},
|
||||
}
|
||||
: {};
|
||||
const stripRef = useRef<HTMLDivElement>(null);
|
||||
// Re-read on every drag event: items and handlers change while the strip stays registered.
|
||||
const dropState = useRef({ items, dnd, vertical });
|
||||
useLayoutEffect(() => {
|
||||
dropState.current = { items, dnd, vertical };
|
||||
});
|
||||
const dndType = dnd?.type;
|
||||
useEffect(() => {
|
||||
const el = stripRef.current;
|
||||
if (!el || !dndType) return;
|
||||
const indexAt = (x: number, y: number) => {
|
||||
const { items, vertical } = dropState.current;
|
||||
return dropIndex(
|
||||
vertical ? y : x,
|
||||
items.map((it) => {
|
||||
const r = tabRefs.current.get(it.id)?.parentElement?.getBoundingClientRect();
|
||||
return vertical ? { left: r?.top ?? 0, width: r?.height ?? 0 } : { left: r?.left ?? 0, width: r?.width ?? 0 };
|
||||
}),
|
||||
);
|
||||
};
|
||||
return registerDropTarget(el, {
|
||||
accepts: (type) => type === dropState.current.dnd?.type,
|
||||
over: (_, x, y) => setDrop(indexAt(x, y)),
|
||||
leave: () => setDrop(null),
|
||||
drop: (data, x, y) => {
|
||||
setDrop(null);
|
||||
dropState.current.dnd?.onDrop(data.payload, indexAt(x, y));
|
||||
},
|
||||
});
|
||||
}, [dndType]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
@@ -249,7 +248,7 @@ export function Tabs({
|
||||
closePosition === "left" && styles.closeLeft,
|
||||
className,
|
||||
)}
|
||||
{...dropHandlers}
|
||||
ref={stripRef}
|
||||
>
|
||||
{vertical && trailing && <div className={styles.trailing}>{trailing}</div>}
|
||||
<div
|
||||
@@ -279,17 +278,17 @@ export function Tabs({
|
||||
item.tint && styles.tinted,
|
||||
)}
|
||||
style={item.tint ? ({ "--tab-tint": item.tint } as CSSProperties) : undefined}
|
||||
draggable={!!dnd}
|
||||
onDragStart={
|
||||
onPointerDown={
|
||||
dnd
|
||||
? (e) => {
|
||||
e.dataTransfer.setData(dnd.type, dnd.payload(item.id));
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
setDragging(item.id);
|
||||
if ((e.target as Element).closest("[data-tab-close]")) return;
|
||||
startPointerDrag(e, e.currentTarget, { type: dnd.type, payload: dnd.payload(item.id) }, {
|
||||
onStart: () => setDragging(item.id),
|
||||
onEnd: () => setDragging(null),
|
||||
});
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onDragEnd={dnd ? () => (setDragging(null), setDrop(null)) : undefined}
|
||||
onMouseDown={(e) => {
|
||||
if (e.button === 1 && closable) {
|
||||
e.preventDefault();
|
||||
@@ -324,7 +323,7 @@ export function Tabs({
|
||||
{item.modified && !closable && <span className={styles.modified} aria-label="Modified" />}
|
||||
</button>
|
||||
{closable && (
|
||||
<span className={styles.closeSlot}>
|
||||
<span className={styles.closeSlot} data-tab-close>
|
||||
{item.modified && <span className={styles.modified} aria-label="Modified" />}
|
||||
<IconButton
|
||||
icon="close"
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<!-- Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="5.70142" y="12" width="4.6" height="1" fill="#6C707E"/>
|
||||
<path d="M6 14H10C10 14.5523 9.55228 15 9 15H7C6.44772 15 6 14.5523 6 14Z" fill="#6C707E"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.8704 9.14748C12.0417 8.27221 12.8 6.87465 12.8 5.3C12.8 2.64903 10.6509 0.5 7.99995 0.5C5.34898 0.5 3.19995 2.64903 3.19995 5.3C3.19995 6.87464 3.95817 8.27218 5.12943 9.14746L5.49994 11H10.4999L10.8704 9.14748Z" fill="#E55765"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 670 B |
@@ -0,0 +1,6 @@
|
||||
<!-- Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="5.70142" y="12" width="4.6" height="1" fill="#CED0D6"/>
|
||||
<path d="M6 14H10C10 14.5523 9.55228 15 9 15H7C6.44772 15 6 14.5523 6 14Z" fill="#CED0D6"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.8704 9.14748C12.0417 8.27221 12.8 6.87465 12.8 5.3C12.8 2.64903 10.6509 0.5 7.99995 0.5C5.34898 0.5 3.19995 2.64903 3.19995 5.3C3.19995 6.87464 3.95817 8.27218 5.12943 9.14746L5.49994 11H10.4999L10.8704 9.14748Z" fill="#DB5C5C"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 670 B |
@@ -247,6 +247,7 @@ const ICONS = {
|
||||
list: "general/listFiles",
|
||||
keyboard: "general/keyboard",
|
||||
bulb: "codeInsight/intentionBulb",
|
||||
"bulb-fix": "codeInsight/quickfixBulb",
|
||||
package: "nodes/package",
|
||||
plugin: "nodes/plugin",
|
||||
"soft-wrap": "general/softWrap",
|
||||
|
||||
@@ -6,6 +6,7 @@ export * from "./Input";
|
||||
export * from "./Checkbox";
|
||||
export * from "./Select";
|
||||
export * from "./Tabs";
|
||||
export { FILES_DRAG, dragRequiresAlt, dragSession, registerDropTarget, setDragRequiresAlt, startPointerDrag, type DragData, type DragSession, type DropTarget } from "./pointerDrag";
|
||||
export * from "./Tree";
|
||||
export * from "./List";
|
||||
export * from "./Popup";
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
.ghost {
|
||||
position: fixed;
|
||||
z-index: var(--z-tooltip);
|
||||
margin: 0;
|
||||
opacity: 0.85;
|
||||
pointer-events: none;
|
||||
box-shadow: var(--shadow-tooltip);
|
||||
background: var(--bg-popup);
|
||||
}
|
||||
|
||||
.noTarget {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.dragging,
|
||||
.dragging * {
|
||||
cursor: grabbing !important;
|
||||
user-select: none !important;
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* Pointer-driven drag & drop between components. HTML5 drag and drop can't be
|
||||
* used: with the window's native file drop enabled, the webview never sees
|
||||
* dragover / drop for drags that start inside it. Sources start a drag with
|
||||
* `startPointerDrag` (or drive a `DragSession` themselves); targets register
|
||||
* an element with `registerDropTarget` and are told about drags over it.
|
||||
*/
|
||||
import styles from "./pointerDrag.module.css";
|
||||
|
||||
/** What is dragged: `type` is matched by targets (e.g. `application/x-openidea-editor-tab`). */
|
||||
export interface DragData {
|
||||
type: string;
|
||||
payload: string;
|
||||
}
|
||||
|
||||
/** Project files dragged from the Project view: `payload` is the paths joined by `\n`. */
|
||||
export const FILES_DRAG = "application/x-openidea-files";
|
||||
|
||||
export interface DropTarget {
|
||||
accepts(type: string): boolean;
|
||||
/** The pointer moved over the target. */
|
||||
over(data: DragData, x: number, y: number): void;
|
||||
/** The pointer left the target, or the drag ended elsewhere / was cancelled. */
|
||||
leave(): void;
|
||||
drop(data: DragData, x: number, y: number): void;
|
||||
}
|
||||
|
||||
const targets = new Map<Element, DropTarget>();
|
||||
|
||||
export function registerDropTarget(el: Element, target: DropTarget): () => void {
|
||||
targets.set(el, target);
|
||||
return () => {
|
||||
if (targets.get(el) === target) targets.delete(el);
|
||||
};
|
||||
}
|
||||
|
||||
/** The innermost registered target under (x, y) accepting `type`. */
|
||||
function targetAt(type: string, x: number, y: number): DropTarget | null {
|
||||
for (let el = document.elementFromPoint(x, y); el; el = el.parentElement) {
|
||||
const t = targets.get(el);
|
||||
if (t?.accepts(type)) return t;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface DragSession {
|
||||
/** Updates hover feedback; true when a target under (x, y) accepts the drag. */
|
||||
move(x: number, y: number): boolean;
|
||||
/** Drops on the target under (x, y); false when there is none. */
|
||||
drop(x: number, y: number): boolean;
|
||||
cancel(): void;
|
||||
}
|
||||
|
||||
export function dragSession(data: DragData): DragSession {
|
||||
let current: DropTarget | null = null;
|
||||
const enter = (t: DropTarget | null) => {
|
||||
if (t === current) return;
|
||||
current?.leave();
|
||||
current = t;
|
||||
};
|
||||
return {
|
||||
move(x, y) {
|
||||
enter(targetAt(data.type, x, y));
|
||||
current?.over(data, x, y);
|
||||
return !!current;
|
||||
},
|
||||
drop(x, y) {
|
||||
const t = targetAt(data.type, x, y);
|
||||
enter(null);
|
||||
t?.drop(data, x, y);
|
||||
return !!t;
|
||||
},
|
||||
cancel: () => enter(null),
|
||||
};
|
||||
}
|
||||
|
||||
/** Movement before a press becomes a drag, px. */
|
||||
const DRAG_THRESHOLD = 4;
|
||||
|
||||
/** Settings › Appearance › "Drag-and-drop with Alt pressed only" for tabs and stripe buttons. */
|
||||
let requiresAlt = () => false;
|
||||
export function setDragRequiresAlt(fn: () => boolean) {
|
||||
requiresAlt = fn;
|
||||
}
|
||||
export const dragRequiresAlt = () => requiresAlt();
|
||||
|
||||
/**
|
||||
* Starts a drag from a primary-button press on `source` once the pointer moved
|
||||
* past the threshold; a ghost copy of `source` follows the pointer. Escape
|
||||
* cancels. `onEnd` gets whether the drag happened (to swallow the click).
|
||||
*/
|
||||
export function startPointerDrag(
|
||||
e: { button: number; clientX: number; clientY: number; altKey: boolean },
|
||||
source: HTMLElement,
|
||||
data: DragData,
|
||||
opts: { onStart?: () => void; onEnd?: (dragged: boolean) => void } = {},
|
||||
) {
|
||||
if (e.button !== 0 || (requiresAlt() && !e.altKey)) return;
|
||||
const start = { x: e.clientX, y: e.clientY };
|
||||
let ghost: HTMLElement | null = null;
|
||||
const session = dragSession(data);
|
||||
const onMove = (ev: PointerEvent) => {
|
||||
if (!ghost) {
|
||||
if (Math.hypot(ev.clientX - start.x, ev.clientY - start.y) < DRAG_THRESHOLD) return;
|
||||
const r = source.getBoundingClientRect();
|
||||
ghost = source.cloneNode(true) as HTMLElement;
|
||||
ghost.removeAttribute("id");
|
||||
ghost.querySelectorAll("[id]").forEach((n) => n.removeAttribute("id"));
|
||||
ghost.classList.add(styles.ghost!);
|
||||
ghost.setAttribute("aria-hidden", "true");
|
||||
Object.assign(ghost.style, {
|
||||
left: `${r.left}px`,
|
||||
top: `${r.top}px`,
|
||||
width: `${r.width}px`,
|
||||
height: `${r.height}px`,
|
||||
});
|
||||
document.body.appendChild(ghost);
|
||||
document.documentElement.classList.add(styles.dragging!);
|
||||
opts.onStart?.();
|
||||
}
|
||||
ghost.style.transform = `translate(${ev.clientX - start.x}px, ${ev.clientY - start.y}px)`;
|
||||
ghost.classList.toggle(styles.noTarget!, !session.move(ev.clientX, ev.clientY));
|
||||
};
|
||||
const onUp = (ev: PointerEvent) => {
|
||||
const dragged = !!ghost;
|
||||
cleanup();
|
||||
if (dragged) session.drop(ev.clientX, ev.clientY);
|
||||
opts.onEnd?.(dragged);
|
||||
};
|
||||
const onKey = (ev: KeyboardEvent) => {
|
||||
if (ev.key !== "Escape" || !ghost) return;
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
session.cancel();
|
||||
cleanup();
|
||||
opts.onEnd?.(true);
|
||||
};
|
||||
const cleanup = () => {
|
||||
window.removeEventListener("pointermove", onMove);
|
||||
window.removeEventListener("pointerup", onUp);
|
||||
window.removeEventListener("pointercancel", onCancel);
|
||||
window.removeEventListener("keydown", onKey, true);
|
||||
ghost?.remove();
|
||||
document.documentElement.classList.remove(styles.dragging!);
|
||||
};
|
||||
const onCancel = () => {
|
||||
session.cancel();
|
||||
const dragged = !!ghost;
|
||||
cleanup();
|
||||
opts.onEnd?.(dragged);
|
||||
};
|
||||
window.addEventListener("pointermove", onMove);
|
||||
window.addEventListener("pointerup", onUp);
|
||||
window.addEventListener("pointercancel", onCancel);
|
||||
window.addEventListener("keydown", onKey, true);
|
||||
}
|
||||
@@ -379,6 +379,8 @@ export interface OpenOptions {
|
||||
preview?: boolean;
|
||||
/** Open to the side (split right). */
|
||||
side?: boolean;
|
||||
/** Open in this editor pane, in a tab of its own even when another pane shows the file. */
|
||||
pane?: string;
|
||||
focus?: boolean;
|
||||
/** Open in the text editor even when a file editor provider claims the file (SVG as text). */
|
||||
asText?: boolean;
|
||||
|
||||
@@ -15,7 +15,7 @@ import { create } from "zustand";
|
||||
import { call, commands } from "@/platform/ipc";
|
||||
import { shortcutLabel } from "@/platform/keymap";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { Icon, IconButton, Menu, showContextMenu, tooltipAttrs, type MenuItem, T } from "@/ui";
|
||||
import { dragRequiresAlt, Icon, IconButton, Menu, showContextMenu, tooltipAttrs, type MenuItem, T } from "@/ui";
|
||||
import { FOCUSABLE } from "@/ui/popupState";
|
||||
import { TabStripRegistry } from "@/ui/Tabs";
|
||||
import { useContributions, type ToolWindowAnchor, type ToolWindowContribution } from "../contributions";
|
||||
@@ -303,7 +303,7 @@ function stripeDropAt(x: number, y: number): StripeDrop | null {
|
||||
}
|
||||
|
||||
function startStripeDrag(e: PointerEvent<HTMLButtonElement>, id: string) {
|
||||
if (e.button !== 0) return;
|
||||
if (e.button !== 0 || (dragRequiresAlt() && !e.altKey)) return;
|
||||
const button = e.currentTarget;
|
||||
const start = { x: e.clientX, y: e.clientY };
|
||||
let ghost: HTMLElement | null = null;
|
||||
|
||||
Reference in New Issue
Block a user